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,154 lines • 82.9 kB
JavaScript
/**
* # 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 { flatten, KestrelParseError, lex } from "./lex.js";
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.js";
import { ATOMIC_MESSAGE, bandMessage, budgetMessage, citationBadHashMessage, CITATION_INLINE_BODY_MESSAGE, citationOnMessage, elevatesProvenance, findMarkInTrigger, HELD_CROSS_MESSAGE, isCitationHash, isSimpleSeries, markMessage, provenanceMessage, } from "./validate.js";
export { KestrelParseError } from "./lex.js";
// 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 = 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 = 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) {
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 = new Set(["ceiling", "floor", "limit"]);
/** Repair hint for a natural-language price anchor (diagnostic only — `tok` stays rejected). */
function priceAnchorSynonymMessage(tok) {
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) {
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, at) {
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, interior) {
const lines = [];
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) {
return layer === undefined ? {} : { comments: layer };
}
// ─────────────────────────────────────────────────────────────────────────────
// Cursor — a positioned token stream over one logical line
// ─────────────────────────────────────────────────────────────────────────────
class Cursor {
toks;
fallbackLine;
i = 0;
constructor(toks, fallbackLine) {
this.toks = toks;
this.fallbackLine = fallbackLine;
}
peek(k = 0) {
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) {
const t = this.toks[this.i + k];
return t !== undefined && t.type === "word" ? t.text : undefined;
}
atEnd() {
return this.i >= this.toks.length;
}
here() {
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) {
const { line, col } = this.here();
throw new KestrelParseError(message, line, col);
}
advance() {
const t = this.toks[this.i];
if (t === undefined)
this.fail("unexpected end of line");
this.i++;
return t;
}
matchWord(text) {
return this.wordAt() === text;
}
optWord(text) {
if (this.matchWord(text)) {
this.i++;
return true;
}
return false;
}
expectWord(text, ctx) {
if (!this.matchWord(text))
this.fail(`expected \`${text}\` ${ctx}, got ${describe(this.peek())}`);
this.i++;
}
matchPunct(text) {
const t = this.peek();
return t !== undefined && t.type === "punct" && t.text === text;
}
optPunct(text) {
if (this.matchPunct(text)) {
this.i++;
return true;
}
return false;
}
expectPunct(text, ctx) {
if (!this.matchPunct(text))
this.fail(`expected \`${text}\` ${ctx}, got ${describe(this.peek())}`);
this.i++;
}
expectString(ctx) {
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) {
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) {
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) {
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, clauseCtx) {
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, ctx) {
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;
}
function readWindow(c) {
const value = c.expectNumber("for a window size");
const unit = readTimeUnit(c, "for a window");
return { kind: "window", value, unit };
}
function readDuration(c, ctx) {
const value = c.expectNumber(`for a duration ${ctx}`);
const unit = readTimeUnit(c, ctx);
return { kind: "duration", value, unit };
}
function readTimeOfDay(c) {
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, ctx) {
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) {
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) {
return q.unit === undefined ? String(q.value) : `${q.value}${q.unit}`;
}
function readOptUnit(c) {
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) {
const segments = [];
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;
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;
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) {
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) {
return parseOr(c);
}
function parseOr(c) {
const terms = [parseAnd(c)];
while (c.optWord("OR"))
terms.push(parseAnd(c));
return terms.length === 1 ? terms[0] : { kind: "or", terms };
}
function parseAnd(c) {
const terms = [parseNot(c)];
while (c.optWord("AND"))
terms.push(parseNot(c));
return terms.length === 1 ? terms[0] : { kind: "and", terms };
}
function parseNot(c) {
if (c.optWord("NOT"))
return { kind: "not", term: parseNot(c) };
return parsePostfix(c);
}
function parsePostfix(c) {
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) {
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) {
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;
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) {
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) {
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 } : {};
const above = { kind: "cross", left, dir: "above", right: hi, ...touchOpt };
const below = { kind: "cross", left, dir: "below", right: lo, ...touchOpt };
return { kind: "or", terms: [above, below] };
}
let 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;
if (c.optWord("band"))
band = readBand(c);
return {
kind: "cross",
left,
dir,
right,
...(touch ? { touch } : {}),
...(band !== undefined ? { band } : {}),
};
}
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) };
}
// 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) {
const t = c.peek();
if (t !== undefined && t.type === "word") {
if (ANCHORS.has(t.text)) {
c.advance();
return { kind: "anchor", name: t.text };
}
if (t.text === "min" || t.text === "max") {
c.advance();
c.expectPunct("(", `after \`${t.text}\``);
const args = [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 = [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) {
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;
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, amount, unit };
continue;
}
break;
}
return base;
}
// ─────────────────────────────────────────────────────────────────────────────
// Legs, order policy, held quantifiers
// ─────────────────────────────────────────────────────────────────────────────
function parseStrike(c) {
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) {
let side;
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;
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;
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) {
const legs = [parseLeg(c)];
while (c.optPunct(","))
legs.push(parseLeg(c));
return legs;
}
function parseHeldQuantifier(c) {
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) {
let pricing;
const esc = [];
const caps = [];
const floors = [];
let cancelIf;
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;
}
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) {
if (c.matchWord("atomic"))
c.fail(ATOMIC_MESSAGE);
}
// ─────────────────────────────────────────────────────────────────────────────
// Plan clauses
// ─────────────────────────────────────────────────────────────────────────────
function parseTpTarget(c) {
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) {
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 } : { kind: "also", ...base };
}
case "RELOAD": {
let when;
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 } : {}),
};
}
case "TP": {
const target = parseTpTarget(c);
let frac;
if (c.optWord("frac"))
frac = c.expectNumber("for a TP fraction");
const over = parseHeldQuantifier(c);
let price;
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 } : {}),
};
}
case "EXIT": {
const when = parseTrigger(c);
const mark = findMarkInTrigger(when);
if (mark !== undefined)
c.fail(markMessage(mark));
const over = parseHeldQuantifier(c);
let price;
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 } : {}),
};
}
case "INVALIDATE": {
const when = parseTrigger(c);
expectClauseEnd(c, "the INVALIDATE clause");
return { kind: "invalidate", when };
}
case "CANCEL-IF": {
const when = parseTrigger(c);
expectClauseEnd(c, "the CANCEL-IF clause");
return { kind: "cancel-if", when };
}
case "ARM": {
let when;
if (c.optWord("WHEN"))
when = parseTrigger(c);
let basis;
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 } : {}),
};
}
default:
c.fail(`unknown clause \`${kw}\`; expected one of ${CLAUSE_KEYWORDS}`);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Instruments, USING, provenance
// ─────────────────────────────────────────────────────────────────────────────
function parseExpiry(c) {
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, opts) {
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 = new Set(["signal", "exec"]);
function parseUsingBody(c) {
let signal;
let exec;
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) {
c.expectPunct("{", "to open a provenance map");
let tier;
let author;
let origin;
let replay;
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;
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) {
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.e