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
+6 -12
View File
@@ -58,6 +58,12 @@ export interface NotesSource {
filePath: string;
/** ISO — when this file was imported (the checkpoint's foldedAt). */
importedAt: string;
/** sha256 of the imported text at the last EXTRACTION run — the
* same-path re-import guard (owner, 2026-09-29): a matching
* fingerprint is a TRUE no-op (no model calls, no usage record);
* a mismatch or a legacy missing stamp re-runs the whole file.
* The mappings ledger used to give this idempotency by accident. */
contentFp?: string;
/** The run's usage log — same append-only discipline as
* ChatRecord.extractions, idempotent per startedAt. */
extractions?: ExtractionRunUsage[];
@@ -109,18 +115,6 @@ export interface GraphEdge {
provenance: ProvenancePoint[];
}
/** mappings.jsonl line — the session→graph identity map: the extraction
* pipeline's proposition (identified by its run-local source id) → the
* graph node it resolved to (created or linked). The audit trail of the
* auditor's identity verdicts; also the chat-scoped resolution table the
* merger uses to point events and edges at node ids. */
export interface NodeMapping {
chatId: string;
/** The extractor's run-local proposition id ("p1", …). */
sourceId: string;
graphNodeId: string;
}
/** sessions.jsonl line — the emission checkpoint for one chat (which chats
* have been folded, and through which updatedAt — never fold twice). */
export interface SessionCheckpoint {
+5 -30
View File
@@ -13,7 +13,7 @@
*
* preserve-graph the chat's extraction bookkeeping dies — its coverage
* rows and its legacy checkpoint lines — and NOTHING else:
* nodes, edges, mappings stay byte-identical.
* nodes and edges stay byte-identical.
* The graph keeps the session's contributions; the
* snapshot simply no longer resolves the dead chatId to
* a title (nodeSessions filters unknown ids at read
@@ -22,8 +22,8 @@
* delete-nodes "this session never happened": every trace of the
* chat leaves the ledgers — its provenance points on
* every node and edge line, its link lines (their
* phrasings die with them), its mapping lines (the audit trail), its
* coverage/checkpoint rows — and orphans die: a node
* phrasings die with them), its coverage/checkpoint
* rows — and orphans die: a node
* whose references all came from this chat disappears
* entirely, pruned from the positions and embedding
* ledgers too (dead members would break cluster hulls).
@@ -57,7 +57,6 @@ import type {
CoverageEntry,
GraphEdge,
GraphNode,
NodeMapping,
SessionCheckpoint,
} from "./contracts.ts";
import type { GraphStore } from "./ports.ts";
@@ -74,8 +73,6 @@ export interface ChatDeletionStats {
nodesRemoved: number;
/** Edge assertion LINES removed (empty-provenance or dead endpoint). */
edgesRemoved: number;
/** Mapping lines removed (the chat's audit trail). */
mappingsRemoved: number;
/** Coverage rows removed. */
coverageRemoved: number;
/** Checkpoint lines removed. */
@@ -85,7 +82,6 @@ export interface ChatDeletionStats {
const EMPTY_STATS: ChatDeletionStats = {
nodesRemoved: 0,
edgesRemoved: 0,
mappingsRemoved: 0,
coverageRemoved: 0,
checkpointsRemoved: 0,
};
@@ -121,7 +117,6 @@ export function deleteChatTraces(graphStore: GraphStore, chatId: string, mode: C
// ── delete-nodes: the full purge ──────────────────────────────────────
const nodeLines = graphStore.readNodes();
const edgeLines = graphStore.readEdges();
const mappings = graphStore.readMappings();
// Does the chat touch the graph at all? (A never-extracted chat still
// needs its coverage rows dropped above, but no ledger rewrite — keeps
@@ -129,8 +124,7 @@ export function deleteChatTraces(graphStore: GraphStore, chatId: string, mode: C
// changed.)
const touched =
nodeLines.some((line) => line.provenance.some((pt) => pt.chatId === chatId)) ||
edgeLines.some((line) => line.provenance.some((pt) => pt.chatId === chatId)) ||
mappings.some((m) => m.chatId === chatId);
edgeLines.some((line) => line.provenance.some((pt) => pt.chatId === chatId));
if (!touched) {
if (stats.coverageRemoved > 0 || stats.checkpointsRemoved > 0) {
graphStore.rewriteLedgers({ coverage, checkpoints });
@@ -153,9 +147,6 @@ export function deleteChatTraces(graphStore: GraphStore, chatId: string, mode: C
for (const line of nodeLines) {
if (survives(line.provenance)) live.add(line.id);
}
for (const mapping of mappings) {
if (mapping.chatId !== chatId) live.add(mapping.graphNodeId);
}
// 2. Node lines. Per-line (each line is ONE extraction run's append, so
// per-session alias provenance exists at line granularity):
@@ -209,17 +200,12 @@ export function deleteChatTraces(graphStore: GraphStore, chatId: string, mode: C
outEdges.push(points.length === line.provenance.length ? line : { ...line, provenance: points });
}
// 4. Mappings: the chat's rows die outright.
const outMappings = mappings.filter((m) => m.chatId !== chatId);
stats.nodesRemoved = [...new Set(nodeLines.map((line) => line.id))].filter((id) => !live.has(id)).length;
stats.edgesRemoved = edgeLines.length - outEdges.length;
stats.mappingsRemoved = mappings.length - outMappings.length;
graphStore.rewriteLedgers({
nodes: outNodes,
edges: outEdges,
mappings: outMappings,
coverage,
checkpoints,
});
@@ -272,14 +258,11 @@ export function deleteChatTraces(graphStore: GraphStore, chatId: string, mode: C
export interface NodeDeletionStats {
/** Edge assertion LINES removed (every edge touching the node). */
edgesRemoved: number;
/** Mapping lines removed (the node's audit trail). */
mappingsRemoved: number;
}
/** The confirm dialog's pre-flight facts (the GET on the same node id):
* what full death would take with it. sessions is the deduped set of
* chats/notes sources that reference the node — provenance points and
* mapping lines count. */
* chats/notes sources that reference the node — provenance points count. */
export interface NodeDeletionImpact {
edges: number;
sessions: string[];
@@ -298,9 +281,6 @@ export function nodeDeletionImpact(graphStore: GraphStore, nodeId: string): Node
for (const line of nodeLinesOf(graphStore, nodeId)) {
for (const pt of line.provenance) sessions.add(pt.chatId);
}
for (const mapping of graphStore.readMappings()) {
if (mapping.graphNodeId === nodeId) sessions.add(mapping.chatId);
}
return {
edges: edges.length,
sessions: [...sessions].sort(),
@@ -318,10 +298,7 @@ export function deleteNodeTraces(graphStore: GraphStore, nodeId: string): NodeDe
if (nodeLines.length === 0) return null;
const edgeLines = graphStore.readEdges();
const mappings = graphStore.readMappings();
const outEdges = edgeLines.filter((line) => line.from !== nodeId && line.to !== nodeId);
const outMappings = mappings.filter((m) => m.graphNodeId !== nodeId);
// Only the touched ledgers: the node's own lines always die (its file
// must move), everything else rewrites only when rows actually drop.
@@ -329,7 +306,6 @@ export function deleteNodeTraces(graphStore: GraphStore, nodeId: string): NodeDe
graphStore.rewriteLedgers({
nodes: outNodes,
...(outEdges.length !== edgeLines.length ? { edges: outEdges } : {}),
...(outMappings.length !== mappings.length ? { mappings: outMappings } : {}),
});
// The resting position dies with the node.
@@ -341,6 +317,5 @@ export function deleteNodeTraces(graphStore: GraphStore, nodeId: string): NodeDe
return {
edgesRemoved: edgeLines.length - outEdges.length,
mappingsRemoved: mappings.length - outMappings.length,
};
}
+33 -44
View File
@@ -55,7 +55,7 @@ import type {
GraphSnapshot,
} from "./contracts.ts";
import { MIN_CLUSTER_SIZE } from "./clusters.ts";
import { birthMappingIndex, birthSessionOf, birthSessionPartition } from "./session-clusters.ts";
import { birthSessionOf, birthSessionPartition } from "./session-clusters.ts";
import type { ChatStore, GraphStore } from "./ports.ts";
/** The storage ports every graph operation needs, passed explicitly. */
@@ -101,21 +101,23 @@ const samePoint = (a: ProvenancePoint, b: ProvenancePoint): boolean =>
// ─── the merger (extraction pipeline stage 3 — deterministic) ────────────────
/**
* Merge one chat's audited findings into the graph ledgers — the pipeline's
* apply stage. Deterministic + idempotent: re-merging the same verdicts (or
* a later re-extraction of the same chat) appends nothing but the new.
* Merge one job's audited findings into the graph ledgers — the pipeline's
* apply stage. Deterministic. Each chunk/batch job merges INDEPENDENTLY
* (owner, 2026-09-29): resolution is a job-local in-memory map from this
* submit's proposition ids to graph node ids — nothing persists between
* jobs, so no job can see (or collide with) another's ids. Cross-job
* identity is the auditor's alone: it links this job's propositions to
* existing nodes before this function is ever called.
*
* Identity is the auditor's link-or-create verdict: `linkTo` an existing
* node (validated against the ledger — an unknown id falls back to create)
* appends a LINK line (the session's phrasings join the node's aliases and
* the provenance unions; getGraph merges lines by id); absent → a new node.
* Mapping lines record each verdict: one per (chat, sourceId). Idempotent:
* a proposition id already mapped for this chat reuses its resolution.
* Identity inside the job is the auditor's link-or-create verdict: `linkTo`
* an existing node (validated against the ledger — an unknown id falls
* back to create) appends a LINK line (the job's phrasings join the node's
* aliases and the provenance unions; getGraph merges lines by id);
* absent → a new node.
*
* `chatId` is the run's source key: a real chat, or a notes source id
* ("src_…") for the format-agnostic import path — same ledgers, same
* idempotency, one graph. The `source` parameter carries the checkpoint
* stamp; the merger itself is chat-store-free.
* `chatId` is the job's source key: a real chat, or a notes source id
* ("src_…") for the format-agnostic import path. The `source` parameter
* carries the checkpoint stamp; the merger itself is chat-store-free.
*/
export function mergeFindings(
chatId: string,
@@ -127,26 +129,22 @@ export function mergeFindings(
const { graphStore: ledgers } = deps;
const stats: EmissionStats = { nodes: 0, edges: 0 };
// 1. Resolution: this run's propositions → graph node ids. Existing
// mapping lines for THIS chat are loaded first — the audit trail doubles
// as the resolution table, which makes re-merging the same chat
// idempotent (a re-extraction re-merges through the auditor's linkTo
// verdicts instead). Fresh sourceIds resolve as the verdicts say.
// 1. Resolution: THIS job's propositions → graph node ids, built
// in-memory as the propositions resolve (owner, 2026-09-29: the
// mappings ledger is gone — a persisted table was a shared namespace
// across independent jobs, and cross-job id collisions silently
// dropped propositions and welded wrong edges). Dedupe across runs
// is the coverage ledger (nothing re-extracts) and the auditor's
// equivalence links (re-encountered knowledge merges in).
const existingIds = new Set(ledgers.readNodes().map((n) => n.id));
const mapping = new Map<string, string>();
const seenMappingKeys = new Set<string>();
for (const m of ledgers.readMappings()) {
if (m.chatId !== chatId) continue;
mapping.set(m.sourceId, m.graphNodeId);
seenMappingKeys.add(m.sourceId);
}
const now = new Date().toISOString();
const linkOf = new Map(verdicts.links.map((l) => [l.id, l.linkTo]));
// VERDICTS-ONLY (owner, 2026-09-28): the propositions come from the
// extractor's findings verbatim; the auditor's links join here by id.
for (const p of findings.propositions) {
if (mapping.has(p.id)) continue; // already resolved for this chat — idempotent
if (mapping.has(p.id)) continue; // duplicate id WITHIN this submit — first resolution stands
const points = pointOf(chatId);
const linkTo = linkOf.get(p.id);
let graphNodeId: string;
@@ -180,20 +178,14 @@ export function mergeFindings(
existingIds.add(graphNodeId);
}
mapping.set(p.id, graphNodeId);
if (!seenMappingKeys.has(p.id)) {
ledgers.appendMapping({ chatId, sourceId: p.id, graphNodeId });
seenMappingKeys.add(p.id);
}
}
// 2. Edges — the extractor's own first, then the auditor's BRIDGING
// edges (endpoints may be local ids and/or EXISTING graph node ids —
// the auditor welding the batch into the graph it explored). Both
// resolve through the mapping, both carry the run's chat-level
// provenance point, and both dedupe by (from,to,type) already
// asserted by THIS chat: an echoed duplicate (a weak auditor
// restating an extractor edge) merges to one append; an assertion
// by another chat stays untouched (provenance unions at read time).
// 2. Edges — the extractor's own, the ONLY edge source (the auditor's
// edges argument is gone, owner 2026-09-28 evening). Endpoints are
// this job's local proposition ids and resolve through the job-local
// map; edges carry the run's chat-level provenance point and dedupe
// by (from,to,type) already asserted by THIS chat — an assertion by
// another chat stays untouched (provenance unions at read time).
const assertedByChat = new Set<string>();
for (const line of ledgers.readEdges()) {
const key = `${line.from}\u0000${line.to}\u0000${line.type}`;
@@ -295,7 +287,6 @@ export function graphSnapshot(deps: GraphDeps): GraphSnapshot {
}
chats.add(chatId);
};
for (const m of deps.graphStore.readMappings()) touch(m.graphNodeId, m.chatId);
for (const node of nodes) {
for (const pt of node.provenance) touch(node.id, pt.chatId);
}
@@ -339,7 +330,7 @@ export function graphSnapshot(deps: GraphDeps): GraphSnapshot {
// 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, deps.graphStore.readMappings());
const communities = birthSessionPartition(nodes);
const clusters: Cluster[] = communities
.filter((community) => community.members.length >= MIN_CLUSTER_SIZE)
.map((community) => ({
@@ -396,8 +387,7 @@ export interface GraphSessionEntry {
export function graphSessionIndex(deps: GraphDeps): GraphSessionEntry[] {
const counts = new Map<string, number>();
const born = getGraph(deps.graphStore);
const mappingFallback = birthMappingIndex(deps.graphStore.readMappings());
for (const cluster of birthSessionPartition(born.nodes, deps.graphStore.readMappings())) {
for (const cluster of birthSessionPartition(born.nodes)) {
counts.set(cluster.id, cluster.members.length);
}
const chats = new Map(deps.chatStore.listChats().map((chat) => [chat.id, chat]));
@@ -445,8 +435,7 @@ export interface GraphSessionProposition {
/** The propositions BORN in one session — what that session first
* articulated. Members sorted by node id — deterministic. */
export function graphSessionPropositions(deps: GraphDeps, sessionId: string): GraphSessionProposition[] {
const mappingFallback = birthMappingIndex(deps.graphStore.readMappings());
const born = getGraph(deps.graphStore).nodes.filter((node) => birthSessionOf(node, mappingFallback) === sessionId);
const born = getGraph(deps.graphStore).nodes.filter((node) => birthSessionOf(node) === sessionId);
return born
.map((node) => ({ nodeId: node.id, name: node.name }))
.sort((a, b) => (a.nodeId < b.nodeId ? -1 : 1));
+2 -3
View File
@@ -1,4 +1,4 @@
import type { GraphEdge, GraphNode, NodeMapping, Position } from "./contracts.ts";
import type { GraphEdge, GraphNode, Position } from "./contracts.ts";
import { incrementalLayout } from "./layout.ts";
import { birthSessionPartition } from "./session-clusters.ts";
@@ -19,7 +19,6 @@ import { birthSessionPartition } from "./session-clusters.ts";
export function settleGraphLayout(graphStore: {
readNodes(): GraphNode[];
readEdges(): GraphEdge[];
readMappings(): NodeMapping[];
readPositions(): Record<string, Position>;
writePositions(positions: Record<string, Position>): void;
}): void {
@@ -39,7 +38,7 @@ export function settleGraphLayout(graphStore: {
// 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, graphStore.readMappings());
const communities = birthSessionPartition(unique);
const next = incrementalLayout(unique, edges, previous, { clusters: communities });
graphStore.writePositions(Object.fromEntries(next));
}
-6
View File
@@ -19,7 +19,6 @@ import type {
JobKind,
JobRecord,
ExtractionRunUsage,
NodeMapping,
PlanTree,
Position,
CoverageEntry,
@@ -84,10 +83,6 @@ export interface GraphStore {
/** Append one quiz outcome / understanding observation. */
/** Append one identity-map line (chat's tree node → graph node). */
appendMapping(mapping: NodeMapping): void;
readMappings(): NodeMapping[];
/** Append one emission checkpoint. */
appendCheckpoint(checkpoint: SessionCheckpoint): void;
readCheckpoints(): SessionCheckpoint[];
@@ -127,7 +122,6 @@ export interface GraphStore {
rewriteLedgers(next: {
nodes?: GraphNode[];
edges?: GraphEdge[];
mappings?: NodeMapping[];
coverage?: CoverageEntry[];
checkpoints?: SessionCheckpoint[];
}): void;
+14 -38
View File
@@ -21,24 +21,18 @@
*
* 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). The
* mapping-line fallback (owner-approved 2026-09-23, surfaced by the
* interlude's live smoke): production notes imports create provenance-
* EMPTY nodes (the notes front door passes messages: null — the file's
* anchor is the mapping line), so a provenance-empty node's birth session
* is its FIRST mapping line's chatId in ledger order. Only notes-born
* nodes ever hit the fallback — chat extraction always writes provenance
* points, and a re-encountered node's link-append adds one, which
* re-opens the provenance rule above. Deletion-safe: a deleted chat's
* mapping lines are stripped alongside its provenance; notes mappings
* survive because files are never deleted.
* them with the file's path (kind: file in the session views). The old
* mapping-line fallback for provenance-empty notes nodes is GONE (owner,
* 2026-09-29): every merge writes provenance points, and the mappings
* ledger itself died with per-job in-memory resolution — provenance is
* the one birth record.
*
* 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, NodeMapping } from "./contracts.ts";
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
@@ -51,44 +45,26 @@ export interface BirthSessionCluster {
members: string[];
}
/** A node's birth session: its first surviving provenance point's chatId;
* for a provenance-empty node, its first mapping line's chatId (the notes
* fallback — see the module doc). null when neither exists. */
export function birthSessionOf(node: GraphNode, mappingFallback?: ReadonlyMap<string, string>): string | null {
const first = node.provenance[0];
if (first !== undefined) return first.chatId;
return mappingFallback?.get(node.id) ?? null;
}
/** The notes fallback index: nodeId → the chatId of the node's FIRST
* mapping line in ledger order. */
export function birthMappingIndex(mappings: readonly NodeMapping[]): Map<string, string> {
const index = new Map<string, string>();
for (const mapping of mappings) {
if (!index.has(mapping.graphNodeId)) index.set(mapping.graphNodeId, mapping.chatId);
}
return index;
/** A node's birth session: its first surviving provenance point's chatId.
* null when the node carries no provenance at all (legacy data). */
export function birthSessionOf(node: GraphNode): string | null {
return node.provenance[0]?.chatId ?? null;
}
/**
* 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
* fall back to their first mapping line (notes-born); a node with neither
* is left unclustered — it belongs to no session the graph can name.
* ties break by session id ascending). A node with no provenance is left
* unclustered — it belongs to no session the graph can name.
*/
export function birthSessionPartition(
nodes: readonly GraphNode[],
mappings: readonly NodeMapping[] = [],
): BirthSessionCluster[] {
const mappingFallback = birthMappingIndex(mappings);
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, mappingFallback);
const birth = birthSessionOf(node);
if (birth === null) continue;
const bucket = members.get(birth);
if (bucket) bucket.push(node.id);