UNPKG

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.

260 lines (244 loc) 16.6 kB
/** * # session/harness/cascade — the two-tier strategist+watcher cascade driver (ADR-0032 tracer, kestrel-aus) * * The thinnest real driver that runs ADR-0032's **two-tier cascade** end-to-end through the EXISTING * Simulate runner ({@link import("../simulate.ts").runSimulateSession}) with **zero determinism-core * change**. It composes TWO ADR-0012/0013 {@link Agent}s over ONE Session at two cadences: * * - **STRATEGIST** — a frontier model (Fable/opus). Invoked at the OPEN keyframe (authors the day's * Plan + Brief + Mandate + View) and on a watcher **escalation** (re-brief: supersede a new * Plan/Brief). The few expensive judgments that set the frame. * - **WATCHER** — a small/fast model (haiku). Invoked at every in-loop Wake. It **manages** the * strategist's armed Plan within the Mandate (reload/exit/adjust/size-within-budget) — MANAGE-ONLY * (owner v1 default): it may NOT arm new authority. When it reaches the edge of its authority it * **escalates via the driver** to the strategist. * * ## The load-bearing trick: the cascade IS one {@link Agent} (no new determinism-core plumbing) * `runSimulateSession` already keys {@link import("../agent.ts").CapturedTurns} on each wake's stable * identity ({@link import("../agent.ts").wakeKeyOf}, kestrel-5zl.19) and captures the ONE terminal turn * each wake returns. So the cascade is expressed as a single * {@link Agent} whose `open` dispatches to the strategist and whose `decide` dispatches to the watcher * — and, on an escalation, re-invokes the strategist and returns ITS turn instead. Exactly one terminal * turn crosses to the Bus per wake, so: * - the driver captures the whole cascade into `CapturedTurns` for free, and * - {@link import("../agent.ts").recordedAgent} replays the cascade **byte-identically** (it replays * the captured terminal turn per wake — it never learns which tier authored it). * This is the ADR §6 claim ("both tiers are above-the-line Agents; recordedAgent replays the whole * cascade byte-identically") realized without touching the determinism line. * * ## The safety keystone rides for free (ADR-0032 §3) * Every terminal turn — watcher OR strategist — crosses into the SAME fail-closed admission Gate * (`core.gate`) inside `runSimulateSession`: SELL floored at intrinsic, budget clamp, never-naked, the * bounded-risk envelope. The watcher buys judgment; the runtime keeps authority. A watcher action that * would exceed the Mandate/budget is refused fail-closed by that Gate exactly as any over-reaching * frontier action is; a watcher attempt to **arm new authority** (a `supersede`) is refused at the tier * boundary here ({@link classifyEscalation}) and **escalated** to the strategist rather than admitted. * Neither model tier is trusted; both are bounded. * * ## Attribution across tiers (ADR-0032 §6, the three-level ladder) * {@link attributeCascade} computes the frozen-Plan counterfactual over the SAME recorded tape: * `strategist_alpha = EV(frozen strategist Plan, pure-algo) − EV(benchmark)` and * `watcher_alpha = EV(cascade) − EV(frozen strategist Plan)`, so we can read which tier earned the P&L. */ import { OPEN_ORDINAL, OPEN_WAKE_KEY, wakeKeyOf, type ActingFrame, type Agent, type AgentConfig, type AgentTurn, type WakeKey, } from "../agent.ts"; import type { BriefingInput } from "../../frame/types.ts"; import { disarmsPlan } from "../../engine/disarm.ts"; // ───────────────────────────────────────────────────────────────────────────── // Escalation — the watcher wakes the strategist (ADR-0032 §4, owner v1 "minimal") // ───────────────────────────────────────────────────────────────────────────── /** Why the watcher escalated to the strategist (the two minimal owner-v1 triggers): * - `arm-new-authority` — the watcher authored a `supersede` (arm/re-author standing authority), * which is BEYOND its MANAGE-ONLY tactical authority (§5); the driver refuses it at the tier * boundary and re-invokes the strategist (the right actor for a mandate/thesis change). * - `defer-and-flag` — the watcher explicitly asked for a re-brief (a `standDown` reason or a journal * opening with {@link ESCALATION_SENTINEL}), the "I am out of my depth; call the PM" move. */ export type EscalationReason = "arm-new-authority" | "defer-and-flag"; /** The token a prompted watcher opens its `standDown` reason / journal with to explicitly request a * re-brief (the defer-and-flag escalation). A legible, greppable signal — matched case-insensitively at * the start of the trimmed text so a fast model's `RE-BRIEF: regime broke` reads as an escalation. */ export const ESCALATION_SENTINEL = "RE-BRIEF"; /** * Pure classification of a watcher turn: does it escalate to the strategist, and why? Returns the * {@link EscalationReason}, or `null` when the turn is WITHIN the watcher's manage-only authority (it * crosses to the Bus and the admission Gate as-is). Above the determinism line — a total function of * the turn, so the driver's routing is deterministic and unit-testable. */ export function classifyEscalation(turn: AgentTurn): EscalationReason | null { // MANAGE-ONLY: a `supersede` is an attempt to arm/re-author standing authority — the watcher may not. // Refuse it at the tier boundary and escalate (the strategist is the right actor). if (turn.actions.some((a) => a.kind === "supersede")) return "arm-new-authority"; // Explicit defer-and-flag: a standDown reason or the journal opening with the sentinel. const flags: string[] = []; if (turn.journal !== undefined) flags.push(turn.journal); for (const a of turn.actions) if (a.kind === "standDown") flags.push(a.reason); if (flags.some((t) => t.trimStart().toUpperCase().startsWith(ESCALATION_SENTINEL))) return "defer-and-flag"; return null; } // ───────────────────────────────────────────────────────────────────────────── // The cascade Agent // ───────────────────────────────────────────────────────────────────────────── /** One routing record the cascade appends per invoked ordinal (audit evidence, ABOVE the determinism * line — never a graded input, never a replay key). The `escalation-rebrief` records BOTH the watcher's * escalation (why) and that the strategist's re-brief turn is what crossed to the Bus. */ export interface CascadeStep { /** {@link OPEN_ORDINAL} for the open turn, else the wake ordinal — a DISPLAY/log coordinate only (it * shifts when a source inserts a vantage). Join a step to its captured turn on {@link key}. */ readonly ordinal: number; /** The step's stable wake identity (kestrel-5zl.19) — {@link OPEN_WAKE_KEY} for the open turn, else * {@link wakeKeyOf} of the delivered frame: the SAME key the driver captured the turn under. */ readonly key: WakeKey; /** The tier whose turn CROSSED to the Bus at this ordinal (on an escalation, the strategist). On an * `escalation-hold-armed` step NEITHER tier's turn crosses — the driver holds the frozen plan armed * (an empty PASS) — so the tier records the strategist (the tier the escalation was routed to). */ readonly tier: "strategist" | "watcher"; /** `escalation-hold-armed` (frozen-fan only): the watcher escalated but the frozen/recorded strategist * carried NO re-brief; instead of letting its fail-closed stand-down DISARM the armed plan, the driver * REFUSED the watcher's over-reach and HELD the frozen plan armed (fail-closed to an empty PASS). */ readonly kind: "open" | "wake" | "escalation-rebrief" | "escalation-hold-armed"; readonly escalated: boolean; readonly escalationReason?: EscalationReason; } /** Options for {@link cascadeAgent}. `strategist`/`watcher` are two ordinary {@link Agent}s (live, * scripted, or recorded); `log`, when supplied, receives one {@link CascadeStep} per invoked ordinal. */ export interface CascadeOptions { readonly strategist: Agent; readonly watcher: Agent; /** Override the advertised cascade {@link AgentConfig}; defaults to {@link cascadeConfigOf}. */ readonly config?: AgentConfig; /** The above-the-line routing audit (one record per invoked ordinal). */ readonly log?: CascadeStep[]; /** * FROZEN/RECORDED-strategist FAN MODE ONLY (set by {@link import("./plan-fixture.ts").fanWatchers}, * whose strategist tier is a {@link import("./plan-fixture.ts").recordedStrategistOf} recording). When * `true`, a watcher escalation whose strategist re-invoke fails closed to a stand-down does NOT DISARM * the armed Plan: a frozen/recorded strategist carries no captured re-brief, so its `decide` stands the * book down — and letting that cross would tear down the armed CONTROL plan and collapse the whole * frozen-plan fan to $0 (docs/results/author-and-fan/, PR #8). Instead the watcher's over-reach is * REFUSED and the frozen plan is HELD ARMED (fail-closed to an empty PASS — the existing armed authority * persists), so the fan can measure watcher management against a LIVE armed plan. * * SAFETY SCOPING: this is confined to the recorded/frozen fan. A LIVE cascade leaves this UNSET — a live * watcher that exceeds its mandate is still refused and its live strategist re-briefed or stood down * exactly as before. It is NOT a global flag: only the recorded-fan builder sets it, and it never * weakens the admission Gate (every crossing turn, held or not, still passes the same fail-closed Gate). */ readonly frozenStrategist?: boolean; } /** The fail-closed HOLD turn the frozen fan returns instead of a strategist stand-down: an empty PASS * (the standing armed Plan keeps managing itself) carrying a legible pre-hoc JOURNAL naming the refusal. * Byte-stable — no per-turn material, no clock. */ const HELD_ARMED_TURN: AgentTurn = { actions: [], journal: "cascade(frozen-fan): watcher over-reach REFUSED at the tier boundary; frozen plan HELD ARMED " + "(no captured strategist re-brief — the armed authority persists rather than being torn down)", }; /** Derive the cascade's advertised {@link AgentConfig} from the two tiers: the strategist's identity * (the frame-setting brain) with a cascade `label` and the cascade `harness` folded in, so a cascade * run mints its OWN ConfigId / grid column and never averages with a single-tier column (ADR-0032 §7, * the ADR-0013 controlled-division discipline). The watcher's model rides the label. */ export function cascadeConfigOf(strategist: AgentConfig, watcher: AgentConfig): AgentConfig { return { ...strategist, label: `cascade(${strategist.label}»${watcher.label})`, harness: "strategist-watcher-cascade", harnessVersion: "v1", }; } /** * Build the cascade as ONE {@link Agent} over two tiers. `open` → the strategist authors the frame * (Plan/Brief/Mandate/View). `decide` → the watcher manages in-loop; if the watcher escalates * ({@link classifyEscalation}), the strategist is re-invoked on the SAME frozen frame (which carries * the book/mandate/plan state + the engine log since last wake — the acting Frame IS the state) and * ITS re-brief turn crosses to the Bus instead. Exactly one terminal turn per ordinal → the driver * captures the whole cascade and {@link recordedAgent} replays it byte-identically. */ export function cascadeAgent(opts: CascadeOptions): Agent { const { strategist, watcher, log } = opts; const config = opts.config ?? cascadeConfigOf(strategist.config, watcher.config); return { config, async open(briefing: BriefingInput): Promise<AgentTurn> { const turn = await strategist.open(briefing); log?.push({ ordinal: OPEN_ORDINAL, key: OPEN_WAKE_KEY, tier: "strategist", kind: "open", escalated: false }); return turn; }, async decide(frame: ActingFrame): Promise<AgentTurn> { const watcherTurn = await watcher.decide(frame); const reason = classifyEscalation(watcherTurn); if (reason === null) { // Within manage-only authority — the watcher's turn crosses to the Bus + the admission Gate. log?.push({ ordinal: frame.wakeOrdinal, key: wakeKeyOf(frame), tier: "watcher", kind: "wake", escalated: false }); return watcherTurn; } // Escalate: the watcher wakes the strategist (attention, not risk — ADR-0001's Wake invariant). // Re-invoke the strategist on the same frozen frame; its re-brief supersede is the terminal turn. const strategistTurn = await strategist.decide(frame); // FROZEN/RECORDED-STRATEGIST FAN MODE ONLY (opts.frozenStrategist): a recorded strategist has no // captured re-brief, so its `decide` fails closed to a stand-down. Letting that DISARM the armed // plan would collapse the whole frozen-plan fan to $0. Instead REFUSE the watcher's over-reach and // HOLD the frozen plan armed (fail-closed to an empty PASS — the armed authority persists). A frozen // strategist that DID capture a genuine re-brief (a non-disarming turn) is still honored. This is // scoped to the recorded fan: a LIVE cascade leaves frozenStrategist unset and disarms exactly as // before, and the admission Gate is untouched (the held PASS still crosses the same Gate). if (opts.frozenStrategist === true && disarmsPlan(strategistTurn)) { log?.push({ ordinal: frame.wakeOrdinal, key: wakeKeyOf(frame), tier: "strategist", kind: "escalation-hold-armed", escalated: true, escalationReason: reason, }); return HELD_ARMED_TURN; } log?.push({ ordinal: frame.wakeOrdinal, key: wakeKeyOf(frame), tier: "strategist", kind: "escalation-rebrief", escalated: true, escalationReason: reason, }); return strategistTurn; }, }; } // ───────────────────────────────────────────────────────────────────────────── // Three-level attribution ladder (ADR-0032 §6) — the frozen-Plan counterfactual // ───────────────────────────────────────────────────────────────────────────── /** The three EVs the attribution ladder reads, each a graded run over the SAME recorded tape + fill * model (so the only variable is the tier under test — the controlled-comparison discipline). Use the * conservative floor P&L (`blotter.totals.floor`) — a deterministic, definite-fill number under the * strict-cross family, comparable across the three arms. */ export interface AttributionInput { /** `EV(benchmark)` — buy-and-hold / the null baseline over the tape. */ readonly benchmark: number; /** `EV(frozen strategist Plan, replayed pure-algo, no watcher)` — the armed Plan alone. */ readonly frozenPlan: number; /** `EV(cascade)` — the watcher managing the strategist's armed Plan in the loop. */ readonly cascade: number; } /** The three-level `management_alpha` decomposition (ADR-0032 §6): `strategist_alpha` is the frame * (plan/brief) alpha vs the benchmark; `watcher_alpha` is the in-loop management alpha the watcher adds * ON TOP of the frozen plan; `cascadeTotal` is their sum (= `EV(cascade) − benchmark`). Pure. */ export interface Attribution { readonly strategistAlpha: number; readonly watcherAlpha: number; readonly cascadeTotal: number; } /** Compute the three-level ladder from the three frozen counterfactual EVs (ADR-0032 §6). Pure. */ export function attributeCascade(x: AttributionInput): Attribution { const strategistAlpha = x.frozenPlan - x.benchmark; const watcherAlpha = x.cascade - x.frozenPlan; return { strategistAlpha, watcherAlpha, cascadeTotal: strategistAlpha + watcherAlpha }; }