/**
 * # fill/model — the FillModel seam: a floor judge and a ceiling judge (RUNTIME §6)
 *
 * One seam sits under every mode of grading: `FillModel.assess(order, leg, ctx) → { fill?,
 * pFill, kind }`. Every plan **and the structural null cross the same fill model**, so
 * strategies are compared on fills, not on fill assumptions (ARCHITECTURE §4). A fill model
 * never returns a bare boolean — it returns a definite floor `fill?` (present only when the
 * conservative strict-cross rule fires) *and* a per-assessment probability `pFill` the engine
 * accumulates for expected-value accounting.
 *
 * Two implementations, deliberately a floor and a ceiling:
 *
 * - {@link StrictCrossV1} — the **floor judge**. A resting order fills only when the tape
 *   quote strictly crosses its price (a BUY fills iff the leg's ask moves *strictly below*
 *   the resting price; symmetric for a SELL vs the bid). Same-price never fills; a dark side
 *   never fills. `pFill ∈ {0, 1}`. Conservative by construction — it under-counts fills, so a
 *   strategy that clears it clears any honest model.
 *
 * - {@link MakerFairV1} — the **ceiling judge**. Strict-cross ∪ a calibrated hazard of being
 *   filled while resting at/near `fair`. The hazard **parameters are injected as a typed data
 *   object** (a constructor arg): calibration is versioned *input*, not code. The shipped
 *   default ({@link MAKER_FAIR_DEFAULT_HAZARD}) is clearly labelled **uncalibrated** — it
 *   exists to make the model runnable, never to assert a fill rate.
 *
 * Both are pure functions of their arguments (no wall clock, no RNG — RUNTIME §0). All time
 * enters as `ctx.dtSinceLast` (milliseconds since this order's previous assessment), injected
 * by the engine from the bus clock.
 */

import type { OptionQuote, Right } from "../bus/index.ts";
import { dexp } from "../fair/detmath.ts";
// The support flag + its banking predicate are OWNED by src/support (kestrel-dpy, ADR-0014): support is
// the single source, the fill seam re-exports so its consumers keep one import surface. FillSupport is
// imported for use in this module's own signatures below and re-exported alongside isCalibratedSupport.
import type { FillSupport } from "../support/index.ts";
export type { FillSupport } from "../support/index.ts";
export { isCalibratedSupport } from "../support/index.ts";
// The directional far-OTM guard — classification, the GuardedIntent verdict, and the far-OTM cap
// decision — is OWNED by fill/guarded-intent (kestrel-vav2, arch-review C1b): ONE interface the fill
// model consumes so the never-naked property is testable through the enforcing seam. This module
// re-exports the classification surface so consumers keep one fill import, and calls `guardedPfill`
// (over `guardedIntentOf`) on both hazard paths — it authors no cap logic of its own.
import { type GuardedIntent, type Moneyness, guardedIntentOf, guardedPfill } from "./guarded-intent.ts";
export {
  classifyMoneyness,
  MONEYNESS_ATM_BAND,
  MONEYNESS_DEEP_OTM_FRAC,
  SELL_FAR_OTM_CAP,
  BUY_FAR_OTM_CROSSING,
} from "./guarded-intent.ts";
export type { Moneyness } from "./guarded-intent.ts";

/** The order fields a fill model reads — a defined-risk, single option leg at a resting
 * price. `px` is the resting limit; the strike/right locate the leg on the book. */
export interface FillOrderView {
  readonly side: "buy" | "sell";
  readonly qty: number;
  /** The resting limit price. A floor fill always fills *at this price* (RUNTIME §6). */
  readonly px: number;
  readonly strike: number;
  readonly right: Right;
  /** Coarse moneyness vs the underlier, for the directional far-OTM guard.
   * Absent ⇒ symmetric legacy path (guard cannot fire). */
  readonly moneyness?: Moneyness;
  /** `true` when this leg is the **covering wing of a multi-leg defined-risk package** (a
   * vertical / condor / fly that fills as a combo, anchored by its near leg). A covered far-OTM
   * short is NOT the standalone-offer fantasy, so the sell-far-OTM cap does not apply to it
   * (penalizing it would kill every defined-risk spread). Default (absent /
   * `false`) ⇒ standalone. */
  readonly covered?: boolean;
}

/** Everything a model needs beyond the order and the book leg: the engine's ExecutionFair
 * for this leg (absent when unbuildable — the model then falls back to the floor only) and
 * the time since this order's previous assessment (ms, injected — RUNTIME §0). */
export interface FillAssessCtx {
  /** ExecutionFair for the leg (`@fair`), when the engine could build it. Absent ⇒ dark. */
  readonly fair?: number;
  /** Milliseconds since this order was last assessed. `0` on the first look after placement
   * or on a same-timestamp update; negative inputs are treated as `0` (causal, never
   * rewound). */
  readonly dtSinceLast: number;
}

/** A single assessment. `fill` is the **definite floor outcome** — present only when the
 * conservative strict-cross rule fires (then `pFill` is `1`). When `fill` is absent, `pFill`
 * is the **per-assessment** probability the engine folds into a survival product for E[$]
 * accounting. `kind` names why (`"cross"`, `"hazard"`, `"no-cross"`, `"no-fair"`).
 *
 * `episodeKey` opts a model into **per-episode** (not per-look) accrual: when present the engine
 * folds `pFill` into the survival product **only when the key changes** from the order's last
 * accrued episode (a fresh resting stint), and contributes nothing on a same-key re-look — a
 * Bernoulli per resting EPISODE rather than a per-second hazard. Absent ⇒ the legacy per-look
 * (time-integrated) accrual. */
export interface FillAssessment {
  readonly fill?: { readonly qty: number; readonly px: number };
  readonly pFill: number;
  readonly kind: string;
  readonly episodeKey?: string;
  /** The per-assessment **support flag** ({@link FillSupport}): is this cell
   * covered by realized live data (`"calibrated"`) or priced off the offline ceiling with no live
   * anchor (`"extrapolated"`)? A structural strict-cross outcome is `"calibrated"` (a price-priority
   * fact, not an extrapolation); an uncalibrated hazard cell and the far-OTM-offer / dark-level-lift
   * internalization regions are `"extrapolated"`. A grader **REFUSES to bank expected-$ from an
   * extrapolated cell** ({@link isCalibratedSupport}) so strategies cannot hill-climb into
   * uncalibrated corners of the fill model. Optional/additive: a missing flag is treated
   * conservatively downstream (not bankable). */
  readonly support?: FillSupport;
}

// FillSupport + isCalibratedSupport are re-homed to src/support (the single source, ADR-0014) and
// re-exported at the top of this file — see the import block.

/** How a model stamps its calibration on a grade (RUNTIME §6 / ADR-0006: every grade stamps
 * its judge). A pure floor model has none; a hazard model carries its versioned data. */
export interface FillCalibration {
  readonly version: string;
  readonly calibrated: boolean;
}

/**
 * One thing this fill model (the judge) **structurally could NOT observe** — a stable machine `code`
 * plus an honest human `note` (a57.4). Each is a documented structural fact of the model, not a
 * fabrication, so a downstream consumer cannot OVER-CLAIM realism the judge never had. This is the exact
 * shape the Blotter's fidelity `self_limitation` carries; the model DECLARES it (rather than a downstream
 * name-string lookup guessing it), so a calibrated model can never be silently mislabelled.
 */
export interface FillLimit {
  /** A stable, lexical machine token for the limit (e.g. `no-queue-position`). */
  readonly code: string;
  /** The honest human phrasing of what the judge could not see. */
  readonly note: string;
}

/** Every judge in this build reads only the recorded top-of-book tape, so none sees queue position or
 * intra-tick sequencing — the two limits every model shares. */
const NO_QUEUE_POSITION: FillLimit = {
  code: "no-queue-position",
  note: "recorded top-of-book tape only; the judge cannot observe queue position or fill priority",
};
const BOOK_TAPE_RESOLUTION: FillLimit = {
  code: "book-tape-resolution",
  note: "fills resolve at the recorded book tape's resolution; no intra-tick or intraminute sequencing",
};

/** {@link StrictCrossV1}'s self-limitation: the shared tape limits + it credits a fill ONLY on a strict
 * tape cross (a conservative floor; no passive/maker fills). */
export const STRICT_CROSS_LIMITS: readonly FillLimit[] = [
  NO_QUEUE_POSITION,
  BOOK_TAPE_RESOLUTION,
  { code: "strict-cross-only", note: "credits a fill only on a strict tape cross; no passive/maker fill is ever credited (a conservative floor)" },
];

/** The maker-fair judges' self-limitation: the shared tape limits + a MODELED passive fill near fair (not
 * an observed execution). Shared by {@link MakerFairV1} and the CALIBRATED {@link MakerFairCalV1}: both
 * observe the same recorded top-of-book tape and both model passive fills, so the calibrated judge DERIVES
 * its declaration from here rather than falling through to an "unstated" fail-closed default. */
export const MAKER_FAIR_LIMITS: readonly FillLimit[] = [
  NO_QUEUE_POSITION,
  BOOK_TAPE_RESOLUTION,
  { code: "maker-fill-modeled", note: "passive maker fills are a modeled per-second hazard near fair, not observed executions" },
];

/** The one seam. Implementations are pure; time is injected via {@link FillAssessCtx}. */
export interface FillModel {
  readonly name: string;
  readonly version: string;
  /** The calibration data behind this model, when it has any (stamped on reports). */
  readonly calibration?: FillCalibration;
  /** What this judge structurally could NOT observe (a57.4) — declared by the model itself, in a stable
   * order, so it rides the graded META and reaches the Blotter's fidelity stamp WITHOUT a downstream
   * name-string lookup that could silently mislabel a calibrated model. */
  readonly self_limitation: readonly FillLimit[];
  assess(order: FillOrderView, leg: OptionQuote, ctx: FillAssessCtx): FillAssessment;
}

// ─────────────────────────────────────────────────────────────────────────────
// Shared primitives
// ─────────────────────────────────────────────────────────────────────────────

/**
 * The strict-cross floor rule (RUNTIME §6) — a QUOTE cross **or** a TRADE-through print, the two
 * deterministic, calibration-free ways the recorded tape hands a resting order a definite fill:
 *
 * - **Quote cross.** A resting BUY fills iff the leg's ask is present and **strictly below** the
 *   resting price; a resting SELL iff the bid is present and **strictly above** it. Same-price is
 *   *not* a cross (`<` / `>`, never `≤` / `≥`); a dark side (`null`) never crosses.
 * - **Trade-through** (kestrel-9gu.9). A resting BUY also fills when a trade **prints at or
 *   through** its price (`leg.last ≤ px`); a resting SELL when `leg.last ≥ px`. RUNTIME §6 defines
 *   strict-cross on "trade prints/quotes moving through the level" — `OptionQuote.last` carries
 *   those prints, yet no fill path read it (the 9gu.9 gap). A print AT the resting level is a real
 *   execution the passive order shared, so it is at-or-through (`≤`/`≥`), not the strict `<`/`>` of
 *   the quote rule. Deterministic and immediately bankable (a recorded print, not a modeled hazard).
 *
 * **Same-price doctrine split vs spot (kestrel-0gnb).** The two arms read a same-price *trade
 * print* differently, and its spot sibling {@link ../fill/spot.ts spotStrictCross} reads it the
 * OTHER way: this options arm is *at-or-through* (a fresh print AT the level fills), while the spot
 * arm is strictly-through (`<`/`>`) and REFUSES same-price, because the quote-less catalog tapes
 * leave queue position unknowable there whereas the option tape's fresh `last` is an execution the
 * passive order shared. Both are floor-legal; spot is strictly the more conservative reading. The
 * two share the `strict-cross` judge NAME, so an option-vs-equity comparison under one stamp must
 * key the same-price rule on the arm, not the name. (Reconciling the two would be a semantics
 * change needing its own review.)
 *
 *   **Freshness is the ENGINE's contract, not this function's.** The tape converter carries `last`
 *   forward session-cumulatively (99%+ of last-bearing leg-events on the recorded tapes are stale
 *   carries), and a pure per-look rule cannot tell a fresh execution from a morning print an
 *   afternoon order never interacted with. {@link ../fill/engine.ts SimFillEngine} owns the
 *   cross-event print memory and hands `assess` a leg whose `last` is PRESENT only when the print
 *   CHANGED on this event (fresh; a first sighting is inert). This function therefore treats a
 *   present `last` as fresh — a caller that bypasses the engine must gate freshness itself.
 *
 * Either condition is a definite fill, always AT the resting price (RUNTIME §6). This is the
 * conservative core all three models share (both call sites strict-cross-first).
 */
export function strictCross(order: FillOrderView, leg: OptionQuote): boolean {
  if (order.side === "buy") {
    if (leg.ask !== null && leg.ask < order.px) return true;
    return leg.last !== undefined && leg.last <= order.px;
  }
  if (leg.bid !== null && leg.bid > order.px) return true;
  return leg.last !== undefined && leg.last >= order.px;
}

// ─────────────────────────────────────────────────────────────────────────────
// The DIRECTIONAL fill guard — a resting maker p_fill is ASYMMETRIC by SIDE × MONEYNESS in the
// far-OTM wing, never symmetric. The classification, the GuardedIntent verdict, and the cap decision
// all live in fill/guarded-intent (kestrel-vav2, arch-review C1b) — the ONE interface both hazard
// paths below consume via `guardedPfill(pSym, guardedIntentOf(order))`. `classifyMoneyness`, the
// moneyness boundaries, and the far-OTM constants are re-exported (above) from that owner so the fill
// import surface is unchanged.
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Apply the directional far-OTM asymmetry to a **symmetric** maker hazard `pSym` — the LEGACY
 * positional adapter, retained so existing direct callers keep one call shape. It builds a
 * {@link GuardedIntent} and delegates to {@link guardedPfill} (fill/guarded-intent), which is the
 * single owner of the cap decision; this function adds NO logic of its own. New code on the fill path
 * consumes `guardedPfill(pSym, guardedIntentOf(order))` directly.
 */
export function directionalPfill(
  pSym: number,
  opts: {
    readonly isBuy: boolean;
    readonly moneyness?: Moneyness | undefined;
    readonly covered?: boolean | undefined;
  },
): number {
  const guarded: GuardedIntent = {
    side: opts.isBuy ? "buy" : "sell",
    ...(opts.moneyness !== undefined ? { moneyness: opts.moneyness } : {}),
    ...(opts.covered !== undefined ? { covered: opts.covered } : {}),
    unresolved: false,
  };
  return guardedPfill(pSym, guarded);
}

// ─────────────────────────────────────────────────────────────────────────────
// strict-cross-v1 — the floor judge
// ─────────────────────────────────────────────────────────────────────────────

/** The floor judge: fills only on a strict tape cross, at the resting price, with
 * `pFill ∈ {0, 1}`. It ignores `fair` and time entirely — the most conservative honest
 * model. */
export class StrictCrossV1 implements FillModel {
  readonly name = "strict-cross";
  readonly version = "v1";
  readonly self_limitation = STRICT_CROSS_LIMITS;

  assess(order: FillOrderView, leg: OptionQuote, _ctx: FillAssessCtx): FillAssessment {
    // Strict-cross outcomes are STRUCTURAL price-priority facts (a definite cross, or a definite
    // no-cross under the conservative rule) — not extrapolations. Both stamp `calibrated` support
    // (`support` is the fill-model analogue of data-quality; a structural cross is
    // as real as a fill gets). This model never hazard-prices, so it has no extrapolated cell.
    if (strictCross(order, leg)) {
      return { fill: { qty: order.qty, px: order.px }, pFill: 1, kind: "cross", support: "calibrated" };
    }
    return { pFill: 0, kind: "no-cross", support: "calibrated" };
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// maker-fair-v1 — the ceiling judge (hazard parameters are injected DATA)
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Hazard parameters for {@link MakerFairV1} — **versioned calibration data, injected as a
 * constructor argument, never hard-coded logic.** A resting maker order near `fair` gets
 * filled by passing flow at a per-second hazard rate that decays as the order rests further
 * from fair. Calibrating this against realized live fills produces a new record with a new
 * `version`; EVs across versions do not naively compare (RUNTIME §6).
 */
export interface MakerFairHazardParams {
  /** Calibration identity, stamped on every grade. */
  readonly version: string;
  /** `false` for any set not fit to realized fills — the shipped default is `false`. */
  readonly calibrated: boolean;
  /** Fill hazard **per second** at zero edge (resting exactly at fair). */
  readonly baseHazardPerSec: number;
  /** The relative-edge e-folding scale: at `edgeRel = edgeScale` the hazard rate is `1/e` of
   * its at-fair value. `edgeRel = |px − fair| / max(|fair|, fairFloor)`. */
  readonly edgeScale: number;
  /** Floor for the fair denominator, so a near-zero fair does not explode the relative
   * edge. */
  readonly fairFloor: number;
}

/**
 * The shipped default hazard set — **UNCALIBRATED**. It exists only so `maker-fair-v1` is
 * runnable out of the box; it asserts no real fill rate. A live-fit calibration file replaces
 * it as versioned input (RUNTIME §6). `calibrated: false` is what a report stamps.
 */
export const MAKER_FAIR_DEFAULT_HAZARD: MakerFairHazardParams = {
  version: "uncalibrated-0",
  calibrated: false,
  baseHazardPerSec: 0.05,
  edgeScale: 0.5,
  fairFloor: 0.05,
};

/**
 * The per-assessment fill hazard: `1 − exp(−λ(edge)·dt)`, with `λ(edge) = baseHazardPerSec ·
 * exp(−edgeRel / edgeScale)`. Monotone **increasing** in `dtMs` (longer at rest ⇒ more likely
 * to have been hit) and **decreasing** in `edgeRel` (further from fair ⇒ less flow interacts).
 * A non-positive `dtMs` yields `0` (no time elapsed, no hazard). Pure. The exponentials are the
 * vendored {@link dexp}, not `Math.exp`, so the hazard is bit-identical across V8/JSC on the
 * certified-grade path (see {@link ../fair/detmath.ts}).
 */
export function makerFairHazard(params: MakerFairHazardParams, edgeRel: number, dtMs: number): number {
  if (dtMs <= 0) return 0;
  const decay = dexp(-Math.max(edgeRel, 0) / params.edgeScale);
  const lambda = params.baseHazardPerSec * decay;
  const dtSec = dtMs / 1000;
  return 1 - dexp(-lambda * dtSec);
}

/**
 * The ceiling judge: strict-cross ∪ a hazard of being filled while resting at/near `fair`.
 * On a strict cross it returns a definite floor fill (`pFill = 1`). Otherwise, if `fair` is
 * available, it returns the per-assessment hazard as `pFill` (no `fill`); with no `fair` it
 * degrades to the floor only (`pFill = 0`) — conservative, fail-closed.
 */
export class MakerFairV1 implements FillModel {
  readonly name = "maker-fair";
  readonly version = "v1";
  readonly self_limitation = MAKER_FAIR_LIMITS;
  readonly params: MakerFairHazardParams;

  constructor(params: MakerFairHazardParams = MAKER_FAIR_DEFAULT_HAZARD) {
    this.params = params;
  }

  get calibration(): FillCalibration {
    return { version: this.params.version, calibrated: this.params.calibrated };
  }

  assess(order: FillOrderView, leg: OptionQuote, ctx: FillAssessCtx): FillAssessment {
    // A structural strict-cross is calibrated (a price-priority fact) even on this uncalibrated model.
    if (strictCross(order, leg)) {
      return { fill: { qty: order.qty, px: order.px }, pFill: 1, kind: "cross", support: "calibrated" };
    }
    if (ctx.fair === undefined) {
      // No fair ⇒ degrade to the floor only; there is no calibrated basis to assert, so the cell is
      // extrapolated (and pFill is 0 anyway ⇒ nothing to bank). Fail-closed.
      return { pFill: 0, kind: "no-fair", support: "extrapolated" };
    }
    const denom = Math.max(Math.abs(ctx.fair), this.params.fairFloor);
    const edgeRel = Math.abs(order.px - ctx.fair) / denom;
    const hSym = makerFairHazard(this.params, edgeRel, ctx.dtSinceLast);
    // The maker hazard is DIRECTIONAL in the far-OTM wing (buy = real crossing-print,
    // sell = fantasy → floored to SELL_FAR_OTM_CAP; a covered wing is exempt). Near-the-money /
    // unclassified ⇒ unchanged. The guard is a POST-CALIBRATION structural multiplier on the OUTPUT
    // p_fill (directionalPfill applied to the already-calibrated symmetric hazard pSym) — it edits no
    // fitted DATA, so it also belongs on the calibrated MakerFairCalV1
    // path (9gu.1/9gu.2).
    const h = guardedPfill(hSym, guardedIntentOf(order));
    // This shipped default is uncalibrated (calibrated:false) ⇒ its hazard cells are extrapolated;
    // an injected live-fit param set (calibrated:true) marks them calibrated.
    const support: FillSupport = this.params.calibrated ? "calibrated" : "extrapolated";
    return { pFill: h, kind: "hazard", support };
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// maker-fair-v1 + episode-sigmoid-u — the CALIBRATED ceiling judge (params = versioned DATA)
// ─────────────────────────────────────────────────────────────────────────────

/** The per-side constants of the {@link EpisodeSigmoidParams} form. `uStar` is `null` on a
 * saturated side (the sigmoid degenerates to the constant `rho`). */
export interface EpisodeSigmoidSide {
  readonly alpha: number;
  readonly uStar: number | null;
  readonly rho: number;
  readonly floor: number;
  readonly saturated: boolean;
  readonly internalizationFloor: number;
}

/**
 * The `episode-sigmoid-u-v1` fill-hazard form — **versioned calibration DATA** loaded from a
 * private JSON, never hard-coded numbers. A resting order's per-episode fill probability is a
 * sigmoid in the **side-signed aggression axis** `u = a / halfSpread`, where `a = px − fair`
 * for a BUY and `a = fair − px` for a SELL (u<0 passive, 0 at-fair, >0 crossed) and `halfSpread`
 * is the leg's current `(ask − bid)/2`:
 *
 *   `p(u) = floor + (rho − floor)·sigmoid(alpha·(u − u_star))`
 *
 * A **saturated** side (alpha≈0 ⇒ SELL) degenerates to the constant `p = rho`. A **dark / one-
 * sided** book (no half-spread, so `u` is undefined) is a LEVEL lift: `p = internalizationFloor`
 * (a lone-bid/PFOF hazard), never a `u_star` shift. It is a per-RESTING-EPISODE Bernoulli, not a
 * per-second hazard (measured time-to-fill is near-instant — median 0s — so time-at-rest adds no
 * accrual); the engine folds one `p` per episode via {@link FillAssessment.episodeKey}. */
export interface EpisodeSigmoidParams {
  readonly version: string;
  readonly buy: EpisodeSigmoidSide;
  readonly sell: EpisodeSigmoidSide;
}

/** Logistic sigmoid on the vendored {@link dexp} (not `Math.exp`) so the calibrated hazard is
 * bit-identical across V8/JSC on the certified-grade path (see {@link ../fair/detmath.ts}). */
function sigmoid(x: number): number {
  return 1 / (1 + dexp(-x));
}

const clamp01 = (p: number): number => (p < 0 ? 0 : p > 1 ? 1 : p);

/**
 * Validate and load {@link EpisodeSigmoidParams} from a parsed calibration JSON (the private
 * `hazard_constants.{BUY,SELL}` schema). Fails **loud** on a missing/malformed field — a
 * calibration you cannot read is never silently defaulted (RUNTIME §8). The private numbers stay
 * in the (gitignored) data file; only the shape is asserted here.
 */
export function loadEpisodeSigmoidParams(raw: unknown): EpisodeSigmoidParams {
  const o = raw as { version?: unknown; hazard_constants?: Record<string, unknown> };
  const hc = o?.hazard_constants;
  if (typeof o?.version !== "string" || hc === undefined || hc === null) {
    throw new Error("fill: malformed hazard-params (need string `version` + `hazard_constants`)");
  }
  const num = (v: unknown, where: string): number => {
    if (typeof v !== "number" || !Number.isFinite(v)) throw new Error(`fill: hazard-params ${where} must be a finite number`);
    return v;
  };
  const side = (s: unknown, name: string): EpisodeSigmoidSide => {
    const o2 = s as Record<string, unknown> | undefined;
    if (o2 === undefined) throw new Error(`fill: hazard-params missing side ${name}`);
    const saturated = o2.saturated === true;
    const uStarRaw = o2.u_star;
    return {
      alpha: num(o2.alpha, `${name}.alpha`),
      uStar: uStarRaw === null || uStarRaw === undefined ? null : num(uStarRaw, `${name}.u_star`),
      rho: num(o2.rho, `${name}.rho`),
      floor: num(o2.floor, `${name}.floor`),
      saturated,
      internalizationFloor: num(o2.internalization_floor, `${name}.internalization_floor`),
    };
  };
  return { version: o.version, buy: side(hc.BUY, "BUY"), sell: side(hc.SELL, "SELL") };
}

/**
 * The calibrated ceiling judge (`episode-sigmoid-u-v1`). Strict-cross ∪ the per-episode sigmoid
 * hazard above. It returns an {@link FillAssessment.episodeKey} (`"lit"` / `"dark"`) so the
 * engine accrues **one Bernoulli per resting episode** (placement, each reprice = a new order/
 * episode, and each dark↔lit transition = a new episode) rather than integrating over time.
 */
export class MakerFairCalV1 implements FillModel {
  readonly name: string;
  readonly version = "cal";
  // DERIVED from MakerFairV1: this calibrated judge observes the SAME recorded top-of-book tape and models
  // passive fills the same way (only the hazard NUMBERS are fit), so it shares maker-fair's self-limitation.
  // Declaring it here is what fixes the mislabel — its `maker-fair-v1+<version>` name matched no lookup key
  // and was silently downgraded to "unstated"/maximally-limited (kestrel-o32).
  readonly self_limitation = MAKER_FAIR_LIMITS;
  readonly params: EpisodeSigmoidParams;

  constructor(params: EpisodeSigmoidParams) {
    this.params = params;
    this.name = `maker-fair-v1+${params.version}`;
  }

  get calibration(): FillCalibration {
    return { version: this.params.version, calibrated: true };
  }

  assess(order: FillOrderView, leg: OptionQuote, ctx: FillAssessCtx): FillAssessment {
    // A structural strict-cross is calibrated (a price-priority fact).
    if (strictCross(order, leg)) {
      return { fill: { qty: order.qty, px: order.px }, pFill: 1, kind: "cross", support: "calibrated" };
    }
    const c = order.side === "buy" ? this.params.buy : this.params.sell;
    // u needs a two-sided book (a half-spread) AND a fair; otherwise it is the dark LEVEL branch.
    const twoSided =
      leg.bid !== null && leg.ask !== null && leg.ask - leg.bid > 0 && ctx.fair !== undefined;
    if (!twoSided) {
      // dark / one-sided / no-fair ⇒ level lift to the internalization floor (lone-bid/PFOF). This is
      // OUTSIDE the fitted u-sigmoid support — a dark internalization region with no live anchor — so
      // it is EXTRAPOLATED (the far-OTM-offer / dark-lift corners a grader refuses to
      // bank — the fantasy region). Fail-closed for banking.
      return { pFill: clamp01(c.internalizationFloor), kind: "cal-dark", episodeKey: "dark", support: "extrapolated" };
    }
    const halfSpread = (leg.ask! - leg.bid!) / 2;
    const a = order.side === "buy" ? order.px - ctx.fair! : ctx.fair! - order.px;
    const u = a / halfSpread;
    const pSym = c.saturated ? c.rho : c.floor + (c.rho - c.floor) * sigmoid(c.alpha * (u - (c.uStar ?? 0)));
    // The directional far-OTM guard is a POST-CALIBRATION
    // structural multiplier applied to the OUTPUT p_fill (directionalPfill over the already-calibrated
    // symmetric hazard pSym), NOT a data edit — the fitted episode-sigmoid JSON is never
    // touched, so this belongs on the CALIBRATED path too. Without it a standalone far-OTM SELL takes
    // this lit branch (the saturated sell `rho` is a fat p) and returns an uncapped, bankable p_fill —
    // the far-OTM-offer FANTASY the guard exists to close (BOUNDED RISK / NEVER NAKED). A covering wing
    // of a defined-risk package is exempt; near-the-money / unclassified legs pass through unchanged.
    const p = guardedPfill(pSym, guardedIntentOf(order));
    // A lit cell within the fitted episode-sigmoid support is CALIBRATED (`support` keys off the
    // per-cell live-anchor table, orthogonal to the directional guard — the guard caps p_fill, the
    // support flag reports data quality; both apply on this path).
    return { pFill: clamp01(p), kind: "cal-sigmoid", episodeKey: "lit", support: "calibrated" };
  }
}
