1use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11pub const MECHANISM_COMPRESSION: &str = "compression";
14pub const MECHANISM_ROUTING: &str = "routing";
17pub const MECHANISM_CACHING: &str = "caching";
20
21fn default_mechanism() -> String {
22 MECHANISM_COMPRESSION.to_string()
23}
24
25fn default_version() -> String {
30 String::new()
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
37#[serde(rename_all = "snake_case")]
38pub enum MeasurementMethod {
39 DirectCount,
41 Holdout,
43 BaselineEstimate,
45 ProviderReconciled,
47 Unknown,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
53#[serde(rename_all = "snake_case")]
54pub enum EvidenceClass {
55 Measured,
57 Approximated,
59 Statistical,
61 Declared,
63 Unclassified,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
69#[serde(rename_all = "snake_case")]
70pub enum CustomerApproval {
71 Pending,
73 Approved,
75 Disputed,
77 Superseded,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
83#[serde(rename_all = "snake_case")]
84pub enum SettlementStatus {
85 Ineligible,
87 Eligible,
89 Settled,
91 Reversed,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
96pub struct SavingsEvent {
97 pub ts: String,
98 pub tool: String,
101 #[serde(default = "default_mechanism")]
105 pub mechanism: String,
106 pub model_id: String,
108 pub tokenizer: String,
112 pub baseline_tokens: u64,
114 pub actual_tokens: u64,
116 pub saved_tokens: u64,
118 pub bounce_adjustment: u64,
121 pub unit_price_per_m_usd: f64,
123 pub saved_usd: f64,
126 pub repo_hash: String,
129 pub agent_id: String,
130 pub prev_hash: String,
131 pub entry_hash: String,
132 #[serde(default = "default_version")]
138 pub version: String,
139
140 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub intent_tag: Option<String>,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub outcome: Option<String>,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub model_original: Option<String>,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub model_routed: Option<String>,
153 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub routing_savings: Option<u64>,
156 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub response_original_tokens: Option<u64>,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub response_delivered_tokens: Option<u64>,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub agent_chain_id: Option<String>,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub chain_depth: Option<u8>,
168
169 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub measurement_method: Option<MeasurementMethod>,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub evidence_class: Option<EvidenceClass>,
176 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub confidence: Option<f64>,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub quality_signal: Option<String>,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub attribution_group: Option<String>,
185 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub attribution_id: Option<String>,
188 #[serde(default, skip_serializing_if = "Option::is_none")]
190 pub baseline_ref: Option<String>,
191 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub price_version: Option<String>,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub customer_approval: Option<CustomerApproval>,
197 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub settlement_status: Option<SettlementStatus>,
200
201 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub is_first_inject: Option<bool>,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub cache_read_per_m_usd: Option<f64>,
209 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub cache_write_per_m_usd: Option<f64>,
212}
213
214impl SavingsEvent {
215 pub fn canonical_content(&self) -> String {
220 format!(
221 "v5|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
222 self.ts,
223 self.tool,
224 self.mechanism,
225 self.model_id,
226 self.tokenizer,
227 self.baseline_tokens,
228 self.actual_tokens,
229 self.saved_tokens,
230 self.bounce_adjustment,
231 micro_usd(self.unit_price_per_m_usd),
232 micro_usd(self.saved_usd),
233 self.repo_hash,
234 self.agent_id,
235 self.version,
236 option_str(self.attribution_id.as_ref()),
237 option_str(self.intent_tag.as_ref()),
238 option_str(self.model_routed.as_ref()),
239 self.measurement_method.as_ref().map_or("_", |m| match m {
240 MeasurementMethod::DirectCount => "direct_count",
241 MeasurementMethod::Holdout => "holdout",
242 MeasurementMethod::BaselineEstimate => "baseline_estimate",
243 MeasurementMethod::ProviderReconciled => "provider_reconciled",
244 MeasurementMethod::Unknown => "unknown",
245 }),
246 self.evidence_class.as_ref().map_or("_", |e| match e {
247 EvidenceClass::Measured => "measured",
248 EvidenceClass::Approximated => "approximated",
249 EvidenceClass::Statistical => "statistical",
250 EvidenceClass::Declared => "declared",
251 EvidenceClass::Unclassified => "unclassified",
252 }),
253 )
254 }
255
256 pub fn canonical_content_v4(&self) -> String {
258 format!(
259 "v4|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
260 self.ts,
261 self.tool,
262 self.mechanism,
263 self.model_id,
264 self.tokenizer,
265 self.baseline_tokens,
266 self.actual_tokens,
267 self.saved_tokens,
268 self.bounce_adjustment,
269 micro_usd(self.unit_price_per_m_usd),
270 micro_usd(self.saved_usd),
271 self.repo_hash,
272 self.agent_id,
273 self.version,
274 )
275 }
276
277 pub fn canonical_content_v3(&self) -> String {
281 format!(
282 "v3|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
283 self.ts,
284 self.tool,
285 self.mechanism,
286 self.model_id,
287 self.tokenizer,
288 self.baseline_tokens,
289 self.actual_tokens,
290 self.saved_tokens,
291 self.bounce_adjustment,
292 micro_usd(self.unit_price_per_m_usd),
293 micro_usd(self.saved_usd),
294 self.repo_hash,
295 self.agent_id,
296 )
297 }
298
299 pub fn canonical_content_v2(&self) -> String {
302 format!(
303 "v2|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
304 self.ts,
305 self.tool,
306 self.model_id,
307 self.tokenizer,
308 self.baseline_tokens,
309 self.actual_tokens,
310 self.saved_tokens,
311 self.bounce_adjustment,
312 micro_usd(self.unit_price_per_m_usd),
313 micro_usd(self.saved_usd),
314 self.repo_hash,
315 self.agent_id,
316 )
317 }
318
319 pub fn canonical_content_legacy(&self) -> String {
323 format!(
324 "{}|{}|{}|{}|{}|{}|{}|{}|{:.6}|{:.6}|{}|{}",
325 self.ts,
326 self.tool,
327 self.model_id,
328 self.tokenizer,
329 self.baseline_tokens,
330 self.actual_tokens,
331 self.saved_tokens,
332 self.bounce_adjustment,
333 self.unit_price_per_m_usd,
334 self.saved_usd,
335 self.repo_hash,
336 self.agent_id,
337 )
338 }
339
340 pub fn hash_matches(&self, prev_hash: &str) -> bool {
345 self.entry_hash == compute_hash(prev_hash, &self.canonical_content())
346 || self.entry_hash == compute_hash(prev_hash, &self.canonical_content_v4())
347 || self.entry_hash == compute_hash(prev_hash, &self.canonical_content_v3())
348 || self.entry_hash == compute_hash(prev_hash, &self.canonical_content_v2())
349 || self.entry_hash == compute_hash(prev_hash, &self.canonical_content_legacy())
350 }
351}
352
353fn micro_usd(usd: f64) -> i64 {
364 const TIE_EPSILON_MICRO: f64 = 1e-6;
365 let scaled = usd * 1_000_000.0;
366 (scaled + TIE_EPSILON_MICRO.copysign(scaled)).round() as i64
367}
368
369fn option_str(opt: Option<&String>) -> &str {
371 opt.map_or("_", String::as_str)
372}
373
374pub fn compute_hash(prev_hash: &str, content: &str) -> String {
376 let mut hasher = Sha256::new();
377 hasher.update(prev_hash.as_bytes());
378 hasher.update(content.as_bytes());
379 crate::core::agent_identity::hex_encode(&hasher.finalize())
380}
381
382#[cfg(test)]
383mod tests {
384 use super::*;
385
386 fn ev() -> SavingsEvent {
387 SavingsEvent {
388 ts: "2026-06-01T00:00:00+00:00".into(),
389 tool: "ctx_read".into(),
390 mechanism: MECHANISM_COMPRESSION.into(),
391 model_id: "claude-3.5-sonnet".into(),
392 tokenizer: "o200k_base".into(),
393 baseline_tokens: 1000,
394 actual_tokens: 300,
395 saved_tokens: 700,
396 bounce_adjustment: 0,
397 unit_price_per_m_usd: 3.0,
398 saved_usd: 0.0021,
399 repo_hash: "abc123".into(),
400 agent_id: "local".into(),
401 prev_hash: String::new(),
402 entry_hash: String::new(),
403 version: "3.9.0".into(),
404 intent_tag: None,
405 outcome: None,
406 model_original: None,
407 model_routed: None,
408 routing_savings: None,
409 response_original_tokens: None,
410 response_delivered_tokens: None,
411 agent_chain_id: None,
412 chain_depth: None,
413 measurement_method: None,
414 evidence_class: None,
415 confidence: None,
416 quality_signal: None,
417 attribution_group: None,
418 attribution_id: None,
419 baseline_ref: None,
420 price_version: None,
421 customer_approval: None,
422 settlement_status: None,
423 is_first_inject: None,
424 cache_read_per_m_usd: None,
425 cache_write_per_m_usd: None,
426 }
427 }
428
429 #[test]
430 fn hash_is_deterministic() {
431 let e = ev();
432 let a = compute_hash("genesis", &e.canonical_content());
433 let b = compute_hash("genesis", &e.canonical_content());
434 assert_eq!(a, b);
435 assert_eq!(a.len(), 64, "sha-256 hex is 64 chars");
436 }
437
438 #[test]
439 fn hash_changes_when_content_changes() {
440 let mut e = ev();
441 let a = compute_hash("genesis", &e.canonical_content());
442 e.saved_tokens = 701;
443 let b = compute_hash("genesis", &e.canonical_content());
444 assert_ne!(a, b, "tampering with a content field must change the hash");
445 }
446
447 #[test]
448 fn hash_depends_on_prev() {
449 let e = ev();
450 let a = compute_hash("genesis", &e.canonical_content());
451 let b = compute_hash("other", &e.canonical_content());
452 assert_ne!(a, b, "chain link must depend on prev_hash");
453 }
454
455 #[test]
459 fn v2_hash_is_roundtrip_stable_on_decimal_tie() {
460 let mut e = ev();
461 e.saved_tokens = 9423;
462 e.unit_price_per_m_usd = 2.5;
463 e.saved_usd = 9423.0 * 2.5 / 1_000_000.0; e.prev_hash = "genesis".into();
465 e.entry_hash = compute_hash(&e.prev_hash, &e.canonical_content());
466
467 let json = serde_json::to_string(&e).unwrap();
468 let parsed: SavingsEvent = serde_json::from_str(&json).unwrap();
469
470 assert!(
471 parsed.hash_matches(&parsed.prev_hash),
472 "v2 chain must survive a JSON round-trip on a decimal-tie value"
473 );
474 }
475
476 #[test]
481 fn v2_hash_is_roundtrip_stable_on_production_order_tie() {
482 let mut e = ev();
483 e.saved_tokens = 7831;
484 e.unit_price_per_m_usd = 2.5;
485 e.saved_usd = e.saved_tokens as f64 / 1_000_000.0 * e.unit_price_per_m_usd;
487 e.prev_hash = "genesis".into();
488 e.entry_hash = compute_hash(&e.prev_hash, &e.canonical_content());
489
490 let json = serde_json::to_string(&e).unwrap();
491 let parsed: SavingsEvent = serde_json::from_str(&json).unwrap();
492 assert!(
493 parsed.hash_matches(&parsed.prev_hash),
494 "v2 chain must survive a JSON round-trip on a production-order half-micro tie"
495 );
496 }
497
498 #[test]
499 fn micro_usd_resolves_half_micro_ties_consistently() {
500 let tie = 19_577.5_f64 / 1_000_000.0;
503 let below = f64::from_bits(tie.to_bits() - 1);
504 assert_eq!(micro_usd(tie), micro_usd(below));
505 }
506
507 #[test]
508 fn legacy_v1_hash_still_verifies() {
509 let mut e = ev();
512 e.prev_hash = "genesis".into();
513 e.entry_hash = compute_hash(&e.prev_hash, &e.canonical_content_legacy());
514 assert!(e.hash_matches(&e.prev_hash), "legacy v1 hash must verify");
515 }
516
517 #[test]
518 fn v2_hash_still_verifies_and_v3_commits_mechanism() {
519 let mut e = ev();
522 e.prev_hash = "genesis".into();
523 e.entry_hash = compute_hash(&e.prev_hash, &e.canonical_content_v2());
524 assert!(e.hash_matches(&e.prev_hash), "v2 hash must verify");
525
526 let json = serde_json::to_string(&e).unwrap();
527 let stripped = json.replace(r#""mechanism":"compression","#, "");
528 let parsed: SavingsEvent = serde_json::from_str(&stripped).unwrap();
529 assert_eq!(parsed.mechanism, MECHANISM_COMPRESSION, "serde default");
530 assert!(parsed.hash_matches(&parsed.prev_hash), "v2 after roundtrip");
531
532 let mut v3 = ev();
534 v3.mechanism = MECHANISM_ROUTING.into();
535 v3.prev_hash = "genesis".into();
536 v3.entry_hash = compute_hash(&v3.prev_hash, &v3.canonical_content());
537 assert!(v3.hash_matches(&v3.prev_hash));
538 let mut forged = v3.clone();
539 forged.mechanism = MECHANISM_COMPRESSION.into();
540 assert!(
541 !forged.hash_matches(&forged.prev_hash),
542 "reattributing a routing saving to compression must be tamper-evident"
543 );
544 }
545
546 #[test]
547 fn v3_hash_still_verifies_and_v4_commits_version() {
548 let mut e = ev();
551 e.prev_hash = "genesis".into();
552 e.entry_hash = compute_hash(&e.prev_hash, &e.canonical_content_v3());
553 assert!(e.hash_matches(&e.prev_hash), "v3 hash must verify");
554
555 let json = serde_json::to_string(&e).unwrap();
556 let stripped = json.replace(r#","version":"3.9.0""#, "");
559 let parsed: SavingsEvent = serde_json::from_str(&stripped).unwrap();
560 assert_eq!(parsed.version, "", "serde default for a pre-v4 entry");
561 assert!(parsed.hash_matches(&parsed.prev_hash), "v3 after roundtrip");
562
563 let mut v4 = ev();
565 v4.version = "3.8.18".into();
566 v4.prev_hash = "genesis".into();
567 v4.entry_hash = compute_hash(&v4.prev_hash, &v4.canonical_content());
568 assert!(v4.hash_matches(&v4.prev_hash));
569 let mut forged = v4.clone();
570 forged.version = "3.9.0".into();
571 assert!(
572 !forged.hash_matches(&forged.prev_hash),
573 "rewriting which version recorded a saving must be tamper-evident"
574 );
575 }
576
577 #[test]
578 fn micro_usd_quantizes_to_millionths() {
579 assert_eq!(micro_usd(2.5), 2_500_000);
580 assert_eq!(micro_usd(0.0), 0);
581 assert_eq!(micro_usd(0.000_001), 1);
582 let tie = 9423.0 * 2.5 / 1_000_000.0;
585 assert_eq!(micro_usd(tie), micro_usd(tie));
586 }
587 #[test]
588 fn v4_hash_still_verifies_after_v5_upgrade() {
589 let mut e = ev();
590 e.prev_hash = "genesis".into();
591 e.entry_hash = compute_hash(&e.prev_hash, &e.canonical_content_v4());
592 assert!(
593 e.hash_matches(&e.prev_hash),
594 "v4 hash must verify via hash_matches"
595 );
596 }
597
598 #[test]
599 fn v5_commits_p5_fields() {
600 let mut e = ev();
601 e.attribution_id = Some("attr_001".into());
602 e.measurement_method = Some(MeasurementMethod::DirectCount);
603 e.evidence_class = Some(EvidenceClass::Measured);
604 e.prev_hash = "genesis".into();
605 e.entry_hash = compute_hash(&e.prev_hash, &e.canonical_content());
606 assert!(e.hash_matches(&e.prev_hash));
607
608 let mut forged = e.clone();
609 forged.attribution_id = Some("attr_002".into());
610 assert!(
611 !forged.hash_matches(&forged.prev_hash),
612 "rewriting attribution_id must be tamper-evident"
613 );
614 }
615
616 #[test]
617 fn p5_fields_default_to_none_on_deserialize() {
618 let e = ev();
619 let json = serde_json::to_string(&e).unwrap();
620 let parsed: SavingsEvent = serde_json::from_str(&json).unwrap();
621 assert_eq!(parsed.attribution_id, None);
622 assert_eq!(parsed.measurement_method, None);
623 assert_eq!(parsed.evidence_class, None);
624 assert_eq!(parsed.customer_approval, None);
625 assert_eq!(parsed.settlement_status, None);
626 }
627
628 #[test]
629 fn p5_enums_serialize_roundtrip() {
630 let mut e = ev();
631 e.measurement_method = Some(MeasurementMethod::Holdout);
632 e.evidence_class = Some(EvidenceClass::Statistical);
633 e.customer_approval = Some(CustomerApproval::Approved);
634 e.settlement_status = Some(SettlementStatus::Eligible);
635 e.confidence = Some(0.95);
636 e.attribution_id = Some("blake3_abc".into());
637
638 let json = serde_json::to_string(&e).unwrap();
639 let parsed: SavingsEvent = serde_json::from_str(&json).unwrap();
640 assert_eq!(parsed.measurement_method, Some(MeasurementMethod::Holdout));
641 assert_eq!(parsed.evidence_class, Some(EvidenceClass::Statistical));
642 assert_eq!(parsed.customer_approval, Some(CustomerApproval::Approved));
643 assert_eq!(parsed.settlement_status, Some(SettlementStatus::Eligible));
644 assert_eq!(parsed.confidence, Some(0.95));
645 assert_eq!(parsed.attribution_id, Some("blake3_abc".into()));
646 }
647
648 #[test]
649 fn option_str_maps_none_to_underscore() {
650 assert_eq!(option_str(None), "_");
651 assert_eq!(option_str(Some(&"val".to_string())), "val");
652 }
653}