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:
+98
-101
@@ -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
@@ -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]
|
||||
|
||||
|
||||
"#;
|
||||
|
||||
Reference in New Issue
Block a user