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.
885 lines (843 loc) • 99.6 kB
text/typescript
/**
* # session/day — the STEPPED session (the agent-day of EVALUATION.md Part 1)
*
* The supervised, wake-by-wake run of a single session in ONE process (RUNTIME §7 + EVALUATION
* Part 1). Where {@link runSimSession} drives a bus straight through with a fixed document set,
* {@link runDaySession} drives the SAME {@link SessionCore} but pauses at wake checkpoints and
* hands control to an **external author** (an agent in another process) through a **file
* handshake**: it writes a Frame + a machine summary to the run directory, then BLOCKS for the
* author's reply (a revised document, or an explicit pass) before continuing. The revised document
* **supersedes** the standing one (RUNTIME §5, "document supersession").
*
* ## The handshake protocol (files under `<dir>`)
* 1. `briefing.txt` — the OPEN keyframe, written before the first event. The runner then BLOCKS
* for `plans-0.kestrel` (the day's authored plan document), parses + arms it.
* 2. At each wake `n` (authored triggers + the structural cadence into the close), the runner
* drives events up to the wake's sim-time, then writes `frame-<n>-<HHMM>.txt` (a delta Frame:
* tape since the last vantage, positions, resting, fills, budget, plan states) and
* `state-<n>.json` (the machine summary), and BLOCKS for either `revision-<n>.kestrel`
* (supersede) OR `pass-<n>` (an empty file = no change).
* 3. After the last wake it drives to settle, writes `report.json`, and the caller records the run
* with the FULL concatenated authored text as `plansText`.
*
* ## The injected-time boundary (documented, deliberate) — a LATENCY-BLIND clause
* The **sim clock is entirely injected** from the bus (`now` = the current event's `ts`, RUNTIME
* §0) — the graded computation reads no wall clock. The **host wait loop** (polling the filesystem
* for the author's reply, and the `--max-wait` safety timeout) DOES read the host wall clock: it is
* an out-of-band author interaction, not part of the graded computation. Determinism is unaffected
* because the graded output is a pure function of `(bus, wake set, handshake file contents)` — how
* long the host waited never enters it. With identical revision files pre-staged, a re-run replays
* byte-identically (the certification test asserts this).
*
* **This "host wall-clock wait is off the graded clock" clause is scoped to LATENCY-BLIND sessions**
* (ADR-0040 / clock-honest-wakes §3, RUNTIME §7 edit). `runDaySession` reaches the driver
* ({@link runSimulateSession}) with `BACKTEST_CONFIG` — a NO-LLM config that declares no `clockHonest`
* — so a stepped DAY is always latency-blind today: no deliberation record, the `--max-wait` poll never
* touches the graded tape, and the frozen `44cf2cbf` reference is preserved byte-for-byte. **There is no
* transport special-case.** Were an operator to hand the driver a `clockHonest: true` config over an
* eligible tape (§4 data floor), the SAME uniform driver seam that clocks an in-process `decide`
* (`clockedTurn` in {@link runSimulateSession}) would clock the file-handshake `await` — the author's
* wall time (the block-with-retries) becomes `measured_ms`, priced into the tape like any deliberation.
* A handshake driven by a *human* would then grade terribly clock-honest; operators run those
* latency-blind. The day driver does not special-case the file transport — the clock-honest eligibility
* gate (not the handshake) decides admissibility, and this file threads no `now`/`clockHonest` of its
* own (it inherits the driver's seam unchanged). See docs/design/clock-honest-wakes.md §3.
*
* ## Fail-closed (RUNTIME §8)
* Missing META / no instruments / unknown fill model → refuse (via {@link SessionCore}). An
* abandoned handshake (no reply within `--max-wait`) exits LOUDLY rather than hanging forever. A
* malformed `plans-0` / `revision-n` document is a loud parse error (never a silent skip).
*
* ## No handshake wait is ever silent (kestrel-4fc9)
* A blocked day must not look like a hung one. Before EVERY handshake block — the OPEN wait for
* `plans-0` and each wake's wait for a `revision-<n>`/`pass-<n>` — the runner ANNOUNCES the contract on
* the host status channel ({@link DayHooks.notify} → stderr): which artifact it just wrote, which file(s)
* it now wants, and the budget it will actually enforce. While blocked (only ever under `--await-author`)
* it emits a HEARTBEAT every {@link HEARTBEAT_MS}, so a day waiting on a slow author is observably
* distinct from a hung process without truncating the real workflow.
*
* ## The default TERMINATES; the out-of-band author wait is OPT-IN (kestrel-4fc9, supersedes the 0.4.x default)
* `kestrel day --dir <empty> </dev/null` (the kestrel-4fc9 repro, and the site's published copy-button) has
* no author and never will. The runner CANNOT infer author presence/absence from the host — a non-interactive
* stdin is also exactly what the primary agentic path looks like (an LLM authoring out-of-band from a
* piped/CI harness), and sniffing `isTTY` would both mis-serve a real caller and put a host property on the
* control-flow path. So BOTH presence and absence are DECLARED, never detected — symmetric flags:
*
* - **default** (neither flag): the day runs from whatever is STAGED in `--dir` and, on a missing document,
* refuses PROMPTLY after {@link NO_AUTHOR_GRACE_MS} with a typed {@link DayHandshakeError} (code `NO_AUTHOR`,
* exit 6) that names the missing file and both ways forward. A published command therefore always
* TERMINATES under plain node — it never rides a 900s poll for a reply the caller never said was coming.
* - **`--await-author`** ({@link RunDayOptions.awaitAuthor}): the caller DECLARES an out-of-band author will
* reply, so each handshake blocks up to `--max-wait` (default 900s), announcing + heartbeating, and
* abandons LOUDLY (`HANDSHAKE_ABANDONED`, exit 6) only past that budget. This is the pre-0.4.x default
* behaviour, now explicit. Supplying `--max-wait` implies it.
* - **`--no-author`** ({@link RunDayOptions.noAuthor}): the explicit form of the default — documents are
* pre-staged; a miss is the same prompt `NO_AUTHOR` refusal. Kept for self-documenting callers.
*
* The 0.4.x contract (a bare `day` rides `--max-wait` 900s for the author) is SUPERSEDED: a shipped,
* published verb that polls 900s under plain node never terminated inside a caller's budget (the web repo's
* offline command guard, 120s) — a fail-closed violation on a live surface (RUNTIME §7/§8, this bead's P0
* re-groom). The real author path is unchanged in substance — it simply says `--await-author` now.
*/
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import type { BusEvent, MetaEvent, OptionQuote } from "../bus/index.ts";
import { foldBook, readBus, type BookState } from "../bus/index.ts";
import { renderBriefing, renderWakeDelta } from "../frame/render.ts";
import type { BriefingInput } from "../frame/types.ts";
import { parse, print, type KestrelNode, type Module } from "../lang/index.ts";
import { UNKNOWN } from "../series/index.ts";
import {
BACKTEST_CONFIG,
OPEN_WAKE_KEY,
type ActingFrame,
type Agent,
type AgentTurn,
type CapturedTurns,
type WakeAt,
type WakeKey,
type WakeSource,
} from "./agent.ts";
import type { PendingWake } from "./carry.ts";
import type { Blotter } from "../blotter/index.ts";
// The ONE shared driver (kestrel-5zl.3). Circular with `./simulate.ts` (which imports this module's pure
// projections — `computeWakes`/`projectAuthorFrame`/`snapshotOf`/…); both sides use the other only INSIDE
// functions (never at module-eval time), so the ESM cycle resolves by call time.
import { runSimulateSession, type DayCompatBridge } from "./simulate.ts";
import { etMinuteOfDay, etWallClockMs } from "./clock.ts";
import type { EpisodeSigmoidParams } from "../fill/index.ts";
// `SessionCore` is a type-only use here (the exported `snapshotOf` signature); the graded event loop it
// drives now lives ENTIRELY behind `runSimulateSession` (kestrel-5zl.3), never re-instantiated in this file.
import { SessionCore, requireMeta, writeReport, type FillModelName, type EpisodeReport } from "./sim.ts";
import { ARM_BOUND_NOTHING_MARKER, type InstrumentSpec } from "../engine/index.ts";
// ─────────────────────────────────────────────────────────────────────────────
// Public surface
// ─────────────────────────────────────────────────────────────────────────────
/** An ET wall-clock time-of-day (a wake vantage). */
export interface TimeOfDay {
readonly hour: number;
readonly minute: number;
}
/** The host-interaction seam — the ONLY wall-clock/filesystem/stderr touch points, all off the graded
* computation (see the file header's injected-time boundary). Defaults use `node:fs` + a sleep +
* `Date.now` + `process.stderr`; a test injects deterministic hooks so no real wait ever happens.
*
* NOTE ON OMISSION: {@link RunDayOptions.hooks} is spread OVER {@link DEFAULT_HOOKS}, so an omitted
* field inherits the REAL host — an omitted `notify` writes to the process's actual stderr, it is not
* silenced. A test that must not leak status lines pins `notify` explicitly. */
export interface DayHooks {
exists(path: string): boolean;
read(path: string): string;
write(path: string, content: string): void;
/** Block the HOST briefly between polls (NOT sim time). */
sleep(ms: number): void;
/** HOST wall clock (ms) for the `--max-wait` timeout (NOT sim time). */
clock(): number;
/** Announce host status lines — the handshake contract the runner is about to block on, and the
* heartbeat while it is blocked (kestrel-4fc9). NOT an author artifact: never date-fenced, never
* graded, never on stdout (the report owns that). Omitted ⇒ the real `process.stderr` (see the
* interface note); pin it to `() => {}` in a test that must stay quiet. */
notify(line: string): void;
}
const DEFAULT_HOOKS: DayHooks = {
exists: (p) => existsSync(p),
read: (p) => readFileSync(p, "utf8"),
write: (p, c) => writeFileSync(p, c),
// `Bun.sleepSync` only under Bun — the published CLI bundle runs the heavy verbs under PLAIN NODE
// (kestrel-4fc9), where `Bun` is undefined. Fall back to a synchronous host park (Atomics.wait on a
// throwaway buffer) so the poll loop never busy-spins and never throws on the reference runtime.
sleep: (ms) => {
if (typeof Bun !== "undefined") Bun.sleepSync(ms);
else Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
},
clock: () => Date.now(),
notify: (line) => void process.stderr.write(line + "\n"),
};
/** Options for {@link runDaySession}. Exactly one of `busPath`/`events` supplies the bus. */
export interface RunDayOptions {
readonly busPath?: string;
readonly events?: readonly BusEvent[];
/** The run directory the handshake files live in. */
readonly dir: string;
/** Authored wake vantages (ET times-of-day). */
readonly wakes?: readonly TimeOfDay[];
/** The author acting cutoff (an ET wall-clock time-of-day). The OPEN keyframe is built ONLY from
* observations watermarked at or before this instant — never a forward peek. Defaults to the
* canonical benchmark's T-5 (the 09:30 regular open minus 5 min = 09:25 ET). Injected + explicit so
* no post-cutoff (e.g. 09:30-or-later) print can leak to the author (kestrel-m9i.11, fail-closed). */
readonly authorCutoff?: TimeOfDay;
/** Add the structural cadence into the close: T-2h, T-1h, T-30m, T-15m, T-10m, T-5m (RUNTIME
* §5 / EVALUATION Part 1). */
readonly structural?: boolean;
readonly fillModel: FillModelName;
readonly rUsd: number;
readonly instruments?: readonly InstrumentSpec[];
readonly fairTauYears?: (now: number) => number | null;
readonly makerFairParams?: EpisodeSigmoidParams;
/** The safety timeout (seconds) an abandoned handshake exits loudly after — the budget the
* out-of-band author wait rides. Only consulted when the caller has DECLARED it will wait
* ({@link awaitAuthor}, or supplying this value at all). Default 900. */
readonly maxWaitSec?: number;
/** DECLARE that an external author WILL reply out-of-band (`--await-author`, kestrel-4fc9): block at
* each handshake for the author's document, riding {@link maxWaitSec} (default 900s), announcing +
* heartbeating while blocked, and abandoning LOUDLY (`HANDSHAKE_ABANDONED`) only past that budget. This
* is the primary agentic path (an LLM authoring from a piped/CI harness). Supplying {@link maxWaitSec}
* IMPLIES it — bounding a wait is itself a declaration that you intend to wait. Default false. */
readonly awaitAuthor?: boolean;
/** DECLARE that no external author will reply (`--no-author`, kestrel-4fc9): the handshake documents
* must already be staged in {@link dir}, and any miss is a prompt typed refusal after
* {@link NO_AUTHOR_GRACE_MS}. This is a caller ASSERTION, never an inference — the runner cannot tell a
* `</dev/null` repro from a real out-of-band agent harness. Redundant with the default now (which also
* refuses promptly) but kept as an explicit, self-documenting assertion; it wins over {@link awaitAuthor}
* if both are somehow set. */
readonly noAuthor?: boolean;
/** Host poll interval (ms) between handshake-file checks. Default 50. */
readonly pollMs?: number;
/** Write the report to this path too (pretty JSON). */
readonly out?: string;
/** Host-interaction hooks (test seam). */
readonly hooks?: Partial<DayHooks>;
}
/** One wake vantage as it was actually delivered + answered. */
export interface WakeRecord {
readonly n: number;
readonly ts: number;
readonly label: string;
readonly structural: boolean;
/** Did the author supersede here (a `revision-<n>` reply), or pass? */
readonly revised: boolean;
}
/** The result of a stepped day: the graded report, the concatenated authored text (the ledger
* `plansText`), and the delivered wake sequence (the decision trace scaffold). */
export interface DaySessionResult {
readonly report: EpisodeReport;
readonly plansText: string;
readonly wakes: readonly WakeRecord[];
/** The turns the run consumed, keyed by wake ordinal (kestrel-5zl.3 re-run — the fileHandshakeAgent
* refactor over the ONE shared {@link import("./simulate.ts").runSimulateSession} driver). Populated ONLY
* once `runDaySession` delegates to the driver in DAY-COMPAT MODE: it is the SINGLE driver run's
* {@link CapturedTurns}, so the day is re-runnable as a Backtest — {@link import("./agent.ts").recordedAgent}
* replays it through `runSimulateSession` byte-identically, proving there is no forked second progression
* path. Absent on the pre-refactor stepped path (this field is what makes the TDD-red contract observable). */
readonly captured?: CapturedTurns;
/** The graded {@link Blotter} from that SAME single driver run (a pure projection of the graded Bus,
* ADR-0011) — never a second run stapled on for the Blotter. Present under the driver delegation; absent
* on the pre-refactor stepped path. */
readonly blotter?: Blotter;
}
// ─────────────────────────────────────────────────────────────────────────────
// Wake sequence
// ─────────────────────────────────────────────────────────────────────────────
/** The structural cadence offsets (minutes before the 16:00 ET close), ordered. */
const STRUCTURAL_OFFSETS_MIN = [120, 60, 30, 15, 10, 5] as const;
// ─────────────────────────────────────────────────────────────────────────────
// The author acting cutoff (kestrel-m9i.11 — the T-5 information cutoff)
//
// The OPEN keyframe is built ONLY from observations watermarked AT OR BEFORE this instant. It is
// EXPLICIT + INJECTED (an option, defaulted) so no forward scan can leak post-cutoff — e.g.
// 09:30-or-later regular-session — data to the author before plans-0 is authored (a look-ahead that
// contaminates the benchmark). This is a CAUSAL cutoff, orthogonal to date-blinding (which strips
// calendar identity): both hold independently.
// ─────────────────────────────────────────────────────────────────────────────
/** The US regular-session open (ET). The canonical benchmark's acting cutoff is measured back from here. */
export const REGULAR_OPEN: TimeOfDay = { hour: 9, minute: 30 };
/** The canonical benchmark's acting-cutoff LEAD in minutes before the open: T-5. The default author
* cutoff is `REGULAR_OPEN − AUTHOR_CUTOFF_LEAD_MIN` = 09:25 ET. */
export const AUTHOR_CUTOFF_LEAD_MIN = 5;
/**
* The acting-cutoff epoch-ms for a session: an explicit `authorCutoff` (an ET wall-clock time-of-day)
* mapped onto `sessionDate`, or — when unset — the canonical-benchmark default T-5 (the 09:30 regular
* open minus 5 min = 09:25 ET). Pure ET calendar arithmetic (no wall clock, RUNTIME §0), so replay is
* byte-stable.
*/
export function authorCutoffTs(sessionDate: string, authorCutoff?: TimeOfDay): number {
if (authorCutoff !== undefined) return etWallClockMs(sessionDate, authorCutoff.hour, authorCutoff.minute);
const openMs = etWallClockMs(sessionDate, REGULAR_OPEN.hour, REGULAR_OPEN.minute);
return openMs - AUTHOR_CUTOFF_LEAD_MIN * 60_000;
}
/** Parse a `--wakes HH:MM,HH:MM,…` string into times-of-day (loud on a malformed token). */
export function parseWakeTimes(spec: string): TimeOfDay[] {
const out: TimeOfDay[] = [];
for (const raw of spec.split(",")) {
const tok = raw.trim();
if (tok.length === 0) continue;
const m = /^(\d{1,2}):(\d{2})$/.exec(tok);
if (m === null) throw new Error(`day: bad --wakes token ${JSON.stringify(tok)} — expected HH:MM`);
const hour = Number(m[1]);
const minute = Number(m[2]);
if (hour > 23 || minute > 59) throw new Error(`day: --wakes time out of range: ${JSON.stringify(tok)}`);
out.push({ hour, minute });
}
return out;
}
/** Parse an `HH:MM` ET clock (an `atClockET` wake field) into a {@link TimeOfDay}, or `null` when it is
* structurally unresolvable (a malformed token, or hour/minute out of range). The FAIL-CLOSED sibling of
* {@link parseWakeTimes} (which THROWS on a bad CLI token) — a `null` is dropped-and-logged by the caller,
* never a silent-default vantage. */
export function parseClockET(clockET: string): TimeOfDay | null {
const m = /^(\d{1,2}):(\d{2})$/.exec(clockET.trim());
if (m === null) return null;
const hour = Number(m[1]);
const minute = Number(m[2]);
if (hour > 23 || minute > 59) return null;
return { hour, minute };
}
/** Re-anchor a carried wake {@link PendingWake} frontier onto the NEXT session (kestrel-5zl.16.9, ADR-0015
* D3/D1). An `atClockET` pending wake becomes an authored {@link TimeOfDay} vantage: {@link computeWakes}
* then maps it onto the TARGET session's `session_date`, so "16:00" fires at 16:00 on the NEXT session's
* date — never the origin's. A structurally-unresolvable clock is DROPPED into `dropped` with its `reason`
* (fail-closed — never injected as a silent-default vantage).
*
* **The fate of an `inMinutes` offset at THIS seam (kestrel-5zl.16.9 mustFix D — a DELIBERATE decision).**
* This consumer seam re-expresses a pending wake as a dateless {@link TimeOfDay} clock that
* {@link computeWakes} anchors to the TARGET `session_date`. An `inMinutes` offset is NOT a clock and has
* NO vantage in the target session to measure from (its origin vantage died with the prior session), so it
* cannot be honestly re-injected here. Under fail-closed there is no third option: it is DROPPED into
* `dropped` WITH a reason — SURFACED, never silently swallowed (the pre-fix `continue` vanished it, the
* review's finding 4). Native `inMinutes` carry (riding an offset across the boundary off a real target
* vantage) is the b3l Wake-router producer's concern (kestrel-b3l.1), a different mechanism than this
* `atClockET` re-anchoring; until it lands, an `inMinutes` on the carry frontier fails closed here.
*
* Pure ET calendar arithmetic on the caller's side (this returns dateless vantages), so replay is
* deterministic. */
export function reanchorFrontier(frontier: readonly PendingWake[]): {
readonly wakes: readonly TimeOfDay[];
readonly dropped: readonly { readonly reason: string; readonly why: string }[];
} {
const wakes: TimeOfDay[] = [];
const dropped: { reason: string; why: string }[] = [];
for (const w of frontier) {
if (w.at.kind === "inMinutes") {
// FAIL-CLOSED, LOUD (mustFix D): an `inMinutes` offset is not re-anchorable at this atClockET seam —
// drop it WITH a reason (the b3l producer carries it natively when it lands), never a silent skip.
dropped.push({
reason: w.reason,
why: `inMinutes offset +${w.at.minutes}m is not re-anchorable at the carry seam (no target vantage) — deferred to the b3l producer (kestrel-b3l.1)`,
});
continue;
}
const tod = parseClockET(w.at.clockET);
if (tod === null) {
dropped.push({ reason: w.reason, why: `unresolvable atClockET ${JSON.stringify(w.at.clockET)}` });
continue;
}
wakes.push(tod);
}
return { wakes, dropped };
}
/** Zero-padded `HHMM` label for a wake ts (ET minute-of-day). */
function labelOf(ts: number): string {
const mod = etMinuteOfDay(ts);
const hh = Math.floor(mod / 60);
const mm = mod % 60;
return `${String(hh).padStart(2, "0")}${String(mm).padStart(2, "0")}`;
}
/** Compute the ordered, de-duplicated wake sequence for a session, in epoch-ms sim time. Authored
* wakes + (optionally) the structural cadence, mapped onto `session_date` via the ET calendar,
* filtered to strictly inside `(firstTs, lastTs)` — a wake before the first event or at/after the
* settle delivers nothing actionable. Structural provenance is retained (for the decision trace). */
export function computeWakes(
sessionDate: string,
authored: readonly TimeOfDay[],
structural: boolean,
firstTs: number,
lastTs: number,
): { ts: number; label: string; structural: boolean }[] {
const settleMs = etWallClockMs(sessionDate, 16, 0);
const byTs = new Map<number, { ts: number; label: string; structural: boolean }>();
const add = (ts: number, isStructural: boolean): void => {
if (ts <= firstTs || ts >= lastTs) return; // strictly inside the driven window
const prior = byTs.get(ts);
// An authored + structural collision keeps authored provenance (structural is the fallback).
byTs.set(ts, { ts, label: labelOf(ts), structural: prior ? prior.structural && isStructural : isStructural });
};
for (const w of authored) add(etWallClockMs(sessionDate, w.hour, w.minute), false);
if (structural) for (const off of STRUCTURAL_OFFSETS_MIN) add(settleMs - off * 60_000, true);
return [...byTs.values()].sort((a, b) => a.ts - b.ts);
}
// ─────────────────────────────────────────────────────────────────────────────
// Frame projection + machine-state channel (local)
//
// The author-facing TEXT frames are rendered by THE canonical renderer in `src/frame`
// (`renderBriefing`/`renderWakeDelta` — the swap the pre-landing local renderer promised,
// kestrel-wa0j.2): one renderer, so the day artifacts an operator reads are the SAME screen an
// in-process agent sees. What stays local is the typed {@link DaySnapshot}, its date-blind
// {@link AuthorFrame} projection (the one seam every author channel derives from), and the
// machine-summary JSON channel — projections, not a parallel Rendering.
// ─────────────────────────────────────────────────────────────────────────────
/** A resting/filled child order, projected from the engine's deterministic dump. */
interface OrderLine {
/** The authoritative Gate ref the fill engine rests this order under (the engine child's `ref`, e.g.
* `o1`). Threaded so an acting agent can `cancelOrder` a resting order by its REAL ref (kestrel-5zl.5).
* The canonical kernel PRINTS resting refs (`ref=o1` — deliberate: the ref is what a cancel names), so
* since the renderer swap (kestrel-wa0j.2) it is visible in the day artifacts too. */
readonly ref: string;
readonly plan: string;
readonly role: string;
readonly side: string;
readonly qty: number;
readonly strike: number;
readonly right: string;
readonly px: number;
readonly filled: boolean;
}
/** A plan's state at a vantage. */
interface PlanLine {
readonly name: string;
readonly state: string;
readonly heldQty: number;
/** kestrel-50w: WHY an `authored` plan is stuck (an unsatisfiable/UNKNOWN regime gate). Present ⇒ the
* frame renders `authored (blocked: <reason>)`; absent ⇒ a bare `authored` (a live plan awaiting WHEN). */
readonly blockedReason?: string;
}
/** The typed bundle a Frame renders — the narrow seam between the day runner and `renderFrame`. */
export interface DaySnapshot {
readonly kind: "keyframe" | "delta";
readonly n: number;
readonly label: string;
readonly nowTs: number;
readonly instruments: readonly string[];
readonly phase: string;
readonly spot: number | null;
/** The epoch-ms the SPOT tick behind `spot` printed at (kestrel-rs4) — a REPLAY-side wall epoch,
* blinded to the relative {@link AuthorFrame.spotAgeMs} at the projection seam. `null`/absent ⇒
* never observed (`spot` is `null` too, and reads UNKNOWN). */
readonly spotTs?: number | null;
readonly priorClose: number | null;
readonly hod: number | null;
readonly lod: number | null;
readonly vwap: number | null;
/** The frozen opening-range high/low (`CanonicalState.orHigh`/`orLow`) — `null` until the first spot
* anchors the range. Carried so the `failed-breaks` pane's ORB fake-out level reaches the author on the
* driver path (kestrel-wa0j.58): the state computed it, but the projection used to drop it, so the pane
* rendered all-dashes on every corpus frame. */
readonly orHigh: number | null;
readonly orLow: number | null;
readonly chain: { readonly underlierPx: number; readonly expiry?: string; readonly legs: readonly OptionQuote[] } | null;
/** Delta-only: the underlier tape since the last vantage (`[ts, px]`). */
readonly tape: readonly { readonly ts: number; readonly px: number }[];
readonly plans: readonly PlanLine[];
readonly resting: readonly OrderLine[];
readonly fills: readonly OrderLine[];
readonly committedUsd: number;
readonly rUsd: number;
}
/**
* The **date-blind AUTHOR projection** of a {@link DaySnapshot} — the ONE shared seam every
* delivered author artifact (briefing, delta frames, machine JSON, and therefore their filenames)
* derives from. Calendar identity is stripped HERE, once, so no downstream channel can re-leak it:
*
* - every wall-clock epoch (`nowTs`, each `tape[].ts`) becomes **relative ms since session open**
* (`sinceOpenMs`) — a small dateless integer — plus an `HHMM` ET time-of-day `label` (a clock,
* which pins no calendar date);
* - an **absolute chain expiry** (`2026-07-17`) becomes a **relative days-to-expiry** offset
* (`dte`) measured from the session date (0 for a 0dte) — a relative label pins no date.
*
* Replay-only calendar identity (the raw session date, wall epochs) stays OUTSIDE this projection,
* on the host/replay side. A leak that still reaches the boundary is caught by {@link assertDateBlind}
* (fail-closed), never silently emitted.
*/
export interface AuthorFrame {
readonly kind: "keyframe" | "delta";
readonly n: number;
/** `HHMM` ET time-of-day of this vantage (a clock — dateless). For a session that is part of a
* multi-session Instance the label is prefixed with the relative-day ordinal `d{k}` (e.g. `d2 0935`);
* a standalone session (no ordinal) keeps the bare `HHMM` — byte-identical to today. */
readonly label: string;
/** Relative ms since the session's first bus event (dateless). */
readonly sinceOpenMs: number;
/** The relative-day coordinate `k` (kestrel-5zl.16.4) — the session's ordinal in its Instance (`d0,
* d1, …`), pinning no calendar date. Present ONLY for a multi-session run; absent (and unserialized)
* for a standalone session, so existing single-session frames are byte-identical. Paired with the
* per-session `sinceOpenMs` (which RESETS at each session's open), this is the date-blind coordinate
* that lets a horizon span any number of sessions without `sinceOpenMs` ever reaching the 10-digit
* epoch signature — the ~11.57-day fence limit disappears structurally, the pattern unchanged. */
readonly sessionOrdinal?: number;
readonly instruments: readonly string[];
readonly phase: string;
readonly spot: number | null;
/**
* How long `spot` has been standing without a reprint at this vantage — ms since the SPOT tick
* that established it (kestrel-rs4). Dateless by construction: a duration, the {@link sinceOpenMs}
* idiom, never the epoch it was derived from. This is the ONLY channel that tells a genuinely
* frozen market apart from a feed that stopped printing — the two produce an identical `spot`.
* Absent (and unserialized) when nothing was ever observed, so a spot-less frame is byte-identical.
*/
readonly spotAgeMs?: number;
readonly priorClose: number | null;
readonly hod: number | null;
readonly lod: number | null;
readonly vwap: number | null;
/** The frozen opening-range high/low, passed through from the {@link DaySnapshot} (kestrel-wa0j.58). A
* price pins no calendar date, so it rides the date-blind projection unchanged; `null` before the range
* anchors. Without it every driver-path frame's OR reads `—` and the `failed-breaks` pane is inert. */
readonly orHigh: number | null;
readonly orLow: number | null;
/** The chain, with its absolute expiry replaced by a relative days-to-expiry offset (`dte`). */
readonly chain: { readonly underlierPx: number; readonly dte: number | null; readonly legs: readonly OptionQuote[] } | null;
/** The underlier tape since the last vantage — dateless (`HHMM` label + relative ms). */
readonly tape: readonly { readonly label: string; readonly sinceOpenMs: number; readonly px: number }[];
readonly plans: readonly PlanLine[];
readonly resting: readonly OrderLine[];
readonly fills: readonly OrderLine[];
readonly committedUsd: number;
readonly rUsd: number;
}
/**
* Calendar days from `sessionDate` to an expiry token, or `null`. An **absolute** date expiry
* (`2026-07-17`) is converted to this RELATIVE offset (it pins no calendar date once the session
* origin is itself blinded); a bare `Ndte` tag is read for its `N`; any other shape fails closed to
* `null` (the chain shows no expiry rather than emit an un-relativized token — {@link assertDateBlind}
* is the backstop). Pure arithmetic (`Date.UTC` is a pure function of its args — no wall clock read,
* RUNTIME §0), so replay stays byte-stable.
*/
function relativeDte(expiry: string | undefined, sessionDate: string): number | null {
if (expiry === undefined) return null;
const iso = /^(\d{4})-(\d{2})-(\d{2})$/.exec(expiry.trim());
if (iso !== null) {
const expMid = Date.UTC(Number(iso[1]), Number(iso[2]) - 1, Number(iso[3]));
const [sy, sm, sd] = sessionDate.split("-").map(Number);
const sesMid = Date.UTC(sy ?? 1970, (sm ?? 1) - 1, sd ?? 1);
return Math.round((expMid - sesMid) / 86_400_000);
}
const tag = /^(\d+)\s*dte$/i.exec(expiry.trim());
return tag !== null ? Number(tag[1]) : null;
}
/** Project a raw {@link DaySnapshot} into the date-blind {@link AuthorFrame} (the single seam).
* `openTs` is the session's first bus-event `ts` (the injected clock origin, RUNTIME §0). `sessionOrdinal`
* (kestrel-5zl.16.4), when supplied, is the relative-day coordinate `k`: it prefixes the label with `d{k}`
* and rides the frame as {@link AuthorFrame.sessionOrdinal}. Absent (every standalone caller) ⇒ today's
* bare `HHMM` label and no ordinal field — byte-identical. */
export function projectAuthorFrame(
s: DaySnapshot,
openTs: number,
sessionDate: string,
sessionOrdinal?: number,
): AuthorFrame {
return {
kind: s.kind,
n: s.n,
label: sessionOrdinal === undefined ? s.label : `d${sessionOrdinal} ${s.label}`,
sinceOpenMs: s.nowTs - openTs,
...(sessionOrdinal !== undefined ? { sessionOrdinal } : {}),
instruments: [...s.instruments],
phase: s.phase,
spot: s.spot,
// kestrel-rs4: the spot's epoch becomes an AGE here — the one seam calendar identity is stripped
// at (file header). A duration pins no date, and it is what keeps a dead feed from reaching the
// author looking live. Never observed ⇒ omitted (byte-identical), and `spot` reads UNKNOWN anyway.
...(s.spotTs !== undefined && s.spotTs !== null ? { spotAgeMs: Math.max(0, s.nowTs - s.spotTs) } : {}),
priorClose: s.priorClose,
hod: s.hod,
lod: s.lod,
vwap: s.vwap,
// kestrel-wa0j.58: the frozen opening range rides through unchanged (a price pins no date).
orHigh: s.orHigh,
orLow: s.orLow,
chain:
s.chain === null
? null
: { underlierPx: s.chain.underlierPx, dte: relativeDte(s.chain.expiry, sessionDate), legs: [...s.chain.legs] },
tape: s.tape.map((t) => ({ label: labelOf(t.ts), sinceOpenMs: t.ts - openTs, px: t.px })),
plans: [...s.plans],
resting: [...s.resting],
fills: [...s.fills],
committedUsd: s.committedUsd,
rUsd: s.rUsd,
};
}
// ─────────────────────────────────────────────────────────────────────────────
// The date-blind fence (fail-closed guard over the ACTUAL emitted author artifacts)
//
// The projection strips the calendar identity we know about; this guard is the fail-closed backstop
// that scans the FINAL emitted string (and its filename) for any recoverable calendar token — an ISO
// or slash date, a month name, an epoch-looking integer, a compact date fused into a symbol root
// (OCC style), or a weekday name — and REFUSES to emit it (throws loudly, EVALUATION contamination
// fence). A leak in a field the projection did not anticipate is caught here rather than silently
// delivered.
// ─────────────────────────────────────────────────────────────────────────────
/** The calendar-leak patterns the author boundary is fenced against. Shared by the guard below and
* the CI test so the two never drift. A relative-ms integer (≤ 8 digits for a full session) and an
* `HHMM` clock (4 digits) sit below the epoch threshold; prices/strikes/qty never reach it.
*
* The `compact-date` class is why we CANNOT simply flag any 6/8-digit run: a full-session
* `sinceOpenMs` legitimately reaches 8 digits (≈2.3e7 ms), so a bare digit-run pattern would
* false-close on it. Instead we target the recoverable signature it collides with — a YYMMDD/YYYYMMDD
* run FUSED to a letter root (`SPXW260710`, the standard OCC contract shape). A relative-ms integer
* is always space/quote/`: `-delimited (never letter-adjacent), so the lookbehind never touches it,
* while a date-bearing instrument symbol — passed through verbatim at the author boundary — is
* caught. */
// A month name ONLY counts as a calendar leak when it carries a DATE CONTEXT — a day-number or a
// year ADJACENT to the month word. A BARE month word ("the feed may degrade", "march toward the
// level", "august highs") is an English homograph that legitimately appears in owner-authored
// mandate/Brief prose embedded in the acting frame, NOT a recoverable date, so it must PASS. This
// mirrors compact-date's context-requiring lookbehind: precision comes from demanding the token's
// surrounding date signature, never a bare word. The narrowing is orthogonal to the sibling
// patterns (iso/slash/compact/epoch/weekday), so a NUMERIC date ("2024-05-01") is still caught
// regardless — recall is preserved. Adjacency allows a space/comma/hyphen (and an optional "of",
// for "15th of March"). A DAY is 1–31 with an optional ordinal (st/nd/rd/th) on EITHER side; a YEAR
// is four digits after the month.
const MONTH_WORD =
"jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?";
const DAY_NUM = "(?:0?[1-9]|[12]\\d|3[01])(?:st|nd|rd|th)?"; // 1–31, optional ordinal suffix
const DATE_SEP = "[\\s,\\-]+(?:of[\\s,\\-]+)?"; // space/comma/hyphen adjacency, optional "of"
const MONTH_NAME_RE = new RegExp(
// MONTH → day-or-year: "March 15" | "Mar 15, 2024" | "May 2024"
`\\b(?:${MONTH_WORD})\\b${DATE_SEP}(?:${DAY_NUM}|\\d{4})\\b` +
// day → MONTH: "15 March" | "15th of March" | "1 May"
`|\\b${DAY_NUM}${DATE_SEP}(?:${MONTH_WORD})\\b`,
"i",
);
export const DATE_LEAK_PATTERNS: readonly { readonly name: string; readonly re: RegExp }[] = [
{ name: "iso-date", re: /\d{4}-\d{2}-\d{2}/ },
{ name: "slash-date", re: /\d{1,2}\/\d{1,2}\/\d{2,4}/ },
{ name: "month-name", re: MONTH_NAME_RE },
// A 6-or-more-digit run fused directly onto a letter root — a compact YYMMDD/YYYYMMDD date carried
// inside an OCC-style instrument symbol (`SPXW260710`). The lookbehind keeps standalone relative-ms
// integers (always delimited, never letter-adjacent) from tripping it.
//
// The lookbehind demands an UPPERCASE root letter (kestrel-z46). OCC instrument symbols are UPPERCASE
// by spec — the root (`SPXW`) and the C/P right indicator alike — and nothing in the engine ever
// lowercases a symbol, so this is a PRECISION-ONLY narrowing: every real compact-date leak vector
// still trips (recall preserved; the SPXW260710 backstop test stays red-on-delete). What it excludes
// is the false positive it was mis-firing on: a LOWERCASE-hex sha256 hash — a Brief/frame/rendering
// identity like `sha256:e739bbc12e0d5b78979162ec…` (ADR-0032 tracer-3) — where a `[a-f]`+6-digit slice
// (`b78979162`) read as an OCC date and blocked an honest, date-blind frame. sha256 hex digests are
// lowercase, so an uppercase lookbehind can never see one; and because the discriminator is CASE, not
// "surrounded by hex letters", an all-hex-letter ticker root (`ADBE260710`, `FACE260710`) is STILL
// caught — the naive hex-context exclusion's blind-spot (the reason-feedback review's noted gap) is
// not opened here.
{ name: "compact-date", re: /(?<=[A-Z])\d{6,}/ },
// A weekday name — a recoverable calendar signal even without the numeric date. Full names only,
// so a short ticker root can never collide with a 3-letter abbreviation.
{ name: "weekday-name", re: /\b(mon|tues|wednes|thurs|fri|satur|sun)day\b/i },
// 10+ contiguous digits ≈ an epoch (seconds=10, ms=13) — a recoverable raw timestamp.
{ name: "epoch", re: /\b\d{10,}\b/ },
];
/** The first calendar-leak in `body`, or `null` when it is date-blind. Deterministic. */
export function scanDateLeak(body: string): { readonly name: string; readonly match: string } | null {
for (const { name, re } of DATE_LEAK_PATTERNS) {
const m = re.exec(body);
if (m !== null) return { name, match: m[0] };
}
return null;
}
/** Fail-closed guard: throw loudly if `body` (an emitted author artifact, or its filename `what`)
* carries any calendar identity. Called at the author boundary before every write — a leak is caught,
* never silently emitted (EVALUATION contamination fence; CLAUDE fail-closed). */
export function assertDateBlind(what: string, body: string): void {
const hit = scanDateLeak(body);
if (hit !== null) {
throw new Error(
`day: date-blind fence tripped — ${what} leaks a ${hit.name} (${JSON.stringify(hit.match)}) at the author boundary (fail-closed, EVALUATION contamination fence)`,
);
}
}
/** The session phase as a legible label, or an explicit `"—"` when canonical phase is UNRESOLVED
* (F3): the renderer invents no value — it never substitutes a plausible `"pre"` for a state the
* canonical series has not actually resolved (RUNTIME §2/§3, UNKNOWN is never a guess). */
export function phaseLabel(phase: unknown): string {
return typeof phase === "string" ? phase : "—";
}
/** The DAY-protocol reply instruction appended AFTER a rendered frame. Protocol scaffolding (never
* frame content — it invents no value), placed as a TRAILER so the canonical kernel still LEADS the
* artifact ({@link renderBriefing}/{@link renderWakeDelta} enforce the lead guard on their own output).
* Routed through the same date-blind fence as every author artifact. */
function withDayReplyTrailer(rendered: string, instruction: string): string {
return `${rendered}\n\n→ ${instruction}\n`;
}
/**
* The DAY wake-frame body: THE canonical {@link renderWakeDelta} screen over the driver's typed
* {@link ActingFrame}, rendered under the frame's DELIVERED View (kestrel-wa0j.19 §2 — the day runner
* threads `frame.view` exactly like the live-agent + file-handshake adapters; the pre-fix omission was a
* latent screen divergence where the day artifact ignored a View the other adapters honored). Absent
* view ⇒ the default WAKE panes, BYTE-IDENTICAL to before.
*
* FAIL-CLOSED, NEVER CRASH (§1a): a delivered View that resolves but cannot MATERIALIZE (a window arg the
* frame can't serve, an over-budget View) falls back to the default WAKE panes with the refusal LEADING
* the artifact — surfaced, never silent, and never a dead day session. A throw with NO View to blame is a
* genuine render defect and is re-thrown. Pure (no I/O, no clock) — exported so the threading + the
* fallback are directly testable.
*/
export function dayWakeFrameText(frame: ActingFrame): string {
try {
return renderWakeDelta(frame, frame.view !== undefined ? { view: frame.view } : {});
} catch (e) {
if (frame.view === undefined) throw e;
const note = `⚠ handshake: delivered View "${frame.view.name}" could not be materialized — ${e instanceof Error ? e.message : String(e)}; showing the default WAKE panes (fail-closed, kestrel-wa0j.19).`;
return `${note}\n\n${renderWakeDelta(frame, {})}`;
}
}
/**
* The machine summary (`state-<n>.json`) — the deterministic JSON an agent author reads. It
* serializes the date-blind {@link AuthorFrame} DIRECTLY: the projection already rewrote every
* wall-clock epoch to relative `sinceOpenMs` and the absolute chain expiry to a relative `dte`, so
* the machine channel carries the SAME blinded fields the txt frames do — no channel can re-leak.
*/
export function machineSummary(f: AuthorFrame): string {
// Round every finite number to a 1e-6 grid — an agent-facing summary needs no 15-significant-digit
// float tails, and a raw `100.33333333333333` vwap would trip the date-blind EPOCH grep on its
// 14-digit fractional run (a false date signal). Integers (relative-ms, qty, strike) are untouched.
return JSON.stringify(f, (_k, v) => (typeof v === "number" && Number.isFinite(v) ? Math.round(v * 1e6) / 1e6 : v), 2) + "\n";
}
// ─────────────────────────────────────────────────────────────────────────────
// Snapshot assembly (from the engine's deterministic dump)
// ─────────────────────────────────────────────────────────────────────────────
interface DumpChild {
readonly ref: string;
readonly role: string;
readonly side: string;
readonly qty: number;
readonly strike: number;
readonly right: string;
readonly px: number;
readonly filled: boolean;
readonly cancelled: boolean;
}
interface DumpPlan {
readonly name: string;
readonly state: string;
readonly armBlockReason?: string | null;
readonly filledBuyQty: number;
readonly filledSellQty: number;
readonly children: readonly DumpChild[];
}
interface EngineDump {
readonly plans: readonly DumpPlan[];
readonly envelopes: readonly { readonly name: string; readonly committedUsd: number }[];
}
function numOrNull(v: unknown): number | null {
return typeof v === "number" && Number.isFinite(v) ? v : null;
}
/** Build the frame snapshot from the live core at a vantage. Pure over the core's state. Exported so
* the Simulate driver (`./simulate.ts`) assembles its acting Frame from the SAME snapshot the stepped
* day renders — one snapshot shape, one date-blind projection (no forked vantage). */
export function snapshotOf(
core: SessionCore,
kind: "keyframe" | "delta",
n: number,
label: string,
nowTs: number,
instruments: readonly string[],
rUsd: number,
tape: readonly { ts: number; px: number }[],
): DaySnapshot {
const dump = core.engine.dumpState() as EngineDump;
const plans: PlanLine[] = dump.plans.map((p) => ({
name: p.name,
state: p.state,
heldQty: p.filledBuyQty - p.filledSellQty,
// kestrel-50w: carry the arm-time gate-block reason onto the projected plan line (present only when
// the engine latched one — a plan stuck `authored` on an unsatisfiable regime gate).
...(p.armBlockReason != null ? { blockedReason: p.armBlockReason } : {}),
}));
const resting: OrderLine[] = [];
const fills: OrderLine[] = [];
for (const p of dump.plans) {
for (const c of p.children) {
const line: OrderLine = {
ref: c.ref,
plan: p.name,
role: c.role,
side: c.side,
qty: c.qty,
strike: c.strike,
right: c.right,
px: c.px,
filled: c.filled,
};
if (c.filled) fills.push(line);
else if (!c.cancelled) resting.push(line);
}
}
const committedUsd = dump.envelopes.reduce((m, e) => Math.max(m, e.committedUsd), 0);
const execSym = core.execSpec.symbol;
const book = core.books.get(execSym);
const st = core.state;
// kestrel-rs4: canonical state already stamps the tick behind `spot` (`spotStamp`) — carry that ts
// so the projection can age it. Without it a feed that died hours ago renders as a live level.
const spotStamp = st.spotStamp;
return {
kind,
n,
label,
nowTs,
instruments: [...instruments],
phase: phaseLabel(st.phase),
spot: numOrNull(st.spot),
spotTs: spotStamp === UNKNOWN ? null : spotStamp.ts,
priorClose: numOrNull(st.priorClose),
hod: numOrNull(st.hod),
lod: numOrNull(st.lod),
vwap: numOrNull(st.vwap),
// kestrel-wa0j.58: the CanonicalState froze the opening range (`onSpot`) but the projection dropped it,
// so `failed-breaks` rendered all-dashes on every corpus frame. `numOrNull` maps UNKNOWN → null (the
// range reads `—` before the first spot anchors it) — fail-closed, never a fabricated level.
orHigh: numOrNull(st.orHigh),
orLow: numOrNull(st.orLow),
chain:
book === undefined
? null
: { underlierPx: book.underlier_px, ...(book.expiry !== undefined ? { expiry: book.expiry } : {}), legs: [...book.legs] },
tape: [...tape],
plans,
resting,
fills,
committedUsd,
rUsd,
};
}
/** Options for {@link briefingSnapshot}. */
export interface BriefingSnapshotOptions {
/** Present the observed range (an INTRADAY authoring vantage) rather than the pre-open keyframe
* (kestrel-1vw). When `true`, the OPEN briefing folds every exec observation at/before the cutoff into
* `spot` (current)/`hod`/`lod`/`tape` — the opening range an ORB cell authors at its OR-close. Absent /
* `false` ⇒ the pre-open keyframe (first spot/chain only, range UNKNOWN), byte-identical to before. */
readonly carryRange?: boolean;
}
/**
* Build the OPEN-briefing snapshot BEFORE the first event — the OPEN keyframe of EVALUATION Part 1.
*
* It is bounded by the **author acting cutoff** (`cutoffTs`, kestrel-m9i.11): it admits ONLY the exec
* SPOT/BOOK observations whose watermark is AT OR BEFORE the cutoff, and NEVER peeks past it — so a
* regular-session print at/after the 09:30 open (a T-5 benchmark, or a delayed feed) does NOT leak into
* the author's keyframe (a look-ahead that contaminates the benchmark).
*
* By DEFAULT it is the pre-open keyframe: it takes the first exec spot/chain at/before the cutoff and
* leaves the range (`hod`/`lod`/`tape`) UNKNOWN — the renderer shows the dash + a coverage reason
* (fail-closed: missing → UNKNOWN). `opts.carryRange` opts into an **intraday authoring vantage**
* (kestrel-1vw): an ORB cell authoring at its opening-range close (~10:00 ET) folds EVERY exec
* observation at/before the cutoff, so the OPEN briefing surfaces the range as of the cutoff — `spot`
* is the current print, `hod`/`lod` are the session high/low (the OR high/low at OR-close), and `tape`
* is the observed range. The causal fence is identical in both paths (no post-cutoff print is ever
* admitted); the flag only decides whether the admitted prefix is COLLAPSED to the opening keyframe or
* PRESENTED as the range. `priorClose`/`vwap` remain UNKNOWN — admitted ONLY through governed context
* that carries its own as-of/source watermark, never inferred here. Folds nothing into the core (no
* order can act pre-briefing; the read is informational).
*/
export function briefingSnapshot(
meta: MetaEvent,
execSym: string,
events: readonly BusEvent[],
rUsd: number,
cutoffTs: number,
opts: BriefingSnapshotOptions = {},
): DaySnapshot {
// Default (pre-open) path — the canonical T-5 briefing: take the FIRST exec spot/chain at/before the
// cutoff and leave the range (`hod`/`lod`/`tape`) UNKNOWN. Byte-identical to before this fix for every
// caller that does not explicitly opt into an intraday authoring vantage.
if (opts.carryRange !== true) {
let spot: number | null = null;
let spotTs: number | null = null;
let chain: DaySnapshot["chain"] = null;
let book: BookState | undefined;
for (const ev of events) {
if (ev.ts > cutoffTs) continue; // fail-closed: never admit a post-cutoff (future) observation
if (ev.stream !== "TICK") continue;
if (spot === null && ev.type === "SPOT" && ev.instrument === execSym) {
spot = ev.px;
spotTs = ev.ts; // kestrel-rs4: the age this keyframe's spot is presented with rides on THIS ts
}
if (ev.type === "BOOK" && ev.instrument === execSym) {
book = foldBook(ev, book);
if (chain === null) chain = { underlierPx: ev.underlier_px, ...(ev.expiry !== undefined