From 870dfcec5b332f9781b3eabb7c67ff68ba4a1d7b Mon Sep 17 00:00:00 2001 From: Brummel Date: Sun, 7 Jun 2026 22:20:28 +0200 Subject: [PATCH] plan: 0017 blueprint view as main graph + composite definitions Two tasks, CLI-render-only (graph.rs + main.rs tests): - Task 1: rewrite render_blueprint to the main-graph + where:-definitions model (composite = opaque node; collect_distinct_composites recursive/deduped by name; render_definition with [in:k]/[out] port markers); drop ItemDisplay/producer_id/ consumer_ids and the nested-composite unimplemented!; behavioural tests RED->GREEN (opaque-node, defines-once, nested-no-panic, reuse-defined-once). - Task 2: recapture the blueprint golden from `aura graph` stdout; full-workspace test + clippy gate. render_compilat untouched (its golden stays byte-identical). refs #38 --- docs/plans/0017-blueprint-view-definitions.md | 384 ++++++++++++++++++ 1 file changed, 384 insertions(+) create mode 100644 docs/plans/0017-blueprint-view-definitions.md diff --git a/docs/plans/0017-blueprint-view-definitions.md b/docs/plans/0017-blueprint-view-definitions.md new file mode 100644 index 0000000..4465d4e --- /dev/null +++ b/docs/plans/0017-blueprint-view-definitions.md @@ -0,0 +1,384 @@ +# Blueprint view as main graph + composite definitions — Implementation Plan + +> **Parent spec:** `docs/specs/0017-blueprint-view-definitions.md` +> +> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run +> this plan. Steps use `- [ ]` checkboxes for tracking. + +**Goal:** Rewrite the `aura graph` blueprint view (`render_blueprint`) to a main +graph (composites as opaque nodes) + a `where:` definitions section, on a flat +ascii-dag layout only, and re-point its tests. + +**Architecture:** `render_blueprint` builds a flat main graph (one node per +top-level item — a composite labelled by `name()`, opaque) plus a definitions +block where each distinct composite type (deduped by `name()`, collected +recursively) renders its interior once with `[in:k]`/`[out]` port markers. The +old `ItemDisplay`/`producer_id`/`consumer_ids`/subgraph machinery and the +nested-composite `unimplemented!` are removed. `render_compilat` is untouched. + +**Tech Stack:** `crates/aura-cli/src/graph.rs` (render), `crates/aura-cli/src/main.rs` +(tests), `ascii-dag` flat `RenderMode::Vertical`, the `aura_engine` +Blueprint/Composite API. + +--- + +**Files this plan creates or modifies:** + +- Modify: `crates/aura-cli/src/graph.rs:1-132` — module doc-comment (1-10), drop + `ItemDisplay`/`producer_id`/`consumer_ids` (16-46), rewrite `render_blueprint` + (48-132) incl. removing the `unimplemented!` (74-77); add `render_flat`, + `collect_distinct_composites`, `render_definition`; fix imports (12-14). +- Modify: `crates/aura-cli/src/main.rs:184` — stale "clustered blueprint view" + comment in the `graph` arm. +- Test: `crates/aura-cli/src/main.rs:215-227,256-286` — replace the two old + blueprint tests; add `blueprint_view_main_graph_shows_composite_as_opaque_node`, + `blueprint_view_defines_each_composite_once`, + `nested_composite_renders_without_panic`, `reused_composite_defined_once`, and a + recaptured `blueprint_view_golden`. + +--- + +### Task 1: Rewrite `render_blueprint` + behavioural tests (RED → GREEN) + +**Files:** +- Modify: `crates/aura-cli/src/graph.rs:1-132` +- Test: `crates/aura-cli/src/main.rs:215-227,256-286` (+ new tests) + +- [ ] **Step 1: Replace the two old blueprint tests with the four behavioural tests** + +In `crates/aura-cli/src/main.rs`, DELETE the test +`blueprint_view_shows_cluster_and_param_generic_labels` (currently lines 215-227) +and the test `blueprint_view_golden` (currently lines 256-286). In their place put +the four behavioural tests below (the recaptured golden is added in Task 2). Insert +them in the `#[cfg(test)] mod tests` block (e.g. where the old blueprint tests were): + +```rust + #[test] + fn blueprint_view_main_graph_shows_composite_as_opaque_node() { + let out = graph::render_blueprint(&sample_blueprint()); + // 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}"); + } +``` + +These tests need `Composite`, `OutPort`, `BlueprintNode`, `Blueprint`, `Edge`, +`SourceSpec`, `Target`, `ScalarKind`, `Sma`, `Sub`, `Exposure` in scope. They are +already imported at the top of `main.rs` (lines 11-16: `aura_core::{... ScalarKind ...}`, +`aura_engine::{... Blueprint, BlueprintNode, Composite, Edge, ... OutPort, ... +SourceSpec, Target}`, `aura_std::{Exposure, Recorder, SimBroker, Sma, Sub}`). No +new imports needed in `main.rs`. + +- [ ] **Step 2: Run the new tests to verify they fail (RED)** + +Run: `cargo test -p aura-cli` +Expected: FAIL — the four new tests fail (the opaque-node / `where:` / definition +assertions fail against the current cluster render, and +`nested_composite_renders_without_panic` panics at the +`unimplemented!("...nested-composite cluster rendering...")`). The four +must-stay-green tests still pass; the build itself is clean (this is a behavioural +RED, not a compile error). + +- [ ] **Step 3: Rewrite `graph.rs` — imports + module doc** + +In `crates/aura-cli/src/graph.rs`, replace the module doc-comment (lines 1-10) and +imports (lines 12-14) with: + +```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 `[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 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, Composite, Edge, SourceSpec}; +``` + +(`Composite` is added — named by the new helpers; `Target` is dropped — no longer +named after the `ItemDisplay` collapse. `Edge`/`SourceSpec`/`Node` remain: they are +`render_compilat`'s parameter types.) + +- [ ] **Step 4: Rewrite `graph.rs` — replace `ItemDisplay`/`producer_id`/`consumer_ids`/`render_blueprint` (old lines 16-132)** + +Delete the `ItemDisplay` enum, `producer_id`, `consumer_ids`, and the old +`render_blueprint` body (old lines 16-132) and replace with: + +```rust +/// 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 = Vec::new(); + let mut item_ids: Vec = 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 = 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); + + // 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::>().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); + } + 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 { + if !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 `":\n"`. +fn render_definition(c: &Composite) -> String { + let mut labels: Vec = 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)) +} +``` + +- [ ] **Step 5: Build (compile gate — `render_blueprint`'s signature is unchanged, so `main()` still compiles)** + +Run: `cargo build -p aura-cli --all-targets` +Expected: compiles, 0 errors. (If clippy flags an unused `Target` import, it was +not dropped in Step 3 — drop it.) + +- [ ] **Step 6: Run the package suite to verify the new tests pass (GREEN)** + +Run: `cargo test -p aura-cli` +Expected: PASS — the four new tests now pass, and the four must-stay-green tests +still pass. (`blueprint_view_golden` is not present yet; it is added in Task 2.) + +--- + +### Task 2: Recapture the blueprint golden + full-suite gate + +**Files:** +- Test: `crates/aura-cli/src/main.rs` (re-add `blueprint_view_golden`); comment at + `main.rs:184`. + +- [ ] **Step 1: Capture the exact rendered bytes** + +Run: `cargo run -q -p aura-cli -- graph` +Expected: the new flat main-graph + `where: sma_cross: ...` render prints to +stdout (deterministic Sugiyama layout, no RNG). Copy the **verbatim** stdout +(including the trailing blank lines `render()` emits) for the golden below. + +- [ ] **Step 2: Add the recaptured `blueprint_view_golden` test** + +In `crates/aura-cli/src/main.rs`, in the `#[cfg(test)] mod tests` block, add: + +```rust + #[test] + 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 — main graph (composites opaque) + the + // `where:` definitions section. Re-capture via `aura graph` if intended. + let expected = r#""#; + assert_eq!(out, expected, "blueprint render drifted; re-capture if intended"); + } +``` + +Replace `` with the exact bytes captured in +Step 1 (raw string `r#"..."#`; preserve every space, box glyph, arrow, and the +trailing newlines). Do not hand-edit the captured bytes. + +- [ ] **Step 3: Fix the stale comment in the `graph` arm** + +In `crates/aura-cli/src/main.rs`, in `main()`'s `Some("graph")` arm (around line +184), update the comment that calls the default view the "clustered blueprint +view": + +Replace: +```rust + // `--compiled` selects the flat post-inline view; default is the + // clustered blueprint view. Strictness beyond this stays minimal (#16). +``` +with: +```rust + // `--compiled` selects the flat post-inline view; default is the + // blueprint view (main graph + composite definitions). Strictness + // beyond this stays minimal (#16). +``` + +- [ ] **Step 4: Run the package suite to verify the golden passes** + +Run: `cargo test -p aura-cli` +Expected: PASS — all aura-cli tests including the new `blueprint_view_golden`. If +the golden fails, the pasted bytes do not match — re-capture via Step 1 and +re-paste; do not hand-edit. + +- [ ] **Step 5: Full workspace gate — tests + lint** + +Run: `cargo test --workspace` +Expected: PASS, 0 failed. In particular the four must-stay-green tests +(`compiled_view_dissolves_the_composite_boundary`, `compiled_view_golden`, +`swapped_param_vector_changes_the_compiled_render`, +`run_sample_is_deterministic_and_non_trivial`) remain green — `render_compilat` is +untouched, so `compiled_view_golden` is byte-identical. + +Run: `cargo clippy --workspace --all-targets -- -D warnings` +Expected: 0 warnings (no unused `Target` import, no dead `ItemDisplay`/`producer_id`/ +`consumer_ids`).