feat(cli): dissolve aura generalize into the campaign path (#210 T3)
`dispatch_generalize` synthesizes the bare `sma_signal` blueprint, resolves the
window (explicit --from/--to, else the first symbol's full window), and calls
the new `run_generalize_sugar`; the inline `run_generalize` is deleted. The
sugar builds the selection-bearing campaign doc, runs it through
`run_campaign_returning`, reprints the byte-identical `{"generalize":…}` +
`{"family_id":…}` lines, and persists the CrossInstrument family.
The exact-grade anchor stays green through the path shift — proving the two stop
mechanisms (former grid axis, now the risk-regime seam -> StopRule::Vol) yield
identical R. The family pin relaxes to "exactly one CrossInstrument family"
since the campaign executor now also persists a per-instrument Sweep family per
cell (the durable audit trail; #210 Q4). Two new e2e pin the window fallback and
the family member attribution order.
Deviations from the plan (necessary, verified): cross_instrument_members returns
Vec<aura_engine::RunReport> (aura_registry::RunReport is not pub); a shared
validate_before_register helper is factored out of run_sweep_sugar (DRY); the
run_generalize doc-comment and the now-unused `generalization` import removed.
refs #210
This commit is contained in:
+60
-50
@@ -31,7 +31,7 @@ use aura_engine::{
|
||||
WindowBounds, WindowRoller, WindowRun,
|
||||
};
|
||||
use aura_registry::{
|
||||
check_r_metric, generalization, group_families, mc_member_reports, optimize_deflated,
|
||||
check_r_metric, group_families, mc_member_reports, optimize_deflated,
|
||||
optimize_plateau, rank_by, sweep_member_reports, walkforward_member_reports, FamilyKind,
|
||||
FamilyMember, Generalization, NameKind, PlateauMode, Registry, RunTraces, WriteKind,
|
||||
DEFLATION_BLOCK_LEN, DEFLATION_N_RESAMPLES,
|
||||
@@ -1654,49 +1654,6 @@ fn run_sweep(
|
||||
}
|
||||
}
|
||||
|
||||
/// `aura generalize --real <SYM1,SYM2,...> --fast <n> --slow <n> --stop-length <n>
|
||||
/// --stop-k <f>`: grade one r-sma candidate (a single-cell grid) across an
|
||||
/// instrument list. Pre-checks the R-metric data-free (refuse a non-R / unknown
|
||||
/// metric before any run), runs the candidate per instrument (stamping each report's
|
||||
/// manifest `instrument`), reduces to the worst-case floor + sign-agreement +
|
||||
/// per-instrument breakdown (`generalization`, C9/C10), prints the aggregate, and
|
||||
/// persists the per-instrument members as a `CrossInstrument` family (C12/C18).
|
||||
fn run_generalize(
|
||||
name: &str,
|
||||
symbols: &[String],
|
||||
grid: &RGrid,
|
||||
metric: &str,
|
||||
from_ms: Option<i64>,
|
||||
to_ms: Option<i64>,
|
||||
env: &project::Env,
|
||||
) {
|
||||
// data-free metric pre-check: refuse a non-R / unknown metric before any run.
|
||||
if let Err(e) = check_r_metric(metric) {
|
||||
eprintln!("aura: {e}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
let mut members: Vec<RunReport> = Vec::new();
|
||||
for symbol in symbols {
|
||||
let choice = DataChoice::Real { symbol: symbol.clone(), from_ms, to_ms };
|
||||
let data = DataSource::from_choice(choice, env); // per-instrument pip_or_refuse + has_symbol
|
||||
let family = r_sma_sweep_family(None, &data, grid, env); // single-cell grid -> 1 member
|
||||
let mut report = family.points[0].report.clone();
|
||||
report.manifest.instrument = Some(symbol.clone());
|
||||
members.push(report);
|
||||
}
|
||||
let pairs: Vec<(String, &RunReport)> = symbols.iter().cloned().zip(members.iter()).collect();
|
||||
let agg = match generalization(&pairs, metric) {
|
||||
Ok(a) => a,
|
||||
Err(e) => { eprintln!("aura: {e}"); std::process::exit(2); }
|
||||
};
|
||||
println!("{}", generalize_json(&agg));
|
||||
let reg = env.registry();
|
||||
match reg.append_family(name, FamilyKind::CrossInstrument, &members) {
|
||||
Ok(id) => println!("{{\"family_id\":\"{id}\"}}"),
|
||||
Err(e) => { eprintln!("aura: failed to persist family: {e}"); std::process::exit(1); }
|
||||
}
|
||||
}
|
||||
|
||||
/// `aura walkforward [--name <n>|--trace <n>]`: run a built-in rolling walk-forward
|
||||
/// over the sample blueprint + a synthetic windowed source. Per window: sweep the
|
||||
/// built-in grid on the in-sample slice, optimize by total_pips (axis 2 inside axis 3,
|
||||
@@ -4179,10 +4136,10 @@ fn run_args_from(a: &RunCmd) -> Result<RunArgs, String> {
|
||||
Ok(RunArgs { harness, data, trace: a.trace.clone(), cost, slip_vol_mult, carry_per_cycle })
|
||||
}
|
||||
|
||||
/// The old `parse_generalize_args` body minus tokenizing: convert `GeneralizeCmd`
|
||||
/// into the `run_generalize` argument shape. The candidate is a single cell (clap
|
||||
/// already types `--fast`/etc. as one `i64`/`f64`), all four knobs required, `--real`
|
||||
/// a `>=2`-distinct comma list; every refusal reuses the old message string.
|
||||
/// Convert `GeneralizeCmd` into the resolved argument shape the generalize sugar
|
||||
/// consumes. The candidate is a single cell (clap already types `--fast`/etc. as
|
||||
/// one `i64`/`f64`), all four knobs required, `--real` a `>=2`-distinct comma
|
||||
/// list; every refusal reuses the old message string (byte-identical front-end).
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn generalize_args_from(
|
||||
a: &GeneralizeCmd,
|
||||
@@ -4388,11 +4345,64 @@ fn dispatch_graph(a: GraphCmd, env: &project::Env) {
|
||||
}
|
||||
|
||||
fn dispatch_generalize(a: GeneralizeCmd, env: &project::Env) {
|
||||
let (name, symbols, grid, metric, from_ms, to_ms) = generalize_args_from(&a).unwrap_or_else(|m| {
|
||||
let (name, symbols, grid, metric, from, to) = generalize_args_from(&a).unwrap_or_else(|m| {
|
||||
eprintln!("aura: {m}");
|
||||
std::process::exit(2);
|
||||
});
|
||||
run_generalize(&name, &symbols, &grid, &metric, from_ms, to_ms, env);
|
||||
// Data-free R-metric refusal, byte-identical to the inline path (exit 2)
|
||||
// before any archive is touched.
|
||||
if let Err(e) = check_r_metric(&metric) {
|
||||
eprintln!("aura: {e}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
// Synthesize the bare open SMA signal as the stored strategy blueprint (the
|
||||
// same shape the sweep-dissolution fixture `sma_signal_open.json` stores);
|
||||
// fast/slow become single-value campaign axes and the stop rides the risk
|
||||
// regime (mapped back to StopRule::Vol by the member runner). topology_hash
|
||||
// == content_id_of(canonical), so the strategy ref resolves against this one
|
||||
// blueprint-store write.
|
||||
let signal = sma_signal(None, None);
|
||||
let canonical = blueprint_to_json(&signal).expect("a bare sma_signal serializes");
|
||||
let reg = env.registry();
|
||||
let topo = topology_hash(&signal);
|
||||
reg.put_blueprint(&topo, &canonical).unwrap_or_else(|e| {
|
||||
eprintln!("aura: {e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
// Window: the explicit --from/--to when both present (byte-identical to the
|
||||
// inline path, verified by the exact-grade anchor); otherwise the first
|
||||
// symbol's full archive window as the single shared campaign window.
|
||||
let (from_ms, to_ms) = match (from, to) {
|
||||
(Some(f), Some(t)) => (f, t),
|
||||
_ => {
|
||||
let source = DataSource::from_choice(
|
||||
DataChoice::Real { symbol: symbols[0].clone(), from_ms: from, to_ms: to },
|
||||
env,
|
||||
);
|
||||
let (from_ts, to_ts) = source.full_window(env);
|
||||
(
|
||||
aura_ingest::epoch_ns_to_unix_ms(from_ts),
|
||||
aura_ingest::epoch_ns_to_unix_ms(to_ts),
|
||||
)
|
||||
}
|
||||
};
|
||||
verb_sugar::run_generalize_sugar(
|
||||
grid.fast[0],
|
||||
grid.slow[0],
|
||||
grid.stop_length[0],
|
||||
grid.stop_k[0],
|
||||
&metric,
|
||||
&name,
|
||||
&symbols,
|
||||
from_ms,
|
||||
to_ms,
|
||||
&canonical,
|
||||
env,
|
||||
)
|
||||
.unwrap_or_else(|m| {
|
||||
eprintln!("aura: {m}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
fn dispatch_runs(a: RunsCmd, env: &project::Env) {
|
||||
|
||||
@@ -7,12 +7,6 @@
|
||||
//! becomes durable, diffable, reproducible intent), then run through the one
|
||||
//! campaign executor in sugar presentation mode (member lines only).
|
||||
|
||||
// Temporary until the dispatch rewire wires the new generalize translator (a
|
||||
// later task in this plan, which removes this line): the same bridge pattern
|
||||
// used for the sweep translator's own landing (#210 T3) — the translator lands
|
||||
// ahead of its caller.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use aura_core::{Scalar, ScalarKind};
|
||||
@@ -214,6 +208,49 @@ pub(crate) fn register_generated_g(
|
||||
Ok((process_id, campaign_id))
|
||||
}
|
||||
|
||||
/// Validate a generated `(process, campaign)` pair BEFORE anything is
|
||||
/// registered (#210 c0110 fieldtest, "store litter" finding): a referential
|
||||
/// refusal must never leave a dead generated document in the content-
|
||||
/// addressed store. Defensive doc-shape checks first (generated docs should
|
||||
/// always pass this — the translators build them by construction), then the
|
||||
/// executable-shape preflight, then the axis-binding check against the
|
||||
/// blueprint's own (wrapped) open param space via the pure `bind_axes` seam
|
||||
/// (campaign_run.rs) — the referential shape the dispatch-boundary name
|
||||
/// preflight (main.rs) does not catch: a subset of axes that leaves an open
|
||||
/// param unbound (probe P3). `probe_params` is any one grid value per axis
|
||||
/// (checks NAME coverage/uniqueness, never the bound value). Shared by both
|
||||
/// dissolved-verb sugars: one seam enforcing the store-litter invariant,
|
||||
/// rather than two drift-prone copies.
|
||||
fn validate_before_register(
|
||||
process: &ProcessDoc,
|
||||
campaign: &CampaignDoc,
|
||||
blueprint_canonical: &str,
|
||||
probe_params: &[(String, Scalar)],
|
||||
env: &crate::project::Env,
|
||||
) -> Result<(), String> {
|
||||
let doc_faults = aura_research::validate_campaign(campaign);
|
||||
if !doc_faults.is_empty() {
|
||||
return Err(crate::research_docs::fault_block(
|
||||
"generated campaign document invalid:",
|
||||
doc_faults.iter().map(crate::research_docs::doc_fault_prose).collect(),
|
||||
));
|
||||
}
|
||||
let process_faults = aura_research::validate_process(process);
|
||||
if !process_faults.is_empty() {
|
||||
return Err(crate::research_docs::fault_block(
|
||||
"generated process document invalid:",
|
||||
process_faults.iter().map(crate::research_docs::doc_fault_prose).collect(),
|
||||
));
|
||||
}
|
||||
aura_campaign::preflight(process, campaign).map_err(|f| crate::campaign_run::exec_fault_prose(&f))?;
|
||||
|
||||
let space = crate::blueprint_axis_probe(blueprint_canonical, env).param_space();
|
||||
let strategy_id = content_id_of(blueprint_canonical);
|
||||
crate::campaign_run::bind_axes(&space, &strategy_id, probe_params)
|
||||
.map(|_| ())
|
||||
.map_err(|f| crate::campaign_run::exec_fault_prose(&aura_campaign::ExecFault::Member(f)))
|
||||
}
|
||||
|
||||
/// Run one dissolved sweep invocation end-to-end: register the generated
|
||||
/// documents, then execute through the one campaign path in sugar mode.
|
||||
pub(crate) fn run_sweep_sugar(
|
||||
@@ -227,51 +264,93 @@ pub(crate) fn run_sweep_sugar(
|
||||
) -> Result<(), String> {
|
||||
let generated = translate_sweep(axes, name, symbol, from_ms, to_ms, blueprint_canonical)?;
|
||||
|
||||
// Validate BEFORE registering anything (#210 c0110 fieldtest, "store
|
||||
// litter" finding): a referential refusal must never leave a dead
|
||||
// generated document in the content-addressed store. Defensive doc-shape
|
||||
// checks first (generated docs should always pass this — `translate_sweep`
|
||||
// builds them by construction), then the executable-shape preflight, then
|
||||
// the axis-binding check against the blueprint's own (wrapped) open param
|
||||
// space — the referential shape the dispatch-boundary name preflight
|
||||
// (main.rs) does not catch: a subset of axes that leaves an open param
|
||||
// unbound (probe P3).
|
||||
let doc_faults = aura_research::validate_campaign(&generated.campaign);
|
||||
if !doc_faults.is_empty() {
|
||||
return Err(crate::research_docs::fault_block(
|
||||
"generated campaign document invalid:",
|
||||
doc_faults.iter().map(crate::research_docs::doc_fault_prose).collect(),
|
||||
));
|
||||
}
|
||||
let process_faults = aura_research::validate_process(&generated.process);
|
||||
if !process_faults.is_empty() {
|
||||
return Err(crate::research_docs::fault_block(
|
||||
"generated process document invalid:",
|
||||
process_faults.iter().map(crate::research_docs::doc_fault_prose).collect(),
|
||||
));
|
||||
}
|
||||
aura_campaign::preflight(&generated.process, &generated.campaign)
|
||||
.map_err(|f| crate::campaign_run::exec_fault_prose(&f))?;
|
||||
|
||||
// The pure `bind_axes` seam (campaign_run.rs), reused rather than
|
||||
// re-inlined (#210): checks every generated axis name resolves to exactly
|
||||
// one open slot of the WRAPPED param space and every open slot is
|
||||
// covered. Any one grid value per axis suffices — this checks NAME
|
||||
// coverage/uniqueness, never the bound value.
|
||||
let space = crate::blueprint_axis_probe(blueprint_canonical, env).param_space();
|
||||
let strategy_id = content_id_of(blueprint_canonical);
|
||||
let probe_params: Vec<(String, Scalar)> = axes
|
||||
.iter()
|
||||
.filter_map(|(n, vals)| vals.first().map(|v| (n.clone(), *v)))
|
||||
.collect();
|
||||
crate::campaign_run::bind_axes(&space, &strategy_id, &probe_params)
|
||||
.map_err(|f| crate::campaign_run::exec_fault_prose(&aura_campaign::ExecFault::Member(f)))?;
|
||||
validate_before_register(&generated.process, &generated.campaign, blueprint_canonical, &probe_params, env)?;
|
||||
|
||||
let reg = env.registry();
|
||||
let (_process_id, campaign_id) = register_generated(®, &generated)?;
|
||||
crate::campaign_run::run_campaign_by_id(&campaign_id, env, crate::campaign_run::RunPresentation::MemberLinesOnly)
|
||||
}
|
||||
|
||||
/// Build the `CrossInstrument` family members from an executed generalize
|
||||
/// campaign: each cell's nominee `RunReport`, in cell (instrument doc) order —
|
||||
/// the same order + shape the inline `run_generalize` built its `members` in,
|
||||
/// each already carrying `manifest.instrument`. Debug-asserts one member per
|
||||
/// cell: generalize's pipeline has no gate stage, so every cell nominates —
|
||||
/// a cell silently missing its nominee here would be a campaign-executor
|
||||
/// regression, not a legitimate generalize outcome, and the filter_map below
|
||||
/// must not swallow it unnoticed.
|
||||
fn cross_instrument_members(
|
||||
outcome: &aura_campaign::CampaignOutcome,
|
||||
) -> Vec<aura_engine::RunReport> {
|
||||
let members: Vec<aura_engine::RunReport> = outcome
|
||||
.cells
|
||||
.iter()
|
||||
.filter_map(|c| c.nominee.as_ref().map(|(_, report)| report.clone()))
|
||||
.collect();
|
||||
debug_assert_eq!(
|
||||
members.len(),
|
||||
outcome.cells.len(),
|
||||
"generalize has no gate stage — every cell must nominate"
|
||||
);
|
||||
members
|
||||
}
|
||||
|
||||
/// Run one dissolved `generalize` invocation end-to-end: register the generated
|
||||
/// documents, run through the one campaign path, then reprint today's exact
|
||||
/// `{"generalize":{…}}` + `{"family_id":…}` lines from the recorded outcome and
|
||||
/// persist the `CrossInstrument` family. The stdout is byte-identical to the
|
||||
/// inline path (the committed exact-grade anchor is the gate).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn run_generalize_sugar(
|
||||
fast: i64,
|
||||
slow: i64,
|
||||
stop_length: i64,
|
||||
stop_k: f64,
|
||||
metric: &str,
|
||||
name: &str,
|
||||
symbols: &[String],
|
||||
from_ms: i64,
|
||||
to_ms: i64,
|
||||
blueprint_canonical: &str,
|
||||
env: &crate::project::Env,
|
||||
) -> Result<(), String> {
|
||||
let generated = translate_generalize(
|
||||
fast, slow, stop_length, stop_k, metric, name, symbols, from_ms, to_ms, blueprint_canonical,
|
||||
)?;
|
||||
|
||||
let probe_params: Vec<(String, Scalar)> = vec![
|
||||
("fast.length".to_string(), Scalar::i64(fast)),
|
||||
("slow.length".to_string(), Scalar::i64(slow)),
|
||||
];
|
||||
validate_before_register(&generated.process, &generated.campaign, blueprint_canonical, &probe_params, env)?;
|
||||
|
||||
let reg = env.registry();
|
||||
let (_process_id, campaign_id) = register_generated_g(®, &generated)?;
|
||||
let run = crate::campaign_run::run_campaign_returning(&campaign_id, env)?;
|
||||
|
||||
// Reprint the verb's aggregate line from the recorded cross-instrument
|
||||
// grade — byte-identical to the inline `generalize_json(&agg)`.
|
||||
let cg = run
|
||||
.outcome
|
||||
.record
|
||||
.generalizations
|
||||
.iter()
|
||||
.find_map(|g| g.generalization.as_ref())
|
||||
.ok_or("generalize produced no cross-instrument grade")?;
|
||||
println!("{}", crate::generalize_json(cg));
|
||||
|
||||
let members = cross_instrument_members(&run.outcome);
|
||||
let family_id = reg
|
||||
.append_family(name, aura_registry::FamilyKind::CrossInstrument, &members)
|
||||
.map_err(|e| e.to_string())?;
|
||||
println!("{{\"family_id\":\"{family_id}\"}}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -3637,6 +3637,45 @@ fn generalize_real_e2e_pins_the_exact_current_grade() {
|
||||
assert_eq!(per[1][1].as_f64(), Some(0.005795903617609842), "USDJPY expectancy R: {grade_line}");
|
||||
}
|
||||
|
||||
/// Property (#210 T3, dissolution): omitting `--from`/`--to` still completes —
|
||||
/// the dispatch rewrite's window-resolution fallback (`dispatch_generalize` in
|
||||
/// main.rs) resolves ONE shared campaign window from the FIRST listed symbol's
|
||||
/// full archive geometry and applies it to every instrument in the campaign
|
||||
/// document (a `CampaignDoc` carries a single shared window, unlike the old
|
||||
/// inline `run_generalize`, which let each instrument resolve its OWN
|
||||
/// independent full window). A wrong index or an empty-`symbols` panic in that
|
||||
/// new fallback would only surface when `--from`/`--to` are absent — every
|
||||
/// other generalize e2e pins the explicit-window path, leaving this one
|
||||
/// unexercised. Gated on local GER40/USDJPY data.
|
||||
#[test]
|
||||
fn generalize_without_explicit_window_falls_back_to_the_first_symbols_full_window() {
|
||||
let cwd = temp_cwd("generalize-default-window");
|
||||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args([
|
||||
"generalize", "--strategy", "r-sma", "--real", "GER40,USDJPY",
|
||||
"--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0",
|
||||
])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura");
|
||||
if out.status.code() == Some(1) {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
stderr.contains("no local data") || stderr.contains("no recorded geometry"),
|
||||
"exit 1 must be a data refusal, got: {stderr}"
|
||||
);
|
||||
eprintln!("skip: no local GER40/USDJPY data");
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
return;
|
||||
}
|
||||
assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status);
|
||||
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||||
assert!(stdout.contains("\"generalize\":"), "aggregate object: {stdout}");
|
||||
assert!(stdout.contains("\"n_instruments\":2"), "two instruments graded: {stdout}");
|
||||
assert!(stdout.contains("\"family_id\":\"generalize-0\""), "family handle: {stdout}");
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
/// Property: a single-instrument generalize is refused at parse time (exit 2),
|
||||
/// before any data access — so it asserts the arity refusal on any machine.
|
||||
#[test]
|
||||
@@ -3785,10 +3824,91 @@ fn generalize_persists_a_discoverable_cross_instrument_family() {
|
||||
.expect("spawn families");
|
||||
assert!(fams.status.success(), "families exit: {:?}", fams.status);
|
||||
let fams_out = String::from_utf8(fams.stdout).expect("utf-8");
|
||||
assert_eq!(fams_out.lines().count(), 1, "families: {fams_out:?}");
|
||||
assert!(fams_out.contains("\"family_id\":\"generalize-0\""), "families: {fams_out:?}");
|
||||
assert!(fams_out.contains("\"kind\":\"CrossInstrument\""), "families: {fams_out:?}");
|
||||
assert!(fams_out.contains("\"members\":2"), "one member per instrument: {fams_out:?}");
|
||||
// The dissolved path routes through the campaign executor, which persists a
|
||||
// per-instrument Sweep family per cell (each instrument's candidate run, now
|
||||
// a durable audit artifact) alongside the one CrossInstrument grade family
|
||||
// the sugar appends. Pin exactly one CrossInstrument family (the generalize
|
||||
// result) — not the total family count, which now includes those per-cell
|
||||
// Sweep families.
|
||||
let cross: Vec<&str> = fams_out
|
||||
.lines()
|
||||
.filter(|l| l.contains("\"kind\":\"CrossInstrument\""))
|
||||
.collect();
|
||||
assert_eq!(cross.len(), 1, "exactly one CrossInstrument family: {fams_out:?}");
|
||||
assert!(cross[0].contains("\"family_id\":\"generalize-0\""), "families: {fams_out:?}");
|
||||
assert!(cross[0].contains("\"members\":2"), "one member per instrument: {fams_out:?}");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
/// Property (#210 T3, dissolution): the persisted `CrossInstrument` family's
|
||||
/// members are attributed to the RIGHT instrument in the RIGHT cell order —
|
||||
/// not merely correct in count. The dissolved path builds the family via
|
||||
/// `cross_instrument_members`, which walks `outcome.cells` and trusts their
|
||||
/// order/labelling, replacing the old `run_generalize`'s explicit
|
||||
/// `zip(symbols, members)`. A cell-ordering or index bug in the new
|
||||
/// extraction would still print the right aggregate (the campaign's own
|
||||
/// `Generalize` stage computes that independently) while silently persisting
|
||||
/// swapped or mislabelled members — a regression this test, not the exact-grade
|
||||
/// anchor, would catch. Asserts `aura runs family generalize-0` lists GER40
|
||||
/// first with the exact-grade anchor's GER40 `expectancy_r`, then USDJPY with
|
||||
/// its own. Gated on the local GER40/USDJPY data (the shared Sept-2024 window).
|
||||
#[test]
|
||||
fn generalize_family_members_are_attributed_to_the_right_instrument() {
|
||||
const FROM_MS: &str = "1725148800000";
|
||||
const TO_MS: &str = "1727740799999";
|
||||
let cwd = temp_cwd("generalize-member-attribution");
|
||||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args([
|
||||
"generalize", "--strategy", "r-sma", "--real", "GER40,USDJPY",
|
||||
"--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0",
|
||||
"--from", FROM_MS, "--to", TO_MS,
|
||||
])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura");
|
||||
if out.status.code() == Some(1) {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
stderr.contains("no local data") || stderr.contains("no recorded geometry"),
|
||||
"exit 1 must be a data refusal, got: {stderr}"
|
||||
);
|
||||
eprintln!("skip: no local GER40/USDJPY data");
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
return;
|
||||
}
|
||||
assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status);
|
||||
|
||||
let fam = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args(["runs", "family", "generalize-0"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn runs family");
|
||||
assert!(fam.status.success(), "runs family exit: {:?}", fam.status);
|
||||
let fam_out = String::from_utf8(fam.stdout).expect("utf-8");
|
||||
let members: Vec<serde_json::Value> = fam_out
|
||||
.lines()
|
||||
.filter(|l| l.starts_with('{'))
|
||||
.map(|l| serde_json::from_str(l).expect("member line parses as JSON"))
|
||||
.collect();
|
||||
assert_eq!(members.len(), 2, "one member per instrument: {fam_out:?}");
|
||||
|
||||
assert_eq!(
|
||||
members[0]["manifest"]["instrument"].as_str(), Some("GER40"),
|
||||
"first member is GER40: {fam_out:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
members[0]["metrics"]["r"]["expectancy_r"].as_f64(), Some(0.01056371324510624),
|
||||
"GER40's own expectancy_r, matching the exact-grade anchor: {fam_out:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
members[1]["manifest"]["instrument"].as_str(), Some("USDJPY"),
|
||||
"second member is USDJPY: {fam_out:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
members[1]["metrics"]["r"]["expectancy_r"].as_f64(), Some(0.005795903617609842),
|
||||
"USDJPY's own expectancy_r, matching the exact-grade anchor: {fam_out:?}"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user