From bdcf0452f41b1777b831ba2cc13952c896b370dd Mon Sep 17 00:00:00 2001 From: Brummel Date: Sat, 11 Jul 2026 07:24:15 +0200 Subject: [PATCH] feat(research,cli): the CostSpec campaign vocabulary (#234 task 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CampaignDoc gains an additive cost: Vec 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 --- crates/aura-campaign/src/lib.rs | 2 + crates/aura-campaign/tests/execute.rs | 1 + crates/aura-cli/src/research_docs.rs | 3 + crates/aura-cli/src/verb_sugar.rs | 4 + crates/aura-cli/tests/research_docs.rs | 61 ++++++-- crates/aura-research/src/lib.rs | 187 +++++++++++++++++++++++-- 6 files changed, 238 insertions(+), 20 deletions(-) diff --git a/crates/aura-campaign/src/lib.rs b/crates/aura-campaign/src/lib.rs index 3c43a68..4bd8bf4 100644 --- a/crates/aura-campaign/src/lib.rs +++ b/crates/aura-campaign/src/lib.rs @@ -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([( diff --git a/crates/aura-campaign/tests/execute.rs b/crates/aura-campaign/tests/execute.rs index c28ae7c..eb7585d 100644 --- a/crates/aura-campaign/tests/execute.rs +++ b/crates/aura-campaign/tests/execute.rs @@ -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(), diff --git a/crates/aura-cli/src/research_docs.rs b/crates/aura-cli/src/research_docs.rs index 07a53a0..ec8bd19 100644 --- a/crates/aura-cli/src/research_docs.rs +++ b/crates/aura-cli/src/research_docs.rs @@ -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 } => { diff --git a/crates/aura-cli/src/verb_sugar.rs b/crates/aura-cli/src/verb_sugar.rs index a5a5ef5..188518f 100644 --- a/crates/aura-cli/src/verb_sugar.rs +++ b/crates/aura-cli/src/verb_sugar.rs @@ -162,6 +162,7 @@ pub(crate) fn translate_sweep(inv: &SugarInvocation) -> Result= 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 { diff --git a/crates/aura-research/src/lib.rs b/crates/aura-research/src/lib.rs index 57eaa4c..21f5ad9 100644 --- a/crates/aura-research/src/lib.rs +++ b/crates/aura-research/src/lib.rs @@ -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, + /// 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, pub strategies: Vec, 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 { 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, 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::(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( - "risk", - "optional, list of stop regimes { vol: { length, k } }; absent = one default regime", - )] + 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]