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
+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 {