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:
amosblomqvist
2026-09-29 02:25:32 +03:00
parent 92146248d2
commit 8355fbabdb
17 changed files with 182 additions and 304 deletions
+36 -26
View File
@@ -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);