# 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.
