Skip to Content

cluster

Detect code communities — candidate modules and subsystems — with the Leiden algorithm, and attach the community index onto each node as metadata.community. Where enrich annotates nodes with runtime weight, cluster annotates them with structure: which symbols form a cohesive group under the call / type / reference graph.

Leiden (not Louvain) because its refinement phase guarantees every community it returns is internally connected — a “module” that is secretly two disconnected groups is worse than useless for reasoning about boundaries. It runs the CPM (Constant Potts Model) quality function from networkanalysis-ts, the TypeScript port of the library by the authors of the Leiden paper.

Source: src/commands/cluster_command.ts · detector: CommunityDetector in src/cluster/community_detector.ts · orchestrator: GraphClusterer in src/cluster/graph_clusterer.ts

Synopsis

npx codespine cluster [detect] [options] # detect communities (the default action) npx codespine cluster communities [options] # list communities + members, ready to name npx codespine cluster rename --labels <file> # apply human-readable community labels

Arguments

None. cluster clusters the whole graph; its communities and rename subcommands (below) take no positional arguments either.

Options

OptionDefaultDescription
-o, --output-folder <dir>./.codespineOutput folder; the Kùzu database is read from <dir>/graph.kuzu.
--resolution <n>0.1CPM resolution — a threshold on a community’s average internal edge weight. Higher → more, smaller communities; lower → fewer, larger ones.
--jsonfalseEmit the clustering report as JSON instead of the formatted summary.

What it does

  1. Reads the weighted edges. Every edge whose kind carries a weight (see Edge weighting) is read with its call-site metadata.count; the edge’s weight is the kind’s coefficient × count.
  2. Projects to an undirected graph. Directed edges are symmetrized — both directions of a pair sum onto one undirected edge, so a mutual call counts once with the combined weight. Self-loops are dropped.
  3. Runs Leiden (CPM). Builds a Network, runs the Leiden algorithm over several random starts, and keeps the partition with the best CPM quality. Uniform node weights make the resolution a portable density threshold, independent of node degree.
  4. Writes metadata.community — an integer community index — and metadata.communityLabel — a deterministic structural label derived from the members (their shared directory and most-coupled symbol, e.g. utils · citation) — onto each clustered node, merging with existing metadata. Only those two keys change, so re-running on an unchanged graph is idempotent (parallel to how enrich writes metadata.runtime). That structural label is a baseline; the naming flow described below replaces it with a responsibility-based name.
  5. Records a clustering manifest at the graph level (a GraphMeta row): the algorithm, resolution, community count, and CPM quality of the chosen partition.

Edge weighting

The signal each edge kind contributes is a tunable coefficient (in src/cluster/cluster_weights.ts); the effective weight of an edge is coefficient × metadata.count:

Edge kindsWeightWhy
CALLS3behavioral coupling — the strongest module signal
CALLS_RUNTIME4observed runtime calls (enrich), weighted by normalized samples
INSTANTIATES, EXTENDS, IMPLEMENTS2construction + heritage
OVERRIDES, WRITES1.5overrides and mutation
READS, USES_TYPE, RETURNS, PARAM_TYPE1value + type cohesion
CONTAINS0.5weak same-file pull

IMPORTS / EXPORTS are excluded (module wiring, not coupling), as are the system-level kinds (their targets are synthesized nodes). Because the strongest signals are the semantic edges, cluster wants a --semantic extraction — on a structural-only graph the only weighted edge is CONTAINS, so communities collapse to files. CALLS_RUNTIME contributes only after enrich: its sample weight is normalized to the hottest runtime edge so it stays on-scale with the static coefficients, making cluster true static + runtime fusion on an enriched graph.

Resolution

CPM resolution is a threshold on a community’s average internal edge weight: a group is kept as a community only when its members are coupled above --resolution. It is the scale knob — sweep it:

  • lower (e.g. 0.05) → fewer, larger modules (subsystems);
  • higher (e.g. 0.51) → more, tighter clusters (down to function groups).

The default 0.1 is tuned for module-scale grouping. The right value tracks the edge-weight magnitudes above, so re-tune if you change the coefficients.

Output

Formatted (default):

✓ assigned 40 node(s) to 13 communities resolution 0.1, CPM quality 0.6729 largest communities: 16, 6, 4, 3, 2, 2, 1, 1

JSON (--json) — a ClusterReport: nodesAssigned, communityCount, quality, resolution, sizes (member count per community, descending), and labels (each community’s label, aligned with sizes).

Naming communities with an agent (no API key)

Leiden finds the communities; naming them well is a language task. The structural communityLabel above names a community’s location (its directory and hub symbol). To name its responsibility instead — “Citation rendering” rather than utils · citationcluster exposes a two-step handshake that an agent such as Claude Code  drives in-session. There is no model API call and no key: the CLI emits each community’s members, the agent picks the names, and the CLI writes them back. The /codespine-name-communities command scripts the whole loop.

cluster communities

A read-only dump of every detected community and its members, largest first, for the agent to read and name:

npx codespine cluster communities --json
{ "communityCount": 12, "communities": [ { "index": 0, "currentLabel": "utils · citation", "size": 5, "members": [ { "name": "citation", "kind": "Method", "filePath": "report/text_report.ts" } // … ] } // … ] }

It reads the metadata.community written by detection, so run cluster first; on an unclustered graph it reports communityCount: 0.

cluster rename

Applies the agent’s names. --labels points at a JSON object mapping each community index (as a string) to its label:

echo '{ "0": "Citation rendering", "2": "Legacy string helpers" }' > labels.json npx codespine cluster rename --labels labels.json --json

It writes the labels onto metadata.communityLabel for every member and updates the clustering manifest, so the webview legend and node queries pick them up with no further change. Indexes not in the file are ignored (and reported under unknownIndexes), and a label equal to the current one is skipped — so a re-run is safe. Because it keys off the persisted community index, run it without re-detecting in between; a later cluster resets labels to the structural baseline (and may renumber communities).

Inspecting the communities

No query change is needed — metadata.community rides the JSON metadata column and is returned by every node query:

npx codespine find titleCase --json # → [ { "id": "...", "metadata": { "community": 2, ... } } ] npx codespine neighbors '<id>' --json # the community of each neighbour

The webview visualisation can colour nodes by community — its Colour by → community mode reads this metadata.community and draws a community legend, so a clustered graph shows its module decomposition at a glance.

Try it on a sample project:

npm run project01:rebuild # build the graph (once) npm run project01:cluster # detect communities and attach metadata.community

Notes and caveats

  • Leiden over Louvain. The refinement phase guarantees every community is internally connected; Louvain can leave a community split into disconnected pieces. On small or sparse graphs the two agree — the difference shows on large, hub-heavy graphs.
  • Direction is discarded. CALLS is directed; the clustering is undirected, so both directions sum onto one edge. Flow-direction-sensitive analysis is a different tool (e.g. Infomap).
  • Resolution needs tuning. There is no universal value — --resolution is a density threshold in the same units as the edge weights. Sweep it for the scale of module you want.
  • Stochastic. The algorithm uses random starts and keeps the best CPM quality; the partition is stable in practice but not byte-identical across runs unless seeded.
  • It writes to the database. Unlike the read-only query commands, cluster mutates metadata.community. Re-run it after a fresh load, since a reload rewrites nodes.

See also

  • enrich — the other annotation pass; attaches metadata.runtime, the runtime counterpart to this structural pass.
  • extract — run with --semantic so the CALLS / type edges cluster weights exist.
  • webview — serve the graph; its Colour by → community mode visualises this clustering.
  • /codespine-name-communities — let an agent replace the structural labels with responsibility-based names, no API key.
Last updated on