use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SavingsEvent {
pub ts: String,
pub tool: String,
pub model_id: String,
pub tokenizer: String,
pub baseline_tokens: u64,
pub actual_tokens: u64,
pub saved_tokens: u64,
pub bounce_adjustment: u64,
pub unit_price_per_m_usd: f64,
pub saved_usd: f64,
pub repo_hash: String,
pub agent_id: String,
pub prev_hash: String,
pub entry_hash: String,
}
impl SavingsEvent {
pub fn canonical_content(&self) -> String {
format!(
"v2|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
self.ts,
self.tool,
self.model_id,
self.tokenizer,
self.baseline_tokens,
self.actual_tokens,
self.saved_tokens,
self.bounce_adjustment,
micro_usd(self.unit_price_per_m_usd),
micro_usd(self.saved_usd),
self.repo_hash,
self.agent_id,
)
}
pub fn canonical_content_legacy(&self) -> String {
format!(
"{}|{}|{}|{}|{}|{}|{}|{}|{:.6}|{:.6}|{}|{}",
self.ts,
self.tool,
self.model_id,
self.tokenizer,
self.baseline_tokens,
self.actual_tokens,
self.saved_tokens,
self.bounce_adjustment,
self.unit_price_per_m_usd,
self.saved_usd,
self.repo_hash,
self.agent_id,
)
}
pub fn hash_matches(&self, prev_hash: &str) -> bool {
self.entry_hash == compute_hash(prev_hash, &self.canonical_content())
|| self.entry_hash == compute_hash(prev_hash, &self.canonical_content_legacy())
}
}
fn micro_usd(usd: f64) -> i64 {
(usd * 1_000_000.0).round() as i64
}
pub fn compute_hash(prev_hash: &str, content: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(prev_hash.as_bytes());
hasher.update(content.as_bytes());
format!("{:x}", hasher.finalize())
}
#[cfg(test)]
mod tests {
use super::*;
fn ev() -> SavingsEvent {
SavingsEvent {
ts: "2026-06-01T00:00:00+00:00".into(),
tool: "ctx_read".into(),
model_id: "claude-3.5-sonnet".into(),
tokenizer: "o200k_base".into(),
baseline_tokens: 1000,
actual_tokens: 300,
saved_tokens: 700,
bounce_adjustment: 0,
unit_price_per_m_usd: 3.0,
saved_usd: 0.0021,
repo_hash: "abc123".into(),
agent_id: "local".into(),
prev_hash: String::new(),
entry_hash: String::new(),
}
}
#[test]
fn hash_is_deterministic() {
let e = ev();
let a = compute_hash("genesis", &e.canonical_content());
let b = compute_hash("genesis", &e.canonical_content());
assert_eq!(a, b);
assert_eq!(a.len(), 64, "sha-256 hex is 64 chars");
}
#[test]
fn hash_changes_when_content_changes() {
let mut e = ev();
let a = compute_hash("genesis", &e.canonical_content());
e.saved_tokens = 701;
let b = compute_hash("genesis", &e.canonical_content());
assert_ne!(a, b, "tampering with a content field must change the hash");
}
#[test]
fn hash_depends_on_prev() {
let e = ev();
let a = compute_hash("genesis", &e.canonical_content());
let b = compute_hash("other", &e.canonical_content());
assert_ne!(a, b, "chain link must depend on prev_hash");
}
#[test]
fn v2_hash_is_roundtrip_stable_on_decimal_tie() {
let mut e = ev();
e.saved_tokens = 9423;
e.unit_price_per_m_usd = 2.5;
e.saved_usd = 9423.0 * 2.5 / 1_000_000.0; e.prev_hash = "genesis".into();
e.entry_hash = compute_hash(&e.prev_hash, &e.canonical_content());
let json = serde_json::to_string(&e).unwrap();
let parsed: SavingsEvent = serde_json::from_str(&json).unwrap();
assert!(
parsed.hash_matches(&parsed.prev_hash),
"v2 chain must survive a JSON round-trip on a decimal-tie value"
);
}
#[test]
fn legacy_v1_hash_still_verifies() {
let mut e = ev();
e.prev_hash = "genesis".into();
e.entry_hash = compute_hash(&e.prev_hash, &e.canonical_content_legacy());
assert!(e.hash_matches(&e.prev_hash), "legacy v1 hash must verify");
}
#[test]
fn micro_usd_quantizes_to_millionths() {
assert_eq!(micro_usd(2.5), 2_500_000);
assert_eq!(micro_usd(0.0), 0);
assert_eq!(micro_usd(0.000_001), 1);
let tie = 9423.0 * 2.5 / 1_000_000.0;
assert_eq!(micro_usd(tie), micro_usd(tie));
}
}