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 labelsArguments
None. cluster clusters the whole graph; its communities and rename
subcommands (below) take no positional arguments either.
Options
| Option | Default | Description |
|---|---|---|
-o, --output-folder <dir> | ./.codespine | Output folder; the Kùzu database is read from <dir>/graph.kuzu. |
--resolution <n> | 0.1 | CPM resolution — a threshold on a community’s average internal edge weight. Higher → more, smaller communities; lower → fewer, larger ones. |
--json | false | Emit the clustering report as JSON instead of the formatted summary. |
What it does
- 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. - 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.
- 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. - Writes
metadata.community— an integer community index — andmetadata.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 howenrichwritesmetadata.runtime). That structural label is a baseline; the naming flow described below replaces it with a responsibility-based name. - Records a clustering manifest at the graph level (a
GraphMetarow): 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 kinds | Weight | Why |
|---|---|---|
CALLS | 3 | behavioral coupling — the strongest module signal |
CALLS_RUNTIME | 4 | observed runtime calls (enrich), weighted by normalized samples |
INSTANTIATES, EXTENDS, IMPLEMENTS | 2 | construction + heritage |
OVERRIDES, WRITES | 1.5 | overrides and mutation |
READS, USES_TYPE, RETURNS, PARAM_TYPE | 1 | value + type cohesion |
CONTAINS | 0.5 | weak 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.5–1) → 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, 1JSON (--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 · citation — cluster 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 --jsonIt 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 neighbourThe 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.communityNotes 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.
CALLSis 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 —
--resolutionis 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,
clustermutatesmetadata.community. Re-run it after a freshload, since a reload rewrites nodes.
See also
enrich— the other annotation pass; attachesmetadata.runtime, the runtime counterpart to this structural pass.extract— run with--semanticso theCALLS/ type edgesclusterweights 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.