diff --git a/docs/specs/0016-param-set-injection.md b/docs/specs/0016-param-set-injection.md new file mode 100644 index 0000000..2d4ec6e --- /dev/null +++ b/docs/specs/0016-param-set-injection.md @@ -0,0 +1,311 @@ +# 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. + +## 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 { 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 { + params: Vec, + build: Box Box>, +} + +impl LeafFactory { + pub fn new( + params: Vec, + build: impl Fn(&[Scalar]) -> Box + 'static, + ) -> Self { + Self { params, build: Box::new(build) } + } + pub fn params(&self) -> &[ParamSpec] { &self.params } + pub fn build(&self, p: &[Scalar]) -> Box { (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), 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 { 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` 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). | +| 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.