Files
Aura/docs/specs/0030-named-param-binding.md
T
Brummel 8fae58fdc4 spec: 0030 named param binding (boss-signed)
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
2026-06-11 00:00:55 +02:00

18 KiB
Raw Blame History

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:

  1. Fluent builders (Composite::with / Composite::axis) accumulate named bindings as plain data, then resolve at the terminal call.
  2. 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).
  3. 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_axes reuses the same name→slot mapping as resolve, differing only in per-slot fill (one Scalar vs a Vec<Scalar> axis), the EmptyAxis check, and a per-element kind check over each axis. Its validation is a superset of what GridSpace::new checks (arity, non-empty, per-element kind), so the GridSpace::new call 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_paramsHarness.

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

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 ad 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 ef:

  • 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; got is the kind of the first offending element, so the surfaced error is deterministic. This per-element check is total — it is exactly the check GridSpace::new repeats — so once resolve_axes returns Ok, the grid it produces is fully arity-, non-empty-, and per-element-kind-valid, making the downstream GridSpace::new(&space, ordered).expect(…) in SweepBinder::sweep genuinely infallible: it can never panic on author input, because every SweepError GridSpace::new can raise has already been raised as the corresponding BindError in 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 af sub-order, and the slot order are all fixed, so no two inputs share an undefined winner.

Testing strategy

  • resolve round-trip: a named binding resolves to a Vec<Scalar> bit-identical to the hand-written positional vector.
  • One RED assertion per BindError variant: 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 unqualified fast raises UnknownKnob("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) surfaces UnknownKnob("typo") — the ad 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 the F64 slot raises KindMismatch (the second element is I64) as a clean BindError — never reaching GridSpace::new, pinning that resolve_axes per-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

  1. 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 exact param_space() name.
  2. 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 positional GridSpace.
  3. Every BindError variant is reachable and has a covering RED test.
  4. The C1 equivalence test passes (named ≡ positional, bit-identical run).
  5. The CLI sample single-run and sweep use the new form; all existing tests stay green.
  6. cargo build/test --workspace green, cargo clippy --workspace --all-targets -- -D warnings clean.

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.