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.
271 lines (221 loc) • 18.8 kB
Markdown
# 0016 — Resting-episode identity and survival semantics for the sampled fill channel
Status: Proposed (owner-approval gate for kestrel-9gu.7; implementation is kestrel-9gu.8)
Date: 2026-07-13
Deciders: fill-model doctrine (epic kestrel-9gu)
Supersedes: none — refines the per-episode accrual introduced with `FillAssessment.episodeKey`
(src/fill/model.ts, src/fill/engine.ts).
## Context
The FillModel seam (RUNTIME §6) accrues a resting order's expected fill probability as a
**survival product** `S = Π(1 − pFillᵢ)`, expected fill = `1 − S`. The calibrated ceiling judge
(`MakerFairCalV1`, `episode-sigmoid-u-v1`) is a **per-RESTING-EPISODE Bernoulli**, not a
per-second hazard: the paid calibration measured time-to-fill median = 0.0s, so time-at-rest adds
no accrual and a per-second integration would double-count. The engine already folds `pFill` only
when `episodeKey` changes (`freshEpisode` in `onBook`).
The upstream reference for this design is the prior research engine's fill-replay routine. It keys a
seeded RNG substream by order identity — a hash of `(seed, model.version, ref)` — and realizes a
fill when `draw < p_fill`; its fair-fill evaluator evaluates the hazard at a **fixed** reference
rest window (its measured p75 rest window), explicitly *not* the raw inter-observation gap, because
"the curve is a per-episode (≈one-poll) rate; a bus's book cadence is an artifact of recording." A
**reprice is a cancel + place of a new ticket** (the reprice path cancels the resting child with
reason `reprice`, then resumes and places a new child with a fresh ticket id; a reprice event marks
the old→new transition). The ensemble is `seed + i` per run (the ensemble replay routine).
This ADR pins **what one episode is across an order's lifecycle**, so that 9gu.8 can feed *sampled*
(seeded-Bernoulli) fills causally into Plan management without `pFill` compounding on mere
re-observation of the same market state — the exact bug the per-episode form exists to avoid.
## Decisions
### 1. Episode identity
An **episode** is a maximal span during which one `order.ref` rests continuously at a fixed
`(px, book-side-regime)`. It is identified by the tuple **`(order.ref, episodeIndex)`**, where
`episodeIndex` starts at `0` on placement and increments on each dark↔lit regime flip under that
same ref. Each episode is eligible for **exactly one** Bernoulli draw / one `pFill` fold.
A new episode **begins** (mint; one new draw) at exactly these boundaries:
- **B1 — Placement.** A new `order.ref` rests: `episodeIndex = 0`, `survival = 1`.
- **B2 — Reprice / peg step / escalation rung / agent cancel+replace.** All are modeled as
`cancel` (terminal) + `place` of a **new `order.ref`** — matching the prior fill model's cancel-replace-to-a-new-
ticket. The new ref begins a fresh episode at `episodeIndex 0`, `survival = 1`.
- **B3 — Book side-regime flip under the same ref**: lit→dark or dark→lit (`episodeKey` changes
from the order's last folded key). `episodeIndex++`.
An episode does **not** begin (continue; **no** re-draw, engine folds nothing) on:
- **N1 — Re-observation of the same regime** (same ref, same `episodeKey`). This is the
anti-double-count core: `freshEpisode` is false, `survival` is untouched.
- **N2 — A `fair`/quote refresh** that does not flip the side-regime and does not trigger a reprice.
- **N3 — A Wake / lifecycle checkpoint.** A wake never reassesses fills (fills-before-sweep,
RUNTIME §7: the market acts on a BOOK event, then the plan reacts); it can never mint an episode.
- **N4 — A partial floor fill** of the remaining qty at the same price/regime (see §2).
**Anti-double-count principle.** Episodes are never minted by *re-observing the same market state*.
Bankable inflation is impossible on the re-observation axis (N1) and on the dark axis (dark cells
are extrapolated ⇒ unbankable, §5), so the only draws that can move bankable EV are lit episodes,
and a lit→lit re-look mints nothing.
### 2. Survival semantics across cancel / reprice / escalation
Survival state is **per `(order.ref, episodeIndex)` and never carries across a reprice.** Because a
reprice is `cancel` (terminal — a cancelled order can never later fill, engine §Lifecycle) + new
`ref`, each price level gets its **own** survival product, seeded fresh at `1`. This matches the
reference model: the replaced ticket's RNG substream is keyed by the *new* ref, so each rested price
is an independent chance. Compounding across rungs (`1 − Π(1 − pₖ)` over N rungs) is **honest, not
spurious** — the order genuinely rested at N distinct prices — and is **bounded by the reprice
policy's hysteresis**: a peg re-evaluates on data change but cancel/replaces only on ≥ 1-tick drift
under a global reprice token bucket (RUNTIME §4). Hysteresis is what stops a peg from churning many
episodes, exactly as the same-key rule (N1) stops re-observation from doing so.
Escalation (`esc fair 2m esc ask 4m`) is a **sequence of reprices** = N new refs = N episodes, one
Bernoulli per rung, survival independent per rung. The esc **stage clock is
absolute-from-placement** (RUNTIME §4; the reference model carries the original placement timestamp
across the replace) — but because the draw is **per-episode, not per-second**, the stage clock never
scales the draw; it only schedules *when* the next rung reprices.
A **partial floor fill** reduces the resting qty for the remainder but does **not** mint a new
episode (N4): same ref, same price, same regime ⇒ same episode, no re-draw. The sampled channel is
all-or-nothing at the order's full resting qty in v1 (see §5, deviation D3).
### 3. Draw determinism
The per-order-per-episode RNG substream is pinned to the exact tuple:
```
substream = hash(runSeed, model.version, order.ref, episodeIndex)
draw = substream.next() // consumed once, at episode mint (B1/B2/B3)
sampledFill = draw < pFill_episode // pFill after the §5 guards/floors
```
- `runSeed`, `model.version`, and `order.ref` reproduce the prior fill model's key; **`episodeIndex` is added** so
each dark↔lit episode under one ref gets its own independent, replay-stable draw (deviation D1).
- **One draw per episode**, consumed at mint — so adding a book observation that does *not* mint an
episode (N1/N2) consumes no randomness and perturbs nothing. Same `runSeed` + same Bus + same
armed documents ⇒ **byte-identical** event stream and Blotter (the determinism invariant).
- **Ensemble** run `i` uses `runSeed = baseSeed + i` (identical to the reference model's ensemble
replay routine). The ensemble is judged on the distribution (median + tails), never a single run,
and never inflates
the Session's effective sample size (9gu.8 AC).
- **`fillSeed` is a REPLICATE axis, not a behavioral one — in identity terms: it folds into `ConfigId`
and `SimRunId` (each seed is a distinct RUN) but is STRIPPED from `CellKey`** (`cellConfigId`,
`src/session/config.ts`; the driver stamps the stripped axis as `meta.cell_config_id` only when it
diverges from `config_id`). So a seed ensemble is N replicates in ONE comparison cell — the MC error
the `CellAggregate` reports — never N distinct grid columns (which would split the ensemble and defeat
this §3). A *behavioral* knob (`temperature`/`thinkingLevel`) does split cells (m9i.2 owner tweak 1);
a seed does not (kestrel-9gu.8.1, reconciled here).
### 4. Interaction with the floor — the containment rule
`sampled ⊆ ceiling ⊇ floor`, pinned pointwise per assessment:
- **Floor ⊆ sampled.** A strict-cross floor fill returns `pFill = 1` at `order.px`. Since every
`draw ∈ [0, 1)` satisfies `draw < 1`, a floor fill **always** also fills in the sampled channel,
at the same price. Implementation guarantees this the way the reference model does: **strict-cross
is evaluated first** and short-circuits the hazard (engine `onBook`: a `res.fill` deletes the order before any hazard
accrual), so the sampled channel can never *miss* a fill the floor credited.
- **Sampled ⊆ ceiling.** The analytic ceiling (expected / max-fill) takes every hazard-reachable
order; a draw-gated realization is a subset of it.
- **A sampled fill never contradicts the floor's no-fill — that is the point.** When strict-cross
does not fire, the floor asserts `$0` / no position; a sampled hazard fill is the passive
maker/internalization fill the conservative floor is too strict to credit. It fills **at
`order.px`** (the resting price) — same-or-better than the floor, never worse: a BUY never pays
above its limit, a SELL never sells below its limit (and the SELL limit is already floored at
intrinsic, RUNTIME §4). The floor channel is computed independently and is **unaffected** by any
sampled/episode logic (9gu.7 AC; RUNTIME §6 strict-cross is the deterministic primary benchmark).
### 5. Fail-closed edges
- **Extrapolated support cells** (uncalibrated hazard, dark internalization, fantasy corners): the
draw **is** taken and the sampled branch **may** realize a fill — 9gu.8 needs realized branches
for causal management — **but the resulting gains are UNBANKABLE.** The order latches
`supportExtrapolated` (engine) and the settle telemetry stamps `extrapolated`; a grader refuses to
bank expected-$ from an extrapolated cell (`isCalibratedSupport`, ADR-0014). Draw allowed, gains
not banked — never "no draw."
- **One-sided / dark book** (no half-spread, or no `fair`): a **LEVEL lift** at the per-side
`internalizationFloor` (the lone-bid / PFOF hazard), keyed `episodeKey = "dark"`, support
`extrapolated`. The floor applies to **whichever side is resting** — it is a per-side constant
(calibrated per-side survival hazards from the reference dataset), never zero, never a
`u_star` shift. One Bernoulli per dark episode.
- **Far-OTM guard** (deep_otm wing): the directional cap is applied to `pFill` **before** the draw.
A standalone far-OTM SELL is capped to `SELL_FAR_OTM_CAP` (≈0.01, a positive floor, never a hard
zero) ⇒ it essentially never sampled-fills (the no-lifter fantasy, fail-closed). A far-OTM BUY
gets `BUY_FAR_OTM_CROSSING` (the real crossing-print dump flow). A **covered** wing of a
defined-risk package is exempt (fills as a combo). The guard edits the output `pFill` the sampled
draw tests against — it never edits fitted data.
- **No `fair` / unbuildable execution-fair**: degrade to the floor only (`pFill = 0`); the hazard
draw can never fire (`draw < 0` is never true). Fail-closed.
- **Cancel before any book / out-of-order look**: a cancelled ref is terminal and never assessed; a
book event predating the order's `lastTs` is skipped (causal guard, 9gu.4) — neither mints an
episode nor draws.
## Bus evidence (re-derivability)
So Blotter/Grade re-derive every expected total **without engine-state scraping** (9gu.7 AC; ADR-0011):
- Episode **boundaries are already bus events**: `place` (B1), the `cancel`+`place`/`REPRICE`
pair (B2). A **dark↔lit transition (B3) must be marked** — 9gu.8 emits a per-episode hazard
TELEMETRY line carrying `{order_id, episodeIndex, episodeKey, pFill, support, sampled?}` at each
mint, alongside the existing per-order settle telemetry (`expected_fill_prob`, `support`,
`floor_pnl`, `expected_pnl`). The survival product `Π(1 − pFill_episode)` and the sampled path are
then a pure function of the Bus bytes.
- Unsupported or ambiguous transitions (no-fair, dark, far-OTM fantasy) carry `support =
extrapolated` on the wire, so a re-derivation **refuses the EV claim explicitly** rather than
silently banking it.
## Fixtures required (built under 9gu.8)
Deterministic fixtures: **cancel-before-book** (no draw, no fill), **reprice** (two episodes, two
independent draws, survival not carried), **escalation** (N rungs = N episodes, absolute stage
clock), **dark↔lit flip** (episodeIndex increments; lit bankable, dark not), **partial fill** (no
new episode for the remainder), and **terminal outcomes** (settle floor vs expected vs sampled).
Certification: same `runSeed` + Bus ⇒ identical determinism hash; ensemble `seed+i` reproducible.
## Deviations from the reference model (cited)
- **D1 — Explicit `episodeIndex` in the substream key and one draw per episode.** The reference
model kept one RNG per `ref` and drew **per observation** at a fixed reference rest window, relying
on median-ttf ≈ 0 to make per-observation ≈ per-episode. Kestrel makes the episode explicit
(`episodeKey`) and keys the draw by `(runSeed, model.version, order.ref, episodeIndex)`, folding
once per episode. Stricter and stable under re-observation; ensemble semantics (`seed+i`,
`draw < pFill`) are unchanged.
- **D2 — Dark↔lit flips mint a new episode under the same ref (B3).** The reference model re-assessed
every observation regardless; kestrel folds only on `episodeKey` change. Because dark cells are
extrapolated/unbankable, this cannot inflate bankable EV; if lit-side book flicker ever becomes a
hill-climbing surface, a regime-transition hysteresis (mirroring the reprice token bucket) is the
documented mitigation.
- **D3 — Sampled fills are all-or-nothing per episode at full resting qty.** The reference model's
partial-fill take supports partial remaining-qty hazard fills; kestrel v1 does not model partial
*hazard* fills. A simplification, revisited if partial-fill fidelity is required.
Everything else matches the reference model: reprice = cancel + new ref = new episode; strict-first
containment; per-side dark internalization floor; the directional far-OTM cap; `seed + i` ensembles.
## Addendum — B4: time-based re-mint at a reference cadence (2026-07-19, kestrel-9gu.13)
**Status of this addendum:** proposed amendment to §1 (episode identity) — **flagged for owner
review** (the original owner approved the per-episode form; this reintroduces a *bounded* amount of the
per-observation cadence D1 deliberately dropped, so the owner should confirm the trade).
### The gap
The per-episode form (D1) mints an episode ONLY on a structural boundary — placement (B1), a
reprice/peg/escalation/cancel-replace (B2), or a dark↔lit flip (B3). A **truly static resting order**
— never repriced, no regime flip — therefore takes **exactly one** Bernoulli all session, so its
cumulative fill is pinned at a single-episode `pFill` (a patient at-fair BUY at `p ≈ 0.25`) and can
**never approach the calibrated cumulative ground truth**. True-Sim R1 observed the one-sided symptom:
sells saturate correctly while passive buys fill almost only by aggressive cross. D1's justification —
"median-ttf ≈ 0 makes per-observation ≈ per-episode" — is exactly what a static order violates: it
rests for a long, real span during which passing flow keeps interacting with it, yet the model draws
only once.
### Decision — boundary B4
Add a fourth episode boundary to §1, for **keyed (per-episode) models only**:
- **B4 — Continuous same-regime rest across a reference cadence `DT_REF`.** While an order rests
continuously under one `episodeKey` (no B2, no B3), it mints **one additional episode per completed
`DT_REF` window** of rest, each a fresh `episodeIndex` (its own independent draw) folded into the
survival product. `DT_REF = 5s` — the upstream reference model's per-observation cadence (the
§Context "book cadence" window; the model drew per observation at ≈5s while median-ttf ≈ 0 made each
draw a per-window rate). Constant `EPISODE_REMINT_DT_REF_MS`, `src/fill/engine.ts`.
This restores the reference model's compounding for a static rest **without** reintroducing the D1
double-count that motivated the per-episode form — the needle B4 threads:
**Anti-double-count (the risk the bead flags).** B4 counts **windows of injected tape time**, not
observations: the number of re-mints over a rest span is `floor(restMs / DT_REF)`, and the cadence
anchor advances by whole `DT_REF` windows. So
- **N1 is preserved unchanged** — a re-look *inside* a window mints nothing (`windows === 0`): re-
observing the same market state can still never inflate the survival product.
- **Density-independence** — a dense tape (many BOOK looks/sec) and a sparse tape covering the same
rest span mint the **same** episodes, because the count keys off elapsed tape time, not look count.
"A bus's book cadence is an artifact of recording" (§Context) — B4 honours that; the naive per-
observation re-draw D1 rejected would not.
- **Determinism** — the count is a pure function of injected `ts` (no wall clock, no observation-order
dependence); same bus + same armed documents ⇒ byte-identical stream (each B4 mint emits its per-
episode hazard TELEMETRY line, so `Π(1 − pFill)` and the sampled path still re-derive from the bus,
ADR-0011 / Bus-evidence above).
- **Bankable-EV containment is unchanged (§Decisions).** B4 mints in whatever regime the order rests
in; a *dark* rest re-mints dark episodes, which stay `extrapolated`/unbankable (§5), so the only
draws that move bankable EV remain lit — as before.
**Saturation floor.** On a long-gap catch-up (a single look after a long rest) B4 would fold many
windows at once; once the survival product falls below `EPISODE_SURVIVAL_SATURATION_EPS` (1e-9) — the
order's expected fill is effectively 1 — the catch-up stops folding (and stops emitting per-episode
telemetry). The engine survival stays byte-identical to the bus-re-derived one (both stop at the same
deterministic threshold), and telemetry/work are bounded.
### Scope / compatibility
- **Un-keyed models are untouched.** `StrictCrossV1` and the per-second `MakerFairV1` return no
`episodeKey`; B4 never fires for them (the per-second hazard already integrates `dtSinceLast`). Their
bytes are identical.
- **Keyed models (`MakerFairCalV1`) take B4** whenever a calibration is present. In this OSS repo no
golden/bench runs a keyed model (the `episode-sigmoid` calibration is private and gitignored — the
shipped default degrades to the un-keyed `MakerFairV1`), so **no golden moves**; the behaviour change
is realized in the managed service, where the private calibration is placed.
### Follow-ups for owner review
- `DT_REF` is currently an engine doctrine constant (the reference model's 5s). If a future calibration
measures a different per-episode reference window, promote `DT_REF` into the versioned
`EpisodeSigmoidParams` DATA rather than hard-coding it (mirrors "calibration is versioned input").
- B4 re-mints in the resting regime uniformly; if a hill-climb ever games the lit cadence, the D2
regime-transition hysteresis note (a token bucket on re-mints) is the documented mitigation.