diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 9b8264c..ef2004b 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -2959,8 +2959,10 @@ fn exit_axis_register_error(e: AxisRegisterError) -> ! { /// seam-crossing (never reimplement the division inline) so the executor's /// `unix_ms_to_epoch_ns` re-normalizes exactly once downstream, not twice. /// Shared by the campaign dispatchers — sweep/walkforward/mc always clip; -/// generalize reaches here only through its no-explicit-window fallback (an -/// explicit `--from`+`--to` pair passes through unclipped there BY DESIGN). +/// generalize reaches here once per listed symbol, only through its +/// no-explicit-window fallback, and intersects the per-symbol results into the +/// one shared window (#213); an explicit `--from`+`--to` pair passes through +/// unclipped there BY DESIGN. fn campaign_window_ms(choice: DataChoice, env: &project::Env) -> (i64, i64) { let source = DataSource::from_choice(choice, env); let (from_ts, to_ts) = source.full_window(env); @@ -2970,6 +2972,30 @@ fn campaign_window_ms(choice: DataChoice, env: &project::Env) -> (i64, i64) { ) } +type SymbolSpans<'a> = Vec<(&'a str, (i64, i64))>; + +/// The pure decision `generalize`'s no-explicit-window fallback reduces to once +/// every listed symbol's full window is resolved (#213): the shared window is +/// the intersection (latest start, earliest end); `Err` carries each symbol +/// paired with its own resolved window when the intersection is empty (the +/// archives never overlap), for the caller's eprintln-then-exit(1) refusal. +/// Kept separate from `dispatch_generalize` so the intersect-or-refuse +/// arithmetic is unit-testable on synthetic windows — real archives on this +/// host never disjoint (every symbol's tail reaches the live present), so the +/// refusal branch has no reachable e2e fixture. +fn intersect_shared_window<'a>( + symbols: &'a [String], + windows: &[(i64, i64)], +) -> Result<(i64, i64), SymbolSpans<'a>> { + let shared_from = windows.iter().map(|w| w.0).max().expect("caller passes at least one window"); + let shared_to = windows.iter().map(|w| w.1).min().expect("caller passes at least one window"); + if shared_from > shared_to { + Err(symbols.iter().map(String::as_str).zip(windows.iter().copied()).collect()) + } else { + Ok((shared_from, shared_to)) + } +} + /// `aura run`: the loaded-blueprint branch (an existing `.json` first-positional) or /// the built-in harness-kind dispatch. fn dispatch_run(a: RunCmd, env: &project::Env) { @@ -3124,13 +3150,35 @@ fn dispatch_generalize(a: GeneralizeCmd, env: &project::Env) { .unwrap_or_else(|e| exit_axis_register_error(e)); // Window: the explicit --from/--to when both present (byte-identical to the // retired welded path, verified by the exact-grade anchor); otherwise the - // first symbol's full archive window as the single shared campaign window. + // INTERSECTION of every listed symbol's full archive window — the only span + // over which every instrument actually has data (#213). A single listed + // symbol degenerates to its own full window unchanged. let (from_ms, to_ms) = match (from, to) { (Some(f), Some(t)) => (f, t), - _ => campaign_window_ms( - DataChoice::Real { symbol: symbols[0].clone(), from_ms: from, to_ms: to }, - env, - ), + _ => { + let windows: Vec<(i64, i64)> = symbols + .iter() + .map(|s| { + campaign_window_ms( + DataChoice::Real { symbol: s.clone(), from_ms: from, to_ms: to }, + env, + ) + }) + .collect(); + match intersect_shared_window(&symbols, &windows) { + Ok(w) => w, + Err(spans) => { + for (s, w) in spans { + eprintln!("aura: generalize: {s} spans [{}, {}]", w.0, w.1); + } + eprintln!( + "aura: generalize: no window is shared across all listed symbols \ + (their archives do not overlap)" + ); + std::process::exit(1); + } + } + } }; let inv = verb_sugar::SugarInvocation { axes: &raw_axes, @@ -3601,6 +3649,41 @@ fn main() { mod tests { use super::*; + /// A single listed symbol degenerates to its own full window unchanged + /// (#213) — the intersection of one window with itself is that window. + #[test] + fn intersect_shared_window_of_a_single_symbol_is_its_own_window() { + let symbols = vec!["GER40".to_string()]; + let windows = vec![(100, 200)]; + assert_eq!(intersect_shared_window(&symbols, &windows), Ok((100, 200))); + } + + /// Overlapping multi-symbol windows resolve to the intersection — the + /// latest start, earliest end (#213) — never `symbols[0]`'s own window. + #[test] + fn intersect_shared_window_of_overlapping_windows_is_latest_start_earliest_end() { + let symbols = vec!["AAPL.US".to_string(), "GER40".to_string()]; + let windows = vec![(0, 300), (100, 200)]; + assert_eq!(intersect_shared_window(&symbols, &windows), Ok((100, 200))); + } + + /// Disjoint archives (no shared instant across all listed symbols) refuse + /// rather than silently pooling a floor over different periods per + /// instrument (#213); the error names each symbol next to its own span, + /// which the caller eprintln's before exiting 1 — the boundary this + /// covers is otherwise unreachable in an e2e fixture on this host, since + /// every archived symbol's tail reaches the live present (no two windows + /// are ever genuinely disjoint here). + #[test] + fn intersect_shared_window_of_disjoint_windows_refuses_with_every_span() { + let symbols = vec!["A".to_string(), "B".to_string()]; + let windows = vec![(0, 100), (200, 300)]; + assert_eq!( + intersect_shared_window(&symbols, &windows), + Err(vec![("A", (0, 100)), ("B", (200, 300))]), + ); + } + /// Regenerates the shipped r_breakout examples from the carved signal. Run by hand: /// `cargo test --bin aura emit_r_breakout_examples -- --ignored`. The examples are /// green-by-construction (serialised from the builder, never hand-authored). diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index 27d1056..98e2119 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -3000,18 +3000,19 @@ fn generalize_distinct_invocations_persist_distinct_campaign_documents() { assert_eq!(count("campaigns"), 2, "two distinct candidates persist two distinct campaign documents"); } -/// 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 +/// Property (#210 T3, dissolution; window semantics superseded by #213): +/// omitting `--from`/`--to` still completes — the dispatch rewrite's +/// window-resolution fallback (`dispatch_generalize` in main.rs) resolves ONE +/// shared campaign window from the INTERSECTION of the listed symbols' 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. +/// independent full window). A wrong intersection (or a panic on the +/// per-symbol probe) in that 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() { +fn generalize_without_explicit_window_resolves_a_shared_window_and_completes() { let (cwd, _g) = fresh_project(); let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))