# Param-set injection: value-empty blueprints bound by a positional vector — Design Spec

**Date:** 2026-06-07
**Status:** Draft — awaiting user spec review
**Authors:** orchestrator + Claude

## Goal

Make a blueprint's tunable values **injected at bootstrap** rather than baked into
the builder, so one param-generic blueprint bootstraps into many distinct frozen
instances under different param-sets, with no cdylib rebuild (C12/C19). This is
cycle B (#31) of the milestone "The World — parameter-space & sweep": the binding
primitive a sweep (#32) drives.

Today a node's value is baked at authoring time — `Sma::new(2)` constructs a live
node carrying `length = 2`, and `BlueprintNode::Leaf` holds that already-valued
`Box<dyn Node>`. One blueprint therefore yields exactly one instance. #30 landed
the declarable surface (`ParamSpec`, `NodeSchema.params`, `Blueprint::param_space()`
— a flat, path-qualified, positionally-identified param-space). #31 makes the
param-set an injected positional vector bound slot-by-slot against that space.

The design choice ratified for this cycle: a blueprint leaf is a **recipe**
(`params → sized node`), never a built instance. A node's value lives **only** in
the injected vector — there is no default value baked into the blueprint. This
keeps the blueprint a pure param-generic recipe (C19) and routes every injected
value through the node's own constructor (the single sizing/validation gate).

## Architecture

A `BlueprintNode::Leaf` stops holding a live `Box<dyn Node>` and instead holds a
`LeafFactory { params, build }`: the node's declared param specs plus a closure
`&[Scalar] -> Box<dyn Node>` that builds a sized instance through the node's own
constructor. The blueprint is value-empty — it carries the topology and the
declared param knobs, but no values.

`Blueprint::param_space()` reads each leaf's `factory.params` (pre-build, no
instances), path-qualified through composites exactly as in #30. The aggregated
flat vector is the binding target.

The bootstrap *is* the compilation (C23): a new `bootstrap_with_params(self,
params: Vec<Scalar>)` lowers the recipe and **builds each leaf as it lowers**,
consuming the param vector slot-by-slot in the same depth-first order
`param_space()` reports. Building before wiring means the wiring step reads io
from the *built* nodes' `schema()` — so no static io skeleton has to be declared a
second time on the factory. Topology is param-invariant (C19), so re-running this
pass per param-set is exactly the "cheap graph re-compilation per param-set" C19
sanctions (not a code/cdylib recompile).

Because `compile_with_params` consumes the vector in the same recipe-walk order
`param_space()` reports, the two share one traversal. The dual-traversal drift
hazard that #34 hardened against (a separate read-only `collect_params` walk vs.
the `lower_items` walk that could desync) collapses into a single source. #34's
single-level mirror guard is subsumed by this structural unification; the cycle's
own tests re-pin the property in its new form (the order `param_space()` reports
equals the order `compile_with_params` consumes).

Determinism (C1) is preserved: the same vector against the same blueprint produces
a bit-identical run, because building is a pure function of the param slice and the
lowering/wiring is unchanged in structure.

**Blueprint rendering (C22 "structure before").** A value-empty blueprint has no
bound values, so the `aura graph` *blueprint view* (`render_blueprint`, pre-inline
cluster boxes) can no longer label a leaf `SMA(2)` — there is no `2` until a vector
is injected. The view instead renders the **param-generic** label from
`LeafFactory::label()`: the **bare node type**, e.g. `SMA`, `Exposure`, `SimBroker`.
Both SMAs of a cross render identically as `[SMA]` (they are the same recipe; their
distinct values live in the vector, and the graph's edges still distinguish them
positionally). The tunable knobs are surfaced by `param_space()`, not in the graph.
The label is the bare type — not `SMA(length)` — because the `ascii-dag` renderer
writes a label verbatim on one line (no multi-line) and overlaps two wide sibling
boxes inside a cluster subgraph; a knob suffix garbles the view, and domain labels
grow unboundedly wide. The *compiled view* is unaffected: it renders post-build flat
nodes via the unchanged `Node::label()`, so a bound `SMA(2)` still shows there. This
is the correct C22 reading — structure (param-generic) before a run, values (bound)
after.

**Vestigial pre-build interface, removed.** `Composite::schema()` and the private
`BlueprintNode::schema()` derive a blueprint item's interface from its interior
*built* leaves' `schema()`. A value-empty leaf has no built node, and these methods
have no live caller — `compile` resolves every interface structurally on the built
flat nodes (`slot_kind`, the output-field range checks), not on pre-build item
schemas. They are removed (with their one dedicated unit test,
`composite_schema_derives_role_and_output_kinds`); this is why the factory carries
no input/output skeleton (the "build-then-wire" decision, above).

## Concrete code shapes

### The user-facing program (the acceptance evidence)

The author writes a value-empty harness (factories, not valued nodes) and the run
supplies the point as a positional vector. The realistic SMA-cross harness, the
engine's own worked example:

```rust
// Author the topology once — no baked lengths; the composite holds Sma factories.
let bp = sma_cross_harness();                 // was sma_cross(2, 4); now value-empty

// Inspect the param-space the run binds against (#30, unchanged surface).
let space = bp.param_space();
// space == ["sma_cross.length": I64, "sma_cross.length": I64, "scale": F64]

// Inject a total positional vector -> one frozen instance (the #31 primitive).
let mut inst = bp.bootstrap_with_params(vec![
    Scalar::I64(2),   // sma_cross.length  (slot 0)
    Scalar::I64(4),   // sma_cross.length  (slot 1)
    Scalar::F64(0.5), // scale             (slot 2)
])?;
inst.run(vec![prices]);
// equity + exposure traces are bit-identical to today's hand-wired sma_cross(2, 4).

// The same blueprint, a different point -> a distinct instance, no cdylib rebuild.
let mut wider = sma_cross_harness().bootstrap_with_params(vec![
    Scalar::I64(5), Scalar::I64(20), Scalar::F64(1.0),
])?;
wider.run(vec![prices]);                       // different lengths -> different trace
```

A node author exposes a recipe instead of a valued constructor at the blueprint
site:

```rust
impl Sma {
    /// The param-generic recipe for a blueprint leaf: declares the knob and builds
    /// a sized node 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)),
        )
    }
}
```

The build closures read typed values out of the `Scalar` slice. `Scalar` today
exposes only `kind()` and `From` conversions, so this cycle adds two value
accessors in aura-core, mirroring `Scalar::kind`:

```rust
// crates/aura-core/src/scalar.rs (new, beside `kind`)
impl Scalar {
    pub fn as_i64(self) -> Option<i64> { if let Scalar::I64(v) = self { Some(v) } else { None } }
    pub fn as_f64(self) -> Option<f64> { if let Scalar::F64(v) = self { Some(v) } else { None } }
}
```

The accessors return `Option` (honest for a wrong-kind read); the build closures
`.expect` because `compile_with_params` kind-checks each slot before calling
`build`, so a `None` there is unreachable by construction.

### Implementation shapes (before → after)

The new construction-contract type, in aura-core beside `Node`/`ParamSpec`:

```rust
// crates/aura-core/src/node.rs (new)
pub struct LeafFactory {
    name: &'static str,
    params: Vec<ParamSpec>,
    build: Box<dyn Fn(&[Scalar]) -> Box<dyn Node>>,
}

impl LeafFactory {
    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) }
    }
    pub fn params(&self) -> &[ParamSpec] { &self.params }
    pub fn build(&self, p: &[Scalar]) -> Box<dyn Node> { (self.build)(p) }
    /// The param-generic render label for the blueprint view (C22 "structure
    /// before"): just the node type, e.g. `SMA`. A value-empty recipe has no
    /// values to show; the tunable knobs are surfaced by `Blueprint::param_space`,
    /// not in the graph. The label is the bare type because the `ascii-dag`
    /// renderer writes a label verbatim on one line (no wrapping / multi-line) and
    /// overlaps two wide sibling boxes inside a cluster subgraph — a knob suffix
    /// (`SMA(length)`) garbles the view, and domain labels grow unboundedly wide.
    pub fn label(&self) -> String {
        self.name.to_string()
    }
}
```

The blueprint leaf becomes a recipe; the ergonomic lift changes accordingly:

```rust
// crates/aura-engine/src/blueprint.rs
// before:
//   pub enum BlueprintNode { Leaf(Box<dyn Node>), Composite(Composite) }
//   impl<N: Node + 'static> From<N> for BlueprintNode { ... Leaf(Box::new(node)) }
// after:
pub enum BlueprintNode {
    Leaf(LeafFactory),
    Composite(Composite),
}
impl From<LeafFactory> for BlueprintNode {
    fn from(f: LeafFactory) -> Self { BlueprintNode::Leaf(f) }
}
```

`collect_params` reads `factory.params()` instead of a live node's
`schema().params` (the walk and path-qualification are otherwise unchanged from
#30):

```rust
// crates/aura-engine/src/blueprint.rs
match item {
    BlueprintNode::Leaf(f) => {
        for p in f.params() {
            let name = if prefix.is_empty() { p.name.clone() }
                       else { format!("{prefix}.{}", p.name) };
            out.push(ParamSpec { name, kind: p.kind });
        }
    }
    BlueprintNode::Composite(c) => { /* recurse, unchanged */ }
}
```

The param-driven compilation and the new entry point:

```rust
// crates/aura-engine/src/blueprint.rs
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)
}
```

`lower_items` / `inline_composite` thread a cursor over `params`, build each leaf
from its slice (kind-checked), and otherwise lower exactly as before — now over
built nodes. `compile_with_params` errors if the cursor does not consume the whole
vector (arity).

The two new structural faults:

```rust
// crates/aura-engine/src/blueprint.rs
pub enum CompileError {
    // ... existing variants ...
    ParamKindMismatch { slot: usize, expected: ScalarKind, got: ScalarKind },
    ParamArity { expected: usize, got: usize },
}
```

## Components

| Crate / file | Change |
|---|---|
| `crates/aura-core/src/node.rs` | New `LeafFactory { name, params, build }` (+ `new`/`params`/`build`/`label`). `label()` renders the bare node type for the blueprint view (the ascii-dag renderer cannot render wide cluster-sibling labels, so no knob suffix). `Node` trait unchanged — construction lives in the closure, not a new trait method. |
| `crates/aura-core/src/scalar.rs` | New value accessors `Scalar::as_i64(self) -> Option<i64>` and `as_f64(self) -> Option<f64>` (mirroring `Scalar::kind`), used by the build closures to read the kind-checked slice. |
| `crates/aura-core/src/lib.rs` | Re-export `LeafFactory`. |
| `crates/aura-std/src/*.rs` | Each node gains `fn factory() -> LeafFactory`. With params: `Sma` (`length:I64`), `Exposure` (`scale:F64`), `LinComb::factory(arity)` declares `arity` × `weights[i]:F64` (the arity is topology — fixed per blueprint, C19 — taken as a factory arg; only the weight *values* are injected). Paramless: `Sub`, `Add`, `SimBroker`, `Recorder` (`params: vec![]`; their `build` closures forward the non-param construction args these nodes already take, e.g. the `Recorder` channel — captured by the closure). |
| `crates/aura-engine/src/blueprint.rs` | `BlueprintNode::Leaf(LeafFactory)`; `From<N>` → `From<LeafFactory>`; `collect_params` reads `factory.params()`; `compile` → `compile_with_params(&self, &[Scalar])` (build-then-wire); `bootstrap_with_params`; two `CompileError` variants. `compile`/`bootstrap` no-param forms are retained as thin wrappers over the param-driven path with an empty vector (valid only when no params are declared). Removes the vestigial `Composite::schema` / `BlueprintNode::schema` (no live caller — interface resolution is structural on built flat nodes) and their unit test. |
| `crates/aura-cli/src/graph.rs` | `render_blueprint`'s per-leaf label comes from `LeafFactory::label()` (param-generic) instead of a built node's `Node::label()`. The compiled view is unchanged (post-build flat nodes still label valued). |
| Fixtures / CLI sample | `sma_cross(2, 4)` → `sma_cross()` (factory leaves); fixtures and the `aura run`/`aura graph` sample supply their point as a vector. `sample_blueprint_swapped` expresses its mis-wiring by swapping the injected vector, not the builder args (OQ3). Bit-identity, blueprint-view, and compiled-view golden tests re-expressed (blueprint-view labels become param-generic; compiled-view labels stay valued). |

**Param declaration lives in two honest places.** `factory.params` (the
param-generic recipe, read by `param_space()` pre-build) and the built node's
`schema().params` (#30) describe the same slots. This is a natural per-node-author
obligation, like `schema` ↔ `eval` agreement; a test pins `factory.params ==
built.schema().params` per node so they cannot silently diverge.

## Data flow

```
recipe (LeafFactories + edges + sources)
  │  param_space()  -> reads factory.params, path-qualified  -> [length:I64, length:I64, scale:F64]
  │                                                              (consumer orders its vector against this)
  ▼  bootstrap_with_params(vec![I64(2), I64(4), F64(0.5)])
compile_with_params: depth-first walk; per leaf  slice = params[cur .. cur + n]
                     ├─ kind-check slice against factory.params  (structural fault -> CompileError)
                     ├─ node = factory.build(slice)               (through new(): assert + sizing)
                     └─ lower / inline / wire as today, over built nodes
                     └─ after the walk: cur == params.len()       (else ParamArity)
  ▼  flat (nodes, sources, edges)  ->  Harness::bootstrap  ->  frozen instance  ->  run
C1: the same vector -> a bit-identical run.
```

## Error handling

`bootstrap_with_params` / `compile_with_params` return `Result<_, CompileError>`,
with two new variants for the structural contract:

- `ParamKindMismatch { slot, expected, got }` — an injected value's `ScalarKind`
  does not match the slot's declared kind (the typed-value check #30 deferred,
  realized here). `slot` is the flat param-space index.
- `ParamArity { expected, got }` — the vector length does not equal the sum of
  declared params across the blueprint.

The **value domain** (e.g. `length >= 1`) is *not* a new typed error in this
cycle. `factory.build` calls the node's constructor, whose own `assert!`
(`Sma::new` asserts `length >= 1`) stands as the invariant gate; injecting an
out-of-domain value panics. The *search-range / valid domain* belongs to the run,
not the node (C20, established at #30's close), and full domain validation is
#32/C20's concern — introducing it here would be scope creep.

## Testing strategy

- **Bit-identity (the central proof).** `bootstrap_with_params(vec![I64(2),
  I64(4), F64(0.5)])` on the SMA-cross harness produces equity + exposure traces
  bit-identical to the hand-wired `sma_cross(2, 4)` graph — build-then-wire does
  not change the flat graph for a given point. (Re-expresses
  `composite_sma_cross_runs_bit_identical_to_hand_wired`.)
- **Injection is observable.** A different vector (`[I64(5), I64(20), F64(1.0)]`)
  yields a different, populated trace from the same blueprint — distinct instances,
  no rebuild.
- **Kind check.** A vector with a slot of the wrong kind (e.g. `F64` where the slot
  is `I64`) returns `ParamKindMismatch { slot, .. }`.
- **Arity check.** A too-short and a too-long vector each return
  `ParamArity { expected, got }`.
- **Determinism (C1).** The same vector bootstrapped twice yields identical runs.
- **Param declaration agreement.** For each std node, `factory().params()` equals
  the built node's `schema().params`.
- **Order invariant (subsumes #34).** `param_space()`'s slot order equals the order
  `compile_with_params` consumes the vector, on a nested-composite harness — pinned
  by building the nested blueprint and asserting kind-by-slot against the compiled
  flat-node order (the #34 nested case, re-pinned in the unified form).
- **Paramless harness.** `bootstrap_with_params(vec![])` on a paramless blueprint
  bootstraps and runs; a non-empty vector against it returns `ParamArity`.
- **Blueprint-view render (C22).** `render_blueprint` on a value-empty harness
  renders bare-type labels (`[SMA]`, `[Exposure]`, `[SimBroker]`); the compiled
  view, run on built flat nodes, still renders valued labels (`SMA(2)`). The `aura
  graph` golden tests are re-expressed accordingly (blueprint-view goldens become
  bare-type; compiled-view goldens unchanged).

## Acceptance criteria

The feature passes aura's acceptance criterion (CLAUDE.md): the milestone's intended
author (the World / a sweep) reaches for exactly this primitive — one blueprint,
many instances under different vectors; it measurably enables the next cycle (#32
cannot enumerate instances without it); and it reintroduces no failure class the
core constraints forbid (determinism C1 preserved — same vector, bit-identical run;
topology unchanged C19 — params size, never restructure; no look-ahead introduced).

Concretely, the cycle is accepted when:

1. A value-empty `sma_cross_harness()` bootstraps under an injected vector to a run
   bit-identical to today's hand-wired `sma_cross(2, 4)`.
2. The same blueprint under a different vector produces a distinct, populated run.
3. Kind and arity faults surface as typed `CompileError`s, not panics.
4. `cargo build/test --workspace` is green and `cargo clippy --workspace
   --all-targets -- -D warnings` is clean, with `compile`/`inline` structurally
   unchanged (only threaded with the build-from-slice step).

## Out of scope (deferred)

- Sweep enumeration of a family of vectors (#32).
- Search-range / value-domain validation beyond the constructor's own `assert`
  (#32 / C20 — the range belongs to the run).
- Single-run authoring convenience (a named-param binding or call-site value
  pairing that restores one-off ergonomics without a baked default) — filed as #35.
- Any change to `Node::eval` or the run loop.
