feat(cli,ledger): supply CLI run sources by role key; ledger note
closes #275 The CLI half of by-name source binding, plus the ledger record. Every production run site that carries a ResolvedBinding — `run_signal_r`, `run_blueprint_member` (sweep / reproduction), and the campaign re-run/trace path — now keys its opened sources by role name via `key_supply(binding, sources)` and drives the harness through `Harness::run_bound` instead of the positional `run(sources)`. `key_supply` pairs each opened column with its declared role from `binding.entries()` — the one place open-order and role-order meet, made explicit so `bind_sources` verifies the wiring↔supply role match by name rather than by a maintained canonical-order convention. Because the current single-binding CLI derives both the `SourceSpec` roles (via `wrap_r`) and the supply roles (via `key_supply`) from the same `binding.entries()`, the bind cannot fail on this path — so the call site asserts the invariant with `.expect`, matching the adjacent `close_handle.expect("ResolvedBinding guarantees a close entry")` idiom. The named `SourceBindError` refusal path lives in `bind_sources` for future decoupled-supply callers (independently-built multi-feed / recorded sources, #124), where a mismatch becomes reachable. Ledger: a C4 realization note (supply resolved by name into declaration order, so supply order is no longer load-bearing; the tie-break guarantee is unchanged) and a scoped C23 refinement (a `SourceSpec.role` is load-bearing for source binding; every other flat-graph name stays a non-load-bearing raw-index symbol). The fieldtest-corpus `SourceSpec` sweep (planned Task 5) was reverted: the fieldtest packages already fail to build against the current engine API for reasons unrelated to `SourceSpec` (bootstrap arity, removed `InputSpec`, drifted `Recorder`/`SimBroker`/`RMetrics`, renamed `Scalar` helpers) — pre-existing bit-rot from earlier cycles. A partial `SourceSpec` migration neither revives nor further breaks them, so the scope decision to touch them (premised on their being otherwise-buildable) was withdrawn; reviving the corpus is separate from #275. Verification: `cargo test --workspace` green (0 failed; the real-data OHLC channel e2e exercises the migrated `run_bound` path byte-identically); `cargo build --workspace --all-targets` clean; `cargo clippy --workspace --all-targets -D warnings` clean.
This commit is contained in:
@@ -93,6 +93,16 @@ fn mc_outside_project_refuses_and_leaves_no_store() {
|
||||
assert!(!cwd.join("runs").exists(), "a refused run must leave no store");
|
||||
}
|
||||
|
||||
/// Property (#275, retrofitted): `run_signal_r` now keys its opened sources by
|
||||
/// role name (`h.run_bound(key_supply(&binding, sources))`) instead of feeding
|
||||
/// the harness positionally (`h.run(sources)`). This pre-existing happy-path
|
||||
/// pin therefore doubles as the by-name-binding transparency check for that one
|
||||
/// call site: a well-bound strategy's by-name path resolves back to the exact
|
||||
/// same source order `bind_sources` was given, so the migration is invisible
|
||||
/// here — unchanged exit code, unchanged JSON output byte-for-byte. It does NOT
|
||||
/// exercise `run_blueprint_member` or the campaign re-run/trace path — those
|
||||
/// two migrated call sites are covered by the sweep/campaign e2e fixtures
|
||||
/// elsewhere in this crate, not by this test.
|
||||
#[test]
|
||||
fn run_prints_json_and_exits_zero() {
|
||||
// `run` is blueprint-only now (#159 cut 4 retired the bare built-in default);
|
||||
@@ -1379,6 +1389,51 @@ fn run_real_ohlc_channel_example_end_to_end() {
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
/// Property (#275, hostless): `run_signal_r`'s migrated production call site
|
||||
/// (`h.run_bound(key_supply(&binding, sources))`, replacing the old positional
|
||||
/// `h.run(sources)`) pairs each opened archive column with its declared role
|
||||
/// **by name**, not by list position, for a genuinely multi-role strategy
|
||||
/// (r_channel declares three distinct roles — high, low, close). Unlike
|
||||
/// `run_real_ohlc_channel_example_end_to_end` above (gated on a local GER40
|
||||
/// mount, which this sandbox does not have), this drives the same fixture over
|
||||
/// `fresh_project_with_data`'s hermetic synthetic archive, so the by-name-binding
|
||||
/// regression guard for this call site actually runs on every host, not just a
|
||||
/// data-ful one. The exact R summary is a characterization pin against the
|
||||
/// synthetic archive's deterministic price curve: a column swap (e.g. high/low
|
||||
/// crossed) would change the channel breakout logic and move this number.
|
||||
#[test]
|
||||
fn run_real_ohlc_channel_example_end_to_end_hostless() {
|
||||
let (cwd, _g) = fresh_project_with_data();
|
||||
let fixture = format!("{}/examples/r_channel.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let out = std::process::Command::new(BIN)
|
||||
.args([
|
||||
"run", &fixture,
|
||||
"--real", "SYMA",
|
||||
"--from", "1707120000000", // 2024-02-05 08:00 UTC (Monday)
|
||||
"--to", "1707325200000", // 2024-02-07 17:00 UTC (Wednesday)
|
||||
])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("run report parses as JSON");
|
||||
assert!(
|
||||
v["manifest"]["topology_hash"].as_str().is_some(),
|
||||
"report carries the signal hash: {stdout}"
|
||||
);
|
||||
assert_eq!(
|
||||
v["metrics"]["r"]["n_trades"], serde_json::json!(1),
|
||||
"characterization pin over the deterministic synthetic archive \
|
||||
(a high/low/close column mispairing would move this count): {stdout}"
|
||||
);
|
||||
assert_eq!(
|
||||
v["metrics"]["r"]["expectancy_r"], serde_json::json!(-0.7466116470582123),
|
||||
"characterization pin over the deterministic synthetic archive \
|
||||
(a high/low/close column mispairing would move this value): {stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The synthetic walk generates a close series only: a multi-column blueprint
|
||||
/// without `--real` refuses honestly (exit 1, columns + remedy named). NOT
|
||||
/// gated — the refusal precedes any data access.
|
||||
@@ -1467,6 +1522,66 @@ fn sweep_real_ohlc_channel_members_run_and_reproduce() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Property (#275, hostless): `run_blueprint_member`'s migrated production call
|
||||
/// site (`h.run_bound(key_supply(binding, sources))`) resolves the same
|
||||
/// role-keyed pairing on every member of a multi-column sweep AND on the
|
||||
/// independent `reproduce` re-run — a sibling of
|
||||
/// `sweep_real_ohlc_channel_members_run_and_reproduce` above (gated on a local
|
||||
/// GER40 mount this sandbox lacks) that drives the identical multi-role fixture
|
||||
/// over `fresh_project_with_data`'s hermetic synthetic archive, so this call
|
||||
/// site's by-name-binding regression guard runs on every host. Bit-identical
|
||||
/// reproduction is the stronger check here: `reproduce` re-derives `binding` and
|
||||
/// re-opens `sources` independently of the first run, so it can only match if
|
||||
/// both derivations resolve to the exact same role→column pairing.
|
||||
#[test]
|
||||
fn sweep_real_ohlc_channel_members_run_and_reproduce_hostless() {
|
||||
let (dir, _g) = fresh_project_with_data();
|
||||
let fixture = format!("{}/tests/fixtures/r_channel_open.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let out = std::process::Command::new(BIN)
|
||||
.args([
|
||||
"sweep", &fixture,
|
||||
"--real", "SYMA",
|
||||
"--from", "1707120000000", // 2024-02-05 08:00 UTC (Monday)
|
||||
"--to", "1707325200000", // 2024-02-07 17:00 UTC (Wednesday)
|
||||
"--axis", "hl_channel.channel_length=3,5",
|
||||
"--name", "chan-hostless",
|
||||
])
|
||||
.current_dir(&dir)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
|
||||
assert_eq!(stdout.lines().count(), 2, "one member line per channel length: {stdout}");
|
||||
for line in stdout.lines() {
|
||||
let v: serde_json::Value = serde_json::from_str(line).expect("member line parses as JSON");
|
||||
assert_eq!(
|
||||
v["report"]["manifest"]["instrument"].as_str(),
|
||||
Some("SYMA"),
|
||||
"campaign member manifest stamps the instrument: {line}"
|
||||
);
|
||||
}
|
||||
let family_id = serde_json::from_str::<serde_json::Value>(stdout.lines().next().unwrap())
|
||||
.expect("first member line parses")["family_id"]
|
||||
.as_str()
|
||||
.expect("member line carries family_id")
|
||||
.to_string();
|
||||
let rep = std::process::Command::new(BIN)
|
||||
.args(["reproduce", &family_id])
|
||||
.current_dir(&dir)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
rep.status.success(),
|
||||
"reproduce stderr: {}",
|
||||
String::from_utf8_lossy(&rep.stderr)
|
||||
);
|
||||
assert!(
|
||||
String::from_utf8_lossy(&rep.stdout).contains("reproduced 2/2 members bit-identically"),
|
||||
"stdout: {}",
|
||||
String::from_utf8_lossy(&rep.stdout)
|
||||
);
|
||||
}
|
||||
|
||||
/// Property: the real-data blueprint sweep path (`aura sweep <blueprint.json>
|
||||
/// --real SYM --axis …`, dispatched as sugar over the one campaign executor)
|
||||
/// prints one member line per grid point, IN AXIS ORDER (odometer: the first
|
||||
|
||||
Reference in New Issue
Block a user