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

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

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

5262 lines
214 KiB
Markdown
Raw Blame History

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