757e3ac1bd
The #300 keystone cycle: the residual run-quintet flags were already document vocabulary on the --real arm (#210/#220); this closes the loop and ratifies the surface. - `aura process|campaign show <content-id>` prints a registered document's canonical bytes (print!, no framing — #164), so generate -> retrieve -> hand-extend -> re-register needs no direct store filesystem access. Refusals reuse the store-hint prose, exit 1. - walkforward/mc --stop-length/--stop-k become clap-typed i64/f64 (generalize's form); stop_knob_or and its parse_csv_list helper are deleted (multi-value/invalid input is now clap's exit-2 rejection — the two dissolved-verb refusal tests retarget, the type-dead unit test is deleted). - --select accepts bare `plateau` as the documented default plateau:mean (#227 carry); document schema untouched — the two spellings generate content-id-identical campaigns (test-pinned). - family.rs's quadruplicated demo stop literal collapses onto one DEFAULT_STOP const (maintainer-lens drift finding). - Ledger: C25's "which projection next" line resolved (document-first delivered, per-verb identity re-ratified — F8; host/MCP stay demand-driven); C18 records the read-back. Design triage, fork decisions F1-F8, and the auto-signed spec live on the reference issue. Verification: full workspace suite 1482 passed / 0 failed with the real GER40 archive exercised (the byte-identity round-trips ran, not skipped); clippy -D warnings clean. closes #300
6710 lines
321 KiB
Rust
6710 lines
321 KiB
Rust
//! Integration test: drive the built `aura` binary as a downstream user would,
|
||
//! asserting the `run` subcommand's stdout/exit contract and the bad-args path.
|
||
|
||
use std::path::Path;
|
||
use std::process::Command;
|
||
|
||
mod common;
|
||
use common::{fresh_project, fresh_project_with_data, fresh_project_with_gapped_data};
|
||
|
||
/// Path to the freshly-built `aura` binary (Cargo sets this env var for the test
|
||
/// crate; the binary is named `aura` in `Cargo.toml`).
|
||
const BIN: &str = env!("CARGO_BIN_EXE_aura");
|
||
|
||
/// A fresh, unique working directory for a process test that persists run
|
||
/// records (so `aura sweep`'s `./runs/runs.jsonl` never dirties the repo).
|
||
/// Unique per test + per process; no external tempfile dependency.
|
||
fn temp_cwd(name: &str) -> std::path::PathBuf {
|
||
let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-cli-{name}"));
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
std::fs::create_dir_all(&dir).expect("create temp cwd");
|
||
dir
|
||
}
|
||
|
||
/// #218: a well-formed dissolved verb run outside a project refuses up front
|
||
/// (exit 1, "needs a project") and leaves no store — parity with `campaign run`.
|
||
/// The refusal names the INVOKED verb, not a copy-pasted generic string — a
|
||
/// per-verb literal shared across four call sites (`validate_and_register_axes`
|
||
/// takes `verb` as a parameter) is exactly the kind of edit that silently
|
||
/// regresses to one hardcoded verb name; this pins that each call site still
|
||
/// threads its own name through.
|
||
#[test]
|
||
fn sweep_outside_project_refuses_and_leaves_no_store() {
|
||
let cwd = temp_cwd("sweep-outside-project-refuses");
|
||
let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["sweep", &bp, "--real", "GER40", "--axis", "sma_signal.fast.length=2,4"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura");
|
||
assert_eq!(out.status.code(), Some(1), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||
assert!(String::from_utf8_lossy(&out.stderr).contains("sweep needs a project"));
|
||
assert!(!cwd.join("runs").exists(), "a refused run must leave no store");
|
||
}
|
||
|
||
/// #218: same refusal as `sweep_outside_project_refuses_and_leaves_no_store`,
|
||
/// for `generalize`'s instrument-list form — the message names `generalize`,
|
||
/// not `sweep` (see that test's doc comment for the property).
|
||
#[test]
|
||
fn generalize_outside_project_refuses_and_leaves_no_store() {
|
||
let cwd = temp_cwd("generalize-outside-project-refuses");
|
||
let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["generalize", &bp, "--real", "GER40,USDJPY", "--axis", "sma_signal.fast.length=2"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura");
|
||
assert_eq!(out.status.code(), Some(1), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||
assert!(String::from_utf8_lossy(&out.stderr).contains("generalize needs a project"));
|
||
assert!(!cwd.join("runs").exists(), "a refused run must leave no store");
|
||
}
|
||
|
||
/// #218: same refusal as `sweep_outside_project_refuses_and_leaves_no_store`,
|
||
/// for `walkforward`'s valid `--axis` form — the message names `walkforward`,
|
||
/// not `sweep` (see that test's doc comment for the property).
|
||
#[test]
|
||
fn walkforward_outside_project_refuses_and_leaves_no_store() {
|
||
let cwd = temp_cwd("walkforward-outside-project-refuses");
|
||
let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["walkforward", &bp, "--real", "GER40", "--axis", "sma_signal.fast.length=2,4"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura");
|
||
assert_eq!(out.status.code(), Some(1), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||
assert!(String::from_utf8_lossy(&out.stderr).contains("walkforward needs a project"));
|
||
assert!(!cwd.join("runs").exists(), "a refused run must leave no store");
|
||
}
|
||
|
||
/// #218: same refusal as `sweep_outside_project_refuses_and_leaves_no_store`,
|
||
/// for `mc`'s valid `--axis` form — the message names `mc`, not `sweep` (see
|
||
/// that test's doc comment for the property).
|
||
#[test]
|
||
fn mc_outside_project_refuses_and_leaves_no_store() {
|
||
let cwd = temp_cwd("mc-outside-project-refuses");
|
||
let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["mc", &bp, "--real", "GER40", "--axis", "sma_signal.fast.length=2,4"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura");
|
||
assert_eq!(out.status.code(), Some(1), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||
assert!(String::from_utf8_lossy(&out.stderr).contains("mc needs a project"));
|
||
assert!(!cwd.join("runs").exists(), "a refused run must leave no store");
|
||
}
|
||
|
||
/// Property (#275, retrofitted): `run_signal_r` now keys its opened sources by
|
||
/// role name (`h.run_bound(key_supply(&binding, sources))`) instead of feeding
|
||
/// the harness positionally (`h.run(sources)`). This pre-existing happy-path
|
||
/// pin therefore doubles as the by-name-binding transparency check for that one
|
||
/// call site: a well-bound strategy's by-name path resolves back to the exact
|
||
/// same source order `bind_sources` was given, so the migration is invisible
|
||
/// here — unchanged exit code, unchanged JSON output byte-for-byte. It does NOT
|
||
/// exercise `run_blueprint_member` or the campaign re-run/trace path — those
|
||
/// two migrated call sites are covered by the sweep/campaign e2e fixtures
|
||
/// elsewhere in this crate, not by this test.
|
||
#[test]
|
||
fn run_prints_json_and_exits_zero() {
|
||
// `run` is blueprint-only now (#159 cut 4 retired the bare built-in default);
|
||
// the shipped r-sma example is the deterministic reference blueprint.
|
||
let out = Command::new(BIN).args(["run", "examples/r_sma.json"]).output().expect("spawn aura run");
|
||
assert!(out.status.success(), "exit status: {:?}", out.status);
|
||
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
// exactly one line (the JSON object + a trailing newline from println!).
|
||
assert_eq!(stdout.lines().count(), 1, "stdout was: {stdout:?}");
|
||
let line = stdout.trim_end();
|
||
|
||
// canonical cycle-0009 JSON shape: nested manifest + metrics, stable keys.
|
||
// The manifest leads with a `commit` field; its value is the build's git
|
||
// identity (asserted by `run_manifest_commit_carries_real_git_head`), so
|
||
// this shape check pins only the surrounding structure, not the value.
|
||
assert!(line.starts_with("{\"manifest\":{\"commit\":\""), "got: {line}");
|
||
assert!(line.contains("\"broker\":\"sim-optimal+risk-executor(pip_size=0.0001)\""), "got: {line}");
|
||
assert!(line.contains("\"window\":[1,18]"), "got: {line}");
|
||
assert!(line.contains("\"metrics\":{\"total_pips\":"), "got: {line}");
|
||
// the r-sma harness carries an R block (unlike the retired pip-only default).
|
||
assert!(line.contains("\"r\":{\"expectancy_r\":"), "got: {line}");
|
||
}
|
||
|
||
/// Property (#249): a plain `aura run` on a fully bound blueprint records the
|
||
/// untouched bound param values *directly in the outcome artifact*, so a raw
|
||
/// reader of the manifest recovers the effective params without dereferencing
|
||
/// the topology hash into the content-addressed store and re-implementing
|
||
/// aura's merge semantics. The manifest carries a `defaults` field — the bound
|
||
/// params merged inside `PrimitiveBuilder::build` below every record-writing
|
||
/// layer — as wrap-prefixed `(name, Scalar)` pairs in `bound_param_space()`
|
||
/// order (the same coordinate `--axis` uses). `params` keeps its "what varied"
|
||
/// meaning: on a plain run nothing varied, so it stays `[]`, and every bound
|
||
/// value lands in `defaults` instead of vanishing from the record.
|
||
#[test]
|
||
fn run_manifest_stamps_untouched_bound_defaults() {
|
||
// The shipped r_sma.json is fully bound (fast.length=2, slow.length=4,
|
||
// bias.scale=0.5); no axis re-opens any of them, so none flow through the
|
||
// param space — today they appear in no outcome artifact ("params":[]).
|
||
let out = Command::new(BIN).args(["run", "examples/r_sma.json"]).output().expect("spawn aura run");
|
||
assert!(out.status.success(), "exit status: {:?}", out.status);
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
let line = stdout.trim_end();
|
||
|
||
// "what varied" is empty on a plain run — the bound defaults are NOT params.
|
||
assert!(line.contains("\"params\":[]"), "params stays empty on a plain run: {line}");
|
||
|
||
// The untouched bound values are stamped in `defaults`, wrap-prefixed with
|
||
// the blueprint name and ordered by `bound_param_space()` (node-declaration
|
||
// order: fast, slow, bias), each a self-describing tagged Scalar (C14).
|
||
assert!(
|
||
line.contains(
|
||
r#""defaults":[["sma_signal.fast.length",{"I64":2}],["sma_signal.slow.length",{"I64":4}],["sma_signal.bias.scale",{"F64":0.5}]]"#
|
||
),
|
||
"manifest must carry the untouched bound defaults: {line}",
|
||
);
|
||
}
|
||
|
||
/// Property: the RunManifest is self-identifying — its `commit` carries the
|
||
/// real code identity that produced the run, not a placeholder. C8/C18 audit
|
||
/// trail (this run = this commit): once runs are archived, `manifest.commit`
|
||
/// must point back at the source. The build captures the current git HEAD at
|
||
/// compile time; the `"unknown"` literal survives only when there is no git.
|
||
///
|
||
/// Structural assertion (not exact-equality) on purpose: the working tree may
|
||
/// be dirty when this runs (e.g. this very test file uncommitted), so a build
|
||
/// that appends a `-dirty` suffix would not equal `rev-parse HEAD` verbatim.
|
||
/// The honest contract is: the field CONTAINS the current HEAD sha AND is not
|
||
/// the bare `"unknown"` placeholder — true regardless of dirtiness/suffix.
|
||
#[test]
|
||
fn run_manifest_commit_carries_real_git_head() {
|
||
// The current HEAD sha, obtained the same way the build is expected to.
|
||
let head = Command::new("git")
|
||
.args(["rev-parse", "HEAD"])
|
||
.output()
|
||
.expect("spawn git rev-parse HEAD");
|
||
assert!(head.status.success(), "git rev-parse HEAD failed");
|
||
let head_sha = String::from_utf8(head.stdout).expect("utf-8 sha");
|
||
let head_sha = head_sha.trim();
|
||
assert!(!head_sha.is_empty(), "empty HEAD sha");
|
||
|
||
// Drive the binary and pull `manifest.commit` out of the single JSON line.
|
||
// `run` is blueprint-only now (#159 cut 4); the shipped r-sma example stands in.
|
||
let out = Command::new(BIN).args(["run", "examples/r_sma.json"]).output().expect("spawn aura run");
|
||
assert!(out.status.success(), "exit status: {:?}", out.status);
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
let line = stdout.trim_end();
|
||
|
||
// Locate the `"commit":"<value>"` value without a JSON dependency (the
|
||
// sibling tests parse this output by substring too).
|
||
let key = "\"commit\":\"";
|
||
let start = line.find(key).expect("manifest has a commit field") + key.len();
|
||
let rest = &line[start..];
|
||
let end = rest.find('"').expect("commit field is a closed string");
|
||
let commit = &rest[..end];
|
||
|
||
assert_ne!(
|
||
commit, "unknown",
|
||
"manifest.commit is still the placeholder: {line}"
|
||
);
|
||
assert!(
|
||
commit.contains(head_sha),
|
||
"manifest.commit {commit:?} should contain the current HEAD sha {head_sha:?}; line: {line}"
|
||
);
|
||
}
|
||
|
||
/// Property: `aura run` is strict about its argument vector — a trailing
|
||
/// unexpected token (e.g. a typo'd flag like `aura run --sweep`) takes the
|
||
/// error path (usage on stderr, exit 2), never a silent full run. This is the
|
||
/// strict reading ratified in #16: keying only on the first token being `run`
|
||
/// and ignoring the rest would let a typo masquerade as a successful run.
|
||
/// The same test pins positive-preservation — a valid `aura run <blueprint.json>`
|
||
/// still emits the JSON report on stdout and exits 0 — so the strict guard cannot
|
||
/// regress the happy path. (`run` is blueprint-only now, #159 cut 4: there is no
|
||
/// more bare/built-in default to preserve, so the positive case uses the shipped
|
||
/// r-sma example.)
|
||
#[test]
|
||
fn run_with_trailing_token_is_strict_and_exits_two() {
|
||
// Negative: an unexpected trailing token is rejected like a bad-args call.
|
||
let out = Command::new(BIN)
|
||
.args(["run", "extra-garbage"])
|
||
.output()
|
||
.expect("spawn aura run extra-garbage");
|
||
assert_eq!(
|
||
out.status.code(),
|
||
Some(2),
|
||
"`aura run extra-garbage` must reject the trailing token: {:?}",
|
||
out.status
|
||
);
|
||
assert!(
|
||
out.stdout.is_empty(),
|
||
"stdout should be empty on the usage path, got: {:?}",
|
||
String::from_utf8_lossy(&out.stdout)
|
||
);
|
||
let stderr = String::from_utf8(out.stderr).expect("utf-8 stderr");
|
||
assert!(stderr.contains("Usage"), "stderr was: {stderr:?}");
|
||
|
||
// Positive-preservation: a valid blueprint invocation still runs and prints the report.
|
||
let ok = Command::new(BIN)
|
||
.args(["run", "examples/r_sma.json"])
|
||
.output()
|
||
.expect("spawn aura run examples/r_sma.json");
|
||
assert_eq!(
|
||
ok.status.code(),
|
||
Some(0),
|
||
"a valid `aura run <blueprint.json>` must still succeed: {:?}",
|
||
ok.status
|
||
);
|
||
let ok_stdout = String::from_utf8(ok.stdout).expect("utf-8 stdout");
|
||
assert!(
|
||
ok_stdout.trim_start().starts_with("{\"manifest\":"),
|
||
"a valid run should print the JSON report, got: {ok_stdout:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn run_real_no_geometry_symbol_refuses_with_exit_1() {
|
||
// The geometry-sidecar lookup precedes any bar-data access, so a symbol with no
|
||
// recorded geometry refuses with NO local data required (CI-safe). "NONEXISTENT"
|
||
// has no recorded geometry on any host. `run` is blueprint-only now (#159 cut 4);
|
||
// the shipped r-sma example reaches the same pip-refusal gate in `run_data_from`.
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["run", "examples/r_sma.json", "--real", "NONEXISTENT"])
|
||
.output()
|
||
.expect("spawn aura");
|
||
assert_eq!(out.status.code(), Some(1), "expected exit 1");
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(
|
||
stderr.contains("no recorded geometry for symbol 'NONEXISTENT'"),
|
||
"expected the per-instrument-pip refusal message, got: {stderr}"
|
||
);
|
||
}
|
||
|
||
/// Property: the pip-refusal is sourced ONLY from recorded provider geometry — the
|
||
/// removed authored instrument floor (the hand-authored "vetted" table) no
|
||
/// longer participates in the refusal path. The cycle-0074 headline is the floor's
|
||
/// REMOVAL, not a rename; the observable signature is that the refusal message
|
||
/// never points the user at a hand-authored table to extend. A regression that
|
||
/// re-introduced an authored fallback (or restored the old "add it to the
|
||
/// instrument table" wording) would re-leak this vocabulary. Asserts the OLD
|
||
/// authored-floor phrasing is ABSENT — the negative complement to the positive
|
||
/// "no recorded geometry" assertion above. Archive-independent: "NONEXISTENT" has
|
||
/// no sidecar on any host, so the refusal fires without local data.
|
||
#[test]
|
||
fn run_real_refusal_names_no_authored_floor() {
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["run", "examples/r_sma.json", "--real", "NONEXISTENT"])
|
||
.output()
|
||
.expect("spawn aura");
|
||
assert_eq!(out.status.code(), Some(1), "expected exit 1");
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(
|
||
!stderr.contains("instrument table"),
|
||
"refusal must not point at a removed authored table, got: {stderr}"
|
||
);
|
||
assert!(
|
||
!stderr.contains("vetted"),
|
||
"refusal must not use the removed vetted-floor vocabulary, got: {stderr}"
|
||
);
|
||
assert!(
|
||
!stderr.contains("instrument spec"),
|
||
"refusal must not reference the removed authored instrument floor, got: {stderr}"
|
||
);
|
||
}
|
||
|
||
/// Property: the un-specced-symbol refusal is a CLEAN refusal — it emits NOTHING
|
||
/// on stdout, never a partial `RunReport`. The #22 acceptance is "refuse INSTEAD
|
||
/// of emitting nonsense": the pip lookup precedes harness construction and data
|
||
/// access, so a `None` spec must short-circuit to `exit(1)` before any JSON line
|
||
/// can reach stdout. A regression that moved the lookup after a partial run would
|
||
/// leak a `{"manifest":...}` line here while still exiting non-zero; this pins
|
||
/// that it cannot. Archive-independent (the lookup never touches local data).
|
||
#[test]
|
||
fn run_real_no_geometry_symbol_emits_no_stdout_report() {
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["run", "examples/r_sma.json", "--real", "NONEXISTENT"])
|
||
.output()
|
||
.expect("spawn aura");
|
||
assert_eq!(out.status.code(), Some(1), "expected exit 1: {:?}", out.status);
|
||
assert!(
|
||
out.stdout.is_empty(),
|
||
"the refusal must not leak a partial report to stdout, got: {:?}",
|
||
String::from_utf8_lossy(&out.stdout)
|
||
);
|
||
}
|
||
|
||
/// Property: the pip-honesty refusal keys on the SYMBOL having no recorded geometry,
|
||
/// not on `--real` being taken at all. A symbol *with* a recorded sidecar (`EURUSD`)
|
||
/// gets PAST the geometry lookup, so it never produces the pip-refusal message — it
|
||
/// either runs to a report (local data present) or hits the DISTINCT no-local-data
|
||
/// usage path ("no local data for symbol"). Either way the "no recorded geometry"
|
||
/// string is absent. This distinguishes the per-instrument-pip honesty lever from
|
||
/// the unrelated arg-grammar / no-data error paths, and is archive-independent
|
||
/// (asserts only on the absence of the pip-refusal string, true whether or not
|
||
/// EURUSD data is on the machine).
|
||
#[test]
|
||
fn run_real_sidecar_symbol_never_hits_the_pip_refusal() {
|
||
// A bounded window (the same EURUSD 2024-06 calendar month the gated sweep tests
|
||
// use) keeps this fast; the full archive is unbounded and not needed for a
|
||
// pip-lookup property. `run` is blueprint-only now (#159 cut 4).
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"run", "examples/r_sma.json", "--real", "EURUSD",
|
||
"--from", &EURUSD_JUN2024_FROM_MS.to_string(),
|
||
"--to", &EURUSD_JUN2024_TO_MS.to_string(),
|
||
])
|
||
.output()
|
||
.expect("spawn aura");
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(
|
||
!stderr.contains("no recorded geometry"),
|
||
"a symbol with a recorded sidecar must clear the pip lookup, never the pip-refusal; stderr: {stderr}"
|
||
);
|
||
// It resolves to exactly one of the two legitimate outcomes: a clean report
|
||
// (exit 0, data present) or the distinct no-local-data refusal (exit 1).
|
||
match out.status.code() {
|
||
Some(0) => assert!(
|
||
String::from_utf8_lossy(&out.stdout)
|
||
.trim_start()
|
||
.starts_with("{\"manifest\":"),
|
||
"exit 0 must carry a JSON report, got: {:?}",
|
||
String::from_utf8_lossy(&out.stdout)
|
||
),
|
||
Some(1) => assert!(
|
||
stderr.contains("no local data for symbol 'EURUSD'"),
|
||
"exit 1 for a symbol with a recorded sidecar must be the no-data path, got: {stderr}"
|
||
),
|
||
other => panic!("unexpected exit for a real run with a recorded sidecar: {other:?}; stderr: {stderr}"),
|
||
}
|
||
}
|
||
|
||
/// Property: a recorded-sidecar symbol threads its looked-up pip all the way to the
|
||
/// binary's emitted manifest — `aura run --real GER40` renders the INDEX pip
|
||
/// `sim-optimal(pip_size=1)`, not the FX `0.0001` literal the synthetic path uses.
|
||
/// This is the #22 headline at the built-binary boundary (criterion 2: GER40=1.0
|
||
/// vs EURUSD=0.0001 vs no-geometry→refuse): the metadata channel reaches stdout, so
|
||
/// equity is honestly scaled per instrument. Gated on local GER40 data — skip
|
||
/// cleanly when the archive is absent (the project's skip-on-no-data convention),
|
||
/// so it never fails on a data-less machine; the no-data path is the distinct
|
||
/// "no local data" refusal (exit 1), not the pip-refusal, asserted above.
|
||
#[test]
|
||
fn run_real_sidecar_index_pip_reaches_the_emitted_manifest() {
|
||
// The GER40 Sept-2024 window (inclusive Unix-ms), the same calendar
|
||
// month the gated ingest path drives; bounding it keeps the run fast.
|
||
const FROM_MS: &str = "1725148800000";
|
||
const TO_MS: &str = "1727740799999";
|
||
// `run` is blueprint-only now (#159 cut 4); the shipped r-sma example still
|
||
// routes through the same real-data pip lookup + manifest stamping.
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["run", "examples/r_sma.json", "--real", "GER40", "--from", FROM_MS, "--to", TO_MS])
|
||
.output()
|
||
.expect("spawn aura");
|
||
|
||
// Skip on a data-less machine: a recorded-sidecar symbol with no local data takes
|
||
// the distinct no-data path (exit 1, "no local data"), never the pip-refusal.
|
||
if out.status.code() == Some(1) {
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(
|
||
stderr.contains("no local data for symbol 'GER40'"),
|
||
"exit 1 for GER40 must be the no-data path, got: {stderr}"
|
||
);
|
||
eprintln!("skip: no local GER40 data");
|
||
return;
|
||
}
|
||
|
||
assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status);
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
let line = stdout.trim_end();
|
||
// the looked-up index pip (1.0 -> "1") reaches the manifest, not the FX 0.0001.
|
||
assert!(
|
||
line.contains("\"broker\":\"sim-optimal+risk-executor(pip_size=1)\""),
|
||
"GER40 must render the index pip in the manifest, got: {line}"
|
||
);
|
||
}
|
||
|
||
/// Property: the authored instrument table no longer gates real runs — a symbol
|
||
/// absent from the (removed) vetted floor but carrying a recorded geometry sidecar
|
||
/// resolves its pip from the sidecar and runs. USDJPY (never vetted; JPY pip 0.01)
|
||
/// renders the JPY pip in the manifest, proving the pip is sourced from the provider
|
||
/// geometry, not a hand-authored table entry. Gated: skips cleanly when USDJPY has
|
||
/// no local geometry/data (no sidecar → "no recorded geometry"; sidecar but no bars
|
||
/// → "no local data").
|
||
#[test]
|
||
fn run_real_nonvetted_symbol_resolves_pip_from_sidecar() {
|
||
// FX trades continuously; the GER40 Sept-2024 window also has USDJPY bars.
|
||
const FROM_MS: &str = "1725148800000";
|
||
const TO_MS: &str = "1727740799999";
|
||
// `run` is blueprint-only now (#159 cut 4); the shipped r-sma example still
|
||
// routes through the same real-data pip lookup.
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["run", "examples/r_sma.json", "--real", "USDJPY", "--from", FROM_MS, "--to", TO_MS])
|
||
.output()
|
||
.expect("spawn aura");
|
||
|
||
// Skip on a host without USDJPY geometry/data — either refusal is a clean skip,
|
||
// sourced from recorded geometry, never an authored-table miss.
|
||
if out.status.code() == Some(1) {
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
if stderr.contains("no recorded geometry") || stderr.contains("no local data") {
|
||
eprintln!("skip: no local USDJPY geometry/data");
|
||
return;
|
||
}
|
||
}
|
||
|
||
assert_eq!(out.status.code(), Some(0), "USDJPY should resolve and run; exit: {:?}, stderr: {}",
|
||
out.status, String::from_utf8_lossy(&out.stderr));
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
assert!(
|
||
stdout.contains("pip_size=0.01"),
|
||
"USDJPY must render the JPY sidecar pip (0.01) in the manifest, got: {}",
|
||
stdout.trim_end()
|
||
);
|
||
}
|
||
|
||
/// `aura run <blueprint.json>` loads a serialized signal blueprint and runs it
|
||
/// end-to-end (#165): exit 0, a RunReport whose manifest carries the topology_hash.
|
||
#[test]
|
||
fn aura_run_loads_and_runs_a_blueprint_file() {
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["run", "examples/r_sma.json"])
|
||
.output()
|
||
.expect("spawn aura run blueprint");
|
||
assert_eq!(
|
||
out.status.code(),
|
||
Some(0),
|
||
"exit: {:?} stderr={}",
|
||
out.status,
|
||
String::from_utf8_lossy(&out.stderr)
|
||
);
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8");
|
||
assert!(stdout.contains("\"topology_hash\":\""), "manifest carries the topology hash: {stdout}");
|
||
}
|
||
|
||
/// A blueprint referencing a node type outside the closed vocabulary fails clean
|
||
/// at load (the #160 closed-set guard at the data-plane face; invariant 9), and
|
||
/// (#184) the failure is phrased as house-style prose — matching the `graph
|
||
/// build` presenter's wording — never the raw `UnknownNodeType` Debug leak.
|
||
#[test]
|
||
fn aura_run_rejects_an_unknown_node_blueprint() {
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["run", "tests/fixtures/unknown_node.json"])
|
||
.output()
|
||
.expect("spawn aura run unknown");
|
||
assert_ne!(out.status.code(), Some(0), "an unknown node type must fail the run");
|
||
let stderr = String::from_utf8(out.stderr).expect("utf-8");
|
||
assert!(
|
||
stderr.contains(r#"unknown node type "Nope""#),
|
||
"house-style prose names the type: {stderr}"
|
||
);
|
||
assert!(
|
||
!stderr.contains("UnknownNodeType"),
|
||
"does not leak the Debug variant name: {stderr}"
|
||
);
|
||
}
|
||
|
||
/// Property (#175, refuse-don't-guess): built-in-only flags on `aura run
|
||
/// <blueprint.json>` are REFUSED, not silently dropped. The loaded-blueprint
|
||
/// grammar accepts only `--params`/`--seed`/`--real`/`--from`/`--to`; the removed
|
||
/// hand-parser rejected any other flag (exit 2). clap's optional `[blueprint]`
|
||
/// positional makes `--trace` structurally parseable (it stays on `RunCmd` for
|
||
/// the built-in-harness grammar), so the dispatch guard must re-reject it —
|
||
/// mirroring the sweep/mc blueprint branches, which reject their non-branch
|
||
/// flags exhaustively. A regression that dropped the guard would silently
|
||
/// no-op a user-provided flag. (The cost flags this test used to also cover
|
||
/// were removed from `RunCmd` entirely in #159's builtin retirement; clap now
|
||
/// rejects them as unknown args before dispatch ever sees them, so they no
|
||
/// longer exercise this guard.)
|
||
#[test]
|
||
fn run_blueprint_rejects_builtin_only_flags_exit_two() {
|
||
let args = ["run", "examples/r_sma.json", "--trace", "foo"];
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(args)
|
||
.output()
|
||
.expect("spawn aura run blueprint + builtin-only flag");
|
||
assert_eq!(
|
||
out.status.code(),
|
||
Some(2),
|
||
"{args:?} must be refused (exit 2), got {:?}; stderr: {}",
|
||
out.status,
|
||
String::from_utf8_lossy(&out.stderr)
|
||
);
|
||
assert!(
|
||
out.stdout.is_empty(),
|
||
"{args:?} must not emit a report on stdout: {:?}",
|
||
String::from_utf8_lossy(&out.stdout)
|
||
);
|
||
}
|
||
|
||
// Note (#159 cut 4 collateral): `--trace` on a single `aura run`/`aura sweep`
|
||
// is no longer reachable from any CLI surface — the built-in default that
|
||
// accepted it retired with the last two PIP strategies, and the blueprint
|
||
// branches (`dispatch_run`, `run_blueprint_sweep`) never wire a trace-persist
|
||
// path. Repointing would require new production code (out of scope for a
|
||
// test-fixing task), so the single-run trace-persist + shape tests are
|
||
// deleted here rather than adjusted.
|
||
|
||
/// Property: an unknown `chart <name>` is a runtime failure pinned at BOTH the exit code
|
||
/// and the user-facing message — exit 1 (never a panic) AND the NotFound branch's
|
||
/// "no recorded run or family '<name>'" wording, which now covers families too. A
|
||
/// regression that drifted the message (e.g. back to the run-only phrasing) or
|
||
/// downgraded the exit would slip past the bundled exit-only check; this pins the
|
||
/// message/exit contract. Archive-local: charts into an empty cwd so the name is
|
||
/// genuinely absent.
|
||
#[test]
|
||
fn chart_unknown_name_refuses_with_exit_1_and_message() {
|
||
let cwd = temp_cwd("chart-unknown");
|
||
|
||
let out = Command::new(BIN).args(["chart", "ghost"]).current_dir(&cwd).output().expect("spawn chart ghost");
|
||
assert_eq!(out.status.code(), Some(1), "unknown name must exit 1: {:?}", out.status);
|
||
assert!(out.stdout.is_empty(), "the refusal must not leak a chart page to stdout, got: {:?}", String::from_utf8_lossy(&out.stdout));
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(
|
||
stderr.contains("no recorded run or family 'ghost'"),
|
||
"expected the unknown-name (run-or-family) refusal message, got: {stderr}"
|
||
);
|
||
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// Property (milestone fieldtest B1): a sweep / walk-forward `--trace` family is
|
||
/// chartable by the very handle the producing run prints. Those verbs fan a
|
||
/// selection-free sweep out per member into the #224 depth-2 layout
|
||
/// `runs/traces/<name>/<cell>/<member>/index.json` (one directory level deeper
|
||
/// than the campaign path's depth-1 nominee layout `<name>/<cell>/index.json`).
|
||
/// `aura chart <handle>` must resolve that fan-out as a family: exit 0 with the
|
||
/// chart page on stdout, covering the members' taps. AND the genuinely-absent-
|
||
/// handle refusal must not tell the user to re-run a command with that same
|
||
/// handle as the `--trace` argument — that command produced the data (it would
|
||
/// already be on disk), so pointing back at it is a dead-end remedy.
|
||
///
|
||
/// The fixture is fabricated inline through `TraceStore` (no archive, no
|
||
/// data-server, no sweep run): two members under one cell, exactly the depth-2
|
||
/// shape `persist_campaign_traces`' member fan-out writes. On today's tree the
|
||
/// depth-1-only `name_kind` / `read_family` classify the handle as NotFound, so
|
||
/// `chart` exits 1 with the misleading remedy — RED on both counts.
|
||
#[test]
|
||
fn chart_opens_a_sweep_trace_family_by_its_printed_handle() {
|
||
use aura_core::{Scalar, ScalarKind, Timestamp};
|
||
use aura_engine::{ColumnarTrace, RunManifest};
|
||
use aura_registry::TraceStore;
|
||
|
||
let cwd = temp_cwd("chart-sweep-family");
|
||
|
||
let manifest = || RunManifest {
|
||
commit: "c0ffee".to_string(),
|
||
params: vec![("fast".to_string(), Scalar::f64(2.0))],
|
||
defaults: vec![],
|
||
window: (Timestamp(1), Timestamp(2)),
|
||
seed: 0,
|
||
broker: "sim-optimal(pip_size=1)".to_string(),
|
||
selection: None,
|
||
instrument: Some("GER40".to_string()),
|
||
topology_hash: None,
|
||
project: None,
|
||
};
|
||
let taps = |a: f64, b: f64| {
|
||
vec![ColumnarTrace::from_rows(
|
||
"equity",
|
||
&[ScalarKind::F64],
|
||
&[(Timestamp(1), vec![Scalar::f64(a)]), (Timestamp(2), vec![Scalar::f64(b)])],
|
||
)]
|
||
};
|
||
|
||
// The #224 sweep fan-out layout: two members under ONE cell, so each member's
|
||
// index.json lives at depth 2 (`<name>/<cell>/<member>/index.json`) — not the
|
||
// depth-1 nominee layout (`<name>/<cell>/index.json`) the campaign path writes.
|
||
// `TraceStore::open` roots at `<runs_dir>/traces`, matching `env.trace_store()`.
|
||
let store = TraceStore::open(cwd.join("runs"));
|
||
store.write("swp/cell0/m0", &manifest(), &taps(0.0, 0.4)).expect("write member m0");
|
||
store.write("swp/cell0/m1", &manifest(), &taps(0.0, 0.7)).expect("write member m1");
|
||
|
||
// (1) chart the family by the handle the sweep prints: exit 0 + chart page.
|
||
let out = Command::new(BIN).args(["chart", "swp"]).current_dir(&cwd).output().expect("spawn chart swp");
|
||
assert_eq!(
|
||
out.status.code(),
|
||
Some(0),
|
||
"charting a sweep --trace family by its handle must exit 0; got {:?}, stderr: {}",
|
||
out.status.code(),
|
||
String::from_utf8_lossy(&out.stderr),
|
||
);
|
||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||
assert!(
|
||
stdout.contains("window.AURA_TRACES") && stdout.contains("<title>aura chart"),
|
||
"the family handle must render a chart page covering its members' taps \
|
||
(AURA_TRACES + chart title); stdout len {}",
|
||
stdout.len(),
|
||
);
|
||
|
||
// (2) the genuinely-absent-handle refusal must not suggest re-running a
|
||
// command with the handle itself as the `--trace` argument (a dead-end remedy).
|
||
let ghost = Command::new(BIN).args(["chart", "ghost"]).current_dir(&cwd).output().expect("spawn chart ghost");
|
||
assert_eq!(ghost.status.code(), Some(1), "an absent handle still exits 1: {:?}", ghost.status);
|
||
let gerr = String::from_utf8_lossy(&ghost.stderr);
|
||
assert!(
|
||
!gerr.contains("--trace ghost"),
|
||
"the not-found remedy must not tell the user to re-run with the handle as --trace \
|
||
(that command produced the data / would not create this handle); got: {gerr}",
|
||
);
|
||
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// Property (#238): a sweep / walk-forward `--trace <NAME>` family is chartable by
|
||
/// the very NAME the user chose — not only by the content-derived handle the run
|
||
/// prints. `--trace <NAME>` lands `NAME` in the generated campaign document's
|
||
/// `name` field (content-addressed via `put_campaign`), while the trace directory
|
||
/// stays keyed by the content-derived handle `{campaign8}-{run}` that
|
||
/// `append_campaign_run` re-derives (C18, untouched). Today `aura chart <NAME>`
|
||
/// classifies `NAME` as an unknown trace handle and exits 1, so the chosen name
|
||
/// has no charting effect. It must instead resolve `NAME` against the recorded
|
||
/// campaign runs: the ONE record whose stored campaign document carries
|
||
/// `name == NAME` and a persisted `trace_name` charts that family by its handle —
|
||
/// exit 0 with the same chart page charting the handle directly renders.
|
||
///
|
||
/// The state is fabricated in-process, no archive / sweep run: a campaign
|
||
/// document carrying a distinctive `name` is stored via `put_campaign`; a
|
||
/// campaign-run record with a `Some` trace_name sentinel is appended (which
|
||
/// re-derives the handle `{campaign8}-0`); a family is written under that exact
|
||
/// handle via `TraceStore` — mirroring the handle-charted sibling above (the
|
||
/// #224 depth-2 fan-out). The registry roots where `env.registry()` does
|
||
/// (`<cwd>/runs/runs.jsonl`), so the running binary reads the same store. On
|
||
/// today's tree `chart <NAME>` hits `emit_chart`'s NotFound arm (no registry
|
||
/// query), so it exits 1 with the "no recorded run or family" message — RED for
|
||
/// the right reason (the name-resolution is absent, not a fabrication error).
|
||
#[test]
|
||
fn chart_resolves_a_trace_family_by_its_chosen_campaign_name() {
|
||
use aura_core::{Scalar, ScalarKind, Timestamp};
|
||
use aura_engine::{ColumnarTrace, RunManifest};
|
||
use aura_registry::{derive_trace_name, CampaignRunRecord, Registry, TraceStore};
|
||
|
||
let cwd = temp_cwd("chart-by-campaign-name");
|
||
|
||
// The chosen family name (`--trace <NAME>`) — distinctive, not a trace handle
|
||
// (no such directory under runs/traces, so `name_kind` classifies NotFound).
|
||
const CAMPAIGN_NAME: &str = "mom-ger40-sept24";
|
||
|
||
// (1) Store a campaign document carrying that name (a valid CampaignDoc, so a
|
||
// resolver can parse it by any strategy — Value lookup or full deserialize).
|
||
// `put_campaign` self-keys by content, returning the id the campaign-run
|
||
// record must reference. The registry roots where `env.registry()` does:
|
||
// `<cwd>/runs/runs.jsonl` (campaign docs at `<cwd>/runs/campaigns/<id>.json`,
|
||
// campaign_runs at `<cwd>/runs/campaign_runs.jsonl`).
|
||
let reg = Registry::open(cwd.join("runs").join("runs.jsonl"));
|
||
let campaign_json = r#"{
|
||
"format_version": 1,
|
||
"kind": "campaign",
|
||
"name": "__NAME__",
|
||
"data": { "instruments": ["GER40"],
|
||
"windows": [ { "from_ms": 1, "to_ms": 2 } ] },
|
||
"strategies": [
|
||
{ "ref": { "content_id": "9f3a" },
|
||
"axes": { "fast": { "kind": "I64", "values": [8, 12] } } }
|
||
],
|
||
"process": { "ref": { "content_id": "4e2d" } },
|
||
"seed": 1,
|
||
"presentation": { "persist_taps": ["equity"], "emit": ["family_table"] }
|
||
}"#
|
||
.replace("__NAME__", CAMPAIGN_NAME);
|
||
let campaign_id = reg.put_campaign(&campaign_json).expect("store campaign document");
|
||
|
||
// (2) Append a campaign-run record whose trace_name is Some (the executor's
|
||
// claim sentinel; `append_campaign_run` assigns the per-campaign run counter
|
||
// and re-derives the stored trace_name to `{campaign8}-{run}`).
|
||
let record = CampaignRunRecord {
|
||
campaign: campaign_id.clone(),
|
||
process: "4e2d".to_string(),
|
||
run: 0, // ignored — append assigns the counter
|
||
seed: 1,
|
||
cells: vec![],
|
||
generalizations: vec![],
|
||
trace_name: Some("claim".to_string()),
|
||
};
|
||
let run = reg.append_campaign_run(&record).expect("append campaign run");
|
||
let handle = derive_trace_name(&campaign_id, run);
|
||
|
||
// (3) Write a family under that exact handle via TraceStore — the same
|
||
// fabrication the handle-charted sibling uses (#224 depth-2: two members under
|
||
// one cell). Charting `handle` directly is thus known to render a family chart.
|
||
let manifest = || RunManifest {
|
||
commit: "c0ffee".to_string(),
|
||
params: vec![("fast".to_string(), Scalar::f64(2.0))],
|
||
defaults: vec![],
|
||
window: (Timestamp(1), Timestamp(2)),
|
||
seed: 0,
|
||
broker: "sim-optimal(pip_size=1)".to_string(),
|
||
selection: None,
|
||
instrument: Some("GER40".to_string()),
|
||
topology_hash: None,
|
||
project: None,
|
||
};
|
||
let taps = |a: f64, b: f64| {
|
||
vec![ColumnarTrace::from_rows(
|
||
"equity",
|
||
&[ScalarKind::F64],
|
||
&[(Timestamp(1), vec![Scalar::f64(a)]), (Timestamp(2), vec![Scalar::f64(b)])],
|
||
)]
|
||
};
|
||
let store = TraceStore::open(cwd.join("runs"));
|
||
store.write(&format!("{handle}/cell0/m0"), &manifest(), &taps(0.0, 0.4)).expect("write member m0");
|
||
store.write(&format!("{handle}/cell0/m1"), &manifest(), &taps(0.0, 0.7)).expect("write member m1");
|
||
|
||
// Chart by the chosen NAME (not the handle): resolution must go through the
|
||
// campaign-run query and land on `handle`, rendering the same family chart.
|
||
let out = Command::new(BIN).args(["chart", CAMPAIGN_NAME]).current_dir(&cwd).output().expect("spawn chart <name>");
|
||
assert_eq!(
|
||
out.status.code(),
|
||
Some(0),
|
||
"charting a --trace family by its chosen campaign name must exit 0; got {:?}, stderr: {}",
|
||
out.status.code(),
|
||
String::from_utf8_lossy(&out.stderr),
|
||
);
|
||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||
assert!(
|
||
stdout.contains("window.AURA_TRACES") && stdout.contains("<title>aura chart"),
|
||
"the chosen name must render the same family chart as its handle \
|
||
(AURA_TRACES + chart title); stdout len {}",
|
||
stdout.len(),
|
||
);
|
||
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// Property (#238 ambiguity): when TWO recorded campaign runs' stored documents
|
||
/// both carry the SAME chosen `name`, `chart <NAME>` must refuse (exit 1) rather
|
||
/// than silently pick one — the resolver names both candidate trace-store
|
||
/// handles in the refusal (deterministic file/append order), so the user can
|
||
/// re-run `chart` against the specific handle they mean. Mirrors the fixture of
|
||
/// [`chart_resolves_a_trace_family_by_its_chosen_campaign_name`], duplicated
|
||
/// once with a second `put_campaign` + `append_campaign_run` under the same
|
||
/// `name` (a distinct campaign document — the `data.windows` bound differs —
|
||
/// still resolves to the same `name`, exactly the "two separate --trace <NAME>
|
||
/// invocations reusing a name" scenario this guards against).
|
||
#[test]
|
||
fn chart_by_campaign_name_refuses_when_the_name_is_ambiguous() {
|
||
use aura_core::{Scalar, ScalarKind, Timestamp};
|
||
use aura_engine::{ColumnarTrace, RunManifest};
|
||
use aura_registry::{derive_trace_name, CampaignRunRecord, Registry, TraceStore};
|
||
|
||
let cwd = temp_cwd("chart-by-name-ambiguous");
|
||
const CAMPAIGN_NAME: &str = "reused-name";
|
||
|
||
let reg = Registry::open(cwd.join("runs").join("runs.jsonl"));
|
||
let campaign_json_of = |to_ms: i64| {
|
||
format!(
|
||
r#"{{
|
||
"format_version": 1,
|
||
"kind": "campaign",
|
||
"name": "{CAMPAIGN_NAME}",
|
||
"data": {{ "instruments": ["GER40"],
|
||
"windows": [ {{ "from_ms": 1, "to_ms": {to_ms} }} ] }},
|
||
"strategies": [
|
||
{{ "ref": {{ "content_id": "9f3a" }},
|
||
"axes": {{ "fast": {{ "kind": "I64", "values": [8, 12] }} }} }}
|
||
],
|
||
"process": {{ "ref": {{ "content_id": "4e2d" }} }},
|
||
"seed": 1,
|
||
"presentation": {{ "persist_taps": ["equity"], "emit": ["family_table"] }}
|
||
}}"#
|
||
)
|
||
};
|
||
|
||
let manifest = || RunManifest {
|
||
commit: "c0ffee".to_string(),
|
||
params: vec![("fast".to_string(), Scalar::f64(2.0))],
|
||
defaults: vec![],
|
||
window: (Timestamp(1), Timestamp(2)),
|
||
seed: 0,
|
||
broker: "sim-optimal(pip_size=1)".to_string(),
|
||
selection: None,
|
||
instrument: Some("GER40".to_string()),
|
||
topology_hash: None,
|
||
project: None,
|
||
};
|
||
let taps = |a: f64, b: f64| {
|
||
vec![ColumnarTrace::from_rows(
|
||
"equity",
|
||
&[ScalarKind::F64],
|
||
&[(Timestamp(1), vec![Scalar::f64(a)]), (Timestamp(2), vec![Scalar::f64(b)])],
|
||
)]
|
||
};
|
||
let store = TraceStore::open(cwd.join("runs"));
|
||
|
||
let mut handles = Vec::new();
|
||
for to_ms in [2, 3] {
|
||
let campaign_id = reg.put_campaign(&campaign_json_of(to_ms)).expect("store campaign document");
|
||
let record = CampaignRunRecord {
|
||
campaign: campaign_id.clone(),
|
||
process: "4e2d".to_string(),
|
||
run: 0,
|
||
seed: 1,
|
||
cells: vec![],
|
||
generalizations: vec![],
|
||
trace_name: Some("claim".to_string()),
|
||
};
|
||
let run = reg.append_campaign_run(&record).expect("append campaign run");
|
||
let handle = derive_trace_name(&campaign_id, run);
|
||
store.write(&format!("{handle}/cell0/m0"), &manifest(), &taps(0.0, 0.4)).expect("write member m0");
|
||
store.write(&format!("{handle}/cell0/m1"), &manifest(), &taps(0.0, 0.7)).expect("write member m1");
|
||
handles.push(handle);
|
||
}
|
||
|
||
let out = Command::new(BIN).args(["chart", CAMPAIGN_NAME]).current_dir(&cwd).output().expect("spawn chart <name>");
|
||
assert_eq!(
|
||
out.status.code(),
|
||
Some(1),
|
||
"an ambiguous campaign name must refuse (exit 1), not silently pick one; got {:?}, stdout: {}",
|
||
out.status.code(),
|
||
String::from_utf8_lossy(&out.stdout),
|
||
);
|
||
assert!(out.stdout.is_empty(), "the refusal must not leak a chart page to stdout");
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
for handle in &handles {
|
||
assert!(
|
||
stderr.contains(handle.as_str()),
|
||
"the refusal must list every candidate handle deterministically; got: {stderr}, want handle: {handle}"
|
||
);
|
||
}
|
||
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
#[test]
|
||
fn no_args_prints_usage_and_exits_two() {
|
||
let out = Command::new(BIN).output().expect("spawn aura");
|
||
assert_eq!(out.status.code(), Some(2), "exit status: {:?}", out.status);
|
||
assert!(out.stdout.is_empty(), "stdout should be empty on the usage path");
|
||
let stderr = String::from_utf8(out.stderr).expect("utf-8 stderr");
|
||
assert!(stderr.contains("Usage"), "stderr was: {stderr:?}");
|
||
}
|
||
|
||
/// Property: `--help`/`-h` is the success-path help affordance, not the error
|
||
/// path. A newcomer's reflex first command (C22 first-contact ergonomics, #20)
|
||
/// prints the usage to stdout and exits 0; the conventional `-h` alias behaves
|
||
/// identically. An *unknown* subcommand keeps the error path (exit 2) so the
|
||
/// help fix does not regress bad-args handling.
|
||
#[test]
|
||
fn help_flag_prints_usage_to_stdout_and_exits_zero() {
|
||
for flag in ["--help", "-h"] {
|
||
let out = Command::new(BIN).arg(flag).output().expect("spawn aura --help");
|
||
assert_eq!(out.status.code(), Some(0), "`aura {flag}` exit: {:?}", out.status);
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
assert!(
|
||
!stdout.trim().is_empty(),
|
||
"`aura {flag}` should print help to stdout, got: {stdout:?}"
|
||
);
|
||
// The usage names the only subcommand a newcomer can run.
|
||
assert!(
|
||
stdout.contains("run"),
|
||
"`aura {flag}` help should mention the `run` subcommand, got: {stdout:?}"
|
||
);
|
||
}
|
||
|
||
// Negative-preservation: an unknown subcommand is still the error path.
|
||
let out = Command::new(BIN).arg("frobnicate").output().expect("spawn aura frobnicate");
|
||
assert_eq!(
|
||
out.status.code(),
|
||
Some(2),
|
||
"unknown subcommand must stay exit 2: {:?}",
|
||
out.status
|
||
);
|
||
}
|
||
|
||
/// Property (post-clap migration, #175): subcommand help is SCOPED — `aura <sub>
|
||
/// --help` (and `-h`) prints that subcommand's own options to stdout and exits 0,
|
||
/// with nothing on stderr, for every subcommand. This supersedes the retired #131
|
||
/// "uniform (byte-identical) help" surface: clap gives each subcommand its own
|
||
/// scoped Options section rather than one shared global usage blob.
|
||
#[test]
|
||
fn per_subcommand_help_is_scoped_stdout_exit_zero() {
|
||
for sub in ["run", "chart", "sweep", "walkforward", "mc", "graph", "runs"] {
|
||
for help in ["--help", "-h"] {
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([sub, help]).output().expect("spawn");
|
||
assert_eq!(out.status.code(), Some(0), "{sub} {help} exit");
|
||
assert!(!out.stdout.is_empty(), "{sub} {help} stdout empty");
|
||
assert!(out.stderr.is_empty(), "{sub} {help} stderr not empty: {:?}",
|
||
String::from_utf8_lossy(&out.stderr));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Property: `aura graph` emits a single self-contained HTML page — the
|
||
/// interactive pin-graph viewer with the deterministic model inlined and the
|
||
/// vendored Graphviz-WASM + pan-zoom inlined (no remote fetch). Driven through
|
||
/// the built binary so it pins the observable CLI contract. The DOT/SVG are
|
||
/// Graphviz-version-dependent and not asserted (the prototype is the by-hand
|
||
/// visual reference); this pins the page envelope + the retirement of the old
|
||
/// ascii-dag notation.
|
||
#[test]
|
||
fn graph_emits_self_contained_html_viewer() {
|
||
let out = Command::new(BIN).arg("graph").output().expect("spawn aura graph");
|
||
assert_eq!(out.status.code(), Some(0), "`aura graph` exit: {:?}", out.status);
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
// a self-contained HTML document...
|
||
assert!(
|
||
stdout.trim_start().starts_with("<!doctype html>"),
|
||
"not an HTML doc: {:?}",
|
||
&stdout[..stdout.len().min(80)]
|
||
);
|
||
// ...with the deterministic model inlined as the viewer's data source...
|
||
assert!(stdout.contains("window.AURA_MODEL = {\"root\":"), "model not inlined: {stdout:?}");
|
||
// ...the Graphviz-WASM bootstrap present, and no remote asset fetch.
|
||
assert!(stdout.contains("Viz.instance"), "viewer bootstrap missing");
|
||
assert!(!stdout.contains("<script src"), "assets must be inlined, found remote <script src>");
|
||
// the retired ascii-dag invented notation is gone.
|
||
assert!(!stdout.contains("Sub(#Sf,#Ss)"), "retired ascii-dag notation must be gone: {stdout}");
|
||
}
|
||
|
||
/// Property (#28): `aura graph <blueprint.json>` renders the CONSUMER's own
|
||
/// graph — the structure of the file it is handed — not the embedded r_sma
|
||
/// sample. Mirrors the `dispatch_run` dual-grammar: a first-positional naming
|
||
/// an existing `.json` selects the loaded-blueprint branch, which reads the
|
||
/// file, reloads it via `blueprint_from_json`, and `render_html`s it — so the
|
||
/// emitted page's inlined `window.AURA_MODEL` reflects THAT file's nodes.
|
||
/// Handed the retired r-breakout example (std-vocabulary only, so no project is
|
||
/// needed), the page must carry a marker unique to that graph — `channel_hi`, a
|
||
/// RollingMax node name absent from the sample and from every static viewer
|
||
/// asset — and must NOT carry the sample-only `"type":"Bias"` node, i.e. it did
|
||
/// not silently fall back to the embedded r_sma sample. Driven through the built
|
||
/// binary so it pins the observable CLI contract.
|
||
#[test]
|
||
fn graph_renders_a_consumer_blueprint_file_not_the_embedded_sample() {
|
||
let cwd = temp_cwd("graph-consumer-blueprint");
|
||
// A committed consumer blueprint that is NOT the embedded sample and resolves
|
||
// against the base std vocabulary alone (RollingMax/RollingMin/Delay/Gt/Latch/
|
||
// Sub) — no project fixture required. Absolute path, so the temp cwd is free of
|
||
// any Aura.toml (guarantees std-env resolution).
|
||
let blueprint = format!("{}/examples/r_breakout.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = Command::new(BIN)
|
||
.arg("graph")
|
||
.arg(&blueprint)
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura graph <blueprint>");
|
||
assert_eq!(
|
||
out.status.code(),
|
||
Some(0),
|
||
"`aura graph <blueprint.json>` exit: {:?}\nstderr: {}",
|
||
out.status,
|
||
String::from_utf8_lossy(&out.stderr)
|
||
);
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
// the page inlines a model as the viewer's data source...
|
||
assert!(
|
||
stdout.contains("window.AURA_MODEL = {\"root\":"),
|
||
"model not inlined into the page"
|
||
);
|
||
// ...and that model is the CONSUMER graph's — its own node `channel_hi`
|
||
// (unique to r-breakout, absent from the r_sma sample and from every static
|
||
// asset) is present.
|
||
assert!(
|
||
stdout.contains("channel_hi"),
|
||
"the consumer blueprint's own node (channel_hi) is missing — its graph was not rendered"
|
||
);
|
||
// ...NOT the embedded r_sma sample: the sample's Bias node type must be absent
|
||
// (proof the file was rendered, not silently ignored in favour of the sample).
|
||
assert!(
|
||
!stdout.contains("\"type\":\"Bias\""),
|
||
"rendered the embedded r_sma sample (its Bias node is present) instead of the consumer blueprint"
|
||
);
|
||
}
|
||
|
||
/// Property (#28 close-audit): a NAMED-but-unreadable blueprint arg to
|
||
/// `aura graph` is a usage fault, not a silent fallback to the embedded sample.
|
||
/// `dispatch_graph`'s no-subcommand arm branches on `is_blueprint_file`, which
|
||
/// returns `None` for BOTH "no positional given" (→ legitimate sample default)
|
||
/// and "a positional was given but it is not a readable `.json` file" (→ a bad
|
||
/// arg). The two must not be conflated: a bad arg (typo, nonexistent path, wrong
|
||
/// extension) must `eprintln!` a usage error and exit 2, mirroring `dispatch_run`
|
||
/// — NOT render the r_sma sample and exit 0, which re-introduces the exact #28
|
||
/// friction ("I asked for my graph, got the sample") in the error path. The
|
||
/// bare-`aura graph` default (no positional → sample, exit 0) is guarded in the
|
||
/// same test so the fix cannot regress it.
|
||
#[test]
|
||
fn graph_with_bad_blueprint_arg_is_a_usage_fault_not_a_silent_sample() {
|
||
let cwd = temp_cwd("graph-bad-arg");
|
||
// A named .json path that does not exist: `is_blueprint_file` returns None,
|
||
// and the buggy arm falls through to the embedded sample instead of refusing.
|
||
let out = Command::new(BIN)
|
||
.arg("graph")
|
||
.arg("does-not-exist.json")
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura graph <bad-arg>");
|
||
// Headline pin: a named-but-unreadable blueprint arg exits 2 (usage fault),
|
||
// mirroring `dispatch_run`'s bad-blueprint branch — not 0.
|
||
assert_eq!(
|
||
out.status.code(),
|
||
Some(2),
|
||
"`aura graph <nonexistent.json>` must be a usage fault (exit 2), not a silent sample fallback; got {:?}\nstderr: {}",
|
||
out.status,
|
||
String::from_utf8_lossy(&out.stderr),
|
||
);
|
||
// ...and it must NOT emit the embedded r_sma sample: the sample's inlined
|
||
// model + its Bias node must be absent from stdout.
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
assert!(
|
||
!stdout.contains("\"type\":\"Bias\""),
|
||
"a bad blueprint arg silently rendered the embedded r_sma sample (its Bias node is present) instead of refusing"
|
||
);
|
||
|
||
// Guard the legitimate default: bare `aura graph` (no positional) still
|
||
// renders the embedded sample and exits 0.
|
||
let default = Command::new(BIN)
|
||
.arg("graph")
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura graph");
|
||
assert_eq!(
|
||
default.status.code(),
|
||
Some(0),
|
||
"bare `aura graph` must still render the sample (exit 0); got {:?}",
|
||
default.status,
|
||
);
|
||
let default_out = String::from_utf8(default.stdout).expect("utf-8 stdout");
|
||
assert!(
|
||
default_out.contains("\"type\":\"Bias\""),
|
||
"bare `aura graph` must render the embedded r_sma sample (its Bias node), the no-positional default"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn sweep_with_trailing_token_is_strict_and_exits_two() {
|
||
// strict-arg reading (#16): an unexpected trailing token is a usage error.
|
||
let out = Command::new(BIN).args(["sweep", "extra"]).output().expect("spawn aura");
|
||
assert_eq!(out.status.code(), Some(2), "exit status: {:?}", out.status);
|
||
}
|
||
|
||
#[test]
|
||
fn runs_families_list_and_per_family_rank_across_invocations() {
|
||
let cwd = temp_cwd("runs-flow");
|
||
// two blueprint sweeps accumulate two families (sweep-0, sweep-1) over time,
|
||
// each a related set of 4 records (C18/C21 lineage), in the sibling family
|
||
// store. `sweep` is blueprint-only now (#159 cut 4); the shipped open r-sma
|
||
// example over a 2x2 axis grid stands in for the retired built-in default.
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
for _ in 0..2 {
|
||
let out = Command::new(BIN)
|
||
.args([
|
||
"sweep", &fixture,
|
||
"--axis", "sma_signal.fast.length=2,4",
|
||
"--axis", "sma_signal.slow.length=8,16",
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn sweep");
|
||
assert!(out.status.success(), "sweep exit: {:?}; stderr: {}", out.status, String::from_utf8_lossy(&out.stderr));
|
||
}
|
||
|
||
// `runs families` shows both stored families in first-seen order, each with
|
||
// its kind + member count.
|
||
let fams = Command::new(BIN).args(["runs", "families"]).current_dir(&cwd).output().expect("spawn families");
|
||
assert!(fams.status.success(), "families exit: {:?}", fams.status);
|
||
let fams_out = String::from_utf8(fams.stdout).expect("utf-8");
|
||
let fam_lines: Vec<&str> = fams_out.lines().collect();
|
||
assert_eq!(fam_lines.len(), 2, "families: {fams_out:?}");
|
||
assert!(fam_lines[0].contains("\"family_id\":\"sweep-0\""), "fam0: {fams_out:?}");
|
||
assert!(fam_lines[1].contains("\"family_id\":\"sweep-1\""), "fam1: {fams_out:?}");
|
||
assert!(fam_lines.iter().all(|l| l.contains("\"members\":4")), "members: {fams_out:?}");
|
||
|
||
// `runs family sweep-0 rank total_pips`: the family's 4 members, highest
|
||
// total_pips first.
|
||
let rank = Command::new(BIN)
|
||
.args(["runs", "family", "sweep-0", "rank", "total_pips"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn rank");
|
||
assert!(rank.status.success(), "rank exit: {:?}", rank.status);
|
||
let rank_out = String::from_utf8(rank.stdout).expect("utf-8");
|
||
assert_eq!(rank_out.lines().count(), 4, "rank: {rank_out:?}");
|
||
assert!(rank_out.lines().all(|l| l.starts_with("{\"manifest\":{")), "rank shape: {rank_out:?}");
|
||
let pips: Vec<f64> = rank_out
|
||
.lines()
|
||
.map(|l| {
|
||
let key = "\"total_pips\":";
|
||
let start = l.find(key).expect("line has total_pips") + key.len();
|
||
let tail = &l[start..];
|
||
let end = tail.find([',', '}']).expect("token end");
|
||
tail[..end].parse().expect("total_pips is an f64")
|
||
})
|
||
.collect();
|
||
assert!(pips.windows(2).all(|w| w[0] >= w[1]), "rank not descending: {pips:?}");
|
||
|
||
// an unknown metric is a usage error
|
||
let bogus = Command::new(BIN)
|
||
.args(["runs", "family", "sweep-0", "rank", "bogus"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn bogus");
|
||
assert_eq!(bogus.status.code(), Some(2), "bogus exit: {:?}", bogus.status);
|
||
|
||
// an unknown family id is an empty family: zero lines, exit 0.
|
||
let unknown = Command::new(BIN)
|
||
.args(["runs", "family", "nope-9"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn unknown");
|
||
assert!(unknown.status.success(), "unknown family exit: {:?}", unknown.status);
|
||
assert_eq!(unknown.stdout.len(), 0, "unknown family stdout: {:?}", unknown.stdout);
|
||
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// #147: the pre-rename metric name `exposure_sign_flips` still resolves
|
||
/// through the real, user-facing `aura runs family <id> rank <metric>` path —
|
||
/// not just through a direct `MetricVocabulary::resolve` call in a unit test.
|
||
/// Task 4 re-exports `aura_campaign::RANKABLE_METRICS` from the single-sourced
|
||
/// backtest vocabulary instead of hand-copying it; were the vocabulary's
|
||
/// `resolve()` match arm for the legacy alias ever dropped in that refactor,
|
||
/// this is the only test that would catch it at the CLI's actual observable
|
||
/// boundary (exit code + stdout), rather than inside the crate that owns the
|
||
/// vocabulary.
|
||
#[test]
|
||
fn runs_family_rank_accepts_legacy_exposure_sign_flips_alias() {
|
||
let cwd = temp_cwd("runs-rank-legacy-alias");
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let sweep = Command::new(BIN)
|
||
.args([
|
||
"sweep", &fixture,
|
||
"--axis", "sma_signal.fast.length=2,4",
|
||
"--axis", "sma_signal.slow.length=8,16",
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn sweep");
|
||
assert!(sweep.status.success(), "sweep stderr: {}", String::from_utf8_lossy(&sweep.stderr));
|
||
|
||
let rank = Command::new(BIN)
|
||
.args(["runs", "family", "sweep-0", "rank", "exposure_sign_flips"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn rank");
|
||
assert!(
|
||
rank.status.success(),
|
||
"the legacy alias must still resolve, not usage-error: stderr={}",
|
||
String::from_utf8_lossy(&rank.stderr)
|
||
);
|
||
let rank_out = String::from_utf8(rank.stdout).expect("utf-8");
|
||
assert_eq!(rank_out.lines().count(), 4, "all 4 members rank: {rank_out:?}");
|
||
// the renamed field is what actually ships in the JSON — the alias is a
|
||
// resolve()-only back door, never a second output key.
|
||
assert!(
|
||
rank_out.lines().all(|l| l.contains("\"bias_sign_flips\":")),
|
||
"output still serializes under the new key: {rank_out:?}"
|
||
);
|
||
assert!(
|
||
!rank_out.contains("\"exposure_sign_flips\":"),
|
||
"the legacy key must never appear in output: {rank_out:?}"
|
||
);
|
||
|
||
let flips: Vec<i64> = rank_out
|
||
.lines()
|
||
.map(|l| {
|
||
let key = "\"bias_sign_flips\":";
|
||
let start = l.find(key).expect("line has bias_sign_flips") + key.len();
|
||
let tail = &l[start..];
|
||
let end = tail.find([',', '}']).expect("token end");
|
||
tail[..end].parse().expect("bias_sign_flips is an integer")
|
||
})
|
||
.collect();
|
||
// bias_sign_flips is a lower-is-better metric (fewer direction reversals),
|
||
// so `rank` orders ascending here — unlike total_pips's descending order.
|
||
assert!(flips.windows(2).all(|w| w[0] <= w[1]), "rank not ascending by the aliased metric: {flips:?}");
|
||
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
#[test]
|
||
fn runs_list_and_rank_are_retired_and_exit_two() {
|
||
// `aura runs list` / `runs rank <metric>` were retired (#73): families
|
||
// (sweep/mc/walkforward, C21) subsume standalone over-time comparison. The
|
||
// dropped subcommands now fall through to the strict-arg usage error (exit 2),
|
||
// before any registry access.
|
||
let list = Command::new(BIN).args(["runs", "list"]).output().expect("spawn list");
|
||
assert_eq!(list.status.code(), Some(2), "retired `runs list` must exit 2: {:?}", list.status);
|
||
let rank = Command::new(BIN).args(["runs", "rank", "total_pips"]).output().expect("spawn rank");
|
||
assert_eq!(rank.status.code(), Some(2), "retired `runs rank` must exit 2: {:?}", rank.status);
|
||
}
|
||
|
||
#[test]
|
||
fn runs_bare_subcommand_is_strict_and_exits_two() {
|
||
// `aura runs` with no/unknown sub-subcommand is a usage error (#16 strict).
|
||
let out = Command::new(BIN).arg("runs").output().expect("spawn aura runs");
|
||
assert_eq!(out.status.code(), Some(2), "exit status: {:?}", out.status);
|
||
}
|
||
|
||
// Note: the family-trace-persistence tests deleted above (mc/sweep/walkforward
|
||
// per-member trace dirs, the momentum bool-param dir/gate proofs, the sweep
|
||
// determinism pin) are the Step 3 PIP-surface deletions — same underlying
|
||
// cause as the collateral note above (no blueprint-mode arm persists
|
||
// per-member traces).
|
||
|
||
// --- Real-data family path (#106, plan 0060 Task 5) -------------------------
|
||
//
|
||
// These drive `aura sweep|walkforward --real <SYMBOL>` end to end over the local
|
||
// data-server archive. The gated tests need the Pepperstone M1 archive present;
|
||
// they skip (print + pass) on a data-less machine, the project's
|
||
// skip-on-no-data convention. The pip-refusal test is NOT gated: a symbol with no
|
||
// recorded geometry is rejected before any data access.
|
||
|
||
// EURUSD 2024-06 (UTC inclusive ms): a bounded recorded-sidecar real window.
|
||
const EURUSD_JUN2024_FROM_MS: i64 = 1_717_200_000_000;
|
||
const EURUSD_JUN2024_TO_MS: i64 = 1_719_791_999_999;
|
||
|
||
fn local_data_present() -> bool {
|
||
std::path::Path::new(data_server::DEFAULT_DATA_PATH).is_dir()
|
||
}
|
||
|
||
/// Property: the per-instrument pip refusal fires for `aura sweep --real
|
||
/// <no-geometry symbol>` exactly as it does for `aura run --real` — a symbol with no
|
||
/// recorded geometry (`NONEXISTENT`, no sidecar on any host) is rejected with exit 1
|
||
/// and the "no recorded geometry" message BEFORE any bar-data access. NOT gated: the
|
||
/// refusal precedes the archive entirely, so it is CI-safe and runs on every machine.
|
||
/// (#159 cut 4: `sweep` is blueprint-only now, so the fixture supplies a valid
|
||
/// blueprint + axis to clear the usage grammar before the geometry refusal fires.)
|
||
#[test]
|
||
fn sweep_real_no_geometry_symbol_refuses_with_exit_1() {
|
||
// not gated: the pip refusal happens before any data access.
|
||
let (dir, _g) = fresh_project();
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(BIN)
|
||
.args(["sweep", &fixture, "--axis", "sma_signal.fast.length=2,4", "--real", "NONEXISTENT"])
|
||
.current_dir(&dir).output().unwrap();
|
||
assert_eq!(out.status.code(), Some(1));
|
||
assert!(String::from_utf8_lossy(&out.stderr).contains("no recorded geometry"));
|
||
}
|
||
|
||
// GER40 Sept-2024 window (inclusive Unix-ms) — the same gated calendar month
|
||
// used by `run_real_sidecar_index_pip_reaches_the_emitted_manifest` and the
|
||
// campaign-path e2e in `research_docs.rs`.
|
||
const GER40_SEPT2024_FROM_MS: &str = "1725148800000";
|
||
const GER40_SEPT2024_TO_MS: &str = "1727740799999";
|
||
|
||
/// Property (#242): a `--real` window that holds no bars for a symbol that IS in
|
||
/// the archive refuses with a WINDOW-aware message — distinct from the genuinely
|
||
/// unknown-symbol refusal. GER40 is present (the covered Sept-2024 window runs),
|
||
/// so an *inverted* window (`--from` > `--to`) is empty by construction for any
|
||
/// archive — no bar can satisfy `from <= t <= to` — yet the symbol, its geometry,
|
||
/// and the month's file all exist. The refusal must name the requested window
|
||
/// rather than reuse the symbol-absence message "no local data for symbol", which
|
||
/// misattributes an empty WINDOW to a missing SYMBOL. Gated on the local archive;
|
||
/// skips cleanly when absent (the project's skip-on-no-data convention), so CI
|
||
/// without the archive stays green.
|
||
#[test]
|
||
fn run_real_empty_window_refusal_names_the_window_not_the_symbol() {
|
||
if !local_data_present() {
|
||
eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH);
|
||
return;
|
||
}
|
||
// The covered Sept-2024 window, INVERTED (from > to): `has_symbol("GER40")` is
|
||
// window-independent and passes, and the month's file overlaps at date
|
||
// granularity, so the empty result is a WINDOW fact, not symbol absence.
|
||
let out = std::process::Command::new(BIN)
|
||
.args([
|
||
"run", "examples/r_sma.json",
|
||
"--real", "GER40",
|
||
"--from", GER40_SEPT2024_TO_MS, // inverted: from > to,
|
||
"--to", GER40_SEPT2024_FROM_MS, // so the in-window bar set is empty
|
||
])
|
||
.output()
|
||
.expect("spawn aura");
|
||
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
// An empty --real window is still a refusal (exit 1 unchanged); only the
|
||
// message changes — the genuinely-unknown-symbol path keeps exit 1 too.
|
||
assert_eq!(
|
||
out.status.code(),
|
||
Some(1),
|
||
"an empty --real window still refuses with exit 1; stderr: {stderr}"
|
||
);
|
||
// The distinction (#242): the empty-window refusal NAMES the window and does
|
||
// NOT reuse the wholesale symbol-absence message for a symbol that is present.
|
||
assert!(
|
||
stderr.to_lowercase().contains("window"),
|
||
"empty-window refusal must name the requested window (GER40 IS present), got: {stderr}"
|
||
);
|
||
assert!(
|
||
!stderr.contains("no local data for symbol"),
|
||
"empty-window refusal must not reuse the symbol-absence message, got: {stderr}"
|
||
);
|
||
}
|
||
|
||
/// Characterization pin (grounding-check ratification for the risk-regime-axis
|
||
/// spec): the dissolved real-data sweep member manifest stamps its resolved stop
|
||
/// (`stop_length`/`stop_k`) — the default regime here. The R-path stop knobs are
|
||
/// bound via `run_blueprint_member`'s `stop` param and stamped into the manifest
|
||
/// alongside the swept signal axes; the sibling
|
||
/// `sweep_real_blueprint_member_lines_pin_the_dissolved_contract` locates each
|
||
/// binding with `find` and so never pins the exact resolved VALUES this test
|
||
/// checks. Gated on the local GER40 archive; skips cleanly when absent.
|
||
#[test]
|
||
fn sweep_real_blueprint_member_manifest_stamps_the_default_stop() {
|
||
if !local_data_present() {
|
||
eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH);
|
||
return;
|
||
}
|
||
let (dir, _g) = fresh_project();
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(BIN)
|
||
.args([
|
||
"sweep", &fixture,
|
||
"--real", "GER40",
|
||
"--from", GER40_SEPT2024_FROM_MS,
|
||
"--to", GER40_SEPT2024_TO_MS,
|
||
"--axis", "sma_signal.fast.length=2,4",
|
||
"--axis", "sma_signal.slow.length=8",
|
||
"--name", "brp_nostop",
|
||
])
|
||
.current_dir(&dir)
|
||
.output()
|
||
.unwrap();
|
||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
|
||
let lines: Vec<&str> = stdout.lines().collect();
|
||
assert_eq!(lines.len(), 2, "one member line per grid point: {stdout}");
|
||
|
||
for line in &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(3),
|
||
"the resolved default stop length is stamped: {line}");
|
||
assert_eq!(get("stop_k").and_then(|p| p[1]["F64"].as_f64()), Some(2.0),
|
||
"the resolved default stop k is stamped: {line}");
|
||
}
|
||
}
|
||
|
||
/// Property (#218 migration follow-up): running a dissolved `sweep` inside a
|
||
/// project (`fresh_project()`, the fixture this migration introduced) stamps
|
||
/// `report.manifest.project` on every member with the project's audit-trail
|
||
/// provenance (C18/C16) — the `demo` namespace and a well-formed 64-hex-char
|
||
/// dylib sha256 — rather than the `None` a bare-cwd run (no `Aura.toml`)
|
||
/// produces. Before this migration no cli_run.rs sweep test ran inside a real
|
||
/// project, so this stamping path was never exercised end-to-end. Gated on the
|
||
/// local GER40 archive; skips cleanly when absent.
|
||
#[test]
|
||
fn sweep_real_blueprint_member_manifest_stamps_project_provenance() {
|
||
if !local_data_present() {
|
||
eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH);
|
||
return;
|
||
}
|
||
let (dir, _g) = fresh_project();
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(BIN)
|
||
.args([
|
||
"sweep", &fixture,
|
||
"--real", "GER40",
|
||
"--from", GER40_SEPT2024_FROM_MS,
|
||
"--to", GER40_SEPT2024_TO_MS,
|
||
"--axis", "sma_signal.fast.length=2,4",
|
||
"--axis", "sma_signal.slow.length=8",
|
||
"--name", "brp_provenance",
|
||
])
|
||
.current_dir(&dir)
|
||
.output()
|
||
.unwrap();
|
||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
|
||
let lines: Vec<&str> = stdout.lines().collect();
|
||
assert_eq!(lines.len(), 2, "one member line per grid point: {stdout}");
|
||
|
||
for line in &lines {
|
||
let v: serde_json::Value = serde_json::from_str(line).expect("member line parses as JSON");
|
||
let project = &v["report"]["manifest"]["project"];
|
||
assert_eq!(
|
||
project["namespace"].as_str(), Some("demo"),
|
||
"member manifest carries the fixture project's namespace: {line}"
|
||
);
|
||
let sha = project["dylib_sha256"].as_str().unwrap_or_else(|| panic!("dylib_sha256 present: {line}"));
|
||
assert_eq!(sha.len(), 64, "dylib_sha256 is a full sha256 hex digest: {line}");
|
||
assert!(sha.chars().all(|c| c.is_ascii_hexdigit()), "dylib_sha256 is hex: {line}");
|
||
}
|
||
}
|
||
|
||
/// Acceptance (harness-input-binding spec, #231): an OHLC-consuming strategy
|
||
/// blueprint runs end to end on real archive data through `aura run` — the
|
||
/// role names bind columns, three real sources open in canonical order, the
|
||
/// close column feeds broker/executor, and the report carries the R summary.
|
||
/// Gated on the local GER40 archive; skips cleanly when absent.
|
||
#[test]
|
||
fn run_real_ohlc_channel_example_end_to_end() {
|
||
if !local_data_present() {
|
||
eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH);
|
||
return;
|
||
}
|
||
let cwd = temp_cwd("ohlc-run-real");
|
||
let fixture = format!("{}/examples/r_channel.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(BIN)
|
||
.args([
|
||
"run", &fixture,
|
||
"--real", "GER40",
|
||
"--from", GER40_SEPT2024_FROM_MS,
|
||
"--to", GER40_SEPT2024_TO_MS,
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.unwrap();
|
||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||
let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("run report parses as JSON");
|
||
assert!(
|
||
v["manifest"]["topology_hash"].as_str().is_some(),
|
||
"report carries the signal hash: {stdout}"
|
||
);
|
||
assert!(v["metrics"]["r"].is_object(), "the R summary is computed: {stdout}");
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// Property (#275, hostless): `run_signal_r`'s migrated production call site
|
||
/// (`h.run_bound(key_supply(&binding, sources))`, replacing the old positional
|
||
/// `h.run(sources)`) pairs each opened archive column with its declared role
|
||
/// **by name**, not by list position, for a genuinely multi-role strategy
|
||
/// (r_channel declares three distinct roles — high, low, close). Unlike
|
||
/// `run_real_ohlc_channel_example_end_to_end` above (gated on a local GER40
|
||
/// mount, which this sandbox does not have), this drives the same fixture over
|
||
/// `fresh_project_with_data`'s hermetic synthetic archive, so the by-name-binding
|
||
/// regression guard for this call site actually runs on every host, not just a
|
||
/// data-ful one. The exact R summary is a characterization pin against the
|
||
/// synthetic archive's deterministic price curve: a column swap (e.g. high/low
|
||
/// crossed) would change the channel breakout logic and move this number.
|
||
#[test]
|
||
fn run_real_ohlc_channel_example_end_to_end_hostless() {
|
||
let (cwd, _g) = fresh_project_with_data();
|
||
let fixture = format!("{}/examples/r_channel.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(BIN)
|
||
.args([
|
||
"run", &fixture,
|
||
"--real", "SYMA",
|
||
"--from", "1707120000000", // 2024-02-05 08:00 UTC (Monday)
|
||
"--to", "1707325200000", // 2024-02-07 17:00 UTC (Wednesday)
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.unwrap();
|
||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||
let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("run report parses as JSON");
|
||
assert!(
|
||
v["manifest"]["topology_hash"].as_str().is_some(),
|
||
"report carries the signal hash: {stdout}"
|
||
);
|
||
assert_eq!(
|
||
v["metrics"]["r"]["n_trades"], serde_json::json!(1),
|
||
"characterization pin over the deterministic synthetic archive \
|
||
(a high/low/close column mispairing would move this count): {stdout}"
|
||
);
|
||
assert_eq!(
|
||
v["metrics"]["r"]["expectancy_r"], serde_json::json!(-0.7466116470582123),
|
||
"characterization pin over the deterministic synthetic archive \
|
||
(a high/low/close column mispairing would move this value): {stdout}"
|
||
);
|
||
}
|
||
|
||
/// The synthetic walk generates a close series only: a multi-column blueprint
|
||
/// without `--real` refuses honestly (exit 1, columns + remedy named). NOT
|
||
/// gated — the refusal precedes any data access.
|
||
#[test]
|
||
fn run_synthetic_refuses_a_multi_column_blueprint() {
|
||
let fixture = format!("{}/examples/r_channel.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(BIN)
|
||
.args(["run", &fixture])
|
||
.output()
|
||
.unwrap();
|
||
assert_eq!(out.status.code(), Some(1));
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(stderr.contains("consumes columns beyond close"), "names the shape: {stderr}");
|
||
assert!(stderr.contains("--real"), "names the remedy: {stderr}");
|
||
}
|
||
|
||
/// The dissolved real-data sweep + reproduce over the OHLC example (#231
|
||
/// acceptance): `--list-axes` pins the ganged channel knob's wrapped axis
|
||
/// name, the sweep runs one member per grid point over three real columns,
|
||
/// and every member re-derives bit-identically from the store (#229 real-data
|
||
/// reproduce, now multi-column). Gated on the local GER40 archive.
|
||
#[test]
|
||
fn sweep_real_ohlc_channel_members_run_and_reproduce() {
|
||
if !local_data_present() {
|
||
eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH);
|
||
return;
|
||
}
|
||
let (dir, _g) = fresh_project();
|
||
let fixture = format!("{}/tests/fixtures/r_channel_open.json", env!("CARGO_MANIFEST_DIR"));
|
||
let axes = std::process::Command::new(BIN)
|
||
.args(["sweep", &fixture, "--list-axes"])
|
||
.current_dir(&dir)
|
||
.output()
|
||
.unwrap();
|
||
assert_eq!(
|
||
String::from_utf8_lossy(&axes.stdout),
|
||
"hl_channel.channel_length:I64\n\
|
||
hl_channel.prev_high.lag:I64 default=1\n\
|
||
hl_channel.prev_low.lag:I64 default=1\n",
|
||
"the ganged channel knob is the one open axis, alongside the fixture's \
|
||
bound Delay lags as #246 defaults; stderr: {}",
|
||
String::from_utf8_lossy(&axes.stderr)
|
||
);
|
||
let out = std::process::Command::new(BIN)
|
||
.args([
|
||
"sweep", &fixture,
|
||
"--real", "GER40",
|
||
"--from", GER40_SEPT2024_FROM_MS,
|
||
"--to", GER40_SEPT2024_TO_MS,
|
||
"--axis", "hl_channel.channel_length=3,5",
|
||
"--name", "chan",
|
||
])
|
||
.current_dir(&dir)
|
||
.output()
|
||
.unwrap();
|
||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
|
||
assert_eq!(stdout.lines().count(), 2, "one member line per channel length: {stdout}");
|
||
for line in stdout.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 stamps the instrument: {line}"
|
||
);
|
||
}
|
||
let family_id = serde_json::from_str::<serde_json::Value>(stdout.lines().next().unwrap())
|
||
.expect("first member line parses")["family_id"]
|
||
.as_str()
|
||
.expect("member line carries family_id")
|
||
.to_string();
|
||
let rep = std::process::Command::new(BIN)
|
||
.args(["reproduce", &family_id])
|
||
.current_dir(&dir)
|
||
.output()
|
||
.unwrap();
|
||
assert!(
|
||
rep.status.success(),
|
||
"reproduce stderr: {}",
|
||
String::from_utf8_lossy(&rep.stderr)
|
||
);
|
||
assert!(
|
||
String::from_utf8_lossy(&rep.stdout).contains("reproduced 2/2 members bit-identically"),
|
||
"stdout: {}",
|
||
String::from_utf8_lossy(&rep.stdout)
|
||
);
|
||
}
|
||
|
||
/// Property (#275, hostless): `run_blueprint_member`'s migrated production call
|
||
/// site (`h.run_bound(key_supply(binding, sources))`) resolves the same
|
||
/// role-keyed pairing on every member of a multi-column sweep AND on the
|
||
/// independent `reproduce` re-run — a sibling of
|
||
/// `sweep_real_ohlc_channel_members_run_and_reproduce` above (gated on a local
|
||
/// GER40 mount this sandbox lacks) that drives the identical multi-role fixture
|
||
/// over `fresh_project_with_data`'s hermetic synthetic archive, so this call
|
||
/// site's by-name-binding regression guard runs on every host. Bit-identical
|
||
/// reproduction is the stronger check here: `reproduce` re-derives `binding` and
|
||
/// re-opens `sources` independently of the first run, so it can only match if
|
||
/// both derivations resolve to the exact same role→column pairing.
|
||
#[test]
|
||
fn sweep_real_ohlc_channel_members_run_and_reproduce_hostless() {
|
||
let (dir, _g) = fresh_project_with_data();
|
||
let fixture = format!("{}/tests/fixtures/r_channel_open.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(BIN)
|
||
.args([
|
||
"sweep", &fixture,
|
||
"--real", "SYMA",
|
||
"--from", "1707120000000", // 2024-02-05 08:00 UTC (Monday)
|
||
"--to", "1707325200000", // 2024-02-07 17:00 UTC (Wednesday)
|
||
"--axis", "hl_channel.channel_length=3,5",
|
||
"--name", "chan-hostless",
|
||
])
|
||
.current_dir(&dir)
|
||
.output()
|
||
.unwrap();
|
||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
|
||
assert_eq!(stdout.lines().count(), 2, "one member line per channel length: {stdout}");
|
||
for line in stdout.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("SYMA"),
|
||
"campaign member manifest stamps the instrument: {line}"
|
||
);
|
||
}
|
||
let family_id = serde_json::from_str::<serde_json::Value>(stdout.lines().next().unwrap())
|
||
.expect("first member line parses")["family_id"]
|
||
.as_str()
|
||
.expect("member line carries family_id")
|
||
.to_string();
|
||
let rep = std::process::Command::new(BIN)
|
||
.args(["reproduce", &family_id])
|
||
.current_dir(&dir)
|
||
.output()
|
||
.unwrap();
|
||
assert!(
|
||
rep.status.success(),
|
||
"reproduce stderr: {}",
|
||
String::from_utf8_lossy(&rep.stderr)
|
||
);
|
||
assert!(
|
||
String::from_utf8_lossy(&rep.stdout).contains("reproduced 2/2 members bit-identically"),
|
||
"stdout: {}",
|
||
String::from_utf8_lossy(&rep.stdout)
|
||
);
|
||
}
|
||
|
||
/// Property: the real-data blueprint sweep path (`aura sweep <blueprint.json>
|
||
/// --real SYM --axis …`, dispatched as sugar over the one campaign executor)
|
||
/// prints one member line per grid point, IN AXIS ORDER (odometer: the first
|
||
/// `--axis` varies slowest), each line JSON with a `family_id` and a `report`
|
||
/// whose `manifest.params` carries every swept param binding — and, the
|
||
/// sanctioned additive delta over the synthetic path, **`report.manifest`
|
||
/// carries a stamped `instrument` key** (the campaign substrate stamps every
|
||
/// member's manifest with the instrument under test, exactly like the sibling
|
||
/// `campaign_run_real_e2e_sweep_gate_walkforward` in `research_docs.rs`). Also
|
||
/// pins that the run persists exactly one new `FamilyKind::Sweep` family with
|
||
/// the grid's member count, and that the underlying generated process/campaign
|
||
/// documents and campaign-run record are durably auto-registered. Gated on the
|
||
/// local GER40 archive (the project's skip-on-no-data convention); skips
|
||
/// cleanly when absent.
|
||
#[test]
|
||
fn sweep_real_blueprint_member_lines_pin_the_dissolved_contract() {
|
||
if !local_data_present() {
|
||
eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH);
|
||
return;
|
||
}
|
||
let (dir, _g) = fresh_project();
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(BIN)
|
||
.args([
|
||
"sweep", &fixture,
|
||
"--real", "GER40",
|
||
"--from", GER40_SEPT2024_FROM_MS,
|
||
"--to", GER40_SEPT2024_TO_MS,
|
||
"--axis", "sma_signal.fast.length=2,4",
|
||
"--axis", "sma_signal.slow.length=8,16",
|
||
"--name", "brp",
|
||
])
|
||
.current_dir(&dir)
|
||
.output()
|
||
.unwrap();
|
||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
|
||
let lines: Vec<&str> = stdout.lines().collect();
|
||
assert_eq!(lines.len(), 4, "one member line per 2x2 grid point: {stdout}");
|
||
|
||
// Odometer order: `sma_signal.fast.length` (first `--axis`, outer/slowest)
|
||
// x `sma_signal.slow.length` (second `--axis`, inner) -> (2,8),(2,16),(4,8),(4,16).
|
||
let expected: [[(&str, i64); 2]; 4] = [
|
||
[("sma_signal.fast.length", 2), ("sma_signal.slow.length", 8)],
|
||
[("sma_signal.fast.length", 2), ("sma_signal.slow.length", 16)],
|
||
[("sma_signal.fast.length", 4), ("sma_signal.slow.length", 8)],
|
||
[("sma_signal.fast.length", 4), ("sma_signal.slow.length", 16)],
|
||
];
|
||
|
||
for i in 0..4 {
|
||
let line = lines[i];
|
||
let v: serde_json::Value = serde_json::from_str(line).expect("member line parses as JSON");
|
||
assert!(v["family_id"].as_str().is_some(), "member line carries a family_id: {line}");
|
||
let manifest = &v["report"]["manifest"];
|
||
assert!(manifest.is_object(), "report.manifest is present: {line}");
|
||
let params = manifest["params"].as_array().expect("manifest.params is an array");
|
||
for (name, value) in expected[i] {
|
||
let bound = params
|
||
.iter()
|
||
.find(|p| p[0].as_str() == Some(name))
|
||
.and_then(|p| p[1]["I64"].as_i64());
|
||
assert_eq!(bound, Some(value), "manifest carries the swept binding {name}={value}: {line}");
|
||
}
|
||
// the sanctioned additive delta: the campaign substrate stamps every
|
||
// member's manifest with the instrument.
|
||
assert_eq!(
|
||
manifest["instrument"].as_str(),
|
||
Some("GER40"),
|
||
"the dissolved sweep stamps the instrument: {line}"
|
||
);
|
||
assert!(
|
||
params.iter().any(|p| p[0].as_str() == Some("stop_length"))
|
||
&& params.iter().any(|p| p[0].as_str() == Some("stop_k")),
|
||
"the dissolved sweep stamps the resolved stop: {line}"
|
||
);
|
||
}
|
||
|
||
// exactly one new Sweep family persisted, with the grid's four members.
|
||
let fams = std::process::Command::new(BIN)
|
||
.args(["runs", "families"])
|
||
.current_dir(&dir)
|
||
.output()
|
||
.unwrap();
|
||
assert!(fams.status.success(), "families exit: {:?}", fams.status);
|
||
let fams_out = String::from_utf8_lossy(&fams.stdout).into_owned();
|
||
assert_eq!(fams_out.lines().count(), 1, "exactly one family persisted: {fams_out}");
|
||
assert!(fams_out.contains("\"kind\":\"Sweep\""), "families: {fams_out}");
|
||
assert!(fams_out.contains("\"members\":4"), "2x2 grid -> four members: {fams_out}");
|
||
|
||
// Auto-registration: generated documents are durable intent — exactly
|
||
// one process doc, one campaign doc, one campaign run record.
|
||
let count = |sub: &str| {
|
||
std::fs::read_dir(dir.join("runs").join(sub))
|
||
.map(|d| d.count())
|
||
.unwrap_or(0)
|
||
};
|
||
assert_eq!(count("processes"), 1, "one generated process document registered");
|
||
assert_eq!(count("campaigns"), 1, "one generated campaign document registered");
|
||
let runs_log = std::fs::read_to_string(dir.join("runs").join("campaign_runs.jsonl"))
|
||
.expect("campaign_runs.jsonl exists after a sugar run");
|
||
assert_eq!(runs_log.lines().count(), 1, "one campaign run recorded");
|
||
// `CampaignRunRecord` (aura-registry's lineage.rs)
|
||
// carries no name field at all (campaign id/process id/run/seed/cells/
|
||
// generalizations/trace_name only) — the `--name` handle is NOT part of
|
||
// the run-log line. It IS part of the generated CAMPAIGN DOCUMENT
|
||
// (`translate_sweep` sets `campaign.name = name`), so the handle is
|
||
// checked on that persisted document instead.
|
||
let campaigns_dir = dir.join("runs").join("campaigns");
|
||
let campaign_doc_path = std::fs::read_dir(&campaigns_dir)
|
||
.expect("campaigns dir exists")
|
||
.next()
|
||
.expect("exactly one campaign document")
|
||
.expect("readable dir entry")
|
||
.path();
|
||
let campaign_doc_json = std::fs::read_to_string(&campaign_doc_path).expect("read campaign doc");
|
||
assert!(
|
||
campaign_doc_json.contains("\"name\":\"brp\""),
|
||
"the --name handle lands on the generated campaign document: {campaign_doc_json}"
|
||
);
|
||
|
||
// Property (dispatch-rewire seam, docs/specs/sweep-dissolution.md Task 4
|
||
// Step 3): the CLI's `--axis` names are typed against the WRAPPED probe
|
||
// namespace (`sma_signal.fast.length`), but a campaign document's
|
||
// `strategies[].axes` speak the RAW `param_space` namespace
|
||
// (`fast.length`) — `bind_axes`'s convention (#203). `dispatch_sweep`
|
||
// strips the wrapper's one leading node segment before generating the
|
||
// document; a regression that stopped stripping (or stripped one segment
|
||
// too many/few) would persist the wrong axis keys and
|
||
// `validate_campaign_refs` would refuse every axis as unknown before any
|
||
// member ran — this pin catches that at the artifact level, directly.
|
||
let campaign_doc: serde_json::Value =
|
||
serde_json::from_str(&campaign_doc_json).expect("generated campaign doc parses as JSON");
|
||
let axes = campaign_doc["strategies"][0]["axes"]
|
||
.as_object()
|
||
.expect("strategies[0].axes is an object");
|
||
assert!(
|
||
axes.contains_key("fast.length"),
|
||
"the raw (unwrapped) axis name is the document key: {campaign_doc_json}"
|
||
);
|
||
assert!(
|
||
axes.contains_key("slow.length"),
|
||
"the raw (unwrapped) axis name is the document key: {campaign_doc_json}"
|
||
);
|
||
assert!(
|
||
!axes.contains_key("sma_signal.fast.length") && !axes.contains_key("sma_signal.slow.length"),
|
||
"the wrapped CLI probe name must never leak into the stored document: {campaign_doc_json}"
|
||
);
|
||
|
||
// Property (dispatch-rewire seam: the ms<->ns crossing, C3): the probed
|
||
// archive window is carried through `DataSource::full_window` (aura's
|
||
// native epoch-ns `Timestamp`, clamped to the archive's actual first/last
|
||
// bar inside the requested range) and converted back to Unix-ms via
|
||
// `aura_ingest::epoch_ns_to_unix_ms` exactly once before landing in the
|
||
// generated document's `data.windows[0]`. A regression that skipped the
|
||
// conversion (or applied it twice, or swapped ms<->ns) would persist a
|
||
// window off by a factor of ~1e6 from the requested `--from`/`--to` —
|
||
// silently, since the executor would still run (over the wrong or an
|
||
// empty range) rather than refuse outright. Pin the sane invariant that
|
||
// survives the archive's exact clamp: the stored window is a
|
||
// (from <= to) sub-range of the requested Unix-ms bounds, not a value
|
||
// orders of magnitude away.
|
||
let from_req = GER40_SEPT2024_FROM_MS.parse::<i64>().unwrap();
|
||
let to_req = GER40_SEPT2024_TO_MS.parse::<i64>().unwrap();
|
||
let window = &campaign_doc["data"]["windows"][0];
|
||
let from_stored = window["from_ms"].as_i64().expect("from_ms is an integer");
|
||
let to_stored = window["to_ms"].as_i64().expect("to_ms is an integer");
|
||
assert!(
|
||
from_req <= from_stored && from_stored <= to_stored && to_stored <= to_req,
|
||
"the stored window is a ms sub-range of the requested [{from_req}, {to_req}]: {campaign_doc_json}"
|
||
);
|
||
|
||
// Dedup: a second identical invocation reuses both documents (content-
|
||
// addressed) and appends a second run record.
|
||
let out2 = std::process::Command::new(BIN)
|
||
.args([
|
||
"sweep", &fixture,
|
||
"--real", "GER40",
|
||
"--from", GER40_SEPT2024_FROM_MS,
|
||
"--to", GER40_SEPT2024_TO_MS,
|
||
"--axis", "sma_signal.fast.length=2,4",
|
||
"--axis", "sma_signal.slow.length=8,16",
|
||
"--name", "brp",
|
||
])
|
||
.current_dir(&dir)
|
||
.output()
|
||
.unwrap();
|
||
assert!(out2.status.success(), "second run stderr: {}", String::from_utf8_lossy(&out2.stderr));
|
||
// `family_id` carries a per-execution uniqueness
|
||
// suffix ("the '-{run}' suffix is appended by the registry",
|
||
// aura-campaign/src/exec.rs `run_cell`) — a fresh family record is
|
||
// written on every execution even when the campaign/process documents
|
||
// themselves dedupe, so two identical invocations are NOT literally
|
||
// byte-identical on stdout: only the trailing run-suffix (0 -> 1)
|
||
// differs. C1 determinism is checked at the member-report level instead
|
||
// (every report field byte-identical), plus the run-suffix increment.
|
||
let out2_stdout = String::from_utf8_lossy(&out2.stdout).into_owned();
|
||
let lines1: Vec<&str> = stdout.lines().collect();
|
||
let lines2: Vec<&str> = out2_stdout.lines().collect();
|
||
assert_eq!(lines2.len(), lines1.len(), "same member count on the dedup run: {out2_stdout}");
|
||
for (l1, l2) in lines1.iter().zip(&lines2) {
|
||
let v1: serde_json::Value = serde_json::from_str(l1).expect("first run member line parses");
|
||
let v2: serde_json::Value = serde_json::from_str(l2).expect("second run member line parses");
|
||
assert_eq!(v1["report"], v2["report"], "identical invocations reproduce identical member reports (C1): {l1} vs {l2}");
|
||
let (fam1, fam2) = (v1["family_id"].as_str().unwrap(), v2["family_id"].as_str().unwrap());
|
||
assert_eq!(
|
||
fam1.trim_end_matches(|c: char| c.is_ascii_digit()),
|
||
fam2.trim_end_matches(|c: char| c.is_ascii_digit()),
|
||
"same family name up to the run-suffix: {fam1} vs {fam2}"
|
||
);
|
||
assert!(fam1.ends_with("-0") && fam2.ends_with("-1"), "run-suffix increments 0 -> 1: {fam1} vs {fam2}");
|
||
}
|
||
assert_eq!(count("processes"), 1, "second identical run dedupes the process doc");
|
||
assert_eq!(count("campaigns"), 1, "second identical run dedupes the campaign doc");
|
||
}
|
||
|
||
/// True iff `root` contains, at any depth, a regular file of non-zero length —
|
||
/// the layout-agnostic "traces actually landed on disk" probe. #224 does not yet
|
||
/// pin the per-member trace-directory naming (the sweep sugar's cell has no
|
||
/// nominee and `persist_campaign_traces` keys by `<trace_name>/<cell_key>`), so
|
||
/// this walks the tree rather than hard-coding a path, keeping the headline about
|
||
/// *that* traces are written, not *where*.
|
||
fn any_nonempty_file(root: &Path) -> bool {
|
||
let mut stack = vec![root.to_path_buf()];
|
||
while let Some(p) = stack.pop() {
|
||
let Ok(entries) = std::fs::read_dir(&p) else { continue };
|
||
for entry in entries.flatten() {
|
||
let path = entry.path();
|
||
if path.is_dir() {
|
||
stack.push(path);
|
||
} else if std::fs::metadata(&path).map(|m| m.len() > 0).unwrap_or(false) {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
false
|
||
}
|
||
|
||
/// Property (#224): `aura sweep <blueprint.json> --real SYM --axis … --trace <fam>`
|
||
/// DELIVERS the advertised trace-writing capability instead of the #168
|
||
/// refuse-with-pointer. Two things must hold once the capability ships:
|
||
/// (1) the run no longer refuses (`aura: --trace is not yet available on sweep;
|
||
/// see #224`, exit 2) but succeeds, and (2) the tap series actually land on disk
|
||
/// under `runs/traces/` — at least one non-empty tap file is written, the honest
|
||
/// inverse of the #168 no-op where the `--real` sugar set `persist_taps: vec![]`
|
||
/// (verb_sugar.rs) and `run_blueprint_sweep` did `let _ = persist`, so nothing was
|
||
/// ever written. The exact on-disk layout (trace name + per-member/nominee key)
|
||
/// is deliberately NOT pinned here: this headline pins that traces are written at
|
||
/// all — not how many dirs, nor under what member key — because that naming is
|
||
/// the GREEN slice's open design point (the selection-free sweep cell has no
|
||
/// nominee for the existing per-cell `persist_campaign_traces` mechanism, #224).
|
||
/// Gated on the local GER40 archive (the project skip-on-no-data convention);
|
||
/// the refused status quo makes it RED on a data-ful host today.
|
||
#[test]
|
||
fn sweep_real_with_trace_writes_tap_series_to_disk() {
|
||
if !local_data_present() {
|
||
eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH);
|
||
return;
|
||
}
|
||
let (dir, _g) = fresh_project();
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(BIN)
|
||
.args([
|
||
"sweep", &fixture,
|
||
"--real", "GER40",
|
||
"--from", GER40_SEPT2024_FROM_MS,
|
||
"--to", GER40_SEPT2024_TO_MS,
|
||
"--axis", "sma_signal.fast.length=2,4",
|
||
"--axis", "sma_signal.slow.length=8",
|
||
"--trace", "brp",
|
||
])
|
||
.current_dir(&dir)
|
||
.output()
|
||
.unwrap();
|
||
// (1) the #168 refusal is lifted: the advertised flag runs instead of exit 2.
|
||
assert!(
|
||
out.status.success(),
|
||
"sweep --trace must no longer refuse (#224); exit {:?}, stderr: {}",
|
||
out.status.code(),
|
||
String::from_utf8_lossy(&out.stderr),
|
||
);
|
||
// (2) the tap series actually land on disk under runs/traces/ (non-empty) —
|
||
// the honest inverse of the #168 `persist_taps: vec![]` no-op.
|
||
let traces_root = dir.join("runs").join("traces");
|
||
assert!(
|
||
any_nonempty_file(&traces_root),
|
||
"sweep --trace must write at least one non-empty tap file under {} \
|
||
(found none — the #168 no-op wrote nothing); stderr: {}",
|
||
traces_root.display(),
|
||
String::from_utf8_lossy(&out.stderr),
|
||
);
|
||
}
|
||
|
||
/// Extract the first persisted family whose `kind` matches `want` from
|
||
/// `aura runs families` stdout (one JSON object per line), returning its
|
||
/// `family_id` — the id `aura reproduce` addresses. Panics if none matches so a
|
||
/// mis-minted store fails loudly rather than skipping the assertion.
|
||
fn family_id_of_kind(dir: &Path, want: &str) -> String {
|
||
let out = Command::new(BIN).args(["runs", "families"]).current_dir(dir).output().unwrap();
|
||
assert!(out.status.success(), "runs families exit: {:?}", out.status);
|
||
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
|
||
for line in stdout.lines() {
|
||
let v: serde_json::Value = serde_json::from_str(line).expect("family line parses as JSON");
|
||
if v["kind"].as_str() == Some(want) {
|
||
return v["family_id"].as_str().expect("family_id is a string").to_string();
|
||
}
|
||
}
|
||
panic!("no {want} family in the store: {stdout}");
|
||
}
|
||
|
||
/// Property (C18/C1 reproduce guarantee): `aura reproduce` re-derives a family
|
||
/// over the SAME data it was minted on. A sweep family minted over real GER40
|
||
/// bars must therefore reproduce bit-identically — the run is deterministic
|
||
/// (a fresh identical sweep matches the persisted metrics to the last digit), so
|
||
/// the README's "check it is bit-identical" promise must hold. It must NOT
|
||
/// silently re-run every member over the built-in synthetic showcase stream,
|
||
/// which diverges from the stored real-data metrics on every member (observed:
|
||
/// `reproduced 0/N bit-identically`, exit 1). Gated on the local GER40 archive
|
||
/// (skip-on-no-data convention); uses the plain `r_sma.json` blueprint —
|
||
/// no gang, no campaign-specific fixture — because the divergence is driven by
|
||
/// `--real`, not by the minting path.
|
||
#[test]
|
||
fn reproduce_real_sweep_family_re_derives_bit_identically() {
|
||
if !local_data_present() {
|
||
eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH);
|
||
return;
|
||
}
|
||
let (dir, _g) = fresh_project();
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let mint = Command::new(BIN)
|
||
.args([
|
||
"sweep", &fixture,
|
||
"--real", "GER40",
|
||
"--from", GER40_SEPT2024_FROM_MS,
|
||
"--to", GER40_SEPT2024_TO_MS,
|
||
"--axis", "sma_signal.fast.length=2,4",
|
||
"--axis", "sma_signal.slow.length=8",
|
||
"--name", "repro_sweep",
|
||
])
|
||
.current_dir(&dir)
|
||
.output()
|
||
.unwrap();
|
||
assert!(mint.status.success(), "sweep mint stderr: {}", String::from_utf8_lossy(&mint.stderr));
|
||
|
||
let id = family_id_of_kind(&dir, "Sweep");
|
||
let out = Command::new(BIN).args(["reproduce", &id]).current_dir(&dir).output().unwrap();
|
||
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
|
||
assert!(
|
||
out.status.success(),
|
||
"reproduce of a real-data sweep family must succeed (exit 0), got {:?}\nstdout: {stdout}\nstderr: {}",
|
||
out.status.code(),
|
||
String::from_utf8_lossy(&out.stderr)
|
||
);
|
||
assert!(
|
||
stdout.contains("reproduced 2/2 members bit-identically"),
|
||
"every member of a deterministic real-data sweep re-derives bit-identically: {stdout}"
|
||
);
|
||
assert!(
|
||
!stdout.contains("DIVERGED"),
|
||
"no member may DIVERGE — the run is deterministic, reproduce must use the recorded data source: {stdout}"
|
||
);
|
||
}
|
||
|
||
/// Property (C18/C1 reproduce guarantee, #246): the same reproduce guarantee holds
|
||
/// when the swept axis names a BOUND param of a CLOSED starter blueprint, not an
|
||
/// already-open one — `r_sma.json` (both `sma_signal.fast.length` and
|
||
/// `sma_signal.slow.length` bound), overriding only the `fast.length` axis and
|
||
/// leaving `slow.length` bound — unlike the walk-forward reproduce sibling
|
||
/// (`aura_walkforward_over_a_blueprint_reproduces_bit_identically`), which
|
||
/// overrides both axes over the same closed blueprint.
|
||
/// Modelled on that sibling (synthetic in-process walk-forward — no `--real`),
|
||
/// NOT on `reproduce_real_sweep_family_re_derives_bit_identically`: a real-data
|
||
/// mint routes through the campaign executor (`CliMemberRunner::run_member` +
|
||
/// `validate_and_register_axes`), neither of which carries any #246 override
|
||
/// threading — a gap outside this task's file scope (`blueprint_sweep_over`,
|
||
/// `reproduce_family_in`) and spanning all four dispatch verbs, so exercising
|
||
/// it here would silently expand into unrelated, unreviewed surface. The
|
||
/// synthetic walk-forward path exercises exactly the threaded functions:
|
||
/// `blueprint_sweep_over` (via `blueprint_walkforward_family`) re-opens
|
||
/// `sma_signal.fast.length` for the per-window IS re-fit, and
|
||
/// `reproduce_family_in` re-opens it again from the recorded manifest params
|
||
/// so the re-derivation resolves against the same space the mint used.
|
||
#[test]
|
||
fn reproduce_family_with_overridden_bound_param_re_derives() {
|
||
let cwd = temp_cwd("walkforward-reproduce-override");
|
||
let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let run = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["walkforward", &bp, "--axis", "sma_signal.fast.length=2,3", "--name", "wfo"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura walkforward");
|
||
assert_eq!(
|
||
run.status.code(),
|
||
Some(0),
|
||
"walk-forward over a bound-param axis must succeed (#246 re-open), stderr: {}",
|
||
String::from_utf8_lossy(&run.stderr)
|
||
);
|
||
let repro = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["reproduce", "wfo-0"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura reproduce");
|
||
assert_eq!(
|
||
repro.status.code(),
|
||
Some(0),
|
||
"reproduce stderr: {}",
|
||
String::from_utf8_lossy(&repro.stderr)
|
||
);
|
||
let out = String::from_utf8(repro.stdout).expect("utf-8");
|
||
assert!(
|
||
out.contains("reproduced 3/3 members bit-identically"),
|
||
"every OOS member of a deterministic walk-forward over a bound-param axis \
|
||
re-derives bit-identically: {out}"
|
||
);
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// Property (#246, distinct code path from `reproduce_family_with_overridden_bound_param_re_derives`):
|
||
/// `reproduce_family_in`'s override derivation (`wrapped_bound_overrides_of`, computed once
|
||
/// per family from the FIRST member's recorded params) applies uniformly across
|
||
/// `FamilyKind`s, not only `WalkForward` — a plain `aura sweep` family minted by
|
||
/// overriding a bound param on the CLOSED `r_sma.json` fixture re-derives
|
||
/// bit-identically too. The sibling test exercises `FamilyKind::WalkForward`'s
|
||
/// branch (per-window OOS reload via `run_oos_blueprint`); this one exercises the
|
||
/// `Sweep`-kind branch (`data.run_sources`/`data.full_window`, no per-window
|
||
/// re-fit) of the same shared dispatch in `reproduce_family_in`. A regression that
|
||
/// threaded the #246 override set through one branch but not the other would pass
|
||
/// the walk-forward sibling while failing here.
|
||
#[test]
|
||
fn aura_reproduce_re_derives_a_sweep_family_minted_over_a_bound_param_override() {
|
||
let cwd = temp_cwd("sweep-reproduce-bound-override");
|
||
let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let sweep = Command::new(BIN)
|
||
.args(["sweep", &bp, "--axis", "sma_signal.fast.length=2,3", "--name", "swo"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura sweep over a bound-param axis");
|
||
assert_eq!(
|
||
sweep.status.code(),
|
||
Some(0),
|
||
"sweeping a bound param of a closed blueprint must succeed (#246 re-open), stderr: {}",
|
||
String::from_utf8_lossy(&sweep.stderr)
|
||
);
|
||
let repro = Command::new(BIN)
|
||
.args(["reproduce", "swo-0"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura reproduce");
|
||
assert_eq!(
|
||
repro.status.code(),
|
||
Some(0),
|
||
"reproduce stderr: {}",
|
||
String::from_utf8_lossy(&repro.stderr)
|
||
);
|
||
let out = String::from_utf8(repro.stdout).expect("utf-8");
|
||
assert!(
|
||
out.contains("reproduced 2/2 members bit-identically"),
|
||
"every member of a plain sweep minted over a bound-param override \
|
||
re-derives bit-identically: {out}"
|
||
);
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// Property: `aura reproduce` yields a handled outcome — a reproduction verdict
|
||
/// or a clean `aura:` refusal — for a real-data WalkForward family, and NEVER an
|
||
/// internal panic. A WalkForward member's OOS window is stored as real epoch-ns
|
||
/// bounds; reconstructing that window over the built-in synthetic walk (whose 60
|
||
/// bars fall entirely outside those bounds) yields an empty source, which today
|
||
/// reaches the unguarded `window_of(&s).expect("non-empty OOS window")` and
|
||
/// aborts the process (exit 101). Gated on the local GER40 archive
|
||
/// (skip-on-no-data convention); a ~135-day window is the minimum that stitches
|
||
/// at least one OOS window (IS 90d + OOS 30d).
|
||
#[test]
|
||
fn reproduce_real_walkforward_family_does_not_panic() {
|
||
if !local_data_present() {
|
||
eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH);
|
||
return;
|
||
}
|
||
// 2025-01-01 .. ~2025-05-16 (~135 days) — long enough for one IS(90d)+OOS(30d)
|
||
// walk-forward window over the local GER40 archive.
|
||
const WF_FROM_MS: &str = "1735689600000";
|
||
const WF_TO_MS: &str = "1747353600000";
|
||
let (dir, _g) = fresh_project();
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let mint = Command::new(BIN)
|
||
.args([
|
||
"walkforward", &fixture,
|
||
"--real", "GER40",
|
||
"--from", WF_FROM_MS,
|
||
"--to", WF_TO_MS,
|
||
"--axis", "sma_signal.fast.length=2,4",
|
||
"--axis", "sma_signal.slow.length=8",
|
||
"--name", "repro_wf",
|
||
])
|
||
.current_dir(&dir)
|
||
.output()
|
||
.unwrap();
|
||
assert!(mint.status.success(), "walkforward mint stderr: {}", String::from_utf8_lossy(&mint.stderr));
|
||
|
||
let id = family_id_of_kind(&dir, "WalkForward");
|
||
let out = Command::new(BIN).args(["reproduce", &id]).current_dir(&dir).output().unwrap();
|
||
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
|
||
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
|
||
assert_ne!(
|
||
out.status.code(),
|
||
Some(101),
|
||
"reproduce of a WalkForward family must not panic (exit 101)\nstdout: {stdout}\nstderr: {stderr}"
|
||
);
|
||
assert!(
|
||
!stderr.contains("panicked") && !stderr.contains("non-empty OOS window"),
|
||
"no internal panic may leak from reproduce: {stderr}"
|
||
);
|
||
assert!(
|
||
stdout.contains("reproduced") || stderr.starts_with("aura:"),
|
||
"reproduce must yield a verdict (stdout) or a clean `aura:` refusal (stderr): stdout={stdout} stderr={stderr}"
|
||
);
|
||
}
|
||
|
||
// Fixture blueprints for the verb-level multi-column binding coverage below
|
||
// (#231 task 4 quality follow-up): a CLOSED high/low blueprint (`run`
|
||
// requires zero free knobs) and an OPEN high/low blueprint with two
|
||
// SMA-length axes (`walkforward` requires >= 1 axis). Written to a fresh
|
||
// temp file per test rather than a tracked `examples/` fixture — these exist
|
||
// only to drive the refusal / real-open contract, not as authoring samples.
|
||
const HL_RANGE_CLOSED_BLUEPRINT: &str = r#"{
|
||
"format_version": 1,
|
||
"blueprint": {
|
||
"name": "hl_range",
|
||
"nodes": [ {"primitive":{"type":"Sub"}} ],
|
||
"edges": [],
|
||
"input_roles": [
|
||
{"name":"high","targets":[{"node":0,"slot":0}],"source":"F64"},
|
||
{"name":"low","targets":[{"node":0,"slot":1}],"source":"F64"}
|
||
],
|
||
"output": [{"node":0,"field":0,"name":"bias"}]
|
||
}
|
||
}"#;
|
||
const HL_SIGNAL_OPEN_BLUEPRINT: &str = r#"{"format_version":1,"blueprint":{"name":"hl_signal","nodes":[{"primitive":{"type":"SMA","name":"fast"}},{"primitive":{"type":"SMA","name":"slow"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"high","targets":[{"node":0,"slot":0}],"source":"F64"},{"name":"low","targets":[{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#;
|
||
|
||
/// Property (#231 task 4 quality follow-up): `aura run` over a multi-column
|
||
/// blueprint with NO `--real` (the synthetic default) refuses through the
|
||
/// verb-level exit-1 + stderr contract — `binding::synthetic_refusal`'s exact
|
||
/// prose, not merely its `Result` shape (the unit-mod test one layer down
|
||
/// covers the `Err`-returning builders only). Data-free: the guard fires
|
||
/// directly after binding resolution, before any archive access, so no
|
||
/// gating is needed.
|
||
#[test]
|
||
fn run_synthetic_refuses_a_multi_column_blueprint_before_data_access() {
|
||
let dir = temp_cwd("run_synthetic_multicolumn_refusal");
|
||
let fixture = dir.join("hl_range.json");
|
||
std::fs::write(&fixture, HL_RANGE_CLOSED_BLUEPRINT).expect("write fixture");
|
||
let out = Command::new(BIN).args(["run", fixture.to_str().unwrap()]).output().expect("spawn aura run");
|
||
assert_eq!(out.status.code(), Some(1), "status: {:?}", out.status);
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert_eq!(
|
||
stderr.trim_end(),
|
||
"aura: strategy \"hl_range\" consumes columns beyond close (high, low) — synthetic \
|
||
data generates a close series only; run with --real <SYMBOL>"
|
||
);
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// Property (#231 task 4 quality follow-up): the sibling exit-process arm at
|
||
/// `blueprint_walkforward_family` — `aura walkforward` over a multi-column
|
||
/// blueprint with no `--real` — refuses identically. Data-free (the guard
|
||
/// fires directly after binding resolution, before any window/archive
|
||
/// access); run inside a temp cwd purely by the project's verb-test
|
||
/// convention (no store write is expected on this refusal path either).
|
||
#[test]
|
||
fn walkforward_synthetic_refuses_a_multi_column_blueprint_before_data_access() {
|
||
let dir = temp_cwd("walkforward_synthetic_multicolumn_refusal");
|
||
let fixture = dir.join("hl_signal.json");
|
||
std::fs::write(&fixture, HL_SIGNAL_OPEN_BLUEPRINT).expect("write fixture");
|
||
let out = Command::new(BIN)
|
||
.args([
|
||
"walkforward", fixture.to_str().unwrap(),
|
||
"--axis", "hl_signal.fast.length=2,4",
|
||
"--axis", "hl_signal.slow.length=8",
|
||
])
|
||
.current_dir(&dir)
|
||
.output()
|
||
.expect("spawn aura walkforward");
|
||
assert_eq!(out.status.code(), Some(1), "status: {:?}", out.status);
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert_eq!(
|
||
stderr.trim_end(),
|
||
"aura: strategy \"hl_signal\" consumes columns beyond close (high, low) — synthetic \
|
||
data generates a close series only; run with --real <SYMBOL>"
|
||
);
|
||
assert!(!dir.join("runs").exists(), "a refused synthetic family must not start a run store");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// Property (#231 task 4 quality follow-up): the positive counterpart of the
|
||
/// two refusals above — a multi-column blueprint over `--real GER40` opens
|
||
/// BOTH the high and low columns end to end (`open_real_source` ->
|
||
/// `resolve_run_data` -> `run_signal_r`) and produces a real `RunReport`,
|
||
/// never the single-close weld the pre-task-4 code hardcoded. Gated on the
|
||
/// local GER40 archive (skip-on-no-data convention).
|
||
#[test]
|
||
fn run_real_multi_column_blueprint_opens_high_and_low_columns() {
|
||
if !local_data_present() {
|
||
eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH);
|
||
return;
|
||
}
|
||
let dir = temp_cwd("run_real_multicolumn_open");
|
||
let fixture = dir.join("hl_range.json");
|
||
std::fs::write(&fixture, HL_RANGE_CLOSED_BLUEPRINT).expect("write fixture");
|
||
let out = Command::new(BIN)
|
||
.args([
|
||
"run", fixture.to_str().unwrap(),
|
||
"--real", "GER40",
|
||
"--from", GER40_SEPT2024_FROM_MS,
|
||
"--to", GER40_SEPT2024_TO_MS,
|
||
])
|
||
.output()
|
||
.expect("spawn aura run");
|
||
assert_eq!(
|
||
out.status.code(), Some(0),
|
||
"a high/low blueprint over real GER40 data must run cleanly, got status={:?} stderr={}",
|
||
out.status, String::from_utf8_lossy(&out.stderr)
|
||
);
|
||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||
assert!(
|
||
stdout.trim_start().starts_with("{\"manifest\":"),
|
||
"exit 0 must carry a JSON report, got: {stdout}"
|
||
);
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// Property (#231 task 4, "every real-data path" — `run --real` above covers
|
||
/// only `open_real_source`/`run_signal_r`; `sweep --real` walks a DISTINCT
|
||
/// real-data path, `blueprint_sweep_family` -> `blueprint_sweep_over` ->
|
||
/// `DataSource::windowed_sources`, which independently needed `binding.
|
||
/// columns()` threaded through in task 4): a multi-column (high/low) OPEN
|
||
/// blueprint swept over `--real GER40` opens both columns per member and
|
||
/// produces one member line per grid point, not a source-count-mismatch panic
|
||
/// or a fallback to a single close source. Gated on the local GER40 archive.
|
||
#[test]
|
||
fn sweep_real_multi_column_blueprint_opens_high_and_low_columns() {
|
||
if !local_data_present() {
|
||
eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH);
|
||
return;
|
||
}
|
||
let (dir, _g) = fresh_project();
|
||
let fixture_dir = temp_cwd("sweep_real_multicolumn_open");
|
||
let fixture = fixture_dir.join("hl_signal.json");
|
||
std::fs::write(&fixture, HL_SIGNAL_OPEN_BLUEPRINT).expect("write fixture");
|
||
let out = std::process::Command::new(BIN)
|
||
.args([
|
||
"sweep", fixture.to_str().unwrap(),
|
||
"--real", "GER40",
|
||
"--from", GER40_SEPT2024_FROM_MS,
|
||
"--to", GER40_SEPT2024_TO_MS,
|
||
"--axis", "hl_signal.fast.length=2,4",
|
||
"--axis", "hl_signal.slow.length=8",
|
||
])
|
||
.current_dir(&dir)
|
||
.output()
|
||
.unwrap();
|
||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
|
||
let lines: Vec<&str> = stdout.lines().collect();
|
||
assert_eq!(lines.len(), 2, "one member line per grid point: {stdout}");
|
||
for line in &lines {
|
||
let v: serde_json::Value = serde_json::from_str(line).expect("member line parses as JSON");
|
||
assert!(
|
||
v["report"]["manifest"].is_object(),
|
||
"each member carries a real report, not a source-mismatch fault: {line}"
|
||
);
|
||
}
|
||
let _ = std::fs::remove_dir_all(&fixture_dir);
|
||
}
|
||
|
||
/// Property (#210 c0110 fieldtest, "the wrapped-name refusal mangles the axis
|
||
/// the user typed"): the dissolved real-data blueprint sweep types `--axis`
|
||
/// against the WRAPPED probe namespace (`sma_signal.fast.length`, what
|
||
/// `--list-axes` prints), not the raw campaign-document namespace
|
||
/// (`fast.length`). A user who types the raw form must be refused up front,
|
||
/// echoing exactly what they typed — never a stripped-then-mangled fragment
|
||
/// (the old failure mode: `fast.length` -> strip one segment -> the
|
||
/// nonsensical bare `"length"`). NOT gated: the preflight is data-free (it
|
||
/// only reloads the blueprint to probe its param space), so the refusal
|
||
/// fires before the archive is ever touched — this test needs no local data.
|
||
#[test]
|
||
fn aura_sweep_real_blueprint_refuses_a_raw_form_axis_name_before_data_access() {
|
||
let dir = temp_cwd("sweep_real_rawname_refusal");
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = Command::new(BIN)
|
||
.args([
|
||
"sweep", &fixture,
|
||
"--real", "GER40",
|
||
"--from", GER40_SEPT2024_FROM_MS,
|
||
"--to", GER40_SEPT2024_TO_MS,
|
||
"--axis", "fast.length=2,4",
|
||
"--name", "c0110-rawname",
|
||
])
|
||
.current_dir(&dir)
|
||
.output()
|
||
.expect("spawn aura sweep (raw-form axis)");
|
||
assert_eq!(out.status.code(), Some(2), "a raw-form axis name must be refused, not run: status={:?}", out.status);
|
||
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
|
||
assert!(
|
||
stderr.contains("axis \"fast.length\""),
|
||
"the refusal echoes exactly the name the user typed: {stderr}"
|
||
);
|
||
assert!(
|
||
!stderr.contains("axis \"length\""),
|
||
"must never mangle the typed axis into a stripped fragment: {stderr}"
|
||
);
|
||
// Data-free: no archive/store access at all.
|
||
assert!(!dir.join("runs").exists(), "a refused axis name must not start a real run");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// Property (#247): every sweep-verb boundary `BindError` renders as prose in
|
||
/// the same register as its unknown-axis sibling
|
||
/// (`aura: axis <name>: names no param of this blueprint … — see `aura sweep
|
||
/// <bp> --list-axes``) — never a raw Rust Debug struct. A wrong-kind axis value
|
||
/// and an uncovered open knob are the two variants that today leak
|
||
/// `KindMismatch { knob: …, expected: …, got: … }` and
|
||
/// `MissingKnob("…")` verbatim to stderr; both must instead name the axis, carry
|
||
/// the specifics (expected vs supplied kind / the unbound knob), and point at
|
||
/// `--list-axes`, with the exit code staying 2. Data-free: both faults are
|
||
/// caught at the axis-resolution boundary before any member runs, so the test
|
||
/// needs no archive and no project (hostless synthetic sweep).
|
||
#[test]
|
||
fn sweep_boundary_bind_errors_render_as_prose_not_debug_structs() {
|
||
let dir = temp_cwd("sweep-bind-error-prose");
|
||
|
||
// (a) KindMismatch — an axis re-opens the bound I64 `fast.length` (#246) but
|
||
// supplies F64 values: the specifics are the expected vs supplied kind.
|
||
let closed = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let km = Command::new(BIN)
|
||
.args(["sweep", &closed, "--axis", "sma_signal.fast.length=2.5,3.5"])
|
||
.current_dir(&dir)
|
||
.output()
|
||
.expect("spawn aura sweep (kind-mismatched axis)");
|
||
assert_eq!(
|
||
km.status.code(),
|
||
Some(2),
|
||
"a kind-mismatched axis value stays a usage refusal: status={:?}",
|
||
km.status
|
||
);
|
||
let km_err = String::from_utf8_lossy(&km.stderr).into_owned();
|
||
assert!(
|
||
!km_err.contains("KindMismatch"),
|
||
"must not leak the raw Rust Debug struct name: {km_err}"
|
||
);
|
||
assert!(
|
||
km_err.contains("sma_signal.fast.length"),
|
||
"the prose names the offending axis exactly as typed: {km_err}"
|
||
);
|
||
assert!(
|
||
km_err.contains("I64") && km_err.contains("F64"),
|
||
"the prose carries the expected vs supplied kind specifics: {km_err}"
|
||
);
|
||
assert!(
|
||
km_err.contains("--list-axes"),
|
||
"the prose points at the discovery surface, like the unknown-axis sibling: {km_err}"
|
||
);
|
||
|
||
// (b) MissingKnob — the open fixture leaves `slow.length` unbound when only
|
||
// `fast.length` is swept: the specific is the uncovered open knob.
|
||
let open = format!("{}/tests/fixtures/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
|
||
let mk = Command::new(BIN)
|
||
.args(["sweep", &open, "--axis", "sma_signal.fast.length=2,4"])
|
||
.current_dir(&dir)
|
||
.output()
|
||
.expect("spawn aura sweep (uncovered open knob)");
|
||
assert_eq!(
|
||
mk.status.code(),
|
||
Some(2),
|
||
"an uncovered open knob stays a usage refusal: status={:?}",
|
||
mk.status
|
||
);
|
||
let mk_err = String::from_utf8_lossy(&mk.stderr).into_owned();
|
||
assert!(
|
||
!mk_err.contains("MissingKnob"),
|
||
"must not leak the raw Rust Debug struct name: {mk_err}"
|
||
);
|
||
assert!(
|
||
mk_err.contains("sma_signal.slow.length"),
|
||
"the prose names the unbound knob no axis supplies: {mk_err}"
|
||
);
|
||
assert!(
|
||
mk_err.contains("--list-axes"),
|
||
"the prose points at the discovery surface, like the unknown-axis sibling: {mk_err}"
|
||
);
|
||
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// Property (#210 c0110 fieldtest, "refused dissolved sweeps still register
|
||
/// their generated campaign document" — store litter, probe P3): binding only
|
||
/// a SUBSET of the blueprint's open axes (here: `fast.length`, leaving
|
||
/// `slow.length` unbound) is a referential refusal at member-run time — but
|
||
/// the sugar must validate the generated documents (including the
|
||
/// axis-binding shape) BEFORE `register_generated`, so the refusal leaves
|
||
/// `runs/processes/` and `runs/campaigns/` untouched and appends no
|
||
/// `campaign_runs.jsonl` record. Gated on the local GER40 archive (the
|
||
/// referential refusal is reached only after the archive is probed for the
|
||
/// window, same as the sibling dissolved-sweep pin above).
|
||
#[test]
|
||
fn aura_sweep_real_blueprint_subset_axes_refuses_without_store_litter() {
|
||
if !local_data_present() {
|
||
eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH);
|
||
return;
|
||
}
|
||
let (dir, _g) = fresh_project();
|
||
let fixture = format!("{}/tests/fixtures/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = Command::new(BIN)
|
||
.args([
|
||
"sweep", &fixture,
|
||
"--real", "GER40",
|
||
"--from", GER40_SEPT2024_FROM_MS,
|
||
"--to", GER40_SEPT2024_TO_MS,
|
||
"--axis", "sma_signal.fast.length=2,4",
|
||
"--name", "c0110-subset",
|
||
])
|
||
.current_dir(&dir)
|
||
.output()
|
||
.expect("spawn aura sweep (subset axes)");
|
||
assert_ne!(
|
||
out.status.code(),
|
||
Some(0),
|
||
"leaving slow.length unbound must refuse, not run: stdout={}",
|
||
String::from_utf8_lossy(&out.stdout)
|
||
);
|
||
let count = |sub: &str| {
|
||
std::fs::read_dir(dir.join("runs").join(sub)).map(|d| d.count()).unwrap_or(0)
|
||
};
|
||
assert_eq!(count("processes"), 0, "a refused sweep must not register a generated process document");
|
||
assert_eq!(count("campaigns"), 0, "a refused sweep must not register a generated campaign document");
|
||
let runs_log = dir.join("runs").join("campaign_runs.jsonl");
|
||
assert!(
|
||
!runs_log.exists() || std::fs::read_to_string(&runs_log).unwrap().is_empty(),
|
||
"no realization recorded for a refused sweep"
|
||
);
|
||
}
|
||
|
||
/// Property (issue #246, task 5): the dissolved real-data `aura sweep`
|
||
/// campaign path accepts an `--axis` naming a BOUND param of a CLOSED
|
||
/// blueprint, not only an already-open one. Tasks 3-4 threaded this override
|
||
/// convention (bound value = default, `Composite::reopen`) through the
|
||
/// SYNTHETIC blueprint verb paths (`blueprint_sweep_family`/
|
||
/// `blueprint_sweep_over`, `reproduce_family_with_overridden_bound_param_re_derives`);
|
||
/// the real-data dissolved path routes through a distinct seam
|
||
/// (`CliMemberRunner::run_member` + `verb_sugar::validate_before_register`'s
|
||
/// P3 preflight) that carried no such threading at all, the explicit gap
|
||
/// named on `reproduce_family_with_overridden_bound_param_re_derives`'s doc
|
||
/// comment. Modelled on `sweep_real_blueprint_member_lines_pin_the_dissolved_contract`
|
||
/// (the dissolved-sweep e2e family `walkforward_dissolved_refuses_an_unknown_axis_name`
|
||
/// belongs to): same store-reading assertions (`aura runs families`, one
|
||
/// persisted `Sweep` family, member count), over the same `r_sma.json`
|
||
/// fixture every other real-data sweep pin uses (`sma_signal.fast.length`/
|
||
/// `sma_signal.slow.length` both bound) — the distinguishing property here
|
||
/// is that the swept axis names an already-bound param, not that the
|
||
/// fixture differs.
|
||
#[test]
|
||
fn sweep_dissolved_accepts_an_axis_over_a_bound_param() {
|
||
if !local_data_present() {
|
||
eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH);
|
||
return;
|
||
}
|
||
let (dir, _g) = fresh_project();
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = Command::new(BIN)
|
||
.args([
|
||
"sweep", &fixture,
|
||
"--real", "GER40",
|
||
"--from", GER40_SEPT2024_FROM_MS,
|
||
"--to", GER40_SEPT2024_TO_MS,
|
||
"--axis", "sma_signal.fast.length=2,3",
|
||
"--name", "bound-override",
|
||
])
|
||
.current_dir(&dir)
|
||
.output()
|
||
.expect("spawn aura sweep over a bound-param axis");
|
||
assert_eq!(
|
||
out.status.code(),
|
||
Some(0),
|
||
"a bound-param axis must re-open (#246) on the dissolved real-data campaign \
|
||
path too, stderr: {}",
|
||
String::from_utf8_lossy(&out.stderr)
|
||
);
|
||
let fams = Command::new(BIN).args(["runs", "families"]).current_dir(&dir).output().unwrap();
|
||
assert!(fams.status.success(), "families exit: {:?}", fams.status);
|
||
let fams_out = String::from_utf8_lossy(&fams.stdout).into_owned();
|
||
assert_eq!(fams_out.lines().count(), 1, "exactly one family persisted: {fams_out}");
|
||
assert!(fams_out.contains("\"kind\":\"Sweep\""), "families: {fams_out}");
|
||
assert!(
|
||
fams_out.contains("\"members\":2"),
|
||
"the two-value bound-param axis grid yields two members: {fams_out}"
|
||
);
|
||
}
|
||
|
||
/// Property (#246 override merge, exercised at scale by #248's open-twins
|
||
/// migration): sweeping the CLOSED `examples/r_sma.json` (both SMA lengths
|
||
/// bound to defaults 2/4) with `--axis` values that override BOTH bound
|
||
/// lengths yields BIT-IDENTICAL per-member `total_pips` to sweeping the
|
||
/// historical OPEN `tests/fixtures/r_sma_open.json` (both lengths genuinely
|
||
/// unbound) over the same axis values. #248's migration replaced the "bind
|
||
/// an open param" idiom with "override a bound param" at dozens of call
|
||
/// sites across cli_run.rs/research_docs.rs/project_load.rs; if the override
|
||
/// merge ever diverged from the historical bind path (e.g. a different
|
||
/// position in the rebuilt param vector), every migrated seed/grade site
|
||
/// would silently compile a DIFFERENT graph while still exiting 0 — no other
|
||
/// test cross-checks the two idioms against each other. NOT gated — a
|
||
/// synthetic sweep, no `--real`, so it is CI-safe on every machine.
|
||
#[test]
|
||
fn sweep_over_a_bound_override_matches_the_historical_open_bind() {
|
||
let closed_dir = temp_cwd("bound-override-matches-open-closed");
|
||
let closed_bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let closed_out = Command::new(BIN)
|
||
.args([
|
||
"sweep", &closed_bp,
|
||
"--axis", "sma_signal.fast.length=2,4",
|
||
"--axis", "sma_signal.slow.length=8,16",
|
||
])
|
||
.current_dir(&closed_dir)
|
||
.output()
|
||
.expect("spawn sweep over the closed blueprint's bound-override axes");
|
||
assert!(
|
||
closed_out.status.success(),
|
||
"closed sweep exit: {:?}; stderr: {}",
|
||
closed_out.status,
|
||
String::from_utf8_lossy(&closed_out.stderr)
|
||
);
|
||
|
||
let open_dir = temp_cwd("bound-override-matches-open-open");
|
||
let open_bp = format!("{}/tests/fixtures/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
|
||
let open_out = Command::new(BIN)
|
||
.args([
|
||
"sweep", &open_bp,
|
||
"--axis", "sma_signal.fast.length=2,4",
|
||
"--axis", "sma_signal.slow.length=8,16",
|
||
])
|
||
.current_dir(&open_dir)
|
||
.output()
|
||
.expect("spawn sweep over the open blueprint's unbound axes");
|
||
assert!(
|
||
open_out.status.success(),
|
||
"open sweep exit: {:?}; stderr: {}",
|
||
open_out.status,
|
||
String::from_utf8_lossy(&open_out.stderr)
|
||
);
|
||
|
||
let pips = |dir: &Path| -> Vec<f64> {
|
||
let rank = Command::new(BIN)
|
||
.args(["runs", "family", "sweep-0", "rank", "total_pips"])
|
||
.current_dir(dir)
|
||
.output()
|
||
.expect("spawn rank");
|
||
assert!(rank.status.success(), "rank exit: {:?}", rank.status);
|
||
let out = String::from_utf8(rank.stdout).expect("utf-8");
|
||
out.lines()
|
||
.map(|l| {
|
||
let key = "\"total_pips\":";
|
||
let start = l.find(key).expect("line has total_pips") + key.len();
|
||
let tail = &l[start..];
|
||
let end = tail.find([',', '}']).expect("token end");
|
||
tail[..end].parse().expect("total_pips is an f64")
|
||
})
|
||
.collect()
|
||
};
|
||
let mut closed_pips = pips(&closed_dir);
|
||
let mut open_pips = pips(&open_dir);
|
||
closed_pips.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||
open_pips.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||
assert_eq!(
|
||
closed_pips, open_pips,
|
||
"bound-override and historical open-bind must compute bit-identical \
|
||
member metrics for the same axis grid"
|
||
);
|
||
|
||
let _ = std::fs::remove_dir_all(&closed_dir);
|
||
let _ = std::fs::remove_dir_all(&open_dir);
|
||
}
|
||
|
||
/// The synthetic seed-resweep `mc` is undefined over real bars (one realization ->
|
||
/// identical members), so a bare `aura mc --real EURUSD` — with no R candidate — is
|
||
/// REJECTED (usage on stderr, exit 2) at the binary boundary. The real path is
|
||
/// reachable ONLY via the blueprint positional + `--axis` campaign form (the
|
||
/// R-bootstrap over the pooled OOS R series,
|
||
/// `mc_r_bootstrap_real_e2e_pins_the_exact_current_grade`; spelled
|
||
/// `--strategy r-sma` before the #220 argv migration); a `--real` without a
|
||
/// blueprint stays a usage error. NOT gated — the refusal precedes any
|
||
/// data access, so it is CI-safe on every machine.
|
||
#[test]
|
||
fn mc_rejects_real_flag_with_usage_exit_2() {
|
||
let dir = temp_cwd("mc_no_real");
|
||
let out = Command::new(BIN)
|
||
.args(["mc", "--real", "EURUSD"])
|
||
.current_dir(&dir)
|
||
.output()
|
||
.expect("spawn aura mc --real");
|
||
assert_eq!(out.status.code(), Some(2), "mc --real without a blueprint is a usage error");
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(stderr.contains("Usage: aura mc"), "usage refusal: {stderr}");
|
||
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// Property (window-flags-require-`--real`, at the binary boundary): a family
|
||
/// command given a `--from`/`--to` window WITHOUT `--real` is a usage error —
|
||
/// there is no synthetic window knob, so `aura sweep --from 100` and
|
||
/// `aura walkforward --to 200` exit 2 (usage on stderr), never a silent synthetic
|
||
/// run over the whole built-in stream. The pure grammar is unit-tested in
|
||
/// `main.rs`; this pins that the `Err` arm actually wires through to `exit(2)` at
|
||
/// the dispatch for BOTH family commands. NOT gated — the refusal is pure
|
||
/// argument parsing, before any data access.
|
||
#[test]
|
||
fn family_window_flag_without_real_refuses_with_usage_exit_2() {
|
||
// (subcommand args, the subcommand token the usage message must name)
|
||
for (args, cmd) in [
|
||
(&["sweep", "--from", "100"][..], "sweep"),
|
||
(&["sweep", "--to", "200"][..], "sweep"),
|
||
(&["walkforward", "--from", "100"][..], "walkforward"),
|
||
(&["walkforward", "--to", "200"][..], "walkforward"),
|
||
] {
|
||
let dir = temp_cwd("family_window_no_real");
|
||
let out = Command::new(BIN)
|
||
.args(args)
|
||
.current_dir(&dir)
|
||
.output()
|
||
.unwrap_or_else(|e| panic!("spawn aura {args:?}: {e}"));
|
||
assert_eq!(out.status.code(), Some(2), "`aura {args:?}` must exit 2; status: {:?}", out.status);
|
||
assert!(out.stdout.is_empty(), "`aura {args:?}` must emit no run on stdout: {:?}", out.stdout);
|
||
// the refusal carries the subcommand's usage, which names `--real` — the
|
||
// flag the window needs and was given without.
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(stderr.contains(cmd), "`aura {args:?}` stderr must name `{cmd}`: {stderr:?}");
|
||
assert!(stderr.contains("--real"), "`aura {args:?}` stderr must name `--real`: {stderr:?}");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
}
|
||
|
||
/// Property (house style, #179): every hand-rolled usage line reads
|
||
/// `Usage: aura <verb> …` — capital `Usage:` plus the program name inside the
|
||
/// grammar. `sweep`/`walkforward`'s built-in-branch usage closures were bare
|
||
/// (`"sweep [--strategy …]"`, no prefix, no program name) before cycle 0101; a
|
||
/// regression that drops the prefix again would NOT be caught by the older
|
||
/// `family_window_flag_without_real_refuses_with_usage_exit_2` pin above, which
|
||
/// only checks for the verb token and `--real` — not the `Usage: aura` lead-in.
|
||
/// An unknown `--strategy` name reaches sweep's closure; the bare verb reaches
|
||
/// walkforward's.
|
||
#[test]
|
||
fn builtin_branch_usage_lines_lead_with_the_house_style_prefix() {
|
||
for (args, verb) in [(&["sweep", "--strategy", "bogus"][..], "sweep"), (&["walkforward"][..], "walkforward")] {
|
||
let dir = temp_cwd("builtin_usage_prefix");
|
||
let out = Command::new(BIN)
|
||
.args(args)
|
||
.current_dir(&dir)
|
||
.output()
|
||
.unwrap_or_else(|e| panic!("spawn aura {args:?}: {e}"));
|
||
assert_eq!(out.status.code(), Some(2), "`aura {args:?}` must exit 2; status: {:?}", out.status);
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
let want = format!("aura: Usage: aura {verb} ");
|
||
assert!(stderr.starts_with(&want), "`aura {args:?}` stderr must lead with {want:?}: {stderr:?}");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
}
|
||
|
||
/// Property (house style, #179): the built-in `mc` usage line carries TWO
|
||
/// `|`-separated grammar alternatives, and BOTH name the program (`aura mc …`),
|
||
/// not just the first. This is the one lockstep-pinned site
|
||
/// (`mc_rejects_real_flag_with_usage_exit_2` above), but that pin only checks
|
||
/// for a single `"Usage"` occurrence — a regression that fixed only the first
|
||
/// alternative (leaving the second `| aura mc <blueprint.json> --real …`
|
||
/// alternative unprefixed) would still pass it. `--seeds` with no blueprint
|
||
/// file is the built-in branch's own
|
||
/// grammar violation, reaching this exact literal. (#159 cut 4: the first
|
||
/// alternative's literal is now the blueprint-mode grammar, not a bare
|
||
/// `[--name` — `mc` is blueprint-first.)
|
||
#[test]
|
||
fn mc_builtin_usage_names_the_program_in_both_alternatives() {
|
||
let dir = temp_cwd("mc_usage_both_alts");
|
||
let out = Command::new(BIN)
|
||
.args(["mc", "--seeds", "3"])
|
||
.current_dir(&dir)
|
||
.output()
|
||
.expect("spawn aura mc --seeds");
|
||
assert_eq!(out.status.code(), Some(2), "mc --seeds without a blueprint must exit 2; status: {:?}", out.status);
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(
|
||
stderr.contains("Usage: aura mc <blueprint.json>"),
|
||
"first alternative must name the program: {stderr:?}"
|
||
);
|
||
assert!(
|
||
stderr.contains("| aura mc <blueprint.json> --real"),
|
||
"second alternative must ALSO name the program: {stderr:?}"
|
||
);
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// Property (house style, #179): `mc`'s loaded-blueprint branch usage line
|
||
/// also normalizes to `Usage: aura mc <blueprint.json> …` — before cycle 0101
|
||
/// it was fully lowercase and carried no program name (`"usage: mc
|
||
/// <blueprint.json> …"`). Reusing `sma_signal.json` (an existing closed
|
||
/// blueprint fixture) only to reach the dispatch branch; omitting `--seeds`
|
||
/// is this branch's own grammar violation.
|
||
#[test]
|
||
fn mc_over_a_blueprint_usage_names_the_program() {
|
||
let dir = temp_cwd("mc_blueprint_usage_prefix");
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = Command::new(BIN)
|
||
.args(["mc", &fixture])
|
||
.current_dir(&dir)
|
||
.output()
|
||
.expect("spawn aura mc <blueprint.json>");
|
||
assert_eq!(out.status.code(), Some(2), "mc <blueprint.json> without --seeds must exit 2; status: {:?}", out.status);
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(
|
||
stderr.contains("aura: Usage: aura mc <blueprint.json>"),
|
||
"stderr must lead with the house-style, program-named usage: {stderr:?}"
|
||
);
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
// Note (#159 cut 4 collateral): this removes the `aura chart <name>`
|
||
// CLI-integration tests (single-run and family overlay/tap-filter/meta, the
|
||
// cross-kind write-guard collision + same-kind-overwrite pair) — every one of
|
||
// them populated its `TraceStore` fixture via a bare `run --trace`/`sweep
|
||
// --trace` invocation, both retired. The only surviving `TraceStore::write`
|
||
// call site left in the whole crate is `campaign_run::persist_campaign_traces`
|
||
// (behind a campaign's `--persist-taps`) — an entirely different, much
|
||
// heavier fixture than any of these tests set up. Removed as PIP-retirement
|
||
// collateral, not
|
||
// repointed; the render/build logic they exercised stays covered by the
|
||
// hand-built-fixture unit tests (`build_chart_data_threads_run_manifest_into_meta`
|
||
// here, `render_chart_html_*` in render.rs), which never touch the CLI or disk.
|
||
|
||
// --- `aura run --harness <name>` selector (iter-3 Task 3) --------------------
|
||
|
||
/// Property: an unknown `--harness` name is a usage error at the binary boundary —
|
||
/// exit 2 (never a silent default run), the `Err` arm wiring through to `exit(2)`.
|
||
#[test]
|
||
fn run_unknown_harness_exits_two() {
|
||
let out = std::process::Command::new(BIN).args(["run", "--harness", "nope"]).output().unwrap();
|
||
assert_eq!(out.status.code(), Some(2));
|
||
}
|
||
|
||
/// Property (#159 cut 2): `--strategy r-breakout` is a fully retired CLI token, on
|
||
/// BOTH `sweep` and `walkforward` — indistinguishable from an unrecognized token
|
||
/// (`bogus`). `--strategy` is now a plain `Option<String>` with no per-token
|
||
/// dispatch, so a retired value carries nothing special: it flows into clap's
|
||
/// generic usage path and out as exit 2 with the generic `aura: Usage: aura
|
||
/// <verb> …` line, never a "valid-but-unsupported" message. A regression that
|
||
/// re-taught the CLI to recognize "r-breakout" specially (rather than leaving it
|
||
/// deleted) would make this test's stderr start naming "r-breakout" instead of
|
||
/// reading the generic usage line — this is the negative that catches a
|
||
/// half-finished retirement.
|
||
#[test]
|
||
fn sweep_and_walkforward_reject_retired_r_breakout_token_as_unrecognized() {
|
||
for (args, verb) in [
|
||
(&["sweep", "--strategy", "r-breakout"][..], "sweep"),
|
||
(&["walkforward", "--strategy", "r-breakout"][..], "walkforward"),
|
||
] {
|
||
let cwd = temp_cwd("retired-r-breakout-token");
|
||
let out = Command::new(BIN)
|
||
.args(args)
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.unwrap_or_else(|e| panic!("spawn aura {args:?}: {e}"));
|
||
assert_eq!(out.status.code(), Some(2), "`aura {args:?}` must exit 2; status: {:?}", out.status);
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
if verb == "sweep" {
|
||
let want = format!("aura: Usage: aura {verb} ");
|
||
assert!(
|
||
stderr.starts_with(&want),
|
||
"`aura {args:?}` must fall into the generic usage error, not a special-cased \
|
||
message: {stderr:?}"
|
||
);
|
||
} else {
|
||
assert!(
|
||
stderr.contains("unexpected argument '--strategy'"),
|
||
"`aura {args:?}` must be a clap unknown-argument rejection: {stderr:?}"
|
||
);
|
||
}
|
||
assert!(out.stdout.is_empty(), "no family summary on the rejected path: {:?}", out.stdout);
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
}
|
||
|
||
/// Property (#159 cut 3): `--strategy r-meanrev` is a fully retired token on both
|
||
/// sweep and walkforward — the generic usage error, no special-casing.
|
||
#[test]
|
||
fn sweep_and_walkforward_reject_retired_r_meanrev_token_as_unrecognized() {
|
||
for (args, verb) in [
|
||
(&["sweep", "--strategy", "r-meanrev"][..], "sweep"),
|
||
(&["walkforward", "--strategy", "r-meanrev"][..], "walkforward"),
|
||
] {
|
||
let cwd = temp_cwd("retired-r-meanrev-token");
|
||
let out = Command::new(BIN).args(args).current_dir(&cwd).output()
|
||
.unwrap_or_else(|e| panic!("spawn aura {args:?}: {e}"));
|
||
assert_eq!(out.status.code(), Some(2), "`aura {args:?}` must exit 2; status: {:?}", out.status);
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
if verb == "sweep" {
|
||
let want = format!("aura: Usage: aura {verb} ");
|
||
assert!(stderr.starts_with(&want), "generic usage, not special-cased: {stderr:?}");
|
||
} else {
|
||
assert!(
|
||
stderr.contains("unexpected argument '--strategy'"),
|
||
"`aura {args:?}` must be a clap unknown-argument rejection: {stderr:?}"
|
||
);
|
||
}
|
||
assert!(out.stdout.is_empty(), "no summary on the rejected path: {:?}", out.stdout);
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
}
|
||
|
||
/// Property (#159 cut 4): `--strategy sma` and `--strategy momentum` are fully
|
||
/// retired CLI tokens on both sweep and walkforward — the generic usage error,
|
||
/// no special-casing (the PIP built-in strategies retired with their runners).
|
||
#[test]
|
||
fn sweep_and_walkforward_reject_retired_pip_tokens_as_unrecognized() {
|
||
for tok in ["sma", "momentum"] {
|
||
for verb in ["sweep", "walkforward"] {
|
||
let args = [verb, "--strategy", tok];
|
||
let cwd = temp_cwd(&format!("retired-pip-{tok}-{verb}"));
|
||
let out = Command::new(BIN)
|
||
.args(args)
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.unwrap_or_else(|e| panic!("spawn aura {args:?}: {e}"));
|
||
assert_eq!(
|
||
out.status.code(),
|
||
Some(2),
|
||
"`aura {args:?}` must exit 2; status: {:?}",
|
||
out.status
|
||
);
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
if verb == "sweep" {
|
||
let want = format!("aura: Usage: aura {verb} ");
|
||
assert!(
|
||
stderr.starts_with(&want),
|
||
"`aura {args:?}` must fall into the generic usage error, not a \
|
||
special-cased message: {stderr:?}"
|
||
);
|
||
} else {
|
||
assert!(
|
||
stderr.contains("unexpected argument '--strategy'"),
|
||
"`aura {args:?}` must be a clap unknown-argument rejection: {stderr:?}"
|
||
);
|
||
}
|
||
assert!(out.stdout.is_empty(), "no summary on the rejected path: {:?}", out.stdout);
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Property (#159 cut 2): `--channel` is genuinely removed from the sweep grammar,
|
||
/// not merely unused — clap rejects it as a structurally unknown argument (exit 2)
|
||
/// rather than silently accepting and dropping it. A regression that left the flag
|
||
/// on `SweepCmd` while deleting only its plumbing would parse successfully here and
|
||
/// this test would fail on the exit code.
|
||
#[test]
|
||
fn sweep_channel_flag_is_removed_from_the_grammar() {
|
||
let cwd = temp_cwd("sweep-channel-flag-retired");
|
||
let out = Command::new(BIN)
|
||
.args(["sweep", "--strategy", "sma", "--channel", "3"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura sweep --channel");
|
||
assert_eq!(out.status.code(), Some(2), "--channel must be an unrecognized clap argument: {:?}", out.status);
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(
|
||
stderr.contains("--channel") || stderr.to_lowercase().contains("unexpected argument"),
|
||
"clap must name the unknown flag: {stderr:?}"
|
||
);
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// Property (#220 generalize vertical): `--strategy`/`--fast`/`--slow` are
|
||
/// genuinely removed from the generalize grammar, not merely unused — clap
|
||
/// rejects `--strategy` as a structurally unknown argument (exit 2) rather
|
||
/// than silently accepting and ignoring it now that the candidate is an
|
||
/// arbitrary blueprint positional + generic `--axis`. A regression that left
|
||
/// the old flags on `GeneralizeCmd` while deleting only their plumbing would
|
||
/// parse successfully here and this test would fail on the exit code.
|
||
#[test]
|
||
fn generalize_strategy_flag_is_removed_from_the_grammar() {
|
||
let cwd = temp_cwd("generalize-strategy-flag-retired");
|
||
let out = Command::new(BIN)
|
||
.args([
|
||
"generalize", "--strategy", "r-sma", "--real", "GER40,USDJPY",
|
||
"--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0",
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura generalize --strategy");
|
||
assert_eq!(out.status.code(), Some(2), "--strategy must be an unrecognized clap argument: {:?}", out.status);
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(
|
||
stderr.contains("--strategy") || stderr.to_lowercase().contains("unexpected argument"),
|
||
"clap must name the unknown flag: {stderr:?}"
|
||
);
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// Property: `aura generalize` grades a single r-sma candidate across two
|
||
/// instruments — its stdout carries the per-instrument breakdown + the worst-case
|
||
/// floor + the sign-agreement, then the persisted CrossInstrument family handle.
|
||
/// Gated on local GER40/USDJPY data (the shared Sept-2024 window), skips cleanly
|
||
/// when the archive is absent.
|
||
#[test]
|
||
fn generalize_grades_a_candidate_across_two_instruments() {
|
||
const FROM_MS: &str = "1725148800000";
|
||
const TO_MS: &str = "1727740799999";
|
||
let (cwd, _g) = fresh_project();
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"generalize", &fixture, "--real", "GER40,USDJPY",
|
||
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
|
||
"--stop-length", "14", "--stop-k", "2.0",
|
||
"--from", FROM_MS, "--to", TO_MS,
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura");
|
||
if out.status.code() == Some(1) {
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(
|
||
stderr.contains("no local data") || stderr.contains("no recorded geometry"),
|
||
"exit 1 must be a data refusal, got: {stderr}"
|
||
);
|
||
eprintln!("skip: no local GER40/USDJPY data");
|
||
return;
|
||
}
|
||
assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status);
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
assert!(stdout.contains("\"generalize\":"), "aggregate object: {stdout}");
|
||
assert!(stdout.contains("\"n_instruments\":2"), "two instruments: {stdout}");
|
||
assert!(stdout.contains("\"worst_case\":"), "worst_case present: {stdout}");
|
||
assert!(stdout.contains("\"sign_agreement\":"), "sign_agreement present: {stdout}");
|
||
assert!(stdout.contains("GER40") && stdout.contains("USDJPY"), "per-instrument breakdown: {stdout}");
|
||
assert!(stdout.contains("\"family_id\":\"generalize-0\""), "family handle: {stdout}");
|
||
}
|
||
|
||
/// Characterization pin (byte-identity anchor for the generalize dissolution,
|
||
/// #210). The current `aura generalize` binds its protective stop as a grid axis
|
||
/// (`blueprint_sweep_family` with `stop_open=true`); the dissolution rebinds the same
|
||
/// stop through the risk-regime seam (`RiskRegime::Vol -> StopRule::Vol`). The
|
||
/// sibling `generalize_grades_a_candidate_across_two_instruments` asserts shape
|
||
/// only — `worst_case` and the per-instrument R floats are never checked — so a
|
||
/// stop-mechanism divergence between the two bindings would ship green. This pins
|
||
/// the EXACT current R grade of the identical invocation; after the dissolution
|
||
/// the same command must reproduce these bytes (the acceptance gate), and any
|
||
/// drift fails here loudly. Gated on the shared GER40/USDJPY Sept-2024 archive;
|
||
/// skips cleanly on a data refusal.
|
||
#[test]
|
||
fn generalize_real_e2e_pins_the_exact_current_grade() {
|
||
const FROM_MS: &str = "1725148800000";
|
||
const TO_MS: &str = "1727740799999";
|
||
let (cwd, _g) = fresh_project();
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"generalize", &fixture, "--real", "GER40,USDJPY",
|
||
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
|
||
"--stop-length", "14", "--stop-k", "2.0",
|
||
"--from", FROM_MS, "--to", TO_MS,
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura");
|
||
if out.status.code() == Some(1) {
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(
|
||
stderr.contains("no local data") || stderr.contains("no recorded geometry"),
|
||
"exit 1 must be a data refusal, got: {stderr}"
|
||
);
|
||
eprintln!("skip: no local GER40/USDJPY data");
|
||
return;
|
||
}
|
||
assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status);
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
let grade_line = stdout
|
||
.lines()
|
||
.find(|l| l.starts_with("{\"generalize\":"))
|
||
.unwrap_or_else(|| panic!("the generalize grade line: {stdout}"));
|
||
let v: serde_json::Value = serde_json::from_str(grade_line).expect("grade line parses as JSON");
|
||
let g = &v["generalize"];
|
||
assert_eq!(g["metric"].as_str(), Some("expectancy_r"), "metric: {grade_line}");
|
||
assert_eq!(g["n_instruments"].as_u64(), Some(2), "two instruments: {grade_line}");
|
||
assert_eq!(g["sign_agreement"].as_u64(), Some(2), "both agree in sign: {grade_line}");
|
||
// EXACT R floats — the byte-identity anchor the dissolution must preserve.
|
||
assert_eq!(g["worst_case"].as_f64(), Some(0.005795903617609842), "worst-case R floor: {grade_line}");
|
||
let per = g["per_instrument"].as_array().expect("per_instrument is an array");
|
||
assert_eq!(per.len(), 2, "two per-instrument grades: {grade_line}");
|
||
assert_eq!(per[0][0].as_str(), Some("GER40"), "first instrument: {grade_line}");
|
||
assert_eq!(per[0][1].as_f64(), Some(0.01056371324510624), "GER40 expectancy R: {grade_line}");
|
||
assert_eq!(per[1][0].as_str(), Some("USDJPY"), "second instrument: {grade_line}");
|
||
assert_eq!(per[1][1].as_f64(), Some(0.005795903617609842), "USDJPY expectancy R: {grade_line}");
|
||
}
|
||
|
||
/// Property (#220 generalize vertical): `aura generalize` grades ANY sweepable
|
||
/// blueprint, not just the historical r-sma candidate — the whole point of
|
||
/// dropping `--strategy r-sma`/`--fast`/`--slow` for a positional blueprint +
|
||
/// generic `--axis`. Every other generalize E2E test here still exercises
|
||
/// `r_sma.json`, so a regression that silently special-cased r-sma paths
|
||
/// back in (e.g. resolving axes against a hardcoded `sma_signal.*` namespace)
|
||
/// would ship green there while breaking every other blueprint. This runs the
|
||
/// unrelated r-breakout candidate (the ganged `channel_length` axis, no
|
||
/// `sma_signal` node in sight) end-to-end and asserts a well-formed two-
|
||
/// instrument grade. Gated on the shared GER40/USDJPY Sept-2024 archive; skips
|
||
/// cleanly on a data refusal.
|
||
#[test]
|
||
fn generalize_grades_a_non_r_sma_blueprint() {
|
||
const FROM_MS: &str = "1725148800000";
|
||
const TO_MS: &str = "1727740799999";
|
||
let (cwd, _g) = fresh_project();
|
||
let fixture = format!("{}/tests/fixtures/r_breakout_open.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"generalize", &fixture, "--real", "GER40,USDJPY",
|
||
"--axis", "r_breakout_signal.channel_length=20",
|
||
"--stop-length", "14", "--stop-k", "2.0",
|
||
"--from", FROM_MS, "--to", TO_MS,
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura");
|
||
if out.status.code() == Some(1) {
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(
|
||
stderr.contains("no local data") || stderr.contains("no recorded geometry"),
|
||
"exit 1 must be a data refusal, got: {stderr}"
|
||
);
|
||
eprintln!("skip: no local GER40/USDJPY data");
|
||
return;
|
||
}
|
||
assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status);
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
assert!(stdout.contains("\"generalize\":"), "aggregate object: {stdout}");
|
||
assert!(stdout.contains("\"n_instruments\":2"), "two instruments: {stdout}");
|
||
assert!(stdout.contains("\"worst_case\":"), "worst_case present: {stdout}");
|
||
assert!(stdout.contains("\"sign_agreement\":"), "sign_agreement present: {stdout}");
|
||
assert!(stdout.contains("GER40") && stdout.contains("USDJPY"), "per-instrument breakdown: {stdout}");
|
||
assert!(stdout.contains("\"family_id\":"), "family handle: {stdout}");
|
||
}
|
||
|
||
/// Characterization pin (byte-identity anchor for the walkforward dissolution,
|
||
/// #210). The current `aura walkforward` runs its per-window IS-refit inline
|
||
/// (`walkforward_family` -> `select_winner` -> OOS run -> stitch/pool); the
|
||
/// dissolution reroutes the same rolling IS-sweep -> OOS-run -> deflation through
|
||
/// the campaign `std::walk_forward` stage, and must retain the OOS pip curve the
|
||
/// campaign runner currently discards (`oos_equity: vec![]`) to reproduce
|
||
/// `stitched_total_pips`, plus reconstruct pooled `oos_r`. The multi-point grid
|
||
/// (fast 3,5 x slow 12,20) makes the per-window winner selection non-degenerate --
|
||
/// `param_stability` varies across windows -- so a winner-pick divergence between
|
||
/// the inline and campaign refit cannot ship green. This pins the EXACT current
|
||
/// grade of the identical invocation over a fixed 2025 GER40 window; after the
|
||
/// dissolution the same command must reproduce these bytes (the acceptance gate).
|
||
/// Gated on the shared GER40 archive; skips cleanly on a data refusal.
|
||
#[test]
|
||
fn walkforward_real_e2e_pins_the_exact_current_grade() {
|
||
const FROM_MS: &str = "1735689600000";
|
||
const TO_MS: &str = "1767225599000";
|
||
let (cwd, _g) = fresh_project();
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"walkforward", &fixture, "--real", "GER40",
|
||
"--axis", "sma_signal.fast.length=3,5", "--axis", "sma_signal.slow.length=12,20",
|
||
"--stop-length", "14", "--stop-k", "2.0",
|
||
"--from", FROM_MS, "--to", TO_MS,
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura");
|
||
if out.status.code() == Some(1) {
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(
|
||
stderr.contains("no local data") || stderr.contains("no recorded geometry"),
|
||
"exit 1 must be a data refusal, got: {stderr}"
|
||
);
|
||
eprintln!("skip: no local GER40 data");
|
||
return;
|
||
}
|
||
assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status);
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
let grade_line = stdout
|
||
.lines()
|
||
.find(|l| l.starts_with("{\"walkforward\":"))
|
||
.unwrap_or_else(|| panic!("the walkforward summary line: {stdout}"));
|
||
let v: serde_json::Value = serde_json::from_str(grade_line).expect("summary line parses as JSON");
|
||
let wf = &v["walkforward"];
|
||
assert_eq!(wf["windows"].as_u64(), Some(9), "window count: {grade_line}");
|
||
// EXACT floats -- the byte-identity anchor the dissolution must preserve.
|
||
// stitched_total_pips is retained only if the campaign runner keeps the OOS curve.
|
||
assert_eq!(wf["stitched_total_pips"].as_f64(), Some(-1039.8606666650755), "stitched OOS pips: {grade_line}");
|
||
let r = &wf["oos_r"];
|
||
assert_eq!(r["n_trades"].as_u64(), Some(20681), "pooled OOS trade count: {grade_line}");
|
||
assert_eq!(r["expectancy_r"].as_f64(), Some(-0.002397100685333715), "pooled OOS expectancy R: {grade_line}");
|
||
// param_stability varies across windows -> the per-window IS-refit picks
|
||
// different winners; pinning the means locks the winner-selection equivalence.
|
||
let ps = wf["param_stability"].as_array().expect("param_stability is an array");
|
||
assert_eq!(ps[0]["mean"].as_f64(), Some(3.888888888888889), "fast-MA refit mean: {grade_line}");
|
||
assert_eq!(ps[1]["mean"].as_f64(), Some(17.333333333333332), "slow-MA refit mean: {grade_line}");
|
||
}
|
||
|
||
/// Property (#220 walkforward vertical): `aura walkforward --real` IS-refits ANY
|
||
/// sweepable blueprint through the campaign path, not just the historical r-sma
|
||
/// candidate — the whole point of dropping `--strategy r-sma`/`--fast`/`--slow`
|
||
/// for a positional blueprint + generic `--axis`. Every other walkforward E2E
|
||
/// test here still exercises `r_sma.json`; a regression that silently
|
||
/// special-cased the refit's axis lookup back to a hardcoded `sma_signal.*`
|
||
/// namespace (e.g. in `walkforward_summary_json_from_reports`'s per-axis
|
||
/// `raw_matches_wrapped` search) would ship green there while breaking every
|
||
/// other blueprint. This runs the unrelated r-breakout candidate
|
||
/// (the ganged `channel_length` axis, no `sma_signal` node in sight) end-to-end
|
||
/// and asserts a well-formed multi-window summary whose `param_stability` has
|
||
/// exactly one entry per bound axis (1 ganged blueprint axis + the 2 stop columns).
|
||
/// The window count (9) is roller/data-derived, not strategy-derived, so it is
|
||
/// pinned exactly against the same GER40 2025 span the r-sma anchor uses.
|
||
/// Gated on the shared GER40 archive; skips cleanly on a data refusal.
|
||
#[test]
|
||
fn walkforward_dissolves_a_non_r_sma_blueprint() {
|
||
const FROM_MS: &str = "1735689600000";
|
||
const TO_MS: &str = "1767225599000";
|
||
let (cwd, _g) = fresh_project();
|
||
let fixture = format!("{}/tests/fixtures/r_breakout_open.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"walkforward", &fixture, "--real", "GER40",
|
||
"--axis", "r_breakout_signal.channel_length=10,20",
|
||
"--stop-length", "14", "--stop-k", "2.0",
|
||
"--from", FROM_MS, "--to", TO_MS,
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura");
|
||
if out.status.code() == Some(1) {
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(
|
||
stderr.contains("no local data") || stderr.contains("no recorded geometry"),
|
||
"exit 1 must be a data refusal, got: {stderr}"
|
||
);
|
||
eprintln!("skip: no local GER40 data");
|
||
return;
|
||
}
|
||
assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status);
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
let grade_line = stdout
|
||
.lines()
|
||
.find(|l| l.starts_with("{\"walkforward\":"))
|
||
.unwrap_or_else(|| panic!("the walkforward summary line: {stdout}"));
|
||
let v: serde_json::Value = serde_json::from_str(grade_line).expect("summary line parses as JSON");
|
||
let wf = &v["walkforward"];
|
||
assert_eq!(wf["windows"].as_u64(), Some(9), "window count (data/roller-derived): {grade_line}");
|
||
assert!(wf["oos_r"]["n_trades"].as_u64().is_some(), "pooled OOS trade count present: {grade_line}");
|
||
let ps = wf["param_stability"].as_array().expect("param_stability is an array");
|
||
assert_eq!(ps.len(), 3, "one entry per bound axis (channel_length, stop_length, stop_k): {grade_line}");
|
||
}
|
||
|
||
/// Fork A: the dissolved walkforward binds the stop as a single risk regime, so a
|
||
/// multi-value --stop-length/--stop-k is refused (exit 2) before any run.
|
||
#[test]
|
||
fn walkforward_dissolved_refuses_a_multi_value_stop() {
|
||
let cwd = temp_cwd("walkforward-multi-stop");
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"walkforward", &fixture, "--real", "GER40",
|
||
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
|
||
"--stop-length", "14,20", "--stop-k", "2.0",
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura");
|
||
assert_eq!(out.status.code(), Some(2), "multi-value stop is a usage refusal");
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(stderr.contains("invalid value"), "stop-regime refusal: {stderr}");
|
||
}
|
||
|
||
/// #215: --select plateau is no longer refused on the dissolved path — it now
|
||
/// threads through to the wf stage exactly like the built-in synthetic path.
|
||
/// The property is flag acceptance, not window semantics, so the window is
|
||
/// bounded (~135 days, one IS(90d)+OOS(30d) roll) rather than streaming the
|
||
/// full multi-year archive. Gated on the shared GER40 archive; skips cleanly
|
||
/// on a data refusal (exit 1), same tolerance as the sibling
|
||
/// stop-knob-defaulting test.
|
||
#[test]
|
||
fn walkforward_dissolved_accepts_select_plateau() {
|
||
let (cwd, _g) = fresh_project();
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"walkforward", &fixture, "--real", "GER40",
|
||
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
|
||
"--stop-length", "14", "--stop-k", "2.0",
|
||
"--select", "plateau:worst",
|
||
"--from", "1735689600000", "--to", "1747353600000",
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura");
|
||
if out.status.code() == Some(1) {
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(
|
||
stderr.contains("no local data") || stderr.contains("no recorded geometry"),
|
||
"exit 1 must be a data refusal, got: {stderr}"
|
||
);
|
||
eprintln!("skip: no local GER40 data");
|
||
return;
|
||
}
|
||
assert_eq!(
|
||
out.status.code(),
|
||
Some(0),
|
||
"--select plateau:worst must no longer be refused on the dissolved path: {}",
|
||
String::from_utf8_lossy(&out.stderr)
|
||
);
|
||
}
|
||
|
||
/// Characterization pin (#215): the same identical invocation as
|
||
/// `walkforward_real_e2e_pins_the_exact_current_grade` (fixture, real
|
||
/// instrument, axes, stop, window), but selecting `plateau:worst` instead of
|
||
/// the default argmax. Plateau scores the closed neighbourhood over the full
|
||
/// IS grid rather than picking the single best point, so its winner (and
|
||
/// therefore its OOS stitch) legitimately differs from the argmax anchor;
|
||
/// this pins that divergence exactly, so a regression that silently
|
||
/// collapsed plateau back to argmax (or vice versa) inside
|
||
/// `run_walk_forward_stage`'s `select_sweep_winner` dispatch goes red here
|
||
/// even though the argmax anchor stays green. Gated on the shared GER40
|
||
/// archive; skips cleanly on a data refusal.
|
||
#[test]
|
||
fn walkforward_real_e2e_pins_the_exact_current_plateau_grade() {
|
||
const FROM_MS: &str = "1735689600000";
|
||
const TO_MS: &str = "1767225599000";
|
||
let (cwd, _g) = fresh_project();
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"walkforward", &fixture, "--real", "GER40",
|
||
"--axis", "sma_signal.fast.length=3,5", "--axis", "sma_signal.slow.length=12,20",
|
||
"--stop-length", "14", "--stop-k", "2.0",
|
||
"--from", FROM_MS, "--to", TO_MS,
|
||
"--select", "plateau:worst",
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura");
|
||
if out.status.code() == Some(1) {
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(
|
||
stderr.contains("no local data") || stderr.contains("no recorded geometry"),
|
||
"exit 1 must be a data refusal, got: {stderr}"
|
||
);
|
||
eprintln!("skip: no local GER40 data");
|
||
return;
|
||
}
|
||
assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status);
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
let grade_line = stdout
|
||
.lines()
|
||
.find(|l| l.starts_with("{\"walkforward\":"))
|
||
.unwrap_or_else(|| panic!("the walkforward summary line: {stdout}"));
|
||
let v: serde_json::Value = serde_json::from_str(grade_line).expect("summary line parses as JSON");
|
||
let wf = &v["walkforward"];
|
||
assert_eq!(wf["windows"].as_u64(), Some(9), "window count: {grade_line}");
|
||
// EXACT floats -- the byte-identity anchor a plateau-vs-argmax regression must preserve.
|
||
assert_eq!(wf["stitched_total_pips"].as_f64(), Some(-968.3776666637464), "stitched OOS pips: {grade_line}");
|
||
let r = &wf["oos_r"];
|
||
assert_eq!(r["n_trades"].as_u64(), Some(20667), "pooled OOS trade count: {grade_line}");
|
||
assert_eq!(r["expectancy_r"].as_f64(), Some(0.007635120926372627), "pooled OOS expectancy R: {grade_line}");
|
||
// param_stability varies across windows -> the per-window plateau refit picks
|
||
// different winners than the argmax anchor; pinning the means locks the
|
||
// plateau winner-selection path.
|
||
let ps = wf["param_stability"].as_array().expect("param_stability is an array");
|
||
assert_eq!(ps[0]["mean"].as_f64(), Some(4.777777777777778), "fast-MA refit mean: {grade_line}");
|
||
assert_eq!(ps[1]["mean"].as_f64(), Some(16.444444444444443), "slow-MA refit mean: {grade_line}");
|
||
}
|
||
|
||
/// Property (#217): `walkforward_args_from` no longer requires both stop knobs
|
||
/// together on the campaign path — each defaults independently to the
|
||
/// single-sourced regime. Omitting only `--stop-k` while keeping `--stop-length`
|
||
/// explicit still succeeds, proving the defaulting is per-flag, not
|
||
/// all-or-nothing (the sibling
|
||
/// `walkforward_stopless_defaults_the_regime_byte_identically_to_explicit_3_2`
|
||
/// pins the both-omitted case). The property is per-flag defaulting, not
|
||
/// window semantics, so the window is bounded (~135 days) rather than
|
||
/// streaming the full multi-year archive. Gated on the shared GER40 archive;
|
||
/// skips cleanly on a data refusal.
|
||
#[test]
|
||
fn walkforward_dissolved_defaults_a_single_omitted_knob() {
|
||
let (cwd, _g) = fresh_project();
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"walkforward", &fixture, "--real", "GER40",
|
||
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
|
||
"--stop-length", "3",
|
||
"--from", "1735689600000", "--to", "1747353600000",
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura");
|
||
if out.status.code() == Some(1) {
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(
|
||
stderr.contains("no local data") || stderr.contains("no recorded geometry"),
|
||
"exit 1 must be a data refusal, got: {stderr}"
|
||
);
|
||
eprintln!("skip: no local GER40 data");
|
||
return;
|
||
}
|
||
assert_eq!(
|
||
out.status.code(),
|
||
Some(0),
|
||
"omitting only --stop-k must still succeed (it defaults independently): {}",
|
||
String::from_utf8_lossy(&out.stderr)
|
||
);
|
||
}
|
||
|
||
/// An empty `--real ""` still satisfies clap's `Option::is_some()` dispatch guard, so
|
||
/// `walkforward_args_from`'s own empty-string check is what refuses it (exit 2) —
|
||
/// distinct from omitting `--real` entirely, which never enters the dissolved branch.
|
||
#[test]
|
||
fn walkforward_dissolved_refuses_an_empty_real_symbol() {
|
||
let cwd = temp_cwd("walkforward-empty-real");
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"walkforward", &fixture, "--real", "",
|
||
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
|
||
"--stop-length", "14", "--stop-k", "2.0",
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura");
|
||
assert_eq!(out.status.code(), Some(2), "empty --real is a usage refusal");
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(stderr.contains("dissolves only over --real"), "empty-real refusal: {stderr}");
|
||
}
|
||
|
||
/// Property: an `--axis` name that is not among the loaded blueprint's sweepable
|
||
/// axes is refused before the raw-namespace strip or any archive access (exit 2),
|
||
/// echoing exactly the name the user typed — the same WRAPPED-probe preflight the
|
||
/// sibling verbs enforce
|
||
/// (`aura_sweep_real_blueprint_refuses_a_raw_form_axis_name_before_data_access`,
|
||
/// `generalize_refuses_an_unknown_axis_name`). Data-free: no archive/store access.
|
||
#[test]
|
||
fn walkforward_dissolved_refuses_an_unknown_axis_name() {
|
||
let cwd = temp_cwd("walkforward-unknown-axis");
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"walkforward", &fixture, "--real", "GER40",
|
||
"--axis", "sma_signal.nope=3", "--axis", "sma_signal.slow.length=12",
|
||
"--stop-length", "14", "--stop-k", "2.0",
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura");
|
||
assert_eq!(out.status.code(), Some(2), "an unknown axis name must exit 2");
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(
|
||
stderr.contains("axis \"sma_signal.nope\"") && stderr.contains("sweepable axes"),
|
||
"must name the unresolved axis exactly as typed: {stderr}"
|
||
);
|
||
assert!(
|
||
out.stdout.is_empty(),
|
||
"the refusal must not leak an aggregate to stdout, got: {:?}",
|
||
String::from_utf8_lossy(&out.stdout)
|
||
);
|
||
assert!(!cwd.join("runs").exists(), "a refused axis name must not start a real run");
|
||
}
|
||
|
||
/// Front-end parity: an unknown `--select` token is refused (exit 2) on the
|
||
/// dissolved path exactly as it is on the inline path — it must not be silently
|
||
/// swallowed by `select_rule_of`'s conversion and fall through to argmax.
|
||
#[test]
|
||
fn walkforward_dissolved_refuses_an_unknown_select_token() {
|
||
let cwd = temp_cwd("walkforward-bad-select");
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"walkforward", &fixture, "--real", "GER40",
|
||
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
|
||
"--stop-length", "14", "--stop-k", "2.0",
|
||
"--select", "bogus",
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura");
|
||
assert_eq!(out.status.code(), Some(2), "unknown --select token is a usage refusal");
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(stderr.contains("Usage: aura walkforward"), "unknown-select refusal: {stderr}");
|
||
}
|
||
|
||
/// `--name`/`--trace` are mutually exclusive on the walkforward real-data path
|
||
/// (#224 delivers `--trace` there — the up-front #168 forward-pointer refusal
|
||
/// is gone; `walkforward_args_from`'s own mutual-exclusion check is what fires
|
||
/// when both are given at once), exit 2, naming the mutual-exclusion contract
|
||
/// rather than a now-stale #224 forward pointer.
|
||
#[test]
|
||
fn walkforward_dissolved_refuses_name_and_trace_together() {
|
||
let cwd = temp_cwd("walkforward-name-and-trace");
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"walkforward", &fixture, "--real", "GER40",
|
||
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
|
||
"--stop-length", "14", "--stop-k", "2.0",
|
||
"--name", "a", "--trace", "b",
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura");
|
||
assert_eq!(out.status.code(), Some(2), "--name/--trace together is refused (exit 2)");
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(
|
||
stderr.contains("mutually exclusive"),
|
||
"refusal names the mutual-exclusion contract: {stderr}"
|
||
);
|
||
}
|
||
|
||
/// Property (#210 T3, dispatch split): a successful real-data walkforward
|
||
/// (`aura walkforward <blueprint> --real --axis …`; spelled `--strategy r-sma
|
||
/// --real` before the #220 argv migration) durably auto-registers exactly one
|
||
/// generated process document, one
|
||
/// generated campaign document (carrying the `--name` handle and the stop as a
|
||
/// non-empty single risk regime), and one campaign-run record — mirroring the
|
||
/// generalize dissolution's audit trail. The persisted family set is exactly one
|
||
/// `WalkForward` family (the per-window OOS reports) and NO `Sweep` family —
|
||
/// the leading `std::grid` stage only enumerates (#256) — the observable proof that
|
||
/// `dispatch_walkforward`'s real-data path runs through the one campaign
|
||
/// executor rather than the deleted inline roller. Gated on the shared GER40
|
||
/// archive; skips cleanly on a data refusal.
|
||
#[test]
|
||
fn walkforward_dissolves_through_the_campaign_path() {
|
||
const FROM_MS: &str = "1735689600000";
|
||
const TO_MS: &str = "1767225599000";
|
||
let (cwd, _g) = fresh_project();
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"walkforward", &fixture, "--real", "GER40",
|
||
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
|
||
"--stop-length", "14", "--stop-k", "2.0",
|
||
"--from", FROM_MS, "--to", TO_MS,
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura");
|
||
if out.status.code() == Some(1) {
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(
|
||
stderr.contains("no local data") || stderr.contains("no recorded geometry"),
|
||
"exit 1 must be a data refusal, got: {stderr}"
|
||
);
|
||
eprintln!("skip: no local GER40 data");
|
||
return;
|
||
}
|
||
assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status);
|
||
|
||
let count = |sub: &str| {
|
||
std::fs::read_dir(cwd.join("runs").join(sub))
|
||
.map(|d| d.count())
|
||
.unwrap_or(0)
|
||
};
|
||
assert_eq!(count("processes"), 1, "one generated process document registered");
|
||
assert_eq!(count("campaigns"), 1, "one generated campaign document registered");
|
||
let runs_log = std::fs::read_to_string(cwd.join("runs").join("campaign_runs.jsonl"))
|
||
.expect("campaign_runs.jsonl exists after a sugar run");
|
||
assert_eq!(runs_log.lines().count(), 1, "one campaign run recorded");
|
||
|
||
let campaigns_dir = cwd.join("runs").join("campaigns");
|
||
let campaign_doc_path = std::fs::read_dir(&campaigns_dir)
|
||
.expect("campaigns dir exists")
|
||
.next()
|
||
.expect("exactly one campaign document")
|
||
.expect("readable dir entry")
|
||
.path();
|
||
let campaign_doc_json = std::fs::read_to_string(&campaign_doc_path).expect("read campaign doc");
|
||
assert!(
|
||
campaign_doc_json.contains("\"name\":\"walkforward\""),
|
||
"the generated campaign document carries the --name handle: {campaign_doc_json}"
|
||
);
|
||
assert!(
|
||
campaign_doc_json.contains("\"risk\":[")
|
||
&& campaign_doc_json.contains("\"length\":14")
|
||
&& campaign_doc_json.contains("\"k\":2.0"),
|
||
"the stop rides a non-empty risk regime: {campaign_doc_json}"
|
||
);
|
||
|
||
let fams = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["runs", "families"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn families");
|
||
let fams_out = String::from_utf8_lossy(&fams.stdout).into_owned();
|
||
assert_eq!(
|
||
fams_out.lines().filter(|l| l.contains("\"kind\":\"Sweep\"")).count(),
|
||
0,
|
||
"the enumerate-only std::grid leading stage persists no Sweep family: {fams_out}"
|
||
);
|
||
assert_eq!(
|
||
fams_out.lines().filter(|l| l.contains("\"kind\":\"WalkForward\"")).count(),
|
||
1,
|
||
"one WalkForward family holding the per-window OOS reports: {fams_out}"
|
||
);
|
||
}
|
||
|
||
/// Property (#217): on the dissolved `aura walkforward --real` path,
|
||
/// `--stop-length`/`--stop-k` are OPTIONAL — a stop-less invocation succeeds,
|
||
/// defaulting each omitted knob to the single-sourced default regime (length 3,
|
||
/// k 2.0), the very regime the campaign member runner already resolves for an
|
||
/// unbound regime (`stop_rule_for_regime`). The default is byte-identical to
|
||
/// spelling the knobs out: running the same blueprint in the same store first
|
||
/// stop-less, then with an explicit `--stop-length 3 --stop-k 2.0`, leaves
|
||
/// EXACTLY ONE generated campaign document — the two documents are byte-identical
|
||
/// so content-addressed storage collapses them onto one content id — while each
|
||
/// invocation still records its own campaign-run line. This aligns walkforward's
|
||
/// knob contract with sweep's (which already defaults the stop) without any
|
||
/// content-id churn on the explicit spelling. The sibling verbs mc/generalize gain
|
||
/// the same independent per-flag defaulting on the GREEN side. Gated on the shared
|
||
/// GER40 archive; skips cleanly on a data refusal. (The multi-value stop is still
|
||
/// refused — `walkforward_dissolved_refuses_a_multi_value_stop` — the stop stays a
|
||
/// single risk regime; only its *presence* becomes optional.)
|
||
#[test]
|
||
fn walkforward_stopless_defaults_the_regime_byte_identically_to_explicit_3_2() {
|
||
const FROM_MS: &str = "1735689600000";
|
||
const TO_MS: &str = "1767225599000";
|
||
let (cwd, _g) = fresh_project();
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
|
||
// Stop-less: the two knobs are omitted entirely. This is the headline — today it
|
||
// is refused at the argv boundary (exit 2, "requires --stop-length --stop-k");
|
||
// the feature makes the knobs optional so this run defaults the regime and runs.
|
||
let stopless = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"walkforward", &fixture, "--real", "GER40",
|
||
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
|
||
"--from", FROM_MS, "--to", TO_MS,
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura");
|
||
if stopless.status.code() == Some(1) {
|
||
let stderr = String::from_utf8_lossy(&stopless.stderr);
|
||
assert!(
|
||
stderr.contains("no local data") || stderr.contains("no recorded geometry"),
|
||
"exit 1 must be a data refusal, got: {stderr}"
|
||
);
|
||
eprintln!("skip: no local GER40 data");
|
||
return;
|
||
}
|
||
assert_eq!(
|
||
stopless.status.code(),
|
||
Some(0),
|
||
"a stop-less walkforward must succeed (the stop knobs are optional, each defaulting \
|
||
to the single-sourced 3/2.0 regime): {}",
|
||
String::from_utf8_lossy(&stopless.stderr)
|
||
);
|
||
|
||
// Explicit 3/2.0: names the very defaults the stop-less run resolves. Same
|
||
// blueprint, same axes, same default `--name` ("walkforward"), same window.
|
||
let explicit = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"walkforward", &fixture, "--real", "GER40",
|
||
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
|
||
"--stop-length", "3", "--stop-k", "2.0",
|
||
"--from", FROM_MS, "--to", TO_MS,
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura");
|
||
assert_eq!(
|
||
explicit.status.code(),
|
||
Some(0),
|
||
"the explicit --stop-length 3 --stop-k 2.0 walkforward must succeed: {}",
|
||
String::from_utf8_lossy(&explicit.stderr)
|
||
);
|
||
|
||
// Byte-identity: the default regime IS 3/2.0, so both invocations generate the
|
||
// same campaign document and content-addressed storage collapses them onto one
|
||
// content id — exactly one campaign document in the store.
|
||
let count = |sub: &str| {
|
||
std::fs::read_dir(cwd.join("runs").join(sub))
|
||
.map(|d| d.count())
|
||
.unwrap_or(0)
|
||
};
|
||
assert_eq!(
|
||
count("campaigns"),
|
||
1,
|
||
"the stop-less default binds the SAME 3/2.0 regime as the explicit run, so the two \
|
||
campaign documents are byte-identical and dedup to one content id"
|
||
);
|
||
let runs_log = std::fs::read_to_string(cwd.join("runs").join("campaign_runs.jsonl"))
|
||
.expect("campaign_runs.jsonl exists after two sugar runs");
|
||
assert_eq!(
|
||
runs_log.lines().count(),
|
||
2,
|
||
"each invocation still records its own campaign run (a run is an event, a document \
|
||
is content)"
|
||
);
|
||
}
|
||
|
||
/// #227: bare `--select plateau` is the documented default aggregation
|
||
/// (`plateau:mean`), not a distinct spelling. Same dedup-pin idiom as
|
||
/// `walkforward_stopless_defaults_the_regime_byte_identically_to_explicit_3_2`:
|
||
/// running the same grid-sweep blueprint in the same store first with
|
||
/// `--select plateau`, then with `--select plateau:mean`, must produce
|
||
/// byte-identical campaign documents — content-addressed storage collapses
|
||
/// them onto EXACTLY ONE stored campaign — while each invocation still
|
||
/// records its own campaign-run line. Gated on the shared GER40 archive;
|
||
/// skips cleanly on a data refusal.
|
||
#[test]
|
||
fn walkforward_bare_plateau_generates_the_same_campaign_as_plateau_mean() {
|
||
const FROM_MS: &str = "1735689600000";
|
||
const TO_MS: &str = "1747353600000";
|
||
let (cwd, _g) = fresh_project();
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
|
||
// Bare `plateau`: today this is an unknown-token usage refusal (exit 2);
|
||
// the feature makes it an alias for `plateau:mean`.
|
||
let bare = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"walkforward", &fixture, "--real", "GER40",
|
||
"--axis", "sma_signal.fast.length=3,5", "--axis", "sma_signal.slow.length=12,20",
|
||
"--stop-length", "14", "--stop-k", "2.0",
|
||
"--select", "plateau",
|
||
"--from", FROM_MS, "--to", TO_MS,
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura");
|
||
if bare.status.code() == Some(1) {
|
||
let stderr = String::from_utf8_lossy(&bare.stderr);
|
||
assert!(
|
||
stderr.contains("no local data") || stderr.contains("no recorded geometry"),
|
||
"exit 1 must be a data refusal, got: {stderr}"
|
||
);
|
||
eprintln!("skip: no local GER40 data");
|
||
return;
|
||
}
|
||
assert_eq!(
|
||
bare.status.code(),
|
||
Some(0),
|
||
"bare --select plateau must no longer be refused (it aliases plateau:mean): {}",
|
||
String::from_utf8_lossy(&bare.stderr)
|
||
);
|
||
|
||
// Explicit `plateau:mean`: names the very aggregation the bare spelling
|
||
// resolves to. Same blueprint, same axes, same window, same stop.
|
||
let explicit = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"walkforward", &fixture, "--real", "GER40",
|
||
"--axis", "sma_signal.fast.length=3,5", "--axis", "sma_signal.slow.length=12,20",
|
||
"--stop-length", "14", "--stop-k", "2.0",
|
||
"--select", "plateau:mean",
|
||
"--from", FROM_MS, "--to", TO_MS,
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura");
|
||
assert_eq!(
|
||
explicit.status.code(),
|
||
Some(0),
|
||
"the explicit --select plateau:mean walkforward must succeed: {}",
|
||
String::from_utf8_lossy(&explicit.stderr)
|
||
);
|
||
|
||
// Byte-identity: bare `plateau` IS `plateau:mean`, so both invocations
|
||
// generate the same campaign document and content-addressed storage
|
||
// collapses them onto one content id — exactly one campaign document in
|
||
// the store, but two independent campaign-run lines.
|
||
let count = |sub: &str| {
|
||
std::fs::read_dir(cwd.join("runs").join(sub))
|
||
.map(|d| d.count())
|
||
.unwrap_or(0)
|
||
};
|
||
assert_eq!(
|
||
count("campaigns"),
|
||
1,
|
||
"bare plateau binds the SAME aggregation as plateau:mean, so the two campaign \
|
||
documents are byte-identical and dedup to one content id"
|
||
);
|
||
let runs_log = std::fs::read_to_string(cwd.join("runs").join("campaign_runs.jsonl"))
|
||
.expect("campaign_runs.jsonl exists after two sugar runs");
|
||
assert_eq!(
|
||
runs_log.lines().count(),
|
||
2,
|
||
"each invocation still records its own campaign run (a run is an event, a document \
|
||
is content)"
|
||
);
|
||
}
|
||
|
||
/// Property: `aura generalize` is now thin sugar over the one campaign path — a
|
||
/// successful run durably auto-registers exactly one generated process document,
|
||
/// one generated campaign document (carrying the `--name` handle and the stop as
|
||
/// a non-empty single risk regime), and one campaign-run record. This is the
|
||
/// observable proof the inline path is gone and the dissolution runs through the
|
||
/// executor. Gated on the shared GER40/USDJPY Sept-2024 archive; skips cleanly on
|
||
/// a data refusal.
|
||
#[test]
|
||
fn generalize_dissolves_through_the_campaign_path() {
|
||
const FROM_MS: &str = "1725148800000";
|
||
const TO_MS: &str = "1727740799999";
|
||
let (cwd, _g) = fresh_project();
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"generalize", &fixture, "--real", "GER40,USDJPY",
|
||
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
|
||
"--stop-length", "14", "--stop-k", "2.0",
|
||
"--from", FROM_MS, "--to", TO_MS,
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura");
|
||
if out.status.code() == Some(1) {
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(
|
||
stderr.contains("no local data") || stderr.contains("no recorded geometry"),
|
||
"exit 1 must be a data refusal, got: {stderr}"
|
||
);
|
||
eprintln!("skip: no local GER40/USDJPY data");
|
||
return;
|
||
}
|
||
assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status);
|
||
|
||
let count = |sub: &str| {
|
||
std::fs::read_dir(cwd.join("runs").join(sub))
|
||
.map(|d| d.count())
|
||
.unwrap_or(0)
|
||
};
|
||
assert_eq!(count("processes"), 1, "one generated process document registered");
|
||
assert_eq!(count("campaigns"), 1, "one generated campaign document registered");
|
||
let runs_log = std::fs::read_to_string(cwd.join("runs").join("campaign_runs.jsonl"))
|
||
.expect("campaign_runs.jsonl exists after a sugar run");
|
||
assert_eq!(runs_log.lines().count(), 1, "one campaign run recorded");
|
||
|
||
let campaigns_dir = cwd.join("runs").join("campaigns");
|
||
let campaign_doc_path = std::fs::read_dir(&campaigns_dir)
|
||
.expect("campaigns dir exists")
|
||
.next()
|
||
.expect("exactly one campaign document")
|
||
.expect("readable dir entry")
|
||
.path();
|
||
let campaign_doc_json = std::fs::read_to_string(&campaign_doc_path).expect("read campaign doc");
|
||
assert!(
|
||
campaign_doc_json.contains("\"name\":\"generalize\""),
|
||
"the generated campaign document carries the --name handle: {campaign_doc_json}"
|
||
);
|
||
// The structural delta vs the sweep translator: the stop is a non-empty
|
||
// single risk regime, not empty risk.
|
||
assert!(
|
||
campaign_doc_json.contains("\"risk\":[")
|
||
&& campaign_doc_json.contains("\"length\":14")
|
||
&& campaign_doc_json.contains("\"k\":2.0"),
|
||
"the stop rides a non-empty risk regime: {campaign_doc_json}"
|
||
);
|
||
|
||
// The campaign path's family set (#210 Q4): exactly one CrossInstrument grade
|
||
// family (the generalize result, appended by the sugar) plus one
|
||
// per-instrument Sweep family per cell (persisted by the executor).
|
||
let fams = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["runs", "families"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn families");
|
||
let fams_out = String::from_utf8_lossy(&fams.stdout).into_owned();
|
||
assert_eq!(
|
||
fams_out.lines().filter(|l| l.contains("\"kind\":\"CrossInstrument\"")).count(),
|
||
1,
|
||
"one CrossInstrument grade family: {fams_out}"
|
||
);
|
||
assert_eq!(
|
||
fams_out.lines().filter(|l| l.contains("\"kind\":\"Sweep\"")).count(),
|
||
2,
|
||
"one per-instrument Sweep family per cell: {fams_out}"
|
||
);
|
||
}
|
||
|
||
/// Property (#210, dissolution — content-addressed idempotency): re-running the
|
||
/// IDENTICAL `aura generalize` invocation in the same project does not litter the
|
||
/// store with a second generated process/campaign document — `register_generated_g`
|
||
/// writes under a content-id filename, so two runs of the same candidate collapse
|
||
/// onto the same one process doc and one campaign doc, while each invocation still
|
||
/// records its OWN campaign-run line (a run is an event, a document is content).
|
||
/// Were the sugar to salt its generated documents with anything non-deterministic
|
||
/// (e.g. a timestamp), this would regress from 1 to 2 stored documents per repeat
|
||
/// invocation — the store-litter regression the campaign-path dissolution must not
|
||
/// introduce. Gated on the shared GER40/USDJPY Sept-2024 archive; skips cleanly on
|
||
/// a data refusal.
|
||
#[test]
|
||
fn generalize_repeated_identical_invocation_does_not_litter_the_store() {
|
||
const FROM_MS: &str = "1725148800000";
|
||
const TO_MS: &str = "1727740799999";
|
||
let (cwd, _g) = fresh_project();
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let invoke = || {
|
||
std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"generalize", &fixture, "--real", "GER40,USDJPY",
|
||
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
|
||
"--stop-length", "14", "--stop-k", "2.0",
|
||
"--from", FROM_MS, "--to", TO_MS,
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura")
|
||
};
|
||
let first = invoke();
|
||
if first.status.code() == Some(1) {
|
||
let stderr = String::from_utf8_lossy(&first.stderr);
|
||
assert!(
|
||
stderr.contains("no local data") || stderr.contains("no recorded geometry"),
|
||
"exit 1 must be a data refusal, got: {stderr}"
|
||
);
|
||
eprintln!("skip: no local GER40/USDJPY data");
|
||
return;
|
||
}
|
||
assert_eq!(first.status.code(), Some(0), "first run exit: {:?}", first.status);
|
||
let second = invoke();
|
||
assert_eq!(second.status.code(), Some(0), "second run exit: {:?}", second.status);
|
||
|
||
let count = |sub: &str| {
|
||
std::fs::read_dir(cwd.join("runs").join(sub))
|
||
.map(|d| d.count())
|
||
.unwrap_or(0)
|
||
};
|
||
assert_eq!(count("processes"), 1, "two identical invocations share one process document");
|
||
assert_eq!(count("campaigns"), 1, "two identical invocations share one campaign document");
|
||
let runs_log = std::fs::read_to_string(cwd.join("runs").join("campaign_runs.jsonl"))
|
||
.expect("campaign_runs.jsonl exists");
|
||
assert_eq!(runs_log.lines().count(), 2, "each invocation still records its own campaign run");
|
||
}
|
||
|
||
/// Property (#210, dissolution — content-addressed distinctness): the idempotency
|
||
/// above is not a bug masquerading as a feature — two DIFFERENT `--fast`
|
||
/// invocations (differing candidate content) generate and persist TWO distinct
|
||
/// campaign documents (and two distinct process documents, since the `--metric`
|
||
/// text is unchanged so the process content matches — pinned via the campaign
|
||
/// count only, the axis differs on the campaign, not the process). Together with
|
||
/// `generalize_repeated_identical_invocation_does_not_litter_the_store`, this rules
|
||
/// out a store that always overwrites one fixed filename regardless of content —
|
||
/// the failure mode that would make the idempotency test above pass for the wrong
|
||
/// reason. Gated on the shared GER40/USDJPY Sept-2024 archive; skips cleanly on a
|
||
/// data refusal.
|
||
#[test]
|
||
fn generalize_distinct_invocations_persist_distinct_campaign_documents() {
|
||
const FROM_MS: &str = "1725148800000";
|
||
const TO_MS: &str = "1727740799999";
|
||
let (cwd, _g) = fresh_project();
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let invoke = |fast: &str| {
|
||
let fast_axis = format!("sma_signal.fast.length={fast}");
|
||
std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"generalize", &fixture, "--real", "GER40,USDJPY",
|
||
"--axis", &fast_axis, "--axis", "sma_signal.slow.length=12",
|
||
"--stop-length", "14", "--stop-k", "2.0",
|
||
"--from", FROM_MS, "--to", TO_MS,
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura")
|
||
};
|
||
let first = invoke("3");
|
||
if first.status.code() == Some(1) {
|
||
let stderr = String::from_utf8_lossy(&first.stderr);
|
||
assert!(
|
||
stderr.contains("no local data") || stderr.contains("no recorded geometry"),
|
||
"exit 1 must be a data refusal, got: {stderr}"
|
||
);
|
||
eprintln!("skip: no local GER40/USDJPY data");
|
||
return;
|
||
}
|
||
assert_eq!(first.status.code(), Some(0), "first run exit: {:?}", first.status);
|
||
let second = invoke("4");
|
||
assert_eq!(second.status.code(), Some(0), "second run exit: {:?}", second.status);
|
||
|
||
let count = |sub: &str| {
|
||
std::fs::read_dir(cwd.join("runs").join(sub))
|
||
.map(|d| d.count())
|
||
.unwrap_or(0)
|
||
};
|
||
assert_eq!(count("campaigns"), 2, "two distinct candidates persist two distinct campaign documents");
|
||
}
|
||
|
||
/// Property (#210 T3, dissolution; window semantics superseded by #213):
|
||
/// omitting `--from`/`--to` still completes — the dispatch rewrite's
|
||
/// window-resolution fallback (`dispatch_generalize` in main.rs) resolves ONE
|
||
/// shared campaign window from the INTERSECTION of the listed symbols' full
|
||
/// archive geometry and applies it to every instrument in the campaign
|
||
/// document (a `CampaignDoc` carries a single shared window, unlike the old
|
||
/// inline `run_generalize`, which let each instrument resolve its OWN
|
||
/// independent full window). A wrong intersection (or a panic on the
|
||
/// per-symbol probe) in that fallback would only surface when `--from`/`--to`
|
||
/// are absent — every other generalize e2e pins the explicit-window path,
|
||
/// leaving this one unexercised. The property is the no-window RESOLUTION
|
||
/// mechanics, not any particular archive's span, so it runs against
|
||
/// `fresh_project_with_data`'s tiny synthetic two-symbol archive (#250) —
|
||
/// hostless, no data-refusal skip arm needed.
|
||
#[test]
|
||
fn generalize_without_explicit_window_resolves_a_shared_window_and_completes() {
|
||
let (cwd, _g) = fresh_project_with_data();
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"generalize", &fixture, "--real", "SYMA,SYMB",
|
||
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
|
||
"--stop-length", "14", "--stop-k", "2.0",
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura");
|
||
assert_eq!(out.status.code(), Some(0), "exit: {:?}, stderr: {}", out.status, String::from_utf8_lossy(&out.stderr));
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
assert!(stdout.contains("\"generalize\":"), "aggregate object: {stdout}");
|
||
assert!(stdout.contains("\"n_instruments\":2"), "two instruments graded: {stdout}");
|
||
assert!(stdout.contains("\"family_id\":\"generalize-0\""), "family handle: {stdout}");
|
||
}
|
||
|
||
/// Property (#213, no-window semantics): invoked WITHOUT `--from`/`--to`, a
|
||
/// multi-symbol `generalize` resolves the ONE shared campaign window as the
|
||
/// INTERSECTION of the listed symbols' full archive windows — the latest start
|
||
/// and earliest end, i.e. the only span over which EVERY listed instrument
|
||
/// actually has data. `generalize` is the cross-instrument comparison (the
|
||
/// worst-case R floor, #146); a floor pooled over a different period per
|
||
/// instrument conflates the instrument axis with the period axis, so the common
|
||
/// window is the only one that isolates what the verb measures. Today's fallback
|
||
/// picks `symbols[0]`'s full window alone (the superseded shape test above):
|
||
/// listing the WIDER synthetic symbol first (`SYMA`, spanning 2024-01..08) keeps
|
||
/// SYMA's early start, whereas the intersection with `SYMB` (spanning 2024-03..06,
|
||
/// strictly inside SYMA's span) must start at SYMB's first bar and end at SYMB's
|
||
/// last — so on the current tree the resolved window differs from the
|
||
/// intersection on both bounds. Each symbol's full window is resolved
|
||
/// independently here: a single-symbol `sweep` with no window records it into its
|
||
/// generated campaign document, in the same Unix-ms currency and via the same
|
||
/// `campaign_window_ms(full_window)` probe generalize itself resolves against —
|
||
/// so the expected intersection is derived from the live archive, never a
|
||
/// hard-coded span. The archive's SPAN SHAPE is authored (#250,
|
||
/// `fresh_project_with_data`), not its size, so this runs hostless in
|
||
/// milliseconds instead of streaming two full host archives.
|
||
#[test]
|
||
fn generalize_without_explicit_window_resolves_the_intersection_of_all_symbols() {
|
||
let (cwd, _g) = fresh_project_with_data();
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
|
||
// The resolved shared window persists verbatim in the generated campaign
|
||
// document; read the (from_ms, to_ms) of the one whose `name` field matches.
|
||
let window_of = |name: &str| -> (i64, i64) {
|
||
let dir = cwd.join("runs").join("campaigns");
|
||
for entry in std::fs::read_dir(&dir).expect("campaigns dir exists after a run") {
|
||
let path = entry.expect("readable dir entry").path();
|
||
let doc: serde_json::Value =
|
||
serde_json::from_str(&std::fs::read_to_string(&path).expect("read campaign doc"))
|
||
.expect("campaign doc parses as JSON");
|
||
if doc["name"].as_str() == Some(name) {
|
||
let w = &doc["data"]["windows"][0];
|
||
return (
|
||
w["from_ms"].as_i64().expect("from_ms is an integer"),
|
||
w["to_ms"].as_i64().expect("to_ms is an integer"),
|
||
);
|
||
}
|
||
}
|
||
panic!("no campaign document named {name:?} in {}", dir.display());
|
||
};
|
||
|
||
// Resolve one symbol's full archive window by running a single-symbol,
|
||
// single-cell sweep with NO --from/--to; the window it records is exactly the
|
||
// `campaign_window_ms(full_window)` value generalize resolves per symbol.
|
||
let probe = |symbol: &str, name: &str| -> (i64, i64) {
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"sweep", &fixture, "--real", symbol,
|
||
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
|
||
"--name", name,
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura sweep");
|
||
assert_eq!(
|
||
out.status.code(), Some(0),
|
||
"single-symbol window-probe sweep for {symbol} must run to exit 0: {}",
|
||
String::from_utf8_lossy(&out.stderr)
|
||
);
|
||
window_of(name)
|
||
};
|
||
|
||
let syma = probe("SYMA", "probe-syma");
|
||
let symb = probe("SYMB", "probe-symb");
|
||
// Precondition: the archives genuinely differ, so "the intersection" is a
|
||
// distinct claim from "symbols[0]'s full window" (else the test is vacuous).
|
||
assert_ne!(
|
||
syma, symb,
|
||
"SYMA {syma:?} and SYMB {symb:?} must span different windows for the test to bite"
|
||
);
|
||
|
||
// The contract: the shared window is the intersection — latest start, earliest end.
|
||
let expected = (syma.0.max(symb.0), syma.1.min(symb.1));
|
||
|
||
// SYMA listed FIRST: today's `symbols[0]` fallback keeps SYMA's (wider)
|
||
// window, which differs from the intersection on both bounds — so a pass here
|
||
// can only mean the intersection semantics landed.
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"generalize", &fixture, "--real", "SYMA,SYMB",
|
||
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
|
||
"--name", "gen-both",
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura generalize");
|
||
assert_eq!(
|
||
out.status.code(), Some(0),
|
||
"two-symbol no-window generalize must run to exit 0: {}",
|
||
String::from_utf8_lossy(&out.stderr)
|
||
);
|
||
let resolved = window_of("gen-both");
|
||
|
||
assert_eq!(
|
||
resolved, expected,
|
||
"no-window generalize must resolve the INTERSECTION {expected:?} of SYMA {syma:?} \
|
||
and SYMB {symb:?} — not symbols[0]'s ({syma:?}) full window (resolved {resolved:?})"
|
||
);
|
||
}
|
||
|
||
/// Property: a single-instrument generalize is refused at parse time (exit 2),
|
||
/// before any data access — so it asserts the arity refusal on any machine.
|
||
#[test]
|
||
fn generalize_refuses_a_single_instrument() {
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"generalize", &fixture, "--real", "GER40",
|
||
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
|
||
"--stop-length", "14", "--stop-k", "2.0",
|
||
])
|
||
.output()
|
||
.expect("spawn aura");
|
||
assert_eq!(out.status.code(), Some(2), "single instrument must exit 2");
|
||
}
|
||
|
||
/// Property: a non-R metric is refused before any instrument runs (the data-free
|
||
/// `check_r_metric` pre-check), so this asserts the R-only refusal on any machine.
|
||
#[test]
|
||
fn generalize_refuses_a_non_r_metric() {
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"generalize", &fixture, "--real", "GER40,USDJPY",
|
||
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
|
||
"--stop-length", "14", "--stop-k", "2.0",
|
||
"--metric", "total_pips",
|
||
])
|
||
.output()
|
||
.expect("spawn aura");
|
||
assert_eq!(out.status.code(), Some(2), "a non-R metric must exit 2");
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(stderr.contains("R-only"), "must name the R-only refusal, got: {stderr}");
|
||
}
|
||
|
||
/// Property: a duplicate instrument in the `--real` list is refused at the binary
|
||
/// boundary (exit 2, before any data access) — `GER40,GER40` never runs. The spec's
|
||
/// double-count hazard: a repeated symbol would be evaluated twice and so counted
|
||
/// twice in both the worst-case floor and the sign-agreement count, silently
|
||
/// corrupting the generalization grade. The parser unit test pins the grammar; this
|
||
/// pins that the parse error actually reaches `exit(2)` through the dispatch arm
|
||
/// (a swallowed Err would let a duplicated run proceed). Data-free — the duplicate
|
||
/// check precedes any geometry/archive lookup, so it asserts on any machine.
|
||
#[test]
|
||
fn generalize_refuses_a_duplicate_instrument() {
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"generalize", &fixture, "--real", "GER40,GER40",
|
||
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
|
||
"--stop-length", "14", "--stop-k", "2.0",
|
||
])
|
||
.output()
|
||
.expect("spawn aura");
|
||
assert_eq!(out.status.code(), Some(2), "a duplicate instrument must exit 2");
|
||
assert!(
|
||
out.stdout.is_empty(),
|
||
"the refusal must not leak an aggregate to stdout, got: {:?}",
|
||
String::from_utf8_lossy(&out.stdout)
|
||
);
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(stderr.contains("generalize"), "must surface the generalize usage, got: {stderr}");
|
||
}
|
||
|
||
/// Property: a multi-value axis is refused at the binary boundary (exit 2, no
|
||
/// run, data-free). A *candidate* is a single grid cell, not a sweep — so any
|
||
/// `--axis` carrying more than one value is refused, never silently widened
|
||
/// into a multi-cell run whose generalization grade would be ill-defined.
|
||
#[test]
|
||
fn generalize_refuses_a_multi_value_axis() {
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"generalize", &fixture, "--real", "GER40,USDJPY",
|
||
"--axis", "sma_signal.fast.length=2,3", "--axis", "sma_signal.slow.length=12",
|
||
"--stop-length", "14", "--stop-k", "2.0",
|
||
])
|
||
.output()
|
||
.expect("spawn aura");
|
||
assert_eq!(out.status.code(), Some(2), "a multi-value axis must exit 2");
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(stderr.contains("each --axis takes exactly one value"), "arity refusal: {stderr}");
|
||
assert!(
|
||
out.stdout.is_empty(),
|
||
"the refusal must not leak an aggregate to stdout, got: {:?}",
|
||
String::from_utf8_lossy(&out.stdout)
|
||
);
|
||
}
|
||
|
||
/// Property: a `--axis` with no `--axis` flags at all is a usage error (exit 2,
|
||
/// no run) — generalize needs >= 1 axis to name a candidate, mirroring the sibling
|
||
/// sweep/walkforward "no axis" refusals. Data-free (fires before any archive access).
|
||
#[test]
|
||
fn generalize_refuses_no_axis() {
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"generalize", &fixture, "--real", "GER40,USDJPY",
|
||
"--stop-length", "14", "--stop-k", "2.0",
|
||
])
|
||
.output()
|
||
.expect("spawn aura");
|
||
assert_eq!(out.status.code(), Some(2), "zero axes must exit 2");
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(stderr.contains("Usage: aura generalize"), "must print the generalize usage: {stderr}");
|
||
assert!(
|
||
out.stdout.is_empty(),
|
||
"the refusal must not leak an aggregate to stdout, got: {:?}",
|
||
String::from_utf8_lossy(&out.stdout)
|
||
);
|
||
}
|
||
|
||
/// Property: an `--axis` name that is not among the loaded blueprint's sweepable
|
||
/// axes is refused before the raw-namespace strip or any archive access (exit 2),
|
||
/// echoing exactly the name the user typed — the same WRAPPED-probe preflight
|
||
/// `aura sweep` enforces (`aura_sweep_real_blueprint_refuses_a_raw_form_axis_name_before_data_access`).
|
||
#[test]
|
||
fn generalize_refuses_an_unknown_axis_name() {
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"generalize", &fixture, "--real", "GER40,USDJPY",
|
||
"--axis", "sma_signal.nope=3", "--axis", "sma_signal.slow.length=12",
|
||
"--stop-length", "14", "--stop-k", "2.0",
|
||
])
|
||
.output()
|
||
.expect("spawn aura");
|
||
assert_eq!(out.status.code(), Some(2), "an unknown axis name must exit 2");
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(
|
||
stderr.contains("axis \"sma_signal.nope\"") && stderr.contains("sweepable axes"),
|
||
"must name the unresolved axis exactly as typed: {stderr}"
|
||
);
|
||
assert!(
|
||
out.stdout.is_empty(),
|
||
"the refusal must not leak an aggregate to stdout, got: {:?}",
|
||
String::from_utf8_lossy(&out.stdout)
|
||
);
|
||
}
|
||
|
||
/// Property: a successful `aura generalize` persists its per-instrument runs as a
|
||
/// discoverable `CrossInstrument` family — the C12 comparison axis / C18 lineage at
|
||
/// the built-binary boundary. The unit `cli_families_persist_and_round_trip_per_kind`
|
||
/// covers Sweep/MonteCarlo/WalkForward but not CrossInstrument; this is the
|
||
/// cross-instrument sibling of `mc_runs_persists_a_monte_carlo_family_and_lists_it`:
|
||
/// after the run, `aura runs families` lists `generalize-0` with
|
||
/// `"kind":"CrossInstrument"` and `"members":2` (one member per instrument). A
|
||
/// regression that mis-tagged the family kind, or persisted the wrong member count,
|
||
/// would slip past the stdout-only happy-path assertion above but fail here. Gated on
|
||
/// local GER40/USDJPY data (the shared Sept-2024 window); skips cleanly when absent.
|
||
#[test]
|
||
fn generalize_persists_a_discoverable_cross_instrument_family() {
|
||
const FROM_MS: &str = "1725148800000";
|
||
const TO_MS: &str = "1727740799999";
|
||
let (cwd, _g) = fresh_project();
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"generalize", &fixture, "--real", "GER40,USDJPY",
|
||
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
|
||
"--stop-length", "14", "--stop-k", "2.0",
|
||
"--from", FROM_MS, "--to", TO_MS,
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura");
|
||
if out.status.code() == Some(1) {
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(
|
||
stderr.contains("no local data") || stderr.contains("no recorded geometry"),
|
||
"exit 1 must be a data refusal, got: {stderr}"
|
||
);
|
||
eprintln!("skip: no local GER40/USDJPY data");
|
||
return;
|
||
}
|
||
assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status);
|
||
|
||
// the run persisted the per-instrument members as a discoverable CrossInstrument family.
|
||
let fams = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["runs", "families"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn families");
|
||
assert!(fams.status.success(), "families exit: {:?}", fams.status);
|
||
let fams_out = String::from_utf8(fams.stdout).expect("utf-8");
|
||
// The dissolved path routes through the campaign executor, which persists a
|
||
// per-instrument Sweep family per cell (each instrument's candidate run, now
|
||
// a durable audit artifact) alongside the one CrossInstrument grade family
|
||
// the sugar appends. Pin exactly one CrossInstrument family (the generalize
|
||
// result) — not the total family count, which now includes those per-cell
|
||
// Sweep families.
|
||
let cross: Vec<&str> = fams_out
|
||
.lines()
|
||
.filter(|l| l.contains("\"kind\":\"CrossInstrument\""))
|
||
.collect();
|
||
assert_eq!(cross.len(), 1, "exactly one CrossInstrument family: {fams_out:?}");
|
||
assert!(cross[0].contains("\"family_id\":\"generalize-0\""), "families: {fams_out:?}");
|
||
assert!(cross[0].contains("\"members\":2"), "one member per instrument: {fams_out:?}");
|
||
}
|
||
|
||
/// Property (#210 T3, dissolution): the persisted `CrossInstrument` family's
|
||
/// members are attributed to the RIGHT instrument in the RIGHT cell order —
|
||
/// not merely correct in count. The dissolved path builds the family via
|
||
/// `cross_instrument_members`, which walks `outcome.cells` and trusts their
|
||
/// order/labelling, replacing the old `run_generalize`'s explicit
|
||
/// `zip(symbols, members)`. A cell-ordering or index bug in the new
|
||
/// extraction would still print the right aggregate (the campaign's own
|
||
/// `Generalize` stage computes that independently) while silently persisting
|
||
/// swapped or mislabelled members — a regression this test, not the exact-grade
|
||
/// anchor, would catch. Asserts `aura runs family generalize-0` lists GER40
|
||
/// first with the exact-grade anchor's GER40 `expectancy_r`, then USDJPY with
|
||
/// its own. Gated on the local GER40/USDJPY data (the shared Sept-2024 window).
|
||
#[test]
|
||
fn generalize_family_members_are_attributed_to_the_right_instrument() {
|
||
const FROM_MS: &str = "1725148800000";
|
||
const TO_MS: &str = "1727740799999";
|
||
let (cwd, _g) = fresh_project();
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"generalize", &fixture, "--real", "GER40,USDJPY",
|
||
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
|
||
"--stop-length", "14", "--stop-k", "2.0",
|
||
"--from", FROM_MS, "--to", TO_MS,
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura");
|
||
if out.status.code() == Some(1) {
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(
|
||
stderr.contains("no local data") || stderr.contains("no recorded geometry"),
|
||
"exit 1 must be a data refusal, got: {stderr}"
|
||
);
|
||
eprintln!("skip: no local GER40/USDJPY data");
|
||
return;
|
||
}
|
||
assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status);
|
||
|
||
let fam = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["runs", "family", "generalize-0"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn runs family");
|
||
assert!(fam.status.success(), "runs family exit: {:?}", fam.status);
|
||
let fam_out = String::from_utf8(fam.stdout).expect("utf-8");
|
||
let members: Vec<serde_json::Value> = fam_out
|
||
.lines()
|
||
.filter(|l| l.starts_with('{'))
|
||
.map(|l| serde_json::from_str(l).expect("member line parses as JSON"))
|
||
.collect();
|
||
assert_eq!(members.len(), 2, "one member per instrument: {fam_out:?}");
|
||
|
||
assert_eq!(
|
||
members[0]["manifest"]["instrument"].as_str(), Some("GER40"),
|
||
"first member is GER40: {fam_out:?}"
|
||
);
|
||
assert_eq!(
|
||
members[0]["metrics"]["r"]["expectancy_r"].as_f64(), Some(0.01056371324510624),
|
||
"GER40's own expectancy_r, matching the exact-grade anchor: {fam_out:?}"
|
||
);
|
||
assert_eq!(
|
||
members[1]["manifest"]["instrument"].as_str(), Some("USDJPY"),
|
||
"second member is USDJPY: {fam_out:?}"
|
||
);
|
||
assert_eq!(
|
||
members[1]["metrics"]["r"]["expectancy_r"].as_f64(), Some(0.005795903617609842),
|
||
"USDJPY's own expectancy_r, matching the exact-grade anchor: {fam_out:?}"
|
||
);
|
||
}
|
||
|
||
/// Property (#218 migration follow-up): `generalize`'s per-instrument members
|
||
/// route through the shared campaign path (`campaign_run.rs`'s
|
||
/// `run_blueprint_member` call, a code path distinct from sweep's in
|
||
/// `main.rs`), and that path ALSO stamps `manifest.project` with the running
|
||
/// project's provenance (C18/C16) for every persisted member — not just the
|
||
/// sweep chokepoint. Before the project-fixture migration this cli_run.rs
|
||
/// path only ever ran outside a project, so `project` was always `None` here.
|
||
/// Gated on the shared GER40/USDJPY Sept-2024 archive.
|
||
#[test]
|
||
fn generalize_family_members_stamp_project_provenance() {
|
||
const FROM_MS: &str = "1725148800000";
|
||
const TO_MS: &str = "1727740799999";
|
||
let (cwd, _g) = fresh_project();
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"generalize", &fixture, "--real", "GER40,USDJPY",
|
||
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
|
||
"--stop-length", "14", "--stop-k", "2.0",
|
||
"--from", FROM_MS, "--to", TO_MS,
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura");
|
||
if out.status.code() == Some(1) {
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(
|
||
stderr.contains("no local data") || stderr.contains("no recorded geometry"),
|
||
"exit 1 must be a data refusal, got: {stderr}"
|
||
);
|
||
eprintln!("skip: no local GER40/USDJPY data");
|
||
return;
|
||
}
|
||
assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status);
|
||
|
||
let fam = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["runs", "family", "generalize-0"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn runs family");
|
||
assert!(fam.status.success(), "runs family exit: {:?}", fam.status);
|
||
let fam_out = String::from_utf8(fam.stdout).expect("utf-8");
|
||
let members: Vec<serde_json::Value> = fam_out
|
||
.lines()
|
||
.filter(|l| l.starts_with('{'))
|
||
.map(|l| serde_json::from_str(l).expect("member line parses as JSON"))
|
||
.collect();
|
||
assert_eq!(members.len(), 2, "one member per instrument: {fam_out:?}");
|
||
for m in &members {
|
||
let project = &m["manifest"]["project"];
|
||
assert_eq!(
|
||
project["namespace"].as_str(), Some("demo"),
|
||
"campaign-path member manifest carries the fixture project's namespace: {fam_out:?}"
|
||
);
|
||
let sha = project["dylib_sha256"].as_str()
|
||
.unwrap_or_else(|| panic!("dylib_sha256 present: {fam_out:?}"));
|
||
assert_eq!(sha.len(), 64, "dylib_sha256 is a full sha256 hex digest: {fam_out:?}");
|
||
}
|
||
}
|
||
|
||
/// `aura sweep <blueprint.json> --axis <name>=<csv> …` (#166): sweeps a loaded CLOSED
|
||
/// blueprint over its named bound-override axes and persists a discoverable Sweep family.
|
||
/// Both bound knobs (fast/slow) must be re-opened by axes; the 2×2 grid yields 4 members.
|
||
#[test]
|
||
fn aura_sweep_loads_a_blueprint_and_persists_a_sweep_family() {
|
||
let cwd = temp_cwd("blueprint-sweep");
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = Command::new(BIN)
|
||
.args([
|
||
"sweep", &fixture,
|
||
"--axis", "sma_signal.fast.length=2,4",
|
||
"--axis", "sma_signal.slow.length=8,16",
|
||
"--name", "f",
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura sweep blueprint");
|
||
assert!(out.status.success(), "exit: {:?} stderr={}", out.status, String::from_utf8_lossy(&out.stderr));
|
||
let fams = Command::new(BIN).args(["runs", "families"]).current_dir(&cwd).output().expect("spawn families");
|
||
let fo = String::from_utf8(fams.stdout).expect("utf-8");
|
||
assert!(fo.contains("\"kind\":\"Sweep\""), "families: {fo}");
|
||
assert!(fo.contains("\"members\":4"), "2×2 grid -> four members: {fo}");
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// Property (#168, refuse-don't-lie): `aura sweep`/`aura walkforward` must REFUSE
|
||
/// `--trace` with a forward-pointer to the deferred per-member-trace feature (#224),
|
||
/// never silently accept it. The clap help ("also persists each member's taps") and
|
||
/// the chart `NotFound` hint ("run `aura sweep --trace` first") advertise
|
||
/// trace-recording, but no blueprint-mode arm persists per-member taps —
|
||
/// `run_blueprint_sweep` reads `let _ = persist;` and the dissolved `--real` sugar
|
||
/// sets `persist_taps: vec![]` — so a bare accept makes the surface lie. This mirrors
|
||
/// the existing `aura run` / `aura mc` `--trace` refusals (exit 2, refuse-don't-guess).
|
||
/// Driven over the SYNTHETIC path (both open knobs bound, no `--real`), so the test is
|
||
/// autonomous: no recorded archive, no project. Today `sweep --trace` exits 0 (silent
|
||
/// accept) and `walkforward --trace` exits 2 with a GENERIC usage string (no #224) —
|
||
/// both are the lying/pointer-less surface this pins.
|
||
#[test]
|
||
fn sweep_and_walkforward_refuse_trace_with_a_forward_pointer() {
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
for verb in ["sweep", "walkforward"] {
|
||
let cwd = temp_cwd(&format!("{verb}-trace-refusal"));
|
||
let out = Command::new(BIN)
|
||
.args([
|
||
verb,
|
||
&fixture,
|
||
"--axis",
|
||
"sma_signal.fast.length=2,4",
|
||
"--axis",
|
||
"sma_signal.slow.length=8,16",
|
||
"--trace",
|
||
"fam",
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura <verb> --trace");
|
||
assert_eq!(
|
||
out.status.code(),
|
||
Some(2),
|
||
"`aura {verb} --trace` must be refused (exit 2); got {:?}, stderr: {}",
|
||
out.status,
|
||
String::from_utf8_lossy(&out.stderr),
|
||
);
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(
|
||
stderr.contains("--trace"),
|
||
"`aura {verb}` refusal names the offending flag: {stderr}"
|
||
);
|
||
assert!(
|
||
stderr.contains("#224"),
|
||
"`aura {verb}` refusal points forward to the deferred trace feature (#224): {stderr}"
|
||
);
|
||
assert!(
|
||
out.stdout.is_empty(),
|
||
"`aura {verb} --trace` must emit no report on stdout: {}",
|
||
String::from_utf8_lossy(&out.stdout),
|
||
);
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
}
|
||
|
||
/// Property (#158/#164, C18/C11/C12): a blueprint sweep content-addresses its topology
|
||
/// — the canonical blueprint is persisted ONCE under `runs/blueprints/<topology_hash>.json`,
|
||
/// keyed by the very SHA256 every member's manifest carries, holding exactly the bytes
|
||
/// `blueprint_to_json(blueprint_from_json(doc))` yields. That stored topology is what makes
|
||
/// a generated sweep family reproducible from disk (the `aura reproduce` re-derivation).
|
||
#[test]
|
||
fn aura_sweep_persists_the_canonical_blueprint_keyed_by_topology_hash() {
|
||
use aura_engine::{blueprint_from_json, blueprint_to_json};
|
||
use aura_vocabulary::std_vocabulary;
|
||
use sha2::{Digest, Sha256};
|
||
|
||
let cwd = temp_cwd("blueprint-sweep-store");
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = Command::new(BIN)
|
||
.args([
|
||
"sweep", &fixture,
|
||
"--axis", "sma_signal.fast.length=2,4",
|
||
"--axis", "sma_signal.slow.length=8,16",
|
||
"--name", "f",
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura sweep blueprint");
|
||
assert!(out.status.success(), "exit: {:?} stderr={}", out.status, String::from_utf8_lossy(&out.stderr));
|
||
|
||
// exactly one blueprint stored for the whole family (one topology per family).
|
||
let store = cwd.join("runs/blueprints");
|
||
let mut entries: Vec<_> = std::fs::read_dir(&store)
|
||
.unwrap_or_else(|e| panic!("the sweep must create the blueprint store {store:?}: {e}"))
|
||
.map(|e| e.expect("dir entry").path())
|
||
.collect();
|
||
entries.sort();
|
||
assert_eq!(entries.len(), 1, "one stored topology per family: {entries:?}");
|
||
|
||
// keyed by a 64-hex topology hash, holding the canonical re-serialization.
|
||
let stored_path = &entries[0];
|
||
let stem = stored_path.file_stem().expect("stem").to_str().expect("utf-8");
|
||
assert_eq!(stem.len(), 64, "content id is a 64-hex SHA256: {stem}");
|
||
assert!(stem.bytes().all(|b| b.is_ascii_hexdigit()), "content id is hex: {stem}");
|
||
|
||
let doc = std::fs::read_to_string(&fixture).expect("read fixture");
|
||
let expected = blueprint_to_json(&blueprint_from_json(&doc, &|t| std_vocabulary(t)).expect("loads"))
|
||
.expect("re-serializes");
|
||
let stored = std::fs::read_to_string(stored_path).expect("read stored blueprint");
|
||
assert_eq!(stored, expected, "the store holds the canonical blueprint bytes");
|
||
|
||
// content-addressing, the feature's load-bearing property: the key IS the SHA256 of
|
||
// the stored bytes — `get_blueprint(hash)` addresses exactly these bytes by identity,
|
||
// not by coincidence of the store and the members computing the hash separately.
|
||
let content_id: String =
|
||
Sha256::digest(stored.as_bytes()).iter().map(|b| format!("{b:02x}")).collect();
|
||
assert_eq!(stem, content_id, "the store key is the SHA256 of its content");
|
||
|
||
// and that key is exactly what every family member's manifest carries — the hash the
|
||
// reproduce path reads back to fetch the topology. All 4 members share the one topology.
|
||
let members = std::fs::read_to_string(cwd.join("runs/families.jsonl")).expect("read family store");
|
||
let carried = members.matches(&format!("\"topology_hash\":\"{stem}\"")).count();
|
||
assert_eq!(carried, 4, "every member records the store key as its topology_hash: {members}");
|
||
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// An `--axis` name that does not resolve against the loaded blueprint's param_space
|
||
/// fails clean (a named BindError, non-zero exit), not a panic. The CLOSED fixture has
|
||
/// bound-override knobs, so a nonsense axis name (`nope`) is genuinely unknown — it names
|
||
/// neither a re-openable bound param nor an already-open knob (#246: a bound name would
|
||
/// re-open instead).
|
||
#[test]
|
||
fn aura_sweep_rejects_an_unknown_axis() {
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = Command::new(BIN)
|
||
.args(["sweep", &fixture, "--axis", "nope=1,2", "--name", "f"])
|
||
.output()
|
||
.expect("spawn aura sweep unknown-axis");
|
||
assert_ne!(out.status.code(), Some(0), "an unknown axis must fail the sweep");
|
||
let stderr = String::from_utf8(out.stderr).expect("utf-8");
|
||
assert!(
|
||
stderr.contains("Knob") || stderr.to_lowercase().contains("nope"),
|
||
"names the unresolved axis: {stderr}"
|
||
);
|
||
}
|
||
|
||
/// Property (#158, C18 "re-derives full results on demand"): `aura reproduce <family-id>`
|
||
/// re-derives every member of a persisted sweep family from the content-addressed
|
||
/// blueprint store and reports each bit-identical, exiting 0. Drives the verb through the
|
||
/// binary — the `reproduce_family` stdout contract (a per-member `bit-identical` line plus
|
||
/// a `reproduced N/N` summary) that the `reproduce_family_in` unit seam bypasses.
|
||
#[test]
|
||
fn aura_reproduce_re_derives_a_persisted_sweep_family() {
|
||
let cwd = temp_cwd("blueprint-reproduce");
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let sweep = Command::new(BIN)
|
||
.args([
|
||
"sweep", &fixture,
|
||
"--axis", "sma_signal.fast.length=2,4",
|
||
"--axis", "sma_signal.slow.length=8,16",
|
||
"--name", "f",
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura sweep blueprint");
|
||
assert!(sweep.status.success(), "sweep exit: {:?} stderr={}", sweep.status, String::from_utf8_lossy(&sweep.stderr));
|
||
|
||
// `--trace f` names the family `f-0`; reproduce it from disk.
|
||
let out = Command::new(BIN)
|
||
.args(["reproduce", "f-0"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura reproduce");
|
||
assert!(out.status.success(), "reproduce exit: {:?} stderr={}", out.status, String::from_utf8_lossy(&out.stderr));
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
// one verdict line per member (all bit-identical) + the summary; the label renders
|
||
// param values portably (`length=2`, not the `I64(2)` Debug form).
|
||
assert_eq!(
|
||
stdout.lines().filter(|l| l.contains("reproduced: bit-identical")).count(),
|
||
4,
|
||
"every member re-derives bit-identically: {stdout}"
|
||
);
|
||
assert!(stdout.contains("sma_signal.fast.length=2"), "label renders values portably: {stdout}");
|
||
assert!(!stdout.contains("I64("), "label must not leak the Scalar Debug form: {stdout}");
|
||
assert!(stdout.contains("reproduced 4/4 members bit-identically"), "summary line: {stdout}");
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// Property (C1/C18 integrity net): when a persisted member's stored metrics no longer
|
||
/// match its re-derivation, `aura reproduce` reports that member `DIVERGED` and exits 1
|
||
/// — the non-happy branch of the verb. Forced by tampering one member's on-disk
|
||
/// `total_pips` after the sweep (the store's bit-identical compare IS the integrity check).
|
||
#[test]
|
||
fn aura_reproduce_reports_a_diverged_member_and_exits_one() {
|
||
let cwd = temp_cwd("blueprint-reproduce-diverge");
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let sweep = Command::new(BIN)
|
||
.args([
|
||
"sweep", &fixture,
|
||
"--axis", "sma_signal.fast.length=2,4",
|
||
"--axis", "sma_signal.slow.length=8,16",
|
||
"--name", "f",
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura sweep blueprint");
|
||
assert!(sweep.status.success(), "sweep exit: {:?} stderr={}", sweep.status, String::from_utf8_lossy(&sweep.stderr));
|
||
|
||
// corrupt the first member's stored `total_pips` so its re-run metrics differ.
|
||
let store = cwd.join("runs/families.jsonl");
|
||
let s = std::fs::read_to_string(&store).expect("read family store");
|
||
let key = "\"total_pips\":";
|
||
let at = s.find(key).expect("a member records total_pips") + key.len();
|
||
let end = at + s[at..].find(',').expect("total_pips value terminates");
|
||
let corrupted = format!("{}999.0{}", &s[..at], &s[end..]);
|
||
std::fs::write(&store, corrupted).expect("write corrupted store");
|
||
|
||
let out = Command::new(BIN)
|
||
.args(["reproduce", "f-0"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura reproduce");
|
||
assert_eq!(out.status.code(), Some(1), "a diverged member exits 1: stderr={}", String::from_utf8_lossy(&out.stderr));
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
assert!(stdout.contains("reproduced: DIVERGED"), "the tampered member is reported DIVERGED: {stdout}");
|
||
assert!(stdout.contains("reproduced 3/4 members bit-identically"), "summary counts the diverged member: {stdout}");
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// E2E (acc 2): persist a blueprint sweep, then `aura reproduce <id>` re-derives every
|
||
/// member bit-identically from the content-addressed store and exits 0.
|
||
#[test]
|
||
fn aura_reproduce_re_derives_a_persisted_sweep_bit_identically() {
|
||
let cwd = temp_cwd("reproduce_roundtrip");
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
|
||
let sweep = std::process::Command::new(BIN)
|
||
.args([
|
||
"sweep",
|
||
&fixture,
|
||
"--axis",
|
||
"sma_signal.fast.length=2",
|
||
"--axis",
|
||
"sma_signal.slow.length=4,6",
|
||
"--name",
|
||
"smacross",
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("run aura sweep");
|
||
assert!(sweep.status.success(), "sweep ok: {}", String::from_utf8_lossy(&sweep.stderr));
|
||
|
||
let repro = std::process::Command::new(BIN)
|
||
.args(["reproduce", "smacross-0"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("run aura reproduce");
|
||
let stdout = String::from_utf8_lossy(&repro.stdout);
|
||
assert!(repro.status.success(), "reproduce exits 0: {}", String::from_utf8_lossy(&repro.stderr));
|
||
assert!(
|
||
stdout.contains("reproduced 2/2 members bit-identically"),
|
||
"every member re-derives: {stdout}"
|
||
);
|
||
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// E2E (acc 1+3+4): `aura mc <closed blueprint.json> --seeds N` builds a MonteCarlo
|
||
/// family (one member per seed), writes exactly one content-addressed blueprint, and
|
||
/// `aura reproduce <id>` re-derives every member bit-identically (exit 0).
|
||
#[test]
|
||
fn aura_mc_over_a_blueprint_reproduces_bit_identically() {
|
||
let cwd = temp_cwd("mc-blueprint-reproduce");
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
|
||
let mc = Command::new(BIN)
|
||
.args(["mc", &fixture, "--seeds", "4", "--name", "mcx"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura mc blueprint");
|
||
assert!(mc.status.success(), "mc exit: {:?} stderr={}", mc.status, String::from_utf8_lossy(&mc.stderr));
|
||
let mc_out = String::from_utf8(mc.stdout).expect("utf-8 stdout");
|
||
// one member line per seed (each carries "seed":n) + one aggregate line.
|
||
assert_eq!(
|
||
mc_out.lines().filter(|l| l.contains("\"seed\":")).count(),
|
||
4,
|
||
"one member line per seed: {mc_out}"
|
||
);
|
||
assert!(mc_out.contains("mc_aggregate"), "prints the aggregate line: {mc_out}");
|
||
|
||
// exactly one blueprint stored for the whole family (one topology per family).
|
||
let store = cwd.join("runs/blueprints");
|
||
let entries: Vec<_> = std::fs::read_dir(&store)
|
||
.unwrap_or_else(|e| panic!("mc must create the blueprint store {store:?}: {e}"))
|
||
.map(|e| e.expect("dir entry").path())
|
||
.collect();
|
||
assert_eq!(entries.len(), 1, "one stored topology per family: {entries:?}");
|
||
|
||
let repro = Command::new(BIN)
|
||
.args(["reproduce", "mcx-0"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura reproduce");
|
||
assert!(repro.status.success(), "reproduce exit: {:?} stderr={}", repro.status, String::from_utf8_lossy(&repro.stderr));
|
||
let repro_out = String::from_utf8(repro.stdout).expect("utf-8 stdout");
|
||
assert!(
|
||
repro_out.contains("reproduced 4/4 members bit-identically"),
|
||
"every MC member re-derives: {repro_out}"
|
||
);
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// E2E (#173): an IS-refit walk-forward over a loaded blueprint persists a
|
||
/// content-addressed WalkForward family that `aura reproduce`s bit-identically —
|
||
/// each OOS member's windowed slice + winner params are recovered from its manifest.
|
||
#[test]
|
||
fn aura_walkforward_over_a_blueprint_reproduces_bit_identically() {
|
||
let cwd = temp_cwd("blueprint-walkforward-reproduce");
|
||
let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let run = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["walkforward", &bp, "--axis", "sma_signal.fast.length=2,3",
|
||
"--axis", "sma_signal.slow.length=4,6", "--name", "wfr"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura walkforward");
|
||
assert_eq!(run.status.code(), Some(0), "stderr: {}", String::from_utf8_lossy(&run.stderr));
|
||
let repro = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["reproduce", "wfr-0"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura reproduce");
|
||
assert_eq!(repro.status.code(), Some(0));
|
||
let out = String::from_utf8(repro.stdout).expect("utf-8");
|
||
assert!(out.contains("reproduced 3/3 members bit-identically"), "stdout: {out}");
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// E2E (#173): the loaded-blueprint walk-forward runs + persists a WalkForward
|
||
/// family (3 OOS members) with a {"walkforward":…} summary carrying oos_r, and
|
||
/// stores exactly one content-addressed blueprint.
|
||
#[test]
|
||
fn aura_walkforward_over_a_blueprint_persists_a_family() {
|
||
let cwd = temp_cwd("blueprint-walkforward-persist");
|
||
let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["walkforward", &bp, "--axis", "sma_signal.fast.length=2,3",
|
||
"--axis", "sma_signal.slow.length=4,6", "--name", "wfx"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura walkforward");
|
||
assert_eq!(out.status.code(), Some(0), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8");
|
||
assert_eq!(stdout.matches("wfx-").count(), 3, "3 OOS member lines: {stdout}");
|
||
assert!(stdout.contains("\"walkforward\""), "summary line: {stdout}");
|
||
assert!(stdout.contains("\"oos_r\""), "R-measured summary: {stdout}");
|
||
let families = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["runs", "families"]).current_dir(&cwd).output().expect("spawn families");
|
||
let fo = String::from_utf8(families.stdout).expect("utf-8");
|
||
assert!(fo.contains("\"kind\":\"WalkForward\""), "families: {fo}");
|
||
let n = std::fs::read_dir(cwd.join("runs").join("blueprints")).map(|d| d.count()).unwrap_or(0);
|
||
assert_eq!(n, 1, "exactly one content-addressed blueprint stored");
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// E2E (#173): a CLOSED blueprint with no --axis (nothing to re-fit) is a usage
|
||
/// error (exit 2), naming that >= 1 --axis is required.
|
||
#[test]
|
||
fn aura_walkforward_over_a_blueprint_rejects_no_axis() {
|
||
let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["walkforward", &bp])
|
||
.output()
|
||
.expect("spawn aura walkforward (no axis)");
|
||
assert_eq!(out.status.code(), Some(2));
|
||
let stderr = String::from_utf8(out.stderr).expect("utf-8");
|
||
assert!(stderr.contains("requires >= 1 --axis"), "stderr: {stderr}");
|
||
}
|
||
|
||
/// E2E (#173/#184, safety): a malformed blueprint is rejected at the dispatch
|
||
/// boundary (exit 2) rather than panicking, phrased house-style (#184's
|
||
/// `blueprint_load_prose` convention) — never the raw `UnknownNodeType` Debug
|
||
/// leak (c0110 fieldtest finding, mirroring `aura_run_rejects_an_unknown_node_blueprint`).
|
||
#[test]
|
||
fn aura_walkforward_over_a_blueprint_rejects_malformed() {
|
||
let bp = format!("{}/tests/fixtures/unknown_node.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["walkforward", &bp, "--axis", "sma_signal.fast.length=2,3"])
|
||
.output()
|
||
.expect("spawn aura walkforward (malformed)");
|
||
assert_eq!(out.status.code(), Some(2), "malformed must be rejected, not panic");
|
||
let stderr = String::from_utf8(out.stderr).expect("utf-8");
|
||
assert!(stderr.contains(r#"unknown node type "Nope""#), "house-style prose names the type: {stderr}");
|
||
assert!(!stderr.contains("UnknownNodeType"), "does not leak the Debug variant name: {stderr}");
|
||
assert!(!stderr.contains("panicked"), "must reject cleanly: {stderr}");
|
||
}
|
||
|
||
/// E2E (#173, panic-safety): a `--axis` naming a knob that is not in the loaded
|
||
/// blueprint's param_space fails clean (named BindError, exit 2, no panic). This
|
||
/// pins the in-closure fallible-bind path — `blueprint_sweep_over` returns a
|
||
/// `BindError` from inside the `walk_forward` window closure — which is a DISTINCT
|
||
/// branch from the dispatch-boundary blueprint-parse rejection (the malformed test):
|
||
/// the blueprint here is well-formed and passes dispatch, then the per-window
|
||
/// re-fit's bind fails. A regression to `.expect()` on that bind would panic here
|
||
/// while leaving the malformed test green.
|
||
#[test]
|
||
fn aura_walkforward_over_a_blueprint_rejects_an_unknown_axis() {
|
||
let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["walkforward", &bp, "--axis", "nope=1,2"])
|
||
.output()
|
||
.expect("spawn aura walkforward (unknown axis)");
|
||
assert_eq!(out.status.code(), Some(2), "an unknown axis must fail clean, not panic");
|
||
let stderr = String::from_utf8(out.stderr).expect("utf-8");
|
||
assert!(
|
||
stderr.contains("names no param") && stderr.contains("nope"),
|
||
"names the unresolved axis via the prose payload (#269): {stderr}"
|
||
);
|
||
assert!(!stderr.contains("panicked"), "must reject cleanly, never panic: {stderr}");
|
||
}
|
||
|
||
/// E2E (#177): a rejected `--axis` on a loaded walk-forward blueprint is reported
|
||
/// EXACTLY ONCE, however many IS/OOS windows the roll would have spanned — mirroring
|
||
/// the sibling `aura sweep` / `aura mc` paths, which validate the axis ONCE up front
|
||
/// and emit their rejection a single time. The property this protects: axis
|
||
/// resolution is a single pre-flight, not a per-window re-raise.
|
||
///
|
||
/// The CLOSED fixture has an empty param_space, so ANY `--axis` name is an
|
||
/// `UnknownKnob`. The pre-fix code validates the axis INSIDE the per-window closure
|
||
/// that `walk_forward` fans out in parallel across the windows, so more than one
|
||
/// window `eprintln!`s the same rejection before the racing `exit(2)` tears the
|
||
/// process down — a race whose emit count is 1 or 2 per run. A single invocation
|
||
/// would therefore be a flaky assertion (it emits once ~1/3 of the time by luck), so
|
||
/// this drives the verb repeatedly: observing the double-emit at least once is then
|
||
/// near-certain, and the assertion is that EVERY run emits the rejection exactly
|
||
/// once. The fix (hoist axis resolution to one pre-flight before the windowed
|
||
/// fan-out) makes every run deterministically single.
|
||
#[test]
|
||
fn aura_walkforward_emits_an_unknown_axis_rejection_once() {
|
||
let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
for attempt in 1..=20 {
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["walkforward", &bp, "--axis", "graph.fast.length=2,3"])
|
||
.output()
|
||
.expect("spawn aura walkforward (unknown axis)");
|
||
assert_eq!(out.status.code(), Some(2), "an unknown axis must fail clean, not panic");
|
||
let stderr = String::from_utf8(out.stderr).expect("utf-8");
|
||
assert!(!stderr.contains("panicked"), "must reject cleanly, never panic: {stderr}");
|
||
let emits = stderr.matches("names no param").count();
|
||
assert_eq!(
|
||
emits, 1,
|
||
"attempt {attempt}: the axis rejection must be emitted exactly once, got {emits}: {stderr:?}",
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Property (#269): the walkforward verb's unknown-axis rejection reaches a
|
||
/// user's stderr as prose — the same register `aura sweep`'s boundary errors
|
||
/// adopted in #247 — never wrapped in the `UnknownKnob("…")` Rust `Debug`
|
||
/// frame. The walkforward path already carries a fully-prosed payload INSIDE
|
||
/// that wrapper (`axis <name>: names no param of this blueprint … — see …
|
||
/// --list-axes`), so the fix sheds the frame rather than authoring new prose:
|
||
/// the message that reaches stderr is exactly that payload, the axis named as
|
||
/// typed, exit still 2. Data-free — the axis is rejected at the resolution
|
||
/// pre-flight before any window runs, so no archive and no project are needed.
|
||
///
|
||
/// NB: this is the post-#269 contract. The two sibling tests above
|
||
/// (`aura_walkforward_over_a_blueprint_rejects_an_unknown_axis`,
|
||
/// `aura_walkforward_emits_an_unknown_axis_rejection_once`) still pin the
|
||
/// `UnknownKnob` substring — stale pins of the defective frame that the GREEN
|
||
/// slice repoints onto the prose fragment.
|
||
#[test]
|
||
fn walkforward_unknown_axis_rejection_renders_as_prose_not_a_debug_frame() {
|
||
let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["walkforward", &bp, "--axis", "nope=1,2"])
|
||
.output()
|
||
.expect("spawn aura walkforward (unknown axis)");
|
||
assert_eq!(
|
||
out.status.code(),
|
||
Some(2),
|
||
"an unknown axis stays a usage refusal (exit 2, not a panic): status={:?}",
|
||
out.status
|
||
);
|
||
let stderr = String::from_utf8(out.stderr).expect("utf-8");
|
||
// The headline — the whole defect: no Rust `Debug` frame reaches the user.
|
||
assert!(
|
||
!stderr.contains("UnknownKnob"),
|
||
"must not leak the `UnknownKnob(…)` Debug frame around the prose: {stderr}"
|
||
);
|
||
// The prose payload survives intact — the axis named as typed, the
|
||
// names-no-param wording, and the `--list-axes` pointer (the sweep
|
||
// sibling's register).
|
||
assert!(
|
||
stderr.contains("nope"),
|
||
"the prose names the offending axis exactly as typed: {stderr}"
|
||
);
|
||
assert!(
|
||
stderr.contains("names no param"),
|
||
"the prose carries the boundary payload wording: {stderr}"
|
||
);
|
||
assert!(
|
||
stderr.contains("--list-axes"),
|
||
"the prose points at the discovery surface, like the sweep sibling: {stderr}"
|
||
);
|
||
assert!(
|
||
!stderr.contains("panicked"),
|
||
"must reject cleanly, never panic: {stderr}"
|
||
);
|
||
}
|
||
|
||
/// E2E (acc 2): `aura mc <open blueprint.json>` is rejected with a named error + exit 2
|
||
/// (NOT a panic / exit 101) — MC binds no axis, so a free knob has no binder.
|
||
#[test]
|
||
fn aura_mc_rejects_an_open_blueprint() {
|
||
let cwd = temp_cwd("mc-blueprint-open-reject");
|
||
let fixture = format!("{}/tests/fixtures/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = Command::new(BIN)
|
||
.args(["mc", &fixture, "--seeds", "4"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura mc open-blueprint");
|
||
assert_eq!(out.status.code(), Some(2), "an open blueprint fails clean (exit 2, not a panic): stderr={}", String::from_utf8_lossy(&out.stderr));
|
||
let stderr = String::from_utf8(out.stderr).expect("utf-8");
|
||
assert!(stderr.contains("closed blueprint"), "names the closed-blueprint requirement: {stderr}");
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// E2E (#176): `aura run <open blueprint.json>` refuses an open blueprint the same way the
|
||
/// sibling `aura mc` does — a clean named error + exit 2, NEVER a `compile_with_params`
|
||
/// arity panic (exit 101). The `run` dispatch bootstraps over the EMPTY point (a
|
||
/// closed-blueprint assumption), so a free knob must be rejected at the dispatch boundary
|
||
/// before that bootstrap, mirroring `blueprint_mc_family`'s closed-guard.
|
||
#[test]
|
||
fn aura_run_rejects_an_open_blueprint_without_panicking() {
|
||
let cwd = temp_cwd("run-blueprint-open-reject");
|
||
let fixture = format!("{}/tests/fixtures/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = Command::new(BIN)
|
||
.args(["run", &fixture])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura run open-blueprint");
|
||
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
|
||
assert_eq!(
|
||
out.status.code(),
|
||
Some(2),
|
||
"an open blueprint fails clean (exit 2, not a panic/exit 101): stderr={stderr}"
|
||
);
|
||
assert!(
|
||
stderr.contains("closed blueprint"),
|
||
"names the closed-blueprint requirement (mirroring `aura mc`): {stderr}"
|
||
);
|
||
assert!(
|
||
!stderr.contains("panicked"),
|
||
"must refuse at the dispatch boundary, never panic: {stderr}"
|
||
);
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// E2E (negative): `aura reproduce <unknown>` is a hard error (exit non-zero + named
|
||
/// cause on stderr), distinct from `runs family <id>`'s treat-as-empty exit 0.
|
||
#[test]
|
||
fn aura_reproduce_rejects_an_unknown_family() {
|
||
let cwd = temp_cwd("reproduce_unknown");
|
||
let out = std::process::Command::new(BIN)
|
||
.args(["reproduce", "no-such-family-1"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("run aura reproduce");
|
||
assert!(!out.status.success(), "unknown family exits non-zero");
|
||
assert!(
|
||
String::from_utf8_lossy(&out.stderr).contains("no such family 'no-such-family-1'"),
|
||
"names the cause: {}",
|
||
String::from_utf8_lossy(&out.stderr)
|
||
);
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
// Note (#159 cut 4 collateral): this removes
|
||
// `aura_reproduce_rejects_a_non_generated_family` — its fixture was a
|
||
// HARD-WIRED (bare `sweep --name`) family with no `topology_hash`; sweep is
|
||
// blueprint-only now and every blueprint-sweep member stamps a
|
||
// `topology_hash`, so no surviving CLI surface can construct the fixture.
|
||
// The defensive "not a generated run" refusal in `main.rs` stays as dead-path
|
||
// safety for a malformed/legacy `families.jsonl`. Removed as collateral, not
|
||
// repointed.
|
||
|
||
/// E2E (refuse-don't-guess): `aura reproduce` when the content-addressed blueprint was
|
||
/// removed from the store exits non-zero, naming the missing blueprint — never re-runs
|
||
/// against a guessed topology (C10/C18).
|
||
#[test]
|
||
fn aura_reproduce_rejects_a_missing_stored_blueprint() {
|
||
let cwd = temp_cwd("reproduce_missing_store");
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let sweep = std::process::Command::new(BIN)
|
||
.args([
|
||
"sweep",
|
||
&fixture,
|
||
"--axis",
|
||
"sma_signal.fast.length=2",
|
||
"--axis",
|
||
"sma_signal.slow.length=4",
|
||
"--name",
|
||
"bp",
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("run aura sweep");
|
||
assert!(sweep.status.success(), "blueprint sweep ok: {}", String::from_utf8_lossy(&sweep.stderr));
|
||
std::fs::remove_dir_all(cwd.join("runs").join("blueprints")).expect("remove the blueprint store");
|
||
let out = std::process::Command::new(BIN)
|
||
.args(["reproduce", "bp-0"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("run aura reproduce");
|
||
assert!(!out.status.success(), "a missing stored blueprint exits non-zero");
|
||
assert!(
|
||
String::from_utf8_lossy(&out.stderr).contains("missing from store"),
|
||
"names the cause: {}",
|
||
String::from_utf8_lossy(&out.stderr)
|
||
);
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// E2E (#169/#246): `aura sweep <open blueprint.json> --list-axes` prints one
|
||
/// `<name>:<kind>` line per open sweepable knob, in `param_space()` order,
|
||
/// followed by one `<name>:<kind> default=<value>` line per BOUND param (the
|
||
/// fixture's `bias.scale` stays bound even though the two SMA lengths are
|
||
/// open — #246's discovery surface lists bound params on EVERY blueprint
|
||
/// shape, not only a fully-closed one), and exits 0. The names are prefixed
|
||
/// by the wrapping (sma_signal.*) — exactly the strings `--axis` binds — so a
|
||
/// user can discover then sweep or re-open without guessing.
|
||
#[test]
|
||
fn aura_sweep_list_axes_prints_prefixed_names() {
|
||
let bp = format!("{}/tests/fixtures/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["sweep", &bp, "--list-axes"])
|
||
.output()
|
||
.expect("spawn aura sweep --list-axes");
|
||
assert_eq!(out.status.code(), Some(0));
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8");
|
||
assert_eq!(
|
||
stdout,
|
||
"sma_signal.fast.length:I64\n\
|
||
sma_signal.slow.length:I64\n\
|
||
sma_signal.bias.scale:F64 default=0.5\n"
|
||
);
|
||
}
|
||
|
||
/// E2E (#246): `--list-axes` on a CLOSED blueprint (all knobs bound) prints one
|
||
/// `<bp>.<node>.<param>:<KIND> default=<value>` line per bound param — the
|
||
/// bound value IS the default, overridable by naming the same path as an
|
||
/// `--axis` (#246's re-open contract) — and exits 0. The sibling pin above
|
||
/// (`aura_sweep_list_axes_prints_prefixed_names`) proves the same bound-param
|
||
/// lines also appear on a PARTIALLY open blueprint, after its open lines.
|
||
#[test]
|
||
fn aura_sweep_list_axes_closed_prints_bound_defaults() {
|
||
let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["sweep", &bp, "--list-axes"])
|
||
.output()
|
||
.expect("spawn aura sweep --list-axes (closed)");
|
||
assert_eq!(out.status.code(), Some(0));
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8");
|
||
assert_eq!(
|
||
stdout,
|
||
"sma_signal.fast.length:I64 default=2\n\
|
||
sma_signal.slow.length:I64 default=4\n\
|
||
sma_signal.bias.scale:F64 default=0.5\n"
|
||
);
|
||
}
|
||
|
||
/// E2E (#169): `--list-axes` is a standalone query — combining it with a real
|
||
/// sweep flag (`--axis`) is a usage error (exit 2), naming that it lists axes
|
||
/// and takes no other flags. A query cannot also seed a sweep.
|
||
#[test]
|
||
fn aura_sweep_list_axes_rejects_other_flags() {
|
||
let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["sweep", &bp, "--list-axes", "--axis", "sma_signal.fast.length=2,3"])
|
||
.output()
|
||
.expect("spawn aura sweep --list-axes + --axis");
|
||
assert_eq!(out.status.code(), Some(2));
|
||
let stderr = String::from_utf8(out.stderr).expect("utf-8");
|
||
assert!(stderr.contains("--list-axes lists axes"), "stderr was: {stderr}");
|
||
}
|
||
|
||
/// E2E (#169/#184, safety): `--list-axes` over a malformed blueprint rejects
|
||
/// cleanly (exit 2), phrased house-style — never the raw `UnknownNodeType`
|
||
/// Debug leak (c0110 fieldtest finding) — rather than panicking; the axis
|
||
/// probe reloads the doc, so the dispatch-boundary validation must run first,
|
||
/// never a raw `.expect`.
|
||
#[test]
|
||
fn aura_sweep_list_axes_rejects_malformed_blueprint() {
|
||
let bp = format!("{}/tests/fixtures/unknown_node.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["sweep", &bp, "--list-axes"])
|
||
.output()
|
||
.expect("spawn aura sweep --list-axes (malformed)");
|
||
assert_eq!(out.status.code(), Some(2), "malformed blueprint must be rejected, not panic");
|
||
let stderr = String::from_utf8(out.stderr).expect("utf-8");
|
||
assert!(stderr.contains(r#"unknown node type "Nope""#), "house-style prose names the type: {stderr}");
|
||
assert!(!stderr.contains("UnknownNodeType"), "does not leak the Debug variant name: {stderr}");
|
||
assert!(!stderr.contains("panicked"), "must reject cleanly, not panic: {stderr}");
|
||
}
|
||
|
||
/// E2E (#210 c0110 fieldtest, "aura sweep <op-script> rejects the op-script
|
||
/// array with a raw serde error"): `aura sweep`'s blueprint slot accepts only
|
||
/// the built blueprint envelope; an un-built op-script (a JSON array) is
|
||
/// shape-discriminated and refused with a targeted, house-style hint — never
|
||
/// the leaked `Json(Error("invalid type: map, expected u32", ...))`.
|
||
#[test]
|
||
fn aura_sweep_rejects_an_op_script_array_with_a_build_first_hint() {
|
||
let dir = temp_cwd("sweep_rejects_op_script");
|
||
let op_script = dir.join("ops.json");
|
||
std::fs::write(&op_script, r#"[{"op":"source","role":"price","kind":"F64"}]"#)
|
||
.expect("write op-script fixture");
|
||
let out = Command::new(BIN)
|
||
.args(["sweep", op_script.to_str().unwrap(), "--list-axes"])
|
||
.output()
|
||
.expect("spawn aura sweep (op-script)");
|
||
assert_eq!(out.status.code(), Some(2), "an op-script array must be refused, not panic");
|
||
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
|
||
assert!(
|
||
stderr.contains("op-script") && stderr.contains("aura graph build"),
|
||
"names the op-script cause + the fix: {stderr}"
|
||
);
|
||
assert!(!stderr.contains("Json(Error"), "does not leak the raw serde error: {stderr}");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// E2E (#210 c0110 fieldtest, house-style loader prose): a genuinely malformed
|
||
/// (syntactically invalid) JSON document in the sweep blueprint slot is
|
||
/// refused with `blueprint_load_prose`'s wording, never the raw `{e:?}` Debug
|
||
/// form the dispatch used to print.
|
||
#[test]
|
||
fn aura_sweep_rejects_malformed_json_house_style() {
|
||
let dir = temp_cwd("sweep_rejects_malformed_json");
|
||
let bad = dir.join("bad.json");
|
||
std::fs::write(&bad, "{not valid json").expect("write malformed fixture");
|
||
let out = Command::new(BIN)
|
||
.args(["sweep", bad.to_str().unwrap(), "--list-axes"])
|
||
.output()
|
||
.expect("spawn aura sweep (malformed json)");
|
||
assert_eq!(out.status.code(), Some(2), "malformed JSON must be refused, not panic");
|
||
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
|
||
assert!(
|
||
stderr.contains("blueprint document is not valid JSON"),
|
||
"house-style prose, not a raw Debug leak: {stderr}"
|
||
);
|
||
assert!(!stderr.contains("Json(Error"), "does not leak the raw serde Debug form: {stderr}");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// E2E (#169, the load-bearing "listed == swept" invariant): a name emitted by
|
||
/// `--list-axes` is accepted VERBATIM by `--axis` — the discovered axis namespace
|
||
/// and the swept axis namespace are the same set (shared-probe construction). The
|
||
/// axis names here are DERIVED from the `--list-axes` output, never hardcoded, then
|
||
/// fed straight back into a real sweep: the sweep exits 0 and persists a `Sweep`
|
||
/// family, proving every listed name resolved (no UnknownKnob). This is the
|
||
/// discovery-removes-guessing property #169 exists to close — were the list and
|
||
/// sweep surfaces ever to diverge, a discovered name would be rejected here and this
|
||
/// round-trip would fail, whereas the literal-pinned per-surface tests could not see it.
|
||
#[test]
|
||
fn aura_sweep_list_axes_names_round_trip_into_a_working_sweep() {
|
||
let cwd = temp_cwd("blueprint-list-axes-roundtrip");
|
||
let bp = format!("{}/tests/fixtures/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
|
||
|
||
// 1) discover the axis names + kinds (do NOT hardcode them) — this is what a user
|
||
// reads. #246: the fixture now lists a BOUND param (`bias.scale`, F64) alongside
|
||
// the two open I64 lengths, so the CSV below must be typed per discovered kind,
|
||
// not assumed integer.
|
||
let list = Command::new(BIN)
|
||
.args(["sweep", &bp, "--list-axes"])
|
||
.output()
|
||
.expect("spawn aura sweep --list-axes");
|
||
assert_eq!(list.status.code(), Some(0));
|
||
let listed = String::from_utf8(list.stdout).expect("utf-8");
|
||
let names: Vec<(String, String)> = listed
|
||
.lines()
|
||
.map(|l| {
|
||
let mut parts = l.splitn(2, ':');
|
||
let name = parts.next().expect("a `name:kind` line").to_string();
|
||
let kind = parts
|
||
.next()
|
||
.expect("kind after ':'")
|
||
.split_whitespace()
|
||
.next()
|
||
.expect("a kind token")
|
||
.to_string();
|
||
(name, kind)
|
||
})
|
||
.collect();
|
||
assert!(!names.is_empty(), "the open fixture must list >= 1 axis");
|
||
|
||
// 2) feed the DISCOVERED names straight back into a real sweep. Values are
|
||
// position-assigned (first axis small, rest large) to stay in a non-degenerate
|
||
// SMA regime without re-hardcoding the names; each axis takes two values, typed
|
||
// per its discovered kind so a bound F64 default (e.g. `bias.scale`) resolves
|
||
// instead of tripping `KindMismatch` on an integer-shaped token.
|
||
let mut args: Vec<String> = vec!["sweep".into(), bp.clone()];
|
||
for (i, (name, kind)) in names.iter().enumerate() {
|
||
let vals: &str = match (i == 0, kind.as_str()) {
|
||
(true, "F64") => "2.0,4.0",
|
||
(true, _) => "2,4",
|
||
(false, "F64") => "8.0,16.0",
|
||
(false, _) => "8,16",
|
||
};
|
||
args.push("--axis".into());
|
||
args.push(format!("{name}={vals}"));
|
||
}
|
||
let sweep = Command::new(BIN)
|
||
.args(&args)
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura sweep over discovered axes");
|
||
assert!(
|
||
sweep.status.success(),
|
||
"every listed name must be a valid --axis; exit={:?} stderr={}",
|
||
sweep.status,
|
||
String::from_utf8_lossy(&sweep.stderr)
|
||
);
|
||
|
||
// 3) the sweep persisted a Sweep family whose grid spans exactly the discovered
|
||
// axes (each bound to two values -> 2^k members) — the names resolved end-to-end.
|
||
let fams = Command::new(BIN).args(["runs", "families"]).current_dir(&cwd).output().expect("spawn families");
|
||
let fo = String::from_utf8(fams.stdout).expect("utf-8");
|
||
assert!(fo.contains("\"kind\":\"Sweep\""), "families: {fo}");
|
||
let expected = 1usize << names.len(); // two values per discovered axis
|
||
assert!(fo.contains(&format!("\"members\":{expected}")), "grid over the discovered axes: {fo}");
|
||
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// `--version` / `-V` surface the crate version on stdout and exit 0 (GNU MUST):
|
||
/// a version query is a successful query, not a usage error. The expected string
|
||
/// is the crate version itself (`CARGO_PKG_VERSION`, i.e. the workspace version),
|
||
/// so this stays honest across version bumps and keeps one source of truth.
|
||
#[test]
|
||
fn version_flag_prints_version_to_stdout_and_exits_zero() {
|
||
for flag in ["--version", "-V"] {
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.arg(flag)
|
||
.output()
|
||
.expect("spawn aura");
|
||
assert_eq!(out.status.code(), Some(0), "{flag}: exit {:?}", out.status);
|
||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||
assert!(stdout.contains(env!("CARGO_PKG_VERSION")), "{flag} stdout: {stdout:?}");
|
||
}
|
||
}
|
||
|
||
/// `--version` carries the ENGINE COMMIT beside the semver, drawn from the SAME
|
||
/// `AURA_COMMIT` build-time provenance the run manifest stamps (the
|
||
/// `option_env!("AURA_COMMIT")` that fills `RunManifest.commit`), so a stale
|
||
/// binary is distinguishable at the CLI surface *before* any run (#266). Shape:
|
||
/// `aura 0.1.0 (<commit-ish>)`, exit 0.
|
||
///
|
||
/// Property, not a specific sha: the expected commit is read from the same
|
||
/// provenance source the built binary was compiled with — this integration test
|
||
/// receives aura-cli's build-script `rustc-env`, so it sees the identical value
|
||
/// (dirty/`unknown` suffixes included) rather than today's HEAD, keeping the
|
||
/// assertion honest against a lagging or unclean tree. The load-bearing claim is
|
||
/// that the parenthesized commit rides beside the still-present semver.
|
||
#[test]
|
||
fn version_flag_carries_the_engine_commit_from_manifest_provenance() {
|
||
// The single source of truth — the same env the manifest's commit is sourced
|
||
// from; falls back to "unknown" identically to `sim_optimal_manifest`.
|
||
let commit = option_env!("AURA_COMMIT").unwrap_or("unknown");
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.arg("--version")
|
||
.output()
|
||
.expect("spawn aura");
|
||
assert_eq!(out.status.code(), Some(0), "--version exit {:?}", out.status);
|
||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||
// Semver stays present (one source of truth, CARGO_PKG_VERSION).
|
||
assert!(
|
||
stdout.contains(env!("CARGO_PKG_VERSION")),
|
||
"--version still prints the semver: {stdout:?}"
|
||
);
|
||
// ...and the engine commit rides beside it, parenthesized, from the shared
|
||
// manifest provenance — the load-bearing assertion #266 adds.
|
||
assert!(
|
||
stdout.contains(&format!("({commit})")),
|
||
"--version prints the engine commit ({commit}) beside the semver: {stdout:?}"
|
||
);
|
||
}
|
||
|
||
/// Subcommand help is SCOPED: `aura sweep --help` names sweep's own flags and is
|
||
/// NOT byte-identical to `aura run --help` — each subcommand documents its own
|
||
/// options rather than reprinting one shared global blob (the #131 uniform-help
|
||
/// surface retired).
|
||
#[test]
|
||
fn subcommand_help_is_scoped_not_uniform() {
|
||
let sweep = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["sweep", "--help"]).output().expect("spawn");
|
||
let run = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["run", "--help"]).output().expect("spawn");
|
||
assert_eq!(sweep.status.code(), Some(0), "sweep --help exit");
|
||
assert_eq!(run.status.code(), Some(0), "run --help exit");
|
||
let sweep_out = String::from_utf8_lossy(&sweep.stdout);
|
||
let run_out = String::from_utf8_lossy(&run.stdout);
|
||
assert!(sweep_out.contains("--strategy"), "sweep help names its flags: {sweep_out:?}");
|
||
assert_ne!(sweep_out, run_out, "per-subcommand help must be scoped, not uniform");
|
||
}
|
||
|
||
/// The GNU `--flag=value` equals form is accepted, equivalent to the
|
||
/// space-separated `--flag value`. (#159 cut 4: `--harness` retired along with
|
||
/// the built-in default harness; `--seed` on a shipped closed blueprint is the
|
||
/// surviving vehicle flag for this clap-grammar property.)
|
||
#[test]
|
||
fn gnu_equals_form_is_accepted() {
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["run", &fixture, "--seed=0"]).output().expect("spawn");
|
||
assert_eq!(out.status.code(), Some(0),
|
||
"run --seed=0 should run; stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||
}
|
||
|
||
/// The `--` end-of-options terminator is recognized (a bare `--` after the flags
|
||
/// is a no-op that still runs). (#159 cut 4: repointed off the retired
|
||
/// `--harness` onto `--seed` over a shipped closed blueprint.)
|
||
#[test]
|
||
fn double_dash_terminator_is_recognized() {
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["run", &fixture, "--seed", "0", "--"]).output().expect("spawn");
|
||
assert_eq!(out.status.code(), Some(0),
|
||
"run ... -- should run; stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||
}
|
||
|
||
/// GNU long-option abbreviation (spec acceptance criterion 4): an unambiguous prefix
|
||
/// of a long flag is accepted (`--se` → `--seed`), via clap `infer_long_args`
|
||
/// on the root command (which propagates to subcommands). An LLM/automation caller
|
||
/// always writes the full flag, but GNU compliance (the cycle's ratified purpose)
|
||
/// includes abbreviation, and clap delivers it with one root attribute. (#159 cut 4:
|
||
/// repointed off the retired `--harness` onto `--seed` over a shipped closed
|
||
/// blueprint.)
|
||
#[test]
|
||
fn long_option_abbreviation_is_accepted() {
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["run", &fixture, "--se", "0"]).output().expect("spawn");
|
||
assert_eq!(out.status.code(), Some(0),
|
||
"run --se 0 (abbrev of --seed) should run; stderr: {}",
|
||
String::from_utf8_lossy(&out.stderr));
|
||
}
|
||
|
||
/// Property (#175, dual-grammar built-in branch stays strict): a leading positional
|
||
/// that is NOT an existing `.json` file is a usage error (exit 2), never silently
|
||
/// swallowed — for every dual-grammar subcommand (run/sweep/walkforward/mc). The
|
||
/// clap migration models the loaded-blueprint `[BLUEPRINT]` as an OPTIONAL positional
|
||
/// on one shared args struct; that makes a typo'd leading token (`aura run bogus`,
|
||
/// meant as `--harness bogus`) structurally parseable where the old hand-parser
|
||
/// rejected any stray token. Each built-in handler therefore re-asserts the guard
|
||
/// (`if a.blueprint.is_some() { usage; exit 2 }`). Dropping any one guard would
|
||
/// silently run the default harness on a mistyped invocation — the exact regression
|
||
/// clap's optional positional invites and this pins shut. Exit 2 preserved (the
|
||
/// usage/runtime split is iteration 2); nothing on stdout on the refusal path.
|
||
#[test]
|
||
fn dual_grammar_stray_positional_is_a_usage_error_not_swallowed() {
|
||
// A trailing flag is added where the built-in grammar needs one to reach the
|
||
// guard rather than a bare-run default; `bogus` never ends in `.json`, so the
|
||
// is_file() discriminator sends every case down the built-in branch.
|
||
for argv in [
|
||
&["run", "bogus"][..],
|
||
&["sweep", "bogus", "--name", "x"][..],
|
||
&["walkforward", "bogus"][..],
|
||
&["mc", "bogus"][..],
|
||
] {
|
||
let out = Command::new(BIN).args(argv).output().expect("spawn aura <sub> <stray-positional>");
|
||
assert_eq!(
|
||
out.status.code(),
|
||
Some(2),
|
||
"{argv:?} must be refused (exit 2), got {:?}; stderr: {}",
|
||
out.status,
|
||
String::from_utf8_lossy(&out.stderr)
|
||
);
|
||
assert!(
|
||
out.stdout.is_empty(),
|
||
"{argv:?} must not emit a report on stdout: {:?}",
|
||
String::from_utf8_lossy(&out.stdout)
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Property (#175, dual-grammar discriminator keys on `is_file()`, not the suffix):
|
||
/// `aura run <name>.json` where the file does NOT exist is refused with the same
|
||
/// usage error as a bare, blueprint-less `run` (exit 2), NOT read as a blueprint.
|
||
/// The discriminator is `ends_with(".json") && Path::is_file()`; a regression that
|
||
/// dropped the `is_file()` conjunct (suffix-only) would send this down the
|
||
/// blueprint-read branch and fail with a file-read error instead — a different,
|
||
/// misleading refusal. (#159 cut 4: the built-in `--harness` branch retired, so the
|
||
/// no-blueprint arm now emits the loaded-blueprint usage line unconditionally; the
|
||
/// assertion is repointed onto that line, still distinct from a file-read error.)
|
||
/// The positive `.json`-file path stays covered by
|
||
/// `aura_run_loads_and_runs_a_blueprint_file`.
|
||
#[test]
|
||
fn dual_grammar_discriminator_requires_an_existing_file_not_just_json_suffix() {
|
||
let out = Command::new(BIN)
|
||
.args(["run", "definitely-not-a-real-file.json"])
|
||
.output()
|
||
.expect("spawn aura run <nonexistent>.json");
|
||
assert_eq!(
|
||
out.status.code(),
|
||
Some(2),
|
||
"a .json name that is not a file must be refused (exit 2), got {:?}; stderr: {}",
|
||
out.status,
|
||
String::from_utf8_lossy(&out.stderr)
|
||
);
|
||
assert!(out.stdout.is_empty(), "no report on stdout: {:?}", String::from_utf8_lossy(&out.stdout));
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(
|
||
stderr.contains("Usage: aura run <blueprint.json>"),
|
||
"the no-blueprint usage arm (not the blueprint-read path) must handle it: {stderr:?}"
|
||
);
|
||
}
|
||
|
||
/// The exit-code partition (#175 iteration 2, deviation #8): a USAGE error (a
|
||
/// malformed command line) exits 2; a RUNTIME failure (a well-formed command
|
||
/// whose needed data/state is missing) exits 1. Pins the partition as a property.
|
||
/// (#159 cut 4: `run` is blueprint-only now, so the runtime-failure arm needs a
|
||
/// shipped closed blueprint to clear the usage grammar before the no-geometry
|
||
/// refusal fires.)
|
||
#[test]
|
||
fn exit_codes_partition_usage_two_from_runtime_one() {
|
||
// usage: an unknown flag is a command-line error → exit 2
|
||
let usage = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["run", "--bogus"]).output().expect("spawn");
|
||
assert_eq!(usage.status.code(), Some(2),
|
||
"unknown flag is a usage error → 2; stderr: {}", String::from_utf8_lossy(&usage.stderr));
|
||
// runtime: a well-formed command whose symbol has no recorded data → exit 1
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let runtime = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["run", &fixture, "--real", "NONEXISTENT"]).output().expect("spawn");
|
||
assert_eq!(runtime.status.code(), Some(1),
|
||
"no data for a valid command is a runtime failure → 1; stderr: {}",
|
||
String::from_utf8_lossy(&runtime.stderr));
|
||
}
|
||
|
||
/// Characterization pin (byte-identity anchor for the mc R-bootstrap dissolution,
|
||
/// #210, the last verb; argv since migrated to the generic form by #220).
|
||
/// `aura mc <blueprint> --real --axis …` pools the per-window OOS trade-R
|
||
/// series and r-bootstraps E[R] through the campaign
|
||
/// `[std::sweep(argmax), std::walk_forward, std::monte_carlo]`
|
||
/// process, whose terminal monte_carlo stage does the
|
||
/// `StageBootstrap::PooledOos(r_bootstrap(...))` seeded from the campaign seed -- so
|
||
/// the campaign seed carries the mc `--seed`. The wf winners are
|
||
/// deflation-seed-independent (argmax), so the pooled series, and thus the
|
||
/// bootstrap, is stable across that seed remap (the walkforward anchor already
|
||
/// proved winner seed-independence). The multi-point grid makes the per-window
|
||
/// winner selection non-degenerate. The pinned bootstrap grade of this fixed 2025
|
||
/// GER40 invocation ORIGINATES from the pre-#210 inline rolling walk-forward
|
||
/// (`mc_r_bootstrap_report`) under the retired welded `aura mc --strategy r-sma
|
||
/// --real` grammar, and survived both the campaign dissolution and the #220 argv
|
||
/// migration byte-identically (the acceptance gate then, the regression anchor
|
||
/// now). Gated on the GER40 archive; skips
|
||
/// cleanly on a data refusal.
|
||
#[test]
|
||
fn mc_r_bootstrap_real_e2e_pins_the_exact_current_grade() {
|
||
const FROM_MS: &str = "1735689600000";
|
||
const TO_MS: &str = "1767225599000";
|
||
let (cwd, _g) = fresh_project();
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"mc", &fixture, "--real", "GER40",
|
||
"--axis", "sma_signal.fast.length=3,5", "--axis", "sma_signal.slow.length=12,20",
|
||
"--stop-length", "14", "--stop-k", "2.0",
|
||
"--block-len", "5", "--resamples", "1000", "--seed", "42",
|
||
"--from", FROM_MS, "--to", TO_MS,
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura");
|
||
if out.status.code() == Some(1) {
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(
|
||
stderr.contains("no local data") || stderr.contains("no recorded geometry"),
|
||
"exit 1 must be a data refusal, got: {stderr}"
|
||
);
|
||
eprintln!("skip: no local GER40 data");
|
||
return;
|
||
}
|
||
assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status);
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
let grade_line = stdout
|
||
.lines()
|
||
.find(|l| l.starts_with("{\"mc_r_bootstrap\":"))
|
||
.unwrap_or_else(|| panic!("the mc_r_bootstrap grade line: {stdout}"));
|
||
let v: serde_json::Value = serde_json::from_str(grade_line).expect("grade line parses as JSON");
|
||
let mc = &v["mc_r_bootstrap"];
|
||
assert_eq!(mc["block_len"].as_u64(), Some(5), "block_len: {grade_line}");
|
||
assert_eq!(mc["n_resamples"].as_u64(), Some(1000), "n_resamples: {grade_line}");
|
||
assert_eq!(mc["n_trades"].as_u64(), Some(20681), "pooled OOS trade count: {grade_line}");
|
||
// EXACT bootstrap floats -- the byte-identity anchor the dissolution must preserve.
|
||
assert_eq!(mc["e_r"]["mean"].as_f64(), Some(-0.0025753095301594307), "E[R] mean: {grade_line}");
|
||
assert_eq!(mc["e_r"]["p50"].as_f64(), Some(-0.0023049902245975227), "E[R] median: {grade_line}");
|
||
assert_eq!(mc["prob_le_zero"].as_f64(), Some(0.558), "P(E[R] <= 0): {grade_line}");
|
||
}
|
||
|
||
/// Property (#220 mc vertical): `aura mc --real` R-bootstraps ANY sweepable
|
||
/// blueprint through the campaign path, not just the historical r-sma candidate
|
||
/// — the whole point of dropping `--strategy r-sma`/`--fast`/`--slow` for a
|
||
/// positional blueprint + generic `--axis`. Every other mc E2E test here still
|
||
/// exercises `r_sma.json`; a regression that silently special-cased the
|
||
/// pipeline's axis lookup back to a hardcoded `sma_signal.*` namespace would
|
||
/// ship green there while breaking every other blueprint. This runs the
|
||
/// unrelated r-breakout candidate (the ganged `channel_length` axis, no
|
||
/// `sma_signal` node in sight) end-to-end and asserts a well-formed
|
||
/// `mc_r_bootstrap` grade: the argv-echoed knobs (`block_len`/`n_resamples`)
|
||
/// pin exactly, `n_trades`/`e_r.mean`/`prob_le_zero` are asserted present and
|
||
/// well-typed (their exact values are strategy-specific, already the r-sma
|
||
/// pin's job above). Gated on the shared GER40 archive; skips cleanly on a
|
||
/// data refusal.
|
||
#[test]
|
||
fn mc_dissolves_a_non_r_sma_blueprint() {
|
||
const FROM_MS: &str = "1735689600000";
|
||
const TO_MS: &str = "1767225599000";
|
||
let (cwd, _g) = fresh_project();
|
||
let fixture = format!("{}/tests/fixtures/r_breakout_open.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"mc", &fixture, "--real", "GER40",
|
||
"--axis", "r_breakout_signal.channel_length=10,20",
|
||
"--stop-length", "14", "--stop-k", "2.0",
|
||
"--block-len", "5", "--resamples", "1000", "--seed", "42",
|
||
"--from", FROM_MS, "--to", TO_MS,
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura");
|
||
if out.status.code() == Some(1) {
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(
|
||
stderr.contains("no local data") || stderr.contains("no recorded geometry"),
|
||
"exit 1 must be a data refusal, got: {stderr}"
|
||
);
|
||
eprintln!("skip: no local GER40 data");
|
||
return;
|
||
}
|
||
assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status);
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
let grade_line = stdout
|
||
.lines()
|
||
.find(|l| l.starts_with("{\"mc_r_bootstrap\":"))
|
||
.unwrap_or_else(|| panic!("the mc_r_bootstrap grade line: {stdout}"));
|
||
let v: serde_json::Value = serde_json::from_str(grade_line).expect("grade line parses as JSON");
|
||
let mc = &v["mc_r_bootstrap"];
|
||
assert_eq!(mc["block_len"].as_u64(), Some(5), "block_len (argv echo): {grade_line}");
|
||
assert_eq!(mc["n_resamples"].as_u64(), Some(1000), "n_resamples (argv echo): {grade_line}");
|
||
assert!(mc["n_trades"].as_u64().is_some_and(|n| n > 0), "pooled OOS trade count present: {grade_line}");
|
||
assert!(mc["e_r"]["mean"].as_f64().is_some_and(|m| m.is_finite()), "E[R] mean present + finite: {grade_line}");
|
||
assert!(
|
||
mc["prob_le_zero"].as_f64().is_some_and(|p| (0.0..=1.0).contains(&p)),
|
||
"P(E[R] <= 0) present + in [0,1]: {grade_line}"
|
||
);
|
||
}
|
||
|
||
/// Property: real-data `aura mc <blueprint> --real --axis …` (the #210 dissolution
|
||
/// of the retired welded `--strategy r-sma` form, argv since migrated by #220) is
|
||
/// thin sugar over the one campaign
|
||
/// path — a successful run durably auto-registers exactly one generated process
|
||
/// document, one generated campaign document (carrying the constant "mc" name and the
|
||
/// stop as a non-empty single risk regime), and one campaign-run record, and emits the
|
||
/// single `mc_r_bootstrap` grade line. The pipeline's walk_forward stage persists
|
||
/// the one family; the leading `std::grid` stage only enumerates (#256) and the
|
||
/// terminal monte_carlo stage is an annotator — neither adds a family. This is
|
||
/// the observable proof the inline path is gone
|
||
/// and the dissolution runs through the executor. Gated on the GER40 2025 archive;
|
||
/// skips cleanly on a data refusal.
|
||
#[test]
|
||
fn mc_dissolves_through_the_campaign_path() {
|
||
const FROM_MS: &str = "1735689600000";
|
||
const TO_MS: &str = "1767225599000";
|
||
let (cwd, _g) = fresh_project();
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"mc", &fixture, "--real", "GER40",
|
||
"--axis", "sma_signal.fast.length=3,5", "--axis", "sma_signal.slow.length=12,20",
|
||
"--stop-length", "14", "--stop-k", "2.0",
|
||
"--block-len", "5", "--resamples", "1000", "--seed", "42",
|
||
"--from", FROM_MS, "--to", TO_MS,
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura");
|
||
if out.status.code() == Some(1) {
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(
|
||
stderr.contains("no local data") || stderr.contains("no recorded geometry"),
|
||
"exit 1 must be a data refusal, got: {stderr}"
|
||
);
|
||
eprintln!("skip: no local GER40 data");
|
||
return;
|
||
}
|
||
assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status);
|
||
|
||
// the single grade line is emitted (and nothing else on stdout).
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
assert_eq!(stdout.lines().count(), 1, "mc prints exactly one grade line: {stdout:?}");
|
||
assert!(
|
||
stdout.lines().next().is_some_and(|l| l.starts_with("{\"mc_r_bootstrap\":")),
|
||
"the one line is the mc_r_bootstrap grade: {stdout}"
|
||
);
|
||
|
||
let count = |sub: &str| {
|
||
std::fs::read_dir(cwd.join("runs").join(sub))
|
||
.map(|d| d.count())
|
||
.unwrap_or(0)
|
||
};
|
||
assert_eq!(count("processes"), 1, "one generated process document registered");
|
||
assert_eq!(count("campaigns"), 1, "one generated campaign document registered");
|
||
let runs_log = std::fs::read_to_string(cwd.join("runs").join("campaign_runs.jsonl"))
|
||
.expect("campaign_runs.jsonl exists after a sugar run");
|
||
assert_eq!(runs_log.lines().count(), 1, "one campaign run recorded");
|
||
|
||
let campaigns_dir = cwd.join("runs").join("campaigns");
|
||
let campaign_doc_path = std::fs::read_dir(&campaigns_dir)
|
||
.expect("campaigns dir exists")
|
||
.next()
|
||
.expect("exactly one campaign document")
|
||
.expect("readable dir entry")
|
||
.path();
|
||
let campaign_doc_json = std::fs::read_to_string(&campaign_doc_path).expect("read campaign doc");
|
||
assert!(
|
||
campaign_doc_json.contains("\"name\":\"mc\""),
|
||
"the generated campaign document carries the constant mc name: {campaign_doc_json}"
|
||
);
|
||
assert!(
|
||
campaign_doc_json.contains("\"risk\":[")
|
||
&& campaign_doc_json.contains("\"length\":14")
|
||
&& campaign_doc_json.contains("\"k\":2.0"),
|
||
"the stop rides a non-empty risk regime: {campaign_doc_json}"
|
||
);
|
||
|
||
let fams = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["runs", "families"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn families");
|
||
let fams_out = String::from_utf8_lossy(&fams.stdout).into_owned();
|
||
assert_eq!(
|
||
fams_out.lines().filter(|l| l.contains("\"kind\":\"Sweep\"")).count(),
|
||
0,
|
||
"the enumerate-only std::grid leading stage persists no Sweep family: {fams_out}"
|
||
);
|
||
assert_eq!(
|
||
fams_out.lines().filter(|l| l.contains("\"kind\":\"WalkForward\"")).count(),
|
||
1,
|
||
"one WalkForward family holding the per-window OOS reports: {fams_out}"
|
||
);
|
||
assert_eq!(
|
||
fams_out.lines().filter(|l| l.contains("\"kind\":\"MonteCarlo\"")).count(),
|
||
0,
|
||
"the terminal monte_carlo stage is an annotator, not a family: {fams_out}"
|
||
);
|
||
}
|
||
|
||
/// Property (#239): a real-data `aura mc --real` invocation over a window SHORTER
|
||
/// than the fixed 90-day-in-sample / 30-day-out-of-sample roller runs the R-bootstrap
|
||
/// to completion by fitting the injected walk-forward roller down to the campaign
|
||
/// window, instead of refusing late at the executor with "walk_forward windows do not
|
||
/// fit the campaign window" (exit 1, no remedy reachable from the mc surface). The
|
||
/// short window is a FIXED ~30 days (2025-04-16..2025-05-16, the tail of the
|
||
/// ~135-day span the walkforward e2e tests use): what the property needs is "a
|
||
/// data-carrying window far shorter than the roller", which a fixed recent window
|
||
/// satisfies without the full-archive probe sweep a live-span derivation costs —
|
||
/// the archive only grows forward, so the window never falls off its end. The
|
||
/// leading std::grid stage only enumerates (nothing runs over the short window); the
|
||
/// refusal fires at stage 1 (the walk_forward roller), so this bites only when the
|
||
/// window genuinely has data. Gated on the GER40 archive; skips cleanly on a data-less host.
|
||
#[test]
|
||
fn mc_real_fits_the_injected_roller_to_a_short_window() {
|
||
if !local_data_present() {
|
||
eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH);
|
||
return;
|
||
}
|
||
let (cwd, _g) = fresh_project();
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
|
||
// ~30 days, far shorter than the fixed 90+30-day roller, so the injected
|
||
// walk_forward cannot fit unless it scales down to the window.
|
||
const SUB_FROM_MS: &str = "1744761600000";
|
||
const SUB_TO_MS: &str = "1747353600000";
|
||
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"mc", &fixture, "--real", "GER40",
|
||
"--axis", "sma_signal.fast.length=3,5", "--axis", "sma_signal.slow.length=12,20",
|
||
"--stop-length", "14", "--stop-k", "2.0",
|
||
"--block-len", "5", "--resamples", "100", "--seed", "42",
|
||
"--from", SUB_FROM_MS, "--to", SUB_TO_MS,
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura mc");
|
||
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
// Defensive: a genuine data refusal (never the late window-fit refusal this test
|
||
// pins) still means "no usable data on this host" — skip, don't fail.
|
||
if out.status.code() == Some(1)
|
||
&& (stderr.contains("no local data") || stderr.contains("no recorded geometry"))
|
||
{
|
||
eprintln!("skip: no local GER40 data");
|
||
return;
|
||
}
|
||
assert_eq!(
|
||
out.status.code(), Some(0),
|
||
"a short real window must fit the injected roller and run to completion, \
|
||
not refuse late; stderr: {stderr}"
|
||
);
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
assert!(
|
||
stdout.lines().any(|l| l.starts_with("{\"mc_r_bootstrap\":")),
|
||
"the mc_r_bootstrap grade line must be emitted for the fitted short window: {stdout}"
|
||
);
|
||
}
|
||
|
||
/// Fork A: the stop is a single risk regime on the dissolved mc path — a multi-value
|
||
/// `--stop-length`/`--stop-k` is a usage refusal (exit 2), not a swept axis. Mirrors
|
||
/// `walkforward_dissolved_refuses_a_multi_value_stop`. NOT gated — the refusal is pure
|
||
/// argument parsing, before any data access.
|
||
#[test]
|
||
fn mc_dissolved_refuses_a_multi_value_stop() {
|
||
let cwd = temp_cwd("mc-multi-stop");
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"mc", &fixture, "--real", "GER40",
|
||
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
|
||
"--stop-length", "14,20", "--stop-k", "2.0",
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura");
|
||
assert_eq!(out.status.code(), Some(2), "multi-value stop is a usage refusal");
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(stderr.contains("invalid value"), "stop-regime refusal: {stderr}");
|
||
}
|
||
|
||
/// Property: an `--axis` name that is not among the loaded blueprint's sweepable
|
||
/// axes is refused before the raw-namespace strip or any archive access (exit 2),
|
||
/// echoing exactly the name the user typed — mc shares the identical WRAPPED-probe
|
||
/// preflight `walkforward_dissolved_refuses_an_unknown_axis_name` pins for
|
||
/// walkforward (both dispatchers now call the same `validate_and_register_axes`
|
||
/// helper); this test pins mc's own copy so a future edit cannot silently break it
|
||
/// while walkforward's stays green. Data-free: no archive/store access.
|
||
#[test]
|
||
fn mc_dissolved_refuses_an_unknown_axis_name() {
|
||
let cwd = temp_cwd("mc-unknown-axis");
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"mc", &fixture, "--real", "GER40",
|
||
"--axis", "sma_signal.nope=3", "--axis", "sma_signal.slow.length=12",
|
||
"--stop-length", "14", "--stop-k", "2.0",
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura");
|
||
assert_eq!(out.status.code(), Some(2), "an unknown axis name must exit 2");
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(
|
||
stderr.contains("axis \"sma_signal.nope\"") && stderr.contains("sweepable axes"),
|
||
"must name the unresolved axis exactly as typed: {stderr}"
|
||
);
|
||
assert!(
|
||
out.stdout.is_empty(),
|
||
"the refusal must not leak an aggregate to stdout, got: {:?}",
|
||
String::from_utf8_lossy(&out.stdout)
|
||
);
|
||
assert!(!cwd.join("runs").exists(), "a refused axis name must not start a real run");
|
||
}
|
||
|
||
/// Property: the one load-bearing decision new to the mc dissolution — `translate_mc`
|
||
/// maps `campaign.seed` to the mc `--seed` (unlike `translate_walkforward`, which
|
||
/// hardcodes `seed: 0`) — actually flows through the executor to the emitted grade,
|
||
/// not just into the generated document's content id (already unit-pinned by
|
||
/// `translate_mc_diverging_seeds_and_stops_do_not_collide_content_ids`). Two runs that
|
||
/// differ ONLY in `--seed` must emit a different `e_r` distribution (the bootstrap
|
||
/// resample draw is seeded) while pooling the IDENTICAL `n_trades` (the wf winners are
|
||
/// argmax-selected, hence deflation-seed-independent — the walkforward anchor already
|
||
/// proved this for the shared roller): the seed perturbs the resample, never the
|
||
/// underlying OOS trade-R series. Without this test, a dropped seed remap (e.g. mc
|
||
/// always bootstrapping at a fixed internal seed) would still pass every other mc
|
||
/// dissolution test, since none of them compare two distinct `--seed` runs. Gated on
|
||
/// the GER40 2025 archive; skips cleanly on a data refusal.
|
||
#[test]
|
||
fn mc_dissolved_seed_changes_the_bootstrap_draw_not_the_pooled_series() {
|
||
const FROM_MS: &str = "1735689600000";
|
||
const TO_MS: &str = "1767225599000";
|
||
let (cwd, _g) = fresh_project();
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let run = |seed: &str| {
|
||
std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"mc", &fixture, "--real", "GER40",
|
||
"--axis", "sma_signal.fast.length=3,5", "--axis", "sma_signal.slow.length=12,20",
|
||
"--stop-length", "14", "--stop-k", "2.0",
|
||
"--block-len", "5", "--resamples", "1000", "--seed", seed,
|
||
"--from", FROM_MS, "--to", TO_MS,
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura")
|
||
};
|
||
let out42 = run("42");
|
||
if out42.status.code() == Some(1) {
|
||
let stderr = String::from_utf8_lossy(&out42.stderr);
|
||
assert!(
|
||
stderr.contains("no local data") || stderr.contains("no recorded geometry"),
|
||
"exit 1 must be a data refusal, got: {stderr}"
|
||
);
|
||
eprintln!("skip: no local GER40 data");
|
||
return;
|
||
}
|
||
let out7 = run("7");
|
||
assert_eq!(out42.status.code(), Some(0), "exit (seed 42): {:?}", out42.status);
|
||
assert_eq!(out7.status.code(), Some(0), "exit (seed 7): {:?}", out7.status);
|
||
|
||
let parse = |out: &std::process::Output| -> serde_json::Value {
|
||
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
|
||
let line = stdout
|
||
.lines()
|
||
.find(|l| l.starts_with("{\"mc_r_bootstrap\":"))
|
||
.unwrap_or_else(|| panic!("the mc_r_bootstrap grade line: {stdout}"))
|
||
.to_string();
|
||
serde_json::from_str(&line).expect("grade line parses as JSON")
|
||
};
|
||
let v42 = parse(&out42);
|
||
let v7 = parse(&out7);
|
||
|
||
assert_eq!(
|
||
v42["mc_r_bootstrap"]["n_trades"], v7["mc_r_bootstrap"]["n_trades"],
|
||
"the pooled OOS trade-R series is seed-independent (argmax winners): {v42} vs {v7}"
|
||
);
|
||
assert_ne!(
|
||
v42["mc_r_bootstrap"]["e_r"]["mean"], v7["mc_r_bootstrap"]["e_r"]["mean"],
|
||
"a different --seed must move the resampled E[R] mean: {v42} vs {v7}"
|
||
);
|
||
}
|
||
|
||
/// #220 weld-gone proof: the walkforward campaign path runs an arbitrary user
|
||
/// blueprint with axis names the CLI has never heard of — the r-sma weld
|
||
/// (hardcoded fast/slow) is structurally gone. Gated on the GER40 archive;
|
||
/// skips cleanly when absent.
|
||
#[test]
|
||
fn walkforward_campaign_runs_an_arbitrary_blueprint() {
|
||
if !local_data_present() {
|
||
eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH);
|
||
return;
|
||
}
|
||
const FROM_MS: &str = "1735689600000";
|
||
const TO_MS: &str = "1767225599000";
|
||
let (cwd, _g) = fresh_project();
|
||
let fixture = format!("{}/tests/fixtures/r_breakout_open.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"walkforward", &fixture, "--real", "GER40",
|
||
"--axis", "r_breakout_signal.channel_length=10,20",
|
||
"--stop-length", "3", "--stop-k", "2.0",
|
||
"--from", FROM_MS, "--to", TO_MS,
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura");
|
||
if out.status.code() == Some(1) {
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(
|
||
stderr.contains("no local data") || stderr.contains("no recorded geometry"),
|
||
"exit 1 must be a data refusal, got: {stderr}"
|
||
);
|
||
eprintln!("skip: no local GER40 data");
|
||
return;
|
||
}
|
||
assert_eq!(
|
||
out.status.code(),
|
||
Some(0),
|
||
"exit: {:?} stderr: {}",
|
||
out.status,
|
||
String::from_utf8_lossy(&out.stderr)
|
||
);
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
let grade_line = stdout
|
||
.lines()
|
||
.find(|l| l.starts_with("{\"walkforward\":"))
|
||
.unwrap_or_else(|| panic!("the walkforward summary line: {stdout}"));
|
||
let v: serde_json::Value = serde_json::from_str(grade_line).expect("summary line parses as JSON");
|
||
let wf = &v["walkforward"];
|
||
assert!(wf["windows"].as_u64().is_some_and(|w| w >= 1), "at least one window: {grade_line}");
|
||
// param_stability rows: channel_length, stop_length, stop_k.
|
||
let ps = wf["param_stability"].as_array().expect("param_stability is an array");
|
||
assert_eq!(ps.len(), 3, "one ganged channel axis + the stop pair: {grade_line}");
|
||
}
|
||
|
||
/// #220 weld-gone proof for mc: the R-bootstrap pipeline runs the same
|
||
/// arbitrary breakout blueprint. Gated on the GER40 archive.
|
||
#[test]
|
||
fn mc_campaign_runs_an_arbitrary_blueprint() {
|
||
if !local_data_present() {
|
||
eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH);
|
||
return;
|
||
}
|
||
const FROM_MS: &str = "1735689600000";
|
||
const TO_MS: &str = "1767225599000";
|
||
let (cwd, _g) = fresh_project();
|
||
let fixture = format!("{}/tests/fixtures/r_breakout_open.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"mc", &fixture, "--real", "GER40",
|
||
"--axis", "r_breakout_signal.channel_length=10,20",
|
||
"--stop-length", "3", "--stop-k", "2.0",
|
||
"--block-len", "5", "--resamples", "100", "--seed", "7",
|
||
"--from", FROM_MS, "--to", TO_MS,
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura");
|
||
if out.status.code() == Some(1) {
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(
|
||
stderr.contains("no local data") || stderr.contains("no recorded geometry"),
|
||
"exit 1 must be a data refusal, got: {stderr}"
|
||
);
|
||
eprintln!("skip: no local GER40 data");
|
||
return;
|
||
}
|
||
assert_eq!(
|
||
out.status.code(),
|
||
Some(0),
|
||
"exit: {:?} stderr: {}",
|
||
out.status,
|
||
String::from_utf8_lossy(&out.stderr)
|
||
);
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
let grade_line = stdout
|
||
.lines()
|
||
.find(|l| l.starts_with("{\"mc_r_bootstrap\":"))
|
||
.unwrap_or_else(|| panic!("the mc_r_bootstrap grade line: {stdout}"));
|
||
let v: serde_json::Value = serde_json::from_str(grade_line).expect("grade line parses as JSON");
|
||
assert_eq!(v["mc_r_bootstrap"]["n_resamples"].as_u64(), Some(100), "resamples flow: {grade_line}");
|
||
assert!(v["mc_r_bootstrap"]["e_r"]["mean"].is_number(), "bootstrap E[R] present: {grade_line}");
|
||
}
|
||
|
||
/// #220 weld-gone proof for generalize: one mean-reversion candidate graded
|
||
/// across two instruments — two axes with names the CLI has never heard of —
|
||
/// one ganged I64 window and an independent F64 band. Gated on the GER40/USDJPY
|
||
/// Sept-2024 archive.
|
||
#[test]
|
||
fn generalize_campaign_runs_an_arbitrary_blueprint() {
|
||
if !local_data_present() {
|
||
eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH);
|
||
return;
|
||
}
|
||
let (cwd, _g) = fresh_project();
|
||
let fixture = format!("{}/tests/fixtures/r_meanrev_open.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"generalize", &fixture, "--real", "GER40,USDJPY",
|
||
"--axis", "r_meanrev_signal.window=20",
|
||
"--axis", "r_meanrev_signal.band.factor=2.0",
|
||
"--stop-length", "3", "--stop-k", "2.0",
|
||
"--from", GER40_SEPT2024_FROM_MS, "--to", GER40_SEPT2024_TO_MS,
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura");
|
||
if out.status.code() == Some(1) {
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(
|
||
stderr.contains("no local data") || stderr.contains("no recorded geometry"),
|
||
"exit 1 must be a data refusal, got: {stderr}"
|
||
);
|
||
eprintln!("skip: no local GER40/USDJPY data");
|
||
return;
|
||
}
|
||
assert_eq!(
|
||
out.status.code(),
|
||
Some(0),
|
||
"exit: {:?} stderr: {}",
|
||
out.status,
|
||
String::from_utf8_lossy(&out.stderr)
|
||
);
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
assert!(stdout.contains("\"generalize\":"), "aggregate object: {stdout}");
|
||
assert!(stdout.contains("\"n_instruments\":2"), "two instruments graded: {stdout}");
|
||
assert!(stdout.contains("\"worst_case\":"), "worst-case floor present: {stdout}");
|
||
}
|
||
|
||
/// #220: the walkforward campaign path requires >= 1 --axis (exit 2, data-free).
|
||
#[test]
|
||
fn walkforward_campaign_refuses_missing_axis() {
|
||
let cwd = temp_cwd("walkforward-campaign-no-axis");
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["walkforward", &fixture, "--real", "GER40", "--stop-length", "14", "--stop-k", "2.0"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura");
|
||
assert_eq!(out.status.code(), Some(2), "missing --axis is a usage refusal");
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(stderr.contains("requires >= 1 --axis"), "axis-arity refusal: {stderr}");
|
||
}
|
||
|
||
/// #220: mc's two modes are disjoint — --seeds (synthetic seed family) with
|
||
/// --real (campaign pipeline) is a cross-mode usage error (exit 2, data-free).
|
||
#[test]
|
||
fn mc_campaign_refuses_seeds_with_real() {
|
||
let cwd = temp_cwd("mc-campaign-seeds-mix");
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"mc", &fixture, "--real", "GER40", "--seeds", "3",
|
||
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
|
||
"--stop-length", "14", "--stop-k", "2.0",
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura");
|
||
assert_eq!(out.status.code(), Some(2), "--seeds with --real is a cross-mode usage refusal");
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(stderr.contains("Usage: aura mc"), "usage refusal: {stderr}");
|
||
}
|
||
|
||
/// #220: a raw-form axis name is refused before any data access on the migrated
|
||
/// verbs, exactly as sweep's shipped refusal — echoing what the user typed, with
|
||
/// no store litter (mirrors aura_sweep_real_blueprint_refuses_a_raw_form_axis_name_before_data_access).
|
||
#[test]
|
||
fn walkforward_campaign_refuses_a_raw_form_axis_name() {
|
||
let cwd = temp_cwd("walkforward-campaign-raw-axis");
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"walkforward", &fixture, "--real", "GER40",
|
||
"--axis", "fast.length=3,5", "--axis", "sma_signal.slow.length=12,20",
|
||
"--stop-length", "14", "--stop-k", "2.0",
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura");
|
||
assert_eq!(out.status.code(), Some(2), "a raw-form axis name is refused before any data access");
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(stderr.contains("axis \"fast.length\""), "echoes the raw name verbatim: {stderr}");
|
||
assert!(!cwd.join("runs").exists(), "no store litter on the refusal path");
|
||
}
|
||
|
||
/// #220: an unknown axis name is refused at the dispatch boundary on the
|
||
/// migrated verbs (mirrors aura_sweep_rejects_an_unknown_axis).
|
||
#[test]
|
||
fn mc_campaign_refuses_an_unknown_axis() {
|
||
let cwd = temp_cwd("mc-campaign-unknown-axis");
|
||
let fixture = format!("{}/examples/r_breakout.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"mc", &fixture, "--real", "GER40",
|
||
"--axis", "r_breakout_signal.nope.length=1",
|
||
"--stop-length", "14", "--stop-k", "2.0",
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura");
|
||
assert_eq!(out.status.code(), Some(2), "an unknown axis is refused at the dispatch boundary");
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(
|
||
stderr.contains("axis \"r_breakout_signal.nope.length\""),
|
||
"names the offending axis: {stderr}"
|
||
);
|
||
}
|
||
|
||
/// #220: the mc campaign path also requires >= 1 --axis before any archive
|
||
/// access (exit 2, data-free) — the same arity gate walkforward enforces
|
||
/// (`walkforward_campaign_refuses_missing_axis`) and generalize enforces
|
||
/// (`generalize_refuses_no_axis`), but which Task 5 left untested for mc: the
|
||
/// `axes.is_empty()` check lives in `dispatch_mc`'s `--real` arm alone, so a
|
||
/// regression dropping it would otherwise surface only once real data hits the
|
||
/// R-bootstrap pipeline, not at argv-parse time.
|
||
#[test]
|
||
fn mc_campaign_refuses_missing_axis() {
|
||
let cwd = temp_cwd("mc-campaign-no-axis");
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["mc", &fixture, "--real", "GER40", "--stop-length", "14", "--stop-k", "2.0"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura");
|
||
assert_eq!(out.status.code(), Some(2), "missing --axis is a usage refusal");
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(stderr.contains("Usage: aura mc"), "usage refusal: {stderr}");
|
||
assert!(!cwd.join("runs").exists(), "no store litter on the refusal path");
|
||
}
|
||
|
||
/// `aura run` on the shipped closed example reproduces the built-in r-sma grade
|
||
/// (#159): the example is the proven data successor to `--harness r-sma`. Survives
|
||
/// the Cut-1b builder deletion (depends only on the example + generic run path).
|
||
#[test]
|
||
fn shipped_r_sma_example_reproduces_the_builtin_grade() {
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["run", "examples/r_sma.json"])
|
||
.output()
|
||
.expect("spawn aura run example");
|
||
assert_eq!(
|
||
out.status.code(),
|
||
Some(0),
|
||
"exit: {:?} stderr={}",
|
||
out.status,
|
||
String::from_utf8_lossy(&out.stderr)
|
||
);
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8");
|
||
// the same metrics r_sma_single_run_output_golden pins for --harness r-sma
|
||
assert!(stdout.contains("\"expectancy_r\":1.2710005136982836"), "{stdout}");
|
||
assert!(stdout.contains("\"sqn\":3.141496526818299"), "{stdout}");
|
||
assert!(stdout.contains("\"total_pips\":0.34185000000002036"), "{stdout}");
|
||
assert!(stdout.contains("\"n_trades\":3"), "{stdout}");
|
||
}
|
||
|
||
/// Characterization pin (#252, byte-identity anchor before `probe_window`'s
|
||
/// internals swap from a full-window drain to `aura_ingest::archive_extent`'s
|
||
/// boundary-month derivation). Captured against the PRE-#252 drain-based code
|
||
/// over `fresh_project_with_data`'s hermetic two-symbol synthetic archive
|
||
/// (SYMA spans 2024-01..08, SYMB 2024-03..06, weekday 08:00-16:00 UTC bars):
|
||
/// the resolved `campaign_window_ms(full_window)` span persists verbatim into
|
||
/// the generated campaign document's `data.windows[0]`, exactly like the
|
||
/// sibling `generalize_without_explicit_window_resolves_the_intersection_of_all_symbols`
|
||
/// probe. Three cases: SYMA's and SYMB's full (no-window) spans, and an
|
||
/// explicit `--from`/`--to` sub-window straddling three month files
|
||
/// (2024-03-15..2024-05-15, so `filter_files` alone would load 3 files under
|
||
/// the old drain). The first bar of each captured span carries a known -1ms
|
||
/// float round-trip artifact (the synthetic fixture's Delphi-`TDateTime`
|
||
/// pack/unpack in `data-server`'s own record format, not a `probe_window` bug)
|
||
/// — this test does not explain it, only pins it exactly, since the
|
||
/// post-swap code must reproduce the identical bytes (same underlying decoder,
|
||
/// only fewer files touched to reach them).
|
||
#[test]
|
||
fn probe_window_resolves_the_pinned_span_before_the_252_swap() {
|
||
let (cwd, _g) = fresh_project_with_data();
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let window_of = |name: &str| -> (i64, i64) {
|
||
let dir = cwd.join("runs").join("campaigns");
|
||
let path = std::fs::read_dir(&dir)
|
||
.expect("campaigns dir exists after a run")
|
||
.filter_map(|e| e.ok())
|
||
.map(|e| e.path())
|
||
.find(|p| {
|
||
let doc: serde_json::Value =
|
||
serde_json::from_str(&std::fs::read_to_string(p).expect("read campaign doc"))
|
||
.expect("campaign doc parses as JSON");
|
||
doc["name"].as_str() == Some(name)
|
||
})
|
||
.unwrap_or_else(|| panic!("no campaign document named {name:?} in {}", dir.display()));
|
||
let doc: serde_json::Value =
|
||
serde_json::from_str(&std::fs::read_to_string(&path).expect("read campaign doc"))
|
||
.expect("campaign doc parses as JSON");
|
||
let w = &doc["data"]["windows"][0];
|
||
(
|
||
w["from_ms"].as_i64().expect("from_ms is an integer"),
|
||
w["to_ms"].as_i64().expect("to_ms is an integer"),
|
||
)
|
||
};
|
||
let sweep = |symbol: &str, name: &str, from: Option<&str>, to: Option<&str>| {
|
||
let mut args = vec![
|
||
"sweep", fixture.as_str(), "--real", symbol,
|
||
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
|
||
"--name", name,
|
||
];
|
||
if let Some(f) = from {
|
||
args.push("--from");
|
||
args.push(f);
|
||
}
|
||
if let Some(t) = to {
|
||
args.push("--to");
|
||
args.push(t);
|
||
}
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(&args)
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura sweep");
|
||
assert_eq!(
|
||
out.status.code(), Some(0),
|
||
"window-probe sweep {name:?} must run to exit 0: {}",
|
||
String::from_utf8_lossy(&out.stderr)
|
||
);
|
||
};
|
||
|
||
sweep("SYMA", "probe-252-no-window-syma", None, None);
|
||
assert_eq!(
|
||
window_of("probe-252-no-window-syma"),
|
||
(1_704_095_999_999, 1_725_033_540_000),
|
||
"SYMA's full (no-window) resolved span"
|
||
);
|
||
|
||
sweep("SYMB", "probe-252-no-window-symb", None, None);
|
||
assert_eq!(
|
||
window_of("probe-252-no-window-symb"),
|
||
(1_709_279_999_999, 1_719_590_340_000),
|
||
"SYMB's full (no-window) resolved span"
|
||
);
|
||
|
||
// 2024-03-15 00:00 UTC .. 2024-05-15 00:00 UTC: straddles the March, April,
|
||
// and May files (three boundary months under the old file-range drain).
|
||
sweep("SYMA", "probe-252-straddle", Some("1710460800000"), Some("1715731200000"));
|
||
assert_eq!(
|
||
window_of("probe-252-straddle"),
|
||
(1_710_489_599_999, 1_715_702_340_000),
|
||
"explicit sub-window straddling month boundaries, clipped to the archive's actual bars"
|
||
);
|
||
}
|
||
|
||
/// Characterization pin (#252, hostless zero-bar case): a window that falls
|
||
/// entirely on a weekend inside a COVERED month (2024-03-02/03, both inside
|
||
/// SYMA's Jan-Aug 2024 span, weekday-only bars) still refuses with the SAME
|
||
/// exit-1 "no data ... in the requested window" message `probe_window`'s
|
||
/// drain-based emptiness check raises today — `archive_extent` must reach the
|
||
/// identical conclusion by finding zero matching months in range, not by
|
||
/// draining a stream, and the refusal wording is part of what must not move
|
||
/// (mirrors `run_real_empty_window_refusal_names_the_window_not_the_symbol`,
|
||
/// hostless here since the property (an empty resolved sub-window still
|
||
/// refuses) needs no full archive, only a covered-but-empty range).
|
||
#[test]
|
||
fn probe_window_still_refuses_a_hostless_zero_bar_window() {
|
||
let (cwd, _g) = fresh_project_with_data();
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"sweep", &fixture, "--real", "SYMA",
|
||
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
|
||
"--name", "probe-252-zero-bars",
|
||
"--from", "1709337600000", // 2024-03-02 00:00 UTC (Saturday)
|
||
"--to", "1709424000000", // 2024-03-03 00:00 UTC (Sunday)
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura sweep");
|
||
assert_eq!(
|
||
out.status.code(), Some(1),
|
||
"a zero-bar window inside covered archive months still refuses with exit 1: stderr={}",
|
||
String::from_utf8_lossy(&out.stderr)
|
||
);
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(
|
||
stderr.contains("no data") && stderr.contains("window"),
|
||
"must name the empty window, not a missing symbol: {stderr}"
|
||
);
|
||
assert!(
|
||
!cwd.join("runs").join("campaigns").exists()
|
||
|| std::fs::read_dir(cwd.join("runs").join("campaigns")).unwrap().count() == 0,
|
||
"a refused zero-bar window must not register a generated campaign document"
|
||
);
|
||
}
|
||
|
||
/// `aura run` on the shipped closed r-meanrev example (#159 cut 3) actually loads
|
||
/// and grades through the real, separately-linked `aura` binary — not merely
|
||
/// in-process, as `r_meanrev_example_loaded_runs_identically_to_the_carved_signal`
|
||
/// (main.rs) and this test's own grade-equivalence check both do by calling
|
||
/// `run_signal_r`/`blueprint_from_json` inside the *test* binary. This is the
|
||
/// CLI-seam companion `shipped_r_sma_example_reproduces_the_builtin_grade` has for
|
||
/// r-sma: it pins that the `Scale`-based band, freshly added to the closed
|
||
/// vocabulary this iteration, resolves and runs cleanly through the production
|
||
/// vocabulary lookup + arg-parsing path a real invocation takes, not just through
|
||
/// the test harness's direct function calls. The synthetic stream never crosses
|
||
/// the band at this window/k, so the grade is genuinely all-zero (no trade) —
|
||
/// still a deterministic, meaningful pin: a regression that broke `Scale`'s CLI
|
||
/// wiring (e.g. a roster/registration mismatch) would surface as a non-zero exit
|
||
/// or a load error here, not as a metrics drift.
|
||
#[test]
|
||
fn shipped_r_meanrev_example_runs_end_to_end_via_aura_run() {
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["run", "examples/r_meanrev.json"])
|
||
.output()
|
||
.expect("spawn aura run example");
|
||
assert_eq!(
|
||
out.status.code(),
|
||
Some(0),
|
||
"exit: {:?} stderr={}",
|
||
out.status,
|
||
String::from_utf8_lossy(&out.stderr)
|
||
);
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8");
|
||
assert!(stdout.contains("\"topology_hash\":\""), "manifest carries the topology hash: {stdout}");
|
||
assert!(stdout.contains("\"expectancy_r\":0.0"), "{stdout}");
|
||
assert!(stdout.contains("\"n_trades\":0"), "{stdout}");
|
||
assert!(stdout.contains("\"total_pips\":0.0"), "{stdout}");
|
||
}
|
||
|
||
/// Property (#264, coverage cut 1): `aura data coverage <SYMBOL>` surfaces the
|
||
/// archive's month COVERAGE from the monthly file index — the first and last
|
||
/// present month AND every interior missing-month range (a gap) — so the Copper
|
||
/// failure mode is visible up front. Copper had files from 2016-08 but was
|
||
/// missing 2018-01..2018-10, so a first/last-bounds check passed while a
|
||
/// window opening in the hole had no data, aborting a multi-hour campaign
|
||
/// mid-run; a per-symbol gap report would have caught it before the run. Over a
|
||
/// hermetic synthetic archive whose `GAPSYM` has 2024-01..02 and 2024-05..06
|
||
/// present and 2024-03..04 absent, the verb exits 0, frames the span by its
|
||
/// first (2024-01) and last (2024-06) present month, and reports the interior
|
||
/// hole as a single collapsed month-range line `missing: 2024-03..2024-04`
|
||
/// (one line for the whole contiguous gap, as Copper's ten-month hole would be
|
||
/// one line — not one line per missing month).
|
||
#[test]
|
||
fn data_coverage_reports_month_span_and_interior_gap_range() {
|
||
let (cwd, _g) = fresh_project_with_gapped_data();
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["data", "coverage", "GAPSYM"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura data coverage");
|
||
assert_eq!(
|
||
out.status.code(),
|
||
Some(0),
|
||
"exit: {:?}, stderr: {}",
|
||
out.status,
|
||
String::from_utf8_lossy(&out.stderr)
|
||
);
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
// The interior gap is the headline — the Copper failure the verb exists to
|
||
// surface — reported as one collapsed range for the whole contiguous hole.
|
||
assert!(
|
||
stdout.contains("missing: 2024-03..2024-04"),
|
||
"interior gap month-range reported as one line: {stdout}"
|
||
);
|
||
// The span bounds frame the gap: first and last present month.
|
||
assert!(
|
||
stdout.contains("2024-01") && stdout.contains("2024-06"),
|
||
"first (2024-01) and last (2024-06) present month reported: {stdout}"
|
||
);
|
||
}
|
||
|
||
/// Property (#264, list cut 2): `aura data list` enumerates the symbols the
|
||
/// archive contains, one per line, sorted — the discovery step a campaign
|
||
/// author (or an agent operating through the CLI alone, the deployment
|
||
/// posture) needs before `aura data coverage <symbol>`, since without it the
|
||
/// inventory is obtainable only by listing the archive directory outside
|
||
/// aura. The archive root resolves through the same project path as `coverage`
|
||
/// (`Env::data_path` — a project-local `[paths] data` override when set). Over
|
||
/// a hermetic synthetic archive holding two symbols (`SYMA` 2024-01..08 and
|
||
/// `SYMB` 2024-03..06), the verb exits 0 and prints EXACTLY the two symbol
|
||
/// names in ascending order, one per line — `SYMA` before `SYMB`, nothing
|
||
/// else.
|
||
#[test]
|
||
fn data_list_enumerates_archive_symbols_one_per_line_sorted() {
|
||
let (cwd, _g) = fresh_project_with_data();
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["data", "list"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura data list");
|
||
assert_eq!(
|
||
out.status.code(),
|
||
Some(0),
|
||
"exit: {:?}, stderr: {}",
|
||
out.status,
|
||
String::from_utf8_lossy(&out.stderr)
|
||
);
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
// The whole contract: the two archive symbols, sorted, one per line, and
|
||
// nothing else — this is the inventory a campaign author reads before
|
||
// scoping an instrument matrix.
|
||
assert_eq!(stdout, "SYMA\nSYMB\n", "sorted symbol enumeration: {stdout:?}");
|
||
}
|
||
|
||
/// Property (#300): `aura campaign show <CID>` prints the registered
|
||
/// document's exact canonical bytes to stdout — round-tripping them back
|
||
/// through `aura campaign register` re-derives the SAME content id (canonical
|
||
/// bytes are content-id-identical), and the store stays deduplicated at one
|
||
/// document. Gated on the shared GER40 archive; skips cleanly on a data
|
||
/// refusal (same guard shape as
|
||
/// `walkforward_stopless_defaults_the_regime_byte_identically_to_explicit_3_2`).
|
||
#[test]
|
||
fn campaign_show_round_trips_the_registered_document() {
|
||
const FROM_MS: &str = "1735689600000";
|
||
const TO_MS: &str = "1767225599000";
|
||
let (cwd, _g) = fresh_project();
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
|
||
let run = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"walkforward", &fixture, "--real", "GER40",
|
||
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
|
||
"--select", "plateau:mean",
|
||
"--from", FROM_MS, "--to", TO_MS,
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura");
|
||
if run.status.code() == Some(1) {
|
||
let stderr = String::from_utf8_lossy(&run.stderr);
|
||
assert!(
|
||
stderr.contains("no local data") || stderr.contains("no recorded geometry"),
|
||
"exit 1 must be a data refusal, got: {stderr}"
|
||
);
|
||
eprintln!("skip: no local GER40 data");
|
||
return;
|
||
}
|
||
assert_eq!(
|
||
run.status.code(),
|
||
Some(0),
|
||
"walkforward must succeed: {}",
|
||
String::from_utf8_lossy(&run.stderr)
|
||
);
|
||
|
||
let campaigns_dir = cwd.join("runs").join("campaigns");
|
||
let cid = std::fs::read_dir(&campaigns_dir)
|
||
.expect("campaigns dir exists")
|
||
.next()
|
||
.expect("exactly one campaign document")
|
||
.expect("readable dir entry")
|
||
.path()
|
||
.file_stem()
|
||
.expect("campaign filename has a stem")
|
||
.to_string_lossy()
|
||
.into_owned();
|
||
|
||
let show = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["campaign", "show", &cid])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura campaign show");
|
||
assert_eq!(
|
||
show.status.code(),
|
||
Some(0),
|
||
"campaign show must succeed: {}",
|
||
String::from_utf8_lossy(&show.stderr)
|
||
);
|
||
|
||
// Directly pin the #164 no-framing invariant: `show` must print the
|
||
// stored canonical bytes byte-for-byte, with no added trailing newline
|
||
// (a `println!` regression in `show_campaign` would still pass the
|
||
// re-register check below via `campaign_to_json` -> `content_id_of`
|
||
// stripping it back out before hashing, so this comparison is the only
|
||
// thing that would catch it).
|
||
let stored_bytes = std::fs::read(campaigns_dir.join(format!("{cid}.json"))).expect("read stored campaign document");
|
||
assert_eq!(
|
||
show.stdout, stored_bytes,
|
||
"campaign show must print the stored canonical bytes byte-for-byte, no framing"
|
||
);
|
||
|
||
let roundtrip_path = cwd.join("roundtrip.campaign.json");
|
||
std::fs::write(&roundtrip_path, &show.stdout).expect("write roundtrip campaign doc");
|
||
|
||
let register = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["campaign", "register", roundtrip_path.to_str().expect("utf-8 path")])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura campaign register");
|
||
assert_eq!(
|
||
register.status.code(),
|
||
Some(0),
|
||
"re-registering the shown document must succeed: {}",
|
||
String::from_utf8_lossy(®ister.stderr)
|
||
);
|
||
let register_out = String::from_utf8_lossy(®ister.stdout).into_owned();
|
||
assert!(
|
||
register_out.contains(&format!("registered campaign {cid}")),
|
||
"canonical bytes must re-register content-id-identically to {cid}: {register_out}"
|
||
);
|
||
|
||
let count = std::fs::read_dir(&campaigns_dir).expect("campaigns dir exists").count();
|
||
assert_eq!(count, 1, "the shown bytes dedup onto the same one stored document");
|
||
}
|
||
|
||
/// Property (#300): identical to `campaign_show_round_trips_the_registered_document`
|
||
/// but over the generated process document — `aura process show <PID>` prints
|
||
/// canonical bytes that re-register content-id-identically through `aura
|
||
/// process register`, with the store staying deduplicated at one document.
|
||
#[test]
|
||
fn process_show_round_trips_the_registered_document() {
|
||
const FROM_MS: &str = "1735689600000";
|
||
const TO_MS: &str = "1767225599000";
|
||
let (cwd, _g) = fresh_project();
|
||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||
|
||
let run = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"walkforward", &fixture, "--real", "GER40",
|
||
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
|
||
"--select", "plateau:mean",
|
||
"--from", FROM_MS, "--to", TO_MS,
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura");
|
||
if run.status.code() == Some(1) {
|
||
let stderr = String::from_utf8_lossy(&run.stderr);
|
||
assert!(
|
||
stderr.contains("no local data") || stderr.contains("no recorded geometry"),
|
||
"exit 1 must be a data refusal, got: {stderr}"
|
||
);
|
||
eprintln!("skip: no local GER40 data");
|
||
return;
|
||
}
|
||
assert_eq!(
|
||
run.status.code(),
|
||
Some(0),
|
||
"walkforward must succeed: {}",
|
||
String::from_utf8_lossy(&run.stderr)
|
||
);
|
||
|
||
let processes_dir = cwd.join("runs").join("processes");
|
||
let pid = std::fs::read_dir(&processes_dir)
|
||
.expect("processes dir exists")
|
||
.next()
|
||
.expect("exactly one process document")
|
||
.expect("readable dir entry")
|
||
.path()
|
||
.file_stem()
|
||
.expect("process filename has a stem")
|
||
.to_string_lossy()
|
||
.into_owned();
|
||
|
||
let show = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["process", "show", &pid])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura process show");
|
||
assert_eq!(
|
||
show.status.code(),
|
||
Some(0),
|
||
"process show must succeed: {}",
|
||
String::from_utf8_lossy(&show.stderr)
|
||
);
|
||
|
||
// Directly pin the #164 no-framing invariant (see the sibling campaign
|
||
// test's comment): `show` must print the stored canonical bytes
|
||
// byte-for-byte, with no added trailing newline.
|
||
let stored_bytes = std::fs::read(processes_dir.join(format!("{pid}.json"))).expect("read stored process document");
|
||
assert_eq!(
|
||
show.stdout, stored_bytes,
|
||
"process show must print the stored canonical bytes byte-for-byte, no framing"
|
||
);
|
||
|
||
let roundtrip_path = cwd.join("roundtrip.process.json");
|
||
std::fs::write(&roundtrip_path, &show.stdout).expect("write roundtrip process doc");
|
||
|
||
let register = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["process", "register", roundtrip_path.to_str().expect("utf-8 path")])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura process register");
|
||
assert_eq!(
|
||
register.status.code(),
|
||
Some(0),
|
||
"re-registering the shown document must succeed: {}",
|
||
String::from_utf8_lossy(®ister.stderr)
|
||
);
|
||
let register_out = String::from_utf8_lossy(®ister.stdout).into_owned();
|
||
assert!(
|
||
register_out.contains(&format!("registered process {pid}")),
|
||
"canonical bytes must re-register content-id-identically to {pid}: {register_out}"
|
||
);
|
||
|
||
let count = std::fs::read_dir(&processes_dir).expect("processes dir exists").count();
|
||
assert_eq!(count, 1, "the shown bytes dedup onto the same one stored document");
|
||
}
|
||
|
||
/// Property (#300): `show` over an unknown id refuses with the same
|
||
/// project-store-not-found prose the referential tier already uses for a
|
||
/// missing `process.ref`/`strategy.ref` (`ref_fault_prose`'s
|
||
/// `ProcessNotFound`/campaign-equivalent hint shape) — no project run needed,
|
||
/// so this test needs no archive gate.
|
||
#[test]
|
||
fn show_refuses_an_unknown_id_with_the_store_hint() {
|
||
let (cwd, _g) = fresh_project();
|
||
|
||
let campaign_show = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["campaign", "show", "deadbeef"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura campaign show");
|
||
assert_eq!(campaign_show.status.code(), Some(1), "unknown campaign id is a runtime refusal");
|
||
let campaign_stderr = String::from_utf8_lossy(&campaign_show.stderr);
|
||
assert!(
|
||
campaign_stderr.contains("campaign deadbeef not found in the project store"),
|
||
"campaign show stderr: {campaign_stderr}"
|
||
);
|
||
|
||
let process_show = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["process", "show", "deadbeef"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura process show");
|
||
assert_eq!(process_show.status.code(), Some(1), "unknown process id is a runtime refusal");
|
||
let process_stderr = String::from_utf8_lossy(&process_show.stderr);
|
||
assert!(
|
||
process_stderr.contains("process deadbeef not found in the project store"),
|
||
"process show stderr: {process_stderr}"
|
||
);
|
||
}
|
||
|
||
/// Property (#300): `show`'s not-found prose reuses the existing `prefix_hint`
|
||
/// authoring-trap detector (#194) — an id pasted WITH the display-only
|
||
/// `content:` prefix (the form `ref_fault_prose` already prints elsewhere for
|
||
/// an unresolved `process.ref`/`strategy.ref`) still gets the "drop the
|
||
/// 'content:' prefix" hint through the new `show` code path, not a bare
|
||
/// not-found. This is the one authoring mistake the store can name outright,
|
||
/// and it must survive `show` reusing `ref_fault_prose`'s helper rather than
|
||
/// re-deriving its own prose.
|
||
#[test]
|
||
fn show_hints_the_content_prefix_authoring_mistake() {
|
||
let (cwd, _g) = fresh_project();
|
||
|
||
let campaign_show = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["campaign", "show", "content:deadbeef"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura campaign show");
|
||
assert_eq!(campaign_show.status.code(), Some(1), "unresolvable id is a runtime refusal");
|
||
let campaign_stderr = String::from_utf8_lossy(&campaign_show.stderr);
|
||
assert!(
|
||
campaign_stderr.contains("drop the 'content:' prefix"),
|
||
"campaign show must hint the content: authoring mistake: {campaign_stderr}"
|
||
);
|
||
|
||
let process_show = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["process", "show", "content:deadbeef"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura process show");
|
||
assert_eq!(process_show.status.code(), Some(1), "unresolvable id is a runtime refusal");
|
||
let process_stderr = String::from_utf8_lossy(&process_show.stderr);
|
||
assert!(
|
||
process_stderr.contains("drop the 'content:' prefix"),
|
||
"process show must hint the content: authoring mistake: {process_stderr}"
|
||
);
|
||
}
|
||
|
||
/// Property (#300): `show` is scoped to its own document-kind store — a
|
||
/// content id that IS a real, registered document in one store (here: a
|
||
/// minimal sweep-only process) does not resolve through the OTHER verb
|
||
/// (`campaign show`). `show_process`/`show_campaign` are two near-identical
|
||
/// functions written in the same change (both `registry.get_*(id) -> Option
|
||
/// -> print! the bytes or format! a not-found`); this pins that they read
|
||
/// from their own store method and neither accidentally shares the other's
|
||
/// namespace or falls back to a generic content-blob lookup.
|
||
#[test]
|
||
fn show_is_scoped_to_its_own_document_store() {
|
||
let (cwd, _g) = fresh_project();
|
||
const SWEEP_ONLY_PROCESS: &str = r#"{"format_version":1,"kind":"process","name":"knob-sweep-only","pipeline":[{"block":"std::sweep","metric":"sqn_normalized","select":"argmax"}]}"#;
|
||
std::fs::write(cwd.join("knob.process.json"), SWEEP_ONLY_PROCESS).expect("write process doc");
|
||
|
||
let register = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["process", "register", "knob.process.json"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura process register");
|
||
assert_eq!(
|
||
register.status.code(),
|
||
Some(0),
|
||
"process register must succeed: {}",
|
||
String::from_utf8_lossy(®ister.stderr)
|
||
);
|
||
let register_out = String::from_utf8_lossy(®ister.stdout).into_owned();
|
||
let pid = register_out
|
||
.lines()
|
||
.find(|l| l.starts_with("registered process "))
|
||
.expect("register line")
|
||
.trim_start_matches("registered process ")
|
||
.split(' ')
|
||
.next()
|
||
.expect("process id")
|
||
.to_string();
|
||
|
||
let process_show = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["process", "show", &pid])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura process show");
|
||
assert_eq!(
|
||
process_show.status.code(),
|
||
Some(0),
|
||
"process show must find the just-registered process: {}",
|
||
String::from_utf8_lossy(&process_show.stderr)
|
||
);
|
||
|
||
let campaign_show = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["campaign", "show", &pid])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura campaign show");
|
||
assert_eq!(
|
||
campaign_show.status.code(),
|
||
Some(1),
|
||
"a process id must NOT resolve through campaign show: {}",
|
||
String::from_utf8_lossy(&campaign_show.stdout)
|
||
);
|
||
let campaign_stderr = String::from_utf8_lossy(&campaign_show.stderr);
|
||
assert!(
|
||
campaign_stderr.contains(&format!("campaign {pid} not found in the project store")),
|
||
"campaign show stderr: {campaign_stderr}"
|
||
);
|
||
}
|