feat(aura-cli): aura sweep — grid-sweep demonstrator (the World, cycle C / iter 2)
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
This commit is contained in:
+183
-13
@@ -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<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")),
|
||||
@@ -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::<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
|
||||
}
|
||||
|
||||
// --- 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::<Vec<_>>().is_empty(), "equity sink drained empty");
|
||||
assert!(!rx_ex.try_iter().collect::<Vec<_>>().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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user