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.

117 lines (107 loc) 6.01 kB
/** * # bus/plan-instances — group a bus's PLAN stream into exact Plan INSTANCES (kestrel-22j.15) * * A pure, deterministic fold of a bus's {@link PlanEvent}s into the distinct Plan **instances** they trace — * the ONE place both report projections (`project(bus)` in {@link ../blotter/project.ts} and `buildReport` * in {@link ../session/sim.ts}) agree on "what is one instance", so their groupings can never drift. * * ## Why a name is not an instance (the P0 this fixes) * A plan NAME is a **Lineage** key (names-are-data, ADR-0006): `fade-ladder` authored fresh on 40 days is one * strategy with 40 instances, and a name may legally RECUR after an earlier instance is `done` (the F4 * same-name-supersession guard in {@link ../engine/plans.ts} only forbids re-declaring a name still owned by a * NOT-done plan). Grouping the PLAN stream by name ALONE therefore merges a `done` instance and its same-name * replacement into one trace whose terminal outcome is the last one seen — silently erasing the predecessor's * (kestrel-22j.15). Grouping by INSTANCE keeps them apart; Lineage aggregation (by name) is a SEPARATE roll-up * a consumer does over `group.plan`. * * ## The instance key (v5 explicit, else an explicit pre-v5 migration) * - **v5 bus** — every PLAN event carries {@link PlanEvent.plan_instance}, the id the engine minted * deterministically at registration. The key IS that id: exact, no inference. * - **pre-v5 bus** (no `plan_instance`) — MIGRATE EXPLICITLY by **lifecycle sessionization**: a name's next * instance opens at the structural boundary where its currently-open instance has already reached a terminal * `done`. This is exact — never a timestamp heuristic, never fuzzy name-matching — because F4 guarantees a * name only recurs after its predecessor is `done`, so a `done`→next-event boundary is precisely one * instance boundary. Fail-closed doctrine (RUNTIME §8): a pre-v5 bus is migrated, never silently * mis-identified. * * A single bus is all-or-nothing (an engine either stamps every plan event with an id or, pre-v5, none), so the * two paths never interleave within one bus. * * Determinism (RUNTIME §0): pure function of the bus bytes — no wall clock, no RNG. Output order is * `(name asc, first-seq asc)`, which reproduces the prior name-sorted order exactly when every name has a * single instance, and orders same-name instances by first appearance. */ import type { BusEvent, PlanEvent } from "./types.ts"; /** One exact Plan instance traced off the bus: its Lineage name, its instance identity (the explicit * `plan_instance` on a v5 bus, else a deterministic sessionized key), and its PLAN events in seq order. */ export interface PlanInstanceGroup { /** The authored plan NAME — the Lineage aggregation key (a consumer rolls instances up BY this). */ readonly plan: string; /** The exact instance identity: {@link PlanEvent.plan_instance} (v5) or a sessionized migration key (pre-v5). */ readonly instance: string; /** This instance's PLAN events, in bus seq order (lifecycle transitions + any reject notices). */ readonly events: readonly PlanEvent[]; } /** The synthetic instance key for a pre-v5 (id-less) PLAN stream — the migration key. `` cannot appear * in a JSON string token boundary a name would collide on, and the `#<ordinal>` suffix disambiguates same-name * instances by their session order. Never leaks onto the bus; it is a projection-time grouping key only. */ function sessionKey(name: string, ordinal: number): string { return `${name}#${ordinal}`; } /** * Group a bus's PLAN events into the distinct Plan instances they trace (see the module header). Pure and * deterministic. Non-PLAN events are ignored. The bus is expected in seq order (as every well-formed bus is); * events are folded in the order given. */ export function groupPlanInstances(bus: readonly BusEvent[]): PlanInstanceGroup[] { interface Group { plan: string; instance: string; events: PlanEvent[]; firstSeq: number; } const byKey = new Map<string, Group>(); const order: string[] = []; // Sessionization bookkeeping (pre-v5 path only): the currently-open instance per name + whether it has // reached a terminal `done`, plus the running ordinal for that name. const openByName = new Map<string, { key: string; terminal: boolean }>(); const ordinalByName = new Map<string, number>(); for (const e of bus) { if (e.stream !== "PLAN") continue; const pe = e as PlanEvent; const name = pe.plan; let key: string; if (pe.plan_instance !== undefined) { // v5: the exact minted identity — no inference. key = pe.plan_instance; } else { // pre-v5: explicit migration by lifecycle sessionization. const open = openByName.get(name); let openedNew: boolean; if (open === undefined || open.terminal) { const ordinal = (ordinalByName.get(name) ?? -1) + 1; ordinalByName.set(name, ordinal); key = sessionKey(name, ordinal); openedNew = true; } else { key = open.key; openedNew = false; } // The freshly-opened instance's terminal flag is derived from the CURRENT event alone — never carried // from the still-terminal predecessor (which would immediately re-stamp the replacement terminal and // mint a phantom instance per subsequent same-name event, kestrel-22j.15). openByName.set(name, { key, terminal: (openedNew ? false : open!.terminal) || pe.state === "done" }); } let g = byKey.get(key); if (g === undefined) { g = { plan: name, instance: key, events: [], firstSeq: pe.seq }; byKey.set(key, g); order.push(key); } g.events.push(pe); } return order .map((k) => byKey.get(k)!) .sort((a, b) => (a.plan < b.plan ? -1 : a.plan > b.plan ? 1 : a.firstSeq - b.firstSeq)) .map((g) => ({ plan: g.plan, instance: g.instance, events: g.events })); }