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