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
This commit is contained in:
2026-06-09 12:49:22 +02:00
parent c7fc2f6a37
commit 1b3909316e
17 changed files with 1398 additions and 775 deletions
+26 -20
View File
@@ -15,10 +15,10 @@
use ascii_dag::graph::{Graph, RenderMode};
use ascii_dag::render::colors::Palette;
use aura_core::{LeafFactory, Node, ScalarKind};
use aura_core::{Node, PrimitiveBuilder, ScalarKind};
use aura_engine::{
aliases_on, signature_of, Blueprint, BlueprintNode, Composite, Edge, OutField, ParamAlias,
SourceSpec, Target,
aliases_on, signature_of, BlueprintNode, Composite, Edge, OutField, ParamAlias, SourceSpec,
Target,
};
/// Where a leaf's param **names** come from in the shared leaf-label path — the one
@@ -58,18 +58,24 @@ pub enum Color {
/// Blueprint view: the authored structure (#38). A flat main graph wires the
/// harness with each composite shown as a single opaque node; a `where:` section
/// defines each distinct composite type once. Flat layout only (no subgraphs).
pub fn render_blueprint(bp: &Blueprint, color: Color) -> String {
// the main graph is the shared graph core (`render_graph`) over the blueprint's
// top-level (nodes, edges): leaves enriched exactly as `where:` interior leaves
// are (param names folded in, a `<field> →` prefix for any input fed by a
// multi-output producer — the headline: macd's `histogram` driving Exposure),
// composites opaque. The deltas vs a composite definition: param names come from
// the factory (no alias overlay at the root); the entries are sources, not roles;
// there is no output record (terminals are sinks) and no title.
pub fn render_blueprint(bp: &Composite, color: Color) -> String {
// the main graph is the shared graph core (`render_graph`) over the root
// composite's top-level (nodes, edges): leaves enriched exactly as `where:`
// interior leaves are (param names folded in, a `<field> →` prefix for any input
// fed by a multi-output producer — the headline: macd's `histogram` driving
// Exposure), composites opaque. The deltas vs a composite definition: param names
// come from the builder (no alias overlay at the root); the entries are the
// root's source-bound roles, not interior roles; there is no output record
// (terminals are sinks) and no title.
let entries: Vec<Entry> = bp
.sources()
.input_roles()
.iter()
.map(|src| Entry { name: format!("source:{:?}", src.kind), targets: &src.targets })
.filter_map(|role| {
role.source.map(|kind| Entry {
name: format!("source:{kind:?}"),
targets: &role.targets,
})
})
.collect();
let main = render_graph(
bp.nodes(),
@@ -94,7 +100,7 @@ pub fn render_blueprint(bp: &Blueprint, color: Color) -> String {
/// The shared graph core both views render through (#48): a flat ascii-dag of the
/// computation DAG plus external-entry markers. `nodes`/`edges` are a borrowed
/// (blueprint-root or composite-interior) graph slice — never an owned
/// `Composite` (`LeafFactory` is not `Clone`). Each leaf gets the unified
/// `Composite` (`PrimitiveBuilder` is not `Clone`). Each leaf gets the unified
/// [`leaf_label`]; each composite is opaque (`[name]`); a producing node carrying an
/// `OutField` re-export gets the `name := ` binding prefix folded on (empty list →
/// no binding, the blueprint case); every [`Entry`] is drawn as a marker wired to its
@@ -115,7 +121,7 @@ fn render_graph(
let mut labels: Vec<String> = Vec::with_capacity(nodes.len());
for (i, item) in nodes.iter().enumerate() {
let base = match item {
BlueprintNode::Leaf(factory) => {
BlueprintNode::Primitive(factory) => {
leaf_label(nodes, edges, entries, i, factory, &param_names, stub_ctx)
}
BlueprintNode::Composite(c) => c.name().to_string(),
@@ -166,7 +172,7 @@ fn render_flat(labels: &[String], edges: &[(usize, usize)], color: Color) -> Str
/// `name()` (the authoring type identity — same name implies same structure).
/// Recurses into a composite's interior on first sight so nested composites get
/// their own definition; a later same-name occurrence is skipped (deduped).
fn collect_distinct_composites(bp: &Blueprint) -> Vec<&Composite> {
fn collect_distinct_composites(bp: &Composite) -> Vec<&Composite> {
fn walk<'a>(items: &'a [BlueprintNode], seen: &mut Vec<&'a str>, out: &mut Vec<&'a Composite>) {
for item in items {
if let BlueprintNode::Composite(c) = item
@@ -209,7 +215,7 @@ fn signature(c: &Composite) -> String {
.nodes()
.get(a.node)
.and_then(|n| match n {
BlueprintNode::Leaf(f) => f.params().get(a.slot).map(|p| kind_str(p.kind)),
BlueprintNode::Primitive(f) => f.params().get(a.slot).map(|p| kind_str(p.kind)),
BlueprintNode::Composite(_) => None,
})
.unwrap_or("?");
@@ -247,7 +253,7 @@ fn leaf_label(
edges: &[Edge],
entries: &[Entry],
index: usize,
factory: &LeafFactory,
factory: &PrimitiveBuilder,
param_names: &ParamNames,
stub_ctx: Option<&Composite>,
) -> String {
@@ -334,7 +340,7 @@ fn multi_output_field_name(nodes: &[BlueprintNode], from: usize, from_field: usi
None
}
}
BlueprintNode::Leaf(_) => (from_field > 0).then(|| from_field.to_string()),
BlueprintNode::Primitive(_) => (from_field > 0).then(|| from_field.to_string()),
}
}
@@ -396,7 +402,7 @@ fn slot_source(c: &Composite, index: usize, slot: usize) -> (String, usize, bool
/// rendered identifier never goes below.
fn signature_base_len(c: &Composite, node: usize) -> usize {
match &c.nodes()[node] {
BlueprintNode::Leaf(_) => 1 + aliases_on(c.params(), node).count(),
BlueprintNode::Primitive(_) => 1 + aliases_on(c.params(), node).count(),
BlueprintNode::Composite(_) => 1,
}
}
+129 -87
View File
@@ -10,8 +10,8 @@ mod graph;
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
use aura_engine::{
f64_field, summarize, Blueprint, BlueprintNode, Composite, Edge, Harness, OutField,
ParamAlias, Role, RunManifest, RunReport, SourceSpec, Target,
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;
@@ -50,8 +50,13 @@ fn sample_harness() -> (
) {
let (tx_eq, rx_eq) = mpsc::channel();
let (tx_ex, rx_ex) = mpsc::channel();
let h = Harness::bootstrap(
vec![
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
@@ -60,7 +65,16 @@ fn sample_harness() -> (
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_eq)), // 5 equity sink
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_ex)), // 6 exposure sink
],
vec![SourceSpec {
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 },
@@ -68,7 +82,7 @@ fn sample_harness() -> (
Target { node: 4, slot: 1 }, // price into the broker's price slot
],
}],
vec![
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 },
@@ -76,7 +90,7 @@ fn sample_harness() -> (
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)
}
@@ -122,7 +136,7 @@ fn run_sample() -> RunReport {
fn sma_cross(name: &str) -> Composite {
Composite::new(
name,
vec![Sma::factory().into(), Sma::factory().into(), Sub::factory().into()],
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 },
@@ -130,6 +144,7 @@ fn sma_cross(name: &str) -> Composite {
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
@@ -143,35 +158,39 @@ fn sma_cross(name: &str) -> Composite {
/// 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() -> Blueprint {
fn build_sample() -> Composite {
let (tx_eq, _rx_eq) = mpsc::channel();
let (tx_ex, _rx_ex) = mpsc::channel();
Blueprint::new(
Composite::new(
"sample",
vec![
BlueprintNode::Composite(sma_cross("sma_cross")),
Exposure::factory().into(),
SimBroker::factory(0.0001).into(),
Recorder::factory(vec![ScalarKind::F64], Firing::Any, tx_eq).into(),
Recorder::factory(vec![ScalarKind::F64], Firing::Any, tx_ex).into(),
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![SourceSpec {
kind: ScalarKind::F64,
targets: vec![
Target { node: 0, slot: 0 }, // price -> sma_cross role 0
Target { node: 2, slot: 1 }, // price -> SimBroker price slot
],
}],
vec![
Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // spread -> Exposure
Edge { from: 1, to: 2, slot: 0, from_field: 0 }, // exposure -> broker slot 0
Edge { from: 2, to: 3, slot: 0, from_field: 0 }, // equity -> sink
Edge { from: 1, to: 4, slot: 0, from_field: 0 }, // exposure -> sink
],
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() -> Blueprint {
fn sample_blueprint() -> Composite {
build_sample()
}
@@ -195,11 +214,11 @@ fn macd(name: &str) -> Composite {
Composite::new(
name,
vec![
Ema::factory().into(), // 0 fast EMA
Ema::factory().into(), // 1 slow EMA
Sub::factory().into(), // 2 MACD line = fast slow
Ema::factory().into(), // 3 signal EMA of the MACD line
Sub::factory().into(), // 4 histogram = MACD line signal
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]
@@ -214,6 +233,7 @@ fn macd(name: &str) -> Composite {
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
@@ -234,34 +254,38 @@ fn macd(name: &str) -> Composite {
fn macd_strategy_blueprint(
tx_eq: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
tx_ex: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
) -> Blueprint {
Blueprint::new(
) -> Composite {
Composite::new(
"macd_strategy",
vec![
BlueprintNode::Composite(macd("macd")),
Exposure::factory().into(),
SimBroker::factory(0.0001).into(),
Recorder::factory(vec![ScalarKind::F64], Firing::Any, tx_eq).into(),
Recorder::factory(vec![ScalarKind::F64], Firing::Any, tx_ex).into(),
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![SourceSpec {
kind: ScalarKind::F64,
targets: vec![
Target { node: 0, slot: 0 }, // price → macd role 0
Target { node: 2, slot: 1 }, // price → SimBroker price slot
],
}],
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() -> Blueprint {
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)
@@ -296,10 +320,10 @@ fn macd_prices() -> Vec<(Timestamp, Scalar)> {
fn run_macd() -> RunReport {
let (tx_eq, rx_eq) = mpsc::channel();
let (tx_ex, rx_ex) = mpsc::channel();
let (nodes, sources, edges) = macd_strategy_blueprint(tx_eq, tx_ex)
let flat = macd_strategy_blueprint(tx_eq, tx_ex)
.compile_with_params(&macd_point())
.expect("valid macd blueprint");
let mut h = Harness::bootstrap(nodes, sources, edges).expect("valid macd harness");
let mut h = Harness::bootstrap(flat).expect("valid macd harness");
let prices = macd_prices();
let window = (
@@ -333,9 +357,9 @@ fn run_macd() -> RunReport {
/// 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: Blueprint, point: &[Scalar], color: graph::Color) -> String {
let (nodes, sources, edges) = bp.compile_with_params(point).expect("valid blueprint");
graph::render_compilat(&nodes, &sources, &edges, color)
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]]";
@@ -415,25 +439,30 @@ mod tests {
// structure only (no compile/validate), so a minimal fixture suffices.
let inner = Composite::new(
"inner",
vec![Sma::factory().into()],
vec![Sma::builder().into()],
vec![],
vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }] }],
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::factory().into()],
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 }] }],
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 = Blueprint::new(
vec![BlueprintNode::Composite(outer)],
vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }],
vec![],
);
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}");
@@ -445,21 +474,23 @@ mod tests {
#[test]
fn reused_composite_defined_once() {
// the same composite type used twice: two opaque nodes, one definition.
let bp = Blueprint::new(
vec![
let bp = Composite::new(
"root",
vec![
BlueprintNode::Composite(sma_cross("dup")),
BlueprintNode::Composite(sma_cross("dup")),
Exposure::factory().into(),
Exposure::builder().into(),
],
vec![SourceSpec {
kind: ScalarKind::F64,
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
}],
vec![
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}");
@@ -468,9 +499,8 @@ mod tests {
#[test]
fn compiled_view_dissolves_the_composite_boundary() {
let bp = sample_blueprint();
let (nodes, sources, edges) =
bp.compile_with_params(&sample_point()).expect("valid sample");
let out = graph::render_compilat(&nodes, &sources, &edges, graph::Color::Plain);
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)
@@ -483,12 +513,12 @@ mod tests {
// 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 (cn, cs, ce) =
let cflat =
sample_blueprint().compile_with_params(&sample_point()).expect("valid sample");
let correct = graph::render_compilat(&cn, &cs, &ce, graph::Color::Plain);
let (sn, ss, se) =
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(&sn, &ss, &se, graph::Color::Plain);
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");
}
@@ -534,9 +564,8 @@ sma_cross(fast:i64, slow:i64) -> (cross):
#[test]
fn compiled_view_golden() {
let bp = sample_blueprint();
let (nodes, sources, edges) =
bp.compile_with_params(&sample_point()).expect("valid sample");
let out = graph::render_compilat(&nodes, &sources, &edges, graph::Color::Plain);
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]
┌────────└─┐──────┐
↓ ↓ │
@@ -615,13 +644,20 @@ sma_cross(fast:i64, slow:i64) -> (cross):
// #price (role name) and #Es
let c = Composite::new(
"roles",
vec![Ema::factory().into(), Sub::factory().into()],
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 }] }],
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 = Blueprint::new(vec![BlueprintNode::Composite(c)], vec![], vec![]);
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}");
}
@@ -634,13 +670,13 @@ sma_cross(fast:i64, slow:i64) -> (cross):
let c = Composite::new(
"nest",
vec![
Ema::factory().into(), // 0 fast
Ema::factory().into(), // 1 slow
Ema::factory().into(), // 2 up
Ema::factory().into(), // 3 down
Sub::factory().into(), // 4 = Sub(0,1)
Sub::factory().into(), // 5 = Sub(2,3)
Sub::factory().into(), // 6 = Sub(4,5) (the outer fan-in)
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 },
@@ -657,8 +693,7 @@ sma_cross(fast:i64, slow:i64) -> (cross):
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 },
@@ -667,7 +702,14 @@ sma_cross(fast:i64, slow:i64) -> (cross):
],
vec![OutField { node: 6, field: 0, name: "o".into() }],
);
let bp = Blueprint::new(vec![BlueprintNode::Composite(c)], vec![], vec![]);
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}");