Files
Aura/docs/plans/risk-regime-axis.md
T
Brummel abab1f1684 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
2026-07-06 13:33:25 +02:00

34 KiB

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.rsRiskRegime 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:158CampaignGeneralization.regime_ordinal.
  • Modify: crates/aura-registry/src/lib.rs — the CampaignGeneralization test literal.
  • Modify: crates/aura-campaign/src/lib.rs:26CellSpec.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:18CellSpec literal.
  • Modify: crates/aura-campaign/tests/execute.rs — family-id pins + new two-regime test.
  • Modify: crates/aura-cli/src/main.rsStopArm, wrap_r signature, r_sma_graph map, run_blueprint_member stop param + manifest stamp + its 6 callers.
  • Modify: crates/aura-cli/src/campaign_run.rsCliMemberRunner::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:76risk: 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):

#[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:

/// 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,:

    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:

#[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):

    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):

    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):

        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):

#[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,:

    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),:

    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):

    // 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:

    // 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:

            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):

    // 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:

    // 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),:

        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:

    // 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]);
    // 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):

    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:

        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:

/// 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:

    // before
    stop_open: bool,
    // after
    stop: StopArm,

and the stop-arm body (line 2691-2695):

    // 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:

    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:

    topo: &str,
    env: &project::Env,
    stop: StopRule,
) -> RunReport {

Change its wrap_r call (line 2892), passing the bound stop:

    // 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):

    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:

    // 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:

// 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:

/// 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):

        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):

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