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
446 lines
18 KiB
TypeScript
446 lines
18 KiB
TypeScript
/**
|
|
* The knowledge graph: merger (the deterministic final stage of the
|
|
* extraction pipeline — vision §1 construction) + derivation (reads).
|
|
*
|
|
* The extraction pipeline is the SOLE graph writer, tree-blind and post-hoc
|
|
* (2026-09-27 cleanup — clear separation of work):
|
|
*
|
|
* extractor (LLM) → SessionFindings: the raw reasoning chains —
|
|
* propositions (lonely relevant ones included) and
|
|
* follows/supports edges,
|
|
* from the source ALONE (whole session as markdown,
|
|
* or raw notes). Fully trusted: no stage re-checks it.
|
|
* auditor (LLM) → AuditVerdicts: identity links (local id → an existing
|
|
* node, or create) via graph-exploration tools —
|
|
* links ONLY (owner, 2026-09-28 evening: the auditor
|
|
* never mints edges; cross-source connection happens
|
|
* solely through identified equivalences). Never sees
|
|
* the source; never drops.
|
|
* merger (HERE) → mergeFindings(): deterministic ledger appends
|
|
*
|
|
* The merger is deliberately the ONLY stage that touches the ledgers, and it
|
|
* is pure code: the LLM supplies exactly the judgment calls (what the
|
|
* propositions are, what follows/supports what, whether a proposition IS an
|
|
* existing node), while timestamps/provenance — anything the model could
|
|
* silently mis-copy — are rebuilt deterministically. Idempotent +
|
|
* incremental: edges dedupe by (from,to,type)-already-asserted-by-this-chat,
|
|
* mapping lines dedupe by (chat, sourceId), and coverage entries /
|
|
* sessions.jsonl checkpoints which messages and chats have been extracted
|
|
* (never the same turns twice). No events: the events ledger is GONE
|
|
* (2026-09-27) — the graph carries propositions + edges, nothing else.
|
|
*
|
|
* Identity across re-encounters is the AUDITOR's judgment (link-or-create
|
|
* with an equivalence bias — paraphrases merge), not string matching.
|
|
* A re-extracted session's propositions link to the existing nodes; the
|
|
* link appends a node line whose phrasings join `aliases` and whose
|
|
* provenance unions (getGraph merges lines by id).
|
|
*
|
|
* Derivation below (getGraph / graphSnapshot) is reads only — everything
|
|
* is recomputed from the ledgers. Every function takes the storage ports
|
|
* (core/ports.ts) as explicit parameters — pure core, no adapter imports;
|
|
* the composition root (src/server/stores.ts) wires the fs adapters in.
|
|
*/
|
|
|
|
import { randomBytes } from "node:crypto";
|
|
import type { CoverageEntry } from "./contracts/graph.ts";
|
|
import type {
|
|
AuditVerdicts,
|
|
ExtractedEdge,
|
|
SessionFindings,
|
|
Cluster,
|
|
EmissionStats,
|
|
GraphEdge,
|
|
GraphNode,
|
|
ProvenancePoint,
|
|
GraphSnapshot,
|
|
} from "./contracts.ts";
|
|
import { MIN_CLUSTER_SIZE } from "./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. */
|
|
export interface GraphDeps {
|
|
chatStore: ChatStore;
|
|
graphStore: GraphStore;
|
|
}
|
|
|
|
/** "n_" + 12 hex chars (contracts.ts GraphNode.id — store-assigned). */
|
|
function newGraphNodeId(): string {
|
|
return `n_${randomBytes(6).toString("hex")}`;
|
|
}
|
|
|
|
/** What one merge run merges FROM (the caller supplies it — the merger never
|
|
* reads the chat store): the coverage checkpoint for a session batch, or —
|
|
* for the format-agnostic notes front door (2026-09-13) — no coverage at
|
|
* all: the whole-file checkpoint stamps the import.
|
|
*
|
|
* Coverage (vision-extraction-coverage.md, settled 2026-09-15): a chat merge
|
|
* passes the BATCH it extracted — the caller computed the fingerprints
|
|
* before the run, from the same snapshot the merger sees — and the final
|
|
* step appends one CoverageEntry per message on success ("covered" means
|
|
* exactly this merge happened). The notes path passes neither and keeps
|
|
* the whole-file checkpoint. */
|
|
export interface MergeSource {
|
|
foldedAt: string;
|
|
/** The batch being merged (chat path): one entry per message in the
|
|
* extracted batch — undefined (notes path / coverage-less callers) →
|
|
* the legacy whole-chat checkpoint is written instead. */
|
|
coverage?: CoverageEntry[];
|
|
}
|
|
|
|
/** The run's chat-level provenance point (submit-schema slim, 2026-09-28:
|
|
* propositions no longer carry message ids — the model never reported
|
|
* them reliably anyway — so every birth from a run shares one point). */
|
|
function pointOf(chatId: string): ProvenancePoint[] {
|
|
return [{ chatId, messageId: "" }];
|
|
}
|
|
|
|
const samePoint = (a: ProvenancePoint, b: ProvenancePoint): boolean =>
|
|
a.chatId === b.chatId && a.messageId === b.messageId;
|
|
|
|
// ─── the merger (extraction pipeline stage 3 — deterministic) ────────────────
|
|
|
|
/**
|
|
* 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 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 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,
|
|
findings: SessionFindings,
|
|
verdicts: AuditVerdicts,
|
|
deps: GraphDeps,
|
|
source: MergeSource,
|
|
): EmissionStats {
|
|
const { graphStore: ledgers } = deps;
|
|
const stats: EmissionStats = { nodes: 0, edges: 0 };
|
|
|
|
// 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 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; // duplicate id WITHIN this submit — first resolution stands
|
|
const points = pointOf(chatId);
|
|
const linkTo = linkOf.get(p.id);
|
|
let graphNodeId: string;
|
|
if (linkTo !== undefined && existingIds.has(linkTo)) {
|
|
// Identity verdict: this proposition IS that node. Append a link
|
|
// line — the session's phrasings (statement + aliases) join the
|
|
// node's aliases, the provenance unions. Reads merge by id.
|
|
graphNodeId = linkTo;
|
|
// Link-append: phrasings + provenance join at read time (the
|
|
// first line's name/statement/createdAt stand).
|
|
ledgers.appendNode({
|
|
id: graphNodeId,
|
|
name: p.name,
|
|
statement: p.statement,
|
|
aliases: [...p.aliases],
|
|
createdAt: now,
|
|
provenance: points,
|
|
});
|
|
} else {
|
|
const node: GraphNode = {
|
|
id: newGraphNodeId(),
|
|
name: p.name,
|
|
statement: p.statement,
|
|
aliases: [...p.aliases],
|
|
createdAt: now,
|
|
provenance: points,
|
|
};
|
|
ledgers.appendNode(node);
|
|
stats.nodes++;
|
|
graphNodeId = node.id;
|
|
existingIds.add(graphNodeId);
|
|
}
|
|
mapping.set(p.id, graphNodeId);
|
|
}
|
|
|
|
// 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}`;
|
|
if (line.provenance.some((pt) => pt.chatId === chatId)) assertedByChat.add(key);
|
|
}
|
|
const emitEdge = (edge: ExtractedEdge): void => {
|
|
// Extractor-local ids only — the auditor's bridging edges are gone
|
|
// (owner, 2026-09-28 evening), so raw node-id endpoints no longer occur.
|
|
const resolve = (endpoint: string): string | undefined => mapping.get(endpoint);
|
|
const from = resolve(edge.from);
|
|
const to = resolve(edge.to);
|
|
if (!from || !to || from === to) return; // unresolvable endpoint — skip
|
|
const key = `${from}\u0000${to}\u0000${edge.type}`;
|
|
if (assertedByChat.has(key)) return;
|
|
ledgers.appendEdge({ from, to, type: edge.type, provenance: pointOf(chatId) });
|
|
assertedByChat.add(key);
|
|
stats.edges++;
|
|
};
|
|
for (const edge of findings.edges) emitEdge(edge); // the extractor's edges — the ONLY edge source
|
|
|
|
// 3. Checkpoint — THE COVERAGE DISCIPLINE (vision-extraction-coverage.md):
|
|
// only now, after every append succeeded, is the batch covered. The
|
|
// chat path appends one CoverageEntry per message (per-batch atomic —
|
|
// an abort or failure mid-run leaves earlier batches covered, this
|
|
// batch's messages uncovered); the notes path keeps the whole-file
|
|
// checkpoint (a file is one shot).
|
|
if (source.coverage !== undefined) {
|
|
for (const entry of source.coverage) ledgers.appendCoverage(entry);
|
|
} else {
|
|
ledgers.appendCheckpoint({ chatId, foldedAt: source.foldedAt });
|
|
}
|
|
return stats;
|
|
}
|
|
|
|
// ─── derivation (reads only — everything below is recomputable) ─────────────
|
|
|
|
export interface DerivedGraph {
|
|
nodes: GraphNode[];
|
|
edges: GraphEdge[];
|
|
}
|
|
|
|
/**
|
|
* The whole graph from the ledgers. Node LINES may repeat an id (link-
|
|
* appends): the FIRST line defines name/statement/createdAt; later
|
|
* lines' statements and aliases join `aliases`, provenance unions — the
|
|
* name never changes after creation. Edge
|
|
* LINES are assertions, merged by (from,to,type) with unioned provenance,
|
|
* first-seen order. `store events, derive state` — this is the only reader
|
|
* that matters.
|
|
*/
|
|
export function getGraph(graphStore: GraphStore): DerivedGraph {
|
|
// Merge node lines by id (first line defines; later lines add phrasings).
|
|
const byId = new Map<string, GraphNode>();
|
|
for (const line of graphStore.readNodes()) {
|
|
const existing = byId.get(line.id);
|
|
if (!existing) {
|
|
byId.set(line.id, { ...line, aliases: [...line.aliases], provenance: [...line.provenance] });
|
|
continue;
|
|
}
|
|
// A later line's statement is a phrasing, not a replacement.
|
|
for (const phrasing of [line.statement, ...line.aliases]) {
|
|
if (phrasing.length > 0 && !existing.aliases.includes(phrasing)) existing.aliases.push(phrasing);
|
|
}
|
|
for (const pt of line.provenance) {
|
|
if (!existing.provenance.some((q) => samePoint(q, pt))) existing.provenance.push(pt);
|
|
}
|
|
}
|
|
|
|
const merged: GraphEdge[] = [];
|
|
const byKey = new Map<string, GraphEdge>();
|
|
for (const line of graphStore.readEdges()) {
|
|
const key = `${line.from}\u0000${line.to}\u0000${line.type}`;
|
|
const existing = byKey.get(key);
|
|
if (existing) {
|
|
for (const pt of line.provenance) {
|
|
if (!existing.provenance.some((q) => samePoint(q, pt))) existing.provenance.push(pt);
|
|
}
|
|
} else {
|
|
const edge: GraphEdge = { ...line, provenance: [...line.provenance] };
|
|
byKey.set(key, edge);
|
|
merged.push(edge);
|
|
}
|
|
}
|
|
return { nodes: [...byId.values()], edges: merged };
|
|
}
|
|
|
|
// ─── the /api/graph payload ──────────────────────────────────────────────────
|
|
|
|
/** Everything the home graph view needs, in one fetch (shape: contracts). */
|
|
export function graphSnapshot(deps: GraphDeps): GraphSnapshot {
|
|
const { nodes, edges } = getGraph(deps.graphStore);
|
|
|
|
const nodeChats = new Map<string, Set<string>>();
|
|
const touch = (nodeId: string, chatId: string): void => {
|
|
let chats = nodeChats.get(nodeId);
|
|
if (!chats) {
|
|
chats = new Set();
|
|
nodeChats.set(nodeId, chats);
|
|
}
|
|
chats.add(chatId);
|
|
};
|
|
for (const node of nodes) {
|
|
for (const pt of node.provenance) touch(node.id, pt.chatId);
|
|
}
|
|
for (const edge of edges) {
|
|
for (const pt of edge.provenance) {
|
|
touch(edge.from, pt.chatId);
|
|
touch(edge.to, pt.chatId);
|
|
}
|
|
}
|
|
|
|
// Labels for every source the graph can point back at: real chats
|
|
// (title, deep-linkable) and imported notes files (path, static — the
|
|
// format-agnostic front door's provenance).
|
|
const sourceIds = new Set<string>();
|
|
for (const chats of nodeChats.values()) {
|
|
for (const id of chats) sourceIds.add(id);
|
|
}
|
|
const sessions: Record<string, string> = {};
|
|
const fileSources: Record<string, string> = {};
|
|
for (const id of sourceIds) {
|
|
const chat = deps.chatStore.getChat(id);
|
|
if (chat) {
|
|
sessions[id] = chat.title;
|
|
continue;
|
|
}
|
|
const source = deps.graphStore.readSources().find((s) => s.id === id);
|
|
if (source) fileSources[id] = source.filePath;
|
|
}
|
|
|
|
const nodeSessions: Record<string, string[]> = {};
|
|
for (const [nodeId, chats] of nodeChats) {
|
|
const ids = [...chats].filter((id) => sessions[id] !== undefined || fileSources[id] !== undefined);
|
|
if (ids.length > 0) nodeSessions[nodeId] = ids;
|
|
}
|
|
|
|
// BIRTH-SESSION CLUSTERS (wave 7, owner-locked 2026-09-23): embeddings
|
|
// are dead — a cluster is the SESSION THAT CREATED ITS PROPOSITIONS,
|
|
// derived on read from provenance (core/session-clusters.ts). The label
|
|
// is the session's NAME (chat title / file path): no LLM naming pass, no
|
|
// persisted partition to invalidate. MIN_CLUSTER_SIZE stays the hull
|
|
// presentation rule (a region needs >= 3 propositions for a hull). A
|
|
// birth session whose record is gone (deleted chat) keeps its members
|
|
// but renders unlabeled — honest, never guessed.
|
|
const communities = birthSessionPartition(nodes);
|
|
const clusters: Cluster[] = communities
|
|
.filter((community) => community.members.length >= MIN_CLUSTER_SIZE)
|
|
.map((community) => ({
|
|
id: community.id,
|
|
label: sessions[community.id] ?? fileSources[community.id] ?? null,
|
|
members: community.members,
|
|
}));
|
|
|
|
// Resting layout (Option A): the positions ledger, when the settle has
|
|
// ever run. Absent on a fresh graph — the view falls back to its own
|
|
// one-shot pass until the first merge writes the ledger.
|
|
const positions = deps.graphStore.readPositions();
|
|
|
|
return {
|
|
nodes,
|
|
edges,
|
|
sessions,
|
|
fileSources,
|
|
nodeSessions,
|
|
clusters,
|
|
...(Object.keys(positions).length > 0 ? { positions } : {}),
|
|
};
|
|
}
|
|
|
|
// ─── Prober graph tools — the data seam (wave 7, owner-locked 2026-09-23) ───
|
|
|
|
/** One session in the prober's graph view: every provenance anchor the
|
|
* graph can name — a real chat (deep-linkable) or an imported notes file
|
|
* (owner ruling 2026-09-23: files are birth-sessions too, kind "file").
|
|
* The prober's session-list tool renders these; the per-session view is
|
|
* graphSessionPropositions below. Pure derivation over the same ports
|
|
* the snapshot reads — Teacher's tool handlers call these in-process. */
|
|
export interface GraphSessionEntry {
|
|
/** chatId or notes source id ("src_…") — the birth-session key. */
|
|
id: string;
|
|
/** The chat's title, or the file's path — the cluster label's source. */
|
|
name: string;
|
|
/** A real chat (its record still exists) or an imported notes file. */
|
|
kind: "chat" | "file";
|
|
/** When the session began: the chat's createdAt, or the file's
|
|
* importedAt. null when no record can date it. */
|
|
createdAt: string | null;
|
|
/** Most recent activity: the chat's updatedAt, or the file's
|
|
* importedAt. null when no record can date it. */
|
|
updatedAt: string | null;
|
|
/** How many propositions were BORN in this session (birth-session
|
|
* clustering's count — shared/re-encountered nodes count for their
|
|
* birth session only). */
|
|
propositions: number;
|
|
}
|
|
|
|
/** The prober's session list: every birth session, most-recently-active
|
|
* first (undatable records sink, in stable id order). */
|
|
export function graphSessionIndex(deps: GraphDeps): GraphSessionEntry[] {
|
|
const counts = new Map<string, number>();
|
|
const born = getGraph(deps.graphStore);
|
|
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]));
|
|
const sources = new Map(deps.graphStore.readSources().map((s) => [s.id, s]));
|
|
const entries: GraphSessionEntry[] = [];
|
|
for (const [id, propositions] of counts) {
|
|
const chat = chats.get(id); if (chat) {
|
|
entries.push({
|
|
id,
|
|
name: chat.title,
|
|
kind: "chat",
|
|
createdAt: chat.createdAt,
|
|
updatedAt: chat.updatedAt,
|
|
propositions,
|
|
});
|
|
continue;
|
|
}
|
|
const source = sources.get(id);
|
|
entries.push({
|
|
id,
|
|
name: source?.filePath ?? id,
|
|
kind: "file",
|
|
createdAt: source?.importedAt ?? null,
|
|
updatedAt: source?.importedAt ?? null,
|
|
propositions,
|
|
});
|
|
}
|
|
return entries.sort(
|
|
(a, b) =>
|
|
(b.updatedAt ?? "").localeCompare(a.updatedAt ?? "") ||
|
|
(b.createdAt ?? "").localeCompare(a.createdAt ?? "") ||
|
|
(a.id < b.id ? -1 : 1),
|
|
);
|
|
}
|
|
|
|
/** One birth proposition of one session — WHAT WAS COVERED there (2026-09-27
|
|
* cleanup: no understanding levels; assessment is practice-module
|
|
* territory and nothing writes it until that module exists). */
|
|
export interface GraphSessionProposition {
|
|
nodeId: string;
|
|
/** The compact handle — the same name the canvas shows. */
|
|
name: string;
|
|
}
|
|
|
|
/** 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 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));
|
|
}
|
|
|
|
// Re-export: the contract owns the shape; legacy importers still reach it.
|
|
export type { EmissionStats };
|