Files
Aura/docs/specs/0016-param-set-injection.md
T
Brummel b9edeaf2db spec: 0016 param-set injection — value-empty blueprints bound by a vector (#31)
Cycle B of milestone "The World — parameter-space & sweep". A blueprint's tunable
values are injected at bootstrap rather than baked into the builder: a leaf becomes
a param-generic recipe (LeafFactory: params -> sized node), and a new build-then-
wire compile path binds a positional Scalar vector slot-by-slot against
param_space(), building each node through its own constructor.

Ratified design (brainstorm): value-empty B over mutate-in-place and over the
default-bearing variant. The value lives only in the injected vector (no baked
default), so the blueprint stays a pure param-generic recipe (C19); injected
values flow through the single new() sizing/validation gate; and because
compile_with_params consumes the vector in the same depth-first order param_space()
reports, the two share one traversal — subsuming #34's dual-traversal drift hazard.

Scope: one vector -> one instance; LeafFactory in aura-core; bootstrap_with_params/
compile_with_params; CompileError::{ParamKindMismatch, ParamArity}; Scalar::as_i64/
as_f64 accessors; fixtures/CLI/bit-identity re-expressed against the vector.
Deferred: sweep enumeration (#32), domain validation (#32/C20), single-run
authoring convenience (#35).

Gates: grounding-check PASS (8 current-behaviour assumptions ratified against green
tests; the sole BLOCK — a missing Scalar value accessor — resolved by adding it as
in-scope surface). Parse gate a no-op (profile declares no spec_validation).

refs #31
2026-06-07 18:57:52 +02:00

312 lines
15 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: 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.
## 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(
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 {
params: Vec<ParamSpec>,
build: Box<dyn Fn(&[Scalar]) -> Box<dyn Node>>,
}
impl LeafFactory {
pub fn new(
params: Vec<ParamSpec>,
build: impl Fn(&[Scalar]) -> Box<dyn Node> + 'static,
) -> Self {
Self { 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 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 { params, build }` (+ `new`/`params`/`build`). `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). |
| 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. Bit-identity and golden tests re-expressed against `bootstrap_with_params`. |
**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`.
## 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.