//! The `aura graph` ASCII-DAG adapter (#13, #38): turns the engine's //! graph-as-data (C9) into an `ascii_dag::Graph` rendered to a `String`. Two //! views. `render_blueprint` shows the authored structure — a flat main graph //! wiring the harness with each composite as a single opaque node, plus a //! `where:` section that defines each distinct composite type once (its interior //! with `[]` input/output port markers). `render_compilat` shows the flat //! post-inline graph (boundaries dissolved, C23). Rendering reads structure + //! node `label()`s only — never `eval`. //! //! ascii-dag borrows its node labels as `&'a str`, so each function first //! materializes the owned label `String`s (which outlive the `Graph`), then //! borrows into them. `RenderMode::Vertical` is mandatory: Horizontal collapses a //! fan-out onto one path. Both views build flat graphs (no subgraphs): the //! subgraph layout mis-centres wide sibling labels, the flat layout does not. use ascii_dag::graph::{Graph, RenderMode}; use ascii_dag::render::colors::Palette; use aura_core::{Node, PrimitiveBuilder, ScalarKind}; use aura_engine::{ aliases_on, signature_of, 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 **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, 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 /// CLI selects `Ansi` only when stdout is a TTY; tests always render `Plain`. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum Color { Plain, Ansi, } /// Blueprint view: the authored structure (#38). A flat main graph wires the /// 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: &Composite, color: Color) -> String { // the main graph is the shared graph core (`render_graph`) over the root // 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(|role| role.source.is_some()) .map(|role| Entry { name: role.name.clone(), targets: &role.targets }) .collect(); let main = render_graph( bp.nodes(), bp.edges(), &entries, &[], // no output bindings: a blueprint's terminals are sinks ParamNames::Factory, bp, // the root IS the fan-in stub context (it carries roles + edges) None, // no title color, ); // definitions: each distinct composite type, once, recursively. let defs = collect_distinct_composites(bp); if defs.is_empty() { return main; } let body = defs.iter().map(|c| render_definition(c, color)).collect::>().join("\n"); 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` (`PrimitiveBuilder` 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 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], edges: &[Edge], entries: &[Entry], output: &[OutField], param_names: ParamNames, stub_ctx: &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::Primitive(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 { let mut g = Graph::with_mode(RenderMode::Vertical); for (id, l) in labels.iter().enumerate() { g.add_node(id, l); } for &(from, to) in edges { g.add_edge(from, to, None); } match color { Color::Plain => g.render(), // colour lives in the layout IR's scanline pass; node labels stay default // colour (ascii-dag only colours edges), so the box text is unaffected. Color::Ansi => g.compute_layout().render_scanline_colored(Palette::Ansi), } } /// Every distinct composite type in the blueprint, in first-seen order, keyed by /// `name()` (the authoring type identity — same name implies same structure). /// Recurses into a composite's interior on first sight so nested composites get /// their own definition; a later same-name occurrence is skipped (deduped). fn collect_distinct_composites(bp: &Composite) -> Vec<&Composite> { fn walk<'a>(items: &'a [BlueprintNode], seen: &mut Vec<&'a str>, out: &mut Vec<&'a Composite>) { for item in items { if let BlueprintNode::Composite(c) = item && !seen.contains(&c.name()) { seen.push(c.name()); out.push(c); walk(c.nodes(), seen, out); } } } let mut seen: Vec<&str> = Vec::new(); let mut out: Vec<&Composite> = Vec::new(); walk(bp.nodes(), &mut seen, &mut out); out } /// `ScalarKind` as a lowercase type string for a signature (`i64`/`f64`/`bool`/ /// `timestamp`). The derived `Debug` gives PascalCase (`I64`), so this is explicit. fn kind_str(kind: ScalarKind) -> &'static str { match kind { ScalarKind::I64 => "i64", ScalarKind::F64 => "f64", ScalarKind::Bool => "bool", ScalarKind::Timestamp => "timestamp", } } /// The composite's typed signature for the definition title: /// `name(p1:kind, …) -> (o1, …)`. Param kinds come from the aliased interior leaf's /// declared params; output **names only** (kinds need a pre-build factory interface, /// #43). An empty alias list renders `name()`. Total: a malformed alias falls back /// to `?` rather than panicking (compile is the validator, #41). fn signature(c: &Composite) -> String { let params: Vec = c .params() .iter() .map(|a| { let kind = c .nodes() .get(a.node) .and_then(|n| match n { BlueprintNode::Primitive(f) => f.params().get(a.slot).map(|p| kind_str(p.kind)), BlueprintNode::Composite(_) => None, }) .unwrap_or("?"); format!("{}:{}", a.name, kind) }) .collect(); let outs: Vec = c.output().iter().map(|of| of.name.clone()).collect(); format!("{}({}) -> ({})", c.name(), params.join(", "), outs.join(", ")) } /// 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. 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 /// the three enrichments apply. fn leaf_label( nodes: &[BlueprintNode], edges: &[Edge], entries: &[Entry], index: usize, factory: &PrimitiveBuilder, param_names: &ParamNames, stub_ctx: &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 edges { if e.to == index && !slots.contains(&e.slot) { slots.push(e.slot); } } 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(stub_ctx, index, &slots) } else { Vec::new() }; 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(",")) } }; // 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::Primitive(_) => (from_field > 0).then(|| from_field.to_string()), } } /// One `#…` identifier per wired slot of a fan-in leaf, in slot order. /// - A role-fed slot uses the role name verbatim (`#price`), never shortened. /// - An interior-fed slot uses its source signature, never shorter than the /// source's **base** (type initial + alias initials), extended into the /// recursive tail only as far as needed to be unique among the siblings. /// - Two siblings with equal full signatures (interchangeable inputs) cannot be /// separated — those slots fall back to the positional letter `#A`. The engine /// constraint guarantees no configuration-distinct pair fully collides, so a /// valid blueprint reaches the fallback only for genuinely-interchangeable /// inputs. fn fan_in_identifiers(c: &Composite, index: usize, slots: &[usize]) -> Vec { // per slot: (slot, signature, base_len, is_role) let srcs: Vec<(usize, String, usize, bool)> = slots .iter() .map(|&slot| { let (sig, base, is_role) = slot_source(c, index, slot); (slot, sig, base, is_role) }) .collect(); srcs.iter() .map(|(slot, sig, base, is_role)| { if *is_role { return format!("#{sig}"); // role name verbatim } let others: Vec<&String> = srcs.iter().filter(|(s, _, _, _)| s != slot).map(|(_, x, _, _)| x).collect(); match unique_prefix_from(sig, *base, &others) { Some(p) => format!("#{p}"), None => format!("#{}", (b'A' + *slot as u8) as char), // interchangeable fallback } }) .collect() } /// The source feeding `(index, slot)`: `(signature, base_len, is_role)`. A role /// returns its name verbatim with `is_role = true` (base_len unused); an interior /// producer returns its `signature_of` and its base length (type initial + alias /// initials). fn slot_source(c: &Composite, index: usize, slot: usize) -> (String, usize, bool) { for e in c.edges() { if e.to == index && e.slot == slot { let sig = signature_of(c.nodes(), c.edges(), c.input_roles(), c.params(), e.from); return (sig, signature_base_len(c, e.from), false); } } for r in c.input_roles() { if r.targets.iter().any(|t| t.node == index && t.slot == slot) { return (r.name.clone(), 0, true); } } (String::new(), 0, false) } /// The base length of an interior node's signature: 1 (type / composite-name /// initial) plus one per declared param alias on that node — the minimum the /// rendered identifier never goes below. fn signature_base_len(c: &Composite, node: usize) -> usize { match &c.nodes()[node] { BlueprintNode::Primitive(_) => 1 + aliases_on(c.params(), node).count(), BlueprintNode::Composite(_) => 1, } } /// The shortest prefix of `sig` of length ≥ `base` that no `other` starts with — /// i.e. distinguishes `sig` from all siblings while never dropping below the /// base. `None` when some `other` equals `sig` in full (inseparable — /// interchangeable, caller uses the positional fallback). fn unique_prefix_from(sig: &str, base: usize, others: &[&String]) -> Option { if others.iter().any(|o| o.as_str() == sig) { return None; } let chars: Vec = sig.chars().collect(); if chars.is_empty() { return Some(String::new()); // degenerate (no producer); not reached for a wired slot } let start = base.max(1).min(chars.len()); for len in start..=chars.len() { let prefix: String = chars[..len].iter().collect(); if others.iter().all(|o| !o.starts_with(&prefix)) { return Some(prefix); } } Some(sig.to_string()) } /// 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]`, and an `[]` /// 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 { // 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()), c, Some(&title), 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(output: &[OutField], i: usize) -> Option { let mut outs: Vec<&OutField> = 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` labels itself; node display id = node index. pub fn render_compilat( nodes: &[Box], sources: &[SourceSpec], edges: &[Edge], color: Color, ) -> String { let mut labels: Vec = nodes.iter().map(|n| n.label()).collect(); let source_base = labels.len(); for src in sources { labels.push(format!("source:{:?}", src.kind)); } let mut edge_pairs: Vec<(usize, usize)> = edges.iter().map(|e| (e.from, e.to)).collect(); for (i, src) in sources.iter().enumerate() { for t in &src.targets { edge_pairs.push((source_base + i, t.node)); } } render_flat(&labels, &edge_pairs, color) }