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:
+129
-17
@@ -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<string, number>();
|
||||
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<string, { at: string; note: string; index: number }>();
|
||||
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 };
|
||||
|
||||
+12
-11
@@ -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<string, Position>;
|
||||
writePositions(positions: Record<string, Position>): 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));
|
||||
}
|
||||
@@ -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<string, string[]>();
|
||||
const seen = new Set<string>();
|
||||
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));
|
||||
}
|
||||
@@ -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<GraphSettings>): boolean {
|
||||
export function graphContextEnabled(graph: Required<GraphSettings>): 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<GraphSettings>;
|
||||
agent: { graphTools: { enabled?: boolean } };
|
||||
}): boolean {
|
||||
return effective.graph.enabled && (effective.agent.graphTools.enabled ?? true);
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>): AgentSettings {
|
||||
const engine = (raw.search as Record<string, unknown>).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<string, unknown>).enabled;
|
||||
if (enabled === undefined) out.graphTools = {};
|
||||
else if (typeof enabled === "boolean") out.graphTools = { enabled };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -195,6 +195,18 @@ export function parseAgentSettingsBody(body: unknown): SettingsBodyResult<AgentS
|
||||
settings.search = { engine: search.engine };
|
||||
}
|
||||
}
|
||||
// Prober graph tools (wave 7, owner ruling 2026-09-23): an agent-section
|
||||
// subsection, not a tool-toggles key — the mechanism is agent capability
|
||||
// (tool registration), the subject is the graph. Strict write side:
|
||||
// object shape only; `enabled` boolean when present. Absent = ON.
|
||||
if (raw.graphTools !== undefined) {
|
||||
const graphTools = asRecord(raw.graphTools);
|
||||
if (!graphTools) return { ok: false, error: "graphTools must be an object" };
|
||||
if (graphTools.enabled !== undefined && typeof graphTools.enabled !== "boolean") {
|
||||
return { ok: false, error: "graphTools.enabled must be a boolean" };
|
||||
}
|
||||
settings.graphTools = graphTools.enabled === undefined ? {} : { enabled: graphTools.enabled };
|
||||
}
|
||||
return { ok: true, settings };
|
||||
}
|
||||
|
||||
|
||||
+13
-31
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user