From 68317ec95db2faa4e50049d38f0320a8daf53acc Mon Sep 17 00:00:00 2001 From: Brummel Date: Thu, 9 Jul 2026 16:48:49 +0200 Subject: [PATCH] =?UTF-8?q?chore(cli,ledger):=20#217=20cycle-close=20audit?= =?UTF-8?q?=20=E2=80=94=20coverage=20backfill=20+=20stop-knob=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cycle-close audit for the stop-default cycle (af8564d RED + 2102305 GREEN). Architect review: what holds — the argv extractors and the campaign member runner default to the same single-sourced regime constants; the multi-value refusal and the C10/C20 regime semantics survive; manifests stamp the resolved stop (C18). Three drift items, all resolved fix-path here: - generalize's default path was shipped untested; added the generalize_args_from_defaults_missing_knobs_to_the_regime unit test mirroring mc's sibling. - The None=>default / Some=>parse+single-value stop block recurred 4x across the walkforward/mc extractors; extracted into the generic stop_knob_or helper (refusal strings byte-identical; generalize's typed unwrap_or extractor deliberately not unified — no parsing there). - Ledger stop-defaulting note now records the two default representations: sweep binds no regime (late-resolved by the member runner), the three verbs bind the default eagerly into the document — same R behaviour, deliberately different content ids. Regression gate green, no baseline moved: full workspace suite 0 failures across all test-result groups; clippy -D warnings clean; cargo doc 0 warnings. No spec/plan working files existed (tdd entry path — the committed executable spec is the contract). refs #217 --- crates/aura-cli/src/main.rs | 89 ++++++++++++++++++++----------------- docs/design/INDEX.md | 10 ++++- 2 files changed, 58 insertions(+), 41 deletions(-) diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 3996250..7256cc5 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -2307,6 +2307,29 @@ fn select_is_plateau(select: Option<&str>) -> bool { matches!(select.map(parse_select), Some(Ok(Selection::Plateau(_)))) } +/// Resolve a single optional stop knob (`--stop-length`/`--stop-k`): an absent flag +/// defaults to `default`; a present one parses via [`parse_csv_list`] and refuses +/// unless it holds exactly one value (the stop is a risk regime, not a swept axis). +/// Single-sourced across `walkforward_args_from`/`mc_args_from` (#217 follow-up) — +/// `generalize_args_from`'s typed `Option`/`Option` fields need no parse +/// step, so it keeps its own plain `unwrap_or`. +fn stop_knob_or( + raw: Option<&str>, + default: T, + regime: &impl Fn() -> String, +) -> Result { + match raw { + None => Ok(default), + Some(s) => { + let v: Vec = parse_csv_list(s).map_err(|()| regime())?; + if v.len() != 1 { + return Err(regime()); + } + Ok(v[0]) + } + } +} + /// Convert `WalkforwardCmd` into the resolved argument shape the walkforward sugar /// consumes (the dissolved `.json --real` branch). Single instrument, single-value /// stop (Fork A: the stop is a risk regime, not a swept axis); `parse_csv_list` is @@ -2328,26 +2351,8 @@ fn walkforward_args_from( Some(s) => s.to_string(), }; let regime = || "walkforward: the stop is a single risk regime; --stop-length and --stop-k take one value each".to_string(); - let stop_length = match a.stop_length.as_deref() { - None => R_SMA_STOP_LENGTH, - Some(s) => { - let v: Vec = parse_csv_list(s).map_err(|()| regime())?; - if v.len() != 1 { - return Err(regime()); - } - v[0] - } - }; - let stop_k = match a.stop_k.as_deref() { - None => R_SMA_STOP_K, - Some(s) => { - let v: Vec = parse_csv_list(s).map_err(|()| regime())?; - if v.len() != 1 { - return Err(regime()); - } - v[0] - } - }; + let stop_length = stop_knob_or(a.stop_length.as_deref(), R_SMA_STOP_LENGTH, ®ime)?; + let stop_k = stop_knob_or(a.stop_k.as_deref(), R_SMA_STOP_K, ®ime)?; let name = match (a.name.as_deref(), a.trace.as_deref()) { (Some(_), Some(_)) => { return Err("walkforward: --name and --trace are mutually exclusive".to_string()); @@ -2383,26 +2388,8 @@ fn mc_args_from( return Err("mc --real: --name/--trace are not accepted (the R-bootstrap records without a family name)".to_string()); } let regime = || "mc: the stop is a single risk regime; --stop-length and --stop-k take one value each".to_string(); - let stop_length = match a.stop_length.as_deref() { - None => R_SMA_STOP_LENGTH, - Some(s) => { - let v: Vec = parse_csv_list(s).map_err(|()| regime())?; - if v.len() != 1 { - return Err(regime()); - } - v[0] - } - }; - let stop_k = match a.stop_k.as_deref() { - None => R_SMA_STOP_K, - Some(s) => { - let v: Vec = parse_csv_list(s).map_err(|()| regime())?; - if v.len() != 1 { - return Err(regime()); - } - v[0] - } - }; + let stop_length = stop_knob_or(a.stop_length.as_deref(), R_SMA_STOP_LENGTH, ®ime)?; + let stop_k = stop_knob_or(a.stop_k.as_deref(), R_SMA_STOP_K, ®ime)?; let block_len = a.block_len.map(|v| v as u32).unwrap_or(1); let resamples = a.resamples.map(|v| v as u32).unwrap_or(1000); let seed = a.seed.unwrap_or(1); @@ -4216,4 +4203,26 @@ mod tests { let err = mc_args_from(&a).unwrap_err(); assert!(err.contains("single risk regime"), "stop-regime refusal: {err}"); } + + /// Property (#217): a missing `--stop-length`/`--stop-k` no longer refuses — + /// each independently defaults to the single-sourced [`R_SMA_STOP_LENGTH`]/ + /// [`R_SMA_STOP_K`] regime (length 3, k 2.0). + #[test] + fn generalize_args_from_defaults_missing_knobs_to_the_regime() { + let a = GeneralizeCmd { + blueprint: Some("candidate.json".to_string()), + real: Some("GER40,USDJPY".to_string()), + axis: vec!["k=1".to_string()], + stop_length: None, + stop_k: None, + from: None, + to: None, + metric: None, + name: None, + }; + let (_, _, stop_length, stop_k, ..) = + generalize_args_from(&a).expect("stop-less generalize resolves"); + assert_eq!(stop_length, R_SMA_STOP_LENGTH, "omitted --stop-length defaults to the regime"); + assert_eq!(stop_k, R_SMA_STOP_K, "omitted --stop-k defaults to the regime"); + } } diff --git a/docs/design/INDEX.md b/docs/design/INDEX.md index fafca8a..f190611 100644 --- a/docs/design/INDEX.md +++ b/docs/design/INDEX.md @@ -960,7 +960,15 @@ methodology (finding the best regime = the same walk-forward / worst-case-R validation, run once per regime, and picking the most robust — a comparison, not a search). Each member manifest stamps its resolved stop (default included), closing the C18 gap; absent/empty `risk` = one implicit default regime, -absent-serializing for content-id parity. Deferred (documented): regime-aware +absent-serializing for content-id parity. Two default *representations* +coexist by design (#217): a dissolved sweep binds no regime at all +(`risk: []`), late-resolved per member by `stop_rule_for_regime` at run +time, while walkforward/mc/generalize — whose `--stop-length`/`--stop-k` +became optional — bind the default regime *eagerly* into the campaign +document (`risk: [Vol{length:3,k:2.0}]`). Same R behaviour either way, but +deliberately different document content ids: a stop-less verb invocation's +document equals its explicit `--stop-length 3 --stop-k 2.0` spelling, not +a stop-less sweep's document. Deferred (documented): regime-aware **trace** persistence — the trace re-run and cell-key dir naming still assume the default stop, since `CellRealization` carries no regime (`#212`); the core run/stamp/generalize path is unaffected.