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
19 KiB
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(¶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:
// 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(); 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). 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 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'sScalarKinddoes not match the slot's declared kind (the typed-value check #30 deferred, realized here).slotis 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-wiredsma_cross(2, 4)graph — build-then-wire does not change the compilat for a given point. (Re-expressescomposite_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.
F64where the slot isI64) returnsParamKindMismatch { 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'sschema().params. - Order invariant (subsumes #34).
param_space()'s slot order equals the ordercompile_with_paramsconsumes 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 returnsParamArity. - Blueprint-view render (C22).
render_blueprinton 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)). Theaura graphgolden 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:
- A value-empty
sma_cross_harness()bootstraps under an injected vector to a run bit-identical to today's hand-wiredsma_cross(2, 4). - The same blueprint under a different vector produces a distinct, populated run.
- Kind and arity faults surface as typed
CompileErrors, not panics. cargo build/test --workspaceis green andcargo clippy --workspace --all-targets -- -D warningsis clean, withcompile/inlinestructurally 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::evalor the run loop.