spec: 0025 render root like any composite (slot stubs + role-name entries)

Settled source: issue #49, enabled by cycle 0024 (1b39093, root is now a
Composite with input_roles). The cycle removes the last two render special-cases
the root still carries, both pre-0024 vestiges:

(A) render_blueprint threads stub_ctx: None, so a top-level multi-input leaf
    (SimBroker) renders bare [SimBroker] instead of the #… slot stubs an interior
    leaf already gets. Fix: pass the root composite as stub_ctx (it IS a
    &Composite since 0024); stub_ctx drops its Option (both render_graph callers
    pass &Composite). The existing slot_source/fan_in_identifiers/signature_of
    serve a top-level leaf verbatim — no second stub path (#49 acceptance #3).
    -> [SimBroker(#E,#price)] (#E = exposure producer's signature prefix,
    #price = the source role name).

(B) render_blueprint names root entries source:{kind} while render_definition
    names interior entries by role.name — the marker/stub asymmetry. Fix: build
    root entries from role.name (bound-only filter retained), identical to the
    interior. -> [price] instead of [source:F64], byte-symmetric with how the
    same role renders in a where: definition.

render_compilat is left as [source:F64]: post-inline the role name has dissolved
(C23), only the kind survives — [price] pre-inline / [source:F64] post-inline is
C23 made visible, and compiled_view_golden is the negative control.

Scope: crates/aura-cli/src/graph.rs only. Read-only render (C9),
behaviour-preserving for the run path (C1); no engine change. Grounding-check
PASS (10 assumptions ratified against green tests). Render goldens are the
accepted value-asserted regression; closes #49 on land.
This commit is contained in:
2026-06-09 17:47:18 +02:00
parent d6f59d7a52
commit ed1d22bbfa
+256
View File
@@ -0,0 +1,256 @@
# Render the root composite like any other composite (slot stubs + role-name entries) — Design Spec
**Date:** 2026-06-09
**Status:** Draft — awaiting user spec review
**Authors:** orchestrator + Claude
## Goal
Close issue #49, and in doing so remove the **last render special-case the root
still carries**. After the render-path unification (#48) the blueprint main graph
and a composite's `where:`-interior share one core (`render_graph`,
`crates/aura-cli/src/graph.rs`), but the root is still rendered specially in two
places, both vestiges of the pre-0024 `struct Blueprint`:
1. A composite-**interior** multi-input leaf renders its wired slots as `#…`
source-derived stubs (`[cross := Sub(#Sf,#Ss)]`); a **top-level** leaf does not
`SimBroker` renders as bare `[SimBroker]` — because `render_blueprint`
threads `stub_ctx: None`.
2. An interior role renders as a `[price]` entry marker (the role **name**); a
root role renders as `[source:F64]` (the ingestion **kind**), because
`render_blueprint` builds entries from `format!("source:{kind}")`.
Cycle 0024 (`1b39093`) deleted `struct Blueprint`: the root is now an ordinary
`Composite` with `input_roles: Vec<Role>`. So both special-cases are obsolete —
the root has roles, and the stub machinery is already keyed on `&Composite` +
`&[Role]`. This cycle makes the root render **identically to an interior**: a
root role becomes `[price]` marker + `#price` stub, byte-symmetric with how the
same role renders inside a `where:` definition. The payoff is the C12 "render the
structure so any mis-wiring is visible before a run" guarantee, now complete and
uniform at every level — a swapped exposure/price wiring at a top-level
`SimBroker` becomes visible, and the root no longer reads as a different kind of
graph than its interiors.
## Architecture
Two coordinated changes, both dissolving root-specialness; neither adds code that
branches on "is this the root":
**(A) Thread the stub context.** `render_blueprint` passes the root composite as
`stub_ctx` instead of `None`. Since 0024 the root **is** a `&Composite` carrying
`input_roles` + edges, exactly what `fan_in_identifiers``slot_source`
`signature_of` already consume. `stub_ctx` drops its now-vestigial `Option` (both
`render_graph` callers pass `&Composite`). No new stub function — acceptance #3
holds — the existing machinery serves a top-level leaf verbatim. This makes the
stubs mechanically determined:
- An **interior-fed** root slot (SimBroker slot 0, the `Exposure` edge) takes the
producer's sibling-unique `signature_of` prefix → `#E`.
- A **source-fed** root slot (SimBroker slot 1, the `price` role) takes the role
name verbatim via `slot_source`'s role branch → `#price`.
**(B) Name root entries by role.** `render_blueprint` builds its external-entry
markers from `role.name` (filtered to bound roles, `source.is_some()`), the same
construction `render_definition` already uses for interior roles. The
`source:{kind}` naming is removed. A bound root role therefore renders `[price]`,
and its slot stub `#price` now refers to a marker that carries the same name —
the marker/stub mismatch the `source:{kind}` vestige caused is gone at the source,
not patched at the stub.
**What stays special is only what is genuinely special.** The root still filters
its entries to *bound* roles (`source: Some`) — an interior role is an open port,
a root role is a bound ingestion feed (C3). That filter is semantic, not
cosmetic, and is the sole remaining distinction; the *rendering* of a role is now
identical across levels.
**The compiled view is deliberately left as `[source:F64]`.** `render_compilat`
(graph.rs:495) builds source labels from the flat `SourceSpec.kind` — post-inline
the role *name* has dissolved (C23, names are non-load-bearing debug symbols that
do not reach the compilat), so only the kind survives there. Thus `[price]`
pre-inline (name alive) and `[source:F64]` post-inline (name gone) is not an
inconsistency — it is C23 made visible, and it is the negative control proving the
change is confined to the structural blueprint view.
Scope: the CLI render layer only (`crates/aura-cli/src/graph.rs`). No engine
change, no run-loop change; read-only render (C9), behaviour-preserving for the
run path (C1). `signature_of` (`crates/aura-engine/src/blueprint.rs`) is read, not
modified.
## Concrete code shapes
### User-facing: `aura graph --macd` (the empirical evidence)
The MACD strategy root wires `SimBroker` (node 2) slot 0 (exposure) from the
interior `Exposure` edge and slot 1 (price) from the `price` source role. Two
lines of the main graph change — the source marker and the broker leaf:
```text
# before — root reads as a different graph: kind-named source, bare fan-in leaf
[source:F64]
...
[Recorder] [SimBroker]
# after — root reads like an interior: role-named source, stubbed fan-in leaf
[price]
...
[Recorder] [SimBroker(#E,#price)]
```
`#E` is the exposure producer's sibling-unique signature prefix (`Exposure`'s
`signature_of` is `Em` at the macd root / `Es` at the sample root; the leading `E`
already separates it from `price`). `#price` is the source role's name, verbatim —
and now matches the `[price]` marker feeding the slot. The same two changes appear
in the default `aura graph` (sample) view, whose root is wired identically.
Both marker and leaf change width, so the ascii-dag Sugiyama layout of the whole
main graph re-flows. The full golden is re-captured from the actual `aura graph`
output (not a line edit); the load-bearing facts are: the source marker reads
`[price]`, the `SimBroker` leaf carries the two `#…` stubs, and the `where:`
section stays byte-identical.
### Implementation before → after
```rust
// render_blueprint entry construction (crates/aura-cli/src/graph.rs ~line 70)
// before — kind-named source marker (pre-0024 vestige)
let entries: Vec<Entry> = bp.input_roles().iter()
.filter_map(|role| role.source.map(|kind| Entry {
name: format!("source:{kind:?}"),
targets: &role.targets,
}))
.collect();
// after — role-named, identical to render_definition modulo the bound-only filter
let entries: Vec<Entry> = bp.input_roles().iter()
.filter(|role| role.source.is_some())
.map(|role| Entry { name: role.name.clone(), targets: &role.targets })
.collect();
```
```rust
// render_graph signature (crates/aura-cli/src/graph.rs ~line 111)
// before: stub_ctx: Option<&Composite> after: stub_ctx: &Composite
```
```rust
// leaf_label stub gate (crates/aura-cli/src/graph.rs ~line 283)
// before — Some/None gate; the None arm was the root carve-out
let stubs: Vec<String> = match stub_ctx {
Some(c) if slots.len() > 1 => fan_in_identifiers(c, index, &slots),
_ => Vec::new(),
};
// after — context always present; the fan-in test alone decides
let stubs: Vec<String> = if slots.len() > 1 {
fan_in_identifiers(stub_ctx, index, &slots)
} else {
Vec::new()
};
```
```rust
// the two render_graph call sites (crates/aura-cli/src/graph.rs ~80, ~454)
// render_blueprint: None, -> bp,
// render_definition: Some(c), -> c,
```
The `Entry` doc-comment (graph.rs ~41, "the blueprint from `SourceSpec`
(name = `source:{kind}`)") and `leaf_label`'s item-3 doc-comment (graph.rs ~243,
"At the blueprint root no such context is threaded…") are rewritten: a root entry
is named by its role like any interior role, and a multi-input fan-in renders its
slot stubs in both views (the "root has no context" carve-out is removed).
## Components
- **`render_blueprint`** (graph.rs): entries built from `role.name` (bound-only
filter retained); passes the root composite as `stub_ctx`.
- **`render_graph` / `leaf_label`** (graph.rs): `stub_ctx` becomes `&Composite`;
the fan-in gate keys on `slots.len() > 1` alone.
- **`render_definition`** (graph.rs): passes `c` instead of `Some(c)` (mechanical).
- **`render_compilat`** (graph.rs): **unchanged** — keeps `source:{kind}` from the
flat `SourceSpec` (C23).
- **`slot_source` / `fan_in_identifiers` / `signature_of`**: unchanged, reused
verbatim.
## Data flow
`render_blueprint(bp)` builds entries `{ name: role.name, targets }` for each
bound root role, then calls `render_graph(bp.nodes(), bp.edges(), entries, …,
stub_ctx = bp)`. Per node, `leaf_label(…, stub_ctx = bp)`; for a multi-input leaf,
`fan_in_identifiers(bp, index, slots)` → per slot `slot_source(bp, index, slot)`:
an edge-fed slot resolves through `signature_of(bp.nodes(), bp.edges(),
bp.input_roles(), bp.params(), e.from)`; a role-fed slot returns the role name.
The `where:` definitions still flow through `render_definition(c)`
`render_graph(…, stub_ctx = c)`, untouched and byte-stable. The compiled view
flows through the independent `render_compilat`, untouched.
## Error handling
No new failure modes. Read-only structural rendering (C9): the stub path indexes
`bp.nodes()`/`bp.edges()`/`bp.input_roles()` the caller already holds;
`slot_source` returns an empty signature for an unwired slot (never panics);
`fan_in_identifiers` falls back to a positional `#A` for genuinely
interchangeable inputs. The dropped `Option` and the dropped `source:{kind}`
format both remove branches rather than add them. No engine validity surface is
touched (the `derive_signature` raw-indexing gap is the separate, still-open #24).
## Testing strategy
All affected tests live in `crates/aura-cli/src/main.rs`:
1. **`blueprint_view_main_graph_shows_composite_as_opaque_node`** (~line 410):
the needle list asserts the sample render contains `[SimBroker]` (bare) with
the comment "paramless leaves (SimBroker, Recorder) stay bare". Update: the
`SimBroker` needle becomes `[SimBroker(#E,#price)]`, and the comment is
corrected — a paramless **single-input** leaf (`Recorder`) stays bare, a
paramless **multi-input fan-in** (`SimBroker`) now shows its slot stubs.
2. **`blueprint_view_golden`** (~line 526): full byte golden of the sample
blueprint render — re-captured from the actual `aura graph` output. Two
changes: the source marker `[source:F64]``[price]`, the `SimBroker` leaf
gains `(#E,#price)`; the main graph re-flows around both. The `where:` section
stays byte-identical (acceptance #2).
3. **`macd_blueprint_renders_a_nested_composite_definition`** (~line 616): add an
assertion that the macd root `SimBroker` shows its two slot stubs —
`out.contains("[SimBroker(#E,#price)]")`. The macd root source marker also
becomes `[price]`; no existing assertion in this test pins the source marker,
so the `where:`-interior and opaque-composite assertions are unchanged
(acceptance #1 for `--macd` + acceptance #2).
4. **`compiled_view_golden`** (~line 569): asserted **unchanged** — renders
through `render_compilat`, a separate path that keeps `[source:F64]` (C23:
name dissolved post-inline) and `[SimBroker(0.0001)]`. The negative control.
5. **`nested_composite_renders_without_panic`** (~437) and
**`reused_composite_defined_once`** (~475): use a `src`-named bound root role.
Their `[source:F64]` marker becomes `[src]`, but neither asserts on the source
marker text (only on composite markers `[outer]`/`[inner]`/`[dup]` and
definition counts), so both stay green unmodified. Confirms the entry-naming
change carries the role name through generally, not just for `price`.
Build/test/lint gates: `cargo build --workspace`, `cargo test --workspace`,
`cargo clippy --workspace --all-targets -- -D warnings`.
## Acceptance criteria
Verbatim from #49:
- [ ] `aura graph --macd` renders `SimBroker`'s two input slots as `#…` stubs
(`[SimBroker(#E,#price)]`).
- [ ] The `where:` section stays byte-identical (the composite stub output is
unchanged).
- [ ] No second parallel stub function is introduced (the de-duplication from #48
holds) — `stub_ctx` loses its `Option`; both views call the same
`fan_in_identifiers`/`slot_source` path.
Plus the cycle's structural-cleanliness goals:
- [ ] A bound root role renders `[price]` (role name), byte-symmetric with how the
same role renders as an interior entry marker; the `source:{kind}` special-case
is removed from the blueprint view.
- [ ] The sample `aura graph` view shows both `[price]` and `[SimBroker(#E,#price)]`
(identical root wiring); `blueprint_view_golden` re-captured value-asserted.
- [ ] `compiled_view_golden` is byte-unchanged: the compiled view keeps
`[source:F64]` (C23 — name dissolved post-inline). Negative control.
- [ ] Run-path determinism untouched (C1): only `crates/aura-cli/src/graph.rs`
changes.
- [ ] Closes #49 on land.