b02f31cdd4
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
356 lines
19 KiB
Markdown
356 lines
19 KiB
Markdown
# 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:
|
||
|
||
```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(
|
||
"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`:
|
||
|
||
```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 {
|
||
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:
|
||
|
||
```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 { 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'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 `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.
|