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:
@@ -1221,11 +1221,19 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
||||
|
||||
/** Import ONE raw notes file: register the source (deterministic id per
|
||||
* file path — re-import keeps the same identity and its usage history)
|
||||
* and run the pipeline over it. */
|
||||
* and run the pipeline over it. Re-import guard (owner, 2026-09-29):
|
||||
* the file's content fingerprint decides — unchanged text is a TRUE
|
||||
* no-op (the mappings ledger used to dedupe this at merge time by
|
||||
* accident); changed text re-runs the whole file. */
|
||||
async function importNotes(input: NotesImportInput): Promise<EmissionStats> {
|
||||
const id = `src_${crypto.createHash("sha256").update(input.filePath).digest("hex").slice(0, 12)}`;
|
||||
const contentFp = crypto.createHash("sha256").update(input.text).digest("hex").slice(0, 16);
|
||||
if (graphStore.readSources().find((s) => s.id === id)?.contentFp === contentFp) {
|
||||
console.info(`[extraction ${id}] re-import with unchanged content (fp match) — skipping`);
|
||||
return { nodes: 0, edges: 0 };
|
||||
}
|
||||
const importedAt = new Date().toISOString();
|
||||
graphStore.upsertSource({ id, filePath: input.filePath, importedAt });
|
||||
graphStore.upsertSource({ id, filePath: input.filePath, importedAt, contentFp });
|
||||
return runNotesExtraction({ id, ...input, importedAt });
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -57,7 +57,6 @@ export function graphRevision(): number {
|
||||
for (const file of [
|
||||
LEDGER_FILES.nodes,
|
||||
LEDGER_FILES.edges,
|
||||
LEDGER_FILES.mappings,
|
||||
LEDGER_FILES.sessions,
|
||||
LEDGER_FILES.coverage,
|
||||
POSITIONS_FILE,
|
||||
|
||||
@@ -10,8 +10,6 @@
|
||||
* edges.jsonl GraphEdge — one line per edge ASSERTION (a second
|
||||
* session asserting the same (from,to,type)
|
||||
* appends another line; reads merge)
|
||||
* mappings.jsonl NodeMapping — the session→graph identity map:
|
||||
* (chatId, sourceId) → graphNodeId
|
||||
* sessions.jsonl SessionCheckpoint— emission checkpoints (foldedAt)
|
||||
*
|
||||
* Plus config.SOURCES_FILE (sources.json): the imported-notes bookkeeping
|
||||
@@ -34,7 +32,6 @@ import type {
|
||||
GraphNode,
|
||||
ExtractionRunUsage,
|
||||
NotesSource,
|
||||
NodeMapping,
|
||||
Position,
|
||||
ProvenancePoint,
|
||||
CoverageEntry,
|
||||
@@ -107,9 +104,6 @@ const isGraphEdge = (v: unknown): v is GraphEdge =>
|
||||
Array.isArray(v.provenance) &&
|
||||
v.provenance.every(isProvenancePoint);
|
||||
|
||||
const isNodeMapping = (v: unknown): v is NodeMapping =>
|
||||
isRecord(v) && isString(v.chatId) && isString(v.sourceId) && isString(v.graphNodeId);
|
||||
|
||||
const isCheckpoint = (v: unknown): v is SessionCheckpoint =>
|
||||
isRecord(v) && isString(v.chatId) && isString(v.foldedAt);
|
||||
|
||||
@@ -137,13 +131,6 @@ export const graphStore: GraphStore = {
|
||||
return readValid(LEDGER_FILES.edges, isGraphEdge);
|
||||
},
|
||||
|
||||
appendMapping(mapping: NodeMapping): void {
|
||||
appendLine(LEDGER_FILES.mappings, mapping);
|
||||
},
|
||||
readMappings(): NodeMapping[] {
|
||||
return readValid(LEDGER_FILES.mappings, isNodeMapping);
|
||||
},
|
||||
|
||||
appendCheckpoint(checkpoint: SessionCheckpoint): void {
|
||||
appendLine(LEDGER_FILES.sessions, checkpoint);
|
||||
},
|
||||
@@ -198,14 +185,12 @@ export const graphStore: GraphStore = {
|
||||
rewriteLedgers(next: {
|
||||
nodes?: GraphNode[];
|
||||
edges?: GraphEdge[];
|
||||
mappings?: NodeMapping[];
|
||||
coverage?: CoverageEntry[];
|
||||
checkpoints?: SessionCheckpoint[];
|
||||
}): void {
|
||||
const files = {
|
||||
nodes: LEDGER_FILES.nodes,
|
||||
edges: LEDGER_FILES.edges,
|
||||
mappings: LEDGER_FILES.mappings,
|
||||
coverage: LEDGER_FILES.coverage,
|
||||
checkpoints: LEDGER_FILES.sessions,
|
||||
} as const;
|
||||
|
||||
Reference in New Issue
Block a user