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:
2026-07-06 17:42:36 +02:00
parent ed8b179e34
commit 256ec7320c
3 changed files with 304 additions and 95 deletions
+60 -50
View File
@@ -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) {
+120 -41
View File
@@ -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(&reg, &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(&reg, &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::*;
+124 -4
View File
@@ -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);
}