docs(specs,plans): risk-regime structural campaign axis — spec + plan (#210)

Spec auto-signed on a grounding-check PASS: the risk regime becomes a
structural campaign axis (a peer of instruments/windows, kept-separate in
the executor, each member's resolved stop stamped into its manifest;
regimes are compared, never argmax-selected across — a cross-regime E[R]
argmax would mix R units, C10). The 5-task plan threads it through
aura-research (RiskRegime + CampaignDoc.risk), aura-campaign + aura-registry
(matrix expansion + regime_ordinal), and aura-cli (StopArm + wrap_r + the
manifest stamp). Two bounded, documented descopes: the descriptive
CAMPAIGN_SECTIONS introspection entry and multi-regime trace-dir isolation.

refs #210
This commit is contained in:
2026-07-06 13:33:25 +02:00
parent 455916db73
commit abab1f1684
2 changed files with 1180 additions and 0 deletions
+767
View File
@@ -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<Scalar> }` 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<RiskRegime>,
pub strategies: Vec<StrategyEntry>,
```
- [ ] **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(), &reg)
.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<aura_research::RiskRegime>,
/// 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<aura_research::RiskRegime>)> = 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 &regimes {
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<f64> = vec![];
let mut r1_k: Vec<f64> = 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.