delete the mappings ledger — per-job in-memory resolution + notes fingerprint guard
The persisted (chatId, sourceId)→node table was a shared namespace across independent chunk/batch jobs: each chunk's extractor mints local ids fresh (p1..pN), so later chunks collided with earlier ones — propositions silently skipped as 'already resolved' (coverage checkpointed anyway: permanent silent loss) and edges resolved onto the wrong nodes (the 'everything links to everything' mush). Owner ruling 2026-09-29: mergeFindings resolves THIS job's ids in memory, nothing persists between jobs; cross-job identity is the auditor's linkTo verdicts alone. - contracts: NodeMapping dies; NotesSource.contentFp — sha256 of the text at the last run, the re-import guard (unchanged re-import = TRUE no-op, no model calls, no usage; changed text re-runs; legacy unstamped sources re-run once) - merger: job-local resolution map, in-submit duplicate ids skip once, edges resolve extractor-local ids only - ports/store: appendMapping/readMappings, LEDGER_FILES.mappings, rewriteLedgers.mappings gone (stale data/graph/mappings.jsonl just stops being read) - session-clusters: birthSessionOf is provenance-only — the legacy mapping-line fallback for provenance-empty notes nodes dies - deletion: no mapping rows to strip; stats lose mappingsRemoved - replay-layout batches nodes by first provenance point - graph.test pins the new independence semantics (re-merge re-creates; duplicate ids within one submit resolve once); extraction tests pin per-chunk merges + the fingerprint guard
This commit is contained in:
+36
-26
@@ -35,7 +35,7 @@ import type {
|
||||
ExtractionRunUsage,
|
||||
GraphEdge,
|
||||
GraphNode,
|
||||
NodeMapping,
|
||||
|
||||
NotesSource,
|
||||
SessionCheckpoint,
|
||||
SessionFindings,
|
||||
@@ -65,7 +65,6 @@ function fauxEngine(steps: FauxResponseStep[]): PiEngine {
|
||||
function memoryLedgers() {
|
||||
const nodes: GraphNode[] = [];
|
||||
const edges: GraphEdge[] = [];
|
||||
const mappings: NodeMapping[] = [];
|
||||
const checkpoints: SessionCheckpoint[] = [];
|
||||
const coverage: CoverageEntry[] = [];
|
||||
const sources = new Map<string, NotesSource>();
|
||||
@@ -75,8 +74,6 @@ function memoryLedgers() {
|
||||
readNodes: () => nodes.map((n) => ({ ...n })),
|
||||
appendEdge: (e: GraphEdge) => void edges.push(e),
|
||||
readEdges: () => edges.map((e) => ({ ...e })),
|
||||
appendMapping: (m: NodeMapping) => void mappings.push(m),
|
||||
readMappings: () => mappings.map((m) => ({ ...m })),
|
||||
appendCheckpoint: (c: SessionCheckpoint) => void checkpoints.push(c),
|
||||
readCheckpoints: () => checkpoints.map((c) => ({ ...c })),
|
||||
// Extraction coverage (vision-extraction-coverage.md) — append-only;
|
||||
@@ -108,7 +105,6 @@ function memoryLedgers() {
|
||||
rewriteLedgers: (next) => {
|
||||
if (next.nodes) nodes.splice(0, nodes.length, ...next.nodes.map((n) => ({ ...n, provenance: [...n.provenance], aliases: [...n.aliases] })));
|
||||
if (next.edges) edges.splice(0, edges.length, ...next.edges.map((e) => ({ ...e, provenance: [...e.provenance] })));
|
||||
if (next.mappings) mappings.splice(0, mappings.length, ...next.mappings.map((m) => ({ ...m })));
|
||||
if (next.coverage) coverage.splice(0, coverage.length, ...next.coverage.map((c) => ({ ...c })));
|
||||
if (next.checkpoints) checkpoints.splice(0, checkpoints.length, ...next.checkpoints.map((c) => ({ ...c })));
|
||||
},
|
||||
@@ -419,11 +415,11 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
||||
// Link-append: the phrasing joined the existing node's aliases and
|
||||
// the provenance unioned; p2's create line is its own node.
|
||||
assert.equal(ledgers.readNodes().length, 3); // pre-seeded + link line + p2's create
|
||||
const mappings = ledgers.readMappings();
|
||||
assert.equal(mappings.length, 2);
|
||||
assert.deepEqual(mappings[0], { chatId, sourceId: "p1", graphNodeId: existingId });
|
||||
assert.equal(mappings[1]!.sourceId, "p2");
|
||||
assert.match(mappings[1]!.graphNodeId, /^n_/);
|
||||
const linkedLines = ledgers.readNodes().filter((n) => n.id === existingId);
|
||||
assert.ok(
|
||||
linkedLines.some((line) => line.provenance.some((pt) => pt.chatId === chatId)),
|
||||
"the link-append unions provenance",
|
||||
);
|
||||
|
||||
// COVERAGE checkpointed per message (not a whole-chat checkpoint):
|
||||
// one entry per transcript message, fingerprinted content each —
|
||||
@@ -1021,7 +1017,6 @@ describe("the notes front door (importNotes — format-agnostic)", () => {
|
||||
const node = ledgers.readNodes()[0]!;
|
||||
assert.equal(node.statement, "Vim starts in Normal mode, where keys are commands.");
|
||||
assert.deepEqual(node.provenance, [{ chatId: source.id, messageId: "" }]);
|
||||
assert.deepEqual(ledgers.readMappings(), [{ chatId: source.id, sourceId: "p1", graphNodeId: node.id }]);
|
||||
// The checkpoint stamps the import.
|
||||
assert.equal(ledgers.readCheckpoints()[0]!.chatId, source.id);
|
||||
});
|
||||
@@ -1062,10 +1057,12 @@ describe("the notes front door (importNotes — format-agnostic)", () => {
|
||||
assert.ok(calls >= 2, `expected ≥2 extractor calls, got ${calls}`);
|
||||
assert.match(views[0]!, new RegExp(`part 1/${calls}`));
|
||||
assert.match(views.at(-1)!, new RegExp(`part ${calls}/${calls}`));
|
||||
// Same findings in every chunk → ONE node total (mapping dedupe
|
||||
// across chunks), stats never double-count.
|
||||
assert.deepEqual(stats, { nodes: 1, edges: 0 });
|
||||
assert.equal(ledgers.readNodes().length, 1);
|
||||
// Same findings in every chunk → one node PER CHUNK: jobs merge
|
||||
// independently (owner, 2026-09-29 — the mappings ledger is gone);
|
||||
// cross-chunk identity is the auditor's linkTo verdict, and these
|
||||
// fixtures submit none.
|
||||
assert.deepEqual(stats, { nodes: calls, edges: 0 });
|
||||
assert.equal(ledgers.readNodes().length, calls);
|
||||
});
|
||||
|
||||
it("required top-level fields (owner, 2026-09-28): a findings submit MISSING edges fails validation — no silent edge-less merge", async () => {
|
||||
@@ -1098,7 +1095,7 @@ describe("the notes front door (importNotes — format-agnostic)", () => {
|
||||
assert.equal(ledgers.readCoverage().length, 0, "nothing checkpointed — the messages stay re-runnable");
|
||||
});
|
||||
|
||||
it("re-importing the same file is idempotent: same source identity, refs dedupe, usage history grows", async () => {
|
||||
it("re-importing is fingerprint-guarded: unchanged text is a TRUE no-op; changed text re-runs (owner, 2026-09-29)", async () => {
|
||||
const ledgers = memoryLedgers();
|
||||
const mk = () =>
|
||||
createExtractionPipeline({
|
||||
@@ -1110,12 +1107,23 @@ describe("the notes front door (importNotes — format-agnostic)", () => {
|
||||
const first = await mk().importNotes({ filePath: "/vault/learn vim.md", text: NOTES_TEXT });
|
||||
assert.deepEqual(first, { nodes: 1, edges: 0 });
|
||||
|
||||
// Unchanged text: the content fingerprint matches — no extraction
|
||||
// at all (no model calls, no usage record; the mappings ledger
|
||||
// used to give this no-op at merge time by accident).
|
||||
const second = await mk().importNotes({ filePath: "/vault/learn vim.md", text: NOTES_TEXT });
|
||||
assert.deepEqual(second, { nodes: 0, edges: 0 }, "the same source id dedupes everything");
|
||||
assert.deepEqual(second, { nodes: 0, edges: 0 }, "unchanged content is a true no-op");
|
||||
|
||||
assert.equal(ledgers.readSources().length, 1, "one source identity — the path hashes to the same id");
|
||||
assert.equal(ledgers.readSources()[0]!.extractions?.length, 2, "both runs billed their usage");
|
||||
assert.equal(ledgers.readSources()[0]!.extractions?.length, 1, "only the real run billed its usage");
|
||||
assert.equal(ledgers.readNodes().length, 1, "no duplicate node");
|
||||
|
||||
// Changed text under the same path: the fingerprint misses — the
|
||||
// whole file re-runs (fresh merge; the auditor's links would dedupe
|
||||
// identity on a real model).
|
||||
const third = await mk().importNotes({ filePath: "/vault/learn vim.md", text: `${NOTES_TEXT}\nA new closing line.` });
|
||||
assert.deepEqual(third, { nodes: 1, edges: 0 }, "changed content re-extracts");
|
||||
assert.equal(ledgers.readSources()[0]!.extractions?.length, 2, "the re-run bills its usage");
|
||||
assert.equal(ledgers.readNodes().length, 2);
|
||||
});
|
||||
|
||||
it("aborts like a chat run: true cancellation, nothing merged, partial usage in the source record", async () => {
|
||||
@@ -1221,9 +1229,11 @@ describe("the chunked pipeline (extraction coverage)", () => {
|
||||
assert.ok(views[0]!.startsWith("# Chunky coverage fixture"));
|
||||
|
||||
// Batch 1 merged the full findings (verdicts-only: the extractor's
|
||||
// output always fully merges); batch 2's identical ids mapped to
|
||||
// the same nodes (chat-scoped mapping) — nothing new.
|
||||
assert.deepEqual(stats, { nodes: 2, edges: 1 });
|
||||
// output always fully merges); batch 2's identical ids are a
|
||||
// SEPARATE job's namespace (owner, 2026-09-29 — no mapping table)
|
||||
// — its propositions merge again as fresh nodes. Cross-batch
|
||||
// identity is the auditor's linkTo verdict, never the merger's.
|
||||
assert.deepEqual(stats, { nodes: 4, edges: 2 });
|
||||
|
||||
// Per-batch coverage: every message checkpointed, nothing legacy.
|
||||
const coverage = ledgers.readCoverage();
|
||||
@@ -1464,10 +1474,11 @@ describe("per-chunk parallel jobs (owner, 2026-09-28)", () => {
|
||||
|
||||
assert.equal(extractPeak, 3, "8 chunks, 3 permits — the peak pins both the fan-out and the cap");
|
||||
assert.equal(auditPeak, 1, "audits serialize behind the gate — never two at once");
|
||||
// Identical findings in every chunk: one merge, then dedupe — the
|
||||
// aggregate counts real emissions, never double.
|
||||
assert.deepEqual(stats, { nodes: 2, edges: 1 });
|
||||
assert.equal(ledgers.readNodes().length, 2);
|
||||
// Identical findings in every chunk, one merge PER chunk (jobs are
|
||||
// independent — owner, 2026-09-29): 8 chunks × 2 propositions,
|
||||
// 8 × 1 edge. The auditor's links, not the merger, dedupe identity.
|
||||
assert.deepEqual(stats, { nodes: 16, edges: 8 });
|
||||
assert.equal(ledgers.readNodes().length, 16);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1496,7 +1507,6 @@ describe("the session-deletion guard (chat deleted mid-run)", () => {
|
||||
// trace for a later snapshot to resurrect.
|
||||
assert.equal(ledgers.readNodes().length, 0);
|
||||
assert.equal(ledgers.readEdges().length, 0);
|
||||
assert.equal(ledgers.readMappings().length, 0);
|
||||
assert.equal(ledgers.readCoverage().length, 0);
|
||||
assert.equal(ledgers.readCheckpoints().length, 0);
|
||||
|
||||
|
||||
@@ -37,14 +37,12 @@ function fakeStores(
|
||||
nodes: GraphNode[],
|
||||
over: {
|
||||
chats?: Array<Pick<ChatSummary, "id" | "title" | "createdAt" | "updatedAt">>;
|
||||
mappings?: Array<{ chatId: string; sourceId: string; graphNodeId: string }>;
|
||||
} = {},
|
||||
): { chatStore: ChatStore; graphStore: GraphStore } {
|
||||
const edges: GraphEdge[] = [];
|
||||
const graphStore: GraphStore = {
|
||||
readNodes: () => nodes,
|
||||
readEdges: () => edges,
|
||||
readMappings: () => over.mappings ?? [],
|
||||
readCheckpoints: () => [],
|
||||
readCoverage: () => [],
|
||||
readSources: () => [],
|
||||
@@ -217,12 +215,7 @@ describe("auditor graph tools", () => {
|
||||
});
|
||||
|
||||
it("list_propositions: FULL statement text (identity work), no dates", async () => {
|
||||
const { chatStore: cs, graphStore: gs } = fakeStores(nodes, {
|
||||
mappings: [
|
||||
{ chatId: "c1", sourceId: "p1", graphNodeId: "n1" },
|
||||
{ chatId: "c1", sourceId: "p2", graphNodeId: "n2" },
|
||||
],
|
||||
});
|
||||
const { chatStore: cs, graphStore: gs } = fakeStores(nodes);
|
||||
const tools = auditorGraphTools({ chatStore: cs, graphStore: gs });
|
||||
const out = (await tools.list_propositions!.execute!("tc", { session: "c1" })).details as string;
|
||||
assert.match(out, /2 proposition\(s\) born in this session:/);
|
||||
|
||||
+57
-61
@@ -272,14 +272,8 @@ let chat3Id = "";
|
||||
* emissions run concurrently against the same files — scope, never assume). */
|
||||
function scopedGraph(chatIds: string[]) {
|
||||
const graph = getGraph(graphStore);
|
||||
const nodeIds = new Set(
|
||||
graphStore
|
||||
.readMappings()
|
||||
.filter((m) => chatIds.includes(m.chatId))
|
||||
.map((m) => m.graphNodeId),
|
||||
);
|
||||
return {
|
||||
nodes: graph.nodes.filter((n) => nodeIds.has(n.id)),
|
||||
nodes: graph.nodes.filter((n) => n.provenance.some((pt) => chatIds.includes(pt.chatId))),
|
||||
edges: graph.edges.filter((e) => e.provenance.some((pt) => chatIds.includes(pt.chatId))),
|
||||
};
|
||||
}
|
||||
@@ -312,21 +306,45 @@ describe("the merger (mergeFindings)", () => {
|
||||
assert.equal(edge.type, "follows");
|
||||
assert.deepEqual(edge.provenance, [{ chatId: chat1.id, messageId: "" }]);
|
||||
|
||||
// Mapping lines: one per (chat, sourceId).
|
||||
assert.deepEqual(
|
||||
graphStore.readMappings().map((m) => m.sourceId),
|
||||
["p1", "p2", "p3"],
|
||||
);
|
||||
});
|
||||
|
||||
it("is idempotent — re-merging the same verdicts appends nothing", () => {
|
||||
const stats = mergeFindings(chat1.id, fixtureFindings(), fixtureVerdicts(), graphDeps, sessionSource(chat1.id));
|
||||
assert.deepEqual(stats, { nodes: 0, edges: 0 });
|
||||
const graph = scopedGraph([chat1.id]);
|
||||
assert.equal(graph.nodes.length, 3);
|
||||
assert.equal(graph.edges.length, 1);
|
||||
it("merges INDEPENDENTLY per call — no cross-run identity (owner, 2026-09-29)", () => {
|
||||
// The mappings ledger is gone: resolution is a job-local in-memory
|
||||
// map, so a second merge of the same verdicts knows nothing of the
|
||||
// first. Cross-run identity is the AUDITOR's job (linkTo) plus the
|
||||
// coverage ledger (nothing re-extracts) — never the merger's.
|
||||
// (Own chat + unique statements — this suite stacks on shared
|
||||
// files, so nothing here may touch the other tests' counts.)
|
||||
const rep = fixtureChat("Independence fixture", [user("u1", "rep")].flat());
|
||||
const findings: SessionFindings = {
|
||||
propositions: [
|
||||
{ id: "p1", name: "Rep one", statement: "Independence one statement", aliases: [] },
|
||||
{ id: "p2", name: "Rep two", statement: "Independence two statement", aliases: [] },
|
||||
],
|
||||
edges: [{ from: "p1", to: "p2", type: "follows" }],
|
||||
};
|
||||
const first = mergeFindings(rep.id, findings, { links: [] }, graphDeps, sessionSource(rep.id));
|
||||
assert.deepEqual(first, { nodes: 2, edges: 1 });
|
||||
const second = mergeFindings(rep.id, findings, { links: [] }, graphDeps, sessionSource(rep.id));
|
||||
assert.deepEqual(second, { nodes: 2, edges: 1 }, "nothing remembered — everything re-creates");
|
||||
const graph = scopedGraph([rep.id]);
|
||||
assert.equal(graph.nodes.length, 4, "the propositions merge again as fresh nodes");
|
||||
assert.equal(graph.edges.length, 2, "fresh endpoints — fresh edge assertions");
|
||||
// exactly one checkpoint line per merge, though — checkpoints are a log
|
||||
assert.equal(graphStore.readCheckpoints().filter((c) => c.chatId === chat1.id).length, 2);
|
||||
assert.equal(graphStore.readCheckpoints().filter((c) => c.chatId === rep.id).length, 2);
|
||||
});
|
||||
|
||||
it("is idempotent WITHIN one submit — a repeated proposition id resolves once", () => {
|
||||
const dup = fixtureChat("Duplicate-id fixture", [user("u1", "dup")].flat());
|
||||
const findings: SessionFindings = {
|
||||
propositions: [
|
||||
{ id: "p1", name: "Dup", statement: "Dup statement", aliases: [] },
|
||||
{ id: "p1", name: "Dup", statement: "Dup statement", aliases: [] },
|
||||
],
|
||||
edges: [],
|
||||
};
|
||||
const stats = mergeFindings(dup.id, findings, { links: [] }, graphDeps, sessionSource(dup.id));
|
||||
assert.deepEqual(stats, { nodes: 1, edges: 0 }, "the duplicate id is skipped — first resolution stands");
|
||||
});
|
||||
|
||||
it("applies a linkTo verdict: phrasings join aliases, provenance unions, no duplicate node", () => {
|
||||
@@ -374,9 +392,6 @@ describe("the merger (mergeFindings)", () => {
|
||||
alpha.provenance.some((pt) => pt.chatId === chat2.id),
|
||||
"provenance unions",
|
||||
);
|
||||
// The mapping records the verdict: (chat2, q1) → the linked node.
|
||||
const m = graphStore.readMappings().find((x) => x.chatId === chat2.id)!;
|
||||
assert.deepEqual(m, { chatId: chat2.id, sourceId: "q1", graphNodeId: alphaId });
|
||||
});
|
||||
|
||||
it("resolves imported-notes ids to their file paths in the snapshot (static provenance)", () => {
|
||||
@@ -514,11 +529,6 @@ describe("the merger (mergeFindings)", () => {
|
||||
|
||||
const node = getGraph(graphStore).nodes.find((n) => n.statement.startsWith("Vim starts"))!;
|
||||
assert.deepEqual(node.provenance, [{ chatId: "src_notesfixture", messageId: "" }], "one chat-level point — the source key, no message anchors");
|
||||
assert.deepEqual(graphStore.readMappings().find((m) => m.chatId === "src_notesfixture"), {
|
||||
chatId: "src_notesfixture",
|
||||
sourceId: "p1",
|
||||
graphNodeId: node.id,
|
||||
});
|
||||
// The checkpoint stamps the import time.
|
||||
assert.equal(graphStore.readCheckpoints().find((c) => c.chatId === "src_notesfixture")?.foldedAt, "2026-09-13T00:00:00.000Z");
|
||||
});
|
||||
@@ -591,7 +601,7 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
|
||||
* asserts X→Y; dB links X (its own phrasings), creates Z, asserts X→Z.
|
||||
* Deleting dA must: keep X (dB's link line survives, dA's create-line
|
||||
* aliases stripped — the identity stays), kill Y (orphan), keep Z and
|
||||
* dB's edge, kill dA's events/mappings/coverage/checkpoints. */
|
||||
* dB's edge, kill dA's coverage/checkpoints. */
|
||||
function buildTwoSessionScenario() {
|
||||
const dA = fixtureChat("Deletion session A", [
|
||||
user("du1", "teach me the deletion topic"),
|
||||
@@ -611,13 +621,12 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
|
||||
edges: [{ from: "p1", to: "p2", type: "follows", }],
|
||||
};
|
||||
mergeFindings(dA.id, findingsA, { links: [] }, graphDeps, sessionSource(dA.id));
|
||||
// Resolve through THIS session's mappings — the shared ledger holds
|
||||
// lookalike statements from earlier tests, so statement-lookup would
|
||||
// link dB to the wrong node.
|
||||
const mappedId = (sourceId: string): string =>
|
||||
graphStore.readMappings().find((m) => m.chatId === dA.id && m.sourceId === sourceId)!.graphNodeId;
|
||||
const xId = mappedId("p1");
|
||||
const yId = mappedId("p2");
|
||||
// Resolve through THIS session's provenance — the shared ledger
|
||||
// holds lookalike statements from earlier tests, so scope by chat.
|
||||
const bornOf = (chatId: string, statement: string): string =>
|
||||
getGraph(graphStore).nodes.find((n) => n.statement === statement && n.provenance.some((pt) => pt.chatId === chatId))!.id;
|
||||
const xId = bornOf(dA.id, "Shared core statement");
|
||||
const yId = bornOf(dA.id, "Solo fact statement");
|
||||
|
||||
const dB = fixtureChat("Deletion session B", [
|
||||
user("eu1", "more on the shared core"),
|
||||
@@ -637,7 +646,7 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
|
||||
edges: [{ from: "q1", to: "q2", type: "follows", }],
|
||||
};
|
||||
mergeFindings(dB.id, findingsB, { links: [{ id: "q1", linkTo: xId }] }, graphDeps, sessionSource(dB.id));
|
||||
const zId = graphStore.readMappings().find((m) => m.chatId === dB.id && m.sourceId === "q2")!.graphNodeId;
|
||||
const zId = bornOf(dB.id, "B extra statement");
|
||||
|
||||
// Coverage rows (both chats) + positions garnish — the deletion
|
||||
// prunes both ledgers.
|
||||
@@ -658,7 +667,6 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
|
||||
assert.deepEqual(stats, {
|
||||
nodesRemoved: 1,
|
||||
edgesRemoved: 1,
|
||||
mappingsRemoved: 2,
|
||||
coverageRemoved: 2,
|
||||
checkpointsRemoved: 1,
|
||||
} satisfies ChatDeletionStats);
|
||||
@@ -680,9 +688,6 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
|
||||
// Edges: dA's assertion X→Y died; dB's X→Z survived.
|
||||
const scoped = scopedGraph([dA.id, dB.id]);
|
||||
assert.deepEqual(scoped.edges, [{ from: xId, to: zId, type: "follows", provenance: [{ chatId: dB.id, messageId: "" }] }]);
|
||||
// Mappings: dA's audit trail died; dB's stays.
|
||||
const mappings = graphStore.readMappings().filter((m) => [dA.id, dB.id].includes(m.chatId));
|
||||
assert.deepEqual(mappings, [{ chatId: dB.id, sourceId: "q1", graphNodeId: xId }, { chatId: dB.id, sourceId: "q2", graphNodeId: zId }]);
|
||||
// Positions: the orphan pruned.
|
||||
const positions = graphStore.readPositions();
|
||||
assert.equal(positions[yId], undefined);
|
||||
@@ -699,7 +704,7 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
|
||||
graphStore.appendNode({ id: "n_del_append_probe", name: "Probe", statement: "Probe statement", aliases: [], createdAt: "2026-09-23T00:04:00.000Z", provenance: [{ chatId: "chat_foreign_probe", messageId: "" }] });
|
||||
assert.equal(graphStore.readNodes().length, nodesBefore + 1);
|
||||
// Idempotent: a second run finds nothing.
|
||||
assert.deepEqual(deleteChatTraces(graphStore, dA.id, "delete-nodes"), { ...stats, nodesRemoved: 0, edgesRemoved: 0, mappingsRemoved: 0, coverageRemoved: 0, checkpointsRemoved: 0 } satisfies ChatDeletionStats);
|
||||
assert.deepEqual(deleteChatTraces(graphStore, dA.id, "delete-nodes"), { ...stats, nodesRemoved: 0, edgesRemoved: 0, coverageRemoved: 0, checkpointsRemoved: 0 } satisfies ChatDeletionStats);
|
||||
});
|
||||
|
||||
it("preserve-graph: only the extraction bookkeeping dies — the graph ledgers are byte-identical", () => {
|
||||
@@ -707,20 +712,17 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
|
||||
const before = {
|
||||
nodes: graphStore.readNodes(),
|
||||
edges: graphStore.readEdges(),
|
||||
mappings: graphStore.readMappings(),
|
||||
positions: graphStore.readPositions(),
|
||||
};
|
||||
const stats = deleteChatTraces(graphStore, dA.id, "preserve-graph");
|
||||
assert.deepEqual(stats, {
|
||||
nodesRemoved: 0,
|
||||
edgesRemoved: 0,
|
||||
mappingsRemoved: 0,
|
||||
coverageRemoved: 2,
|
||||
checkpointsRemoved: 1,
|
||||
} satisfies ChatDeletionStats);
|
||||
assert.deepEqual(graphStore.readNodes(), before.nodes);
|
||||
assert.deepEqual(graphStore.readEdges(), before.edges);
|
||||
assert.deepEqual(graphStore.readMappings(), before.mappings);
|
||||
assert.deepEqual(graphStore.readPositions(), before.positions);
|
||||
assert.equal(graphStore.readCoverage().some((row) => row.chatId === dA.id), false);
|
||||
assert.equal(graphStore.readCoverage().some((row) => row.chatId === dB.id), true);
|
||||
@@ -765,15 +767,15 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
|
||||
// ─── single-node deletion (node interlude, full death) ───────────────────────
|
||||
|
||||
describe("single-node deletion (deleteNodeTraces + deleteGraphNode, node interlude)", () => {
|
||||
/** The full-death scenario: node X is born in a notes file (the
|
||||
* production orphan shape — EMPTY provenance, the mapping line is the
|
||||
* anchor), re-encountered by a chat (link line: alias + provenance),
|
||||
* asserted in an edge, and evaluated by two sessions. */
|
||||
/** The full-death scenario: node X is born in a notes file (EMPTY
|
||||
* provenance — a legacy line; the mappings anchor is gone, owner
|
||||
* 2026-09-29), re-encountered by a chat (link line: alias +
|
||||
* provenance), and asserted in edges by two sessions. */
|
||||
function buildNodeScenario() {
|
||||
// Clean slate per test: each test in this describe builds its own
|
||||
// scenario from empty ledgers (the suite's isolation covers the
|
||||
// whole file; this covers describe-internal stacking).
|
||||
graphStore.rewriteLedgers({ nodes: [], edges: [], mappings: [] });
|
||||
graphStore.rewriteLedgers({ nodes: [], edges: [] });
|
||||
graphStore.writePositions({});
|
||||
graphStore.appendNode({
|
||||
id: "n_dead",
|
||||
@@ -804,10 +806,6 @@ describe("single-node deletion (deleteNodeTraces + deleteGraphNode, node interlu
|
||||
graphStore.appendEdge({ from: "n_dead", to: "n_keep", type: "follows", provenance: [{ chatId: "chat_node_a", messageId: "m1" }] });
|
||||
graphStore.appendEdge({ from: "n_keep", to: "n_dead", type: "supports", provenance: [{ chatId: "chat_node_b", messageId: "m3" }] });
|
||||
graphStore.appendEdge({ from: "n_keep", to: "n_other", type: "follows", provenance: [{ chatId: "chat_node_b", messageId: "m3" }] });
|
||||
// Mappings: the notes anchor + the chat's audit trail.
|
||||
graphStore.appendMapping({ chatId: "src_nodefile", sourceId: "p1", graphNodeId: "n_dead" });
|
||||
graphStore.appendMapping({ chatId: "chat_node_a", sourceId: "p1", graphNodeId: "n_dead" });
|
||||
graphStore.appendMapping({ chatId: "chat_node_a", sourceId: "p2", graphNodeId: "n_keep" });
|
||||
// Resting positions for both.
|
||||
graphStore.writePositions({ n_dead: { x: 1, y: 1 }, n_keep: { x: 2, y: 2 } });
|
||||
}
|
||||
@@ -816,9 +814,9 @@ describe("single-node deletion (deleteNodeTraces + deleteGraphNode, node interlu
|
||||
buildNodeScenario();
|
||||
assert.deepEqual(nodeDeletionImpact(graphStore, "n_dead"), {
|
||||
edges: 2,
|
||||
// The file anchor + the re-encounter — every referencing
|
||||
// session, deduped and sorted.
|
||||
sessions: ["chat_node_a", "src_nodefile"],
|
||||
// The re-encounter — the only session provenance can name (the
|
||||
// legacy empty-provenance line names none).
|
||||
sessions: ["chat_node_a"],
|
||||
});
|
||||
// Unknown node: null (the route's 404).
|
||||
assert.equal(nodeDeletionImpact(graphStore, "n_missing"), null);
|
||||
@@ -828,7 +826,7 @@ describe("single-node deletion (deleteNodeTraces + deleteGraphNode, node interlu
|
||||
buildNodeScenario();
|
||||
const keepLineBefore = JSON.stringify(graphStore.readNodes().find((n) => n.id === "n_keep"));
|
||||
const stats = deleteNodeTraces(graphStore, "n_dead");
|
||||
assert.deepEqual(stats, { edgesRemoved: 2, mappingsRemoved: 2 } satisfies NodeDeletionStats);
|
||||
assert.deepEqual(stats, { edgesRemoved: 2 } satisfies NodeDeletionStats);
|
||||
|
||||
// The node's EVERY line is gone; the neighbor is byte-identical
|
||||
// (full death is node-scoped — other nodes' sessions keep their
|
||||
@@ -844,8 +842,6 @@ describe("single-node deletion (deleteNodeTraces + deleteGraphNode, node interlu
|
||||
graphStore.readEdges().map((e) => [e.from, e.to]),
|
||||
[["n_keep", "n_other"]],
|
||||
);
|
||||
// The audit trail: X's mappings gone, the neighbor's stay.
|
||||
assert.deepEqual(graphStore.readMappings().map((m) => m.graphNodeId), ["n_keep"]);
|
||||
// The resting position died with the node.
|
||||
const positions = graphStore.readPositions();
|
||||
assert.equal(positions["n_dead"], undefined);
|
||||
@@ -866,7 +862,7 @@ describe("single-node deletion (deleteNodeTraces + deleteGraphNode, node interlu
|
||||
provenance: [{ chatId: "chat_node_solo", messageId: "m1" }],
|
||||
});
|
||||
const stats = deleteNodeTraces(graphStore, "n_solo");
|
||||
assert.deepEqual(stats, { edgesRemoved: 0, mappingsRemoved: 0 } satisfies NodeDeletionStats);
|
||||
assert.deepEqual(stats, { edgesRemoved: 0 } satisfies NodeDeletionStats);
|
||||
assert.equal(graphStore.readNodes().some((n) => n.id === "n_solo"), false);
|
||||
});
|
||||
|
||||
@@ -916,7 +912,7 @@ describe("single-node deletion (deleteNodeTraces + deleteGraphNode, node interlu
|
||||
const revisionBefore = graphRevision();
|
||||
const outcome = deleteGraphNode("n_orch");
|
||||
assert.ok(outcome.ok);
|
||||
assert.deepEqual(outcome.stats, { edgesRemoved: 0, mappingsRemoved: 0 });
|
||||
assert.deepEqual(outcome.stats, { edgesRemoved: 0 });
|
||||
assert.ok(outcome.graphRevision > 0);
|
||||
assert.ok(outcome.graphRevision >= revisionBefore, "the version stamp moved");
|
||||
assert.equal(graphStore.readNodes().some((n) => n.id === "n_orch"), false);
|
||||
|
||||
@@ -108,7 +108,6 @@ describe("settleGraphLayout", () => {
|
||||
const store = {
|
||||
readNodes: () => lines.map((l) => ({ ...l })),
|
||||
readEdges: () => [edge("a", "b")],
|
||||
readMappings: () => [],
|
||||
readPositions: () => ({} as Record<string, Position>),
|
||||
writePositions: (p: Record<string, Position>) => void written.push(p),
|
||||
};
|
||||
@@ -122,7 +121,6 @@ describe("settleGraphLayout", () => {
|
||||
settleGraphLayout({
|
||||
readNodes: () => [],
|
||||
readEdges: () => [],
|
||||
readMappings: () => [],
|
||||
readPositions: () => ({} as Record<string, Position>),
|
||||
writePositions: (p) => void written.push(p),
|
||||
});
|
||||
|
||||
@@ -38,14 +38,12 @@ function fakeStores(
|
||||
over: {
|
||||
chats?: Array<Pick<ChatSummary, "id" | "title" | "createdAt" | "updatedAt">>;
|
||||
sources?: Array<Pick<NotesSource, "id" | "filePath" | "importedAt">>;
|
||||
mappings?: Array<{ chatId: string; sourceId: string; graphNodeId: string }>;
|
||||
} = {},
|
||||
): GraphDeps {
|
||||
const edges: GraphEdge[] = [];
|
||||
const graphStore: GraphStore = {
|
||||
readNodes: () => nodes,
|
||||
readEdges: () => edges,
|
||||
readMappings: () => over.mappings ?? [],
|
||||
readCheckpoints: () => [],
|
||||
readCoverage: () => [],
|
||||
readSources: () =>
|
||||
@@ -103,46 +101,15 @@ describe("birthSessionPartition", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── the notes mapping fallback (owner-approved 2026-09-23) ─────────────────
|
||||
|
||||
describe("the notes mapping-line fallback", () => {
|
||||
// The PRODUCTION notes shape: importNotes passes messages: null, so
|
||||
// nodes are born with EMPTY provenance — the file's anchor is the
|
||||
// mapping line (interlude live-smoke finding: the provenance-only rule
|
||||
// left every imported file unclustered and invisible to the prober).
|
||||
it("empty-provenance nodes birth under their first mapping line's session", () => {
|
||||
const nodes = [
|
||||
node("a", "Alpha", []),
|
||||
node("b", "Beta", []),
|
||||
node("c", "Gamma", []),
|
||||
node("d", "Delta", [pt("chat1")]), // provenance wins — mapping ignored
|
||||
];
|
||||
const mappings = [
|
||||
{ chatId: "src_notes", sourceId: "p1", graphNodeId: "a" },
|
||||
{ chatId: "src_notes", sourceId: "p2", graphNodeId: "b" },
|
||||
{ chatId: "src_notes", sourceId: "p3", graphNodeId: "c" },
|
||||
// A chat mapping for a node that ALSO has provenance: never consulted.
|
||||
{ chatId: "chat2", sourceId: "p4", graphNodeId: "d" },
|
||||
// A mapping for a node that does not exist: ignored.
|
||||
{ chatId: "src_notes", sourceId: "p5", graphNodeId: "ghost" },
|
||||
];
|
||||
const clusters = birthSessionPartition(nodes, mappings);
|
||||
assert.deepEqual(clusters, [
|
||||
{ id: "src_notes", members: ["a", "b", "c"] },
|
||||
{ id: "chat1", members: ["d"] },
|
||||
], "size-desc, then id asc — src_notes first");
|
||||
// No mapping and no provenance: unclustered.
|
||||
assert.deepEqual(birthSessionPartition([node("x", "X", [])], mappings), []);
|
||||
});
|
||||
// ─── notes files as birth sessions (production shape) ────────────────────────
|
||||
|
||||
describe("notes files as birth sessions", () => {
|
||||
// The mapping-line fallback is GONE (owner, 2026-09-29): the mappings
|
||||
// ledger itself died with per-job in-memory resolution, and every merge
|
||||
// writes provenance points — the src id IS the birth session.
|
||||
it("graphSessionIndex lists imported files as kind:file with birth counts (production shape)", () => {
|
||||
const nodes = [node("a", "Alpha", []), node("b", "Beta", []), node("c", "Gamma", [])];
|
||||
const nodes = [node("a", "Alpha", [pt("src_abc")]), node("b", "Beta", [pt("src_abc")]), node("c", "Gamma", [pt("src_abc")])];
|
||||
const deps = fakeStores(nodes, {
|
||||
mappings: [
|
||||
{ chatId: "src_abc", sourceId: "p1", graphNodeId: "a" },
|
||||
{ chatId: "src_abc", sourceId: "p2", graphNodeId: "b" },
|
||||
{ chatId: "src_abc", sourceId: "p3", graphNodeId: "c" },
|
||||
],
|
||||
sources: [{ id: "src_abc", filePath: "/vault/learn vim.md", importedAt: "2026-09-22T00:00:00.000Z" }],
|
||||
});
|
||||
const index = graphSessionIndex(deps);
|
||||
@@ -159,11 +126,7 @@ describe("the notes mapping-line fallback", () => {
|
||||
});
|
||||
|
||||
it("graphSessionPropositions lists the file's births under the src id (a pure birth list, 2026-09-27)", () => {
|
||||
const deps = fakeStores([node("a", "Alpha", []), node("b", "Beta", [])], {
|
||||
mappings: [
|
||||
{ chatId: "src_abc", sourceId: "p1", graphNodeId: "a" },
|
||||
{ chatId: "src_abc", sourceId: "p2", graphNodeId: "b" },
|
||||
],
|
||||
const deps = fakeStores([node("a", "Alpha", [pt("src_abc")]), node("b", "Beta", [pt("src_abc")])], {
|
||||
sources: [{ id: "src_abc", filePath: "/vault/learn vim.md", importedAt: "2026-09-22T00:00:00.000Z" }],
|
||||
});
|
||||
assert.deepEqual(graphSessionPropositions(deps, "src_abc"), [
|
||||
|
||||
Reference in New Issue
Block a user