# Param-sweep grid — Iteration 2 (`aura sweep` CLI demonstrator) — Implementation Plan > **Parent spec:** `docs/specs/0028-param-sweep-grid.md` > > **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run > this plan. Steps use `- [ ]` checkboxes for tracking. **Goal:** Make the iteration-1 sweep primitive visible without writing Rust — an `aura sweep` subcommand that runs the built-in sample over a small built-in grid and prints one JSON line per point. **Architecture:** All edits live in `crates/aura-cli/src/main.rs` plus a golden in `crates/aura-cli/tests/cli_run.rs`. A `sample_blueprint_with_sinks()` factory holds the sample topology and returns the two Recorder receivers (today `build_sample` drops them); `build_sample` is re-expressed on top of it so the `aura graph` path is unchanged. A pure `sweep_report() -> String` declares the grid, calls the engine `sweep()` with a per-point build→run→drain→summarize closure, and renders each `SweepPoint` via a hand-rolled `sweep_point_to_json` (C14, no serde — mirrors `RunReport::to_json`'s token rules). The `main()` dispatch grows a `["sweep"]` arm. **Tech Stack:** `crates/aura-cli` (`main.rs` subcommand + helpers, `tests/cli_run.rs` golden); consumes the iter-1 engine exports `GridSpace`/`sweep`/`SweepPoint` and the existing `f64_field`/`summarize`/`synthetic_prices`/`sma_cross` surface. --- **Files this plan creates or modifies:** - Modify: `crates/aura-cli/src/main.rs:11-17` — extend the `use` blocks (`ParamSpec` from aura-core; `GridSpace`, `sweep`, `SweepPoint` from aura-engine). - Modify: `crates/aura-cli/src/main.rs:160-194` — add `sample_blueprint_with_sinks()`; re-express `build_sample()` on top of it. - Modify: `crates/aura-cli/src/main.rs:352` — extend `USAGE`. - Modify: `crates/aura-cli/src/main.rs:359-368` — add the `["sweep"]` dispatch arm. - Modify: `crates/aura-cli/src/main.rs` (new fns `sweep_report`, `sweep_point_to_json`, `json_string`, `scalar_token`) + its `#[cfg(test)]` module. - Modify: `crates/aura-cli/tests/cli_run.rs` — add the `aura sweep` process golden. --- ## Task 1: `sample_blueprint_with_sinks` factory (sinks reachable for draining) **Files:** - Modify: `crates/aura-cli/src/main.rs:160-194` - [ ] **Step 1: Write the failing test (RED)** In `crates/aura-cli/src/main.rs`, inside the existing `#[cfg(test)] mod tests { … }` block (around `main.rs:371-459`), add this test: ```rust #[test] fn sample_blueprint_with_sinks_bootstraps_runs_and_drains() { // the factory returns the two Recorder receivers (build_sample drops them), // so a caller can bootstrap one point, run it, and drain both sinks. let (bp, rx_eq, rx_ex) = sample_blueprint_with_sinks(); let mut h = bp .bootstrap_with_params(vec![Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)]) .expect("sample blueprint compiles under a valid point"); h.run(vec![synthetic_prices()]); assert!(!rx_eq.try_iter().collect::>().is_empty(), "equity sink drained empty"); assert!(!rx_ex.try_iter().collect::>().is_empty(), "exposure sink drained empty"); } ``` - [ ] **Step 2: Run the test to verify it fails (RED)** Run: `cargo test -p aura-cli --bin aura sample_blueprint_with_sinks` Expected: FAIL — compile error, `cannot find function sample_blueprint_with_sinks` in this scope. - [ ] **Step 3: Add the factory and re-express `build_sample` (GREEN)** In `crates/aura-cli/src/main.rs`, replace the current `build_sample` function (`main.rs:160-189`, the one that constructs two channels and drops the receivers at `let (tx_eq, _rx_eq) = …` / `let (tx_ex, _rx_ex) = …`) with the factory plus a thin `build_sample` that discards the receivers: ```rust /// The sample signal-quality blueprint (value-empty) **with its two recording /// sinks reachable**: returns the equity + exposure receivers a per-point sweep /// run drains (the SMA lengths + exposure scale are injected at compile via the /// point vector). The single source of the sample topology — `build_sample` /// (the `aura graph` entry) is expressed on top of it. #[allow(clippy::type_complexity)] fn sample_blueprint_with_sinks() -> ( Composite, Receiver<(Timestamp, Vec)>, Receiver<(Timestamp, Vec)>, ) { let (tx_eq, rx_eq) = mpsc::channel(); let (tx_ex, rx_ex) = mpsc::channel(); let bp = Composite::new( "sample", vec![ BlueprintNode::Composite(sma_cross("sma_cross")), Exposure::builder().into(), SimBroker::builder(0.0001).into(), Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq).into(), Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex).into(), ], vec![ Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // spread -> Exposure Edge { from: 1, to: 2, slot: 0, from_field: 0 }, // exposure -> broker slot 0 Edge { from: 2, to: 3, slot: 0, from_field: 0 }, // equity -> sink Edge { from: 1, to: 4, slot: 0, from_field: 0 }, // exposure -> sink ], vec![Role { name: "price".into(), targets: vec![ Target { node: 0, slot: 0 }, // price -> sma_cross role 0 Target { node: 2, slot: 1 }, // price -> SimBroker price slot ], source: Some(ScalarKind::F64), }], vec![], // params: the interior sma_cross carries the aliases vec![], // output: the root ends in sinks, no re-export ); (bp, rx_eq, rx_ex) } /// The sample blueprint without its sink receivers — the `aura graph` render /// entry, which never runs the graph (so the receivers are dropped). fn build_sample() -> Composite { sample_blueprint_with_sinks().0 } ``` (The `sample_blueprint()` wrapper at `main.rs:192-194` — `fn sample_blueprint() -> Composite { build_sample() }` — is unchanged.) - [ ] **Step 4: Run the test to verify it passes (GREEN)** Run: `cargo test -p aura-cli --bin aura sample_blueprint_with_sinks` Expected: PASS — 1 test (`sample_blueprint_with_sinks_bootstraps_runs_and_drains`). - [ ] **Step 5: Graph non-regression + lint gate** Run: `cargo test -p aura-cli` Expected: PASS — the whole aura-cli suite green, **including** the existing graph golden `graph_emits_self_contained_html_viewer` in `tests/cli_run.rs` (proves the `build_sample` re-expression did not change the rendered sample topology). Run: `cargo clippy -p aura-cli --all-targets -- -D warnings` Expected: clean. --- ## Task 2: `sweep` subcommand — grid, render, and the JSON goldens **Files:** - Modify: `crates/aura-cli/src/main.rs:11-17` (imports), `:352` (USAGE), `:359-368` (dispatch), plus new fns + tests - Modify: `crates/aura-cli/tests/cli_run.rs` - [ ] **Step 1: Write the `sweep_point_to_json` byte-pin test (RED)** In `crates/aura-cli/src/main.rs`, inside the `#[cfg(test)] mod tests { … }` block, add this test (a hand-constructed point, so the exact bytes — including the `12.0 -> 12` / `0.5` token rules — are pinned): ```rust #[test] fn sweep_point_to_json_renders_canonical_form() { let space = vec![ ParamSpec { name: "sma_cross.fast".into(), kind: ScalarKind::I64 }, ParamSpec { name: "sma_cross.slow".into(), kind: ScalarKind::I64 }, ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }, ]; let pt = SweepPoint { params: vec![Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)], // fully-qualified: RunMetrics is needed only by this test, so it is not // imported into production (where it would be an unused import — the // production code reads `pt.metrics` by field, never naming the type). metrics: aura_engine::RunMetrics { total_pips: 12.0, max_drawdown: 1.0, exposure_sign_flips: 1 }, }; assert_eq!( sweep_point_to_json(&pt, &space), r#"{"params":{"sma_cross.fast":2,"sma_cross.slow":4,"scale":0.5},"metrics":{"total_pips":12,"max_drawdown":1,"exposure_sign_flips":1}}"#, ); } ``` `SweepPoint` and `ParamSpec` reach this test via the existing `use super::*` (both are added to production imports in Step 3); `Scalar`/`ScalarKind` are already in `super` (`main.rs:11`). `RunMetrics` is fully-qualified above, so it needs no import. - [ ] **Step 2: Run the test to verify it fails (RED)** Run: `cargo test -p aura-cli --bin aura sweep_point_to_json` Expected: FAIL — compile error, `cannot find function sweep_point_to_json` / `cannot find type SweepPoint` / `cannot find value ParamSpec` (the helpers and imports do not exist yet). - [ ] **Step 3: Extend the imports + write the JSON helpers (GREEN for Step 1)** In `crates/aura-cli/src/main.rs`, change the aura-core import (`main.rs:11`) to add `ParamSpec`: ```rust use aura_core::{Firing, ParamSpec, Scalar, ScalarKind, Timestamp}; ``` and the aura-engine import (`main.rs:12-15`) to add `GridSpace`, `sweep`, and `SweepPoint` (NOT `RunMetrics` — it is used only by the Step-1 test, which fully-qualifies it, so importing it here would be an unused import in the production build): ```rust use aura_engine::{ f64_field, summarize, sweep, BlueprintNode, Composite, Edge, FlatGraph, GridSpace, Harness, OutField, ParamAlias, Role, RunManifest, RunReport, SourceSpec, SweepPoint, Target, }; ``` Then add the three render helpers (place them next to the other sample helpers, e.g. directly after `sample_blueprint()` near `main.rs:194`): ```rust /// Render one swept point as one canonical JSON object (C14, hand-rolled — the /// engine's `json_str` is private, so the rules of `RunReport::to_json` are /// mirrored here, not called): `{"params":{name:value,…},"metrics":{…}}`. Param /// names come from `param_space()` zipped onto the point vector; `f64` fields use /// the round-trippable shortest form, so a whole-valued float renders without a /// fractional part (`12.0` -> `12`). fn sweep_point_to_json(pt: &SweepPoint, space: &[ParamSpec]) -> String { let mut params = String::from("{"); for (i, (ps, v)) in space.iter().zip(&pt.params).enumerate() { if i > 0 { params.push(','); } params.push_str(&json_string(&ps.name)); params.push(':'); params.push_str(&scalar_token(*v)); } params.push('}'); format!( "{{\"params\":{params},\"metrics\":{{\"total_pips\":{tp},\"max_drawdown\":{dd},\"exposure_sign_flips\":{fl}}}}}", tp = pt.metrics.total_pips, dd = pt.metrics.max_drawdown, fl = pt.metrics.exposure_sign_flips, ) } /// Minimal JSON string rendering: wrap in quotes, escape `"` and `\` (mirrors the /// engine's private `json_str`; our param names contain neither, but the escape /// keeps the helper honest). fn json_string(s: &str) -> String { let mut out = String::with_capacity(s.len() + 2); out.push('"'); for c in s.chars() { match c { '"' => out.push_str("\\\""), '\\' => out.push_str("\\\\"), _ => out.push(c), } } out.push('"'); out } /// One scalar as its JSON value token: an integer for `I64`, the shortest /// round-trippable form for `F64`, `true`/`false` for `Bool`, the epoch-ns /// integer for a `Timestamp`. fn scalar_token(v: Scalar) -> String { match v { Scalar::I64(n) => n.to_string(), Scalar::F64(f) => f.to_string(), Scalar::Bool(b) => b.to_string(), Scalar::Ts(t) => t.0.to_string(), } } ``` - [ ] **Step 4: Run the byte-pin test to verify it passes (GREEN)** Run: `cargo test -p aura-cli --bin aura sweep_point_to_json` Expected: PASS — 1 test (`sweep_point_to_json_renders_canonical_form`). - [ ] **Step 5: Add `sweep_report`, the dispatch arm, and the usage string** In `crates/aura-cli/src/main.rs`, add the `sweep_report` function (e.g. directly after `scalar_token`): ```rust /// Run the built-in sample over a small built-in grid (fast ∈ {2,3}, /// slow ∈ {4,5}, scale ∈ {0.5} — 4 points) and render one JSON line per point in /// enumeration (odometer) order. Pure + deterministic (C1): the same build yields /// the same report. Each point builds a fresh blueprint (fresh sink channels), /// bootstraps it under the point vector, runs it, and folds the drained sinks to /// metrics — the per-point closure the engine `sweep` drives disjointly. fn sweep_report() -> String { let space = sample_blueprint_with_sinks().0.param_space(); let grid = GridSpace::new( &space, vec![ vec![Scalar::I64(2), Scalar::I64(3)], // fast ∈ {2, 3} vec![Scalar::I64(4), Scalar::I64(5)], // slow ∈ {4, 5} vec![Scalar::F64(0.5)], // scale ∈ {0.5} ], ) .expect("the built-in grid matches the sample param-space"); let family = sweep(&grid, |point| { let (bp, rx_eq, rx_ex) = sample_blueprint_with_sinks(); let mut h = bp .bootstrap_with_params(point.to_vec()) .expect("grid points are kind-checked against param_space"); h.run(vec![synthetic_prices()]); let equity = f64_field(&rx_eq.try_iter().collect::>(), 0); let exposure = f64_field(&rx_ex.try_iter().collect::>(), 0); summarize(&equity, &exposure) }); let mut out = String::new(); for pt in &family.points { out.push_str(&sweep_point_to_json(pt, &space)); out.push('\n'); } out } ``` Change the `USAGE` const (`main.rs:352`) to advertise the new form: ```rust const USAGE: &str = "usage: aura run [--macd] | aura graph | aura sweep"; ``` Add the `["sweep"]` arm to the `match` in `main()` (`main.rs:359-368`), beside the `["graph"]` arm: ```rust ["sweep"] => print!("{}", sweep_report()), ``` (The strict-trailing-token contract is inherited for free: `aura sweep extra` does not match `["sweep"]` and falls through to the `_` usage-error arm.) - [ ] **Step 6: Write the `sweep_report` structure goldens (RED then GREEN)** In `crates/aura-cli/src/main.rs`, inside `#[cfg(test)] mod tests { … }`, add: ```rust #[test] fn sweep_report_renders_four_points_in_odometer_order() { let out = sweep_report(); let lines: Vec<&str> = out.lines().collect(); assert_eq!(lines.len(), 4, "one JSON line per grid point; got: {out:?}"); // params byte-pinned per line: odometer order (last axis fastest) + the // param-name and value-token rules. Metric *values* are left to the // determinism check below (they are computed f64s, not predictable here). assert!(lines[0].starts_with( r#"{"params":{"sma_cross.fast":2,"sma_cross.slow":4,"scale":0.5},"metrics":{"# )); assert!(lines[1].starts_with( r#"{"params":{"sma_cross.fast":2,"sma_cross.slow":5,"scale":0.5},"metrics":{"# )); assert!(lines[2].starts_with( r#"{"params":{"sma_cross.fast":3,"sma_cross.slow":4,"scale":0.5},"metrics":{"# )); assert!(lines[3].starts_with( r#"{"params":{"sma_cross.fast":3,"sma_cross.slow":5,"scale":0.5},"metrics":{"# )); for line in &lines { assert!(line.contains(r#""total_pips":"#), "missing total_pips: {line}"); assert!(line.contains(r#""max_drawdown":"#), "missing max_drawdown: {line}"); assert!(line.contains(r#""exposure_sign_flips":"#), "missing flips: {line}"); assert!(line.ends_with('}'), "line not closed: {line}"); } } #[test] fn sweep_report_is_deterministic() { // C1 at the CLI edge: the same build yields a bit-identical report. assert_eq!(sweep_report(), sweep_report()); } ``` Run: `cargo test -p aura-cli --bin aura sweep_report` Expected: PASS — 2 tests (`sweep_report_renders_four_points_in_odometer_order`, `sweep_report_is_deterministic`). (If run before Step 5's `sweep_report` exists, this fails to compile — that is the RED state.) - [ ] **Step 7: Write the `aura sweep` process golden (RED then GREEN)** In `crates/aura-cli/tests/cli_run.rs`, append: ```rust #[test] fn sweep_prints_four_json_lines_and_exits_zero() { let out = Command::new(BIN).arg("sweep").output().expect("spawn aura sweep"); assert!(out.status.success(), "exit status: {:?}", out.status); let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); // one JSON line per grid point (4-point built-in grid), each terminated by a // newline from `sweep_report`. assert_eq!(stdout.lines().count(), 4, "stdout was: {stdout:?}"); // the first line is the first odometer point (fast=2, slow=4), params pinned. assert!( stdout.lines().next().unwrap().starts_with( "{\"params\":{\"sma_cross.fast\":2,\"sma_cross.slow\":4,\"scale\":0.5},\"metrics\":{" ), "got: {stdout}" ); } #[test] fn sweep_with_trailing_token_is_strict_and_exits_two() { // strict-arg reading (#16): an unexpected trailing token is a usage error. let out = Command::new(BIN).args(["sweep", "extra"]).output().expect("spawn aura"); assert_eq!(out.status.code(), Some(2), "exit status: {:?}", out.status); } ``` Run: `cargo test -p aura-cli --test cli_run sweep` Expected: PASS — 2 tests (`sweep_prints_four_json_lines_and_exits_zero`, `sweep_with_trailing_token_is_strict_and_exits_two`). - [ ] **Step 8: Full workspace build + test + lint gate** Run: `cargo build --workspace` Expected: success. Run: `cargo test --workspace` Expected: PASS — the whole suite green, including the new aura-cli `sweep` tests and all pre-existing tests (no regression — the engine sweep tests from iter 1, the `aura run`/`aura graph` goldens, the MACD tests). Run: `cargo clippy --workspace --all-targets -- -D warnings` Expected: clean (no warnings).