1b3909316e
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
791 lines
35 KiB
Rust
791 lines
35 KiB
Rust
//! `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);
|
||
}
|
||
}
|