feat(cli): campaign cost models reach the member run and both re-run sides (#234 tasks 4-5)

cost_nodes_for (beside stop_rule_for_regime) is the one CostSpec ->
bound-builder binding — every component fully bound, so the wrapped
param space stays cost-invariant. run_blueprint_member joins the cost
rows into summarize_r (member metrics genuinely net) and stamps the
cost components into the manifest params; CliMemberRunner threads the
doc's cost. The re-run sides re-derive the model from the manifest:
reproduce via cost_specs_from_params (the #233 stop pattern — a costed
family reproduces bit-identically) and persist_campaign_traces binds
the same model so the C1 drift alarm compares like with like.
Hand-computed pin: a constant cost model yields
net_expectancy_r == expectancy_r - cost_per_trade/|entry-stop| on a
synthetic member.

Verified: full workspace suite green, clippy -D warnings clean; in-loop
spec + quality gates passed per task.

refs #234
This commit is contained in:
2026-07-11 10:41:16 +02:00
parent a1afc2fd02
commit bb1f6c8bdd
3 changed files with 576 additions and 16 deletions
+84 -8
View File
@@ -19,7 +19,7 @@ use std::path::{Path, PathBuf};
use std::sync::{mpsc, Arc};
use aura_campaign::{CampaignOutcome, CellOutcome, CellSpec, ExecFault, MemberFault, MemberRunner};
use aura_core::{Cell, ParamSpec, Scalar, ScalarKind, Timestamp};
use aura_core::{Cell, ParamSpec, PrimitiveBuilder, Scalar, ScalarKind, Timestamp};
use aura_engine::{
blueprint_from_json, f64_field, summarize, summarize_r, ColumnarTrace, FamilySelection,
RunReport,
@@ -28,8 +28,9 @@ use aura_ingest::{instrument_geometry, open_columns_window, unix_ms_to_epoch_ns}
use aura_registry::{CampaignRunRecord, WriteKind};
use aura_research::{
campaign_to_json, content_id_of, parse_campaign, parse_process, validate_campaign,
validate_process, CampaignDoc, DocRef,
validate_process, CampaignDoc, CostSpec, DocRef,
};
use aura_std::{CarryCost, ConstantCost, VolSlippageCost};
use crate::project::Env;
use crate::research_docs::{
@@ -61,6 +62,40 @@ fn stop_rule_for_regime(regime: Option<aura_research::RiskRegime>) -> aura_compo
}
}
/// The one CostSpec -> cost-node binding (#234), beside its risk sibling
/// `stop_rule_for_regime` and shared the same way: `CliMemberRunner::run_member`
/// (via `run_blueprint_member`) and `persist_campaign_traces`'s drift-alarm
/// re-run both bind through here, so the persist-side re-run structurally
/// cannot diverge from the run-side cost model (the #219 divergence class,
/// cost edition). The bound knob names are the builders' own `ParamSpec` names
/// — the `CostSpec` serde vocabulary conforms to them.
pub(crate) fn cost_nodes_for(specs: &[CostSpec]) -> Vec<PrimitiveBuilder> {
specs
.iter()
.map(|s| {
let (knob, v) = cost_knob(s);
match s {
CostSpec::Constant { .. } => ConstantCost::builder().bind(knob, Scalar::f64(v)),
CostSpec::VolSlippage { .. } => VolSlippageCost::builder().bind(knob, Scalar::f64(v)),
CostSpec::Carry { .. } => CarryCost::builder().bind(knob, Scalar::f64(v)),
}
})
.collect()
}
/// The one CostSpec-variant -> (knob name, value) mapping, shared by
/// `cost_nodes_for`'s bind above and `run_blueprint_member`'s manifest stamp
/// (main.rs): the stamp key must equal the bind key for reproduce to
/// re-derive a `CostSpec` from a stored manifest, so both sites read the
/// name off this single function rather than each carrying its own literal.
pub(crate) fn cost_knob(spec: &CostSpec) -> (&'static str, f64) {
match spec {
CostSpec::Constant { cost_per_trade } => ("cost_per_trade", *cost_per_trade),
CostSpec::VolSlippage { slip_vol_mult } => ("slip_vol_mult", *slip_vol_mult),
CostSpec::Carry { carry_per_cycle } => ("carry_per_cycle", *carry_per_cycle),
}
}
/// Phrase an [`ExecFault`] for stderr — Debug-leak-free, path-addressed
/// (stage index + block id), the `doc_fault_prose`/`ref_fault_prose` register.
pub(crate) fn exec_fault_prose(f: &ExecFault) -> String {
@@ -232,6 +267,9 @@ struct CliMemberRunner<'a> {
/// into per-member binding resolution (#231: rebind per campaign without
/// touching the blueprint's content id).
bindings: BTreeMap<String, String>,
/// The campaign's cost model (#234), threaded into every member run via
/// `cost_nodes_for` (empty = zero costs, today's graph).
cost: Vec<CostSpec>,
}
impl MemberRunner for CliMemberRunner<'_> {
@@ -313,6 +351,7 @@ impl MemberRunner for CliMemberRunner<'_> {
self.env,
stop,
&binding,
&self.cost,
);
report.manifest.instrument = Some(cell.instrument.clone());
Ok(report)
@@ -482,6 +521,7 @@ pub(crate) fn run_campaign_returning(
env,
server: Arc::new(data_server::DataServer::new(env.data_path())),
bindings: campaign.data.bindings.clone(),
cost: campaign.cost.clone(),
};
let outcome = aura_campaign::execute(
campaign_id,
@@ -862,16 +902,32 @@ 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, &binding, None)
.bootstrap_with_cells(&point)
.expect("the member's point re-bootstraps (it already ran this realization)");
// The campaign's OWN cost model (#234), bound the same way
// `CliMemberRunner::run_member` binds it (via `cost_nodes_for`):
// empty = no leg, so a cost-less campaign's re-run is unchanged.
// A non-empty model MUST be threaded here too — the recorded
// member ran net (Task 4's `run_blueprint_member`); leaving this
// re-run gross would trip the C1 drift alarm below on every
// legitimate costed campaign, not just on a real divergence.
let (tx_cost, rx_cost) = mpsc::channel();
let (tx_net, _rx_net) = mpsc::channel();
let cost_leg = (!campaign.cost.is_empty()).then(|| crate::CostLeg {
nodes: cost_nodes_for(&campaign.cost),
tx_cost,
tx_net,
});
let mut h =
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);
// Drain ALL FOUR channels (`run_signal_r` leaves req undrained; the
// trace path must not): eq/ex/req feed the taps, r feeds the metrics.
// Drain ALL FIVE channels (`run_signal_r` leaves req undrained; the
// trace path must not): eq/ex/req feed the taps, r+cost feed the metrics.
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();
let req_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_req.try_iter().collect();
let cost_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_cost.try_iter().collect();
// The C1 drift alarm: metrics equality against the recorded
// member. The reduce-mode fold shares its arithmetic with this
@@ -879,7 +935,7 @@ pub(crate) fn persist_campaign_traces(
// emits exactly the rows `summarize_r`'s ledger reads), so equality
// is bit-exact.
let mut rerun_metrics = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0));
rerun_metrics.r = Some(summarize_r(&r_rows, &[]));
rerun_metrics.r = Some(summarize_r(&r_rows, &cost_rows));
if rerun_metrics != member_report.metrics {
return Err(format!(
"trace re-run diverged from the recorded member (C1 violation): cell \
@@ -1225,4 +1281,24 @@ mod tests {
assert_eq!(tap_channel("r_equity"), Some(TapChannel::REquity));
assert_eq!(tap_channel("net_r_equity"), None);
}
#[test]
/// #234: the one CostSpec -> builder binding maps each component to its
/// shipped cost node with the knob BOUND — a bound component adds no open
/// param, so the wrapped param_space stays cost-invariant (the probe/member
/// space equivalence the campaign path relies on).
fn cost_nodes_for_maps_each_component_to_its_bound_builder() {
let nodes = cost_nodes_for(&[
aura_research::CostSpec::Constant { cost_per_trade: 2.0 },
aura_research::CostSpec::VolSlippage { slip_vol_mult: 0.5 },
aura_research::CostSpec::Carry { carry_per_cycle: 0.1 },
]);
let labels: Vec<String> = nodes.iter().map(|n| n.label()).collect();
assert_eq!(labels, ["ConstantCost", "VolSlippageCost", "CarryCost"]);
assert!(
nodes.iter().all(|n| n.params().is_empty()),
"every component must be fully bound (no open param leaks into param_space)"
);
assert!(cost_nodes_for(&[]).is_empty(), "an empty model binds no nodes");
}
}