# 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<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 graph 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_flat_graph` / 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_flat_graph` 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.
