b18a695531
A single run recorded into `runs/traces/<render-name>/`, so a second run of the same blueprint overwrote the first and could leave a stale tap file beside an index that no longer listed it. The directory is now `runs/traces/<render-name>-<id8>/`, where the id is a digest over the run's own manifest: two runs differing in any identity-bearing input land in two directories, two runs differing in nothing land in one. The digest hashes the manifest with its two provenance fields removed and its parameter vectors merged. - `commit` (the aura binary's build sha) and `project.commit` (the project repo's HEAD plus a `-dirty` marker, re-derived on every invocation) record who built or checked out the code, not what the run was. The latter would have minted a fresh directory for any uncommitted file anywhere in the project worktree — the authoring loop's normal state. - `params` and `defaults` are merged name-sorted before hashing: how a value was supplied is not what the run is, so a no-op `--override` no longer splits one run across two directories. The two vectors are disjoint by construction. `project.dylib_sha256` stays in. It is what the C13 hot-reload comparison varies — an unchanged blueprint against a rebuilt node crate — which the campaign leg structurally cannot express, since its axes vary params, instrument and window, never code. The manifest is now assembled before the tap bind rather than after the run. Every input it needs was already resolved at that point, and the one value both keys the directory and enters the report, so a handle can never name one identity while the record describes another. `aura chart <render-name>` no longer names a directory; it now lists the trace handles beginning with that name instead of refusing blankly. `TraceStoreError::NameTaken` stops prescribing `--trace`, retired in #319. C18, C22, C23 and C27 record the new identity, each superseded clause moved verbatim to its history sidecar; C23 gains its first. closes #311
187 lines
8.2 KiB
Rust
187 lines
8.2 KiB
Rust
//! #290 / C28: `aura measure ic <run> --signal <tap> --price <tap>` reduces a
|
|
//! measurement run's two recorded taps to an Information Coefficient + its
|
|
//! permutation-null significance. Exercises the WIRING and well-formedness over a
|
|
//! real causal run; the signal-vs-noise math is unit-tested (a look-ahead-engineered
|
|
//! signal is impossible in a causal run, C2).
|
|
|
|
use std::path::Path;
|
|
use std::process::Command;
|
|
|
|
const BIN: &str = env!("CARGO_BIN_EXE_aura");
|
|
|
|
fn temp_cwd(name: &str) -> std::path::PathBuf {
|
|
let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-cli-measic-{name}"));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).expect("create temp cwd");
|
|
dir
|
|
}
|
|
|
|
/// `examples/r_sma.json` turned MEASUREMENT-shaped with TWO taps (node 0 "signal",
|
|
/// node 1 "price") and no `bias` output — same closed topology, runs on the built-in
|
|
/// synthetic stream. (Two-tap authoring mirrors tests/tap_recording.rs.)
|
|
fn two_tap_blueprint_json() -> String {
|
|
let path = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
|
let doc = std::fs::read_to_string(path).expect("read examples/r_sma.json");
|
|
let mut v: serde_json::Value = serde_json::from_str(&doc).expect("parse r_sma.json");
|
|
v["blueprint"]["taps"] = serde_json::json!([
|
|
{"name": "signal", "from": {"node": 0, "field": 0}},
|
|
{"name": "price", "from": {"node": 1, "field": 0}},
|
|
]);
|
|
v["blueprint"]["output"] = serde_json::json!([]);
|
|
serde_json::to_string(&v).expect("re-serialize measurement blueprint")
|
|
}
|
|
|
|
/// Run the two-tap measurement blueprint and return the trace handle it
|
|
/// printed. #311: the trace directory is keyed by the run's own identity, so
|
|
/// every `measure ic` argv below addresses the run by the handle it reported —
|
|
/// which is also this file's read-back pin for the analysis consumer.
|
|
fn run_measurement(cwd: &Path) -> String {
|
|
let bp = cwd.join("measurement.json");
|
|
std::fs::write(&bp, two_tap_blueprint_json()).expect("write blueprint");
|
|
let out = Command::new(BIN)
|
|
.args(["exec", bp.to_str().unwrap()])
|
|
.current_dir(cwd)
|
|
.output()
|
|
.expect("spawn aura run");
|
|
assert!(out.status.success(), "aura run stderr: {}", String::from_utf8_lossy(&out.stderr));
|
|
let line = String::from_utf8_lossy(&out.stdout);
|
|
let v: serde_json::Value =
|
|
serde_json::from_str(line.trim()).expect("stdout is one JSON record line");
|
|
let handle = v["trace_name"]
|
|
.as_str()
|
|
.expect("a recording run prints its trace_name")
|
|
.to_string();
|
|
assert!(handle.starts_with("sma_signal-"), "the render name stays the prefix: {handle}");
|
|
handle
|
|
}
|
|
|
|
fn measure_ic(cwd: &Path, run: &str, extra: &[&str]) -> std::process::Output {
|
|
let mut args = vec!["measure", "ic", run, "--signal", "signal", "--price", "price"];
|
|
args.extend_from_slice(extra);
|
|
Command::new(BIN).args(&args).current_dir(cwd).output().expect("spawn aura measure ic")
|
|
}
|
|
|
|
#[test]
|
|
fn measure_ic_emits_a_well_formed_report() {
|
|
let cwd = temp_cwd("wellformed");
|
|
let handle = run_measurement(&cwd);
|
|
let out = measure_ic(&cwd, &handle, &[]);
|
|
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
|
let r: serde_json::Value = serde_json::from_slice(&out.stdout).expect("stdout is IcReport JSON");
|
|
// #311: the handle the run printed reaches that run's data — the analysis
|
|
// consumer's own round trip through the identity-keyed store.
|
|
assert_eq!(
|
|
r["run"].as_str(),
|
|
Some(handle.as_str()),
|
|
"the report addresses the run by the handle the run itself printed"
|
|
);
|
|
assert_eq!(r["signal_tap"], "signal");
|
|
assert_eq!(r["price_tap"], "price");
|
|
assert_eq!(r["horizon"], 1);
|
|
assert_eq!(r["permutations"], 1000);
|
|
assert_eq!(r["seed"], 0);
|
|
assert!(r["n_pairs"].as_u64().unwrap() >= 2, "expected aligned pairs, got {}", r["n_pairs"]);
|
|
let ic = r["information_coefficient"].as_f64().unwrap();
|
|
assert!(ic.is_finite() && (-1.0..=1.0).contains(&ic), "ic = {ic}");
|
|
let p = r["overfit_probability"].as_f64().unwrap();
|
|
assert!(p > 0.0 && p <= 1.0, "overfit_probability = {p}");
|
|
}
|
|
|
|
#[test]
|
|
fn measure_ic_is_deterministic() {
|
|
let cwd = temp_cwd("determinism");
|
|
let handle = run_measurement(&cwd);
|
|
let a = measure_ic(&cwd, &handle, &["--seed", "9"]);
|
|
let b = measure_ic(&cwd, &handle, &["--seed", "9"]);
|
|
assert!(a.status.success() && b.status.success());
|
|
assert_eq!(a.stdout, b.stdout, "same seed → byte-identical report");
|
|
}
|
|
|
|
#[test]
|
|
fn measure_ic_unknown_run_errors() {
|
|
let cwd = temp_cwd("unknownrun");
|
|
run_measurement(&cwd);
|
|
let out = measure_ic(&cwd, "no_such_run", &[]);
|
|
assert!(!out.status.success(), "an unknown run must exit non-zero");
|
|
}
|
|
|
|
/// `--horizon` is real plumbing from the CLI arg through
|
|
/// `information_coefficient`'s alignment window, not merely echoed into the
|
|
/// report: a horizon that exceeds the recorded price series collapses every
|
|
/// signal/forward-return pair, hitting the reduction's documented degenerate
|
|
/// floor (`n_pairs=0`, `ic=0.0`, `overfit_probability=1.0`, per main.rs's
|
|
/// `information_coefficient` doc comment) — a floor the unit tests exercise
|
|
/// only by calling the reduction in-memory, never through CLI parsing + a
|
|
/// persisted-trace round trip.
|
|
#[test]
|
|
fn measure_ic_oversized_horizon_degenerates_through_the_cli() {
|
|
let cwd = temp_cwd("oversizedhorizon");
|
|
let handle = run_measurement(&cwd);
|
|
let out = measure_ic(&cwd, &handle, &["--horizon", "100000000"]);
|
|
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
|
let r: serde_json::Value = serde_json::from_slice(&out.stdout).expect("stdout is IcReport JSON");
|
|
assert_eq!(r["horizon"], 100_000_000);
|
|
assert_eq!(r["n_pairs"], 0, "an oversized horizon aligns no pairs");
|
|
assert_eq!(r["information_coefficient"], 0.0, "degenerate floor: ic = 0.0");
|
|
assert_eq!(r["overfit_probability"], 1.0, "degenerate floor: overfit_probability = 1.0");
|
|
}
|
|
|
|
#[test]
|
|
fn measure_ic_missing_tap_errors() {
|
|
let cwd = temp_cwd("missingtap");
|
|
let handle = run_measurement(&cwd);
|
|
let out = Command::new(BIN)
|
|
.args(["measure", "ic", &handle, "--signal", "nope", "--price", "price"])
|
|
.current_dir(&cwd)
|
|
.output()
|
|
.expect("spawn");
|
|
assert!(!out.status.success(), "a missing tap must exit non-zero");
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stderr).contains("nope"),
|
|
"the error names the missing tap: {}",
|
|
String::from_utf8_lossy(&out.stderr)
|
|
);
|
|
}
|
|
|
|
/// #309 fieldtest: a campaign's trace handle is exactly what the run printed
|
|
/// and what `chart` accepts, so denying that it exists at all is false. A
|
|
/// family holds members, not one series — say so, and name the members, since
|
|
/// no other text surface lists them.
|
|
#[test]
|
|
fn measure_ic_on_a_family_handle_names_its_members_instead_of_denying_the_handle() {
|
|
let cwd = temp_cwd("measure-ic-family");
|
|
for member in ["c0/m0", "c0/m1"] {
|
|
let dir = cwd.join("runs/traces/fam1234-0").join(member);
|
|
std::fs::create_dir_all(&dir).expect("fabricate a family fan-out");
|
|
// A minimally valid member index: the manifest shape the store reads
|
|
// back, with no taps (the refusal needs the member KEY, not series).
|
|
std::fs::write(
|
|
dir.join("index.json"),
|
|
r#"{"manifest":{"commit":"fieldtest","params":[],"defaults":[],"window":[0,0],"seed":0,"broker":"sim"},"taps":[]}"#,
|
|
)
|
|
.expect("write member index");
|
|
}
|
|
|
|
let out = std::process::Command::new(BIN)
|
|
.args(["measure", "ic", "fam1234-0", "--signal", "signal", "--price", "price"])
|
|
.current_dir(&cwd)
|
|
.output()
|
|
.expect("spawn measure ic");
|
|
|
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
|
assert!(
|
|
!stderr.contains("no recorded run 'fam1234-0'"),
|
|
"the handle exists as a family; denying it outright is false: {stderr}"
|
|
);
|
|
assert!(
|
|
stderr.contains("family"),
|
|
"the refusal must say the handle names a family: {stderr}"
|
|
);
|
|
assert!(
|
|
stderr.contains("c0/m0") && stderr.contains("c0/m1"),
|
|
"the refusal must name the member keys, the only text route to them: {stderr}"
|
|
);
|
|
|
|
let _ = std::fs::remove_dir_all(&cwd);
|
|
}
|