# 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_flat_graph`, 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_flat_graph`) | 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_flat_graph`. 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 flat graph. 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<usize>, output_interior: usize, input_roles: Vec<Vec<Target>> },
}

// 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<usize> {
    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::<Vec<_>>().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 "<name>:\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_flat_graph` 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 `"<name>:\n"`.
- **`ItemDisplay`**, **`producer_id`**, **`consumer_ids`** (simplified as above).
- **`render_flat_graph`** — 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 `"<name>:\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_flat_graph`
  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.)
