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
This commit is contained in:
@@ -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<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 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<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
|
||||
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.
|
||||
Reference in New Issue
Block a user