feat(aura): render composite output re-exports as producer bindings

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
This commit is contained in:
2026-06-08 17:16:02 +02:00
parent b2f27f8590
commit 3b496883e6
3 changed files with 51 additions and 24 deletions
+37 -12
View File
@@ -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<Stri
/// Render one composite's interior as a flat graph: interior leaves as
/// `[type(param…; #slot…)]` (aliased param names + ordered input-slot stubs folded
/// in via `leaf_label`), nested composites as opaque `[name]`, an `[<name>]`
/// entry marker per input role (wired to its interior targets), and an `[<name>]`
/// 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 `[<name>]`
/// 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<String> = 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<String> {
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<dyn Node>` labels itself; node display id = node index.
pub fn render_compilat(
+12 -10
View File
@@ -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}");
}
+2 -2
View File
@@ -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!(