refactor(cli): wrap_r declares its root roles from the resolved binding (#231 task 3)

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
This commit is contained in:
2026-07-11 02:27:05 +02:00
parent 595c98b264
commit 99a7f6603c
3 changed files with 139 additions and 40 deletions
+17
View File
@@ -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<M1Field> {
match name {
+20 -7
View File
@@ -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<Box<dyn aura_engine::Source>> = vec![Box::new(source)];
+102 -33
View File
@@ -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<String> = 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 <blueprint.json> --list-axes`: one `<name>:<kind>` 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<Scalar>)], from: Timestamp, to: Timestamp, data: &DataSource,
env: &project::Env,
env: &project::Env, binding: &binding::ResolvedBinding,
) -> Result<(SweepFamily, Vec<usize>), 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.