UNPKG

kestrel.markets

Version:

A typed, token-efficient language + runtime for agentic trading: agents author bounded plans, the runtime fires them at the tick. CLI + typed library + MCP server.

788 lines (712 loc) 31.3 kB
/** * # print — the canonical printer (objects -> canonical text) * * The inverse projection of the phase-3 parser and the round-trip target (ADR-0004): * deterministic, minimal, 2-space indented clause lines under statement headers, and * **USING-covered qualifiers elided** against the ambient `USING` (ARCHITECTURE §2). * * Every switch is exhaustive via {@link assertNever}; the printer fails **closed** — a * non-finite number, an empty time-window, or an unhandled variant throws loudly rather * than emitting text that would not round-trip. */ import type { AbsolutePrice, Anchor, Arbitration, ArmClause, Baseline, BookStatement, Budget, Citation, CommentLayer, LineComment, Coverage, Counterfactual, DeliverSpec, Duration, ExpirySelector, GradeDimension, GradeStatement, HeldQuantifier, Instrument, KestrelNode, Leg, Module, Operand, OrderPolicy, PaneArg, PathSegment, PlanClause, PlanStatement, PodStatement, PriceExpr, Provenance, Quantity, RegimeGate, RiskLine, SeriesRef, Statement, StrikeSpec, TimeOfDay, TpTarget, Trigger, Ttl, Using, ViewStatement, WakeStatement, Window, } from "./ast.ts"; import { CLOCK_STOP_KEYWORD, CMP_TO_PUNCT, EXPIRY_PREFIX, HELD_STOP_KEYWORD, ORDINAL_PREFIX, ORDINALS } from "./vocab.ts"; import { refuseAtomic, refuseBadCitation, refuseExitOnMark, refuseHeldOverCross, refuseNonPositiveBand, refuseNonPositiveBudget, refuseProvenanceElevation, } from "./validate.ts"; // ───────────────────────────────────────────────────────────────────────────── // Exhaustiveness + fail-closed primitives // ───────────────────────────────────────────────────────────────────────────── /** Compile-time exhaustiveness guard; at runtime it fails closed on an unmodeled * variant rather than silently emitting nothing. */ export function assertNever(x: never, ctx: string): never { throw new Error(`kestrel/print: unhandled variant in ${ctx}: ${JSON.stringify(x)}`); } function num(v: number): string { if (!Number.isFinite(v)) { throw new Error(`kestrel/print: cannot render non-finite number (${String(v)})`); } if (Object.is(v, -0)) return "0"; const s = String(v); // The lexer tokenizes only plain decimals; `String()` emits exponential notation // ("1e+21", "1e-7") for magnitudes outside that range, which the lexer cannot re-read — // it would break the ADR-0004 round-trip. Fail closed rather than emit un-lexable text. if (!/^-?\d+(\.\d+)?$/.test(s)) { throw new Error( `kestrel/print: number ${s} has no lexable decimal form — out of Kestrel's numeric range`, ); } return s; } // The six doctrine invariants (atomic reserved, held-over-cross, band>0, budget>0, // EXIT-not-on-a-mark, provenance ceiling) live in ./validate.ts; the printer calls its // `refuse*` guards before emitting so it never prints text `parse` would reject (ADR-0004). function str(s: string): string { return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; } function pad(indent: number): string { return " ".repeat(indent); } function pad2(n: number): string { if (!Number.isInteger(n) || n < 0) { throw new Error(`kestrel/print: bad clock component (${String(n)})`); } return n < 10 ? `0${n}` : `${n}`; } function ordinal(n: number): string { // Ordinals are 1-based (`first`, `second`, …); the parser's `ordinalValue` requires // idx >= 1 and a `<n>th` suffix would round-trip a negative as bare "-Nth" the lexer // rejects. Fail closed rather than emit "zeroth" / "-1th" that will not re-parse. if (!Number.isInteger(n) || n < 1) { throw new Error(`kestrel/print: ordinal must be a positive integer (>= 1); got ${String(n)}`); } return n < ORDINALS.length ? ORDINALS[n]! : `${num(n)}th`; } // ───────────────────────────────────────────────────────────────────────────── // Lexical units // ───────────────────────────────────────────────────────────────────────────── function windowStr(w: Window): string { return `${num(w.value)}${w.unit}`; } function durationStr(d: Duration): string { return `${num(d.value)}${d.unit}`; } function timeOfDay(t: TimeOfDay): string { return `${pad2(t.hour)}:${pad2(t.minute)}`; } function quantity(q: Quantity): string { return q.unit === undefined ? num(q.value) : `${num(q.value)}${q.unit}`; } function baseline(b: Baseline): string { return b.stat === "p" ? `p${num(b.n)}` : `${num(b.n)}sigma`; } function segment(seg: PathSegment): string { if (seg.selector === undefined) return seg.name; const sel = seg.selector.kind === "sel-quant" ? seg.selector.q : seg.selector.name; return `${seg.name}(${sel})`; } function seriesRef(s: SeriesRef): string { const path = s.segments.map(segment).join("."); return s.window === undefined ? path : `${path}(${windowStr(s.window)})`; } function operand(o: Operand): string { switch (o.kind) { case "series": return seriesRef(o); case "quantity": return quantity(o); case "baseline": return baseline(o); default: return assertNever(o, "operand"); } } // ───────────────────────────────────────────────────────────────────────────── // Price expressions // ───────────────────────────────────────────────────────────────────────────── function anchorName(a: Anchor): string { return a.name; } function absolutePrice(p: AbsolutePrice): string { return num(p.value); } function price(p: PriceExpr): string { switch (p.kind) { case "anchor": return anchorName(p); case "price-abs": return absolutePrice(p); case "price-offset": return `${price(p.base)}${p.sign}${num(p.amount)}${p.unit}`; case "lean": return `lean(${price(p.a)}, ${price(p.b)}, ${num(p.x)})`; case "price-fn": return `${p.fn}(${p.args.map(price).join(", ")})`; default: return assertNever(p, "price"); } } // ───────────────────────────────────────────────────────────────────────────── // Trigger algebra (precedence-aware; minimal parens) // ───────────────────────────────────────────────────────────────────────────── // OR < AND < NOT < postfix(held/within) < atom const PREC_OR = 1; const PREC_AND = 2; const PREC_NOT = 3; const PREC_POSTFIX = 4; const PREC_ATOM = 5; function wrap(s: string, own: number, parent: number): string { return own < parent ? `(${s})` : s; } function timeWindow(from: TimeOfDay | undefined, to: TimeOfDay | undefined): string { if (from !== undefined && to !== undefined) return `time ${timeOfDay(from)}..${timeOfDay(to)}`; if (from !== undefined) return `time after ${timeOfDay(from)}`; if (to !== undefined) return `time before ${timeOfDay(to)}`; throw new Error("kestrel/print: time-window trigger has neither from nor to"); } function trigger(t: Trigger, parent: number): string { switch (t.kind) { case "cmp": { const op = CMP_TO_PUNCT[t.op]; if (op === undefined) throw new Error(`kestrel/print: unknown compare op ${t.op}`); return wrap(`${operand(t.left)} ${op} ${operand(t.right)}`, PREC_ATOM, parent); } case "cross": { const verb = t.touch === true ? "touches" : "crosses"; let band = ""; if (t.band !== undefined) { // Refuse to PRINT a non-positive band symmetrically: emitting `band 0c` would print // text `parse` throws on, cracking the round-trip (ADR-0004). refuseNonPositiveBand(t.band); band = ` band ${quantity(t.band)}`; } return wrap(`${operand(t.left)} ${verb} ${t.dir} ${operand(t.right)}${band}`, PREC_ATOM, parent); } case "event": return wrap( t.of === undefined ? t.name : `${t.name} of ${operand(t.of)}`, PREC_ATOM, parent, ); case "phase": return wrap(`phase ${t.phase}`, PREC_ATOM, parent); case "time-window": return wrap(timeWindow(t.from, t.to), PREC_ATOM, parent); case "fill": return wrap( t.leg === undefined ? t.event : `${t.event} leg ${num(t.leg)}`, PREC_ATOM, parent, ); case "nth": return wrap(`${ordinal(t.ordinal)} ${trigger(t.event, PREC_ATOM)}`, PREC_ATOM, parent); case "break-hold": // A cross is an edge event; holding it is the sfg8 anti-pattern the parser rejects. // Refuse symmetrically — emitting it would print text `parse` throws on (ADR-0004). refuseHeldOverCross(t.inner); return wrap( `${trigger(t.inner, PREC_POSTFIX)} held ${durationStr(t.dur)}`, PREC_POSTFIX, parent, ); case "within": return wrap( `${trigger(t.inner, PREC_POSTFIX)} within ${durationStr(t.dur)}`, PREC_POSTFIX, parent, ); case "until": // The thesis temporal envelope (kestrel-rtf) — a postfix sibling of `within`/`held`. return wrap( `${trigger(t.inner, PREC_POSTFIX)} until ${timeOfDay(t.at)}`, PREC_POSTFIX, parent, ); case "at": return wrap( `${trigger(t.inner, PREC_POSTFIX)} at ${timeOfDay(t.at)}`, PREC_POSTFIX, parent, ); case "held-stop": // A 0DTE time-held stop (`held 90m`) — a bare, LEADING hold-clock (no inner predicate), // an atom, not the `<inner> held <dur>` postfix (see ast.ts TimeHeldStop). return wrap(`${HELD_STOP_KEYWORD} ${durationStr(t.dur)}`, PREC_ATOM, parent); case "clock-stop": // A 0DTE wall-clock stop (`clockET 15:40`). return wrap(`${CLOCK_STOP_KEYWORD} ${timeOfDay(t.at)}`, PREC_ATOM, parent); case "not": return wrap(`NOT ${trigger(t.term, PREC_NOT)}`, PREC_NOT, parent); case "and": return wrap(t.terms.map((x) => trigger(x, PREC_AND)).join(" AND "), PREC_AND, parent); case "or": return wrap(t.terms.map((x) => trigger(x, PREC_OR)).join(" OR "), PREC_OR, parent); default: return assertNever(t, "trigger"); } } // ───────────────────────────────────────────────────────────────────────────── // Order policy, legs, plan clauses // ───────────────────────────────────────────────────────────────────────────── function orderPolicy(p: OrderPolicy): string { const parts: string[] = []; if (p.pricing !== undefined) parts.push(p.pricing); for (const stage of p.esc ?? []) parts.push(`esc ${price(stage.to)} ${durationStr(stage.after)}`); for (const c of p.caps ?? []) parts.push(`cap ${price(c)}`); for (const f of p.floors ?? []) parts.push(`floor ${price(f)}`); if (p.cancelIf !== undefined) parts.push(`cancel-if ${trigger(p.cancelIf, 0)}`); if (p.gtc === true) parts.push("gtc"); return parts.join(" "); } function strike(s: StrikeSpec): string { switch (s.kind) { case "strike-rel": return s.steps >= 0 ? `+${num(s.steps)}` : num(s.steps); case "strike-abs": return num(s.strike); case "strike-atm": return "atm"; case "strike-delta": return `${num(s.delta)}d`; default: return assertNever(s, "strike"); } } function leg(l: Leg): string { // An equity/spot leg prints `<side> <qty> shares` (ADR-0017); an option leg keeps // `<side> <qty> <strike> <right>`, plus an optional per-leg expiry `exp <selector>` // (kestrel-ih5h seam 1). A leg with NO expiry prints exactly as it always has — the tail is // additive, so every pre-existing plan round-trips byte-identically (ADR-0004). if (l.kind === "equity-leg") return `${l.side} ${num(l.qty)} shares`; const head = `${l.side} ${num(l.qty)} ${strike(l.strike)} ${l.right}`; return l.expiry === undefined ? head : `${head} exp ${expiryStr(l.expiry)}`; } function legs(ls: readonly Leg[]): string { return ls.map(leg).join(", "); } function heldQuant(h: HeldQuantifier): string { return h.kind === "held-foreach" ? "foreach held leg" : "any held leg"; } function tpTarget(t: TpTarget): string { switch (t.kind) { case "tp-pct": return `${t.pct >= 0 ? "+" : ""}${num(t.pct)}%`; case "tp-mult": return `${num(t.mult)}x`; case "tp-price": return price(t.price); default: return assertNever(t, "tp-target"); } } /** Join a head keyword with a trailing policy string, dropping the space when empty. */ function withPolicy(head: string, policy: OrderPolicy | undefined): string { if (policy === undefined) return head; const p = orderPolicy(policy); return p === "" ? head : `${head} ${p}`; } /** Print ONE plan clause to its canonical Kestrel text (kestrel-wa0j.29): exported so a consumer that * holds a plan's typed AST (the sim driver, capturing the ARMED document's terms for the `armed-plan` * pane) prints each clause through the SAME canonical printer the round-trip is defined by — a typed * value → its byte-stable text, never a hand-rolled restatement. Pure. */ export function planClause(c: PlanClause): string { switch (c.kind) { case "do": case "also": { refuseAtomic(c.atomic); const kw = c.kind === "do" ? "DO" : "ALSO"; return withPolicy(`${kw} ${legs(c.legs)} @ ${price(c.price)}`, c.policy); } case "reload": { refuseAtomic(c.atomic); const when = c.when === undefined ? "" : `WHEN ${trigger(c.when, 0)} `; return withPolicy(`RELOAD ${when}${legs(c.legs)} @ ${price(c.price)}`, c.policy); } case "tp": { let head = `TP ${tpTarget(c.target)}`; if (c.frac !== undefined) head += ` frac ${num(c.frac)}`; if (c.over !== undefined) head += ` ${heldQuant(c.over)}`; if (c.price !== undefined) head += ` @ ${price(c.price)}`; return withPolicy(head, c.policy); } case "exit": { refuseExitOnMark(c.when); // marks lie: never emit an EXIT `parse` would reject (ADR-0005) let head = `EXIT ${trigger(c.when, 0)}`; if (c.over !== undefined) head += ` ${heldQuant(c.over)}`; if (c.price !== undefined) head += ` @ ${price(c.price)}`; return withPolicy(head, c.policy); } case "invalidate": return `INVALIDATE ${trigger(c.when, 0)}`; case "cancel-if": return `CANCEL-IF ${trigger(c.when, 0)}`; case "arm": { let head = "ARM"; if (c.when !== undefined) head += ` WHEN ${trigger(c.when, 0)}`; if (c.basis !== undefined) head += ` basis ${price(c.basis)}`; if (c.over !== undefined) head += ` ${heldQuant(c.over)}`; return head; } default: return assertNever(c, "plan-clause"); } } // ───────────────────────────────────────────────────────────────────────────── // Instruments, USING (with elision), provenance, regime, budget, ttl // ───────────────────────────────────────────────────────────────────────────── function expiryStr(e: ExpirySelector): string { switch (e.kind) { case "expiry-dte": return `${num(e.dte)}dte`; case "expiry-date": return e.date; case "expiry-tag": return e.tag; default: return assertNever(e, "expiry"); } } function instrument(i: Instrument): string { return i.expiry === undefined ? i.symbol : `${i.symbol} ${expiryStr(i.expiry)}`; } function expiryEq(a: ExpirySelector | undefined, b: ExpirySelector | undefined): boolean { if (a === b) return true; if (a === undefined || b === undefined) return false; if (a.kind !== b.kind) return false; if (a.kind === "expiry-dte" && b.kind === "expiry-dte") return a.dte === b.dte; if (a.kind === "expiry-date" && b.kind === "expiry-date") return a.date === b.date; if (a.kind === "expiry-tag" && b.kind === "expiry-tag") return a.tag === b.tag; return false; } function instrEq(a: Instrument | undefined, b: Instrument | undefined): boolean { if (a === b) return true; if (a === undefined || b === undefined) return false; return a.symbol === b.symbol && expiryEq(a.expiry, b.expiry); } /** Render the body of a `USING`, elided against the ambient. Returns "" when nothing * survives (caller omits the line entirely). */ function usingBody(u: Using, ambient: Using | undefined): string { const parts: string[] = []; if (u.signal !== undefined && !instrEq(u.signal, ambient?.signal)) { parts.push(`signal ${instrument(u.signal)}`); } if (u.exec !== undefined && !instrEq(u.exec, ambient?.exec)) { parts.push(`exec ${instrument(u.exec)}`); } return parts.join(" "); } function provenanceBrace(p: Provenance): string { const parts: string[] = []; if (p.tier !== undefined) parts.push(`tier: ${p.tier}`); if (p.author !== undefined) parts.push(`author: ${str(p.author)}`); if (p.origin !== undefined) parts.push(`origin: ${str(p.origin)}`); if (p.replay !== undefined) parts.push(`replay: ${str(p.replay)}`); return `{${parts.join(", ")}}`; } function regimeGate(r: RegimeGate): string { const body = r.tags.map((t) => `${t.scope}: ${t.value}`).join(", "); return `regime {${body}}`; } /** Print a plan's top-level WHEN trigger to its canonical text (kestrel-wa0j.29), at outermost * precedence (no enclosing parens) — the ARMED plan's arming premise, captured by the sim driver for * the `armed-plan` pane. Pure; the same canonical trigger algebra a round-trip prints. */ export function printTrigger(t: Trigger): string { return trigger(t, 0); } /** Print a plan's risk BUDGET (the size envelope, e.g. `0.5R`) to its canonical text (kestrel-wa0j.29), * exported for the `armed-plan` pane's size-envelope term. Pure; refuses a non-positive budget (as the * round-trip does). */ export function budgetStr(b: Budget): string { refuseNonPositiveBudget(b.value); // never emit `budget -0.5R` — `parse` would reject it return `${num(b.value)}${b.unit}`; } function ttlStr(t: Ttl): string { return t.kind === "ttl-rel" ? `+${durationStr(t.dur)}` : timeOfDay(t.at); } /** The `because` pre-registration citation (kestrel-rtf): `sha256:<64 hex>`, content-hash only. * Refuse to emit a malformed digest symmetrically — printing `sha256:deadbeef` would print text * `parse` throws on, cracking the round-trip (ADR-0004). */ function citationStr(c: Citation): string { refuseBadCitation(c); return `${c.algo}:${c.hash}`; } // ───────────────────────────────────────────────────────────────────────────── // Comment trivia — re-emit the byte-stable "why" in canonical position (ADR-0033) // ───────────────────────────────────────────────────────────────────────────── /** Render an own-line comment at `pad` indentation: `<pad>#<verbatim-text>`. */ function commentLine(pad: string, raw: string): string { return `${pad}#${raw}`; } /** Append a trailing-inline comment to a code line, canonical single space before `#`. */ function withTrailing(line: string, trailing: string | undefined): string { return trailing === undefined ? line : `${line} #${trailing}`; } function lineCommentAt(cmts: CommentLayer | undefined, at: number): LineComment | undefined { return cmts?.lines?.find((c) => c.at === at); } /** * Assembles a block's printed lines while interleaving its comment trivia (ADR-0033). The * header is ordinal 0; each `line()` call is the next interior ordinal (1..N); `block()` emits * a child block (which owns its own trivia and consumes no ordinal). `finish()` appends the * block's tail comments. The ordinal walk MIRRORS the parser's `commentLayer`, so a canonical * comment-bearing document round-trips byte-for-byte. */ class BlockLines { private readonly out: string[] = []; private ord = 0; constructor(private readonly cmts: CommentLayer | undefined) {} header(pad: string, text: string): void { const lc = lineCommentAt(this.cmts, 0); for (const raw of lc?.leading ?? []) this.out.push(commentLine(pad, raw)); this.out.push(withTrailing(pad + text, lc?.trailing)); } line(pad: string, text: string): void { this.ord += 1; const lc = lineCommentAt(this.cmts, this.ord); for (const raw of lc?.leading ?? []) this.out.push(commentLine(pad, raw)); this.out.push(withTrailing(pad + text, lc?.trailing)); } block(rendered: string): void { this.out.push(rendered); } finish(tailPad: string): string { for (const raw of this.cmts?.tail ?? []) this.out.push(commentLine(tailPad, raw)); return this.out.join("\n"); } } // ───────────────────────────────────────────────────────────────────────────── // Statements // ───────────────────────────────────────────────────────────────────────────── interface Ctx { readonly indent: number; readonly ambientUsing?: Using; } function paneArg(a: PaneArg): string { // One canonical printed form per supersort (ADR-0041 §1): an ident prints its name, a window its // `<n><unit>`, a count its bare numeral, a session ordinal `d-<n>`, an expiry ordinal `e-<n>` — so // `print(parse(text))` stays byte-stable (ADR-0004). if (a.kind === "arg-ident") return a.name; if (a.kind === "arg-count") return num(a.count); if (a.kind === "arg-ordinal") return `${ORDINAL_PREFIX}-${num(a.ordinal)}`; if (a.kind === "arg-expiry") return `${EXPIRY_PREFIX}-${num(a.expiry)}`; return windowStr(a.window); } function printView(v: ViewStatement, ctx: Ctx): string { const cpad = pad(ctx.indent + 1); const head = `VIEW ${v.name}${v.budget === undefined ? "" : ` budget ${num(v.budget)}`}`; const bl = new BlockLines(v.comments); bl.header(pad(ctx.indent), head); for (const p of v.panes) { const args = p.args.map(paneArg).join(" "); bl.line(cpad, args === "" ? p.name : `${p.name} ${args}`); } return bl.finish(cpad); } function printWake(w: WakeStatement, ctx: Ctx): string { const cpad = pad(ctx.indent + 1); const bl = new BlockLines(w.comments); bl.header(pad(ctx.indent), `WAKE ${w.name}`); bl.line(cpad, `WHEN ${trigger(w.when, 0)}`); if (w.because !== undefined) bl.line(cpad, `BECAUSE ${citationStr(w.because)}`); if (w.deliver !== undefined) { const d: DeliverSpec = w.deliver; let s = `DELIVER ${d.view}`; if (d.mandatory === true) s += " MANDATORY"; if (d.keyframe === true) s += " KEYFRAME"; bl.line(cpad, s); } if (w.priority !== undefined) bl.line(cpad, `PRIORITY ${num(w.priority)}`); if (w.coalesce !== undefined) bl.line(cpad, `COALESCE ${w.coalesce}`); if (w.budget !== undefined) { const parts: string[] = []; if (w.budget.wakesPerDay !== undefined) parts.push(`${num(w.budget.wakesPerDay)} wakes/day`); if (w.budget.tokensPerDay !== undefined) parts.push(`${num(w.budget.tokensPerDay)} tokens/day`); if (parts.length > 0) bl.line(cpad, `BUDGET ${parts.join(" ")}`); } if (w.universe !== undefined) bl.line(cpad, `UNIVERSE ${w.universe.universe}`); return bl.finish(cpad); } function printPlan(p: PlanStatement, ctx: Ctx): string { const cpad = pad(ctx.indent + 1); const head = ["PLAN", p.name]; if (p.budget !== undefined) head.push(`budget ${budgetStr(p.budget)}`); if (p.ttl !== undefined) head.push(`ttl ${ttlStr(p.ttl)}`); if (p.regime !== undefined) head.push(regimeGate(p.regime)); if (p.priority !== undefined) head.push(`priority ${num(p.priority)}`); if (p.standing !== undefined) head.push(`standing ${p.standing}`); if (p.provenance !== undefined) head.push(`prov ${provenanceBrace(p.provenance)}`); refuseAtomic(p.atomic); const bl = new BlockLines(p.comments); bl.header(pad(ctx.indent), head.join(" ")); if (p.using !== undefined) { const body = usingBody(p.using, ctx.ambientUsing); if (body !== "") bl.line(cpad, `USING ${body}`); } if (p.when !== undefined) bl.line(cpad, `WHEN ${trigger(p.when, 0)}`); if (p.because !== undefined) bl.line(cpad, `BECAUSE ${citationStr(p.because)}`); for (const c of p.clauses) bl.line(cpad, planClause(c)); return bl.finish(cpad); } const CF_ORDER: readonly Counterfactual["kind"][] = ["cf-ungated", "cf-null", "cf-bracket"]; const CF_NAME: Readonly<Record<string, string>> = { "cf-ungated": "ungated", "cf-null": "null", "cf-bracket": "bracket", }; function gradeDim(d: GradeDimension): string { switch (d.kind) { case "dim-series": return seriesRef(d.series); case "dim-vehicle": return "vehicle"; case "dim-name": return "name"; case "dim-lineage": return "lineage"; default: return assertNever(d, "grade-dim"); } } function printGrade(g: GradeStatement, ctx: Ctx): string { const cpad = pad(ctx.indent + 1); let head = `GRADE ${g.subject.what} ${g.subject.name}`; if (g.over !== undefined) head += ` OVER ${g.over.from}..${g.over.to}`; if (g.fill !== undefined) head += ` FILL ${g.fill}`; const bl = new BlockLines(g.comments); bl.header(pad(ctx.indent), head); if (g.versus.length > 0) { const seen = new Set(g.versus.map((c) => c.kind)); const names = CF_ORDER.filter((k) => seen.has(k)).map((k) => CF_NAME[k]); bl.line(cpad, `VS ${names.join(" ")}`); } if (g.by.length > 0) bl.line(cpad, `BY ${g.by.map(gradeDim).join(", ")}`); return bl.finish(cpad); } function riskLine(r: RiskLine): string { return `RISK ${r.metric} ${quantity(r.threshold)} -> ${r.action}`; } function coverageStr(c: Coverage): string { return `coverage ${c.instruments.map(instrument).join(" ")} thesis ${str(c.thesis)}`; } function arbitrationStr(a: Arbitration): string { return `arbitration ${a.policy}${a.concurrency === undefined ? "" : ` concurrency ${num(a.concurrency)}`}`; } function printBook(b: BookStatement, ctx: Ctx): string { const head = ["BOOK", b.name]; if (b.budget !== undefined) head.push(`budget ${budgetStr(b.budget)}`); if (b.coverage !== undefined) head.push(coverageStr(b.coverage)); if (b.arbitration !== undefined) head.push(arbitrationStr(b.arbitration)); const cpad = pad(ctx.indent + 1); const bl = new BlockLines(b.comments); bl.header(pad(ctx.indent), head.join(" ")); for (const r of b.risk ?? []) bl.line(cpad, riskLine(r)); return bl.finish(cpad); } function printPod(p: PodStatement, ctx: Ctx): string { const childCtx: Ctx = { ...ctx, indent: ctx.indent + 1 }; const cpad = pad(ctx.indent + 1); const bl = new BlockLines(p.comments); bl.header(pad(ctx.indent), `POD ${p.name}`); // RISK lines are the pod's OWN interior lines (ordinals 1..r); child blocks own their trivia. for (const r of p.risk) bl.line(cpad, riskLine(r)); for (const child of p.children) bl.block(printStatement(child, childCtx)); return bl.finish(cpad); } function printStatement(s: Statement, ctx: Ctx): string { switch (s.kind) { case "view": return printView(s, ctx); case "wake": return printWake(s, ctx); case "plan": return printPlan(s, ctx); case "grade": return printGrade(s, ctx); case "pod": return printPod(s, ctx); case "book": return printBook(s, ctx); default: return assertNever(s, "statement"); } } function printModule(m: Module): string { // Provenance ceiling (ARCHITECTURE §6): never emit a module whose statement elevates above // its declared tier — `parse` would reject the text, cracking the round-trip. refuseProvenanceElevation(m.provenance?.tier, m.statements); // Module directive lines carry 0-based comment ordinals in canonical emit order (imports, // provenance, using) — the module has no header line of its own (ADR-0033). const header: string[] = []; let dord = 0; const pushDirective = (text: string): void => { const lc = lineCommentAt(m.comments, dord); for (const raw of lc?.leading ?? []) header.push(commentLine("", raw)); header.push(withTrailing(text, lc?.trailing)); dord += 1; }; for (const imp of m.imports) { pushDirective(`IMPORT { ${imp.names.join(", ")} } FROM ${str(imp.from)}`); } if (m.provenance !== undefined) pushDirective(`PROVENANCE ${provenanceBrace(m.provenance)}`); if (m.using !== undefined) { const body = usingBody(m.using, undefined); if (body !== "") pushDirective(`USING ${body}`); } const ctx: Ctx = { indent: 0, ...(m.using !== undefined ? { ambientUsing: m.using } : {}), }; const blocks: string[] = []; if (header.length > 0) blocks.push(header.join("\n")); for (const s of m.statements) blocks.push(printStatement(s, ctx)); let body = blocks.join("\n\n"); // Document-tail comments (incl. a comment-only module) sit at column 0 right after the last // block — the canonical printer owns vertical spacing, so no blank line precedes them. const tail = m.comments?.tail ?? []; if (tail.length > 0) { const tailText = tail.map((raw) => commentLine("", raw)).join("\n"); body = body === "" ? tailText : `${body}\n${tailText}`; } return body; } /** * Print any node to canonical Kestrel text. The round-trip target: for well-formed * input, `print(parse(print(x))) === print(x)` byte-for-byte (ADR-0004). No trailing * newline — file serialization appends one (`.editorconfig`). */ export function print(node: KestrelNode): string { if (node.kind === "module") return printModule(node); return printStatement(node, { indent: 0 }); }