Skip to main content

lean_ctx/core/savings_ledger/
event.rs

1//! The auditable per-event savings record (the G1 counterfactual unit).
2//!
3//! One [`SavingsEvent`] is appended per value-producing read: it captures the
4//! counterfactual (`baseline_tokens` = what the agent would have consumed) against the
5//! `actual_tokens` actually sent, the resolved pricing model, and a SHA-256 hash chain
6//! so the history is tamper-evident. See `docs/business/03-verified-savings-ledger.md`.
7
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11/// Savings produced by making the payload smaller (tool output compression,
12/// proxy wire compression). The historical default: every pre-v3 event is one.
13pub const MECHANISM_COMPRESSION: &str = "compression";
14/// Savings produced by serving the request with a cheaper model (active
15/// router, enterprise#13): same tokens, lower rate.
16pub const MECHANISM_ROUTING: &str = "routing";
17/// Savings produced by provider prompt-cache discounts: cache-read tokens
18/// billed below the input rate.
19pub const MECHANISM_CACHING: &str = "caching";
20
21fn default_mechanism() -> String {
22    MECHANISM_COMPRESSION.to_string()
23}
24
25/// Pre-v4 events carry no `version` field. Empty, not the current crate
26/// version — an empty string honestly says "unknown" instead of implying an
27/// old entry was written by whatever binary happens to be reading it now.
28/// Same convention as `DayStats::version` in `core/stats/model.rs`.
29fn default_version() -> String {
30    String::new()
31}
32
33// ── P5 Unified Ledger Enums ──────────────────────────────────────────────────
34
35/// How the savings value was determined (P5 — billing-grade evidence).
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
37#[serde(rename_all = "snake_case")]
38pub enum MeasurementMethod {
39    /// Token counts measured by local tokenizer before/after compression.
40    DirectCount,
41    /// Savings inferred from an A/B holdout experiment.
42    Holdout,
43    /// Savings estimated from a calibrated baseline model.
44    BaselineEstimate,
45    /// Savings confirmed by provider billing reconciliation.
46    ProviderReconciled,
47    /// Method not yet determined or legacy events.
48    Unknown,
49}
50
51/// Trustworthiness class of the evidence backing a savings claim (P5).
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
53#[serde(rename_all = "snake_case")]
54pub enum EvidenceClass {
55    /// Locally measured, reproducible, deterministic.
56    Measured,
57    /// Locally measured but with known approximation (e.g. proxy tokenizer).
58    Approximated,
59    /// Derived from statistical experiment (holdout).
60    Statistical,
61    /// Declared by operator without independent measurement.
62    Declared,
63    /// No evidence attached (legacy or unknown).
64    Unclassified,
65}
66
67/// Customer disposition of a savings claim (P5 — settlement path).
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
69#[serde(rename_all = "snake_case")]
70pub enum CustomerApproval {
71    /// No customer review yet.
72    Pending,
73    /// Customer accepted the savings claim.
74    Approved,
75    /// Customer disputed the savings claim.
76    Disputed,
77    /// Claim superseded by a correction event.
78    Superseded,
79}
80
81/// Settlement lifecycle state (P5 — billing pipeline).
82#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
83#[serde(rename_all = "snake_case")]
84pub enum SettlementStatus {
85    /// Not eligible for settlement (insufficient evidence).
86    Ineligible,
87    /// Evidence sufficient, awaiting approval.
88    Eligible,
89    /// Included in a settlement batch.
90    Settled,
91    /// Reversed after settlement.
92    Reversed,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
96pub struct SavingsEvent {
97    pub ts: String,
98    /// Originating tool (e.g. "ctx_read"). Coarse for now; per-mode granularity is a
99    /// later refinement (stats already tracks per-mode).
100    pub tool: String,
101    /// Savings mechanism this event attributes to: `compression` | `routing` |
102    /// `caching` (enterprise#19). Pre-v3 events carry no field and default to
103    /// `compression` — the only mechanism that existed when they were written.
104    #[serde(default = "default_mechanism")]
105    pub mechanism: String,
106    /// Resolved pricing model key the saving was valued against.
107    pub model_id: String,
108    /// Tokenizer family that produced `baseline_tokens`/`actual_tokens` (e.g.
109    /// `"o200k_base"`). Recorded separately from `model_id` because lean-ctx counts with
110    /// one tokenizer as a proxy; the model's own tokenizer may differ by a few percent.
111    pub tokenizer: String,
112    /// Counterfactual: tokens the agent would have consumed without lean-ctx.
113    pub baseline_tokens: u64,
114    /// Tokens actually sent.
115    pub actual_tokens: u64,
116    /// `baseline_tokens - actual_tokens`.
117    pub saved_tokens: u64,
118    /// Tokens later wasted by a compressed->full re-read (G7). Always 0 until a
119    /// *persisted* bounce signal exists — we never silently inflate with a guessed 0.
120    pub bounce_adjustment: u64,
121    /// Model input price per 1M tokens used to value the saving.
122    pub unit_price_per_m_usd: f64,
123    /// `(saved_tokens - bounce_adjustment) * unit_price_per_m_usd / 1e6`. Upper bound
124    /// (ignores prompt-cache discounts), consistent with the Wrapped headline.
125    pub saved_usd: f64,
126    /// Attribution: SHA-256 (truncated) of the recording process working directory.
127    /// Privacy-preserving — never the file path or its contents.
128    pub repo_hash: String,
129    pub agent_id: String,
130    pub prev_hash: String,
131    pub entry_hash: String,
132    /// lean-ctx version active when this event was recorded (`CARGO_PKG_VERSION`,
133    /// #NNN). Lets a `stats.json` rebuilt from the ledger (after corruption or
134    /// otherwise) recover the per-day version tag `lean-ctx gain --daily` shows,
135    /// which the ledger previously had no way to answer. Pre-v4 events default
136    /// to empty (unknown), never a guessed version.
137    #[serde(default = "default_version")]
138    pub version: String,
139
140    // ── P5 Unified Ledger Fields (all Option + serde(default) = backward-compat) ──
141    /// DIM 3: intent tag from IntentClassifier.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub intent_tag: Option<String>,
144    /// Outcome of the context delivery: used | merged | sent | discarded | unknown.
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub outcome: Option<String>,
147    /// DIM 3: originally requested model.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub model_original: Option<String>,
150    /// DIM 3: actually routed model.
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub model_routed: Option<String>,
153    /// DIM 3: tokens saved through routing (cheaper model, same content).
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub routing_savings: Option<u64>,
156    /// DIM 2: original response output tokens.
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub response_original_tokens: Option<u64>,
159    /// DIM 2: delivered response output tokens.
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub response_delivered_tokens: Option<u64>,
162    /// DIM 4: agent chain correlation ID.
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub agent_chain_id: Option<String>,
165    /// DIM 4: depth in the agent chain.
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub chain_depth: Option<u8>,
168
169    // ── P5 Evidence & Settlement Fields ──
170    /// How the savings were measured.
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub measurement_method: Option<MeasurementMethod>,
173    /// Trustworthiness class of the evidence.
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub evidence_class: Option<EvidenceClass>,
176    /// Confidence score [0.0, 1.0] for the savings measurement.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub confidence: Option<f64>,
179    // ── G2 Trace Correlation (#closure) ──
180    /// OCLA request ID that generated this savings event.
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub request_id: Option<String>,
183    /// Session ID from the agent context.
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub session_id: Option<String>,
186    /// Distributed trace ID for end-to-end correlation.
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub trace_id: Option<String>,
189
190    /// Quality signal from outcome tracking.
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub quality_signal: Option<String>,
193    /// Exclusive attribution group (no double-counting across groups).
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub attribution_group: Option<String>,
196    /// BLAKE3 hash identifying the attribution scope uniquely.
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub attribution_id: Option<String>,
199    /// Reference to the baseline used for counterfactual.
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub baseline_ref: Option<String>,
202    /// Pricing model version used for valuation.
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub price_version: Option<String>,
205    /// Customer approval state.
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub customer_approval: Option<CustomerApproval>,
208    /// Settlement lifecycle state.
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    pub settlement_status: Option<SettlementStatus>,
211
212    // ── G8 Token-Stream Attribution (#1191) ──
213    /// Whether this is a first-inject (turn 1 = cache_write rate) or re-read
214    /// (turn 2+ = cache_read rate). `None` for pre-G8 events.
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub is_first_inject: Option<bool>,
217    /// Cache-read rate for the model, if known at recording time.
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub cache_read_per_m_usd: Option<f64>,
220    /// Cache-write rate for the model, if known at recording time.
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub cache_write_per_m_usd: Option<f64>,
223}
224
225impl SavingsEvent {
226    /// Canonical (v5) representation: v4 + P5 unified ledger fields.
227    /// New fields are committed as `option_str(field)` — `None` becomes "_"
228    /// (a sentinel that never appears in real values), so the hash is stable
229    /// regardless of whether the field was populated.
230    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    /// v4 canonical: v3 + version field. Retained so v4-written ledgers verify.
268    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    /// v3 canonical (pre-`version`): v2 + the `mechanism` attribution field
289    /// (enterprise#19). Retained so ledgers written between the v3 fix and v4
290    /// keep verifying unchanged.
291    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    /// v2 canonical (pre-`mechanism`): integer micro-USD money, no attribution field.
311    /// Retained so ledgers written between the v2 fix and v3 keep verifying unchanged.
312    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    /// Legacy (v1) canonical: `{:.6}` of the raw `f64` money fields. Retained only so
331    /// `verify` keeps validating pre-v2 ledgers that never hit a tie value; new appends and
332    /// re-chained ledgers always use [`Self::canonical_content`].
333    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    /// True if `entry_hash` matches the current (v4) canonical hash, the v3 hash, the v2
352    /// hash, or the legacy v1 hash. Accepting all four lets `verify` validate ledgers
353    /// written under any scheme without forcing a migration (clean old ledgers stay
354    /// valid; broken-by-bug ones are repaired by `rechain`, which re-hashes under v4).
355    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
364/// Rounds a USD amount to integer micro-USD (millionths of a dollar) — the float-free money
365/// unit committed by the v2 hash chain.
366///
367/// A *half*-micro-USD tie (e.g. `7831 tokens * $2.5/M = 19577.5 µ$`) is the one input where a
368/// bare `(usd * 1e6).round()` is fragile: the scaled product computed at the append call site
369/// and the value recomputed at the verify call site can differ by a sub-ULP amount (float-op
370/// contraction / a different inlining context), landing on opposite sides of `.5` and breaking
371/// the chain for *untampered* data. Nudging by a sub-micro epsilon before rounding resolves the
372/// tie identically at every call site. `1e-6 µ$` (= `1e-12 USD`) is far below any real monetary
373/// unit and only ever moves a value sitting on the tie, so reported totals are unaffected.
374fn 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
380/// Maps `Option<String>` to a stable canonical form: the value or `"_"` for None.
381fn option_str(opt: Option<&String>) -> &str {
382    opt.map_or("_", String::as_str)
383}
384
385/// `SHA-256(prev_hash || content)` as lowercase hex — the chain link primitive.
386pub 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    /// Regression: `saved_usd = 0.0235575` is a 6th-decimal tie that broke the legacy
470    /// `{:.6}` chain after a JSON round-trip. The v2 integer-micro-USD canonical must be
471    /// stable across serialize -> deserialize so `verify` accepts an untampered entry.
472    #[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; // = 0.0235575, a {:.6} tie
478        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    /// Regression: the production recorder values a read as `saved_tokens / 1e6 * price`, whose
491    /// result for `7831 tokens @ $2.5/M` lands on a half-micro-USD tie (`19577.5 µ$`). That tie
492    /// broke the v2 chain on a fresh, untampered ledger. The tie-stable [`micro_usd`] must make
493    /// append and verify agree across a JSON round-trip regardless of the computation order.
494    #[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        // Same order as `record_read_event`: divide first, then multiply.
500        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        // A value exactly on the tie and a value one ULP below it must quantize the same way,
515        // so an append/verify pair that observes either side of the tie still agrees.
516        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        // An entry hashed under the old {:.6} scheme must keep validating via hash_matches,
524        // so upgrading does not invalidate clean pre-v2 ledgers.
525        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        // Pre-mechanism (v2) entries — including their JSON form without the
534        // field — must keep verifying after the v3 upgrade (enterprise#19).
535        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        // v3 commits the mechanism: rewriting the attribution breaks the hash.
547        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        // Pre-version (v3) entries — including their JSON form without the
563        // field — must keep verifying after the v4 upgrade (#NNN).
564        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        // `version` is the last struct field, so its JSON key is preceded by
571        // a comma, not followed by one.
572        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        // v4 commits the version: rewriting it breaks the hash.
578        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        // Determinism for a given f64 is the property the chain relies on (the exact rounding
597        // of a tie is irrelevant as long as it is reproducible).
598        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}