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