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
+8 -1
View File
@@ -1084,7 +1084,14 @@ pub(crate) fn persist_campaign_traces(
crate::wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, stop, false, geo.pip_size, &binding, cost_leg)
.bootstrap_with_cells(&point)
.expect("the member's point re-bootstraps (it already ran this realization)");
h.run(sources);
// The recorded member already ran this realization once (see the
// `.expect` immediately above); `sources` are re-opened against the
// SAME `binding` this trace re-run resolved, so a `SourceBindError`
// here can only be an internal wiring inconsistency, not a fresh
// user input mistake. Panic like the sibling bootstrap invariant
// instead of a process-exit dressed as a refusal message.
h.run_bound(crate::key_supply(&binding, sources))
.expect("sources re-opened against `binding` key-match that binding's own roles by construction");
// Drain ALL SIX channels (`run_signal_r` leaves req undrained; the
// trace path must not): eq/ex/req/net feed the taps, r+cost feed
// the metrics.
+34 -2
View File
@@ -1666,6 +1666,23 @@ fn wrap_r(
g.build().expect("r_sma wiring resolves")
}
/// Pair each opened source with its role name from the resolved binding, forming
/// the keyed supply `run_bound` resolves by name. `sources` are opened in
/// `binding.columns()` order (== `binding.entries()` order), so entry `i`'s role
/// names source `i` — the one place open-order and role-order meet, made explicit
/// so `bind_sources` verifies the wiring↔supply role match by name (#275).
fn key_supply(
binding: &binding::ResolvedBinding,
sources: Vec<Box<dyn aura_engine::Source>>,
) -> Vec<(String, Box<dyn aura_engine::Source>)> {
binding
.entries()
.iter()
.map(|e| e.role.clone())
.zip(sources)
.collect()
}
/// Resolve a `RunData` selector to the `(sources, window, pip_size)` triple the
/// run paths feed to the harness: the built-in synthetic R stream (close-only —
/// the caller guards the binding shape), or the lazily-streamed real sources of
@@ -1732,7 +1749,14 @@ fn run_signal_r(
.compile_with_params(params)
.expect("signal binds + wraps to a valid harness");
let mut h = Harness::bootstrap(flat).expect("valid r-sma harness");
h.run(sources);
// `sources` were opened via `resolve_run_data(&data, env, &binding)` against
// this SAME `binding`, and `key_supply` keys them by that binding's own role
// names — a `SourceBindError` here can only mean the wiring↔supply pairing
// this function builds is internally inconsistent, never a user input
// mistake. Panic like the adjacent bootstrap invariants above, not a
// process exit dressed as a refusal message.
h.run_bound(key_supply(&binding, sources))
.expect("sources opened against `binding` key-match that binding's own roles by construction");
let eq_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_eq.try_iter().collect();
let ex_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_ex.try_iter().collect();
let r_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_r.try_iter().collect();
@@ -1796,7 +1820,15 @@ fn run_blueprint_member(
let mut h = wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, stop, true, pip, binding, cost_leg)
.bootstrap_with_cells(point)
.expect("member bootstraps (point kind-checked against param_space)");
h.run(sources);
// Called from inside `CliMemberRunner::run_member`'s `catch_unwind`
// containment (#272 — that impl's doc contract: "never a process exit
// inside a sweep worker, every refusal a member fault"). A
// `SourceBindError` here is an internal wiring/supply mismatch, not user
// input (see the sibling `.expect` two lines above) — panic so the
// containing `catch_unwind` records it as a per-cell `MemberFault::Panic`
// instead of `process::exit` aborting the whole campaign.
h.run_bound(key_supply(binding, sources))
.expect("sources opened against `binding` key-match that binding's own roles by construction");
let mut named = zip_params(space, point); // by-name params for the manifest record
// `match` (not an irrefutable `let`): `StopRule` also has a `Fixed` variant,
// which stamps no vol knobs. The campaign/single-run paths only pass `Vol`
+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