# Blueprint view as main graph + composite definitions — Design Spec **Date:** 2026-06-07 **Status:** Draft — awaiting user spec review **Authors:** orchestrator + Claude ## Goal Redesign the `aura graph` **blueprint view** (`render_blueprint` in `crates/aura-cli/src/graph.rs`) from the current "composites as ascii-dag subgraph cluster boxes, interiors expanded inline" model to a **program-with-subroutines** model: a small **main graph** that wires the harness with each composite shown as a single opaque node `[name]`, plus a **definitions section** that renders each *distinct* composite type's interior exactly once. This is the faithful representation of the blueprint as *authored structure* (C9/C12: a composite is an authoring-level node), distinct from the compiled view which is the *inlined machine form* (C23). It scales to real strategies, renders a reused composite's body once, removes the current nested-composite `unimplemented!`, and renders on ascii-dag's **flat** layout only — sidestepping the cluster-layout bug at the root. The **compiled view** (`render_compilat`, flat fully-inlined, C23) is unchanged, byte-for-byte. ## Architecture Two ontological tiers map onto two render shapes: | View | What it shows | Mechanism | |------|---------------|-----------| | Blueprint (`render_blueprint`) | authored source: harness wiring + composites as named subroutines, each body rendered once | main flat graph (composite = opaque node) + a `where:` block of per-type definitions | | Compiled (`render_compilat`) | inlined machine form, boundaries dissolved (C23) | one flat graph (unchanged) | The redesign is **CLI-render-only**. No change to the engine, the Node/Blueprint data model, or `render_compilat`. The blueprint data model already carries everything needed (`Composite::name/nodes/edges/input_roles/output`, `Blueprint::nodes/sources/edges`). **Why the model (substance, not effort):** 1. **Correctness under width.** ascii-dag 0.9.1's subgraph level-centering rounds sibling x-positions with an integer `/2`; wide sibling labels inside a cluster overlap (e.g. `[SimpleMovingAverage(length)[SimpleMovingAverage(length)]`). The garble is width/parity-sensitive and has no config or padding dodge that survives unequal-width siblings (`Positioning` exposes only `Compact`; `Barycentric`/`BrandesKopf` are unimplemented stubs; `render()` takes no config). The **flat** layout reserves `width + 3` per node and is collision-free at all widths. The new model renders only flat graphs, so the bug cannot arise. 2. **Scale.** The blueprint is authored structure, not the compilat. A composite with 50 interior nodes is **one** node in the main graph; blueprint size tracks top-level wiring, not inlined node count. Real strategies stay displayable. 3. **Render-once.** A composite reused N times appears N times as an opaque node in the main graph, but its body is rendered exactly **once** in the definitions section (deduplicated by composite type). These satisfy aura's feature-acceptance criterion: the intended audience (a human or LLM inspecting an authored strategy) naturally reaches for a structural view that does not explode with strategy size; it removes a redundancy (a reused body drawn N times) and a latent failure (the cluster garble); and it reintroduces no look-ahead / determinism / scalar-typing violation (it is a read-only structural render, no `eval`). ## Concrete code shapes ### Worked example — what `aura graph` emits (the acceptance evidence) For the built-in sample (`build_sample()` in `crates/aura-cli/src/main.rs`): the `sma_cross` composite (two `SMA` leaves → `Sub`) wired into `Exposure → SimBroker → Recorder`, with the price source feeding the composite and the broker, and exposure also tapped to a second `Recorder`. The new blueprint view (real ascii-dag flat renders; final byte-exact goldens captured at implementation — the assembly format below is the shape): ```text [source:F64] ┌───└─────┐ ↓ │ [sma_cross] │ └┐ │ ↓ │ [Exposure] │ ┌───┘────────│ ↓ ↓ [Recorder] [SimBroker] ┌─────┘ ↓ [Recorder] where: sma_cross: [in:0] ┌───└───┐ ↓ ↓ [SMA] [SMA] └───┌───┘ ↓ [Sub] │ ↓ [out] ``` The **main graph** shows `sma_cross` as a single opaque node; its body appears once under `where:`. A 50-node composite, or one reused three times, leaves the main graph the same size. Both `SMA` leaves render identically as `[SMA]` (bare type; values appear only in the compiled view, C22/#31). ### Before → after: `render_blueprint` The load-bearing change is that a **composite maps to one display node**, not a cluster of interior ids, and a second pass emits the definitions. `ItemDisplay` — a composite is now one node, not a set of interior ids: ```rust // before: a composite carried its interior display ids + boundary-resolution data enum ItemDisplay { Leaf(usize), Composite { interior_ids: Vec, output_interior: usize, input_roles: Vec> }, } // after: a composite is a single opaque main-graph node enum ItemDisplay { Leaf(usize), Composite(usize), // the one opaque display id } ``` Boundary resolution collapses — every edge touching a composite attaches to its single node, regardless of slot: ```rust fn producer_id(d: &ItemDisplay) -> usize { match d { ItemDisplay::Leaf(id) | ItemDisplay::Composite(id) => *id } } // a composite's input slots all land on the one opaque node fn consumer_ids(d: &ItemDisplay, _slot: usize) -> Vec { match d { ItemDisplay::Leaf(id) | ItemDisplay::Composite(id) => vec![*id] } } ``` `render_blueprint` becomes "render main graph (flat, composites opaque) + render each distinct composite definition once": ```rust pub fn render_blueprint(bp: &Blueprint) -> String { // pass 1: one display node per top-level item (composite label = its name), // plus source nodes; collect distinct composites by name in first-seen // order for the definitions section. // pass 2: top-level + source edges, attaching to opaque composite nodes. let main = render_flat(/* main-graph nodes + edges, NO subgraphs */); // definitions: each distinct composite type once, recursively. A composite // nested inside another's interior is an opaque node there and queues its own // definition (dedup by name across the whole blueprint). let mut defs = collect_distinct_composites(bp); // ordered, by name() let body = defs.iter().map(render_definition).collect::>().join("\n"); if defs.is_empty() { main } else { format!("{main}\nwhere:\n\n{body}") } } ``` `render_definition` renders one composite's interior as a flat graph with port markers — input roles as entry nodes `[in:k]`, the output port as `[out]`: ```rust fn render_definition(c: &Composite) -> String { // interior leaves -> [type] nodes; nested composites -> opaque [name] nodes // (queued for their own definition). interior edges as-is. // role r -> add an [in:r] node with edges to each interior target in input_roles()[r]. // output -> add an [out] node with an edge from the output interior node. // flat ascii-dag render, prefixed with ":\n". } ``` ## Components - **`render_blueprint(bp) -> String`** (rewritten). Produces `main` + optional `where:` block. Uses only flat ascii-dag graphs. - **`render_flat(labels, edges) -> String`** (new internal helper, or inline). A thin wrapper over the existing flat-graph build (`Graph::with_mode(Vertical)`, `add_node`, `add_edge`, `render`) — exactly what `render_compilat` already does, with **no** `add_subgraph` / `put_nodes`. - **`collect_distinct_composites(bp) -> Vec<&Composite>`** (new). Walks top-level items and, recursively, composite interiors; returns distinct composites in first-seen order, keyed by `name()`. Recursion is what removes the `unimplemented!`. - **`render_definition(c) -> String`** (new). One composite interior → flat graph with `[in:k]` / `[out]` port markers, prefixed `":\n"`. - **`ItemDisplay`**, **`producer_id`**, **`consumer_ids`** (simplified as above). - **`render_compilat`** — untouched. ## Data flow `render_blueprint(bp)`: 1. **Main graph.** For each `bp.nodes()` item: a leaf → one node labelled `factory.label()`; a composite → one node labelled `c.name()` (opaque). Each `bp.sources()` → one `source:{kind}` node. Edges: `bp.edges()` and source targets, resolved through `producer_id`/`consumer_ids` (composites attach at their single node). Render flat → `main`. 2. **Distinct composites.** Traverse `bp.nodes()` and every composite's `c.nodes()` recursively; record each composite the first time its `name()` is seen (later same-name occurrences are skipped — dedup). Order = first-seen. 3. **Definitions.** For each distinct composite, `render_definition`: interior leaves → `[type]`, interior nested composites → opaque `[name]` (already queued in step 2), interior edges from `c.edges()`; add `[in:k]` nodes wired to `c.input_roles()[k]` targets, an `[out]` node wired from `c.output().node`. Render flat, prefix `":\n"`. 4. **Assemble.** `main`, then (if any composites) `\nwhere:\n\n` + definitions joined by a blank line. ## Error handling - **Same name, different structure.** Dedup keys on `name()`. The authoring convention is that a composite name is its type, so same name ⇒ same structure; the first-seen body is rendered and later same-name composites are treated as the same type. This is a **stated assumption**, consistent with the ledger's "name is a non-load-bearing render symbol" (C23). A structural-identity key is a possible future refinement, out of scope here. No error is raised — this is a best-effort debug view, not a validator. - **No composites.** A blueprint of only leaves emits just the main graph, no `where:` block (the `defs.is_empty()` branch). - **Recursion.** Nested composites terminate because the blueprint is a finite DAG of items; dedup-by-name additionally guarantees each type is rendered once even under repeated nesting. (A blueprint cannot contain itself; composites are values built bottom-up.) - The render reads structure and `label()` only — never `eval`, no look-ahead, no determinism surface touched. ## Testing strategy Unit tests in `crates/aura-cli/src/main.rs` (replacing the two blueprint tests that assert the old cluster-box form): 1. **`blueprint_view_main_graph_shows_composite_as_opaque_node`** — render the sample; assert the main graph contains `[sma_cross]` (the opaque node) and the top-level leaves `[Exposure]` / `[SimBroker]` / `[Recorder]`, and that a `where:` section is present. 2. **`blueprint_view_defines_each_composite_once`** — assert the `sma_cross` definition appears, containing `[SMA]`, `[Sub]`, and the port markers `[in:0]` / `[out]`; assert `sma_cross:` (the definition header) occurs exactly once. 3. **`blueprint_view_golden`** (re-captured) — the exact bytes of the new render for the sample (ascii-dag's Sugiyama layout is deterministic; capture at implementation). 4. **`nested_composite_renders_without_panic`** — construct a small blueprint with a composite whose interior contains another composite (one level of nesting); assert it renders (no `unimplemented!` panic), the outer definition shows the inner as an opaque `[name]` node, and the inner composite gets its own definition. This pins the removal of the `graph.rs:75` `unimplemented!`. 5. **Reuse-renders-once** — a blueprint using the same composite type twice; assert the main graph shows two opaque nodes but the definition header appears once. Unchanged and must stay green: `compiled_view_dissolves_the_composite_boundary`, `compiled_view_golden`, `swapped_param_vector_changes_the_compiled_render`, `run_sample_is_deterministic_and_non_trivial`. ## Acceptance criteria - `aura graph` (no flag) prints the sample as a small main graph with `[sma_cross]` opaque, followed by a `where:` block defining `sma_cross` once, on a flat layout (no `╔═╗` subgraph boxes). - `aura graph --compiled` output is byte-identical to before (`render_compilat` untouched; its golden unchanged). - The `unimplemented!("nested-composite cluster rendering")` at `graph.rs:75` is gone; a one-level-nested composite renders. - A composite reused N times renders its body once. - `cargo test --workspace` green; `cargo clippy --workspace --all-targets -D warnings` clean. - Closes #38. (#37 — interactive enter/focus navigation — is the playground counterpart and explicitly out of scope.)