From 0fcffdffaf1d2e539488b5a55410efd7364217eb Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 23 Sep 2026 14:51:37 +0300 Subject: [PATCH] Birth-session clustering + the prober graph-tools data seam (wave 7 chunk A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/core/graph.ts | 146 +++++++++++++++--- src/core/layout-settle.ts | 23 +-- src/core/session-clusters.ts | 73 +++++++++ src/core/settings-resolution.ts | 18 +++ src/core/settings.ts | 16 ++ src/server/settings-body.ts | 12 ++ tests/graph.test.ts | 44 ++---- tests/layout.test.ts | 2 - tests/session-clusters.test.ts | 236 ++++++++++++++++++++++++++++++ tests/settings-body.test.ts | 12 ++ tests/settings-resolution.test.ts | 17 +++ tests/settings.test.ts | 13 ++ 12 files changed, 551 insertions(+), 61 deletions(-) create mode 100644 src/core/session-clusters.ts create mode 100644 tests/session-clusters.test.ts diff --git a/src/core/graph.ts b/src/core/graph.ts index 986c81b..c5a780d 100644 --- a/src/core/graph.ts +++ b/src/core/graph.ts @@ -51,8 +51,10 @@ import type { GraphSnapshot, LedgerEvent, LedgerEventKind, + UnderstandingLevel, } from "./contracts.ts"; -import { matchClusterLabelings, MIN_CLUSTER_SIZE } from "./clusters.ts"; +import { MIN_CLUSTER_SIZE } from "./clusters.ts"; +import { birthSessionPartition } from "./session-clusters.ts"; import type { QuestionAnswerData } from "./question-core.ts"; import type { ChatStore, GraphStore } from "./ports.ts"; @@ -496,23 +498,20 @@ export function graphSnapshot(deps: GraphDeps): GraphSnapshot { (nodeObservations[nodeId] ??= {})[chatId] = note; } - // Semantic topic communities (DECIDED 2026-09-13, replacing structural - // Louvain): average-linkage clustering over embeddings — related - // topics, not reasoning chains. Edge-orphans join topics by meaning; - // stragglers stay loose (filtered at the presentation threshold - // below). The partition is computed ONCE per embedding batch by the - // embedder (the agglomerative pass is ~1.5s at current scale) and - // PERSISTED in the embedding ledger — reads just load it. Nodes whose - // 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()); + // BIRTH-SESSION CLUSTERS (wave 7, owner-locked 2026-09-23): embeddings + // are dead — a cluster is the SESSION THAT CREATED ITS PROPOSITIONS, + // derived on read from provenance (core/session-clusters.ts). The label + // is the session's NAME (chat title / file path): no LLM naming pass, no + // persisted partition to invalidate. MIN_CLUSTER_SIZE stays the hull + // presentation rule (a region needs >= 3 propositions for a hull). A + // birth session whose record is gone (deleted chat) keeps its members + // but renders unlabeled — honest, never guessed. + const communities = birthSessionPartition(nodes); const clusters: Cluster[] = communities - .map((community, index) => ({ community, claim: claims[index] })) - .filter(({ community }) => community.members.length >= MIN_CLUSTER_SIZE) - .map(({ community, claim }) => ({ - id: claim?.id ?? clusterContentId(community.members), - label: claim?.label ?? null, + .filter((community) => community.members.length >= MIN_CLUSTER_SIZE) + .map((community) => ({ + id: community.id, + label: sessions[community.id] ?? fileSources[community.id] ?? null, members: community.members, })); @@ -533,5 +532,118 @@ export function graphSnapshot(deps: GraphDeps): GraphSnapshot { }; } +// ─── Prober graph tools — the data seam (wave 7, owner-locked 2026-09-23) ─── + +/** One session in the prober's graph view: every provenance anchor the + * graph can name — a real chat (deep-linkable) or an imported notes file + * (owner ruling 2026-09-23: files are birth-sessions too, kind "file"). + * The prober's session-list tool renders these; the per-session view is + * graphSessionPropositions below. Pure derivation over the same ports + * the snapshot reads — Teacher's tool handlers call these in-process. */ +export interface GraphSessionEntry { + /** chatId or notes source id ("src_…") — the birth-session key. */ + id: string; + /** The chat's title, or the file's path — the cluster label's source. */ + name: string; + /** A real chat (its record still exists) or an imported notes file. */ + kind: "chat" | "file"; + /** When the session began: the chat's createdAt, or the file's + * importedAt. null when no record can date it. */ + createdAt: string | null; + /** Most recent activity: the chat's updatedAt, or the file's + * importedAt. null when no record can date it. */ + updatedAt: string | null; + /** How many propositions were BORN in this session (birth-session + * clustering's count — shared/re-encountered nodes count for their + * birth session only). */ + propositions: number; +} + +/** The prober's session list: every birth session, most-recently-active + * first (undatable records sink, in stable id order). */ +export function graphSessionIndex(deps: GraphDeps): GraphSessionEntry[] { + const counts = new Map(); + for (const cluster of birthSessionPartition(getGraph(deps.graphStore).nodes)) { + counts.set(cluster.id, cluster.members.length); + } + const chats = new Map(deps.chatStore.listChats().map((chat) => [chat.id, chat])); + const sources = new Map(deps.graphStore.readSources().map((s) => [s.id, s])); + const entries: GraphSessionEntry[] = []; + for (const [id, propositions] of counts) { + const chat = chats.get(id); if (chat) { + entries.push({ + id, + name: chat.title, + kind: "chat", + createdAt: chat.createdAt, + updatedAt: chat.updatedAt, + propositions, + }); + continue; + } + const source = sources.get(id); + entries.push({ + id, + name: source?.filePath ?? id, + kind: "file", + createdAt: source?.importedAt ?? null, + updatedAt: source?.importedAt ?? null, + propositions, + }); + } + return entries.sort( + (a, b) => + (b.updatedAt ?? "").localeCompare(a.updatedAt ?? "") || + (b.createdAt ?? "").localeCompare(a.createdAt ?? "") || + (a.id < b.id ? -1 : 1), + ); +} + +/** One birth proposition of one session, for the prober's per-session + * view. `understanding` is the extractor's latest observation for + * (node, session) — the named-word scale (solid/shaky/accepted/resisted/ + * not yet engaged) — and `evaluatedAt` is THAT event's own timestamp + * (owner ruling 2026-09-23: quiz outcomes are evidence in the ledger, + * but they never move the evaluation date — the level's event does). */ +export interface GraphSessionProposition { + nodeId: string; + /** The compact handle — the same name the canvas shows. */ + name: string; + understanding: UnderstandingLevel | null; + /** The `at` of the event that minted `understanding`; null when the + * session never evaluated this proposition. */ + evaluatedAt: string | null; +} + +/** The propositions BORN in one session, with the session's latest + * understanding of each. Latest observation wins by `at`, then by + * ledger order (the same tie rule the snapshot's nodeObservations + * uses). Members sorted by node id — deterministic. */ +export function graphSessionPropositions(deps: GraphDeps, sessionId: string): GraphSessionProposition[] { + const born = getGraph(deps.graphStore).nodes.filter( + (node) => node.provenance[0] !== undefined && node.provenance[0]!.chatId === sessionId, + ); + const latest = new Map(); + deps.graphStore.readEvents().forEach((event, index) => { + if (event.kind !== "observation" || event.nodeId === null || event.chatId !== sessionId) return; + if (!event.note) return; + const current = latest.get(event.nodeId); + if (!current || event.at > current.at || (event.at === current.at && index > current.index)) { + latest.set(event.nodeId, { at: event.at, note: event.note, index }); + } + }); + return born + .map((node) => { + const observation = latest.get(node.id); + return { + nodeId: node.id, + name: node.name, + understanding: (observation?.note as UnderstandingLevel | undefined) ?? null, + evaluatedAt: observation?.at ?? null, + }; + }) + .sort((a, b) => (a.nodeId < b.nodeId ? -1 : 1)); +} + // Re-export: the contract owns the shape; legacy importers still reach it. export type { EmissionStats }; diff --git a/src/core/layout-settle.ts b/src/core/layout-settle.ts index 8a405d6..f8ccdf7 100644 --- a/src/core/layout-settle.ts +++ b/src/core/layout-settle.ts @@ -1,5 +1,6 @@ -import type { EmbeddingsLedger, GraphEdge, GraphNode } from "./contracts.ts"; +import type { GraphEdge, GraphNode } from "./contracts.ts"; import { incrementalLayout } from "./layout.ts"; +import { birthSessionPartition } from "./session-clusters.ts"; import type { Position } from "./contracts.ts"; /** @@ -11,17 +12,16 @@ import type { Position } from "./contracts.ts"; * finish before the next /api/graph fetch reflects the change. * * SPLIT FROM layout.ts deliberately (2026-09-13): layout.ts is in the - * browser barrel, and the settle needs the Louvain partition for the - * cluster centroid pull — the heavy deps must not ship in the client - * bundle. Server code imports this module DIRECTLY; it is never - * re-exported through src/core/index.ts. + * browser barrel, and the settle needs the cluster partition for the + * cluster centroid pull — this module must not ship in the client bundle. + * Server code imports it DIRECTLY; it is never re-exported through + * src/core/index.ts. */ export function settleGraphLayout(graphStore: { readNodes(): GraphNode[]; readEdges(): GraphEdge[]; readPositions(): Record; writePositions(positions: Record): void; - readEmbeddings(): EmbeddingsLedger; }): void { const nodes = graphStore.readNodes(); if (nodes.length === 0) return; @@ -34,11 +34,12 @@ export function settleGraphLayout(graphStore: { }); const edges = graphStore.readEdges(); const previous = new Map(Object.entries(graphStore.readPositions())); - // The cluster partition for the centroid pull: the SAME persisted - // semantic topics the snapshot renders (computed by the embedder - // once per batch, stored in the embedding ledger) — one cluster - // concept everywhere. - const communities = graphStore.readEmbeddings().clusters; + // The cluster partition for the centroid pull: the SAME birth-session + // partition the snapshot renders (derived from provenance on read — + // core/session-clusters.ts, wave 7) — one cluster concept everywhere. + // The unique list is the deduped set the partition needs (lines may + // repeat an id — first defines). + const communities = birthSessionPartition(unique); const next = incrementalLayout(unique, edges, previous, { clusters: communities }); graphStore.writePositions(Object.fromEntries(next)); } \ No newline at end of file diff --git a/src/core/session-clusters.ts b/src/core/session-clusters.ts new file mode 100644 index 0000000..ef6f2a0 --- /dev/null +++ b/src/core/session-clusters.ts @@ -0,0 +1,73 @@ +/** + * BIRTH-SESSION CLUSTERING (wave 7, owner-locked 2026-09-23): embeddings + * are dead — a proposition's cluster is the SESSION THAT CREATED IT, + * derived deterministically from provenance. Cluster labels are session + * names (the chat title or the imported file's path): no model call, no + * key, no configuration. What the agglomerative pass grouped by MEANING + * proximity, this groups by WHERE KNOWLEDGE WAS BORN — the owner's call + * that the session that minted a set of propositions is the honest + * "region of the world model" they belong to. + * + * Birth session (owner ruling 2026-09-23): a node's FIRST surviving + * provenance point's chatId — the create line's first point at read time + * (getGraph merges lines by id, first line defines, provenance unions in + * line order — so provenance[0] is the birth point of the surviving + * lines). This makes deletion self-healing with ZERO special handling: + * delete-nodes strips a session's create lines and its provenance points; + * a SURVIVING shared node re-attributes to its earliest remaining + * contributor (provenance[0] walks forward), and a node left with NO + * provenance at all is simply unclustered. The partition is derived on + * every read — there is no persisted partition to invalidate. + * + * Notes imports are birth-sessions too (owner ruling 2026-09-23): their + * nodes carry a "src_…" source id instead of a chat id; the caller labels + * them with the file's path (kind: file in the session views). + * + * Browser-safe pure logic (no node builtins, no fs): same contract style + * as the rest of the core graph derivation. Not re-exported through the + * core barrel — the shell reads clusters from the snapshot; only the + * server-side derivations (snapshot, settle) and tests import this. + */ +import type { GraphNode } from "./contracts.ts"; + +/** One birth-session cluster: the session id + the SORTED member node + * ids born in it. The shape is deliberately the { members } contract + * the layout consumer takes; `id` rides along for consumers that need + * the session identity (the snapshot's cluster id + label join). */ +export interface BirthSessionCluster { + /** The birth session's id — a chatId or a notes source id ("src_…"). */ + id: string; + /** Node ids born in this session, sorted. */ + members: string[]; +} + +/** A node's birth session: its first surviving provenance point's chatId, + * or null when the node carries no provenance at all (unclustered). */ +export function birthSessionOf(node: GraphNode): string | null { + const first = node.provenance[0]; + return first === undefined ? null : first.chatId; +} + +/** + * The birth-session partition over the given node LINES: dedupes by id + * (first line defines — the getGraph rule), groups by birth session, and + * returns the groups size-desc with members sorted (deterministic output: + * ties break by session id ascending). Nodes with no surviving provenance + * are left unclustered — they belong to no session the graph can name. + */ +export function birthSessionPartition(nodes: readonly GraphNode[]): BirthSessionCluster[] { + const members = new Map(); + const seen = new Set(); + for (const node of nodes) { + if (seen.has(node.id)) continue; + seen.add(node.id); + const birth = birthSessionOf(node); + if (birth === null) continue; + const bucket = members.get(birth); + if (bucket) bucket.push(node.id); + else members.set(birth, [node.id]); + } + return [...members.entries()] + .map(([id, list]) => ({ id, members: [...list].sort() })) + .sort((a, b) => b.members.length - a.members.length || (a.id < b.id ? -1 : 1)); +} \ No newline at end of file diff --git a/src/core/settings-resolution.ts b/src/core/settings-resolution.ts index 95596b0..f7b012b 100644 --- a/src/core/settings-resolution.ts +++ b/src/core/settings-resolution.ts @@ -81,6 +81,9 @@ export interface EffectiveSettings { * passthrough delta — engine resolution (pin → auto → unavailable) * lives in server/search.ts, where keys are visible. */ search: { engine?: SearchEngineId }; + /** Prober graph tools (wave 7): passthrough delta as stored — + * absent/undefined enabled = ON; consumers use graphToolsEnabled. */ + graphTools: { enabled?: boolean }; }; /** The resolved strong/cheap/vision slots: each tier's concrete model or * NULL when the tier holds no model (no factory floor — BYOK all the @@ -178,6 +181,7 @@ export function resolveSettings(record: UserSettings | null): EffectiveSettings tools: agent.tools ?? {}, prompts: agent.prompts ?? {}, search: agent.search ?? {}, + graphTools: agent.graphTools ?? {}, }, models: { strong: resolveSlot(modelsSection.strong), @@ -228,3 +232,17 @@ export function extractionEnabled(graph: Required): boolean { export function graphContextEnabled(graph: Required): boolean { return graph.enabled && graph.embedding; } + +/** + * The PROBER GRAPH TOOLS gate (wave 7, owner ruling 2026-09-23): the + * toggle is an agent-capability switch (it gates tool registration), but + * the tools only mean anything when the graph itself runs — so the + * effective gate composes the graph master switch AND the toggle. + * Teacher's tool factories consume this at registration time. + */ +export function graphToolsEnabled(effective: { + graph: Required; + agent: { graphTools: { enabled?: boolean } }; +}): boolean { + return effective.graph.enabled && (effective.agent.graphTools.enabled ?? true); +} diff --git a/src/core/settings.ts b/src/core/settings.ts index 6481f25..9b658c6 100644 --- a/src/core/settings.ts +++ b/src/core/settings.ts @@ -200,6 +200,14 @@ export interface AgentSettings { * engine must still have its key, else search is honestly unavailable. * Absent + no key = unavailable (no silent fallback, no default). */ search?: { engine?: SearchEngineId }; + /** Prober graph tools (wave 7, owner ruling 2026-09-23): read-only + * knowledge-graph views for the prober agent — a session list + + * per-session propositions with understanding levels. Its SUBJECT + * is the graph but its MECHANISM is agent capability (tool + * registration), so it lives here in its OWN subsection, not in the + * tool-toggles map. Absent = enabled (the toggle discipline); the + * effective gate composes graph.enabled AND this switch. */ + graphTools?: { enabled?: boolean }; /** System-prompt override SWITCHES keyed by segment path (absent key = * off). Switch-on requires the override file to exist (enforced by the * route); the switch is explicit so users keep a file on disk but run @@ -419,6 +427,14 @@ function sanitizeAgent(raw: Record): AgentSettings { const engine = (raw.search as Record).engine; if (engine === "tavily" || engine === "brave") out.search = { engine }; } + // Prober graph tools (wave 7): its own agent-section subsection — + // lenient read side (a bad shape drops the key and the default ON + // resumes; the write side's parser is strict). + if (raw.graphTools !== null && typeof raw.graphTools === "object" && !Array.isArray(raw.graphTools)) { + const enabled = (raw.graphTools as Record).enabled; + if (enabled === undefined) out.graphTools = {}; + else if (typeof enabled === "boolean") out.graphTools = { enabled }; + } return out; } diff --git a/src/server/settings-body.ts b/src/server/settings-body.ts index 2f823d3..d83e2d8 100644 --- a/src/server/settings-body.ts +++ b/src/server/settings-body.ts @@ -195,6 +195,18 @@ export function parseAgentSettingsBody(body: unknown): SettingsBodyResult { 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", () => { diff --git a/tests/layout.test.ts b/tests/layout.test.ts index f9e1a11..a3fe029 100644 --- a/tests/layout.test.ts +++ b/tests/layout.test.ts @@ -110,7 +110,6 @@ describe("settleGraphLayout", () => { readEdges: () => [edge("a", "b")], readPositions: () => ({} as Record), writePositions: (p: Record) => 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), writePositions: (p) => void written.push(p), - readEmbeddings: () => ({ model: "", dim: 0, vectors: {}, clusters: [], updatedAt: "" }), }); assert.equal(written.length, 0); }); diff --git a/tests/session-clusters.test.ts b/tests/session-clusters.test.ts new file mode 100644 index 0000000..8d2025f --- /dev/null +++ b/tests/session-clusters.test.ts @@ -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>; + sources?: Array>; + } = {}, +): 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 }, + ]); + }); +}); \ No newline at end of file diff --git a/tests/settings-body.test.ts b/tests/settings-body.test.ts index fa69652..9d4e14b 100644 --- a/tests/settings-body.test.ts +++ b/tests/settings-body.test.ts @@ -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); diff --git a/tests/settings-resolution.test.ts b/tests/settings-resolution.test.ts index f9eaa68..d403578 100644 --- a/tests/settings-resolution.test.ts +++ b/tests/settings-resolution.test.ts @@ -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 }); + }); }); \ No newline at end of file diff --git a/tests/settings.test.ts b/tests/settings.test.ts index 71c29c7..c702c62 100644 --- a/tests/settings.test.ts +++ b/tests/settings.test.ts @@ -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);