b2f27f8590
RED-first: re-pin all five render assertions whose producer carries an OutField (macd, sma_cross cross, two fan-in `o` outputs — across main.rs + cli_run.rs) to the `:= ` binding form, then fold output_binding onto the producer label in render_definition. Blast radius wider than spec §Components named (flagged for audit). refs #46
358 lines
16 KiB
Markdown
358 lines
16 KiB
Markdown
# Composite Output Re-exports as Producer Bindings — Implementation Plan
|
|
|
|
> **Parent spec:** `docs/specs/0022-composite-output-binding-render.md`
|
|
>
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
|
|
> this plan. Steps use `- [ ]` checkboxes for tracking.
|
|
|
|
**Goal:** In the blueprint view's `where:` section, render a composite's output
|
|
re-exports as bindings on their producing node's label (`name := <producer-label>`)
|
|
instead of standalone terminal nodes.
|
|
|
|
**Architecture:** A single render-layer change in `crates/aura-cli/src/graph.rs`:
|
|
`render_definition` drops its per-`OutField` node+edge loop and instead prefixes
|
|
each interior node's label with an `output_binding(c, i)` (single `name := ` or
|
|
tuple `(a, b) := `). RED-first: re-pin every render test that asserts a producer
|
|
label which carries an `OutField` (the blast radius is wider than the spec's
|
|
Components table named — see Scope note), then implement.
|
|
|
|
**Tech Stack:** Rust; `aura-cli` (`graph.rs` renderer + in-crate tests +
|
|
`tests/cli_run.rs` integration test); `aura-engine::OutField` (read-only).
|
|
|
|
---
|
|
|
|
## Scope note — test blast radius (wider than spec §Components)
|
|
|
|
The spec's Components table named only `macd_blueprint_renders_a_nested_composite_definition`.
|
|
Recon + a workspace grep found **four** more render assertions that mechanically
|
|
break, because their asserted producer node carries an `OutField` and so gains a
|
|
`:=` prefix (the leading `[` in `contains("[Sub(…)]")` stops matching once the
|
|
label becomes `[name := Sub(…)]`). All are forced by the design — zero design
|
|
judgement. Full list (the only render assertions that change):
|
|
|
|
| Site | Test | Producer / OutField | Old assertion | New assertion |
|
|
|------|------|---------------------|---------------|---------------|
|
|
| `main.rs:405` | `blueprint_view_defines_each_composite_once` | `sma_cross` node 2 = `OutField "cross"` | `[Sub(#Sf,#Ss)]` + standalone `[cross]` | `[cross := Sub(#Sf,#Ss)]` (drop standalone `[cross]`) |
|
|
| `main.rs:600-602` | `macd_blueprint_renders_a_nested_composite_definition` | macd nodes 2/3/4 = `macd`/`signal`/`histogram` | `[EMA(signal)]`, `[Sub(#Ef,#Es)]`, `[Sub(#S,#Es)]` | `[signal := EMA(signal)]`, `[macd := Sub(#Ef,#Es)]`, `[histogram := Sub(#S,#Es)]` + negatives on `[signal]`/`[histogram]` |
|
|
| `main.rs:622` | `fan_in_identifiers_are_source_derived_and_scoped_per_node_call` | `roles` node 1 = `OutField "o"` | `[Sub(#price,#Es)]` | `[o := Sub(#price,#Es)]` |
|
|
| `main.rs:668` | `fan_in_identifiers_descend_into_bare_combinators` | `nest` node 6 = `OutField "o"` | `[Sub(#SEf,#SEu)]` | `[o := Sub(#SEf,#SEu)]` |
|
|
| `cli_run.rs:177` | `graph_renders_source_derived_fan_in_identifiers` | `sma_cross` node 2 = `OutField "cross"` | `[Sub(#Sf,#Ss)]` | `[cross := Sub(#Sf,#Ss)]` |
|
|
|
|
**Verified to survive unchanged** (asserted producer carries NO OutField, or the
|
|
assertion targets a main-graph opaque node): `main.rs:598/599` (`[EMA(fast)]`,
|
|
`[EMA(slow)]`), `main.rs:405` (`[SMA(fast)]`, `[SMA(slow)]`, `[price]`),
|
|
`main.rs:592` (`[macd]` opaque main node), `main.rs:669` (`[Sub(#Ef,#Es)]` — `nest`
|
|
node 4 carries no OutField), `main.rs:681` (`[macd]` opaque), `main.rs:437-440`
|
|
(`nested_composite_renders_without_panic` asserts only `[outer]`/`[inner]` opaque
|
|
nodes + `(`-counts; the re-exported producers there are not asserted), `cli_run.rs:182`
|
|
(negative `!contains("[Sub(#A,#B)]")`). The compiled-view golden and the MACD
|
|
determinism test are render-independent regression guards.
|
|
|
|
---
|
|
|
|
**Files this plan creates or modifies:**
|
|
|
|
- Modify: `crates/aura-cli/src/main.rs` — 4 render-assertion sites re-pinned (Task 1)
|
|
- Modify: `crates/aura-cli/tests/cli_run.rs:177` — 1 render-assertion site re-pinned (Task 1)
|
|
- Modify: `crates/aura-cli/src/graph.rs:19` — add `OutField` to the `aura_engine` use group (Task 2)
|
|
- Modify: `crates/aura-cli/src/graph.rs:282-315` — rewrite `render_definition` doc comment + output handling; add `output_binding` helper (Task 2)
|
|
|
|
No new files; no engine/blueprint/bootstrap change; the `fn macd` / `fn sma_cross`
|
|
fixtures are unchanged.
|
|
|
|
---
|
|
|
|
## Task 1: RED — re-pin render assertions to the `:=` binding form
|
|
|
|
**Files:**
|
|
- Modify: `crates/aura-cli/src/main.rs` (lines 405, 600-602, 622, 668)
|
|
- Modify: `crates/aura-cli/tests/cli_run.rs` (line 177)
|
|
|
|
- [ ] **Step 1: Re-pin `blueprint_view_defines_each_composite_once` (main.rs:405)**
|
|
|
|
Replace this exact line:
|
|
|
|
```rust
|
|
for needle in ["[SMA(fast)]", "[SMA(slow)]", "[Sub(#Sf,#Ss)]", "[price]", "[cross]"] {
|
|
```
|
|
|
|
with:
|
|
|
|
```rust
|
|
for needle in ["[SMA(fast)]", "[SMA(slow)]", "[cross := Sub(#Sf,#Ss)]", "[price]"] {
|
|
```
|
|
|
|
(The `cross` output is now folded onto its producer Sub, so `[Sub(#Sf,#Ss)]` becomes
|
|
`[cross := Sub(#Sf,#Ss)]` and the standalone `[cross]` stub is gone.)
|
|
|
|
- [ ] **Step 2: Re-pin the MACD producer assertions + add negatives (main.rs:600-602)**
|
|
|
|
Replace these exact three lines:
|
|
|
|
```rust
|
|
assert!(out.contains("[EMA(signal)]"), "signal EMA folds its param: {out}");
|
|
assert!(out.contains("[Sub(#Ef,#Es)]"), "MACD line Sub is fast-EMA minus slow-EMA: {out}");
|
|
assert!(out.contains("[Sub(#S,#Es)]"), "histogram Sub is the line minus signal-EMA: {out}");
|
|
```
|
|
|
|
with:
|
|
|
|
```rust
|
|
assert!(out.contains("[macd := Sub(#Ef,#Es)]"), "macd line Sub bound as output `macd`: {out}");
|
|
assert!(out.contains("[signal := EMA(signal)]"), "signal EMA bound as output `signal` (name/param pun is intended): {out}");
|
|
assert!(out.contains("[histogram := Sub(#S,#Es)]"), "histogram Sub bound as output `histogram`: {out}");
|
|
// output re-exports are folded onto their producers — no standalone stubs.
|
|
// `[macd]` is NOT a valid negative here: it still appears as the opaque
|
|
// composite node in the MAIN graph. Discriminate on signal/histogram.
|
|
assert!(!out.contains("[signal]"), "no standalone signal output stub: {out}");
|
|
assert!(!out.contains("[histogram]"), "no standalone histogram output stub: {out}");
|
|
```
|
|
|
|
(`[EMA(fast)]`/`[EMA(slow)]` on the lines just above stay unchanged — nodes 0/1
|
|
carry no OutField.)
|
|
|
|
- [ ] **Step 3: Re-pin `fan_in_identifiers_are_source_derived_and_scoped_per_node_call` (main.rs:622)**
|
|
|
|
Replace this exact line:
|
|
|
|
```rust
|
|
assert!(out.contains("[Sub(#price,#Es)]"), "role name verbatim + source-derived: {out}");
|
|
```
|
|
|
|
with:
|
|
|
|
```rust
|
|
assert!(out.contains("[o := Sub(#price,#Es)]"), "role name verbatim + source-derived, bound as output `o`: {out}");
|
|
```
|
|
|
|
- [ ] **Step 4: Re-pin `fan_in_identifiers_descend_into_bare_combinators` (main.rs:668)**
|
|
|
|
Replace this exact line:
|
|
|
|
```rust
|
|
assert!(out.contains("[Sub(#SEf,#SEu)]"), "outer Sub descends into inner Subs: {out}");
|
|
```
|
|
|
|
with:
|
|
|
|
```rust
|
|
assert!(out.contains("[o := Sub(#SEf,#SEu)]"), "outer Sub descends into inner Subs, bound as output `o`: {out}");
|
|
```
|
|
|
|
(The next line, `assert!(out.contains("[Sub(#Ef,#Es)]"), …)`, stays unchanged —
|
|
`nest` node 4 carries no OutField.)
|
|
|
|
- [ ] **Step 5: Re-pin the CLI integration test (cli_run.rs:177)**
|
|
|
|
Replace this exact block:
|
|
|
|
```rust
|
|
assert!(
|
|
stdout.contains("[Sub(#Sf,#Ss)]"),
|
|
"fan-in inputs must be source-derived (#Sf/#Ss), not positional: {stdout}"
|
|
);
|
|
```
|
|
|
|
with:
|
|
|
|
```rust
|
|
assert!(
|
|
stdout.contains("[cross := Sub(#Sf,#Ss)]"),
|
|
"fan-in inputs source-derived (#Sf/#Ss), bound as output `cross`: {stdout}"
|
|
);
|
|
```
|
|
|
|
(The negative assertion below, `!stdout.contains("[Sub(#A,#B)]")`, stays unchanged.)
|
|
|
|
- [ ] **Step 6: Run the re-pinned tests to verify they FAIL (RED)**
|
|
|
|
Run: `cargo test -p aura-cli 2>&1 | tail -40`
|
|
Expected: FAIL. The current renderer still emits standalone output nodes and bare
|
|
producer labels, so the new `contains("[… := …]")` assertions fail (and the MACD
|
|
negatives `!contains("[signal]")` fail because the standalone `[signal]`/`[histogram]`
|
|
stubs still exist). Failing tests include `blueprint_view_defines_each_composite_once`,
|
|
`macd_blueprint_renders_a_nested_composite_definition`,
|
|
`fan_in_identifiers_are_source_derived_and_scoped_per_node_call`,
|
|
`fan_in_identifiers_descend_into_bare_combinators`, and (integration)
|
|
`graph_renders_source_derived_fan_in_identifiers`.
|
|
|
|
## Task 2: GREEN — fold output re-exports onto their producers in `render_definition`
|
|
|
|
**Files:**
|
|
- Modify: `crates/aura-cli/src/graph.rs:19` (use group)
|
|
- Modify: `crates/aura-cli/src/graph.rs:282-315` (`render_definition` + new `output_binding`)
|
|
|
|
- [ ] **Step 1: Import `OutField` (graph.rs:19)**
|
|
|
|
Replace this exact line:
|
|
|
|
```rust
|
|
use aura_engine::{aliases_on, signature_of, Blueprint, BlueprintNode, Composite, Edge, SourceSpec};
|
|
```
|
|
|
|
with:
|
|
|
|
```rust
|
|
use aura_engine::{
|
|
aliases_on, signature_of, Blueprint, BlueprintNode, Composite, Edge, OutField, SourceSpec,
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 2: Rewrite `render_definition`'s doc comment + body and add `output_binding`**
|
|
|
|
Replace the exact current doc comment + function (graph.rs:282-315):
|
|
|
|
```rust
|
|
/// Render one composite's interior as a flat graph: interior leaves as
|
|
/// `[type(param…; #slot…)]` (aliased param names + ordered input-slot stubs folded
|
|
/// in via `leaf_label`), nested composites as opaque `[name]`, an `[<name>]`
|
|
/// entry marker per input role (wired to its interior targets), and an `[<name>]`
|
|
/// node per re-exported output field (wired from its producer). The title line is
|
|
/// the composite's typed `signature` (`name(p:kind, …) -> (out, …)`); params live
|
|
/// in the signature, not as marker nodes.
|
|
fn render_definition(c: &Composite, color: Color) -> String {
|
|
let mut labels: Vec<String> = Vec::with_capacity(c.nodes().len());
|
|
for (i, inner) in c.nodes().iter().enumerate() {
|
|
labels.push(match inner {
|
|
BlueprintNode::Leaf(factory) => leaf_label(c, i, factory),
|
|
BlueprintNode::Composite(inner_c) => inner_c.name().to_string(),
|
|
});
|
|
}
|
|
let mut edges: Vec<(usize, usize)> = Vec::new();
|
|
for e in c.edges() {
|
|
edges.push((e.from, e.to));
|
|
}
|
|
for role in c.input_roles() {
|
|
let in_id = labels.len();
|
|
labels.push(role.name.clone());
|
|
for t in &role.targets {
|
|
edges.push((in_id, t.node));
|
|
}
|
|
}
|
|
for of in c.output() {
|
|
let out_id = labels.len();
|
|
labels.push(of.name.clone());
|
|
edges.push((of.node, out_id));
|
|
}
|
|
|
|
format!("{}:\n\n{}", signature(c), render_flat(&labels, &edges, color))
|
|
}
|
|
```
|
|
|
|
with:
|
|
|
|
```rust
|
|
/// Render one composite's interior as a flat graph: interior leaves as
|
|
/// `[type(param…; #slot…)]` (aliased param names + ordered input-slot stubs folded
|
|
/// in via `leaf_label`), nested composites as opaque `[name]`, and an `[<name>]`
|
|
/// entry marker per input role (wired to its interior targets). A re-exported
|
|
/// output is **not** a standalone node: its name is folded onto its producing
|
|
/// node's label as a binding (`[macd := Sub(…)]`, tuple `(a, b) := …` for several
|
|
/// outputs on one node) via [`output_binding`] — so the drawn graph is exactly the
|
|
/// computation DAG, every node a real step (C23: the name is a render symbol on the
|
|
/// producer, never a wired terminal). The title line is the composite's typed
|
|
/// `signature` (`name(p:kind, …) -> (out, …)`); params live in the signature, not
|
|
/// as marker nodes.
|
|
fn render_definition(c: &Composite, color: Color) -> String {
|
|
let mut labels: Vec<String> = Vec::with_capacity(c.nodes().len());
|
|
for (i, inner) in c.nodes().iter().enumerate() {
|
|
let base = match inner {
|
|
BlueprintNode::Leaf(factory) => leaf_label(c, i, factory),
|
|
BlueprintNode::Composite(inner_c) => inner_c.name().to_string(),
|
|
};
|
|
labels.push(match output_binding(c, i) {
|
|
Some(prefix) => format!("{prefix}{base}"),
|
|
None => base,
|
|
});
|
|
}
|
|
let mut edges: Vec<(usize, usize)> = Vec::new();
|
|
for e in c.edges() {
|
|
edges.push((e.from, e.to));
|
|
}
|
|
for role in c.input_roles() {
|
|
let in_id = labels.len();
|
|
labels.push(role.name.clone());
|
|
for t in &role.targets {
|
|
edges.push((in_id, t.node));
|
|
}
|
|
}
|
|
// outputs are folded onto their producers by output_binding above — no
|
|
// standalone output node and no producer→output edge (C23).
|
|
|
|
format!("{}:\n\n{}", signature(c), render_flat(&labels, &edges, color))
|
|
}
|
|
|
|
/// The `name := ` (single) or `(n1, n2) := ` (tuple) binding prefix for interior
|
|
/// node `i`, if any `OutField` re-exports it; `None` for a non-output node. Names
|
|
/// are ordered by re-exported `field` index (ties keep author order). The producer
|
|
/// label follows the prefix; no standalone output node or producer→output edge is
|
|
/// emitted (the output *is* the producer, surfaced by name — C23).
|
|
fn output_binding(c: &Composite, i: usize) -> Option<String> {
|
|
let mut outs: Vec<&OutField> = c.output().iter().filter(|of| of.node == i).collect();
|
|
if outs.is_empty() {
|
|
return None;
|
|
}
|
|
outs.sort_by_key(|of| of.field);
|
|
let names: Vec<&str> = outs.iter().map(|of| of.name.as_str()).collect();
|
|
Some(match names.as_slice() {
|
|
[one] => format!("{one} := "),
|
|
many => format!("({}) := ", many.join(", ")),
|
|
})
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Run the named render tests to verify they PASS (GREEN)**
|
|
|
|
Run: `cargo test -p aura-cli 2>&1 | tail -40`
|
|
Expected: PASS. All five re-pinned assertions now hold; the MACD negatives pass
|
|
(no standalone `[signal]`/`[histogram]`); the survivors (`[EMA(fast)]`, `[price]`,
|
|
`[macd]` opaque, `[Sub(#Ef,#Es)]` on `nest` node 4, `compiled_view_golden`,
|
|
`run_macd_compiles_from_nested_composite_and_is_deterministic`) stay green.
|
|
|
|
- [ ] **Step 4: Full workspace gate**
|
|
|
|
Run: `cargo test --workspace 2>&1 | tail -20`
|
|
Expected: PASS (0 failed). In particular `compiled_view_golden` is byte-identical
|
|
(C23 — the compiled view never carried output names) and the MACD run is
|
|
deterministic and unchanged in metrics.
|
|
|
|
- [ ] **Step 5: Lint + build gate**
|
|
|
|
Run: `cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -20`
|
|
Expected: PASS, no warnings (the `output_binding` `match names.as_slice()` arms and
|
|
the `OutField` import are all used).
|
|
|
|
---
|
|
|
|
## Self-review (planner, inline)
|
|
|
|
1. **Spec coverage:** spec's render change → Task 2; spec's "re-pin the pinning
|
|
test" → Task 1 Step 2; the four incidental breaks the spec under-counted →
|
|
Task 1 Steps 1/3/4/5 (Scope note documents why each is forced by the design).
|
|
Regression guards (golden, determinism) → Task 2 Steps 3-4. Covered.
|
|
2. **Placeholder scan:** no TBD/TODO/"similar to"/"implement later". Clean.
|
|
3. **Type consistency:** `output_binding`, `OutField`, `render_definition`,
|
|
`leaf_label`, `render_flat` spelled identically across tasks and matching the
|
|
tree. `OutField` fields (`node`, `field`, `name`) match `blueprint.rs:24`.
|
|
4. **Step granularity:** each step is one edit or one command (2-5 min).
|
|
5. **No commit steps:** none — orchestrator commits at iter close.
|
|
6. **Pin/replacement substring contiguity:** every asserted substring
|
|
(`[cross := Sub(#Sf,#Ss)]`, `[macd := Sub(#Ef,#Es)]`, `[signal := EMA(signal)]`,
|
|
`[histogram := Sub(#S,#Es)]`, `[o := Sub(#price,#Es)]`, `[o := Sub(#SEf,#SEu)]`)
|
|
appears contiguously as a single string literal in its assertion, and is
|
|
produced contiguously by the renderer as `format!("{prefix}{base}")` (e.g.
|
|
prefix `"macd := "` + base `"Sub(#Ef,#Es)"`; ascii-dag adds the `[…]`). No
|
|
soft-wrap split.
|
|
7. **Compile-gate vs deferred-caller:** no signature change. `render_definition`'s
|
|
signature is unchanged; `output_binding` is new and self-contained. Task 1
|
|
leaves the tree compiling (test-body edits only); Task 2's build gate is
|
|
satisfiable.
|
|
8. **Verification-command filters resolve:** Run steps use `cargo test -p aura-cli`
|
|
(whole crate suite — all five named tests exist in it today; no filter
|
|
substring that could match zero) and `cargo test --workspace` with an explicit
|
|
"0 failed" expectation, so "nothing ran" cannot masquerade as success.
|
|
9. **Parse-the-bytes:** all inlined bodies are Rust source-language (test +
|
|
implementation), not surface-language snippets; the profile declares no
|
|
`spec_validation` parser → parse gate is a no-op. The `implement` Rust compile
|
|
gate (Task 2 Steps 4-5) is the real check.
|