From 99a7f6603c88d6dadc8892f3917840df0e37cc62 Mon Sep 17 00:00:00 2001 From: Brummel Date: Sat, 11 Jul 2026 02:27:05 +0200 Subject: [PATCH] refactor(cli): wrap_r declares its root roles from the resolved binding (#231 task 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behaviour-preserving threading: wrap_r's hard-wired source_role("price") weld is gone — root roles now come from a ResolvedBinding, one per entry in canonical column order, each fed into the signal's same-named port; the guaranteed close entry always feeds broker + executor (whose 'price' is a node-schema port name, not a role). Every existing blueprint resolves to the identical single-price plan, so the entire suite is the gate: green unchanged. Threaded through all callers: run_signal_r, run_blueprint_member (+ its production and test callers), blueprint_axis_probe (lenient probe binding), reproduce_family_in, the sweep/oos/walkforward/mc family builders, CliMemberRunner::run_member, persist_campaign_traces. Vocabulary role names use their static form at the port seam; only an override-renamed role leaks its name once per graph build (post-review polish capping the per-member leak). Verified: full workspace suite green unchanged, clippy -D warnings clean. refs #231 --- crates/aura-cli/src/binding.rs | 17 ++++ crates/aura-cli/src/campaign_run.rs | 27 ++++-- crates/aura-cli/src/main.rs | 135 +++++++++++++++++++++------- 3 files changed, 139 insertions(+), 40 deletions(-) diff --git a/crates/aura-cli/src/binding.rs b/crates/aura-cli/src/binding.rs index 49cad3b..f75adf8 100644 --- a/crates/aura-cli/src/binding.rs +++ b/crates/aura-cli/src/binding.rs @@ -31,6 +31,23 @@ const RESERVED_CLOSE_ROLE: &str = "close"; pub(crate) const COLUMN_VOCABULARY: &str = "open, high, low, close (alias: price), spread, volume"; +/// The `&'static str` form of a vocabulary role name, when it is one — the +/// common case at the wrap seam, so declaring a port for a vocabulary-named +/// role needs no per-build leak; only an override-renamed (non-vocabulary) +/// role falls back to leaking its name. +pub(crate) fn static_role_name(name: &str) -> Option<&'static str> { + match name { + "open" => Some("open"), + "high" => Some("high"), + "low" => Some("low"), + "close" => Some("close"), + "price" => Some("price"), + "spread" => Some("spread"), + "volume" => Some("volume"), + _ => None, + } +} + /// The closed role-name -> archive-column map (`price` aliases `close`). pub(crate) fn column_for_role(name: &str) -> Option { match name { diff --git a/crates/aura-cli/src/campaign_run.rs b/crates/aura-cli/src/campaign_run.rs index 0a72bb9..43e369c 100644 --- a/crates/aura-cli/src/campaign_run.rs +++ b/crates/aura-cli/src/campaign_run.rs @@ -14,6 +14,7 @@ //! submodule idiom: main.rs is the crate root, so its private items are //! visible to child modules without promotion. +use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::sync::{mpsc, Arc}; @@ -241,6 +242,15 @@ impl MemberRunner for CliMemberRunner<'_> { let space = crate::blueprint_axis_probe(&cell.blueprint_json, self.env).param_space(); let point = bind_axes(&space, &cell.strategy_id, params)?; + // The member's blueprint + its resolved input binding (name defaults; + // the campaign's data.bindings overrides thread through in the + // DataSection task). A refusal is a member fault, never a process exit. + let signal = blueprint_from_json(&cell.blueprint_json, &|t| self.env.resolve(t)) + .expect("stored blueprint passed the referential gate; reload is infallible"); + let binding = + crate::binding::resolve_binding(&cell.strategy_id, signal.input_roles(), &BTreeMap::new()) + .map_err(MemberFault::Bind)?; + // Real windowed data — geometry BEFORE bar data (the shipped pre-data // refusal order of `open_real_source`), both as member faults. let geo = instrument_geometry(&self.server, &cell.instrument).ok_or_else(|| { @@ -279,12 +289,10 @@ impl MemberRunner for CliMemberRunner<'_> { }); } - // The shipped member recipe (reload — a Composite is !Clone — wrap, - // bind, run, summarize): `run_blueprint_member` verbatim. Seed 0 - // (seed-free real-data runs), topology_hash = the strategy's content - // id, project provenance stamped inside the helper. - let signal = blueprint_from_json(&cell.blueprint_json, &|t| self.env.resolve(t)) - .expect("stored blueprint passed the referential gate; reload is infallible"); + // The shipped member recipe (wrap, bind, run, summarize): + // `run_blueprint_member` verbatim. Seed 0 (seed-free real-data runs), + // topology_hash = the strategy's content id, project provenance + // stamped inside the helper. let stop = stop_rule_for_regime(cell.regime); let mut report = crate::run_blueprint_member( signal, @@ -297,6 +305,7 @@ impl MemberRunner for CliMemberRunner<'_> { &cell.strategy_id, self.env, stop, + &binding, ); report.manifest.instrument = Some(cell.instrument.clone()); Ok(report) @@ -827,6 +836,10 @@ pub(crate) fn persist_campaign_traces( } let signal = blueprint_from_json(blueprint_json, &|t| env.resolve(t)) .expect("stored blueprint passed the referential gate; reload is infallible"); + // Same name-default binding the member ran under (campaign + // overrides thread through in the DataSection task). + let binding = + crate::binding::resolve_binding(&cell_rec.strategy, signal.input_roles(), &BTreeMap::new())?; // The cell's OWN regime (#219/#212), not the hardcoded default: the // recorded member ran under `cell_rec.regime`, bound through the same // `stop_rule_for_regime` helper `CliMemberRunner::run_member` uses, so @@ -837,7 +850,7 @@ pub(crate) fn persist_campaign_traces( let (tx_ex, rx_ex) = mpsc::channel(); let (tx_r, rx_r) = mpsc::channel(); let (tx_req, rx_req) = mpsc::channel(); - let mut h = crate::wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, stop, false, geo.pip_size) + let mut h = crate::wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, stop, false, geo.pip_size, &binding) .bootstrap_with_cells(&point) .expect("the member's point re-bootstraps (it already ran this realization)"); let sources: Vec> = vec![Box::new(source)]; diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index b898ac6..35dc6c8 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -68,7 +68,7 @@ use aura_std::{Bias, Ema, Sma}; use aura_std::{RollingMax, RollingMin, Sub}; use std::sync::mpsc; use std::sync::LazyLock; -use std::collections::HashSet; +use std::collections::{BTreeMap, HashSet}; use clap::{Args, Parser, Subcommand}; /// The pip size the built-in *synthetic* families (sweep/walkforward/mc over a @@ -1111,7 +1111,14 @@ fn reproduce_family_in( let (tx_r, _) = mpsc::channel(); let (tx_req, _) = mpsc::channel(); let stop = stop_rule_from_params(&stored.manifest.params); - let space = wrap_r(reload(), tx_eq, tx_ex, tx_r, tx_req, stop, true, SYNTHETIC_PIP_SIZE).param_space(); + // The member's binding, re-derived from the stored blueprint's own + // input roles (name defaults — family manifests carry no overrides). + let binding = binding::resolve_binding(&hash, reload().input_roles(), &BTreeMap::new()) + .unwrap_or_else(|m| { + eprintln!("aura: {m}"); + std::process::exit(1); + }); + let space = wrap_r(reload(), tx_eq, tx_ex, tx_r, tx_req, stop, true, SYNTHETIC_PIP_SIZE, &binding).param_space(); let point = point_from_params(&space, &stored.manifest.params); // A MonteCarlo member carries no tuning params (the params-join is empty), so its // reproduce line would print a BLANK member label; the seed IS its realization @@ -1172,6 +1179,7 @@ fn reproduce_family_in( &hash, env, stop, + &binding, ); outcomes.push((label, rerun.metrics == stored.metrics)); } @@ -1265,11 +1273,14 @@ fn topology_hash(signal: &Composite) -> String { content_id(&blueprint_to_json(signal).expect("a buildable signal serializes")) } -/// Wrap a `signal` composite (a `price`→`bias` leg) in the R run +/// Wrap a `signal` composite (a roles→`bias` leg) in the R run /// scaffolding: pip broker, the per-tap recorders, the vol-stop RiskExecutor, the -/// r_equity / cost legs. The signal is nested and its `price`/`bias` boundary is -/// wired across; a serialized signal loaded via `blueprint_from_json` runs through -/// exactly the scaffolding the Rust-built signal does. +/// r_equity / cost legs. The signal is nested; its root roles come from the +/// resolved `binding` (one per entry, canonical column order — the same order the +/// callers open real columns in, so role i receives source i), and the binding's +/// guaranteed close entry always feeds the broker/executor pair. A serialized +/// signal loaded via `blueprint_from_json` runs through exactly the scaffolding +/// the Rust-built signal does. #[allow(clippy::type_complexity, clippy::too_many_arguments)] fn wrap_r( signal: Composite, @@ -1280,9 +1291,10 @@ fn wrap_r( stop: StopRule, reduce: bool, pip_size: f64, + binding: &binding::ResolvedBinding, ) -> Composite { let mut g = GraphBuilder::new("r_sma"); - // SMA-cross signal → Bias, nested as a serializable `price`→`bias` leg. + // The strategy signal → Bias, nested as a serializable roles→`bias` leg. let sig = g.add(BlueprintNode::Composite(signal)); // pip branch (verbatim from the retired `sample_blueprint_with_sinks`, #159). let broker = g.add(SimBroker::builder(pip_size)); @@ -1313,13 +1325,32 @@ fn wrap_r( } else { g.add(Recorder::builder(PM_RECORD_KINDS.to_vec(), Firing::Any, tx_r)) }; - let price = g.source_role("price", ScalarKind::F64); - let price_targets = vec![ - sig.input("price"), - broker.input("price"), - exec.input("price"), - ]; - g.feed(price, price_targets); + // Root roles come from the resolved binding, one per entry in canonical + // column order (the C4 merge tie-break order the callers open columns in). + // The binding guarantees a close entry, which always feeds the broker and + // the executor — "price" below is their node-schema PORT name, not a role + // name. For a single-`price` signal this declares exactly the old weld: + // one role, targets [sig, broker, exec] (feed calls append targets). + let mut close_handle = None; + for entry in binding.entries() { + let role = g.source_role(&entry.role, binding::column_kind(entry.column)); + if entry.feeds_signal { + // `NodeHandle::input` takes a `&'static str` port name (the fluent + // `GraphBuilder`'s authoring-time contract, builder.rs) but the + // binding's role names are dynamic (loaded from a blueprint at + // runtime). Vocabulary names use their static form; only an + // override-renamed role leaks its name (once per graph build, + // never per-tick). + let port: &'static str = binding::static_role_name(&entry.role) + .unwrap_or_else(|| Box::leak(entry.role.clone().into_boxed_str())); + g.feed(role, [sig.input(port)]); + } + if entry.column == aura_ingest::M1Field::Close && close_handle.is_none() { + close_handle = Some(role); + } + } + let close = close_handle.expect("ResolvedBinding guarantees a close entry"); + g.feed(close, [broker.input("price"), exec.input("price")]); g.connect(sig.output("bias"), broker.input("exposure")); g.connect(sig.output("bias"), ex.input("col[0]")); g.connect(sig.output("bias"), exec.input("bias")); @@ -1378,6 +1409,13 @@ fn run_signal_r( signal: Composite, params: &[Scalar], data: RunData, seed: u64, env: &project::Env, ) -> RunReport { let topo = topology_hash(&signal); // before signal is consumed + // The default binding (name defaults; `aura run` carries no campaign + // overrides). Refusals are the established `aura: ` + exit-1 register. + let binding = binding::resolve_binding(signal.name(), signal.input_roles(), &BTreeMap::new()) + .unwrap_or_else(|m| { + eprintln!("aura: {m}"); + std::process::exit(1); + }); let names: Vec = signal .param_space() .iter() @@ -1390,7 +1428,7 @@ fn run_signal_r( // receiver alive so the sink's sends do not fail, but do not drain it. let (tx_req, _rx_req) = mpsc::channel(); let (sources, window, pip_size) = resolve_run_data(&data, env); - let wrapped = wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, false, pip_size); + let wrapped = wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, false, pip_size, &binding); let flat = wrapped .compile_with_params(params) .expect("signal binds + wraps to a valid harness"); @@ -1428,12 +1466,13 @@ fn run_blueprint_member( topo: &str, env: &project::Env, stop: StopRule, + binding: &binding::ResolvedBinding, ) -> RunReport { let (tx_eq, rx_eq) = mpsc::channel(); let (tx_ex, rx_ex) = mpsc::channel(); let (tx_r, rx_r) = mpsc::channel(); let (tx_req, _rx_req) = mpsc::channel(); - let mut h = wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, stop, true, pip) + let mut h = wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, stop, true, pip, binding) .bootstrap_with_cells(point) .expect("member bootstraps (point kind-checked against param_space)"); h.run(sources); @@ -1476,11 +1515,15 @@ fn run_blueprint_member( fn blueprint_axis_probe(doc: &str, env: &project::Env) -> Composite { let signal = blueprint_from_json(doc, &|t| env.resolve(t)) .expect("doc parse-validated at the dispatch boundary; reload is infallible"); + // The PROBE binding is lenient (unresolvable roles fall back to close), + // like the probe's synthetic pip: the wrap is built for its param_space + // only, never run over data — strict resolution lives on the run paths. + let probe = binding::probe_binding(signal.input_roles()); let (tx_eq, _) = mpsc::channel(); let (tx_ex, _) = mpsc::channel(); let (tx_r, _) = mpsc::channel(); let (tx_req, _) = mpsc::channel(); - wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, true, SYNTHETIC_PIP_SIZE) + wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, true, SYNTHETIC_PIP_SIZE, &probe) } /// `aura sweep --list-axes`: one `:` line per open @@ -1521,7 +1564,11 @@ fn blueprint_sweep_family( blueprint_from_json(d, &|t| env.resolve(t)) .expect("doc parse-validated at the dispatch boundary; reload is infallible") }; - let topo = topology_hash(&reload(doc)); + let probe_signal = reload(doc); + let topo = topology_hash(&probe_signal); + // Strict binding resolution (name defaults — the verb path carries no + // campaign overrides): the family's open plan and wrap plan in one value. + let binding = binding::resolve_binding(probe_signal.name(), probe_signal.input_roles(), &BTreeMap::new())?; let pip = data.pip_size(); let window = data.full_window(env); // a single throwaway floated build, only to resolve param_space (borrow) then seed @@ -1554,7 +1601,7 @@ fn blueprint_sweep_family( .sweep(|point| { // fresh per-member graph (Composite is !Clone, reload per member) run through // the shared reduce-mode member path — the same fn reproduction re-runs. - run_blueprint_member(reload(doc), point, &space, data.run_sources(env), window, 0, pip, &topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }) + run_blueprint_member(reload(doc), point, &space, data.run_sources(env), window, 0, pip, &topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, &binding) }) // render the sweep terminal's BindError to a message (the fn's String error contract), // so `UnknownKnob("nope")` still surfaces verbatim at the IO wrapper. @@ -1568,7 +1615,7 @@ fn blueprint_sweep_family( /// sweep terminal (no panic, no hidden exit) for the caller to render. fn blueprint_sweep_over( doc: &str, axes: &[(String, Vec)], from: Timestamp, to: Timestamp, data: &DataSource, - env: &project::Env, + env: &project::Env, binding: &binding::ResolvedBinding, ) -> Result<(SweepFamily, Vec), BindError> { let reload = |d: &str| { blueprint_from_json(d, &|t| env.resolve(t)) @@ -1587,7 +1634,7 @@ fn blueprint_sweep_over( binder.sweep_with_lattice(|point| { let sources = data.windowed_sources(from, to, env); let window = window_of(&sources).expect("non-empty in-sample window"); - run_blueprint_member(reload(doc), point, &space, sources, window, 0, pip, &topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }) + run_blueprint_member(reload(doc), point, &space, sources, window, 0, pip, &topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, binding) }) } @@ -1598,14 +1645,14 @@ fn blueprint_sweep_over( #[allow(clippy::too_many_arguments)] fn run_oos_blueprint( doc: &str, params: &[Cell], space: &[ParamSpec], from: Timestamp, to: Timestamp, - topo: &str, data: &DataSource, env: &project::Env, + topo: &str, data: &DataSource, env: &project::Env, binding: &binding::ResolvedBinding, ) -> (Vec<(Timestamp, f64)>, RunReport) { let reload = blueprint_from_json(doc, &|t| env.resolve(t)) .expect("doc parse-validated at the dispatch boundary; reload is infallible"); let pip = data.pip_size(); let sources = data.windowed_sources(from, to, env); let window = window_of(&sources).expect("non-empty out-of-sample window"); - let report = run_blueprint_member(reload, params, space, sources, window, 0, pip, topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }); + let report = run_blueprint_member(reload, params, space, sources, window, 0, pip, topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, binding); (Vec::new(), report) } @@ -1628,8 +1675,16 @@ fn blueprint_walkforward_family( } }; let space = blueprint_axis_probe(doc, env).param_space(); - let topo = topology_hash(&blueprint_from_json(doc, &|t| env.resolve(t)) - .expect("doc parse-validated at the dispatch boundary; reload is infallible")); + let probe_signal = blueprint_from_json(doc, &|t| env.resolve(t)) + .expect("doc parse-validated at the dispatch boundary; reload is infallible"); + let topo = topology_hash(&probe_signal); + // Strict binding resolution, once per family; refusal is the established + // `aura: ` + exit-1 register (the roller's usage refusals stay exit 2). + let binding = binding::resolve_binding(probe_signal.name(), probe_signal.input_roles(), &BTreeMap::new()) + .unwrap_or_else(|m| { + eprintln!("aura: {m}"); + std::process::exit(1); + }); // Validate the `--axis` grid ONCE at the dispatch boundary, mirroring `aura sweep` // (which resolves its axes a single time before any member runs). `walk_forward` fans // the per-window closure out across the windows in parallel, so a `BindError` raised @@ -1642,19 +1697,19 @@ fn blueprint_walkforward_family( .next() .expect("roller yields >= 1 window (validated above)") .is; - if let Err(e) = blueprint_sweep_over(doc, axes, first_is.0, first_is.1, data, env) { + if let Err(e) = blueprint_sweep_over(doc, axes, first_is.0, first_is.1, data, env, &binding) { eprintln!("aura: {e:?}"); std::process::exit(2); } walk_forward(roller, space.clone(), |w: WindowBounds| { - let (is_family, lattice) = blueprint_sweep_over(doc, axes, w.is.0, w.is.1, data, env) + let (is_family, lattice) = blueprint_sweep_over(doc, axes, w.is.0, w.is.1, data, env, &binding) .expect("axes validated in the dispatch-boundary pre-flight"); let (best, selection) = match select_winner(&is_family, WINNER_SELECTION_METRIC, select, Some(&lattice)) { Ok(v) => v, Err(msg) => { eprintln!("aura: {msg}"); std::process::exit(2); } }; let (oos_equity, mut oos_report) = - run_oos_blueprint(doc, &best.params, &space, w.oos.0, w.oos.1, &topo, data, env); + run_oos_blueprint(doc, &best.params, &space, w.oos.0, w.oos.1, &topo, data, env, &binding); oos_report.manifest.selection = Some(selection); WindowRun { chosen_params: best.params, oos_equity, oos_report } }) @@ -1686,7 +1741,11 @@ fn blueprint_mc_family( blueprint_from_json(d, &|t| env.resolve(t)) .expect("doc parse-validated at the dispatch boundary; reload is infallible") }; - let topo = topology_hash(&reload(doc)); + let probe_signal = reload(doc); + let topo = topology_hash(&probe_signal); + // Strict binding resolution (name defaults — mc's synthetic family binds + // no campaign overrides); the exit-free Err contract of this builder. + let binding = binding::resolve_binding(probe_signal.name(), probe_signal.input_roles(), &BTreeMap::new())?; let pip = data.pip_size(); // probe the wrapped param_space (the same probe the sweep resolves against); // MC needs it empty. `blueprint_axis_probe` is the single source of that wrap. @@ -1711,7 +1770,7 @@ fn blueprint_mc_family( let family = monte_carlo(&base_point, &seeds, |seed, _base| { let sources = synthetic_walk_sources(seed); let window = window_of(&sources).expect("non-empty synthetic walk"); - run_blueprint_member(reload(doc), &[], &space, sources, window, seed, pip, &topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }) + run_blueprint_member(reload(doc), &[], &space, sources, window, seed, pip, &topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, &binding) }); // Silent-vacuous MC guard (refuse-don't-guess, C10): with >= 2 seeds, if every draw's // metrics are bit-identical to the first, no seed reached a distinguishable realization — @@ -3414,8 +3473,11 @@ mod tests { let (tx_ex, rx_ex) = mpsc::channel(); let (tx_r, _rx_r) = mpsc::channel(); let (tx_req, _rx_req) = mpsc::channel(); + let signal = r_meanrev_signal(Some(3), Some(0.0)); + let binding = binding::resolve_binding(signal.name(), signal.input_roles(), &BTreeMap::new()) + .expect("the price role resolves"); let flat = wrap_r( - r_meanrev_signal(Some(3), Some(0.0)), + signal, tx_eq, tx_ex, tx_r, @@ -3423,6 +3485,7 @@ mod tests { StopRule::Vol { length: 3, k: 2.0 }, false, SYNTHETIC_PIP_SIZE, + &binding, ) .compile_with_params(&[]) .expect("r-meanrev signal wraps to a valid harness"); @@ -3479,11 +3542,14 @@ mod tests { // from the synthetic 0.0001 — i.e. the real-data condition. let pip_a = 1.0_f64; let pip_b = 0.1_f64; + let signal_a = r_meanrev_signal(Some(3), Some(0.0)); + let binding = binding::resolve_binding(signal_a.name(), signal_a.input_roles(), &BTreeMap::new()) + .expect("the price role resolves"); let report_a = run_blueprint_member( - r_meanrev_signal(Some(3), Some(0.0)), &[], &space, make_source(), window, 0, pip_a, "topo", &env, stop, + signal_a, &[], &space, make_source(), window, 0, pip_a, "topo", &env, stop, &binding, ); let report_b = run_blueprint_member( - r_meanrev_signal(Some(3), Some(0.0)), &[], &space, make_source(), window, 0, pip_b, "topo", &env, stop, + r_meanrev_signal(Some(3), Some(0.0)), &[], &space, make_source(), window, 0, pip_b, "topo", &env, stop, &binding, ); // Guard: the run must have actually traded, else the invariant is vacuous. assert!( @@ -4062,6 +4128,8 @@ mod tests { // Mint one member under a NON-default vol-stop regime (default is length=3, k=2.0). // run_blueprint_member stamps stop_length=8/stop_k=4.0 into the manifest params. let non_default = StopRule::Vol { length: 8, k: 4.0 }; + let binding = binding::resolve_binding("stoprepro", reload().input_roles(), &BTreeMap::new()) + .expect("the price role resolves"); let report = run_blueprint_member( reload(), &[], @@ -4073,6 +4141,7 @@ mod tests { &topo, &env, non_default, + &binding, ); // persist exactly as the sweep/campaign paths do: store the blueprint, append the family.