From 256ec7320cee29abd0b9d981e16e50d62b73aa70 Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 6 Jul 2026 17:42:36 +0200 Subject: [PATCH] feat(cli): dissolve `aura generalize` into the campaign path (#210 T3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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_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 --- crates/aura-cli/src/main.rs | 110 ++++++++++---------- crates/aura-cli/src/verb_sugar.rs | 161 ++++++++++++++++++++++-------- crates/aura-cli/tests/cli_run.rs | 128 +++++++++++++++++++++++- 3 files changed, 304 insertions(+), 95 deletions(-) diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index b6a5ff3..4bb0c43 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -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 --fast --slow --stop-length -/// --stop-k `: 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, - to_ms: Option, - 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 = 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 |--trace ]`: 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 { 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) { diff --git a/crates/aura-cli/src/verb_sugar.rs b/crates/aura-cli/src/verb_sugar.rs index 3ac01f3..241ec9d 100644 --- a/crates/aura-cli/src/verb_sugar.rs +++ b/crates/aura-cli/src/verb_sugar.rs @@ -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 { + let members: Vec = 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::*; diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index 8c7c677..865acc1 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -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 = 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); }