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
text/typescript
/**
* # 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 });
}