//! `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 graph; use aura_core::{Firing, Scalar, ScalarKind, Timestamp}; use aura_engine::{ f64_field, summarize, Blueprint, BlueprintNode, Composite, Edge, Harness, OutPort, RunManifest, RunReport, SourceSpec, Target, }; use aura_std::{Exposure, 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() } /// 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)>, Receiver<(Timestamp, Vec)>, ) { let (tx_eq, rx_eq) = mpsc::channel(); let (tx_ex, rx_ex) = mpsc::channel(); let h = Harness::bootstrap( 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 ], 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 ], }], 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) } /// 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)> = rx_eq.try_iter().collect(); let ex_rows: Vec<(Timestamp, Vec)> = 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: RunManifest { commit: option_env!("AURA_COMMIT").unwrap_or("unknown").to_string(), params: vec![ ("sma_fast".to_string(), 2.0), ("sma_slow".to_string(), 4.0), ("exposure_scale".to_string(), 0.5), ], window, seed: 0, broker: "sim-optimal(pip_size=0.0001)".to_string(), }, 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). fn sma_cross(name: &str, fast: usize, slow: usize) -> Composite { Composite::new( name, vec![Sma::new(fast).into(), Sma::new(slow).into(), Sub::new().into()], vec![ Edge { from: 0, to: 2, slot: 0, from_field: 0 }, Edge { from: 1, to: 2, slot: 1, from_field: 0 }, ], vec![vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }]], OutPort { node: 2, field: 0 }, ) } /// The sample signal-quality blueprint, parameterized by the SMA windows so a /// test can author a deliberately swapped variant. Recorders need a channel to /// construct; the receivers are dropped because the render never runs the graph. fn build_sample(fast: usize, slow: usize) -> Blueprint { let (tx_eq, _rx_eq) = mpsc::channel(); let (tx_ex, _rx_ex) = mpsc::channel(); Blueprint::new( vec![ BlueprintNode::Composite(sma_cross("sma_cross", fast, slow)), Exposure::new(0.5).into(), SimBroker::new(0.0001).into(), Recorder::new(&[ScalarKind::F64], Firing::Any, tx_eq).into(), Recorder::new(&[ScalarKind::F64], Firing::Any, tx_ex).into(), ], vec![SourceSpec { kind: ScalarKind::F64, targets: vec![ Target { node: 0, slot: 0 }, // price -> sma_cross role 0 Target { node: 2, slot: 1 }, // price -> SimBroker price slot ], }], 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 ], ) } /// The built-in sample rendered by `aura graph`. fn sample_blueprint() -> Blueprint { build_sample(2, 4) } fn main() { let mut args = std::env::args().skip(1); match args.next().as_deref() { // strict: a bare `run` proceeds; a trailing token falls through to the // usage-error path rather than masquerading as a successful run (#16). Some("run") if args.next().is_none() => println!("{}", run_sample().to_json()), Some("graph") => { // `--compiled` selects the flat post-inline view; default is the // clustered blueprint view. Strictness beyond this stays minimal (#16). let compiled = args.next().as_deref() == Some("--compiled"); let bp = sample_blueprint(); let out = if compiled { let (nodes, sources, edges) = bp.compile().expect("valid sample blueprint"); graph::render_compilat(&nodes, &sources, &edges) } else { graph::render_blueprint(&bp) }; println!("{out}"); } Some("--help") | Some("-h") => println!("usage: aura run | aura graph [--compiled]"), _ => { eprintln!("aura: usage: aura run | aura graph [--compiled]"); std::process::exit(2); } } } #[cfg(test)] mod tests { use super::*; /// The sample authored with fast/slow SMA windows swapped — the mis-wiring /// the render must surface. Test-only: nothing outside tests builds it. fn sample_blueprint_swapped() -> Blueprint { build_sample(4, 2) } #[test] fn blueprint_view_shows_cluster_and_param_labels() { let out = graph::render_blueprint(&sample_blueprint()); // the composite renders as a named cluster box assert!(out.contains("sma_cross"), "missing composite name:\n{out}"); // param-carrying labels disambiguate the two SMAs assert!(out.contains("SMA(2)"), "missing SMA(2):\n{out}"); assert!(out.contains("SMA(4)"), "missing SMA(4):\n{out}"); for needle in ["Sub", "Exposure(0.5)", "SimBroker(0.0001)", "Recorder"] { assert!(out.contains(needle), "missing {needle}:\n{out}"); } } #[test] fn compiled_view_dissolves_the_composite_boundary() { let bp = sample_blueprint(); let (nodes, sources, edges) = bp.compile().expect("valid sample"); let out = graph::render_compilat(&nodes, &sources, &edges); // node labels survive inlining... assert!(out.contains("SMA(2)") && out.contains("SMA(4)"), "labels lost:\n{out}"); // ...but the composite cluster name does NOT (boundary dissolved, C23) assert!(!out.contains("sma_cross"), "compiled view must not show the cluster:\n{out}"); } #[test] fn swapped_sma_inputs_render_differently() { // the property the cycle exists to buy: a mis-wiring is no longer invisible. let correct = graph::render_blueprint(&sample_blueprint()); let swapped = graph::render_blueprint(&sample_blueprint_swapped()); assert_ne!(correct, swapped, "a fast/slow SMA swap must change the render"); } #[test] fn blueprint_view_golden() { let out = graph::render_blueprint(&sample_blueprint()); // ascii-dag's Sugiyama layout is deterministic (no RNG); these are the // exact bytes `aura graph` emits (render() ends in three newlines). let expected = r#" [source:F64] ┌─────────└┐────────┐ │ │ │ ╔═════╪══════════╪════╗ │ ║ sma_cross │ ║ │ ║ ↓ ↓ ║ │ ║ [SMA(2)] [SMA(4)] ║ │ ║ └──┌───────┘ ║ │ ║ ↓ ┌─╫───┘ ║ [Sub] │ ║ ║ │ │ ║ ╚════════╪══════════╪═╝ │ │ ↓ │ [Exposure(0.5)] │ ┌───────┘────────┐ │ ↓ ↓─┘ [Recorder] [SimBroker(0.0001)] │ ↓ [Recorder] "#; assert_eq!(out, expected, "blueprint render drifted; re-capture if intended"); } #[test] fn compiled_view_golden() { let bp = sample_blueprint(); let (nodes, sources, edges) = bp.compile().expect("valid sample"); let out = graph::render_compilat(&nodes, &sources, &edges); let expected = r#" [source:F64] ┌────────└─┐──────┐ ↓ ↓ │ [SMA(2)] [SMA(4)] │ └────┌─────┘ │ ↓ ┌──────┘ [Sub] │ │ │ ↓ └────┐ [Exposure(0.5)] │ ┌──────┘─────────┐│ ↓ ↓┘ [Recorder] [SimBroker(0.0001)] ┌─────┘ ↓ [Recorder] "#; assert_eq!(out, expected, "compiled render drifted; re-capture if intended"); } #[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); } }