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#[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}