spec: 0046 sweep named-binding (C12 #57)

Thread a derived named view of a sweep point so consumers stop re-zipping
param_space() names onto the bare &[Scalar]. A free function zip_params over
(ParamSpec names ⊗ positional point) in aura-core; GridSpace retains the
ParamSpec list it already receives in new(); SweepFamily carries it and
exposes named_params(i). The run-closure signature
`F: Fn(&[Scalar]) -> RunReport + Sync` stays byte-for-byte (honours #52's
#71-firewall constraint, so RandomSpace plugs in with zero reconciliation).
The lossy f64 collapse moves into the manifest constructor; the view stays
typed. Behaviour-preserving; SweepPoint and enumeration untouched.

Design ratified in-context (brainstorm, approach C over A, free function
over a NamedPoint type); a reconciliation comment on #57 records the
resolved forks with provenance. Human sign-off after the auto-sign panel's
grounding lens caught a blast-radius undercount in the first draft —
corrected to all eight sim_optimal_manifest call sites plus the
aura-registry SweepFamily struct-literal.

refs #57
This commit is contained in:
2026-06-15 23:07:09 +02:00
parent c2756732d4
commit 119221c8bb
+320
View File
@@ -0,0 +1,320 @@
# Sweep named-binding — Design Spec
**Date:** 2026-06-15
**Status:** Draft — awaiting user spec review
**Authors:** orchestrator + Claude
## Goal
A sweep/walk-forward consumer that wants readable knob names — in a
`RunManifest`, or when reading back a returned family — currently re-derives
the name↔value pairing by hand from `param_space()`, because the engine hands
the per-point closure a bare positional `&[Scalar]` and `SweepPoint` carries
only positional `Vec<Scalar>`. This is open-coded in three near-identical sites
in the CLI (`sweep_family`, `sweep_over`, `run_oos`), each re-reading
`param_space()` and zipping it back onto the positional point.
This cycle removes that redundancy with a single derived projection — a free
function `zip_params(space, point)` — and makes a returned `SweepFamily`
self-describing (it carries its `param_space()` once, exposing
`named_params(i)`). The named view is the prerequisite for #52 (random
param-sweep), whose `RandomSpace` must report readable per-point params over the
same execution layer.
The fix is **behaviour-preserving** and **does not touch the run-closure
signature**: `F: Fn(&[Scalar]) -> RunReport + Sync` stays byte-for-byte, so
#52's `RandomSpace` plugs into the same `sweep()` layer with zero signature
reconciliation (#52's #71-firewall constraint, 2026-06-15 comment).
## Architecture
A derived named view over the **unchanged positional** sweep machinery. The
positional core keeps its shape — `GridSpace.axes`, `GridSpace::points() ->
Vec<Vec<Scalar>>`, the closure `Fn(&[Scalar])`, and `SweepPoint.params:
Vec<Scalar>` are all untouched in kind. Three small additions thread the names,
which already exist in `param_space()`, through to the two consumers that need
them:
1. **`GridSpace` retains the `&[ParamSpec]` it already receives** in `new()`
(today it validates against them, then discards them). No `new()` signature
change.
2. **`SweepFamily` carries that `Vec<ParamSpec>` once** (stamped where the
family is assembled), so a returned family is self-describing.
3. **A free function `zip_params(space, point)`** is the one shared projection
that pairs names with a positional point — used in-closure to build the
manifest (consumer i) and by `SweepFamily::named_params` to read a returned
family by name (consumer ii).
The name is a **derived projection**, not new identity and not new per-point
state: a param's identity remains its positional slot (C23/C8); `ParamSpec.name`
is the non-load-bearing debug symbol it always was. `SweepPoint` stays
positional.
## Concrete code shapes
### Worked consumer code (the acceptance evidence)
**Consumer (i) — in the per-point closure** (the friction the issue names). The
three CLI sites collapse from a 5-line hand-zip to one function call. Today:
```rust
// crates/aura-cli/src/main.rs ~386 (also ~503, ~530) — BEFORE
.sweep(|point: &[Scalar]| {
let mut h = bp.bootstrap_with_params(point.to_vec()).expect("kind-checked");
// ...run, drain sinks...
let params = space
.iter()
.zip(point)
.map(|(ps, v)| (ps.name.clone(), scalar_as_param_f64(v))) // hand re-zip + lossy coerce
.collect();
RunReport { manifest: sim_optimal_manifest(params, window, 0), metrics }
})
```
After — the name pairing is one call; the lossy f64 collapse moves into the
manifest constructor (it owns its own lossiness):
```rust
// crates/aura-cli/src/main.rs ~386 — AFTER
.sweep(|point: &[Scalar]| { // signature UNCHANGED: Fn(&[Scalar])
let mut h = bp.bootstrap_with_params(point.to_vec()).expect("kind-checked");
// ...run, drain sinks...
RunReport { manifest: sim_optimal_manifest(zip_params(&space, point), window, 0), metrics }
})
```
**Consumer (ii) — reading a returned family by name** (post-hoc; the readable
half that #52 needs). No blueprint in hand, no re-zip:
```rust
// a downstream reader of a returned SweepFamily
let fam: SweepFamily = bp.axis("sma_cross.fast", [2, 3]).axis("exposure.scale", [0.5]).sweep(run)?;
for (i, pt) in fam.points.iter().enumerate() {
let named: Vec<(String, Scalar)> = fam.named_params(i); // [("sma_cross.fast", I64(2)), ("exposure.scale", F64(0.5))]
println!("{named:?} -> {}", pt.report.metrics.total_pips);
}
```
### Implementation shapes (before → after)
**New free function** — the one shared projection (aura-core, alongside
`ParamSpec` in `node.rs`):
```rust
// crates/aura-core/src/node.rs (or a small params module) — NEW
/// Pair each param-space name with the co-indexed positional value.
/// The inverse of the positional binding (C23): names are a derived view,
/// not identity. `space` and `point` are co-indexed in param_space() slot order.
pub fn zip_params(space: &[ParamSpec], point: &[Scalar]) -> Vec<(String, Scalar)> {
space.iter().zip(point).map(|(ps, v)| (ps.name.clone(), *v)).collect()
}
```
**`GridSpace` retains its specs** (sweep.rs):
```rust
// crates/aura-engine/src/sweep.rs ~14 — BEFORE
#[derive(Debug)]
pub struct GridSpace { axes: Vec<Vec<Scalar>> }
// AFTER
#[derive(Debug)]
pub struct GridSpace { space: Vec<ParamSpec>, axes: Vec<Vec<Scalar>> }
impl GridSpace {
// new(space, axes) signature UNCHANGED; it now stores `space: space.to_vec()`
// after the existing validation, instead of discarding it.
pub fn param_specs(&self) -> &[ParamSpec] { &self.space }
}
```
**`SweepFamily` carries the space + exposes the named view** (sweep.rs):
```rust
// crates/aura-engine/src/sweep.rs ~106 — BEFORE
#[derive(Clone, Debug, PartialEq)]
pub struct SweepFamily { pub points: Vec<SweepPoint> }
// AFTER
#[derive(Clone, Debug, PartialEq)]
pub struct SweepFamily { pub space: Vec<ParamSpec>, pub points: Vec<SweepPoint> }
impl SweepFamily {
/// The i-th point's params paired with their names (derived; reuses zip_params).
pub fn named_params(&self, i: usize) -> Vec<(String, Scalar)> {
zip_params(&self.space, &self.points[i].params)
}
}
// SweepPoint is UNCHANGED: pub struct SweepPoint { pub params: Vec<Scalar>, pub report: RunReport }
```
The single stamp point in the hot path — `sweep_with_threads` already has the
`&GridSpace`:
```rust
// crates/aura-engine/src/sweep.rs ~176 — the family assembly gains the space
SweepFamily {
space: space.param_specs().to_vec(),
points: points.into_iter().zip(reports).map(|(params, report)| SweepPoint { params, report }).collect(),
}
```
**`sim_optimal_manifest` owns the lossy f64 collapse** (CLI):
```rust
// crates/aura-cli/src/main.rs ~130 — BEFORE
fn sim_optimal_manifest(params: Vec<(String, f64)>, window: (Timestamp, Timestamp), seed: u64) -> RunManifest {
RunManifest { commit: commit_hash(), params, window, seed, broker: "sim-optimal".into() }
}
// AFTER — takes typed Scalar pairs, collapses to f64 internally (one place);
// preserves the existing scalar_as_param_f64 contract verbatim (I64/F64 -> f64,
// panic on Bool/Ts). The stored field stays Vec<(String, f64)> (no serde change).
fn sim_optimal_manifest(params: Vec<(String, Scalar)>, window: (Timestamp, Timestamp), seed: u64) -> RunManifest {
let params = params.into_iter().map(|(n, s)| (n, scalar_as_param_f64(&s))).collect();
RunManifest { commit: commit_hash(), params, window, seed, broker: "sim-optimal".into() }
}
```
All **five** hand-listing callers adapt their literals to `Scalar` (every
caller that passes a literal `Vec<(String, f64)>` today — not only the two the
sweep path uses):
```rust
// crates/aura-cli/src/main.rs — the five literal callers, BEFORE -> AFTER
// run_sample (~161), run_sample_real / `aura run --real` (~224),
// mc_family (~613), run_sample_seeded (test helper, ~970):
vec![("sma_fast".into(), 2.0), ("sma_slow".into(), 4.0), ("exposure_scale".into(), 0.5)]
// becomes
vec![("sma_fast".into(), Scalar::F64(2.0)), ("sma_slow".into(), Scalar::F64(4.0)), ("exposure_scale".into(), Scalar::F64(0.5))]
// run_macd / `aura run --macd` (~869) — a 4-tuple incl. ema_signal:
vec![("ema_fast".into(), 2.0), ("ema_slow".into(), 4.0), ("ema_signal".into(), 3.0), ("exposure_scale".into(), 0.5)]
// becomes
vec![("ema_fast".into(), Scalar::F64(2.0)), ("ema_slow".into(), Scalar::F64(4.0)), ("ema_signal".into(), Scalar::F64(3.0)), ("exposure_scale".into(), Scalar::F64(0.5))]
```
## Components
- **`aura-core::zip_params`** — free function, the single name↔value projection.
No lifetime, no struct. Reusable by mc / walk_forward / registry consumers.
Re-exported from `aura-core`'s lib root and (transitively, as needed) reachable
by the CLI.
- **`GridSpace` (aura-engine, sweep.rs)** — gains a private `space:
Vec<ParamSpec>` field, retained from the `&[ParamSpec]` already passed to
`new()`, plus a `param_specs(&self) -> &[ParamSpec]` accessor. `new()` keeps
its signature; `points()` and the odometer are untouched.
- **`SweepFamily` (aura-engine, sweep.rs)** — gains a public `space:
Vec<ParamSpec>` field, stamped once in `sweep_with_threads`, plus
`named_params(&self, i) -> Vec<(String, Scalar)>`. `SweepPoint` is unchanged.
- **`sim_optimal_manifest` (aura-cli)** — input parameter type changes from
`Vec<(String, f64)>` to `Vec<(String, Scalar)>`; the lossy f64 collapse moves
inside. It has **eight** call sites (main.rs:161, 224, 392, 509, 536, 613,
869, 970), all of which the signature change touches — every one must be
migrated for the tree to compile:
- **three sweep closures** now pass `zip_params(...)`: `sweep_family` (~392),
`sweep_over` (~509), `run_oos` (~536, zips `&space` against its `params`
argument);
- **five hand-listing callers** now pass `Scalar` literals: `run_sample`
(~161), `run_sample_real` / `aura run --real` (~224), `mc_family` (~613),
`run_macd` / `aura run --macd` (~869, a 4-tuple incl. `ema_signal`),
`run_sample_seeded` (test helper, ~970).
## Data flow
1. `Composite::param_space() -> Vec<ParamSpec>` (names, slot order) — unchanged
source of names.
2. `SweepBinder::sweep` resolves named axes → positional grid →
`GridSpace::new(&space, axes)`, which now **retains** `space`.
3. `sweep_with_threads`: `points = grid.points()` (positional); the closure runs
over `&points[i]` (bare `&[Scalar]`, unchanged); the family is assembled as
`SweepFamily { space: grid.param_specs().to_vec(), points }`. The low-level
`sweep(&GridSpace, ..)` path gets the names too, with no `sweep()` signature
change.
4. **Consumer (i)** — in-closure: `sim_optimal_manifest(zip_params(&space,
point), ..)` at the three CLI sites; `space` is the `param_space()` already
captured before each closure.
5. **Consumer (ii)** — post-hoc: `family.named_params(i)` reads names off the
carried `space`.
The positional binding path (`bootstrap_with_params(point.to_vec())`, C23
by-index) is entirely untouched.
## Error handling
- `zip_params` pairs co-indexed slices; equal slot order and length is the
invariant (both originate from the same family / same `param_space()`). `zip`
truncates to the shorter on a (contract-violating) mismatch; no panic.
- `SweepFamily::named_params(i)` indexes `self.points[i]` — an out-of-range `i`
panics like any slice index (caller's contract), consistent with existing
`points` access.
- The f64 collapse in `sim_optimal_manifest` preserves the existing
`scalar_as_param_f64` contract verbatim: `I64`/`F64 -> f64`, `unreachable!`
on `Bool`/`Ts`. Changing this (a typed `Vec<(String, Scalar)>` manifest field)
is the deferred "typed param-space" item, explicitly out of scope here.
## Testing strategy
Engine + core unit tests:
- `zip_params_pairs_names_with_values_in_slot_order` — names and values are
paired in `param_space()` slot order for a multi-axis space.
- `zip_params_matches_hand_zip` — behaviour-preservation pin: `zip_params(space,
point)` (after coercion) equals the old
`space.iter().zip(point).map(...).collect()` output for a known point.
- `sweep_family_carries_param_space` — a returned `SweepFamily.space` equals the
blueprint's `param_space()`.
- `family_named_params_round_trips` — `fam.named_params(i)` pairs the right names
with the right slot values for each member.
Existing tests that must stay green (regression guard, enumeration untouched):
the determinism suite — `points_enumerate_in_odometer_order`,
`family_is_deterministic_across_thread_counts`, `sweep_equals_n_independent_runs`.
**Cross-crate blast radius — the new `SweepFamily.space` field reaches beyond
aura-engine.** The planner threads the new `SweepFamily.space` / `GridSpace.space`
fields through every site that constructs those structs by literal, including
**`crates/aura-registry/src/lib.rs` (~278, the `optimize` test
`optimize_picks_the_max_metric_point_ties_to_earliest`)**, which builds a
`SweepFamily { points: vec![..] }` literal and would otherwise fail to compile.
`optimize`/`rank_by` read only `.points[].report`, so the test's `space` can be
an empty or representative `Vec<ParamSpec>` — the planner picks. **aura-registry
is therefore a touched crate this iteration**, not only a `zip_params` consumer.
E2E (the worked consumer is the acceptance evidence): `aura sweep` output is
**byte-identical** before and after (the manifest's `params` are unchanged), and
`aura runs family` / walk-forward outputs are unchanged — pinning that the
consumer migration is behaviour-preserving.
No serde impact: `SweepFamily` and `ParamSpec` are not serde-derived; the
lineage extractors read only `.report`; `Scalar` stays serde-free and
`RunManifest.params` stays `Vec<(String, f64)>`.
## Acceptance criteria
Judged against aura's domain invariants and the worked consumer code above:
1. **Removes redundancy (measurable).** The three identical hand-zip sites in
`aura-cli` collapse to one `zip_params(&space, point)` call each; the
name↔value pairing logic lives once, tested. A consumer reading a returned
family by name calls `named_params(i)` instead of re-fetching `param_space()`
and re-zipping.
2. **Audience naturally reaches for it.** A sweep/walk-forward author building a
manifest, and #52's `RandomSpace` author reporting readable per-point params,
both reach for these surfaces rather than re-deriving the pairing.
3. **Reintroduces no failure class.** The run-closure signature is unchanged
(firewall held; #52 plugs in with zero reconciliation); names stay a derived
view (C23 — slot is identity); enumeration/determinism is untouched (C1);
no serde/lineage change.
4. **Behaviour-preserving.** `aura sweep` / walk-forward output is byte-identical
before and after; the existing determinism suite stays green.
## Non-goals
- **Typed `RunManifest.params`** (`Vec<(String, Scalar)>`) + `Scalar` serde —
the deferred "typed param-space" cycle. `zip_params`' typed output sets it up
but this cycle does not ship it; the manifest field stays f64.
- **#52's `RandomSpace`** (random param-sweep) — a separate issue; this is its
prerequisite.
- **Any change to the run-closure signature** — firewall-forbidden.
- **Cross-family comparison** — out of scope.