feat(research): RiskRegime vocabulary + CampaignDoc.risk field + validation (#210 T1-2)

The first slice of the risk-regime structural axis (spec/plan abab1f1):

- aura-research: a serializable `RiskRegime` closed vocabulary (externally
  tagged, sole variant `Vol { length, k }` — a content-addressable mirror of
  the shipped two-variant StopRule, so a future Fixed regime is additive), and
  an optional top-level `CampaignDoc.risk: Vec<RiskRegime>` that absent-
  serializes (the `description` precedent) — content-id parity for every
  risk-less document.
- validate_campaign refuses a non-positive regime (length < 1 or k <= 0, NaN
  included) via a new `DocFault::BadRegime`; the CLI renders it as prose at the
  research_docs seam (register/validate paths).
- The three CLI-seam E2E tests pin that a well-formed risk section passes
  `campaign validate` and a bad one is refused with prose (exit 1, no Debug
  leak) — the field is reachable at the seam a data author drives, not only in
  library units.

Adding the field is compile-breaking at every `CampaignDoc { .. }` literal
(serde default does not cover Rust construction), so this slice also threads
`risk: vec![]` into the sites across aura-cli and aura-campaign — the
field-addition's compile-gate completion the plan should have scoped here. The
BadRegime prose reads `risk[i]: ...` (the implementer's terser wording over the
plan's illustrative string; the spec pinned no exact text). Full workspace
suite green.

refs #210
This commit is contained in:
2026-07-06 14:02:33 +02:00
parent abab1f1684
commit 2f9f3571ae
6 changed files with 171 additions and 0 deletions
+2
View File
@@ -468,6 +468,7 @@ mod tests {
instruments: vec!["EURUSD".to_string()],
windows: vec![Window { from_ms: 0, to_ms: 10_000 }],
},
risk: vec![],
strategies: vec![StrategyEntry {
r#ref: DocRef::ContentId("0".repeat(64)),
axes: BTreeMap::from([(
@@ -877,6 +878,7 @@ mod wf_tests {
instruments: vec!["SYNTH".to_string()],
windows: vec![Window { from_ms: window.0, to_ms: window.1 }],
},
risk: vec![],
strategies: vec![StrategyEntry {
r#ref: DocRef::ContentId("c".repeat(64)),
axes: BTreeMap::from([(
+1
View File
@@ -150,6 +150,7 @@ fn campaign(instruments: &[&str]) -> CampaignDoc {
instruments: instruments.iter().map(|s| s.to_string()).collect(),
windows: vec![Window { from_ms: 1_000, to_ms: 9_000 }],
},
risk: vec![],
strategies: vec![StrategyEntry {
r#ref: DocRef::ContentId(STRATEGY_ID.to_string()),
axes: axes_2x2(),
+3
View File
@@ -141,6 +141,9 @@ pub(crate) fn doc_fault_prose(f: &DocFault) -> String {
DocFault::BadWindow { index } => {
format!("data.windows[{index}]: from_ms must be earlier than to_ms")
}
DocFault::BadRegime { index } => {
format!("risk[{index}]: stop length must be >= 1 and k must be > 0")
}
DocFault::NoStrategy => "strategies is empty".into(),
DocFault::EmptyAxes { strategy } => format!("strategies[{strategy}]: axes is empty"),
DocFault::EmptyAxis { strategy, axis } => {
+4
View File
@@ -87,6 +87,10 @@ pub(crate) fn translate_sweep(
axes: doc_axes,
}],
process: ProcessRef { r#ref: DocRef::ContentId(content_id_of(&process_to_json(&process))) },
// No stop regime is bound for a dissolved single-sweep member; the
// R-path stop is a wrap_r constant baked outside the signal axes
// (absent risk == empty risk, per the established parity).
risk: vec![],
// No stage of a selection-free single-sweep pipeline consumes the
// seed; a fixed zero keeps generated bytes deterministic so identical
// invocations dedupe onto identical content ids.
+76
View File
@@ -500,6 +500,53 @@ fn campaign_validate_refuses_empty_axis_prose_exit_1() {
assert!(!out.contains("EmptyAxis"), "Debug leak: {out}");
}
/// #210 risk-regime axis: a campaign document carrying a `risk` section
/// round-trips through the CLI's actual `campaign validate` binary path
/// (parse -> `validate_campaign`) without a fault. Before this iteration the
/// `RiskRegime`/`CampaignDoc.risk` wiring was proven only inside
/// `aura-research`'s own unit tests; this pins that the new field is actually
/// reachable — and accepted when well-formed — at the CLI seam a data author
/// drives, not merely inside the library.
#[test]
fn campaign_validate_accepts_a_valid_risk_section_end_to_end() {
let dir = temp_cwd("campaign-validate-risk-ok");
let with_risk = CAMPAIGN_DOC.replacen(
"\"seed\": 1,",
"\"seed\": 1,\n \"risk\": [ { \"vol\": { \"length\": 3, \"k\": 1.5 } } ],",
1,
);
assert_ne!(with_risk, CAMPAIGN_DOC, "replacen must actually match the fixture's seed field");
write_doc(&dir, "risk-ok.campaign.json", &with_risk);
let (out, code) = run_code_in(&dir, &["campaign", "validate", "risk-ok.campaign.json"]);
assert_eq!(code, Some(0), "stdout/stderr: {out}");
assert!(out.contains("campaign document valid (intrinsic):"), "stdout/stderr: {out}");
assert!(!out.contains("BadRegime"), "Debug leak: {out}");
}
/// #210: a non-positive stop regime (`k <= 0`, here `k: 0.0`) is refused by
/// `validate_campaign`'s `DocFault::BadRegime` and — the property under test —
/// that fault reaches the CLI's `doc_fault_prose` mapping end to end: exit 1,
/// prose naming the offending index and BOTH malformed fields ("stop length
/// must be >= 1 and k must be > 0"), never the bare `BadRegime` Debug variant.
#[test]
fn campaign_validate_refuses_bad_risk_regime_prose_exit_1() {
let dir = temp_cwd("campaign-validate-risk-bad");
let with_bad_risk = CAMPAIGN_DOC.replacen(
"\"seed\": 1,",
"\"seed\": 1,\n \"risk\": [ { \"vol\": { \"length\": 3, \"k\": 0.0 } } ],",
1,
);
assert_ne!(with_bad_risk, CAMPAIGN_DOC, "replacen must actually match the fixture's seed field");
write_doc(&dir, "risk-bad.campaign.json", &with_bad_risk);
let (out, code) = run_code_in(&dir, &["campaign", "validate", "risk-bad.campaign.json"]);
assert_eq!(code, Some(1), "stdout/stderr: {out}");
assert!(
out.contains("risk[0]: stop length must be >= 1 and k must be > 0"),
"stdout/stderr: {out}"
);
assert!(!out.contains("BadRegime"), "Debug leak: {out}");
}
#[test]
fn campaign_introspect_vocabulary_lists_sections() {
let (out, code) = run_code(&["campaign", "introspect", "--vocabulary"]);
@@ -719,6 +766,35 @@ fn campaign_register_refuses_invalid_document_and_writes_nothing() {
);
}
/// #210: the register validate-gate extends to the new risk axis — a
/// document whose only fault is a non-positive stop regime is refused with
/// prose (exit 1) and, the property that matters for a content-addressed
/// store, no file is ever written under `runs/campaigns/`. A store that
/// accepted a document because register only checked the OLD fault kinds
/// would silently persist an unenforceable risk regime.
#[test]
fn campaign_register_refuses_invalid_risk_section_and_writes_nothing() {
let dir = temp_cwd("campaign-register-invalid-risk");
let bad = CAMPAIGN_DOC.replacen(
"\"seed\": 1,",
"\"seed\": 1,\n \"risk\": [ { \"vol\": { \"length\": 0, \"k\": 2.0 } } ],",
1,
);
assert_ne!(bad, CAMPAIGN_DOC, "replacen must actually match the fixture's seed field");
write_doc(&dir, "bad-risk.campaign.json", &bad);
let (out, code) = run_code_in(&dir, &["campaign", "register", "bad-risk.campaign.json"]);
assert_eq!(code, Some(1), "stdout/stderr: {out}");
assert!(out.contains("refusing to register:"), "stdout/stderr: {out}");
assert!(
out.contains("risk[0]: stop length must be >= 1 and k must be > 0"),
"stdout/stderr: {out}"
);
assert!(
!dir.join("runs").join("campaigns").exists(),
"register must not create a store entry for an invalid risk section"
);
}
/// Seed one open-param blueprint into the built demo project's store via a
/// real sweep and return its content id (the referential test's recipe).
fn seed_blueprint(dir: &Path, name: &str) -> String {
+85
View File
@@ -429,6 +429,12 @@ pub struct CampaignDoc {
#[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,
@@ -470,6 +476,18 @@ pub struct Axis {
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 {
@@ -656,6 +674,7 @@ pub enum DocFault {
EmptyInstruments,
NoWindow,
BadWindow { index: usize },
BadRegime { index: usize },
NoStrategy,
EmptyAxes { strategy: usize },
EmptyAxis { strategy: usize, axis: String },
@@ -745,6 +764,13 @@ pub fn validate_campaign(doc: &CampaignDoc) -> Vec<DocFault> {
faults.push(DocFault::BadWindow { index: i });
}
}
for (i, r) in doc.risk.iter().enumerate() {
let RiskRegime::Vol { length, k } = r;
// length < 1 catches 0 and negatives; !(k > 0.0) catches k <= 0 AND NaN.
if *length < 1 || !(*k > 0.0) {
faults.push(DocFault::BadRegime { index: i });
}
}
if doc.strategies.is_empty() {
faults.push(DocFault::NoStrategy);
}
@@ -1248,6 +1274,65 @@ mod tests {
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.