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`