4de6d5cbad
The stage1 ordinal has been dead vocabulary since the C10 reframe dropped the two-stage research model: a 1 structurally implies a 2 that no longer exists. The family is renamed by its live discriminator - the R yardstick - with members named by their signal, uniform with their signal-named siblings (sma, macd, momentum): - selectors: stage1-r -> r-sma, stage1-breakout -> r-breakout, stage1-meanrev -> r-meanrev (old tokens are usage errors, exit 2 - no silent alias) - identifiers: Strategy::RSma/RBreakout/RMeanRev, HarnessKind::RSma, r_sma_*/r_breakout_*/r_meanrev_*, wrap_r, run_signal_r, RGrid, R_SMA_* - persisted identity: the sma_signal composite (param prefix sma_signal.fast.length / .slow.length; fixtures regenerated; content-ids shift - no test pins a literal hash, the registry parses no record names) - e2e test files git-mv'd to r_sma_e2e / r_breakout_e2e / r_meanrev_e2e - dead Stage-1/Stage-2 prose reworded to post-C10 vocabulary across rustdoc, Cargo.tomls, ledger live lines, and the glossary (historical entries stay; fieldtests corpus untouched) - CLAUDE.md invariant 7 rewritten to record the ratified C10 reframe faithfully (the token-swap alone would have laundered the retired gated-currency/realistic-broker design into unmarked live prose); the unbacked account-mode clause dropped Verification: cargo build/test --workspace green (51 targets), clippy -D warnings clean, doc build clean, acceptance grep gate leaves exactly the one resampling-stage false positive (harness.rs), smoke: --harness r-sma runs, --harness stage1-r exits 2 with the new usage line. Decision log: forks and rationale recorded on the issue (reconciliation + implementation-phase comments). closes #174
4107 lines
203 KiB
Rust
4107 lines
203 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::process::Command;
|
||
|
||
/// 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 = std::env::temp_dir().join(format!("aura-cli-{}-{}", std::process::id(), name));
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
std::fs::create_dir_all(&dir).expect("create temp cwd");
|
||
dir
|
||
}
|
||
|
||
#[test]
|
||
fn run_prints_json_and_exits_zero() {
|
||
let out = Command::new(BIN).arg("run").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(pip_size=0.0001)\""), "got: {line}");
|
||
assert!(line.contains("\"window\":[1,7]"), "got: {line}");
|
||
assert!(line.contains("\"metrics\":{\"total_pips\":"), "got: {line}");
|
||
// the integer sign-flip count is stable across float renderings.
|
||
assert!(line.ends_with("\"bias_sign_flips\":1}}"), "got: {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.
|
||
let out = Command::new(BIN).arg("run").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 — bare `aura run` still emits the
|
||
/// JSON report on stdout and exits 0 — so the strict guard cannot regress the
|
||
/// happy path.
|
||
#[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: bare `aura run` still runs and prints the report.
|
||
let ok = Command::new(BIN).arg("run").output().expect("spawn aura run");
|
||
assert_eq!(
|
||
ok.status.code(),
|
||
Some(0),
|
||
"bare `aura run` 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\":"),
|
||
"bare `aura 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.
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["run", "--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", "--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", "--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() {
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["run", "--real", "EURUSD"])
|
||
.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";
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["run", "--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(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";
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["run", "--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", "tests/fixtures/sma_signal.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).
|
||
#[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("UnknownNodeType"), "names the cause: {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` and the cost flags structurally parseable, so the
|
||
/// dispatch guard must re-reject them — 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.
|
||
#[test]
|
||
fn run_blueprint_rejects_builtin_only_flags_exit_two() {
|
||
for extra in [
|
||
&["--trace", "foo"][..],
|
||
&["--cost-per-trade", "1"][..],
|
||
&["--slip-vol-mult", "1"][..],
|
||
&["--carry-per-cycle", "1"][..],
|
||
] {
|
||
let mut args = vec!["run", "tests/fixtures/sma_signal.json"];
|
||
args.extend_from_slice(extra);
|
||
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)
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn run_trace_persists_taps_and_plain_run_writes_no_traces() {
|
||
let cwd = temp_cwd("run-trace");
|
||
|
||
// plain `aura run` (no --trace) persists nothing to disk.
|
||
let plain = Command::new(BIN).arg("run").current_dir(&cwd).output().expect("spawn aura run");
|
||
assert!(plain.status.success(), "plain run exit: {:?}", plain.status);
|
||
assert!(!cwd.join("runs/traces").exists(), "plain run must write no trace files");
|
||
|
||
// `aura run --trace demo` persists the two taps + index, and still prints the report.
|
||
let traced = Command::new(BIN)
|
||
.args(["run", "--trace", "demo"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura run --trace");
|
||
assert!(traced.status.success(), "traced run exit: {:?}", traced.status);
|
||
let stdout = String::from_utf8(traced.stdout).expect("utf-8 stdout");
|
||
assert!(stdout.trim_end().starts_with("{\"manifest\":{"), "report still printed: {stdout:?}");
|
||
assert!(cwd.join("runs/traces/demo/index.json").exists(), "index.json written");
|
||
assert!(cwd.join("runs/traces/demo/equity.json").exists(), "equity tap written");
|
||
assert!(cwd.join("runs/traces/demo/exposure.json").exists(), "exposure tap written");
|
||
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// Property: a persisted `<tap>.json` is the **columnar SoA form** a downstream
|
||
/// chart can consume, not an opaque blob — it carries the four
|
||
/// `tap`/`kinds`/`ts`/`columns` keys, the equity tap is kind-tagged `["F64"]`
|
||
/// (C7: the base type survives on disk), and the SoA invariant holds: `ts` has one
|
||
/// entry per recorded cycle and EVERY column has exactly that many entries (parallel
|
||
/// arrays, one timestamp per row). This is acceptance criterion 2 at the built-binary
|
||
/// file boundary — the unit round-trip pins the in-memory transpose, but only an
|
||
/// end-to-end run pins that the bytes actually written to disk hold that shape.
|
||
/// Asserts on structure + array-length parity, never on the exact float values
|
||
/// (pip equity's low bits vary build-to-build), so it stays deterministic.
|
||
#[test]
|
||
fn run_trace_persists_columnar_soa_shape_on_disk() {
|
||
let cwd = temp_cwd("run-trace-shape");
|
||
let out = Command::new(BIN)
|
||
.args(["run", "--trace", "demo"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura run --trace");
|
||
assert!(out.status.success(), "traced run exit: {:?}", out.status);
|
||
|
||
let equity = std::fs::read_to_string(cwd.join("runs/traces/demo/equity.json"))
|
||
.expect("read equity.json");
|
||
// The four SoA keys are present, equity is f64-kind-tagged, and the parallel
|
||
// arrays open as expected (the synthetic sample harness has a single column).
|
||
assert!(equity.contains("\"tap\":\"equity\""), "tap name missing: {equity}");
|
||
assert!(equity.contains("\"kinds\":[\"F64\"]"), "F64 kind tag missing: {equity}");
|
||
assert!(equity.contains("\"ts\":["), "ts axis missing: {equity}");
|
||
assert!(equity.contains("\"columns\":[["), "columns array missing: {equity}");
|
||
|
||
// SoA invariant: one timestamp per row, and every column has exactly that many
|
||
// entries. Count the ts entries (the synthetic window is 7 cycles) and the
|
||
// single column's entries by their comma-separated cardinality.
|
||
let ts_body = json_array_body(&equity, "\"ts\":[");
|
||
let col_body = json_array_body(&equity, "\"columns\":[[");
|
||
let ts_len = ts_body.split(',').count();
|
||
let col_len = col_body.split(',').count();
|
||
assert_eq!(ts_len, 7, "synthetic run records 7 cycles; ts was: {ts_body:?}");
|
||
assert_eq!(
|
||
col_len, ts_len,
|
||
"SoA parity broken: ts has {ts_len} entries but the column has {col_len}; file: {equity}"
|
||
);
|
||
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// Property: `index.json` is the run's **table of contents** — it carries the run's
|
||
/// `RunManifest` (so a chart page can show run context: commit, broker, window) and
|
||
/// the tap names in a **deterministic order** (`["equity","exposure"]`). The serve
|
||
/// half (`aura chart`, iteration 2) reads taps in this index order; a regression that
|
||
/// dropped the manifest, or made the tap list order-unstable, would silently corrupt
|
||
/// the chart header / the per-series mapping. Asserts on the manifest's structural
|
||
/// presence (the commit value is the volatile git HEAD, pinned elsewhere) and the
|
||
/// exact deterministic tap order.
|
||
#[test]
|
||
fn run_trace_index_carries_manifest_and_ordered_tap_list() {
|
||
let cwd = temp_cwd("run-trace-index");
|
||
let out = Command::new(BIN)
|
||
.args(["run", "--trace", "demo"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura run --trace");
|
||
assert!(out.status.success(), "traced run exit: {:?}", out.status);
|
||
|
||
let index = std::fs::read_to_string(cwd.join("runs/traces/demo/index.json"))
|
||
.expect("read index.json");
|
||
// The manifest is embedded with its identifying context for the chart header.
|
||
assert!(index.contains("\"manifest\":{\"commit\":\""), "manifest missing: {index}");
|
||
assert!(index.contains("\"window\":[1,7]"), "window missing from manifest: {index}");
|
||
assert!(
|
||
index.contains("\"broker\":\"sim-optimal(pip_size=0.0001)\""),
|
||
"broker label missing: {index}"
|
||
);
|
||
// The tap list is the deterministic read order the serve path depends on.
|
||
assert!(
|
||
index.contains("\"taps\":[\"equity\",\"exposure\"]"),
|
||
"tap order must be deterministic [equity, exposure]: {index}"
|
||
);
|
||
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// Property: `--trace` is an **orthogonal modifier** — it composes with `aura run
|
||
/// --real <SYM>`, not only plain `run`, and is strictly **additive**: the looked-up
|
||
/// instrument pip still reaches stdout byte-for-byte while the same two taps land on
|
||
/// disk. The spec's headline is "one shared persist helper, every run form"; the
|
||
/// existing E2E only exercised the synthetic `run` path. Gated on local GER40 data
|
||
/// (skip cleanly when absent, the project convention) so it never fails on a
|
||
/// data-less machine; the no-data path is the distinct "no local data" refusal.
|
||
#[test]
|
||
fn run_real_with_trace_persists_taps_and_keeps_stdout_unchanged() {
|
||
const FROM_MS: &str = "1725148800000";
|
||
const TO_MS: &str = "1727740799999";
|
||
let cwd = temp_cwd("run-real-trace");
|
||
let out = Command::new(BIN)
|
||
.args(["run", "--real", "GER40", "--from", FROM_MS, "--to", TO_MS, "--trace", "ger"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura run --real --trace");
|
||
|
||
// Skip on a data-less machine: GER40 with no local data takes the
|
||
// distinct no-data path (exit 1, runtime), never the pip-refusal, and writes no traces.
|
||
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 --trace must be the no-data path, got: {stderr}"
|
||
);
|
||
eprintln!("skip: no local GER40 data");
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
return;
|
||
}
|
||
|
||
assert_eq!(out.status.code(), Some(0), "real --trace exit: {:?}", out.status);
|
||
// Additive: the index-pip report still reaches stdout unchanged.
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
let line = stdout.trim_end();
|
||
assert!(line.starts_with("{\"manifest\":{"), "report still printed: {line}");
|
||
assert!(
|
||
line.contains("\"broker\":\"sim-optimal(pip_size=1)\""),
|
||
"real --trace must keep the GER40 index pip on stdout: {line}"
|
||
);
|
||
// And the same two taps landed under the named run dir.
|
||
assert!(cwd.join("runs/traces/ger/index.json").exists(), "index.json written");
|
||
assert!(cwd.join("runs/traces/ger/equity.json").exists(), "equity tap written");
|
||
assert!(cwd.join("runs/traces/ger/exposure.json").exists(), "exposure tap written");
|
||
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// Extract the body of a JSON array opened by `marker` (e.g. `"ts":[`) up to its
|
||
/// closing `]`, by substring — no serde dependency, mirroring the sibling tests'
|
||
/// substring parsing. Used only to count comma-separated entries for the SoA
|
||
/// length-parity check.
|
||
fn json_array_body<'a>(json: &'a str, marker: &str) -> &'a str {
|
||
let start = json.find(marker).expect("array marker present") + marker.len();
|
||
let rest = &json[start..];
|
||
let end = rest.find(']').expect("array is closed");
|
||
&rest[..end]
|
||
}
|
||
|
||
#[test]
|
||
fn chart_emits_self_contained_html_for_a_persisted_run() {
|
||
let cwd = temp_cwd("chart-serve");
|
||
|
||
// persist a run first (iteration 1), then chart it.
|
||
let traced = Command::new(BIN).args(["run", "--trace", "demo"]).current_dir(&cwd).output().expect("spawn run --trace");
|
||
assert!(traced.status.success(), "run --trace exit: {:?}", traced.status);
|
||
|
||
let chart = Command::new(BIN).args(["chart", "demo"]).current_dir(&cwd).output().expect("spawn chart");
|
||
assert!(chart.status.success(), "chart exit: {:?}", chart.status);
|
||
let html = String::from_utf8(chart.stdout).expect("utf-8 html");
|
||
assert!(html.starts_with("<!doctype html>"), "not an HTML doc: {:?}", &html[..html.len().min(40)]);
|
||
assert!(html.contains("window.AURA_TRACES = {"), "AURA_TRACES not injected");
|
||
assert!(html.contains("\"equity\""), "equity series missing from the chart");
|
||
assert!(html.contains("uPlot=function"), "vendored uPlot not inlined");
|
||
|
||
// a missing run is a runtime failure (stderr + exit 1), not a panic.
|
||
let missing = Command::new(BIN).args(["chart", "nope"]).current_dir(&cwd).output().expect("spawn chart nope");
|
||
assert_eq!(missing.status.code(), Some(1), "missing run must exit 1");
|
||
assert!(!missing.status.success());
|
||
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// Property: the `--panels` flag is an honest CLI dispatch axis — `chart <name>`
|
||
/// serves the overlay page and `chart <name> --panels` serves the panels page over
|
||
/// the SAME persisted run. The mode the viewer reads (`AURA_TRACES.mode`) reflects
|
||
/// which arm dispatched: a `--panels`→Overlay miswiring would leave every shape and
|
||
/// render test green while silently ignoring the flag, so this pins it at the
|
||
/// binary's observable stdout.
|
||
#[test]
|
||
fn chart_panels_flag_selects_panels_mode() {
|
||
let cwd = temp_cwd("chart-panels");
|
||
|
||
let traced = Command::new(BIN).args(["run", "--trace", "demo"]).current_dir(&cwd).output().expect("spawn run --trace");
|
||
assert!(traced.status.success(), "run --trace exit: {:?}", traced.status);
|
||
|
||
// default (no flag) -> overlay.
|
||
let overlay = Command::new(BIN).args(["chart", "demo"]).current_dir(&cwd).output().expect("spawn chart");
|
||
assert!(overlay.status.success(), "chart exit: {:?}", overlay.status);
|
||
let overlay_html = String::from_utf8(overlay.stdout).expect("utf-8 html");
|
||
assert!(overlay_html.contains("\"mode\":\"overlay\""), "default chart must serve overlay mode");
|
||
assert!(!overlay_html.contains("\"mode\":\"panels\""), "default chart must not serve panels mode");
|
||
|
||
// --panels -> panels, over the very same persisted run.
|
||
let panels = Command::new(BIN).args(["chart", "demo", "--panels"]).current_dir(&cwd).output().expect("spawn chart --panels");
|
||
assert!(panels.status.success(), "chart --panels exit: {:?}", panels.status);
|
||
let panels_html = String::from_utf8(panels.stdout).expect("utf-8 html");
|
||
assert!(panels_html.contains("\"mode\":\"panels\""), "--panels must serve panels mode");
|
||
assert!(!panels_html.contains("\"mode\":\"overlay\""), "--panels must not fall back to overlay");
|
||
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// Property: `--tap <nonexistent>` on a single-run chart is a CLEAN refusal at the
|
||
/// CLI seam — refuse-don't-guess (C10). The builder's no-tap `Err` is unit-tested,
|
||
/// but this pins the user-facing wiring: `filter_to_tap` returning `Err` must reach
|
||
/// `eprintln!` + `exit(1)`, never a panic and never a chart of zero series. A
|
||
/// regression that swallowed the `Err` (e.g. charting an empty `ChartData`) would
|
||
/// exit 0 with an empty page; this fails it. Archive-local (persists its own run).
|
||
#[test]
|
||
fn chart_tap_nonexistent_on_run_refuses_with_exit_1() {
|
||
let cwd = temp_cwd("chart-tap-missing");
|
||
|
||
// persist a single run; its taps are `equity` / `exposure`, never `nope`.
|
||
let traced = Command::new(BIN).args(["run", "--trace", "demo"]).current_dir(&cwd).output().expect("spawn run --trace");
|
||
assert!(traced.status.success(), "run --trace exit: {:?}", traced.status);
|
||
|
||
let out = Command::new(BIN).args(["chart", "demo", "--tap", "nope"]).current_dir(&cwd).output().expect("spawn chart --tap");
|
||
assert_eq!(out.status.code(), Some(1), "nonexistent tap 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("run has no tap named 'nope'"),
|
||
"expected the no-such-tap refusal message, got: {stderr}"
|
||
);
|
||
// #131: the refusal lists the valid taps so the user can correct the typo
|
||
// (the demo run's taps are `equity` and `exposure`).
|
||
assert!(
|
||
stderr.contains("available:") && stderr.contains("equity") && stderr.contains("exposure"),
|
||
"the refusal should enumerate the available taps, got: {stderr}"
|
||
);
|
||
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// 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);
|
||
}
|
||
|
||
#[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 (#153): the cost-flag units/constraints are discoverable from
|
||
/// `aura run --help` — the appended note states the in-R charge, the per-ENGINE-
|
||
/// cycle carry semantics, the `>= 0` constraint, and that cost flags are r-sma
|
||
/// only. Pre-#153 the usage listed the flags with no unit or scale guidance.
|
||
#[test]
|
||
fn run_help_surfaces_cost_flag_units_note() {
|
||
let out = Command::new(BIN).args(["run", "--help"]).output().expect("spawn aura run --help");
|
||
assert_eq!(out.status.code(), Some(0), "run --help exits 0: {:?}", out.status);
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
// clap wraps `long_help` at the render width, which can split a multi-word phrase
|
||
// across lines; pin the single tokens clap never hyphenates instead of the
|
||
// multi-word "per ENGINE cycle".
|
||
assert!(stdout.contains("ENGINE"), "help must explain carry units: {stdout}");
|
||
assert!(stdout.contains("cycle"), "help must explain the per-cycle carry scale: {stdout}");
|
||
assert!(stdout.contains("r-sma"), "help must state the R-harness requirement: {stdout}");
|
||
}
|
||
|
||
/// Property (#153/#175): a malformed cost value (`--cost-per-trade x`, not a f64) is
|
||
/// a usage error — exit 2 with a Usage line on STDERR and nothing on stdout. Post-clap
|
||
/// migration the terse parse error names the offending flag and points to `--help`
|
||
/// (where the units note lives, pinned by `run_help_surfaces_cost_flag_units_note`);
|
||
/// clap does not inline `long_help` into a parse error, so the multi-word units note
|
||
/// no longer rides the grammar-error path — the pin is the exit code + Usage surface.
|
||
#[test]
|
||
fn run_malformed_cost_value_usage_error_carries_note_on_stderr() {
|
||
let out = Command::new(BIN)
|
||
.args(["run", "--harness", "r-sma", "--cost-per-trade", "x"])
|
||
.output()
|
||
.expect("spawn aura run --cost-per-trade x");
|
||
assert_eq!(out.status.code(), Some(2), "a malformed cost value is a usage error (exit 2); stderr: {}",
|
||
String::from_utf8_lossy(&out.stderr));
|
||
assert!(out.stdout.is_empty(), "a usage error must not emit a report on stdout: {:?}", out.stdout);
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(stderr.contains("--cost-per-trade"), "the parse error names the offending flag: {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}");
|
||
}
|
||
|
||
#[test]
|
||
fn sweep_prints_four_family_json_lines_and_exits_zero() {
|
||
let cwd = temp_cwd("sweep4");
|
||
let out = Command::new(BIN).arg("sweep").current_dir(&cwd).output().expect("spawn aura sweep");
|
||
assert!(out.status.success(), "exit status: {:?}", out.status);
|
||
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
assert_eq!(stdout.lines().count(), 4, "stdout was: {stdout:?}");
|
||
let first = stdout.lines().next().unwrap();
|
||
// each line is now a family member: the assigned `family_id` plus the nested
|
||
// RunReport (C18/C21 lineage). The first run assigns `sweep-0`; the nested
|
||
// report is serialized via `serde_json`, so the manifest's fields lead with
|
||
// `broker` (the commit value is the volatile git HEAD, pinned by params below).
|
||
assert!(first.starts_with("{\"family_id\":\"sweep-0\",\"report\":{\"manifest\":{"), "got: {stdout}");
|
||
assert!(
|
||
first.contains("\"params\":[[\"signals.trend.fast.length\",{\"I64\":2}],[\"signals.trend.slow.length\",{\"I64\":4}],[\"signals.momentum.fast.length\",{\"I64\":2}],[\"signals.momentum.slow.length\",{\"I64\":4}],[\"signals.momentum.signal.length\",{\"I64\":3}],[\"signals.blend.weights[0]\",{\"F64\":1.0}],[\"signals.blend.weights[1]\",{\"F64\":1.0}],[\"bias.scale\",{\"F64\":0.5}]]"),
|
||
"got: {stdout}"
|
||
);
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
|
||
/// Property: `aura mc` runs the built-in Monte-Carlo family end-to-end through the
|
||
/// binary — it prints one member line per seed (carrying the assigned `family_id`)
|
||
/// plus one `mc_aggregate` line, exits 0, and persists the draws as a discoverable
|
||
/// `MonteCarlo` family (C12 axis 4 / C18/C21). Driven through the built binary so
|
||
/// it pins the observable CLI contract, not just the in-crate render helper.
|
||
#[test]
|
||
fn mc_runs_persists_a_monte_carlo_family_and_lists_it() {
|
||
let cwd = temp_cwd("mc-family");
|
||
let out = Command::new(BIN).arg("mc").current_dir(&cwd).output().expect("spawn aura mc");
|
||
assert!(out.status.success(), "mc exit: {:?}", out.status);
|
||
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
let lines: Vec<&str> = stdout.lines().collect();
|
||
// three built-in seeds -> three member lines + one aggregate line.
|
||
assert_eq!(lines.len(), 4, "mc stdout: {stdout:?}");
|
||
for line in &lines[..3] {
|
||
assert!(line.contains("\"family_id\":\"mc-0\""), "member missing family_id: {line}");
|
||
assert!(line.contains("\"seed\":"), "member missing seed: {line}");
|
||
}
|
||
assert!(lines[3].contains("\"mc_aggregate\":"), "missing aggregate line: {}", lines[3]);
|
||
|
||
// the run persisted the draws as a discoverable MonteCarlo family.
|
||
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");
|
||
assert_eq!(fams_out.lines().count(), 1, "families: {fams_out:?}");
|
||
assert!(fams_out.contains("\"family_id\":\"mc-0\""), "families: {fams_out:?}");
|
||
assert!(fams_out.contains("\"kind\":\"MonteCarlo\""), "families: {fams_out:?}");
|
||
assert!(fams_out.contains("\"members\":3"), "families: {fams_out:?}");
|
||
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
#[test]
|
||
fn runs_families_list_and_per_family_rank_across_invocations() {
|
||
let cwd = temp_cwd("runs-flow");
|
||
// two 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.
|
||
for _ in 0..2 {
|
||
let out = Command::new(BIN).arg("sweep").current_dir(&cwd).output().expect("spawn sweep");
|
||
assert!(out.status.success(), "sweep exit: {:?}", out.status);
|
||
}
|
||
|
||
// `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);
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
|
||
/// Property: `aura mc --trace <name>` persists one standalone, round-tripping
|
||
/// run-dir per draw seed (the built-in MC family draws seeds 1,2,3), and a plain
|
||
/// `aura mc` (no --trace) writes no trace tree (opt-in; byte-unchanged path).
|
||
#[test]
|
||
fn mc_trace_persists_a_member_dir_per_seed() {
|
||
let cwd = temp_cwd("mc-trace");
|
||
|
||
// opt-in OFF: plain `aura mc` persists no per-member trace dirs.
|
||
let plain = Command::new(BIN).arg("mc").current_dir(&cwd).output().expect("spawn aura mc");
|
||
assert!(plain.status.success(), "plain mc exit: {:?}", plain.status);
|
||
assert!(!cwd.join("runs/traces").exists(), "plain mc must write no trace files");
|
||
|
||
// opt-in ON: one standalone run-dir per draw seed.
|
||
let traced = Command::new(BIN)
|
||
.args(["mc", "--trace", "mc1"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura mc --trace");
|
||
assert!(traced.status.success(), "traced mc exit: {:?}", traced.status);
|
||
for seed in [1, 2, 3] {
|
||
let dir = cwd.join(format!("runs/traces/mc1/seed{seed}"));
|
||
assert!(dir.join("index.json").exists(), "seed{seed} index.json missing");
|
||
assert!(dir.join("equity.json").exists(), "seed{seed} equity tap missing");
|
||
assert!(dir.join("exposure.json").exists(), "seed{seed} exposure tap missing");
|
||
// the persisted equity tap is columnar SoA (C7): kind tag + ts/column parity.
|
||
let equity = std::fs::read_to_string(dir.join("equity.json")).expect("read equity.json");
|
||
assert!(equity.contains("\"kinds\":[\"F64\"]"), "seed{seed} F64 tag: {equity}");
|
||
let ts_len = json_array_body(&equity, "\"ts\":[").split(',').count();
|
||
let col_len = json_array_body(&equity, "\"columns\":[[").split(',').count();
|
||
assert_eq!(col_len, ts_len, "seed{seed} SoA parity: ts={ts_len} col={col_len}; {equity}");
|
||
}
|
||
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// Property: `aura sweep --trace <name>` persists one standalone, round-tripping
|
||
/// run-dir per grid point. The built-in grid is fast∈{2,3} × slow∈{4,5} = 4
|
||
/// points, content-keyed by the varying axes (signals.trend.fast.length /
|
||
/// signals.trend.slow.length); a plain `aura sweep` writes nothing.
|
||
#[test]
|
||
fn sweep_trace_persists_a_member_dir_per_grid_point() {
|
||
let cwd = temp_cwd("sweep-trace");
|
||
|
||
let plain = Command::new(BIN).arg("sweep").current_dir(&cwd).output().expect("spawn aura sweep");
|
||
assert!(plain.status.success(), "plain sweep exit: {:?}", plain.status);
|
||
assert!(!cwd.join("runs/traces").exists(), "plain sweep must write no trace files");
|
||
|
||
let traced = Command::new(BIN)
|
||
.args(["sweep", "--trace", "swp"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura sweep --trace");
|
||
assert!(traced.status.success(), "traced sweep exit: {:?}", traced.status);
|
||
for key in [
|
||
"signals.trend.fast.length-2_signals.trend.slow.length-4",
|
||
"signals.trend.fast.length-2_signals.trend.slow.length-5",
|
||
"signals.trend.fast.length-3_signals.trend.slow.length-4",
|
||
"signals.trend.fast.length-3_signals.trend.slow.length-5",
|
||
] {
|
||
let dir = cwd.join(format!("runs/traces/swp/{key}"));
|
||
assert!(dir.join("index.json").exists(), "{key} index.json missing");
|
||
assert!(dir.join("equity.json").exists(), "{key} equity tap missing");
|
||
assert!(dir.join("exposure.json").exists(), "{key} exposure tap missing");
|
||
let equity = std::fs::read_to_string(dir.join("equity.json")).expect("read equity.json");
|
||
assert!(equity.contains("\"kinds\":[\"F64\"]"), "{key} F64 tag: {equity}");
|
||
let ts_len = json_array_body(&equity, "\"ts\":[").split(',').count();
|
||
let col_len = json_array_body(&equity, "\"columns\":[[").split(',').count();
|
||
assert_eq!(col_len, ts_len, "{key} SoA parity: ts={ts_len} col={col_len}; {equity}");
|
||
}
|
||
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// Property: `aura walkforward --trace <name>` persists one standalone,
|
||
/// round-tripping run-dir per OOS window (the built-in roll = 3 windows), each
|
||
/// keyed `oos<ns>`; a plain `aura walkforward` writes nothing.
|
||
#[test]
|
||
fn walkforward_trace_persists_a_member_dir_per_oos_window() {
|
||
let cwd = temp_cwd("wf-trace");
|
||
|
||
let plain = Command::new(BIN).arg("walkforward").current_dir(&cwd).output().expect("spawn aura walkforward");
|
||
assert!(plain.status.success(), "plain walkforward exit: {:?}", plain.status);
|
||
assert!(!cwd.join("runs/traces").exists(), "plain walkforward must write no trace files");
|
||
|
||
let traced = Command::new(BIN)
|
||
.args(["walkforward", "--trace", "wf1"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura walkforward --trace");
|
||
assert!(traced.status.success(), "traced walkforward exit: {:?}", traced.status);
|
||
|
||
let base = cwd.join("runs/traces/wf1");
|
||
let mut members: Vec<String> = std::fs::read_dir(&base)
|
||
.expect("read wf1 trace dir")
|
||
.map(|e| e.expect("dir entry").file_name().to_string_lossy().into_owned())
|
||
.collect();
|
||
members.sort();
|
||
assert_eq!(members.len(), 3, "built-in roll = 3 OOS windows -> 3 member dirs; got {members:?}");
|
||
for key in &members {
|
||
assert!(key.starts_with("oos"), "member key must be oos<ns>: {key}");
|
||
let dir = base.join(key);
|
||
assert!(dir.join("index.json").exists(), "{key} index.json missing");
|
||
let equity = std::fs::read_to_string(dir.join("equity.json")).expect("read equity.json");
|
||
assert!(equity.contains("\"kinds\":[\"F64\"]"), "{key} F64 tag: {equity}");
|
||
let ts_len = json_array_body(&equity, "\"ts\":[").split(',').count();
|
||
let col_len = json_array_body(&equity, "\"columns\":[[").split(',').count();
|
||
assert_eq!(col_len, ts_len, "{key} SoA parity: ts={ts_len} col={col_len}; {equity}");
|
||
}
|
||
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// Property (C1 / Fork-C content-derived keys): `aura sweep --trace` is
|
||
/// reproducible — re-running it produces the SAME member-dir set and
|
||
/// BYTE-IDENTICAL tap files. The whole reason member keys are derived from the
|
||
/// grid point's content (not a runtime ordinal counter) is that members run in
|
||
/// parallel across threads (C1: parallelism *across* sims); a thread-race in the
|
||
/// keying or the persisted bytes would silently regress determinism. A runtime
|
||
/// counter would shuffle which member lands in which content-keyed dir run-to-run; this
|
||
/// test fails loudly if that drift is ever reintroduced.
|
||
#[test]
|
||
fn sweep_trace_is_byte_deterministic_across_runs() {
|
||
let collect = |tag: &str| -> std::collections::BTreeMap<String, String> {
|
||
let cwd = temp_cwd(tag);
|
||
let out = Command::new(BIN)
|
||
.args(["sweep", "--trace", "det"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura sweep --trace");
|
||
assert!(out.status.success(), "{tag} sweep exit: {:?}", out.status);
|
||
|
||
// Map each member dir's tap files to their bytes (member_key/file -> content).
|
||
let base = cwd.join("runs/traces/det");
|
||
let mut taps = std::collections::BTreeMap::new();
|
||
for member in std::fs::read_dir(&base).expect("read det trace dir") {
|
||
let member = member.expect("dir entry").file_name().to_string_lossy().into_owned();
|
||
for file in ["index.json", "equity.json", "exposure.json"] {
|
||
let body = std::fs::read_to_string(base.join(&member).join(file))
|
||
.unwrap_or_else(|e| panic!("{tag} read {member}/{file}: {e}"));
|
||
taps.insert(format!("{member}/{file}"), body);
|
||
}
|
||
}
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
taps
|
||
};
|
||
|
||
let first = collect("sweep-det-1");
|
||
let second = collect("sweep-det-2");
|
||
|
||
// Same member-dir set, run-to-run (content-keyed, not order-keyed).
|
||
let first_keys: Vec<&String> = first.keys().collect();
|
||
let second_keys: Vec<&String> = second.keys().collect();
|
||
assert_eq!(first_keys, second_keys, "member-dir set drifted across runs");
|
||
|
||
// Every tap byte-identical across the two runs (the index.json git-commit
|
||
// field is build-identity, constant within one build, so it compares equal
|
||
// here too — this pins run-to-run determinism, C1).
|
||
assert_eq!(first, second, "a traced member's bytes drifted across two runs (C1 violation)");
|
||
}
|
||
|
||
/// Property (#105 generic key, the bool-param proof): `aura sweep --strategy
|
||
/// momentum --trace <name>` sweeps the momentum strategy — a totally different
|
||
/// param surface incl. a bool — and persists one portable member dir per grid
|
||
/// point. Every member-dir name is filesystem-conformant (`[A-Za-z0-9._-]`), the
|
||
/// bool axis shows up (both `longonly.enabled-true` and `-false`), and one member
|
||
/// is chartable.
|
||
#[test]
|
||
fn momentum_sweep_trace_persists_portable_member_dirs_with_the_bool() {
|
||
let cwd = temp_cwd("momentum-trace");
|
||
|
||
let traced = Command::new(BIN)
|
||
.args(["sweep", "--strategy", "momentum", "--trace", "mom"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura sweep --strategy momentum --trace");
|
||
assert!(
|
||
traced.status.success(),
|
||
"momentum sweep exit: {:?}; stderr: {}",
|
||
traced.status,
|
||
String::from_utf8_lossy(&traced.stderr)
|
||
);
|
||
|
||
let base = cwd.join("runs/traces/mom");
|
||
let members: Vec<String> = std::fs::read_dir(&base)
|
||
.expect("read mom trace dir")
|
||
.map(|e| e.expect("dir entry").file_name().to_string_lossy().into_owned())
|
||
.collect();
|
||
assert_eq!(members.len(), 8, "2x2x2 momentum grid = 8 member dirs; got {members:?}");
|
||
for m in &members {
|
||
assert!(
|
||
m.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')),
|
||
"non-portable member dir name: {m}"
|
||
);
|
||
}
|
||
assert!(members.iter().any(|m| m.contains("longonly.enabled-true")), "missing bool=true: {members:?}");
|
||
assert!(members.iter().any(|m| m.contains("longonly.enabled-false")), "missing bool=false: {members:?}");
|
||
|
||
// one member is chartable (the bool sits inside the dir name passed through).
|
||
let one = &members[0];
|
||
let chart = Command::new(BIN)
|
||
.args(["chart", &format!("mom/{one}")])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura chart mom/<member>");
|
||
assert!(chart.status.success(), "chart exit: {:?}", chart.status);
|
||
assert!(
|
||
String::from_utf8_lossy(&chart.stdout).contains("uPlot"),
|
||
"expected a self-contained uPlot page"
|
||
);
|
||
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// Property (#105, the `--strategy` selector at the stdout boundary): `aura sweep
|
||
/// --strategy momentum` SWITCHES the swept surface — it prints exactly the
|
||
/// 8-point momentum family (vs the default SMA arm's 4 points), and every line is
|
||
/// a `RunReport` over the momentum param surface, carrying the bool axis as a
|
||
/// TYPED `{"Bool":...}` param (both `true` and `false` appear across the grid).
|
||
/// The disk-trace test pins the member-dir names; this pins the binary's actual
|
||
/// stdout. A selector miswiring (`momentum`→`SmaCross`) would leave that test
|
||
/// green — the dirs would still differ — while silently printing SMA reports
|
||
/// here; nothing else catches it. Asserts on structure + the typed bool token,
|
||
/// never the volatile commit value or the metric floats, so it stays
|
||
/// deterministic. Run in a fresh temp cwd so the family-store ordinal is stable.
|
||
#[test]
|
||
fn sweep_strategy_momentum_prints_eight_typed_bool_member_lines() {
|
||
let cwd = temp_cwd("sweep-strategy-momentum");
|
||
let out = Command::new(BIN)
|
||
.args(["sweep", "--strategy", "momentum"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura sweep --strategy momentum");
|
||
assert!(
|
||
out.status.success(),
|
||
"momentum sweep exit: {:?}; stderr: {}",
|
||
out.status,
|
||
String::from_utf8_lossy(&out.stderr)
|
||
);
|
||
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
let lines: Vec<&str> = stdout.lines().collect();
|
||
// 2x2x2 momentum grid = 8 member lines (vs the default SMA arm's 4): the
|
||
// selector switched the swept surface, observable at stdout.
|
||
assert_eq!(lines.len(), 8, "momentum sweep must print 8 family lines: {stdout:?}");
|
||
|
||
// Every line is a family member: assigned id + a nested RunReport over the
|
||
// MOMENTUM param surface (the SMA arm carries `signals.trend.*`, never
|
||
// `ema.length`/`longonly.enabled`).
|
||
for line in &lines {
|
||
assert!(line.starts_with("{\"family_id\":\"sweep-0\",\"report\":{\"manifest\":{"), "got: {line}");
|
||
assert!(line.contains("[\"ema.length\","), "momentum surface missing ema.length: {line}");
|
||
assert!(!line.contains("signals.trend.fast.length"), "must not be the SMA surface: {line}");
|
||
}
|
||
|
||
// The bool axis is carried as a TYPED `{"Bool":...}` param, both values
|
||
// present across the 8-point grid — the bool-param proof at the stdout edge.
|
||
assert!(
|
||
stdout.contains("[\"longonly.enabled\",{\"Bool\":true}]"),
|
||
"missing typed Bool=true param: {stdout}"
|
||
);
|
||
assert!(
|
||
stdout.contains("[\"longonly.enabled\",{\"Bool\":false}]"),
|
||
"missing typed Bool=false param: {stdout}"
|
||
);
|
||
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// Property (#105, the bool actually gates — observable on disk): the
|
||
/// `longonly.enabled` param is genuinely WIRED into the momentum harness, not a
|
||
/// no-op knob. For one grid point's two bool members (same `ema.length` +
|
||
/// `bias.scale`, differing only in the bool), the `enabled=true` member
|
||
/// records a long-only exposure stream — every recorded value `>= 0` — while the
|
||
/// otherwise-identical `enabled=false` member records at least one NEGATIVE
|
||
/// exposure over the same deterministic showcase stream. This pins the gate's
|
||
/// effect on the persisted C7 exposure tap end-to-end: the LongOnly unit test
|
||
/// proves `max(x,0)` in isolation, the dir-name test proves the bool reaches the
|
||
/// key, but only this proves the bool changes the OBSERVED run output. A
|
||
/// regression that dropped the gate from the wiring (passing exposure straight to
|
||
/// the broker) would make the two members' exposure taps identical — this fails
|
||
/// loudly. Asserts on the sign (>=0 / <0), never exact floats, so it is
|
||
/// deterministic across builds.
|
||
#[test]
|
||
fn momentum_longonly_true_clamps_exposure_nonnegative_false_keeps_negatives() {
|
||
let cwd = temp_cwd("momentum-longonly-gate");
|
||
let traced = Command::new(BIN)
|
||
.args(["sweep", "--strategy", "momentum", "--trace", "gate"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura sweep --strategy momentum --trace");
|
||
assert!(
|
||
traced.status.success(),
|
||
"momentum --trace exit: {:?}; stderr: {}",
|
||
traced.status,
|
||
String::from_utf8_lossy(&traced.stderr)
|
||
);
|
||
|
||
// One grid point, its two bool members (identical but for the bool).
|
||
let base = cwd.join("runs/traces/gate");
|
||
let enabled = base.join("ema.length-5_bias.scale-1_longonly.enabled-true");
|
||
let disabled = base.join("ema.length-5_bias.scale-1_longonly.enabled-false");
|
||
|
||
let exposures = |dir: &std::path::Path| -> Vec<f64> {
|
||
let body = std::fs::read_to_string(dir.join("exposure.json")).expect("read exposure.json");
|
||
// the single f64 column's comma-separated values (substring parse, no serde).
|
||
json_array_body(&body, "\"columns\":[[")
|
||
.split(',')
|
||
.map(|t| t.trim().parse::<f64>().expect("exposure value is an f64"))
|
||
.collect()
|
||
};
|
||
|
||
let enabled_vals = exposures(&enabled);
|
||
let disabled_vals = exposures(&disabled);
|
||
|
||
// enabled=true is a long-only gate: NO recorded exposure is negative.
|
||
assert!(
|
||
enabled_vals.iter().all(|&x| x >= 0.0),
|
||
"long-only (enabled=true) must clamp short exposure to >= 0; got: {enabled_vals:?}"
|
||
);
|
||
// enabled=false passes through: at least one negative survives over the same
|
||
// showcase stream — proof the bool genuinely changes the run, not a no-op.
|
||
assert!(
|
||
disabled_vals.iter().any(|&x| x < 0.0),
|
||
"pass-through (enabled=false) must keep a short exposure over this stream; got: {disabled_vals:?}"
|
||
);
|
||
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
// --- 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;
|
||
|
||
// EURUSD 2024 full year (UTC inclusive ms): 2024-01-01 00:00:00.000 ..
|
||
// 2024-12-31 23:59:59.999 — a span wide enough for several 90/30/30-day
|
||
// walk-forward windows.
|
||
const EURUSD_2024_FROM_MS: i64 = 1_704_067_200_000;
|
||
const EURUSD_2024_TO_MS: i64 = 1_735_689_599_999;
|
||
|
||
fn local_data_present() -> bool {
|
||
std::path::Path::new(data_server::DEFAULT_DATA_PATH).is_dir()
|
||
}
|
||
|
||
/// Property: `aura sweep --real EURUSD` runs the whole built-in grid over REAL M1
|
||
/// close bars (not the synthetic VecSource), persisting one filesystem-portable
|
||
/// member dir per grid point — the four-point SMA grid lands as four
|
||
/// `[A-Za-z0-9._-]` dirs under `runs/traces/<name>/`, and one of them charts as a
|
||
/// self-contained uPlot page. This pins the real source actually flowing through
|
||
/// the family builders (Tasks 2-4) at the built-binary boundary, gated on local
|
||
/// data so it never fails on a data-less machine.
|
||
#[test]
|
||
fn sweep_real_persists_portable_member_dirs_over_real_bars() {
|
||
if !local_data_present() {
|
||
eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH);
|
||
return;
|
||
}
|
||
let dir = temp_cwd("sweep_real");
|
||
let out = std::process::Command::new(BIN)
|
||
.args(["sweep", "--real", "EURUSD",
|
||
"--from", &EURUSD_JUN2024_FROM_MS.to_string(),
|
||
"--to", &EURUSD_JUN2024_TO_MS.to_string(),
|
||
"--trace", "swpr"])
|
||
.current_dir(&dir).output().unwrap();
|
||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||
let members: Vec<_> = std::fs::read_dir(dir.join("runs/traces/swpr")).unwrap()
|
||
.map(|e| e.unwrap().file_name().into_string().unwrap()).collect();
|
||
assert_eq!(members.len(), 4, "four sweep members: {members:?}");
|
||
assert!(members.iter().all(|k| k.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))),
|
||
"all keys filesystem-portable: {members:?}");
|
||
// one member charts as a uPlot page
|
||
let key = &members[0];
|
||
let chart = std::process::Command::new(BIN)
|
||
.args(["chart", &format!("swpr/{key}")]).current_dir(&dir).output().unwrap();
|
||
assert!(chart.status.success(), "stderr: {}", String::from_utf8_lossy(&chart.stderr));
|
||
assert!(String::from_utf8_lossy(&chart.stdout).contains("uPlot"));
|
||
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// Property: `aura walkforward --real EURUSD` over a full year of real bars rolls
|
||
/// the real-data window roller (90/30/30-day, ns-stamped) into SEVERAL OOS
|
||
/// windows, persisting one `oos<ns>`-keyed member dir per window. Pins that the
|
||
/// windowed real path draws its span from the probed `--from..--to` (not the
|
||
/// synthetic showcase span) and that the roller produces a multi-window family
|
||
/// over real data, gated on the local archive.
|
||
#[test]
|
||
fn walkforward_real_persists_one_oos_member_per_window() {
|
||
if !local_data_present() {
|
||
eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH);
|
||
return;
|
||
}
|
||
let dir = temp_cwd("wf_real");
|
||
// a full year so 90/30/30-day rolling fits several windows
|
||
let out = std::process::Command::new(BIN)
|
||
.args(["walkforward", "--real", "EURUSD",
|
||
"--from", &EURUSD_2024_FROM_MS.to_string(),
|
||
"--to", &EURUSD_2024_TO_MS.to_string(),
|
||
"--trace", "wfr"])
|
||
.current_dir(&dir).output().unwrap();
|
||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||
let members: Vec<_> = std::fs::read_dir(dir.join("runs/traces/wfr")).unwrap()
|
||
.map(|e| e.unwrap().file_name().into_string().unwrap()).collect();
|
||
assert!(members.iter().all(|k| k.starts_with("oos")), "oos<ns> member dirs: {members:?}");
|
||
assert!(members.len() >= 2, "several OOS windows over a year: {members:?}");
|
||
|
||
let _ = std::fs::remove_dir_all(&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.
|
||
#[test]
|
||
fn sweep_real_no_geometry_symbol_refuses_with_exit_1() {
|
||
// not gated: the pip refusal happens before any data access.
|
||
let dir = temp_cwd("sweep_real_refuse");
|
||
let out = std::process::Command::new(BIN)
|
||
.args(["sweep", "--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"));
|
||
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// Property (C1): the same real sweep run twice is byte-identical — re-running
|
||
/// `aura sweep --real EURUSD` over the same bounded window produces an identical
|
||
/// report body. The whole determinism contract rests on a real backtest being
|
||
/// reproducible; a thread-race in the real source ingestion or member fold would
|
||
/// surface as a drift here. Gated on the local archive.
|
||
#[test]
|
||
fn sweep_real_is_byte_deterministic_across_runs() {
|
||
if !local_data_present() {
|
||
eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH);
|
||
return;
|
||
}
|
||
let run = || {
|
||
// a fresh wiped cwd per call -> an empty registry each run, so the
|
||
// `family_id` ordinal is `sweep-0` for both; the raw stdout bodies are
|
||
// directly comparable with no ordinal normalisation needed.
|
||
let dir = temp_cwd("sweep_real_det");
|
||
let o = std::process::Command::new(BIN)
|
||
.args(["sweep", "--real", "EURUSD",
|
||
"--from", &EURUSD_JUN2024_FROM_MS.to_string(),
|
||
"--to", &EURUSD_JUN2024_TO_MS.to_string()])
|
||
.current_dir(&dir).output().unwrap();
|
||
let body = String::from_utf8_lossy(&o.stdout).into_owned();
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
body
|
||
};
|
||
assert_eq!(run(), run(), "same real sweep twice must be byte-identical (C1)");
|
||
}
|
||
|
||
/// 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 `--strategy r-sma` (the R-bootstrap over the pooled OOS R
|
||
/// series, `mc_strategy_r_sma_prints_a_bootstrap_line_deterministically`); a
|
||
/// `--real` without it 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 must exit 2; status: {:?}", out.status);
|
||
assert!(out.stdout.is_empty(), "mc --real must not emit a run on stdout: {:?}", out.stdout);
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(stderr.contains("usage"), "mc --real stderr must carry usage: {stderr:?}");
|
||
// the exclusion is total: no `runs/` registry write either.
|
||
assert!(!dir.join("runs").exists(), "mc --real must not start a real run");
|
||
|
||
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: `aura chart <family>` overlays one series per family member, labelled
|
||
/// by member key. The built-in sweep grid is fast∈{2,3} × slow∈{4,5} = 4 members;
|
||
/// each member key appears as a series name in the injected AURA_TRACES.
|
||
#[test]
|
||
fn chart_of_a_family_overlays_one_series_per_member() {
|
||
let cwd = temp_cwd("chart-family");
|
||
let swept = Command::new(BIN)
|
||
.args(["sweep", "--trace", "fam"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn sweep --trace");
|
||
assert!(swept.status.success(), "sweep --trace exit: {:?}", swept.status);
|
||
|
||
let chart = Command::new(BIN).args(["chart", "fam"]).current_dir(&cwd).output().expect("spawn chart fam");
|
||
assert!(chart.status.success(), "chart family exit: {:?}", chart.status);
|
||
let html = String::from_utf8(chart.stdout).expect("utf-8 html");
|
||
assert!(html.contains("window.AURA_TRACES = {"), "AURA_TRACES not injected");
|
||
for key in [
|
||
"signals.trend.fast.length-2_signals.trend.slow.length-4",
|
||
"signals.trend.fast.length-2_signals.trend.slow.length-5",
|
||
"signals.trend.fast.length-3_signals.trend.slow.length-4",
|
||
"signals.trend.fast.length-3_signals.trend.slow.length-5",
|
||
] {
|
||
assert!(html.contains(key), "member series '{key}' missing from the family chart");
|
||
}
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// Property: the single-run chart path is unchanged — no `--tap` shows ALL taps
|
||
/// (equity + exposure); `--tap equity` filters to one. A regression net for the
|
||
/// "single-run byte-unchanged when no --tap" acceptance bullet.
|
||
#[test]
|
||
fn chart_single_run_tap_filter_keeps_only_the_named_tap() {
|
||
let cwd = temp_cwd("chart-tap");
|
||
let traced = Command::new(BIN).args(["run", "--trace", "solo"]).current_dir(&cwd).output().expect("spawn run --trace");
|
||
assert!(traced.status.success(), "run --trace exit: {:?}", traced.status);
|
||
|
||
// no --tap: both taps present (unchanged behaviour).
|
||
let all = Command::new(BIN).args(["chart", "solo"]).current_dir(&cwd).output().expect("spawn chart");
|
||
assert!(all.status.success());
|
||
let all_html = String::from_utf8(all.stdout).expect("utf-8");
|
||
assert!(all_html.contains("\"equity\""), "equity series missing");
|
||
assert!(all_html.contains("\"exposure\""), "exposure series missing");
|
||
|
||
// --tap equity: only equity remains.
|
||
let one = Command::new(BIN).args(["chart", "solo", "--tap", "equity"]).current_dir(&cwd).output().expect("spawn chart --tap");
|
||
assert!(one.status.success(), "chart --tap exit: {:?}", one.status);
|
||
let one_html = String::from_utf8(one.stdout).expect("utf-8");
|
||
assert!(one_html.contains("\"equity\""), "equity series missing under --tap");
|
||
assert!(!one_html.contains("\"exposure\""), "--tap equity must drop exposure");
|
||
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// Property: a trace name cannot be reused across kinds — the write-guard refuses
|
||
/// it (exit 2), keeping `aura chart <name>` resolution a total function.
|
||
#[test]
|
||
fn trace_name_collision_across_kinds_is_refused() {
|
||
let cwd = temp_cwd("collision");
|
||
|
||
// run then sweep on the same name -> the sweep is refused.
|
||
let run = Command::new(BIN).args(["run", "--trace", "t"]).current_dir(&cwd).output().expect("spawn run --trace");
|
||
assert!(run.status.success(), "run --trace exit: {:?}", run.status);
|
||
let sweep = Command::new(BIN).args(["sweep", "--trace", "t"]).current_dir(&cwd).output().expect("spawn sweep --trace");
|
||
assert_eq!(sweep.status.code(), Some(1), "sweep onto a run name must exit 1");
|
||
|
||
// reverse: sweep then run on a fresh name -> the run is refused.
|
||
let sweep2 = Command::new(BIN).args(["sweep", "--trace", "u"]).current_dir(&cwd).output().expect("spawn sweep --trace u");
|
||
assert!(sweep2.status.success(), "sweep --trace u exit: {:?}", sweep2.status);
|
||
let run2 = Command::new(BIN).args(["run", "--trace", "u"]).current_dir(&cwd).output().expect("spawn run --trace u");
|
||
assert_eq!(run2.status.code(), Some(1), "run onto a family name must exit 1");
|
||
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// Property: the write-guard refuses ONLY cross-kind name reuse — a SAME-kind
|
||
/// overwrite stays Ok. Re-running `aura sweep --trace <name>` onto a name already
|
||
/// held by a family succeeds (exit 0), so the guard does not over-broadly forbid
|
||
/// all reuse. Without this the collision test alone would pass even if the guard
|
||
/// degenerated into "refuse every existing name", silently breaking re-tracing.
|
||
#[test]
|
||
fn same_kind_overwrite_is_allowed_by_the_write_guard() {
|
||
let cwd = temp_cwd("same-kind-overwrite");
|
||
|
||
let first = Command::new(BIN).args(["sweep", "--trace", "fam"]).current_dir(&cwd).output().expect("spawn sweep --trace fam");
|
||
assert!(first.status.success(), "first sweep --trace exit: {:?}", first.status);
|
||
|
||
// Same name, same kind (family -> family): the guard must NOT refuse it.
|
||
let again = Command::new(BIN).args(["sweep", "--trace", "fam"]).current_dir(&cwd).output().expect("spawn sweep --trace fam again");
|
||
assert_eq!(again.status.code(), Some(0), "same-kind family overwrite must stay Ok, got: {:?}; stderr: {}", again.status, String::from_utf8_lossy(&again.stderr));
|
||
|
||
// And the re-traced family is still chartable (name resolution unbroken).
|
||
let chart = Command::new(BIN).args(["chart", "fam"]).current_dir(&cwd).output().expect("spawn chart fam");
|
||
assert!(chart.status.success(), "chart after overwrite exit: {:?}", chart.status);
|
||
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// Property: the family overlay honours a non-default `--tap` — `chart <family>
|
||
/// --tap exposure` overlays the exposure tap of every member, not the default
|
||
/// equity. Pins the family branch's `tap.unwrap_or("equity")` selection: a
|
||
/// regression that ignored `--tap` on families (always equity) would still pass
|
||
/// the default-equity family test, so the explicit non-default tap is its own net.
|
||
#[test]
|
||
fn chart_family_with_tap_overlays_the_named_tap_per_member() {
|
||
let cwd = temp_cwd("chart-family-tap");
|
||
|
||
let swept = Command::new(BIN).args(["sweep", "--trace", "fam"]).current_dir(&cwd).output().expect("spawn sweep --trace");
|
||
assert!(swept.status.success(), "sweep --trace exit: {:?}", swept.status);
|
||
|
||
let chart = Command::new(BIN).args(["chart", "fam", "--tap", "exposure"]).current_dir(&cwd).output().expect("spawn chart fam --tap exposure");
|
||
assert!(chart.status.success(), "chart family --tap exposure exit: {:?}", chart.status);
|
||
let html = String::from_utf8(chart.stdout).expect("utf-8 html");
|
||
assert!(html.contains("window.AURA_TRACES = {"), "AURA_TRACES not injected");
|
||
// One series per member, keyed by member key — the exposure overlay still
|
||
// spans all four grid points (the tap selection changes the column, not the
|
||
// member set).
|
||
for key in [
|
||
"signals.trend.fast.length-2_signals.trend.slow.length-4",
|
||
"signals.trend.fast.length-2_signals.trend.slow.length-5",
|
||
"signals.trend.fast.length-3_signals.trend.slow.length-4",
|
||
"signals.trend.fast.length-3_signals.trend.slow.length-5",
|
||
] {
|
||
assert!(html.contains(key), "member series '{key}' missing from the --tap exposure family chart");
|
||
}
|
||
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// Property (#102, single-run run-context reaches the served page): `aura chart
|
||
/// <run>` injects the run's `RunManifest` context into `window.AURA_TRACES.meta`
|
||
/// at the binary boundary — `kind:"run"`, the chart name, the manifest broker and
|
||
/// data window, the charted taps, and `members:null` (a single run carries no
|
||
/// member count). The render-layer unit (`render_chart_html_injects_run_context`)
|
||
/// proves the struct serializes, but only an end-to-end run pins that the
|
||
/// *built-and-read-back* manifest threads through `build_chart_data` into the
|
||
/// served HTML; a regression dropping the `meta` build (charting bare series with a
|
||
/// `ChartMeta::default()`) would leave every render/unit test green while serving a
|
||
/// header-less page. Asserts on the stable manifest fields (the synthetic window is
|
||
/// `[1,7]`, the broker is the FX sim-optimal label, kind/taps/members are fixed),
|
||
/// NEVER on the volatile `commit` (the dirty git HEAD), so it stays deterministic.
|
||
#[test]
|
||
fn chart_run_serves_manifest_run_context_in_meta() {
|
||
let cwd = temp_cwd("chart-run-meta");
|
||
|
||
let traced = Command::new(BIN).args(["run", "--trace", "demo"]).current_dir(&cwd).output().expect("spawn run --trace");
|
||
assert!(traced.status.success(), "run --trace exit: {:?}", traced.status);
|
||
|
||
let chart = Command::new(BIN).args(["chart", "demo"]).current_dir(&cwd).output().expect("spawn chart");
|
||
assert!(chart.status.success(), "chart exit: {:?}", chart.status);
|
||
let html = String::from_utf8(chart.stdout).expect("utf-8 html");
|
||
|
||
// The run-context meta is injected, tagged as a single run named `demo`.
|
||
assert!(html.contains("\"meta\":{"), "run-context meta not injected into the served page: {html:?}");
|
||
assert!(html.contains("\"kind\":\"run\""), "served meta must be kind run");
|
||
assert!(html.contains("\"name\":\"demo\""), "served meta must carry the run name");
|
||
// The manifest's broker + data window thread through to the served page.
|
||
assert!(html.contains("\"broker\":\"sim-optimal(pip_size=0.0001)\""), "manifest broker missing from served meta");
|
||
assert!(html.contains("\"window\":[1,7]"), "manifest window missing from served meta");
|
||
// The charted taps + the single-run sentinel.
|
||
assert!(html.contains("\"taps\":[\"equity\",\"exposure\"]"), "charted taps missing from served meta");
|
||
assert!(html.contains("\"members\":null"), "a single run must carry no member count");
|
||
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// Property (#102, family run-context + SPANNING window at the served page): `aura
|
||
/// chart <family>` injects family-shaped `meta` — `kind:"family"`, a `members`
|
||
/// count, the one compared `taps`, and crucially a `window` that is the SPAN across
|
||
/// all members, NOT a single member's window. The built-in sweep grid has 4
|
||
/// members, each over the synthetic `[1,7]` window, but the family's recorded
|
||
/// timestamps union to a wider `[1,18]` span; the served `window` must report that
|
||
/// span (the ledger's families-comparison invariant: only the span labels a
|
||
/// walk-forward family's true OOS coverage, and for sweep/MC it is still the union
|
||
/// of member windows). A regression that took the first member's window, or dropped
|
||
/// the family `members` count, would still pass the family-overlay series test
|
||
/// (which only checks series labels); this pins the header context. Deterministic:
|
||
/// asserts the fixed kind/members/taps/span, never the volatile commit.
|
||
#[test]
|
||
fn chart_family_serves_member_count_and_spanning_window_in_meta() {
|
||
let cwd = temp_cwd("chart-family-meta");
|
||
|
||
let swept = Command::new(BIN).args(["sweep", "--trace", "fam"]).current_dir(&cwd).output().expect("spawn sweep --trace");
|
||
assert!(swept.status.success(), "sweep --trace exit: {:?}", swept.status);
|
||
|
||
let chart = Command::new(BIN).args(["chart", "fam"]).current_dir(&cwd).output().expect("spawn chart fam");
|
||
assert!(chart.status.success(), "chart family exit: {:?}", chart.status);
|
||
let html = String::from_utf8(chart.stdout).expect("utf-8 html");
|
||
|
||
assert!(html.contains("\"meta\":{"), "family run-context meta not injected: {html:?}");
|
||
assert!(html.contains("\"kind\":\"family\""), "served meta must be kind family");
|
||
assert!(html.contains("\"name\":\"fam\""), "served meta must carry the family name");
|
||
// The 4-point built-in grid -> a member count of 4 (the single-run sentinel is gone).
|
||
assert!(html.contains("\"members\":4"), "family meta must carry the member count, got: {html}");
|
||
assert!(!html.contains("\"members\":null"), "a family must not carry the single-run null sentinel");
|
||
// The default compared tap (equity), one entry — not the per-member labels.
|
||
assert!(html.contains("\"taps\":[\"equity\"]"), "family meta must carry the single compared tap");
|
||
// The SPANNING window across members ([1,18]), wider than any single member's [1,7].
|
||
assert!(html.contains("\"window\":[1,18]"), "family meta must carry the SPAN across members, not one member's window; got: {html}");
|
||
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
// --- `aura run --harness <name>` selector (iter-3 Task 3) --------------------
|
||
|
||
/// Property: `aura run --harness r-sma` runs the R-scored harness — its stdout
|
||
/// `RunReport` carries the nested `r` block (the R yardstick), and that block carries
|
||
/// the `sqn` field. Pins the selector wiring r-sma → `run_r_sma` at the
|
||
/// built-binary boundary; a miswiring to the pip-only SMA arm would drop the `r` key.
|
||
#[test]
|
||
fn run_harness_r_sma_prints_an_r_block() {
|
||
let out = std::process::Command::new(BIN).args(["run", "--harness", "r-sma"]).output().unwrap();
|
||
assert!(out.status.success());
|
||
let s = String::from_utf8(out.stdout).unwrap();
|
||
assert!(s.contains("\"r\":{"), "r-sma run must carry an r block: {s}");
|
||
assert!(s.contains("\"sqn\""), "the r block must carry sqn: {s}");
|
||
}
|
||
|
||
/// Property: `aura run --harness sma` is the pip-only default — its `RunReport`
|
||
/// OMITS the `r` key entirely (the `skip_serializing_if` keeps a pip run byte-clean).
|
||
/// The companion of the r-sma test: it proves the selector switches the surface,
|
||
/// not that every run sprouts an `r` block.
|
||
#[test]
|
||
fn run_harness_sma_is_pip_only_no_r_block() {
|
||
let out = std::process::Command::new(BIN).args(["run", "--harness", "sma"]).output().unwrap();
|
||
assert!(out.status.success());
|
||
let s = String::from_utf8(out.stdout).unwrap();
|
||
assert!(!s.contains("\"r\":"), "a pip run must omit the r key: {s}");
|
||
}
|
||
|
||
/// Property (#153): a cost flag on a non-R harness (sma/macd, which produce no R)
|
||
/// is a usage error — exit 2 with a named diagnostic, no silent no-op, no `runs/`
|
||
/// write. The pre-#153 behaviour silently ignored the flag and exited 0.
|
||
#[test]
|
||
fn run_cost_flag_on_non_r_harness_is_refused_exit_2() {
|
||
let dir = temp_cwd("sma-cost-refused");
|
||
let out = Command::new(BIN)
|
||
.current_dir(&dir)
|
||
.args(["run", "--harness", "sma", "--cost-per-trade", "0.001"])
|
||
.output()
|
||
.expect("spawn aura run --harness sma --cost-per-trade");
|
||
assert_eq!(out.status.code(), Some(2), "cost on a non-R harness must exit 2; stderr: {}",
|
||
String::from_utf8_lossy(&out.stderr));
|
||
assert!(out.stdout.is_empty(), "a refused run must not emit a report: {:?}", out.stdout);
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(stderr.contains("R-evaluator harness"), "stderr must name the cause: {stderr:?}");
|
||
assert!(!dir.join("runs").exists(), "a refused run must not write the registry");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// Property (#153): the non-R-harness cost guard fires on the IMPLICIT default
|
||
/// harness too — `aura run --cost-per-trade ...` with no `--harness` defaults to
|
||
/// sma (non-R) and is refused (exit 2, named cause, no `runs/` write). Distinct
|
||
/// from the explicit `--harness sma` case: this pins that the guard reads the
|
||
/// *resolved* harness (after the `unwrap_or(Sma)` default), so a refactor that only
|
||
/// guarded an explicitly-named harness would leak a silent no-op through the default.
|
||
#[test]
|
||
fn run_cost_flag_on_default_harness_is_refused_exit_2() {
|
||
let dir = temp_cwd("default-harness-cost-refused");
|
||
let out = Command::new(BIN)
|
||
.current_dir(&dir)
|
||
.args(["run", "--cost-per-trade", "0.001"])
|
||
.output()
|
||
.expect("spawn aura run --cost-per-trade (no --harness)");
|
||
assert_eq!(out.status.code(), Some(2), "cost on the default (sma) harness must exit 2; stderr: {}",
|
||
String::from_utf8_lossy(&out.stderr));
|
||
assert!(out.stdout.is_empty(), "a refused run must not emit a report: {:?}", out.stdout);
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(stderr.contains("R-evaluator harness"), "stderr must name the cause: {stderr:?}");
|
||
assert!(!dir.join("runs").exists(), "a refused run must not write the registry");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// 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: `--trace` on the r-sma harness persists the THIRD `r_equity` tap
|
||
/// beside equity/exposure, and that tap round-trips through `aura chart --tap
|
||
/// r_equity` into a rendered series. Pins the full author→persist→chart loop for the
|
||
/// R-equity series end to end (the pip harnesses write only the two taps).
|
||
#[test]
|
||
fn r_sma_trace_persists_r_equity_and_charts_it() {
|
||
// `temp_cwd` (cli_run.rs:13) gives a unique CWD with no external tempfile dep — the
|
||
// pattern the existing trace tests use; it returns a `PathBuf`, so `.join` directly.
|
||
let dir = temp_cwd("r-sma-trace");
|
||
let run = Command::new(BIN)
|
||
.current_dir(&dir)
|
||
.args(["run", "--harness", "r-sma", "--trace", "q1"])
|
||
.output()
|
||
.unwrap();
|
||
assert!(run.status.success());
|
||
// the third tap is persisted beside equity/exposure
|
||
assert!(dir.join("runs/traces/q1/r_equity.json").exists(), "r_equity tap must persist");
|
||
let chart = Command::new(BIN)
|
||
.current_dir(&dir)
|
||
.args(["chart", "q1", "--tap", "r_equity"])
|
||
.output()
|
||
.unwrap();
|
||
assert!(chart.status.success());
|
||
let html = String::from_utf8(chart.stdout).unwrap();
|
||
assert!(!html.is_empty() && html.contains("r_equity"), "chart must render the r_equity series");
|
||
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// Property (Task 3, the run-path cost seam): `aura run --harness r-sma
|
||
/// --cost-per-trade <c>` charges a flat round-trip cost in R end to end — the run
|
||
/// persists a fourth `net_r_equity` tap beside r_equity and the report's
|
||
/// `net_expectancy_r` is STRICTLY below the gross `expectancy_r`. Pins the whole
|
||
/// flag→ConstantCost node→net_r_equity tap→summarize_r net fold loop (a no-cost run
|
||
/// leaves net == gross, the held golden); a regression that dropped the cost node or
|
||
/// left the net fold reading an empty stream would collapse net back onto gross here.
|
||
#[test]
|
||
fn r_sma_cost_run_persists_net_r_equity_and_charges_cost() {
|
||
let dir = temp_cwd("r-sma-cost");
|
||
let run = Command::new(BIN)
|
||
.current_dir(&dir)
|
||
.args(["run", "--harness", "r-sma", "--cost-per-trade", "2", "--trace", "c1"])
|
||
.output()
|
||
.unwrap();
|
||
assert!(run.status.success(), "exit: {:?}; stderr: {}", run.status,
|
||
String::from_utf8_lossy(&run.stderr));
|
||
assert!(dir.join("runs/traces/c1/net_r_equity.json").exists(), "net_r_equity trace persisted");
|
||
let s = String::from_utf8(run.stdout).expect("utf-8 stdout");
|
||
let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap();
|
||
let gross = v["metrics"]["r"]["expectancy_r"].as_f64().unwrap();
|
||
let net = v["metrics"]["r"]["net_expectancy_r"].as_f64().unwrap();
|
||
assert!(net < gross, "cost charged: net {net} < gross {gross}");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// Property (spec 0082, the cost-graph composition headline): `--cost-per-trade`
|
||
/// and `--slip-vol-mult` set together compose — both cost nodes sum into one
|
||
/// net-R curve. The combined net is strictly below the flat-cost-only net (the
|
||
/// vol-slippage node bites on top), and the `net_r_equity` trace persists.
|
||
#[test]
|
||
fn r_sma_both_costs_compose_net_below_each_alone() {
|
||
let dir = temp_cwd("r-sma-compose");
|
||
let run_net = |args: &[&str], trace: &str| -> f64 {
|
||
let mut full = vec!["run", "--harness", "r-sma"];
|
||
full.extend_from_slice(args);
|
||
full.extend_from_slice(&["--trace", trace]);
|
||
let run = Command::new(BIN).current_dir(&dir).args(&full).output().unwrap();
|
||
assert!(run.status.success(), "exit: {:?}; stderr: {}", run.status,
|
||
String::from_utf8_lossy(&run.stderr));
|
||
let s = String::from_utf8(run.stdout).expect("utf-8 stdout");
|
||
let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap();
|
||
v["metrics"]["r"]["net_expectancy_r"].as_f64().unwrap()
|
||
};
|
||
let net_flat = run_net(&["--cost-per-trade", "2"], "flat");
|
||
let net_both = run_net(&["--cost-per-trade", "2", "--slip-vol-mult", "0.5"], "both");
|
||
assert!(dir.join("runs/traces/both/net_r_equity.json").exists(), "net_r_equity persisted");
|
||
assert!(net_both < net_flat, "composed cost bites more: net_both {net_both} < net_flat {net_flat}");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// Golden characterization (cycle 0083, the CostNode-trait migration): pins the
|
||
/// EXACT `net_expectancy_r` of `aura run --harness r-sma --cost-per-trade 2`, so
|
||
/// the ConstantCost-through-CostRunner migration (Task 2) is VERIFIED byte-preserving
|
||
/// at the observable boundary, not assumed. The sibling
|
||
/// `r_sma_cost_run_persists_net_r_equity_and_charges_cost` only asserts the
|
||
/// RELATION `net < gross`; a regression that drifted the cost numerator or the
|
||
/// `cost_per_trade / |entry - stop|` R-normalization (the token form the cycle
|
||
/// promises to keep verbatim) would keep `net < gross` true and pass that test
|
||
/// silently while shifting this value. Pins only the cost-affected float — gross
|
||
/// `expectancy_r` is already pinned by `r_sma_single_run_output_golden` and is
|
||
/// unchanged by cost. Deterministic over the fixed synthetic stream (C1).
|
||
#[test]
|
||
fn r_sma_flat_cost_net_expectancy_r_golden() {
|
||
let out = Command::new(BIN)
|
||
.args(["run", "--harness", "r-sma", "--cost-per-trade", "2"])
|
||
.output()
|
||
.unwrap();
|
||
assert!(out.status.success(), "exit: {:?}; stderr: {}", out.status,
|
||
String::from_utf8_lossy(&out.stderr));
|
||
let s = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap();
|
||
let net = v["metrics"]["r"]["net_expectancy_r"].as_f64().unwrap();
|
||
assert_eq!(
|
||
net, -614.3134020253314,
|
||
"flat-cost net_expectancy_r drifted from the golden — the ConstantCost \
|
||
factor migration is not byte-preserving: {s}"
|
||
);
|
||
}
|
||
|
||
/// Golden characterization (cycle 0083, the composed cost path): pins the EXACT
|
||
/// `net_expectancy_r` of `aura run --harness r-sma --cost-per-trade 2
|
||
/// --slip-vol-mult 0.5`, so the VolSlippageCost-through-CostRunner migration (Task 3)
|
||
/// AND the CostSum per-field aggregation reading the shared `COST_FIELD_NAMES`
|
||
/// contract (Task 4) are VERIFIED byte-preserving end to end. The sibling
|
||
/// `r_sma_both_costs_compose_net_below_each_alone` only asserts the RELATION
|
||
/// `net_both < net_flat`; a drift in the vol-scaled numerator, the CostSum field
|
||
/// order, or the `cost_graph` composite's per-node `cost[k].<port>` wiring would keep
|
||
/// that relation true and pass silently while shifting this value. Deterministic
|
||
/// over the fixed synthetic stream (C1).
|
||
#[test]
|
||
fn r_sma_composed_cost_net_expectancy_r_golden() {
|
||
let out = Command::new(BIN)
|
||
.args(["run", "--harness", "r-sma", "--cost-per-trade", "2", "--slip-vol-mult", "0.5"])
|
||
.output()
|
||
.unwrap();
|
||
assert!(out.status.success(), "exit: {:?}; stderr: {}", out.status,
|
||
String::from_utf8_lossy(&out.stderr));
|
||
let s = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap();
|
||
let net = v["metrics"]["r"]["net_expectancy_r"].as_f64().unwrap();
|
||
assert_eq!(
|
||
net, -615.0304388396047,
|
||
"composed-cost net_expectancy_r drifted from the golden — the VolSlippageCost \
|
||
factor + CostSum aggregation is not byte-preserving: {s}"
|
||
);
|
||
}
|
||
|
||
/// Golden characterization (cycle 0085, the per-held-cycle accrual path): pins the EXACT
|
||
/// `net_expectancy_r` of `aura run --harness r-sma --carry-per-cycle 0.5`, so the
|
||
/// CarryCost accrual (open_cost grows over the hold, dumps into cum at close) is VERIFIED
|
||
/// at the observable boundary. Deterministic over the fixed synthetic stream (C1).
|
||
#[test]
|
||
fn r_sma_carry_cost_net_expectancy_r_golden() {
|
||
let out = Command::new(BIN)
|
||
.args(["run", "--harness", "r-sma", "--carry-per-cycle", "0.5"])
|
||
.output()
|
||
.unwrap();
|
||
assert!(out.status.success(), "exit: {:?}; stderr: {}", out.status,
|
||
String::from_utf8_lossy(&out.stderr));
|
||
let s = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap();
|
||
let net = v["metrics"]["r"]["net_expectancy_r"].as_f64().unwrap();
|
||
assert_eq!(
|
||
net, -768.2095026600887,
|
||
"carry net_expectancy_r drifted from the golden: {s}"
|
||
);
|
||
}
|
||
|
||
/// Golden characterization (cycle 0085, composed at-close + accrual): pins the EXACT
|
||
/// `net_expectancy_r` of `aura run --harness r-sma --cost-per-trade 2 --carry-per-cycle
|
||
/// 0.5` — the per-trade ConstantCost and the per-held-cycle CarryCost sum through
|
||
/// cost_graph/CostSum into one net-R curve. Deterministic (C1).
|
||
#[test]
|
||
fn r_sma_cost_and_carry_composed_net_expectancy_r_golden() {
|
||
let out = Command::new(BIN)
|
||
.args(["run", "--harness", "r-sma", "--cost-per-trade", "2", "--carry-per-cycle", "0.5"])
|
||
.output()
|
||
.unwrap();
|
||
assert!(out.status.success(), "exit: {:?}; stderr: {}", out.status,
|
||
String::from_utf8_lossy(&out.stderr));
|
||
let s = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap();
|
||
let net = v["metrics"]["r"]["net_expectancy_r"].as_f64().unwrap();
|
||
assert_eq!(
|
||
net, -1383.7939051991182,
|
||
"composed cost+carry net_expectancy_r drifted from the golden: {s}"
|
||
);
|
||
}
|
||
|
||
/// Property (cycle 0085, the approach-B discriminator): with `--carry-per-cycle`, the
|
||
/// `net_r_equity` tap BLEEDS continuously during a multi-cycle hold — the gap to the
|
||
/// gross `r_equity` strictly grows each held cycle as the carry accrues, rather than
|
||
/// stepping only at close (approach A, which net_expectancy_r alone could not tell
|
||
/// apart). Read over the synthetic fixture's terminal open hold (the position held to the
|
||
/// end, never closing), where cum_cost_in_r is constant so the growth is the open-side
|
||
/// accrual alone.
|
||
#[test]
|
||
fn r_sma_carry_net_r_equity_bleeds_over_the_hold() {
|
||
let dir = temp_cwd("r-sma-carry-bleed");
|
||
let run = Command::new(BIN)
|
||
.current_dir(&dir)
|
||
.args(["run", "--harness", "r-sma", "--carry-per-cycle", "0.5", "--trace", "bleed"])
|
||
.output()
|
||
.unwrap();
|
||
assert!(run.status.success(), "exit: {:?}; stderr: {}", run.status,
|
||
String::from_utf8_lossy(&run.stderr));
|
||
|
||
let read_col = |tap: &str| -> Vec<f64> {
|
||
let s = std::fs::read_to_string(dir.join(format!("runs/traces/bleed/{tap}.json")))
|
||
.unwrap_or_else(|e| panic!("read {tap}.json: {e}"));
|
||
json_array_body(&s, "\"columns\":[[")
|
||
.split(',')
|
||
.map(|x| x.trim().parse::<f64>().unwrap_or_else(|_| panic!("parse {tap} value {x:?}")))
|
||
.collect()
|
||
};
|
||
let r_eq = read_col("r_equity");
|
||
let net_eq = read_col("net_r_equity");
|
||
assert_eq!(r_eq.len(), net_eq.len(), "taps are co-temporal (parallel arrays)");
|
||
let n = r_eq.len();
|
||
assert!(n >= 3, "need a multi-cycle terminal hold to observe the bleed; len {n}");
|
||
// gap = r_equity − net_r_equity = cum_cost_in_r + open_cost_in_r. Over the terminal
|
||
// open hold cum is constant, so a strictly growing gap proves the open-side accrual.
|
||
let gap = |i: usize| r_eq[i] - net_eq[i];
|
||
assert!(
|
||
gap(n - 3) < gap(n - 2) && gap(n - 2) < gap(n - 1),
|
||
"net_r_equity must bleed during the terminal hold (gap strictly grows): \
|
||
gaps {}, {}, {}",
|
||
gap(n - 3), gap(n - 2), gap(n - 1)
|
||
);
|
||
assert!(gap(n - 1) > 0.0, "carry is charged: terminal gap {} > 0", gap(n - 1));
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// Property (cycle 0085 + #153, cost-flag validation symmetry): a NEGATIVE
|
||
/// `--carry-per-cycle` is refused at the binary boundary — exit 2, a named
|
||
/// non-negativity diagnostic on stderr, nothing on stdout, no `runs/` write — the
|
||
/// same shape the sibling `--cost-per-trade`/`--slip-vol-mult` flags enforce. The
|
||
/// `v < 0.0` guard is load-bearing: without it a negative carry reaches
|
||
/// `CarryCost::new` and PANICs, and a negative price-unit carry would CREDIT R —
|
||
/// inverting the accrual sign. Deterministic; the refusal precedes any data access.
|
||
#[test]
|
||
fn r_sma_negative_carry_per_cycle_refused_non_negative_exit_2() {
|
||
let dir = temp_cwd("r-sma-carry-neg");
|
||
let out = Command::new(BIN)
|
||
.current_dir(&dir)
|
||
.args(["run", "--harness", "r-sma", "--carry-per-cycle", "-1"])
|
||
.output()
|
||
.expect("spawn aura run --carry-per-cycle -1");
|
||
assert_eq!(out.status.code(), Some(2),
|
||
"negative carry must exit 2, not panic; status: {:?}; stderr: {}",
|
||
out.status, String::from_utf8_lossy(&out.stderr));
|
||
assert!(out.stdout.is_empty(), "a refused run must not emit a report on stdout: {:?}", out.stdout);
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(stderr.contains("must be non-negative"), "negative carry stderr must name the cause: {stderr:?}");
|
||
assert!(!dir.join("runs").exists(), "a refused run must not write the registry");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// Property (#153): a negative `--cost-per-trade` is refused at the binary
|
||
/// boundary — exit 2, a named non-negativity diagnostic on stderr, no `runs/`
|
||
/// write — the sibling of the carry guard, exercising the per-trade arm.
|
||
#[test]
|
||
fn run_negative_cost_per_trade_refused_non_negative_exit_2() {
|
||
let dir = temp_cwd("cost-per-trade-neg");
|
||
let out = Command::new(BIN)
|
||
.current_dir(&dir)
|
||
.args(["run", "--harness", "r-sma", "--cost-per-trade", "-0.5"])
|
||
.output()
|
||
.expect("spawn aura run --cost-per-trade -0.5");
|
||
assert_eq!(out.status.code(), Some(2), "negative cost must exit 2; stderr: {}",
|
||
String::from_utf8_lossy(&out.stderr));
|
||
assert!(out.stdout.is_empty(), "a refused run must not emit a report: {:?}", out.stdout);
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(stderr.contains("must be non-negative"), "stderr must name the cause: {stderr:?}");
|
||
assert!(!dir.join("runs").exists(), "a refused run must not write the registry");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// Property (cycle 0085, carry is a monotone debit in the rate): a larger
|
||
/// `--carry-per-cycle` makes the run's `net_expectancy_r` STRICTLY more negative. The
|
||
/// carry is purely downstream — bias/stop/signal (hence trade count and gross R) are
|
||
/// identical across rates — so doubling the per-held-cycle carry can only deepen the
|
||
/// charge. The point-goldens pin two exact values but not the DIRECTION: a sign flip that
|
||
/// made carry a CREDIT, or a rate that were ignored/clamped, would re-baseline to some
|
||
/// other pair of numbers and slip past a golden; this relation survives any future
|
||
/// fixture re-baseline. Deterministic over the fixed synthetic stream (C1).
|
||
#[test]
|
||
fn r_sma_larger_carry_per_cycle_lowers_net_expectancy_r() {
|
||
let net = |rate: &str| -> f64 {
|
||
let out = Command::new(BIN)
|
||
.args(["run", "--harness", "r-sma", "--carry-per-cycle", rate])
|
||
.output()
|
||
.unwrap();
|
||
assert!(out.status.success(), "exit: {:?}; stderr: {}", out.status,
|
||
String::from_utf8_lossy(&out.stderr));
|
||
let s = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap();
|
||
v["metrics"]["r"]["net_expectancy_r"].as_f64().unwrap()
|
||
};
|
||
let lo = net("0.5");
|
||
let hi = net("1.0");
|
||
assert!(hi < lo, "a larger carry must charge more (lower net): carry-1.0 {hi} < carry-0.5 {lo}");
|
||
}
|
||
|
||
/// Property (cycle 0085, the zero-rate boundary is exactly free): `--carry-per-cycle 0`
|
||
/// activates the cost path (a CarryCost node IS wired) yet charges NOTHING — the run's
|
||
/// `net_expectancy_r` is bit-identical to the gross `expectancy_r`. Guards against an
|
||
/// accrual branch that leaks a spurious charge even at rate 0 (e.g. an off-by-one in the
|
||
/// open-side mark): such a bug would shift net away from gross while the positive-rate
|
||
/// goldens stayed green. Pins the zero ACCRUAL is zero COST invariant relationally, so it
|
||
/// survives any future re-baseline of the gross value. Deterministic (C1).
|
||
#[test]
|
||
fn r_sma_zero_carry_per_cycle_charges_nothing() {
|
||
let out = Command::new(BIN)
|
||
.args(["run", "--harness", "r-sma", "--carry-per-cycle", "0"])
|
||
.output()
|
||
.unwrap();
|
||
assert!(out.status.success(), "exit: {:?}; stderr: {}", out.status,
|
||
String::from_utf8_lossy(&out.stderr));
|
||
let s = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap();
|
||
let r = &v["metrics"]["r"];
|
||
let net = r["net_expectancy_r"].as_f64().unwrap();
|
||
let gross = r["expectancy_r"].as_f64().unwrap();
|
||
assert_eq!(net, gross, "zero carry must be exactly free: net {net} == gross {gross}; {s}");
|
||
}
|
||
|
||
/// Property (the spec's dual-yardstick headline): `aura run --harness r-sma` scores
|
||
/// ONE signal in BOTH yardsticks at once — the single emitted `RunReport` carries the pip
|
||
/// `metrics.total_pips` (off the SimBroker branch) AND the nested `r` block (off the
|
||
/// RiskExecutor branch), because one bias is fanned into both. The shipped selector test
|
||
/// pins only that the `r` block appears; this pins that the pip branch SURVIVES alongside
|
||
/// it. A regression that swapped the pip branch out for the R branch (dropping the dual
|
||
/// tap) would still print an `r` block — and pass that test — while silently losing the
|
||
/// pip yardstick; this fails it. Asserts on the structural co-presence of both keys, never
|
||
/// the volatile metric floats, so it stays deterministic.
|
||
#[test]
|
||
fn run_harness_r_sma_carries_both_pip_and_r_yardsticks() {
|
||
let out = std::process::Command::new(BIN)
|
||
.args(["run", "--harness", "r-sma"])
|
||
.output()
|
||
.expect("spawn aura run --harness r-sma");
|
||
assert!(out.status.success(), "exit: {:?}", out.status);
|
||
let s = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
// exactly one report line.
|
||
assert_eq!(s.lines().count(), 1, "stdout was: {s:?}");
|
||
// the pip yardstick (SimBroker branch) is still present in the same report...
|
||
assert!(s.contains("\"total_pips\":"), "r-sma must keep the pip yardstick: {s}");
|
||
// ...and the R yardstick (RiskExecutor branch) is folded in alongside it.
|
||
assert!(s.contains("\"r\":{"), "r-sma must carry the R yardstick too: {s}");
|
||
}
|
||
|
||
/// Property (C1, the foundational determinism invariant at the new harness boundary):
|
||
/// `aura run --harness r-sma` is byte-deterministic — running it twice over the
|
||
/// fixed synthetic stream yields BYTE-IDENTICAL stdout (modulo the build-constant git
|
||
/// commit, which is equal within one build). The whole engine rests on a backtest being
|
||
/// reproducible (C1); the sibling sweep/walkforward paths each have a determinism E2E,
|
||
/// but the brand-new dual-tap r-sma run-loop (the SMA→Bias fan into SimBroker + the
|
||
/// RiskExecutor + the summarize_r fold) had none. A non-determinism in the new R branch
|
||
/// (e.g. an unstable channel drain order or an allocator-ordered fold) would surface as a
|
||
/// drift here. Compares the full stdout body, so it pins the metric floats too.
|
||
#[test]
|
||
fn run_harness_r_sma_is_byte_deterministic_across_runs() {
|
||
let run = || {
|
||
let out = std::process::Command::new(BIN)
|
||
.args(["run", "--harness", "r-sma"])
|
||
.output()
|
||
.expect("spawn aura run --harness r-sma");
|
||
assert!(out.status.success(), "exit: {:?}", out.status);
|
||
String::from_utf8(out.stdout).expect("utf-8 stdout")
|
||
};
|
||
assert_eq!(run(), run(), "the r-sma run must be byte-identical across runs (C1)");
|
||
}
|
||
|
||
/// Golden characterization (cycle 0066): pins the EXACT metric output of the
|
||
/// r-sma single run, so the `r_sma_graph` helper extraction (Task 2) is
|
||
/// VERIFIED byte-preserving, not assumed. Captured against HEAD before the
|
||
/// refactor; the refactor must keep it identical (a drift is a behaviour change →
|
||
/// debug). Commit-agnostic: pins only the `metrics` object, since the manifest
|
||
/// carries the volatile build commit.
|
||
#[test]
|
||
fn r_sma_single_run_output_golden() {
|
||
let out = Command::new(BIN).args(["run", "--harness", "r-sma"]).output().unwrap();
|
||
assert!(out.status.success(), "exit: {:?}", out.status);
|
||
let s = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
let key = "\"metrics\":";
|
||
let start = s.find(key).expect("stdout has a metrics object");
|
||
let metrics = s[start..].trim_end();
|
||
assert_eq!(
|
||
metrics,
|
||
r#""metrics":{"total_pips":0.34185000000002036,"max_drawdown":0.11139999999998655,"bias_sign_flips":2,"r":{"expectancy_r":1.2710005136982836,"n_trades":3,"win_rate":1.0,"avg_win_r":1.2710005136982836,"avg_loss_r":0.0,"profit_factor":0.0,"max_r_drawdown":0.0,"n_open_at_end":1,"sqn":3.141496526818299,"sqn_normalized":3.141496526818299,"net_expectancy_r":1.2710005136982836,"conviction_terciles_r":[0.9285858482198718,2.0771328641652427,0.8072828287097363]}}}"#,
|
||
"r-sma single-run metric output drifted from the golden: {s}"
|
||
);
|
||
}
|
||
|
||
/// Property (#133): `aura sweep --strategy r-sma` runs the r-sma harness over
|
||
/// the fast×slow signal grid (4 members), each carrying an R block, persisted as a
|
||
/// rankable family; `runs family <id> rank sqn` orders them highest-SQN first.
|
||
#[test]
|
||
fn sweep_strategy_r_sma_ranks_a_family_by_sqn() {
|
||
let cwd = temp_cwd("sweep-r-sma");
|
||
let out = Command::new(BIN)
|
||
.args(["sweep", "--strategy", "r-sma"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura sweep --strategy r-sma");
|
||
assert!(
|
||
out.status.success(),
|
||
"r-sma sweep exit: {:?}; stderr: {}",
|
||
out.status,
|
||
String::from_utf8_lossy(&out.stderr)
|
||
);
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8");
|
||
let lines: Vec<&str> = stdout.lines().collect();
|
||
assert_eq!(lines.len(), 4, "r-sma sweep must print 4 member lines: {stdout:?}");
|
||
for line in &lines {
|
||
assert!(line.contains("\"r\":{"), "member must carry an r block: {line}");
|
||
assert!(line.contains("\"sqn\""), "member r block must carry sqn: {line}");
|
||
// each member records the fixed R-defining params (the stop that defines 1R),
|
||
// so it is reproducible from its own manifest (C18) and the constant-stop
|
||
// basis of cross-member SQN comparability (C10) is auditable from the record.
|
||
assert!(
|
||
line.contains("\"stop_length\"") && line.contains("\"stop_k\""),
|
||
"member manifest must record the fixed R-defining stop params: {line}"
|
||
);
|
||
}
|
||
// rank the family by sqn — highest first.
|
||
let rank = Command::new(BIN)
|
||
.args(["runs", "family", "sweep-0", "rank", "sqn"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn rank sqn");
|
||
assert!(rank.status.success(), "rank sqn exit: {:?}", rank.status);
|
||
let rank_out = String::from_utf8(rank.stdout).expect("utf-8");
|
||
assert_eq!(rank_out.lines().count(), 4, "rank: {rank_out:?}");
|
||
let sqns: Vec<f64> = rank_out
|
||
.lines()
|
||
.map(|l| {
|
||
let key = "\"sqn\":";
|
||
let start = l.find(key).expect("line has sqn") + key.len();
|
||
let tail = &l[start..];
|
||
let end = tail.find([',', '}']).expect("token end");
|
||
tail[..end].parse().expect("sqn is an f64")
|
||
})
|
||
.collect();
|
||
assert!(sqns.windows(2).all(|w| w[0] >= w[1]), "rank not descending by sqn: {sqns:?}");
|
||
// an unknown metric is a usage error (exit 2).
|
||
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);
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// Property (#130): `sqn_normalized` (SQN100) is a working rank key *through the
|
||
/// binary*, not just an in-memory `metric_cmp` arm. A persisted r-sma family
|
||
/// must carry the new `sqn_normalized` field in each member's on-disk `r` block,
|
||
/// and `aura runs family <id> rank sqn_normalized` must parse the new key (it was
|
||
/// an `UnknownMetric` exit-2 error before #130), read the field back from
|
||
/// `families.jsonl`, and order the members highest-first. This exercises the CLI
|
||
/// parse path + the serde round-trip of the new field through the family store,
|
||
/// which the registry unit test (synthetic in-memory reports) never touches. The
|
||
/// signal-only grid means each member's n_trades is small (cap inactive), so
|
||
/// `sqn_normalized == sqn` per member and the SQN100 ranking matches the raw-SQN
|
||
/// ranking — asserted as a cross-check.
|
||
#[test]
|
||
fn sweep_strategy_r_sma_ranks_a_family_by_sqn_normalized() {
|
||
let cwd = temp_cwd("sweep-r-sma-sqn100");
|
||
let out = Command::new(BIN)
|
||
.args(["sweep", "--strategy", "r-sma"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura sweep --strategy r-sma");
|
||
assert!(
|
||
out.status.success(),
|
||
"r-sma sweep exit: {:?}; stderr: {}",
|
||
out.status,
|
||
String::from_utf8_lossy(&out.stderr)
|
||
);
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8");
|
||
// every persisted member carries the new field in its r block.
|
||
for line in stdout.lines() {
|
||
assert!(
|
||
line.contains("\"sqn_normalized\""),
|
||
"member r block must carry sqn_normalized: {line}"
|
||
);
|
||
}
|
||
// rank by the NEW key — must succeed (was UnknownMetric/exit-2 before #130).
|
||
let rank = Command::new(BIN)
|
||
.args(["runs", "family", "sweep-0", "rank", "sqn_normalized"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn rank sqn_normalized");
|
||
assert!(
|
||
rank.status.success(),
|
||
"rank sqn_normalized must succeed (exit 0): {:?}; stderr: {}",
|
||
rank.status,
|
||
String::from_utf8_lossy(&rank.stderr)
|
||
);
|
||
let rank_out = String::from_utf8(rank.stdout).expect("utf-8");
|
||
assert_eq!(rank_out.lines().count(), 4, "rank: {rank_out:?}");
|
||
let norms: Vec<f64> = rank_out
|
||
.lines()
|
||
.map(|l| {
|
||
let key = "\"sqn_normalized\":";
|
||
let start = l.find(key).expect("line has sqn_normalized") + key.len();
|
||
let tail = &l[start..];
|
||
let end = tail.find([',', '}']).expect("token end");
|
||
tail[..end].parse().expect("sqn_normalized is an f64")
|
||
})
|
||
.collect();
|
||
assert!(
|
||
norms.windows(2).all(|w| w[0] >= w[1]),
|
||
"rank not descending by sqn_normalized: {norms:?}"
|
||
);
|
||
// cap inactive at these trade counts -> per member sqn_normalized == sqn, so the
|
||
// SQN100 ranking is the same as the raw-sqn ranking.
|
||
let by_sqn = Command::new(BIN)
|
||
.args(["runs", "family", "sweep-0", "rank", "sqn"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn rank sqn");
|
||
let by_sqn_out = String::from_utf8(by_sqn.stdout).expect("utf-8");
|
||
let sqns: Vec<f64> = by_sqn_out
|
||
.lines()
|
||
.map(|l| {
|
||
let key = "\"sqn\":";
|
||
let start = l.find(key).expect("line has sqn") + key.len();
|
||
let tail = &l[start..];
|
||
let end = tail.find([',', '}']).expect("token end");
|
||
tail[..end].parse().expect("sqn is an f64")
|
||
})
|
||
.collect();
|
||
assert_eq!(norms, sqns, "below the cap, SQN100 ranking must equal raw-sqn ranking");
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// Property (#135): `aura sweep --strategy r-sma --trace <n>` now persists one
|
||
/// member dir per grid point (it was refused with exit 2 before). The 2×2 fast×slow
|
||
/// grid yields 4 members; each carries the THIRD `r_equity` tap (the r-sma member
|
||
/// has the R curve, unlike the two-tap sma/momentum members), and a member is
|
||
/// chartable via `chart <n>/<member> --tap r_equity`. `--name` (record the rankable
|
||
/// family, no per-member streams) still works.
|
||
#[test]
|
||
fn sweep_strategy_r_sma_trace_persists_member_dirs_with_r_equity() {
|
||
let cwd = temp_cwd("sweep-r-sma-trace");
|
||
let traced = Command::new(BIN)
|
||
.args(["sweep", "--strategy", "r-sma", "--trace", "t1"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura sweep --strategy r-sma --trace");
|
||
assert!(
|
||
traced.status.success(),
|
||
"r-sma --trace must persist members (exit 0): {:?}; stderr: {}",
|
||
traced.status,
|
||
String::from_utf8_lossy(&traced.stderr)
|
||
);
|
||
let base = cwd.join("runs/traces/t1");
|
||
let members: Vec<String> = std::fs::read_dir(&base)
|
||
.expect("read t1 trace dir")
|
||
.map(|e| e.expect("dir entry").file_name().to_string_lossy().into_owned())
|
||
.collect();
|
||
assert_eq!(members.len(), 4, "2x2 fast×slow r-sma grid = 4 member dirs; got {members:?}");
|
||
for m in &members {
|
||
assert!(
|
||
m.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')),
|
||
"non-portable member dir name: {m}"
|
||
);
|
||
assert!(
|
||
base.join(m).join("r_equity.json").exists(),
|
||
"member {m} must carry the r_equity tap"
|
||
);
|
||
}
|
||
// one member is chartable via chart <name>/<member> --tap r_equity.
|
||
let one = &members[0];
|
||
let chart = Command::new(BIN)
|
||
.args(["chart", &format!("t1/{one}"), "--tap", "r_equity"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura chart t1/<member> --tap r_equity");
|
||
assert!(chart.status.success(), "chart exit: {:?}", chart.status);
|
||
assert!(
|
||
String::from_utf8_lossy(&chart.stdout).contains("r_equity"),
|
||
"chart must render the r_equity series"
|
||
);
|
||
// --name records the rankable family (no per-member streams) — still succeeds.
|
||
let named = Command::new(BIN)
|
||
.args(["sweep", "--strategy", "r-sma", "--name", "n1"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura sweep --strategy r-sma --name");
|
||
assert!(
|
||
named.status.success(),
|
||
"r-sma --name must succeed: {:?}; stderr: {}",
|
||
named.status,
|
||
String::from_utf8_lossy(&named.stderr)
|
||
);
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// Property (#137 — headline): `aura sweep --strategy r-sma` accepts the four
|
||
/// optional comma-separated grid flags `--fast`, `--slow`, `--stop-length`,
|
||
/// `--stop-k`, which become the sweep's grid axes over the four r_sma_graph
|
||
/// knobs (sma fast length, sma slow length, vol-stop length, vol-stop k). The
|
||
/// family is the cartesian product of the supplied lists; a single-value list per
|
||
/// axis is a 1-member family. This is the enabling change that lets the stop
|
||
/// timescale track a *slow* signal (the pinned 3-bar = 3-minute vol-stop is far too
|
||
/// fast for a 240/960-bar momentum cross) — so a slow time-series-momentum edge can
|
||
/// be screened across timeframes, stop included.
|
||
///
|
||
/// Mirrors `sweep_strategy_r_sma_ranks_a_family_by_sqn`: drive the built binary,
|
||
/// read each stdout member line (the assigned `family_id` + the nested RunReport),
|
||
/// and inspect the manifest `params` array — whose on-disk shape is
|
||
/// `["fast.length",{"I64":N}]` / `["stop_k",{"F64":K}]` (verified against the live
|
||
/// CLI output). The single member must carry exactly fast=240, slow=960,
|
||
/// stop_length=240, stop_k=2.0. Today every one of these four flags is an unknown
|
||
/// token rejected with exit 2 (the grid only honours the hardcoded fast×slow), so
|
||
/// this is RED for the right reason: the flags + gridding are absent.
|
||
#[test]
|
||
fn sweep_strategy_r_sma_grids_all_four_knobs_from_csv_flags() {
|
||
let cwd = temp_cwd("sweep-r-sma-grid-flags");
|
||
let out = Command::new(BIN)
|
||
.args([
|
||
"sweep",
|
||
"--strategy",
|
||
"r-sma",
|
||
"--fast",
|
||
"240",
|
||
"--slow",
|
||
"960",
|
||
"--stop-length",
|
||
"240",
|
||
"--stop-k",
|
||
"2.0",
|
||
"--name",
|
||
"g1",
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura sweep --strategy r-sma with grid flags");
|
||
assert!(
|
||
out.status.success(),
|
||
"gridded r-sma sweep must succeed (exit 0): {:?}; stderr: {}",
|
||
out.status,
|
||
String::from_utf8_lossy(&out.stderr)
|
||
);
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8");
|
||
let lines: Vec<&str> = stdout.lines().collect();
|
||
// a one-value-per-axis grid is a 1-member family (cartesian product of singletons).
|
||
assert_eq!(
|
||
lines.len(),
|
||
1,
|
||
"single-value lists on all four axes = a 1-member family: {stdout:?}"
|
||
);
|
||
let member = lines[0];
|
||
// the member is a real run (carries an R block), not a "0 ran" no-op.
|
||
assert!(member.contains("\"r\":{"), "member must carry an r block: {member}");
|
||
// the single member's four griddable knobs take the supplied values — the
|
||
// on-disk manifest-params shape (verified against live CLI output).
|
||
for needle in [
|
||
"[\"fast.length\",{\"I64\":240}]",
|
||
"[\"slow.length\",{\"I64\":960}]",
|
||
"[\"stop_length\",{\"I64\":240}]",
|
||
"[\"stop_k\",{\"F64\":2.0}]",
|
||
] {
|
||
assert!(
|
||
member.contains(needle),
|
||
"member must grid {needle} from the CSV flag: {member}"
|
||
);
|
||
}
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// Property (#137 — default preservation, the hard constraint): when the grid flags
|
||
/// are ABSENT, `aura sweep --strategy r-sma` must produce *exactly today's*
|
||
/// family — the 2×2 fast×slow grid {2,3}×{6,12} (4 members) with the stop pinned at
|
||
/// stop_length=3, stop_k=2.0. This guards the byte-greenness of every existing
|
||
/// golden/sweep test once the four flags land: a no-flags invocation falls back to
|
||
/// the historical defaults, not to an empty/changed grid. It pins the contract that
|
||
/// the flag wiring must not move the no-flags baseline.
|
||
#[test]
|
||
fn sweep_strategy_r_sma_no_flags_keeps_the_default_grid() {
|
||
let cwd = temp_cwd("sweep-r-sma-default-grid");
|
||
let out = Command::new(BIN)
|
||
.args(["sweep", "--strategy", "r-sma"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura sweep --strategy r-sma");
|
||
assert!(
|
||
out.status.success(),
|
||
"no-flags r-sma sweep exit: {:?}; stderr: {}",
|
||
out.status,
|
||
String::from_utf8_lossy(&out.stderr)
|
||
);
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8");
|
||
let lines: Vec<&str> = stdout.lines().collect();
|
||
assert_eq!(lines.len(), 4, "no-flags grid = the historical 2×2 = 4 members: {stdout:?}");
|
||
// the four members are exactly the {2,3}×{6,12} fast×slow cartesian product,
|
||
// each with the historical pinned stop (stop_length=3, stop_k=2.0).
|
||
let mut fast_slow: Vec<(&str, &str)> = Vec::new();
|
||
for m in &lines {
|
||
assert!(
|
||
m.contains("[\"stop_length\",{\"I64\":3}]") && m.contains("[\"stop_k\",{\"F64\":2.0}]"),
|
||
"no-flags member must keep the historical pinned stop: {m}"
|
||
);
|
||
let fast = if m.contains("[\"fast.length\",{\"I64\":2}]") {
|
||
"2"
|
||
} else {
|
||
assert!(m.contains("[\"fast.length\",{\"I64\":3}]"), "fast must be 2 or 3: {m}");
|
||
"3"
|
||
};
|
||
let slow = if m.contains("[\"slow.length\",{\"I64\":6}]") {
|
||
"6"
|
||
} else {
|
||
assert!(m.contains("[\"slow.length\",{\"I64\":12}]"), "slow must be 6 or 12: {m}");
|
||
"12"
|
||
};
|
||
fast_slow.push((fast, slow));
|
||
}
|
||
fast_slow.sort_unstable();
|
||
assert_eq!(
|
||
fast_slow,
|
||
vec![("2", "12"), ("2", "6"), ("3", "12"), ("3", "6")],
|
||
"no-flags grid must be exactly {{2,3}}×{{6,12}}: {fast_slow:?}"
|
||
);
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// Extract the balanced `"metrics":{...}` object substring from one stdout member
|
||
/// line. The `family_id` prefix legitimately differs between a no-trace sweep
|
||
/// (`sweep-0`) and a `--trace n` sweep (`n-0`), so an equivalence check must
|
||
/// compare only the `metrics` block (the reduced/raw output under test), not the
|
||
/// whole line. Brace-balanced scan, not a JSON parser (no serde dep in this test
|
||
/// crate); the member lines are flat enough for this to be exact.
|
||
fn metrics_object(line: &str) -> &str {
|
||
let key = "\"metrics\":";
|
||
let start = line.find(key).expect("member line has a metrics block") + key.len();
|
||
let bytes = line.as_bytes();
|
||
assert_eq!(bytes[start], b'{', "metrics value must be an object: {line}");
|
||
let mut depth = 0usize;
|
||
for (i, &b) in bytes[start..].iter().enumerate() {
|
||
match b {
|
||
b'{' => depth += 1,
|
||
b'}' => {
|
||
depth -= 1;
|
||
if depth == 0 {
|
||
return &line[start..start + i + 1];
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
panic!("unbalanced metrics object: {line}")
|
||
}
|
||
|
||
/// Property (#138, task 8 — the streaming-reduction wiring invariant): a no-trace
|
||
/// `aura sweep --strategy r-sma` (the *folded* path — `SeriesReducer` folds the
|
||
/// eq/ex series to one summary row each, `GatedRecorder` retains only the gated R
|
||
/// rows, so memory is O(trades) not O(cycles)) reports **byte-for-byte the same
|
||
/// `metrics` object per member** as the `--trace` path (the *raw-recorder*
|
||
/// reference that buffers every per-cycle row). This pins the actual M3 fix at the
|
||
/// CLI seam: the engine-level Task-7 equivalence tests prove the sink nodes agree
|
||
/// in isolation, but only this end-to-end check proves the CLI *wiring* —
|
||
/// `reduce = trace.is_none()` selecting the folding topology, and the folded
|
||
/// consumer reconstructing `RunMetrics` (`total_pips`/`max_drawdown` from the eq
|
||
/// `SeriesReducer` row, `bias_sign_flips` from the ex row, `r` from `summarize_r`
|
||
/// over the gated rows) — produces the identical observable report. Members are
|
||
/// matched in stdout order (both paths iterate the same deterministic grid, C1),
|
||
/// and the `metrics` block excludes the family_id, which is allowed to differ.
|
||
#[test]
|
||
fn sweep_strategy_r_sma_folded_no_trace_metrics_equal_raw_trace_metrics() {
|
||
let cwd = temp_cwd("sweep-r-sma-fold-vs-raw");
|
||
|
||
// folded path: no --trace -> reduce = true (SeriesReducer + GatedRecorder).
|
||
let folded = Command::new(BIN)
|
||
.args(["sweep", "--strategy", "r-sma"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn folded (no-trace) r-sma sweep");
|
||
assert!(
|
||
folded.status.success(),
|
||
"folded no-trace sweep exit: {:?}; stderr: {}",
|
||
folded.status,
|
||
String::from_utf8_lossy(&folded.stderr)
|
||
);
|
||
let folded_out = String::from_utf8(folded.stdout).expect("utf-8");
|
||
|
||
// raw path: --trace -> reduce = false (the verbatim Recorder reference).
|
||
let raw = Command::new(BIN)
|
||
.args(["sweep", "--strategy", "r-sma", "--trace", "t1"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn raw (--trace) r-sma sweep");
|
||
assert!(
|
||
raw.status.success(),
|
||
"raw --trace sweep exit: {:?}; stderr: {}",
|
||
raw.status,
|
||
String::from_utf8_lossy(&raw.stderr)
|
||
);
|
||
let raw_out = String::from_utf8(raw.stdout).expect("utf-8");
|
||
|
||
let folded_lines: Vec<&str> = folded_out.lines().collect();
|
||
let raw_lines: Vec<&str> = raw_out.lines().collect();
|
||
// both paths run the same 2×2 grid -> 4 members, in the same deterministic order.
|
||
assert_eq!(folded_lines.len(), 4, "folded sweep must print 4 members: {folded_out:?}");
|
||
assert_eq!(
|
||
raw_lines.len(),
|
||
folded_lines.len(),
|
||
"the two paths must yield the same member count: folded {} vs raw {}",
|
||
folded_lines.len(),
|
||
raw_lines.len()
|
||
);
|
||
|
||
for (i, (f, r)) in folded_lines.iter().zip(raw_lines.iter()).enumerate() {
|
||
let fm = metrics_object(f);
|
||
let rm = metrics_object(r);
|
||
// the metrics block is non-trivial: it must carry the R yardstick, so this
|
||
// is comparing a real reduced output, not two empty objects.
|
||
assert!(fm.contains("\"r\":{"), "folded member {i} must carry an r block: {fm}");
|
||
assert_eq!(
|
||
fm, rm,
|
||
"folded vs raw metrics diverge at member {i}\n folded: {fm}\n raw: {rm}"
|
||
);
|
||
}
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// The breakout strategy reaches the CLI seam: `aura sweep --strategy r-breakout`
|
||
/// emits an R-bearing member, and the folded no-trace path equals the raw --trace path
|
||
/// byte-for-byte (parity with the r-sma fold-vs-raw guard). channel 3 warms up on
|
||
/// the synthetic stream; one channel value × the single default stop = one member.
|
||
#[test]
|
||
fn sweep_strategy_r_breakout_folded_no_trace_metrics_equal_raw_trace_metrics() {
|
||
let cwd = temp_cwd("sweep-r-breakout-fold-vs-raw");
|
||
|
||
let folded = Command::new(BIN)
|
||
.args(["sweep", "--strategy", "r-breakout", "--channel", "3"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn folded (no-trace) r-breakout sweep");
|
||
assert!(
|
||
folded.status.success(),
|
||
"folded no-trace breakout sweep exit: {:?}; stderr: {}",
|
||
folded.status,
|
||
String::from_utf8_lossy(&folded.stderr)
|
||
);
|
||
let folded_out = String::from_utf8(folded.stdout).expect("utf-8");
|
||
|
||
let raw = Command::new(BIN)
|
||
.args(["sweep", "--strategy", "r-breakout", "--channel", "3", "--trace", "b1"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn raw (--trace) r-breakout sweep");
|
||
assert!(
|
||
raw.status.success(),
|
||
"raw --trace breakout sweep exit: {:?}; stderr: {}",
|
||
raw.status,
|
||
String::from_utf8_lossy(&raw.stderr)
|
||
);
|
||
let raw_out = String::from_utf8(raw.stdout).expect("utf-8");
|
||
|
||
let folded_lines: Vec<&str> = folded_out.lines().collect();
|
||
let raw_lines: Vec<&str> = raw_out.lines().collect();
|
||
assert_eq!(folded_lines.len(), 1, "folded breakout sweep must print 1 member: {folded_out:?}");
|
||
assert_eq!(
|
||
raw_lines.len(),
|
||
folded_lines.len(),
|
||
"member count must match: folded {} vs raw {}",
|
||
folded_lines.len(),
|
||
raw_lines.len()
|
||
);
|
||
|
||
for (i, (f, r)) in folded_lines.iter().zip(raw_lines.iter()).enumerate() {
|
||
let fm = metrics_object(f);
|
||
let rm = metrics_object(r);
|
||
assert!(fm.contains("\"r\":{"), "folded breakout member {i} must carry an r block: {fm}");
|
||
assert_eq!(
|
||
fm, rm,
|
||
"folded vs raw breakout metrics diverge at member {i}\n folded: {fm}\n raw: {rm}"
|
||
);
|
||
}
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// Property: the breakout family is the cartesian product of its grid axes — a
|
||
/// multi-value `--channel` list yields exactly one member per channel value, each
|
||
/// member carrying its own bound channel length in the manifest params. Guards the
|
||
/// manual cartesian-product loop in `r_breakout_sweep_family`: a regression that
|
||
/// collapsed the loop (sweeping only the last value, or ganging the two channels)
|
||
/// would silently drop members here. Channels 2 and 3 both warm up on the 18-bar
|
||
/// synthetic stream, so two members must appear, in grid order, distinctly.
|
||
#[test]
|
||
fn sweep_strategy_r_breakout_grids_one_member_per_channel() {
|
||
let cwd = temp_cwd("sweep-r-breakout-channel-grid");
|
||
let out = Command::new(BIN)
|
||
.args(["sweep", "--strategy", "r-breakout", "--channel", "2,3"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn 2-channel r-breakout sweep");
|
||
assert!(
|
||
out.status.success(),
|
||
"channel-grid breakout sweep exit: {:?}; stderr: {}",
|
||
out.status,
|
||
String::from_utf8_lossy(&out.stderr)
|
||
);
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8");
|
||
let lines: Vec<&str> = stdout.lines().collect();
|
||
assert_eq!(lines.len(), 2, "2-channel grid must print 2 members: {stdout:?}");
|
||
// each member carries its own bound channel length (grid order: 2 then 3).
|
||
assert!(
|
||
lines[0].contains("[\"channel\",{\"I64\":2}]"),
|
||
"first member must bind channel=2: {}",
|
||
lines[0]
|
||
);
|
||
assert!(
|
||
lines[1].contains("[\"channel\",{\"I64\":3}]"),
|
||
"second member must bind channel=3: {}",
|
||
lines[1]
|
||
);
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// The mean-reversion strategy reaches the CLI seam: `aura sweep --strategy
|
||
/// r-meanrev` emits an R-bearing member, and the folded no-trace path equals
|
||
/// the raw --trace path byte-for-byte (parity with the r-sma / breakout
|
||
/// fold-vs-raw guard). window 3 warms up on the synthetic stream; one window ×
|
||
/// one band_k × the single default stop = one member.
|
||
#[test]
|
||
fn sweep_strategy_r_meanrev_folded_no_trace_metrics_equal_raw_trace_metrics() {
|
||
let cwd = temp_cwd("sweep-r-meanrev-fold-vs-raw");
|
||
|
||
let folded = Command::new(BIN)
|
||
.args(["sweep", "--strategy", "r-meanrev", "--window", "3"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn folded (no-trace) r-meanrev sweep");
|
||
assert!(
|
||
folded.status.success(),
|
||
"folded no-trace meanrev sweep exit: {:?}; stderr: {}",
|
||
folded.status,
|
||
String::from_utf8_lossy(&folded.stderr)
|
||
);
|
||
let folded_out = String::from_utf8(folded.stdout).expect("utf-8");
|
||
|
||
let raw = Command::new(BIN)
|
||
.args(["sweep", "--strategy", "r-meanrev", "--window", "3", "--trace", "m1"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn raw (--trace) r-meanrev sweep");
|
||
assert!(
|
||
raw.status.success(),
|
||
"raw --trace meanrev sweep exit: {:?}; stderr: {}",
|
||
raw.status,
|
||
String::from_utf8_lossy(&raw.stderr)
|
||
);
|
||
let raw_out = String::from_utf8(raw.stdout).expect("utf-8");
|
||
|
||
let folded_lines: Vec<&str> = folded_out.lines().collect();
|
||
let raw_lines: Vec<&str> = raw_out.lines().collect();
|
||
assert_eq!(folded_lines.len(), 1, "folded meanrev sweep must print 1 member: {folded_out:?}");
|
||
assert_eq!(
|
||
raw_lines.len(),
|
||
folded_lines.len(),
|
||
"member count must match: folded {} vs raw {}",
|
||
folded_lines.len(),
|
||
raw_lines.len()
|
||
);
|
||
|
||
for (i, (f, r)) in folded_lines.iter().zip(raw_lines.iter()).enumerate() {
|
||
let fm = metrics_object(f);
|
||
let rm = metrics_object(r);
|
||
assert!(fm.contains("\"r\":{"), "folded meanrev member {i} must carry an r block: {fm}");
|
||
assert_eq!(
|
||
fm, rm,
|
||
"folded vs raw meanrev metrics diverge at member {i}\n folded: {fm}\n raw: {rm}"
|
||
);
|
||
}
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// Property: the meanrev family is the cartesian product of its grid axes — a
|
||
/// multi-value `--window` list yields exactly one member per window value, each
|
||
/// carrying its own bound window length in the manifest params. Guards the manual
|
||
/// cartesian loop in `r_meanrev_sweep_family`. windows 3 and 4 both warm up
|
||
/// on the 18-bar synthetic stream, so two members must appear, in grid order.
|
||
#[test]
|
||
fn sweep_strategy_r_meanrev_grids_one_member_per_window() {
|
||
let cwd = temp_cwd("sweep-r-meanrev-window-grid");
|
||
let out = Command::new(BIN)
|
||
.args(["sweep", "--strategy", "r-meanrev", "--window", "3,4"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn 2-window r-meanrev sweep");
|
||
assert!(
|
||
out.status.success(),
|
||
"window-grid meanrev sweep exit: {:?}; stderr: {}",
|
||
out.status,
|
||
String::from_utf8_lossy(&out.stderr)
|
||
);
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8");
|
||
let lines: Vec<&str> = stdout.lines().collect();
|
||
assert_eq!(lines.len(), 2, "2-window grid must print 2 members: {stdout:?}");
|
||
assert!(
|
||
lines[0].contains("[\"window\",{\"I64\":3}]"),
|
||
"first member must bind window=3: {}",
|
||
lines[0]
|
||
);
|
||
assert!(
|
||
lines[1].contains("[\"window\",{\"I64\":4}]"),
|
||
"second member must bind window=4: {}",
|
||
lines[1]
|
||
);
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// Property: `aura walkforward --strategy r-sma` on synthetic data reports R
|
||
/// quality per window AND pooled across windows — every per-window member line
|
||
/// carries a `metrics.r` block (the windowed reduce-mode IS sweep folds the dense
|
||
/// R-record), and the summary line carries a pooled `oos_r` block (the across-window
|
||
/// reduction of the OOS per-trade R series). Deterministic: a second run is
|
||
/// byte-identical (C1).
|
||
#[test]
|
||
fn walkforward_strategy_r_sma_reports_oos_r() {
|
||
let run = || {
|
||
let cwd = temp_cwd("wf-r-sma");
|
||
let out = Command::new(BIN)
|
||
.args(["walkforward", "--strategy", "r-sma"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura walkforward --strategy r-sma");
|
||
assert!(
|
||
out.status.success(),
|
||
"walkforward --strategy r-sma exit: {:?}; stderr: {}",
|
||
out.status,
|
||
String::from_utf8_lossy(&out.stderr)
|
||
);
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8");
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
stdout
|
||
};
|
||
let out = run();
|
||
let lines: Vec<&str> = out.trim().lines().collect();
|
||
let summary: serde_json::Value = serde_json::from_str(lines.last().unwrap()).unwrap();
|
||
assert!(summary["walkforward"]["oos_r"].is_object(), "summary carries oos_r");
|
||
assert!(summary["walkforward"]["oos_r"]["n_trades"].is_number());
|
||
// each per-window member line carries metrics.r
|
||
for l in &lines[..lines.len() - 1] {
|
||
let v: serde_json::Value = serde_json::from_str(l).unwrap();
|
||
assert!(v["report"]["metrics"]["r"].is_object(), "per-window r block present");
|
||
}
|
||
let out2 = run();
|
||
assert_eq!(out, out2, "r-sma walkforward is deterministic");
|
||
}
|
||
|
||
/// Property: a r-sma walk-forward stamps each OOS winner's manifest with the
|
||
/// trials-deflation provenance (#144), and `runs family <id> rank` surfaces it as a
|
||
/// human-readable `deflated=… P(overfit)=…` line beside each member's JSON. This is
|
||
/// the end-to-end witness that the selection record reaches disk (C18) and the
|
||
/// display path renders it; sweep/mc families stay unstamped (verified elsewhere).
|
||
#[test]
|
||
fn runs_family_rank_shows_deflated_line() {
|
||
let cwd = temp_cwd("runs-deflated");
|
||
let wf = Command::new(BIN)
|
||
.args(["walkforward", "--strategy", "r-sma", "--name", "wf-defl"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn walkforward --strategy r-sma --name wf-defl");
|
||
assert!(
|
||
wf.status.success(),
|
||
"walkforward exit: {:?}; stderr: {}",
|
||
wf.status,
|
||
String::from_utf8_lossy(&wf.stderr)
|
||
);
|
||
|
||
let rank = Command::new(BIN)
|
||
.args(["runs", "family", "wf-defl-0", "rank", "sqn_normalized"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn runs family wf-defl-0 rank sqn_normalized");
|
||
assert!(rank.status.success(), "rank exit: {:?}", rank.status);
|
||
let rank_out = String::from_utf8(rank.stdout).expect("utf-8");
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
|
||
assert!(rank_out.contains("deflated="), "rank output missing deflated=: {rank_out:?}");
|
||
assert!(rank_out.contains("P(overfit)="), "rank output missing P(overfit)=: {rank_out:?}");
|
||
// each stamped member's JSON also carries the selection block (selection_metric).
|
||
assert!(
|
||
rank_out.contains("\"selection_metric\":\"sqn_normalized\""),
|
||
"selection block present: {rank_out:?}"
|
||
);
|
||
}
|
||
|
||
/// Property: a r-sma walk-forward run with `--select plateau:mean` selects the
|
||
/// neighbourhood-smoothed winner and stamps each OOS manifest with the plateau
|
||
/// provenance (`mode = PlateauMean`, `neighbourhood_score`, `n_neighbours`); `runs
|
||
/// family … rank` renders the `plateau(mean)=… over N cells` line, and the
|
||
/// deflation fields are omitted (orthogonal annotation). End-to-end witness that
|
||
/// the opt-in selection rule reaches disk (C18) and the display path renders it.
|
||
#[test]
|
||
fn walkforward_plateau_select_stamps_plateau_provenance() {
|
||
let cwd = temp_cwd("runs-plateau");
|
||
let wf = Command::new(BIN)
|
||
.args([
|
||
"walkforward", "--strategy", "r-sma", "--select", "plateau:mean",
|
||
"--fast", "50,100", "--slow", "200,400",
|
||
"--stop-length", "14,21", "--stop-k", "2.0,3.0",
|
||
"--name", "wf-plat",
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn walkforward --select plateau:mean");
|
||
assert!(
|
||
wf.status.success(),
|
||
"walkforward exit: {:?}; stderr: {}",
|
||
wf.status,
|
||
String::from_utf8_lossy(&wf.stderr)
|
||
);
|
||
|
||
let rank = Command::new(BIN)
|
||
.args(["runs", "family", "wf-plat-0", "rank", "sqn_normalized"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn runs family wf-plat-0 rank sqn_normalized");
|
||
assert!(rank.status.success(), "rank exit: {:?}", rank.status);
|
||
let rank_out = String::from_utf8(rank.stdout).expect("utf-8");
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
|
||
assert!(rank_out.contains("plateau(mean)="), "rank output missing plateau(mean)=: {rank_out:?}");
|
||
assert!(rank_out.contains(" cells"), "rank output missing 'over N cells': {rank_out:?}");
|
||
assert!(rank_out.contains("\"mode\":\"PlateauMean\""), "manifest carries PlateauMean: {rank_out:?}");
|
||
assert!(!rank_out.contains("\"deflated_score\""), "plateau run omits deflated_score: {rank_out:?}");
|
||
}
|
||
|
||
/// Property (C23, the opt-in feature's headline guarantee): `--select argmax` is a
|
||
/// no-op against the default. A r-sma walk-forward run with an EXPLICIT
|
||
/// `--select argmax` produces stdout byte-for-byte identical to the same run with
|
||
/// no `--select` flag at all, AND keeps the trials-deflation provenance
|
||
/// (`mode == Argmax`, the deflated annotation) on each OOS manifest. Pins both
|
||
/// halves of "plateau is strictly opt-in": the flag's default IS argmax (no
|
||
/// divergence), and threading the new `Selection` did not silently demote argmax to
|
||
/// a bare pick (the #144 deflation path survives). A regression where `--select`
|
||
/// perturbed the default path, or where the default switched away from argmax,
|
||
/// passes every other test but fails this byte-equality.
|
||
#[test]
|
||
fn walkforward_select_argmax_is_byte_identical_to_default() {
|
||
let run = |args: &[&str]| {
|
||
let cwd = temp_cwd("wf-argmax-noop");
|
||
let out = Command::new(BIN)
|
||
.arg("walkforward")
|
||
.args(args)
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura walkforward");
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
assert!(
|
||
out.status.success(),
|
||
"walkforward exit: {:?}; stderr: {}",
|
||
out.status,
|
||
String::from_utf8_lossy(&out.stderr)
|
||
);
|
||
String::from_utf8(out.stdout).expect("utf-8")
|
||
};
|
||
let default = run(&["--strategy", "r-sma"]);
|
||
let explicit = run(&["--strategy", "r-sma", "--select", "argmax"]);
|
||
assert_eq!(default, explicit, "explicit --select argmax must be a no-op vs the default");
|
||
// argmax stayed the deflation path (not a bare argmax): the per-window member
|
||
// JSON carries the Argmax mode and its deflation annotation.
|
||
assert!(default.contains("\"mode\":\"Argmax\""), "argmax manifest mode: {default:?}");
|
||
assert!(default.contains("\"deflated_score\""), "argmax keeps the deflation annotation: {default:?}");
|
||
assert!(!default.contains("\"neighbourhood_score\""), "argmax omits the plateau annotation: {default:?}");
|
||
}
|
||
|
||
/// Property: `--select plateau:worst` reaches disk and the display path through the
|
||
/// DISTINCT worst-case branch — a separate `SelectionMode::PlateauWorst` enum
|
||
/// variant on the wire and a separate `plateau(worst)=` display label. The landed
|
||
/// happy-path E2E exercises only `plateau:mean`; the worst arm's serialization
|
||
/// variant and its display label are otherwise untouched end-to-end, so a
|
||
/// regression that mislabelled worst as mean (or failed to round-trip the variant)
|
||
/// would pass the mean test. Same fixture grid as the mean E2E, only the rule
|
||
/// differs.
|
||
#[test]
|
||
fn walkforward_plateau_worst_stamps_worst_variant_and_label() {
|
||
let cwd = temp_cwd("runs-plateau-worst");
|
||
let wf = Command::new(BIN)
|
||
.args([
|
||
"walkforward", "--strategy", "r-sma", "--select", "plateau:worst",
|
||
"--fast", "50,100", "--slow", "200,400",
|
||
"--stop-length", "14,21", "--stop-k", "2.0,3.0",
|
||
"--name", "wf-worst",
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn walkforward --select plateau:worst");
|
||
assert!(
|
||
wf.status.success(),
|
||
"walkforward exit: {:?}; stderr: {}",
|
||
wf.status,
|
||
String::from_utf8_lossy(&wf.stderr)
|
||
);
|
||
|
||
let rank = Command::new(BIN)
|
||
.args(["runs", "family", "wf-worst-0", "rank", "sqn_normalized"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn runs family wf-worst-0 rank sqn_normalized");
|
||
assert!(rank.status.success(), "rank exit: {:?}", rank.status);
|
||
let rank_out = String::from_utf8(rank.stdout).expect("utf-8");
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
|
||
assert!(rank_out.contains("plateau(worst)="), "rank output missing plateau(worst)=: {rank_out:?}");
|
||
assert!(rank_out.contains("\"mode\":\"PlateauWorst\""), "manifest carries PlateauWorst: {rank_out:?}");
|
||
// the worst label must not be rendered or stamped as mean
|
||
assert!(!rank_out.contains("plateau(mean)="), "worst run must not render the mean label: {rank_out:?}");
|
||
assert!(!rank_out.contains("\"mode\":\"PlateauMean\""), "worst run must not stamp PlateauMean: {rank_out:?}");
|
||
}
|
||
|
||
/// Property: the bare `aura walkforward` (default SMA-cross) summary is preserved
|
||
/// byte-for-byte by the new `--strategy`/grid plumbing — it still carries exactly
|
||
/// `windows`, `stitched_total_pips`, `param_stability` and NOT the r-sma-only
|
||
/// `oos_r` block. The spec promised the bare path's golden is unchanged; the
|
||
/// signature widening defaults to SmaCross and the summary gates `oos_r` on
|
||
/// `metrics.r.is_some()`, so a regression that leaked R-reporting (or any other
|
||
/// key) into the SMA summary would be a silent contract break. This pins the
|
||
/// negative: the SMA arm produces no `metrics.r`, hence no `oos_r`.
|
||
#[test]
|
||
fn walkforward_bare_sma_summary_has_no_oos_r() {
|
||
let cwd = temp_cwd("wf-bare-sma");
|
||
let out = Command::new(BIN)
|
||
.arg("walkforward")
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura walkforward");
|
||
assert!(out.status.success(), "bare walkforward exit: {:?}", out.status);
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8");
|
||
let lines: Vec<&str> = stdout.trim().lines().collect();
|
||
let summary: serde_json::Value = serde_json::from_str(lines.last().unwrap()).unwrap();
|
||
let wf = &summary["walkforward"];
|
||
assert!(wf["oos_r"].is_null(), "bare SMA summary must NOT carry oos_r: {wf}");
|
||
assert!(wf["windows"].is_number(), "bare SMA summary keeps windows: {wf}");
|
||
assert!(wf["stitched_total_pips"].is_number(), "bare SMA summary keeps stitched_total_pips: {wf}");
|
||
assert!(wf["param_stability"].is_array(), "bare SMA summary keeps param_stability: {wf}");
|
||
// no per-window member line carries a metrics.r block (R-reporting is r-sma-only)
|
||
for l in &lines[..lines.len() - 1] {
|
||
let v: serde_json::Value = serde_json::from_str(l).unwrap();
|
||
assert!(v["report"]["metrics"]["r"].is_null(), "bare SMA per-window line must NOT carry metrics.r: {l}");
|
||
}
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// Property: a strategy that parses but has no walk-forward form yet (e.g.
|
||
/// `r-breakout`) is rejected with exit 2 and a stderr message that names the
|
||
/// OFFENDING strategy by its CLI token — not silently run as SMA, not crashed.
|
||
/// The dispatch's catch-all arm routes through `Strategy::cli_token()`, so the
|
||
/// diagnostic must echo the token the user typed (`r-breakout`), proving the
|
||
/// inverse map is wired. Distinct from a bad `--strategy bogus` (a usage parse
|
||
/// error): here the token is a valid strategy with no walk-forward arm.
|
||
#[test]
|
||
fn walkforward_unsupported_strategy_exits_2_naming_the_token() {
|
||
let cwd = temp_cwd("wf-unsupported");
|
||
let out = Command::new(BIN)
|
||
.args(["walkforward", "--strategy", "r-breakout"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura walkforward --strategy r-breakout");
|
||
assert_eq!(out.status.code(), Some(2), "unsupported walkforward strategy must exit 2");
|
||
let stderr = String::from_utf8(out.stderr).expect("utf-8 stderr");
|
||
assert!(
|
||
stderr.contains("r-breakout"),
|
||
"stderr must name the offending strategy token: {stderr:?}"
|
||
);
|
||
assert!(out.stdout.is_empty(), "no family summary on the rejected path: {:?}", out.stdout);
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// Property: `aura mc --strategy r-sma` runs the r-sma walk-forward on
|
||
/// synthetic data, bootstraps the pooled OOS per-trade R series, and prints exactly
|
||
/// one canonical `mc_r_bootstrap` line carrying an `e_r` distribution block and
|
||
/// `prob_le_zero`. Deterministic: a second run with the same (default) seed is
|
||
/// byte-identical (C1). Frictionless gross R — no costs, no `runs/` family write.
|
||
#[test]
|
||
fn mc_strategy_r_sma_prints_a_bootstrap_line_deterministically() {
|
||
let dir = temp_cwd("mc_r_sma_boot");
|
||
let run = || {
|
||
Command::new(BIN)
|
||
.args(["mc", "--strategy", "r-sma", "--resamples", "200"])
|
||
.current_dir(&dir)
|
||
.output()
|
||
.expect("spawn aura mc --strategy r-sma")
|
||
};
|
||
let out = run();
|
||
assert!(out.status.success(), "exit: {:?} stderr: {}", out.status, String::from_utf8_lossy(&out.stderr));
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
assert_eq!(stdout.lines().count(), 1, "exactly one mc_r_bootstrap line: {stdout:?}");
|
||
let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("canonical JSON");
|
||
assert!(v["mc_r_bootstrap"]["e_r"]["mean"].is_number(), "e_r block present: {stdout}");
|
||
assert!(v["mc_r_bootstrap"]["prob_le_zero"].is_number(), "prob_le_zero present: {stdout}");
|
||
assert_eq!(v["mc_r_bootstrap"]["n_resamples"], 200);
|
||
|
||
let out2 = run();
|
||
assert_eq!(stdout.as_bytes(), out2.stdout.as_slice(), "same seed -> byte-identical line (C1)");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// Property: `--block-len` is the moving-block knob, and it is observable AND
|
||
/// load-bearing at the binary boundary — it round-trips into the emitted
|
||
/// `block_len` field, and a different `block-len` (over the same seed + pooled R
|
||
/// series) yields a genuinely different E[R] distribution. This is what separates
|
||
/// the moving-block bootstrap from a plain i.i.d. trade shuffle: contiguous runs
|
||
/// preserve serial correlation a `block-len 1` shuffle erases. Without this test a
|
||
/// regression that dropped `--block-len` on the floor (or wired it to `block_len 1`
|
||
/// always) would still pass the happy-path E2E. Same-seed determinism (C1) pins
|
||
/// that the difference is the block length, not RNG noise. Not gated — synthetic.
|
||
#[test]
|
||
fn mc_r_sma_block_len_is_observable_and_changes_the_distribution() {
|
||
let dir = temp_cwd("mc_r_sma_block");
|
||
let run = |block_len: &str| {
|
||
let out = Command::new(BIN)
|
||
.args(["mc", "--strategy", "r-sma", "--resamples", "256", "--seed", "1", "--block-len", block_len])
|
||
.current_dir(&dir)
|
||
.output()
|
||
.expect("spawn aura mc --strategy r-sma --block-len");
|
||
assert!(out.status.success(), "exit: {:?} stderr: {}", out.status, String::from_utf8_lossy(&out.stderr));
|
||
String::from_utf8(out.stdout).expect("utf-8 stdout")
|
||
};
|
||
|
||
let one = run("1");
|
||
let three = run("3");
|
||
|
||
let v1: serde_json::Value = serde_json::from_str(one.trim()).expect("canonical JSON (block-len 1)");
|
||
let v3: serde_json::Value = serde_json::from_str(three.trim()).expect("canonical JSON (block-len 3)");
|
||
// the flag round-trips verbatim into the emitted line.
|
||
assert_eq!(v1["mc_r_bootstrap"]["block_len"], 1, "block_len 1 must echo: {one}");
|
||
assert_eq!(v3["mc_r_bootstrap"]["block_len"], 3, "block_len 3 must echo: {three}");
|
||
// same seed, same pooled series, different block length -> different E[R] spread.
|
||
// (a no-op `--block-len` would make these byte-identical.)
|
||
assert_ne!(one, three, "block-len must change the resampled distribution, not be ignored");
|
||
|
||
// C1 at the CLI edge: the block-len-1 line is byte-identical on re-run.
|
||
assert_eq!(one, run("1"), "same seed+block-len -> byte-identical line (C1)");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// Property: a `--block-len` at or above the pooled-series length is CLAMPED to the
|
||
/// series length at the binary boundary (no panic, no out-of-bounds), and the
|
||
/// degenerate single-block case collapses the bootstrap — when every resample IS
|
||
/// the full series, the E[R] distribution becomes a single point (all quantiles
|
||
/// equal). The emitted `block_len` reports the clamped value, not the raw flag.
|
||
/// This pins the clamp the engine primitive guards in-crate, now at the observable
|
||
/// CLI edge where an off-by-one in the index arithmetic would otherwise surface as
|
||
/// a process crash. Not gated — synthetic r-sma pools a fixed, non-empty series.
|
||
#[test]
|
||
fn mc_r_sma_oversized_block_len_clamps_and_collapses() {
|
||
let dir = temp_cwd("mc_r_sma_clamp");
|
||
let out = Command::new(BIN)
|
||
.args(["mc", "--strategy", "r-sma", "--resamples", "200", "--seed", "1", "--block-len", "9999"])
|
||
.current_dir(&dir)
|
||
.output()
|
||
.expect("spawn aura mc --strategy r-sma --block-len 9999");
|
||
assert!(out.status.success(), "must not crash on oversized block-len: {:?} stderr: {}",
|
||
out.status, String::from_utf8_lossy(&out.stderr));
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("canonical JSON");
|
||
let obj = &v["mc_r_bootstrap"];
|
||
let n_trades = obj["n_trades"].as_u64().expect("n_trades is an integer");
|
||
assert!(n_trades >= 1, "synthetic r-sma must pool a non-empty R series: {stdout}");
|
||
// block_len is reported clamped to the pooled-series length, never the raw 9999.
|
||
assert_eq!(obj["block_len"], serde_json::json!(n_trades),
|
||
"oversized block-len must clamp to n_trades: {stdout}");
|
||
// single-block degenerate: every resample is the full series, so the E[R]
|
||
// distribution is a single point — p5 == p95 (zero spread).
|
||
let p5 = obj["e_r"]["p5"].as_f64().expect("p5 is a number");
|
||
let p95 = obj["e_r"]["p95"].as_f64().expect("p95 is a number");
|
||
assert!((p5 - p95).abs() < 1e-12, "single-block bootstrap must have zero spread: p5={p5} p95={p95}");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// 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 = temp_cwd("generalize-two");
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"generalize", "--strategy", "r-sma", "--real", "GER40,USDJPY",
|
||
"--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0",
|
||
"--from", FROM_MS, "--to", TO_MS,
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura");
|
||
if out.status.code() == Some(1) {
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(
|
||
stderr.contains("no local data") || stderr.contains("no recorded geometry"),
|
||
"exit 1 must be a data refusal, got: {stderr}"
|
||
);
|
||
eprintln!("skip: no local GER40/USDJPY data");
|
||
return;
|
||
}
|
||
assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status);
|
||
let 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}");
|
||
}
|
||
|
||
/// 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 out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"generalize", "--strategy", "r-sma", "--real", "GER40",
|
||
"--fast", "3", "--slow", "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 out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"generalize", "--strategy", "r-sma", "--real", "GER40,USDJPY",
|
||
"--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0",
|
||
"--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 out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"generalize", "--strategy", "r-sma", "--real", "GER40,GER40",
|
||
"--fast", "3", "--slow", "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 non-`r-sma` strategy is refused at the binary boundary (exit 2,
|
||
/// no run, data-free). The candidate must produce R (C10) — the cross-instrument
|
||
/// reduction is R-only — so only the r-sma grid is an admissible candidate. The
|
||
/// parser unit test pins the grammar refusal; this pins that `--strategy sma`
|
||
/// reaches `exit(2)` through the dispatch arm rather than silently defaulting to a
|
||
/// running candidate. Asserts on any machine (the strategy check precedes any run).
|
||
#[test]
|
||
fn generalize_refuses_a_non_r_sma_strategy() {
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"generalize", "--strategy", "sma", "--real", "GER40,USDJPY",
|
||
"--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0",
|
||
])
|
||
.output()
|
||
.expect("spawn aura");
|
||
assert_eq!(out.status.code(), Some(2), "a non-r-sma strategy must exit 2");
|
||
assert!(
|
||
out.stdout.is_empty(),
|
||
"the refusal must not leak an aggregate to stdout, got: {:?}",
|
||
String::from_utf8_lossy(&out.stdout)
|
||
);
|
||
}
|
||
|
||
/// Property: a multi-value candidate flag is refused at the binary boundary (exit 2,
|
||
/// no run, data-free). A *candidate* is a single grid cell, not a sweep — so any of
|
||
/// `--fast/--slow/--stop-length/--stop-k` carrying more than one value (`--fast 2,3`)
|
||
/// is refused, never silently widened into a multi-cell run whose generalization
|
||
/// grade would be ill-defined (the reduction grades one candidate, not a family).
|
||
/// The parser unit test pins the grammar; this pins the wiring to `exit(2)`.
|
||
#[test]
|
||
fn generalize_refuses_a_multi_value_candidate_flag() {
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"generalize", "--strategy", "r-sma", "--real", "GER40,USDJPY",
|
||
"--fast", "2,3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0",
|
||
])
|
||
.output()
|
||
.expect("spawn aura");
|
||
assert_eq!(out.status.code(), Some(2), "a multi-value candidate flag must exit 2");
|
||
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 = temp_cwd("generalize-family");
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args([
|
||
"generalize", "--strategy", "r-sma", "--real", "GER40,USDJPY",
|
||
"--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0",
|
||
"--from", FROM_MS, "--to", TO_MS,
|
||
])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura");
|
||
if out.status.code() == Some(1) {
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(
|
||
stderr.contains("no local data") || stderr.contains("no recorded geometry"),
|
||
"exit 1 must be a data refusal, got: {stderr}"
|
||
);
|
||
eprintln!("skip: no local GER40/USDJPY data");
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
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");
|
||
assert_eq!(fams_out.lines().count(), 1, "families: {fams_out:?}");
|
||
assert!(fams_out.contains("\"family_id\":\"generalize-0\""), "families: {fams_out:?}");
|
||
assert!(fams_out.contains("\"kind\":\"CrossInstrument\""), "families: {fams_out:?}");
|
||
assert!(fams_out.contains("\"members\":2"), "one member per instrument: {fams_out:?}");
|
||
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// `aura sweep <blueprint.json> --axis <name>=<csv> …` (#166): sweeps a loaded OPEN
|
||
/// signal over its named param-space axes and persists a discoverable Sweep family.
|
||
/// Both open knobs (fast/slow) must be bound 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!("{}/tests/fixtures/sma_signal_open.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",
|
||
"--trace", "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 (#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_std::std_vocabulary;
|
||
use sha2::{Digest, Sha256};
|
||
|
||
let cwd = temp_cwd("blueprint-sweep-store");
|
||
let fixture = format!("{}/tests/fixtures/sma_signal_open.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",
|
||
"--trace", "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 OPEN fixture has
|
||
/// sweepable knobs, so a nonsense axis name (`nope`) is genuinely unknown — distinct
|
||
/// from a fully-bound blueprint, which is refused earlier as "nothing to sweep".
|
||
#[test]
|
||
fn aura_sweep_rejects_an_unknown_axis() {
|
||
let fixture = format!("{}/tests/fixtures/sma_signal_open.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!("{}/tests/fixtures/sma_signal_open.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",
|
||
"--trace", "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!("{}/tests/fixtures/sma_signal_open.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",
|
||
"--trace", "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!("{}/tests/fixtures/sma_signal_open.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!("{}/tests/fixtures/sma_signal.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!("{}/tests/fixtures/sma_signal_open.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!("{}/tests/fixtures/sma_signal_open.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): an OPEN 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!("{}/tests/fixtures/sma_signal_open.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, safety): a malformed blueprint is rejected at the dispatch boundary
|
||
/// (exit 2, UnknownNodeType) rather than panicking.
|
||
#[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("UnknownNodeType"), "stderr: {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!("{}/tests/fixtures/sma_signal_open.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("Knob") && stderr.contains("nope"),
|
||
"names the unresolved axis via BindError: {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!("{}/tests/fixtures/sma_signal.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("UnknownKnob").count();
|
||
assert_eq!(
|
||
emits, 1,
|
||
"attempt {attempt}: the axis rejection must be emitted exactly once, got {emits}: {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/sma_signal_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/sma_signal_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);
|
||
}
|
||
|
||
/// E2E (refuse-don't-guess): `aura reproduce` on a HARD-WIRED sweep family — its members
|
||
/// carry no `topology_hash` (not built from a blueprint) — exits non-zero, naming that the
|
||
/// member is not a generated run. Content-addressed reproduction covers only generated runs.
|
||
#[test]
|
||
fn aura_reproduce_rejects_a_non_generated_family() {
|
||
let cwd = temp_cwd("reproduce_non_generated");
|
||
let sweep = std::process::Command::new(BIN)
|
||
.args(["sweep", "--name", "hw"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("run aura sweep");
|
||
assert!(sweep.status.success(), "hard-wired sweep ok: {}", String::from_utf8_lossy(&sweep.stderr));
|
||
let out = std::process::Command::new(BIN)
|
||
.args(["reproduce", "hw-0"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("run aura reproduce");
|
||
assert!(!out.status.success(), "a non-generated family exits non-zero");
|
||
assert!(
|
||
String::from_utf8_lossy(&out.stderr).contains("not a generated run"),
|
||
"names the cause: {}",
|
||
String::from_utf8_lossy(&out.stderr)
|
||
);
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// 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!("{}/tests/fixtures/sma_signal_open.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): `aura sweep <open blueprint.json> --list-axes` prints one
|
||
/// `<name>:<kind>` line per open sweepable knob, in `param_space()` order, and
|
||
/// exits 0. The names are prefixed by the wrapping (sma_signal.*) — exactly
|
||
/// the strings `--axis` binds — so a user can discover then sweep without guessing.
|
||
#[test]
|
||
fn aura_sweep_list_axes_prints_prefixed_names() {
|
||
let bp = format!("{}/tests/fixtures/sma_signal_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\nsma_signal.slow.length:I64\n");
|
||
}
|
||
|
||
/// E2E (#169): `--list-axes` on a CLOSED blueprint (all knobs bound) prints
|
||
/// nothing and exits 0 — an empty axis set is the honest answer, not an error.
|
||
#[test]
|
||
fn aura_sweep_list_axes_closed_is_empty() {
|
||
let bp = format!("{}/tests/fixtures/sma_signal.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));
|
||
assert!(out.stdout.is_empty(), "a closed blueprint has no open axes");
|
||
}
|
||
|
||
/// 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!("{}/tests/fixtures/sma_signal_open.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, safety): `--list-axes` over a malformed blueprint rejects cleanly
|
||
/// (exit 2, naming UnknownNodeType) 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("UnknownNodeType"), "stderr was: {stderr}");
|
||
assert!(!stderr.contains("panicked"), "must reject cleanly, not panic: {stderr}");
|
||
}
|
||
|
||
/// 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/sma_signal_open.json", env!("CARGO_MANIFEST_DIR"));
|
||
|
||
// 1) discover the axis names (do NOT hardcode them) — this is what a user reads.
|
||
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> = listed
|
||
.lines()
|
||
.map(|l| l.split(':').next().expect("a `name:kind` line").to_string())
|
||
.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.
|
||
let mut args: Vec<String> = vec!["sweep".into(), bp.clone()];
|
||
for (i, name) in names.iter().enumerate() {
|
||
args.push("--axis".into());
|
||
args.push(format!("{name}={}", if i == 0 { "2,4" } else { "8,16" }));
|
||
}
|
||
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:?}");
|
||
}
|
||
}
|
||
|
||
/// 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`.
|
||
#[test]
|
||
fn gnu_equals_form_is_accepted() {
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["run", "--harness=sma"]).output().expect("spawn");
|
||
assert_eq!(out.status.code(), Some(0),
|
||
"run --harness=sma 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 the default harness).
|
||
#[test]
|
||
fn double_dash_terminator_is_recognized() {
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["run", "--harness", "sma", "--"]).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 (`--harn` → `--harness`), 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.
|
||
#[test]
|
||
fn long_option_abbreviation_is_accepted() {
|
||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["run", "--harn", "sma"]).output().expect("spawn");
|
||
assert_eq!(out.status.code(), Some(0),
|
||
"run --harn sma (abbrev of --harness) 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", "--fast", "3"][..],
|
||
&["walkforward", "bogus", "--strategy", "sma"][..],
|
||
&["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 treated as the built-in
|
||
/// grammar with a stray positional (refused, exit 2, built-in usage naming
|
||
/// `--harness`), NOT as a loaded 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. The
|
||
/// `--harness`-in-stderr assertion pins that the built-in branch was chosen; 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("--harness"),
|
||
"the built-in run grammar (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.
|
||
#[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 runtime = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||
.args(["run", "--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));
|
||
}
|