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.

1,288 lines (1,197 loc) 81.1 kB
/** * # parse — recursive-descent parser (canonical text -> the typed object model) * * The inverse projection of {@link ./print.ts} (ADR-0004): `parse(print(x))` deep-equals * `x`, and `print(parse(text))` is byte-stable. The parser reads the {@link LineNode} tree * the {@link ./lex.ts} lexer produces — a statement's header is one line, its clauses are * the lines nested beneath, and a continued clause folds in via {@link flatten}. * * Design commitments: * - **Fail closed** (ARCHITECTURE §6): every escape is a {@link KestrelParseError} with * `line`/`col` and an instructive message (the text is read by LLM agents — it names * what was expected and lists the legal alternatives). No silent clause-dropping. * - **Registry-open names** (CONTEXT: Registry): any well-formed identifier parses as a * series reference; resolution happens at arm time, never at parse time. * - **`atomic` is refused, not faked** (ADR-0005): the keyword parses to a loud rejection. * - **USING defaults thread through** (ARCHITECTURE §2): a statement inside a module with * an ambient `USING` is fully qualified by merging the ambient with any explicit clause, * so the elided form round-trips. */ import type { AlsoTicket, Anchor, AnchorName, Arbitration, ArmClause, Baseline, BookStatement, Budget, CancelIfClause, Citation, CommentLayer, LineComment, Comparison, Counterfactual, Coverage, CrossEvent, DoTicket, Duration, EscStage, ExitClause, ExpirySelector, FillEvent, GradeDimension, GradeStatement, GradeSubject, HeldQuantifier, ImportDecl, Instrument, InvalidateClause, KestrelNode, Leg, Module, Operand, OrderPolicy, PaneArg, PaneRef, PathSegment, PlanClause, PlanStatement, PodStatement, PriceExpr, Provenance, ProvenanceTier, Quantity, RegimeGate, RegimeTagBinding, ReloadClause, RiskLine, SeriesRef, Standing, Statement, StrikeSpec, TimeOfDay, TimeUnit, TpClause, TpTarget, Trigger, Ttl, Using, ViewStatement, WakeBudget, WakeStatement, Window, } from "./ast.ts"; import { flatten, KestrelParseError, lex, type LineNode, type Token } from "./lex.ts"; import { ANCHOR_NAMES, ANCHORS, CITATION_ALGOS, CLOCK_STOP_KEYWORD, CMP_FROM_PUNCT, FILL_EVENTS, GRADE_WHATS, EXPIRY_PREFIX, HELD_STOP_KEYWORD, ORDINAL_PREFIX, ORDINALS, PROV_RANK, PROV_TIERS, STANDINGS, TIME_UNITS, TIME_UNITS_LIST, } from "./vocab.ts"; import { ATOMIC_MESSAGE, bandMessage, budgetMessage, citationBadHashMessage, CITATION_INLINE_BODY_MESSAGE, citationOnMessage, elevatesProvenance, findMarkInTrigger, HELD_CROSS_MESSAGE, isCitationHash, isSimpleSeries, markMessage, provenanceMessage, } from "./validate.ts"; export { KestrelParseError } from "./lex.ts"; // The closed vocabularies (anchors, time units, fill events, standings, provenance tiers, // grade subjects, comparison ops, ordinals) live in ./vocab.ts, derived from the ast.ts // unions; the six doctrine invariants live in ./validate.ts. This file reads both — it keeps // no private copy — so parser and printer can never drift from the types or from each other. const CLAUSE_KEYWORDS = "WHEN, USING, DO, ALSO, RELOAD, TP, EXIT, INVALIDATE, CANCEL-IF, or ARM"; /** * The clause keywords that live INSIDE a PLAN block (indented beneath the `PLAN <name>` header). * Used ONLY to make diagnostics repair-guiding when a live author (a) collapses two clauses onto one * line (`WHEN … DO …`) or (b) hoists a clause to the top level (a bare `WHEN …` statement) — the two * dominant live-model authoring mistakes (dry-run-1, docs/results/dry-run-1-live-baseline.md, 45 of * ~54 escapes). PURELY DIAGNOSTIC: what the parser accepts/rejects is unchanged (a trailing/top-level * clause keyword was always an error) — only the MESSAGE on rejection names the fix. No grammar * contract change, no honesty/fail-closed-guard change. */ const PLAN_CLAUSE_KEYWORD_SET: ReadonlySet<string> = new Set([ "WHEN", "USING", "DO", "ALSO", "RELOAD", "TP", "EXIT", "INVALIDATE", "CANCEL-IF", "ARM", ]); /** * Natural-language trigger verbs a strong model reaches for in the infix `<operand> <verb> …` * position where Kestrel's ONLY verbs are `crosses`/`touches` (or a `<`/`>` comparison). Observed * live (finding #1): `spot breaks below …`, `spot dips to …`. PURELY DIAGNOSTIC — none of these is * an accepted token; the parser still rejects them. The MESSAGE names the bad verb and steers to the * canonical crossing form, mirroring the WHEN/DO-collapse hint. No grammar-contract change. */ const TRIGGER_VERB_SYNONYMS: ReadonlySet<string> = new Set([ "breaks", "break", "dips", "dip", "rises", "rise", "falls", "fall", "moves", "move", "drops", "drop", "climbs", "climb", "jumps", "jump", "hits", "hit", "reaches", "reach", "exceeds", "exceed", ]); /** Repair hint for a natural-language trigger verb (diagnostic only — `verb` stays rejected). */ function triggerVerbSynonymMessage(verb: string): string { return ( `unexpected \`${verb}\` in a trigger — Kestrel has no \`${verb}\` trigger verb. ` + `Write a threshold event as \`crosses above|below\` (a strict crossing) or ` + `\`touches above|below\` (at-or-touch), e.g. \`spot crosses below 499.15\` — ` + `or a plain comparison with \`<\`/\`>\`, e.g. \`spot < 499.15\`.` ); } /** * Natural-language price-anchor tokens a model reaches for to express a bounded price where Kestrel's * bounding constructors are `min`/`max`/`lean`. PURELY DIAGNOSTIC — none is an accepted anchor; the * MESSAGE names the bad token and steers to the canonical constructor. * * `cap` USED to live here but has since GRADUATED to an accepted synonym (ADR-0030 measured-grammar * relaxation, kestrel-hvgd): `cap A, B` normalizes to `min(A, B)` in {@link parsePriceAtom}. The * remaining tokens stay rejected-with-a-steer — the cluster for them has not cleared teaching. */ const PRICE_ANCHOR_SYNONYMS: ReadonlySet<string> = new Set(["ceiling", "floor", "limit"]); /** Repair hint for a natural-language price anchor (diagnostic only — `tok` stays rejected). */ function priceAnchorSynonymMessage(tok: string): string { return ( `unexpected \`${tok}\` — Kestrel has no \`${tok}\` price anchor. ` + `To bound a price use \`min(...)\` to cap it or \`max(...)\` to floor it, ` + `or \`lean(a,b,x)\` to lean a fraction between two anchors — ` + `e.g. \`min(fair, 0.95)\` caps the price at 0.95.` ); } function describe(t: Token | undefined): string { if (t === undefined) return "end of line"; if (t.type === "string") return "a string"; if (t.type === "number") return `number ${t.text}`; return `\`${t.text}\``; } // ───────────────────────────────────────────────────────────────────────────── // Comment trivia — carry the byte-stable "why" from the line tree into the AST (ADR-0033) // ───────────────────────────────────────────────────────────────────────────── /** Build a {@link LineComment} for one printed line from its source {@link LineNode} trivia, * or undefined when the line carries no comments. */ function lineCommentOf(node: LineNode, at: number): LineComment | undefined { const leading = node.leading; const trailing = node.trailing; const hasLeading = leading !== undefined && leading.length > 0; if (!hasLeading && trailing === undefined) return undefined; return { at, ...(hasLeading ? { leading } : {}), ...(trailing !== undefined ? { trailing } : {}), }; } /** * Assemble a block's comment sidecar from its header line (ordinal 0), its direct interior * lines in canonical print order (ordinals 1..N), and the header node's `tail`. Child blocks * (a pod's books/sub-pods) own their own trivia and are NOT passed as interior lines. Returns * undefined when nothing is present, so a comment-free statement carries no `comments` field * and stays deep-equal to its builder output. */ function commentLayer(header: LineNode, interior: readonly LineNode[]): CommentLayer | undefined { const lines: LineComment[] = []; const h = lineCommentOf(header, 0); if (h !== undefined) lines.push(h); interior.forEach((n, i) => { const lc = lineCommentOf(n, i + 1); if (lc !== undefined) lines.push(lc); }); const tail = header.tail; const hasTail = tail !== undefined && tail.length > 0; if (lines.length === 0 && !hasTail) return undefined; return { ...(lines.length > 0 ? { lines } : {}), ...(hasTail ? { tail } : {}), }; } /** Spread helper: `{ ...withComments(layer) }` adds a `comments` field only when present. */ function withComments(layer: CommentLayer | undefined): { comments?: CommentLayer } { return layer === undefined ? {} : { comments: layer }; } // ───────────────────────────────────────────────────────────────────────────── // Cursor — a positioned token stream over one logical line // ───────────────────────────────────────────────────────────────────────────── class Cursor { private i = 0; constructor( private readonly toks: readonly Token[], private readonly fallbackLine: number, ) {} peek(k = 0): Token | undefined { return this.toks[this.i + k]; } /** Text of the token at `k` iff it is a word, else undefined (for keyword dispatch). */ wordAt(k = 0): string | undefined { const t = this.toks[this.i + k]; return t !== undefined && t.type === "word" ? t.text : undefined; } atEnd(): boolean { return this.i >= this.toks.length; } private here(): { line: number; col: number } { const t = this.toks[this.i]; if (t !== undefined) return { line: t.line, col: t.col }; const last = this.toks[this.toks.length - 1]; return last !== undefined ? { line: last.line, col: last.end } : { line: this.fallbackLine, col: 1 }; } fail(message: string): never { const { line, col } = this.here(); throw new KestrelParseError(message, line, col); } advance(): Token { const t = this.toks[this.i]; if (t === undefined) this.fail("unexpected end of line"); this.i++; return t; } matchWord(text: string): boolean { return this.wordAt() === text; } optWord(text: string): boolean { if (this.matchWord(text)) { this.i++; return true; } return false; } expectWord(text: string, ctx: string): void { if (!this.matchWord(text)) this.fail(`expected \`${text}\` ${ctx}, got ${describe(this.peek())}`); this.i++; } matchPunct(text: string): boolean { const t = this.peek(); return t !== undefined && t.type === "punct" && t.text === text; } optPunct(text: string): boolean { if (this.matchPunct(text)) { this.i++; return true; } return false; } expectPunct(text: string, ctx: string): void { if (!this.matchPunct(text)) this.fail(`expected \`${text}\` ${ctx}, got ${describe(this.peek())}`); this.i++; } expectString(ctx: string): string { const t = this.peek(); if (t === undefined || t.type !== "string") this.fail(`expected a quoted string ${ctx}, got ${describe(t)}`); this.i++; return t.text; } expectNumber(ctx: string): number { const t = this.peek(); if (t === undefined || t.type !== "number") this.fail(`expected a number ${ctx}, got ${describe(t)}`); this.i++; return Number(t.text); } expectEnd(ctx: string): void { if (!this.atEnd()) this.fail(`unexpected ${describe(this.peek())} after ${ctx}`); } /** * Read an identifier, reassembling the tight hyphen/digit forms the lexer split * (`zoom-1d`, `chase-urgent`). Extends across a maximal run of adjacent word/number * tokens joined by `-`/`_`, stopping at the first gap or non-name token. */ readName(what: string): string { const first = this.peek(); if (first === undefined || first.type !== "word") this.fail(`expected ${what}, got ${describe(first)}`); let text = first.text; let end = first.end; this.i++; for (;;) { const t = this.peek(); if (t === undefined || t.col !== end) break; if (t.type === "punct" && (t.text === "-" || t.text === "_")) { const after = this.peek(1); if (after !== undefined && after.col === t.end && (after.type === "word" || after.type === "number")) { text += t.text + after.text; end = after.end; this.i += 2; continue; } break; } if (t.type === "word" || t.type === "number") { text += t.text; end = t.end; this.i++; continue; } break; } return text; } } // ───────────────────────────────────────────────────────────────────────────── // Repair-guiding clause-end (diagnostic-only — accept/reject set unchanged) // ───────────────────────────────────────────────────────────────────────────── /** * End a PLAN clause line. If a SIBLING clause keyword trails on the same line, fail with a * repair-guiding message naming the fix instead of the bare `unexpected \`DO\` after …`. The * WHEN/DO-on-one-line collapse was the single dominant live-model authoring error (dry-run-1: 35 of * ~54 escapes). Diagnostic only — a trailing clause keyword was always an error; this just teaches * the reader (a model or a human reading the journalled error) how to repair it. */ function expectClauseEnd(c: Cursor, clauseCtx: string): void { const t = c.peek(); if (t !== undefined && t.type === "word" && PLAN_CLAUSE_KEYWORD_SET.has(t.text)) { c.fail( `unexpected \`${t.text}\` after ${clauseCtx} — this looks like two clauses on one line. ` + `Each PLAN clause goes on its OWN line, indented two spaces beneath the \`PLAN <name>\` header: ` + `write \`WHEN …\` and \`DO …\` as SEPARATE lines, never \`WHEN … DO …\` together.`, ); } c.expectEnd(clauseCtx); } // ───────────────────────────────────────────────────────────────────────────── // Lexical readers // ───────────────────────────────────────────────────────────────────────────── function readTimeUnit(c: Cursor, ctx: string): TimeUnit { const units = TIME_UNITS_LIST.join(", "); const u = c.readName(`a time unit (${units}) ${ctx}`); if (!TIME_UNITS.has(u)) c.fail(`unknown time unit \`${u}\`; expected one of ${units}`); return u as TimeUnit; } function readWindow(c: Cursor): Window { const value = c.expectNumber("for a window size"); const unit = readTimeUnit(c, "for a window"); return { kind: "window", value, unit }; } function readDuration(c: Cursor, ctx: string): Duration { const value = c.expectNumber(`for a duration ${ctx}`); const unit = readTimeUnit(c, ctx); return { kind: "duration", value, unit }; } function readTimeOfDay(c: Cursor): TimeOfDay { const hour = c.expectNumber("for the hour of a clock time"); c.expectPunct(":", "in a clock time (HH:MM)"); const minute = c.expectNumber("for the minute of a clock time"); if (!Number.isInteger(hour) || hour < 0 || hour > 23) c.fail(`bad clock hour ${hour}; expected 0..23`); if (!Number.isInteger(minute) || minute < 0 || minute > 59) c.fail(`bad clock minute ${minute}; expected 0..59`); return { kind: "time-of-day", hour, minute }; } /** A quantity: an optional sign, a number, an optional unit (`R`, `c`, `%`, `bp`). */ function readQuantity(c: Cursor, ctx: string): Quantity { let sign = 1; if (c.matchPunct("+")) c.advance(); else if (c.matchPunct("-")) { c.advance(); sign = -1; } const value = sign * c.expectNumber(ctx); const unit = readOptUnit(c); return unit === undefined ? { kind: "quantity", value } : { kind: "quantity", value, unit }; } /** A cross re-arm band width: a positive quantity (the band>0 doctrine invariant lives in * ./validate.ts; here it fails closed on the value with position). */ function readBand(c: Cursor): Quantity { const q = readQuantity(c, "for a cross re-arm band width (`band 5c`)"); if (!(q.value > 0)) c.fail(bandMessage(quantityText(q))); return q; } /** A human-readable rendering of a quantity for error messages (parse-only; does not need * to be canonical). */ function quantityText(q: Quantity): string { return q.unit === undefined ? String(q.value) : `${q.value}${q.unit}`; } function readOptUnit(c: Cursor): Quantity["unit"] | undefined { if (c.matchPunct("%")) { c.advance(); return "%"; } const w = c.wordAt(); if (w === "R" || w === "c" || w === "bp") { c.advance(); return w; } return undefined; } // ───────────────────────────────────────────────────────────────────────────── // Series references & operands // ───────────────────────────────────────────────────────────────────────────── function parseSeries(c: Cursor): SeriesRef { const segments: PathSegment[] = []; for (;;) { const name = c.readName("a series name"); // A selector is `(word...)`; a window is `(number...)` and belongs to the whole series. if (c.matchPunct("(") && c.peek(1)?.type === "word") { c.advance(); // ( const q = c.wordAt(); let selector: PathSegment["selector"]; if (q === "any" || q === "all") { c.advance(); selector = { kind: "sel-quant", q }; } else { selector = { kind: "sel-name", name: c.readName("a selector name inside `(...)`") }; } c.expectPunct(")", "to close a series selector"); segments.push({ name, selector }); } else { segments.push({ name }); } if (c.optPunct(".")) continue; break; } let window: Window | undefined; if (c.matchPunct("(") && c.peek(1)?.type === "number") { c.advance(); // ( window = readWindow(c); c.expectPunct(")", "to close a series window"); } return window === undefined ? { kind: "series", segments } : { kind: "series", segments, window }; } /** An operand of a comparison/cross: a number/quantity, a baseline, or a series. */ function parseOperand(c: Cursor): Operand { const t = c.peek(); if (t !== undefined && (t.type === "number" || (t.type === "punct" && (t.text === "+" || t.text === "-")))) { // signed number: sigma baseline (`3sigma`), a quantity, or a bare number let sign = 1; if (c.matchPunct("+")) c.advance(); else if (c.matchPunct("-")) { c.advance(); sign = -1; } const value = c.expectNumber("for an operand"); if (c.matchWord("sigma")) { c.advance(); return { kind: "baseline", stat: "sigma", n: value }; } const unit = readOptUnit(c); return unit === undefined ? { kind: "quantity", value: sign * value } : { kind: "quantity", value: sign * value, unit }; } const w = c.wordAt(); if (w !== undefined && /^p\d+$/.test(w)) { c.advance(); return { kind: "baseline", stat: "p", n: Number(w.slice(1)) }; } if (w !== undefined) return parseSeries(c); c.fail(`expected an operand (a series name, a number, or a baseline like p99/3sigma), got ${describe(t)}`); } // `isSimpleSeries` and the EXIT-may-not-condition-on-a-mark walk (`findMarkInTrigger`) live in // ./validate.ts, shared with the builder/printer so a mark-conditioned EXIT is refused on every // surface; this file imports them. // ───────────────────────────────────────────────────────────────────────────── // Trigger algebra (OR < AND < NOT < postfix < atom) — mirrors print.ts precedence // ───────────────────────────────────────────────────────────────────────────── function parseTrigger(c: Cursor): Trigger { return parseOr(c); } function parseOr(c: Cursor): Trigger { const terms = [parseAnd(c)]; while (c.optWord("OR")) terms.push(parseAnd(c)); return terms.length === 1 ? terms[0]! : { kind: "or", terms }; } function parseAnd(c: Cursor): Trigger { const terms = [parseNot(c)]; while (c.optWord("AND")) terms.push(parseNot(c)); return terms.length === 1 ? terms[0]! : { kind: "and", terms }; } function parseNot(c: Cursor): Trigger { if (c.optWord("NOT")) return { kind: "not", term: parseNot(c) }; return parsePostfix(c); } function parsePostfix(c: Cursor): Trigger { let inner = parseAtom(c); for (;;) { if (c.matchWord("held")) { // Fail closed at the `held` token: holding a cross (a bare edge) is the sfg8 anti-pattern. if (inner.kind === "cross") c.fail(HELD_CROSS_MESSAGE); c.advance(); inner = { kind: "break-hold", inner, dur: readDuration(c, "after `held`") }; } else if (c.matchWord("within")) { c.advance(); inner = { kind: "within", inner, dur: readDuration(c, "after `within`") }; } else if (c.matchWord("until")) { // The thesis temporal envelope (kestrel-rtf): `until`/`at` are postfix combinators over // the SAME predicate surface as `within` — siblings, not a second predicate language — // each taking a wall-clock time via the existing `readTimeOfDay`. c.advance(); inner = { kind: "until", inner, at: readTimeOfDay(c) }; } else if (c.matchWord("at")) { c.advance(); inner = { kind: "at", inner, at: readTimeOfDay(c) }; } else break; } return inner; } function ordinalValue(c: Cursor): number | undefined { const w = c.wordAt(); if (w !== undefined) { const idx = ORDINALS.indexOf(w); if (idx >= 1) return idx; } // numeric ordinal: `<n>th` const t = c.peek(); if (t !== undefined && t.type === "number" && c.wordAt(1) === "th") return Number(t.text); return undefined; } function parseAtom(c: Cursor): Trigger { if (c.optPunct("(")) { const t = parseTrigger(c); c.expectPunct(")", "to close a parenthesized trigger"); return t; } const ord = ordinalValue(c); if (ord !== undefined) { // consume the ordinal (a word, or `<number> th`) if (c.wordAt() !== undefined) c.advance(); else { c.advance(); // number c.advance(); // th } return { kind: "nth", ordinal: ord, event: parseAtom(c) }; } if (c.matchWord("phase")) { c.advance(); return { kind: "phase", phase: c.readName("a phase name after `phase`") }; } if (c.matchWord("time")) { c.advance(); return parseTimeWindow(c); } // 0DTE time-exit stops (docs/results/fomc-options-axis) — a bare, LEADING time-clock, distinct // from the `<inner> held <dur>` break-hold postfix (which always has an inner atom before `held`, // so it never reaches this leading position). `held <dur>` = a TimeHeldStop; `clockET <clock>` = a // wall-clock ClockStop. A leading `held` is intercepted ONLY when a duration number follows, so a // bare `held` identifier keeps its old reading (a structural event named `held`) — strict superset. if (c.matchWord(HELD_STOP_KEYWORD) && c.peek(1)?.type === "number") { c.advance(); return { kind: "held-stop", dur: readDuration(c, "after `held` (a 0DTE time-held stop, `held 90m`)") }; } if (c.matchWord(CLOCK_STOP_KEYWORD)) { c.advance(); return { kind: "clock-stop", at: readTimeOfDay(c) }; } const w = c.wordAt(); if (w !== undefined && FILL_EVENTS.has(w)) { c.advance(); const event = w as FillEvent["event"]; if (c.optWord("leg")) { const leg = c.expectNumber("for a fill-event leg index"); return { kind: "fill", event, leg }; } return { kind: "fill", event }; } return parseOperandLed(c); } function parseTimeWindow(c: Cursor): Trigger { if (c.optWord("after")) return { kind: "time-window", from: readTimeOfDay(c) }; if (c.optWord("before")) return { kind: "time-window", to: readTimeOfDay(c) }; const from = readTimeOfDay(c); c.expectPunct("..", "in a time window (`time HH:MM..HH:MM`)"); const to = readTimeOfDay(c); return { kind: "time-window", from, to }; } function parseOperandLed(c: Cursor): Trigger { const left = parseOperand(c); if (c.matchWord("crosses") || c.matchWord("touches")) { // `touches` is the at-or-touch predicate (>=/<=); `crosses` is a strict crossing (>/<). const touch = c.advance().text === "touches"; const kw = touch ? "touches" : "crosses"; // `left crosses|touches outside LO-HI` — a RANGE-BREAKOUT (ADR-0030 measured-grammar widening, // kestrel-hvgd): fire on a breakout past EITHER edge of the band. It DESUGARS to the OR of the // two hardened single-edge crosses — `left <verb> above HI OR left <verb> below LO` — so it adds // NO new runtime node (the engine already evaluates OR + cross) and inherits the sfg8 hardening. // `touches outside` keeps the at-or-touch predicate on both edges; `crosses outside` is strict. if (c.optWord("outside")) { const lo = parseOperand(c); c.expectPunct("-", "between the low and high of a breakout range (`crosses outside LO-HI`)"); const hi = parseOperand(c); const touchOpt = touch ? { touch: true as const } : {}; const above: CrossEvent = { kind: "cross", left, dir: "above", right: hi, ...touchOpt }; const below: CrossEvent = { kind: "cross", left, dir: "below", right: lo, ...touchOpt }; return { kind: "or", terms: [above, below] }; } let dir: CrossEvent["dir"]; if (c.optWord("above")) dir = "above"; else if (c.optWord("below")) dir = "below"; else c.fail(`expected \`above\`, \`below\`, or \`outside LO-HI\` (a breakout) after \`${kw}\`, got ${describe(c.peek())}`); const right = parseOperand(c); // Optional re-arm band: `... band 5c`. The band must be a positive width (a zero/negative // band can never re-arm) — fail closed on it rather than arm a cross that can't re-arm. let band: Quantity | undefined; if (c.optWord("band")) band = readBand(c); return { kind: "cross", left, dir, right, ...(touch ? { touch } : {}), ...(band !== undefined ? { band } : {}), } satisfies CrossEvent; } const opTok = c.peek(); if (opTok !== undefined && opTok.type === "punct" && opTok.text in CMP_FROM_PUNCT) { c.advance(); const op = CMP_FROM_PUNCT[opTok.text]!; return { kind: "cmp", op, left, right: parseOperand(c) } satisfies Comparison; } // A natural-language trigger verb in the infix position (where only crosses/touches/`<`/`>` are // legal) — name it and steer to the canonical crossing form rather than swallowing the operand as a // bare event and failing downstream with a bare `unexpected \`breaks\` …`. Diagnostic only. const infix = c.wordAt(); if (infix !== undefined && TRIGGER_VERB_SYNONYMS.has(infix)) c.fail(triggerVerbSynonymMessage(infix)); // otherwise this is a structural event named by a bare identifier if (isSimpleSeries(left)) { const name = left.segments[0]!.name; if (c.optWord("of")) return { kind: "event", name, of: parseOperand(c) }; return { kind: "event", name }; } c.fail( `expected a comparison (>, >=, <, <=, ==, !=), \`crosses above/below\`, or \`of\` ` + `after this operand, got ${describe(c.peek())}`, ); } // ───────────────────────────────────────────────────────────────────────────── // Price expressions // ───────────────────────────────────────────────────────────────────────────── function parsePriceAtom(c: Cursor): PriceExpr { const t = c.peek(); if (t !== undefined && t.type === "word") { if (ANCHORS.has(t.text)) { c.advance(); return { kind: "anchor", name: t.text as AnchorName } satisfies Anchor; } if (t.text === "min" || t.text === "max") { c.advance(); c.expectPunct("(", `after \`${t.text}\``); const args: PriceExpr[] = [parsePrice(c)]; while (c.optPunct(",")) args.push(parsePrice(c)); c.expectPunct(")", `to close \`${t.text}(...)\``); return { kind: "price-fn", fn: t.text, args }; } // `cap A, B` — a measured-grammar synonym (ADR-0030, kestrel-hvgd) for `min(A, B)`. Models // author `@ cap fair, 0.95` to CAP a price at a ceiling — exactly what `min` does. Parenless, // comma-separated, ≥2 operands; it NORMALIZES to the canonical `min(...)` price-fn so the // printer emits only `min` (one canonical form) and round-trip stays byte-stable (ADR-0004). // A strict superset: `cap` in a PRICE position was rejected-with-a-steer before, never accepted, // and the order-policy `cap <price>` list keyword is consumed one rung up (parseOrderPolicy) // before this atom is ever reached, so no previously-parsing form changes meaning. if (t.text === "cap") { c.advance(); const args: PriceExpr[] = [parsePrice(c)]; while (c.optPunct(",")) args.push(parsePrice(c)); if (args.length < 2) { c.fail( "`cap` needs a ceiling — write `cap <price>, <ceiling>` (it normalizes to " + "`min(<price>, <ceiling>)`), e.g. `cap fair, 0.95`", ); } return { kind: "price-fn", fn: "min", args }; } if (t.text === "lean") { c.advance(); c.expectPunct("(", "after `lean`"); const a = parsePrice(c); c.expectPunct(",", "between lean arguments"); const b = parsePrice(c); c.expectPunct(",", "between lean arguments"); const x = c.expectNumber("for the lean fraction"); c.expectPunct(")", "to close `lean(...)`"); return { kind: "lean", a, b, x }; } } if (t !== undefined && t.type === "number") { c.advance(); return { kind: "price-abs", value: Number(t.text) }; } // A natural-language price-anchor token (`ceiling`/`floor`/`limit`) — name it and steer to the // canonical bounding constructor rather than the bare `got \`ceiling\``. Diagnostic only. (`cap` // is no longer here: it graduated to an accepted `min` synonym above — kestrel-hvgd.) if (t !== undefined && t.type === "word" && PRICE_ANCHOR_SYNONYMS.has(t.text)) { c.fail(priceAnchorSynonymMessage(t.text)); } c.fail( `expected a price: an anchor (${ANCHOR_NAMES.join(", ")}), a number, or min/max/lean(...), ` + `got ${describe(t)}`, ); } function parsePrice(c: Cursor): PriceExpr { let base = parsePriceAtom(c); for (;;) { const t = c.peek(); const next = c.peek(1); if (t !== undefined && t.type === "punct" && (t.text === "+" || t.text === "-") && next?.type === "number") { c.advance(); // sign const amount = c.expectNumber("for a price offset"); let unit: "c" | "%"; if (c.optPunct("%")) unit = "%"; else if (c.matchWord("c")) { c.advance(); unit = "c"; } else { c.fail(`expected \`c\` or \`%\` after a price offset amount, got ${describe(c.peek())}`); } base = { kind: "price-offset", base, sign: t.text as "+" | "-", amount, unit }; continue; } break; } return base; } // ───────────────────────────────────────────────────────────────────────────── // Legs, order policy, held quantifiers // ───────────────────────────────────────────────────────────────────────────── function parseStrike(c: Cursor): StrikeSpec { if (c.optWord("atm")) return { kind: "strike-atm" }; if (c.matchPunct("+") || c.matchPunct("-")) { const sign = c.advance().text === "-" ? -1 : 1; return { kind: "strike-rel", steps: sign * c.expectNumber("for a relative strike") }; } const t = c.peek(); if (t !== undefined && t.type === "number") { c.advance(); if (c.matchWord("d")) { c.advance(); return { kind: "strike-delta", delta: Number(t.text) }; } return { kind: "strike-abs", strike: Number(t.text) }; } // Name BOTH continuations after a leg quantity: a strike (the option leg) OR `shares` (the equity // leg, ADR-0017). Every mistake must price in one frame — an author who wrote `buy 100 @ mid` needs // to see that `buy 100 shares @ mid` is the equity form, not just that a strike was expected. c.fail( `expected a strike (+N/-N relative, N absolute, atm, or Nd delta) for an option leg, ` + `or \`shares\` for an equity leg (\`buy N shares\`, ADR-0017), got ${describe(t)}`, ); } function parseLeg(c: Cursor): Leg { let side: "buy" | "sell"; if (c.optWord("buy")) side = "buy"; else if (c.optWord("sell")) side = "sell"; else c.fail(`expected \`buy\` or \`sell\` to start a leg, got ${describe(c.peek())}`); const qty = c.expectNumber("for a leg quantity"); // An equity/spot leg (`buy 100 shares`, ADR-0017): the `shares` marker replaces strike+right; // the symbol is the ambient `USING exec`. Anything else is an option leg (`buy 2 +1 C`). if (c.optWord("shares")) { // Fail-closed: a spot instrument has no strike or right, so `shares` is a COMPLETE leg (ADR-0017). // A strike/right token trailing it is the equity↔option confusion — refuse loudly, never silently // reinterpret. (A `,` next leg, an `@` price, or end-of-ticket are the only legal continuations.) const t = c.peek(); const w = c.wordAt(); if ((t !== undefined && t.type === "number") || w === "C" || w === "P" || w === "atm" || c.matchPunct("+") || c.matchPunct("-")) { c.fail(`an equity/spot leg carries no strike or right — \`${qty} shares\` is complete (ADR-0017); drop the strike/right`); } return { kind: "equity-leg", side, qty }; } const strike = parseStrike(c); let right: "C" | "P"; if (c.optWord("C")) right = "C"; else if (c.optWord("P")) right = "P"; else c.fail(`expected \`C\` or \`P\` (option right) after a strike, got ${describe(c.peek())}`); // An optional PER-LEG expiry (`buy 2 +1 C exp 0dte`, kestrel-ih5h seam 1). The selector vocabulary is // the SAME `parseExpiry` the execution instrument uses (`USING exec SPY 0dte`) — `Ndte`, a date, or a // tag — so an author learns one expiry language, not two. Unlike the instrument form (positional, held // apart by a stop-set) a leg's expiry needs the `exp` MARKER: a leg is followed by `,` or `@`, and a // bare tag selector reads names, so a positional tag would silently swallow whatever came next. Absent // `exp`, the leg leaves `expiry` undefined and inherits the ambient `USING exec` tenor (additive: // every plan written before this syntax prints byte-identically, ADR-0004). let expiry: ExpirySelector | undefined; if (c.optWord("exp")) { // Fail closed: `exp` with nothing usable after it is a parse escape, NEVER a silent undefined — an // undefined expiry means "inherit the exec tenor", so accepting a bare `exp` would quietly read back // as the ambient (often 0dte) expiry the author was trying to override. No selector can begin with // punctuation, so `exp @ mid` / `exp ,` are caught here rather than misread as a tag. const t = c.peek(); if (t === undefined || t.type === "punct") { c.fail(`\`exp\` needs an expiry selector after it — \`0dte\`, a date (\`2026-07-17\`), or a tag (\`weekly\`) — got ${describe(t)}`); } expiry = parseExpiry(c); if (expiry === undefined) c.fail("`exp` needs an expiry selector after it (`0dte`, a date like `2026-07-17`, or a tag like `weekly`)"); } return { kind: "leg", side, qty, strike, right, ...(expiry !== undefined ? { expiry } : {}) }; } function parseLegs(c: Cursor): Leg[] { const legs = [parseLeg(c)]; while (c.optPunct(",")) legs.push(parseLeg(c)); return legs; } function parseHeldQuantifier(c: Cursor): HeldQuantifier | undefined { if (c.matchWord("foreach")) { c.advance(); c.expectWord("held", "in a held-leg quantifier (`foreach held leg`)"); c.expectWord("leg", "in a held-leg quantifier (`foreach held leg`)"); return { kind: "held-foreach" }; } if (c.matchWord("any")) { c.advance(); c.expectWord("held", "in a held-leg quantifier (`any held leg`)"); c.expectWord("leg", "in a held-leg quantifier (`any held leg`)"); return { kind: "held-any" }; } // A bare `held leg` with no `foreach`/`any` head is a MIS-TYPED quantifier — the author reached for // the adoption idiom and dropped the quantifier keyword (`ARM held leg` instead of `ARM foreach held // leg`). Without this, the leading `held` falls through to a generic `expectClauseEnd` "unexpected // `held`" that flags the wrong token and never teaches the fix — unlike the `foreach`/`any` siblings, // which echo the full form. Detect `held leg` specifically (a bare `held <duration>` is a legitimate // TimeHeldStop inside a trigger and never reaches here) and echo BOTH correct forms (kestrel-b4wx). if (c.wordAt() === "held" && c.wordAt(1) === "leg") { c.fail( "a held-leg quantifier needs a `foreach` or `any` head — `held leg` alone is not a quantifier. " + "Did you mean `foreach held leg` (every held leg) or `any held leg`?", ); } return undefined; } function parseOrderPolicy(c: Cursor): OrderPolicy | undefined { let pricing: "peg" | "fix" | undefined; const esc: EscStage[] = []; const caps: PriceExpr[] = []; const floors: PriceExpr[] = []; let cancelIf: Trigger | undefined; let gtc = false; for (;;) { if (c.matchWord("peg") || c.matchWord("fix")) { if (pricing !== undefined) c.fail("a ticket may declare `peg` or `fix` only once"); pricing = c.advance().text as "peg" | "fix"; } else if (c.optWord("esc")) { const to = parsePrice(c); esc.push({ kind: "esc-stage", to, after: readDuration(c, "for an esc stage") }); } else if (c.optWord("cap")) { caps.push(parsePrice(c)); } else if (c.optWord("floor")) { floors.push(parsePrice(c)); } else if (c.optWord("cancel-if")) { cancelIf = parseTrigger(c); } else if (c.optWord("gtc")) { gtc = true; } else break; } if (pricing === undefined && esc.length === 0 && caps.length === 0 && floors.length === 0 && cancelIf === undefined && !gtc) { return undefined; } return { kind: "order-policy", ...(pricing !== undefined ? { pricing } : {}), ...(esc.length > 0 ? { esc } : {}), ...(caps.length > 0 ? { caps } : {}), ...(floors.length > 0 ? { floors } : {}), ...(cancelIf !== undefined ? { cancelIf } : {}), ...(gtc ? { gtc } : {}), }; } function rejectAtomicIfPresent(c: Cursor): void { if (c.matchWord("atomic")) c.fail(ATOMIC_MESSAGE); } // ───────────────────────────────────────────────────────────────────────────── // Plan clauses // ───────────────────────────────────────────────────────────────────────────── function parseTpTarget(c: Cursor): TpTarget { if (c.matchPunct("+") || c.matchPunct("-")) { const sign = c.advance().text === "-" ? -1 : 1; const pct = sign * c.expectNumber("for a TP percentage"); c.expectPunct("%", "after a TP percentage (`+100%`)"); return { kind: "tp-pct", pct }; } const t = c.peek(); if (t !== undefined && t.type === "number") { if (c.peek(1)?.type === "punct" && c.peek(1)!.text === "%") { c.advance(); c.advance(); return { kind: "tp-pct", pct: Number(t.text) }; } if (c.wordAt(1) === "x") { c.advance(); c.advance(); return { kind: "tp-mult", mult: Number(t.text) }; } } return { kind: "tp-price", price: parsePrice(c) }; } function parsePlanClause(c: Cursor): PlanClause { const kw = c.readName(`a clause keyword (${CLAUSE_KEYWORDS})`); switch (kw) { case "DO": case "ALSO": { rejectAtomicIfPresent(c); const legs = parseLegs(c); c.expectPunct("@", "before the ticket price"); const price = parsePrice(c); const policy = parseOrderPolicy(c); expectClauseEnd(c, `the ${kw} ticket`); const base = { legs, price, ...(policy !== undefined ? { policy } : {}) }; return kw === "DO" ? ({ kind: "do", ...base } satisfies DoTicket) : ({ kind: "also", ...base } satisfies AlsoTicket); } case "RELOAD": { let when: Trigger | undefined; if (c.optWord("WHEN")) when = parseTrigger(c); rejectAtomicIfPresent(c); const legs = parseLegs(c); c.expectPunct("@", "before the RELOAD price"); const price = parsePrice(c); const policy = parseOrderPolicy(c); expectClauseEnd(c, "the RELOAD ticket"); return { kind: "reload", ...(when !== undefined ? { when } : {}), legs, price, ...(policy !== undefined ? { policy } : {}), } satisfies ReloadClause; } case "TP": { const target = parseTpTarget(c); let frac: number | undefined; if (c.optWord("frac")) frac = c.expectNumber("for a TP fraction"); const over = parseHeldQuantifier(c); let price: PriceExpr | undefined; if (c.optPunct("@")) price = parsePrice(c); const policy = parseOrderPolicy(c); expectClauseEnd(c, "the TP clause"); return { kind: "tp", target, ...(frac !== undefined ? { frac } : {}), ...(over !== undefined ? { over } : {}), ...(price !== undefined ? { price } : {}), ...(policy !== undefined ? { policy } : {}), } satisfies TpClause; } case "EXIT": { const when = parseTrigger(c); const mark = findMarkInTrigger(when); if (mark !== undefined) c.fail(markMessage(mark)); const over = parseHeldQuantifier(c); let price: PriceExpr | undefined; if (c.optPunct("@")) price = parsePrice(c); const policy = parseOrderPolicy(c); expectClauseEnd(c, "the EXIT clause"); return { kind: "exit", when, ...(over !== undefined ? { over } : {}), ...(price !== undefined ? { price } : {}), ...(policy !== undefined ? { policy } : {}), } satisfies ExitClause; } case "INVALIDATE": { const when = parseTrigger(c); expectClauseEnd(c, "the INVALIDATE clause"); return { kind: "invalidate", when } satisfies InvalidateClause; } case "CANCEL-IF": { const when = parseTrigger(c); expectClauseEnd(c, "the CANCEL-IF clause"); return { kind: "cancel-if", when } satisfies CancelIfClause; } case "ARM": { let when: Trigger | undefined; if (c.optWord("WHEN")) when = parseTrigger(c); let basis: PriceExpr | undefined; if (c.optWord("basis")) basis = parsePrice(c); const over = parseHeldQuantifier(c); // An ARM clause must DO something: adopt a held leg (`… foreach held leg`) OR chain on another // plan's state (`ARM WHEN …`). A clause carrying NEITHER a held quantifier NOR a WHEN trigger // binds nothing — bare `ARM` and `ARM basis <px>` parse valid today and then silently adopt // NOTHING (no de-arm reason, no frame notice), re-locking the r866 "a fill is a one-way door" // trap one dropped keyword away (kestrel-b4wx). Refuse it AT PARSE — the earliest, loudest signal, // naming the two real idioms — rather than let an author ship a plan that arms into the void. // A `basis` needs a held leg to anchor onto (RUNTIME §4: `basis` is unresolvable with no held // position), but a `basis` alongside a WHEN is legitimate (a chain-armed manage plan carries its // cost basis; see the `manage-inventory` golden), so the refusal keys ONLY on "neither when nor // held quantifier", never on basis alone. if (when === undefined && over === undefined) { c.fail( basis === undefined ? "bare `ARM` binds nothing — an ARM clause must adopt a held leg or chain a plan. " + "Did you mean `ARM foreach held leg` (adopt a superseded plan's held leg — the exit " + "escape hatch) or `ARM WHEN <trigger>` (chain plans on another's state)?" : "`ARM basis <px>` carries an adoption basis but binds no leg — a basis only anchors an " + "ADOPTED held leg (RUNTIME §4). Did you mean `ARM basis <px> foreach held leg` (adopt " + "the superseded plan's held leg at that basis)?", ); } expectClauseEnd(c, "the ARM clause"); return { kind: "arm", ...(when !== undefined ? { when } : {}), ...(basis !== undefined ? { basis } : {}), ...(over !== undefined ? { over } : {}), } satisfies ArmClause; } default: c.fail(`unknown clause \`${kw}\`; expected one of ${CLAUSE_KEYWORDS}`); } } // ───────────────────────────────────────────────────────────────────────────── // Instruments, USING, provenance // ───────────────────────────────────────────────────────────────────────────── function parseExpiry(c: Cursor): ExpirySelector | undefined { const t = c.peek(); if (t === undefined) return undefined; if (t.type === "number") { c.advance(); if (c.matchWord("dte")) { c.advance(); return { kind: "expiry-dte", dte: Number(t.text) }; } // date token: N(-N)+ — preserve the RAW digit text (zero-padded `07` stays `07`); // Number() here would drop leading zeros and break the round-trip. if (c.matchPunct("-")) { let s = t.text; while (c.optPunct("-")) { const seg = c.peek(); if (seg === undefined || seg.type !== "number") c.fail(`expected a number after \`-\` in an expiry date, got ${describe(seg)}`); s += "-" + seg.text; c.advance(); } return { kind: "expiry-date", date: s }; } c.fail(`expected \`dte\` or a date after ${t.text} in an expiry, got ${describe(c.peek())}`); } return { kind: "expiry-tag", tag: c.readName("an expiry tag") }; } function parseInstrument(c: Cursor, opts: { expiry: boolean; stop: ReadonlySet<string> }): Instrument { const symbol = c.readName("an instrument symbol"); if (!opts.expiry) return { kind: "instrument", symbol }; const w = c.wordAt(); const nextIsStop = w !== undefined && opts.stop.has(w); const hasExpiry = !c.atEnd() && !nextIsStop; if (!hasExpiry) return { kind: "instrument", symbol }; const expiry = parseExpiry(c); return expiry === undefined ? { kind: "instrument", symbol } : { kind: "instrument", symbol, expiry }; } const USING_STOP: ReadonlySet<string> = new Set(["signal", "exec"]); function parseUsingBody(c: Cursor): Using { let signal: Instrument | undefined; let exec: Instrument | undefined; for (;;) { if (c.optWord("signal")) signal = parseInstrument(c, { expiry: true, stop: USING_STOP }); else if (c.optWord("exec")) exec = parseInstrument(c, { expiry: true, stop: USING_STOP }); else break; } if (signal === undefined && exec === undefined) { c.fail("`USING` needs at least a `signal` or an `exec` instrument"); } return { kind: "using", ...(signal !== undefined ? { signal } : {}), ...(exec !== undefined ? { exec } : {}), }; } function parseProvenanceBrace(c: Cursor): Provenance { c.expectPunct("{", "to open a provenance map"); let tier: ProvenanceTier | undefined; let author: string | undefined; let origin: string | undefined; let replay: string | undefined; if (!c.matchPunct("}")) { for (;;) { const key = c.readName("a provenance field (tier, author, origin, or replay)"); c.expectPunct(":", "after a provenance field name"); switch (key) { case "tier": { const v = c.readName("a provenance tier (vetted, candidate, or unvetted)"); if (!PROV_TIERS.has(v)) c.fail(`unknown provenance tier \`${v}\`; expected vetted, candidate, or unvetted`); tier = v as ProvenanceTier; break; } case "author": author = c.expectString("for a provenance author"); break; case "origin": origin = c.expectString("for a provenance origin"); break; case "replay": replay = c.expectString("for a provenance replay record"); break; default: c.fail(`unknown provenance field \`${key}\`; expected tier, author, origin, or replay`); } if (!c.optPunct(",")) break; } } c.expectPunct("}", "to close a provenance map"); return { kind: "provenance", ...(tier !== undefined ? { tier } : {}), ...(author !== undefined ? { author } : {}), ...(origin !== undefined ? { origin } : {}), ...(replay !== undefined ? { replay } : {}), }; } // ───────────────────────────────────────────────────────────────────────────── // The `because` pre-registration citation (kestrel-rtf) // ───────────────────────────────────────────────────────────────────────────── /** * Reassemble a content-hash digest from the adjacent tokens the lexer split it into. There is * no lexed hash token in Kestrel, so a hex digest lexes as a maximal run of adjacent word/number * tokens: a letter-leading digest (`af3c…`) is one word, but a **digit-leading** digest (`82f6…`) * splits into a `number` (`82`) then a `word` (`f6…`) — so we join by adjacency exactly like * {@link Cursor.readName} / {@link readCorpusToken}, stopping at the first gap or non-hex token. */ function readContentHash(c: Cursor): string { const first = c.peek(); if (first === undefined || (first.type !== "word" && first.type !== "number")) { c.fail(citationBadHashMessage(describe(first))); } let text = first.text; let end = first.end; c.advance(); for (;;) { const t = c.peek(); if (t === undefined || t.col !== end) break; // a gap (space) ends the digest if (t.type !== "word" && t.type !== "number") break; text += t.text; end = t.end; c.advance(); } return text; } /** * Parse a `because` citation body — the `BECAUSE` keyword is already consumed by the caller. * The fixed shape is `<algo>:<64 hex>` (only `sha256` today; {@link CITATION_ALGOS}). Fails * closed on a bad/absent hash and on an inline body — the clause carries a content hash ONLY * (the thesis prose lives platform-side). */ function parseBecause(c: Cursor): Citation { const algo = c.readName("a content-hash algorithm in a `because` citation (`sha256:<64 hex>`)"); if (!CITATION_ALGOS.has(algo)) c.fail(citationBadHashMessage(`${algo}:…`)); c.expectPunct(":", "between `sha256` and its digest in a `because` citation (`sha256:<64 hex>`)"); const hash = readContentHash(c); if (!isCitationHash(hash)) c.fail(citationBadHashMessage(hash)); // Content-hash ONLY: any trailing token (a quoted body, more prose) is the refu