diff --git a/docs/plans/risk-regime-axis.md b/docs/plans/risk-regime-axis.md new file mode 100644 index 0000000..ac43c9a --- /dev/null +++ b/docs/plans/risk-regime-axis.md @@ -0,0 +1,767 @@ +# Risk regime as a structural campaign axis — Implementation Plan + +> **Parent spec:** `docs/specs/risk-regime-axis.md` +> +> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to +> run this plan. Steps use `- [ ]` checkboxes for tracking. + +**Goal:** Make the protective-stop regime a first-class structural axis of the +campaign matrix — a peer of instruments and windows — so a campaign runs every +`(strategy, instrument, window, regime)` cell, stamps each member's resolved +stop into its manifest, and compares regimes at presentation without ever +argmax-selecting across them. + +**Architecture:** Three tiers. `aura-research` gains the serializable +`RiskRegime` vocabulary and an optional `CampaignDoc.risk` list (absent = one +implicit default regime, absent-serializes for content-id parity). +`aura-campaign` (+ the `aura-registry` record it writes) expands the matrix with +the regime as a *kept-separate* axis (peer of `window`; the nominee key gains +the regime ordinal, so generalize aggregates across instruments *within* a +regime, never across regimes). `aura-cli` maps `RiskRegime → StopRule` at the +member-runner boundary, threads it through `wrap_r`'s bound-stop arm, and stamps +it into the manifest. + +**Tech Stack:** Rust workspace crates `aura-research`, `aura-registry`, +`aura-campaign`, `aura-cli`; serde; the `wrap_r` R-scaffolding; the campaign +executor + registry. + +**Two bounded, documented descopes** (non-load-bearing details of the spec's +Components/Family-naming sections, deferred as clean follow-ups): + +1. **Descriptive `CAMPAIGN_SECTIONS` introspection entry for `risk`.** `risk` is + a top-level array, not a slot-bearing section like `data`/`strategy`, so it + does not fit the current `BlockSchema` slot model. The load-bearing + *validation* (a bad regime is refused) still lands, in Task 2. Introspection + completeness for the regime list is a follow-up. +2. **Multi-regime trace-directory isolation (`campaign_cell_key`).** The + load-bearing registry family-collision fix is the `-r{regime_ordinal}` + discriminator in the `family_name` (Task 3, exec.rs) — the family store keys + on that. `campaign_cell_key` names *trace* dirs and runs only when + `persist_taps` is non-empty; threading a regime through it would drag in a + `CellRealization.regime_ordinal` stored-format change. This cycle's E2E uses + `persist_taps: []`, so multi-regime trace-dir isolation is deferred. + +--- + +**Files this plan creates or modifies:** + +- Modify: `crates/aura-research/src/lib.rs` — `RiskRegime` enum, `CampaignDoc.risk` field, `DocFault::BadRegime`, `validate_campaign` regime check, in-file tests. +- Modify: `crates/aura-cli/src/research_docs.rs:141` — CLI phrasing for `DocFault::BadRegime`. +- Modify: `crates/aura-registry/src/lineage.rs:158` — `CampaignGeneralization.regime_ordinal`. +- Modify: `crates/aura-registry/src/lib.rs` — the `CampaignGeneralization` test literal. +- Modify: `crates/aura-campaign/src/lib.rs:26` — `CellSpec.regime` + `regime_ordinal`. +- Modify: `crates/aura-campaign/src/exec.rs` — regime expansion, 3-tuple nominee key, generalize `regime_ordinal`, family-name discriminator. +- Modify: `crates/aura-campaign/tests/member_seam_e2e.rs:18` — `CellSpec` literal. +- Modify: `crates/aura-campaign/tests/execute.rs` — family-id pins + new two-regime test. +- Modify: `crates/aura-cli/src/main.rs` — `StopArm`, `wrap_r` signature, `r_sma_graph` map, `run_blueprint_member` stop param + manifest stamp + its 6 callers. +- Modify: `crates/aura-cli/src/campaign_run.rs` — `CliMemberRunner::run_member` regime→stop map. +- Modify: `crates/aura-cli/tests/cli_run.rs` — flip the characterization pin, extend the dissolved-contract pin, add the two-regime E2E. +- Modify: `crates/aura-cli/src/verb_sugar.rs:76` — `risk: vec![]` in the generated doc. +- Modify: `docs/design/INDEX.md` — the C10/C20 amendment note. + +--- + +## Task 1: `RiskRegime` vocabulary + `CampaignDoc.risk` field (aura-research) + +**Files:** +- Modify: `crates/aura-research/src/lib.rs` + +- [ ] **Step 1: Write the failing tests** + +Add to the `#[cfg(test)] mod` in `crates/aura-research/src/lib.rs` (the module that already holds `CAMPAIGN_FIXTURE`): + +```rust +#[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); +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cargo test -p aura-research risk_regime 2>&1 | tail -20` +Expected: compile error — `cannot find type RiskRegime` / no field `risk` on `CampaignDoc`. + +- [ ] **Step 3: Add the `RiskRegime` enum** + +In `crates/aura-research/src/lib.rs`, immediately after the `Axis` type block (after the `pub struct Axis { pub kind: ScalarKind, pub values: Vec }` region near line 471), add: + +```rust +/// 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 }, +} +``` + +- [ ] **Step 4: Add the `CampaignDoc.risk` field** + +In `crates/aura-research/src/lib.rs`, in `pub struct CampaignDoc` (line 425-436), insert the `risk` field immediately after `pub data: DataSection,`: + +```rust + 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, +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +(One positional filter per invocation — a multi-filter `cargo test X Y` prints nothing in this environment.) +Run: `cargo test -p aura-research risk_regime_round_trips 2>&1 | tail -5` +Expected: PASS (1 test). +Run: `cargo test -p aura-research campaign_absent_and_empty_risk 2>&1 | tail -5` +Expected: PASS (1 test). +Run: `cargo test -p aura-research campaign_with_risk_serializes 2>&1 | tail -5` +Expected: PASS (1 test). + +--- + +## Task 2: `validate_campaign` regime refusal + CLI phrasing (aura-research + aura-cli) + +**Files:** +- Modify: `crates/aura-research/src/lib.rs` +- Modify: `crates/aura-cli/src/research_docs.rs:141` + +- [ ] **Step 1: Write the failing test** + +Add to the `#[cfg(test)] mod` in `crates/aura-research/src/lib.rs`: + +```rust +#[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)); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cargo test -p aura-research validate_campaign_flags 2>&1 | tail -20` +Expected: compile error — `no variant BadRegime on DocFault`. + +- [ ] **Step 3: Add the `DocFault::BadRegime` variant** + +In `crates/aura-research/src/lib.rs`, in `pub enum DocFault`, insert on the campaign side, immediately after `BadWindow { index: usize },` (line 658): + +```rust + BadWindow { index: usize }, + BadRegime { index: usize }, +``` + +- [ ] **Step 4: Add the regime check to `validate_campaign`** + +In `crates/aura-research/src/lib.rs`, in `pub fn validate_campaign`, insert after the windows loop closes (after line 747, the `}` that ends the `for (i, w) in doc.data.windows...` loop): + +```rust + 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 }); + } + } +``` + +- [ ] **Step 5: Add the CLI phrasing arm (cross-crate compile break — same task)** + +In `crates/aura-cli/src/research_docs.rs`, in the `DocFault` match, insert immediately after the `DocFault::BadWindow { index } => { ... }` arm (line 141): + +```rust + DocFault::BadRegime { index } => { + format!("risk regime {index} is invalid: stop length must be >= 1 and k must be > 0") + } +``` + +- [ ] **Step 6: Run the tests + the workspace build gate** + +Run: `cargo build -p aura-research -p aura-cli 2>&1 | tail -5` +Expected: `Finished` — 0 errors (the `DocFault` match is exhaustive again). +Run: `cargo test -p aura-research validate_campaign_flags 2>&1 | tail -5` +Expected: PASS (1 test). +Run: `cargo test -p aura-research validate_campaign_accepts_a_valid_risk 2>&1 | tail -5` +Expected: PASS (1 test). + +--- + +## Task 3: matrix regime expansion (aura-campaign + aura-registry) + +**Files:** +- Modify: `crates/aura-registry/src/lineage.rs:158` +- Modify: `crates/aura-registry/src/lib.rs` — the `CampaignGeneralization` test literal (~1852) +- Modify: `crates/aura-campaign/src/lib.rs:26` +- Modify: `crates/aura-campaign/src/exec.rs` +- Modify: `crates/aura-campaign/tests/member_seam_e2e.rs:18` +- Modify: `crates/aura-campaign/tests/execute.rs` + +- [ ] **Step 1: Write the failing test** + +Add to `crates/aura-campaign/tests/execute.rs` (modeled on `execute_sweep_only_records_family_and_selection`): + +```rust +#[test] +fn execute_runs_one_family_per_regime_with_distinct_ids() { + let reg = temp_registry("two_regimes"); + let mut doc = campaign(&["EURUSD"]); + doc.risk = vec![ + aura_research::RiskRegime::Vol { length: 3, k: 1.5 }, + aura_research::RiskRegime::Vol { length: 3, k: 2.0 }, + ]; + let proc_doc = process(vec![sweep_stage(false)]); + let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®) + .expect("two-regime campaign executes"); + + // one cell per regime, each with its own sweep family. + assert_eq!(out.cells.len(), 2, "one cell per regime"); + let p = &CAMPAIGN_ID[..8]; + assert_eq!(out.cells[0].families[0].family_id, format!("{p}-0-EURUSD-w0-r0-s0-0")); + assert_eq!(out.cells[1].families[0].family_id, format!("{p}-0-EURUSD-w0-r1-s0-0")); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cargo test -p aura-campaign execute_runs_one_family_per_regime 2>&1 | tail -20` +Expected: compile error — no field `risk` visible on the test `doc` path is fine (Task 1 shipped it), so the real failure is the assertion: `family_id` is `...-w0-s0-0` (no `-r0`) and `out.cells.len() == 1` (regimes not expanded). + +- [ ] **Step 3: Add `regime_ordinal` to `CampaignGeneralization` (aura-registry)** + +In `crates/aura-registry/src/lineage.rs`, in `pub struct CampaignGeneralization` (line 158), insert after `pub window_ordinal: usize,`: + +```rust + pub window_ordinal: usize, + /// The regime ordinal (0 for the default regime) — the third structural + /// coordinate, so a generalize grade is unambiguous per (strategy, window, + /// regime). `#[serde(default)]` keeps pre-feature stored records parsing. + #[serde(default)] + pub regime_ordinal: usize, +``` + +- [ ] **Step 4: Update the `CampaignGeneralization` test literal (aura-registry)** + +In `crates/aura-registry/src/lib.rs`, find the `CampaignGeneralization { ... }` literal in the tests (near line 1852) and add `regime_ordinal: 0,` after its `window_ordinal:` field. + +Run: `git grep -nE 'CampaignGeneralization \{' -- crates/aura-registry/src/lib.rs` +(then edit the one literal — the `pub struct` def is in lineage.rs, not lib.rs). + +- [ ] **Step 5: Add the regime fields to `CellSpec` (aura-campaign)** + +In `crates/aura-campaign/src/lib.rs`, in `pub struct CellSpec` (line 26-37), insert after `pub window_ms: (i64, i64),`: + +```rust + pub window_ms: (i64, i64), + /// The cell's risk regime. `None` = the member runner's baked default (the + /// absent/empty-`risk` case); `Some` = an explicit document regime. + pub regime: Option, + /// The regime's ordinal in the resolved regime list (0 for the default) — + /// keys the generalize unit and discriminates family names. + pub regime_ordinal: usize, +``` + +(If `aura_research::RiskRegime` is not yet imported in this file, add it to the existing `use aura_research::{...}` line.) + +- [ ] **Step 6: Expand the matrix over regimes in `exec.rs`** + +In `crates/aura-campaign/src/exec.rs`, change the nominee map type (line 124): + +```rust + // before + let mut nominees: BTreeMap<(usize, usize), Vec<(String, Nominee)>> = BTreeMap::new(); + // after + let mut nominees: BTreeMap<(usize, usize, usize), Vec<(String, Nominee)>> = BTreeMap::new(); +``` + +Immediately before the `for (strategy_ordinal, ...)` loop (line 125), resolve the regime list: + +```rust + // Absent/empty risk = a single default cell (regime None, ordinal 0); a + // non-empty list maps each regime to Some. The default's value is the member + // runner's — the doc is never mutated (its content id hashes with risk absent). + let regimes: Vec<(usize, Option)> = if campaign.risk.is_empty() { + vec![(0, None)] + } else { + campaign.risk.iter().copied().enumerate().map(|(i, r)| (i, Some(r))).collect() + }; +``` + +Wrap the cell build in a regime loop (innermost, inside the window loop at line 129) and thread the ordinal into the cell + nominee key: + +```rust + for (window_ordinal, window) in campaign.data.windows.iter().enumerate() { + for (regime_ordinal, regime) in ®imes { + let cell = CellSpec { + strategy_ordinal, + strategy_id: strategy_id.clone(), + blueprint_json: blueprint_json.clone(), + axes: entry.axes.clone(), + instrument: instrument.clone(), + window_ms: (window.from_ms, window.to_ms), + regime: *regime, + regime_ordinal: *regime_ordinal, + }; + let (outcome, realization) = run_cell( + &cell, process, campaign_prefix, window_ordinal, campaign.seed, runner, registry, + )?; + nominees + .entry((strategy_ordinal, window_ordinal, *regime_ordinal)) + .or_default() + .push((instrument.clone(), outcome.nominee.clone())); + cells_out.push(outcome); + cells_rec.push(realization); + } + } +``` + +- [ ] **Step 7: Thread the regime ordinal through generalize** + +In `crates/aura-campaign/src/exec.rs`, the generalize loop (line 166) now iterates the 3-tuple key; update the destructuring and the `CampaignGeneralization` construction (line 187): + +```rust + // before + for ((strategy_ordinal, window_ordinal), cells) in &nominees { + // after + for ((strategy_ordinal, window_ordinal, regime_ordinal), cells) in &nominees { +``` + +and in the `CampaignGeneralization { ... }` literal add `regime_ordinal: *regime_ordinal,` after `window_ordinal: *window_ordinal,`. + +- [ ] **Step 8: Add the `-r{regime_ordinal}` family-name discriminator** + +In `crates/aura-campaign/src/exec.rs`, at BOTH family-name format sites (line 250 and line 352), change: + +```rust + // before + "{campaign_prefix}-{}-{}-w{window_ordinal}-s{stage_ordinal}", + cell.strategy_ordinal, cell.instrument, + // after + "{campaign_prefix}-{}-{}-w{window_ordinal}-r{}-s{stage_ordinal}", + cell.strategy_ordinal, cell.instrument, cell.regime_ordinal, +``` + +- [ ] **Step 9: Update the `CellSpec` literal in `member_seam_e2e.rs`** + +In `crates/aura-campaign/tests/member_seam_e2e.rs`, in `fn cell()` (line 18-26), add after `instrument: "EURUSD".to_string(),` / `window_ms: (0, 20),`: + +```rust + window_ms: (0, 20), + regime: None, + regime_ordinal: 0, +``` + +- [ ] **Step 10: Update the family-id pins in `execute.rs`** + +In `crates/aura-campaign/tests/execute.rs`, the existing tests use `campaign()` (no risk → one default regime, ordinal 0), so every family id gains exactly `-r0`. Update all three sites: + +```rust + // line 238 and line 301: before + let expected_family_id = format!("{}-0-EURUSD-w0-s0-0", &CAMPAIGN_ID[..8]); + // after + let expected_family_id = format!("{}-0-EURUSD-w0-r0-s0-0", &CAMPAIGN_ID[..8]); +``` + +```rust + // line 381: before + let base = format!("{}-0-EURUSD-w0-s0", &CAMPAIGN_ID[..8]); + // after + let base = format!("{}-0-EURUSD-w0-r0-s0", &CAMPAIGN_ID[..8]); +``` + +- [ ] **Step 11: Build + test gate (compile-coherent unit)** + +Run: `cargo build -p aura-registry -p aura-campaign 2>&1 | tail -5` +Expected: `Finished` — 0 errors (all `CellSpec` / `CampaignGeneralization` literals threaded). +Run: `cargo test -p aura-campaign 2>&1 | tail -15` +Expected: PASS, including `execute_runs_one_family_per_regime_with_distinct_ids` and the updated pins. + +--- + +## Task 4: the runtime binding — `StopArm`, stamp, regime→stop map (aura-cli) + +**Files:** +- Modify: `crates/aura-cli/src/main.rs` +- Modify: `crates/aura-cli/src/campaign_run.rs` +- Modify: `crates/aura-cli/tests/cli_run.rs` + +- [ ] **Step 1: Flip the characterization pin + extend the dissolved-contract pin (the RED side)** + +In `crates/aura-cli/tests/cli_run.rs`, in `sweep_real_blueprint_member_manifest_carries_no_stop_param_today`, replace the assertion body of the `for line in &lines` loop so it now REQUIRES the default stop (the ratified additive stamp): + +```rust + for line in &lines { + let v: serde_json::Value = serde_json::from_str(line).expect("member line parses as JSON"); + let params = v["report"]["manifest"]["params"] + .as_array() + .expect("manifest.params is an array"); + let get = |name: &str| params.iter().find(|p| p[0].as_str() == Some(name)); + assert_eq!(get("stop_length").and_then(|p| p[1]["I64"].as_i64()), Some(3), + "the resolved default stop length is stamped: {line}"); + assert_eq!(get("stop_k").and_then(|p| p[1]["F64"].as_f64()), Some(2.0), + "the resolved default stop k is stamped: {line}"); + } +``` + +Rename the test to `sweep_real_blueprint_member_manifest_stamps_the_default_stop` and update its doc comment's first sentence to: `the dissolved real-data sweep member manifest stamps its resolved stop (`stop_length`/`stop_k`) — the default regime here.` + +In `sweep_real_blueprint_member_lines_pin_the_dissolved_contract`, inside its `for i in 0..4` loop, after the instrument-stamp assertion (line 1471-1475), add: + +```rust + assert!( + params.iter().any(|p| p[0].as_str() == Some("stop_length")) + && params.iter().any(|p| p[0].as_str() == Some("stop_k")), + "the dissolved sweep stamps the resolved stop: {line}" + ); +``` + +- [ ] **Step 2: Run the pins to verify they fail** + +Run: `cargo test -p aura-cli --test cli_run sweep_real_blueprint_member_manifest_stamps_the_default_stop 2>&1 | tail -20` +Expected: FAIL — the manifest carries no `stop_length`/`stop_k` yet (assertion `Some(3)` gets `None`). + +- [ ] **Step 3: Add the `StopArm` enum** + +In `crates/aura-cli/src/main.rs`, immediately before `fn wrap_r(` (line 2652, above its doc comment at 2645), add: + +```rust +/// The vol-stop arm `wrap_r` embeds: an OPEN stop whose two knobs land in +/// `param_space` as sweep axes (the r-sma verb's gridded-stop path), or a BOUND +/// stop with fixed params (a single run, a blueprint/campaign member). +enum StopArm { + Open, + Bound(StopRule), +} +``` + +- [ ] **Step 4: Change `wrap_r`'s signature and stop arm** + +In `crates/aura-cli/src/main.rs`, in `fn wrap_r` (line 2652-2665), change the 6th parameter: + +```rust + // before + stop_open: bool, + // after + stop: StopArm, +``` + +and the stop-arm body (line 2691-2695): + +```rust + // before + let exec = g.add(if stop_open { + risk_executor_vol_open(1.0) + } else { + risk_executor(StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, 1.0) + }); + // after + let exec = g.add(match stop { + StopArm::Open => risk_executor_vol_open(1.0), + StopArm::Bound(rule) => risk_executor(rule, 1.0), + }); +``` + +- [ ] **Step 5: Map `r_sma_graph`'s bool at its `wrap_r` call** + +In `crates/aura-cli/src/main.rs`, in `fn r_sma_graph`, the `wrap_r(...)` call (line 2633-2642) passes `stop_open` as the 6th arg; replace that arg with the mapped `StopArm`: + +```rust + wrap_r( + sma_signal(fast_len, slow_len), + tx_eq, + tx_ex, + tx_r, + tx_req, + if stop_open { + StopArm::Open + } else { + StopArm::Bound(StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }) + }, + reduce, + cost, + ) +``` + +- [ ] **Step 6: Give `run_blueprint_member` a `stop` param + the manifest stamp** + +In `crates/aura-cli/src/main.rs`, in `fn run_blueprint_member` (line 2877-2886), add a trailing parameter: + +```rust + topo: &str, + env: &project::Env, + stop: StopRule, +) -> RunReport { +``` + +Change its `wrap_r` call (line 2892), passing the bound stop: + +```rust + // before + let mut h = wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, false, true, None) + // after + let mut h = wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, StopArm::Bound(stop), true, None) +``` + +Stamp the resolved stop into the manifest params — after `let named = zip_params(space, point);` (line 2896): + +```rust + let named = zip_params(space, point); // by-name params for the manifest record + let named = { + let mut p = named; + // `if let` (not an irrefutable `let`): `StopRule` also has a `Fixed` + // variant, which stamps no vol knobs. The campaign/single-run paths only + // pass `Vol`, so the `Fixed` arm is inert here. + if let StopRule::Vol { length, k } = stop { + p.push(("stop_length".to_string(), Scalar::i64(length))); + p.push(("stop_k".to_string(), Scalar::f64(k))); + } + p + }; +``` + +- [ ] **Step 7: Thread the remaining `wrap_r` call sites (bound default)** + +In `crates/aura-cli/src/main.rs`, the three other `wrap_r` sites pass `false` as the 6th arg — replace each with `StopArm::Bound(StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K })`: +- line 2288: `wrap_r(reload(), tx_eq, tx_ex, tx_r, tx_req, StopArm::Bound(StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }), true, None).param_space();` +- line 2849: `let wrapped = wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, StopArm::Bound(StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }), false, None);` +- line 2932: `wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, StopArm::Bound(StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }), true, None)` + +- [ ] **Step 8: Thread the 5 default `run_blueprint_member` call sites (main.rs)** + +In `crates/aura-cli/src/main.rs`, append `StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }` as the new last argument at each of these `run_blueprint_member(...)` calls: line 2326 (the `let rerun = run_blueprint_member(` multi-line call — add as the final arg after `env`), 3005, 3038, 3056, 3163. Each was `run_blueprint_member(reload(doc), point, &space, ..., pip, &topo, env)` → `..., &topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K })`. + +- [ ] **Step 9: Map the regime → stop in the campaign member runner** + +In `crates/aura-cli/src/campaign_run.rs`, in `impl MemberRunner for CliMemberRunner`, `run_member` (line 269-281), map the cell regime and pass it into the `run_blueprint_member` call: + +```rust + // before the run_blueprint_member call (after the `let signal = ...` at line 269): + let stop = match cell.regime { + None => aura_composites::StopRule::Vol { + length: crate::R_SMA_STOP_LENGTH, + k: crate::R_SMA_STOP_K, + }, + Some(aura_research::RiskRegime::Vol { length, k }) => { + aura_composites::StopRule::Vol { length, k } + } + }; + let mut report = crate::run_blueprint_member( + signal, + &point, + &space, + vec![Box::new(source)], + (from, to), + 0, + geo.pip_size, + &cell.strategy_id, + self.env, + stop, + ); +``` + +The full paths `aura_composites::StopRule` / `aura_research::RiskRegime` resolve without a new `use` (both crates are aura-cli deps). Also in `crates/aura-cli/src/main.rs`, mark the two stop constants crate-visible so campaign_run.rs can read them: + +```rust +// before +const R_SMA_STOP_LENGTH: i64 = 3; +const R_SMA_STOP_K: f64 = 2.0; +// after +pub(crate) const R_SMA_STOP_LENGTH: i64 = 3; +pub(crate) const R_SMA_STOP_K: f64 = 2.0; +``` + +- [ ] **Step 10: Build + run the gate (aura-cli, real-data pins)** + +Run: `cargo build -p aura-cli 2>&1 | tail -8` +Expected: `Finished` — 0 errors (all `wrap_r` + `run_blueprint_member` callers threaded). +Run: `cargo test -p aura-cli --test cli_run sweep_real_blueprint_member_manifest_stamps_the_default_stop 2>&1 | tail -10` +Expected: PASS (the flipped characterization pin now sees the stamped default stop). +Run: `cargo test -p aura-cli --test cli_run sweep_real_blueprint_member_lines_pin_the_dissolved_contract 2>&1 | tail -10` +Expected: PASS (the extended dissolved-contract pin sees the stop keys present). +Run: `cargo test -p aura-cli 2>&1 | tail -20` +Expected: PASS across aura-cli. NOTE: the stamp lives in `run_blueprint_member`, so the five single-`blueprint run` paths (main.rs callers at 2326/3005/3038/3056/3163) now stamp the default stop too — the same ratified C18 extension. Any aura-cli test asserting a stop-less blueprint-run OR campaign-member manifest param set is updated here for the additive `stop_length`/`stop_k` keys (an exact-set assertion, not a `find`, is what surfaces it). This is expected drift, not a regression. + +--- + +## Task 5: sugar parity, two-regime E2E, ledger note (aura-cli + docs) + +**Files:** +- Modify: `crates/aura-cli/src/verb_sugar.rs:76` +- Modify: `crates/aura-cli/tests/research_docs.rs` — the two-regime E2E (its helpers live here) +- Modify: `docs/design/INDEX.md` + +- [ ] **Step 1: Write the failing two-regime E2E** + +Add to `crates/aura-cli/tests/research_docs.rs` (its helpers `seed_blueprint` / `SWEEP_ONLY_PROCESS_DOC` / `run_code_in` / `ScratchGuard` live here). The campaign document is written inline (the `campaign_doc_json` helper has no `risk` parameter), carrying a two-regime `risk` section; real-data-gated with the same skip idiom as the sibling e2e: + +```rust +/// Property (risk-regime axis, #210): a campaign document with two vol regimes +/// runs one sweep family per regime; the families' ids differ by the +/// `-r{ordinal}` discriminator, and every member's manifest stamps ITS regime's +/// resolved stop (k=1.5 for regime 0, k=2.5 for regime 1). Gated on the local +/// GER40 archive. +#[test] +fn campaign_two_regimes_stamp_distinct_stops_and_family_ids() { + let _fixture = project_lock(); + let dir = built_project(); + let runs_dir = dir.join("runs"); + std::fs::remove_dir_all(&runs_dir).ok(); + let _cleanup = ScratchGuard(vec![ + ScratchPath::Dir(runs_dir.clone()), + ScratchPath::File(dir.join("regimes.process.json")), + ScratchPath::File(dir.join("regimes.campaign.json")), + ]); + let bp_id = seed_blueprint(dir, "two-regimes-seed"); + let proc_id = register_process_doc(dir, "regimes.process.json", SWEEP_ONLY_PROCESS_DOC); + let doc = format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "two-regimes", + "data": {{ "instruments": ["GER40"], "windows": [ {{ "from_ms": 1725148800000, "to_ms": 1727740799999 }} ] }}, + "risk": [ {{ "vol": {{ "length": 3, "k": 1.5 }} }}, {{ "vol": {{ "length": 3, "k": 2.5 }} }} ], + "strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }}, + "axes": {{ "fast.length": {{ "kind": "I64", "values": [2, 4] }}, + "slow.length": {{ "kind": "I64", "values": [8, 16] }} }} }} ], + "process": {{ "ref": {{ "content_id": "{proc_id}" }} }}, + "seed": 7, + "presentation": {{ "persist_taps": [], "emit": ["family_table"] }} +}}"# + ); + write_doc(dir, "regimes.campaign.json", &doc); + let (out, code) = run_code_in(dir, &["campaign", "run", "regimes.campaign.json"]); + + if code == Some(1) + && (out.contains("no recorded geometry") || out.contains("no data for instrument")) + { + eprintln!("skip: no local GER40 data for the two-regime campaign e2e"); + return; + } + assert_eq!(code, Some(0), "stdout/stderr: {out}"); + + // Group member lines by regime discriminator; each stamps its own stop_k. + let mut r0_k: Vec = vec![]; + let mut r1_k: Vec = vec![]; + for line in out.lines().filter(|l| l.starts_with("{\"family_id\":")) { + let v: serde_json::Value = serde_json::from_str(line).expect("member line JSON"); + let fid = v["family_id"].as_str().expect("family_id"); + let params = v["report"]["manifest"]["params"].as_array().expect("params"); + let k = params + .iter() + .find(|p| p[0].as_str() == Some("stop_k")) + .and_then(|p| p[1]["F64"].as_f64()) + .expect("the member manifest stamps stop_k"); + if fid.contains("-r0-") { + r0_k.push(k); + } else if fid.contains("-r1-") { + r1_k.push(k); + } + } + assert!(!r0_k.is_empty() && !r1_k.is_empty(), "both regime families emitted members: {out}"); + assert!(r0_k.iter().all(|&k| k == 1.5), "regime 0 stamps k=1.5: {r0_k:?}"); + assert!(r1_k.iter().all(|&k| k == 2.5), "regime 1 stamps k=2.5: {r1_k:?}"); +} +``` + +Run: `cargo test -p aura-cli --test research_docs campaign_two_regimes 2>&1 | tail -20` +Expected: at RED (before Tasks 3-4 land) this cannot even reach the assertions — but it is authored in Task 5 after Tasks 1-4 are green, so its first run here is the GREEN gate: PASS (or a clean skip on a data-less machine). + +- [ ] **Step 2: Add `risk: vec![]` to the generated sweep doc (parity)** + +In `crates/aura-cli/src/verb_sugar.rs`, in `translate_sweep`'s `CampaignDoc { ... }` literal (line 76-98), add after the `data: DataSection { ... },` block (line 84): + +```rust + data: DataSection { + instruments: vec![symbol.to_string()], + windows: vec![Window { from_ms, to_ms }], + }, + risk: vec![], + strategies: vec![StrategyEntry { +``` + +- [ ] **Step 3: Verify sugar parity (generated doc unchanged)** + +Run: `cargo test -p aura-cli translate_sweep_is_deterministic 2>&1 | tail` +Expected: PASS — `risk: vec![]` absent-serializes, so the generated campaign doc's content id is byte-unchanged. + +- [ ] **Step 4: Add the design-ledger amendment note** + +In `docs/design/INDEX.md`, in the C10 (or C20) section, append this paragraph (find the C10 block near line 492 / the C20 structural-axis discussion near line 1622; place it as a dated realization note under whichever the maintainer keeps the structural-axis list): + +```markdown +**Realization (2026-07-06 — the risk regime as a structural campaign axis, +#210).** The protective-stop regime is a first-class structural axis of the +campaign document (`CampaignDoc.risk: [RiskRegime]`), a peer of instruments and +windows — kept-separate in the executor (the nominee key carries the regime +ordinal), so generalize aggregates across instruments *within* a regime, never +across regimes. Regimes are compared at presentation, never argmax-selected +across: a cross-regime E[R] argmax would compare R-multiples in different R units +(the stop defines 1R), so the verbs' `--stop-length`/`--stop-k` joint stop grid +is retired as a campaign methodology. Each member manifest stamps its resolved +stop (default included), closing the C18 gap. Absent `risk` = one implicit +default regime, absent-serializing for content-id parity. +``` + +- [ ] **Step 5: Full workspace gate** + +Run: `cargo build --workspace 2>&1 | tail -3` +Expected: `Finished` — 0 errors. +Run: `cargo test --workspace 2>&1 | tail -8` +Expected: PASS across the workspace (the new regime tests green, all prior pins updated). +Run: `cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -3` +Expected: no warnings. diff --git a/docs/specs/risk-regime-axis.md b/docs/specs/risk-regime-axis.md new file mode 100644 index 0000000..f9e4606 --- /dev/null +++ b/docs/specs/risk-regime-axis.md @@ -0,0 +1,413 @@ +# Risk regime as a structural campaign axis — Design Spec + +**Date:** 2026-07-06 +**Status:** Draft — awaiting user spec review +**Authors:** orchestrator + Claude + +## Goal + +The campaign document cannot vary the protective stop. The R-path stop is +baked to fixed constants (vol-stop length 3, k 2.0) inside the non-serialized +`wrap_r` scaffolding, invisible to every stored artifact; stored strategy +blueprints are the signal leg only (price → bias). Only the legacy verbs vary +stops, and they do it by gridding stop params jointly with the signal params +and argmaxing across the whole grid — comparing R-multiples measured in +*different* R units (C10: the stop defines 1R). This blocks the +behaviour-preserving dissolution of `generalize`, `walkforward`, and `mc`'s +R-path equally, and leaves the executed stop regime unreconstructable from any +recorded artifact (the C18 audit gap named in the fork triage). + +This cycle makes the risk regime a first-class **structural axis** of the +campaign matrix — a peer of instruments and windows — so that: + +1. a campaign document enumerates a finite list of risk regimes and runs every + `(strategy, instrument, window, regime)` cell; +2. each member's resolved stop is stamped into its manifest, closing the C18 + gap (the stop becomes addressable, auditable data); +3. regimes are **compared** at presentation, never argmax-**selected** across — + the cross-R-unit comparison the structural framing deliberately forecloses + (the parity break ratified in the direction decision, Gitea #210). + +The user decision (regime = structural axis, not an ordinary param axis) and +the derived sub-forks are recorded on Gitea #210 (comments 2846 / 2847). This +is the design pass that unblocks the remaining verb dissolutions; the +"how do I find the best regime" methodology is a consequence of the compared- +not-selected semantics and is deliberately **not** written down (it follows +from the architecture). + +## Architecture + +Three tiers, matching the existing campaign stack: + +- **Document vocabulary (`aura-research`)** — a new serializable `RiskRegime` + closed vocabulary and an optional top-level `CampaignDoc.risk: Vec` + field. This crate stays dependency-pure (aura-core + serde + sha2); it knows + nothing of the runtime `StopRule`. +- **Matrix expansion (`aura-campaign`)** — the realization matrix gains a + fourth structural coordinate. The regime is a **kept-separate** axis (a peer + of `window`), not an **aggregated-over** axis (a peer of `instrument`): + generalize aggregates across instruments (a candidate must survive *all* + instruments — worst-case R floor), but regimes are alternatives compared to + find the most robust one, never a set the candidate must all survive. So each + `(strategy, window, regime)` triple is an independent generalize-across- + instruments unit, and the regime enters the nominee key. +- **Runtime binding (`aura-cli`)** — the CLI member runner maps + `RiskRegime → StopRule` at the runtime boundary (exactly as it maps campaign + axes → runtime param-spaces), threads the resolved stop into the `wrap_r` + bound-stop arm in place of the baked constants, and stamps the resolved stop + into the member manifest. + +The **default regime** (absent or empty `risk`) is a single implicit regime +equal to today's baked constants. The default's *value* lives in `aura-cli` +(`R_SMA_STOP_LENGTH` / `R_SMA_STOP_K`), so `aura-campaign` carries the regime as +`Option` where `None` means "member runner supplies its default" — +the doc is never mutated to inject a default (its content id must hash with +`risk` absent). + +## Concrete code shapes + +### The campaign document a user writes (the acceptance evidence) + +A campaign that sweeps one signal over one instrument/window across **three +risk regimes** — the new capability: + +```json +{ + "format_version": 1, + "kind": "campaign", + "name": "sma-cross across three vol stops", + "data": { + "instruments": ["GER40"], + "windows": [{ "from_ms": 1725148800000, "to_ms": 1727740799999 }] + }, + "risk": [ + { "vol": { "length": 3, "k": 1.5 } }, + { "vol": { "length": 3, "k": 2.0 } }, + { "vol": { "length": 3, "k": 3.0 } } + ], + "strategies": [ + { "ref": { "content_id": "" }, + "axes": { "fast.length": { "kind": "i64", "values": [2, 3] } } } + ], + "process": { "ref": { "content_id": "" } }, + "seed": 0, + "presentation": { "persist_taps": [], "emit": ["family_table"] } +} +``` + +Running it produces one family per `(strategy, instrument, window, regime)` — +three families here, each a sweep over `fast.length ∈ {2,3}` at that regime's +stop. Every member line carries its resolved stop in the manifest; the three +regimes' results sit side by side in the family table, compared, never collapsed +by an argmax. "Best regime" is read off the compared families (the most robust +out-of-sample R floor across a plateau of stops) — a comparison, not a search. + +The **absent-`risk` parity case** — the document the sweep sugar generates and +every stored campaign document written to date: + +```json +{ "...": "no risk section", "data": { "...": "..." }, "strategies": ["..."] } +``` + +runs exactly one regime equal to the baked default (length 3, k 2.0), +behaviour-identical to today, and its content id is byte-unchanged (the field +absent-serializes). + +### `RiskRegime` — the new closed vocabulary (`aura-research`) + +```rust +/// 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 fixed-stop rule is admitted as a future additive variant +/// (it ships as a composite today but is not yet campaign-reachable). 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 }, +} +``` + +Wire form (serde external tagging): `{ "vol": { "length": 3, "k": 2.0 } }`. + +### `CampaignDoc.risk` — the optional top-level field + +```rust +// before → after (crates/aura-research/src/lib.rs:425) + pub struct CampaignDoc { + pub format_version: u32, + pub kind: DocKind, + pub name: String, + #[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 baked constants). Absent-serializes → content-id ++ /// parity for every risk-less document. ++ #[serde(default, skip_serializing_if = "Vec::is_empty")] ++ pub risk: Vec, + pub strategies: Vec, + pub process: ProcessRef, + pub seed: u64, + pub presentation: Presentation, + } +``` + +`DataSection` is **unchanged** — the regime is a risk-execution concern, kept +out of the "which instruments over which windows" data section (invariant 7). + +### `CellSpec.regime` — the fourth structural coordinate (`aura-campaign`) + +```rust +// before → after (crates/aura-campaign/src/lib.rs:26) + pub struct CellSpec { + pub strategy_ordinal: usize, + pub strategy_id: String, + pub blueprint_json: String, + pub axes: BTreeMap, + pub instrument: String, + pub window_ms: (i64, i64), ++ /// The cell's risk regime. `None` = the member runner's baked default (the ++ /// absent/empty-`risk` case); `Some` = an explicit document regime. ++ pub regime: Option, ++ /// The regime's ordinal in the resolved regime list (0 for the default), ++ /// used to key the generalize unit and discriminate family names. ++ pub regime_ordinal: usize, + } +``` + +### Matrix loop + nominee key (`aura-campaign/src/exec.rs`) + +```rust +// before → after (exec.rs:124 + the strategy×instrument×window loop at :125) +- let mut nominees: BTreeMap<(usize, usize), Vec<(String, Nominee)>> = BTreeMap::new(); ++ let mut nominees: BTreeMap<(usize, usize, usize), Vec<(String, Nominee)>> = BTreeMap::new(); ++ // Absent/empty risk resolves to a single default cell (regime = None, ord 0); ++ // a non-empty list maps each regime to Some. The default's value is the member ++ // runner's; the doc is never mutated (content id hashes with risk absent). ++ let regimes: Vec<(usize, Option)> = if campaign.risk.is_empty() { ++ vec![(0, None)] ++ } else { ++ campaign.risk.iter().copied().enumerate().map(|(i, r)| (i, Some(r))).collect() ++ }; + for (strategy_ordinal, ...) in campaign.strategies.iter().zip(strategies).enumerate() { + for instrument in &campaign.data.instruments { + for (window_ordinal, window) in campaign.data.windows.iter().enumerate() { ++ for (regime_ordinal, regime) in ®imes { + let cell = CellSpec { /* ...as before... */, ++ regime: *regime, regime_ordinal: *regime_ordinal }; + let (outcome, realization) = run_cell(&cell, ...)?; + nominees +- .entry((strategy_ordinal, window_ordinal)) ++ .entry((strategy_ordinal, window_ordinal, *regime_ordinal)) + .or_default() + .push((instrument.clone(), outcome.nominee.clone())); + /* ... */ ++ } + } + } + } +``` + +The campaign-scope generalize loop (exec.rs:164-195) iterates the same +now-3-tuple key, so each `(strategy, window, regime)` gets its own +generalize-across-instruments grade. `CampaignGeneralization` gains a +`regime_ordinal: usize` field beside `strategy_ordinal` / `window_ordinal`. + +### Family naming — the regime discriminator (collision fix) + +The family name (exec.rs:250 / :352) carries no regime component today, so two +regimes for the same `(strategy, instrument, window, stage)` would collide and +overwrite in the registry: + +```rust +// before → after (exec.rs:250, :352) +- "{campaign_prefix}-{}-{}-w{window_ordinal}-s{stage_ordinal}", ++ "{campaign_prefix}-{}-{}-w{window_ordinal}-r{regime_ordinal}-s{stage_ordinal}", +``` + +`campaign_cell_key` (campaign_run.rs:558) gains the regime ordinal by the same +reasoning (report-name isolation across regimes): + +```rust +// before → after +- fn campaign_cell_key(strategy: &str, instrument: &str, window_ordinal: usize) -> String ++ fn campaign_cell_key(strategy: &str, instrument: &str, window_ordinal: usize, regime_ordinal: usize) -> String +``` + +### `wrap_r` bound-stop arm + the member-runner thread (`aura-cli`) + +`wrap_r`'s sixth argument stops being a bare `stop_open: bool` and becomes a +`StopArm` that carries the bound stop's value: + +```rust +// new CLI-internal enum +enum StopArm { + /// Open vol-stop: the two knobs land in param_space as sweep axes (the r-sma + /// verb's gridded-stop path). + Open, + /// Bound vol-stop with these params (single run, blueprint/campaign member). + Bound(StopRule), +} + +// before → after (main.rs:2691 stop arm inside wrap_r) +- let exec = g.add(if stop_open { +- risk_executor_vol_open(1.0) +- } else { +- risk_executor(StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, 1.0) +- }); ++ let exec = g.add(match stop { ++ StopArm::Open => risk_executor_vol_open(1.0), ++ StopArm::Bound(rule) => risk_executor(rule, 1.0), ++ }); +``` + +The signature change propagates to `wrap_r`'s five call sites (compiler- +enumerated). `r_sma_graph` (main.rs:2622) keeps its `stop_open: bool` and maps +at its single `wrap_r` call, localizing the default constants to the Rust-built +path: + +```rust +// r_sma_graph's wrap_r call ++ if stop_open { StopArm::Open } ++ else { StopArm::Bound(StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }) } +``` + +`run_blueprint_member` (main.rs:2877) gains a `stop: StopRule` parameter, passes +`StopArm::Bound(stop)` to `wrap_r`, and stamps the stop into the manifest (next +shape). `CliMemberRunner::run_member` (campaign_run.rs:215) maps the cell's +regime to the runtime rule and threads it in: + +```rust +// campaign_run.rs run_member — the RiskRegime → StopRule map at the runtime boundary ++ let stop = match cell.regime { ++ None => StopRule::Vol { length: crate::R_SMA_STOP_LENGTH, k: crate::R_SMA_STOP_K }, ++ Some(RiskRegime::Vol { length, k }) => StopRule::Vol { length, k }, ++ }; + let mut report = crate::run_blueprint_member(signal, &point, &space, sources, +- (from, to), 0, geo.pip_size, &cell.strategy_id, self.env); ++ (from, to), 0, geo.pip_size, &cell.strategy_id, self.env, stop); +``` + +### Manifest stamp — closing the C18 gap (additive, like the instrument stamp) + +`run_blueprint_member` builds its manifest from `zip_params(space, point)` — +the signal axes only (main.rs:2896), so the campaign member manifest records no +stop today. The resolved stop is appended, matching the single-run manifest +shape (`stop_length` i64 / `stop_k` f64, main.rs:3608-3609): + +```rust +// before → after (main.rs:2896, inside run_blueprint_member) + let named = zip_params(space, point); ++ let named = { let mut p = named; ++ if let StopRule::Vol { length, k } = stop { ++ p.push(("stop_length".into(), Scalar::i64(length))); ++ p.push(("stop_k".into(), Scalar::f64(k))); ++ } ++ p }; + let mut manifest = sim_optimal_manifest(named, window, seed, pip); +``` + +**Ratified contract extension.** `family_member_line` serializes the full +`RunManifest.params` (main.rs:1601-1606), so this additive stamp changes the +dissolved-sweep member-line output. That a campaign member manifest carries **no** +stop param today is a currently-green characterization pin, +`sweep_real_blueprint_member_manifest_carries_no_stop_param_today` +(cli_run.rs) — added as this cycle's grounding ratification, since the older +`sweep_real_blueprint_member_lines_pin_the_dissolved_contract` locates each +binding by `find` and so never pinned the *absence*. The stamp flips that +characterization pin to **require** the stop key — the deliberate C18-gap +closure the milestone exists to perform, an additive stamp exactly like the +instrument stamp cycle 0110 added ("full parity modulo an additive stamp", #210 +decision 5), not accidental drift. The older `find`-based pin also gains the +default stop in its member lines (its `find` assertions stay green; it is +extended to assert the new key). The change is ratified here, not silent. + +### `translate_sweep` — sugar parity (no `risk` emitted) + +```rust +// verb_sugar.rs:76 generated CampaignDoc — the risk field defaults to absent + CampaignDoc { /* ...unchanged... */, data: DataSection { ... }, ++ risk: vec![], // absent-serializes → sweep-sugar doc byte-unchanged + strategies: vec![one], ... } +``` + +## Components + +- **`aura-research`**: `RiskRegime` enum (new); `CampaignDoc.risk` field; + `validate_campaign` regime check (new `DocFault::BadRegime { index, detail }`); + `CAMPAIGN_SECTIONS` gains an optional `std::risk` section descriptor; + `open_slots_campaign` treats `risk` as optional (never an open slot — absence + is legal). `metric_vocabulary` untouched. +- **`aura-campaign`**: `CellSpec.regime` + `regime_ordinal`; exec.rs regime + expansion, 3-tuple nominee key, generalize per `(strategy, window, regime)`; + `CampaignGeneralization.regime_ordinal`; family-name regime discriminator. +- **`aura-cli`**: `StopArm` enum; `wrap_r` signature; `r_sma_graph` bool→arm + map; `run_blueprint_member` `stop` param + manifest stamp; + `CliMemberRunner::run_member` regime→rule map; `campaign_cell_key` regime arg. +- **`verb_sugar`**: `translate_sweep` sets `risk: vec![]`. +- **Design ledger**: a C10/C20 amendment note — the risk regime realized as a + structural campaign axis (kept-separate, compared not selected), and the + ratified parity break (cross-regime argmax is structurally unavailable; the + verbs' `--stop-length`/`--stop-k` joint grid is retired as a methodology). + +## Data flow + +document (`risk` list) → parse (serde, `risk` known/optional) → `validate_campaign` +(regime params sane) → content id (risk absent-serializes) → `execute`: +for each `(strategy, instrument, window, regime)` cell → `run_member` maps +`regime → StopRule` → `run_blueprint_member` binds the signal point, runs with +the bound stop, stamps `stop_length`/`stop_k` into the manifest → nominee keyed +`(strategy, window, regime)` → per-`(strategy, window, regime)` generalize across +instruments → family table rows carry the regime in `family_id` → presentation +compares regimes side by side. + +## Error handling + +- A `Vol` regime with `length < 1` or `k <= 0` (or non-finite `k`) → the new + `DocFault::BadRegime { index, detail }` at `validate_campaign`, phrased by the + CLI like the sibling `EmptyAxis` / `BadWindow` faults (by-identifier, display- + free in the library). +- `risk: []` (explicit empty) is **valid** — it means the default regime, + identical to an absent `risk` (both round-trip to absent under + `skip_serializing_if`). No refusal. +- No duplicate-regime detection, matching the existing non-dedup of instruments + and windows (consistency, not a new invariant). +- An unknown top-level key stays refused by `CampaignDoc`'s + `deny_unknown_fields`; `risk` is now a *known* optional key. + +## Testing strategy + +- **Unit (`aura-research`)**: `RiskRegime` serde round-trip + wire-form pin + (`{"vol":{"length":3,"k":2.0}}`); `validate_campaign` accepts a risk section + and refuses a bad regime; **content-id parity** — a doc with `risk` absent and + the same doc with `risk: []` both hash to the pre-feature content id (the + absent-serialize guarantee). +- **E2E (`aura-cli`)**: a real-data campaign with two distinct regimes runs both + families; each member manifest stamps its regime's `stop_length`/`stop_k`; the + two families' `family_id`s differ by the `-r{ordinal}` discriminator (no + collision/overwrite); the per-`(strategy, window, regime)` generalize grades + are independent. +- **Parity (`aura-cli`)**: the dissolved `aura sweep --real` over an + absent-`risk` generated document still runs one default regime; its member + lines now additionally carry `stop_length:3`/`stop_k:2.0` — the characterization + pin `sweep_real_blueprint_member_manifest_carries_no_stop_param_today` flips + from asserting the stop's absence to requiring it, and the `find`-based 0110 pin + is extended to assert the new key; the generated doc's content id is unchanged. + +## Acceptance criteria + +- The intended audience — a researcher authoring a campaign — writes a `risk` + list and gets one family per regime, compared side by side (the worked example + above is the empirical evidence). +- Every member manifest records its resolved stop, default included → each run + is reproducible from its own manifest (C18); the executed stop is auditable. +- Regimes are kept separate (per-regime generalize) and compared at + presentation; no cross-regime argmax exists (the structural framing forecloses + the cross-R-unit comparison — C10). +- Absent/empty `risk` = byte-identical document content id **and** behaviour + parity (one default regime); `aura sweep` sugar emits no `risk`. +- No new failure class against the core constraints: determinism (the regime is + static structural data, bound at bootstrap), causality (unaffected), and the + no-look-ahead / one-merge invariants are untouched.