# 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`. 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` and instead holds a `LeafFactory { params, build }`: the node's declared param specs plus a closure `&[Scalar] -> Box` 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)` 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 { if let Scalar::I64(v) = self { Some(v) } else { None } } pub fn as_f64(self) -> Option { 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, build: Box Box>, } impl LeafFactory { pub fn new( name: &'static str, params: Vec, build: impl Fn(&[Scalar]) -> Box + 'static, ) -> Self { Self { name, params, build: Box::new(build) } } pub fn params(&self) -> &[ParamSpec] { &self.params } pub fn build(&self, p: &[Scalar]) -> Box { (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), Composite(Composite) } // impl From for BlueprintNode { ... Leaf(Box::new(node)) } // after: pub enum BlueprintNode { Leaf(LeafFactory), Composite(Composite), } impl From 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) -> Result { let (nodes, sources, edges) = self.compile_with_params(¶ms)?; 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` and `as_f64(self) -> Option` (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` → `From`; `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 compilat 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.