Files
Aura/docs/specs/0046-sweep-named-binding.md
T
Brummel 119221c8bb 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
2026-06-15 23:07:09 +02:00

15 KiB

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:

// 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):

// 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:

// 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):

// 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):

// 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):

// 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:

// 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):

// 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):

// 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_tripsfam.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.