Files
Aura/docs/plans/0107-campaign-executor.md
T
Brummel 0cc1858d86 plan: 0107 campaign executor
10 tasks, strictly sequential: walk_forward vocabulary correction
(research + cli twins, workspace-green per task), engine ListSpace,
registry campaign-run store + visibility promotions, aura-campaign
crate (scaffold/types, preflight, execute sweep+gate, walk-forward
stage via byte-exact stub replacement), CLI verb + MemberRunner driver,
seam tests + gated real-data e2e, #196 on-ramp verbs.

Drafted per-task against the live tree, seam-reconciled (task 6/7
signature + module home unified; scaffold dead-code allowances dropped
by the task that consumes them), self-reviewed per the planner
checklist (no placeholders, single-filter test gates, compile-gate
ordering honoured via partial -p gates in task 1).

refs #198, refs #196
2026-07-03 18:49:56 +02:00

214 KiB
Raw Blame History

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 <file|content-id> 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.rsexecute, 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.rsstd::walk_forward correction (variant, WfMode, tables, parser, validator, faults, fixtures, golden pin)
  • Modify: crates/aura-engine/src/sweep.rs + src/lib.rsListSpace + 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.rsmod campaign_run; + GraphSub::Register + introspect --params / --content-id [FILE]
  • Modify: crates/aura-cli/src/research_docs.rsCampaignSub::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):

        { "block": "std::walk_forward", "folds": 4, "in_sample_bars": 4000,
          "out_of_sample_bars": 1000, "metric": "net_expectancy_r", "select": "argmax" }

with (new):

        { "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):

    #[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):

    #[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):

/// 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):

/// 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):

pub enum SlotKind {
    MetricName,
    SelectRule,
    Bool,

with (new):

pub enum SlotKind {
    MetricName,
    SelectRule,
    /// Walk-forward roll mode (`WfMode` wire strings).
    WfMode,
    Bool,

Then in slot_kind_label (lines 706-707), replace (old):

        SlotKind::SelectRule => "select rule: argmax | plateau:mean | plateau:worst",
        SlotKind::Bool => "bool",

with (new):

        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):

    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):

    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):

fn select_from(
    m: &serde_json::Map<String, serde_json::Value>,
    block: &str,
) -> Result<SelectRule, String> {
    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):

fn select_from(
    m: &serde_json::Map<String, serde_json::Value>,
    block: &str,
) -> Result<SelectRule, String> {
    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<String, serde_json::Value>,
    block: &str,
) -> Result<WfMode, String> {
    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):

        "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):

        "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):

    GateFirst,
    GateAfterTerminalStage { stage: usize },
    // campaign side

with (new):

    GateFirst,
    GateAfterTerminalStage { stage: usize },
    ZeroWalkForwardLength { stage: usize, field: &'static str },
    // campaign side

Then in validate_process (lines 592-598), replace (old):

            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):

            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):

        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):

        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):

        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):

        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):

        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):

        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):

        assert!(validate_process(&gate_after)
            .contains(&DocFault::GateAfterTerminalStage { stage: 2 }));
    }

with (new — same closing, plus the two new tests):

        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):

        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):

        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):

        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):

        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):

    { "block": "std::walk_forward", "folds": 4, "in_sample_bars": 4000,
      "out_of_sample_bars": 1000, "metric": "net_expectancy_r", "select": "argmax" }

with (new):

    { "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):

    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):

    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):

    assert!(out.contains("unknown metric \"netto_r\""));
    assert!(!out.contains("UnknownMetric"), "Debug leak: {out}");
}

with (new — same closing, plus the new e2e test):

    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):

    #[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:

    #[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::<Vec<_>>().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::<Vec<_>>().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):

/// 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:

/// 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<ParamSpec>,
    points: Vec<Vec<Scalar>>,
}

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<Vec<Scalar>>) -> Result<Self, SweepError> {
        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<Vec<Cell>> {
        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):

//! 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:

//! 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):

/// 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:

/// 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):

pub use sweep::{
    sweep, GridSpace, ParamRange, RandomSpace, Space, SweepError, SweepFamily, SweepPoint,
};

Replace it with:

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_emptyListSpace 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:

        match check_r_metric("nope") {
            Err(RegistryError::UnknownMetric(m)) => assert_eq!(m, "nope"),
            other => panic!("expected UnknownMetric, got {other:?}"),
        }
    }
}

with:

        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<_>>(),
            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::<CampaignRunRecord>::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:

//! ([`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:

//! ([`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:

/// 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<FamilyRunRecord>,
}

with:

/// 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<FamilyRunRecord>,
}

/// 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<CellRealization>,
}

/// 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<StageRealization>,
}

/// 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<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub survivor_ordinals: Option<Vec<usize>>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub selection: Option<StageSelection>,
}

/// 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:

        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:

        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<usize, RegistryError> {
        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<Vec<CampaignRunRecord>, 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:

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:

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:

pub use lineage::{
    group_families, mc_member_reports, sweep_member_reports, walkforward_member_reports, Family,
    FamilyKind, FamilyRunRecord,
};

with:

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:

    /// 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:

    /// 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:

    /// 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<PrimitiveBuilder>,
    ) -> Result<Option<String>, RegistryError> {

with:

    /// 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<PrimitiveBuilder>,
    ) -> Result<Option<String>, 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:

    "crates/aura-registry",
    "crates/aura-research",
]

with:

    "crates/aura-registry",
    "crates/aura-research",
    "crates/aura-campaign",
]
  • Step 2: Create the crate manifest

Create crates/aura-campaign/Cargo.toml with exactly:

[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:

//! 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<String, Axis>,
    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<RunReport, MemberFault>;
}

/// 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<f64> {
    todo!("cycle 0107 task 4")
}

/// Whether `value <cmp> 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:

pub fn member_metric(report: &RunReport, name: &str) -> Option<f64> {
    todo!("cycle 0107 task 4")
}

with:

pub fn member_metric(report: &RunReport, name: &str) -> Option<f64> {
    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:

fn predicate_holds(cmp: &Cmp, value: f64, threshold: f64) -> bool {
    todo!("cycle 0107 task 4")
}

with:

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:

use aura_research::{Axis, Cmp};

with:

use aura_research::{Axis, CampaignDoc, Cmp, ProcessDoc};

Then, in the same file, replace:

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:

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:

        assert!(predicate_holds(&Cmp::Le, 0.0, 0.0));
        assert!(!predicate_holds(&Cmp::Le, 0.1, 0.0));
    }
}

with:

        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<StageBlock>) -> 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:

use aura_research::{Axis, CampaignDoc, Cmp, ProcessDoc};

with:

use aura_research::{Axis, CampaignDoc, Cmp, ProcessDoc, SelectRule, StageBlock};

Then, in the same file, replace:

pub(crate) fn preflight(process: &ProcessDoc, _campaign: &CampaignDoc) -> Result<(), ExecFault> {
    todo!("cycle 0107 task 5")
}

with:

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<String, Axis>, 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<RunReport, MemberFault>; }; 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<f64>; 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<Vec<Scalar>>) -> Result<Self, SweepError>) 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<StageFamily, ExecFault>survivors crosses the seam as plain &[Vec<Scalar>] (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:

//! 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<RunReport, MemberFault> {
        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<String, Axis> {
    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<StageBlock>) -> 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(), &reg)
        .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<_>>(), 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(), &reg)
        .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(), &reg)
        .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(), &reg)
        .expect("first run");
    let second = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
        .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, &reg)
        .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(), &reg)
        .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:

//! 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<RunReport>,
}

/// 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<StageFamily>,
    pub selections: Vec<StageSelectionOut>,
}

/// 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<CellOutcome>,
}

/// 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<CampaignOutcome, ExecFault> {
    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<CellOutcome> = Vec::new();
    let mut cells_rec: Vec<CellRealization> = 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<StageFamily> = Vec::new();
    let mut selections: Vec<StageSelectionOut> = Vec::new();
    let mut stages: Vec<StageRealization> = 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<Scalar>, 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<usize> = 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<Vec<Scalar>> =
                    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<ParamSpec>,
    axis_lens: Vec<usize>,
    points: Vec<Vec<Scalar>>,
}

/// 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<String, Axis>) -> SweepGrid {
    let specs: Vec<ParamSpec> = 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<usize> = 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<SweepFamily, ExecFault> {
    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<Vec<Cell>> =
        grid.points.iter().map(|p| p.iter().map(|s| s.cell()).collect()).collect();
    let faults: Mutex<Vec<(usize, MemberFault)>> = Mutex::new(Vec::new());
    let family = sweep(&space, |point: &[Cell]| {
        let params = zip_params(&grid.specs, point);
        match runner.run_member(cell, &params, 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(&params, 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<Scalar> {
    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<Scalar>],
    family_name: &str,
    runner: &dyn MemberRunner,
    registry: &Registry,
) -> Result<StageFamily, ExecFault> {
    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):

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:

/// Whether `value <cmp> 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:

/// Whether `value <cmp> threshold` holds — the gate's comparator arm.
fn predicate_holds(cmp: &Cmp, value: f64, threshold: f64) -> bool {

then replace:

/// 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:

/// 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):

#[allow(clippy::too_many_arguments)]
fn run_walk_forward_stage(
    seed: u64,
    cell: &CellSpec,
    stage: usize,
    block: &StageBlock,
    specs: &[ParamSpec],
    survivors: &[Vec<Scalar>],
    family_name: &str,
    runner: &dyn MemberRunner,
    registry: &Registry,
) -> Result<StageFamily, ExecFault> {
    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):

#[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<f64>, 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<Vec<((i64, i64), Vec<(String, Scalar)>)>>,
    }

    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<RunReport, MemberFault> {
            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<CampaignOutcome, ExecFault> {
        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, &registry).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, &registry).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, &registry).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, &registry);
        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:

use aura_engine::{sweep, ListSpace, RunManifest, RunMetrics, RunReport, SweepFamily, SweepPoint};

with:

use aura_engine::{
    sweep, walk_forward, ListSpace, RollMode, RunManifest, RunMetrics, RunReport, Space,
    SweepFamily, SweepPoint, WindowBounds, WindowRoller, WindowRun,
};

then replace:

use aura_registry::{
    optimize, optimize_deflated, optimize_plateau, sweep_member_reports, CampaignRunRecord,
    CellRealization, FamilyKind, PlateauMode, Registry, StageRealization, StageSelection,
};

with:

use aura_registry::{
    optimize, optimize_deflated, optimize_plateau, sweep_member_reports,
    walkforward_member_reports, CampaignRunRecord, CellRealization, FamilyKind, PlateauMode,
    Registry, StageRealization, StageSelection,
};

then replace:

use aura_research::{Axis, CampaignDoc, DocRef, ProcessDoc, SelectRule, StageBlock};

with:

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:

/// 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:

/// 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<Scalar>],
    family_name: &str,
    runner: &dyn MemberRunner,
    registry: &Registry,
) -> Result<StageFamily, ExecFault> {
    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<WindowBounds> = 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<Vec<(usize, ExecFault)>> = 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<Vec<(usize, MemberFault)>> = 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 <file|content-id> over the aura-campaign library (Tasks 47) — 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 1920)

  • Modify: crates/aura-cli/src/main.rs (mod block, lines 1418)

  • Modify: crates/aura-cli/src/research_docs.rs (:70, :87, :121, :236244, :246, :264277, :279)

  • Create: crates/aura-cli/src/campaign_run.rs

  • Step 1: Add the aura-campaign dependency to crates/aura-cli/Cargo.toml

Replace (old):

aura-registry = { path = "../aura-registry" }
aura-research = { path = "../aura-research" }

with (new):

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):

mod render;
mod graph_construct;
mod project;
mod research_docs;
mod scaffold;

with (new):

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):

fn doc_error_prose(what: &str, e: &DocError) -> String {

with (new):

pub(crate) fn doc_error_prose(what: &str, e: &DocError) -> String {

Replace (old, line 87):

fn doc_fault_prose(f: &DocFault) -> String {

with (new):

pub(crate) fn doc_fault_prose(f: &DocFault) -> String {

Replace (old, line 121):

fn fault_block(header: &str, lines: Vec<String>) -> String {

with (new):

pub(crate) fn fault_block(header: &str, lines: Vec<String>) -> String {

Replace (old, line 246):

fn ref_fault_prose(f: &RefFault) -> String {

with (new):

pub(crate) fn ref_fault_prose(f: &RefFault) -> String {

Replace (old, line 279):

fn parse_valid_campaign(file: &PathBuf) -> Result<CampaignDoc, String> {

with (new):

pub(crate) fn parse_valid_campaign(file: &PathBuf) -> Result<CampaignDoc, String> {
  • Step 4: Add the CampaignSub::Run variant (research_docs.rs:236244)

Replace (old):

    /// Register a valid campaign document into the store under the runs root.
    Register { file: PathBuf },
}

with (new):

    /// 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:264277)

Replace (old):

        CampaignSub::Register { file } => register_campaign(file, env),
    };
    if let Err(m) = result {

with (new):

        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)
//! `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<data_server::DataServer>,
}

impl MemberRunner for CliMemberRunner<'_> {
    fn run_member(
        &self,
        cell: &CellSpec,
        params: &[(String, Scalar)],
        window_ms: (i64, i64),
    ) -> Result<RunReport, MemberFault> {
        // 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<Option<Scalar>> = vec![None; space.len()];
        for (raw, value) in params {
            let hits: Vec<usize> = 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<Cell> = 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 <target>`: 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,
        &registry,
    )
    .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 :56; existing fixture test :196199; helpers + six new tests appended at end of file)

  • Step 1: Widen the std::sync import

Replace (old, lines 56):

use std::path::{Path, PathBuf};
use std::sync::OnceLock;

with (new):

use std::path::{Path, PathBuf};
use std::sync::{Mutex, MutexGuard, OnceLock};
  • Step 2: Add the fixture lock (insert above the ScratchPath enum)

Replace (old):

/// A scratch filesystem entry this test writes under the git-tracked

with (new):

/// Serializes every test that touches the shared demo-project fixture store
/// (they remove/re-seed `<fixture>/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 196199):

#[test]
fn campaign_validate_in_project_reports_referential_tier_end_to_end() {
    let dir = built_project();
    let runs_dir = dir.join("runs");

with (new):

#[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):

/// 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 <blueprint.json> — 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 <FILE|ID> — 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:

    /// 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:

    /// 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):

/// 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 <args>` in `dir` (no stdin); return (stdout, stderr, exit code).
fn run_in(dir: &std::path::Path, args: &[&str]) -> (String, String, Option<i32>) {
    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 <blueprint.json>` parses
/// the document through the vocabulary, canonicalizes, and stores it content-
/// addressed under `runs/blueprints/<id>.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/<id>.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 <FILE>` 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(&reg_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 <FILE|ID>"), "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 <FILE|ID>).

  • Step 4: Add the Register variant to GraphSub in crates/aura-cli/src/main.rs (:3801-3807)

Replace:

#[derive(Subcommand)]
enum GraphSub {
    /// Construct a graph from a stdin op-list.
    Build,
    /// Introspect a graph.
    Introspect(GraphIntrospectCmd),
}

with:

#[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:

    /// 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:

    /// 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<Option<std::path::PathBuf>>,
    /// 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<String>,
}
  • Step 6: Dispatch the new subcommand in dispatch_graph (:4338-4344)

Replace:

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:

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:

use std::collections::BTreeMap;

use aura_engine::{
    blueprint_identity_json, blueprint_to_json, replay, BindOpError, Composite, GraphSession, Op,
    OpError, Scalar, ScalarKind,
};

with:

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:

/// `aura graph introspect`: dispatch the read-only queries. Exactly one of
/// `--vocabulary` / `--node <T>` / `--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 <T> | --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:

/// `aura graph introspect`: dispatch the read-only queries. Exactly one of
/// `--vocabulary` / `--node <T>` / `--unwired` / `--params <FILE|ID>` / 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 <T> | --unwired | --params <FILE|ID> | --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 <FILE|ID> (#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):

/// 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<Composite, String> {
    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<String, String> {
    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 <FILE|ID>` (#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<String, String> {
    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 <blueprint.json>` (#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<String, String> {
    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.