Files
Aura/crates/aura-research/src/lib.rs
T
claude 73ad87b08a fix(aura-research): process.ref hint states the tagged { content_id } shape
campaign introspect (--block std::process_ref and --unwired) described
the slot as "content id of a process document" — a plausible bare-string
reading the validator rejects; the required {"content_id":"..."} tagging
appeared in no introspection output (binary-only M1 fieldtest finding).

The SlotKind::ContentRef label and the open-slot hint now lead with the
tagged { content_id } shape. Deliberately narrower than the issue's
suggestion of mirroring std::strategy's { content_id } | { identity_id }
form: validate_campaign refuses an identity_id process ref
(DocFault::ProcessRefMustBeContentId) and a pinned test forbids
advertising it, so the hint states the one accepted key only (C29
honesty; correction minuted on the issue). RED-first: the extended
process_ref_describe_advertises_content_id_only assertion failed against
the old label before the fix.

closes #329
2026-07-24 16:02:45 +02:00

2565 lines
114 KiB
Rust

//! 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<String>,
pub pipeline: Vec<StageBlock>,
}
/// One pipeline stage. Serialize derives the internally-tagged wire form;
/// Deserialize is hand-rolled (schema-strict) because serde's
/// `deny_unknown_fields` does not compose with `#[serde(tag = ...)]`.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(tag = "block")]
pub enum StageBlock {
#[serde(rename = "std::sweep")]
Sweep {
/// The selection triple is one optional group: `None` is a
/// selection-free sweep (permitted only as the terminal stage — the
/// executor's preflight rule), `Some` carries metric+select(+deflate).
/// Flattened so the wire form stays the flat `metric`/`select`/
/// `deflate` slots and every existing document's canonical bytes —
/// and content id — are unchanged.
#[serde(flatten, skip_serializing_if = "Option::is_none")]
selection: Option<SweepSelection>,
},
#[serde(rename = "std::gate")]
Gate { all: Vec<Predicate> },
#[serde(rename = "std::walk_forward")]
WalkForward {
in_sample_ms: u64,
out_of_sample_ms: u64,
step_ms: u64,
mode: WfMode,
metric: String,
select: SelectRule,
},
#[serde(rename = "std::monte_carlo")]
MonteCarlo { resamples: u32, block_len: u32 },
#[serde(rename = "std::generalize")]
Generalize { metric: String },
/// Enumerate-only leading stage (#256 fork B): yields the axis grid's
/// parameter points to the next stage; executes and persists nothing.
/// Fieldless — the axes live in the campaign document.
#[serde(rename = "std::grid")]
Grid,
}
fn is_false(b: &bool) -> bool {
!*b
}
/// The sweep stage's selection group — all-or-nothing (a half-populated
/// group is unrepresentable; the schema-strict parser refuses it).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SweepSelection {
pub metric: String,
pub select: SelectRule,
#[serde(default, skip_serializing_if = "is_false")]
pub deflate: bool,
}
/// Winner-selection rule; wire strings mirror the CLI `--select` values.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum SelectRule {
#[serde(rename = "argmax")]
Argmax,
#[serde(rename = "plateau:mean")]
PlateauMean,
#[serde(rename = "plateau:worst")]
PlateauWorst,
}
/// Walk-forward roll mode — wire form "rolling" | "anchored" (the two shipped
/// engine `RollMode`s: fixed-length rolling in-sample vs anchored-origin
/// growing in-sample).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub enum WfMode {
#[serde(rename = "rolling")]
Rolling,
#[serde(rename = "anchored")]
Anchored,
}
/// One typed gate predicate: `<metric> <cmp> <value>`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Predicate {
pub metric: String,
pub cmp: Cmp,
pub value: f64,
}
/// Closed comparator set (no equality on floats).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Cmp {
Gt,
Ge,
Lt,
Le,
}
// ---------------------------------------------------------------------------
// Block schema tables — the single source for slot strictness (unknown-slot
// rejection in `stage_from_value`) AND the introspection contract (the 0105
// roster lesson). Block *ids* still need declaring at each of the enum
// `#[serde(rename)]`, `PROCESS_BLOCKS.id`, and the `stage_from_value` match
// arms — Rust's static enum representation doesn't let a single runtime
// table drive both the type and the parse dispatch.
// ---------------------------------------------------------------------------
/// Slot value kind, for schema description and CLI presentation.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SlotKind {
MetricName,
SelectRule,
/// Walk-forward roll mode (`WfMode` wire strings).
WfMode,
Bool,
U32,
U64,
Predicates,
Strings,
Windows,
Ref,
/// A content-id-only reference (the process ref: `validate_campaign`
/// refuses an identity id there, so the describe path must not offer one).
ContentRef,
Axes,
EmitKinds,
/// The closed tap vocabulary (`tap_vocabulary()` entries).
TapKinds,
Regimes,
Costs,
}
/// One typed slot of a block.
pub struct SlotInfo {
pub name: &'static str,
pub kind: SlotKind,
pub required: bool,
}
/// One block of a document vocabulary: id, one-line doc, typed slots.
pub struct BlockSchema {
pub id: &'static str,
pub doc: &'static str,
pub slots: &'static [SlotInfo],
}
/// The closed v1 process-stage vocabulary (std set; ids are namespaced).
pub const PROCESS_BLOCKS: &[BlockSchema] = &[
BlockSchema {
id: "std::sweep",
doc: "evaluate the campaign's axes-space; reduce members to R metrics; \
optionally select a winner (the selection group metric+select is \
all-or-nothing; omitted = selection-free, terminal-stage-only)",
slots: &[
SlotInfo { name: "metric", kind: SlotKind::MetricName, required: false },
SlotInfo { name: "select", kind: SlotKind::SelectRule, required: false },
SlotInfo { name: "deflate", kind: SlotKind::Bool, required: false },
],
},
BlockSchema {
id: "std::gate",
doc: "filter survivors by a conjunction of typed metric predicates",
slots: &[SlotInfo { name: "all", kind: SlotKind::Predicates, required: true }],
},
BlockSchema {
id: "std::walk_forward",
doc: "rolling in-sample optimize + out-of-sample test",
slots: &[
SlotInfo { name: "in_sample_ms", kind: SlotKind::U64, required: true },
SlotInfo { name: "out_of_sample_ms", kind: SlotKind::U64, required: true },
SlotInfo { name: "step_ms", kind: SlotKind::U64, required: true },
SlotInfo { name: "mode", kind: SlotKind::WfMode, required: true },
SlotInfo { name: "metric", kind: SlotKind::MetricName, required: true },
SlotInfo { name: "select", kind: SlotKind::SelectRule, required: true },
],
},
BlockSchema {
id: "std::monte_carlo",
doc: "R-bootstrap over realised R (terminal annotator): after a \
walk_forward, ONE bootstrap over its pooled OOS trade series; \
otherwise one per surviving member; both slots must be > 0 to \
execute",
slots: &[
SlotInfo { name: "resamples", kind: SlotKind::U32, required: true },
SlotInfo { name: "block_len", kind: SlotKind::U32, required: true },
],
},
BlockSchema {
id: "std::generalize",
doc: "cross-instrument worst-case floor (terminal annotator): runs at \
campaign scope, per (strategy, window) over the cells' nominees; \
needs >= 2 instruments and an R-expectancy metric to execute",
slots: &[SlotInfo { name: "metric", kind: SlotKind::MetricName, required: true }],
},
BlockSchema {
id: "std::grid",
doc: "enumerate the axis grid as parameter points for the next stage \
(enumerate-only leading stage): executes and persists nothing; \
legal only as the first stage, immediately before \
std::walk_forward",
slots: &[],
},
];
// ---------------------------------------------------------------------------
// Strict stage parsing (schema-driven).
// ---------------------------------------------------------------------------
fn require_str(
m: &serde_json::Map<String, serde_json::Value>,
key: &str,
block: &str,
) -> Result<String, String> {
match m.get(key) {
Some(serde_json::Value::String(s)) => Ok(s.clone()),
Some(_) => Err(format!("block {block}: slot \"{key}\" must be a string")),
None => Err(format!("block {block}: missing required slot \"{key}\"")),
}
}
fn require_u64(
m: &serde_json::Map<String, serde_json::Value>,
key: &str,
block: &str,
) -> Result<u64, String> {
match m.get(key) {
Some(v) => v
.as_u64()
.ok_or_else(|| format!("block {block}: slot \"{key}\" must be a non-negative integer")),
None => Err(format!("block {block}: missing required slot \"{key}\"")),
}
}
fn require_u32(
m: &serde_json::Map<String, serde_json::Value>,
key: &str,
block: &str,
) -> Result<u32, String> {
let v = require_u64(m, key, block)?;
u32::try_from(v).map_err(|_| format!("block {block}: slot \"{key}\" out of range"))
}
fn optional_bool(
m: &serde_json::Map<String, serde_json::Value>,
key: &str,
block: &str,
) -> Result<bool, String> {
match m.get(key) {
None => Ok(false),
Some(serde_json::Value::Bool(b)) => Ok(*b),
Some(_) => Err(format!("block {block}: slot \"{key}\" must be a bool")),
}
}
fn select_from(
m: &serde_json::Map<String, serde_json::Value>,
block: &str,
) -> Result<SelectRule, String> {
let s = require_str(m, "select", block)?;
// SelectRule's own derived Deserialize (which carries the #[serde(rename)]
// wire strings) is the type oracle 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<String, serde_json::Value>,
block: &str,
) -> Result<WfMode, String> {
let s = require_str(m, "mode", block)?;
// WfMode's own derived Deserialize (which carries the #[serde(rename)]
// wire strings) is the type oracle, mirroring select_from: no second
// hardcoded copy of the two roll-mode wire strings.
serde_json::from_value(serde_json::Value::String(s.clone()))
.map_err(|_| format!("block {block}: unknown mode \"{s}\""))
}
fn stage_from_value(v: &serde_json::Value) -> Result<StageBlock, String> {
let m = v
.as_object()
.ok_or_else(|| "a pipeline stage must be an object".to_string())?;
let id = match m.get("block") {
Some(serde_json::Value::String(s)) => s.clone(),
_ => return Err("a pipeline stage must carry a string \"block\" id".to_string()),
};
let schema = PROCESS_BLOCKS
.iter()
.find(|b| b.id == id)
.ok_or_else(|| format!("unknown block \"{id}\""))?;
for key in m.keys() {
if key != "block" && !schema.slots.iter().any(|s| s.name == key) {
return Err(format!("block {id}: unknown slot \"{key}\""));
}
}
match id.as_str() {
"std::sweep" => {
let has_metric = m.contains_key("metric");
let has_select = m.contains_key("select");
let selection = match (has_metric, has_select) {
(true, true) => Some(SweepSelection {
metric: require_str(m, "metric", &id)?,
select: select_from(m, &id)?,
deflate: optional_bool(m, "deflate", &id)?,
}),
(false, false) => {
if m.contains_key("deflate") {
return Err(format!(
"block {id}: slot \"deflate\" needs the selection \
group (\"metric\" + \"select\")"
));
}
None
}
(true, false) => {
return Err(format!(
"block {id}: slot \"metric\" without \"select\" — the \
selection group is all-or-nothing"
));
}
(false, true) => {
return Err(format!(
"block {id}: slot \"select\" without \"metric\" — the \
selection group is all-or-nothing"
));
}
};
Ok(StageBlock::Sweep { selection })
}
"std::gate" => {
let all = m
.get("all")
.ok_or_else(|| format!("block {id}: missing required slot \"all\""))?;
let all: Vec<Predicate> = serde_json::from_value(all.clone())
.map_err(|e| format!("block {id}: bad predicate list: {e}"))?;
Ok(StageBlock::Gate { all })
}
"std::walk_forward" => Ok(StageBlock::WalkForward {
in_sample_ms: require_u64(m, "in_sample_ms", &id)?,
out_of_sample_ms: require_u64(m, "out_of_sample_ms", &id)?,
step_ms: require_u64(m, "step_ms", &id)?,
mode: mode_from(m, &id)?,
metric: require_str(m, "metric", &id)?,
select: select_from(m, &id)?,
}),
"std::monte_carlo" => Ok(StageBlock::MonteCarlo {
resamples: require_u32(m, "resamples", &id)?,
block_len: require_u32(m, "block_len", &id)?,
}),
"std::generalize" => Ok(StageBlock::Generalize {
metric: require_str(m, "metric", &id)?,
}),
"std::grid" => Ok(StageBlock::Grid),
_ => unreachable!("schema membership checked above"),
}
}
impl<'de> Deserialize<'de> for StageBlock {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = serde_json::Value::deserialize(d)?;
stage_from_value(&v).map_err(serde::de::Error::custom)
}
}
// ---------------------------------------------------------------------------
// Parse (envelope-checked).
// ---------------------------------------------------------------------------
/// Parse-level refusals. By-identifier and Display-free: the CLI phrases them.
#[derive(Clone, Debug, PartialEq)]
pub enum DocError {
NotJson(String),
BadFormatVersion(u32),
WrongKind { expected: DocKind },
Malformed(String),
}
fn check_envelope(v: &serde_json::Value, expected: DocKind) -> Result<(), DocError> {
let fv = v.get("format_version").and_then(|x| x.as_u64());
if fv != Some(u64::from(FORMAT_VERSION)) {
return Err(DocError::BadFormatVersion(fv.unwrap_or(0) as u32));
}
let want = match expected {
DocKind::Process => "process",
DocKind::Campaign => "campaign",
};
match v.get("kind").and_then(|k| k.as_str()) {
Some(k) if k == want => Ok(()),
_ => Err(DocError::WrongKind { expected }),
}
}
/// Parse a process document from text (envelope + strict vocabulary).
pub fn parse_process(text: &str) -> Result<ProcessDoc, DocError> {
let v: serde_json::Value =
serde_json::from_str(text).map_err(|e| DocError::NotJson(e.to_string()))?;
check_envelope(&v, DocKind::Process)?;
serde_json::from_value(v).map_err(|e| DocError::Malformed(e.to_string()))
}
/// A campaign document: persisted experiment intent.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CampaignDoc {
pub format_version: u32,
pub kind: DocKind,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub data: DataSection,
/// Structural risk-execution axis: the finite list of stop regimes the
/// matrix runs each cell under. Absent or empty = one implicit default
/// regime (the runtime's baked constants). Absent-serializes → content-id
/// parity for every risk-less document (the `description` precedent).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub risk: Vec<RiskRegime>,
/// The campaign's cost model (#234): additive components (C10) every
/// member runs under. Absent or empty = zero costs (`summarize_r`'s
/// empty-slice baseline: net == gross). Absent-serializes → content-id
/// parity for every cost-less document (the `risk` precedent above).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cost: Vec<CostSpec>,
pub strategies: Vec<StrategyEntry>,
pub process: ProcessRef,
pub seed: u64,
pub presentation: Presentation,
}
/// Structural data axes: which instruments over which windows, plus the
/// optional per-campaign input-binding overrides (role name -> column name,
/// #231 — the campaign rebind seam: re-aim a strategy's input roles without
/// touching its blueprint's content identity). Additive on the
/// `description`/`risk` precedent: absent-serializes, so binding-less
/// documents keep their content ids.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DataSection {
pub instruments: Vec<String>,
pub windows: Vec<Window>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub bindings: BTreeMap<String, String>,
}
/// One data window, inclusive epoch-ms bounds (the CLI --from/--to form).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Window {
pub from_ms: i64,
pub to_ms: i64,
}
/// A strategy under test: an opaque id handle + its tuning axes.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StrategyEntry {
#[serde(rename = "ref")]
pub r#ref: DocRef,
pub axes: BTreeMap<String, Axis>,
}
/// One tuning axis: the parameter's kind, declared ONCE, plus a finite list
/// of bare JSON values parsed as that kind. A mixed-kind axis is
/// unrepresentable by construction (the kind is a property of the
/// parameter, not of each value — user decision 2026-07-03, #189).
#[derive(Clone, Debug, PartialEq)]
pub struct Axis {
pub kind: ScalarKind,
pub values: Vec<Scalar>,
}
/// A protective-stop regime: a serializable, content-addressable mirror of the
/// runtime `StopRule` structural axis (C10/C20). The sole implemented variant is
/// the vol-stop; the shipped fixed-stop rule is admitted as a future additive
/// variant (it runs as a composite today but is not yet campaign-reachable).
/// Externally tagged so adding `Fixed` is additive — no content-id churn on
/// stored `Vol` regimes.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub enum RiskRegime {
Vol { length: i64, k: f64 },
VolTf { period_minutes: i64, length: i64, k: f64 },
}
/// One component of the campaign's cost model (#234): a closed, additive
/// vocabulary over the shipped C10 cost nodes — components sum (`CostSum`).
/// Serde field names are the builders' own `ParamSpec` names
/// (`constant_cost.rs` / `vol_slippage_cost.rs` / `carry_cost.rs`), so the doc
/// vocabulary conforms to the node vocabulary. Externally tagged so a future
/// component is additive — no content-id churn on stored components.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub enum CostSpec {
Constant { cost_per_trade: CostValue },
VolSlippage { slip_vol_mult: CostValue },
Carry { carry_per_cycle: CostValue },
}
/// One cost knob's value (#260): one number for every cell, or an
/// instrument-keyed map resolved per cell at member construction. Custom
/// serde keeps the scalar form byte-identical to the historical bare number
/// (content-id parity for stored docs — the `Axis` precedent); the map form
/// is a plain JSON object of numbers (BTreeMap: deterministic key order).
#[derive(Clone, Debug, PartialEq)]
pub enum CostValue {
Scalar(f64),
PerInstrument(std::collections::BTreeMap<String, f64>),
}
impl CostValue {
/// The per-cell value: the scalar, or the instrument's map entry. `None`
/// only for an uncovered instrument — unreachable after intrinsic
/// validation; callers refuse with prose rather than defaulting.
pub fn resolve(&self, instrument: &str) -> Option<f64> {
match self {
CostValue::Scalar(v) => Some(*v),
CostValue::PerInstrument(m) => m.get(instrument).copied(),
}
}
/// Every numeric value the validator finiteness/sign-checks.
pub fn values(&self) -> Vec<f64> {
match self {
CostValue::Scalar(v) => vec![*v],
CostValue::PerInstrument(m) => m.values().copied().collect(),
}
}
/// The map's key set (empty for a scalar) — the validate cross-check input.
pub fn instrument_keys(&self) -> Vec<&str> {
match self {
CostValue::Scalar(_) => Vec::new(),
CostValue::PerInstrument(m) => m.keys().map(String::as_str).collect(),
}
}
}
impl Serialize for CostValue {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
match self {
CostValue::Scalar(v) => v.serialize(s),
CostValue::PerInstrument(m) => m.serialize(s),
}
}
}
impl<'de> Deserialize<'de> for CostValue {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
use serde::de::Error as _;
#[derive(Deserialize)]
#[serde(untagged)]
enum Raw {
Scalar(f64),
PerInstrument(std::collections::BTreeMap<String, f64>),
}
match Raw::deserialize(d)? {
Raw::Scalar(v) => Ok(CostValue::Scalar(v)),
Raw::PerInstrument(m) if m.is_empty() => {
Err(D::Error::custom("instrument map is empty"))
}
Raw::PerInstrument(m) => Ok(CostValue::PerInstrument(m)),
}
}
}
/// Bare wire value of one Scalar (strips the tagged form via Scalar's own
/// serde as the oracle — no Scalar internals assumed).
fn scalar_to_bare(s: &Scalar) -> serde_json::Value {
match serde_json::to_value(s).expect("scalar serializes") {
serde_json::Value::Object(m) => m.into_values().next().expect("tagged form has one value"),
v => v,
}
}
/// The wire tag of a `ScalarKind`, using `ScalarKind`'s own serde as the
/// oracle (mirrors `scalar_to_bare`: no wire shape assumed, only asked of
/// serde) rather than re-hardcoding the four tag strings as a second source
/// of truth for `Scalar`'s variant names.
fn kind_tag(kind: ScalarKind) -> String {
match serde_json::to_value(kind).expect("scalar kind serializes") {
serde_json::Value::String(s) => s,
other => unreachable!("ScalarKind serializes to a string, got {other}"),
}
}
/// Parse one bare JSON value as a Scalar of the declared kind, using
/// Scalar's own serde as the type oracle (an int literal under F64 parses
/// as F64; a float under I64 refuses).
fn scalar_from_bare(kind: ScalarKind, v: &serde_json::Value) -> Result<Scalar, String> {
let tag = kind_tag(kind);
serde_json::from_value(serde_json::json!({ tag.clone(): v }))
.map_err(|_| format!("value {v} is not a valid {tag}"))
}
impl Serialize for Axis {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeStruct;
let mut st = s.serialize_struct("Axis", 2)?;
st.serialize_field("kind", &self.kind)?;
let bare: Vec<serde_json::Value> = self.values.iter().map(scalar_to_bare).collect();
st.serialize_field("values", &bare)?;
st.end()
}
}
impl<'de> Deserialize<'de> for Axis {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
use serde::de::Error as _;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Raw {
kind: ScalarKind,
values: Vec<serde_json::Value>,
}
let raw = Raw::deserialize(d)?;
let values = raw
.values
.iter()
.map(|v| scalar_from_bare(raw.kind, v))
.collect::<Result<Vec<_>, _>>()
.map_err(D::Error::custom)?;
Ok(Axis { kind: raw.kind, values })
}
}
/// Reference to a content-addressed artifact: by byte-exact content id or
/// by topological identity id. Externally tagged one-of.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DocRef {
ContentId(String),
IdentityId(String),
}
/// The campaign's process slot: reference-only, never an inline body.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProcessRef {
#[serde(rename = "ref")]
pub r#ref: DocRef,
}
/// Data-level presentation: what to record and emit (never how to render).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Presentation {
pub persist_taps: Vec<String>,
pub emit: Vec<String>,
}
/// Parse a campaign document from text (envelope + strict fields).
pub fn parse_campaign(text: &str) -> Result<CampaignDoc, DocError> {
let v: serde_json::Value =
serde_json::from_str(text).map_err(|e| DocError::NotJson(e.to_string()))?;
check_envelope(&v, DocKind::Campaign)?;
serde_json::from_value(v).map_err(|e| DocError::Malformed(e.to_string()))
}
// ---------------------------------------------------------------------------
// Canonical form + content id — THE hashing primitive (library home; the
// CLI's content_id/topology_hash delegate here).
// ---------------------------------------------------------------------------
/// Canonical byte form of a process document: omit-defaults JSON in struct
/// field order, no trailing newline (the blueprint canonical-form recipe).
pub fn process_to_json(doc: &ProcessDoc) -> String {
serde_json::to_string(doc).expect("process document serializes")
}
/// Canonical byte form of a campaign document. Value spelling is
/// normalized: an F64 axis serializes 8 as 8.0, so equivalent spellings
/// share one content id.
pub fn campaign_to_json(doc: &CampaignDoc) -> String {
serde_json::to_string(doc).expect("campaign document serializes")
}
/// SHA-256 hex over canonical bytes — the single content-id primitive.
pub fn content_id_of(canonical: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(canonical.as_bytes());
format!("{:x}", hasher.finalize())
}
// ---------------------------------------------------------------------------
// Intrinsic validation (store-free).
// ---------------------------------------------------------------------------
/// 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<String>, extra: Vec<String> },
NoStrategy,
EmptyAxes { strategy: usize },
EmptyAxis { strategy: usize, axis: String },
UnknownEmitKind(String),
UnknownTap { index: usize, tap: String },
UnknownBindingColumn { role: String, column: String },
ProcessRefMustBeContentId,
/// 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<DocFault> {
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<DocFault> {
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<String> = doc
.data
.instruments
.iter()
.filter(|inst| !keys.contains(&inst.as_str()))
.cloned()
.collect();
let extra: Vec<String> = keys
.iter()
.filter(|k| !doc.data.instruments.iter().any(|inst| inst == *k))
.map(|k| k.to_string())
.collect();
if !missing.is_empty() || !extra.is_empty() {
faults.push(DocFault::CostInstrumentKeys { index: i, missing, extra });
}
}
}
if doc.strategies.is_empty() {
faults.push(DocFault::NoStrategy);
}
for (i, s) in doc.strategies.iter().enumerate() {
if s.axes.is_empty() {
faults.push(DocFault::EmptyAxes { strategy: i });
}
for (axis, ax) in &s.axes {
if ax.values.is_empty() {
faults.push(DocFault::EmptyAxis { strategy: i, axis: axis.clone() });
}
}
}
if matches!(doc.process.r#ref, DocRef::IdentityId(_)) {
faults.push(DocFault::ProcessRefMustBeContentId);
}
for e in &doc.presentation.emit {
if !emit_vocabulary().contains(&e.as_str()) {
faults.push(DocFault::UnknownEmitKind(e.clone()));
}
}
for (i, t) in doc.presentation.persist_taps.iter().enumerate() {
if !tap_vocabulary().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<String>,
}
fn open(path: impl Into<String>, hint: impl Into<String>) -> OpenSlot {
OpenSlot { path: path.into(), hint: hint.into(), notes: Vec::new() }
}
/// Like `open`, but carries additional unit/semantics notes (#265) rendered
/// as indented sub-lines under the slot — the `hint` string stays exactly as
/// `open` would have produced it, so sibling tests pinning `hint` verbatim
/// are unaffected.
fn open_with_notes(path: impl Into<String>, hint: impl Into<String>, notes: Vec<String>) -> OpenSlot {
OpenSlot { path: path.into(), hint: hint.into(), notes }
}
/// List the open slots of a (possibly partial) process document. Only the
/// text being JSON is required; everything else missing is reported as open.
pub fn open_slots_process(text: &str) -> Result<Vec<OpenSlot>, DocError> {
let v: serde_json::Value =
serde_json::from_str(text).map_err(|e| DocError::NotJson(e.to_string()))?;
let mut slots = Vec::new();
envelope_open_slots(&v, "process", &mut slots);
if v.get("name").and_then(|n| n.as_str()).is_none() {
slots.push(open("name", "required, string"));
}
match v.get("pipeline").and_then(|p| p.as_array()) {
None => slots.push(open("pipeline", "required, list of stage blocks")),
Some(stages) if stages.is_empty() => {
slots.push(open("pipeline", "declared empty — a process needs at least one stage"))
}
Some(stages) => {
for (i, stage) in stages.iter().enumerate() {
let Some(m) = stage.as_object() else {
slots.push(open(format!("pipeline[{i}]"), "must be a stage object"));
continue;
};
let Some(id) = m.get("block").and_then(|b| b.as_str()) else {
slots.push(open(format!("pipeline[{i}].block"), "required, block id"));
continue;
};
let Some(schema) = PROCESS_BLOCKS.iter().find(|b| b.id == id) else {
slots.push(open(format!("pipeline[{i}].block"), format!("unknown block \"{id}\"")));
continue;
};
for s in schema.slots.iter().filter(|s| s.required) {
if !m.contains_key(s.name) {
slots.push(open(
format!("pipeline[{i}].{}", s.name),
format!("required, {}", slot_kind_label(s.kind)),
));
}
}
}
}
}
Ok(slots)
}
/// List the open slots of a (possibly partial) campaign document.
/// Whether a `ref` slot in a DRAFT reads as open: absent, or one of the two
/// natural placeholder spellings `{}` / `null` (#195). A draft affordance of
/// the open-slot probe only — the strict parse tier keeps refusing
/// placeholders (they are not a valid document form).
fn ref_slot_is_open(r: Option<&serde_json::Value>) -> bool {
match r {
None | Some(serde_json::Value::Null) => true,
Some(serde_json::Value::Object(m)) => m.is_empty(),
Some(_) => false,
}
}
pub fn open_slots_campaign(text: &str) -> Result<Vec<OpenSlot>, DocError> {
let v: serde_json::Value =
serde_json::from_str(text).map_err(|e| DocError::NotJson(e.to_string()))?;
let mut slots = Vec::new();
envelope_open_slots(&v, "campaign", &mut slots);
if v.get("name").and_then(|n| n.as_str()).is_none() {
slots.push(open("name", "required, string"));
}
match v.get("data") {
None => slots.push(open("data", "required section: instruments + windows")),
Some(d) => {
if d.get("instruments").and_then(|x| x.as_array()).is_none_or(|a| a.is_empty()) {
slots.push(open("data.instruments", "required, non-empty list of strings"));
}
if d.get("windows").and_then(|x| x.as_array()).is_none_or(|a| a.is_empty()) {
slots.push(open("data.windows", "required, non-empty list of { from_ms, to_ms }"));
}
}
}
// The one optional slot the probe lists: the structural risk axis is
// invisible to a required-slot sweep (#[serde(default)]), which is exactly
// how it went undiscovered (#216). Absent, empty, or non-array = open.
let risk_bound = v.get("risk").and_then(|r| r.as_array()).is_some_and(|a| !a.is_empty());
if !risk_bound {
slots.push(open_with_notes(
"risk",
"optional, list of stop regimes { vol: { length, k } } | { vol_tf: { period_minutes, length, k } }; absent = one default regime",
vec![
"vol.length smooths the per-cycle vol estimator; it does not set the stop's timescale.".to_string(),
"vol.k scales the stop distance (the risk-unit lever).".to_string(),
"vol_tf computes the same estimator over completed period_minutes buckets: period_minutes IS the stop's timescale; length smooths in buckets; k scales the distance.".to_string(),
],
));
}
// The optional cost slot, mirroring the risk axis (#216's discoverability
// lesson applied to #234): absent, empty, or non-array = open.
let cost_bound = v.get("cost").and_then(|c| c.as_array()).is_some_and(|a| !a.is_empty());
if !cost_bound {
slots.push(open_with_notes(
"cost",
"optional, list of cost components { constant: { cost_per_trade } } | { vol_slippage: { slip_vol_mult } } | { carry: { carry_per_cycle } }; absent = zero costs (net == gross)",
vec![
"constant.cost_per_trade is in price units, not R (charged per close as cost/|entry-stop| in R).".to_string(),
"vol_slippage.slip_vol_mult is a multiplier on the per-cycle vol estimate (charged in R likewise).".to_string(),
"carry.carry_per_cycle is in price units per held cycle, not R (accrues over the hold, emitted in R likewise).".to_string(),
"each knob takes one number for all instruments, or an instrument-keyed map { \"GER40\": 2.0, ... } covering exactly the campaign's instruments.".to_string(),
],
));
}
match v.get("strategies").and_then(|s| s.as_array()) {
None => slots.push(open("strategies", "required, non-empty list of { ref, axes }")),
Some(list) if list.is_empty() => {
slots.push(open("strategies", "declared empty — a campaign needs at least one strategy"))
}
Some(list) => {
for (i, s) in list.iter().enumerate() {
if ref_slot_is_open(s.get("ref")) {
slots.push(open(
format!("strategies[{i}].ref"),
"required, one of: content_id, identity_id",
));
}
match s.get("axes").and_then(|a| a.as_object()) {
None => slots.push(open(
format!("strategies[{i}].axes"),
"required, map: param name -> { kind, values }",
)),
Some(axes) => {
for (axis, spec) in axes {
let empty = spec
.get("values")
.and_then(|c| c.as_array())
.is_none_or(|c| c.is_empty());
if empty {
slots.push(open(
format!("strategies[{i}].axes.{axis}"),
"axis declared empty — a P1 axis is a non-empty finite set",
));
}
}
}
}
}
}
}
if ref_slot_is_open(v.get("process").and_then(|p| p.get("ref"))) {
// deliberately narrower than the strategy-ref hint: validate_campaign
// refuses an identity_id process ref, so the guide must not offer it.
// 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::<Vec<_>>().join(" | ")
),
));
}
Ok(slots)
}
fn envelope_open_slots(v: &serde_json::Value, kind: &str, slots: &mut Vec<OpenSlot>) {
if v.get("format_version").and_then(|x| x.as_u64()) != Some(u64::from(FORMAT_VERSION)) {
slots.push(open("format_version", "required, must be 1"));
}
if v.get("kind").and_then(|k| k.as_str()) != Some(kind) {
slots.push(open("kind", format!("required, must be \"{kind}\"")));
}
// 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::<CostSpec>(r#"{"constant":{"per_close_r":2.0}}"#).is_err());
}
/// Property (#260): a cost knob's map form round-trips as a plain JSON
/// object keyed by instrument, and the scalar form stays the bare number
/// (content-id parity for stored docs is pinned by the sibling scalar
/// round-trip test, which must stay green untouched).
#[test]
fn cost_value_map_form_round_trips_and_scalar_stays_bare() {
let json = r#"{"constant":{"cost_per_trade":{"EURUSD":0.00012,"GER40":2.0}}}"#;
let spec: CostSpec = serde_json::from_str(json).expect("map form parses");
assert_eq!(serde_json::to_string(&spec).expect("serializes"), json);
let bare: CostSpec = serde_json::from_str(r#"{"constant":{"cost_per_trade":2.0}}"#)
.expect("scalar form parses");
assert_eq!(
serde_json::to_string(&bare).expect("serializes"),
r#"{"constant":{"cost_per_trade":2.0}}"#,
"scalar wire form must stay the bare number byte-identically"
);
let CostSpec::Constant { cost_per_trade } = &spec else { panic!("constant") };
assert_eq!(cost_per_trade.resolve("GER40"), Some(2.0));
assert_eq!(cost_per_trade.resolve("EURUSD"), Some(0.00012));
assert_eq!(cost_per_trade.resolve("XAUUSD"), None);
}
/// Property (#260): an empty instrument map (`{}`) is refused at
/// deserialize time — `validate_campaign`'s cross-check skips a knob
/// whose `instrument_keys()` is empty (that shape also means "scalar"),
/// so an empty map that slipped past this guard would silently escape
/// the instrument-coverage check entirely.
#[test]
fn cost_value_empty_map_is_refused_at_deserialize() {
let err = serde_json::from_str::<CostSpec>(r#"{"constant":{"cost_per_trade":{}}}"#)
.expect_err("an empty instrument map must not parse");
assert!(
err.to_string().contains("instrument map is empty"),
"error names the defect: {err}"
);
}
/// Property (#260): the intrinsic tier cross-checks every instrument map
/// against the doc's own data.instruments — a missing instrument and an
/// unknown extra key are each a fault naming the component; a complete
/// map is valid. No silent default, no partial coverage.
#[test]
fn validate_campaign_cross_checks_cost_instrument_maps() {
let mk = |cost: &str| {
let doc = format!(
r#"{{ "format_version": 1, "kind": "campaign", "name": "t",
"data": {{ "instruments": ["GER40", "EURUSD"],
"windows": [ {{ "from_ms": 1, "to_ms": 2 }} ] }},
"strategies": [ {{ "ref": {{ "content_id": "9f" }},
"axes": {{ "a": {{ "kind": "I64", "values": [1] }} }} }} ],
"process": {{ "ref": {{ "content_id": "9f" }} }},
"cost": [ {cost} ],
"presentation": {{ "persist_taps": [], "emit": [] }},
"seed": 1 }}"#
);
let parsed = parse_campaign(&doc).expect("parses");
validate_campaign(&parsed)
};
assert!(
mk(r#"{ "constant": { "cost_per_trade": { "GER40": 2.0, "EURUSD": 0.00012 } } }"#)
.is_empty(),
"a complete map must validate"
);
let missing = mk(r#"{ "constant": { "cost_per_trade": { "GER40": 2.0 } } }"#);
assert!(
missing.iter().any(|f| matches!(
f,
DocFault::CostInstrumentKeys { index: 0, .. }
)),
"fault names the component: {missing:?}"
);
let extra = mk(
r#"{ "constant": { "cost_per_trade": { "GER40": 2.0, "EURUSD": 1.0, "GER40.cash": 3.0 } } }"#,
);
assert!(
extra.iter().any(|f| matches!(f, DocFault::CostInstrumentKeys { index: 0, .. })),
"fault names the component: {extra:?}"
);
}
#[test]
fn campaign_absent_and_empty_cost_omit_from_canonical_bytes() {
// The risk precedent's sibling: cost-less documents keep their content ids.
let doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
assert!(doc.cost.is_empty(), "an absent cost key deserializes to empty");
let absent = campaign_to_json(&doc);
assert!(!absent.contains("\"cost\""), "absent cost omits from serialization: {absent}");
let mut empty = doc.clone();
empty.cost = vec![];
assert_eq!(
content_id_of(&campaign_to_json(&empty)),
content_id_of(&absent),
"an explicit empty cost hashes identically to an absent one"
);
}
#[test]
fn campaign_with_cost_serializes_and_round_trips_the_components() {
let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
doc.cost = vec![
CostSpec::Constant { cost_per_trade: CostValue::Scalar(2.0) },
CostSpec::VolSlippage { slip_vol_mult: CostValue::Scalar(0.5) },
];
let out = campaign_to_json(&doc);
assert!(
out.contains(r#""cost":[{"constant":{"cost_per_trade":2.0}},{"vol_slippage":{"slip_vol_mult":0.5}}]"#),
"cost serializes in place as a tagged list: {out}"
);
let back: CampaignDoc = serde_json::from_str(&out).unwrap();
assert_eq!(back.cost, doc.cost);
}
#[test]
fn validate_campaign_flags_each_bad_cost_component() {
let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
doc.cost = vec![
CostSpec::Constant { cost_per_trade: CostValue::Scalar(0.0) }, // 0: valid (zero cost is a legal stress point)
CostSpec::VolSlippage { slip_vol_mult: CostValue::Scalar(-0.5) }, // 1: negative
CostSpec::Carry { carry_per_cycle: CostValue::Scalar(f64::NAN) }, // 2: non-finite (unreachable via JSON; defensive)
];
let faults = validate_campaign(&doc);
assert!(faults.contains(&DocFault::BadCost { index: 1 }), "{faults:?}");
assert!(faults.contains(&DocFault::BadCost { index: 2 }), "{faults:?}");
assert!(!faults.contains(&DocFault::BadCost { index: 0 }), "valid component not flagged: {faults:?}");
}
/// Property (#260): the finiteness/sign check applies to EVERY value
/// inside a `PerInstrument` map, not only the scalar form — a negative
/// entry nested in an otherwise fully-keyed map is refused exactly like
/// a bad scalar (quality-review gap: the map cross-check test above only
/// exercised valid map values, leaving this path unexercised).
#[test]
fn validate_campaign_flags_a_bad_value_inside_an_instrument_map() {
let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
doc.cost = vec![CostSpec::Constant {
cost_per_trade: CostValue::PerInstrument(std::collections::BTreeMap::from([(
"GER40".to_string(),
-1.0,
)])),
}];
let faults = validate_campaign(&doc);
assert!(faults.contains(&DocFault::BadCost { index: 0 }), "{faults:?}");
}
#[test]
fn validate_campaign_accepts_a_valid_cost_section() {
let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
doc.cost = vec![CostSpec::Carry { carry_per_cycle: CostValue::Scalar(0.25) }];
assert!(validate_campaign(&doc).is_empty(), "{:?}", validate_campaign(&doc));
}
#[test]
fn cost_block_is_describable_beside_risk() {
let block = describe_block("std::cost").expect("std::cost describable");
assert_eq!(block.slots.len(), 1);
assert_eq!(block.slots[0].name, "cost");
assert!(!block.slots[0].required);
assert_eq!(
slot_kind_label(block.slots[0].kind),
"list of cost components { constant: { cost_per_trade } } | { vol_slippage: { slip_vol_mult } } | { carry: { carry_per_cycle } }; absent = zero costs (net == gross)"
);
}
/// The intrinsic tier does NOT constrain where a population-transforming
/// stage sits relative to the annotators (terminal_seen gates only Gate
/// stages): `[sweep, monte_carlo, walk_forward]` is intrinsically valid.
/// Execution-shape refusals are the executor preflight's job, one tier up
/// — this pin is the boundary between the two tiers.
#[test]
fn validate_process_permits_walk_forward_after_an_annotator() {
let doc = parse_process(
r#"{ "format_version": 1, "kind": "process", "name": "wf-after-mc",
"pipeline": [
{ "block": "std::sweep", "metric": "sqn", "select": "argmax" },
{ "block": "std::monte_carlo", "resamples": 100, "block_len": 5 },
{ "block": "std::walk_forward", "in_sample_ms": 4000, "out_of_sample_ms": 1000,
"step_ms": 1000, "mode": "rolling", "metric": "sqn", "select": "argmax" }
] }"#,
)
.unwrap();
assert_eq!(validate_process(&doc), Vec::new());
}
/// 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::<Vec<_>>().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::<Vec<_>>().join(" | ")
)
);
assert_eq!(
pres.hint,
"required section: persist_taps (equity | exposure | r_equity | net_r_equity) + emit"
);
}
/// #256 fork B: the fieldless enumerate-only stage round-trips through
/// the schema-strict parser, and any slot on it is refused (its slot
/// list is empty, so the generic unknown-slot check covers every key).
#[test]
fn grid_block_round_trips_and_refuses_slots() {
let json = serde_json::to_string(&StageBlock::Grid).expect("serialize");
assert_eq!(json, r#"{"block":"std::grid"}"#);
let back: StageBlock = serde_json::from_str(&json).expect("parse back");
assert_eq!(back, StageBlock::Grid);
let err = serde_json::from_str::<StageBlock>(
r#"{"block":"std::grid","metric":"sqn"}"#,
)
.expect_err("a slot on the fieldless std::grid must be refused");
assert!(err.to_string().contains("unknown slot"), "got: {err}");
}
#[test]
fn vocabularies_enumerate_every_block_with_typed_slots() {
let ids: Vec<&str> = process_vocabulary().iter().map(|b| b.id).collect();
assert_eq!(
ids,
vec![
"std::sweep",
"std::gate",
"std::walk_forward",
"std::monte_carlo",
"std::generalize",
"std::grid"
]
);
for b in process_vocabulary().iter().chain(campaign_vocabulary()) {
assert!(!b.doc.is_empty(), "block {} has no doc line", b.id);
// std::grid is the one legitimate fieldless block (#256); every
// other block keeps its documented-slots guard.
assert!(
b.id == "std::grid" || !b.slots.is_empty(),
"block {} has no slots",
b.id
);
for s in b.slots {
assert!(!slot_kind_label(s.kind).is_empty());
}
}
let sweep = describe_block("std::sweep").expect("sweep describable");
assert!(sweep.slots.iter().any(|s| s.name == "metric" && !s.required));
assert!(sweep.slots.iter().any(|s| s.name == "deflate" && !s.required));
let wf = describe_block("std::walk_forward").expect("walk_forward describable");
let names: Vec<&str> = wf.slots.iter().map(|s| s.name).collect();
assert_eq!(
names,
vec!["in_sample_ms", "out_of_sample_ms", "step_ms", "mode", "metric", "select"]
);
let mode = wf.slots.iter().find(|s| s.name == "mode").expect("mode slot");
assert_eq!(slot_kind_label(mode.kind), "one of: rolling | anchored");
assert!(describe_block("std::strategy").is_some());
assert!(describe_block("std::nope").is_none());
}
/// A campaign process reference is content-id-only: `validate_campaign`
/// refuses an `identity_id` process ref (`ProcessRefMustBeContentId`), and
/// the block's own doc line says "(content id)". So the `std::process_ref`
/// describe output — the `describe_block` + `slot_kind_label` composition
/// the `--block` CLI verb renders — must not advertise an `identity_id`
/// form the validator would reject. The `--unwired` hint is already
/// narrowed (see `open_slots_campaign`); this pins the parallel describe
/// path to the same content-id-only contract. The narrowing is
/// process-ref-specific: a `std::strategy` ref legitimately accepts either
/// id form and must keep advertising both.
#[test]
fn process_ref_describe_advertises_content_id_only() {
let block = describe_block("std::process_ref").expect("process_ref describable");
let ref_slot =
block.slots.iter().find(|s| s.name == "ref").expect("process_ref has a ref slot");
let label = slot_kind_label(ref_slot.kind);
assert!(
!label.contains("identity_id"),
"process_ref advertises an identity_id form the validator refuses: {label}"
);
assert!(
label.contains("content"),
"process_ref describe must still name the content-id form: {label}"
);
// #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)));
}
}