spec: 0093 sweep-from-blueprint (boss-signed)
Cycle 2 of the World/C21 milestone (#166): `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 seam, aggregating to a FamilyKind::Sweep family; each member manifest carries the shared topology_hash. Derivable extension, not a fork (recon + grounding confirm): the sweep is by-name param-space-driven (SweepBinder.axis / resolve_axes / bootstrap_with_cells), so a loaded blueprint's param_space slots in; wrap_stage1r nests an arbitrary signal; append_family is blueprint-agnostic. Scoped to sweep (the keystone family); MC / walk-forward over a loaded blueprint are the same machinery per verb and follow as a fast follow-on. Decisions recorded on #166. Boss-signed on a grounding-check PASS: all six load-bearing current-behaviour assumptions ratified by named green tests (wrap_stage1r arbitrary-signal nesting, the by-name param-space sweep, stage1_r_sweep_family, append_family/FamilyKind::Sweep, topology_hash, the .json-discriminator dispatch precedent). refs #166
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
# 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