Skip to main content

lean_ctx_protocol/
savings.rs

1use crate::common::{
2    ReceiptId, TaskId, ValidationError, deserialize_milliunit, deserialize_schema_version,
3    validate_milliunit, validate_schema_version,
4};
5use crate::execution::{ContextBalanceV1, ExecutionReceiptV1};
6use crate::{EvidenceRefV1, MoneyV1, UsageBreakdownV1};
7use serde::{Deserialize, Serialize};
8
9/// What OSS emits. NOT VerifiedSavings (that's proprietary).
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
11pub struct SavingsObservationV1 {
12    pub observation_id: String,
13    pub provider_id: String,
14    pub model_id: String,
15    pub original_usage: UsageBreakdownV1,
16    pub actual_usage: UsageBreakdownV1,
17    pub local_cost_estimate: MoneyV1,
18    pub evidence_refs: Vec<EvidenceRefV1>,
19    pub observed_at: String,
20    pub runtime_version: String,
21    pub sequence_number: u64,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum MeasurementMethod {
27    ProviderReported,
28    Estimated,
29    Unavailable,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(deny_unknown_fields)]
34pub struct SavingsReceiptV1 {
35    #[serde(deserialize_with = "deserialize_schema_version")]
36    pub schema_version: u32,
37    pub savings_id: String,
38    pub task_id: TaskId,
39    pub baseline_receipt_id: ReceiptId,
40    pub treatment_receipt_id: ReceiptId,
41    pub baseline_cost_micros: u64,
42    pub treatment_cost_micros: u64,
43    pub avoided_cost_micros: u64,
44    pub baseline_tokens: ContextBalanceV1,
45    pub treatment_tokens: ContextBalanceV1,
46    #[serde(deserialize_with = "deserialize_milliunit")]
47    pub token_savings_ratio_milli: u16,
48    pub quality_preserved: bool,
49    #[serde(deserialize_with = "deserialize_milliunit")]
50    pub quality_baseline_score_milli: u16,
51    #[serde(deserialize_with = "deserialize_milliunit")]
52    pub quality_treatment_score_milli: u16,
53    pub measurement_method: MeasurementMethod,
54    pub context_strategy: String,
55    pub methodology_version: String,
56    pub evidence_refs: Vec<EvidenceRefV1>,
57    pub decision_refs: Vec<String>,
58    pub signature: String,
59}
60
61impl SavingsReceiptV1 {
62    pub fn validate(&self) -> Result<(), ValidationError> {
63        validate_schema_version(self.schema_version)?;
64        self.baseline_tokens.validate()?;
65        self.treatment_tokens.validate()?;
66        validate_milliunit(self.token_savings_ratio_milli, "token_savings_ratio_milli")?;
67        validate_milliunit(
68            self.quality_baseline_score_milli,
69            "quality_baseline_score_milli",
70        )?;
71        validate_milliunit(
72            self.quality_treatment_score_milli,
73            "quality_treatment_score_milli",
74        )?;
75
76        if self.avoided_cost_micros > self.baseline_cost_micros {
77            return Err(ValidationError::new(
78                "avoided_cost_micros exceeds baseline_cost_micros",
79            ));
80        }
81
82        if self.quality_preserved
83            != (self.quality_treatment_score_milli >= self.quality_baseline_score_milli)
84        {
85            return Err(ValidationError::new(
86                "quality_preserved does not match quality scores",
87            ));
88        }
89
90        Ok(())
91    }
92
93    pub fn compute_from_arms(
94        baseline: &ExecutionReceiptV1,
95        treatment: &ExecutionReceiptV1,
96    ) -> Self {
97        let baseline_cost_micros = baseline.actual_cost_micros;
98        let treatment_cost_micros = treatment.actual_cost_micros;
99        let baseline_provider_tokens = baseline.context_balance.provider_billed_tokens;
100        let treatment_provider_tokens = treatment.context_balance.provider_billed_tokens;
101        let savings_id = format!(
102            "savings-{}-{}",
103            baseline.receipt_id.as_str(),
104            treatment.receipt_id.as_str()
105        );
106
107        let mut evidence_refs = baseline.evidence_refs.clone();
108        evidence_refs.extend(treatment.evidence_refs.iter().cloned());
109        let mut decision_refs = baseline.decision_refs.clone();
110        decision_refs.extend(treatment.decision_refs.iter().cloned());
111
112        Self {
113            schema_version: baseline.schema_version,
114            savings_id,
115            task_id: baseline.task_id.clone(),
116            baseline_receipt_id: baseline.receipt_id.clone(),
117            treatment_receipt_id: treatment.receipt_id.clone(),
118            baseline_cost_micros,
119            treatment_cost_micros,
120            avoided_cost_micros: baseline_cost_micros.saturating_sub(treatment_cost_micros),
121            baseline_tokens: baseline.context_balance.clone(),
122            treatment_tokens: treatment.context_balance.clone(),
123            token_savings_ratio_milli: savings_ratio_milli(
124                baseline_provider_tokens,
125                treatment_provider_tokens,
126            ),
127            quality_preserved: true,
128            quality_baseline_score_milli: 0,
129            quality_treatment_score_milli: 0,
130            measurement_method: MeasurementMethod::ProviderReported,
131            context_strategy: String::new(),
132            methodology_version: "savings-receipt-v1".to_owned(),
133            evidence_refs,
134            decision_refs,
135            signature: String::new(),
136        }
137    }
138}
139
140fn savings_ratio_milli(baseline_tokens: u64, treatment_tokens: u64) -> u16 {
141    if baseline_tokens == 0 {
142        return 0;
143    }
144
145    let avoided_tokens = baseline_tokens.saturating_sub(treatment_tokens) as u128;
146    ((avoided_tokens * 1000) / baseline_tokens as u128).min(1000) as u16
147}
148
149#[cfg(test)]
150mod tests {
151    use crate::{MoneyV1, SavingsObservationV1, UsageBreakdownV1};
152
153    #[test]
154    fn serialization_round_trip() {
155        let observation = SavingsObservationV1 {
156            observation_id: "obs-1".to_owned(),
157            provider_id: "provider".to_owned(),
158            model_id: "model".to_owned(),
159            original_usage: UsageBreakdownV1::default(),
160            actual_usage: UsageBreakdownV1::default(),
161            local_cost_estimate: MoneyV1 {
162                currency: "USD".to_owned(),
163                coefficient: 1,
164                scale: 4,
165            },
166            evidence_refs: Vec::new(),
167            observed_at: "2026-01-01T00:00:00Z".to_owned(),
168            runtime_version: "1.0.0".to_owned(),
169            sequence_number: 1,
170        };
171        let json = serde_json::to_string(&observation).expect("observation should serialize");
172        let decoded = serde_json::from_str(&json).expect("observation should deserialize");
173        assert_eq!(observation, decoded);
174    }
175}
176
177#[cfg(test)]
178mod savings_receipt_tests {
179    use super::*;
180    use crate::PlanId;
181
182    fn id<T>(value: &str) -> T
183    where
184        T: std::str::FromStr,
185        T::Err: std::fmt::Debug,
186    {
187        value.parse().expect("valid identifier")
188    }
189
190    fn balance(provider_billed_tokens: u64) -> ContextBalanceV1 {
191        ContextBalanceV1 {
192            original_tokens: provider_billed_tokens,
193            materialized_tokens: provider_billed_tokens,
194            delivered_tokens: provider_billed_tokens,
195            provider_billed_tokens,
196        }
197    }
198
199    fn execution(
200        receipt_id: &str,
201        actual_cost_micros: u64,
202        provider_billed_tokens: u64,
203    ) -> ExecutionReceiptV1 {
204        ExecutionReceiptV1 {
205            schema_version: 1,
206            receipt_id: id(receipt_id),
207            task_id: id("task-1"),
208            plan_id: PlanId::try_from("plan-1").expect("valid plan id"),
209            context_balance: balance(provider_billed_tokens),
210            fresh_input_tokens: provider_billed_tokens,
211            cached_input_tokens: 0,
212            output_tokens: 10,
213            reasoning_tokens: 0,
214            requested_model: "model-1".to_owned(),
215            selected_model: "model-1".to_owned(),
216            provider: "provider-1".to_owned(),
217            capability_id: None,
218            capability_version: None,
219            model_calls: 1,
220            retries: 0,
221            latency_ms: 10,
222            actual_cost_micros,
223            baseline_cost_micros: actual_cost_micros,
224            avoided_cost_micros: 0,
225            etpao_milli: 0,
226            outcome_ref: None,
227            knowledge_refs: Vec::new(),
228            decision_refs: vec!["decision-1".to_owned()],
229            evidence_refs: Vec::new(),
230            signature: "signature-1".to_owned(),
231            executor_agent_id: None,
232            attempt_id: None,
233            context_plan_id: None,
234            context_receipt_ref: None,
235            capability_bindings: Vec::new(),
236            observations: crate::ExecutionObservationsV1 {
237                model_calls: Some(1),
238                retries: Some(0),
239                latency_ms: Some(10),
240                actual_cost_micros: Some(actual_cost_micros),
241            },
242            extensions: Default::default(),
243        }
244    }
245
246    #[test]
247    fn serialization_round_trip() {
248        let receipt = SavingsReceiptV1 {
249            schema_version: 1,
250            savings_id: "savings-1".to_owned(),
251            task_id: id("task-1"),
252            baseline_receipt_id: id("receipt-baseline"),
253            treatment_receipt_id: id("receipt-treatment"),
254            baseline_cost_micros: 10_000,
255            treatment_cost_micros: 5_000,
256            avoided_cost_micros: 5_000,
257            baseline_tokens: balance(1_000),
258            treatment_tokens: balance(600),
259            token_savings_ratio_milli: 400,
260            quality_preserved: true,
261            quality_baseline_score_milli: 900,
262            quality_treatment_score_milli: 900,
263            measurement_method: MeasurementMethod::ProviderReported,
264            context_strategy: "semantic".to_owned(),
265            methodology_version: "method-v1".to_owned(),
266            evidence_refs: Vec::new(),
267            decision_refs: vec!["decision-1".to_owned()],
268            signature: "signature-1".to_owned(),
269        };
270
271        let json = serde_json::to_string(&receipt).expect("receipt should serialize");
272        let decoded: SavingsReceiptV1 =
273            serde_json::from_str(&json).expect("receipt should deserialize");
274        assert_eq!(receipt, decoded);
275    }
276
277    #[test]
278    fn validation_rejects_avoided_cost_above_baseline() {
279        let mut receipt = SavingsReceiptV1::compute_from_arms(
280            &execution("receipt-baseline", 1_000, 1_000),
281            &execution("receipt-treatment", 500, 500),
282        );
283        receipt.avoided_cost_micros = receipt.baseline_cost_micros + 1;
284
285        assert!(receipt.validate().is_err());
286    }
287
288    #[test]
289    fn compute_from_arms_uses_cost_and_token_deltas() {
290        let baseline = execution("receipt-baseline", 10_000, 1_000);
291        let treatment = execution("receipt-treatment", 6_000, 600);
292
293        let receipt = SavingsReceiptV1::compute_from_arms(&baseline, &treatment);
294
295        assert_eq!(receipt.task_id, baseline.task_id);
296        assert_eq!(receipt.baseline_receipt_id, baseline.receipt_id);
297        assert_eq!(receipt.treatment_receipt_id, treatment.receipt_id);
298        assert_eq!(receipt.baseline_cost_micros, 10_000);
299        assert_eq!(receipt.treatment_cost_micros, 6_000);
300        assert_eq!(receipt.avoided_cost_micros, 4_000);
301        assert_eq!(receipt.token_savings_ratio_milli, 400);
302        assert_eq!(receipt.baseline_tokens, baseline.context_balance);
303        assert_eq!(receipt.treatment_tokens, treatment.context_balance);
304        assert!(receipt.validate().is_ok());
305    }
306}