From 529a8af0d60b5d2a058b8b84a6d3783cea64f5bb Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 13 Jul 2026 21:49:24 +0200 Subject: [PATCH] =?UTF-8?q?feat(cli,ingest):=20aura=20data=20coverage=20?= =?UTF-8?q?=E2=80=94=20month-gap=20inventory=20for=20a=20symbol?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit aura data coverage prints the archive's monthly file-index coverage: a span line (first..last present month) and either an explicit 'no gaps' line or one 'missing: YYYY-MM..YYYY-MM' line per interior contiguous hole. The Copper failure mode — a campaign aborting mid-run because a first/last-bounds check passed while the window start had no data — is now visible before any run (the real archive reports 'Copper missing: 2017-04..2019-09'). An unknown symbol (no archive files) refuses on stderr with exit 1; informational absence stays prose + exit 0 per the verb corpus convention. Mechanics: list_m1_months goes pub in aura-ingest (archive_extent deliberately walks past gaps and cannot report them); a new two-level 'aura data' clap namespace follows the Nodes precedent; the archive root resolves through the normal project path (paths.data override, data-server default otherwise). Pure report fn with unit tests for the refusal, the gapless span, and the collapsed-range shape; the headline e2e runs hostless over a new gapped synthetic-archive fixture. refs #264 (cut 2, aura data list, follows separately) --- crates/aura-cli/src/main.rs | 125 ++++++++++++++++++++++++++++++++++ crates/aura-ingest/src/lib.rs | 7 +- 2 files changed, 130 insertions(+), 2 deletions(-) diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 6865c80..a9c50b6 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -2684,6 +2684,8 @@ enum Command { Process(research_docs::ProcessCmd), /// Validate, introspect, and register campaign documents (experiment intent). Campaign(research_docs::CampaignCmd), + /// Inspect the project's data archive. + Data(DataCmd), } #[derive(Args)] @@ -2728,6 +2730,24 @@ struct NodesNewCmd { namespace: Option, } +#[derive(Args)] +struct DataCmd { + #[command(subcommand)] + command: DataCommand, +} + +#[derive(Subcommand)] +enum DataCommand { + /// Report a symbol's monthly file-index coverage (span + interior gaps). + Coverage(DataCoverageCmd), +} + +#[derive(Args)] +struct DataCoverageCmd { + /// The symbol to inventory (e.g. `GER40`). + symbol: String, +} + #[derive(Args)] #[command(args_conflicts_with_subcommands = true)] struct GraphCmd { @@ -3651,6 +3671,77 @@ fn dispatch_nodes_new(a: NodesNewCmd) { ); } +fn dispatch_data(cmd: DataCmd, env: &project::Env) { + match cmd.command { + DataCommand::Coverage(a) => dispatch_data_coverage(a, env), + } +} + +/// `aura data coverage ` (#264 cut 1): print the archive's month +/// coverage for `SYMBOL` — the Copper failure mode (files present at both +/// ends of a window while an interior month is missing, so a first/last-bounds +/// check passes but a campaign run aborts mid-window) made visible up front. +/// Resolves the archive root through the normal project path (`Env::data_path` +/// — a project-local `[paths] data` override when set, the data-server default +/// otherwise), so a project-scoped archive is inventoried, not the host one. +fn dispatch_data_coverage(a: DataCoverageCmd, env: &project::Env) { + let data_path = std::path::PathBuf::from(env.data_path()); + let months = aura_ingest::list_m1_months(&data_path, &a.symbol); + match data_coverage_report(&a.symbol, &months) { + Ok(lines) => { + for line in lines { + println!("{line}"); + } + } + Err(e) => { + eprintln!("aura: {e} at {}", env.data_path()); + std::process::exit(1); + } + } +} + +/// Pure: render `symbol`'s coverage report from its sorted `(year, month)` file +/// list — one `span:` line framing the first/last present month, then either a +/// `no gaps` line or one `missing: YYYY-MM..YYYY-MM` line per interior +/// contiguous gap (a ten-month hole is one line, not ten). `Err` exactly when +/// `months` is empty — no archive file exists for `symbol` at all (the +/// caller's "unknown symbol" refusal, stderr + exit 1). +fn data_coverage_report(symbol: &str, months: &[(u16, u8)]) -> Result, String> { + let Some(&first) = months.first() else { + return Err(format!("no archive files found for symbol \"{symbol}\"")); + }; + let last = *months.last().expect("non-empty checked above"); + let mut lines = vec![format!("{symbol} span: {}..{}", fmt_year_month(first), fmt_year_month(last))]; + let gaps: Vec<((u16, u8), (u16, u8))> = months + .windows(2) + .filter_map(|pair| { + let (prev, next) = (pair[0], pair[1]); + let expected = next_year_month(prev); + (expected != next).then(|| (expected, prev_year_month(next))) + }) + .collect(); + if gaps.is_empty() { + lines.push(format!("{symbol} no gaps")); + } else { + for (from, to) in gaps { + lines.push(format!("{symbol} missing: {}..{}", fmt_year_month(from), fmt_year_month(to))); + } + } + Ok(lines) +} + +fn fmt_year_month((y, m): (u16, u8)) -> String { + format!("{y:04}-{m:02}") +} + +fn next_year_month((y, m): (u16, u8)) -> (u16, u8) { + if m == 12 { (y + 1, 1) } else { (y, m + 1) } +} + +fn prev_year_month((y, m): (u16, u8)) -> (u16, u8) { + if m == 1 { (y - 1, 12) } else { (y, m - 1) } +} + /// `aura sweep`: loaded-blueprint by-name axis sweep (or `--list-axes` probe) when the /// first-positional is an existing `.json`, else the built-in `--strategy` grid sweep. fn dispatch_sweep(a: SweepCmd, env: &project::Env) { @@ -4053,6 +4144,7 @@ fn main() { Command::Nodes(a) => dispatch_nodes(a), Command::Process(a) => research_docs::process_cmd(a, &env), Command::Campaign(a) => research_docs::campaign_cmd(a, &env), + Command::Data(a) => dispatch_data(a, &env), } } @@ -4078,6 +4170,39 @@ mod tests { assert_eq!(intersect_shared_window(&symbols, &windows), Ok((100, 200))); } + /// An unknown symbol (no archive files at all — an empty month list) + /// refuses rather than reporting a bogus empty-span coverage (#264): the + /// caller eprintln's the message and exits 1. + #[test] + fn data_coverage_report_refuses_an_unknown_symbol() { + let err = data_coverage_report("GHOST", &[]).unwrap_err(); + assert!(err.contains("GHOST"), "names the unknown symbol: {err}"); + } + + /// A symbol with a fully contiguous file index reports its span plus an + /// explicit `no gaps` line — never a bare span with no gap-status line at + /// all, which would leave "no gaps" indistinguishable from "gaps not yet + /// checked" (#264). + #[test] + fn data_coverage_report_of_a_gapless_symbol_is_span_plus_no_gaps() { + let months = [(2024, 1), (2024, 2), (2024, 3)]; + let lines = data_coverage_report("SYMA", &months).expect("known symbol"); + assert_eq!(lines, vec!["SYMA span: 2024-01..2024-03", "SYMA no gaps"]); + } + + /// The Copper failure shape itself: one interior gap collapses to a single + /// `missing: YYYY-MM..YYYY-MM` line naming the whole contiguous hole, not + /// one line per missing month (#264). + #[test] + fn data_coverage_report_collapses_an_interior_gap_to_one_range_line() { + let months = [(2024, 1), (2024, 2), (2024, 5), (2024, 6)]; + let lines = data_coverage_report("GAPSYM", &months).expect("known symbol"); + assert_eq!( + lines, + vec!["GAPSYM span: 2024-01..2024-06", "GAPSYM missing: 2024-03..2024-04"] + ); + } + /// 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, diff --git a/crates/aura-ingest/src/lib.rs b/crates/aura-ingest/src/lib.rs index ba766aa..5193180 100644 --- a/crates/aura-ingest/src/lib.rs +++ b/crates/aura-ingest/src/lib.rs @@ -453,8 +453,11 @@ fn month_end_ms_exclusive(year: u16, month: u8) -> i64 { /// needs no general symbol-boundary regex and reads no private index (#252: /// no new data-server surface). Empty when `data_path` doesn't exist or holds /// no matching file for `symbol` — the same "no archive for this symbol" -/// condition an absent `DataServer` symbol represents. -fn list_m1_months(data_path: &Path, symbol: &str) -> Vec<(u16, u8)> { +/// condition an absent `DataServer` symbol represents. `pub` (#264): unlike +/// [`archive_extent`], which deliberately walks *past* an interior gap to find +/// the window's first/last bar, a coverage report needs the raw month list +/// itself to detect and render that gap. +pub fn list_m1_months(data_path: &Path, symbol: &str) -> Vec<(u16, u8)> { let prefix = format!("{symbol}_"); let mut months = Vec::new(); let Ok(entries) = std::fs::read_dir(data_path) else {