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::*;