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// OSS savings verification workflow states.
68// Distinct from commercial Solution Audit Trail (Section 8)
69// which provides compliance-grade decision logging with approvals.
70/// Customer disposition of a savings claim (P5 — settlement path).
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
72#[serde(rename_all = "snake_case")]
73pub enum CustomerApproval {
74    /// No customer review yet.
75    Pending,
76    /// Customer accepted the savings claim.
77    Approved,
78    /// Customer disputed the savings claim.
79    Disputed,
80    /// Claim superseded by a correction event.
81    Superseded,
82}
83
84/// Settlement lifecycle state (P5 — billing pipeline).
85#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
86#[serde(rename_all = "snake_case")]
87pub enum SettlementStatus {
88    /// Not eligible for settlement (insufficient evidence).
89    Ineligible,
90    /// Evidence sufficient, awaiting approval.
91    Eligible,
92    /// Included in a settlement batch.
93    Settled,
94    /// Reversed after settlement.
95    Reversed,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
99pub struct SavingsEvent {
100    pub ts: String,
101    /// Originating tool (e.g. "ctx_read"). Coarse for now; per-mode granularity is a
102    /// later refinement (stats already tracks per-mode).
103    pub tool: String,
104    /// Savings mechanism this event attributes to: `compression` | `routing` |
105    /// `caching` (enterprise#19). Pre-v3 events carry no field and default to
106    /// `compression` — the only mechanism that existed when they were written.
107    #[serde(default = "default_mechanism")]
108    pub mechanism: String,
109    /// Resolved pricing model key the saving was valued against.
110    pub model_id: String,
111    /// Tokenizer family that produced `baseline_tokens`/`actual_tokens` (e.g.
112    /// `"o200k_base"`). Recorded separately from `model_id` because lean-ctx counts with
113    /// one tokenizer as a proxy; the model's own tokenizer may differ by a few percent.
114    pub tokenizer: String,
115    /// Counterfactual: tokens the agent would have consumed without lean-ctx.
116    pub baseline_tokens: u64,
117    /// Tokens actually sent.
118    pub actual_tokens: u64,
119    /// `baseline_tokens - actual_tokens`.
120    pub saved_tokens: u64,
121    /// Tokens later wasted by a compressed->full re-read (G7). Always 0 until a
122    /// *persisted* bounce signal exists — we never silently inflate with a guessed 0.
123    pub bounce_adjustment: u64,
124    /// Model input price per 1M tokens used to value the saving.
125    pub unit_price_per_m_usd: f64,
126    /// `(saved_tokens - bounce_adjustment) * unit_price_per_m_usd / 1e6`. Upper bound
127    /// (ignores prompt-cache discounts), consistent with the Wrapped headline.
128    pub saved_usd: f64,
129    /// Attribution: SHA-256 (truncated) of the recording process working directory.
130    /// Privacy-preserving — never the file path or its contents.
131    pub repo_hash: String,
132    pub agent_id: String,
133    pub prev_hash: String,
134    pub entry_hash: String,
135    /// lean-ctx version active when this event was recorded (`CARGO_PKG_VERSION`,
136    /// #NNN). Lets a `stats.json` rebuilt from the ledger (after corruption or
137    /// otherwise) recover the per-day version tag `lean-ctx gain --daily` shows,
138    /// which the ledger previously had no way to answer. Pre-v4 events default
139    /// to empty (unknown), never a guessed version.
140    #[serde(default = "default_version")]
141    pub version: String,
142
143    // ── P5 Unified Ledger Fields (all Option + serde(default) = backward-compat) ──
144    /// DIM 3: intent tag from IntentClassifier.
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub intent_tag: Option<String>,
147    /// Outcome of the context delivery: used | merged | sent | discarded | unknown.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub outcome: Option<String>,
150    /// DIM 3: originally requested model.
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub model_original: Option<String>,
153    /// DIM 3: actually routed model.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub model_routed: Option<String>,
156    /// DIM 3: tokens saved through routing (cheaper model, same content).
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub routing_savings: Option<u64>,
159    /// DIM 2: original response output tokens.
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub response_original_tokens: Option<u64>,
162    /// DIM 2: delivered response output tokens.
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub response_delivered_tokens: Option<u64>,
165    /// DIM 4: agent chain correlation ID.
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub agent_chain_id: Option<String>,
168    /// DIM 4: depth in the agent chain.
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub chain_depth: Option<u8>,
171
172    // ── P5 Evidence & Settlement Fields ──
173    /// How the savings were measured.
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub measurement_method: Option<MeasurementMethod>,
176    /// Trustworthiness class of the evidence.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub evidence_class: Option<EvidenceClass>,
179    /// Confidence score [0.0, 1.0] for the savings measurement.
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub confidence: Option<f64>,
182    // ── G2 Trace Correlation (#closure) ──
183    /// OCLA request ID that generated this savings event.
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub request_id: Option<String>,
186    /// Session ID from the agent context.
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub session_id: Option<String>,
189    /// Distributed trace ID for end-to-end correlation.
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub trace_id: Option<String>,
192
193    // ── Solution Intelligence Fields ──
194    /// Decision selected by the solution workflow.
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub solution_decision: Option<String>,
197    /// Lines added by the selected solution.
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub loc_added: Option<u64>,
200    /// Lines removed by the selected solution.
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub loc_removed: Option<u64>,
203
204    /// Path affected by an `edit` ledger event.
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub path: Option<String>,
207
208    /// Lines added by an `edit` ledger event.
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    pub lines_added: Option<u64>,
211
212    /// Lines removed by an `edit` ledger event.
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub lines_removed: Option<u64>,
215
216    /// Removed minus added for an `edit` ledger event; positive means a reduction.
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub net: Option<i64>,
219
220    /// Compression-ratio band (`excellent` ≥ 70 % removed … `marginal` < 30 %).
221    /// Despite the field name it says nothing about answer or context quality;
222    /// those live in `core::context_quality`. Name kept: events are hash-chained.
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub quality_signal: Option<String>,
225    /// Exclusive attribution group (no double-counting across groups).
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub attribution_group: Option<String>,
228    /// BLAKE3 hash identifying the attribution scope uniquely.
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub attribution_id: Option<String>,
231    /// Reference to the baseline used for counterfactual.
232    #[serde(default, skip_serializing_if = "Option::is_none")]
233    pub baseline_ref: Option<String>,
234    /// Pricing model version used for valuation.
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub price_version: Option<String>,
237    /// Customer approval state.
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub customer_approval: Option<CustomerApproval>,
240    /// Settlement lifecycle state.
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub settlement_status: Option<SettlementStatus>,
243
244    // ── G8 Token-Stream Attribution (#1191) ──
245    /// Whether this is a first-inject (turn 1 = cache_write rate) or re-read
246    /// (turn 2+ = cache_read rate). `None` for pre-G8 events.
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub is_first_inject: Option<bool>,
249    /// Cache-read rate for the model, if known at recording time.
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub cache_read_per_m_usd: Option<f64>,
252    /// Cache-write rate for the model, if known at recording time.
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub cache_write_per_m_usd: Option<f64>,
255
256    // ── Governor decision receipt (governor design v1 §5) ──
257    /// Which governor rule decided on a native tool result and how. `None` for
258    /// every non-governor event. Not part of the hash chain (`canonical_content`
259    /// stays v7): the receipt annotates an event whose token numbers are
260    /// committed as usual.
261    #[serde(default, skip_serializing_if = "Option::is_none")]
262    pub governor: Option<GovernorReceipt>,
263}
264
265/// One governor decision (`hook govern`). The receipt never reaches the model.
266#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
267pub struct GovernorReceipt {
268    /// `R1.read`, `R1.bash`, `R2` or `R3`.
269    pub rule_id: String,
270    /// Version of the rule's trigger and thresholds.
271    pub rule_version: u32,
272    /// `applied` (the model saw the smaller result) or `shadow` (logged only).
273    pub decision: String,
274    /// Host whose hook made the decision, e.g. `claude-code`.
275    pub host: String,
276    /// Truncated BLAKE3 of the host session id (never the id itself).
277    pub session_hash: String,
278    /// Tokens the rule removed or, for `shadow`, would have removed.
279    pub would_save_tokens: u64,
280}
281
282impl SavingsEvent {
283    /// Canonical (v7) representation: v6 + `session_id`, so a per-session
284    /// value claim (`lean-ctx value`) is backed by events whose session
285    /// attribution is committed to the chain and cannot be re-assigned.
286    /// Optional fields are committed as `option_str(field)` — `None` becomes "_"
287    /// (a sentinel that never appears in real values), so the hash is stable
288    /// regardless of whether the field was populated.
289    pub fn canonical_content(&self) -> String {
290        format!(
291            "v7|{}|{}",
292            self.canonical_content_v6()
293                .strip_prefix("v6|")
294                .unwrap_or_default(),
295            option_str(self.session_id.as_ref()),
296        )
297    }
298
299    /// v6 canonical: retained so v6-written ledgers verify after the session upgrade.
300    pub fn canonical_content_v6(&self) -> String {
301        format!(
302            "v6|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
303            self.ts,
304            self.tool,
305            self.mechanism,
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            self.version,
317            option_str(self.attribution_id.as_ref()),
318            option_str(self.intent_tag.as_ref()),
319            option_str(self.model_routed.as_ref()),
320            self.measurement_method.as_ref().map_or("_", |m| match m {
321                MeasurementMethod::DirectCount => "direct_count",
322                MeasurementMethod::Holdout => "holdout",
323                MeasurementMethod::BaselineEstimate => "baseline_estimate",
324                MeasurementMethod::ProviderReconciled => "provider_reconciled",
325                MeasurementMethod::Unknown => "unknown",
326            }),
327            self.evidence_class.as_ref().map_or("_", |e| match e {
328                EvidenceClass::Measured => "measured",
329                EvidenceClass::Approximated => "approximated",
330                EvidenceClass::Statistical => "statistical",
331                EvidenceClass::Declared => "declared",
332                EvidenceClass::Unclassified => "unclassified",
333            }),
334            option_str(self.path.as_ref()),
335            self.lines_added
336                .map_or_else(|| "_".to_string(), |value| value.to_string()),
337            self.lines_removed
338                .map_or_else(|| "_".to_string(), |value| value.to_string()),
339            self.net
340                .map_or_else(|| "_".to_string(), |value| value.to_string()),
341        )
342    }
343
344    /// v5 canonical: retained so v5-written ledgers verify after the edit event upgrade.
345    pub fn canonical_content_v5(&self) -> String {
346        format!(
347            "v5|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
348            self.ts,
349            self.tool,
350            self.mechanism,
351            self.model_id,
352            self.tokenizer,
353            self.baseline_tokens,
354            self.actual_tokens,
355            self.saved_tokens,
356            self.bounce_adjustment,
357            micro_usd(self.unit_price_per_m_usd),
358            micro_usd(self.saved_usd),
359            self.repo_hash,
360            self.agent_id,
361            self.version,
362            option_str(self.attribution_id.as_ref()),
363            option_str(self.intent_tag.as_ref()),
364            option_str(self.model_routed.as_ref()),
365            self.measurement_method.as_ref().map_or("_", |m| match m {
366                MeasurementMethod::DirectCount => "direct_count",
367                MeasurementMethod::Holdout => "holdout",
368                MeasurementMethod::BaselineEstimate => "baseline_estimate",
369                MeasurementMethod::ProviderReconciled => "provider_reconciled",
370                MeasurementMethod::Unknown => "unknown",
371            }),
372            self.evidence_class.as_ref().map_or("_", |e| match e {
373                EvidenceClass::Measured => "measured",
374                EvidenceClass::Approximated => "approximated",
375                EvidenceClass::Statistical => "statistical",
376                EvidenceClass::Declared => "declared",
377                EvidenceClass::Unclassified => "unclassified",
378            }),
379        )
380    }
381
382    /// v4 canonical: v3 + version field. Retained so v4-written ledgers verify.
383    pub fn canonical_content_v4(&self) -> String {
384        format!(
385            "v4|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
386            self.ts,
387            self.tool,
388            self.mechanism,
389            self.model_id,
390            self.tokenizer,
391            self.baseline_tokens,
392            self.actual_tokens,
393            self.saved_tokens,
394            self.bounce_adjustment,
395            micro_usd(self.unit_price_per_m_usd),
396            micro_usd(self.saved_usd),
397            self.repo_hash,
398            self.agent_id,
399            self.version,
400        )
401    }
402
403    /// v3 canonical (pre-`version`): v2 + the `mechanism` attribution field
404    /// (enterprise#19). Retained so ledgers written between the v3 fix and v4
405    /// keep verifying unchanged.
406    pub fn canonical_content_v3(&self) -> String {
407        format!(
408            "v3|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
409            self.ts,
410            self.tool,
411            self.mechanism,
412            self.model_id,
413            self.tokenizer,
414            self.baseline_tokens,
415            self.actual_tokens,
416            self.saved_tokens,
417            self.bounce_adjustment,
418            micro_usd(self.unit_price_per_m_usd),
419            micro_usd(self.saved_usd),
420            self.repo_hash,
421            self.agent_id,
422        )
423    }
424
425    /// v2 canonical (pre-`mechanism`): integer micro-USD money, no attribution field.
426    /// Retained so ledgers written between the v2 fix and v3 keep verifying unchanged.
427    pub fn canonical_content_v2(&self) -> String {
428        format!(
429            "v2|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
430            self.ts,
431            self.tool,
432            self.model_id,
433            self.tokenizer,
434            self.baseline_tokens,
435            self.actual_tokens,
436            self.saved_tokens,
437            self.bounce_adjustment,
438            micro_usd(self.unit_price_per_m_usd),
439            micro_usd(self.saved_usd),
440            self.repo_hash,
441            self.agent_id,
442        )
443    }
444
445    /// Legacy (v1) canonical: `{:.6}` of the raw `f64` money fields. Retained only so
446    /// `verify` keeps validating pre-v2 ledgers that never hit a tie value; new appends and
447    /// re-chained ledgers always use [`Self::canonical_content`].
448    pub fn canonical_content_legacy(&self) -> String {
449        format!(
450            "{}|{}|{}|{}|{}|{}|{}|{}|{:.6}|{:.6}|{}|{}",
451            self.ts,
452            self.tool,
453            self.model_id,
454            self.tokenizer,
455            self.baseline_tokens,
456            self.actual_tokens,
457            self.saved_tokens,
458            self.bounce_adjustment,
459            self.unit_price_per_m_usd,
460            self.saved_usd,
461            self.repo_hash,
462            self.agent_id,
463        )
464    }
465
466    /// True if `entry_hash` matches the current canonical hash or a prior schema version.
467    /// Accepting all versions lets `verify` validate ledgers
468    /// written under any scheme without forcing a migration (clean old ledgers stay
469    /// valid; broken-by-bug ones are repaired by `rechain`, which re-hashes under v4).
470    pub fn hash_matches(&self, prev_hash: &str) -> bool {
471        self.entry_hash == compute_hash(prev_hash, &self.canonical_content())
472            || self.entry_hash == compute_hash(prev_hash, &self.canonical_content_v6())
473            || self.entry_hash == compute_hash(prev_hash, &self.canonical_content_v5())
474            || self.entry_hash == compute_hash(prev_hash, &self.canonical_content_v4())
475            || self.entry_hash == compute_hash(prev_hash, &self.canonical_content_v3())
476            || self.entry_hash == compute_hash(prev_hash, &self.canonical_content_v2())
477            || self.entry_hash == compute_hash(prev_hash, &self.canonical_content_legacy())
478    }
479}
480
481/// Rounds a USD amount to integer micro-USD (millionths of a dollar) — the float-free money
482/// unit committed by the v2 hash chain.
483///
484/// A *half*-micro-USD tie (e.g. `7831 tokens * $2.5/M = 19577.5 µ$`) is the one input where a
485/// bare `(usd * 1e6).round()` is fragile: the scaled product computed at the append call site
486/// and the value recomputed at the verify call site can differ by a sub-ULP amount (float-op
487/// contraction / a different inlining context), landing on opposite sides of `.5` and breaking
488/// the chain for *untampered* data. Nudging by a sub-micro epsilon before rounding resolves the
489/// tie identically at every call site. `1e-6 µ$` (= `1e-12 USD`) is far below any real monetary
490/// unit and only ever moves a value sitting on the tie, so reported totals are unaffected.
491fn micro_usd(usd: f64) -> i64 {
492    const TIE_EPSILON_MICRO: f64 = 1e-6;
493    let scaled = usd * 1_000_000.0;
494    (scaled + TIE_EPSILON_MICRO.copysign(scaled)).round() as i64
495}
496
497/// Maps `Option<String>` to a stable canonical form: the value or `"_"` for None.
498fn option_str(opt: Option<&String>) -> &str {
499    opt.map_or("_", String::as_str)
500}
501
502/// `SHA-256(prev_hash || content)` as lowercase hex — the chain link primitive.
503pub fn compute_hash(prev_hash: &str, content: &str) -> String {
504    let mut hasher = Sha256::new();
505    hasher.update(prev_hash.as_bytes());
506    hasher.update(content.as_bytes());
507    crate::core::agent_identity::hex_encode(&hasher.finalize())
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513
514    fn ev() -> SavingsEvent {
515        SavingsEvent {
516            ts: "2026-06-01T00:00:00+00:00".into(),
517            tool: "ctx_read".into(),
518            mechanism: MECHANISM_COMPRESSION.into(),
519            model_id: "claude-3.5-sonnet".into(),
520            tokenizer: "o200k_base".into(),
521            baseline_tokens: 1000,
522            actual_tokens: 300,
523            saved_tokens: 700,
524            bounce_adjustment: 0,
525            unit_price_per_m_usd: 3.0,
526            saved_usd: 0.0021,
527            repo_hash: "abc123".into(),
528            agent_id: "local".into(),
529            prev_hash: String::new(),
530            entry_hash: String::new(),
531            version: "3.9.0".into(),
532            intent_tag: None,
533            outcome: None,
534            model_original: None,
535            model_routed: None,
536            routing_savings: None,
537            response_original_tokens: None,
538            response_delivered_tokens: None,
539            agent_chain_id: None,
540            chain_depth: None,
541            measurement_method: None,
542            evidence_class: None,
543            confidence: None,
544            request_id: None,
545            session_id: None,
546            trace_id: None,
547            solution_decision: None,
548            loc_added: None,
549            loc_removed: None,
550            path: None,
551            lines_added: None,
552            lines_removed: None,
553            net: None,
554            quality_signal: None,
555            attribution_group: None,
556            attribution_id: None,
557            baseline_ref: None,
558            price_version: None,
559            customer_approval: None,
560            settlement_status: None,
561            is_first_inject: None,
562            cache_read_per_m_usd: None,
563            cache_write_per_m_usd: None,
564            governor: None,
565        }
566    }
567
568    #[test]
569    fn hash_is_deterministic() {
570        let e = ev();
571        let a = compute_hash("genesis", &e.canonical_content());
572        let b = compute_hash("genesis", &e.canonical_content());
573        assert_eq!(a, b);
574        assert_eq!(a.len(), 64, "sha-256 hex is 64 chars");
575    }
576
577    #[test]
578    fn hash_changes_when_content_changes() {
579        let mut e = ev();
580        let a = compute_hash("genesis", &e.canonical_content());
581        e.saved_tokens = 701;
582        let b = compute_hash("genesis", &e.canonical_content());
583        assert_ne!(a, b, "tampering with a content field must change the hash");
584    }
585
586    #[test]
587    fn hash_depends_on_prev() {
588        let e = ev();
589        let a = compute_hash("genesis", &e.canonical_content());
590        let b = compute_hash("other", &e.canonical_content());
591        assert_ne!(a, b, "chain link must depend on prev_hash");
592    }
593
594    /// Regression: `saved_usd = 0.0235575` is a 6th-decimal tie that broke the legacy
595    /// `{:.6}` chain after a JSON round-trip. The v2 integer-micro-USD canonical must be
596    /// stable across serialize -> deserialize so `verify` accepts an untampered entry.
597    #[test]
598    fn v2_hash_is_roundtrip_stable_on_decimal_tie() {
599        let mut e = ev();
600        e.saved_tokens = 9423;
601        e.unit_price_per_m_usd = 2.5;
602        e.saved_usd = 9423.0 * 2.5 / 1_000_000.0; // = 0.0235575, a {:.6} tie
603        e.prev_hash = "genesis".into();
604        e.entry_hash = compute_hash(&e.prev_hash, &e.canonical_content_v4());
605
606        let json = serde_json::to_string(&e).unwrap();
607        let parsed: SavingsEvent = serde_json::from_str(&json).unwrap();
608
609        assert!(
610            parsed.hash_matches(&parsed.prev_hash),
611            "v2 chain must survive a JSON round-trip on a decimal-tie value"
612        );
613    }
614
615    /// Regression: the production recorder values a read as `saved_tokens / 1e6 * price`, whose
616    /// result for `7831 tokens @ $2.5/M` lands on a half-micro-USD tie (`19577.5 µ$`). That tie
617    /// broke the v2 chain on a fresh, untampered ledger. The tie-stable [`micro_usd`] must make
618    /// append and verify agree across a JSON round-trip regardless of the computation order.
619    #[test]
620    fn v2_hash_is_roundtrip_stable_on_production_order_tie() {
621        let mut e = ev();
622        e.saved_tokens = 7831;
623        e.unit_price_per_m_usd = 2.5;
624        // Same order as `record_read_event`: divide first, then multiply.
625        e.saved_usd = e.saved_tokens as f64 / 1_000_000.0 * e.unit_price_per_m_usd;
626        e.prev_hash = "genesis".into();
627        e.entry_hash = compute_hash(&e.prev_hash, &e.canonical_content_v5());
628
629        let json = serde_json::to_string(&e).unwrap();
630        let parsed: SavingsEvent = serde_json::from_str(&json).unwrap();
631        assert!(
632            parsed.hash_matches(&parsed.prev_hash),
633            "v2 chain must survive a JSON round-trip on a production-order half-micro tie"
634        );
635    }
636
637    #[test]
638    fn micro_usd_resolves_half_micro_ties_consistently() {
639        // A value exactly on the tie and a value one ULP below it must quantize the same way,
640        // so an append/verify pair that observes either side of the tie still agrees.
641        let tie = 19_577.5_f64 / 1_000_000.0;
642        let below = f64::from_bits(tie.to_bits() - 1);
643        assert_eq!(micro_usd(tie), micro_usd(below));
644    }
645
646    #[test]
647    fn legacy_v1_hash_still_verifies() {
648        // An entry hashed under the old {:.6} scheme must keep validating via hash_matches,
649        // so upgrading does not invalidate clean pre-v2 ledgers.
650        let mut e = ev();
651        e.prev_hash = "genesis".into();
652        e.entry_hash = compute_hash(&e.prev_hash, &e.canonical_content_legacy());
653        assert!(e.hash_matches(&e.prev_hash), "legacy v1 hash must verify");
654    }
655
656    #[test]
657    fn v2_hash_still_verifies_and_v3_commits_mechanism() {
658        // Pre-mechanism (v2) entries — including their JSON form without the
659        // field — must keep verifying after the v3 upgrade (enterprise#19).
660        let mut e = ev();
661        e.prev_hash = "genesis".into();
662        e.entry_hash = compute_hash(&e.prev_hash, &e.canonical_content_v2());
663        assert!(e.hash_matches(&e.prev_hash), "v2 hash must verify");
664
665        let json = serde_json::to_string(&e).unwrap();
666        let stripped = json.replace(r#""mechanism":"compression","#, "");
667        let parsed: SavingsEvent = serde_json::from_str(&stripped).unwrap();
668        assert_eq!(parsed.mechanism, MECHANISM_COMPRESSION, "serde default");
669        assert!(parsed.hash_matches(&parsed.prev_hash), "v2 after roundtrip");
670
671        // v3 commits the mechanism: rewriting the attribution breaks the hash.
672        let mut v3 = ev();
673        v3.mechanism = MECHANISM_ROUTING.into();
674        v3.prev_hash = "genesis".into();
675        v3.entry_hash = compute_hash(&v3.prev_hash, &v3.canonical_content());
676        assert!(v3.hash_matches(&v3.prev_hash));
677        let mut forged = v3.clone();
678        forged.mechanism = MECHANISM_COMPRESSION.into();
679        assert!(
680            !forged.hash_matches(&forged.prev_hash),
681            "reattributing a routing saving to compression must be tamper-evident"
682        );
683    }
684
685    #[test]
686    fn v3_hash_still_verifies_and_v4_commits_version() {
687        // Pre-version (v3) entries — including their JSON form without the
688        // field — must keep verifying after the v4 upgrade (#NNN).
689        let mut e = ev();
690        e.prev_hash = "genesis".into();
691        e.entry_hash = compute_hash(&e.prev_hash, &e.canonical_content_v3());
692        assert!(e.hash_matches(&e.prev_hash), "v3 hash must verify");
693
694        let json = serde_json::to_string(&e).unwrap();
695        // `version` is the last struct field, so its JSON key is preceded by
696        // a comma, not followed by one.
697        let stripped = json.replace(r#","version":"3.9.0""#, "");
698        let parsed: SavingsEvent = serde_json::from_str(&stripped).unwrap();
699        assert_eq!(parsed.version, "", "serde default for a pre-v4 entry");
700        assert!(parsed.hash_matches(&parsed.prev_hash), "v3 after roundtrip");
701
702        // v4 commits the version: rewriting it breaks the hash.
703        let mut v4 = ev();
704        v4.version = "3.8.18".into();
705        v4.prev_hash = "genesis".into();
706        v4.entry_hash = compute_hash(&v4.prev_hash, &v4.canonical_content_v4());
707        assert!(v4.hash_matches(&v4.prev_hash));
708        let mut forged = v4.clone();
709        forged.version = "3.9.0".into();
710        assert!(
711            !forged.hash_matches(&forged.prev_hash),
712            "rewriting which version recorded a saving must be tamper-evident"
713        );
714    }
715
716    #[test]
717    fn micro_usd_quantizes_to_millionths() {
718        assert_eq!(micro_usd(2.5), 2_500_000);
719        assert_eq!(micro_usd(0.0), 0);
720        assert_eq!(micro_usd(0.000_001), 1);
721        // Determinism for a given f64 is the property the chain relies on (the exact rounding
722        // of a tie is irrelevant as long as it is reproducible).
723        let tie = 9423.0 * 2.5 / 1_000_000.0;
724        assert_eq!(micro_usd(tie), micro_usd(tie));
725    }
726    #[test]
727    fn v4_hash_still_verifies_after_v5_upgrade() {
728        let mut e = ev();
729        e.prev_hash = "genesis".into();
730        e.entry_hash = compute_hash(&e.prev_hash, &e.canonical_content_v4());
731        assert!(
732            e.hash_matches(&e.prev_hash),
733            "v4 hash must verify via hash_matches"
734        );
735    }
736
737    #[test]
738    fn v5_commits_p5_fields() {
739        let mut e = ev();
740        e.attribution_id = Some("attr_001".into());
741        e.measurement_method = Some(MeasurementMethod::DirectCount);
742        e.evidence_class = Some(EvidenceClass::Measured);
743        e.prev_hash = "genesis".into();
744        e.entry_hash = compute_hash(&e.prev_hash, &e.canonical_content_v5());
745        assert!(e.hash_matches(&e.prev_hash));
746
747        let mut forged = e.clone();
748        forged.attribution_id = Some("attr_002".into());
749        assert!(
750            !forged.hash_matches(&forged.prev_hash),
751            "rewriting attribution_id must be tamper-evident"
752        );
753    }
754
755    #[test]
756    fn v6_commits_edit_fields() {
757        let mut e = ev();
758        e.path = Some("rust/src/core/edit_metering.rs".into());
759        e.lines_added = Some(2);
760        e.lines_removed = Some(5);
761        e.net = Some(3);
762        e.prev_hash = "genesis".into();
763        e.entry_hash = compute_hash(&e.prev_hash, &e.canonical_content());
764        assert!(e.hash_matches(&e.prev_hash));
765
766        let mut forged = e.clone();
767        forged.net = Some(2);
768        assert!(
769            !forged.hash_matches(&forged.prev_hash),
770            "rewriting edit LOC must be tamper-evident"
771        );
772    }
773
774    #[test]
775    fn v7_commits_session_id() {
776        let mut e = ev();
777        e.session_id = Some("sess-a".into());
778        e.prev_hash = "genesis".into();
779        e.entry_hash = compute_hash(&e.prev_hash, &e.canonical_content());
780        assert!(e.canonical_content().starts_with("v7|"));
781        assert!(e.hash_matches(&e.prev_hash));
782
783        let mut moved = e.clone();
784        moved.session_id = Some("sess-b".into());
785        assert!(
786            !moved.hash_matches(&moved.prev_hash),
787            "re-attributing an event to another session must be tamper-evident"
788        );
789
790        let mut v6 = ev();
791        v6.prev_hash = "genesis".into();
792        v6.entry_hash = compute_hash(&v6.prev_hash, &v6.canonical_content_v6());
793        assert!(v6.hash_matches(&v6.prev_hash), "v6 ledgers keep verifying");
794    }
795
796    #[test]
797    fn p5_fields_default_to_none_on_deserialize() {
798        let e = ev();
799        let json = serde_json::to_string(&e).unwrap();
800        let parsed: SavingsEvent = serde_json::from_str(&json).unwrap();
801        assert_eq!(parsed.attribution_id, None);
802        assert_eq!(parsed.measurement_method, None);
803        assert_eq!(parsed.evidence_class, None);
804        assert_eq!(parsed.customer_approval, None);
805        assert_eq!(parsed.settlement_status, None);
806    }
807
808    #[test]
809    fn p5_enums_serialize_roundtrip() {
810        let mut e = ev();
811        e.measurement_method = Some(MeasurementMethod::Holdout);
812        e.evidence_class = Some(EvidenceClass::Statistical);
813        e.customer_approval = Some(CustomerApproval::Approved);
814        e.settlement_status = Some(SettlementStatus::Eligible);
815        e.confidence = Some(0.95);
816        e.attribution_id = Some("blake3_abc".into());
817
818        let json = serde_json::to_string(&e).unwrap();
819        let parsed: SavingsEvent = serde_json::from_str(&json).unwrap();
820        assert_eq!(parsed.measurement_method, Some(MeasurementMethod::Holdout));
821        assert_eq!(parsed.evidence_class, Some(EvidenceClass::Statistical));
822        assert_eq!(parsed.customer_approval, Some(CustomerApproval::Approved));
823        assert_eq!(parsed.settlement_status, Some(SettlementStatus::Eligible));
824        assert_eq!(parsed.confidence, Some(0.95));
825        assert_eq!(parsed.attribution_id, Some("blake3_abc".into()));
826    }
827
828    #[test]
829    fn governor_receipt_is_optional_and_outside_the_hash_chain() {
830        let plain = ev();
831        let json = serde_json::to_string(&plain).unwrap();
832        assert!(
833            !json.contains("governor"),
834            "absent receipt is not serialised"
835        );
836        let parsed: SavingsEvent = serde_json::from_str(&json).unwrap();
837        assert_eq!(parsed.governor, None, "pre-governor events still parse");
838
839        let mut with = plain.clone();
840        with.governor = Some(GovernorReceipt {
841            rule_id: "R1.bash".into(),
842            rule_version: 1,
843            decision: "shadow".into(),
844            host: "claude-code".into(),
845            session_hash: "0123456789abcdef".into(),
846            would_save_tokens: 900,
847        });
848        let back: SavingsEvent =
849            serde_json::from_str(&serde_json::to_string(&with).unwrap()).unwrap();
850        assert_eq!(back.governor, with.governor);
851        assert_eq!(
852            with.canonical_content(),
853            plain.canonical_content(),
854            "the v7 chain contract is unchanged"
855        );
856    }
857
858    #[test]
859    fn option_str_maps_none_to_underscore() {
860        assert_eq!(option_str(None), "_");
861        assert_eq!(option_str(Some(&"val".to_string())), "val");
862    }
863}