diff --git a/crates/aura-cli/src/graph.rs b/crates/aura-cli/src/graph.rs index 280e877..7a42fe8 100644 --- a/crates/aura-cli/src/graph.rs +++ b/crates/aura-cli/src/graph.rs @@ -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 ` →` 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 ` →` 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 = 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 = match stub_ctx { - Some(c) if slots.len() > 1 => fan_in_identifiers(c, index, &slots), - _ => Vec::new(), + let stubs: Vec = 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, ) diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index e4f3ee2..c855f0e 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -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.