feat(cli): dissolve aura generalize into the campaign path (#210 T3)

`dispatch_generalize` synthesizes the bare `sma_signal` blueprint, resolves the
window (explicit --from/--to, else the first symbol's full window), and calls
the new `run_generalize_sugar`; the inline `run_generalize` is deleted. The
sugar builds the selection-bearing campaign doc, runs it through
`run_campaign_returning`, reprints the byte-identical `{"generalize":…}` +
`{"family_id":…}` lines, and persists the CrossInstrument family.

The exact-grade anchor stays green through the path shift — proving the two stop
mechanisms (former grid axis, now the risk-regime seam -> StopRule::Vol) yield
identical R. The family pin relaxes to "exactly one CrossInstrument family"
since the campaign executor now also persists a per-instrument Sweep family per
cell (the durable audit trail; #210 Q4). Two new e2e pin the window fallback and
the family member attribution order.

Deviations from the plan (necessary, verified): cross_instrument_members returns
Vec<aura_engine::RunReport> (aura_registry::RunReport is not pub); a shared
validate_before_register helper is factored out of run_sweep_sugar (DRY); the
run_generalize doc-comment and the now-unused `generalization` import removed.

refs #210
This commit is contained in:
2026-07-06 17:42:36 +02:00
parent ed8b179e34
commit 256ec7320c
3 changed files with 304 additions and 95 deletions
+124 -4
View File
@@ -3637,6 +3637,45 @@ 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 (#210 T3, dissolution): omitting `--from`/`--to` still completes —
/// the dispatch rewrite's window-resolution fallback (`dispatch_generalize` in
/// main.rs) resolves ONE shared campaign window from the FIRST listed symbol's
/// full archive geometry and applies it to every instrument in the campaign
/// document (a `CampaignDoc` carries a single shared window, unlike the old
/// inline `run_generalize`, which let each instrument resolve its OWN
/// independent full window). A wrong index or an empty-`symbols` panic in that
/// new fallback would only surface when `--from`/`--to` are absent — every
/// other generalize e2e pins the explicit-window path, leaving this one
/// unexercised. Gated on local GER40/USDJPY data.
#[test]
fn generalize_without_explicit_window_falls_back_to_the_first_symbols_full_window() {
let cwd = temp_cwd("generalize-default-window");
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",
])
.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");
let _ = std::fs::remove_dir_all(&cwd);
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 graded: {stdout}");
assert!(stdout.contains("\"family_id\":\"generalize-0\""), "family handle: {stdout}");
let _ = std::fs::remove_dir_all(&cwd);
}
/// Property: a single-instrument generalize is refused at parse time (exit 2),
/// before any data access — so it asserts the arity refusal on any machine.
#[test]
@@ -3785,10 +3824,91 @@ fn generalize_persists_a_discoverable_cross_instrument_family() {
.expect("spawn families");
assert!(fams.status.success(), "families exit: {:?}", fams.status);
let fams_out = String::from_utf8(fams.stdout).expect("utf-8");
assert_eq!(fams_out.lines().count(), 1, "families: {fams_out:?}");
assert!(fams_out.contains("\"family_id\":\"generalize-0\""), "families: {fams_out:?}");
assert!(fams_out.contains("\"kind\":\"CrossInstrument\""), "families: {fams_out:?}");
assert!(fams_out.contains("\"members\":2"), "one member per instrument: {fams_out:?}");
// The dissolved path routes through the campaign executor, which persists a
// per-instrument Sweep family per cell (each instrument's candidate run, now
// a durable audit artifact) alongside the one CrossInstrument grade family
// the sugar appends. Pin exactly one CrossInstrument family (the generalize
// result) — not the total family count, which now includes those per-cell
// Sweep families.
let cross: Vec<&str> = fams_out
.lines()
.filter(|l| l.contains("\"kind\":\"CrossInstrument\""))
.collect();
assert_eq!(cross.len(), 1, "exactly one CrossInstrument family: {fams_out:?}");
assert!(cross[0].contains("\"family_id\":\"generalize-0\""), "families: {fams_out:?}");
assert!(cross[0].contains("\"members\":2"), "one member per instrument: {fams_out:?}");
let _ = std::fs::remove_dir_all(&cwd);
}
/// Property (#210 T3, dissolution): the persisted `CrossInstrument` family's
/// members are attributed to the RIGHT instrument in the RIGHT cell order —
/// not merely correct in count. The dissolved path builds the family via
/// `cross_instrument_members`, which walks `outcome.cells` and trusts their
/// order/labelling, replacing the old `run_generalize`'s explicit
/// `zip(symbols, members)`. A cell-ordering or index bug in the new
/// extraction would still print the right aggregate (the campaign's own
/// `Generalize` stage computes that independently) while silently persisting
/// swapped or mislabelled members — a regression this test, not the exact-grade
/// anchor, would catch. Asserts `aura runs family generalize-0` lists GER40
/// first with the exact-grade anchor's GER40 `expectancy_r`, then USDJPY with
/// its own. Gated on the local GER40/USDJPY data (the shared Sept-2024 window).
#[test]
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 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",
"--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");
let _ = std::fs::remove_dir_all(&cwd);
return;
}
assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status);
let fam = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["runs", "family", "generalize-0"])
.current_dir(&cwd)
.output()
.expect("spawn runs family");
assert!(fam.status.success(), "runs family exit: {:?}", fam.status);
let fam_out = String::from_utf8(fam.stdout).expect("utf-8");
let members: Vec<serde_json::Value> = fam_out
.lines()
.filter(|l| l.starts_with('{'))
.map(|l| serde_json::from_str(l).expect("member line parses as JSON"))
.collect();
assert_eq!(members.len(), 2, "one member per instrument: {fam_out:?}");
assert_eq!(
members[0]["manifest"]["instrument"].as_str(), Some("GER40"),
"first member is GER40: {fam_out:?}"
);
assert_eq!(
members[0]["metrics"]["r"]["expectancy_r"].as_f64(), Some(0.01056371324510624),
"GER40's own expectancy_r, matching the exact-grade anchor: {fam_out:?}"
);
assert_eq!(
members[1]["manifest"]["instrument"].as_str(), Some("USDJPY"),
"second member is USDJPY: {fam_out:?}"
);
assert_eq!(
members[1]["metrics"]["r"]["expectancy_r"].as_f64(), Some(0.005795903617609842),
"USDJPY's own expectancy_r, matching the exact-grade anchor: {fam_out:?}"
);
let _ = std::fs::remove_dir_all(&cwd);
}