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:
@@ -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([(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 } => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user