diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index 4073d1a..eb83eec 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -1859,6 +1859,101 @@ fn walkforward_dissolves_through_the_campaign_path() { ); } +/// Property (#217): on the dissolved `aura walkforward --real` path, +/// `--stop-length`/`--stop-k` are OPTIONAL — a stop-less invocation succeeds, +/// defaulting each omitted knob to the single-sourced default regime (length 3, +/// k 2.0), the very regime the campaign member runner already resolves for an +/// unbound regime (`stop_rule_for_regime`). The default is byte-identical to +/// spelling the knobs out: running the same blueprint in the same store first +/// stop-less, then with an explicit `--stop-length 3 --stop-k 2.0`, leaves +/// EXACTLY ONE generated campaign document — the two documents are byte-identical +/// so content-addressed storage collapses them onto one content id — while each +/// invocation still records its own campaign-run line. This aligns walkforward's +/// knob contract with sweep's (which already defaults the stop) without any +/// content-id churn on the explicit spelling. The sibling verbs mc/generalize gain +/// the same independent per-flag defaulting on the GREEN side. Gated on the shared +/// GER40 archive; skips cleanly on a data refusal. (The multi-value stop is still +/// refused — `walkforward_dissolved_refuses_a_multi_value_stop` — the stop stays a +/// single risk regime; only its *presence* becomes optional.) +#[test] +fn walkforward_stopless_defaults_the_regime_byte_identically_to_explicit_3_2() { + const FROM_MS: &str = "1735689600000"; + const TO_MS: &str = "1767225599000"; + let cwd = temp_cwd("walkforward-stopless-defaults"); + let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); + + // Stop-less: the two knobs are omitted entirely. This is the headline — today it + // is refused at the argv boundary (exit 2, "requires --stop-length --stop-k"); + // the feature makes the knobs optional so this run defaults the regime and runs. + let stopless = 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", + "--from", FROM_MS, "--to", TO_MS, + ]) + .current_dir(&cwd) + .output() + .expect("spawn aura"); + if stopless.status.code() == Some(1) { + let stderr = String::from_utf8_lossy(&stopless.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!( + stopless.status.code(), + Some(0), + "a stop-less walkforward must succeed (the stop knobs are optional, each defaulting \ + to the single-sourced 3/2.0 regime): {}", + String::from_utf8_lossy(&stopless.stderr) + ); + + // Explicit 3/2.0: names the very defaults the stop-less run resolves. Same + // blueprint, same axes, same default `--name` ("walkforward"), same window. + let explicit = 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", "--stop-k", "2.0", + "--from", FROM_MS, "--to", TO_MS, + ]) + .current_dir(&cwd) + .output() + .expect("spawn aura"); + assert_eq!( + explicit.status.code(), + Some(0), + "the explicit --stop-length 3 --stop-k 2.0 walkforward must succeed: {}", + String::from_utf8_lossy(&explicit.stderr) + ); + + // Byte-identity: the default regime IS 3/2.0, so both invocations generate the + // same campaign document and content-addressed storage collapses them onto one + // content id — exactly one campaign document in the store. + let count = |sub: &str| { + std::fs::read_dir(cwd.join("runs").join(sub)) + .map(|d| d.count()) + .unwrap_or(0) + }; + assert_eq!( + count("campaigns"), + 1, + "the stop-less default binds the SAME 3/2.0 regime as the explicit run, so the two \ + campaign documents are byte-identical and dedup to one content id" + ); + let runs_log = std::fs::read_to_string(cwd.join("runs").join("campaign_runs.jsonl")) + .expect("campaign_runs.jsonl exists after two sugar runs"); + assert_eq!( + runs_log.lines().count(), + 2, + "each invocation still records its own campaign run (a run is an event, a document \ + is content)" + ); +} + /// Property: `aura generalize` is now thin sugar over the one campaign path — a /// successful run durably auto-registers exactly one generated process document, /// one generated campaign document (carrying the `--name` handle and the stop as