69d20949c0
Collapse the two correctness-neutral debt items the 0021 fan-in pre-pass left behind (architect drift review at cycle close). Alias-validity: the `BadInteriorIndex` check was raised in two places — the structural pre-pass `check_composite_fan_in` and `inline_composite`. Since `compile_with_params` runs the pre-pass over every composite before any lowering, `inline_composite`'s copy was dead for that error path. The check now lives in one helper `check_alias_indices`, called solely by the pre-pass; `inline_composite` drops its copy (and the now-unused `param_aliases` binding — the overlay is a pure naming layer, unused in lowering, which is driven by the injected scalar vector). Alias-count: the per-node `a.node == node` predicate was re-derived in three spots (signature base, the unaliased-param test, the CLI render base-length). All three now route through one exported anchor `aliases_on`, so an alias-semantics change touches one site. Behaviour-preserving: the full workspace suite is the guard (15+6 cli, 25 core, 69 engine, 6+1+1 ingest/misc, 29 std — 0 failed), `out_of_range_param_alias_rejected` still green (now raised by the pre-pass), clippy -D warnings clean. No ledger change (the C9 refinement landed in 0021). closes #45
339 lines
14 KiB
Rust
339 lines
14 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::{LeafFactory, Node, ScalarKind};
|
|
use aura_engine::{aliases_on, signature_of, Blueprint, BlueprintNode, Composite, Edge, SourceSpec};
|
|
|
|
/// 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: &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<String> = Vec::new();
|
|
let mut item_ids: Vec<usize> = 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<usize> = 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);
|
|
|
|
// 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}")
|
|
}
|
|
|
|
/// 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: &Blueprint) -> 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::Leaf(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(", "))
|
|
}
|
|
|
|
/// 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();
|
|
|
|
let mut slots: Vec<usize> = Vec::new();
|
|
for e in c.edges() {
|
|
if e.to == index && !slots.contains(&e.slot) {
|
|
slots.push(e.slot);
|
|
}
|
|
}
|
|
for role in c.input_roles() {
|
|
for t in &role.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(c, index, &slots)
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
let parts: Vec<String> = 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(",")],
|
|
};
|
|
format!("{}({})", factory.label(), parts.join("; "))
|
|
}
|
|
|
|
/// 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::Leaf(_) => 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]`, an `[<name>]`
|
|
/// entry marker per input role (wired to its interior targets), and an `[<name>]`
|
|
/// node per re-exported output field (wired from its producer). 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 {
|
|
let mut labels: Vec<String> = Vec::with_capacity(c.nodes().len());
|
|
for (i, inner) in c.nodes().iter().enumerate() {
|
|
labels.push(match inner {
|
|
BlueprintNode::Leaf(factory) => leaf_label(c, i, factory),
|
|
BlueprintNode::Composite(inner_c) => inner_c.name().to_string(),
|
|
});
|
|
}
|
|
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));
|
|
}
|
|
}
|
|
for of in c.output() {
|
|
let out_id = labels.len();
|
|
labels.push(of.name.clone());
|
|
edges.push((of.node, out_id));
|
|
}
|
|
|
|
format!("{}:\n\n{}", signature(c), render_flat(&labels, &edges, color))
|
|
}
|
|
|
|
/// 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)
|
|
}
|