diff --git a/docs/specs/0023-unify-blueprint-render.md b/docs/specs/0023-unify-blueprint-render.md new file mode 100644 index 0000000..63e3f38 --- /dev/null +++ b/docs/specs/0023-unify-blueprint-render.md @@ -0,0 +1,336 @@ +# Unify the Blueprint Main-Graph Render Through the Composite Machinery — Design Spec + +**Date:** 2026-06-08 +**Status:** Draft — awaiting user spec review +**Authors:** orchestrator + Claude + +Source: Gitea issue #48 (exhaustive, ratified in-session). This spec produces +the design that issue names; it does not re-decide it. + +## Goal + +`aura graph --macd` must make the strategy **legible**. Today the main graph +draws bare nodes and bare edges, so what the strategy computes is invisible: + +``` +[macd] → [Exposure] → [SimBroker] → [Recorder] +``` + +Two facts the strategy turns on never reach the page: that the `histogram` +field (not the macd line, not the signal) drives `Exposure`, and that +`Exposure`'s behaviour is parameterised by `scale`. Both are already present in +the model (`Edge.from_field`, `LeafFactory::params()`) and both are already +rendered for a composite's interior in the `where:` view. The main graph is +poorer only because it runs a **separate, less capable render path**. + +This cycle removes that asymmetry: the blueprint main graph renders through the +**same machinery** as a composite interior, because a `Blueprint` is — for +render purposes — the *root composite*. After this cycle the main graph carries +leaf params, multi-input slot stubs, and a `from_field` edge label on every +multi-output producer. + +**In scope:** unifying the leaf-label and edge-rendering of the main graph with +the composite-definition render (`leaf_label`, the edge handling, the slot-stub +machinery, and a new `from_field` edge label that serves **both** views). + +**Out of scope (deferred, see Architecture §Deferred):** a harness signature +header line over the main graph. It is orthogonal to legibility and its exact +shape is unresolved; it is surfaced at the review gate, not baked here. + +## Architecture + +### The asymmetry, precisely + +Two render functions in `crates/aura-cli/src/graph.rs` consume nearly the same +data but diverge in capability: + +- `render_blueprint` (the main graph) — `crates/aura-cli/src/graph.rs:36`. + Pass 1 labels each top-level leaf with the **bare** `factory.label()` + (`graph.rs:44`); pass 2 pushes each edge as a bare `(from, to)` pair, dropping + `from_field` and `slot` (`graph.rs:60`). +- `render_definition` (a composite interior) — `crates/aura-cli/src/graph.rs:295`. + Labels each leaf with the **enriched** `leaf_label(c, i, factory)` + (`graph.rs:299`), which folds in aliased param names and ordered input-slot + stubs, and folds re-exported outputs onto producers via `output_binding`. + +The enriched path operates only on a composite's `nodes` + `edges`. Those two +fields are the **same types** on a `Blueprint`: + +``` +Composite { name, nodes: Vec, edges: Vec, + input_roles: Vec, params: Vec, output: Vec } +Blueprint { nodes: Vec, edges: Vec, + sources: Vec } +``` + +So the enriched machinery applies to a `Blueprint` unchanged on the interior, +and differs only at the three **borders**. Each border is a small adapter, not +a new code path: + +1. **Entries.** `sources` (typed, bound) play the role `input_roles` (named, + open) play in a composite. Both already render as an entry-marker node — + `[source:F64]` is the counterpart of `[price]`. The blueprint pass already + draws source markers (`graph.rs:49-54`); this is unchanged. +2. **Per-node params.** A composite re-names a leaf's params through its + `ParamAlias` list. A blueprint has no alias list — a top-level leaf carries + its own params, whose names the `LeafFactory` declares pre-build + (`LeafFactory::params() -> &[ParamSpec]`, `crates/aura-core/src/node.rs:85`; + #43). So the param **source** differs (factory-declared name vs composite + alias), the rendering **principle** does not (show the leaf's param names). + The render stays value-empty (names, not values) — consistent with the + param-generic blueprint (C11) and with the current `[source:F64]` / + `[Exposure]` golden. +3. **Outputs / sinks.** A composite re-exports ports (`OutField`, folded onto + producers by `output_binding`). A blueprint re-exports nothing; its terminal + nodes are **sinks**. Per the issue's sink sharpening, a sink is not a type — + no `trait Sink`/enum exists; the only mark is an empty output schema + (`output: vec![]`, e.g. `Recorder`, `crates/aura-std/src/recorder.rs`). A + sink is a leaf like any other that happens to have no outgoing edge, run + through the same `leaf_label`. **No special handling**, and therefore no new + border logic — this border is "absence of `output_binding`", which the + blueprint already gets for free by carrying no `OutField`s. + +### The `from_field` edge label (serves both views) + +`Edge.from_field` selects which output field a consumer reads. It is dropped at +`graph.rs:60` (blueprint) and never rendered in `render_definition` either; +ascii-dag's per-edge label slot is passed `None` at `graph.rs:87`. The strategy +fact "Exposure reads the histogram" lives exactly here: the macd→Exposure edge +carries `from_field: 2` (`crates/aura-cli/src/main.rs:254`), and macd's field 2 +is `histogram` (`OutField`, `crates/aura-cli/src/main.rs:226`). + +**Rule (default, ratify at review):** an edge renders a field label **only when +its producer has more than one output field** — mirroring the slot-stub +convention (`fan_in_identifiers` labels a slot only when a node has >1 wired +input, `graph.rs:184`). For a single-output producer the label is omitted, so +the existing golden output is unchanged. + +The field **name** is resolved from the producer: + +- Producer is a composite → `Composite::output()[from_field].name` + (`crates/aura-engine/src/blueprint.rs:119`). The headline case: macd is a + composite, so `from_field: 2` resolves to `histogram`. +- Producer is a leaf → pre-build field names are not declared (#43, value-empty + `LeafFactory`), so the label falls back to the field **index** (e.g. `2`). No + multi-output leaf exists in the current sample/macd, so the fallback is + latent, not exercised — recorded so the planner handles it deliberately + rather than panicking. + +The label is attached through ascii-dag's existing `add_edge(from, to, label)` +slot (`graph.rs:87`), shared by `render_flat` and therefore by **both** views. + +**Implementation risk (planner verifies):** whether ascii-dag's `RenderMode::Vertical` +renders a per-edge label legibly is not yet confirmed in this codebase. If the +vertical layout cannot place an edge label cleanly, the fallback that preserves +the same observable — "the selected field is visible at the macd→Exposure +connection" — is to surface the field name at the consumer side, adjacent to the +input that reads it (the slot-stub locus the `#…` machinery already owns). The +acceptance is form-invariant: the field is visible; the exact locus is a +planner/implementer call against ascii-dag's actual capability. + +### Deferred: the harness signature header + +The issue's invariant note establishes that C12 (a harness is ontologically not +a node) is the **wrong axis** to deny the main graph a signature — ontology and +render form are independent. A header such as +`harness(price:f64) {fast, slow, signal, scale} -> {equity, exposure}` is +therefore *permissible*. It is nonetheless **deferred**: its exact shape is a +genuine open choice (sources-and-sinks only vs the full param space; whether a +root graph needs a title at all the way a `where:`-block does), and it is +**orthogonal** to this cycle's headline — legibility is delivered by the leaf +params and the edge label without it. Deferring keeps this spec fork-free. The +choice is surfaced at the review gate; if wanted, it becomes its own cycle. + +## Concrete code shapes + +### User-facing: `aura graph --macd` (the acceptance evidence) + +The program the user runs is unchanged; its output gains information. Current +main-graph fragment (value-empty, composites opaque): + +``` + [macd] + │ + ↓ + [Exposure] +``` + +After this cycle (shapes shown; exact bytes are a golden re-capture, not +spec-owned): + +``` + [macd] + │ histogram ← from_field: 2 resolved via macd's OutField names + ↓ + [Exposure(scale)] ← factory-declared param name, value-empty +``` + +`SimBroker`, with two wired input slots (exposure, price), gains slot stubs the +same way the composite interior already shows them (e.g. `[SimBroker(#…,#…)]`); +`Recorder` (no params, one input, a sink) is unchanged — a bare terminal leaf. +The headline is legibility: the histogram selection and `Exposure`'s +parameterisation are now on the page. + +The `where:` section is unchanged in spirit. A composite interior already shows +enriched leaves; the only addition reaching it is the same `from_field` edge +label, which is a no-op for the all-single-output sample/macd interiors. + +### Implementation shape: one enriched path over the shared interior + +The change is to make the enriched leaf/edge rendering reachable for the +blueprint's top-level `nodes` + `edges`, and to teach the edge step the +`from_field` label. Direction only — the planner owns the exact factoring (a +shared helper over `(nodes, edges, param-source)`, vs a thin blueprint-side call +into the existing helpers); this spec does not prescribe the mechanism. + +Before — blueprint leaf label is bare, edge drops field/slot: + +```rust +// graph.rs:43-46 (render_blueprint, pass 1) +labels.push(match item { + BlueprintNode::Leaf(factory) => factory.label(), // bare + BlueprintNode::Composite(c) => c.name().to_string(), // opaque (retained) +}); + +// graph.rs:59-61 (render_blueprint, pass 2) +for e in bp.edges() { + edges.push((item_ids[e.from], item_ids[e.to])); // from_field/slot dropped +} +``` + +After — top-level leaf is enriched from its factory params + wired slots; the +edge carries a field label when the producer is multi-output (composites stay +opaque, sinks need no special case): + +```rust +// pass 1: a top-level leaf renders its factory-declared param names and its +// wired input-slot stubs — the same enrichment leaf_label already produces for +// a composite interior, sourced from factory.params() instead of a ParamAlias +// list. Composites remain opaque (#38). A sink is just a leaf with no out-edge. +labels.push(match item { + BlueprintNode::Leaf(factory) => leaf_label_toplevel(bp, idx, factory), // enriched + BlueprintNode::Composite(c) => c.name().to_string(), // opaque +}); + +// pass 2: keep the (from, to) pair, add the field label when the producer has +// >1 output field — name via Composite::output() for a composite producer, +// index fallback for a (currently absent) multi-output leaf producer. +let label = field_label(bp, e); // Some("histogram") | None for single-output +edges.push((item_ids[e.from], item_ids[e.to], label)); +``` + +The edge-label rule, shared by both views through `render_flat` / +`add_edge` (`graph.rs:81-94`): + +```rust +// only-when-ambiguous, mirroring fan_in_identifiers' >1-slot guard: +// producer single-output -> None (golden unchanged) +// producer multi-output -> Some(field_name_or_index) +fn field_label(producer_outputs: usize, from_field: usize, name: Option<&str>) -> Option { + if producer_outputs <= 1 { return None; } + Some(name.map(str::to_string).unwrap_or_else(|| from_field.to_string())) +} +``` + +## Components + +- `crates/aura-cli/src/graph.rs` — the only production file changed. + - `render_blueprint` — pass 1 enriches top-level leaves; pass 2 adds the field + label. Composite-opaque rendering retained. + - `render_flat` / the `add_edge` call (`graph.rs:81-94`) — gains an optional + per-edge label argument; used by `render_blueprint`, `render_definition`, + and `render_compilat` alike (shared, so the label mechanism is single-owned). + - `leaf_label` — generalised (or paralleled by a top-level variant) to source + param names from `LeafFactory::params()` when there is no `ParamAlias` list. + - a `from_field` → name resolver using `Composite::output()` with an + index fallback. +- `crates/aura-cli/src/main.rs` — golden tests re-captured (`blueprint_view_golden`, + the macd render assertions); no production change. +- No engine change. `Blueprint`/`Composite`/`Edge`/`LeafFactory` accessors all + already exist; render reads structure + `label()` + `params()`/`output()` only, + never `eval` (C9). + +## Data flow + +Render is a pure read over graph-as-data (C9), unchanged in nature: + +1. `render_blueprint(bp)` walks `bp.nodes()`: a leaf → enriched label from + `factory.label()` + `factory.params()` names + wired-slot stubs; a composite + → opaque `c.name()`. +2. Walks `bp.edges()` + `bp.sources()`: each edge → `(from, to, field_label)`, + the label resolved from the producer's output arity and names; each source → + marker node + edges to its targets. +3. `render_flat` builds the ascii-dag graph with the optional labels and renders + (Plain for golden/pipe, Ansi for TTY). +4. The `where:` section renders each distinct composite via `render_definition`, + now also passing field labels through the shared `add_edge` slot. + +No `eval`, no compile, no ordering dependence — the same determinism the current +render has (the golden tests are byte-exact, layout is RNG-free). + +## Error handling + +- **Malformed `from_field`** (index past the producer's output arity): the + render is a best-effort debug view, not the validator (the compile path is — + `graph.rs:135` `signature` already falls back to `?` rather than panicking). + The field resolver falls back to the index string and never panics. +- **Multi-output leaf producer** (no pre-build field names, #43): label falls + back to the field index. Latent in the current corpus; handled, not assumed + away. +- **ascii-dag vertical edge-label limitation:** if the label cannot be placed + legibly in `RenderMode::Vertical`, fall back to the consumer-side field locus + (Architecture §`from_field` edge label). Observable is preserved. + +## Testing strategy + +- **Golden re-capture, value-asserted (not just re-blessed).** `blueprint_view_golden` + (`crates/aura-cli/src/main.rs:494`) is re-captured; the diff must show the new + information (a param name on `[Exposure …]`, slot stubs on `[SimBroker …]`), + proving enrichment rather than a blind re-bless. +- **The headline assertion (macd):** a test on `render_blueprint(&macd_blueprint())` + asserts the macd→Exposure edge surfaces `histogram` and that `[Exposure(scale)]` + shows its param — the legibility property the cycle exists to buy. Extends the + existing `macd_blueprint_renders_a_nested_composite_definition` + (`crates/aura-cli/src/main.rs:585`). +- **Single-output producers carry no field label** — asserted on the sma_cross + sample, where every producer is single-output, so the main-graph edges stay + label-free (guards the only-when-ambiguous rule and keeps the sample golden + honest). +- **`where:` unchanged in spirit:** the composite-definition assertions + (`[EMA(fast)]`, `[macd := Sub(#Ef,#Es)]`, the typed signature line) still pass + — the shared edge-label change is a no-op for single-output interiors. +- **Determinism:** byte-exact goldens remain the determinism guard (layout is + RNG-free); no new determinism test needed. +- Full `cargo test --workspace` + `cargo clippy --workspace --all-targets -D warnings`. + +Note: the project profile declares no `spec_validation` parser, so the +parse-every-block gate is a no-op this cycle (recorded for the Step-4 trace). + +## Acceptance criteria + +Against aura's feature-acceptance criterion (CLAUDE.md): + +- **The audience reaches for it.** A researcher running `aura graph` on a + strategy wants to see what the strategy *does*; legibility is the command's + purpose. The worked `aura graph --macd` output above — histogram on the edge, + `scale` on `Exposure` — is the empirical evidence. +- **Improves correctness / removes redundancy.** Collapses two divergent render + paths into one (less duplication) and stops the render from silently dropping + `from_field` (more faithful). +- **Reintroduces no failure class.** Render reads structure + `label()` only, + never `eval`; determinism (C1), causality (C2), and the SoA hot path (C4) are + untouched. Composites stay opaque (#38); sinks need no new concept (issue sink + sharpening). + +Concretely, the cycle is done when: + +- [ ] `aura graph --macd`'s main graph shows the `histogram` field on the + macd→Exposure connection and `scale` on the `Exposure` leaf. +- [ ] `SimBroker`'s two input slots render as stubs in the main graph (the same + enrichment the composite interior already shows). +- [ ] Single-output producers carry no field label; the sma_cross sample golden + shows no spurious labels. +- [ ] The `where:` section's existing assertions still pass (unchanged in spirit). +- [ ] Composites remain opaque in the main graph (#38 retained). +- [ ] `cargo test --workspace` and clippy are green; goldens re-captured with + visible new information.