Files
Aura/docs/specs/0016-param-set-injection.md
T
Brummel e9eea1ada3 spec: 0016 amend — value-empty render surface + vestigial schema removal (#31)
Plan-recon surfaced two surfaces the first draft under-scoped; resolved here after
a user design decision on the render form.

- Blueprint rendering (C22): a value-empty blueprint has no bound values, so the
  `aura graph` blueprint view can no longer label a leaf `SMA(2)`. LeafFactory gains
  `name` + `label()`, and the blueprint view renders the param-generic form
  `SMA(length)` (type + tunable knob names, no values), chosen over a bare `SMA` to
  surface the knobs the milestone is about. The compiled view (post-build flat
  nodes) is unchanged — it still labels valued. Correct C22 reading: structure
  before, values after.
- Vestigial pre-build interface removed: `Composite::schema` / `BlueprintNode::schema`
  derive an item interface from built interior leaves; a value-empty leaf has none,
  and both methods have no live caller (compile resolves every interface structurally
  on the built flat nodes). Removed with their unit test. This is why the factory
  carries no io skeleton — the build-then-wire decision stands, no schema duplication.
- OQ3: the swapped-sample mis-wiring moves from the builder args to the injected
  vector; its observability moves to the compiled view (the blueprint view is
  param-generic and identical for both orderings).

Grounding-check re-run: PASS (LeafFactory::label internally consistent; the
no-live-caller claim for the removed methods verified; blueprint-view vs
compiled-view goldens correctly partitioned).

refs #31
2026-06-07 20:11:06 +02:00

18 KiB
Raw Blame History

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 node type plus its tunable knob names, e.g. SMA(length), Exposure(scale), bare SimBroker. Both SMAs of a cross render identically as SMA(length) (they are the same recipe; their distinct values live in the vector, and the graph's edges still distinguish them positionally). 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:

// 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:

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:

// 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:

// 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"): the node type plus its tunable param *names* — no values, a
    /// value-empty recipe has none — e.g. `SMA(length)`, `LinComb(weights[0],
    /// weights[1])`, or bare `SimBroker` when paramless.
    pub fn label(&self) -> String {
        if self.params.is_empty() {
            self.name.to_string()
        } else {
            let knobs: Vec<&str> = self.params.iter().map(|p| p.name.as_str()).collect();
            format!("{}({})", self.name, knobs.join(", "))
        }
    }
}

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

// 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):

// 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:

// 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:

// 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 param-generic blueprint-view label from name + param knob names. 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(); compilecompile_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 schemaeval 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 param-generic labels (SMA(length), Exposure(scale), bare 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 param-generic; 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 CompileErrors, 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.