feat(cli): SugarInvocation + generalize goes blueprint-generic (#220 slice 1)

Tasks 1-2 of the verb-axis-generalization plan (spec auto-signed on a
grounding-check PASS, decision log on #220):

- verb_sugar gains the shared SugarInvocation<'_> carrier (#214) with
  VolStop and the factored doc_axes_from/risk_from/probe_params_from
  helpers; translate_sweep/run_sweep_sugar adopt it (no behaviour
  change — sweep argv and family output byte-identical), and
  translate_generalize/run_generalize_sugar follow, dropping their
  too_many_arguments allows (4 remain for wf/mc).
- generalize is de-welded: GeneralizeCmd takes a blueprint positional
  plus repeatable --axis <wrapped-name>=<value> (one value per axis);
  --strategy/--fast/--slow are deleted; dispatch_generalize runs the
  sweep sequence (read, wrapped-namespace validation, canonicalize +
  topology_hash registration, wrapped_to_raw_axis strip) with a new
  usage closure. RGrid is deleted workspace-wide.
- cli_run generalize tests migrate argv-only; the exact-grade anchor
  keeps all pinned floats verbatim; generalize_refuses_a_non_r_sma_
  strategy is deleted (its premise — a strategy gate — dissolves by
  design); the multi-value refusal now fires per --axis.

Verification: full workspace suite green in the loop's independent
verify; build + clippy clean. Held quality finding (kept as a
plan-hold): dispatch_generalize duplicates dispatch_sweep's
validation/strip block verbatim — dedup follows once wf/mc land the
same block (rule-of-three, end of this cycle).

refs #220, refs #214
This commit is contained in:
2026-07-09 13:13:59 +02:00
parent 51b8550237
commit a2294c7c3a
3 changed files with 404 additions and 250 deletions
+171 -45
View File
@@ -1306,6 +1306,33 @@ fn sweep_channel_flag_is_removed_from_the_grammar() {
let _ = std::fs::remove_dir_all(&cwd);
}
/// Property (#220 generalize vertical): `--strategy`/`--fast`/`--slow` are
/// genuinely removed from the generalize grammar, not merely unused — clap
/// rejects `--strategy` as a structurally unknown argument (exit 2) rather
/// than silently accepting and ignoring it now that the candidate is an
/// arbitrary blueprint positional + generic `--axis`. A regression that left
/// the old flags on `GeneralizeCmd` while deleting only their plumbing would
/// parse successfully here and this test would fail on the exit code.
#[test]
fn generalize_strategy_flag_is_removed_from_the_grammar() {
let cwd = temp_cwd("generalize-strategy-flag-retired");
let out = Command::new(BIN)
.args([
"generalize", "--strategy", "r-sma", "--real", "GER40,USDJPY",
"--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0",
])
.current_dir(&cwd)
.output()
.expect("spawn aura generalize --strategy");
assert_eq!(out.status.code(), Some(2), "--strategy must be an unrecognized clap argument: {:?}", out.status);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("--strategy") || stderr.to_lowercase().contains("unexpected argument"),
"clap must name the unknown flag: {stderr:?}"
);
let _ = std::fs::remove_dir_all(&cwd);
}
/// Property: `aura generalize` grades a single r-sma candidate across two
/// instruments — its stdout carries the per-instrument breakdown + the worst-case
/// floor + the sign-agreement, then the persisted CrossInstrument family handle.
@@ -1316,10 +1343,12 @@ fn generalize_grades_a_candidate_across_two_instruments() {
const FROM_MS: &str = "1725148800000";
const TO_MS: &str = "1727740799999";
let cwd = temp_cwd("generalize-two");
let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args([
"generalize", "--strategy", "r-sma", "--real", "GER40,USDJPY",
"--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0",
"generalize", &fixture, "--real", "GER40,USDJPY",
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
"--stop-length", "14", "--stop-k", "2.0",
"--from", FROM_MS, "--to", TO_MS,
])
.current_dir(&cwd)
@@ -1360,10 +1389,12 @@ fn generalize_real_e2e_pins_the_exact_current_grade() {
const FROM_MS: &str = "1725148800000";
const TO_MS: &str = "1727740799999";
let cwd = temp_cwd("generalize-exact-grade");
let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args([
"generalize", "--strategy", "r-sma", "--real", "GER40,USDJPY",
"--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0",
"generalize", &fixture, "--real", "GER40,USDJPY",
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
"--stop-length", "14", "--stop-k", "2.0",
"--from", FROM_MS, "--to", TO_MS,
])
.current_dir(&cwd)
@@ -1399,6 +1430,53 @@ fn generalize_real_e2e_pins_the_exact_current_grade() {
assert_eq!(per[1][1].as_f64(), Some(0.005795903617609842), "USDJPY expectancy R: {grade_line}");
}
/// Property (#220 generalize vertical): `aura generalize` grades ANY sweepable
/// blueprint, not just the historical r-sma candidate — the whole point of
/// dropping `--strategy r-sma`/`--fast`/`--slow` for a positional blueprint +
/// generic `--axis`. Every other generalize E2E test here still exercises
/// `r_sma_open.json`, so a regression that silently special-cased r-sma paths
/// back in (e.g. resolving axes against a hardcoded `sma_signal.*` namespace)
/// would ship green there while breaking every other blueprint. This runs the
/// unrelated r-breakout candidate (`channel_hi`/`channel_lo` axes, no
/// `sma_signal` node in sight) end-to-end and asserts a well-formed two-
/// instrument grade. Gated on the shared GER40/USDJPY Sept-2024 archive; skips
/// cleanly on a data refusal.
#[test]
fn generalize_grades_a_non_r_sma_blueprint() {
const FROM_MS: &str = "1725148800000";
const TO_MS: &str = "1727740799999";
let cwd = temp_cwd("generalize-non-r-sma");
let fixture = format!("{}/examples/r_breakout_open.json", env!("CARGO_MANIFEST_DIR"));
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args([
"generalize", &fixture, "--real", "GER40,USDJPY",
"--axis", "r_breakout_signal.channel_hi.length=20",
"--axis", "r_breakout_signal.channel_lo.length=20",
"--stop-length", "14", "--stop-k", "2.0",
"--from", FROM_MS, "--to", TO_MS,
])
.current_dir(&cwd)
.output()
.expect("spawn aura");
if out.status.code() == Some(1) {
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("no local data") || stderr.contains("no recorded geometry"),
"exit 1 must be a data refusal, got: {stderr}"
);
eprintln!("skip: no local GER40/USDJPY data");
return;
}
assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status);
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
assert!(stdout.contains("\"generalize\":"), "aggregate object: {stdout}");
assert!(stdout.contains("\"n_instruments\":2"), "two instruments: {stdout}");
assert!(stdout.contains("\"worst_case\":"), "worst_case present: {stdout}");
assert!(stdout.contains("\"sign_agreement\":"), "sign_agreement present: {stdout}");
assert!(stdout.contains("GER40") && stdout.contains("USDJPY"), "per-instrument breakdown: {stdout}");
assert!(stdout.contains("\"family_id\":"), "family handle: {stdout}");
}
/// Characterization pin (byte-identity anchor for the walkforward dissolution,
/// #210). The current `aura walkforward` runs its per-window IS-refit inline
/// (`walkforward_family` -> `select_winner` -> OOS run -> stitch/pool); the
@@ -1494,9 +1572,8 @@ fn walkforward_dissolved_refuses_select_plateau() {
assert!(stderr.contains("#215"), "forward pointer to the plateau follow-up: {stderr}");
}
/// `walkforward_args_from` requires all four grid knobs on the dissolved r-sma-real
/// path (unlike the inline path, which defaults an absent flag via `RGrid::default()`)
/// — a missing knob is a usage refusal (exit 2), not a silent default.
/// `walkforward_args_from` requires both stop knobs on the campaign path — a
/// missing knob is a usage refusal (exit 2), not a silent default.
#[test]
fn walkforward_dissolved_refuses_missing_knobs() {
let cwd = temp_cwd("walkforward-missing-knobs");
@@ -1665,10 +1742,12 @@ fn generalize_dissolves_through_the_campaign_path() {
const FROM_MS: &str = "1725148800000";
const TO_MS: &str = "1727740799999";
let cwd = temp_cwd("generalize-dissolves");
let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args([
"generalize", "--strategy", "r-sma", "--real", "GER40,USDJPY",
"--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0",
"generalize", &fixture, "--real", "GER40,USDJPY",
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
"--stop-length", "14", "--stop-k", "2.0",
"--from", FROM_MS, "--to", TO_MS,
])
.current_dir(&cwd)
@@ -1754,11 +1833,13 @@ fn generalize_repeated_identical_invocation_does_not_litter_the_store() {
const FROM_MS: &str = "1725148800000";
const TO_MS: &str = "1727740799999";
let cwd = temp_cwd("generalize-repeat-idempotent");
let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
let invoke = || {
std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args([
"generalize", "--strategy", "r-sma", "--real", "GER40,USDJPY",
"--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0",
"generalize", &fixture, "--real", "GER40,USDJPY",
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
"--stop-length", "14", "--stop-k", "2.0",
"--from", FROM_MS, "--to", TO_MS,
])
.current_dir(&cwd)
@@ -1807,11 +1888,14 @@ fn generalize_distinct_invocations_persist_distinct_campaign_documents() {
const FROM_MS: &str = "1725148800000";
const TO_MS: &str = "1727740799999";
let cwd = temp_cwd("generalize-distinct-content");
let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
let invoke = |fast: &str| {
let fast_axis = format!("sma_signal.fast.length={fast}");
std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args([
"generalize", "--strategy", "r-sma", "--real", "GER40,USDJPY",
"--fast", fast, "--slow", "12", "--stop-length", "14", "--stop-k", "2.0",
"generalize", &fixture, "--real", "GER40,USDJPY",
"--axis", &fast_axis, "--axis", "sma_signal.slow.length=12",
"--stop-length", "14", "--stop-k", "2.0",
"--from", FROM_MS, "--to", TO_MS,
])
.current_dir(&cwd)
@@ -1853,10 +1937,12 @@ fn generalize_distinct_invocations_persist_distinct_campaign_documents() {
#[test]
fn generalize_without_explicit_window_falls_back_to_the_first_symbols_full_window() {
let cwd = temp_cwd("generalize-default-window");
let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args([
"generalize", "--strategy", "r-sma", "--real", "GER40,USDJPY",
"--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0",
"generalize", &fixture, "--real", "GER40,USDJPY",
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
"--stop-length", "14", "--stop-k", "2.0",
])
.current_dir(&cwd)
.output()
@@ -1883,10 +1969,12 @@ fn generalize_without_explicit_window_falls_back_to_the_first_symbols_full_windo
/// before any data access — so it asserts the arity refusal on any machine.
#[test]
fn generalize_refuses_a_single_instrument() {
let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args([
"generalize", "--strategy", "r-sma", "--real", "GER40",
"--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0",
"generalize", &fixture, "--real", "GER40",
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
"--stop-length", "14", "--stop-k", "2.0",
])
.output()
.expect("spawn aura");
@@ -1897,10 +1985,12 @@ fn generalize_refuses_a_single_instrument() {
/// `check_r_metric` pre-check), so this asserts the R-only refusal on any machine.
#[test]
fn generalize_refuses_a_non_r_metric() {
let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args([
"generalize", "--strategy", "r-sma", "--real", "GER40,USDJPY",
"--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0",
"generalize", &fixture, "--real", "GER40,USDJPY",
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
"--stop-length", "14", "--stop-k", "2.0",
"--metric", "total_pips",
])
.output()
@@ -1920,10 +2010,12 @@ fn generalize_refuses_a_non_r_metric() {
/// check precedes any geometry/archive lookup, so it asserts on any machine.
#[test]
fn generalize_refuses_a_duplicate_instrument() {
let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args([
"generalize", "--strategy", "r-sma", "--real", "GER40,GER40",
"--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0",
"generalize", &fixture, "--real", "GER40,GER40",
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
"--stop-length", "14", "--stop-k", "2.0",
])
.output()
.expect("spawn aura");
@@ -1937,22 +2029,24 @@ fn generalize_refuses_a_duplicate_instrument() {
assert!(stderr.contains("generalize"), "must surface the generalize usage, got: {stderr}");
}
/// Property: a non-`r-sma` strategy is refused at the binary boundary (exit 2,
/// no run, data-free). The candidate must produce R (C10) — the cross-instrument
/// reduction is R-only — so only the r-sma grid is an admissible candidate. The
/// parser unit test pins the grammar refusal; this pins that `--strategy sma`
/// reaches `exit(2)` through the dispatch arm rather than silently defaulting to a
/// running candidate. Asserts on any machine (the strategy check precedes any run).
/// Property: a multi-value axis is refused at the binary boundary (exit 2, no
/// run, data-free). A *candidate* is a single grid cell, not a sweep — so any
/// `--axis` carrying more than one value is refused, never silently widened
/// into a multi-cell run whose generalization grade would be ill-defined.
#[test]
fn generalize_refuses_a_non_r_sma_strategy() {
fn generalize_refuses_a_multi_value_axis() {
let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args([
"generalize", "--strategy", "sma", "--real", "GER40,USDJPY",
"--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0",
"generalize", &fixture, "--real", "GER40,USDJPY",
"--axis", "sma_signal.fast.length=2,3", "--axis", "sma_signal.slow.length=12",
"--stop-length", "14", "--stop-k", "2.0",
])
.output()
.expect("spawn aura");
assert_eq!(out.status.code(), Some(2), "a non-r-sma strategy must exit 2");
assert_eq!(out.status.code(), Some(2), "a multi-value axis must exit 2");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("each --axis takes exactly one value"), "arity refusal: {stderr}");
assert!(
out.stdout.is_empty(),
"the refusal must not leak an aggregate to stdout, got: {:?}",
@@ -1960,22 +2054,50 @@ fn generalize_refuses_a_non_r_sma_strategy() {
);
}
/// Property: a multi-value candidate flag is refused at the binary boundary (exit 2,
/// no run, data-free). A *candidate* is a single grid cell, not a sweep — so any of
/// `--fast/--slow/--stop-length/--stop-k` carrying more than one value (`--fast 2,3`)
/// is refused, never silently widened into a multi-cell run whose generalization
/// grade would be ill-defined (the reduction grades one candidate, not a family).
/// The parser unit test pins the grammar; this pins the wiring to `exit(2)`.
/// Property: a `--axis` with no `--axis` flags at all is a usage error (exit 2,
/// no run) — generalize needs >= 1 axis to name a candidate, mirroring the sibling
/// sweep/walkforward "no axis" refusals. Data-free (fires before any archive access).
#[test]
fn generalize_refuses_a_multi_value_candidate_flag() {
fn generalize_refuses_no_axis() {
let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args([
"generalize", "--strategy", "r-sma", "--real", "GER40,USDJPY",
"--fast", "2,3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0",
"generalize", &fixture, "--real", "GER40,USDJPY",
"--stop-length", "14", "--stop-k", "2.0",
])
.output()
.expect("spawn aura");
assert_eq!(out.status.code(), Some(2), "a multi-value candidate flag must exit 2");
assert_eq!(out.status.code(), Some(2), "zero axes must exit 2");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("Usage: aura generalize"), "must print the generalize usage: {stderr}");
assert!(
out.stdout.is_empty(),
"the refusal must not leak an aggregate to stdout, got: {:?}",
String::from_utf8_lossy(&out.stdout)
);
}
/// Property: an `--axis` name that is not among the loaded blueprint's sweepable
/// axes is refused before the raw-namespace strip or any archive access (exit 2),
/// echoing exactly the name the user typed — the same WRAPPED-probe preflight
/// `aura sweep` enforces (`aura_sweep_real_blueprint_refuses_a_raw_form_axis_name_before_data_access`).
#[test]
fn generalize_refuses_an_unknown_axis_name() {
let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args([
"generalize", &fixture, "--real", "GER40,USDJPY",
"--axis", "sma_signal.nope=3", "--axis", "sma_signal.slow.length=12",
"--stop-length", "14", "--stop-k", "2.0",
])
.output()
.expect("spawn aura");
assert_eq!(out.status.code(), Some(2), "an unknown axis name must exit 2");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("axis \"sma_signal.nope\"") && stderr.contains("sweepable axes"),
"must name the unresolved axis exactly as typed: {stderr}"
);
assert!(
out.stdout.is_empty(),
"the refusal must not leak an aggregate to stdout, got: {:?}",
@@ -1998,10 +2120,12 @@ fn generalize_persists_a_discoverable_cross_instrument_family() {
const FROM_MS: &str = "1725148800000";
const TO_MS: &str = "1727740799999";
let cwd = temp_cwd("generalize-family");
let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args([
"generalize", "--strategy", "r-sma", "--real", "GER40,USDJPY",
"--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0",
"generalize", &fixture, "--real", "GER40,USDJPY",
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
"--stop-length", "14", "--stop-k", "2.0",
"--from", FROM_MS, "--to", TO_MS,
])
.current_dir(&cwd)
@@ -2061,10 +2185,12 @@ fn generalize_family_members_are_attributed_to_the_right_instrument() {
const FROM_MS: &str = "1725148800000";
const TO_MS: &str = "1727740799999";
let cwd = temp_cwd("generalize-member-attribution");
let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args([
"generalize", "--strategy", "r-sma", "--real", "GER40,USDJPY",
"--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0",
"generalize", &fixture, "--real", "GER40,USDJPY",
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
"--stop-length", "14", "--stop-k", "2.0",
"--from", FROM_MS, "--to", TO_MS,
])
.current_dir(&cwd)