//! `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, BlueprintNode, Composite, Edge, FlatGraph, Harness, OutField, ParamAlias, Role, RunManifest, RunReport, SourceSpec, Target, }; use aura_std::{Ema, Exposure, Recorder, SimBroker, Sma, Sub}; use std::io::IsTerminal; 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 f64_recorder_sig = || aura_engine::NodeSchema { inputs: vec![aura_engine::PortSpec { kind: ScalarKind::F64, firing: Firing::Any }], 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) } /// 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). Value-empty: the SMA /// lengths are injected at compile, not baked here. fn sma_cross(name: &str) -> Composite { Composite::new( name, vec![Sma::builder().into(), Sma::builder().into(), Sub::builder().into()], vec![ Edge { from: 0, to: 2, slot: 0, from_field: 0 }, Edge { from: 1, to: 2, slot: 1, from_field: 0 }, ], vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }], source: None, }], vec![ ParamAlias { name: "fast".into(), node: 0, slot: 0 }, // fast SMA length ParamAlias { name: "slow".into(), node: 1, slot: 0 }, // slow SMA length ], vec![OutField { node: 2, field: 0, name: "cross".into() }], ) } /// The sample signal-quality blueprint (value-empty): a recipe whose SMA lengths + /// exposure scale are injected at compile via the point vector (see /// `sample_point`). 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( "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 ) } /// The built-in sample rendered by `aura graph`. fn sample_blueprint() -> Composite { build_sample() } /// The point vector injected into the sample blueprint, in `param_space()` slot /// order: `[fast SMA length, slow SMA length, exposure scale]`. fn sample_point() -> Vec { vec![Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)] } // --- 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 { Composite::new( name, vec![ Ema::builder().into(), // 0 fast EMA Ema::builder().into(), // 1 slow EMA Sub::builder().into(), // 2 MACD line = fast − slow Ema::builder().into(), // 3 signal EMA of the MACD line Sub::builder().into(), // 4 histogram = MACD line − signal ], vec![ Edge { from: 0, to: 2, slot: 0, from_field: 0 }, // fast → line[0] Edge { from: 1, to: 2, slot: 1, from_field: 0 }, // slow → line[1] Edge { from: 2, to: 3, slot: 0, from_field: 0 }, // line → signal EMA Edge { from: 2, to: 4, slot: 0, from_field: 0 }, // line → histogram[0] Edge { from: 3, to: 4, slot: 1, from_field: 0 }, // signal → histogram[1] ], vec![Role { name: "price".into(), targets: vec![ Target { node: 0, slot: 0 }, // price → fast EMA Target { node: 1, slot: 0 }, // price → slow EMA ], source: None, }], vec![ ParamAlias { name: "fast".into(), node: 0, slot: 0 }, // fast EMA length ParamAlias { name: "slow".into(), node: 1, slot: 0 }, // slow EMA length ParamAlias { name: "signal".into(), node: 3, slot: 0 }, // signal EMA length ], vec![ OutField { node: 2, field: 0, name: "macd".into() }, // the MACD line OutField { node: 3, field: 0, name: "signal".into() }, // the signal line OutField { node: 4, field: 0, name: "histogram".into() }, // the histogram ], ) } /// 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)>, tx_ex: mpsc::Sender<(Timestamp, Vec)>, ) -> Composite { Composite::new( "macd_strategy", vec![ BlueprintNode::Composite(macd("macd")), 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: 2 }, // histogram → 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 → macd role 0 Target { node: 2, slot: 1 }, // price → SimBroker price slot ], source: Some(ScalarKind::F64), }], vec![], // params: the interior macd carries the aliases vec![], // output: the root ends in sinks, no re-export ) } /// The MACD strategy blueprint for the structural render (receivers dropped, as /// the render never runs the graph). 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 { 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)> = 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![ ("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, seed: 0, broker: "sim-optimal(pip_size=0.0001)".to_string(), }, metrics, } } /// Compile a blueprint under its point vector and render the flat post-inline /// (C23) view — the shared body of the `--compiled` paths. fn render_compiled(bp: Composite, point: &[Scalar], color: graph::Color) -> String { let flat = bp.compile_with_params(point).expect("valid blueprint"); graph::render_compilat(&flat.nodes, &flat.sources, &flat.edges, color) } const USAGE: &str = "usage: aura run [--macd] | aura graph [--compiled | --macd [--compiled]]"; 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 = std::env::args().skip(1).collect(); // Edge colouring only when stdout is an interactive terminal; a redirect to a // file or pipe stays plain (no escape codes). `run` output is JSON, never coloured. let color = if std::io::stdout().is_terminal() { graph::Color::Ansi } else { graph::Color::Plain }; match args.iter().map(String::as_str).collect::>().as_slice() { ["run"] => println!("{}", run_sample().to_json()), ["run", "--macd"] => println!("{}", run_macd().to_json()), ["graph"] => println!("{}", graph::render_blueprint(&sample_blueprint(), color)), ["graph", "--compiled"] => { println!("{}", render_compiled(sample_blueprint(), &sample_point(), color)); } ["graph", "--macd"] => println!("{}", graph::render_blueprint(&macd_blueprint(), color)), ["graph", "--macd", "--compiled"] => { println!("{}", render_compiled(macd_blueprint(), &macd_point(), color)); } ["--help"] | ["-h"] => println!("{USAGE}"), _ => { eprintln!("aura: {USAGE}"); std::process::exit(2); } } } #[cfg(test)] mod tests { use super::*; /// The sample's point vector with the fast/slow SMA lengths swapped — the /// mis-wiring the compiled render must surface. The blueprint is param-generic /// and identical for both orderings; only the injected vector differs. fn swapped_point() -> Vec { vec![Scalar::I64(4), Scalar::I64(2), Scalar::F64(0.5)] } #[test] fn blueprint_view_main_graph_shows_composite_as_opaque_node() { let out = graph::render_blueprint(&sample_blueprint(), graph::Color::Plain); // the composite is a single opaque main-graph node, not an expanded cluster assert!(out.contains("[sma_cross]"), "missing opaque composite node:\n{out}"); // top-level leaves render enriched (param names folded in, #48), exactly as // the `where:` interior leaves are — `Exposure` folds its `scale` param; // paramless leaves (SimBroker, Recorder) stay bare. for needle in ["[Exposure(scale)]", "[SimBroker]", "[Recorder]"] { assert!(out.contains(needle), "missing {needle}:\n{out}"); } // a definitions section is present assert!(out.contains("where:"), "missing where: section:\n{out}"); // the flat layout draws no subgraph cluster box assert!(!out.contains('╔'), "blueprint view must not draw a cluster box:\n{out}"); } #[test] fn blueprint_view_defines_each_composite_once() { let out = graph::render_blueprint(&sample_blueprint(), graph::Color::Plain); // the sma_cross body is defined exactly once, with its interior + ports assert_eq!(out.matches("sma_cross(").count(), 1, "definition not rendered once:\n{out}"); for needle in ["[SMA(fast)]", "[SMA(slow)]", "[cross := Sub(#Sf,#Ss)]", "[price]"] { assert!(out.contains(needle), "missing {needle} in definition:\n{out}"); } } #[test] fn nested_composite_renders_without_panic() { // a composite whose interior contains another composite — render reads // structure only (no compile/validate), so a minimal fixture suffices. let inner = Composite::new( "inner", vec![Sma::builder().into()], vec![], vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }], vec![], vec![OutField { node: 0, field: 0, name: "out".into() }], ); let outer = Composite::new( "outer", vec![BlueprintNode::Composite(inner), Sub::builder().into()], vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }], vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }], vec![], vec![OutField { node: 1, field: 0, name: "out".into() }], ); let bp = Composite::new( "root", vec![BlueprintNode::Composite(outer)], vec![], vec![ Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) }, ], vec![], // params vec![], // output ); let out = graph::render_blueprint(&bp, graph::Color::Plain); // must not panic (no unimplemented!) // outer shows the inner composite as an opaque node, and both get a definition assert!(out.contains("[outer]"), "missing opaque outer node:\n{out}"); assert!(out.contains("[inner]"), "inner must be opaque inside outer's definition:\n{out}"); assert_eq!(out.matches("outer(").count(), 1, "outer defined once:\n{out}"); assert_eq!(out.matches("inner(").count(), 1, "inner defined once:\n{out}"); } #[test] fn reused_composite_defined_once() { // the same composite type used twice: two opaque nodes, one definition. let bp = Composite::new( "root", vec![ BlueprintNode::Composite(sma_cross("dup")), BlueprintNode::Composite(sma_cross("dup")), Exposure::builder().into(), ], vec![ Edge { from: 0, to: 2, slot: 0, from_field: 0 }, Edge { from: 1, to: 2, slot: 0, from_field: 0 }, ], vec![ Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }], source: Some(ScalarKind::F64) }, ], vec![], // params vec![], // output ); let out = graph::render_blueprint(&bp, graph::Color::Plain); assert_eq!(out.matches("[dup]").count(), 2, "two opaque uses expected:\n{out}"); assert_eq!(out.matches("dup(").count(), 1, "body defined once:\n{out}"); } #[test] fn compiled_view_dissolves_the_composite_boundary() { let bp = sample_blueprint(); let flat = bp.compile_with_params(&sample_point()).expect("valid sample"); let out = graph::render_compilat(&flat.nodes, &flat.sources, &flat.edges, graph::Color::Plain); // 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_param_vector_changes_the_compiled_render() { // the property the cycle exists to buy: a mis-wiring is no longer invisible. // The blueprint is now param-generic (identical for both orderings), so the // swap is observable only after the vector is injected — in the COMPILED // view, where the flat nodes carry their valued labels (SMA(2)/SMA(4)). let cflat = sample_blueprint().compile_with_params(&sample_point()).expect("valid sample"); let correct = graph::render_compilat(&cflat.nodes, &cflat.sources, &cflat.edges, graph::Color::Plain); let sflat = sample_blueprint().compile_with_params(&swapped_point()).expect("valid sample"); let swapped = graph::render_compilat(&sflat.nodes, &sflat.sources, &sflat.edges, graph::Color::Plain); assert_ne!(correct, swapped, "a fast/slow SMA swap must change the compiled render"); } #[test] fn blueprint_view_golden() { let out = graph::render_blueprint(&sample_blueprint(), graph::Color::Plain); // ascii-dag's Sugiyama layout is deterministic (no RNG); these are the // exact bytes `aura graph` emits — main graph (composites opaque) + the // `where:` definitions section. Re-capture via `aura graph` if intended. let expected = r#" [source:F64] ┌───└─────┐ ↓ │ [sma_cross] │ │ │ ↓ └──┐ [Exposure(scale)] │ ┌──┘─────────┐ │ ↓ ↓──┘ [Recorder] [SimBroker] ┌─────┘ ↓ [Recorder] where: sma_cross(fast:i64, slow:i64) -> (cross): [price] ┌──────└──────┐ ↓ ↓ [SMA(fast)] [SMA(slow)] └──────┌──────┘ ↓ [cross := Sub(#Sf,#Ss)] "#; assert_eq!(out, expected, "blueprint render drifted; re-capture if intended"); } #[test] fn compiled_view_golden() { let bp = sample_blueprint(); let flat = bp.compile_with_params(&sample_point()).expect("valid sample"); let out = graph::render_compilat(&flat.nodes, &flat.sources, &flat.edges, graph::Color::Plain); 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_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 ); } #[test] fn macd_blueprint_renders_a_nested_composite_definition() { // the MACD blueprint view shows the composite opaque in the main graph and // defines its EMA-of-EMA interior once under `where:`. let out = graph::render_blueprint(&macd_blueprint(), graph::Color::Plain); assert!(out.contains("[macd]"), "missing opaque macd node:\n{out}"); assert_eq!(out.matches("macd(").count(), 1, "macd defined once:\n{out}"); assert!( out.contains("macd(fast:i64, slow:i64, signal:i64) -> (macd, signal, histogram)"), "typed signature line: {out}" ); assert!(out.contains("[EMA(fast)]"), "fast EMA folds its param: {out}"); assert!(out.contains("[EMA(slow)]"), "slow EMA folds its param: {out}"); assert!(out.contains("[macd := Sub(#Ef,#Es)]"), "macd line Sub bound as output `macd`: {out}"); assert!(out.contains("[signal := EMA(signal)]"), "signal EMA bound as output `signal` (name/param pun is intended): {out}"); assert!(out.contains("[histogram := Sub(#S,#Es)]"), "histogram Sub bound as output `histogram`: {out}"); // output re-exports are folded onto their producers — no standalone stubs. // `[macd]` is NOT a valid negative here: it still appears as the opaque // composite node in the MAIN graph. Discriminate on signal/histogram. assert!(!out.contains("[signal]"), "no standalone signal output stub: {out}"); assert!(!out.contains("[histogram]"), "no standalone histogram output stub: {out}"); assert!(out.contains("[price]"), "named MACD input role: {out}"); assert!(!out.contains("[param:"), "param marker nodes removed: {out}"); assert!(!out.contains("[out:"), "output prefix dropped: {out}"); } #[test] fn fan_in_identifiers_are_source_derived_and_scoped_per_node_call() { // role passthrough: a Sub fed by role `price` + an EMA(slow) -> // #price (role name) and #Es let c = Composite::new( "roles", vec![Ema::builder().into(), Sub::builder().into()], vec![Edge { from: 0, to: 1, slot: 1, from_field: 0 }], vec![Role { name: "price".into(), targets: vec![Target { node: 1, slot: 0 }], source: None }], vec![ParamAlias { name: "slow".into(), node: 0, slot: 0 }], vec![OutField { node: 1, field: 0, name: "o".into() }], ); let bp = Composite::new( "root", vec![BlueprintNode::Composite(c)], vec![], vec![], vec![], // params vec![], // output ); let out = graph::render_blueprint(&bp, graph::Color::Plain); assert!(out.contains("[o := Sub(#price,#Es)]"), "role name verbatim + source-derived, bound as output `o`: {out}"); } #[test] fn fan_in_identifiers_descend_into_bare_combinators() { // Sub( Sub(EMA fast, EMA slow), Sub(EMA up, EMA down) ): the two inner Subs // are param-less but have distinct recursive signatures (SEf… vs SEu…), so // the outer Sub descends just far enough -> [Sub(#SEf,#SEu)]. let c = Composite::new( "nest", vec![ Ema::builder().into(), // 0 fast Ema::builder().into(), // 1 slow Ema::builder().into(), // 2 up Ema::builder().into(), // 3 down Sub::builder().into(), // 4 = Sub(0,1) Sub::builder().into(), // 5 = Sub(2,3) Sub::builder().into(), // 6 = Sub(4,5) (the outer fan-in) ], vec![ Edge { from: 0, to: 4, slot: 0, from_field: 0 }, Edge { from: 1, to: 4, slot: 1, from_field: 0 }, Edge { from: 2, to: 5, slot: 0, from_field: 0 }, Edge { from: 3, to: 5, slot: 1, from_field: 0 }, Edge { from: 4, to: 6, slot: 0, from_field: 0 }, Edge { from: 5, to: 6, slot: 1, from_field: 0 }, ], vec![Role { name: "price".into(), targets: vec![ Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }, Target { node: 2, slot: 0 }, Target { node: 3, slot: 0 }, ], source: None, }], vec![ ParamAlias { name: "fast".into(), node: 0, slot: 0 }, ParamAlias { name: "slow".into(), node: 1, slot: 0 }, ParamAlias { name: "up".into(), node: 2, slot: 0 }, ParamAlias { name: "down".into(), node: 3, slot: 0 }, ], vec![OutField { node: 6, field: 0, name: "o".into() }], ); let bp = Composite::new( "root", vec![BlueprintNode::Composite(c)], vec![], vec![], vec![], // params vec![], // output ); let out = graph::render_blueprint(&bp, graph::Color::Plain); assert!(out.contains("[o := Sub(#SEf,#SEu)]"), "outer Sub descends into inner Subs, bound as output `o`: {out}"); assert!(out.contains("[Sub(#Ef,#Es)]"), "inner Sub uses EMA aliases: {out}"); } #[test] fn ansi_colour_emits_escapes_only_when_requested() { // `Color::Ansi` adds per-edge ANSI escapes for an interactive terminal; // `Color::Plain` is byte-clean (the golden / redirected-to-file path). The // colour is orthogonal to content — node-label text is identical either way. let plain = graph::render_blueprint(&macd_blueprint(), graph::Color::Plain); let coloured = graph::render_blueprint(&macd_blueprint(), graph::Color::Ansi); assert!(!plain.contains('\x1b'), "plain render must carry no escape codes:\n{plain}"); assert!(coloured.contains('\x1b'), "ansi render must carry escape codes"); assert!(coloured.contains("[macd]"), "labels survive colouring: {coloured}"); } /// E2E acceptance (#41 / spec 0019, the worked example): the real MACD strategy /// blueprint's swept param surface relabels the three otherwise-indistinguishable /// EMA `length` slots to `macd.fast` / `macd.slow` / `macd.signal` — the named /// composite boundary visible end-to-end through `param_space()`, with the slot /// count and order unchanged (C23 — pure naming overlay, not curation: every /// interior slot stays sweepable, the `scale` knob is unaffected). #[test] fn macd_param_space_surfaces_the_three_named_aliases() { let names: Vec = macd_blueprint().param_space().into_iter().map(|p| p.name).collect(); // three aliased composite slots, in declared (fast, slow, signal) order, // then the strategy-level Exposure `scale` (outside the composite, unaliased). assert_eq!( names, vec![ "macd.fast".to_string(), "macd.slow".to_string(), "macd.signal".to_string(), "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); } }