/**
 * # frame/render-json — the `json` Rendering: a THIRD format adapter at the one-walk seam (ADR-0052 §2)
 *
 * The `json` Rendering materializes a {@link ./types.ts Kernel}/Frame into **typed, structural JSON**
 * carrying every Field WHOLE — the machine-legible twin of the canonical text screen
 * ({@link ./render.ts}) and the token-optimal ARM ({@link ./render-arm.ts}). Like both text adapters,
 * the kernel goes through the ONE cockpit-kernel walk ({@link ./kernel-walk.ts walkKernel}): the
 * {@link JsonKernelSink} is the third {@link ./kernel-walk.ts KernelSink}, visiting the SAME 8
 * fixed-order sections in the SAME order, so a new/reordered kernel section lands in json from the
 * single edit in `kernel-walk.ts`. This module NEVER touches canonical/ARM bytes — the renderer epoch
 * corpus (canonical text + both ARMs) is unchanged; the json Rendering's byte contract is its own
 * golden fixtures, not the epoch.
 *
 * ## The invariants this adapter lives inside (ADR-0052 §2, ADR-0009 — not up for grading)
 * - **Fields cross WHOLE.** A {@link ./types.ts Field} (a predictor/regime {@link ./types.ts Claim})
 *   is emitted as `{ value, attribution, source?, modelVer?, confidence?, asOfSeq? }` — a `MODEL`
 *   Field keeps its receipt (`source` + `modelVer`) and `confidence`; an `OBS`/`CALC` value crosses as
 *   its raw typed value (it carries no receipt). Provenance is never invented: a raw kernel number
 *   (a health rate, a strike, an envelope) is NOT dressed up with a fabricated attribution.
 * - **null Fields cross as explicit UNKNOWN.** An absent/`null`/non-finite value is carried as the
 *   explicit {@link UNKNOWN} marker — NEVER omitted, NEVER defaulted to `0`/`false`. This mirrors the
 *   canonical codebook (ADR-0041 §1: the `UNKNOWN` marker, never a bare `?`/omission); the whole
 *   point is that a reader can tell "unknown" from "absent" from "zero".
 * - **Invents no value.** Every number/string is a field on the input; there is no layout to compute
 *   (json is structural), so a Rendering here never derives a value canonical draws (an axis bound, a
 *   column) — it carries the raw Fields, from which a consumer (the honest chart, ADR-0052 §3)
 *   computes its own view.
 * - **Fail-closed honesty.** A dishonest `MODEL` claim (non-MODEL, or missing its receipt/confidence)
 *   is REFUSED at the seam ({@link ./types.ts assertClaimHonest}) exactly as the canonical adapter
 *   refuses it — a degraded claim is never serialized as if honest.
 * - **Deterministic + byte-stable.** Serialization runs through the ONE canonical JSON module
 *   ({@link ../canonical/json.ts canonicalizeJson}: recursively ordered keys, kept array order, no
 *   trailing newline) — the SAME bytes the Bus/Blotter content-address, so the same Frame always
 *   yields byte-identical json. No wall clock, no RNG (RUNTIME §0).
 */

import { canonicalizeJson } from "../canonical/json.ts";
import { assertBriefHashHonest } from "./brief.ts";
import { assertClaimHonest } from "./types.ts";
import type {
  Brief,
  BriefingInput,
  ChainRow,
  Claim,
  EngineAction,
  Field,
  InstrumentSpec,
  Kernel,
  LevelSet,
  Mandate,
  OwnerAct,
  Position,
  RestingOrder,
  SizingHeadroom,
  TapeRow,
  VehicleHealth,
  WakeDeltaInput,
  WakeRef,
} from "./types.ts";
import {
  bucketEngineLog,
  walkKernel,
  type BudgetEnvelopeArg,
  type KernelSink,
} from "./kernel-walk.ts";

// ─────────────────────────────────────────────────────────────────────────────
// Explicit UNKNOWN — a null Field crosses the wire as an explicit marker, never omitted/defaulted
// ─────────────────────────────────────────────────────────────────────────────

/** The explicit unknown marker (ADR-0052 §2 / the ADR-0041 §1 codebook). An absent/`null`/non-finite
 * value crosses the json wire as this literal — NEVER an omitted key (which reads as "absent") and
 * NEVER a defaulted `0`/`false` (which reads as a real value). The one honest reading of a null Field. */
export const UNKNOWN = "UNKNOWN" as const;
export type JsonUnknown = typeof UNKNOWN;
/** A value that may be genuinely unavailable: the value, or the explicit {@link UNKNOWN} marker. */
export type Maybe<T> = T | JsonUnknown;

/** A number Field, or explicit UNKNOWN when absent/`null`/non-finite (never `0`, never omitted). */
function maybeNum(x: number | null | undefined): Maybe<number> {
  return x === null || x === undefined || !Number.isFinite(x) ? UNKNOWN : x;
}

/** A string Field, or explicit UNKNOWN when absent/empty (never omitted). */
function maybeStr(s: string | null | undefined): Maybe<string> {
  return s === null || s === undefined || s === "" ? UNKNOWN : s;
}

// ─────────────────────────────────────────────────────────────────────────────
// The typed json Rendering shapes (Fields carried WHOLE)
// ─────────────────────────────────────────────────────────────────────────────

/** A {@link ./types.ts Field} carried WHOLE — value + attribution, plus the `MODEL` receipt
 * (`source` + `modelVer`) and `confidence` when present. A raw `OBS`/`CALC` value has no receipt. */
export interface FieldJson {
  readonly value: number | string;
  readonly attribution: Field["attribution"];
  readonly source?: string;
  readonly modelVer?: string;
  readonly confidence?: number;
  readonly asOfSeq?: number;
}

/** A predictor/regime claim carried WHOLE — its type + its honest `MODEL` {@link FieldJson}. */
export interface ClaimJson {
  readonly claimType: Claim["claimType"];
  readonly field: FieldJson;
}

export interface MandateJson {
  readonly objective: string;
  readonly rUsd: Maybe<number>;
  readonly successCriterion: string;
  readonly riskRule: string;
}

export interface BriefJson {
  readonly text: string;
  readonly hash: string;
  readonly version?: string;
  /** Labelled at the seam, exactly as the text adapters do: the Brief is directional English that can
   * NEVER enter admission/narrowing (ADR-0026 hard guard) — a consumer must not read it as a constraint. */
  readonly channel: "directional; NOT a constraint, NOT an admission input";
}

export interface WakeJson {
  readonly reason: string;
  readonly severity: WakeRef["severity"];
  /** Minutes-to-close, a RELATIVE duration (date-blind); UNKNOWN when absent. Never a wall clock/date. */
  readonly deadlineMinToClose: Maybe<number>;
}

export interface VehicleHealthJson {
  readonly instrument: string;
  readonly bidPresentRate: Maybe<number>;
  readonly twoSided: boolean;
  readonly staleS: Maybe<number>;
  readonly dark: boolean;
}

export interface PositionJson {
  readonly instrument: string;
  /** An equity/spot leg carries NEITHER strike NOR right (ADR-0017) — both UNKNOWN; an option carries both. */
  readonly strike: Maybe<number>;
  readonly right: Maybe<Position["right"] & string>;
  readonly qty: number;
  readonly basis: Maybe<number>;
  readonly unrealUsd: Maybe<number>;
  readonly structure: Maybe<string>;
  readonly claimOwner: Maybe<string>;
  readonly plan: Maybe<string>;
}

export interface RestingJson {
  readonly ref: string;
  readonly side: RestingOrder["side"];
  readonly instrument: string;
  readonly strike: Maybe<number>;
  readonly right: Maybe<RestingOrder["right"] & string>;
  readonly qty: number;
  readonly px: Maybe<number>;
  readonly live: boolean;
  readonly clamped: boolean;
  readonly note: Maybe<string>;
}

export interface BudgetJson {
  readonly remainingR: Maybe<number>;
  readonly planEnvelope: Maybe<number>;
  readonly bookEnvelope: Maybe<number>;
  readonly ownerEnvelope: Maybe<number>;
}

export interface SizingJson {
  readonly instrument: string;
  readonly unit: SizingHeadroom["unit"];
  readonly basisPerUnit: Maybe<number>;
  readonly maxUnits: Maybe<number>;
  readonly remainingUsd: Maybe<number>;
  readonly note?: string;
}

export interface OwnerActJson {
  readonly id: string;
  readonly kind: string;
  readonly asOfSeq: number;
}

/** The engine log bucketed exactly as the text adapters bucket it — the four fixed buckets in order
 * plus a catch-all `other` (absent-not-hidden: a kind outside the closed union is surfaced, never dropped). */
export interface EngineActionJson {
  readonly id: string;
  readonly kind: string;
  readonly asOfSeq: number;
  readonly reason: Maybe<string>;
}
export interface EngineLogBucketJson {
  readonly kind: string;
  readonly count: number;
  readonly actions: readonly EngineActionJson[];
}
export interface EngineLogJson {
  readonly buckets: readonly EngineLogBucketJson[];
  readonly other: readonly EngineActionJson[];
}

/** The cockpit kernel as typed JSON — the same 8 fixed-order sections the text adapters render, every
 * section ALWAYS present (absent-not-hidden: an empty section is `[]`/explicit UNKNOWN, never omitted). */
export interface KernelJson {
  readonly frame: "OPEN" | "WAKE";
  readonly mandate: MandateJson | null;
  readonly brief: BriefJson | null;
  readonly wake: Maybe<WakeJson>;
  readonly dataHealth: readonly VehicleHealthJson[];
  readonly unavailable: readonly string[];
  readonly positions: readonly PositionJson[];
  readonly resting: readonly RestingJson[];
  readonly budget: Maybe<BudgetJson>;
  readonly sizing: Maybe<SizingJson>;
  readonly ownerEnvelope: Maybe<number>;
  readonly ownerActs: readonly OwnerActJson[];
  readonly engineLog: EngineLogJson;
  readonly claims: readonly ClaimJson[];
}

// ─────────────────────────────────────────────────────────────────────────────
// The json KernelSink — the THIRD adapter over the ONE walk (ADR-0052 §1/§2)
// ─────────────────────────────────────────────────────────────────────────────

const emptyEngineLog: EngineLogJson = { buckets: [], other: [] };

function fieldJson(f: Field): FieldJson {
  const out: {
    value: number | string;
    attribution: Field["attribution"];
    source?: string;
    modelVer?: string;
    confidence?: number;
    asOfSeq?: number;
  } = { value: f.value as number | string, attribution: f.attribution };
  if (f.source !== undefined) out.source = f.source;
  if (f.modelVer !== undefined) out.modelVer = f.modelVer;
  if (f.confidence !== undefined) out.confidence = f.confidence;
  if (f.asOfSeq !== undefined) out.asOfSeq = f.asOfSeq;
  return out;
}

function engineActionsJson(items: readonly EngineAction[]): readonly EngineActionJson[] {
  return items.map((e) => ({ id: e.id, kind: e.kind, asOfSeq: e.asofSeq, reason: maybeStr(e.reason) }));
}

/**
 * The json {@link KernelSink} — {@link walkKernel}'s third adapter. It accumulates a typed
 * {@link KernelJson}: the SAME 8 fixed-order sections, every section ALWAYS assigned (absent-not-hidden),
 * Fields carried WHOLE, nulls as explicit {@link UNKNOWN}. Refuses a dishonest claim (fail-closed),
 * exactly as the canonical sink does. PURE — no wall clock, no RNG.
 */
class JsonKernelSink implements KernelSink {
  private frame: "OPEN" | "WAKE" = "OPEN";
  private mandateJson: MandateJson | null = null;
  private briefJson: BriefJson | null = null;
  private wakeJson: Maybe<WakeJson> = UNKNOWN;
  private dataHealthJson: readonly VehicleHealthJson[] = [];
  private unavailableJson: readonly string[] = [];
  private positionsJson: readonly PositionJson[] = [];
  private restingJson: readonly RestingJson[] = [];
  private budgetJson: Maybe<BudgetJson> = UNKNOWN;
  private sizingJson: Maybe<SizingJson> = UNKNOWN;
  private ownerEnvelopeJson: Maybe<number> = UNKNOWN;
  private ownerActsJson: readonly OwnerActJson[] = [];
  private engineLogJson: EngineLogJson = emptyEngineLog;
  private claimsJson: readonly ClaimJson[] = [];

  sentinel(frameKind: "OPEN" | "WAKE"): void {
    this.frame = frameKind;
  }

  frameLine(): void {
    // The frame kind rides {@link sentinel}; json folds it into the `frame` key (no separate line).
  }

  mandate(m: Mandate): void {
    this.mandateJson = {
      objective: m.objective,
      rUsd: maybeNum(m.rUsd),
      successCriterion: m.successCriterion,
      riskRule: m.riskRule,
    };
  }

  brief(b: Brief): void {
    // Fail-closed (kestrel-voy9), exactly as the canonical sink does before emitting the hash: a
    // supplied `{ text, hash }` whose hash disagrees with its text would attribute the WRONG thesis.
    assertBriefHashHonest(b);
    const out: BriefJson = {
      text: b.text,
      hash: b.hash,
      channel: "directional; NOT a constraint, NOT an admission input",
      ...(b.version !== undefined ? { version: b.version } : {}),
    };
    this.briefJson = out;
  }

  // §1 WAKE — reason / severity / RELATIVE deadline (never a wall-clock/date).
  wake(w: WakeRef | null | undefined): void {
    this.wakeJson =
      w === undefined || w === null
        ? UNKNOWN
        : { reason: w.reason, severity: w.severity, deadlineMinToClose: maybeNum(w.deadline) };
  }

  // §2 DATA-HEALTH per vehicle + the NAMED unavailable capabilities (empty ⇒ [], never omitted).
  dataHealth(rows: readonly VehicleHealth[], unavailable: readonly string[]): void {
    this.dataHealthJson = rows.map((h) => ({
      instrument: h.instrument,
      bidPresentRate: maybeNum(h.bidPresentRate),
      twoSided: h.twoSided === true,
      staleS: maybeNum(h.staleS),
      dark: h.dark === true,
    }));
    this.unavailableJson = [...unavailable];
  }

  // §3 POSITIONS / INVENTORY-CLAIMS — an equity/spot leg carries neither strike nor right (ADR-0017).
  positions(positions: readonly Position[]): void {
    this.positionsJson = positions.map((p) => ({
      instrument: p.instrument,
      strike: maybeNum(p.strike),
      right: p.right === undefined || p.right === null ? UNKNOWN : p.right,
      qty: p.qty,
      basis: maybeNum(p.basis),
      unrealUsd: maybeNum(p.unrealUsd),
      structure: maybeStr(p.structure),
      claimOwner: maybeStr(p.claimOwner),
      plan: maybeStr(p.plan),
    }));
  }

  // §4 RESTING ORDERS — live AND clamped as independent flags (a live flag never masks a clamp).
  resting(resting: readonly RestingOrder[]): void {
    this.restingJson = resting.map((o) => ({
      ref: o.ref,
      side: o.side,
      instrument: o.instrument,
      strike: maybeNum(o.strike),
      right: o.right === undefined || o.right === null ? UNKNOWN : o.right,
      qty: o.qty,
      px: maybeNum(o.px),
      live: o.live === true,
      clamped: o.clamped === true,
      note: maybeStr(o.note),
    }));
  }

  // §5 BUDGET / REMAINING-R + the sizing headroom.
  budget(b: BudgetEnvelopeArg): void {
    if (b === undefined || b === null) {
      this.budgetJson = UNKNOWN;
      this.sizingJson = UNKNOWN;
      return;
    }
    this.budgetJson = {
      remainingR: maybeNum(b.remainingR),
      planEnvelope: maybeNum(b.planEnvelope),
      bookEnvelope: maybeNum(b.bookEnvelope),
      ownerEnvelope: maybeNum(b.ownerEnvelope),
    };
    this.sizingJson = sizingJson(b.sizing);
  }

  // §6 OWNER ENVELOPE + ACTS — the envelope echo reads the same §5 budget.
  ownerActs(b: BudgetEnvelopeArg, acts: readonly OwnerAct[]): void {
    this.ownerEnvelopeJson = b !== undefined && b !== null ? maybeNum(b.ownerEnvelope) : UNKNOWN;
    this.ownerActsJson = acts.map((a) => ({ id: a.id, kind: a.kind, asOfSeq: a.asofSeq }));
  }

  // §7 L0/L1 ENGINE LOG — bucketed fired | cancelled | rejected | clamped (+ catch-all `other`).
  engineLog(elog: readonly EngineAction[]): void {
    const { buckets, other } = bucketEngineLog(elog);
    this.engineLogJson = {
      buckets: buckets.map((b) => ({ kind: b.kind, count: b.items.length, actions: engineActionsJson(b.items) })),
      other: engineActionsJson(other),
    };
  }

  // §8 PREDICTOR / REGIME CLAIMS — each an HONEST MODEL Field carried WHOLE (fail-closed on a dishonest one).
  claims(claims: readonly Claim[]): void {
    this.claimsJson = claims.map((c) => {
      assertClaimHonest(c); // fail-closed: a dishonest claim is REFUSED, never serialized as if honest.
      return { claimType: c.claimType, field: fieldJson(c.field) };
    });
  }

  result(): KernelJson {
    return {
      frame: this.frame,
      mandate: this.mandateJson,
      brief: this.briefJson,
      wake: this.wakeJson,
      dataHealth: this.dataHealthJson,
      unavailable: this.unavailableJson,
      positions: this.positionsJson,
      resting: this.restingJson,
      budget: this.budgetJson,
      sizing: this.sizingJson,
      ownerEnvelope: this.ownerEnvelopeJson,
      ownerActs: this.ownerActsJson,
      engineLog: this.engineLogJson,
      claims: this.claimsJson,
    };
  }
}

function sizingJson(s: SizingHeadroom | null | undefined): Maybe<SizingJson> {
  if (s === undefined || s === null) return UNKNOWN;
  const out: SizingJson = {
    instrument: s.instrument,
    unit: s.unit,
    basisPerUnit: maybeNum(s.basisPerUnit),
    maxUnits: maybeNum(s.maxUnits),
    remainingUsd: maybeNum(s.remainingUsd),
    ...(s.note !== undefined ? { note: s.note } : {}),
  };
  return out;
}

/**
 * Materialize the cockpit kernel as typed {@link KernelJson} — the ONE walk ({@link walkKernel})
 * driving the {@link JsonKernelSink}. This is the json Rendering at the walkKernel seam. PURE +
 * deterministic; refuses a dishonest claim (fail-closed, {@link assertClaimHonest}).
 */
export function renderKernelJson(kernel: Kernel, frameKind: "OPEN" | "WAKE"): KernelJson {
  const sink = new JsonKernelSink();
  walkKernel(kernel, frameKind, sink);
  return sink.result();
}

// ─────────────────────────────────────────────────────────────────────────────
// The market/instrument body — raw input Fields carried WHOLE (the honest-chart substrate, ADR-0052 §3)
// ─────────────────────────────────────────────────────────────────────────────

export interface InstrumentJson {
  readonly symbol: string;
  readonly assetClass: InstrumentSpec["assetClass"];
  readonly role: Maybe<string>;
  readonly multiplier: Maybe<number>;
  readonly tick: Maybe<number>;
}

export interface LevelsJson {
  readonly spot: Maybe<number>;
  readonly priorClose: Maybe<number>;
  readonly hod: Maybe<number>;
  readonly lod: Maybe<number>;
  readonly vwap: Maybe<number>;
  readonly orHigh: Maybe<number>;
  readonly orLow: Maybe<number>;
}

/** One tape bucket — OHLC carried WHOLE with its HH:MM ET clock (date-blind). The honest chart draws
 * candles from THESE raw numbers; an UNKNOWN never becomes an interpolated candle (ADR-0052 §3). */
export interface TapeRowJson {
  readonly clock: string;
  readonly open: Maybe<number>;
  readonly high: Maybe<number>;
  readonly low: Maybe<number>;
  readonly close: Maybe<number>;
  readonly volume: Maybe<number>;
}

export interface ChainRowJson {
  readonly strike: Maybe<number>;
  readonly right: ChainRow["right"];
  /** A `null` bid/ask side is DARK — carried as explicit UNKNOWN, never a fabricated price (ADR §4). */
  readonly bid: Maybe<number>;
  readonly ask: Maybe<number>;
  readonly fair: Maybe<number>;
  readonly fairNote: Maybe<string>;
}

export interface MarketJson {
  readonly instrument: string;
  readonly levels: LevelsJson;
  readonly tapeBucketMin: Maybe<number>;
  readonly tape: readonly TapeRowJson[];
  readonly chainDte: Maybe<number>;
  readonly chain: readonly ChainRowJson[];
}

function instrumentsJson(instruments: readonly InstrumentSpec[]): readonly InstrumentJson[] {
  return instruments.map((i) => ({
    symbol: i.symbol,
    assetClass: i.assetClass,
    role: maybeStr(i.role),
    multiplier: maybeNum(i.multiplier),
    tick: maybeNum(i.tick),
  }));
}

function levelsJson(lv: LevelSet): LevelsJson {
  return {
    spot: maybeNum(lv.spot),
    priorClose: maybeNum(lv.priorClose),
    hod: maybeNum(lv.hod),
    lod: maybeNum(lv.lod),
    vwap: maybeNum(lv.vwap),
    orHigh: maybeNum(lv.orHigh),
    orLow: maybeNum(lv.orLow),
  };
}

function tapeJson(rows: readonly TapeRow[]): readonly TapeRowJson[] {
  return rows.map((r) => ({
    clock: r.clock,
    open: maybeNum(r.open),
    high: maybeNum(r.high),
    low: maybeNum(r.low),
    close: maybeNum(r.close),
    volume: maybeNum(r.volume),
  }));
}

function chainJson(rows: readonly ChainRow[]): readonly ChainRowJson[] {
  return rows.map((r) => ({
    strike: maybeNum(r.strike),
    right: r.right,
    bid: maybeNum(r.bid),
    ask: maybeNum(r.ask),
    fair: maybeNum(r.fair),
    fairNote: maybeStr(r.fairNote),
  }));
}

function marketJson(market: BriefingInput["market"]): MarketJson {
  return {
    instrument: market.instrument,
    levels: levelsJson(market.levels),
    tapeBucketMin: maybeNum(market.tapeBucketMin),
    tape: tapeJson(market.tape),
    chainDte: maybeNum(market.chainDte),
    chain: chainJson(market.chain),
  };
}

// ─────────────────────────────────────────────────────────────────────────────
// The full Frame json Rendering — kernel (via the seam) + the raw market body + the header
// ─────────────────────────────────────────────────────────────────────────────

/** The OPEN keyframe as typed JSON. */
export interface BriefingJson {
  readonly kind: "OPEN";
  readonly timeToCloseMin: Maybe<number>;
  readonly phase: Maybe<string>;
  readonly clockET: Maybe<string>;
  readonly kernel: KernelJson;
  readonly instruments: readonly InstrumentJson[];
  readonly market: MarketJson;
}

/** The WAKE delta frame as typed JSON. */
export interface WakeDeltaJson {
  readonly kind: "WAKE";
  readonly wakeIndex: number;
  readonly minutesSinceLast: Maybe<number>;
  readonly timeToCloseMin: Maybe<number>;
  readonly phase: Maybe<string>;
  readonly clockET: Maybe<string>;
  readonly wakeReason: Maybe<string>;
  readonly kernel: KernelJson;
  readonly market: MarketJson;
}

export type FrameJson = BriefingJson | WakeDeltaJson;

/**
 * Render the OPEN keyframe briefing as the typed json Rendering (ADR-0052 §2) — the kernel through
 * the seam ({@link renderKernelJson}) plus the raw market/instrument Fields (the honest-chart
 * substrate). Fields WHOLE, nulls explicit UNKNOWN, no value invented. Refuses a dishonest claim
 * (fail-closed). PURE + deterministic.
 */
export function renderBriefingJson(input: BriefingInput): BriefingJson {
  return {
    kind: "OPEN",
    timeToCloseMin: maybeNum(input.timeToCloseMin),
    phase: maybeStr(input.phase),
    clockET: maybeStr(input.clockET),
    kernel: renderKernelJson(input.kernel, "OPEN"),
    instruments: instrumentsJson(input.instruments),
    market: marketJson(input.market),
  };
}

/**
 * Render a WAKE delta frame as the typed json Rendering (ADR-0052 §2). Structural, so it carries the
 * full since-last kernel + market (json is NOT the KV-cache-optimized text delta path — a consumer
 * diffs structurally). Fields WHOLE, nulls explicit UNKNOWN. Refuses a dishonest claim (fail-closed).
 * PURE + deterministic.
 */
export function renderWakeDeltaJson(input: WakeDeltaInput): WakeDeltaJson {
  return {
    kind: "WAKE",
    wakeIndex: input.wakeIndex,
    minutesSinceLast: maybeNum(input.minutesSinceLast),
    timeToCloseMin: maybeNum(input.timeToCloseMin),
    phase: maybeStr(input.phase),
    clockET: maybeStr(input.clockET),
    wakeReason: maybeStr(input.wakeReason),
    kernel: renderKernelJson(input.kernel, "WAKE"),
    market: marketJson(input.market),
  };
}

/**
 * Serialize a json Rendering to its byte-stable canonical bytes — through the ONE canonical JSON
 * module ({@link ../canonical/json.ts canonicalizeJson}: recursively ordered keys, kept array order,
 * NO trailing newline), the SAME bytes the Bus/Blotter content-address. The same Frame always yields
 * byte-identical text (determinism, RUNTIME §0). Deterministic + pure.
 */
export function serializeFrameJson(frame: FrameJson | KernelJson): string {
  return canonicalizeJson(frame);
}
