UNPKG

kestrel.markets

Version:

A typed, token-efficient language + runtime for agentic trading: agents author bounded plans, the runtime fires them at the tick. CLI + typed library + MCP server.

384 lines (345 loc) 22.8 kB
/** * # frame/template-record — the TemplateRecord evidence ledger (SKELETON: types + storage, ADR-0041 §2, kestrel-wa0j.26) * * `PerceptProfile` reduced to nothing more than a **TemplateRecord set** (ADR-0041 §2): the evidence * ledger where every founder — or agent-authored — `.kestrel` template enters as a HYPOTHESIS carrying * a cell-keyed ledger entry, NEVER a blessed default. This module is the record family + the in-memory * store + fail-closed (de)serialization. It is a **skeleton by design**: no grading logic, no promotion * logic, no harness — those land later (the matched-set harness owns selection/grading; ADR-0029 owns * owner-gated freezes). What ships here is the SHAPE the evidence must take, and the shape is chosen so * the passivity trap cannot hide in it. * * ## The cell key is a 5-tuple PHANTOM INDEX (ADR-0041 §2) * Evidence keys on **instrumentClass × band × archetype-family × phase × role** (a {@link Cell}). It is * a *phantom index*: there is NO coercion between cells, ever. Pooling grades across `role` (the pod * {@link SeatId} axis) or across `phase` is ill-typed, because a pane's sign is role-dependent and * phase-dependent. This module provides no API that merges two cells' evidence — cross-cell coercion * has no surface, by construction. `role` is the closed {@link SeatId} roster (ADR-0041 A1); an unknown * role is REFUSED at deserialization (fail-closed — the ledger keys on the role, so it can never admit a * value outside the roster). * * ## The dual hash (ADR-0041 §2) * A template carries TWO hashes, bound by one rule: `templateHash` (sha256 of the canonical PRINTED * bytes) is the SKU — **identity** follows it; `elabHash` (sha256 of the ELABORATED/saturated form — * every defaulted slot explicitly printed) carries **evidence**. A catalog-default change can move a * template's denotation while its `templateHash` holds constant, but it changes the `elabHash`, so old * grades can never silently re-attach to a template whose meaning drifted underneath them. This module * takes both hashes as inputs (the printing lives in the renderer/lang, not here) — the skeleton records * them, it does not compute the printed forms. * * ## Grades are POLE-VECTORS, never sums (ADR-0041 §2) * The passivity trap is a *sign structure* — a restraint pane cuts the loss on the restraint pole while * forfeiting the gain on the action pole, and a scalar that netted the two would hide it. So a cell's * grade is a {@link PoleVector}: two separately-named sample vectors (restraint pole, action pole) with * **no `sum`/`net` field**, and this module offers **no function** that reduces a PoleVector to a * scalar. Scalar netting is inexpressible by construction, not by convention. * * Determinism: pure mapping + validation, no wall clock, no RNG (RUNTIME §0). Hashes are sha256 of * canonical bytes; `committedAt` is a caller-supplied logical stamp, never `Date.now()` inside here. */ import { canonicalizeJson } from "../canonical/json.ts"; import { isSeatId, type SeatId } from "./seat.ts"; // ───────────────────────────────────────────────────────────────────────────── // The cell key — the 5-tuple phantom index (ADR-0041 §2) // ───────────────────────────────────────────────────────────────────────────── /** The closed `instrumentClass` sort (ADR-0041 §2) — pinned NOW while the ledger is empty; opened only * by the sort-introduction ceremony (§3). Every founder record carries `equity-option` until a * non-equity cohort exists. */ export const INSTRUMENT_CLASSES = ["equity-option", "future", "perp", "fx-pair", "spot-equity"] as const; export type InstrumentClass = (typeof INSTRUMENT_CLASSES)[number]; /** The closed `phase` axis (ADR-0041 §2) — orthogonal to band; a phase template is a named View * delivered by a Wake. */ export const LEDGER_PHASES = ["OPEN", "WAKE", "SHOCK", "CLOSE"] as const; export type LedgerPhase = (typeof LEDGER_PHASES)[number]; /** * The evidence cell key — **instrumentClass × band × archetype-family × phase × role** (ADR-0041 §2). * `band` and `archetype-family` are open working-set strings (their taxonomy is a measurement question * for the first cohorts, ADR-0041 open questions #2), so they are typed as `string`; the two closed * sorts (`instrumentClass`, `phase`) and the closed `role` roster ({@link SeatId}) are the schema this * module enforces. Two cells are equal iff all five axes match — a phantom index, never coerced. */ export interface Cell { readonly instrumentClass: InstrumentClass; readonly band: string; readonly archetypeFamily: string; readonly phase: LedgerPhase; readonly role: SeatId; } /** The canonical string key of a {@link Cell} — a stable, order-fixed join over the five axes, used as * the ledger's per-cell map key. Because the axis order is fixed here in ONE place, two constructions of * the same cell always key identically, and two DIFFERENT cells never collide (the phantom index). Pure. */ export function cellKey(cell: Cell): string { // A NUL join over the fixed axis order — the axis strings can never contain NUL, so the key is // unambiguous (no separator injection), and the order is pinned here so it never drifts. return [cell.instrumentClass, cell.band, cell.archetypeFamily, cell.phase, cell.role].join(""); } /** Fail-closed cell validation: every axis must be a member of its closed sort/roster (unknown role, * instrumentClass, or phase is REFUSED — never coerced into a neighbouring cell, ADR-0041 §2). `band` * and `archetype-family` must be non-empty strings (an empty axis is not a cell). Returns the narrowed * {@link Cell}. */ export function parseCell(raw: unknown): Cell { if (typeof raw !== "object" || raw === null) throw new Error("cell: not an object"); const r = raw as Record<string, unknown>; if (!(INSTRUMENT_CLASSES as readonly string[]).includes(r.instrumentClass as string)) { throw new Error(`cell: unknown instrumentClass ${JSON.stringify(r.instrumentClass)} — closed sort: ${INSTRUMENT_CLASSES.join(", ")}`); } if (!(LEDGER_PHASES as readonly string[]).includes(r.phase as string)) { throw new Error(`cell: unknown phase ${JSON.stringify(r.phase)} — closed sort: ${LEDGER_PHASES.join(", ")}`); } if (!isSeatId(r.role)) { throw new Error(`cell: unknown role ${JSON.stringify(r.role)} — the closed pod-seat roster (ADR-0041 A1) refuses it; never coerced`); } if (typeof r.band !== "string" || r.band === "") throw new Error("cell: band must be a non-empty string"); if (typeof r.archetypeFamily !== "string" || r.archetypeFamily === "") throw new Error("cell: archetypeFamily must be a non-empty string"); return { instrumentClass: r.instrumentClass as InstrumentClass, band: r.band, archetypeFamily: r.archetypeFamily, phase: r.phase as LedgerPhase, role: r.role, }; } // ───────────────────────────────────────────────────────────────────────────── // TemplateRecord — the hypothesis carrying a ledger entry (ADR-0041 §2) // ───────────────────────────────────────────────────────────────────────────── /** The ledger tier of a template (ADR-0041 §2 / kestrel-wa0j.26). Every seed ENTERS as `tier-0` (a * hypothesis vs the required baseline pair); promotion toward a frozen default stays owner-gated on * realized-blotter evidence (ADR-0029 §q4) — this skeleton records the tier, it does not promote. */ export const LEDGER_TIERS = ["tier-0", "tier-1", "frozen-default"] as const; export type LedgerTier = (typeof LEDGER_TIERS)[number]; /** Where a template came from + its re-seed lineage (ADR-0041 §2: reuse of a template in a NEW cell is * an explicit, lineage-recorded re-seed against a fresh null — never a silent coercion). `origin` * distinguishes a founder seed from an agent-authored lens from a re-seed; `parentTemplateHash` links a * re-seed to the template it descends from. */ export interface Lineage { readonly origin: "founder" | "agent-authored" | "reseed"; readonly parentTemplateHash?: string; readonly note?: string; } const LINEAGE_ORIGINS = ["founder", "agent-authored", "reseed"] as const; /** * A **TemplateRecord** (ADR-0041 §2): `{ templateHash, elabHash, cell, receipts, lineage, tier }`. * `templateHash` is the SKU (identity); `elabHash` carries evidence (denotation). `receipts` are the * content-addressed attribution references the template's builders carried (the skeleton stores them as * opaque strings — the receipt CONTENT is the renderer's concern). A template is a hypothesis; the * ledger is where it earns or loses its cell. */ export interface TemplateRecord { readonly templateHash: string; readonly elabHash: string; readonly cell: Cell; readonly receipts: readonly string[]; readonly lineage: Lineage; readonly tier: LedgerTier; } /** A 64-hex sha256 string guard — both hashes and `predictionHash` must be sha256 hex, never a free * label (identity/evidence follow the hash, ADR-0041 §2). */ function isSha256Hex(v: unknown): v is string { return typeof v === "string" && /^[0-9a-f]{64}$/.test(v); } /** Fail-closed {@link TemplateRecord} validation from untrusted JSON — every field checked, the cell * parsed through {@link parseCell} (so an unknown role refuses). Never coerces a malformed record onto a * passing shape. */ export function parseTemplateRecord(raw: unknown): TemplateRecord { if (typeof raw !== "object" || raw === null) throw new Error("TemplateRecord: not an object"); const r = raw as Record<string, unknown>; if (!isSha256Hex(r.templateHash)) throw new Error("TemplateRecord: templateHash must be a sha256 hex string (the SKU)"); if (!isSha256Hex(r.elabHash)) throw new Error("TemplateRecord: elabHash must be a sha256 hex string (the evidence key)"); const cell = parseCell(r.cell); if (!Array.isArray(r.receipts) || !r.receipts.every((x) => typeof x === "string")) { throw new Error("TemplateRecord: receipts must be a string[]"); } if (!(LEDGER_TIERS as readonly string[]).includes(r.tier as string)) { throw new Error(`TemplateRecord: unknown tier ${JSON.stringify(r.tier)} — closed: ${LEDGER_TIERS.join(", ")}`); } const lin = r.lineage; if (typeof lin !== "object" || lin === null) throw new Error("TemplateRecord: lineage must be an object"); const l = lin as Record<string, unknown>; if (!(LINEAGE_ORIGINS as readonly string[]).includes(l.origin as string)) { throw new Error(`TemplateRecord: unknown lineage.origin ${JSON.stringify(l.origin)} — closed: ${LINEAGE_ORIGINS.join(", ")}`); } if (l.parentTemplateHash !== undefined && !isSha256Hex(l.parentTemplateHash)) { throw new Error("TemplateRecord: lineage.parentTemplateHash must be a sha256 hex string when present"); } if (l.note !== undefined && typeof l.note !== "string") throw new Error("TemplateRecord: lineage.note must be a string when present"); const lineage: Lineage = { origin: l.origin as Lineage["origin"], ...(l.parentTemplateHash !== undefined ? { parentTemplateHash: l.parentTemplateHash as string } : {}), ...(l.note !== undefined ? { note: l.note as string } : {}), }; return { templateHash: r.templateHash, elabHash: r.elabHash, cell, receipts: [...r.receipts], lineage, tier: r.tier as LedgerTier }; } /** Canonical JSON serialization (sorted keys, dropped undefined) — the byte-stable form the schema test * asserts and the store round-trips through. */ export function serializeTemplateRecord(record: TemplateRecord): string { return canonicalizeJson(record); } // ───────────────────────────────────────────────────────────────────────────── // PredictionRecord — the pre-committed claim + its closed adjudication state machine (ADR-0041 §2) // ───────────────────────────────────────────────────────────────────────────── /** The closed adjudication states (ADR-0041 §2). The machine: COMMITTED → RUNNABLE | UNRUNNABLE; * RUNNABLE → GRADED; GRADED → CONFIRMED | REFUTED. `UNRUNNABLE` is an honorable TERMINAL state (the * theta pre-registration precedent), not a failure; `CONFIRMED`/`REFUTED` are terminal at identical * schema fidelity — refutations are recorded, there is no file drawer. */ export const ADJUDICATION_STATES = ["COMMITTED", "RUNNABLE", "UNRUNNABLE", "GRADED", "CONFIRMED", "REFUTED"] as const; export type AdjudicationState = (typeof ADJUDICATION_STATES)[number]; /** The closed transition table: `state → the states it may advance to`. A terminal state maps to `[]`. */ const ADJUDICATION_TRANSITIONS: Readonly<Record<AdjudicationState, readonly AdjudicationState[]>> = { COMMITTED: ["RUNNABLE", "UNRUNNABLE"], RUNNABLE: ["GRADED"], GRADED: ["CONFIRMED", "REFUTED"], UNRUNNABLE: [], CONFIRMED: [], REFUTED: [], }; /** Is `to` a legal next state from `from` under the closed adjudication machine? Pure. */ export function canAdjudicate(from: AdjudicationState, to: AdjudicationState): boolean { return ADJUDICATION_TRANSITIONS[from].includes(to); } /** Advance an adjudication along the closed machine, or THROW naming the illegal transition (fail-closed * — a prediction can never jump COMMITTED → CONFIRMED, skipping the runnable/graded firewall). Returns * the new state. */ export function advanceAdjudication(from: AdjudicationState, to: AdjudicationState): AdjudicationState { if (!canAdjudicate(from, to)) { const legal = ADJUDICATION_TRANSITIONS[from]; throw new Error( `illegal adjudication transition ${from}${to}` + (legal.length === 0 ? ` (${from} is terminal)` : ` — from ${from} only: ${legal.join(", ")}`), ); } return to; } /** * A **PredictionRecord** (ADR-0041 §2): `{ predictionHash, templateHash?, cell, claim, falsifier, * committedAt, adjudication }`. The `falsifier` is a predicate the merged harness can compute — an * experiment whose falsifier is prose is refused admission (the skeleton stores the falsifier as a * string IDENTIFIER/expression; the SKELETON does not evaluate it). `committedAt` is a caller-supplied * logical stamp (never a wall clock inside this module). Tier promotion later requires citing a * prediction whose hash predates the grading sample's draw — the two-stage forking-paths firewall — but * that gating is the harness's, not this skeleton's. */ export interface PredictionRecord { readonly predictionHash: string; readonly templateHash?: string; readonly cell: Cell; readonly claim: string; readonly falsifier: string; readonly committedAt: string; readonly adjudication: AdjudicationState; } /** Fail-closed {@link PredictionRecord} validation from untrusted JSON. The cell is parsed through * {@link parseCell} (unknown role refuses); the adjudication must be a member of the closed state set. */ export function parsePredictionRecord(raw: unknown): PredictionRecord { if (typeof raw !== "object" || raw === null) throw new Error("PredictionRecord: not an object"); const r = raw as Record<string, unknown>; if (!isSha256Hex(r.predictionHash)) throw new Error("PredictionRecord: predictionHash must be a sha256 hex string"); if (r.templateHash !== undefined && !isSha256Hex(r.templateHash)) { throw new Error("PredictionRecord: templateHash must be a sha256 hex string when present"); } const cell = parseCell(r.cell); if (typeof r.claim !== "string" || r.claim === "") throw new Error("PredictionRecord: claim must be a non-empty string"); if (typeof r.falsifier !== "string" || r.falsifier === "") { throw new Error("PredictionRecord: falsifier must be a non-empty computable-predicate string (prose is refused, ADR-0041 §2)"); } if (typeof r.committedAt !== "string" || r.committedAt === "") throw new Error("PredictionRecord: committedAt must be a non-empty logical stamp"); if (!(ADJUDICATION_STATES as readonly string[]).includes(r.adjudication as string)) { throw new Error(`PredictionRecord: unknown adjudication ${JSON.stringify(r.adjudication)} — closed: ${ADJUDICATION_STATES.join(", ")}`); } return { predictionHash: r.predictionHash, ...(r.templateHash !== undefined ? { templateHash: r.templateHash as string } : {}), cell, claim: r.claim, falsifier: r.falsifier, committedAt: r.committedAt, adjudication: r.adjudication as AdjudicationState, }; } /** Canonical JSON serialization of a {@link PredictionRecord} (sorted keys, dropped undefined). */ export function serializePredictionRecord(record: PredictionRecord): string { return canonicalizeJson(record); } // ───────────────────────────────────────────────────────────────────────────── // Grades — POLE-VECTORS per cell, never sums (ADR-0041 §2) // ───────────────────────────────────────────────────────────────────────────── /** The two poles of a matched set, named so a grade is always attributed to one of them (ADR-0041 §2 — * the null is that a pane HURTS until BOTH poles say otherwise). */ export type Pole = "restraint" | "action"; /** * A cell's grade evidence: two SEPARATELY-NAMED sample vectors (the `frozenPlanEv` draws on each pole), * with **NO `sum`/`net`/`mean` field** (ADR-0041 §2). The passivity trap is a sign structure — a * positive restraint entry netted against a negative action entry would look innocent as a scalar — so * the record shape is unable to hide it: there is no scalar, and this module offers no reduction of a * PoleVector to one. A reader that wants a pole-mean computes it OUTSIDE the ledger, per pole, on * purpose. */ export interface PoleVector { readonly restraint: readonly number[]; readonly action: readonly number[]; } /** An empty pole-vector — the identity a fresh cell starts from (both poles ungraded). */ export function emptyPoleVector(): PoleVector { return { restraint: [], action: [] }; } /** Append ONE frozenPlanEv sample to ONE pole of a pole-vector, returning a NEW pole-vector (immutable). * The other pole is untouched — a sample is always attributed to exactly one pole, never smeared across * both, and never netted. Pure. */ export function appendPoleSample(pv: PoleVector, pole: Pole, sample: number): PoleVector { if (!Number.isFinite(sample)) throw new Error(`pole sample must be finite (got ${sample}) — never a fabricated grade`); return pole === "restraint" ? { restraint: [...pv.restraint, sample], action: pv.action } : { restraint: pv.restraint, action: [...pv.action, sample] }; } // ───────────────────────────────────────────────────────────────────────────── // The in-memory ledger store (skeleton — no grading logic) // ───────────────────────────────────────────────────────────────────────────── /** * The in-memory TemplateRecord ledger (ADR-0041 §2 skeleton): templates keyed by `templateHash`, * predictions keyed by `predictionHash`, and grade pole-vectors keyed by {@link cellKey}. It stores and * retrieves; it does NOT grade, promote, or net. The phantom-index no-coercion rule is structural: * there is no method that merges two cells' grades — grades are addressable only by an exact cell key, * so cross-cell coercion has no API surface at all. */ export class TemplateLedger { readonly #templates = new Map<string, TemplateRecord>(); readonly #predictions = new Map<string, PredictionRecord>(); readonly #grades = new Map<string, PoleVector>(); /** Record (or replace) a template by its SKU. Re-recording the same `templateHash` is an idempotent * upsert — same identity, same slot. */ putTemplate(record: TemplateRecord): void { this.#templates.set(record.templateHash, record); } /** The template for a SKU, or `undefined`. */ template(templateHash: string): TemplateRecord | undefined { return this.#templates.get(templateHash); } /** Every template, insertion-ordered. */ templates(): readonly TemplateRecord[] { return [...this.#templates.values()]; } /** Record (or replace) a prediction by its hash. */ putPrediction(record: PredictionRecord): void { this.#predictions.set(record.predictionHash, record); } /** The prediction for a hash, or `undefined`. */ prediction(predictionHash: string): PredictionRecord | undefined { return this.#predictions.get(predictionHash); } /** Every prediction, insertion-ordered. */ predictions(): readonly PredictionRecord[] { return [...this.#predictions.values()]; } /** Append ONE frozenPlanEv sample to ONE pole of ONE cell's grade (ADR-0041 §2). A sample lands in * EXACTLY the addressed cell (`cellKey(cell)`) and EXACTLY the named pole — never coerced into a * neighbouring cell, never netted against the other pole. Fail-closed on a non-finite sample. */ recordGrade(cell: Cell, pole: Pole, sample: number): void { const key = cellKey(cell); const current = this.#grades.get(key) ?? emptyPoleVector(); this.#grades.set(key, appendPoleSample(current, pole, sample)); } /** The pole-vector for an EXACT cell, or `undefined` when the cell is ungraded. There is NO variant * that reads across cells or nets the poles — the phantom index is total. */ grade(cell: Cell): PoleVector | undefined { return this.#grades.get(cellKey(cell)); } }