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
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:
GridSpaceretains the&[ParamSpec]it already receives innew()(today it validates against them, then discards them). Nonew()signature change.SweepFamilycarries thatVec<ParamSpec>once (stamped where the family is assembled), so a returned family is self-describing.- 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 bySweepFamily::named_paramsto 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 fromaura-core's lib root and (transitively, as needed) reachable by the CLI.GridSpace(aura-engine, sweep.rs) — gains a privatespace: Vec<ParamSpec>field, retained from the&[ParamSpec]already passed tonew(), plus aparam_specs(&self) -> &[ParamSpec]accessor.new()keeps its signature;points()and the odometer are untouched.SweepFamily(aura-engine, sweep.rs) — gains a publicspace: Vec<ParamSpec>field, stamped once insweep_with_threads, plusnamed_params(&self, i) -> Vec<(String, Scalar)>.SweepPointis unchanged.sim_optimal_manifest(aura-cli) — input parameter type changes fromVec<(String, f64)>toVec<(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&spaceagainst itsparamsargument); - five hand-listing callers now pass
Scalarliterals: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).
- three sweep closures now pass
Data flow
Composite::param_space() -> Vec<ParamSpec>(names, slot order) — unchanged source of names.SweepBinder::sweepresolves named axes → positional grid →GridSpace::new(&space, axes), which now retainsspace.sweep_with_threads:points = grid.points()(positional); the closure runs over&points[i](bare&[Scalar], unchanged); the family is assembled asSweepFamily { space: grid.param_specs().to_vec(), points }. The low-levelsweep(&GridSpace, ..)path gets the names too, with nosweep()signature change.- Consumer (i) — in-closure:
sim_optimal_manifest(zip_params(&space, point), ..)at the three CLI sites;spaceis theparam_space()already captured before each closure. - Consumer (ii) — post-hoc:
family.named_params(i)reads names off the carriedspace.
The positional binding path (bootstrap_with_params(point.to_vec()), C23
by-index) is entirely untouched.
Error handling
zip_paramspairs co-indexed slices; equal slot order and length is the invariant (both originate from the same family / sameparam_space()).ziptruncates to the shorter on a (contract-violating) mismatch; no panic.SweepFamily::named_params(i)indexesself.points[i]— an out-of-rangeipanics like any slice index (caller's contract), consistent with existingpointsaccess.- The f64 collapse in
sim_optimal_manifestpreserves the existingscalar_as_param_f64contract verbatim:I64/F64 -> f64,unreachable!onBool/Ts. Changing this (a typedVec<(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 inparam_space()slot order for a multi-axis space.zip_params_matches_hand_zip— behaviour-preservation pin:zip_params(space, point)(after coercion) equals the oldspace.iter().zip(point).map(...).collect()output for a known point.sweep_family_carries_param_space— a returnedSweepFamily.spaceequals the blueprint'sparam_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:
- Removes redundancy (measurable). The three identical hand-zip sites in
aura-clicollapse to onezip_params(&space, point)call each; the name↔value pairing logic lives once, tested. A consumer reading a returned family by name callsnamed_params(i)instead of re-fetchingparam_space()and re-zipping. - Audience naturally reaches for it. A sweep/walk-forward author building a
manifest, and #52's
RandomSpaceauthor reporting readable per-point params, both reach for these surfaces rather than re-deriving the pairing. - 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.
- 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)>) +Scalarserde — 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.