Files
Aura/docs/plans/0028-sweep-cli-demonstrator.md
T

18 KiB

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:

    #[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::<Vec<_>>().is_empty(), "equity sink drained empty");
        assert!(!rx_ex.try_iter().collect::<Vec<_>>().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:

/// 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<Scalar>)>,
    Receiver<(Timestamp, Vec<Scalar>)>,
) {
    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-194fn 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):

    #[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:

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):

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):

/// 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):

/// 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::<Vec<_>>(), 0);
        let exposure = f64_field(&rx_ex.try_iter().collect::<Vec<_>>(), 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:

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:

        ["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:

    #[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:

#[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).