0cc1858d86
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
5262 lines
214 KiB
Markdown
5262 lines
214 KiB
Markdown
# 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(), ®)
|
||
.expect("sweep-only campaign executes");
|
||
|
||
// outcome payloads: one cell, one family of 4 members, one selection
|
||
assert_eq!(out.run, 0);
|
||
assert_eq!(out.cells.len(), 1);
|
||
assert_eq!(out.cells[0].families.len(), 1);
|
||
assert_eq!(out.cells[0].families[0].reports.len(), 4);
|
||
let expected_family_id = format!("{}-0-EURUSD-w0-s0-0", &CAMPAIGN_ID[..8]);
|
||
assert_eq!(out.cells[0].families[0].family_id, expected_family_id);
|
||
|
||
// planted argmax: (3,9) is odometer point 3 (last axis fastest)
|
||
assert_eq!(out.cells[0].selections.len(), 1);
|
||
let sel = &out.cells[0].selections[0];
|
||
assert_eq!(sel.winner_ordinal, 3);
|
||
assert_eq!(
|
||
sel.params,
|
||
vec![("fast".to_string(), Scalar::i64(3)), ("slow".to_string(), Scalar::i64(9))],
|
||
);
|
||
|
||
// family persisted: 4 sweep members under the derived id, ordinal-ordered
|
||
let members = reg.load_family_members().expect("load members");
|
||
assert_eq!(members.len(), 4);
|
||
assert!(members.iter().all(|m| m.kind == FamilyKind::Sweep));
|
||
assert!(members.iter().all(|m| m.family_id() == expected_family_id));
|
||
assert_eq!(members.iter().map(|m| m.ordinal).collect::<Vec<_>>(), vec![0, 1, 2, 3]);
|
||
|
||
// realization persisted: one record linking the family + the selection
|
||
let runs = reg.load_campaign_runs().expect("load campaign runs");
|
||
assert_eq!(runs.len(), 1);
|
||
assert_eq!(runs[0], out.record);
|
||
assert_eq!(runs[0].campaign, CAMPAIGN_ID);
|
||
assert_eq!(runs[0].process, PROCESS_ID);
|
||
assert_eq!(runs[0].run, 0);
|
||
assert_eq!(runs[0].seed, 7);
|
||
assert_eq!(runs[0].cells.len(), 1);
|
||
let cell = &runs[0].cells[0];
|
||
assert_eq!(cell.strategy, STRATEGY_ID);
|
||
assert_eq!(cell.instrument, "EURUSD");
|
||
assert_eq!(cell.window_ms, (1_000, 9_000));
|
||
assert_eq!(cell.stages.len(), 1);
|
||
assert_eq!(cell.stages[0].block, "std::sweep");
|
||
assert_eq!(cell.stages[0].family_id.as_deref(), Some(expected_family_id.as_str()));
|
||
let stored = cell.stages[0].selection.as_ref().expect("sweep stage carries a selection");
|
||
assert_eq!(stored.winner_ordinal, 3);
|
||
assert_eq!(stored.selection.mode, SelectionMode::Argmax);
|
||
assert_eq!(stored.selection.n_trials, 4);
|
||
assert!((stored.selection.raw_winner_metric - 0.39).abs() < 1e-12);
|
||
assert_eq!(stored.selection.seed, None, "no deflation annotation without deflate");
|
||
}
|
||
|
||
#[test]
|
||
fn execute_gate_filters_per_member() {
|
||
let reg = temp_registry("gate_filters");
|
||
let doc = campaign(&["EURUSD"]);
|
||
let proc_doc = process(vec![sweep_stage(false), gate_stage(0.3)]);
|
||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®)
|
||
.expect("gated campaign executes");
|
||
|
||
// planted nets 0.26 / 0.29 / 0.36 / 0.39 -> gt 0.3 keeps ordinals 2 and 3
|
||
let cell = &out.record.cells[0];
|
||
assert_eq!(cell.stages.len(), 2);
|
||
assert_eq!(cell.stages[1].block, "std::gate");
|
||
assert_eq!(cell.stages[1].survivor_ordinals, Some(vec![2, 3]));
|
||
assert_eq!(cell.stages[1].family_id, None);
|
||
assert_eq!(cell.stages[1].selection, None);
|
||
}
|
||
|
||
#[test]
|
||
fn execute_zero_survivors_truncates_cell_and_continues() {
|
||
let reg = temp_registry("zero_survivors");
|
||
// two instruments: the fake plants NEGATIVE nets for "AAA", positive for "BBB"
|
||
let doc = campaign(&["AAA", "BBB"]);
|
||
// sweep -> gate(net > 0) -> gate(net > -1000): AAA dies at stage 1, BBB passes both
|
||
let proc_doc = process(vec![sweep_stage(false), gate_stage(0.0), gate_stage(-1000.0)]);
|
||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®)
|
||
.expect("a zero-survivor cell is a valid result, not a fault");
|
||
|
||
assert_eq!(out.record.cells.len(), 2, "the second cell still runs");
|
||
// cell 0 (AAA): realization truncated AT the empty gate — stage 2 never realized
|
||
let aaa = &out.record.cells[0];
|
||
assert_eq!(aaa.instrument, "AAA");
|
||
assert_eq!(aaa.stages.len(), 2);
|
||
assert_eq!(aaa.stages[1].survivor_ordinals, Some(vec![]));
|
||
// cell 1 (BBB): full pipeline realized
|
||
let bbb = &out.record.cells[1];
|
||
assert_eq!(bbb.instrument, "BBB");
|
||
assert_eq!(bbb.stages.len(), 3);
|
||
assert_eq!(bbb.stages[1].survivor_ordinals, Some(vec![0, 1, 2, 3]));
|
||
assert_eq!(bbb.stages[2].survivor_ordinals, Some(vec![0, 1, 2, 3]));
|
||
}
|
||
|
||
#[test]
|
||
fn execute_is_deterministic_twice() {
|
||
let reg = temp_registry("deterministic_twice");
|
||
let doc = campaign(&["EURUSD"]);
|
||
let proc_doc = process(vec![sweep_stage(false)]);
|
||
let first = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®)
|
||
.expect("first run");
|
||
let second = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®)
|
||
.expect("second run");
|
||
|
||
// run counter advances per campaign id
|
||
assert_eq!((first.run, second.run), (0, 1));
|
||
assert_eq!((first.record.run, second.record.run), (0, 1));
|
||
|
||
// family ids differ ONLY by the "-{run}" suffix
|
||
let base = format!("{}-0-EURUSD-w0-s0", &CAMPAIGN_ID[..8]);
|
||
assert_eq!(first.cells[0].families[0].family_id, format!("{base}-0"));
|
||
assert_eq!(second.cells[0].families[0].family_id, format!("{base}-1"));
|
||
|
||
// everything else in the record is identical (C1): normalize the two
|
||
// divergent fields and compare whole records
|
||
let mut normalized = second.record.clone();
|
||
normalized.run = first.record.run;
|
||
normalized.cells[0].stages[0].family_id = first.record.cells[0].stages[0].family_id.clone();
|
||
assert_eq!(normalized, first.record);
|
||
|
||
// both records stored, in order
|
||
let runs = reg.load_campaign_runs().expect("load campaign runs");
|
||
assert_eq!(runs.len(), 2);
|
||
assert_eq!(runs[0], first.record);
|
||
assert_eq!(runs[1], second.record);
|
||
}
|
||
|
||
#[test]
|
||
fn execute_member_fault_aborts_with_lowest_index() {
|
||
let reg = temp_registry("member_fault");
|
||
let doc = campaign(&["EURUSD"]);
|
||
let proc_doc = process(vec![sweep_stage(false)]);
|
||
// faults planted at enumeration indices 1 (2,9) and 2 (3,6)
|
||
let point = |fast: i64, slow: i64| {
|
||
vec![("fast".to_string(), Scalar::i64(fast)), ("slow".to_string(), Scalar::i64(slow))]
|
||
};
|
||
let runner = FakeRunner {
|
||
faults: vec![
|
||
(point(2, 9), MemberFault::Run("boom at index 1".to_string())),
|
||
(point(3, 6), MemberFault::Run("boom at index 2".to_string())),
|
||
],
|
||
};
|
||
let err = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &runner, ®)
|
||
.expect_err("a faulted member aborts the run");
|
||
match err {
|
||
ExecFault::Member(fault) => assert_eq!(
|
||
fault,
|
||
MemberFault::Run("boom at index 1".to_string()),
|
||
"the LOWEST enumeration index's fault is reported",
|
||
),
|
||
other => panic!("expected ExecFault::Member, got {other:?}"),
|
||
}
|
||
// the fault aborted before any family or realization write
|
||
assert!(reg.load_family_members().expect("load members").is_empty());
|
||
assert!(reg.load_campaign_runs().expect("load campaign runs").is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn execute_deflate_uses_campaign_seed() {
|
||
let reg = temp_registry("deflate_seed");
|
||
let doc = campaign(&["EURUSD"]); // seed: 7
|
||
let proc_doc = process(vec![sweep_stage(true)]);
|
||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®)
|
||
.expect("deflated sweep executes");
|
||
let sel = out.record.cells[0].stages[0].selection.as_ref().expect("selection recorded");
|
||
assert_eq!(sel.selection.mode, SelectionMode::Argmax);
|
||
assert_eq!(sel.selection.seed, Some(7), "deflation nulls seed from the campaign doc");
|
||
assert_eq!(sel.selection.n_resamples, Some(1000));
|
||
assert_eq!(sel.selection.block_len, Some(5));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run the RED gate**
|
||
|
||
Run: `cargo test -p aura-campaign execute`
|
||
Expected: FAILS to compile — `error[E0432]: unresolved import` naming `execute` (not yet in `aura_campaign`); no tests run. This is the RED evidence for the new API.
|
||
|
||
- [ ] **Step 3: Implement the executor module**
|
||
|
||
Create `crates/aura-campaign/src/exec.rs` with exactly:
|
||
|
||
```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, ¶ms, cell.window_ms) {
|
||
Ok(report) => report,
|
||
Err(fault) => {
|
||
let index = cells
|
||
.iter()
|
||
.position(|c| c.as_slice() == point)
|
||
.expect("the sweep only enumerates the grid's own points");
|
||
faults.lock().expect("fault capture lock").push((index, fault));
|
||
placeholder_report(¶ms, cell.window_ms)
|
||
}
|
||
}
|
||
});
|
||
let mut captured = faults.into_inner().expect("fault capture lock");
|
||
captured.sort_by_key(|&(index, _)| index);
|
||
if let Some((_, fault)) = captured.into_iter().next() {
|
||
return Err(ExecFault::Member(fault));
|
||
}
|
||
Ok(family)
|
||
}
|
||
|
||
/// Slot filler for a faulted member: the engine sweep contract needs one
|
||
/// report per slot, but a captured fault aborts `execute` before any family
|
||
/// write, so this report is never persisted and never ranked.
|
||
fn placeholder_report(params: &[(String, Scalar)], window_ms: (i64, i64)) -> RunReport {
|
||
RunReport {
|
||
manifest: RunManifest {
|
||
commit: String::new(),
|
||
params: params.to_vec(),
|
||
window: (Timestamp(window_ms.0), Timestamp(window_ms.1)),
|
||
seed: 0,
|
||
broker: "faulted-member-placeholder".to_string(),
|
||
selection: None,
|
||
instrument: None,
|
||
topology_hash: None,
|
||
project: None,
|
||
},
|
||
metrics: RunMetrics { total_pips: 0.0, max_drawdown: 0.0, bias_sign_flips: 0, r: None },
|
||
}
|
||
}
|
||
|
||
/// The sweep stage's winner + selection provenance. deflate=true composes only
|
||
/// with argmax (preflighted: `DeflatePlateauConflict`), seeding the deflation
|
||
/// nulls from the CAMPAIGN seed (C1: a campaign's realization is a pure
|
||
/// function of the document). Bare argmax has no annotation to compute, so its
|
||
/// `FamilySelection` is synthesized annotation-free; `raw_winner_metric`
|
||
/// mirrors the registry's ranking read (an R metric against `r: None` ranks
|
||
/// `NEG_INFINITY`).
|
||
fn select_sweep_winner(
|
||
family: &SweepFamily,
|
||
axis_lens: &[usize],
|
||
metric: &str,
|
||
select: SelectRule,
|
||
deflate: bool,
|
||
seed: u64,
|
||
) -> Result<(SweepPoint, FamilySelection), ExecFault> {
|
||
match (select, deflate) {
|
||
(SelectRule::Argmax, true) => {
|
||
optimize_deflated(family, metric, DEFLATION_N_RESAMPLES, DEFLATION_BLOCK_LEN, seed)
|
||
.map_err(ExecFault::Registry)
|
||
}
|
||
(SelectRule::Argmax, false) => {
|
||
let winner = optimize(family, metric).map_err(ExecFault::Registry)?;
|
||
let raw = member_metric(&winner.report, metric).unwrap_or(f64::NEG_INFINITY);
|
||
let selection = FamilySelection {
|
||
selection_metric: metric.to_string(),
|
||
n_trials: family.points.len(),
|
||
raw_winner_metric: raw,
|
||
mode: SelectionMode::Argmax,
|
||
deflated_score: None,
|
||
overfit_probability: None,
|
||
n_resamples: None,
|
||
block_len: None,
|
||
seed: None,
|
||
neighbourhood_score: None,
|
||
n_neighbours: None,
|
||
};
|
||
Ok((winner, selection))
|
||
}
|
||
(SelectRule::PlateauMean, _) => {
|
||
optimize_plateau(family, axis_lens, metric, PlateauMode::Mean)
|
||
.map_err(ExecFault::Registry)
|
||
}
|
||
(SelectRule::PlateauWorst, _) => {
|
||
optimize_plateau(family, axis_lens, metric, PlateauMode::Worst)
|
||
.map_err(ExecFault::Registry)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A family point's tag-free cells lifted back to self-describing scalars in
|
||
/// `space` slot order — the survivor-point form the walk-forward stage
|
||
/// re-sweeps through a `ListSpace`.
|
||
fn scalar_point(space: &[ParamSpec], point: &[Cell]) -> Vec<Scalar> {
|
||
space.iter().zip(point).map(|(ps, c)| Scalar::from_cell(ps.kind, *c)).collect()
|
||
}
|
||
|
||
/// Walk-forward stage seam — the next plan task replaces this WHOLE fn (doc
|
||
/// comment, the `let _ = (...)` placeholder line, and the refusal body); the
|
||
/// `#[allow(clippy::too_many_arguments)]` and the signature stay
|
||
/// byte-identical, so the call site in `run_cell` is untouched. `survivors`
|
||
/// are the surviving param points in enumeration order — the IS point set;
|
||
/// `family_name` is the stage's pre-formatted registry family name; `specs`
|
||
/// the param space the points are coordinates in; `seed` the campaign seed
|
||
/// (deflation nulls). Until then, a walk_forward-bearing pipeline executes up
|
||
/// to this stage (preflight ADMITS walk_forward) and refuses here.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn run_walk_forward_stage(
|
||
seed: u64,
|
||
cell: &CellSpec,
|
||
stage: usize,
|
||
block: &StageBlock,
|
||
specs: &[ParamSpec],
|
||
survivors: &[Vec<Scalar>],
|
||
family_name: &str,
|
||
runner: &dyn MemberRunner,
|
||
registry: &Registry,
|
||
) -> Result<StageFamily, ExecFault> {
|
||
let _ = (seed, cell, stage, block, specs, survivors, family_name, runner, registry);
|
||
Err(ExecFault::PipelineShape { detail: "walk_forward execution lands in the next task".into() })
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Wire the module into the crate root**
|
||
|
||
Open `crates/aura-campaign/src/lib.rs`. Immediately AFTER the crate-level doc comment block (the leading `//!` lines) and BEFORE the first `use` statement or item, insert these two lines (plus a blank line after them):
|
||
|
||
```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, ®istry).expect("wf executes");
|
||
|
||
// The oracle is the roller's own math over the same config.
|
||
let expected: Vec<_> =
|
||
WindowRoller::new((Timestamp(0), Timestamp(99)), 40, 20, 20, RollMode::Rolling)
|
||
.expect("valid roll")
|
||
.collect();
|
||
assert_eq!(expected.len(), 3, "fixture sanity: 3 windows over 0..=99");
|
||
|
||
let fam = outcome.cells[0]
|
||
.families
|
||
.iter()
|
||
.find(|f| f.block == "std::walk_forward")
|
||
.expect("wf family present");
|
||
assert_eq!(fam.reports.len(), expected.len());
|
||
for (report, bounds) in fam.reports.iter().zip(&expected) {
|
||
assert_eq!(report.manifest.window, (bounds.oos.0, bounds.oos.1));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn wf_searches_only_the_survivor_points() {
|
||
let registry = wf_registry("survivors");
|
||
let campaign = wf_campaign((0, 99));
|
||
// total_pips == len; gate total_pips > 2.5 keeps len 3 and len 4 of [1,2,3,4]
|
||
let process = wf_process(Some(2.5), 40, 20, 20);
|
||
let runner = WfFakeRunner::new();
|
||
run_wf(&campaign, &process, &runner, ®istry).expect("wf executes");
|
||
|
||
let is_bounds: Vec<(i64, i64)> =
|
||
WindowRoller::new((Timestamp(0), Timestamp(99)), 40, 20, 20, RollMode::Rolling)
|
||
.expect("valid roll")
|
||
.map(|w| (w.is.0 .0, w.is.1 .0))
|
||
.collect();
|
||
let log = runner.log.lock().unwrap();
|
||
let mut is_total = 0;
|
||
let mut seen3 = 0;
|
||
let mut seen4 = 0;
|
||
for (window, params) in log.iter() {
|
||
if !is_bounds.contains(window) {
|
||
continue;
|
||
}
|
||
is_total += 1;
|
||
match params[0].1 {
|
||
Scalar::I64(3) => seen3 += 1,
|
||
Scalar::I64(4) => seen4 += 1,
|
||
other => panic!("gated-out point ran in an IS window: {other:?}"),
|
||
}
|
||
}
|
||
// 3 windows x exactly the 2 surviving points
|
||
assert_eq!(is_total, 6);
|
||
assert_eq!(seen3, 3);
|
||
assert_eq!(seen4, 3);
|
||
}
|
||
|
||
#[test]
|
||
fn wf_stamps_selection_on_oos_members() {
|
||
let registry = wf_registry("selection");
|
||
let campaign = wf_campaign((0, 99));
|
||
let process = wf_process(None, 40, 20, 20);
|
||
let runner = WfFakeRunner::new();
|
||
let outcome = run_wf(&campaign, &process, &runner, ®istry).expect("wf executes");
|
||
|
||
let fam = outcome.cells[0]
|
||
.families
|
||
.iter()
|
||
.find(|f| f.block == "std::walk_forward")
|
||
.expect("wf family present");
|
||
assert!(!fam.reports.is_empty());
|
||
for report in &fam.reports {
|
||
let sel = report
|
||
.manifest
|
||
.selection
|
||
.as_ref()
|
||
.expect("every OOS member carries its IS selection");
|
||
assert_eq!(sel.selection_metric, "total_pips");
|
||
assert_eq!(sel.seed, Some(7), "deflation is seeded from campaign.seed");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn wf_roller_refusal_maps_to_window_fault() {
|
||
let registry = wf_registry("window-fault");
|
||
// 0..=49 cannot fit is 40 + oos 20 (window 0 needs 59) -> the roller
|
||
// refuses at runtime (zero lengths are already doc-tier faults).
|
||
let campaign = wf_campaign((0, 49));
|
||
let process = wf_process(None, 40, 20, 20);
|
||
let runner = WfFakeRunner::new();
|
||
let result = run_wf(&campaign, &process, &runner, ®istry);
|
||
let Err(err) = result else { panic!("span too short for one window must refuse") };
|
||
match err {
|
||
ExecFault::Window { stage, detail } => {
|
||
assert_eq!(stage, 1, "walk_forward is pipeline stage 1 here");
|
||
assert!(
|
||
detail.contains("SpanTooShort"),
|
||
"detail carries the roller refusal: {detail}"
|
||
);
|
||
}
|
||
other => panic!("expected ExecFault::Window, got {other:?}"),
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run the new tests — expect RED**
|
||
|
||
Run: `cargo test -p aura-campaign wf_`
|
||
Expected: the module compiles and all four tests FAIL against the Task 6 stub — `wf_rolls_the_declared_window_in_ms`, `wf_searches_only_the_survivor_points`, and `wf_stamps_selection_on_oos_members` panic at `.expect("wf executes")` (the stub's `Err(ExecFault::PipelineShape { .. })` propagates out of `execute`), and `wf_roller_refusal_maps_to_window_fault` panics at `expected ExecFault::Window, got PipelineShape { .. }`. Output ends with `test result: FAILED. 0 passed; 4 failed`.
|
||
|
||
- [ ] **Step 3: Merge the implementation imports**
|
||
|
||
The replacement code in Step 4 references eight names not yet imported by `crates/aura-campaign/src/exec.rs`: `walk_forward`, `RollMode`, `Space` (the trait whose `points()` the survivor `ListSpace` is read through), `WindowBounds`, `WindowRoller`, `WindowRun`, `walkforward_member_reports`, and `WfMode`. Everything else the replacement uses (`Timestamp`, `Cell`, `ParamSpec`, `Scalar`, `zip_params` from `aura_core`; `Mutex`; `sweep`, `ListSpace`; the crate-root helpers and the deflation consts) is already imported by Task 6. In `crates/aura-campaign/src/exec.rs`, replace:
|
||
|
||
```rust
|
||
use aura_engine::{sweep, ListSpace, RunManifest, RunMetrics, RunReport, SweepFamily, SweepPoint};
|
||
```
|
||
|
||
with:
|
||
|
||
```rust
|
||
use aura_engine::{
|
||
sweep, walk_forward, ListSpace, RollMode, RunManifest, RunMetrics, RunReport, Space,
|
||
SweepFamily, SweepPoint, WindowBounds, WindowRoller, WindowRun,
|
||
};
|
||
```
|
||
|
||
then replace:
|
||
|
||
```rust
|
||
use aura_registry::{
|
||
optimize, optimize_deflated, optimize_plateau, sweep_member_reports, CampaignRunRecord,
|
||
CellRealization, FamilyKind, PlateauMode, Registry, StageRealization, StageSelection,
|
||
};
|
||
```
|
||
|
||
with:
|
||
|
||
```rust
|
||
use aura_registry::{
|
||
optimize, optimize_deflated, optimize_plateau, sweep_member_reports,
|
||
walkforward_member_reports, CampaignRunRecord, CellRealization, FamilyKind, PlateauMode,
|
||
Registry, StageRealization, StageSelection,
|
||
};
|
||
```
|
||
|
||
then replace:
|
||
|
||
```rust
|
||
use aura_research::{Axis, CampaignDoc, DocRef, ProcessDoc, SelectRule, StageBlock};
|
||
```
|
||
|
||
with:
|
||
|
||
```rust
|
||
use aura_research::{Axis, CampaignDoc, DocRef, ProcessDoc, SelectRule, StageBlock, WfMode};
|
||
```
|
||
|
||
If an intermediate repair pass reformatted the file, re-anchor on the same three `use` lines (the names, not the wrapping, are load-bearing) and add ONLY the eight new names — a duplicate import is a hard compile error.
|
||
|
||
- [ ] **Step 4: Replace the `run_walk_forward_stage` stub with the real stage**
|
||
|
||
In `crates/aura-campaign/src/exec.rs`, first add this helper directly above the `run_walk_forward_stage` doc comment:
|
||
|
||
```rust
|
||
/// A placeholder for a faulted window slot: arity-correct chosen params (the
|
||
/// engine asserts every window's chosen-point arity against the param-space),
|
||
/// empty OOS equity, inert report. Never surfaces — a recorded window fault
|
||
/// fails the stage after the roll, before any registry write.
|
||
fn placeholder_window_run(specs: &[ParamSpec]) -> WindowRun {
|
||
WindowRun {
|
||
chosen_params: vec![Cell::from_i64(0); specs.len()],
|
||
oos_equity: vec![],
|
||
oos_report: placeholder_report(&[], (0, 0)),
|
||
}
|
||
}
|
||
```
|
||
|
||
Then replace the ENTIRE `run_walk_forward_stage` function — Task 6's stub doc comment, `#[allow]`, signature, and body (the `#[allow]`-through-`}` bytes are quoted in the Precondition above) — with the following; the signature is byte-identical to the stub's, so the call site in `run_cell` is untouched:
|
||
|
||
```rust
|
||
/// Execute one `std::walk_forward` stage over the cell's surviving points:
|
||
/// roll (IS, OOS) window splits over the cell window — entirely in ms
|
||
/// (`Timestamp` is unit-agnostic `i64`; the driver owns ms->ns at its data
|
||
/// seam) — then per window sweep the survivor points over the IS bounds, pick
|
||
/// the winner (argmax with trials-deflation provenance, seeded from the
|
||
/// campaign seed — the shipped select_winner convention; plateau in
|
||
/// walk_forward is preflight-refused), run the winner over the OOS bounds
|
||
/// with the selection stamped on its fresh report, and append the per-window
|
||
/// OOS reports as a `FamilyKind::WalkForward` family.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn run_walk_forward_stage(
|
||
seed: u64,
|
||
cell: &CellSpec,
|
||
stage: usize,
|
||
block: &StageBlock,
|
||
specs: &[ParamSpec],
|
||
survivors: &[Vec<Scalar>],
|
||
family_name: &str,
|
||
runner: &dyn MemberRunner,
|
||
registry: &Registry,
|
||
) -> Result<StageFamily, ExecFault> {
|
||
let StageBlock::WalkForward { in_sample_ms, out_of_sample_ms, step_ms, mode, metric, .. } =
|
||
block
|
||
else {
|
||
return Err(ExecFault::PipelineShape {
|
||
detail: format!("stage {stage} is not std::walk_forward"),
|
||
});
|
||
};
|
||
if survivors.is_empty() {
|
||
// execute() truncates a cell at an empty gate before reaching here;
|
||
// refuse (never panic) if that contract is ever violated.
|
||
return Err(ExecFault::PipelineShape {
|
||
detail: format!("stage {stage}: walk_forward reached with zero survivor points"),
|
||
});
|
||
}
|
||
// Preflight guarantees the u64 lengths fit i64.
|
||
let span = (Timestamp(cell.window_ms.0), Timestamp(cell.window_ms.1));
|
||
let roll_mode = match mode {
|
||
WfMode::Rolling => RollMode::Rolling,
|
||
WfMode::Anchored => RollMode::Anchored,
|
||
};
|
||
let roller = WindowRoller::new(
|
||
span,
|
||
*in_sample_ms as i64,
|
||
*out_of_sample_ms as i64,
|
||
*step_ms as i64,
|
||
roll_mode,
|
||
)
|
||
.map_err(|e| ExecFault::Window { stage, detail: format!("{e:?}") })?;
|
||
// A second identical roller enumerates the bounds up front: the engine's
|
||
// walk_forward consumes its roller, and the parallel closure needs each
|
||
// window's roll index for deterministic fault attribution.
|
||
let bounds: Vec<WindowBounds> = WindowRoller::new(
|
||
span,
|
||
*in_sample_ms as i64,
|
||
*out_of_sample_ms as i64,
|
||
*step_ms as i64,
|
||
roll_mode,
|
||
)
|
||
.expect("identical roller config validated above")
|
||
.collect();
|
||
|
||
let is_space = ListSpace::new(specs, survivors.to_vec()).map_err(|e| {
|
||
ExecFault::PipelineShape {
|
||
detail: format!("stage {stage}: survivor points do not fit the param space: {e:?}"),
|
||
}
|
||
})?;
|
||
let is_points = is_space.points();
|
||
|
||
// Windows run in parallel and the engine closure cannot return Err:
|
||
// faulted windows record here (keyed by roll index) and yield a
|
||
// placeholder run; after the roll the lowest-index fault wins
|
||
// (deterministic prose, the sweep-stage capture pattern one level up).
|
||
let faults: Mutex<Vec<(usize, ExecFault)>> = Mutex::new(Vec::new());
|
||
|
||
let result = walk_forward(roller, specs.to_vec(), |w| {
|
||
let widx =
|
||
bounds.iter().position(|b| *b == w).expect("bounds come from an identical roller");
|
||
// IS: engine sweep of the survivor points over the in-sample bounds,
|
||
// with the same member fault capture the sweep stage uses.
|
||
let is_faults: Mutex<Vec<(usize, MemberFault)>> = Mutex::new(Vec::new());
|
||
let is_family = sweep(&is_space, |cells| {
|
||
match runner.run_member(cell, &zip_params(specs, cells), (w.is.0 .0, w.is.1 .0)) {
|
||
Ok(report) => report,
|
||
Err(fault) => {
|
||
let midx = is_points
|
||
.iter()
|
||
.position(|p| p.as_slice() == cells)
|
||
.expect("sweep enumerates exactly is_points");
|
||
is_faults.lock().unwrap().push((midx, fault));
|
||
placeholder_report(&[], (0, 0))
|
||
}
|
||
}
|
||
});
|
||
let member_faults = is_faults.into_inner().expect("no thread panicked holding the lock");
|
||
if let Some((_, fault)) = member_faults.into_iter().min_by_key(|(i, _)| *i) {
|
||
faults.lock().unwrap().push((widx, ExecFault::Member(fault)));
|
||
return placeholder_window_run(specs);
|
||
}
|
||
// IS winner: argmax with trials-deflation provenance, seeded from the
|
||
// campaign seed (the shipped select_winner convention).
|
||
let (winner, selection) = match optimize_deflated(
|
||
&is_family,
|
||
metric,
|
||
DEFLATION_N_RESAMPLES,
|
||
DEFLATION_BLOCK_LEN,
|
||
seed,
|
||
) {
|
||
Ok(picked) => picked,
|
||
Err(e) => {
|
||
faults.lock().unwrap().push((widx, ExecFault::Registry(e)));
|
||
return placeholder_window_run(specs);
|
||
}
|
||
};
|
||
// OOS: run the winner over the out-of-sample bounds; the selection is
|
||
// stamped on the fresh OOS report (a new record, never a mutation of
|
||
// a stored one).
|
||
let mut oos_report = match runner.run_member(
|
||
cell,
|
||
&zip_params(specs, &winner.params),
|
||
(w.oos.0 .0, w.oos.1 .0),
|
||
) {
|
||
Ok(report) => report,
|
||
Err(fault) => {
|
||
faults.lock().unwrap().push((widx, ExecFault::Member(fault)));
|
||
return placeholder_window_run(specs);
|
||
}
|
||
};
|
||
oos_report.manifest.selection = Some(selection);
|
||
WindowRun { chosen_params: winner.params, oos_equity: vec![], oos_report }
|
||
});
|
||
|
||
let window_faults = faults.into_inner().expect("no thread panicked holding the lock");
|
||
if let Some((_, fault)) = window_faults.into_iter().min_by_key(|(i, _)| *i) {
|
||
return Err(fault);
|
||
}
|
||
|
||
let reports = walkforward_member_reports(&result);
|
||
let family_id = registry
|
||
.append_family(family_name, FamilyKind::WalkForward, &reports)
|
||
.map_err(ExecFault::Registry)?;
|
||
Ok(StageFamily { stage, block: "std::walk_forward", family_id, reports })
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run the new tests — expect GREEN**
|
||
|
||
Run: `cargo test -p aura-campaign wf_`
|
||
Expected: `test result: ok. 4 passed; 0 failed` — all four `wf_*` tests pass.
|
||
|
||
- [ ] **Step 6: Run the full aura-campaign suite**
|
||
|
||
Run: `cargo test -p aura-campaign`
|
||
Expected: every test in the crate passes (the Tasks 4-6 tests plus the four new ones); `0 failed`.
|
||
|
||
- [ ] **Step 7: Workspace build gate**
|
||
|
||
Run: `cargo build --workspace`
|
||
Expected: compiles with 0 errors.
|
||
### Task 8: aura-cli campaign run — driver module, verb, prose, emission
|
||
|
||
The CLI half of the executor (#198): a new `campaign_run` module implementing
|
||
`aura campaign run <file|content-id>` over the `aura-campaign` library (Tasks
|
||
4–7) — target resolution, project/referential gates, the `MemberRunner`
|
||
implementation over the shipped loaded-blueprint machinery, fault prose, and
|
||
stdout/stderr emission. Prose seams: `exec_fault_prose` lives in
|
||
**campaign_run.rs** (it consumes `aura_campaign` types; `research_docs.rs`
|
||
stays doc-tier-only). No `pub(crate)` promotion is needed for any main.rs item:
|
||
main.rs is the crate root and its private items are visible to child modules
|
||
via `crate::` (the shipped idiom — `graph_construct.rs:258` already calls the
|
||
private `crate::content_id`, main.rs:2588). Five `research_docs.rs` fns DO need
|
||
promotion (sibling-module privacy). Pinned ordering (load-bearing for Task 9's
|
||
`campaign_run_persist_taps_deferred_loudly`): the `persist_taps` stderr note
|
||
prints after all document gates and **before** `aura_campaign::execute` is
|
||
called, so it is present even when the run then refuses at the member-data seam.
|
||
|
||
**Files:**
|
||
- Modify: crates/aura-cli/Cargo.toml (dependency block, lines 19–20)
|
||
- Modify: crates/aura-cli/src/main.rs (mod block, lines 14–18)
|
||
- Modify: crates/aura-cli/src/research_docs.rs (:70, :87, :121, :236–244, :246, :264–277, :279)
|
||
- Create: crates/aura-cli/src/campaign_run.rs
|
||
|
||
- [ ] **Step 1: Add the aura-campaign dependency to crates/aura-cli/Cargo.toml**
|
||
|
||
Replace (old):
|
||
```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:236–244)**
|
||
|
||
Replace (old):
|
||
```rust
|
||
/// Register a valid campaign document into the store under the runs root.
|
||
Register { file: PathBuf },
|
||
}
|
||
```
|
||
with (new):
|
||
```rust
|
||
/// Register a valid campaign document into the store under the runs root.
|
||
Register { file: PathBuf },
|
||
/// Execute a stored campaign into a realized run-set (a .json file is
|
||
/// register-then-run sugar; the canonical address is the content id).
|
||
Run { target: String },
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Dispatch the Run arm in campaign_cmd (research_docs.rs:264–277)**
|
||
|
||
Replace (old):
|
||
```rust
|
||
CampaignSub::Register { file } => register_campaign(file, env),
|
||
};
|
||
if let Err(m) = result {
|
||
```
|
||
with (new):
|
||
```rust
|
||
CampaignSub::Register { file } => register_campaign(file, env),
|
||
CampaignSub::Run { target } => crate::campaign_run::run_campaign(target, env),
|
||
};
|
||
if let Err(m) = result {
|
||
```
|
||
|
||
(The existing `Err -> eprintln!("aura: {m}") + exit(1)` tail of `campaign_cmd`
|
||
is the refusal exit path; usage stays clap, exit 2.)
|
||
|
||
- [ ] **Step 6: Create crates/aura-cli/src/campaign_run.rs (complete file)**
|
||
|
||
```rust
|
||
//! `aura campaign run` — the driver that turns a stored campaign document into
|
||
//! a realized run-set (#198). The execution *semantics* (preflight, cell loop,
|
||
//! stage sequencing, selection, registry writes) live in `aura-campaign`; this
|
||
//! module owns what is CLI-specific: target resolution (file sugar vs content
|
||
//! id), the project + referential gates, the [`MemberRunner`] implementation
|
||
//! over the shipped loaded-blueprint machinery (`wrap_r` reduce-mode member
|
||
//! runs via `run_blueprint_member` + `M1FieldSource` windowed real-data
|
||
//! binding), fault prose (`exec_fault_prose` lives HERE, beside its consumer,
|
||
//! not in `research_docs.rs` — it phrases `aura_campaign` types the doc-tier
|
||
//! module deliberately does not import), and stdout/stderr emission.
|
||
//!
|
||
//! Root items (`blueprint_axis_probe`, `run_blueprint_member`,
|
||
//! `family_member_line`) are reached via `crate::` — the `graph_construct`
|
||
//! submodule idiom: main.rs is the crate root, so its private items are
|
||
//! visible to child modules without promotion.
|
||
|
||
use std::path::{Path, PathBuf};
|
||
use std::sync::Arc;
|
||
|
||
use aura_campaign::{CellSpec, ExecFault, MemberFault, MemberRunner};
|
||
use aura_core::{Cell, Scalar};
|
||
use aura_engine::{blueprint_from_json, FamilySelection, RunReport};
|
||
use aura_ingest::{instrument_geometry, unix_ms_to_epoch_ns, M1Field, M1FieldSource};
|
||
use aura_registry::CampaignRunRecord;
|
||
use aura_research::{
|
||
campaign_to_json, content_id_of, parse_campaign, parse_process, validate_campaign,
|
||
validate_process, DocRef,
|
||
};
|
||
|
||
use crate::project::Env;
|
||
use crate::research_docs::{
|
||
doc_error_prose, doc_fault_prose, fault_block, parse_valid_campaign, ref_fault_prose,
|
||
};
|
||
|
||
/// A bare store address: exactly 64 lowercase hex chars (the content-id key shape).
|
||
fn is_content_id(s: &str) -> bool {
|
||
s.len() == 64 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
|
||
}
|
||
|
||
/// Phrase an [`ExecFault`] for stderr — Debug-leak-free, path-addressed
|
||
/// (stage index + block id), the `doc_fault_prose`/`ref_fault_prose` register.
|
||
fn exec_fault_prose(f: &ExecFault) -> String {
|
||
match f {
|
||
ExecFault::UnsupportedStage { stage, block } => format!(
|
||
"process stage {stage} ({block}) is not executable in v1; executable \
|
||
pipeline shape: std::sweep [std::gate]* [std::walk_forward]"
|
||
),
|
||
ExecFault::PipelineShape { detail } => {
|
||
format!("process pipeline is not executable: {detail}")
|
||
}
|
||
ExecFault::UnrankableMetric { stage, metric } => format!(
|
||
"process stage {stage}: metric \"{metric}\" is not rankable (winner \
|
||
selection needs one of the registry's rankable metrics)"
|
||
),
|
||
ExecFault::GateMetricNotPerMember { stage, metric } => format!(
|
||
"process stage {stage}: gate metric \"{metric}\" is not a per-member \
|
||
scalar (selection annotations cannot gate members)"
|
||
),
|
||
ExecFault::PlateauInWalkForward { stage } => format!(
|
||
"process stage {stage}: walk_forward cannot use a plateau select (a \
|
||
gated survivor subset has no parameter lattice)"
|
||
),
|
||
ExecFault::DeflatePlateauConflict { stage } => format!(
|
||
"process stage {stage}: sweep deflate: true composes only with select \"argmax\""
|
||
),
|
||
ExecFault::Window { stage, detail } => format!(
|
||
"process stage {stage}: walk_forward windows do not fit the campaign \
|
||
window: {detail}"
|
||
),
|
||
ExecFault::Member(MemberFault::NoData { instrument, window_ms }) => format!(
|
||
"no data for instrument {instrument} in window [{}, {}] (epoch-ms)",
|
||
window_ms.0, window_ms.1
|
||
),
|
||
ExecFault::Member(MemberFault::Bind(detail)) => {
|
||
format!("a member failed to bind: {detail}")
|
||
}
|
||
ExecFault::Member(MemberFault::Run(detail)) => {
|
||
format!("a member failed to run: {detail}")
|
||
}
|
||
ExecFault::Registry(e) => e.to_string(),
|
||
}
|
||
}
|
||
|
||
/// stdout wire form of one selection-bearing stage. Field order is the wire
|
||
/// contract (serde derives declaration order).
|
||
#[derive(serde::Serialize)]
|
||
struct SelectionReportLine<'a> {
|
||
selection_report: SelectionReportBody<'a>,
|
||
}
|
||
|
||
#[derive(serde::Serialize)]
|
||
struct SelectionReportBody<'a> {
|
||
family_id: &'a str,
|
||
stage: usize,
|
||
block: &'a str,
|
||
winner_ordinal: usize,
|
||
params: &'a [(String, Scalar)],
|
||
selection: &'a FamilySelection,
|
||
}
|
||
|
||
/// The always-on final line: the stored realization record under one key.
|
||
#[derive(serde::Serialize)]
|
||
struct CampaignRunLine<'a> {
|
||
campaign_run: &'a CampaignRunRecord,
|
||
}
|
||
|
||
/// The CLI's harness/data binding seam for `aura_campaign::execute`: members
|
||
/// run through the shipped loaded-blueprint machinery (`wrap_r` reduce-mode
|
||
/// via `crate::run_blueprint_member`) over windowed real M1 close bars
|
||
/// (`M1FieldSource::open_window` — the ms→ns crossing happens at exactly this
|
||
/// seam, via `unix_ms_to_epoch_ns`). All refusals are member faults for the
|
||
/// library to surface; never a process exit inside a sweep worker.
|
||
struct CliMemberRunner<'a> {
|
||
env: &'a Env,
|
||
server: Arc<data_server::DataServer>,
|
||
}
|
||
|
||
impl MemberRunner for CliMemberRunner<'_> {
|
||
fn run_member(
|
||
&self,
|
||
cell: &CellSpec,
|
||
params: &[(String, Scalar)],
|
||
window_ms: (i64, i64),
|
||
) -> Result<RunReport, MemberFault> {
|
||
// The wrapped axis namespace — the SAME probe the sweep verbs resolve
|
||
// against (single-sourced in main.rs; identical `false, true, None` wrap).
|
||
let space = crate::blueprint_axis_probe(&cell.blueprint_json, self.env).param_space();
|
||
|
||
// Suffix-join each raw campaign axis onto exactly one wrapped param
|
||
// (wrapped == raw, or wrapped ends with ".{raw}" — the established
|
||
// suffix-match pattern); then every wrapped slot must be covered.
|
||
let mut slots: Vec<Option<Scalar>> = vec![None; space.len()];
|
||
for (raw, value) in params {
|
||
let hits: Vec<usize> = space
|
||
.iter()
|
||
.enumerate()
|
||
.filter(|(_, p)| p.name == *raw || p.name.ends_with(&format!(".{raw}")))
|
||
.map(|(i, _)| i)
|
||
.collect();
|
||
match hits.as_slice() {
|
||
[i] => slots[*i] = Some(*value),
|
||
[] => {
|
||
return Err(MemberFault::Bind(format!(
|
||
"axis \"{raw}\" matches no open param of the wrapped strategy {}",
|
||
cell.strategy_id
|
||
)));
|
||
}
|
||
_ => {
|
||
let names: Vec<&str> =
|
||
hits.iter().map(|&i| space[i].name.as_str()).collect();
|
||
return Err(MemberFault::Bind(format!(
|
||
"axis \"{raw}\" is ambiguous in the wrapped param space of \
|
||
strategy {}: matches {}",
|
||
cell.strategy_id,
|
||
names.join(", ")
|
||
)));
|
||
}
|
||
}
|
||
}
|
||
let mut point: Vec<Cell> = Vec::with_capacity(space.len());
|
||
for (spec, slot) in space.iter().zip(&slots) {
|
||
match slot {
|
||
Some(v) => point.push(v.cell()),
|
||
None => {
|
||
return Err(MemberFault::Bind(format!(
|
||
"open param \"{}\" of strategy {} is bound by no campaign axis",
|
||
spec.name, cell.strategy_id
|
||
)));
|
||
}
|
||
}
|
||
}
|
||
|
||
// Real windowed data — geometry BEFORE bar data (the shipped pre-data
|
||
// refusal order of `open_real_source`), both as member faults.
|
||
let geo = instrument_geometry(&self.server, &cell.instrument).ok_or_else(|| {
|
||
MemberFault::Run(format!(
|
||
"no recorded geometry for symbol '{}' at {} — refusing to run a \
|
||
real instrument with a guessed pip",
|
||
cell.instrument,
|
||
self.env.data_path()
|
||
))
|
||
})?;
|
||
let from = unix_ms_to_epoch_ns(window_ms.0);
|
||
let to = unix_ms_to_epoch_ns(window_ms.1);
|
||
let source = match M1FieldSource::open_window(
|
||
&self.server,
|
||
&cell.instrument,
|
||
Some(from),
|
||
Some(to),
|
||
M1Field::Close,
|
||
) {
|
||
Some(s) => s,
|
||
// No archived file overlaps the window at all.
|
||
None => {
|
||
return Err(MemberFault::NoData {
|
||
instrument: cell.instrument.clone(),
|
||
window_ms,
|
||
});
|
||
}
|
||
};
|
||
// A window that overlaps a file but holds zero matching bars yields a
|
||
// source whose first peek is None (open_window's documented contract)
|
||
// — the same no-data condition.
|
||
if aura_engine::Source::peek(&source).is_none() {
|
||
return Err(MemberFault::NoData {
|
||
instrument: cell.instrument.clone(),
|
||
window_ms,
|
||
});
|
||
}
|
||
|
||
// The shipped member recipe (reload — a Composite is !Clone — wrap,
|
||
// bind, run, summarize): `run_blueprint_member` verbatim. Seed 0
|
||
// (seed-free real-data runs), topology_hash = the strategy's content
|
||
// id, project provenance stamped inside the helper.
|
||
let signal = blueprint_from_json(&cell.blueprint_json, &|t| self.env.resolve(t))
|
||
.expect("stored blueprint passed the referential gate; reload is infallible");
|
||
let mut report = crate::run_blueprint_member(
|
||
signal,
|
||
&point,
|
||
&space,
|
||
vec![Box::new(source)],
|
||
(from, to),
|
||
0,
|
||
geo.pip_size,
|
||
&cell.strategy_id,
|
||
self.env,
|
||
);
|
||
report.manifest.instrument = Some(cell.instrument.clone());
|
||
Ok(report)
|
||
}
|
||
}
|
||
|
||
/// `aura campaign run <target>`: resolve, gate, execute, emit. Every `Err`
|
||
/// is a refusal `campaign_cmd` renders as `aura: {msg}` + exit 1.
|
||
pub(crate) fn run_campaign(target: &str, env: &Env) -> Result<(), String> {
|
||
// Project gate FIRST: nothing (not even the file-sugar registration)
|
||
// touches a store outside a project.
|
||
if env.provenance().is_none() {
|
||
let cwd = std::env::current_dir()
|
||
.map(|d| d.display().to_string())
|
||
.unwrap_or_default();
|
||
return Err(format!(
|
||
"campaign run needs a project: strategies resolve against the project \
|
||
store and vocabulary (no Aura.toml found up from {cwd})"
|
||
));
|
||
}
|
||
let registry = env.registry();
|
||
|
||
// Target resolution: a readable file is register-then-run sugar; a bare
|
||
// 64-hex token addresses the store directly; anything else refuses naming
|
||
// both readings.
|
||
let campaign_id = if Path::new(target).is_file() {
|
||
let doc = parse_valid_campaign(&PathBuf::from(target))?;
|
||
registry
|
||
.put_campaign(&campaign_to_json(&doc))
|
||
.map_err(|e| e.to_string())?
|
||
} else if is_content_id(target) {
|
||
target.to_string()
|
||
} else {
|
||
return Err(format!(
|
||
"'{target}' is neither a readable .json file nor a 64-hex content id"
|
||
));
|
||
};
|
||
|
||
// One uniform path from here: fetch the stored canonical bytes by id (so
|
||
// file addressing and id addressing produce the same realization by
|
||
// construction) and re-run the intrinsic tier on them.
|
||
let campaign_text = registry
|
||
.get_campaign(&campaign_id)
|
||
.map_err(|e| e.to_string())?
|
||
.ok_or_else(|| format!("no campaign {campaign_id} in the project store"))?;
|
||
let campaign = parse_campaign(&campaign_text)
|
||
.map_err(|e| doc_error_prose("stored campaign document", &e))?;
|
||
let faults = validate_campaign(&campaign);
|
||
if !faults.is_empty() {
|
||
return Err(fault_block(
|
||
"campaign document invalid:",
|
||
faults.iter().map(doc_fault_prose).collect(),
|
||
));
|
||
}
|
||
|
||
// Referential gate: zero faults or refuse (the campaign-validate seam).
|
||
let resolve = |t: &str| env.resolve(t);
|
||
let ref_faults = registry
|
||
.validate_campaign_refs(&campaign, &resolve)
|
||
.map_err(|e| e.to_string())?;
|
||
if !ref_faults.is_empty() {
|
||
return Err(fault_block(
|
||
"campaign references do not resolve:",
|
||
ref_faults.iter().map(ref_fault_prose).collect(),
|
||
));
|
||
}
|
||
|
||
// Process fetch + intrinsic validation (stored text, not a file path —
|
||
// parse_valid_process is file-based, so its constituents run here).
|
||
let DocRef::ContentId(process_id) = &campaign.process.r#ref else {
|
||
// validate_campaign already refuses identity process refs; defensive.
|
||
return Err("process.ref: a process is referenced by content id in this version".into());
|
||
};
|
||
let process_text = registry
|
||
.get_process(process_id)
|
||
.map_err(|e| e.to_string())?
|
||
.ok_or_else(|| format!("no process {process_id} in the project store"))?;
|
||
let process = parse_process(&process_text)
|
||
.map_err(|e| doc_error_prose("stored process document", &e))?;
|
||
let process_faults = validate_process(&process);
|
||
if !process_faults.is_empty() {
|
||
return Err(fault_block(
|
||
"process document invalid:",
|
||
process_faults.iter().map(doc_fault_prose).collect(),
|
||
));
|
||
}
|
||
|
||
// Strategies: canonical bytes from the store, index-aligned with the doc.
|
||
// The recorded id is the content id of those bytes (== the ref id for
|
||
// content refs; computed for identity refs).
|
||
let mut strategies: Vec<(String, String)> = Vec::with_capacity(campaign.strategies.len());
|
||
for entry in &campaign.strategies {
|
||
let canonical = match &entry.r#ref {
|
||
DocRef::ContentId(id) => registry
|
||
.get_blueprint(id)
|
||
.map_err(|e| e.to_string())?
|
||
.ok_or_else(|| format!("strategy {id} not found in the blueprint store"))?,
|
||
DocRef::IdentityId(id) => registry
|
||
.find_blueprint_by_identity(id, &resolve)
|
||
.map_err(|e| e.to_string())?
|
||
.ok_or_else(|| format!("identity id {id} matches no stored blueprint"))?,
|
||
};
|
||
strategies.push((content_id_of(&canonical), canonical));
|
||
}
|
||
|
||
// Loud deferral (#198 decision 6), once per run — pinned ORDER: this
|
||
// prints after the document gates and BEFORE any member executes, so a
|
||
// data refusal inside execute cannot swallow it.
|
||
if !campaign.presentation.persist_taps.is_empty() {
|
||
eprintln!(
|
||
"aura: persist_taps not yet honored ({} tap(s) ignored)",
|
||
campaign.presentation.persist_taps.len()
|
||
);
|
||
}
|
||
|
||
let runner = CliMemberRunner {
|
||
env,
|
||
server: Arc::new(data_server::DataServer::new(env.data_path())),
|
||
};
|
||
let outcome = aura_campaign::execute(
|
||
&campaign_id,
|
||
&campaign,
|
||
&process,
|
||
&strategies,
|
||
&runner,
|
||
®istry,
|
||
)
|
||
.map_err(|f| exec_fault_prose(&f))?;
|
||
|
||
// Zero-survivor stderr notes (exit stays 0 — a null result is a valid
|
||
// research result, #198 decision 8). Cells are recorded in strategy ×
|
||
// instrument × window doc order, so the ordinals derive from the index.
|
||
let n_windows = campaign.data.windows.len();
|
||
let n_instruments = campaign.data.instruments.len();
|
||
for (ci, cell) in outcome.record.cells.iter().enumerate() {
|
||
let w_ord = ci % n_windows;
|
||
let s_ord = ci / (n_windows * n_instruments);
|
||
for (stage_ix, st) in cell.stages.iter().enumerate() {
|
||
if matches!(&st.survivor_ordinals, Some(v) if v.is_empty()) {
|
||
eprintln!(
|
||
"aura: cell {s_ord}/{}/w{w_ord}: gate at stage {stage_ix} left no \
|
||
survivors; cell realization truncated",
|
||
cell.instrument
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Emission: emit-gated family/selection lines per cell, then the
|
||
// always-on final record line.
|
||
let emit_family = campaign.presentation.emit.iter().any(|e| e == "family_table");
|
||
let emit_selection = campaign.presentation.emit.iter().any(|e| e == "selection_report");
|
||
for cell_out in &outcome.cells {
|
||
if emit_family {
|
||
for fam in &cell_out.families {
|
||
for report in &fam.reports {
|
||
println!("{}", crate::family_member_line(&fam.family_id, report));
|
||
}
|
||
}
|
||
}
|
||
if emit_selection {
|
||
for sel in &cell_out.selections {
|
||
let line = SelectionReportLine {
|
||
selection_report: SelectionReportBody {
|
||
family_id: &sel.family_id,
|
||
stage: sel.stage,
|
||
block: sel.block,
|
||
winner_ordinal: sel.winner_ordinal,
|
||
params: &sel.params,
|
||
selection: &sel.selection,
|
||
},
|
||
};
|
||
println!(
|
||
"{}",
|
||
serde_json::to_string(&line).expect("selection report serializes")
|
||
);
|
||
}
|
||
}
|
||
}
|
||
println!(
|
||
"{}",
|
||
serde_json::to_string(&CampaignRunLine { campaign_run: &outcome.record })
|
||
.expect("campaign run record serializes")
|
||
);
|
||
Ok(())
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: Build the workspace**
|
||
|
||
Run: `cargo build --workspace`
|
||
Expected: compiles with 0 errors (warnings-free in the new module: every import is used).
|
||
|
||
- [ ] **Step 8: Existing aura-cli suite stays green**
|
||
|
||
Run: `cargo test -p aura-cli`
|
||
Expected: all existing unit + integration tests pass, count unchanged (this task adds no tests; the new verb only extends the clap surface, and no existing test asserts an exhaustive `campaign` subcommand list).
|
||
|
||
### Task 9: aura-cli campaign run seam tests + gated real-data e2e
|
||
|
||
Seam tests for Task 8's `aura campaign run`, in the existing
|
||
`crates/aura-cli/tests/research_docs.rs` (chosen over a new test binary: every
|
||
helper it needs — `temp_cwd`/`run_code_in`/`write_doc`/`built_project`/
|
||
`ScratchGuard` — already lives there). These tests pin behaviour Task 8 already
|
||
implemented, so they must pass on first run; a failure is a Task 8 defect — fix
|
||
the implementation, never weaken the assert. Because several new tests mutate
|
||
the shared demo-project fixture's `runs/` store and test threads run in
|
||
parallel, this task adds a static-Mutex `project_lock()` and threads it through
|
||
the one existing fixture test as well. The persist-taps test relies on Task 8's
|
||
pinned ordering (tap note before member execution): its campaign window is
|
||
`[1, 2]` epoch-ms (1970), so the run deterministically refuses at the
|
||
member-data seam on every machine (data-less host: geometry refusal; data-ful
|
||
host: no file overlaps → NoData) — the tap line must be on stderr either way.
|
||
The e2e mirrors the `cli_run.rs:250-281` skip idiom (GER40 Sept-2024 window;
|
||
clean `eprintln!` skip when the archive is absent).
|
||
|
||
**Files:**
|
||
- Test: crates/aura-cli/tests/research_docs.rs (imports :5–6; existing fixture test :196–199; helpers + six new tests appended at end of file)
|
||
|
||
- [ ] **Step 1: Widen the std::sync import**
|
||
|
||
Replace (old, lines 5–6):
|
||
```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 196–199):
|
||
```rust
|
||
#[test]
|
||
fn campaign_validate_in_project_reports_referential_tier_end_to_end() {
|
||
let dir = built_project();
|
||
let runs_dir = dir.join("runs");
|
||
```
|
||
with (new):
|
||
```rust
|
||
#[test]
|
||
fn campaign_validate_in_project_reports_referential_tier_end_to_end() {
|
||
let _fixture = project_lock();
|
||
let dir = built_project();
|
||
let runs_dir = dir.join("runs");
|
||
```
|
||
|
||
- [ ] **Step 4: Append helpers, fixture docs, and the six campaign-run tests at the end of the file**
|
||
|
||
Append after the last test (`campaign_register_refuses_invalid_document_and_writes_nothing`'s closing brace, end of file):
|
||
|
||
```rust
|
||
/// Seed one open-param blueprint into the built demo project's store via a
|
||
/// real sweep and return its content id (the referential test's recipe).
|
||
fn seed_blueprint(dir: &Path, name: &str) -> String {
|
||
let open_bp = format!("{}/tests/fixtures/sma_signal_open.json", env!("CARGO_MANIFEST_DIR"));
|
||
let (out, code) = run_code_in(
|
||
dir,
|
||
&[
|
||
"sweep",
|
||
&open_bp,
|
||
"--axis",
|
||
"sma_signal.fast.length=2,4",
|
||
"--axis",
|
||
"sma_signal.slow.length=8,16",
|
||
"--name",
|
||
name,
|
||
],
|
||
);
|
||
assert_eq!(code, Some(0), "seed sweep failed: {out}");
|
||
std::fs::read_dir(dir.join("runs").join("blueprints"))
|
||
.expect("blueprints dir")
|
||
.next()
|
||
.expect("one stored blueprint")
|
||
.expect("dir entry")
|
||
.path()
|
||
.file_stem()
|
||
.expect("stem")
|
||
.to_string_lossy()
|
||
.into_owned()
|
||
}
|
||
|
||
/// Register `doc` as a process document in the project store; returns its id.
|
||
/// Asserts register exits 0 — an intrinsically valid document (an mc-bearing
|
||
/// one included) must always register; only `campaign run` draws the v1 line.
|
||
fn register_process_doc(dir: &Path, file: &str, doc: &str) -> String {
|
||
write_doc(dir, file, doc);
|
||
let (out, code) = run_code_in(dir, &["process", "register", file]);
|
||
assert_eq!(code, Some(0), "process register failed: {out}");
|
||
out.lines()
|
||
.find(|l| l.starts_with("registered process content:"))
|
||
.expect("register line")
|
||
.trim_start_matches("registered process content:")
|
||
.split(' ')
|
||
.next()
|
||
.expect("id")
|
||
.to_string()
|
||
}
|
||
|
||
/// A referentially-resolving campaign over the seeded blueprint. Axes name
|
||
/// the RAW `param_space` names (`fast.length` / `slow.length` — see the
|
||
/// naming note in the referential test above). `persist_taps`/`emit` are
|
||
/// spliced verbatim (pass `""` for empty, `"\"family_table\""` etc.).
|
||
fn campaign_doc_json(
|
||
bp_id: &str,
|
||
proc_id: &str,
|
||
window: (i64, i64),
|
||
persist_taps: &str,
|
||
emit: &str,
|
||
) -> String {
|
||
format!(
|
||
r#"{{
|
||
"format_version": 1,
|
||
"kind": "campaign",
|
||
"name": "run-seam",
|
||
"data": {{ "instruments": ["GER40"], "windows": [ {{ "from_ms": {from}, "to_ms": {to} }} ] }},
|
||
"strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }},
|
||
"axes": {{ "fast.length": {{ "kind": "I64", "values": [2, 4] }},
|
||
"slow.length": {{ "kind": "I64", "values": [8, 16] }} }} }} ],
|
||
"process": {{ "ref": {{ "content_id": "{proc_id}" }} }},
|
||
"seed": 7,
|
||
"presentation": {{ "persist_taps": [{persist_taps}], "emit": [{emit}] }}
|
||
}}"#,
|
||
from = window.0,
|
||
to = window.1,
|
||
)
|
||
}
|
||
|
||
/// An mc-bearing process: intrinsically VALID (register accepts it) but past
|
||
/// the executable v1 boundary (`campaign run` refuses it at preflight).
|
||
const MC_PROCESS_DOC: &str = r#"{
|
||
"format_version": 1,
|
||
"kind": "process",
|
||
"name": "mc-screen",
|
||
"pipeline": [
|
||
{ "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" },
|
||
{ "block": "std::monte_carlo", "resamples": 100, "block_len": 5 }
|
||
]
|
||
}"#;
|
||
|
||
/// The minimal executable v1 pipeline (one sweep stage).
|
||
const SWEEP_ONLY_PROCESS_DOC: &str = r#"{
|
||
"format_version": 1,
|
||
"kind": "process",
|
||
"name": "sweep-only",
|
||
"pipeline": [ { "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" } ]
|
||
}"#;
|
||
|
||
/// The full v1 shape for the gated e2e: sweep -> gate -> walk_forward. The
|
||
/// gate (`n_trades ge 0`) passes every member, so walk-forward always has
|
||
/// survivors. Roller: 14d IS / 7d OOS / 7d step in epoch-ms, tiling the
|
||
/// ~30-day GER40 Sept-2024 campaign window.
|
||
const WF_PROCESS_DOC: &str = r#"{
|
||
"format_version": 1,
|
||
"kind": "process",
|
||
"name": "screen-then-walkforward",
|
||
"pipeline": [
|
||
{ "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" },
|
||
{ "block": "std::gate", "all": [ { "metric": "n_trades", "cmp": "ge", "value": 0.0 } ] },
|
||
{ "block": "std::walk_forward", "in_sample_ms": 1209600000, "out_of_sample_ms": 604800000,
|
||
"step_ms": 604800000, "mode": "rolling", "metric": "net_expectancy_r", "select": "argmax" }
|
||
]
|
||
}"#;
|
||
|
||
/// `campaign run` outside a project refuses up front — before target
|
||
/// resolution, so not even the file-sugar registration touches a store.
|
||
#[test]
|
||
fn campaign_run_outside_project_refuses() {
|
||
let dir = temp_cwd("campaign-run-outside-project");
|
||
write_doc(&dir, "c.campaign.json", CAMPAIGN_DOC);
|
||
let (out, code) = run_code_in(&dir, &["campaign", "run", "c.campaign.json"]);
|
||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||
assert!(out.contains("campaign run needs a project"), "stdout/stderr: {out}");
|
||
assert!(
|
||
!dir.join("runs").exists(),
|
||
"a refused run must not create a store outside a project"
|
||
);
|
||
}
|
||
|
||
/// A target that is neither a readable file nor a 64-hex id refuses naming
|
||
/// both readings (inside the project, past the project gate).
|
||
#[test]
|
||
fn campaign_run_bogus_target_refuses() {
|
||
let _fixture = project_lock();
|
||
let dir = built_project();
|
||
let (out, code) = run_code_in(dir, &["campaign", "run", "no-such-target"]);
|
||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||
assert!(
|
||
out.contains("'no-such-target' is neither a readable .json file nor a 64-hex content id"),
|
||
"stdout/stderr: {out}"
|
||
);
|
||
}
|
||
|
||
/// A well-formed but unknown content id refuses with the store-miss prose.
|
||
#[test]
|
||
fn campaign_run_unknown_id_refuses() {
|
||
let _fixture = project_lock();
|
||
let dir = built_project();
|
||
let id = "0".repeat(64);
|
||
let (out, code) = run_code_in(dir, &["campaign", "run", &id]);
|
||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||
assert!(
|
||
out.contains(&format!("no campaign {id} in the project store")),
|
||
"stdout/stderr: {out}"
|
||
);
|
||
}
|
||
|
||
/// The v1 boundary: `process register` ACCEPTS an mc-bearing document (it is
|
||
/// intrinsically valid — asserted inside `register_process_doc`); only
|
||
/// `campaign run` refuses it, at preflight, before any member runs (so no
|
||
/// data is needed), with path-addressed Debug-free prose.
|
||
#[test]
|
||
fn campaign_run_v1_boundary_refuses_mc_process() {
|
||
let _fixture = project_lock();
|
||
let dir = built_project();
|
||
let runs_dir = dir.join("runs");
|
||
std::fs::remove_dir_all(&runs_dir).ok();
|
||
let _cleanup = ScratchGuard(vec![
|
||
ScratchPath::Dir(runs_dir.clone()),
|
||
ScratchPath::File(dir.join("mc.process.json")),
|
||
ScratchPath::File(dir.join("mc.campaign.json")),
|
||
]);
|
||
let bp_id = seed_blueprint(dir, "campaign-run-mc-seed");
|
||
let proc_id = register_process_doc(dir, "mc.process.json", MC_PROCESS_DOC);
|
||
write_doc(dir, "mc.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""));
|
||
let (out, code) = run_code_in(dir, &["campaign", "run", "mc.campaign.json"]);
|
||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||
assert!(out.contains("not executable in v1"), "stdout/stderr: {out}");
|
||
assert!(out.contains("std::monte_carlo"), "the prose names the block: {out}");
|
||
assert!(!out.contains("UnsupportedStage"), "Debug leak: {out}");
|
||
}
|
||
|
||
/// Non-empty persist_taps defers LOUDLY, and the note's ORDER is pinned: it
|
||
/// prints before member execution (Task 8), so it is asserted here on a run
|
||
/// that refuses at the member-data seam. The [1, 2] epoch-ms window (1970)
|
||
/// makes that refusal deterministic on every machine: a data-less host
|
||
/// refuses on missing geometry, a data-ful host on a window no archive file
|
||
/// overlaps — exit 1 either way, tap note already on stderr.
|
||
#[test]
|
||
fn campaign_run_persist_taps_deferred_loudly() {
|
||
let _fixture = project_lock();
|
||
let dir = built_project();
|
||
let runs_dir = dir.join("runs");
|
||
std::fs::remove_dir_all(&runs_dir).ok();
|
||
let _cleanup = ScratchGuard(vec![
|
||
ScratchPath::Dir(runs_dir.clone()),
|
||
ScratchPath::File(dir.join("taps.process.json")),
|
||
ScratchPath::File(dir.join("taps.campaign.json")),
|
||
]);
|
||
let bp_id = seed_blueprint(dir, "campaign-run-taps-seed");
|
||
let proc_id = register_process_doc(dir, "taps.process.json", SWEEP_ONLY_PROCESS_DOC);
|
||
write_doc(
|
||
dir,
|
||
"taps.campaign.json",
|
||
&campaign_doc_json(&bp_id, &proc_id, (1, 2), "\"r_record\"", ""),
|
||
);
|
||
let (out, code) = run_code_in(dir, &["campaign", "run", "taps.campaign.json"]);
|
||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||
assert!(
|
||
out.contains("aura: persist_taps not yet honored (1 tap(s) ignored)"),
|
||
"the tap note must precede the member-data refusal: {out}"
|
||
);
|
||
assert!(
|
||
out.contains("no recorded geometry") || out.contains("no data for instrument"),
|
||
"the refusal names the data condition: {out}"
|
||
);
|
||
}
|
||
|
||
/// Gated real-data e2e (the `cli_run.rs` skip idiom): a full
|
||
/// sweep -> gate -> walk_forward campaign over GER40 Sept-2024 where the
|
||
/// local archive is present — exit 0, emit-gated family/selection lines, a
|
||
/// final line parseable as JSON with top-level `campaign_run` linking a sweep
|
||
/// family id and a walk-forward family id, and the `campaign_runs.jsonl`
|
||
/// sibling store written. Skips with a note elsewhere so
|
||
/// `cargo test --workspace` stays green on a data-less machine.
|
||
#[test]
|
||
fn campaign_run_real_e2e_sweep_gate_walkforward() {
|
||
let _fixture = project_lock();
|
||
let dir = built_project();
|
||
let runs_dir = dir.join("runs");
|
||
std::fs::remove_dir_all(&runs_dir).ok();
|
||
let _cleanup = ScratchGuard(vec![
|
||
ScratchPath::Dir(runs_dir.clone()),
|
||
ScratchPath::File(dir.join("wf.process.json")),
|
||
ScratchPath::File(dir.join("wf.campaign.json")),
|
||
]);
|
||
let bp_id = seed_blueprint(dir, "campaign-run-e2e-seed");
|
||
let proc_id = register_process_doc(dir, "wf.process.json", WF_PROCESS_DOC);
|
||
// The GER40 Sept-2024 window (inclusive Unix-ms) — the same gated window
|
||
// cli_run.rs drives; ~30 days, so the (14d, 7d, 7d) roller tiles it.
|
||
write_doc(
|
||
dir,
|
||
"wf.campaign.json",
|
||
&campaign_doc_json(
|
||
&bp_id,
|
||
&proc_id,
|
||
(1725148800000, 1727740799999),
|
||
"",
|
||
"\"family_table\", \"selection_report\"",
|
||
),
|
||
);
|
||
let (out, code) = run_code_in(dir, &["campaign", "run", "wf.campaign.json"]);
|
||
|
||
// Skip on a data-less machine: the member-data refusal, never a panic.
|
||
if code == Some(1)
|
||
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
|
||
{
|
||
eprintln!("skip: no local GER40 data for the campaign e2e");
|
||
return;
|
||
}
|
||
|
||
assert_eq!(code, Some(0), "stdout/stderr: {out}");
|
||
let record_line = out
|
||
.lines()
|
||
.find(|l| l.starts_with("{\"campaign_run\":"))
|
||
.expect("the always-on final campaign_run line");
|
||
let v: serde_json::Value =
|
||
serde_json::from_str(record_line).expect("campaign_run line parses as JSON");
|
||
let cells = v["campaign_run"]["cells"].as_array().expect("cells array");
|
||
assert_eq!(cells.len(), 1, "one (strategy, instrument, window) cell: {record_line}");
|
||
let stages = cells[0]["stages"].as_array().expect("stages array");
|
||
assert_eq!(stages.len(), 3, "sweep + gate + walk_forward realized: {record_line}");
|
||
assert!(
|
||
stages[0]["family_id"].as_str().is_some(),
|
||
"sweep stage links a family: {record_line}"
|
||
);
|
||
assert_eq!(
|
||
stages[1]["survivor_ordinals"].as_array().map(|a| a.len()),
|
||
Some(4),
|
||
"the always-true gate keeps all four members: {record_line}"
|
||
);
|
||
assert!(
|
||
stages[2]["family_id"].as_str().is_some(),
|
||
"walk-forward stage links a family: {record_line}"
|
||
);
|
||
// Emit-gated lines: per-member family_table lines (4 sweep members plus
|
||
// the walk-forward OOS members) and at least one selection_report line.
|
||
assert!(
|
||
out.lines().filter(|l| l.starts_with("{\"family_id\":")).count() >= 4,
|
||
"family_table member lines emitted: {out}"
|
||
);
|
||
assert!(
|
||
out.lines().any(|l| l.starts_with("{\"selection_report\":")),
|
||
"selection_report line emitted: {out}"
|
||
);
|
||
// The registry's new sibling store carries the realization record.
|
||
assert!(
|
||
runs_dir.join("campaign_runs.jsonl").is_file(),
|
||
"campaign_runs.jsonl written beside runs.jsonl"
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run the new tests**
|
||
|
||
Run: `cargo test -p aura-cli campaign_run`
|
||
Expected: 6 tests matched and passing in the `research_docs` integration binary (0 elsewhere). On a host without the local GER40 archive, `campaign_run_real_e2e_sweep_gate_walkforward` prints `skip: no local GER40 data for the campaign e2e` and still reports ok. These pin Task 8 behaviour — any failure is a Task 8 defect to fix, not an assert to weaken.
|
||
|
||
- [ ] **Step 6: Full workspace stays green**
|
||
|
||
Run: `cargo test --workspace`
|
||
Expected: all crates green (the fixture-store tests serialize on `project_lock`, so the shared demo-project `runs/` store no longer races).
|
||
### Task 10: #196 blueprint on-ramp — graph register, introspect --params, blueprint-file --content-id
|
||
|
||
**Files:**
|
||
- Modify: `crates/aura-registry/src/lib.rs` :109-115 (conditional — only if Task 3 has not already promoted `blueprint_path` to `pub`)
|
||
- Modify: `crates/aura-cli/src/main.rs` :3801-3807 (`GraphSub`), :3809-3826 (`GraphIntrospectCmd`), :4338-4344 (`dispatch_graph`)
|
||
- Modify: `crates/aura-cli/src/graph_construct.rs` :7-12 (imports), :193-275 (`introspect_cmd`), append new fns after `introspect_cmd`
|
||
- Test: `crates/aura-cli/tests/graph_construct.rs` (append helpers + 5 tests at end of file)
|
||
|
||
Context for the implementer: #196 closes the campaign authoring gap. Three additions to the `aura graph` verb family: (a) `aura graph register <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(®_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.
|