Files
Aura/docs/plans/0046-sweep-named-binding.md
T
Brummel cd9f7e4ea3 plan: 0046 sweep named-binding
Three tasks for spec 0046. T1: zip_params (aura-core), RED-first. T2:
GridSpace retains its ParamSpec list + SweepFamily carries it + named_params
(aura-engine), RED-first, with the aura-registry optimize-test SweepFamily
literal threaded in the same task. T3: sim_optimal_manifest typed-Scalar
migration + all eight call sites (aura-cli), behaviour-preserving. Each
signature/field change threads every one of its sites within its own task,
before that task's workspace compile gate.

refs #57
2026-06-15 23:16:51 +02:00

16 KiB

Sweep named-binding — Implementation Plan

Parent spec: docs/specs/0046-sweep-named-binding.md

For agentic workers: REQUIRED SUB-SKILL: use the implement skill to run this plan. Steps use - [ ] checkboxes for tracking.

Goal: Replace the three open-coded param_space()-name re-zips in the CLI with one derived projection (zip_params) and make a returned SweepFamily self-describing (named_params), without touching the run-closure signature.

Architecture: A free function zip_params(space, point) -> Vec<(String, Scalar)> in aura-core pairs names with a positional point. GridSpace retains the ParamSpec list it already receives in new(); SweepFamily carries it (stamped once in sweep_with_threads) and exposes named_params(i). The CLI's sim_optimal_manifest takes typed Scalar pairs and collapses to f64 internally; all eight of its call sites migrate. Behaviour-preserving: SweepPoint, enumeration, and the closure bound are untouched.

Tech Stack: aura-core (node.rs, lib.rs), aura-engine (sweep.rs), aura-registry (lib.rs test), aura-cli (main.rs).


Files this plan creates or modifies:

  • Modify: crates/aura-core/src/node.rs — add free fn zip_params + a unit-test module
  • Modify: crates/aura-core/src/lib.rs:42 — add zip_params to the node re-export
  • Modify: crates/aura-engine/src/sweep.rsGridSpace.space + param_specs(); SweepFamily.space + named_params(); stamp in sweep_with_threads; import zip_params; 2 tests
  • Modify: crates/aura-registry/src/lib.rs:278 — thread space: vec![] into the optimize test's SweepFamily literal
  • Modify: crates/aura-cli/src/main.rssim_optimal_manifest input type + 8 call sites; import zip_params

Task 1: zip_params (aura-core)

Files:

  • Modify: crates/aura-core/src/node.rs (ParamSpec at :67-71; module imports at :13)

  • Modify: crates/aura-core/src/lib.rs:42

  • Step 1: Write the failing tests

Append to crates/aura-core/src/node.rs (end of file):

#[cfg(test)]
mod zip_params_tests {
    use super::{zip_params, ParamSpec};
    use crate::{Scalar, ScalarKind};

    #[test]
    fn zip_params_pairs_names_with_values_in_slot_order() {
        let space = vec![
            ParamSpec { name: "fast".into(), kind: ScalarKind::I64 },
            ParamSpec { name: "scale".into(), kind: ScalarKind::F64 },
        ];
        let point = vec![Scalar::I64(2), Scalar::F64(0.5)];
        assert_eq!(
            zip_params(&space, &point),
            vec![
                ("fast".to_string(), Scalar::I64(2)),
                ("scale".to_string(), Scalar::F64(0.5)),
            ],
        );
    }

    #[test]
    fn zip_params_matches_hand_zip() {
        let space = vec![
            ParamSpec { name: "a".into(), kind: ScalarKind::I64 },
            ParamSpec { name: "b".into(), kind: ScalarKind::I64 },
        ];
        let point = vec![Scalar::I64(7), Scalar::I64(9)];
        let hand: Vec<(String, Scalar)> =
            space.iter().zip(&point).map(|(ps, v)| (ps.name.clone(), *v)).collect();
        assert_eq!(zip_params(&space, &point), hand);
    }
}
  • Step 2: Run tests to verify they fail

Run: cargo test -p aura-core zip_params Expected: FAIL — compile error cannot find function zip_params in this scope / unresolved import super::zip_params (E0432/E0425); zip_params does not exist yet.

  • Step 3: Add the function and re-export it

Scalar is already in scope in node.rs (its use crate::{Ctx, Scalar, ScalarKind}; at line 13). Add this free function to crates/aura-core/src/node.rs (immediately after the ParamSpec struct, ~line 71):

/// Pair each param-space name with the co-indexed positional value — the
/// inverse of positional binding (C23): names are a derived view, not identity.
/// `space` and `point` are co-indexed in `param_space()` slot order; a length
/// mismatch (contract violation) truncates to the shorter, never panics.
pub fn zip_params(space: &[ParamSpec], point: &[Scalar]) -> Vec<(String, Scalar)> {
    space.iter().zip(point).map(|(ps, v)| (ps.name.clone(), *v)).collect()
}

Then add zip_params to the node re-export in crates/aura-core/src/lib.rs:42. Change:

pub use node::{FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder};

to:

pub use node::{
    zip_params, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
};
  • Step 4: Run tests to verify they pass

Run: cargo test -p aura-core zip_params Expected: PASS — 2 tests (zip_params_pairs_names_with_values_in_slot_order, zip_params_matches_hand_zip).


Task 2: GridSpace retains specs; SweepFamily carries them + named_params (aura-engine), with the aura-registry literal threaded

Files:

  • Modify: crates/aura-engine/src/sweep.rs (imports :7; GridSpace :14-17; GridSpace::new :24-44; SweepFamily :106-109; sweep_with_threads :169-182; test module :184+)

  • Modify: crates/aura-registry/src/lib.rs:278 (the optimize test's SweepFamily literal)

  • Step 1: Write the failing tests

Add to the #[cfg(test)] mod tests in crates/aura-engine/src/sweep.rs (after sweep_equals_n_independent_runs, ~line 326). These reuse the existing composite_sma_cross_harness, sma_cross_grid, and run_point helpers:

    #[test]
    fn sweep_family_carries_param_space() {
        let space = composite_sma_cross_harness().0.param_space();
        let family = sweep(&sma_cross_grid(), run_point);
        assert_eq!(family.space, space);
    }

    #[test]
    fn family_named_params_round_trips() {
        let space = composite_sma_cross_harness().0.param_space();
        let family = sweep(&sma_cross_grid(), run_point);
        // odometer-first point is [I64(2), I64(4), F64(0.5)]
        let expected: Vec<(String, Scalar)> = space
            .iter()
            .cloned()
            .zip(family.points[0].params.clone())
            .map(|(ps, v)| (ps.name, v))
            .collect();
        assert_eq!(family.named_params(0), expected);
        assert_eq!(family.named_params(0)[0].1, Scalar::I64(2));
    }
  • Step 2: Run tests to verify they fail

Run: cargo test -p aura-engine sweep_family_carries_param_space Expected: FAIL — compile error no field spaceon type&SweepFamily`` (E0609); the field does not exist yet.

  • Step 3: Retain the specs on GridSpace

Import zip_params in crates/aura-engine/src/sweep.rs:7. Change:

use aura_core::{ParamSpec, Scalar, ScalarKind};

to:

use aura_core::{zip_params, ParamSpec, Scalar, ScalarKind};

Change the GridSpace struct (sweep.rs:14-17):

#[derive(Debug)]
pub struct GridSpace {
    axes: Vec<Vec<Scalar>>,
}

to:

#[derive(Debug)]
pub struct GridSpace {
    space: Vec<ParamSpec>,
    axes: Vec<Vec<Scalar>>,
}

In GridSpace::new, change the final constructor (sweep.rs:43) from Ok(Self { axes }) to:

        Ok(Self { space: space.to_vec(), axes })

Add a param_specs accessor inside impl GridSpace (next to points):

    /// The param-space (names + kinds) this grid was validated against, retained
    /// for the family to carry — the derived-name source (C23: names, not identity).
    pub fn param_specs(&self) -> &[ParamSpec] {
        &self.space
    }
  • Step 4: Carry the space on SweepFamily + add named_params; stamp it

Change the SweepFamily struct (sweep.rs:106-109):

#[derive(Clone, Debug, PartialEq)]
pub struct SweepFamily {
    pub points: Vec<SweepPoint>,
}

to:

#[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 — a derived view over the
    /// carried param-space (reuses [`zip_params`]); no new per-point state.
    pub fn named_params(&self, i: usize) -> Vec<(String, Scalar)> {
        zip_params(&self.space, &self.points[i].params)
    }
}

Change the family assembly in sweep_with_threads (sweep.rs:175-181) from:

    SweepFamily {
        points: points
            .into_iter()
            .zip(reports)
            .map(|(params, report)| SweepPoint { params, report })
            .collect(),
    }

to:

    SweepFamily {
        space: space.param_specs().to_vec(),
        points: points
            .into_iter()
            .zip(reports)
            .map(|(params, report)| SweepPoint { params, report })
            .collect(),
    }
  • Step 5: Thread the new field into the aura-registry test literal

The field addition breaks the SweepFamily { points: vec![..] } literal in the optimize test (crates/aura-registry/src/lib.rs:278). optimize/rank_by read only .points[].report, so an empty space suffices. Change:

        let family = SweepFamily {
            points: vec![
                point(0.0, 1.0),  // 0: also-ran
                point(10.0, 3.0), // 1: tied max, earliest -> the winner
                point(20.0, 2.0), // 2: also-ran
                point(30.0, 3.0), // 3: tied max, but later -> must lose the tie
            ],
        };

to:

        let family = SweepFamily {
            space: vec![],
            points: vec![
                point(0.0, 1.0),  // 0: also-ran
                point(10.0, 3.0), // 1: tied max, earliest -> the winner
                point(20.0, 2.0), // 2: also-ran
                point(30.0, 3.0), // 3: tied max, but later -> must lose the tie
            ],
        };
  • Step 6: Compile the whole workspace (incl. tests) to confirm the field is fully threaded

Run: cargo test --workspace --no-run Expected: builds with 0 errors (aura-engine assembly + aura-registry test literal both threaded; the aura-cli still compiles — it constructs no SweepFamily literal).

  • Step 7: Run the new tests to verify they pass

Run: cargo test -p aura-engine sweep_family_carries_param_space Expected: PASS — 1 test.

Run: cargo test -p aura-engine family_named_params_round_trips Expected: PASS — 1 test.

  • Step 8: Verify the determinism guard still passes

Run: cargo test -p aura-engine family_is_deterministic_across_thread_counts Expected: PASS — 1 test (enumeration untouched; the new space field is equal across thread counts for the same grid).


Task 3: Migrate sim_optimal_manifest to typed Scalar pairs + all 8 call sites (aura-cli)

Behaviour-preserving migration (no new RED test): the manifest's stored params: Vec<(String, f64)> is byte-identical before and after; the existing CLI E2E suite is the regression guard.

Files:

  • Modify: crates/aura-cli/src/main.rs (imports :16; sim_optimal_manifest :130-142; closures :386-392, :503-509, :530-536; literal callers :161, :224, :613, :869, :970)

  • Step 1: Import zip_params

Change crates/aura-cli/src/main.rs:16:

use aura_core::{Firing, Scalar, ScalarKind, Timestamp};

to:

use aura_core::{zip_params, Firing, Scalar, ScalarKind, Timestamp};
  • Step 2: Change sim_optimal_manifest to take typed pairs, collapsing f64 internally

Change crates/aura-cli/src/main.rs:130-142:

fn sim_optimal_manifest(
    params: Vec<(String, f64)>,
    window: (Timestamp, Timestamp),
    seed: u64,
) -> RunManifest {
    RunManifest {
        commit: option_env!("AURA_COMMIT").unwrap_or("unknown").to_string(),
        params,
        window,
        seed,
        broker: "sim-optimal(pip_size=0.0001)".to_string(),
    }
}

to:

fn sim_optimal_manifest(
    params: Vec<(String, Scalar)>,
    window: (Timestamp, Timestamp),
    seed: u64,
) -> RunManifest {
    // The lossy f64 collapse lives here — the manifest field (the deferred typed
    // param-space precursor) owns its own lossiness; callers pass typed Scalars.
    let params = params.into_iter().map(|(n, s)| (n, scalar_as_param_f64(&s))).collect();
    RunManifest {
        commit: option_env!("AURA_COMMIT").unwrap_or("unknown").to_string(),
        params,
        window,
        seed,
        broker: "sim-optimal(pip_size=0.0001)".to_string(),
    }
}
  • Step 3: Migrate the three sweep closures to zip_params

In sweep_family (main.rs:386-392), delete the hand-zip and pass zip_params. Change:

            let params = space
                .iter()
                .zip(point)
                .map(|(ps, v)| (ps.name.clone(), scalar_as_param_f64(v)))
                .collect();
            RunReport {
                manifest: sim_optimal_manifest(params, window, 0),
                metrics: summarize(&equity, &exposure),
            }

to:

            RunReport {
                manifest: sim_optimal_manifest(zip_params(&space, point), window, 0),
                metrics: summarize(&equity, &exposure),
            }

In sweep_over (main.rs:503-509), the block is byte-identical to sweep_family's. Change:

            let params = space
                .iter()
                .zip(point)
                .map(|(ps, v)| (ps.name.clone(), scalar_as_param_f64(v)))
                .collect();
            RunReport {
                manifest: sim_optimal_manifest(params, window, 0),
                metrics: summarize(&equity, &exposure),
            }

to:

            RunReport {
                manifest: sim_optimal_manifest(zip_params(&space, point), window, 0),
                metrics: summarize(&equity, &exposure),
            }

In run_oos (main.rs:530-536), the zip is over the params argument into named. Change:

    let named = space
        .iter()
        .zip(params)
        .map(|(ps, v)| (ps.name.clone(), scalar_as_param_f64(v)))
        .collect();
    let report = RunReport {
        manifest: sim_optimal_manifest(named, window, 0),
        metrics: summarize(&equity, &exposure),
    };

to:

    let report = RunReport {
        manifest: sim_optimal_manifest(zip_params(&space, params), window, 0),
        metrics: summarize(&equity, &exposure),
    };
  • Step 4: Migrate the five literal callers to Scalar values

In run_sample (main.rs:161), run_sample_real (main.rs:224), mc_family (main.rs:613), and run_sample_seeded (main.rs:970) — each passes the same 3-tuple. Change every occurrence of:

            vec![
                ("sma_fast".to_string(), 2.0),
                ("sma_slow".to_string(), 4.0),
                ("exposure_scale".to_string(), 0.5),
            ],

to:

            vec![
                ("sma_fast".to_string(), Scalar::F64(2.0)),
                ("sma_slow".to_string(), Scalar::F64(4.0)),
                ("exposure_scale".to_string(), Scalar::F64(0.5)),
            ],

In run_macd (main.rs:869), the 4-tuple incl. ema_signal. Change:

            vec![
                ("ema_fast".to_string(), 2.0),
                ("ema_slow".to_string(), 4.0),
                ("ema_signal".to_string(), 3.0),
                ("exposure_scale".to_string(), 0.5),
            ],

to:

            vec![
                ("ema_fast".to_string(), Scalar::F64(2.0)),
                ("ema_slow".to_string(), Scalar::F64(4.0)),
                ("ema_signal".to_string(), Scalar::F64(3.0)),
                ("exposure_scale".to_string(), Scalar::F64(0.5)),
            ],
  • Step 5: Compile the whole workspace (incl. tests)

Run: cargo test --workspace --no-run Expected: builds with 0 errors (all 8 sim_optimal_manifest call sites migrated, incl. the #[cfg(test)] helper run_sample_seeded).

  • Step 6: Verify behaviour-preservation via the existing E2E suite

Run: cargo test -p aura-cli run_sample_is_deterministic_and_non_trivial Expected: PASS (the manifest params for the sample run are unchanged).

Run: cargo test -p aura-cli sweep_report_renders_four_points_in_odometer_order Expected: PASS (the aura sweep output is byte-identical).

Run: cargo test -p aura-cli run_macd_compiles_from_nested_composite_and_is_deterministic Expected: PASS (the 4-tuple run_macd manifest is unchanged).

  • Step 7: Full workspace suite + lint

Run: cargo test --workspace Expected: PASS — all tests green (no regressions; the two new aura-engine tests and two new aura-core tests included).

Run: cargo clippy --workspace --all-targets -- -D warnings Expected: clean — 0 warnings.