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
This commit is contained in:
@@ -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<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(¶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<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.
|
||||
Reference in New Issue
Block a user