Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
5b409da
docs: add true Leiden refinement phase to backlog (ID 103)
carlos-alm Mar 20, 2026
87957ee
fix: mark backlog item #103 as breaking, add deterministic seed note …
carlos-alm Mar 21, 2026
53cc638
feat: implement true Leiden probabilistic refinement (Algorithm 3)
carlos-alm Mar 21, 2026
1e79ec5
merge: resolve BACKLOG.md conflict — keep DONE status with remote des…
carlos-alm Mar 21, 2026
b89b764
docs: remove backlog #103 — ships in this PR, not a breaking change
carlos-alm Mar 21, 2026
6776509
fix: align Leiden refinement with Algorithm 3 (Traag et al. 2019)
carlos-alm Mar 21, 2026
57d85a6
test: add Algorithm 3 conformance tests for Leiden refinement
carlos-alm Mar 21, 2026
c38b7f1
feat: post-refinement connectivity split and fix default theta
carlos-alm Mar 21, 2026
721e949
docs: replace language support prose with checkmark compatibility matrix
carlos-alm Mar 21, 2026
d6644e3
docs: use ✓ instead of ✅ in language compatibility matrix
carlos-alm Mar 21, 2026
eeecd19
docs: restructure roadmap phases and expand language support to 34
carlos-alm Mar 21, 2026
3d6b157
fix: address Greptile review feedback on Leiden refinement
carlos-alm Mar 21, 2026
ccbdcb0
fix: address Greptile review feedback on Leiden refinement
carlos-alm Mar 21, 2026
e4b1dfa
Merge branch 'feat/leiden-refinement-103' of https://github.com/optav…
carlos-alm Mar 21, 2026
f4698ec
fix: update config test to include refinementTheta default
carlos-alm Mar 21, 2026
ba81953
ci: trigger CI rerun
carlos-alm Mar 21, 2026
08051d5
fix: retrigger CI after test fix
carlos-alm Mar 21, 2026
696ca2b
fix: resolve merge conflicts with main
carlos-alm Mar 21, 2026
e607ef7
style: format community defaults test assertion
carlos-alm Mar 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 17 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -471,20 +471,23 @@ codegraph registry remove <name> # Unregister

## 🌐 Language Support

| Language | Extensions | Symbols Extracted | Type Inference | Parity |
|---|---|---|:---:|:---:|
| ![JavaScript](https://img.shields.io/badge/-JavaScript-F7DF1E?style=flat-square&logo=javascript&logoColor=black) | `.js`, `.jsx`, `.mjs`, `.cjs` | functions, classes, methods, imports, exports, call sites, constants, dataflow | ✅ | ✅ |
| ![TypeScript](https://img.shields.io/badge/-TypeScript-3178C6?style=flat-square&logo=typescript&logoColor=white) | `.ts`, `.tsx` | functions, classes, interfaces, type aliases, methods, imports, exports, call sites, dataflow | ✅ | ✅ |
| ![Python](https://img.shields.io/badge/-Python-3776AB?style=flat-square&logo=python&logoColor=white) | `.py`, `.pyi` | functions, classes, methods, imports, decorators, constants, call sites, dataflow | ✅ | ✅ |
| ![Go](https://img.shields.io/badge/-Go-00ADD8?style=flat-square&logo=go&logoColor=white) | `.go` | functions, methods, structs, interfaces, constants, imports, call sites, dataflow | ✅ | ✅ |
| ![Rust](https://img.shields.io/badge/-Rust-000000?style=flat-square&logo=rust&logoColor=white) | `.rs` | functions, methods, structs, enums, traits, constants, `use` imports, call sites, dataflow | ✅ | ✅ |
| ![Java](https://img.shields.io/badge/-Java-ED8B00?style=flat-square&logo=openjdk&logoColor=white) | `.java` | classes, methods, constructors, interfaces, enums, imports, call sites, dataflow | ✅ | ✅ |
| ![C#](https://img.shields.io/badge/-C%23-512BD4?style=flat-square&logo=dotnet&logoColor=white) | `.cs` | classes, structs, records, interfaces, enums, methods, constructors, properties, using directives, call sites, dataflow | ✅ | ✅ |
| ![PHP](https://img.shields.io/badge/-PHP-777BB4?style=flat-square&logo=php&logoColor=white) | `.php`, `.phtml` | functions, classes, interfaces, traits, enums, methods, namespace use, call sites, dataflow | ✅ | ✅ |
| ![Ruby](https://img.shields.io/badge/-Ruby-CC342D?style=flat-square&logo=ruby&logoColor=white) | `.rb`, `.rake`, `.gemspec` | classes, modules, methods, singleton methods, require/require_relative, include/extend, dataflow | N/A⁴ | ✅ |
| ![Terraform](https://img.shields.io/badge/-Terraform-844FBA?style=flat-square&logo=terraform&logoColor=white) | `.tf`, `.hcl` | resource, data, variable, module, output blocks | N/A⁴ | ✅ |

> **Type Inference** extracts a per-file type map from annotations (`const x: Router`, `MyType x`, `x: MyType`) and `new` expressions, enabling the edge resolver to connect `x.method()` → `Type.method()`. **Parity** = WASM and native Rust engines produce identical output. ⁴ Ruby and HCL are dynamically typed / declarative — type inference does not apply.
| Language | Extensions | Imports | Exports | Call Sites | Heritage¹ | Type Inference² | Dataflow |
|---|---|:---:|:---:|:---:|:---:|:---:|:---:|
| ![JavaScript](https://img.shields.io/badge/-JavaScript-F7DF1E?style=flat-square&logo=javascript&logoColor=black) | `.js`, `.jsx`, `.mjs`, `.cjs` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| ![TypeScript](https://img.shields.io/badge/-TypeScript-3178C6?style=flat-square&logo=typescript&logoColor=white) | `.ts`, `.tsx` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| ![Python](https://img.shields.io/badge/-Python-3776AB?style=flat-square&logo=python&logoColor=white) | `.py`, `.pyi` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| ![Go](https://img.shields.io/badge/-Go-00ADD8?style=flat-square&logo=go&logoColor=white) | `.go` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| ![Rust](https://img.shields.io/badge/-Rust-000000?style=flat-square&logo=rust&logoColor=white) | `.rs` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| ![Java](https://img.shields.io/badge/-Java-ED8B00?style=flat-square&logo=openjdk&logoColor=white) | `.java` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| ![C#](https://img.shields.io/badge/-C%23-512BD4?style=flat-square&logo=dotnet&logoColor=white) | `.cs` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| ![PHP](https://img.shields.io/badge/-PHP-777BB4?style=flat-square&logo=php&logoColor=white) | `.php`, `.phtml` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| ![Ruby](https://img.shields.io/badge/-Ruby-CC342D?style=flat-square&logo=ruby&logoColor=white) | `.rb`, `.rake`, `.gemspec` | ✓ | ✓ | ✓ | ✓ | —³ | ✓ |
| ![Terraform](https://img.shields.io/badge/-Terraform-844FBA?style=flat-square&logo=terraform&logoColor=white) | `.tf`, `.hcl` | ✓ | —³ | —³ | —³ | —³ | —³ |

> ¹ **Heritage** = `extends`, `implements`, `include`/`extend` (Ruby), trait `impl` (Rust), receiver methods (Go).
> ² **Type Inference** extracts a per-file type map from annotations (`const x: Router`, `MyType x`, `x: MyType`) and `new` expressions, enabling the edge resolver to connect `x.method()` → `Type.method()`.
> ³ Not applicable — Ruby is dynamically typed; Terraform/HCL is declarative (no functions, classes, or type system).
> All languages have full **parity** between the native Rust engine and the WASM fallback.

## ⚙️ How It Works

Expand Down
1 change: 1 addition & 0 deletions docs/roadmap/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ Community detection will use a vendored Leiden optimiser (PR #545) with full con
| 101 | Hierarchical community decomposition | Run Leiden at multiple resolution levels (e.g., γ=0.5, 1.0, 2.0) and expose nested community structure — macro-clusters containing sub-clusters. The vendored optimiser already computes multi-level coarsening internally; surface it as `communities --hierarchical` with a tree output showing which fine-grained communities nest inside coarse ones. Store hierarchy in a `community_hierarchy` table or JSON metadata. | Architecture | Single-resolution communities force a choice between broad architectural groups and tight cohesion clusters. Hierarchical decomposition gives both — agents can zoom from "this is the graph subsystem" to "specifically the Leiden algorithm cluster within it" without re-running at different resolutions | ✓ | ✓ | 3 | No | #545 |
| 102 | Community-aware impact scoring | Factor community boundaries into `fn-impact` and `diff-impact` risk scoring. Changes that cross community boundaries are architecturally riskier than changes within a single community — they indicate coupling between modules that should be independent. Add `crossCommunityCount` to impact output and weight it in triage risk scoring. A function with blast radius 5 all within one community is lower risk than blast radius 5 spanning 4 communities. | Analysis | Directly improves blast radius accuracy — the core problem codegraph exists to solve. Community-crossing impact is a strong signal for architectural coupling that raw call-chain fan-out doesn't capture | ✓ | ✓ | 4 | No | #545 |


### Tier 1f — Embeddings leverage (build on existing `embeddings` table)

Symbol embeddings and FTS index are populated via `codegraph embed`. Currently only consumed by the `search` command. The vectors and `cosineSim()` function already exist.
Expand Down
1 change: 1 addition & 0 deletions src/graph/algorithms/leiden/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { runLouvainUndirectedModularity } from './optimiser.js';
* @param {number} [options.maxCommunitySize]
* @param {Set|Array} [options.fixedNodes]
* @param {string} [options.candidateStrategy] - 'neighbors' | 'all' | 'random' | 'random-neighbor'
* @param {number} [options.refinementTheta=1.0] - Temperature for probabilistic Leiden refinement (Algorithm 3, Traag et al. 2019). Lower → more greedy, higher → more exploratory. Deterministic via seeded PRNG
* @returns {{ getClass(id): number, getCommunities(): Map, quality(): number, toJSON(): object }}
*
* **Note on `quality()`:** For modularity, `quality()` always evaluates at γ=1.0
Expand Down
190 changes: 160 additions & 30 deletions src/graph/algorithms/leiden/optimiser.js
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,12 @@ export function runLouvainUndirectedModularity(graph, optionsInput = {}) {
options,
level === 0 ? fixedNodeMask : null,
);
// Post-refinement: split any disconnected communities into their
// connected components. This is the cheap O(V+E) alternative to
// checking γ-connectedness on every candidate during refinement.
// A disconnected community violates even basic connectivity, so
// splitting is always correct.
splitDisconnectedCommunities(graphAdapter, refined);
renumberCommunities(refined, options.preserveLabels);
effectivePartition = refined;
}
Expand Down Expand Up @@ -229,6 +235,28 @@ function buildCoarseGraph(g, p) {
return coarse;
}

/**
* True Leiden refinement phase (Algorithm 3, Traag et al. 2019).
*
* Key properties that distinguish this from Louvain-style refinement:
*
* 1. **Singleton start** — each node begins in its own community.
* 2. **Singleton guard** — only nodes still in singleton communities are
* considered for merging. Once a node joins a non-singleton community
* it is locked for the remainder of the pass. This prevents oscillation
* and is essential for the γ-connectedness guarantee.
* 3. **Single pass** — one randomized sweep through all nodes, not an
* iterative loop until convergence (that would be Louvain behavior).
* 4. **Probabilistic selection** — candidate communities are sampled from
* a Boltzmann distribution `p(v, C) ∝ exp(ΔH / θ)`, with the "stay
* as singleton" option (ΔH = 0) included in the distribution. This
* means a node may probabilistically choose to remain alone even when
* positive-gain merges exist.
*
* θ (refinementTheta) controls temperature: lower → more deterministic
* (approaches greedy), higher → more exploratory. Determinism is preserved
* via the seeded PRNG — same seed produces the same assignments.
*/
function refineWithinCoarseCommunities(g, basePart, rng, opts, fixedMask0) {
const p = makePartition(g);
p.initializeAggregates();
Expand All @@ -237,45 +265,144 @@ function refineWithinCoarseCommunities(g, basePart, rng, opts, fixedMask0) {
const commMacro = new Int32Array(p.communityCount);
for (let i = 0; i < p.communityCount; i++) commMacro[i] = macro[i];

const theta = typeof opts.refinementTheta === 'number' ? opts.refinementTheta : 1.0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 theta ≤ 0 causes division by zero and NaN weights

Math.exp(x / theta) where theta = 0 produces Infinity, -Infinity, or NaN depending on the sign of x. A caller passing refinementTheta: 0 (intending "fully greedy") would silently corrupt the entire partition — totalWeight becomes NaN, r < stayWeight is never true, and chosenC falls through to the arbitrary last-candidate fallback for every node.

The parameter description says "lower → more greedy" but does not state it must be strictly positive; 0 is a natural misinterpretation. A guard and a clear error message would prevent silent corruption:

const theta = typeof opts.refinementTheta === 'number' ? opts.refinementTheta : 1.0;
if (theta <= 0) throw new RangeError(`refinementTheta must be > 0 (got ${theta})`);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3d6b157. Added a RangeError guard immediately after resolving theta:

if (theta <= 0) throw new RangeError(`refinementTheta must be > 0 (got ${theta})`);

This catches both explicit zero and negative values before they reach Math.exp(x / theta).


// Single pass in random order (Algorithm 3, step 2).
const order = new Int32Array(g.n);
for (let i = 0; i < g.n; i++) order[i] = i;
let improved = true;
let passes = 0;
while (improved) {
improved = false;
passes++;
shuffleArrayInPlace(order, rng);
for (let idx = 0; idx < order.length; idx++) {
const v = order[idx];
if (fixedMask0?.[v]) continue;
const macroV = macro[v];
const touchedCount = p.accumulateNeighborCommunityEdgeWeights(v);
let bestC = p.nodeCommunity[v];
let bestGain = 0;
const maxSize = Number.isFinite(opts.maxCommunitySize) ? opts.maxCommunitySize : Infinity;
for (let t = 0; t < touchedCount; t++) {
const c = p.getCandidateCommunityAt(t);
if (commMacro[c] !== macroV) continue;
if (maxSize < Infinity) {
const nextSize = p.getCommunityTotalSize(c) + g.size[v];
if (nextSize > maxSize) continue;
}
const gain = computeQualityGain(p, v, c, opts);
if (gain > bestGain) {
bestGain = gain;
bestC = c;
}
shuffleArrayInPlace(order, rng);

for (let idx = 0; idx < order.length; idx++) {
const v = order[idx];
if (fixedMask0?.[v]) continue;

// Singleton guard: only move nodes still alone in their community.
if (p.getCommunityNodeCount(p.nodeCommunity[v]) > 1) continue;

const macroV = macro[v];
const touchedCount = p.accumulateNeighborCommunityEdgeWeights(v);
const maxSize = Number.isFinite(opts.maxCommunitySize) ? opts.maxCommunitySize : Infinity;

// Collect eligible communities and their quality gains.
const candidates = [];
for (let t = 0; t < touchedCount; t++) {
const c = p.getCandidateCommunityAt(t);
if (c === p.nodeCommunity[v]) continue;
if (commMacro[c] !== macroV) continue;
if (maxSize < Infinity) {
const nextSize = p.getCommunityTotalSize(c) + g.size[v];
if (nextSize > maxSize) continue;
}
if (bestC !== p.nodeCommunity[v] && bestGain > GAIN_EPSILON) {
p.moveNodeToCommunity(v, bestC);
improved = true;
const gain = computeQualityGain(p, v, c, opts);
if (gain > GAIN_EPSILON) {
candidates.push({ c, gain });
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Per-node array allocation in hot loop creates GC pressure

candidates is a fresh [] allocated and pushed to on every node visit. Each { c, gain } object is then mutated with a .weight property, causing hidden-class invalidation in V8. For large graphs the tight inner loop will generate a lot of short-lived garbage.

Consider reusing a single flat array with a length counter, similar to the existing candidateCommunities / candidateCommunityCount pattern already used in the partition:

// Before the node loop:
const candidateC = new Int32Array(g.n);
const candidateGain = new Float64Array(g.n);
const candidateWeight = new Float64Array(g.n);
let nCandidates = 0;

// Inside the loop, replace the array push + weight mutation:
nCandidates = 0;
for (let t = 0; t < touchedCount; t++) {
  ...
  if (gain > GAIN_EPSILON) {
    candidateC[nCandidates] = c;
    candidateGain[nCandidates++] = gain;
  }
}

Then update the Boltzmann sampling section to index these flat arrays instead of candidates[i].*.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3d6b157. Replaced the per-node candidates array and { c, gain } object allocations with pre-allocated flat typed arrays (candC: Int32Array, candGain: Float64Array, candWeight: Float64Array) sized to g.n and a candLen counter. This follows the same pattern used by candidateCommunities in partition.js and eliminates per-node GC pressure in the hot loop.

}
if (passes >= opts.maxLocalPasses) break;

if (candidates.length === 0) continue;

// Probabilistic selection: p(v, C) ∝ exp(ΔH / θ), with the "stay"
// option (ΔH = 0) included per Algorithm 3.
// For numerical stability, subtract the max gain before exponentiation.
const maxGain = candidates.reduce((m, x) => (x.gain > m ? x.gain : m), 0);
// "Stay as singleton" weight: exp((0 - maxGain) / theta)
const stayWeight = Math.exp((0 - maxGain) / theta);
let totalWeight = stayWeight;
for (let i = 0; i < candidates.length; i++) {
candidates[i].weight = Math.exp((candidates[i].gain - maxGain) / theta);
totalWeight += candidates[i].weight;
}

const r = rng() * totalWeight;
if (r < stayWeight) continue; // node stays as singleton

let cumulative = stayWeight;
let chosenC = candidates[candidates.length - 1].c; // fallback
for (let i = 0; i < candidates.length; i++) {
cumulative += candidates[i].weight;
if (r < cumulative) {
chosenC = candidates[i].c;
break;
}
}

p.moveNodeToCommunity(v, chosenC);
}
return p;
}

/**
* Post-refinement connectivity check. For each community, run a BFS on
* the subgraph induced by its members (using the adapter's outEdges).
* If a community has multiple connected components, assign secondary
* components to new community IDs, then reinitialize aggregates once.
*
* O(V+E) total since communities partition V.
*
* This replaces the per-candidate γ-connectedness check from the paper
* with a cheaper post-step that catches the most important violation
* (disconnected subcommunities).
*/
function splitDisconnectedCommunities(g, partition) {
const n = g.n;
const nc = partition.nodeCommunity;
const members = partition.getCommunityMembers();
let nextC = partition.communityCount;
let didSplit = false;

const visited = new Uint8Array(n);
const inCommunity = new Uint8Array(n);

for (let c = 0; c < members.length; c++) {
const nodes = members[c];
if (nodes.length <= 1) continue;

for (let i = 0; i < nodes.length; i++) inCommunity[nodes[i]] = 1;

let componentCount = 0;
for (let i = 0; i < nodes.length; i++) {
const start = nodes[i];
if (visited[start]) continue;
componentCount++;

// BFS within the community subgraph.
const queue = [start];
visited[start] = 1;
let head = 0;
while (head < queue.length) {
const v = queue[head++];
const edges = g.outEdges[v];
for (let k = 0; k < edges.length; k++) {
const w = edges[k].to;
if (inCommunity[w] && !visited[w]) {
visited[w] = 1;
queue.push(w);
}
}
}

if (componentCount > 1) {
// Secondary component — assign new community ID directly.
const newC = nextC++;
for (let q = 0; q < queue.length; q++) nc[queue[q]] = newC;
didSplit = true;
}
}

for (let i = 0; i < nodes.length; i++) {
inCommunity[nodes[i]] = 0;
visited[nodes[i]] = 0;
}
}

if (didSplit) {
// Grow the partition's typed arrays to accommodate new community IDs,
// then recompute all aggregates from the updated nodeCommunity array.
partition.resizeCommunities(nextC);
partition.initializeAggregates();
}
}

function computeQualityGain(partition, v, c, opts) {
const quality = (opts.quality || 'modularity').toLowerCase();
const gamma = typeof opts.resolution === 'number' ? opts.resolution : 1.0;
Expand Down Expand Up @@ -329,6 +456,8 @@ function normalizeOptions(options = {}) {
const maxCommunitySize = Number.isFinite(options.maxCommunitySize)
? options.maxCommunitySize
: Infinity;
const refinementTheta =
typeof options.refinementTheta === 'number' ? options.refinementTheta : 1.0;
return {
directed,
randomSeed,
Expand All @@ -341,6 +470,7 @@ function normalizeOptions(options = {}) {
refine,
preserveLabels,
maxCommunitySize,
refinementTheta,
fixedNodes: options.fixedNodes,
};
}
Expand Down
4 changes: 4 additions & 0 deletions src/graph/algorithms/leiden/partition.js
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,10 @@ export function makePartition(graph) {
get communityTotalInStrength() {
return communityTotalInStrength;
},
resizeCommunities(newCount) {
ensureCommCapacity(newCount);
communityCount = newCount;
},
initializeAggregates,
accumulateNeighborCommunityEdgeWeights,
getCandidateCommunityCount: () => candidateCommunityCount,
Expand Down
Loading
Loading