Birth-session clustering + the prober graph-tools data seam (wave 7 chunk A)

Embeddings are dying (chunk B removes the machinery); the clusters they
used to carry become the session that created them — derived on read.

- New src/core/session-clusters.ts: birthSessionPartition groups merged
  nodes by their FIRST SURVIVING provenance point's chatId (owner-locked
  2026-09-23). Deletion is self-healing by construction: stripped-birth
  survivors re-attribute to their earliest remaining contributor, and
  empty-provenance nodes stay unclustered. Notes imports are birth-sessions
  too (owner ruling) — their label is the file path.
- graphSnapshot: clusters = the birth partition filtered >= MIN_CLUSTER_SIZE
  (the hull presentation rule), id = session id, label = session name (chat
  title / file path; a deleted record renders unlabeled). The persisted
  cluster-labelings join dies; no LLM naming pass needed anymore.
- settleGraphLayout derives the same partition from readNodes — drops its
  readEmbeddings dep. One cluster concept everywhere.
- Prober data seam (core/graph.ts, pure ports-driven like the snapshot):
  graphSessionIndex (every birth session: id, name, chat|file kind,
  createdAt/updatedAt, birth-proposition count, recency-ordered) and
  graphSessionPropositions (a session's birth propositions with the
  extractor's latest understanding level and THAT event's own timestamp —
  owner ruling: quiz outcomes are ledger evidence, they never move the
  evaluation date). Teacher's tool handlers consume these in-process.
- New agent.graphTools subsection { enabled?: boolean } (owner ruling:
  the toggle gates prober tool REGISTRATION — agent capability, agent
  section, its own subsection, NOT the tool-toggles map; absent = ON).
  Lenient sanitizer, strict settings-body parser, passthrough resolution,
  and graphToolsEnabled(effective) = graph.enabled && toggle for Teacher's
  registry wiring.

Gates: tsc clean, eslint clean, 923/923 tests, next build compiles.
Live-verified on the worktree dev data: 10 nodes → 2 clusters labeled
with their chat titles; session index + propositions carry levels and
their own timestamps.
This commit is contained in:
amosblomqvist
2026-09-23 14:51:37 +03:00
parent fb66606555
commit 0fcffdffaf
12 changed files with 551 additions and 61 deletions
+13 -31
View File
@@ -20,7 +20,7 @@ import { after, describe, it } from "node:test";
import type { UIMessage } from "ai";
import type { AuditResult, NamingRunUsage } from "../src/core/contracts.ts";
import type { QuestionAnswerData } from "../src/core/question-core.ts";
import { clusterContentId, getGraph, graphSnapshot, mergeFindings, type GraphDeps } from "../src/core/graph.ts";
import { getGraph, graphSnapshot, mergeFindings, type GraphDeps } from "../src/core/graph.ts";
import { graphStore } from "../src/store/graph.ts";
import { chatStore } from "../src/store/chats.ts";
import { LEDGER_FILES, PATHS, SOURCES_FILE, EMBEDDINGS_FILE } from "../config.ts";
@@ -409,11 +409,13 @@ describe("the merger (mergeFindings)", () => {
assert.equal(snap.nodeObservations[node.id]!["src_snapfixture"], "solid");
});
it("derives semantic clusters in graphSnapshot, joined with persisted labelings", () => {
// 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.
it("derives BIRTH-SESSION clusters in graphSnapshot, labeled with the session's name", () => {
// Wave 7 (owner-locked 2026-09-23): embeddings are dead — a cluster is
// the session that BIRTHED its propositions, derived on read from
// provenance. No ledger partition to play, no LLM naming pass — the
// label IS the session's name (chat title / file path).
const tri = ["Alpha.", "Beta.", "Gamma cluster."] as const;
graphStore.upsertSource({ id: "src_clusterfixture", filePath: "/vault/cluster notes.md", importedAt: "2026-09-13T00:00:00.000Z" });
const merge = mergeFindings(
"src_clusterfixture",
{
@@ -422,7 +424,7 @@ describe("the merger (mergeFindings)", () => {
name: `Cluster node ${i + 1}`,
statement,
aliases: [],
messages: [],
messages: [`cluster_msg_${i + 1}`], // birth provenance — the message anchors
evidence: ["fixture evidence"],
})),
edges: [
@@ -437,35 +439,15 @@ describe("the merger (mergeFindings)", () => {
{ messages: null, foldedAt: "2026-09-13T00:00:00.000Z" },
);
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);
// Presentation threshold: every cluster is >= 3 members.
assert.ok(snap.clusters.every((c) => c.members.length >= 3));
// Our trio is exactly one cluster, unlabeled until the namer runs.
// Our trio is exactly one cluster — the notes file that BIRTHED it,
// labeled with its path, id = the source id (stable, no content hash).
const mine = snap.clusters.find((c) => c.members.length === 3 && tri.every((s) => c.members.includes(snap.nodes.find((n) => n.statement === s)!.id)));
assert.ok(mine, "the 3-clique is a cluster");
assert.equal(mine!.label, null);
assert.match(mine!.id, /^clu_/);
// Mint the labeling the namer would append — the next read joins it.
graphStore.appendClusterLabel({
id: clusterContentId(mine!.members),
label: "Test region",
core: mine!.members,
createdAt: "2026-09-13T00:00:00.000Z",
});
const snap2 = graphSnapshot(graphDeps);
const claimed = snap2.clusters.find((c) => c.members.length === 3 && tri.every((s) => c.members.includes(snap2.nodes.find((n) => n.statement === s)!.id)))!;
assert.equal(claimed.label, "Test region");
assert.equal(claimed.id, clusterContentId(mine!.members), "the claimed cluster carries the record's id");
assert.ok(mine, "the trio is one cluster");
assert.equal(mine!.id, "src_clusterfixture");
assert.equal(mine!.label, "/vault/cluster notes.md", "a notes file is a birth-session — the label is its path");
});
it("derives sessions and per-session understanding observations in graphSnapshot", () => {
-2
View File
@@ -110,7 +110,6 @@ describe("settleGraphLayout", () => {
readEdges: () => [edge("a", "b")],
readPositions: () => ({} as Record<string, Position>),
writePositions: (p: Record<string, Position>) => void written.push(p),
readEmbeddings: () => ({ model: "", dim: 0, vectors: {}, clusters: [], updatedAt: "" }),
};
settleGraphLayout(store);
assert.equal(written.length, 1);
@@ -124,7 +123,6 @@ describe("settleGraphLayout", () => {
readEdges: () => [],
readPositions: () => ({} as Record<string, Position>),
writePositions: (p) => void written.push(p),
readEmbeddings: () => ({ model: "", dim: 0, vectors: {}, clusters: [], updatedAt: "" }),
});
assert.equal(written.length, 0);
});
+236
View File
@@ -0,0 +1,236 @@
/**
* BIRTH-SESSION CLUSTERING + THE PROBER GRAPH-TOOLS DATA SEAM (wave 7,
* owner-locked 2026-09-23): embeddings are dead — a proposition's cluster
* is the session that created it, derived on read from provenance
* (session-clusters.ts); labels are session names; the prober tools read a
* session list + per-session propositions with the extractor's latest
* understanding level and its own timestamp. All in-memory fakes — the
* fs-backed snapshot join (with the real store adapters) lives in
* graph.test.ts.
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import type { GraphEdge, GraphNode, LedgerEvent, NotesSource, ChatSummary } from "../src/core/contracts.ts";
import type { ChatStore, GraphStore } from "../src/core/ports.ts";
import {
graphSessionIndex,
graphSessionPropositions,
graphSnapshot,
type GraphDeps,
} from "../src/core/graph.ts";
import { birthSessionOf, birthSessionPartition } from "../src/core/session-clusters.ts";
// ─── fixtures ────────────────────────────────────────────────────────────────
const pt = (chatId: string, messageId = "m1") => ({ chatId, messageId });
const node = (id: string, name: string, provenance: Array<{ chatId: string; messageId: string }>): GraphNode => ({
id,
name,
statement: `${name} statement`,
aliases: [],
createdAt: "2026-09-23T00:00:00.000Z",
provenance,
});
function fakeStores(
nodes: GraphNode[],
over: {
events?: LedgerEvent[];
chats?: Array<Pick<ChatSummary, "id" | "title" | "createdAt" | "updatedAt">>;
sources?: Array<Pick<NotesSource, "id" | "filePath" | "importedAt">>;
} = {},
): GraphDeps {
const edges: GraphEdge[] = [];
const graphStore: GraphStore = {
readNodes: () => nodes,
readEdges: () => edges,
readEvents: () => over.events ?? [],
readMappings: () => [],
readCheckpoints: () => [],
readCoverage: () => [],
readSources: () =>
(over.sources ?? []).map((s) => ({ ...s, extractions: [] }) as unknown as NotesSource),
readPositions: () => ({}),
writePositions: () => {},
readEmbeddings: () => ({ model: "dead", dim: 0, vectors: {}, clusters: [], updatedAt: "" }),
writeEmbeddings: () => {},
readClusterLabels: () => [],
appendClusterLabel: () => {},
appendNamingRun: () => {},
readNamingRuns: () => [],
} as unknown as GraphStore;
const chatStore: ChatStore = {
listChats: () =>
(over.chats ?? []).map((c) => ({ ...c, messageCount: 0 }) as unknown as ChatSummary),
getChat: (id: string) =>
((over.chats ?? []).find((c) => c.id === id) as unknown as ChatSummary) ?? undefined,
} as unknown as ChatStore;
return { chatStore, graphStore };
}
// ─── the partition ──────────────────────────────────────────────────────────
describe("birthSessionPartition", () => {
it("groups by the first provenance point, dedupes repeated lines, sorts deterministically", () => {
const nodes = [
node("a", "A", [pt("chat1")]),
node("b", "B", [pt("chat1")]),
node("c", "C", [pt("chat2")]),
node("a", "A-linked", [pt("chat2")]), // link-append line for "a": ignored (first defines)
node("d", "D", [pt("chat1")]),
];
assert.deepEqual(birthSessionPartition(nodes), [
{ id: "chat1", members: ["a", "b", "d"] },
{ id: "chat2", members: ["c"] },
]);
});
it("a SHARED node belongs to its birth session only — later contributors never claim it", () => {
const nodes = [
node("born-1", "X", [pt("chat1")]),
node("born-1", "X-linked", [pt("chat2")]), // chat2 re-encountered it
node("born-2", "Y", [pt("chat2")]),
];
assert.deepEqual(birthSessionPartition(nodes), [
{ id: "chat1", members: ["born-1"] },
{ id: "chat2", members: ["born-2"] },
], "chat2 holds only what it BIRTHED — sizes tie (1/1), id order breaks it");
});
it("deletion re-attribution: a stripped birth falls to the earliest remaining contributor; no provenance = unclustered", () => {
// What delete-nodes leaves behind: the birth session's points are
// stripped from surviving lines — the merged provenance walks forward.
const reattributed = node("survivor", "S", [pt("chat2"), pt("chat3")]);
assert.equal(birthSessionOf(reattributed), "chat2");
const naked = node("naked", "N", []);
assert.equal(birthSessionOf(naked), null);
const partition = birthSessionPartition([reattributed, naked]);
assert.deepEqual(partition, [{ id: "chat2", members: ["survivor"] }], "the naked node joins nothing");
});
});
// ─── the snapshot join ──────────────────────────────────────────────────────
describe("graphSnapshot birth-session clusters", () => {
it("labels clusters with session names (chat title / file path), keeps the ≥3 hull rule", () => {
const nodes = [
node("a", "A", [pt("chat1")]),
node("b", "B", [pt("chat1")]),
node("c", "C", [pt("chat1")]),
node("d", "D", [pt("src_notes")]),
node("e", "E", [pt("src_notes")]),
node("f", "F", [pt("src_notes")]),
];
const deps = fakeStores(nodes, {
chats: [{ id: "chat1", title: "Learning session", createdAt: "2026-09-20T00:00:00.000Z", updatedAt: "2026-09-21T00:00:00.000Z" }],
sources: [{ id: "src_notes", filePath: "/vault/notes.md", importedAt: "2026-09-22T00:00:00.000Z" }],
});
const snap = graphSnapshot(deps);
assert.equal(snap.clusters.length, 2);
const byLabel = new Map(snap.clusters.map((c) => [c.label, c]));
assert.deepEqual(byLabel.get("Learning session"), {
id: "chat1",
label: "Learning session",
members: ["a", "b", "c"],
});
assert.deepEqual(byLabel.get("/vault/notes.md"), {
id: "src_notes",
label: "/vault/notes.md",
members: ["d", "e", "f"],
}, "a notes file is a birth-session too (owner ruling)");
// A 2-proposition session renders no hull (the presentation rule).
const two = fakeStores([node("x", "X", [pt("chat1")]), node("y", "Y", [pt("chat1")])], {
chats: [{ id: "chat1", title: "tiny", createdAt: "", updatedAt: "" }],
});
assert.equal(graphSnapshot(two).clusters.length, 0);
});
it("a birth session whose record is gone keeps its members but renders unlabeled", () => {
const nodes = [node("a", "A", [pt("gone")]), node("b", "B", [pt("gone")]), node("c", "C", [pt("gone")])];
const snap = graphSnapshot(fakeStores(nodes));
assert.equal(snap.clusters.length, 1);
assert.equal(snap.clusters[0]!.label, null);
assert.equal(snap.clusters[0]!.id, "gone");
});
});
// ─── the prober data seam ───────────────────────────────────────────────────
describe("graphSessionIndex (the prober's session list)", () => {
it("birth counts, chat/file kinds, recency order, deleted records fall back honestly", () => {
const nodes = [
node("a", "A", [pt("chat1")]),
node("b", "B", [pt("chat1")]),
node("c", "C", [pt("chat1")]),
node("d", "D", [pt("src_notes")]),
node("e", "E", [pt("gone")]), // its chat record no longer exists
];
const deps = fakeStores(nodes, {
chats: [
{ id: "chat1", title: "Older session", createdAt: "2026-09-20T00:00:00.000Z", updatedAt: "2026-09-21T00:00:00.000Z" },
],
sources: [{ id: "src_notes", filePath: "/vault/notes.md", importedAt: "2026-09-22T00:00:00.000Z" }],
});
const index = graphSessionIndex(deps);
assert.deepEqual(index, [
{
id: "src_notes",
name: "/vault/notes.md",
kind: "file",
createdAt: "2026-09-22T00:00:00.000Z",
updatedAt: "2026-09-22T00:00:00.000Z",
propositions: 1,
},
{
id: "chat1",
name: "Older session",
kind: "chat",
createdAt: "2026-09-20T00:00:00.000Z",
updatedAt: "2026-09-21T00:00:00.000Z",
propositions: 3,
},
{
id: "gone",
name: "gone",
kind: "file",
createdAt: null,
updatedAt: null,
propositions: 1,
},
], "most recent first; the undatable record sinks and names itself by id");
});
});
describe("graphSessionPropositions (the per-session view)", () => {
it("birth propositions with the latest extractor evaluation AND that event's own timestamp", () => {
const nodes = [
node("a", "Alpha", [pt("chat1")]),
node("b", "Beta", [pt("chat1")]),
node("c", "Gamma", [pt("chat2")]), // born elsewhere — never listed under chat1
];
const events: LedgerEvent[] = [
{ nodeId: "a", kind: "observation", chatId: "chat1", at: "2026-09-20T00:00:00.000Z", note: "shaky", ref: "obs:p1" },
// A LATER evaluation supersedes the earlier one for (a, chat1).
{ nodeId: "a", kind: "observation", chatId: "chat1", at: "2026-09-21T00:00:00.000Z", note: "solid", ref: "obs:p2" },
// Quiz evidence (owner ruling 2026-09-23): recorded in the ledger
// but it NEVER moves the evaluation date.
{ nodeId: "a", kind: "correct", chatId: "chat1", at: "2026-09-22T00:00:00.000Z", note: "got it" },
// Another session's evaluation of the same node is not ours.
{ nodeId: "a", kind: "observation", chatId: "chat2", at: "2026-09-23T00:00:00.000Z", note: "resisted" },
// Same-at events: ledger order breaks the tie (the snapshot's rule).
{ nodeId: "b", kind: "observation", chatId: "chat1", at: "2026-09-20T00:00:00.000Z", note: "accepted", ref: "obs:p3" },
{ nodeId: "b", kind: "observation", chatId: "chat1", at: "2026-09-20T00:00:00.000Z", note: "not yet engaged", ref: "obs:p4" },
];
const deps = fakeStores(nodes, { events });
assert.deepEqual(graphSessionPropositions(deps, "chat1"), [
{ nodeId: "a", name: "Alpha", understanding: "solid", evaluatedAt: "2026-09-21T00:00:00.000Z" },
{ nodeId: "b", name: "Beta", understanding: "not yet engaged", evaluatedAt: "2026-09-20T00:00:00.000Z" },
]);
// A node the session never evaluated shows nulls, never a guess.
const fresh = fakeStores([node("x", "X", [pt("chat1")])], { events });
assert.deepEqual(graphSessionPropositions(fresh, "chat1"), [
{ nodeId: "x", name: "X", understanding: null, evaluatedAt: null },
]);
});
});
+12
View File
@@ -77,6 +77,18 @@ describe("parseAgentSettingsBody", () => {
assert.equal(parseAgentSettingsBody({ search: { engine: 1 } }).ok, false);
});
it("graphTools: strict object subsection, enabled boolean when present — wave 7, 2026-09-23", () => {
const off = parseAgentSettingsBody({ graphTools: { enabled: false } });
assert.ok(off.ok);
assert.deepEqual(off.settings.graphTools, { enabled: false });
const bare = parseAgentSettingsBody({ graphTools: {} });
assert.ok(bare.ok);
assert.deepEqual(bare.settings.graphTools, {}, "explicit subsection with no key = the default ON");
assert.equal(parseAgentSettingsBody({ graphTools: "off" }).ok, false, "graphTools must be an object");
assert.equal(parseAgentSettingsBody({ graphTools: { enabled: "yes" } }).ok, false, "enabled must be a boolean");
assert.equal(parseAgentSettingsBody({ graphTools: null }).ok, false);
});
it("an empty {} agent section parses to {} (replace-whole clear)", () => {
const r = parseAgentSettingsBody({});
assert.ok(r.ok);
+17
View File
@@ -12,6 +12,7 @@ import { describe, it } from "node:test";
import {
extractionEnabled,
graphContextEnabled,
graphToolsEnabled,
resolveConversationSpec,
resolveModelSpec,
resolveRoleSpec,
@@ -243,4 +244,20 @@ describe("the graph channel gates (the wave's seam honoring)", () => {
assert.equal(extractionEnabled(off.graph), false);
assert.equal(graphContextEnabled(off.graph), false);
});
it("PROBER GRAPH TOOLS gate (wave 7, owner ruling 2026-09-23): the agent toggle AND the graph master switch compose", () => {
const graph = { enabled: true, embedding: true, clustering: true, labeling: true, embeddingModel: null };
assert.equal(graphToolsEnabled({ graph, agent: { graphTools: {} } }), true, "absent subsection = ON");
assert.equal(graphToolsEnabled({ graph, agent: { graphTools: { enabled: true } } }), true);
assert.equal(graphToolsEnabled({ graph, agent: { graphTools: { enabled: false } } }), false);
const dead = { ...graph, enabled: false };
assert.equal(graphToolsEnabled({ graph: dead, agent: { graphTools: { enabled: true } } }), false, "graph off kills the tools regardless of the toggle");
});
it("graphTools rides resolveSettings: absent record → the empty subsection (ON); a record's delta passes through", () => {
const eff = resolveSettings(null);
assert.deepEqual(eff.agent.graphTools, {});
const off = resolveSettings({ version: 1, agent: { graphTools: { enabled: false } } });
assert.deepEqual(off.agent.graphTools, { enabled: false });
});
});
+13
View File
@@ -167,6 +167,19 @@ describe("sanitizeUserSettings", () => {
assert.equal(notObject?.agent?.search, undefined);
});
it("graphTools subsection: boolean survives; bad shapes drop (default ON resumes) — wave 7, 2026-09-23", () => {
const on = sanitizeUserSettings({ version: 1, agent: { graphTools: { enabled: true } } });
assert.deepEqual(on?.agent?.graphTools, { enabled: true });
const off = sanitizeUserSettings({ version: 1, agent: { graphTools: { enabled: false } } });
assert.deepEqual(off?.agent?.graphTools, { enabled: false });
const bare = sanitizeUserSettings({ version: 1, agent: { graphTools: {} } });
assert.deepEqual(bare?.agent?.graphTools, {});
const badShape = sanitizeUserSettings({ version: 1, agent: { graphTools: "on" } });
assert.equal(badShape?.agent?.graphTools, undefined);
const badEnabled = sanitizeUserSettings({ version: 1, agent: { graphTools: { enabled: "yes" } } });
assert.equal(badEnabled?.agent?.graphTools, undefined);
});
it("partial maps: booleans survive, non-boolean entries drop individually", () => {
const out = sanitizeUserSettings({ agent: { tools: { quiz: false, demo: true, visual: "x" } } });
assert.ok(out);