spec: 0017 blueprint view as main graph + composite definitions

Redesign the `aura graph` blueprint view (render_blueprint) from ascii-dag
subgraph cluster boxes to a "program with subroutines" model: a small main
graph that wires the harness with each composite shown as a single opaque
node, plus a `where:` section rendering each distinct composite type's
interior once. render_compilat (flat, fully-inlined, C23) is unchanged.

Design ratified in-session. The 3a-vs-3b fork was resolved by medium: the
interactive enter/focus model (3a) is the playground's, parked as #37; the
static main+definitions model (3b) is the CLI's, this cycle.

Substance:
- 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). The flat layout is collision-free; the new model
  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.
- Removes the nested-composite `unimplemented!` at graph.rs:74-77 (the
  definitions pass is recursive).

Defaults baked (not forks): composite-type dedup key = name() (assumes
same name => same structure, the authoring convention); opaque-node ports
shown as [in:k] / [out] markers in a definition.

grounding-check PASS — all nine load-bearing assumptions ratified by green
tests (Composite/Blueprint API, the flat render_compilat path, the
unimplemented! site, the two replaced cluster tests, the four must-stay-green
tests). spec_validation parse gate is a confirmed no-op (none configured).

refs #38
This commit is contained in:
2026-06-07 22:13:15 +02:00
parent a70c5faab7
commit 2d3792e45f
@@ -0,0 +1,275 @@
# 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<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_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 `"<name>:\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 `"<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_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.)