feat(cli): sweep axes override bound params on the blueprint verb paths
Task 3-4 of the bound-override cycle. The family boundary derives the override set from the invocation's axes (override_paths: an axis in the open space passes through, one naming a bound param re-opens it in strategy coordinates, one matching neither is refused with a message pointing at --list-axes) and threads it through the axis probe and every per-member reload (blueprint_axis_probe_reopened / reopen_all), so probe and members stay slot-identical. The 'fully bound; nothing to sweep' refusal retires; identity stays the authored document (the topology_hash probe reload is never re-opened). --list-axes now prints the bound surface after the open knobs — one '<bp>.<node>.<param>:<KIND> default=<value>' line per bound param, unconditionally (a bound param is an equally re-openable axis regardless of how many knobs are open beside it). Walk-forward (blueprint_sweep_over, blueprint_walkforward_family, run_oos_blueprint) and reproduce (reproduce_family_in plus the campaign-side trace re-run) derive the same set silently from recorded manifest param names (bound_overrides_of), so a family that swept an overridden bound param re-derives bit-identically. refs #246
This commit is contained in:
@@ -1401,9 +1401,12 @@ fn sweep_real_ohlc_channel_members_run_and_reproduce() {
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&axes.stdout).trim(),
|
||||
"hl_channel.channel_length:I64",
|
||||
"the ganged channel knob is the one sweepable axis; stderr: {}",
|
||||
String::from_utf8_lossy(&axes.stdout),
|
||||
"hl_channel.channel_length:I64\n\
|
||||
hl_channel.prev_high.lag:I64 default=1\n\
|
||||
hl_channel.prev_low.lag:I64 default=1\n",
|
||||
"the ganged channel knob is the one open axis, alongside the fixture's \
|
||||
bound Delay lags as #246 defaults; stderr: {}",
|
||||
String::from_utf8_lossy(&axes.stderr)
|
||||
);
|
||||
let out = std::process::Command::new(BIN)
|
||||
@@ -1813,6 +1816,105 @@ fn reproduce_real_sweep_family_re_derives_bit_identically() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Property (C18/C1 reproduce guarantee, #246): the same reproduce guarantee holds
|
||||
/// when the swept axis names a BOUND param of a CLOSED starter blueprint, not an
|
||||
/// already-open one — `r_sma.json` (both `sma_signal.fast.length` and
|
||||
/// `sma_signal.slow.length` bound), the closed counterpart of the OPEN
|
||||
/// `r_sma_open.json` the walk-forward reproduce sibling
|
||||
/// (`aura_walkforward_over_a_blueprint_reproduces_bit_identically`) sweeps.
|
||||
/// Modelled on that sibling (synthetic in-process walk-forward — no `--real`),
|
||||
/// NOT on `reproduce_real_sweep_family_re_derives_bit_identically`: a real-data
|
||||
/// mint routes through the campaign executor (`CliMemberRunner::run_member` +
|
||||
/// `validate_and_register_axes`), neither of which carries any #246 override
|
||||
/// threading — a gap outside this task's file scope (`blueprint_sweep_over`,
|
||||
/// `reproduce_family_in`) and spanning all four dispatch verbs, so exercising
|
||||
/// it here would silently expand into unrelated, unreviewed surface. The
|
||||
/// synthetic walk-forward path exercises exactly the threaded functions:
|
||||
/// `blueprint_sweep_over` (via `blueprint_walkforward_family`) re-opens
|
||||
/// `sma_signal.fast.length` for the per-window IS re-fit, and
|
||||
/// `reproduce_family_in` re-opens it again from the recorded manifest params
|
||||
/// so the re-derivation resolves against the same space the mint used.
|
||||
#[test]
|
||||
fn reproduce_family_with_overridden_bound_param_re_derives() {
|
||||
let cwd = temp_cwd("walkforward-reproduce-override");
|
||||
let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let run = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args(["walkforward", &bp, "--axis", "sma_signal.fast.length=2,3", "--name", "wfo"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura walkforward");
|
||||
assert_eq!(
|
||||
run.status.code(),
|
||||
Some(0),
|
||||
"walk-forward over a bound-param axis must succeed (#246 re-open), stderr: {}",
|
||||
String::from_utf8_lossy(&run.stderr)
|
||||
);
|
||||
let repro = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args(["reproduce", "wfo-0"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura reproduce");
|
||||
assert_eq!(
|
||||
repro.status.code(),
|
||||
Some(0),
|
||||
"reproduce stderr: {}",
|
||||
String::from_utf8_lossy(&repro.stderr)
|
||||
);
|
||||
let out = String::from_utf8(repro.stdout).expect("utf-8");
|
||||
assert!(
|
||||
out.contains("reproduced 3/3 members bit-identically"),
|
||||
"every OOS member of a deterministic walk-forward over a bound-param axis \
|
||||
re-derives bit-identically: {out}"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
/// Property (#246, distinct code path from `reproduce_family_with_overridden_bound_param_re_derives`):
|
||||
/// `reproduce_family_in`'s override derivation (`bound_overrides_of`, computed once
|
||||
/// per family from the FIRST member's recorded params) applies uniformly across
|
||||
/// `FamilyKind`s, not only `WalkForward` — a plain `aura sweep` family minted by
|
||||
/// overriding a bound param on the CLOSED `r_sma.json` fixture re-derives
|
||||
/// bit-identically too. The sibling test exercises `FamilyKind::WalkForward`'s
|
||||
/// branch (per-window OOS reload via `run_oos_blueprint`); this one exercises the
|
||||
/// `Sweep`-kind branch (`data.run_sources`/`data.full_window`, no per-window
|
||||
/// re-fit) of the same shared dispatch in `reproduce_family_in`. A regression that
|
||||
/// threaded the #246 override set through one branch but not the other would pass
|
||||
/// the walk-forward sibling while failing here.
|
||||
#[test]
|
||||
fn aura_reproduce_re_derives_a_sweep_family_minted_over_a_bound_param_override() {
|
||||
let cwd = temp_cwd("sweep-reproduce-bound-override");
|
||||
let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let sweep = Command::new(BIN)
|
||||
.args(["sweep", &bp, "--axis", "sma_signal.fast.length=2,3", "--name", "swo"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura sweep over a bound-param axis");
|
||||
assert_eq!(
|
||||
sweep.status.code(),
|
||||
Some(0),
|
||||
"sweeping a bound param of a closed blueprint must succeed (#246 re-open), stderr: {}",
|
||||
String::from_utf8_lossy(&sweep.stderr)
|
||||
);
|
||||
let repro = Command::new(BIN)
|
||||
.args(["reproduce", "swo-0"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura reproduce");
|
||||
assert_eq!(
|
||||
repro.status.code(),
|
||||
Some(0),
|
||||
"reproduce stderr: {}",
|
||||
String::from_utf8_lossy(&repro.stderr)
|
||||
);
|
||||
let out = String::from_utf8(repro.stdout).expect("utf-8");
|
||||
assert!(
|
||||
out.contains("reproduced 2/2 members bit-identically"),
|
||||
"every member of a plain sweep minted over a bound-param override \
|
||||
re-derives bit-identically: {out}"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
/// Property: `aura reproduce` yields a handled outcome — a reproduction verdict
|
||||
/// or a clean `aura:` refusal — for a real-data WalkForward family, and NEVER an
|
||||
/// internal panic. A WalkForward member's OOS window is stored as real epoch-ns
|
||||
@@ -4385,10 +4487,14 @@ fn aura_reproduce_rejects_a_missing_stored_blueprint() {
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
/// E2E (#169): `aura sweep <open blueprint.json> --list-axes` prints one
|
||||
/// `<name>:<kind>` line per open sweepable knob, in `param_space()` order, and
|
||||
/// exits 0. The names are prefixed by the wrapping (sma_signal.*) — exactly
|
||||
/// the strings `--axis` binds — so a user can discover then sweep without guessing.
|
||||
/// E2E (#169/#246): `aura sweep <open blueprint.json> --list-axes` prints one
|
||||
/// `<name>:<kind>` line per open sweepable knob, in `param_space()` order,
|
||||
/// followed by one `<name>:<kind> default=<value>` line per BOUND param (the
|
||||
/// fixture's `bias.scale` stays bound even though the two SMA lengths are
|
||||
/// open — #246's discovery surface lists bound params on EVERY blueprint
|
||||
/// shape, not only a fully-closed one), and exits 0. The names are prefixed
|
||||
/// by the wrapping (sma_signal.*) — exactly the strings `--axis` binds — so a
|
||||
/// user can discover then sweep or re-open without guessing.
|
||||
#[test]
|
||||
fn aura_sweep_list_axes_prints_prefixed_names() {
|
||||
let bp = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
|
||||
@@ -4398,20 +4504,35 @@ fn aura_sweep_list_axes_prints_prefixed_names() {
|
||||
.expect("spawn aura sweep --list-axes");
|
||||
assert_eq!(out.status.code(), Some(0));
|
||||
let stdout = String::from_utf8(out.stdout).expect("utf-8");
|
||||
assert_eq!(stdout, "sma_signal.fast.length:I64\nsma_signal.slow.length:I64\n");
|
||||
assert_eq!(
|
||||
stdout,
|
||||
"sma_signal.fast.length:I64\n\
|
||||
sma_signal.slow.length:I64\n\
|
||||
sma_signal.bias.scale:F64 default=0.5\n"
|
||||
);
|
||||
}
|
||||
|
||||
/// E2E (#169): `--list-axes` on a CLOSED blueprint (all knobs bound) prints
|
||||
/// nothing and exits 0 — an empty axis set is the honest answer, not an error.
|
||||
/// E2E (#246): `--list-axes` on a CLOSED blueprint (all knobs bound) prints one
|
||||
/// `<bp>.<node>.<param>:<KIND> default=<value>` line per bound param — the
|
||||
/// bound value IS the default, overridable by naming the same path as an
|
||||
/// `--axis` (#246's re-open contract) — and exits 0. The sibling pin above
|
||||
/// (`aura_sweep_list_axes_prints_prefixed_names`) proves the same bound-param
|
||||
/// lines also appear on a PARTIALLY open blueprint, after its open lines.
|
||||
#[test]
|
||||
fn aura_sweep_list_axes_closed_is_empty() {
|
||||
fn aura_sweep_list_axes_closed_prints_bound_defaults() {
|
||||
let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args(["sweep", &bp, "--list-axes"])
|
||||
.output()
|
||||
.expect("spawn aura sweep --list-axes (closed)");
|
||||
assert_eq!(out.status.code(), Some(0));
|
||||
assert!(out.stdout.is_empty(), "a closed blueprint has no open axes");
|
||||
let stdout = String::from_utf8(out.stdout).expect("utf-8");
|
||||
assert_eq!(
|
||||
stdout,
|
||||
"sma_signal.fast.length:I64 default=2\n\
|
||||
sma_signal.slow.length:I64 default=4\n\
|
||||
sma_signal.bias.scale:F64 default=0.5\n"
|
||||
);
|
||||
}
|
||||
|
||||
/// E2E (#169): `--list-axes` is a standalone query — combining it with a real
|
||||
@@ -4510,26 +4631,48 @@ fn aura_sweep_list_axes_names_round_trip_into_a_working_sweep() {
|
||||
let cwd = temp_cwd("blueprint-list-axes-roundtrip");
|
||||
let bp = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
|
||||
|
||||
// 1) discover the axis names (do NOT hardcode them) — this is what a user reads.
|
||||
// 1) discover the axis names + kinds (do NOT hardcode them) — this is what a user
|
||||
// reads. #246: the fixture now lists a BOUND param (`bias.scale`, F64) alongside
|
||||
// the two open I64 lengths, so the CSV below must be typed per discovered kind,
|
||||
// not assumed integer.
|
||||
let list = Command::new(BIN)
|
||||
.args(["sweep", &bp, "--list-axes"])
|
||||
.output()
|
||||
.expect("spawn aura sweep --list-axes");
|
||||
assert_eq!(list.status.code(), Some(0));
|
||||
let listed = String::from_utf8(list.stdout).expect("utf-8");
|
||||
let names: Vec<String> = listed
|
||||
let names: Vec<(String, String)> = listed
|
||||
.lines()
|
||||
.map(|l| l.split(':').next().expect("a `name:kind` line").to_string())
|
||||
.map(|l| {
|
||||
let mut parts = l.splitn(2, ':');
|
||||
let name = parts.next().expect("a `name:kind` line").to_string();
|
||||
let kind = parts
|
||||
.next()
|
||||
.expect("kind after ':'")
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.expect("a kind token")
|
||||
.to_string();
|
||||
(name, kind)
|
||||
})
|
||||
.collect();
|
||||
assert!(!names.is_empty(), "the open fixture must list >= 1 axis");
|
||||
|
||||
// 2) feed the DISCOVERED names straight back into a real sweep. Values are
|
||||
// position-assigned (first axis small, rest large) to stay in a non-degenerate
|
||||
// SMA regime without re-hardcoding the names; each axis takes two values.
|
||||
// SMA regime without re-hardcoding the names; each axis takes two values, typed
|
||||
// per its discovered kind so a bound F64 default (e.g. `bias.scale`) resolves
|
||||
// instead of tripping `KindMismatch` on an integer-shaped token.
|
||||
let mut args: Vec<String> = vec!["sweep".into(), bp.clone()];
|
||||
for (i, name) in names.iter().enumerate() {
|
||||
for (i, (name, kind)) in names.iter().enumerate() {
|
||||
let vals: &str = match (i == 0, kind.as_str()) {
|
||||
(true, "F64") => "2.0,4.0",
|
||||
(true, _) => "2,4",
|
||||
(false, "F64") => "8.0,16.0",
|
||||
(false, _) => "8,16",
|
||||
};
|
||||
args.push("--axis".into());
|
||||
args.push(format!("{name}={}", if i == 0 { "2,4" } else { "8,16" }));
|
||||
args.push(format!("{name}={vals}"));
|
||||
}
|
||||
let sweep = Command::new(BIN)
|
||||
.args(&args)
|
||||
|
||||
Reference in New Issue
Block a user