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.
248 lines (247 loc) • 18.5 kB
JavaScript
/**
* # session/config — the sim config descriptor: canonicalize → ConfigId → the a57.14 envelope (kestrel-5zl.6)
*
* The comparison axis of Simulate is the **{@link AgentConfig} descriptor**: the same recorded tape graded
* under N configs yields comparable {@link import("../blotter").Blotter}s, keyed by config. This module is
* the CAPTURE side of that axis — three pure derivations, no wall clock, no RNG (RUNTIME §0):
*
* 1. {@link canonicalizeConfig} — `(AgentConfig) → a canonical, normalized descriptor`. Deterministic and
* ORDER-INSENSITIVE: the SAME field values in ANY key insertion order (top-level or nested) canonicalize
* byte-identically, via the Blotter's own sorted-key / drop-undefined canonicalizer (the agent-harness ADR (d)).
* 2. {@link deriveConfigId} — `(canonical) → ConfigId = sha256(canonical)` (the agent-harness ADR (d)). The **grid
* column key** the config matrix (5zl.7 run-identity, 5zl.8 grid/leaderboard) keys a run by: same config
* ⇒ same id; ANY changed field ⇒ a DIFFERENT id (a config edit mints a new column, never contaminates an
* old one). The ConfigId is a SUPERSET of the envelope identity — it additionally folds the sampling axis
* (`temperature`/`thinkingLevel`), `format`, and the leaderboard `label`, none of which are envelope
* fields — so two runs differing only in `temperature` are different columns yet share every envelope field.
* 3. {@link envelopeOf} / {@link envelopeForConfig} — map the canonical descriptor to the a57.14
* {@link ExperimentalEnvelope} (its INPUT half — the agent-harness ADR (d): one definition, never a second one), which
* the Simulate driver stamps onto the graded META so the finalized Blotter carries it. The `prompt_hash`
* carries a HASH, NEVER prompt/strategy content (the provenance fence, AGENTS.md §7).
*
* ## The descriptor → envelope field map (deliverable 1)
* ModelIdentity provider → provider · model → model · version → version · trainingCutoff → training_cutoff
* RenderingIdentity tokenizer → tokenizer · renderingIdentity → rendering_identity (format rides the ConfigId only)
* AuthorPolicy promptHash → prompt_hash (a HASH) · toolPolicy → tool_policy · wakeSource → wake_source ·
* corpusTier → corpus_tier
* Interface/harness face → face (http|sdk|cli|mcp) · adapter → adapter · adapterVersion → adapter_version
* (m9i.2 owner tweaks — the m9i.7 tournament axes, mandatory-for-certified)
* injected tokenAccounting → token_accounting (off the deterministic record path)
* ConfigId-only cachePolicy (kestrel-rul, System Profile v1.1 `cache policy`) — folds into the ConfigId
* like `format`/`temperature`, but is DELIBERATELY NOT an envelope field: caching is
* transport-side + off the graded path, so two policies grade identically yet mint
* distinct grid columns (the m9i.5 append-only-vs-redraw / cache-on-off A/B).
* cacheTtl (kestrel-wa0j.1) rides the same rule: a ConfigId-only transport axis (absent ⇒
* provider default ⇒ existing ConfigIds are untouched), never an envelope field.
* clockHonest + latencyBufferMs (ADR-0040 / kestrel-w7la.1) are ConfigId axes too, never
* envelope fields (the a57.14 fourteen stay frozen): they fold in automatically via the
* generic canonicalizer, so a clocked run — or a different buffer — NEVER shares a grid
* column with an unclocked one (clock-honest wakes §4). Absent ⇒ latency-blind ⇒ every
* existing ConfigId is byte-identical.
*
* ## Fail-closed by omission (the blast-radius rule, a57.14)
* A NO-LLM Backtest ({@link import("./agent.ts").fixedPlanAgent} / `recordedAgent`, whose
* {@link import("./agent.ts").BACKTEST_CONFIG} authors nothing — no `provider`/`version`/`promptHash`/…)
* DECLARES no envelope: {@link declaresEnvelope} is false ⇒ no stamp ⇒ the Blotter stays `certified` (exactly
* a57.14's "a bus declaring NO envelope stays certified", and "never fabricate identity"). Only a config that
* declares authorship stamps — and a declared-but-INCOMPLETE identity (e.g. a blank `provider`) is stamped as
* an incomplete envelope so a57.14's projector fails the Blotter closed to PROVISIONAL, never omitted (which
* would certify-by-implication) and never silently defaulted.
*/
import { readSampledQualificationClaim } from "../grade/headline.js";
import { sha256 } from "../crypto/sha256.js";
import { canonicalize, canonicalizeJson } from "../canonical/json.js";
import { RENDERER_REVISION } from "../frame/renderer-revision.js";
/**
* Canonicalize an {@link AgentConfig} into its normalized descriptor: sorted keys, dropped `undefined`,
* recursive — the SAME {@link canonicalize} the Blotter serializes through, so the ConfigId and the Blotter
* share one notion of "canonical". PURE and ORDER-INSENSITIVE — the same field values in any key order
* (incl. the nested `tokenAccounting`) fold to byte-identical output. No wall clock, no RNG (RUNTIME §0).
*/
export function canonicalizeConfig(config) {
return canonicalize(config);
}
/**
* Derive the {@link ConfigId} — the deterministic **grid column key** — as `sha256(canonical)` (the agent-harness ADR
* (d)). Re-canonicalizes its input so the id is stable even if handed a raw descriptor. Same config ⇒ same
* 64-hex id; ANY changed field ⇒ a different id. Pure (no wall clock, no RNG).
*/
export function deriveConfigId(canonical) {
return sha256(canonicalizeJson(canonical));
}
// ─────────────────────────────────────────────────────────────────────────────
// The CELL config axis — the full ConfigId with the replicate axes stripped (ADR-0016 ensembles)
// ─────────────────────────────────────────────────────────────────────────────
/** The config keys that are **replicate axes** — Monte-Carlo measurement noise, NOT experimental behavior.
* A seed ensemble (`baseSeed + i`) is N replicates in ONE comparison cell whose spread is the MC error
* (ADR-0016 §3), never N distinct subjects — so these keys fold into the full {@link deriveConfigId ConfigId}
* / {@link import("./run-identity.ts").SimRunId SimRunId} (each seed is a distinct RUN) yet are STRIPPED from
* the cell config axis (seeds share one {@link import("./run-identity.ts").CellKey CellKey}). Today the sole
* replicate axis is `fillSeed`; a future replicate axis is added here (one definition, no drift). Behavioral
* knobs (`temperature`/`thinkingLevel`) are NOT here — they split cells. */
const REPLICATE_AXIS_KEYS = ["fillSeed"];
/** The canonical descriptor with the {@link REPLICATE_AXIS_KEYS} (fillSeed) removed — the substrate the CELL
* config axis hashes. Identical to {@link canonicalizeConfig} for a config that carries no replicate axis, so
* a seedless run's cell axis equals its full ConfigId. Pure, order-insensitive. */
export function cellCanonicalConfig(config) {
const c = { ...config };
for (const k of REPLICATE_AXIS_KEYS)
delete c[k];
return canonicalizeConfig(c);
}
/**
* The **cell config-axis id**: `deriveConfigId(cellCanonicalConfig(config))` — the full ConfigId with the
* replicate axes (fillSeed) stripped. This is the config axis the grid {@link import("./run-identity.ts").CellKey
* CellKey} keys on (m9i.2 owner tweak 1 — temperature-only variants are DISTINCT cells — reconciled with
* ADR-0016: seed-only variants are REPLICATES in ONE cell, not distinct columns). The ONE definition shared by
* the driver's `cell_config_id` stamp and the grid's CellKey/cellOf, so they can never drift. For a seedless
* config it equals the full {@link deriveConfigId ConfigId}. Pure (no wall clock, no RNG).
*/
export function cellConfigId(config) {
return deriveConfigId(cellCanonicalConfig(config));
}
// ─────────────────────────────────────────────────────────────────────────────
// The DERIVED rendering_identity — bound to the renderer's revision, never a hand-set label (kestrel-m9i.24)
// ─────────────────────────────────────────────────────────────────────────────
/**
* Derive the stamped `rendering_identity` from a renderer revision — the DRIFT-PROOF binding (ADR-0013(d),
* SYSTEM-PROFILE.md `rendering_identity`). A content-addressed handle `sha256:${sha256("kestrel.rendering/" +
* rev)}` (the codebase's {@link import("../catalog/session-catalog.ts") RenderingIdentity} handle idiom — the
* analog of the catalog's NO-LLM machine-frame handle), NOT a free per-config string a human bumps.
*
* A REAL authoring run sets `renderingIdentity = deriveRenderingIdentity()`, so the identity is a function of
* the renderer's actual revision: a renderer BEHAVIORAL change bumps {@link RENDERER_REVISION} ⇒ a DIFFERENT
* handle ⇒ a DIFFERENT {@link deriveConfigId ConfigId}/{@link import("./run-identity.ts").CellKey CellKey} (a
* fresh grid column, never a collapse into the old one — the drift m9i.24 closes). The default argument binds
* to the ONE-place {@link RENDERER_REVISION} source (`src/frame/renderer-revision.ts`), so there is exactly one
* place a renderer revision is declared. Pure — no wall clock, no RNG (RUNTIME §0).
*
* NO-CHURN: this is purely ADDITIVE — it does NOT touch {@link canonicalizeConfig}/{@link deriveConfigId}/
* {@link cellConfigId}/{@link envelopeOf}, so every existing explicit-label ConfigId/CellKey/conformance-root/
* grid pin is byte-identical; the derived value for the current renderer is a stable pin, and only a FUTURE
* revision bump moves it.
*/
export function deriveRenderingIdentity(rendererRevision = RENDERER_REVISION) {
return `sha256:${sha256("kestrel.rendering/" + rendererRevision)}`;
}
// ─────────────────────────────────────────────────────────────────────────────
// The a57.14 envelope — the descriptor's INPUT half (one definition, the agent-harness ADR (d))
// ─────────────────────────────────────────────────────────────────────────────
/** The descriptor keys BEYOND the NO-LLM Backtest core (`model`/`tokenizer`/`format`/`temperature`/
* `thinkingLevel`/`label`) — the ModelIdentity ⊕ AuthorPolicy ⊕ injected-accounting fields only a real
* authoring run carries. A config declaring ANY of them declares an experimental envelope. */
const ENVELOPE_DECLARING_KEYS = [
"provider",
"version",
"trainingCutoff",
"renderingIdentity",
"promptHash",
"toolPolicy",
"wakeSource",
"corpusTier",
"face",
"adapter",
"adapterVersion",
"tokenAccounting",
];
/**
* Does this config DECLARE an experimental envelope (i.e. is it an authoring run, not a NO-LLM Backtest)?
* True iff it carries any {@link ENVELOPE_DECLARING_KEYS} field. A {@link import("./agent.ts").BACKTEST_CONFIG}
* carries none ⇒ false ⇒ no envelope is stamped (fail-closed by omission — the Blotter stays `certified`,
* a57.14). A config with a BLANK required field (e.g. `provider: ""`) still DECLARES (it carries the other
* identity fields) ⇒ true ⇒ its incomplete envelope IS stamped so the projector fails it closed.
*/
export function declaresEnvelope(config) {
const c = config;
return ENVELOPE_DECLARING_KEYS.some((k) => c[k] !== undefined);
}
/** A required identity string, or `""` when absent/malformed — never a fabricated plausible value. The
* a57.14 projector treats `""` exactly like a missing field: it records a fail-closed reason NAMING it and
* flips the Blotter to PROVISIONAL (a blank identity is never certified-by-implication). */
function idString(v) {
return typeof v === "string" ? v : "";
}
/** The injected token accounting mapped verbatim (off the deterministic record path). A malformed/absent
* counter becomes `NaN` so the a57.14 projector rejects it as an incomplete envelope (fail-closed), never a
* silent `0`. */
function tokenAccountingOf(v) {
const r = typeof v === "object" && v !== null ? v : {};
const num = (x) => (typeof x === "number" ? x : NaN);
return { input: num(r.input), output: num(r.output), thinking: num(r.thinking) };
}
/**
* Map a canonical descriptor to the a57.14 {@link ExperimentalEnvelope} (the 1:1 field map above). `format`,
* `temperature`, `thinkingLevel`, and `label` are NOT envelope identity — they ride the ConfigId only. The
* `prompt_hash` is carried verbatim as a HASH; this function never sees prompt/strategy content (the
* provenance fence). Pure — a deterministic function of the descriptor.
*/
export function envelopeOf(canonical) {
const c = canonical;
return {
provider: idString(c.provider),
model: idString(c.model),
version: idString(c.version),
training_cutoff: idString(c.trainingCutoff),
prompt_hash: idString(c.promptHash),
tokenizer: idString(c.tokenizer),
tool_policy: idString(c.toolPolicy),
rendering_identity: idString(c.renderingIdentity),
wake_source: idString(c.wakeSource),
token_accounting: tokenAccountingOf(c.tokenAccounting),
corpus_tier: idString(c.corpusTier),
face: idString(c.face),
adapter: idString(c.adapter),
adapter_version: idString(c.adapterVersion),
};
}
/**
* The envelope a Simulate run stamps for `config`, or `undefined` when the config declares none (a NO-LLM
* Backtest — {@link declaresEnvelope} false ⇒ the driver stamps no `meta.envelope` ⇒ the Blotter stays
* `certified`, a57.14). When it declares one, the canonicalized descriptor is mapped through
* {@link envelopeOf} (complete ⇒ the projector stamps it + certifies; incomplete ⇒ the projector fails it
* closed to PROVISIONAL — capture happens HERE, the fail-closed evaluation stays in a57.14). Pure.
*/
export function envelopeForConfig(config) {
if (!declaresEnvelope(config))
return undefined;
return envelopeOf(canonicalizeConfig(config));
}
// ─────────────────────────────────────────────────────────────────────────────
// The SamplingConfig fill-realization seed — provenance for the sampled fill channel (kestrel-9gu.8.1)
// ─────────────────────────────────────────────────────────────────────────────
/**
* The `runSeed` a Simulate run realizes sampled (seeded-Bernoulli) fills under, or `undefined` when the
* config declares none — in which case **the sampled channel is OFF** (fail-closed, ADR-0016 §5): the run
* grades the deterministic floor + analytic expected totals only, taking no draws. A malformed seed (a
* non-finite or non-integer value) is treated exactly like an absent one — the sampler is never armed off a
* value that would not replay byte-identically, never a silent `0`.
*
* This is the ONE provenance read the Simulate driver (kestrel-9gu.8.3) uses to decide whether to construct
* the engine with a seeded rng. It reads the SamplingConfig axis, NOT the envelope: `fillSeed` folds into the
* full {@link deriveConfigId ConfigId} / {@link import("./run-identity.ts").SimRunId SimRunId} (each seed is a
* distinct RUN) but is a **replicate axis** STRIPPED from the cell config axis ({@link cellConfigId}), so two
* seeds share one {@link import("./run-identity.ts").CellKey CellKey} (replicates in one cell — ADR-0016
* ensembles) while each graded bus — carrying different realized fills — hashes to a distinct SimRunId. Pure.
*/
export function fillSeedOf(config) {
const s = config.fillSeed;
return typeof s === "number" && Number.isInteger(s) ? s : undefined;
}
// ─────────────────────────────────────────────────────────────────────────────
// The sampled-channel qualification claim — the 9gu.8.6 gate input (kestrel-9gu.12)
// ─────────────────────────────────────────────────────────────────────────────
/**
* The owner-recorded {@link SampledQualificationClaim} a config carries (`sampledQualification`), or
* `undefined` when it declares none — in which case **the qualification gate fails closed** and the
* strict-cross floor stays the headline (kestrel-9gu.12; 9gu.8 AC#7). A malformed claim (a missing leg, a
* non-boolean, an empty reference) is treated exactly like an absent one — never coerced onto a passing
* shape (RUNTIME §8, via {@link readSampledQualificationClaim}).
*
* Like `fillSeed`, the claim folds into the {@link deriveConfigId ConfigId} (a qualification edit mints a
* new grid column-run) but not the a57.14 envelope. As of kestrel-9gu.12 **no study has passed** — the
* study is kestrel-9gu.8.6 — so no shipped config declares this; the field exists so the gate seam is
* exercised end-to-end and flipping the headline later is DATA, not code. Pure.
*/
export function sampledQualificationOf(config) {
return readSampledQualificationClaim(config.sampledQualification);
}