feat(cli,engine): multi-column opening on every real-data path (#231 task 4)

The six Close-only open sites route through the resolved binding's
column set via open_columns (probe_window, open_real_source,
run_sources/windowed_sources, the campaign member open, the trace
re-run open) — a strategy declaring high/low/close roles gets exactly
those columns, merged in the canonical C4 order. Synthetic data refuses
any binding beyond {close} with the honest --real remedy (the walk
generates a close series only); the sweep/mc family builders ride their
established Err contract, run/walkforward/reproduce refuse exit-1.

Proof at two layers: an engine e2e composes an OHLC blueprint from data
and runs it over four inline VecSources to hand-computed rows (the
merge-order pin), and an archive-gated CLI e2e sweeps a high/low
blueprint over GER40 opening the High and Low columns.

Verified: engine OHLC e2e green, full workspace suite green, clippy -D
warnings clean.

refs #231
This commit is contained in:
2026-07-11 03:37:14 +02:00
parent 99a7f6603c
commit 1d14ebc1cd
4 changed files with 428 additions and 65 deletions
+156
View File
@@ -1427,6 +1427,162 @@ fn reproduce_real_walkforward_family_does_not_panic() {
);
}
// Fixture blueprints for the verb-level multi-column binding coverage below
// (#231 task 4 quality follow-up): a CLOSED high/low blueprint (`run`
// requires zero free knobs) and an OPEN high/low blueprint with two
// SMA-length axes (`walkforward` requires >= 1 axis). Written to a fresh
// temp file per test rather than a tracked `examples/` fixture — these exist
// only to drive the refusal / real-open contract, not as authoring samples.
const HL_RANGE_CLOSED_BLUEPRINT: &str = r#"{
"format_version": 1,
"blueprint": {
"name": "hl_range",
"nodes": [ {"primitive":{"type":"Sub"}} ],
"edges": [],
"input_roles": [
{"name":"high","targets":[{"node":0,"slot":0}],"source":"F64"},
{"name":"low","targets":[{"node":0,"slot":1}],"source":"F64"}
],
"output": [{"node":0,"field":0,"name":"bias"}]
}
}"#;
const HL_SIGNAL_OPEN_BLUEPRINT: &str = r#"{"format_version":1,"blueprint":{"name":"hl_signal","nodes":[{"primitive":{"type":"SMA","name":"fast"}},{"primitive":{"type":"SMA","name":"slow"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"high","targets":[{"node":0,"slot":0}],"source":"F64"},{"name":"low","targets":[{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#;
/// Property (#231 task 4 quality follow-up): `aura run` over a multi-column
/// blueprint with NO `--real` (the synthetic default) refuses through the
/// verb-level exit-1 + stderr contract — `binding::synthetic_refusal`'s exact
/// prose, not merely its `Result` shape (the unit-mod test one layer down
/// covers the `Err`-returning builders only). Data-free: the guard fires
/// directly after binding resolution, before any archive access, so no
/// gating is needed.
#[test]
fn run_synthetic_refuses_a_multi_column_blueprint_before_data_access() {
let dir = temp_cwd("run_synthetic_multicolumn_refusal");
let fixture = dir.join("hl_range.json");
std::fs::write(&fixture, HL_RANGE_CLOSED_BLUEPRINT).expect("write fixture");
let out = Command::new(BIN).args(["run", fixture.to_str().unwrap()]).output().expect("spawn aura run");
assert_eq!(out.status.code(), Some(1), "status: {:?}", out.status);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(
stderr.trim_end(),
"aura: strategy \"hl_range\" consumes columns beyond close (high, low) — synthetic \
data generates a close series only; run with --real <SYMBOL>"
);
let _ = std::fs::remove_dir_all(&dir);
}
/// Property (#231 task 4 quality follow-up): the sibling exit-process arm at
/// `blueprint_walkforward_family` — `aura walkforward` over a multi-column
/// blueprint with no `--real` — refuses identically. Data-free (the guard
/// fires directly after binding resolution, before any window/archive
/// access); run inside a temp cwd purely by the project's verb-test
/// convention (no store write is expected on this refusal path either).
#[test]
fn walkforward_synthetic_refuses_a_multi_column_blueprint_before_data_access() {
let dir = temp_cwd("walkforward_synthetic_multicolumn_refusal");
let fixture = dir.join("hl_signal.json");
std::fs::write(&fixture, HL_SIGNAL_OPEN_BLUEPRINT).expect("write fixture");
let out = Command::new(BIN)
.args([
"walkforward", fixture.to_str().unwrap(),
"--axis", "hl_signal.fast.length=2,4",
"--axis", "hl_signal.slow.length=8",
])
.current_dir(&dir)
.output()
.expect("spawn aura walkforward");
assert_eq!(out.status.code(), Some(1), "status: {:?}", out.status);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(
stderr.trim_end(),
"aura: strategy \"hl_signal\" consumes columns beyond close (high, low) — synthetic \
data generates a close series only; run with --real <SYMBOL>"
);
assert!(!dir.join("runs").exists(), "a refused synthetic family must not start a run store");
let _ = std::fs::remove_dir_all(&dir);
}
/// Property (#231 task 4 quality follow-up): the positive counterpart of the
/// two refusals above — a multi-column blueprint over `--real GER40` opens
/// BOTH the high and low columns end to end (`open_real_source` ->
/// `resolve_run_data` -> `run_signal_r`) and produces a real `RunReport`,
/// never the single-close weld the pre-task-4 code hardcoded. Gated on the
/// local GER40 archive (skip-on-no-data convention).
#[test]
fn run_real_multi_column_blueprint_opens_high_and_low_columns() {
if !local_data_present() {
eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH);
return;
}
let dir = temp_cwd("run_real_multicolumn_open");
let fixture = dir.join("hl_range.json");
std::fs::write(&fixture, HL_RANGE_CLOSED_BLUEPRINT).expect("write fixture");
let out = Command::new(BIN)
.args([
"run", fixture.to_str().unwrap(),
"--real", "GER40",
"--from", GER40_SEPT2024_FROM_MS,
"--to", GER40_SEPT2024_TO_MS,
])
.output()
.expect("spawn aura run");
assert_eq!(
out.status.code(), Some(0),
"a high/low blueprint over real GER40 data must run cleanly, got status={:?} stderr={}",
out.status, String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.trim_start().starts_with("{\"manifest\":"),
"exit 0 must carry a JSON report, got: {stdout}"
);
let _ = std::fs::remove_dir_all(&dir);
}
/// Property (#231 task 4, "every real-data path" — `run --real` above covers
/// only `open_real_source`/`run_signal_r`; `sweep --real` walks a DISTINCT
/// real-data path, `blueprint_sweep_family` -> `blueprint_sweep_over` ->
/// `DataSource::windowed_sources`, which independently needed `binding.
/// columns()` threaded through in task 4): a multi-column (high/low) OPEN
/// blueprint swept over `--real GER40` opens both columns per member and
/// produces one member line per grid point, not a source-count-mismatch panic
/// or a fallback to a single close source. Gated on the local GER40 archive.
#[test]
fn sweep_real_multi_column_blueprint_opens_high_and_low_columns() {
if !local_data_present() {
eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH);
return;
}
let (dir, _g) = fresh_project();
let fixture_dir = temp_cwd("sweep_real_multicolumn_open");
let fixture = fixture_dir.join("hl_signal.json");
std::fs::write(&fixture, HL_SIGNAL_OPEN_BLUEPRINT).expect("write fixture");
let out = std::process::Command::new(BIN)
.args([
"sweep", fixture.to_str().unwrap(),
"--real", "GER40",
"--from", GER40_SEPT2024_FROM_MS,
"--to", GER40_SEPT2024_TO_MS,
"--axis", "hl_signal.fast.length=2,4",
"--axis", "hl_signal.slow.length=8",
])
.current_dir(dir)
.output()
.unwrap();
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines.len(), 2, "one member line per grid point: {stdout}");
for line in &lines {
let v: serde_json::Value = serde_json::from_str(line).expect("member line parses as JSON");
assert!(
v["report"]["manifest"].is_object(),
"each member carries a real report, not a source-mismatch fault: {line}"
);
}
let _ = std::fs::remove_dir_all(&fixture_dir);
}
/// Property (#210 c0110 fieldtest, "the wrapped-name refusal mangles the axis
/// the user typed"): the dissolved real-data blueprint sweep types `--axis`
/// against the WRAPPED probe namespace (`sma_signal.fast.length`, what