From 210230596eff1221c441edc094bccba3896f2d24 Mon Sep 17 00:00:00 2001 From: Brummel Date: Thu, 9 Jul 2026 16:38:05 +0200 Subject: [PATCH] feat(cli): default the stop regime on walkforward/mc/generalize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GREEN side of the #217 executable-spec: --stop-length/--stop-k are now optional on the three dissolved verbs; each missing flag independently defaults to the single-sourced regime constants (length 3, k 2.0) at the argv-boundary extractors — the same regime the campaign member runner resolves for an unbound regime. The generated campaign document still binds the regime explicitly, so the default spelling is content-identical to an explicit --stop-length 3 --stop-k 2.0 (pinned by registry dedup in the headline test). The multi-value stop refusal survives byte-identically; sweep is untouched. Sanctioned contract migrations (decision on the issue, 2026-07-09): walkforward_dissolved_refuses_missing_knobs now pins the surviving multi-value refusal; mc_args_from_refuses_missing_knobs pins the default path. Usage strings name the defaults. Held quality nit (deferred to cycle close, minimal-slice constraint): the None=>default / Some=>parse stop block recurs 4x across the walkforward/mc extractors. closes #217 --- crates/aura-cli/src/main.rs | 116 +++++++++++++++++++++---------- crates/aura-cli/tests/cli_run.rs | 33 +++++++-- 2 files changed, 104 insertions(+), 45 deletions(-) diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 0ba38ce..3996250 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -2081,10 +2081,12 @@ struct GeneralizeCmd { /// Candidate axis `=` (repeatable, >=1; one value per axis). #[arg(long)] axis: Vec, - /// Candidate stop length (single value; required). + /// Candidate stop length (single value; optional, defaults to + /// [`R_SMA_STOP_LENGTH`]). #[arg(long)] stop_length: Option, - /// Candidate stop-k multiple (single value; required). + /// Candidate stop-k multiple (single value; optional, defaults to + /// [`R_SMA_STOP_K`]). #[arg(long)] stop_k: Option, /// Window start (Unix ms, inclusive). @@ -2309,12 +2311,14 @@ fn select_is_plateau(select: Option<&str>) -> bool { /// 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 /// generic over `T: FromStr`, so the same helper parses the i64 length and the f64 -/// k; the local `knobs` closure (captureless → `Copy`, mirroring `mc_args_from`'s -/// idiom) is reused across both `ok_or_else` calls. `--name`/`--trace` are -/// mutually exclusive, matching the flag's own documented contract and the inline -/// path's `name_persist` refusal; an omitted flag defaults the family name to -/// "walkforward". The IS-refit `--axis` grid is parsed separately at the dispatch -/// site, not here. +/// k. `--stop-length`/`--stop-k` are each OPTIONAL (#217): a missing flag defaults +/// independently to the single-sourced [`R_SMA_STOP_LENGTH`]/[`R_SMA_STOP_K`] +/// regime; a present-but-multi-value stop still refuses (the local `regime` +/// closure, captureless → `Copy`, is reused across both arms). `--name`/`--trace` +/// are mutually exclusive, matching the flag's own documented contract and the +/// inline path's `name_persist` refusal; an omitted flag defaults the family name +/// to "walkforward". The IS-refit `--axis` grid is parsed separately at the +/// dispatch site, not here. #[allow(clippy::type_complexity)] fn walkforward_args_from( a: &WalkforwardCmd, @@ -2323,12 +2327,27 @@ fn walkforward_args_from( None | Some("") => return Err("walkforward dissolves only over --real ".to_string()), Some(s) => s.to_string(), }; - let knobs = || "walkforward requires --stop-length --stop-k (single-value; the stop is a risk regime)".to_string(); - let stop_length: Vec = parse_csv_list(a.stop_length.as_deref().ok_or_else(knobs)?).map_err(|()| knobs())?; - let stop_k: Vec = 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 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 name = match (a.name.as_deref(), a.trace.as_deref()) { (Some(_), Some(_)) => { return Err("walkforward: --name and --trace are mutually exclusive".to_string()); @@ -2337,12 +2356,15 @@ fn walkforward_args_from( (None, Some(t)) => t.to_string(), (None, None) => "walkforward".to_string(), }; - Ok((name, symbol, stop_length[0], stop_k[0], a.from, a.to)) + Ok((name, symbol, stop_length, stop_k, a.from, a.to)) } /// Convert `McCmd` into the resolved argument shape the mc sugar consumes (the /// `--real` campaign branch). Single instrument, single-value stop (Fork A: the -/// stop is a risk regime, not a swept axis). `--block-len`/`--resamples`/`--seed` +/// stop is a risk regime, not a swept axis); `--stop-length`/`--stop-k` are each +/// OPTIONAL (#217), independently defaulting to the single-sourced +/// [`R_SMA_STOP_LENGTH`]/[`R_SMA_STOP_K`] regime when omitted — a present-but- +/// multi-value stop still refuses. `--block-len`/`--resamples`/`--seed` /// default to `1`/`1000`/`1` — the same defaults the retired real-R dispatch /// used, so an omitted flag produces the same document either way. The /// usize->u32 conversion lands HERE, at the argv boundary where the CLI's @@ -2360,22 +2382,40 @@ fn mc_args_from( if a.name.is_some() || a.trace.is_some() { return Err("mc --real: --name/--trace are not accepted (the R-bootstrap records without a family name)".to_string()); } - let knobs = || "mc requires --stop-length --stop-k (single-value; the stop is a risk regime)".to_string(); - let stop_length: Vec = parse_csv_list(a.stop_length.as_deref().ok_or_else(knobs)?).map_err(|()| knobs())?; - let stop_k: Vec = 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("mc: the stop is a single risk regime; --stop-length and --stop-k take one value each".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 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); - Ok(("mc".to_string(), symbol, stop_length[0], stop_k[0], block_len, resamples, seed, a.from, a.to)) + Ok(("mc".to_string(), symbol, stop_length, stop_k, block_len, resamples, seed, a.from, a.to)) } /// Convert `GeneralizeCmd` into the resolved argument shape the generalize sugar -/// consumes. `--real` is a `>=2`-distinct comma list; the stop knobs are required -/// single values. Every refusal whose flag survives #220 reuses the old message -/// string (byte-identical front-end). +/// consumes. `--real` is a `>=2`-distinct comma list; the stop knobs are single +/// values, each OPTIONAL (#217) and independently defaulting to the single- +/// sourced [`R_SMA_STOP_LENGTH`]/[`R_SMA_STOP_K`] regime when omitted — clap's +/// typed `Option`/`Option` already forbids a multi-value spelling, so +/// no separate regime refusal is needed here. Every refusal whose flag survives +/// #220 reuses the old message string (byte-identical front-end). #[allow(clippy::type_complexity)] fn generalize_args_from( a: &GeneralizeCmd, @@ -2400,9 +2440,8 @@ fn generalize_args_from( if !symbols.iter().all(|s| seen.insert(s.clone())) { return Err("generalize: each instrument may appear once; --real has a duplicate symbol".to_string()); } - let knobs = || "generalize requires --stop-length and --stop-k, each a single value".to_string(); - let stop_length = a.stop_length.ok_or_else(knobs)?; - let stop_k = a.stop_k.ok_or_else(knobs)?; + let stop_length = a.stop_length.unwrap_or(R_SMA_STOP_LENGTH); + let stop_k = a.stop_k.unwrap_or(R_SMA_STOP_K); let metric = a.metric.clone().unwrap_or_else(|| "expectancy_r".to_string()); let name = a.name.clone().unwrap_or_else(|| "generalize".to_string()); Ok((name, symbols, stop_length, stop_k, metric, a.from, a.to)) @@ -2633,7 +2672,7 @@ fn dispatch_graph(a: GraphCmd, env: &project::Env) { } fn dispatch_generalize(a: GeneralizeCmd, env: &project::Env) { - let usage = || "Usage: aura generalize --real --axis = [--axis …] --stop-length --stop-k [--metric ] [--name ] [--from ] [--to ]".to_string(); + let usage = || format!("Usage: aura generalize --real --axis = [--axis …] [--stop-length (default {R_SMA_STOP_LENGTH})] [--stop-k (default {R_SMA_STOP_K:.1})] [--metric ] [--name ] [--from ] [--to ]"); let Some(path) = is_blueprint_file(&a.blueprint) else { eprintln!("aura: {}", usage()); std::process::exit(2); @@ -2857,7 +2896,7 @@ fn dispatch_sweep(a: SweepCmd, env: &project::Env) { /// #220: arbitrary user blueprint + axes, formerly the welded r-sma branch). fn dispatch_walkforward(a: WalkforwardCmd, env: &project::Env) { // Single-sourced: both arms below read this one closure (house style, #179). - let usage = || "Usage: aura walkforward --axis = [--axis …] [--select ] [--name ] | aura walkforward --real --axis = [--axis …] --stop-length --stop-k [--from ] [--to ] [--name | --trace ]".to_string(); + let usage = || format!("Usage: aura walkforward --axis = [--axis …] [--select ] [--name ] | aura walkforward --real --axis = [--axis …] [--stop-length (default {R_SMA_STOP_LENGTH})] [--stop-k (default {R_SMA_STOP_K:.1})] [--from ] [--to ] [--name | --trace ]"); match is_blueprint_file(&a.blueprint) { Some(path) => { let doc = std::fs::read_to_string(path).unwrap_or_else(|e| { @@ -2965,7 +3004,7 @@ fn dispatch_walkforward(a: WalkforwardCmd, env: &project::Env) { /// #220: arbitrary user blueprint + axes, formerly the welded r-sma branch). fn dispatch_mc(a: McCmd, env: &project::Env) { // Single-sourced: every arm below reads this one closure (house style, #179). - let usage = || "Usage: aura mc --seeds [--name ] | aura mc --real --axis = [--axis …] --stop-length --stop-k [--block-len ] [--resamples ] [--seed ] [--from ] [--to ]".to_string(); + let usage = || format!("Usage: aura mc --seeds [--name ] | aura mc --real --axis = [--axis …] [--stop-length (default {R_SMA_STOP_LENGTH})] [--stop-k (default {R_SMA_STOP_K:.1})] [--block-len ] [--resamples ] [--seed ] [--from ] [--to ]"); match is_blueprint_file(&a.blueprint) { Some(path) => { let doc = std::fs::read_to_string(path).unwrap_or_else(|e| { @@ -4155,14 +4194,15 @@ mod tests { assert!(err.contains("--name/--trace are not accepted"), "refuse message: {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 mc_args_from_refuses_missing_knobs() { + fn mc_args_from_defaults_missing_knobs_to_the_regime() { let a = McCmd { real: Some("GER40".to_string()), ..bare_mc_cmd() }; - let err = mc_args_from(&a).unwrap_err(); - assert!( - err.contains("requires --stop-length --stop-k"), - "missing-knob refusal: {err}" - ); + let (_, _, stop_length, stop_k, ..) = mc_args_from(&a).expect("stop-less mc 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"); } #[test] diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index eb83eec..50456d9 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -1654,23 +1654,42 @@ fn walkforward_dissolved_refuses_select_plateau() { assert!(stderr.contains("#215"), "forward pointer to the plateau follow-up: {stderr}"); } -/// `walkforward_args_from` requires both stop knobs on the campaign path — a -/// missing knob is a usage refusal (exit 2), not a silent default. +/// Property (#217): `walkforward_args_from` no longer requires both stop knobs +/// together on the campaign path — each defaults independently to the +/// single-sourced regime. Omitting only `--stop-k` while keeping `--stop-length` +/// explicit still succeeds, proving the defaulting is per-flag, not +/// all-or-nothing (the sibling +/// `walkforward_stopless_defaults_the_regime_byte_identically_to_explicit_3_2` +/// pins the both-omitted case). Gated on the shared GER40 archive; skips +/// cleanly on a data refusal. #[test] -fn walkforward_dissolved_refuses_missing_knobs() { - let cwd = temp_cwd("walkforward-missing-knobs"); +fn walkforward_dissolved_defaults_a_single_omitted_knob() { + let cwd = temp_cwd("walkforward-single-knob-default"); let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args([ "walkforward", &fixture, "--real", "GER40", "--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12", + "--stop-length", "3", ]) .current_dir(&cwd) .output() .expect("spawn aura"); - assert_eq!(out.status.code(), Some(2), "missing --stop-length/--stop-k is a usage refusal"); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!(stderr.contains("requires --stop-length --stop-k"), "missing-knob refusal: {stderr}"); + 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 data"); + return; + } + assert_eq!( + out.status.code(), + Some(0), + "omitting only --stop-k must still succeed (it defaults independently): {}", + String::from_utf8_lossy(&out.stderr) + ); } /// An empty `--real ""` still satisfies clap's `Option::is_some()` dispatch guard, so