diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index e0ee704..0c3bbab 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -1397,6 +1397,97 @@ fn sweep_real_is_byte_deterministic_across_runs() { assert_eq!(run(), run(), "same real sweep twice must be byte-identical (C1)"); } +// GER40 Sept-2024 window (inclusive Unix-ms) — the same gated calendar month +// used by `run_real_sidecar_index_pip_reaches_the_emitted_manifest` and the +// campaign-path e2e in `research_docs.rs`. +const GER40_SEPT2024_FROM_MS: &str = "1725148800000"; +const GER40_SEPT2024_TO_MS: &str = "1727740799999"; + +/// Property (docs/specs/sweep-dissolution.md, Testing strategy 1 — the +/// characterization pin ahead of the sweep-dissolution re-cut): today's inline +/// `aura sweep --real SYM --axis …` path (`run_blueprint_sweep`) +/// prints one member line per grid point, IN AXIS ORDER (odometer: the first +/// `--axis` varies slowest), each line JSON with a `family_id` and a `report` +/// whose `manifest.params` carries every swept param binding — and, the +/// load-bearing pin the dissolution cycle must preserve unchanged, **`report.manifest` +/// carries NO `instrument` key at all** (the inline path leaves `RunManifest::instrument` +/// `None`, and `#[serde(skip_serializing_if = "Option::is_none")]` omits the key +/// entirely from the wire form — contrast the campaign substrate's stamped sibling, +/// `campaign_run_real_e2e_sweep_gate_walkforward` in `research_docs.rs`). Also pins +/// that the run persists exactly one new `FamilyKind::Sweep` family with the grid's +/// member count. Gated on the local GER40 archive (the project's skip-on-no-data +/// convention); skips cleanly when absent. +#[test] +fn sweep_real_blueprint_member_lines_pin_the_inline_contract() { + if !local_data_present() { + eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH); + return; + } + let dir = temp_cwd("sweep_real_blueprint"); + let fixture = format!("{}/tests/fixtures/sma_signal_open.json", env!("CARGO_MANIFEST_DIR")); + let out = std::process::Command::new(BIN) + .args([ + "sweep", &fixture, + "--real", "GER40", + "--from", GER40_SEPT2024_FROM_MS, + "--to", GER40_SEPT2024_TO_MS, + "--axis", "sma_signal.fast.length=2,4", + "--axis", "sma_signal.slow.length=8,16", + "--name", "brp", + ]) + .current_dir(&dir) + .output() + .unwrap(); + assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + let lines: Vec<&str> = stdout.lines().collect(); + assert_eq!(lines.len(), 4, "one member line per 2x2 grid point: {stdout}"); + + // Odometer order: `sma_signal.fast.length` (first `--axis`, outer/slowest) + // x `sma_signal.slow.length` (second `--axis`, inner) -> (2,8),(2,16),(4,8),(4,16). + let expected: [[(&str, i64); 2]; 4] = [ + [("sma_signal.fast.length", 2), ("sma_signal.slow.length", 8)], + [("sma_signal.fast.length", 2), ("sma_signal.slow.length", 16)], + [("sma_signal.fast.length", 4), ("sma_signal.slow.length", 8)], + [("sma_signal.fast.length", 4), ("sma_signal.slow.length", 16)], + ]; + + for i in 0..4 { + let line = lines[i]; + let v: serde_json::Value = serde_json::from_str(line).expect("member line parses as JSON"); + assert!(v["family_id"].as_str().is_some(), "member line carries a family_id: {line}"); + let manifest = &v["report"]["manifest"]; + assert!(manifest.is_object(), "report.manifest is present: {line}"); + let params = manifest["params"].as_array().expect("manifest.params is an array"); + for (name, value) in expected[i] { + let bound = params + .iter() + .find(|p| p[0].as_str() == Some(name)) + .and_then(|p| p[1]["I64"].as_i64()); + assert_eq!(bound, Some(value), "manifest carries the swept binding {name}={value}: {line}"); + } + // the load-bearing pin: the inline real-data path never stamps `instrument`. + assert!( + manifest.get("instrument").is_none(), + "the inline blueprint sweep must NOT stamp an instrument key: {line}" + ); + } + + // exactly one new Sweep family persisted, with the grid's four members. + let fams = std::process::Command::new(BIN) + .args(["runs", "families"]) + .current_dir(&dir) + .output() + .unwrap(); + assert!(fams.status.success(), "families exit: {:?}", fams.status); + let fams_out = String::from_utf8_lossy(&fams.stdout).into_owned(); + assert_eq!(fams_out.lines().count(), 1, "exactly one family persisted: {fams_out}"); + assert!(fams_out.contains("\"kind\":\"Sweep\""), "families: {fams_out}"); + assert!(fams_out.contains("\"members\":4"), "2x2 grid -> four members: {fams_out}"); + + let _ = std::fs::remove_dir_all(&dir); +} + /// The synthetic seed-resweep `mc` is undefined over real bars (one realization -> /// identical members), so a bare `aura mc --real EURUSD` — with no R candidate — is /// REJECTED (usage on stderr, exit 2) at the binary boundary. The real path is diff --git a/crates/aura-cli/tests/research_docs.rs b/crates/aura-cli/tests/research_docs.rs index 9dbd553..2750bda 100644 --- a/crates/aura-cli/tests/research_docs.rs +++ b/crates/aura-cli/tests/research_docs.rs @@ -1493,10 +1493,28 @@ fn campaign_run_real_e2e_sweep_gate_walkforward() { ); // Emit-gated lines: per-member family_table lines (4 sweep members plus // the walk-forward OOS members) and at least one selection_report line. + let member_lines: Vec<&str> = out + .lines() + .filter(|l| l.starts_with("{\"family_id\":")) + .collect(); assert!( - out.lines().filter(|l| l.starts_with("{\"family_id\":")).count() >= 4, + member_lines.len() >= 4, "family_table member lines emitted: {out}" ); + // Property (sweep-dissolution.md Goal point 5): the campaign member runner + // (the real `CliMemberRunner` path, `campaign_run.rs`'s + // `report.manifest.instrument = Some(cell.instrument.clone())`) stamps every + // emitted member's manifest with the instrument under test — GER40 here. A + // regression that dropped or mis-stamped the instrument on the campaign path + // would slip past the count-only assertion above but fail here. + for line in &member_lines { + let v: serde_json::Value = serde_json::from_str(line).expect("member line parses as JSON"); + assert_eq!( + v["report"]["manifest"]["instrument"].as_str(), + Some("GER40"), + "campaign member manifest must be stamped with the instrument: {line}" + ); + } assert!( out.lines().any(|l| l.starts_with("{\"selection_report\":")), "selection_report line emitted: {out}"