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:
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user