feat(aura-cli, aura-runner): JSON data namespace — info verb, NDJSON list, coverage retired

Reshape the aura data namespace around one principle: every verb emits
JSON, no format flag (the headless-agent posture #264 named).

- aura data info <symbol> (new): one flat JSON object — symbol plus the
  six neutral geometry fields (digits, pipSize, tickSize, lotSize,
  baseAsset, quoteAsset) from DataServer::symbol_meta when the
  <SYMBOL>.meta.json sidecar yields geometry; a symbol is known via bar
  files OR sidecar (a sidecar-only symbol still reports its geometry);
  an unknown symbol refuses with prose, exit 1. description stays out
  until the neutral reader exposes it (data-server#4).
- aura data list: NDJSON — one JSON string per line; an empty archive
  emits zero stdout lines, with the human affordance moved to a stderr
  note in the #278 class vocabulary.
- aura data coverage: retired — #272's per-cell fault isolation subsumed
  the prophylactic pre-run check; aura-runner's data_coverage_report and
  its presentation helpers go with it, while interior_gap_months stays
  as the campaign path's single gap walk (#295).

The three derived design decisions (description omitted, broader
refusal prose, empty-archive stderr notice) are minuted on the issue.

closes #273
This commit is contained in:
2026-07-25 12:25:45 +02:00
parent 32eb5a6a9e
commit 9cfe2965c0
3 changed files with 322 additions and 218 deletions
+145 -54
View File
@@ -1258,15 +1258,15 @@ struct DataCmd {
#[derive(Subcommand)]
enum DataCommand {
/// Report a symbol's monthly file-index coverage (span + interior gaps).
Coverage(DataCoverageCmd),
/// List the archive's known symbols, sorted, one per line.
/// Print a symbol's recorded geometry as one flat JSON object.
Info(DataInfoCmd),
/// List the archive's known symbols as NDJSON, sorted, one per line.
List,
}
#[derive(Args)]
struct DataCoverageCmd {
/// The symbol to inventory (e.g. `GER40`).
struct DataInfoCmd {
/// The symbol to inspect (e.g. `GER40`).
symbol: String,
}
@@ -2280,7 +2280,7 @@ fn dispatch_nodes_new(a: NodesNewCmd) {
fn dispatch_data(cmd: DataCmd, env: &aura_runner::project::Env) {
match cmd.command {
DataCommand::Coverage(a) => dispatch_data_coverage(a, env),
DataCommand::Info(a) => dispatch_data_info(a, env),
DataCommand::List => dispatch_data_list(env),
}
}
@@ -2325,57 +2325,116 @@ fn dispatch_measure_ic(a: MeasureIcCmd, env: &aura_runner::project::Env) {
println!("{}", report.to_json());
}
/// `aura data list` (#264 cut 2): print the archive's known symbols, sorted
/// ascending, one per line — the discovery step before `aura data coverage`
/// or scoping a campaign's instrument matrix. Resolves the archive root
/// through the normal project path ([`dispatch_data_coverage`]'s
/// `Env::data_path`), then reuses `DataServer`'s own symbol index
/// (`DataServer::symbols()`, already sorted) rather than re-deriving a
/// directory scan. An empty or absent archive is informational absence, not a
/// fault: a `no symbols` prose line, exit 0 (the verb corpus's established
/// empty-result register — cf. `runs family`'s unknown-id empty exit 0).
/// `aura data list` (#273: the namespace-wide NDJSON reshape): print the
/// archive's known symbols as NDJSON, sorted ascending, one JSON string per
/// line — the discovery step before `aura data info <symbol>` or scoping a
/// campaign's instrument matrix. Resolves the archive root through the normal
/// project path (`Env::data_path`), then reuses `DataServer`'s own symbol
/// index (`DataServer::symbols()`, already sorted) rather than re-deriving a
/// directory scan. An empty or absent archive prints zero stdout lines — the
/// honest NDJSON of an empty set, since a placeholder prose line would break
/// `| jq`-style consumption; the human affordance moves to a
/// `crate::diag::note!` stderr notice instead (#273 design minutes,
/// 2026-07-25).
fn dispatch_data_list(env: &aura_runner::project::Env) {
let data_path = std::path::PathBuf::from(env.data_path());
let server = aura_ingest::DataServer::new(&data_path);
for line in data_list_report(&server.symbols()) {
let report = data_list_report(&server.symbols());
if report.is_empty() {
crate::diag::note!("no symbols in the archive");
return;
}
for line in report {
println!("{line}");
}
}
/// Pure: render `symbols` (already sorted by `DataServer::symbols()`) as one
/// line per symbol, or a single `no symbols` line when the archive is empty
/// or absent — informational absence, never a fault (#264).
/// JSON-string line per symbol (`serde_json::to_string`, so `SYMA` becomes the
/// quoted `"SYMA"` NDJSON line) — an empty archive yields an empty `Vec`,
/// never a placeholder line, so stdout stays pure NDJSON either way (#273).
fn data_list_report(symbols: &[std::sync::Arc<str>]) -> Vec<String> {
if symbols.is_empty() {
return vec!["no symbols".to_string()];
}
symbols.iter().map(|s| s.to_string()).collect()
symbols
.iter()
.map(|s| serde_json::to_string(s.as_ref()).expect("a symbol string always serializes"))
.collect()
}
/// `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: &aura_runner::project::Env) {
/// `aura data info <SYMBOL>` (#273): print a symbol's recorded geometry as one
/// flat JSON object — the machine-readable follow-up to `aura data list`'s
/// discovery pass, surfacing `DataServer::symbol_meta`'s neutral,
/// broker-agnostic geometry (digits / pip / tick / lot sizing, base / quote
/// asset) without a human ever opening the `<SYMBOL>.meta.json` sidecar by
/// hand. Resolves the archive root through the normal project path
/// (`Env::data_path`, same as every other `data` verb). A symbol is KNOWN
/// when the archive holds bar files for it (`DataServer::has_symbol`) OR it
/// carries a usable geometry sidecar (`DataServer::symbol_meta`) — a
/// sidecar-only symbol (bars not yet ingested) still reports its geometry. An
/// unknown symbol (neither) refuses on stderr, exit 1. Nothing else about the
/// archive (span, gaps, provider metadata) surfaces here — that was `aura
/// data coverage`'s job; #272's per-cell fault isolation made it redundant,
/// so this issue retires it rather than folding it in (#273 design minutes).
fn dispatch_data_info(a: DataInfoCmd, env: &aura_runner::project::Env) {
let data_path = std::path::PathBuf::from(env.data_path());
let months = aura_ingest::list_m1_months(&data_path, &a.symbol);
// The report body is single-sourced with a campaign cell's own coverage
// annotation (#295 Task 7: `aura_runner::coverage::interior_gap_months`
// is the one gap walk both consumers call).
match aura_runner::coverage::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);
}
let server = aura_ingest::DataServer::new(&data_path);
let geo = server.symbol_meta(&a.symbol);
if geo.is_none() && !server.has_symbol(&a.symbol) {
eprintln!(
"aura: no recorded data for symbol \"{}\" (no archive files, no geometry sidecar)",
a.symbol
);
std::process::exit(1);
}
println!("{}", data_info_report(&a.symbol, geo.as_ref()));
}
/// One `aura data info` object's shape: `symbol` plus, when a geometry
/// sidecar was found, its six neutral price-geometry fields — never a
/// provider-specific field (`generator`, `schemaVersion`, `generatedAtUtc`,
/// #273 acceptance), since `InstrumentGeometry` never carries one to begin
/// with. `InstrumentGeometry` itself has no `Serialize` impl, so
/// [`data_info_report`] copies its fields in here. `rename_all = "camelCase"`
/// turns `pip_size`/`tick_size`/`lot_size`/`base_asset`/`quote_asset` into the
/// sidecar's own `pipSize`/`tickSize`/`lotSize`/`baseAsset`/`quoteAsset`
/// spelling; `#[serde(skip_serializing_if)]` drops the whole geometry tail
/// when absent rather than emitting `null`s, so a sidecar-less known symbol
/// reports as bare `{"symbol":"…"}` (#273 design minutes, 2026-07-25:
/// `description` stays out of this report until the reader exposes it).
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct DataInfoReport {
symbol: String,
#[serde(skip_serializing_if = "Option::is_none")]
digits: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pip_size: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
tick_size: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
lot_size: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
base_asset: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
quote_asset: Option<String>,
}
/// Pure: `aura data info <SYMBOL>`'s report body (#273) — one flat JSON
/// object, field order `symbol`, `digits`, `pipSize`, `tickSize`, `lotSize`,
/// `baseAsset`, `quoteAsset` (serde's field-declaration order is stable,
/// unlike `serde_json::Map`'s alphabetical iteration). `geo` is `None` for a
/// known-but-sidecar-less symbol, in which case every geometry field is
/// omitted (see [`DataInfoReport`]).
fn data_info_report(symbol: &str, geo: Option<&aura_ingest::InstrumentGeometry>) -> String {
let report = DataInfoReport {
symbol: symbol.to_string(),
digits: geo.map(|g| g.digits),
pip_size: geo.map(|g| g.pip_size),
tick_size: geo.map(|g| g.tick_size),
lot_size: geo.map(|g| g.lot_size),
base_asset: geo.map(|g| g.base.clone()),
quote_asset: geo.map(|g| g.quote.clone()),
};
serde_json::to_string(&report).expect("a finite DataInfoReport always serializes")
}
/// `aura sweep`: loaded-blueprint by-name axis sweep (or `--list-axes` probe) when the
@@ -2819,23 +2878,55 @@ mod tests {
}
/// An empty (or absent) archive is informational absence, not a fault
/// (#264): `data_list_report` returns the single `no symbols` prose line
/// rather than an empty `Vec`, so the caller's exit-0 println always has
/// something to say.
/// (#264), but under the all-JSON namespace reshape (#273) stdout must
/// stay pure NDJSON: `data_list_report` returns an empty `Vec` rather
/// than a placeholder prose line, so the caller has nothing to print and
/// moves the human affordance to stderr instead.
#[test]
fn data_list_report_of_an_empty_archive_is_a_no_symbols_line() {
fn data_list_report_of_an_empty_archive_is_empty() {
let symbols: Vec<std::sync::Arc<str>> = vec![];
assert_eq!(data_list_report(&symbols), vec!["no symbols".to_string()]);
assert_eq!(data_list_report(&symbols), Vec::<String>::new());
}
/// A non-empty archive renders one line per symbol, in the order handed
/// in (`DataServer::symbols()` already sorts) — no `no symbols` line
/// mixed in (#264).
/// A non-empty archive renders one NDJSON line per symbol, in the order
/// handed in (`DataServer::symbols()` already sorts) — each symbol as a
/// quoted JSON string, not a bare name (#273: the NDJSON reshape).
#[test]
fn data_list_report_of_a_populated_archive_is_one_line_per_symbol() {
fn data_list_report_of_a_populated_archive_is_one_json_string_per_line() {
let symbols: Vec<std::sync::Arc<str>> =
vec![std::sync::Arc::from("SYMA"), std::sync::Arc::from("SYMB")];
assert_eq!(data_list_report(&symbols), vec!["SYMA".to_string(), "SYMB".to_string()]);
assert_eq!(
data_list_report(&symbols),
vec!["\"SYMA\"".to_string(), "\"SYMB\"".to_string()]
);
}
/// `aura data info`'s report body with a full geometry sidecar present:
/// the exact output string, pinning both the field order (`symbol` then
/// the six geometry fields, camelCased) and serde_json's own float
/// rendering (#273).
#[test]
fn data_info_report_with_geometry_is_symbol_plus_six_camelcase_fields() {
let geo = aura_ingest::InstrumentGeometry {
digits: 5,
pip_size: 0.0001,
tick_size: 1e-05,
lot_size: 100_000.0,
base: "EUR".to_string(),
quote: "USD".to_string(),
};
assert_eq!(
data_info_report("EURUSD", Some(&geo)),
r#"{"symbol":"EURUSD","digits":5,"pipSize":0.0001,"tickSize":0.00001,"lotSize":100000.0,"baseAsset":"EUR","quoteAsset":"USD"}"#
);
}
/// A known symbol with no geometry sidecar reports bare identity — no
/// `null` geometry fields, no provider metadata, just `symbol` (#273:
/// "a sidecar-less known symbol yields just `{"symbol":"…"}`").
#[test]
fn data_info_report_without_geometry_is_bare_symbol() {
assert_eq!(data_info_report("SYMX", None), r#"{"symbol":"SYMX"}"#);
}
/// #234: `campaign_run::persist_campaign_traces`'s C1 drift alarm re-runs a