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:
+145
-54
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user