Third verb dissolution: route `aura walkforward --strategy r-sma --real`
through the one campaign path as thin sugar, reproducing the summary line
byte-for-byte. Key finding (verified): this is PURE SUGAR — no executor
change — because the campaign walk_forward stage's per-window OOS reports
carry everything the summary needs (stitched_total_pips == the per-window
total_pips summed in roll order; pooled oos_r from the reports' in-memory
trade_rs). The built-in arm SPLITS: only r-sma-real dissolves; the synthetic
and non-r-sma-real branches keep the inline run_walkforward (Reading A). The
--select plateau path is carved out to #215; the equivalence anchor 96dc783
is the acceptance gate.
refs #210
19 KiB
Walkforward dissolution — Design Spec
Date: 2026-07-06 Status: Draft — awaiting user spec review Authors: orchestrator + Claude
Milestone #210 ("Verb dissolution — the campaign path as canonical orchestration"), third dissolution (after sweep and generalize). Reference issue: Gitea Brummel/Aura #210. Scope reading: decision 2, Reading A (the verb surfaces stay fenced until #159; a real-archive execution of a built-in strategy routes through the campaign path now). Derived forks A/B and the pure-sugar finding are recorded as #210 comments (2026-07-06).
Goal
Dissolve aura walkforward's inline built-in --strategy r-sma --real execution
into thin sugar over the one campaign executor (aura_campaign::execute),
exactly as sweep and generalize already were. The dispatch_walkforward built-in
arm gains an r-sma-real branch that runs through a generated, auto-registered,
content-addressed campaign document, reproducing the {"walkforward":{…}}
summary line byte-for-byte. The committed anchor
walkforward_real_e2e_pins_the_exact_current_grade is the acceptance gate.
run_walkforward is NOT deleted (unlike generalize, which had no synthetic
path): the built-in arm splits, and everything the dissolution predicate does not
match stays on the inline handler.
Out of scope (stay verb-wired, fenced until #159 — Reading A): the blueprint-file
branch (run_blueprint_walkforward, hardcoded DataSource::Synthetic), the
synthetic built-in path (any strategy, no --real), and the non-r-sma real path
(--strategy sma --real — a different blueprint the r-sma sugar does not
synthesize). The --select plateau:* sub-path is carved out to a follow-up (Fork
B): the dissolved dispatch refuses it (exit 2) with a forward pointer until the
decision-4 wf-stage plateau relaxation (#215) ships.
Architecture
The dissolution reuses the generalize seam verbatim; the only new insight is that the walk-forward verb summary reconstructs from the campaign's recorded outcome as pure sugar — no executor change.
Grounding (verified, recorded on #210):
- The campaign
std::walk_forwardstage (crates/aura-campaign/src/exec.rs,run_walk_forward_stage) already runs the per-window IS-sweep →optimize_deflatedwinner → OOS run, and appends the per-window OOS reports as aFamilyKind::WalkForwardfamily.CellOutcome.familiessurfaces thatStageFamily { block: "std::walk_forward", family_id, reports }in-memory to the caller, with each report'smetrics.r.trade_rsintact (it is#[serde(skip)]on the wire but present in-memory). stitched_total_pips= Σ (per-windowmetrics.total_pips), summed left-to-right in window (roll) order. The enginestitchoffsets each segment by the running sum of prior segments' final cumulative values, sostitched_oos_equity.last() = Σ_i seg_i.last, andseg_i.last == total_pips_i. Verified byte-exact on the anchor:-10398606.666650848. The discardedoos_equity: vec is therefore NOT needed.- pooled
oos_r=r_metrics_from_rs(pool of the per-windowtrade_rs, roll order) — the same reduction the inlinepooled_oos_trade_rsperforms. Nostd::monte_carlostage is appended. (Σ per-windown_trades= 20681 verified == the pooledn_trades.) param_stabilityis the on-demand reduction over the per-window chosen params; reconstructable from the family reports' recorded manifest params (roll order).
The canonical process shape for a pure walk-forward is [std::sweep(argmax), std::walk_forward]: the wf stage's per-window IS-refit needs a survivor
population, and the campaign feeds it from a preceding sweep (a selection-free
sweep is permitted only as the terminal stage). The preceding sweep's whole
family flows on as survivors (select names a recorded selection, never a
filter — exec.rs:319), so the wf stage receives the full fast×slow grid and
refits it per window. That preceding full-window sweep records an extra
Sweep family (a campaign-path audit-trail addition, accepted exactly as
generalize's family-set change was — the anchor pins only the summary line).
Fork A (stop mapping): fast/slow are the IS-refit grid (multi-value campaign
strategy axes); the stop is a single risk regime RiskRegime::Vol{stop_length, stop_k} (the member runner maps cell.regime → StopRule::Vol). --stop-length
/ --stop-k are single-value; a multi-value stop is refused.
Load-bearing implementation detail (anchor-gated, not an assumption about current
behaviour): the inline roller works in ns (span from probe_window, sizes
WF_REAL_*_NS); the campaign wf stage works in ms (span cell.window_ms from the
doc's from_ms/to_ms, sizes the StageBlock _ms fields). Since ns = ms ×
1e6 linearly, the ms roller sizes (WF_REAL_*_NS / 1_000_000) over the doc window
must reproduce the identical 9 calendar windows — the anchor's windows count and
exact floats are what verify this end-to-end; any span/roller drift fails there
loudly.
Concrete code shapes
The user-facing program (unchanged surface, dissolved execution)
$ aura walkforward --strategy r-sma --real GER40 \
--from 1735689600000 --to 1767225599000 \
--fast 3,5 --slow 12,20 --stop-length 14 --stop-k 2.0
{"family_id":"…","report":{…}} # per-window OOS member line (×9)
…
{"walkforward":{"oos_r":{…},"param_stability":[…],
"stitched_total_pips":-10398606.666650848,"windows":9}}
The summary line is byte-identical to today's inline output (the acceptance
gate). The per-window member lines carry the campaign-generated WalkForward
family id rather than the inline walkforward-N handle (a family-handle change,
accepted like generalize's — the anchor pins the summary line, not the member
handles).
This example IS the acceptance-criterion evidence (aura CLAUDE.md domain
invariant 12: the World runs any harness through the one path; invariant 10:
closed-vocabulary campaign data, no verb-special logic). The audience — a
researcher validating a candidate out-of-sample — reaches for the identical
command; nothing about the surface changes, while the execution becomes a durable,
diffable, reproducible campaign document (C18). It reintroduces no look-ahead or
determinism failure: the campaign wf stage is the same deterministic engine
walk_forward (C1/C2), and the dissolution adds no new streamed non-scalar.
--select plateau:mean|plateau:worst is refused on the dissolved path:
$ aura walkforward --strategy r-sma --real GER40 --select plateau:worst …
aura: --select plateau is not yet available on the campaign path; see #215
$ echo $?
2
Sugar translator — mirror of translate_generalize (verb_sugar.rs)
The new translate_walkforward differs from translate_generalize in three
ways: the process is [Sweep(argmax), WalkForward] (not [Sweep(argmax), Generalize]); the campaign carries one instrument and multi-value
fast/slow axes (the IS-refit grid, not a single candidate); the wf stage's roller
sizes are computed from the data kind.
// crates/aura-cli/src/verb_sugar.rs — new, after translate_generalize
pub(crate) struct GeneratedWalkforward { pub process: ProcessDoc, pub campaign: CampaignDoc }
#[allow(clippy::too_many_arguments)] // #214: bundle deferred until all verbs dissolve
pub(crate) fn translate_walkforward(
fast: &[i64], slow: &[i64], // the IS-refit grid axes (multi-value)
stop_length: i64, stop_k: f64, // single-value → one risk regime
metric: &str, // "sqn_normalized" (the RSma wf objective)
in_sample_ms: u64, out_of_sample_ms: u64, step_ms: u64, // roller sizes, ms
name: &str, symbol: &str, from_ms: i64, to_ms: i64,
blueprint_canonical: &str,
) -> Result<GeneratedWalkforward, String> {
let process = ProcessDoc {
format_version: FORMAT_VERSION, kind: DocKind::Process,
name: "walkforward".to_string(), description: None,
pipeline: vec![
// the preceding sweep only enumerates the survivor grid for the wf
// stage; its recorded argmax selection is not part of the summary
StageBlock::Sweep { selection: Some(SweepSelection {
metric: metric.to_string(), select: SelectRule::Argmax, deflate: false }) },
StageBlock::WalkForward {
in_sample_ms, out_of_sample_ms, step_ms,
mode: WfMode::Rolling, metric: metric.to_string(), select: SelectRule::Argmax },
],
};
let mut doc_axes: BTreeMap<String, Axis> = BTreeMap::new();
doc_axes.insert("fast.length".to_string(),
axis_from_values("fast.length", &fast.iter().map(|&v| Scalar::i64(v)).collect::<Vec<_>>())?);
doc_axes.insert("slow.length".to_string(),
axis_from_values("slow.length", &slow.iter().map(|&v| Scalar::i64(v)).collect::<Vec<_>>())?);
let campaign = CampaignDoc {
format_version: FORMAT_VERSION, kind: DocKind::Campaign,
name: name.to_string(), description: None,
data: DataSection { instruments: vec![symbol.to_string()],
windows: vec![Window { from_ms, to_ms }] },
strategies: vec![StrategyEntry {
r#ref: DocRef::ContentId(content_id_of(blueprint_canonical)), axes: doc_axes }],
process: ProcessRef { r#ref: DocRef::ContentId(content_id_of(&process_to_json(&process))) },
risk: vec![RiskRegime::Vol { length: stop_length, k: stop_k }],
seed: 0,
presentation: Presentation { persist_taps: vec![], emit: vec!["family_table".to_string()] },
};
Ok(GeneratedWalkforward { process, campaign })
}
Sugar runner — reprint the summary from the recorded WalkForward family
// crates/aura-cli/src/verb_sugar.rs — new, mirror of run_generalize_sugar
#[allow(clippy::too_many_arguments)]
pub(crate) fn run_walkforward_sugar(/* same args as translate_walkforward + env */)
-> Result<(), String> {
let generated = translate_walkforward(/* … */)?;
let probe_params = 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 (_p, campaign_id) = register_generated_wf(®, &generated)?; // put_process + put_campaign
let run = crate::campaign_run::run_campaign_returning(&campaign_id, env)?;
// The single cell's walk_forward StageFamily (block == "std::walk_forward").
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")?;
// Per-window member lines (campaign family id), then the summary line —
// both reconstructed from wf.reports in roll order.
for report in &wf.reports { println!("{}", crate::family_member_line(&wf.family_id, report)); }
println!("{}", crate::walkforward_summary_json_from_reports(&wf.reports)); // new pure-sugar reducer
Ok(())
}
The new walkforward_summary_json_from_reports is the pure-sugar reducer over
the recorded per-window reports (verified byte-exact against the inline
walkforward_summary_json): windows = reports.len(); stitched_total_pips = reports.iter().map(|r| r.metrics.total_pips).sum() (left-to-right, roll order —
IEEE-754 order preserved); the oos_r block from
r_metrics_from_rs(pooled trade_rs); param_stability from the per-window
recorded params. It reuses the existing pooled_oos_trade_rs/param_stability
reductions where their inputs are reconstructable from the reports.
Dispatch rewrite — split the built-in arm; run_walkforward STAYS for the fenced paths
Unlike generalize (which requires --real and has no synthetic path),
dispatch_walkforward's None (built-in) arm today routes BOTH synthetic
(aura walkforward without --real) and real through run_walkforward. Under
decision 2 / Reading A only the real-archive r-sma execution dissolves; the
synthetic path (any strategy) and the non-r-sma real path (--strategy sma --real — a different blueprint the r-sma sugar does not synthesize) stay
verb-wired, fenced until #159. So the arm splits on the dissolution
predicate and run_walkforward is retained for the fenced branch — this
cycle deletes NO inline code; it adds an r-sma-real branch alongside the existing
body.
// crates/aura-cli/src/main.rs — dispatch_walkforward, the None (built-in) arm
None => {
// …existing usage / blueprint-flag guards, unchanged…
let dissolves = a.strategy.as_deref() == Some("r-sma") && a.real.is_some();
if !dissolves {
// Fenced (Reading A): synthetic (any strategy) + non-r-sma real stay on
// the inline handler — the EXISTING arm body, byte-for-byte unchanged:
// strategy_from / RGrid parse / parse_select / name_persist /
// data_choice_from → run_walkforward(strategy, &name, persist,
// DataSource::from_choice(data, env), &grid, select, env);
return; /* existing body, verbatim */
}
// r-sma --real: the dissolved sugar path.
if is_plateau(a.select.as_deref()) { // Fork B: refuse plateau on the dissolved path
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: eprintln + exit 2, the generalize idiom */;
// Synthesize the bare open sma_signal blueprint (identical to generalize).
let signal = sma_signal(None, None);
let canonical = blueprint_to_json(&signal).expect("a bare sma_signal serializes");
let reg = env.registry();
reg.put_blueprint(&topology_hash(&signal), &canonical) /* exit 1 on err */;
// Window: explicit --from/--to when both present, else the symbol's full
// archive window (as generalize; no-window grade drifts with the archive).
let (from_ms, to_ms) = resolve_window(&symbol, from, to, env);
let (is_ms, oos_ms, step_ms) = wf_ms_sizes(); // WF_REAL_*_NS / 1_000_000
verb_sugar::run_walkforward_sugar(&fast, &slow, stop_length, stop_k,
"sqn_normalized", is_ms, oos_ms, step_ms, &name, &symbol, from_ms, to_ms, &canonical, env)
/* unwrap_or_else: eprintln + exit 1 */;
}
run_walkforward (main.rs:1665) and every helper it uses — walkforward_family,
walkforward_summary_json, pooled_oos_trade_rs, param_stability,
select_winner, run_oos_r, family_member_line — are retained: recon
confirmed each keeps a live non-test caller (the synthetic path, the blueprint
walkforward path, and the mc R-bootstrap path). Nothing is deleted; the new
sugar functions are additive.
Components
crates/aura-cli/src/verb_sugar.rs—translate_walkforward,GeneratedWalkforward,register_generated_wf,run_walkforward_sugar,walkforward_summary_json_from_reports(or reuse the inline reducer if it can read&[RunReport]). Reusesvalidate_before_register,axis_from_values.crates/aura-cli/src/main.rs—walkforward_args_from(mirror ofgeneralize_args_from: single instrument, multi-value fast/slow, single-value stop with a multi-value refusal, all-four-knobs-required), a--select plateaurefusal, thedispatch_walkforwardbuilt-in-arm split (add the r-sma-real sugar branch; retain the existing body +run_walkforwardfor the fenced else), the ns→ms roller-size helper. No inline handler or helper is removed.- No executor change (
aura-campaign,aura-engine,aura-researchuntouched) for the argmax path.
Data flow
argv → walkforward_args_from (name, symbol, fast[], slow[], stop, window) →
dispatch_walkforward synthesizes + stores the sma_signal blueprint, resolves
the window, computes ms roller sizes → run_walkforward_sugar →
translate_walkforward builds [Sweep(argmax), WalkForward] + a campaign
(fast/slow axes, one instrument, one Vol regime) → validate_before_register →
register_generated_wf → run_campaign_returning runs the one executor (sweep
enumerates survivors → wf refits per window → appends the WalkForward family) →
the sugar reads the WalkForward StageFamily from CellOutcome.families,
reprints the member lines + the summary reducer.
Error handling
- Non-
r-smastrategy, missing knobs, empty/absent--real: reuse the inline message strings (byte-identical front-end refusals, exit 2), as generalize did. - Multi-value
--stop-length/--stop-k: exit 2 with "the stop is a single risk regime; --stop-length/--stop-k take one value each" (Fork A). --select plateau:*: exit 2 with the forward-pointer message (Fork B).- Referential faults (unbound axis, unresolvable ref):
validate_before_registerrefuses BEFORE any store write (no store litter), reusing the shared seam. - Data refusal (no local archive): the campaign member runner's existing
no_real_datapath (exit 1), same as generalize.
Testing strategy
- The committed anchor
walkforward_real_e2e_pins_the_exact_current_grade(96dc783) is the equivalence gate: it must stay green through the dissolution (windows=9, stitched_total_pips, oos_r, param_stability means all exact). translate_walkforwardunit tests (mirror the generalize translator tests): deterministic content ids for identical invocations; the pipeline is[Sweep(argmax), WalkForward]with the right roller sizes + metric; the risk regime carries the stop; diverging stops do not collide content ids;register_generated_wfstores documents the campaign can resolve.- Dissolution-proof e2e (mirror
generalize_dissolves_through_the_campaign_path): a successful real run auto-registers exactly one process + one campaign doc + one campaign-run record, and the walk_forward family is present. - Front-end refusal tests: multi-value stop (exit 2),
--select plateau(exit 2), non-r-sma (exit 2) — each byte-identical or forward-pointing. walkforward_summary_json_from_reportsunit test: on a small fixture of per-window reports, its output equals the inlinewalkforward_summary_jsonon the same windows (the pure-sugar equivalence, in isolation).
Acceptance criteria
aura walkforward --strategy r-sma --real <SYM> --from --to --fast <csv> --slow <csv> --stop-length <v> --stop-k <v>runs through the campaign path; the anchor stays green (summary line byte-identical).--strategy r-sma --realno longer flows throughrun_walkforward; it runs the sugar/campaign path.run_walkforwardis retained (not deleted) and still reached for the fenced branches.- The fenced branches are behaviour-unchanged (decision 2 / Reading A): the
blueprint-file path, the synthetic built-in path (any strategy), and the
non-r-sma real path (
--strategy sma --real) still run inline throughrun_walkforwardexactly as before; every shared helper (walkforward_family,walkforward_summary_json,select_winner, …) is retained. --select plateau:*is explicitly refused (exit 2) with a forward pointer; the decision-4 wf-stage plateau relaxation is filed as a follow-up issue.- Multi-value
--stop-length/--stop-kis refused (Fork A). - Full workspace suite green;
clippy --all-targets -D warningsclean. - No executor change (
aura-campaign/aura-engine/aura-researchdiff-free for the argmax path).