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
text/typescript
/**
* # 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}