Files
Aura/docs/specs/0016-param-set-injection.md
T
Brummel b02f31cdd4 spec: 0016 — blueprint-view label is the bare type (ascii-dag wide-label limit) (#31)
The amend chose `SMA(length)` (type + knob names) for the param-generic blueprint
view. Implementation surfaced that the ascii-dag 0.9.1 renderer writes a label
verbatim on one line (no multi-line — `render/ascii.rs::write_node` brackets the
raw string) and its Sugiyama subgraph layout overlaps two wide sibling boxes inside
a cluster (`[SMA(length[SMA(length)]`). Width is auto-computed but the cluster
packing does not honor it; the only spacing knob (`node_spacing`) is on the
deprecated config path, global, and width-independent — no robust option, and
domain labels grow unboundedly wide (`LinComb(weights[0], weights[1])`, deep
path-qualified names). Horizontal mode is already rejected (collapses fan-outs).

So `LeafFactory::label()` renders the bare node type (`SMA`). The tunable knobs are
surfaced by `param_space()`, not in the graph; the compiled view still labels built
nodes valued (`SMA(2)`) via `Node::label`. Correct C22 reading either way —
structure (now: type-only) before, values after.

refs #31
2026-06-07 21:04:31 +02:00

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

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

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