Skip to main content

coding_agent_search/analytics/
derive.rs

1//! Derived metric computation for analytics buckets.
2//!
3//! All division operations are safe against zero denominators and produce
4//! `None` (rendered as JSON `null`) rather than NaN / Infinity.
5
6use super::types::{DerivedMetrics, UsageBucket};
7
8/// Compute all derived metrics from a [`UsageBucket`].
9pub fn compute_derived(bucket: &UsageBucket) -> DerivedMetrics {
10    let api_coverage_pct = safe_pct(bucket.api_coverage_message_count, bucket.message_count);
11
12    let api_tokens_per_assistant_msg =
13        safe_div(bucket.api_tokens_total, bucket.assistant_message_count);
14
15    let content_tokens_per_user_msg =
16        safe_div(bucket.content_tokens_est_total, bucket.user_message_count);
17
18    let tool_calls_per_1k_api_tokens = if bucket.api_tokens_total > 0 {
19        Some(bucket.tool_call_count as f64 / (bucket.api_tokens_total as f64 / 1000.0))
20    } else {
21        None
22    };
23
24    let tool_calls_per_1k_content_tokens = if bucket.content_tokens_est_total > 0 {
25        Some(bucket.tool_call_count as f64 / (bucket.content_tokens_est_total as f64 / 1000.0))
26    } else {
27        None
28    };
29
30    let plan_message_pct = if bucket.message_count > 0 {
31        Some((bucket.plan_message_count as f64 / bucket.message_count as f64) * 100.0)
32    } else {
33        None
34    };
35
36    let plan_token_share_content = safe_div(
37        bucket.plan_content_tokens_est_total,
38        bucket.content_tokens_est_total,
39    );
40    let plan_token_share_api = safe_div(bucket.plan_api_tokens_total, bucket.api_tokens_total);
41
42    DerivedMetrics {
43        api_coverage_pct,
44        api_tokens_per_assistant_msg,
45        content_tokens_per_user_msg,
46        tool_calls_per_1k_api_tokens,
47        tool_calls_per_1k_content_tokens,
48        plan_message_pct,
49        plan_token_share_content,
50        plan_token_share_api,
51    }
52}
53
54/// Percentage safe against zero denominator.  Returns 0.0 when denominator is
55/// zero. Result is rounded to 2 decimal places (matching the original CLI
56/// rounding: `(pct * 100.0).round() / 100.0`).
57pub fn safe_pct(numerator: i64, denominator: i64) -> f64 {
58    if denominator == 0 {
59        0.0
60    } else {
61        let pct = (numerator as f64 / denominator as f64) * 100.0;
62        (pct * 100.0).round() / 100.0
63    }
64}
65
66/// Safe division returning `None` when the denominator is zero.
67pub fn safe_div(numerator: i64, denominator: i64) -> Option<f64> {
68    if denominator == 0 {
69        None
70    } else {
71        Some(numerator as f64 / denominator as f64)
72    }
73}
74
75/// Safe division for f64 numerator with i64 denominator.
76/// Returns `None` when the denominator is zero.
77pub fn safe_div_f64(numerator: f64, denominator: i64) -> Option<f64> {
78    if denominator == 0 {
79        None
80    } else {
81        Some(numerator / denominator as f64)
82    }
83}
84
85// ---------------------------------------------------------------------------
86// Tests
87// ---------------------------------------------------------------------------
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    #[test]
94    fn safe_div_zero_denominator() {
95        assert_eq!(safe_div(100, 0), None);
96    }
97
98    #[test]
99    fn safe_div_normal() {
100        assert_eq!(safe_div(100, 50), Some(2.0));
101    }
102
103    #[test]
104    fn safe_div_f64_zero_denominator() {
105        assert_eq!(safe_div_f64(1.50, 0), None);
106    }
107
108    #[test]
109    fn safe_div_f64_normal() {
110        let result = safe_div_f64(3.0, 2);
111        assert_eq!(result, Some(1.5));
112    }
113
114    #[test]
115    fn safe_pct_zero_denominator() {
116        assert_eq!(safe_pct(50, 0), 0.0);
117    }
118
119    #[test]
120    fn safe_pct_normal() {
121        let pct = safe_pct(75, 100);
122        assert!((pct - 75.0).abs() < 0.01);
123    }
124
125    #[test]
126    fn safe_pct_rounding() {
127        // 1/3 = 33.333...% → should round to 33.33
128        let pct = safe_pct(1, 3);
129        assert!((pct - 33.33).abs() < 0.01);
130    }
131
132    #[test]
133    fn compute_derived_empty_bucket() {
134        let bucket = UsageBucket::default();
135        let d = compute_derived(&bucket);
136        assert_eq!(d.api_coverage_pct, 0.0);
137        assert_eq!(d.api_tokens_per_assistant_msg, None);
138        assert_eq!(d.content_tokens_per_user_msg, None);
139        assert_eq!(d.tool_calls_per_1k_api_tokens, None);
140        assert_eq!(d.tool_calls_per_1k_content_tokens, None);
141        assert_eq!(d.plan_message_pct, None);
142        assert_eq!(d.plan_token_share_content, None);
143        assert_eq!(d.plan_token_share_api, None);
144    }
145
146    #[test]
147    fn compute_derived_realistic_bucket() {
148        let bucket = UsageBucket {
149            message_count: 100,
150            user_message_count: 50,
151            assistant_message_count: 50,
152            tool_call_count: 10,
153            plan_message_count: 5,
154            plan_content_tokens_est_total: 2_500,
155            plan_api_tokens_total: 3_000,
156            api_coverage_message_count: 80,
157            content_tokens_est_total: 50_000,
158            api_tokens_total: 60_000,
159            estimated_cost_usd: 3.00,
160            ..Default::default()
161        };
162        let d = compute_derived(&bucket);
163        assert!((d.api_coverage_pct - 80.0).abs() < 0.01);
164        assert_eq!(d.api_tokens_per_assistant_msg, Some(1200.0));
165        assert_eq!(d.content_tokens_per_user_msg, Some(1000.0));
166        assert!(d.tool_calls_per_1k_api_tokens.is_some());
167        assert!(d.tool_calls_per_1k_content_tokens.is_some());
168        assert!((d.plan_message_pct.unwrap() - 5.0).abs() < 0.01);
169        assert_eq!(d.plan_token_share_content, Some(0.05));
170        assert_eq!(d.plan_token_share_api, Some(0.05));
171    }
172
173    #[test]
174    fn no_nan_or_infinity() {
175        // Even with weird values, we should never get NaN or Infinity
176        let bucket = UsageBucket {
177            message_count: 0,
178            api_tokens_total: 0,
179            content_tokens_est_total: 0,
180            ..Default::default()
181        };
182        let d = compute_derived(&bucket);
183        assert!(!d.api_coverage_pct.is_nan());
184        assert!(!d.api_coverage_pct.is_infinite());
185    }
186}