// rationale.ts — the inference, told as it happens. // // Sema's edge over a weight matrix is that every answer is a DERIVATION over // explicit facts, not a sample from an opaque distribution. This module turns // that derivation into a stream a human (or a debugger) can read: as {@link // Mind.respond} thinks, each inference MECHANISM it runs emits a {@link // RationaleStep} the moment it completes — what it was handed, what it produced, // where it sits in the nesting of mechanisms, and which earlier steps fed it. // // Nothing here drives the inference; it only WITNESSES it. When no // `inspectRationale` callback is supplied the tracer is never constructed and // the cost is exactly zero — every emit site in src/mind/mind.ts is guarded by `?.`, and // optional-chaining short-circuits its arguments, so the items are not even // built (see {@link Mind.respond}). // // The shape of a step mirrors how Sema reasons. A mechanism is rarely a 1→1 // map: {@link Mind.recognise} DECOMPOSES one query into many recognised forms; // the cover COMBINES many forms back into one answer; resonance fans one gist // out into a ranked list of hits. So a step's `inputs` and `outputs` are each a // VECTOR — an ordered list of {@link RationaleItem}s, one per element — and the // fan-out / fan-in is visible in their lengths. import type { Vec } from "../vec.js"; /** One element of a step's input or output vector. * * Modality-neutral and deliberately partial: an element might be a byte span of * the query, a resolved graph node, a resonance hit with its score, a spliced * connector — so every descriptive field is optional and a mechanism fills only * the ones that carry meaning for what it did. `text` is always present (the * human-readable rendering); the rest is provenance a debugger can lean on. */ export interface RationaleItem { /** Human-readable rendering — decoded text for a byte span, else a label like * "‹none›" or an operator name. Always set, so a step always reads. */ text: string; /** The graph node this element is, or resolved to, when known — the handle to * point back at the exact stored fact in the content-addressed DAG. */ node?: number; /** The `[start, end)` span this element occupies in its step's frame of * reference (usually the query or the answer being composed). */ span?: [number, number]; /** The resonance / cosine score that selected this element, when it was chosen * by similarity rather than by exact structure. */ score?: number; /** A short role tag — "query", "leaf", "form", "hit", "connector", "answer", * … — naming what KIND of element this is within the step. */ role?: string; /** The gist vector, only when the element fundamentally IS a vector and a * caller asked to carry it (off by default — a D-float array per item would * bury the reasoning it is meant to explain). */ v?: Vec; } /** A single completed act of inference — one mechanism, run once. * * Steps are emitted in COMPLETION order (a sub-mechanism finishes, and is * reported, before the mechanism that called it), while `index` is assigned in * ENTRY order (a parent reserves its index before its children run). So a * parent's `index` is always lower than its children's, and the two orderings * together give a valid topological reading of the dependency graph. */ export interface RationaleStep { /** This step's index, assigned when the mechanism was ENTERED — a strict, * incremental ordering over the whole inference. */ index: number; /** The mechanism and its enclosing mechanisms, outermost → innermost, e.g. * `["respond", "think", "recognise"]`. The last entry is this step; the * prefix is the nest of sub-mechanisms it ran inside. */ mechanism: string[]; /** The enclosing mechanism's step index, or -1 at the root — the NESTING edge * of the dependency graph (which step this one is a part of). */ parent: number; /** The earlier steps whose OUTPUTS became this step's inputs — the DATA-FLOW * edges of the dependency graph. Defaults to the previous sibling (the step * run just before this one inside the same mechanism), or the parent when * this is the first sub-step; a mechanism that fuses several earlier results * names them all explicitly. */ dependsOn: number[]; /** The vector of elements handed to the mechanism (one or more). */ inputs: RationaleItem[]; /** The vector of elements the mechanism produced (one or more) — longer than * `inputs` when it decomposed, shorter when it combined. */ outputs: RationaleItem[]; /** A one-line, human account of what the mechanism did and why — the sentence * that turns the data into an explanation. */ note?: string; /** Optional structured payload — a mechanism-specific, plain-serialisable * shape (no Map/Set/vectors/mutable internals) that carries more than the * human-readable `note` can, for a debugger or downstream tool to consume * programmatically. Never read by inference; purely additive. */ data?: unknown; } /** The callback {@link Mind.respond} / {@link Mind.respondText} accept. It is * invoked once per completed step, AS the inference unfolds — never batched at * the end — so a caller can stream the reasoning live or accumulate it. */ export type InspectRationale = (step: RationaleStep) => void; /** Decode bytes to text for display, dropping the NUL padding the encoder uses * (the same cleanup {@link Mind.respondText} does for its result). */ export function decodeText(bytes: Uint8Array): string { return new TextDecoder().decode(bytes.filter((b) => b !== 0x00)); } /** The `[start, end)` gaps of `[0, queryLen)` NOT covered by `accounted` — * the same union-of-spans reading think's grounding decider prices at PASS * per byte, exposed here so a mechanism can turn it into a human label. */ export function unexplainedSpans( queryLen: number, accounted: ReadonlyArray<[number, number]>, ): Array<[number, number]> { const sorted = accounted .map(([s, e]) => [Math.max(0, s), Math.min(queryLen, e)] as [number, number] ) .filter(([s, e]) => e > s) .sort((a, b) => a[0] - b[0]); const gaps: Array<[number, number]> = []; let reach = 0; for (const [s, e] of sorted) { if (s > reach) gaps.push([reach, s]); if (e > reach) reach = e; } if (reach < queryLen) gaps.push([reach, queryLen]); return gaps; } /** A human-readable label for the query bytes a mechanism's `accounted` * spans leave unexplained — purely diagnostic (Task 2's negative evidence): * it never changes a candidate's weight, only what the rationale trace * says the mechanism left on the table. `""` when nothing is unexplained. */ export function unexplainedLabel( query: Uint8Array, accounted: ReadonlyArray<[number, number]>, ): string { const gaps = unexplainedSpans(query.length, accounted); if (gaps.length === 0) return ""; return gaps.map(([s, e]) => decodeText(query.subarray(s, e))).join(" … "); } /** An open mechanism — the handle {@link Rationale.enter} returns. Hold it for * the duration of the mechanism and call {@link Scope.done} with the outputs * when it finishes; that emits the step and pops the nesting. */ export interface Scope { /** The step index reserved for this mechanism at entry — pass it as an * explicit dependency of a later step that consumes this one's output. */ readonly index: number; /** Close the mechanism: emit its step with these outputs and pop it off the * nesting stack. Idempotent — a second call is ignored, so a `finally` that * closes after an early return is safe. */ done(outputs: RationaleItem[], note?: string, data?: unknown): void; } /** The live tracer: a stack of open mechanisms over one {@link Mind.respond}. * * Sema's inference is single-threaded and strictly sequential — every async * step is awaited before the next begins, and `respond` holds no two thoughts * at once — so a plain stack exactly tracks the current nesting: {@link enter} * pushes, {@link Scope.done} pops, and {@link step} (a mechanism with no * sub-steps) is the two fused. The tracer never branches the control flow; it * only records it. */ export class Rationale { private next = 0; /** Open mechanisms, outermost first. Each frame remembers the last child it * has spawned so the next sibling can default its data-flow edge to it. */ private readonly stack: Array<{ index: number; name: string; lastChild: number | null; }> = []; /** The most recent step index emitted under each mechanism name — the handle * a later step uses to name an EARLIER mechanism as its data-flow producer * (e.g. cover depends on the latest recognise / computeExtensions). One tracer is * built per response and inference is sequential, so "most recent" is exactly * "the one that produced the inputs I am about to consume". */ private readonly lastByName = new Map(); constructor(private readonly sink: InspectRationale) {} /** The index of the most recent step with this mechanism name, or undefined if * none has run. Used to wire an explicit producer edge into {@link * Scope.done} / {@link step}'s `deps`. */ lastIndex(name: string): number | undefined { return this.lastByName.get(name); } /** The mechanism names currently open, outermost → innermost. */ private path(leaf: string): string[] { const p = this.stack.map((f) => f.name); p.push(leaf); return p; } /** The default data-flow edge for a step entering now: the previous sibling * inside the current mechanism, else the enclosing mechanism, else nothing * (the root). An explicit `deps` overrides this. */ private defaultDeps(): number[] { const top = this.stack[this.stack.length - 1]; if (!top) return []; return [top.lastChild ?? top.index]; } /** Reserve this step's index and register it as the current mechanism's most * recent child (so the NEXT sibling chains to it) and as the most recent step * of its own NAME (so a later mechanism can name it as a producer). */ private reserve(name: string): number { const index = this.next++; const top = this.stack[this.stack.length - 1]; if (top) top.lastChild = index; this.lastByName.set(name, index); return index; } private emit( index: number, mechanism: string[], inputs: RationaleItem[], outputs: RationaleItem[], deps: number[] | undefined, note: string | undefined, data?: unknown, ): void { this.sink({ index, mechanism, parent: this.stack.length > 0 ? this.stack[this.stack.length - 1].index : -1, dependsOn: deps ?? this.defaultDeps(), inputs, outputs, note, data, }); } /** Enter a mechanism that has sub-steps. Captures its inputs and the nesting * now; the matching {@link Scope.done} supplies the outputs when it finishes. * `deps` overrides the default data-flow edge (previous sibling / parent). */ enter( name: string, inputs: RationaleItem[], deps?: number[], ): Scope { const mechanism = this.path(name); const resolvedDeps = deps ?? this.defaultDeps(); const index = this.reserve(name); this.stack.push({ index, name, lastChild: null }); let closed = false; const emit = this.emit.bind(this); const pop = () => { // Pop down to and including this frame — tolerant of a sub-mechanism that // forgot to close, so one missed `done` cannot desync the whole stack. const at = this.stack.findIndex((f) => f.index === index); if (at >= 0) this.stack.length = at; }; return { index, done: (outputs, note, data) => { if (closed) return; closed = true; pop(); emit(index, mechanism, inputs, outputs, resolvedDeps, note, data); }, }; } /** Record a mechanism that has no sub-steps — its inputs and outputs are both * known at the call site. Returns its index, for a later step to depend on. */ step( name: string, inputs: RationaleItem[], outputs: RationaleItem[], note?: string, deps?: number[], data?: unknown, ): number { const mechanism = this.path(name); const resolvedDeps = deps ?? this.defaultDeps(); const index = this.reserve(name); this.emit(index, mechanism, inputs, outputs, resolvedDeps, note, data); return index; } }