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
+16
View File
@@ -160,6 +160,16 @@ multiple sources) break by source declaration order.
either wastes empty cycles or clumps ticks. Backtest and live differ only in the
origin of records, not the cycle semantics. Tie determinism preserves C1.
**Realization (#275, 2026-07-15).** Ingestion sources are supplied to `run` **by
role name**, not by list position. `SourceSpec` carries a `role: Option<String>`
(the bound `Role`'s name, load-bearing for source binding), and `Harness::run_bound`
resolves a keyed supply against those roles, emitting sources in `SourceSpec`
declaration order. The C4 tie-break stays "source declaration order" — now
independent of how the caller orders the supply — and a mis-bound feed is a named
`SourceBindError` at start time rather than a silently wrong run. The raw-index
`run(Vec)` primitive (positional, C23) is unchanged; every hand-built graph keeps
`role: None`.
### C5 — Freshness-gated recompute and sample-and-hold
**Guarantee.** The `cycle_id` advances everywhere (a cheap counter), but a node
re-evaluates only when ≥1 of its own inputs is fresh this cycle (detected by
@@ -1658,6 +1668,12 @@ cdylib** rebuild (C12/C13: the cdylib loads once); re-deriving an instance per
param-set is a cheap **graph re-compilation**, not a code recompile. Naming the
build phase a compilation makes its successor explicit: the flat graph is the target
of behaviour-preserving optimisation (C23).
**Refinement (#275, 2026-07-15).** One narrow exception to "role names demoted to
non-load-bearing": a `SourceSpec.role` (the lowered bound-`Role` name) is
**load-bearing for source binding** — the key `Harness::run_bound` / `bind_sources`
resolve a keyed source supply against. Every other flat-graph name (edges, ports,
composite boundaries) stays a non-load-bearing debug symbol; the raw-index
positional `run` path carries no role.
**Realization (cycle 0016 — param-set injection).** The bootstrap now binds an
injected param-set, realizing C12's "params injected at graph build (the optimizer
sees a generic vector of typed ranges)" and C19's "factory `params → sized node`"