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:
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`"
|
||||
|
||||
Reference in New Issue
Block a user