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")]
182 pub request_id: Option<String>,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub session_id: Option<String>,
186 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub trace_id: Option<String>,
189
190 #[serde(default, skip_serializing_if = "Option::is_none")]
192 pub quality_signal: Option<String>,
193 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub attribution_group: Option<String>,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub attribution_id: Option<String>,
199 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub baseline_ref: Option<String>,
202 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub price_version: Option<String>,
205 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub customer_approval: Option<CustomerApproval>,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub settlement_status: Option<SettlementStatus>,
211
212 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub is_first_inject: Option<bool>,
217 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub cache_read_per_m_usd: Option<f64>,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub cache_write_per_m_usd: Option<f64>,
223}
224
225impl SavingsEvent {
226 pub fn canonical_content(&self) -> String {
231 format!(
232 "v5|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
233 self.ts,
234 self.tool,
235 self.mechanism,
236 self.model_id,
237 self.tokenizer,
238 self.baseline_tokens,
239 self.actual_tokens,
240 self.saved_tokens,
241 self.bounce_adjustment,
242 micro_usd(self.unit_price_per_m_usd),
243 micro_usd(self.saved_usd),
244 self.repo_hash,
245 self.agent_id,
246 self.version,
247 option_str(self.attribution_id.as_ref()),
248 option_str(self.intent_tag.as_ref()),
249 option_str(self.model_routed.as_ref()),
250 self.measurement_method.as_ref().map_or("_", |m| match m {
251 MeasurementMethod::DirectCount => "direct_count",
252 MeasurementMethod::Holdout => "holdout",
253 MeasurementMethod::BaselineEstimate => "baseline_estimate",
254 MeasurementMethod::ProviderReconciled => "provider_reconciled",
255 MeasurementMethod::Unknown => "unknown",
256 }),
257 self.evidence_class.as_ref().map_or("_", |e| match e {
258 EvidenceClass::Measured => "measured",
259 EvidenceClass::Approximated => "approximated",
260 EvidenceClass::Statistical => "statistical",
261 EvidenceClass::Declared => "declared",
262 EvidenceClass::Unclassified => "unclassified",
263 }),
264 )
265 }
266
267 pub fn canonical_content_v4(&self) -> String {
269 format!(
270 "v4|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
271 self.ts,
272 self.tool,
273 self.mechanism,
274 self.model_id,
275 self.tokenizer,
276 self.baseline_tokens,
277 self.actual_tokens,
278 self.saved_tokens,
279 self.bounce_adjustment,
280 micro_usd(self.unit_price_per_m_usd),
281 micro_usd(self.saved_usd),
282 self.repo_hash,
283 self.agent_id,
284 self.version,
285 )
286 }
287
288 pub fn canonical_content_v3(&self) -> String {
292 format!(
293 "v3|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
294 self.ts,
295 self.tool,
296 self.mechanism,
297 self.model_id,
298 self.tokenizer,
299 self.baseline_tokens,
300 self.actual_tokens,
301 self.saved_tokens,
302 self.bounce_adjustment,
303 micro_usd(self.unit_price_per_m_usd),
304 micro_usd(self.saved_usd),
305 self.repo_hash,
306 self.agent_id,
307 )
308 }
309
310 pub fn canonical_content_v2(&self) -> String {
313 format!(
314 "v2|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
315 self.ts,
316 self.tool,
317 self.model_id,
318 self.tokenizer,
319 self.baseline_tokens,
320 self.actual_tokens,
321 self.saved_tokens,
322 self.bounce_adjustment,
323 micro_usd(self.unit_price_per_m_usd),
324 micro_usd(self.saved_usd),
325 self.repo_hash,
326 self.agent_id,
327 )
328 }
329
330 pub fn canonical_content_legacy(&self) -> String {
334 format!(
335 "{}|{}|{}|{}|{}|{}|{}|{}|{:.6}|{:.6}|{}|{}",
336 self.ts,
337 self.tool,
338 self.model_id,
339 self.tokenizer,
340 self.baseline_tokens,
341 self.actual_tokens,
342 self.saved_tokens,
343 self.bounce_adjustment,
344 self.unit_price_per_m_usd,
345 self.saved_usd,
346 self.repo_hash,
347 self.agent_id,
348 )
349 }
350
351 pub fn hash_matches(&self, prev_hash: &str) -> bool {
356 self.entry_hash == compute_hash(prev_hash, &self.canonical_content())
357 || self.entry_hash == compute_hash(prev_hash, &self.canonical_content_v4())
358 || self.entry_hash == compute_hash(prev_hash, &self.canonical_content_v3())
359 || self.entry_hash == compute_hash(prev_hash, &self.canonical_content_v2())
360 || self.entry_hash == compute_hash(prev_hash, &self.canonical_content_legacy())
361 }
362}
363
364fn micro_usd(usd: f64) -> i64 {
375 const TIE_EPSILON_MICRO: f64 = 1e-6;
376 let scaled = usd * 1_000_000.0;
377 (scaled + TIE_EPSILON_MICRO.copysign(scaled)).round() as i64
378}
379
380fn option_str(opt: Option<&String>) -> &str {
382 opt.map_or("_", String::as_str)
383}
384
385pub fn compute_hash(prev_hash: &str, content: &str) -> String {
387 let mut hasher = Sha256::new();
388 hasher.update(prev_hash.as_bytes());
389 hasher.update(content.as_bytes());
390 crate::core::agent_identity::hex_encode(&hasher.finalize())
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396
397 fn ev() -> SavingsEvent {
398 SavingsEvent {
399 ts: "2026-06-01T00:00:00+00:00".into(),
400 tool: "ctx_read".into(),
401 mechanism: MECHANISM_COMPRESSION.into(),
402 model_id: "claude-3.5-sonnet".into(),
403 tokenizer: "o200k_base".into(),
404 baseline_tokens: 1000,
405 actual_tokens: 300,
406 saved_tokens: 700,
407 bounce_adjustment: 0,
408 unit_price_per_m_usd: 3.0,
409 saved_usd: 0.0021,
410 repo_hash: "abc123".into(),
411 agent_id: "local".into(),
412 prev_hash: String::new(),
413 entry_hash: String::new(),
414 version: "3.9.0".into(),
415 intent_tag: None,
416 outcome: None,
417 model_original: None,
418 model_routed: None,
419 routing_savings: None,
420 response_original_tokens: None,
421 response_delivered_tokens: None,
422 agent_chain_id: None,
423 chain_depth: None,
424 measurement_method: None,
425 evidence_class: None,
426 confidence: None,
427 request_id: None,
428 session_id: None,
429 trace_id: None,
430 quality_signal: None,
431 attribution_group: None,
432 attribution_id: None,
433 baseline_ref: None,
434 price_version: None,
435 customer_approval: None,
436 settlement_status: None,
437 is_first_inject: None,
438 cache_read_per_m_usd: None,
439 cache_write_per_m_usd: None,
440 }
441 }
442
443 #[test]
444 fn hash_is_deterministic() {
445 let e = ev();
446 let a = compute_hash("genesis", &e.canonical_content());
447 let b = compute_hash("genesis", &e.canonical_content());
448 assert_eq!(a, b);
449 assert_eq!(a.len(), 64, "sha-256 hex is 64 chars");
450 }
451
452 #[test]
453 fn hash_changes_when_content_changes() {
454 let mut e = ev();
455 let a = compute_hash("genesis", &e.canonical_content());
456 e.saved_tokens = 701;
457 let b = compute_hash("genesis", &e.canonical_content());
458 assert_ne!(a, b, "tampering with a content field must change the hash");
459 }
460
461 #[test]
462 fn hash_depends_on_prev() {
463 let e = ev();
464 let a = compute_hash("genesis", &e.canonical_content());
465 let b = compute_hash("other", &e.canonical_content());
466 assert_ne!(a, b, "chain link must depend on prev_hash");
467 }
468
469 #[test]
473 fn v2_hash_is_roundtrip_stable_on_decimal_tie() {
474 let mut e = ev();
475 e.saved_tokens = 9423;
476 e.unit_price_per_m_usd = 2.5;
477 e.saved_usd = 9423.0 * 2.5 / 1_000_000.0; e.prev_hash = "genesis".into();
479 e.entry_hash = compute_hash(&e.prev_hash, &e.canonical_content());
480
481 let json = serde_json::to_string(&e).unwrap();
482 let parsed: SavingsEvent = serde_json::from_str(&json).unwrap();
483
484 assert!(
485 parsed.hash_matches(&parsed.prev_hash),
486 "v2 chain must survive a JSON round-trip on a decimal-tie value"
487 );
488 }
489
490 #[test]
495 fn v2_hash_is_roundtrip_stable_on_production_order_tie() {
496 let mut e = ev();
497 e.saved_tokens = 7831;
498 e.unit_price_per_m_usd = 2.5;
499 e.saved_usd = e.saved_tokens as f64 / 1_000_000.0 * e.unit_price_per_m_usd;
501 e.prev_hash = "genesis".into();
502 e.entry_hash = compute_hash(&e.prev_hash, &e.canonical_content());
503
504 let json = serde_json::to_string(&e).unwrap();
505 let parsed: SavingsEvent = serde_json::from_str(&json).unwrap();
506 assert!(
507 parsed.hash_matches(&parsed.prev_hash),
508 "v2 chain must survive a JSON round-trip on a production-order half-micro tie"
509 );
510 }
511
512 #[test]
513 fn micro_usd_resolves_half_micro_ties_consistently() {
514 let tie = 19_577.5_f64 / 1_000_000.0;
517 let below = f64::from_bits(tie.to_bits() - 1);
518 assert_eq!(micro_usd(tie), micro_usd(below));
519 }
520
521 #[test]
522 fn legacy_v1_hash_still_verifies() {
523 let mut e = ev();
526 e.prev_hash = "genesis".into();
527 e.entry_hash = compute_hash(&e.prev_hash, &e.canonical_content_legacy());
528 assert!(e.hash_matches(&e.prev_hash), "legacy v1 hash must verify");
529 }
530
531 #[test]
532 fn v2_hash_still_verifies_and_v3_commits_mechanism() {
533 let mut e = ev();
536 e.prev_hash = "genesis".into();
537 e.entry_hash = compute_hash(&e.prev_hash, &e.canonical_content_v2());
538 assert!(e.hash_matches(&e.prev_hash), "v2 hash must verify");
539
540 let json = serde_json::to_string(&e).unwrap();
541 let stripped = json.replace(r#""mechanism":"compression","#, "");
542 let parsed: SavingsEvent = serde_json::from_str(&stripped).unwrap();
543 assert_eq!(parsed.mechanism, MECHANISM_COMPRESSION, "serde default");
544 assert!(parsed.hash_matches(&parsed.prev_hash), "v2 after roundtrip");
545
546 let mut v3 = ev();
548 v3.mechanism = MECHANISM_ROUTING.into();
549 v3.prev_hash = "genesis".into();
550 v3.entry_hash = compute_hash(&v3.prev_hash, &v3.canonical_content());
551 assert!(v3.hash_matches(&v3.prev_hash));
552 let mut forged = v3.clone();
553 forged.mechanism = MECHANISM_COMPRESSION.into();
554 assert!(
555 !forged.hash_matches(&forged.prev_hash),
556 "reattributing a routing saving to compression must be tamper-evident"
557 );
558 }
559
560 #[test]
561 fn v3_hash_still_verifies_and_v4_commits_version() {
562 let mut e = ev();
565 e.prev_hash = "genesis".into();
566 e.entry_hash = compute_hash(&e.prev_hash, &e.canonical_content_v3());
567 assert!(e.hash_matches(&e.prev_hash), "v3 hash must verify");
568
569 let json = serde_json::to_string(&e).unwrap();
570 let stripped = json.replace(r#","version":"3.9.0""#, "");
573 let parsed: SavingsEvent = serde_json::from_str(&stripped).unwrap();
574 assert_eq!(parsed.version, "", "serde default for a pre-v4 entry");
575 assert!(parsed.hash_matches(&parsed.prev_hash), "v3 after roundtrip");
576
577 let mut v4 = ev();
579 v4.version = "3.8.18".into();
580 v4.prev_hash = "genesis".into();
581 v4.entry_hash = compute_hash(&v4.prev_hash, &v4.canonical_content());
582 assert!(v4.hash_matches(&v4.prev_hash));
583 let mut forged = v4.clone();
584 forged.version = "3.9.0".into();
585 assert!(
586 !forged.hash_matches(&forged.prev_hash),
587 "rewriting which version recorded a saving must be tamper-evident"
588 );
589 }
590
591 #[test]
592 fn micro_usd_quantizes_to_millionths() {
593 assert_eq!(micro_usd(2.5), 2_500_000);
594 assert_eq!(micro_usd(0.0), 0);
595 assert_eq!(micro_usd(0.000_001), 1);
596 let tie = 9423.0 * 2.5 / 1_000_000.0;
599 assert_eq!(micro_usd(tie), micro_usd(tie));
600 }
601 #[test]
602 fn v4_hash_still_verifies_after_v5_upgrade() {
603 let mut e = ev();
604 e.prev_hash = "genesis".into();
605 e.entry_hash = compute_hash(&e.prev_hash, &e.canonical_content_v4());
606 assert!(
607 e.hash_matches(&e.prev_hash),
608 "v4 hash must verify via hash_matches"
609 );
610 }
611
612 #[test]
613 fn v5_commits_p5_fields() {
614 let mut e = ev();
615 e.attribution_id = Some("attr_001".into());
616 e.measurement_method = Some(MeasurementMethod::DirectCount);
617 e.evidence_class = Some(EvidenceClass::Measured);
618 e.prev_hash = "genesis".into();
619 e.entry_hash = compute_hash(&e.prev_hash, &e.canonical_content());
620 assert!(e.hash_matches(&e.prev_hash));
621
622 let mut forged = e.clone();
623 forged.attribution_id = Some("attr_002".into());
624 assert!(
625 !forged.hash_matches(&forged.prev_hash),
626 "rewriting attribution_id must be tamper-evident"
627 );
628 }
629
630 #[test]
631 fn p5_fields_default_to_none_on_deserialize() {
632 let e = ev();
633 let json = serde_json::to_string(&e).unwrap();
634 let parsed: SavingsEvent = serde_json::from_str(&json).unwrap();
635 assert_eq!(parsed.attribution_id, None);
636 assert_eq!(parsed.measurement_method, None);
637 assert_eq!(parsed.evidence_class, None);
638 assert_eq!(parsed.customer_approval, None);
639 assert_eq!(parsed.settlement_status, None);
640 }
641
642 #[test]
643 fn p5_enums_serialize_roundtrip() {
644 let mut e = ev();
645 e.measurement_method = Some(MeasurementMethod::Holdout);
646 e.evidence_class = Some(EvidenceClass::Statistical);
647 e.customer_approval = Some(CustomerApproval::Approved);
648 e.settlement_status = Some(SettlementStatus::Eligible);
649 e.confidence = Some(0.95);
650 e.attribution_id = Some("blake3_abc".into());
651
652 let json = serde_json::to_string(&e).unwrap();
653 let parsed: SavingsEvent = serde_json::from_str(&json).unwrap();
654 assert_eq!(parsed.measurement_method, Some(MeasurementMethod::Holdout));
655 assert_eq!(parsed.evidence_class, Some(EvidenceClass::Statistical));
656 assert_eq!(parsed.customer_approval, Some(CustomerApproval::Approved));
657 assert_eq!(parsed.settlement_status, Some(SettlementStatus::Eligible));
658 assert_eq!(parsed.confidence, Some(0.95));
659 assert_eq!(parsed.attribution_id, Some("blake3_abc".into()));
660 }
661
662 #[test]
663 fn option_str_maps_none_to_underscore() {
664 assert_eq!(option_str(None), "_");
665 assert_eq!(option_str(Some(&"val".to_string())), "val");
666 }
667}