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