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        }
232    }
233
234    #[test]
235    fn serialization_round_trip() {
236        let receipt = SavingsReceiptV1 {
237            schema_version: 1,
238            savings_id: "savings-1".to_owned(),
239            task_id: id("task-1"),
240            baseline_receipt_id: id("receipt-baseline"),
241            treatment_receipt_id: id("receipt-treatment"),
242            baseline_cost_micros: 10_000,
243            treatment_cost_micros: 5_000,
244            avoided_cost_micros: 5_000,
245            baseline_tokens: balance(1_000),
246            treatment_tokens: balance(600),
247            token_savings_ratio_milli: 400,
248            quality_preserved: true,
249            quality_baseline_score_milli: 900,
250            quality_treatment_score_milli: 900,
251            measurement_method: MeasurementMethod::ProviderReported,
252            context_strategy: "semantic".to_owned(),
253            methodology_version: "method-v1".to_owned(),
254            evidence_refs: Vec::new(),
255            decision_refs: vec!["decision-1".to_owned()],
256            signature: "signature-1".to_owned(),
257        };
258
259        let json = serde_json::to_string(&receipt).expect("receipt should serialize");
260        let decoded: SavingsReceiptV1 =
261            serde_json::from_str(&json).expect("receipt should deserialize");
262        assert_eq!(receipt, decoded);
263    }
264
265    #[test]
266    fn validation_rejects_avoided_cost_above_baseline() {
267        let mut receipt = SavingsReceiptV1::compute_from_arms(
268            &execution("receipt-baseline", 1_000, 1_000),
269            &execution("receipt-treatment", 500, 500),
270        );
271        receipt.avoided_cost_micros = receipt.baseline_cost_micros + 1;
272
273        assert!(receipt.validate().is_err());
274    }
275
276    #[test]
277    fn compute_from_arms_uses_cost_and_token_deltas() {
278        let baseline = execution("receipt-baseline", 10_000, 1_000);
279        let treatment = execution("receipt-treatment", 6_000, 600);
280
281        let receipt = SavingsReceiptV1::compute_from_arms(&baseline, &treatment);
282
283        assert_eq!(receipt.task_id, baseline.task_id);
284        assert_eq!(receipt.baseline_receipt_id, baseline.receipt_id);
285        assert_eq!(receipt.treatment_receipt_id, treatment.receipt_id);
286        assert_eq!(receipt.baseline_cost_micros, 10_000);
287        assert_eq!(receipt.treatment_cost_micros, 6_000);
288        assert_eq!(receipt.avoided_cost_micros, 4_000);
289        assert_eq!(receipt.token_savings_ratio_milli, 400);
290        assert_eq!(receipt.baseline_tokens, baseline.context_balance);
291        assert_eq!(receipt.treatment_tokens, treatment.context_balance);
292        assert!(receipt.validate().is_ok());
293    }
294}