diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index 865acc1..54d1bbf 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -3637,6 +3637,193 @@ fn generalize_real_e2e_pins_the_exact_current_grade() { assert_eq!(per[1][1].as_f64(), Some(0.005795903617609842), "USDJPY expectancy R: {grade_line}"); } +/// 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 +/// a non-empty single risk regime), and one campaign-run record. This is the +/// observable proof the inline path is gone and the dissolution runs through the +/// executor. Gated on the shared GER40/USDJPY Sept-2024 archive; skips cleanly on +/// a data refusal. +#[test] +fn generalize_dissolves_through_the_campaign_path() { + const FROM_MS: &str = "1725148800000"; + const TO_MS: &str = "1727740799999"; + let cwd = temp_cwd("generalize-dissolves"); + let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args([ + "generalize", "--strategy", "r-sma", "--real", "GER40,USDJPY", + "--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0", + "--from", FROM_MS, "--to", TO_MS, + ]) + .current_dir(&cwd) + .output() + .expect("spawn aura"); + 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/USDJPY data"); + return; + } + assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status); + + let count = |sub: &str| { + std::fs::read_dir(cwd.join("runs").join(sub)) + .map(|d| d.count()) + .unwrap_or(0) + }; + assert_eq!(count("processes"), 1, "one generated process document registered"); + assert_eq!(count("campaigns"), 1, "one generated campaign document registered"); + let runs_log = std::fs::read_to_string(cwd.join("runs").join("campaign_runs.jsonl")) + .expect("campaign_runs.jsonl exists after a sugar run"); + assert_eq!(runs_log.lines().count(), 1, "one campaign run recorded"); + + let campaigns_dir = cwd.join("runs").join("campaigns"); + let campaign_doc_path = std::fs::read_dir(&campaigns_dir) + .expect("campaigns dir exists") + .next() + .expect("exactly one campaign document") + .expect("readable dir entry") + .path(); + let campaign_doc_json = std::fs::read_to_string(&campaign_doc_path).expect("read campaign doc"); + assert!( + campaign_doc_json.contains("\"name\":\"generalize\""), + "the generated campaign document carries the --name handle: {campaign_doc_json}" + ); + // The structural delta vs the sweep translator: the stop is a non-empty + // single risk regime, not empty risk. + assert!( + campaign_doc_json.contains("\"risk\":[") + && campaign_doc_json.contains("\"length\":14") + && campaign_doc_json.contains("\"k\":2.0"), + "the stop rides a non-empty risk regime: {campaign_doc_json}" + ); + + // The campaign path's family set (#210 Q4): exactly one CrossInstrument grade + // family (the generalize result, appended by the sugar) plus one + // per-instrument Sweep family per cell (persisted by the executor). + let fams = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args(["runs", "families"]) + .current_dir(&cwd) + .output() + .expect("spawn families"); + let fams_out = String::from_utf8_lossy(&fams.stdout).into_owned(); + assert_eq!( + fams_out.lines().filter(|l| l.contains("\"kind\":\"CrossInstrument\"")).count(), + 1, + "one CrossInstrument grade family: {fams_out}" + ); + assert_eq!( + fams_out.lines().filter(|l| l.contains("\"kind\":\"Sweep\"")).count(), + 2, + "one per-instrument Sweep family per cell: {fams_out}" + ); +} + +/// Property (#210, dissolution — content-addressed idempotency): re-running the +/// IDENTICAL `aura generalize` invocation in the same project does not litter the +/// store with a second generated process/campaign document — `register_generated_g` +/// writes under a content-id filename, so two runs of the same candidate collapse +/// onto the same one process doc and one campaign doc, while each invocation still +/// records its OWN campaign-run line (a run is an event, a document is content). +/// Were the sugar to salt its generated documents with anything non-deterministic +/// (e.g. a timestamp), this would regress from 1 to 2 stored documents per repeat +/// invocation — the store-litter regression the campaign-path dissolution must not +/// introduce. Gated on the shared GER40/USDJPY Sept-2024 archive; skips cleanly on +/// a data refusal. +#[test] +fn generalize_repeated_identical_invocation_does_not_litter_the_store() { + const FROM_MS: &str = "1725148800000"; + const TO_MS: &str = "1727740799999"; + let cwd = temp_cwd("generalize-repeat-idempotent"); + let invoke = || { + std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args([ + "generalize", "--strategy", "r-sma", "--real", "GER40,USDJPY", + "--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0", + "--from", FROM_MS, "--to", TO_MS, + ]) + .current_dir(&cwd) + .output() + .expect("spawn aura") + }; + let first = invoke(); + if first.status.code() == Some(1) { + let stderr = String::from_utf8_lossy(&first.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/USDJPY data"); + return; + } + assert_eq!(first.status.code(), Some(0), "first run exit: {:?}", first.status); + let second = invoke(); + assert_eq!(second.status.code(), Some(0), "second run exit: {:?}", second.status); + + let count = |sub: &str| { + std::fs::read_dir(cwd.join("runs").join(sub)) + .map(|d| d.count()) + .unwrap_or(0) + }; + assert_eq!(count("processes"), 1, "two identical invocations share one process document"); + assert_eq!(count("campaigns"), 1, "two identical invocations share one campaign document"); + let runs_log = std::fs::read_to_string(cwd.join("runs").join("campaign_runs.jsonl")) + .expect("campaign_runs.jsonl exists"); + assert_eq!(runs_log.lines().count(), 2, "each invocation still records its own campaign run"); +} + +/// Property (#210, dissolution — content-addressed distinctness): the idempotency +/// above is not a bug masquerading as a feature — two DIFFERENT `--fast` +/// invocations (differing candidate content) generate and persist TWO distinct +/// campaign documents (and two distinct process documents, since the `--metric` +/// text is unchanged so the process content matches — pinned via the campaign +/// count only, the axis differs on the campaign, not the process). Together with +/// `generalize_repeated_identical_invocation_does_not_litter_the_store`, this rules +/// out a store that always overwrites one fixed filename regardless of content — +/// the failure mode that would make the idempotency test above pass for the wrong +/// reason. Gated on the shared GER40/USDJPY Sept-2024 archive; skips cleanly on a +/// data refusal. +#[test] +fn generalize_distinct_invocations_persist_distinct_campaign_documents() { + const FROM_MS: &str = "1725148800000"; + const TO_MS: &str = "1727740799999"; + let cwd = temp_cwd("generalize-distinct-content"); + let invoke = |fast: &str| { + std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args([ + "generalize", "--strategy", "r-sma", "--real", "GER40,USDJPY", + "--fast", fast, "--slow", "12", "--stop-length", "14", "--stop-k", "2.0", + "--from", FROM_MS, "--to", TO_MS, + ]) + .current_dir(&cwd) + .output() + .expect("spawn aura") + }; + let first = invoke("3"); + if first.status.code() == Some(1) { + let stderr = String::from_utf8_lossy(&first.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/USDJPY data"); + return; + } + assert_eq!(first.status.code(), Some(0), "first run exit: {:?}", first.status); + let second = invoke("4"); + assert_eq!(second.status.code(), Some(0), "second run exit: {:?}", second.status); + + let count = |sub: &str| { + std::fs::read_dir(cwd.join("runs").join(sub)) + .map(|d| d.count()) + .unwrap_or(0) + }; + assert_eq!(count("campaigns"), 2, "two distinct candidates persist two distinct campaign documents"); +} + /// Property (#210 T3, dissolution): omitting `--from`/`--to` still completes — /// the dispatch rewrite's window-resolution fallback (`dispatch_generalize` in /// main.rs) resolves ONE shared campaign window from the FIRST listed symbol's