Files
Aura/crates/aura-cli/src/main.rs
T
Brummel 1b3909316e feat(aura): node signature lives in the blueprint; collapse Blueprint into Composite
Consolidate the node data structure so every node's signature (NodeSchema:
inputs/output/params) is declared once and exists in the blueprint pre-build,
and dissolve the special "root graph" type. Behaviour-preserving (C1).

Signature vs sizing
- NodeSchema is now the static signature only: InputSpec -> PortSpec{kind,firing},
  with lookback removed. The signature is fully static per blueprint (input
  kinds/firing, output fields, params); LinComb's variable arity is a builder arg,
  not an injected param.
- The one param-dependent quantity, an input's buffer lookback (e.g. Sma's window =
  its injected length), moves out of the signature to Node::lookbacks() -> Vec<usize>,
  read only by bootstrap for sizing. Node::schema() is removed.
- LeafFactory -> PrimitiveBuilder, which carries the full NodeSchema. The built node
  no longer re-declares it: closes the params-declared-twice drift (#36, the 8
  per-node factory_params_match_built_node_schema lockstep tests are deleted — their
  subject is now structurally impossible) and a value-empty recipe exposes its full
  I/O interface pre-build (#43).

Root is just a bound composite
- struct Blueprint is deleted; its compile/bootstrap/param_space methods move onto
  Composite. Role gains source: Option<ScalarKind> (None = open interior port,
  Some = bound ingestion feed). A composite is runnable iff every root role is bound;
  the "main graph" is no longer a category, only the fully-source-bound composite.
  New error CompileError::UnboundRootRole for an open root role.
- BlueprintNode::signature() answers uniformly for both arms: Primitive returns the
  builder's declared schema, Composite derives it from the interior (role kinds in,
  OutField kinds out, aggregated params), pre-build, no build.

compile -> FlatGraph -> bootstrap
- compile validates structure pre-build via signature() (validate_wiring: range +
  kind, returning the same variants as before, so an edge kind fault is now caught
  before any build closure fires) and emits FlatGraph{nodes,signatures,sources,edges}.
- bootstrap consumes the FlatGraph: kinds/firing/output from the carried signatures,
  buffer depth from node.lookbacks(). SourceSpec survives as the flat descriptor.

Renames: BlueprintNode::Leaf -> Primitive, LeafFactory -> PrimitiveBuilder.

Render (aura-cli/src/graph.rs) is migrated compile-only: it takes &Composite, maps
bound roles to the same source-entry shape, so both render goldens reproduce
byte-identical output (no re-capture needed). Render-fidelity tuning is the next cycle.

Verification (orchestrator-run, not agent-reported): cargo build --workspace green;
cargo test --workspace 150 passed / 0 failed; cargo clippy --workspace --all-targets
-D warnings clean. All pinned determinism/run-output tests pass with values unchanged;
no behavioural assertion was altered to go green. 5 new tests assert the signature is
pre-build and uniform, that compile rejects a kind mismatch without building (via a
panicking builder), UnboundRootRole, and lookbacks()/signature arity agreement.

Deferred to cycle-close audit (per plan): docs/design/INDEX.md and some aura-std
module docs still name the old Node::schema()/LeafFactory/BlueprintNode::Leaf/
Blueprint::param_space contracts; prose reconciliation is the architect's at audit.

closes #43 #36
2026-06-09 12:49:22 +02:00

791 lines
35 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 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<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 }],
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<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: 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<Scalar> {
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<Scalar>)>,
tx_ex: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
) -> 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<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: 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<String> = 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::<Vec<_>>().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<Scalar> {
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<String> =
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);
}
}