feat(aura-cli): render the root composite like any other composite

Close #49 and remove the last two render special-cases the blueprint root still
carried, both pre-0024 vestiges. Since cycle 0024 the root is an ordinary
Composite with input_roles, so the render no longer needs to treat it apart:

(A) Fan-in slot stubs at the root. A composite-interior multi-input leaf already
    renders its wired slots as #… stubs; a top-level leaf did not, because
    render_blueprint threaded stub_ctx: None. It now passes the root composite
    (it IS a &Composite, carrying roles + edges), and stub_ctx drops its Option
    — both render_graph callers pass &Composite. The existing slot_source/
    fan_in_identifiers/signature_of serve a top-level leaf verbatim; no second
    stub path (#49 acceptance). SimBroker now renders [SimBroker(#E,#price)]
    (#E = the Exposure producer's sibling-unique signature prefix, #price = the
    price source role name).

(B) Role-named root entries. render_blueprint built entry markers from
    format!("source:{kind}") while render_definition uses role.name — the
    marker/stub asymmetry. The root now builds entries from role.name (filtered
    to bound roles, source.is_some()), identical to the interior, so the source
    marker reads [price] and matches the #price slot stub it feeds.

render_compilat is deliberately untouched: post-inline the role name has
dissolved (C23), so the compiled view keeps [source:F64] from the flat
SourceSpec.kind — [price] pre-inline / [source:F64] post-inline is C23 made
visible, and compiled_view_golden is the byte-unchanged negative control.

Design call (orchestrator, with the user): the marker/stub asymmetry was fixed at
its source (the entry naming) rather than by special-casing the stub to match the
kind-named marker — the latter would have been a root-only stub behaviour,
violating the "no second stub path" acceptance. The user chose full symmetry
([price], dropping the kind annotation) over a name+kind marker, prioritising
structural cleanliness; the only remaining root-vs-interior distinction is the
bound-role filter (C3), which is semantic, not cosmetic.

Scope: crates/aura-cli/src/graph.rs only; read-only render (C9),
behaviour-preserving for the run path (C1), no engine change.
blueprint_view_golden re-captured from the live `aura graph` output (the
where: section stays byte-identical — only the main graph re-flows); the
SimBroker needle, a root-SimBroker-stub assertion on the macd view, and a [src]
role-name-passthrough assertion are added.

Verification (orchestrator-run, not agent-reported): cargo build --workspace
green; cargo test --workspace 150 passed / 0 failed (same count, no run-path
test moved); cargo clippy --workspace --all-targets -D warnings clean.
compiled_view_golden and render_compilat byte-untouched.

closes #49
This commit is contained in:
2026-06-09 18:11:22 +02:00
parent cd569aa6d0
commit 12b1bcec22
2 changed files with 53 additions and 47 deletions
+29 -30
View File
@@ -35,10 +35,11 @@ enum ParamNames<'a> {
}
/// A CLI-local, *borrowed* render notion unifying a composite **input role** and a
/// blueprint **source** for the shared graph core (#48): both are an external entry
/// drawn as a marker node wired into its interior `targets`. The composite builds
/// these from `Role` (name = role name), the blueprint from `SourceSpec` (name =
/// `source:{kind}`) — no engine change, the list is assembled CLI-side and borrows
/// blueprint **bound source role** for the shared graph core (#48): both are an
/// external entry drawn as a marker node wired into its interior `targets`. Both
/// views build these from `Role` (name = role name); the blueprint root filters to
/// bound roles (`source.is_some()`), the only remaining root-vs-interior
/// distinction (C3) — no engine change, the list is assembled CLI-side and borrows
/// the engine's `Target` slices.
struct Entry<'a> {
name: String,
@@ -60,22 +61,18 @@ pub enum Color {
/// defines each distinct composite type once. Flat layout only (no subgraphs).
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.
// composite's top-level (nodes, edges), rendered exactly as a `where:` interior:
// leaves enriched (param names, a `<field> →` prefix for a multi-output producer
// — macd's `histogram` driving Exposure — and, now threaded at the root too,
// fan-in slot stubs), composites opaque, entries named by their role. The only
// deltas vs a composite definition: param names come from the builder (no alias
// overlay at the root); entries are filtered to bound source roles (C3); there
// is no output record (terminals are sinks) and no title.
let entries: Vec<Entry> = bp
.input_roles()
.iter()
.filter_map(|role| {
role.source.map(|kind| Entry {
name: format!("source:{kind:?}"),
targets: &role.targets,
})
})
.filter(|role| role.source.is_some())
.map(|role| Entry { name: role.name.clone(), targets: &role.targets })
.collect();
let main = render_graph(
bp.nodes(),
@@ -83,7 +80,7 @@ pub fn render_blueprint(bp: &Composite, color: Color) -> String {
&entries,
&[], // no output bindings: a blueprint's terminals are sinks
ParamNames::Factory,
None, // no fan-in stub context at the root (deterministic no-op, #48)
bp, // the root IS the fan-in stub context (it carries roles + edges)
None, // no title
color,
);
@@ -105,8 +102,9 @@ pub fn render_blueprint(bp: &Composite, color: Color) -> String {
/// `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
/// interior targets. A `title` (the composite's typed signature) is prefixed when
/// `Some`. The two render-borders that differ — param-name source and fan-in stub
/// availability — are passed as `param_names` / `stub_ctx`.
/// `Some`. The render-border that differs the param-name source — is passed as
/// `param_names`; `stub_ctx` is the borrowed composite (root or interior) both
/// views thread for fan-in slot resolution.
#[allow(clippy::too_many_arguments)]
fn render_graph(
nodes: &[BlueprintNode],
@@ -114,7 +112,7 @@ fn render_graph(
entries: &[Entry],
output: &[OutField],
param_names: ParamNames,
stub_ctx: Option<&Composite>,
stub_ctx: &Composite,
title: Option<&str>,
color: Color,
) -> String {
@@ -240,10 +238,10 @@ fn signature(c: &Composite) -> String {
/// composite path keeps using the alias overlay filtered to this node (so `where:`
/// stays byte-identical — only aliased slots, never factory defaults); the
/// blueprint-root path uses the factory's declared names.
/// 3. The leaf's input-slot stubs (`#Sf`, …) when it is a multi-input fan-in **and** a
/// composite context is available (`stub_ctx = Some`). At the blueprint root no
/// such context is threaded, so a top-level fan-in renders without stubs (a
/// deterministic no-op, #48) rather than duplicating the signature machinery.
/// 3. The leaf's input-slot stubs (`#Sf`, …) when it is a multi-input fan-in. The
/// `stub_ctx` is the borrowed composite (root or interior) the wired slots
/// resolve against; both views thread it, so a top-level fan-in stubs exactly as
/// an interior one does — one shared path, no root carve-out (#49).
///
/// Params and stubs are `; `-separated inside the `(...)` when both present; the
/// field prefix always sits outside the parens. The bare label results when none of
@@ -255,7 +253,7 @@ fn leaf_label(
index: usize,
factory: &PrimitiveBuilder,
param_names: &ParamNames,
stub_ctx: Option<&Composite>,
stub_ctx: &Composite,
) -> String {
let params: Vec<&str> = match param_names {
ParamNames::Aliases(aliases) => {
@@ -280,9 +278,10 @@ fn leaf_label(
}
}
slots.sort_unstable();
let stubs: Vec<String> = match stub_ctx {
Some(c) if slots.len() > 1 => fan_in_identifiers(c, index, &slots),
_ => Vec::new(),
let stubs: Vec<String> = if slots.len() > 1 {
fan_in_identifiers(stub_ctx, index, &slots)
} else {
Vec::new()
};
let inner: String = match (params.is_empty(), stubs.is_empty()) {
@@ -457,7 +456,7 @@ fn render_definition(c: &Composite, color: Color) -> String {
&entries,
c.output(),
ParamNames::Aliases(c.params()),
Some(c),
c,
Some(&title),
color,
)
+24 -17
View File
@@ -411,10 +411,11 @@ mod tests {
let out = graph::render_blueprint(&sample_blueprint(), graph::Color::Plain);
// the composite is a single opaque main-graph node, not an expanded cluster
assert!(out.contains("[sma_cross]"), "missing opaque composite node:\n{out}");
// top-level leaves render enriched (param names folded in, #48), exactly as
// the `where:` interior leaves are — `Exposure` folds its `scale` param;
// paramless leaves (SimBroker, Recorder) stay bare.
for needle in ["[Exposure(scale)]", "[SimBroker]", "[Recorder]"] {
// top-level leaves render enriched exactly as the `where:` interior leaves:
// `Exposure` folds its `scale` param; a paramless SINGLE-input leaf
// (`Recorder`) stays bare, but a paramless MULTI-input fan-in (`SimBroker`)
// now shows its slot stubs (#49).
for needle in ["[Exposure(scale)]", "[SimBroker(#E,#price)]", "[Recorder]"] {
assert!(out.contains(needle), "missing {needle}:\n{out}");
}
// a definitions section is present
@@ -494,6 +495,9 @@ mod tests {
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}");
// the bound root role named "src" carries its name into the entry marker
// (general non-"price" role-name passthrough, not just the sample's price).
assert!(out.contains("[src]"), "root role name renders as the entry marker: {out}");
}
#[test]
@@ -528,19 +532,19 @@ mod tests {
// ascii-dag's Sugiyama layout is deterministic (no RNG); these are the
// exact bytes `aura graph` emits — main graph (composites opaque) + the
// `where:` definitions section. Re-capture via `aura graph` if intended.
let expected = r#" [source:F64]
┌──└─────┐
↓ │
[sma_cross] │
│ │
↓ └──┐
[Exposure(scale)] │
┌──┘─────────┐ │
↓ ↓──┘
[Recorder] [SimBroker]
─────┘
[Recorder]
let expected = r#" [price]
┌──└─────┐
↓ │
[sma_cross] │
│ │
↓ └──┐
[Exposure(scale)] │
┌────────┘─────────┐ │
↓──┘
[Recorder] [SimBroker(#E,#price)]
┌──────┘
[Recorder]
@@ -628,6 +632,9 @@ sma_cross(fast:i64, slow:i64) -> (cross):
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}");
// the root SimBroker is a top-level multi-input fan-in: its two slots now
// render as #… stubs, mirroring the where: interior (#49).
assert!(out.contains("[SimBroker(#E,#price)]"), "root SimBroker shows its two slot stubs: {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.