From b6d4ebfe233579f24e1e3dc0e4c818a5939d1e38 Mon Sep 17 00:00:00 2001 From: Brummel Date: Tue, 30 Jun 2026 23:07:09 +0200 Subject: [PATCH] plan: 0093 sweep-from-blueprint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three-task decomposition of #166 (sweep a loaded blueprint): the parser (parse_sweep_blueprint_args + SweepBlueprintArgs + --axis i64-then-f64 lexing), the member-build (run_blueprint_sweep — a structural mirror of stage1_r_sweep_family with three deviations: by-name axes, reload-per-member since Composite is !Clone, per-member topology_hash) + the .json dispatch arm, and the tests (grammar + equivalence-vs-run_signal_stage1r + the binary sweep-family E2E + unknown-axis fail-clean). Purely additive; reuses the engine sweep machinery + cycle-1's wrap_stage1r unchanged. Grounding decisions (Q1-Q5) recorded on #166. refs #166 --- docs/plans/0093-sweep-from-blueprint.md | 290 ++++++++++++++++++++++++ 1 file changed, 290 insertions(+) create mode 100644 docs/plans/0093-sweep-from-blueprint.md diff --git a/docs/plans/0093-sweep-from-blueprint.md b/docs/plans/0093-sweep-from-blueprint.md new file mode 100644 index 0000000..4bdd0bb --- /dev/null +++ b/docs/plans/0093-sweep-from-blueprint.md @@ -0,0 +1,290 @@ +# Sweep a harness family from a serialized blueprint — Implementation Plan + +> **Parent spec:** `docs/specs/0093-sweep-from-blueprint.md` (#166) +> +> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run +> this plan. Steps use `- [ ]` checkboxes. + +**Goal:** `aura sweep --axis =` sweeps a loaded signal +over its named param-space axes, reusing the existing stage1-r sweep machinery +with the signal source swapped to cycle-1's `wrap_stage1r`, aggregating to a +`FamilyKind::Sweep` family; each member manifest carries the shared `topology_hash`. + +**Architecture:** purely additive — one new dispatch arm + one parser + one +member-build fn, all in `crates/aura-cli/src/main.rs`. No signature change to an +existing fn, no field/variant add, no engine change. The new `run_blueprint_sweep` +mirrors `stage1_r_sweep_family` (`main.rs:1206-1311`) with three deviations: (a) +**by-name** axes (the user's `--axis name=vals`) instead of the suffix-match +discovery; (b) **reload the signal per member** from its serialized doc (Q1: +`Composite` is `!Clone` and `bootstrap_with_cells` consumes it); (c) each member +manifest carries `topology_hash`. + +**Grounding decisions (recorded on #166):** the signal is re-loaded per member via +`blueprint_from_json(doc, …)` (Composite !Clone); `wrap_stage1r` is called +`stop_open=false, reduce=true, cost=None`; member-equivalence is tested against +`run_signal_stage1r` per point (same scaffolding); the parser returns `DataChoice` +and the arm converts via `DataSource::from_choice`; `--axis` CSV lexes i64-then-f64 +and `resolve_axes` kind-checks. + +--- + +### Task 1: `parse_sweep_blueprint_args` + `SweepBlueprintArgs` (the CLI parser) + +**Files:** +- Modify: `crates/aura-cli/src/main.rs` — add the struct + parser beside `parse_blueprint_run_args` (`:3463-3517`) +- Test: `crates/aura-cli/src/main.rs` `#[cfg(test)] mod tests` (beside `parse_sweep_args_*`, `:4523-4760`) + +- [ ] **Step 1: The struct + parser.** Repeatable `--axis =` → + `Vec<(String, Vec)>`; `--name`/`--trace` (mutually exclusive, → name + + persist); the existing `--real`/`--from`/`--to` accumulator (`RealWindowGrammar` + `:653-699`) → `DataChoice` (`:704-707`). Pure, no I/O. Mirror the grammar style of + `parse_sweep_args` (`:1665-1706`) + `parse_blueprint_run_args` (`:3463-3517`): + +```rust +struct SweepBlueprintArgs { + axes: Vec<(String, Vec)>, + name: String, + persist: bool, + data: DataChoice, +} + +/// Parse the `aura sweep …` tail: repeatable `--axis =` +/// (the by-name grid), `--name|--trace `, and the shared `--real/--from/--to` +/// data accumulator. Pure — no DataServer I/O (the arm converts the DataChoice). +fn parse_sweep_blueprint_args(rest: &[&str]) -> Result { + let usage = || "sweep --axis = [--axis …] [--name | --trace ] [--real [--from ] [--to ]]".to_string(); + let mut axes: Vec<(String, Vec)> = Vec::new(); + let mut name: Option = None; + let mut persist = false; + let mut data = RealWindowGrammar::default(); + let mut it = rest.iter(); + while let Some(tok) = it.next() { + match *tok { + "--axis" => { + let spec = it.next().ok_or_else(usage)?; + let (n, csv) = spec.split_once('=').ok_or_else(usage)?; + if n.is_empty() || axes.iter().any(|(a, _)| a == n) { return Err(usage()); } // empty / duplicate axis + let vals = parse_scalar_csv(csv).ok_or_else(usage)?; + if vals.is_empty() { return Err(usage()); } + axes.push((n.to_string(), vals)); + } + "--name" if name.is_none() => { name = Some((*it.next().ok_or_else(usage)?).to_string()); } + "--trace" if name.is_none() => { name = Some((*it.next().ok_or_else(usage)?).to_string()); persist = true; } + "--name" | "--trace" => return Err(usage()), // repeated + flag @ ("--real" | "--from" | "--to") => { + let value = it.next().ok_or_else(usage)?; + data.accept(flag, value, &usage)?; + } + _ => return Err(usage()), + } + } + if axes.is_empty() { return Err(usage()); } // a sweep needs at least one axis + Ok(SweepBlueprintArgs { + axes, + name: name.unwrap_or_else(|| "sweep".to_string()), + persist, + data: data.finish(&usage)?, + }) +} + +/// Lex a `--axis` CSV into typed Scalars by shape (Q5): an integer-shaped token is +/// i64, otherwise f64. `resolve_axes` kind-checks each value against the param's +/// declared kind afterwards (a mismatch is a named error, not a panic). +fn parse_scalar_csv(csv: &str) -> Option> { + csv.split(',').map(|t| { + let t = t.trim(); + if t.is_empty() { return None; } + match t.parse::() { + Ok(i) => Some(Scalar::i64(i)), + Err(_) => t.parse::().ok().map(Scalar::f64), + } + }).collect() +} +``` + + CONFIRM at implement time (recon-flagged): the exact `RealWindowGrammar` method + names (`accept`/`finish` at `:674`/`:692`) and `DataChoice` shape; `parse_sweep_args` + is the authoritative precedent for the `--name`/`--trace`/`--real` grammar. + +- [ ] **Step 2: Unit-test the grammar** (beside `parse_sweep_args_*`): + +```rust +#[test] +fn parse_sweep_blueprint_args_grammar() { + let a = parse_sweep_blueprint_args(&["--axis", "fast.length=2,3,4", "--axis", "slow.length=8,16", "--name", "f"]).expect("ok"); + assert_eq!(a.axes.len(), 2); + assert_eq!(a.axes[0], ("fast.length".to_string(), vec![Scalar::I64(2), Scalar::I64(3), Scalar::I64(4)])); + assert_eq!(a.name, "f"); + assert!(!a.persist); + assert!(parse_sweep_blueprint_args(&["--name", "f"]).is_err()); // no axis + assert!(parse_sweep_blueprint_args(&["--axis", "x=1", "--axis", "x=2"]).is_err()); // duplicate axis + assert!(parse_sweep_blueprint_args(&["--axis", "x="]).is_err()); // empty csv + assert!(parse_sweep_blueprint_args(&["--axis", "bad"]).is_err()); // no '=' + // f64 axis lexes to F64: + let b = parse_sweep_blueprint_args(&["--axis", "bias.scale=0.5,0.75"]).expect("f64 axis"); + assert_eq!(b.axes[0].1, vec![Scalar::F64(0.5), Scalar::F64(0.75)]); +} +``` + +- [ ] **Step 3: Build + test** + +Run: `cargo test -p aura-cli --bin aura parse_sweep_blueprint_args_grammar` +Expected: PASS (`aura-cli` is a bin crate — `--bin aura`, not `--lib`). + +--- + +### Task 2: `run_blueprint_sweep` + the dispatch arm + +**Files:** +- Modify: `crates/aura-cli/src/main.rs` — add `run_blueprint_sweep` (beside `stage1_r_sweep_family` `:1206` or `run_signal_stage1r` `:2940`) + the `.json` dispatch arm in `["sweep", rest @ ..]` (`:3620`) +- Test: `crates/aura-cli/src/main.rs` test module + +- [ ] **Step 1: `run_blueprint_sweep`** — mirror `stage1_r_sweep_family` + (`:1206-1311`) with the three deviations. It takes the **doc** (not a Composite), + reloads per member (Q1), uses **by-name** axes, sets `topology_hash`: + +```rust +/// Sweep a loaded signal over user-named axes. Mirrors stage1_r_sweep_family with +/// the signal source swapped to wrap_stage1r(reload(doc)) and by-name axes; the +/// signal is re-loaded per member (Composite is !Clone). Each member manifest +/// carries the shared topology_hash; member_key distinguishes members. +fn run_blueprint_sweep(doc: &str, axes: &[(String, Vec)], name: &str, persist: bool, data: DataSource) { + let reload = |d: &str| blueprint_from_json(d, &|t| std_vocabulary(t)) + .unwrap_or_else(|e| { eprintln!("aura: {e:?}"); std::process::exit(2); }); + let topo = topology_hash(&reload(doc)); + let pip = data.pip_size(); + let window = data.full_window(); + // open wrapped graph for param_space (throwaway channels): + let (dtx, _drx) = (mpsc::channel(), ()); // four throwaway senders; see stage1_r_sweep_family channel setup + let probe = wrap_stage1r(reload(doc), /* throwaway tx_eq..tx_req */, false, true, None); + let space = probe.param_space(); + // resolve the by-name axes against `space`, build the binder (the existing + // SweepBinder.axis(name, vals) — an unknown name yields BindError::UnknownKnob, + // a kind mismatch BindError::KindMismatch, surfaced as a named error not a panic): + let mut binder = probe.into_sweep(); // or the existing `.axis(..)` entry — match stage1_r_sweep_family + for (n, vals) in axes { binder = binder.axis(n, vals.clone()); } + let reports = binder.sweep(|point| { + // fresh per-member graph (Q1: reload + fresh channels), bootstrap with the point's cells: + let (tx_eq, rx_eq) = mpsc::channel(); /* tx_ex, tx_r, tx_req similarly */ + let member = wrap_stage1r(reload(doc), tx_eq, tx_ex, tx_r, tx_req, false, true, None); + let mut h = member.bootstrap_with_cells(point).expect("member bootstraps"); + h.run(data.run_sources()); + // drain + fold metrics EXACTLY as stage1_r_sweep_family's closure does (:1281-1307): + let named = zip_params(&space, point); // by-name params for the manifest + let mut manifest = sim_optimal_manifest(named, window, 0, pip); + manifest.broker = stage1_r_broker_label(pip); + manifest.topology_hash = Some(topo.clone()); // the deviation + let metrics = /* folded eq/ex/r metrics, mirror :1281-1307 */; + RunReport { manifest, metrics } + }); + let reg = Registry::open("runs"); + if persist { reg.append_family(name, FamilyKind::Sweep, &reports).unwrap_or_else(|e| { eprintln!("aura: {e}"); std::process::exit(2); }); } + for r in &reports { println!("{}", r.to_json()); } // mirror run_sweep's stdout shape (:1876 area) +} +``` + + This is a STRUCTURAL mirror — read `stage1_r_sweep_family:1206-1311` and + `run_sweep:1861-1880` for the EXACT channel setup, the `reduce` taps, the + `varying_axes`/`member_key` derivation, the metric fold, the `Registry` open + + `append_family`/`sweep_member_reports` call, and the stdout shape; reproduce them, + changing only the three deviations above. The `SweepBinder` entry (`.axis()` on the + Composite vs `into_sweep()`) must match what `stage1_r_sweep_family` uses + (`blueprint.rs` `SweepBinder` `:376`, `Composite::axis` `:313`). + +- [ ] **Step 2: The dispatch arm.** In `["sweep", rest @ ..]` (`:3620`), before + `parse_sweep_args`, mirror the cycle-1 run arm (`:3576`): + +```rust +if let Some(path) = rest.first().filter(|a| a.ends_with(".json") && std::path::Path::new(a).is_file()) { + let doc = std::fs::read_to_string(path).unwrap_or_else(|e| { eprintln!("aura: {path}: {e}"); std::process::exit(2); }); + let SweepBlueprintArgs { axes, name, persist, data } = parse_sweep_blueprint_args(&rest[1..]) + .unwrap_or_else(|msg| { eprintln!("aura: {msg}"); std::process::exit(2); }); + run_blueprint_sweep(&doc, &axes, &name, persist, DataSource::from_choice(data)); + return; +} +``` + +- [ ] **Step 3: Unit test — equivalence + hash/member_key** (Q3: vs `run_signal_stage1r`): + +```rust +#[test] +fn blueprint_sweep_member_equals_single_run_and_shares_topology_hash() { + let doc = blueprint_to_json(&stage1_signal(Some(2), Some(4))).expect("serializes"); + // a 1-point "sweep" (fast.length fixed at 2) member must equal the cycle-1 single run: + let single = run_signal_stage1r(stage1_signal(Some(2), Some(4)), &[], RunData::Synthetic, 0); + // build the same member through run_blueprint_sweep's per-member path (extract a testable + // helper if needed, or assert via the persisted family); compare metrics + topology_hash. + // (If run_blueprint_sweep only prints/persists, assert the member RunReport from a 1-axis sweep + // over a fixed value equals `single` in metrics, and topology_hash == topology_hash(&stage1_signal(2,4)).) + assert_eq!(single.manifest.topology_hash.as_deref().map(str::len), Some(64)); + // (the loaded-vs-single equivalence is the keystone; shape the assertion to the chosen surface.) +} +``` + + NOTE: shape this test to whatever surface `run_blueprint_sweep` exposes (member + reports vec vs persisted family). The load-bearing assertions: (a) a member's + metrics == `run_signal_stage1r` for the same params; (b) all members share + `topology_hash`; (c) members' `member_key`s differ. + +- [ ] **Step 4: Build + test** + +Run: `cargo build -p aura-cli` +Expected: 0 errors, 0 warnings. + +Run: `cargo test -p aura-cli --bin aura blueprint_sweep` +Expected: PASS. + +--- + +### Task 3: Binary E2E + unknown-axis fail-clean + +**Files:** +- Test: `crates/aura-cli/tests/cli_run.rs` (the binary-spawn pattern; reuse the cycle-1 `stage1_signal.json` / `unknown_node.json` fixtures) + +- [ ] **Step 1: E2E — a loaded-blueprint sweep persists a Sweep family:** + +```rust +#[test] +fn aura_sweep_loads_a_blueprint_and_persists_a_sweep_family() { + let cwd = /* a temp dir, as the sibling family tests do (see sweep_prints_four_family_json_lines_…) */; + let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args(["sweep", "", "--axis", "fast.length=2,4", "--trace", "f"]) + .current_dir(&cwd) + .output().expect("spawn aura sweep"); + assert_eq!(out.status.code(), Some(0), "stderr={}", String::from_utf8_lossy(&out.stderr)); + // the family is discoverable with two members: + let fams = std::process::Command::new(env!("CARGO_BIN_EXE_aura")).args(["runs", "families"]).current_dir(&cwd).output().expect("families"); + let fo = String::from_utf8(fams.stdout).expect("utf-8"); + assert!(fo.contains("\"kind\":\"Sweep\""), "{fo}"); + assert!(fo.contains("\"members\":2"), "{fo}"); +} +``` + + Match the EXACT temp-dir + `--trace` persistence + `runs families` pattern of the + existing `sweep_*` family E2E tests (`cli_run.rs` `:759-838`, `:2092-2147`) — the + fixture path must be absolute or copied into `cwd`, as those tests do. + +- [ ] **Step 2: unknown-axis fails clean (not a panic):** + +```rust +#[test] +fn aura_sweep_rejects_an_unknown_axis() { + let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args(["sweep", "tests/fixtures/stage1_signal.json", "--axis", "nope=1,2", "--name", "f"]) + .output().expect("spawn"); + assert_ne!(out.status.code(), Some(0), "unknown axis must fail"); + let stderr = String::from_utf8(out.stderr).expect("utf-8"); + assert!(stderr.to_lowercase().contains("knob") || stderr.to_lowercase().contains("axis") || stderr.contains("nope"), "names the cause: {stderr}"); +} +``` + +- [ ] **Step 3: Full verification** + +Run: `cargo test -p aura-cli --test cli_run aura_sweep` +Expected: PASS (both E2E). + +Run: `cargo test --workspace` +Expected: PASS — full suite green (the hard-wired sweep path unchanged). + +Run: `cargo clippy --workspace --all-targets -- -D warnings` +Expected: clean.