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, Eq)]
#[serde(rename_all = "snake_case")]
pub enum MeasurementMethod {
DirectCount,
Holdout,
BaselineEstimate,
ProviderReconciled,
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum EvidenceClass {
Measured,
Approximated,
Statistical,
Declared,
Unclassified,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CustomerApproval {
Pending,
Approved,
Disputed,
Superseded,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SettlementStatus {
Ineligible,
Eligible,
Settled,
Reversed,
}
#[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,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub intent_tag: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub outcome: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_original: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_routed: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub routing_savings: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub response_original_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub response_delivered_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_chain_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub chain_depth: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub measurement_method: Option<MeasurementMethod>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub evidence_class: Option<EvidenceClass>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub confidence: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trace_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub solution_decision: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub loc_added: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub loc_removed: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lines_added: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lines_removed: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub net: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub quality_signal: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attribution_group: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attribution_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub baseline_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub price_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub customer_approval: Option<CustomerApproval>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub settlement_status: Option<SettlementStatus>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub is_first_inject: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_read_per_m_usd: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_write_per_m_usd: Option<f64>,
}
impl SavingsEvent {
pub fn canonical_content(&self) -> String {
format!(
"v6|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
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,
option_str(self.attribution_id.as_ref()),
option_str(self.intent_tag.as_ref()),
option_str(self.model_routed.as_ref()),
self.measurement_method.as_ref().map_or("_", |m| match m {
MeasurementMethod::DirectCount => "direct_count",
MeasurementMethod::Holdout => "holdout",
MeasurementMethod::BaselineEstimate => "baseline_estimate",
MeasurementMethod::ProviderReconciled => "provider_reconciled",
MeasurementMethod::Unknown => "unknown",
}),
self.evidence_class.as_ref().map_or("_", |e| match e {
EvidenceClass::Measured => "measured",
EvidenceClass::Approximated => "approximated",
EvidenceClass::Statistical => "statistical",
EvidenceClass::Declared => "declared",
EvidenceClass::Unclassified => "unclassified",
}),
option_str(self.path.as_ref()),
self.lines_added
.map_or_else(|| "_".to_string(), |value| value.to_string()),
self.lines_removed
.map_or_else(|| "_".to_string(), |value| value.to_string()),
self.net
.map_or_else(|| "_".to_string(), |value| value.to_string()),
)
}
pub fn canonical_content_v5(&self) -> String {
format!(
"v5|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
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,
option_str(self.attribution_id.as_ref()),
option_str(self.intent_tag.as_ref()),
option_str(self.model_routed.as_ref()),
self.measurement_method.as_ref().map_or("_", |m| match m {
MeasurementMethod::DirectCount => "direct_count",
MeasurementMethod::Holdout => "holdout",
MeasurementMethod::BaselineEstimate => "baseline_estimate",
MeasurementMethod::ProviderReconciled => "provider_reconciled",
MeasurementMethod::Unknown => "unknown",
}),
self.evidence_class.as_ref().map_or("_", |e| match e {
EvidenceClass::Measured => "measured",
EvidenceClass::Approximated => "approximated",
EvidenceClass::Statistical => "statistical",
EvidenceClass::Declared => "declared",
EvidenceClass::Unclassified => "unclassified",
}),
)
}
pub fn canonical_content_v4(&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_v5())
|| self.entry_hash == compute_hash(prev_hash, &self.canonical_content_v4())
|| 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
}
fn option_str(opt: Option<&String>) -> &str {
opt.map_or("_", String::as_str)
}
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(),
intent_tag: None,
outcome: None,
model_original: None,
model_routed: None,
routing_savings: None,
response_original_tokens: None,
response_delivered_tokens: None,
agent_chain_id: None,
chain_depth: None,
measurement_method: None,
evidence_class: None,
confidence: None,
request_id: None,
session_id: None,
trace_id: None,
solution_decision: None,
loc_added: None,
loc_removed: None,
path: None,
lines_added: None,
lines_removed: None,
net: None,
quality_signal: None,
attribution_group: None,
attribution_id: None,
baseline_ref: None,
price_version: None,
customer_approval: None,
settlement_status: None,
is_first_inject: None,
cache_read_per_m_usd: None,
cache_write_per_m_usd: None,
}
}
#[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_v4());
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_v5());
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_v4());
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));
}
#[test]
fn v4_hash_still_verifies_after_v5_upgrade() {
let mut e = ev();
e.prev_hash = "genesis".into();
e.entry_hash = compute_hash(&e.prev_hash, &e.canonical_content_v4());
assert!(
e.hash_matches(&e.prev_hash),
"v4 hash must verify via hash_matches"
);
}
#[test]
fn v5_commits_p5_fields() {
let mut e = ev();
e.attribution_id = Some("attr_001".into());
e.measurement_method = Some(MeasurementMethod::DirectCount);
e.evidence_class = Some(EvidenceClass::Measured);
e.prev_hash = "genesis".into();
e.entry_hash = compute_hash(&e.prev_hash, &e.canonical_content_v5());
assert!(e.hash_matches(&e.prev_hash));
let mut forged = e.clone();
forged.attribution_id = Some("attr_002".into());
assert!(
!forged.hash_matches(&forged.prev_hash),
"rewriting attribution_id must be tamper-evident"
);
}
#[test]
fn v6_commits_edit_fields() {
let mut e = ev();
e.path = Some("rust/src/core/edit_metering.rs".into());
e.lines_added = Some(2);
e.lines_removed = Some(5);
e.net = Some(3);
e.prev_hash = "genesis".into();
e.entry_hash = compute_hash(&e.prev_hash, &e.canonical_content());
assert!(e.hash_matches(&e.prev_hash));
let mut forged = e.clone();
forged.net = Some(2);
assert!(
!forged.hash_matches(&forged.prev_hash),
"rewriting edit LOC must be tamper-evident"
);
}
#[test]
fn p5_fields_default_to_none_on_deserialize() {
let e = ev();
let json = serde_json::to_string(&e).unwrap();
let parsed: SavingsEvent = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.attribution_id, None);
assert_eq!(parsed.measurement_method, None);
assert_eq!(parsed.evidence_class, None);
assert_eq!(parsed.customer_approval, None);
assert_eq!(parsed.settlement_status, None);
}
#[test]
fn p5_enums_serialize_roundtrip() {
let mut e = ev();
e.measurement_method = Some(MeasurementMethod::Holdout);
e.evidence_class = Some(EvidenceClass::Statistical);
e.customer_approval = Some(CustomerApproval::Approved);
e.settlement_status = Some(SettlementStatus::Eligible);
e.confidence = Some(0.95);
e.attribution_id = Some("blake3_abc".into());
let json = serde_json::to_string(&e).unwrap();
let parsed: SavingsEvent = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.measurement_method, Some(MeasurementMethod::Holdout));
assert_eq!(parsed.evidence_class, Some(EvidenceClass::Statistical));
assert_eq!(parsed.customer_approval, Some(CustomerApproval::Approved));
assert_eq!(parsed.settlement_status, Some(SettlementStatus::Eligible));
assert_eq!(parsed.confidence, Some(0.95));
assert_eq!(parsed.attribution_id, Some("blake3_abc".into()));
}
#[test]
fn option_str_maps_none_to_underscore() {
assert_eq!(option_str(None), "_");
assert_eq!(option_str(Some(&"val".to_string())), "val");
}
}