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:
@@ -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
@@ -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}");
|
||||
}
|
||||
|
||||
|
||||
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user