diff --git a/docs/specs/0030-named-param-binding.md b/docs/specs/0030-named-param-binding.md new file mode 100644 index 0000000..798f08e --- /dev/null +++ b/docs/specs/0030-named-param-binding.md @@ -0,0 +1,343 @@ +# 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` 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 `.` +(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`; the Rust literal fixes +the variant. Because `From`/`From`/`From` exist for `Scalar` but +**no `From`**, 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) -> 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) -> Binder { + self.bound.push((name.to_string(), v.into())); + self + } + pub fn bootstrap(self) -> Result { + 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 per knob +impl Composite { + pub fn axis(self, name: &str, vals: impl IntoIterator>) -> 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)> } +impl SweepBinder { + pub fn axis(mut self, name: &str, vals: impl IntoIterator>) -> SweepBinder { + self.axes.push((name.to_string(), vals.into_iter().map(Into::into).collect())); + self + } + pub fn sweep(self, run_one: F) -> Result + where F: Fn(&[Scalar]) -> RunReport + Sync { + let space = self.bp.param_space(); + let ordered = resolve_axes(&space, &self.axes)?; // Vec> 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, BindError>; +fn resolve_axes(space: &[ParamSpec], axes: &[(String, Vec)]) -> Result>, 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` 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` → existing `bootstrap_with_params` → `Harness`. + +Sweep: `.axis(name, vals)*` accumulate `(String, Vec)` → terminal +`.sweep(run)` calls `param_space()` → `resolve_axes` produces `Vec>` +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; `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 a–f 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` + 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 + 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 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.