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.

107 lines (106 loc) 6.4 kB
/** * # session/harness/bounded-numerics — the BOUNDED-NUMERICS emission guard (fail-closed, never scored) * * A strict schema / constrained-decoding grammar can force a syntactically-VALID but DEGENERATE number: a * `json_schema` `qty:integer` probe forced digits and the model looped to an **8,173-digit integer** — * valid JSON, garbage. Such an emission is a GENERATION FAILURE, not a decision. It classifies INVALID * (fail-closed) and is NEVER scored — the same "reject unknown input, never default it" rule the * empty→INVALID guard already enforces in {@link file://./live-agent.ts} (empty/zero-token → `invalid`). * * This is the LOCAL port of the HOSTED adapter's `invalid-degenerate` class * (`scripts/bench/hosted/hosted-adapter.ts` `checkBoundedNumerics` / `classifyEmission`), lifted here so the * local live harness and the hosted eval adapter share ONE convention: a degenerate numeric is rejected * identically on both lanes. Pure: no clock, no RNG, no I/O. * * Two cheap, load-bearing checks: * 1. **pre-parse raw-string digit cap** — any run of >{@link MAX_NUMERIC_DIGITS} consecutive digits in the * RAW emission is a decoding loop (the 8,173-digit integer), caught BEFORE `JSON.parse` silently * coerces it to `Infinity` (which `Number.isFinite` would then reject anyway) OR — the sneakier case — * to a FINITE-but-absurd float (e.g. a 15-digit run → ~1e15) that a bare finiteness check would SCORE * as a real order. The raw cap catches BOTH before the coercion hides them. * 2. **per-field range sanity** — every action's numeric field (`order.qty` / `order.strike` / * `scheduleWake.at.minutes`) must be finite and inside {@link NUMERIC_FIELD_BOUNDS}. A `NaN`/`Infinity`/ * absurd value is degenerate. Bounds are generous — they catch garbage, not judgment. */ /** Max run of consecutive digits tolerated in a RAW emission. Any longer run is a constrained-decoding * digit loop, not a number (the 8,173-digit integer). Matches the hosted adapter's cap. */ export const MAX_NUMERIC_DIGITS = 12; /** Per-field sanity ranges for the action-schema numeric fields (finite + in-range; a NaN/Inf/absurd value * is a degenerate emission, never scored). Generous — they catch garbage, not judgment. Mirrors the hosted * adapter's `NUMERIC_FIELD_BOUNDS` and maps onto the Kestrel action schema (`src/session/agent.ts`): * `placeOrder.order.{qty,strike}` and `scheduleWake.at.minutes` (the `inMinutes` variant). */ const NUMERIC_FIELD_BOUNDS = { /** contracts (option) or shares (equity) — a positive, finite, non-absurd count. */ qty: { min: 0, max: 1e7, exclusiveMin: true }, /** an option strike — positive, finite, bounded well above any real underlying. */ strike: { min: 0, max: 1e7, exclusiveMin: true }, /** scheduleWake `inMinutes` — non-negative minutes within a sane horizon. */ minutes: { min: 0, max: 1e7, exclusiveMin: false }, }; function inBounds(v, b) { if (typeof v !== "number" || !Number.isFinite(v)) return false; if (b.exclusiveMin ? v <= b.min : v < b.min) return false; return v <= b.max; } /** Extract + JSON.parse the single top-level object from a reply. Mirrors `prompt.ts:extractJsonObject` * (not exported) and the hosted adapter's `extractObject`: tolerate a ```json fence and surrounding prose, * take the first `{` to the last `}`. Returns `null` when there is no parseable object. */ function extractObject(reply) { const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(reply); const body = (fenced !== null ? fenced[1] : reply).trim(); const start = body.indexOf("{"); const end = body.lastIndexOf("}"); if (start === -1 || end === -1 || end < start) return null; try { const v = JSON.parse(body.slice(start, end + 1)); return typeof v === "object" && v !== null && !Array.isArray(v) ? v : null; } catch { return null; } } /** * The bounded-numerics guard. Returns `{ ok: true }` when the emission carries no degenerate number; * otherwise `{ ok: false, reason }` naming the defect. A parse failure here is NOT this guard's concern * (the turn parser owns unparseable input) — an unparseable body simply passes the field check and is * rejected by the parser downstream. See the module header for the two checks. */ export function checkBoundedNumerics(text) { // 1. Pre-parse raw digit-run cap — catches the digit loop BEFORE JSON.parse coerces it away. const longRun = /\d{13,}/.exec(text); if (longRun !== null) { return { ok: false, reason: `degenerate numeric: a ${longRun[0].length}-digit run exceeds the ${MAX_NUMERIC_DIGITS}-digit cap (constrained-decoding digit loop, not a number)`, }; } // 2. Per-field range sanity on the parsed turn (finite + inside NUMERIC_FIELD_BOUNDS). const obj = extractObject(text); const actions = obj !== null && Array.isArray(obj.actions) ? obj.actions : null; if (actions === null) return { ok: true }; // no turn shape to range-check; the parser owns that. for (const a of actions) { if (typeof a !== "object" || a === null) continue; const act = a; const order = act.order; if (order !== undefined && order !== null && typeof order === "object") { if ("qty" in order && !inBounds(order.qty, NUMERIC_FIELD_BOUNDS.qty)) { return { ok: false, reason: `degenerate numeric: order.qty=${JSON.stringify(order.qty)} out of range (0,${NUMERIC_FIELD_BOUNDS.qty.max}]` }; } if ("strike" in order && order.strike !== undefined && !inBounds(order.strike, NUMERIC_FIELD_BOUNDS.strike)) { return { ok: false, reason: `degenerate numeric: order.strike=${JSON.stringify(order.strike)} out of range (0,${NUMERIC_FIELD_BOUNDS.strike.max}]` }; } } const at = act.at; if (at !== undefined && at !== null && typeof at === "object" && at.kind === "inMinutes" && "minutes" in at) { if (!inBounds(at.minutes, NUMERIC_FIELD_BOUNDS.minutes)) { return { ok: false, reason: `degenerate numeric: scheduleWake.at.minutes=${JSON.stringify(at.minutes)} out of range [0,${NUMERIC_FIELD_BOUNDS.minutes.max}]` }; } } } return { ok: true }; }