audit: cycle close risk-regime axis — drift-clean after the C20 ledger fix (#210)
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
This commit is contained in:
@@ -766,8 +766,9 @@ pub fn validate_campaign(doc: &CampaignDoc) -> Vec<DocFault> {
|
||||
}
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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<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(), ®)
|
||||
.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 ®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<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.
|
||||
@@ -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<RiskRegime>`
|
||||
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<RiskRegime>` 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": "<sma-blueprint-hash>" },
|
||||
"axes": { "fast.length": { "kind": "i64", "values": [2, 3] } } }
|
||||
],
|
||||
"process": { "ref": { "content_id": "<sweep-process-hash>" } },
|
||||
"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<String>,
|
||||
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<RiskRegime>,
|
||||
pub strategies: Vec<StrategyEntry>,
|
||||
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<String, Axis>,
|
||||
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<RiskRegime>,
|
||||
+ /// 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<RiskRegime>)> = 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 <blueprint> --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.
|
||||
Reference in New Issue
Block a user