feat(cli): dissolve aura walkforward --strategy r-sma --real (#210 T3-T4)

The dispatch split lands: `dispatch_walkforward`'s built-in arm now routes
`--strategy r-sma --real` through the one campaign path (`run_walkforward_sugar`
-> generated campaign doc -> executor -> summary reprint), reproducing the
`{"walkforward":{…}}` line byte-for-byte. Everything else — synthetic (any
strategy) and non-r-sma real — stays on the inline `run_walkforward`, fenced
until #159 (Reading A). The committed anchor
`walkforward_real_e2e_pins_the_exact_current_grade` passes: the dissolved path
reproduces windows=9, stitched_total_pips, oos_r, and the param_stability means
exactly. `--select plateau:*` is refused (exit 2 -> #215, Fork B); a multi-value
stop is refused (Fork A).

Two plan guesses were wrong and the anchor + real-data e2e caught both; the fixes
are documented inline:

- Window resolution: the plan mirrored generalize's `(--from,--to) -> (f,t)`
  shortcut, but the inline `wf_full_span` ALWAYS clips `--from`/`--to` to the
  archive's actual first/last bar (`probe_window`), even when both flags are
  explicit — a holiday/weekend edge shifts every IS/OOS window's calendar
  placement (stitched_total_pips diverged by ~5.16M, param_stability means intact,
  confirming only window boundaries moved). The dissolved path now calls
  `source.full_window(env)` unconditionally, matching the inline behaviour;
  verified byte-for-byte against the still-fenced inline path.
- Reducer axis match: the campaign path records the blueprint axes wrapped
  (`sma_signal.fast.length`) while the stop rides unwrapped via the risk regime, so
  the reducer's exact-name match panicked on real data. Fixed with the existing
  `campaign_run::raw_matches_wrapped` (#203) wrap-convention helper.

Tests: the dissolution-proof e2e `walkforward_dissolves_through_the_campaign_path`
(one process + one campaign + one campaign-run auto-registered, windows=9) plus
front-end refusal coverage (multi-value stop, `--select plateau`, missing knobs,
empty `--real`, unknown `--select`, `--name`/`--trace` exclusivity). Full workspace
suite green; `clippy --all-targets -D warnings` clean.

refs #210
This commit is contained in:
2026-07-06 23:05:08 +02:00
parent a72cd6b115
commit a047af65fb
3 changed files with 373 additions and 10 deletions
+126 -7
View File
@@ -67,6 +67,12 @@ const WF_REAL_IS_NS: i64 = 90 * WF_DAY_NS;
const WF_REAL_OOS_NS: i64 = 30 * WF_DAY_NS;
const WF_REAL_STEP_NS: i64 = 30 * WF_DAY_NS;
/// The winner-selection objective for walk-forward's per-window IS refit — the
/// deflation-aware SQN variant (#144 default). Named once so the three call sites
/// (the inline r-sma roller, the inline blueprint roller, and the dissolved
/// campaign sugar's bridge) cannot drift apart on the token.
const WINNER_SELECTION_METRIC: &str = "sqn_normalized";
/// Fixed RNG seed for the trials-deflation reality-check bootstrap in walk-forward
/// winner selection. Recorded on each winner's manifest (so `overfit_probability`
/// is reproducible by re-run); a CLI flag for it is a deferred refinement. The
@@ -1758,7 +1764,7 @@ fn walkforward_family(
let space = r_sma_space();
walk_forward(roller, space, |w: WindowBounds| {
let (is_family, lattice) = r_sma_sweep_over(w.is.0, w.is.1, data, grid, env);
let (best, selection) = match select_winner(&is_family, "sqn_normalized", select, lattice.as_deref()) {
let (best, selection) = match select_winner(&is_family, WINNER_SELECTION_METRIC, select, lattice.as_deref()) {
Ok(v) => v,
Err(msg) => { eprintln!("aura: {msg}"); std::process::exit(2); }
};
@@ -1882,16 +1888,18 @@ fn walkforward_summary_json(result: &WalkForwardResult) -> String {
/// `stitched_total_pips` = the per-window `total_pips` summed left-to-right in roll
/// order (the engine `stitch` folds each segment's final cumulative value, and a
/// window's OOS segment ends at its `total_pips`); `param_stability` reduces each
/// r-sma IS-refit axis over the per-window chosen params (read by exact name from
/// each report's prefix-stripped `manifest.params`) through the same
/// `MetricStats::from_values`; the `oos_r` block pools the per-window `trade_rs`
/// r-sma IS-refit axis over the per-window chosen params (read from each report's
/// `manifest.params` via [`campaign_run::raw_matches_wrapped`] — the blueprint axes
/// `fast.length`/`slow.length` are recorded wrapped as `sma_signal.fast.length` etc.
/// by the strategy's own param space, while `stop_length`/`stop_k` ride the risk
/// regime unwrapped, so an exact-name match would miss the wrapped pair) through the
/// same `MetricStats::from_values`; the `oos_r` block pools the per-window `trade_rs`
/// through `r_metrics_from_rs`. Canonical JSON (C14).
///
/// The axis list + order mirror what `r_sma_space` yields (fast, slow, stop_length,
/// stop_k) — the same order + values the inline `param_stability` reduces. A wrong
/// order or axis would fail the committed exact-grade anchor loudly (it pins
/// `param_stability[0].mean` = the fast-MA refit mean), never silently.
#[allow(dead_code)]
fn walkforward_summary_json_from_reports(reports: &[RunReport]) -> String {
let total: f64 = reports.iter().map(|r| r.metrics.total_pips).sum();
// The four r-sma axis names, in `r_sma_space` order. Coercion to `f64` is
@@ -1910,7 +1918,7 @@ fn walkforward_summary_json_from_reports(reports: &[RunReport]) -> String {
.manifest
.params
.iter()
.find(|(name, _)| name == axis)
.find(|(name, _)| campaign_run::raw_matches_wrapped(axis, name))
.expect("each r-sma walk-forward window records its chosen axis");
match v {
Scalar::I64(i) => *i as f64,
@@ -3141,7 +3149,7 @@ fn blueprint_walkforward_family(
walk_forward(roller, space.clone(), |w: WindowBounds| {
let (is_family, lattice) = blueprint_sweep_over(doc, axes, w.is.0, w.is.1, data, env)
.expect("axes validated in the dispatch-boundary pre-flight");
let (best, selection) = match select_winner(&is_family, "sqn_normalized", select, Some(&lattice)) {
let (best, selection) = match select_winner(&is_family, WINNER_SELECTION_METRIC, select, Some(&lattice)) {
Ok(v) => v,
Err(msg) => { eprintln!("aura: {msg}"); std::process::exit(2); }
};
@@ -4201,6 +4209,61 @@ 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 real walk-forward roller sizes in ms (the campaign wf stage works in ms:
/// span `cell.window_ms`, sizes the `StageBlock` `_ms` fields). `WF_REAL_*_NS` are
/// nanoseconds; divide by 1e6. The ms sizes over the doc window reproduce the same
/// calendar windows the inline ns roller produces (anchor-gated).
fn wf_ms_sizes() -> (u64, u64, u64) {
(
(WF_REAL_IS_NS / 1_000_000) as u64,
(WF_REAL_OOS_NS / 1_000_000) as u64,
(WF_REAL_STEP_NS / 1_000_000) as u64,
)
}
/// True iff `--select` names a plateau mode (refused on the dissolved path, Fork B,
/// until #215 ships the wf-stage plateau relaxation). Reuses `parse_select` so the
/// vocabulary stays single-sourced.
fn select_is_plateau(select: Option<&str>) -> bool {
matches!(select.map(parse_select), Some(Ok(Selection::Plateau(_))))
}
/// Convert `WalkforwardCmd` into the resolved argument shape the walkforward sugar
/// consumes (the dissolved `--strategy r-sma --real` branch). Single instrument,
/// multi-value fast/slow (the IS-refit grid), single-value stop (Fork A: the stop is
/// a risk regime, not a swept axis); all four knobs required. `parse_csv_list`
/// (`:1586`) is generic over `T: FromStr`, so the same helper parses the i64 grids
/// and the f64 stop; the local `knobs` closure (captureless → `Copy`, mirroring
/// `generalize_args_from`'s idiom) is reused across the four `ok_or_else` calls.
/// `--name`/`--trace` are mutually exclusive here too, matching the flag's own
/// documented contract and the inline path's `name_persist` refusal.
#[allow(clippy::type_complexity)]
fn walkforward_args_from(
a: &WalkforwardCmd,
) -> Result<(String, String, Vec<i64>, Vec<i64>, i64, f64, Option<i64>, Option<i64>), String> {
let symbol = match a.real.as_deref() {
None | Some("") => return Err("walkforward --strategy r-sma dissolves only over --real <SYMBOL>".to_string()),
Some(s) => s.to_string(),
};
let knobs = || "walkforward requires --fast --slow --stop-length --stop-k (fast/slow may be CSV grids; the stop is single-value)".to_string();
let fast: Vec<i64> = parse_csv_list(a.fast.as_deref().ok_or_else(knobs)?).map_err(|()| knobs())?;
let slow: Vec<i64> = parse_csv_list(a.slow.as_deref().ok_or_else(knobs)?).map_err(|()| knobs())?;
let stop_length: Vec<i64> = parse_csv_list(a.stop_length.as_deref().ok_or_else(knobs)?).map_err(|()| knobs())?;
let stop_k: Vec<f64> = parse_csv_list(a.stop_k.as_deref().ok_or_else(knobs)?).map_err(|()| knobs())?;
if stop_length.len() != 1 || stop_k.len() != 1 {
return Err("walkforward: the stop is a single risk regime; --stop-length and --stop-k take one value each".to_string());
}
let name = match (a.name.as_deref(), a.trace.as_deref()) {
(Some(_), Some(_)) => {
return Err("walkforward: --name and --trace are mutually exclusive".to_string());
}
(Some(n), None) => n.to_string(),
(None, Some(t)) => t.to_string(),
(None, None) => "walkforward".to_string(),
};
Ok((name, symbol, fast, slow, stop_length[0], stop_k[0], a.from, a.to))
}
/// 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
@@ -4749,6 +4812,62 @@ fn dispatch_walkforward(a: WalkforwardCmd, env: &project::Env) {
eprintln!("aura: {}", usage());
std::process::exit(2);
}
// Dissolution split (#210, Reading A): only the real-archive r-sma
// execution routes through the campaign path. Everything else — synthetic
// (any strategy) and non-r-sma real — stays on the inline handler below,
// fenced until #159.
if a.strategy.as_deref() == Some("r-sma") && a.real.is_some() {
if let Some(s) = a.select.as_deref()
&& parse_select(s).is_err()
{
eprintln!("aura: {}", usage());
std::process::exit(2);
}
if select_is_plateau(a.select.as_deref()) {
eprintln!("aura: --select plateau is not yet available on the campaign path; see #215");
std::process::exit(2);
}
let (name, symbol, fast, slow, stop_length, stop_k, from, to) =
walkforward_args_from(&a).unwrap_or_else(|m| {
eprintln!("aura: {m}");
std::process::exit(2);
});
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);
});
// Unlike `dispatch_generalize` (a single run per instrument, insensitive
// to a day's edge shift), the inline `walkforward_family` sources its span
// from `DataSource::wf_full_span`, which — for Real — ALWAYS clips
// `--from`/`--to` to the archive's actual first/last bar in range (never the
// literal ms request): a holiday/weekend edge shifts every IS/OOS window's
// calendar placement, so the roller must clip identically here too, even
// when both flags are given, or the per-window winners and OOS pips diverge
// from the committed exact-grade anchor.
let source = DataSource::from_choice(
DataChoice::Real { symbol: symbol.clone(), from_ms: from, to_ms: to },
env,
);
let (from_ts, to_ts) = source.full_window(env);
let (from_ms, to_ms) = (
aura_ingest::epoch_ns_to_unix_ms(from_ts),
aura_ingest::epoch_ns_to_unix_ms(to_ts),
);
let (is_ms, oos_ms, step_ms) = wf_ms_sizes();
verb_sugar::run_walkforward_sugar(
&fast, &slow, stop_length, stop_k, WINNER_SELECTION_METRIC,
is_ms, oos_ms, step_ms, &name, &symbol, from_ms, to_ms, &canonical, env,
)
.unwrap_or_else(|m| {
eprintln!("aura: {m}");
std::process::exit(1);
});
return;
}
let strategy = strategy_from(a.strategy.as_deref(), &usage).unwrap_or_else(|m| {
eprintln!("aura: {m}");
std::process::exit(2);
+51 -3
View File
@@ -352,7 +352,6 @@ pub(crate) fn run_generalize_sugar(
}
/// The two generated documents of one dissolved `walkforward` invocation.
#[allow(dead_code)]
#[derive(Debug)]
pub(crate) struct GeneratedWalkforward {
pub process: ProcessDoc,
@@ -368,7 +367,6 @@ pub(crate) struct GeneratedWalkforward {
/// (ms). `blueprint_canonical` is the bare `sma_signal` blueprint already stored by
/// topology hash; its content id is the strategy ref.
#[allow(clippy::too_many_arguments)]
#[allow(dead_code)]
pub(crate) fn translate_walkforward(
fast: &[i64],
slow: &[i64],
@@ -442,7 +440,6 @@ pub(crate) fn translate_walkforward(
/// Auto-register both generated walkforward documents (content-addressed,
/// idempotent). Returns `(process_id, campaign_id)`.
#[allow(dead_code)]
pub(crate) fn register_generated_wf(
reg: &aura_registry::Registry,
generated: &GeneratedWalkforward,
@@ -454,6 +451,57 @@ pub(crate) fn register_generated_wf(
Ok((process_id, campaign_id))
}
/// Run one dissolved `walkforward` invocation end-to-end: register the generated
/// documents, run through the one campaign path, then reprint the per-window member
/// lines + the summary line from the recorded `WalkForward` family. The stdout
/// summary line is byte-identical to the inline path (the committed exact-grade
/// anchor is the gate).
#[allow(clippy::too_many_arguments)]
pub(crate) fn run_walkforward_sugar(
fast: &[i64],
slow: &[i64],
stop_length: i64,
stop_k: f64,
metric: &str,
in_sample_ms: u64,
out_of_sample_ms: u64,
step_ms: u64,
name: &str,
symbol: &str,
from_ms: i64,
to_ms: i64,
blueprint_canonical: &str,
env: &crate::project::Env,
) -> Result<(), String> {
let generated = translate_walkforward(
fast, slow, stop_length, stop_k, metric, in_sample_ms, out_of_sample_ms, step_ms,
name, symbol, from_ms, to_ms, blueprint_canonical,
)?;
let probe_params: Vec<(String, Scalar)> = vec![
("fast.length".to_string(), Scalar::i64(fast[0])),
("slow.length".to_string(), Scalar::i64(slow[0])),
];
validate_before_register(&generated.process, &generated.campaign, blueprint_canonical, &probe_params, env)?;
let reg = env.registry();
let (_process_id, campaign_id) = register_generated_wf(&reg, &generated)?;
let run = crate::campaign_run::run_campaign_returning(&campaign_id, env)?;
// The single cell's walk_forward StageFamily.
let wf = run
.outcome
.cells
.iter()
.flat_map(|c| c.families.iter())
.find(|f| f.block == "std::walk_forward")
.ok_or("walkforward produced no walk_forward family")?;
for report in &wf.reports {
println!("{}", crate::family_member_line(&wf.family_id, report));
}
println!("{}", crate::walkforward_summary_json_from_reports(&wf.reports));
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;