From 3b496883e6e1c4f73e5eaeee869e7db74d159aa6 Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 8 Jun 2026 17:16:02 +0200 Subject: [PATCH] feat(aura): render composite output re-exports as producer bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the blueprint view's `where:` section, a composite's output re-exports now fold onto their producing node's label as a binding (`[macd := Sub(#Ef,#Es)]`) instead of each becoming a standalone terminal node wired from its producer. An OutField re-exports an existing interior node's value — it is never a new node — so the old form drew false terminals (MACD's macd line feeds the signal EMA and histogram internally yet rendered as a dead-end leaf) and inflated the node count by the output arity (MACD where: 8 -> 6 nodes; sma_cross drops its [cross] stub + edge). The drawn graph is now exactly the computation DAG; the `:=` prefix is the visual signal that a node's value escapes the boundary. Input roles stay standalone entry nodes (no producer to annotate) — the asymmetry is deliberate. render_definition drops its per-OutField node+edge loop; a new output_binding helper yields the `name := ` / tuple `(a, b) := ` prefix. Pure render layer: C8/C23 untouched, OutField.name never reaches the compilat, compiled_view_golden byte-identical, MACD run deterministic and unchanged in metrics. Test blast radius was wider than spec 0022 §Components / the plan's Scope note counted: besides the MACD pinning test, four more sites assert a producer that carries an OutField and so gain the `:=` prefix — blueprint_view_defines_each_composite_once, the two fan-in identifier tests, the cli_run integration test, and (missed by the plan, caught at implement) the full-render blueprint_view_golden. All re-pins are forced by the design, zero judgement. The output_binding tuple arm is unexercised by current fixtures (no composite re-exports >1 field from one node). Verified: cargo test --workspace 0 failed; clippy --all-targets -D warnings clean. closes #46 --- crates/aura-cli/src/graph.rs | 49 ++++++++++++++++++++++++-------- crates/aura-cli/src/main.rs | 22 +++++++------- crates/aura-cli/tests/cli_run.rs | 4 +-- 3 files changed, 51 insertions(+), 24 deletions(-) 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!(