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
214 KiB
Campaign Executor — Implementation Plan (cycle 0107)
Parent spec:
docs/specs/0107-campaign-executor.mdFor agentic workers: REQUIRED SUB-SKILL: use the
implementskill 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.rs—execute, stages, realization assembly - Create:
crates/aura-campaign/tests/execute.rs— fake-runner integration tests - Create:
crates/aura-cli/src/campaign_run.rs— verb flow +MemberRunnerdriver + prose - Modify:
Cargo.toml— workspace members +crates/aura-campaign - Modify:
crates/aura-research/src/lib.rs—std::walk_forwardcorrection (variant,WfMode, tables, parser, validator, faults, fixtures, golden pin) - Modify:
crates/aura-engine/src/sweep.rs+src/lib.rs—ListSpace+ re-export - Modify:
crates/aura-registry/src/lineage.rs+src/lib.rs— campaign-run records/store, pubblueprint_path, pubfind_blueprint_by_identity, re-exports - Modify:
crates/aura-cli/Cargo.toml— dep onaura-campaign - Modify:
crates/aura-cli/src/main.rs—mod campaign_run;+GraphSub::Register+ introspect--params/--content-id [FILE] - Modify:
crates/aura-cli/src/research_docs.rs—CampaignSub::Run,doc_fault_prosearm, 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_FIXTUREwalk_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::WalkForwardvariant and add theWfModeenum
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::WfModeand itsslot_kind_labelarm
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_BLOCKSstd::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_fromparser and rewrite thestage_from_valuewalk_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::ZeroWalkForwardLengthand split thevalidate_processWalkForward 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_prosearm 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_DOCto 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;SweepErrordocs :128-137; newListSpaceinserted before theSweepPointdoc at :285; tests appended tomod tests, whose last test ends at :812) - Modify:
crates/aura-engine/src/lib.rs(:77-79, thepub 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
ListSpaceincrates/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
ListSpacefrom 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_empty — ListSpace deliberately exposes no inherent len).
Task 3: aura-registry campaign-run records + store + visibility promotions
Files:
- Modify:
crates/aura-registry/src/lineage.rs(module doc :1-10, imports :12-19, afterFamilystruct :55-60,impl Registryblock :62-118, afternext_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 testsblock ofcrates/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
Familystruct
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_runsto the existingimpl Registryblock
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_runbesidenext_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_pathto pub with aprocess_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_identityto 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: theuse aura_researchline near the top; insertion afterpredicate_holds; appends insidemod tests) -
Test:
crates/aura-campaign/src/lib.rs(same in-file#[cfg(test)] mod tests) -
Step 1: Add the
preflightstub (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 firstuseor 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-campaignis a workspace member with dependenciesaura-core,aura-engine,aura-analysis,aura-registry,aura-research,serde(derive),serde_json.crates/aura-campaign/src/lib.rsdefines 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(derivesClone, Debug, PartialEq; variantsNoData { instrument, window_ms },Bind(String),Run(String));pub enum ExecFault(derivesDebug; variantsUnsupportedStage,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 shapestd::sweep (std::gate)* (std::walk_forward)?(it refuses mc/generalize, non-rankable metrics, annotation-metric gates, plateau-in-wf, deflate+plateau), and a privatefn 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 inexec.rsto the existing name — never re-implement the checks. Private crate-root items are reachable from a child module viacrate::….aura_engine::ListSpaceexists (explicit point set implementingSpace;ListSpace::new(space: &[ParamSpec], points: Vec<Vec<Scalar>>) -> Result<Self, SweepError>) and is re-exported fromaura-engine's lib.rs.aura_registryroot exportsCampaignRunRecord,CellRealization,StageRealization,StageSelectionandRegistry::{append_campaign_run, load_campaign_runs}(from the registry task), beside the existingoptimize/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(), ®)
.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(), ®)
.expect("gated campaign executes");
// planted nets 0.26 / 0.29 / 0.36 / 0.39 -> gt 0.3 keeps ordinals 2 and 3
let cell = &out.record.cells[0];
assert_eq!(cell.stages.len(), 2);
assert_eq!(cell.stages[1].block, "std::gate");
assert_eq!(cell.stages[1].survivor_ordinals, Some(vec![2, 3]));
assert_eq!(cell.stages[1].family_id, None);
assert_eq!(cell.stages[1].selection, None);
}
#[test]
fn execute_zero_survivors_truncates_cell_and_continues() {
let reg = temp_registry("zero_survivors");
// two instruments: the fake plants NEGATIVE nets for "AAA", positive for "BBB"
let doc = campaign(&["AAA", "BBB"]);
// sweep -> gate(net > 0) -> gate(net > -1000): AAA dies at stage 1, BBB passes both
let proc_doc = process(vec![sweep_stage(false), gate_stage(0.0), gate_stage(-1000.0)]);
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®)
.expect("a zero-survivor cell is a valid result, not a fault");
assert_eq!(out.record.cells.len(), 2, "the second cell still runs");
// cell 0 (AAA): realization truncated AT the empty gate — stage 2 never realized
let aaa = &out.record.cells[0];
assert_eq!(aaa.instrument, "AAA");
assert_eq!(aaa.stages.len(), 2);
assert_eq!(aaa.stages[1].survivor_ordinals, Some(vec![]));
// cell 1 (BBB): full pipeline realized
let bbb = &out.record.cells[1];
assert_eq!(bbb.instrument, "BBB");
assert_eq!(bbb.stages.len(), 3);
assert_eq!(bbb.stages[1].survivor_ordinals, Some(vec![0, 1, 2, 3]));
assert_eq!(bbb.stages[2].survivor_ordinals, Some(vec![0, 1, 2, 3]));
}
#[test]
fn execute_is_deterministic_twice() {
let reg = temp_registry("deterministic_twice");
let doc = campaign(&["EURUSD"]);
let proc_doc = process(vec![sweep_stage(false)]);
let first = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®)
.expect("first run");
let second = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®)
.expect("second run");
// run counter advances per campaign id
assert_eq!((first.run, second.run), (0, 1));
assert_eq!((first.record.run, second.record.run), (0, 1));
// family ids differ ONLY by the "-{run}" suffix
let base = format!("{}-0-EURUSD-w0-s0", &CAMPAIGN_ID[..8]);
assert_eq!(first.cells[0].families[0].family_id, format!("{base}-0"));
assert_eq!(second.cells[0].families[0].family_id, format!("{base}-1"));
// everything else in the record is identical (C1): normalize the two
// divergent fields and compare whole records
let mut normalized = second.record.clone();
normalized.run = first.record.run;
normalized.cells[0].stages[0].family_id = first.record.cells[0].stages[0].family_id.clone();
assert_eq!(normalized, first.record);
// both records stored, in order
let runs = reg.load_campaign_runs().expect("load campaign runs");
assert_eq!(runs.len(), 2);
assert_eq!(runs[0], first.record);
assert_eq!(runs[1], second.record);
}
#[test]
fn execute_member_fault_aborts_with_lowest_index() {
let reg = temp_registry("member_fault");
let doc = campaign(&["EURUSD"]);
let proc_doc = process(vec![sweep_stage(false)]);
// faults planted at enumeration indices 1 (2,9) and 2 (3,6)
let point = |fast: i64, slow: i64| {
vec![("fast".to_string(), Scalar::i64(fast)), ("slow".to_string(), Scalar::i64(slow))]
};
let runner = FakeRunner {
faults: vec![
(point(2, 9), MemberFault::Run("boom at index 1".to_string())),
(point(3, 6), MemberFault::Run("boom at index 2".to_string())),
],
};
let err = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &runner, ®)
.expect_err("a faulted member aborts the run");
match err {
ExecFault::Member(fault) => assert_eq!(
fault,
MemberFault::Run("boom at index 1".to_string()),
"the LOWEST enumeration index's fault is reported",
),
other => panic!("expected ExecFault::Member, got {other:?}"),
}
// the fault aborted before any family or realization write
assert!(reg.load_family_members().expect("load members").is_empty());
assert!(reg.load_campaign_runs().expect("load campaign runs").is_empty());
}
#[test]
fn execute_deflate_uses_campaign_seed() {
let reg = temp_registry("deflate_seed");
let doc = campaign(&["EURUSD"]); // seed: 7
let proc_doc = process(vec![sweep_stage(true)]);
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®)
.expect("deflated sweep executes");
let sel = out.record.cells[0].stages[0].selection.as_ref().expect("selection recorded");
assert_eq!(sel.selection.mode, SelectionMode::Argmax);
assert_eq!(sel.selection.seed, Some(7), "deflation nulls seed from the campaign doc");
assert_eq!(sel.selection.n_resamples, Some(1000));
assert_eq!(sel.selection.block_len, Some(5));
}
- Step 2: Run the RED gate
Run: cargo test -p aura-campaign execute
Expected: FAILS to compile — error[E0432]: unresolved import naming execute (not yet in aura_campaign); no tests run. This is the RED evidence for the new API.
- Step 3: Implement the executor module
Create crates/aura-campaign/src/exec.rs with exactly:
//! 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, ¶ms, cell.window_ms) {
Ok(report) => report,
Err(fault) => {
let index = cells
.iter()
.position(|c| c.as_slice() == point)
.expect("the sweep only enumerates the grid's own points");
faults.lock().expect("fault capture lock").push((index, fault));
placeholder_report(¶ms, cell.window_ms)
}
}
});
let mut captured = faults.into_inner().expect("fault capture lock");
captured.sort_by_key(|&(index, _)| index);
if let Some((_, fault)) = captured.into_iter().next() {
return Err(ExecFault::Member(fault));
}
Ok(family)
}
/// Slot filler for a faulted member: the engine sweep contract needs one
/// report per slot, but a captured fault aborts `execute` before any family
/// write, so this report is never persisted and never ranked.
fn placeholder_report(params: &[(String, Scalar)], window_ms: (i64, i64)) -> RunReport {
RunReport {
manifest: RunManifest {
commit: String::new(),
params: params.to_vec(),
window: (Timestamp(window_ms.0), Timestamp(window_ms.1)),
seed: 0,
broker: "faulted-member-placeholder".to_string(),
selection: None,
instrument: None,
topology_hash: None,
project: None,
},
metrics: RunMetrics { total_pips: 0.0, max_drawdown: 0.0, bias_sign_flips: 0, r: None },
}
}
/// The sweep stage's winner + selection provenance. deflate=true composes only
/// with argmax (preflighted: `DeflatePlateauConflict`), seeding the deflation
/// nulls from the CAMPAIGN seed (C1: a campaign's realization is a pure
/// function of the document). Bare argmax has no annotation to compute, so its
/// `FamilySelection` is synthesized annotation-free; `raw_winner_metric`
/// mirrors the registry's ranking read (an R metric against `r: None` ranks
/// `NEG_INFINITY`).
fn select_sweep_winner(
family: &SweepFamily,
axis_lens: &[usize],
metric: &str,
select: SelectRule,
deflate: bool,
seed: u64,
) -> Result<(SweepPoint, FamilySelection), ExecFault> {
match (select, deflate) {
(SelectRule::Argmax, true) => {
optimize_deflated(family, metric, DEFLATION_N_RESAMPLES, DEFLATION_BLOCK_LEN, seed)
.map_err(ExecFault::Registry)
}
(SelectRule::Argmax, false) => {
let winner = optimize(family, metric).map_err(ExecFault::Registry)?;
let raw = member_metric(&winner.report, metric).unwrap_or(f64::NEG_INFINITY);
let selection = FamilySelection {
selection_metric: metric.to_string(),
n_trials: family.points.len(),
raw_winner_metric: raw,
mode: SelectionMode::Argmax,
deflated_score: None,
overfit_probability: None,
n_resamples: None,
block_len: None,
seed: None,
neighbourhood_score: None,
n_neighbours: None,
};
Ok((winner, selection))
}
(SelectRule::PlateauMean, _) => {
optimize_plateau(family, axis_lens, metric, PlateauMode::Mean)
.map_err(ExecFault::Registry)
}
(SelectRule::PlateauWorst, _) => {
optimize_plateau(family, axis_lens, metric, PlateauMode::Worst)
.map_err(ExecFault::Registry)
}
}
}
/// A family point's tag-free cells lifted back to self-describing scalars in
/// `space` slot order — the survivor-point form the walk-forward stage
/// re-sweeps through a `ListSpace`.
fn scalar_point(space: &[ParamSpec], point: &[Cell]) -> Vec<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 privatefn run_walk_forward_stagestub wholesale; add one private helper; merge imports into itsuseblock) - Modify:
crates/aura-campaign/src/lib.rs(append the test module only) - Test:
crates/aura-campaign/src/lib.rs(new#[cfg(test)] mod wf_testswith fourwf_*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_testsmodule
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, ®istry).expect("wf executes");
// The oracle is the roller's own math over the same config.
let expected: Vec<_> =
WindowRoller::new((Timestamp(0), Timestamp(99)), 40, 20, 20, RollMode::Rolling)
.expect("valid roll")
.collect();
assert_eq!(expected.len(), 3, "fixture sanity: 3 windows over 0..=99");
let fam = outcome.cells[0]
.families
.iter()
.find(|f| f.block == "std::walk_forward")
.expect("wf family present");
assert_eq!(fam.reports.len(), expected.len());
for (report, bounds) in fam.reports.iter().zip(&expected) {
assert_eq!(report.manifest.window, (bounds.oos.0, bounds.oos.1));
}
}
#[test]
fn wf_searches_only_the_survivor_points() {
let registry = wf_registry("survivors");
let campaign = wf_campaign((0, 99));
// total_pips == len; gate total_pips > 2.5 keeps len 3 and len 4 of [1,2,3,4]
let process = wf_process(Some(2.5), 40, 20, 20);
let runner = WfFakeRunner::new();
run_wf(&campaign, &process, &runner, ®istry).expect("wf executes");
let is_bounds: Vec<(i64, i64)> =
WindowRoller::new((Timestamp(0), Timestamp(99)), 40, 20, 20, RollMode::Rolling)
.expect("valid roll")
.map(|w| (w.is.0 .0, w.is.1 .0))
.collect();
let log = runner.log.lock().unwrap();
let mut is_total = 0;
let mut seen3 = 0;
let mut seen4 = 0;
for (window, params) in log.iter() {
if !is_bounds.contains(window) {
continue;
}
is_total += 1;
match params[0].1 {
Scalar::I64(3) => seen3 += 1,
Scalar::I64(4) => seen4 += 1,
other => panic!("gated-out point ran in an IS window: {other:?}"),
}
}
// 3 windows x exactly the 2 surviving points
assert_eq!(is_total, 6);
assert_eq!(seen3, 3);
assert_eq!(seen4, 3);
}
#[test]
fn wf_stamps_selection_on_oos_members() {
let registry = wf_registry("selection");
let campaign = wf_campaign((0, 99));
let process = wf_process(None, 40, 20, 20);
let runner = WfFakeRunner::new();
let outcome = run_wf(&campaign, &process, &runner, ®istry).expect("wf executes");
let fam = outcome.cells[0]
.families
.iter()
.find(|f| f.block == "std::walk_forward")
.expect("wf family present");
assert!(!fam.reports.is_empty());
for report in &fam.reports {
let sel = report
.manifest
.selection
.as_ref()
.expect("every OOS member carries its IS selection");
assert_eq!(sel.selection_metric, "total_pips");
assert_eq!(sel.seed, Some(7), "deflation is seeded from campaign.seed");
}
}
#[test]
fn wf_roller_refusal_maps_to_window_fault() {
let registry = wf_registry("window-fault");
// 0..=49 cannot fit is 40 + oos 20 (window 0 needs 59) -> the roller
// refuses at runtime (zero lengths are already doc-tier faults).
let campaign = wf_campaign((0, 49));
let process = wf_process(None, 40, 20, 20);
let runner = WfFakeRunner::new();
let result = run_wf(&campaign, &process, &runner, ®istry);
let Err(err) = result else { panic!("span too short for one window must refuse") };
match err {
ExecFault::Window { stage, detail } => {
assert_eq!(stage, 1, "walk_forward is pipeline stage 1 here");
assert!(
detail.contains("SpanTooShort"),
"detail carries the roller refusal: {detail}"
);
}
other => panic!("expected ExecFault::Window, got {other:?}"),
}
}
}
- Step 2: Run the new tests — expect RED
Run: cargo test -p aura-campaign wf_
Expected: the module compiles and all four tests FAIL against the Task 6 stub — wf_rolls_the_declared_window_in_ms, wf_searches_only_the_survivor_points, and wf_stamps_selection_on_oos_members panic at .expect("wf executes") (the stub's Err(ExecFault::PipelineShape { .. }) propagates out of execute), and wf_roller_refusal_maps_to_window_fault panics at expected ExecFault::Window, got PipelineShape { .. }. Output ends with test result: FAILED. 0 passed; 4 failed.
- Step 3: Merge the implementation imports
The replacement code in Step 4 references eight names not yet imported by crates/aura-campaign/src/exec.rs: walk_forward, RollMode, Space (the trait whose points() the survivor ListSpace is read through), WindowBounds, WindowRoller, WindowRun, walkforward_member_reports, and WfMode. Everything else the replacement uses (Timestamp, Cell, ParamSpec, Scalar, zip_params from aura_core; Mutex; sweep, ListSpace; the crate-root helpers and the deflation consts) is already imported by Task 6. In crates/aura-campaign/src/exec.rs, replace:
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_stagestub 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
4–7) — target resolution, project/referential gates, the MemberRunner
implementation over the shipped loaded-blueprint machinery, fault prose, and
stdout/stderr emission. Prose seams: exec_fault_prose lives in
campaign_run.rs (it consumes aura_campaign types; research_docs.rs
stays doc-tier-only). No pub(crate) promotion is needed for any main.rs item:
main.rs is the crate root and its private items are visible to child modules
via crate:: (the shipped idiom — graph_construct.rs:258 already calls the
private crate::content_id, main.rs:2588). Five research_docs.rs fns DO need
promotion (sibling-module privacy). Pinned ordering (load-bearing for Task 9's
campaign_run_persist_taps_deferred_loudly): the persist_taps stderr note
prints after all document gates and before aura_campaign::execute is
called, so it is present even when the run then refuses at the member-data seam.
Files:
-
Modify: crates/aura-cli/Cargo.toml (dependency block, lines 19–20)
-
Modify: crates/aura-cli/src/main.rs (mod block, lines 14–18)
-
Modify: crates/aura-cli/src/research_docs.rs (:70, :87, :121, :236–244, :246, :264–277, :279)
-
Create: crates/aura-cli/src/campaign_run.rs
-
Step 1: Add the aura-campaign dependency to crates/aura-cli/Cargo.toml
Replace (old):
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:236–244)
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:264–277)
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,
®istry,
)
.map_err(|f| exec_fault_prose(&f))?;
// Zero-survivor stderr notes (exit stays 0 — a null result is a valid
// research result, #198 decision 8). Cells are recorded in strategy ×
// instrument × window doc order, so the ordinals derive from the index.
let n_windows = campaign.data.windows.len();
let n_instruments = campaign.data.instruments.len();
for (ci, cell) in outcome.record.cells.iter().enumerate() {
let w_ord = ci % n_windows;
let s_ord = ci / (n_windows * n_instruments);
for (stage_ix, st) in cell.stages.iter().enumerate() {
if matches!(&st.survivor_ordinals, Some(v) if v.is_empty()) {
eprintln!(
"aura: cell {s_ord}/{}/w{w_ord}: gate at stage {stage_ix} left no \
survivors; cell realization truncated",
cell.instrument
);
}
}
}
// Emission: emit-gated family/selection lines per cell, then the
// always-on final record line.
let emit_family = campaign.presentation.emit.iter().any(|e| e == "family_table");
let emit_selection = campaign.presentation.emit.iter().any(|e| e == "selection_report");
for cell_out in &outcome.cells {
if emit_family {
for fam in &cell_out.families {
for report in &fam.reports {
println!("{}", crate::family_member_line(&fam.family_id, report));
}
}
}
if emit_selection {
for sel in &cell_out.selections {
let line = SelectionReportLine {
selection_report: SelectionReportBody {
family_id: &sel.family_id,
stage: sel.stage,
block: sel.block,
winner_ordinal: sel.winner_ordinal,
params: &sel.params,
selection: &sel.selection,
},
};
println!(
"{}",
serde_json::to_string(&line).expect("selection report serializes")
);
}
}
}
println!(
"{}",
serde_json::to_string(&CampaignRunLine { campaign_run: &outcome.record })
.expect("campaign run record serializes")
);
Ok(())
}
- Step 7: Build the workspace
Run: cargo build --workspace
Expected: compiles with 0 errors (warnings-free in the new module: every import is used).
- Step 8: Existing aura-cli suite stays green
Run: cargo test -p aura-cli
Expected: all existing unit + integration tests pass, count unchanged (this task adds no tests; the new verb only extends the clap surface, and no existing test asserts an exhaustive campaign subcommand list).
Task 9: aura-cli campaign run seam tests + gated real-data e2e
Seam tests for Task 8's aura campaign run, in the existing
crates/aura-cli/tests/research_docs.rs (chosen over a new test binary: every
helper it needs — temp_cwd/run_code_in/write_doc/built_project/
ScratchGuard — already lives there). These tests pin behaviour Task 8 already
implemented, so they must pass on first run; a failure is a Task 8 defect — fix
the implementation, never weaken the assert. Because several new tests mutate
the shared demo-project fixture's runs/ store and test threads run in
parallel, this task adds a static-Mutex project_lock() and threads it through
the one existing fixture test as well. The persist-taps test relies on Task 8's
pinned ordering (tap note before member execution): its campaign window is
[1, 2] epoch-ms (1970), so the run deterministically refuses at the
member-data seam on every machine (data-less host: geometry refusal; data-ful
host: no file overlaps → NoData) — the tap line must be on stderr either way.
The e2e mirrors the cli_run.rs:250-281 skip idiom (GER40 Sept-2024 window;
clean eprintln! skip when the archive is absent).
Files:
-
Test: crates/aura-cli/tests/research_docs.rs (imports :5–6; existing fixture test :196–199; helpers + six new tests appended at end of file)
-
Step 1: Widen the std::sync import
Replace (old, lines 5–6):
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 196–199):
#[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 promotedblueprint_pathtopub) - 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 afterintrospect_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_pathmust bepub
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(®_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
Registervariant toGraphSubincrates/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_cmdincrates/aura-cli/src/graph_construct.rs(:193-275) — extended guard,--paramsmode, 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_cmdtocrates/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.