//! aura-research — the research-artifact document layer.
//!
//! Closed-vocabulary, typed, serialized document types for the methodology
//! and campaign roles (#188): the **process document** (a named
//! validation/eval methodology) and the **campaign document** (persisted
//! experiment intent). This crate owns parsing, intrinsic validation,
//! canonical serialization + content ids, and the introspection contract.
//! It is a leaf: no store, no engine access, no execution semantics.
//!
//! Control-flow power is the ratified P1 tier: bounded axes over declared
//! finite sets and structured gates between stages. Shapes outside P1
//! (variables, recursion, unbounded iteration) are unrepresentable.

use std::collections::BTreeMap;

use aura_core::{Scalar, ScalarKind};
use serde::{Deserialize, Serialize};

/// The one accepted document format version.
pub const FORMAT_VERSION: u32 = 1;

/// Document kind tag carried in every document envelope.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DocKind {
    Process,
    Campaign,
}

/// A process document: a named, versionable validation/eval methodology.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProcessDoc {
    pub format_version: u32,
    pub kind: DocKind,
    pub name: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    pub pipeline: Vec<StageBlock>,
}

/// One pipeline stage. Serialize derives the internally-tagged wire form;
/// Deserialize is hand-rolled (schema-strict) because serde's
/// `deny_unknown_fields` does not compose with `#[serde(tag = ...)]`.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(tag = "block")]
pub enum StageBlock {
    #[serde(rename = "std::sweep")]
    Sweep {
        /// The selection triple is one optional group: `None` is a
        /// selection-free sweep (permitted only as the terminal stage — the
        /// executor's preflight rule), `Some` carries metric+select(+deflate).
        /// Flattened so the wire form stays the flat `metric`/`select`/
        /// `deflate` slots and every existing document's canonical bytes —
        /// and content id — are unchanged.
        #[serde(flatten, skip_serializing_if = "Option::is_none")]
        selection: Option<SweepSelection>,
    },
    #[serde(rename = "std::gate")]
    Gate { all: Vec<Predicate> },
    #[serde(rename = "std::walk_forward")]
    WalkForward {
        in_sample_ms: u64,
        out_of_sample_ms: u64,
        step_ms: u64,
        mode: WfMode,
        metric: String,
        select: SelectRule,
    },
    #[serde(rename = "std::monte_carlo")]
    MonteCarlo { resamples: u32, block_len: u32 },
    #[serde(rename = "std::generalize")]
    Generalize { metric: String },
    /// Enumerate-only leading stage (#256 fork B): yields the axis grid's
    /// parameter points to the next stage; executes and persists nothing.
    /// Fieldless — the axes live in the campaign document.
    #[serde(rename = "std::grid")]
    Grid,
}

fn is_false(b: &bool) -> bool {
    !*b
}

/// The sweep stage's selection group — all-or-nothing (a half-populated
/// group is unrepresentable; the schema-strict parser refuses it).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SweepSelection {
    pub metric: String,
    pub select: SelectRule,
    #[serde(default, skip_serializing_if = "is_false")]
    pub deflate: bool,
}

/// Winner-selection rule; wire strings mirror the CLI `--select` values.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum SelectRule {
    #[serde(rename = "argmax")]
    Argmax,
    #[serde(rename = "plateau:mean")]
    PlateauMean,
    #[serde(rename = "plateau:worst")]
    PlateauWorst,
}

/// Walk-forward roll mode — wire form "rolling" | "anchored" (the two shipped
/// engine `RollMode`s: fixed-length rolling in-sample vs anchored-origin
/// growing in-sample).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub enum WfMode {
    #[serde(rename = "rolling")]
    Rolling,
    #[serde(rename = "anchored")]
    Anchored,
}

/// One typed gate predicate: `<metric> <cmp> <value>`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Predicate {
    pub metric: String,
    pub cmp: Cmp,
    pub value: f64,
}

/// Closed comparator set (no equality on floats).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Cmp {
    Gt,
    Ge,
    Lt,
    Le,
}

// ---------------------------------------------------------------------------
// Block schema tables — the single source for slot strictness (unknown-slot
// rejection in `stage_from_value`) AND the introspection contract (the 0105
// roster lesson). Block *ids* still need declaring at each of the enum
// `#[serde(rename)]`, `PROCESS_BLOCKS.id`, and the `stage_from_value` match
// arms — Rust's static enum representation doesn't let a single runtime
// table drive both the type and the parse dispatch.
// ---------------------------------------------------------------------------

/// Slot value kind, for schema description and CLI presentation.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SlotKind {
    MetricName,
    SelectRule,
    /// Walk-forward roll mode (`WfMode` wire strings).
    WfMode,
    Bool,
    U32,
    U64,
    Predicates,
    Strings,
    Windows,
    Ref,
    /// A content-id-only reference (the process ref: `validate_campaign`
    /// refuses an identity id there, so the describe path must not offer one).
    ContentRef,
    Axes,
    EmitKinds,
    /// The closed tap vocabulary (`tap_vocabulary()` entries).
    TapKinds,
    Regimes,
    Costs,
}

/// One typed slot of a block.
pub struct SlotInfo {
    pub name: &'static str,
    pub kind: SlotKind,
    pub required: bool,
}

/// One block of a document vocabulary: id, one-line doc, typed slots.
pub struct BlockSchema {
    pub id: &'static str,
    pub doc: &'static str,
    pub slots: &'static [SlotInfo],
}

/// The closed v1 process-stage vocabulary (std set; ids are namespaced).
pub const PROCESS_BLOCKS: &[BlockSchema] = &[
    BlockSchema {
        id: "std::sweep",
        doc: "evaluate the campaign's axes-space; reduce members to R metrics; \
              optionally select a winner (the selection group metric+select is \
              all-or-nothing; omitted = selection-free, terminal-stage-only)",
        slots: &[
            SlotInfo { name: "metric", kind: SlotKind::MetricName, required: false },
            SlotInfo { name: "select", kind: SlotKind::SelectRule, required: false },
            SlotInfo { name: "deflate", kind: SlotKind::Bool, required: false },
        ],
    },
    BlockSchema {
        id: "std::gate",
        doc: "filter survivors by a conjunction of typed metric predicates",
        slots: &[SlotInfo { name: "all", kind: SlotKind::Predicates, required: true }],
    },
    BlockSchema {
        id: "std::walk_forward",
        doc: "rolling in-sample optimize + out-of-sample test",
        slots: &[
            SlotInfo { name: "in_sample_ms", kind: SlotKind::U64, required: true },
            SlotInfo { name: "out_of_sample_ms", kind: SlotKind::U64, required: true },
            SlotInfo { name: "step_ms", kind: SlotKind::U64, required: true },
            SlotInfo { name: "mode", kind: SlotKind::WfMode, required: true },
            SlotInfo { name: "metric", kind: SlotKind::MetricName, required: true },
            SlotInfo { name: "select", kind: SlotKind::SelectRule, required: true },
        ],
    },
    BlockSchema {
        id: "std::monte_carlo",
        doc: "R-bootstrap over realised R (terminal annotator): after a \
              walk_forward, ONE bootstrap over its pooled OOS trade series; \
              otherwise one per surviving member; both slots must be > 0 to \
              execute",
        slots: &[
            SlotInfo { name: "resamples", kind: SlotKind::U32, required: true },
            SlotInfo { name: "block_len", kind: SlotKind::U32, required: true },
        ],
    },
    BlockSchema {
        id: "std::generalize",
        doc: "cross-instrument worst-case floor (terminal annotator): runs at \
              campaign scope, per (strategy, window) over the cells' nominees; \
              needs >= 2 instruments and an R-expectancy metric to execute",
        slots: &[SlotInfo { name: "metric", kind: SlotKind::MetricName, required: true }],
    },
    BlockSchema {
        id: "std::grid",
        doc: "enumerate the axis grid as parameter points for the next stage \
              (enumerate-only leading stage): executes and persists nothing; \
              legal only as the first stage, immediately before \
              std::walk_forward",
        slots: &[],
    },
];

// ---------------------------------------------------------------------------
// Strict stage parsing (schema-driven).
// ---------------------------------------------------------------------------

fn require_str(
    m: &serde_json::Map<String, serde_json::Value>,
    key: &str,
    block: &str,
) -> Result<String, String> {
    match m.get(key) {
        Some(serde_json::Value::String(s)) => Ok(s.clone()),
        Some(_) => Err(format!("block {block}: slot \"{key}\" must be a string")),
        None => Err(format!("block {block}: missing required slot \"{key}\"")),
    }
}

fn require_u64(
    m: &serde_json::Map<String, serde_json::Value>,
    key: &str,
    block: &str,
) -> Result<u64, String> {
    match m.get(key) {
        Some(v) => v
            .as_u64()
            .ok_or_else(|| format!("block {block}: slot \"{key}\" must be a non-negative integer")),
        None => Err(format!("block {block}: missing required slot \"{key}\"")),
    }
}

fn require_u32(
    m: &serde_json::Map<String, serde_json::Value>,
    key: &str,
    block: &str,
) -> Result<u32, String> {
    let v = require_u64(m, key, block)?;
    u32::try_from(v).map_err(|_| format!("block {block}: slot \"{key}\" out of range"))
}

fn optional_bool(
    m: &serde_json::Map<String, serde_json::Value>,
    key: &str,
    block: &str,
) -> Result<bool, String> {
    match m.get(key) {
        None => Ok(false),
        Some(serde_json::Value::Bool(b)) => Ok(*b),
        Some(_) => Err(format!("block {block}: slot \"{key}\" must be a bool")),
    }
}

fn select_from(
    m: &serde_json::Map<String, serde_json::Value>,
    block: &str,
) -> Result<SelectRule, String> {
    let s = require_str(m, "select", block)?;
    // SelectRule's own derived Deserialize (which carries the #[serde(rename)]
    // wire strings) is the type oracle, mirroring kind_tag/scalar_from_bare:
    // no second hardcoded copy of the three select-rule wire strings.
    serde_json::from_value(serde_json::Value::String(s.clone()))
        .map_err(|_| format!("block {block}: unknown select rule \"{s}\""))
}

fn mode_from(
    m: &serde_json::Map<String, serde_json::Value>,
    block: &str,
) -> Result<WfMode, String> {
    let s = require_str(m, "mode", block)?;
    // WfMode's own derived Deserialize (which carries the #[serde(rename)]
    // wire strings) is the type oracle, mirroring select_from: no second
    // hardcoded copy of the two roll-mode wire strings.
    serde_json::from_value(serde_json::Value::String(s.clone()))
        .map_err(|_| format!("block {block}: unknown mode \"{s}\""))
}

fn stage_from_value(v: &serde_json::Value) -> Result<StageBlock, String> {
    let m = v
        .as_object()
        .ok_or_else(|| "a pipeline stage must be an object".to_string())?;
    let id = match m.get("block") {
        Some(serde_json::Value::String(s)) => s.clone(),
        _ => return Err("a pipeline stage must carry a string \"block\" id".to_string()),
    };
    let schema = PROCESS_BLOCKS
        .iter()
        .find(|b| b.id == id)
        .ok_or_else(|| format!("unknown block \"{id}\""))?;
    for key in m.keys() {
        if key != "block" && !schema.slots.iter().any(|s| s.name == key) {
            return Err(format!("block {id}: unknown slot \"{key}\""));
        }
    }
    match id.as_str() {
        "std::sweep" => {
            let has_metric = m.contains_key("metric");
            let has_select = m.contains_key("select");
            let selection = match (has_metric, has_select) {
                (true, true) => Some(SweepSelection {
                    metric: require_str(m, "metric", &id)?,
                    select: select_from(m, &id)?,
                    deflate: optional_bool(m, "deflate", &id)?,
                }),
                (false, false) => {
                    if m.contains_key("deflate") {
                        return Err(format!(
                            "block {id}: slot \"deflate\" needs the selection \
                             group (\"metric\" + \"select\")"
                        ));
                    }
                    None
                }
                (true, false) => {
                    return Err(format!(
                        "block {id}: slot \"metric\" without \"select\" — the \
                         selection group is all-or-nothing"
                    ));
                }
                (false, true) => {
                    return Err(format!(
                        "block {id}: slot \"select\" without \"metric\" — the \
                         selection group is all-or-nothing"
                    ));
                }
            };
            Ok(StageBlock::Sweep { selection })
        }
        "std::gate" => {
            let all = m
                .get("all")
                .ok_or_else(|| format!("block {id}: missing required slot \"all\""))?;
            let all: Vec<Predicate> = serde_json::from_value(all.clone())
                .map_err(|e| format!("block {id}: bad predicate list: {e}"))?;
            Ok(StageBlock::Gate { all })
        }
        "std::walk_forward" => Ok(StageBlock::WalkForward {
            in_sample_ms: require_u64(m, "in_sample_ms", &id)?,
            out_of_sample_ms: require_u64(m, "out_of_sample_ms", &id)?,
            step_ms: require_u64(m, "step_ms", &id)?,
            mode: mode_from(m, &id)?,
            metric: require_str(m, "metric", &id)?,
            select: select_from(m, &id)?,
        }),
        "std::monte_carlo" => Ok(StageBlock::MonteCarlo {
            resamples: require_u32(m, "resamples", &id)?,
            block_len: require_u32(m, "block_len", &id)?,
        }),
        "std::generalize" => Ok(StageBlock::Generalize {
            metric: require_str(m, "metric", &id)?,
        }),
        "std::grid" => Ok(StageBlock::Grid),
        _ => unreachable!("schema membership checked above"),
    }
}

impl<'de> Deserialize<'de> for StageBlock {
    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
        let v = serde_json::Value::deserialize(d)?;
        stage_from_value(&v).map_err(serde::de::Error::custom)
    }
}

// ---------------------------------------------------------------------------
// Parse (envelope-checked).
// ---------------------------------------------------------------------------

/// Parse-level refusals. By-identifier and Display-free: the CLI phrases them.
#[derive(Clone, Debug, PartialEq)]
pub enum DocError {
    NotJson(String),
    BadFormatVersion(u32),
    WrongKind { expected: DocKind },
    Malformed(String),
}

fn check_envelope(v: &serde_json::Value, expected: DocKind) -> Result<(), DocError> {
    let fv = v.get("format_version").and_then(|x| x.as_u64());
    if fv != Some(u64::from(FORMAT_VERSION)) {
        return Err(DocError::BadFormatVersion(fv.unwrap_or(0) as u32));
    }
    let want = match expected {
        DocKind::Process => "process",
        DocKind::Campaign => "campaign",
    };
    match v.get("kind").and_then(|k| k.as_str()) {
        Some(k) if k == want => Ok(()),
        _ => Err(DocError::WrongKind { expected }),
    }
}

/// Parse a process document from text (envelope + strict vocabulary).
pub fn parse_process(text: &str) -> Result<ProcessDoc, DocError> {
    let v: serde_json::Value =
        serde_json::from_str(text).map_err(|e| DocError::NotJson(e.to_string()))?;
    check_envelope(&v, DocKind::Process)?;
    serde_json::from_value(v).map_err(|e| DocError::Malformed(e.to_string()))
}

/// A campaign document: persisted experiment intent.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CampaignDoc {
    pub format_version: u32,
    pub kind: DocKind,
    pub name: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    pub data: DataSection,
    /// Structural risk-execution axis: the finite list of stop regimes the
    /// matrix runs each cell under. Absent or empty = one implicit default
    /// regime (the runtime's baked constants). Absent-serializes → content-id
    /// parity for every risk-less document (the `description` precedent).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub risk: Vec<RiskRegime>,
    /// The campaign's cost model (#234): additive components (C10) every
    /// member runs under. Absent or empty = zero costs (`summarize_r`'s
    /// empty-slice baseline: net == gross). Absent-serializes → content-id
    /// parity for every cost-less document (the `risk` precedent above).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub cost: Vec<CostSpec>,
    pub strategies: Vec<StrategyEntry>,
    pub process: ProcessRef,
    pub seed: u64,
    pub presentation: Presentation,
}

/// Structural data axes: which instruments over which windows, plus the
/// optional per-campaign input-binding overrides (role name -> column name,
/// #231 — the campaign rebind seam: re-aim a strategy's input roles without
/// touching its blueprint's content identity). Additive on the
/// `description`/`risk` precedent: absent-serializes, so binding-less
/// documents keep their content ids.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DataSection {
    pub instruments: Vec<String>,
    pub windows: Vec<Window>,
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub bindings: BTreeMap<String, String>,
}

/// One data window, inclusive epoch-ms bounds (the CLI --from/--to form).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Window {
    pub from_ms: i64,
    pub to_ms: i64,
}

/// A strategy under test: an opaque id handle + its tuning axes.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StrategyEntry {
    #[serde(rename = "ref")]
    pub r#ref: DocRef,
    pub axes: BTreeMap<String, Axis>,
}

/// One tuning axis: the parameter's kind, declared ONCE, plus a finite list
/// of bare JSON values parsed as that kind. A mixed-kind axis is
/// unrepresentable by construction (the kind is a property of the
/// parameter, not of each value — user decision 2026-07-03, #189).
#[derive(Clone, Debug, PartialEq)]
pub struct Axis {
    pub kind: ScalarKind,
    pub values: Vec<Scalar>,
}

/// A protective-stop regime: a serializable, content-addressable mirror of the
/// runtime `StopRule` structural axis (C10/C20). The sole implemented variant is
/// the vol-stop; the shipped fixed-stop rule is admitted as a future additive
/// variant (it runs as a composite today but is not yet campaign-reachable).
/// Externally tagged so adding `Fixed` is additive — no content-id churn on
/// stored `Vol` regimes.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub enum RiskRegime {
    Vol { length: i64, k: f64 },
    VolTf { period_minutes: i64, length: i64, k: f64 },
}

/// One component of the campaign's cost model (#234): a closed, additive
/// vocabulary over the shipped C10 cost nodes — components sum (`CostSum`).
/// Serde field names are the builders' own `ParamSpec` names
/// (`constant_cost.rs` / `vol_slippage_cost.rs` / `carry_cost.rs`), so the doc
/// vocabulary conforms to the node vocabulary. Externally tagged so a future
/// component is additive — no content-id churn on stored components.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub enum CostSpec {
    Constant { cost_per_trade: CostValue },
    VolSlippage { slip_vol_mult: CostValue },
    Carry { carry_per_cycle: CostValue },
}

/// One cost knob's value (#260): one number for every cell, or an
/// instrument-keyed map resolved per cell at member construction. Custom
/// serde keeps the scalar form byte-identical to the historical bare number
/// (content-id parity for stored docs — the `Axis` precedent); the map form
/// is a plain JSON object of numbers (BTreeMap: deterministic key order).
#[derive(Clone, Debug, PartialEq)]
pub enum CostValue {
    Scalar(f64),
    PerInstrument(std::collections::BTreeMap<String, f64>),
}

impl CostValue {
    /// The per-cell value: the scalar, or the instrument's map entry. `None`
    /// only for an uncovered instrument — unreachable after intrinsic
    /// validation; callers refuse with prose rather than defaulting.
    pub fn resolve(&self, instrument: &str) -> Option<f64> {
        match self {
            CostValue::Scalar(v) => Some(*v),
            CostValue::PerInstrument(m) => m.get(instrument).copied(),
        }
    }
    /// Every numeric value the validator finiteness/sign-checks.
    pub fn values(&self) -> Vec<f64> {
        match self {
            CostValue::Scalar(v) => vec![*v],
            CostValue::PerInstrument(m) => m.values().copied().collect(),
        }
    }
    /// The map's key set (empty for a scalar) — the validate cross-check input.
    pub fn instrument_keys(&self) -> Vec<&str> {
        match self {
            CostValue::Scalar(_) => Vec::new(),
            CostValue::PerInstrument(m) => m.keys().map(String::as_str).collect(),
        }
    }
}

impl Serialize for CostValue {
    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
        match self {
            CostValue::Scalar(v) => v.serialize(s),
            CostValue::PerInstrument(m) => m.serialize(s),
        }
    }
}

impl<'de> Deserialize<'de> for CostValue {
    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
        use serde::de::Error as _;
        #[derive(Deserialize)]
        #[serde(untagged)]
        enum Raw {
            Scalar(f64),
            PerInstrument(std::collections::BTreeMap<String, f64>),
        }
        match Raw::deserialize(d)? {
            Raw::Scalar(v) => Ok(CostValue::Scalar(v)),
            Raw::PerInstrument(m) if m.is_empty() => {
                Err(D::Error::custom("instrument map is empty"))
            }
            Raw::PerInstrument(m) => Ok(CostValue::PerInstrument(m)),
        }
    }
}

/// Bare wire value of one Scalar (strips the tagged form via Scalar's own
/// serde as the oracle — no Scalar internals assumed).
fn scalar_to_bare(s: &Scalar) -> serde_json::Value {
    match serde_json::to_value(s).expect("scalar serializes") {
        serde_json::Value::Object(m) => m.into_values().next().expect("tagged form has one value"),
        v => v,
    }
}

/// The wire tag of a `ScalarKind`, using `ScalarKind`'s own serde as the
/// oracle (mirrors `scalar_to_bare`: no wire shape assumed, only asked of
/// serde) rather than re-hardcoding the four tag strings as a second source
/// of truth for `Scalar`'s variant names.
fn kind_tag(kind: ScalarKind) -> String {
    match serde_json::to_value(kind).expect("scalar kind serializes") {
        serde_json::Value::String(s) => s,
        other => unreachable!("ScalarKind serializes to a string, got {other}"),
    }
}

/// Parse one bare JSON value as a Scalar of the declared kind, using
/// Scalar's own serde as the type oracle (an int literal under F64 parses
/// as F64; a float under I64 refuses).
fn scalar_from_bare(kind: ScalarKind, v: &serde_json::Value) -> Result<Scalar, String> {
    let tag = kind_tag(kind);
    serde_json::from_value(serde_json::json!({ tag.clone(): v }))
        .map_err(|_| format!("value {v} is not a valid {tag}"))
}

impl Serialize for Axis {
    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
        use serde::ser::SerializeStruct;
        let mut st = s.serialize_struct("Axis", 2)?;
        st.serialize_field("kind", &self.kind)?;
        let bare: Vec<serde_json::Value> = self.values.iter().map(scalar_to_bare).collect();
        st.serialize_field("values", &bare)?;
        st.end()
    }
}

impl<'de> Deserialize<'de> for Axis {
    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
        use serde::de::Error as _;
        #[derive(Deserialize)]
        #[serde(deny_unknown_fields)]
        struct Raw {
            kind: ScalarKind,
            values: Vec<serde_json::Value>,
        }
        let raw = Raw::deserialize(d)?;
        let values = raw
            .values
            .iter()
            .map(|v| scalar_from_bare(raw.kind, v))
            .collect::<Result<Vec<_>, _>>()
            .map_err(D::Error::custom)?;
        Ok(Axis { kind: raw.kind, values })
    }
}

/// Reference to a content-addressed artifact: by byte-exact content id or
/// by topological identity id. Externally tagged one-of.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DocRef {
    ContentId(String),
    IdentityId(String),
}

/// The campaign's process slot: reference-only, never an inline body.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProcessRef {
    #[serde(rename = "ref")]
    pub r#ref: DocRef,
}

/// Data-level presentation: what to record and emit (never how to render).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Presentation {
    pub persist_taps: Vec<String>,
    pub emit: Vec<String>,
}

/// Parse a campaign document from text (envelope + strict fields).
pub fn parse_campaign(text: &str) -> Result<CampaignDoc, DocError> {
    let v: serde_json::Value =
        serde_json::from_str(text).map_err(|e| DocError::NotJson(e.to_string()))?;
    check_envelope(&v, DocKind::Campaign)?;
    serde_json::from_value(v).map_err(|e| DocError::Malformed(e.to_string()))
}

// ---------------------------------------------------------------------------
// Canonical form + content id — THE hashing primitive (library home; the
// CLI's content_id/topology_hash delegate here).
// ---------------------------------------------------------------------------

/// Canonical byte form of a process document: omit-defaults JSON in struct
/// field order, no trailing newline (the blueprint canonical-form recipe).
pub fn process_to_json(doc: &ProcessDoc) -> String {
    serde_json::to_string(doc).expect("process document serializes")
}

/// Canonical byte form of a campaign document. Value spelling is
/// normalized: an F64 axis serializes 8 as 8.0, so equivalent spellings
/// share one content id.
pub fn campaign_to_json(doc: &CampaignDoc) -> String {
    serde_json::to_string(doc).expect("campaign document serializes")
}

/// SHA-256 hex over canonical bytes — the single content-id primitive.
pub fn content_id_of(canonical: &str) -> String {
    use sha2::{Digest, Sha256};
    let mut hasher = Sha256::new();
    hasher.update(canonical.as_bytes());
    format!("{:x}", hasher.finalize())
}

// ---------------------------------------------------------------------------
// Intrinsic validation (store-free).
// ---------------------------------------------------------------------------

/// The closed declared-metric vocabulary: the scalar field names of the
/// shipped metrics/selection types (RMetrics + RunMetrics scalars, plus the
/// FamilySelection annotation scores). Single source for gate/stage checks
/// and introspection.
///
/// Accepted residual: this is a hand-copy of `aura-analysis`'s
/// `RMetrics`/`RunMetrics`/`FamilySelection` field names, not oracle-guarded
/// in-crate — this leaf crate deliberately has no `aura-analysis` dependency
/// (engine/domain separation), so there is no compile-time field pin to
/// reference here, unlike the block-schema tables above (:105). Same #160/0105
/// drift class; fails safe in the direction that matters: a metric renamed or
/// removed upstream surfaces as `DocFault::UnknownMetric` at parse/validation
/// time, never a silent accept. A cross-crate guard test
/// (`aura-campaign/tests/metric_vocabulary_e2e.rs`, #190) also enumerates the
/// shipped scalar fields via serde and goes red on any desync, so a stale
/// hand-edit here is test-caught rather than silent. Keep this list in sync by
/// hand when the upstream field sets change (single-sourcing it away waits on
/// #147).
pub fn metric_vocabulary() -> &'static [&'static str] {
    &[
        "expectancy_r",
        "win_rate",
        "avg_win_r",
        "avg_loss_r",
        "profit_factor",
        "max_r_drawdown",
        "sqn",
        "sqn_normalized",
        "net_expectancy_r",
        "n_trades",
        "n_open_at_end",
        "total_pips",
        "max_drawdown",
        "bias_sign_flips",
        "deflated_score",
        "overfit_probability",
        "neighbourhood_score",
    ]
}

/// The closed v1 emit vocabulary (data-level presentation outputs).
pub fn emit_vocabulary() -> &'static [&'static str] {
    &["family_table", "selection_report"]
}

/// The wrap convention's persisted sink names — the closed tap vocabulary
/// (#201 decision 1). A genuinely new observable becomes a new entry or an
/// authored sink in the strategy blueprint (C22: the choice of sinks is part
/// of the experiment) — never an open node-path namespace in the document.
pub fn tap_vocabulary() -> &'static [&'static str] {
    &["equity", "exposure", "r_equity", "net_r_equity"]
}

/// The closed archive-column vocabulary a campaign `data.bindings` VALUE may
/// name — the six M1 columns plus `price`, the alias of close. Doc-tier
/// mirror of the CLI binding module's role->column map (the `tap_vocabulary`
/// precedent for doc-tier string tables); the CLI pins the two surfaces
/// against each other in `binding::tests`.
pub fn binding_column_vocabulary() -> &'static [&'static str] {
    &["open", "high", "low", "close", "price", "spread", "volume"]
}

/// The vocabulary in the refusal-prose register — the alias annotated
/// instead of listed as a flat column (mirrors the CLI binding module).
pub fn binding_column_vocabulary_display() -> &'static str {
    "open | high | low | close (alias: price) | spread | volume"
}

/// Intrinsic-validation findings. By-identifier and Display-free.
#[derive(Clone, Debug, PartialEq)]
pub enum DocFault {
    // process side
    EmptyPipeline,
    UnknownMetric { stage: usize, metric: String },
    EmptyConjunction { stage: usize },
    GateFirst,
    GateAfterTerminalStage { stage: usize },
    ZeroWalkForwardLength { stage: usize, field: &'static str },
    // campaign side
    EmptyInstruments,
    DuplicateInstrument { index: usize, instrument: String },
    NoWindow,
    BadWindow { index: usize },
    BadRegime { index: usize },
    BadCost { index: usize },
    CostInstrumentKeys { index: usize, missing: Vec<String>, extra: Vec<String> },
    NoStrategy,
    EmptyAxes { strategy: usize },
    EmptyAxis { strategy: usize, axis: String },
    UnknownEmitKind(String),
    UnknownTap { index: usize, tap: String },
    UnknownBindingColumn { role: String, column: String },
    ProcessRefMustBeContentId,
}

fn is_known_metric(name: &str) -> bool {
    metric_vocabulary().contains(&name)
}

/// Intrinsic validation of a process document (P1 constraints included).
/// Returns ALL findings, not the first.
pub fn validate_process(doc: &ProcessDoc) -> Vec<DocFault> {
    let mut faults = Vec::new();
    if doc.pipeline.is_empty() {
        faults.push(DocFault::EmptyPipeline);
    }
    let mut terminal_seen = false;
    for (i, stage) in doc.pipeline.iter().enumerate() {
        match stage {
            StageBlock::Sweep { selection } => {
                if let Some(sel) = selection
                    && !is_known_metric(&sel.metric)
                {
                    faults.push(DocFault::UnknownMetric { stage: i, metric: sel.metric.clone() });
                }
            }
            StageBlock::Generalize { metric } => {
                if !is_known_metric(metric) {
                    faults.push(DocFault::UnknownMetric { stage: i, metric: metric.clone() });
                }
            }
            StageBlock::WalkForward { in_sample_ms, out_of_sample_ms, step_ms, metric, .. } => {
                if !is_known_metric(metric) {
                    faults.push(DocFault::UnknownMetric { stage: i, metric: metric.clone() });
                }
                // WindowRoller::new refuses NonPositiveLength at run time; the
                // doc tier refuses the same zeroes earlier, path-addressed.
                for (field, value) in [
                    ("in_sample_ms", *in_sample_ms),
                    ("out_of_sample_ms", *out_of_sample_ms),
                    ("step_ms", *step_ms),
                ] {
                    if value == 0 {
                        faults.push(DocFault::ZeroWalkForwardLength { stage: i, field });
                    }
                }
            }
            StageBlock::Gate { all } => {
                if i == 0 {
                    faults.push(DocFault::GateFirst);
                } else if terminal_seen {
                    faults.push(DocFault::GateAfterTerminalStage { stage: i });
                }
                if all.is_empty() {
                    faults.push(DocFault::EmptyConjunction { stage: i });
                }
                for p in all {
                    if !is_known_metric(&p.metric) {
                        faults.push(DocFault::UnknownMetric { stage: i, metric: p.metric.clone() });
                    }
                }
            }
            StageBlock::MonteCarlo { .. } => {}
            StageBlock::Grid => {}
        }
        if matches!(stage, StageBlock::MonteCarlo { .. } | StageBlock::Generalize { .. }) {
            terminal_seen = true;
        }
    }
    faults
}

/// Intrinsic validation of a campaign document (P1 constraints included).
/// Returns ALL findings, not the first.
pub fn validate_campaign(doc: &CampaignDoc) -> Vec<DocFault> {
    let mut faults = Vec::new();
    if doc.data.instruments.is_empty() {
        faults.push(DocFault::EmptyInstruments);
    }
    let mut seen_instruments = std::collections::BTreeSet::new();
    for (index, instrument) in doc.data.instruments.iter().enumerate() {
        if !seen_instruments.insert(instrument.as_str()) {
            faults.push(DocFault::DuplicateInstrument {
                index,
                instrument: instrument.clone(),
            });
        }
    }
    if doc.data.windows.is_empty() {
        faults.push(DocFault::NoWindow);
    }
    for (i, w) in doc.data.windows.iter().enumerate() {
        if w.from_ms >= w.to_ms {
            faults.push(DocFault::BadWindow { index: i });
        }
    }
    // Binding VALUES parse against the closed column vocabulary here (the
    // structural tier — no blueprint access); binding KEYS are checked against
    // the strategies' input roles in the resolver tier (validate_campaign_refs).
    for (role, column) in &doc.data.bindings {
        if !binding_column_vocabulary().contains(&column.as_str()) {
            faults.push(DocFault::UnknownBindingColumn {
                role: role.clone(),
                column: column.clone(),
            });
        }
    }
    for (i, r) in doc.risk.iter().enumerate() {
        match r {
            RiskRegime::Vol { length, k } => {
                // length < 1 catches 0 and negatives; `k <= 0.0 || k.is_nan()`
                // catches a non-positive or NaN multiplier (clippy-clean vs
                // `!(k > 0.0)`; +inf is unreachable via JSON, so it need not
                // be excluded).
                if *length < 1 || *k <= 0.0 || k.is_nan() {
                    faults.push(DocFault::BadRegime { index: i });
                }
            }
            RiskRegime::VolTf { period_minutes, length, k } => {
                if *period_minutes < 1 || *length < 1 || *k <= 0.0 || k.is_nan() {
                    faults.push(DocFault::BadRegime { index: i });
                }
            }
        }
    }
    for (i, c) in doc.cost.iter().enumerate() {
        // Every shipped cost node asserts a finite, non-negative price-unit
        // knob (`>= 0`); the doc tier refuses the same bounds earlier,
        // path-addressed (the BadRegime precedent). NaN/inf are unreachable
        // via JSON but refused defensively.
        let value = match c {
            CostSpec::Constant { cost_per_trade } => cost_per_trade,
            CostSpec::VolSlippage { slip_vol_mult } => slip_vol_mult,
            CostSpec::Carry { carry_per_cycle } => carry_per_cycle,
        };
        if value.values().iter().any(|v| !v.is_finite() || *v < 0.0) {
            faults.push(DocFault::BadCost { index: i });
        }
        // #260: an instrument map must cover the campaign's instruments
        // exactly — a missing instrument silently un-costs its cells, an
        // extra key is almost always a symbol typo; both refuse up front.
        let keys = value.instrument_keys();
        if !keys.is_empty() {
            let missing: Vec<String> = doc
                .data
                .instruments
                .iter()
                .filter(|inst| !keys.contains(&inst.as_str()))
                .cloned()
                .collect();
            let extra: Vec<String> = keys
                .iter()
                .filter(|k| !doc.data.instruments.iter().any(|inst| inst == *k))
                .map(|k| k.to_string())
                .collect();
            if !missing.is_empty() || !extra.is_empty() {
                faults.push(DocFault::CostInstrumentKeys { index: i, missing, extra });
            }
        }
    }
    if doc.strategies.is_empty() {
        faults.push(DocFault::NoStrategy);
    }
    for (i, s) in doc.strategies.iter().enumerate() {
        if s.axes.is_empty() {
            faults.push(DocFault::EmptyAxes { strategy: i });
        }
        for (axis, ax) in &s.axes {
            if ax.values.is_empty() {
                faults.push(DocFault::EmptyAxis { strategy: i, axis: axis.clone() });
            }
        }
    }
    if matches!(doc.process.r#ref, DocRef::IdentityId(_)) {
        faults.push(DocFault::ProcessRefMustBeContentId);
    }
    for e in &doc.presentation.emit {
        if !emit_vocabulary().contains(&e.as_str()) {
            faults.push(DocFault::UnknownEmitKind(e.clone()));
        }
    }
    for (i, t) in doc.presentation.persist_taps.iter().enumerate() {
        if !tap_vocabulary().contains(&t.as_str()) {
            faults.push(DocFault::UnknownTap { index: i, tap: t.clone() });
        }
    }
    faults
}

// ---------------------------------------------------------------------------
// Introspection contract: enumerate vocabulary, describe a block's typed
// slots, list a partial document's open slots. The same tables that make
// parsing strict make a block palette generatable (the litmus test).
// ---------------------------------------------------------------------------

/// The campaign document's section vocabulary (descriptive: campaign parsing
/// is derive-strict; this table powers introspection).
pub const CAMPAIGN_SECTIONS: &[BlockSchema] = &[
    BlockSchema {
        id: "std::data",
        doc: "campaign section: instruments + windows",
        slots: &[
            SlotInfo { name: "instruments", kind: SlotKind::Strings, required: true },
            SlotInfo { name: "windows", kind: SlotKind::Windows, required: true },
        ],
    },
    BlockSchema {
        id: "std::risk",
        doc: "campaign section: the structural risk axis — stop regimes the matrix runs each cell under (absent = one default regime)",
        slots: &[SlotInfo { name: "risk", kind: SlotKind::Regimes, required: false }],
    },
    BlockSchema {
        id: "std::cost",
        doc: "campaign section: the cost model — additive components (C10) every member runs under (absent = zero costs; net == gross)",
        slots: &[SlotInfo { name: "cost", kind: SlotKind::Costs, required: false }],
    },
    BlockSchema {
        id: "std::strategy",
        doc: "campaign section: strategy ref (content or identity id) + param axes",
        slots: &[
            SlotInfo { name: "ref", kind: SlotKind::Ref, required: true },
            SlotInfo { name: "axes", kind: SlotKind::Axes, required: true },
        ],
    },
    BlockSchema {
        id: "std::process_ref",
        doc: "campaign section: reference to a named process document (content id)",
        slots: &[SlotInfo { name: "ref", kind: SlotKind::ContentRef, required: true }],
    },
    BlockSchema {
        id: "std::presentation",
        doc: "campaign section: taps to persist + tables to emit (data-level only)",
        slots: &[
            SlotInfo { name: "persist_taps", kind: SlotKind::TapKinds, required: true },
            SlotInfo { name: "emit", kind: SlotKind::EmitKinds, required: true },
        ],
    },
];

/// Human label for a slot kind (CLI presentation).
pub fn slot_kind_label(kind: SlotKind) -> &'static str {
    match kind {
        SlotKind::MetricName => "metric name (see metric_vocabulary)",
        SlotKind::SelectRule => "select rule: argmax | plateau:mean | plateau:worst",
        SlotKind::WfMode => "one of: rolling | anchored",
        SlotKind::Bool => "bool",
        SlotKind::U32 => "non-negative integer (u32)",
        SlotKind::U64 => "non-negative integer (u64)",
        SlotKind::Predicates => "list of { metric, cmp: gt|ge|lt|le, value }",
        SlotKind::Strings => "list of strings",
        SlotKind::Windows => "list of { from_ms, to_ms }",
        SlotKind::Ref => "one of { content_id } | { identity_id }",
        SlotKind::ContentRef => "content id of a process document",
        SlotKind::Axes => "map: param name -> { kind, values }",
        SlotKind::EmitKinds => "list of: family_table | selection_report",
        SlotKind::TapKinds => "list of: equity | exposure | r_equity | net_r_equity",
        SlotKind::Regimes => {
            "list of stop regimes { vol: { length, k } } | { vol_tf: { period_minutes, length, k } }; absent = one default regime"
        }
        SlotKind::Costs => {
            "list of cost components { constant: { cost_per_trade } } | { vol_slippage: { slip_vol_mult } } | { carry: { carry_per_cycle } }; absent = zero costs (net == gross)"
        }
    }
}

/// The process-stage vocabulary (id + doc + slots per block).
pub fn process_vocabulary() -> &'static [BlockSchema] {
    PROCESS_BLOCKS
}

/// The campaign section vocabulary.
pub fn campaign_vocabulary() -> &'static [BlockSchema] {
    CAMPAIGN_SECTIONS
}

/// Describe one block by id, searching both vocabularies.
pub fn describe_block(id: &str) -> Option<&'static BlockSchema> {
    PROCESS_BLOCKS
        .iter()
        .chain(CAMPAIGN_SECTIONS.iter())
        .find(|b| b.id == id)
}

/// One open slot of a partial document.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OpenSlot {
    /// JSON-ish path, e.g. `process.ref` or `strategies[0].axes.slow`.
    pub path: String,
    /// Short human hint, e.g. "required, one of: content_id, identity_id".
    pub hint: String,
    /// Additional unit/semantics notes rendered as indented sub-lines under
    /// the slot's main line (#265) — additive, so the `hint` string itself
    /// (pinned verbatim by earlier tests) never changes.
    pub notes: Vec<String>,
}

fn open(path: impl Into<String>, hint: impl Into<String>) -> OpenSlot {
    OpenSlot { path: path.into(), hint: hint.into(), notes: Vec::new() }
}

/// Like `open`, but carries additional unit/semantics notes (#265) rendered
/// as indented sub-lines under the slot — the `hint` string stays exactly as
/// `open` would have produced it, so sibling tests pinning `hint` verbatim
/// are unaffected.
fn open_with_notes(path: impl Into<String>, hint: impl Into<String>, notes: Vec<String>) -> OpenSlot {
    OpenSlot { path: path.into(), hint: hint.into(), notes }
}

/// List the open slots of a (possibly partial) process document. Only the
/// text being JSON is required; everything else missing is reported as open.
pub fn open_slots_process(text: &str) -> Result<Vec<OpenSlot>, DocError> {
    let v: serde_json::Value =
        serde_json::from_str(text).map_err(|e| DocError::NotJson(e.to_string()))?;
    let mut slots = Vec::new();
    envelope_open_slots(&v, "process", &mut slots);
    if v.get("name").and_then(|n| n.as_str()).is_none() {
        slots.push(open("name", "required, string"));
    }
    match v.get("pipeline").and_then(|p| p.as_array()) {
        None => slots.push(open("pipeline", "required, list of stage blocks")),
        Some(stages) if stages.is_empty() => {
            slots.push(open("pipeline", "declared empty — a process needs at least one stage"))
        }
        Some(stages) => {
            for (i, stage) in stages.iter().enumerate() {
                let Some(m) = stage.as_object() else {
                    slots.push(open(format!("pipeline[{i}]"), "must be a stage object"));
                    continue;
                };
                let Some(id) = m.get("block").and_then(|b| b.as_str()) else {
                    slots.push(open(format!("pipeline[{i}].block"), "required, block id"));
                    continue;
                };
                let Some(schema) = PROCESS_BLOCKS.iter().find(|b| b.id == id) else {
                    slots.push(open(format!("pipeline[{i}].block"), format!("unknown block \"{id}\"")));
                    continue;
                };
                for s in schema.slots.iter().filter(|s| s.required) {
                    if !m.contains_key(s.name) {
                        slots.push(open(
                            format!("pipeline[{i}].{}", s.name),
                            format!("required, {}", slot_kind_label(s.kind)),
                        ));
                    }
                }
            }
        }
    }
    Ok(slots)
}

/// List the open slots of a (possibly partial) campaign document.
/// Whether a `ref` slot in a DRAFT reads as open: absent, or one of the two
/// natural placeholder spellings `{}` / `null` (#195). A draft affordance of
/// the open-slot probe only — the strict parse tier keeps refusing
/// placeholders (they are not a valid document form).
fn ref_slot_is_open(r: Option<&serde_json::Value>) -> bool {
    match r {
        None | Some(serde_json::Value::Null) => true,
        Some(serde_json::Value::Object(m)) => m.is_empty(),
        Some(_) => false,
    }
}

pub fn open_slots_campaign(text: &str) -> Result<Vec<OpenSlot>, DocError> {
    let v: serde_json::Value =
        serde_json::from_str(text).map_err(|e| DocError::NotJson(e.to_string()))?;
    let mut slots = Vec::new();
    envelope_open_slots(&v, "campaign", &mut slots);
    if v.get("name").and_then(|n| n.as_str()).is_none() {
        slots.push(open("name", "required, string"));
    }
    match v.get("data") {
        None => slots.push(open("data", "required section: instruments + windows")),
        Some(d) => {
            if d.get("instruments").and_then(|x| x.as_array()).is_none_or(|a| a.is_empty()) {
                slots.push(open("data.instruments", "required, non-empty list of strings"));
            }
            if d.get("windows").and_then(|x| x.as_array()).is_none_or(|a| a.is_empty()) {
                slots.push(open("data.windows", "required, non-empty list of { from_ms, to_ms }"));
            }
        }
    }
    // The one optional slot the probe lists: the structural risk axis is
    // invisible to a required-slot sweep (#[serde(default)]), which is exactly
    // how it went undiscovered (#216). Absent, empty, or non-array = open.
    let risk_bound = v.get("risk").and_then(|r| r.as_array()).is_some_and(|a| !a.is_empty());
    if !risk_bound {
        slots.push(open_with_notes(
            "risk",
            "optional, list of stop regimes { vol: { length, k } } | { vol_tf: { period_minutes, length, k } }; absent = one default regime",
            vec![
                "vol.length smooths the per-cycle vol estimator; it does not set the stop's timescale.".to_string(),
                "vol.k scales the stop distance (the risk-unit lever).".to_string(),
                "vol_tf computes the same estimator over completed period_minutes buckets: period_minutes IS the stop's timescale; length smooths in buckets; k scales the distance.".to_string(),
            ],
        ));
    }
    // The optional cost slot, mirroring the risk axis (#216's discoverability
    // lesson applied to #234): absent, empty, or non-array = open.
    let cost_bound = v.get("cost").and_then(|c| c.as_array()).is_some_and(|a| !a.is_empty());
    if !cost_bound {
        slots.push(open_with_notes(
            "cost",
            "optional, list of cost components { constant: { cost_per_trade } } | { vol_slippage: { slip_vol_mult } } | { carry: { carry_per_cycle } }; absent = zero costs (net == gross)",
            vec![
                "constant.cost_per_trade is in price units, not R (charged per close as cost/|entry-stop| in R).".to_string(),
                "vol_slippage.slip_vol_mult is a multiplier on the per-cycle vol estimate (charged in R likewise).".to_string(),
                "carry.carry_per_cycle is in price units per held cycle, not R (accrues over the hold, emitted in R likewise).".to_string(),
                "each knob takes one number for all instruments, or an instrument-keyed map { \"GER40\": 2.0, ... } covering exactly the campaign's instruments.".to_string(),
            ],
        ));
    }
    match v.get("strategies").and_then(|s| s.as_array()) {
        None => slots.push(open("strategies", "required, non-empty list of { ref, axes }")),
        Some(list) if list.is_empty() => {
            slots.push(open("strategies", "declared empty — a campaign needs at least one strategy"))
        }
        Some(list) => {
            for (i, s) in list.iter().enumerate() {
                if ref_slot_is_open(s.get("ref")) {
                    slots.push(open(
                        format!("strategies[{i}].ref"),
                        "required, one of: content_id, identity_id",
                    ));
                }
                match s.get("axes").and_then(|a| a.as_object()) {
                    None => slots.push(open(
                        format!("strategies[{i}].axes"),
                        "required, map: param name -> { kind, values }",
                    )),
                    Some(axes) => {
                        for (axis, spec) in axes {
                            let empty = spec
                                .get("values")
                                .and_then(|c| c.as_array())
                                .is_none_or(|c| c.is_empty());
                            if empty {
                                slots.push(open(
                                    format!("strategies[{i}].axes.{axis}"),
                                    "axis declared empty — a P1 axis is a non-empty finite set",
                                ));
                            }
                        }
                    }
                }
            }
        }
    }
    if ref_slot_is_open(v.get("process").and_then(|p| p.get("ref"))) {
        // deliberately narrower than the strategy-ref hint: validate_campaign
        // refuses an identity_id process ref, so the guide must not offer it
        slots.push(open("process.ref", "required, content id of a process document"));
    }
    if v.get("seed").and_then(|s| s.as_u64()).is_none() {
        slots.push(open("seed", "required, non-negative integer"));
    }
    if v.get("presentation").is_none() {
        slots.push(open(
            "presentation",
            format!(
                "required section: persist_taps ({}) + emit",
                tap_vocabulary().join(" | ")
            ),
        ));
    }
    Ok(slots)
}

fn envelope_open_slots(v: &serde_json::Value, kind: &str, slots: &mut Vec<OpenSlot>) {
    if v.get("format_version").and_then(|x| x.as_u64()) != Some(u64::from(FORMAT_VERSION)) {
        slots.push(open("format_version", "required, must be 1"));
    }
    if v.get("kind").and_then(|k| k.as_str()) != Some(kind) {
        slots.push(open("kind", format!("required, must be \"{kind}\"")));
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    pub(crate) const PROCESS_FIXTURE: &str = r#"{
      "format_version": 1,
      "kind": "process",
      "name": "wf-deflated-screen",
      "description": "Sweep, keep only deflated-positive candidates, then walk-forward.",
      "pipeline": [
        { "block": "std::sweep", "metric": "net_expectancy_r", "select": "plateau:worst", "deflate": true },
        { "block": "std::gate", "all": [
            { "metric": "net_expectancy_r", "cmp": "gt", "value": 0.0 },
            { "metric": "overfit_probability", "cmp": "lt", "value": 0.1 } ] },
        { "block": "std::walk_forward", "in_sample_ms": 4000, "out_of_sample_ms": 1000,
          "step_ms": 1000, "mode": "rolling", "metric": "net_expectancy_r", "select": "argmax" }
      ]
    }"#;

    #[test]
    fn process_fixture_parses_to_typed_stages() {
        let doc = parse_process(PROCESS_FIXTURE).expect("fixture parses");
        assert_eq!(doc.format_version, 1);
        assert_eq!(doc.kind, DocKind::Process);
        assert_eq!(doc.name, "wf-deflated-screen");
        assert_eq!(doc.pipeline.len(), 3);
        match &doc.pipeline[0] {
            StageBlock::Sweep { selection } => {
                let sel = selection.as_ref().expect("fixture sweep carries a selection");
                assert_eq!(sel.metric, "net_expectancy_r");
                assert_eq!(sel.select, SelectRule::PlateauWorst);
                assert!(sel.deflate);
            }
            other => panic!("stage 0 is not a sweep: {other:?}"),
        }
        match &doc.pipeline[1] {
            StageBlock::Gate { all } => {
                assert_eq!(all.len(), 2);
                assert_eq!(all[0].cmp, Cmp::Gt);
            }
            other => panic!("stage 1 is not a gate: {other:?}"),
        }
        match &doc.pipeline[2] {
            StageBlock::WalkForward {
                in_sample_ms,
                out_of_sample_ms,
                step_ms,
                mode,
                metric,
                select,
            } => {
                assert_eq!(*in_sample_ms, 4000);
                assert_eq!(*out_of_sample_ms, 1000);
                assert_eq!(*step_ms, 1000);
                assert_eq!(*mode, WfMode::Rolling);
                assert_eq!(metric, "net_expectancy_r");
                assert_eq!(*select, SelectRule::Argmax);
            }
            other => panic!("stage 2 is not a walk_forward: {other:?}"),
        }
    }

    #[test]
    fn process_parse_refuses_bad_envelope_and_unknown_vocabulary() {
        // wrong format_version
        let bad_fv = PROCESS_FIXTURE.replacen("\"format_version\": 1", "\"format_version\": 2", 1);
        assert_eq!(parse_process(&bad_fv), Err(DocError::BadFormatVersion(2)));
        // wrong kind
        let bad_kind = PROCESS_FIXTURE.replacen("\"kind\": \"process\"", "\"kind\": \"campaign\"", 1);
        assert_eq!(
            parse_process(&bad_kind),
            Err(DocError::WrongKind { expected: DocKind::Process })
        );
        // unknown block id
        let bad_block = PROCESS_FIXTURE.replacen("std::sweep", "std::sweeep", 1);
        let err = parse_process(&bad_block).expect_err("unknown block refused");
        assert!(matches!(err, DocError::Malformed(msg) if msg.contains("unknown block")));
        // unknown slot on a known block
        let bad_slot = PROCESS_FIXTURE.replacen("\"deflate\": true", "\"deflaate\": true", 1);
        let err = parse_process(&bad_slot).expect_err("unknown slot refused");
        assert!(matches!(err, DocError::Malformed(msg) if msg.contains("unknown slot")));
        // unknown select rule
        let bad_select = PROCESS_FIXTURE.replacen("plateau:worst", "plateau:median", 1);
        let err = parse_process(&bad_select).expect_err("unknown select refused");
        assert!(matches!(err, DocError::Malformed(msg) if msg.contains("unknown select rule")));
        // top-level unknown field (derive deny_unknown_fields)
        let extra = PROCESS_FIXTURE.replacen("\"name\":", "\"nam3\": \"x\", \"name\":", 1);
        assert!(matches!(parse_process(&extra), Err(DocError::Malformed(_))));
        // not JSON at all
        assert!(matches!(parse_process("not json"), Err(DocError::NotJson(_))));
    }

    pub(crate) const CAMPAIGN_FIXTURE: &str = r#"{
      "format_version": 1,
      "kind": "campaign",
      "name": "ger40-momentum-screen",
      "data": {
        "instruments": ["GER40"],
        "windows": [ { "from_ms": 1704067200000, "to_ms": 1719792000000 } ]
      },
      "strategies": [
        { "ref": { "content_id": "9f3a" },
          "axes": { "fast": { "kind": "I64", "values": [8, 12, 16] },
                    "slow": { "kind": "I64", "values": [21, 34] } } }
      ],
      "process": { "ref": { "content_id": "4e2d" } },
      "seed": 1,
      "presentation": {
        "persist_taps": ["net_r_equity"],
        "emit": ["family_table", "selection_report"]
      }
    }"#;

    #[test]
    fn campaign_fixture_parses_with_axis_level_kind() {
        let doc = parse_campaign(CAMPAIGN_FIXTURE).expect("fixture parses");
        assert_eq!(doc.kind, DocKind::Campaign);
        assert_eq!(doc.data.instruments, vec!["GER40".to_string()]);
        let s = &doc.strategies[0];
        assert_eq!(s.r#ref, DocRef::ContentId("9f3a".into()));
        let fast = &s.axes["fast"];
        assert_eq!(fast.kind, ScalarKind::I64);
        assert_eq!(fast.values, vec![Scalar::I64(8), Scalar::I64(12), Scalar::I64(16)]);
        assert_eq!(doc.process.r#ref, DocRef::ContentId("4e2d".into()));
        // an int literal under an F64 axis parses AS F64 (kind is the oracle)
        let f64_axis: Axis =
            serde_json::from_str(r#"{ "kind": "F64", "values": [1, 2.5] }"#).expect("parses");
        assert_eq!(f64_axis.values, vec![Scalar::F64(1.0), Scalar::F64(2.5)]);
    }

    /// Axis's hand-rolled Serialize strips the tagged Scalar wire form down
    /// to bare values (scalar_to_bare), and Deserialize re-parses those bare
    /// values back through the declared kind (scalar_from_bare) — this must
    /// round-trip exactly, and the wire shape must actually be bare (no
    /// per-value `{"I64": ...}` tag leaking through).
    #[test]
    fn axis_serializes_to_bare_values_and_round_trips() {
        let axis = Axis {
            kind: ScalarKind::I64,
            values: vec![Scalar::I64(8), Scalar::I64(12), Scalar::I64(16)],
        };
        let json = serde_json::to_value(&axis).expect("axis serializes");
        assert_eq!(json, serde_json::json!({ "kind": "I64", "values": [8, 12, 16] }));
        let round_tripped: Axis = serde_json::from_value(json).expect("bare json round-trips");
        assert_eq!(round_tripped, axis);
    }

    #[test]
    fn campaign_refuses_inline_process_and_kindless_axes() {
        // an inline process body in the ref slot must refuse to parse
        let inline = CAMPAIGN_FIXTURE.replacen(
            r#""process": { "ref": { "content_id": "4e2d" } }"#,
            r#""process": { "ref": { "pipeline": [] } }"#,
            1,
        );
        assert!(matches!(parse_campaign(&inline), Err(DocError::Malformed(_))));
        // an inline body BESIDE the ref must refuse too (deny_unknown_fields)
        let beside = CAMPAIGN_FIXTURE.replacen(
            r#""process": { "ref": { "content_id": "4e2d" } }"#,
            r#""process": { "ref": { "content_id": "4e2d" }, "pipeline": [] }"#,
            1,
        );
        assert!(matches!(parse_campaign(&beside), Err(DocError::Malformed(_))));
        // a kindless bare array is not an axis
        let bare = CAMPAIGN_FIXTURE.replacen(
            r#"{ "kind": "I64", "values": [8, 12, 16] }"#,
            "[8, 12, 16]",
            1,
        );
        assert!(matches!(parse_campaign(&bare), Err(DocError::Malformed(_))));
        // a tagged cell inside values is not a bare value
        let tagged = CAMPAIGN_FIXTURE.replacen("[8, 12, 16]", r#"[{"I64": 8}]"#, 1);
        assert!(matches!(parse_campaign(&tagged), Err(DocError::Malformed(_))));
        // a float under an I64 axis refuses
        let float_in_i64 = CAMPAIGN_FIXTURE.replacen("[8, 12, 16]", "[8.5]", 1);
        assert!(matches!(parse_campaign(&float_in_i64), Err(DocError::Malformed(_))));
        // wrong kind envelope
        let bad_kind = CAMPAIGN_FIXTURE.replacen("\"kind\": \"campaign\"", "\"kind\": \"process\"", 1);
        assert_eq!(
            parse_campaign(&bad_kind),
            Err(DocError::WrongKind { expected: DocKind::Campaign })
        );
    }

    #[test]
    fn process_canonical_form_is_pinned_and_id_stable() {
        let doc = parse_process(PROCESS_FIXTURE).unwrap();
        let canonical = process_to_json(&doc);
        let golden = concat!(
            r#"{"format_version":1,"kind":"process","name":"wf-deflated-screen","#,
            r#""description":"Sweep, keep only deflated-positive candidates, then walk-forward.","#,
            r#""pipeline":[{"block":"std::sweep","metric":"net_expectancy_r","select":"plateau:worst","deflate":true},"#,
            r#"{"block":"std::gate","all":[{"metric":"net_expectancy_r","cmp":"gt","value":0.0},"#,
            r#"{"metric":"overfit_probability","cmp":"lt","value":0.1}]},"#,
            r#"{"block":"std::walk_forward","in_sample_ms":4000,"out_of_sample_ms":1000,"step_ms":1000,"#,
            r#""mode":"rolling","metric":"net_expectancy_r","select":"argmax"}]}"#
        );
        assert_eq!(canonical, golden);
        assert!(!canonical.ends_with('\n'));
        let id = content_id_of(&canonical);
        assert_eq!(id.len(), 64);
        assert!(id.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()));
        let reparsed = parse_process(&canonical).unwrap();
        assert_eq!(content_id_of(&process_to_json(&reparsed)), id);
        // omit-defaults: deflate:false disappears from the canonical form
        let no_deflate = PROCESS_FIXTURE.replacen("\"deflate\": true", "\"deflate\": false", 1);
        let canonical2 = process_to_json(&parse_process(&no_deflate).unwrap());
        assert!(!canonical2.contains("\"deflate\":"));
        assert_ne!(content_id_of(&canonical2), id);
    }

    #[test]
    fn campaign_canonical_form_normalizes_and_distinguishes() {
        let doc = parse_campaign(CAMPAIGN_FIXTURE).unwrap();
        let canonical = campaign_to_json(&doc);
        assert!(!canonical.ends_with('\n'));
        let reparsed = parse_campaign(&canonical).unwrap();
        assert_eq!(reparsed, doc);
        assert_eq!(campaign_to_json(&reparsed), canonical);
        let id = content_id_of(&canonical);
        assert_eq!(id.len(), 64);
        // a different seed is a different campaign
        let other = CAMPAIGN_FIXTURE.replacen("\"seed\": 1", "\"seed\": 2", 1);
        let other_id = content_id_of(&campaign_to_json(&parse_campaign(&other).unwrap()));
        assert_ne!(other_id, id);
        // spelling normalization: an F64 axis writes ints as floats, so the
        // two spellings share one canonical form (and thus one content id)
        let spelled_a = CAMPAIGN_FIXTURE.replacen(
            r#""fast": { "kind": "I64", "values": [8, 12, 16] }"#,
            r#""fast": { "kind": "F64", "values": [8, 12.0, 16] }"#,
            1,
        );
        let spelled_b = CAMPAIGN_FIXTURE.replacen(
            r#""fast": { "kind": "I64", "values": [8, 12, 16] }"#,
            r#""fast": { "kind": "F64", "values": [8.0, 12, 16.0] }"#,
            1,
        );
        let ca = campaign_to_json(&parse_campaign(&spelled_a).unwrap());
        let cb = campaign_to_json(&parse_campaign(&spelled_b).unwrap());
        assert_eq!(ca, cb);
        assert!(ca.contains(r#""values":[8.0,12.0,16.0]"#));
    }

    #[test]
    fn risk_regime_round_trips_as_externally_tagged_vol() {
        let r = RiskRegime::Vol { length: 3, k: 2.0 };
        let j = serde_json::to_string(&r).unwrap();
        assert_eq!(j, r#"{"vol":{"length":3,"k":2.0}}"#);
        let back: RiskRegime = serde_json::from_str(&j).unwrap();
        assert_eq!(back, r);
    }

    /// #262: the second variant's wire form is pinned the same way its Vol
    /// sibling is — `vol_tf` snake_case tag, three named fields — so a stored
    /// VolTf document's parse path doesn't silently rot.
    #[test]
    fn risk_regime_round_trips_as_externally_tagged_vol_tf() {
        let r = RiskRegime::VolTf { period_minutes: 60, length: 14, k: 2.0 };
        let j = serde_json::to_string(&r).unwrap();
        assert_eq!(j, r#"{"vol_tf":{"period_minutes":60,"length":14,"k":2.0}}"#);
        let back: RiskRegime = serde_json::from_str(&j).unwrap();
        assert_eq!(back, r);
    }

    #[test]
    fn campaign_absent_and_empty_risk_omit_from_canonical_bytes() {
        let doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
        assert!(doc.risk.is_empty(), "an absent risk key deserializes to empty");
        let absent = campaign_to_json(&doc);
        assert!(!absent.contains("\"risk\""), "absent risk omits from serialization: {absent}");
        let mut empty = doc.clone();
        empty.risk = vec![];
        assert_eq!(
            content_id_of(&campaign_to_json(&empty)),
            content_id_of(&absent),
            "an explicit empty risk hashes identically to an absent one"
        );
    }

    #[test]
    fn campaign_with_risk_serializes_and_round_trips_the_regimes() {
        let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
        doc.risk = vec![
            RiskRegime::Vol { length: 3, k: 1.5 },
            RiskRegime::Vol { length: 3, k: 3.0 },
        ];
        let out = campaign_to_json(&doc);
        assert!(out.contains(r#""risk":[{"vol":{"length":3,"k":1.5}},{"vol":{"length":3,"k":3.0}}]"#),
            "risk serializes in place as a tagged list: {out}");
        let back: CampaignDoc = serde_json::from_str(&out).unwrap();
        assert_eq!(back.risk, doc.risk);
    }

    #[test]
    fn validate_campaign_flags_each_non_positive_regime() {
        let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
        doc.risk = vec![
            RiskRegime::Vol { length: 3, k: 2.0 }, // 0: valid
            RiskRegime::Vol { length: 0, k: 2.0 }, // 1: length < 1
            RiskRegime::Vol { length: 3, k: 0.0 }, // 2: k <= 0
        ];
        let faults = validate_campaign(&doc);
        assert!(faults.contains(&DocFault::BadRegime { index: 1 }), "{faults:?}");
        assert!(faults.contains(&DocFault::BadRegime { index: 2 }), "{faults:?}");
        assert!(!faults.contains(&DocFault::BadRegime { index: 0 }), "valid regime not flagged: {faults:?}");
    }

    /// #262: a non-positive `period_minutes` must be refused as a graceful
    /// `DocFault::BadRegime` at the doc tier — otherwise it reaches
    /// `VolTfStop::new`'s assert and panics instead of a validation error.
    #[test]
    fn validate_campaign_flags_non_positive_period_minutes_regime() {
        let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
        doc.risk = vec![
            RiskRegime::VolTf { period_minutes: 60, length: 14, k: 2.0 }, // 0: valid
            RiskRegime::VolTf { period_minutes: 0, length: 14, k: 2.0 },  // 1: period_minutes < 1
        ];
        let faults = validate_campaign(&doc);
        assert!(faults.contains(&DocFault::BadRegime { index: 1 }), "{faults:?}");
        assert!(!faults.contains(&DocFault::BadRegime { index: 0 }), "valid regime not flagged: {faults:?}");
    }

    #[test]
    fn validate_campaign_accepts_a_valid_risk_section() {
        let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
        doc.risk = vec![RiskRegime::Vol { length: 3, k: 1.5 }];
        assert!(validate_campaign(&doc).is_empty(), "{:?}", validate_campaign(&doc));
    }

    #[test]
    fn cost_spec_round_trips_as_externally_tagged_components() {
        // Serde field names ARE the shipped builders' ParamSpec names
        // (cost_per_trade / slip_vol_mult / carry_per_cycle) — the doc
        // vocabulary conforms to the node vocabulary, not the other way round.
        for (spec, wire) in [
            (
                CostSpec::Constant { cost_per_trade: CostValue::Scalar(2.0) },
                r#"{"constant":{"cost_per_trade":2.0}}"#,
            ),
            (
                CostSpec::VolSlippage { slip_vol_mult: CostValue::Scalar(0.5) },
                r#"{"vol_slippage":{"slip_vol_mult":0.5}}"#,
            ),
            (
                CostSpec::Carry { carry_per_cycle: CostValue::Scalar(0.1) },
                r#"{"carry":{"carry_per_cycle":0.1}}"#,
            ),
        ] {
            assert_eq!(serde_json::to_string(&spec).unwrap(), wire);
            let back: CostSpec = serde_json::from_str(wire).unwrap();
            assert_eq!(back, spec);
        }
        // deny_unknown_fields: a misspelled knob refuses to parse
        assert!(serde_json::from_str::<CostSpec>(r#"{"constant":{"per_close_r":2.0}}"#).is_err());
    }

    /// Property (#260): a cost knob's map form round-trips as a plain JSON
    /// object keyed by instrument, and the scalar form stays the bare number
    /// (content-id parity for stored docs is pinned by the sibling scalar
    /// round-trip test, which must stay green untouched).
    #[test]
    fn cost_value_map_form_round_trips_and_scalar_stays_bare() {
        let json = r#"{"constant":{"cost_per_trade":{"EURUSD":0.00012,"GER40":2.0}}}"#;
        let spec: CostSpec = serde_json::from_str(json).expect("map form parses");
        assert_eq!(serde_json::to_string(&spec).expect("serializes"), json);
        let bare: CostSpec = serde_json::from_str(r#"{"constant":{"cost_per_trade":2.0}}"#)
            .expect("scalar form parses");
        assert_eq!(
            serde_json::to_string(&bare).expect("serializes"),
            r#"{"constant":{"cost_per_trade":2.0}}"#,
            "scalar wire form must stay the bare number byte-identically"
        );
        let CostSpec::Constant { cost_per_trade } = &spec else { panic!("constant") };
        assert_eq!(cost_per_trade.resolve("GER40"), Some(2.0));
        assert_eq!(cost_per_trade.resolve("EURUSD"), Some(0.00012));
        assert_eq!(cost_per_trade.resolve("XAUUSD"), None);
    }

    /// Property (#260): an empty instrument map (`{}`) is refused at
    /// deserialize time — `validate_campaign`'s cross-check skips a knob
    /// whose `instrument_keys()` is empty (that shape also means "scalar"),
    /// so an empty map that slipped past this guard would silently escape
    /// the instrument-coverage check entirely.
    #[test]
    fn cost_value_empty_map_is_refused_at_deserialize() {
        let err = serde_json::from_str::<CostSpec>(r#"{"constant":{"cost_per_trade":{}}}"#)
            .expect_err("an empty instrument map must not parse");
        assert!(
            err.to_string().contains("instrument map is empty"),
            "error names the defect: {err}"
        );
    }

    /// Property (#260): the intrinsic tier cross-checks every instrument map
    /// against the doc's own data.instruments — a missing instrument and an
    /// unknown extra key are each a fault naming the component; a complete
    /// map is valid. No silent default, no partial coverage.
    #[test]
    fn validate_campaign_cross_checks_cost_instrument_maps() {
        let mk = |cost: &str| {
            let doc = format!(
                r#"{{ "format_version": 1, "kind": "campaign", "name": "t",
  "data": {{ "instruments": ["GER40", "EURUSD"],
            "windows": [ {{ "from_ms": 1, "to_ms": 2 }} ] }},
  "strategies": [ {{ "ref": {{ "content_id": "9f" }},
                    "axes": {{ "a": {{ "kind": "I64", "values": [1] }} }} }} ],
  "process": {{ "ref": {{ "content_id": "9f" }} }},
  "cost": [ {cost} ],
  "presentation": {{ "persist_taps": [], "emit": [] }},
  "seed": 1 }}"#
            );
            let parsed = parse_campaign(&doc).expect("parses");
            validate_campaign(&parsed)
        };
        assert!(
            mk(r#"{ "constant": { "cost_per_trade": { "GER40": 2.0, "EURUSD": 0.00012 } } }"#)
                .is_empty(),
            "a complete map must validate"
        );
        let missing = mk(r#"{ "constant": { "cost_per_trade": { "GER40": 2.0 } } }"#);
        assert!(
            missing.iter().any(|f| matches!(
                f,
                DocFault::CostInstrumentKeys { index: 0, .. }
            )),
            "fault names the component: {missing:?}"
        );
        let extra = mk(
            r#"{ "constant": { "cost_per_trade": { "GER40": 2.0, "EURUSD": 1.0, "GER40.cash": 3.0 } } }"#,
        );
        assert!(
            extra.iter().any(|f| matches!(f, DocFault::CostInstrumentKeys { index: 0, .. })),
            "fault names the component: {extra:?}"
        );
    }

    #[test]
    fn campaign_absent_and_empty_cost_omit_from_canonical_bytes() {
        // The risk precedent's sibling: cost-less documents keep their content ids.
        let doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
        assert!(doc.cost.is_empty(), "an absent cost key deserializes to empty");
        let absent = campaign_to_json(&doc);
        assert!(!absent.contains("\"cost\""), "absent cost omits from serialization: {absent}");
        let mut empty = doc.clone();
        empty.cost = vec![];
        assert_eq!(
            content_id_of(&campaign_to_json(&empty)),
            content_id_of(&absent),
            "an explicit empty cost hashes identically to an absent one"
        );
    }

    #[test]
    fn campaign_with_cost_serializes_and_round_trips_the_components() {
        let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
        doc.cost = vec![
            CostSpec::Constant { cost_per_trade: CostValue::Scalar(2.0) },
            CostSpec::VolSlippage { slip_vol_mult: CostValue::Scalar(0.5) },
        ];
        let out = campaign_to_json(&doc);
        assert!(
            out.contains(r#""cost":[{"constant":{"cost_per_trade":2.0}},{"vol_slippage":{"slip_vol_mult":0.5}}]"#),
            "cost serializes in place as a tagged list: {out}"
        );
        let back: CampaignDoc = serde_json::from_str(&out).unwrap();
        assert_eq!(back.cost, doc.cost);
    }

    #[test]
    fn validate_campaign_flags_each_bad_cost_component() {
        let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
        doc.cost = vec![
            CostSpec::Constant { cost_per_trade: CostValue::Scalar(0.0) },      // 0: valid (zero cost is a legal stress point)
            CostSpec::VolSlippage { slip_vol_mult: CostValue::Scalar(-0.5) },   // 1: negative
            CostSpec::Carry { carry_per_cycle: CostValue::Scalar(f64::NAN) },   // 2: non-finite (unreachable via JSON; defensive)
        ];
        let faults = validate_campaign(&doc);
        assert!(faults.contains(&DocFault::BadCost { index: 1 }), "{faults:?}");
        assert!(faults.contains(&DocFault::BadCost { index: 2 }), "{faults:?}");
        assert!(!faults.contains(&DocFault::BadCost { index: 0 }), "valid component not flagged: {faults:?}");
    }

    /// Property (#260): the finiteness/sign check applies to EVERY value
    /// inside a `PerInstrument` map, not only the scalar form — a negative
    /// entry nested in an otherwise fully-keyed map is refused exactly like
    /// a bad scalar (quality-review gap: the map cross-check test above only
    /// exercised valid map values, leaving this path unexercised).
    #[test]
    fn validate_campaign_flags_a_bad_value_inside_an_instrument_map() {
        let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
        doc.cost = vec![CostSpec::Constant {
            cost_per_trade: CostValue::PerInstrument(std::collections::BTreeMap::from([(
                "GER40".to_string(),
                -1.0,
            )])),
        }];
        let faults = validate_campaign(&doc);
        assert!(faults.contains(&DocFault::BadCost { index: 0 }), "{faults:?}");
    }

    #[test]
    fn validate_campaign_accepts_a_valid_cost_section() {
        let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
        doc.cost = vec![CostSpec::Carry { carry_per_cycle: CostValue::Scalar(0.25) }];
        assert!(validate_campaign(&doc).is_empty(), "{:?}", validate_campaign(&doc));
    }

    #[test]
    fn cost_block_is_describable_beside_risk() {
        let block = describe_block("std::cost").expect("std::cost describable");
        assert_eq!(block.slots.len(), 1);
        assert_eq!(block.slots[0].name, "cost");
        assert!(!block.slots[0].required);
        assert_eq!(
            slot_kind_label(block.slots[0].kind),
            "list of cost components { constant: { cost_per_trade } } | { vol_slippage: { slip_vol_mult } } | { carry: { carry_per_cycle } }; absent = zero costs (net == gross)"
        );
    }

    /// The intrinsic tier does NOT constrain where a population-transforming
    /// stage sits relative to the annotators (terminal_seen gates only Gate
    /// stages): `[sweep, monte_carlo, walk_forward]` is intrinsically valid.
    /// Execution-shape refusals are the executor preflight's job, one tier up
    /// — this pin is the boundary between the two tiers.
    #[test]
    fn validate_process_permits_walk_forward_after_an_annotator() {
        let doc = parse_process(
            r#"{ "format_version": 1, "kind": "process", "name": "wf-after-mc",
                 "pipeline": [
                   { "block": "std::sweep", "metric": "sqn", "select": "argmax" },
                   { "block": "std::monte_carlo", "resamples": 100, "block_len": 5 },
                   { "block": "std::walk_forward", "in_sample_ms": 4000, "out_of_sample_ms": 1000,
                     "step_ms": 1000, "mode": "rolling", "metric": "sqn", "select": "argmax" }
                 ] }"#,
        )
        .unwrap();
        assert_eq!(validate_process(&doc), Vec::new());
    }

    #[test]
    fn validate_process_accepts_the_fixture_and_reports_each_fault() {
        let ok = parse_process(PROCESS_FIXTURE).unwrap();
        assert_eq!(validate_process(&ok), Vec::new());

        let empty = ProcessDoc { pipeline: Vec::new(), ..ok.clone() };
        assert_eq!(validate_process(&empty), vec![DocFault::EmptyPipeline]);

        let bad_metric = parse_process(&PROCESS_FIXTURE.replacen(
            "net_expectancy_r\", \"select\": \"plateau:worst",
            "netto_r\", \"select\": \"plateau:worst",
            1,
        ))
        .unwrap();
        assert!(validate_process(&bad_metric)
            .iter()
            .any(|f| matches!(f, DocFault::UnknownMetric { stage: 0, metric } if metric == "netto_r")));

        let gate_first = ProcessDoc {
            pipeline: vec![StageBlock::Gate {
                all: vec![Predicate { metric: "sqn".into(), cmp: Cmp::Gt, value: 1.0 }],
            }],
            ..ok.clone()
        };
        assert!(validate_process(&gate_first).contains(&DocFault::GateFirst));

        let empty_conj = ProcessDoc {
            pipeline: vec![ok.pipeline[0].clone(), StageBlock::Gate { all: Vec::new() }],
            ..ok.clone()
        };
        assert!(validate_process(&empty_conj).contains(&DocFault::EmptyConjunction { stage: 1 }));

        let gate_after = ProcessDoc {
            pipeline: vec![
                ok.pipeline[0].clone(),
                StageBlock::MonteCarlo { resamples: 100, block_len: 5 },
                StageBlock::Gate {
                    all: vec![Predicate { metric: "sqn".into(), cmp: Cmp::Gt, value: 1.0 }],
                },
            ],
            ..ok.clone()
        };
        assert!(validate_process(&gate_after)
            .contains(&DocFault::GateAfterTerminalStage { stage: 2 }));
    }

    /// The intrinsic tier's metric-validation branch for `std::sweep` must
    /// skip a `None` selection cleanly (no metric to resolve, so no spurious
    /// `UnknownMetric`) — the terminal-placement rule itself is the
    /// executor preflight's job, not this validator's.
    #[test]
    fn validate_process_accepts_a_selection_free_sweep() {
        let doc = ProcessDoc {
            format_version: FORMAT_VERSION,
            kind: DocKind::Process,
            name: "sweep-only".to_string(),
            description: None,
            pipeline: vec![StageBlock::Sweep { selection: None }],
        };
        assert_eq!(validate_process(&doc), Vec::new());
    }

    #[test]
    fn walk_forward_parses_both_modes_and_refuses_bad_slots() {
        // "anchored" is the other accepted wire mode
        let anchored = PROCESS_FIXTURE.replacen("\"mode\": \"rolling\"", "\"mode\": \"anchored\"", 1);
        let doc = parse_process(&anchored).expect("anchored parses");
        assert!(matches!(
            &doc.pipeline[2],
            StageBlock::WalkForward { mode: WfMode::Anchored, .. }
        ));
        // an unknown mode string refuses through WfMode's serde oracle
        let bad_mode = PROCESS_FIXTURE.replacen("\"mode\": \"rolling\"", "\"mode\": \"expanding\"", 1);
        let err = parse_process(&bad_mode).expect_err("unknown mode refused");
        assert!(matches!(err, DocError::Malformed(msg) if msg.contains("unknown mode \"expanding\"")));
        // the retired 0106 vocabulary ("folds") is an unknown slot now
        let folds = PROCESS_FIXTURE.replacen("\"in_sample_ms\": 4000", "\"folds\": 4", 1);
        let err = parse_process(&folds).expect_err("folds refused");
        assert!(matches!(err, DocError::Malformed(msg) if msg.contains("unknown slot \"folds\"")));
        // a missing required length slot is named
        let missing = PROCESS_FIXTURE.replacen("\"step_ms\": 1000, ", "", 1);
        let err = parse_process(&missing).expect_err("missing step_ms refused");
        assert!(
            matches!(err, DocError::Malformed(msg) if msg.contains("missing required slot \"step_ms\""))
        );
    }

    #[test]
    fn validate_process_reports_each_zero_walk_forward_length() {
        let ok = parse_process(PROCESS_FIXTURE).unwrap();
        let zeroed = ProcessDoc {
            pipeline: vec![
                ok.pipeline[0].clone(),
                StageBlock::WalkForward {
                    in_sample_ms: 0,
                    out_of_sample_ms: 0,
                    step_ms: 0,
                    mode: WfMode::Rolling,
                    metric: "net_expectancy_r".into(),
                    select: SelectRule::Argmax,
                },
            ],
            ..ok.clone()
        };
        assert_eq!(
            validate_process(&zeroed),
            vec![
                DocFault::ZeroWalkForwardLength { stage: 1, field: "in_sample_ms" },
                DocFault::ZeroWalkForwardLength { stage: 1, field: "out_of_sample_ms" },
                DocFault::ZeroWalkForwardLength { stage: 1, field: "step_ms" },
            ]
        );
        let one = ProcessDoc {
            pipeline: vec![
                ok.pipeline[0].clone(),
                StageBlock::WalkForward {
                    in_sample_ms: 4000,
                    out_of_sample_ms: 1000,
                    step_ms: 0,
                    mode: WfMode::Anchored,
                    metric: "net_expectancy_r".into(),
                    select: SelectRule::Argmax,
                },
            ],
            ..ok.clone()
        };
        assert_eq!(
            validate_process(&one),
            vec![DocFault::ZeroWalkForwardLength { stage: 1, field: "step_ms" }]
        );
    }

    #[test]
    fn validate_campaign_accepts_the_fixture_and_reports_each_fault() {
        let ok = parse_campaign(CAMPAIGN_FIXTURE).unwrap();
        assert_eq!(validate_campaign(&ok), Vec::new());

        let mut bare = ok.clone();
        bare.data.instruments.clear();
        bare.data.windows.clear();
        let faults = validate_campaign(&bare);
        assert!(faults.contains(&DocFault::EmptyInstruments));
        assert!(faults.contains(&DocFault::NoWindow));

        let mut inv = ok.clone();
        inv.data.windows[0] = Window { from_ms: 10, to_ms: 10 };
        assert!(validate_campaign(&inv).contains(&DocFault::BadWindow { index: 0 }));

        let mut none = ok.clone();
        none.strategies.clear();
        assert!(validate_campaign(&none).contains(&DocFault::NoStrategy));

        let mut no_axes = ok.clone();
        no_axes.strategies[0].axes.clear();
        assert!(validate_campaign(&no_axes).contains(&DocFault::EmptyAxes { strategy: 0 }));

        let mut empty_axis = ok.clone();
        empty_axis.strategies[0]
            .axes
            .insert("slow".into(), Axis { kind: ScalarKind::I64, values: Vec::new() });
        assert!(validate_campaign(&empty_axis)
            .contains(&DocFault::EmptyAxis { strategy: 0, axis: "slow".into() }));

        let mut ident = ok.clone();
        ident.process.r#ref = DocRef::IdentityId("aaaa".into());
        assert!(validate_campaign(&ident).contains(&DocFault::ProcessRefMustBeContentId));

        let mut emit = ok.clone();
        emit.presentation.emit.push("pie_chart".into());
        assert!(validate_campaign(&emit).contains(&DocFault::UnknownEmitKind("pie_chart".into())));
    }

    #[test]
    fn a_campaign_with_duplicate_instruments_is_refused() {
        let ok = parse_campaign(CAMPAIGN_FIXTURE).unwrap();
        assert_eq!(validate_campaign(&ok), Vec::new());

        let mut dup = ok.clone();
        let first = dup.data.instruments[0].clone();
        dup.data.instruments.push(first.clone());
        let index = dup.data.instruments.len() - 1;
        assert!(validate_campaign(&dup)
            .contains(&DocFault::DuplicateInstrument { index, instrument: first }));
    }

    /// #201 decision 1 (cycle 0109): the tap namespace is a CLOSED vocabulary —
    /// the wrap convention's four persisted sink names. A genuinely new
    /// observable is a new vocabulary entry (or an authored blueprint sink),
    /// never an open node-path namespace in the document.
    #[test]
    fn tap_vocabulary_is_the_wrap_conventions_four_sink_names() {
        assert_eq!(tap_vocabulary(), ["equity", "exposure", "r_equity", "net_r_equity"]);
    }

    /// Each `presentation.persist_taps` entry outside `tap_vocabulary()` is an
    /// intrinsic fault carrying its index (for path-addressed prose downstream)
    /// and the offending name — mirroring the UnknownEmitKind loop. The
    /// fixture's own "net_r_equity" tap is in-vocabulary, so the clean-fixture
    /// assert in `validate_campaign_accepts_the_fixture_and_reports_each_fault`
    /// stays green unchanged.
    #[test]
    fn validate_campaign_refuses_unknown_taps_by_index() {
        let ok = parse_campaign(CAMPAIGN_FIXTURE).unwrap();
        let mut tap = ok.clone();
        tap.presentation.persist_taps.push("bias".into());
        assert_eq!(
            validate_campaign(&tap),
            vec![DocFault::UnknownTap { index: 1, tap: "bias".into() }]
        );
        // every in-vocabulary name passes
        let mut all = ok.clone();
        all.presentation.persist_taps = tap_vocabulary().iter().map(|t| t.to_string()).collect();
        assert_eq!(validate_campaign(&all), Vec::new());
    }

    /// The campaign `data.bindings` block (#231): serde-ADDITIVE (a
    /// binding-less document serializes with NO "bindings" key — every stored
    /// campaign's content id is byte-stable — and legacy text parses to an
    /// empty map), round-trips when present, and value-checks against the
    /// closed column vocabulary at the intrinsic tier.
    #[test]
    fn bindings_are_serde_additive_and_value_checked() {
        let legacy = format!(
            concat!(
                r#"{{"format_version":1,"kind":"campaign","name":"c","#,
                r#""data":{{"instruments":["GER40"],"windows":[{{"from_ms":1,"to_ms":2}}]}},"#,
                r#""strategies":[{{"ref":{{"content_id":"{id}"}},"axes":{{"len":{{"kind":"I64","values":[2]}}}}}}],"#,
                r#""process":{{"ref":{{"content_id":"{id}"}}}},"#,
                r#""seed":1,"presentation":{{"persist_taps":[],"emit":[]}}}}"#
            ),
            id = "a".repeat(64),
        );
        let doc = parse_campaign(&legacy).expect("legacy campaign parses");
        assert!(doc.data.bindings.is_empty());
        assert!(!campaign_to_json(&doc).contains("bindings"));
        assert!(validate_campaign(&doc).is_empty());

        let with = legacy.replace(
            r#""data":{"instruments""#,
            r#""data":{"bindings":{"price":"open"},"instruments""#,
        );
        assert_ne!(with, legacy, "the fixture splice must hit");
        let doc = parse_campaign(&with).expect("bindings campaign parses");
        assert_eq!(doc.data.bindings.get("price").map(String::as_str), Some("open"));
        assert!(campaign_to_json(&doc).contains(r#""bindings":{"price":"open"}"#));
        assert!(validate_campaign(&doc).is_empty());

        let bad = legacy.replace(
            r#""data":{"instruments""#,
            r#""data":{"bindings":{"price":"hl2"},"instruments""#,
        );
        let doc = parse_campaign(&bad).expect("parses (values are validate-tier)");
        assert_eq!(
            validate_campaign(&doc),
            vec![DocFault::UnknownBindingColumn {
                role: "price".to_string(),
                column: "hl2".to_string()
            }]
        );
    }

    /// The `std::presentation` persist_taps slot advertises the closed tap
    /// vocabulary through the describe path (`describe_block` +
    /// `slot_kind_label`); the label is cross-pinned against `tap_vocabulary()`
    /// so the static label and the validator cannot drift apart.
    #[test]
    fn persist_taps_slot_advertises_the_tap_vocabulary() {
        let block = describe_block("std::presentation").expect("presentation describable");
        let slot = block
            .slots
            .iter()
            .find(|s| s.name == "persist_taps")
            .expect("presentation has a persist_taps slot");
        assert_eq!(slot.kind, SlotKind::TapKinds);
        assert_eq!(
            slot_kind_label(slot.kind),
            format!("list of: {}", tap_vocabulary().join(" | "))
        );
        assert_eq!(
            slot_kind_label(slot.kind),
            "list of: equity | exposure | r_equity | net_r_equity"
        );
    }

    /// A draft missing its `presentation` section is pointed at the section
    /// AND the closed tap vocabulary (the guide never advertises an open
    /// namespace the validator would refuse). The hint is cross-pinned
    /// against `tap_vocabulary()` — like `persist_taps_slot_advertises_the_
    /// tap_vocabulary` above — so a fifth tap cannot leave this draft hint
    /// silently stale.
    #[test]
    fn presentation_open_slot_hint_names_the_tap_vocabulary() {
        let draft = r#"{ "format_version": 1, "kind": "campaign", "name": "draft",
            "data": { "instruments": ["GER40"], "windows": [ { "from_ms": 1, "to_ms": 2 } ] },
            "strategies": [ { "ref": { "content_id": "9f3a" },
                              "axes": { "slow": { "kind": "I64", "values": [10] } } } ],
            "process": { "ref": { "content_id": "4e2d" } },
            "seed": 1 }"#;
        let slots = open_slots_campaign(draft).unwrap();
        let pres = slots
            .iter()
            .find(|s| s.path == "presentation")
            .expect("presentation slot must be open");
        assert_eq!(
            pres.hint,
            format!(
                "required section: persist_taps ({}) + emit",
                tap_vocabulary().join(" | ")
            )
        );
        assert_eq!(
            pres.hint,
            "required section: persist_taps (equity | exposure | r_equity | net_r_equity) + emit"
        );
    }

    /// #256 fork B: the fieldless enumerate-only stage round-trips through
    /// the schema-strict parser, and any slot on it is refused (its slot
    /// list is empty, so the generic unknown-slot check covers every key).
    #[test]
    fn grid_block_round_trips_and_refuses_slots() {
        let json = serde_json::to_string(&StageBlock::Grid).expect("serialize");
        assert_eq!(json, r#"{"block":"std::grid"}"#);
        let back: StageBlock = serde_json::from_str(&json).expect("parse back");
        assert_eq!(back, StageBlock::Grid);
        let err = serde_json::from_str::<StageBlock>(
            r#"{"block":"std::grid","metric":"sqn"}"#,
        )
        .expect_err("a slot on the fieldless std::grid must be refused");
        assert!(err.to_string().contains("unknown slot"), "got: {err}");
    }

    #[test]
    fn vocabularies_enumerate_every_block_with_typed_slots() {
        let ids: Vec<&str> = process_vocabulary().iter().map(|b| b.id).collect();
        assert_eq!(
            ids,
            vec![
                "std::sweep",
                "std::gate",
                "std::walk_forward",
                "std::monte_carlo",
                "std::generalize",
                "std::grid"
            ]
        );
        for b in process_vocabulary().iter().chain(campaign_vocabulary()) {
            assert!(!b.doc.is_empty(), "block {} has no doc line", b.id);
            // std::grid is the one legitimate fieldless block (#256); every
            // other block keeps its documented-slots guard.
            assert!(
                b.id == "std::grid" || !b.slots.is_empty(),
                "block {} has no slots",
                b.id
            );
            for s in b.slots {
                assert!(!slot_kind_label(s.kind).is_empty());
            }
        }
        let sweep = describe_block("std::sweep").expect("sweep describable");
        assert!(sweep.slots.iter().any(|s| s.name == "metric" && !s.required));
        assert!(sweep.slots.iter().any(|s| s.name == "deflate" && !s.required));
        let wf = describe_block("std::walk_forward").expect("walk_forward describable");
        let names: Vec<&str> = wf.slots.iter().map(|s| s.name).collect();
        assert_eq!(
            names,
            vec!["in_sample_ms", "out_of_sample_ms", "step_ms", "mode", "metric", "select"]
        );
        let mode = wf.slots.iter().find(|s| s.name == "mode").expect("mode slot");
        assert_eq!(slot_kind_label(mode.kind), "one of: rolling | anchored");
        assert!(describe_block("std::strategy").is_some());
        assert!(describe_block("std::nope").is_none());
    }

    /// A campaign process reference is content-id-only: `validate_campaign`
    /// refuses an `identity_id` process ref (`ProcessRefMustBeContentId`), and
    /// the block's own doc line says "(content id)". So the `std::process_ref`
    /// describe output — the `describe_block` + `slot_kind_label` composition
    /// the `--block` CLI verb renders — must not advertise an `identity_id`
    /// form the validator would reject. The `--unwired` hint is already
    /// narrowed (see `open_slots_campaign`); this pins the parallel describe
    /// path to the same content-id-only contract. The narrowing is
    /// process-ref-specific: a `std::strategy` ref legitimately accepts either
    /// id form and must keep advertising both.
    #[test]
    fn process_ref_describe_advertises_content_id_only() {
        let block = describe_block("std::process_ref").expect("process_ref describable");
        let ref_slot =
            block.slots.iter().find(|s| s.name == "ref").expect("process_ref has a ref slot");
        let label = slot_kind_label(ref_slot.kind);
        assert!(
            !label.contains("identity_id"),
            "process_ref advertises an identity_id form the validator refuses: {label}"
        );
        assert!(
            label.contains("content"),
            "process_ref describe must still name the content-id form: {label}"
        );

        let strategy = describe_block("std::strategy").expect("strategy describable");
        let strat_ref =
            strategy.slots.iter().find(|s| s.name == "ref").expect("strategy has a ref slot");
        let strat_label = slot_kind_label(strat_ref.kind);
        assert!(
            strat_label.contains("identity_id") && strat_label.contains("content_id"),
            "strategy ref must keep advertising both id forms: {strat_label}"
        );
    }

    #[test]
    fn open_slots_report_partial_documents_by_path() {
        assert_eq!(open_slots_process(PROCESS_FIXTURE).unwrap(), Vec::new());
        // #216/#234: a complete but risk- and cost-less campaign has exactly
        // TWO open slots — the two optional axes, in section order.
        assert_eq!(
            open_slots_campaign(CAMPAIGN_FIXTURE).unwrap(),
            vec![
                open_with_notes(
                    "risk",
                    "optional, list of stop regimes { vol: { length, k } } | { vol_tf: { period_minutes, length, k } }; absent = one default regime",
                    vec![
                        "vol.length smooths the per-cycle vol estimator; it does not set the stop's timescale.".to_string(),
                        "vol.k scales the stop distance (the risk-unit lever).".to_string(),
                        "vol_tf computes the same estimator over completed period_minutes buckets: period_minutes IS the stop's timescale; length smooths in buckets; k scales the distance.".to_string(),
                    ],
                ),
                open_with_notes(
                    "cost",
                    "optional, list of cost components { constant: { cost_per_trade } } | { vol_slippage: { slip_vol_mult } } | { carry: { carry_per_cycle } }; absent = zero costs (net == gross)",
                    vec![
                        "constant.cost_per_trade is in price units, not R (charged per close as cost/|entry-stop| in R).".to_string(),
                        "vol_slippage.slip_vol_mult is a multiplier on the per-cycle vol estimate (charged in R likewise).".to_string(),
                        "carry.carry_per_cycle is in price units per held cycle, not R (accrues over the hold, emitted in R likewise).".to_string(),
                        "each knob takes one number for all instruments, or an instrument-keyed map { \"GER40\": 2.0, ... } covering exactly the campaign's instruments.".to_string(),
                    ],
                ),
            ]
        );

        let partial = r#"{ "format_version": 1, "kind": "process",
            "pipeline": [ { "block": "std::sweep", "metric": "sqn" } ] }"#;
        let slots = open_slots_process(partial).unwrap();
        assert!(slots.iter().any(|s| s.path == "name"));
        assert!(!slots.iter().any(|s| s.path == "pipeline[0].select"));
        assert!(!slots.iter().any(|s| s.path == "pipeline[0].metric"));

        let draft = r#"{ "format_version": 1, "kind": "campaign", "name": "draft",
            "data": { "instruments": ["GER40"], "windows": [ { "from_ms": 1, "to_ms": 2 } ] },
            "strategies": [ { "ref": { "content_id": "9f3a" },
                              "axes": { "slow": { "kind": "I64", "values": [] } } } ],
            "seed": 1,
            "presentation": { "persist_taps": [], "emit": [] } }"#;
        let slots = open_slots_campaign(draft).unwrap();
        assert!(slots.iter().any(|s| s.path == "process.ref"
            && s.hint == "required, content id of a process document"));
        assert!(slots.iter().any(|s| s.path == "strategies[0].axes.slow"
            && s.hint == "axis declared empty — a P1 axis is a non-empty finite set"));

        assert!(matches!(open_slots_process("nope"), Err(DocError::NotJson(_))));
    }

    /// Property: in the open-slot probe, a ref placeholder — `"ref": {}` or
    /// `"ref": null` — is a drafting affordance that reports the SAME open slot
    /// (same path, same existing hint) the omitted-key form reports, on both a
    /// strategy entry and `process.ref`, and never aborts the probe. It lets one
    /// draft file both validate-so-far and enumerate its open slots (fieldtest
    /// 0106 F4 / #195). This pins only the probe; the strict `parse_campaign`
    /// tier keeps refusing placeholders (canonical-form byte stability outranks
    /// draft leniency) and is deliberately not exercised here.
    #[test]
    fn open_slots_report_placeholder_refs_as_open_slots() {
        let draft = r#"{ "format_version": 1, "kind": "campaign", "name": "draft",
            "data": { "instruments": ["GER40"], "windows": [ { "from_ms": 1, "to_ms": 2 } ] },
            "strategies": [ { "ref": null,
                              "axes": { "slow": { "kind": "I64", "values": [10, 20] } } } ],
            "process": { "ref": {} },
            "seed": 1,
            "presentation": { "persist_taps": [], "emit": [] } }"#;
        // Errors on neither placeholder form (the probe stays a probe).
        let slots = open_slots_campaign(draft).expect("placeholder refs must not abort the probe");
        // The `null` strategy ref reports the strategy-ref open slot with its
        // existing both-id-forms hint...
        assert!(
            slots.iter().any(|s| s.path == "strategies[0].ref"
                && s.hint == "required, one of: content_id, identity_id"),
            "strategy `ref: null` placeholder not reported as an open slot: {slots:?}"
        );
        // ...and the `{}` process ref reports the narrower content-id-only hint.
        assert!(
            slots.iter().any(|s| s.path == "process.ref"
                && s.hint == "required, content id of a process document"),
            "process `ref: {{}}` placeholder not reported as an open slot: {slots:?}"
        );
    }

    /// #216/#234: each optional slot is listed only while unbound — binding
    /// risk leaves exactly the cost slot open; binding both closes the probe
    /// (a complete bound document has no open slots at all).
    #[test]
    fn open_slots_omit_bound_risk_and_cost_slots() {
        let with_risk = CAMPAIGN_FIXTURE.replacen(
            "\"seed\": 1,",
            "\"seed\": 1,\n      \"risk\": [ { \"vol\": { \"length\": 3, \"k\": 2.0 } } ],",
            1,
        );
        assert_ne!(with_risk, CAMPAIGN_FIXTURE, "replacen must match the fixture's seed field");
        assert_eq!(
            open_slots_campaign(&with_risk).unwrap(),
            vec![open_with_notes(
                "cost",
                "optional, list of cost components { constant: { cost_per_trade } } | { vol_slippage: { slip_vol_mult } } | { carry: { carry_per_cycle } }; absent = zero costs (net == gross)",
                vec![
                    "constant.cost_per_trade is in price units, not R (charged per close as cost/|entry-stop| in R).".to_string(),
                    "vol_slippage.slip_vol_mult is a multiplier on the per-cycle vol estimate (charged in R likewise).".to_string(),
                    "carry.carry_per_cycle is in price units per held cycle, not R (accrues over the hold, emitted in R likewise).".to_string(),
                    "each knob takes one number for all instruments, or an instrument-keyed map { \"GER40\": 2.0, ... } covering exactly the campaign's instruments.".to_string(),
                ],
            )]
        );
        let with_both = CAMPAIGN_FIXTURE.replacen(
            "\"seed\": 1,",
            "\"seed\": 1,\n      \"risk\": [ { \"vol\": { \"length\": 3, \"k\": 2.0 } } ],\n      \"cost\": [ { \"constant\": { \"cost_per_trade\": 2.0 } } ],",
            1,
        );
        assert_ne!(with_both, CAMPAIGN_FIXTURE, "replacen must match the fixture's seed field");
        assert_eq!(open_slots_campaign(&with_both).unwrap(), Vec::new());
    }

    #[test]
    fn sweep_selection_group_is_all_or_nothing() {
        let free = serde_json::json!({ "block": "std::sweep" });
        assert_eq!(
            stage_from_value(&free).unwrap(),
            StageBlock::Sweep { selection: None }
        );
        let metric_only = serde_json::json!({ "block": "std::sweep", "metric": "sqn" });
        let err = stage_from_value(&metric_only).unwrap_err();
        assert!(err.contains("all-or-nothing"), "{err}");
        let select_only = serde_json::json!({ "block": "std::sweep", "select": "argmax" });
        let err = stage_from_value(&select_only).unwrap_err();
        assert!(err.contains("all-or-nothing"), "{err}");
        let deflate_only = serde_json::json!({ "block": "std::sweep", "deflate": true });
        let err = stage_from_value(&deflate_only).unwrap_err();
        assert!(err.contains("needs the selection group"), "{err}");
    }

    #[test]
    fn selection_free_sweep_round_trips_and_hashes_distinctly() {
        let doc = ProcessDoc {
            format_version: FORMAT_VERSION,
            kind: DocKind::Process,
            name: "sweep".to_string(),
            description: None,
            pipeline: vec![StageBlock::Sweep { selection: None }],
        };
        let text = process_to_json(&doc);
        // the absent group leaves ONLY the block id on the wire
        assert!(text.contains("\"block\":\"std::sweep\""), "{text}");
        assert!(!text.contains("metric"), "{text}");
        assert!(!text.contains("select"), "{text}");
        let back = parse_process(&text).expect("selection-free sweep parses");
        assert_eq!(back, doc);
        // a selected sibling hashes differently
        let selected = ProcessDoc {
            pipeline: vec![StageBlock::Sweep {
                selection: Some(SweepSelection {
                    metric: "sqn_normalized".to_string(),
                    select: SelectRule::Argmax,
                    deflate: false,
                }),
            }],
            ..doc.clone()
        };
        assert_ne!(content_id_of(&text), content_id_of(&process_to_json(&selected)));
    }
}
