use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
pub const MECHANISM_COMPRESSION: &str = "compression";
pub const MECHANISM_ROUTING: &str = "routing";
pub const MECHANISM_CACHING: &str = "caching";
fn default_mechanism() -> String {
MECHANISM_COMPRESSION.to_string()
}
fn default_version() -> String {
String::new()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SavingsEvent {
pub ts: String,
pub tool: String,
#[serde(default = "default_mechanism")]
pub mechanism: 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,
#[serde(default = "default_version")]
pub version: String,
}
impl SavingsEvent {
pub fn canonical_content(&self) -> String {
format!(
"v4|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
self.ts,
self.tool,
self.mechanism,
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,
self.version,
)
}
pub fn canonical_content_v3(&self) -> String {
format!(
"v3|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
self.ts,
self.tool,
self.mechanism,
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_v2(&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_v3())
|| self.entry_hash == compute_hash(prev_hash, &self.canonical_content_v2())
|| self.entry_hash == compute_hash(prev_hash, &self.canonical_content_legacy())
}
}
fn micro_usd(usd: f64) -> i64 {
const TIE_EPSILON_MICRO: f64 = 1e-6;
let scaled = usd * 1_000_000.0;
(scaled + TIE_EPSILON_MICRO.copysign(scaled)).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());
crate::core::agent_identity::hex_encode(&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(),
mechanism: MECHANISM_COMPRESSION.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(),
version: "3.9.0".into(),
}
}
#[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 v2_hash_is_roundtrip_stable_on_production_order_tie() {
let mut e = ev();
e.saved_tokens = 7831;
e.unit_price_per_m_usd = 2.5;
e.saved_usd = e.saved_tokens as f64 / 1_000_000.0 * e.unit_price_per_m_usd;
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 production-order half-micro tie"
);
}
#[test]
fn micro_usd_resolves_half_micro_ties_consistently() {
let tie = 19_577.5_f64 / 1_000_000.0;
let below = f64::from_bits(tie.to_bits() - 1);
assert_eq!(micro_usd(tie), micro_usd(below));
}
#[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 v2_hash_still_verifies_and_v3_commits_mechanism() {
let mut e = ev();
e.prev_hash = "genesis".into();
e.entry_hash = compute_hash(&e.prev_hash, &e.canonical_content_v2());
assert!(e.hash_matches(&e.prev_hash), "v2 hash must verify");
let json = serde_json::to_string(&e).unwrap();
let stripped = json.replace(r#""mechanism":"compression","#, "");
let parsed: SavingsEvent = serde_json::from_str(&stripped).unwrap();
assert_eq!(parsed.mechanism, MECHANISM_COMPRESSION, "serde default");
assert!(parsed.hash_matches(&parsed.prev_hash), "v2 after roundtrip");
let mut v3 = ev();
v3.mechanism = MECHANISM_ROUTING.into();
v3.prev_hash = "genesis".into();
v3.entry_hash = compute_hash(&v3.prev_hash, &v3.canonical_content());
assert!(v3.hash_matches(&v3.prev_hash));
let mut forged = v3.clone();
forged.mechanism = MECHANISM_COMPRESSION.into();
assert!(
!forged.hash_matches(&forged.prev_hash),
"reattributing a routing saving to compression must be tamper-evident"
);
}
#[test]
fn v3_hash_still_verifies_and_v4_commits_version() {
let mut e = ev();
e.prev_hash = "genesis".into();
e.entry_hash = compute_hash(&e.prev_hash, &e.canonical_content_v3());
assert!(e.hash_matches(&e.prev_hash), "v3 hash must verify");
let json = serde_json::to_string(&e).unwrap();
let stripped = json.replace(r#","version":"3.9.0""#, "");
let parsed: SavingsEvent = serde_json::from_str(&stripped).unwrap();
assert_eq!(parsed.version, "", "serde default for a pre-v4 entry");
assert!(parsed.hash_matches(&parsed.prev_hash), "v3 after roundtrip");
let mut v4 = ev();
v4.version = "3.8.18".into();
v4.prev_hash = "genesis".into();
v4.entry_hash = compute_hash(&v4.prev_hash, &v4.canonical_content());
assert!(v4.hash_matches(&v4.prev_hash));
let mut forged = v4.clone();
forged.version = "3.9.0".into();
assert!(
!forged.hash_matches(&forged.prev_hash),
"rewriting which version recorded a saving must be tamper-evident"
);
}
#[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));
}
}