/**
 * # frame/pane-catalog — the ONE pane registry: single source for render / View / prompt (kestrel-4gl.13)
 *
 * Every available (non-kernel) pane is catalogued HERE, exactly once, with its metadata + its pure
 * builder. This registry is what three consumers all read, so they can never drift:
 *   1. the **renderer** ({@link ./render.ts}) MATERIALIZES panes from it;
 *   2. the **View language** ({@link ../lang/ast.ts ViewStatement}) SELECTS panes from it (by id);
 *   3. a **prompt** will later ADVERTISE the menu from it (`title` + one-line `description`).
 *
 * The **cockpit kernel is NOT in this catalog** — it is built in code and LEADS every frame,
 * non-configurably, OUTSIDE any View selection (its reserved ids `kernel`/`cockpit` are refused,
 * {@link ../frame/types.ts assertPaneIdAllowed}). This catalog is the *configurable body* only.
 *
 * ## Single-source guarantee (the whole point)
 * A pane that is NOT in {@link PANE_CATALOG} CANNOT render (there is no other builder path), and a
 * View naming an id absent from the catalog FAILS CLOSED ({@link resolveView}) — never a silent
 * drop. Adding a new pane is one entry here; it is then selectable, renderable, and advertisable
 * with no change anywhere else.
 *
 * ## Attribution class (charter) + token cost
 * Each entry declares an {@link PaneAttribution} (`OBS` observed | `CALC` derived | `MODEL` a model
 * output — a MODEL pane carries a receipt in its rendered text, e.g. the chain's `fair` fairNote)
 * and a documented `tokenCostEstimate` (a rough chars/4 figure for the prompt menu + the View
 * budget planner; the REAL cost is measured at materialization under the active tokenizer).
 *
 * ## Purity (ADR-0009)
 * Every builder is a PURE function of its {@link PaneBuildContext} — no wall clock, no RNG; the same
 * context renders a byte-identical block. Invents no value (`—` for the unknowns, {@link ./format.ts}).
 */

import { KernelHonestyError, assertPaneIdAllowed, makeField } from "./types.ts";
import type { SeatId } from "./seat.ts";
import { founderViewFor } from "./founder-registry.ts";
// The canonical render tokenizer contract (kestrel-2fab): the budget guard measures a materialized
// pane's real cost under it. Homed in the render barrel — importing the pure type here does NOT pull
// in the renderer (`./render.ts` imports THIS module), so no cycle is created.
import type { Tokenizer } from "../render/index.ts";
import { paneRefusal } from "./refusals.ts";
import type {
  ArmedPlanTerms,
  ArmedPlanTermsEntry,
  ChainRow,
  InstrumentSpec,
  Kernel,
  LevelSet,
  MarketPane,
  Position,
  PriorSessionTape,
  PriorVantage,
  RestingOrder,
  FillRecord,
  Budget,
  TapeRow,
  VehicleHealth,
} from "./types.ts";
import { SPOT_STALE_AFTER_MS } from "./options-analytics.ts";
import type { OptionsAnalytics, OptionsExpiry } from "./options-analytics.ts";
import { DASH, isSpotLeg, money, num, pad, px, signedInt, usd } from "./format.ts";
import { candleLine, tapeBounds } from "./tape-geometry.ts";
import { resolveScheme, rollingAnchorOf, schemeHasBoundary, type SessionScheme } from "./session-scheme.ts";
import { PANE_BOUNDARY_REQUIREMENT, schemeConditionedDefect } from "./paradigm-ledger.ts";

// ─────────────────────────────────────────────────────────────────────────────
// The build context a pane builder reads (a pure projection of the frame input)
// ─────────────────────────────────────────────────────────────────────────────

/** Everything a body-pane builder may read — a pure projection of the OPEN/WAKE frame input. A
 * builder is a total function of THIS; it never reaches outside it (no wall clock, no RNG). */
export interface PaneBuildContext {
  readonly instruments: readonly InstrumentSpec[];
  readonly market: MarketPane;
  readonly kernel: Kernel;
  readonly frameKind: "OPEN" | "WAKE";
  /** The prior author vantage's SERVED values (kestrel-wa0j.48) — a pure projection of the WAKE
   * frame input's {@link ../frame/types.ts PriorVantage}. Present only on a WAKE frame the driver
   * threaded a prior vantage into; the `delta` pane fails closed to absent-with-reason without it.
   * Absent on OPEN and on any frame with no prior vantage (additive: those frames are byte-identical). */
  readonly priorVantage?: PriorVantage;
  /** Minutes since the author's last vantage (the WAKE frame's `minutesSinceLast`) — the `(<N>m ago)`
   * cue on the `delta` header. Absent on OPEN / when unknown ⇒ the header omits the age (never a
   * fabricated gap). PURE: a value carried on the frame input, not a wall-clock read. */
  readonly minutesSinceLast?: number | null;
  /** The prior sessions' tapes (kestrel-wa0j.20) — a pure projection of the frame input's
   * {@link ../frame/types.ts PriorSessionTape}[]. Present only on a frame the driver threaded a multiday
   * tape into; `tape d-N` serves ordinal N's rows as its OWN single-session block, else it renders the
   * Train 1B absence line (byte-identical). Absent on a single-day frame (additive, byte-identical). */
  readonly priorSessions?: readonly PriorSessionTape[];
  /** The ARMED plans' enforced terms (kestrel-wa0j.29) — a pure projection of the frame input's
   * {@link ../frame/types.ts ArmedPlanTerms}. Present only on a frame the driver threaded armed-plan
   * terms into (a wake with a plan in an enforced state); the `armed-plan` pane fails closed to exactly
   * one absent-with-reason line without it. Absent ⇒ byte-identical (the pane is catalog-only). */
  readonly armedPlan?: ArmedPlanTerms;
}

// ─────────────────────────────────────────────────────────────────────────────
// The pane builders — the EXISTING v1 panes, moved here verbatim (byte-identical output)
// ─────────────────────────────────────────────────────────────────────────────

// ── instruments ─────────────────────────────────────────────────────────────
function renderInstruments(instruments: readonly InstrumentSpec[]): string {
  if (instruments.length === 0) return "instruments:\n  (none)";
  const lines = instruments.map((i) => {
    const role = i.role !== undefined ? ` ${i.role}` : "";
    const mult = `mult ${px(i.multiplier)}`;
    const tick = `tick ${px(i.tick)}`;
    return `  ${i.symbol} ${i.assetClass}${role}  ${mult}  ${tick}`;
  });
  return ["instruments:", ...lines].join("\n");
}

// ── levels ───────────────────────────────────────────────────────────────────
/**
 * The `spot` cell: the price, plus a `[STALE <age>]` tag once the feed that printed it has gone
 * quiet longer than the backstop (kestrel-rs4). A dead feed and a genuinely frozen market render
 * the SAME price — an untagged one is read as a clean current value, so the author acts on a level
 * the tape stopped supporting. The tag is the only thing that tells the two apart, and it is worth
 * its tokens ONLY when it says something: a fresh spot renders bare (byte-identical to a pre-rs4
 * frame), as does a spot the caller supplied no age for (an absent age is not a freshness claim).
 * `spot` itself is never suppressed or invented — the value prints, the tag keeps it honest (the
 * chain pane's stale-spot flag idiom).
 */
function spotCell(lv: LevelSet): string {
  const age = lv.spotStaleSeconds;
  if (age === undefined || age === null || age * 1000 <= SPOT_STALE_AFTER_MS) return `spot ${px(lv.spot)}`;
  return `spot ${px(lv.spot)} [STALE ${Math.round(age / 60)}m]`;
}

function renderLevels(instrument: string, lv: LevelSet): string {
  const parts = [
    spotCell(lv),
    `prior_close ${px(lv.priorClose)}`,
    `hod ${px(lv.hod)}`,
    `lod ${px(lv.lod)}`,
    `vwap ${px(lv.vwap)}`,
    `or ${px(lv.orLow)}–${px(lv.orHigh)}`,
  ];
  return `levels · ${instrument}\n  ${parts.join("  ·  ")}`;
}

// ── levels level-set inflection (kestrel-wa0j.24 / ADR-0041 §1) ─────────────────
//
// The `levels` pane gains a LEVEL-SET slot: a variadic run of LevelName idents naming WHICH level
// cells render (`levels vwap hod` renders only vwap + hod). No-arg `levels` is unchanged (all six
// cells, byte-identical). Each ident is a ground ADDRESS over the closed LEVEL_ROSTER — never an
// expression (ADR-0041 §1). DENOTATION: an ident names one cell of the frozen {@link LevelSet}; the
// cell renders its value (or `—` when the LevelSet carries none — absent-not-hidden, unchanged). The
// selected cells render in AUTHORED order (the arg selects WHICH cells; it never invents a value),
// so `levels vwap hod` and `levels hod vwap` are DISTINCT byte-stable forms (biunique spelling→bytes,
// ADR-0041 §1 — no collision). An ident OUTSIDE the roster fails closed NAMING the roster (a named
// level-SET like `registry` is a LATENT future inflection, not an individual level — ADR-0041 §3).

/** The closed roster of individual-level idents the `levels` level-set slot addresses — exactly the
 * cells `renderLevels` prints, in its canonical order. (A named level-SET, e.g. `registry`, is a
 * distinct LATENT inflection — the band-keyed level-set train — not a member of THIS roster.) */
const LEVEL_ROSTER = ["spot", "prior_close", "hod", "lod", "vwap", "or"] as const;
type LevelRosterName = (typeof LEVEL_ROSTER)[number];

/** Render ONE named level cell, byte-identical to that cell's rendering inside {@link renderLevels}. */
function levelCell(name: LevelRosterName, lv: LevelSet): string {
  switch (name) {
    case "spot":
      return spotCell(lv);
    case "prior_close":
      return `prior_close ${px(lv.priorClose)}`;
    case "hod":
      return `hod ${px(lv.hod)}`;
    case "lod":
      return `lod ${px(lv.lod)}`;
    case "vwap":
      return `vwap ${px(lv.vwap)}`;
    case "or":
      return `or ${px(lv.orLow)}–${px(lv.orHigh)}`;
  }
}

/**
 * The `levels` pane's ARGED builder (kestrel-wa0j.24 / ADR-0041 §1): a variadic LevelName level-set.
 * Reached ONLY with args already refined to sort LevelName (bare idents) by {@link bindArgs}; this
 * builder applies the `renderable`-rung VALUE check — each ident must NAME a cell in the closed
 * {@link LEVEL_ROSTER}. An unknown ident fails closed NAMING the roster + citing the LATENT
 * named-level-set cell (ADR-0041 §3 four-state paradigm). Selected cells render in authored order.
 */
function renderLevelsWithArgs(ctx: PaneBuildContext, args: readonly PaneSelectionArg[]): string {
  const lv = ctx.market.levels;
  const cells: string[] = [];
  for (const a of args) {
    if (a.kind !== "arg-ident") {
      // Unreachable after bindArgs (sort LevelName ⇐ ident) — fail closed rather than trust it.
      throw paneRefusal({ kind: "unreachable-bound-arg", pane: "levels", expected: "bound LevelName idents", args: [a] });
    }
    if (!(LEVEL_ROSTER as readonly string[]).includes(a.name)) {
      throw paneRefusal({ kind: "unknown-value-naming-roster", pane: "levels", slot: "level-set", value: a.name, roster: LEVEL_ROSTER });
    }
    cells.push(levelCell(a.name as LevelRosterName, lv));
  }
  return `levels · ${ctx.market.instrument}\n  ${cells.join("  ·  ")}`;
}

// ── tape (the incumbent rotated candlestick, rendering-variants.md #2) ─────────
/**
 * Render the rotated-candlestick tape. Price → column position; time flows down the page
 * (append-only). The axis bounds are the data's own extremes (real input numbers — the renderer
 * derives layout, never a value). Each row: leading indent to the low column, `─` wick to the
 * high column, `█` body over open→close. A flat window collapses to a single block column. The
 * candle geometry is the shared {@link ../frame/tape-geometry.ts candleLine} (one geometry, drawn
 * identically by the ARM candlestick adapter); this function owns only the header + row prefix.
 */
function renderTape(rows: readonly TapeRow[], bucketMin: number, sessionTag?: string): string {
  // kestrel-wa0j.20: an OPTIONAL header session tag names a PRIOR session (`d-N` [+ label]) when this
  // block is a `tape d-N` render. Absent (the d-0 / no-arg path) ⇒ the header is BYTE-IDENTICAL to
  // before — a prior-session block differs from a d-0 block of the same rows ONLY by this ` · <tag>`.
  const tag = sessionTag !== undefined ? ` · ${sessionTag}` : "";
  if (rows.length === 0) {
    return [`tape ${bucketMin}m${tag} · (no prints this window)`].join("\n");
  }

  const { lo, hi } = tapeBounds(rows);
  const anchorClock = rows[0]!.clock;
  const header = `tape ${bucketMin}m${tag} · axis ${px(lo)}→${px(hi)} · anchor @ ${anchorClock} ET`;
  const body = rows.map((r) => `${r.clock}  ${candleLine(r, lo, hi)}`);

  return [header, ...body].join("\n");
}

/**
 * CALC re-bucketing (kestrel-wa0j.19 §1): aggregate consecutive runs of `factor` served rows into
 * ONE wider bucket. Deterministic AGGREGATION of real inputs — every number on an output row is a
 * function of the input rows' own numbers (open = the run's FIRST open, high = max high, low = min
 * low, close = the run's LAST close, clock = the CLOSING row's clock — the bucket closed there,
 * volume = the sum WHEN every grouped row carries one; a partially-volumed group omits it rather
 * than present a partial sum as the bucket's volume). A TRAILING PARTIAL run aggregates the rows
 * that exist — it is real data, and nothing on it is invented. Pure; input order preserved.
 * Exported so the aggregation semantics are directly testable (and reusable by the session lane).
 */
export function rebucketTape(rows: readonly TapeRow[], factor: number): TapeRow[] {
  const out: TapeRow[] = [];
  for (let i = 0; i < rows.length; i += factor) {
    const run = rows.slice(i, i + factor);
    const first = run[0]!;
    const last = run[run.length - 1]!;
    let high = -Infinity;
    let low = Infinity;
    let volume = 0;
    let allVolumed = true;
    for (const r of run) {
      if (r.high > high) high = r.high;
      if (r.low < low) low = r.low;
      if (r.volume === null || r.volume === undefined || !Number.isFinite(r.volume)) allVolumed = false;
      else volume += r.volume;
    }
    out.push({
      clock: last.clock,
      open: first.open,
      high,
      low,
      close: last.close,
      ...(allVolumed ? { volume } : {}),
    });
  }
  return out;
}

/**
 * TRUE iff a `tape <window>` arg is SERVABLE by a context whose rows arrive bucketed at `bucketMin`
 * minutes: the window has a fixed minute width AND is a positive INTEGER MULTIPLE of the served
 * bucket (equal ⇒ factor 1). The ONE shared validity predicate (kestrel-wa0j.19 §1): the session
 * lane checks a scheduled View's window arg against it at SCHEDULE time (so a bad window is refused
 * before it ever reaches an unguarded delivery render), and the arged tape builder enforces the
 * SAME predicate at materialization — the two can never drift. Pure unit arithmetic, no context.
 */
export function windowServableBy(
  bucketMin: number,
  window: { readonly value: number; readonly unit: string },
): boolean {
  const w = windowMinutes(window);
  if (w === null || !Number.isFinite(bucketMin) || bucketMin <= 0) return false;
  const factor = w / bucketMin;
  return Number.isInteger(factor) && factor >= 1;
}

/**
 * The tape pane's ARGED builder (kestrel-wa0j.3 / T3a + kestrel-wa0j.19 §1): `tape 5m` — a single
 * WINDOW arg names the bucket width the author wants. The context serves rows PRE-BUCKETED at
 * `ctx.market.tapeBucketMin`; a requested window that is an INTEGER MULTIPLE of that width is
 * served by CALC re-bucketing ({@link rebucketTape} — deterministic aggregation of the real rows,
 * so `tape 5m` materializes over the product driver's 1m buckets), an EQUAL window renders the
 * rows as-is (byte-identical to the no-arg tape), and a SUB-RESOLUTION or NON-MULTIPLE window
 * FAILS CLOSED naming pane + arg + the served width (the served rows cannot honestly express it —
 * never mismatched data under a requested label).
 *
 * MIGRATED onto the ParamSlot substrate (kestrel-wa0j.24 / ADR-0041 §1): the ARITY + SORT refusals
 * (an ident arg, extra args) are the uniform {@link bindArgs} `mat`-rung surface — this builder is
 * reached with the args already refined (an optional Window + an optional SessionOrdinal), so it keeps
 * only the `renderable`-rung reads (which need the Frame). Accepted forms render BYTE-IDENTICALLY to
 * before the migration; the servability message is unchanged.
 *
 * SESSION addressing (SessionOrdinal, Train 1B / ADR-0041 §1): `tape d-0` names the CURRENT session
 * (the frozen frame's own tape) and collapses to the window path — byte-identical to the un-sessioned
 * render. `tape d-N` (N ≥ 1) names a PRIOR session, a TOTAL `renderable` judgment (ADR-0041 §3: absence
 * is a value): scheme-conditioned absence when the instrument's {@link ../frame/session-scheme.ts
 * SessionScheme} declares no session-delimiting `close` boundary (a perp's rolling UTC day — reuse the
 * wa0j.44 conditioning path, never an invented session), else a frame-conditioned absence naming the
 * multiday DATA train (Train 1B lands the ADDRESS; kestrel-wa0j.20 lands the DATA). d-0 is date-blind
 * (relative), exactly like the servability label.
 */
function renderTapeWithArgs(ctx: PaneBuildContext, args: readonly PaneSelectionArg[]): string {
  const windowArg = args.find((a): a is Extract<PaneSelectionArg, { kind: "arg-window" }> => a.kind === "arg-window");
  const sessionArg = args.find((a): a is Extract<PaneSelectionArg, { kind: "arg-ordinal" }> => a.kind === "arg-ordinal");
  // A PRIOR session (d-N, N ≥ 1): a total renderable absence (never an invented session). d-0 falls
  // through to the current-session window path below.
  if (sessionArg !== undefined && sessionArg.ordinal >= 1) {
    return renderTapePriorSession(ctx, sessionArg.ordinal, windowArg);
  }
  const available = ctx.market.tapeBucketMin;
  // Session-only `tape d-0`: the current session at the served bucket — byte-identical to no-arg tape.
  if (windowArg === undefined) return renderTape(ctx.market.tape, available);
  const requested = windowMinutes(windowArg.window);
  if (requested === null || !windowServableBy(available, windowArg.window)) {
    throw paneRefusal({ kind: "window-not-servable", pane: "tape", arg: windowArg, servedBucketMin: available });
  }
  const factor = requested / available;
  // factor 1 (requested === served): the rows as-is — byte-identical to the no-arg tape.
  if (factor === 1) return renderTape(ctx.market.tape, requested);
  // factor > 1: CALC re-bucketing — aggregate runs of `factor` served rows into the requested width.
  return renderTape(rebucketTape(ctx.market.tape, factor), requested);
}

/** The SessionOrdinal `d-N` (N ≥ 1) prior-session render (Train 1B ADDRESS + kestrel-wa0j.20 DATA,
 * ADR-0041 §1/§3) — a TOTAL `renderable` judgment. Three outcomes, single-sourced (the census + this
 * render read the same tables, honoring the authority arrow — materialization consults the
 * scheme/boundary DATA, never the ledger STATE):
 *   • SCHEME-conditioned ABSENCE (reuse the wa0j.44 path): the instrument's SessionScheme declares no
 *     session-delimiting `close` boundary (a perp's rolling UTC day) ⇒ there is no prior-session address
 *     under this scheme — render the sanctioned periphrasis (anchor to the declared rolling extreme).
 *   • SERVED (kestrel-wa0j.20): the scheme HAS the boundary AND the frame CARRIES ordinal N's tape ⇒
 *     render that session's OWN complete single-session block (the EXISTING renderer over its rows; its
 *     own axis from its own extremes; header naming `d-N` + the label) — sessions are NEVER folded.
 *   • FRAME-conditioned ABSENCE: the scheme has the boundary but the frame carries no session at `d-N`
 *     (the v1 single-day tape) — the Train 1B absence line, deliberately NOT ratchet-pinned so this
 *     absence→data flip needs no re-pin. */
function renderTapePriorSession(
  ctx: PaneBuildContext,
  ordinal: number,
  windowArg: Extract<PaneSelectionArg, { kind: "arg-window" }> | undefined,
): string {
  const scheme = schemeOfMarket(ctx);
  const label = windowArg !== undefined ? ` ${windowArg.window.value}${windowArg.window.unit}` : "";
  const addr = `d-${ordinal}`;
  const head = `tape${label} · ${ctx.market.instrument} · ${addr}`;
  const defect = schemeConditionedDefect("tape", scheme);
  if (defect !== undefined) {
    const anchor = rollingAnchorOf(scheme) ?? "rolling extreme";
    return `${head} · (${defect.reason} (${scheme}) — no prior-session address under this SessionScheme; ${defect.periphrasis}: ${anchor})`;
  }
  // kestrel-wa0j.20 — the frame may CARRY ordinal N's tape (the DATA that fills the Train 1B address).
  // Serve it as its OWN block; NEVER fold with the current session (that is wa0j.20b, behind PR #194).
  const carried = ctx.priorSessions?.find((s) => s.ordinal === ordinal);
  if (carried !== undefined && carried.rows.length > 0) {
    return renderPriorSessionBlock(ctx, carried, ordinal, windowArg);
  }
  return `${head} · (frozen frame carries no session at ${addr} — the current session only; multiday tape data threads with kestrel-wa0j.20)`;
}

/** Render one carried prior session as its OWN complete single-session tape block (kestrel-wa0j.20).
 * Reuses the EXISTING single-session {@link renderTape} + the SAME window servability/re-bucketing path
 * the d-0 render walks — so a `tape d-N` block is byte-format IDENTICAL to a `tape d-0` render of the
 * same rows except the header names the ordinal (+ the session label when present). Its axis is derived
 * from ITS OWN extremes (renderTape reads the rows it is handed); no cross-session anchor, no fold. */
function renderPriorSessionBlock(
  ctx: PaneBuildContext,
  session: PriorSessionTape,
  ordinal: number,
  windowArg: Extract<PaneSelectionArg, { kind: "arg-window" }> | undefined,
): string {
  const available = ctx.market.tapeBucketMin;
  const tag = session.sessionLabel !== undefined ? `d-${ordinal} (${session.sessionLabel})` : `d-${ordinal}`;
  // No window arg: the prior session at the served bucket (mirrors the `tape d-0` no-window path).
  if (windowArg === undefined) return renderTape(session.rows, available, tag);
  const requested = windowMinutes(windowArg.window);
  if (requested === null || !windowServableBy(available, windowArg.window)) {
    throw paneRefusal({ kind: "window-not-servable", pane: "tape", arg: windowArg, servedBucketMin: available });
  }
  const factor = requested / available;
  // factor 1 (requested === served): the rows as-is; factor > 1: CALC re-bucketing (same as d-0).
  if (factor === 1) return renderTape(session.rows, requested, tag);
  return renderTape(rebucketTape(session.rows, factor), requested, tag);
}

// ── chain (near-money) — bid/ask/fair + receipt + dark flags ───────────────────
function darkFlag(row: ChainRow): string {
  const bidDark = row.bid === null;
  const askDark = row.ask === null;
  if (bidDark && askDark) return "dark";
  if (bidDark) return "bid dark";
  if (askDark) return "ask dark";
  return DASH;
}

function renderChain(underlier: string, chain: readonly ChainRow[], dte?: number | null): string {
  // The RELATIVE days-to-expiry tag (kestrel-wa0j.19 §4) — restores the `0dte` the header lost in
  // the one-renderer swap (the pre-swap day renderer stamped `(<dte>dte)`; the machine channel kept
  // it as `AuthorFrame.chain.dte`). Relative, never a date (date-blind). `null`/absent ⇒ omitted —
  // the header is byte-identical to today (the TapeRow.volume idiom).
  const dteTag = dte !== undefined && dte !== null && Number.isFinite(dte) ? ` · ${dte}dte` : "";
  if (chain.length === 0) {
    return `chain (near-money) · ${underlier}${dteTag}\n  (no legs)`;
  }
  const head = `  ${pad("strike", 8)}${pad("R", 3)}${pad("bid", 8)}${pad("ask", 8)}${pad("fair", 10)}flags`;
  const lines = chain.map((r) => {
    const fair = r.fairNote !== undefined && r.fair !== null && r.fair !== undefined
      ? `${px(r.fair)} ${r.fairNote}`
      : px(r.fair);
    // The fair cell carries a variable-length receipt annotation; pad to at least its own
    // length + a 2-space gutter so the flags column never jams against it.
    const fairCell = pad(fair, Math.max(10, fair.length + 2));
    return `  ${pad(px(r.strike), 8)}${pad(r.right, 3)}${pad(px(r.bid), 8)}${pad(px(r.ask), 8)}${fairCell}${darkFlag(r)}`;
  });
  return [`chain (near-money) · ${underlier}${dteTag}`, head, ...lines].join("\n");
}

// ── chain Count inflection (kestrel-wa0j.24 / ADR-0041 §1) ──────────────────────
//
// The `chain` pane gains a COUNT slot: `chain 12` addresses the 12 NEAREST-THE-MONEY strikes of the
// frozen near-money chain. DENOTATION: a Count is a ground cardinal naming N nearest strikes — an
// ADDRESS over the frozen chain, never a computation (ADR-0041 §1). The frame already emits legs
// nearest-money FIRST (see `renderChain` in src/session/day.ts — "nearest-the-money first is the
// tape's own order"), so selection preserves that order (no re-sort, no invented ordering; robust
// even when spot is unknown). No-arg `chain` is unchanged (renders every carried leg).
//
// SHORTFALL is FAIL-CLOSED per absent-not-hidden (ADR-0041 §3), NOT a hard refusal and NEVER silently
// fewer: a Count the frozen chain cannot fully serve renders EVERY available strike (real data —
// invent nothing) PLUS an explicit absent-with-reason line for the missing strikes. `chain N` where
// N ≥ the available strike count therefore renders the whole chain + the shortfall line; `chain N`
// where N equals the available count is byte-identical to the no-arg chain (the selection anchor).

/** Select the rows of the N nearest-money DISTINCT strikes, preserving the frame's nearest-first
 * order (no re-sort), and report how many distinct strikes the chain carries. Pure. */
function selectNearMoneyStrikes(chain: readonly ChainRow[], n: number): { readonly rows: ChainRow[]; readonly availableStrikes: number } {
  const seen = new Set<number>();
  const ordered: number[] = [];
  for (const r of chain) {
    if (!seen.has(r.strike)) {
      seen.add(r.strike);
      ordered.push(r.strike);
    }
  }
  const keep = new Set(ordered.slice(0, n));
  return { rows: chain.filter((r) => keep.has(r.strike)), availableStrikes: ordered.length };
}

/**
 * The `chain` pane's ARGED builder (kestrel-wa0j.24 + Train 1B / ADR-0041 §1): an optional Count
 * (the N nearest-money strikes) + an optional ExpiryOrdinal (which expiry). Reached with the args
 * already refined by {@link bindArgs}; this builder applies the `renderable`-rung reads.
 *
 * EXPIRY addressing (ExpiryOrdinal, Train 1B): `chain e-0` names the NEAREST expiry — the frozen
 * near-money chain — and collapses to the Count path (byte-identical to the un-expiry render).
 * `chain e-N` (N ≥ 1) names a FURTHER-OUT expiry: the frozen frame carries ONE expiry of chain, so
 * this is a TOTAL renderable absence (ADR-0041 §3: absence is a value) naming the multi-expiry DATA
 * train (kestrel-wa0j.20) — never a fabricated expiry. On the nearest expiry a Count SHORTFALL stays
 * absent-not-hidden (available strikes + a named shortfall line, never silently fewer).
 */
function renderChainWithArgs(ctx: PaneBuildContext, args: readonly PaneSelectionArg[]): string {
  const countArg = args.find((a): a is Extract<PaneSelectionArg, { kind: "arg-count" }> => a.kind === "arg-count");
  const expiryArg = args.find((a): a is Extract<PaneSelectionArg, { kind: "arg-expiry" }> => a.kind === "arg-expiry");
  // A FURTHER-OUT expiry (e-N, N ≥ 1): a total renderable absence (never a fabricated expiry). e-0
  // falls through to the nearest-expiry Count path below.
  if (expiryArg !== undefined && expiryArg.expiry >= 1) {
    return renderChainFurtherExpiry(ctx, expiryArg.expiry, countArg);
  }
  // e-0 (or no expiry arg) + no Count: the nearest expiry's whole chain — byte-identical to no-arg chain.
  if (countArg === undefined) {
    return renderChain(ctx.market.instrument, ctx.market.chain, ctx.market.chainDte);
  }
  const n = countArg.count;
  const { rows, availableStrikes } = selectNearMoneyStrikes(ctx.market.chain, n);
  const base = renderChain(ctx.market.instrument, rows, ctx.market.chainDte);
  if (availableStrikes >= n) return base; // fully served — the N nearest strikes (byte-identical to no-arg when N = available)
  const missing = n - availableStrikes;
  // Absent-not-hidden (ADR-0041 §3): render the available strikes, then NAME the shortfall — never a
  // silent fewer-than-requested, never a fabricated leg to pad the count.
  const shortfall =
    `  (requested ${n} near-money strikes; ${availableStrikes} available — ${missing} absent with reason: ` +
    `the frozen near-money chain carries only ${availableStrikes} strike${availableStrikes === 1 ? "" : "s"})`;
  return `${base}\n${shortfall}`;
}

/** The ExpiryOrdinal `e-N` (N ≥ 1) further-out-expiry absence render (Train 1B / ADR-0041 §1/§3) — a
 * TOTAL `renderable` judgment: the address is valid (it bound at `mat`) but the frozen frame carries
 * only the nearest expiry, so the pane renders the absence AS A VALUE (naming the multi-expiry data
 * train, kestrel-wa0j.20), never a fabricated expiry. A carried Count is echoed (the requested count is
 * moot when the expiry is absent — say so, never silently drop it). */
function renderChainFurtherExpiry(
  ctx: PaneBuildContext,
  expiry: number,
  countArg: Extract<PaneSelectionArg, { kind: "arg-count" }> | undefined,
): string {
  const countLabel = countArg !== undefined ? ` ${countArg.count}` : "";
  const addr = `e-${expiry}`;
  return (
    `chain (near-money)${countLabel} · ${ctx.market.instrument} · ${addr} · ` +
    `(frozen chain carries no expiry at ${addr} — the nearest expiry only; multi-expiry chain data threads with kestrel-wa0j.20)`
  );
}

// ── macro — absent-with-reason in v1 (EVALUATION.md briefing spec) ─────────────
const MACRO_PANE = "macro: unavailable (v1 harness)";

// ── acting-detail (RUNTIME §5–6) — the plan-lifecycle view ─────────────────────
function planTag(plan: string | undefined): string {
  return plan === undefined ? "" : `  (${plan})`;
}

function renderPositions(positions: readonly Position[]): string {
  if (positions.length === 0) return "positions:\n  (none)";
  // ADR-0017 (kestrel-orx): an equity/spot leg has NO strike/right — render `<instrument> shares` and
  // DROP the option-only `fair` column (a spot leg's fair is not a chain leg); an option leg keeps
  // today's `<strike><right>  … fair …` form BYTE-IDENTICAL.
  const lines = positions.map((p: Position) =>
    isSpotLeg(p)
      ? `  ${signedInt(p.qty)} ${p.instrument} shares  basis ${px(p.basis)}${unrealTag(p)}${planTag(p.plan)}`
      : `  ${signedInt(p.qty)} ${p.instrument} ${px(p.strike)}${p.right}  basis ${px(p.basis)}  fair ${px(p.fair)}${unrealTag(p)}${planTag(p.plan)}`,
  );
  return ["positions:", ...lines].join("\n");
}

/** The running unrealized-P&L tag (kestrel-c11) — the position's `unrealUsd` in DOLLARS, so the agent
 * READS its P&L instead of computing (and mis-scaling) it. Absent ⇒ no tag (purely additive); `null`
 * ⇒ `unreal —` (mark UNKNOWN, fail-closed — never a fabricated 0). */
function unrealTag(p: Position): string {
  return p.unrealUsd === undefined ? "" : `  unreal ${usd(p.unrealUsd)}`;
}

function renderResting(resting: readonly RestingOrder[]): string {
  if (resting.length === 0) return "resting:\n  (none)";
  const lines = resting.map((o: RestingOrder) => {
    const note = o.note !== undefined ? `  [${o.note}]` : "";
    // ADR-0017 (kestrel-orx): equity/spot ⇒ `<instrument> shares`; option ⇒ `<strike><right>` (unchanged).
    const leg = isSpotLeg(o) ? "shares" : `${px(o.strike)}${o.right}`;
    return `  ${o.side.toUpperCase()} ${o.qty} ${o.instrument} ${leg} @ ${px(o.px)}${note}${planTag(o.plan)}`;
  });
  return ["resting:", ...lines].join("\n");
}

function renderFills(fills: readonly FillRecord[]): string {
  if (fills.length === 0) return "fills since last:\n  (none)";
  const lines = fills.map((f: FillRecord) => {
    const at = f.clock !== undefined ? ` @${f.clock}` : "";
    // ADR-0017 (kestrel-orx): equity/spot ⇒ `<instrument> shares`; option ⇒ `<strike><right>` (unchanged).
    const leg = isSpotLeg(f) ? "shares" : `${px(f.strike)}${f.right}`;
    return `  ${f.side.toUpperCase()} ${f.qty} ${f.instrument} ${leg} @ ${px(f.px)}${at}${planTag(f.plan)}`;
  });
  return ["fills since last:", ...lines].join("\n");
}

function renderBudget(budget: Budget | null | undefined): string {
  // kestrel-wa0j.47: labelled `premium budget` (dollars of premium spend), never bare `budget` —
  // the kernel's `-- BUDGET / REMAINING-R --` section quotes the RISK envelope in R units, and one
  // word carrying two units across two blocks is exactly the confusable-surface ADR-0041 §1 forbids.
  if (budget === null || budget === undefined) return `premium budget: ${DASH}`;
  const total = budget.total !== undefined ? `  (total ${px(budget.total)}` : "";
  const maxR = budget.maxConcurrentR !== undefined ? `, maxR ${px(budget.maxConcurrentR)})` : total !== "" ? ")" : "";
  return `premium budget: used ${money(budget.used)} / remaining ${money(budget.remaining)}${total}${maxR}`;
}

function renderPlans(kernel: Kernel): string {
  if (kernel.plans.length === 0) return "plans:\n  (none)";
  const lines = kernel.plans.map((p) => {
    const outcome = p.outcome !== undefined ? `(${p.outcome})` : "";
    // kestrel-50w: an authored plan stuck on an unsatisfiable regime gate carries the engine's arm-block
    // reason — render it as `(blocked: <reason>)` so a bare `authored` (a live plan awaiting its WHEN) is
    // DISTINGUISHABLE from one that can never arm (the phantom-position trap).
    const blocked = p.blockedReason !== undefined ? ` (blocked: ${p.blockedReason})` : "";
    const note = p.note !== undefined ? `  — ${p.note}` : "";
    return `  ${p.name}: ${p.state}${outcome === "" ? "" : " " + outcome}${blocked}${note}`;
  });
  return ["plans:", ...lines].join("\n");
}

/** The trailing plan-lifecycle detail block ("KERNEL (acting)"): fills-since-last + plan states +
 * the detailed inventory view. The safety/control half of the same Kernel LEADS every frame as the
 * cockpit block ({@link ./render.ts renderKernel}); this is its plan-lifecycle complement. */
function renderActingDetail(kernel: Kernel): string {
  return [
    "KERNEL (acting)",
    renderPositions(kernel.positions),
    renderResting(kernel.resting),
    renderFills(kernel.fillsSinceLast),
    renderBudget(kernel.budget),
    renderPlans(kernel),
  ].join("\n");
}

// ── failed-breaks (kestrel-4gl.13.1) — the fake-out trap made legible ──────────
//
// A CALC tally of pokes past HOD/LOD/OR-high/OR-low that did NOT HOLD this session — the ORB
// fake-out poke IS a failed break, so this pane literally shows the trap before the agent chases it.
// PURE over `market.tape` (OHLC bars) + `market.levels` (the break levels). No receipt, no new data.
//
// Definitions (precise, so the count is deterministic):
//   • LEVEL — a break level with a direction: UP levels (hod, orHigh) are broken by trading ABOVE;
//     DOWN levels (lod, orLow) by trading BELOW. VWAP is a MEAN, not a break level → not counted here.
//   • POKE — a maximal run of ≥1 CONSECUTIVE bars that PIERCE the level (up: `bar.high > L`;
//     down: `bar.low < L`). A bar that only touches (`high === L`) does not pierce (strict).
//   • HELD — the poke's run reaches the LAST bar of the window AND that last bar CLOSES past the
//     level (up: `close > L`; down: `close < L`) — i.e. price is still through the level at the last
//     observation (the break sustained).
//   • FAILED — a poke that did not hold: either a later bar brought price back inside (the run ends
//     before the window does), or the run reached the end but the last bar CLOSED back inside (an
//     intrabar poke rejected at the close — the textbook fake-out).
//   • EXCURSION — the max distance a poke reached strictly past the level (its trap depth).
//   • RUN — the poke's length in bars (the "time-above/below", in whole tape buckets — the frozen
//     projection carries no sub-bar time, so we report bars, never invented seconds).

/** A break level and the side a break of it points. UP = broken above (hod/orHigh); DOWN = below. */
interface BreakLevel {
  readonly label: string;
  readonly value: number | null | undefined;
  readonly dir: "up" | "down";
}

/** One poke's outcome over the window: how far past it reached, how many bars it lasted, held?. */
interface Poke {
  readonly excursion: number;
  readonly bars: number;
  readonly held: boolean;
}

/** Every poke past `level` in `dir`, in tape order. Pure: a total function of the OHLC rows. */
function pokesForLevel(rows: readonly TapeRow[], level: number, dir: "up" | "down"): readonly Poke[] {
  const pierces = (r: TapeRow): boolean => (dir === "up" ? r.high > level : r.low < level);
  const pastBy = (p: number): number => (dir === "up" ? p - level : level - p);
  const pokes: Poke[] = [];
  const n = rows.length;
  let i = 0;
  while (i < n) {
    if (!pierces(rows[i]!)) {
      i += 1;
      continue;
    }
    let j = i;
    let excursion = 0;
    while (j < n && pierces(rows[j]!)) {
      const r = rows[j]!;
      const ex = pastBy(dir === "up" ? r.high : r.low);
      if (ex > excursion) excursion = ex;
      j += 1;
    }
    const reachesEnd = j === n;
    const lastClose = rows[j - 1]!.close;
    const held = reachesEnd && (dir === "up" ? lastClose > level : lastClose < level);
    pokes.push({ excursion, bars: j - i, held });
    i = j;
  }
  return pokes;
}

/** One `failed-breaks` line for a level. Absent level ⇒ explicit unknown (invents no value). */
function failedBreakLine(rows: readonly TapeRow[], lv: BreakLevel): { readonly line: string; readonly failed: number } {
  if (lv.value === null || lv.value === undefined || !Number.isFinite(lv.value)) {
    return { line: `  ${lv.label} ${DASH}: level unknown`, failed: 0 };
  }
  const pokes = pokesForLevel(rows, lv.value, lv.dir);
  const probed = pokes.length;
  const held = pokes.filter((p) => p.held).length;
  const failedPokes = pokes.filter((p) => !p.held);
  const failed = failedPokes.length;
  const head = `  ${lv.label} ${px(lv.value)}: probed ${probed}×`;
  if (probed === 0) return { line: head, failed: 0 };
  const detail = `${head} · held ${held}× · failed ${failed}×`;
  if (failed === 0) return { line: `${detail} · clean break (held)`, failed: 0 };
  const maxExc = failedPokes.reduce((m, p) => (p.excursion > m ? p.excursion : m), 0);
  const longest = failedPokes.reduce((m, p) => (p.bars > m ? p.bars : m), 0);
  return {
    line: `${detail} · max excursion ${num(maxExc, 2)} · longest ${longest} bar${longest === 1 ? "" : "s"}`,
    failed,
  };
}

/** The failed-break tally: per-level poke/held/failed counts + trap depth, and a session total. */
function renderFailedBreaks(market: MarketPane): string {
  const lv = market.levels;
  const levels: readonly BreakLevel[] = [
    { label: "hod", value: lv.hod, dir: "up" },
    { label: "lod", value: lv.lod, dir: "down" },
    { label: "orHigh", value: lv.orHigh, dir: "up" },
    { label: "orLow", value: lv.orLow, dir: "down" },
  ];
  if (market.tape.length === 0) {
    return `failed-breaks · ${market.instrument} · (no prints this window)`;
  }
  const built = levels.map((l) => failedBreakLine(market.tape, l));
  const total = built.reduce((s, b) => s + b.failed, 0);
  const header = `failed-breaks · ${market.instrument} · ${total} failed this session`;
  return [header, ...built.map((b) => b.line)].join("\n");
}

// ── level-interaction (kestrel-4gl.13.2) — the poke as exhaustion, not breakout ─
//
// A CALC read of HOW price behaved at each key level (HOD/LOD/VWAP/OR edges): touches, and the
// REJECTION CHARACTER of the most-recent interaction — wick (exhaustion, closed back inside) vs body
// (continuation, closed through). Complements `failed-breaks` (counts) with SHAPE. PURE over the same
// OHLC tape + levels; no receipt, no new data.
//
// Definitions:
//   • TOUCH — a bar whose range straddles the level (`low ≤ L ≤ high`): price traded AT the level.
//   • CHARACTER (break levels) — from the LAST touching bar (the current texture): "broke" if it
//     CLOSED through the level, "rejected" if it closed back inside. The reach past the level splits
//     into BODY-past (`|body edge − L|`, the part the candle body held) and WICK-past (the rest, the
//     part it gave back). A big wick + tiny body that closed inside is a textbook exhaustion print;
//     a body that closed through is a real break.
//   • VWAP (a MEAN, two-sided) — reports touches + which side the last touch CLOSED (reclaim/loss)
//     and the reach on each side of the mean; the reject/break verb does not apply to a mean.

/** The wick-vs-body split of the last touching bar past a break level. Pure geometry. */
function pastSplit(r: TapeRow, level: number, dir: "up" | "down"): { readonly wick: number; readonly body: number } {
  const bodyHi = Math.max(r.open, r.close);
  const bodyLo = Math.min(r.open, r.close);
  if (dir === "up") {
    const body = Math.max(0, bodyHi - level);
    const wick = r.high - Math.max(bodyHi, level);
    return { wick, body };
  }
  const body = Math.max(0, level - bodyLo);
  const wick = Math.min(bodyLo, level) - r.low;
  return { wick, body };
}

/** One `level-interaction` line for a break level (hod/lod/orHigh/orLow). */
function breakInteractionLine(rows: readonly TapeRow[], lv: BreakLevel): string {
  if (lv.value === null || lv.value === undefined || !Number.isFinite(lv.value)) {
    return `  ${lv.label} ${DASH}: level unknown`;
  }
  const level = lv.value;
  const touches = rows.filter((r) => r.low <= level && level <= r.high);
  if (touches.length === 0) return `  ${lv.label} ${px(level)}: no touch`;
  const last = touches[touches.length - 1]!;
  const broke = lv.dir === "up" ? last.close > level : last.close < level;
  const verb = broke ? "broke (body through)" : "rejected (wick, closed inside)";
  const { wick, body } = pastSplit(last, level, lv.dir);
  const wickLabel = lv.dir === "up" ? "upper-wick" : "lower-wick";
  return `  ${lv.label} ${px(level)}: ${touches.length} touch${touches.length === 1 ? "" : "es"} · ${verb} · ${wickLabel} ${num(wick, 2)} / body ${num(body, 2)}`;
}

/** One `level-interaction` line for VWAP (a mean — reclaim/loss + reach each side, no break verb). */
function vwapInteractionLine(rows: readonly TapeRow[], vwap: number | null | undefined): string {
  if (vwap === null || vwap === undefined || !Number.isFinite(vwap)) {
    return `  vwap ${DASH}: level unknown`;
  }
  const touches = rows.filter((r) => r.low <= vwap && vwap <= r.high);
  if (touches.length === 0) return `  vwap ${px(vwap)}: no touch`;
  const last = touches[touches.length - 1]!;
  const side = last.close > vwap ? "closed above (reclaim)" : last.close < vwap ? "closed below (loss)" : "closed at vwap";
  const up = last.high - vwap;
  const down = vwap - last.low;
  return `  vwap ${px(vwap)}: ${touches.length} touch${touches.length === 1 ? "" : "es"} · ${side} · reach up ${num(up, 2)} / down ${num(down, 2)}`;
}

/** The level-interaction detail: rejection character (wick vs body) at each key level. */
function renderLevelInteraction(market: MarketPane): string {
  const lv = market.levels;
  if (market.tape.length === 0) {
    return `level-interaction · ${market.instrument} · (no prints this window)`;
  }
  const breakLevels: readonly BreakLevel[] = [
    { label: "hod", value: lv.hod, dir: "up" },
    { label: "lod", value: lv.lod, dir: "down" },
    { label: "orHigh", value: lv.orHigh, dir: "up" },
    { label: "orLow", value: lv.orLow, dir: "down" },
  ];
  const lines = [
    breakInteractionLine(market.tape, breakLevels[0]!), // hod
    breakInteractionLine(market.tape, breakLevels[1]!), // lod
    vwapInteractionLine(market.tape, lv.vwap),
    breakInteractionLine(market.tape, breakLevels[2]!), // orHigh
    breakInteractionLine(market.tape, breakLevels[3]!), // orLow
  ];
  return [`level-interaction · ${market.instrument}`, ...lines].join("\n");
}

// ── range-velocity (kestrel-4gl.13.3) — the current move's RANGE + VELOCITY, the quality of momentum ─
//
// A CALC read of the realized RANGE (the tape's own excursion) and the VELOCITY of the current move
// (signed close-to-close), and whether that velocity is IMPULSIVE (expanding into the last bar, at/near
// its own p99 — a fundable break) or GRINDING (collapsing, far below p99 — a poke on decelerating
// velocity is unfunded, the textbook EQ fakeout). PURE over `market.tape` ALONE (+ the instrument for
// the header): reads NO level / chain / kernel / model input — CALC, no new data sourced. Aligned with
// the runtime Window doctrine (CONTEXT.md): range = excursion, velocity = signed, move = magnitude,
// p99 = the window's own trailing baseline (the per-bar |Δclose| distribution's nearest-rank max).
//
// Definitions (precise, so the read is deterministic):
//   • RANGE — the tape's realized excursion `max(high) − min(low)` over the window (its OWN extremes,
//     NOT `levels.hod/lod` — "Data: the tape. Nothing new." per spec §3, so the "no new data" proof
//     is maximal — range never reaches for a level Field).
//   • VELOCITY — signed close-to-close: per-bar `Δ_i = close_i − close_{i-1}`; the window's NET velocity
//     is `close_last − close_first`, and MOVE is its magnitude `|net|`.
//   • p99 — the nearest-rank 99th percentile of the per-bar `|Δ|` distribution (for small n this is the
//     max — matching the runtime's `p99 === max{1..10}`). The current bar's velocity is judged vs it.
//   • IMPULSIVE — the current bar `|Δ_last|` is at/near p99 (≥ half) AND accelerating
//     (`|Δ_last| > |Δ_prev|`): the break is being driven — fundable.
//   • GRINDING — the current bar is far below p99 (< half) AND decelerating (`|Δ_last| < |Δ_prev|`):
//     velocity is collapsing — a poke here is unfunded (the EQ fakeout — a poke on decelerating velocity).
//   • FAIL-CLOSED — no prints ⇒ `(no prints this window)`; a single bar (no close-to-close delta) ⇒
//     velocity UNKNOWN (never a fabricated impulsive/grinding read); < 2 deltas ⇒ the accel/decel read
//     is UNKNOWN/insufficient, never invented.

/** The nearest-rank 99th percentile of a non-empty sample (ascending sort; rank = ceil(0.99·n), clamped
 * to ≥1). For small n this is the max — matching the runtime Window doctrine's `p99 === max{1..10}`
 * (CONTEXT.md; tests/series.test.ts). Pure — a total function of the sample. */
function p99NearestRank(sample: readonly number[]): number {
  const sorted = [...sample].sort((a, b) => a - b);
  const rank = Math.max(1, Math.ceil(0.99 * sorted.length));
  return sorted[rank - 1]!;
}

/**
 * `range-velocity` — realized RANGE + VELOCITY of the current move (CALC, no receipt, no new data).
 * Is the break IMPULSIVE (accelerating at/near its p99 — fundable) or GRINDING (decelerating far below
 * p99 — a poke here is unfunded, the EQ fakeout)? PURE over `market.tape` (+ the instrument header);
 * reads no level/chain/kernel/model Field. FAIL-CLOSED: no prints ⇒ absent-not-hidden; a single bar ⇒
 * velocity UNKNOWN; < 3 bars ⇒ the accel/decel verdict is UNKNOWN — never a fabricated velocity.
 * DATE-BLIND: renders bar counts + prices + trend words only (no clock/date token).
 */
function renderRangeVelocity(market: MarketPane): string {
  const rows = market.tape;
  const head = `range-velocity · ${market.instrument}`;
  if (rows.length === 0) {
    return `${head} · (no prints this window)`;
  }

  // RANGE — the tape's OWN realized excursion (max high − min low), never levels.hod/lod (no new data).
  let hi = -Infinity;
  let lo = Infinity;
  for (const r of rows) {
    if (r.high > hi) hi = r.high;
    if (r.low < lo) lo = r.low;
  }
  const range = hi - lo;

  // A single bar carries no close-to-close velocity — fail closed to UNKNOWN (invents no velocity).
  if (rows.length < 2) {
    return [
      head,
      `  range ${num(range, 2)} · velocity ${DASH} · 1 bar (insufficient for a close-to-close velocity)`,
      "  read: UNKNOWN — need ≥2 bars for a velocity read (insufficient)",
    ].join("\n");
  }

  // VELOCITY — signed close-to-close deltas; the window's net + its magnitude (move); the p99 baseline.
  const deltas: number[] = [];
  for (let i = 1; i < rows.length; i += 1) deltas.push(rows[i]!.close - rows[i - 1]!.close);
  const absDeltas = deltas.map((d) => Math.abs(d));
  const net = rows[rows.length - 1]!.close - rows[0]!.close;
  const move = Math.abs(net);
  const p99 = p99NearestRank(absDeltas);
  const lastAbs = absDeltas[absDeltas.length - 1]!;
  const barVel = deltas[deltas.length - 1]!; // the signed current-bar velocity
  const bars = rows.length;

  const ratioPct = p99 > 0 ? Math.round((lastAbs / p99) * 100) : null;
  const ratioStr = ratioPct === null ? DASH : `${ratioPct}%`;
  const l2 = `  range ${num(range, 2)} · velocity ${money(net)} · move ${num(move, 2)} over ${bars} bars`;
  const l3base = `  bar velocity now ${money(barVel)} · p99 ${num(p99, 2)} (${ratioStr} of p99)`;

  // The accel/decel read needs ≥2 deltas (≥3 bars); fewer ⇒ UNKNOWN, never a fabricated verdict.
  const prevAbs = absDeltas.length >= 2 ? absDeltas[absDeltas.length - 2]! : null;
  if (prevAbs === null || ratioPct === null) {
    return [
      head,
      l2,
      `${l3base} · trend UNKNOWN`,
      "  read: UNKNOWN — need ≥3 bars to judge accelerating vs decelerating (insufficient)",
    ].join("\n");
  }

  const trend = lastAbs > prevAbs ? "ACCELERATING" : lastAbs < prevAbs ? "DECELERATING" : "STEADY";
  const l3 = `${l3base} · ${trend}`;

  let read: string;
  if (trend === "ACCELERATING" && ratioPct >= 50) {
    read = "  read: IMPULSIVE — break on accelerating velocity at/near its p99, fundable";
  } else if (trend === "DECELERATING" && ratioPct < 50) {
    read = "  read: GRINDING — poke on decelerating velocity far below p99, unfunded (EQ fakeout)";
  } else {
    read = `  read: MIXED — velocity ${ratioStr} of p99, ${trend.toLowerCase()}; inconclusive (neither clean impulse nor clean grind)`;
  }
  return [head, l2, l3, read].join("\n");
}

// ── series-summary (kestrel-wa0j.63) — the tape's trend as NUMBERS, embedder-legible ─────────
//
// A CALC block of numeric TREND statistics over the served tape — the v5 VELOCITY [CALC] stat block
// PORTED (docs/paper/examples/percept-v5/open.txt: `VELOCITY [CALC] … 1-min |move| p50=… p90=… max=… n=…`)
// and EXTENDED with the geometry-named stats the render only ever carried as ASCII bar-art: drift over
// the served window, close-vs-VWAP signed distance, and the least-squares slope of the closes. PURE over
// `market.tape` (OHLC closes) + `market.levels.vwap` (a level the frame already carries) — invents no value,
// sources no new data, no wall clock, no RNG.
//
// WHY IT EXISTS (embed-geom-1, the EMBEDDING channel — a first): the trend facts the render carries live
// ONLY as glyph tape (rotated candles) — embedder-illegible (trend probes read ~0.45 everywhere across
// embedders because a bar-art trend is not a number an embedder can read). A numeric summary line closes
// that gap. This is the first render change motivated by the embedding channel rather than the LLM channel;
// its ledger cell evidence must therefore carry BOTH instruments (the quiz delta AND the embedder-probe
// delta), since embedder-legibility for the trigger tier (the cascade) is half its value claim (kestrel-wa0j.60).
//
// TIER-MACHINERY (s6ng): this pane LITERALIZES DERIVE-tier constructs — %-above-VWAP (close-vs-vwap here)
// and trend direction (drift/slope sign here). A quiz item over those constructs RECLASSIFIES DERIVE→RECALL
// on any render carrying this pane (the answer is now read off the line, not derived), and the
// literalization-value measurement (pane-off vs pane-on) applies — the line's reading value is measurable
// on day one. SALIENCE-class affordance (kestrel-bwmz): it carries a difficulty-impact stamp obligation
// BEFORE any bench-item use.
//
// Definitions (precise + deterministic, documented like `failed-breaks` so the read is replayable):
//   • WINDOW — the served tape rows (`market.tape`), oldest first. n_buckets = rows.length.
//   • DRIFT — the window's close-to-close move: `close_last − close_first`, in signed points, and as a
//     signed percent of `close_first` (`drift / close_first · 100`). A zero first close carries no percent
//     (division by zero) — the points still render, the percent is `—`. Needs ≥2 rows (a close-to-close
//     move is undefined on one row) — a 1-row/empty window renders absent-with-reason.
//   • CLOSE-vs-VWAP — the last close's signed distance from VWAP: `close_last − vwap`, in points, and as a
//     signed percent of vwap (`dist / vwap · 100`). Reads ONLY the last close + the carried `vwap`, so it
//     renders whenever both are present (independent of window length). Absent VWAP ⇒ absent-with-reason
//     (a distance from an absent mean is not a value we invent); a zero vwap carries no percent (`—`).
//   • SLOPE — the ordinary-least-squares slope of the closes against the bucket index x = 0,1,…,n−1
//     (one unit per bucket): fit `close_i ≈ a + b·x_i`, and b is the reported slope in points/bucket:
//         b = Σ_i (x_i − x̄)(y_i − ȳ) / Σ_i (x_i − x̄)²      (x̄, ȳ the means; y_i = close_i)
//     A positive b is an up-trend over the window, negative a down-trend — the CONTINUOUS companion to
//     drift's endpoint read. Needs ≥2 rows (a line needs two points); denominator 0 (all x equal, only at
//     n=1) never occurs past the ≥2 guard. Undefined on a 1-row/empty window ⇒ absent-with-reason.
//   • VELOCITY (the v5 port) — the 1-bucket |move| distribution: per-bucket `|Δ_i| = |close_i − close_{i-1}|`
//     for i = 1…n−1 (n−1 deltas). Reports p50, p90, max, and n (the delta count). Percentiles are
//     NEAREST-RANK on the ascending sample (rank = ⌈p·n⌉, clamped ≥1) — the repo's Window-doctrine
//     convention (matching `p99NearestRank` / range-velocity), so for small n p90 collapses to max exactly
//     as the v5 block shows (`p90=3.00 max=3.00 n=5`). Undefined on a 1-row/empty window (no deltas).
//   • FAIL-CLOSED — empty tape ⇒ `(no prints this window)`; a 1-row tape ⇒ drift/slope/velocity each render
//     absent-with-reason (need ≥2 buckets), while close-vs-vwap still renders IF vwap is present (it is a
//     point stat, honestly computable from one close — never suppressed, never invented).
//   • ONE CANONICAL FORM — every number renders through the shared {@link ./format.ts} primitives
//     (`num`/`money`/`px`), 2dp, one form; no epoch-shaped token appears (date-blind grep stays clean).

/** The nearest-rank percentile of a non-empty sample at fraction `p` (ascending sort; rank = ⌈p·n⌉,
 * clamped ≥1). The repo's Window-doctrine convention — the generalization of {@link p99NearestRank}
 * to an arbitrary p (p50/p90 here). Pure: a total function of the sample. */
function percentileNearestRank(sample: readonly number[], p: number): number {
  const sorted = [...sample].sort((a, b) => a - b);
  const rank = Math.max(1, Math.ceil(p * sorted.length));
  return sorted[rank - 1]!;
}

/** The least-squares slope b of the closes against the bucket index x = 0…n−1 (points/bucket). Pure.
 * Caller guarantees n ≥ 2, so Σ(x−x̄)² > 0 (never a divide-by-zero). See the block comment for the formula. */
function leastSquaresSlope(closes: readonly number[]): number {
  const n = closes.length;
  const xBar = (n - 1) / 2; // mean of 0…n−1
  let yBar = 0;
  for (const y of closes) yBar += y;
  yBar /= n;
  let num2 = 0;
  let den = 0;
  for (let i = 0; i < n; i += 1) {
    const dx = i - xBar;
    num2 += dx * (closes[i]! - yBar);
    den += dx * dx;
  }
  return num2 / den;
}

/** The close-vs-VWAP line: the last close's signed distance from the carried mean (points + %), or
 * absent-with-reason when VWAP is absent. Independent of window length (a point stat). Pure. */
function closeVsVwapLine(lastClose: number, vwap: number | null | undefined): string {
  if (vwap === null || vwap === undefined || !Number.isFinite(vwap)) {
    return `  close-vs-vwap ${DASH} (vwap unknown)`;
  }
  const dist = lastClose - vwap;
  const pctCell = vwap === 0 ? DASH : `${money((dist / vwap) * 100)}%`;
  return `  close-vs-vwap ${money(dist)} (${pctCell}) — close ${px(lastClose)} vs vwap ${px(vwap)}`;
}

/**
 * `series-summary` — the served tape's TREND as numbers (CALC, no receipt, no new data): drift,
 * close-vs-VWAP, least-squares slope, and the v5 VELOCITY |move| percentiles. PURE over `market.tape`
 * (closes) + `market.levels.vwap`; reads no chain/kernel/model Field. FAIL-CLOSED: empty tape ⇒
 * absent-not-hidden; a 1-row window ⇒ drift/slope/velocity absent-with-reason (need ≥2 buckets), while
 * close-vs-vwap still renders when vwap is present. DATE-BLIND: bucket counts + prices + trend numbers
 * only (no clock/date token). One canonical numeric form throughout ({@link ./format.ts}).
 */
function renderSeriesSummary(market: MarketPane): string {
  const rows = market.tape;
  const head = `series-summary · ${market.instrument}`;
  if (rows.length === 0) {
    return `${head} · (no prints this window)`;
  }
  const closes = rows.map((r) => r.close);
  const lastClose = closes[closes.length - 1]!;
  const vwap = market.levels.vwap;
  const bucketMin = market.tapeBucketMin;

  // A single bucket carries no close-to-close series — drift/slope/velocity fail closed to absent-with-reason
  // (invents no trend). close-vs-vwap is a POINT stat and still renders honestly when vwap is present.
  if (rows.length < 2) {
    return [
      head,
      `  window 1 bucket · ${num(bucketMin, 0)}m each`,
      `  drift ${DASH} (need ≥2 buckets for a close-to-close read)`,
      closeVsVwapLine(lastClose, vwap),
      `  slope ${DASH} (need ≥2 buckets for a least-squares slope)`,
      `  velocity 1-bucket |move| ${DASH} (need ≥2 buckets; n=0)`,
    ].join("\n");
  }

  const firstClose = closes[0]!;
  const drift = lastClose - firstClose;
  const driftPct = firstClose === 0 ? DASH : `${money((drift / firstClose) * 100)}%`;

  const absDeltas: number[] = [];
  for (let i = 1; i < closes.length; i += 1) absDeltas.push(Math.abs(closes[i]! - closes[i - 1]!));
  const p50 = percentileNearestRank(absDeltas, 0.5);
  const p90 = percentileNearestRank(absDeltas, 0.9);
  const max = Math.max(...absDeltas);

  const slope = leastSquaresSlope(closes);

  return [
    head,
    `  window ${rows.length} buckets · ${num(bucketMin, 0)}m each`,
    `  drift ${money(drift)} (${driftPct}) — close ${px(firstClose)} → ${px(lastClose)}`,
    closeVsVwapLine(lastClose, vwap),
    `  slope ${money(slope)} pts/bucket (least-squares over ${rows.length} closes)`,
    `  velocity 1-bucket |move| p50=${num(p50, 2)} p90=${num(p90, 2)} max=${num(max, 2)} n=${absDeltas.length}`,
  ].join("\n");
}

// ─────────────────────────────────────────────────────────────────────────────
// options-analytics panes (kestrel-4gl.13.4/.5/.6) — the GEX/IV hypothesis, MODEL + receipt
//
// These read the frozen options-analytics projection (`market.options`, pane-library-spec §5): the
// full-chain per-strike surface where NBBO/mid/OI are OBSERVED and IV/greeks are Black-Scholes
// MODEL outputs. IV/greeks are COMPUTED, never sourced → every headline value is a MODEL Field that
// MUST carry its receipt (source + modelVer + confidence); {@link makeField} refuses it otherwise.
// PURE over the frozen projection (`tau`/spot/staleness were frozen at the cutoff seq — no wall
// clock, no live index into the tape here). Absent projection ⇒ UNKNOWN (fail-closed); STALE
// underlier ⇒ the GEX pane taints to UNKNOWN (a GEX read off a dead spot is a lie), never a number.
// ─────────────────────────────────────────────────────────────────────────────

/** The stated, load-bearing dealer-positioning assumption GEX rests on (pane-library-spec §GEX): it
 * is an ASSUMPTION, never an observation — OPRA gives open interest, never who holds it. Rendered
 * inline so the pane is never dishonest about it. */
const GEX_ASSUMPTION = "assume dealer short calls / long puts ⇒ short γ above spot, long γ below";

/** Format an IV as a vol-percent (`18.4%`), or `—` when UNKNOWN. */
function ivPct(iv: number | null | undefined): string {
  return iv === null || iv === undefined || !Number.isFinite(iv) ? DASH : `${(iv * 100).toFixed(1)}%`;
}

/** Format a signed $-notional in millions (`+129.4M`, `−12.0M`), or `—` when UNKNOWN. */
function millions(x: number | null | undefined): string {
  if (x === null || x === undefined || !Number.isFinite(x)) return DASH;
  const m = x / 1e6;
  return `${m >= 0 ? "+" : "−"}${Math.abs(m).toFixed(1)}M`;
}

/** One strike's net dealer gamma (in $ per 1% underlier move) under the stated convention. */
interface StrikeGamma {
  readonly strike: number;
  readonly net: number;
}

/** The computed dealer-gamma profile — the pin/break map GEX renders. */
interface GexProfile {
  /** Net dealer gamma across the whole chain, $ per 1% move. */
  readonly net: number;
  /** Per-strike net dealer gamma, ascending by strike. */
  readonly perStrike: readonly StrikeGamma[];
  /** The zero-gamma flip level (interpolated K where cumulative net crosses 0), or `null`. */
  readonly flip: number | null;
  /** The largest positive-gamma wall (a pin/support level), or `null`. */
  readonly posWall: StrikeGamma | null;
  /** The largest negative-gamma wall (an accelerant level), or `null`. */
  readonly negWall: StrikeGamma | null;
  /** Legs that contributed a computed gamma × OI (the confidence numerator). */
  readonly computed: number;
  /** Legs that carried OI at all (the confidence denominator). */
  readonly withOi: number;
}

/**
 * Compute the dealer-gamma profile from the frozen options surface (pure). Per leg with a computed
 * gamma AND positive OI: `dealerGamma = signByRight · gamma · OI · multiplier · spot² · 0.01`
 * ($ per 1% move), `signByRight = C ⇒ −1 (short calls), P ⇒ +1 (long puts)` — the stated convention
 * ({@link GEX_ASSUMPTION}). Aggregated per strike across ALL expiries (GEX is a whole-chain sum);
 * the zero-gamma flip is where the cumulative net (swept low→high strike) crosses zero. Returns
 * `null` when nothing is computable (spot is required and must be fresh — the caller gates on that).
 */
function gexProfile(o: OptionsAnalytics): GexProfile | null {
  const spot = o.spot;
  if (spot === null || !Number.isFinite(spot)) return null;
  const spot2 = spot * spot;
  const byStrike = new Map<number, number>();
  let computed = 0;
  let withOi = 0;
  for (const exp of o.expiries) {
    for (const l of exp.legs) {
      if (l.oi !== null && l.oi > 0) withOi += 1;
      if (l.gamma === null || l.oi === null || l.oi <= 0) continue;
      const sign = l.right === "C" ? -1 : 1;
      const dealerGamma = sign * l.gamma * l.oi * o.multiplier * spot2 * 0.01;
      byStrike.set(l.strike, (byStrike.get(l.strike) ?? 0) + dealerGamma);
      computed += 1;
    }
  }
  if (computed === 0) return null;
  const perStrike: StrikeGamma[] = [...byStrike.entries()]
    .map(([strike, net]) => ({ strike, net }))
    .sort((a, b) => a.strike - b.strike);
  const net = perStrike.reduce((s, p) => s + p.net, 0);

  // Zero-gamma flip: interpolate the strike where the cumulative net crosses zero (low→high sweep).
  let flip: number | null = null;
  let cum = 0;
  let prevK: number | null = null;
  let prevCum = 0;
  for (const p of perStrike) {
    const nextCum = cum + p.net;
    if (prevK !== null && ((cum <= 0 && nextCum > 0) || (cum >= 0 && nextCum < 0))) {
      // crossing between prevK..p.strike is captured at cum (running total AT prevK) → p.strike
      const span = p.strike - prevK;
      const denom = nextCum - cum;
      flip = denom === 0 ? p.strike : Number((prevK + span * ((0 - cum) / denom)).toFixed(2));
    }
    prevK = p.strike;
    prevCum = cum;
    cum = nextCum;
  }
  void prevCum;

  let posWall: StrikeGamma | null = null;
  let negWall: StrikeGamma | null = null;
  for (const p of perStrike) {
    if (p.net > 0 && (posWall === null || p.net > posWall.net)) posWall = p;
    if (p.net < 0 && (negWall === null || p.net < negWall.net)) negWall = p;
  }
  return { net, perStrike, flip, posWall, negWall, computed, withOi };
}

/** The nearest strike to a level in the profile, with its net dealer gamma (or `null`). */
function nearestStrike(perStrike: readonly StrikeGamma[], level: number): StrikeGamma | null {
  let best: StrikeGamma | null = null;
  let bestD = Infinity;
  for (const p of perStrike) {
    const d = Math.abs(p.strike - level);
    if (d < bestD) {
      bestD = d;
      best = p;
    }
  }
  return best;
}

/** GEX confidence in [0,1]: the share of OI-bearing legs for which an IV/gamma was computable. */
function gexConfidence(p: GexProfile): number {
  if (p.withOi === 0) return 0;
  const c = p.computed / p.withOi;
  return c < 0 ? 0 : c > 1 ? 1 : c;
}

/**
 * `gex` — the net dealer-gamma profile by strike (MODEL, receipt `gex-v1`). The owner's headline
 * hypothesis: does the GEX profile show a wall at the ORB fake-out level? Renders the net dealer
 * gamma, the zero-gamma flip, the positive/negative walls, and — when `orHigh` is known — what the
 * profile says AT the fake-out level. The headline net is a MODEL Field carrying its receipt +
 * confidence ({@link makeField} refuses it without them). FAIL-CLOSED: an absent projection, or a
 * STALE/frozen underlier, taints the whole pane to UNKNOWN — never a GEX off a dead spot.
 */
function renderGex(market: MarketPane): string {
  const o = market.options;
  const head = `gex · ${market.instrument} (MODEL)`;
  const assume = `  ${GEX_ASSUMPTION} · gex-v1`;
  if (o === undefined) {
    return [`${head} · ${DASH} — no options-analytics projection (fail-closed)`, assume].join("\n");
  }
  if (o.spotStale || o.spot === null) {
    const age = o.spotStaleSeconds === null ? "never observed" : `stale ${Math.round(o.spotStaleSeconds / 60)}m`;
    return [
      `${head} · ${DASH} — underlier spot ${age}; a GEX read off a dead spot is a lie (fail-closed → UNKNOWN)`,
      `${assume} · spot ${px(o.spot)} (${age})`,
    ].join("\n");
  }
  const p = gexProfile(o);
  if (p === null) {
    return [`${head} · ${DASH} — no computable gamma×OI on the chain (UNKNOWN)`, `${assume} · spot ${px(o.spot)}`].join("\n");
  }
  const conf = gexConfidence(p);
  // The headline net is a MODEL value — construct it through the honesty guard (refuses a
  // receipt-less MODEL). Its receipt is the Black-Scholes method basis + the gex-v1 model version.
  const netField = makeField({
    value: p.net,
    attribution: "MODEL",
    source: `gex(${o.method})`,
    modelVer: "gex-v1",
    confidence: conf,
    asOfSeq: o.asOfSeq,
  });
  const netStr = millions(netField.value);
  const flipStr = p.flip === null ? `${DASH} (net ${p.net >= 0 ? "+" : "−"}γ, no flip in range)` : `K=${px(p.flip)}`;
  const posStr = p.posWall === null ? DASH : `K=${px(p.posWall.strike)} (${millions(p.posWall.net)})`;
  const negStr = p.negWall === null ? DASH : `K=${px(p.negWall.strike)} (${millions(p.negWall.net)})`;
  const lines = [
    `${head} · net dealer γ ${netStr} per 1% · conf ${conf.toFixed(2)}`,
    `${assume} · conf ${conf.toFixed(2)}`,
    `  spot ${px(o.spot)} · zero-γ flip ${flipStr} · pos-γ wall ${posStr} · neg-γ wall ${negStr}`,
  ];
  const orHigh = market.levels.orHigh;
  if (orHigh !== null && orHigh !== undefined && Number.isFinite(orHigh)) {
    const at = nearestStrike(p.perStrike, orHigh);
    if (at !== null) {
      const side = p.flip === null ? "" : orHigh >= p.flip ? " (above flip)" : " (below flip)";
      const read =
        at.net > 0
          ? "positive-γ ⇒ dealers pin / breakouts fade (supports the fakeout thesis)"
          : "negative-γ ⇒ dealers hedge WITH moves / breakouts accelerate (counters the fakeout thesis)";
      lines.push(`  vs orHigh ${px(orHigh)} (ORB fake-out level): nearest K=${px(at.strike)} ${millions(at.net)}${side} — ${read}`);
    }
  }
  return lines.join("\n");
}

/** A near-money IV read for the iv-skew pane: ATM/put-wing/call-wing IV + the near-money coverage. */
interface SkewRead {
  readonly atmStrike: number;
  readonly atmIv: number;
  readonly putIv: number | null;
  readonly putStrike: number | null;
  readonly callIv: number | null;
  readonly callStrike: number | null;
  readonly covered: number;
  readonly nearMoney: number;
}

/** The average IV at a strike across its call/put legs (both back out to ~the same IV at r=0). */
function strikeIv(legs: readonly OptionsExpiry["legs"][number][], strike: number): number | null {
  let sum = 0;
  let n = 0;
  for (const l of legs) {
    if (l.strike === strike && l.iv !== null) {
      sum += l.iv;
      n += 1;
    }
  }
  return n === 0 ? null : sum / n;
}

/** Compute the near-money skew read for one expiry (pure). `bandPct` bounds "near money". */
function skewRead(exp: OptionsExpiry, spot: number, bandPct = 0.05): SkewRead | null {
  const lo = spot * (1 - bandPct);
  const hi = spot * (1 + bandPct);
  const strikes = [...new Set(exp.legs.filter((l) => l.strike >= lo && l.strike <= hi).map((l) => l.strike))].sort(
    (a, b) => a - b,
  );
  const nearMoney = strikes.length;
  const covered = strikes.filter((k) => strikeIv(exp.legs, k) !== null).length;
  // ATM = the near-money strike closest to spot that HAS an IV.
  let atmStrike: number | null = null;
  let atmIv: number | null = null;
  let bestD = Infinity;
  for (const k of strikes) {
    const iv = strikeIv(exp.legs, k);
    if (iv === null) continue;
    const d = Math.abs(k - spot);
    if (d < bestD) {
      bestD = d;
      atmStrike = k;
      atmIv = iv;
    }
  }
  if (atmStrike === null || atmIv === null) return null;
  // A put-wing (below ATM) and call-wing (above ATM) IV, if a covered strike exists on each side.
  let putStrike: number | null = null;
  let putIv: number | null = null;
  let callStrike: number | null = null;
  let callIv: number | null = null;
  for (const k of strikes) {
    const iv = strikeIv(exp.legs, k);
    if (iv === null) continue;
    if (k < atmStrike) {
      putStrike = k;
      putIv = iv;
    } else if (k > atmStrike && callStrike === null) {
      callStrike = k;
      callIv = iv;
    }
  }
  return { atmStrike, atmIv, putIv, putStrike, callIv, callStrike, covered, nearMoney };
}

/**
 * `iv-skew` — the near-money IV skew + term structure (MODEL, receipt `iv-skew-v1`). *Conviction,
 * priced.* Steep put skew + elevated front IV = the market is paying for a move (a break has
 * backing); flat/cheap skew = a quiet tape a breakout must manufacture alone (the poke is unfunded).
 * The ATM IV is a MODEL Field carrying its receipt + confidence. FAIL-CLOSED to UNKNOWN on an absent
 * projection / stale spot / no near-money coverage.
 */
function renderIvSkew(market: MarketPane): string {
  const o = market.options;
  const head = `iv-skew · ${market.instrument} (MODEL)`;
  const assume = "  method BS flat-vol inversion per strike · iv-skew-v1";
  if (o === undefined) {
    return [`${head} · ${DASH} — no options-analytics projection (fail-closed)`, assume].join("\n");
  }
  if (o.spotStale || o.spot === null) {
    return [`${head} · ${DASH} — underlier spot stale/absent (fail-closed → UNKNOWN)`, assume].join("\n");
  }
  const front = o.expiries[0];
  if (front === undefined) {
    return [`${head} · ${DASH} — no expiries on the surface (UNKNOWN)`, assume].join("\n");
  }
  const read = skewRead(front, o.spot);
  if (read === null) {
    return [`${head} · ${DASH} — no near-money IV computable (UNKNOWN)`, `${assume} · spot ${px(o.spot)}`].join("\n");
  }
  const conf = read.nearMoney === 0 ? 0 : read.covered / read.nearMoney;
  const atmField = makeField({
    value: read.atmIv,
    attribution: "MODEL",
    source: `iv-skew(${o.method})`,
    modelVer: "iv-skew-v1",
    confidence: conf,
    asOfSeq: o.asOfSeq,
  });
  // Skew shape: put-wing IV vs call-wing IV (put-heavy = fear is bid; flat = nobody paying).
  let shape = "flat/one-sided (no wing pair)";
  if (read.putIv !== null && read.callIv !== null) {
    const d = read.putIv - read.callIv;
    const eps = 0.01; // 1 vol-point
    shape = d > eps ? "put-heavy (fear bid)" : d < -eps ? "call-heavy" : "flat (nobody paying for a move)";
  }
  // Term structure: front ATM IV vs the next expiry's ATM IV (inverted = event/catalyst).
  let term = "no back expiry";
  const back = o.expiries[1];
  if (back !== undefined) {
    const backRead = skewRead(back, o.spot);
    if (backRead !== null) {
      const inv = read.atmIv > backRead.atmIv + 0.01;
      term = `front ATM ${ivPct(read.atmIv)} vs back ${ivPct(backRead.atmIv)} — ${inv ? "INVERTED (event/catalyst)" : "normal (front ≤ back)"}`;
    }
  }
  const dte = front.daysToExpiry === null ? DASH : `${front.daysToExpiry}d`;
  return [
    `${head} · ATM IV ${ivPct(atmField.value)} @ K=${px(read.atmStrike)} (${dte}) · conf ${conf.toFixed(2)}`,
    `${assume} · conf ${conf.toFixed(2)}`,
    `  skew ${shape} · put-wing ${ivPct(read.putIv)}@${px(read.putStrike)} / call-wing ${ivPct(read.callIv)}@${px(read.callStrike)}`,
    `  term: ${term}`,
  ].join("\n");
}

/**
 * `implied-realized` — implied move vs realized pacing (MODEL, receipt `implied-move-v1`). *Is the
 * break paid for?* The day's implied move (ATM straddle mid ≈ 1σ) vs what price has already realized
 * (the session range so far, from the levels). If realized has already spent the small implied
 * envelope, a late breakout is unfunded. The implied move is a MODEL Field carrying its receipt.
 * FAIL-CLOSED to UNKNOWN on an absent projection / stale spot / no ATM straddle.
 */
function renderImpliedRealized(market: MarketPane): string {
  const o = market.options;
  const head = `implied-realized · ${market.instrument} (MODEL)`;
  const assume = "  ATM straddle mid ≈ 1σ expected move · implied-move-v1";
  if (o === undefined) {
    return [`${head} · ${DASH} — no options-analytics projection (fail-closed)`, assume].join("\n");
  }
  if (o.spotStale || o.spot === null) {
    return [`${head} · ${DASH} — underlier spot stale/absent (fail-closed → UNKNOWN)`, assume].join("\n");
  }
  const front = o.expiries[0];
  if (front === undefined) {
    return [`${head} · ${DASH} — no expiries on the surface (UNKNOWN)`, assume].join("\n");
  }
  const read = skewRead(front, o.spot);
  if (read === null) {
    return [`${head} · ${DASH} — no near-money IV computable (UNKNOWN)`, assume].join("\n");
  }
  // Implied move ≈ ATM straddle mid (call mid + put mid at the ATM strike).
  let straddle: number | null = null;
  for (const l of front.legs) {
    if (l.strike === read.atmStrike && l.mid !== null) straddle = (straddle ?? 0) + l.mid;
  }
  if (straddle === null || straddle <= 0) {
    return [`${head} · ${DASH} — no ATM straddle mid (UNKNOWN)`, `${assume} · spot ${px(o.spot)}`].join("\n");
  }
  const conf = read.nearMoney === 0 ? 0 : read.covered / read.nearMoney;
  const impliedField = makeField({
    value: straddle,
    attribution: "MODEL",
    source: `implied-move(${o.method})`,
    modelVer: "implied-move-v1",
    confidence: conf,
    asOfSeq: o.asOfSeq,
  });
  // Realized so far — the session range (hod − lod), a CALC fact off the levels.
  const hod = market.levels.hod;
  const lod = market.levels.lod;
  const realized = hod !== null && hod !== undefined && lod !== null && lod !== undefined ? hod - lod : null;
  const implied = impliedField.value;
  const consumed = realized === null ? null : (realized / implied) * 100;
  const verdict =
    consumed === null
      ? "realized UNKNOWN"
      : consumed >= 100
        ? "realized has SPENT the implied envelope — a late break is unfunded"
        : consumed >= 60
          ? "most of the implied envelope is spent — little fuel left"
          : "implied envelope largely intact — fuel remains";
  return [
    `${head} · implied ±${num(implied, 2)} (1σ) · realized ${realized === null ? DASH : num(realized, 2)} · conf ${conf.toFixed(2)}`,
    `${assume} · conf ${conf.toFixed(2)}`,
    `  consumed ${consumed === null ? DASH : `${consumed.toFixed(0)}%`} — ${verdict}`,
  ].join("\n");
}

// ── coverage (kestrel-4gl.13.7) — the per-vehicle feed-health BANNER, OBS, no new data ─────────
//
// An OBS re-presentation of the routing gate's OWN observed book health (`kernel.dataHealth`, the
// array of per-vehicle {@link VehicleHealth} records the runtime already projects) as a body pane —
// so DEGRADED-DATA is answerable and the SHOCK frozen-feed stand-down is legible. It sources NO new
// data (introduces no derived/model value → OBS, no receipt): it surfaces exactly the four observed
// fields the cockpit lead reports, under the SAME label convention ({@link ./render.ts} §2 data-health).
//
// PURE over `kernel.dataHealth` ALONE: each line NAMES its own vehicle from the health record
// (`VehicleHealth.instrument`), so the pane reads NO market / levels / chain / instruments input (the
// header is STATIC, unlike the single-instrument panes that name `market.instrument`). Same dataHealth
// ⇒ byte-identical text; DATE-BLIND (VehicleHealth carries no date). FAIL-CLOSED / ABSENT-NOT-HIDDEN:
// absent dataHealth ⇒ UNKNOWN; empty `[]` ⇒ UNKNOWN; a per-vehicle MISSING field ⇒ an explicit `—`
// gap (never a silent 0/false/undefined, never a crash) — booleans fail-close too, so a dropped `dark`
// never falsely reads "not dark".

/** A vehicle is DEGRADED past these thresholds: bids present under half the time (market-maker pull),
 * one-sided, dark (book pulled), or stale past this many seconds (a frozen feed). Documented, not
 * invented — any of these independently taints the vehicle's data. */
const COVERAGE_BID_PRESENT_MIN = 0.5;
const COVERAGE_STALE_S = 30;

/** A boolean cell that FAILS CLOSED: only a real `true`/`false` prints as such; a missing/dropped
 * field is an explicit `—` gap (never a silent `false` that would falsely claim "not dark"). */
function boolCell(b: boolean | undefined | null): string {
  return b === true ? "true" : b === false ? "false" : DASH;
}

/** One `coverage` line for a vehicle: its name + the four observed fields (fail-closed to `—` on a
 * dropped field) + a condition-driven DEGRADED marker with staleness legible in whole minutes. */
function coverageLine(h: VehicleHealth | null | undefined): string {
  // A null/undefined ARRAY ELEMENT (a dropped health record, not just a dropped field) would throw a
  // TypeError on the field derefs below — fail closed to an explicit gap line instead (absent-not-hidden,
  // never a crash, never silently dropped-without-trace). A missing FIELD already fails closed below.
  if (h === null || h === undefined) {
    return `  ${DASH}: per-vehicle health UNKNOWN (null health record — fail-closed gap, absent-not-hidden)`;
  }
  const name = typeof h.instrument === "string" && h.instrument.length > 0 ? h.instrument : DASH;
  const stale = Number.isFinite(h.staleS) && h.staleS > COVERAGE_STALE_S;
  const thinBids = Number.isFinite(h.bidPresentRate) && h.bidPresentRate < COVERAGE_BID_PRESENT_MIN;
  const degraded = h.dark === true || h.twoSided === false || stale || thinBids;
  const cells = [
    `bid_present_rate=${num(h.bidPresentRate, 3)}`,
    `two_sided=${boolCell(h.twoSided)}`,
    `stale_s=${num(h.staleS, 1)}`,
    `dark=${boolCell(h.dark)}`,
  ].join("  ");
  const flag = degraded ? `  DEGRADED${stale ? ` (stale ${Math.round(h.staleS / 60)}m)` : ""}` : "";
  return `  ${name}: ${cells}${flag}`;
}

/**
 * `coverage` — the per-vehicle feed-health banner (OBS, no receipt, no new data). PURE over
 * `kernel.dataHealth`: names each vehicle from its own record, so it reaches for no market/levels/
 * chain/instruments Field. FAIL-CLOSED to UNKNOWN on absent/empty health; a dropped per-vehicle field
 * renders an explicit `—` gap (never a silent default), booleans included. DATE-BLIND.
 */
function renderCoverage(kernel: Kernel): string {
  const head = "coverage · per-vehicle feed health";
  const dh = kernel.dataHealth;
  if (dh === undefined || dh.length === 0) {
    return [head, "  per-vehicle health: UNKNOWN (no vehicle health reported)"].join("\n");
  }
  return [head, ...dh.map(coverageLine)].join("\n");
}

// ── vol / expected-move (kestrel-wa0j.7 / T5) — the straddle decomposition, CALC, book-mid LABELLED ──
//
// The highest-value missing READ pane from the legacy program (the invisible-vol-crush failure
// class): what does the near-money book say a move COSTS, and how much of it is time value? CALC
// ONLY from the {@link PaneBuildContext} — it computes from the OBSERVED near-money chain + levels
// and sources NO model output, NO new data, NO receipt.
//
// Definitions (precise, so the decomposition is deterministic):
//   • ATM STRADDLE — the strike NEAREST spot that carries BOTH a C and a P row on the near-money
//     chain. No such strike ⇒ the straddle is UNKNOWN (explicit), never a one-legged fake.
//   • BOOK MID — `(bid + ask) / 2` per leg, ONLY when BOTH sides are quoted, and LABELLED
//     `straddle (book mid)` — explicitly quoted book state, never presented as a fair and never a
//     price anchor (the never-mid non-negotiable governs ANCHORING; this is a labelled read of the
//     book, in the same class as the chain pane printing bid/ask). A dark/one-sided leg ⇒ that
//     leg's mid is UNKNOWN ⇒ gross UNKNOWN, with the dark side NAMED (never silently one-legged).
//   • ASK-SIDE PAYABLE (kestrel-wa0j.19 §3a) — `C.ask + P.ask` (what lifting the straddle actually
//     costs right now) + each leg's quoted spread — the evidence a chain-less View needs to judge
//     the mid. Same OBSERVED rows, still CALC, still never a price anchor.
//   • DUPLICATE (strike,right) ROWS (kestrel-wa0j.19 §3c) — two chain rows claiming the SAME leg is
//     an ambiguous book: the straddle degrades to UNKNOWN NAMING the duplication (never a silent
//     first-row-wins over rows that disagree about the same quote).
//   • INTRINSIC — `|spot − K|` (the ITM leg's parity value; 0 at the money). EXTRINSIC — `gross −
//     intrinsic` (what the book charges for time/vol above parity). A NEGATIVE extrinsic prints
//     WITH an explicit INCONSISTENT flag (kestrel-wa0j.19 §3b): gross below parity means the book
//     and the spot disagree (stale-spot suspicion) — the value prints, the flag keeps it honest.
//   • REALIZED RANGE — `hod − lod` when both are known (the session's own excursion, off levels).
//   • ABSENT-WITH-REASON — components the frame context cannot carry (the opening implied, the
//     time-of-day range-to-go) render as explicit unavailability, exactly the macro pane's idiom —
//     never invented, never silently omitted.
// FAIL-CLOSED: spot/hod/lod/strike/leg gaps each render an explicit `—` with the reason. DATE-BLIND.

/** The quoted book mid of one leg — ONLY when both sides are present (a dark side ⇒ null). */
function legBookMid(row: ChainRow): number | null {
  return row.bid !== null && row.ask !== null && Number.isFinite(row.bid) && Number.isFinite(row.ask)
    ? (row.bid + row.ask) / 2
    : null;
}

/** One leg's book-mid cell for the straddle line: the mid, or `—` naming the dark side. */
function legMidCell(right: "C" | "P", row: ChainRow): string {
  const mid = legBookMid(row);
  return mid === null ? `${right} ${DASH} (${right} leg ${darkFlag(row)})` : `${right} ${num(mid, 2)}`;
}

/** One leg's quoted spread (`ask − bid`), ONLY when both sides are present (a dark side ⇒ null). */
function legSpread(row: ChainRow): number | null {
  return row.bid !== null && row.ask !== null && Number.isFinite(row.bid) && Number.isFinite(row.ask)
    ? row.ask - row.bid
    : null;
}

/** The ASK-SIDE payable evidence line (kestrel-wa0j.19 §3a): what the straddle actually COSTS to
 * lift right now (`C.ask + P.ask`) + each leg's quoted spread — so a View selecting `vol` WITHOUT
 * the chain pane can judge whether the book mid is trustworthy (a wide spread makes the mid soft).
 * Still CALC over the same two OBSERVED rows — no new data, and NEVER a price anchor (labelled
 * ask-side book state, exactly like the mid's own label). Dark ask side(s) ⇒ payable `—` naming
 * them; a leg's spread needs both sides ⇒ `—` on a one-sided leg. */
function askPayableLine(c: ChainRow, p: ChainRow): string {
  const spreadCell = (right: "C" | "P", row: ChainRow): string => {
    const s = legSpread(row);
    return s === null ? `${right} ${DASH}` : `${right} ${num(s, 2)}`;
  };
  const spreads = `(spread ${spreadCell("C", c)} / ${spreadCell("P", p)})`;
  const cAskOk = c.ask !== null && Number.isFinite(c.ask);
  const pAskOk = p.ask !== null && Number.isFinite(p.ask);
  if (!cAskOk || !pAskOk) {
    const dark = [...(!cAskOk ? ["C"] : []), ...(!pAskOk ? ["P"] : [])].join(" and ");
    return `  payable C.ask+P.ask = ${DASH} (${dark} ask dark)  ${spreads}`;
  }
  return `  payable C.ask+P.ask = ${num((c.ask as number) + (p.ask as number), 2)}  ${spreads}`;
}

/** `vol` — the expected-move decomposition (CALC, no receipt, no new data). See the block comment. */
function renderVol(market: MarketPane): string {
  const head = `vol · ${market.instrument}`;
  const lines: string[] = [head];
  const spot = market.levels.spot;
  const spotOk = spot !== null && Number.isFinite(spot);

  let gross: number | null = null;
  let intrinsic: number | null = null;
  if (!spotOk) {
    lines.push(`  straddle (book mid): ${DASH} (spot unknown — cannot select the ATM strike)`);
    lines.push(`  intrinsic ${DASH} · extrinsic ${DASH} (no ATM straddle)`);
  } else {
    // The ATM strike: nearest to spot with BOTH a C and a P row on the near-money chain.
    // Duplicate (strike, right) rows are DETECTED, never silently first-row-wins (kestrel-wa0j.19
    // §3c): two rows claiming the same leg is an ambiguous book — which is the real quote?
    const calls = new Map<number, ChainRow>();
    const puts = new Map<number, ChainRow>();
    const dupCount = new Map<string, number>(); // "K|R" → row count, for keys seen more than once
    for (const r of market.chain) {
      const side = r.right === "C" ? calls : puts;
      if (side.has(r.strike)) {
        const key = `${r.strike}|${r.right}`;
        dupCount.set(key, (dupCount.get(key) ?? 1) + 1);
      } else {
        side.set(r.strike, r);
      }
    }
    let atm: number | null = null;
    for (const k of calls.keys()) {
      if (!puts.has(k)) continue;
      if (atm === null || Math.abs(k - spot) < Math.abs(atm - spot)) atm = k;
    }
    if (atm === null) {
      lines.push(`  straddle (book mid): ${DASH} (no strike with both C and P legs on the near-money chain)`);
      lines.push(`  intrinsic ${DASH} · extrinsic ${DASH} (no ATM straddle)`);
    } else {
      // Duplicated ATM leg(s) ⇒ the straddle degrades to UNKNOWN NAMING the duplication — never a
      // silent first-row-wins over an ambiguous book (fail-closed; the strike itself still prints).
      const dupLegs = (["C", "P"] as const)
        .map((right) => ({ right, n: dupCount.get(`${atm}|${right}`) }))
        .filter((d): d is { right: "C" | "P"; n: number } => d.n !== undefined);
      if (dupLegs.length > 0) {
        const named = dupLegs.map((d) => `${px(atm)}${d.right} ×${d.n}`).join(", ");
        lines.push(
          `  straddle (book mid) @ K=${px(atm)}: ${DASH} (duplicate (strike,right) chain rows: ${named} — ambiguous book, UNKNOWN)`,
        );
        intrinsic = Math.abs(spot - atm);
        const itm = spot > atm ? "C leg ITM" : spot < atm ? "P leg ITM" : "at the money";
        lines.push(
          `  intrinsic ${num(intrinsic, 2)} (|spot − K|, ${itm}) · extrinsic ${DASH} (straddle UNKNOWN — duplicate chain rows)`,
        );
      } else {
        const c = calls.get(atm)!;
        const p = puts.get(atm)!;
        const midC = legBookMid(c);
        const midP = legBookMid(p);
        gross = midC !== null && midP !== null ? midC + midP : null;
        const grossCell = gross === null ? DASH : num(gross, 2);
        lines.push(
          `  straddle (book mid) @ K=${px(atm)}: ${legMidCell("C", c)} + ${legMidCell("P", p)} = gross ${grossCell}`,
        );
        // The ask-side payable + per-leg spread evidence (kestrel-wa0j.19 §3a) — same rows, CALC.
        lines.push(askPayableLine(c, p));
        intrinsic = Math.abs(spot - atm);
        const itm = spot > atm ? "C leg ITM" : spot < atm ? "P leg ITM" : "at the money";
        const extrinsic = gross === null ? null : gross - intrinsic;
        const extrinsicCell =
          extrinsic === null ? `${DASH} (gross unknown — dark/one-sided leg)` : num(extrinsic, 2);
        // A NEGATIVE extrinsic never prints deadpan (kestrel-wa0j.19 §3b): gross(book mid) below
        // parity means the book and the spot DISAGREE (a stale spot, or a crossed/ghost book) — the
        // value still prints (invent nothing), the flag makes it honest.
        const inconsistent =
          extrinsic !== null && extrinsic < 0
            ? " · INCONSISTENT: gross (book mid) < intrinsic — the book and spot disagree (stale-spot suspicion)"
            : "";
        lines.push(
          `  intrinsic ${num(intrinsic, 2)} (|spot − K|, ${itm}) · extrinsic ${extrinsicCell} (gross − intrinsic)${inconsistent}`,
        );
      }
    }
  }

  // Realized range — the session's own excursion off the levels; either bound unknown ⇒ explicit `—`.
  const hod = market.levels.hod;
  const lod = market.levels.lod;
  const rangeOk = hod !== null && hod !== undefined && Number.isFinite(hod) && lod !== null && lod !== undefined && Number.isFinite(lod);
  lines.push(rangeOk ? `  realized range ${num(hod - lod, 2)} (hod − lod)` : `  realized range ${DASH} (hod/lod unknown)`);

  // Components the frame context CANNOT carry — absent-with-reason (the macro pane's idiom).
  lines.push("  opening implied: unavailable (the frame context carries no opening IV)");
  lines.push("  range-to-go: unavailable (the frame context carries no time-of-day range pacing)");
  return lines.join("\n");
}

// ── prior-context (kestrel-wa0j.8 / T6) — spot vs prior close, CALC off the levels ──────────────
//
// The single most-requested orientation fact in the legacy debrief corpus: where are we relative
// to yesterday? A pure CALC off `market.levels` (spot + priorClose — priorClose is a cross-session
// inject that may be absent): direction word + signed points + signed percent, all through the
// shared format primitives. The LABEL is frameKind-aware (kestrel-wa0j.19 §2): "gap vs prior
// close" is an OPENING fact — on a WAKE frame the current spot has moved since the open, so
// spot-vs-priorClose is the CHANGE ON DAY, and calling it "gap" can state the OPPOSITE direction
// of the true opening gap (gap down, rally up ⇒ "gap: UP" would be a lie). OPEN keeps the gap
// wording BYTE-IDENTICAL; WAKE renders "vs prior close" (change-on-day). Operand disclosure stays
// on both. FAIL-CLOSED: spot or priorClose unknown ⇒ an explicit `—` line (never a fabricated
// 0-gap). DATE-BLIND ("prior close" is relative, no date).

/** The instrument's SessionScheme for THIS frame (kestrel-wa0j.44): the declaration on the matching
 * {@link InstrumentSpec}, resolved (absent ⇒ `equity-rth`, which carries a close — no-churn). The match
 * is by symbol; a market instrument with no spec resolves to the default, so no frame is a silent
 * unknown-scheme. */
function schemeOfMarket(ctx: PaneBuildContext): SessionScheme {
  const spec = ctx.instruments.find((i) => i.symbol === ctx.market.instrument);
  return resolveScheme(spec?.sessionScheme);
}

/** The first PRODUCTION DEFECTIVE refusal (ADR-0041 §3, kestrel-wa0j.44): `prior-context` addressing
 * the prior CLOSE (`d-1`) under a SessionScheme that carries no close boundary. The reason + sanctioned
 * periphrasis are pulled FROM THE LEDGER cell ({@link schemeConditionedDefect} — single source), so the
 * refusal can never quote a reason/periphrasis the paradigm table does not state; the message names the
 * conditioning attribute (`SessionScheme`). Fail-closed: rather than invent a midnight "close", the
 * address is refused — absent-not-hidden reaches the address space (ADR-0041 §3). */
function priorContextDefectiveRefusal(scheme: SessionScheme): KernelHonestyError {
  const defect = schemeConditionedDefect("prior-context", scheme);
  // Unreachable for a no-close scheme (schemeConditionedDefect returns a cell there); fail closed anyway.
  const reason = defect?.reason ?? "no close under this SessionScheme";
  const periphrasis = defect?.periphrasis ?? "anchor to the declared rolling extreme";
  return paneRefusal({
    kind: "defective-cell",
    pane: "prior-context",
    form: "prior-context d-1",
    reason,
    periphrasis,
    conditioning: "SessionScheme",
  });
}

/** The SANCTIONED PERIPHRASIS render (ADR-0041 §3, kestrel-wa0j.44): under a no-close SessionScheme,
 * `prior-context` anchors to the DECLARED ROLLING EXTREME the frame carries
 * ({@link LevelSet.rollingHigh}/{@link LevelSet.rollingLow}), NEVER an invented midnight close. A pure
 * CALC (spot vs the rolling range) — the renderer invents no value. If the frame supplies NEITHER
 * rolling bound the periphrasis has nothing honest to render, so it fails closed to the DEFECTIVE
 * refusal (never a fabricated close, never a blank pane); an individually-absent bound renders `—`
 * (absent-with-reason), exactly as the equity path renders a missing prior close. */
function renderPriorContextRolling(market: MarketPane, scheme: SessionScheme): string {
  const head = `prior-context · ${market.instrument}`;
  const anchor = rollingAnchorOf(scheme) ?? "rolling extreme";
  const lv = market.levels;
  const spot = lv.spot;
  const rHigh = lv.rollingHigh;
  const rLow = lv.rollingLow;
  const spotOk = spot !== null && Number.isFinite(spot);
  const hiOk = rHigh !== null && rHigh !== undefined && Number.isFinite(rHigh);
  const loOk = rLow !== null && rLow !== undefined && Number.isFinite(rLow);
  if (!hiOk && !loOk) {
    // No declared rolling extreme rides the frame — refuse (fail-closed), never invent a close.
    throw priorContextDefectiveRefusal(scheme);
  }
  const label = `vs ${anchor} (no close under this SessionScheme)`;
  const spotCell = spotOk ? `spot ${px(spot)}` : `spot ${DASH}`;
  const rangeCell = `24h range ${loOk ? px(rLow) : DASH}–${hiOk ? px(rHigh) : DASH}`;
  return `${head}\n  ${label}: ${spotCell} · ${rangeCell}`;
}

/** `prior-context` — spot vs prior close (CALC, no receipt, no new data): the opening gap on an
 * OPEN frame, the change-on-day on a WAKE frame (the label follows the frame kind — see above).
 *
 * SCHEME-CONDITIONED (ADR-0041 §3, kestrel-wa0j.44): the read anchors to the prior CLOSE (a
 * SessionOrdinal `d-1` boundary). Under a SessionScheme with NO close boundary (a perp's rolling UTC
 * day) that address is DEFECTIVE — the pane renders the declared rolling extreme instead (the
 * sanctioned periphrasis), never an invented midnight close; and when even that anchor is absent it
 * refuses through the defective-cell constructor. A close-bearing scheme (equity-rth, the default)
 * renders BYTE-IDENTICALLY to before (no-churn). */
function renderPriorContext(ctx: PaneBuildContext): string {
  const market = ctx.market;
  const scheme = schemeOfMarket(ctx);
  // Scheme conditioning (ONE paradigm): prior-context requires a `close` boundary. Under a scheme that
  // lacks it, the prior-close address is DEFECTIVE — render the sanctioned periphrasis (never a close).
  if (PANE_BOUNDARY_REQUIREMENT["prior-context"] !== undefined && !schemeHasBoundary(scheme, "close")) {
    return renderPriorContextRolling(market, scheme);
  }
  const head = `prior-context · ${market.instrument}`;
  // "gap" ONLY where spot IS the open's vicinity (an OPEN frame); a WAKE frame's spot-vs-priorClose
  // is the change on day — labelling it "gap" can invert the true opening gap's direction.
  const label = ctx.frameKind === "OPEN" ? "gap vs prior close" : "vs prior close";
  const spot = market.levels.spot;
  const prior = market.levels.priorClose;
  const spotOk = spot !== null && Number.isFinite(spot);
  const priorOk = prior !== null && prior !== undefined && Number.isFinite(prior);
  if (!spotOk || !priorOk) {
    const missing = [...(!spotOk ? ["spot"] : []), ...(!priorOk ? ["prior close"] : [])].join(" and ");
    return `${head}\n  ${label}: ${DASH} (${missing} unknown)`;
  }
  const gap = spot - prior;
  const dir = gap > 0 ? "UP" : gap < 0 ? "DOWN" : "FLAT";
  // A zero prior close cannot carry a percent (division by zero) — the points still render.
  const pctCell = prior === 0 ? DASH : `${money((gap / prior) * 100)}%`;
  return `${head}\n  ${label}: ${dir} ${money(gap)} (${pctCell}) — spot ${px(spot)} vs prior close ${px(prior)}`;
}

// ── delta (kestrel-wa0j.48) — the WAKE delta-since read, CALC over the frame-carried prior vantage ──
//
// Under `stateless-redraw` a WAKE percept says "37m since last" but carries NO statement of what
// changed: the prior percept is not in context, and the change information exists nowhere in the
// frame. The v5 paper contract already spelled the shape (docs/paper/examples/percept-v5/wake.txt
// "DELTA SINCE" section: spot change, HOD prior, current_implied_remaining). This pane ports it as a
// CALC read of the FRAME-CARRIED prior vantage ({@link PaneBuildContext.priorVantage}) — the renderer
// invents no value (ADR-0041 §1), so the prior data enters through the frame input, never a renderer
// reconstruction. The driver captures the TYPED values it served at the previous vantage (never the
// rendered bytes) and threads them here.
//
// DELTA = WHAT MOVED. One line per fact that changed (spot with its signed change, HOD/LOD prior→now
// only if moved, VWAP change, ATM extrinsic change); an UNCHANGED fact is NOT listed. An empty delta
// (nothing tracked moved) renders "no tracked change since <base>". FAIL-CLOSED / ABSENT-NOT-HIDDEN:
// no prior vantage in the frame ⇒ exactly ONE absent-with-reason line ("delta: unavailable (no prior
// vantage in frame)") — never an invented change, never a silent empty pane. DATE-BLIND (the base is
// named by its HH:MM ET clock + the relative `Nm ago`).

/**
 * The ATM straddle **extrinsic** (time value remaining) off the near-money book — `gross(book mid) −
 * intrinsic` at the strike nearest spot that carries BOTH a C and a P leg. Pure CALC over the OBSERVED
 * chain + spot, mirroring the `vol` pane's decomposition (kestrel-wa0j.7). Returns `null` when it is
 * NOT honestly computable: spot unknown, no ATM strike with both legs, a dark/one-sided ATM leg, or
 * duplicate (strike,right) rows (an ambiguous book) — never a fabricated value. Exported + shared so
 * the `delta` pane's CURRENT read and the driver's PRIOR-vantage capture compute the SAME number
 * (they can never drift). The "never a price anchor" rule is respected: this is a labelled read of the
 * book's own mids, never used to anchor an order price.
 */
export function atmStraddleExtrinsic(market: MarketPane): number | null {
  const spot = market.levels.spot;
  if (spot === null || spot === undefined || !Number.isFinite(spot)) return null;
  const calls = new Map<number, ChainRow>();
  const puts = new Map<number, ChainRow>();
  const dup = new Set<string>();
  for (const r of market.chain) {
    const side = r.right === "C" ? calls : puts;
    if (side.has(r.strike)) dup.add(`${r.strike}|${r.right}`);
    else side.set(r.strike, r);
  }
  let atm: number | null = null;
  for (const k of calls.keys()) {
    if (!puts.has(k)) continue;
    if (atm === null || Math.abs(k - spot) < Math.abs(atm - spot)) atm = k;
  }
  if (atm === null) return null;
  if (dup.has(`${atm}|C`) || dup.has(`${atm}|P`)) return null; // ambiguous book — never first-row-wins
  const midC = legBookMid(calls.get(atm)!);
  const midP = legBookMid(puts.get(atm)!);
  if (midC === null || midP === null) return null; // a dark/one-sided ATM leg ⇒ extrinsic UNKNOWN
  return midC + midP - Math.abs(spot - atm); // gross − intrinsic
}

/** One `delta` fact line for a MOVED numeric level: `<label> <now>  change=<signed>  (prior <prior>)`.
 * Returns `null` (the fact is OMITTED) when it did not MOVE, or when either side is UNKNOWN (no prior
 * value ⇒ no computable delta; a value that went to/from UNKNOWN is not a numeric move this pane
 * fabricates). `null` here is "no line", distinct from the pane's absent-with-reason (no prior vantage
 * at all). Pure. */
function deltaMovedLine(label: string, prior: number | null | undefined, now: number | null | undefined): string | null {
  const pOk = prior !== null && prior !== undefined && Number.isFinite(prior);
  const cOk = now !== null && now !== undefined && Number.isFinite(now);
  if (!pOk || !cOk) return null; // no computable delta (a missing endpoint) — omit, never invent
  if (now === prior) return null; // unchanged ⇒ not listed (delta is what MOVED)
  return `  ${label} ${px(now)}  change=${money((now as number) - (prior as number))}  (prior ${px(prior)})`;
}

/**
 * `delta` — the WAKE delta-since read (CALC, no receipt, no new data). Renders `delta since
 * <baseClockET> (<N>m ago)` + one line per FACT THAT MOVED since the prior vantage (spot, HOD, LOD,
 * VWAP, ATM extrinsic); unchanged facts are omitted, and an empty delta renders `no tracked change
 * since <base>`. FAIL-CLOSED: an absent prior vantage renders exactly one absent-with-reason line —
 * the change is never invented (ADR-0041 §1). The current ATM extrinsic is computed by the SAME
 * shared helper the driver used to capture the prior ({@link atmStraddleExtrinsic}). DATE-BLIND.
 */
function renderDelta(ctx: PaneBuildContext): string {
  const head = `delta · ${ctx.market.instrument}`;
  const pv = ctx.priorVantage;
  // Fail-closed: no prior vantage in the frame ⇒ the change cannot be stated (never reconstructed).
  if (pv === undefined || pv === null) {
    return `${head}\n  delta: unavailable (no prior vantage in frame)`;
  }
  const baseLabel = pv.baseClockET !== undefined && pv.baseClockET !== "" ? pv.baseClockET : "prior vantage";
  const age =
    ctx.minutesSinceLast !== undefined && ctx.minutesSinceLast !== null && Number.isFinite(ctx.minutesSinceLast)
      ? ` (${Math.round(ctx.minutesSinceLast)}m ago)`
      : "";
  const lv = ctx.market.levels;
  const moved: string[] = [];
  const spot = deltaMovedLine("spot", pv.spot, lv.spot);
  if (spot !== null) moved.push(spot);
  const hod = deltaMovedLine("hod", pv.hod, lv.hod);
  if (hod !== null) moved.push(hod);
  const lod = deltaMovedLine("lod", pv.lod, lv.lod);
  if (lod !== null) moved.push(lod);
  const vwap = deltaMovedLine("vwap", pv.vwap, lv.vwap);
  if (vwap !== null) moved.push(vwap);
  const extr = deltaMovedLine("atm-extrinsic", pv.atmExtrinsic, atmStraddleExtrinsic(ctx.market));
  if (extr !== null) moved.push(extr);
  const body = moved.length === 0 ? [`  no tracked change since ${baseLabel}`] : moved;
  return [`${head}\n  delta since ${baseLabel}${age}`, ...body].join("\n");
}

// ── armed-plan — the terms the WATCHER enforces, from the armed document it cannot otherwise read ─────
//
// The watcher's role-keyed percept (kestrel-wa0j.29 / ADR-0041 §2): it is asked to judge a plan's
// PREMISE ("the plan armed on a thesis — is it still true?") yet the armed document's WHEN/DO/TP/EXIT/
// INVALIDATE clauses live in the supersede action on the bus and are NEVER rendered into any frame — the
// kernel carries only the plan's lifecycle NAME + STATE. This pane echoes the ENFORCED terms (premise,
// entries, exits, invalidation, size envelope) for every plan in an enforced state.
//
// ATTRIBUTION HONEST (OBS, ADR-0041 §1 — a pane invents no value): the terms enter THROUGH the frame
// input ({@link ../frame/types.ts ArmedPlanTerms}) as TYPED clause text the sim driver captured from the
// armed AST and printed through the canonical printer — this builder only FORMATS them, it computes
// nothing. Every field is optional-honest; an absent frame field ⇒ exactly ONE absent-with-reason line.

/**
 * `armed-plan` — the ENFORCED terms the watcher manages, per plan (OBS, no receipt). Renders, for each
 * plan the driver threaded (one in an `armed`/`fired`/`managing` state), its arming premise, entry
 * tickets, exit surface, invalidation surface, and size envelope — each a canonical clause text captured
 * from the armed document (never invented here). FAIL-CLOSED: no `armedPlan` on the frame ⇒ exactly one
 * absent-with-reason line (the pane is catalog-only until the driver arms a plan); a plan that authored
 * no enforceable terms renders one honest "no enforceable terms" line under its name. Pure.
 */
function renderArmedPlan(ctx: PaneBuildContext): string {
  const head = "armed-plan · terms the watcher enforces";
  const ap = ctx.armedPlan;
  // Fail-closed: no armed plan threaded into the frame ⇒ the terms cannot be stated (never reconstructed
  // from position geometry). Exactly ONE absent-with-reason line.
  if (ap === undefined || ap.plans.length === 0) {
    return `${head}\n  armed-plan: unavailable (no plan in an enforced state on this frame)`;
  }
  const lines: string[] = [head];
  for (const p of ap.plans) {
    lines.push(`  ${p.name}`);
    const terms: string[] = [];
    if (p.when !== undefined) terms.push(`    premise: ${p.when}`);
    for (const e of p.entries ?? []) terms.push(`    entry: ${e}`);
    for (const x of p.exits ?? []) terms.push(`    exit: ${x}`);
    for (const i of p.invalidations ?? []) terms.push(`    invalidate: ${i}`);
    if (p.sizeEnvelope !== undefined) terms.push(`    size: budget ${p.sizeEnvelope}`);
    // A plan in an enforced state that authored none of the above (e.g. a pure inventory adopter) — say
    // so honestly under its name rather than emitting a bare name with no body (never a silent blank).
    lines.push(...(terms.length > 0 ? terms : ["    (no enforceable terms authored)"]));
  }
  return lines.join("\n");
}

// ── preflight / affordability gate (kestrel-4gl.13.8) — the Track-B "can-I-act" pane, CALC, ask×mult ─
//
// Answers ONE question — CAN THE INTENDED ACTION BE AFFORDED — by pricing the SHOCK PANIC-ASK before
// it is paid: per-unit cost at the CURRENT (possibly-panicked) ASK (`ask × contract multiplier`), the
// MAX affordable qty the remaining-R budget admits (`floor(remaining$ / (ask × mult))`), and the nested
// concurrency envelopes (plan ⊆ book ⊆ owner). A deterministic transform of OBSERVED fields (the raw
// chain ask + the exec multiplier + the budget/envelope) → CALC: it computes NO fair, carries NO
// receipt, sources NO new/external data.
//
// HARD CONSTRAINT (non-negotiable — "mid is never a price anchor; fair carries receipts"): the
// affordability cost anchors on the OBSERVED `chain.ask` ONLY. It NEVER reads `chain.fair`, and NEVER
// derives a mid from `(bid + ask)/2` — the affordability answer must price the ask you actually pay.
//
// REUSE / RECONCILE with the sizing-headroom (m9i.32): the existing {@link SizingHeadroom.remainingUsd}
// (the remaining-R budget in $) is REUSED as the affordability numerator — its arithmetic is NOT
// re-derived here. The headroom's OWN `basisPerUnit`/`maxUnits` are SPOT/premium-anchored (and null for
// an option at the wake seam), i.e. a mid/spot cap, NEVER the ask — so preflight adds its OWN ask-based
// per-unit cost + max-affordable-qty for the verdict, and only borrows the headroom's $ numerator.
//
// FAIL-CLOSED / ABSENT-NOT-HIDDEN: a dark/absent ask ⇒ cost UNKNOWN; an absent multiplier ⇒ cost
// UNKNOWN; an absent budget (no plan-lifecycle budget AND no cockpit envelope) ⇒ the $ numerator is
// UNKNOWN — any of these ⇒ the verdict is UNKNOWN, NEVER silently AFFORDABLE, never a fabricated cost,
// never a crash. DATE-BLIND: renders prices + $ + counts + verdict words only (no clock/date token).

/**
 * `preflight` — the affordability gate (CALC, no receipt, no new data). Prices the SHOCK panic-ask
 * BEFORE it is paid: per-unit cost at the OBSERVED `chain.ask × multiplier`, the max affordable qty
 * `floor(remaining$ / cost)`, and the plan ⊆ book ⊆ owner concurrency envelopes. REUSES the
 * sizing-headroom's `remainingUsd` as the $ numerator (never re-deriving it); adds its OWN ask-based
 * cost because the headroom cap is spot/premium-anchored, never the ask. FAIL-CLOSED to UNKNOWN on a
 * dark/absent ask, an absent multiplier, or an absent budget — never a silent AFFORDABLE, never a
 * fabricated cost. DATE-BLIND.
 */
function renderPreflight(ctx: PaneBuildContext): string {
  const head = "preflight · affordability gate";

  // The OBSERVED ask you PAY on the intended action's near-money leg. NEVER `fair`, NEVER a mid.
  // chain[0] IS the near-money leg by the kernel's chain-ordering convention: the projection emits the
  // legs nearest-the-money FIRST, in the tape's own (event) order — see `renderChain` in
  // src/session/day.ts ("nearest-the-money first is the tape's own order; we keep event order"). A
  // ChainRow carries no explicit atm/near-money marker, so the near-money leg is taken positionally
  // ([0]) under that convention; if such a marker is ever added, select by it instead of the index.
  const leg = ctx.market.chain.length > 0 ? ctx.market.chain[0]! : undefined;
  const ask = leg !== undefined ? leg.ask : null; // number | null (null ⇒ ask dark)
  const askOk = ask !== null && Number.isFinite(ask);

  // The CONTRACT multiplier from the exec instrument (the leg you'd actually fill). Absent ⇒ UNKNOWN.
  const exec = ctx.instruments.find((i) => i.symbol === ctx.market.instrument);
  const mult = exec?.multiplier;
  const multOk = mult !== null && mult !== undefined && Number.isFinite(mult);

  // remaining-$ — REUSE the sizing-headroom's own remainingUsd (m9i.32); do NOT re-derive it. Fall back
  // to the plan-lifecycle Budget.remaining only when the cockpit envelope's headroom is unavailable.
  const env = ctx.kernel.budgetEnvelope ?? null;
  const sizing = env?.sizing ?? null;
  const remainingUsd =
    sizing !== null && sizing.remainingUsd !== null && Number.isFinite(sizing.remainingUsd)
      ? sizing.remainingUsd
      : ctx.kernel.budget?.remaining ?? null;
  const remainingOk = remainingUsd !== null && Number.isFinite(remainingUsd);

  // Per-unit cost at the OBSERVED ASK (ask × mult), rounded to cents so a float ask can't drift the
  // affordable-qty floor() by one. UNKNOWN when the ask is dark/absent or the multiplier is unknown.
  const cost = askOk && multOk ? Math.round((ask as number) * (mult as number) * 100) / 100 : null;

  const askStr = askOk ? px(ask) : `${DASH} (${leg === undefined ? "no near-money leg" : "ask dark"})`;
  const multStr = multOk ? px(mult) : DASH;
  const costStr = cost !== null ? px(cost) : `${DASH} (UNKNOWN)`;
  const remainingDollarStr = remainingOk ? `$${px(remainingUsd)}` : `${DASH} (UNKNOWN)`;

  const lines: string[] = [head];
  lines.push(`  ask ${askStr} × mult ${multStr} = per-unit cost ${costStr} (at the OBSERVED ask — never mid/fair)`);

  // Max affordable qty at the ask-basis cost — UNKNOWN unless BOTH the ask-cost and the remaining-$ are
  // buildable. Never a silent AFFORDABLE; never a fabricated cost/qty.
  if (cost !== null && cost > 0 && remainingOk) {
    // A NEGATIVE remaining budget floors to a negative qty (e.g. floor(-100 / 630) = -1) — CLAMP the
    // DISPLAYED max affordable qty to 0 (a max-affordable-qty is never negative); the verdict stays
    // UNAFFORDABLE and names the over-budget cause honestly. A non-negative remaining is byte-unchanged.
    const rawQty = Math.floor((remainingUsd as number) / cost);
    const overBudget = rawQty < 0;
    const maxQty = overBudget ? 0 : rawQty;
    const verdict = maxQty >= 1 ? "AFFORDABLE" : "UNAFFORDABLE";
    const detail = maxQty >= 1
      ? `can afford up to ${maxQty} at this ask`
      : overBudget
        ? "cannot afford even 1 at this ask (over budget — remaining-R is negative)"
        : "cannot afford even 1 at this ask";
    const qtyExpr = overBudget
      ? `max(0, floor(remaining ${remainingDollarStr} / cost ${px(cost)}))`
      : `floor(remaining ${remainingDollarStr} / cost ${px(cost)})`;
    lines.push(`  max affordable qty ${maxQty} = ${qtyExpr}`);
    lines.push(`  verdict: ${verdict} — ${detail}`);
  } else {
    // cost === null ⇒ a dark/absent ask or an absent multiplier; a non-null cost <= 0 (a zero/negative
    // ask WITH a valid budget present) is its OWN honest cause — a non-positive ask-cost, NOT a budget
    // gap; only a positive-but-unbuildable budget is the budget-UNKNOWN case. All stay verdict UNKNOWN.
    const why =
      cost === null
        ? "ask-cost UNKNOWN (dark/absent ask or multiplier)"
        : cost <= 0
          ? "ask-cost UNKNOWN — non-positive ask-cost (ask ≤ 0)"
          : "remaining-R budget UNKNOWN (no budget state)";
    lines.push(`  max affordable qty: ${DASH} (UNKNOWN)`);
    lines.push(`  verdict: UNKNOWN — ${why}; cannot affirm affordability (fail-closed)`);
  }

  // The cockpit remaining-R fraction (0..1, NOT $) + the nested concurrency envelopes (plan ⊆ book ⊆ owner).
  if (env !== null) {
    const rrStr = env.remainingR !== null && env.remainingR !== undefined && Number.isFinite(env.remainingR) ? num(env.remainingR, 2) : DASH;
    lines.push(`  remaining-R ${rrStr} (fraction of 1R) · remaining ${remainingDollarStr}`);
    lines.push(`  concurrency envelopes: plan ${px(env.planEnvelope)} ⊆ book ${px(env.bookEnvelope)} ⊆ owner ${px(env.ownerEnvelope)}`);
  } else {
    lines.push(`  remaining-R ${DASH} · concurrency envelopes: ${DASH} (UNKNOWN — no cockpit budget envelope)`);
  }

  return lines.join("\n");
}

// ── pin-moving (kestrel-wa0j.14 / CLOSE phase) — the pin magnet vs a travelling tape, CALC/DETECTOR ──
//
// The close-mechanics read (docs/paper/examples/percept-v5/close.txt "PIN / MOVING"): is spot PINNED
// to the nearest strike (a magnet holding it into expiry) or MOVING (travelling away faster than it
// sits)? PURE over spot ({@link MarketPane.levels}) + the chain STRIKES + the tape (recent velocity) —
// sources NO model, NO new data, carries NO receipt (a DETECTOR verdict over OBSERVED inputs = CALC).
// The paper contract carried `pin_state`/`velocity_60s` as SOURCED series; this pane DERIVES the same
// verdict from what the frozen frame already carries (scope: no new frame-input fields).
//
// Definitions (precise + deterministic, documented like `failed-breaks` so the verdict is replayable):
//   • NEAREST STRIKE — the chain strike minimizing |strike − spot| (ties resolve to the LOWER strike,
//     so the choice is a TOTAL, deterministic function of the served strikes).
//   • DISTANCE — |spot − nearestStrike| (how far spot sits from the pin).
//   • STRIKE SPACING — the MINIMUM positive gap between adjacent DISTINCT chain strikes (the pin-band
//     scale). A single distinct strike ⇒ spacing UNKNOWN ⇒ the pin band cannot be scaled ⇒ state UNKNOWN.
//   • VELOCITY — the tape's CURRENT per-bar close-to-close delta (`close_last − close_prev`), signed;
//     < 2 tape bars ⇒ velocity UNKNOWN ⇒ state UNKNOWN (never a fabricated pin/move read).
//   • PINNED — `distance ≤ spacing/2` AND `|velocity| < spacing/2`: spot sits inside the pin's
//     half-band AND its recent per-bar travel is under half a strike (not being carried away).
//   • MOVING — NOT pinned: either spot is past the half-band OR it is travelling ≥ half a strike/bar.
//   • ABSENT-WITH-REASON — no chain (no strikes to pin to) OR no tape (no velocity to read) OR spot
//     UNKNOWN ⇒ the pane renders a single absent-with-reason line (the macro/`delta` pane idiom).

function renderPinMoving(ctx: PaneBuildContext): string {
  const market = ctx.market;
  const head = `pin-moving · ${market.instrument}`;
  const spot = market.levels.spot;
  const spotOk = spot !== null && spot !== undefined && Number.isFinite(spot);
  // Absent-with-reason: the pane's verdict needs a chain (strikes), a tape (velocity), AND a spot.
  if (market.chain.length === 0) {
    return `${head}\n  pin-moving: unavailable (no chain — no strikes to locate a pin)`;
  }
  if (market.tape.length === 0) {
    return `${head}\n  pin-moving: unavailable (no tape — no velocity to read)`;
  }
  if (!spotOk) {
    return `${head}\n  pin-moving: unavailable (spot unknown — cannot locate the nearest strike)`;
  }
  const s = spot as number;
  // NEAREST STRIKE — minimize |strike − spot|; ties resolve to the LOWER strike (ascending scan, strict `<`).
  const strikes = [...new Set(market.chain.map((r) => r.strike))].sort((a, b) => a - b);
  let nearest = strikes[0]!;
  let bestD = Math.abs(nearest - s);
  for (const k of strikes) {
    const d = Math.abs(k - s);
    if (d < bestD) {
      bestD = d;
      nearest = k;
    }
  }
  const distance = Math.abs(s - nearest);
  // STRIKE SPACING — the minimum positive gap between adjacent distinct strikes (the pin-band scale).
  let spacing: number | null = null;
  for (let i = 1; i < strikes.length; i += 1) {
    const gap = strikes[i]! - strikes[i - 1]!;
    if (gap > 0 && (spacing === null || gap < spacing)) spacing = gap;
  }
  // VELOCITY — the CURRENT per-bar close-to-close delta (signed). < 2 bars ⇒ UNKNOWN (invents none).
  const tape = market.tape;
  const velocity = tape.length >= 2 ? tape[tape.length - 1]!.close - tape[tape.length - 2]!.close : null;
  const bucket = market.tapeBucketMin;
  const nearestLine = `  nearest strike ${px(nearest)} · distance ${num(distance, 2)}`;
  const velCell = velocity === null ? `${DASH} (<2 bars — insufficient)` : `${money(velocity)} (last ${bucket}m bar close-to-close)`;
  const velLine = `  velocity ${velCell}`;
  // STATE — PINNED (inside the half-band AND slow) vs MOVING; UNKNOWN when spacing or velocity is UNKNOWN.
  let stateLine: string;
  if (spacing === null) {
    stateLine = "  state UNKNOWN — single strike on the chain, no spacing to scale the pin band";
  } else if (velocity === null) {
    stateLine = "  state UNKNOWN — need ≥2 tape bars for a velocity read (insufficient)";
  } else {
    const half = spacing / 2;
    const pinned = distance <= half && Math.abs(velocity) < half;
    stateLine = pinned
      ? `  state PINNED — within ${num(half, 2)} of K=${px(nearest)} and travelling <${num(half, 2)}/bar (held by the pin)`
      : `  state MOVING — ${distance > half ? `past the ±${num(half, 2)} pin half-band` : `travelling ≥${num(half, 2)}/bar`} (not held by the pin)`;
  }
  return [head, nearestLine, velLine, stateLine].join("\n");
}

// ── intrinsic-ladder (kestrel-wa0j.14 / CLOSE phase) — the sell-floor doctrine made legible, CALC ──
//
// Per near-money leg (docs/paper/examples/percept-v5/close.txt "INTRINSIC LADDER"): the INTRINSIC
// (parity from spot), the OBSERVED BID, BID−INTRINSIC with a BELOW tag when the bid sits UNDER parity
// (a bid that cannot anchor a sell — the never-naked floor), and the MODEL FAIR when the frame carries
// it. The pane's OWN headline (intrinsic, bid−intrinsic) is CALC over the OBSERVED book + spot; the
// `fair` column is passed through WITH its chain `fairNote` receipt (the SAME MODEL value the chain
// pane renders — never re-derived, never receiptless), so the pane sources no new data.
//
// SAFETY LINE (ADR-0041 §1 exactly-twice channel invariant): the pane ENDS with the sell-floor POLICY
// line — the DECISION-NEAREST repetition of the never-naked floor. The cockpit kernel renders NO
// standing floor line today (its 8 fixed sections carry the floor only inside a conditional engine-log
// rejection reason, never as a standing POLICY line), so this is the floor's SINGLE standing
// occurrence; when the kernel later renders one it MUST be BYTE-IDENTICAL to {@link CLOSE_SELL_FLOOR_POLICY}
// (do not fork the spelling — wa0j.47's lesson).
//
// Definitions:
//   • INTRINSIC — a CALL's parity `max(0, spot − K)`; a PUT's `max(0, K − spot)`; 0 for an OTM leg.
//   • BID — the leg's OBSERVED top-of-book bid; a dark bid renders `—` (never a fabricated price).
//   • BID−INTRINSIC — `bid − intrinsic` (signed); a BELOW tag marks `bid < intrinsic` (under the floor,
//     cannot anchor a sell). A dark bid ⇒ `—` (no comparison to fabricate).
//   • FAIR — the leg's MODEL fair WHEN carried, rendered WITH its `fairNote` receipt; absent ⇒ `—`.
// ABSENT-WITH-REASON: no chain OR spot UNKNOWN ⇒ the ladder is uncomputable (a single reason line).

/** The sell-floor POLICY line — the never-naked floor's decision-nearest spelling (close.txt POLICY).
 * ONE definition: if the kernel ever renders a standing floor line, it must reuse THIS byte string so
 * the two occurrences are byte-identical (ADR-0041 §1 exactly-twice channel invariant). */
export const CLOSE_SELL_FLOOR_POLICY = "sell floor=intrinsic; BELOW rows cannot anchor a sell";

/** One ladder row's intrinsic (parity) from spot: CALL `max(0, spot − K)`, PUT `max(0, K − spot)`. */
function legIntrinsic(row: ChainRow, spot: number): number {
  return row.right === "C" ? Math.max(0, spot - row.strike) : Math.max(0, row.strike - spot);
}

function renderIntrinsicLadder(ctx: PaneBuildContext): string {
  const market = ctx.market;
  const head = `intrinsic-ladder · ${market.instrument}`;
  const spot = market.levels.spot;
  const spotOk = spot !== null && spot !== undefined && Number.isFinite(spot);
  if (market.chain.length === 0) {
    return `${head}\n  intrinsic-ladder: unavailable (no chain — no legs to floor)`;
  }
  if (!spotOk) {
    return `${head}\n  intrinsic-ladder: unavailable (spot unknown — cannot compute intrinsic parity)`;
  }
  const s = spot as number;
  const header = `  ${pad("leg", 8)}${pad("intr", 7)}${pad("bid", 7)}${pad("bid-intr", 14)}fair`;
  const rows = market.chain.map((r) => {
    const intr = legIntrinsic(r, s);
    const bidOk = r.bid !== null && r.bid !== undefined && Number.isFinite(r.bid);
    const leg = `${px(r.strike)}${r.right}`;
    const bidCell = bidOk ? px(r.bid) : DASH;
    // BID−INTRINSIC with a BELOW tag when the bid is under the floor; a dark bid has no comparison.
    const diffCell = bidOk ? `${money((r.bid as number) - intr)}${(r.bid as number) < intr ? " BELOW" : ""}` : DASH;
    // FAIR — the MODEL value WITH its chain receipt (fairNote); absent/null ⇒ `—` (never fabricated).
    const fairCell =
      r.fair !== null && r.fair !== undefined
        ? r.fairNote !== undefined
          ? `${px(r.fair)} ${r.fairNote}`
          : px(r.fair)
        : DASH;
    return `  ${pad(leg, 8)}${pad(num(intr, 2), 7)}${pad(bidCell, 7)}${pad(diffCell, 14)}${fairCell}`;
  });
  // The DECISION-NEAREST repetition of the never-naked floor (ADR-0041 §1). Single occurrence today
  // (the kernel renders no standing floor line) — see the block comment.
  const policy = `  ${CLOSE_SELL_FLOOR_POLICY}`;
  return [head, header, ...rows, policy].join("\n");
}

// ─────────────────────────────────────────────────────────────────────────────
// The catalog entry schema (kestrel-4gl.13) — the ADR request-loop needs THIS exact shape
// ─────────────────────────────────────────────────────────────────────────────

/** A pane's attribution class (the Field/Attribution charter, 3-class): `OBS` an observed fact,
 * `CALC` a derived quantity, `MODEL` a model output that carries a receipt in its rendered text. */
export type PaneAttribution = "OBS" | "CALC" | "MODEL";

/** A pane argument as the renderer consumes it — the structural mirror of the language's
 * {@link ../lang/ast.ts PaneArg}, decoupled from the AST exactly as {@link ViewSelection} is (a
 * caller can supply args from config without importing the language; the AST's own `PaneArg` is
 * assignable to this shape). It carries only a **syntactic supersort** — a bare ident, a window
 * `5m`, or a bare count numeral `12` — which a {@link ParamSlot} REFINES to a semantic sort at
 * materialization (ADR-0041 §1: the parser assigns the supersort with zero catalog knowledge). */
export type PaneSelectionArg =
  | { readonly kind: "arg-ident"; readonly name: string }
  | { readonly kind: "arg-window"; readonly window: { readonly value: number; readonly unit: string } }
  | { readonly kind: "arg-count"; readonly count: number }
  | { readonly kind: "arg-ordinal"; readonly ordinal: number }
  | { readonly kind: "arg-expiry"; readonly expiry: number };

/** The **syntactic supersort** the parser assigned an arg, read back with zero semantic knowledge
 * (ADR-0041 §1). `ident` / `window` / `numeral` / `ordinal` / `expiry` are the five closed surface
 * classes; a {@link ParamSlot} refines one of them to its semantic sort at the `mat` rung. */
function syntacticSupersort(a: PaneSelectionArg): SyntacticSupersort {
  switch (a.kind) {
    case "arg-ident":
      return "ident";
    case "arg-count":
      return "numeral";
    case "arg-ordinal":
      return "ordinal";
    case "arg-expiry":
      return "expiry";
    case "arg-window":
      return "window";
  }
}

/** A window arg in MINUTES, or `null` when the unit carries no fixed minute width (mo/q/y —
 * calendar units can never name an intraday tape bucket). Pure unit arithmetic, no calendar. */
function windowMinutes(w: { readonly value: number; readonly unit: string }): number | null {
  switch (w.unit) {
    case "s":
      return w.value / 60;
    case "m":
      return w.value;
    case "h":
      return w.value * 60;
    case "d":
      return w.value * 24 * 60;
    case "w":
      return w.value * 7 * 24 * 60;
    default:
      return null; // mo/q/y (or an unknown unit): no fixed minute width — the caller fails closed
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// THE INFLECTION SUBSTRATE (ADR-0041 §1) — ParamSlot signatures over closed index sorts
//
// Pane arguments are ADDRESSES over closed index sorts, never expressions (ADR-0041 §1: the
// composition grammar has zero function symbols, ever). A pane's signature is an ordered list of
// `ParamSlot`s; materialization binds positional args to slots in signature order, refining each
// arg's syntactic supersort to the slot's semantic sort — one uniform, repair-guiding
// `KernelHonestyError` surface (`bindArgs`), never sixteen bespoke parsers, never a silent drop.
// ─────────────────────────────────────────────────────────────────────────────

/** A closed index sort a {@link ParamSlot} may denote (ADR-0041 §1). LIVE subset after Train 1B:
 * `LevelName` / `Window` / `Count` / `SessionOrdinal` / `ExpiryOrdinal` — Train 1B opened
 * `SessionOrdinal` (`tape d-N`) and `ExpiryOrdinal` (`chain e-N`) by the sort-introduction ceremony
 * (ADR-0041 §3: teaching attestation + canonical-print/round-trip + denotation + consuming signature
 * + reject fixture). `Instrument` is listed because it is pinned into the {@link POSITION_CLASS_ORDER}
 * now (its slot arrives with the CALC cross-instrument lexeme, a later train — pinning ahead of need
 * is the whole point: argument order is canonical bytes, hash-breaking to retrofit). The DEFERRED
 * roster member `Band` is opened only by its own ceremony as its train lands — the roster is closed at
 * any instant. */
export type ParamSort = "Instrument" | "LevelName" | "Window" | "Count" | "SessionOrdinal" | "ExpiryOrdinal";

/** The five closed SURFACE classes the parser assigns with zero catalog knowledge (ADR-0041 §1 — the
 * `wf` rung). A {@link ParamSlot}'s semantic {@link ParamSort} refines FROM exactly one of these. */
export type SyntacticSupersort = "ident" | "window" | "numeral" | "ordinal" | "expiry";

/**
 * THE ONE PINNED CROSS-PANE POSITION-CLASS ORDERING (ADR-0041 §1 — "one hypothesis, one hash").
 *
 * The single order in which sorts emit in a pane's canonical argument sequence, fixed ONCE across
 * EVERY pane. Because printed argument order is part of the canonical bytes (and the template SKU is
 * the sha256 of those bytes), this ordering is **hash-breaking to retrofit** and so is pinned here,
 * in ONE place, before Train 1 prints its first canonical form.
 *
 * DERIVATION (reconciled with `tests/golden/accept/views.kestrel` + the Train 1B honest-absence
 * teaching forms, NOT chosen by taste — ADR-0041 §1 "derived by reconciling with what views.kestrel
 * already teaches"):
 *   • `Instrument` FIRST — it is the concord head (ADR-0041 §1 Concord: "every pane's Instrument
 *     agrees with the head"); the marked cross-instrument exception spells its two `Instrument` args
 *     ahead of everything else, so the head noun leads the phrase.
 *   • `LevelName` next — the named-address selectors (`levels vwap hod`) name WHICH addresses inside
 *     the already-fixed instrument.
 *   • `Window` next — the timescale scopes the temporal resolution of the addressed slice.
 *   • `Count` next — a cardinality over the already-addressed, already-scoped slice (`chain 12`).
 *   • `SessionOrdinal` next, then `ExpiryOrdinal` LAST (Train 1B, APPENDED — extend-only).
 *
 * WHY SessionOrdinal / ExpiryOrdinal are APPENDED (not inserted before Window/Count), despite a
 * session/expiry selector reading semantically like an "addressing" class that might sit earlier:
 * the ordering constant is **extend-only, existing ranks unchanged** — the hash-stability rule
 * (ADR-0041 §1: printed argument order is canonical bytes, hash-breaking to retrofit). Inserting a
 * sort mid-list would renumber `Window`/`Count` and rewrite the canonical bytes of the already-pinned
 * Train-1A forms — forbidden. The COORDINATION is not free-floating: the Train 1B teaching forms
 * `tape 5m d-0` (Window BEFORE SessionOrdinal) and `chain 12 e-0` (Count BEFORE ExpiryOrdinal) are
 * the corpus evidence that PINS Window < SessionOrdinal and Count < ExpiryOrdinal — the append order
 * is exactly what those canonical forms already print. `SessionOrdinal < ExpiryOrdinal` (session
 * addressing outer to expiry selection; no single pane carries both, so their mutual order is inert,
 * pinned session-before-expiry to match the `d-` before `e-` reading). Existing ranks — Instrument 0,
 * LevelName 1, Window 2, Count 3 — are UNCHANGED, so no Train-1A canonical form re-hashes.
 * views.kestrel contains no counter-example: its one multi-sort line (`tape skyline 5m vwap
 * detector-strip`) is the DEFERRED full-tape per-pane position layout (style<window<overlay<strip —
 * distinct position classes, LATENT until the multi-arg tape train), not the cross-pane SORT total
 * order pinned here.
 *
 * ENFORCED (not merely documented): {@link paramSortRank} + a signature-order invariant test assert
 * every catalog signature declares its slots in non-decreasing rank, so no pane can ever spell its
 * args out of the pinned order.
 */
export const POSITION_CLASS_ORDER: readonly ParamSort[] = [
  "Instrument",
  "LevelName",
  "Window",
  "Count",
  "SessionOrdinal",
  "ExpiryOrdinal",
];

/** The rank of a sort in the pinned {@link POSITION_CLASS_ORDER} (lower prints earlier). */
export function paramSortRank(sort: ParamSort): number {
  const r = POSITION_CLASS_ORDER.indexOf(sort);
  if (r < 0) throw new KernelHonestyError(`sort "${sort}" is not in the pinned POSITION_CLASS_ORDER (fail-closed).`);
  return r;
}

/** How many args a slot binds: exactly `one`, or `many` (variadic — MUST be the terminal slot of a
 * signature, since a variadic slot consumes the rest of the positional args). */
export type SlotArity = "one" | "many";

/** One position class of a pane's signature (ADR-0041 §1). `name` labels the slot in a repair
 * message; `sort` is the semantic sort an arg must elaborate to; `arity` is one/many; `default` is
 * the elaborated value when the slot is unfilled (UNTOUCHED in Train 1A — no pane declares one, so
 * the no-arg path stays byte-identical). */
export interface ParamSlot {
  readonly name: string;
  readonly sort: ParamSort;
  readonly arity: SlotArity;
  readonly default?: PaneSelectionArg;
}

/** The syntactic supersort each semantic sort refines FROM (the closed refinement map, ADR-0041
 * §1). `Instrument`/`LevelName` read a bare `ident`; `Window` reads a `window`; `Count` reads a bare
 * `numeral`. */
const SORT_ACCEPTS: Record<ParamSort, SyntacticSupersort> = {
  Instrument: "ident",
  LevelName: "ident",
  Window: "window",
  Count: "numeral",
  SessionOrdinal: "ordinal",
  ExpiryOrdinal: "expiry",
};

/**
 * Bind a View's positional args to a pane's {@link ParamSlot} signature and refine each to its
 * slot's semantic sort — the `mat` rung of the judgment ladder (ADR-0041 §3: "every argument
 * elaborates at its slot's sort and arity"), decided with the catalog ALONE and NO Frame (pure).
 *
 * The ONE uniform, repair-guiding refusal surface (ADR-0041 §1): every failure throws a
 * {@link KernelHonestyError} naming the pane, the slot, the SERVED sort, and the failed rung —
 * `refused` is never one undifferentiated condition. Refusals, all `mat`:
 *   • **arity** — more args than the signature's slots can bind (a variadic terminal slot soaks the
 *     rest, so this fires only past a filled fixed-`one` signature).
 *   • **sort** — an arg whose supersort matches NO remaining slot (a `window` where a `Count` +
 *     `ExpiryOrdinal` signature expects a numeral or an expiry; an `ident` at a `Window`/`SessionOrdinal`
 *     pair). The refusal names the FIRST unfilled slot (the positional anchor).
 *   • **Count / Ordinal refinement** — a numeral / ordinal that is not a NON-NEGATIVE integer cardinal.
 *
 * SORT-DIRECTED, ORDER-PRESERVING binding (Train 1B, ADR-0041 §1 "binds positional args to slots in
 * signature order"): each arg binds to the NEXT not-yet-filled slot (in signature order) whose sort
 * accepts its supersort; earlier optional slots the arg does not match are left UNFILLED (skipped).
 * This is what lets `tape d-1` bind the SessionOrdinal slot with the earlier optional Window slot
 * unfilled, while `tape 5m d-0` still fills BOTH in canonical order — order is preserved (an arg can
 * only ever match a slot at or after the cursor), only the STRICT "arg[i] ↔ slot[i]" coupling is
 * relaxed to "arg ↔ next-compatible slot". An arg that matches no remaining slot is a genuine
 * sort/arity refusal, exactly as before (`chain 12m` still refuses on the `count` slot — byte-identical
 * to Train 1A, since neither `count` nor `expiry` accepts a window).
 *
 * VALUE checks that need the Frame (a window the served bucket cannot express; a count the frozen
 * chain is short of; a level ident absent from the LevelSet; a session/expiry ordinal the frozen frame
 * carries no data for) are the `renderable` rung and live in the pane's own arged builder, NOT here.
 */
function bindArgs(entry: PaneCatalogEntry, args: readonly PaneSelectionArg[]): void {
  const sig = entry.signature;
  if (sig === undefined) {
    // A pane with an arged builder but no signature is a catalog defect — fail closed (never bind blind).
    throw paneRefusal({ kind: "no-signature", pane: entry.id, args });
  }
  let slotIdx = 0;
  for (let a = 0; a < args.length; a += 1) {
    const arg = args[a]!;
    const got = syntacticSupersort(arg);
    // Advance the slot cursor to the next slot (at or after it) whose sort accepts this arg — skipping
    // earlier optional slots the arg does not fill (order-preserving: we never step backwards).
    let target = slotIdx;
    while (target < sig.length && SORT_ACCEPTS[sig[target]!.sort] !== got) target += 1;
    if (target >= sig.length) {
      // No remaining slot accepts this arg. If the cursor already sits past the last slot, this is
      // overflow (arity); otherwise the arg mismatches the first UNFILLED slot (sort) — the positional
      // anchor the author must fix. Byte-identical to Train 1A for a single-slot signature.
      if (slotIdx >= sig.length) throw paneRefusal({ kind: "arity", pane: entry.id, args, slots: sig });
      throw paneRefusal({ kind: "sort-mismatch", pane: entry.id, slot: sig[slotIdx]!, arg });
    }
    const slot = sig[target]!;
    if (slot.arity === "many") {
      // A variadic slot is terminal: it consumes this arg and every remaining arg, each refined to it.
      for (let j = a; j < args.length; j += 1) refineArg(entry, slot, args[j]!);
      return;
    }
    refineArg(entry, slot, arg);
    slotIdx = target + 1;
  }
}

/** Refine one arg to one slot's sort, or throw the uniform `mat`-rung refusal (see {@link bindArgs}).
 * Reached only for an arg whose supersort ALREADY matches the slot's sort ({@link bindArgs} routed it
 * here) — so this applies the per-sort VALUE refinement (a Count / SessionOrdinal / ExpiryOrdinal must
 * be a NON-NEGATIVE integer cardinal; a Count additionally must be POSITIVE — `chain 0` names no
 * strikes, whereas `d-0` / `e-0` name the current session / nearest expiry). */
function refineArg(entry: PaneCatalogEntry, slot: ParamSlot, arg: PaneSelectionArg): void {
  const want = SORT_ACCEPTS[slot.sort];
  const got = syntacticSupersort(arg);
  if (got !== want) {
    throw paneRefusal({ kind: "sort-mismatch", pane: entry.id, slot, arg });
  }
  if (slot.sort === "Count" && arg.kind === "arg-count") {
    if (!Number.isInteger(arg.count) || arg.count <= 0) {
      throw paneRefusal({ kind: "count-not-integer", pane: entry.id, slot, arg });
    }
  }
  if (slot.sort === "SessionOrdinal" && arg.kind === "arg-ordinal") {
    if (!Number.isInteger(arg.ordinal) || arg.ordinal < 0) {
      throw paneRefusal({ kind: "ordinal-not-nonneg-integer", pane: entry.id, slot, arg });
    }
  }
  if (slot.sort === "ExpiryOrdinal" && arg.kind === "arg-expiry") {
    if (!Number.isInteger(arg.expiry) || arg.expiry < 0) {
      throw paneRefusal({ kind: "ordinal-not-nonneg-integer", pane: entry.id, slot, arg });
    }
  }
}

/** One catalogued pane — the single source of truth entry. `id` is what a {@link
 * ../lang/ast.ts ViewStatement} names; `title`/`description` are the prompt menu; `attribution`
 * + `tokenCostEstimate` are planning metadata; `builder` is the pure renderer. */
export interface PaneCatalogEntry {
  /** Stable pane id — what a View selects by, and what the prompt advertises. Never a reserved
   * kernel id ({@link ../frame/types.ts assertPaneIdAllowed}). */
  readonly id: string;
  /** Human title for the prompt menu. */
  readonly title: string;
  /** One-line description for the prompt menu (what this pane shows). */
  readonly description: string;
  /** Attribution class (OBS | CALC | MODEL — a MODEL pane carries a receipt in its text). */
  readonly attribution: PaneAttribution;
  /** A documented rough token-cost estimate (chars/4 order-of-magnitude) for the prompt menu +
   * View budget planning. The REAL cost is measured at materialization under the active tokenizer. */
  readonly tokenCostEstimate: number;
  /** The pure builder: a total function of the {@link PaneBuildContext}. */
  readonly builder: (ctx: PaneBuildContext) => string;
  /** The ordered {@link ParamSlot} signature (ADR-0041 §1 inflection substrate) — the closed index
   * sorts this pane's positional args address. Its PRESENCE (with an {@link argedBuilder}) is what
   * lets a View pass args; {@link bindArgs} elaborates the args against it at the `mat` rung. Absent
   * ⇒ the pane takes no args ({@link resolveView} refuses any). Slots MUST be declared in
   * non-decreasing {@link paramSortRank} (the pinned cross-pane ordering — enforced by test). */
  readonly signature?: readonly ParamSlot[];
  /** The ARGED builder — present ONLY on a pane with a {@link signature} (kestrel-wa0j.3 /
   * ADR-0041 §1). It receives args ALREADY bound + sort-checked by {@link bindArgs} (the `mat`
   * rung), and applies the `renderable`-rung VALUE checks that need the Frame (a window the served
   * bucket cannot express; a count the frozen chain is short of; a level ident absent from the
   * LevelSet), THROWING a {@link KernelHonestyError} naming pane + arg. Pure, exactly like `builder`. */
  readonly argedBuilder?: (ctx: PaneBuildContext, args: readonly PaneSelectionArg[]) => string;
}

// ─────────────────────────────────────────────────────────────────────────────
// THE CATALOG — every non-kernel pane that exists today, exactly once
// ─────────────────────────────────────────────────────────────────────────────

/** The ordered list of catalogued panes (single source of truth). New panes slot in here. */
export const PANE_CATALOG: readonly PaneCatalogEntry[] = [
  {
    id: "instruments",
    title: "Instruments",
    description: "the traded instrument specs (asset class, role, multiplier, tick)",
    attribution: "OBS",
    tokenCostEstimate: 40,
    builder: (ctx) => renderInstruments(ctx.instruments),
  },
  {
    id: "levels",
    title: "Levels",
    description:
      "the primary instrument's key levels (spot, prior close, HOD/LOD, VWAP, OR); optional level-set idents select which cells render (`levels vwap hod`)",
    attribution: "CALC",
    tokenCostEstimate: 40,
    builder: (ctx) => renderLevels(ctx.market.instrument, ctx.market.levels),
    // `levels vwap hod` — a variadic LevelName level-set naming which cells render (ADR-0041 §1).
    signature: [{ name: "level-set", sort: "LevelName", arity: "many" }],
    argedBuilder: renderLevelsWithArgs,
  },
  {
    id: "tape",
    title: "Tape",
    description:
      "the orientation tape (rotated candlestick — price is column, time flows down); optional window re-buckets (`tape 5m`) and an optional session ordinal addresses a session (`tape d-0` current, `tape d-1` prior — prior sessions render absent-with-reason until kestrel-wa0j.20 threads the data)",
    attribution: "OBS",
    tokenCostEstimate: 120,
    builder: (ctx) => renderTape(ctx.market.tape, ctx.market.tapeBucketMin),
    // `tape 5m d-0` — an optional Window (bucket width; a multiple of the served width is CALC
    // re-bucketed) then an optional SessionOrdinal (which session; d-0 current, d-N prior). Slots are
    // in pinned POSITION_CLASS_ORDER (Window rank 2 < SessionOrdinal rank 4); `tape d-1` binds the
    // ordinal with the Window slot skipped (sort-directed binding). kestrel-wa0j.3 / .19 §1 / .24 / ADR-0041 §1.
    signature: [
      { name: "window", sort: "Window", arity: "one" },
      { name: "session", sort: "SessionOrdinal", arity: "one" },
    ],
    argedBuilder: renderTapeWithArgs,
  },
  {
    id: "chain",
    title: "Chain (near-money)",
    description:
      "near-money option legs: bid/ask/fair with a fair-model receipt + dark-side flags; optional Count selects the N nearest-money strikes (`chain 12`) and an optional expiry ordinal addresses an expiry (`chain e-0` nearest, `chain e-1` next — further expiries render absent-with-reason until kestrel-wa0j.20 threads the data)",
    attribution: "MODEL",
    tokenCostEstimate: 80,
    builder: (ctx) => renderChain(ctx.market.instrument, ctx.market.chain, ctx.market.chainDte),
    // `chain 12 e-0` — an optional Count (N nearest-money strikes; a shortfall renders absent-with-reason)
    // then an optional ExpiryOrdinal (which expiry; e-0 nearest, e-N further out). Slots are in pinned
    // POSITION_CLASS_ORDER (Count rank 3 < ExpiryOrdinal rank 5); `chain e-1` binds the expiry with the
    // Count slot skipped (sort-directed binding). kestrel-wa0j.24 / ADR-0041 §1/§3.
    signature: [
      { name: "count", sort: "Count", arity: "one" },
      { name: "expiry", sort: "ExpiryOrdinal", arity: "one" },
    ],
    argedBuilder: renderChainWithArgs,
  },
  {
    id: "macro",
    title: "Macro",
    description: "overnight/macro context (absent-with-reason in the v1 harness)",
    attribution: "OBS",
    tokenCostEstimate: 8,
    builder: () => MACRO_PANE,
  },
  {
    id: "acting",
    title: "Acting detail",
    description: "the plan-lifecycle view: positions, resting orders, fills-since-last, budget, plan states",
    attribution: "OBS",
    tokenCostEstimate: 100,
    builder: (ctx) => renderActingDetail(ctx.kernel),
  },
  {
    id: "failed-breaks",
    title: "Failed-break tally",
    description: "count of pokes past HOD/LOD/OR edges that did NOT hold this session (the fake-out trap)",
    attribution: "CALC",
    tokenCostEstimate: 40,
    builder: (ctx) => renderFailedBreaks(ctx.market),
  },
  {
    id: "level-interaction",
    title: "Level-interaction detail",
    description: "rejection character at each key level (HOD/LOD/VWAP/OR edges): wick (exhaustion) vs body (break)",
    attribution: "CALC",
    tokenCostEstimate: 50,
    builder: (ctx) => renderLevelInteraction(ctx.market),
  },
  {
    id: "range-velocity",
    title: "Range & velocity",
    description: "realized range + velocity of the current move — impulsive (fundable) vs grinding (a poke on decelerating velocity is unfunded)",
    attribution: "CALC",
    tokenCostEstimate: 45,
    builder: (ctx) => renderRangeVelocity(ctx.market),
  },
  {
    id: "series-summary",
    title: "Series summary (numeric trend)",
    description:
      "the served tape's trend as NUMBERS (embedder-legible): drift over the window (signed pts + %), close-vs-VWAP signed distance, least-squares slope of the closes, and the v5 1-bucket |move| velocity percentiles (p50/p90/max/n)",
    attribution: "CALC",
    tokenCostEstimate: 55,
    builder: (ctx) => renderSeriesSummary(ctx.market),
  },
  {
    id: "gex",
    title: "Dealer-gamma / GEX profile",
    description: "net dealer-gamma by strike: zero-γ flip + pin/break walls (MODEL, gex-v1; taints on stale spot)",
    attribution: "MODEL",
    tokenCostEstimate: 70,
    builder: (ctx) => renderGex(ctx.market),
  },
  {
    id: "iv-skew",
    title: "IV skew / term-structure",
    description: "near-money IV skew + term structure — is a real move priced, or is the break unfunded (MODEL, iv-skew-v1)",
    attribution: "MODEL",
    tokenCostEstimate: 60,
    builder: (ctx) => renderIvSkew(ctx.market),
  },
  {
    id: "implied-realized",
    title: "Implied-vs-realized pacing",
    description: "implied move (ATM straddle ≈ 1σ) vs realized session range — is the break paid for (MODEL, implied-move-v1)",
    attribution: "MODEL",
    tokenCostEstimate: 55,
    builder: (ctx) => renderImpliedRealized(ctx.market),
  },
  {
    id: "coverage",
    title: "Coverage / feed health",
    description: "per-vehicle feed health (bid-present rate, two-sided, staleness, dark) — flags DEGRADED-DATA and a frozen feed",
    attribution: "OBS",
    tokenCostEstimate: 45,
    builder: (ctx) => renderCoverage(ctx.kernel),
  },
  {
    id: "preflight",
    title: "Preflight / affordability gate",
    description: "prices the SHOCK panic-ask before it is paid: per-unit cost (ask×mult), max affordable qty, plan ⊆ book ⊆ owner envelopes",
    attribution: "CALC",
    tokenCostEstimate: 55,
    builder: (ctx) => renderPreflight(ctx),
  },
  {
    id: "vol",
    title: "Vol / expected-move decomposition",
    description:
      "ATM straddle from the near-money book — gross (book mid, labelled) + ask-side payable with per-leg spreads / intrinsic / extrinsic + realized range; components the context cannot carry render absent-with-reason",
    attribution: "CALC",
    tokenCostEstimate: 60,
    builder: (ctx) => renderVol(ctx.market),
  },
  {
    id: "prior-context",
    title: "Prior-session context",
    description:
      "spot vs prior close (direction, points, %) from the levels — the opening gap on OPEN, the change-on-day on WAKE; explicit unknown when prior close is absent",
    attribution: "CALC",
    tokenCostEstimate: 30,
    builder: (ctx) => renderPriorContext(ctx),
  },
  {
    id: "delta",
    title: "Delta since last vantage",
    description:
      "the WAKE delta-since read: what MOVED since the prior vantage (spot + signed change, HOD/LOD prior→now, VWAP, ATM extrinsic) from the frame-carried prior; unchanged facts omitted; absent-with-reason when no prior vantage rides the frame",
    attribution: "CALC",
    tokenCostEstimate: 45,
    builder: (ctx) => renderDelta(ctx),
  },
  {
    id: "armed-plan",
    title: "Armed plan (terms the watcher enforces)",
    description:
      "the ENFORCED terms of every plan in an armed/fired/managing state — arming premise (WHEN), entry tickets, exit surface (TP/EXIT), invalidation (INVALIDATE/CANCEL-IF), size envelope — as canonical clause text captured from the armed document; absent-with-reason when no plan is enforced (the watcher's role-keyed percept, kestrel-wa0j.29)",
    attribution: "OBS",
    tokenCostEstimate: 90,
    builder: (ctx) => renderArmedPlan(ctx),
  },
  {
    id: "pin-moving",
    title: "Pin / moving",
    description:
      "close-mechanics pin read: nearest strike + distance + a PINNED/MOVING state derived from recent tape velocity vs the strike spacing; absent-with-reason without a chain or tape",
    attribution: "CALC",
    tokenCostEstimate: 40,
    builder: (ctx) => renderPinMoving(ctx),
  },
  {
    id: "intrinsic-ladder",
    title: "Intrinsic ladder",
    description:
      "per near-money leg: intrinsic (parity from spot), bid, bid-intrinsic with a BELOW tag under the floor, fair (with its receipt) when carried; ends with the sell-floor POLICY line (the never-naked floor)",
    attribution: "CALC",
    tokenCostEstimate: 60,
    builder: (ctx) => renderIntrinsicLadder(ctx),
  },
];

/** The catalog as an id → entry map (built once; the lookup {@link resolveView}/{@link materializePanes} use). */
const CATALOG_BY_ID: ReadonlyMap<string, PaneCatalogEntry> = new Map(PANE_CATALOG.map((e) => [e.id, e]));

/** Look up a catalogued pane by id, or `undefined`. The ONLY builder path — a pane not here cannot render. */
export function paneById(id: string): PaneCatalogEntry | undefined {
  return CATALOG_BY_ID.get(id);
}

// ─────────────────────────────────────────────────────────────────────────────
// Default Views — reproduce today's hardcoded pane set BYTE-IDENTICALLY, per frame kind
// ─────────────────────────────────────────────────────────────────────────────

/** The OPEN keyframe's hardcoded pane order. kestrel-wa0j.47: `macro` left the default set — in the
 * v1 harness the pane can only ever render its absence, and that absence already renders once in the
 * kernel's unavailable-capabilities line (a non-safety fact renders exactly once, ADR-0041 §1). The
 * pane stays catalogued and addressable: a View that names `macro` still renders absent-with-reason. */
export const DEFAULT_OPEN_PANES: readonly string[] = ["instruments", "levels", "tape", "chain", "acting"];

/** The WAKE delta frame's hardcoded pane order (byte-identical to the pre-catalog renderer). */
export const DEFAULT_WAKE_PANES: readonly string[] = ["tape", "levels", "chain", "acting"];

/** The default (no View supplied) ordered pane ids for a frame kind — the current hardcoded set. */
export function defaultPaneIds(frameKind: "OPEN" | "WAKE"): readonly string[] {
  return frameKind === "OPEN" ? DEFAULT_OPEN_PANES : DEFAULT_WAKE_PANES;
}

// ─────────────────────────────────────────────────────────────────────────────
// View resolution + materialization — SELECT from the catalog, honor the budget
// ─────────────────────────────────────────────────────────────────────────────

/** One resolved pane of a {@link ResolvedView}: its validated catalog id + the (possibly empty)
 * args the View passed it — validated as UNDERSTOOD at resolution (only a pane with an
 * `argedBuilder` may carry args), with the arg VALUES validated against the context at
 * materialization (the builders are pure functions of the context; resolution has none). */
export interface ResolvedPane {
  readonly id: string;
  readonly args: readonly PaneSelectionArg[];
}

/** A View resolved against the catalog: an ordered list of validated panes (id + args) + an
 * optional token budget. This is what the renderer materializes; the ViewStatement is resolved
 * into it once. `panes` is the canonical field; `paneIds` is its id projection (kept for id-only
 * consumers), derived ONLY by {@link resolvedViewOf} — never assembled by hand, so the two can
 * never drift. */
export interface ResolvedView {
  readonly name: string;
  readonly paneIds: readonly string[];
  readonly panes: readonly ResolvedPane[];
  readonly budget?: number;
}

/** The ONE constructor of a {@link ResolvedView}: derives `paneIds` from the canonical `panes`
 * (the single source of the projection invariant). `budget` stays ABSENT — not `undefined` —
 * when the View declares none. */
function resolvedViewOf(name: string, panes: readonly ResolvedPane[], budget?: number): ResolvedView {
  return {
    name,
    paneIds: panes.map((p) => p.id),
    panes,
    ...(budget !== undefined ? { budget } : {}),
  };
}

/** The minimal View selection shape the renderer consults — a subset of {@link
 * ../lang/ast.ts ViewStatement} (name + ordered pane names, each with the ViewStatement's optional
 * pane args + optional token budget). Decoupled from the AST so a caller can supply a View from
 * config without importing the language (the AST's `PaneRef.args` is assignable to `args`). */
export interface ViewSelection {
  readonly name: string;
  readonly panes: readonly { readonly name: string; readonly args?: readonly PaneSelectionArg[] }[];
  readonly budget?: number;
}

/**
 * Resolve a View selection against the catalog into an ordered, VALIDATED {@link ResolvedView}, or
 * fall back to a founder seat View / the frame kind's DEFAULT View when none is supplied. FAIL-CLOSED:
 * a pane id the View names that is absent from the catalog THROWS a {@link KernelHonestyError} (never
 * a silent drop — a View can only select panes that exist); a View may never name a reserved kernel id
 * ({@link ../frame/types.ts assertPaneIdAllowed}); and args passed to a pane that has no
 * `argedBuilder` are REFUSED here (an arg is never silently dropped — kestrel-wa0j.3). Arg VALUES
 * are validated against the build context at materialization (resolution has no context). Pure.
 *
 * ## Role-keyed resolution (ADR-0041 §2 / A1, kestrel-wa0j.26)
 * The optional `seat` binds the acting pod {@link SeatId} for this frame. The PRECEDENCE is strict:
 *
 *   explicit authored View  >  seat founder View  >  phase default
 *
 *   1. an explicit `view` ALWAYS wins — a seat never overrides an authored/scheduled/forced lens
 *      (`seat` is ignored when `view` is present);
 *   2. else, when a `seat` is supplied AND it has a founder seed for this frame kind
 *      ({@link ./founder-registry.ts founderViewFor}), that founder View resolves — the graded SEED
 *      for the role (never a blessed default: it is delivered ONLY because the caller opted in);
 *   3. else the frame kind's phase default (byte-identical to the pre-role-axis behaviour).
 *
 * NO-CHURN: `seat` is optional and defaulted absent, so EVERY existing call site (no seat) resolves
 * byte-identically to before this axis existed — the founder branch is unreachable without a seat, and
 * a seat with no founder seed for the frame falls through to the very same phase-default path. The
 * config opt-in that decides whether the driver passes a seat at all lives in the driver
 * (`AgentConfig.seatViews`), never here — this function only applies the precedence it is handed.
 */
export function resolveView(view: ViewSelection | undefined, frameKind: "OPEN" | "WAKE", seat?: SeatId): ResolvedView {
  // Precedence: explicit authored View wins; else the seat's founder seed (when it has one for this
  // frame kind); else phase default. A seat is consulted ONLY when no View was authored.
  const effective = view ?? (seat !== undefined ? founderViewFor(seat, frameKind) : undefined);
  if (effective === undefined) {
    const ids = defaultPaneIds(frameKind);
    return resolvedViewOf(
      `default-${frameKind.toLowerCase()}`,
      ids.map((id) => ({ id, args: [] })),
    );
  }
  const panes: ResolvedPane[] = [];
  for (const p of effective.panes) {
    const id = p.name;
    assertPaneIdAllowed(id); // a View can never claim the non-configurable kernel's reserved id
    const entry = CATALOG_BY_ID.get(id);
    if (entry === undefined) {
      throw paneRefusal({ kind: "unknown-pane", view: effective.name, pane: id, known: PANE_CATALOG.map((e) => e.id) });
    }
    const args = p.args ?? [];
    if (args.length > 0 && entry.argedBuilder === undefined) {
      throw paneRefusal({ kind: "takes-no-args", view: effective.name, pane: id, args });
    }
    panes.push({ id, args });
  }
  return resolvedViewOf(effective.name, panes, effective.budget);
}

/**
 * Render ONE catalogued pane through the real bind → build path — the exact per-pane step {@link
 * materializePanes} drives for every pane in a resolved View, exposed as the public per-pane entry
 * (kestrel-z473.4). It IS that path (materialization calls this), so the bind/compose layer gets
 * covered by every pane test for free and the `builder`/`argedBuilder` handles no longer need to be
 * reached directly: no args ⇒ the pure `builder`; args ⇒ {@link bindArgs} (the `mat`-rung sort/arity
 * refusal, catalog-only + Frame-free) THEN the `argedBuilder` (the `renderable`-rung VALUE checks that
 * need the Frame). FAIL-CLOSED: an `id` absent from the catalog THROWS a {@link KernelHonestyError}
 * (a pane not catalogued cannot render — never a silent empty block), and args passed to an arg-less
 * pane are REFUSED here exactly as materialization refuses them. Pure — every builder is a total
 * function of `ctx` (no wall clock, no RNG). Output bytes are byte-identical to the direct handle call.
 */
export function renderPane(
  id: string,
  args: readonly PaneSelectionArg[],
  ctx: PaneBuildContext,
): string {
  const entry = CATALOG_BY_ID.get(id);
  if (entry === undefined) {
    // A pane not in the catalog has no builder path — fail closed rather than render nothing.
    throw paneRefusal({ kind: "unreachable-not-catalogued", pane: id });
  }
  if (args.length === 0) {
    return entry.builder(ctx); // the no-arg path — byte-identical to before args existed
  }
  if (entry.argedBuilder === undefined) {
    // Args on an arg-less pane (resolveView refuses this earlier; fail closed here too).
    throw paneRefusal({ kind: "unreachable-args-on-argless", pane: id, args });
  }
  // The arged path (ADR-0041 §1): FIRST bind + sort-refine the args against the pane's ParamSlot
  // signature (the `mat` rung — catalog-only, Frame-free, the uniform repair-guiding refusal),
  // THEN hand the well-sorted args to the builder for the `renderable`-rung VALUE checks.
  bindArgs(entry, args);
  return entry.argedBuilder(ctx, args);
}

/**
 * Materialize a resolved View into its ordered list of rendered body-pane blocks (the kernel is
 * NOT here — it leads, non-configurably, in the renderer). Each pane is built PURELY from `ctx` via
 * {@link renderPane} (the single per-pane bind → build path) and measured under `tokenizer`. BUDGET
 * GUARD (fail-closed): if the View declares a token budget and the SELECTED panes' measured total
 * exceeds it, this THROWS a {@link KernelHonestyError} naming the overage — never a silent drop of a
 * pane the View asked for (an over-budget View is a defect the author must fix, exactly as the
 * unknown-pane and kernel-lead guards fail closed). Pure.
 */
export function materializePanes(
  resolved: ResolvedView,
  ctx: PaneBuildContext,
  tokenizer: Tokenizer,
): readonly string[] {
  const blocks: string[] = [];
  let spent = 0;
  for (const p of resolved.panes) {
    const text = renderPane(p.id, p.args, ctx);
    spent += tokenizer.count(text);
    blocks.push(text);
  }
  if (resolved.budget !== undefined && spent > resolved.budget) {
    throw paneRefusal({
      kind: "over-budget",
      view: resolved.name,
      paneIds: resolved.paneIds,
      spent,
      tokenizer: tokenizer.id,
      budget: resolved.budget,
    });
  }
  return blocks;
}
