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:
@@ -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.
|
||||
Reference in New Issue
Block a user