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 std::sync::{mpsc, Arc};
use aura_campaign::{CampaignOutcome, CellOutcome, CellSpec, ExecFault, MemberFault, MemberRunner}; 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::{ use aura_engine::{
blueprint_from_json, f64_field, summarize, summarize_r, ColumnarTrace, FamilySelection, blueprint_from_json, f64_field, summarize, summarize_r, ColumnarTrace, FamilySelection,
RunReport, 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_registry::{CampaignRunRecord, WriteKind};
use aura_research::{ use aura_research::{
campaign_to_json, content_id_of, parse_campaign, parse_process, validate_campaign, 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::project::Env;
use crate::research_docs::{ 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 /// Phrase an [`ExecFault`] for stderr — Debug-leak-free, path-addressed
/// (stage index + block id), the `doc_fault_prose`/`ref_fault_prose` register. /// (stage index + block id), the `doc_fault_prose`/`ref_fault_prose` register.
pub(crate) fn exec_fault_prose(f: &ExecFault) -> String { 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 /// into per-member binding resolution (#231: rebind per campaign without
/// touching the blueprint's content id). /// touching the blueprint's content id).
bindings: BTreeMap<String, String>, 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<'_> { impl MemberRunner for CliMemberRunner<'_> {
@@ -313,6 +351,7 @@ impl MemberRunner for CliMemberRunner<'_> {
self.env, self.env,
stop, stop,
&binding, &binding,
&self.cost,
); );
report.manifest.instrument = Some(cell.instrument.clone()); report.manifest.instrument = Some(cell.instrument.clone());
Ok(report) Ok(report)
@@ -482,6 +521,7 @@ pub(crate) fn run_campaign_returning(
env, env,
server: Arc::new(data_server::DataServer::new(env.data_path())), server: Arc::new(data_server::DataServer::new(env.data_path())),
bindings: campaign.data.bindings.clone(), bindings: campaign.data.bindings.clone(),
cost: campaign.cost.clone(),
}; };
let outcome = aura_campaign::execute( let outcome = aura_campaign::execute(
campaign_id, campaign_id,
@@ -862,16 +902,32 @@ pub(crate) fn persist_campaign_traces(
let (tx_ex, rx_ex) = mpsc::channel(); let (tx_ex, rx_ex) = mpsc::channel();
let (tx_r, rx_r) = mpsc::channel(); let (tx_r, rx_r) = mpsc::channel();
let (tx_req, rx_req) = 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) // The campaign's OWN cost model (#234), bound the same way
.bootstrap_with_cells(&point) // `CliMemberRunner::run_member` binds it (via `cost_nodes_for`):
.expect("the member's point re-bootstraps (it already ran this realization)"); // 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); h.run(sources);
// Drain ALL FOUR channels (`run_signal_r` leaves req undrained; the // Drain ALL FIVE channels (`run_signal_r` leaves req undrained; the
// trace path must not): eq/ex/req feed the taps, r feeds the metrics. // 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 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();
let req_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_req.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 // The C1 drift alarm: metrics equality against the recorded
// member. The reduce-mode fold shares its arithmetic with this // 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 // emits exactly the rows `summarize_r`'s ledger reads), so equality
// is bit-exact. // is bit-exact.
let mut rerun_metrics = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0)); 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 { if rerun_metrics != member_report.metrics {
return Err(format!( return Err(format!(
"trace re-run diverged from the recorded member (C1 violation): cell \ "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("r_equity"), Some(TapChannel::REquity));
assert_eq!(tap_channel("net_r_equity"), None); 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");
}
} }
+314 -8
View File
@@ -1053,6 +1053,39 @@ fn stop_rule_from_params(params: &[(String, Scalar)]) -> StopRule {
} }
} }
/// Re-derive the cost model a member was minted under from its manifest params
/// (`cost[k].<knob>`, stamped by `run_blueprint_member`) — the #233 stop-regime
/// pattern: like the stop, the cost model rides OUTSIDE the wrapped
/// param_space, so `point_from_params` cannot recover it. Components are read
/// in index order; the builder knob name discriminates the variant (each
/// shipped cost node has exactly one, distinctly named knob). No `cost[0].*`
/// param = the empty (gross) model — every pre-cost member widens to it,
/// mirroring `stop_rule_from_params`'s default arm.
fn cost_specs_from_params(params: &[(String, Scalar)]) -> Vec<aura_research::CostSpec> {
let mut specs = Vec::new();
for k in 0.. {
let prefix = format!("cost[{k}].");
let Some((knob, v)) = params
.iter()
.find_map(|(n, s)| n.strip_prefix(&prefix).map(|rest| (rest, *s)))
else {
break;
};
specs.push(match knob {
"cost_per_trade" => aura_research::CostSpec::Constant { cost_per_trade: v.as_f64() },
"slip_vol_mult" => aura_research::CostSpec::VolSlippage { slip_vol_mult: v.as_f64() },
"carry_per_cycle" => aura_research::CostSpec::Carry { carry_per_cycle: v.as_f64() },
other => {
// Corrupted-on-disk manifest data — the reproduce path's clean
// exit register (`aura:` + exit 1), like point_from_params.
eprintln!("aura: manifest cost param cost[{k}].{other} names no cost component");
std::process::exit(1);
}
});
}
specs
}
/// Look up a persisted family by id, or exit 1 (unknown id / registry load failure) — /// Look up a persisted family by id, or exit 1 (unknown id / registry load failure) —
/// the single place `reproduce_family` and `reproduce_family_in` resolve a family, so /// the single place `reproduce_family` and `reproduce_family_in` resolve a family, so
/// the two exit-1 error phrasings can't drift out of sync between the call sites. /// the two exit-1 error phrasings can't drift out of sync between the call sites.
@@ -1115,6 +1148,10 @@ fn reproduce_family_in(
let (tx_r, _) = mpsc::channel(); let (tx_r, _) = mpsc::channel();
let (tx_req, _) = mpsc::channel(); let (tx_req, _) = mpsc::channel();
let stop = stop_rule_from_params(&stored.manifest.params); let stop = stop_rule_from_params(&stored.manifest.params);
// The member's cost model, re-derived from its manifest (the #233
// stop pattern): a costed family re-runs under the exact components it
// was minted with, so `net_expectancy_r` reproduces bit-identically.
let cost = cost_specs_from_params(&stored.manifest.params);
// The member's binding, re-derived from the stored blueprint's own // The member's binding, re-derived from the stored blueprint's own
// input roles (name defaults — family manifests carry no overrides). // input roles (name defaults — family manifests carry no overrides).
let binding = binding::resolve_binding(&hash, reload().input_roles(), &BTreeMap::new()) let binding = binding::resolve_binding(&hash, reload().input_roles(), &BTreeMap::new())
@@ -1188,6 +1225,7 @@ fn reproduce_family_in(
env, env,
stop, stop,
&binding, &binding,
&cost,
); );
outcomes.push((label, rerun.metrics == stored.metrics)); outcomes.push((label, rerun.metrics == stored.metrics));
} }
@@ -1588,12 +1626,23 @@ fn run_blueprint_member(
env: &project::Env, env: &project::Env,
stop: StopRule, stop: StopRule,
binding: &binding::ResolvedBinding, binding: &binding::ResolvedBinding,
cost: &[aura_research::CostSpec],
) -> RunReport { ) -> RunReport {
let (tx_eq, rx_eq) = mpsc::channel(); let (tx_eq, rx_eq) = mpsc::channel();
let (tx_ex, rx_ex) = mpsc::channel(); let (tx_ex, rx_ex) = mpsc::channel();
let (tx_r, rx_r) = mpsc::channel(); let (tx_r, rx_r) = mpsc::channel();
let (tx_req, _rx_req) = 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, binding, None) // The doc's cost model as an optional wrap leg (#234): empty = no leg,
// exactly the pre-cost graph. The net curve is a !reduce trace concern;
// its sender is wired but unread here (the r_equity tap precedent above).
let (tx_cost, rx_cost) = mpsc::channel();
let (tx_net, _rx_net) = mpsc::channel();
let cost_leg = (!cost.is_empty()).then(|| CostLeg {
nodes: campaign_run::cost_nodes_for(cost),
tx_cost,
tx_net,
});
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); h.run(sources);
@@ -1605,11 +1654,27 @@ fn run_blueprint_member(
named.push(("stop_length".to_string(), Scalar::i64(length))); named.push(("stop_length".to_string(), Scalar::i64(length)));
named.push(("stop_k".to_string(), Scalar::f64(k))); named.push(("stop_k".to_string(), Scalar::f64(k)));
} }
// Stamp the cost model the member ran under, beside the stop knobs (#234,
// the #233 pattern): one `cost[k].<knob>` param per component, in
// component order — the knob name discriminates the variant (each shipped
// cost node has exactly one, distinctly named knob). The name is read off
// `campaign_run::cost_knob`, the same function `cost_nodes_for` binds
// through, so the stamp key cannot drift from the bind key: the manifest
// carries enough to re-derive the exact model later. `reproduce_family_in`
// reads this stamp back via `cost_specs_from_params` (the #233 stop-regime
// pattern), so a costed family reproduces net, not gross.
for (k, spec) in cost.iter().enumerate() {
let (knob, v) = campaign_run::cost_knob(spec);
named.push((format!("cost[{k}].{knob}"), Scalar::f64(v)));
}
let mut manifest = sim_optimal_manifest(named, window, seed, pip); let mut manifest = sim_optimal_manifest(named, window, seed, pip);
manifest.broker = r_sma_broker_label(pip); manifest.broker = r_sma_broker_label(pip);
manifest.topology_hash = Some(topo.to_string()); manifest.topology_hash = Some(topo.to_string());
manifest.project = env.provenance(); manifest.project = env.provenance();
let r_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_r.try_iter().collect(); let r_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_r.try_iter().collect();
// The member's cost rows (empty when no cost model) join the R reduction:
// net_expectancy_r diverges from gross by exactly the modelled costs.
let cost_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_cost.try_iter().collect();
let (total_pips, max_drawdown) = rx_eq let (total_pips, max_drawdown) = rx_eq
.try_iter() .try_iter()
.next() .next()
@@ -1618,7 +1683,7 @@ fn run_blueprint_member(
let bias_sign_flips = let bias_sign_flips =
rx_ex.try_iter().next().map(|(_, row)| row[2].as_i64() as u64).unwrap_or(0); rx_ex.try_iter().next().map(|(_, row)| row[2].as_i64() as u64).unwrap_or(0);
let mut m = RunMetrics { total_pips, max_drawdown, bias_sign_flips, r: None }; let mut m = RunMetrics { total_pips, max_drawdown, bias_sign_flips, r: None };
m.r = Some(summarize_r(&r_rows, &[])); m.r = Some(summarize_r(&r_rows, &cost_rows));
RunReport { manifest, metrics: m } RunReport { manifest, metrics: m }
} }
@@ -1725,7 +1790,7 @@ fn blueprint_sweep_family(
.sweep(|point| { .sweep(|point| {
// fresh per-member graph (Composite is !Clone, reload per member) run through // fresh per-member graph (Composite is !Clone, reload per member) run through
// the shared reduce-mode member path — the same fn reproduction re-runs. // the shared reduce-mode member path — the same fn reproduction re-runs.
run_blueprint_member(reload(doc), point, &space, data.run_sources(env, &binding.columns()), window, 0, pip, &topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, &binding) run_blueprint_member(reload(doc), point, &space, data.run_sources(env, &binding.columns()), 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), // 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. // so `UnknownKnob("nope")` still surfaces verbatim at the IO wrapper.
@@ -1758,7 +1823,7 @@ fn blueprint_sweep_over(
binder.sweep_with_lattice(|point| { binder.sweep_with_lattice(|point| {
let sources = data.windowed_sources(from, to, env, &binding.columns()); let sources = data.windowed_sources(from, to, env, &binding.columns());
let window = window_of(&sources).expect("non-empty in-sample window"); 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 }, binding) 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, &[])
}) })
} }
@@ -1776,7 +1841,7 @@ fn run_oos_blueprint(
let pip = data.pip_size(); let pip = data.pip_size();
let sources = data.windowed_sources(from, to, env, &binding.columns()); let sources = data.windowed_sources(from, to, env, &binding.columns());
let window = window_of(&sources).expect("non-empty out-of-sample window"); 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 }, binding); 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) (Vec::new(), report)
} }
@@ -1903,7 +1968,7 @@ fn blueprint_mc_family(
let family = monte_carlo(&base_point, &seeds, |seed, _base| { let family = monte_carlo(&base_point, &seeds, |seed, _base| {
let sources = synthetic_walk_sources(seed); let sources = synthetic_walk_sources(seed);
let window = window_of(&sources).expect("non-empty synthetic walk"); 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 }, &binding) 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 // 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 — // metrics are bit-identical to the first, no seed reached a distinguishable realization —
@@ -3964,6 +4029,184 @@ mod tests {
); );
} }
/// #234: a member run under a constant cost model nets the hand-computed
/// per-trade cost — `net_expectancy_r ≈ expectancy_r mean(cost_per_trade
/// / |entry stop|)` over summarize_r's ledger (closed rows + the
/// window-end open row; the CostRunner R-normalization contract), while
/// every gross field stays byte-identical to the cost-less run; and the
/// manifest stamps the component for reproduce to re-derive.
#[test]
fn run_blueprint_member_joins_a_constant_cost_model_into_net_metrics() {
let env = project::Env::std();
let data = DataSource::Synthetic;
let doc = blueprint_to_json(&load_closed_r_sma()).expect("serializes");
let reload = || blueprint_from_json(&doc, &|t| std_vocabulary(t)).expect("loads");
let space = blueprint_axis_probe(&doc, &env).param_space();
let binding = binding::resolve_binding("costnet", reload().input_roles(), &BTreeMap::new())
.expect("the price role resolves");
let stop = StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K };
let window = data.full_window(&env);
let pip = data.pip_size();
const CPT: f64 = 0.0005; // price units; the synthetic stream trades near 1.0
let run = |cost: &[aura_research::CostSpec]| {
run_blueprint_member(
reload(),
&[],
&space,
data.run_sources(&env, &binding.columns()),
window,
0,
pip,
"topo",
&env,
stop,
&binding,
cost,
)
};
let gross = run(&[]);
let netted = run(&[aura_research::CostSpec::Constant { cost_per_trade: CPT }]);
// The trade geometry, independently: a non-reduce cost-less wrap over
// the same realization, draining the dense R record whose ledger
// summarize_r reads.
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 flat = wrap_r(reload(), tx_eq, tx_ex, tx_r, tx_req, stop, false, pip, &binding, None)
.compile_with_params(&[])
.expect("wraps");
let mut h = Harness::bootstrap(flat).expect("bootstraps");
h.run(data.run_sources(&env, &binding.columns()));
let r_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_r.try_iter().collect();
// summarize_r's ledger: closed rows, plus the final row if open — each
// charged cost_per_trade / |entry stop| (0 when the latched distance
// is 0). PM record cols: closed=0, entry_price=6, stop_price=7,
// open=11 (the aura-analysis r_col contract).
let mut costs: Vec<f64> = Vec::new();
for (i, (_, row)) in r_rows.iter().enumerate() {
let is_last = i == r_rows.len() - 1;
if row[0].as_bool() || (is_last && row[11].as_bool()) {
let latched = (row[6].as_f64() - row[7].as_f64()).abs();
costs.push(if latched > 0.0 { CPT / latched } else { 0.0 });
}
}
let g = gross.metrics.r.as_ref().expect("gross member carries R metrics");
let n = netted.metrics.r.as_ref().expect("netted member carries R metrics");
assert_eq!(
costs.len() as u64, g.n_trades,
"the independent ledger walk must see the member's trades"
);
assert!(costs.iter().any(|&c| c > 0.0), "at least one trade must charge (non-vacuous)");
let mean_cost = costs.iter().sum::<f64>() / costs.len() as f64;
assert_eq!(gross.metrics.total_pips, netted.metrics.total_pips, "gross pips unchanged");
assert_eq!(g.expectancy_r, n.expectancy_r, "gross R stays byte-identical under cost");
assert_eq!(g.n_trades, n.n_trades);
assert_eq!(g.net_expectancy_r, g.expectancy_r, "cost-less: net == gross");
assert!(
(n.net_expectancy_r - (g.expectancy_r - mean_cost)).abs() < 1e-12,
"net = gross mean per-trade cost: net {} gross {} mean_cost {mean_cost}",
n.net_expectancy_r,
g.expectancy_r
);
// The manifest stamps the component (the reproduce re-derivation carrier)...
assert!(
netted
.manifest
.params
.iter()
.any(|(k, v)| k == "cost[0].cost_per_trade" && *v == Scalar::f64(CPT)),
"member manifest stamps the cost component: {:?}",
netted.manifest.params
);
// ...and a cost-less member stamps nothing (content/label stability).
assert!(
!gross.manifest.params.iter().any(|(k, _)| k.starts_with("cost[")),
"a cost-less member stamps no cost params"
);
}
/// #234: `campaign_run::persist_campaign_traces`'s C1 drift alarm re-runs a
/// costed member in `!reduce` mode (fresh per-cycle taps + a `CostLeg`
/// bound the same way `cost_nodes_for` binds it) and compares the result
/// against the recorded reduce-mode member. This pins that cross-mode
/// equivalence holds UNDER a non-empty cost model: the `!reduce` +
/// `CostLeg` wiring (this test, verbatim) and the reduce-mode
/// `run_blueprint_member` path must net to the exact same `RunMetrics`
/// over the same synthetic realization — the equality the drift alarm
/// structurally depends on to not false-positive on every legitimate
/// costed campaign.
#[test]
fn persist_side_nonreduce_rerun_matches_reduce_mode_net_metrics_under_cost() {
let env = project::Env::std();
let data = DataSource::Synthetic;
let doc = blueprint_to_json(&load_closed_r_sma()).expect("serializes");
let reload = || blueprint_from_json(&doc, &|t| std_vocabulary(t)).expect("loads");
let space = blueprint_axis_probe(&doc, &env).param_space();
let binding =
binding::resolve_binding("persistnet", reload().input_roles(), &BTreeMap::new())
.expect("the price role resolves");
let stop = StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K };
let window = data.full_window(&env);
let pip = data.pip_size();
let cost = [aura_research::CostSpec::Constant { cost_per_trade: 0.0005 }];
// The recorded member: reduce-mode, cost-bound — `run_blueprint_member`,
// the exact path `CliMemberRunner::run_member` calls.
let recorded = run_blueprint_member(
reload(),
&[],
&space,
data.run_sources(&env, &binding.columns()),
window,
0,
pip,
"topo",
&env,
stop,
&binding,
&cost,
);
// The persist-side re-run, verbatim structure: `!reduce` + a `CostLeg`
// built through the SAME `campaign_run::cost_nodes_for`, then
// `summarize` + `summarize_r(&r_rows, &cost_rows)` — exactly
// `persist_campaign_traces`'s C1 drift-alarm computation.
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 (tx_cost, rx_cost) = mpsc::channel();
let (tx_net, _rx_net) = mpsc::channel();
let cost_leg =
Some(CostLeg { nodes: campaign_run::cost_nodes_for(&cost), tx_cost, tx_net });
let flat = wrap_r(reload(), tx_eq, tx_ex, tx_r, tx_req, stop, false, pip, &binding, cost_leg)
.compile_with_params(&[])
.expect("the persist-side wrap builds");
let mut h = Harness::bootstrap(flat).expect("the persist-side harness bootstraps");
h.run(data.run_sources(&env, &binding.columns()));
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 cost_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_cost.try_iter().collect();
let mut rerun_metrics = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0));
rerun_metrics.r = Some(summarize_r(&r_rows, &cost_rows));
assert!(
rerun_metrics.r.as_ref().is_some_and(|r| r.n_trades > 0),
"the fixture must actually close at least one costed trade"
);
assert_eq!(
rerun_metrics, recorded.metrics,
"the persist path's !reduce + CostLeg re-run must net to the exact same \
RunMetrics as the reduce-mode member under the same cost model — the \
C1 drift alarm `persist_campaign_traces` relies on"
);
}
/// Property: the pip a run resolves and stamps into its manifest must be the /// Property: the pip a run resolves and stamps into its manifest must be the
/// pip the in-graph `SimBroker` divides by — the resolved (real, per-instrument) /// pip the in-graph `SimBroker` divides by — the resolved (real, per-instrument)
/// pip has to reach the graph, not just the manifest label. The `SimBroker` /// pip has to reach the graph, not just the manifest label. The `SimBroker`
@@ -4001,10 +4244,10 @@ mod tests {
let binding = binding::resolve_binding(signal_a.name(), signal_a.input_roles(), &BTreeMap::new()) let binding = binding::resolve_binding(signal_a.name(), signal_a.input_roles(), &BTreeMap::new())
.expect("the price role resolves"); .expect("the price role resolves");
let report_a = run_blueprint_member( let report_a = run_blueprint_member(
signal_a, &[], &space, make_source(), window, 0, pip_a, "topo", &env, stop, &binding, signal_a, &[], &space, make_source(), window, 0, pip_a, "topo", &env, stop, &binding, &[],
); );
let report_b = run_blueprint_member( let report_b = run_blueprint_member(
r_meanrev_signal(Some(3), Some(0.0)), &[], &space, make_source(), window, 0, pip_b, "topo", &env, stop, &binding, 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. // Guard: the run must have actually traded, else the invariant is vacuous.
assert!( assert!(
@@ -4636,6 +4879,7 @@ mod tests {
&env, &env,
non_default, non_default,
&binding, &binding,
&[],
); );
// persist exactly as the sweep/campaign paths do: store the blueprint, append the family. // persist exactly as the sweep/campaign paths do: store the blueprint, append the family.
@@ -4659,6 +4903,68 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
} }
/// #234: a family minted under a cost model must reproduce bit-identically —
/// reproduce re-derives the components from the member manifest (the #233
/// stop-regime pattern). Without the re-derivation the re-run joins an
/// empty cost slice, its net_expectancy_r reverts to gross, and the member
/// reports DIVERGED. All three variants ride along so every knob name
/// round-trips (the vol_slippage component also exercises the reduce-mode
/// vol proxy).
#[test]
fn reproduce_family_re_derives_a_costed_member_bit_identically() {
let dir = std::env::temp_dir().join(format!("aura-repro-cost-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("temp dir");
let reg = Registry::open(dir.join("runs.jsonl"));
let env = project::Env::std();
let data = DataSource::Synthetic;
let doc = blueprint_to_json(&load_closed_r_sma()).expect("serializes");
let reload = || blueprint_from_json(&doc, &|t| std_vocabulary(t)).expect("loads");
let topo = topology_hash(&reload());
let space = blueprint_axis_probe(&doc, &env).param_space();
let pip = data.pip_size();
let window = data.full_window(&env);
let binding = binding::resolve_binding("costrepro", reload().input_roles(), &BTreeMap::new())
.expect("the price role resolves");
let cost = vec![
aura_research::CostSpec::Constant { cost_per_trade: 0.0005 },
aura_research::CostSpec::VolSlippage { slip_vol_mult: 0.5 },
aura_research::CostSpec::Carry { carry_per_cycle: 0.0001 },
];
let report = run_blueprint_member(
reload(),
&[],
&space,
data.run_sources(&env, &binding.columns()),
window,
0,
pip,
&topo,
&env,
StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K },
&binding,
&cost,
);
// Non-vacuity: the cost model must actually bite, else a DIVERGED
// verdict could never be observed and this pin proves nothing.
let r = report.metrics.r.as_ref().expect("member carries R metrics");
assert_ne!(r.net_expectancy_r, r.expectancy_r, "the cost model must move net off gross");
let canonical = blueprint_to_json(&reload()).unwrap();
reg.put_blueprint(&topo, &canonical).expect("store blueprint");
let id = reg.append_family("costrepro", FamilyKind::Sweep, &[report]).expect("append");
let rep = reproduce_family_in(&reg, &id, &data, &env);
assert_eq!(rep.outcomes.len(), 1, "one member reproduced");
assert!(
rep.outcomes.iter().all(|(_, ok)| *ok),
"the costed member re-derives bit-identically: {:?}",
rep.outcomes
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test] #[test]
fn reproduce_family_re_derives_every_mc_member_bit_identically() { fn reproduce_family_re_derives_every_mc_member_bit_identically() {
let dir = std::env::temp_dir().join(format!("aura-repro-mc-{}", std::process::id())); let dir = std::env::temp_dir().join(format!("aura-repro-mc-{}", std::process::id()));
+178
View File
@@ -2359,6 +2359,184 @@ fn campaign_persist_non_default_regime_does_not_false_fail_c1() {
); );
} }
/// Property (#234 Task 5, quality-review Minor finding): a costed campaign's
/// trace-persist re-run must not false-fail the C1 drift alarm, exercised
/// through the REAL production path — `campaign run` -> `run_campaign_returning`
/// -> `CliMemberRunner` (net-mode member run) -> `persist_campaign_traces`
/// (non-reduce re-run binding a `CostLeg` via `cost_nodes_for`) — not the
/// hand-mirrored arithmetic pinned by
/// `persist_side_nonreduce_rerun_matches_reduce_mode_net_metrics_under_cost` in
/// `main.rs`'s unit tests. Mirrors the sibling non-default-regime e2e above
/// (same #219-class divergence risk, cost edition): a costed campaign
/// document, `persist_taps` non-empty so the re-run actually executes, must
/// exit 0 with its nominee's cost stamp on disk and no C1 violation. Gated on
/// the local GER40 archive; skips on a data-less host.
#[test]
fn campaign_run_real_e2e_costed_campaign_persists_without_drift_alarm() {
let _fixture = project_lock();
let dir = built_project();
let runs_dir = dir.join("runs");
std::fs::remove_dir_all(&runs_dir).ok();
let _cleanup = ScratchGuard(vec![
ScratchPath::Dir(runs_dir.clone()),
ScratchPath::File(dir.join("cost-persist.process.json")),
ScratchPath::File(dir.join("cost-persist.campaign.json")),
]);
let bp_id = seed_blueprint(dir, "campaign-persist-cost-seed");
let proc_id = register_process_doc(dir, "cost-persist.process.json", SWEEP_ONLY_PROCESS_DOC);
let base = campaign_doc_json(
&bp_id,
&proc_id,
(1725148800000, 1727740799999),
"\"equity\", \"r_equity\"",
"\"family_table\"",
);
let with_cost = base.replacen(
"\"seed\": 7,",
"\"seed\": 7,\n \"cost\": [ { \"constant\": { \"cost_per_trade\": 0.0005 } } ],",
1,
);
assert_ne!(with_cost, base, "replacen must actually match the fixture's seed field");
write_doc(dir, "cost-persist.campaign.json", &with_cost);
let (out, code) = run_code_in(dir, &["campaign", "run", "cost-persist.campaign.json"]);
// Skip on a data-less machine: the member-data refusal, never a panic.
if code == Some(1)
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
{
eprintln!("skip: no local GER40 data for the campaign cost-persist e2e");
return;
}
assert!(
!out.contains("C1 violation"),
"the costed persist-side re-run must bind the SAME cost model the nominee ran \
under, not false-fail C1: {out}"
);
assert_eq!(code, Some(0), "a deterministic costed campaign persists cleanly: {out}");
// Non-vacuity: every emitted member actually stamped the cost component
// it ran under (else the C1 comparison above never engaged a non-empty
// CostLeg and this test would pass for the wrong reason).
let member_lines: Vec<&str> =
out.lines().filter(|l| l.starts_with("{\"family_id\":")).collect();
assert!(!member_lines.is_empty(), "family_table member lines emitted: {out}");
for line in &member_lines {
let v: serde_json::Value = serde_json::from_str(line).expect("member line parses as JSON");
let params = v["report"]["manifest"]["params"]
.as_array()
.expect("manifest.params is an array");
assert!(
params.iter().any(|p| p[0].as_str() == Some("cost[0].cost_per_trade")),
"the costed member stamps its cost component: {line}"
);
}
// The nominee's taps actually landed under the costed re-run.
let record_line = out
.lines()
.find(|l| l.starts_with("{\"campaign_run\":"))
.expect("the always-on final campaign_run line");
let v: serde_json::Value =
serde_json::from_str(record_line).expect("campaign_run line parses as JSON");
let trace_name = v["campaign_run"]["trace_name"]
.as_str()
.expect("persist_taps non-empty => the record carries trace_name");
let cell_key = format!("{}-GER40-w0", &bp_id[..8]);
let cell_dir = runs_dir.join("traces").join(trace_name).join(&cell_key);
assert!(
cell_dir.join("r_equity.json").is_file(),
"the costed nominee's r_equity tap persists at {}",
cell_dir.display()
);
}
/// Property (#234): a `vol_slippage` cost component — the structurally
/// distinct leg from `constant` (it reactivates the vol-proxy RollingMax/
/// RollingMin/Sub chain feeding a `VolSlippageCost` node, not a flat per-close
/// charge) — nets a real campaign member through the SAME production path as
/// its `constant` sibling
/// (`campaign_run_real_e2e_costed_campaign_persists_without_drift_alarm`):
/// `campaign run` -> `CliMemberRunner` (net-mode) -> `persist_campaign_traces`
/// (non-reduce re-run binding a `CostLeg`). The re-run must bind the identical
/// vol-slippage model the nominee ran under, so it must not false-fail the C1
/// drift alarm; the emitted member must stamp `cost[0].slip_vol_mult`; and the
/// vol-driven charge must actually move net off gross (non-vacuity — a
/// zero-effect charge would let this pass even if the vol proxy were never
/// wired). Gated on the local GER40 archive; skips on a data-less host.
#[test]
fn campaign_run_real_e2e_vol_slippage_cost_persists_without_drift_alarm() {
let _fixture = project_lock();
let dir = built_project();
let runs_dir = dir.join("runs");
std::fs::remove_dir_all(&runs_dir).ok();
let _cleanup = ScratchGuard(vec![
ScratchPath::Dir(runs_dir.clone()),
ScratchPath::File(dir.join("volcost-persist.process.json")),
ScratchPath::File(dir.join("volcost-persist.campaign.json")),
]);
let bp_id = seed_blueprint(dir, "campaign-persist-volcost-seed");
let proc_id =
register_process_doc(dir, "volcost-persist.process.json", SWEEP_ONLY_PROCESS_DOC);
let base = campaign_doc_json(
&bp_id,
&proc_id,
(1725148800000, 1727740799999),
"\"r_equity\"",
"\"family_table\"",
);
let with_cost = base.replacen(
"\"seed\": 7,",
"\"seed\": 7,\n \"cost\": [ { \"vol_slippage\": { \"slip_vol_mult\": 0.5 } } ],",
1,
);
assert_ne!(with_cost, base, "replacen must actually match the fixture's seed field");
write_doc(dir, "volcost-persist.campaign.json", &with_cost);
let (out, code) = run_code_in(dir, &["campaign", "run", "volcost-persist.campaign.json"]);
// Skip on a data-less machine: the member-data refusal, never a panic.
if code == Some(1)
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
{
eprintln!("skip: no local GER40 data for the campaign vol-slippage-persist e2e");
return;
}
assert!(
!out.contains("C1 violation"),
"the vol-slippage persist-side re-run must bind the SAME cost model the nominee \
ran under, not false-fail C1: {out}"
);
assert_eq!(code, Some(0), "a deterministic vol-slippage-costed campaign persists cleanly: {out}");
let member_lines: Vec<&str> =
out.lines().filter(|l| l.starts_with("{\"family_id\":")).collect();
assert!(!member_lines.is_empty(), "family_table member lines emitted: {out}");
let mut any_net_moved = false;
for line in &member_lines {
let v: serde_json::Value = serde_json::from_str(line).expect("member line parses as JSON");
let params = v["report"]["manifest"]["params"]
.as_array()
.expect("manifest.params is an array");
assert!(
params.iter().any(|p| p[0].as_str() == Some("cost[0].slip_vol_mult")),
"the costed member stamps its vol_slippage component: {line}"
);
if let Some(r) = v["report"]["metrics"]["r"].as_object() {
let gross = r["expectancy_r"].as_f64().expect("expectancy_r is a number");
let net = r["net_expectancy_r"].as_f64().expect("net_expectancy_r is a number");
if gross != net {
any_net_moved = true;
}
}
}
assert!(
any_net_moved,
"at least one member's vol-slippage charge must move net off gross \
(else the vol proxy never engaged): {out}"
);
}
/// RED (#212 — the trace-dir collision facet of the same #219 record gap): two /// RED (#212 — the trace-dir collision facet of the same #219 record gap): two
/// risk regimes over the SAME (strategy, instrument, window) cell must persist /// risk regimes over the SAME (strategy, instrument, window) cell must persist
/// their traces into DISTINCT directories. `campaign_cell_key` names dirs /// their traces into DISTINCT directories. `campaign_cell_key` names dirs