plan: 0016 param-set injection (#31)

Four crate-gated tasks for spec 0016: aura-core (LeafFactory + Scalar accessors),
aura-std (factory() on the 7 nodes), aura-engine (value-empty Leaf, build-then-wire
compile_with_params/bootstrap_with_params, kind+arity errors, vestigial schema
removal, fixtures + injection tests re-expressed), aura-cli (param-generic render,
sample + goldens, swap moved to the compiled view). Compile gates are crate-scoped
so each task's gate is satisfiable with all its callers threaded in-task.

refs #31
This commit is contained in:
2026-06-07 20:16:09 +02:00
parent e9eea1ada3
commit bf5ff22371
+696
View File
@@ -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 12 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<LeafFactory>`, `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<ParamSpec>,
build: Box<dyn Fn(&[Scalar]) -> Box<dyn Node>>,
}
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<ParamSpec>,
build: impl Fn(&[Scalar]) -> Box<dyn Node> + '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<dyn Node> {
(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<i64> {
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<f64> {
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::<ParamSpec>::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 <Node>` 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<ScalarKind>,
firing: Firing,
tx: Sender<(Timestamp, Vec<Scalar>)>,
) -> 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<N>`
(`blueprint.rs:33-37`):
```rust
pub enum BlueprintNode {
Leaf(LeafFactory),
Composite(Composite),
}
impl From<LeafFactory> 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<BlueprintNode>,
params: &[Scalar],
cursor: &mut usize,
flat_nodes: &mut Vec<Box<dyn Node>>,
flat_edges: &mut Vec<Edge>,
) -> Result<Vec<ItemLowering>, 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 = &params[*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<Box<dyn Node>>,
flat_edges: &mut Vec<Edge>,
) -> Result<ItemLowering, CompileError> {
```
- [ ] **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<Box<dyn Node>>, Vec<SourceSpec>, Vec<Edge>), CompileError> {
let expected = self.param_space().len();
if params.len() != expected {
return Err(CompileError::ParamArity { expected, got: params.len() });
}
let mut flat_nodes: Vec<Box<dyn Node>> = Vec::new();
let mut flat_edges: Vec<Edge> = 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<SourceSpec> = Vec::with_capacity(self.sources.len());
for src in &self.sources {
let mut targets: Vec<Target> = 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<Box<dyn Node>>, Vec<SourceSpec>, Vec<Edge>), 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<Scalar>) -> Result<Harness, CompileError> {
let (nodes, sources, edges) = self.compile_with_params(&params)?;
Harness::bootstrap(nodes, sources, edges).map_err(CompileError::Bootstrap)
}
/// No-param bootstrap (paramless blueprint).
pub fn bootstrap(self) -> Result<Harness, CompileError> {
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>(), eq2.try_iter().collect::<Vec<_>>());
}
```
(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.