/** * # 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 ` 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 = 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 ` …` * 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 = 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 = 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 \` 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: `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 ` 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 ` held ` break-hold postfix (which always has an inner atom before `held`, // so it never reaches this leading position). `held ` = a TimeHeldStop; `clockET ` = 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 above HI OR left 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 ` 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 , ` (it normalizes to " + "`min(, )`), 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 ` 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 ` 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 ` (chain plans on another's state)?" : "`ARM basis ` carries an adoption basis but binds no leg — a basis only anchors an " + "ADOPTED held leg (RUNTIME §4). Did you mean `ARM basis 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 }): 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 = 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 `:<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 refused inline body. if (!c.atEnd()) c.fail(CITATION_INLINE_BODY_MESSAGE); return { kind: "citation", algo: algo as Citation["algo"], hash }; } /** Merge an ambient module `USING` with a statement's explicit clause so the AST is fully * qualified (ARCHITECTURE §2): explicit sides win; unspecified sides inherit the ambient. */ function mergeUsing(ambient: Using | undefined, explicit: Using | undefined): Using | undefined { if (ambient === undefined && explicit === undefined) return undefined; const signal = explicit?.signal ?? ambient?.signal; const exec = explicit?.exec ?? ambient?.exec; return { kind: "using", ...(signal !== undefined ? { signal } : {}), ...(exec !== undefined ? { exec } : {}), }; } // ───────────────────────────────────────────────────────────────────────────── // Statement headers // ───────────────────────────────────────────────────────────────────────────── function headerCursor(node: LineNode, keyword: string): Cursor { const c = new Cursor(node.tokens, node.line); c.expectWord(keyword, "to open the statement"); return c; } function parseView(node: LineNode): ViewStatement { const c = headerCursor(node, "VIEW"); const name = c.readName("a view name"); let budget: number | undefined; if (c.optWord("budget")) budget = c.expectNumber("for a view token budget"); c.expectEnd("the VIEW header"); for (const child of node.children) { // Fail closed: a `because` citation may only pre-register a Plan or Wake, never a View // (never a fifth statement kind, ADR-0001) — refuse it before it reads as a pane. if (child.tokens[0]?.type === "word" && child.tokens[0].text === "BECAUSE") { throw new KestrelParseError(citationOnMessage("View"), child.line, child.col); } } const panes = node.children.map(parsePane); return { kind: "view", name, panes, ...(budget !== undefined ? { budget } : {}), ...withComments(commentLayer(node, node.children)), }; } function parsePane(node: LineNode): PaneRef { const c = new Cursor(flatten(node), node.line); const name = c.readName("a pane name"); const args: PaneArg[] = []; while (!c.atEnd()) { const t = c.peek()!; // A `:`/`{` here is the third dominant authoring mistake (dry-run-1, 8 escapes) — a pane written // in a key:value / JSON shape. Name the flat form. (Diagnostic only — still a hard reject.) if (t.type === "punct" && (t.text === ":" || t.text === "{" || t.text === ",")) { c.fail( `a pane is a NAME followed by plain space-separated arguments (e.g. \`chain fair realness\`, ` + `\`tape skyline 5m vwap\`) — not \`${t.text}\`. Drop any \`:\`/\`{…}\`/\`,\` and write one pane per ` + `indented line beneath the \`VIEW \` header.`, ); } // A SESSION / EXPIRY ORDINAL literal (Train 1B / ADR-0041 §1): the tight `d-` / `e-` shape — // the single-letter prefix word (`d`/`e`), an ADJACENT `-`, and an ADJACENT numeral, with zero // catalog knowledge (the `wf` rung never picks a semantic sort). Recognised HERE (before the // generic ident fallback, which `readName` would otherwise glue into an ident "d-0"); the numeral // is carried verbatim and the catalog slot refines it (SessionOrdinal / ExpiryOrdinal) + rejects a // non-integer at the `mat` rung, exactly like Count. A bare `d`/`e` (no `-`) is a normal ident; // `d - 0` (spaced) is not the tight literal and falls through (a `-` in arg position then refuses). if (t.type === "word" && (t.text === ORDINAL_PREFIX || t.text === EXPIRY_PREFIX)) { const dash = c.peek(1); const numTok = c.peek(2); if ( dash !== undefined && dash.type === "punct" && dash.text === "-" && dash.col === t.end && numTok !== undefined && numTok.type === "number" && numTok.col === dash.end ) { c.advance(); // the prefix word c.advance(); // the `-` const value = c.expectNumber(`for a ${t.text === ORDINAL_PREFIX ? "session ordinal (`d-`)" : "expiry ordinal (`e-`)"}`); if (t.text === ORDINAL_PREFIX) args.push({ kind: "arg-ordinal", ordinal: value }); else args.push({ kind: "arg-expiry", expiry: value }); continue; } } // A numeral's syntactic supersort is decided by what FOLLOWS it, with zero catalog knowledge // (ADR-0041 §1): a numeral + a time unit is a WINDOW (`5m`); a bare numeral (no unit) is a COUNT // (`chain 12`). The catalog slot later refines Count → its semantic sort at materialization. if (t.type === "number") { const value = c.expectNumber("for a pane numeral argument"); const next = c.peek(); if (next !== undefined && next.type === "word" && TIME_UNITS.has(next.text)) { const unit = readTimeUnit(c, "for a pane window argument"); args.push({ kind: "arg-window", window: { kind: "window", value, unit } }); } else { args.push({ kind: "arg-count", count: value }); } } else args.push({ kind: "arg-ident", name: c.readName("a pane argument") }); } return { kind: "pane", name, args }; } function parseWake(node: LineNode): WakeStatement { const c = headerCursor(node, "WAKE"); const name = c.readName("a wake name"); c.expectEnd("the WAKE header"); let when: Trigger | undefined; let because: Citation | undefined; let deliver: WakeStatement["deliver"]; let priority: number | undefined; let coalesce: string | undefined; let budget: WakeBudget | undefined; let universe: WakeStatement["universe"]; for (const child of node.children) { const cc = new Cursor(flatten(child), child.line); const kw = cc.readName("a WAKE clause (WHEN, BECAUSE, DELIVER, PRIORITY, COALESCE, BUDGET, or UNIVERSE)"); switch (kw) { case "WHEN": when = parseTrigger(cc); break; case "BECAUSE": if (because !== undefined) cc.fail("a WAKE carries at most one `because` citation"); because = parseBecause(cc); break; case "DELIVER": { const view = cc.readName("a view name after DELIVER"); const mandatory = cc.optWord("MANDATORY"); const keyframe = cc.optWord("KEYFRAME"); deliver = { kind: "deliver", view, ...(mandatory ? { mandatory } : {}), ...(keyframe ? { keyframe } : {}) }; break; } case "PRIORITY": priority = cc.expectNumber("for a wake priority"); break; case "COALESCE": coalesce = cc.readName("a coalesce key"); break; case "BUDGET": budget = parseWakeBudget(cc); break; case "UNIVERSE": universe = { kind: "universe", universe: cc.readName("a universe name") }; break; default: cc.fail(`unknown WAKE clause \`${kw}\`; expected WHEN, BECAUSE, DELIVER, PRIORITY, COALESCE, BUDGET, or UNIVERSE`); } cc.expectEnd(`the ${kw} clause`); } if (when === undefined) throw new KestrelParseError("a WAKE requires a WHEN clause", node.line, node.col); return { kind: "wake", name, when, ...(because !== undefined ? { because } : {}), ...(deliver !== undefined ? { deliver } : {}), ...(priority !== undefined ? { priority } : {}), ...(coalesce !== undefined ? { coalesce } : {}), ...(budget !== undefined ? { budget } : {}), ...(universe !== undefined ? { universe } : {}), ...withComments(commentLayer(node, node.children)), }; } function parseWakeBudget(c: Cursor): WakeBudget { let wakesPerDay: number | undefined; let tokensPerDay: number | undefined; for (;;) { const t = c.peek(); if (t === undefined || t.type !== "number") break; const n = c.expectNumber("for a budget rate"); const which = c.readName("a budget kind (`wakes` or `tokens`)"); c.expectPunct("/", "in a budget rate (`wakes/day`)"); c.expectWord("day", "in a budget rate (`wakes/day`)"); if (which === "wakes") wakesPerDay = n; else if (which === "tokens") tokensPerDay = n; else c.fail(`unknown budget kind \`${which}\`; expected \`wakes\` or \`tokens\``); } if (wakesPerDay === undefined && tokensPerDay === undefined) { c.fail("BUDGET needs at least one of `N wakes/day` or `N tokens/day`"); } return { kind: "wake-budget", ...(wakesPerDay !== undefined ? { wakesPerDay } : {}), ...(tokensPerDay !== undefined ? { tokensPerDay } : {}), }; } function parseBudget(c: Cursor): Budget { // A sign is read here so a non-positive budget fails on the *value* (the honest // invariant) rather than on an "expected a number" surprise at the `-` token. let sign = 1; if (c.optPunct("-")) sign = -1; else if (c.optPunct("+")) sign = 1; const value = sign * c.expectNumber("for a risk budget"); c.expectWord("R", "as the risk-fraction unit of a budget (e.g. `0.2R`)"); // Budget positivity is a doctrine invariant (ARCHITECTURE §6.1), enforced on every surface // from ./validate.ts; here it fails closed on the *value* with position. if (!(value > 0)) c.fail(budgetMessage(value)); return { kind: "budget", value, unit: "R" }; } function parseTtl(c: Cursor): Ttl { if (c.optPunct("+")) return { kind: "ttl-rel", dur: readDuration(c, "for a relative ttl") }; const t = c.peek(); if (t !== undefined && t.type === "number" && c.peek(1)?.type === "punct" && c.peek(1)!.text === ":") { return { kind: "ttl-at", at: readTimeOfDay(c) }; } c.fail(`expected a ttl: relative \`+30m\` or an absolute clock \`16:00\`, got ${describe(t)}`); } function parseRegimeGate(c: Cursor): RegimeGate { c.expectPunct("{", "to open a regime gate"); const tags: RegimeTagBinding[] = []; if (!c.matchPunct("}")) { for (;;) { const scope = c.readName("a regime scope"); c.expectPunct(":", "between a regime scope and its value"); const value = c.readName("a regime tag value"); tags.push({ scope, value }); if (!c.optPunct(",")) break; } } c.expectPunct("}", "to close a regime gate"); return { kind: "regime-gate", tags }; } function parsePlan(node: LineNode, ambient: Using | undefined): PlanStatement { const c = headerCursor(node, "PLAN"); const name = c.readName("a plan name"); let budget: Budget | undefined; let ttl: Ttl | undefined; let regime: RegimeGate | undefined; let priority: number | undefined; let standing: Standing | undefined; let provenance: Provenance | undefined; while (!c.atEnd()) { const w = c.wordAt(); switch (w) { case "budget": c.advance(); budget = parseBudget(c); break; case "ttl": c.advance(); ttl = parseTtl(c); break; case "regime": c.advance(); regime = parseRegimeGate(c); break; case "priority": c.advance(); priority = c.expectNumber("for a plan priority"); break; case "standing": { c.advance(); const s = c.readName("a standing (authored, armed, versioned, or superseded)"); if (!STANDINGS.has(s)) c.fail(`unknown standing \`${s}\`; expected authored, armed, versioned, or superseded`); standing = s as Standing; break; } case "prov": c.advance(); provenance = parseProvenanceBrace(c); break; case "atomic": c.fail(ATOMIC_MESSAGE); break; default: c.fail( `unexpected ${describe(c.peek())} in the PLAN header; expected budget, ttl, regime, ` + `priority, standing, prov, or atomic`, ); } } let explicitUsing: Using | undefined; let when: Trigger | undefined; let because: Citation | undefined; const clauses: PlanClause[] = []; for (const child of node.children) { const cc = new Cursor(flatten(child), child.line); const w = cc.wordAt(); if (w === "USING") { cc.expectWord("USING", "to open a USING clause"); explicitUsing = parseUsingBody(cc); expectClauseEnd(cc, "the USING clause"); } else if (w === "WHEN") { cc.expectWord("WHEN", "to open a WHEN clause"); when = parseTrigger(cc); expectClauseEnd(cc, "the WHEN clause"); } else if (w === "BECAUSE") { cc.expectWord("BECAUSE", "to open a BECAUSE citation clause"); if (because !== undefined) cc.fail("a PLAN carries at most one `because` citation"); because = parseBecause(cc); cc.expectEnd("the BECAUSE clause"); } else { clauses.push(parsePlanClause(cc)); } } const using = mergeUsing(ambient, explicitUsing); return { kind: "plan", name, clauses, ...(budget !== undefined ? { budget } : {}), ...(ttl !== undefined ? { ttl } : {}), ...(regime !== undefined ? { regime } : {}), ...(priority !== undefined ? { priority } : {}), ...(standing !== undefined ? { standing } : {}), ...(provenance !== undefined ? { provenance } : {}), ...(using !== undefined ? { using } : {}), ...(when !== undefined ? { when } : {}), ...(because !== undefined ? { because } : {}), ...withComments(commentLayer(node, node.children)), }; } function parseGrade(node: LineNode): GradeStatement { const c = headerCursor(node, "GRADE"); const what = c.readName("what to grade (plan, wake, view, pod, or tag)"); if (!GRADE_WHATS.has(what)) c.fail(`cannot grade \`${what}\`; expected plan, wake, view, pod, or tag`); const name = c.readName("the name of the thing to grade"); let over: GradeStatement["over"]; if (c.optWord("OVER")) { const from = readCorpusToken(c, true); c.expectPunct("..", "in a corpus range (`from..to`)"); const to = readCorpusToken(c, false); over = { kind: "corpus-range", from, to }; } let fill: string | undefined; if (c.optWord("FILL")) fill = c.readName("a fill-model name after FILL"); c.expectEnd("the GRADE header"); const versus: Counterfactual[] = []; const by: GradeDimension[] = []; for (const child of node.children) { const cc = new Cursor(flatten(child), child.line); const kw = cc.readName("a GRADE clause (VS or BY)"); if (kw === "VS") { while (!cc.atEnd()) { const w = cc.readName("a counterfactual (ungated, null, or bracket)"); if (w === "ungated") versus.push({ kind: "cf-ungated" }); else if (w === "null") versus.push({ kind: "cf-null" }); else if (w === "bracket") versus.push({ kind: "cf-bracket" }); else cc.fail(`unknown counterfactual \`${w}\`; expected ungated, null, or bracket`); } } else if (kw === "BY") { for (;;) { by.push(parseGradeDimension(cc)); if (!cc.optPunct(",")) break; } cc.expectEnd("the BY clause"); } else if (kw === "BECAUSE") { // Fail closed: a `because` citation may only pre-register a Plan or Wake, never a Grade // (never a fifth statement kind, ADR-0001). cc.fail(citationOnMessage("Grade")); } else { cc.fail(`unknown GRADE clause \`${kw}\`; expected VS or BY`); } } return { kind: "grade", subject: { kind: "grade-subject", what: what as GradeSubject["what"], name }, versus, by, ...(over !== undefined ? { over } : {}), ...(fill !== undefined ? { fill } : {}), ...withComments(commentLayer(node, node.children)), }; } function readCorpusToken(c: Cursor, untilRange: boolean): string { let s = ""; for (;;) { const t = c.peek(); if (t === undefined) break; if (untilRange && t.type === "punct" && t.text === "..") break; if (!untilRange && t.type === "word" && t.text === "FILL") break; s += c.advance().text; } if (s === "") c.fail("expected a corpus date token (e.g. 2025-01)"); return s; } function parseGradeDimension(c: Cursor): GradeDimension { const w = c.wordAt(); const next = c.peek(1); const bareKeyword = next === undefined || (next.type === "punct" && next.text === ","); if (bareKeyword && w === "vehicle") { c.advance(); return { kind: "dim-vehicle" }; } if (bareKeyword && w === "name") { c.advance(); return { kind: "dim-name" }; } if (bareKeyword && w === "lineage") { c.advance(); return { kind: "dim-lineage" }; } return { kind: "dim-series", series: parseSeries(c) }; } function parseRiskLine(node: LineNode): RiskLine { const c = new Cursor(flatten(node), node.line); c.expectWord("RISK", "to open a risk line"); const metric = c.readName("a risk metric"); const threshold = readQuantity(c, "for a risk threshold"); c.expectPunct("->", "between a risk threshold and its action (`-> halt`)"); const action = c.readName("a risk action"); c.expectEnd("the RISK line"); return { kind: "risk", metric, threshold, action }; } function parseCoverage(c: Cursor): Coverage { const instruments: Instrument[] = []; while (c.wordAt() !== undefined && c.wordAt() !== "thesis") { instruments.push(parseInstrument(c, { expiry: false, stop: new Set() })); } if (instruments.length === 0) c.fail("coverage needs at least one instrument before `thesis`"); c.expectWord("thesis", "after coverage instruments (instruments alone are not coverage)"); const thesis = c.expectString("for a coverage thesis"); return { kind: "coverage", instruments, thesis }; } function parseArbitration(c: Cursor): Arbitration { const policy = c.readName("an arbitration policy"); let concurrency: number | undefined; if (c.optWord("concurrency")) concurrency = c.expectNumber("for an arbitration concurrency"); return { kind: "arbitration", policy, ...(concurrency !== undefined ? { concurrency } : {}) }; } function parseBook(node: LineNode): BookStatement { const c = headerCursor(node, "BOOK"); const name = c.readName("a book name"); let budget: Budget | undefined; let coverage: Coverage | undefined; let arbitration: Arbitration | undefined; for (;;) { const w = c.wordAt(); if (w === "budget") { c.advance(); budget = parseBudget(c); } else if (w === "coverage") { c.advance(); coverage = parseCoverage(c); } else if (w === "arbitration") { c.advance(); arbitration = parseArbitration(c); } else break; } c.expectEnd("the BOOK header"); const risk = node.children.map((child) => { if (child.tokens[0]?.text !== "RISK") { throw new KestrelParseError( `a BOOK contains only RISK lines; got ${describe(child.tokens[0])}`, child.line, child.col, ); } return parseRiskLine(child); }); return { kind: "book", name, ...(budget !== undefined ? { budget } : {}), ...(coverage !== undefined ? { coverage } : {}), ...(arbitration !== undefined ? { arbitration } : {}), ...(risk.length > 0 ? { risk } : {}), ...withComments(commentLayer(node, node.children)), }; } function parsePod(node: LineNode, ambient: Using | undefined): PodStatement { const c = headerCursor(node, "POD"); const name = c.readName("a pod name"); c.expectEnd("the POD header"); const risk: RiskLine[] = []; const riskNodes: LineNode[] = []; const children: (PodStatement | BookStatement)[] = []; for (const child of node.children) { const kw = child.tokens[0]?.text; if (kw === "RISK") { risk.push(parseRiskLine(child)); riskNodes.push(child); } else if (kw === "BOOK") children.push(parseBook(child)); else if (kw === "POD") children.push(parsePod(child, ambient)); else { throw new KestrelParseError( `unknown pod member ${describe(child.tokens[0])}; expected RISK, BOOK, or POD`, child.line, child.col, ); } } // Only RISK lines are the pod's OWN interior lines (ordinals 1..r, matching the printer, // which emits RISK before children); child BOOK/POD blocks own their own comment trivia. return { kind: "pod", name, risk, children, ...withComments(commentLayer(node, riskNodes)) }; } // ───────────────────────────────────────────────────────────────────────────── // Module assembly + public entry points // ───────────────────────────────────────────────────────────────────────────── const STATEMENT_KEYWORDS: ReadonlySet = new Set(["VIEW", "WAKE", "PLAN", "GRADE", "POD", "BOOK"]); function parseStatement(node: LineNode, ambient: Using | undefined): Statement { const kw = node.tokens[0]?.text; switch (kw) { case "VIEW": return parseView(node); case "WAKE": return parseWake(node); case "PLAN": return parsePlan(node, ambient); case "GRADE": return parseGrade(node); case "POD": return parsePod(node, ambient); case "BOOK": return parseBook(node); default: // A PLAN clause hoisted to the top level (a bare `WHEN …` / `DO …` statement) is the second // dominant live-model authoring mistake (dry-run-1, 10 escapes) — usually a lost indent. // Name the fix, not just the closed set. (Diagnostic only — still a hard reject.) if (kw !== undefined && PLAN_CLAUSE_KEYWORD_SET.has(kw)) { throw new KestrelParseError( `\`${kw}\` is a PLAN clause, not a top-level statement — it must be indented two spaces ` + `beneath a \`PLAN \` header line, alongside its sibling clauses (WHEN, DO, TP, EXIT). ` + `A top-level statement is one of VIEW, WAKE, PLAN, GRADE, POD, or BOOK.`, node.line, node.col, ); } throw new KestrelParseError( `unknown statement ${describe(node.tokens[0])}; expected VIEW, WAKE, PLAN, GRADE, POD, or BOOK`, node.line, node.col, ); } } function parseImport(node: LineNode): ImportDecl { const c = headerCursor(node, "IMPORT"); c.expectPunct("{", "to open an import list"); const names: string[] = []; if (!c.matchPunct("}")) { for (;;) { names.push(c.readName("an imported statement name")); if (!c.optPunct(",")) break; } } c.expectPunct("}", "to close an import list"); c.expectWord("FROM", "before the module path"); const from = c.expectString("for a module path"); c.expectEnd("the IMPORT declaration"); return { kind: "import", names, from }; } /** Build the module's own comment sidecar: its directive lines carry 0-based ordinals in * canonical emit order (IMPORT…, PROVENANCE, USING — a module has no header line of its own), * plus the document tail. Comments before/on a statement live on that statement, not here. */ function moduleComments(directiveNodes: readonly LineNode[], tail: readonly string[]): CommentLayer | undefined { const lines: LineComment[] = []; directiveNodes.forEach((n, i) => { const lc = lineCommentOf(n, i); if (lc !== undefined) lines.push(lc); }); const hasTail = tail.length > 0; if (lines.length === 0 && !hasTail) return undefined; return { ...(lines.length > 0 ? { lines } : {}), ...(hasTail ? { tail } : {}), }; } function documentFromRoots(roots: readonly LineNode[], tail: readonly string[]): KestrelNode { const imports: ImportDecl[] = []; const importNodes: LineNode[] = []; let moduleUsing: Using | undefined; let provenanceNode: LineNode | undefined; let provenance: Provenance | undefined; let usingNode: LineNode | undefined; const statementNodes: LineNode[] = []; for (const root of roots) { const kw = root.tokens[0]?.text; if (kw === "IMPORT") { imports.push(parseImport(root)); importNodes.push(root); } else if (kw === "PROVENANCE") { const c = headerCursor(root, "PROVENANCE"); provenance = parseProvenanceBrace(c); c.expectEnd("the PROVENANCE directive"); provenanceNode = root; } else if (kw === "USING" && !STATEMENT_KEYWORDS.has(kw)) { const c = headerCursor(root, "USING"); moduleUsing = parseUsingBody(c); c.expectEnd("the module USING directive"); usingNode = root; } else { statementNodes.push(root); } } const statements = statementNodes.map((n) => parseStatement(n, moduleUsing)); // Provenance ceiling (ARCHITECTURE §6.6) — a doctrine invariant homed in ./validate.ts and // shared with the builder/printer; here it throws positionally at the offending statement. if (provenance !== undefined && provenance.tier !== undefined) { const modRank = PROV_RANK[provenance.tier]; statements.forEach((s, i) => { const bad = elevatesProvenance(s, modRank); if (bad !== undefined) { const node = statementNodes[i]!; throw new KestrelParseError(provenanceMessage(bad, provenance.tier!), node.line, node.col); } }); } // Directive lines in canonical emit order (imports, then provenance, then using) — their // ordinals must match printModule's header-line order for byte-stable comment placement. const directiveNodes: LineNode[] = [ ...importNodes, ...(provenanceNode !== undefined ? [provenanceNode] : []), ...(usingNode !== undefined ? [usingNode] : []), ]; const comments = moduleComments(directiveNodes, tail); const isModule = imports.length > 0 || moduleUsing !== undefined || provenance !== undefined || statements.length !== 1 || comments !== undefined; // a document tail (incl. a comment-only doc) forces the module form if (!isModule) return statements[0]!; return { kind: "module", imports, statements, ...(moduleUsing !== undefined ? { using: moduleUsing } : {}), ...(provenance !== undefined ? { provenance } : {}), ...withComments(comments), } satisfies Module; } /** * Parse a Kestrel document into the typed object model. Returns a bare {@link Statement} * when the text is a single statement with no module-level directives (so `parse(print(x))` * round-trips a bare statement); otherwise a {@link Module}. Fails closed: any escape is a * {@link KestrelParseError} carrying `line`/`col`. */ export function parse(src: string): KestrelNode { const { roots, tail } = lex(src); if (roots.length === 0 && tail.length === 0) { throw new KestrelParseError("empty document: expected at least one statement", 1, 1); } for (const root of roots) { if (root.indent !== 0) { throw new KestrelParseError( "unexpected indentation: top-level statements start at column 0", root.line, root.col, ); } } // A comment-only document (no statements, only own-line comments) is a valid module carrying // just its tail comments — the authored "why" survives even with nothing to execute (ADR-0033). return documentFromRoots(roots, tail); } const FENCE = /```kestrel[^\n]*\n([\s\S]*?)\n?```/g; /** * Extract every ```` ```kestrel ```` fenced block from Markdown and parse each identically * to a `.kestrel` file (ADR-0001). Returns one {@link KestrelNode} per block, in order. */ export function parseMarkdown(src: string): KestrelNode[] { const out: KestrelNode[] = []; for (const m of src.matchAll(FENCE)) out.push(parse(m[1]!)); return out; }