diff --git a/docs/plans/0016-param-set-injection.md b/docs/plans/0016-param-set-injection.md new file mode 100644 index 0000000..944618d --- /dev/null +++ b/docs/plans/0016-param-set-injection.md @@ -0,0 +1,696 @@ +# Param-set injection — Implementation Plan + +> **Parent spec:** `docs/specs/0016-param-set-injection.md` +> +> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run +> this plan. Steps use `- [ ]` checkboxes for tracking. + +**Goal:** Make a blueprint value-empty — a leaf is a `LeafFactory` recipe +(`params → sized node`) — and bind a positional `Scalar` vector at bootstrap via a +new build-then-wire compile path, with kind + arity checks. + +**Architecture:** `LeafFactory { name, params, build }` lands in aura-core; the 7 +aura-std nodes expose `factory()`; aura-engine's `BlueprintNode::Leaf` becomes a +factory, `compile_with_params`/`bootstrap_with_params` build each leaf from its +kind-checked param slice while lowering (the existing structural inline/edge/source +rewrite is unchanged), and the vestigial pre-build `schema` methods are removed; +aura-cli's blueprint render reads the param-generic `LeafFactory::label()` and the +sample/goldens are re-expressed against the vector. + +**Tech Stack:** Rust workspace — aura-core (Node/Scalar contract), aura-std (nodes), +aura-engine (blueprint/compile/harness), aura-cli (run/graph faces). + +**Sequencing (compile gates):** The `Leaf(LeafFactory)` change breaks every +blueprint-leaf author site until repaired, so tasks are gated crate-by-crate: +Task 1 `cargo build -p aura-core`, Task 2 `-p aura-std`, Task 3 `cargo build -p +aura-engine --all-targets`, Task 4 `cargo build -p aura-cli --all-targets` then +`cargo test --workspace`. Tasks 1–2 are purely additive (no breakage); Task 3 is +the breaking change and repairs every aura-engine site (incl. its own tests) in +one task so its compile gate is satisfiable; Task 4 repairs aura-cli + runs the +workspace gate. + +--- + +## Files this plan creates or modifies + +- Modify: `crates/aura-core/src/node.rs` — add `LeafFactory`. +- Modify: `crates/aura-core/src/scalar.rs` — add `as_i64`/`as_f64`. +- Modify: `crates/aura-core/src/lib.rs` — re-export `LeafFactory`. +- Modify: `crates/aura-std/src/{sma,exposure,lincomb,sub,add,sim_broker,recorder}.rs` + — each gains `fn factory(...)` + a factory↔schema params test. +- Modify: `crates/aura-engine/src/blueprint.rs` — `Leaf(LeafFactory)`, + `From`, `collect_params`, `compile_with_params`, + `bootstrap_with_params`, `CompileError` variants, remove vestigial `schema` + methods + their test, re-express fixtures + tests. +- Modify: `crates/aura-cli/src/graph.rs` — `render_blueprint` uses + `LeafFactory::label()`. +- Modify: `crates/aura-cli/src/main.rs` — sample blueprint → factories + vector; + param-form call sites; re-capture blueprint-view goldens; move the swap to the + compiled view. + +--- + +## Task 1: aura-core — `LeafFactory` + `Scalar` accessors + +**Files:** +- Modify: `crates/aura-core/src/node.rs` +- Modify: `crates/aura-core/src/scalar.rs` +- Modify: `crates/aura-core/src/lib.rs` + +- [ ] **Step 1: Add `LeafFactory` to `node.rs`** + +After the `ParamSpec` struct (ends `node.rs:58`) and before the `NodeSchema` doc, +add (the `use` at `node.rs:11` already imports `Scalar`): + +```rust +/// A param-generic blueprint leaf (C19): a node's declared tunable params plus a +/// closure that builds a sized instance through the node's own constructor (the +/// single sizing/validation gate). A blueprint holds these recipes, never built +/// instances, so it stays value-empty until a param-set is injected (C19/C23). +pub struct LeafFactory { + name: &'static str, + params: Vec, + build: Box Box>, +} + +impl LeafFactory { + /// `name` is the param-generic render label (the node type, e.g. `"SMA"`); + /// `params` the declared knobs; `build` constructs a sized node from a + /// kind-checked param slice. + pub fn new( + name: &'static str, + params: Vec, + build: impl Fn(&[Scalar]) -> Box + 'static, + ) -> Self { + Self { name, params, build: Box::new(build) } + } + /// The declared tunable params (read by `Blueprint::param_space`, pre-build). + pub fn params(&self) -> &[ParamSpec] { + &self.params + } + /// Build a sized node from its param slice (the slice is kind-checked by the + /// caller before this runs). + pub fn build(&self, params: &[Scalar]) -> Box { + (self.build)(params) + } + /// The param-generic render label for the blueprint view (C22 "structure + /// before"): the node type plus its tunable param *names* — no values, a + /// value-empty recipe has none — e.g. `SMA(length)`, `LinComb(weights[0], + /// weights[1])`, or bare `SimBroker` when paramless. + pub fn label(&self) -> String { + if self.params.is_empty() { + self.name.to_string() + } else { + let knobs: Vec<&str> = self.params.iter().map(|p| p.name.as_str()).collect(); + format!("{}({})", self.name, knobs.join(", ")) + } + } +} +``` + +- [ ] **Step 2: Add value accessors to `scalar.rs`** + +Inside the existing `impl Scalar` block (after `kind`, `scalar.rs:30-37`), add: + +```rust + /// The `i64` payload, or `None` if this scalar is not an `I64`. + pub fn as_i64(self) -> Option { + if let Scalar::I64(v) = self { Some(v) } else { None } + } + /// The `f64` payload, or `None` if this scalar is not an `F64`. + pub fn as_f64(self) -> Option { + if let Scalar::F64(v) = self { Some(v) } else { None } + } +``` + +- [ ] **Step 3: Re-export `LeafFactory`** + +In `crates/aura-core/src/lib.rs:42`, add `LeafFactory` to the `pub use node::{...}` +list (keep alphabetical): `pub use node::{FieldSpec, Firing, InputSpec, LeafFactory, +Node, NodeSchema, ParamSpec};` + +- [ ] **Step 4: Tests in `node.rs` and `scalar.rs`** + +In `node.rs` tests (reuse the `Bare` node already defined in that module, `node.rs` +test mod), add: + +```rust + #[test] + fn leaf_factory_label_is_param_generic() { + let with = LeafFactory::new( + "SMA", + vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }], + |_| Box::new(Bare), + ); + assert_eq!(with.label(), "SMA(length)"); + let none = LeafFactory::new("Sub", vec![], |_| Box::new(Bare)); + assert_eq!(none.label(), "Sub"); + } + + #[test] + fn leaf_factory_build_runs_the_closure() { + let f = LeafFactory::new("Bare", vec![], |_| Box::new(Bare)); + assert_eq!(f.build(&[]).schema().params, Vec::::new()); + } +``` + +In `scalar.rs` tests, add: + +```rust + #[test] + fn scalar_value_accessors_are_kind_exact() { + assert_eq!(Scalar::I64(3).as_i64(), Some(3)); + assert_eq!(Scalar::I64(3).as_f64(), None); + assert_eq!(Scalar::F64(0.5).as_f64(), Some(0.5)); + assert_eq!(Scalar::F64(0.5).as_i64(), None); + } +``` + +(If `scalar.rs` has no `#[cfg(test)] mod tests`, add one with `use super::*;`.) + +- [ ] **Step 5: Gate** + +Run: `cargo test -p aura-core` +Expected: PASS, including `leaf_factory_label_is_param_generic`, +`leaf_factory_build_runs_the_closure`, `scalar_value_accessors_are_kind_exact`. + +--- + +## Task 2: aura-std — `factory()` on the 7 nodes + +**Files:** Modify each of +`crates/aura-std/src/{sma,exposure,lincomb,sub,add,sim_broker,recorder}.rs`. + +Each `factory()` is an inherent method in the node's existing `impl ` block +(beside `new`). Add `LeafFactory` to each file's `use aura_core::{...}` line. + +- [ ] **Step 1: `Sma::factory` (`sma.rs`)** + +```rust + /// The param-generic recipe for a blueprint leaf: declares `length` and builds + /// through `Sma::new` (the single sizing/validation gate; the slice is + /// kind-checked before `build` runs, so the typed read is total). + pub fn factory() -> LeafFactory { + LeafFactory::new( + "SMA", + vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }], + |p| Box::new(Sma::new(p[0].as_i64().expect("length slot is I64") as usize)), + ) + } +``` + +- [ ] **Step 2: `Exposure::factory` (`exposure.rs`)** + +```rust + pub fn factory() -> LeafFactory { + LeafFactory::new( + "Exposure", + vec![ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }], + |p| Box::new(Exposure::new(p[0].as_f64().expect("scale slot is F64"))), + ) + } +``` + +- [ ] **Step 3: `LinComb::factory(arity)` (`lincomb.rs`)** + +The arity is topology (fixed per blueprint, C19), taken as a factory arg; only the +weight *values* are injected. + +```rust + pub fn factory(arity: usize) -> LeafFactory { + let params = (0..arity) + .map(|i| ParamSpec { name: format!("weights[{i}]"), kind: ScalarKind::F64 }) + .collect(); + LeafFactory::new( + "LinComb", + params, + |p| Box::new(LinComb::new( + p.iter().map(|s| s.as_f64().expect("weight slot is F64")).collect(), + )), + ) + } +``` + +- [ ] **Step 4: paramless `Sub`/`Add::factory` (`sub.rs`, `add.rs`)** + +```rust + // sub.rs + pub fn factory() -> LeafFactory { + LeafFactory::new("Sub", vec![], |_| Box::new(Sub::new())) + } + // add.rs + pub fn factory() -> LeafFactory { + LeafFactory::new("Add", vec![], |_| Box::new(Add::new())) + } +``` + +- [ ] **Step 5: `SimBroker::factory(pip_size)` (`sim_broker.rs`)** + +`pip_size` is metadata (C10/C15), not a tunable param — captured by the closure. + +```rust + pub fn factory(pip_size: f64) -> LeafFactory { + LeafFactory::new("SimBroker", vec![], move |_| Box::new(SimBroker::new(pip_size))) + } +``` + +- [ ] **Step 6: `Recorder::factory(kinds, firing, tx)` (`recorder.rs`)** + +The channel + kinds + firing are non-param construction args — captured; `tx` is +cloned per build (`mpsc::Sender: Clone`). + +```rust + pub fn factory( + kinds: Vec, + firing: Firing, + tx: Sender<(Timestamp, Vec)>, + ) -> LeafFactory { + LeafFactory::new("Recorder", vec![], move |_| { + Box::new(Recorder::new(&kinds, firing, tx.clone())) + }) + } +``` + +- [ ] **Step 7: factory↔schema params agreement test (one per node)** + +Add to each node's `#[cfg(test)] mod tests` a test asserting `factory().params()` +equals the built node's `schema().params`. Example for `sma.rs`: + +```rust + #[test] + fn factory_params_match_built_node_schema() { + let f = Sma::factory(); + let built = f.build(&[Scalar::I64(3)]); + assert_eq!(f.params(), built.schema().params.as_slice()); + } +``` + +Mirror it per node with a valid sample slice: `Exposure` `&[Scalar::F64(0.5)]`; +`LinComb::factory(2)` `&[Scalar::F64(1.0), Scalar::F64(-1.0)]`; `Sub`/`Add` `&[]`; +`SimBroker::factory(0.0001)` `&[]`; `Recorder::factory(vec![ScalarKind::F64], +Firing::Any, tx)` `&[]` (make a throwaway `mpsc::channel()` for `tx`). + +- [ ] **Step 8: Gate** + +Run: `cargo test -p aura-std` +Expected: PASS, including the 7 `factory_params_match_built_node_schema` tests. + +--- + +## Task 3: aura-engine — value-empty leaf, build-then-wire compile, errors + +**Files:** Modify `crates/aura-engine/src/blueprint.rs`. + +- [ ] **Step 1: `BlueprintNode::Leaf` + the lift** + +Change the enum (`blueprint.rs:27-30`) and replace the generic `From` +(`blueprint.rs:33-37`): + +```rust +pub enum BlueprintNode { + Leaf(LeafFactory), + Composite(Composite), +} + +impl From for BlueprintNode { + fn from(factory: LeafFactory) -> Self { + BlueprintNode::Leaf(factory) + } +} +``` + +Add `LeafFactory` and `Scalar` to the `use aura_core::{...}` at `blueprint.rs:14`. + +- [ ] **Step 2: Remove the vestigial pre-build `schema` methods** + +Delete the `impl BlueprintNode { fn schema(&self) -> NodeSchema {...} }` block +(`blueprint.rs:39-48`) and `Composite::schema` (`blueprint.rs:103-114`). Both have +no live caller — `compile` resolves every interface on the built flat nodes. Delete +the unit test `composite_schema_derives_role_and_output_kinds` (`blueprint.rs:435`). + +- [ ] **Step 3: `collect_params` reads `factory.params()`** + +In `collect_params` (`blueprint.rs:217-240`), the `Leaf` arm (`220-229`): + +```rust + BlueprintNode::Leaf(factory) => { + for p in factory.params() { + let name = if prefix.is_empty() { + p.name.clone() + } else { + format!("{prefix}.{}", p.name) + }; + out.push(ParamSpec { name, kind: p.kind }); + } + } +``` + +(`param_space` at `blueprint.rs:166-170` is otherwise unchanged.) + +- [ ] **Step 4: Two new `CompileError` variants** + +In `enum CompileError` (`blueprint.rs:120-130`) add: + +```rust + /// An injected param value's scalar kind does not match the slot's declared + /// kind. `slot` is the flat param-space index. + ParamKindMismatch { slot: usize, expected: ScalarKind, got: ScalarKind }, + /// The injected vector's length does not equal the sum of declared params. + ParamArity { expected: usize, got: usize }, +``` + +`ScalarKind` is already imported (`blueprint.rs:14`). + +- [ ] **Step 5: Thread params+cursor through `lower_items` / `inline_composite`** + +`lower_items` (`blueprint.rs:255-274`) gains `params: &[Scalar]` and `cursor: &mut +usize`, and its `Leaf` arm builds (kind-checking) instead of moving a node: + +```rust +fn lower_items( + items: Vec, + params: &[Scalar], + cursor: &mut usize, + flat_nodes: &mut Vec>, + flat_edges: &mut Vec, +) -> Result, CompileError> { + let mut lowerings = Vec::with_capacity(items.len()); + for item in items { + match item { + BlueprintNode::Leaf(factory) => { + let n = factory.params().len(); + let slice = ¶ms[*cursor..*cursor + n]; // in range: arity checked up front + for (i, spec) in factory.params().iter().enumerate() { + let got = slice[i].kind(); + if got != spec.kind { + return Err(CompileError::ParamKindMismatch { + slot: *cursor + i, + expected: spec.kind, + got, + }); + } + } + let index = flat_nodes.len(); + flat_nodes.push(factory.build(slice)); + *cursor += n; + lowerings.push(ItemLowering::Leaf { index }); + } + BlueprintNode::Composite(c) => { + lowerings.push(inline_composite(c, params, cursor, flat_nodes, flat_edges)?); + } + } + } + Ok(lowerings) +} +``` + +`inline_composite` (`blueprint.rs:278-339`) gains the same `params: &[Scalar]` + +`cursor: &mut usize` params and forwards them on its recursive `lower_items` call +(`blueprint.rs:295`): `let interior = lower_items(nodes, params, cursor, +flat_nodes, flat_edges)?;`. Its signature line becomes: + +```rust +fn inline_composite( + c: Composite, + params: &[Scalar], + cursor: &mut usize, + flat_nodes: &mut Vec>, + flat_edges: &mut Vec, +) -> Result { +``` + +- [ ] **Step 6: `compile_with_params` + `bootstrap_with_params` + thin no-param wrappers** + +Replace `compile` (`blueprint.rs:179-204`) and `bootstrap` (`207-210`) with the +param-driven path plus no-param wrappers. The arity is checked up front via +`param_space().len()` so the per-leaf slices never overrun (only kind can fail): + +```rust + /// Compile the value-empty recipe under an injected param vector: build each + /// leaf from its kind-checked slice while lowering, then rewrite edges/sources + /// exactly as before (structure is param-invariant, C19/C23). The vector is + /// total and positional — one value per `param_space()` slot, in slot order. + #[allow(clippy::type_complexity)] + pub fn compile_with_params( + self, + params: &[Scalar], + ) -> Result<(Vec>, Vec, Vec), CompileError> { + let expected = self.param_space().len(); + if params.len() != expected { + return Err(CompileError::ParamArity { expected, got: params.len() }); + } + let mut flat_nodes: Vec> = Vec::new(); + let mut flat_edges: Vec = Vec::new(); + let mut cursor = 0usize; + let lowerings = lower_items(self.nodes, params, &mut cursor, &mut flat_nodes, &mut flat_edges)?; + + for e in &self.edges { + for fe in rewrite_edge(e, &lowerings, &flat_nodes)? { + flat_edges.push(fe); + } + } + let mut flat_sources: Vec = Vec::with_capacity(self.sources.len()); + for src in &self.sources { + let mut targets: Vec = Vec::new(); + for t in &src.targets { + targets.extend(resolve_target(t, &lowerings)?); + } + flat_sources.push(SourceSpec { kind: src.kind, targets }); + } + Ok((flat_nodes, flat_sources, flat_edges)) + } + + /// No-param compile (a blueprint that declares no params); errors `ParamArity` + /// if any param is declared. + #[allow(clippy::type_complexity)] + pub fn compile(self) -> Result<(Vec>, Vec, Vec), CompileError> { + self.compile_with_params(&[]) + } + + /// Compile under an injected vector, then hand the flat compilat to the + /// unchanged `Harness::bootstrap`. + pub fn bootstrap_with_params(self, params: Vec) -> Result { + let (nodes, sources, edges) = self.compile_with_params(¶ms)?; + Harness::bootstrap(nodes, sources, edges).map_err(CompileError::Bootstrap) + } + + /// No-param bootstrap (paramless blueprint). + pub fn bootstrap(self) -> Result { + self.bootstrap_with_params(vec![]) + } +``` + +(Keep the existing `#[allow(clippy::type_complexity)]` + comment that sat above +`compile`.) + +- [ ] **Step 7: Re-express the fixtures (mechanical)** + +Every blueprint-leaf author site in the test module changes by the rules below; the +hand-wired `hand_wired_sma_cross_harness` (which builds nodes directly into +`Harness::bootstrap`, not via `BlueprintNode`) is **unchanged**. + +- `Sma::new(k).into()` → `Sma::factory().into()`; the `k` moves into the caller's + bootstrap/compile vector (`Scalar::I64(k)`). +- `Exposure::new(s).into()` → `Exposure::factory().into()`; `s` → `Scalar::F64(s)`. +- `SimBroker::new(p).into()` → `SimBroker::factory(p).into()` (pip captured, no + vector slot). +- `Recorder::new(&[K..], f, tx).into()` → `Recorder::factory(vec![K..], f, tx).into()`. +- `LinComb::new(w).into()` → `LinComb::factory(w.len()).into()`; the weights → + `w.iter().map(|x| Scalar::F64(*x))` in the caller's vector. +- `sma_cross(fast, slow)` builder (`blueprint.rs:721-732`) → `sma_cross()` taking no + args, building two `Sma::factory()` leaves; callers move `fast`/`slow` into their + vector. +- A test-local node `Leaf(Box::new(X))` (sites `440`, `535`, `576`, `618`, `648`) + → an inline factory: `BlueprintNode::Leaf(LeafFactory::new("X", vec![], |_| + Box::new(X::new())))` (or `X::new().into()` if that test node is given a + `factory()`; inline is simpler for one-off test nodes). +- `bp.bootstrap()` → `bp.bootstrap_with_params(vec![..])` with the vector matching + the leaves' declared params in `param_space()` order; `bp.compile()` → + `bp.compile_with_params(&[..])`. For a paramless fixture, `bootstrap_with_params( + vec![])` / `compile_with_params(&[])` (or the thin `bootstrap()`/`compile()`). + +`composite_sma_cross_harness` (`blueprint.rs:736-766`) becomes value-empty: +`Composite(sma_cross())`, `Exposure::factory().into()`, `SimBroker::factory(0.0001) +.into()`, `Recorder::factory(vec![ScalarKind::F64], Firing::Any, tx_eq).into()`, +etc. Its declared `param_space()` is `[length:I64, length:I64, scale:F64]`, so its +point vector is `vec![Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)]`. + +- [ ] **Step 8: Re-express the load-bearing tests** + +- `composite_sma_cross_runs_bit_identical_to_hand_wired` (`blueprint.rs:768-792`): + build the composite via `bp.bootstrap_with_params(vec![Scalar::I64(2), + Scalar::I64(4), Scalar::F64(0.5)])`; the hand-wired side stays `Sma::new(2)`, + `Sma::new(4)`, `Exposure::new(0.5)`. Assertions unchanged (traces bit-identical). +- `param_space_mirrors_compiled_flat_node_param_order` (`802-834`) and + `..._under_nesting` (`886-936`): call `bp.compile_with_params(&[..])` with the + matching vector instead of `bp.compile()`; the `flat_nodes.iter().flat_map(|n| + n.schema().params)` projection and the kind-by-slot assertions are unchanged + (built nodes still carry `schema().params`). For the single-level case the vector + is `[Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)]`; for the nested + `strategy → { fast_slow → [Sma, Sma, Sub], LinComb }` case it is `[Scalar::I64(2), + Scalar::I64(4), Scalar::F64(1.0), Scalar::F64(-1.0)]`. +- `param_space_is_flat_path_qualified_and_slot_disambiguated` (`837`), + `top_level_leaf_params_are_unqualified` (`876`), `param_space_is_deterministic` + (`885`), `param_space_empty_for_paramless_and_empty_blueprints` (`896`): rebuild + their blueprints with factory leaves; the `param_space()` assertions are + unchanged. + +- [ ] **Step 9: New injection tests** + +Add to the test module: + +```rust + #[test] + fn injecting_a_different_vector_changes_the_run() { + let prices = synthetic_prices(); + let (bp, eq, _ex) = composite_sma_cross_harness(); + let mut a = bp.bootstrap_with_params(vec![Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)]) + .expect("compiles"); + a.run(vec![prices.clone()]); + let a_eq = eq.try_iter().collect::>(); + + let (bp2, eq2, _ex2) = composite_sma_cross_harness(); + let mut b = bp2.bootstrap_with_params(vec![Scalar::I64(5), Scalar::I64(20), Scalar::F64(1.0)]) + .expect("compiles"); + b.run(vec![prices]); + let b_eq = eq2.try_iter().collect::>(); + + assert!(!a_eq.is_empty() && !b_eq.is_empty(), "both traces populated"); + assert_ne!(a_eq, b_eq, "a different vector must yield a different run"); + } + + #[test] + fn wrong_kind_is_a_param_kind_mismatch() { + let (bp, _eq, _ex) = composite_sma_cross_harness(); + // slot 0 is I64 (an SMA length); inject F64 there + let err = bp.bootstrap_with_params(vec![Scalar::F64(2.0), Scalar::I64(4), Scalar::F64(0.5)]) + .unwrap_err(); + assert!(matches!(err, CompileError::ParamKindMismatch { slot: 0, .. })); + } + + #[test] + fn wrong_arity_is_a_param_arity_error() { + let (short, _e1, _x1) = composite_sma_cross_harness(); + assert!(matches!( + short.bootstrap_with_params(vec![Scalar::I64(2)]).unwrap_err(), + CompileError::ParamArity { expected: 3, got: 1 } + )); + let (long, _e2, _x2) = composite_sma_cross_harness(); + assert!(matches!( + long.bootstrap_with_params( + vec![Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5), Scalar::F64(0.0)] + ).unwrap_err(), + CompileError::ParamArity { expected: 3, got: 4 } + )); + } + + #[test] + fn same_vector_bootstraps_identically() { + let prices = synthetic_prices(); + let (bp, eq, _ex) = composite_sma_cross_harness(); + let mut a = bp.bootstrap_with_params(vec![Scalar::I64(3), Scalar::I64(9), Scalar::F64(0.7)]) + .expect("compiles"); + a.run(vec![prices.clone()]); + let (bp2, eq2, _ex2) = composite_sma_cross_harness(); + let mut b = bp2.bootstrap_with_params(vec![Scalar::I64(3), Scalar::I64(9), Scalar::F64(0.7)]) + .expect("compiles"); + b.run(vec![prices]); + assert_eq!(eq.try_iter().collect::>(), eq2.try_iter().collect::>()); + } +``` + +(If `composite_sma_cross_harness` returns a fresh blueprint+receivers per call, +each test calls it anew as shown; keep its existing return signature.) + +- [ ] **Step 10: Gate** + +Run: `cargo build -p aura-engine --all-targets` +Expected: 0 errors (every fixture site repaired). + +Run: `cargo test -p aura-engine` +Expected: PASS — bit-identity, both mirror tests, the four new injection tests, and +all `param_space*` tests green. + +--- + +## Task 4: aura-cli — param-generic render + sample + goldens + +**Files:** Modify `crates/aura-cli/src/graph.rs`, `crates/aura-cli/src/main.rs`. + +- [ ] **Step 1: `render_blueprint` reads `LeafFactory::label()` (`graph.rs`)** + +The two leaf arms (`graph.rs:60-63` top-level, `:69-72` composite-interior) push +`node.label()`. The leaf is now a `LeafFactory`; push `factory.label()`: + +```rust + BlueprintNode::Leaf(factory) => { + let id = labels.len(); + labels.push(factory.label()); + item_display.push(ItemDisplay::Leaf(id)); + } +``` + +and inside the composite loop: + +```rust + BlueprintNode::Leaf(factory) => { + let id = labels.len(); + labels.push(factory.label()); + interior_ids.push(id); + } +``` + +(`render_compilat` / the compiled-view renderer operates on built flat nodes via +`Node::label()` and is unchanged.) + +- [ ] **Step 2: Sample blueprint → factories (`main.rs`)** + +- `sma_cross(name, fast, slow)` (`main.rs:120-131`): build two `Sma::factory()` + leaves; drop `fast`/`slow` from the builder (they move to the injected vector). + Keep `name` for the composite. +- `build_sample(fast, slow)` (`main.rs:136-161`): the four `.into()` lifts become + `Exposure::factory().into()`, `SimBroker::factory(0.0001).into()`, + `Recorder::factory(...).into()` per their constructors; the composite is + `BlueprintNode::Composite(sma_cross(name))`. `build_sample` no longer bakes + `fast`/`slow`. +- `sample_blueprint` (`main.rs:164-166`) and the `bp.compile()` sites + (`main.rs:180,221,272`): supply the point vector. The sample's `param_space()` is + `[length:I64, length:I64, scale:F64]`, so its vector is `vec![Scalar::I64(2), + Scalar::I64(4), Scalar::F64(0.5)]`. Use `compile_with_params(&[..])` / + `bootstrap_with_params(vec![..])` at those sites. + +- [ ] **Step 3: Move the swap to the compiled view (`main.rs`)** + +`sample_blueprint_swapped` (`main.rs:201-203`) + `swapped_sma_inputs_render_differently` +(`main.rs:230`): the blueprint view is now param-generic and identical for both +orderings, so the swap is not observable there. Re-express the swap as a different +injected vector (`vec![Scalar::I64(4), Scalar::I64(2), Scalar::F64(0.5)]`) and +assert the **compiled** view differs (`render_compilat` of the compiled flat nodes +shows `SMA(4)`/`SMA(2)` swapped), not the blueprint view. Rename the test to its +new premise (e.g. `swapped_param_vector_changes_the_compiled_render`). + +- [ ] **Step 4: Re-capture the blueprint-view goldens (`main.rs`)** + +`blueprint_view_shows_cluster_and_param_labels` (`main.rs:206`), `blueprint_view_golden` +(`:238`): the blueprint-view labels become param-generic — `SMA(length)` (both +SMAs identical), `Exposure(scale)`, `SimBroker`. Update the pinned ASCII strings to +the param-generic form. The compiled-view goldens `compiled_view_dissolves_the_composite_boundary` +(`:219`) and `compiled_view_golden` (`:270`) stay valued (`SMA(2)`, `SMA(4)`, +`Exposure(0.5)`, `SimBroker(0.0001)`) — do not change them. + +To get the exact new blueprint-view bytes, run the rendering in a scratch +assertion or `cargo run -- graph` (blueprint view) after Steps 1-2 compile, and +paste the produced ASCII verbatim into the golden. Do not hand-guess box-drawing +columns. + +- [ ] **Step 5: Gate** + +Run: `cargo build -p aura-cli --all-targets` +Expected: 0 errors. + +Run: `cargo test --workspace` +Expected: PASS — all crates green, including the re-captured goldens and the +re-premised swap test. + +Run: `cargo clippy --workspace --all-targets -- -D warnings` +Expected: clean.