Clusters phase A: community detection core (Louvain, seeded, structural)
detectCommunities(nodes, edges) in src/core/clusters.ts (new dep graphology-communities-louvain): Louvain modularity over the undirected, unweighted follows+supports structure (user-decided 2026-09-13). The random-walk traversal runs on an inlined seeded mulberry32 so the same graph always yields the same partition; community indices are still unstable across edits, which is exactly why labelings (phase B) key to sorted MEMBER SETS. follows+supports between the same pair collapse to one edge; self-loops and dangling edges are skipped defensively; no min-size filter here (the >=3 render threshold is a presentation rule). Tests: two-cliques-plus-bridge split, determinism, singleton/empty graphs, pair collapse, defensive skips, member sorting. 363/363. Real-data smoke over the current notes graph: 338 nodes / 211 edges -> 138 communities, modularity 0.95, and the big ones are exactly the learn-session topics (databases, differential forms, sales, servers, vim, copulas...). 32 communities clear the >=3 render threshold.
This commit is contained in:
Generated
+53
@@ -26,6 +26,7 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"graphology": "^0.26.0",
|
||||
"graphology-communities-louvain": "^2.0.2",
|
||||
"graphology-layout-forceatlas2": "^0.10.1",
|
||||
"katex": "^0.16.47",
|
||||
"linkedom": "^0.18.13",
|
||||
@@ -8837,6 +8838,34 @@
|
||||
"graphology-types": ">=0.24.0"
|
||||
}
|
||||
},
|
||||
"node_modules/graphology-communities-louvain": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/graphology-communities-louvain/-/graphology-communities-louvain-2.0.2.tgz",
|
||||
"integrity": "sha512-zt+2hHVPYxjEquyecxWXoUoIuN/UvYzsvI7boDdMNz0rRvpESQ7+e+Ejv6wK7AThycbZXuQ6DkG8NPMCq6XwoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graphology-indices": "^0.17.0",
|
||||
"graphology-utils": "^2.4.4",
|
||||
"mnemonist": "^0.39.0",
|
||||
"pandemonium": "^2.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"graphology-types": ">=0.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/graphology-indices": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/graphology-indices/-/graphology-indices-0.17.0.tgz",
|
||||
"integrity": "sha512-A7RXuKQvdqSWOpn7ZVQo4S33O0vCfPBnUSf7FwE0zNCasqwZVUaCXePuWo5HBpWw68KJcwObZDHpFk6HKH6MYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graphology-utils": "^2.4.2",
|
||||
"mnemonist": "^0.39.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"graphology-types": ">=0.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/graphology-layout-forceatlas2": {
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/graphology-layout-forceatlas2/-/graphology-layout-forceatlas2-0.10.1.tgz",
|
||||
@@ -11595,6 +11624,15 @@
|
||||
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mnemonist": {
|
||||
"version": "0.39.8",
|
||||
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.8.tgz",
|
||||
"integrity": "sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"obliterator": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/modern-tar": {
|
||||
"version": "0.8.4",
|
||||
"resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.8.4.tgz",
|
||||
@@ -11993,6 +12031,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/obliterator": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
|
||||
"integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/oniguruma-parser": {
|
||||
"version": "0.12.2",
|
||||
"resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz",
|
||||
@@ -12116,6 +12160,15 @@
|
||||
"integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pandemonium": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/pandemonium/-/pandemonium-2.4.1.tgz",
|
||||
"integrity": "sha512-wRqjisUyiUfXowgm7MFH2rwJzKIr20rca5FsHXCMNm1W5YPP1hCtrZfgmQ62kP7OZ7Xt+cR858aB28lu5NX55g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mnemonist": "^0.39.2"
|
||||
}
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"graphology": "^0.26.0",
|
||||
"graphology-communities-louvain": "^2.0.2",
|
||||
"graphology-layout-forceatlas2": "^0.10.1",
|
||||
"katex": "^0.16.47",
|
||||
"linkedom": "^0.18.13",
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* COMMUNITY DETECTION for the knowledge graph (vision §1, DECIDED
|
||||
* 2026-09-13): Louvain modularity over the undirected, unweighted
|
||||
* follows+supports structure — purely structural, so it never depends on
|
||||
* layout, and the extraction pipeline never knows clusters exist.
|
||||
*
|
||||
* Determinism: Louvain's random-walk traversal is driven by a SEEDED
|
||||
* mulberry32 RNG (default seed constant below), so the same graph always
|
||||
* yields the same partition. Community INDICES are still not stable
|
||||
* across graph edits — which is why labelings (Phase B) key to sorted
|
||||
* MEMBER SETS, never to indices.
|
||||
*
|
||||
* Undirected + unweighted (user-decided 2026-09-13): both edge types are
|
||||
* equally real relations between propositions — direction is
|
||||
* derivational-vs-grounding, not stronger-vs-weaker — and a follows AND
|
||||
* a supports between the same pair collapse to one edge.
|
||||
*
|
||||
* This module is browser-safe pure logic (no node builtins, no fs): the
|
||||
* server runs it for the snapshot payload, the renderer for nothing
|
||||
* (clusters arrive precomputed), and tests run it directly.
|
||||
*
|
||||
* NOTE: no minimum-size filter here — detection returns every community,
|
||||
* singletons included. The ≥3-node render threshold (decided) is a
|
||||
* PRESENTATION rule; it lives in the payload derivation and the
|
||||
* renderer, not in the algorithm.
|
||||
*/
|
||||
import Graph from "graphology";
|
||||
import louvain from "graphology-communities-louvain";
|
||||
|
||||
/** A detected community. Members are SORTED node ids — the member set IS
|
||||
* the identity a labeling record can outlive a reshuffle with. */
|
||||
export interface Community {
|
||||
members: string[];
|
||||
}
|
||||
|
||||
export interface CommunityDetection {
|
||||
communities: Community[];
|
||||
/** Modularity of the found partition — the one quality number. */
|
||||
modularity: number;
|
||||
}
|
||||
|
||||
export interface DetectOptions {
|
||||
/** Louvain resolution (higher → more, smaller communities). Default 1.
|
||||
* Parked for the multi-scale work; exposed now so that dial has a home. */
|
||||
resolution?: number;
|
||||
/** RNG seed. Default "lh-clusters". Same seed + same graph → same
|
||||
* partition, always. */
|
||||
seed?: string;
|
||||
}
|
||||
|
||||
/** mulberry32 seeded by a string (FNV-1a) — the same recipe as
|
||||
* question-core's seededRandom, inlined so this module has no
|
||||
* cross-domain dependency. */
|
||||
function seededRng(seed: string): () => number {
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
h ^= seed.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619);
|
||||
}
|
||||
let a = h >>> 0;
|
||||
return () => {
|
||||
a |= 0;
|
||||
a = (a + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect communities over a graph's structure. Inputs are the minimal
|
||||
* structural shapes of the snapshot contracts (GraphNode.id,
|
||||
* GraphEdge.from/to), so both the real snapshot and test fixtures feed it.
|
||||
*
|
||||
* Edges referencing unknown node ids are skipped defensively (the ledgers
|
||||
* are append-only; a mid-merge read should never crash detection), and
|
||||
* duplicate pairs (follows+supports, or reverse direction) collapse —
|
||||
* mergeEdge keeps the first.
|
||||
*/
|
||||
export function detectCommunities(
|
||||
nodes: readonly { id: string }[],
|
||||
edges: readonly { from: string; to: string }[],
|
||||
opts?: DetectOptions,
|
||||
): CommunityDetection {
|
||||
const graph = new Graph({ type: "undirected" });
|
||||
for (const node of nodes) graph.addNode(node.id);
|
||||
for (const edge of edges) {
|
||||
if (!graph.hasNode(edge.from) || !graph.hasNode(edge.to)) continue;
|
||||
if (edge.from === edge.to) continue; // self-loops: no community signal
|
||||
graph.mergeEdge(edge.from, edge.to);
|
||||
}
|
||||
// Louvain on a 0-edge graph is degenerate; every node is trivially its
|
||||
// own community (and louvain on an empty graph returns nothing useful).
|
||||
if (graph.size === 0) {
|
||||
return {
|
||||
communities: nodes.map((n) => ({ members: [n.id] })).sort((a, b) => (a.members[0] ?? "").localeCompare(b.members[0] ?? "")),
|
||||
modularity: 0,
|
||||
};
|
||||
}
|
||||
const details = louvain.detailed(graph, {
|
||||
rng: seededRng(opts?.seed ?? "lh-clusters"),
|
||||
resolution: opts?.resolution ?? 1,
|
||||
});
|
||||
const byIndex = new Map<number, string[]>();
|
||||
for (const [id, index] of Object.entries(details.communities)) {
|
||||
const bucket = byIndex.get(index) ?? [];
|
||||
bucket.push(id);
|
||||
byIndex.set(index, bucket);
|
||||
}
|
||||
const communities = [...byIndex.values()]
|
||||
.map((ids) => ({ members: ids.sort() }))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.members.length - a.members.length ||
|
||||
(a.members[0] ?? "").localeCompare(b.members[0] ?? ""),
|
||||
);
|
||||
return { communities, modularity: details.modularity };
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export * from "./contracts.ts";
|
||||
export * from "./ports.ts";
|
||||
|
||||
export * from "./question-core.ts";
|
||||
export * from "./clusters.ts";
|
||||
export * from "./plan-tree.ts";
|
||||
export * from "./phases.ts";
|
||||
export * from "./pending.ts";
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Community detection tests (vision §1, DECIDED 2026-09-13) — pure
|
||||
* structural Louvain over follows+supports, deterministic via seed.
|
||||
* Assertions are on SHAPE and MEMBER SETS, never community indices
|
||||
* (indices reshuffle; member sets are what labelings key to).
|
||||
*/
|
||||
import { strict as assert } from "node:assert";
|
||||
import { describe, test } from "node:test";
|
||||
import { detectCommunities } from "../src/core/clusters.ts";
|
||||
import type { Community } from "../src/core/clusters.ts";
|
||||
|
||||
const ids = (n: number, prefix = "n") => [...Array(n).keys()].map((i) => `${prefix}${i}`);
|
||||
|
||||
/** A clique on the given ids. */
|
||||
const clique = (nodeIds: string[]) =>
|
||||
nodeIds.flatMap((a, i) => nodeIds.slice(i + 1).map((b) => ({ from: a, to: b })));
|
||||
|
||||
describe("detectCommunities", () => {
|
||||
test("two dense cliques joined by a single bridge split into two communities", () => {
|
||||
const left = ids(4, "L");
|
||||
const right = ids(4, "R");
|
||||
const bridge = { from: "L0", to: "R0" };
|
||||
const result = detectCommunities([...left, ...right].map((id) => ({ id })), [...clique(left), ...clique(right), bridge]);
|
||||
assert.equal(result.communities.length, 2);
|
||||
assert.equal(result.modularity > 0.3, true);
|
||||
// Member sets, not order: the two big communities are exactly the cliques.
|
||||
const big = result.communities.filter((c) => c.members.length === 4);
|
||||
assert.deepEqual(big.map((c) => c.members.sort()).sort(), [left.slice().sort(), right.slice().sort()]);
|
||||
});
|
||||
|
||||
test("deterministic: same graph, same seed → identical partition", () => {
|
||||
const nodes = ids(10).map((id) => ({ id }));
|
||||
const edges = [...clique(ids(5)), ...clique(ids(5, "n").slice().reverse().slice(0, 5)), { from: "n0", to: "n9" }];
|
||||
const a = detectCommunities(nodes, edges);
|
||||
const b = detectCommunities(nodes, edges);
|
||||
assert.deepEqual(a.communities, b.communities);
|
||||
assert.equal(a.modularity, b.modularity);
|
||||
});
|
||||
|
||||
test("no edges: every node is its own singleton community", () => {
|
||||
const result = detectCommunities(ids(3).map((id) => ({ id })), []);
|
||||
assert.equal(result.communities.length, 3);
|
||||
for (const c of result.communities) assert.equal(c.members.length, 1);
|
||||
assert.equal(result.modularity, 0);
|
||||
});
|
||||
|
||||
test("empty graph: zero communities", () => {
|
||||
assert.deepEqual(detectCommunities([], []), { communities: [], modularity: 0 });
|
||||
});
|
||||
|
||||
test("duplicate and reversed pairs collapse to one edge (undirected, unweighted)", () => {
|
||||
const nodes = ["a", "b", "c"].map((id) => ({ id }));
|
||||
// follows a→b AND supports b→a — same pair twice, both directions.
|
||||
const result = detectCommunities(nodes, [
|
||||
{ from: "a", to: "b" },
|
||||
{ from: "b", to: "a" },
|
||||
{ from: "b", to: "c" },
|
||||
]);
|
||||
assert.equal(result.communities.length, 1);
|
||||
assert.deepEqual(result.communities[0].members.sort(), ["a", "b", "c"]);
|
||||
});
|
||||
|
||||
test("self-loops and unknown-endpoint edges are skipped defensively", () => {
|
||||
const nodes = ["a", "b"].map((id) => ({ id }));
|
||||
const result = detectCommunities(nodes, [
|
||||
{ from: "a", to: "a" }, // self-loop
|
||||
{ from: "a", to: "ghost" }, // dangling
|
||||
{ from: "ghost1", to: "ghost2" },
|
||||
{ from: "a", to: "b" }, // the one real edge
|
||||
]);
|
||||
assert.equal(result.communities.length, 1);
|
||||
assert.deepEqual(result.communities[0].members.sort(), ["a", "b"]);
|
||||
});
|
||||
|
||||
test("members are sorted; communities ordered by size then id", () => {
|
||||
const left = ids(4, "L");
|
||||
const right = ids(4, "R");
|
||||
const result = detectCommunities(
|
||||
[...left, ...right, "iso"].map((id) => ({ id })),
|
||||
[...clique(left), ...clique(right), { from: "L0", to: "R0" }],
|
||||
);
|
||||
for (const c of result.communities as Community[]) {
|
||||
const sorted = c.members.slice().sort();
|
||||
assert.deepEqual(c.members, sorted);
|
||||
}
|
||||
// iso (isolated) is last: size 1 < 4.
|
||||
const last = result.communities[result.communities.length - 1];
|
||||
assert.deepEqual(last.members, ["iso"]);
|
||||
});
|
||||
|
||||
test("seed override changes nothing structurally but stays deterministic", () => {
|
||||
const nodes = ids(8).map((id) => ({ id }));
|
||||
const edges = [...clique(["n0", "n1", "n2", "n3"]), ...clique(["n4", "n5", "n6", "n7"]), { from: "n0", to: "n4" }];
|
||||
const a = detectCommunities(nodes, edges, { seed: "one" });
|
||||
const b = detectCommunities(nodes, edges, { seed: "one" });
|
||||
const c = detectCommunities(nodes, edges, { seed: "two" });
|
||||
assert.deepEqual(a.communities, b.communities);
|
||||
// A different seed partitions the same graph into member sets that
|
||||
// may or may not coincide — but both runs are 2 communities here,
|
||||
// because the structure dominates.
|
||||
assert.equal(c.communities.length, 2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user