Skip to main content

aptu_core/
history.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Local contribution history tracking.
4//!
5//! Stores contribution records in `~/.local/share/aptu/history.json`.
6//! Each contribution tracks repo, issue, action, timestamp, and status.
7
8use std::fs;
9use std::path::PathBuf;
10
11use anyhow::{Context, Result};
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use uuid::Uuid;
15
16use crate::config::data_dir;
17
18// ETU weight constants. These are the structural Anthropic cache pricing ratios;
19// they belong here alongside AiStats rather than in the provider layer.
20const ETU_WEIGHT_INPUT: f64 = 1.0;
21/// Cache-read tokens cost 0.1× input price (90% discount). Stable since Claude 3.
22const ETU_WEIGHT_CACHE_READ: f64 = 0.1;
23/// Cache-write tokens cost 1.25× input price (5-min TTL). Confirmed May 2026.
24const ETU_WEIGHT_CACHE_WRITE: f64 = 1.25;
25/// Output tokens cost 5× input price across all current models. Stable since Claude 3.
26const ETU_WEIGHT_OUTPUT: f64 = 5.0;
27
28/// Compute Effective Token Units from raw token counts.
29///
30/// ETU = 1.0·input + 0.1·`cache_read` + 1.25·`cache_write` + 5.0·output
31///
32/// Weights are structural Anthropic cache pricing ratios (not per-model prices),
33/// stable across all model generations since Claude 3. No pricing table needed.
34#[allow(clippy::cast_precision_loss)]
35pub(crate) fn compute_etu(input: u64, cache_read: u64, cache_write: u64, output: u64) -> f64 {
36    ETU_WEIGHT_INPUT * input as f64
37        + ETU_WEIGHT_CACHE_READ * cache_read as f64
38        + ETU_WEIGHT_CACHE_WRITE * cache_write as f64
39        + ETU_WEIGHT_OUTPUT * output as f64
40}
41
42/// AI usage statistics for a contribution.
43#[derive(Debug, Clone, Default, Serialize, PartialEq)]
44pub struct AiStats {
45    /// Provider name (e.g., "openrouter", "anthropic").
46    pub provider: String,
47    /// Model used for analysis.
48    pub model: String,
49    /// Number of input tokens.
50    pub input_tokens: u64,
51    /// Number of output tokens.
52    pub output_tokens: u64,
53    /// Duration of the API call in milliseconds.
54    pub duration_ms: u64,
55    /// Cost in USD (from `OpenRouter` API, `None` if not reported).
56    #[serde(default)]
57    pub cost_usd: Option<f64>,
58    /// Fallback provider used if primary failed (None if primary succeeded).
59    #[serde(default)]
60    pub fallback_provider: Option<String>,
61    /// Prompt size in characters.
62    #[serde(default)]
63    pub prompt_chars: usize,
64    /// Number of cache read tokens (from Anthropic API).
65    #[serde(default)]
66    pub cache_read_tokens: u64,
67    /// Number of cache write tokens (from Anthropic API).
68    #[serde(default)]
69    pub cache_write_tokens: u64,
70    /// Effective Token Units: a normalized throughput signal comparable across operations.
71    /// Computed via [`compute_etu`]; see that function for the formula and weight rationale.
72    #[serde(default)]
73    pub effective_token_units: f64,
74    /// Trace ID for correlating with context records (optional, not serialized if None).
75    #[serde(default)]
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub trace_id: Option<String>,
78}
79
80impl AiStats {
81    /// Recompute and set `effective_token_units` from the current token counts.
82    ///
83    /// Call at the end of any construction chain to ensure ETU stays consistent
84    /// with the token fields rather than being set manually at each site.
85    #[must_use]
86    pub fn with_computed_etu(mut self) -> Self {
87        self.effective_token_units = compute_etu(
88            self.input_tokens,
89            self.cache_read_tokens,
90            self.cache_write_tokens,
91            self.output_tokens,
92        );
93        self
94    }
95}
96
97impl<'de> Deserialize<'de> for AiStats {
98    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
99    where
100        D: serde::Deserializer<'de>,
101    {
102        #[derive(Deserialize)]
103        struct Helper {
104            #[serde(default)]
105            provider: String,
106            #[serde(default)]
107            model: String,
108            #[serde(default)]
109            input_tokens: u64,
110            #[serde(default)]
111            output_tokens: u64,
112            #[serde(default)]
113            duration_ms: u64,
114            #[serde(default)]
115            cost_usd: Option<f64>,
116            #[serde(default)]
117            fallback_provider: Option<String>,
118            #[serde(default)]
119            prompt_chars: usize,
120            #[serde(default)]
121            cache_read_tokens: u64,
122            #[serde(default)]
123            cache_write_tokens: u64,
124            /// Ignored on deserialise; recomputed in the From impl.
125            #[serde(default)]
126            #[allow(dead_code)]
127            effective_token_units: f64,
128            #[serde(default)]
129            trace_id: Option<String>,
130        }
131
132        let h = Helper::deserialize(deserializer)?;
133        Ok(AiStats {
134            provider: h.provider,
135            model: h.model,
136            input_tokens: h.input_tokens,
137            output_tokens: h.output_tokens,
138            duration_ms: h.duration_ms,
139            cost_usd: h.cost_usd,
140            fallback_provider: h.fallback_provider,
141            prompt_chars: h.prompt_chars,
142            cache_read_tokens: h.cache_read_tokens,
143            cache_write_tokens: h.cache_write_tokens,
144            effective_token_units: 0.0,
145            trace_id: h.trace_id,
146        }
147        .with_computed_etu())
148    }
149}
150
151/// Status of a contribution.
152#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
153#[serde(rename_all = "lowercase")]
154pub enum ContributionStatus {
155    /// Contribution submitted, awaiting maintainer response.
156    #[default]
157    Pending,
158    /// Maintainer accepted the contribution.
159    Accepted,
160    /// Maintainer rejected the contribution.
161    Rejected,
162}
163
164/// A single contribution record.
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct Contribution {
167    /// Unique identifier.
168    pub id: Uuid,
169    /// Repository in "owner/repo" format.
170    pub repo: String,
171    /// Issue number.
172    pub issue: u64,
173    /// Action type (e.g., "triage").
174    pub action: String,
175    /// When the contribution was made.
176    pub timestamp: DateTime<Utc>,
177    /// URL to the posted comment.
178    pub comment_url: String,
179    /// Current status of the contribution.
180    #[serde(default)]
181    pub status: ContributionStatus,
182    /// AI usage statistics for this contribution.
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub ai_stats: Option<AiStats>,
185}
186
187/// Container for all contribution history.
188#[derive(Debug, Default, Serialize, Deserialize)]
189pub struct HistoryData {
190    /// List of contributions.
191    pub contributions: Vec<Contribution>,
192}
193
194impl HistoryData {
195    /// Calculate total tokens used across all contributions.
196    #[must_use]
197    pub fn total_tokens(&self) -> u64 {
198        self.contributions
199            .iter()
200            .filter_map(|c| c.ai_stats.as_ref())
201            .map(|stats| stats.input_tokens + stats.output_tokens)
202            .sum()
203    }
204
205    /// Calculate total cost in USD across all contributions.
206    #[must_use]
207    pub fn total_cost(&self) -> f64 {
208        self.contributions
209            .iter()
210            .filter_map(|c| c.ai_stats.as_ref())
211            .filter_map(|stats| stats.cost_usd)
212            .sum()
213    }
214
215    /// Calculate average tokens per triage.
216    #[must_use]
217    #[allow(clippy::cast_precision_loss)]
218    pub fn avg_tokens_per_triage(&self) -> f64 {
219        let contributions_with_stats: Vec<_> = self
220            .contributions
221            .iter()
222            .filter_map(|c| c.ai_stats.as_ref())
223            .collect();
224
225        if contributions_with_stats.is_empty() {
226            return 0.0;
227        }
228
229        let total: u64 = contributions_with_stats
230            .iter()
231            .map(|stats| stats.input_tokens + stats.output_tokens)
232            .sum();
233
234        total as f64 / contributions_with_stats.len() as f64
235    }
236
237    /// Calculate total cost grouped by model.
238    #[must_use]
239    pub fn cost_by_model(&self) -> std::collections::HashMap<String, f64> {
240        let mut costs = std::collections::HashMap::new();
241
242        for contribution in &self.contributions {
243            if let Some(stats) = &contribution.ai_stats
244                && let Some(cost) = stats.cost_usd
245            {
246                *costs.entry(stats.model.clone()).or_insert(0.0) += cost;
247            }
248        }
249
250        costs
251    }
252}
253
254/// Returns the path to the history file.
255#[must_use]
256pub fn history_file_path() -> PathBuf {
257    data_dir().join("history.json")
258}
259
260/// Load contribution history from disk.
261///
262/// Returns empty history if file doesn't exist.
263pub fn load() -> Result<HistoryData> {
264    let path = history_file_path();
265
266    if !path.exists() {
267        return Ok(HistoryData::default());
268    }
269
270    let contents = fs::read_to_string(&path)
271        .with_context(|| format!("Failed to read history file: {}", path.display()))?;
272
273    let data: HistoryData = serde_json::from_str(&contents)
274        .with_context(|| format!("Failed to parse history file: {}", path.display()))?;
275
276    Ok(data)
277}
278
279/// Save contribution history to disk.
280///
281/// Creates parent directories if they don't exist.
282pub fn save(data: &HistoryData) -> Result<()> {
283    let path = history_file_path();
284
285    // Create parent directories if needed
286    if let Some(parent) = path.parent() {
287        fs::create_dir_all(parent)
288            .with_context(|| format!("Failed to create directory: {}", parent.display()))?;
289    }
290
291    let contents =
292        serde_json::to_string_pretty(data).context("Failed to serialize history data")?;
293
294    fs::write(&path, contents)
295        .with_context(|| format!("Failed to write history file: {}", path.display()))?;
296
297    Ok(())
298}
299
300/// Add a contribution to history.
301///
302/// Loads existing history, appends the new contribution, and saves.
303pub fn add_contribution(contribution: Contribution) -> Result<()> {
304    let mut data = load()?;
305    data.contributions.push(contribution);
306    save(&data)?;
307    Ok(())
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    /// Create a test contribution.
315    fn test_contribution() -> Contribution {
316        Contribution {
317            id: Uuid::new_v4(),
318            repo: "owner/repo".to_string(),
319            issue: 123,
320            action: "triage".to_string(),
321            timestamp: Utc::now(),
322            comment_url: "https://github.com/owner/repo/issues/123#issuecomment-1".to_string(),
323            status: ContributionStatus::Pending,
324            ai_stats: None,
325        }
326    }
327
328    #[test]
329    fn test_contribution_serialization_roundtrip() {
330        let contribution = test_contribution();
331        let json = serde_json::to_string(&contribution).expect("serialize");
332        let parsed: Contribution = serde_json::from_str(&json).expect("deserialize");
333
334        assert_eq!(contribution.id, parsed.id);
335        assert_eq!(contribution.repo, parsed.repo);
336        assert_eq!(contribution.issue, parsed.issue);
337        assert_eq!(contribution.action, parsed.action);
338        assert_eq!(contribution.comment_url, parsed.comment_url);
339        assert_eq!(contribution.status, parsed.status);
340    }
341
342    #[test]
343    fn test_history_data_serialization_roundtrip() {
344        let data = HistoryData {
345            contributions: vec![test_contribution(), test_contribution()],
346        };
347
348        let json = serde_json::to_string_pretty(&data).expect("serialize");
349        let parsed: HistoryData = serde_json::from_str(&json).expect("deserialize");
350
351        assert_eq!(parsed.contributions.len(), 2);
352    }
353
354    #[test]
355    fn test_contribution_status_default() {
356        let status = ContributionStatus::default();
357        assert_eq!(status, ContributionStatus::Pending);
358    }
359
360    #[test]
361    fn test_contribution_status_serialization() {
362        assert_eq!(
363            serde_json::to_string(&ContributionStatus::Pending).unwrap(),
364            "\"pending\""
365        );
366        assert_eq!(
367            serde_json::to_string(&ContributionStatus::Accepted).unwrap(),
368            "\"accepted\""
369        );
370        assert_eq!(
371            serde_json::to_string(&ContributionStatus::Rejected).unwrap(),
372            "\"rejected\""
373        );
374    }
375
376    #[test]
377    fn test_empty_history_default() {
378        let data = HistoryData::default();
379        assert!(data.contributions.is_empty());
380    }
381
382    #[test]
383    fn test_ai_stats_serialization_roundtrip() {
384        let stats = AiStats {
385            provider: "openrouter".to_string(),
386            model: "mistralai/mistral-small-2603".to_string(),
387            input_tokens: 1000,
388            output_tokens: 500,
389            duration_ms: 1500,
390            cost_usd: Some(0.0),
391            fallback_provider: None,
392            prompt_chars: 0,
393            cache_read_tokens: 0,
394            cache_write_tokens: 0,
395            effective_token_units: 0.0,
396            trace_id: None,
397        };
398
399        let json = serde_json::to_string(&stats).expect("serialize");
400        let parsed: AiStats = serde_json::from_str(&json).expect("deserialize");
401
402        // After deserialization, ETU is always recomputed from token counts,
403        // so we compare all fields except effective_token_units.
404        assert_eq!(stats.provider, parsed.provider);
405        assert_eq!(stats.model, parsed.model);
406        assert_eq!(stats.input_tokens, parsed.input_tokens);
407        assert_eq!(stats.output_tokens, parsed.output_tokens);
408        assert_eq!(stats.duration_ms, parsed.duration_ms);
409        assert_eq!(stats.cost_usd, parsed.cost_usd);
410        assert_eq!(stats.fallback_provider, parsed.fallback_provider);
411        assert_eq!(stats.prompt_chars, parsed.prompt_chars);
412        assert_eq!(stats.cache_read_tokens, parsed.cache_read_tokens);
413        assert_eq!(stats.cache_write_tokens, parsed.cache_write_tokens);
414        assert_eq!(stats.trace_id, parsed.trace_id);
415        // ETU must be recomputed: 1000 input + 500*5 output = 3500.0
416        assert!((parsed.effective_token_units - 3500.0).abs() < f64::EPSILON);
417    }
418
419    #[test]
420    fn test_contribution_with_ai_stats() {
421        let mut contribution = test_contribution();
422        contribution.ai_stats = Some(AiStats {
423            provider: "openrouter".to_string(),
424            model: "mistralai/mistral-small-2603".to_string(),
425            input_tokens: 1000,
426            output_tokens: 500,
427            duration_ms: 1500,
428            cost_usd: Some(0.0),
429            fallback_provider: None,
430            prompt_chars: 0,
431            cache_read_tokens: 0,
432            cache_write_tokens: 0,
433            effective_token_units: 0.0,
434            trace_id: None,
435        });
436
437        let json = serde_json::to_string(&contribution).expect("serialize");
438        let parsed: Contribution = serde_json::from_str(&json).expect("deserialize");
439
440        assert!(parsed.ai_stats.is_some());
441        assert_eq!(
442            parsed.ai_stats.unwrap().model,
443            "mistralai/mistral-small-2603"
444        );
445    }
446
447    #[test]
448    fn test_contribution_without_ai_stats_backward_compat() {
449        let json = r#"{
450            "id": "550e8400-e29b-41d4-a716-446655440000",
451            "repo": "owner/repo",
452            "issue": 123,
453            "action": "triage",
454            "timestamp": "2024-01-01T00:00:00Z",
455            "comment_url": "https://github.com/owner/repo/issues/123#issuecomment-1",
456            "status": "pending"
457        }"#;
458
459        let parsed: Contribution = serde_json::from_str(json).expect("deserialize");
460        assert!(parsed.ai_stats.is_none());
461    }
462
463    #[test]
464    fn test_total_tokens() {
465        let mut data = HistoryData::default();
466
467        let mut c1 = test_contribution();
468        c1.ai_stats = Some(AiStats {
469            provider: "openrouter".to_string(),
470            model: "model1".to_string(),
471            input_tokens: 100,
472            output_tokens: 50,
473            duration_ms: 1000,
474            cost_usd: Some(0.01),
475            fallback_provider: None,
476            prompt_chars: 0,
477            cache_read_tokens: 0,
478            cache_write_tokens: 0,
479            effective_token_units: 0.0,
480            trace_id: None,
481        });
482
483        let mut c2 = test_contribution();
484        c2.ai_stats = Some(AiStats {
485            provider: "openrouter".to_string(),
486            model: "model2".to_string(),
487            input_tokens: 200,
488            output_tokens: 100,
489            duration_ms: 2000,
490            cost_usd: Some(0.02),
491            fallback_provider: None,
492            prompt_chars: 0,
493            cache_read_tokens: 0,
494            cache_write_tokens: 0,
495            effective_token_units: 0.0,
496            trace_id: None,
497        });
498
499        data.contributions.push(c1);
500        data.contributions.push(c2);
501        data.contributions.push(test_contribution()); // No stats
502
503        assert_eq!(data.total_tokens(), 450);
504    }
505
506    #[test]
507    fn test_total_cost() {
508        let mut data = HistoryData::default();
509
510        let mut c1 = test_contribution();
511        c1.ai_stats = Some(AiStats {
512            provider: "openrouter".to_string(),
513            model: "model1".to_string(),
514            input_tokens: 100,
515            output_tokens: 50,
516            duration_ms: 1000,
517            cost_usd: Some(0.01),
518            fallback_provider: None,
519            prompt_chars: 0,
520            cache_read_tokens: 0,
521            cache_write_tokens: 0,
522            effective_token_units: 0.0,
523            trace_id: None,
524        });
525
526        let mut c2 = test_contribution();
527        c2.ai_stats = Some(AiStats {
528            provider: "openrouter".to_string(),
529            model: "model2".to_string(),
530            input_tokens: 200,
531            output_tokens: 100,
532            duration_ms: 2000,
533            cost_usd: Some(0.02),
534            fallback_provider: None,
535            prompt_chars: 0,
536            cache_read_tokens: 0,
537            cache_write_tokens: 0,
538            effective_token_units: 0.0,
539            trace_id: None,
540        });
541
542        data.contributions.push(c1);
543        data.contributions.push(c2);
544
545        assert!((data.total_cost() - 0.03).abs() < f64::EPSILON);
546    }
547
548    #[test]
549    fn test_avg_tokens_per_triage() {
550        let mut data = HistoryData::default();
551
552        let mut c1 = test_contribution();
553        c1.ai_stats = Some(AiStats {
554            provider: "openrouter".to_string(),
555            model: "model1".to_string(),
556            input_tokens: 100,
557            output_tokens: 50,
558            duration_ms: 1000,
559            cost_usd: Some(0.01),
560            fallback_provider: None,
561            prompt_chars: 0,
562            cache_read_tokens: 0,
563            cache_write_tokens: 0,
564            effective_token_units: 0.0,
565            trace_id: None,
566        });
567
568        let mut c2 = test_contribution();
569        c2.ai_stats = Some(AiStats {
570            provider: "openrouter".to_string(),
571            model: "model2".to_string(),
572            input_tokens: 200,
573            output_tokens: 100,
574            duration_ms: 2000,
575            cost_usd: Some(0.02),
576            fallback_provider: None,
577            prompt_chars: 0,
578            cache_read_tokens: 0,
579            cache_write_tokens: 0,
580            effective_token_units: 0.0,
581            trace_id: None,
582        });
583
584        data.contributions.push(c1);
585        data.contributions.push(c2);
586
587        assert!((data.avg_tokens_per_triage() - 225.0).abs() < f64::EPSILON);
588    }
589
590    #[test]
591    fn test_avg_tokens_per_triage_empty() {
592        let data = HistoryData::default();
593        assert!((data.avg_tokens_per_triage() - 0.0).abs() < f64::EPSILON);
594    }
595
596    #[test]
597    fn test_cost_by_model() {
598        let mut data = HistoryData::default();
599
600        let mut c1 = test_contribution();
601        c1.ai_stats = Some(AiStats {
602            provider: "openrouter".to_string(),
603            model: "model1".to_string(),
604            input_tokens: 100,
605            output_tokens: 50,
606            duration_ms: 1000,
607            cost_usd: Some(0.01),
608            fallback_provider: None,
609            prompt_chars: 0,
610            cache_read_tokens: 0,
611            cache_write_tokens: 0,
612            effective_token_units: 0.0,
613            trace_id: None,
614        });
615
616        let mut c2 = test_contribution();
617        c2.ai_stats = Some(AiStats {
618            provider: "openrouter".to_string(),
619            model: "model1".to_string(),
620            input_tokens: 200,
621            output_tokens: 100,
622            duration_ms: 2000,
623            cost_usd: Some(0.02),
624            fallback_provider: None,
625            prompt_chars: 0,
626            cache_read_tokens: 0,
627            cache_write_tokens: 0,
628            effective_token_units: 0.0,
629            trace_id: None,
630        });
631
632        let mut c3 = test_contribution();
633        c3.ai_stats = Some(AiStats {
634            provider: "openrouter".to_string(),
635            model: "model2".to_string(),
636            input_tokens: 150,
637            output_tokens: 75,
638            duration_ms: 1500,
639            cost_usd: Some(0.015),
640            fallback_provider: None,
641            prompt_chars: 0,
642            cache_read_tokens: 0,
643            cache_write_tokens: 0,
644            effective_token_units: 0.0,
645            trace_id: None,
646        });
647
648        data.contributions.push(c1);
649        data.contributions.push(c2);
650        data.contributions.push(c3);
651
652        let costs = data.cost_by_model();
653        assert_eq!(costs.len(), 2);
654        assert!((costs.get("model1").unwrap() - 0.03).abs() < f64::EPSILON);
655        assert!((costs.get("model2").unwrap() - 0.015).abs() < f64::EPSILON);
656    }
657
658    #[test]
659    fn test_ai_stats_cache_tokens_roundtrip() {
660        let stats = AiStats {
661            provider: "anthropic".to_string(),
662            model: "claude-sonnet-4-6".to_string(),
663            input_tokens: 1000,
664            output_tokens: 500,
665            duration_ms: 1500,
666            cost_usd: Some(0.05),
667            fallback_provider: None,
668            prompt_chars: 5000,
669            cache_read_tokens: 100,
670            cache_write_tokens: 50,
671            effective_token_units: 0.0,
672            trace_id: None,
673        };
674
675        let json = serde_json::to_string(&stats).expect("serialize");
676        let parsed: AiStats = serde_json::from_str(&json).expect("deserialize");
677
678        // After deserialization, ETU is always recomputed from token counts,
679        // so we compare all fields except effective_token_units.
680        assert_eq!(stats.provider, parsed.provider);
681        assert_eq!(stats.model, parsed.model);
682        assert_eq!(stats.input_tokens, parsed.input_tokens);
683        assert_eq!(stats.output_tokens, parsed.output_tokens);
684        assert_eq!(stats.duration_ms, parsed.duration_ms);
685        assert_eq!(stats.cost_usd, parsed.cost_usd);
686        assert_eq!(stats.fallback_provider, parsed.fallback_provider);
687        assert_eq!(stats.prompt_chars, parsed.prompt_chars);
688        assert_eq!(stats.cache_read_tokens, 100);
689        assert_eq!(stats.cache_write_tokens, 50);
690        assert_eq!(parsed.cache_read_tokens, 100);
691        assert_eq!(parsed.cache_write_tokens, 50);
692        // ETU must be recomputed: 1000 input + 0.1*100 cache_read + 1.25*50 cache_write + 500*5 output = 3572.5
693        assert!((parsed.effective_token_units - 3572.5).abs() < f64::EPSILON);
694    }
695
696    #[test]
697    fn test_ai_stats_cache_tokens_default() {
698        let json = r#"{
699            "provider": "openrouter",
700            "model": "mistralai/mistral-small-2603",
701            "input_tokens": 1000,
702            "output_tokens": 500,
703            "duration_ms": 1500,
704            "cost_usd": 0.0,
705            "fallback_provider": null,
706            "prompt_chars": 0
707        }"#;
708
709        let parsed: AiStats = serde_json::from_str(json).expect("deserialize");
710
711        assert_eq!(parsed.cache_read_tokens, 0);
712        assert_eq!(parsed.cache_write_tokens, 0);
713    }
714
715    #[test]
716    fn test_etu_formula() {
717        // All four token classes with non-trivial values.
718        // input(1000) + cache_read(0.1*500=50) + cache_write(1.25*100=125) + output(5.0*200=1000) = 2175.0
719        let stats = AiStats {
720            input_tokens: 1000,
721            output_tokens: 200,
722            cache_read_tokens: 500,
723            cache_write_tokens: 100,
724            ..AiStats::default()
725        }
726        .with_computed_etu();
727        assert!((stats.effective_token_units - 2175.0).abs() < f64::EPSILON);
728    }
729
730    #[test]
731    fn test_etu_zero_on_default() {
732        // Zero inputs produce zero ETU; also covers the serde default path.
733        let stats = AiStats::default().with_computed_etu();
734        assert_eq!(stats.effective_token_units, 0.0);
735    }
736
737    #[test]
738    fn test_etu_recomputed_on_deserialize() {
739        // A JSON record with a stale/wrong effective_token_units value.
740        // After deserialization the field must be recomputed from token counts.
741        let json = r#"{
742            "provider": "anthropic",
743            "model": "claude-sonnet-4-6",
744            "input_tokens": 1000,
745            "output_tokens": 200,
746            "cache_read_tokens": 500,
747            "cache_write_tokens": 100,
748            "effective_token_units": 99999.0
749        }"#;
750        let stats: AiStats = serde_json::from_str(json).unwrap();
751        // Must equal compute_etu(1000, 500, 100, 200) = 2175.0, not 99999.0
752        assert!((stats.effective_token_units - 2175.0).abs() < f64::EPSILON);
753    }
754}