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.
189 lines (188 loc) • 15 kB
JavaScript
/**
* # session/run-identity — the run-identity layer of the config matrix (kestrel-5zl.7)
*
* The config matrix (5zl.8 `buildGrid` + leaderboard) is a grid of `(tape × fill_model × config)` CELLS,
* each holding the replicate runs graded in that cell. This module is the **identity layer** that grid
* consumes — three PURE derivations (no wall clock, no RNG — RUNTIME §0) that never write a byte back onto
* the graded bus or the {@link import("../blotter").Blotter}: they are READS of a finalized record, never a
* new stamp (stamping a run's own id INTO the record it is derived from would be circular):
*
* 1. {@link SimRunId} — `(gradedBus) → sha256(serializeBus(gradedBus))`: a DETERMINISTIC identity for one
* run. Byte-identical graded bus ⇒ the same 64-hex id; a byte-different graded bus ⇒ a different id, so
* it IDENTIFIES replicates (each re-run of a cell is a distinct run). It is EXACTLY the graded-bus
* content hash the projector already computes as `blotter.session.bus.sha256`
* (`src/blotter/project.ts`) — surfaced here as a named identity, a pure read, not a second stamp:
* `SimRunId(bus) === project(bus).session.bus.sha256`.
*
* 2. {@link CellKey} — `(tape, fill_model, config) → the grid CELL key`: which `(tape × fill_model ×
* config)` cell a run belongs to. Runs sharing a CellKey are REPLICATES in one comparison cell.
* Deterministic in its three inputs. The config axis is the {@link cellConfigId cell ConfigId}
* (`deriveConfigId(cellCanonicalConfig(config))`, 5zl.6) — the full ConfigId folds the sampling axis
* (`temperature`/`thinkingLevel`), `format`, and `label` beyond the envelope, so configs differing ONLY
* in temperature are DISTINCT cells (the m9i.2 owner tweak — never pooled as replicates, which would
* break the temperature×thinking sweep) — MINUS the `fillSeed` REPLICATE axis, which is STRIPPED, so a
* seed ensemble is N replicates in ONE cell (ADR-0016 §3), each a distinct {@link SimRunId}, never N
* distinct columns. A seed is measurement noise, not behavior.
*
* 3. {@link cellOf} — `(finalizedBlotter) → CellKey`, derived by a PURE read of the Blotter's session
* header: the tape axis from `session.instruments`, the fill_model axis from `session.fill_model`, and
* the config axis from `session.cell_config_id` when present (the driver-stamped seed-stripped cell axis)
* else `session.config_id` (a seedless run — its cell axis equals its full ConfigId; m9i.2).
* `cellOf(blotter)` EQUALS `CellKey(tape, fill_model, config)` for the inputs that produced the run —
* that reconciliation is what lets buildGrid key any finalized Blotter into the right cell.
*
* ## DESIGN NOTE — the config axis is the cell ConfigId: behavior splits, the seed is a replicate
* The 5zl.7 tracer keyed the config axis on the a57.14 ENVELOPE identity (the subset that survives onto the
* Blotter), so temperature-only variants shared a cell — different experimental subjects pooled as
* replicates. The m9i.2 owner review made keying on the ConfigId BLOCKING before any benchmark rows exist:
* the driver stamps `config_id = deriveConfigId(canonical config)` (the FULL, seeded ConfigId — the run's
* identity, feeding {@link SimRunId}) on the graded META alongside the envelope, and the projector carries it
* to `session.config_id`. But the CELL axis is the FULL ConfigId with the `fillSeed` REPLICATE axis STRIPPED
* ({@link cellConfigId}) — a seed is measurement noise, not behavior, so a seed ensemble is N replicates in
* ONE cell (ADR-0016 §3), each a distinct SimRunId. The driver stamps that seed-stripped axis as
* `cell_config_id` ONLY when it diverges from `config_id` (i.e. a seed was present), so a seedless run is
* byte-identical to before and its cell axis is just `config_id`. {@link CellKey} and {@link cellOf} both key
* on the cell ConfigId (one definition in `config.ts`, no drift). A pre-m9i.2 Blotter (declared envelope,
* neither id) falls back to the envelope-identity axis — a LEGACY cell, deliberately distinct from every
* ConfigId-keyed cell (an unknown sampling axis is never pooled with a known one). This was a pre-rows
* semantic fix: no persisted CellKeys existed to migrate. {@link SequenceCellKey} (5zl.16.5) composes
* per-session {@link cellOf} keys, so the multi-session sequence cell absorbs the same axis change with no
* further definition.
*
* ## The tape axis
* A finalized Blotter carries NO input-tape content hash — `session.bus.sha256` is the GRADED bus hash (the
* SimRunId), not the tape. The only replicate-invariant, tape-identifying field on the Blotter is
* `session.instruments`, so it is the tape axis (sufficient for the epic's one-tape × N-configs demo). A
* precise tape-content id across same-symbol tapes is the same additive-bump decision, deferred.
*/
import { serializeBus } from "../bus/index.js";
import { cellConfigId, declaresEnvelope, deriveConfigId } from "./config.js";
import { sha256 } from "../crypto/sha256.js";
// ─────────────────────────────────────────────────────────────────────────────
// (1) SimRunId — the graded bus's content hash, surfaced as a named per-run identity (a PURE READ)
// ─────────────────────────────────────────────────────────────────────────────
/**
* The identity of ONE simulate run: `sha256(serializeBus(gradedBus))` — the canonical graded-bus content
* hash, reusing the codebase's one bus serializer + sha convention (`src/bus/write.ts`,
* `src/blotter/project.ts`). Pure and deterministic (no wall clock, no RNG): a byte-identical graded bus
* yields the same 64-hex id, a byte-different one a different id. This is a READ of the record, NOT a new
* stamp — it equals the receipt the projector already computes as `blotter.session.bus.sha256`
* (`SimRunId(bus) === project(bus).session.bus.sha256`), so deriving it changes ZERO bus/Blotter bytes.
* buildGrid (5zl.8) uses it to tell the replicates in one cell apart.
*/
export function SimRunId(gradedBus) {
return sha256(serializeBus(gradedBus));
}
/**
* The codebase's ONE canonical content id — deep-sorted JSON → sha256 — reused as each cell axis's
* identity. `deriveConfigId` (5zl.6, `./config.ts`) IS exactly that generic hash: it re-canonicalizes any
* JSON-representable value (sorting every object's keys, dropping `undefined`) and hashes it, so an axis id
* depends only on the axis DATA, never on key insertion order. That order-insensitivity is precisely what
* makes {@link cellOf} a pure READ that reconciles with {@link CellKey}: two deep-equal axes hash identically.
*/
const axisId = deriveConfigId;
/** The stable config-axis id for a run that DECLARES no envelope (a NO-LLM Backtest — {@link declaresEnvelope}
* false, the driver stamps neither `session.envelope` nor `session.config_id`). Hashing a fixed sentinel —
* never the value `undefined` — keeps the axis a uniform 64-hex and keeps {@link CellKey}/{@link cellOf}
* reconciled for the no-envelope case. A string sentinel can never collide with a real ConfigId (the sha256
* of an OBJECT's canonical bytes) or an envelope's canonical-bytes id. */
const NO_ENVELOPE = axisId("no-envelope");
/** The config-axis id read off a finalized Blotter's session header. Precedence, most-specific first:
* 1. the stamped `cell_config_id` — the full ConfigId with the `fillSeed` REPLICATE axis stripped (the
* driver stamps it only when it diverges from `config_id`, i.e. a seed was present) — so seed-only
* variants read back the SAME axis (replicates in one cell, ADR-0016 §3);
* 2. else the stamped FULL `config_id` (a seedless authoring run — its cell axis EQUALS its full ConfigId,
* so no separate stamp; also the pre-cell_config_id legacy Blotter — unchanged behavior);
* 3. else the envelope-identity id — the pre-m9i.2 LEGACY fallback (a Blotter with neither id), deliberately
* distinct from every ConfigId-keyed cell (an unknown sampling axis is never pooled with a known one);
* 4. else the {@link NO_ENVELOPE} sentinel for a no-envelope run. */
function configAxisOfSession(cellConfigIdField, configId, envelope) {
if (cellConfigIdField !== undefined && cellConfigIdField.length > 0)
return cellConfigIdField;
if (configId !== undefined && configId.length > 0)
return configId;
return envelope === undefined ? NO_ENVELOPE : axisId(envelope);
}
/** The config-axis id derived from a run's config descriptor: the {@link cellConfigId cell ConfigId} for an
* authoring config (the m9i.2 owner tweak — folds temperature/thinkingLevel/format/label, so behavioral
* variants are distinct cells — with the `fillSeed` REPLICATE axis STRIPPED, so seed-only variants share a
* cell, ADR-0016 §3), or the {@link NO_ENVELOPE} sentinel for a NO-LLM Backtest config (which the driver
* stamps nothing for). Mirrors the driver's `cell_config_id`/`config_id` stamp exactly so {@link CellKey} and
* {@link cellOf} reconcile. */
function configAxisOfConfig(config) {
return declaresEnvelope(config) ? cellConfigId(config) : NO_ENVELOPE;
}
/** Assemble the three-axis {@link CellKeyStruct} from raw axis values. The one place the axes are read into
* ids, shared by {@link CellKey} and {@link cellOf} so they can never drift. */
function cellKeyStruct(tape, fillModel, configAxis) {
return { tape: axisId(tape), fill_model: axisId(fillModel), config: configAxis };
}
/** The canonical string form of a {@link CellKeyStruct}: the three axis ids in a FIXED order, so the string
* is a byte-stable map key for buildGrid. */
function stringifyCellKey(k) {
return JSON.stringify({ tape: k.tape, fill_model: k.fill_model, config: k.config });
}
/**
* The grid CELL key for a `(tape, fill_model, config)` triple (kestrel-5zl.7; config axis = the
* {@link cellConfigId cell ConfigId}). Deterministic in its three inputs: same triple ⇒ same key; a
* different tape, a different fill model, or a config whose cell ConfigId differs on any BEHAVIORAL field —
* including the non-envelope sampling axis (`temperature`/`thinkingLevel`), `format`, and `label` — ⇒ a
* different key, so temperature-only variants are DISTINCT cells, never pooled. But a config differing ONLY
* in the `fillSeed` REPLICATE axis yields the SAME key — a seed ensemble is N replicates in one cell
* (ADR-0016 §3), each a distinct {@link SimRunId}. The driver stamps the matching axis onto the graded META
* (`cell_config_id`, or `config_id` when seedless), so a run's {@link cellOf} reconciles with the CellKey of
* the inputs that produced it. Runs sharing a CellKey are REPLICATES in one comparison cell. Pure (no wall
* clock, no RNG).
*/
export function CellKey(tape, fillModel, config) {
return stringifyCellKey(cellKeyStruct(tape, fillModel, configAxisOfConfig(config)));
}
/**
* The CELL a finalized run belongs to, derived by a PURE READ of the Blotter's session header (kestrel-5zl.7):
* the tape axis from `session.instruments`, the fill_model axis from `session.fill_model`, and the config axis
* from `session.config_id` (the driver-stamped FULL ConfigId — m9i.2 owner tweak; envelope-identity legacy
* fallback, {@link configAxisOfSession}). Changes NO Blotter bytes. By construction `cellOf(blotter) ===
* CellKey(tape, fill_model, config)` for the `(tape, fill_model, config)` that produced the run (the driver
* stamps `config_id` under exactly {@link configAxisOfConfig}'s condition), which is the reconciliation
* buildGrid (5zl.8) relies on to key any finalized Blotter into the right cell — while each replicate keeps
* a distinct {@link SimRunId}.
*/
export function cellOf(blotter) {
const s = blotter.session;
return stringifyCellKey(cellKeyStruct(s.instruments, s.fill_model, configAxisOfSession(s.cell_config_id, s.config_id, s.envelope)));
}
// ─────────────────────────────────────────────────────────────────────────────
// (4)+(5) InstanceRunId / SequenceCellKey — the multi-session identity chain (kestrel-5zl.16.5)
// ─────────────────────────────────────────────────────────────────────────────
/**
* The identity of a whole multi-session **Instance**: `sha256` of the ORDERED per-session
* {@link SimRunId}s (a hash of ordered hashes). Pure, deterministic, and ORDER-SENSITIVE — the Instance
* identity is a function of its Sessions AND their order — mirroring the {@link SimRunId} pure-read
* discipline: it writes no byte back onto any bus or Blotter (it is a read of the finalised sequence).
* Same tapes + same agent turns ⇒ byte-identical per-session buses ⇒ identical SimRunIds ⇒ identical
* InstanceRunId. A LENGTH-1 Instance's id is `sha256` of its single SimRunId — distinct from that
* SimRunId's own bytes (a one-session Instance is still an Instance), but fully determined by it.
*/
export function InstanceRunId(orderedSimRunIds) {
// A delimiter that can never appear inside a 64-hex SimRunId, so the join is unambiguous and
// order-sensitive (`[a,b] ≠ [b,a]`, `[ab] ≠ [a,b]`).
return sha256(orderedSimRunIds.join("\n"));
}
/**
* The grid CELL key for a multi-session **sequence cell** (kestrel-5zl.16.5): the tape axis extends from a
* single tape to the ORDERED sequence of per-session {@link CellKey}s. Runs sharing a SequenceCellKey are
* replicates of the same ordered `(tape × fill_model × config)` sequence. **The length-1 case returns the
* single session's CellKey UNCHANGED** — so an existing single-session cell key is byte-identical and
* existing grids are untouched (the degenerate case). A longer sequence hashes the ordered list into a
* distinct, order-sensitive key. Pure (no wall clock, no RNG).
*/
export function SequenceCellKey(perSessionCellKeys) {
if (perSessionCellKeys.length === 0) {
throw new Error("run-identity: SequenceCellKey needs a non-empty sequence (fail-closed)");
}
// Degenerate length-1: the sequence cell IS the single-session cell — unchanged, so existing grids don't move.
if (perSessionCellKeys.length === 1)
return perSessionCellKeys[0];
// A longer sequence: the ordered list of per-session cell keys, canonicalised order-sensitively.
return JSON.stringify({ sequence: [...perSessionCellKeys] });
}