feat(aura-cli): blueprint view = main graph + composite definitions (#38)

Rewrite the `aura graph` blueprint view from ascii-dag subgraph cluster boxes to
a "program with subroutines" model: a flat main graph that wires the harness with
each composite shown as a single opaque node, plus a `where:` section that defines
each distinct composite type once (its interior with [in:k]/[out] port markers).
`render_compilat` (flat, fully-inlined, C23) is byte-for-byte unchanged.

Why (not effort):
- Correctness under width. ascii-dag 0.9.1's subgraph level-centering rounds
  sibling x-positions with `/2`, overlapping wide sibling labels (a
  width/parity-sensitive bug with no config/padding dodge that survives
  unequal-width siblings; verified by reproduction). The flat layout is
  collision-free; this view renders only flat graphs, so the bug cannot arise.
- Scale. A composite is one opaque node in the main graph, so blueprint size
  tracks top-level wiring, not inlined node count — real strategies stay
  displayable.
- Render-once. A composite reused N times has its body rendered once
  (collect_distinct_composites dedups by name(), recursively).
- Removes the nested-composite `unimplemented!` — the definitions pass recurses,
  so nested composites render as opaque nodes with their own definitions.

Mechanics: ItemDisplay/producer_id/consumer_ids are gone (a composite is now one
display node, so boundary fan-resolution collapses); new render_flat (the same
no-subgraph idiom render_compilat uses), collect_distinct_composites, and
render_definition. The now-unused `Target` import is dropped.

Divergence from the plan's literal text: the plan's `collect_distinct_composites`
used a nested `if let { if .. }`; this toolchain's clippy (collapsible_if, rust
1.94) rejects it under -D warnings, so it is the behaviour-identical let-chain form
`if let BlueprintNode::Composite(c) = item && !seen.contains(&c.name())`.

Tests: the two cluster-asserting blueprint tests are replaced by four behavioural
tests (opaque-node main graph; defines-each-composite-once; nested-renders-without-
panic; reused-composite-defined-once) plus a recaptured blueprint_view_golden
(captured verbatim from `aura graph`, not hand-authored). The four must-stay-green
tests pass; compiled_view_golden is byte-identical (render_compilat untouched).
Verified: `cargo test --workspace` all green, `cargo clippy --workspace
--all-targets -D warnings` clean.

The interactive enter/focus navigation counterpart is the playground's, tracked
separately as #37.

closes #38
This commit is contained in:
2026-06-07 22:25:48 +02:00
parent 870dfcec5b
commit cd31447d98
2 changed files with 204 additions and 132 deletions
+98 -101
View File
@@ -1,98 +1,38 @@
//! The `aura graph` ASCII-DAG adapter (#13): turns the engine's graph-as-data
//! (C9) into an `ascii_dag::Graph` rendered to a `String`. Two views:
//! `render_blueprint` draws composites as named cluster boxes (pre-inline);
//! `render_compilat` draws the flat post-inline graph (boundaries dissolved,
//! C23). Rendering reads structure + node `label()`s only — never `eval`.
//! 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 `[in:k]`/`[out]` 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/subgraph 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.
//! 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 aura_core::Node;
use aura_engine::{Blueprint, BlueprintNode, Edge, SourceSpec, Target};
use aura_engine::{Blueprint, BlueprintNode, Composite, Edge, SourceSpec};
/// How one top-level blueprint item maps into display nodes (blueprint view).
enum ItemDisplay {
/// A leaf is one display node at this id.
Leaf(usize),
/// A composite is a cluster of interior leaf display ids; its output port and
/// input roles resolve edges crossing its boundary.
Composite {
interior_ids: Vec<usize>,
output_interior: usize,
input_roles: Vec<Vec<Target>>,
},
}
/// The display id an edge *from* this item originates at (its producer).
fn producer_id(d: &ItemDisplay) -> usize {
match d {
ItemDisplay::Leaf(id) => *id,
ItemDisplay::Composite { interior_ids, output_interior, .. } => interior_ids[*output_interior],
}
}
/// The display id(s) an edge *into* this item at `slot` reaches (its consumers).
/// A composite input role fans into several interior targets.
fn consumer_ids(d: &ItemDisplay, slot: usize) -> Vec<usize> {
match d {
ItemDisplay::Leaf(id) => vec![*id],
ItemDisplay::Composite { interior_ids, input_roles, .. } => {
input_roles[slot].iter().map(|t| interior_ids[t.node]).collect()
}
}
}
/// Blueprint view: composites become labelled cluster boxes (pre-inline, C9).
/// 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) -> 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 sg_names: Vec<String> = Vec::new();
let mut memberships: Vec<(usize, Vec<usize>)> = Vec::new();
let mut edges: Vec<(usize, usize)> = Vec::new();
let mut item_display: Vec<ItemDisplay> = Vec::with_capacity(bp.nodes().len());
// pass 1: assign display ids + labels; open a subgraph per composite; record
// interior edges (the interior ids are in hand here).
let mut item_ids: Vec<usize> = Vec::with_capacity(bp.nodes().len());
for item in bp.nodes() {
match item {
BlueprintNode::Leaf(factory) => {
let id = labels.len();
labels.push(factory.label());
item_display.push(ItemDisplay::Leaf(id));
}
BlueprintNode::Composite(c) => {
let mut interior_ids = Vec::with_capacity(c.nodes().len());
for inner in c.nodes() {
match inner {
BlueprintNode::Leaf(factory) => {
let id = labels.len();
labels.push(factory.label());
interior_ids.push(id);
}
BlueprintNode::Composite(_) => unimplemented!(
"cycle 0013 renders leaf-interior composites (the built-in \
sample); nested-composite cluster rendering is a follow-up"
),
}
}
for e in c.edges() {
edges.push((interior_ids[e.from], interior_ids[e.to]));
}
let sg = sg_names.len();
sg_names.push(c.name().to_string());
memberships.push((sg, interior_ids.clone()));
item_display.push(ItemDisplay::Composite {
interior_ids,
output_interior: c.output().node,
input_roles: c.input_roles().to_vec(),
});
}
}
let id = labels.len();
labels.push(match item {
BlueprintNode::Leaf(factory) => factory.label(),
BlueprintNode::Composite(c) => c.name().to_string(),
});
item_ids.push(id);
}
// sources as producer display nodes (unnamed in the data model -> label by kind)
let mut source_ids: Vec<usize> = Vec::with_capacity(bp.sources().len());
for src in bp.sources() {
let id = labels.len();
@@ -100,37 +40,94 @@ pub fn render_blueprint(bp: &Blueprint) -> String {
source_ids.push(id);
}
// top-level edges, resolved through composite boundaries
// 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() {
let from = producer_id(&item_display[e.from]);
for to in consumer_ids(&item_display[e.to], e.slot) {
edges.push((from, to));
}
edges.push((item_ids[e.from], item_ids[e.to]));
}
// source -> target edges
for (src, &sid) in bp.sources().iter().zip(&source_ids) {
for t in &src.targets {
for to in consumer_ids(&item_display[t.node], t.slot) {
edges.push((sid, to));
}
edges.push((sid, item_ids[t.node]));
}
}
// build the borrowed-label Graph (labels + sg_names are final & owned)
let main = render_flat(&labels, &edges);
// 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)).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)]) -> String {
let mut g = Graph::with_mode(RenderMode::Vertical);
for (id, l) in labels.iter().enumerate() {
g.add_node(id, l);
}
let sg_ids: Vec<usize> = sg_names.iter().map(|n| g.add_subgraph(n)).collect();
for (sg, members) in &memberships {
g.put_nodes(members).inside(sg_ids[*sg]).expect("valid subgraph placement");
}
for (from, to) in edges {
for &(from, to) in edges {
g.add_edge(from, to, None);
}
g.render()
}
/// 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
}
/// Render one composite's interior as a flat graph: interior leaves as `[type]`,
/// nested composites as opaque `[name]`, plus an `[in:k]` entry marker per input
/// role (wired to its interior targets) and an `[out]` marker (wired from the
/// output port). Prefixed `"<name>:\n"`.
fn render_definition(c: &Composite) -> String {
let mut labels: Vec<String> = Vec::with_capacity(c.nodes().len());
for inner in c.nodes() {
labels.push(match inner {
BlueprintNode::Leaf(factory) => factory.label(),
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, targets) in c.input_roles().iter().enumerate() {
let in_id = labels.len();
labels.push(format!("in:{role}"));
for t in targets {
edges.push((in_id, t.node));
}
}
let out_id = labels.len();
labels.push("out".to_string());
edges.push((c.output().node, out_id));
format!("{}:\n{}", c.name(), render_flat(&labels, &edges))
}
/// 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]) -> String {