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:
2026-07-15 19:52:39 +02:00
parent 0b620e1f26
commit 9e30805fcc
4 changed files with 173 additions and 3 deletions
+115
View File
@@ -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