Bind a blueprint's open knobs by name via a fluent builder instead of a
positional, Scalar-wrapped Vec in param_space() order: a single run as
bp.with("sma_cross.fast", 2).with("scale", 0.5).bootstrap(), a sweep as
bp.axis("sma_cross.fast", [2,3]).axis("scale", [0.5]).sweep(run). A pure
authoring layer over the existing bootstrap_with_params / GridSpace / sweep
primitives; the engine core is untouched (C1/C7/C12/C19/C23 preserved).
Ratified design (brainstorm -> specify):
- Fluent builder (.with()/.axis()), not a macro — C10 builder-API idiom.
- Raw literals via Into<Scalar>; the literal fixes the variant (2->I64,
0.5->F64); kind-check is pure equality, no coercion.
- Match key = the EXACT param_space() name (user's Option 1): path-qualified
for composite-interior knobs (sma_cross.fast), bare for root-level knobs
(scale). Short bare names are an authoring choice (promote to a root leaf),
not a job of this layer; no engine change, no unqualified matching.
- .with() not .bind() — bind is reserved for #55's structural-constant overlay.
- Total error order (a-f) over a single BindError vocabulary: Phase 1 validates
bindings (UnknownKnob/AmbiguousKnob/EmptyAxis/DuplicateBinding), Phase 2 walks
slots (MissingKnob/KindMismatch); first failing check wins; sweep kind-check is
per-element, making resolve_axes a superset of GridSpace::new so the downstream
.expect() is infallible.
- Two iterations: single-run side, then sweep-axis side (shared name-resolution
core). The CLI sample stays nested (user's call); the worked example shows both
qualified and bare names honestly, no fixture change.
Auto-signed under /boss spec_auto_sign after the objective gates (precondition,
parse no-op, grounding-check PASS) and a unanimous five-lens spec-skeptic panel.
The panel hardened the spec across five rounds: it corrected the worked-example
names to the real param_space() output, pinned the exact-name match key, and made
the error precedence a total order closing a sweep per-element panic path. The
literal-inference grounding gap was closed separately by e97906a. Two design
points were taken by the user directly (Option 1; keep the sample nested).
refs #35
18 KiB
Named param binding — fluent .with() / .axis() over param_space() — Design Spec
Date: 2026-06-10 Status: Draft — awaiting user spec review Authors: orchestrator + Claude
Goal
Let an author bind a blueprint's open knobs by name instead of by a
positional Vec<Scalar> in param_space() order. Today a single run and a
sweep both supply their point positionally with each value Scalar-wrapped —
which is order-fragile (two swapped same-kind values still compile, a silent
correctness hazard) and noisy (Scalar::I64(2), Scalar::F64(0.5)). A
fluent builder addresses both: bp.with("sma_cross.fast", 2).with("sma_cross.slow", 4).with("scale", 0.5).bootstrap() for a single run, bp.axis("sma_cross.fast", [2, 3]).axis("sma_cross.slow", [4, 5]).axis("scale", [0.5]).sweep(run) for a grid.
The bound name is the exact string param_space() emits (see §Architecture) —
path-qualified for a knob inside a composite (sma_cross.fast), bare for a
root-level knob (scale).
This is a pure authoring layer over the existing bootstrap_with_params /
GridSpace / sweep primitives. The engine core is untouched: no change to
determinism (C1), the param-space ground truth (C12/C19), the alias overlay
(C23), or the edge-time kind check (C7). Out of scope: structural blueprint
constants (a value removed from param_space entirely) — that is the opposite
operation and lives in #55.
Architecture
Three layers, top to bottom:
- Fluent builders (
Composite::with/Composite::axis) accumulate named bindings as plain data, then resolve at the terminal call. - A shared name-resolution core maps each declared
param_space()slot to its binding by name, in slot order, producing the positional structure the engine wants — and the name-level errors (UnknownKnob,DuplicateBinding,AmbiguousKnob). - The existing engine primitives (
bootstrap_with_params,GridSpace::new+sweep) consume the resolved positional structure unchanged.
param_space() is the single ground truth: it carries each slot's name
(alias-aware, C23) and its ScalarKind. That is enough to map a name to a
position and to kind-check the bound value — so the named layer needs no new
metadata on the engine side.
The match key is the exact param_space() name. A bound name matches a slot
iff it equals the string param_space() emits for that slot — no unqualification,
no last-segment matching, no fuzzy resolution. Those strings are
path-qualified: a knob inside a composite surfaces as <composite>.<name>
(e.g. sma_cross.fast, via collect_params prefixing the composite name onto the
C23 alias), while a knob on a root-level leaf surfaces bare (e.g. scale, the
root Exposure's param). This makes the match key both grounded (it is
literally what param_space() returns, pinned by
param_space_is_flat_path_qualified_and_slot_disambiguated) and structurally
unambiguous: two distinct knobs that would otherwise collide on a short name are
already disambiguated by their composite path. An author who wants to bind a knob
by a short bare name promotes it to a root-level leaf (as scale already is) — a
blueprint-authoring choice, not a job of this layer. AmbiguousKnob therefore
fires only when two slots emit the identical exact name (e.g. two unaliased
leaves whose factory param names collide, or two duplicate C23 aliases); the
author resolves it with a distinguishing ParamAlias (C23).
Type lowering. A bound value is impl Into<Scalar>; the Rust literal fixes
the variant. Because From<i64>/From<f64>/From<bool> exist for Scalar but
no From<i32>, a bare integer literal 2 infers as i64 (the only integer
type satisfying the bound), and 0.5 as f64. So .with("sma_cross.fast", 2)
and .with("scale", 0.5) compile with raw literals, no suffix. The kind-check
against the slot's ScalarKind is then pure equality (C7-consistent): there
is no coercion — a 2 (lowered to I64) bound to an F64 slot is a
KindMismatch, and the author writes 2.0.
Naming. The value-supplying method is .with(), deliberately not
.bind() — bind is reserved for #55's structural-constant overlay (which
removes a knob from param_space). .with() is the opposite: it supplies a
value for an open knob that stays in param_space.
Concrete code shapes
User-facing — the worked author examples (the acceptance evidence)
Single run, before → after (the existing CLI sample at
crates/aura-cli/src/main.rs:520):
// before — positional, order-fragile, Scalar-wrapped:
let mut h = bp
.bootstrap_with_params(vec![Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)])
.expect("sample blueprint compiles under a valid point");
// after — named (exact param_space() names), order-free, raw literals:
let mut h = bp
.with("sma_cross.fast", 2) // knob inside the `sma_cross` composite → path-qualified
.with("sma_cross.slow", 4)
.with("scale", 0.5) // root-level Exposure knob → bare
.bootstrap()
.expect("sample blueprint compiles under a valid point");
The mixed forms are deliberate and honest: the sample's two SMA lengths live
inside the sma_cross composite (so param_space() emits sma_cross.fast /
sma_cross.slow), while scale is a root-level knob (bare). The example shows
exactly how each is addressed under the exact-name match key — no fixture change.
Sweep, before → after (the existing CLI grid at
crates/aura-cli/src/main.rs:228-238):
// before — positional axes in param_space() order:
let space = sample_blueprint_with_sinks().0.param_space();
let grid = GridSpace::new(
&space,
vec![
vec![Scalar::I64(2), Scalar::I64(3)],
vec![Scalar::I64(4), Scalar::I64(5)],
vec![Scalar::F64(0.5)],
],
)
.expect("the built-in grid matches the sample param-space");
let family = sweep(&grid, |point| { /* run one */ });
// after — named axes (exact param_space() names), same vocabulary as the single run:
let family = sample_blueprint_with_sinks().0
.axis("sma_cross.fast", [2, 3])
.axis("sma_cross.slow", [4, 5])
.axis("scale", [0.5])
.sweep(|point| { /* run one */ });
Implementation shapes — secondary
// the value-binding builder (single run)
impl Composite {
pub fn with(self, name: &str, v: impl Into<Scalar>) -> Binder {
Binder { bp: self, bound: vec![(name.to_string(), v.into())] }
}
}
pub struct Binder { bp: Composite, bound: Vec<(String, Scalar)> }
impl Binder {
pub fn with(mut self, name: &str, v: impl Into<Scalar>) -> Binder {
self.bound.push((name.to_string(), v.into()));
self
}
pub fn bootstrap(self) -> Result<Harness, BindError> {
let space = self.bp.param_space();
let point = resolve(&space, &self.bound)?; // shared core
self.bp.bootstrap_with_params(point).map_err(BindError::Compile)
}
}
// the axis builder (sweep) — same shape, a Vec<Scalar> per knob
impl Composite {
pub fn axis(self, name: &str, vals: impl IntoIterator<Item = impl Into<Scalar>>) -> SweepBinder {
let axis = vals.into_iter().map(Into::into).collect();
SweepBinder { bp: self, axes: vec![(name.to_string(), axis)] }
}
}
pub struct SweepBinder { bp: Composite, axes: Vec<(String, Vec<Scalar>)> }
impl SweepBinder {
pub fn axis(mut self, name: &str, vals: impl IntoIterator<Item = impl Into<Scalar>>) -> SweepBinder {
self.axes.push((name.to_string(), vals.into_iter().map(Into::into).collect()));
self
}
pub fn sweep<F>(self, run_one: F) -> Result<SweepFamily, BindError>
where F: Fn(&[Scalar]) -> RunReport + Sync {
let space = self.bp.param_space();
let ordered = resolve_axes(&space, &self.axes)?; // Vec<Vec<Scalar>> in slot order
let grid = GridSpace::new(&space, ordered) // pre-validated, cannot fail
.expect("named layer pre-validates arity/kind/non-empty");
Ok(sweep(&grid, run_one))
}
}
// the authoring-layer error vocabulary (name-qualified)
#[derive(Debug, PartialEq)]
pub enum BindError {
UnknownKnob(String), // name matches no slot
MissingKnob(String), // a slot left unbound
KindMismatch { knob: String, expected: ScalarKind, got: ScalarKind },
DuplicateBinding(String), // same name twice
AmbiguousKnob(String), // name matches >1 slot
EmptyAxis(String), // sweep only: axis with no values
Compile(CompileError), // single run: downstream bootstrap fault
}
// the shared single-run core
fn resolve(space: &[ParamSpec], bound: &[(String, Scalar)]) -> Result<Vec<Scalar>, BindError>;
fn resolve_axes(space: &[ParamSpec], axes: &[(String, Vec<Scalar>)]) -> Result<Vec<Vec<Scalar>>, BindError>;
Components
Composite::with/Binder(new,aura-engine): single-run value binding.Composite::axis/SweepBinder(new,aura-engine): sweep-axis binding.resolve/resolve_axes(new,aura-engine): the name-resolution core, the load-bearing shared logic both scopes call.resolve_axesreuses the same name→slot mapping asresolve, differing only in per-slot fill (oneScalarvs aVec<Scalar>axis), theEmptyAxischeck, and a per-element kind check over each axis. Its validation is a superset of whatGridSpace::newchecks (arity, non-empty, per-element kind), so theGridSpace::newcall it feeds is infallible by construction.BindError(new,aura-engine): the authoring-layer error vocabulary.- The CLI sample single-run and sweep (
aura-cli): converted to the new form as living acceptance evidence.
Engine primitives consumed unchanged: param_space(), bootstrap_with_params,
GridSpace::new, sweep, CompileError, SweepError.
Data flow
Single run: .with(name, v)* accumulate (String, Scalar) → terminal
.bootstrap() calls param_space() → resolve runs the two phases (validate
bindings against the slot names, then walk slots in order kind-checking), emits
Vec<Scalar> → existing bootstrap_with_params → Harness.
Sweep: .axis(name, vals)* accumulate (String, Vec<Scalar>) → terminal
.sweep(run) calls param_space() → resolve_axes produces Vec<Vec<Scalar>>
in slot order → existing GridSpace::new (pre-validated) → existing sweep →
SweepFamily.
Error handling
BindError is the single authoring-layer error type for both scopes,
name-qualified so a message names the offending knob rather than a slot index.
The named layer validates fully before handing off, so the downstream
GridSpace::new is given an already-valid grid (its positional SweepError
stays the engine-level error for direct GridSpace::new callers and is not
re-exposed through the named surface).
| Variant | Trigger | Scope | Decision |
|---|---|---|---|
UnknownKnob(name) |
a bound name equals no slot's exact param_space() name |
both | error — almost certainly a typo or a missing composite prefix |
MissingKnob(name) |
a slot is left unbound | both | error — completeness enforced; a single run needs a full vector, and every sweep knob needs an axis |
KindMismatch{knob,expected,got} |
value kind ≠ slot kind | both | error — no coercion; 2 for an F64 slot fails, author writes 2.0 |
DuplicateBinding(name) |
the same name bound twice | both | error — not last-wins; a second .with("fast", …) is almost certainly accidental |
AmbiguousKnob(name) |
a name equals the exact param_space() name of >1 slot (colliding unaliased factory names, or duplicate C23 aliases) |
both | error — author disambiguates via ParamAlias (C23) |
EmptyAxis(name) |
a sweep axis has zero values | sweep | error — nothing to sweep on that knob; the name-qualified counterpart of the engine's SweepError::EmptyAxis |
Compile(CompileError) |
downstream bootstrap fault on the single-run path | single run | forwarded — the named layer pre-validates the point, but bootstrap_with_params may still surface an unrelated construction fault |
Precisification flagged for review: the ratified narrative listed five
name-level variants; this spec adds EmptyAxis (a necessary completeness check
for the ratified sweep scope — an axis with no values has nothing to enumerate)
and Compile (forwarding the existing downstream CompileError on the
single-run terminal, since .bootstrap() ultimately calls
bootstrap_with_params). Neither is a new design decision; both are
consequences of wrapping the existing primitives. Surfaced here, not buried.
Resolution timing and error precedence. Lazy — at the terminal
(.bootstrap() / .sweep()), never per .with()/.axis(). The builder
accumulates plain data; resolution against param_space() happens once. The
first failing check in the total order below wins (error-collection is a
later additive concern). The order is total — every co-occurrence of errors has a
single defined winner — so the surfaced BindError is a pure function of the
inputs:
Phase 1 — per binding, in .with()/.axis() chain order (left to right);
for each binding apply checks a–d in this fixed sub-order before moving to the
next binding:
- a. the name resolves to zero
param_space()slots →UnknownKnob(name). - b. the name resolves to more than one slot (identical exact names) →
AmbiguousKnob(name). - c. (sweep path only) the axis has zero values →
EmptyAxis(name). - d. the (now unique) resolved slot was already claimed by an earlier
binding →
DuplicateBinding(name); otherwise the binding claims its slot.
Phase 2 — only if Phase 1 fully succeeds, walk param_space() slots in slot
order; for each slot apply e–f:
- e. the slot is unclaimed by any binding →
MissingKnob(slot_name). - f. a claimed value's kind ≠ the slot kind →
KindMismatch{knob, expected, got}. Single-run: the one bound value. Sweep: every element of the bound axis is kind-checked, in axis order;gotis the kind of the first offending element, so the surfaced error is deterministic. This per-element check is total — it is exactly the checkGridSpace::newrepeats — so onceresolve_axesreturns Ok, the grid it produces is fully arity-, non-empty-, and per-element-kind-valid, making the downstreamGridSpace::new(&space, ordered).expect(…)inSweepBinder::sweepgenuinely infallible: it can never panic on author input, because everySweepErrorGridSpace::newcan raise has already been raised as the correspondingBindErrorin Phase 1/2.
Compile(CompileError) is downstream of a fully-successful resolve (the
single-run terminal forwarding a bootstrap_with_params fault). Because name
resolution (a/b) precedes the duplicate check (d) within a binding, a name that
is both unknown and repeated — .with("typo", 1).with("typo", 2) — surfaces
UnknownKnob("typo") at its first occurrence, never reaching the duplicate
check. The chain order, the a–f sub-order, and the slot order are all fixed, so
no two inputs share an undefined winner.
Testing strategy
resolveround-trip: a named binding resolves to aVec<Scalar>bit-identical to the hand-written positional vector.- One RED assertion per
BindErrorvariant:UnknownKnob,MissingKnob,KindMismatch,DuplicateBinding,AmbiguousKnob(single run);EmptyAxis,MissingKnob(sweep). - Match-key grounding: binding the sample by its exact
param_space()names (sma_cross.fast,sma_cross.slow,scale) resolves; binding the unqualifiedfastraisesUnknownKnob("fast")— pinning that the match key is the exact emitted name, path-qualification included, not a short form. - Error precedence (cross-phase): a call with both a Phase-1 and a Phase-2
error (a typo'd name
.with("typo", 1)alongside a kind-mismatched valid name) surfaces the Phase-1 error (UnknownKnob("typo")) — binding validation precedes the slot walk. - Error precedence (intra-binding):
.with("typo", 1).with("typo", 2)(a name that is both unknown and duplicated) surfacesUnknownKnob("typo")— the a–d sub-order resolves the name (check a) before the duplicate check (d), so the total order has a single defined winner. - Sweep mixed-kind axis (no panic):
.axis("scale", [0.5, 1])against theF64slot raisesKindMismatch(the second element isI64) as a cleanBindError— never reachingGridSpace::new, pinning thatresolve_axesper-element kind-check is total and the downstream.expect()cannot panic. - Equivalence (C1): the same point expressed named vs positional bootstraps to an instance that runs to a bit-identical result — the convenience changes nothing about the outcome.
- Sweep parity: named axes resolve to the same
GridSpace(same enumerated points, same order) as the positional axes. - Living acceptance evidence: the CLI sample single-run and sweep are
converted to the
.with()/.axis()form; their existing golden/behaviour tests stay green, proving the conversion is behaviour-preserving.
Acceptance criteria
bp.with("sma_cross.fast", 2).with("sma_cross.slow", 4).with("scale", 0.5).bootstrap()compiles with raw literals and bootstraps the same instance as the positional vector — binding by each slot's exactparam_space()name.bp.axis("sma_cross.fast", [2, 3]).axis("sma_cross.slow", [4, 5]).axis("scale", [0.5]).sweep(run)enumerates the same family as the positionalGridSpace.- Every
BindErrorvariant is reachable and has a covering RED test. - The C1 equivalence test passes (named ≡ positional, bit-identical run).
- The CLI sample single-run and sweep use the new form; all existing tests stay green.
cargo build/test --workspacegreen,cargo clippy --workspace --all-targets -- -D warningsclean.
Iteration cut
- Iteration 1 — single run:
resolve,Composite::with/Binder,BindError(the single-run variants +Compile), the C1 equivalence test, and the CLI sample single-run conversion. - Iteration 2 — sweep axes:
resolve_axes,Composite::axis/SweepBinder,EmptyAxis, the sweep-parity test, and the CLI sweep conversion.