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)
|
crate::wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, stop, false, geo.pip_size, &binding, cost_leg)
|
||||||
.bootstrap_with_cells(&point)
|
.bootstrap_with_cells(&point)
|
||||||
.expect("the member's point re-bootstraps (it already ran this realization)");
|
.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
|
// 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
|
// trace path must not): eq/ex/req/net feed the taps, r+cost feed
|
||||||
// the metrics.
|
// the metrics.
|
||||||
|
|||||||
@@ -1666,6 +1666,23 @@ fn wrap_r(
|
|||||||
g.build().expect("r_sma wiring resolves")
|
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
|
/// 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 —
|
/// 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
|
/// 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)
|
.compile_with_params(params)
|
||||||
.expect("signal binds + wraps to a valid harness");
|
.expect("signal binds + wraps to a valid harness");
|
||||||
let mut h = Harness::bootstrap(flat).expect("valid r-sma 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 eq_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_eq.try_iter().collect();
|
||||||
let ex_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_ex.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();
|
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)
|
let mut h = wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, stop, true, pip, binding, cost_leg)
|
||||||
.bootstrap_with_cells(point)
|
.bootstrap_with_cells(point)
|
||||||
.expect("member bootstraps (point kind-checked against param_space)");
|
.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
|
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,
|
// `match` (not an irrefutable `let`): `StopRule` also has a `Fixed` variant,
|
||||||
// which stamps no vol knobs. The campaign/single-run paths only pass `Vol`
|
// 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");
|
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]
|
#[test]
|
||||||
fn run_prints_json_and_exits_zero() {
|
fn run_prints_json_and_exits_zero() {
|
||||||
// `run` is blueprint-only now (#159 cut 4 retired the bare built-in default);
|
// `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);
|
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
|
/// The synthetic walk generates a close series only: a multi-column blueprint
|
||||||
/// without `--real` refuses honestly (exit 1, columns + remedy named). NOT
|
/// without `--real` refuses honestly (exit 1, columns + remedy named). NOT
|
||||||
/// gated — the refusal precedes any data access.
|
/// 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>
|
/// Property: the real-data blueprint sweep path (`aura sweep <blueprint.json>
|
||||||
/// --real SYM --axis …`, dispatched as sugar over the one campaign executor)
|
/// --real SYM --axis …`, dispatched as sugar over the one campaign executor)
|
||||||
/// prints one member line per grid point, IN AXIS ORDER (odometer: the first
|
/// 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
|
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.
|
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
|
### C5 — Freshness-gated recompute and sample-and-hold
|
||||||
**Guarantee.** The `cycle_id` advances everywhere (a cheap counter), but a node
|
**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
|
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
|
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
|
build phase a compilation makes its successor explicit: the flat graph is the target
|
||||||
of behaviour-preserving optimisation (C23).
|
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
|
**Realization (cycle 0016 — param-set injection).** The bootstrap now binds an
|
||||||
injected param-set, realizing C12's "params injected at graph build (the optimizer
|
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`"
|
sees a generic vector of typed ranges)" and C19's "factory `params → sized node`"
|
||||||
|
|||||||
Reference in New Issue
Block a user