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

344 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`):
```rust
// 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`):
```rust
// 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
```rust
// 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_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 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.