From d96af7e1a299d07f1c0cd7766f9d8078c4c9aada Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 6 Jul 2026 15:05:21 +0200 Subject: [PATCH] =?UTF-8?q?audit:=20cycle=20close=20risk-regime=20axis=20?= =?UTF-8?q?=E2=80=94=20drift-clean=20after=20the=20C20=20ledger=20fix=20(#?= =?UTF-8?q?210)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Architect drift review (c9d962f..HEAD). What holds: the nominee key is the 3-tuple (strategy, window, regime) and no argmax over regime_ordinal exists, so the ledger's kept-separate / compared-not-selected semantics is what the code does; the C18 manifest stamp is honest (the characterization pin flipped from asserting absence to requiring the exact resolved 3/2.0, a strengthening, and a real two-regime e2e catches per-cell mis-resolution); no dead code (cell.regime and regime_ordinal are both read after the runtime-binding slice), absent/empty risk keeps content-id parity, and CampaignGeneralization.regime_ordinal is serde-default so pre-feature records still parse. Resolution: - FIX (inline, this commit): the spec promised a C10 AND a C20 note; only C10 landed. C20's enumerated structural-axis list (strategy/instrument/broker/ window) was left silently incomplete — the risk regime is added as the fourth matrix axis, with a dated realization pointer to the C10 semantics. - carry (documented descope): no std::risk descriptor in CAMPAIGN_SECTIONS — risk is a top-level array, not a slot-bearing section; no invariant broken. - carry (filed #212): the persist re-run + campaign_cell_key omit the regime because CellRealization gained none; the architect verified this is a LOUD C1 drift-alarm refusal for a non-default regime + persist_taps, not silent wrong data (#212 corrected accordingly). - trivial: validate_campaign's regime-check comment corrected (it catches NaN, not all non-finite; +inf is unreachable via JSON). Regression gate: full workspace suite green, clippy clean (no dedicated regression script; the suite + lint are the gate). Spec and plan removed (git rm) at cycle close per the project convention. No baseline moved. refs #210 --- crates/aura-research/src/lib.rs | 5 +- docs/design/INDEX.md | 7 +- docs/plans/risk-regime-axis.md | 767 -------------------------------- docs/specs/risk-regime-axis.md | 413 ----------------- 4 files changed, 8 insertions(+), 1184 deletions(-) delete mode 100644 docs/plans/risk-regime-axis.md delete mode 100644 docs/specs/risk-regime-axis.md diff --git a/crates/aura-research/src/lib.rs b/crates/aura-research/src/lib.rs index 41bede2..ff0f6de 100644 --- a/crates/aura-research/src/lib.rs +++ b/crates/aura-research/src/lib.rs @@ -766,8 +766,9 @@ pub fn validate_campaign(doc: &CampaignDoc) -> Vec { } for (i, r) in doc.risk.iter().enumerate() { let RiskRegime::Vol { length, k } = r; - // length < 1 catches 0 and negatives; `k <= 0 || NaN` catches a - // non-positive or non-finite multiplier (clippy-clean vs `!(k > 0.0)`). + // length < 1 catches 0 and negatives; `k <= 0.0 || k.is_nan()` catches + // a non-positive or NaN multiplier (clippy-clean vs `!(k > 0.0)`; +inf + // is unreachable via JSON, so it need not be excluded). if *length < 1 || *k <= 0.0 || k.is_nan() { faults.push(DocFault::BadRegime { index: i }); } diff --git a/docs/design/INDEX.md b/docs/design/INDEX.md index 8aa9d28..7f825d6 100644 --- a/docs/design/INDEX.md +++ b/docs/design/INDEX.md @@ -1648,8 +1648,11 @@ to the strategy's input roles + the strategy + attached broker node(s) + sinks and is itself produced by the bootstrap (C19). A harness *instance* is C1's disjoint unit (RustAst's "root scope"). The harness has **two kinds of parameterization**: **structural axes** (which strategy, which instrument(s), -which broker(s), which window) whose variation selects *different* instances — -the **experiment matrix**; and **tuning params** (the strategy's numeric params) +which broker(s), which window, which **risk regime**) whose variation selects +*different* instances — the **experiment matrix** (the risk regime is the fourth +axis, realized 2026-07-06 / #210 as `CampaignDoc.risk`; see the C10 realization +for its kept-separate, compared-not-selected semantics); and **tuning params** +(the strategy's numeric params) swept *within* a fixed structure (Fork A). The same strategy blueprint is reused across backtest, sweep, visual workspaces, and the frozen live bot — each a different harness. **Both strategy and harness/experiment are authored in Rust** diff --git a/docs/plans/risk-regime-axis.md b/docs/plans/risk-regime-axis.md deleted file mode 100644 index ac43c9a..0000000 --- a/docs/plans/risk-regime-axis.md +++ /dev/null @@ -1,767 +0,0 @@ -# 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 deleted file mode 100644 index f9e4606..0000000 --- a/docs/specs/risk-regime-axis.md +++ /dev/null @@ -1,413 +0,0 @@ -# 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.