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.
252 lines (229 loc) • 20.3 kB
Markdown
# The Simulator — wake-driven agent-in-loop runs, deterministic at the record layer
**Status:** Accepted (owner, 2026-07-16 — implemented/extracted; kestrel-5zl). Extends ADR-0001 (one language, four statement kinds),
ADR-0006 (grade everything authored; names are data), ADR-0007 (class vs instance), ADR-0008
(streaming renderings are append-only), ADR-0010 (legible text is truth), ADR-0011 (the Blotter
projector is a pure function of the Bus).
> **Amendment marker (2026-07-15, kestrel-2sy).** Decision 3 below describes a
> **single-agent** seam — one `Agent` behind `open/decide/close`, one driver
> `runSimulateSession(bus, agent, config)`. That "one agent per session" model is
> **retired** (silently, by extension, until this marker): **ADR-0032** (two-tier
> Strategist + Watcher, amended to a five-layer cascade) supersedes it with a
> *single driver interleaving two-plus tier adapters at their wake ordinals*
> (see ADR-0032 "Resolved 6"), and **ADR-0035 clause (h)** states outright that
> "the 'one agent per session' rule in today's code (`simulate.ts` single
> `opts.agent`) is already being retired by ADR-0032." Both ADRs say they
> *extend* 0012, not supersede it, so 0012's seam, determinism line, and
> Bus-event invariants all still stand — only the *cardinality* (one agent → a
> tier cascade) changed. The winning turn-wire surface (0029 JSON `requestView`
> vs 0031 `SHOW VIEW`, and envelope vs envelope-free) is still an open owner
> decision tracked on kestrel-2sy.
## Context
Grade, Simulate, and Backtest are three distinct things and CONTEXT.md now says so. **Grade** is
the *result* of a run — realized EV, P&L, fills, counterfactuals, fidelity/certification stamps —
and the surface that requests and scores one; **every run, however produced, carries a Grade.**
**Backtest** is one *way to produce* a run: a fixed authored Plan (or set) replayed autonomously
over recorded tape under a named fill model — deterministic, no agent in the loop, the mode `GRADE
plan X …` executes (kestrel-a57.8). **Simulate** is the other way, and the one this ADR builds: a
*live agent in the loop* manages the book in `sim` mode over the strategy's own horizon in
compressed wall-clock — defining its own Wakes, revising Plans, and acting on live oversight
(positions, P&L, fills, new data) exactly as it would live.
Simulate, not Backtest, is the **primary evaluation harness.** A Backtest grades one frozen
judgment; a Simulate run grades *judgment itself*, and it is parameterized by agent **config**
(model, tokenizer, Rendering/format, temperature, thinking level, prompt-profile) so the *same
recorded tape* run under different configs is directly comparable. That comparison — which model,
at which attention cost, under which prompt, manages this tape best — is the point, and it is
orders of magnitude past a Backtest. An agent-day (the pqv epic) **is** a Simulate run.
The tension the design must hold: the live loop is *deliberately non-deterministic* — judgment is
the variable being measured — yet the platform's non-negotiables demand determinism on the
record/runtime path, a byte-stable round-trip, fail-closed behavior, and bounded risk. Placing
that determinism boundary in exactly the right place is the whole decision.
## Decision
**1. The loop is wake-driven; between wakes standing Plans fire autonomously (fire-then-inform).**
Between wakes the runtime executes armed Plans at machine speed and informs the author in parallel.
At a Wake — on the agent's *own* cadence, floored by the platform staleness backstop and an optional
structural cadence — the agent is invoked with the acting Frame (positions, P&L, fills, new data)
and adjusts: it may supersede/author a document, schedule its next Wake, place or cancel an order,
or stand down. This is the existing stepped-day shape (`src/session/day.ts` `runDaySession`) with
one behavioral change: the wake set stops being a precomputed static list and becomes
**agent-driven** via a `scheduleWake` action, still floored by the staleness backstop and
coalesced/downgraded past the attention budget (the squelch stays visible in the Kernel, never
silently dropped).
**2. Time model — phase 1 is frozen-at-wake; latency is phase 2.** In phase 1, market time
*freezes* while the agent thinks: the runtime drives the Bus strictly *before* the wake vantage
(`ts < wakeTs`, no look-ahead), assembles the Frame, awaits the agent's turn, and **stamps every
resulting action at the wake ts.** Fast and slow models therefore decide on byte-identical Frames —
which isolates judgment from think-latency and is exactly what makes cross-model comparison honest.
Phase 2 (later, its own ADR — the m9i.6 latency-causal rung) folds real model latency into the
time-travel by studying recordings; it changes only *how the graded Bus is produced,* not the
record/projection contract of Decision 4, so grids and leaderboards re-rank identically over
phase-2 buses.
**3. The Agent seam is a typed, Session-stateful `Agent`, and the agent authors Kestrel.** The
runtime invokes the live model through one narrow boundary — `open(briefing) / decide(frame) /
close(final)` — that consumes a **frozen, date-blind ActingFrame** (the 4gl.3 cockpit Kernel
layered onto the delta Frame; identical bytes for every Agent) and returns an `AgentTurn`: a set of
typed `Action`s (`supersede | scheduleWake | placeOrder | cancelOrder | standDown`) plus an optional
pre-hoc `journal`. The agent's act at a Wake *is a Kestrel statement* (ADR-0001) — a `supersede`
carries a View/Wake/Plan document that is decision, record, and executable in one object; `print`
of `parse` of it round-trips. `decide` is `async` because the live loop is deliberately
non-deterministic; recorded and fixed adapters resolve synchronously. **Text Rendering lives inside
the live adapter** (it renders the typed Frame via `src/render` under `config.tokenizer/format`), so
the seam itself stays typed — recorded and fixed adapters carry no dead Rendering, and the
frozen-at-wake identical-Frame comparison holds across tokenizers.
Four adapters satisfy the one interface and share one driver `runSimulateSession(bus, agent,
config)`:
- `liveAgent` — the model; the sole non-deterministic point, sitting *above* the determinism line.
- `recordedAgent` — replays a prior run's captured turns keyed by wake ordinal: the deterministic
replay substrate that makes any live run *re-runnable as a Backtest.*
- `fixedPlanAgent` — arm one document at open, pass forever = a Backtest (a57.8) expressed through
the seam (the degenerate case where discretionary and static coincide).
- `fileHandshakeAgent` — today's `runDaySession` file protocol (EVALUATION Part 1), now *one*
adapter rather than the boundary.
This is an **extraction** from `src/session/day.ts`, not a greenfield: the frozen-at-wake
checkpoint, `projectAuthorFrame` date-blindness, and supersede+arm already exist; `runDaySession`
becomes `fileHandshakeAgent` + `runSimulateSession` over the same `SessionCore`.
**4. Determinism lives at the record/projection layer; the boundary is the parsed document.**
*(This is the section the parent patched in from the converged design — the dead record-determinism
fork's deliverable.)* We resolve the central tension by placing the determinism boundary exactly at
the **returned `AgentTurn` / the parsed document.** Above the boundary — Rendering the Frame, the
model call, sampling — is non-deterministic and sits *off* the runtime path. Below it, everything is
a pure function of Bus bytes (ADR-0011). One value crosses: the `AgentTurn`. `runSimulateSession`
serializes each of its Actions to a Bus event stamped at the **injected wake ts** — no wall clock,
no unseeded RNG on the record path; the host wait in the file adapter stays off the graded clock
exactly as `day.ts` documents:
- `supersede` → a CONTROL supersede plus the authored **Kestrel View/Wake/Plan document** (ADR-0001);
a parse escape on that document → the fail-closed `standDown`.
- `placeOrder` / `cancelOrder` → ORDER events routed through the **same Gate as a fired Plan** (SELL
floored at intrinsic, budget clamp, never naked; price is a Kestrel expression, never a bare mid).
- `scheduleWake` → CONTROL (the agent-driven wake set atop the staleness backstop).
- `standDown` → CONTROL de-arm.
- `journal` → a pre-hoc JOURNAL `author` event (a57.2), provably pre-hoc by seq (written *before*
arming).
The record-determinism *contract* — the three pieces the dead fork left only as sketch, now decided:
- **(a) The Action serialization is NOT Kestrel `print()`.** Only `supersede`'s authored document is
a Kestrel surface statement. `standDown`, `scheduleWake`, `placeOrder` (price-by-expression), and
`cancelOrder` (by-ref) are **not** surface statements, so they cannot be — and must not be —
round-tripped through Kestrel `print`/`parse`. The tempting shortcut "store the captured turn as
canonical `print()` text" is **rejected**: it would silently extend the Kestrel grammar to
non-authored actions and put the Round-trip non-negotiable and the golden fixtures at risk. Two
substrates, cleanly separated: **Kestrel `print()`** for the authored document (Round-trip
non-negotiable; `tests/golden/` is its contract), and the **same sorted-key / drop-undefined
canonicalizer the Blotter and receipts already use** (ADR-0010/0011) for the control/order
envelope of every non-authored action.
- **(b) `recordedAgent` is byte-identity, not re-computation.** Captured turns are keyed by wake
ordinal (`CapturedTurns = ReadonlyMap<wakeOrdinal, AgentTurn>`). `recordedAgent(config, turns)`
replays them with **no model call and no Rendering**, re-emitting identical Bus events at identical
wake ts. Its contract is a byte-identity claim: *re-projecting the recorded run's graded Bus is
byte-identical to the original graded Bus.* This — a deterministic re-run over fixed captured turns
— is precisely what "re-runnable as a Backtest" means; it is **not** the (false) claim that a
discretionary decision compiled to a static Plan document.
- **(c) `SimRunId = sha256(graded Bus)`.** A run's record identity is a pure function of the graded
Bus it produced, so grid storage is an **idempotent upsert** keyed by that hash. Because the live
loop is non-deterministic, two runs of one config cell produce different graded buses under one
`ConfigId` — they are **replicates, not duplicates** (distinct `SimRunId`s), and their mean +
dispersion is the honest read.
**One record, two reads.** From the identical frozen input tape: **replay the captured turns → a
deterministic Backtest** (`recordedAgent`); **re-sample the model on the same frozen Frames → a
fresh, non-deterministic Simulate** (`liveAgent`). The frozen ActingFrame is exactly what makes the
re-sample honest — the model re-decides on byte-identical bytes. So a live run touches the wire
*once*; every re-grade and re-projection is provider-free and byte-identical.
**5. The config/model matrix is the point; it keys on two levels and ranks honestly.** The
comparison axis is the agent **config** — the input half of the a57.14 experimental envelope
(pinned model identity, sampling, Rendering identity, author policy) — swept over model × tokenizer
× Rendering/format × temperature × thinking-level × **prompt-profile.** A **prompt-profile** is
`baseline ⊕ model-variant` resolved to a hashed, versioned artifact inside the author policy; the
active variant-set is the **division selector** that maps onto m9i's two divisions, which **must
never be conflated**: baseline held constant across models = the **CONTROLLED** division (raw model
capability); model-specific variants allowed = the **OPTIMIZED** division (best deployable system
per model). Confidentiality follows the same public/private split as the provenance scrub — a generic
baseline may be public/OSS; strategy-bearing variants are private. `ConfigId = sha256(canonical
config)` via the same sorted-key / drop-undefined canonicalizer above, so any edit mints a new
column — a config change is never a silent contamination of an old one — and the frozen config is
stamped on every Blotter's `META` (a57.14) and into the run's **System Profile** (m9i.2/.8). The
grid **cell** is `(tape, fill_model, config)` — literally Grade's `BY`-config `BY`-tape
stratification (ADR-0006 "cells, not pools") made first-class; the fill model is a column-*fixed*
judge, never a compared axis (EVs across fill models refuse naive comparison). `buildGrid` and
`leaderboard` are **pure, regenerable projections** over Blotters + envelopes (ADR-0010/0011 — never
a second truth; `src/ledger` is only the queryable index). The leaderboard ranks configs on honest
axes only — `bankable_ev` (only the calibrated slice of the fill claim, so a config cannot top the
board by hill-climbing into an uncalibrated corner), `floor_pnl`, `fidelity_adjusted_ev`, and
`ev_per_ktoken` (attention efficiency) — over certified, post-cutoff runs only, with
provisional/practice runs counted-and-excluded with reasons.
**6. The live adapter is where the harness, the credentials, and the reasoning-as-evidence live —
all above the determinism line.** The `liveAgent` seam is the *only* place a provider SDK enters;
the deterministic core imports none.
- **Dual harness.** The default flexible driver is the **Vercel AI SDK** (TypeScript, fits Bun) with
**bring-your-own-key**, for uniform multi-provider model-swap; a thin **direct-provider path**
supplies the wire-evidence, prompt-cache (m9i.5), and latency (m9i.6) *fidelity* rungs. m9i.7
tournaments HTTP/SDK/CLI/MCP × model × harness — the AI-SDK path is one face, not the boundary.
- **BYOK is off the record.** Keys arrive via env/keyring config only, **never** on the Bus, the
Blotter, or committed fixtures; the config hash carries *model identity,* not credentials.
Wire-evidence capture **redacts auth headers and keys at write time,** and committed artifacts are
provably key-free (gated by `scripts/check-public-provenance.ts`).
- **Reasoning is captured as free evidence, never re-summarized.** Reasoning models emit a raw trace;
capturing it costs no extra generation, so we keep it as evidence rather than asking the agent to
restate its thinking. The human-readable "why" travels *with* the action as inline **COMMENTS** (a
byte-stable, separate grammar workstream — its own ADR forthcoming). A JOURNAL note stays
**optional and minimal:** a falsifiable pre-hoc commitment (thesis + invalidation) graded by a57.2
(claim vs realized outcome) — and *whether it is required is itself a swept benchmark variable,*
not a mandate.
- **Dogfood djm.** The built-in agent is the **first client of the public SDK** (djm.5), not a
privileged internal path — building it validates the external surface; the CLI (djm.6) and MCP
(djm.7) faces are SDK projections of the same seam. Phasing: an eval-internal driver now
(Simulator/benchmark), graduating to a shipped reference agent later.
## Consequences
- **The seam absorbs four modes into one driver.** Live, recorded, fixed-plan, and file-handshake
all satisfy one `Agent` interface over one `runSimulateSession`. **Backtest stops being a separate
executor conceptually — it is `fixedPlanAgent`** (a57.8's GRADE-plan surface syntax stays a57.8's;
Backtest-through-the-seam is 5zl's); `fileHandshakeAgent` preserves the existing agent-day
protocol; `recordedAgent` gives every live run a deterministic re-run.
- **Fail-closed is structural and proportionate.** A parse escape on an authored `supersede`
document, or a malformed whole turn, → `STAND_DOWN` (de-arm clean, inventory rides its TP/EXIT,
logged reason) and the run continues to settle; the agent can never crash the Session. But a
single unresolvable/past `scheduleWake` is **refuse-that-action-and-log-and-continue** — the book
stays armed on the staleness backstop — *not* a book-wide STAND_DOWN; STAND_DOWN is reserved for an
unparseable *whole turn* or an authored-document parse escape. An empty `actions[]` is a legitimate
pass (standing Plans keep managing). A missing `AgentConfig` or fill model refuses to grade — never
a silent default.
- **Bounded risk / never naked holds at both the action layer and the ranking layer.** `placeOrder`
routes through the *same* Gate as a fired Plan (SELL floored at intrinsic, budget clamp, never
naked); price is a Kestrel expression the engine resolves, never a bare mid; `standDown` never
liquidates. And the leaderboard banks only the calibrated fill-claim slice, so the never-naked
invariant survives all the way up into the comparison grid.
- **Round-trip is preserved by keeping two substrates apart.** The authored `supersede` document
round-trips through Kestrel `parse`/`print` (day.ts already does `print(parse(text))`) and the
golden fixtures are its contract; the non-surface actions (`standDown`/`scheduleWake`/`placeOrder`/
`cancelOrder`) serialize through the Blotter/receipt canonicalizer, **not** Kestrel `print()`. Both
replay byte-stably; neither leaks into the other's grammar.
- **Replicates are a feature, not a defect.** One cell holds N replicate runs (distinct `SimRunId`s
under one `ConfigId`); N=1 is the degenerate case. A high-EV / high-variance config is legibly
different from a steady one, and that dispersion is itself a comparison signal.
- **One residual product call, not an architectural fork:** the board-eligibility gate for ranking a
non-deterministic metric — minimum replicates per cell before a config earns a position, whether an
under-gate cell is flagged or refused, and whether the primary rank is mean-only or
dispersion-penalized (`mean − k·stdev`). The harness is buildable now with `min_replicates = 1`;
the owner sets the production default. Everything else in the matrix is determined.
- **Phase-1 scope is one Session, stated plainly.** The extraction target (`day.ts`) is strictly
single-Session — one `session_date`, one `settle()`, one `buildReport` ⇒ one Blotter — so phase 1
delivers the single-Session agent-day. The epic headline's "minutes to weeks" Instance horizon
(overnight carry, cross-Session standing-Plan re-arm, Blotter-sequence → Instance history) is a
named later child, not a consequence of this loop.
- **Two new modules; the ledger gains an index, not a truth.** `src/session/agent.ts` (seam +
adapters) and `src/session/simulate.ts` (driver); `src/simulate/{config,run,grid}.ts` (the matrix);
`src/ledger` gains regenerable `sim_runs` + `agent_configs` projections purely to query the grid.
The existing `ledger.leaderboard()` ranks *lineages* (plan names); this adds an orthogonal *config*
leaderboard — different key, same names-are-data discipline (ADR-0006).
## Note: fold the upstream cockpit-Kernel ADRs at the 4gl.3 port
Simulate does not define its own oversight surface — it *consumes* the 4gl.3 acting/cockpit Kernel
verbatim (a non-configurable lead block: wake reason/severity/deadline, data health + unavailable
capabilities, positions/resting/budget, attention, and the fired/filled/cancelled/rejected/clamped
counts since the last Wake, each a watermarked, attributed Field per 4gl.1). Simulate adds only two
things to that surface: the frozen-at-wake vantage that bounds "new data since last Wake" as a
half-open Bus interval `(priorVantageSeq, thisVantageSeq]`, and the requirement that the whole
Kernel be a *pure projection of the graded-Bus prefix* (no engine-state scrape), so a live-recorded
run re-projects byte-identically. When 4gl.3 lands (porting the LANDED cockpit Kernel, not the
in-flight one), **fold the upstream cockpit-Kernel decisions into this ADR's review** per
the 4gl.3 port note, and extend ADR-0011's `META` judge-self-description to pin the agent config
alongside `fill_model`/`instance`/`fidelity` — that is what makes the tokenizer-dependent
`attention.remainingTok` a deterministic projection over `(busPrefix, vantage, config)` and keeps
the two-bus rule intact (the input tape stays config-agnostic; the graded Bus self-describes its
config).