audit(0093): cycle close — sweep-from-blueprint; ledger refreshed, debt filed; rm ephemeral spec/plan
Cycle-close drift review (architect) for cycle 0093 / #166 (aura sweep <blueprint.json>). Drift-found on debt only; no contract violation. What holds (architect, evidence of review): invariant 9 fail-clean (resolve_axes runs before the sweep closure → named BindError UnknownKnob/MissingKnob/KindMismatch, exit 2; UnknownNodeType on load; no registry); C1/C12/C18 shape verbatim from stage1_r_sweep_family (members disjoint + enumeration-ordered; shared topology_hash correct, consistent with the cycle-1 #158 signal-only scope; append_family/ sweep_member_reports reused); reload-per-member is sound (Composite !Clone, #164); stdout uses family_member_line with family_id, run_sweep-consistent. Resolved: ledger refreshed (C24 Status — the cycle-0093 realization: the World now orchestrates FAMILIES from blueprint-data; the open-vs-bound fixture fact + the reload-per-member rationale recorded). Drift filed forward: #168 (--trace writes no per-member traces — silently like --name, debt-med), #169 (no axis-name discovery for loaded sweeps, debt-low), #170 (MC/walk-forward over a loaded blueprint — the next family-verb cycle, assigned to the milestone). Ephemeral 0093 spec + plan git-rm'd per the lifecycle convention. closes #166
This commit is contained in:
@@ -1847,6 +1847,22 @@ over the same signal (a behaviour-preserving C19/C23 restructure). Scope note: t
|
||||
covers the **signal** only (the fixed scaffolding is identified by `commit`); a content-id
|
||||
distinguishing harness-structural variants is a #158/#166 concern once scaffolding varies.
|
||||
|
||||
**Realization (2026-07-01, cycle 0093 — the World orchestrates FAMILIES from blueprint-data,
|
||||
#166).** `aura sweep <blueprint.json> --axis <name>=<csv>` loads an **open** signal blueprint,
|
||||
grids the by-name axes against its `param_space()`, builds each member through cycle-1's
|
||||
`wrap_stage1r` seam, and aggregates to a `FamilyKind::Sweep` family — byte-identical in shape
|
||||
to the hard-wired sweep (`append_family` / `sweep_member_reports` reused verbatim). This is the
|
||||
C21 step beyond a single run: the World now constructs and orchestrates **families** of
|
||||
harnesses from topology-data, not just one. Each member manifest carries the **shared**
|
||||
`topology_hash` (one signal topology, only params vary; `member_key` distinguishes members) —
|
||||
reproducible per C18. Two design facts: a sweep needs an **open** blueprint (a fully-bound one
|
||||
has an empty `param_space`, distinct from cycle-1's bound run fixture), and the signal is
|
||||
**re-loaded per member** from its serialized doc (the `Composite` is `!Clone` — its
|
||||
`PrimitiveBuilder`s hold `Box<dyn Fn>` build closures, #164 — and `bootstrap_with_cells`
|
||||
consumes the graph). Monte-Carlo / walk-forward over a loaded blueprint are the same machinery
|
||||
per verb (a tracked follow-on); per-member trace-writing on `--trace` and an axis-name
|
||||
discovery surface are tracked debt.
|
||||
|
||||
**Deferred to the World / project-as-crate cycle**: **full** content-addressed
|
||||
reproduction (#158, C18) — the `topology_hash` field landed cycle 0092; what remains is
|
||||
re-deriving the FlatGraph from a stored manifest and a content-id that covers
|
||||
|
||||
@@ -1,290 +0,0 @@
|
||||
# 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 <blueprint.json> --axis <name>=<csv>` 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 <name>=<csv>` →
|
||||
`Vec<(String, Vec<Scalar>)>`; `--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<Scalar>)>,
|
||||
name: String,
|
||||
persist: bool,
|
||||
data: DataChoice,
|
||||
}
|
||||
|
||||
/// Parse the `aura sweep <blueprint.json> …` tail: repeatable `--axis <name>=<csv>`
|
||||
/// (the by-name grid), `--name|--trace <fam>`, 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<SweepBlueprintArgs, String> {
|
||||
let usage = || "sweep <blueprint.json> --axis <name>=<csv> [--axis …] [--name <n> | --trace <n>] [--real <SYM> [--from <ms>] [--to <ms>]]".to_string();
|
||||
let mut axes: Vec<(String, Vec<Scalar>)> = Vec::new();
|
||||
let mut name: Option<String> = 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<Vec<Scalar>> {
|
||||
csv.split(',').map(|t| {
|
||||
let t = t.trim();
|
||||
if t.is_empty() { return None; }
|
||||
match t.parse::<i64>() {
|
||||
Ok(i) => Some(Scalar::i64(i)),
|
||||
Err(_) => t.parse::<f64>().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<Scalar>)], 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", "<abs path to tests/fixtures/stage1_signal.json>", "--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.
|
||||
@@ -1,181 +0,0 @@
|
||||
# Sweep a harness family from a serialized blueprint (`aura sweep <blueprint.json>`) — Design Spec
|
||||
|
||||
**Date:** 2026-06-30
|
||||
**Status:** Draft — awaiting grounding-check + sign-off
|
||||
**Authors:** orchestrator + Claude
|
||||
**Parent:** World/C21 milestone (run + orchestrate harness families from blueprint-data); reference issue #166
|
||||
|
||||
## Goal
|
||||
|
||||
Sweep a loaded signal blueprint over its named param-space axes:
|
||||
|
||||
```
|
||||
aura sweep <blueprint.json> --axis <name>=<csv> [--axis <name>=<csv> ...] [--name <fam> | --trace <fam>] [--real <SYM> [--from <ms>] [--to <ms>]]
|
||||
```
|
||||
|
||||
loads the signal (`blueprint_from_json`), wraps it in the Stage-1-R scaffolding
|
||||
via the cycle-1 `wrap_stage1r` seam, grids the user's by-name axes against the
|
||||
wrapped graph's `param_space()`, runs one member per grid point (C1: members are
|
||||
disjoint → the existing parallel sweep), and aggregates to a `FamilyKind::Sweep`
|
||||
family — **byte-identical in shape** to the hard-wired `aura sweep --strategy
|
||||
stage1-r` family. Each member's manifest carries `topology_hash` (the #158 anchor).
|
||||
|
||||
This is cycle 2 of the World/C21 milestone: the World now orchestrates **families**
|
||||
of harnesses built from blueprint-data, not just a single run (cycle 1). Monte-Carlo
|
||||
and walk-forward over a loaded blueprint are the same machinery per verb and follow
|
||||
as a fast follow-on (out of scope here; see § Out of scope).
|
||||
|
||||
## Architecture
|
||||
|
||||
The existing sweep is **param-space-driven and blueprint-agnostic**:
|
||||
`stage1_r_sweep_family` (`crates/aura-cli/src/main.rs`) builds an open graph once,
|
||||
reads `param_space()`, resolves named axes to param slots (`SweepBinder.axis(name,
|
||||
values)` → `resolve_axes`, `crates/aura-engine/src/blueprint.rs`), and runs
|
||||
`.sweep(|point| bootstrap_with_cells(point))` over the grid, draining each member to
|
||||
a `RunReport`; `Registry::append_family` (`crates/aura-registry/src/lineage.rs`)
|
||||
persists the members.
|
||||
|
||||
Cycle 2 swaps the **signal source** only: instead of the hard-wired
|
||||
`stage1_signal()` / `stage1_r_graph(None, None, …)`, the open graph is
|
||||
`wrap_stage1r(loaded_signal, …)` (cycle 1's seam, which nests an arbitrary signal
|
||||
Composite — `main.rs:2775`, `g.add(BlueprintNode::Composite(signal))` — and exposes
|
||||
its open params in the wrapped `param_space()`). The axes are **user-supplied by
|
||||
name** (a loaded signal is arbitrary, not only stage1-r's `fast`/`slow`), resolved
|
||||
against that `param_space()` by the existing by-name `resolve_axes`. The
|
||||
member-construction, the parallel sweep, the drain, and the family aggregation are
|
||||
**reused unchanged**.
|
||||
|
||||
The only additions: (1) a `.json`-discriminator `aura sweep <file.json>` arm (mirror
|
||||
of cycle 1's `aura run <file.json>` arm, `main.rs:3576`), (2) `--axis <name>=<csv>`
|
||||
parsing into `Vec<(String, Vec<Scalar>)>`, (3) each member's manifest carries
|
||||
`topology_hash(&loaded_signal)` (one computation, shared across members — same signal
|
||||
topology, only params vary; `member_key` distinguishes members). The hard-wired
|
||||
`--strategy` sweep path stays untouched (#159).
|
||||
|
||||
## Concrete code shapes
|
||||
|
||||
### The user-facing program (the criterion's evidence)
|
||||
|
||||
```console
|
||||
$ aura sweep sma_cross.json --axis fast.length=2,3,4 --axis slow.length=8,16 --name xfam
|
||||
# wraps the loaded signal once, grids fast.length × slow.length (6 members), runs them,
|
||||
# aggregates to a Sweep family; each member manifest carries the shared topology_hash.
|
||||
$ aura runs families
|
||||
{"family_id":"xfam-0","kind":"Sweep","members":6,...}
|
||||
```
|
||||
|
||||
### The sweep-blueprint arm (mirror of the cycle-1 run arm)
|
||||
|
||||
```rust
|
||||
// in the ["sweep", rest @ ..] dispatch, before parse_sweep_args (main.rs ~:3620):
|
||||
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 signal = blueprint_from_json(&doc, &|t| std_vocabulary(t)).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(signal, &axes, &name, persist, data);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
### The member-build (reuse the `stage1_r_sweep_family` pattern, swap the source)
|
||||
|
||||
```rust
|
||||
/// Sweep a loaded signal over user-named axes, reusing the stage1-r sweep
|
||||
/// machinery with the signal source swapped to `wrap_stage1r(signal, …)`. Each
|
||||
/// member manifest carries the signal's topology_hash (shared; member_key
|
||||
/// distinguishes members).
|
||||
fn run_blueprint_sweep(signal: Composite, axes: &[(String, Vec<Scalar>)], name: &str, persist: bool, data: DataSource) {
|
||||
let topo = topology_hash(&signal);
|
||||
// build wrap_stage1r(signal, …) ONCE (open params = the loaded signal's open params),
|
||||
// read param_space(), resolve the by-name `axes` against it (the existing resolve_axes —
|
||||
// an unknown axis name errors), .sweep(|point| bootstrap_with_cells(point) → run → drain
|
||||
// → RunReport with manifest.topology_hash = Some(topo)), then append_family(name,
|
||||
// FamilyKind::Sweep, &reports). The channel/reducer threading mirrors stage1_r_sweep_family.
|
||||
}
|
||||
```
|
||||
|
||||
The before→after on `RunManifest.topology_hash` is none — the field shipped cycle
|
||||
0092; this cycle only sets it `Some(topo)` inside the member loop.
|
||||
|
||||
## Components
|
||||
|
||||
- **CLI** (`main.rs`): the `aura sweep <file.json>` `.json`-discriminator arm +
|
||||
`parse_sweep_blueprint_args` (`--axis name=csv` repeatable → `Vec<(String,
|
||||
Vec<Scalar>)>`; `--name`/`--trace`; the existing `--real`/`--from`/`--to` data
|
||||
flags). A malformed/duplicate axis or a bad csv errors with a named message.
|
||||
- **`run_blueprint_sweep`** (`main.rs`): the member-construction + sweep + aggregation,
|
||||
mirroring `stage1_r_sweep_family` with the signal source = `wrap_stage1r(loaded, …)`
|
||||
and the per-member `topology_hash`.
|
||||
- Reused unchanged: `wrap_stage1r` (cycle 1), `SweepBinder` / `resolve_axes` /
|
||||
`bootstrap_with_cells` (`blueprint.rs`), `append_family` / `FamilyKind::Sweep`
|
||||
(`lineage.rs`), `topology_hash` (cycle 1).
|
||||
|
||||
## Data flow
|
||||
|
||||
`<blueprint.json>` → `blueprint_from_json` → `Composite` (signal) →
|
||||
`wrap_stage1r` → open wrapped `Composite` → `param_space()` → `SweepBinder` grids
|
||||
the by-name axes → per grid point: `bootstrap_with_cells(point)` → `Harness` → run →
|
||||
drain → `RunReport{manifest{…, topology_hash}, metrics}` → `append_family(Sweep)` →
|
||||
`families.jsonl`.
|
||||
|
||||
## Error handling
|
||||
|
||||
- Missing/unreadable file, `LoadError` (malformed / `UnknownNodeType` /
|
||||
`UnsupportedVersion`) → named message, non-zero exit (mirror cycle 1).
|
||||
- An `--axis <name>` that does not resolve against the loaded blueprint's
|
||||
`param_space()` → a clean error naming the unknown axis (the existing
|
||||
`resolve_axes` failure), exit non-zero — not a panic.
|
||||
- A malformed/empty/duplicate `--axis` or csv value → a named usage error.
|
||||
|
||||
## Testing strategy
|
||||
|
||||
- **Family-equivalence (the keystone):** a sweep over a serialized signal produces a
|
||||
`FamilyKind::Sweep` family whose members' metrics equal the equivalent hard-wired
|
||||
stage1-r sweep over the SAME signal + grid (the loaded path and the hard-wired path
|
||||
agree, C1), and each member manifest carries the shared `topology_hash`. Compare via
|
||||
the member reports / `families.jsonl`.
|
||||
- **member_key distinguishes; hash is shared:** all members carry the same
|
||||
`topology_hash`; their `member_key`s differ.
|
||||
- **Unknown axis errors cleanly:** `--axis nope=1,2` over a signal without a `nope`
|
||||
param → a named error, non-zero exit, no panic.
|
||||
- **E2E (binary):** `aura sweep tests/fixtures/stage1_signal.json --axis fast.length=2,4
|
||||
--name f` exits 0 and persists a 2-member Sweep family (`aura runs families` shows
|
||||
`"kind":"Sweep","members":2`); an unknown-node blueprint fails clean.
|
||||
- The hard-wired `aura sweep --strategy stage1-r` path is unchanged (its tests stay green).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `aura sweep <blueprint.json> --axis <name>=<csv> …` builds each member via the
|
||||
cycle-1 `wrap_stage1r` seam over the loaded signal and aggregates to a
|
||||
`FamilyKind::Sweep` family, byte-identical in shape to the hard-wired sweep.
|
||||
- [ ] Each member manifest carries `topology_hash` = SHA256 of the loaded signal's
|
||||
canonical form; all members share it; `member_key` distinguishes members.
|
||||
- [ ] Member metrics equal the equivalent hard-wired stage1-r sweep over the same
|
||||
signal + grid (C1).
|
||||
- [ ] An unknown `--axis` name fails clean (named error, non-zero), not a panic.
|
||||
- [ ] The hard-wired `--strategy` sweep path is untouched (#159); its tests stay green.
|
||||
|
||||
## Out of scope (named homes)
|
||||
|
||||
- **Monte-Carlo / walk-forward over a loaded blueprint** — the same machinery per
|
||||
verb (`run_walkforward` / the mc path + the `.json`-discriminator pattern); a fast
|
||||
follow-on cycle, not this one.
|
||||
- **Axes as composable builder tools** (nesting / chaining orchestration) — the
|
||||
follow-on composable-orchestration surface (charter), not this cycle.
|
||||
- **A content-id that covers harness-structural variants** — #158 (the hash covers
|
||||
the signal only; fine here, since a sweep varies params, not topology).
|
||||
|
||||
## Feature-acceptance (project criterion, applied)
|
||||
|
||||
- **Audience reaches for it:** a researcher who serialized a strategy and ran it once
|
||||
(cycle 1) naturally wants to *sweep* it over a parameter grid without re-authoring
|
||||
Rust — `aura sweep sma_cross.json --axis fast.length=2,3,4` is exactly that.
|
||||
- **Improves correctness / removes redundancy:** it makes a serialized topology
|
||||
*swept* and the members reproducible (`topology_hash`), and reuses the existing
|
||||
sweep machinery rather than a second implementation.
|
||||
- **Reintroduces no failure class:** node logic stays Rust (C17); the resolver
|
||||
resolves only compiled-in types (invariant 9, fails clean); members are disjoint
|
||||
and deterministic (C1); the deploy artifact stays frozen (research-plane, invariant 8).
|
||||
Reference in New Issue
Block a user