Files
Aura/crates/aura-research/src/lib.rs
T
Brummel 43b1c7ff5d feat(research,registry,cli): campaign data.bindings — the 6b rebind seam (#231 task 5)
DataSection gains an additive bindings block (role -> column; serde
default + skip-if-empty, so binding-less documents keep their content
ids — pinned). Validation is two-tier along the existing line: the
intrinsic tier checks binding VALUES against the column vocabulary
(DocFault::UnknownBindingColumn, alias-annotated display register); the
resolver tier checks binding KEYS against the loaded strategies'
input_roles() (RefFault::BindingRoleUnknown — a key must name a role of
at least one campaign strategy). A cross-surface pin keeps the doc-tier
vocabulary and the CLI binding module from drifting.

CliMemberRunner threads the campaign overrides into resolve_binding on
BOTH halves (column opening and wrap), keeping the C1 drift alarm
comparing like-with-like; the verb sugar passes no bindings (name
defaults rule).

Proof: an archive-gated campaign e2e rebinds price -> open and yields
different realized metrics from the close-bound run; refusal prose
exact-pinned at both tiers.

Verified: full workspace suite green, clippy -D warnings clean;
independent quality review, all findings repaired.

refs #231
2026-07-11 04:35:01 +02:00

1914 lines
80 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::{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 },
}
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,
}
/// 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 }],
},
];
// ---------------------------------------------------------------------------
// Strict stage parsing (schema-driven).
// ---------------------------------------------------------------------------
fn require_str(
m: &serde_json::Map<String, serde_json::Value>,
key: &str,
block: &str,
) -> Result<String, String> {
match m.get(key) {
Some(serde_json::Value::String(s)) => Ok(s.clone()),
Some(_) => Err(format!("block {block}: slot \"{key}\" must be a string")),
None => Err(format!("block {block}: missing required slot \"{key}\"")),
}
}
fn require_u64(
m: &serde_json::Map<String, serde_json::Value>,
key: &str,
block: &str,
) -> Result<u64, String> {
match m.get(key) {
Some(v) => v
.as_u64()
.ok_or_else(|| format!("block {block}: slot \"{key}\" must be a non-negative integer")),
None => Err(format!("block {block}: missing required slot \"{key}\"")),
}
}
fn require_u32(
m: &serde_json::Map<String, serde_json::Value>,
key: &str,
block: &str,
) -> Result<u32, String> {
let v = require_u64(m, key, block)?;
u32::try_from(v).map_err(|_| format!("block {block}: slot \"{key}\" out of range"))
}
fn optional_bool(
m: &serde_json::Map<String, serde_json::Value>,
key: &str,
block: &str,
) -> Result<bool, String> {
match m.get(key) {
None => Ok(false),
Some(serde_json::Value::Bool(b)) => Ok(*b),
Some(_) => Err(format!("block {block}: slot \"{key}\" must be a bool")),
}
}
fn select_from(
m: &serde_json::Map<String, serde_json::Value>,
block: &str,
) -> Result<SelectRule, String> {
let s = require_str(m, "select", block)?;
// SelectRule's own derived Deserialize (which carries the #[serde(rename)]
// wire strings) is the type oracle, mirroring kind_tag/scalar_from_bare:
// no second hardcoded copy of the three select-rule wire strings.
serde_json::from_value(serde_json::Value::String(s.clone()))
.map_err(|_| format!("block {block}: unknown select rule \"{s}\""))
}
fn mode_from(
m: &serde_json::Map<String, serde_json::Value>,
block: &str,
) -> Result<WfMode, String> {
let s = require_str(m, "mode", block)?;
// WfMode's own derived Deserialize (which carries the #[serde(rename)]
// wire strings) is the type oracle, mirroring select_from: no second
// hardcoded copy of the two roll-mode wire strings.
serde_json::from_value(serde_json::Value::String(s.clone()))
.map_err(|_| format!("block {block}: unknown mode \"{s}\""))
}
fn stage_from_value(v: &serde_json::Value) -> Result<StageBlock, String> {
let m = v
.as_object()
.ok_or_else(|| "a pipeline stage must be an object".to_string())?;
let id = match m.get("block") {
Some(serde_json::Value::String(s)) => s.clone(),
_ => return Err("a pipeline stage must carry a string \"block\" id".to_string()),
};
let schema = PROCESS_BLOCKS
.iter()
.find(|b| b.id == id)
.ok_or_else(|| format!("unknown block \"{id}\""))?;
for key in m.keys() {
if key != "block" && !schema.slots.iter().any(|s| s.name == key) {
return Err(format!("block {id}: unknown slot \"{key}\""));
}
}
match id.as_str() {
"std::sweep" => {
let has_metric = m.contains_key("metric");
let has_select = m.contains_key("select");
let selection = match (has_metric, has_select) {
(true, true) => Some(SweepSelection {
metric: require_str(m, "metric", &id)?,
select: select_from(m, &id)?,
deflate: optional_bool(m, "deflate", &id)?,
}),
(false, false) => {
if m.contains_key("deflate") {
return Err(format!(
"block {id}: slot \"deflate\" needs the selection \
group (\"metric\" + \"select\")"
));
}
None
}
(true, false) => {
return Err(format!(
"block {id}: slot \"metric\" without \"select\" — the \
selection group is all-or-nothing"
));
}
(false, true) => {
return Err(format!(
"block {id}: slot \"select\" without \"metric\" — the \
selection group is all-or-nothing"
));
}
};
Ok(StageBlock::Sweep { selection })
}
"std::gate" => {
let all = m
.get("all")
.ok_or_else(|| format!("block {id}: missing required slot \"all\""))?;
let all: Vec<Predicate> = serde_json::from_value(all.clone())
.map_err(|e| format!("block {id}: bad predicate list: {e}"))?;
Ok(StageBlock::Gate { all })
}
"std::walk_forward" => Ok(StageBlock::WalkForward {
in_sample_ms: require_u64(m, "in_sample_ms", &id)?,
out_of_sample_ms: require_u64(m, "out_of_sample_ms", &id)?,
step_ms: require_u64(m, "step_ms", &id)?,
mode: mode_from(m, &id)?,
metric: require_str(m, "metric", &id)?,
select: select_from(m, &id)?,
}),
"std::monte_carlo" => Ok(StageBlock::MonteCarlo {
resamples: require_u32(m, "resamples", &id)?,
block_len: require_u32(m, "block_len", &id)?,
}),
"std::generalize" => Ok(StageBlock::Generalize {
metric: require_str(m, "metric", &id)?,
}),
_ => 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>,
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 },
}
/// Bare wire value of one Scalar (strips the tagged form via Scalar's own
/// serde as the oracle — no Scalar internals assumed).
fn scalar_to_bare(s: &Scalar) -> serde_json::Value {
match serde_json::to_value(s).expect("scalar serializes") {
serde_json::Value::Object(m) => m.into_values().next().expect("tagged form has one value"),
v => v,
}
}
/// The wire tag of a `ScalarKind`, using `ScalarKind`'s own serde as the
/// oracle (mirrors `scalar_to_bare`: no wire shape assumed, only asked of
/// serde) rather than re-hardcoding the four tag strings as a second source
/// of truth for `Scalar`'s variant names.
fn kind_tag(kind: ScalarKind) -> String {
match serde_json::to_value(kind).expect("scalar kind serializes") {
serde_json::Value::String(s) => s,
other => unreachable!("ScalarKind serializes to a string, got {other}"),
}
}
/// Parse one bare JSON value as a Scalar of the declared kind, using
/// Scalar's own serde as the type oracle (an int literal under F64 parses
/// as F64; a float under I64 refuses).
fn scalar_from_bare(kind: ScalarKind, v: &serde_json::Value) -> Result<Scalar, String> {
let tag = kind_tag(kind);
serde_json::from_value(serde_json::json!({ tag.clone(): v }))
.map_err(|_| format!("value {v} is not a valid {tag}"))
}
impl Serialize for Axis {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeStruct;
let mut st = s.serialize_struct("Axis", 2)?;
st.serialize_field("kind", &self.kind)?;
let bare: Vec<serde_json::Value> = self.values.iter().map(scalar_to_bare).collect();
st.serialize_field("values", &bare)?;
st.end()
}
}
impl<'de> Deserialize<'de> for Axis {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
use serde::de::Error as _;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Raw {
kind: ScalarKind,
values: Vec<serde_json::Value>,
}
let raw = Raw::deserialize(d)?;
let values = raw
.values
.iter()
.map(|v| scalar_from_bare(raw.kind, v))
.collect::<Result<Vec<_>, _>>()
.map_err(D::Error::custom)?;
Ok(Axis { kind: raw.kind, values })
}
}
/// Reference to a content-addressed artifact: by byte-exact content id or
/// by topological identity id. Externally tagged one-of.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DocRef {
ContentId(String),
IdentityId(String),
}
/// The campaign's process slot: reference-only, never an inline body.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProcessRef {
#[serde(rename = "ref")]
pub r#ref: DocRef,
}
/// Data-level presentation: what to record and emit (never how to render).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Presentation {
pub persist_taps: Vec<String>,
pub emit: Vec<String>,
}
/// Parse a campaign document from text (envelope + strict fields).
pub fn parse_campaign(text: &str) -> Result<CampaignDoc, DocError> {
let v: serde_json::Value =
serde_json::from_str(text).map_err(|e| DocError::NotJson(e.to_string()))?;
check_envelope(&v, DocKind::Campaign)?;
serde_json::from_value(v).map_err(|e| DocError::Malformed(e.to_string()))
}
// ---------------------------------------------------------------------------
// Canonical form + content id — THE hashing primitive (library home; the
// CLI's content_id/topology_hash delegate here).
// ---------------------------------------------------------------------------
/// Canonical byte form of a process document: omit-defaults JSON in struct
/// field order, no trailing newline (the blueprint canonical-form recipe).
pub fn process_to_json(doc: &ProcessDoc) -> String {
serde_json::to_string(doc).expect("process document serializes")
}
/// Canonical byte form of a campaign document. Value spelling is
/// normalized: an F64 axis serializes 8 as 8.0, so equivalent spellings
/// share one content id.
pub fn campaign_to_json(doc: &CampaignDoc) -> String {
serde_json::to_string(doc).expect("campaign document serializes")
}
/// SHA-256 hex over canonical bytes — the single content-id primitive.
pub fn content_id_of(canonical: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(canonical.as_bytes());
format!("{:x}", hasher.finalize())
}
// ---------------------------------------------------------------------------
// Intrinsic validation (store-free).
// ---------------------------------------------------------------------------
/// The closed declared-metric vocabulary: the scalar field names of the
/// shipped metrics/selection types (RMetrics + RunMetrics scalars, plus the
/// FamilySelection annotation scores). Single source for gate/stage checks
/// and introspection.
///
/// Accepted residual: this is a hand-copy of `aura-analysis`'s
/// `RMetrics`/`RunMetrics`/`FamilySelection` field names, not oracle-guarded
/// in-crate — this leaf crate deliberately has no `aura-analysis` dependency
/// (engine/domain separation), so there is no compile-time field pin to
/// reference here, unlike the block-schema tables above (:105). Same #160/0105
/// drift class; fails safe in the direction that matters: a metric renamed or
/// removed upstream surfaces as `DocFault::UnknownMetric` at parse/validation
/// time, never a silent accept. A cross-crate guard test
/// (`aura-campaign/tests/metric_vocabulary_e2e.rs`, #190) also enumerates the
/// shipped scalar fields via serde and goes red on any desync, so a stale
/// hand-edit here is test-caught rather than silent. Keep this list in sync by
/// hand when the upstream field sets change (single-sourcing it away waits on
/// #147).
pub fn metric_vocabulary() -> &'static [&'static str] {
&[
"expectancy_r",
"win_rate",
"avg_win_r",
"avg_loss_r",
"profit_factor",
"max_r_drawdown",
"sqn",
"sqn_normalized",
"net_expectancy_r",
"n_trades",
"n_open_at_end",
"total_pips",
"max_drawdown",
"bias_sign_flips",
"deflated_score",
"overfit_probability",
"neighbourhood_score",
]
}
/// The closed v1 emit vocabulary (data-level presentation outputs).
pub fn emit_vocabulary() -> &'static [&'static str] {
&["family_table", "selection_report"]
}
/// The wrap convention's persisted sink names — the closed tap vocabulary
/// (#201 decision 1). A genuinely new observable becomes a new entry or an
/// authored sink in the strategy blueprint (C22: the choice of sinks is part
/// of the experiment) — never an open node-path namespace in the document.
pub fn tap_vocabulary() -> &'static [&'static str] {
&["equity", "exposure", "r_equity", "net_r_equity"]
}
/// The closed archive-column vocabulary a campaign `data.bindings` VALUE may
/// name — the six M1 columns plus `price`, the alias of close. Doc-tier
/// mirror of the CLI binding module's role->column map (the `tap_vocabulary`
/// precedent for doc-tier string tables); the CLI pins the two surfaces
/// against each other in `binding::tests`.
pub fn binding_column_vocabulary() -> &'static [&'static str] {
&["open", "high", "low", "close", "price", "spread", "volume"]
}
/// The vocabulary in the refusal-prose register — the alias annotated
/// instead of listed as a flat column (mirrors the CLI binding module).
pub fn binding_column_vocabulary_display() -> &'static str {
"open | high | low | close (alias: price) | spread | volume"
}
/// Intrinsic-validation findings. By-identifier and Display-free.
#[derive(Clone, Debug, PartialEq)]
pub enum DocFault {
// process side
EmptyPipeline,
UnknownMetric { stage: usize, metric: String },
EmptyConjunction { stage: usize },
GateFirst,
GateAfterTerminalStage { stage: usize },
ZeroWalkForwardLength { stage: usize, field: &'static str },
// campaign side
EmptyInstruments,
NoWindow,
BadWindow { index: usize },
BadRegime { index: usize },
NoStrategy,
EmptyAxes { strategy: usize },
EmptyAxis { strategy: usize, axis: String },
UnknownEmitKind(String),
UnknownTap { index: usize, tap: String },
UnknownBindingColumn { role: String, column: String },
ProcessRefMustBeContentId,
}
fn is_known_metric(name: &str) -> bool {
metric_vocabulary().contains(&name)
}
/// Intrinsic validation of a process document (P1 constraints included).
/// Returns ALL findings, not the first.
pub fn validate_process(doc: &ProcessDoc) -> Vec<DocFault> {
let mut faults = Vec::new();
if doc.pipeline.is_empty() {
faults.push(DocFault::EmptyPipeline);
}
let mut terminal_seen = false;
for (i, stage) in doc.pipeline.iter().enumerate() {
match stage {
StageBlock::Sweep { selection } => {
if let Some(sel) = selection
&& !is_known_metric(&sel.metric)
{
faults.push(DocFault::UnknownMetric { stage: i, metric: sel.metric.clone() });
}
}
StageBlock::Generalize { metric } => {
if !is_known_metric(metric) {
faults.push(DocFault::UnknownMetric { stage: i, metric: metric.clone() });
}
}
StageBlock::WalkForward { in_sample_ms, out_of_sample_ms, step_ms, metric, .. } => {
if !is_known_metric(metric) {
faults.push(DocFault::UnknownMetric { stage: i, metric: metric.clone() });
}
// WindowRoller::new refuses NonPositiveLength at run time; the
// doc tier refuses the same zeroes earlier, path-addressed.
for (field, value) in [
("in_sample_ms", *in_sample_ms),
("out_of_sample_ms", *out_of_sample_ms),
("step_ms", *step_ms),
] {
if value == 0 {
faults.push(DocFault::ZeroWalkForwardLength { stage: i, field });
}
}
}
StageBlock::Gate { all } => {
if i == 0 {
faults.push(DocFault::GateFirst);
} else if terminal_seen {
faults.push(DocFault::GateAfterTerminalStage { stage: i });
}
if all.is_empty() {
faults.push(DocFault::EmptyConjunction { stage: i });
}
for p in all {
if !is_known_metric(&p.metric) {
faults.push(DocFault::UnknownMetric { stage: i, metric: p.metric.clone() });
}
}
}
StageBlock::MonteCarlo { .. } => {}
}
if matches!(stage, StageBlock::MonteCarlo { .. } | StageBlock::Generalize { .. }) {
terminal_seen = true;
}
}
faults
}
/// Intrinsic validation of a campaign document (P1 constraints included).
/// Returns ALL findings, not the first.
pub fn validate_campaign(doc: &CampaignDoc) -> Vec<DocFault> {
let mut faults = Vec::new();
if doc.data.instruments.is_empty() {
faults.push(DocFault::EmptyInstruments);
}
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() {
let RiskRegime::Vol { length, k } = r;
// 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 });
}
}
if doc.strategies.is_empty() {
faults.push(DocFault::NoStrategy);
}
for (i, s) in doc.strategies.iter().enumerate() {
if s.axes.is_empty() {
faults.push(DocFault::EmptyAxes { strategy: i });
}
for (axis, ax) in &s.axes {
if ax.values.is_empty() {
faults.push(DocFault::EmptyAxis { strategy: i, axis: axis.clone() });
}
}
}
if matches!(doc.process.r#ref, DocRef::IdentityId(_)) {
faults.push(DocFault::ProcessRefMustBeContentId);
}
for e in &doc.presentation.emit {
if !emit_vocabulary().contains(&e.as_str()) {
faults.push(DocFault::UnknownEmitKind(e.clone()));
}
}
for (i, t) in doc.presentation.persist_taps.iter().enumerate() {
if !tap_vocabulary().contains(&t.as_str()) {
faults.push(DocFault::UnknownTap { index: i, tap: t.clone() });
}
}
faults
}
// ---------------------------------------------------------------------------
// Introspection contract: enumerate vocabulary, describe a block's typed
// slots, list a partial document's open slots. The same tables that make
// parsing strict make a block palette generatable (the litmus test).
// ---------------------------------------------------------------------------
/// The campaign document's section vocabulary (descriptive: campaign parsing
/// is derive-strict; this table powers introspection).
pub const CAMPAIGN_SECTIONS: &[BlockSchema] = &[
BlockSchema {
id: "std::data",
doc: "campaign section: instruments + windows",
slots: &[
SlotInfo { name: "instruments", kind: SlotKind::Strings, required: true },
SlotInfo { name: "windows", kind: SlotKind::Windows, required: true },
],
},
BlockSchema {
id: "std::risk",
doc: "campaign section: the structural risk axis — stop regimes the matrix runs each cell under (absent = one default regime)",
slots: &[SlotInfo { name: "risk", kind: SlotKind::Regimes, required: false }],
},
BlockSchema {
id: "std::strategy",
doc: "campaign section: strategy ref (content or identity id) + param axes",
slots: &[
SlotInfo { name: "ref", kind: SlotKind::Ref, required: true },
SlotInfo { name: "axes", kind: SlotKind::Axes, required: true },
],
},
BlockSchema {
id: "std::process_ref",
doc: "campaign section: reference to a named process document (content id)",
slots: &[SlotInfo { name: "ref", kind: SlotKind::ContentRef, required: true }],
},
BlockSchema {
id: "std::presentation",
doc: "campaign section: taps to persist + tables to emit (data-level only)",
slots: &[
SlotInfo { name: "persist_taps", kind: SlotKind::TapKinds, required: true },
SlotInfo { name: "emit", kind: SlotKind::EmitKinds, required: true },
],
},
];
/// Human label for a slot kind (CLI presentation).
pub fn slot_kind_label(kind: SlotKind) -> &'static str {
match kind {
SlotKind::MetricName => "metric name (see metric_vocabulary)",
SlotKind::SelectRule => "select rule: argmax | plateau:mean | plateau:worst",
SlotKind::WfMode => "one of: rolling | anchored",
SlotKind::Bool => "bool",
SlotKind::U32 => "non-negative integer (u32)",
SlotKind::U64 => "non-negative integer (u64)",
SlotKind::Predicates => "list of { metric, cmp: gt|ge|lt|le, value }",
SlotKind::Strings => "list of strings",
SlotKind::Windows => "list of { from_ms, to_ms }",
SlotKind::Ref => "one of { content_id } | { identity_id }",
SlotKind::ContentRef => "content id of a process document",
SlotKind::Axes => "map: param name -> { kind, values }",
SlotKind::EmitKinds => "list of: family_table | selection_report",
SlotKind::TapKinds => "list of: equity | exposure | r_equity | net_r_equity",
SlotKind::Regimes => {
"list of stop regimes { vol: { length, k } }; absent = one default regime"
}
}
}
/// 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,
}
fn open(path: impl Into<String>, hint: impl Into<String>) -> OpenSlot {
OpenSlot { path: path.into(), hint: hint.into() }
}
/// 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(
"risk",
"optional, list of stop regimes { vol: { length, k } }; absent = one default regime",
));
}
match v.get("strategies").and_then(|s| s.as_array()) {
None => slots.push(open("strategies", "required, non-empty list of { ref, axes }")),
Some(list) if list.is_empty() => {
slots.push(open("strategies", "declared empty — a campaign needs at least one strategy"))
}
Some(list) => {
for (i, s) in list.iter().enumerate() {
if ref_slot_is_open(s.get("ref")) {
slots.push(open(
format!("strategies[{i}].ref"),
"required, one of: content_id, identity_id",
));
}
match s.get("axes").and_then(|a| a.as_object()) {
None => slots.push(open(
format!("strategies[{i}].axes"),
"required, map: param name -> { kind, values }",
)),
Some(axes) => {
for (axis, spec) in axes {
let empty = spec
.get("values")
.and_then(|c| c.as_array())
.is_none_or(|c| c.is_empty());
if empty {
slots.push(open(
format!("strategies[{i}].axes.{axis}"),
"axis declared empty — a P1 axis is a non-empty finite set",
));
}
}
}
}
}
}
}
if ref_slot_is_open(v.get("process").and_then(|p| p.get("ref"))) {
// deliberately narrower than the strategy-ref hint: validate_campaign
// refuses an identity_id process ref, so the guide must not offer it
slots.push(open("process.ref", "required, content id of a process document"));
}
if v.get("seed").and_then(|s| s.as_u64()).is_none() {
slots.push(open("seed", "required, non-negative integer"));
}
if v.get("presentation").is_none() {
slots.push(open(
"presentation",
format!(
"required section: persist_taps ({}) + emit",
tap_vocabulary().join(" | ")
),
));
}
Ok(slots)
}
fn envelope_open_slots(v: &serde_json::Value, kind: &str, slots: &mut Vec<OpenSlot>) {
if v.get("format_version").and_then(|x| x.as_u64()) != Some(u64::from(FORMAT_VERSION)) {
slots.push(open("format_version", "required, must be 1"));
}
if v.get("kind").and_then(|k| k.as_str()) != Some(kind) {
slots.push(open("kind", format!("required, must be \"{kind}\"")));
}
}
#[cfg(test)]
mod tests {
use super::*;
pub(crate) const PROCESS_FIXTURE: &str = r#"{
"format_version": 1,
"kind": "process",
"name": "wf-deflated-screen",
"description": "Sweep, keep only deflated-positive candidates, then walk-forward.",
"pipeline": [
{ "block": "std::sweep", "metric": "net_expectancy_r", "select": "plateau:worst", "deflate": true },
{ "block": "std::gate", "all": [
{ "metric": "net_expectancy_r", "cmp": "gt", "value": 0.0 },
{ "metric": "overfit_probability", "cmp": "lt", "value": 0.1 } ] },
{ "block": "std::walk_forward", "in_sample_ms": 4000, "out_of_sample_ms": 1000,
"step_ms": 1000, "mode": "rolling", "metric": "net_expectancy_r", "select": "argmax" }
]
}"#;
#[test]
fn process_fixture_parses_to_typed_stages() {
let doc = parse_process(PROCESS_FIXTURE).expect("fixture parses");
assert_eq!(doc.format_version, 1);
assert_eq!(doc.kind, DocKind::Process);
assert_eq!(doc.name, "wf-deflated-screen");
assert_eq!(doc.pipeline.len(), 3);
match &doc.pipeline[0] {
StageBlock::Sweep { selection } => {
let sel = selection.as_ref().expect("fixture sweep carries a selection");
assert_eq!(sel.metric, "net_expectancy_r");
assert_eq!(sel.select, SelectRule::PlateauWorst);
assert!(sel.deflate);
}
other => panic!("stage 0 is not a sweep: {other:?}"),
}
match &doc.pipeline[1] {
StageBlock::Gate { all } => {
assert_eq!(all.len(), 2);
assert_eq!(all[0].cmp, Cmp::Gt);
}
other => panic!("stage 1 is not a gate: {other:?}"),
}
match &doc.pipeline[2] {
StageBlock::WalkForward {
in_sample_ms,
out_of_sample_ms,
step_ms,
mode,
metric,
select,
} => {
assert_eq!(*in_sample_ms, 4000);
assert_eq!(*out_of_sample_ms, 1000);
assert_eq!(*step_ms, 1000);
assert_eq!(*mode, WfMode::Rolling);
assert_eq!(metric, "net_expectancy_r");
assert_eq!(*select, SelectRule::Argmax);
}
other => panic!("stage 2 is not a walk_forward: {other:?}"),
}
}
#[test]
fn process_parse_refuses_bad_envelope_and_unknown_vocabulary() {
// wrong format_version
let bad_fv = PROCESS_FIXTURE.replacen("\"format_version\": 1", "\"format_version\": 2", 1);
assert_eq!(parse_process(&bad_fv), Err(DocError::BadFormatVersion(2)));
// wrong kind
let bad_kind = PROCESS_FIXTURE.replacen("\"kind\": \"process\"", "\"kind\": \"campaign\"", 1);
assert_eq!(
parse_process(&bad_kind),
Err(DocError::WrongKind { expected: DocKind::Process })
);
// unknown block id
let bad_block = PROCESS_FIXTURE.replacen("std::sweep", "std::sweeep", 1);
let err = parse_process(&bad_block).expect_err("unknown block refused");
assert!(matches!(err, DocError::Malformed(msg) if msg.contains("unknown block")));
// unknown slot on a known block
let bad_slot = PROCESS_FIXTURE.replacen("\"deflate\": true", "\"deflaate\": true", 1);
let err = parse_process(&bad_slot).expect_err("unknown slot refused");
assert!(matches!(err, DocError::Malformed(msg) if msg.contains("unknown slot")));
// unknown select rule
let bad_select = PROCESS_FIXTURE.replacen("plateau:worst", "plateau:median", 1);
let err = parse_process(&bad_select).expect_err("unknown select refused");
assert!(matches!(err, DocError::Malformed(msg) if msg.contains("unknown select rule")));
// top-level unknown field (derive deny_unknown_fields)
let extra = PROCESS_FIXTURE.replacen("\"name\":", "\"nam3\": \"x\", \"name\":", 1);
assert!(matches!(parse_process(&extra), Err(DocError::Malformed(_))));
// not JSON at all
assert!(matches!(parse_process("not json"), Err(DocError::NotJson(_))));
}
pub(crate) const CAMPAIGN_FIXTURE: &str = r#"{
"format_version": 1,
"kind": "campaign",
"name": "ger40-momentum-screen",
"data": {
"instruments": ["GER40"],
"windows": [ { "from_ms": 1704067200000, "to_ms": 1719792000000 } ]
},
"strategies": [
{ "ref": { "content_id": "9f3a" },
"axes": { "fast": { "kind": "I64", "values": [8, 12, 16] },
"slow": { "kind": "I64", "values": [21, 34] } } }
],
"process": { "ref": { "content_id": "4e2d" } },
"seed": 1,
"presentation": {
"persist_taps": ["net_r_equity"],
"emit": ["family_table", "selection_report"]
}
}"#;
#[test]
fn campaign_fixture_parses_with_axis_level_kind() {
let doc = parse_campaign(CAMPAIGN_FIXTURE).expect("fixture parses");
assert_eq!(doc.kind, DocKind::Campaign);
assert_eq!(doc.data.instruments, vec!["GER40".to_string()]);
let s = &doc.strategies[0];
assert_eq!(s.r#ref, DocRef::ContentId("9f3a".into()));
let fast = &s.axes["fast"];
assert_eq!(fast.kind, ScalarKind::I64);
assert_eq!(fast.values, vec![Scalar::I64(8), Scalar::I64(12), Scalar::I64(16)]);
assert_eq!(doc.process.r#ref, DocRef::ContentId("4e2d".into()));
// an int literal under an F64 axis parses AS F64 (kind is the oracle)
let f64_axis: Axis =
serde_json::from_str(r#"{ "kind": "F64", "values": [1, 2.5] }"#).expect("parses");
assert_eq!(f64_axis.values, vec![Scalar::F64(1.0), Scalar::F64(2.5)]);
}
/// Axis's hand-rolled Serialize strips the tagged Scalar wire form down
/// to bare values (scalar_to_bare), and Deserialize re-parses those bare
/// values back through the declared kind (scalar_from_bare) — this must
/// round-trip exactly, and the wire shape must actually be bare (no
/// per-value `{"I64": ...}` tag leaking through).
#[test]
fn axis_serializes_to_bare_values_and_round_trips() {
let axis = Axis {
kind: ScalarKind::I64,
values: vec![Scalar::I64(8), Scalar::I64(12), Scalar::I64(16)],
};
let json = serde_json::to_value(&axis).expect("axis serializes");
assert_eq!(json, serde_json::json!({ "kind": "I64", "values": [8, 12, 16] }));
let round_tripped: Axis = serde_json::from_value(json).expect("bare json round-trips");
assert_eq!(round_tripped, axis);
}
#[test]
fn campaign_refuses_inline_process_and_kindless_axes() {
// an inline process body in the ref slot must refuse to parse
let inline = CAMPAIGN_FIXTURE.replacen(
r#""process": { "ref": { "content_id": "4e2d" } }"#,
r#""process": { "ref": { "pipeline": [] } }"#,
1,
);
assert!(matches!(parse_campaign(&inline), Err(DocError::Malformed(_))));
// an inline body BESIDE the ref must refuse too (deny_unknown_fields)
let beside = CAMPAIGN_FIXTURE.replacen(
r#""process": { "ref": { "content_id": "4e2d" } }"#,
r#""process": { "ref": { "content_id": "4e2d" }, "pipeline": [] }"#,
1,
);
assert!(matches!(parse_campaign(&beside), Err(DocError::Malformed(_))));
// a kindless bare array is not an axis
let bare = CAMPAIGN_FIXTURE.replacen(
r#"{ "kind": "I64", "values": [8, 12, 16] }"#,
"[8, 12, 16]",
1,
);
assert!(matches!(parse_campaign(&bare), Err(DocError::Malformed(_))));
// a tagged cell inside values is not a bare value
let tagged = CAMPAIGN_FIXTURE.replacen("[8, 12, 16]", r#"[{"I64": 8}]"#, 1);
assert!(matches!(parse_campaign(&tagged), Err(DocError::Malformed(_))));
// a float under an I64 axis refuses
let float_in_i64 = CAMPAIGN_FIXTURE.replacen("[8, 12, 16]", "[8.5]", 1);
assert!(matches!(parse_campaign(&float_in_i64), Err(DocError::Malformed(_))));
// wrong kind envelope
let bad_kind = CAMPAIGN_FIXTURE.replacen("\"kind\": \"campaign\"", "\"kind\": \"process\"", 1);
assert_eq!(
parse_campaign(&bad_kind),
Err(DocError::WrongKind { expected: DocKind::Campaign })
);
}
#[test]
fn process_canonical_form_is_pinned_and_id_stable() {
let doc = parse_process(PROCESS_FIXTURE).unwrap();
let canonical = process_to_json(&doc);
let golden = concat!(
r#"{"format_version":1,"kind":"process","name":"wf-deflated-screen","#,
r#""description":"Sweep, keep only deflated-positive candidates, then walk-forward.","#,
r#""pipeline":[{"block":"std::sweep","metric":"net_expectancy_r","select":"plateau:worst","deflate":true},"#,
r#"{"block":"std::gate","all":[{"metric":"net_expectancy_r","cmp":"gt","value":0.0},"#,
r#"{"metric":"overfit_probability","cmp":"lt","value":0.1}]},"#,
r#"{"block":"std::walk_forward","in_sample_ms":4000,"out_of_sample_ms":1000,"step_ms":1000,"#,
r#""mode":"rolling","metric":"net_expectancy_r","select":"argmax"}]}"#
);
assert_eq!(canonical, golden);
assert!(!canonical.ends_with('\n'));
let id = content_id_of(&canonical);
assert_eq!(id.len(), 64);
assert!(id.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()));
let reparsed = parse_process(&canonical).unwrap();
assert_eq!(content_id_of(&process_to_json(&reparsed)), id);
// omit-defaults: deflate:false disappears from the canonical form
let no_deflate = PROCESS_FIXTURE.replacen("\"deflate\": true", "\"deflate\": false", 1);
let canonical2 = process_to_json(&parse_process(&no_deflate).unwrap());
assert!(!canonical2.contains("\"deflate\":"));
assert_ne!(content_id_of(&canonical2), id);
}
#[test]
fn campaign_canonical_form_normalizes_and_distinguishes() {
let doc = parse_campaign(CAMPAIGN_FIXTURE).unwrap();
let canonical = campaign_to_json(&doc);
assert!(!canonical.ends_with('\n'));
let reparsed = parse_campaign(&canonical).unwrap();
assert_eq!(reparsed, doc);
assert_eq!(campaign_to_json(&reparsed), canonical);
let id = content_id_of(&canonical);
assert_eq!(id.len(), 64);
// a different seed is a different campaign
let other = CAMPAIGN_FIXTURE.replacen("\"seed\": 1", "\"seed\": 2", 1);
let other_id = content_id_of(&campaign_to_json(&parse_campaign(&other).unwrap()));
assert_ne!(other_id, id);
// spelling normalization: an F64 axis writes ints as floats, so the
// two spellings share one canonical form (and thus one content id)
let spelled_a = CAMPAIGN_FIXTURE.replacen(
r#""fast": { "kind": "I64", "values": [8, 12, 16] }"#,
r#""fast": { "kind": "F64", "values": [8, 12.0, 16] }"#,
1,
);
let spelled_b = CAMPAIGN_FIXTURE.replacen(
r#""fast": { "kind": "I64", "values": [8, 12, 16] }"#,
r#""fast": { "kind": "F64", "values": [8.0, 12, 16.0] }"#,
1,
);
let ca = campaign_to_json(&parse_campaign(&spelled_a).unwrap());
let cb = campaign_to_json(&parse_campaign(&spelled_b).unwrap());
assert_eq!(ca, cb);
assert!(ca.contains(r#""values":[8.0,12.0,16.0]"#));
}
#[test]
fn risk_regime_round_trips_as_externally_tagged_vol() {
let r = RiskRegime::Vol { length: 3, k: 2.0 };
let j = serde_json::to_string(&r).unwrap();
assert_eq!(j, r#"{"vol":{"length":3,"k":2.0}}"#);
let back: RiskRegime = serde_json::from_str(&j).unwrap();
assert_eq!(back, r);
}
#[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:?}");
}
#[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));
}
/// The intrinsic tier does NOT constrain where a population-transforming
/// stage sits relative to the annotators (terminal_seen gates only Gate
/// stages): `[sweep, monte_carlo, walk_forward]` is intrinsically valid.
/// Execution-shape refusals are the executor preflight's job, one tier up
/// — this pin is the boundary between the two tiers.
#[test]
fn validate_process_permits_walk_forward_after_an_annotator() {
let doc = parse_process(
r#"{ "format_version": 1, "kind": "process", "name": "wf-after-mc",
"pipeline": [
{ "block": "std::sweep", "metric": "sqn", "select": "argmax" },
{ "block": "std::monte_carlo", "resamples": 100, "block_len": 5 },
{ "block": "std::walk_forward", "in_sample_ms": 4000, "out_of_sample_ms": 1000,
"step_ms": 1000, "mode": "rolling", "metric": "sqn", "select": "argmax" }
] }"#,
)
.unwrap();
assert_eq!(validate_process(&doc), Vec::new());
}
#[test]
fn validate_process_accepts_the_fixture_and_reports_each_fault() {
let ok = parse_process(PROCESS_FIXTURE).unwrap();
assert_eq!(validate_process(&ok), Vec::new());
let empty = ProcessDoc { pipeline: Vec::new(), ..ok.clone() };
assert_eq!(validate_process(&empty), vec![DocFault::EmptyPipeline]);
let bad_metric = parse_process(&PROCESS_FIXTURE.replacen(
"net_expectancy_r\", \"select\": \"plateau:worst",
"netto_r\", \"select\": \"plateau:worst",
1,
))
.unwrap();
assert!(validate_process(&bad_metric)
.iter()
.any(|f| matches!(f, DocFault::UnknownMetric { stage: 0, metric } if metric == "netto_r")));
let gate_first = ProcessDoc {
pipeline: vec![StageBlock::Gate {
all: vec![Predicate { metric: "sqn".into(), cmp: Cmp::Gt, value: 1.0 }],
}],
..ok.clone()
};
assert!(validate_process(&gate_first).contains(&DocFault::GateFirst));
let empty_conj = ProcessDoc {
pipeline: vec![ok.pipeline[0].clone(), StageBlock::Gate { all: Vec::new() }],
..ok.clone()
};
assert!(validate_process(&empty_conj).contains(&DocFault::EmptyConjunction { stage: 1 }));
let gate_after = ProcessDoc {
pipeline: vec![
ok.pipeline[0].clone(),
StageBlock::MonteCarlo { resamples: 100, block_len: 5 },
StageBlock::Gate {
all: vec![Predicate { metric: "sqn".into(), cmp: Cmp::Gt, value: 1.0 }],
},
],
..ok.clone()
};
assert!(validate_process(&gate_after)
.contains(&DocFault::GateAfterTerminalStage { stage: 2 }));
}
/// The intrinsic tier's metric-validation branch for `std::sweep` must
/// skip a `None` selection cleanly (no metric to resolve, so no spurious
/// `UnknownMetric`) — the terminal-placement rule itself is the
/// executor preflight's job, not this validator's.
#[test]
fn validate_process_accepts_a_selection_free_sweep() {
let doc = ProcessDoc {
format_version: FORMAT_VERSION,
kind: DocKind::Process,
name: "sweep-only".to_string(),
description: None,
pipeline: vec![StageBlock::Sweep { selection: None }],
};
assert_eq!(validate_process(&doc), Vec::new());
}
#[test]
fn walk_forward_parses_both_modes_and_refuses_bad_slots() {
// "anchored" is the other accepted wire mode
let anchored = PROCESS_FIXTURE.replacen("\"mode\": \"rolling\"", "\"mode\": \"anchored\"", 1);
let doc = parse_process(&anchored).expect("anchored parses");
assert!(matches!(
&doc.pipeline[2],
StageBlock::WalkForward { mode: WfMode::Anchored, .. }
));
// an unknown mode string refuses through WfMode's serde oracle
let bad_mode = PROCESS_FIXTURE.replacen("\"mode\": \"rolling\"", "\"mode\": \"expanding\"", 1);
let err = parse_process(&bad_mode).expect_err("unknown mode refused");
assert!(matches!(err, DocError::Malformed(msg) if msg.contains("unknown mode \"expanding\"")));
// the retired 0106 vocabulary ("folds") is an unknown slot now
let folds = PROCESS_FIXTURE.replacen("\"in_sample_ms\": 4000", "\"folds\": 4", 1);
let err = parse_process(&folds).expect_err("folds refused");
assert!(matches!(err, DocError::Malformed(msg) if msg.contains("unknown slot \"folds\"")));
// a missing required length slot is named
let missing = PROCESS_FIXTURE.replacen("\"step_ms\": 1000, ", "", 1);
let err = parse_process(&missing).expect_err("missing step_ms refused");
assert!(
matches!(err, DocError::Malformed(msg) if msg.contains("missing required slot \"step_ms\""))
);
}
#[test]
fn validate_process_reports_each_zero_walk_forward_length() {
let ok = parse_process(PROCESS_FIXTURE).unwrap();
let zeroed = ProcessDoc {
pipeline: vec![
ok.pipeline[0].clone(),
StageBlock::WalkForward {
in_sample_ms: 0,
out_of_sample_ms: 0,
step_ms: 0,
mode: WfMode::Rolling,
metric: "net_expectancy_r".into(),
select: SelectRule::Argmax,
},
],
..ok.clone()
};
assert_eq!(
validate_process(&zeroed),
vec![
DocFault::ZeroWalkForwardLength { stage: 1, field: "in_sample_ms" },
DocFault::ZeroWalkForwardLength { stage: 1, field: "out_of_sample_ms" },
DocFault::ZeroWalkForwardLength { stage: 1, field: "step_ms" },
]
);
let one = ProcessDoc {
pipeline: vec![
ok.pipeline[0].clone(),
StageBlock::WalkForward {
in_sample_ms: 4000,
out_of_sample_ms: 1000,
step_ms: 0,
mode: WfMode::Anchored,
metric: "net_expectancy_r".into(),
select: SelectRule::Argmax,
},
],
..ok.clone()
};
assert_eq!(
validate_process(&one),
vec![DocFault::ZeroWalkForwardLength { stage: 1, field: "step_ms" }]
);
}
#[test]
fn validate_campaign_accepts_the_fixture_and_reports_each_fault() {
let ok = parse_campaign(CAMPAIGN_FIXTURE).unwrap();
assert_eq!(validate_campaign(&ok), Vec::new());
let mut bare = ok.clone();
bare.data.instruments.clear();
bare.data.windows.clear();
let faults = validate_campaign(&bare);
assert!(faults.contains(&DocFault::EmptyInstruments));
assert!(faults.contains(&DocFault::NoWindow));
let mut inv = ok.clone();
inv.data.windows[0] = Window { from_ms: 10, to_ms: 10 };
assert!(validate_campaign(&inv).contains(&DocFault::BadWindow { index: 0 }));
let mut none = ok.clone();
none.strategies.clear();
assert!(validate_campaign(&none).contains(&DocFault::NoStrategy));
let mut no_axes = ok.clone();
no_axes.strategies[0].axes.clear();
assert!(validate_campaign(&no_axes).contains(&DocFault::EmptyAxes { strategy: 0 }));
let mut empty_axis = ok.clone();
empty_axis.strategies[0]
.axes
.insert("slow".into(), Axis { kind: ScalarKind::I64, values: Vec::new() });
assert!(validate_campaign(&empty_axis)
.contains(&DocFault::EmptyAxis { strategy: 0, axis: "slow".into() }));
let mut ident = ok.clone();
ident.process.r#ref = DocRef::IdentityId("aaaa".into());
assert!(validate_campaign(&ident).contains(&DocFault::ProcessRefMustBeContentId));
let mut emit = ok.clone();
emit.presentation.emit.push("pie_chart".into());
assert!(validate_campaign(&emit).contains(&DocFault::UnknownEmitKind("pie_chart".into())));
}
/// #201 decision 1 (cycle 0109): the tap namespace is a CLOSED vocabulary —
/// the wrap convention's four persisted sink names. A genuinely new
/// observable is a new vocabulary entry (or an authored blueprint sink),
/// never an open node-path namespace in the document.
#[test]
fn tap_vocabulary_is_the_wrap_conventions_four_sink_names() {
assert_eq!(tap_vocabulary(), ["equity", "exposure", "r_equity", "net_r_equity"]);
}
/// Each `presentation.persist_taps` entry outside `tap_vocabulary()` is an
/// intrinsic fault carrying its index (for path-addressed prose downstream)
/// and the offending name — mirroring the UnknownEmitKind loop. The
/// fixture's own "net_r_equity" tap is in-vocabulary, so the clean-fixture
/// assert in `validate_campaign_accepts_the_fixture_and_reports_each_fault`
/// stays green unchanged.
#[test]
fn validate_campaign_refuses_unknown_taps_by_index() {
let ok = parse_campaign(CAMPAIGN_FIXTURE).unwrap();
let mut tap = ok.clone();
tap.presentation.persist_taps.push("bias".into());
assert_eq!(
validate_campaign(&tap),
vec![DocFault::UnknownTap { index: 1, tap: "bias".into() }]
);
// every in-vocabulary name passes
let mut all = ok.clone();
all.presentation.persist_taps = tap_vocabulary().iter().map(|t| t.to_string()).collect();
assert_eq!(validate_campaign(&all), Vec::new());
}
/// The campaign `data.bindings` block (#231): serde-ADDITIVE (a
/// binding-less document serializes with NO "bindings" key — every stored
/// campaign's content id is byte-stable — and legacy text parses to an
/// empty map), round-trips when present, and value-checks against the
/// closed column vocabulary at the intrinsic tier.
#[test]
fn bindings_are_serde_additive_and_value_checked() {
let legacy = format!(
concat!(
r#"{{"format_version":1,"kind":"campaign","name":"c","#,
r#""data":{{"instruments":["GER40"],"windows":[{{"from_ms":1,"to_ms":2}}]}},"#,
r#""strategies":[{{"ref":{{"content_id":"{id}"}},"axes":{{"len":{{"kind":"I64","values":[2]}}}}}}],"#,
r#""process":{{"ref":{{"content_id":"{id}"}}}},"#,
r#""seed":1,"presentation":{{"persist_taps":[],"emit":[]}}}}"#
),
id = "a".repeat(64),
);
let doc = parse_campaign(&legacy).expect("legacy campaign parses");
assert!(doc.data.bindings.is_empty());
assert!(!campaign_to_json(&doc).contains("bindings"));
assert!(validate_campaign(&doc).is_empty());
let with = legacy.replace(
r#""data":{"instruments""#,
r#""data":{"bindings":{"price":"open"},"instruments""#,
);
assert_ne!(with, legacy, "the fixture splice must hit");
let doc = parse_campaign(&with).expect("bindings campaign parses");
assert_eq!(doc.data.bindings.get("price").map(String::as_str), Some("open"));
assert!(campaign_to_json(&doc).contains(r#""bindings":{"price":"open"}"#));
assert!(validate_campaign(&doc).is_empty());
let bad = legacy.replace(
r#""data":{"instruments""#,
r#""data":{"bindings":{"price":"hl2"},"instruments""#,
);
let doc = parse_campaign(&bad).expect("parses (values are validate-tier)");
assert_eq!(
validate_campaign(&doc),
vec![DocFault::UnknownBindingColumn {
role: "price".to_string(),
column: "hl2".to_string()
}]
);
}
/// The `std::presentation` persist_taps slot advertises the closed tap
/// vocabulary through the describe path (`describe_block` +
/// `slot_kind_label`); the label is cross-pinned against `tap_vocabulary()`
/// so the static label and the validator cannot drift apart.
#[test]
fn persist_taps_slot_advertises_the_tap_vocabulary() {
let block = describe_block("std::presentation").expect("presentation describable");
let slot = block
.slots
.iter()
.find(|s| s.name == "persist_taps")
.expect("presentation has a persist_taps slot");
assert_eq!(slot.kind, SlotKind::TapKinds);
assert_eq!(
slot_kind_label(slot.kind),
format!("list of: {}", tap_vocabulary().join(" | "))
);
assert_eq!(
slot_kind_label(slot.kind),
"list of: equity | exposure | r_equity | net_r_equity"
);
}
/// A draft missing its `presentation` section is pointed at the section
/// AND the closed tap vocabulary (the guide never advertises an open
/// namespace the validator would refuse). The hint is cross-pinned
/// against `tap_vocabulary()` — like `persist_taps_slot_advertises_the_
/// tap_vocabulary` above — so a fifth tap cannot leave this draft hint
/// silently stale.
#[test]
fn presentation_open_slot_hint_names_the_tap_vocabulary() {
let draft = r#"{ "format_version": 1, "kind": "campaign", "name": "draft",
"data": { "instruments": ["GER40"], "windows": [ { "from_ms": 1, "to_ms": 2 } ] },
"strategies": [ { "ref": { "content_id": "9f3a" },
"axes": { "slow": { "kind": "I64", "values": [10] } } } ],
"process": { "ref": { "content_id": "4e2d" } },
"seed": 1 }"#;
let slots = open_slots_campaign(draft).unwrap();
let pres = slots
.iter()
.find(|s| s.path == "presentation")
.expect("presentation slot must be open");
assert_eq!(
pres.hint,
format!(
"required section: persist_taps ({}) + emit",
tap_vocabulary().join(" | ")
)
);
assert_eq!(
pres.hint,
"required section: persist_taps (equity | exposure | r_equity | net_r_equity) + emit"
);
}
#[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"]
);
for b in process_vocabulary().iter().chain(campaign_vocabulary()) {
assert!(!b.doc.is_empty(), "block {} has no doc line", b.id);
assert!(!b.slots.is_empty(), "block {} has no slots", b.id);
for s in b.slots {
assert!(!slot_kind_label(s.kind).is_empty());
}
}
let sweep = describe_block("std::sweep").expect("sweep describable");
assert!(sweep.slots.iter().any(|s| s.name == "metric" && !s.required));
assert!(sweep.slots.iter().any(|s| s.name == "deflate" && !s.required));
let wf = describe_block("std::walk_forward").expect("walk_forward describable");
let names: Vec<&str> = wf.slots.iter().map(|s| s.name).collect();
assert_eq!(
names,
vec!["in_sample_ms", "out_of_sample_ms", "step_ms", "mode", "metric", "select"]
);
let mode = wf.slots.iter().find(|s| s.name == "mode").expect("mode slot");
assert_eq!(slot_kind_label(mode.kind), "one of: rolling | anchored");
assert!(describe_block("std::strategy").is_some());
assert!(describe_block("std::nope").is_none());
}
/// A campaign process reference is content-id-only: `validate_campaign`
/// refuses an `identity_id` process ref (`ProcessRefMustBeContentId`), and
/// the block's own doc line says "(content id)". So the `std::process_ref`
/// describe output — the `describe_block` + `slot_kind_label` composition
/// the `--block` CLI verb renders — must not advertise an `identity_id`
/// form the validator would reject. The `--unwired` hint is already
/// narrowed (see `open_slots_campaign`); this pins the parallel describe
/// path to the same content-id-only contract. The narrowing is
/// process-ref-specific: a `std::strategy` ref legitimately accepts either
/// id form and must keep advertising both.
#[test]
fn process_ref_describe_advertises_content_id_only() {
let block = describe_block("std::process_ref").expect("process_ref describable");
let ref_slot =
block.slots.iter().find(|s| s.name == "ref").expect("process_ref has a ref slot");
let label = slot_kind_label(ref_slot.kind);
assert!(
!label.contains("identity_id"),
"process_ref advertises an identity_id form the validator refuses: {label}"
);
assert!(
label.contains("content"),
"process_ref describe must still name the content-id form: {label}"
);
let strategy = describe_block("std::strategy").expect("strategy describable");
let strat_ref =
strategy.slots.iter().find(|s| s.name == "ref").expect("strategy has a ref slot");
let strat_label = slot_kind_label(strat_ref.kind);
assert!(
strat_label.contains("identity_id") && strat_label.contains("content_id"),
"strategy ref must keep advertising both id forms: {strat_label}"
);
}
#[test]
fn open_slots_report_partial_documents_by_path() {
assert_eq!(open_slots_process(PROCESS_FIXTURE).unwrap(), Vec::new());
// #216: a complete risk-less campaign has exactly ONE open slot — the
// optional risk axis (the probe's one optional line).
assert_eq!(
open_slots_campaign(CAMPAIGN_FIXTURE).unwrap(),
vec![open(
"risk",
"optional, list of stop regimes { vol: { length, k } }; absent = one default regime",
)]
);
let partial = r#"{ "format_version": 1, "kind": "process",
"pipeline": [ { "block": "std::sweep", "metric": "sqn" } ] }"#;
let slots = open_slots_process(partial).unwrap();
assert!(slots.iter().any(|s| s.path == "name"));
assert!(!slots.iter().any(|s| s.path == "pipeline[0].select"));
assert!(!slots.iter().any(|s| s.path == "pipeline[0].metric"));
let draft = r#"{ "format_version": 1, "kind": "campaign", "name": "draft",
"data": { "instruments": ["GER40"], "windows": [ { "from_ms": 1, "to_ms": 2 } ] },
"strategies": [ { "ref": { "content_id": "9f3a" },
"axes": { "slow": { "kind": "I64", "values": [] } } } ],
"seed": 1,
"presentation": { "persist_taps": [], "emit": [] } }"#;
let slots = open_slots_campaign(draft).unwrap();
assert!(slots.iter().any(|s| s.path == "process.ref"
&& s.hint == "required, content id of a process document"));
assert!(slots.iter().any(|s| s.path == "strategies[0].axes.slow"
&& s.hint == "axis declared empty — a P1 axis is a non-empty finite set"));
assert!(matches!(open_slots_process("nope"), Err(DocError::NotJson(_))));
}
/// Property: in the open-slot probe, a ref placeholder — `"ref": {}` or
/// `"ref": null` — is a drafting affordance that reports the SAME open slot
/// (same path, same existing hint) the omitted-key form reports, on both a
/// strategy entry and `process.ref`, and never aborts the probe. It lets one
/// draft file both validate-so-far and enumerate its open slots (fieldtest
/// 0106 F4 / #195). This pins only the probe; the strict `parse_campaign`
/// tier keeps refusing placeholders (canonical-form byte stability outranks
/// draft leniency) and is deliberately not exercised here.
#[test]
fn open_slots_report_placeholder_refs_as_open_slots() {
let draft = r#"{ "format_version": 1, "kind": "campaign", "name": "draft",
"data": { "instruments": ["GER40"], "windows": [ { "from_ms": 1, "to_ms": 2 } ] },
"strategies": [ { "ref": null,
"axes": { "slow": { "kind": "I64", "values": [10, 20] } } } ],
"process": { "ref": {} },
"seed": 1,
"presentation": { "persist_taps": [], "emit": [] } }"#;
// Errors on neither placeholder form (the probe stays a probe).
let slots = open_slots_campaign(draft).expect("placeholder refs must not abort the probe");
// The `null` strategy ref reports the strategy-ref open slot with its
// existing both-id-forms hint...
assert!(
slots.iter().any(|s| s.path == "strategies[0].ref"
&& s.hint == "required, one of: content_id, identity_id"),
"strategy `ref: null` placeholder not reported as an open slot: {slots:?}"
);
// ...and the `{}` process ref reports the narrower content-id-only hint.
assert!(
slots.iter().any(|s| s.path == "process.ref"
&& s.hint == "required, content id of a process document"),
"process `ref: {{}}` placeholder not reported as an open slot: {slots:?}"
);
}
/// #216: the optional risk slot is listed only while unbound — a document
/// with a non-empty risk list closes it (a complete bound document has no
/// open slots at all; the risk-less complement is pinned in
/// `open_slots_report_partial_documents_by_path`).
#[test]
fn open_slots_omit_a_bound_risk_slot() {
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::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)));
}
}