Files
Aura/crates/aura-cli/src/main.rs
T
Brummel d8e0fb70c8 refactor(aura-cli): centralize the sim-optimal RunManifest construction
Three RunReport builders hand-rolled the same RunManifest with identical
commit/seed/broker fields (only params/window varied), drifting
independently. Extract sim_optimal_manifest(params, window) — one source for
the broker label, so #22's per-asset pip work has a single edit point.
2026-06-14 22:30:17 +02:00

647 lines
29 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `aura` — the programmatic / CLI face of the engine (the surface the LLM and
//! automation drive: author a node, run a sim/sweep, emit structured metrics).
//!
//! The walking skeleton's closing seam: `aura run` bootstraps a built-in sample
//! signal-quality harness (synthetic source → SMA-cross → Exposure → SimBroker →
//! recording sinks), runs it deterministically (C1), and prints the run's
//! metrics + manifest (#6) as canonical JSON to stdout (the headline C14 move).
mod render;
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
use aura_engine::{
f64_field, summarize, Composite, Edge, FlatGraph, GraphBuilder, Harness,
RunManifest, RunReport, SourceSpec, SweepFamily, Target,
};
use aura_registry::{rank_by, Registry};
use aura_std::{Ema, Exposure, LinComb, Recorder, SimBroker, Sma, Sub};
use std::sync::mpsc::{self, Receiver};
/// The built-in synthetic price stream: rises through t=4 then reverses, so the
/// demo trace carries one exposure sign flip and a real drawdown (C22 populated
/// trace). Deterministic and fixed (C1).
fn synthetic_prices() -> Vec<(Timestamp, Scalar)> {
[
(1_i64, 1.0000_f64),
(2, 1.0010),
(3, 1.0030),
(4, 1.0060),
(5, 1.0040),
(6, 1.0010),
(7, 0.9990),
]
.iter()
.map(|&(t, p)| (Timestamp(t), Scalar::F64(p)))
.collect()
}
/// A warm-up-adequate synthetic stream for the enriched sample/sweep: ~18 ticks
/// rising, falling, then rising again so the trend SMA spread and the MACD
/// EMA-of-EMA histogram both warm up and flip sign. It shares the proven warm-up
/// profile of `macd_prices` (the enriched sample embeds the same `macd` composite,
/// so it needs the same warm-up length); the flat `run_sample` keeps the shorter
/// `synthetic_prices`. Deterministic and fixed (C1).
fn showcase_prices() -> Vec<(Timestamp, Scalar)> {
[
1.0000_f64, 1.0008, 1.0021, 1.0039, 1.0062, 1.0090, 1.0083, 1.0061, 1.0034,
1.0012, 0.9998, 1.0006, 1.0024, 1.0047, 1.0069, 1.0086, 1.0097, 1.0092,
]
.iter()
.enumerate()
.map(|(i, &p)| (Timestamp(i as i64 + 1), Scalar::F64(p)))
.collect()
}
/// Bootstrap the sample signal-quality harness with two recording sinks (equity
/// tapped on the SimBroker, exposure tapped on the Exposure node). Rust-authored
/// wiring (C17/C20) over the raw bootstrap API — no builder DSL this cycle. The
/// price taps both SMAs and the broker's price slot (slot 1); exposure feeds the
/// broker's slot 0 (slot order is load-bearing — both are f64).
// The harness-plus-two-drained-sink-receivers tuple has exactly one call site
// (`run_sample`); a named type would be speculative abstraction this cycle.
#[allow(clippy::type_complexity)]
fn sample_harness() -> (
Harness,
Receiver<(Timestamp, Vec<Scalar>)>,
Receiver<(Timestamp, Vec<Scalar>)>,
) {
let (tx_eq, rx_eq) = mpsc::channel();
let (tx_ex, rx_ex) = mpsc::channel();
let f64_recorder_sig = || aura_engine::NodeSchema {
inputs: vec![aura_engine::PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "in".into() }],
output: vec![],
params: vec![],
};
let h = Harness::bootstrap(FlatGraph {
nodes: vec![
Box::new(Sma::new(2)), // 0 fast SMA
Box::new(Sma::new(4)), // 1 slow SMA
Box::new(Sub::new()), // 2 spread
Box::new(Exposure::new(0.5)), // 3 exposure
Box::new(SimBroker::new(0.0001)), // 4 sim-optimal broker
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_eq)), // 5 equity sink
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_ex)), // 6 exposure sink
],
signatures: vec![
Sma::builder().schema().clone(),
Sma::builder().schema().clone(),
Sub::builder().schema().clone(),
Exposure::builder().schema().clone(),
SimBroker::builder(0.0001).schema().clone(),
f64_recorder_sig(),
f64_recorder_sig(),
],
sources: vec![SourceSpec {
kind: ScalarKind::F64,
targets: vec![
Target { node: 0, slot: 0 },
Target { node: 1, slot: 0 },
Target { node: 4, slot: 1 }, // price into the broker's price slot
],
}],
edges: vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
Edge { from: 2, to: 3, slot: 0, from_field: 0 },
Edge { from: 3, to: 4, slot: 0, from_field: 0 }, // exposure into broker slot 0
Edge { from: 4, to: 5, slot: 0, from_field: 0 }, // equity -> sink 5
Edge { from: 3, to: 6, slot: 0, from_field: 0 }, // exposure -> sink 6
],
})
.expect("valid sample signal-quality DAG");
(h, rx_eq, rx_ex)
}
/// Build the sim-optimal `RunManifest`: the engine-external descriptor fields
/// (commit, seed-free synthetic run, broker label) are constant across the CLI's
/// built-in harnesses — only `params` and `window` vary. Centralizing the broker
/// label keeps it a single source (and a single edit point for #22's per-asset
/// pip work).
fn sim_optimal_manifest(params: Vec<(String, f64)>, window: (Timestamp, Timestamp)) -> RunManifest {
RunManifest {
commit: option_env!("AURA_COMMIT").unwrap_or("unknown").to_string(),
params,
window,
seed: 0,
broker: "sim-optimal(pip_size=0.0001)".to_string(),
}
}
/// Run the sample harness and fold it into a `RunReport` (drain both sinks →
/// `f64_field` → `summarize` → pair with a `RunManifest`). Pure and deterministic
/// (C1): the same build yields the same report.
fn run_sample() -> RunReport {
let (mut h, rx_eq, rx_ex) = sample_harness();
let prices = synthetic_prices();
let window = (
prices.first().expect("non-empty stream").0,
prices.last().expect("non-empty stream").0,
);
h.run(vec![prices]);
let eq_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_eq.try_iter().collect();
let ex_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_ex.try_iter().collect();
let equity = f64_field(&eq_rows, 0);
let exposure = f64_field(&ex_rows, 0);
let metrics = summarize(&equity, &exposure);
RunReport {
manifest: sim_optimal_manifest(
vec![
("sma_fast".to_string(), 2.0),
("sma_slow".to_string(), 4.0),
("exposure_scale".to_string(), 0.5),
],
window,
),
metrics,
}
}
/// The SMA-cross signal as a named composite (price -> fast/slow SMA -> spread).
/// CLI-local sample builder; the engine ships no sample (the duplication with
/// `blueprint.rs`'s test helper is the dedup tracked in #14). Value-empty: the SMA
/// lengths are injected at compile, not baked here.
fn sma_cross(name: &str) -> Composite {
let mut g = GraphBuilder::new(name);
let fast = g.add(Sma::builder().named("fast")); // fast SMA leg
let slow = g.add(Sma::builder().named("slow")); // slow SMA leg
let sub = g.add(Sub::builder());
let price = g.input_role("price");
g.feed(price, [fast.input("series"), slow.input("series")]);
g.connect(fast.output("value"), sub.input("lhs"));
g.connect(slow.output("value"), sub.input("rhs"));
g.expose(sub.output("value"), "cross");
g.build().expect("sample sma_cross wiring resolves")
}
/// The blended signal: a trend leg (SMA-cross) and a momentum leg (MACD), combined
/// by a weighted sum. A multiply-nested composite (root → signals → {trend,
/// momentum}); the blend is a multi-param node living inside it, with one weight
/// bound as a structural constant (so it drops out of the sweepable surface).
fn signals(name: &str) -> Composite {
let mut g = GraphBuilder::new(name);
let trend = g.add(sma_cross("trend")); // trend leg (one f64 "cross")
let momentum = g.add(macd("momentum")); // momentum leg (3 outputs)
// blend: Σ wᵢ·termᵢ over [trend.cross, momentum.histogram, momentum.signal].
// weights[2] is bound (a fixed signal-line weight) → removed from param_space;
// weights[0]/[1] stay tunable. `.named("blend")` makes the path signals.blend.*
// (and renders the `blend:` prefix).
let blend = g.add(
LinComb::builder(3)
.named("blend")
.bind("weights[2]", Scalar::F64(0.5)),
);
let price = g.input_role("price");
g.feed(price, [trend.input("price"), momentum.input("price")]);
g.connect(trend.output("cross"), blend.input("term[0]")); // trend.cross → blend.term[0]
g.connect(momentum.output("histogram"), blend.input("term[1]")); // momentum.histogram → blend.term[1]
g.connect(momentum.output("signal"), blend.input("term[2]")); // momentum.signal → blend.term[2]
g.expose(blend.output("value"), "signal");
g.build().expect("sample signals wiring resolves")
}
/// 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 eight free params — the trend SMA lengths, the momentum EMA
/// lengths, the two open blend weights, and the exposure scale — are injected at
/// compile via the point vector). The root harness of the sample topology, whose
/// signal is built by the nested `signals` composite; `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 mut g = GraphBuilder::new("sample");
let sig = g.add(signals("signals"));
let exposure = g.add(Exposure::builder());
let broker = g.add(SimBroker::builder(0.0001));
let eq = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq));
let ex = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex));
let price = g.source_role("price", ScalarKind::F64);
g.feed(price, [sig.input("price"), broker.input("price")]);
g.connect(sig.output("signal"), exposure.input("signal")); // blended signal -> Exposure
g.connect(exposure.output("exposure"), broker.input("exposure")); // exposure -> broker slot 0
g.connect(broker.output("equity"), eq.input("col[0]")); // equity -> sink
g.connect(exposure.output("exposure"), ex.input("col[0]")); // exposure -> sink
let bp = g.build().expect("sample blueprint wiring resolves");
(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`.
fn sample_blueprint() -> Composite {
build_sample()
}
/// Coerce a sweep point's `Scalar` value to the manifest's `f64` param type.
/// A tuning grid carries only numeric params (`I64` lengths, `F64` scales).
fn scalar_as_param_f64(s: &Scalar) -> f64 {
match s {
Scalar::I64(n) => *n as f64,
Scalar::F64(f) => *f,
other => unreachable!("non-numeric sweep param: {other:?}"),
}
}
/// 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_family() -> SweepFamily {
let bp = sample_blueprint_with_sinks().0;
let space = bp.param_space();
bp.axis("signals.trend.fast.length", [2, 3])
.axis("signals.trend.slow.length", [4, 5])
.axis("signals.momentum.fast.length", [2])
.axis("signals.momentum.slow.length", [4])
.axis("signals.momentum.signal.length", [3])
.axis("signals.blend.weights[0]", [1.0])
.axis("signals.blend.weights[1]", [1.0])
.axis("exposure.scale", [0.5])
.sweep(|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");
let prices = showcase_prices();
let window = (
prices.first().expect("non-empty stream").0,
prices.last().expect("non-empty stream").0,
);
h.run(vec![prices]);
let equity = f64_field(&rx_eq.try_iter().collect::<Vec<_>>(), 0);
let exposure = f64_field(&rx_ex.try_iter().collect::<Vec<_>>(), 0);
let params = space
.iter()
.zip(point)
.map(|(ps, v)| (ps.name.clone(), scalar_as_param_f64(v)))
.collect();
RunReport {
manifest: sim_optimal_manifest(params, window),
metrics: summarize(&equity, &exposure),
}
})
.expect("the built-in named grid matches the sample param-space")
}
/// Render a sweep family as one `RunReport` JSON line per point. Test helper:
/// production (`run_sweep`) renders *and* persists per point.
#[cfg(test)]
fn sweep_report() -> String {
let mut out = String::new();
for pt in &sweep_family().points {
out.push_str(&pt.report.to_json());
out.push('\n');
}
out
}
/// The default run registry: an append-only JSONL store under the current
/// working directory. (A project-configured runs-dir via `Aura.toml` is a later
/// refinement.)
fn default_registry() -> Registry {
Registry::open("runs/runs.jsonl")
}
/// `aura sweep`: run the built-in sweep, persist each point's `RunReport` to the
/// registry (the run record, queryable over time — C18), and print each as one
/// JSON line.
fn run_sweep() {
let reg = default_registry();
for pt in &sweep_family().points {
if let Err(e) = reg.append(&pt.report) {
eprintln!("aura: {e}");
std::process::exit(2);
}
println!("{}", pt.report.to_json());
}
}
/// `aura runs list`: print every stored run record, in store (over-time) order.
fn runs_list() {
for report in &load_runs_or_exit() {
println!("{}", report.to_json());
}
}
/// `aura runs rank <metric>`: print the stored runs best-first by `metric`.
fn runs_rank(metric: &str) {
match rank_by(load_runs_or_exit(), metric) {
Ok(ranked) => {
for report in &ranked {
println!("{}", report.to_json());
}
}
Err(e) => {
eprintln!("aura: {e}");
std::process::exit(2);
}
}
}
/// Load the registry or exit 2 with the error. A missing registry loads empty.
fn load_runs_or_exit() -> Vec<RunReport> {
default_registry().load().unwrap_or_else(|e| {
eprintln!("aura: {e}");
std::process::exit(2);
})
}
// --- MACD proof-of-concept (a richer, nested indicator + strategy) -----------
/// The MACD signal as a named composite: price → fast/slow `Ema` → the MACD line
/// (their spread) → a signal `Ema` of that line → the histogram (line signal).
/// The composite exposes all **three MACD lines** as a named output record
/// (`macd`, `signal`, `histogram`); the strategy trades the histogram by reading
/// `from_field: 2`. A richer fixture than `sma_cross`: a nested EMA-of-EMA chain
/// with interior fan-out (the MACD line feeds *both* the signal EMA and the
/// histogram). Three `length` knobs (fast, slow, signal) are injected at compile
/// in node order; value-empty here.
fn macd(name: &str) -> Composite {
let mut g = GraphBuilder::new(name);
let fast = g.add(Ema::builder().named("fast")); // fast EMA
let slow = g.add(Ema::builder().named("slow")); // slow EMA
let line = g.add(Sub::builder()); // MACD line = fast slow
let signal = g.add(Ema::builder().named("signal")); // signal EMA of the MACD line
let hist = g.add(Sub::builder()); // histogram = MACD line signal
let price = g.input_role("price");
g.feed(price, [fast.input("series"), slow.input("series")]);
g.connect(fast.output("value"), line.input("lhs")); // fast → line
g.connect(slow.output("value"), line.input("rhs")); // slow → line
g.connect(line.output("value"), signal.input("series")); // line → signal EMA
g.connect(line.output("value"), hist.input("lhs")); // line → histogram
g.connect(signal.output("value"), hist.input("rhs")); // signal → histogram
g.expose(line.output("value"), "macd"); // the MACD line
g.expose(signal.output("value"), "signal"); // the signal line
g.expose(hist.output("value"), "histogram"); // the histogram
g.build().expect("sample macd wiring resolves")
}
/// The MACD strategy blueprint (value-empty): the `macd` histogram → `Exposure` →
/// `SimBroker` → recording sinks. Channels are threaded so a run can drain the
/// sinks; `macd_blueprint` drops the receivers for the structural render.
fn macd_strategy_blueprint(
tx_eq: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
tx_ex: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
) -> Composite {
let mut g = GraphBuilder::new("macd_strategy");
let macd_node = g.add(macd("macd"));
let exposure = g.add(Exposure::builder());
let broker = g.add(SimBroker::builder(0.0001));
let eq = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq));
let ex = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex));
let price = g.source_role("price", ScalarKind::F64);
g.feed(price, [macd_node.input("price"), broker.input("price")]);
g.connect(macd_node.output("histogram"), exposure.input("signal")); // histogram → Exposure
g.connect(exposure.output("exposure"), broker.input("exposure")); // exposure → broker slot 0
g.connect(broker.output("equity"), eq.input("col[0]")); // equity → sink
g.connect(exposure.output("exposure"), ex.input("col[0]")); // exposure → sink
g.build().expect("macd_strategy wiring resolves")
}
/// The MACD strategy blueprint as a param-space fixture (receivers dropped, since
/// `param_space()` reads structure only, never runs the graph).
#[cfg(test)]
fn macd_blueprint() -> Composite {
let (tx_eq, _rx_eq) = mpsc::channel();
let (tx_ex, _rx_ex) = mpsc::channel();
macd_strategy_blueprint(tx_eq, tx_ex)
}
/// The point vector for the MACD strategy, in `param_space()` slot order:
/// `[fast EMA length, slow EMA length, signal EMA length, exposure scale]`. Short
/// windows so the 7-tick synthetic stream still produces a non-trivial trace
/// (conventional MACD is 12/26/9, meaningless on 7 points).
fn macd_point() -> Vec<Scalar> {
vec![Scalar::I64(2), Scalar::I64(4), Scalar::I64(3), Scalar::F64(0.5)]
}
/// A longer synthetic stream than the SMA sample's 7 ticks: MACD's EMAs each warm
/// up over their `length`, so the stream rises, falls, then rises again to give the
/// histogram room to flip sign more than once *after* warm-up. Deterministic (C1).
fn macd_prices() -> Vec<(Timestamp, Scalar)> {
[
1.0000_f64, 1.0008, 1.0021, 1.0039, 1.0062, 1.0090, 1.0083, 1.0061, 1.0034,
1.0012, 0.9998, 1.0006, 1.0024, 1.0047, 1.0069, 1.0086, 1.0097, 1.0092,
]
.iter()
.enumerate()
.map(|(i, &p)| (Timestamp(i as i64 + 1), Scalar::F64(p)))
.collect()
}
/// Run the MACD strategy: compile the nested composite blueprint to a flat harness
/// (the same bootstrap path the SMA sample's compiled view uses), drive it on the
/// synthetic stream, and fold both sinks into a `RunReport`. Pure and
/// deterministic (C1).
fn run_macd() -> RunReport {
let (tx_eq, rx_eq) = mpsc::channel();
let (tx_ex, rx_ex) = mpsc::channel();
let flat = macd_strategy_blueprint(tx_eq, tx_ex)
.compile_with_params(&macd_point())
.expect("valid macd blueprint");
let mut h = Harness::bootstrap(flat).expect("valid macd harness");
let prices = macd_prices();
let window = (
prices.first().expect("non-empty stream").0,
prices.last().expect("non-empty stream").0,
);
h.run(vec![prices]);
let eq_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_eq.try_iter().collect();
let ex_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_ex.try_iter().collect();
let equity = f64_field(&eq_rows, 0);
let exposure = f64_field(&ex_rows, 0);
let metrics = summarize(&equity, &exposure);
RunReport {
manifest: sim_optimal_manifest(
vec![
("ema_fast".to_string(), 2.0),
("ema_slow".to_string(), 4.0),
("ema_signal".to_string(), 3.0),
("exposure_scale".to_string(), 0.5),
],
window,
),
metrics,
}
}
const USAGE: &str =
"usage: aura run [--macd] | aura graph | aura sweep | aura runs list | aura runs rank <metric>";
fn main() {
// Collect argv and match the whole vector: every accepted form is exhaustive,
// so an unexpected trailing token falls through to the usage-error path rather
// than masquerading as a successful run (#16 strict reading).
let args: Vec<String> = std::env::args().skip(1).collect();
match args.iter().map(String::as_str).collect::<Vec<_>>().as_slice() {
["run"] => println!("{}", run_sample().to_json()),
["run", "--macd"] => println!("{}", run_macd().to_json()),
["graph"] => print!("{}", render::render_html(&sample_blueprint())),
["sweep"] => run_sweep(),
["runs", "list"] => runs_list(),
["runs", "rank", metric] => runs_rank(metric),
["--help"] | ["-h"] => println!("{USAGE}"),
_ => {
eprintln!("aura: {USAGE}");
std::process::exit(2);
}
}
}
#[cfg(test)]
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
.with("signals.trend.fast.length", 2)
.with("signals.trend.slow.length", 4)
.with("signals.momentum.fast.length", 2)
.with("signals.momentum.slow.length", 4)
.with("signals.momentum.signal.length", 3)
.with("signals.blend.weights[0]", 1.0)
.with("signals.blend.weights[1]", 1.0)
.with("exposure.scale", 0.5)
.bootstrap()
.expect("sample blueprint compiles under a valid point");
h.run(vec![showcase_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_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:?}");
// each line is a full RunReport; the commit is the real git HEAD
// (volatile), so pin the per-point manifest params (odometer order, last
// axis fastest) + the metric keys, not the commit value.
for line in &lines {
assert!(line.starts_with(r#"{"manifest":{"commit":""#), "not a RunReport: {line}");
}
assert!(lines[0].contains(r#""params":[["signals.trend.fast.length",2.0],["signals.trend.slow.length",4.0],["signals.momentum.fast.length",2.0],["signals.momentum.slow.length",4.0],["signals.momentum.signal.length",3.0],["signals.blend.weights[0]",1.0],["signals.blend.weights[1]",1.0],["exposure.scale",0.5]]"#), "line0: {}", lines[0]);
assert!(lines[1].contains(r#""params":[["signals.trend.fast.length",2.0],["signals.trend.slow.length",5.0],["signals.momentum.fast.length",2.0],["signals.momentum.slow.length",4.0],["signals.momentum.signal.length",3.0],["signals.blend.weights[0]",1.0],["signals.blend.weights[1]",1.0],["exposure.scale",0.5]]"#), "line1: {}", lines[1]);
assert!(lines[2].contains(r#""params":[["signals.trend.fast.length",3.0],["signals.trend.slow.length",4.0],["signals.momentum.fast.length",2.0],["signals.momentum.slow.length",4.0],["signals.momentum.signal.length",3.0],["signals.blend.weights[0]",1.0],["signals.blend.weights[1]",1.0],["exposure.scale",0.5]]"#), "line2: {}", lines[2]);
assert!(lines[3].contains(r#""params":[["signals.trend.fast.length",3.0],["signals.trend.slow.length",5.0],["signals.momentum.fast.length",2.0],["signals.momentum.slow.length",4.0],["signals.momentum.signal.length",3.0],["signals.blend.weights[0]",1.0],["signals.blend.weights[1]",1.0],["exposure.scale",0.5]]"#), "line3: {}", lines[3]);
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
// flat runnable harness (the call not panicking proves the compile+bootstrap
// path), and runs it. C1 determinism: two runs are bit-identical.
let r1 = run_macd();
let r2 = run_macd();
assert_eq!(r1.metrics, r2.metrics);
assert_eq!(r1.to_json(), r2.to_json());
// the synthetic stream is carried end-to-end and the trace is well-formed.
let (from, to) = r1.manifest.window;
assert_eq!((from.0, to.0), (1, 18));
assert!(r1.metrics.total_pips.is_finite(), "macd pips must be finite: {:?}", r1.metrics);
assert!(r1.metrics.max_drawdown >= 0.0, "drawdown is non-negative: {:?}", r1.metrics);
// after warm-up the EMA-of-EMA histogram crosses zero, so the strategy
// reverses exposure at least once — a genuinely non-trivial trace.
assert!(
r1.metrics.exposure_sign_flips >= 1,
"macd trace should flip exposure: {:?}",
r1.metrics
);
}
/// E2E acceptance (#41 / spec 0019, the worked example): the real MACD strategy
/// blueprint's swept param surface qualifies the three otherwise-indistinguishable
/// EMA `length` slots by node name to `macd.fast.length` / `macd.slow.length` /
/// `macd.signal.length` — the named composite boundary visible end-to-end through
/// `param_space()`, with the slot count and order unchanged (C23 — node names are
/// non-load-bearing: every interior slot stays sweepable, the `exposure.scale`
/// knob is unaffected).
#[test]
fn macd_param_space_surfaces_the_three_named_legs() {
let names: Vec<String> =
macd_blueprint().param_space().into_iter().map(|p| p.name).collect();
// three named composite-interior slots, in declared (fast, slow, signal)
// order, then the strategy-level Exposure `scale` (a root-level leaf).
assert_eq!(
names,
vec![
"macd.fast.length".to_string(),
"macd.slow.length".to_string(),
"macd.signal.length".to_string(),
"exposure.scale".to_string(),
],
"MACD param surface must expose the three named EMA lengths + scale",
);
}
#[test]
fn run_sample_is_deterministic_and_non_trivial() {
let r1 = run_sample();
let r2 = run_sample();
// C1 determinism: two runs are bit-identical (metrics + rendered JSON).
assert_eq!(r1.metrics, r2.metrics);
assert_eq!(r1.to_json(), r2.to_json());
let m = &r1.metrics;
// exactly one exposure sign flip in the demo trace (rises then reverses).
assert_eq!(m.exposure_sign_flips, 1);
// a non-trivial, populated trace: a real drawdown.
assert!(m.max_drawdown > 0.0);
// hand-computed magnitudes for the chosen stream (float tolerance; the
// computation's dust is ~1e-15).
assert!(
(m.max_drawdown - 0.17).abs() < 1e-9,
"max_drawdown = {}",
m.max_drawdown
);
assert!(
(m.total_pips - (-0.13)).abs() < 1e-9,
"total_pips = {}",
m.total_pips
);
// manifest carries the sample's known configuration.
let (from, to) = r1.manifest.window;
assert_eq!((from.0, to.0), (1, 7));
// commit is the build's git identity (or the no-git "unknown" fallback);
// either way it is non-empty and fixed at compile time, so it is stable
// across runs of the same build (C1 determinism, already asserted above
// via `to_json()`).
assert!(!r1.manifest.commit.is_empty());
assert_eq!(r1.manifest.commit, r2.manifest.commit);
}
}