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.
155 lines (154 loc) • 10.5 kB
JavaScript
/**
* # engine/validate — the arm-level validation surface for consumers with no live core (kestrel-bc38.2)
*
* `validateArm(document, ctx)` runs BOTH gates a live open runs — the grammar (`parse`, ADR-0001) AND
* the engine's arm-time integrity ({@link PlanEngine.armDocument}: the kestrel-mte unknown-series
* refusal, envelope construction) — over an authored document WITHOUT running a session (no bus, no
* tick loop, no fill engine, nothing graded). It returns tiered diagnostics instead of throwing, so a
* caller can preview an authoring before committing it.
*
* ## Why one surface (the parse≠arm hole, kestrel-7tbi / p48s)
* Parse-green is NOT arm-green: case is a VOCABULARY fact, not a grammar fact, so a document that
* parses cleanly can still name a series that resolves UNKNOWN forever (`VELOCITY` for the windowed
* metric `velocity`) — it arms then silently never fires. Before this surface every consumer hand-rolled
* its own preview (`fixedPlanAgent`'s construct-time `parse`; the docs arm-gate; the hosted `/validate`
* route, which historically only PARSED — the 422≠run gap). Routing those through ONE function makes the
* diagnostic BYTE-IDENTICAL whether it reaches an agent as a 422 or a human as a failed run: the
* arm-refusal message a consumer shows here is the very message {@link PlanEngine.armDocument} raises on
* the graded path (returned as a coded `unknown-series` refusal, OSS-ADR-0045), because this function
* drives that exact path.
*
* ## What this surface does NOT cover — the fresh-engine horizon (do not "migrate" day.ts here)
* This validates a document against an EMPTY engine, so it sees only the refusals that are knowable
* from the document + context ALONE. It CANNOT see any guard whose input is the LIVE standing book —
* above all the F4 same-name collision guard, which rejects a revision re-declaring a name still owned
* by a live plan (`PlanEngine.armDocument`, RUNTIME §8). A fresh engine owns no plans, so F4 is
* structurally unreachable from here, and two same-name plans in ONE document report `armed: true`
* (F4 fires on a name colliding with a PRIOR record, not on an intra-document duplicate).
* Consequently `day.ts` keeps its own preview through the live core (`previewSupersede` →
* {@link PlanEngine.supersedeArmRejection}): routing it through `validateArm` would SILENTLY DROP F4
* and let a doomed revision tear down the standing book (the p48s day-swallow bug). This function is
* the shared preview for consumers WITHOUT a live core, not a replacement for a live-core preview.
*
* Pure and deterministic (no clock, no RNG, no IO): a fresh throwaway engine over a no-op Gate/Bus and
* a stub series provider — arm reads the phonebook, never a live quote — so the same document + same
* context yields the same diagnostics. Fail-closed: a document only reports `armed` when it would
* ACTUALLY arm (never merely parse), and an under-specified context is REFUSED, never defaulted
* through (see {@link ArmContext.instruments}).
*/
import { KestrelParseError, parse } from "../lang/index.js";
import { CanonicalSeriesProvider, CanonicalState, FakeOrgFacts } from "../series/index.js";
import { PlanEngine } from "./plans.js";
/** Normalize a parsed node into a module (a bare statement becomes a one-statement module), matching
* the driver's own `toModule` (`src/session/sim.ts`) so this validates exactly what the runtime arms. */
function toModule(node) {
return node.kind === "module" ? node : { kind: "module", imports: [], statements: [node] };
}
/** A no-op Gate — arm submits no orders (it only registers plans + previews their integrity), so this
* is never called; it exists to satisfy the engine's construction contract. */
const ARM_GATE = {
submit(intent) {
return intent.ref;
},
cancel() { },
};
/** A no-op Bus — `armDocument` ALSO emits its arm-time regime-block notice on the bus (the run-time
* plan-lifecycle trace); that emit is swallowed here because a preview has no live frame. The SAME
* notice is returned as DATA on {@link ArmReport.notices} (kestrel-hk9u), which is how this bus-less
* surface still recovers it — see the `report.notices` handling below. */
const ARM_BUS = { emit() { } };
/**
* Validate an authored document through BOTH the grammar and the engine's arm-time integrity WITHOUT
* running a session, returning tiered diagnostics ({@link ArmValidation}). The shared preview for
* consumers that have no live core — the docs arm-gate, the hosted `/validate` route, an agent's
* construct-time check — so the diagnostic is byte-identical whether an agent sees a 422 or a human
* sees a failed run. NOT a replacement for a live-core preview: it cannot see the F4 same-name
* collision guard, so `day.ts` keeps `previewSupersede` (see the module header).
*
* Order (fail-closed, first refusal wins): (1) `context` — an under-specified {@link ArmContext} (no
* instruments) is refused outright, because a verdict would have to come from an invented session;
* (2) `parse` — a grammar escape returns a `parse`-tier diagnostic and stops; (3) `armDocument` on a
* fresh throwaway engine built from `ctx` — the mte unknown-series refusal and envelope construction —
* where any throw returns an `arm`-tier diagnostic carrying the thrown message verbatim. A clean pass
* through all three returns `{ armed: true, diagnostics: [], warnings }` — `warnings` carries any
* non-fatal arm notice (a regime gate whose scope the context never writes, kestrel-hk9u) and never
* flips `armed`.
*/
export function validateArm(document, ctx) {
// (1) The caller's context, BEFORE the document: with no instruments there is no session to validate
// against, and arming a throwaway engine on an invented empty-symbol stub would hand back a
// permissive `armed: true` for a session that does not exist. Refuse (fail-closed, AGENTS.md: never
// a silent default) — the caller must supply the specs its session actually resolved.
if (ctx.instruments.length === 0) {
return {
armed: false,
warnings: [],
diagnostics: [
{
tier: "context",
message: "validateArm: no instruments in the arm context — arm reads instrument contract facts " +
"(multiplier, strike step), so a document cannot be validated against an empty session. " +
"Pass the session's resolved specs (`resolveSpecs(meta)`, src/session/sim.ts). Refused " +
"rather than armed against an invented instrument (fail-closed, RUNTIME §8).",
},
],
};
}
let mod;
try {
mod = toModule(parse(document));
}
catch (err) {
// A grammar escape is a parse-tier refusal — the exact `KestrelParseError` message a live open
// would raise (ADR-0001). Any non-parse throw is re-raised (a real bug, never a silent default).
if (err instanceof KestrelParseError)
return { armed: false, warnings: [], diagnostics: [{ tier: "parse", message: err.message }] };
throw err;
}
// (3) The REAL arm path (kestrel-7tbi/p48s): a fresh engine over a no-op gate/bus + a stub series
// provider (arm reads the phonebook, not a live quote). `armDocument` surfaces the SAME message the
// graded path raises (a coded `unknown-series` refusal on the report), so the `arm`-tier diagnostic
// is byte-identical to a run's refusal.
// The signal pick mirrors a driver's exactly (`src/session/sim.ts`); the `!` is sound because the
// empty context was refused above — no invented fallback symbol.
const signal = ctx.instruments.find((s) => s.role === "signal") ?? ctx.instruments[0];
const provider = new CanonicalSeriesProvider(new CanonicalState({ instrument: signal.symbol }), new FakeOrgFacts());
const engine = new PlanEngine({
instruments: [...ctx.instruments],
seriesProvider: provider,
gate: ARM_GATE,
busWriter: ARM_BUS,
rUsd: 1000,
now: 0,
// The closed-world regime vocabulary of the arm context (kestrel-hk9u): the caller's `tapeRegimeScopes`
// when known (`--bus` derives them from the tape's META), else the EMPTY set — a preview names no live
// future, so a regime gate on a scope the context does not carry gets the DEFINITIVE dead-on-arrival
// notice a real batch run raises (byte-identical), rather than being silently swallowed as "arms clean".
tapeRegimeScopes: ctx.tapeRegimeScopes ?? [],
...(ctx.registry !== undefined ? { registry: ctx.registry } : {}),
});
let report;
try {
report = engine.armDocument(mod);
}
catch (err) {
// The one arm THROW — `plan-name-in-use` (F4), an `ArmError` carrying its `.code` — is
// structurally unreachable on a FRESH engine (it owns no prior plan to collide with), but catch
// defensively and surface it as an arm-tier diagnostic carrying the thrown message verbatim.
return { armed: false, warnings: [], diagnostics: [{ tier: "arm", message: err instanceof Error ? err.message : String(err) }] };
}
// The non-fatal arm NOTICES are DATA on the report (kestrel-hk9u) — a regime gate whose scope the
// arm context never writes. Each surfaces as an `arm`-tier WARNING carrying the notice's `message`
// VERBATIM — byte-identical to the plan-lifecycle reason a real batch run emits (validation=execution),
// because both come from the SAME string built in `armDocument`. A warning never flips `armed`: the
// document IS integrity-clean, it just can never fire on this tape.
const warnings = (report.notices ?? []).map((n) => ({ tier: "arm", message: n.message }));
// The per-statement `unknown-series` refusals are DATA on the report (OSS-ADR-0045), not a throw.
// Each surfaces as an arm-tier diagnostic carrying the refusal's `message` VERBATIM — byte-identical
// to the message the graded path would raise (the 422≡run contract), because both come from the same
// `#unknownSeriesRejection`.
if (report.refusals.length > 0) {
return { armed: false, warnings, diagnostics: report.refusals.map((r) => ({ tier: "arm", message: r.message })) };
}
return { armed: true, diagnostics: [], warnings };
}