diff --git a/docs/plans/0107-campaign-executor.md b/docs/plans/0107-campaign-executor.md new file mode 100644 index 0000000..f793654 --- /dev/null +++ b/docs/plans/0107-campaign-executor.md @@ -0,0 +1,5261 @@ +# Campaign Executor — Implementation Plan (cycle 0107) + +> **Parent spec:** `docs/specs/0107-campaign-executor.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 a persisted campaign's +process pipeline (v1: `std::sweep [std::gate]* [std::walk_forward]?`) per +(strategy, instrument, window) cell over the existing family machinery, records +a campaign-level realization, and honours data-level presentation — semantics in +the new `aura-campaign` library crate, harness/data binding behind the +`MemberRunner` seam implemented by the CLI. + +**Architecture:** Task 1 corrects the `std::walk_forward` document vocabulary to +machinery-true fields (aura-research + aura-cli twins, workspace-green at task +end). Tasks 2-3 add the two substrate pieces (engine `ListSpace`, registry +`CampaignRunRecord` store + visibility promotions). Tasks 4-7 build +`aura-campaign` (scaffold + types + `member_metric`, preflight, `execute` with +sweep/gate stages, then the walk-forward stage replacing a Task-6 stub). Tasks +8-9 wire the CLI verb + `MemberRunner` driver and its seam tests + gated +real-data e2e. Task 10 lands the #196 blueprint on-ramp verbs. + +**Tech Stack:** Rust workspace — crates `aura-research`, `aura-engine`, +`aura-registry`, `aura-campaign` (new), `aura-cli`; serde/serde_json; clap 4. + +**Task-order constraints:** strictly sequential 1→10. Task 5 depends on Task 1 +(`WfMode`, corrected `WalkForward` fields); Task 6 on Tasks 2/3/4/5; Task 7 +replaces the Task-6 stub body byte-exactly; Task 8 on Tasks 3/6/7; Task 9 on +Task 8; Task 10 on Task 3 (pub `blueprint_path`, applied defensively). + +--- +**Files this plan creates or modifies:** + +- Create: `crates/aura-campaign/Cargo.toml` — new leaf library crate manifest +- Create: `crates/aura-campaign/src/lib.rs` — types, `member_metric`, preflight +- Create: `crates/aura-campaign/src/exec.rs` — `execute`, stages, realization assembly +- Create: `crates/aura-campaign/tests/execute.rs` — fake-runner integration tests +- Create: `crates/aura-cli/src/campaign_run.rs` — verb flow + `MemberRunner` driver + prose +- Modify: `Cargo.toml` — workspace members + `crates/aura-campaign` +- Modify: `crates/aura-research/src/lib.rs` — `std::walk_forward` correction (variant, `WfMode`, tables, parser, validator, faults, fixtures, golden pin) +- Modify: `crates/aura-engine/src/sweep.rs` + `src/lib.rs` — `ListSpace` + re-export +- Modify: `crates/aura-registry/src/lineage.rs` + `src/lib.rs` — campaign-run records/store, pub `blueprint_path`, pub `find_blueprint_by_identity`, re-exports +- Modify: `crates/aura-cli/Cargo.toml` — dep on `aura-campaign` +- Modify: `crates/aura-cli/src/main.rs` — `mod campaign_run;` + `GraphSub::Register` + introspect `--params` / `--content-id [FILE]` +- Modify: `crates/aura-cli/src/research_docs.rs` — `CampaignSub::Run`, `doc_fault_prose` arm, pub(crate) promotions +- Modify: `crates/aura-cli/src/graph_construct.rs` — register/params/content-id-file modes +- Test: `crates/aura-cli/tests/research_docs.rs` — fixture twin, campaign-run seam tests, gated e2e +- Test: `crates/aura-cli/tests/graph_construct.rs` (or the existing graph-test home) — on-ramp seam tests + +--- +### Task 1: std::walk_forward vocabulary correction (machinery-true fields) + +The shipped `std::walk_forward` stage vocabulary (`folds`, `in_sample_bars`, `out_of_sample_bars`) maps to nothing the engine's `WindowRoller::new(span, is_len, oos_len, step, mode)` accepts. Correct it to the machinery-true fields — three lengths in epoch-ms plus the roller's mode — with a new `WfMode` enum, a new `SlotKind::WfMode`, a new `DocFault::ZeroWalkForwardLength` intrinsic check, the co-moving golden canonical pin, and the aura-cli prose/fixture twins. Do NOT touch anything under `fieldtests/` (historical corpus) or `docs/glossary.md`. + +**Files:** +- Modify: `crates/aura-research/src/lib.rs` (StageBlock variant :57-64, SelectRule :75-84 [WfMode goes after it], SlotKind :115-131, PROCESS_BLOCKS :163-173, select_from :239-249 [mode parser goes after it], stage_from_value arm :282-288, DocFault :558-576, validate_process :590-597, slot_kind_label :703-717, PROCESS_FIXTURE :877-890, tests :892-914, golden pin :1038-1063, vocabulary test :1184-1202) +- Modify: `crates/aura-cli/src/research_docs.rs` (doc_fault_prose :87-115, tests mod :368-404) +- Modify: `crates/aura-cli/tests/research_docs.rs` (PROCESS_DOC fixture :37-49, introspect e2e :81-99, new zero-length e2e after :79) + +- [ ] **Step 1: Move the aura-research `PROCESS_FIXTURE` walk_forward stage to the corrected vocabulary (RED setup)** + +In `crates/aura-research/src/lib.rs` (inside the `tests` module, `PROCESS_FIXTURE`, lines 887-888), replace (old): + +```rust + { "block": "std::walk_forward", "folds": 4, "in_sample_bars": 4000, + "out_of_sample_bars": 1000, "metric": "net_expectancy_r", "select": "argmax" } +``` + +with (new): + +```rust + { "block": "std::walk_forward", "in_sample_ms": 4000, "out_of_sample_ms": 1000, + "step_ms": 1000, "mode": "rolling", "metric": "net_expectancy_r", "select": "argmax" } +``` + +- [ ] **Step 2: Run the RED evidence — the fixture no longer parses against the old schema** + +Run: `cargo test -p aura-research process_fixture_parses_to_typed_stages` +Expected: FAILED — 1 test fails with a panic whose message contains `fixture parses` and `unknown slot "in_sample_ms"` (the corrected fixture hits the strict unknown-slot rejection of the still-old schema table). This is the RED pin for the whole schema correction. + +- [ ] **Step 3: Rewrite the `StageBlock::WalkForward` variant and add the `WfMode` enum** + +In `crates/aura-research/src/lib.rs` (lines 57-64), replace (old): + +```rust + #[serde(rename = "std::walk_forward")] + WalkForward { + folds: u32, + in_sample_bars: u64, + out_of_sample_bars: u64, + metric: String, + select: SelectRule, + }, +``` + +with (new — canonical field order is declaration order): + +```rust + #[serde(rename = "std::walk_forward")] + WalkForward { + in_sample_ms: u64, + out_of_sample_ms: u64, + step_ms: u64, + mode: WfMode, + metric: String, + select: SelectRule, + }, +``` + +Then, directly below the `SelectRule` enum (lines 75-84), replace (old): + +```rust +/// Winner-selection rule; wire strings mirror the CLI `--select` values. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum SelectRule { + #[serde(rename = "argmax")] + Argmax, + #[serde(rename = "plateau:mean")] + PlateauMean, + #[serde(rename = "plateau:worst")] + PlateauWorst, +} +``` + +with (new — same enum, plus `WfMode` after it): + +```rust +/// Winner-selection rule; wire strings mirror the CLI `--select` values. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum SelectRule { + #[serde(rename = "argmax")] + Argmax, + #[serde(rename = "plateau:mean")] + PlateauMean, + #[serde(rename = "plateau:worst")] + PlateauWorst, +} + +/// Walk-forward roll mode — wire form "rolling" | "anchored" (the two shipped +/// engine `RollMode`s: fixed-length rolling in-sample vs anchored-origin +/// growing in-sample). +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub enum WfMode { + #[serde(rename = "rolling")] + Rolling, + #[serde(rename = "anchored")] + Anchored, +} +``` + +- [ ] **Step 4: Add `SlotKind::WfMode` and its `slot_kind_label` arm** + +In `crates/aura-research/src/lib.rs` (SlotKind enum, lines 115-118), replace (old): + +```rust +pub enum SlotKind { + MetricName, + SelectRule, + Bool, +``` + +with (new): + +```rust +pub enum SlotKind { + MetricName, + SelectRule, + /// Walk-forward roll mode (`WfMode` wire strings). + WfMode, + Bool, +``` + +Then in `slot_kind_label` (lines 706-707), replace (old): + +```rust + SlotKind::SelectRule => "select rule: argmax | plateau:mean | plateau:worst", + SlotKind::Bool => "bool", +``` + +with (new): + +```rust + SlotKind::SelectRule => "select rule: argmax | plateau:mean | plateau:worst", + SlotKind::WfMode => "one of: rolling | anchored", + SlotKind::Bool => "bool", +``` + +- [ ] **Step 5: Rewrite the `PROCESS_BLOCKS` std::walk_forward slot table** + +In `crates/aura-research/src/lib.rs` (lines 163-173), replace (old): + +```rust + BlockSchema { + id: "std::walk_forward", + doc: "rolling in-sample optimize + out-of-sample test", + slots: &[ + SlotInfo { name: "folds", kind: SlotKind::U32, required: true }, + SlotInfo { name: "in_sample_bars", kind: SlotKind::U64, required: true }, + SlotInfo { name: "out_of_sample_bars", kind: SlotKind::U64, required: true }, + SlotInfo { name: "metric", kind: SlotKind::MetricName, required: true }, + SlotInfo { name: "select", kind: SlotKind::SelectRule, required: true }, + ], + }, +``` + +with (new — slot order mirrors the variant's field order): + +```rust + BlockSchema { + id: "std::walk_forward", + doc: "rolling in-sample optimize + out-of-sample test", + slots: &[ + SlotInfo { name: "in_sample_ms", kind: SlotKind::U64, required: true }, + SlotInfo { name: "out_of_sample_ms", kind: SlotKind::U64, required: true }, + SlotInfo { name: "step_ms", kind: SlotKind::U64, required: true }, + SlotInfo { name: "mode", kind: SlotKind::WfMode, required: true }, + SlotInfo { name: "metric", kind: SlotKind::MetricName, required: true }, + SlotInfo { name: "select", kind: SlotKind::SelectRule, required: true }, + ], + }, +``` + +- [ ] **Step 6: Add the `mode_from` parser and rewrite the `stage_from_value` walk_forward arm** + +In `crates/aura-research/src/lib.rs`, directly after the `select_from` function (lines 239-249), replace (old): + +```rust +fn select_from( + m: &serde_json::Map, + block: &str, +) -> Result { + let s = require_str(m, "select", block)?; + // SelectRule's own derived Deserialize (which carries the #[serde(rename)] + // wire strings) is the type oracle, mirroring kind_tag/scalar_from_bare: + // no second hardcoded copy of the three select-rule wire strings. + serde_json::from_value(serde_json::Value::String(s.clone())) + .map_err(|_| format!("block {block}: unknown select rule \"{s}\"")) +} +``` + +with (new — same function, plus `mode_from` mirroring it): + +```rust +fn select_from( + m: &serde_json::Map, + block: &str, +) -> Result { + let s = require_str(m, "select", block)?; + // SelectRule's own derived Deserialize (which carries the #[serde(rename)] + // wire strings) is the type oracle, mirroring kind_tag/scalar_from_bare: + // no second hardcoded copy of the three select-rule wire strings. + serde_json::from_value(serde_json::Value::String(s.clone())) + .map_err(|_| format!("block {block}: unknown select rule \"{s}\"")) +} + +fn mode_from( + m: &serde_json::Map, + block: &str, +) -> Result { + let s = require_str(m, "mode", block)?; + // WfMode's own derived Deserialize (which carries the #[serde(rename)] + // wire strings) is the type oracle, mirroring select_from: no second + // hardcoded copy of the two roll-mode wire strings. + serde_json::from_value(serde_json::Value::String(s.clone())) + .map_err(|_| format!("block {block}: unknown mode \"{s}\"")) +} +``` + +Then in `stage_from_value` (lines 282-288), replace (old): + +```rust + "std::walk_forward" => Ok(StageBlock::WalkForward { + folds: require_u32(m, "folds", &id)?, + in_sample_bars: require_u64(m, "in_sample_bars", &id)?, + out_of_sample_bars: require_u64(m, "out_of_sample_bars", &id)?, + metric: require_str(m, "metric", &id)?, + select: select_from(m, &id)?, + }), +``` + +with (new): + +```rust + "std::walk_forward" => Ok(StageBlock::WalkForward { + in_sample_ms: require_u64(m, "in_sample_ms", &id)?, + out_of_sample_ms: require_u64(m, "out_of_sample_ms", &id)?, + step_ms: require_u64(m, "step_ms", &id)?, + mode: mode_from(m, &id)?, + metric: require_str(m, "metric", &id)?, + select: select_from(m, &id)?, + }), +``` + +(`require_u32` stays: the `std::monte_carlo` arm still uses it.) + +- [ ] **Step 7: Add `DocFault::ZeroWalkForwardLength` and split the `validate_process` WalkForward arm with zero-checks** + +In `crates/aura-research/src/lib.rs` (DocFault enum, lines 565-567), replace (old): + +```rust + GateFirst, + GateAfterTerminalStage { stage: usize }, + // campaign side +``` + +with (new): + +```rust + GateFirst, + GateAfterTerminalStage { stage: usize }, + ZeroWalkForwardLength { stage: usize, field: &'static str }, + // campaign side +``` + +Then in `validate_process` (lines 592-598), replace (old): + +```rust + StageBlock::Sweep { metric, .. } + | StageBlock::Generalize { metric } + | StageBlock::WalkForward { metric, .. } => { + if !is_known_metric(metric) { + faults.push(DocFault::UnknownMetric { stage: i, metric: metric.clone() }); + } + } +``` + +with (new — WalkForward gets its own arm; one fault per zero-length field): + +```rust + StageBlock::Sweep { metric, .. } | StageBlock::Generalize { metric } => { + if !is_known_metric(metric) { + faults.push(DocFault::UnknownMetric { stage: i, metric: metric.clone() }); + } + } + StageBlock::WalkForward { in_sample_ms, out_of_sample_ms, step_ms, metric, .. } => { + if !is_known_metric(metric) { + faults.push(DocFault::UnknownMetric { stage: i, metric: metric.clone() }); + } + // WindowRoller::new refuses NonPositiveLength at run time; the + // doc tier refuses the same zeroes earlier, path-addressed. + for (field, value) in [ + ("in_sample_ms", *in_sample_ms), + ("out_of_sample_ms", *out_of_sample_ms), + ("step_ms", *step_ms), + ] { + if value == 0 { + faults.push(DocFault::ZeroWalkForwardLength { stage: i, field }); + } + } + } +``` + +- [ ] **Step 8: Recompute the golden canonical pin** + +In `crates/aura-research/src/lib.rs` (test `process_canonical_form_is_pinned_and_id_stable`, the `golden` constant, lines 1042-1050), replace (old): + +```rust + let golden = concat!( + r#"{"format_version":1,"kind":"process","name":"wf-deflated-screen","#, + r#""description":"Sweep, keep only deflated-positive candidates, then walk-forward.","#, + r#""pipeline":[{"block":"std::sweep","metric":"net_expectancy_r","select":"plateau:worst","deflate":true},"#, + r#"{"block":"std::gate","all":[{"metric":"net_expectancy_r","cmp":"gt","value":0.0},"#, + r#"{"metric":"overfit_probability","cmp":"lt","value":0.1}]},"#, + r#"{"block":"std::walk_forward","folds":4,"in_sample_bars":4000,"out_of_sample_bars":1000,"#, + r#""metric":"net_expectancy_r","select":"argmax"}]}"# + ); +``` + +with (new — only the walk_forward segment moves; the id assertions below re-derive from `canonical`, so no hash constant changes): + +```rust + let golden = concat!( + r#"{"format_version":1,"kind":"process","name":"wf-deflated-screen","#, + r#""description":"Sweep, keep only deflated-positive candidates, then walk-forward.","#, + r#""pipeline":[{"block":"std::sweep","metric":"net_expectancy_r","select":"plateau:worst","deflate":true},"#, + r#"{"block":"std::gate","all":[{"metric":"net_expectancy_r","cmp":"gt","value":0.0},"#, + r#"{"metric":"overfit_probability","cmp":"lt","value":0.1}]},"#, + r#"{"block":"std::walk_forward","in_sample_ms":4000,"out_of_sample_ms":1000,"step_ms":1000,"#, + r#""mode":"rolling","metric":"net_expectancy_r","select":"argmax"}]}"# + ); +``` + +- [ ] **Step 9: Extend the fixture parse test with the stage-2 walk_forward assertions** + +In `crates/aura-research/src/lib.rs` (end of test `process_fixture_parses_to_typed_stages`, lines 907-914), replace (old): + +```rust + match &doc.pipeline[1] { + StageBlock::Gate { all } => { + assert_eq!(all.len(), 2); + assert_eq!(all[0].cmp, Cmp::Gt); + } + other => panic!("stage 1 is not a gate: {other:?}"), + } + } +``` + +with (new): + +```rust + match &doc.pipeline[1] { + StageBlock::Gate { all } => { + assert_eq!(all.len(), 2); + assert_eq!(all[0].cmp, Cmp::Gt); + } + other => panic!("stage 1 is not a gate: {other:?}"), + } + match &doc.pipeline[2] { + StageBlock::WalkForward { + in_sample_ms, + out_of_sample_ms, + step_ms, + mode, + metric, + select, + } => { + assert_eq!(*in_sample_ms, 4000); + assert_eq!(*out_of_sample_ms, 1000); + assert_eq!(*step_ms, 1000); + assert_eq!(*mode, WfMode::Rolling); + assert_eq!(metric, "net_expectancy_r"); + assert_eq!(*select, SelectRule::Argmax); + } + other => panic!("stage 2 is not a walk_forward: {other:?}"), + } + } +``` + +- [ ] **Step 10: Extend the vocabulary test with the walk_forward slot roster and mode label** + +In `crates/aura-research/src/lib.rs` (test `vocabularies_enumerate_every_block_with_typed_slots`, lines 1197-1201), replace (old): + +```rust + let sweep = describe_block("std::sweep").expect("sweep describable"); + assert!(sweep.slots.iter().any(|s| s.name == "metric" && s.required)); + assert!(sweep.slots.iter().any(|s| s.name == "deflate" && !s.required)); + assert!(describe_block("std::strategy").is_some()); + assert!(describe_block("std::nope").is_none()); +``` + +with (new): + +```rust + let sweep = describe_block("std::sweep").expect("sweep describable"); + assert!(sweep.slots.iter().any(|s| s.name == "metric" && s.required)); + assert!(sweep.slots.iter().any(|s| s.name == "deflate" && !s.required)); + let wf = describe_block("std::walk_forward").expect("walk_forward describable"); + let names: Vec<&str> = wf.slots.iter().map(|s| s.name).collect(); + assert_eq!( + names, + vec!["in_sample_ms", "out_of_sample_ms", "step_ms", "mode", "metric", "select"] + ); + let mode = wf.slots.iter().find(|s| s.name == "mode").expect("mode slot"); + assert_eq!(slot_kind_label(mode.kind), "one of: rolling | anchored"); + assert!(describe_block("std::strategy").is_some()); + assert!(describe_block("std::nope").is_none()); +``` + +- [ ] **Step 11: Add the two new aura-research tests (mode vocabulary/slot refusals; zero-length faults)** + +In `crates/aura-research/src/lib.rs`, directly after the end of test `validate_process_accepts_the_fixture_and_reports_each_fault` (lines 1139-1141), replace (old): + +```rust + assert!(validate_process(&gate_after) + .contains(&DocFault::GateAfterTerminalStage { stage: 2 })); + } +``` + +with (new — same closing, plus the two new tests): + +```rust + assert!(validate_process(&gate_after) + .contains(&DocFault::GateAfterTerminalStage { stage: 2 })); + } + + #[test] + fn walk_forward_parses_both_modes_and_refuses_bad_slots() { + // "anchored" is the other accepted wire mode + let anchored = PROCESS_FIXTURE.replacen("\"mode\": \"rolling\"", "\"mode\": \"anchored\"", 1); + let doc = parse_process(&anchored).expect("anchored parses"); + assert!(matches!( + &doc.pipeline[2], + StageBlock::WalkForward { mode: WfMode::Anchored, .. } + )); + // an unknown mode string refuses through WfMode's serde oracle + let bad_mode = PROCESS_FIXTURE.replacen("\"mode\": \"rolling\"", "\"mode\": \"expanding\"", 1); + let err = parse_process(&bad_mode).expect_err("unknown mode refused"); + assert!(matches!(err, DocError::Malformed(msg) if msg.contains("unknown mode \"expanding\""))); + // the retired 0106 vocabulary ("folds") is an unknown slot now + let folds = PROCESS_FIXTURE.replacen("\"in_sample_ms\": 4000", "\"folds\": 4", 1); + let err = parse_process(&folds).expect_err("folds refused"); + assert!(matches!(err, DocError::Malformed(msg) if msg.contains("unknown slot \"folds\""))); + // a missing required length slot is named + let missing = PROCESS_FIXTURE.replacen("\"step_ms\": 1000, ", "", 1); + let err = parse_process(&missing).expect_err("missing step_ms refused"); + assert!( + matches!(err, DocError::Malformed(msg) if msg.contains("missing required slot \"step_ms\"")) + ); + } + + #[test] + fn validate_process_reports_each_zero_walk_forward_length() { + let ok = parse_process(PROCESS_FIXTURE).unwrap(); + let zeroed = ProcessDoc { + pipeline: vec![ + ok.pipeline[0].clone(), + StageBlock::WalkForward { + in_sample_ms: 0, + out_of_sample_ms: 0, + step_ms: 0, + mode: WfMode::Rolling, + metric: "net_expectancy_r".into(), + select: SelectRule::Argmax, + }, + ], + ..ok.clone() + }; + assert_eq!( + validate_process(&zeroed), + vec![ + DocFault::ZeroWalkForwardLength { stage: 1, field: "in_sample_ms" }, + DocFault::ZeroWalkForwardLength { stage: 1, field: "out_of_sample_ms" }, + DocFault::ZeroWalkForwardLength { stage: 1, field: "step_ms" }, + ] + ); + let one = ProcessDoc { + pipeline: vec![ + ok.pipeline[0].clone(), + StageBlock::WalkForward { + in_sample_ms: 4000, + out_of_sample_ms: 1000, + step_ms: 0, + mode: WfMode::Anchored, + metric: "net_expectancy_r".into(), + select: SelectRule::Argmax, + }, + ], + ..ok.clone() + }; + assert_eq!( + validate_process(&one), + vec![DocFault::ZeroWalkForwardLength { stage: 1, field: "step_ms" }] + ); + } +``` + +- [ ] **Step 12: Gate — aura-research green** + +Run: `cargo test -p aura-research` +Expected: unit tests `test result: ok. 14 passed; 0 failed` (the 12 baseline tests including the re-pinned golden, plus the 2 new ones); doc-tests 0. + +- [ ] **Step 13: aura-cli twin — the `doc_fault_prose` arm for the new fault** + +In `crates/aura-cli/src/research_docs.rs` (doc_fault_prose, lines 97-100), replace (old): + +```rust + DocFault::GateAfterTerminalStage { stage } => { + format!("stage {stage}: a gate cannot follow a terminal stage (monte_carlo/generalize)") + } + DocFault::EmptyInstruments => "data.instruments is empty".into(), +``` + +with (new): + +```rust + DocFault::GateAfterTerminalStage { stage } => { + format!("stage {stage}: a gate cannot follow a terminal stage (monte_carlo/generalize)") + } + DocFault::ZeroWalkForwardLength { stage, field } => { + format!("pipeline[{stage}]: walk_forward {field} must be > 0") + } + DocFault::EmptyInstruments => "data.instruments is empty".into(), +``` + +- [ ] **Step 14: aura-cli unit test pinning the new prose** + +In `crates/aura-cli/src/research_docs.rs` (tests mod, end of `ref_fault_prose_is_debug_free`, lines 397-404), replace (old): + +```rust + for c in &cases { + assert!( + !c.contains("NotFound") && !c.contains("Mismatch") && !c.contains("Unmatched") && !c.contains("NotInParamSpace"), + "Debug leak: {c}" + ); + } + } +} +``` + +with (new — same closing, plus the new test): + +```rust + for c in &cases { + assert!( + !c.contains("NotFound") && !c.contains("Mismatch") && !c.contains("Unmatched") && !c.contains("NotInParamSpace"), + "Debug leak: {c}" + ); + } + } + + #[test] + fn zero_walk_forward_length_prose_is_path_addressed_and_debug_free() { + let prose = doc_fault_prose(&DocFault::ZeroWalkForwardLength { stage: 2, field: "step_ms" }); + assert_eq!(prose, "pipeline[2]: walk_forward step_ms must be > 0"); + assert!(!prose.contains("ZeroWalkForwardLength"), "Debug leak: {prose}"); + } +} +``` + +- [ ] **Step 15: aura-cli e2e fixture twin — move `PROCESS_DOC` to the corrected vocabulary** + +In `crates/aura-cli/tests/research_docs.rs` (PROCESS_DOC, lines 46-47), replace (old): + +```rust + { "block": "std::walk_forward", "folds": 4, "in_sample_bars": 4000, + "out_of_sample_bars": 1000, "metric": "net_expectancy_r", "select": "argmax" } +``` + +with (new): + +```rust + { "block": "std::walk_forward", "in_sample_ms": 4000, "out_of_sample_ms": 1000, + "step_ms": 1000, "mode": "rolling", "metric": "net_expectancy_r", "select": "argmax" } +``` + +(The fixture's consumer assertions all survive unchanged: "3 pipeline blocks, 2 gate predicates" still holds, the unknown-metric replacen target and the `plateau:worst`/`deflate` targets live in the untouched sweep stage, and register/content-id tests only assert id shape.) + +- [ ] **Step 16: aura-cli e2e — pin the corrected introspection and the zero-length refusal at the CLI seam** + +In `crates/aura-cli/tests/research_docs.rs` (test `process_introspect_vocabulary_block_and_content_id`, lines 88-91), replace (old): + +```rust + let (out, code) = run_code(&["process", "introspect", "--block", "std::sweep"]); + assert_eq!(code, Some(0)); + assert!(out.contains("metric")); + assert!(out.contains("required")); +``` + +with (new): + +```rust + let (out, code) = run_code(&["process", "introspect", "--block", "std::sweep"]); + assert_eq!(code, Some(0)); + assert!(out.contains("metric")); + assert!(out.contains("required")); + + let (out, code) = run_code(&["process", "introspect", "--block", "std::walk_forward"]); + assert_eq!(code, Some(0)); + for slot in ["in_sample_ms", "out_of_sample_ms", "step_ms", "mode"] { + assert!(out.contains(slot), "walk_forward describe misses {slot}: {out}"); + } + assert!(out.contains("one of: rolling | anchored"), "mode label missing: {out}"); + assert!(!out.contains("folds"), "retired slot still advertised: {out}"); +``` + +Then, directly after the end of test `process_validate_refuses_unknown_metric_as_prose_exit_1` (lines 77-79), replace (old): + +```rust + assert!(out.contains("unknown metric \"netto_r\"")); + assert!(!out.contains("UnknownMetric"), "Debug leak: {out}"); +} +``` + +with (new — same closing, plus the new e2e test): + +```rust + assert!(out.contains("unknown metric \"netto_r\"")); + assert!(!out.contains("UnknownMetric"), "Debug leak: {out}"); +} + +#[test] +fn process_validate_refuses_zero_walk_forward_length_as_prose_exit_1() { + let dir = temp_cwd("process-validate-zero-wf"); + let bad = PROCESS_DOC.replacen("\"step_ms\": 1000", "\"step_ms\": 0", 1); + write_doc(&dir, "bad.process.json", &bad); + let (out, code) = run_code_in(&dir, &["process", "validate", "bad.process.json"]); + assert_eq!(code, Some(1), "stdout/stderr: {out}"); + assert!(out.contains("pipeline[2]: walk_forward step_ms must be > 0"), "stdout/stderr: {out}"); + assert!(!out.contains("ZeroWalkForwardLength"), "Debug leak: {out}"); +} +``` + +- [ ] **Step 17: Final gate — aura-research** + +Run: `cargo test -p aura-research` +Expected: all pass (`test result: ok. 14 passed; 0 failed` for the unit tests). + +- [ ] **Step 18: Final gate — aura-cli** + +Run: `cargo test -p aura-cli` +Expected: all test binaries report `ok` with 0 failed (the `research_docs` integration binary now includes `process_validate_refuses_zero_walk_forward_length_as_prose_exit_1`; the demo-project e2e fixture build makes this the slow gate). + +- [ ] **Step 19: Final gate — workspace build** + +Run: `cargo build --workspace` +Expected: 0 errors (the `DocFault` match in aura-cli is exhaustive again after Step 13; no other crate destructures `StageBlock::WalkForward` or matches `SlotKind`). +### Task 2: aura-engine ListSpace + +**Files:** +- Modify: `crates/aura-engine/src/sweep.rs` (module doc :1-8; `SweepError` docs :128-137; new `ListSpace` inserted before the `SweepPoint` doc at :285; tests appended to `mod tests`, whose last test ends at :812) +- Modify: `crates/aura-engine/src/lib.rs` (:77-79, the `pub use sweep::{...}` block) +- Test: `crates/aura-engine/src/sweep.rs` (`mod tests`, same file) + +Context for the implementer: a gate stage in the campaign executor produces an arbitrary member subset with no cartesian structure, so the engine needs an explicit-point-set `Space` beside `GridSpace`/`RandomSpace`. `ListSpace::new` validates points against the param-space exactly like `GridSpace::new` (arity, kind), reusing the existing `SweepError::Arity`/`KindMismatch` variants unchanged (for a list, `KindMismatch.value_index` carries the point ordinal). An empty point list is valid and sweeps to an empty family. All edits below are literal old→new replacements; apply them byte-exactly. + +- [ ] **Step 1: RED — append the three ListSpace tests plus a fake-report helper to the test module in `crates/aura-engine/src/sweep.rs`** + +Find this exact text (the last test of `mod tests` and the module's closing brace, ends at line 812-813): + +```rust + #[test] + fn random_sweep_family_named_view_round_trips() { + let rs = sma_cross_random(); + let family = sweep(&rs, run_point); + let space = composite_sma_cross_harness().0.param_space(); + let expected: Vec<(String, Scalar)> = space + .iter() + .zip(&family.points[0].params) + .map(|(ps, c)| (ps.name.clone(), Scalar::from_cell(ps.kind, *c))) + .collect(); + assert_eq!(family.named_params(0), expected); + } +} +``` + +Replace it with: + +```rust + #[test] + fn random_sweep_family_named_view_round_trips() { + let rs = sma_cross_random(); + let family = sweep(&rs, run_point); + let space = composite_sma_cross_harness().0.param_space(); + let expected: Vec<(String, Scalar)> = space + .iter() + .zip(&family.points[0].params) + .map(|(ps, c)| (ps.name.clone(), Scalar::from_cell(ps.kind, *c))) + .collect(); + assert_eq!(family.named_params(0), expected); + } + + /// A fake report whose manifest `commit` encodes the input cells — enough to + /// prove the sweep closure ran on exactly the given point, without a harness + /// run. A free `fn` (Sync) so it serves directly as the `sweep` closure. + fn tagged_report(point: &[Cell]) -> RunReport { + RunReport { + manifest: RunManifest { + commit: point.iter().map(|c| c.i64().to_string()).collect::>().join(","), + params: Vec::new(), + window: (Timestamp(0), Timestamp(0)), + seed: 0, + broker: "test".to_string(), + selection: None, + instrument: None, + topology_hash: None, + project: None, + }, + metrics: summarize(&[], &[]), + } + } + + #[test] + fn list_space_runs_exactly_the_given_points_in_order() { + let space = i64_space(2); + // deliberately non-odometer order: the list enumerates the input order, + // not any canonical order + let ls = ListSpace::new( + &space, + vec![ + vec![Scalar::i64(3), Scalar::i64(5)], + vec![Scalar::i64(2), Scalar::i64(4)], + vec![Scalar::i64(3), Scalar::i64(4)], + ], + ) + .expect("valid explicit point set"); + let expected = vec![ + vec![Cell::from_i64(3), Cell::from_i64(5)], + vec![Cell::from_i64(2), Cell::from_i64(4)], + vec![Cell::from_i64(3), Cell::from_i64(4)], + ]; + assert_eq!(ls.points(), expected); + let family = sweep(&ls, tagged_report); + assert_eq!(family.points.len(), 3); + assert_eq!(family.space, space, "family carries the validated param-space"); + for (pt, cells) in family.points.iter().zip(&expected) { + assert_eq!(&pt.params, cells, "points carried tag-free, in input order"); + let tag: String = + cells.iter().map(|c| c.i64().to_string()).collect::>().join(","); + assert_eq!(pt.report.manifest.commit, tag, "the closure ran on exactly this point"); + } + } + + #[test] + fn list_space_refuses_arity_and_kind_mismatch() { + let space = i64_space(2); + // arity: the second point carries 1 value for a 2-slot space + let err = ListSpace::new( + &space, + vec![vec![Scalar::i64(1), Scalar::i64(2)], vec![Scalar::i64(3)]], + ) + .unwrap_err(); + assert_eq!(err, SweepError::Arity { expected: 2, got: 1 }); + // kind: point 1, slot 1 carries an F64 in an I64 slot + let err = ListSpace::new( + &space, + vec![ + vec![Scalar::i64(1), Scalar::i64(2)], + vec![Scalar::i64(3), Scalar::f64(0.5)], + ], + ) + .unwrap_err(); + assert_eq!( + err, + SweepError::KindMismatch { + slot: 1, + value_index: 1, + expected: ScalarKind::I64, + got: ScalarKind::F64, + }, + ); + } + + #[test] + fn list_space_allows_empty_point_list() { + let space = i64_space(2); + let ls = ListSpace::new(&space, vec![]).expect("an empty point list is valid"); + assert!(ls.points().is_empty(), "zero points enumerated"); + let family = sweep(&ls, tagged_report); + assert!(family.points.is_empty(), "an empty ListSpace sweeps to an empty family"); + assert_eq!(family.space, space, "the empty family still carries the param-space"); + } +} +``` + +(No new imports needed: `Cell`, `ParamSpec`, `Scalar`, `ScalarKind`, `Timestamp`, `RunManifest`, `RunReport`, `summarize`, and the `i64_space` helper are already in scope in this test module.) + +- [ ] **Step 2: Run the new tests — confirm RED** + +Run: `cargo test -p aura-engine list_space` +Expected: compilation FAILS with `error[E0433]` `use of undeclared type ListSpace` (4 errors, `could not compile aura-engine (lib test)`) — the RED state: the type does not exist yet. + +- [ ] **Step 3: Implement `ListSpace` in `crates/aura-engine/src/sweep.rs`** + +Find this exact text (the `SweepPoint` doc comment, line 285): + +```rust +/// One enumerated point and the full `RunReport` its run produced. +/// Self-describing: `params` is the point's coordinate in `param_space()` order, +/// and `report` carries the run's `(manifest, metrics)` — the unit the run +/// registry indexes (C18). +``` + +Replace it with: + +```rust +/// An explicit, validated point set over a blueprint's param-space — the +/// enumeration for arbitrary member subsets (e.g. a gate's survivor set) that +/// no cartesian `GridSpace` can represent. Points are validated against +/// `space` at construction (the `GridSpace::new` contract: arity per point, +/// kind per slot); an empty point list is valid and yields an empty family. +#[derive(Debug)] +pub struct ListSpace { + space: Vec, + points: Vec>, +} + +impl ListSpace { + /// Validate `points` against `space` (the blueprint's `param_space()`): + /// every point carries one value per slot (`Arity`), every value the + /// slot's declared kind (`KindMismatch`, whose `value_index` is the point + /// ordinal here). An empty point list is allowed — a legitimately-empty + /// survivor set is representable, it just enumerates zero points. + pub fn new(space: &[ParamSpec], points: Vec>) -> Result { + for (point_index, point) in points.iter().enumerate() { + if point.len() != space.len() { + return Err(SweepError::Arity { expected: space.len(), got: point.len() }); + } + for (slot, (v, ps)) in point.iter().zip(space).enumerate() { + if v.kind() != ps.kind { + return Err(SweepError::KindMismatch { + slot, + value_index: point_index, + expected: ps.kind, + got: v.kind(), + }); + } + } + } + Ok(Self { space: space.to_vec(), points }) + } +} + +impl Space for ListSpace { + /// The points exactly as given, in input order (no enumeration structure — + /// determinism is the caller's declared order, C1), lowered to tag-free + /// cells in the declared kinds (the kind lives once, in `param_specs()`). + fn points(&self) -> Vec> { + self.points + .iter() + .map(|p| p.iter().map(|s| s.cell()).collect()) + .collect() + } + fn param_specs(&self) -> &[ParamSpec] { + &self.space + } +} + +/// One enumerated point and the full `RunReport` its run produced. +/// Self-describing: `params` is the point's coordinate in `param_space()` order, +/// and `report` carries the run's `(manifest, metrics)` — the unit the run +/// registry indexes (C18). +``` + +- [ ] **Step 4: Update the two doc comments that are now stale (same file)** + +Edit 4a — find this exact text (the module doc, lines 1-8): + +```rust +//! Param-sweep (C12.1): enumerate a blueprint's param-space — either a cartesian +//! `GridSpace` (a discrete per-slot lattice) or a seeded `RandomSpace` (`N` draws +//! over typed continuous ranges) — and run each point disjointly (C1). Both +//! enumerations implement the `Space` trait that `sweep` is generic over, so +//! either runs through one execution path. This module owns enumeration +//! (`GridSpace` / `RandomSpace` / the `Space` trait), execution (`sweep`), and +//! collection (`SweepFamily`); the per-point run-to-metrics closure is the +//! author's (harness-specific sink glue the engine cannot generically own — C8/C18). +``` + +Replace it with: + +```rust +//! Param-sweep (C12.1): enumerate a blueprint's param-space — a cartesian +//! `GridSpace` (a discrete per-slot lattice), a seeded `RandomSpace` (`N` draws +//! over typed continuous ranges), or an explicit `ListSpace` (an arbitrary +//! validated point set, e.g. a gate's survivor subset) — and run each point +//! disjointly (C1). All three enumerations implement the `Space` trait that +//! `sweep` is generic over, so each runs through one execution path. This module +//! owns enumeration (`GridSpace` / `RandomSpace` / `ListSpace` / the `Space` +//! trait), execution (`sweep`), and collection (`SweepFamily`); the per-point +//! run-to-metrics closure is the author's (harness-specific sink glue the engine +//! cannot generically own — C8/C18). +``` + +Edit 4b — find this exact text (the `SweepError` doc + first two variants, lines 128-137): + +```rust +/// A structural fault constructing a `GridSpace` or a `RandomSpace` — the shared +/// typed gate before any run (grid faults: `Arity` / `KindMismatch` / `EmptyAxis`; +/// random faults: `NonNumericRange` / `RangeKindMismatch` / `EmptyRange`). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SweepError { + /// The number of axes does not equal the param-space slot count. + Arity { expected: usize, got: usize }, + /// A grid value's kind does not match its slot's declared kind. `slot` is the + /// flat param-space index; `value_index` is the position within that axis. + KindMismatch { slot: usize, value_index: usize, expected: ScalarKind, got: ScalarKind }, +``` + +Replace it with: + +```rust +/// A structural fault constructing a `GridSpace`, a `RandomSpace`, or a +/// `ListSpace` — the shared typed gate before any run (grid faults: `Arity` / +/// `KindMismatch` / `EmptyAxis`; random faults: `NonNumericRange` / +/// `RangeKindMismatch` / `EmptyRange`; list faults reuse `Arity` / +/// `KindMismatch`, with `value_index` carrying the point ordinal). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SweepError { + /// The number of axes (grid/random) — or of a single point's values (list) + /// — does not equal the param-space slot count. + Arity { expected: usize, got: usize }, + /// A grid or list value's kind does not match its slot's declared kind. + /// `slot` is the flat param-space index; `value_index` is the position + /// within that axis (grid) or the point ordinal (list). + KindMismatch { slot: usize, value_index: usize, expected: ScalarKind, got: ScalarKind }, +``` + +- [ ] **Step 5: Run the new tests — confirm GREEN** + +Run: `cargo test -p aura-engine list_space` +Expected: compiles; the lib-test binary reports `test result: ok. 3 passed; 0 failed` with exactly these tests passing: `sweep::tests::list_space_runs_exactly_the_given_points_in_order`, `sweep::tests::list_space_refuses_arity_and_kind_mismatch`, `sweep::tests::list_space_allows_empty_point_list` (the other test binaries report 0 passed with everything filtered out). + +- [ ] **Step 6: Re-export `ListSpace` from the crate root** + +In `crates/aura-engine/src/lib.rs`, find this exact text (lines 77-79): + +```rust +pub use sweep::{ + sweep, GridSpace, ParamRange, RandomSpace, Space, SweepError, SweepFamily, SweepPoint, +}; +``` + +Replace it with: + +```rust +pub use sweep::{ + sweep, GridSpace, ListSpace, ParamRange, RandomSpace, Space, SweepError, SweepFamily, + SweepPoint, +}; +``` + +- [ ] **Step 7: Workspace build gate** + +Run: `cargo build --workspace` +Expected: `Finished` with 0 errors (warnings-free). + +- [ ] **Step 8: Lint gate on the touched crate** + +Run: `cargo clippy -p aura-engine --all-targets -- -D warnings` +Expected: `Finished` with no warnings or errors (in particular no `len_without_is_empty` — `ListSpace` deliberately exposes no inherent `len`). +### Task 3: aura-registry campaign-run records + store + visibility promotions + +**Files:** +- Modify: `crates/aura-registry/src/lineage.rs` (module doc :1-10, imports :12-19, after `Family` struct :55-60, `impl Registry` block :62-118, after `next_run` :124-131) +- Modify: `crates/aura-registry/src/lib.rs` (`pub use lineage::{...}` :30-34, `blueprint_path` :109-115, `find_blueprint_by_identity` :283-288) +- Test: `crates/aura-registry/src/lib.rs` (`mod tests`, appended before the module's closing brace ~:1607) + +Context for the implementer: the registry's family store (`families.jsonl`) is implemented in `crates/aura-registry/src/lineage.rs` — the `Registry` methods `append_family`/`load_family_members` live in an `impl Registry` block **inside lineage.rs**, with a free helper fn `next_run` beside them. This task adds a parallel campaign-run store (`campaign_runs.jsonl`) following exactly that pattern, plus two visibility promotions in `lib.rs`. `FamilySelection` reaches this crate via `aura_engine`'s re-export (that is the path `lib.rs:23` already uses); `Scalar` comes from `aura_core` (a direct dependency). All work stays unstaged; no `git commit`. + +- [ ] **Step 1: RED — add the three campaign-run tests to the `mod tests` block of `crates/aura-registry/src/lib.rs`** + +In `crates/aura-registry/src/lib.rs`, find the end of the tests module (the last test, `check_r_metric_accepts_r_and_refuses_pip`, ends at the module's closing brace). Replace: + +```rust + match check_r_metric("nope") { + Err(RegistryError::UnknownMetric(m)) => assert_eq!(m, "nope"), + other => panic!("expected UnknownMetric, got {other:?}"), + } + } +} +``` + +with: + +```rust + match check_r_metric("nope") { + Err(RegistryError::UnknownMetric(m)) => assert_eq!(m, "nope"), + other => panic!("expected UnknownMetric, got {other:?}"), + } + } + + /// A minimal campaign-run record for the store tests. `run` is deliberately + /// wrong (99): `append_campaign_run` assigns the real counter and must + /// override it in the stored line. + fn campaign_run_record(campaign: &str) -> CampaignRunRecord { + CampaignRunRecord { + campaign: campaign.to_string(), + process: "proc-id".to_string(), + run: 99, + seed: 7, + cells: vec![], + } + } + + #[test] + fn campaign_run_counter_assigns_sequential_runs() { + let path = temp_family_dir("campaign_run_counter"); + let reg = Registry::open(&path); + let a0 = reg.append_campaign_run(&campaign_run_record("aaaa")).expect("aaaa run 0"); + let a1 = reg.append_campaign_run(&campaign_run_record("aaaa")).expect("aaaa run 1"); + let b0 = reg.append_campaign_run(&campaign_run_record("bbbb")).expect("bbbb run 0"); + assert_eq!((a0, a1, b0), (0, 1, 0), "per-campaign counter, independent per id"); + // the stored lines carry the ASSIGNED run, not the input record's 99 + let stored = reg.load_campaign_runs().expect("load"); + assert_eq!( + stored.iter().map(|r| (r.campaign.as_str(), r.run)).collect::>(), + vec![("aaaa", 0), ("aaaa", 1), ("bbbb", 0)], + ); + } + + #[test] + fn load_campaign_runs_missing_file_is_empty() { + let path = temp_family_dir("campaign_runs_missing"); + let reg = Registry::open(&path); + assert_eq!( + reg.load_campaign_runs().expect("load missing"), + Vec::::new() + ); + } + + #[test] + fn campaign_run_record_roundtrips() { + let path = temp_family_dir("campaign_run_roundtrip"); + let reg = Registry::open(&path); + let record = CampaignRunRecord { + campaign: "cafe".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: (1_136_073_600_000, 1_154_390_400_000), + stages: vec![ + StageRealization { + block: "std::sweep".to_string(), + family_id: Some("cafe-0-EURUSD-w0-s0-0".to_string()), + survivor_ordinals: None, + selection: Some(StageSelection { + winner_ordinal: 4, + params: vec![ + ("sma_cross.fast.length".to_string(), Scalar::i64(3)), + ("sma_cross.slow.length".to_string(), Scalar::i64(9)), + ], + selection: FamilySelection { + selection_metric: "sqn_normalized".to_string(), + n_trials: 9, + raw_winner_metric: 1.8, + mode: SelectionMode::Argmax, + deflated_score: Some(0.2), + overfit_probability: Some(0.06), + n_resamples: Some(1000), + block_len: Some(5), + seed: Some(7), + neighbourhood_score: None, + n_neighbours: None, + }, + }), + }, + StageRealization { + block: "std::gate".to_string(), + family_id: None, + survivor_ordinals: Some(vec![0, 3, 4, 7]), + selection: None, + }, + ], + }], + }; + let run = reg.append_campaign_run(&record).expect("append"); + assert_eq!(run, 0); + assert_eq!(reg.load_campaign_runs().expect("load"), vec![record]); + } +} +``` + +(`FamilySelection`, `SelectionMode`, and the new lineage types are in scope via the module's `use super::*;`; `Scalar` via the existing `use aura_core::{Cell, Scalar, Timestamp};` at the top of `mod tests`. `temp_family_dir` is the existing per-test-directory fixture the family-store tests already use.) + +- [ ] **Step 2: Run the RED gate** + +Run: `cargo test -p aura-registry campaign_run` +Expected: compilation FAILS with errors naming the not-yet-existing items (e.g. `cannot find struct, variant or union type CampaignRunRecord in this scope`, and similarly for `CellRealization`/`StageRealization`/`StageSelection`; `append_campaign_run`/`load_campaign_runs` unresolved). This is the RED confirmation — do not proceed if the failure is anything other than these missing-item errors. + +- [ ] **Step 3: lineage.rs — extend the module doc and the imports** + +In `crates/aura-registry/src/lineage.rs`, replace: + +```rust +//! ([`group_families`]). The family store is a sibling JSONL of the flat runs +//! store (`families.jsonl`), so the flat `runs.jsonl` path and API are untouched. + +use std::collections::HashMap; +use std::fs; +use std::io::Write; + +use aura_engine::{McFamily, RunReport, SweepFamily, WalkForwardResult}; +use serde::{Deserialize, Serialize}; +``` + +with: + +```rust +//! ([`group_families`]). The family store is a sibling JSONL of the flat runs +//! store (`families.jsonl`), so the flat `runs.jsonl` path and API are untouched. +//! +//! Campaign realizations (cycle 0107) follow the same growth pattern: a thin +//! [`CampaignRunRecord`] linking untouched family records, one JSONL line per +//! campaign run in a second sibling store (`campaign_runs.jsonl`), written by +//! [`Registry::append_campaign_run`] and read by [`Registry::load_campaign_runs`]. + +use std::collections::HashMap; +use std::fs; +use std::io::Write; + +use aura_core::Scalar; +use aura_engine::{FamilySelection, McFamily, RunReport, SweepFamily, WalkForwardResult}; +use serde::{Deserialize, Serialize}; +``` + +- [ ] **Step 4: lineage.rs — add the four campaign-run record types after the `Family` struct** + +In `crates/aura-registry/src/lineage.rs`, replace: + +```rust +/// A re-derived family: the members sharing one `(family, run)` key (and thus one +/// derived `family_id`), ordinal-sorted — the round-trip result of +/// [`group_families`]. +#[derive(Clone, Debug, PartialEq)] +pub struct Family { + pub id: String, + pub kind: FamilyKind, + pub members: Vec, +} +``` + +with: + +```rust +/// A re-derived family: the members sharing one `(family, run)` key (and thus one +/// derived `family_id`), ordinal-sorted — the round-trip result of +/// [`group_families`]. +#[derive(Clone, Debug, PartialEq)] +pub struct Family { + pub id: String, + pub kind: FamilyKind, + pub members: Vec, +} + +/// Campaign realization: a THIN linking record over untouched family records +/// (#198). One JSONL line per campaign run in `campaign_runs.jsonl`, a sibling +/// of `runs.jsonl`/`families.jsonl` — the registry's growth pattern. `run` is +/// the per-campaign counter assigned by [`Registry::append_campaign_run`] (the +/// family store's per-name `run` pattern). +#[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, +} + +/// One realized (strategy, instrument, window) cell: the pipeline prefix that +/// actually ran (`stages` stops after an empty gate). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct CellRealization { + /// Blueprint content id. + pub strategy: String, + pub instrument: String, + /// Inclusive epoch-ms window. + pub window_ms: (i64, i64), + pub stages: Vec, +} + +/// One realized pipeline stage. `family_id` is set for family-producing stages +/// (sweep / walk_forward); `survivor_ordinals` for gate stages (ordinals index +/// the nearest preceding `family_id`-bearing stage's family); `selection` for +/// sweep stages (walk-forward selections live in the wf family members' +/// manifests). +#[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, +} + +/// The recorded winner of a selection-bearing stage: its ordinal in the stage's +/// family, its winning param coordinate, and the selection provenance. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct StageSelection { + pub winner_ordinal: usize, + pub params: Vec<(String, Scalar)>, + pub selection: FamilySelection, +} +``` + +- [ ] **Step 5: lineage.rs — add `append_campaign_run`/`load_campaign_runs` to the existing `impl Registry` block** + +In `crates/aura-registry/src/lineage.rs`, the `impl Registry` block ends after `load_family_members`. Replace: + +```rust + let mut records = Vec::new(); + for (i, raw) in text.lines().enumerate() { + if raw.trim().is_empty() { + continue; + } + let record = serde_json::from_str(raw) + .map_err(|source| RegistryError::Parse { line: i + 1, source })?; + records.push(record); + } + Ok(records) + } +} +``` + +with: + +```rust + let mut records = Vec::new(); + for (i, raw) in text.lines().enumerate() { + if raw.trim().is_empty() { + continue; + } + let record = serde_json::from_str(raw) + .map_err(|source| RegistryError::Parse { line: i + 1, source })?; + records.push(record); + } + Ok(records) + } + + /// Assign a fresh per-campaign `run` index (one past the highest `run` + /// already stored for `record.campaign`, or `0` if unseen — the + /// [`Registry::append_family`] counter pattern), write the record carrying + /// that assigned run as ONE JSONL line to the campaign-run store (a sibling + /// of the flat runs store, `campaign_runs.jsonl`), and return the assigned + /// run. The input record's own `run` field is ignored. Reads the store once + /// to pick the run index (a read-before-write; single-process CLI + /// invocations do not race). + pub fn append_campaign_run( + &self, + record: &CampaignRunRecord, + ) -> Result { + let run = next_campaign_run(&record.campaign, &self.load_campaign_runs()?); + + let path = self.path.with_file_name("campaign_runs.jsonl"); + if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) { + fs::create_dir_all(parent)?; + } + let mut file = fs::OpenOptions::new().create(true).append(true).open(&path)?; + let stored = CampaignRunRecord { run, ..record.clone() }; + let line = serde_json::to_string(&stored).expect("a finite CampaignRunRecord serializes"); + writeln!(file, "{line}")?; + Ok(run) + } + + /// Parse every stored campaign-run record, in file order. A missing file is + /// an empty campaign-run store (`Ok(vec![])`), exactly as + /// [`Registry::load_family_members`] treats a missing family store. + pub fn load_campaign_runs(&self) -> Result, RegistryError> { + let path = self.path.with_file_name("campaign_runs.jsonl"); + let text = match fs::read_to_string(&path) { + Ok(t) => t, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(RegistryError::Io(e)), + }; + let mut records = Vec::new(); + for (i, raw) in text.lines().enumerate() { + if raw.trim().is_empty() { + continue; + } + let record = serde_json::from_str(raw) + .map_err(|source| RegistryError::Parse { line: i + 1, source })?; + records.push(record); + } + Ok(records) + } +} +``` + +- [ ] **Step 6: lineage.rs — add `next_campaign_run` beside `next_run`** + +In `crates/aura-registry/src/lineage.rs`, replace: + +```rust +fn next_run(name: &str, records: &[FamilyRunRecord]) -> usize { + records + .iter() + .filter(|r| r.family == name) + .map(|r| r.run + 1) + .max() + .unwrap_or(0) +} +``` + +with: + +```rust +fn next_run(name: &str, records: &[FamilyRunRecord]) -> usize { + records + .iter() + .filter(|r| r.family == name) + .map(|r| r.run + 1) + .max() + .unwrap_or(0) +} + +/// The next per-campaign run index: one past the highest `run` already stored +/// for `campaign`, or `0` if the campaign is unseen — [`next_run`]'s parallel +/// for the campaign-run store. Deliberately a separate fn, not a +/// generalization of `next_run`: the two stores' key fields stay independently +/// named and typed. +fn next_campaign_run(campaign: &str, records: &[CampaignRunRecord]) -> usize { + records + .iter() + .filter(|r| r.campaign == campaign) + .map(|r| r.run + 1) + .max() + .unwrap_or(0) +} +``` + +- [ ] **Step 7: lib.rs — extend the lineage re-export** + +In `crates/aura-registry/src/lib.rs`, replace: + +```rust +pub use lineage::{ + group_families, mc_member_reports, sweep_member_reports, walkforward_member_reports, Family, + FamilyKind, FamilyRunRecord, +}; +``` + +with: + +```rust +pub use lineage::{ + group_families, mc_member_reports, sweep_member_reports, walkforward_member_reports, + CampaignRunRecord, CellRealization, Family, FamilyKind, FamilyRunRecord, StageRealization, + StageSelection, +}; +``` + +- [ ] **Step 8: lib.rs — promote `blueprint_path` to pub with a `process_path`-style doc comment** + +In `crates/aura-registry/src/lib.rs`, replace: + +```rust + /// The single content-id→path mapping `put_blueprint` and `get_blueprint` both + /// route through, so the store can never write one path and read another (a + /// drifted key would silently break round-trip — `get` returns `None` and + /// reproduction cannot re-derive the member, rather than erroring). + fn blueprint_path(&self, hash: &str) -> PathBuf { + self.blueprints_dir().join(format!("{hash}.json")) + } +``` + +with: + +```rust + /// The store path a blueprint with this content id lives at — the single + /// content-id→path mapping `put_blueprint` and `get_blueprint` both route + /// through (so the store can never write one path and read another; a + /// drifted key would silently break round-trip), exposed so consumers + /// never re-derive the layout. + pub fn blueprint_path(&self, hash: &str) -> PathBuf { + self.blueprints_dir().join(format!("{hash}.json")) + } +``` + +- [ ] **Step 9: lib.rs — promote `find_blueprint_by_identity` to pub** + +In `crates/aura-registry/src/lib.rs`, replace: + +```rust + /// Scan the blueprint store for a blueprint whose identity id matches. + fn find_blueprint_by_identity( + &self, + identity_id: &str, + resolve: &dyn Fn(&str) -> Option, + ) -> Result, RegistryError> { +``` + +with: + +```rust + /// Scan the blueprint store for a blueprint whose identity id matches — + /// public so a campaign run resolves the same identity refs the + /// referential tier validates. + pub fn find_blueprint_by_identity( + &self, + identity_id: &str, + resolve: &dyn Fn(&str) -> Option, + ) -> Result, RegistryError> { +``` + +- [ ] **Step 10: Run the GREEN gate on the new tests** + +Run: `cargo test -p aura-registry campaign_run` +Expected: compiles clean; exactly 3 tests run and pass (`campaign_run_counter_assigns_sequential_runs`, `load_campaign_runs_missing_file_is_empty`, `campaign_run_record_roundtrips`) — `3 passed; 0 failed`. + +- [ ] **Step 11: Run the whole crate's suite** + +Run: `cargo test -p aura-registry` +Expected: all tests pass, `0 failed` (the family-store, ranking, plateau, deflation, generalization, and referential-tier tests are untouched by this task and must stay green). + +- [ ] **Step 12: Run the workspace build gate** + +Run: `cargo build --workspace` +Expected: builds with 0 errors. +### Task 4: aura-campaign crate scaffold + core types + member_metric + +*Task-order note: this task is independent of Tasks 1-3 — its `aura-research` imports (`Axis`, `Cmp`) predate this cycle — but is executed in plan order. Task 5 (preflight) edits the files this task creates.* + +**Files:** +- Modify: `Cargo.toml` (workspace members list, lines 11-21) +- Create: `crates/aura-campaign/Cargo.toml` +- Create: `crates/aura-campaign/src/lib.rs` +- Test: `crates/aura-campaign/src/lib.rs` (in-file `#[cfg(test)] mod tests`) + +- [ ] **Step 1: Register the new crate in the workspace members list** + +In `Cargo.toml` (repo root), replace: + +```toml + "crates/aura-registry", + "crates/aura-research", +] +``` + +with: + +```toml + "crates/aura-registry", + "crates/aura-research", + "crates/aura-campaign", +] +``` + +- [ ] **Step 2: Create the crate manifest** + +Create `crates/aura-campaign/Cargo.toml` with exactly: + +```toml +[package] +name = "aura-campaign" +edition.workspace = true +version.workspace = true +license.workspace = true +publish.workspace = true + +# Deliberately NO aura-ingest / aura-std / aura-composites: harness and data +# binding enter through the one-method MemberRunner seam (src/lib.rs), so the +# deploy-condemned CLI scaffolding never becomes a library dep and a +# differently-binding consumer (playground, tests) implements the same seam. +[dependencies] +# the scalar vocabulary: params cross the MemberRunner seam as (name, Scalar) pairs. +aura-core = { path = "../aura-core" } +# RunReport (the member result type) + the sweep/walk_forward orchestration machinery. +aura-engine = { path = "../aura-engine" } +# FamilySelection — the selection-provenance type stage selections carry. +aura-analysis = { path = "../aura-analysis" } +# family + campaign-run stores and the optimize/optimize_plateau/optimize_deflated selectors. +aura-registry = { path = "../aura-registry" } +# the campaign/process document types this crate executes. +aura-research = { path = "../aura-research" } +# realization payloads derive serde (the registry per-case policy, INDEX.md). +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +``` + +- [ ] **Step 3: Create lib.rs — crate doc, core types, consts, stubbed `member_metric`/`predicate_holds`, and the RED tests** + +Create `crates/aura-campaign/src/lib.rs` with exactly: + +```rust +//! aura-campaign — the campaign-execution library (#198, cycle 0107). +//! +//! Campaign *semantics* as a reusable leaf crate: cell enumeration over a +//! campaign document's (strategy, instrument, window) matrix, preflight of +//! the v1 executable pipeline shape, per-member gate evaluation, winner +//! selection, and realization assembly over the registry's family machinery. +//! Harness construction and data binding enter exclusively through the +//! one-method [`MemberRunner`] seam, so every consumer (the CLI today; the +//! playground and tests tomorrow) binds its own runner while the execution +//! semantics live here once — this crate is NOT the World (C12/C21): it +//! realizes one campaign document; it owns no topology, no data sources, +//! and no UI. + +use std::collections::BTreeMap; + +use aura_core::Scalar; +use aura_engine::RunReport; +use aura_registry::RegistryError; +use aura_research::{Axis, Cmp}; + +/// One structural cell of the campaign matrix: (strategy, instrument, +/// window) — #198 decision 7. +pub struct CellSpec { + pub strategy_ordinal: usize, + /// Resolved blueprint content id (== the topology hash). + pub strategy_id: String, + /// Canonical blueprint bytes from the store. + pub blueprint_json: String, + /// The campaign's tuning axes (raw `param_space()` names). + pub axes: BTreeMap, + pub instrument: String, + /// Inclusive epoch-ms bounds. + pub window_ms: (i64, i64), +} + +/// The harness/data binding seam — the ONLY thing a consumer implements. +/// Params arrive as (raw axis name, value) pairs; the implementation binds +/// them to its harness convention and runs the member over `cell.instrument` +/// restricted to `window_ms` (inclusive epoch-ms, a sub-range of +/// `cell.window_ms` — a walk-forward stage passes sub-windows). +pub trait MemberRunner: Sync { + fn run_member( + &self, + cell: &CellSpec, + params: &[(String, Scalar)], + window_ms: (i64, i64), + ) -> Result; +} + +/// Display-free member faults (the consumer phrases them — the RefFault +/// pattern). +#[derive(Clone, Debug, PartialEq)] +pub enum MemberFault { + NoData { instrument: String, window_ms: (i64, i64) }, + Bind(String), + Run(String), +} + +/// Preflight + runtime refusals. Display-free and by-identifier: the +/// consumer phrases them (the DocFault/RefFault pattern). +#[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 }, + /// A sweep/walk_forward selection metric outside the registry's rankable + /// roster. + UnrankableMetric { stage: usize, metric: String }, + /// A gate predicate metric that is not a per-member scalar (e.g. an + /// annotation name such as `deflated_score`). + GateMetricNotPerMember { stage: usize, metric: String }, + /// `plateau:*` selection in walk_forward (a gated survivor subset has no + /// grid lattice to smooth over). + PlateauInWalkForward { stage: usize }, + /// sweep `deflate: true` with a non-argmax select rule. + DeflatePlateauConflict { stage: usize }, + /// `WindowRoller` construction refusals at runtime. + Window { stage: usize, detail: String }, + Member(MemberFault), + Registry(RegistryError), +} + +/// The 14 per-member scalars a gate predicate may reference: the 3 +/// `RunMetrics` scalars plus the 11 `RMetrics` scalars. The three +/// selection-annotation names (`deflated_score` / `overfit_probability` / +/// `neighbourhood_score`) are deliberately NOT here — they describe a +/// selection, not a member. Hand-copied roster (the third metric-roster site +/// beside aura-research's 17-name vocabulary and aura-registry's rankable +/// set, #190); drift fails safe: an unknown name is a preflight refusal, +/// never a wrong number. +pub const PER_MEMBER_METRICS: &[&str] = &[ + "total_pips", "max_drawdown", "bias_sign_flips", + "expectancy_r", "n_trades", "win_rate", "avg_win_r", "avg_loss_r", + "profit_factor", "max_r_drawdown", "n_open_at_end", "sqn_normalized", + "sqn", "net_expectancy_r", +]; + +/// The registry's rankable roster (`resolve_metric`'s name set) — the metrics +/// a sweep/walk_forward stage may select on. Hand-copied (#190); drift fails +/// safe (a name the registry would refuse is refused here first, before any +/// member runs). +pub const RANKABLE_METRICS: &[&str] = &[ + "total_pips", "max_drawdown", "bias_sign_flips", + "sqn", "sqn_normalized", "expectancy_r", "net_expectancy_r", +]; + +/// Deflation resample count — the shipped CLI `select_winner` constant. +pub const DEFLATION_N_RESAMPLES: usize = 1000; +/// Deflation moving-block length — the shipped CLI `select_winner` constant. +pub const DEFLATION_BLOCK_LEN: usize = 5; + +/// Resolve one of the 14 [`PER_MEMBER_METRICS`] against a member's report. +/// An R-metric name against `metrics.r == None` reads `None` (conservative +/// and deterministic: a gate predicate over `None` fails the member). +/// Annotation names and unknown names read `None`. +pub fn member_metric(report: &RunReport, name: &str) -> Option { + todo!("cycle 0107 task 4") +} + +/// Whether `value threshold` holds — the gate's comparator arm. +// Consumed by `execute`'s gate stage (a later task of this plan drops the allow). +#[allow(dead_code)] +fn predicate_holds(cmp: &Cmp, value: f64, threshold: f64) -> bool { + todo!("cycle 0107 task 4") +} + +#[cfg(test)] +mod tests { + use super::*; + use aura_engine::{RMetrics, RunManifest, RunMetrics, Timestamp}; + + /// A report with every per-member scalar planted to a distinct value + /// (1..=14 in `PER_MEMBER_METRICS` order), r block present. + fn report_with_r() -> RunReport { + RunReport { + manifest: RunManifest { + commit: "test".to_string(), + params: vec![], + window: (Timestamp(0), Timestamp(1)), + seed: 0, + broker: "sim".to_string(), + selection: None, + instrument: None, + topology_hash: None, + project: None, + }, + metrics: RunMetrics { + total_pips: 1.0, + max_drawdown: 2.0, + bias_sign_flips: 3, + r: Some(RMetrics { + expectancy_r: 4.0, + n_trades: 5, + win_rate: 6.0, + avg_win_r: 7.0, + avg_loss_r: 8.0, + profit_factor: 9.0, + max_r_drawdown: 10.0, + n_open_at_end: 11, + sqn_normalized: 12.0, + sqn: 13.0, + net_expectancy_r: 14.0, + conviction_terciles_r: [0.0; 3], + trade_rs: Vec::new(), + }), + }, + } + } + + #[test] + fn member_metric_resolves_all_fourteen_names() { + let rep = report_with_r(); + let expected: &[(&str, f64)] = &[ + ("total_pips", 1.0), + ("max_drawdown", 2.0), + ("bias_sign_flips", 3.0), + ("expectancy_r", 4.0), + ("n_trades", 5.0), + ("win_rate", 6.0), + ("avg_win_r", 7.0), + ("avg_loss_r", 8.0), + ("profit_factor", 9.0), + ("max_r_drawdown", 10.0), + ("n_open_at_end", 11.0), + ("sqn_normalized", 12.0), + ("sqn", 13.0), + ("net_expectancy_r", 14.0), + ]; + assert_eq!(expected.len(), 14); + assert_eq!(PER_MEMBER_METRICS.len(), 14); + for (name, want) in expected { + assert!( + PER_MEMBER_METRICS.contains(name), + "{name} missing from PER_MEMBER_METRICS" + ); + assert_eq!(member_metric(&rep, name), Some(*want), "metric {name}"); + } + } + + #[test] + fn member_metric_r_names_none_without_r_block() { + let mut rep = report_with_r(); + rep.metrics.r = None; + // the three run-level scalars still resolve... + assert_eq!(member_metric(&rep, "total_pips"), Some(1.0)); + assert_eq!(member_metric(&rep, "max_drawdown"), Some(2.0)); + assert_eq!(member_metric(&rep, "bias_sign_flips"), Some(3.0)); + // ...and every R name reads None (a gate predicate over it fails). + for name in [ + "expectancy_r", "n_trades", "win_rate", "avg_win_r", "avg_loss_r", + "profit_factor", "max_r_drawdown", "n_open_at_end", "sqn_normalized", + "sqn", "net_expectancy_r", + ] { + assert_eq!(member_metric(&rep, name), None, "R metric {name} without r block"); + } + } + + #[test] + fn member_metric_refuses_annotation_and_unknown_names() { + let rep = report_with_r(); + for name in [ + "deflated_score", "overfit_probability", "neighbourhood_score", "no_such_metric", + ] { + assert_eq!(member_metric(&rep, name), None, "{name} must not resolve"); + assert!( + !PER_MEMBER_METRICS.contains(&name), + "{name} must not be in PER_MEMBER_METRICS" + ); + } + } + + #[test] + fn predicate_holds_covers_all_four_cmps() { + assert!(predicate_holds(&Cmp::Gt, 1.0, 0.0)); + assert!(!predicate_holds(&Cmp::Gt, 0.0, 0.0)); + assert!(predicate_holds(&Cmp::Ge, 0.0, 0.0)); + assert!(!predicate_holds(&Cmp::Ge, -0.1, 0.0)); + assert!(predicate_holds(&Cmp::Lt, -1.0, 0.0)); + assert!(!predicate_holds(&Cmp::Lt, 0.0, 0.0)); + assert!(predicate_holds(&Cmp::Le, 0.0, 0.0)); + assert!(!predicate_holds(&Cmp::Le, 0.1, 0.0)); + } +} +``` + +- [ ] **Step 4: Run the tests RED** + +Run: `cargo test -p aura-campaign` +Expected: the crate compiles (unused-parameter warnings from the two `todo!` stubs are expected at this step only) and all 4 tests FAIL — `test result: FAILED. 0 passed; 4 failed`, each panicking with `not yet implemented: cycle 0107 task 4`. This is the RED gate; do not proceed if anything fails to compile. + +- [ ] **Step 5: Implement `member_metric`** + +In `crates/aura-campaign/src/lib.rs`, replace: + +```rust +pub fn member_metric(report: &RunReport, name: &str) -> Option { + todo!("cycle 0107 task 4") +} +``` + +with: + +```rust +pub fn member_metric(report: &RunReport, name: &str) -> Option { + let m = &report.metrics; + match name { + "total_pips" => Some(m.total_pips), + "max_drawdown" => Some(m.max_drawdown), + "bias_sign_flips" => Some(m.bias_sign_flips as f64), + _ => { + let r = m.r.as_ref()?; + match name { + "expectancy_r" => Some(r.expectancy_r), + "n_trades" => Some(r.n_trades as f64), + "win_rate" => Some(r.win_rate), + "avg_win_r" => Some(r.avg_win_r), + "avg_loss_r" => Some(r.avg_loss_r), + "profit_factor" => Some(r.profit_factor), + "max_r_drawdown" => Some(r.max_r_drawdown), + "n_open_at_end" => Some(r.n_open_at_end as f64), + "sqn_normalized" => Some(r.sqn_normalized), + "sqn" => Some(r.sqn), + "net_expectancy_r" => Some(r.net_expectancy_r), + _ => None, + } + } + } +} +``` + +- [ ] **Step 6: Implement `predicate_holds`** + +In `crates/aura-campaign/src/lib.rs`, replace: + +```rust +fn predicate_holds(cmp: &Cmp, value: f64, threshold: f64) -> bool { + todo!("cycle 0107 task 4") +} +``` + +with: + +```rust +fn predicate_holds(cmp: &Cmp, value: f64, threshold: f64) -> bool { + match cmp { + Cmp::Gt => value > threshold, + Cmp::Ge => value >= threshold, + Cmp::Lt => value < threshold, + Cmp::Le => value <= threshold, + } +} +``` + +- [ ] **Step 7: Run the tests GREEN** + +Run: `cargo test -p aura-campaign` +Expected: `test result: ok. 4 passed; 0 failed`. + +- [ ] **Step 8: Workspace build gate** + +Run: `cargo build --workspace` +Expected: builds with 0 errors and no warnings from `aura-campaign` (the `#[allow(dead_code)]` on `predicate_holds` covers its build-time-unreferenced state; a later task's `execute` consumes it and drops the allow). + +### Task 5: aura-campaign preflight + +*Task-order note: REQUIRES Task 1's `aura-research` schema correction in the working tree — the tests here construct `StageBlock::WalkForward { in_sample_ms, out_of_sample_ms, step_ms, mode, metric, select }` and import `WfMode`, both introduced by Task 1. Also requires Task 4 (this task edits the crate Task 4 creates).* + +**Files:** +- Modify: `crates/aura-campaign/src/lib.rs` (created by Task 4: the `use aura_research` line near the top; insertion after `predicate_holds`; appends inside `mod tests`) +- Test: `crates/aura-campaign/src/lib.rs` (same in-file `#[cfg(test)] mod tests`) + +- [ ] **Step 1: Add the `preflight` stub (and the doc-type imports its signature needs)** + +In `crates/aura-campaign/src/lib.rs`, replace: + +```rust +use aura_research::{Axis, Cmp}; +``` + +with: + +```rust +use aura_research::{Axis, CampaignDoc, Cmp, ProcessDoc}; +``` + +Then, in the same file, replace: + +```rust +fn predicate_holds(cmp: &Cmp, value: f64, threshold: f64) -> bool { + match cmp { + Cmp::Gt => value > threshold, + Cmp::Ge => value >= threshold, + Cmp::Lt => value < threshold, + Cmp::Le => value <= threshold, + } +} +``` + +with: + +```rust +fn predicate_holds(cmp: &Cmp, value: f64, threshold: f64) -> bool { + match cmp { + Cmp::Gt => value > threshold, + Cmp::Ge => value >= threshold, + Cmp::Lt => value < threshold, + Cmp::Le => value <= threshold, + } +} + +/// 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). +// Consumed by `execute` (a later task of this plan drops the allow). +#[allow(dead_code)] +pub(crate) fn preflight(process: &ProcessDoc, _campaign: &CampaignDoc) -> Result<(), ExecFault> { + todo!("cycle 0107 task 5") +} +``` + +- [ ] **Step 2: Append the RED preflight tests to the tests module** + +In `crates/aura-campaign/src/lib.rs`, replace: + +```rust + assert!(predicate_holds(&Cmp::Le, 0.0, 0.0)); + assert!(!predicate_holds(&Cmp::Le, 0.1, 0.0)); + } +} +``` + +with: + +```rust + assert!(predicate_holds(&Cmp::Le, 0.0, 0.0)); + assert!(!predicate_holds(&Cmp::Le, 0.1, 0.0)); + } + + // ----------------------------------------------------------------- + // preflight + // ----------------------------------------------------------------- + + use aura_core::{Scalar, ScalarKind}; + use aura_research::{ + DataSection, DocKind, DocRef, Predicate, Presentation, ProcessRef, SelectRule, + StageBlock, StrategyEntry, WfMode, Window, + }; + + fn process_of(pipeline: Vec) -> ProcessDoc { + ProcessDoc { + format_version: 1, + kind: DocKind::Process, + name: "p".to_string(), + description: None, + pipeline, + } + } + + /// A minimal intrinsically-valid campaign; preflight's campaign parameter + /// carries no v1 rules, so one fixture serves every test. + fn campaign() -> CampaignDoc { + CampaignDoc { + format_version: 1, + kind: DocKind::Campaign, + name: "c".to_string(), + description: None, + data: DataSection { + instruments: vec!["EURUSD".to_string()], + windows: vec![Window { from_ms: 0, to_ms: 10_000 }], + }, + strategies: vec![StrategyEntry { + r#ref: DocRef::ContentId("0".repeat(64)), + axes: BTreeMap::from([( + "len".to_string(), + Axis { + kind: ScalarKind::I64, + values: vec![Scalar::i64(2), Scalar::i64(3)], + }, + )]), + }], + process: ProcessRef { r#ref: DocRef::ContentId("1".repeat(64)) }, + seed: 7, + presentation: Presentation { persist_taps: vec![], emit: vec![] }, + } + } + + fn sweep_stage(metric: &str, select: SelectRule, deflate: bool) -> StageBlock { + StageBlock::Sweep { metric: metric.to_string(), select, deflate } + } + + fn gate_stage(metric: &str) -> StageBlock { + StageBlock::Gate { + all: vec![Predicate { metric: metric.to_string(), cmp: Cmp::Gt, value: 0.0 }], + } + } + + 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, + } + } + + #[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()); + } + + #[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" + )); + } + + #[test] + fn preflight_refuses_non_sweep_first_and_double_sweep() { + let c = campaign(); + let gate_first = process_of(vec![gate_stage("expectancy_r")]); + assert!(matches!(preflight(&gate_first, &c), Err(ExecFault::PipelineShape { .. }))); + let empty = process_of(vec![]); + assert!(matches!(preflight(&empty, &c), Err(ExecFault::PipelineShape { .. }))); + let double = process_of(vec![ + sweep_stage("sqn", SelectRule::Argmax, false), + sweep_stage("sqn", SelectRule::Argmax, false), + ]); + assert!(matches!(preflight(&double, &c), Err(ExecFault::PipelineShape { .. }))); + // walk_forward anywhere but last is a shape refusal too. + let wf_mid = process_of(vec![ + sweep_stage("sqn", SelectRule::Argmax, false), + wf_stage("sqn", SelectRule::Argmax), + gate_stage("expectancy_r"), + ]); + assert!(matches!(preflight(&wf_mid, &c), Err(ExecFault::PipelineShape { .. }))); + } + + #[test] + fn preflight_refuses_unrankable_select_metric() { + let c = campaign(); + // win_rate is a per-member scalar but NOT in the registry's rankable roster. + let sweep_bad = process_of(vec![sweep_stage("win_rate", SelectRule::Argmax, false)]); + assert!(matches!( + preflight(&sweep_bad, &c), + Err(ExecFault::UnrankableMetric { stage: 0, metric }) if metric == "win_rate" + )); + let wf_bad = process_of(vec![ + sweep_stage("sqn", SelectRule::Argmax, false), + wf_stage("profit_factor", SelectRule::Argmax), + ]); + assert!(matches!( + preflight(&wf_bad, &c), + Err(ExecFault::UnrankableMetric { stage: 1, metric }) if metric == "profit_factor" + )); + } + + #[test] + fn preflight_refuses_annotation_gate_metric() { + let c = campaign(); + let p = process_of(vec![ + sweep_stage("sqn", SelectRule::Argmax, false), + gate_stage("deflated_score"), + ]); + assert!(matches!( + preflight(&p, &c), + Err(ExecFault::GateMetricNotPerMember { stage: 1, metric }) if metric == "deflated_score" + )); + } + + #[test] + fn preflight_refuses_plateau_in_walk_forward() { + let c = campaign(); + for select in [SelectRule::PlateauMean, SelectRule::PlateauWorst] { + let p = process_of(vec![ + sweep_stage("sqn", SelectRule::Argmax, false), + wf_stage("sqn", select), + ]); + assert!(matches!( + preflight(&p, &c), + Err(ExecFault::PlateauInWalkForward { stage: 1 }) + )); + } + } + + #[test] + fn preflight_refuses_deflate_with_plateau() { + let c = campaign(); + for select in [SelectRule::PlateauMean, SelectRule::PlateauWorst] { + let p = process_of(vec![sweep_stage("sqn", select, true)]); + assert!(matches!( + preflight(&p, &c), + Err(ExecFault::DeflatePlateauConflict { stage: 0 }) + )); + } + } +} +``` + +- [ ] **Step 3: Run the preflight tests RED** + +Run: `cargo test -p aura-campaign preflight` +Expected: compiles; 7 tests run and all FAIL — `0 passed; 7 failed`, each panicking with `not yet implemented: cycle 0107 task 5`. (An unused-parameter warning on the stub's `process` is expected at this step only.) + +- [ ] **Step 4: Implement `preflight`** + +In `crates/aura-campaign/src/lib.rs`, replace: + +```rust +use aura_research::{Axis, CampaignDoc, Cmp, ProcessDoc}; +``` + +with: + +```rust +use aura_research::{Axis, CampaignDoc, Cmp, ProcessDoc, SelectRule, StageBlock}; +``` + +Then, in the same file, replace: + +```rust +pub(crate) fn preflight(process: &ProcessDoc, _campaign: &CampaignDoc) -> Result<(), ExecFault> { + todo!("cycle 0107 task 5") +} +``` + +with: + +```rust +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(()) +} +``` + +- [ ] **Step 5: Run the preflight tests GREEN** + +Run: `cargo test -p aura-campaign preflight` +Expected: `7 passed; 0 failed`. + +- [ ] **Step 6: Run the whole crate's suite** + +Run: `cargo test -p aura-campaign` +Expected: `test result: ok. 11 passed; 0 failed` (Task 4's 4 tests + this task's 7). + +- [ ] **Step 7: Workspace build gate** + +Run: `cargo build --workspace` +Expected: builds with 0 errors and no warnings from `aura-campaign` (the `#[allow(dead_code)]` on `preflight` covers its build-time-unreferenced state until `execute` lands in a later task). +### Task 6: aura-campaign execute — cell loop, sweep stage, gate stage, realization record + +**Files:** +- Create: `crates/aura-campaign/tests/execute.rs` +- Create: `crates/aura-campaign/src/exec.rs` +- Modify: `crates/aura-campaign/src/lib.rs` (2-line module splice directly after the crate-level `//!` doc block; the file was created by the previous task, so no line anchor is given — the anchor is "after the leading `//!` lines, before the first `use` or item") +- Test: `crates/aura-campaign/tests/execute.rs` + +**Tree state assumed (established by earlier tasks in this plan — verify by reading `crates/aura-campaign/src/lib.rs` before starting):** + +- `crates/aura-campaign` is a workspace member with dependencies `aura-core`, `aura-engine`, `aura-analysis`, `aura-registry`, `aura-research`, `serde` (derive), `serde_json`. +- `crates/aura-campaign/src/lib.rs` defines at the crate root: `pub struct CellSpec { pub strategy_ordinal: usize, pub strategy_id: String, pub blueprint_json: String, pub axes: BTreeMap, pub instrument: String, pub window_ms: (i64, i64) }`; `pub trait MemberRunner: Sync { fn run_member(&self, cell: &CellSpec, params: &[(String, Scalar)], window_ms: (i64, i64)) -> Result; }`; `pub enum MemberFault` (derives `Clone, Debug, PartialEq`; variants `NoData { instrument, window_ms }`, `Bind(String)`, `Run(String)`); `pub enum ExecFault` (derives `Debug`; variants `UnsupportedStage`, `PipelineShape { detail: String }`, `UnrankableMetric`, `GateMetricNotPerMember`, `PlateauInWalkForward`, `DeflatePlateauConflict`, `Window`, `Member(MemberFault)`, `Registry(RegistryError)`); `pub const PER_MEMBER_METRICS`, `pub const RANKABLE_METRICS`, `pub const DEFLATION_N_RESAMPLES: usize = 1000`, `pub const DEFLATION_BLOCK_LEN: usize = 5`; `pub fn member_metric(report: &RunReport, name: &str) -> Option`; and two crate-root helpers — `pub(crate) fn preflight(process: &ProcessDoc, _campaign: &CampaignDoc) -> Result<(), ExecFault>`, which ADMITS the v1 pipeline shape `std::sweep (std::gate)* (std::walk_forward)?` (it refuses mc/generalize, non-rankable metrics, annotation-metric gates, plateau-in-wf, deflate+plateau), and a private `fn predicate_holds(cmp: &Cmp, value: f64, threshold: f64) -> bool` — both still carrying `#[allow(dead_code)]` plus a forward-pointing comment (this task consumes them and drops both allows, Step 5). If either helper's name differs in the scaffold, adapt the call sites in `exec.rs` to the existing name — never re-implement the checks. Private crate-root items are reachable from a child module via `crate::…`. +- `aura_engine::ListSpace` exists (explicit point set implementing `Space`; `ListSpace::new(space: &[ParamSpec], points: Vec>) -> Result`) and is re-exported from `aura-engine`'s lib.rs. +- `aura_registry` root exports `CampaignRunRecord`, `CellRealization`, `StageRealization`, `StageSelection` and `Registry::{append_campaign_run, load_campaign_runs}` (from the registry task), beside the existing `optimize` / `optimize_plateau` / `optimize_deflated` / `PlateauMode` / `FamilyKind` / `sweep_member_reports` / `append_family` / `load_family_members`. + +**Handoff to Task 7 (walk-forward):** this task's `run_cell` routes `StageBlock::WalkForward { .. }` through a private fn `run_walk_forward_stage(seed, cell, stage, block, specs, survivors, family_name, runner, registry) -> Result` — `survivors` crosses the seam as plain `&[Vec]` (the surviving param points in enumeration order) — whose body here is the `let _ = (...)` parameter-silencing line plus `Err(ExecFault::PipelineShape { detail: "walk_forward execution lands in the next task".into() })`. The call site constructs and pushes the stage's `StageRealization` itself, so the seam returns `StageFamily` only. Task 7 replaces the WHOLE fn (doc comment, placeholder line, and body); the `#[allow(clippy::too_many_arguments)]` and the signature stay byte-identical. Preflight ALLOWS walk_forward — the refusal is the seam body, not a preflight rule. Every test in THIS task uses pipelines WITHOUT a walk_forward stage. + +- [ ] **Step 1: Write the RED tests — hermetic executor semantics over a fake MemberRunner** + +Create `crates/aura-campaign/tests/execute.rs` with exactly: + +```rust +//! Executor semantics over a fake `MemberRunner` (hermetic — no engine +//! harness, no data): cell loop, sweep stage, gate filtering, zero-survivor +//! truncation, determinism, fault attribution, deflation seeding. + +use std::collections::BTreeMap; + +use aura_campaign::{execute, CellSpec, ExecFault, MemberFault, MemberRunner}; +use aura_core::{Scalar, ScalarKind, Timestamp}; +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, +}; + +const CAMPAIGN_ID: &str = "aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999"; +const PROCESS_ID: &str = "9999888877776666555544443333222211110000ffffeeeeddddccccbbbbaaaa"; +const STRATEGY_ID: &str = "1111222233334444555566667777888899990000aaaabbbbccccddddeeeeffff"; + +/// Deterministic fake: every planted metric is a pure function of +/// (params, instrument). `fast`/`slow` are the two I64 axes; +/// net = (fast*10 + slow)/100, negated for the instrument named "AAA". +struct FakeRunner { + /// (params that fault, the fault) — checked before planting a report. + faults: Vec<(Vec<(String, Scalar)>, MemberFault)>, +} + +impl FakeRunner { + fn clean() -> Self { + FakeRunner { faults: Vec::new() } + } +} + +fn param_i64(params: &[(String, Scalar)], name: &str) -> i64 { + params + .iter() + .find(|(n, _)| n == name) + .map(|(_, v)| v.as_i64()) + .expect("planted axis present") +} + +fn planted_report(cell: &CellSpec, params: &[(String, Scalar)], window_ms: (i64, i64)) -> RunReport { + let fast = param_i64(params, "fast"); + let slow = param_i64(params, "slow"); + let mut net = (fast * 10 + slow) as f64 / 100.0; + if cell.instrument == "AAA" { + net = -net; + } + RunReport { + manifest: RunManifest { + commit: "fake".to_string(), + params: params.to_vec(), + window: (Timestamp(window_ms.0), Timestamp(window_ms.1)), + seed: 0, + broker: "fake".to_string(), + selection: None, + instrument: Some(cell.instrument.clone()), + topology_hash: Some(cell.strategy_id.clone()), + project: None, + }, + metrics: RunMetrics { + total_pips: net * 100.0, + max_drawdown: 1.0, + bias_sign_flips: 1, + r: Some(RMetrics { + expectancy_r: net, + n_trades: 4, + win_rate: 0.5, + avg_win_r: 1.0, + avg_loss_r: -0.5, + profit_factor: 2.0, + max_r_drawdown: 0.5, + n_open_at_end: 0, + sqn: net, + sqn_normalized: net, + net_expectancy_r: net, + conviction_terciles_r: [0.0, 0.0, 0.0], + trade_rs: Vec::new(), + }), + }, + } +} + +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)) + } +} + +/// fast in {2,3}, slow in {6,9} -> 4 odometer points (last axis fastest): +/// 0:(2,6) net .26 | 1:(2,9) net .29 | 2:(3,6) net .36 | 3:(3,9) net .39. +fn axes_2x2() -> BTreeMap { + let mut axes = BTreeMap::new(); + axes.insert( + "fast".to_string(), + Axis { kind: ScalarKind::I64, values: vec![Scalar::i64(2), Scalar::i64(3)] }, + ); + axes.insert( + "slow".to_string(), + Axis { kind: ScalarKind::I64, values: vec![Scalar::i64(6), Scalar::i64(9)] }, + ); + axes +} + +fn campaign(instruments: &[&str]) -> CampaignDoc { + CampaignDoc { + format_version: 1, + kind: DocKind::Campaign, + name: "exec-test".to_string(), + description: None, + data: DataSection { + instruments: instruments.iter().map(|s| s.to_string()).collect(), + windows: vec![Window { from_ms: 1_000, to_ms: 9_000 }], + }, + strategies: vec![StrategyEntry { + r#ref: DocRef::ContentId(STRATEGY_ID.to_string()), + axes: axes_2x2(), + }], + process: ProcessRef { r#ref: DocRef::ContentId(PROCESS_ID.to_string()) }, + seed: 7, + presentation: Presentation { persist_taps: vec![], emit: vec![] }, + } +} + +fn sweep_stage(deflate: bool) -> StageBlock { + StageBlock::Sweep { + metric: "net_expectancy_r".to_string(), + select: SelectRule::Argmax, + deflate, + } +} + +fn gate_stage(value: f64) -> StageBlock { + StageBlock::Gate { + all: vec![Predicate { metric: "net_expectancy_r".to_string(), cmp: Cmp::Gt, value }], + } +} + +fn process(pipeline: Vec) -> ProcessDoc { + ProcessDoc { + format_version: 1, + kind: DocKind::Process, + name: "exec-test-process".to_string(), + description: None, + pipeline, + } +} + +fn strategies() -> Vec<(String, String)> { + vec![(STRATEGY_ID.to_string(), r#"{"format_version":1}"#.to_string())] +} + +/// Per-test registry DIRECTORY (family/campaign stores are per-directory +/// siblings of the runs path — the aura-registry temp-dir idiom). +fn temp_registry(name: &str) -> Registry { + let dir = std::env::temp_dir() + .join(format!("aura-campaign-exec-{}-{}", std::process::id(), name)); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("create temp registry dir"); + Registry::open(dir.join("runs.jsonl")) +} + +#[test] +fn execute_sweep_only_records_family_and_selection() { + let reg = temp_registry("sweep_only"); + let doc = campaign(&["EURUSD"]); + let proc_doc = process(vec![sweep_stage(false)]); + let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®) + .expect("sweep-only campaign executes"); + + // outcome payloads: one cell, one family of 4 members, one selection + assert_eq!(out.run, 0); + assert_eq!(out.cells.len(), 1); + assert_eq!(out.cells[0].families.len(), 1); + assert_eq!(out.cells[0].families[0].reports.len(), 4); + let expected_family_id = format!("{}-0-EURUSD-w0-s0-0", &CAMPAIGN_ID[..8]); + assert_eq!(out.cells[0].families[0].family_id, expected_family_id); + + // planted argmax: (3,9) is odometer point 3 (last axis fastest) + assert_eq!(out.cells[0].selections.len(), 1); + let sel = &out.cells[0].selections[0]; + assert_eq!(sel.winner_ordinal, 3); + assert_eq!( + sel.params, + vec![("fast".to_string(), Scalar::i64(3)), ("slow".to_string(), Scalar::i64(9))], + ); + + // family persisted: 4 sweep members under the derived id, ordinal-ordered + let members = reg.load_family_members().expect("load members"); + assert_eq!(members.len(), 4); + assert!(members.iter().all(|m| m.kind == FamilyKind::Sweep)); + assert!(members.iter().all(|m| m.family_id() == expected_family_id)); + assert_eq!(members.iter().map(|m| m.ordinal).collect::>(), vec![0, 1, 2, 3]); + + // realization persisted: one record linking the family + the selection + let runs = reg.load_campaign_runs().expect("load campaign runs"); + assert_eq!(runs.len(), 1); + assert_eq!(runs[0], out.record); + assert_eq!(runs[0].campaign, CAMPAIGN_ID); + assert_eq!(runs[0].process, PROCESS_ID); + assert_eq!(runs[0].run, 0); + assert_eq!(runs[0].seed, 7); + assert_eq!(runs[0].cells.len(), 1); + let cell = &runs[0].cells[0]; + assert_eq!(cell.strategy, STRATEGY_ID); + assert_eq!(cell.instrument, "EURUSD"); + assert_eq!(cell.window_ms, (1_000, 9_000)); + assert_eq!(cell.stages.len(), 1); + assert_eq!(cell.stages[0].block, "std::sweep"); + assert_eq!(cell.stages[0].family_id.as_deref(), Some(expected_family_id.as_str())); + let stored = cell.stages[0].selection.as_ref().expect("sweep stage carries a selection"); + assert_eq!(stored.winner_ordinal, 3); + assert_eq!(stored.selection.mode, SelectionMode::Argmax); + assert_eq!(stored.selection.n_trials, 4); + assert!((stored.selection.raw_winner_metric - 0.39).abs() < 1e-12); + assert_eq!(stored.selection.seed, None, "no deflation annotation without deflate"); +} + +#[test] +fn execute_gate_filters_per_member() { + let reg = temp_registry("gate_filters"); + let doc = campaign(&["EURUSD"]); + let proc_doc = process(vec![sweep_stage(false), gate_stage(0.3)]); + let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®) + .expect("gated campaign executes"); + + // planted nets 0.26 / 0.29 / 0.36 / 0.39 -> gt 0.3 keeps ordinals 2 and 3 + let cell = &out.record.cells[0]; + assert_eq!(cell.stages.len(), 2); + assert_eq!(cell.stages[1].block, "std::gate"); + assert_eq!(cell.stages[1].survivor_ordinals, Some(vec![2, 3])); + assert_eq!(cell.stages[1].family_id, None); + assert_eq!(cell.stages[1].selection, None); +} + +#[test] +fn execute_zero_survivors_truncates_cell_and_continues() { + let reg = temp_registry("zero_survivors"); + // two instruments: the fake plants NEGATIVE nets for "AAA", positive for "BBB" + let doc = campaign(&["AAA", "BBB"]); + // sweep -> gate(net > 0) -> gate(net > -1000): AAA dies at stage 1, BBB passes both + let proc_doc = process(vec![sweep_stage(false), gate_stage(0.0), gate_stage(-1000.0)]); + let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®) + .expect("a zero-survivor cell is a valid result, not a fault"); + + assert_eq!(out.record.cells.len(), 2, "the second cell still runs"); + // cell 0 (AAA): realization truncated AT the empty gate — stage 2 never realized + let aaa = &out.record.cells[0]; + assert_eq!(aaa.instrument, "AAA"); + assert_eq!(aaa.stages.len(), 2); + assert_eq!(aaa.stages[1].survivor_ordinals, Some(vec![])); + // cell 1 (BBB): full pipeline realized + let bbb = &out.record.cells[1]; + assert_eq!(bbb.instrument, "BBB"); + assert_eq!(bbb.stages.len(), 3); + assert_eq!(bbb.stages[1].survivor_ordinals, Some(vec![0, 1, 2, 3])); + assert_eq!(bbb.stages[2].survivor_ordinals, Some(vec![0, 1, 2, 3])); +} + +#[test] +fn execute_is_deterministic_twice() { + let reg = temp_registry("deterministic_twice"); + let doc = campaign(&["EURUSD"]); + let proc_doc = process(vec![sweep_stage(false)]); + let first = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®) + .expect("first run"); + let second = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®) + .expect("second run"); + + // run counter advances per campaign id + assert_eq!((first.run, second.run), (0, 1)); + assert_eq!((first.record.run, second.record.run), (0, 1)); + + // family ids differ ONLY by the "-{run}" suffix + let base = format!("{}-0-EURUSD-w0-s0", &CAMPAIGN_ID[..8]); + assert_eq!(first.cells[0].families[0].family_id, format!("{base}-0")); + assert_eq!(second.cells[0].families[0].family_id, format!("{base}-1")); + + // everything else in the record is identical (C1): normalize the two + // divergent fields and compare whole records + let mut normalized = second.record.clone(); + normalized.run = first.record.run; + normalized.cells[0].stages[0].family_id = first.record.cells[0].stages[0].family_id.clone(); + assert_eq!(normalized, first.record); + + // both records stored, in order + let runs = reg.load_campaign_runs().expect("load campaign runs"); + assert_eq!(runs.len(), 2); + assert_eq!(runs[0], first.record); + assert_eq!(runs[1], second.record); +} + +#[test] +fn execute_member_fault_aborts_with_lowest_index() { + let reg = temp_registry("member_fault"); + let doc = campaign(&["EURUSD"]); + let proc_doc = process(vec![sweep_stage(false)]); + // faults planted at enumeration indices 1 (2,9) and 2 (3,6) + let point = |fast: i64, slow: i64| { + vec![("fast".to_string(), Scalar::i64(fast)), ("slow".to_string(), Scalar::i64(slow))] + }; + let runner = FakeRunner { + faults: vec![ + (point(2, 9), MemberFault::Run("boom at index 1".to_string())), + (point(3, 6), MemberFault::Run("boom at index 2".to_string())), + ], + }; + let err = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &runner, ®) + .expect_err("a faulted member aborts the run"); + match err { + ExecFault::Member(fault) => assert_eq!( + fault, + MemberFault::Run("boom at index 1".to_string()), + "the LOWEST enumeration index's fault is reported", + ), + other => panic!("expected ExecFault::Member, got {other:?}"), + } + // the fault aborted before any family or realization write + assert!(reg.load_family_members().expect("load members").is_empty()); + assert!(reg.load_campaign_runs().expect("load campaign runs").is_empty()); +} + +#[test] +fn execute_deflate_uses_campaign_seed() { + let reg = temp_registry("deflate_seed"); + let doc = campaign(&["EURUSD"]); // seed: 7 + let proc_doc = process(vec![sweep_stage(true)]); + let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®) + .expect("deflated sweep executes"); + let sel = out.record.cells[0].stages[0].selection.as_ref().expect("selection recorded"); + assert_eq!(sel.selection.mode, SelectionMode::Argmax); + assert_eq!(sel.selection.seed, Some(7), "deflation nulls seed from the campaign doc"); + assert_eq!(sel.selection.n_resamples, Some(1000)); + assert_eq!(sel.selection.block_len, Some(5)); +} +``` + +- [ ] **Step 2: Run the RED gate** + +Run: `cargo test -p aura-campaign execute` +Expected: FAILS to compile — `error[E0432]: unresolved import` naming `execute` (not yet in `aura_campaign`); no tests run. This is the RED evidence for the new API. + +- [ ] **Step 3: Implement the executor module** + +Create `crates/aura-campaign/src/exec.rs` with exactly: + +```rust +//! Campaign execution (#198): the cell loop over strategy x instrument x +//! window, the v1 stage semantics (sweep members -> family -> selection; +//! per-member gate filtering; the walk-forward seam), and the realization +//! record. Precondition: the CALLER has run the doc tiers (intrinsic + +//! referential validation), so this module re-checks nothing a validated +//! document guarantees (e.g. non-empty axes); execution-level refusals live +//! in the crate's preflight, which `execute` runs before any member runs. + +use std::collections::BTreeMap; +use std::sync::Mutex; + +use aura_analysis::{FamilySelection, SelectionMode}; +use aura_core::{zip_params, Cell, ParamSpec, Scalar, Timestamp}; +use aura_engine::{sweep, ListSpace, RunManifest, RunMetrics, RunReport, SweepFamily, SweepPoint}; +use aura_registry::{ + optimize, optimize_deflated, optimize_plateau, sweep_member_reports, CampaignRunRecord, + CellRealization, FamilyKind, PlateauMode, Registry, StageRealization, StageSelection, +}; +use aura_research::{Axis, CampaignDoc, DocRef, ProcessDoc, SelectRule, StageBlock}; + +use crate::{ + member_metric, predicate_holds, preflight, CellSpec, ExecFault, MemberFault, MemberRunner, + DEFLATION_BLOCK_LEN, DEFLATION_N_RESAMPLES, +}; + +/// One family-producing stage's full member payload — what an emit renderer +/// (`family_table`) prints, in ordinal order. +#[derive(Clone, Debug)] +pub struct StageFamily { + pub stage: usize, + pub block: &'static str, + pub family_id: String, + pub reports: Vec, +} + +/// One selection-bearing stage's payload — what an emit renderer +/// (`selection_report`) prints. +#[derive(Clone, Debug)] +pub struct StageSelectionOut { + pub stage: usize, + pub block: &'static str, + pub family_id: String, + pub winner_ordinal: usize, + pub params: Vec<(String, Scalar)>, + pub selection: FamilySelection, +} + +/// Per-cell stage payloads, for the consumer's emit rendering. +#[derive(Clone, Debug)] +pub struct CellOutcome { + pub families: Vec, + pub selections: Vec, +} + +/// Everything one campaign run produced: the stored realization record, the +/// assigned run counter, and the per-cell payloads. +#[derive(Clone, Debug)] +pub struct CampaignOutcome { + pub record: CampaignRunRecord, + pub run: usize, + pub cells: Vec, +} + +/// Execute a validated campaign document's process pipeline once per +/// (strategy, instrument, window) cell, in doc order, sequentially (C1: +/// parallelism lives inside the engine `sweep`, across members, never across +/// cells). `campaign_id` is the campaign document's 64-hex content id (its +/// first 8 chars prefix family names); `strategies` is index-aligned with +/// `campaign.strategies` as (blueprint content id, canonical blueprint json). +/// Writes one family per family-producing stage plus one campaign-run record; +/// a `MemberFault` aborts the whole run before any write of the faulted stage. +pub fn execute( + campaign_id: &str, + campaign: &CampaignDoc, + process: &ProcessDoc, + strategies: &[(String, String)], + runner: &dyn MemberRunner, + registry: &Registry, +) -> Result { + preflight(process, campaign)?; + if strategies.len() != campaign.strategies.len() { + return Err(ExecFault::PipelineShape { + detail: format!( + "resolved strategies ({}) are not index-aligned with campaign.strategies ({})", + strategies.len(), + campaign.strategies.len(), + ), + }); + } + let process_id = match &campaign.process.r#ref { + DocRef::ContentId(id) | DocRef::IdentityId(id) => id.clone(), + }; + let campaign_prefix = &campaign_id[..8]; + + let mut cells_out: Vec = Vec::new(); + let mut cells_rec: Vec = Vec::new(); + for (strategy_ordinal, (entry, (strategy_id, blueprint_json))) in + campaign.strategies.iter().zip(strategies).enumerate() + { + for instrument in &campaign.data.instruments { + for (window_ordinal, window) in campaign.data.windows.iter().enumerate() { + let cell = CellSpec { + strategy_ordinal, + strategy_id: strategy_id.clone(), + blueprint_json: blueprint_json.clone(), + axes: entry.axes.clone(), + instrument: instrument.clone(), + window_ms: (window.from_ms, window.to_ms), + }; + let (outcome, realization) = run_cell( + &cell, + process, + campaign_prefix, + window_ordinal, + campaign.seed, + runner, + registry, + )?; + cells_out.push(outcome); + cells_rec.push(realization); + } + } + } + + let mut record = CampaignRunRecord { + campaign: campaign_id.to_string(), + process: process_id, + run: 0, + seed: campaign.seed, + cells: cells_rec, + }; + let run = registry.append_campaign_run(&record).map_err(ExecFault::Registry)?; + record.run = run; + Ok(CampaignOutcome { record, run, cells: cells_out }) +} + +/// Run one cell's pipeline: stages in doc order, a realized prefix that stops +/// at an empty gate (decision 8 — the cell truncates, the campaign continues). +fn run_cell( + cell: &CellSpec, + process: &ProcessDoc, + campaign_prefix: &str, + window_ordinal: usize, + seed: u64, + runner: &dyn MemberRunner, + registry: &Registry, +) -> Result<(CellOutcome, CellRealization), ExecFault> { + let grid = enumerate_grid(&cell.axes); + let mut families: Vec = Vec::new(); + let mut selections: Vec = Vec::new(); + let mut stages: Vec = Vec::new(); + // 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(); + + for (stage_ordinal, stage) in process.pipeline.iter().enumerate() { + // Deterministic, self-describing family name (the "-{run}" suffix is + // appended by the registry). + let family_name = format!( + "{campaign_prefix}-{}-{}-w{window_ordinal}-s{stage_ordinal}", + cell.strategy_ordinal, cell.instrument, + ); + match stage { + StageBlock::Sweep { metric, select, deflate } => { + let family = run_members(cell, &grid, runner)?; + let family_id = registry + .append_family(&family_name, FamilyKind::Sweep, &sweep_member_reports(&family)) + .map_err(ExecFault::Registry)?; + let (winner, selection) = + select_sweep_winner(&family, &grid.axis_lens, metric, *select, *deflate, seed)?; + let winner_ordinal = family + .points + .iter() + .position(|p| p == &winner) + .expect("the winner is a member of its own family"); + let params = zip_params(&family.space, &winner.params); + 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 { + block: "std::sweep".to_string(), + family_id: Some(family_id.clone()), + survivor_ordinals: None, + selection: Some(StageSelection { winner_ordinal, params, selection }), + }); + // The whole family flows on (decision 3: `select` names the + // recorded selection, never a filter). + survivors = family + .points + .iter() + .enumerate() + .map(|(i, p)| (i, scalar_point(&family.space, &p.params), p.report.clone())) + .collect(); + families.push(StageFamily { + stage: stage_ordinal, + block: "std::sweep", + family_id, + reports: family.points.into_iter().map(|p| p.report).collect(), + }); + } + StageBlock::Gate { all } => { + // A member survives iff ALL predicates hold; the crate-root + // `predicate_holds` compares the resolved per-member metric. + // An unresolvable metric (an R name against `metrics.r == + // None`) fails the member — conservative and deterministic + // (the metric NAME was already preflighted against + // `PER_MEMBER_METRICS`). + survivors.retain(|(_, _, report)| { + all.iter().all(|p| { + member_metric(report, &p.metric) + .is_some_and(|value| predicate_holds(&p.cmp, value, p.value)) + }) + }); + let ordinals: Vec = survivors.iter().map(|(i, _, _)| *i).collect(); + let empty = ordinals.is_empty(); + stages.push(StageRealization { + block: "std::gate".to_string(), + family_id: None, + survivor_ordinals: Some(ordinals), + selection: None, + }); + if empty { + break; // decision 8: truncate this cell, keep running cells + } + } + StageBlock::WalkForward { .. } => { + // The seam crossing is plain points: the walk-forward stage + // re-sweeps the surviving param points and never needs the + // gate bookkeeping's ordinals or reports. + let survivor_points: Vec> = + survivors.iter().map(|(_, point, _)| point.clone()).collect(); + let fam = run_walk_forward_stage( + seed, + cell, + stage_ordinal, + stage, + &grid.specs, + &survivor_points, + &family_name, + runner, + registry, + )?; + stages.push(StageRealization { + block: "std::walk_forward".to_string(), + family_id: Some(fam.family_id.clone()), + survivor_ordinals: None, + selection: None, + }); + families.push(fam); + } + StageBlock::MonteCarlo { .. } | StageBlock::Generalize { .. } => { + unreachable!("preflight refuses non-v1 stages before any member runs") + } + } + } + + Ok(( + CellOutcome { families, selections }, + CellRealization { + strategy: cell.strategy_id.clone(), + instrument: cell.instrument.clone(), + window_ms: cell.window_ms, + stages, + }, + )) +} + +/// The enumerated sweep grid of one cell: param specs (axis name = key, kind = +/// axis kind) in BTreeMap (lexicographic) key order, the per-axis radixes, and +/// the odometer point list — LAST axis fastest, the `GridSpace` order +/// discipline (`optimize_plateau` reads `axis_lens` in exactly this order). +struct SweepGrid { + specs: Vec, + axis_lens: Vec, + points: Vec>, +} + +/// Odometer enumeration over the axes map. Mirrors `GridSpace::points` (last +/// axis fastest); zero axes yield the single empty point. Empty VALUE lists +/// cannot reach here (`validate_campaign` refuses `EmptyAxis` upstream). +fn enumerate_grid(axes: &BTreeMap) -> SweepGrid { + let specs: Vec = axes + .iter() + .map(|(name, axis)| ParamSpec { name: name.clone(), kind: axis.kind }) + .collect(); + let values: Vec<&[Scalar]> = axes.values().map(|axis| axis.values.as_slice()).collect(); + let axis_lens: Vec = values.iter().map(|v| v.len()).collect(); + let mut points = Vec::new(); + let mut idx = vec![0usize; values.len()]; + loop { + points.push(idx.iter().zip(&values).map(|(&i, vals)| vals[i]).collect()); + // odometer increment from the last axis + let mut k = values.len(); + loop { + if k == 0 { + return SweepGrid { specs, axis_lens, points }; + } + k -= 1; + idx[k] += 1; + if idx[k] < values[k].len() { + break; + } + idx[k] = 0; + } + } +} + +/// Run every grid point through the consumer's `MemberRunner` via the engine +/// `sweep` (disjoint parallel members, C1 enumeration order). Faults are +/// captured per slot (the closure must return a report), and the LOWEST +/// enumeration index's fault aborts the stage after the sweep joins — +/// deterministic regardless of thread completion order. The placeholder +/// report for a faulted slot is never persisted or ranked: a captured fault +/// returns before any family write. +fn run_members( + cell: &CellSpec, + grid: &SweepGrid, + runner: &dyn MemberRunner, +) -> Result { + let space = ListSpace::new(&grid.specs, grid.points.clone()).map_err(|e| { + ExecFault::PipelineShape { + detail: format!("axis grid does not form a valid param space: {e:?}"), + } + })?; + // The engine closure receives only the point's cells; recover the + // enumeration index by value (duplicate points — possible only when an + // axis repeats a value — collapse to the first index, which is exactly + // the lowest-index attribution this capture exists for). + let cells: Vec> = + grid.points.iter().map(|p| p.iter().map(|s| s.cell()).collect()).collect(); + let faults: Mutex> = Mutex::new(Vec::new()); + let family = sweep(&space, |point: &[Cell]| { + let params = zip_params(&grid.specs, point); + match runner.run_member(cell, ¶ms, cell.window_ms) { + Ok(report) => report, + Err(fault) => { + let index = cells + .iter() + .position(|c| c.as_slice() == point) + .expect("the sweep only enumerates the grid's own points"); + faults.lock().expect("fault capture lock").push((index, fault)); + placeholder_report(¶ms, cell.window_ms) + } + } + }); + let mut captured = faults.into_inner().expect("fault capture lock"); + captured.sort_by_key(|&(index, _)| index); + if let Some((_, fault)) = captured.into_iter().next() { + return Err(ExecFault::Member(fault)); + } + Ok(family) +} + +/// Slot filler for a faulted member: the engine sweep contract needs one +/// report per slot, but a captured fault aborts `execute` before any family +/// write, so this report is never persisted and never ranked. +fn placeholder_report(params: &[(String, Scalar)], window_ms: (i64, i64)) -> RunReport { + RunReport { + manifest: RunManifest { + commit: String::new(), + params: params.to_vec(), + window: (Timestamp(window_ms.0), Timestamp(window_ms.1)), + seed: 0, + broker: "faulted-member-placeholder".to_string(), + selection: None, + instrument: None, + topology_hash: None, + project: None, + }, + metrics: RunMetrics { total_pips: 0.0, max_drawdown: 0.0, bias_sign_flips: 0, r: None }, + } +} + +/// The sweep stage's winner + selection provenance. deflate=true composes only +/// with argmax (preflighted: `DeflatePlateauConflict`), seeding the deflation +/// nulls from the CAMPAIGN seed (C1: a campaign's realization is a pure +/// function of the document). Bare argmax has no annotation to compute, so its +/// `FamilySelection` is synthesized annotation-free; `raw_winner_metric` +/// mirrors the registry's ranking read (an R metric against `r: None` ranks +/// `NEG_INFINITY`). +fn select_sweep_winner( + family: &SweepFamily, + axis_lens: &[usize], + metric: &str, + select: SelectRule, + deflate: bool, + seed: u64, +) -> Result<(SweepPoint, FamilySelection), ExecFault> { + match (select, deflate) { + (SelectRule::Argmax, true) => { + optimize_deflated(family, metric, DEFLATION_N_RESAMPLES, DEFLATION_BLOCK_LEN, seed) + .map_err(ExecFault::Registry) + } + (SelectRule::Argmax, false) => { + let winner = optimize(family, metric).map_err(ExecFault::Registry)?; + let raw = member_metric(&winner.report, metric).unwrap_or(f64::NEG_INFINITY); + let selection = FamilySelection { + selection_metric: metric.to_string(), + n_trials: family.points.len(), + raw_winner_metric: raw, + mode: SelectionMode::Argmax, + deflated_score: None, + overfit_probability: None, + n_resamples: None, + block_len: None, + seed: None, + neighbourhood_score: None, + n_neighbours: None, + }; + Ok((winner, selection)) + } + (SelectRule::PlateauMean, _) => { + optimize_plateau(family, axis_lens, metric, PlateauMode::Mean) + .map_err(ExecFault::Registry) + } + (SelectRule::PlateauWorst, _) => { + optimize_plateau(family, axis_lens, metric, PlateauMode::Worst) + .map_err(ExecFault::Registry) + } + } +} + +/// 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() +} + +/// Walk-forward stage seam — the next plan task replaces this WHOLE fn (doc +/// comment, the `let _ = (...)` placeholder line, and the refusal body); the +/// `#[allow(clippy::too_many_arguments)]` and the signature stay +/// byte-identical, so the call site in `run_cell` is untouched. `survivors` +/// are the surviving param points in enumeration order — the IS point set; +/// `family_name` is the stage's pre-formatted registry family name; `specs` +/// the param space the points are coordinates in; `seed` the campaign seed +/// (deflation nulls). Until then, a walk_forward-bearing pipeline executes up +/// to this stage (preflight ADMITS walk_forward) and refuses here. +#[allow(clippy::too_many_arguments)] +fn run_walk_forward_stage( + seed: u64, + cell: &CellSpec, + stage: usize, + block: &StageBlock, + specs: &[ParamSpec], + survivors: &[Vec], + family_name: &str, + runner: &dyn MemberRunner, + registry: &Registry, +) -> Result { + let _ = (seed, cell, stage, block, specs, survivors, family_name, runner, registry); + Err(ExecFault::PipelineShape { detail: "walk_forward execution lands in the next task".into() }) +} +``` + +- [ ] **Step 4: Wire the module into the crate root** + +Open `crates/aura-campaign/src/lib.rs`. Immediately AFTER the crate-level doc comment block (the leading `//!` lines) and BEFORE the first `use` statement or item, insert these two lines (plus a blank line after them): + +```rust +mod exec; +pub use exec::{execute, CampaignOutcome, CellOutcome, StageFamily, StageSelectionOut}; +``` + +Note: `exec.rs` consumes two scaffold helpers through its `use crate::{…}` import — `preflight` (signature `pub(crate) fn preflight(process: &ProcessDoc, _campaign: &CampaignDoc) -> Result<(), ExecFault>`) and `predicate_holds` (signature `fn predicate_holds(cmp: &Cmp, value: f64, threshold: f64) -> bool`). Private crate-root items ARE reachable from the `exec` child module. If the scaffold named either helper differently, change only the import/call sites in `exec.rs` to the existing names — do not re-implement the checks and do not change the helpers. + +- [ ] **Step 5: Drop the scaffold's dead-code allowances (now consumed)** + +`execute` now consumes both crate-root helpers, so the `#[allow(dead_code)]` markers and their forward-pointing comments come off — the scaffold's own comments demand exactly this of the task that lands `execute`. In `crates/aura-campaign/src/lib.rs`, replace: + +```rust +/// Whether `value threshold` holds — the gate's comparator arm. +// Consumed by `execute`'s gate stage (a later task of this plan drops the allow). +#[allow(dead_code)] +fn predicate_holds(cmp: &Cmp, value: f64, threshold: f64) -> bool { +``` + +with: + +```rust +/// Whether `value threshold` holds — the gate's comparator arm. +fn predicate_holds(cmp: &Cmp, value: f64, threshold: f64) -> bool { +``` + +then replace: + +```rust +/// campaign-level static checks (none in v1, hence unused). +// Consumed by `execute` (a later task of this plan drops the allow). +#[allow(dead_code)] +pub(crate) fn preflight(process: &ProcessDoc, _campaign: &CampaignDoc) -> Result<(), ExecFault> { +``` + +with: + +```rust +/// campaign-level static checks (none in v1, hence unused). +pub(crate) fn preflight(process: &ProcessDoc, _campaign: &CampaignDoc) -> Result<(), ExecFault> { +``` + +- [ ] **Step 6: Run the GREEN gate** + +Run: `cargo test -p aura-campaign execute` +Expected: compiles; the 6 new tests pass — `execute_sweep_only_records_family_and_selection`, `execute_gate_filters_per_member`, `execute_zero_survivors_truncates_cell_and_continues`, `execute_is_deterministic_twice`, `execute_member_fault_aborts_with_lowest_index`, `execute_deflate_uses_campaign_seed` — 0 failed (any pre-existing tests matching the filter also pass). + +- [ ] **Step 7: Workspace build gate** + +Run: `cargo build --workspace` +Expected: 0 errors (warnings none; the seam fn carries `#[allow(clippy::too_many_arguments)]` so the lint gate stays clean). +### Task 7: aura-campaign walk_forward stage + +**Files:** +- Modify: `crates/aura-campaign/src/exec.rs` (created by Task 6 of this plan, so anchors are symbols, not line numbers: replace the private `fn run_walk_forward_stage` stub wholesale; add one private helper; merge imports into its `use` block) +- Modify: `crates/aura-campaign/src/lib.rs` (append the test module only) +- Test: `crates/aura-campaign/src/lib.rs` (new `#[cfg(test)] mod wf_tests` with four `wf_*` tests, appended at the end of the file; they drive the crate-root re-exports) + +**Precondition (state of the tree after Task 6).** Task 6 shipped `execute()` in `crates/aura-campaign/src/exec.rs` (re-exported at the crate root via `pub use exec::{…}`) with a private stage runner whose stub reads byte-exactly as follows (a doc comment sits directly above the `#[allow]`; Step 4 replaces it together with the fn): + +```rust +#[allow(clippy::too_many_arguments)] +fn run_walk_forward_stage( + seed: u64, + cell: &CellSpec, + stage: usize, + block: &StageBlock, + specs: &[ParamSpec], + survivors: &[Vec], + family_name: &str, + runner: &dyn MemberRunner, + registry: &Registry, +) -> Result { + let _ = (seed, cell, stage, block, specs, survivors, family_name, runner, registry); + Err(ExecFault::PipelineShape { detail: "walk_forward execution lands in the next task".into() }) +} +``` + +Its call site in `run_cell` (exec.rs) already passes `(seed, cell, stage_ordinal, stage, &grid.specs, &survivor_points, &family_name, runner, registry)` and, on `Ok(fam)`, already pushes `StageRealization { block: "std::walk_forward".to_string(), family_id: Some(fam.family_id.clone()), survivor_ordinals: None, selection: None }` onto the cell realization and `fam` onto the `CellOutcome`'s `families` — this task does NOT touch `execute()`/`run_cell`. Task 6 also shipped the private slot filler `fn placeholder_report(params: &[(String, Scalar)], window_ms: (i64, i64)) -> RunReport` in exec.rs; this task's fault paths reuse it with inert arguments (`&[], (0, 0)`). This task REPLACES the stub wholesale (keeping the signature byte-identical), and its tests replace nothing — they add. + +- [ ] **Step 1: RED — append the `wf_tests` module** + +Append the following module at the very end of `crates/aura-campaign/src/lib.rs` (after the existing `#[cfg(test)] mod tests` from Tasks 4-5; it is a sibling module and shares nothing with it — `super::execute` etc. resolve through the crate root's `pub use exec::{…}` re-export): + +```rust +#[cfg(test)] +mod wf_tests { + use std::collections::BTreeMap; + use std::sync::Mutex; + + use aura_core::{Scalar, ScalarKind}; + use aura_engine::{RollMode, RunManifest, RunMetrics, RunReport, Timestamp, WindowRoller}; + use aura_registry::Registry; + use aura_research::{ + Axis, CampaignDoc, Cmp, DataSection, DocKind, DocRef, Predicate, Presentation, + ProcessDoc, ProcessRef, SelectRule, StageBlock, StrategyEntry, WfMode, Window, + }; + + use super::{execute, CampaignOutcome, CellSpec, ExecFault, MemberFault, MemberRunner}; + + /// A per-test registry in a fresh temp dir (a Registry's family store + /// isolates per DIRECTORY, not per filename — see Registry::open). + fn wf_registry(name: &str) -> Registry { + let dir = std::env::temp_dir() + .join(format!("aura-campaign-wf-{}-{}", std::process::id(), name)); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("temp dir"); + Registry::open(dir.join("runs.jsonl")) + } + + /// One strategy x one instrument x one window, over a single I64 axis + /// "len" with values [1, 2, 3, 4] (the fake runner scores total_pips == + /// the len value, so ranking and gating are fully determined). + fn wf_campaign(window: (i64, i64)) -> CampaignDoc { + CampaignDoc { + format_version: 1, + kind: DocKind::Campaign, + name: "wf-test".to_string(), + description: None, + data: DataSection { + instruments: vec!["SYNTH".to_string()], + windows: vec![Window { from_ms: window.0, to_ms: window.1 }], + }, + strategies: vec![StrategyEntry { + r#ref: DocRef::ContentId("c".repeat(64)), + axes: BTreeMap::from([( + "len".to_string(), + Axis { + kind: ScalarKind::I64, + values: vec![ + Scalar::i64(1), + Scalar::i64(2), + Scalar::i64(3), + Scalar::i64(4), + ], + }, + )]), + }], + process: ProcessRef { r#ref: DocRef::ContentId("d".repeat(64)) }, + seed: 7, + presentation: Presentation { persist_taps: vec![], emit: vec![] }, + } + } + + /// sweep -> (optional total_pips-gt gate) -> walk_forward, both ranked + /// stages on total_pips / argmax, rolling mode. + fn wf_process(gate_gt: Option, is_ms: u64, oos_ms: u64, step_ms: u64) -> ProcessDoc { + let mut pipeline = vec![StageBlock::Sweep { + metric: "total_pips".to_string(), + select: SelectRule::Argmax, + deflate: false, + }]; + if let Some(threshold) = gate_gt { + pipeline.push(StageBlock::Gate { + all: vec![Predicate { + metric: "total_pips".to_string(), + cmp: Cmp::Gt, + value: threshold, + }], + }); + } + pipeline.push(StageBlock::WalkForward { + in_sample_ms: is_ms, + out_of_sample_ms: oos_ms, + step_ms, + mode: WfMode::Rolling, + metric: "total_pips".to_string(), + select: SelectRule::Argmax, + }); + ProcessDoc { + format_version: 1, + kind: DocKind::Process, + name: "wf-proc".to_string(), + description: None, + pipeline, + } + } + + /// Deterministic fake member runner keyed on (window bounds, params): + /// total_pips == the summed numeric param values (here: the single "len" + /// value), the report's manifest.window echoes the window it was run over, + /// and every call is logged for the IS-population assertions. + struct WfFakeRunner { + log: Mutex)>>, + } + + impl WfFakeRunner { + fn new() -> Self { + WfFakeRunner { log: Mutex::new(Vec::new()) } + } + } + + impl MemberRunner for WfFakeRunner { + fn run_member( + &self, + _cell: &CellSpec, + params: &[(String, Scalar)], + window_ms: (i64, i64), + ) -> Result { + self.log.lock().unwrap().push((window_ms, params.to_vec())); + let total: f64 = params + .iter() + .map(|(_, s)| match s { + Scalar::I64(v) => *v as f64, + Scalar::F64(v) => *v, + _ => 0.0, + }) + .sum(); + Ok(RunReport { + manifest: RunManifest { + commit: "wf-fake".to_string(), + params: params.to_vec(), + window: (Timestamp(window_ms.0), Timestamp(window_ms.1)), + seed: 0, + broker: "fake".to_string(), + selection: None, + instrument: None, + topology_hash: None, + project: None, + }, + metrics: RunMetrics { + total_pips: total, + max_drawdown: 0.0, + bias_sign_flips: 0, + r: None, + }, + }) + } + } + + fn run_wf( + campaign: &CampaignDoc, + process: &ProcessDoc, + runner: &WfFakeRunner, + registry: &Registry, + ) -> Result { + let strategies = vec![("s".repeat(64), "{}".to_string())]; + let campaign_id = "e".repeat(64); + execute(&campaign_id, campaign, process, &strategies, runner, registry) + } + + #[test] + fn wf_rolls_the_declared_window_in_ms() { + let registry = wf_registry("rolls"); + let campaign = wf_campaign((0, 99)); + let process = wf_process(None, 40, 20, 20); + let runner = WfFakeRunner::new(); + let outcome = run_wf(&campaign, &process, &runner, ®istry).expect("wf executes"); + + // The oracle is the roller's own math over the same config. + let expected: Vec<_> = + WindowRoller::new((Timestamp(0), Timestamp(99)), 40, 20, 20, RollMode::Rolling) + .expect("valid roll") + .collect(); + assert_eq!(expected.len(), 3, "fixture sanity: 3 windows over 0..=99"); + + let fam = outcome.cells[0] + .families + .iter() + .find(|f| f.block == "std::walk_forward") + .expect("wf family present"); + assert_eq!(fam.reports.len(), expected.len()); + for (report, bounds) in fam.reports.iter().zip(&expected) { + assert_eq!(report.manifest.window, (bounds.oos.0, bounds.oos.1)); + } + } + + #[test] + fn wf_searches_only_the_survivor_points() { + let registry = wf_registry("survivors"); + let campaign = wf_campaign((0, 99)); + // total_pips == len; gate total_pips > 2.5 keeps len 3 and len 4 of [1,2,3,4] + let process = wf_process(Some(2.5), 40, 20, 20); + let runner = WfFakeRunner::new(); + run_wf(&campaign, &process, &runner, ®istry).expect("wf executes"); + + let is_bounds: Vec<(i64, i64)> = + WindowRoller::new((Timestamp(0), Timestamp(99)), 40, 20, 20, RollMode::Rolling) + .expect("valid roll") + .map(|w| (w.is.0 .0, w.is.1 .0)) + .collect(); + let log = runner.log.lock().unwrap(); + let mut is_total = 0; + let mut seen3 = 0; + let mut seen4 = 0; + for (window, params) in log.iter() { + if !is_bounds.contains(window) { + continue; + } + is_total += 1; + match params[0].1 { + Scalar::I64(3) => seen3 += 1, + Scalar::I64(4) => seen4 += 1, + other => panic!("gated-out point ran in an IS window: {other:?}"), + } + } + // 3 windows x exactly the 2 surviving points + assert_eq!(is_total, 6); + assert_eq!(seen3, 3); + assert_eq!(seen4, 3); + } + + #[test] + fn wf_stamps_selection_on_oos_members() { + let registry = wf_registry("selection"); + let campaign = wf_campaign((0, 99)); + let process = wf_process(None, 40, 20, 20); + let runner = WfFakeRunner::new(); + let outcome = run_wf(&campaign, &process, &runner, ®istry).expect("wf executes"); + + let fam = outcome.cells[0] + .families + .iter() + .find(|f| f.block == "std::walk_forward") + .expect("wf family present"); + assert!(!fam.reports.is_empty()); + for report in &fam.reports { + let sel = report + .manifest + .selection + .as_ref() + .expect("every OOS member carries its IS selection"); + assert_eq!(sel.selection_metric, "total_pips"); + assert_eq!(sel.seed, Some(7), "deflation is seeded from campaign.seed"); + } + } + + #[test] + fn wf_roller_refusal_maps_to_window_fault() { + let registry = wf_registry("window-fault"); + // 0..=49 cannot fit is 40 + oos 20 (window 0 needs 59) -> the roller + // refuses at runtime (zero lengths are already doc-tier faults). + let campaign = wf_campaign((0, 49)); + let process = wf_process(None, 40, 20, 20); + let runner = WfFakeRunner::new(); + let result = run_wf(&campaign, &process, &runner, ®istry); + let Err(err) = result else { panic!("span too short for one window must refuse") }; + match err { + ExecFault::Window { stage, detail } => { + assert_eq!(stage, 1, "walk_forward is pipeline stage 1 here"); + assert!( + detail.contains("SpanTooShort"), + "detail carries the roller refusal: {detail}" + ); + } + other => panic!("expected ExecFault::Window, got {other:?}"), + } + } +} +``` + +- [ ] **Step 2: Run the new tests — expect RED** + +Run: `cargo test -p aura-campaign wf_` +Expected: the module compiles and all four tests FAIL against the Task 6 stub — `wf_rolls_the_declared_window_in_ms`, `wf_searches_only_the_survivor_points`, and `wf_stamps_selection_on_oos_members` panic at `.expect("wf executes")` (the stub's `Err(ExecFault::PipelineShape { .. })` propagates out of `execute`), and `wf_roller_refusal_maps_to_window_fault` panics at `expected ExecFault::Window, got PipelineShape { .. }`. Output ends with `test result: FAILED. 0 passed; 4 failed`. + +- [ ] **Step 3: Merge the implementation imports** + +The replacement code in Step 4 references eight names not yet imported by `crates/aura-campaign/src/exec.rs`: `walk_forward`, `RollMode`, `Space` (the trait whose `points()` the survivor `ListSpace` is read through), `WindowBounds`, `WindowRoller`, `WindowRun`, `walkforward_member_reports`, and `WfMode`. Everything else the replacement uses (`Timestamp`, `Cell`, `ParamSpec`, `Scalar`, `zip_params` from `aura_core`; `Mutex`; `sweep`, `ListSpace`; the crate-root helpers and the deflation consts) is already imported by Task 6. In `crates/aura-campaign/src/exec.rs`, replace: + +```rust +use aura_engine::{sweep, ListSpace, RunManifest, RunMetrics, RunReport, SweepFamily, SweepPoint}; +``` + +with: + +```rust +use aura_engine::{ + sweep, walk_forward, ListSpace, RollMode, RunManifest, RunMetrics, RunReport, Space, + SweepFamily, SweepPoint, WindowBounds, WindowRoller, WindowRun, +}; +``` + +then replace: + +```rust +use aura_registry::{ + optimize, optimize_deflated, optimize_plateau, sweep_member_reports, CampaignRunRecord, + CellRealization, FamilyKind, PlateauMode, Registry, StageRealization, StageSelection, +}; +``` + +with: + +```rust +use aura_registry::{ + optimize, optimize_deflated, optimize_plateau, sweep_member_reports, + walkforward_member_reports, CampaignRunRecord, CellRealization, FamilyKind, PlateauMode, + Registry, StageRealization, StageSelection, +}; +``` + +then replace: + +```rust +use aura_research::{Axis, CampaignDoc, DocRef, ProcessDoc, SelectRule, StageBlock}; +``` + +with: + +```rust +use aura_research::{Axis, CampaignDoc, DocRef, ProcessDoc, SelectRule, StageBlock, WfMode}; +``` + +If an intermediate repair pass reformatted the file, re-anchor on the same three `use` lines (the names, not the wrapping, are load-bearing) and add ONLY the eight new names — a duplicate import is a hard compile error. + +- [ ] **Step 4: Replace the `run_walk_forward_stage` stub with the real stage** + +In `crates/aura-campaign/src/exec.rs`, first add this helper directly above the `run_walk_forward_stage` doc comment: + +```rust +/// A placeholder for a faulted window slot: arity-correct chosen params (the +/// engine asserts every window's chosen-point arity against the param-space), +/// empty OOS equity, inert report. Never surfaces — a recorded window fault +/// fails the stage after the roll, before any registry write. +fn placeholder_window_run(specs: &[ParamSpec]) -> WindowRun { + WindowRun { + chosen_params: vec![Cell::from_i64(0); specs.len()], + oos_equity: vec![], + oos_report: placeholder_report(&[], (0, 0)), + } +} +``` + +Then replace the ENTIRE `run_walk_forward_stage` function — Task 6's stub doc comment, `#[allow]`, signature, and body (the `#[allow]`-through-`}` bytes are quoted in the Precondition above) — with the following; the signature is byte-identical to the stub's, so the call site in `run_cell` is untouched: + +```rust +/// Execute one `std::walk_forward` stage over the cell's surviving points: +/// roll (IS, OOS) window splits over the cell window — entirely in ms +/// (`Timestamp` is unit-agnostic `i64`; the driver owns ms->ns at its data +/// seam) — then per window sweep the survivor points over the IS bounds, pick +/// the winner (argmax with trials-deflation provenance, seeded from the +/// campaign seed — the shipped select_winner convention; plateau in +/// walk_forward is preflight-refused), run the winner over the OOS bounds +/// with the selection stamped on its fresh report, and append the per-window +/// OOS reports as a `FamilyKind::WalkForward` family. +#[allow(clippy::too_many_arguments)] +fn run_walk_forward_stage( + seed: u64, + cell: &CellSpec, + stage: usize, + block: &StageBlock, + specs: &[ParamSpec], + survivors: &[Vec], + family_name: &str, + runner: &dyn MemberRunner, + registry: &Registry, +) -> Result { + let StageBlock::WalkForward { in_sample_ms, out_of_sample_ms, step_ms, mode, metric, .. } = + block + else { + return Err(ExecFault::PipelineShape { + detail: format!("stage {stage} is not std::walk_forward"), + }); + }; + if survivors.is_empty() { + // execute() truncates a cell at an empty gate before reaching here; + // refuse (never panic) if that contract is ever violated. + return Err(ExecFault::PipelineShape { + detail: format!("stage {stage}: walk_forward reached with zero survivor points"), + }); + } + // Preflight guarantees the u64 lengths fit i64. + let span = (Timestamp(cell.window_ms.0), Timestamp(cell.window_ms.1)); + let roll_mode = match mode { + WfMode::Rolling => RollMode::Rolling, + WfMode::Anchored => RollMode::Anchored, + }; + let roller = WindowRoller::new( + span, + *in_sample_ms as i64, + *out_of_sample_ms as i64, + *step_ms as i64, + roll_mode, + ) + .map_err(|e| ExecFault::Window { stage, detail: format!("{e:?}") })?; + // A second identical roller enumerates the bounds up front: the engine's + // walk_forward consumes its roller, and the parallel closure needs each + // window's roll index for deterministic fault attribution. + let bounds: Vec = WindowRoller::new( + span, + *in_sample_ms as i64, + *out_of_sample_ms as i64, + *step_ms as i64, + roll_mode, + ) + .expect("identical roller config validated above") + .collect(); + + let is_space = ListSpace::new(specs, survivors.to_vec()).map_err(|e| { + ExecFault::PipelineShape { + detail: format!("stage {stage}: survivor points do not fit the param space: {e:?}"), + } + })?; + let is_points = is_space.points(); + + // Windows run in parallel and the engine closure cannot return Err: + // faulted windows record here (keyed by roll index) and yield a + // placeholder run; after the roll the lowest-index fault wins + // (deterministic prose, the sweep-stage capture pattern one level up). + let faults: Mutex> = Mutex::new(Vec::new()); + + let result = walk_forward(roller, specs.to_vec(), |w| { + let widx = + bounds.iter().position(|b| *b == w).expect("bounds come from an identical roller"); + // IS: engine sweep of the survivor points over the in-sample bounds, + // with the same member fault capture the sweep stage uses. + let is_faults: Mutex> = Mutex::new(Vec::new()); + let is_family = sweep(&is_space, |cells| { + match runner.run_member(cell, &zip_params(specs, cells), (w.is.0 .0, w.is.1 .0)) { + Ok(report) => report, + Err(fault) => { + let midx = is_points + .iter() + .position(|p| p.as_slice() == cells) + .expect("sweep enumerates exactly is_points"); + is_faults.lock().unwrap().push((midx, fault)); + placeholder_report(&[], (0, 0)) + } + } + }); + let member_faults = is_faults.into_inner().expect("no thread panicked holding the lock"); + if let Some((_, fault)) = member_faults.into_iter().min_by_key(|(i, _)| *i) { + faults.lock().unwrap().push((widx, ExecFault::Member(fault))); + return placeholder_window_run(specs); + } + // IS winner: argmax with trials-deflation provenance, seeded from the + // campaign seed (the shipped select_winner convention). + let (winner, selection) = match optimize_deflated( + &is_family, + metric, + DEFLATION_N_RESAMPLES, + DEFLATION_BLOCK_LEN, + seed, + ) { + Ok(picked) => picked, + Err(e) => { + faults.lock().unwrap().push((widx, ExecFault::Registry(e))); + return placeholder_window_run(specs); + } + }; + // OOS: run the winner over the out-of-sample bounds; the selection is + // stamped on the fresh OOS report (a new record, never a mutation of + // a stored one). + let mut oos_report = match runner.run_member( + cell, + &zip_params(specs, &winner.params), + (w.oos.0 .0, w.oos.1 .0), + ) { + Ok(report) => report, + Err(fault) => { + faults.lock().unwrap().push((widx, ExecFault::Member(fault))); + return placeholder_window_run(specs); + } + }; + oos_report.manifest.selection = Some(selection); + WindowRun { chosen_params: winner.params, oos_equity: vec![], oos_report } + }); + + let window_faults = faults.into_inner().expect("no thread panicked holding the lock"); + if let Some((_, fault)) = window_faults.into_iter().min_by_key(|(i, _)| *i) { + return Err(fault); + } + + let reports = walkforward_member_reports(&result); + let family_id = registry + .append_family(family_name, FamilyKind::WalkForward, &reports) + .map_err(ExecFault::Registry)?; + Ok(StageFamily { stage, block: "std::walk_forward", family_id, reports }) +} +``` + +- [ ] **Step 5: Run the new tests — expect GREEN** + +Run: `cargo test -p aura-campaign wf_` +Expected: `test result: ok. 4 passed; 0 failed` — all four `wf_*` tests pass. + +- [ ] **Step 6: Run the full aura-campaign suite** + +Run: `cargo test -p aura-campaign` +Expected: every test in the crate passes (the Tasks 4-6 tests plus the four new ones); `0 failed`. + +- [ ] **Step 7: Workspace build gate** + +Run: `cargo build --workspace` +Expected: compiles with 0 errors. +### Task 8: aura-cli campaign run — driver module, verb, prose, emission + +The CLI half of the executor (#198): a new `campaign_run` module implementing +`aura campaign run ` over the `aura-campaign` library (Tasks +4–7) — target resolution, project/referential gates, the `MemberRunner` +implementation over the shipped loaded-blueprint machinery, fault prose, and +stdout/stderr emission. Prose seams: `exec_fault_prose` lives in +**campaign_run.rs** (it consumes `aura_campaign` types; `research_docs.rs` +stays doc-tier-only). No `pub(crate)` promotion is needed for any main.rs item: +main.rs is the crate root and its private items are visible to child modules +via `crate::` (the shipped idiom — `graph_construct.rs:258` already calls the +private `crate::content_id`, main.rs:2588). Five `research_docs.rs` fns DO need +promotion (sibling-module privacy). Pinned ordering (load-bearing for Task 9's +`campaign_run_persist_taps_deferred_loudly`): the `persist_taps` stderr note +prints after all document gates and **before** `aura_campaign::execute` is +called, so it is present even when the run then refuses at the member-data seam. + +**Files:** +- Modify: crates/aura-cli/Cargo.toml (dependency block, lines 19–20) +- Modify: crates/aura-cli/src/main.rs (mod block, lines 14–18) +- Modify: crates/aura-cli/src/research_docs.rs (:70, :87, :121, :236–244, :246, :264–277, :279) +- Create: crates/aura-cli/src/campaign_run.rs + +- [ ] **Step 1: Add the aura-campaign dependency to crates/aura-cli/Cargo.toml** + +Replace (old): +```toml +aura-registry = { path = "../aura-registry" } +aura-research = { path = "../aura-research" } +``` +with (new): +```toml +aura-registry = { path = "../aura-registry" } +aura-research = { path = "../aura-research" } +# aura-campaign: campaign-execution semantics (preflight, cell loop, stage +# sequencing, realization record); the CLI implements its MemberRunner seam +# and renders its outcome (#198). +aura-campaign = { path = "../aura-campaign" } +``` + +- [ ] **Step 2: Declare the module in crates/aura-cli/src/main.rs** + +Replace (old): +```rust +mod render; +mod graph_construct; +mod project; +mod research_docs; +mod scaffold; +``` +with (new): +```rust +mod render; +mod graph_construct; +mod project; +mod campaign_run; +mod research_docs; +mod scaffold; +``` + +- [ ] **Step 3: Promote the five research_docs.rs prose/parse fns to pub(crate)** + +Five one-line replacements in crates/aura-cli/src/research_docs.rs (sibling-module +privacy: `campaign_run.rs` cannot see `research_docs`-private items; these are +the ONLY promotions this cycle needs — main.rs items need none, see task intro). + +Replace (old, line 70): +```rust +fn doc_error_prose(what: &str, e: &DocError) -> String { +``` +with (new): +```rust +pub(crate) fn doc_error_prose(what: &str, e: &DocError) -> String { +``` + +Replace (old, line 87): +```rust +fn doc_fault_prose(f: &DocFault) -> String { +``` +with (new): +```rust +pub(crate) fn doc_fault_prose(f: &DocFault) -> String { +``` + +Replace (old, line 121): +```rust +fn fault_block(header: &str, lines: Vec) -> String { +``` +with (new): +```rust +pub(crate) fn fault_block(header: &str, lines: Vec) -> String { +``` + +Replace (old, line 246): +```rust +fn ref_fault_prose(f: &RefFault) -> String { +``` +with (new): +```rust +pub(crate) fn ref_fault_prose(f: &RefFault) -> String { +``` + +Replace (old, line 279): +```rust +fn parse_valid_campaign(file: &PathBuf) -> Result { +``` +with (new): +```rust +pub(crate) fn parse_valid_campaign(file: &PathBuf) -> Result { +``` + +- [ ] **Step 4: Add the CampaignSub::Run variant (research_docs.rs:236–244)** + +Replace (old): +```rust + /// Register a valid campaign document into the store under the runs root. + Register { file: PathBuf }, +} +``` +with (new): +```rust + /// Register a valid campaign document into the store under the runs root. + Register { file: PathBuf }, + /// Execute a stored campaign into a realized run-set (a .json file is + /// register-then-run sugar; the canonical address is the content id). + Run { target: String }, +} +``` + +- [ ] **Step 5: Dispatch the Run arm in campaign_cmd (research_docs.rs:264–277)** + +Replace (old): +```rust + CampaignSub::Register { file } => register_campaign(file, env), + }; + if let Err(m) = result { +``` +with (new): +```rust + CampaignSub::Register { file } => register_campaign(file, env), + CampaignSub::Run { target } => crate::campaign_run::run_campaign(target, env), + }; + if let Err(m) = result { +``` + +(The existing `Err -> eprintln!("aura: {m}") + exit(1)` tail of `campaign_cmd` +is the refusal exit path; usage stays clap, exit 2.) + +- [ ] **Step 6: Create crates/aura-cli/src/campaign_run.rs (complete file)** + +```rust +//! `aura campaign run` — the driver that turns a stored campaign document into +//! a realized run-set (#198). The execution *semantics* (preflight, cell loop, +//! stage sequencing, selection, registry writes) live in `aura-campaign`; this +//! module owns what is CLI-specific: target resolution (file sugar vs content +//! id), the project + referential gates, the [`MemberRunner`] implementation +//! over the shipped loaded-blueprint machinery (`wrap_r` reduce-mode member +//! runs via `run_blueprint_member` + `M1FieldSource` windowed real-data +//! binding), fault prose (`exec_fault_prose` lives HERE, beside its consumer, +//! not in `research_docs.rs` — it phrases `aura_campaign` types the doc-tier +//! module deliberately does not import), and stdout/stderr emission. +//! +//! Root items (`blueprint_axis_probe`, `run_blueprint_member`, +//! `family_member_line`) are reached via `crate::` — the `graph_construct` +//! submodule idiom: main.rs is the crate root, so its private items are +//! visible to child modules without promotion. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use aura_campaign::{CellSpec, ExecFault, MemberFault, MemberRunner}; +use aura_core::{Cell, Scalar}; +use aura_engine::{blueprint_from_json, FamilySelection, RunReport}; +use aura_ingest::{instrument_geometry, unix_ms_to_epoch_ns, M1Field, M1FieldSource}; +use aura_registry::CampaignRunRecord; +use aura_research::{ + campaign_to_json, content_id_of, parse_campaign, parse_process, validate_campaign, + validate_process, DocRef, +}; + +use crate::project::Env; +use crate::research_docs::{ + doc_error_prose, doc_fault_prose, fault_block, parse_valid_campaign, ref_fault_prose, +}; + +/// A bare store address: exactly 64 lowercase hex chars (the content-id key shape). +fn is_content_id(s: &str) -> bool { + s.len() == 64 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) +} + +/// Phrase an [`ExecFault`] for stderr — Debug-leak-free, path-addressed +/// (stage index + block id), the `doc_fault_prose`/`ref_fault_prose` register. +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}") + } + ExecFault::UnrankableMetric { stage, metric } => format!( + "process stage {stage}: metric \"{metric}\" is not rankable (winner \ + selection needs one of the registry's rankable metrics)" + ), + ExecFault::GateMetricNotPerMember { stage, metric } => format!( + "process stage {stage}: gate metric \"{metric}\" is not a per-member \ + scalar (selection annotations cannot gate members)" + ), + ExecFault::PlateauInWalkForward { stage } => format!( + "process stage {stage}: walk_forward cannot use a plateau select (a \ + gated survivor subset has no parameter lattice)" + ), + 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}" + ), + ExecFault::Member(MemberFault::NoData { instrument, window_ms }) => format!( + "no data for instrument {instrument} in window [{}, {}] (epoch-ms)", + window_ms.0, window_ms.1 + ), + ExecFault::Member(MemberFault::Bind(detail)) => { + format!("a member failed to bind: {detail}") + } + ExecFault::Member(MemberFault::Run(detail)) => { + format!("a member failed to run: {detail}") + } + ExecFault::Registry(e) => e.to_string(), + } +} + +/// stdout wire form of one selection-bearing stage. Field order is the wire +/// contract (serde derives declaration order). +#[derive(serde::Serialize)] +struct SelectionReportLine<'a> { + selection_report: SelectionReportBody<'a>, +} + +#[derive(serde::Serialize)] +struct SelectionReportBody<'a> { + family_id: &'a str, + stage: usize, + block: &'a str, + winner_ordinal: usize, + params: &'a [(String, Scalar)], + selection: &'a FamilySelection, +} + +/// The always-on final line: the stored realization record under one key. +#[derive(serde::Serialize)] +struct CampaignRunLine<'a> { + campaign_run: &'a CampaignRunRecord, +} + +/// The CLI's harness/data binding seam for `aura_campaign::execute`: members +/// run through the shipped loaded-blueprint machinery (`wrap_r` reduce-mode +/// via `crate::run_blueprint_member`) over windowed real M1 close bars +/// (`M1FieldSource::open_window` — the ms→ns crossing happens at exactly this +/// seam, via `unix_ms_to_epoch_ns`). All refusals are member faults for the +/// library to surface; never a process exit inside a sweep worker. +struct CliMemberRunner<'a> { + env: &'a Env, + server: Arc, +} + +impl MemberRunner for CliMemberRunner<'_> { + fn run_member( + &self, + cell: &CellSpec, + params: &[(String, Scalar)], + window_ms: (i64, i64), + ) -> Result { + // The wrapped axis namespace — the SAME probe the sweep verbs resolve + // against (single-sourced in main.rs; identical `false, true, None` wrap). + let space = crate::blueprint_axis_probe(&cell.blueprint_json, self.env).param_space(); + + // Suffix-join each raw campaign axis onto exactly one wrapped param + // (wrapped == raw, or wrapped ends with ".{raw}" — the established + // suffix-match pattern); then every wrapped slot must be covered. + let mut slots: Vec> = vec![None; space.len()]; + for (raw, value) in params { + let hits: Vec = space + .iter() + .enumerate() + .filter(|(_, p)| p.name == *raw || p.name.ends_with(&format!(".{raw}"))) + .map(|(i, _)| i) + .collect(); + match hits.as_slice() { + [i] => slots[*i] = Some(*value), + [] => { + return Err(MemberFault::Bind(format!( + "axis \"{raw}\" matches no open param of the wrapped strategy {}", + cell.strategy_id + ))); + } + _ => { + let names: Vec<&str> = + hits.iter().map(|&i| space[i].name.as_str()).collect(); + return Err(MemberFault::Bind(format!( + "axis \"{raw}\" is ambiguous in the wrapped param space of \ + strategy {}: matches {}", + cell.strategy_id, + names.join(", ") + ))); + } + } + } + let mut point: Vec = Vec::with_capacity(space.len()); + for (spec, slot) in space.iter().zip(&slots) { + match slot { + Some(v) => point.push(v.cell()), + None => { + return Err(MemberFault::Bind(format!( + "open param \"{}\" of strategy {} is bound by no campaign axis", + spec.name, cell.strategy_id + ))); + } + } + } + + // Real windowed data — geometry BEFORE bar data (the shipped pre-data + // refusal order of `open_real_source`), both as member faults. + let geo = instrument_geometry(&self.server, &cell.instrument).ok_or_else(|| { + MemberFault::Run(format!( + "no recorded geometry for symbol '{}' at {} — refusing to run a \ + real instrument with a guessed pip", + cell.instrument, + self.env.data_path() + )) + })?; + let from = unix_ms_to_epoch_ns(window_ms.0); + let to = unix_ms_to_epoch_ns(window_ms.1); + let source = match M1FieldSource::open_window( + &self.server, + &cell.instrument, + Some(from), + Some(to), + M1Field::Close, + ) { + Some(s) => s, + // No archived file overlaps the window at all. + None => { + return Err(MemberFault::NoData { + instrument: cell.instrument.clone(), + window_ms, + }); + } + }; + // A window that overlaps a file but holds zero matching bars yields a + // source whose first peek is None (open_window's documented contract) + // — the same no-data condition. + if aura_engine::Source::peek(&source).is_none() { + return Err(MemberFault::NoData { + instrument: cell.instrument.clone(), + window_ms, + }); + } + + // The shipped member recipe (reload — a Composite is !Clone — wrap, + // bind, run, summarize): `run_blueprint_member` verbatim. Seed 0 + // (seed-free real-data runs), topology_hash = the strategy's content + // id, project provenance stamped inside the helper. + let signal = blueprint_from_json(&cell.blueprint_json, &|t| self.env.resolve(t)) + .expect("stored blueprint passed the referential gate; reload is infallible"); + let mut report = crate::run_blueprint_member( + signal, + &point, + &space, + vec![Box::new(source)], + (from, to), + 0, + geo.pip_size, + &cell.strategy_id, + self.env, + ); + report.manifest.instrument = Some(cell.instrument.clone()); + Ok(report) + } +} + +/// `aura campaign run `: resolve, gate, execute, emit. Every `Err` +/// is a refusal `campaign_cmd` renders as `aura: {msg}` + exit 1. +pub(crate) fn run_campaign(target: &str, env: &Env) -> Result<(), String> { + // Project gate FIRST: nothing (not even the file-sugar registration) + // touches a store outside a project. + if env.provenance().is_none() { + let cwd = std::env::current_dir() + .map(|d| d.display().to_string()) + .unwrap_or_default(); + return Err(format!( + "campaign run needs a project: strategies resolve against the project \ + store and vocabulary (no Aura.toml found up from {cwd})" + )); + } + let registry = env.registry(); + + // Target resolution: a readable file is register-then-run sugar; a bare + // 64-hex token addresses the store directly; anything else refuses naming + // both readings. + let campaign_id = if Path::new(target).is_file() { + let doc = parse_valid_campaign(&PathBuf::from(target))?; + registry + .put_campaign(&campaign_to_json(&doc)) + .map_err(|e| e.to_string())? + } else if is_content_id(target) { + target.to_string() + } else { + return Err(format!( + "'{target}' is neither a readable .json file nor a 64-hex content id" + )); + }; + + // One uniform path from here: fetch the stored canonical bytes by id (so + // file addressing and id addressing produce the same realization by + // construction) and re-run the intrinsic tier on them. + let campaign_text = registry + .get_campaign(&campaign_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("no campaign {campaign_id} in the project store"))?; + let campaign = parse_campaign(&campaign_text) + .map_err(|e| doc_error_prose("stored campaign document", &e))?; + let faults = validate_campaign(&campaign); + if !faults.is_empty() { + return Err(fault_block( + "campaign document invalid:", + faults.iter().map(doc_fault_prose).collect(), + )); + } + + // Referential gate: zero faults or refuse (the campaign-validate seam). + let resolve = |t: &str| env.resolve(t); + let ref_faults = registry + .validate_campaign_refs(&campaign, &resolve) + .map_err(|e| e.to_string())?; + if !ref_faults.is_empty() { + return Err(fault_block( + "campaign references do not resolve:", + ref_faults.iter().map(ref_fault_prose).collect(), + )); + } + + // Process fetch + intrinsic validation (stored text, not a file path — + // parse_valid_process is file-based, so its constituents run here). + let DocRef::ContentId(process_id) = &campaign.process.r#ref else { + // validate_campaign already refuses identity process refs; defensive. + return Err("process.ref: a process is referenced by content id in this version".into()); + }; + let process_text = registry + .get_process(process_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("no process {process_id} in the project store"))?; + let process = parse_process(&process_text) + .map_err(|e| doc_error_prose("stored process document", &e))?; + let process_faults = validate_process(&process); + if !process_faults.is_empty() { + return Err(fault_block( + "process document invalid:", + process_faults.iter().map(doc_fault_prose).collect(), + )); + } + + // Strategies: canonical bytes from the store, index-aligned with the doc. + // The recorded id is the content id of those bytes (== the ref id for + // content refs; computed for identity refs). + let mut strategies: Vec<(String, String)> = Vec::with_capacity(campaign.strategies.len()); + for entry in &campaign.strategies { + let canonical = match &entry.r#ref { + DocRef::ContentId(id) => registry + .get_blueprint(id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("strategy {id} not found in the blueprint store"))?, + DocRef::IdentityId(id) => registry + .find_blueprint_by_identity(id, &resolve) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("identity id {id} matches no stored blueprint"))?, + }; + strategies.push((content_id_of(&canonical), canonical)); + } + + // Loud deferral (#198 decision 6), once per run — pinned ORDER: this + // prints after the document gates and BEFORE any member executes, so a + // data refusal inside execute cannot swallow it. + if !campaign.presentation.persist_taps.is_empty() { + eprintln!( + "aura: persist_taps not yet honored ({} tap(s) ignored)", + campaign.presentation.persist_taps.len() + ); + } + + let runner = CliMemberRunner { + env, + server: Arc::new(data_server::DataServer::new(env.data_path())), + }; + let outcome = aura_campaign::execute( + &campaign_id, + &campaign, + &process, + &strategies, + &runner, + ®istry, + ) + .map_err(|f| exec_fault_prose(&f))?; + + // Zero-survivor stderr notes (exit stays 0 — a null result is a valid + // research result, #198 decision 8). Cells are recorded in strategy × + // instrument × window doc order, so the ordinals derive from the index. + let n_windows = campaign.data.windows.len(); + let n_instruments = campaign.data.instruments.len(); + for (ci, cell) in outcome.record.cells.iter().enumerate() { + let w_ord = ci % n_windows; + let s_ord = ci / (n_windows * n_instruments); + for (stage_ix, st) in cell.stages.iter().enumerate() { + if matches!(&st.survivor_ordinals, Some(v) if v.is_empty()) { + eprintln!( + "aura: cell {s_ord}/{}/w{w_ord}: gate at stage {stage_ix} left no \ + survivors; cell realization truncated", + cell.instrument + ); + } + } + } + + // Emission: emit-gated family/selection lines per cell, then the + // always-on final record line. + let emit_family = campaign.presentation.emit.iter().any(|e| e == "family_table"); + let emit_selection = campaign.presentation.emit.iter().any(|e| e == "selection_report"); + for cell_out in &outcome.cells { + if emit_family { + for fam in &cell_out.families { + for report in &fam.reports { + println!("{}", crate::family_member_line(&fam.family_id, report)); + } + } + } + if emit_selection { + for sel in &cell_out.selections { + let line = SelectionReportLine { + selection_report: SelectionReportBody { + family_id: &sel.family_id, + stage: sel.stage, + block: sel.block, + winner_ordinal: sel.winner_ordinal, + params: &sel.params, + selection: &sel.selection, + }, + }; + println!( + "{}", + serde_json::to_string(&line).expect("selection report serializes") + ); + } + } + } + println!( + "{}", + serde_json::to_string(&CampaignRunLine { campaign_run: &outcome.record }) + .expect("campaign run record serializes") + ); + Ok(()) +} +``` + +- [ ] **Step 7: Build the workspace** + +Run: `cargo build --workspace` +Expected: compiles with 0 errors (warnings-free in the new module: every import is used). + +- [ ] **Step 8: Existing aura-cli suite stays green** + +Run: `cargo test -p aura-cli` +Expected: all existing unit + integration tests pass, count unchanged (this task adds no tests; the new verb only extends the clap surface, and no existing test asserts an exhaustive `campaign` subcommand list). + +### Task 9: aura-cli campaign run seam tests + gated real-data e2e + +Seam tests for Task 8's `aura campaign run`, in the existing +`crates/aura-cli/tests/research_docs.rs` (chosen over a new test binary: every +helper it needs — `temp_cwd`/`run_code_in`/`write_doc`/`built_project`/ +`ScratchGuard` — already lives there). These tests pin behaviour Task 8 already +implemented, so they must pass on first run; a failure is a Task 8 defect — fix +the implementation, never weaken the assert. Because several new tests mutate +the shared demo-project fixture's `runs/` store and test threads run in +parallel, this task adds a static-Mutex `project_lock()` and threads it through +the one existing fixture test as well. The persist-taps test relies on Task 8's +pinned ordering (tap note before member execution): its campaign window is +`[1, 2]` epoch-ms (1970), so the run deterministically refuses at the +member-data seam on every machine (data-less host: geometry refusal; data-ful +host: no file overlaps → NoData) — the tap line must be on stderr either way. +The e2e mirrors the `cli_run.rs:250-281` skip idiom (GER40 Sept-2024 window; +clean `eprintln!` skip when the archive is absent). + +**Files:** +- Test: crates/aura-cli/tests/research_docs.rs (imports :5–6; existing fixture test :196–199; helpers + six new tests appended at end of file) + +- [ ] **Step 1: Widen the std::sync import** + +Replace (old, lines 5–6): +```rust +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; +``` +with (new): +```rust +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, MutexGuard, OnceLock}; +``` + +- [ ] **Step 2: Add the fixture lock (insert above the ScratchPath enum)** + +Replace (old): +```rust +/// A scratch filesystem entry this test writes under the git-tracked +``` +with (new): +```rust +/// Serializes every test that touches the shared demo-project fixture store +/// (they remove/re-seed `/runs`, so parallel test threads would race +/// on it). A poisoned lock is taken over: one failed test must not cascade +/// into unrelated lock panics. +fn project_lock() -> MutexGuard<'static, ()> { + static LOCK: Mutex<()> = Mutex::new(()); + LOCK.lock().unwrap_or_else(|e| e.into_inner()) +} + +/// A scratch filesystem entry this test writes under the git-tracked +``` + +- [ ] **Step 3: Take the lock in the existing fixture test** + +Replace (old, lines 196–199): +```rust +#[test] +fn campaign_validate_in_project_reports_referential_tier_end_to_end() { + let dir = built_project(); + let runs_dir = dir.join("runs"); +``` +with (new): +```rust +#[test] +fn campaign_validate_in_project_reports_referential_tier_end_to_end() { + let _fixture = project_lock(); + let dir = built_project(); + let runs_dir = dir.join("runs"); +``` + +- [ ] **Step 4: Append helpers, fixture docs, and the six campaign-run tests at the end of the file** + +Append after the last test (`campaign_register_refuses_invalid_document_and_writes_nothing`'s closing brace, end of file): + +```rust +/// Seed one open-param blueprint into the built demo project's store via a +/// real sweep and return its content id (the referential test's recipe). +fn seed_blueprint(dir: &Path, name: &str) -> String { + let open_bp = format!("{}/tests/fixtures/sma_signal_open.json", env!("CARGO_MANIFEST_DIR")); + let (out, code) = run_code_in( + dir, + &[ + "sweep", + &open_bp, + "--axis", + "sma_signal.fast.length=2,4", + "--axis", + "sma_signal.slow.length=8,16", + "--name", + name, + ], + ); + assert_eq!(code, Some(0), "seed sweep failed: {out}"); + std::fs::read_dir(dir.join("runs").join("blueprints")) + .expect("blueprints dir") + .next() + .expect("one stored blueprint") + .expect("dir entry") + .path() + .file_stem() + .expect("stem") + .to_string_lossy() + .into_owned() +} + +/// 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. +fn register_process_doc(dir: &Path, file: &str, doc: &str) -> String { + write_doc(dir, file, doc); + let (out, code) = run_code_in(dir, &["process", "register", file]); + assert_eq!(code, Some(0), "process register failed: {out}"); + out.lines() + .find(|l| l.starts_with("registered process content:")) + .expect("register line") + .trim_start_matches("registered process content:") + .split(' ') + .next() + .expect("id") + .to_string() +} + +/// A referentially-resolving campaign over the seeded blueprint. Axes name +/// the RAW `param_space` names (`fast.length` / `slow.length` — see the +/// naming note in the referential test above). `persist_taps`/`emit` are +/// spliced verbatim (pass `""` for empty, `"\"family_table\""` etc.). +fn campaign_doc_json( + bp_id: &str, + proc_id: &str, + window: (i64, i64), + persist_taps: &str, + emit: &str, +) -> String { + format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "run-seam", + "data": {{ "instruments": ["GER40"], "windows": [ {{ "from_ms": {from}, "to_ms": {to} }} ] }}, + "strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }}, + "axes": {{ "fast.length": {{ "kind": "I64", "values": [2, 4] }}, + "slow.length": {{ "kind": "I64", "values": [8, 16] }} }} }} ], + "process": {{ "ref": {{ "content_id": "{proc_id}" }} }}, + "seed": 7, + "presentation": {{ "persist_taps": [{persist_taps}], "emit": [{emit}] }} +}}"#, + from = window.0, + to = window.1, + ) +} + +/// 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). +const SWEEP_ONLY_PROCESS_DOC: &str = r#"{ + "format_version": 1, + "kind": "process", + "name": "sweep-only", + "pipeline": [ { "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" } ] +}"#; + +/// 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" } + ] +}"#; + +/// `campaign run` outside a project refuses up front — before target +/// resolution, so not even the file-sugar registration touches a store. +#[test] +fn campaign_run_outside_project_refuses() { + let dir = temp_cwd("campaign-run-outside-project"); + write_doc(&dir, "c.campaign.json", CAMPAIGN_DOC); + let (out, code) = run_code_in(&dir, &["campaign", "run", "c.campaign.json"]); + assert_eq!(code, Some(1), "stdout/stderr: {out}"); + assert!(out.contains("campaign run needs a project"), "stdout/stderr: {out}"); + assert!( + !dir.join("runs").exists(), + "a refused run must not create a store outside a project" + ); +} + +/// A target that is neither a readable file nor a 64-hex id refuses naming +/// both readings (inside the project, past the project gate). +#[test] +fn campaign_run_bogus_target_refuses() { + let _fixture = project_lock(); + let dir = built_project(); + let (out, code) = run_code_in(dir, &["campaign", "run", "no-such-target"]); + assert_eq!(code, Some(1), "stdout/stderr: {out}"); + assert!( + out.contains("'no-such-target' is neither a readable .json file nor a 64-hex content id"), + "stdout/stderr: {out}" + ); +} + +/// A well-formed but unknown content id refuses with the store-miss prose. +#[test] +fn campaign_run_unknown_id_refuses() { + let _fixture = project_lock(); + let dir = built_project(); + let id = "0".repeat(64); + let (out, code) = run_code_in(dir, &["campaign", "run", &id]); + assert_eq!(code, Some(1), "stdout/stderr: {out}"); + assert!( + out.contains(&format!("no campaign {id} in the project store")), + "stdout/stderr: {out}" + ); +} + +/// 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}"); +} + +/// Non-empty persist_taps defers LOUDLY, and the note's ORDER is pinned: it +/// prints before member execution (Task 8), so it is asserted here on a run +/// that refuses at the member-data seam. The [1, 2] epoch-ms window (1970) +/// makes that refusal deterministic on every machine: a data-less host +/// refuses on missing geometry, a data-ful host on a window no archive file +/// overlaps — exit 1 either way, tap note already on stderr. +#[test] +fn campaign_run_persist_taps_deferred_loudly() { + 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("taps.process.json")), + ScratchPath::File(dir.join("taps.campaign.json")), + ]); + let bp_id = seed_blueprint(dir, "campaign-run-taps-seed"); + let proc_id = register_process_doc(dir, "taps.process.json", SWEEP_ONLY_PROCESS_DOC); + write_doc( + dir, + "taps.campaign.json", + &campaign_doc_json(&bp_id, &proc_id, (1, 2), "\"r_record\"", ""), + ); + let (out, code) = run_code_in(dir, &["campaign", "run", "taps.campaign.json"]); + assert_eq!(code, Some(1), "stdout/stderr: {out}"); + assert!( + out.contains("aura: persist_taps not yet honored (1 tap(s) ignored)"), + "the tap note must precede the member-data refusal: {out}" + ); + assert!( + out.contains("no recorded geometry") || out.contains("no data for instrument"), + "the refusal names the data condition: {out}" + ); +} + +/// 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. +#[test] +fn campaign_run_real_e2e_sweep_gate_walkforward() { + 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("wf.process.json")), + ScratchPath::File(dir.join("wf.campaign.json")), + ]); + let bp_id = seed_blueprint(dir, "campaign-run-e2e-seed"); + let proc_id = register_process_doc(dir, "wf.process.json", WF_PROCESS_DOC); + // The GER40 Sept-2024 window (inclusive Unix-ms) — the same gated window + // cli_run.rs drives; ~30 days, so the (14d, 7d, 7d) roller tiles it. + write_doc( + dir, + "wf.campaign.json", + &campaign_doc_json( + &bp_id, + &proc_id, + (1725148800000, 1727740799999), + "", + "\"family_table\", \"selection_report\"", + ), + ); + let (out, code) = run_code_in(dir, &["campaign", "run", "wf.campaign.json"]); + + // Skip on a data-less machine: the member-data refusal, never a panic. + if code == Some(1) + && (out.contains("no recorded geometry") || out.contains("no data for instrument")) + { + eprintln!("skip: no local GER40 data for the campaign e2e"); + return; + } + + assert_eq!(code, Some(0), "stdout/stderr: {out}"); + let record_line = out + .lines() + .find(|l| l.starts_with("{\"campaign_run\":")) + .expect("the always-on final campaign_run line"); + let v: serde_json::Value = + serde_json::from_str(record_line).expect("campaign_run line parses as JSON"); + let cells = v["campaign_run"]["cells"].as_array().expect("cells array"); + assert_eq!(cells.len(), 1, "one (strategy, instrument, window) cell: {record_line}"); + let stages = cells[0]["stages"].as_array().expect("stages array"); + assert_eq!(stages.len(), 3, "sweep + gate + walk_forward realized: {record_line}"); + assert!( + stages[0]["family_id"].as_str().is_some(), + "sweep stage links a family: {record_line}" + ); + assert_eq!( + stages[1]["survivor_ordinals"].as_array().map(|a| a.len()), + Some(4), + "the always-true gate keeps all four members: {record_line}" + ); + assert!( + stages[2]["family_id"].as_str().is_some(), + "walk-forward stage links a family: {record_line}" + ); + // Emit-gated lines: per-member family_table lines (4 sweep members plus + // the walk-forward OOS members) and at least one selection_report line. + assert!( + out.lines().filter(|l| l.starts_with("{\"family_id\":")).count() >= 4, + "family_table member lines emitted: {out}" + ); + assert!( + out.lines().any(|l| l.starts_with("{\"selection_report\":")), + "selection_report line emitted: {out}" + ); + // The registry's new sibling store carries the realization record. + assert!( + runs_dir.join("campaign_runs.jsonl").is_file(), + "campaign_runs.jsonl written beside runs.jsonl" + ); +} +``` + +- [ ] **Step 5: Run the new tests** + +Run: `cargo test -p aura-cli campaign_run` +Expected: 6 tests matched and passing in the `research_docs` integration binary (0 elsewhere). On a host without the local GER40 archive, `campaign_run_real_e2e_sweep_gate_walkforward` prints `skip: no local GER40 data for the campaign e2e` and still reports ok. These pin Task 8 behaviour — any failure is a Task 8 defect to fix, not an assert to weaken. + +- [ ] **Step 6: Full workspace stays green** + +Run: `cargo test --workspace` +Expected: all crates green (the fixture-store tests serialize on `project_lock`, so the shared demo-project `runs/` store no longer races). +### Task 10: #196 blueprint on-ramp — graph register, introspect --params, blueprint-file --content-id + +**Files:** +- Modify: `crates/aura-registry/src/lib.rs` :109-115 (conditional — only if Task 3 has not already promoted `blueprint_path` to `pub`) +- Modify: `crates/aura-cli/src/main.rs` :3801-3807 (`GraphSub`), :3809-3826 (`GraphIntrospectCmd`), :4338-4344 (`dispatch_graph`) +- Modify: `crates/aura-cli/src/graph_construct.rs` :7-12 (imports), :193-275 (`introspect_cmd`), append new fns after `introspect_cmd` +- Test: `crates/aura-cli/tests/graph_construct.rs` (append helpers + 5 tests at end of file) + +Context for the implementer: #196 closes the campaign authoring gap. Three additions to the `aura graph` verb family: (a) `aura graph register ` — parse the file through the project vocabulary (`blueprint_from_json`), canonicalize (`blueprint_to_json`), content-address (`crate::content_id`, the one shared primitive), store via `Registry::put_blueprint`, print `registered blueprint content:{id} ({path})` (the `process register` pattern); (b) `aura graph introspect --params ` — print the RAW composite `param_space()` (NO harness wrap — this is the namespace `validate_campaign_refs` checks campaign axes against), one `{name}:{kind:?}` line per open param; (c) `--content-id` gains an optional FILE value — a JSON object is the #155 blueprint envelope (`format_version` + `blueprint`), a JSON array a construction op-list, each canonicalized by its own rules; without FILE the stdin op-list behaviour is byte-identical to today. The one-mode guard extends over the new `--params` mode (usage exit 2). + +- [ ] **Step 1: Verify the Task-3 dependency — `Registry::blueprint_path` must be `pub`** + +Run: `grep -n "pub fn blueprint_path" crates/aura-registry/src/lib.rs` + +Expected: one match (Task 3 already promoted it). **Only if there is NO match**, apply this edit to `crates/aura-registry/src/lib.rs` (then re-run the grep and see one match): + +Replace: +```rust + /// The single content-id→path mapping `put_blueprint` and `get_blueprint` both + /// route through, so the store can never write one path and read another (a + /// drifted key would silently break round-trip — `get` returns `None` and + /// reproduction cannot re-derive the member, rather than erroring). + fn blueprint_path(&self, hash: &str) -> PathBuf { + self.blueprints_dir().join(format!("{hash}.json")) + } +``` +with: +```rust + /// The store path a blueprint with this content id lives at — the single + /// content-id→path mapping `put_blueprint` and `get_blueprint` both + /// route through, so the store can never write one path and read another (a + /// drifted key would silently break round-trip — `get` returns `None` and + /// reproduction cannot re-derive the member, rather than erroring). Public + /// so consumers (`aura graph register`) never re-derive the layout (the + /// `process_path` pattern). + pub fn blueprint_path(&self, hash: &str) -> PathBuf { + self.blueprints_dir().join(format!("{hash}.json")) + } +``` + +- [ ] **Step 2: Append the five RED tests + helpers to `crates/aura-cli/tests/graph_construct.rs`** + +Append at the end of the file (after `graph_introspect_no_flag_is_usage_exit_2`, line 380): + +```rust +/// A fresh, unique working directory for a test that persists content-addressed +/// blueprints under `./runs/` (mirrors `research_docs.rs`'s `temp_cwd`). +fn temp_cwd(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("aura-graph-{}-{}", std::process::id(), name)); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("create temp cwd"); + dir +} + +/// Run `aura ` in `dir` (no stdin); return (stdout, stderr, exit code). +fn run_in(dir: &std::path::Path, args: &[&str]) -> (String, String, Option) { + let out = Command::new(BIN) + .args(args) + .current_dir(dir) + .output() + .expect("binary runs"); + ( + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + out.status.code(), + ) +} + +/// The absolute path of a blueprint fixture shipped with this test crate. +fn fixture(name: &str) -> String { + format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR")) +} + +/// The id extracted from a `registered blueprint content:{id} ({path})` line. +fn registered_id(stdout: &str) -> String { + stdout + .lines() + .find(|l| l.starts_with("registered blueprint content:")) + .expect("register line") + .trim_start_matches("registered blueprint content:") + .split(' ') + .next() + .expect("id") + .to_string() +} + +/// Property (#196, the on-ramp): `aura graph register ` parses +/// the document through the vocabulary, canonicalizes, and stores it content- +/// addressed under `runs/blueprints/.json`, printing the id + store path. +#[test] +fn graph_register_stores_and_prints_content_id() { + let dir = temp_cwd("register"); + let (stdout, stderr, code) = run_in(&dir, &["graph", "register", &fixture("sma_signal.json")]); + assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}"); + let id = registered_id(&stdout); + assert_eq!(id.len(), 64, "a 64-hex content id: {id:?}"); + assert!(id.chars().all(|c| c.is_ascii_hexdigit()), "hex only: {id:?}"); + assert!( + dir.join("runs").join("blueprints").join(format!("{id}.json")).is_file(), + "stored under runs/blueprints/.json: {stdout}" + ); +} + +/// Register is write-once content-addressed: the same document registers to the +/// same id, exit 0 both times (the idempotent `put_blueprint` contract). +#[test] +fn graph_register_is_idempotent() { + let dir = temp_cwd("register-idempotent"); + let bp = fixture("sma_signal.json"); + let (out1, err1, code1) = run_in(&dir, &["graph", "register", &bp]); + let (out2, err2, code2) = run_in(&dir, &["graph", "register", &bp]); + assert_eq!(code1, Some(0), "first register: {out1} {err1}"); + assert_eq!(code2, Some(0), "second register: {out2} {err2}"); + assert_eq!(registered_id(&out1), registered_id(&out2), "same id twice"); +} + +/// Property (#196, the campaign-axis namespace): `--params ` prints the +/// RAW composite param space — one `{name}:{kind:?}` line per open param, in +/// lowering order, WITHOUT the harness-wrap prefix `aura sweep --list-axes` +/// shows. The open fixture leaves exactly the two SMA lengths unbound +/// (`bias.scale` is bound in the document). +#[test] +fn graph_params_lists_raw_axis_namespace() { + let dir = temp_cwd("params"); + let (stdout, stderr, code) = + run_in(&dir, &["graph", "introspect", "--params", &fixture("sma_signal_open.json")]); + assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}"); + assert_eq!(stdout, "fast.length:I64\nslow.length:I64\n", "the raw open params, in order"); +} + +/// Property (#196, file-mode --content-id): a blueprint FILE's printed content +/// id is exactly the store key `graph register` prints — the file is shape- +/// discriminated from the op-list form and canonicalized by the blueprint rules. +#[test] +fn graph_content_id_accepts_a_blueprint_file() { + let dir = temp_cwd("content-id-file"); + let bp = fixture("sma_signal.json"); + let (reg_out, reg_err, reg_code) = run_in(&dir, &["graph", "register", &bp]); + assert_eq!(reg_code, Some(0), "register: {reg_out} {reg_err}"); + let (id_out, id_err, id_code) = run_in(&dir, &["graph", "introspect", "--content-id", &bp]); + assert_eq!(id_code, Some(0), "content-id: {id_out} {id_err}"); + assert_eq!(id_out.trim(), registered_id(®_out), "file content id == store key"); +} + +/// The one-mode guard extends over the new `--params` mode: zero modes and two +/// modes both refuse usage-exit-2, and the usage line names the new mode. +#[test] +fn graph_introspect_mode_guard_still_exits_two_on_zero_or_two_modes() { + let dir = temp_cwd("mode-guard"); + let (_out0, err0, code0) = run_in(&dir, &["graph", "introspect"]); + assert_eq!(code0, Some(2), "zero modes is usage exit 2: {err0}"); + assert!(err0.contains("--params "), "usage line names --params: {err0}"); + let (_out2, err2, code2) = + run_in(&dir, &["graph", "introspect", "--vocabulary", "--params", "x.json"]); + assert_eq!(code2, Some(2), "two modes is usage exit 2: {err2}"); + assert!(err2.contains("Usage: aura graph introspect"), "prints the usage line: {err2}"); +} +``` + +- [ ] **Step 3: Run the new tests RED** + +Run: `cargo test -p aura-cli --test graph_construct graph_` + +Expected: compiles; `21 passed; 5 failed` — the five failures are exactly `graph_register_stores_and_prints_content_id`, `graph_register_is_idempotent`, `graph_params_lists_raw_axis_namespace`, `graph_content_id_accepts_a_blueprint_file`, `graph_introspect_mode_guard_still_exits_two_on_zero_or_two_modes` (clap rejects the unknown `register` subcommand / `--params` flag with exit 2, and the old usage line lacks `--params `). + +- [ ] **Step 4: Add the `Register` variant to `GraphSub` in `crates/aura-cli/src/main.rs` (:3801-3807)** + +Replace: +```rust +#[derive(Subcommand)] +enum GraphSub { + /// Construct a graph from a stdin op-list. + Build, + /// Introspect a graph. + Introspect(GraphIntrospectCmd), +} +``` +with: +```rust +#[derive(Subcommand)] +enum GraphSub { + /// Construct a graph from a stdin op-list. + Build, + /// Introspect a graph. + Introspect(GraphIntrospectCmd), + /// Register a blueprint document into the content-addressed store (#196). + Register { + /// The blueprint .json file to register. + file: std::path::PathBuf, + }, +} +``` + +- [ ] **Step 5: Extend `GraphIntrospectCmd` — optional FILE on `--content-id`, new `--params` (:3820-3826)** + +Replace: +```rust + /// Print the graph's content id (topology hash). + #[arg(long)] + content_id: bool, + /// Print the graph's topology-identity id (debug names stripped). + #[arg(long)] + identity_id: bool, +} +``` +with: +```rust + /// Print the graph's content id (topology hash). With FILE, read the + /// document from it (a blueprint envelope or an op-list, shape- + /// discriminated, #196); without FILE, read a stdin op-list as before. + #[arg(long, value_name = "FILE")] + content_id: Option>, + /// Print the graph's topology-identity id (debug names stripped). + #[arg(long)] + identity_id: bool, + /// Print a blueprint's raw param space (the campaign-axis namespace), one + /// name:kind line per open param. FILE path or 64-hex store content id (#196). + #[arg(long, value_name = "FILE|ID")] + params: Option, +} +``` + +- [ ] **Step 6: Dispatch the new subcommand in `dispatch_graph` (:4338-4344)** + +Replace: +```rust +fn dispatch_graph(a: GraphCmd, env: &project::Env) { + match a.sub { + None => print!("{}", render::render_html(&sample_blueprint())), + Some(GraphSub::Build) => graph_construct::build_cmd(env), + Some(GraphSub::Introspect(i)) => graph_construct::introspect_cmd(i, env), + } +} +``` +with: +```rust +fn dispatch_graph(a: GraphCmd, env: &project::Env) { + match a.sub { + None => print!("{}", render::render_html(&sample_blueprint())), + Some(GraphSub::Build) => graph_construct::build_cmd(env), + Some(GraphSub::Introspect(i)) => graph_construct::introspect_cmd(i, env), + Some(GraphSub::Register { file }) => graph_construct::register_cmd(&file, env), + } +} +``` + +- [ ] **Step 7: Extend the imports in `crates/aura-cli/src/graph_construct.rs` (:7-12)** + +Replace: +```rust +use std::collections::BTreeMap; + +use aura_engine::{ + blueprint_identity_json, blueprint_to_json, replay, BindOpError, Composite, GraphSession, Op, + OpError, Scalar, ScalarKind, +}; +``` +with: +```rust +use std::collections::BTreeMap; +use std::path::Path; + +use aura_engine::{ + blueprint_from_json, blueprint_identity_json, blueprint_to_json, replay, BindOpError, + Composite, GraphSession, LoadError, Op, OpError, Scalar, ScalarKind, +}; +``` + +- [ ] **Step 8: Replace `introspect_cmd` in `crates/aura-cli/src/graph_construct.rs` (:193-275) — extended guard, `--params` mode, file-mode `--content-id`** + +Replace: +```rust +/// `aura graph introspect`: dispatch the read-only queries. Exactly one of +/// `--vocabulary` / `--node ` / `--unwired` / the id group must be set; zero +/// or more than one is the usage error (exit 2). The id group is `--content-id` +/// and/or `--identity-id` — the two id flags may combine (one build, both ids, +/// content id first). +pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &crate::project::Env) { + let count = cmd.vocabulary as usize + + cmd.node.is_some() as usize + + cmd.unwired as usize + + (cmd.content_id || cmd.identity_id) as usize; + if count != 1 { + eprintln!( + "aura: Usage: aura graph introspect --vocabulary | --node | --unwired | --content-id | --identity-id (the two id flags may be combined)" + ); + std::process::exit(2); + } + if cmd.vocabulary { + for t in env.type_ids() { + println!("{t}"); + } + } else if let Some(type_id) = cmd.node.as_deref() { + match introspect_node(type_id, env) { + Ok(s) => print!("{s}"), + Err(m) => { + eprintln!("aura: {m}"); + std::process::exit(2); + } + } + } else if cmd.unwired { + use std::io::Read; + let mut doc = String::new(); + if let Err(e) = std::io::stdin().read_to_string(&mut doc) { + eprintln!("aura: reading stdin: {e}"); + std::process::exit(1); + } + match introspect_unwired(&doc, env) { + Ok(s) => print!("{s}"), + Err(m) => { + eprintln!("aura: {m}"); + std::process::exit(1); + } + } + } else { + // --content-id / --identity-id (combinable): one build of the op-list, then + // each requested id on its own line, content id first. The content id (#158) + // is the SHA256 of the same `blueprint_to_json` bytes `graph build` emits; + // the identity id (#171) the SHA256 of the debug-name-blind + // `blueprint_identity_json` form — both via the one shared + // `crate::content_id` primitive `topology_hash` also uses, so all surfaces + // agree by construction. + use std::io::Read; + let mut doc = String::new(); + if let Err(e) = std::io::stdin().read_to_string(&mut doc) { + eprintln!("aura: reading stdin: {e}"); + std::process::exit(1); + } + let composite = match composite_from_str(&doc, env) { + Ok(c) => c, + Err(m) => { + eprintln!("aura: {m}"); + std::process::exit(1); + } + }; + if cmd.content_id { + match blueprint_to_json(&composite) { + Ok(json) => println!("{}", crate::content_id(&json)), + Err(e) => { + eprintln!("aura: serialize error: {e:?}"); + std::process::exit(1); + } + } + } + if cmd.identity_id { + match blueprint_identity_json(&composite) { + Ok(json) => println!("{}", crate::content_id(&json)), + Err(e) => { + eprintln!("aura: serialize error: {e:?}"); + std::process::exit(1); + } + } + } + } +} +``` +with: +```rust +/// `aura graph introspect`: dispatch the read-only queries. Exactly one of +/// `--vocabulary` / `--node ` / `--unwired` / `--params ` / the id +/// group must be set; zero or more than one is the usage error (exit 2). The id +/// group is `--content-id [FILE]` and/or `--identity-id` — the two id flags may +/// combine (one build, both ids, content id first). +pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &crate::project::Env) { + let count = cmd.vocabulary as usize + + cmd.node.is_some() as usize + + cmd.unwired as usize + + cmd.params.is_some() as usize + + (cmd.content_id.is_some() || cmd.identity_id) as usize; + if count != 1 { + eprintln!( + "aura: Usage: aura graph introspect --vocabulary | --node | --unwired | --params | --content-id [FILE] | --identity-id (the two id flags may be combined)" + ); + std::process::exit(2); + } + if cmd.vocabulary { + for t in env.type_ids() { + println!("{t}"); + } + } else if let Some(type_id) = cmd.node.as_deref() { + match introspect_node(type_id, env) { + Ok(s) => print!("{s}"), + Err(m) => { + eprintln!("aura: {m}"); + std::process::exit(2); + } + } + } else if cmd.unwired { + use std::io::Read; + let mut doc = String::new(); + if let Err(e) = std::io::stdin().read_to_string(&mut doc) { + eprintln!("aura: reading stdin: {e}"); + std::process::exit(1); + } + match introspect_unwired(&doc, env) { + Ok(s) => print!("{s}"), + Err(m) => { + eprintln!("aura: {m}"); + std::process::exit(1); + } + } + } else if let Some(target) = cmd.params.as_deref() { + // --params (#196): the RAW composite param space — exactly the + // namespace campaign axes are validated against (`validate_campaign_refs` + // checks the raw space; the wrapped `--list-axes` namespace on `aura sweep` + // is the sweep-verb view, not the campaign view). + match params_lines(target, env) { + Ok(s) => print!("{s}"), + Err(m) => { + eprintln!("aura: {m}"); + std::process::exit(1); + } + } + } else { + // --content-id [FILE] / --identity-id (combinable): one build, then each + // requested id on its own line, content id first. With a FILE value the + // document is read from it, shape-discriminated (#196): a JSON array is a + // construction op-list, a JSON object the #155 blueprint envelope — so a + // registered blueprint's printed id is exactly its store key. Without a + // FILE the op-list is read from stdin (the pre-#196 behaviour, unchanged). + // The content id (#158) is the SHA256 of the same `blueprint_to_json` + // bytes `graph build` emits; the identity id (#171) the SHA256 of the + // debug-name-blind `blueprint_identity_json` form — both via the one + // shared `crate::content_id` primitive `topology_hash` also uses, so all + // surfaces agree by construction. + let composite = match cmd.content_id.as_ref() { + Some(Some(file)) => { + let text = match std::fs::read_to_string(file) { + Ok(t) => t, + Err(e) => { + eprintln!("aura: cannot read {}: {e}", file.display()); + std::process::exit(1); + } + }; + match composite_from_any(&text, env) { + Ok(c) => c, + Err(m) => { + eprintln!("aura: {m}"); + std::process::exit(1); + } + } + } + _ => { + use std::io::Read; + let mut doc = String::new(); + if let Err(e) = std::io::stdin().read_to_string(&mut doc) { + eprintln!("aura: reading stdin: {e}"); + std::process::exit(1); + } + match composite_from_str(&doc, env) { + Ok(c) => c, + Err(m) => { + eprintln!("aura: {m}"); + std::process::exit(1); + } + } + } + }; + if cmd.content_id.is_some() { + match blueprint_to_json(&composite) { + Ok(json) => println!("{}", crate::content_id(&json)), + Err(e) => { + eprintln!("aura: serialize error: {e:?}"); + std::process::exit(1); + } + } + } + if cmd.identity_id { + match blueprint_identity_json(&composite) { + Ok(json) => println!("{}", crate::content_id(&json)), + Err(e) => { + eprintln!("aura: serialize error: {e:?}"); + std::process::exit(1); + } + } + } + } +} +``` + +- [ ] **Step 9: Append the #196 helper fns + `register_cmd` to `crates/aura-cli/src/graph_construct.rs`** + +Insert immediately after the closing brace of `introspect_cmd` (before the `#[cfg(test)]` module): + +```rust +/// Phrase a blueprint-document `LoadError` as prose (Debug-leak-free; the +/// engine error types are `Display`-free by convention — this is the CLI's +/// presentation layer, like `format_op_error`). +fn blueprint_load_prose(e: &LoadError) -> String { + match e { + LoadError::Json(err) => format!("blueprint document is not valid JSON: {err}"), + LoadError::UnsupportedVersion { found, supported } => { + format!("blueprint format_version {found} is unsupported (this build reads {supported})") + } + LoadError::UnknownNodeType(t) => format!("unknown node type {t:?}"), + } +} + +/// #196: build a `Composite` from a document that is EITHER a #155 blueprint +/// envelope (a JSON object: `format_version` + `blueprint`) OR a construction +/// op-list (a JSON array) — shape-discriminated on the top-level JSON type, +/// each canonicalized by its own rules. +fn composite_from_any(text: &str, env: &crate::project::Env) -> Result { + let value: serde_json::Value = + serde_json::from_str(text).map_err(|e| format!("invalid document: {e}"))?; + match value { + serde_json::Value::Array(_) => composite_from_str(text, env), + serde_json::Value::Object(_) => { + blueprint_from_json(text, &|t| env.resolve(t)).map_err(|e| blueprint_load_prose(&e)) + } + _ => Err("document is neither a blueprint envelope (object) nor an op-list (array)".into()), + } +} + +/// Resolve a blueprint document's bytes from a file path or a 64-hex content +/// id in the project store (the campaign-run target-addressing convention). +fn resolve_blueprint_text(target: &str, env: &crate::project::Env) -> Result { + let path = Path::new(target); + if path.is_file() { + return std::fs::read_to_string(path) + .map_err(|e| format!("cannot read {}: {e}", path.display())); + } + if target.len() == 64 && target.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')) { + return match env.registry().get_blueprint(target) { + Ok(Some(json)) => Ok(json), + Ok(None) => Err(format!("no blueprint {target} in the project store")), + Err(e) => Err(e.to_string()), + }; + } + Err(format!("'{target}' is neither a readable .json file nor a 64-hex content id")) +} + +/// `aura graph introspect --params ` (#196): one `{name}:{kind:?}` +/// line per open param of the RAW composite (no harness wrap) — the +/// campaign-axis namespace `validate_campaign_refs` checks axes against. +fn params_lines(target: &str, env: &crate::project::Env) -> Result { + let text = resolve_blueprint_text(target, env)?; + let composite = + blueprint_from_json(&text, &|t| env.resolve(t)).map_err(|e| blueprint_load_prose(&e))?; + let mut out = String::new(); + for p in composite.param_space() { + out.push_str(&format!("{}:{:?}\n", p.name, p.kind)); // ScalarKind Debug -> I64/F64/Bool/Timestamp + } + Ok(out) +} + +/// `aura graph register ` (#196): parse the blueprint through +/// the project vocabulary, canonicalize, content-address, and store — the +/// `process register` pattern, printing the store path so the trail is +/// followable. Prose to stderr + exit 1 on any refusal. +pub fn register_cmd(file: &Path, env: &crate::project::Env) { + match register_blueprint(file, env) { + Ok(line) => println!("{line}"), + Err(m) => { + eprintln!("aura: {m}"); + std::process::exit(1); + } + } +} + +fn register_blueprint(file: &Path, env: &crate::project::Env) -> Result { + let text = std::fs::read_to_string(file) + .map_err(|e| format!("cannot read {}: {e}", file.display()))?; + let composite = + blueprint_from_json(&text, &|t| env.resolve(t)).map_err(|e| blueprint_load_prose(&e))?; + let canonical = blueprint_to_json(&composite).map_err(|e| format!("serialize error: {e:?}"))?; + let id = crate::content_id(&canonical); + let registry = env.registry(); + registry.put_blueprint(&id, &canonical).map_err(|e| e.to_string())?; + Ok(format!( + "registered blueprint content:{id} ({})", + registry.blueprint_path(&id).display() + )) +} +``` + +- [ ] **Step 10: Run the new tests GREEN** + +Run: `cargo test -p aura-cli --test graph_construct graph_` + +Expected: `26 passed; 0 failed` — the 5 new tests pass, all 21 existing tests in the file (including `graph_introspect_content_and_identity_id_combine` and `graph_introspect_no_flag_is_usage_exit_2`, which pin the preserved stdin/combine and guard behaviour) stay green. + +- [ ] **Step 11: Run the wider graph-named test set** + +Run: `cargo test -p aura-cli graph_` + +Expected: all matched tests pass (the 26 above plus any unit tests whose module path contains `graph_`); `0 failed`. + +- [ ] **Step 12: Lint gate** + +Run: `cargo clippy -p aura-cli --all-targets -- -D warnings` + +Expected: exit 0, no warnings. + +- [ ] **Step 13: Workspace gate** + +Run: `cargo test --workspace` + +Expected: green — `0 failed` across all crates.