From 95a44772835c1eb4caf10b81b35d6b520beeea4f Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sun, 13 Sep 2026 03:46:24 +0300 Subject: [PATCH] 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. --- package-lock.json | 53 ++++++++++++++++++ package.json | 1 + src/core/clusters.ts | 118 +++++++++++++++++++++++++++++++++++++++++ src/core/index.ts | 1 + tests/clusters.test.ts | 103 +++++++++++++++++++++++++++++++++++ 5 files changed, 276 insertions(+) create mode 100644 src/core/clusters.ts create mode 100644 tests/clusters.test.ts diff --git a/package-lock.json b/package-lock.json index f9278b2..e095625 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 5eb7a45..32a7514 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/core/clusters.ts b/src/core/clusters.ts new file mode 100644 index 0000000..edeb6b8 --- /dev/null +++ b/src/core/clusters.ts @@ -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(); + 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 }; +} diff --git a/src/core/index.ts b/src/core/index.ts index 84d1676..a611727 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -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"; diff --git a/tests/clusters.test.ts b/tests/clusters.test.ts new file mode 100644 index 0000000..5ff3f50 --- /dev/null +++ b/tests/clusters.test.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); + }); +});