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