Skip to main content

ccboard_core/analytics/
optimization.rs

1//! Cost optimization suggestion engine
2//!
3//! Analyzes plugin analytics and tool token usage to generate actionable
4//! suggestions for reducing Claude Code costs.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::sync::Arc;
9
10use crate::analytics::plugin_usage::PluginAnalytics;
11use crate::models::session::SessionMetadata;
12
13/// Category of a cost optimization suggestion
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15pub enum OptimizationCategory {
16    /// A plugin/skill/command that is defined but never invoked
17    UnusedPlugin,
18    /// A tool consuming a disproportionate share of tokens
19    HighCostTool,
20    /// Opportunity to downgrade model for simpler tasks
21    ModelDowngrade,
22    /// Repeated identical tool calls that could be cached/batched
23    RedundantCalls,
24}
25
26impl OptimizationCategory {
27    /// Human-readable label
28    pub fn label(&self) -> &'static str {
29        match self {
30            Self::UnusedPlugin => "Unused Plugin",
31            Self::HighCostTool => "High-Cost Tool",
32            Self::ModelDowngrade => "Model Downgrade",
33            Self::RedundantCalls => "Redundant Calls",
34        }
35    }
36
37    /// Icon for TUI/Web display
38    pub fn icon(&self) -> &'static str {
39        match self {
40            Self::UnusedPlugin => "🗑️",
41            Self::HighCostTool => "🔥",
42            Self::ModelDowngrade => "⬇️",
43            Self::RedundantCalls => "♻️",
44        }
45    }
46}
47
48/// A single actionable cost optimization suggestion
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct CostSuggestion {
51    /// Category for grouping
52    pub category: OptimizationCategory,
53    /// Short title (displayed in list)
54    pub title: String,
55    /// Full description with supporting data
56    pub description: String,
57    /// Estimated monthly savings in dollars (0 if unknown)
58    pub potential_savings: f64,
59    /// Concrete action the user can take
60    pub action: String,
61}
62
63/// Generate cost optimization suggestions from available analytics data
64///
65/// # Arguments
66/// - `plugin_analytics`: Aggregated plugin usage with cost attribution
67/// - `tool_token_usage`: Aggregated per-tool token map across all sessions
68/// - `total_monthly_cost`: Current MTD or 30-day cost in dollars
69///
70/// Returns suggestions sorted by potential savings descending.
71pub fn generate_cost_suggestions(
72    plugin_analytics: &PluginAnalytics,
73    tool_token_usage: &HashMap<String, u64>,
74    total_monthly_cost: f64,
75) -> Vec<CostSuggestion> {
76    let mut suggestions: Vec<CostSuggestion> = Vec::new();
77
78    // 1. Dead plugins: defined but zero invocations
79    for dead_name in &plugin_analytics.dead_plugins {
80        suggestions.push(CostSuggestion {
81            category: OptimizationCategory::UnusedPlugin,
82            title: format!("Unused plugin: {}", dead_name),
83            description: format!(
84                "'{}' is defined in .claude/ but has never been invoked across all scanned sessions.",
85                dead_name
86            ),
87            potential_savings: 0.0,
88            action: format!(
89                "Remove or archive '{}' to reduce cognitive noise in your setup.",
90                dead_name
91            ),
92        });
93    }
94
95    // 2. High-cost tools: any single tool consuming >20% of total tokens
96    if !tool_token_usage.is_empty() {
97        let total_tokens: u64 = tool_token_usage.values().sum();
98        if total_tokens > 0 {
99            let mut sorted_tools: Vec<(&String, &u64)> = tool_token_usage.iter().collect();
100            sorted_tools.sort_by(|a, b| b.1.cmp(a.1));
101
102            for (tool, &tokens) in &sorted_tools {
103                let pct = tokens as f64 / total_tokens as f64;
104                if pct > 0.20 {
105                    let estimated_cost = total_monthly_cost * pct;
106                    let savings = estimated_cost * 0.30; // ~30% reduction potential
107                    suggestions.push(CostSuggestion {
108                        category: OptimizationCategory::HighCostTool,
109                        title: format!("High-cost tool: {}", tool),
110                        description: format!(
111                            "'{}' consumes {:.1}% of your total token budget ({} tokens). \
112                             This accounts for an estimated ${:.2}/month.",
113                            tool,
114                            pct * 100.0,
115                            tokens,
116                            estimated_cost
117                        ),
118                        potential_savings: savings,
119                        action: format!(
120                            "Review usage of '{}' — consider batching calls, caching results, \
121                             or reducing call frequency.",
122                            tool
123                        ),
124                    });
125                }
126            }
127        }
128    }
129
130    // 3. Sort by potential savings descending, then by category stability
131    suggestions.sort_by(|a, b| {
132        b.potential_savings
133            .partial_cmp(&a.potential_savings)
134            .unwrap_or(std::cmp::Ordering::Equal)
135    });
136
137    suggestions
138}
139
140/// Generate model downgrade recommendations based on session history.
141///
142/// If >60% of sessions use an Opus model AND average tool calls per session < 8,
143/// suggests switching to Sonnet for routine work. Estimates monthly savings.
144///
145/// # Returns
146/// Vec of `CostSuggestion` with category `ModelDowngrade`, sorted by savings desc.
147pub fn generate_model_recommendations(
148    sessions: &[Arc<SessionMetadata>],
149    total_monthly_cost: f64,
150) -> Vec<CostSuggestion> {
151    const MIN_SESSIONS: usize = 5;
152    const OPUS_THRESHOLD: f64 = 0.60;
153    const LOW_TOOL_CALLS: usize = 8;
154
155    if sessions.len() < MIN_SESSIONS || total_monthly_cost < 0.50 {
156        return vec![];
157    }
158
159    let mut opus_sessions = 0usize;
160    let mut total_tool_calls_opus = 0usize;
161
162    for session in sessions {
163        let uses_opus = session
164            .models_used
165            .iter()
166            .any(|m| m.to_lowercase().contains("opus"));
167        if uses_opus {
168            opus_sessions += 1;
169            total_tool_calls_opus += session.tool_usage.values().sum::<usize>();
170        }
171    }
172
173    if opus_sessions == 0 {
174        return vec![];
175    }
176
177    let opus_pct = opus_sessions as f64 / sessions.len() as f64;
178    let avg_tool_calls = total_tool_calls_opus as f64 / opus_sessions as f64;
179
180    if opus_pct > OPUS_THRESHOLD && avg_tool_calls < LOW_TOOL_CALLS as f64 {
181        // Opus is ~5x more expensive than Sonnet; estimate 70% savings on Opus sessions
182        let savings = total_monthly_cost * opus_pct * 0.70;
183        vec![CostSuggestion {
184            category: OptimizationCategory::ModelDowngrade,
185            title: format!("Switch {:.0}% of Opus sessions to Sonnet", opus_pct * 100.0),
186            description: format!(
187                "{:.0}% of sessions use Opus with only ~{:.0} avg tool calls — \
188                 Sonnet handles most coding tasks at ~5× lower cost.",
189                opus_pct * 100.0,
190                avg_tool_calls
191            ),
192            potential_savings: savings,
193            action: "Use claude-sonnet-4-6 for routine tasks, reserve Opus 4.6 for complex \
194                     multi-step reasoning."
195                .to_string(),
196        }]
197    } else {
198        vec![]
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use crate::analytics::plugin_usage::PluginAnalytics;
206    use crate::models::session::SessionMetadata;
207    use std::collections::HashMap;
208    use std::sync::Arc;
209
210    fn empty_analytics() -> PluginAnalytics {
211        PluginAnalytics::empty()
212    }
213
214    #[test]
215    fn test_no_suggestions_empty_data() {
216        let analytics = empty_analytics();
217        let tool_tokens: HashMap<String, u64> = HashMap::new();
218        let suggestions = generate_cost_suggestions(&analytics, &tool_tokens, 0.0);
219        assert!(suggestions.is_empty());
220    }
221
222    #[test]
223    fn test_unused_plugin_suggestion() {
224        let mut analytics = empty_analytics();
225        analytics.dead_plugins = vec!["unused-skill".to_string(), "old-command".to_string()];
226
227        let tool_tokens: HashMap<String, u64> = HashMap::new();
228        let suggestions = generate_cost_suggestions(&analytics, &tool_tokens, 10.0);
229
230        let unused: Vec<_> = suggestions
231            .iter()
232            .filter(|s| s.category == OptimizationCategory::UnusedPlugin)
233            .collect();
234        assert_eq!(unused.len(), 2);
235    }
236
237    #[test]
238    fn test_high_cost_tool_threshold() {
239        let analytics = empty_analytics();
240        let mut tool_tokens = HashMap::new();
241        // Bash uses 50% of tokens — should trigger high-cost suggestion
242        tool_tokens.insert("Bash".to_string(), 500u64);
243        tool_tokens.insert("Read".to_string(), 500u64);
244
245        let suggestions = generate_cost_suggestions(&analytics, &tool_tokens, 20.0);
246
247        let high_cost: Vec<_> = suggestions
248            .iter()
249            .filter(|s| s.category == OptimizationCategory::HighCostTool)
250            .collect();
251        // Both tools at 50% each should trigger suggestions
252        assert_eq!(high_cost.len(), 2);
253        // Potential savings should be non-zero
254        assert!(high_cost.iter().all(|s| s.potential_savings > 0.0));
255    }
256
257    #[test]
258    fn test_below_threshold_no_high_cost() {
259        let analytics = empty_analytics();
260        let mut tool_tokens = HashMap::new();
261        // 5 tools at 20% each — none exceed the 20% threshold (exclusive >)
262        for i in 0..5 {
263            tool_tokens.insert(format!("Tool{}", i), 200u64);
264        }
265
266        let suggestions = generate_cost_suggestions(&analytics, &tool_tokens, 10.0);
267        let high_cost: Vec<_> = suggestions
268            .iter()
269            .filter(|s| s.category == OptimizationCategory::HighCostTool)
270            .collect();
271        assert!(high_cost.is_empty(), "20% exactly is not above threshold");
272    }
273
274    #[test]
275    fn test_sorted_by_savings() {
276        let analytics = empty_analytics();
277        let mut tool_tokens = HashMap::new();
278        // One dominant tool at 80%
279        tool_tokens.insert("Bash".to_string(), 800u64);
280        tool_tokens.insert("Read".to_string(), 200u64);
281
282        let suggestions = generate_cost_suggestions(&analytics, &tool_tokens, 100.0);
283
284        if suggestions.len() >= 2 {
285            assert!(
286                suggestions[0].potential_savings >= suggestions[1].potential_savings,
287                "Should be sorted by potential savings descending"
288            );
289        }
290    }
291
292    // --- generate_model_recommendations tests ---
293
294    fn make_session(id: &str, model: &str, tool_calls: usize) -> Arc<SessionMetadata> {
295        let mut tool_usage = HashMap::new();
296        if tool_calls > 0 {
297            tool_usage.insert("Bash".to_string(), tool_calls);
298        }
299        Arc::new(SessionMetadata {
300            id: id.into(),
301            file_path: std::path::PathBuf::from(format!("/tmp/{}.jsonl", id)),
302            project_path: "test".into(),
303            first_timestamp: Some(chrono::Utc::now()),
304            last_timestamp: Some(chrono::Utc::now()),
305            message_count: 5,
306            total_tokens: 10_000,
307            input_tokens: 5_000,
308            output_tokens: 5_000,
309            cache_creation_tokens: 0,
310            cache_read_tokens: 0,
311            models_used: vec![model.to_string()],
312            file_size_bytes: 1024,
313            first_user_message: None,
314            has_subagents: false,
315            duration_seconds: Some(60),
316            branch: None,
317            tool_usage,
318            tool_token_usage: HashMap::new(),
319        })
320    }
321
322    #[test]
323    fn test_model_recommendations_too_few_sessions() {
324        // Below MIN_SESSIONS=5 — should return empty
325        let sessions: Vec<_> = (0..4)
326            .map(|i| make_session(&format!("s{}", i), "claude-opus-4-6", 3))
327            .collect();
328        let recs = generate_model_recommendations(&sessions, 50.0);
329        assert!(recs.is_empty(), "Need at least 5 sessions");
330    }
331
332    #[test]
333    fn test_model_recommendations_low_cost() {
334        // Below $0.50/month — should return empty
335        let sessions: Vec<_> = (0..10)
336            .map(|i| make_session(&format!("s{}", i), "claude-opus-4-6", 3))
337            .collect();
338        let recs = generate_model_recommendations(&sessions, 0.40);
339        assert!(recs.is_empty(), "Cost too low to recommend downgrade");
340    }
341
342    #[test]
343    fn test_model_recommendations_sonnet_heavy() {
344        // Mostly Sonnet — no downgrade recommendation
345        let mut sessions: Vec<_> = (0..8)
346            .map(|i| make_session(&format!("sonnet_{}", i), "claude-sonnet-4-6", 5))
347            .collect();
348        sessions.extend((0..2).map(|i| make_session(&format!("opus_{}", i), "claude-opus-4-6", 3)));
349        let recs = generate_model_recommendations(&sessions, 50.0);
350        assert!(recs.is_empty(), "Mostly Sonnet — no downgrade needed");
351    }
352
353    #[test]
354    fn test_model_recommendations_opus_heavy_low_tools() {
355        // >60% Opus with <8 avg tool calls — should recommend downgrade
356        let sessions: Vec<_> = (0..10)
357            .map(|i| make_session(&format!("s{}", i), "claude-opus-4-6", 4)) // 4 tool calls avg
358            .collect();
359        let recs = generate_model_recommendations(&sessions, 100.0);
360        assert!(
361            !recs.is_empty(),
362            "Should recommend Sonnet for low-tool Opus sessions"
363        );
364        assert_eq!(recs[0].category, OptimizationCategory::ModelDowngrade);
365        assert!(recs[0].potential_savings > 0.0);
366    }
367
368    #[test]
369    fn test_model_recommendations_opus_heavy_high_tools() {
370        // >60% Opus but with many tool calls — no recommendation (complex tasks)
371        let sessions: Vec<_> = (0..10)
372            .map(|i| make_session(&format!("s{}", i), "claude-opus-4-6", 12)) // 12 tool calls avg
373            .collect();
374        let recs = generate_model_recommendations(&sessions, 100.0);
375        assert!(recs.is_empty(), "High tool calls — Opus justified");
376    }
377}