From 481172ae2e8b06dcff7c2549661b42dc71c5c3f9 Mon Sep 17 00:00:00 2001 From: Brummel Date: Tue, 9 Jun 2026 00:09:17 +0200 Subject: [PATCH] feat(aura-cli): unify blueprint main-graph render through one shared core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit render_blueprint and render_definition were two divergent paths: the main graph drew bare factory.label() leaves and bare edges, the composite interior drew enriched leaves (params, slot stubs, output bindings). They now delegate to one shared render_graph over borrowed slices — the blueprint is the root composite, differing only at the borders (ParamNames::Factory vs Aliases, Entry unifying SourceSpec and Role, empty OutField list = no bindings). LeafFactory holds a Box, so it is not Clone — an owned root-composite adapter is impossible; the shared core takes borrowed slices instead. The main graph now shows what the strategy does: a multi-output producer's selected field surfaces as a consumer-side prefix ([histogram -> Exposure(scale)]), mirroring the := output-binding form. Edge labels are deliberately avoided — ascii-dag 0.9.1 silently drops an edge label it cannot place without collision (arena_render.rs can_place_label), unreliable in a dense graph. Deferred: top-level blueprint multi-input leaves render without #-slot stubs (stub_ctx None) — fan_in_identifiers is still composite-coupled via signature_of. refs #48 --- crates/aura-cli/src/graph.rs | 312 +++++++++++++++++++++++++---------- crates/aura-cli/src/main.rs | 16 +- 2 files changed, 233 insertions(+), 95 deletions(-) diff --git a/crates/aura-cli/src/graph.rs b/crates/aura-cli/src/graph.rs index 8e2ab4a..2370c5e 100644 --- a/crates/aura-cli/src/graph.rs +++ b/crates/aura-cli/src/graph.rs @@ -17,9 +17,34 @@ 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, OutField, SourceSpec, + aliases_on, signature_of, Blueprint, BlueprintNode, Composite, Edge, OutField, ParamAlias, + SourceSpec, Target, }; +/// Where a leaf's param **names** come from in the shared leaf-label path — the one +/// border that differs between the two views (#48). A composite interior leaf draws +/// its names from the composite's `ParamAlias` overlay (filtered to this node, +/// keeping the existing `where:` form byte-for-byte); a top-level blueprint leaf has +/// no alias overlay, so its names come straight from the factory's declared params. +enum ParamNames<'a> { + /// Composite interior: the alias list, filtered by `node == index` (existing + /// `where:` behaviour — only aliased slots show, never factory defaults). + Aliases(&'a [ParamAlias]), + /// Top-level blueprint leaf: every factory-declared param name, in order. + Factory, +} + +/// 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 +/// the engine's `Target` slices. +struct Entry<'a> { + name: String, + targets: &'a [Target], +} + /// 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 /// ANSI colours so crossing edges stay traceable on an interactive terminal. The @@ -34,38 +59,28 @@ pub enum Color { /// harness with each composite shown as a single opaque node; a `where:` section /// defines each distinct composite type once. Flat layout only (no subgraphs). pub fn render_blueprint(bp: &Blueprint, color: Color) -> String { - // pass 1: one main-graph node per top-level item (leaf -> bare-type label; - // composite -> its name, opaque) and one node per source. - let mut labels: Vec = Vec::new(); - let mut item_ids: Vec = Vec::with_capacity(bp.nodes().len()); - for item in bp.nodes() { - let id = labels.len(); - labels.push(match item { - BlueprintNode::Leaf(factory) => factory.label(), - BlueprintNode::Composite(c) => c.name().to_string(), - }); - item_ids.push(id); - } - let mut source_ids: Vec = Vec::with_capacity(bp.sources().len()); - for src in bp.sources() { - let id = labels.len(); - labels.push(format!("source:{:?}", src.kind)); - source_ids.push(id); - } - - // pass 2: edges — every endpoint is a single opaque node, so the slot that - // mattered for cluster fan-in is irrelevant here. - let mut edges: Vec<(usize, usize)> = Vec::new(); - for e in bp.edges() { - edges.push((item_ids[e.from], item_ids[e.to])); - } - for (src, &sid) in bp.sources().iter().zip(&source_ids) { - for t in &src.targets { - edges.push((sid, item_ids[t.node])); - } - } - - let main = render_flat(&labels, &edges, color); + // the main graph is the shared graph core (`render_graph`) over the blueprint'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 factory (no alias overlay at the root); the entries are sources, not roles; + // there is no output record (terminals are sinks) and no title. + let entries: Vec = bp + .sources() + .iter() + .map(|src| Entry { name: format!("source:{:?}", src.kind), targets: &src.targets }) + .collect(); + let main = render_graph( + bp.nodes(), + bp.edges(), + &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) + None, // no title + color, + ); // definitions: each distinct composite type, once, recursively. let defs = collect_distinct_composites(bp); @@ -76,6 +91,59 @@ pub fn render_blueprint(bp: &Blueprint, color: Color) -> String { format!("{main}\nwhere:\n\n{body}") } +/// The shared graph core both views render through (#48): a flat ascii-dag of the +/// computation DAG plus external-entry markers. `nodes`/`edges` are a borrowed +/// (blueprint-root or composite-interior) graph slice — never an owned +/// `Composite` (`LeafFactory` is not `Clone`). Each leaf gets the unified +/// [`leaf_label`]; each composite is opaque (`[name]`); a producing node carrying an +/// `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`. +#[allow(clippy::too_many_arguments)] +fn render_graph( + nodes: &[BlueprintNode], + edges: &[Edge], + entries: &[Entry], + output: &[OutField], + param_names: ParamNames, + stub_ctx: Option<&Composite>, + title: Option<&str>, + color: Color, +) -> String { + let mut labels: Vec = Vec::with_capacity(nodes.len()); + for (i, item) in nodes.iter().enumerate() { + let base = match item { + BlueprintNode::Leaf(factory) => { + leaf_label(nodes, edges, entries, i, factory, ¶m_names, stub_ctx) + } + BlueprintNode::Composite(c) => c.name().to_string(), + }; + labels.push(match output_binding(output, i) { + Some(prefix) => format!("{prefix}{base}"), + None => base, + }); + } + + let mut edge_pairs: Vec<(usize, usize)> = edges.iter().map(|e| (e.from, e.to)).collect(); + for entry in entries { + let entry_id = labels.len(); + labels.push(entry.name.clone()); + for t in entry.targets { + edge_pairs.push((entry_id, t.node)); + } + } + // outputs are folded onto their producers by output_binding above — no + // standalone output node and no producer→output edge (C23). + + let flat = render_flat(&labels, &edge_pairs, color); + match title { + Some(t) => format!("{t}:\n\n{flat}"), + None => flat, + } +} + /// Build and render a flat (no-subgraph) ascii-dag graph from owned labels and /// edge pairs — the same idiom `render_compilat` uses. fn render_flat(labels: &[String], edges: &[(usize, usize)], color: Color) -> String { @@ -152,48 +220,122 @@ fn signature(c: &Composite) -> String { format!("{}({}) -> ({})", c.name(), params.join(", "), outs.join(", ")) } -/// A leaf item's render label: `factory.label()` plus an optional `(...)` listing -/// its aliased param names, then its input-slot stubs (`#A`, `#B`, … one per wired -/// input slot, slot index → letter) when the leaf has more than one wired input -/// slot. Params and stubs are `; `-separated when both present; either alone has no -/// separator; neither yields the bare label. A node's wired slots are the distinct -/// `.slot` values targeting it across interior edges (`Edge.to == index`) and input -/// roles (`Role.targets` with `node == index`). -fn leaf_label(c: &Composite, index: usize, factory: &LeafFactory) -> String { - let params: Vec<&str> = c - .params() - .iter() - .filter(|a| a.node == index) - .map(|a| a.name.as_str()) - .collect(); +/// The unified leaf-label both views render through (#48): `factory.label()` +/// enriched in three value-empty ways (names, never values — C22 "structure +/// before"), folded into one `factory.label(...)` form: +/// +/// 1. A ` → ` consumer-side prefix per input slot fed by a **multi-output +/// producer** (the headline — macd's `histogram` driving `Exposure`). Single-output +/// producers contribute nothing, so single-out graphs (sample sma_cross interior +/// and main graph) stay prefix-free. Drawn outside the `(...)`, like the +/// `output_binding` consumer-prefix form, because ascii-dag 0.9.1 silently drops +/// an edge label it cannot place. +/// 2. The leaf's param **names** in `(...)`. The source is [`ParamNames`]: the +/// 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. +/// +/// 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 +/// the three enrichments apply. +fn leaf_label( + nodes: &[BlueprintNode], + edges: &[Edge], + entries: &[Entry], + index: usize, + factory: &LeafFactory, + param_names: &ParamNames, + stub_ctx: Option<&Composite>, +) -> String { + let params: Vec<&str> = match param_names { + ParamNames::Aliases(aliases) => { + aliases.iter().filter(|a| a.node == index).map(|a| a.name.as_str()).collect() + } + ParamNames::Factory => factory.params().iter().map(|p| p.name.as_str()).collect(), + }; + // wired input slots: the distinct `.slot` values targeting this node across + // interior/root edges and external entries (roles or sources). let mut slots: Vec = Vec::new(); - for e in c.edges() { + for e in edges { if e.to == index && !slots.contains(&e.slot) { slots.push(e.slot); } } - for role in c.input_roles() { - for t in &role.targets { + for entry in entries { + for t in entry.targets { if t.node == index && !slots.contains(&t.slot) { slots.push(t.slot); } } } slots.sort_unstable(); - let stubs: Vec = if slots.len() > 1 { - fan_in_identifiers(c, index, &slots) - } else { - Vec::new() + let stubs: Vec = match stub_ctx { + Some(c) if slots.len() > 1 => fan_in_identifiers(c, index, &slots), + _ => Vec::new(), }; - let parts: Vec = match (params.is_empty(), stubs.is_empty()) { - (true, true) => return factory.label(), - (false, true) => vec![params.join(", ")], - (true, false) => vec![stubs.join(",")], - (false, false) => vec![params.join(", "), stubs.join(",")], + let inner: String = match (params.is_empty(), stubs.is_empty()) { + (true, true) => factory.label(), + (false, true) => format!("{}({})", factory.label(), params.join(", ")), + (true, false) => format!("{}({})", factory.label(), stubs.join(",")), + (false, false) => { + format!("{}({}; {})", factory.label(), params.join(", "), stubs.join(",")) + } }; - format!("{}({})", factory.label(), parts.join("; ")) + + // field prefixes: for each edge feeding this leaf from a multi-output producer, + // a ` → ` consumer-side prefix, in slot order for determinism. A no-op for + // both current corpora's single-output interiors (so `where:` is byte-stable). + let mut fed: Vec<(usize, String)> = Vec::new(); + for e in edges { + if e.to != index { + continue; + } + if let Some(field) = multi_output_field_name(nodes, e.from, e.from_field) { + fed.push((e.slot, field)); + } + } + fed.sort_by_key(|(slot, _)| *slot); + if fed.is_empty() { + return inner; + } + // a per-field ` → ` stack, then the (possibly enriched) label. + let prefix = fed.iter().map(|(_, f)| format!("{f} \u{2192} ")).collect::(); + format!("{prefix}{inner}") +} + +/// The driving field's *name* when producer `from` (in `nodes`) is a **multi-output** +/// producer feeding its field `from_field` into a consumer; `None` when single-output +/// (no prefix — keeps single-out graphs clean). Operates on a borrowed node slice, so +/// it serves both views uniformly (blueprint root and composite interior). +/// +/// - Composite producer: multi-output iff `output().len() > 1`; the name is +/// `output()[from_field].name` (the headline — macd's `output()[2] == "histogram"`). +/// - Leaf producer: pre-build field names are unavailable (#43), so a leaf's output +/// arity is unknown here. Treat `from_field > 0` as the only structurally-certain +/// multi-output signal and fall back to the field **index** as a string; this case +/// is absent in both current corpora and must never panic. +fn multi_output_field_name(nodes: &[BlueprintNode], from: usize, from_field: usize) -> Option { + match nodes.get(from)? { + BlueprintNode::Composite(c) => { + if c.output().len() > 1 { + Some( + c.output() + .get(from_field) + .map(|of| of.name.clone()) + .unwrap_or_else(|| from_field.to_string()), + ) + } else { + None + } + } + BlueprintNode::Leaf(_) => (from_field > 0).then(|| from_field.to_string()), + } } /// One `#…` identifier per wired slot of a fan-in leaf, in slot order. @@ -293,32 +435,26 @@ fn unique_prefix_from(sig: &str, base: usize, others: &[&String]) -> Option (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() { - 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(); - for e in c.edges() { - edges.push((e.from, e.to)); - } - for role in c.input_roles() { - let in_id = labels.len(); - labels.push(role.name.clone()); - for t in &role.targets { - edges.push((in_id, t.node)); - } - } - // 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 same shared graph core the main graph uses (#48); the composite-specific + // borders: param names from the alias overlay, entries from input roles, the + // output record folded as bindings, fan-in stub context available, and the typed + // signature as title. + let entries: Vec = c + .input_roles() + .iter() + .map(|role| Entry { name: role.name.clone(), targets: &role.targets }) + .collect(); + let title = signature(c); + render_graph( + c.nodes(), + c.edges(), + &entries, + c.output(), + ParamNames::Aliases(c.params()), + Some(c), + Some(&title), + color, + ) } /// The `name := ` (single) or `(n1, n2) := ` (tuple) binding prefix for interior @@ -326,8 +462,8 @@ fn render_definition(c: &Composite, color: Color) -> String { /// 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(); +fn output_binding(output: &[OutField], i: usize) -> Option { + let mut outs: Vec<&OutField> = output.iter().filter(|of| of.node == i).collect(); if outs.is_empty() { return None; } diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 7a20c26..29c6ce1 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -387,8 +387,10 @@ 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 as their bare-type nodes - for needle in ["[Exposure]", "[SimBroker]", "[Recorder]"] { + // 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]"] { assert!(out.contains(needle), "missing {needle}:\n{out}"); } // a definitions section is present @@ -500,11 +502,11 @@ mod tests { ┌───└─────┐ ↓ │ [sma_cross] │ - └┐ │ - ↓ │ - [Exposure] │ - ┌───┘────────│ - ↓ ↓ + │ │ + ↓ └──┐ + [Exposure(scale)] │ + ┌──┘─────────┐ │ + ↓ ↓──┘ [Recorder] [SimBroker] ┌─────┘ ↓