119 lines
4.4 KiB
TypeScript
119 lines
4.4 KiB
TypeScript
/**
|
|||
|
|
* 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 };
|
||
|
|
}
|