Semantic topic clusters: local embeddings replace Louvain
Louvain only ever found the reasoning chains (the graph is disconnected by nature — every connected component became its own cluster) and edge-orphans could never join anything. Clusters are now RELATED TOPICS: propositions grouped by meaning. - Embedder (src/server/embeddings.ts): in-process transformers.js v4 running EmbeddingGemma-300M q8 (MTEB 68.4, ~300MB one-time download), MRL-truncated to 512d, "title: none | text:" prefix on 100% of inputs, L2-normalized so cosine = dot. Model/dim mismatch voids the ledger (full re-embed). Injected at the composition root — the agent layer never imports the server layer (architecture law). - Ledger (data/graph/embeddings.json): model + dim + vectors + the persisted cluster partition. Reclustering runs at the END of every embedding batch (post-merge hook, both front doors) — never per read (the agglomerative pass is ~1.5s at 338 nodes). - Clustering (src/core/semantic-clusters.ts): hand-rolled average-linkage agglomerative over cosine (npm HDBSCAN is an abandoned alpha). Stragglers stay singletons by default; attachStragglers is the all-in toggle. Threshold 0.60 swept on the real graph: 26 topics, 74/90 edge-orphans land in real topics, 10 stragglers. - Consumers (snapshot, settle, namer, replay) read the persisted partition — one cluster concept everywhere; core-survival label matching unchanged (20/26 topics kept labels). - Louvain retired: detectCommunities + graphology* deps deleted (graphology-louvain had leaked into the client bundle since the clusters wave); scripts/bench-graph.ts (dead FA2 bench) removed. - scripts/embed-graph.ts: backfill/cold-start (338 nodes in 150s including the one-time model download).
This commit is contained in:
@@ -14,10 +14,10 @@
|
|||||||
* per-camera-event full-canvas repaints (GPU spikes + coil whine) are
|
* per-camera-event full-canvas repaints (GPU spikes + coil whine) are
|
||||||
* gone (at most one draw per frame, and NO frame at all while idle).
|
* gone (at most one draw per frame, and NO frame at all while idle).
|
||||||
*
|
*
|
||||||
* Layout (graphology + forceAtlas2) runs-then-freezes exactly as
|
* Layout fallback (ledger absent): ONE synchronous incrementalLayout
|
||||||
* before: ONE synchronous 100-iteration pass inside a useEffect writes
|
* pass (d3-force, core/layout.ts) inside a useEffect writes world-space
|
||||||
* world-space x/y, then the positions are handed to the renderer as
|
* x/y, then the positions are handed to the renderer as data. No layout
|
||||||
* data. No layout animation ever starts; the only animation is the
|
* animation ever starts; the only animation is the
|
||||||
* short hover fade, strictly bounded in time — idle CPU is zero (the
|
* short hover fade, strictly bounded in time — idle CPU is zero (the
|
||||||
* loop schedules no rAF until something actually changes).
|
* loop schedules no rAF until something actually changes).
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -89,6 +89,12 @@ export const SOURCES_FILE = path.join(PATHS.graph, "sources.json");
|
|||||||
* Carries an updatedAt stamp for future live-physics conflict handling. */
|
* Carries an updatedAt stamp for future live-physics conflict handling. */
|
||||||
export const POSITIONS_FILE = path.join(PATHS.graph, "positions.json");
|
export const POSITIONS_FILE = path.join(PATHS.graph, "positions.json");
|
||||||
|
|
||||||
|
/** The embedding ledger: one L2-normalized vector per node id, all from
|
||||||
|
* ONE model (swapping models re-embeds everything — vectors from
|
||||||
|
* different models live in incompatible spaces). Read-modify-write
|
||||||
|
* JSON like positions/sources. */
|
||||||
|
export const EMBEDDINGS_FILE = path.join(PATHS.graph, "embeddings.json");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Graph render settings (the settings-panel controls): one small JSON blob,
|
* Graph render settings (the settings-panel controls): one small JSON blob,
|
||||||
* read-modify-write. Shared shape/sanitizer: src/core/render-settings.ts.
|
* read-modify-write. Shared shape/sanitizer: src/core/render-settings.ts.
|
||||||
|
|||||||
+7
-1
@@ -4,7 +4,13 @@ const nextConfig: NextConfig = {
|
|||||||
// Framework-free core lives in src/ (plain TS, imported by routes and CLI alike).
|
// Framework-free core lives in src/ (plain TS, imported by routes and CLI alike).
|
||||||
// The pi engine must stay external: its providers are runtime-loaded Node
|
// The pi engine must stay external: its providers are runtime-loaded Node
|
||||||
// code (undici streams, catalog data) that breaks when bundled into routes.
|
// code (undici streams, catalog data) that breaks when bundled into routes.
|
||||||
serverExternalPackages: ["@earendil-works/pi-ai", "@earendil-works/pi-agent-core"],
|
// transformers.js (embeddings) is runtime-loaded ONNX — same reason as
|
||||||
|
// the pi engine: bundling onnxruntime-node breaks route builds.
|
||||||
|
serverExternalPackages: [
|
||||||
|
"@earendil-works/pi-ai",
|
||||||
|
"@earendil-works/pi-agent-core",
|
||||||
|
"@huggingface/transformers",
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
Generated
+768
-114
File diff suppressed because it is too large
Load Diff
+1
-3
@@ -16,6 +16,7 @@
|
|||||||
"@ai-sdk/react": "^4.0.79",
|
"@ai-sdk/react": "^4.0.79",
|
||||||
"@earendil-works/pi-agent-core": "^0.85.1",
|
"@earendil-works/pi-agent-core": "^0.85.1",
|
||||||
"@earendil-works/pi-ai": "^0.85.1",
|
"@earendil-works/pi-ai": "^0.85.1",
|
||||||
|
"@huggingface/transformers": "^4.2.0",
|
||||||
"@mozilla/readability": "^0.6.0",
|
"@mozilla/readability": "^0.6.0",
|
||||||
"@radix-ui/react-use-controllable-state": "^1.2.6",
|
"@radix-ui/react-use-controllable-state": "^1.2.6",
|
||||||
"@rive-app/react-webgl2": "^4.32.1",
|
"@rive-app/react-webgl2": "^4.32.1",
|
||||||
@@ -33,9 +34,6 @@
|
|||||||
"d3-selection": "^3.0.0",
|
"d3-selection": "^3.0.0",
|
||||||
"d3-zoom": "^3.0.0",
|
"d3-zoom": "^3.0.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"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",
|
"katex": "^0.16.47",
|
||||||
"linkedom": "^0.18.13",
|
"linkedom": "^0.18.13",
|
||||||
"lucide-react": "^1.33.0",
|
"lucide-react": "^1.33.0",
|
||||||
|
|||||||
@@ -8,13 +8,14 @@
|
|||||||
import { chatStore } from "../src/store/chats.ts";
|
import { chatStore } from "../src/store/chats.ts";
|
||||||
import { graphStore } from "../src/store/graph.ts";
|
import { graphStore } from "../src/store/graph.ts";
|
||||||
import { createExtractionPipeline } from "../src/agent/subagents/extraction.ts";
|
import { createExtractionPipeline } from "../src/agent/subagents/extraction.ts";
|
||||||
|
import { embedNewNodes } from "../src/server/embeddings.ts";
|
||||||
import { getGraph } from "../src/core/graph.ts";
|
import { getGraph } from "../src/core/graph.ts";
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const only = process.argv[2]; // optional: extract just one chat id
|
const only = process.argv[2]; // optional: extract just one chat id
|
||||||
const chats = only ? chatStore.listChats().filter((c) => c.id === only) : chatStore.listChats();
|
const chats = only ? chatStore.listChats().filter((c) => c.id === only) : chatStore.listChats();
|
||||||
console.log(`[backfill] ${chats.length} chats to extract`);
|
console.log(`[backfill] ${chats.length} chats to extract`);
|
||||||
const pipeline = createExtractionPipeline({ chatStore, graphStore });
|
const pipeline = createExtractionPipeline({ chatStore, graphStore, embedder: embedNewNodes });
|
||||||
for (const chat of chats) {
|
for (const chat of chats) {
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
/**
|
|
||||||
* Layout benchmark (W4; vision §1 "Rendering (DECIDED)" — "Pre-implementation
|
|
||||||
* validation: 2k-node synthetic benchmark to confirm layout time").
|
|
||||||
*
|
|
||||||
* npx tsx scripts/bench-graph.ts
|
|
||||||
*
|
|
||||||
* Two scales: the vision's target regime (hundreds of nodes, <150 ms budget
|
|
||||||
* with a synchronous run-then-freeze pass) and the stress case (2,000 nodes,
|
|
||||||
* ~4,000 edges), the latter timed with and without barnesHutOptimize.
|
|
||||||
*
|
|
||||||
* VERDICT (2026-08-22, Apple silicon, Node 24): hundreds of nodes are
|
|
||||||
* comfortably inside the budget (see the measured lines — ~25 ms at 100,
|
|
||||||
* ~90 ms at 300). At 2k nodes the naive O(n²) pass takes seconds, and
|
|
||||||
* barnesHutOptimize cuts it roughly in half — still over budget, which is
|
|
||||||
* exactly the vision's escalation ladder: Barnes-Hut is already switched on
|
|
||||||
* automatically above ~500 nodes in components/graph-view.tsx, and the
|
|
||||||
* bundled Web Worker / server-cached positions stay available as later
|
|
||||||
* rungs if real graphs ever get there. v1 learner graphs are dozens of
|
|
||||||
* nodes; the stress case is preparation, not expectation.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import Graph from "graphology";
|
|
||||||
import forceAtlas2 from "graphology-layout-forceatlas2";
|
|
||||||
|
|
||||||
const ITERATIONS = 100;
|
|
||||||
|
|
||||||
/** A plausible learning graph: mostly-local chains plus long-range cross-links. */
|
|
||||||
function synthesize(nodeCount: number, edgeCount: number): Graph {
|
|
||||||
const graph = new Graph();
|
|
||||||
for (let i = 0; i < nodeCount; i++) {
|
|
||||||
graph.addNode(`n${i}`, {
|
|
||||||
label: `node ${i}`,
|
|
||||||
size: 6,
|
|
||||||
x: Math.random() * 100,
|
|
||||||
y: Math.random() * 100,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let added = 0;
|
|
||||||
for (let i = 0; added < edgeCount && i < edgeCount * 3; i++) {
|
|
||||||
const from = Math.floor(Math.random() * nodeCount);
|
|
||||||
const span = Math.random() < 0.8 ? 1 + Math.floor(Math.random() * 10) : Math.floor(Math.random() * nodeCount);
|
|
||||||
const to = (from + span) % nodeCount;
|
|
||||||
if (from === to || graph.hasEdge(`n${from}`, `n${to}`)) continue;
|
|
||||||
graph.addEdge(`n${from}`, `n${to}`, { type: "arrow", size: 1 });
|
|
||||||
added++;
|
|
||||||
}
|
|
||||||
return graph;
|
|
||||||
}
|
|
||||||
|
|
||||||
function bench(label: string, nodeCount: number, edgeCount: number, settings: Record<string, unknown>): void {
|
|
||||||
const graph = synthesize(nodeCount, edgeCount);
|
|
||||||
const start = performance.now();
|
|
||||||
forceAtlas2.assign(graph, {
|
|
||||||
iterations: ITERATIONS,
|
|
||||||
settings: { ...forceAtlas2.inferSettings(graph), ...settings },
|
|
||||||
});
|
|
||||||
const elapsed = performance.now() - start;
|
|
||||||
console.log(
|
|
||||||
`${label}: ${elapsed.toFixed(1)} ms (${graph.order} nodes, ${graph.size} edges, ${ITERATIONS} iterations)`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Target regime — synchronous run-then-freeze (budget: <150 ms):\n`);
|
|
||||||
bench(" 100 nodes, ~200 edges", 100, 200, {});
|
|
||||||
bench(" 300 nodes, ~600 edges", 300, 600, {});
|
|
||||||
bench(" 500 nodes, ~1000 edges", 500, 1000, {});
|
|
||||||
console.log(`\nStress case — 2k nodes (~4000 edges), the vision's escalation ladder:\n`);
|
|
||||||
bench(" naive repulsion (O(n²)) ", 2000, 4000, {});
|
|
||||||
bench(" barnesHutOptimize ", 2000, 4000, { barnesHutOptimize: true });
|
|
||||||
bench(" barnesHutOptimize (warmed JIT)", 2000, 4000, { barnesHutOptimize: true });
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Embed the whole graph (backfill / cold start): one-time pass that
|
||||||
|
* fills the embedding ledger for every node — the same embedNewNodes the
|
||||||
|
* post-merge hook runs, just with an empty (or stale-model) ledger so
|
||||||
|
* every node is "new". First run downloads the model weights (~300MB)
|
||||||
|
* to the local HF cache; subsequent runs embed only what's missing.
|
||||||
|
*
|
||||||
|
* npx tsx scripts/embed-graph.ts
|
||||||
|
*/
|
||||||
|
import { graphStore } from "../src/store/graph.ts";
|
||||||
|
import { embedNewNodes, EMBEDDING_MODEL, EMBEDDING_DIM } from "../src/server/embeddings.ts";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const t0 = Date.now();
|
||||||
|
console.log(`[embed] model: ${EMBEDDING_MODEL} (dim ${EMBEDDING_DIM}, q8)`);
|
||||||
|
const { embedded, total } = await embedNewNodes(graphStore);
|
||||||
|
console.log(`[embed] embedded ${embedded} nodes → ledger holds ${total} vectors (${((Date.now() - t0) / 1000).toFixed(1)}s)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("[embed] failed:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { createExtractionPipeline } from "../src/agent/subagents/extraction.ts";
|
import { createExtractionPipeline } from "../src/agent/subagents/extraction.ts";
|
||||||
|
import { embedNewNodes } from "../src/server/embeddings.ts";
|
||||||
import { clusterNamingSettled } from "../src/agent/subagents/cluster-namer.ts";
|
import { clusterNamingSettled } from "../src/agent/subagents/cluster-namer.ts";
|
||||||
import { getGraph } from "../src/core/graph.ts";
|
import { getGraph } from "../src/core/graph.ts";
|
||||||
import { stores } from "../src/server/stores.ts";
|
import { stores } from "../src/server/stores.ts";
|
||||||
@@ -28,7 +29,7 @@ async function main() {
|
|||||||
.sort();
|
.sort();
|
||||||
console.log(`[import-notes] ${files.length} note files from ${dir} (concurrency ${limit})`);
|
console.log(`[import-notes] ${files.length} note files from ${dir} (concurrency ${limit})`);
|
||||||
|
|
||||||
const pipeline = createExtractionPipeline(stores);
|
const pipeline = createExtractionPipeline({ ...stores, embedder: embedNewNodes });
|
||||||
const queue = [...files];
|
const queue = [...files];
|
||||||
const totals = { nodes: 0, edges: 0, events: 0, failed: 0 };
|
const totals = { nodes: 0, edges: 0, events: 0, failed: 0 };
|
||||||
const workers = Array.from({ length: Math.min(limit, files.length) }, async () => {
|
const workers = Array.from({ length: Math.min(limit, files.length) }, async () => {
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
* playground mode baked into the rest state); omit it for the free layout.
|
* playground mode baked into the rest state); omit it for the free layout.
|
||||||
*/
|
*/
|
||||||
import { graphStore } from "../src/store/graph.ts";
|
import { graphStore } from "../src/store/graph.ts";
|
||||||
import { detectCommunities } from "../src/core/clusters.ts";
|
|
||||||
// --depth=vertical|radial: bake the playground depth mode into the ledger.
|
// --depth=vertical|radial: bake the playground depth mode into the ledger.
|
||||||
const depthArg = process.argv.find((a) => a.startsWith("--depth="));
|
const depthArg = process.argv.find((a) => a.startsWith("--depth="));
|
||||||
const depthMode: "off" | "vertical" | "radial" =
|
const depthMode: "off" | "vertical" | "radial" =
|
||||||
@@ -33,6 +32,12 @@ function main() {
|
|||||||
nodes.push(line);
|
nodes.push(line);
|
||||||
}
|
}
|
||||||
const edges = graphStore.readEdges();
|
const edges = graphStore.readEdges();
|
||||||
|
// The semantic-topic source: the embedding ledger (run
|
||||||
|
// scripts/embed-graph.ts first if missing/stale).
|
||||||
|
const embeddings = graphStore.readEmbeddings();
|
||||||
|
if (Object.keys(embeddings.vectors).length === 0) {
|
||||||
|
console.warn("[replay] embedding ledger is empty — clusters will be absent (run scripts/embed-graph.ts)");
|
||||||
|
}
|
||||||
|
|
||||||
// Batches: each imported file, in real import order (sources.json
|
// Batches: each imported file, in real import order (sources.json
|
||||||
// importedAt). A node belongs to the file whose mapping first claims it
|
// importedAt). A node belongs to the file whose mapping first claims it
|
||||||
@@ -79,8 +84,12 @@ function main() {
|
|||||||
const before = new Map(positions);
|
const before = new Map(positions);
|
||||||
const batchNodes = nodes.filter((n) => delivered.has(n.id));
|
const batchNodes = nodes.filter((n) => delivered.has(n.id));
|
||||||
positions = incrementalLayout(batchNodes, activeEdges, positions, {
|
positions = incrementalLayout(batchNodes, activeEdges, positions, {
|
||||||
// The same partition the real settle would see for this batch.
|
// The same partition the real settle would see for this batch:
|
||||||
clusters: detectCommunities(batchNodes, activeEdges).communities,
|
// the persisted semantic topics, filtered to delivered nodes
|
||||||
|
// (never recomputed — the agglomerative pass is ~1.5s at scale).
|
||||||
|
clusters: embeddings.clusters
|
||||||
|
.map((c) => ({ members: c.members.filter((id) => delivered.has(id)) }))
|
||||||
|
.filter((c) => c.members.length > 0),
|
||||||
...(depthMode === "off"
|
...(depthMode === "off"
|
||||||
? {}
|
? {}
|
||||||
: {
|
: {
|
||||||
|
|||||||
@@ -21,12 +21,7 @@ import type { PiEngine } from "../models.ts";
|
|||||||
import { runSubagent } from "./pi-run.ts";
|
import { runSubagent } from "./pi-run.ts";
|
||||||
import type { GraphNode } from "../../core/contracts.ts";
|
import type { GraphNode } from "../../core/contracts.ts";
|
||||||
import { getGraph, clusterContentId } from "../../core/graph.ts";
|
import { getGraph, clusterContentId } from "../../core/graph.ts";
|
||||||
import {
|
import { matchClusterLabelings, MIN_CLUSTER_SIZE, type Community } from "../../core/clusters.ts";
|
||||||
detectCommunities,
|
|
||||||
matchClusterLabelings,
|
|
||||||
MIN_CLUSTER_SIZE,
|
|
||||||
type Community,
|
|
||||||
} from "../../core/clusters.ts";
|
|
||||||
import type { GraphStore } from "../../core/ports.ts";
|
import type { GraphStore } from "../../core/ports.ts";
|
||||||
|
|
||||||
/** How many member propositions the namer sees per cluster — enough to
|
/** How many member propositions the namer sees per cluster — enough to
|
||||||
@@ -138,14 +133,17 @@ async function doRefresh(deps: {
|
|||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
}): Promise<ClusterRefreshStats> {
|
}): Promise<ClusterRefreshStats> {
|
||||||
const { graphStore, engine, signal } = deps;
|
const { graphStore, engine, signal } = deps;
|
||||||
const { nodes, edges } = getGraph(graphStore);
|
const { nodes } = getGraph(graphStore);
|
||||||
const detection = detectCommunities(nodes, edges);
|
// Semantic topics: the partition the embedder persisted in the
|
||||||
|
// embedding ledger (same source the snapshot renders — never
|
||||||
|
// recomputed here; the agglomerative pass is ~1.5s at scale).
|
||||||
|
const communities = graphStore.readEmbeddings().clusters;
|
||||||
const labelings = graphStore.readClusterLabels();
|
const labelings = graphStore.readClusterLabels();
|
||||||
const claims = matchClusterLabelings(detection.communities, labelings);
|
const claims = matchClusterLabelings(communities, labelings);
|
||||||
const byId = new Map(nodes.map((n) => [n.id, n]));
|
const byId = new Map(nodes.map((n) => [n.id, n]));
|
||||||
|
|
||||||
const claimed = claims.filter((c) => c !== null).length;
|
const claimed = claims.filter((c) => c !== null).length;
|
||||||
const unnamed = detection.communities
|
const unnamed = communities
|
||||||
.filter((c, i) => !claims[i] && c.members.length >= MIN_CLUSTER_SIZE)
|
.filter((c, i) => !claims[i] && c.members.length >= MIN_CLUSTER_SIZE)
|
||||||
.map((c: Community, i) => {
|
.map((c: Community, i) => {
|
||||||
const nodesFor = c.members
|
const nodesFor = c.members
|
||||||
@@ -156,7 +154,7 @@ async function doRefresh(deps: {
|
|||||||
return { key: `c${i}`, members: c.members, nodes: nodesFor };
|
return { key: `c${i}`, members: c.members, nodes: nodesFor };
|
||||||
});
|
});
|
||||||
|
|
||||||
if (unnamed.length === 0) return { detected: detection.communities.length, claimed, named: 0, appended: 0 };
|
if (unnamed.length === 0) return { detected: communities.length, claimed, named: 0, appended: 0 };
|
||||||
|
|
||||||
const labels = await nameClusters(engine, signal, unnamed);
|
const labels = await nameClusters(engine, signal, unnamed);
|
||||||
let appended = 0;
|
let appended = 0;
|
||||||
@@ -174,7 +172,7 @@ async function doRefresh(deps: {
|
|||||||
});
|
});
|
||||||
appended++;
|
appended++;
|
||||||
}
|
}
|
||||||
return { detected: detection.communities.length, claimed, named: unnamed.length, appended };
|
return { detected: communities.length, claimed, named: unnamed.length, appended };
|
||||||
}
|
}
|
||||||
|
|
||||||
let namingTail: Promise<unknown> = Promise.resolve();
|
let namingTail: Promise<unknown> = Promise.resolve();
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type {
|
|||||||
SessionFindings,
|
SessionFindings,
|
||||||
} from "../../core/contracts.ts";
|
} from "../../core/contracts.ts";
|
||||||
import { getGraph, mergeFindings, type EmissionStats, type GraphDeps } from "../../core/graph.ts";
|
import { getGraph, mergeFindings, type EmissionStats, type GraphDeps } from "../../core/graph.ts";
|
||||||
|
import type { GraphStore } from "../../core/ports.ts";
|
||||||
import { settleGraphLayout } from "../../core/layout-settle.ts";
|
import { settleGraphLayout } from "../../core/layout-settle.ts";
|
||||||
import { refreshClusterLabels } from "./cluster-namer.ts";
|
import { refreshClusterLabels } from "./cluster-namer.ts";
|
||||||
|
|
||||||
@@ -554,7 +555,14 @@ export interface ExtractionModels {
|
|||||||
labeler?: PiEngine;
|
labeler?: PiEngine;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExtractionPipelineDeps extends GraphDeps, ExtractionModels {}
|
export interface ExtractionPipelineDeps extends GraphDeps, ExtractionModels {
|
||||||
|
/** The post-merge embedding batch (semantic clusters run on it).
|
||||||
|
* INJECTED by the composition root (the agent layer never imports
|
||||||
|
* the server layer — architecture law): production passes the real
|
||||||
|
* local-model embedder (server/embeddings.ts embedNewNodes), tests
|
||||||
|
* fake it. Failure inside it is non-fatal by contract. */
|
||||||
|
embedder: (graphStore: GraphStore) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface NotesImportInput {
|
export interface NotesImportInput {
|
||||||
/** The source's file path — its identity (deterministic src id) AND its
|
/** The source's file path — its identity (deterministic src id) AND its
|
||||||
@@ -576,7 +584,7 @@ export interface ExtractionPipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createExtractionPipeline(deps: ExtractionPipelineDeps): ExtractionPipeline {
|
export function createExtractionPipeline(deps: ExtractionPipelineDeps): ExtractionPipeline {
|
||||||
const { chatStore, graphStore } = deps;
|
const { chatStore, graphStore, embedder: embed } = deps;
|
||||||
const inFlight = new Set<string>();
|
const inFlight = new Set<string>();
|
||||||
|
|
||||||
function setPhase(chatId: string, phase: ExtractionPhase, kind: "chat" | "notes", label?: string): void {
|
function setPhase(chatId: string, phase: ExtractionPhase, kind: "chat" | "notes", label?: string): void {
|
||||||
@@ -715,6 +723,13 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
|||||||
messages: record.messages as UIMessage[],
|
messages: record.messages as UIMessage[],
|
||||||
foldedAt: record.updatedAt,
|
foldedAt: record.updatedAt,
|
||||||
});
|
});
|
||||||
|
// Semantic embeddings (DECIDED 2026-09-13): topics come from
|
||||||
|
// MEANING, so embed the new propositions BEFORE the settle and
|
||||||
|
// the namer look at clusters. Failure is non-fatal — clustering
|
||||||
|
// runs without the new nodes until the next merge retries.
|
||||||
|
await embed(graphStore).catch((err) => {
|
||||||
|
console.error(`[extraction ${chatId}] embedding failed (non-fatal):`, err);
|
||||||
|
});
|
||||||
// Option A: the layout ledger evolves with the merge (sync — the
|
// Option A: the layout ledger evolves with the merge (sync — the
|
||||||
// next snapshot fetch must already reflect the new nodes).
|
// next snapshot fetch must already reflect the new nodes).
|
||||||
settleGraphLayout(graphStore);
|
settleGraphLayout(graphStore);
|
||||||
@@ -828,6 +843,11 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
|||||||
// (messages: null) — nothing to attribute, nothing to build.
|
// (messages: null) — nothing to attribute, nothing to build.
|
||||||
const auditResult: AuditResult = { ...notesResult, attribution: { quiz: {} } };
|
const auditResult: AuditResult = { ...notesResult, attribution: { quiz: {} } };
|
||||||
const stats = mergeFindings(id, auditResult, { chatStore, graphStore }, { messages: null, foldedAt: input.importedAt });
|
const stats = mergeFindings(id, auditResult, { chatStore, graphStore }, { messages: null, foldedAt: input.importedAt });
|
||||||
|
// Same post-merge chain as the chat path: embeddings first
|
||||||
|
// (topics from meaning), then the settle, then the namer.
|
||||||
|
await embed(graphStore).catch((err) => {
|
||||||
|
console.error(`[extraction ${id}] embedding failed (non-fatal):`, err);
|
||||||
|
});
|
||||||
// Option A: same post-merge settle as the chat path.
|
// Option A: same post-merge settle as the chat path.
|
||||||
settleGraphLayout(graphStore);
|
settleGraphLayout(graphStore);
|
||||||
persistRunUsage(id, Math.round(performance.now() - t0), stats);
|
persistRunUsage(id, Math.round(performance.now() - t0), stats);
|
||||||
|
|||||||
+12
-108
@@ -1,31 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* COMMUNITY DETECTION for the knowledge graph (vision §1, DECIDED
|
* CLUSTER LABEL MATCHING (vision §1). DETECTION moved to semantic topic
|
||||||
* 2026-09-13): Louvain modularity over the undirected, unweighted
|
* clustering over embeddings (core/semantic-clusters.ts, DECIDED
|
||||||
* follows+supports structure — purely structural, so it never depends on
|
* 2026-09-13) — Louvain over the edge structure is retired: it only ever
|
||||||
* layout, and the extraction pipeline never knows clusters exist.
|
* found the reasoning chains (the graph is disconnected by nature), and
|
||||||
|
* edge-orphans could never join anything. What REMAINS here is the
|
||||||
|
* cluster contract + core-survival label matching, which is
|
||||||
|
* detection-agnostic: it consumes the same { members } shape semantic
|
||||||
|
* clustering emits.
|
||||||
*
|
*
|
||||||
* Determinism: Louvain's random-walk traversal is driven by a SEEDED
|
* Labelings key to sorted MEMBER SETS, never to detection indices —
|
||||||
* mulberry32 RNG (default seed constant below), so the same graph always
|
* a labeling record outlives any reshuffle of the partition.
|
||||||
* 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
|
* Browser-safe pure logic (no node builtins, no fs): the server runs
|
||||||
* equally real relations between propositions — direction is
|
* matching for the snapshot payload, tests run it directly.
|
||||||
* 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";
|
|
||||||
import type { ClusterLabeling } from "./contracts.ts";
|
import type { ClusterLabeling } from "./contracts.ts";
|
||||||
|
|
||||||
/** A detected community. Members are SORTED node ids — the member set IS
|
/** A detected community. Members are SORTED node ids — the member set IS
|
||||||
@@ -39,90 +27,6 @@ export interface Community {
|
|||||||
* least three propositions to be worth a hull and a label. */
|
* least three propositions to be worth a hull and a label. */
|
||||||
export const MIN_CLUSTER_SIZE = 3;
|
export const MIN_CLUSTER_SIZE = 3;
|
||||||
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Claim labeling records for detected communities — CORE-SURVIVAL (DECIDED
|
* Claim labeling records for detected communities — CORE-SURVIVAL (DECIDED
|
||||||
* 2026-09-13): a community keeps a record's label while AT LEAST HALF of
|
* 2026-09-13): a community keeps a record's label while AT LEAST HALF of
|
||||||
|
|||||||
@@ -278,6 +278,26 @@ export interface AuditResult {
|
|||||||
* least half its core survives in some single community (core-survival;
|
* least half its core survives in some single community (core-survival;
|
||||||
* under half → the community is renamed, splits can keep only one
|
* under half → the community is renamed, splits can keep only one
|
||||||
* successor). */
|
* successor). */
|
||||||
|
/** The embedding ledger shape (config.EMBEDDINGS_FILE): vectors for
|
||||||
|
* EVERY embedded node, all produced by one model at one dimensionality. */
|
||||||
|
export interface EmbeddingsLedger {
|
||||||
|
/** The embedding model id (e.g. the HF repo) — mismatch = stale
|
||||||
|
* vectors, the writer re-embeds everything. */
|
||||||
|
model: string;
|
||||||
|
/** Vector dimensionality (MRL-truncated on write). */
|
||||||
|
dim: number;
|
||||||
|
/** nodeId → L2-normalized vector (cosine similarity = dot product). */
|
||||||
|
vectors: Record<string, number[]>;
|
||||||
|
/** The topic partition over those vectors, computed by the SAME
|
||||||
|
* embedder run (the user-decided trigger: recluster at the end of
|
||||||
|
* every embedding batch, never per read — the agglomerative pass is
|
||||||
|
* ~1.5s at current scale). Consumers (snapshot, settle, namer) read
|
||||||
|
* this instead of recomputing. Empty when nothing is embedded. */
|
||||||
|
clusters: Array<{ members: string[] }>;
|
||||||
|
/** ISO timestamp of the last write. */
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ClusterLabeling {
|
export interface ClusterLabeling {
|
||||||
/** "clu_" + content hash of the sorted core. */
|
/** "clu_" + content hash of the sorted core. */
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
+13
-9
@@ -51,7 +51,7 @@ import type {
|
|||||||
LedgerEvent,
|
LedgerEvent,
|
||||||
LedgerEventKind,
|
LedgerEventKind,
|
||||||
} from "./contracts.ts";
|
} from "./contracts.ts";
|
||||||
import { detectCommunities, matchClusterLabelings, MIN_CLUSTER_SIZE } from "./clusters.ts";
|
import { matchClusterLabelings, MIN_CLUSTER_SIZE } from "./clusters.ts";
|
||||||
import type { QuestionAnswerData } from "./question-core.ts";
|
import type { QuestionAnswerData } from "./question-core.ts";
|
||||||
import type { ChatStore, GraphStore } from "./ports.ts";
|
import type { ChatStore, GraphStore } from "./ports.ts";
|
||||||
|
|
||||||
@@ -497,14 +497,18 @@ export function graphSnapshot(deps: GraphDeps): GraphSnapshot {
|
|||||||
(nodeObservations[nodeId] ??= {})[chatId] = note;
|
(nodeObservations[nodeId] ??= {})[chatId] = note;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Structural communities (vision §1): Louvain over the undirected
|
// Semantic topic communities (DECIDED 2026-09-13, replacing structural
|
||||||
// follows+supports structure, joined with persisted labelings —
|
// Louvain): average-linkage clustering over embeddings — related
|
||||||
// core-survival matching, size-filtered at the presentation threshold.
|
// topics, not reasoning chains. Edge-orphans join topics by meaning;
|
||||||
// Detection is DETERMINISTIC (seeded), so no memo in v1: a snapshot
|
// stragglers stay loose (filtered at the presentation threshold
|
||||||
// read recomputes it (ms at current scale; revisit at vault scale).
|
// below). The partition is computed ONCE per embedding batch by the
|
||||||
const detection = detectCommunities(nodes, edges);
|
// embedder (the agglomerative pass is ~1.5s at current scale) and
|
||||||
const claims = matchClusterLabelings(detection.communities, deps.graphStore.readClusterLabels());
|
// PERSISTED in the embedding ledger — reads just load it. Nodes whose
|
||||||
const clusters: Cluster[] = detection.communities
|
// embedding hasn't landed yet simply sit unclustered until the next
|
||||||
|
// read after the embedder runs.
|
||||||
|
const communities = deps.graphStore.readEmbeddings().clusters;
|
||||||
|
const claims = matchClusterLabelings(communities, deps.graphStore.readClusterLabels());
|
||||||
|
const clusters: Cluster[] = communities
|
||||||
.map((community, index) => ({ community, claim: claims[index] }))
|
.map((community, index) => ({ community, claim: claims[index] }))
|
||||||
.filter(({ community }) => community.members.length >= MIN_CLUSTER_SIZE)
|
.filter(({ community }) => community.members.length >= MIN_CLUSTER_SIZE)
|
||||||
.map(({ community, claim }) => ({
|
.map(({ community, claim }) => ({
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ export * from "./render-settings.ts";
|
|||||||
export * from "./question-core.ts";
|
export * from "./question-core.ts";
|
||||||
// clusters.ts (Louvain) is deliberately NOT re-exported: nothing in the
|
// clusters.ts (Louvain) is deliberately NOT re-exported: nothing in the
|
||||||
// shell runs community detection — the snapshot carries the partition
|
// shell runs community detection — the snapshot carries the partition
|
||||||
// from the server. Keeping it out keeps graphology-louvain out of the
|
// from the server. Keeping it out keeps the heavy clustering deps out of the
|
||||||
// client bundle (verified 2026-09-13: chunks contained it while this
|
// client bundle (verified 2026-09-13: chunks contained it while this
|
||||||
// line existed). Same logic as layout-settle.ts below.
|
// line existed). Same logic as layout-settle.ts below.
|
||||||
export * from "./layout.ts";
|
export * from "./layout.ts";
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { GraphEdge, GraphNode } from "./contracts.ts";
|
import type { EmbeddingsLedger, GraphEdge, GraphNode } from "./contracts.ts";
|
||||||
import { detectCommunities } from "./clusters.ts";
|
|
||||||
import { incrementalLayout } from "./layout.ts";
|
import { incrementalLayout } from "./layout.ts";
|
||||||
import type { Position } from "./contracts.ts";
|
import type { Position } from "./contracts.ts";
|
||||||
|
|
||||||
@@ -13,7 +12,7 @@ import type { Position } from "./contracts.ts";
|
|||||||
*
|
*
|
||||||
* SPLIT FROM layout.ts deliberately (2026-09-13): layout.ts is in the
|
* SPLIT FROM layout.ts deliberately (2026-09-13): layout.ts is in the
|
||||||
* browser barrel, and the settle needs the Louvain partition for the
|
* browser barrel, and the settle needs the Louvain partition for the
|
||||||
* cluster centroid pull — graphology-louvain must not ship in the client
|
* cluster centroid pull — the heavy deps must not ship in the client
|
||||||
* bundle. Server code imports this module DIRECTLY; it is never
|
* bundle. Server code imports this module DIRECTLY; it is never
|
||||||
* re-exported through src/core/index.ts.
|
* re-exported through src/core/index.ts.
|
||||||
*/
|
*/
|
||||||
@@ -22,6 +21,7 @@ export function settleGraphLayout(graphStore: {
|
|||||||
readEdges(): GraphEdge[];
|
readEdges(): GraphEdge[];
|
||||||
readPositions(): Record<string, Position>;
|
readPositions(): Record<string, Position>;
|
||||||
writePositions(positions: Record<string, Position>): void;
|
writePositions(positions: Record<string, Position>): void;
|
||||||
|
readEmbeddings(): EmbeddingsLedger;
|
||||||
}): void {
|
}): void {
|
||||||
const nodes = graphStore.readNodes();
|
const nodes = graphStore.readNodes();
|
||||||
if (nodes.length === 0) return;
|
if (nodes.length === 0) return;
|
||||||
@@ -34,10 +34,11 @@ export function settleGraphLayout(graphStore: {
|
|||||||
});
|
});
|
||||||
const edges = graphStore.readEdges();
|
const edges = graphStore.readEdges();
|
||||||
const previous = new Map(Object.entries(graphStore.readPositions()));
|
const previous = new Map(Object.entries(graphStore.readPositions()));
|
||||||
// The cluster partition for the centroid pull: same Louvain the
|
// The cluster partition for the centroid pull: the SAME persisted
|
||||||
// snapshot uses (same nodes/edges = same communities). 338 nodes:
|
// semantic topics the snapshot renders (computed by the embedder
|
||||||
// ~1-2ms, noise next to the settle itself.
|
// once per batch, stored in the embedding ledger) — one cluster
|
||||||
const { communities } = detectCommunities(unique, edges);
|
// concept everywhere.
|
||||||
|
const communities = graphStore.readEmbeddings().clusters;
|
||||||
const next = incrementalLayout(unique, edges, previous, { clusters: communities });
|
const next = incrementalLayout(unique, edges, previous, { clusters: communities });
|
||||||
graphStore.writePositions(Object.fromEntries(next));
|
graphStore.writePositions(Object.fromEntries(next));
|
||||||
}
|
}
|
||||||
+1
-1
@@ -386,7 +386,7 @@ export interface IncrementalLayoutOptions {
|
|||||||
gravityStrength?: number;
|
gravityStrength?: number;
|
||||||
/** Cluster partition (Louvain output shape). Supplied by callers so
|
/** Cluster partition (Louvain output shape). Supplied by callers so
|
||||||
* this module stays louvain-free (the browser bundle must not carry
|
* this module stays louvain-free (the browser bundle must not carry
|
||||||
* graphology-louvain); absent = no cluster pull. */
|
* embedding ledger; absent = no cluster pull. */
|
||||||
clusters?: ReadonlyArray<{ members: ReadonlyArray<string> }>;
|
clusters?: ReadonlyArray<{ members: ReadonlyArray<string> }>;
|
||||||
/** Cluster centroid pull strength (default CLUSTER_PULL_STRENGTH). */
|
/** Cluster centroid pull strength (default CLUSTER_PULL_STRENGTH). */
|
||||||
clusterPullStrength?: number;
|
clusterPullStrength?: number;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import type {
|
|||||||
TurnUsage,
|
TurnUsage,
|
||||||
NotesSource,
|
NotesSource,
|
||||||
ClusterLabeling,
|
ClusterLabeling,
|
||||||
|
EmbeddingsLedger,
|
||||||
} from "./contracts.ts";
|
} from "./contracts.ts";
|
||||||
|
|
||||||
// ─── ChatStore — the session record's home (data/chats) ─────────────────────
|
// ─── ChatStore — the session record's home (data/chats) ─────────────────────
|
||||||
@@ -104,6 +105,13 @@ export interface GraphStore {
|
|||||||
readPositions(): Record<string, Position>;
|
readPositions(): Record<string, Position>;
|
||||||
/** Replace the whole ledger (the settle is a full-graph computation). */
|
/** Replace the whole ledger (the settle is a full-graph computation). */
|
||||||
writePositions(positions: Record<string, Position>): void;
|
writePositions(positions: Record<string, Position>): void;
|
||||||
|
|
||||||
|
/** The embedding ledger (config.EMBEDDINGS_FILE). WRITTEN ONLY by
|
||||||
|
* the embedder (server/embeddings.ts) after a merge — read-only for
|
||||||
|
* clustering consumers (snapshot, settle, namer). */
|
||||||
|
readEmbeddings(): EmbeddingsLedger;
|
||||||
|
/** Replace the whole ledger (embedding is a full-batch computation). */
|
||||||
|
writeEmbeddings(ledger: EmbeddingsLedger): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── JobStore — background sub-agent jobs (data/jobs) ─────────────────────────
|
// ─── JobStore — background sub-agent jobs (data/jobs) ─────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
// Semantic topic clustering — the REPLACEMENT for Louvain community
|
||||||
|
// detection (DECIDED 2026-09-13): structural clusters were just the
|
||||||
|
// reasoning chains (the graph is disconnected by nature — every connected
|
||||||
|
// component became its own "cluster"), and edge-orphans could never join
|
||||||
|
// anything. Clusters are now RELATED TOPICS: nodes whose proposition
|
||||||
|
// MEANING is close, computed over the embedding ledger
|
||||||
|
// (config.EMBEDDINGS_FILE). This pulls orphans into topics by meaning
|
||||||
|
// and groups small chains with the large chains they discuss.
|
||||||
|
//
|
||||||
|
// Algorithm: threshold AGGLOMERATIVE (average linkage over cosine
|
||||||
|
// similarity). No JS HDBSCAN is trustworthy (the npm package is an
|
||||||
|
// abandoned alpha), and at proposition scale (~100s-1000s of short texts)
|
||||||
|
// brute-force pairwise cosine is sub-second — so the honest ~60-line
|
||||||
|
// hand-roll wins. "Stragglers" (nodes that fit nothing) fall out
|
||||||
|
// naturally: they simply never merge, and stay singletons.
|
||||||
|
|
||||||
|
/** The default similarity threshold, SWEPT ON THE REAL GRAPH
|
||||||
|
* (2026-09-13): 0.60 → 26 topics of ~20 propositions, 74 of the 90
|
||||||
|
* edge-orphans land in real topics, stragglers 10. Higher (0.65-0.68)
|
||||||
|
* gives tighter micro-topics with far more stragglers; lower (0.55)
|
||||||
|
* collapses into ~13 mega-topics. */
|
||||||
|
export const SEMANTIC_SIM_THRESHOLD = 0.6;
|
||||||
|
|
||||||
|
/** Cosine similarity of two vectors (defensively normalizes inputs;
|
||||||
|
* ledger vectors are already L2-normalized, so this is a dot product). */
|
||||||
|
export function cosineSimilarity(a: readonly number[], b: readonly number[]): number {
|
||||||
|
let dot = 0;
|
||||||
|
let ma = 0;
|
||||||
|
let mb = 0;
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
dot += a[i]! * b[i]!;
|
||||||
|
ma += a[i]! * a[i]!;
|
||||||
|
mb += b[i]! * b[i]!;
|
||||||
|
}
|
||||||
|
if (ma === 0 || mb === 0) return 0;
|
||||||
|
return dot / (Math.sqrt(ma) * Math.sqrt(mb));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SemanticClusterOptions {
|
||||||
|
/** Merge while the best cluster pair's average-linkage similarity is
|
||||||
|
* >= this (default SEMANTIC_SIM_THRESHOLD). */
|
||||||
|
similarityThreshold?: number;
|
||||||
|
/** After the cut, force every singleton into its nearest topic
|
||||||
|
* ("all-in" mode) instead of leaving stragglers loose. Default
|
||||||
|
* false: some propositions genuinely fit nothing. */
|
||||||
|
attachStragglers?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Average-linkage agglomerative clustering over embedding vectors.
|
||||||
|
* Pure and deterministic (stable Map iteration, greedy by best
|
||||||
|
* similarity, first-found wins ties). Returns clusters size-desc — the
|
||||||
|
* same { members } shape Louvain produced, so label matching and the
|
||||||
|
* renderer contract are unchanged. An empty/missing ledger returns []
|
||||||
|
* (nothing embedded yet: no clusters, every node loose — the snapshot
|
||||||
|
* degrades gracefully until the first embed). */
|
||||||
|
export function semanticClusters(
|
||||||
|
vectors: Readonly<Record<string, readonly number[]>>,
|
||||||
|
options: SemanticClusterOptions = {},
|
||||||
|
): Array<{ members: string[] }> {
|
||||||
|
const ids = Object.keys(vectors);
|
||||||
|
if (ids.length === 0) return [];
|
||||||
|
const simThreshold = options.similarityThreshold ?? SEMANTIC_SIM_THRESHOLD;
|
||||||
|
const attach = options.attachStragglers ?? false;
|
||||||
|
|
||||||
|
// Sum vectors per cluster: average-linkage similarity between clusters
|
||||||
|
// A and B is dot(sumA, sumB) / (|A|·|B|) — the exact mean of pairwise
|
||||||
|
// dots — so the merge loop never revisits the raw O(n²) matrix.
|
||||||
|
const sums = new Map<number, number[]>();
|
||||||
|
const members = new Map<number, string[]>();
|
||||||
|
ids.forEach((id, i) => {
|
||||||
|
sums.set(i, [...vectors[id]!]);
|
||||||
|
members.set(i, [id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Greedy merge: repeatedly join the pair with the highest
|
||||||
|
// average-linkage similarity until it drops below the threshold.
|
||||||
|
// O(k²) pair scan per merge — trivial at proposition scale.
|
||||||
|
for (;;) {
|
||||||
|
let bestA = -1;
|
||||||
|
let bestB = -1;
|
||||||
|
let bestSim = -Infinity;
|
||||||
|
for (const [a, sumA] of sums) {
|
||||||
|
for (const [b, sumB] of sums) {
|
||||||
|
if (a >= b) continue;
|
||||||
|
let dot = 0;
|
||||||
|
for (let i = 0; i < sumA.length; i++) dot += sumA[i]! * sumB[i]!;
|
||||||
|
const sim = dot / (members.get(a)!.length * members.get(b)!.length);
|
||||||
|
if (sim > bestSim) {
|
||||||
|
bestSim = sim;
|
||||||
|
bestA = a;
|
||||||
|
bestB = b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bestA < 0 || bestSim < simThreshold) break;
|
||||||
|
// Merge B into A (deterministic direction: lower cluster index).
|
||||||
|
const sumA = sums.get(bestA)!;
|
||||||
|
const sumB = sums.get(bestB)!;
|
||||||
|
for (let i = 0; i < sumA.length; i++) sumA[i] = sumA[i]! + sumB[i]!;
|
||||||
|
members.get(bestA)!.push(...members.get(bestB)!);
|
||||||
|
sums.delete(bestB);
|
||||||
|
members.delete(bestB);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional all-in pass: singletons join their nearest topic regardless
|
||||||
|
// of threshold — exactly the "forced membership" mode. The difference
|
||||||
|
// from stragglers mode is this one pass. Singleton-vs-cluster
|
||||||
|
// average-linkage similarity is dot(sumS, sumK) / |K|.
|
||||||
|
if (attach) {
|
||||||
|
const singletons = [...members.entries()].filter(([, m]) => m.length === 1).map(([k]) => k);
|
||||||
|
for (const s of singletons) {
|
||||||
|
if (!members.has(s)) continue; // absorbed by an earlier attach
|
||||||
|
let bestTarget = -1;
|
||||||
|
let bestSim = -Infinity;
|
||||||
|
for (const [k, m] of members) {
|
||||||
|
if (k === s || m.length < 2) continue;
|
||||||
|
let dot = 0;
|
||||||
|
const sumS = sums.get(s)!;
|
||||||
|
const sumK = sums.get(k)!;
|
||||||
|
for (let i = 0; i < sumS.length; i++) dot += sumS[i]! * sumK[i]!;
|
||||||
|
const sim = dot / m.length;
|
||||||
|
if (sim > bestSim) {
|
||||||
|
bestSim = sim;
|
||||||
|
bestTarget = k;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bestTarget >= 0) {
|
||||||
|
const sumT = sums.get(bestTarget)!;
|
||||||
|
const sumS = sums.get(s)!;
|
||||||
|
for (let i = 0; i < sumT.length; i++) sumT[i] = sumT[i]! + sumS[i]!;
|
||||||
|
members.get(bestTarget)!.push(...members.get(s)!);
|
||||||
|
sums.delete(s);
|
||||||
|
members.delete(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...members.values()]
|
||||||
|
.map((m) => ({ members: [...m].sort() }))
|
||||||
|
.sort((a, b) => b.members.length - a.members.length || (a.members[0] ?? "").localeCompare(b.members[0] ?? ""));
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { createProberAgent, createTeacherAgent, type TeachingAgent } from "../ag
|
|||||||
import { piEngineFor } from "../agent/models.ts";
|
import { piEngineFor } from "../agent/models.ts";
|
||||||
import { createJobRunner } from "../agent/subagents/runner.ts";
|
import { createJobRunner } from "../agent/subagents/runner.ts";
|
||||||
import { createExtractionPipeline } from "../agent/subagents/extraction.ts";
|
import { createExtractionPipeline } from "../agent/subagents/extraction.ts";
|
||||||
|
import { embedNewNodes } from "./embeddings.ts";
|
||||||
import type { ModelRole } from "../../config.ts";
|
import type { ModelRole } from "../../config.ts";
|
||||||
import { claimSteers, deliverJobResult, setJobRespawn, setTurnRunner } from "./jobs.ts";
|
import { claimSteers, deliverJobResult, setJobRespawn, setTurnRunner } from "./jobs.ts";
|
||||||
import { recordToPiMessages } from "./pi/record-adapter.ts";
|
import { recordToPiMessages } from "./pi/record-adapter.ts";
|
||||||
@@ -40,7 +41,12 @@ const jobRunner = createJobRunner({
|
|||||||
/** The app's ONE extraction pipeline instance — both front doors share it:
|
/** The app's ONE extraction pipeline instance — both front doors share it:
|
||||||
* quiz-triggered session runs (turn-service) and notes imports
|
* quiz-triggered session runs (turn-service) and notes imports
|
||||||
* (/api/graph/import) hit the same single-flight registry. */
|
* (/api/graph/import) hit the same single-flight registry. */
|
||||||
export const extraction = createExtractionPipeline(stores);
|
export const extraction = createExtractionPipeline({
|
||||||
|
...stores,
|
||||||
|
// The REAL embedder (local transformer model): post-merge embedding
|
||||||
|
// batch — semantic clusters are computed and persisted here.
|
||||||
|
embedder: embedNewNodes,
|
||||||
|
});
|
||||||
|
|
||||||
// Wake-pipeline wiring (contracts.ts "THE ASYNC PROTOCOL" §4): the pipeline
|
// Wake-pipeline wiring (contracts.ts "THE ASYNC PROTOCOL" §4): the pipeline
|
||||||
// fires its server-side teacher turns through THIS composition root, and
|
// fires its server-side teacher turns through THIS composition root, and
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* THE EMBEDDER (DECIDED 2026-09-13): semantic topic clustering needs
|
||||||
|
* vectors, and vectors need a model running ON the user's machine — no
|
||||||
|
* external services, no API bills, no network dependency for re-embedding.
|
||||||
|
*
|
||||||
|
* Runtime: in-process transformers.js (@huggingface/transformers v4 over
|
||||||
|
* onnxruntime-node — native Apple Silicon acceleration). The model
|
||||||
|
* weights download ONCE to the local HF cache on first run (~300MB).
|
||||||
|
*
|
||||||
|
* Model: EmbeddingGemma-300M (q8) — best quality-per-megabyte small
|
||||||
|
* embedding model as of late 2025 (MTEB-en 68.36), built for on-device
|
||||||
|
* use, with MRL truncation so we store 512 of its 768 dims. Every input
|
||||||
|
* uses the model card's document format ("title: none | text: …"),
|
||||||
|
* consistently — mixing prefixed and unprefixed inputs degrades quality.
|
||||||
|
*
|
||||||
|
* Model swaps re-embed EVERYTHING: vectors from different models live in
|
||||||
|
* incompatible spaces. The ledger records model + dim; a mismatch marks
|
||||||
|
* the whole ledger stale and this module re-embeds from scratch (minutes
|
||||||
|
* at current scale).
|
||||||
|
*/
|
||||||
|
import { pipeline, type FeatureExtractionPipeline } from "@huggingface/transformers";
|
||||||
|
import type { EmbeddingsLedger, GraphNode } from "../core/contracts.ts";
|
||||||
|
import { semanticClusters, SEMANTIC_SIM_THRESHOLD } from "../core/semantic-clusters.ts";
|
||||||
|
|
||||||
|
export const EMBEDDING_MODEL = "onnx-community/embeddinggemma-300m-ONNX";
|
||||||
|
/** MRL truncation of the native 768 — negligible quality loss, half the
|
||||||
|
* ledger size (2026-09-13 researcher sweep). */
|
||||||
|
export const EMBEDDING_DIM = 512;
|
||||||
|
/** Batch size bounds peak memory; propositions are tiny so 32 per call
|
||||||
|
* keeps the pipeline warm without thrashing. */
|
||||||
|
const EMBED_BATCH = 32;
|
||||||
|
|
||||||
|
let pipelinePromise: Promise<FeatureExtractionPipeline> | null = null;
|
||||||
|
|
||||||
|
function getPipeline(): Promise<FeatureExtractionPipeline> {
|
||||||
|
pipelinePromise ??= pipeline("feature-extraction", EMBEDDING_MODEL, { dtype: "q8" });
|
||||||
|
return pipelinePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** EmbeddingGemma's required document format (model card). */
|
||||||
|
function embedInput(statement: string): string {
|
||||||
|
return `title: none | text: ${statement}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Truncate an L2-normalized vector to EMBEDDING_DIM (MRL: leading dims
|
||||||
|
* carry most of the signal) and re-normalize so cosine = dot product. */
|
||||||
|
function truncateNormalize(vec: readonly number[]): number[] {
|
||||||
|
const out = vec.slice(0, EMBEDDING_DIM);
|
||||||
|
let mag = 0;
|
||||||
|
for (const x of out) mag += x * x;
|
||||||
|
mag = Math.sqrt(mag);
|
||||||
|
return mag > 0 ? out.map((x) => x / mag) : out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Embed every node the ledger is missing — the post-merge hook (new
|
||||||
|
* propositions only, typically a handful) AND the cold-start backfill
|
||||||
|
* (empty ledger = every node) in one function. Model/dim mismatch voids
|
||||||
|
* the ledger first (full re-embed). Also prunes vectors for nodes that
|
||||||
|
* no longer exist. Writes the whole ledger and returns counts for logs.
|
||||||
|
* Non-fatal by contract: the caller treats failure as "clustering runs
|
||||||
|
* without the new nodes until the next merge retries".
|
||||||
|
*/
|
||||||
|
export async function embedNewNodes(
|
||||||
|
graphStore: {
|
||||||
|
readNodes(): GraphNode[];
|
||||||
|
readEmbeddings(): EmbeddingsLedger;
|
||||||
|
writeEmbeddings(ledger: EmbeddingsLedger): void;
|
||||||
|
},
|
||||||
|
): Promise<{ embedded: number; total: number }> {
|
||||||
|
const ledger = graphStore.readEmbeddings();
|
||||||
|
const stale = ledger.model !== EMBEDDING_MODEL || ledger.dim !== EMBEDDING_DIM;
|
||||||
|
const vectors: Record<string, number[]> = stale ? {} : { ...ledger.vectors };
|
||||||
|
|
||||||
|
// Node LINES may repeat an id — dedupe, first defines (getGraph rule).
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const todo: GraphNode[] = [];
|
||||||
|
for (const node of graphStore.readNodes()) {
|
||||||
|
if (seen.has(node.id)) continue;
|
||||||
|
seen.add(node.id);
|
||||||
|
if (!vectors[node.id]) todo.push(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (todo.length > 0) {
|
||||||
|
const extractor = await getPipeline();
|
||||||
|
for (let i = 0; i < todo.length; i += EMBED_BATCH) {
|
||||||
|
const batch = todo.slice(i, i + EMBED_BATCH);
|
||||||
|
const output = await extractor(batch.map((n) => embedInput(n.statement)), {
|
||||||
|
pooling: "mean",
|
||||||
|
normalize: true,
|
||||||
|
});
|
||||||
|
const rows = output.tolist() as number[][];
|
||||||
|
for (let j = 0; j < batch.length; j++) {
|
||||||
|
vectors[batch[j]!.id] = truncateNormalize(rows[j]!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prune ghosts: vectors for node ids that no longer exist.
|
||||||
|
let pruned = 0;
|
||||||
|
for (const id of Object.keys(vectors)) {
|
||||||
|
if (!seen.has(id)) {
|
||||||
|
delete vectors[id];
|
||||||
|
pruned++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RECLUSTER AT THE END OF THE BATCH (user-decided 2026-09-13): the
|
||||||
|
// agglomerative pass is ~1.5s at current scale, so it runs HERE —
|
||||||
|
// once per embedding batch — and consumers (snapshot, settle, namer)
|
||||||
|
// read the persisted partition instead of recomputing per read.
|
||||||
|
const clusters = semanticClusters(vectors, { similarityThreshold: SEMANTIC_SIM_THRESHOLD });
|
||||||
|
|
||||||
|
const wrote = todo.length > 0 || pruned > 0 || stale || ledger.clusters.length !== clusters.length;
|
||||||
|
if (wrote) {
|
||||||
|
graphStore.writeEmbeddings({
|
||||||
|
model: EMBEDDING_MODEL,
|
||||||
|
dim: EMBEDDING_DIM,
|
||||||
|
vectors,
|
||||||
|
clusters,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { embedded: todo.length, total: Object.keys(vectors).length };
|
||||||
|
}
|
||||||
+59
-1
@@ -29,9 +29,10 @@
|
|||||||
|
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { LEDGER_FILES, POSITIONS_FILE, SOURCES_FILE } from "../../config.ts";
|
import { EMBEDDINGS_FILE, LEDGER_FILES, POSITIONS_FILE, SOURCES_FILE } from "../../config.ts";
|
||||||
import type {
|
import type {
|
||||||
ClusterLabeling,
|
ClusterLabeling,
|
||||||
|
EmbeddingsLedger,
|
||||||
GraphEdge,
|
GraphEdge,
|
||||||
GraphNode,
|
GraphNode,
|
||||||
ExtractionRunUsage,
|
ExtractionRunUsage,
|
||||||
@@ -216,6 +217,12 @@ export const graphStore: GraphStore = {
|
|||||||
writePositions(positions: Record<string, Position>): void {
|
writePositions(positions: Record<string, Position>): void {
|
||||||
writePositionMap(positions);
|
writePositionMap(positions);
|
||||||
},
|
},
|
||||||
|
readEmbeddings(): EmbeddingsLedger {
|
||||||
|
return readEmbeddingsLedger();
|
||||||
|
},
|
||||||
|
writeEmbeddings(ledger: EmbeddingsLedger): void {
|
||||||
|
writeEmbeddingsLedger(ledger);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/** positions.json — { positions, updatedAt }. Read-modify-write, NOT
|
/** positions.json — { positions, updatedAt }. Read-modify-write, NOT
|
||||||
@@ -252,6 +259,57 @@ function writePositionMap(positions: Record<string, Position>): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** embeddings.json — the whole EmbeddingsLedger at once. Missing/corrupt
|
||||||
|
* file reads as an EMPTY ledger (model "") so consumers see "nothing
|
||||||
|
* embedded yet" rather than an error; the embedder backfills. */
|
||||||
|
function readEmbeddingsLedger(): EmbeddingsLedger {
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
raw = fs.readFileSync(EMBEDDINGS_FILE, "utf-8");
|
||||||
|
} catch {
|
||||||
|
return { model: "", dim: 0, vectors: {}, clusters: [], updatedAt: "" };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
if (!isRecord(parsed)) return { model: "", dim: 0, vectors: {}, clusters: [], updatedAt: "" };
|
||||||
|
const model = typeof parsed.model === "string" ? parsed.model : "";
|
||||||
|
const dim = typeof parsed.dim === "number" && Number.isFinite(parsed.dim) ? parsed.dim : 0;
|
||||||
|
const vectors: Record<string, number[]> = {};
|
||||||
|
if (isRecord(parsed.vectors)) {
|
||||||
|
for (const [id, value] of Object.entries(parsed.vectors)) {
|
||||||
|
if (
|
||||||
|
Array.isArray(value) &&
|
||||||
|
value.length > 0 &&
|
||||||
|
value.every((x) => typeof x === "number" && Number.isFinite(x))
|
||||||
|
) {
|
||||||
|
vectors[id] = value as number[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const clusters: Array<{ members: string[] }> = [];
|
||||||
|
if (Array.isArray(parsed.clusters)) {
|
||||||
|
for (const c of parsed.clusters) {
|
||||||
|
if (
|
||||||
|
isRecord(c) &&
|
||||||
|
Array.isArray(c.members) &&
|
||||||
|
c.members.length > 0 &&
|
||||||
|
c.members.every((m) => typeof m === "string")
|
||||||
|
) {
|
||||||
|
clusters.push({ members: [...(c.members as string[])] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { model, dim, vectors, clusters, updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : "" };
|
||||||
|
} catch {
|
||||||
|
return { model: "", dim: 0, vectors: {}, clusters: [], updatedAt: "" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeEmbeddingsLedger(ledger: EmbeddingsLedger): void {
|
||||||
|
fs.mkdirSync(path.dirname(EMBEDDINGS_FILE), { recursive: true });
|
||||||
|
fs.writeFileSync(EMBEDDINGS_FILE, JSON.stringify(ledger, null, "\t"), "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
const isPosition = (v: unknown): v is Position =>
|
const isPosition = (v: unknown): v is Position =>
|
||||||
isRecord(v) && typeof v.x === "number" && Number.isFinite(v.x) && typeof v.y === "number" && Number.isFinite(v.y);
|
isRecord(v) && typeof v.x === "number" && Number.isFinite(v.x) && typeof v.y === "number" && Number.isFinite(v.y);
|
||||||
|
|
||||||
|
|||||||
+1
-88
@@ -6,9 +6,8 @@
|
|||||||
*/
|
*/
|
||||||
import { strict as assert } from "node:assert";
|
import { strict as assert } from "node:assert";
|
||||||
import { describe, test } from "node:test";
|
import { describe, test } from "node:test";
|
||||||
import { detectCommunities, matchClusterLabelings, MIN_CLUSTER_SIZE } from "../src/core/clusters.ts";
|
import { matchClusterLabelings, MIN_CLUSTER_SIZE, type Community } from "../src/core/clusters.ts";
|
||||||
import { clusterHue } from "../components/cluster-geometry.ts";
|
import { clusterHue } from "../components/cluster-geometry.ts";
|
||||||
import type { Community } from "../src/core/clusters.ts";
|
|
||||||
import type { ClusterLabeling } from "../src/core/contracts.ts";
|
import type { ClusterLabeling } from "../src/core/contracts.ts";
|
||||||
|
|
||||||
const ids = (n: number, prefix = "n") => [...Array(n).keys()].map((i) => `${prefix}${i}`);
|
const ids = (n: number, prefix = "n") => [...Array(n).keys()].map((i) => `${prefix}${i}`);
|
||||||
@@ -84,89 +83,3 @@ describe("clusterHue", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import type {
|
|||||||
import type { GraphStore } from "../src/core/ports.ts";
|
import type { GraphStore } from "../src/core/ports.ts";
|
||||||
import { abortExtraction, createExtractionPipeline, extractionStatuses, transcriptView } from "../src/agent/subagents/extraction.ts";
|
import { abortExtraction, createExtractionPipeline, extractionStatuses, transcriptView } from "../src/agent/subagents/extraction.ts";
|
||||||
import { clusterNamingSettled, refreshClusterLabels } from "../src/agent/subagents/cluster-namer.ts";
|
import { clusterNamingSettled, refreshClusterLabels } from "../src/agent/subagents/cluster-namer.ts";
|
||||||
|
import { semanticClusters } from "../src/core/semantic-clusters.ts";
|
||||||
import { chatStore } from "../src/store/chats.ts";
|
import { chatStore } from "../src/store/chats.ts";
|
||||||
import { PATHS } from "../config.ts";
|
import { PATHS } from "../config.ts";
|
||||||
|
|
||||||
@@ -67,6 +68,13 @@ function memoryLedgers() {
|
|||||||
const sources = new Map<string, NotesSource>();
|
const sources = new Map<string, NotesSource>();
|
||||||
const clusterLabels: ClusterLabeling[] = [];
|
const clusterLabels: ClusterLabeling[] = [];
|
||||||
const positions = new Map<string, { x: number; y: number }>();
|
const positions = new Map<string, { x: number; y: number }>();
|
||||||
|
const embeddings = {
|
||||||
|
model: "",
|
||||||
|
dim: 0,
|
||||||
|
updatedAt: "",
|
||||||
|
vectors: new Map<string, number[]>(),
|
||||||
|
clusters: new Array<Array<string>>(),
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
appendNode: (n: GraphNode) => void nodes.push(n),
|
appendNode: (n: GraphNode) => void nodes.push(n),
|
||||||
readNodes: () => nodes.map((n) => ({ ...n })),
|
readNodes: () => nodes.map((n) => ({ ...n })),
|
||||||
@@ -102,6 +110,16 @@ function memoryLedgers() {
|
|||||||
positions.clear();
|
positions.clear();
|
||||||
for (const [id, p] of Object.entries(next)) positions.set(id, { x: p.x, y: p.y });
|
for (const [id, p] of Object.entries(next)) positions.set(id, { x: p.x, y: p.y });
|
||||||
},
|
},
|
||||||
|
// The embedding ledger — the embedder writes it after a merge.
|
||||||
|
readEmbeddings: () => ({ model: embeddings.model, dim: embeddings.dim, vectors: Object.fromEntries([...embeddings.vectors].map(([id, v]) => [id, [...v]])), clusters: [...embeddings.clusters.map((c) => ({ members: [...c] }))], updatedAt: embeddings.updatedAt }),
|
||||||
|
writeEmbeddings: (next) => {
|
||||||
|
embeddings.model = next.model;
|
||||||
|
embeddings.dim = next.dim;
|
||||||
|
embeddings.updatedAt = next.updatedAt;
|
||||||
|
embeddings.vectors.clear();
|
||||||
|
for (const [id, v] of Object.entries(next.vectors)) embeddings.vectors.set(id, [...v]);
|
||||||
|
embeddings.clusters = next.clusters.map((c) => [...c.members]);
|
||||||
|
},
|
||||||
} satisfies GraphStore;
|
} satisfies GraphStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,6 +337,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
|||||||
const pipeline = createExtractionPipeline({
|
const pipeline = createExtractionPipeline({
|
||||||
chatStore,
|
chatStore,
|
||||||
graphStore: ledgers,
|
graphStore: ledgers,
|
||||||
|
embedder: fakeEmbedder,
|
||||||
extractor: submitModel("submit_findings", FINDINGS),
|
extractor: submitModel("submit_findings", FINDINGS),
|
||||||
auditor: submitModel("submit_review", verdict),
|
auditor: submitModel("submit_review", verdict),
|
||||||
labeler: submitModel("submit_labels", { labels: [] }),
|
labeler: submitModel("submit_labels", { labels: [] }),
|
||||||
@@ -360,6 +379,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
|||||||
const pipeline = createExtractionPipeline({
|
const pipeline = createExtractionPipeline({
|
||||||
chatStore,
|
chatStore,
|
||||||
graphStore: ledgers,
|
graphStore: ledgers,
|
||||||
|
embedder: fakeEmbedder,
|
||||||
extractor: submitModel("submit_findings", FINDINGS),
|
extractor: submitModel("submit_findings", FINDINGS),
|
||||||
auditor: silentModel(), // never submits the review
|
auditor: silentModel(), // never submits the review
|
||||||
labeler: submitModel("submit_labels", { labels: [] }),
|
labeler: submitModel("submit_labels", { labels: [] }),
|
||||||
@@ -371,6 +391,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
|||||||
const failingExtractor = createExtractionPipeline({
|
const failingExtractor = createExtractionPipeline({
|
||||||
chatStore,
|
chatStore,
|
||||||
graphStore: ledgers,
|
graphStore: ledgers,
|
||||||
|
embedder: fakeEmbedder,
|
||||||
extractor: silentModel(),
|
extractor: silentModel(),
|
||||||
auditor: submitModel("submit_review", { ...FINDINGS, dropped: [] }),
|
auditor: submitModel("submit_review", { ...FINDINGS, dropped: [] }),
|
||||||
labeler: submitModel("submit_labels", { labels: [] }),
|
labeler: submitModel("submit_labels", { labels: [] }),
|
||||||
@@ -402,6 +423,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
|||||||
const pipeline = createExtractionPipeline({
|
const pipeline = createExtractionPipeline({
|
||||||
chatStore,
|
chatStore,
|
||||||
graphStore: ledgers,
|
graphStore: ledgers,
|
||||||
|
embedder: fakeEmbedder,
|
||||||
// Each stage's first generate is the observation point: the
|
// Each stage's first generate is the observation point: the
|
||||||
// registry must already carry this chat at the right phase.
|
// registry must already carry this chat at the right phase.
|
||||||
extractor: submitModel("submit_findings", FINDINGS, () => seen.push(phaseOf())),
|
extractor: submitModel("submit_findings", FINDINGS, () => seen.push(phaseOf())),
|
||||||
@@ -435,6 +457,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
|||||||
const pipeline = createExtractionPipeline({
|
const pipeline = createExtractionPipeline({
|
||||||
chatStore,
|
chatStore,
|
||||||
graphStore: ledgers,
|
graphStore: ledgers,
|
||||||
|
embedder: fakeEmbedder,
|
||||||
// During the auditor's generate, the registry must already carry
|
// During the auditor's generate, the registry must already carry
|
||||||
// the extractor's fully accumulated live usage (2 steps × 10/5).
|
// the extractor's fully accumulated live usage (2 steps × 10/5).
|
||||||
extractor: submitModel("submit_findings", FINDINGS),
|
extractor: submitModel("submit_findings", FINDINGS),
|
||||||
@@ -476,6 +499,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
|||||||
const pipeline = createExtractionPipeline({
|
const pipeline = createExtractionPipeline({
|
||||||
chatStore,
|
chatStore,
|
||||||
graphStore: ledgers,
|
graphStore: ledgers,
|
||||||
|
embedder: fakeEmbedder,
|
||||||
extractor: hangingModel(), // hangs until aborted — no step finishes
|
extractor: hangingModel(), // hangs until aborted — no step finishes
|
||||||
auditor: submitModel("submit_review", { propositions: [], edges: [], attribution: { quiz: {} }, dropped: [] }),
|
auditor: submitModel("submit_review", { propositions: [], edges: [], attribution: { quiz: {} }, dropped: [] }),
|
||||||
labeler: submitModel("submit_labels", { labels: [] }),
|
labeler: submitModel("submit_labels", { labels: [] }),
|
||||||
@@ -527,6 +551,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
|||||||
const pipeline = createExtractionPipeline({
|
const pipeline = createExtractionPipeline({
|
||||||
chatStore,
|
chatStore,
|
||||||
graphStore: ledgers,
|
graphStore: ledgers,
|
||||||
|
embedder: fakeEmbedder,
|
||||||
extractor: gatedExtractor,
|
extractor: gatedExtractor,
|
||||||
auditor: silentModel(),
|
auditor: silentModel(),
|
||||||
labeler: submitModel("submit_labels", { labels: [] }),
|
labeler: submitModel("submit_labels", { labels: [] }),
|
||||||
@@ -545,6 +570,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
|||||||
const pipeline = createExtractionPipeline({
|
const pipeline = createExtractionPipeline({
|
||||||
chatStore,
|
chatStore,
|
||||||
graphStore: ledgers,
|
graphStore: ledgers,
|
||||||
|
embedder: fakeEmbedder,
|
||||||
extractor: silentModel(),
|
extractor: silentModel(),
|
||||||
auditor: silentModel(),
|
auditor: silentModel(),
|
||||||
labeler: submitModel("submit_labels", { labels: [] }),
|
labeler: submitModel("submit_labels", { labels: [] }),
|
||||||
@@ -603,6 +629,27 @@ const CLUSTER_VERDICT = {
|
|||||||
dropped: [],
|
dropped: [],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/** Test seam for the embedder: fakes the post-merge embedding batch —
|
||||||
|
* one identical unit vector per node → semanticClusters merges them all
|
||||||
|
* into ONE topic — then persists the partition like the real embedder. */
|
||||||
|
const fakeEmbedder = async (store: GraphStore) => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (const n of store.readNodes()) {
|
||||||
|
if (seen.has(n.id)) continue;
|
||||||
|
seen.add(n.id);
|
||||||
|
ids.push(n.id);
|
||||||
|
}
|
||||||
|
const vectors = Object.fromEntries(ids.map((id) => [id, [1, 0, 0]]));
|
||||||
|
store.writeEmbeddings({
|
||||||
|
model: "test/embedder",
|
||||||
|
dim: 3,
|
||||||
|
vectors,
|
||||||
|
clusters: semanticClusters(vectors),
|
||||||
|
updatedAt: "2026-09-13T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
describe("cluster naming (the post-extraction garnish)", () => {
|
describe("cluster naming (the post-extraction garnish)", () => {
|
||||||
it("names a new community after a notes merge — one record, claimed on the next refresh", async () => {
|
it("names a new community after a notes merge — one record, claimed on the next refresh", async () => {
|
||||||
const ledgers = memoryLedgers();
|
const ledgers = memoryLedgers();
|
||||||
@@ -611,6 +658,7 @@ describe("cluster naming (the post-extraction garnish)", () => {
|
|||||||
const pipeline = createExtractionPipeline({
|
const pipeline = createExtractionPipeline({
|
||||||
chatStore,
|
chatStore,
|
||||||
graphStore: ledgers,
|
graphStore: ledgers,
|
||||||
|
embedder: fakeEmbedder,
|
||||||
extractor: submitModel("submit_findings", CLUSTER_FINDINGS),
|
extractor: submitModel("submit_findings", CLUSTER_FINDINGS),
|
||||||
auditor: submitModel("submit_review", CLUSTER_VERDICT),
|
auditor: submitModel("submit_review", CLUSTER_VERDICT),
|
||||||
labeler,
|
labeler,
|
||||||
@@ -638,6 +686,7 @@ describe("cluster naming (the post-extraction garnish)", () => {
|
|||||||
const pipeline = createExtractionPipeline({
|
const pipeline = createExtractionPipeline({
|
||||||
chatStore,
|
chatStore,
|
||||||
graphStore: ledgers,
|
graphStore: ledgers,
|
||||||
|
embedder: fakeEmbedder,
|
||||||
extractor: submitModel("submit_findings", CLUSTER_FINDINGS),
|
extractor: submitModel("submit_findings", CLUSTER_FINDINGS),
|
||||||
auditor: submitModel("submit_review", CLUSTER_VERDICT),
|
auditor: submitModel("submit_review", CLUSTER_VERDICT),
|
||||||
labeler: silentModel(), // never submits — the namer throws
|
labeler: silentModel(), // never submits — the namer throws
|
||||||
@@ -656,6 +705,7 @@ describe("the notes front door (importNotes — format-agnostic)", () => {
|
|||||||
const pipeline = createExtractionPipeline({
|
const pipeline = createExtractionPipeline({
|
||||||
chatStore,
|
chatStore,
|
||||||
graphStore: ledgers,
|
graphStore: ledgers,
|
||||||
|
embedder: fakeEmbedder,
|
||||||
extractor: submitModel("submit_findings", NOTES_FINDINGS),
|
extractor: submitModel("submit_findings", NOTES_FINDINGS),
|
||||||
// During the auditor's call the registry must show the notes run.
|
// During the auditor's call the registry must show the notes run.
|
||||||
auditor: submitModel("submit_review", NOTES_VERDICT, () => {
|
auditor: submitModel("submit_review", NOTES_VERDICT, () => {
|
||||||
@@ -704,6 +754,7 @@ describe("the notes front door (importNotes — format-agnostic)", () => {
|
|||||||
createExtractionPipeline({
|
createExtractionPipeline({
|
||||||
chatStore,
|
chatStore,
|
||||||
graphStore: ledgers,
|
graphStore: ledgers,
|
||||||
|
embedder: fakeEmbedder,
|
||||||
extractor: submitModel("submit_findings", NOTES_FINDINGS),
|
extractor: submitModel("submit_findings", NOTES_FINDINGS),
|
||||||
auditor: submitModel("submit_review", NOTES_VERDICT),
|
auditor: submitModel("submit_review", NOTES_VERDICT),
|
||||||
labeler: submitModel("submit_labels", { labels: [] }),
|
labeler: submitModel("submit_labels", { labels: [] }),
|
||||||
@@ -724,6 +775,7 @@ describe("the notes front door (importNotes — format-agnostic)", () => {
|
|||||||
const pipeline = createExtractionPipeline({
|
const pipeline = createExtractionPipeline({
|
||||||
chatStore,
|
chatStore,
|
||||||
graphStore: ledgers,
|
graphStore: ledgers,
|
||||||
|
embedder: fakeEmbedder,
|
||||||
extractor: hangingModel(),
|
extractor: hangingModel(),
|
||||||
auditor: submitModel("submit_review", NOTES_VERDICT),
|
auditor: submitModel("submit_review", NOTES_VERDICT),
|
||||||
labeler: submitModel("submit_labels", { labels: [] }),
|
labeler: submitModel("submit_labels", { labels: [] }),
|
||||||
|
|||||||
+18
-4
@@ -23,7 +23,7 @@ import type { QuestionAnswerData } from "../src/core/question-core.ts";
|
|||||||
import { clusterContentId, getGraph, graphSnapshot, mergeFindings, pendingQuizAnswers, type GraphDeps } from "../src/core/graph.ts";
|
import { clusterContentId, getGraph, graphSnapshot, mergeFindings, pendingQuizAnswers, type GraphDeps } from "../src/core/graph.ts";
|
||||||
import { graphStore } from "../src/store/graph.ts";
|
import { graphStore } from "../src/store/graph.ts";
|
||||||
import { chatStore } from "../src/store/chats.ts";
|
import { chatStore } from "../src/store/chats.ts";
|
||||||
import { LEDGER_FILES, PATHS, SOURCES_FILE } from "../config.ts";
|
import { LEDGER_FILES, PATHS, SOURCES_FILE, EMBEDDINGS_FILE } from "../config.ts";
|
||||||
|
|
||||||
/** The wired fs adapters, exactly what src/server/stores.ts passes in production. */
|
/** The wired fs adapters, exactly what src/server/stores.ts passes in production. */
|
||||||
const graphDeps = { chatStore, graphStore } as const satisfies GraphDeps;
|
const graphDeps = { chatStore, graphStore } as const satisfies GraphDeps;
|
||||||
@@ -38,7 +38,9 @@ function sessionSource(chatId: string): { messages: UIMessage[]; foldedAt: strin
|
|||||||
|
|
||||||
// ─── ledger isolation (backup-and-clear of data/graph/*.jsonl) ──────────────
|
// ─── ledger isolation (backup-and-clear of data/graph/*.jsonl) ──────────────
|
||||||
|
|
||||||
const LEDGER_PATHS = [...Object.values(LEDGER_FILES), SOURCES_FILE];
|
// EMBEDDINGS_FILE too: the semantic-cluster source (read by graphSnapshot)
|
||||||
|
// — isolated like the rest so this suite never touches the real vectors.
|
||||||
|
const LEDGER_PATHS = [...Object.values(LEDGER_FILES), SOURCES_FILE, EMBEDDINGS_FILE];
|
||||||
const backups: Array<{ file: string; bak: string }> = [];
|
const backups: Array<{ file: string; bak: string }> = [];
|
||||||
const createdChats: string[] = [];
|
const createdChats: string[] = [];
|
||||||
|
|
||||||
@@ -407,8 +409,10 @@ describe("the merger (mergeFindings)", () => {
|
|||||||
assert.equal(snap.nodeObservations[node.id]!["src_snapfixture"], "solid");
|
assert.equal(snap.nodeObservations[node.id]!["src_snapfixture"], "solid");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("derives structural clusters in graphSnapshot, joined with persisted labelings", () => {
|
it("derives semantic clusters in graphSnapshot, joined with persisted labelings", () => {
|
||||||
// A self-contained 3-clique: three propositions, all mutually linked.
|
// Three propositions; the cluster comes from the EMBEDDING ledger
|
||||||
|
// (semantic topics, DECIDED 2026-09-13) — the embedder persists the
|
||||||
|
// partition, the snapshot just reads it. Here we play the embedder.
|
||||||
const tri = ["Alpha.", "Beta.", "Gamma cluster."] as const;
|
const tri = ["Alpha.", "Beta.", "Gamma cluster."] as const;
|
||||||
const merge = mergeFindings(
|
const merge = mergeFindings(
|
||||||
"src_clusterfixture",
|
"src_clusterfixture",
|
||||||
@@ -433,6 +437,16 @@ describe("the merger (mergeFindings)", () => {
|
|||||||
{ messages: null, foldedAt: "2026-09-13T00:00:00.000Z" },
|
{ messages: null, foldedAt: "2026-09-13T00:00:00.000Z" },
|
||||||
);
|
);
|
||||||
assert.equal(merge.nodes, 3);
|
assert.equal(merge.nodes, 3);
|
||||||
|
// The embedder's contract: recluster at the end of the batch and
|
||||||
|
// PERSIST the partition in the embeddings ledger.
|
||||||
|
const ids = tri.map((s) => getGraph(graphStore).nodes.find((n) => n.statement === s)!.id);
|
||||||
|
graphStore.writeEmbeddings({
|
||||||
|
model: "test/embedder",
|
||||||
|
dim: 2,
|
||||||
|
vectors: {},
|
||||||
|
clusters: [{ members: ids }],
|
||||||
|
updatedAt: "2026-09-13T00:00:00.000Z",
|
||||||
|
});
|
||||||
const snap = graphSnapshot(graphDeps);
|
const snap = graphSnapshot(graphDeps);
|
||||||
// Presentation threshold: every cluster is >= 3 members.
|
// Presentation threshold: every cluster is >= 3 members.
|
||||||
assert.ok(snap.clusters.every((c) => c.members.length >= 3));
|
assert.ok(snap.clusters.every((c) => c.members.length >= 3));
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ describe("settleGraphLayout", () => {
|
|||||||
readEdges: () => [edge("a", "b")],
|
readEdges: () => [edge("a", "b")],
|
||||||
readPositions: () => ({} as Record<string, Position>),
|
readPositions: () => ({} as Record<string, Position>),
|
||||||
writePositions: (p: Record<string, Position>) => void written.push(p),
|
writePositions: (p: Record<string, Position>) => void written.push(p),
|
||||||
|
readEmbeddings: () => ({ model: "", dim: 0, vectors: {}, clusters: [], updatedAt: "" }),
|
||||||
};
|
};
|
||||||
settleGraphLayout(store);
|
settleGraphLayout(store);
|
||||||
assert.equal(written.length, 1);
|
assert.equal(written.length, 1);
|
||||||
@@ -123,6 +124,7 @@ describe("settleGraphLayout", () => {
|
|||||||
readEdges: () => [],
|
readEdges: () => [],
|
||||||
readPositions: () => ({} as Record<string, Position>),
|
readPositions: () => ({} as Record<string, Position>),
|
||||||
writePositions: (p) => void written.push(p),
|
writePositions: (p) => void written.push(p),
|
||||||
|
readEmbeddings: () => ({ model: "", dim: 0, vectors: {}, clusters: [], updatedAt: "" }),
|
||||||
});
|
});
|
||||||
assert.equal(written.length, 0);
|
assert.equal(written.length, 0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* Semantic topic clustering (the Louvain REPLACEMENT, DECIDED
|
||||||
|
* 2026-09-13): average-linkage agglomerative clustering over the
|
||||||
|
* embedding ledger — related TOPICS, not reasoning chains. Pure and
|
||||||
|
* deterministic, so these tests are exact.
|
||||||
|
*/
|
||||||
|
import { strict as assert } from "node:assert";
|
||||||
|
import { describe, test } from "node:test";
|
||||||
|
import { semanticClusters, SEMANTIC_SIM_THRESHOLD, cosineSimilarity } from "../src/core/semantic-clusters.ts";
|
||||||
|
|
||||||
|
/** Two clearly-related one-hot-ish vectors (high cosine similarity). */
|
||||||
|
function near(vec: number[], target: number[], amount = 0.2): number[] {
|
||||||
|
let dot = 0;
|
||||||
|
for (let i = 0; i < vec.length; i++) dot += vec[i]! * target[i]!;
|
||||||
|
const mixed = vec.map((x, i) => x + amount * target[i]!);
|
||||||
|
let mag = 0;
|
||||||
|
for (const x of mixed) mag += x * x;
|
||||||
|
mag = Math.sqrt(mag);
|
||||||
|
return mixed.map((x) => x / mag);
|
||||||
|
}
|
||||||
|
|
||||||
|
function axis(dim: number, k: number): number[] {
|
||||||
|
return Array.from({ length: dim }, (_, i) => (i === k ? 1 : 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("cosineSimilarity", () => {
|
||||||
|
test("identical unit vectors → 1; orthogonal → 0; defensive normalization", () => {
|
||||||
|
const a = axis(4, 0);
|
||||||
|
const b = axis(4, 1);
|
||||||
|
assert.ok(cosineSimilarity(a, a) > 0.999);
|
||||||
|
assert.ok(Math.abs(cosineSimilarity(a, b)) < 1e-9);
|
||||||
|
// Unnormalized twins still match.
|
||||||
|
assert.ok(cosineSimilarity([3, 0], [5, 0]) > 0.999);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("semanticClusters", () => {
|
||||||
|
test("empty ledger: zero clusters (nothing embedded yet)", () => {
|
||||||
|
assert.deepEqual(semanticClusters({}), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("related vectors merge; distant ones stay separate (threshold)", () => {
|
||||||
|
const dim = 8;
|
||||||
|
const topicA = axis(dim, 0);
|
||||||
|
const topicB = axis(dim, 1);
|
||||||
|
const vectors = {
|
||||||
|
a1: near(topicA, topicB, 0.1),
|
||||||
|
a2: near(topicA, topicB, 0.15),
|
||||||
|
a3: near(topicA, topicB, 0.05),
|
||||||
|
b1: near(topicB, topicA, 0.1),
|
||||||
|
b2: near(topicB, topicA, 0.12),
|
||||||
|
};
|
||||||
|
const clusters = semanticClusters(vectors);
|
||||||
|
// Both topics form; average-linkage similarity within each is well
|
||||||
|
// above the default threshold, across is well below.
|
||||||
|
const big = clusters.filter((c) => c.members.length >= 2);
|
||||||
|
assert.equal(big.length, 2);
|
||||||
|
assert.deepEqual(
|
||||||
|
big.map((c) => c.members.sort()).sort((x, y) => x[0]!.localeCompare(y[0]!)),
|
||||||
|
[["a1", "a2", "a3"], ["b1", "b2"]],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("threshold is the granularity knob: higher splits, lower merges", () => {
|
||||||
|
const dim = 6;
|
||||||
|
const vectors = {
|
||||||
|
x: near(axis(dim, 0), axis(dim, 1), 0.3),
|
||||||
|
y: near(axis(dim, 1), axis(dim, 0), 0.3),
|
||||||
|
};
|
||||||
|
// Similarity ~0.72: merges below that, splits above.
|
||||||
|
const sim = cosineSimilarity(vectors.x!, vectors.y!);
|
||||||
|
assert.equal(semanticClusters(vectors, { similarityThreshold: sim - 0.01 }).length, 1);
|
||||||
|
assert.equal(semanticClusters(vectors, { similarityThreshold: sim + 0.01 }).length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stragglers stay singletons by default; attachStragglers forces membership", () => {
|
||||||
|
const dim = 6;
|
||||||
|
const topic = axis(dim, 0);
|
||||||
|
const vectors = {
|
||||||
|
t1: near(topic, axis(dim, 1), 0.1),
|
||||||
|
t2: near(topic, axis(dim, 2), 0.1),
|
||||||
|
t3: near(topic, axis(dim, 3), 0.1),
|
||||||
|
loner: axis(dim, 5), // orthogonal: fits nothing
|
||||||
|
};
|
||||||
|
const loose = semanticClusters(vectors);
|
||||||
|
const looseLoner = loose.find((c) => c.members.includes("loner"));
|
||||||
|
assert.equal(looseLoner?.members.length, 1, "default: loner is a straggler");
|
||||||
|
assert.equal(loose.find((c) => c.members.length >= 3)?.members.length, 3);
|
||||||
|
|
||||||
|
const allIn = semanticClusters(vectors, { attachStragglers: true });
|
||||||
|
assert.equal(allIn.length, 1, "attach: the loner joins the nearest topic");
|
||||||
|
assert.ok(allIn[0]!.members.includes("loner"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("members sorted; clusters ordered by size then id (deterministic)", () => {
|
||||||
|
const dim = 4;
|
||||||
|
const vectors = {
|
||||||
|
solo: axis(dim, 3),
|
||||||
|
p1: near(axis(dim, 0), axis(dim, 1), 0.05),
|
||||||
|
p2: near(axis(dim, 0), axis(dim, 2), 0.05),
|
||||||
|
q1: near(axis(dim, 1), axis(dim, 0), 0.05),
|
||||||
|
q2: near(axis(dim, 1), axis(dim, 2), 0.05),
|
||||||
|
};
|
||||||
|
const a = semanticClusters(vectors);
|
||||||
|
const b = semanticClusters(vectors);
|
||||||
|
assert.deepEqual(a, b, "same input → identical partition");
|
||||||
|
for (let i = 1; i < a.length; i++) {
|
||||||
|
assert.ok(a[i - 1]!.members.length >= a[i]!.members.length, "size-descending");
|
||||||
|
}
|
||||||
|
for (const c of a) {
|
||||||
|
assert.deepEqual(c.members, [...c.members].sort(), "members sorted");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("default threshold is the swept real-graph value", () => {
|
||||||
|
assert.equal(SEMANTIC_SIM_THRESHOLD, 0.6);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user