//! 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::{doc_gate, DocGateFault, 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, pub pipeline: Vec, } /// 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, }, #[serde(rename = "std::gate")] Gate { all: Vec }, #[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: ` `. #[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, key: &str, block: &str, ) -> Result { 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, key: &str, block: &str, ) -> Result { 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, key: &str, block: &str, ) -> Result { 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, key: &str, block: &str, ) -> Result { 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, block: &str, ) -> Result { let s = require_str(m, "select", block)?; // SelectRule's own derived Deserialize (which carries the #[serde(rename)] // wire strings) is the type oracle for *parsing*, mirroring // kind_tag/scalar_from_bare. The refusal's roster text below is sourced // from slot_kind_label(SlotKind::SelectRule) rather than a second // hardcoded copy of the three wire strings: CLI describe output and the // document refusal stay in sync by construction (add a SelectRule // variant, update one string). serde_json::from_value(serde_json::Value::String(s.clone())).map_err(|_| { let roster = slot_kind_label(SlotKind::SelectRule).trim_start_matches("select rule: "); format!("block {block}: unknown select rule \"{s}\" (accepted: {roster})") }) } fn mode_from( m: &serde_json::Map, block: &str, ) -> Result { 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 { 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 = 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: D) -> Result { 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 { 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, 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, /// 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, pub strategies: Vec, 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, pub windows: Vec, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub bindings: BTreeMap, } /// 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, } /// 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, } /// 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), } 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 { 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 { 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(&self, s: S) -> Result { match self { CostValue::Scalar(v) => v.serialize(s), CostValue::PerInstrument(m) => m.serialize(s), } } } impl<'de> Deserialize<'de> for CostValue { fn deserialize>(d: D) -> Result { use serde::de::Error as _; #[derive(Deserialize)] #[serde(untagged)] enum Raw { Scalar(f64), PerInstrument(std::collections::BTreeMap), } 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 { 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(&self, s: S) -> Result { use serde::ser::SerializeStruct; let mut st = s.serialize_struct("Axis", 2)?; st.serialize_field("kind", &self.kind)?; let bare: Vec = self.values.iter().map(scalar_to_bare).collect(); st.serialize_field("values", &bare)?; st.end() } } impl<'de> Deserialize<'de> for Axis { fn deserialize>(d: D) -> Result { use serde::de::Error as _; #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct Raw { kind: ScalarKind, values: Vec, } let raw = Raw::deserialize(d)?; let values = raw .values .iter() .map(|v| scalar_from_bare(raw.kind, v)) .collect::, _>>() .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, pub emit: Vec, } /// Parse a campaign document from text (envelope + strict fields). pub fn parse_campaign(text: &str) -> Result { 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). // --------------------------------------------------------------------------- /// A shipped metric with its one-line meaning (C29). One carrier — the /// #190 guard keeps this list, the RMetrics serde fields, and the CLI /// count in lockstep; the doc column adds no fourth roster site. pub struct MetricSchema { pub id: &'static str, pub doc: &'static str, } /// 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 (this list stays a hand-copy by /// the crate's ratified core-only isolation; #147 single-sourced the rankable /// roster elsewhere and the extended #190 guard pins this list's nesting). pub fn metric_vocabulary() -> &'static [MetricSchema] { &[ MetricSchema { id: "expectancy_r", doc: "mean realized R per closed trade — the headline E[R]" }, MetricSchema { id: "win_rate", doc: "fraction of closed trades with positive realized R" }, MetricSchema { id: "avg_win_r", doc: "mean realized R over winning trades" }, MetricSchema { id: "avg_loss_r", doc: "mean realized R over losing trades" }, MetricSchema { id: "profit_factor", doc: "gross winning R divided by absolute gross losing R" }, MetricSchema { id: "max_r_drawdown", doc: "deepest peak-to-trough decline of the cumulative R curve" }, MetricSchema { id: "sqn", doc: "System Quality Number: mean R over its std deviation, scaled by trade count" }, MetricSchema { id: "sqn_normalized", doc: "SQN normalized to a 100-trade basis for n-independent comparison" }, MetricSchema { id: "net_expectancy_r", doc: "expectancy net of the cost model's per-trade cost in R" }, MetricSchema { id: "n_trades", doc: "count of closed trades in the run" }, MetricSchema { id: "n_open_at_end", doc: "positions still open when the run ends" }, MetricSchema { id: "total_pips", doc: "summed pip result over all closed trades" }, MetricSchema { id: "max_drawdown", doc: "deepest peak-to-trough decline of the pip equity curve" }, MetricSchema { id: "bias_sign_flips", doc: "count of sign changes in the strategy's bias stream" }, MetricSchema { id: "deflated_score", doc: "selection score deflated for multiple testing against the bootstrap null" }, MetricSchema { id: "overfit_probability", doc: "probability the selected edge is overfit, from the bootstrap null" }, MetricSchema { id: "neighbourhood_score", doc: "plateau robustness: score aggregated over the param-space neighbourhood" }, ] } /// The closed v1 emit vocabulary (data-level presentation outputs). pub fn emit_vocabulary() -> &'static [&'static str] { &["family_table", "selection_report"] } /// A closed-vocabulary persisted-tap slot with its one-line meaning (C29). pub struct TapSchema { pub id: &'static str, pub doc: &'static str, } /// 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 [TapSchema] { &[ TapSchema { id: "equity", doc: "cumulative pip equity per cycle" }, TapSchema { id: "exposure", doc: "signed position exposure per cycle" }, TapSchema { id: "r_equity", doc: "cumulative gross R per cycle" }, TapSchema { id: "net_r_equity", doc: "cumulative net R per cycle, after the cost model" }, ] } /// 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, extra: Vec }, NoStrategy, EmptyAxes { strategy: usize }, EmptyAxis { strategy: usize, axis: String }, UnknownEmitKind(String), UnknownTap { index: usize, tap: String }, UnknownBindingColumn { role: String, column: String }, ProcessRefMustBeContentId, /// C29 (#316): a document's optional top-level `description` is present /// but fails the shape gate. Absent is never a fault. BadDescription { subject: String, fault: DocGateFault }, } fn is_known_metric(name: &str) -> bool { metric_vocabulary().iter().any(|m| m.id == name) } /// Intrinsic validation of a process document (P1 constraints included). /// Returns ALL findings, not the first. pub fn validate_process(doc: &ProcessDoc) -> Vec { let mut faults = Vec::new(); if let Some(desc) = &doc.description && let Err(fault) = doc_gate(&doc.name, desc) { faults.push(DocFault::BadDescription { subject: doc.name.clone(), fault }); } 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 { let mut faults = Vec::new(); if let Some(desc) = &doc.description && let Err(fault) = doc_gate(&doc.name, desc) { faults.push(DocFault::BadDescription { subject: doc.name.clone(), fault }); } 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 = doc .data .instruments .iter() .filter(|inst| !keys.contains(&inst.as_str())) .cloned() .collect(); let extra: Vec = 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().iter().any(|k| k.id == 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 }: 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, } fn open(path: impl Into, hint: impl Into) -> 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, hint: impl Into, notes: Vec) -> 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, 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, 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. // The tagged { content_id } shape is stated explicitly (#329) so the // hint cannot be misread as accepting a bare id string. slots.push(open("process.ref", "required, { content_id }: 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().iter().map(|t| t.id).collect::>().join(" | ") ), )); } Ok(slots) } fn envelope_open_slots(v: &serde_json::Value, kind: &str, slots: &mut Vec) { 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}\""))); } // C29 (#323): the description slot is enumerable like every other // envelope slot — optional, so only listed while absent. if v.get("description").is_none() { slots.push(open( "description", "optional, string — a one-line meaning (C29-gated when present)", )); } } #[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(_)))); } /// #301 (fieldtest cycle-300 df_3): the unknown-select-rule refusal must /// teach the accepted vocabulary, not just refuse — an author who learned /// bare `plateau` at the CLI pastes it into a document and needs the /// roster (argmax | plateau:mean | plateau:worst) in the refusal prose to /// self-serve the fix. The schema stays strict: bare "plateau" remains /// refused in documents (#300 F7 — it is a CLI-only argv alias), and the /// prose stays sparse — the roster only, no alias-bridging sentence. #[test] fn unknown_select_rule_refusal_enumerates_accepted_rules() { let bare_plateau = PROCESS_FIXTURE.replacen("plateau:worst", "plateau", 1); let err = parse_process(&bare_plateau).expect_err("bare plateau stays refused in documents"); let DocError::Malformed(msg) = err else { panic!("expected DocError::Malformed, got {err:?}"); }; // The existing contract survives: the offending value stays named, quoted. assert!( msg.contains("unknown select rule \"plateau\""), "offending value no longer named: {msg}" ); // The new behaviour: the refusal enumerates the accepted rules. for accepted in ["argmax", "plateau:mean", "plateau:worst"] { assert!( msg.contains(accepted), "accepted rule {accepted:?} not enumerated in the refusal: {msg}" ); } // Precise and sparse: the roster alone rescues the paste case — no // alias-hint sentence bridging plateau -> plateau:mean. assert!(!msg.contains("alias"), "refusal grew an alias hint: {msg}"); } /// The refusal derives its roster from slot_kind_label by trimming the /// "select rule: " prefix. That trim is a positional contract: if the /// label's wording ever drifts, trim_start_matches degrades to a no-op /// and the refusal grows a redundant prefix without any test noticing. /// This pin makes that drift loud. #[test] fn select_rule_label_keeps_the_prefix_the_refusal_trims() { assert!( slot_kind_label(SlotKind::SelectRule).starts_with("select rule: "), "slot_kind_label(SelectRule) no longer starts with \"select rule: \" — \ update select_from's roster derivation alongside the label" ); } pub(crate) const CAMPAIGN_FIXTURE: &str = r#"{ "format_version": 1, "kind": "campaign", "name": "ger40-momentum-screen", "description": "GER40 momentum screen over the fast/slow SMA grid.", "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::(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::(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()); } /// C29 (#316): a present document description passes the shape gate; /// absent is never a fault. Both fault arms fire and carry the doc name. #[test] fn document_description_gate_flags_empty_and_restatement() { let base = r#"{"format_version":1,"kind":"process","name":"p","pipeline":[{"block":"std::generalize","metric":"sqn"}]}"#; let mut doc: ProcessDoc = serde_json::from_str(base).expect("fixture parses"); assert!(validate_process(&doc).is_empty(), "no description → no fault"); doc.description = Some(" ".into()); assert!(matches!( validate_process(&doc).as_slice(), [DocFault::BadDescription { subject, fault: DocGateFault::Empty }] if subject == "p" )); doc.description = Some("P".into()); assert!(matches!( validate_process(&doc).as_slice(), [DocFault::BadDescription { fault: DocGateFault::RestatesName, .. }] )); doc.description = Some("walk-forward validation methodology".into()); assert!(validate_process(&doc).is_empty(), "described → clean"); } /// The campaign side takes the identical gate, exercised over the /// crate's existing valid campaign fixture (`CAMPAIGN_FIXTURE`, which /// validates clean — pinned by `validate_campaign_accepts_a_valid_risk_section`). #[test] fn campaign_description_gate_mirrors_process() { let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap(); assert!(validate_campaign(&doc).is_empty(), "fixture validates clean"); doc.description = Some("".into()); assert!(matches!( validate_campaign(&doc).as_slice(), [DocFault::BadDescription { fault: DocGateFault::Empty, .. }] )); doc.description = Some("GER40 walk-forward over the momentum stack".into()); assert!(validate_campaign(&doc).is_empty(), "described → clean"); } /// C29 (#316): a present description participates in the content id /// (absent ⇒ byte-identical is pinned by the existing round-trips). #[test] fn document_description_participates_in_the_content_id() { let base = r#"{"format_version":1,"kind":"process","name":"p","pipeline":[{"block":"std::generalize","metric":"sqn"}]}"#; let bare: ProcessDoc = serde_json::from_str(base).unwrap(); let mut described: ProcessDoc = serde_json::from_str(base).unwrap(); described.description = Some("walk-forward validation methodology".into()); let bare_id = content_id_of(&process_to_json(&bare)); let described_id = content_id_of(&process_to_json(&described)); assert_ne!(bare_id, described_id, "description must move the content id"); } #[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() { let ids: Vec<&str> = tap_vocabulary().iter().map(|t| t.id).collect(); assert_eq!(ids, ["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.id.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().iter().map(|t| t.id).collect::>().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().iter().map(|t| t.id).collect::>().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::( 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}" ); // #329: the label must state the TAGGED shape ({ content_id: ... }) the // parser actually requires, not read like a bare id string — mirroring // the `{ content_id } | { identity_id }` bracket notation std::strategy // already uses, narrowed to the one key process.ref accepts. assert!( label.contains("{ content_id }"), "process_ref describe must state the tagged {{ content_id }} shape, not a bare id: {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 }: 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 }: 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))); } }