feat(cli): aura campaign run — the executor verb over the MemberRunner driver (0107 tasks 8-9)
campaign_run.rs: target resolution (file is register-then-run sugar; bare 64-hex is the canonical id — #198 decision 1), project gate before any store write, zero-fault referential gate, process fetch + v1 preflight, then aura_campaign::execute with the CLI MemberRunner over the shipped loaded-blueprint convention: per-member blueprint reload, reduce-mode wrap, unique suffix-join of raw campaign axis names onto the wrapped param_space, windowed real data via the shipped ms->ns source seam (absent archive/zero-bar windows -> NoData). Emission: family_table / selection_report lines gated on the doc's emit list (names debug_asserted against emit_vocabulary), the campaign_run record line always, persist_taps deferred LOUDLY on stderr before execution (F7 lesson), zero-survivor cells noted on stderr with exit 0. exec_fault_prose/member_fault_prose keep the Debug-free house seam. Seam tests: 8 campaign_run_* e2e tests incl. outside-project, bogus target, unknown id, v1-boundary (mc process registers fine, run refuses), persist_taps ordering, and the gated real-data sweep->gate->walkforward e2e — which ran its full assert path on this host (local GER40 2024-09 archive) rather than the data-less skip. Also: exec.rs campaign-id guard tightened to lowercase hex, matching is_content_id and the store's self-keyed form (review nit). Gates: workspace 975/0, clippy -D warnings clean. refs #198
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
//! author uses.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
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`
|
||||
@@ -217,6 +217,15 @@ fn built_project() -> &'static PathBuf {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -253,6 +262,7 @@ impl Drop for ScratchGuard {
|
||||
/// 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();
|
||||
@@ -477,3 +487,388 @@ fn campaign_register_refuses_invalid_document_and_writes_nothing() {
|
||||
"register must not create a store entry for an invalid document"
|
||||
);
|
||||
}
|
||||
|
||||
/// 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!("{}/tests/fixtures/sma_signal_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 (an mc-bearing
|
||||
/// one included) must always register; only `campaign run` draws the v1 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 content:"))
|
||||
.expect("register line")
|
||||
.trim_start_matches("registered process content:")
|
||||
.split(' ')
|
||||
.next()
|
||||
.expect("id")
|
||||
.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 (register accepts it) but past
|
||||
/// the executable v1 boundary (`campaign run` refuses it at preflight).
|
||||
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 }
|
||||
]
|
||||
}"#;
|
||||
|
||||
/// The minimal executable v1 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 v1 shape for the gated e2e: sweep -> gate -> walk_forward. The
|
||||
/// gate (`n_trades ge 0`) passes every member, so walk-forward always has
|
||||
/// survivors. Roller: 14d IS / 7d OOS / 7d step in epoch-ms, tiling the
|
||||
/// ~30-day GER40 Sept-2024 campaign window.
|
||||
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" }
|
||||
]
|
||||
}"#;
|
||||
|
||||
/// `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}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The v1 boundary: `process register` ACCEPTS an mc-bearing document (it is
|
||||
/// intrinsically valid — asserted inside `register_process_doc`); only
|
||||
/// `campaign run` refuses it, at preflight, before any member runs (so no
|
||||
/// data is needed), with path-addressed Debug-free prose.
|
||||
#[test]
|
||||
fn campaign_run_v1_boundary_refuses_mc_process() {
|
||||
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-seed");
|
||||
let proc_id = register_process_doc(dir, "mc.process.json", MC_PROCESS_DOC);
|
||||
write_doc(dir, "mc.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""));
|
||||
let (out, code) = run_code_in(dir, &["campaign", "run", "mc.campaign.json"]);
|
||||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||||
assert!(out.contains("not executable in v1"), "stdout/stderr: {out}");
|
||||
assert!(out.contains("std::monte_carlo"), "the prose names the block: {out}");
|
||||
assert!(!out.contains("UnsupportedStage"), "Debug leak: {out}");
|
||||
}
|
||||
|
||||
/// Non-empty persist_taps defers LOUDLY, and the note's ORDER is pinned: it
|
||||
/// prints before member execution, so it is asserted here on a run
|
||||
/// that refuses at the member-data seam. The [1, 2] epoch-ms window (1970)
|
||||
/// makes that refusal deterministic on every machine: a data-less host
|
||||
/// refuses on missing geometry, a data-ful host on a window no archive file
|
||||
/// overlaps — exit 1 either way, tap note already on stderr.
|
||||
#[test]
|
||||
fn campaign_run_persist_taps_deferred_loudly() {
|
||||
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), "\"r_record\"", ""),
|
||||
);
|
||||
let (out, code) = run_code_in(dir, &["campaign", "run", "taps.campaign.json"]);
|
||||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||||
assert!(
|
||||
out.contains("aura: persist_taps not yet honored (1 tap(s) ignored)"),
|
||||
"the tap note must precede the member-data refusal: {out}"
|
||||
);
|
||||
assert!(
|
||||
out.contains("no recorded geometry") || out.contains("no data for instrument"),
|
||||
"the refusal names the data condition: {out}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Gated real-data e2e (the `cli_run.rs` skip idiom): a full
|
||||
/// sweep -> gate -> walk_forward 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 and a walk-forward family id, and the `campaign_runs.jsonl`
|
||||
/// sibling store written. 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),
|
||||
"",
|
||||
"\"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(), 3, "sweep + gate + walk_forward 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}"
|
||||
);
|
||||
// Emit-gated lines: per-member family_table lines (4 sweep members plus
|
||||
// the walk-forward OOS members) and at least one selection_report line.
|
||||
assert!(
|
||||
out.lines().filter(|l| l.starts_with("{\"family_id\":")).count() >= 4,
|
||||
"family_table member lines emitted: {out}"
|
||||
);
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
/// `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: both runs refuse
|
||||
/// at the (data-free) v1-pipeline-shape preflight.
|
||||
#[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 content:"))
|
||||
.expect("register line")
|
||||
.trim_start_matches("registered campaign content:")
|
||||
.split(' ')
|
||||
.next()
|
||||
.expect("id")
|
||||
.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_eq!(file_line, id_line, "file- and id-addressed runs must refuse identically");
|
||||
}
|
||||
|
||||
/// `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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user