feat(research,cli): per-instrument cost factors in one campaign document

Each cost component's knob (cost_per_trade / slip_vol_mult /
carry_per_cycle) now accepts one number for every cell — today's form,
byte-identical on the wire so stored documents keep their content ids
(CostValue's custom serde, the Axis precedent) — or an instrument-keyed
map resolved per cell at member construction. A mixed-scale matrix
(GER40 ~2e4, EURUSD ~1.1) keeps consistent cost units in ONE document:
the N-copies workaround retires and generalize-under-constant-costs
becomes expressible again.

Validation is strict both ways at the intrinsic tier: a map missing a
campaign instrument, or naming one the campaign does not list, refuses
at validate with prose naming the component and instruments (no silent
default — a partial map would reproduce exactly the silent unit
inconsistency the issue reports). The instrument threads through
cost_knob/cost_nodes_for/run_blueprint_member from the cell (run side
and persist re-run side receive the same value — the C1 drift-alarm
lockstep); the manifest stamps the resolved per-cell value under the
unchanged cost[k].<knob> key, and the sugar/reproduce paths pass an
inert instrument (their specs are scalar by construction). Fork
decisions and the discarded first attempt: issue #260 comments.

New coverage: serde round-trips (map form; scalar stays bare), validate
cross-check units, both prose directions pinned, and two hostless e2es
over the synthetic SYMA+SYMB archive — per-cell resolution visible in
the member manifests, and the validate refusal.

closes #260
This commit is contained in:
2026-07-14 01:20:59 +02:00
parent ca4a89864c
commit e00e264252
5 changed files with 456 additions and 50 deletions
+32 -15
View File
@@ -69,11 +69,11 @@ fn stop_rule_for_regime(regime: Option<aura_research::RiskRegime>) -> aura_compo
/// 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> {
pub(crate) fn cost_nodes_for(specs: &[CostSpec], instrument: &str) -> Vec<PrimitiveBuilder> {
specs
.iter()
.map(|s| {
let (knob, v) = cost_knob(s);
let (knob, v) = cost_knob(s, instrument);
match s {
CostSpec::Constant { .. } => ConstantCost::builder().bind(knob, Scalar::f64(v)),
CostSpec::VolSlippage { .. } => VolSlippageCost::builder().bind(knob, Scalar::f64(v)),
@@ -88,12 +88,19 @@ pub(crate) fn cost_nodes_for(specs: &[CostSpec]) -> Vec<PrimitiveBuilder> {
/// (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),
}
pub(crate) fn cost_knob(spec: &CostSpec, instrument: &str) -> (&'static str, f64) {
let (knob, value) = 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),
};
let v = value.resolve(instrument).unwrap_or_else(|| {
// Unreachable after intrinsic validation (map keys ≡ instruments);
// refuse loudly rather than charging 0 silently if it ever surfaces.
eprintln!("aura: cost {knob}: no entry for instrument {instrument}");
std::process::exit(1);
});
(knob, v)
}
/// Phrase an [`ExecFault`] for stderr — Debug-leak-free, path-addressed
@@ -397,6 +404,7 @@ impl MemberRunner for CliMemberRunner<'_> {
stop,
&binding,
&self.cost,
&cell.instrument,
);
report.manifest.instrument = Some(cell.instrument.clone());
Ok(report)
@@ -975,7 +983,7 @@ pub(crate) fn persist_campaign_traces(
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),
nodes: cost_nodes_for(&campaign.cost, &cell_rec.instrument),
tx_cost,
tx_net,
});
@@ -1393,18 +1401,27 @@ mod tests {
/// 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 nodes = cost_nodes_for(
&[
aura_research::CostSpec::Constant {
cost_per_trade: aura_research::CostValue::Scalar(2.0),
},
aura_research::CostSpec::VolSlippage {
slip_vol_mult: aura_research::CostValue::Scalar(0.5),
},
aura_research::CostSpec::Carry {
carry_per_cycle: aura_research::CostValue::Scalar(0.1),
},
],
"GER40",
);
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");
assert!(cost_nodes_for(&[], "GER40").is_empty(), "an empty model binds no nodes");
}
#[test]
+50 -17
View File
@@ -1175,9 +1175,15 @@ fn cost_specs_from_params(params: &[(String, Scalar)]) -> Vec<aura_research::Cos
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() },
"cost_per_trade" => aura_research::CostSpec::Constant {
cost_per_trade: aura_research::CostValue::Scalar(v.as_f64()),
},
"slip_vol_mult" => aura_research::CostSpec::VolSlippage {
slip_vol_mult: aura_research::CostValue::Scalar(v.as_f64()),
},
"carry_per_cycle" => aura_research::CostSpec::Carry {
carry_per_cycle: aura_research::CostValue::Scalar(v.as_f64()),
},
other => {
// Corrupted-on-disk manifest data — the reproduce path's clean
// exit register (`aura:` + exit 1), like point_from_params.
@@ -1347,6 +1353,10 @@ fn reproduce_family_in(
stop,
&binding,
&cost,
// The re-run specs come from the stamp and are scalar by
// construction (`cost_specs_from_params`), so the instrument is
// inert; the fallback is never resolved against a map.
stored.manifest.instrument.as_deref().unwrap_or(""),
);
outcomes.push((label, rerun.metrics == stored.metrics));
}
@@ -1730,6 +1740,14 @@ fn run_signal_r(
RunReport { manifest, metrics }
}
/// #260: the r-sma sugar/MC paths below run with either no cost model (empty
/// `cost` slice) or CLI-flag cost specs (scalar-only — `cost_specs_from_params`
/// only ever wraps `CostValue::Scalar`), so an instrument-keyed map can never
/// originate on these paths and the instrument context is genuinely inert.
/// Named once so every such call site states its intent by reference instead
/// of repeating the justifying comment.
const NO_INSTRUMENT_CONTEXT: &str = "";
/// Run one bootstrapped member of a loaded-signal sweep: the reduce-mode path
/// (`SeriesReducer` folds eq/ex, `GatedRecorder` retains the gated R rows — the
/// O(cycles)→O(trades) fold), shared by the live sweep AND reproduction so a
@@ -1750,6 +1768,7 @@ fn run_blueprint_member(
stop: StopRule,
binding: &binding::ResolvedBinding,
cost: &[aura_research::CostSpec],
instrument: &str,
) -> RunReport {
let defaults = wrapped_bound_defaults(&signal); // read before `signal` is consumed below
let (tx_eq, rx_eq) = mpsc::channel();
@@ -1762,7 +1781,7 @@ fn run_blueprint_member(
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),
nodes: campaign_run::cost_nodes_for(cost, instrument),
tx_cost,
tx_net,
});
@@ -1788,7 +1807,7 @@ fn run_blueprint_member(
// 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);
let (knob, v) = campaign_run::cost_knob(spec, instrument);
named.push((format!("cost[{k}].{knob}"), Scalar::f64(v)));
}
let mut manifest = sim_optimal_manifest(named, window, seed, pip);
@@ -2055,7 +2074,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, &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, &[], NO_INSTRUMENT_CONTEXT)
})
// render the sweep terminal's BindError to prose (#247), the fn's String error
// contract — never the raw Debug struct.
@@ -2101,7 +2120,7 @@ fn blueprint_sweep_over(
binder.sweep_with_lattice(|point| {
let sources = data.windowed_sources(from, to, env, &binding.columns());
let window = window_of(&sources).expect("non-empty in-sample window");
run_blueprint_member(reopen_all(reload(doc), &overrides), point, &space, sources, window, 0, pip, &topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, binding, &[])
run_blueprint_member(reopen_all(reload(doc), &overrides), point, &space, sources, window, 0, pip, &topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, binding, &[], NO_INSTRUMENT_CONTEXT)
})
}
@@ -2127,7 +2146,7 @@ fn run_oos_blueprint(
let pip = data.pip_size();
let sources = data.windowed_sources(from, to, env, &binding.columns());
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, &[], NO_INSTRUMENT_CONTEXT);
(Vec::new(), report)
}
@@ -2307,7 +2326,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 }, &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, &[], NO_INSTRUMENT_CONTEXT)
});
// 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 —
@@ -4828,10 +4847,13 @@ mod tests {
stop,
&binding,
cost,
"GER40",
)
};
let gross = run(&[]);
let netted = run(&[aura_research::CostSpec::Constant { cost_per_trade: CPT }]);
let netted = run(&[aura_research::CostSpec::Constant {
cost_per_trade: aura_research::CostValue::Scalar(CPT),
}]);
// The trade geometry, independently: a non-reduce cost-less wrap over
// the same realization, draining the dense R record whose ledger
@@ -4918,7 +4940,9 @@ mod tests {
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 }];
let cost = [aura_research::CostSpec::Constant {
cost_per_trade: aura_research::CostValue::Scalar(0.0005),
}];
// The recorded member: reduce-mode, cost-bound — `run_blueprint_member`,
// the exact path `CliMemberRunner::run_member` calls.
@@ -4935,6 +4959,7 @@ mod tests {
stop,
&binding,
&cost,
"GER40",
);
// The persist-side re-run, verbatim structure: `!reduce` + a `CostLeg`
@@ -4948,7 +4973,7 @@ mod tests {
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 });
Some(CostLeg { nodes: campaign_run::cost_nodes_for(&cost, "GER40"), 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");
@@ -5010,10 +5035,10 @@ mod tests {
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(
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, &[], "GER40",
);
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, &[], "GER40",
);
// Guard: the run must have actually traded, else the invariant is vacuous.
assert!(
@@ -5676,6 +5701,7 @@ mod tests {
non_default,
&binding,
&[],
"GER40",
);
// persist exactly as the sweep/campaign paths do: store the blueprint, append the family.
@@ -5725,9 +5751,15 @@ mod tests {
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 },
aura_research::CostSpec::Constant {
cost_per_trade: aura_research::CostValue::Scalar(0.0005),
},
aura_research::CostSpec::VolSlippage {
slip_vol_mult: aura_research::CostValue::Scalar(0.5),
},
aura_research::CostSpec::Carry {
carry_per_cycle: aura_research::CostValue::Scalar(0.0001),
},
];
let report = run_blueprint_member(
reload(),
@@ -5742,6 +5774,7 @@ mod tests {
StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K },
&binding,
&cost,
"GER40",
);
// Non-vacuity: the cost model must actually bite, else a DIVERGED
// verdict could never be observed and this pin proves nothing.
+29
View File
@@ -147,6 +147,16 @@ pub(crate) fn doc_fault_prose(f: &DocFault) -> String {
DocFault::BadCost { index } => {
format!("cost[{index}]: the component's price-unit knob must be finite and >= 0")
}
DocFault::CostInstrumentKeys { index, missing, extra } => {
let mut parts = Vec::new();
if !missing.is_empty() {
parts.push(format!("misses campaign instrument(s) {}", missing.join(", ")));
}
if !extra.is_empty() {
parts.push(format!("names no campaign instrument(s): {}", extra.join(", ")));
}
format!("cost[{index}]: instrument map {}", parts.join("; "))
}
DocFault::NoStrategy => "strategies is empty".into(),
DocFault::EmptyAxes { strategy } => format!("strategies[{strategy}]: axes is empty"),
DocFault::EmptyAxis { strategy, axis } => {
@@ -654,4 +664,23 @@ mod tests {
);
assert!(!prose.contains("UnknownTap"), "Debug leak: {prose}");
}
/// #260: the instrument-map key-mismatch fault is path-addressed and names
/// both directions — the campaign instruments the map misses AND the map
/// keys naming no campaign instrument — in one line, no Debug leak. The
/// extra-keys branch is otherwise unpinned (the e2e covers only missing).
#[test]
fn cost_instrument_keys_prose_names_both_directions() {
let prose = doc_fault_prose(&DocFault::CostInstrumentKeys {
index: 1,
missing: vec!["EURUSD".into()],
extra: vec!["GER40.cash".into()],
});
assert_eq!(
prose,
"cost[1]: instrument map misses campaign instrument(s) EURUSD; \
names no campaign instrument(s): GER40.cash"
);
assert!(!prose.contains("CostInstrumentKeys"), "Debug leak: {prose}");
}
}