Files
Aura/docs/plans/0016-param-set-injection.md
T
Brummel bf5ff22371 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
2026-06-07 20:16:09 +02:00

697 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.