feat(research,cli): the CostSpec campaign vocabulary (#234 task 2)

CampaignDoc gains an additive cost: Vec<CostSpec> block — a closed,
externally tagged vocabulary over the three shipped cost nodes
(constant/vol_slippage/carry, field names = the builders' ParamSpec
names), mirroring the risk block: serde default + skip-if-empty (cost-
less documents keep their content ids, pinned), DocFault::BadCost
validation (finite/bounds per component), std::cost construct-
vocabulary block + SlotKind::Costs + open-slot mirror, prose arm, and
all CampaignDoc struct literals across crates carry cost: vec![]
(compile-driven).

Verified: aura-research suite green, campaign validate e2e green, full
workspace suite green, clippy -D warnings clean; in-loop spec review
compliant and quality review approved (real diff fingerprint — the
worktree-anchored review prompt works).

refs #234
This commit is contained in:
2026-07-11 07:24:15 +02:00
parent 249aafdb0f
commit bdcf0452f4
6 changed files with 238 additions and 20 deletions
+2
View File
@@ -478,6 +478,7 @@ mod tests {
bindings: BTreeMap::new(),
},
risk: vec![],
cost: vec![],
strategies: vec![StrategyEntry {
r#ref: DocRef::ContentId("0".repeat(64)),
axes: BTreeMap::from([(
@@ -905,6 +906,7 @@ mod wf_tests {
bindings: BTreeMap::new(),
},
risk: vec![],
cost: vec![],
strategies: vec![StrategyEntry {
r#ref: DocRef::ContentId("c".repeat(64)),
axes: BTreeMap::from([(
+1
View File
@@ -152,6 +152,7 @@ fn campaign(instruments: &[&str]) -> CampaignDoc {
bindings: BTreeMap::new(),
},
risk: vec![],
cost: vec![],
strategies: vec![StrategyEntry {
r#ref: DocRef::ContentId(STRATEGY_ID.to_string()),
axes: axes_2x2(),
+3
View File
@@ -144,6 +144,9 @@ pub(crate) fn doc_fault_prose(f: &DocFault) -> String {
DocFault::BadRegime { index } => {
format!("risk[{index}]: stop length must be >= 1 and k must be > 0")
}
DocFault::BadCost { index } => {
format!("cost[{index}]: the component's price-unit knob must be finite and >= 0")
}
DocFault::NoStrategy => "strategies is empty".into(),
DocFault::EmptyAxes { strategy } => format!("strategies[{strategy}]: axes is empty"),
DocFault::EmptyAxis { strategy, axis } => {
+4
View File
@@ -162,6 +162,7 @@ pub(crate) fn translate_sweep(inv: &SugarInvocation) -> Result<GeneratedSweep, S
// established parity). The other verbs bind the single Vol regime
// through this same seam.
risk: risk_from(inv.stop),
cost: vec![],
// No stage of a selection-free single-sweep pipeline consumes the
// seed; a fixed zero keeps generated bytes deterministic so identical
// invocations dedupe onto identical content ids.
@@ -241,6 +242,7 @@ pub(crate) fn translate_generalize(
// The stop is a single structural risk regime; the member runner maps
// it back to StopRule::Vol at run time.
risk: risk_from(inv.stop),
cost: vec![],
seed: 0,
presentation: Presentation {
persist_taps: vec![],
@@ -441,6 +443,7 @@ pub(crate) fn translate_walkforward(
}],
process: ProcessRef { r#ref: DocRef::ContentId(content_id_of(&process_to_json(&process))) },
risk: risk_from(inv.stop),
cost: vec![],
seed: 0,
presentation: Presentation {
persist_taps: persist_taps_from(inv.trace),
@@ -596,6 +599,7 @@ pub(crate) fn translate_mc(
}],
process: ProcessRef { r#ref: DocRef::ContentId(content_id_of(&process_to_json(&process))) },
risk: risk_from(inv.stop),
cost: vec![],
seed: mc.seed,
presentation: Presentation {
persist_taps: vec![],
+53 -8
View File
@@ -597,7 +597,7 @@ fn campaign_introspect_vocabulary_lists_sections() {
// #216: std::risk joins the roster the CLI's own --vocabulary listing
// enumerates — the regime axis is discoverable from the public surface,
// not only from the design ledger or a generated document.
for id in ["std::data", "std::strategy", "std::process_ref", "std::presentation", "std::risk"] {
for id in ["std::data", "std::strategy", "std::process_ref", "std::presentation", "std::risk", "std::cost"] {
assert!(out.contains(id), "vocabulary misses {id}: {out}");
}
}
@@ -625,6 +625,23 @@ fn campaign_introspect_block_risk_describes_the_regime_shape() {
);
}
/// #234: `--block std::cost` names the component shape and its
/// absent-means-zero-costs semantics — the cost model is discoverable from
/// the public introspection surface (the `std::risk` #216 precedent).
#[test]
fn campaign_introspect_block_cost_describes_the_component_shape() {
let (out, code) = run_code(&["campaign", "introspect", "--block", "std::cost"]);
assert_eq!(code, Some(0), "stdout/stderr: {out}");
assert!(
out.contains("optional, list of cost components { constant: { cost_per_trade } } | { vol_slippage: { slip_vol_mult } } | { carry: { carry_per_cycle } }; absent = zero costs (net == gross)"),
"the cost slot must render as optional with the component shape: {out}"
);
assert!(
out.contains("std::cost — campaign section: the cost model"),
"stdout/stderr: {out}"
);
}
/// #216: a complete-but-risk-less campaign document (the exact shape a
/// verb-generated document has before a `--stop-*` flag is added) has its one
/// remaining open slot — the optional risk axis — enumerated by the public
@@ -660,22 +677,23 @@ fn campaign_introspect_unwired_lists_the_optional_risk_slot_on_bare_envelope() {
);
}
/// #216: a bound (non-empty) risk list closes the slot — a complete document
/// with risk reports no open slots at all.
/// #216/#234: bound (non-empty) risk AND cost lists close both optional
/// slots — a complete document with both reports no open slots at all.
#[test]
fn campaign_introspect_unwired_omits_a_bound_risk_slot() {
fn campaign_introspect_unwired_omits_bound_risk_and_cost_slots() {
let dir = temp_cwd("campaign-introspect-unwired-risk-bound");
let with_risk = CAMPAIGN_DOC.replacen(
let with_both = CAMPAIGN_DOC.replacen(
"\"seed\": 1,",
"\"seed\": 1,\n \"risk\": [ { \"vol\": { \"length\": 3, \"k\": 2.0 } } ],",
"\"seed\": 1,\n \"risk\": [ { \"vol\": { \"length\": 3, \"k\": 2.0 } } ],\n \"cost\": [ { \"constant\": { \"cost_per_trade\": 2.0 } } ],",
1,
);
assert_ne!(with_risk, CAMPAIGN_DOC, "replacen must actually match the fixture's seed field");
write_doc(&dir, "bound.campaign.json", &with_risk);
assert_ne!(with_both, CAMPAIGN_DOC, "replacen must actually match the fixture's seed field");
write_doc(&dir, "bound.campaign.json", &with_both);
let (out, code) =
run_code_in(&dir, &["campaign", "introspect", "--unwired", "bound.campaign.json"]);
assert_eq!(code, Some(0));
assert!(!out.contains("open slot: risk"), "bound risk must not list: {out}");
assert!(!out.contains("open slot: cost"), "bound cost must not list: {out}");
assert!(out.contains("no open slots"), "stdout/stderr: {out}");
}
@@ -965,6 +983,33 @@ fn campaign_register_refuses_invalid_risk_section_and_writes_nothing() {
);
}
/// #234: the register validate-gate extends to the cost model — a document
/// whose only fault is a negative cost knob is refused with prose (exit 1)
/// and no file is ever written under `runs/campaigns/` (the risk-section
/// precedent).
#[test]
fn campaign_register_refuses_invalid_cost_section_and_writes_nothing() {
let dir = temp_cwd("campaign-register-invalid-cost");
let bad = CAMPAIGN_DOC.replacen(
"\"seed\": 1,",
"\"seed\": 1,\n \"cost\": [ { \"constant\": { \"cost_per_trade\": -2.0 } } ],",
1,
);
assert_ne!(bad, CAMPAIGN_DOC, "replacen must actually match the fixture's seed field");
write_doc(&dir, "bad-cost.campaign.json", &bad);
let (out, code) = run_code_in(&dir, &["campaign", "register", "bad-cost.campaign.json"]);
assert_eq!(code, Some(1), "stdout/stderr: {out}");
assert!(out.contains("refusing to register:"), "stdout/stderr: {out}");
assert!(
out.contains("cost[0]: the component's price-unit knob must be finite and >= 0"),
"stdout/stderr: {out}"
);
assert!(
!dir.join("runs").join("campaigns").exists(),
"register must not create a store entry for an invalid cost section"
);
}
/// Seed one open-param blueprint into the built demo project's store via a
/// real sweep and return its content id (the referential test's recipe).
fn seed_blueprint(dir: &Path, name: &str) -> String {
+173 -10
View File
@@ -159,6 +159,7 @@ pub enum SlotKind {
/// The closed tap vocabulary (`tap_vocabulary()` entries).
TapKinds,
Regimes,
Costs,
}
/// One typed slot of a block.
@@ -436,6 +437,12 @@ pub struct CampaignDoc {
/// parity for every risk-less document (the `description` precedent).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub risk: Vec<RiskRegime>,
/// The campaign's cost model (#234): additive components (C10) every
/// member runs under. Absent or empty = zero costs (`summarize_r`'s
/// empty-slice baseline: net == gross). Absent-serializes → content-id
/// parity for every cost-less document (the `risk` precedent above).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cost: Vec<CostSpec>,
pub strategies: Vec<StrategyEntry>,
pub process: ProcessRef,
pub seed: u64,
@@ -496,6 +503,20 @@ pub enum RiskRegime {
Vol { length: i64, k: f64 },
}
/// One component of the campaign's cost model (#234): a closed, additive
/// vocabulary over the shipped C10 cost nodes — components sum (`CostSum`).
/// Serde field names are the builders' own `ParamSpec` names
/// (`constant_cost.rs` / `vol_slippage_cost.rs` / `carry_cost.rs`), so the doc
/// vocabulary conforms to the node vocabulary. Externally tagged so a future
/// component is additive — no content-id churn on stored components.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub enum CostSpec {
Constant { cost_per_trade: f64 },
VolSlippage { slip_vol_mult: f64 },
Carry { carry_per_cycle: f64 },
}
/// Bare wire value of one Scalar (strips the tagged form via Scalar's own
/// serde as the oracle — no Scalar internals assumed).
fn scalar_to_bare(s: &Scalar) -> serde_json::Value {
@@ -702,6 +723,7 @@ pub enum DocFault {
NoWindow,
BadWindow { index: usize },
BadRegime { index: usize },
BadCost { index: usize },
NoStrategy,
EmptyAxes { strategy: usize },
EmptyAxis { strategy: usize, axis: String },
@@ -812,6 +834,20 @@ pub fn validate_campaign(doc: &CampaignDoc) -> Vec<DocFault> {
faults.push(DocFault::BadRegime { index: i });
}
}
for (i, c) in doc.cost.iter().enumerate() {
// Every shipped cost node asserts a finite, non-negative price-unit
// knob (`>= 0`); the doc tier refuses the same bounds earlier,
// path-addressed (the BadRegime precedent). NaN/inf are unreachable
// via JSON but refused defensively.
let v = match c {
CostSpec::Constant { cost_per_trade } => *cost_per_trade,
CostSpec::VolSlippage { slip_vol_mult } => *slip_vol_mult,
CostSpec::Carry { carry_per_cycle } => *carry_per_cycle,
};
if !v.is_finite() || v < 0.0 {
faults.push(DocFault::BadCost { index: i });
}
}
if doc.strategies.is_empty() {
faults.push(DocFault::NoStrategy);
}
@@ -863,6 +899,11 @@ pub const CAMPAIGN_SECTIONS: &[BlockSchema] = &[
doc: "campaign section: the structural risk axis — stop regimes the matrix runs each cell under (absent = one default regime)",
slots: &[SlotInfo { name: "risk", kind: SlotKind::Regimes, required: false }],
},
BlockSchema {
id: "std::cost",
doc: "campaign section: the cost model — additive components (C10) every member runs under (absent = zero costs; net == gross)",
slots: &[SlotInfo { name: "cost", kind: SlotKind::Costs, required: false }],
},
BlockSchema {
id: "std::strategy",
doc: "campaign section: strategy ref (content or identity id) + param axes",
@@ -906,6 +947,9 @@ pub fn slot_kind_label(kind: SlotKind) -> &'static str {
SlotKind::Regimes => {
"list of stop regimes { vol: { length, k } }; absent = one default regime"
}
SlotKind::Costs => {
"list of cost components { constant: { cost_per_trade } } | { vol_slippage: { slip_vol_mult } } | { carry: { carry_per_cycle } }; absent = zero costs (net == gross)"
}
}
}
@@ -1025,6 +1069,15 @@ pub fn open_slots_campaign(text: &str) -> Result<Vec<OpenSlot>, DocError> {
"optional, list of stop regimes { vol: { length, k } }; absent = one default regime",
));
}
// The optional cost slot, mirroring the risk axis (#216's discoverability
// lesson applied to #234): absent, empty, or non-array = open.
let cost_bound = v.get("cost").and_then(|c| c.as_array()).is_some_and(|a| !a.is_empty());
if !cost_bound {
slots.push(open(
"cost",
"optional, list of cost components { constant: { cost_per_trade } } | { vol_slippage: { slip_vol_mult } } | { carry: { carry_per_cycle } }; absent = zero costs (net == gross)",
));
}
match v.get("strategies").and_then(|s| s.as_array()) {
None => slots.push(open("strategies", "required, non-empty list of { ref, axes }")),
Some(list) if list.is_empty() => {
@@ -1392,6 +1445,98 @@ mod tests {
assert!(validate_campaign(&doc).is_empty(), "{:?}", validate_campaign(&doc));
}
#[test]
fn cost_spec_round_trips_as_externally_tagged_components() {
// Serde field names ARE the shipped builders' ParamSpec names
// (cost_per_trade / slip_vol_mult / carry_per_cycle) — the doc
// vocabulary conforms to the node vocabulary, not the other way round.
for (spec, wire) in [
(
CostSpec::Constant { cost_per_trade: 2.0 },
r#"{"constant":{"cost_per_trade":2.0}}"#,
),
(
CostSpec::VolSlippage { slip_vol_mult: 0.5 },
r#"{"vol_slippage":{"slip_vol_mult":0.5}}"#,
),
(
CostSpec::Carry { carry_per_cycle: 0.1 },
r#"{"carry":{"carry_per_cycle":0.1}}"#,
),
] {
assert_eq!(serde_json::to_string(&spec).unwrap(), wire);
let back: CostSpec = serde_json::from_str(wire).unwrap();
assert_eq!(back, spec);
}
// deny_unknown_fields: a misspelled knob refuses to parse
assert!(serde_json::from_str::<CostSpec>(r#"{"constant":{"per_close_r":2.0}}"#).is_err());
}
#[test]
fn campaign_absent_and_empty_cost_omit_from_canonical_bytes() {
// The risk precedent's sibling: cost-less documents keep their content ids.
let doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
assert!(doc.cost.is_empty(), "an absent cost key deserializes to empty");
let absent = campaign_to_json(&doc);
assert!(!absent.contains("\"cost\""), "absent cost omits from serialization: {absent}");
let mut empty = doc.clone();
empty.cost = vec![];
assert_eq!(
content_id_of(&campaign_to_json(&empty)),
content_id_of(&absent),
"an explicit empty cost hashes identically to an absent one"
);
}
#[test]
fn campaign_with_cost_serializes_and_round_trips_the_components() {
let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
doc.cost = vec![
CostSpec::Constant { cost_per_trade: 2.0 },
CostSpec::VolSlippage { slip_vol_mult: 0.5 },
];
let out = campaign_to_json(&doc);
assert!(
out.contains(r#""cost":[{"constant":{"cost_per_trade":2.0}},{"vol_slippage":{"slip_vol_mult":0.5}}]"#),
"cost serializes in place as a tagged list: {out}"
);
let back: CampaignDoc = serde_json::from_str(&out).unwrap();
assert_eq!(back.cost, doc.cost);
}
#[test]
fn validate_campaign_flags_each_bad_cost_component() {
let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
doc.cost = vec![
CostSpec::Constant { cost_per_trade: 0.0 }, // 0: valid (zero cost is a legal stress point)
CostSpec::VolSlippage { slip_vol_mult: -0.5 }, // 1: negative
CostSpec::Carry { carry_per_cycle: f64::NAN }, // 2: non-finite (unreachable via JSON; defensive)
];
let faults = validate_campaign(&doc);
assert!(faults.contains(&DocFault::BadCost { index: 1 }), "{faults:?}");
assert!(faults.contains(&DocFault::BadCost { index: 2 }), "{faults:?}");
assert!(!faults.contains(&DocFault::BadCost { index: 0 }), "valid component not flagged: {faults:?}");
}
#[test]
fn validate_campaign_accepts_a_valid_cost_section() {
let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
doc.cost = vec![CostSpec::Carry { carry_per_cycle: 0.25 }];
assert!(validate_campaign(&doc).is_empty(), "{:?}", validate_campaign(&doc));
}
#[test]
fn cost_block_is_describable_beside_risk() {
let block = describe_block("std::cost").expect("std::cost describable");
assert_eq!(block.slots.len(), 1);
assert_eq!(block.slots[0].name, "cost");
assert!(!block.slots[0].required);
assert_eq!(
slot_kind_label(block.slots[0].kind),
"list of cost components { constant: { cost_per_trade } } | { vol_slippage: { slip_vol_mult } } | { carry: { carry_per_cycle } }; absent = zero costs (net == gross)"
);
}
/// The intrinsic tier does NOT constrain where a population-transforming
/// stage sits relative to the annotators (terminal_seen gates only Gate
/// stages): `[sweep, monte_carlo, walk_forward]` is intrinsically valid.
@@ -1782,14 +1927,20 @@ mod tests {
#[test]
fn open_slots_report_partial_documents_by_path() {
assert_eq!(open_slots_process(PROCESS_FIXTURE).unwrap(), Vec::new());
// #216: a complete risk-less campaign has exactly ONE open slot — the
// optional risk axis (the probe's one optional line).
// #216/#234: a complete but risk- and cost-less campaign has exactly
// TWO open slots — the two optional axes, in section order.
assert_eq!(
open_slots_campaign(CAMPAIGN_FIXTURE).unwrap(),
vec![open(
vec![
open(
"risk",
"optional, list of stop regimes { vol: { length, k } }; absent = one default regime",
)]
),
open(
"cost",
"optional, list of cost components { constant: { cost_per_trade } } | { vol_slippage: { slip_vol_mult } } | { carry: { carry_per_cycle } }; absent = zero costs (net == gross)",
),
]
);
let partial = r#"{ "format_version": 1, "kind": "process",
@@ -1848,19 +1999,31 @@ mod tests {
);
}
/// #216: the optional risk slot is listed only while unbound — a document
/// with a non-empty risk list closes it (a complete bound document has no
/// open slots at all; the risk-less complement is pinned in
/// `open_slots_report_partial_documents_by_path`).
/// #216/#234: each optional slot is listed only while unbound — binding
/// risk leaves exactly the cost slot open; binding both closes the probe
/// (a complete bound document has no open slots at all).
#[test]
fn open_slots_omit_a_bound_risk_slot() {
fn open_slots_omit_bound_risk_and_cost_slots() {
let with_risk = CAMPAIGN_FIXTURE.replacen(
"\"seed\": 1,",
"\"seed\": 1,\n \"risk\": [ { \"vol\": { \"length\": 3, \"k\": 2.0 } } ],",
1,
);
assert_ne!(with_risk, CAMPAIGN_FIXTURE, "replacen must match the fixture's seed field");
assert_eq!(open_slots_campaign(&with_risk).unwrap(), Vec::new());
assert_eq!(
open_slots_campaign(&with_risk).unwrap(),
vec![open(
"cost",
"optional, list of cost components { constant: { cost_per_trade } } | { vol_slippage: { slip_vol_mult } } | { carry: { carry_per_cycle } }; absent = zero costs (net == gross)",
)]
);
let with_both = CAMPAIGN_FIXTURE.replacen(
"\"seed\": 1,",
"\"seed\": 1,\n \"risk\": [ { \"vol\": { \"length\": 3, \"k\": 2.0 } } ],\n \"cost\": [ { \"constant\": { \"cost_per_trade\": 2.0 } } ],",
1,
);
assert_ne!(with_both, CAMPAIGN_FIXTURE, "replacen must match the fixture's seed field");
assert_eq!(open_slots_campaign(&with_both).unwrap(), Vec::new());
}
#[test]