Skip to main content

vtcode_core/tools/
search_metrics.rs

1/// Search operation metrics and optimization
2///
3/// Tracks token cost of search results to enable intelligent caching,
4/// sampling, and summarization of expensive searches in large codebases.
5use hashbrown::HashMap;
6
7/// Metrics for a single search operation
8#[derive(Debug, Clone)]
9pub struct SearchMetric {
10    /// Search pattern used
11    pub pattern: String,
12    /// Number of matches found
13    pub match_count: usize,
14    /// Estimated tokens for results
15    pub result_tokens: usize,
16    /// Time to execute search
17    pub duration_ms: u64,
18    /// Files searched
19    pub files_searched: usize,
20    /// Whether search was expensive (high token cost)
21    pub is_expensive: bool,
22}
23
24/// Tracks search operation metrics for optimization
25#[derive(Debug, Clone)]
26pub struct SearchMetrics {
27    /// All recorded searches indexed by pattern
28    searches: HashMap<String, SearchMetric>,
29    /// Total token cost of all searches
30    pub total_tokens: usize,
31    /// Total searches executed
32    pub total_searches: usize,
33    /// Token threshold for "expensive" searches (default: 10000)
34    expensive_threshold: usize,
35}
36
37impl Default for SearchMetrics {
38    fn default() -> Self {
39        Self::new()
40    }
41}
42
43impl SearchMetrics {
44    /// Create new search metrics tracker
45    pub fn new() -> Self {
46        Self {
47            searches: HashMap::new(),
48            total_tokens: 0,
49            total_searches: 0,
50            expensive_threshold: 10000,
51        }
52    }
53
54    /// Set token threshold for expensive searches
55    pub fn with_expensive_threshold(mut self, threshold: usize) -> Self {
56        self.expensive_threshold = threshold;
57        self
58    }
59
60    /// Record a search operation
61    pub fn record_search(
62        &mut self,
63        pattern: &str,
64        match_count: usize,
65        result_chars: usize,
66        duration_ms: u64,
67        files_searched: usize,
68    ) {
69        // Estimate tokens from character count (using default 4.0 chars/token)
70        #[allow(
71            clippy::cast_sign_loss,
72            reason = "Intentional compatibility, platform, or test-only suppression."
73        )]
74        let estimated_tokens = ((result_chars as f64 / 4.0).ceil()).max(0.0) as usize;
75        let is_expensive = estimated_tokens > self.expensive_threshold;
76
77        let metric = SearchMetric {
78            pattern: pattern.to_owned(),
79            match_count,
80            result_tokens: estimated_tokens,
81            duration_ms,
82            files_searched,
83            is_expensive,
84        };
85
86        self.total_tokens += estimated_tokens;
87        self.total_searches += 1;
88        self.searches.insert(pattern.to_owned(), metric);
89    }
90
91    /// Get metric for a specific pattern
92    pub fn get_search(&self, pattern: &str) -> Option<&SearchMetric> {
93        self.searches.get(pattern)
94    }
95
96    /// Find most expensive searches
97    pub fn expensive_searches(&self, limit: usize) -> Vec<&SearchMetric> {
98        let mut searches: Vec<_> = self.searches.values().filter(|s| s.is_expensive).collect();
99        searches.sort_by_key(|a| std::cmp::Reverse(a.result_tokens));
100        searches.into_iter().take(limit).collect()
101    }
102
103    /// Find slowest searches
104    pub fn slowest_searches(&self, limit: usize) -> Vec<&SearchMetric> {
105        let mut searches: Vec<_> = self.searches.values().collect();
106        searches.sort_by_key(|a| std::cmp::Reverse(a.duration_ms));
107        searches.into_iter().take(limit).collect()
108    }
109
110    /// Calculate average tokens per search
111    pub fn avg_tokens_per_search(&self) -> f64 {
112        if self.total_searches == 0 {
113            0.0
114        } else {
115            self.total_tokens as f64 / self.total_searches as f64
116        }
117    }
118
119    /// Check if search should be sampled (too many results)
120    pub fn should_sample_results(&self, pattern: &str) -> bool {
121        self.get_search(pattern).map(|m| m.is_expensive).unwrap_or(false)
122    }
123
124    /// Estimate sampling ratio for expensive search
125    ///
126    /// Returns a value between 0.1 (10% of results) and 1.0 (no sampling)
127    pub fn estimate_sampling_ratio(&self, pattern: &str) -> f64 {
128        if let Some(metric) = self.get_search(pattern) {
129            if !metric.is_expensive {
130                return 1.0;
131            }
132
133            // Linear interpolation: at threshold = 1.0, at 2x threshold = 0.1
134            let ratio = self.expensive_threshold as f64 / metric.result_tokens as f64;
135            (ratio * 0.9 + 0.1).clamp(0.1, 1.0)
136        } else {
137            1.0 // No sampling if not tracked
138        }
139    }
140
141    /// Format metrics for display
142    pub fn format_summary(&self) -> String {
143        use std::fmt::Write;
144        // Pre-allocate with reasonable estimate for metrics summary
145        let mut output = String::with_capacity(512);
146        output.push_str("[SEARCH] Metrics Summary\n");
147        let _ = writeln!(output, "  Total searches: {}", self.total_searches);
148        let _ = writeln!(output, "  Total tokens: {}", self.total_tokens);
149        let _ = writeln!(output, "  Avg tokens/search: {:.0}", self.avg_tokens_per_search());
150        let _ = writeln!(output, "  Expensive searches: {}", self.searches.values().filter(|s| s.is_expensive).count());
151
152        let expensive = self.expensive_searches(3);
153        if !expensive.is_empty() {
154            output.push_str("\n  Most expensive searches:\n");
155            for (i, metric) in expensive.iter().enumerate() {
156                let _ = writeln!(
157                    output,
158                    "    {}. '{}': {} tokens ({} matches)",
159                    i + 1,
160                    metric.pattern,
161                    metric.result_tokens,
162                    metric.match_count
163                );
164            }
165        }
166
167        output
168    }
169
170    /// Clear all metrics
171    pub fn reset(&mut self) {
172        self.searches.clear();
173        self.total_tokens = 0;
174        self.total_searches = 0;
175    }
176
177    /// Get stats for monitoring
178    pub fn stats(&self) -> SearchMetricsStats {
179        let expensive_count = self.searches.values().filter(|s| s.is_expensive).count();
180        SearchMetricsStats {
181            total_searches: self.total_searches,
182            total_tokens: self.total_tokens,
183            expensive_searches: expensive_count,
184            avg_tokens_per_search: self.avg_tokens_per_search(),
185        }
186    }
187}
188
189/// Statistics for search metrics
190#[derive(Debug, Clone)]
191pub struct SearchMetricsStats {
192    pub total_searches: usize,
193    pub total_tokens: usize,
194    pub expensive_searches: usize,
195    pub avg_tokens_per_search: f64,
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn test_creates_metrics() {
204        let metrics = SearchMetrics::new();
205        assert_eq!(metrics.total_searches, 0);
206        assert_eq!(metrics.total_tokens, 0);
207    }
208
209    #[test]
210    fn test_records_search() {
211        let mut metrics = SearchMetrics::new();
212        metrics.record_search("fn main", 5, 1000, 10, 3);
213
214        assert_eq!(metrics.total_searches, 1);
215        assert!(metrics.total_tokens > 0);
216
217        let metric = metrics.get_search("fn main").unwrap();
218        assert_eq!(metric.match_count, 5);
219        assert_eq!(metric.files_searched, 3);
220    }
221
222    #[test]
223    fn test_identifies_expensive_searches() {
224        let mut metrics = SearchMetrics::new().with_expensive_threshold(5000);
225        // This will be expensive (12500 tokens estimated)
226        metrics.record_search("common_pattern", 100, 50000, 50, 50);
227
228        let metric = metrics.get_search("common_pattern").unwrap();
229        assert!(metric.is_expensive);
230    }
231
232    #[test]
233    fn test_expensive_searches() {
234        let mut metrics = SearchMetrics::new().with_expensive_threshold(5000);
235        metrics.record_search("pattern1", 10, 25000, 20, 5);
236        metrics.record_search("pattern2", 5, 2000, 10, 2);
237        metrics.record_search("pattern3", 50, 30000, 100, 20);
238
239        let expensive = metrics.expensive_searches(2);
240        assert_eq!(expensive.len(), 2);
241        assert!(expensive[0].result_tokens > expensive[1].result_tokens);
242    }
243
244    #[test]
245    fn test_slowest_searches() {
246        let mut metrics = SearchMetrics::new();
247        metrics.record_search("fast", 10, 1000, 5, 2);
248        metrics.record_search("slow", 10, 1000, 100, 2);
249        metrics.record_search("medium", 10, 1000, 50, 2);
250
251        let slowest = metrics.slowest_searches(2);
252        assert_eq!(slowest.len(), 2);
253        assert!(slowest[0].duration_ms > slowest[1].duration_ms);
254    }
255
256    #[test]
257    fn test_sampling_ratio() {
258        let mut metrics = SearchMetrics::new().with_expensive_threshold(10000);
259
260        // Non-expensive search should not be sampled
261        metrics.record_search("cheap", 10, 5000, 10, 5);
262        assert!((metrics.estimate_sampling_ratio("cheap") - 1.0).abs() < f64::EPSILON);
263
264        // Expensive search should be sampled
265        metrics.record_search("expensive", 100, 50000, 100, 50);
266        let ratio = metrics.estimate_sampling_ratio("expensive");
267        assert!(ratio < 1.0);
268        assert!(ratio >= 0.1);
269    }
270
271    #[test]
272    fn test_average_tokens() {
273        let mut metrics = SearchMetrics::new();
274        metrics.record_search("search1", 10, 4000, 10, 5);
275        metrics.record_search("search2", 5, 8000, 20, 3);
276
277        let avg = metrics.avg_tokens_per_search();
278        assert!(avg > 0.0);
279        // Should be approximately (1000 + 2000) / 2 = 1500
280    }
281
282    #[test]
283    fn test_should_sample_results() {
284        let mut metrics = SearchMetrics::new().with_expensive_threshold(5000);
285        metrics.record_search("cheap", 10, 2000, 10, 5);
286        metrics.record_search("expensive", 100, 50000, 100, 50);
287
288        assert!(!metrics.should_sample_results("cheap"));
289        assert!(metrics.should_sample_results("expensive"));
290    }
291
292    #[test]
293    fn test_format_summary() {
294        let mut metrics = SearchMetrics::new();
295        metrics.record_search("pattern1", 10, 4000, 10, 5);
296
297        let summary = metrics.format_summary();
298        assert!(summary.contains("[SEARCH] Metrics Summary"));
299        assert!(summary.contains("Total searches: 1"));
300    }
301
302    #[test]
303    fn test_reset() {
304        let mut metrics = SearchMetrics::new();
305        metrics.record_search("pattern1", 10, 4000, 10, 5);
306
307        metrics.reset();
308        assert_eq!(metrics.total_searches, 0);
309        assert_eq!(metrics.total_tokens, 0);
310        assert!(metrics.get_search("pattern1").is_none());
311    }
312
313    #[test]
314    fn test_stats() {
315        let mut metrics = SearchMetrics::new().with_expensive_threshold(5000);
316        metrics.record_search("cheap", 10, 2000, 10, 5);
317        metrics.record_search("expensive", 100, 50000, 100, 50);
318
319        let stats = metrics.stats();
320        assert_eq!(stats.total_searches, 2);
321        assert_eq!(stats.expensive_searches, 1);
322        assert!(stats.avg_tokens_per_search > 0.0);
323    }
324}