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 {
+106 -31
View File
@@ -181,7 +181,8 @@ fn main() {
Some("run") if args.next().is_none() => println!("{}", run_sample().to_json()),
Some("graph") => {
// `--compiled` selects the flat post-inline view; default is the
// clustered blueprint view. Strictness beyond this stays minimal (#16).
// blueprint view (main graph + composite definitions). Strictness
// beyond this stays minimal (#16).
let compiled = args.next().as_deref() == Some("--compiled");
let bp = sample_blueprint();
let out = if compiled {
@@ -213,17 +214,82 @@ mod tests {
}
#[test]
fn blueprint_view_shows_cluster_and_param_generic_labels() {
fn blueprint_view_main_graph_shows_composite_as_opaque_node() {
let out = graph::render_blueprint(&sample_blueprint());
// the composite renders as a named cluster box
assert!(out.contains("sma_cross"), "missing composite name:\n{out}");
// the value-empty blueprint view labels leaves param-generically by bare
// type (no values, and no knob suffix — the ascii-dag layout cannot render
// wide cluster-sibling labels); both SMAs render identically as [SMA]
assert!(out.contains("[SMA]"), "missing [SMA]:\n{out}");
for needle in ["Sub", "Exposure", "SimBroker", "Recorder"] {
// 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]"] {
assert!(out.contains(needle), "missing {needle}:\n{out}");
}
// a definitions section is present
assert!(out.contains("where:"), "missing where: section:\n{out}");
// the flat layout draws no subgraph cluster box
assert!(!out.contains('╔'), "blueprint view must not draw a cluster box:\n{out}");
}
#[test]
fn blueprint_view_defines_each_composite_once() {
let out = graph::render_blueprint(&sample_blueprint());
// the sma_cross body is defined exactly once, with its interior + ports
assert_eq!(out.matches("sma_cross:").count(), 1, "definition not rendered once:\n{out}");
for needle in ["[SMA]", "[Sub]", "[in:0]", "[out]"] {
assert!(out.contains(needle), "missing {needle} in definition:\n{out}");
}
}
#[test]
fn nested_composite_renders_without_panic() {
// a composite whose interior contains another composite — render reads
// structure only (no compile/validate), so a minimal fixture suffices.
let inner = Composite::new(
"inner",
vec![Sma::factory().into()],
vec![],
vec![vec![Target { node: 0, slot: 0 }]],
OutPort { node: 0, field: 0 },
);
let outer = Composite::new(
"outer",
vec![BlueprintNode::Composite(inner), Sub::factory().into()],
vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }],
vec![vec![Target { node: 0, slot: 0 }]],
OutPort { node: 1, field: 0 },
);
let bp = Blueprint::new(
vec![BlueprintNode::Composite(outer)],
vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }],
vec![],
);
let out = graph::render_blueprint(&bp); // must not panic (no unimplemented!)
// outer shows the inner composite as an opaque node, and both get a definition
assert!(out.contains("[outer]"), "missing opaque outer node:\n{out}");
assert!(out.contains("[inner]"), "inner must be opaque inside outer's definition:\n{out}");
assert_eq!(out.matches("outer:").count(), 1, "outer defined once:\n{out}");
assert_eq!(out.matches("inner:").count(), 1, "inner defined once:\n{out}");
}
#[test]
fn reused_composite_defined_once() {
// the same composite type used twice: two opaque nodes, one definition.
let bp = Blueprint::new(
vec![
BlueprintNode::Composite(sma_cross("dup")),
BlueprintNode::Composite(sma_cross("dup")),
Exposure::factory().into(),
],
vec![SourceSpec {
kind: ScalarKind::F64,
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
}],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 0, from_field: 0 },
],
);
let out = graph::render_blueprint(&bp);
assert_eq!(out.matches("[dup]").count(), 2, "two opaque uses expected:\n{out}");
assert_eq!(out.matches("dup:").count(), 1, "body defined once:\n{out}");
}
#[test]
@@ -257,28 +323,37 @@ mod tests {
fn blueprint_view_golden() {
let out = graph::render_blueprint(&sample_blueprint());
// ascii-dag's Sugiyama layout is deterministic (no RNG); these are the
// exact bytes `aura graph` emits (render() ends in three newlines).
let expected = r#" [source:F64]
┌───────└───────┐
│ │ │
╔═══╪═══════╪═══╗
sma_cross │ ║
║ ↓
║ [SMA] [SMA] ║
└────┌──┘ ║
║ ↓ ║┌──┘
║ [Sub] ║│
║ │ ║│
╚════════╪══════╝│
│ │
↓ │
[Exposure] │
┌───┘───────┼┐
↓ └↓
[Recorder] [SimBroker]
[Recorder]
// exact bytes `aura graph` emits — main graph (composites opaque) + the
// `where:` definitions section. Re-capture via `aura graph` if intended.
let expected = r#" [source:F64]
┌───└─────┐
[sma_cross]
└┐
[Exposure]
┌───┘────────│
↓ ↓
[Recorder] [SimBroker]
┌─────┘
[Recorder]
where:
sma_cross:
[in:0]
┌───└───┐
↓ ↓
[SMA] [SMA]
└───┌───┘
[Sub]
[out]
"#;