diff --git a/crates/aura-cli/src/graph.rs b/crates/aura-cli/src/graph.rs index 7d35421..8e2ab4a 100644 --- a/crates/aura-cli/src/graph.rs +++ b/crates/aura-cli/src/graph.rs @@ -16,7 +16,9 @@ use ascii_dag::graph::{Graph, RenderMode}; use ascii_dag::render::colors::Palette; use aura_core::{LeafFactory, Node, ScalarKind}; -use aura_engine::{aliases_on, signature_of, Blueprint, BlueprintNode, Composite, Edge, SourceSpec}; +use aura_engine::{ + aliases_on, signature_of, Blueprint, BlueprintNode, Composite, Edge, OutField, SourceSpec, +}; /// Edge colouring for a rendered graph. `Plain` is monochrome — golden-stable and /// pipe-safe (no escape codes when redirected to a file). `Ansi` emits per-edge @@ -281,17 +283,25 @@ fn unique_prefix_from(sig: &str, base: usize, others: &[&String]) -> Option]` -/// entry marker per input role (wired to its interior targets), and an `[]` -/// node per re-exported output field (wired from its producer). The title line is -/// the composite's typed `signature` (`name(p:kind, …) -> (out, …)`); params live -/// in the signature, not as marker nodes. +/// in via `leaf_label`), nested composites as opaque `[name]`, and an `[]` +/// entry marker per input role (wired to its interior targets). A re-exported +/// output is **not** a standalone node: its name is folded onto its producing +/// node's label as a binding (`[macd := Sub(…)]`, tuple `(a, b) := …` for several +/// outputs on one node) via [`output_binding`] — so the drawn graph is exactly the +/// computation DAG, every node a real step (C23: the name is a render symbol on the +/// producer, never a wired terminal). The title line is the composite's typed +/// `signature` (`name(p:kind, …) -> (out, …)`); params live in the signature, not +/// as marker nodes. fn render_definition(c: &Composite, color: Color) -> String { let mut labels: Vec = Vec::with_capacity(c.nodes().len()); for (i, inner) in c.nodes().iter().enumerate() { - labels.push(match inner { + let base = match inner { BlueprintNode::Leaf(factory) => leaf_label(c, i, factory), BlueprintNode::Composite(inner_c) => inner_c.name().to_string(), + }; + labels.push(match output_binding(c, i) { + Some(prefix) => format!("{prefix}{base}"), + None => base, }); } let mut edges: Vec<(usize, usize)> = Vec::new(); @@ -305,15 +315,30 @@ fn render_definition(c: &Composite, color: Color) -> String { edges.push((in_id, t.node)); } } - for of in c.output() { - let out_id = labels.len(); - labels.push(of.name.clone()); - edges.push((of.node, out_id)); - } + // outputs are folded onto their producers by output_binding above — no + // standalone output node and no producer→output edge (C23). format!("{}:\n\n{}", signature(c), render_flat(&labels, &edges, color)) } +/// The `name := ` (single) or `(n1, n2) := ` (tuple) binding prefix for interior +/// node `i`, if any `OutField` re-exports it; `None` for a non-output node. Names +/// are ordered by re-exported `field` index (ties keep author order). The producer +/// label follows the prefix; no standalone output node or producer→output edge is +/// emitted (the output *is* the producer, surfaced by name — C23). +fn output_binding(c: &Composite, i: usize) -> Option { + let mut outs: Vec<&OutField> = c.output().iter().filter(|of| of.node == i).collect(); + if outs.is_empty() { + return None; + } + outs.sort_by_key(|of| of.field); + let names: Vec<&str> = outs.iter().map(|of| of.name.as_str()).collect(); + Some(match names.as_slice() { + [one] => format!("{one} := "), + many => format!("({}) := ", many.join(", ")), + }) +} + /// Compiled view: the flat post-inline graph (no clusters; boundaries dissolved, /// C23). Each `Box` labels itself; node display id = node index. pub fn render_compilat( diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 845f758..7a20c26 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -402,7 +402,7 @@ mod tests { 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)]", "[Sub(#Sf,#Ss)]", "[price]", "[cross]"] { + for needle in ["[SMA(fast)]", "[SMA(slow)]", "[cross := Sub(#Sf,#Ss)]", "[price]"] { assert!(out.contains(needle), "missing {needle} in definition:\n{out}"); } } @@ -522,10 +522,7 @@ sma_cross(fast:i64, slow:i64) -> (cross): [SMA(fast)] [SMA(slow)] └──────┌──────┘ ↓ - [Sub(#Sf,#Ss)] - │ - ↓ - [cross] + [cross := Sub(#Sf,#Ss)] "#; @@ -597,9 +594,14 @@ sma_cross(fast:i64, slow:i64) -> (cross): ); 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("[EMA(signal)]"), "signal EMA folds its param: {out}"); - assert!(out.contains("[Sub(#Ef,#Es)]"), "MACD line Sub is fast-EMA minus slow-EMA: {out}"); - assert!(out.contains("[Sub(#S,#Es)]"), "histogram Sub is the line minus signal-EMA: {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}"); @@ -619,7 +621,7 @@ sma_cross(fast:i64, slow:i64) -> (cross): ); let bp = Blueprint::new(vec![BlueprintNode::Composite(c)], vec![], vec![]); let out = graph::render_blueprint(&bp, graph::Color::Plain); - assert!(out.contains("[Sub(#price,#Es)]"), "role name verbatim + source-derived: {out}"); + assert!(out.contains("[o := Sub(#price,#Es)]"), "role name verbatim + source-derived, bound as output `o`: {out}"); } #[test] @@ -665,7 +667,7 @@ sma_cross(fast:i64, slow:i64) -> (cross): ); let bp = Blueprint::new(vec![BlueprintNode::Composite(c)], vec![], vec![]); let out = graph::render_blueprint(&bp, graph::Color::Plain); - assert!(out.contains("[Sub(#SEf,#SEu)]"), "outer Sub descends into inner Subs: {out}"); + 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}"); } diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index c728258..16b7da1 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -174,8 +174,8 @@ fn graph_renders_source_derived_fan_in_identifiers() { assert_eq!(out.status.code(), Some(0), "`aura graph` exit: {:?}", out.status); let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); assert!( - stdout.contains("[Sub(#Sf,#Ss)]"), - "fan-in inputs must be source-derived (#Sf/#Ss), not positional: {stdout}" + stdout.contains("[cross := Sub(#Sf,#Ss)]"), + "fan-in inputs source-derived (#Sf/#Ss), bound as output `cross`: {stdout}" ); // the retired positional form must not reappear. assert!(