From e94ee4253a1619206139f6e3f4bfacd844c2988b Mon Sep 17 00:00:00 2001 From: Brummel Date: Wed, 10 Jun 2026 18:51:23 +0200 Subject: [PATCH] =?UTF-8?q?feat(aura-cli):=20aura=20sweep=20=E2=80=94=20gr?= =?UTF-8?q?id-sweep=20demonstrator=20(the=20World,=20cycle=20C=20/=20iter?= =?UTF-8?q?=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the iteration-1 sweep primitive visible without writing Rust: an `aura sweep` subcommand runs the built-in sample over a small built-in grid (fast in {2,3}, slow in {4,5}, scale in {0.5} = 4 points) and prints one canonical JSON line per point, in enumeration (odometer) order. - sample_blueprint_with_sinks() -> (Composite, Receiver, Receiver) now holds the sample topology and returns the two Recorder receivers a per-point run drains; build_sample() (the `aura graph` entry, which never runs the graph) is re-expressed as `sample_blueprint_with_sinks().0`, so there is one topology definition and the graph render is unchanged (its golden stays green). - sweep_report() declares the grid over the sample's param_space and calls the engine sweep() with a per-point closure (build fresh -> bootstrap_with_params -> run -> drain both sinks via f64_field -> summarize). Pure + deterministic (C1): the same build yields a bit-identical report. - sweep_point_to_json + json_string + scalar_token hand-roll the JSON (C14, no serde — the engine's json_str is private), mirroring RunReport::to_json's token rules: a whole-valued f64 renders without a fractional part (12.0->12), an I64 as an integer token, an F64 as the shortest round-trippable form. - The main() dispatch grows a ["sweep"] arm; USAGE advertises it; the strict trailing-token contract is inherited (aura sweep extra -> exit 2). Tested: sweep_point_to_json byte-pinned on a hand-built point (the f64/i64 token rules); sweep_report pins 4 lines with per-line params byte-exact in odometer order + determinism; process goldens in tests/cli_run.rs pin the stdout line-count/exit-zero and the strict-arg exit-2 path. Full cargo test --workspace green (graph golden, engine iter-1 sweep tests, run/macd all unaffected); clippy --workspace --all-targets -- -D warnings clean. refs #32 --- crates/aura-cli/src/main.rs | 196 +++++++++++++++++++++++++++++-- crates/aura-cli/tests/cli_run.rs | 25 ++++ 2 files changed, 208 insertions(+), 13 deletions(-) diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 35ebdbc..be6ebb4 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -8,10 +8,10 @@ mod render; -use aura_core::{Firing, Scalar, ScalarKind, Timestamp}; +use aura_core::{Firing, ParamSpec, Scalar, ScalarKind, Timestamp}; use aura_engine::{ - f64_field, summarize, BlueprintNode, Composite, Edge, FlatGraph, Harness, OutField, ParamAlias, - Role, RunManifest, RunReport, SourceSpec, Target, + f64_field, summarize, sweep, BlueprintNode, Composite, Edge, FlatGraph, GridSpace, Harness, + OutField, ParamAlias, Role, RunManifest, RunReport, SourceSpec, SweepPoint, Target, }; use aura_std::{Ema, Exposure, Recorder, SimBroker, Sma, Sub}; use std::sync::mpsc::{self, Receiver}; @@ -153,14 +153,20 @@ fn sma_cross(name: &str) -> Composite { ) } -/// The sample signal-quality blueprint (value-empty): a recipe whose SMA lengths + -/// exposure scale are injected at compile via the point vector. Recorders need a -/// channel to construct; the receivers are dropped because the render never runs -/// the graph. -fn build_sample() -> Composite { - let (tx_eq, _rx_eq) = mpsc::channel(); - let (tx_ex, _rx_ex) = mpsc::channel(); - Composite::new( +/// 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")), @@ -185,7 +191,14 @@ fn build_sample() -> Composite { }], 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 built-in sample rendered by `aura graph`. @@ -193,6 +206,95 @@ fn sample_blueprint() -> Composite { build_sample() } +/// 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(), + } +} + +/// 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 +} + // --- MACD proof-of-concept (a richer, nested indicator + strategy) ----------- /// The MACD signal as a named composite: price → fast/slow `Ema` → the MACD line @@ -349,7 +451,7 @@ fn run_macd() -> RunReport { } } -const USAGE: &str = "usage: aura run [--macd] | aura graph"; +const USAGE: &str = "usage: aura run [--macd] | aura graph | aura sweep"; fn main() { // Collect argv and match the whole vector: every accepted form is exhaustive, @@ -360,6 +462,7 @@ fn main() { ["run"] => println!("{}", run_sample().to_json()), ["run", "--macd"] => println!("{}", run_macd().to_json()), ["graph"] => print!("{}", render::render_html(&sample_blueprint())), + ["sweep"] => print!("{}", sweep_report()), ["--help"] | ["-h"] => println!("{USAGE}"), _ => { eprintln!("aura: {USAGE}"); @@ -372,6 +475,73 @@ fn main() { mod tests { use super::*; + #[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"); + } + + #[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}}"#, + ); + } + + #[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()); + } + #[test] fn run_macd_compiles_from_nested_composite_and_is_deterministic() { // the MACD strategy authors a nested EMA-of-EMA composite, compiles it to a diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index 178c60a..25a19e0 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -187,3 +187,28 @@ fn graph_emits_self_contained_html_viewer() { // the retired ascii-dag invented notation is gone. assert!(!stdout.contains("Sub(#Sf,#Ss)"), "retired ascii-dag notation must be gone: {stdout}"); } + +#[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); +}