diff --git a/crates/aura-campaign/src/lib.rs b/crates/aura-campaign/src/lib.rs index 7464a71..5e743b6 100644 --- a/crates/aura-campaign/src/lib.rs +++ b/crates/aura-campaign/src/lib.rs @@ -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([( diff --git a/crates/aura-campaign/tests/execute.rs b/crates/aura-campaign/tests/execute.rs index 35d2fd3..db9a539 100644 --- a/crates/aura-campaign/tests/execute.rs +++ b/crates/aura-campaign/tests/execute.rs @@ -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(), diff --git a/crates/aura-cli/src/research_docs.rs b/crates/aura-cli/src/research_docs.rs index e06a757..e724a36 100644 --- a/crates/aura-cli/src/research_docs.rs +++ b/crates/aura-cli/src/research_docs.rs @@ -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 } => { diff --git a/crates/aura-cli/src/verb_sugar.rs b/crates/aura-cli/src/verb_sugar.rs index 3d99db3..4e2bb94 100644 --- a/crates/aura-cli/src/verb_sugar.rs +++ b/crates/aura-cli/src/verb_sugar.rs @@ -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. diff --git a/crates/aura-cli/tests/research_docs.rs b/crates/aura-cli/tests/research_docs.rs index fd0702f..c8ed0b9 100644 --- a/crates/aura-cli/tests/research_docs.rs +++ b/crates/aura-cli/tests/research_docs.rs @@ -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 { diff --git a/crates/aura-research/src/lib.rs b/crates/aura-research/src/lib.rs index caaa930..e10f8a3 100644 --- a/crates/aura-research/src/lib.rs +++ b/crates/aura-research/src/lib.rs @@ -429,6 +429,12 @@ pub struct CampaignDoc { #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, pub data: DataSection, + /// Structural risk-execution axis: the finite list of stop regimes the + /// matrix runs each cell under. Absent or empty = one implicit default + /// regime (the runtime's baked constants). Absent-serializes → content-id + /// parity for every risk-less document (the `description` precedent). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub risk: Vec, pub strategies: Vec, pub process: ProcessRef, pub seed: u64, @@ -470,6 +476,18 @@ pub struct Axis { pub values: Vec, } +/// A protective-stop regime: a serializable, content-addressable mirror of the +/// runtime `StopRule` structural axis (C10/C20). The sole implemented variant is +/// the vol-stop; the shipped fixed-stop rule is admitted as a future additive +/// variant (it runs as a composite today but is not yet campaign-reachable). +/// Externally tagged so adding `Fixed` is additive — no content-id churn on +/// stored `Vol` regimes. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "snake_case")] +pub enum RiskRegime { + Vol { length: i64, k: f64 }, +} + /// 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 { 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.