Files
learnharness/src/core/contracts/graph.ts
T
amosblomqvist 3e263a18cc Graph pipeline cost metrics: capture naming spend, fold all channels, cost pill + panel
- Cluster namer bills the money channel now: per-step usage + model
  captured via onStep, persisted as NamingRunUsage records to
  data/graph/naming-runs.jsonl (idempotent per startedAt; failed/aborted
  runs persist too — money spent either way). callUsage promoted to
  extraction.ts module scope for reuse.
- core/cost-summary.ts: the pure fold behind the pill — chat runs,
  notes-source runs, and naming runs into stage totals (extraction /
  audit / naming) + newest-first recent runs. Dollars INFERRED read-time
  via prices.ts, never stored; unpriced models excluded-but-counted
  (render '—', never a silent zero); tokens always sum.
- GET /api/graph/costs (src/server graphCosts composition, thin route).
- Graph view: bottom-left cost pill (cumulative dollars headline) +
  expandable breakdown panel (stage rows with tokens secondary, recent
  runs with time/title/merged/cost); fetched on mount, refetched when a
  pipeline run finishes; merged into one bottom-left stack with the
  running-pipeline pills.
- Tests: pure-fold suite, naming capture wiring (full importNotes ->
  naming path), store idempotency + strict reads.
2026-09-19 20:55:35 +03:00

415 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Knowledge-graph contracts — nodes, ledgers, extraction findings
*
* The minimal implementation of the graph vision (docs/vision.md §1, the
* 2026-09-02 redesign): every node is a TRUE proposition; edges are follows
* (derivable) and supports (grounds without entailing); provenance is
* message-granular; extraction is free-form and post-hoc, fully decoupled
* from the session's plan tree; the event ledger records quiz outcomes plus
* the extractor's per-proposition understanding observations. Anything not
* yet decided (the state model, strength derivation, understanding
* treatment, selection strategies) is deliberately NOT here — transitional
* stubs carry the renderer until those land.
*
* Part of the domain-split contracts (src/core/contracts/): each domain file
* is owned outright by its stream — cross-domain schema changes go through
* master. Pure types + protocol docs + tiny pure helpers. See docs/vision.md.
*/
// ─── Knowledge graph (vision §1 — global, cross-session) ────────────────────
/** Two relations only (DECIDED 2026-09-02): follows = b is derivable from a;
* supports = a grounds b without entailing it. No scalars, no encoded
* strength — strength is derived at read time from topology. */
import type { CallUsage, ExtractionRunUsage } from "./chat.ts";
export type EdgeType = "follows" | "supports";
/** What one extraction run merged into the graph ledgers — the merger's
* output, also persisted in extraction-run usage records (contracts/chat). */
export interface EmissionStats {
/** New graph nodes appended. */
nodes: number;
/** New edge assertions appended. */
edges: number;
/** Ledger events rebuilt (quiz outcomes + observations). */
events: number;
}
/** One provenance point — message-granular (DECIDED 2026-09-02): nodes and
* edges record every (chat, message) where they came up, deep-linkable to
* the exact transcript point. */
export interface ProvenancePoint {
/** The chat this point anchors to — or a notes source id ("src_…"):
* imports carry no message anchors (provenance is the file itself,
* recorded through the mapping line + the source record). */
chatId: string;
messageId: string;
}
/** One imported notes file — the format-agnostic front door's bookkeeping
* (DECIDED 2026-09-13): personal notes in ARBITRARY formats run through the
* same extractor → auditor → merger pipeline, with the file itself as the
* provenance anchor. There is no chat: the detail card shows the file path
* as a STATIC entry (not a link), and understanding observations still
* apply (judged from the author's own voice in the notes). The run's usage
* log lives here, mirroring ChatRecord.extractions. Ids are "src_" + hash
* of the file path — deterministic, so re-importing the same file re-runs
* extraction against the same source id (mappings/events dedupe by it). */
export interface NotesSource {
id: string;
filePath: string;
/** ISO — when this file was imported (the checkpoint's foldedAt). */
importedAt: string;
/** The run's usage log — same append-only discipline as
* ChatRecord.extractions, idempotent per startedAt. */
extractions?: ExtractionRunUsage[];
}
/** What one cluster-naming refresh did — the namer's ClusterRefreshStats,
* contract-shaped so the agent layer needs no contracts import.
* Structural twins stay interchangeable. */
export interface NamingRunStats {
/** Communities the current graph divides into (all sizes). */
detected: number;
/** Communities that kept a label via core-survival. */
claimed: number;
/** Communities sent to the namer (unnamed, size >= MIN_CLUSTER_SIZE). */
named: number;
/** Labeling records actually appended (idempotent dedupe may shrink). */
appended: number;
}
/** One cluster-naming run's usage record — the money channel's THIRD stage
* (graph cost metrics, 2026-09). The namer is graph-global (tied to no
* chat or source), so runs live in their own append-only ledger
* (config.LEDGER_FILES.namingRuns). Appended when a run actually called
* the model; skipped refreshes (nothing to name) never bill and never
* append. Idempotent per startedAt — same discipline as every usage
* channel. Cost is INFERRED at read time from steps + model
* (core/prices.ts) — dollars are never stored. */
export interface NamingRunUsage {
/** ms epoch — the idempotency key. */
startedAt: number;
/** ISO — wall-clock time of the run. */
at: string;
durationMs: number;
/** The labeler role's model id. */
model: string;
/** Every billed call (usually ONE — the namer is single-submit). */
steps: CallUsage[];
stats: NamingRunStats;
}
export interface GraphNode {
/** Opaque stable id, store-assigned: "n_" + nanoid-like. */
id: string;
/** A very short label for the node — a compact handle like a glossary
* headword ("Will to power", "Divine command theory"): what the graph
* shows at a glance. Presentational only — not unique, not an identity
* key, never precise: the statement carries the precision. Set at
* creation (first line defines); a link-append never changes it. */
name: string;
/** The minimal proposition — one true, self-contained statement, at
* full precision. The IDENTITY ANCHOR: the auditor recognizes a
* re-encounter by judgment against this (link-or-create), not by
* string matching. Shown in the detail card; the canvas label is the
* name. */
statement: string;
/** Verbatim phrasings sessions used for this proposition (the identity
* audit trail — how the world said what the node says). */
aliases: string[];
createdAt: string;
/** Every (chat, message) point where this proposition came up. */
provenance: ProvenancePoint[];
}
export interface GraphEdge {
from: string;
to: string;
type: EdgeType;
/** Every (chat, message) point where this relation was asserted. */
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 {
chatId: string;
/** The chat record's `updatedAt` at the time of the fold. */
foldedAt: string;
}
/** coverage.jsonl line (vision-extraction-coverage.md, settled 2026-09-15):
* ONE MESSAGE of one chat has been extracted — by content fingerprint over
* exactly what the extraction view sees. The MESSAGE is the unit of
* coverage; "uncovered" is derived (fingerprint the current transcript,
* diff) — never dual-tracked. A quiz answer merging into the asking
* message changes its fingerprint: the message re-opens itself, only
* itself. Chat extractions write these instead of whole-chat checkpoints;
* notes imports keep the checkpoint (a file is one shot). */
export interface CoverageEntry {
chatId: string;
/** sha256 of the message's view lines — "covered" means exactly this. */
fp: string;
/** chars/4 estimate of the message's view — display + budgeting. */
tokens: number;
/** ISO — when this message's last extraction run merged. */
foldedAt: string;
}
/**
* Event kinds (DECIDED 2026-09-02): quiz outcomes only — correct / miss /
* dont-know — plus `observation`, the extractor's per-proposition
* understanding note joining the ledger as a session-scoped observation
* (named-word scale; treatment pending the state-model ideation). No
* `taught` events at all; no misconception kind — a misconception is a
* TRUE correction in the graph, and the wrongness lives here, in the
* ledger, until the state model exists.
*/
export type LedgerEventKind = "correct" | "miss" | "dont-know" | "observation";
export interface LedgerEvent {
/** null = not yet attributed. */
nodeId: string | null;
kind: LedgerEventKind;
/** The chat that produced this evidence — or a notes source id
* ("src_…") for imported-notes observations. */
chatId: string;
at: string;
/** Idempotency key: the quiz part's toolCallId, or `obs:<sourceId>` for
* understanding observations — re-emission of the same chat must never
* double-record an event. */
ref?: string;
/** The learner's quiz note (diagnostic gold), or the understanding
* LEVEL word for observations — a session-relative judgment. */
note?: string;
/** The quiz question text, when nodeId is null (later attribution input). */
question?: string;
}
/**
* Store format (src/store/graph.ts): JSONL ledgers at config.LEDGER_FILES
* (nodes/edges/events/mappings/sessions), all append-only.
*
* nodes.jsonl one line per node — the FIRST line for an id defines
* name/statement/createdAt; later lines for the
* same id are link-appends (a session's re-encounter):
* their statements/aliases join `aliases`, their
* provenance unions; the name stays the first line's.
* getGraph merges by id.
* edges.jsonl one line per edge ASSERTION; readers merge by
* (from,to,type), unioning provenance.
*
* The derived graph is rebuilt by reading the ledgers — store events,
* derive state. sessions.jsonl is the extraction checkpoint: which chats
* the extraction pipeline has processed, and through which updatedAt
* (never extract the same turns twice).
*
* The EXTRACTION PIPELINE is the sole graph writer (extractor → auditor →
* merger), tree-blind and post-hoc: the extractor reads only the transcript
* and proposes propositions/edges/quiz-attribution with verbatim evidence;
* the auditor sees the extractor's output PLUS the current graph, owns
* faithfulness (evidence check) and identity (link-or-create: point each
* proposition at the existing node it is, or let it be created — recording
* the session's phrasings as aliases); the merger (core/graph.ts,
* deterministic) applies the verdicts and appends.
*/
// ─── Extraction findings (extractor output → audit → merger input) ───────────
/**
* The extractor's standardized per-session understanding scale (DECIDED
* 2026-09-08): a CLOSED named-word list — no numbers, no prose, no
* justifications (a justification, if the agent wants one, is dropped;
* it is not stored). The level is session- and extraction-run-relative
* judgment about the learner, shown next to its session — never a node
* property.
*/
export type UnderstandingLevel = "solid" | "shaky" | "accepted" | "resisted" | "not yet engaged";
/** One proposed proposition — a node candidate (or a re-encounter of one). */
export interface ExtractedProposition {
/** Extractor-local id ("p1", …) referenced by edges + attribution. */
id: string;
/** A very short label — a few words, a compact handle like a glossary
* headword. Not a sentence, needn't be unique or self-contained: the
* statement carries the precision. Becomes the node's name on create;
* on link, the existing node keeps its name. */
name: string;
/** The minimal proposition — a true, self-contained, world-facing
* statement, at full precision. No learner / teacher / session
* references: misconceptions are stated as the true correction, and
* wrongness lives in the event ledger, never in the node. */
statement: string;
/** Verbatim phrasings the session used. */
aliases: string[];
/** The message ids where this proposition came up — provenance. */
messages: string[];
/** Verbatim transcript quotes backing the proposition. */
evidence: string[];
/** The extractor's understanding level for this proposition IN THIS
* session — one word from the UnderstandingLevel scale. Joins the
* ledger as an observation event (note = the level word); treatment
* pending the state-model ideation. */
understanding?: UnderstandingLevel;
}
/** One proposed relation between two propositions. */
export interface ExtractedEdge {
/** Proposition id that is followed / supported. */
from: string;
to: string;
type: EdgeType;
/** The message ids where this relation was asserted — provenance. */
messages: string[];
/** Verbatim transcript quotes backing the relation. */
evidence: string[];
}
/**
* The extractor's quiz attribution — its ONLY say about quiz events. Event
* kinds, timestamps, idempotency refs, notes and question texts are all
* rebuilt deterministically by the merger from the transcript's quiz tool
* parts; the LLM supplies exactly the judgment call: WHICH proposition each
* quiz tested. Tree-blind, so EVERY quiz attributes here (there is no tree
* mapping to lean on) — retroactively against the whole transcript.
*/
export interface ExtractionAttribution {
/** quiz toolCallId → proposition id; null / missing = unattributed (the
* event lands with nodeId null + the question text, a later-
* attribution input). */
quiz: Record<string, string | null>;
}
/** The extractor's output shape. */
export interface SessionFindings {
propositions: ExtractedProposition[];
edges: ExtractedEdge[];
attribution: ExtractionAttribution;
}
/** The auditor's verdict on one proposition: the findings, plus `linkTo` —
* the identity judgment. Absent = create a new node. */
export interface AuditedProposition extends ExtractedProposition {
/** The existing GraphNode id this proposition IS — the auditor's
* identity verdict. On link, the session's phrasings (statement,
* aliases) join the node's aliases and the provenance unions.
* Conservative: when unsure, leave it out — duplicates over wrong
* merges. */
linkTo?: string;
}
/** The auditor's output: the verified findings (with identity verdicts),
* plus the drops (the audit trail of what was rejected and why). */
export interface AuditResult {
propositions: AuditedProposition[];
edges: ExtractedEdge[];
attribution: ExtractionAttribution;
/** Dropped candidates: proposition id, edge "from→to", or attribution
* toolCallId — with the reason. */
dropped: { ref: string; reason: string }[];
}
/** The GET /api/graph payload — everything the home view's PURE renderer needs. */
/** cluster-labels.jsonl line — one LLM-named community (vision §1,
* DECIDED 2026-09-13). Append-only history: records are immutable, never
* edited or deleted; dead labelings remain as history. IDENTITY is the
* sorted member set ("core"), hashed into the id, so re-naming the same
* community dedupes and a labeling survives graph edits as long as at
* least half its core survives in some single community (core-survival;
* under half → the community is renamed, splits can keep only one
* successor). */
/** The embedding ledger shape (config.EMBEDDINGS_FILE): vectors for
* EVERY embedded node, all produced by one model at one dimensionality. */
export interface EmbeddingsLedger {
/** The embedding model id (e.g. the HF repo) — mismatch = stale
* vectors, the writer re-embeds everything. */
model: string;
/** Vector dimensionality (MRL-truncated on write). */
dim: number;
/** nodeId → L2-normalized vector (cosine similarity = dot product). */
vectors: Record<string, number[]>;
/** The topic partition over those vectors, computed by the SAME
* embedder run (the user-decided trigger: recluster at the end of
* every embedding batch, never per read — the agglomerative pass is
* ~1.5s at current scale). Consumers (snapshot, settle, namer) read
* this instead of recomputing. Empty when nothing is embedded. */
clusters: Array<{ members: string[] }>;
/** ISO timestamp of the last write. */
updatedAt: string;
}
export interface ClusterLabeling {
/** "clu_" + content hash of the sorted core. */
id: string;
/** The short domain label (1–4 words), e.g. "Differential forms". */
label: string;
/** The member node ids the label was minted for, sorted — the survival core. */
core: string[];
createdAt: string;
}
/** One rendered cluster in the snapshot payload (vision §1). Members are
* node ids; the renderer draws the hull from member positions at draw
* time and gates the label inversely on the node-label zoom control. */
export interface Cluster {
/** The labeling record id when claimed; unlabeled clusters still
* appear (hull, no name) with a member-hash id. */
id: string;
/** null until the naming call has minted/claimed a labeling. */
label: string | null;
/** Node ids, sorted. Hull + centroid are renderer concerns. */
members: string[];
}
export interface GraphSnapshot {
nodes: GraphNode[];
edges: GraphEdge[];
/** chatId → title, for every chat that touched the graph. */
sessions: Record<string, string>;
/** srcId → file path, for every IMPORTED NOTES file that touched the
* graph (the format-agnostic front door): a static provenance entry —
* the detail card shows the path as plain text, not a link. */
fileSources: Record<string, string>;
/** nodeId → chatIds that touched the node (click-through provenance). */
nodeSessions: Record<string, string[]>;
/** nodeId → chatId → that session's understanding observation (the
* extractor's per-session named-word note; session-relative data,
* shown next to the session list — the confidence history). Multiple
* notes for one (node, chat) collapse to the latest. */
nodeObservations: Record<string, Record<string, string>>;
/** Structural communities (Louvain over follows+supports), size ≥ 3,
* joined with persisted LLM labelings. Purely structural — never
* depends on layout. */
clusters: Cluster[];
/** Resting layout positions per node id, when a positions ledger
* exists (core/layout.ts settle writes it). Absent on a fresh graph —
* the view then falls back to its own one-shot forceAtlas2 pass. */
positions?: Record<string, Position>;
}
/** A node's resting position in layout space (the positions ledger). */
export interface Position {
x: number;
y: number;
}
/** Derive a short chat title from the learner's first message (pure).
* Used on chat creation; deterministic so tests can pin it. */
export function titleFromPrompt(prompt: string): string {
const oneLine = prompt.replace(/\s+/g, " ").trim();
return oneLine.length > 72 ? `${oneLine.slice(0, 72)}…` : oneLine;
}