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    /// Quality signal from outcome tracking.
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub quality_signal: Option<String>,
182    /// Exclusive attribution group (no double-counting across groups).
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub attribution_group: Option<String>,
185    /// BLAKE3 hash identifying the attribution scope uniquely.
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub attribution_id: Option<String>,
188    /// Reference to the baseline used for counterfactual.
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub baseline_ref: Option<String>,
191    /// Pricing model version used for valuation.
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub price_version: Option<String>,
194    /// Customer approval state.
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub customer_approval: Option<CustomerApproval>,
197    /// Settlement lifecycle state.
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub settlement_status: Option<SettlementStatus>,
200
201    // ── G8 Token-Stream Attribution (#1191) ──
202    /// Whether this is a first-inject (turn 1 = cache_write rate) or re-read
203    /// (turn 2+ = cache_read rate). `None` for pre-G8 events.
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub is_first_inject: Option<bool>,
206    /// Cache-read rate for the model, if known at recording time.
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub cache_read_per_m_usd: Option<f64>,
209    /// Cache-write rate for the model, if known at recording time.
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub cache_write_per_m_usd: Option<f64>,
212}
213
214impl SavingsEvent {
215    /// Canonical (v5) representation: v4 + P5 unified ledger fields.
216    /// New fields are committed as `option_str(field)` — `None` becomes "_"
217    /// (a sentinel that never appears in real values), so the hash is stable
218    /// regardless of whether the field was populated.
219    pub fn canonical_content(&self) -> String {
220        format!(
221            "v5|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
222            self.ts,
223            self.tool,
224            self.mechanism,
225            self.model_id,
226            self.tokenizer,
227            self.baseline_tokens,
228            self.actual_tokens,
229            self.saved_tokens,
230            self.bounce_adjustment,
231            micro_usd(self.unit_price_per_m_usd),
232            micro_usd(self.saved_usd),
233            self.repo_hash,
234            self.agent_id,
235            self.version,
236            option_str(self.attribution_id.as_ref()),
237            option_str(self.intent_tag.as_ref()),
238            option_str(self.model_routed.as_ref()),
239            self.measurement_method.as_ref().map_or("_", |m| match m {
240                MeasurementMethod::DirectCount => "direct_count",
241                MeasurementMethod::Holdout => "holdout",
242                MeasurementMethod::BaselineEstimate => "baseline_estimate",
243                MeasurementMethod::ProviderReconciled => "provider_reconciled",
244                MeasurementMethod::Unknown => "unknown",
245            }),
246            self.evidence_class.as_ref().map_or("_", |e| match e {
247                EvidenceClass::Measured => "measured",
248                EvidenceClass::Approximated => "approximated",
249                EvidenceClass::Statistical => "statistical",
250                EvidenceClass::Declared => "declared",
251                EvidenceClass::Unclassified => "unclassified",
252            }),
253        )
254    }
255
256    /// v4 canonical: v3 + version field. Retained so v4-written ledgers verify.
257    pub fn canonical_content_v4(&self) -> String {
258        format!(
259            "v4|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
260            self.ts,
261            self.tool,
262            self.mechanism,
263            self.model_id,
264            self.tokenizer,
265            self.baseline_tokens,
266            self.actual_tokens,
267            self.saved_tokens,
268            self.bounce_adjustment,
269            micro_usd(self.unit_price_per_m_usd),
270            micro_usd(self.saved_usd),
271            self.repo_hash,
272            self.agent_id,
273            self.version,
274        )
275    }
276
277    /// v3 canonical (pre-`version`): v2 + the `mechanism` attribution field
278    /// (enterprise#19). Retained so ledgers written between the v3 fix and v4
279    /// keep verifying unchanged.
280    pub fn canonical_content_v3(&self) -> String {
281        format!(
282            "v3|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
283            self.ts,
284            self.tool,
285            self.mechanism,
286            self.model_id,
287            self.tokenizer,
288            self.baseline_tokens,
289            self.actual_tokens,
290            self.saved_tokens,
291            self.bounce_adjustment,
292            micro_usd(self.unit_price_per_m_usd),
293            micro_usd(self.saved_usd),
294            self.repo_hash,
295            self.agent_id,
296        )
297    }
298
299    /// v2 canonical (pre-`mechanism`): integer micro-USD money, no attribution field.
300    /// Retained so ledgers written between the v2 fix and v3 keep verifying unchanged.
301    pub fn canonical_content_v2(&self) -> String {
302        format!(
303            "v2|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
304            self.ts,
305            self.tool,
306            self.model_id,
307            self.tokenizer,
308            self.baseline_tokens,
309            self.actual_tokens,
310            self.saved_tokens,
311            self.bounce_adjustment,
312            micro_usd(self.unit_price_per_m_usd),
313            micro_usd(self.saved_usd),
314            self.repo_hash,
315            self.agent_id,
316        )
317    }
318
319    /// Legacy (v1) canonical: `{:.6}` of the raw `f64` money fields. Retained only so
320    /// `verify` keeps validating pre-v2 ledgers that never hit a tie value; new appends and
321    /// re-chained ledgers always use [`Self::canonical_content`].
322    pub fn canonical_content_legacy(&self) -> String {
323        format!(
324            "{}|{}|{}|{}|{}|{}|{}|{}|{:.6}|{:.6}|{}|{}",
325            self.ts,
326            self.tool,
327            self.model_id,
328            self.tokenizer,
329            self.baseline_tokens,
330            self.actual_tokens,
331            self.saved_tokens,
332            self.bounce_adjustment,
333            self.unit_price_per_m_usd,
334            self.saved_usd,
335            self.repo_hash,
336            self.agent_id,
337        )
338    }
339
340    /// True if `entry_hash` matches the current (v4) canonical hash, the v3 hash, the v2
341    /// hash, or the legacy v1 hash. Accepting all four lets `verify` validate ledgers
342    /// written under any scheme without forcing a migration (clean old ledgers stay
343    /// valid; broken-by-bug ones are repaired by `rechain`, which re-hashes under v4).
344    pub fn hash_matches(&self, prev_hash: &str) -> bool {
345        self.entry_hash == compute_hash(prev_hash, &self.canonical_content())
346            || self.entry_hash == compute_hash(prev_hash, &self.canonical_content_v4())
347            || self.entry_hash == compute_hash(prev_hash, &self.canonical_content_v3())
348            || self.entry_hash == compute_hash(prev_hash, &self.canonical_content_v2())
349            || self.entry_hash == compute_hash(prev_hash, &self.canonical_content_legacy())
350    }
351}
352
353/// Rounds a USD amount to integer micro-USD (millionths of a dollar) — the float-free money
354/// unit committed by the v2 hash chain.
355///
356/// A *half*-micro-USD tie (e.g. `7831 tokens * $2.5/M = 19577.5 µ$`) is the one input where a
357/// bare `(usd * 1e6).round()` is fragile: the scaled product computed at the append call site
358/// and the value recomputed at the verify call site can differ by a sub-ULP amount (float-op
359/// contraction / a different inlining context), landing on opposite sides of `.5` and breaking
360/// the chain for *untampered* data. Nudging by a sub-micro epsilon before rounding resolves the
361/// tie identically at every call site. `1e-6 µ$` (= `1e-12 USD`) is far below any real monetary
362/// unit and only ever moves a value sitting on the tie, so reported totals are unaffected.
363fn micro_usd(usd: f64) -> i64 {
364    const TIE_EPSILON_MICRO: f64 = 1e-6;
365    let scaled = usd * 1_000_000.0;
366    (scaled + TIE_EPSILON_MICRO.copysign(scaled)).round() as i64
367}
368
369/// Maps `Option<String>` to a stable canonical form: the value or `"_"` for None.
370fn option_str(opt: Option<&String>) -> &str {
371    opt.map_or("_", String::as_str)
372}
373
374/// `SHA-256(prev_hash || content)` as lowercase hex — the chain link primitive.
375pub fn compute_hash(prev_hash: &str, content: &str) -> String {
376    let mut hasher = Sha256::new();
377    hasher.update(prev_hash.as_bytes());
378    hasher.update(content.as_bytes());
379    crate::core::agent_identity::hex_encode(&hasher.finalize())
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385
386    fn ev() -> SavingsEvent {
387        SavingsEvent {
388            ts: "2026-06-01T00:00:00+00:00".into(),
389            tool: "ctx_read".into(),
390            mechanism: MECHANISM_COMPRESSION.into(),
391            model_id: "claude-3.5-sonnet".into(),
392            tokenizer: "o200k_base".into(),
393            baseline_tokens: 1000,
394            actual_tokens: 300,
395            saved_tokens: 700,
396            bounce_adjustment: 0,
397            unit_price_per_m_usd: 3.0,
398            saved_usd: 0.0021,
399            repo_hash: "abc123".into(),
400            agent_id: "local".into(),
401            prev_hash: String::new(),
402            entry_hash: String::new(),
403            version: "3.9.0".into(),
404            intent_tag: None,
405            outcome: None,
406            model_original: None,
407            model_routed: None,
408            routing_savings: None,
409            response_original_tokens: None,
410            response_delivered_tokens: None,
411            agent_chain_id: None,
412            chain_depth: None,
413            measurement_method: None,
414            evidence_class: None,
415            confidence: None,
416            quality_signal: None,
417            attribution_group: None,
418            attribution_id: None,
419            baseline_ref: None,
420            price_version: None,
421            customer_approval: None,
422            settlement_status: None,
423            is_first_inject: None,
424            cache_read_per_m_usd: None,
425            cache_write_per_m_usd: None,
426        }
427    }
428
429    #[test]
430    fn hash_is_deterministic() {
431        let e = ev();
432        let a = compute_hash("genesis", &e.canonical_content());
433        let b = compute_hash("genesis", &e.canonical_content());
434        assert_eq!(a, b);
435        assert_eq!(a.len(), 64, "sha-256 hex is 64 chars");
436    }
437
438    #[test]
439    fn hash_changes_when_content_changes() {
440        let mut e = ev();
441        let a = compute_hash("genesis", &e.canonical_content());
442        e.saved_tokens = 701;
443        let b = compute_hash("genesis", &e.canonical_content());
444        assert_ne!(a, b, "tampering with a content field must change the hash");
445    }
446
447    #[test]
448    fn hash_depends_on_prev() {
449        let e = ev();
450        let a = compute_hash("genesis", &e.canonical_content());
451        let b = compute_hash("other", &e.canonical_content());
452        assert_ne!(a, b, "chain link must depend on prev_hash");
453    }
454
455    /// Regression: `saved_usd = 0.0235575` is a 6th-decimal tie that broke the legacy
456    /// `{:.6}` chain after a JSON round-trip. The v2 integer-micro-USD canonical must be
457    /// stable across serialize -> deserialize so `verify` accepts an untampered entry.
458    #[test]
459    fn v2_hash_is_roundtrip_stable_on_decimal_tie() {
460        let mut e = ev();
461        e.saved_tokens = 9423;
462        e.unit_price_per_m_usd = 2.5;
463        e.saved_usd = 9423.0 * 2.5 / 1_000_000.0; // = 0.0235575, a {:.6} tie
464        e.prev_hash = "genesis".into();
465        e.entry_hash = compute_hash(&e.prev_hash, &e.canonical_content());
466
467        let json = serde_json::to_string(&e).unwrap();
468        let parsed: SavingsEvent = serde_json::from_str(&json).unwrap();
469
470        assert!(
471            parsed.hash_matches(&parsed.prev_hash),
472            "v2 chain must survive a JSON round-trip on a decimal-tie value"
473        );
474    }
475
476    /// Regression: the production recorder values a read as `saved_tokens / 1e6 * price`, whose
477    /// result for `7831 tokens @ $2.5/M` lands on a half-micro-USD tie (`19577.5 µ$`). That tie
478    /// broke the v2 chain on a fresh, untampered ledger. The tie-stable [`micro_usd`] must make
479    /// append and verify agree across a JSON round-trip regardless of the computation order.
480    #[test]
481    fn v2_hash_is_roundtrip_stable_on_production_order_tie() {
482        let mut e = ev();
483        e.saved_tokens = 7831;
484        e.unit_price_per_m_usd = 2.5;
485        // Same order as `record_read_event`: divide first, then multiply.
486        e.saved_usd = e.saved_tokens as f64 / 1_000_000.0 * e.unit_price_per_m_usd;
487        e.prev_hash = "genesis".into();
488        e.entry_hash = compute_hash(&e.prev_hash, &e.canonical_content());
489
490        let json = serde_json::to_string(&e).unwrap();
491        let parsed: SavingsEvent = serde_json::from_str(&json).unwrap();
492        assert!(
493            parsed.hash_matches(&parsed.prev_hash),
494            "v2 chain must survive a JSON round-trip on a production-order half-micro tie"
495        );
496    }
497
498    #[test]
499    fn micro_usd_resolves_half_micro_ties_consistently() {
500        // A value exactly on the tie and a value one ULP below it must quantize the same way,
501        // so an append/verify pair that observes either side of the tie still agrees.
502        let tie = 19_577.5_f64 / 1_000_000.0;
503        let below = f64::from_bits(tie.to_bits() - 1);
504        assert_eq!(micro_usd(tie), micro_usd(below));
505    }
506
507    #[test]
508    fn legacy_v1_hash_still_verifies() {
509        // An entry hashed under the old {:.6} scheme must keep validating via hash_matches,
510        // so upgrading does not invalidate clean pre-v2 ledgers.
511        let mut e = ev();
512        e.prev_hash = "genesis".into();
513        e.entry_hash = compute_hash(&e.prev_hash, &e.canonical_content_legacy());
514        assert!(e.hash_matches(&e.prev_hash), "legacy v1 hash must verify");
515    }
516
517    #[test]
518    fn v2_hash_still_verifies_and_v3_commits_mechanism() {
519        // Pre-mechanism (v2) entries — including their JSON form without the
520        // field — must keep verifying after the v3 upgrade (enterprise#19).
521        let mut e = ev();
522        e.prev_hash = "genesis".into();
523        e.entry_hash = compute_hash(&e.prev_hash, &e.canonical_content_v2());
524        assert!(e.hash_matches(&e.prev_hash), "v2 hash must verify");
525
526        let json = serde_json::to_string(&e).unwrap();
527        let stripped = json.replace(r#""mechanism":"compression","#, "");
528        let parsed: SavingsEvent = serde_json::from_str(&stripped).unwrap();
529        assert_eq!(parsed.mechanism, MECHANISM_COMPRESSION, "serde default");
530        assert!(parsed.hash_matches(&parsed.prev_hash), "v2 after roundtrip");
531
532        // v3 commits the mechanism: rewriting the attribution breaks the hash.
533        let mut v3 = ev();
534        v3.mechanism = MECHANISM_ROUTING.into();
535        v3.prev_hash = "genesis".into();
536        v3.entry_hash = compute_hash(&v3.prev_hash, &v3.canonical_content());
537        assert!(v3.hash_matches(&v3.prev_hash));
538        let mut forged = v3.clone();
539        forged.mechanism = MECHANISM_COMPRESSION.into();
540        assert!(
541            !forged.hash_matches(&forged.prev_hash),
542            "reattributing a routing saving to compression must be tamper-evident"
543        );
544    }
545
546    #[test]
547    fn v3_hash_still_verifies_and_v4_commits_version() {
548        // Pre-version (v3) entries — including their JSON form without the
549        // field — must keep verifying after the v4 upgrade (#NNN).
550        let mut e = ev();
551        e.prev_hash = "genesis".into();
552        e.entry_hash = compute_hash(&e.prev_hash, &e.canonical_content_v3());
553        assert!(e.hash_matches(&e.prev_hash), "v3 hash must verify");
554
555        let json = serde_json::to_string(&e).unwrap();
556        // `version` is the last struct field, so its JSON key is preceded by
557        // a comma, not followed by one.
558        let stripped = json.replace(r#","version":"3.9.0""#, "");
559        let parsed: SavingsEvent = serde_json::from_str(&stripped).unwrap();
560        assert_eq!(parsed.version, "", "serde default for a pre-v4 entry");
561        assert!(parsed.hash_matches(&parsed.prev_hash), "v3 after roundtrip");
562
563        // v4 commits the version: rewriting it breaks the hash.
564        let mut v4 = ev();
565        v4.version = "3.8.18".into();
566        v4.prev_hash = "genesis".into();
567        v4.entry_hash = compute_hash(&v4.prev_hash, &v4.canonical_content());
568        assert!(v4.hash_matches(&v4.prev_hash));
569        let mut forged = v4.clone();
570        forged.version = "3.9.0".into();
571        assert!(
572            !forged.hash_matches(&forged.prev_hash),
573            "rewriting which version recorded a saving must be tamper-evident"
574        );
575    }
576
577    #[test]
578    fn micro_usd_quantizes_to_millionths() {
579        assert_eq!(micro_usd(2.5), 2_500_000);
580        assert_eq!(micro_usd(0.0), 0);
581        assert_eq!(micro_usd(0.000_001), 1);
582        // Determinism for a given f64 is the property the chain relies on (the exact rounding
583        // of a tie is irrelevant as long as it is reproducible).
584        let tie = 9423.0 * 2.5 / 1_000_000.0;
585        assert_eq!(micro_usd(tie), micro_usd(tie));
586    }
587    #[test]
588    fn v4_hash_still_verifies_after_v5_upgrade() {
589        let mut e = ev();
590        e.prev_hash = "genesis".into();
591        e.entry_hash = compute_hash(&e.prev_hash, &e.canonical_content_v4());
592        assert!(
593            e.hash_matches(&e.prev_hash),
594            "v4 hash must verify via hash_matches"
595        );
596    }
597
598    #[test]
599    fn v5_commits_p5_fields() {
600        let mut e = ev();
601        e.attribution_id = Some("attr_001".into());
602        e.measurement_method = Some(MeasurementMethod::DirectCount);
603        e.evidence_class = Some(EvidenceClass::Measured);
604        e.prev_hash = "genesis".into();
605        e.entry_hash = compute_hash(&e.prev_hash, &e.canonical_content());
606        assert!(e.hash_matches(&e.prev_hash));
607
608        let mut forged = e.clone();
609        forged.attribution_id = Some("attr_002".into());
610        assert!(
611            !forged.hash_matches(&forged.prev_hash),
612            "rewriting attribution_id must be tamper-evident"
613        );
614    }
615
616    #[test]
617    fn p5_fields_default_to_none_on_deserialize() {
618        let e = ev();
619        let json = serde_json::to_string(&e).unwrap();
620        let parsed: SavingsEvent = serde_json::from_str(&json).unwrap();
621        assert_eq!(parsed.attribution_id, None);
622        assert_eq!(parsed.measurement_method, None);
623        assert_eq!(parsed.evidence_class, None);
624        assert_eq!(parsed.customer_approval, None);
625        assert_eq!(parsed.settlement_status, None);
626    }
627
628    #[test]
629    fn p5_enums_serialize_roundtrip() {
630        let mut e = ev();
631        e.measurement_method = Some(MeasurementMethod::Holdout);
632        e.evidence_class = Some(EvidenceClass::Statistical);
633        e.customer_approval = Some(CustomerApproval::Approved);
634        e.settlement_status = Some(SettlementStatus::Eligible);
635        e.confidence = Some(0.95);
636        e.attribution_id = Some("blake3_abc".into());
637
638        let json = serde_json::to_string(&e).unwrap();
639        let parsed: SavingsEvent = serde_json::from_str(&json).unwrap();
640        assert_eq!(parsed.measurement_method, Some(MeasurementMethod::Holdout));
641        assert_eq!(parsed.evidence_class, Some(EvidenceClass::Statistical));
642        assert_eq!(parsed.customer_approval, Some(CustomerApproval::Approved));
643        assert_eq!(parsed.settlement_status, Some(SettlementStatus::Eligible));
644        assert_eq!(parsed.confidence, Some(0.95));
645        assert_eq!(parsed.attribution_id, Some("blake3_abc".into()));
646    }
647
648    #[test]
649    fn option_str_maps_none_to_underscore() {
650        assert_eq!(option_str(None), "_");
651        assert_eq!(option_str(Some(&"val".to_string())), "val");
652    }
653}