test(cli): RED — no-window generalize resolves symbols[0], not the intersection

The new archive-gated e2e pins the #213 contract: a multi-symbol
generalize without --from/--to must resolve the shared campaign window
as the intersection (latest start, earliest end) of the listed symbols'
full archive windows. On the current tree it resolves symbols[0]'s full
window (AAPL.US listed first keeps AAPL's wider span on both bounds), so
the test fails RED. Expected windows are derived from the live archive
via single-symbol sweep probes, never hard-coded.

refs #213
This commit is contained in:
2026-07-11 13:14:03 +02:00
parent aaca18c6f6
commit bb8c01895b
+108
View File
@@ -3039,6 +3039,114 @@ fn generalize_without_explicit_window_falls_back_to_the_first_symbols_full_windo
assert!(stdout.contains("\"family_id\":\"generalize-0\""), "family handle: {stdout}"); assert!(stdout.contains("\"family_id\":\"generalize-0\""), "family handle: {stdout}");
} }
/// Property (#213, no-window semantics): invoked WITHOUT `--from`/`--to`, a
/// multi-symbol `generalize` resolves the ONE shared campaign window as the
/// INTERSECTION of the listed symbols' full archive windows — the latest start
/// and earliest end, i.e. the only span over which EVERY listed instrument
/// actually has data. `generalize` is the cross-instrument comparison (the
/// worst-case R floor, #146); a floor pooled over a different period per
/// instrument conflates the instrument axis with the period axis, so the common
/// window is the only one that isolates what the verb measures. Today's fallback
/// picks `symbols[0]`'s full window alone (the superseded shape test above):
/// listing the WIDER symbol first (`AAPL.US`, archive from 2006) keeps AAPL's
/// early start, whereas the intersection with the later-starting `GER40` (archive
/// from 2014) must start at GER40's first bar and end at whichever archive ends
/// first — so on the current tree the resolved window differs from the
/// intersection on both bounds. Each symbol's full window is resolved
/// independently here: a single-symbol `sweep` with no window records it into its
/// generated campaign document, in the same Unix-ms currency and via the same
/// `campaign_window_ms(full_window)` probe generalize itself resolves against —
/// so the expected intersection is derived from the live archive, never a
/// hard-coded span that shifts as the archive grows. Archive-gated; skips
/// cleanly on a data-less host.
#[test]
fn generalize_without_explicit_window_resolves_the_intersection_of_all_symbols() {
if !local_data_present() {
eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH);
return;
}
let (cwd, _g) = fresh_project();
let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
// The resolved shared window persists verbatim in the generated campaign
// document; read the (from_ms, to_ms) of the one whose `name` field matches.
let window_of = |name: &str| -> (i64, i64) {
let dir = cwd.join("runs").join("campaigns");
for entry in std::fs::read_dir(&dir).expect("campaigns dir exists after a run") {
let path = entry.expect("readable dir entry").path();
let doc: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).expect("read campaign doc"))
.expect("campaign doc parses as JSON");
if doc["name"].as_str() == Some(name) {
let w = &doc["data"]["windows"][0];
return (
w["from_ms"].as_i64().expect("from_ms is an integer"),
w["to_ms"].as_i64().expect("to_ms is an integer"),
);
}
}
panic!("no campaign document named {name:?} in {}", dir.display());
};
// Resolve one symbol's full archive window by running a single-symbol,
// single-cell sweep with NO --from/--to; the window it records is exactly the
// `campaign_window_ms(full_window)` value generalize resolves per symbol.
let probe = |symbol: &str, name: &str| -> (i64, i64) {
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args([
"sweep", &fixture, "--real", symbol,
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
"--name", name,
])
.current_dir(cwd)
.output()
.expect("spawn aura sweep");
assert_eq!(
out.status.code(), Some(0),
"single-symbol window-probe sweep for {symbol} must run to exit 0: {}",
String::from_utf8_lossy(&out.stderr)
);
window_of(name)
};
let aapl = probe("AAPL.US", "probe-aapl");
let ger40 = probe("GER40", "probe-ger40");
// Precondition: the archives genuinely differ, so "the intersection" is a
// distinct claim from "symbols[0]'s full window" (else the test is vacuous).
assert_ne!(
aapl, ger40,
"AAPL.US {aapl:?} and GER40 {ger40:?} must span different windows for the test to bite"
);
// The contract: the shared window is the intersection — latest start, earliest end.
let expected = (aapl.0.max(ger40.0), aapl.1.min(ger40.1));
// AAPL.US listed FIRST: today's `symbols[0]` fallback keeps AAPL's (wider)
// window, which differs from the intersection on both bounds — so a pass here
// can only mean the intersection semantics landed.
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args([
"generalize", &fixture, "--real", "AAPL.US,GER40",
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
"--name", "gen-both",
])
.current_dir(cwd)
.output()
.expect("spawn aura generalize");
assert_eq!(
out.status.code(), Some(0),
"two-symbol no-window generalize must run to exit 0: {}",
String::from_utf8_lossy(&out.stderr)
);
let resolved = window_of("gen-both");
assert_eq!(
resolved, expected,
"no-window generalize must resolve the INTERSECTION {expected:?} of AAPL.US {aapl:?} \
and GER40 {ger40:?} — not symbols[0]'s ({aapl:?}) full window (resolved {resolved:?})"
);
}
/// Property: a single-instrument generalize is refused at parse time (exit 2), /// 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. /// before any data access — so it asserts the arity refusal on any machine.
#[test] #[test]