43b1c7ff5d
DataSection gains an additive bindings block (role -> column; serde default + skip-if-empty, so binding-less documents keep their content ids — pinned). Validation is two-tier along the existing line: the intrinsic tier checks binding VALUES against the column vocabulary (DocFault::UnknownBindingColumn, alias-annotated display register); the resolver tier checks binding KEYS against the loaded strategies' input_roles() (RefFault::BindingRoleUnknown — a key must name a role of at least one campaign strategy). A cross-surface pin keeps the doc-tier vocabulary and the CLI binding module from drifting. CliMemberRunner threads the campaign overrides into resolve_binding on BOTH halves (column opening and wrap), keeping the C1 drift alarm comparing like-with-like; the verb sugar passes no bindings (name defaults rule). Proof: an archive-gated campaign e2e rebinds price -> open and yields different realized metrics from the close-bound run; refusal prose exact-pinned at both tiers. Verified: full workspace suite green, clippy -D warnings clean; independent quality review, all findings repaired. refs #231
2747 lines
128 KiB
Rust
2747 lines
128 KiB
Rust
//! End-to-end coverage for the `aura process` verb family (#188/#189): drives
|
||
//! the built `aura` binary over argv/file, the exact surface a data-level
|
||
//! author uses.
|
||
|
||
use std::path::{Path, PathBuf};
|
||
use std::sync::{Mutex, MutexGuard, OnceLock};
|
||
|
||
/// A fresh, unique working directory for a process test that persists
|
||
/// content-addressed documents under `./runs/` (so `aura process register`
|
||
/// never dirties the repo). Unique per test + per process; no external
|
||
/// tempfile dependency (mirrors `cli_run.rs` / `cli_broken_pipe.rs`).
|
||
fn temp_cwd(name: &str) -> PathBuf {
|
||
let dir = std::env::temp_dir().join(format!("aura-cli-{}-{}", std::process::id(), name));
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
std::fs::create_dir_all(&dir).expect("create temp cwd");
|
||
dir
|
||
}
|
||
|
||
fn run_code_in(dir: &Path, args: &[&str]) -> (String, Option<i32>) {
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(args)
|
||
.current_dir(dir)
|
||
.output()
|
||
.expect("binary runs");
|
||
let text = format!(
|
||
"{}{}",
|
||
String::from_utf8_lossy(&out.stdout),
|
||
String::from_utf8_lossy(&out.stderr)
|
||
);
|
||
(text, out.status.code())
|
||
}
|
||
|
||
fn run_code(args: &[&str]) -> (String, Option<i32>) {
|
||
run_code_in(Path::new("."), args)
|
||
}
|
||
|
||
const PROCESS_DOC: &str = r#"{
|
||
"format_version": 1,
|
||
"kind": "process",
|
||
"name": "wf-deflated-screen",
|
||
"pipeline": [
|
||
{ "block": "std::sweep", "metric": "net_expectancy_r", "select": "plateau:worst", "deflate": true },
|
||
{ "block": "std::gate", "all": [
|
||
{ "metric": "net_expectancy_r", "cmp": "gt", "value": 0.0 },
|
||
{ "metric": "overfit_probability", "cmp": "lt", "value": 0.1 } ] },
|
||
{ "block": "std::walk_forward", "in_sample_ms": 4000, "out_of_sample_ms": 1000,
|
||
"step_ms": 1000, "mode": "rolling", "metric": "net_expectancy_r", "select": "argmax" }
|
||
]
|
||
}"#;
|
||
|
||
fn write_doc(dir: &Path, name: &str, text: &str) -> PathBuf {
|
||
let p = dir.join(name);
|
||
std::fs::write(&p, text).expect("write doc");
|
||
p
|
||
}
|
||
|
||
#[test]
|
||
fn process_validate_reports_intrinsic_ok() {
|
||
let dir = temp_cwd("process-validate-ok");
|
||
write_doc(&dir, "p.process.json", PROCESS_DOC);
|
||
let (out, code) = run_code_in(&dir, &["process", "validate", "p.process.json"]);
|
||
assert_eq!(code, Some(0), "stdout/stderr: {out}");
|
||
assert!(out.contains("process document valid (intrinsic): 3 pipeline blocks, 2 gate predicates"));
|
||
}
|
||
|
||
#[test]
|
||
fn process_validate_refuses_unknown_metric_as_prose_exit_1() {
|
||
let dir = temp_cwd("process-validate-bad-metric");
|
||
let bad = PROCESS_DOC.replacen(
|
||
"net_expectancy_r\", \"select\": \"plateau:worst",
|
||
"netto_r\", \"select\": \"plateau:worst",
|
||
1,
|
||
);
|
||
write_doc(&dir, "bad.process.json", &bad);
|
||
let (out, code) = run_code_in(&dir, &["process", "validate", "bad.process.json"]);
|
||
assert_eq!(code, Some(1));
|
||
assert!(out.contains("unknown metric \"netto_r\""));
|
||
assert!(!out.contains("UnknownMetric"), "Debug leak: {out}");
|
||
}
|
||
|
||
#[test]
|
||
fn process_validate_refuses_zero_walk_forward_length_as_prose_exit_1() {
|
||
let dir = temp_cwd("process-validate-zero-wf");
|
||
let bad = PROCESS_DOC.replacen("\"step_ms\": 1000", "\"step_ms\": 0", 1);
|
||
write_doc(&dir, "bad.process.json", &bad);
|
||
let (out, code) = run_code_in(&dir, &["process", "validate", "bad.process.json"]);
|
||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||
assert!(out.contains("pipeline[2]: walk_forward step_ms must be > 0"), "stdout/stderr: {out}");
|
||
assert!(!out.contains("ZeroWalkForwardLength"), "Debug leak: {out}");
|
||
}
|
||
|
||
/// A document authored against the vocabulary `std::walk_forward` carried
|
||
/// before cycle 0107 (`folds`/`in_sample_bars`/`out_of_sample_bars`, none of
|
||
/// which the engine's `WindowRoller` accepts) is refused end-to-end through
|
||
/// the CLI's malformed-document path (a parse failure, distinct from the
|
||
/// validate-fault path exercised above) with prose naming the offending
|
||
/// slot — never a Debug-formatted `DocError`, never a silent accept of the
|
||
/// retired shape.
|
||
#[test]
|
||
fn process_validate_refuses_retired_walk_forward_vocabulary_as_prose_exit_1() {
|
||
let dir = temp_cwd("process-validate-retired-wf-vocab");
|
||
let bad = PROCESS_DOC.replacen(
|
||
"{ \"block\": \"std::walk_forward\", \"in_sample_ms\": 4000, \"out_of_sample_ms\": 1000,\n \"step_ms\": 1000, \"mode\": \"rolling\", \"metric\": \"net_expectancy_r\", \"select\": \"argmax\" }",
|
||
"{ \"block\": \"std::walk_forward\", \"folds\": 4, \"in_sample_bars\": 4000, \"out_of_sample_bars\": 1000, \"metric\": \"net_expectancy_r\", \"select\": \"argmax\" }",
|
||
1,
|
||
);
|
||
assert_ne!(bad, PROCESS_DOC, "replacen must actually match the fixture's walk_forward stage");
|
||
write_doc(&dir, "bad.process.json", &bad);
|
||
let (out, code) = run_code_in(&dir, &["process", "validate", "bad.process.json"]);
|
||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||
assert!(out.contains("unknown slot \"folds\""), "stdout/stderr: {out}");
|
||
assert!(!out.contains("Malformed"), "Debug leak: {out}");
|
||
}
|
||
|
||
/// The roller's second `RollMode` — `anchored` (growing, origin-fixed
|
||
/// in-sample) — round-trips through the CLI's actual validate path, not
|
||
/// just the `slot_kind_label` description string: a data author who writes
|
||
/// `"mode": "anchored"` gets a valid document, exercising the `WfMode`
|
||
/// serde oracle's non-default variant end to end.
|
||
#[test]
|
||
fn process_validate_accepts_anchored_walk_forward_mode() {
|
||
let dir = temp_cwd("process-validate-anchored-wf-mode");
|
||
let anchored = PROCESS_DOC.replacen("\"mode\": \"rolling\"", "\"mode\": \"anchored\"", 1);
|
||
assert_ne!(anchored, PROCESS_DOC, "replacen must actually match the fixture's mode field");
|
||
write_doc(&dir, "anchored.process.json", &anchored);
|
||
let (out, code) = run_code_in(&dir, &["process", "validate", "anchored.process.json"]);
|
||
assert_eq!(code, Some(0), "stdout/stderr: {out}");
|
||
assert!(out.contains("process document valid (intrinsic): 3 pipeline blocks, 2 gate predicates"));
|
||
}
|
||
|
||
#[test]
|
||
fn process_introspect_vocabulary_block_and_content_id() {
|
||
let (out, code) = run_code(&["process", "introspect", "--vocabulary"]);
|
||
assert_eq!(code, Some(0));
|
||
for id in ["std::sweep", "std::gate", "std::walk_forward", "std::monte_carlo", "std::generalize"] {
|
||
assert!(out.contains(id), "vocabulary misses {id}: {out}");
|
||
}
|
||
let (out, code) = run_code(&["process", "introspect", "--block", "std::sweep"]);
|
||
assert_eq!(code, Some(0));
|
||
assert!(out.contains("metric"));
|
||
// The selection group (metric/select/deflate) is optional — a sweep with
|
||
// no selection is a terminal, selection-free stage (#210).
|
||
assert!(out.contains("optional"));
|
||
|
||
let (out, code) = run_code(&["process", "introspect", "--block", "std::walk_forward"]);
|
||
assert_eq!(code, Some(0));
|
||
for slot in ["in_sample_ms", "out_of_sample_ms", "step_ms", "mode"] {
|
||
assert!(out.contains(slot), "walk_forward describe misses {slot}: {out}");
|
||
}
|
||
assert!(out.contains("one of: rolling | anchored"), "mode label missing: {out}");
|
||
assert!(!out.contains("folds"), "retired slot still advertised: {out}");
|
||
|
||
let dir = temp_cwd("process-introspect-content-id");
|
||
write_doc(&dir, "p.process.json", PROCESS_DOC);
|
||
let (out, code) = run_code_in(&dir, &["process", "introspect", "--content-id", "p.process.json"]);
|
||
assert_eq!(code, Some(0), "stdout/stderr: {out}");
|
||
// #194: --content-id prints the bare 64-hex id (matching `graph introspect
|
||
// --content-id` and the store filename), no glued `content:` display prefix.
|
||
let line = out.lines().last().expect("id line").trim();
|
||
assert_eq!(line.len(), 64, "bare 64-hex id, no content: prefix: {out}");
|
||
assert!(line.chars().all(|c| c.is_ascii_hexdigit()), "bare hex id: {out}");
|
||
assert!(!out.contains("content:"), "--content-id must not glue the content: prefix: {out}");
|
||
}
|
||
|
||
#[test]
|
||
fn process_introspect_unknown_block_is_prose_exit_1() {
|
||
let (out, code) = run_code(&["process", "introspect", "--block", "std::nope"]);
|
||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||
assert!(out.contains("unknown block \"std::nope\""), "stdout/stderr: {out}");
|
||
}
|
||
|
||
#[test]
|
||
fn process_introspect_unwired_lists_open_slots() {
|
||
let dir = temp_cwd("process-introspect-unwired");
|
||
// std::gate's "all" slot is required (unlike std::sweep's now-optional
|
||
// selection group, #210), so it still exercises the pipeline-level open
|
||
// slot listing alongside the top-level "name" slot.
|
||
let partial = r#"{ "format_version": 1, "kind": "process",
|
||
"pipeline": [ { "block": "std::gate" } ] }"#;
|
||
write_doc(&dir, "partial.process.json", partial);
|
||
let (out, code) = run_code_in(&dir, &["process", "introspect", "--unwired", "partial.process.json"]);
|
||
assert_eq!(code, Some(0));
|
||
assert!(out.contains("open slot: name"));
|
||
assert!(out.contains("open slot: pipeline[0].all"));
|
||
}
|
||
|
||
#[test]
|
||
fn process_introspect_no_flag_is_usage_exit_2() {
|
||
let (_out, code) = run_code(&["process", "introspect"]);
|
||
assert_eq!(code, Some(2));
|
||
}
|
||
|
||
/// #197 (fieldtest 0107 F9): the metric roster is enumerable from the public
|
||
/// surface. `aura process introspect --metrics` lists every name in
|
||
/// `aura_research::metric_vocabulary()` (17), each annotated with where it is
|
||
/// applicable — `rankable` (a sweep/walk_forward `select` metric, from
|
||
/// `aura_campaign::RANKABLE_METRICS`), `gate` (a gate-predicate per-member
|
||
/// scalar, from `aura_campaign::PER_MEMBER_METRICS`), or `annotation` (in
|
||
/// neither roster — a selection annotation that can neither rank nor gate). The
|
||
/// split the executor enforces at preflight (a metric may exist yet not be
|
||
/// rankable, F9) is thereby readable before any trial run. #207 (fieldtest
|
||
/// 0108 F8) adds a fourth tag `generalize` on the rankable R-expectancy family
|
||
/// (`expectancy_r`, `net_expectancy_r`, `sqn`, `sqn_normalized`) — exactly the
|
||
/// names `aura_registry::check_r_metric` accepts, i.e. std::generalize's
|
||
/// cross-instrument accept-set — so an author can read WHICH metrics generalize
|
||
/// before authoring the stage. A rankable non-R metric (`total_pips`) and an
|
||
/// R-denominated-but-unranked metric (`max_r_drawdown`) both stay OUT of it:
|
||
/// the tag tracks `check_r_metric`, not the presence of `_r` in the name.
|
||
/// Substrings are pinned, not exact column widths.
|
||
#[test]
|
||
fn process_introspect_metrics_lists_vocabulary_with_applicability() {
|
||
let (out, code) = run_code(&["process", "introspect", "--metrics"]);
|
||
assert_eq!(code, Some(0), "stdout/stderr: {out}");
|
||
|
||
// One line per vocabulary name, nothing else — the whole 17-name roster.
|
||
let lines: Vec<&str> = out.lines().filter(|l| !l.trim().is_empty()).collect();
|
||
assert_eq!(lines.len(), 17, "one line per metric_vocabulary() name: {out}");
|
||
|
||
// Locate a metric's line by its exact name column (first whitespace token),
|
||
// so `expectancy_r` never matches `net_expectancy_r`'s line.
|
||
let line_for = |name: &str| -> String {
|
||
out.lines()
|
||
.find(|l| l.split_whitespace().next() == Some(name))
|
||
.unwrap_or_else(|| panic!("no line for {name}: {out}"))
|
||
.to_string()
|
||
};
|
||
|
||
// expectancy_r is BOTH rankable and a per-member gate scalar.
|
||
let er = line_for("expectancy_r");
|
||
assert!(er.contains("rankable") && er.contains("gate"), "expectancy_r tags: {er}");
|
||
|
||
// total_pips likewise (a rankable scalar that is also per-member).
|
||
let tp = line_for("total_pips");
|
||
assert!(tp.contains("rankable") && tp.contains("gate"), "total_pips tags: {tp}");
|
||
|
||
// win_rate is per-member (gate) but NOT rankable.
|
||
let wr = line_for("win_rate");
|
||
assert!(wr.contains("gate") && !wr.contains("rankable"), "win_rate tags: {wr}");
|
||
|
||
// deflated_score is a selection annotation — in neither roster.
|
||
let ds = line_for("deflated_score");
|
||
assert!(ds.contains("annotation"), "deflated_score tag: {ds}");
|
||
|
||
// #207: the four rankable R-expectancy names — std::generalize's
|
||
// cross-instrument accept-set (aura_registry::check_r_metric) — carry the
|
||
// `generalize` tag, so an author reads WHICH metrics generalize from the
|
||
// roster instead of guessing at the stage.
|
||
for name in ["expectancy_r", "net_expectancy_r", "sqn", "sqn_normalized"] {
|
||
let l = line_for(name);
|
||
assert!(l.contains("generalize"), "{name} must carry the generalize tag: {l}");
|
||
}
|
||
|
||
// A rankable-but-not-R metric (total_pips) and an R-DENOMINATED-but-unranked
|
||
// metric (max_r_drawdown, a drawdown IN R) both stay out of the generalize
|
||
// set: the tag tracks check_r_metric, never the `_r` in the name.
|
||
assert!(!tp.contains("generalize"), "total_pips must not carry generalize: {tp}");
|
||
let mrd = line_for("max_r_drawdown");
|
||
assert!(!mrd.contains("generalize"), "max_r_drawdown must not carry generalize: {mrd}");
|
||
}
|
||
|
||
/// `--metrics` joins the existing one-mode guard: exactly one introspect mode
|
||
/// is allowed, so `--metrics` alongside another mode refuses with the usage
|
||
/// line (exit 2), and that usage line is extended to advertise `--metrics` as
|
||
/// one of the modes. (The guard's own `Usage: aura process introspect
|
||
/// --vocabulary …` line is asserted — never clap's unknown-arg error, which
|
||
/// spells the flag list as `[OPTIONS]`.)
|
||
#[test]
|
||
fn process_introspect_metrics_plus_another_mode_is_usage_exit_2() {
|
||
let (out, code) = run_code(&["process", "introspect", "--metrics", "--vocabulary"]);
|
||
assert_eq!(code, Some(2), "stdout/stderr: {out}");
|
||
assert!(
|
||
out.contains("Usage: aura process introspect --vocabulary"),
|
||
"the one-mode guard usage line must fire: {out}"
|
||
);
|
||
assert!(out.contains("--metrics"), "the usage line must advertise --metrics: {out}");
|
||
}
|
||
|
||
#[test]
|
||
fn process_register_stores_content_addressed_under_runs_root() {
|
||
let dir = temp_cwd("process-register");
|
||
write_doc(&dir, "p.process.json", PROCESS_DOC);
|
||
let (out, code) = run_code_in(&dir, &["process", "register", "p.process.json"]);
|
||
assert_eq!(code, Some(0), "stdout/stderr: {out}");
|
||
// #194: the register verb prints the bare id — `registered process <id> (<path>)`.
|
||
let line = out.lines().find(|l| l.starts_with("registered process ")).expect("line");
|
||
assert!(!line.contains("content:"), "register line prints the bare id, no content: prefix: {line}");
|
||
let id = line
|
||
.trim_start_matches("registered process ")
|
||
.split(' ')
|
||
.next()
|
||
.expect("id");
|
||
assert!(dir.join("runs").join("processes").join(format!("{id}.json")).is_file());
|
||
}
|
||
|
||
/// The demo-project fixture (Aura.toml present), built once — mirrors
|
||
/// `project_load.rs`'s `built_fixture` (a separate test binary, so it needs
|
||
/// its own `OnceLock`, but `cargo build` is idempotent).
|
||
fn built_project() -> &'static PathBuf {
|
||
static BUILT: OnceLock<PathBuf> = OnceLock::new();
|
||
BUILT.get_or_init(|| {
|
||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/demo-project");
|
||
let out = std::process::Command::new("cargo")
|
||
.arg("build")
|
||
.current_dir(&dir)
|
||
.output()
|
||
.expect("spawn cargo build for the fixture project");
|
||
assert!(
|
||
out.status.success(),
|
||
"fixture build failed:\n{}",
|
||
String::from_utf8_lossy(&out.stderr)
|
||
);
|
||
dir
|
||
})
|
||
}
|
||
|
||
/// Serializes every test that touches the shared demo-project fixture store
|
||
/// (they remove/re-seed `<fixture>/runs`, so parallel test threads would race
|
||
/// on it). A poisoned lock is taken over: one failed test must not cascade
|
||
/// into unrelated lock panics.
|
||
fn project_lock() -> MutexGuard<'static, ()> {
|
||
static LOCK: Mutex<()> = Mutex::new(());
|
||
LOCK.lock().unwrap_or_else(|e| e.into_inner())
|
||
}
|
||
|
||
/// A scratch filesystem entry this test writes under the git-tracked
|
||
/// demo-project fixture root (only `runs/` there is fixture-gitignored),
|
||
/// removed on drop — including during a mid-test panic — so a failed
|
||
/// assertion never leaks scratch docs into tracked working-tree state.
|
||
enum ScratchPath {
|
||
File(PathBuf),
|
||
Dir(PathBuf),
|
||
}
|
||
|
||
struct ScratchGuard(Vec<ScratchPath>);
|
||
|
||
impl Drop for ScratchGuard {
|
||
fn drop(&mut self) {
|
||
for p in &self.0 {
|
||
match p {
|
||
ScratchPath::File(p) => {
|
||
let _ = std::fs::remove_file(p);
|
||
}
|
||
ScratchPath::Dir(p) => {
|
||
let _ = std::fs::remove_dir_all(p);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The `aura campaign validate` referential tier (the campaign family's
|
||
/// headline addition over the intrinsic-only process family), exercised at
|
||
/// the CLI seam inside a real project: a fully-resolved document prints the
|
||
/// "valid (referential)" success line, and an unresolved reference prints
|
||
/// the "do not resolve" fault block through `ref_fault_prose`. The
|
||
/// constituents (`validate_campaign_refs`, `ref_fault_prose`) are unit-
|
||
/// tested elsewhere; this pins that `aura campaign validate` actually wires
|
||
/// them together end to end.
|
||
#[test]
|
||
fn campaign_validate_in_project_reports_referential_tier_end_to_end() {
|
||
let _fixture = project_lock();
|
||
let dir = built_project();
|
||
let runs_dir = dir.join("runs");
|
||
std::fs::remove_dir_all(&runs_dir).ok();
|
||
let _cleanup = ScratchGuard(vec![
|
||
ScratchPath::Dir(runs_dir.clone()),
|
||
ScratchPath::File(dir.join("seed.process.json")),
|
||
ScratchPath::File(dir.join("ref-ok.campaign.json")),
|
||
ScratchPath::File(dir.join("ref-bad.campaign.json")),
|
||
]);
|
||
|
||
// Seed one real, content-addressed, open-param blueprint into the
|
||
// project's own store via a real sweep (mirrors `project_load.rs`'s
|
||
// anchor test).
|
||
let open_bp = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
|
||
let (sweep_out, sweep_code) = run_code_in(
|
||
dir,
|
||
&[
|
||
"sweep",
|
||
&open_bp,
|
||
"--axis",
|
||
"sma_signal.fast.length=2,4",
|
||
"--axis",
|
||
"sma_signal.slow.length=8,16",
|
||
"--name",
|
||
"campaign-ref-seed",
|
||
],
|
||
);
|
||
assert_eq!(sweep_code, Some(0), "seed sweep failed: {sweep_out}");
|
||
let bp_id = std::fs::read_dir(runs_dir.join("blueprints"))
|
||
.expect("blueprints dir")
|
||
.next()
|
||
.expect("one stored blueprint")
|
||
.expect("dir entry")
|
||
.path()
|
||
.file_stem()
|
||
.expect("stem")
|
||
.to_string_lossy()
|
||
.into_owned();
|
||
|
||
// Register a valid AND executable process into the same project store:
|
||
// this test's OK campaign must pass all three validate tiers, and
|
||
// PROCESS_DOC's sweep (deflate: true + plateau:worst) is intrinsically
|
||
// valid but statically refused by the executor preflight (#205's third
|
||
// tier) — so the referential fixture gets an argmax/no-deflate twin.
|
||
let exec_process = PROCESS_DOC
|
||
.replacen(
|
||
"\"select\": \"plateau:worst\", \"deflate\": true",
|
||
"\"select\": \"argmax\", \"deflate\": false",
|
||
1,
|
||
)
|
||
// ... and its gate predicates over the annotation metric
|
||
// overfit_probability (not per-member) flip to a per-member scalar.
|
||
.replacen("\"overfit_probability\", \"cmp\": \"lt\", \"value\": 0.1", "\"win_rate\", \"cmp\": \"lt\", \"value\": 1.1", 1);
|
||
assert_ne!(exec_process, PROCESS_DOC, "replacen must match the sweep stage");
|
||
write_doc(dir, "seed.process.json", &exec_process);
|
||
let (proc_out, proc_code) = run_code_in(dir, &["process", "register", "seed.process.json"]);
|
||
assert_eq!(proc_code, Some(0), "seed process register failed: {proc_out}");
|
||
let proc_id = proc_out
|
||
.lines()
|
||
.find(|l| l.starts_with("registered process "))
|
||
.expect("register line")
|
||
.trim_start_matches("registered process ")
|
||
.split(' ')
|
||
.next()
|
||
.expect("id")
|
||
.trim_start_matches("content:")
|
||
.to_string();
|
||
|
||
// "fast.length": the axis name is the RAW composite's `param_space` name.
|
||
// `validate_campaign_refs` loads the stored blueprint bare, unlike the
|
||
// sweep's `wrap_r`-wrapped axis probe, so it does NOT carry the
|
||
// "sma_signal." prefix `aura sweep --axis` binds against.
|
||
let campaign = format!(
|
||
r#"{{
|
||
"format_version": 1,
|
||
"kind": "campaign",
|
||
"name": "ref-check",
|
||
"data": {{ "instruments": ["GER40"], "windows": [ {{ "from_ms": 1, "to_ms": 2 }} ] }},
|
||
"strategies": [ {{ "ref": {{ "content_id": "{bp}" }},
|
||
"axes": {{ "fast.length": {{ "kind": "I64", "values": [2] }},
|
||
"slow.length": {{ "kind": "I64", "values": [6] }} }} }} ],
|
||
"process": {{ "ref": {{ "content_id": "{proc}" }} }},
|
||
"seed": 1,
|
||
"presentation": {{ "persist_taps": [], "emit": ["family_table"] }}
|
||
}}"#,
|
||
bp = bp_id,
|
||
proc = proc_id
|
||
);
|
||
write_doc(dir, "ref-ok.campaign.json", &campaign);
|
||
let (out, code) = run_code_in(dir, &["campaign", "validate", "ref-ok.campaign.json"]);
|
||
assert_eq!(code, Some(0), "stdout/stderr: {out}");
|
||
assert!(out.contains(
|
||
"campaign document valid (referential): all references resolve, axes are in the param space"
|
||
));
|
||
|
||
// An unresolved process reference: the fault block + `ref_fault_prose`
|
||
// mapping, prose exit 1, at the same CLI seam.
|
||
let bad = campaign.replace(&proc_id, "0000000000000000000000000000000000000000000000000000000000000000");
|
||
write_doc(dir, "ref-bad.campaign.json", &bad);
|
||
let (out2, code2) = run_code_in(dir, &["campaign", "validate", "ref-bad.campaign.json"]);
|
||
assert_eq!(code2, Some(1));
|
||
assert!(out2.contains("campaign references do not resolve:"), "stdout/stderr: {out2}");
|
||
assert!(out2.contains("not found in the project store"), "stdout/stderr: {out2}");
|
||
|
||
// `_cleanup` (a `ScratchGuard`) removes the scratch docs and `runs/` on
|
||
// drop, at the end of this scope — including on a mid-test panic.
|
||
}
|
||
|
||
const CAMPAIGN_DOC: &str = r#"{
|
||
"format_version": 1,
|
||
"kind": "campaign",
|
||
"name": "screen",
|
||
"data": { "instruments": ["GER40"], "windows": [ { "from_ms": 1, "to_ms": 2 } ] },
|
||
"strategies": [ { "ref": { "content_id": "9f3a" },
|
||
"axes": { "fast": { "kind": "I64", "values": [8] } } } ],
|
||
"process": { "ref": { "content_id": "4e2d" } },
|
||
"seed": 1,
|
||
"presentation": { "persist_taps": [], "emit": ["family_table"] }
|
||
}"#;
|
||
|
||
#[test]
|
||
fn campaign_validate_outside_project_skips_referential_tier() {
|
||
let dir = temp_cwd("campaign-validate-outside-project");
|
||
write_doc(&dir, "c.campaign.json", CAMPAIGN_DOC);
|
||
let (out, code) = run_code_in(&dir, &["campaign", "validate", "c.campaign.json"]);
|
||
assert_eq!(code, Some(0), "stdout/stderr: {out}");
|
||
assert!(out.contains("campaign document valid (intrinsic):"));
|
||
assert!(out.contains("referential checks skipped (no Aura.toml found up from "));
|
||
}
|
||
|
||
#[test]
|
||
fn campaign_validate_refuses_empty_axis_prose_exit_1() {
|
||
let dir = temp_cwd("campaign-validate-empty-axis");
|
||
let bad = CAMPAIGN_DOC.replacen(r#""values": [8]"#, r#""values": []"#, 1);
|
||
write_doc(&dir, "bad.campaign.json", &bad);
|
||
let (out, code) = run_code_in(&dir, &["campaign", "validate", "bad.campaign.json"]);
|
||
assert_eq!(code, Some(1));
|
||
assert!(out.contains("axes.fast: an axis is a non-empty finite set"));
|
||
assert!(!out.contains("EmptyAxis"), "Debug leak: {out}");
|
||
}
|
||
|
||
/// #210 risk-regime axis: a campaign document carrying a `risk` section
|
||
/// round-trips through the CLI's actual `campaign validate` binary path
|
||
/// (parse -> `validate_campaign`) without a fault. Before this iteration the
|
||
/// `RiskRegime`/`CampaignDoc.risk` wiring was proven only inside
|
||
/// `aura-research`'s own unit tests; this pins that the new field is actually
|
||
/// reachable — and accepted when well-formed — at the CLI seam a data author
|
||
/// drives, not merely inside the library.
|
||
#[test]
|
||
fn campaign_validate_accepts_a_valid_risk_section_end_to_end() {
|
||
let dir = temp_cwd("campaign-validate-risk-ok");
|
||
let with_risk = CAMPAIGN_DOC.replacen(
|
||
"\"seed\": 1,",
|
||
"\"seed\": 1,\n \"risk\": [ { \"vol\": { \"length\": 3, \"k\": 1.5 } } ],",
|
||
1,
|
||
);
|
||
assert_ne!(with_risk, CAMPAIGN_DOC, "replacen must actually match the fixture's seed field");
|
||
write_doc(&dir, "risk-ok.campaign.json", &with_risk);
|
||
let (out, code) = run_code_in(&dir, &["campaign", "validate", "risk-ok.campaign.json"]);
|
||
assert_eq!(code, Some(0), "stdout/stderr: {out}");
|
||
assert!(out.contains("campaign document valid (intrinsic):"), "stdout/stderr: {out}");
|
||
assert!(!out.contains("BadRegime"), "Debug leak: {out}");
|
||
}
|
||
|
||
/// #210: a non-positive stop regime (`k <= 0`, here `k: 0.0`) is refused by
|
||
/// `validate_campaign`'s `DocFault::BadRegime` and — the property under test —
|
||
/// that fault reaches the CLI's `doc_fault_prose` mapping end to end: exit 1,
|
||
/// prose naming the offending index and BOTH malformed fields ("stop length
|
||
/// must be >= 1 and k must be > 0"), never the bare `BadRegime` Debug variant.
|
||
#[test]
|
||
fn campaign_validate_refuses_bad_risk_regime_prose_exit_1() {
|
||
let dir = temp_cwd("campaign-validate-risk-bad");
|
||
let with_bad_risk = CAMPAIGN_DOC.replacen(
|
||
"\"seed\": 1,",
|
||
"\"seed\": 1,\n \"risk\": [ { \"vol\": { \"length\": 3, \"k\": 0.0 } } ],",
|
||
1,
|
||
);
|
||
assert_ne!(with_bad_risk, CAMPAIGN_DOC, "replacen must actually match the fixture's seed field");
|
||
write_doc(&dir, "risk-bad.campaign.json", &with_bad_risk);
|
||
let (out, code) = run_code_in(&dir, &["campaign", "validate", "risk-bad.campaign.json"]);
|
||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||
assert!(
|
||
out.contains("risk[0]: stop length must be >= 1 and k must be > 0"),
|
||
"stdout/stderr: {out}"
|
||
);
|
||
assert!(!out.contains("BadRegime"), "Debug leak: {out}");
|
||
}
|
||
|
||
/// #216: the intrinsic validate summary reports the realized matrix — the
|
||
/// exec cross-product strategies × instruments × windows × regimes — so an
|
||
/// 8-cell document no longer reads as regime-less. Points (the axis grid) are
|
||
/// deliberately NOT a cell factor: 2 points here, yet 8 cells.
|
||
#[test]
|
||
fn campaign_validate_counts_regimes_and_cells() {
|
||
let dir = temp_cwd("campaign-validate-cells");
|
||
let doc = r#"{
|
||
"format_version": 1,
|
||
"kind": "campaign",
|
||
"name": "screen",
|
||
"data": { "instruments": ["GER40", "FRA40"],
|
||
"windows": [ { "from_ms": 1, "to_ms": 2 }, { "from_ms": 2, "to_ms": 3 } ] },
|
||
"risk": [ { "vol": { "length": 3, "k": 1.5 } }, { "vol": { "length": 3, "k": 3.0 } } ],
|
||
"strategies": [ { "ref": { "content_id": "9f3a" },
|
||
"axes": { "fast": { "kind": "I64", "values": [8, 16] } } } ],
|
||
"process": { "ref": { "content_id": "4e2d" } },
|
||
"seed": 1,
|
||
"presentation": { "persist_taps": [], "emit": ["family_table"] }
|
||
}"#;
|
||
write_doc(&dir, "cells.campaign.json", doc);
|
||
let (out, code) = run_code_in(&dir, &["campaign", "validate", "cells.campaign.json"]);
|
||
assert_eq!(code, Some(0), "stdout/stderr: {out}");
|
||
assert!(
|
||
out.contains("campaign document valid (intrinsic): 1 strategy(ies), 1 axes (2 points), 2 instrument(s), 2 window(s), 2 regime(s) — 8 cell(s)"),
|
||
"stdout/stderr: {out}"
|
||
);
|
||
}
|
||
|
||
/// #216: a risk-less document counts its implicit default regime honestly —
|
||
/// one regime, marked "(default)" — instead of reading as regime-less.
|
||
#[test]
|
||
fn campaign_validate_counts_the_default_regime() {
|
||
let dir = temp_cwd("campaign-validate-default-regime");
|
||
write_doc(&dir, "c.campaign.json", CAMPAIGN_DOC);
|
||
let (out, code) = run_code_in(&dir, &["campaign", "validate", "c.campaign.json"]);
|
||
assert_eq!(code, Some(0), "stdout/stderr: {out}");
|
||
assert!(
|
||
out.contains("campaign document valid (intrinsic): 1 strategy(ies), 1 axes (1 points), 1 instrument(s), 1 window(s), 1 regime(s) (default) — 1 cell(s)"),
|
||
"stdout/stderr: {out}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn campaign_introspect_vocabulary_lists_sections() {
|
||
let (out, code) = run_code(&["campaign", "introspect", "--vocabulary"]);
|
||
assert_eq!(code, Some(0));
|
||
// #216: std::risk joins the roster the CLI's own --vocabulary listing
|
||
// enumerates — the regime axis is discoverable from the public surface,
|
||
// not only from the design ledger or a generated document.
|
||
for id in ["std::data", "std::strategy", "std::process_ref", "std::presentation", "std::risk"] {
|
||
assert!(out.contains(id), "vocabulary misses {id}: {out}");
|
||
}
|
||
}
|
||
|
||
/// #216: `--block std::risk` (the CLI's own introspection, not the
|
||
/// `aura-research` library directly) names the regime shape and its
|
||
/// absent-means-default semantics — the second half of "the risk-regime axis
|
||
/// is undiscoverable from the ... introspection surface" (mirrors the
|
||
/// existing `--block std::presentation` / tap-vocabulary pin).
|
||
#[test]
|
||
fn campaign_introspect_block_risk_describes_the_regime_shape() {
|
||
let (out, code) = run_code(&["campaign", "introspect", "--block", "std::risk"]);
|
||
assert_eq!(code, Some(0), "stdout/stderr: {out}");
|
||
assert!(
|
||
out.contains("list of stop regimes { vol: { length, k } }; absent = one default regime"),
|
||
"the block description must name the regime shape: {out}"
|
||
);
|
||
assert!(
|
||
out.contains("optional, list of stop regimes { vol: { length, k } }; absent = one default regime"),
|
||
"the risk slot must render as optional: {out}"
|
||
);
|
||
assert!(
|
||
out.contains("std::risk — campaign section: the structural risk axis"),
|
||
"stdout/stderr: {out}"
|
||
);
|
||
}
|
||
|
||
/// #216: a complete-but-risk-less campaign document (the exact shape a
|
||
/// verb-generated document has before a `--stop-*` flag is added) has its one
|
||
/// remaining open slot — the optional risk axis — enumerated by the public
|
||
/// `campaign introspect --unwired` CLI surface. Before this iteration the
|
||
/// slot existed only in the `aura-research` library's own unit tests; a
|
||
/// consumer with just the built binary had no way to discover it (#216's
|
||
/// exact complaint: "never lists the risk slot").
|
||
#[test]
|
||
fn campaign_introspect_unwired_lists_the_risk_slot_when_absent() {
|
||
let dir = temp_cwd("campaign-introspect-unwired-risk");
|
||
write_doc(&dir, "c.campaign.json", CAMPAIGN_DOC);
|
||
let (out, code) = run_code_in(&dir, &["campaign", "introspect", "--unwired", "c.campaign.json"]);
|
||
assert_eq!(code, Some(0), "stdout/stderr: {out}");
|
||
assert!(
|
||
out.contains(
|
||
"open slot: risk (optional, list of stop regimes { vol: { length, k } }; absent = one default regime)"
|
||
),
|
||
"stdout/stderr: {out}"
|
||
);
|
||
}
|
||
|
||
/// #216: the bare-envelope probe (`{}` — the issue's exact reproduction)
|
||
/// names the optional risk axis alongside the required envelope slots.
|
||
#[test]
|
||
fn campaign_introspect_unwired_lists_the_optional_risk_slot_on_bare_envelope() {
|
||
let dir = temp_cwd("campaign-introspect-unwired-risk-bare");
|
||
write_doc(&dir, "bare.json", "{}");
|
||
let (out, code) = run_code_in(&dir, &["campaign", "introspect", "--unwired", "bare.json"]);
|
||
assert_eq!(code, Some(0));
|
||
assert!(
|
||
out.contains("open slot: risk (optional, list of stop regimes { vol: { length, k } }; absent = one default regime)"),
|
||
"stdout/stderr: {out}"
|
||
);
|
||
}
|
||
|
||
/// #216: a bound (non-empty) risk list closes the slot — a complete document
|
||
/// with risk reports no open slots at all.
|
||
#[test]
|
||
fn campaign_introspect_unwired_omits_a_bound_risk_slot() {
|
||
let dir = temp_cwd("campaign-introspect-unwired-risk-bound");
|
||
let with_risk = CAMPAIGN_DOC.replacen(
|
||
"\"seed\": 1,",
|
||
"\"seed\": 1,\n \"risk\": [ { \"vol\": { \"length\": 3, \"k\": 2.0 } } ],",
|
||
1,
|
||
);
|
||
assert_ne!(with_risk, CAMPAIGN_DOC, "replacen must actually match the fixture's seed field");
|
||
write_doc(&dir, "bound.campaign.json", &with_risk);
|
||
let (out, code) =
|
||
run_code_in(&dir, &["campaign", "introspect", "--unwired", "bound.campaign.json"]);
|
||
assert_eq!(code, Some(0));
|
||
assert!(!out.contains("open slot: risk"), "bound risk must not list: {out}");
|
||
assert!(out.contains("no open slots"), "stdout/stderr: {out}");
|
||
}
|
||
|
||
/// #216: an EXPLICIT `"risk": []` (key present, array empty) is still an open
|
||
/// slot — same as the key being absent entirely. The probe distinguishes
|
||
/// "bound" from "unbound" by non-emptiness, not by key presence
|
||
/// (`v.get("risk").and_then(|r| r.as_array()).is_some_and(|a| !a.is_empty())`);
|
||
/// a probe that instead branched on `is_none()` would wrongly treat an
|
||
/// explicit empty list as closed, and no other test in this file exercises
|
||
/// that branch (the absent-key and non-empty-array cases are covered above).
|
||
#[test]
|
||
fn campaign_introspect_unwired_lists_the_risk_slot_when_explicitly_empty() {
|
||
let dir = temp_cwd("campaign-introspect-unwired-risk-explicit-empty");
|
||
let explicit_empty = CAMPAIGN_DOC.replacen("\"seed\": 1,", "\"seed\": 1,\n \"risk\": [],", 1);
|
||
assert_ne!(explicit_empty, CAMPAIGN_DOC, "replacen must actually match the fixture's seed field");
|
||
write_doc(&dir, "empty-risk.campaign.json", &explicit_empty);
|
||
let (out, code) =
|
||
run_code_in(&dir, &["campaign", "introspect", "--unwired", "empty-risk.campaign.json"]);
|
||
assert_eq!(code, Some(0), "stdout/stderr: {out}");
|
||
assert!(
|
||
out.contains(
|
||
"open slot: risk (optional, list of stop regimes { vol: { length, k } }; absent = one default regime)"
|
||
),
|
||
"an explicit empty risk array must still count as an open slot: {out}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn campaign_introspect_unwired_reports_the_spec_example_slots() {
|
||
let dir = temp_cwd("campaign-introspect-unwired");
|
||
let draft = r#"{ "format_version": 1, "kind": "campaign", "name": "draft",
|
||
"data": { "instruments": ["GER40"], "windows": [ { "from_ms": 1, "to_ms": 2 } ] },
|
||
"strategies": [ { "ref": { "content_id": "9f3a" },
|
||
"axes": { "slow": { "kind": "I64", "values": [] } } } ],
|
||
"seed": 1,
|
||
"presentation": { "persist_taps": [], "emit": [] } }"#;
|
||
write_doc(&dir, "draft.campaign.json", draft);
|
||
let (out, code) = run_code_in(&dir, &["campaign", "introspect", "--unwired", "draft.campaign.json"]);
|
||
assert_eq!(code, Some(0));
|
||
assert!(out.contains("open slot: process.ref (required, content id of a process document)"));
|
||
assert!(out.contains("open slot: strategies[0].axes.slow (axis declared empty — a P1 axis is a non-empty finite set)"));
|
||
}
|
||
|
||
/// #195 (fieldtest 0106 F4): the consumer view of the placeholder-ref probe.
|
||
/// A draft that spells its open refs as `"ref": {}` / `"ref": null` — the
|
||
/// natural drafting placeholders — has `--unwired` enumerate BOTH as open slots
|
||
/// (exit 0), instead of the old cryptic serde refusal / silent "no open slots".
|
||
#[test]
|
||
fn campaign_introspect_unwired_reports_placeholder_refs() {
|
||
let dir = temp_cwd("campaign-introspect-unwired-placeholder");
|
||
let draft = r#"{ "format_version": 1, "kind": "campaign", "name": "draft",
|
||
"data": { "instruments": ["GER40"], "windows": [ { "from_ms": 1, "to_ms": 2 } ] },
|
||
"strategies": [ { "ref": null,
|
||
"axes": { "slow": { "kind": "I64", "values": [10, 20] } } } ],
|
||
"process": { "ref": {} },
|
||
"seed": 1,
|
||
"presentation": { "persist_taps": [], "emit": [] } }"#;
|
||
write_doc(&dir, "draft.campaign.json", draft);
|
||
let (out, code) = run_code_in(&dir, &["campaign", "introspect", "--unwired", "draft.campaign.json"]);
|
||
assert_eq!(code, Some(0), "stdout/stderr: {out}");
|
||
assert!(
|
||
out.contains("open slot: strategies[0].ref (required, one of: content_id, identity_id)"),
|
||
"strategy `ref: null` placeholder not enumerated: {out}"
|
||
);
|
||
assert!(
|
||
out.contains("open slot: process.ref (required, content id of a process document)"),
|
||
"process `ref: {{}}` placeholder not enumerated: {out}"
|
||
);
|
||
}
|
||
|
||
/// The intrinsic tier value-checks `data.bindings` (no project, no store,
|
||
/// #231): a value outside the closed column vocabulary refuses
|
||
/// path-addressed, naming the vocabulary. NOT gated.
|
||
#[test]
|
||
fn campaign_validate_refuses_an_unknown_binding_column() {
|
||
let dir = temp_cwd("campaign-validate-bad-binding");
|
||
let doc = r#"{ "format_version": 1, "kind": "campaign", "name": "bad-binding",
|
||
"data": { "bindings": { "price": "hl2" },
|
||
"instruments": ["GER40"], "windows": [ { "from_ms": 1, "to_ms": 2 } ] },
|
||
"strategies": [ { "ref": { "content_id": "9f3a" },
|
||
"axes": { "slow": { "kind": "I64", "values": [10] } } } ],
|
||
"process": { "ref": { "content_id": "9f3a" } },
|
||
"seed": 1,
|
||
"presentation": { "persist_taps": [], "emit": [] } }"#;
|
||
write_doc(&dir, "bad.campaign.json", doc);
|
||
let (out, code) = run_code_in(&dir, &["campaign", "validate", "bad.campaign.json"]);
|
||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||
assert!(
|
||
out.contains(r#"data.bindings.price: "hl2" names no archive column"#),
|
||
"the refusal is path-addressed and names the vocabulary: {out}"
|
||
);
|
||
}
|
||
|
||
/// The campaign twin of `process_introspect_vocabulary_block_and_content_id`'s
|
||
/// `--content-id` branch: pins that `campaign introspect --content-id` wires
|
||
/// `parse_valid_campaign` + `campaign_to_json` + `content_id_of` end to end
|
||
/// (the process branch was covered; this one was not).
|
||
#[test]
|
||
fn campaign_introspect_content_id_prints_hash() {
|
||
let dir = temp_cwd("campaign-introspect-content-id");
|
||
write_doc(&dir, "c.campaign.json", CAMPAIGN_DOC);
|
||
let (out, code) = run_code_in(&dir, &["campaign", "introspect", "--content-id", "c.campaign.json"]);
|
||
assert_eq!(code, Some(0), "stdout/stderr: {out}");
|
||
// #194: --content-id prints the bare 64-hex id, no glued `content:` prefix.
|
||
let line = out.lines().last().expect("id line").trim();
|
||
assert_eq!(line.len(), 64, "bare 64-hex id, no content: prefix: {out}");
|
||
assert!(line.chars().all(|c| c.is_ascii_hexdigit()), "bare hex id: {out}");
|
||
assert!(!out.contains("content:"), "--content-id must not glue the content: prefix: {out}");
|
||
}
|
||
|
||
#[test]
|
||
fn campaign_register_stores_content_addressed_under_runs_root() {
|
||
let dir = temp_cwd("campaign-register");
|
||
write_doc(&dir, "c.campaign.json", CAMPAIGN_DOC);
|
||
let (out, code) = run_code_in(&dir, &["campaign", "register", "c.campaign.json"]);
|
||
assert_eq!(code, Some(0), "stdout/stderr: {out}");
|
||
// #194: the register verb prints the bare id — `registered campaign <id> (<path>)`.
|
||
let line = out.lines().find(|l| l.starts_with("registered campaign ")).expect("line");
|
||
assert!(!line.contains("content:"), "register line prints the bare id, no content: prefix: {line}");
|
||
let id = line
|
||
.trim_start_matches("registered campaign ")
|
||
.split(' ')
|
||
.next()
|
||
.expect("id");
|
||
assert!(dir.join("runs").join("campaigns").join(format!("{id}.json")).is_file());
|
||
}
|
||
|
||
/// `guard_one_mode` is shared, family-parameterized code (`process` and
|
||
/// `campaign` both route through it). The process family already pins the
|
||
/// zero-flags arm; this pins that the campaign family gets its own family
|
||
/// name threaded into the usage line, not a copy-pasted "process" string.
|
||
#[test]
|
||
fn campaign_introspect_no_flag_usage_names_the_campaign_family() {
|
||
let (out, code) = run_code(&["campaign", "introspect"]);
|
||
assert_eq!(code, Some(2), "stdout/stderr: {out}");
|
||
assert!(
|
||
out.contains("Usage: aura campaign introspect --vocabulary | --block <ID> | --unwired <FILE> | --content-id <FILE>"),
|
||
"stdout/stderr: {out}"
|
||
);
|
||
}
|
||
|
||
/// `guard_one_mode` requires *exactly* one mode: the zero-flags arm is
|
||
/// pinned elsewhere, but nothing previously exercised the over-selection
|
||
/// arm (`selected_modes() != 1` is not just `== 0`). Two modes at once must
|
||
/// refuse with the same usage-exit-2 idiom, not silently pick one.
|
||
#[test]
|
||
fn campaign_introspect_two_flags_is_usage_exit_2() {
|
||
let (out, code) = run_code(&["campaign", "introspect", "--vocabulary", "--block", "std::data"]);
|
||
assert_eq!(code, Some(2), "stdout/stderr: {out}");
|
||
assert!(out.contains("Usage: aura campaign introspect"), "stdout/stderr: {out}");
|
||
}
|
||
|
||
/// #197: the metrics roster is process-side R-vocabulary, but `--metrics` rides
|
||
/// the shared `DocIntrospectCmd` both families route through, so `aura campaign
|
||
/// introspect --metrics` WORKS too rather than usage-refusing. Ground for the
|
||
/// pin: a mode the shared struct carries needs no per-family exclusion, and the
|
||
/// vocabulary a campaign's referenced process must name is thus discoverable
|
||
/// from the campaign verb as well (simplest honest reading). Same annotated
|
||
/// roster as the process family.
|
||
#[test]
|
||
fn campaign_introspect_metrics_also_lists_the_vocabulary() {
|
||
let (out, code) = run_code(&["campaign", "introspect", "--metrics"]);
|
||
assert_eq!(code, Some(0), "stdout/stderr: {out}");
|
||
let er = out
|
||
.lines()
|
||
.find(|l| l.split_whitespace().next() == Some("expectancy_r"))
|
||
.unwrap_or_else(|| panic!("no expectancy_r line: {out}"));
|
||
assert!(er.contains("rankable") && er.contains("gate"), "expectancy_r tags: {er}");
|
||
assert!(
|
||
out.lines()
|
||
.any(|l| l.split_whitespace().next() == Some("deflated_score") && l.contains("annotation")),
|
||
"the annotation roster is reachable from the campaign family too: {out}"
|
||
);
|
||
}
|
||
|
||
/// 0109 task 1 (#201 decision 1): the closed tap vocabulary is discoverable
|
||
/// through the actual CLI binary, not just the `aura-research` library —
|
||
/// `campaign introspect --block std::presentation` composes
|
||
/// `describe_block` + `slot_kind_label` over the new `SlotKind::TapKinds`,
|
||
/// and this pins that composition reaches stdout with the exact four names,
|
||
/// so a future edit to `tap_vocabulary()` that forgets the CLI-visible label
|
||
/// (there is no other CLI test exercising `--block std::presentation`) fails
|
||
/// here instead of only in a library-level unit test.
|
||
#[test]
|
||
fn campaign_introspect_block_presentation_names_the_tap_vocabulary() {
|
||
let (out, code) = run_code(&["campaign", "introspect", "--block", "std::presentation"]);
|
||
assert_eq!(code, Some(0), "stdout/stderr: {out}");
|
||
assert!(
|
||
out.contains("list of: equity | exposure | r_equity | net_r_equity"),
|
||
"the block description must name the closed tap vocabulary: {out}"
|
||
);
|
||
assert!(out.contains("persist_taps"), "stdout/stderr: {out}");
|
||
}
|
||
|
||
/// #193 (narrowed scope, cycle-0107 F3): the whole document envelope IS
|
||
/// derivable — `introspect --unwired` over a bare `{}` enumerates every
|
||
/// top-level field (seed, data, strategies, process.ref, presentation) as an
|
||
/// open slot — but nothing pointed the author there, so the 0106 round
|
||
/// iterated blind `validate` errors instead of starting from the skeleton.
|
||
/// The shared `--unwired` help line now carries the skeleton hint naming that
|
||
/// starting move, making it discoverable from `--help` alone (the process and
|
||
/// campaign families share `DocIntrospectCmd`, so pinning one covers both).
|
||
/// Whitespace is normalized before matching so clap's help-column wrapping
|
||
/// (terminal-width dependent) can never split the pinned phrase.
|
||
#[test]
|
||
fn introspect_help_hints_the_bare_envelope_probe() {
|
||
let (out, code) = run_code(&["campaign", "introspect", "--help"]);
|
||
assert_eq!(code, Some(0), "clap --help exits 0: {out}");
|
||
let flat = out.split_whitespace().collect::<Vec<_>>().join(" ");
|
||
assert!(
|
||
flat.contains("start from a bare {}"),
|
||
"the --unwired help line must point at the bare-{{}} envelope probe: {out}"
|
||
);
|
||
}
|
||
|
||
/// #193 (narrowed scope, cycle-0106 F1): a document whose `kind` key carries
|
||
/// the wrong value — the blind-author trap of pointing `process validate` at
|
||
/// a campaign-kind file — is refused with prose that NAMES the `kind` key the
|
||
/// author must fix ("the \"kind\" key must be \"process\""), instead of the
|
||
/// opaque "wrong document kind" that never told them WHICH field to change.
|
||
/// Autonomous: a minimal campaign-kind envelope written inline, refused at the
|
||
/// envelope check before any deserialization (exit 1), Debug-free.
|
||
#[test]
|
||
fn wrong_kind_refusal_names_the_kind_key() {
|
||
let dir = temp_cwd("wrong-kind-names-key");
|
||
write_doc(
|
||
&dir,
|
||
"mislabeled.process.json",
|
||
r#"{ "format_version": 1, "kind": "campaign", "name": "x", "pipeline": [] }"#,
|
||
);
|
||
let (out, code) = run_code_in(&dir, &["process", "validate", "mislabeled.process.json"]);
|
||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||
assert!(
|
||
out.contains("the \"kind\" key must be \"process\""),
|
||
"the refusal must name the kind key the author must fix: {out}"
|
||
);
|
||
assert!(!out.contains("WrongKind"), "Debug leak: {out}");
|
||
}
|
||
|
||
/// Register must be a gate, not a passthrough: an invalid document is
|
||
/// refused with prose (exit 1) and — the property that matters for a
|
||
/// content-addressed store — no file is ever written under `runs/campaigns/`
|
||
/// for it. A store that could contain unvalidated documents would make
|
||
/// every downstream reader re-derive the checks register was supposed to
|
||
/// have already made.
|
||
#[test]
|
||
fn campaign_register_refuses_invalid_document_and_writes_nothing() {
|
||
let dir = temp_cwd("campaign-register-invalid");
|
||
let bad = CAMPAIGN_DOC.replacen(r#""values": [8]"#, r#""values": []"#, 1);
|
||
write_doc(&dir, "bad.campaign.json", &bad);
|
||
let (out, code) = run_code_in(&dir, &["campaign", "register", "bad.campaign.json"]);
|
||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||
assert!(out.contains("refusing to register:"), "stdout/stderr: {out}");
|
||
assert!(out.contains("axes.fast: an axis is a non-empty finite set"), "stdout/stderr: {out}");
|
||
assert!(
|
||
!dir.join("runs").join("campaigns").exists(),
|
||
"register must not create a store entry for an invalid document"
|
||
);
|
||
}
|
||
|
||
/// #210: the register validate-gate extends to the new risk axis — a
|
||
/// document whose only fault is a non-positive stop regime is refused with
|
||
/// prose (exit 1) and, the property that matters for a content-addressed
|
||
/// store, no file is ever written under `runs/campaigns/`. A store that
|
||
/// accepted a document because register only checked the OLD fault kinds
|
||
/// would silently persist an unenforceable risk regime.
|
||
#[test]
|
||
fn campaign_register_refuses_invalid_risk_section_and_writes_nothing() {
|
||
let dir = temp_cwd("campaign-register-invalid-risk");
|
||
let bad = CAMPAIGN_DOC.replacen(
|
||
"\"seed\": 1,",
|
||
"\"seed\": 1,\n \"risk\": [ { \"vol\": { \"length\": 0, \"k\": 2.0 } } ],",
|
||
1,
|
||
);
|
||
assert_ne!(bad, CAMPAIGN_DOC, "replacen must actually match the fixture's seed field");
|
||
write_doc(&dir, "bad-risk.campaign.json", &bad);
|
||
let (out, code) = run_code_in(&dir, &["campaign", "register", "bad-risk.campaign.json"]);
|
||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||
assert!(out.contains("refusing to register:"), "stdout/stderr: {out}");
|
||
assert!(
|
||
out.contains("risk[0]: stop length must be >= 1 and k must be > 0"),
|
||
"stdout/stderr: {out}"
|
||
);
|
||
assert!(
|
||
!dir.join("runs").join("campaigns").exists(),
|
||
"register must not create a store entry for an invalid risk section"
|
||
);
|
||
}
|
||
|
||
/// Seed one open-param blueprint into the built demo project's store via a
|
||
/// real sweep and return its content id (the referential test's recipe).
|
||
fn seed_blueprint(dir: &Path, name: &str) -> String {
|
||
let open_bp = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
|
||
let (out, code) = run_code_in(
|
||
dir,
|
||
&[
|
||
"sweep",
|
||
&open_bp,
|
||
"--axis",
|
||
"sma_signal.fast.length=2,4",
|
||
"--axis",
|
||
"sma_signal.slow.length=8,16",
|
||
"--name",
|
||
name,
|
||
],
|
||
);
|
||
assert_eq!(code, Some(0), "seed sweep failed: {out}");
|
||
std::fs::read_dir(dir.join("runs").join("blueprints"))
|
||
.expect("blueprints dir")
|
||
.next()
|
||
.expect("one stored blueprint")
|
||
.expect("dir entry")
|
||
.path()
|
||
.file_stem()
|
||
.expect("stem")
|
||
.to_string_lossy()
|
||
.into_owned()
|
||
}
|
||
|
||
/// Register `doc` as a process document in the project store; returns its id.
|
||
/// Asserts register exits 0 — an intrinsically valid document (annotator
|
||
/// stages in any order included) must always register; only `campaign run`'s
|
||
/// preflight draws the executable-shape line.
|
||
fn register_process_doc(dir: &Path, file: &str, doc: &str) -> String {
|
||
write_doc(dir, file, doc);
|
||
let (out, code) = run_code_in(dir, &["process", "register", file]);
|
||
assert_eq!(code, Some(0), "process register failed: {out}");
|
||
out.lines()
|
||
.find(|l| l.starts_with("registered process "))
|
||
.expect("register line")
|
||
.trim_start_matches("registered process ")
|
||
.split(' ')
|
||
.next()
|
||
.expect("id")
|
||
.trim_start_matches("content:")
|
||
.to_string()
|
||
}
|
||
|
||
/// A referentially-resolving campaign over the seeded blueprint. Axes name
|
||
/// the RAW `param_space` names (`fast.length` / `slow.length` — see the
|
||
/// naming note in the referential test above). `persist_taps`/`emit` are
|
||
/// spliced verbatim (pass `""` for empty, `"\"family_table\""` etc.).
|
||
fn campaign_doc_json(
|
||
bp_id: &str,
|
||
proc_id: &str,
|
||
window: (i64, i64),
|
||
persist_taps: &str,
|
||
emit: &str,
|
||
) -> String {
|
||
format!(
|
||
r#"{{
|
||
"format_version": 1,
|
||
"kind": "campaign",
|
||
"name": "run-seam",
|
||
"data": {{ "instruments": ["GER40"], "windows": [ {{ "from_ms": {from}, "to_ms": {to} }} ] }},
|
||
"strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }},
|
||
"axes": {{ "fast.length": {{ "kind": "I64", "values": [2, 4] }},
|
||
"slow.length": {{ "kind": "I64", "values": [8, 16] }} }} }} ],
|
||
"process": {{ "ref": {{ "content_id": "{proc_id}" }} }},
|
||
"seed": 7,
|
||
"presentation": {{ "persist_taps": [{persist_taps}], "emit": [{emit}] }}
|
||
}}"#,
|
||
from = window.0,
|
||
to = window.1,
|
||
)
|
||
}
|
||
|
||
/// An mc-bearing process: intrinsically valid AND (since the v2 executor) an
|
||
/// executable pipeline shape — `sweep -> monte_carlo` annotates each sweep
|
||
/// survivor. The two addressing-mode tests below run it over the [1, 2] 1970
|
||
/// window, so they refuse deterministically at the member-data seam.
|
||
const MC_PROCESS_DOC: &str = r#"{
|
||
"format_version": 1,
|
||
"kind": "process",
|
||
"name": "mc-screen",
|
||
"pipeline": [
|
||
{ "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" },
|
||
{ "block": "std::monte_carlo", "resamples": 100, "block_len": 5 }
|
||
]
|
||
}"#;
|
||
|
||
/// Intrinsically valid but NOT an executable v2 shape: the annotator sits
|
||
/// before the population stage (`walk_forward` after `monte_carlo`). The
|
||
/// intrinsic tier deliberately permits this order (the shipped
|
||
/// `validate_process_permits_walk_forward_after_an_annotator` pin in
|
||
/// aura-research), so register accepts it; only `campaign run`'s preflight
|
||
/// refuses it.
|
||
const MC_BEFORE_WF_PROCESS_DOC: &str = r#"{
|
||
"format_version": 1,
|
||
"kind": "process",
|
||
"name": "mc-before-wf",
|
||
"pipeline": [
|
||
{ "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" },
|
||
{ "block": "std::monte_carlo", "resamples": 100, "block_len": 5 },
|
||
{ "block": "std::walk_forward", "in_sample_ms": 1209600000, "out_of_sample_ms": 604800000,
|
||
"step_ms": 604800000, "mode": "rolling", "metric": "sqn_normalized", "select": "argmax" }
|
||
]
|
||
}"#;
|
||
|
||
/// An executable generalize-bearing shape (`sweep -> generalize`). The
|
||
/// refusal under test is the campaign-side static guard (std::generalize
|
||
/// needs >= 2 instruments), not the pipeline shape.
|
||
const GENERALIZE_PROCESS_DOC: &str = r#"{
|
||
"format_version": 1,
|
||
"kind": "process",
|
||
"name": "gen-screen",
|
||
"pipeline": [
|
||
{ "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" },
|
||
{ "block": "std::generalize", "metric": "net_expectancy_r" }
|
||
]
|
||
}"#;
|
||
|
||
/// The minimal executable pipeline (one sweep stage).
|
||
const SWEEP_ONLY_PROCESS_DOC: &str = r#"{
|
||
"format_version": 1,
|
||
"kind": "process",
|
||
"name": "sweep-only",
|
||
"pipeline": [ { "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" } ]
|
||
}"#;
|
||
|
||
/// The full annotated shape for the gated e2e: sweep -> gate -> walk_forward
|
||
/// -> monte_carlo. The gate (`n_trades ge 0`) passes every member, so
|
||
/// walk-forward always has survivors; the mc annotator bootstraps the pooled
|
||
/// per-window OOS trade-R series (200 resamples, block 5). Roller: 14d IS /
|
||
/// 7d OOS / 7d step in epoch-ms, tiling the ~30-day GER40 Sept-2024 campaign
|
||
/// window. NO generalize stage: this campaign has ONE instrument (a second
|
||
/// local archive is not guaranteed), and the single-instrument refusal is
|
||
/// pinned data-free in `campaign_run_refuses_single_instrument_generalize`.
|
||
const WF_PROCESS_DOC: &str = r#"{
|
||
"format_version": 1,
|
||
"kind": "process",
|
||
"name": "screen-then-walkforward",
|
||
"pipeline": [
|
||
{ "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" },
|
||
{ "block": "std::gate", "all": [ { "metric": "n_trades", "cmp": "ge", "value": 0.0 } ] },
|
||
{ "block": "std::walk_forward", "in_sample_ms": 1209600000, "out_of_sample_ms": 604800000,
|
||
"step_ms": 604800000, "mode": "rolling", "metric": "net_expectancy_r", "select": "argmax" },
|
||
{ "block": "std::monte_carlo", "resamples": 200, "block_len": 5 }
|
||
]
|
||
}"#;
|
||
|
||
/// `campaign run` outside a project refuses up front — before target
|
||
/// resolution, so not even the file-sugar registration touches a store.
|
||
#[test]
|
||
fn campaign_run_outside_project_refuses() {
|
||
let dir = temp_cwd("campaign-run-outside-project");
|
||
write_doc(&dir, "c.campaign.json", CAMPAIGN_DOC);
|
||
let (out, code) = run_code_in(&dir, &["campaign", "run", "c.campaign.json"]);
|
||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||
assert!(out.contains("campaign run needs a project"), "stdout/stderr: {out}");
|
||
assert!(
|
||
!dir.join("runs").exists(),
|
||
"a refused run must not create a store outside a project"
|
||
);
|
||
}
|
||
|
||
/// A target that is neither a readable file nor a 64-hex id refuses naming
|
||
/// both readings (inside the project, past the project gate).
|
||
#[test]
|
||
fn campaign_run_bogus_target_refuses() {
|
||
let _fixture = project_lock();
|
||
let dir = built_project();
|
||
let (out, code) = run_code_in(dir, &["campaign", "run", "no-such-target"]);
|
||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||
assert!(
|
||
out.contains("'no-such-target' is neither a readable .json file nor a 64-hex content id"),
|
||
"stdout/stderr: {out}"
|
||
);
|
||
}
|
||
|
||
/// A well-formed but unknown content id refuses with the store-miss prose.
|
||
#[test]
|
||
fn campaign_run_unknown_id_refuses() {
|
||
let _fixture = project_lock();
|
||
let dir = built_project();
|
||
let id = "0".repeat(64);
|
||
let (out, code) = run_code_in(dir, &["campaign", "run", &id]);
|
||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||
assert!(
|
||
out.contains(&format!("no campaign {id} in the project store")),
|
||
"stdout/stderr: {out}"
|
||
);
|
||
}
|
||
|
||
/// Property: the recorded campaign realizations are READABLE BACK. A read-back
|
||
/// verb (`aura campaign runs`) both lists every stored campaign-run record on a
|
||
/// scannable line carrying its campaign id, and — addressed by that id — dumps
|
||
/// the BARE persisted store record (the `campaign_runs.jsonl` line form), NOT
|
||
/// the `{"campaign_run": …}` stdout wrapper the `campaign run` emit uses (F10,
|
||
/// fieldtest 0108). Today `runs` is an unrecognized campaign subcommand (clap
|
||
/// exit 2) — the feature is absent.
|
||
///
|
||
/// Hermetic seed: one hand-written record line in the exact bare store form
|
||
/// serde emits (mirroring the aura-registry pre-widening parse line —
|
||
/// canonical field order, sparse-skip fields omitted — so a re-serialized dump
|
||
/// is byte-identical). Appending a real record needs a full campaign run over
|
||
/// data; hand-writing the store line is the only data-free path and pins the
|
||
/// bare-store-line contract exactly.
|
||
#[test]
|
||
fn campaign_runs_lists_and_dumps_the_bare_stored_record() {
|
||
let _fixture = project_lock();
|
||
let dir = built_project();
|
||
let runs_dir = dir.join("runs");
|
||
std::fs::remove_dir_all(&runs_dir).ok();
|
||
std::fs::create_dir_all(&runs_dir).expect("create runs dir");
|
||
let _cleanup = ScratchGuard(vec![ScratchPath::Dir(runs_dir.clone())]);
|
||
|
||
let campaign_id = "deadbeef".repeat(8); // a 64-hex content id
|
||
let record_line = format!(
|
||
r#"{{"campaign":"{campaign_id}","process":"beef","run":0,"seed":42,"cells":[{{"strategy":"3f9c","instrument":"GER40","window_ms":[1725148800000,1727740799999],"stages":[{{"block":"std::sweep","family_id":"sweep-0"}},{{"block":"std::gate","survivor_ordinals":[0,1,2,3]}}]}}]}}"#
|
||
);
|
||
std::fs::write(runs_dir.join("campaign_runs.jsonl"), format!("{record_line}\n"))
|
||
.expect("seed the campaign-run store");
|
||
|
||
// LIST mode: one scannable line per stored record, carrying the campaign
|
||
// id (bare 64-hex or a prefix), exit 0.
|
||
let (list_out, list_code) = run_code_in(dir, &["campaign", "runs"]);
|
||
assert_eq!(list_code, Some(0), "list exits 0: {list_out}");
|
||
assert!(
|
||
list_out.contains(&campaign_id[..8]),
|
||
"the list line carries the campaign id (bare or prefix): {list_out}"
|
||
);
|
||
|
||
// DUMP mode: addressed by the campaign content id, prints the BARE stored
|
||
// record line — the store form, NOT the `{{"campaign_run": …}}` run-emit
|
||
// wrapper (F10) — byte-identical to the seeded line, exit 0.
|
||
let (dump_out, dump_code) = run_code_in(dir, &["campaign", "runs", &campaign_id]);
|
||
assert_eq!(dump_code, Some(0), "dump exits 0: {dump_out}");
|
||
assert!(
|
||
!dump_out.contains("\"campaign_run\""),
|
||
"dump prints the bare store record, not the run-emit wrapper (F10): {dump_out}"
|
||
);
|
||
assert_eq!(
|
||
dump_out.trim(),
|
||
record_line,
|
||
"dump is byte-identical to the bare stored line (F10): {dump_out}"
|
||
);
|
||
}
|
||
|
||
/// 0109 task 2: `campaign runs <id>` dumps by deserializing the stored line
|
||
/// into `CampaignRunRecord` and re-serializing it (`research_docs.rs`'s
|
||
/// `campaign_runs` — "serde round-trip == the stored bytes"), so the
|
||
/// byte-identical contract `campaign_runs_lists_and_dumps_the_bare_stored_record`
|
||
/// pins above is only proven for a record where `trace_name` is absent
|
||
/// (pre-0109 shape). This is the same round-trip pinned with the new field
|
||
/// POPULATED: a stored line carrying `"trace_name":"deadbeef-3"` must dump
|
||
/// back byte-identical — the new field's declaration position (after
|
||
/// `generalizations`) and its `skip_serializing_if` must not perturb the
|
||
/// existing sparse-skip / declaration-order contract the read-back verb
|
||
/// depends on.
|
||
#[test]
|
||
fn campaign_runs_dump_round_trips_a_populated_trace_name() {
|
||
let _fixture = project_lock();
|
||
let dir = built_project();
|
||
let runs_dir = dir.join("runs");
|
||
std::fs::remove_dir_all(&runs_dir).ok();
|
||
std::fs::create_dir_all(&runs_dir).expect("create runs dir");
|
||
let _cleanup = ScratchGuard(vec![ScratchPath::Dir(runs_dir.clone())]);
|
||
|
||
let campaign_id = "deadbeef".repeat(8);
|
||
let record_line = format!(
|
||
r#"{{"campaign":"{campaign_id}","process":"beef","run":3,"seed":42,"cells":[{{"strategy":"3f9c","instrument":"GER40","window_ms":[1725148800000,1727740799999],"stages":[{{"block":"std::sweep","family_id":"sweep-0"}}]}}],"trace_name":"deadbeef-3"}}"#
|
||
);
|
||
std::fs::write(runs_dir.join("campaign_runs.jsonl"), format!("{record_line}\n"))
|
||
.expect("seed the campaign-run store");
|
||
|
||
let (dump_out, dump_code) = run_code_in(dir, &["campaign", "runs", &campaign_id]);
|
||
assert_eq!(dump_code, Some(0), "dump exits 0: {dump_out}");
|
||
assert_eq!(
|
||
dump_out.trim(),
|
||
record_line,
|
||
"a populated trace_name round-trips byte-identical through the read-back verb: {dump_out}"
|
||
);
|
||
}
|
||
|
||
/// The v2 boundary, order side: `[sweep, monte_carlo, walk_forward]` is
|
||
/// intrinsically valid — `process register` ACCEPTS it (asserted inside
|
||
/// `register_process_doc`; the intrinsic-tier ground is the shipped
|
||
/// `validate_process_permits_walk_forward_after_an_annotator` pin) — but the
|
||
/// executable pipeline shape fixes the suffix order (`std::sweep [std::gate]*
|
||
/// [std::walk_forward]? [std::monte_carlo]? [std::generalize]?`), so
|
||
/// `campaign run` refuses it at the data-free preflight, before any member
|
||
/// runs, with Debug-free PipelineShape prose.
|
||
#[test]
|
||
fn campaign_run_refuses_mc_before_walk_forward() {
|
||
let _fixture = project_lock();
|
||
let dir = built_project();
|
||
let runs_dir = dir.join("runs");
|
||
std::fs::remove_dir_all(&runs_dir).ok();
|
||
let _cleanup = ScratchGuard(vec![
|
||
ScratchPath::Dir(runs_dir.clone()),
|
||
ScratchPath::File(dir.join("mcwf.process.json")),
|
||
ScratchPath::File(dir.join("mcwf.campaign.json")),
|
||
]);
|
||
let bp_id = seed_blueprint(dir, "campaign-run-mcwf-seed");
|
||
let proc_id = register_process_doc(dir, "mcwf.process.json", MC_BEFORE_WF_PROCESS_DOC);
|
||
write_doc(dir, "mcwf.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""));
|
||
let (out, code) = run_code_in(dir, &["campaign", "run", "mcwf.campaign.json"]);
|
||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||
assert!(out.contains("process pipeline is not executable:"), "stdout/stderr: {out}");
|
||
assert!(
|
||
out.contains("std::walk_forward cannot follow std::monte_carlo"),
|
||
"the detail names the ordering violation: {out}"
|
||
);
|
||
assert!(!out.contains("not executable in v1"), "retired v1 prose resurfaced: {out}");
|
||
assert!(!out.contains("PipelineShape"), "Debug leak: {out}");
|
||
}
|
||
|
||
/// A v2 process doc whose `std::sweep` carries no selection group (no
|
||
/// `metric`/`select`) followed by a `std::gate` stage: intrinsically valid
|
||
/// (`process register` accepts a selection-free sweep as any other stage
|
||
/// shape) but not an executable v2 shape, since a selection-free sweep is
|
||
/// permitted ONLY as the terminal stage of its process (#210's terminal
|
||
/// rule — the executor's preflight rule, not the intrinsic validator's).
|
||
const SELECTION_FREE_SWEEP_THEN_GATE_PROCESS_DOC: &str = r#"{
|
||
"format_version": 1,
|
||
"kind": "process",
|
||
"name": "sweep-then-gate-no-selection",
|
||
"pipeline": [
|
||
{ "block": "std::sweep" },
|
||
{ "block": "std::gate", "all": [ { "metric": "n_trades", "cmp": "ge", "value": 0.0 } ] }
|
||
]
|
||
}"#;
|
||
|
||
/// Property (#210, sweep-dissolution-a): a selection-free `std::sweep` (no
|
||
/// metric/select group) anywhere but the LAST stage of its process is
|
||
/// refused by `campaign run`'s data-free preflight — the same seam that
|
||
/// already refuses `[sweep, monte_carlo, walk_forward]`'s ordering fault
|
||
/// above — before any member runs (the [1, 2] 1970-epoch-ms window is never
|
||
/// reached), naming the offending stage index, Debug-free. A regression
|
||
/// that let a non-terminal selection-free sweep through would silently
|
||
/// drop a stage's nominee downstream (nothing to select a winner from,
|
||
/// nothing for a following stage to consume).
|
||
#[test]
|
||
fn campaign_run_refuses_a_non_terminal_selection_free_sweep() {
|
||
let _fixture = project_lock();
|
||
let dir = built_project();
|
||
let runs_dir = dir.join("runs");
|
||
std::fs::remove_dir_all(&runs_dir).ok();
|
||
let _cleanup = ScratchGuard(vec![
|
||
ScratchPath::Dir(runs_dir.clone()),
|
||
ScratchPath::File(dir.join("selfree.process.json")),
|
||
ScratchPath::File(dir.join("selfree.campaign.json")),
|
||
]);
|
||
let bp_id = seed_blueprint(dir, "campaign-run-selfree-seed");
|
||
let proc_id =
|
||
register_process_doc(dir, "selfree.process.json", SELECTION_FREE_SWEEP_THEN_GATE_PROCESS_DOC);
|
||
write_doc(dir, "selfree.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""));
|
||
let (out, code) = run_code_in(dir, &["campaign", "run", "selfree.campaign.json"]);
|
||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||
assert!(
|
||
out.contains(
|
||
"process stage 0: a sweep without a selection group (metric + select) must be \
|
||
the last stage of its process"
|
||
),
|
||
"the detail names the terminal-only rule: {out}"
|
||
);
|
||
assert!(!out.contains("SelectionFreeSweepNotTerminal"), "Debug leak: {out}");
|
||
}
|
||
|
||
/// The same process shape, but the selection-free sweep IS the (only, hence
|
||
/// terminal) stage: `campaign validate`'s third (executor-preflight) tier
|
||
/// accepts it, data-free, exit 0 — the selection-free arm is a legitimate
|
||
/// executable shape, not merely a document-tier-only accept. Pairs with the
|
||
/// refusal above: together they pin the exact placement boundary (terminal:
|
||
/// yes / anywhere else: no) the executor preflight enforces.
|
||
const SELECTION_FREE_SWEEP_ONLY_PROCESS_DOC: &str = r#"{
|
||
"format_version": 1,
|
||
"kind": "process",
|
||
"name": "sweep-only-no-selection",
|
||
"pipeline": [ { "block": "std::sweep" } ]
|
||
}"#;
|
||
|
||
#[test]
|
||
fn campaign_validate_accepts_a_terminal_selection_free_sweep() {
|
||
let _fixture = project_lock();
|
||
let dir = built_project();
|
||
let runs_dir = dir.join("runs");
|
||
std::fs::remove_dir_all(&runs_dir).ok();
|
||
let _cleanup = ScratchGuard(vec![
|
||
ScratchPath::Dir(runs_dir.clone()),
|
||
ScratchPath::File(dir.join("selfreeonly.process.json")),
|
||
ScratchPath::File(dir.join("selfreeonly.campaign.json")),
|
||
]);
|
||
let bp_id = seed_blueprint(dir, "campaign-validate-selfreeonly-seed");
|
||
let proc_id =
|
||
register_process_doc(dir, "selfreeonly.process.json", SELECTION_FREE_SWEEP_ONLY_PROCESS_DOC);
|
||
write_doc(
|
||
dir,
|
||
"selfreeonly.campaign.json",
|
||
&campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""),
|
||
);
|
||
let (out, code) = run_code_in(dir, &["campaign", "validate", "selfreeonly.campaign.json"]);
|
||
assert_eq!(code, Some(0), "a terminal selection-free sweep validates as executable: {out}");
|
||
assert!(
|
||
out.contains("campaign document valid (executable): pipeline shape and static guards pass"),
|
||
"stdout/stderr: {out}"
|
||
);
|
||
}
|
||
|
||
/// The v2 boundary, campaign side: a generalize-bearing process is an
|
||
/// executable shape, but `std::generalize` needs >= 2 instruments in the
|
||
/// campaign — a STATIC preflight fact (the referential gate has run, no data
|
||
/// is read), so the single-GER40 fixture campaign refuses with exit 1 before
|
||
/// any member runs. This is the ONE home for the single-instrument refusal;
|
||
/// the gated real-data e2e does not duplicate it (its campaign is also
|
||
/// single-instrument, and this refusal is data-free by construction).
|
||
#[test]
|
||
fn campaign_run_refuses_single_instrument_generalize() {
|
||
let _fixture = project_lock();
|
||
let dir = built_project();
|
||
let runs_dir = dir.join("runs");
|
||
std::fs::remove_dir_all(&runs_dir).ok();
|
||
let _cleanup = ScratchGuard(vec![
|
||
ScratchPath::Dir(runs_dir.clone()),
|
||
ScratchPath::File(dir.join("gen1.process.json")),
|
||
ScratchPath::File(dir.join("gen1.campaign.json")),
|
||
]);
|
||
let bp_id = seed_blueprint(dir, "campaign-run-gen1-seed");
|
||
let proc_id = register_process_doc(dir, "gen1.process.json", GENERALIZE_PROCESS_DOC);
|
||
write_doc(dir, "gen1.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""));
|
||
let (out, code) = run_code_in(dir, &["campaign", "run", "gen1.campaign.json"]);
|
||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||
assert!(
|
||
out.contains("std::generalize needs at least 2"),
|
||
"the refusal names the block and the minimum: {out}"
|
||
);
|
||
assert!(out.contains("1 instrument"), "the refusal names the available count: {out}");
|
||
assert!(!out.contains("GeneralizeNeedsInstruments"), "Debug leak: {out}");
|
||
}
|
||
|
||
/// An executable generalize-bearing shape whose metric is a pip metric, not
|
||
/// an R metric (`total_pips` is a known, registered scalar — `process
|
||
/// register` accepts it — but not R-denominated).
|
||
const GENERALIZE_NON_R_PROCESS_DOC: &str = r#"{
|
||
"format_version": 1,
|
||
"kind": "process",
|
||
"name": "gen-pip-screen",
|
||
"pipeline": [
|
||
{ "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" },
|
||
{ "block": "std::generalize", "metric": "total_pips" }
|
||
]
|
||
}"#;
|
||
|
||
/// The v2 boundary, metric side: `std::generalize`'s metric guard
|
||
/// (`check_r_metric`) is a STATIC preflight fact independent of the
|
||
/// instrument-arity guard above it in the same match arm — with >= 2
|
||
/// campaign instruments (so arity passes) and a pip metric, `campaign run`
|
||
/// must still refuse data-free, naming the metric, and never fall through to
|
||
/// the instrument-count prose or a Debug-formatted fault. A regression that
|
||
/// let the arity check short-circuit past the metric check (or vice versa)
|
||
/// would silently accept a pip-denominated `std::generalize`.
|
||
#[test]
|
||
fn campaign_run_refuses_generalize_non_r_metric() {
|
||
let _fixture = project_lock();
|
||
let dir = built_project();
|
||
let runs_dir = dir.join("runs");
|
||
std::fs::remove_dir_all(&runs_dir).ok();
|
||
let _cleanup = ScratchGuard(vec![
|
||
ScratchPath::Dir(runs_dir.clone()),
|
||
ScratchPath::File(dir.join("genpip.process.json")),
|
||
ScratchPath::File(dir.join("genpip.campaign.json")),
|
||
]);
|
||
let bp_id = seed_blueprint(dir, "campaign-run-genpip-seed");
|
||
let proc_id = register_process_doc(dir, "genpip.process.json", GENERALIZE_NON_R_PROCESS_DOC);
|
||
// Two instruments (arity passes), so the metric guard is the one that
|
||
// actually fires. The [1, 2] 1970 window never matters: this refusal is
|
||
// data-free, before any member runs.
|
||
let campaign_doc = format!(
|
||
r#"{{
|
||
"format_version": 1,
|
||
"kind": "campaign",
|
||
"name": "run-seam-genpip",
|
||
"data": {{ "instruments": ["GER40", "EURUSD"], "windows": [ {{ "from_ms": 1, "to_ms": 2 }} ] }},
|
||
"strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }},
|
||
"axes": {{ "fast.length": {{ "kind": "I64", "values": [2, 4] }},
|
||
"slow.length": {{ "kind": "I64", "values": [8, 16] }} }} }} ],
|
||
"process": {{ "ref": {{ "content_id": "{proc_id}" }} }},
|
||
"seed": 7,
|
||
"presentation": {{ "persist_taps": [], "emit": [] }}
|
||
}}"#
|
||
);
|
||
write_doc(dir, "genpip.campaign.json", &campaign_doc);
|
||
let (out, code) = run_code_in(dir, &["campaign", "run", "genpip.campaign.json"]);
|
||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||
// #207 (fieldtest 0108 F8): the refusal names the REAL rule (the rankable
|
||
// R-expectancy family), never the mislabel "pip metric" — max_r_drawdown is
|
||
// R-denominated yet unranked, so "pip metric" was always wrong.
|
||
assert!(
|
||
out.contains("std::generalize metric \"total_pips\" is not in the rankable R-expectancy family"),
|
||
"the refusal names the offending metric and the real rule: {out}"
|
||
);
|
||
assert!(!out.contains("2 instrument"), "the arity guard must not fire when arity is fine: {out}");
|
||
assert!(!out.contains("GeneralizeNonRMetric"), "Debug leak: {out}");
|
||
}
|
||
|
||
/// A v2 process doc with `std::walk_forward` following the annotator
|
||
/// `std::monte_carlo` — intrinsically valid (order across population vs.
|
||
/// annotator stages is not a document-tier concern), refused only by the
|
||
/// executor's v2 preflight shape guard.
|
||
const WF_AFTER_MC_PROCESS_DOC: &str = r#"{
|
||
"format_version": 1,
|
||
"kind": "process",
|
||
"name": "wf-after-mc",
|
||
"pipeline": [
|
||
{ "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" },
|
||
{ "block": "std::monte_carlo", "resamples": 100, "block_len": 5 },
|
||
{ "block": "std::walk_forward", "in_sample_ms": 1209600000, "out_of_sample_ms": 604800000,
|
||
"step_ms": 604800000, "mode": "rolling", "metric": "net_expectancy_r", "select": "argmax" }
|
||
]
|
||
}"#;
|
||
|
||
/// Property (0108 task 2, mirroring aura-research's characterization pin
|
||
/// `validate_process_permits_walk_forward_after_an_annotator`): the
|
||
/// intrinsic/executor tier boundary is observable across TWO CLI commands.
|
||
/// `process validate` accepts `[sweep, monte_carlo, walk_forward]` (exit 0 —
|
||
/// the document tier does not order population vs. annotator stages) while
|
||
/// `campaign run`'s v2 preflight refuses the very same document, before any
|
||
/// member executes (data-free: the [1, 2] 1970-epoch-ms window is never
|
||
/// reached), because `std::walk_forward` cannot follow the annotator
|
||
/// `std::monte_carlo`. A regression that let the executor silently accept the
|
||
/// intrinsic tier's looser order (or that widened the intrinsic tier to
|
||
/// match) would collapse this two-command divergence.
|
||
#[test]
|
||
fn process_validate_permits_wf_after_mc_but_campaign_run_refuses_it() {
|
||
// intrinsic tier: no project needed, and it accepts the doc outright.
|
||
let vdir = temp_cwd("wf-after-mc-validate");
|
||
write_doc(&vdir, "wfmc.process.json", WF_AFTER_MC_PROCESS_DOC);
|
||
let (vout, vcode) = run_code_in(&vdir, &["process", "validate", "wfmc.process.json"]);
|
||
assert_eq!(vcode, Some(0), "intrinsic validate must accept sweep->mc->walk_forward: {vout}");
|
||
assert!(
|
||
vout.contains("process document valid (intrinsic): 3 pipeline blocks"),
|
||
"stdout/stderr: {vout}"
|
||
);
|
||
|
||
// executor tier: campaign run's v2 preflight refuses the same shape.
|
||
let _fixture = project_lock();
|
||
let dir = built_project();
|
||
let runs_dir = dir.join("runs");
|
||
std::fs::remove_dir_all(&runs_dir).ok();
|
||
let _cleanup = ScratchGuard(vec![
|
||
ScratchPath::Dir(runs_dir.clone()),
|
||
ScratchPath::File(dir.join("wfmc.process.json")),
|
||
ScratchPath::File(dir.join("wfmc.campaign.json")),
|
||
]);
|
||
let bp_id = seed_blueprint(dir, "campaign-run-wfmc-seed");
|
||
let proc_id = register_process_doc(dir, "wfmc.process.json", WF_AFTER_MC_PROCESS_DOC);
|
||
write_doc(dir, "wfmc.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""));
|
||
let (out, code) = run_code_in(dir, &["campaign", "run", "wfmc.campaign.json"]);
|
||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||
assert!(
|
||
out.contains("std::walk_forward cannot follow std::monte_carlo"),
|
||
"stdout/stderr: {out}"
|
||
);
|
||
assert!(!out.contains("PipelineShape {"), "Debug leak: {out}");
|
||
}
|
||
|
||
/// Property (#205, fieldtest 0108 F6): `aura campaign validate` runs the
|
||
/// executor's data-free static preflight (`aura_campaign::preflight`) as a
|
||
/// THIRD tier, so a pure process-shape fault that needs no data is caught at
|
||
/// validate time, not only at `campaign run` time — "valid" now means
|
||
/// "runnable". Two faces of the one behaviour, both inside a project (the
|
||
/// tier needs the store to fetch the referenced process, like the referential
|
||
/// tier above it):
|
||
/// - an executable pipeline shape gains a third line, `campaign document
|
||
/// valid (executable): pipeline shape and static guards pass`, exit 0;
|
||
/// - a `[sweep, monte_carlo, walk_forward]` process — intrinsically valid
|
||
/// (`process register` accepts it) and referentially valid (its ref
|
||
/// resolves) yet not an executable shape — refuses at VALIDATE with the
|
||
/// executor fault header `campaign is not executable:` and the
|
||
/// `exec_fault_prose` shape detail, exit 1, before any member runs.
|
||
///
|
||
/// A regression that dropped the third tier would let an executor-unrunnable
|
||
/// shape validate clean (exit 0), the F6 friction.
|
||
#[test]
|
||
fn campaign_validate_runs_the_executor_preflight_as_a_third_tier() {
|
||
let _fixture = project_lock();
|
||
let dir = built_project();
|
||
let runs_dir = dir.join("runs");
|
||
std::fs::remove_dir_all(&runs_dir).ok();
|
||
let _cleanup = ScratchGuard(vec![
|
||
ScratchPath::Dir(runs_dir.clone()),
|
||
ScratchPath::File(dir.join("runnable.process.json")),
|
||
ScratchPath::File(dir.join("runnable.campaign.json")),
|
||
ScratchPath::File(dir.join("wfmc.process.json")),
|
||
ScratchPath::File(dir.join("wfmc.campaign.json")),
|
||
]);
|
||
let bp_id = seed_blueprint(dir, "campaign-validate-3tier-seed");
|
||
|
||
// Face 1 — the happy path: an executable pipeline shape (a bare
|
||
// std::sweep). Intrinsic + referential pass today (exit 0), but the
|
||
// executor tier is invisible; after #205 validate gains the third
|
||
// "valid (executable)" line while staying exit 0.
|
||
let ok_proc = register_process_doc(dir, "runnable.process.json", SWEEP_ONLY_PROCESS_DOC);
|
||
write_doc(dir, "runnable.campaign.json", &campaign_doc_json(&bp_id, &ok_proc, (1, 2), "", ""));
|
||
let (ok_out, ok_code) = run_code_in(dir, &["campaign", "validate", "runnable.campaign.json"]);
|
||
assert_eq!(ok_code, Some(0), "an executable campaign validates clean: {ok_out}");
|
||
assert!(
|
||
ok_out.contains("campaign document valid (referential): all references resolve"),
|
||
"the referential tier line survives the added third tier: {ok_out}"
|
||
);
|
||
assert!(
|
||
ok_out.contains(
|
||
"campaign document valid (executable): pipeline shape and static guards pass"
|
||
),
|
||
"the new third (executor-preflight) tier line is emitted: {ok_out}"
|
||
);
|
||
|
||
// Face 2 — the refuse path: `[sweep, monte_carlo, walk_forward]` is
|
||
// intrinsically valid (register exits 0, asserted in register_process_doc)
|
||
// and referentially valid (same seeded blueprint + axes), so validate
|
||
// reaches the third tier — which refuses the non-executable shape at
|
||
// validate time (data-free: the [1, 2] 1970-epoch-ms window is never
|
||
// reached), exit 1, with the executor fault header + the shape prose.
|
||
let bad_proc = register_process_doc(dir, "wfmc.process.json", WF_AFTER_MC_PROCESS_DOC);
|
||
write_doc(dir, "wfmc.campaign.json", &campaign_doc_json(&bp_id, &bad_proc, (1, 2), "", ""));
|
||
let (bad_out, bad_code) = run_code_in(dir, &["campaign", "validate", "wfmc.campaign.json"]);
|
||
assert_eq!(bad_code, Some(1), "an executor-unrunnable shape refuses at validate: {bad_out}");
|
||
assert!(
|
||
bad_out.contains("campaign is not executable:"),
|
||
"the executor-tier fault header (mirrors 'campaign references do not resolve:'): {bad_out}"
|
||
);
|
||
assert!(
|
||
bad_out.contains("std::walk_forward cannot follow std::monte_carlo"),
|
||
"the shape prose names the ordering violation via exec_fault_prose: {bad_out}"
|
||
);
|
||
assert!(!bad_out.contains("PipelineShape"), "Debug leak: {bad_out}");
|
||
}
|
||
|
||
/// #215: `walk_forward` selecting a plateau mode with NO gate ahead of it —
|
||
/// the sweep survivors are the full parameter grid, so the plateau
|
||
/// neighbourhood scoring `axis_lens` assumes is intact.
|
||
const WF_PLATEAU_GATE_FREE_PROCESS_DOC: &str = r#"{
|
||
"format_version": 1,
|
||
"kind": "process",
|
||
"name": "wf-plateau-gate-free",
|
||
"pipeline": [
|
||
{ "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" },
|
||
{ "block": "std::walk_forward", "in_sample_ms": 1209600000, "out_of_sample_ms": 604800000,
|
||
"step_ms": 604800000, "mode": "rolling", "metric": "net_expectancy_r", "select": "plateau:worst" }
|
||
]
|
||
}"#;
|
||
|
||
/// #215: the same `walk_forward` plateau select, but a `std::gate` stage now
|
||
/// sits between the sweep and the walk-forward — the gate can filter
|
||
/// survivors below the full grid, breaking the lattice the plateau
|
||
/// neighbourhood assumes, so the executor preflight must still refuse it.
|
||
const WF_PLATEAU_GATED_PROCESS_DOC: &str = r#"{
|
||
"format_version": 1,
|
||
"kind": "process",
|
||
"name": "wf-plateau-gated",
|
||
"pipeline": [
|
||
{ "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" },
|
||
{ "block": "std::gate", "all": [ { "metric": "n_trades", "cmp": "ge", "value": 0.0 } ] },
|
||
{ "block": "std::walk_forward", "in_sample_ms": 1209600000, "out_of_sample_ms": 604800000,
|
||
"step_ms": 604800000, "mode": "rolling", "metric": "net_expectancy_r", "select": "plateau:worst" }
|
||
]
|
||
}"#;
|
||
|
||
/// Property (#215): the executor preflight's plateau-in-walk_forward refusal
|
||
/// is narrowed to gate-preceded stages, not a blanket ban on plateau select
|
||
/// in walk_forward — pinned through the full document surface (`campaign
|
||
/// validate`'s third, executor tier — data-free, before any member runs),
|
||
/// mirroring the pattern `campaign_validate_runs_the_executor_preflight_as_a_
|
||
/// third_tier` uses for the sibling shape guard. A regression that reverted
|
||
/// to the blanket refusal would flip the gate-free face to exit 1; a
|
||
/// regression that dropped the refusal entirely would flip the gated face to
|
||
/// exit 0 — either direction goes red here.
|
||
#[test]
|
||
fn campaign_validate_narrows_plateau_in_wf_refusal_to_gate_preceded_stages() {
|
||
let _fixture = project_lock();
|
||
let dir = built_project();
|
||
let runs_dir = dir.join("runs");
|
||
std::fs::remove_dir_all(&runs_dir).ok();
|
||
let _cleanup = ScratchGuard(vec![
|
||
ScratchPath::Dir(runs_dir.clone()),
|
||
ScratchPath::File(dir.join("wf-plateau-free.process.json")),
|
||
ScratchPath::File(dir.join("wf-plateau-free.campaign.json")),
|
||
ScratchPath::File(dir.join("wf-plateau-gated.process.json")),
|
||
ScratchPath::File(dir.join("wf-plateau-gated.campaign.json")),
|
||
]);
|
||
let bp_id = seed_blueprint(dir, "campaign-validate-wf-plateau-seed");
|
||
|
||
// Face 1 — gate-free: sweep -> walk_forward(plateau:worst). The lattice
|
||
// is intact, so the executor tier accepts it (exit 0).
|
||
let free_id =
|
||
register_process_doc(dir, "wf-plateau-free.process.json", WF_PLATEAU_GATE_FREE_PROCESS_DOC);
|
||
write_doc(
|
||
dir,
|
||
"wf-plateau-free.campaign.json",
|
||
&campaign_doc_json(&bp_id, &free_id, (1, 2), "", ""),
|
||
);
|
||
let (free_out, free_code) =
|
||
run_code_in(dir, &["campaign", "validate", "wf-plateau-free.campaign.json"]);
|
||
assert_eq!(free_code, Some(0), "gate-free plateau walk_forward validates clean: {free_out}");
|
||
assert!(
|
||
free_out.contains(
|
||
"campaign document valid (executable): pipeline shape and static guards pass"
|
||
),
|
||
"stdout/stderr: {free_out}"
|
||
);
|
||
|
||
// Face 2 — gate-preceded: sweep -> gate -> walk_forward(plateau:worst).
|
||
// The gate can break the lattice, so the executor tier still refuses it
|
||
// (exit 1), naming the offending stage.
|
||
let gated_id =
|
||
register_process_doc(dir, "wf-plateau-gated.process.json", WF_PLATEAU_GATED_PROCESS_DOC);
|
||
write_doc(
|
||
dir,
|
||
"wf-plateau-gated.campaign.json",
|
||
&campaign_doc_json(&bp_id, &gated_id, (1, 2), "", ""),
|
||
);
|
||
let (gated_out, gated_code) =
|
||
run_code_in(dir, &["campaign", "validate", "wf-plateau-gated.campaign.json"]);
|
||
assert_eq!(gated_code, Some(1), "gate-preceded plateau walk_forward still refuses: {gated_out}");
|
||
assert!(
|
||
gated_out.contains("campaign is not executable:"),
|
||
"the executor-tier fault header: {gated_out}"
|
||
);
|
||
assert!(
|
||
gated_out.contains(
|
||
"process stage 2: walk_forward cannot use a plateau select (a gated survivor subset has no parameter lattice)"
|
||
),
|
||
"the exec_fault_prose naming the gate-broken lattice: {gated_out}"
|
||
);
|
||
assert!(!gated_out.contains("PlateauInWalkForward"), "Debug leak: {gated_out}");
|
||
}
|
||
|
||
/// A v2 process doc with a static `std::monte_carlo resamples: 0` defect.
|
||
const ZERO_RESAMPLES_MC_PROCESS_DOC: &str = r#"{
|
||
"format_version": 1,
|
||
"kind": "process",
|
||
"name": "zero-resamples-mc",
|
||
"pipeline": [
|
||
{ "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" },
|
||
{ "block": "std::monte_carlo", "resamples": 0, "block_len": 5 }
|
||
]
|
||
}"#;
|
||
|
||
/// Property (0108 task 2): `std::monte_carlo`'s `resamples > 0` guard is a
|
||
/// STATIC executor-preflight refusal, wired end to end from the on-disk
|
||
/// document through to CLI stdout, firing before any member runs (data-free:
|
||
/// the [1, 2] 1970-epoch-ms window is never reached) and naming the stage
|
||
/// index and the offending field. A regression that dropped the static guard
|
||
/// (deferring to the engine's runtime all-zero-resamples degenerate instead)
|
||
/// would let this refusal disappear or drift to a different message.
|
||
#[test]
|
||
fn campaign_run_refuses_zero_resamples_monte_carlo_before_any_member_runs() {
|
||
let _fixture = project_lock();
|
||
let dir = built_project();
|
||
let runs_dir = dir.join("runs");
|
||
std::fs::remove_dir_all(&runs_dir).ok();
|
||
let _cleanup = ScratchGuard(vec![
|
||
ScratchPath::Dir(runs_dir.clone()),
|
||
ScratchPath::File(dir.join("zeromc.process.json")),
|
||
ScratchPath::File(dir.join("zeromc.campaign.json")),
|
||
]);
|
||
let bp_id = seed_blueprint(dir, "campaign-run-zeromc-seed");
|
||
let proc_id = register_process_doc(dir, "zeromc.process.json", ZERO_RESAMPLES_MC_PROCESS_DOC);
|
||
write_doc(dir, "zeromc.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""));
|
||
let (out, code) = run_code_in(dir, &["campaign", "run", "zeromc.campaign.json"]);
|
||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||
assert!(
|
||
out.contains("process stage 1: monte_carlo resamples must be > 0"),
|
||
"stdout/stderr: {out}"
|
||
);
|
||
assert!(!out.contains("ZeroBootstrapParam"), "Debug leak: {out}");
|
||
}
|
||
|
||
/// 0109 split (a) — the tap namespace is CLOSED: the 0107 deferral fixture's
|
||
/// made-up "r_record" tap now refuses INTRINSICALLY, exit 1, path-addressed
|
||
/// and vocabulary-enumerating. The old test drove `campaign run` on a file
|
||
/// target, so this pin drives the same path: the refusal fires at the run's
|
||
/// parse-valid gate (`parse_valid_campaign` — the same `validate_campaign` +
|
||
/// `doc_fault_prose` composition `campaign validate` shares), before
|
||
/// registration, before the referential tier, before any member runs — and
|
||
/// therefore before the (Task-3-doomed) loud-deferral note, whose absence is
|
||
/// pinned here.
|
||
#[test]
|
||
fn campaign_run_refuses_unknown_tap_at_validate() {
|
||
let _fixture = project_lock();
|
||
let dir = built_project();
|
||
let runs_dir = dir.join("runs");
|
||
std::fs::remove_dir_all(&runs_dir).ok();
|
||
let _cleanup = ScratchGuard(vec![
|
||
ScratchPath::Dir(runs_dir.clone()),
|
||
ScratchPath::File(dir.join("badtap.process.json")),
|
||
ScratchPath::File(dir.join("badtap.campaign.json")),
|
||
]);
|
||
let bp_id = seed_blueprint(dir, "campaign-run-badtap-seed");
|
||
let proc_id = register_process_doc(dir, "badtap.process.json", SWEEP_ONLY_PROCESS_DOC);
|
||
write_doc(
|
||
dir,
|
||
"badtap.campaign.json",
|
||
&campaign_doc_json(&bp_id, &proc_id, (1, 2), "\"r_record\"", ""),
|
||
);
|
||
let (out, code) = run_code_in(dir, &["campaign", "run", "badtap.campaign.json"]);
|
||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||
assert!(out.contains("campaign document invalid:"), "stdout/stderr: {out}");
|
||
assert!(
|
||
out.contains(
|
||
"presentation.persist_taps[0]: unknown tap \"r_record\" \
|
||
(taps: equity | exposure | r_equity | net_r_equity)"
|
||
),
|
||
"the refusal is path-addressed and enumerates the vocabulary: {out}"
|
||
);
|
||
assert!(
|
||
!out.contains("not yet honored"),
|
||
"an invalid document must never reach the deferral note: {out}"
|
||
);
|
||
assert!(!out.contains("UnknownTap"), "Debug leak: {out}");
|
||
}
|
||
|
||
/// 0109 split (b) — the control: an in-vocabulary tap ("equity") sails
|
||
/// through the new intrinsic gate (no UnknownTap prose) and the run proceeds
|
||
/// to refuse at the member-data seam, exactly as a tap-less run over the
|
||
/// [1, 2] 1970 epoch-ms window does (data-less host: missing geometry;
|
||
/// data-ful host: no archive file overlaps) — exit 1 either way, and no
|
||
/// trace output (tracing never starts on a run that dies before members;
|
||
/// there is no "traces persisted" summary line). NOTE: the 0107
|
||
/// loud-deferral eprintln still fires in this task's tree and is
|
||
/// deliberately NOT asserted either way here — Task 3 deletes it and
|
||
/// tightens this test to pin its absence.
|
||
#[test]
|
||
fn campaign_run_valid_tap_reaches_the_member_data_seam() {
|
||
let _fixture = project_lock();
|
||
let dir = built_project();
|
||
let runs_dir = dir.join("runs");
|
||
std::fs::remove_dir_all(&runs_dir).ok();
|
||
let _cleanup = ScratchGuard(vec![
|
||
ScratchPath::Dir(runs_dir.clone()),
|
||
ScratchPath::File(dir.join("taps.process.json")),
|
||
ScratchPath::File(dir.join("taps.campaign.json")),
|
||
]);
|
||
let bp_id = seed_blueprint(dir, "campaign-run-taps-seed");
|
||
let proc_id = register_process_doc(dir, "taps.process.json", SWEEP_ONLY_PROCESS_DOC);
|
||
write_doc(
|
||
dir,
|
||
"taps.campaign.json",
|
||
&campaign_doc_json(&bp_id, &proc_id, (1, 2), "\"equity\"", ""),
|
||
);
|
||
let (out, code) = run_code_in(dir, &["campaign", "run", "taps.campaign.json"]);
|
||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||
assert!(
|
||
!out.contains("unknown tap"),
|
||
"\"equity\" is in the closed vocabulary and must pass validate: {out}"
|
||
);
|
||
assert!(
|
||
out.contains("no recorded geometry") || out.contains("no data for instrument"),
|
||
"the refusal names the data condition: {out}"
|
||
);
|
||
assert!(
|
||
!out.contains("traces persisted"),
|
||
"no trace summary on a run that never reached a member: {out}"
|
||
);
|
||
assert!(
|
||
!out.contains("persist_taps not yet honored"),
|
||
"the 0107 deferral line is retired; persist_taps is honored from 0109 on: {out}"
|
||
);
|
||
assert!(
|
||
!out.contains("traces persisted:"),
|
||
"a member-data refusal aborts inside execute, before any tracing: {out}"
|
||
);
|
||
}
|
||
|
||
/// Gated real-data e2e (the `cli_run.rs` skip idiom): a full
|
||
/// sweep -> gate -> walk_forward -> monte_carlo campaign over GER40
|
||
/// Sept-2024 where the local archive is present — exit 0, emit-gated
|
||
/// family/selection lines, a final line parseable as JSON with top-level
|
||
/// `campaign_run` linking a sweep family id, a walk-forward family id, and a
|
||
/// pooled-OOS bootstrap on the terminal mc stage, and the
|
||
/// `campaign_runs.jsonl` sibling store written. Since 0109 the campaign
|
||
/// also requests `persist_taps: ["equity", "r_equity"]`: the record carries
|
||
/// `trace_name`, the nominee's taps land under `traces/<name>/<cell_key>/`
|
||
/// (the persist re-run equality-asserted against the recorded nominee —
|
||
/// C1), and `aura chart <name>/<cell_key>` reads them back through the
|
||
/// shipped viewer. Skips with a note elsewhere so `cargo test --workspace`
|
||
/// stays green on a data-less machine.
|
||
#[test]
|
||
fn campaign_run_real_e2e_sweep_gate_walkforward() {
|
||
let _fixture = project_lock();
|
||
let dir = built_project();
|
||
let runs_dir = dir.join("runs");
|
||
std::fs::remove_dir_all(&runs_dir).ok();
|
||
let _cleanup = ScratchGuard(vec![
|
||
ScratchPath::Dir(runs_dir.clone()),
|
||
ScratchPath::File(dir.join("wf.process.json")),
|
||
ScratchPath::File(dir.join("wf.campaign.json")),
|
||
]);
|
||
let bp_id = seed_blueprint(dir, "campaign-run-e2e-seed");
|
||
let proc_id = register_process_doc(dir, "wf.process.json", WF_PROCESS_DOC);
|
||
// The GER40 Sept-2024 window (inclusive Unix-ms) — the same gated window
|
||
// cli_run.rs drives; ~30 days, so the (14d, 7d, 7d) roller tiles it.
|
||
write_doc(
|
||
dir,
|
||
"wf.campaign.json",
|
||
&campaign_doc_json(
|
||
&bp_id,
|
||
&proc_id,
|
||
(1725148800000, 1727740799999),
|
||
"\"equity\", \"r_equity\"",
|
||
"\"family_table\", \"selection_report\"",
|
||
),
|
||
);
|
||
let (out, code) = run_code_in(dir, &["campaign", "run", "wf.campaign.json"]);
|
||
|
||
// Skip on a data-less machine: the member-data refusal, never a panic.
|
||
if code == Some(1)
|
||
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
|
||
{
|
||
eprintln!("skip: no local GER40 data for the campaign e2e");
|
||
return;
|
||
}
|
||
|
||
assert_eq!(code, Some(0), "stdout/stderr: {out}");
|
||
let record_line = out
|
||
.lines()
|
||
.find(|l| l.starts_with("{\"campaign_run\":"))
|
||
.expect("the always-on final campaign_run line");
|
||
let v: serde_json::Value =
|
||
serde_json::from_str(record_line).expect("campaign_run line parses as JSON");
|
||
let cells = v["campaign_run"]["cells"].as_array().expect("cells array");
|
||
assert_eq!(cells.len(), 1, "one (strategy, instrument, window) cell: {record_line}");
|
||
let stages = cells[0]["stages"].as_array().expect("stages array");
|
||
assert_eq!(
|
||
stages.len(),
|
||
4,
|
||
"sweep + gate + walk_forward + monte_carlo realized: {record_line}"
|
||
);
|
||
assert!(
|
||
stages[0]["family_id"].as_str().is_some(),
|
||
"sweep stage links a family: {record_line}"
|
||
);
|
||
assert_eq!(
|
||
stages[1]["survivor_ordinals"].as_array().map(|a| a.len()),
|
||
Some(4),
|
||
"the always-true gate keeps all four members: {record_line}"
|
||
);
|
||
assert!(
|
||
stages[2]["family_id"].as_str().is_some(),
|
||
"walk-forward stage links a family: {record_line}"
|
||
);
|
||
assert_eq!(
|
||
stages[3]["block"].as_str(),
|
||
Some("std::monte_carlo"),
|
||
"the annotator stage is realized last: {record_line}"
|
||
);
|
||
let pooled = &stages[3]["bootstrap"]["pooled_oos"];
|
||
assert!(
|
||
pooled.is_object(),
|
||
"an mc stage after walk_forward records ONE pooled-OOS bootstrap: {record_line}"
|
||
);
|
||
assert_eq!(
|
||
pooled["n_resamples"].as_u64(),
|
||
Some(200),
|
||
"the bootstrap carries the doc's resamples verbatim: {record_line}"
|
||
);
|
||
// 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!(
|
||
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}"
|
||
);
|
||
// The registry's new sibling store carries the realization record.
|
||
assert!(
|
||
runs_dir.join("campaign_runs.jsonl").is_file(),
|
||
"campaign_runs.jsonl written beside runs.jsonl"
|
||
);
|
||
// 0109: the record claims the trace family it persisted under —
|
||
// "{campaign8}-{run}" (fresh store => run 0).
|
||
let trace_name = v["campaign_run"]["trace_name"]
|
||
.as_str()
|
||
.expect("persist_taps non-empty => the record carries trace_name");
|
||
assert!(
|
||
trace_name.len() == 10 && trace_name.ends_with("-0"),
|
||
"trace_name is {{campaign8}}-{{run}}: {trace_name}"
|
||
);
|
||
assert!(
|
||
trace_name[..8].chars().all(|c| c.is_ascii_hexdigit()),
|
||
"trace_name prefix is the campaign id's first 8 hex chars: {trace_name}"
|
||
);
|
||
// The summary stderr line names the tap x cell arity (2 requested taps,
|
||
// one (strategy, instrument, window) cell).
|
||
assert!(
|
||
out.contains(&format!("aura: traces persisted: {trace_name} (2 tap(s) x 1 cell(s))")),
|
||
"the persist summary line: {out}"
|
||
);
|
||
// The taps land under traces/<name>/<cell_key>/ — the cell key is
|
||
// content-derived: strategy8-instrument-w<window_ordinal>.
|
||
let cell_key = format!("{}-GER40-w0", &bp_id[..8]);
|
||
let cell_dir = runs_dir.join("traces").join(trace_name).join(&cell_key);
|
||
assert!(
|
||
cell_dir.join("equity.json").is_file(),
|
||
"equity tap persisted at {}",
|
||
cell_dir.display()
|
||
);
|
||
assert!(
|
||
cell_dir.join("r_equity.json").is_file(),
|
||
"r_equity tap persisted at {}",
|
||
cell_dir.display()
|
||
);
|
||
assert!(
|
||
!cell_dir.join("exposure.json").exists(),
|
||
"unrequested taps are not persisted"
|
||
);
|
||
// The shipped viewer reads the member back (the cli_run.rs chart
|
||
// idiom): exit 0 and an HTML page carrying both persisted tap series.
|
||
let (chart_out, chart_code) =
|
||
run_code_in(dir, &["chart", &format!("{trace_name}/{cell_key}")]);
|
||
assert_eq!(chart_code, Some(0), "aura chart reads the persisted member: {chart_out}");
|
||
assert!(
|
||
chart_out.contains("\"equity\"") && chart_out.contains("\"r_equity\""),
|
||
"the chart page carries the two persisted taps: {chart_out}"
|
||
);
|
||
}
|
||
|
||
/// The campaign `data.bindings` override end to end (#231, 6b): the same
|
||
/// seeded strategy, window, and seed run twice — bare (price<-close default)
|
||
/// and with `price` rebound to the open column. Both exit 0; the realized
|
||
/// member metrics differ (the open series is not the close series); the
|
||
/// strategy blueprint is untouched (both docs reference the same content id).
|
||
/// Gated on the local GER40 archive; skips cleanly when absent.
|
||
#[test]
|
||
fn campaign_run_real_e2e_bindings_override_rebinds_price_to_open() {
|
||
let _fixture = project_lock();
|
||
let dir = built_project();
|
||
let runs_dir = dir.join("runs");
|
||
std::fs::remove_dir_all(&runs_dir).ok();
|
||
let _cleanup = ScratchGuard(vec![
|
||
ScratchPath::Dir(runs_dir.clone()),
|
||
ScratchPath::File(dir.join("sweep.process.json")),
|
||
ScratchPath::File(dir.join("close.campaign.json")),
|
||
ScratchPath::File(dir.join("open.campaign.json")),
|
||
]);
|
||
let bp_id = seed_blueprint(dir, "binding-override-seed");
|
||
let proc_id = register_process_doc(dir, "sweep.process.json", SWEEP_ONLY_PROCESS_DOC);
|
||
let base = campaign_doc_json(
|
||
&bp_id,
|
||
&proc_id,
|
||
(1725148800000, 1727740799999),
|
||
"",
|
||
"\"family_table\"",
|
||
);
|
||
let rebound = base.replace(
|
||
r#""data": { "instruments": ["GER40"],"#,
|
||
r#""data": { "bindings": { "price": "open" }, "instruments": ["GER40"],"#,
|
||
);
|
||
assert_ne!(rebound, base, "the override splice must hit the data section");
|
||
write_doc(dir, "close.campaign.json", &base);
|
||
write_doc(dir, "open.campaign.json", &rebound);
|
||
|
||
let (out_close, code_close) = run_code_in(dir, &["campaign", "run", "close.campaign.json"]);
|
||
if code_close == Some(1)
|
||
&& (out_close.contains("no recorded geometry") || out_close.contains("no data for instrument"))
|
||
{
|
||
eprintln!("skip: no local GER40 data for the binding-override e2e");
|
||
return;
|
||
}
|
||
assert_eq!(code_close, Some(0), "stdout/stderr: {out_close}");
|
||
let (out_open, code_open) = run_code_in(dir, &["campaign", "run", "open.campaign.json"]);
|
||
assert_eq!(code_open, Some(0), "stdout/stderr: {out_open}");
|
||
|
||
let first_member = |out: &str| -> serde_json::Value {
|
||
out.lines()
|
||
.find(|l| l.starts_with("{\"family_id\":"))
|
||
.map(|l| serde_json::from_str(l).expect("member line parses"))
|
||
.expect("a family_table member line")
|
||
};
|
||
let close_metrics = first_member(&out_close)["report"]["metrics"].clone();
|
||
let open_metrics = first_member(&out_open)["report"]["metrics"].clone();
|
||
assert_ne!(
|
||
close_metrics, open_metrics,
|
||
"rebinding price<-open must change the realized member metrics"
|
||
);
|
||
}
|
||
|
||
/// Property (#210 T3, Task 4 Step 9 — the regime->stop map): a campaign
|
||
/// document's non-default risk regime (`risk: [{ "vol": { length, k } }]`)
|
||
/// reaches the real `CliMemberRunner::run_member` path and IS the stop actually
|
||
/// stamped into each emitted member's manifest — `stop_length`/`stop_k` equal
|
||
/// the regime's own values (5, 3.5), not `R_SMA_STOP_LENGTH`/`R_SMA_STOP_K`
|
||
/// (3, 2.0). Every other campaign-run test in this file is regime-less (`risk`
|
||
/// absent), so they all only ever exercise the `None` arm of the campaign
|
||
/// member runner's regime match and would keep passing even if a regression
|
||
/// dropped `cell.regime` on the floor and always stamped the baked default;
|
||
/// this is the one test that actually drives the `Some(RiskRegime::Vol { .. })`
|
||
/// arm end to end. Gated on the local GER40 archive like its sibling e2e tests
|
||
/// above; skips on a data-less host.
|
||
#[test]
|
||
fn campaign_run_real_e2e_non_default_regime_stamps_its_own_stop() {
|
||
let _fixture = project_lock();
|
||
let dir = built_project();
|
||
let runs_dir = dir.join("runs");
|
||
std::fs::remove_dir_all(&runs_dir).ok();
|
||
let _cleanup = ScratchGuard(vec![
|
||
ScratchPath::Dir(runs_dir.clone()),
|
||
ScratchPath::File(dir.join("regime.process.json")),
|
||
ScratchPath::File(dir.join("regime.campaign.json")),
|
||
]);
|
||
let bp_id = seed_blueprint(dir, "campaign-run-regime-seed");
|
||
let proc_id = register_process_doc(dir, "regime.process.json", SWEEP_ONLY_PROCESS_DOC);
|
||
let base = campaign_doc_json(
|
||
&bp_id,
|
||
&proc_id,
|
||
(1725148800000, 1727740799999),
|
||
"",
|
||
"\"family_table\"",
|
||
);
|
||
let with_regime = base.replacen(
|
||
"\"seed\": 7,",
|
||
"\"seed\": 7,\n \"risk\": [ { \"vol\": { \"length\": 5, \"k\": 3.5 } } ],",
|
||
1,
|
||
);
|
||
assert_ne!(with_regime, base, "replacen must actually match the fixture's seed field");
|
||
write_doc(dir, "regime.campaign.json", &with_regime);
|
||
let (out, code) = run_code_in(dir, &["campaign", "run", "regime.campaign.json"]);
|
||
|
||
if code == Some(1)
|
||
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
|
||
{
|
||
eprintln!("skip: no local GER40 data for the campaign regime e2e");
|
||
return;
|
||
}
|
||
assert_eq!(code, Some(0), "stdout/stderr: {out}");
|
||
let member_lines: Vec<&str> =
|
||
out.lines().filter(|l| l.starts_with("{\"family_id\":")).collect();
|
||
assert!(!member_lines.is_empty(), "family_table member lines emitted: {out}");
|
||
for line in &member_lines {
|
||
let v: serde_json::Value = serde_json::from_str(line).expect("member line parses as JSON");
|
||
let params = v["report"]["manifest"]["params"]
|
||
.as_array()
|
||
.expect("manifest.params is an array");
|
||
let get = |name: &str| params.iter().find(|p| p[0].as_str() == Some(name));
|
||
assert_eq!(
|
||
get("stop_length").and_then(|p| p[1]["I64"].as_i64()),
|
||
Some(5),
|
||
"the campaign's own regime length is stamped, not the R_SMA default: {line}"
|
||
);
|
||
assert_eq!(
|
||
get("stop_k").and_then(|p| p[1]["F64"].as_f64()),
|
||
Some(3.5),
|
||
"the campaign's own regime k is stamped, not the R_SMA default: {line}"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Property (#210 T3/T4 — per-cell regime resolution, not a shared last-write):
|
||
/// a campaign with TWO distinct non-default risk regimes stamps EACH regime's
|
||
/// OWN stop into ITS OWN cell's members, never the other regime's values and
|
||
/// never both collapsing onto one. The single-regime sibling test above cannot
|
||
/// catch a bug where the executor's per-cell loop resolves the WRONG regime for
|
||
/// a cell (e.g. an off-by-one on `regime_ordinal`, or a closure that captures
|
||
/// the last-seen `regime` instead of the current one) — with only one non-
|
||
/// default entry, such a bug is invisible (there is nothing else it could pick).
|
||
/// Distinguishes the two cells' member lines by the `-r{regime_ordinal}-`
|
||
/// segment `run_cell` bakes into the family name (exec.rs), which is the only
|
||
/// externally observable handle on which cell a member line belongs to. Gated
|
||
/// on the local GER40 archive like its siblings; skips on a data-less host.
|
||
#[test]
|
||
fn campaign_run_real_e2e_two_regimes_each_stamp_their_own_stop() {
|
||
let _fixture = project_lock();
|
||
let dir = built_project();
|
||
let runs_dir = dir.join("runs");
|
||
std::fs::remove_dir_all(&runs_dir).ok();
|
||
let _cleanup = ScratchGuard(vec![
|
||
ScratchPath::Dir(runs_dir.clone()),
|
||
ScratchPath::File(dir.join("tworegimes.process.json")),
|
||
ScratchPath::File(dir.join("tworegimes.campaign.json")),
|
||
]);
|
||
let bp_id = seed_blueprint(dir, "campaign-run-two-regimes-seed");
|
||
let proc_id = register_process_doc(dir, "tworegimes.process.json", SWEEP_ONLY_PROCESS_DOC);
|
||
let base = campaign_doc_json(
|
||
&bp_id,
|
||
&proc_id,
|
||
(1725148800000, 1727740799999),
|
||
"",
|
||
"\"family_table\"",
|
||
);
|
||
let with_regimes = base.replacen(
|
||
"\"seed\": 7,",
|
||
"\"seed\": 7,\n \"risk\": [ { \"vol\": { \"length\": 5, \"k\": 3.5 } }, \
|
||
{ \"vol\": { \"length\": 8, \"k\": 1.2 } } ],",
|
||
1,
|
||
);
|
||
assert_ne!(with_regimes, base, "replacen must actually match the fixture's seed field");
|
||
write_doc(dir, "tworegimes.campaign.json", &with_regimes);
|
||
let (out, code) = run_code_in(dir, &["campaign", "run", "tworegimes.campaign.json"]);
|
||
|
||
if code == Some(1)
|
||
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
|
||
{
|
||
eprintln!("skip: no local GER40 data for the campaign two-regime e2e");
|
||
return;
|
||
}
|
||
assert_eq!(code, Some(0), "stdout/stderr: {out}");
|
||
let member_lines: Vec<&str> =
|
||
out.lines().filter(|l| l.starts_with("{\"family_id\":")).collect();
|
||
assert!(!member_lines.is_empty(), "family_table member lines emitted: {out}");
|
||
|
||
let expect_for = |regime_tag: &str, want_length: i64, want_k: f64| {
|
||
let matches: Vec<&&str> = member_lines
|
||
.iter()
|
||
.filter(|line| line.contains(regime_tag))
|
||
.collect();
|
||
assert!(!matches.is_empty(), "at least one member line tagged {regime_tag}: {out}");
|
||
for line in matches {
|
||
let v: serde_json::Value =
|
||
serde_json::from_str(line).expect("member line parses as JSON");
|
||
let params = v["report"]["manifest"]["params"]
|
||
.as_array()
|
||
.expect("manifest.params is an array");
|
||
let get = |name: &str| params.iter().find(|p| p[0].as_str() == Some(name));
|
||
assert_eq!(
|
||
get("stop_length").and_then(|p| p[1]["I64"].as_i64()),
|
||
Some(want_length),
|
||
"{regime_tag} stamps its own stop_length, not the other regime's: {line}"
|
||
);
|
||
assert_eq!(
|
||
get("stop_k").and_then(|p| p[1]["F64"].as_f64()),
|
||
Some(want_k),
|
||
"{regime_tag} stamps its own stop_k, not the other regime's: {line}"
|
||
);
|
||
}
|
||
};
|
||
expect_for("-r0-", 5, 3.5);
|
||
expect_for("-r1-", 8, 1.2);
|
||
}
|
||
|
||
/// RED (#219 / #212): the campaign trace-persist re-run must reproduce the
|
||
/// nominee it recorded, INCLUDING when the cell ran under a non-default risk
|
||
/// regime. `run_blueprint_member` stamps the regime-resolved stop into the
|
||
/// member manifest (`stop_length`/`stop_k`), but `persist_campaign_traces`
|
||
/// rebuilds the re-run's `point` only from the SIGNAL blueprint's param space
|
||
/// (the SMA lengths) and hardcodes the DEFAULT `Vol { R_SMA_STOP_LENGTH,
|
||
/// R_SMA_STOP_K }` (3, 2.0) for the stop (campaign_run.rs). For a non-default
|
||
/// regime the re-run's stop differs from the recorded run's stop, its metrics
|
||
/// diverge, and the C1 drift alarm returns a FALSE "trace re-run diverged from
|
||
/// the recorded nominee (C1 violation)" refusal — exit 1 on a run that is in
|
||
/// fact perfectly deterministic. The record boundary (`CellRealization`) drops
|
||
/// the regime; the fix threads it through so the re-run binds the cell's OWN
|
||
/// stop. Every other persist e2e in this file is regime-less (default risk),
|
||
/// which is exactly why the bug shipped unnoticed. Gated on the local GER40
|
||
/// archive like its siblings; skips on a data-less host.
|
||
#[test]
|
||
fn campaign_persist_non_default_regime_does_not_false_fail_c1() {
|
||
let _fixture = project_lock();
|
||
let dir = built_project();
|
||
let runs_dir = dir.join("runs");
|
||
std::fs::remove_dir_all(&runs_dir).ok();
|
||
let _cleanup = ScratchGuard(vec![
|
||
ScratchPath::Dir(runs_dir.clone()),
|
||
ScratchPath::File(dir.join("regime-persist.process.json")),
|
||
ScratchPath::File(dir.join("regime-persist.campaign.json")),
|
||
]);
|
||
let bp_id = seed_blueprint(dir, "campaign-persist-regime-seed");
|
||
let proc_id =
|
||
register_process_doc(dir, "regime-persist.process.json", SWEEP_ONLY_PROCESS_DOC);
|
||
let base = campaign_doc_json(
|
||
&bp_id,
|
||
&proc_id,
|
||
(1725148800000, 1727740799999),
|
||
"\"equity\", \"r_equity\"",
|
||
"\"family_table\"",
|
||
);
|
||
// A non-default regime: length != 3 or k != 2.0. Vol { 5, 3.0 } is a 50%
|
||
// wider stop over a longer ATR window — its exits, R rows, and metrics
|
||
// differ from the default the persist re-run hardcodes.
|
||
let with_regime = base.replacen(
|
||
"\"seed\": 7,",
|
||
"\"seed\": 7,\n \"risk\": [ { \"vol\": { \"length\": 5, \"k\": 3.0 } } ],",
|
||
1,
|
||
);
|
||
assert_ne!(with_regime, base, "replacen must actually match the fixture's seed field");
|
||
write_doc(dir, "regime-persist.campaign.json", &with_regime);
|
||
let (out, code) = run_code_in(dir, &["campaign", "run", "regime-persist.campaign.json"]);
|
||
|
||
// Skip on a data-less machine: the member-data refusal, never a panic.
|
||
if code == Some(1)
|
||
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
|
||
{
|
||
eprintln!("skip: no local GER40 data for the campaign regime-persist e2e");
|
||
return;
|
||
}
|
||
|
||
// The symptom, pinned exactly: the persist re-run hardcodes the default
|
||
// stop and false-fails the C1 drift alarm for the non-default regime.
|
||
assert!(
|
||
!out.contains("trace re-run diverged from the recorded nominee (C1 violation)"),
|
||
"the persist re-run must bind the cell's own regime stop, not false-fail C1: {out}"
|
||
);
|
||
assert_eq!(
|
||
code,
|
||
Some(0),
|
||
"a deterministic non-default-regime campaign persists cleanly: {out}"
|
||
);
|
||
|
||
// The nominee's taps actually landed (not merely that the refusal was
|
||
// suppressed): traces/<name>/<cell_key>/equity.json exists.
|
||
let record_line = out
|
||
.lines()
|
||
.find(|l| l.starts_with("{\"campaign_run\":"))
|
||
.expect("the always-on final campaign_run line");
|
||
let v: serde_json::Value =
|
||
serde_json::from_str(record_line).expect("campaign_run line parses as JSON");
|
||
let trace_name = v["campaign_run"]["trace_name"]
|
||
.as_str()
|
||
.expect("persist_taps non-empty => the record carries trace_name");
|
||
let cell_key = format!("{}-GER40-w0", &bp_id[..8]);
|
||
let cell_dir = runs_dir.join("traces").join(trace_name).join(&cell_key);
|
||
assert!(
|
||
cell_dir.join("equity.json").is_file(),
|
||
"the non-default-regime nominee's equity tap persists at {}",
|
||
cell_dir.display()
|
||
);
|
||
}
|
||
|
||
/// RED (#212 — the trace-dir collision facet of the same #219 record gap): two
|
||
/// risk regimes over the SAME (strategy, instrument, window) cell must persist
|
||
/// their traces into DISTINCT directories. `campaign_cell_key` names dirs
|
||
/// `strategy8-instrument-w{window_ordinal}` with NO regime discriminator
|
||
/// (campaign_run.rs), so both regimes' nominees resolve to the identical
|
||
/// `strategy8-GER40-w0` member dir and the second write clobbers the first.
|
||
/// NOTE (masking): today this fails EARLIER — at the same false C1 refusal as
|
||
/// its sibling above, because the persist re-run's hardcoded default stop
|
||
/// diverges from each non-default regime's recorded nominee. Once the regime is
|
||
/// threaded so the re-run stops false-failing, THIS assertion (two distinct
|
||
/// on-disk trace dirs, one per regime) becomes the live guard that the cell key
|
||
/// gained its `-r{ordinal}` discriminator — without it the two regimes collide
|
||
/// into one dir. Gated on the local GER40 archive; skips on a data-less host.
|
||
#[test]
|
||
fn campaign_persist_two_regimes_land_in_distinct_trace_dirs() {
|
||
let _fixture = project_lock();
|
||
let dir = built_project();
|
||
let runs_dir = dir.join("runs");
|
||
std::fs::remove_dir_all(&runs_dir).ok();
|
||
let _cleanup = ScratchGuard(vec![
|
||
ScratchPath::Dir(runs_dir.clone()),
|
||
ScratchPath::File(dir.join("tworegimes-persist.process.json")),
|
||
ScratchPath::File(dir.join("tworegimes-persist.campaign.json")),
|
||
]);
|
||
let bp_id = seed_blueprint(dir, "campaign-persist-two-regimes-seed");
|
||
let proc_id = register_process_doc(
|
||
dir,
|
||
"tworegimes-persist.process.json",
|
||
SWEEP_ONLY_PROCESS_DOC,
|
||
);
|
||
let base = campaign_doc_json(
|
||
&bp_id,
|
||
&proc_id,
|
||
(1725148800000, 1727740799999),
|
||
"\"equity\"",
|
||
"\"family_table\"",
|
||
);
|
||
let with_regimes = base.replacen(
|
||
"\"seed\": 7,",
|
||
"\"seed\": 7,\n \"risk\": [ { \"vol\": { \"length\": 5, \"k\": 3.0 } }, \
|
||
{ \"vol\": { \"length\": 8, \"k\": 1.2 } } ],",
|
||
1,
|
||
);
|
||
assert_ne!(with_regimes, base, "replacen must actually match the fixture's seed field");
|
||
write_doc(dir, "tworegimes-persist.campaign.json", &with_regimes);
|
||
let (out, code) = run_code_in(dir, &["campaign", "run", "tworegimes-persist.campaign.json"]);
|
||
|
||
// Skip on a data-less machine: the member-data refusal, never a panic.
|
||
if code == Some(1)
|
||
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
|
||
{
|
||
eprintln!("skip: no local GER40 data for the two-regime persist e2e");
|
||
return;
|
||
}
|
||
|
||
// Precondition (facet A): neither regime's persist re-run may false-fail C1.
|
||
assert!(
|
||
!out.contains("trace re-run diverged from the recorded nominee (C1 violation)"),
|
||
"the persist re-run must bind each cell's own regime stop, not false-fail C1: {out}"
|
||
);
|
||
assert_eq!(code, Some(0), "a two-regime campaign persists both cells cleanly: {out}");
|
||
|
||
// The guard (facet B): each regime's equity tap lands in its OWN dir under
|
||
// traces/<name>/, so there are TWO distinct tap-bearing dirs, not one.
|
||
let record_line = out
|
||
.lines()
|
||
.find(|l| l.starts_with("{\"campaign_run\":"))
|
||
.expect("the always-on final campaign_run line");
|
||
let v: serde_json::Value =
|
||
serde_json::from_str(record_line).expect("campaign_run line parses as JSON");
|
||
let trace_name = v["campaign_run"]["trace_name"]
|
||
.as_str()
|
||
.expect("persist_taps non-empty => the record carries trace_name");
|
||
let family_dir = runs_dir.join("traces").join(trace_name);
|
||
let mut tap_dirs: Vec<String> = std::fs::read_dir(&family_dir)
|
||
.expect("trace family dir exists")
|
||
.filter_map(|e| e.ok())
|
||
.filter(|e| e.path().join("equity.json").is_file())
|
||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||
.collect();
|
||
tap_dirs.sort();
|
||
tap_dirs.dedup();
|
||
assert_eq!(
|
||
tap_dirs.len(),
|
||
2,
|
||
"two regimes over one cell persist into two distinct trace dirs, got {tap_dirs:?}"
|
||
);
|
||
}
|
||
|
||
/// Property: an UNPRODUCIBLE tap requested alongside a producible one degrades
|
||
/// gracefully, never a crash or a silently-dropped whole request. `net_r_equity`
|
||
/// is in the closed tap vocabulary (`validate_campaign` accepts it) but has no
|
||
/// drained channel on the campaign runner's cost-free wrap (`tap_channel`
|
||
/// returns `None`); the one-time stderr note names exactly that tap, the
|
||
/// summary line's arity counts only what was actually written (1 tap, not 2),
|
||
/// and no `net_r_equity.json` file is ever created — only the producible
|
||
/// `equity` tap lands on disk. Uses `SWEEP_ONLY_PROCESS_DOC` (cheapest
|
||
/// executable shape) over the same GER40 Sept-2024 window as the gated wf e2e
|
||
/// above, so the underlying member run needs real data; skips on a data-less
|
||
/// host like its siblings.
|
||
#[test]
|
||
fn campaign_run_real_e2e_skips_unproducible_tap_gracefully() {
|
||
let _fixture = project_lock();
|
||
let dir = built_project();
|
||
let runs_dir = dir.join("runs");
|
||
std::fs::remove_dir_all(&runs_dir).ok();
|
||
let _cleanup = ScratchGuard(vec![
|
||
ScratchPath::Dir(runs_dir.clone()),
|
||
ScratchPath::File(dir.join("mixedtap.process.json")),
|
||
ScratchPath::File(dir.join("mixedtap.campaign.json")),
|
||
]);
|
||
let bp_id = seed_blueprint(dir, "campaign-run-mixedtap-seed");
|
||
let proc_id = register_process_doc(dir, "mixedtap.process.json", SWEEP_ONLY_PROCESS_DOC);
|
||
write_doc(
|
||
dir,
|
||
"mixedtap.campaign.json",
|
||
&campaign_doc_json(
|
||
&bp_id,
|
||
&proc_id,
|
||
(1725148800000, 1727740799999),
|
||
"\"net_r_equity\", \"equity\"",
|
||
"",
|
||
),
|
||
);
|
||
let (out, code) = run_code_in(dir, &["campaign", "run", "mixedtap.campaign.json"]);
|
||
|
||
// Skip on a data-less machine: the member-data refusal, never a panic.
|
||
if code == Some(1)
|
||
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
|
||
{
|
||
eprintln!("skip: no local GER40 data for the campaign e2e");
|
||
return;
|
||
}
|
||
|
||
assert_eq!(code, Some(0), "stdout/stderr: {out}");
|
||
let record_line = out
|
||
.lines()
|
||
.find(|l| l.starts_with("{\"campaign_run\":"))
|
||
.expect("the always-on final campaign_run line");
|
||
let v: serde_json::Value =
|
||
serde_json::from_str(record_line).expect("campaign_run line parses as JSON");
|
||
let trace_name = v["campaign_run"]["trace_name"]
|
||
.as_str()
|
||
.expect("persist_taps non-empty => the record carries trace_name");
|
||
|
||
assert!(
|
||
out.contains(
|
||
"aura: tap \"net_r_equity\" is not produced by this run (needs a cost run); skipped"
|
||
),
|
||
"the unproducible tap gets a named, one-time note: {out}"
|
||
);
|
||
assert!(
|
||
out.contains(&format!("aura: traces persisted: {trace_name} (1 tap(s) x 1 cell(s))")),
|
||
"the summary counts only the producible tap actually written: {out}"
|
||
);
|
||
let cell_key = format!("{}-GER40-w0", &bp_id[..8]);
|
||
let cell_dir = runs_dir.join("traces").join(trace_name).join(&cell_key);
|
||
assert!(
|
||
cell_dir.join("equity.json").is_file(),
|
||
"the producible tap still persists: {}",
|
||
cell_dir.display()
|
||
);
|
||
assert!(
|
||
!cell_dir.join("net_r_equity.json").exists(),
|
||
"an unproducible tap is never written to disk"
|
||
);
|
||
}
|
||
|
||
/// Gated real-data e2e, the OTHER mc bootstrap input shape (#200 d1): with no
|
||
/// walk_forward in the pipeline (`sweep -> monte_carlo`), the terminal
|
||
/// annotator's dual-input dispatch takes the `PerSurvivor` arm, not
|
||
/// `PooledOos` — one bootstrap per surviving sweep member, keyed by the
|
||
/// member's ordinal into the sweep family, rather than one pooled bootstrap
|
||
/// over walk-forward OOS windows. `campaign_run_real_e2e_sweep_gate_walkforward`
|
||
/// above only exercises `PooledOos`; this is the only e2e coverage of
|
||
/// `PerSurvivor` with real data (the addressing-mode tests use it only as a
|
||
/// data-free refusal fixture).
|
||
#[test]
|
||
fn campaign_run_real_e2e_sweep_monte_carlo_per_survivor() {
|
||
let _fixture = project_lock();
|
||
let dir = built_project();
|
||
let runs_dir = dir.join("runs");
|
||
std::fs::remove_dir_all(&runs_dir).ok();
|
||
let _cleanup = ScratchGuard(vec![
|
||
ScratchPath::Dir(runs_dir.clone()),
|
||
ScratchPath::File(dir.join("mc.process.json")),
|
||
ScratchPath::File(dir.join("mc.campaign.json")),
|
||
]);
|
||
let bp_id = seed_blueprint(dir, "campaign-run-mc-persurvivor-seed");
|
||
let proc_id = register_process_doc(dir, "mc.process.json", MC_PROCESS_DOC);
|
||
// Same GER40 Sept-2024 window as the gated wf e2e above.
|
||
write_doc(
|
||
dir,
|
||
"mc.campaign.json",
|
||
&campaign_doc_json(&bp_id, &proc_id, (1725148800000, 1727740799999), "", ""),
|
||
);
|
||
let (out, code) = run_code_in(dir, &["campaign", "run", "mc.campaign.json"]);
|
||
|
||
// Skip on a data-less machine: the member-data refusal, never a panic.
|
||
if code == Some(1)
|
||
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
|
||
{
|
||
eprintln!("skip: no local GER40 data for the campaign e2e");
|
||
return;
|
||
}
|
||
|
||
assert_eq!(code, Some(0), "stdout/stderr: {out}");
|
||
let record_line = out
|
||
.lines()
|
||
.find(|l| l.starts_with("{\"campaign_run\":"))
|
||
.expect("the always-on final campaign_run line");
|
||
let v: serde_json::Value =
|
||
serde_json::from_str(record_line).expect("campaign_run line parses as JSON");
|
||
let cells = v["campaign_run"]["cells"].as_array().expect("cells array");
|
||
let stages = cells[0]["stages"].as_array().expect("stages array");
|
||
assert_eq!(stages.len(), 2, "sweep + monte_carlo realized: {record_line}");
|
||
assert_eq!(
|
||
stages[1]["block"].as_str(),
|
||
Some("std::monte_carlo"),
|
||
"the annotator stage is realized last: {record_line}"
|
||
);
|
||
let per_survivor = stages[1]["bootstrap"]["per_survivor"]
|
||
.as_array()
|
||
.expect("no walk_forward precedes: the PerSurvivor arm, not pooled_oos");
|
||
assert_eq!(
|
||
per_survivor.len(),
|
||
4,
|
||
"one bootstrap per surviving sweep member (4 axis combinations, no gate): {record_line}"
|
||
);
|
||
let (ordinal, bootstrap) = (per_survivor[0][0].as_u64(), &per_survivor[0][1]);
|
||
assert_eq!(ordinal, Some(0), "each entry is keyed by ordinal into the sweep family: {record_line}");
|
||
assert_eq!(
|
||
bootstrap["n_resamples"].as_u64(),
|
||
Some(100),
|
||
"the bootstrap carries the doc's resamples verbatim: {record_line}"
|
||
);
|
||
}
|
||
|
||
/// `run_campaign` resolves a target two ways — a readable file (register-then-
|
||
/// run sugar) or a bare content id (direct store address) — then funnels BOTH
|
||
/// through one shared post-resolution path: "fetch the stored canonical bytes
|
||
/// by id ... so file addressing and id addressing produce the same
|
||
/// realization by construction" (the driver's own doc comment). This pins
|
||
/// that property observably: registering a document and then running it once
|
||
/// by FILE and once by its own resulting content ID must refuse with the
|
||
/// byte-identical prose — proof the two addressing modes are not two
|
||
/// independently drifting code paths. No real data needed: with the [1, 2]
|
||
/// 1970 window, both runs pass the (now mc-permitting) preflight and refuse
|
||
/// at the member-data seam — no recorded geometry on a data-less host, no
|
||
/// overlapping archive window on a data-ful one — exit 1 and byte-identical
|
||
/// prose either way (the message depends only on env/instrument/window).
|
||
#[test]
|
||
fn campaign_run_by_content_id_matches_file_sugar_refusal() {
|
||
let _fixture = project_lock();
|
||
let dir = built_project();
|
||
let runs_dir = dir.join("runs");
|
||
std::fs::remove_dir_all(&runs_dir).ok();
|
||
let _cleanup = ScratchGuard(vec![
|
||
ScratchPath::Dir(runs_dir.clone()),
|
||
ScratchPath::File(dir.join("mc2.process.json")),
|
||
ScratchPath::File(dir.join("mc2.campaign.json")),
|
||
]);
|
||
let bp_id = seed_blueprint(dir, "campaign-run-id-seed");
|
||
let proc_id = register_process_doc(dir, "mc2.process.json", MC_PROCESS_DOC);
|
||
write_doc(dir, "mc2.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""));
|
||
|
||
let (file_out, file_code) = run_code_in(dir, &["campaign", "run", "mc2.campaign.json"]);
|
||
assert_eq!(file_code, Some(1), "stdout/stderr: {file_out}");
|
||
|
||
let (reg_out, reg_code) = run_code_in(dir, &["campaign", "register", "mc2.campaign.json"]);
|
||
assert_eq!(reg_code, Some(0), "register failed: {reg_out}");
|
||
let id = reg_out
|
||
.lines()
|
||
.find(|l| l.starts_with("registered campaign "))
|
||
.expect("register line")
|
||
.trim_start_matches("registered campaign ")
|
||
.split(' ')
|
||
.next()
|
||
.expect("id")
|
||
.trim_start_matches("content:")
|
||
.to_string();
|
||
let (id_out, id_code) = run_code_in(dir, &["campaign", "run", &id]);
|
||
assert_eq!(id_code, Some(1), "stdout/stderr: {id_out}");
|
||
|
||
let file_line = file_out.lines().find(|l| l.starts_with("aura: ")).expect("file refusal line");
|
||
let id_line = id_out.lines().find(|l| l.starts_with("aura: ")).expect("id refusal line");
|
||
assert!(
|
||
file_line.contains("no recorded geometry") || file_line.contains("no data for instrument"),
|
||
"the refusal is the member-data seam (mc processes execute now): {file_line}"
|
||
);
|
||
assert_eq!(file_line, id_line, "file- and id-addressed runs must refuse identically");
|
||
}
|
||
|
||
/// #194 (the prefix trap): a `campaign run` target may carry the display
|
||
/// `content:` prefix copied verbatim from the tool's own register/introspect
|
||
/// output — the CLI strips it and resolves the bare store address. Pinned
|
||
/// observably: running the SAME registered campaign by bare id and by its
|
||
/// `content:`-prefixed form must refuse identically (proof the prefix is
|
||
/// stripped, not read as a second, unresolvable address). Both refuse at the
|
||
/// member-data seam (the mc process is executable in v2; the [1, 2] 1970
|
||
/// window guarantees a deterministic data refusal), so no real data is
|
||
/// needed.
|
||
#[test]
|
||
fn campaign_run_tolerates_content_prefix_on_target() {
|
||
let _fixture = project_lock();
|
||
let dir = built_project();
|
||
let runs_dir = dir.join("runs");
|
||
std::fs::remove_dir_all(&runs_dir).ok();
|
||
let _cleanup = ScratchGuard(vec![
|
||
ScratchPath::Dir(runs_dir.clone()),
|
||
ScratchPath::File(dir.join("prefix.process.json")),
|
||
ScratchPath::File(dir.join("prefix.campaign.json")),
|
||
]);
|
||
let bp_id = seed_blueprint(dir, "campaign-run-prefix-seed");
|
||
let proc_id = register_process_doc(dir, "prefix.process.json", MC_PROCESS_DOC);
|
||
write_doc(dir, "prefix.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""));
|
||
|
||
let (reg_out, reg_code) = run_code_in(dir, &["campaign", "register", "prefix.campaign.json"]);
|
||
assert_eq!(reg_code, Some(0), "register failed: {reg_out}");
|
||
let id = reg_out
|
||
.lines()
|
||
.find(|l| l.starts_with("registered campaign "))
|
||
.expect("register line")
|
||
.trim_start_matches("registered campaign ")
|
||
.split(' ')
|
||
.next()
|
||
.expect("id")
|
||
.trim_start_matches("content:")
|
||
.to_string();
|
||
|
||
let (bare_out, _) = run_code_in(dir, &["campaign", "run", &id]);
|
||
let (pfx_out, _) = run_code_in(dir, &["campaign", "run", &format!("content:{id}")]);
|
||
|
||
let pfx_line = pfx_out.lines().find(|l| l.starts_with("aura: ")).expect("prefixed refusal line");
|
||
assert!(
|
||
!pfx_line.contains("neither a readable .json file nor a 64-hex content id"),
|
||
"a content:-prefixed target must resolve as the bare id, not refuse as an unknown target: {pfx_line}"
|
||
);
|
||
let bare_line = bare_out.lines().find(|l| l.starts_with("aura: ")).expect("bare refusal line");
|
||
assert!(
|
||
bare_line.contains("no recorded geometry") || bare_line.contains("no data for instrument"),
|
||
"the refusal is the member-data seam (mc processes execute now): {bare_line}"
|
||
);
|
||
assert_eq!(bare_line, pfx_line, "bare-id and content:-prefixed runs must resolve identically");
|
||
}
|
||
|
||
/// Property (#196 on-ramp x #198 executor): a blueprint registered through
|
||
/// `aura graph register` — NOT the pre-#196 `aura sweep` side-effect
|
||
/// `seed_blueprint` relies on elsewhere in this file — is a fully valid
|
||
/// campaign strategy ref: its content id resolves through the referential
|
||
/// gate, and its raw param space (`fast.length`/`slow.length`, the same
|
||
/// names `graph introspect --params` reports for this fixture) binds against
|
||
/// the campaign document's axes in `bind_axes`. This closes fieldtest F5
|
||
/// ("strategy refs unresolvable from the public surface — no blueprint
|
||
/// register verb") end to end: the run reaches the member-data seam (the
|
||
/// same [1, 2] 1970 window used elsewhere in this file for a deterministic,
|
||
/// data-free refusal) rather than failing earlier at referencing or binding.
|
||
#[test]
|
||
fn campaign_run_accepts_a_graph_register_seeded_strategy() {
|
||
let _fixture = project_lock();
|
||
let dir = built_project();
|
||
let runs_dir = dir.join("runs");
|
||
std::fs::remove_dir_all(&runs_dir).ok();
|
||
let _cleanup = ScratchGuard(vec![
|
||
ScratchPath::Dir(runs_dir.clone()),
|
||
ScratchPath::File(dir.join("onramp.process.json")),
|
||
ScratchPath::File(dir.join("onramp.campaign.json")),
|
||
]);
|
||
let open_bp = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
|
||
let (reg_out, reg_code) = run_code_in(dir, &["graph", "register", &open_bp]);
|
||
assert_eq!(reg_code, Some(0), "graph register failed: {reg_out}");
|
||
let bp_id = reg_out
|
||
.lines()
|
||
.find(|l| l.starts_with("registered blueprint "))
|
||
.expect("register line")
|
||
.trim_start_matches("registered blueprint ")
|
||
.split(' ')
|
||
.next()
|
||
.expect("id")
|
||
.trim_start_matches("content:")
|
||
.to_string();
|
||
|
||
let proc_id = register_process_doc(dir, "onramp.process.json", SWEEP_ONLY_PROCESS_DOC);
|
||
write_doc(dir, "onramp.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""));
|
||
let (out, code) = run_code_in(dir, &["campaign", "run", "onramp.campaign.json"]);
|
||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||
assert!(
|
||
out.contains("no recorded geometry") || out.contains("no data for instrument"),
|
||
"referencing + axis-binding must succeed, reaching the member-data \
|
||
seam rather than a referential or bind refusal: {out}"
|
||
);
|
||
}
|
||
|
||
/// `campaign run`'s file-sugar branch calls `parse_valid_campaign` directly
|
||
/// (unwrapped), the SAME intrinsic validator `campaign register` calls but
|
||
/// wraps in its own "refusing to register:" prefix. This pins that `run`
|
||
/// does NOT inherit register's wrapper — an intrinsically invalid document
|
||
/// refuses with the bare "campaign document invalid:" prose — and, like
|
||
/// register, never touches the store for it. No project scaffolding, no
|
||
/// data, no seeded blueprint needed: the refusal fires before any of that is
|
||
/// read.
|
||
#[test]
|
||
fn campaign_run_invalid_file_refuses_before_touching_store() {
|
||
// Run inside the built project (campaign run needs one, per the project
|
||
// gate); the document fails intrinsic validation before any store or
|
||
// referential check is reached, so the project's own store is untouched.
|
||
let _fixture = project_lock();
|
||
let dir = built_project();
|
||
let runs_dir = dir.join("runs");
|
||
std::fs::remove_dir_all(&runs_dir).ok();
|
||
let bad = CAMPAIGN_DOC.replacen(r#""values": [8]"#, r#""values": []"#, 1);
|
||
let bad_path = write_doc(dir, "bad.campaign.json", &bad);
|
||
let _cleanup = ScratchGuard(vec![ScratchPath::Dir(runs_dir.clone()), ScratchPath::File(bad_path)]);
|
||
|
||
let (out, code) = run_code_in(dir, &["campaign", "run", "bad.campaign.json"]);
|
||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||
assert!(
|
||
out.contains("aura: campaign document invalid:"),
|
||
"run must surface the bare doc-tier prose, not register's wrapper: {out}"
|
||
);
|
||
assert!(
|
||
out.contains("axes.fast: an axis is a non-empty finite set"),
|
||
"the doc-tier fault names the offending axis: {out}"
|
||
);
|
||
assert!(!out.contains("refusing to register:"), "run must not reuse register's prefix: {out}");
|
||
assert!(
|
||
!dir.join("runs").join("campaigns").exists(),
|
||
"an invalid document must not create a store entry"
|
||
);
|
||
}
|