diff --git a/docs/plans/0108-annotator-stages.md b/docs/plans/0108-annotator-stages.md new file mode 100644 index 0000000..be20f61 --- /dev/null +++ b/docs/plans/0108-annotator-stages.md @@ -0,0 +1,2747 @@ +# Annotator Stages — Implementation Plan (cycle 0108) + +> **Parent spec:** `docs/specs/0108-annotator-stages.md` +> +> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run +> this plan. Steps use `- [ ]` checkboxes for tracking. + +**Goal:** `aura campaign run` executes the two annotator stages: the v2 +pipeline shape `std::sweep [std::gate]* [std::walk_forward]? [std::monte_carlo]? +[std::generalize]?` — mc bootstraps the stage's incoming R-evidence +(per-survivor / pooled-OOS), generalize runs at campaign scope; both terminal; +no document content id moves. + +**Architecture:** Task 1 widens the records serde-default (engine/registry + +literal threading, workspace-green). Task 2 swaps the fault set + preflight to +v2 (aura-campaign + aura-cli prose) — it leaves ONE known-RED aura-cli +integration test (`campaign_run_v1_boundary_refuses_mc_process`), flipped by +Task 5; Tasks 2-4 gate on `cargo test -p aura-campaign` + `cargo test -p +aura-cli --bin aura` + `cargo build --workspace`. Tasks 3-4 land the mc arm and +the campaign-scope generalize phase (fake-runner tests). Task 5 flips the seam +tests, tidies doc strings, extends the gated e2e, and restores full workspace +green. + +**Tech Stack:** Rust workspace — aura-engine, aura-registry, aura-campaign, +aura-cli, aura-research (doc-string text only). + +**Task-order constraints:** strictly sequential 1→5. Task 3 quotes the +pre-Task-2 `unreachable!` arm bytes in exec.rs (Task 2 must not touch exec.rs); +Task 4 quotes Task 1's `generalizations: Vec::new()` literal bytes; Task 5 +pins prose Task 2 defines. + +--- +### Task 1: Record widenings + serde derives (aura-engine, aura-registry) + +Widen the campaign-run lineage records for the 0108 annotator stages (spec `docs/specs/0108-annotator-stages.md`, #200): `RBootstrap` and `Generalization` become serde-serializable; `lineage.rs` gains `StageBootstrap` + `CampaignGeneralization`; `StageRealization` gains a sparse `bootstrap` field and `CampaignRunRecord` a sparse `generalizations` field — both serde-default so every pre-0108 `campaign_runs.jsonl` line stays parseable (the C14/C23 widening convention). This task changes NO behaviour: every new field is `None`/empty at every existing construction site; a later task of this cycle computes them. + +**Files:** +- Modify: `crates/aura-engine/src/mc.rs` — `RBootstrap` derive (:160); verify `MetricStats` (:47) untouched +- Modify: `crates/aura-registry/src/lineage.rs` — imports (:22, :25), `CampaignRunRecord` widening (:73-83), `StageRealization` widening (:102-111), new `StageBootstrap` + `CampaignGeneralization` before `impl Registry` (:122); verify :198 needs no change +- Modify: `crates/aura-registry/src/lib.rs` — re-export extension (:30-35), `Generalization` derive (:664), test-literal threading (:1707, :1756, :1781) +- Modify: `crates/aura-campaign/src/exec.rs` — literal threading (:142, :202, :238, :271) +- Modify: `crates/aura-registry/tests/campaign_run_store_e2e.rs` — literal threading (:46-79) +- Test: `crates/aura-registry/src/lib.rs` tests module — NEW `campaign_run_record_roundtrips_with_annotator_fields`, NEW `campaign_run_line_without_new_fields_still_parses` + +- [ ] **Step 1: RED — add the two new registry tests (they must NOT compile yet)** + +In `crates/aura-registry/src/lib.rs`, at the end of the `tests` module (the tail of `campaign_run_record_roundtrips` is the last test in the file), replace: + +```rust + let run = reg.append_campaign_run(&record).expect("append"); + assert_eq!(run, 0); + assert_eq!(reg.load_campaign_runs().expect("load"), vec![record]); + } +} +``` + +with: + +```rust + let run = reg.append_campaign_run(&record).expect("append"); + assert_eq!(run, 0); + assert_eq!(reg.load_campaign_runs().expect("load"), vec![record]); + } + + #[test] + fn campaign_run_record_roundtrips_with_annotator_fields() { + use aura_engine::r_bootstrap; + let path = temp_family_dir("campaign_run_annotator_roundtrip"); + let reg = Registry::open(&path); + // r_bootstrap is pure given its seed (C1), and serde_json round-trips + // finite f64 exactly, so whole-record PartialEq holds across the store. + let per_a = r_bootstrap(&[0.5, -0.2, 0.3, 1.1], 8, 2, 7); + let per_b = r_bootstrap(&[-0.4, 0.9], 8, 1, 7); + let pooled = r_bootstrap(&[0.2, 0.2, -0.1, 0.6, 0.4], 8, 2, 7); + let record = CampaignRunRecord { + campaign: "feed".to_string(), + process: "beef".to_string(), + run: 0, // matches the counter's first assignment, so whole-record PartialEq holds + seed: 7, + cells: vec![ + CellRealization { + strategy: "3f9c".to_string(), + instrument: "EURUSD".to_string(), + window_ms: (0, 1000), + stages: vec![StageRealization { + block: "std::monte_carlo".to_string(), + family_id: None, + survivor_ordinals: None, + selection: None, + bootstrap: Some(StageBootstrap::PerSurvivor(vec![ + (0, per_a), + (2, per_b), + ])), + }], + }, + CellRealization { + strategy: "3f9c".to_string(), + instrument: "GER40".to_string(), + window_ms: (0, 1000), + stages: vec![StageRealization { + block: "std::monte_carlo".to_string(), + family_id: None, + survivor_ordinals: None, + selection: None, + bootstrap: Some(StageBootstrap::PooledOos(pooled)), + }], + }, + ], + generalizations: vec![CampaignGeneralization { + strategy_ordinal: 0, + window_ordinal: 0, + generalization: Some(Generalization { + selection_metric: "net_expectancy_r".to_string(), + n_instruments: 2, + worst_case: 0.04, + sign_agreement: 2, + per_instrument: vec![ + ("EURUSD".to_string(), 0.04), + ("GER40".to_string(), 0.11), + ], + }), + winners: vec![ + ( + "EURUSD".to_string(), + vec![("sma_cross.fast.length".to_string(), Scalar::i64(3))], + ), + ( + "GER40".to_string(), + vec![("sma_cross.fast.length".to_string(), Scalar::i64(5))], + ), + ], + missing: vec!["US500".to_string()], + }], + }; + let run = reg.append_campaign_run(&record).expect("append annotator record"); + assert_eq!(run, 0); + assert_eq!( + reg.load_campaign_runs().expect("load annotator record"), + vec![record], + "bootstrap (both variants) and generalizations survive the round-trip", + ); + } + + /// A pre-0108 stored line — no `bootstrap`, no `generalizations` — still + /// parses (the C14/C23 serde-default widening convention): existing + /// `campaign_runs.jsonl` stores survive the record widening unchanged. + #[test] + fn campaign_run_line_without_new_fields_still_parses() { + let path = temp_family_dir("campaign_run_pre_widening"); + let store = path.with_file_name("campaign_runs.jsonl"); + let line = r#"{"campaign":"cafe","process":"beef","run":0,"seed":7,"cells":[{"strategy":"3f9c","instrument":"EURUSD","window_ms":[0,1000],"stages":[{"block":"std::gate","survivor_ordinals":[0,2]}]}]}"#; + fs::write(&store, format!("{line}\n")).expect("write pre-widening line"); + let reg = Registry::open(&path); + let loaded = reg.load_campaign_runs().expect("pre-widening line parses"); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].cells[0].stages[0].bootstrap, None); + assert!(loaded[0].generalizations.is_empty()); + } +} +``` + +(`Registry`, `CampaignRunRecord`, `CellRealization`, `StageRealization`, `StageBootstrap`, `CampaignGeneralization`, `Generalization`, `fs`, and `temp_family_dir` all resolve via the module's existing `use super::*;` once the later steps land; `Scalar` is already imported in the tests module; `r_bootstrap` is imported locally above because the crate root does not import it.) + +- [ ] **Step 2: RED run — the new tests must fail to compile** + +Run: `cargo test -p aura-registry campaign_run_record_roundtrips_with_annotator_fields` + +Expected: compilation FAILURE (this is the RED evidence for the widening): errors naming the not-yet-existing items — `cannot find ... StageBootstrap` / `cannot find ... CampaignGeneralization` (E0422/E0433) and no field `bootstrap` on `StageRealization` / no field `generalizations` on `CampaignRunRecord` (E0560). Do NOT proceed if it compiles. + +- [ ] **Step 3: aura-engine — serde derives on `RBootstrap`** + +First verify `MetricStats` (the `e_r` field type) already derives serde — Run: `grep -n -B 1 "pub struct MetricStats" crates/aura-engine/src/mc.rs` + +Expected: the derive line reads `#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]` — `MetricStats` needs NO edit. + +Then in `crates/aura-engine/src/mc.rs`, replace: + +```rust +#[derive(Clone, Debug, PartialEq)] +pub struct RBootstrap { +``` + +with: + +```rust +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct RBootstrap { +``` + +(Fully-qualified `serde::` paths, matching the `MetricStats` derive in this file — `mc.rs` has no `use serde` import and must not gain one.) + +- [ ] **Step 4: aura-registry lineage.rs — imports + the two new types** + +In `crates/aura-registry/src/lineage.rs`, replace: + +```rust +use aura_engine::{FamilySelection, McFamily, RunReport, SweepFamily, WalkForwardResult}; +use serde::{Deserialize, Serialize}; + +use crate::{Registry, RegistryError}; +``` + +with: + +```rust +use aura_engine::{ + FamilySelection, McFamily, RBootstrap, RunReport, SweepFamily, WalkForwardResult, +}; +use serde::{Deserialize, Serialize}; + +use crate::{Generalization, Registry, RegistryError}; +``` + +Then, in the same file, insert the two new types immediately before the `impl Registry` block by replacing: + +```rust +impl Registry { + /// Assign a fresh per-name `run` index (one past the highest `run` already +``` + +with: + +```rust +/// The recorded result of a `std::monte_carlo` stage (cycle 0108, #200 d1/d4): +/// the payload mirrors the stage's input shape. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StageBootstrap { + /// After sweep/gates: one bootstrap per surviving member, + /// `(ordinal into the population family, result)`. + PerSurvivor(Vec<(usize, RBootstrap)>), + /// After a walk_forward: one bootstrap over the pooled per-window + /// OOS trade-R series (roll order). + PooledOos(RBootstrap), +} + +/// The campaign-scope generalize annotation (#200 d2): recorded at the scope +/// it is computed, keyed (strategy, window) across instruments. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct CampaignGeneralization { + pub strategy_ordinal: usize, + pub window_ordinal: usize, + /// None when fewer than 2 instrument cells produced a winner (gate + /// truncation) — the shortfall is recorded, never computed around. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub generalization: Option, + /// Per-instrument winning params — divergent winners are exposed, + /// never averaged away. Present whenever >= 1 winner exists. + pub winners: Vec<(String, Vec<(String, Scalar)>)>, + /// Instrument cells that contributed no winner (gate-truncated), by name. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub missing: Vec, +} + +impl Registry { + /// Assign a fresh per-name `run` index (one past the highest `run` already +``` + +- [ ] **Step 5: aura-registry lineage.rs — widen `StageRealization` and `CampaignRunRecord`** + +In `crates/aura-registry/src/lineage.rs`, replace: + +```rust +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct StageRealization { + pub block: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub family_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub survivor_ordinals: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selection: Option, +} +``` + +with: + +```rust +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct StageRealization { + pub block: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub family_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub survivor_ordinals: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selection: Option, + /// std::monte_carlo stages only (cycle 0108). Absent pre-0108 — the + /// serde-default widening keeps existing stored lines parseable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bootstrap: Option, +} +``` + +Then replace: + +```rust +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct CampaignRunRecord { + /// Campaign document content id. + pub campaign: String, + /// Process document content id. + pub process: String, + /// Per-campaign run counter — assigned on append, never caller-supplied. + pub run: usize, + pub seed: u64, + pub cells: Vec, +} +``` + +with: + +```rust +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct CampaignRunRecord { + /// Campaign document content id. + pub campaign: String, + /// Process document content id. + pub process: String, + /// Per-campaign run counter — assigned on append, never caller-supplied. + pub run: usize, + pub seed: u64, + pub cells: Vec, + /// Campaign-scope generalize annotations (cycle 0108), one per + /// (strategy, window) across instruments. Absent pre-0108 — the + /// serde-default widening keeps existing stored lines parseable. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub generalizations: Vec, +} +``` + +Then verify the struct-update site needs NO change — Run: `grep -n "record.clone()" crates/aura-registry/src/lineage.rs` + +Expected: exactly one hit, `let stored = CampaignRunRecord { run, ..record.clone() };` (in `append_campaign_run`) — struct-update syntax carries the new `generalizations` field from `record` automatically; do not edit it. + +- [ ] **Step 6: aura-registry lib.rs — `Generalization` serde derives + re-export extension** + +In `crates/aura-registry/src/lib.rs`, replace: + +```rust +#[derive(Clone, Debug, PartialEq)] +pub struct Generalization { +``` + +with: + +```rust +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Generalization { +``` + +(Fully-qualified `serde::` paths — `lib.rs` has no `use serde` import and must not gain one; the fields are unchanged.) + +Then replace: + +```rust +mod lineage; +pub use lineage::{ + group_families, mc_member_reports, sweep_member_reports, walkforward_member_reports, + CampaignRunRecord, CellRealization, Family, FamilyKind, FamilyRunRecord, StageRealization, + StageSelection, +}; +``` + +with: + +```rust +mod lineage; +pub use lineage::{ + group_families, mc_member_reports, sweep_member_reports, walkforward_member_reports, + CampaignGeneralization, CampaignRunRecord, CellRealization, Family, FamilyKind, + FamilyRunRecord, StageBootstrap, StageRealization, StageSelection, +}; +``` + +- [ ] **Step 7: thread the new fields through the existing registry test literals** + +In `crates/aura-registry/src/lib.rs`, replace (the `campaign_run_record` helper): + +```rust + fn campaign_run_record(campaign: &str) -> CampaignRunRecord { + CampaignRunRecord { + campaign: campaign.to_string(), + process: "proc-id".to_string(), + run: 99, + seed: 7, + cells: vec![], + } + } +``` + +with: + +```rust + fn campaign_run_record(campaign: &str) -> CampaignRunRecord { + CampaignRunRecord { + campaign: campaign.to_string(), + process: "proc-id".to_string(), + run: 99, + seed: 7, + cells: vec![], + generalizations: vec![], + } + } +``` + +Then, in the existing `campaign_run_record_roundtrips` test, replace (the tail of the sweep-stage literal plus the head of the gate-stage literal): + +```rust + neighbourhood_score: None, + n_neighbours: None, + }, + }), + }, + StageRealization { + block: "std::gate".to_string(), +``` + +with: + +```rust + neighbourhood_score: None, + n_neighbours: None, + }, + }), + bootstrap: None, + }, + StageRealization { + block: "std::gate".to_string(), +``` + +Then replace (the gate-stage literal's tail plus the record's tail): + +```rust + family_id: None, + survivor_ordinals: Some(vec![0, 3, 4, 7]), + selection: None, + }, + ], + }], + }; +``` + +with: + +```rust + family_id: None, + survivor_ordinals: Some(vec![0, 3, 4, 7]), + selection: None, + bootstrap: None, + }, + ], + }], + generalizations: vec![], + }; +``` + +- [ ] **Step 8: thread the new fields through aura-campaign's literal sites** + +In `crates/aura-campaign/src/exec.rs`, replace (the `execute` record assembly): + +```rust + let mut record = CampaignRunRecord { + campaign: campaign_id.to_string(), + process: process_id, + run: 0, + seed: campaign.seed, + cells: cells_rec, + }; +``` + +with: + +```rust + let mut record = CampaignRunRecord { + campaign: campaign_id.to_string(), + process: process_id, + run: 0, + seed: campaign.seed, + cells: cells_rec, + // Empty for now: the campaign-scope generalize phase (a later task of + // this cycle) computes and fills this after the cell loop. + generalizations: Vec::new(), + }; +``` + +Then replace (the sweep-stage realization in `run_cell`): + +```rust + stages.push(StageRealization { + block: "std::sweep".to_string(), + family_id: Some(family_id.clone()), + survivor_ordinals: None, + selection: Some(StageSelection { winner_ordinal, params, selection }), + }); +``` + +with: + +```rust + stages.push(StageRealization { + block: "std::sweep".to_string(), + family_id: Some(family_id.clone()), + survivor_ordinals: None, + selection: Some(StageSelection { winner_ordinal, params, selection }), + bootstrap: None, + }); +``` + +Then replace (the gate-stage realization): + +```rust + stages.push(StageRealization { + block: "std::gate".to_string(), + family_id: None, + survivor_ordinals: Some(ordinals), + selection: None, + }); +``` + +with: + +```rust + stages.push(StageRealization { + block: "std::gate".to_string(), + family_id: None, + survivor_ordinals: Some(ordinals), + selection: None, + bootstrap: None, + }); +``` + +Then replace (the walk-forward-stage realization): + +```rust + stages.push(StageRealization { + block: "std::walk_forward".to_string(), + family_id: Some(fam.family_id.clone()), + survivor_ordinals: None, + selection: None, + }); +``` + +with: + +```rust + stages.push(StageRealization { + block: "std::walk_forward".to_string(), + family_id: Some(fam.family_id.clone()), + survivor_ordinals: None, + selection: None, + bootstrap: None, + }); +``` + +(These four sites plus the two in Step 9 are ALL existing construction sites of the two widened types outside `lineage.rs` — verified by `grep -rn "StageRealization {" crates` / `grep -rn "CampaignRunRecord {" crates`.) + +- [ ] **Step 9: thread the new fields through the registry e2e test** + +In `crates/aura-registry/tests/campaign_run_store_e2e.rs`, replace (the tail of the `campaign_run` fixture fn): + +```rust + }), + }], + }], + } +} +``` + +with: + +```rust + }), + bootstrap: None, + }], + }], + generalizations: vec![], + } +} +``` + +- [ ] **Step 10: GREEN — full gates** + +Run: `cargo test -p aura-registry campaign_run_record_roundtrips_with_annotator_fields` + +Expected: `test result: ok. 1 passed; 0 failed` (both `StageBootstrap` variants and a populated `generalizations` survive the disk round-trip with whole-record equality). + +Run: `cargo test -p aura-registry campaign_run_line_without_new_fields_still_parses` + +Expected: `test result: ok. 1 passed; 0 failed` (a pre-0108 JSONL line parses; the new fields default to `None`/empty). + +Run: `cargo test -p aura-registry` + +Expected: every test target reports `test result: ok` with `0 failed` — all pre-existing registry tests (counter, round-trip, e2e store tests) stay green after the threading. + +Run: `cargo test -p aura-campaign` + +Expected: every test target reports `test result: ok` with `0 failed` — the existing aura-campaign suite (currently 29 tests: 20 lib + 7 + 2 integration) is unchanged in count and all green; this task adds no campaign behaviour. + +Run: `cargo build --workspace` + +Expected: builds with 0 errors (aura-cli and every other consumer compile against the widened records — the new fields are additive and defaulted, so no site outside the ones threaded above needs changes). +### Task 2: preflight v2 + fault-set swap (aura-campaign + aura-cli prose) + +**Files:** Modify `crates/aura-campaign/src/lib.rs` (import :21; `ExecFault` :64-85; `preflight` :158-253; test helpers + preflight tests :420-503). Modify `crates/aura-cli/src/campaign_run.rs` (`exec_fault_prose` :45-87; `mod tests` :444-546). Tests: the rewritten `preflight_*` suite in `crates/aura-campaign/src/lib.rs`; three new `*_prose_names_*` pins in `crates/aura-cli/src/campaign_run.rs`. Line numbers are pre-task positions. + +Context (spec `docs/specs/0108-annotator-stages.md`, #200): the v1 executor refused `std::monte_carlo`/`std::generalize` outright via `ExecFault::UnsupportedStage`. This task replaces that boundary with the v2 preflight: the executable pipeline shape becomes `std::sweep (std::gate)* (std::walk_forward)? (std::monte_carlo)? (std::generalize)?` — each suffix stage at most once, order fixed, `std::generalize` strictly last — plus three new static guards (`GeneralizeNeedsInstruments`, `GeneralizeNonRMetric`, `ZeroBootstrapParam`). `UnsupportedStage` disappears; the CLI prose swaps accordingly. This task touches NO execution code: do NOT modify `crates/aura-campaign/src/exec.rs` (its `MonteCarlo | Generalize` `unreachable!` arm at exec.rs:279-281 is replaced by Task 3; no test in this task's gate drives `execute()` with an mc/generalize pipeline, so that arm cannot fire here). Do NOT modify `crates/aura-cli/tests/research_docs.rs` (Task 5 flips it — see the LOUD note at the end). + +- [ ] **Step 1: RED — rewrite the aura-campaign preflight tests to the v2 semantics** + +In `crates/aura-campaign/src/lib.rs`, inside `mod tests`, append the mc/generalize stage helpers and a two-instrument campaign fixture. Replace: + +```rust + fn wf_stage(metric: &str, select: SelectRule) -> StageBlock { + StageBlock::WalkForward { + in_sample_ms: 4000, + out_of_sample_ms: 1000, + step_ms: 1000, + mode: WfMode::Rolling, + metric: metric.to_string(), + select, + } + } +``` + +with: + +```rust + fn wf_stage(metric: &str, select: SelectRule) -> StageBlock { + StageBlock::WalkForward { + in_sample_ms: 4000, + out_of_sample_ms: 1000, + step_ms: 1000, + mode: WfMode::Rolling, + metric: metric.to_string(), + select, + } + } + + fn mc_stage(resamples: u32, block_len: u32) -> StageBlock { + StageBlock::MonteCarlo { resamples, block_len } + } + + fn generalize_stage(metric: &str) -> StageBlock { + StageBlock::Generalize { metric: metric.to_string() } + } + + /// [`campaign`] with a second instrument — generalize's static arity + /// guard needs >= 2 to pass. + fn campaign_two_instruments() -> CampaignDoc { + let mut c = campaign(); + c.data.instruments.push("GER40".to_string()); + c + } +``` + +In the same file, replace the v1 acceptance test: + +```rust + #[test] + fn preflight_accepts_the_v1_shape() { + let c = campaign(); + // the full v1 shape: sweep (gate)* (walk_forward)? + let full = process_of(vec![ + sweep_stage("sqn_normalized", SelectRule::Argmax, true), + gate_stage("net_expectancy_r"), + wf_stage("sqn_normalized", SelectRule::Argmax), + ]); + assert!(preflight(&full, &c).is_ok()); + // degenerate accepted shapes: bare sweep (plateau select without + // deflate is legal on a sweep); sweep + gates without walk_forward. + let bare = process_of(vec![sweep_stage("total_pips", SelectRule::PlateauMean, false)]); + assert!(preflight(&bare, &c).is_ok()); + let gated = process_of(vec![ + sweep_stage("expectancy_r", SelectRule::Argmax, false), + gate_stage("n_trades"), + gate_stage("win_rate"), + ]); + assert!(preflight(&gated, &c).is_ok()); + } +``` + +with: + +```rust + #[test] + fn preflight_accepts_the_v2_shapes() { + let c = campaign(); + let c2 = campaign_two_instruments(); + // the full v2 shape: sweep (gate)* (walk_forward)? (monte_carlo)? + // (generalize)? + let full = process_of(vec![ + sweep_stage("sqn_normalized", SelectRule::Argmax, true), + gate_stage("net_expectancy_r"), + wf_stage("sqn_normalized", SelectRule::Argmax), + mc_stage(1000, 5), + generalize_stage("net_expectancy_r"), + ]); + assert!(preflight(&full, &c2).is_ok()); + // degenerate accepted shapes: bare sweep (plateau select without + // deflate is legal on a sweep); sweep + gates without walk_forward. + let bare = process_of(vec![sweep_stage("total_pips", SelectRule::PlateauMean, false)]); + assert!(preflight(&bare, &c).is_ok()); + let gated = process_of(vec![ + sweep_stage("expectancy_r", SelectRule::Argmax, false), + gate_stage("n_trades"), + gate_stage("win_rate"), + ]); + assert!(preflight(&gated, &c).is_ok()); + // suffix stages compose independently: mc without wf (single + // instrument fine), generalize without mc (needs 2 instruments), + // wf + mc without generalize. + let mc_only = process_of(vec![ + sweep_stage("sqn", SelectRule::Argmax, false), + mc_stage(1000, 5), + ]); + assert!(preflight(&mc_only, &c).is_ok()); + let gen_only = process_of(vec![ + sweep_stage("sqn", SelectRule::Argmax, false), + generalize_stage("expectancy_r"), + ]); + assert!(preflight(&gen_only, &c2).is_ok()); + let wf_mc = process_of(vec![ + sweep_stage("sqn", SelectRule::Argmax, false), + wf_stage("sqn", SelectRule::Argmax), + mc_stage(100, 3), + ]); + assert!(preflight(&wf_mc, &c).is_ok()); + } +``` + +Replace the v1-boundary refusal test: + +```rust + #[test] + fn preflight_refuses_mc_and_generalize_stages() { + let c = campaign(); + let mc = process_of(vec![ + sweep_stage("sqn", SelectRule::Argmax, false), + StageBlock::MonteCarlo { resamples: 100, block_len: 5 }, + ]); + assert!(matches!( + preflight(&mc, &c), + Err(ExecFault::UnsupportedStage { stage: 1, block }) if block == "std::monte_carlo" + )); + let g = process_of(vec![ + sweep_stage("sqn", SelectRule::Argmax, false), + StageBlock::Generalize { metric: "expectancy_r".to_string() }, + ]); + assert!(matches!( + preflight(&g, &c), + Err(ExecFault::UnsupportedStage { stage: 1, block }) if block == "std::generalize" + )); + } +``` + +with: + +```rust + /// v2 shape: an annotator may not precede a population stage, and + /// std::generalize is strictly last. `[sweep, mc, walk_forward]` is + /// intrinsically valid at the document tier yet refused here (spec + /// acceptance criterion 3). + #[test] + fn preflight_refuses_annotator_order_violations() { + let c2 = campaign_two_instruments(); + let cases: Vec<(Vec, &str)> = vec![ + ( + vec![ + sweep_stage("sqn", SelectRule::Argmax, false), + mc_stage(100, 5), + wf_stage("sqn", SelectRule::Argmax), + ], + "stage 2: std::walk_forward cannot follow std::monte_carlo", + ), + ( + vec![ + sweep_stage("sqn", SelectRule::Argmax, false), + mc_stage(100, 5), + gate_stage("expectancy_r"), + ], + "stage 2: std::gate cannot follow std::monte_carlo", + ), + ( + vec![ + sweep_stage("sqn", SelectRule::Argmax, false), + generalize_stage("expectancy_r"), + mc_stage(100, 5), + ], + "stage 2: std::monte_carlo cannot follow std::generalize", + ), + ( + vec![ + sweep_stage("sqn", SelectRule::Argmax, false), + wf_stage("sqn", SelectRule::Argmax), + gate_stage("expectancy_r"), + ], + "stage 2: std::gate cannot follow std::walk_forward", + ), + ]; + for (pipeline, want) in cases { + match preflight(&process_of(pipeline), &c2) { + Err(ExecFault::PipelineShape { detail }) => assert_eq!(detail, want), + other => panic!("expected PipelineShape({want}), got {other:?}"), + } + } + } + + /// Each v2 suffix stage appears at most once. + #[test] + fn preflight_refuses_duplicate_suffix_stages() { + let c2 = campaign_two_instruments(); + let cases: Vec<(Vec, &str)> = vec![ + ( + vec![ + sweep_stage("sqn", SelectRule::Argmax, false), + wf_stage("sqn", SelectRule::Argmax), + wf_stage("sqn", SelectRule::Argmax), + ], + "stage 2: std::walk_forward may appear at most once", + ), + ( + vec![ + sweep_stage("sqn", SelectRule::Argmax, false), + mc_stage(100, 5), + mc_stage(100, 5), + ], + "stage 2: std::monte_carlo may appear at most once", + ), + ( + vec![ + sweep_stage("sqn", SelectRule::Argmax, false), + generalize_stage("expectancy_r"), + generalize_stage("expectancy_r"), + ], + "stage 2: std::generalize may appear at most once", + ), + ]; + for (pipeline, want) in cases { + match preflight(&process_of(pipeline), &c2) { + Err(ExecFault::PipelineShape { detail }) => assert_eq!(detail, want), + other => panic!("expected PipelineShape({want}), got {other:?}"), + } + } + } + + /// generalize's static arity guard: the campaign must carry >= 2 + /// instruments (the shipped generalization() would only refuse at + /// runtime, after members ran). + #[test] + fn preflight_refuses_single_instrument_generalize() { + let c = campaign(); // one instrument + let p = process_of(vec![ + sweep_stage("sqn", SelectRule::Argmax, false), + generalize_stage("expectancy_r"), + ]); + assert!(matches!( + preflight(&p, &c), + Err(ExecFault::GeneralizeNeedsInstruments { available: 1 }) + )); + } + + /// generalize's metric guard: check_r_metric refuses pip metrics AND + /// unknown names — both surface as GeneralizeNonRMetric. + #[test] + fn preflight_refuses_non_r_generalize_metric() { + let c2 = campaign_two_instruments(); + for bad in ["total_pips", "no_such_metric"] { + let p = process_of(vec![ + sweep_stage("sqn", SelectRule::Argmax, false), + generalize_stage(bad), + ]); + assert!(matches!( + preflight(&p, &c2), + Err(ExecFault::GeneralizeNonRMetric { metric }) if metric == bad + )); + } + } + + /// monte_carlo zero params are static refusals (instead of the engine's + /// defined all-zero degenerate at runtime), naming the offending field. + #[test] + fn preflight_refuses_zero_bootstrap_params() { + let c = campaign(); + let zero_resamples = process_of(vec![ + sweep_stage("sqn", SelectRule::Argmax, false), + mc_stage(0, 5), + ]); + assert!(matches!( + preflight(&zero_resamples, &c), + Err(ExecFault::ZeroBootstrapParam { stage: 1, field: "resamples" }) + )); + let zero_block = process_of(vec![ + sweep_stage("sqn", SelectRule::Argmax, false), + mc_stage(100, 0), + ]); + assert!(matches!( + preflight(&zero_block, &c), + Err(ExecFault::ZeroBootstrapParam { stage: 1, field: "block_len" }) + )); + } +``` + + +Still in `mod tests`, the wf-mid case in `preflight_refuses_non_sweep_first_and_double_sweep` stays RED-relevant but its comment is now wrong (v2 relaxes wf-strictly-last: annotators may follow wf; a *gate* after wf is still a shape violation). Replace: + +```rust + // walk_forward anywhere but last is a shape refusal too. + let wf_mid = process_of(vec![ +``` + +with: + +```rust + // a gate after walk_forward is still a shape refusal (v2 relaxes + // wf-final only for the annotator suffix: mc/generalize may follow). + let wf_mid = process_of(vec![ +``` + +(The assertion itself — `Err(ExecFault::PipelineShape { .. })` — still holds under v2 and is not changed. All other tests in :484-573 — unrankable metric, annotation gate metric, plateau-in-wf, i64 fit, deflate/plateau conflict — were read and verified to hold unchanged under v2; none pins wf-strictly-last prose.) + +- [ ] **Step 2: Run the RED check** + +Run: + +``` +cargo test -p aura-campaign 2>&1 | tail -25 +``` + +Expected: compilation FAILS with `error[E0599]`-class errors — no variant `GeneralizeNeedsInstruments`, `GeneralizeNonRMetric`, `ZeroBootstrapParam` found for enum `ExecFault`. This compile failure is the RED absence record for the new fault set (the tests reference variants that do not yet exist; a runtime-RED state is unreachable because the variant swap is compile-coupled). + +- [ ] **Step 3: Swap the ExecFault variant set** + +In `crates/aura-campaign/src/lib.rs`, replace the import at line 21: + +```rust +use aura_registry::RegistryError; +``` + +with: + +```rust +use aura_registry::{check_r_metric, RegistryError}; +``` + +Remove `UnsupportedStage` and update the `PipelineShape` doc to the v2 shape — replace: + +```rust +#[derive(Debug)] +pub enum ExecFault { + /// v1 boundary: `std::monte_carlo` / `std::generalize` are not executable. + UnsupportedStage { stage: usize, block: String }, + /// The pipeline is not `std::sweep (std::gate)* (std::walk_forward)?`. + PipelineShape { detail: String }, +``` + +with: + +```rust +#[derive(Debug)] +pub enum ExecFault { + /// The pipeline is not `std::sweep (std::gate)* (std::walk_forward)? + /// (std::monte_carlo)? (std::generalize)?`. + PipelineShape { detail: String }, +``` + +Add the three new static-fault variants (doc comments per the spec), keeping the preflight faults grouped before the runtime faults — replace: + +```rust + /// sweep `deflate: true` with a non-argmax select rule. + DeflatePlateauConflict { stage: usize }, + /// `WindowRoller` construction refusals at runtime. + Window { stage: usize, detail: String }, +``` + +with: + +```rust + /// sweep `deflate: true` with a non-argmax select rule. + DeflatePlateauConflict { stage: usize }, + /// generalize needs >= 2 instruments in the campaign (static). + GeneralizeNeedsInstruments { available: usize }, + /// generalize's metric must be an R metric (static — the intrinsic tier + /// accepts any vocabulary name; the shipped generalization() would only + /// refuse at runtime). + GeneralizeNonRMetric { metric: String }, + /// monte_carlo with resamples == 0 or block_len == 0 (static, instead of + /// the engine's defined all-zero degenerate). + ZeroBootstrapParam { stage: usize, field: &'static str }, + /// `WindowRoller` construction refusals at runtime. + Window { stage: usize, detail: String }, +``` + +- [ ] **Step 4: Replace preflight with the v2 shape sweep + static guards** + +In `crates/aura-campaign/src/lib.rs`, replace the entire v1 `preflight` (doc comment through closing brace): + +```rust +/// Statically refuse everything refusable before any member runs (the F7 +/// lesson applied forward): the v1 executable pipeline shape is exactly +/// `std::sweep (std::gate)* (std::walk_forward)?`; every sweep/walk_forward +/// selection metric is in [`RANKABLE_METRICS`]; every gate predicate metric +/// is in [`PER_MEMBER_METRICS`]; walk_forward must not select `plateau:*` +/// (a gated survivor subset has no grid lattice); sweep `deflate: true` +/// composes only with `argmax`; walk_forward lengths must fit `i64` (the +/// roller's Timestamp unit). The campaign parameter is the seam for +/// campaign-level static checks (none in v1, hence unused). +pub(crate) fn preflight(process: &ProcessDoc, _campaign: &CampaignDoc) -> Result<(), ExecFault> { + // v1 boundary first: an mc/generalize stage refuses wherever it sits, + // before any shape complaint about the same stage. + for (i, stage) in process.pipeline.iter().enumerate() { + let block = match stage { + StageBlock::MonteCarlo { .. } => "std::monte_carlo", + StageBlock::Generalize { .. } => "std::generalize", + _ => continue, + }; + return Err(ExecFault::UnsupportedStage { stage: i, block: block.to_string() }); + } + // shape: exactly `std::sweep (std::gate)* (std::walk_forward)?`. + if !matches!(process.pipeline.first(), Some(StageBlock::Sweep { .. })) { + return Err(ExecFault::PipelineShape { + detail: "the first stage must be std::sweep".to_string(), + }); + } + let last = process.pipeline.len() - 1; + for (i, stage) in process.pipeline.iter().enumerate().skip(1) { + match stage { + StageBlock::Sweep { .. } => { + return Err(ExecFault::PipelineShape { + detail: format!("stage {i}: only the first stage may be std::sweep"), + }); + } + StageBlock::WalkForward { .. } if i != last => { + return Err(ExecFault::PipelineShape { + detail: format!("stage {i}: std::walk_forward must be the final stage"), + }); + } + _ => {} + } + } + // per-stage slot rules (shape already established above). + for (i, stage) in process.pipeline.iter().enumerate() { + match stage { + StageBlock::Sweep { metric, select, deflate } => { + if !RANKABLE_METRICS.contains(&metric.as_str()) { + return Err(ExecFault::UnrankableMetric { stage: i, metric: metric.clone() }); + } + if *deflate && *select != SelectRule::Argmax { + return Err(ExecFault::DeflatePlateauConflict { stage: i }); + } + } + StageBlock::Gate { all } => { + for p in all { + if !PER_MEMBER_METRICS.contains(&p.metric.as_str()) { + return Err(ExecFault::GateMetricNotPerMember { + stage: i, + metric: p.metric.clone(), + }); + } + } + } + StageBlock::WalkForward { + in_sample_ms, + out_of_sample_ms, + step_ms, + mode: _, + metric, + select, + } => { + if !RANKABLE_METRICS.contains(&metric.as_str()) { + return Err(ExecFault::UnrankableMetric { stage: i, metric: metric.clone() }); + } + if matches!(select, SelectRule::PlateauMean | SelectRule::PlateauWorst) { + return Err(ExecFault::PlateauInWalkForward { stage: i }); + } + for (field, len) in [ + ("in_sample_ms", *in_sample_ms), + ("out_of_sample_ms", *out_of_sample_ms), + ("step_ms", *step_ms), + ] { + if i64::try_from(len).is_err() { + return Err(ExecFault::PipelineShape { + detail: format!("stage {i}: walk_forward {field} does not fit i64"), + }); + } + } + } + StageBlock::MonteCarlo { .. } | StageBlock::Generalize { .. } => { + unreachable!("refused by the v1 scan above") + } + } + } + Ok(()) +} +``` + +with: + +```rust +/// Statically refuse everything refusable before any member runs (the F7 +/// lesson applied forward): the v2 executable pipeline shape is exactly +/// `std::sweep (std::gate)* (std::walk_forward)? (std::monte_carlo)? +/// (std::generalize)?` — each suffix stage at most once, order fixed, +/// `std::generalize` strictly last; every sweep/walk_forward selection +/// metric is in [`RANKABLE_METRICS`]; every gate predicate metric is in +/// [`PER_MEMBER_METRICS`]; walk_forward must not select `plateau:*` (a gated +/// survivor subset has no grid lattice); sweep `deflate: true` composes only +/// with `argmax`; walk_forward lengths must fit `i64` (the roller's +/// Timestamp unit); monte_carlo `resamples` and `block_len` must be > 0 +/// (static, instead of the engine's defined all-zero degenerate); generalize +/// needs >= 2 campaign instruments and an R selection metric (the registry's +/// `check_r_metric`). The campaign parameter carries the campaign-level +/// static checks (generalize's instrument arity). +pub(crate) fn preflight(process: &ProcessDoc, campaign: &CampaignDoc) -> Result<(), ExecFault> { + // Position of a stage in the fixed v2 shape; gates (rank 1) may repeat, + // every other rank appears at most once and ranks never decrease. + fn shape_rank(stage: &StageBlock) -> usize { + match stage { + StageBlock::Sweep { .. } => 0, + StageBlock::Gate { .. } => 1, + StageBlock::WalkForward { .. } => 2, + StageBlock::MonteCarlo { .. } => 3, + StageBlock::Generalize { .. } => 4, + } + } + fn block_id(stage: &StageBlock) -> &'static str { + match stage { + StageBlock::Sweep { .. } => "std::sweep", + StageBlock::Gate { .. } => "std::gate", + StageBlock::WalkForward { .. } => "std::walk_forward", + StageBlock::MonteCarlo { .. } => "std::monte_carlo", + StageBlock::Generalize { .. } => "std::generalize", + } + } + // shape: `std::sweep (std::gate)* (std::walk_forward)? (std::monte_carlo)? + // (std::generalize)?` — a monotone rank walk over adjacent pairs captures + // every violation: a rank drop is an out-of-order stage (an annotator + // before a population stage, anything after std::generalize), an adjacent + // equal rank above the gate tier is a duplicate suffix stage. + if !matches!(process.pipeline.first(), Some(StageBlock::Sweep { .. })) { + return Err(ExecFault::PipelineShape { + detail: "the first stage must be std::sweep".to_string(), + }); + } + let mut prev = &process.pipeline[0]; + for (i, stage) in process.pipeline.iter().enumerate().skip(1) { + if matches!(stage, StageBlock::Sweep { .. }) { + return Err(ExecFault::PipelineShape { + detail: format!("stage {i}: only the first stage may be std::sweep"), + }); + } + let (rank, prev_rank) = (shape_rank(stage), shape_rank(prev)); + if rank < prev_rank { + return Err(ExecFault::PipelineShape { + detail: format!( + "stage {i}: {} cannot follow {}", + block_id(stage), + block_id(prev) + ), + }); + } + if rank == prev_rank && rank > 1 { + return Err(ExecFault::PipelineShape { + detail: format!("stage {i}: {} may appear at most once", block_id(stage)), + }); + } + prev = stage; + } + // per-stage slot rules (shape already established above). + for (i, stage) in process.pipeline.iter().enumerate() { + match stage { + StageBlock::Sweep { metric, select, deflate } => { + if !RANKABLE_METRICS.contains(&metric.as_str()) { + return Err(ExecFault::UnrankableMetric { stage: i, metric: metric.clone() }); + } + if *deflate && *select != SelectRule::Argmax { + return Err(ExecFault::DeflatePlateauConflict { stage: i }); + } + } + StageBlock::Gate { all } => { + for p in all { + if !PER_MEMBER_METRICS.contains(&p.metric.as_str()) { + return Err(ExecFault::GateMetricNotPerMember { + stage: i, + metric: p.metric.clone(), + }); + } + } + } + StageBlock::WalkForward { + in_sample_ms, + out_of_sample_ms, + step_ms, + mode: _, + metric, + select, + } => { + if !RANKABLE_METRICS.contains(&metric.as_str()) { + return Err(ExecFault::UnrankableMetric { stage: i, metric: metric.clone() }); + } + if matches!(select, SelectRule::PlateauMean | SelectRule::PlateauWorst) { + return Err(ExecFault::PlateauInWalkForward { stage: i }); + } + for (field, len) in [ + ("in_sample_ms", *in_sample_ms), + ("out_of_sample_ms", *out_of_sample_ms), + ("step_ms", *step_ms), + ] { + if i64::try_from(len).is_err() { + return Err(ExecFault::PipelineShape { + detail: format!("stage {i}: walk_forward {field} does not fit i64"), + }); + } + } + } + StageBlock::MonteCarlo { resamples, block_len } => { + if *resamples == 0 { + return Err(ExecFault::ZeroBootstrapParam { stage: i, field: "resamples" }); + } + if *block_len == 0 { + return Err(ExecFault::ZeroBootstrapParam { stage: i, field: "block_len" }); + } + } + StageBlock::Generalize { metric } => { + let available = campaign.data.instruments.len(); + if available < 2 { + return Err(ExecFault::GeneralizeNeedsInstruments { available }); + } + if check_r_metric(metric).is_err() { + return Err(ExecFault::GeneralizeNonRMetric { metric: metric.clone() }); + } + } + } + } + Ok(()) +} +``` + +(The i64-fit, rankable, gate-metric, and plateau/deflate checks are byte-identical to v1 — only the doc comment, the parameter name, the shape walk, and the `MonteCarlo`/`Generalize` arms change. The shape walk is complete: any violation of the v2 regex manifests as an adjacent rank inversion or an adjacent duplicate above the gate tier.) + +- [ ] **Step 5: GREEN gate on aura-campaign** + +Run: + +``` +cargo test -p aura-campaign preflight_ +``` + +Expected: the lib test target reports `test result: ok. 12 passed` (the 6 rewritten/new v2 tests + the 6 surviving v1 refusal tests); other targets report 0 matched. Then run the full crate suite: + +``` +cargo test -p aura-campaign +``` + +Expected: every target (`lib` unit tests incl. `wf_tests`, `tests/execute.rs`) reports `test result: ok` with 0 failed. NOTE: at this point `aura-cli` does NOT compile (its `exec_fault_prose` still matches the removed variant) — that is expected and fixed in Steps 6-7; `cargo test -p aura-campaign` does not build aura-cli. + +- [ ] **Step 6: RED — pin the three new fault proses in aura-cli** + +In `crates/aura-cli/src/campaign_run.rs`, inside `mod tests`, append three prose pins. Replace: + +```rust + fn gate_metric_not_per_member_prose_points_at_the_metrics_introspection() { + let prose = exec_fault_prose(&ExecFault::GateMetricNotPerMember { + stage: 1, + metric: "deflated_score".into(), + }); + assert!(prose.contains("is not a per-member"), "intrinsic message kept: {prose}"); + assert!( + prose.contains("aura process introspect --metrics"), + "the refusal must point at the enumeration verb: {prose}" + ); + } +``` + +with: + +```rust + fn gate_metric_not_per_member_prose_points_at_the_metrics_introspection() { + let prose = exec_fault_prose(&ExecFault::GateMetricNotPerMember { + stage: 1, + metric: "deflated_score".into(), + }); + assert!(prose.contains("is not a per-member"), "intrinsic message kept: {prose}"); + assert!( + prose.contains("aura process introspect --metrics"), + "the refusal must point at the enumeration verb: {prose}" + ); + } + + #[test] + /// 0108: generalize's static instrument-arity refusal is Debug-free and + /// names the available count plus the required floor. + fn generalize_needs_instruments_prose_names_the_shortfall() { + let prose = exec_fault_prose(&ExecFault::GeneralizeNeedsInstruments { available: 1 }); + assert_eq!(prose, "campaign has 1 instrument(s); std::generalize needs at least 2"); + assert!(!prose.contains("GeneralizeNeedsInstruments"), "Debug leak: {prose}"); + } + + #[test] + /// 0108: generalize's R-metric refusal names the metric and the R-only + /// rationale (pip metrics are not comparable across instruments). + fn generalize_non_r_metric_prose_names_the_metric() { + let prose = exec_fault_prose(&ExecFault::GeneralizeNonRMetric { + metric: "total_pips".into(), + }); + assert_eq!( + prose, + "std::generalize metric \"total_pips\" is not an R metric (gross/net R \ + families generalize; pip metrics do not)" + ); + assert!(!prose.contains("GeneralizeNonRMetric"), "Debug leak: {prose}"); + } + + #[test] + /// 0108: the zero-bootstrap-param refusal is path-addressed (stage index) + /// and names the offending field. + fn zero_bootstrap_param_prose_names_stage_and_field() { + let prose = exec_fault_prose(&ExecFault::ZeroBootstrapParam { + stage: 3, + field: "block_len", + }); + assert_eq!(prose, "process stage 3: monte_carlo block_len must be > 0"); + assert!(!prose.contains("ZeroBootstrapParam"), "Debug leak: {prose}"); + } +``` + +Run: + +``` +cargo test -p aura-cli --bin aura 2>&1 | tail -25 +``` + +Expected: compilation FAILS — an E0599-class error on the stale `ExecFault::UnsupportedStage` pattern (the variant no longer exists) and/or `error[E0004]` (non-exhaustive match: `GeneralizeNeedsInstruments`, `GeneralizeNonRMetric`, `ZeroBootstrapParam` not covered). This is the RED record for the prose arms. + +- [ ] **Step 7: Swap the exec_fault_prose arms** + +In `crates/aura-cli/src/campaign_run.rs`, remove the `UnsupportedStage` arm — replace: + +```rust +fn exec_fault_prose(f: &ExecFault) -> String { + match f { + ExecFault::UnsupportedStage { stage, block } => format!( + "process stage {stage} ({block}) is not executable in v1; executable \ + pipeline shape: std::sweep [std::gate]* [std::walk_forward]" + ), + ExecFault::PipelineShape { detail } => { + format!("process pipeline is not executable: {detail}") + } +``` + +with: + +```rust +fn exec_fault_prose(f: &ExecFault) -> String { + match f { + ExecFault::PipelineShape { detail } => { + format!("process pipeline is not executable: {detail}") + } +``` + +Add the three new arms, mirroring the enum's variant order (after `DeflatePlateauConflict`, before `Window`) — replace: + +```rust + ExecFault::DeflatePlateauConflict { stage } => format!( + "process stage {stage}: sweep deflate: true composes only with select \"argmax\"" + ), + ExecFault::Window { stage, detail } => format!( + "process stage {stage}: walk_forward windows do not fit the campaign \ + window: {detail}" + ), +``` + +with: + +```rust + ExecFault::DeflatePlateauConflict { stage } => format!( + "process stage {stage}: sweep deflate: true composes only with select \"argmax\"" + ), + ExecFault::GeneralizeNeedsInstruments { available } => format!( + "campaign has {available} instrument(s); std::generalize needs at least 2" + ), + ExecFault::GeneralizeNonRMetric { metric } => format!( + "std::generalize metric \"{metric}\" is not an R metric (gross/net R \ + families generalize; pip metrics do not)" + ), + ExecFault::ZeroBootstrapParam { stage, field } => format!( + "process stage {stage}: monte_carlo {field} must be > 0" + ), + ExecFault::Window { stage, detail } => format!( + "process stage {stage}: walk_forward windows do not fit the campaign \ + window: {detail}" + ), +``` + +Deliberately NO `(see: aura process introspect --metrics)` pointers on these three arms — the spec's prose for them (spec section "aura-cli") carries none; pointers stay confined to the two metric-roster refusals that already have them. + +- [ ] **Step 8: GREEN gate on the aura-cli unit tests + workspace build** + +The `campaign_run.rs` unit tests live in the `aura` bin target (aura-cli has no lib target), so they are invoked with `--bin aura`. Run: + +``` +cargo test -p aura-cli --bin aura prose_names +``` + +Expected: `test result: ok. 3 passed` (the three new pins), remainder filtered out. Then the full bin unit-test target: + +``` +cargo test -p aura-cli --bin aura +``` + +Expected: `test result: ok` with 0 failed (113 passed = the 110 pre-existing bin unit tests + the 3 new pins; the invariant is 0 failed and the 3 new tests present). Finally: + +``` +cargo build --workspace +``` + +Expected: builds clean (no errors; no new warnings). + +**LOUD BOUNDARY NOTE — expected RED left behind by this task:** the aura-cli *integration* test `campaign_run_v1_boundary_refuses_mc_process` in `crates/aura-cli/tests/research_docs.rs` (around :777-795; its :794 asserts `!out.contains("UnsupportedStage")` inside a test that also asserts the now-removed `"not executable in v1"` prose and exit 1) WILL FAIL after this task — an mc-bearing process now passes preflight by design. Task 5 flips that test to the v2 semantics. Do NOT touch `crates/aura-cli/tests/research_docs.rs` in this task, and do NOT run `cargo test -p aura-cli` (unfiltered, all targets) as a gate here. The intermediate gate for this task is exactly: `cargo test -p aura-campaign` + `cargo test -p aura-cli --bin aura` + `cargo build --workspace` (the two crate suites EXCLUDING the aura-cli integration tests, plus the workspace build). Likewise expected: `crates/aura-campaign/src/exec.rs:279-281` still holds the `MonteCarlo | Generalize` `unreachable!` arm — Task 3 replaces it with the execution arms; no test in this task's gate reaches it. +### Task 3: Execute the `std::monte_carlo` stage arm (dual-input bootstrap annotation) + +Depends on Task 1 (registry/engine widenings: `StageBootstrap`, `StageRealization.bootstrap`, serde on `RBootstrap`) and Task 2 (preflight v2 accepts the annotator suffix). Line references are anchors into the pre-cycle file state — always match on the quoted bytes, not on line numbers. + +**Files:** +- Modify: `crates/aura-campaign/src/exec.rs` (import blocks at the top of the file; the `StageBlock::MonteCarlo | StageBlock::Generalize` unreachable arm near the end of `run_cell`'s stage loop; a new private helper after `scalar_point`) +- Test: `crates/aura-campaign/tests/execute.rs` (imports; `planted_report` gains a filled `trade_rs`; new `wf_stage` constructor; new `ZeroTradeRunner` fake; three new `execute_mc_*` tests) + +- [ ] **Step 1: RED — extend the fake and add the three mc tests** + +In `crates/aura-campaign/tests/execute.rs`, replace: + +```rust +use aura_engine::{RMetrics, RunManifest, RunMetrics, RunReport, SelectionMode}; +use aura_registry::{FamilyKind, Registry}; +use aura_research::{ + Axis, CampaignDoc, Cmp, DataSection, DocKind, DocRef, Predicate, Presentation, ProcessDoc, + ProcessRef, SelectRule, StageBlock, StrategyEntry, Window, +}; +``` + +with: + +```rust +use aura_engine::{r_bootstrap, RMetrics, RunManifest, RunMetrics, RunReport, SelectionMode}; +use aura_registry::{FamilyKind, Registry, StageBootstrap}; +use aura_research::{ + Axis, CampaignDoc, Cmp, DataSection, DocKind, DocRef, Predicate, Presentation, ProcessDoc, + ProcessRef, SelectRule, StageBlock, StrategyEntry, WfMode, Window, +}; +``` + +In the same file, replace: + +```rust +fn planted_report(cell: &CellSpec, params: &[(String, Scalar)], window_ms: (i64, i64)) -> RunReport { + let fast = param_i64(params, "fast"); +``` + +with: + +```rust +/// The deterministic 4-trade R series a planted report carries (matches +/// `n_trades: 4`; non-constant so block resampling has structure). In-memory +/// only: `trade_rs` is serde-skipped and excluded from `PartialEq`, so every +/// existing equality and round-trip assertion stays green. +fn planted_trade_rs(net: f64) -> Vec { + vec![net, -net, 2.0 * net, net] +} + +fn planted_report(cell: &CellSpec, params: &[(String, Scalar)], window_ms: (i64, i64)) -> RunReport { + let fast = param_i64(params, "fast"); +``` + +In the same file, replace: + +```rust + net_expectancy_r: net, + conviction_terciles_r: [0.0, 0.0, 0.0], + trade_rs: Vec::new(), +``` + +with: + +```rust + net_expectancy_r: net, + conviction_terciles_r: [0.0, 0.0, 0.0], + trade_rs: planted_trade_rs(net), +``` + +In the same file, replace: + +```rust +impl MemberRunner for FakeRunner { + fn run_member( + &self, + cell: &CellSpec, + params: &[(String, Scalar)], + window_ms: (i64, i64), + ) -> Result { + if let Some((_, fault)) = self.faults.iter().find(|(p, _)| p.as_slice() == params) { + return Err(fault.clone()); + } + Ok(planted_report(cell, params, window_ms)) + } +} +``` + +with: + +```rust +impl MemberRunner for FakeRunner { + fn run_member( + &self, + cell: &CellSpec, + params: &[(String, Scalar)], + window_ms: (i64, i64), + ) -> Result { + if let Some((_, fault)) = self.faults.iter().find(|(p, _)| p.as_slice() == params) { + return Err(fault.clone()); + } + Ok(planted_report(cell, params, window_ms)) + } +} + +/// Wraps the clean fake and strips the R block from ONE param point's report — +/// the zero-trade survivor for the mc degenerate pin. +struct ZeroTradeRunner { + inner: FakeRunner, + strip: Vec<(String, Scalar)>, +} + +impl MemberRunner for ZeroTradeRunner { + fn run_member( + &self, + cell: &CellSpec, + params: &[(String, Scalar)], + window_ms: (i64, i64), + ) -> Result { + let mut report = self.inner.run_member(cell, params, window_ms)?; + if params == self.strip.as_slice() { + report.metrics.r = None; + } + Ok(report) + } +} +``` + +In the same file, replace: + +```rust +fn gate_stage(value: f64) -> StageBlock { + StageBlock::Gate { + all: vec![Predicate { metric: "net_expectancy_r".to_string(), cmp: Cmp::Gt, value }], + } +} +``` + +with: + +```rust +fn gate_stage(value: f64) -> StageBlock { + StageBlock::Gate { + all: vec![Predicate { metric: "net_expectancy_r".to_string(), cmp: Cmp::Gt, value }], + } +} + +/// 2 rolling windows over the campaign window [1_000, 9_000]: IS 4_000 ms, +/// OOS 2_000 ms, step 2_000 ms -> OOS [5_000, 6_999] and [7_000, 8_999] +/// (window 2 would need OOS end 10_999 > 9_000, so the roller stops at 2). +fn wf_stage() -> StageBlock { + StageBlock::WalkForward { + in_sample_ms: 4_000, + out_of_sample_ms: 2_000, + step_ms: 2_000, + mode: WfMode::Rolling, + metric: "net_expectancy_r".to_string(), + select: SelectRule::Argmax, + } +} +``` + +In the same file, append at the very end (after the closing brace of `execute_refuses_malformed_campaign_id`): + +```rust +#[test] +fn execute_mc_after_gate_bootstraps_each_survivor() { + let reg = temp_registry("mc_per_survivor"); + let doc = campaign(&["EURUSD"]); // seed: 7 + let proc_doc = process(vec![ + sweep_stage(false), + gate_stage(0.3), + StageBlock::MonteCarlo { resamples: 200, block_len: 2 }, + ]); + let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®) + .expect("mc-after-gate campaign executes"); + + // planted nets .26/.29/.36/.39 -> gate gt 0.3 keeps ordinals 2 and 3 + let cell = &out.record.cells[0]; + assert_eq!(cell.stages.len(), 3); + let mc = &cell.stages[2]; + assert_eq!(mc.block, "std::monte_carlo"); + assert_eq!(mc.family_id, None); + assert_eq!(mc.survivor_ordinals, None); + assert_eq!(mc.selection, None); + + // each survivor's bootstrap == a hand-called r_bootstrap on its planted + // trade_rs, seeded from the campaign doc (the deflation convention) + let net = |fast: i64, slow: i64| (fast * 10 + slow) as f64 / 100.0; + let expected = StageBootstrap::PerSurvivor(vec![ + (2, r_bootstrap(&planted_trade_rs(net(3, 6)), 200, 2, 7)), + (3, r_bootstrap(&planted_trade_rs(net(3, 9)), 200, 2, 7)), + ]); + assert_eq!(mc.bootstrap, Some(expected)); + + // the record round-trips through the store with the bootstrap intact + let runs = reg.load_campaign_runs().expect("load campaign runs"); + assert_eq!(runs[0], out.record); +} + +#[test] +fn execute_mc_after_wf_pools_the_oos_series() { + let reg = temp_registry("mc_pooled"); + let doc = campaign(&["EURUSD"]); // seed: 7, window [1_000, 9_000] + let proc_doc = process(vec![ + sweep_stage(false), + wf_stage(), + StageBlock::MonteCarlo { resamples: 200, block_len: 3 }, + ]); + let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®) + .expect("mc-after-wf campaign executes"); + + // the roller yields 2 windows; both pick the planted argmax (3,9) + let wf = &out.cells[0].families[1]; + assert_eq!(wf.block, "std::walk_forward"); + assert_eq!(wf.reports.len(), 2); + + // pooled input = the wf family reports' trade_rs concatenated in report + // order (roll order per walkforward_member_reports) + let mut pooled: Vec = Vec::new(); + for report in &wf.reports { + pooled.extend_from_slice(&report.metrics.r.as_ref().expect("planted R block").trade_rs); + } + let net = (3 * 10 + 9) as f64 / 100.0; + let mut expected_pool = planted_trade_rs(net); + expected_pool.extend(planted_trade_rs(net)); + assert_eq!(pooled, expected_pool); + + let mc = &out.record.cells[0].stages[2]; + assert_eq!(mc.block, "std::monte_carlo"); + assert_eq!(mc.family_id, None); + assert_eq!(mc.bootstrap, Some(StageBootstrap::PooledOos(r_bootstrap(&pooled, 200, 3, 7)))); +} + +#[test] +fn execute_mc_zero_trade_member_records_degenerate() { + let reg = temp_registry("mc_zero_trade"); + let doc = campaign(&["EURUSD"]); // seed: 7 + let proc_doc = + process(vec![sweep_stage(false), StageBlock::MonteCarlo { resamples: 200, block_len: 2 }]); + // ordinal 0 == odometer point (2,6): its report carries no R block at all + let runner = ZeroTradeRunner { + inner: FakeRunner::clean(), + strip: vec![("fast".to_string(), Scalar::i64(2)), ("slow".to_string(), Scalar::i64(6))], + }; + let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &runner, ®) + .expect("a zero-trade member is a valid result, not a fault"); + + let mc = &out.record.cells[0].stages[1]; + assert_eq!(mc.block, "std::monte_carlo"); + let Some(StageBootstrap::PerSurvivor(entries)) = &mc.bootstrap else { + panic!("expected a per-survivor bootstrap, got {:?}", mc.bootstrap); + }; + // no gate ran: all 4 members survive, each annotated + assert_eq!(entries.iter().map(|(o, _)| *o).collect::>(), vec![0, 1, 2, 3]); + // the r: None member records the engine's defined all-zero degenerate + assert_eq!(entries[0].1, r_bootstrap(&[], 200, 2, 7)); + assert_eq!(entries[0].1.n_trades, 0); + assert_eq!(entries[0].1.e_r.mean, 0.0); + assert_eq!(entries[0].1.prob_le_zero, 0.0); +} +``` + +- [ ] **Step 2: Run — confirm RED** + +Run: `cargo test -p aura-campaign execute_mc` +Expected: the test target compiles; the three `execute_mc_*` tests FAIL, each panicking with `internal error: entered unreachable code: preflight refuses non-v1 stages before any member runs` (preflight v2 now admits the stage, the executor arm does not exist yet). No other test fails. + +- [ ] **Step 3: import `r_bootstrap` and `StageBootstrap` in the executor** + +In `crates/aura-campaign/src/exec.rs`, replace: + +```rust +use aura_engine::{ + sweep, walk_forward, GridSpace, ListSpace, RollMode, RunManifest, RunMetrics, RunReport, + Space, SweepFamily, SweepPoint, WalkForwardError, WindowBounds, WindowRoller, WindowRun, +}; +use aura_registry::{ + optimize, optimize_deflated, optimize_plateau, sweep_member_reports, + walkforward_member_reports, CampaignRunRecord, CellRealization, FamilyKind, PlateauMode, + Registry, StageRealization, StageSelection, +}; +``` + +with: + +```rust +use aura_engine::{ + r_bootstrap, sweep, walk_forward, GridSpace, ListSpace, RollMode, RunManifest, RunMetrics, + RunReport, Space, SweepFamily, SweepPoint, WalkForwardError, WindowBounds, WindowRoller, + WindowRun, +}; +use aura_registry::{ + optimize, optimize_deflated, optimize_plateau, sweep_member_reports, + walkforward_member_reports, CampaignRunRecord, CellRealization, FamilyKind, PlateauMode, + Registry, StageBootstrap, StageRealization, StageSelection, +}; +``` + +- [ ] **Step 4: add the `pooled_trade_rs` helper** + +In `crates/aura-campaign/src/exec.rs`, replace: + +```rust +/// A family point's tag-free cells lifted back to self-describing scalars in +/// `space` slot order — the survivor-point form the walk-forward stage +/// re-sweeps through a `ListSpace`. +fn scalar_point(space: &[ParamSpec], point: &[Cell]) -> Vec { + space.iter().zip(point).map(|(ps, c)| Scalar::from_cell(ps.kind, *c)).collect() +} +``` + +with: + +```rust +/// A family point's tag-free cells lifted back to self-describing scalars in +/// `space` slot order — the survivor-point form the walk-forward stage +/// re-sweeps through a `ListSpace`. +fn scalar_point(space: &[ParamSpec], point: &[Cell]) -> Vec { + space.iter().zip(point).map(|(ps, c)| Scalar::from_cell(ps.kind, *c)).collect() +} + +/// The pooled walk-forward OOS trade-R series: the family reports' `trade_rs` +/// concatenated in report order — which IS roll order (the wf stage builds its +/// family via `walkforward_member_reports`, per-window OOS reports in roll +/// order). Reports without an R block contribute nothing. +fn pooled_trade_rs(reports: &[RunReport]) -> Vec { + let mut pooled = Vec::new(); + for report in reports { + if let Some(r) = &report.metrics.r { + pooled.extend_from_slice(&r.trade_rs); + } + } + pooled +} +``` + +- [ ] **Step 5: split the unreachable arm into the mc execution and the generalize no-op** + +In `crates/aura-campaign/src/exec.rs` (`run_cell`'s stage loop), replace: + +```rust + StageBlock::MonteCarlo { .. } | StageBlock::Generalize { .. } => { + unreachable!("preflight refuses non-v1 stages before any member runs") + } +``` + +with: + +```rust + StageBlock::MonteCarlo { resamples, block_len } => { + // Terminal annotator (#200 d1/d3): nothing flows out. Dual + // input: after a walk_forward, ONE bootstrap over the pooled + // per-window OOS trade-R series (roll order); after + // sweep/gates, one bootstrap per surviving member. Seeded from + // the campaign seed (the deflation convention, C1). + let bootstrap = + match families.iter().rev().find(|f| f.block == "std::walk_forward") { + Some(fam) => StageBootstrap::PooledOos(r_bootstrap( + &pooled_trade_rs(&fam.reports), + *resamples as usize, + *block_len as usize, + seed, + )), + // Zero-trade members (r: None) feed an empty slice — + // the engine's defined all-zero degenerate, recorded + // explicitly (a null result is a valid result). + None => StageBootstrap::PerSurvivor( + survivors + .iter() + .map(|(ordinal, _, report)| { + let rs = report + .metrics + .r + .as_ref() + .map(|r| r.trade_rs.as_slice()) + .unwrap_or(&[]); + ( + *ordinal, + r_bootstrap( + rs, + *resamples as usize, + *block_len as usize, + seed, + ), + ) + }) + .collect(), + ), + }; + stages.push(StageRealization { + block: "std::monte_carlo".to_string(), + family_id: None, + survivor_ordinals: None, + selection: None, + bootstrap: Some(bootstrap), + }); + } + StageBlock::Generalize { .. } => { + // Campaign-scope stage (#200 d2): `execute` runs it AFTER the + // cell loop, over the per-cell nominees across instruments — + // nothing realizes per cell. + } +``` + +- [ ] **Step 6: Run — confirm GREEN** + +Run: `cargo test -p aura-campaign execute_mc` +Expected: `3 passed; 0 failed` in the `execute` test target (other targets filter to 0 tests). + +- [ ] **Step 7: Run — full package suite** + +Run: `cargo test -p aura-campaign` +Expected: all tests pass, `0 failed` in every target (the filled `trade_rs` changes no pinned value: it is serde-skipped and `PartialEq`-excluded, and no aura-campaign test pins `deflated_score`/`overfit_probability` numerics). + +- [ ] **Step 8: Run — workspace build** + +Run: `cargo build --workspace` +Expected: builds cleanly, no errors, no new warnings. + +### Task 4: The campaign-scope generalize phase (`CellOutcome.nominee` + `CampaignRunRecord.generalizations`) + +Depends on Tasks 1-3 (`CampaignGeneralization` + the `generalizations: Vec::new()` placeholder in the record literal from Task 1; `wf_stage()`/`planted_trade_rs()` test helpers from Task 3). Line references are anchors — match on the quoted bytes. + +**Files:** +- Modify: `crates/aura-campaign/src/exec.rs` (registry import block; `CellOutcome` struct; `run_cell` nominee population at four sites; `execute`'s cell loop + a post-loop generalize phase replacing the record literal's placeholder) +- Test: `crates/aura-campaign/tests/execute.rs` (imports; a `generalize_stage` constructor; three new `execute_generalize_*` tests) + +- [ ] **Step 1: RED — add the three generalize tests** + +In `crates/aura-campaign/tests/execute.rs`, replace: + +```rust +use aura_registry::{FamilyKind, Registry, StageBootstrap}; +``` + +with: + +```rust +use aura_registry::{generalization, FamilyKind, Registry, StageBootstrap}; +``` + +In the same file, replace: + +```rust +fn wf_stage() -> StageBlock { + StageBlock::WalkForward { + in_sample_ms: 4_000, + out_of_sample_ms: 2_000, + step_ms: 2_000, + mode: WfMode::Rolling, + metric: "net_expectancy_r".to_string(), + select: SelectRule::Argmax, + } +} +``` + +with: + +```rust +fn wf_stage() -> StageBlock { + StageBlock::WalkForward { + in_sample_ms: 4_000, + out_of_sample_ms: 2_000, + step_ms: 2_000, + mode: WfMode::Rolling, + metric: "net_expectancy_r".to_string(), + select: SelectRule::Argmax, + } +} + +fn generalize_stage() -> StageBlock { + StageBlock::Generalize { metric: "net_expectancy_r".to_string() } +} +``` + +In the same file, append at the very end (after the closing brace of `execute_mc_zero_trade_member_records_degenerate`): + +```rust +#[test] +fn execute_generalize_across_two_instruments() { + let reg = temp_registry("generalize_two"); + // the fake negates "AAA" nets -> divergent winners across instruments + let doc = campaign(&["AAA", "BBB"]); // seed: 7 + let proc_doc = process(vec![sweep_stage(false), wf_stage(), generalize_stage()]); + let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®) + .expect("generalize campaign executes"); + + assert_eq!(out.record.generalizations.len(), 1); + let g = &out.record.generalizations[0]; + assert_eq!((g.strategy_ordinal, g.window_ordinal), (0, 0)); + assert!(g.missing.is_empty()); + + // winners carry each instrument's nominated params (the last wf window's + // chosen point): AAA argmaxes its NEGATED nets -> (2,6); BBB -> (3,9) + assert_eq!( + g.winners, + vec![ + ( + "AAA".to_string(), + vec![("fast".to_string(), Scalar::i64(2)), ("slow".to_string(), Scalar::i64(6))], + ), + ( + "BBB".to_string(), + vec![("fast".to_string(), Scalar::i64(3)), ("slow".to_string(), Scalar::i64(9))], + ), + ], + ); + + // the stored grade == the shipped generalization() hand-called over the + // two nominated reports (cells are in instrument order: AAA then BBB) + let pairs: Vec<(String, &RunReport)> = out + .cells + .iter() + .zip(["AAA", "BBB"]) + .map(|(cell, inst)| { + let (_, report) = cell.nominee.as_ref().expect("nominee present"); + (inst.to_string(), report) + }) + .collect(); + let expected = generalization(&pairs, "net_expectancy_r").expect("hand-called generalization"); + assert_eq!(g.generalization.as_ref(), Some(&expected)); + + // divergence is exposed, never averaged: worst case is AAA's negated net + let aaa_net = -((2 * 10 + 6) as f64 / 100.0); + let bbb_net = (3 * 10 + 9) as f64 / 100.0; + assert_eq!( + expected.per_instrument, + vec![("AAA".to_string(), aaa_net), ("BBB".to_string(), bbb_net)], + ); + assert_eq!(expected.worst_case, aaa_net); + assert_eq!(expected.sign_agreement, 1); + + // the record round-trips through the store with the generalization intact + let runs = reg.load_campaign_runs().expect("load campaign runs"); + assert_eq!(runs[0], out.record); +} + +#[test] +fn execute_generalize_shortfall_records_missing() { + let reg = temp_registry("generalize_shortfall"); + // AAA's planted nets are all negative -> the gt-0 gate empties its cell + let doc = campaign(&["AAA", "BBB"]); + let proc_doc = process(vec![sweep_stage(false), gate_stage(0.0), generalize_stage()]); + let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®) + .expect("a truncated cell is a recorded shortfall, not a fault"); + + assert!(out.cells[0].nominee.is_none(), "gate-truncated cell nominates nothing"); + assert_eq!(out.record.generalizations.len(), 1); + let g = &out.record.generalizations[0]; + assert_eq!(g.generalization, None, "fewer than 2 winners -> no grade computed"); + assert_eq!(g.missing, vec!["AAA".to_string()]); + assert_eq!(g.winners.len(), 1); + assert_eq!(g.winners[0].0, "BBB"); + assert_eq!( + g.winners[0].1, + vec![("fast".to_string(), Scalar::i64(3)), ("slow".to_string(), Scalar::i64(9))], + ); +} + +#[test] +fn execute_generalize_only_after_sweep() { + let reg = temp_registry("generalize_sweep_only"); + let doc = campaign(&["EURUSD", "GER40"]); + let proc_doc = process(vec![sweep_stage(false), generalize_stage()]); + let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®) + .expect("sweep-only generalize campaign executes"); + + // no wf ran: each cell nominates its sweep winner (planted argmax (3,9)) + let winner_params = + vec![("fast".to_string(), Scalar::i64(3)), ("slow".to_string(), Scalar::i64(9))]; + for cell in &out.cells { + let (params, report) = cell.nominee.as_ref().expect("sweep winner nominated"); + assert_eq!(params, &winner_params); + assert_eq!(report.manifest.params, winner_params); + } + + let g = &out.record.generalizations[0]; + assert_eq!( + g.winners, + vec![ + ("EURUSD".to_string(), winner_params.clone()), + ("GER40".to_string(), winner_params.clone()), + ], + ); + assert!(g.missing.is_empty()); + let grade = g.generalization.as_ref().expect("two winners -> graded"); + let net = (3 * 10 + 9) as f64 / 100.0; + assert_eq!(grade.n_instruments, 2); + assert_eq!(grade.sign_agreement, 2); + assert_eq!(grade.worst_case, net); +} +``` + +- [ ] **Step 2: Run — confirm RED** + +Run: `cargo test -p aura-campaign execute_generalize` +Expected: the `execute` test target FAILS TO COMPILE with `error[E0609]: no field 'nominee'` on `CellOutcome` (the record's `generalizations` field already exists from Task 1; `nominee` does not exist yet). + +- [ ] **Step 3: import `generalization` and `CampaignGeneralization` in the executor** + +In `crates/aura-campaign/src/exec.rs`, replace: + +```rust +use aura_registry::{ + optimize, optimize_deflated, optimize_plateau, sweep_member_reports, + walkforward_member_reports, CampaignRunRecord, CellRealization, FamilyKind, PlateauMode, + Registry, StageBootstrap, StageRealization, StageSelection, +}; +``` + +with: + +```rust +use aura_registry::{ + generalization, optimize, optimize_deflated, optimize_plateau, sweep_member_reports, + walkforward_member_reports, CampaignGeneralization, CampaignRunRecord, CellRealization, + FamilyKind, PlateauMode, Registry, StageBootstrap, StageRealization, StageSelection, +}; +``` + +- [ ] **Step 4: widen `CellOutcome` with the nominee** + +In `crates/aura-campaign/src/exec.rs`, replace: + +```rust +/// Per-cell stage payloads, for the consumer's emit rendering. +#[derive(Clone, Debug)] +pub struct CellOutcome { + pub families: Vec, + pub selections: Vec, +} +``` + +with: + +```rust +/// Per-cell stage payloads, for the consumer's emit rendering. +#[derive(Clone, Debug)] +pub struct CellOutcome { + pub families: Vec, + pub selections: Vec, + /// The cell's nominated generalize candidate (spec-fixed): the LAST + /// walk-forward window's OOS report + chosen params when a wf stage ran + /// (the freshest out-of-sample evidence for the finally-selected params), + /// else the sweep stage's winner; `None` for gate-truncated cells. + pub nominee: Option<(Vec<(String, Scalar)>, RunReport)>, +} +``` + +- [ ] **Step 5: populate the nominee in `run_cell` (four sites)** + +In `crates/aura-campaign/src/exec.rs`, replace: + +```rust + // The surviving population: (ordinal into the nearest preceding + // family_id-bearing stage's family, param point, member report). + let mut survivors: Vec<(usize, Vec, RunReport)> = Vec::new(); +``` + +with: + +```rust + // The surviving population: (ordinal into the nearest preceding + // family_id-bearing stage's family, param point, member report). + let mut survivors: Vec<(usize, Vec, RunReport)> = Vec::new(); + // The cell's nominated generalize candidate: last wf window's OOS report + // when a walk_forward ran, else the sweep winner; None when truncated. + let mut nominee: Option<(Vec<(String, Scalar)>, RunReport)> = None; +``` + +In the same file (sweep arm), replace: + +```rust + selections.push(StageSelectionOut { + stage: stage_ordinal, + block: "std::sweep", + family_id: family_id.clone(), + winner_ordinal, + params: params.clone(), + selection: selection.clone(), + }); + stages.push(StageRealization { +``` + +with: + +```rust + selections.push(StageSelectionOut { + stage: stage_ordinal, + block: "std::sweep", + family_id: family_id.clone(), + winner_ordinal, + params: params.clone(), + selection: selection.clone(), + }); + // Nominate the sweep winner (superseded by a later + // walk_forward stage; cleared by an empty gate). + nominee = Some((params.clone(), winner.report.clone())); + stages.push(StageRealization { +``` + +In the same file (gate arm), replace: + +```rust + if empty { + break; // decision 8: truncate this cell, keep running cells + } +``` + +with: + +```rust + if empty { + nominee = None; // a truncated cell nominates no candidate + break; // decision 8: truncate this cell, keep running cells + } +``` + +In the same file (walk_forward arm), replace: + +```rust + families.push(fam); +``` + +with: + +```rust + // Nominate the LAST window's OOS report (roll order per + // walkforward_member_reports) with its chosen params — the wf + // stage stamps them on the fresh OOS report's manifest. + nominee = fam + .reports + .last() + .map(|last| (last.manifest.params.clone(), last.clone())); + families.push(fam); +``` + +In the same file (`run_cell`'s return), replace: + +```rust + Ok(( + CellOutcome { families, selections }, +``` + +with: + +```rust + Ok(( + CellOutcome { families, selections, nominee }, +``` + +- [ ] **Step 6: collect nominees in the cell loop and run the campaign-scope generalize phase** + +In `crates/aura-campaign/src/exec.rs` (`execute`), replace: + +```rust + let mut cells_out: Vec = Vec::new(); + let mut cells_rec: Vec = Vec::new(); +``` + +with: + +```rust + let mut cells_out: Vec = Vec::new(); + let mut cells_rec: Vec = Vec::new(); + // Per-(strategy_ordinal, window_ordinal) nominees across instruments, in + // cell-loop (instrument) order — the campaign-scope generalize input. + type Nominee = Option<(Vec<(String, Scalar)>, RunReport)>; + let mut nominees: BTreeMap<(usize, usize), Vec<(String, Nominee)>> = BTreeMap::new(); +``` + +In the same function, replace: + +```rust + cells_out.push(outcome); + cells_rec.push(realization); +``` + +with: + +```rust + nominees + .entry((strategy_ordinal, window_ordinal)) + .or_default() + .push((instrument.clone(), outcome.nominee.clone())); + cells_out.push(outcome); + cells_rec.push(realization); +``` + +In the same function, replace (the record literal as Task 1 left it): + +```rust + let mut record = CampaignRunRecord { + campaign: campaign_id.to_string(), + process: process_id, + run: 0, + seed: campaign.seed, + cells: cells_rec, + // Empty for now: the campaign-scope generalize phase (a later task of + // this cycle) computes and fills this after the cell loop. + generalizations: Vec::new(), + }; +``` + +with: + +```rust + // Campaign-scope generalize (#200 d2): for each (strategy, window), grade + // the per-instrument nominees via the shipped `generalization`; fewer than + // 2 winners is a recorded shortfall, never computed around. + let generalize_metric = process.pipeline.iter().find_map(|s| match s { + StageBlock::Generalize { metric } => Some(metric.clone()), + _ => None, + }); + let mut generalizations: Vec = Vec::new(); + if let Some(metric) = generalize_metric { + for ((strategy_ordinal, window_ordinal), cells) in &nominees { + let winners: Vec<(String, Vec<(String, Scalar)>)> = cells + .iter() + .filter_map(|(inst, nom)| { + nom.as_ref().map(|(params, _)| (inst.clone(), params.clone())) + }) + .collect(); + let missing: Vec = cells + .iter() + .filter(|(_, nom)| nom.is_none()) + .map(|(inst, _)| inst.clone()) + .collect(); + let pairs: Vec<(String, &RunReport)> = cells + .iter() + .filter_map(|(inst, nom)| nom.as_ref().map(|(_, report)| (inst.clone(), report))) + .collect(); + let grade = if pairs.len() >= 2 { + Some(generalization(&pairs, &metric).map_err(ExecFault::Registry)?) + } else { + None + }; + generalizations.push(CampaignGeneralization { + strategy_ordinal: *strategy_ordinal, + window_ordinal: *window_ordinal, + generalization: grade, + winners, + missing, + }); + } + } + + let mut record = CampaignRunRecord { + campaign: campaign_id.to_string(), + process: process_id, + run: 0, + seed: campaign.seed, + cells: cells_rec, + generalizations, + }; +``` + +- [ ] **Step 7: Run — confirm GREEN** + +Run: `cargo test -p aura-campaign execute_generalize` +Expected: `3 passed; 0 failed` in the `execute` test target (other targets filter to 0 tests). + +- [ ] **Step 8: Run — full package suite** + +Run: `cargo test -p aura-campaign` +Expected: all tests pass, `0 failed` in every target (pipelines without a generalize stage record `generalizations: []`, which serde-skips, so every pre-existing record equality and round-trip pin is untouched). + +- [ ] **Step 9: Run — workspace build** + +Run: `cargo build --workspace` +Expected: builds cleanly, no errors, no new warnings. +### Task 5: CLI seam flips + doc tidy + gated e2e extension + +**Files:** +- Modify: `crates/aura-cli/tests/research_docs.rs` — fixture prose + new process fixtures (`MC_PROCESS_DOC` :693-703, `register_process_doc` doc :646-648, `SWEEP_ONLY_PROCESS_DOC` doc :705); test rewrite (`campaign_run_v1_boundary_refuses_mc_process` :772-795 → two v2 seam tests); addressing-mode tests re-seamed (:917-963, :965-1010); gated e2e extension (`WF_PROCESS_DOC` :713-727, `campaign_run_real_e2e_sweep_gate_walkforward` :833-915) +- Modify: `crates/aura-research/src/lib.rs` — `PROCESS_BLOCKS` doc strings :191, :199 (introspection text only; `vocabularies_enumerate_every_block_with_typed_slots` :1348 asserts only non-empty doc + slots, so no pin moves) +- Test: all pins live in `crates/aura-cli/tests/research_docs.rs` (the tests this task rewrites/extends) + +All `Run` commands execute from the repo root `/home/brummel/dev/aura`. Line anchors refer to the tree before this task's own edits (Tasks 1-4 do not touch these two files). + +- [ ] **Step 1: Observe the stale v1 pin failing (RED evidence for the flip)** + +Run: +```bash +cargo test -p aura-cli --test research_docs campaign_run_v1_boundary_refuses_mc_process +``` +Expected: 1 failed — the assertion `out.contains("not executable in v1")` no longer holds: since Task 2's preflight v2, an mc-bearing process passes the shape check and the run proceeds to the member-data seam (exit 1 with data prose, not the retired v1 prose). This is the stale pin this task replaces. + +- [ ] **Step 2: Retire the stale v1 fixture prose; add the two new process fixtures** + +In `crates/aura-cli/tests/research_docs.rs`, replace (old): +```rust +/// Register `doc` as a process document in the project store; returns its id. +/// Asserts register exits 0 — an intrinsically valid document (an mc-bearing +/// one included) must always register; only `campaign run` draws the v1 line. +``` +with (new): +```rust +/// Register `doc` as a process document in the project store; returns its id. +/// Asserts register exits 0 — an intrinsically valid document (annotator +/// stages in any order included) must always register; only `campaign run`'s +/// preflight draws the executable-shape line. +``` + +Then replace (old): +```rust +/// An mc-bearing process: intrinsically VALID (register accepts it) but past +/// the executable v1 boundary (`campaign run` refuses it at preflight). +const MC_PROCESS_DOC: &str = r#"{ + "format_version": 1, + "kind": "process", + "name": "mc-screen", + "pipeline": [ + { "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" }, + { "block": "std::monte_carlo", "resamples": 100, "block_len": 5 } + ] +}"#; + +/// The minimal executable v1 pipeline (one sweep stage). +``` +with (new): +```rust +/// An mc-bearing process: intrinsically valid AND (since the v2 executor) an +/// executable pipeline shape — `sweep -> monte_carlo` annotates each sweep +/// survivor. The two addressing-mode tests below run it over the [1, 2] 1970 +/// window, so they refuse deterministically at the member-data seam. +const MC_PROCESS_DOC: &str = r#"{ + "format_version": 1, + "kind": "process", + "name": "mc-screen", + "pipeline": [ + { "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" }, + { "block": "std::monte_carlo", "resamples": 100, "block_len": 5 } + ] +}"#; + +/// Intrinsically valid but NOT an executable v2 shape: the annotator sits +/// before the population stage (`walk_forward` after `monte_carlo`). The +/// intrinsic tier deliberately permits this order (the shipped +/// `validate_process_permits_walk_forward_after_an_annotator` pin in +/// aura-research), so register accepts it; only `campaign run`'s preflight +/// refuses it. +const MC_BEFORE_WF_PROCESS_DOC: &str = r#"{ + "format_version": 1, + "kind": "process", + "name": "mc-before-wf", + "pipeline": [ + { "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" }, + { "block": "std::monte_carlo", "resamples": 100, "block_len": 5 }, + { "block": "std::walk_forward", "in_sample_ms": 1209600000, "out_of_sample_ms": 604800000, + "step_ms": 604800000, "mode": "rolling", "metric": "sqn_normalized", "select": "argmax" } + ] +}"#; + +/// An executable generalize-bearing shape (`sweep -> generalize`). The +/// refusal under test is the campaign-side static guard (std::generalize +/// needs >= 2 instruments), not the pipeline shape. +const GENERALIZE_PROCESS_DOC: &str = r#"{ + "format_version": 1, + "kind": "process", + "name": "gen-screen", + "pipeline": [ + { "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" }, + { "block": "std::generalize", "metric": "net_expectancy_r" } + ] +}"#; + +/// The minimal executable pipeline (one sweep stage). +``` + +- [ ] **Step 3: Replace the v1-boundary test with the two v2 refusal seam tests** + +In `crates/aura-cli/tests/research_docs.rs`, replace (old): +```rust +/// The v1 boundary: `process register` ACCEPTS an mc-bearing document (it is +/// intrinsically valid — asserted inside `register_process_doc`); only +/// `campaign run` refuses it, at preflight, before any member runs (so no +/// data is needed), with path-addressed Debug-free prose. +#[test] +fn campaign_run_v1_boundary_refuses_mc_process() { + 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("mc.process.json")), + ScratchPath::File(dir.join("mc.campaign.json")), + ]); + let bp_id = seed_blueprint(dir, "campaign-run-mc-seed"); + let proc_id = register_process_doc(dir, "mc.process.json", MC_PROCESS_DOC); + write_doc(dir, "mc.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", "")); + let (out, code) = run_code_in(dir, &["campaign", "run", "mc.campaign.json"]); + assert_eq!(code, Some(1), "stdout/stderr: {out}"); + assert!(out.contains("not executable in v1"), "stdout/stderr: {out}"); + assert!(out.contains("std::monte_carlo"), "the prose names the block: {out}"); + assert!(!out.contains("UnsupportedStage"), "Debug leak: {out}"); +} +``` +with (new): +```rust +/// The v2 boundary, order side: `[sweep, monte_carlo, walk_forward]` is +/// intrinsically valid — `process register` ACCEPTS it (asserted inside +/// `register_process_doc`; the intrinsic-tier ground is the shipped +/// `validate_process_permits_walk_forward_after_an_annotator` pin) — but the +/// executable pipeline shape fixes the suffix order (`std::sweep [std::gate]* +/// [std::walk_forward]? [std::monte_carlo]? [std::generalize]?`), so +/// `campaign run` refuses it at the data-free preflight, before any member +/// runs, with Debug-free PipelineShape prose. +#[test] +fn campaign_run_refuses_mc_before_walk_forward() { + 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("mcwf.process.json")), + ScratchPath::File(dir.join("mcwf.campaign.json")), + ]); + let bp_id = seed_blueprint(dir, "campaign-run-mcwf-seed"); + let proc_id = register_process_doc(dir, "mcwf.process.json", MC_BEFORE_WF_PROCESS_DOC); + write_doc(dir, "mcwf.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", "")); + let (out, code) = run_code_in(dir, &["campaign", "run", "mcwf.campaign.json"]); + assert_eq!(code, Some(1), "stdout/stderr: {out}"); + assert!(out.contains("process pipeline is not executable:"), "stdout/stderr: {out}"); + assert!( + out.contains("std::walk_forward cannot follow std::monte_carlo"), + "the detail names the ordering violation: {out}" + ); + assert!(!out.contains("not executable in v1"), "retired v1 prose resurfaced: {out}"); + assert!(!out.contains("PipelineShape"), "Debug leak: {out}"); +} + +/// The v2 boundary, campaign side: a generalize-bearing process is an +/// executable shape, but `std::generalize` needs >= 2 instruments in the +/// campaign — a STATIC preflight fact (the referential gate has run, no data +/// is read), so the single-GER40 fixture campaign refuses with exit 1 before +/// any member runs. This is the ONE home for the single-instrument refusal; +/// the gated real-data e2e does not duplicate it (its campaign is also +/// single-instrument, and this refusal is data-free by construction). +#[test] +fn campaign_run_refuses_single_instrument_generalize() { + 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("gen1.process.json")), + ScratchPath::File(dir.join("gen1.campaign.json")), + ]); + let bp_id = seed_blueprint(dir, "campaign-run-gen1-seed"); + let proc_id = register_process_doc(dir, "gen1.process.json", GENERALIZE_PROCESS_DOC); + write_doc(dir, "gen1.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", "")); + let (out, code) = run_code_in(dir, &["campaign", "run", "gen1.campaign.json"]); + assert_eq!(code, Some(1), "stdout/stderr: {out}"); + assert!( + out.contains("std::generalize needs at least 2"), + "the refusal names the block and the minimum: {out}" + ); + assert!(out.contains("1 instrument"), "the refusal names the available count: {out}"); + assert!(!out.contains("GeneralizeNeedsInstruments"), "Debug leak: {out}"); +} +``` + +- [ ] **Step 4: Verify the pinned prose against the tree's actual fault strings, then run the two seam tests** + +Run: +```bash +grep -n "PipelineShape" crates/aura-campaign/src/lib.rs +``` +Expected: the v2 preflight's shape-violation arms (landed by Task 2). Read the arm that fires for a `walk_forward` following a `monte_carlo`: its `detail` must contain the byte-string asserted in Step 3, `std::walk_forward cannot follow std::monte_carlo`. If Task 2's landed detail wording differs, update ONLY the Step-3 assertion string to the executor's actual bytes (the test pins the executor's exact prose; do not change the executor). + +Run: +```bash +grep -n "needs at least 2" crates/aura-cli/src/campaign_run.rs +``` +Expected: one hit inside `exec_fault_prose`'s `GeneralizeNeedsInstruments` arm (house prose per the spec: `campaign has {available} instrument(s); std::generalize needs at least 2`). If absent or worded differently, align the two Step-3 substring assertions (`std::generalize needs at least 2`, `1 instrument`) to the landed prose the same way. + +Run: +```bash +cargo test -p aura-cli --test research_docs campaign_run_refuses +``` +Expected: `2 passed` (exactly the two new tests match this filter), 0 failed. + +- [ ] **Step 5: Point the two addressing-mode tests at the member-data seam (comments name what actually refuses now)** + +In `crates/aura-cli/tests/research_docs.rs`, replace (old): +```rust +/// independently drifting code paths. No real data needed: both runs refuse +/// at the (data-free) v1-pipeline-shape preflight. +``` +with (new): +```rust +/// independently drifting code paths. No real data needed: with the [1, 2] +/// 1970 window, both runs pass the (now mc-permitting) preflight and refuse +/// at the member-data seam — no recorded geometry on a data-less host, no +/// overlapping archive window on a data-ful one — exit 1 and byte-identical +/// prose either way (the message depends only on env/instrument/window). +``` + +Then replace (old): +```rust + let file_line = file_out.lines().find(|l| l.starts_with("aura: ")).expect("file refusal line"); + let id_line = id_out.lines().find(|l| l.starts_with("aura: ")).expect("id refusal line"); + assert_eq!(file_line, id_line, "file- and id-addressed runs must refuse identically"); +``` +with (new): +```rust + let file_line = file_out.lines().find(|l| l.starts_with("aura: ")).expect("file refusal line"); + let id_line = id_out.lines().find(|l| l.starts_with("aura: ")).expect("id refusal line"); + assert!( + file_line.contains("no recorded geometry") || file_line.contains("no data for instrument"), + "the refusal is the member-data seam (mc processes execute now): {file_line}" + ); + assert_eq!(file_line, id_line, "file- and id-addressed runs must refuse identically"); +``` + +Then replace (old): +```rust +/// stripped, not read as a second, unresolvable address). Both refuse at the +/// data-free v1 preflight (MC process), so no real data is needed. +``` +with (new): +```rust +/// stripped, not read as a second, unresolvable address). Both refuse at the +/// member-data seam (the mc process is executable in v2; the [1, 2] 1970 +/// window guarantees a deterministic data refusal), so no real data is +/// needed. +``` + +Then replace (old): +```rust + let bare_line = bare_out.lines().find(|l| l.starts_with("aura: ")).expect("bare refusal line"); + assert_eq!(bare_line, pfx_line, "bare-id and content:-prefixed runs must resolve identically"); +``` +with (new): +```rust + let bare_line = bare_out.lines().find(|l| l.starts_with("aura: ")).expect("bare refusal line"); + assert!( + bare_line.contains("no recorded geometry") || bare_line.contains("no data for instrument"), + "the refusal is the member-data seam (mc processes execute now): {bare_line}" + ); + assert_eq!(bare_line, pfx_line, "bare-id and content:-prefixed runs must resolve identically"); +``` + +Run: +```bash +cargo test -p aura-cli --test research_docs campaign_run_by_content_id_matches_file_sugar_refusal +``` +Expected: 1 passed. + +Run: +```bash +cargo test -p aura-cli --test research_docs campaign_run_tolerates_content_prefix_on_target +``` +Expected: 1 passed. + +- [ ] **Step 6: Extend the gated e2e with the mc annotator suffix** + +In `crates/aura-cli/tests/research_docs.rs`, replace (old): +```rust +/// The full v1 shape for the gated e2e: sweep -> gate -> walk_forward. The +/// gate (`n_trades ge 0`) passes every member, so walk-forward always has +/// survivors. Roller: 14d IS / 7d OOS / 7d step in epoch-ms, tiling the +/// ~30-day GER40 Sept-2024 campaign window. +const WF_PROCESS_DOC: &str = r#"{ + "format_version": 1, + "kind": "process", + "name": "screen-then-walkforward", + "pipeline": [ + { "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" }, + { "block": "std::gate", "all": [ { "metric": "n_trades", "cmp": "ge", "value": 0.0 } ] }, + { "block": "std::walk_forward", "in_sample_ms": 1209600000, "out_of_sample_ms": 604800000, + "step_ms": 604800000, "mode": "rolling", "metric": "net_expectancy_r", "select": "argmax" } + ] +}"#; +``` +with (new): +```rust +/// The full annotated shape for the gated e2e: sweep -> gate -> walk_forward +/// -> monte_carlo. The gate (`n_trades ge 0`) passes every member, so +/// walk-forward always has survivors; the mc annotator bootstraps the pooled +/// per-window OOS trade-R series (200 resamples, block 5). Roller: 14d IS / +/// 7d OOS / 7d step in epoch-ms, tiling the ~30-day GER40 Sept-2024 campaign +/// window. NO generalize stage: this campaign has ONE instrument (a second +/// local archive is not guaranteed), and the single-instrument refusal is +/// pinned data-free in `campaign_run_refuses_single_instrument_generalize`. +const WF_PROCESS_DOC: &str = r#"{ + "format_version": 1, + "kind": "process", + "name": "screen-then-walkforward", + "pipeline": [ + { "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" }, + { "block": "std::gate", "all": [ { "metric": "n_trades", "cmp": "ge", "value": 0.0 } ] }, + { "block": "std::walk_forward", "in_sample_ms": 1209600000, "out_of_sample_ms": 604800000, + "step_ms": 604800000, "mode": "rolling", "metric": "net_expectancy_r", "select": "argmax" }, + { "block": "std::monte_carlo", "resamples": 200, "block_len": 5 } + ] +}"#; +``` + +Then replace (old): +```rust +/// Gated real-data e2e (the `cli_run.rs` skip idiom): a full +/// sweep -> gate -> walk_forward campaign over GER40 Sept-2024 where the +/// local archive is present — exit 0, emit-gated family/selection lines, a +/// final line parseable as JSON with top-level `campaign_run` linking a sweep +/// family id and a walk-forward family id, and the `campaign_runs.jsonl` +/// sibling store written. Skips with a note elsewhere so +/// `cargo test --workspace` stays green on a data-less machine. +``` +with (new): +```rust +/// Gated real-data e2e (the `cli_run.rs` skip idiom): a full +/// sweep -> gate -> walk_forward -> monte_carlo campaign over GER40 +/// Sept-2024 where the local archive is present — exit 0, emit-gated +/// family/selection lines, a final line parseable as JSON with top-level +/// `campaign_run` linking a sweep family id, a walk-forward family id, and a +/// pooled-OOS bootstrap on the terminal mc stage, and the +/// `campaign_runs.jsonl` sibling store written. Skips with a note elsewhere +/// so `cargo test --workspace` stays green on a data-less machine. +``` + +Then replace (old): +```rust + let stages = cells[0]["stages"].as_array().expect("stages array"); + assert_eq!(stages.len(), 3, "sweep + gate + walk_forward realized: {record_line}"); +``` +with (new): +```rust + let stages = cells[0]["stages"].as_array().expect("stages array"); + assert_eq!( + stages.len(), + 4, + "sweep + gate + walk_forward + monte_carlo realized: {record_line}" + ); +``` + +Then replace (old): +```rust + assert!( + stages[2]["family_id"].as_str().is_some(), + "walk-forward stage links a family: {record_line}" + ); +``` +with (new): +```rust + assert!( + stages[2]["family_id"].as_str().is_some(), + "walk-forward stage links a family: {record_line}" + ); + assert_eq!( + stages[3]["block"].as_str(), + Some("std::monte_carlo"), + "the annotator stage is realized last: {record_line}" + ); + let pooled = &stages[3]["bootstrap"]["pooled_oos"]; + assert!( + pooled.is_object(), + "an mc stage after walk_forward records ONE pooled-OOS bootstrap: {record_line}" + ); + assert_eq!( + pooled["n_resamples"].as_u64(), + Some(200), + "the bootstrap carries the doc's resamples verbatim: {record_line}" + ); +``` + +Run: +```bash +cargo test -p aura-cli --test research_docs campaign_run_real_e2e_sweep_gate_walkforward +``` +Expected: 1 passed — on this machine the local GER40 archive is present, so the full assert path runs (4 stages, `std::monte_carlo` last, `pooled_oos` object with `n_resamples` 200). On a data-less host the test prints `skip: no local GER40 data for the campaign e2e` and still passes. + +- [ ] **Step 7: aura-research doc-string tidy — "(terminal annotator in v1)" loses the version tag** + +In `crates/aura-research/src/lib.rs`, replace (old): +```rust + doc: "R-bootstrap over realised R (terminal annotator in v1)", +``` +with (new): +```rust + doc: "R-bootstrap over realised R (terminal annotator)", +``` + +Then replace (old): +```rust + doc: "cross-instrument worst-case floor (terminal annotator in v1)", +``` +with (new): +```rust + doc: "cross-instrument worst-case floor (terminal annotator)", +``` + +Run: +```bash +grep -rn "terminal annotator in v1" crates +``` +Expected: no output, exit 1 (the string survives nowhere; no test pins the parenthetical — the describe test asserts slots + non-empty doc only). + +Run: +```bash +cargo test -p aura-research +``` +Expected: all tests pass, 0 failed (introspection TEXT only — no document bytes, no content-id movement). + +- [ ] **Step 8: End gates** + +Run: +```bash +cargo test -p aura-cli campaign_run +``` +Expected: 0 failed in every test binary. The `research_docs` binary runs the 11 `campaign_run_*` integration tests (all pass; the gated e2e may print the skip note on a data-less host and still passes); the module-path match also runs the `campaign_run::tests::*` unit tests in the `aura` binary — all pass. + +Run: +```bash +cargo test --workspace +``` +Expected: green — 0 failed across all crates. + +Run: +```bash +cargo clippy --workspace --all-targets -- -D warnings +``` +Expected: clean, exit 0.