12b1bcec22
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
505 lines
22 KiB
Rust
505 lines
22 KiB
Rust
//! 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 `[<name>]` 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 `<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(|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::<Vec<_>>().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<String> = 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<String> = 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<String> = 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 `<field> → ` 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<usize> = 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<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()) {
|
|
(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 `<field> → ` 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 `<field> → ` stack, then the (possibly enriched) label.
|
|
let prefix = fed.iter().map(|(_, f)| format!("{f} \u{2192} ")).collect::<String>();
|
|
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<String> {
|
|
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<String> {
|
|
// 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<String> {
|
|
if others.iter().any(|o| o.as_str() == sig) {
|
|
return None;
|
|
}
|
|
let chars: Vec<char> = 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 `[<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 {
|
|
// 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<Entry> = 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<String> {
|
|
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<dyn Node>` labels itself; node display id = node index.
|
|
pub fn render_compilat(
|
|
nodes: &[Box<dyn Node>],
|
|
sources: &[SourceSpec],
|
|
edges: &[Edge],
|
|
color: Color,
|
|
) -> String {
|
|
let mut labels: Vec<String> = 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)
|
|
}
|