feat(cli,ingest): aura data coverage — month-gap inventory for a symbol

aura data coverage <SYMBOL> 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)
This commit is contained in:
2026-07-13 21:49:24 +02:00
parent 28984ba73c
commit 529a8af0d6
2 changed files with 130 additions and 2 deletions
+125
View File
@@ -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<String>,
}
#[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 <SYMBOL>` (#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<Vec<String>, 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,
+5 -2
View File
@@ -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 {