diff --git a/docs/specs/0017-blueprint-view-definitions.md b/docs/specs/0017-blueprint-view-definitions.md new file mode 100644 index 0000000..77c18a5 --- /dev/null +++ b/docs/specs/0017-blueprint-view-definitions.md @@ -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, 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.)