Skip to main content

skm_learn/
metrics.rs

1//! Real-time selection metrics.
2//!
3//! Provides comprehensive metrics collection for skill selection performance,
4//! including latency histograms, hit rates, and per-skill/strategy breakdowns.
5
6use std::collections::HashMap;
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::sync::Mutex;
9use std::time::Duration;
10
11use hdrhistogram::Histogram;
12
13use skm_core::SkillName;
14use skm_select::Confidence;
15
16/// Summary of metrics.
17#[derive(Debug, Clone)]
18pub struct MetricsSummary {
19    /// Total selections.
20    pub total_selections: u64,
21
22    /// Total timeouts.
23    pub total_timeouts: u64,
24
25    /// Total queries with no match.
26    pub total_no_match: u64,
27
28    /// Selections by confidence level.
29    pub by_confidence: HashMap<String, u64>,
30
31    /// Latency percentiles (in milliseconds).
32    pub latency_p50: f64,
33    pub latency_p95: f64,
34    pub latency_p99: f64,
35
36    /// Mean latency (in milliseconds).
37    pub latency_mean: f64,
38
39    /// Min/Max latency (in milliseconds).
40    pub latency_min: f64,
41    pub latency_max: f64,
42
43    /// Per-strategy selection counts.
44    pub by_strategy: HashMap<String, u64>,
45
46    /// Per-skill selection counts.
47    pub by_skill: HashMap<SkillName, u64>,
48
49    /// Trigger strategy hit rate (resolved by triggers without needing slower strategies).
50    pub trigger_hit_rate: f64,
51
52    /// Semantic strategy hit rate.
53    pub semantic_hit_rate: f64,
54
55    /// LLM fallback rate.
56    pub llm_fallback_rate: f64,
57}
58
59/// Real-time metrics collector.
60///
61/// Thread-safe metrics collection for production use.
62/// Supports Prometheus export format.
63pub struct SelectionMetrics {
64    /// Total selection count.
65    total_selections: AtomicU64,
66
67    /// Timeout count.
68    total_timeouts: AtomicU64,
69
70    /// No match count.
71    total_no_match: AtomicU64,
72
73    /// Selections by confidence level.
74    by_confidence: Mutex<HashMap<String, u64>>,
75
76    /// Selections by strategy.
77    by_strategy: Mutex<HashMap<String, u64>>,
78
79    /// Selections by skill.
80    by_skill: Mutex<HashMap<SkillName, u64>>,
81
82    /// Latency histogram (in microseconds).
83    latency_histogram: Mutex<Histogram<u64>>,
84
85    /// Cache stats
86    cache_hits: AtomicU64,
87    cache_misses: AtomicU64,
88}
89
90impl Default for SelectionMetrics {
91    fn default() -> Self {
92        Self::new()
93    }
94}
95
96impl SelectionMetrics {
97    /// Create a new metrics collector.
98    pub fn new() -> Self {
99        Self {
100            total_selections: AtomicU64::new(0),
101            total_timeouts: AtomicU64::new(0),
102            total_no_match: AtomicU64::new(0),
103            by_confidence: Mutex::new(HashMap::new()),
104            by_strategy: Mutex::new(HashMap::new()),
105            by_skill: Mutex::new(HashMap::new()),
106            latency_histogram: Mutex::new(Histogram::new(3).unwrap()),
107            cache_hits: AtomicU64::new(0),
108            cache_misses: AtomicU64::new(0),
109        }
110    }
111
112    /// Record a successful selection.
113    pub fn record_selection(
114        &self,
115        skill: &SkillName,
116        strategy: &str,
117        confidence: Confidence,
118        latency: Duration,
119    ) {
120        self.total_selections.fetch_add(1, Ordering::Relaxed);
121
122        // Record confidence
123        {
124            let mut by_conf = self.by_confidence.lock().unwrap();
125            *by_conf.entry(format!("{:?}", confidence)).or_default() += 1;
126        }
127
128        // Record strategy
129        {
130            let mut by_strat = self.by_strategy.lock().unwrap();
131            *by_strat.entry(strategy.to_string()).or_default() += 1;
132        }
133
134        // Record skill
135        {
136            let mut by_skill = self.by_skill.lock().unwrap();
137            *by_skill.entry(skill.clone()).or_default() += 1;
138        }
139
140        // Record latency
141        {
142            let mut hist = self.latency_histogram.lock().unwrap();
143            let micros = latency.as_micros() as u64;
144            let high = hist.high();
145            let _ = hist.record(micros.min(high));
146        }
147    }
148
149    /// Record a timeout.
150    pub fn record_timeout(&self, latency: Duration) {
151        self.total_timeouts.fetch_add(1, Ordering::Relaxed);
152
153        // Still record latency
154        let mut hist = self.latency_histogram.lock().unwrap();
155        let micros = latency.as_micros() as u64;
156        let high = hist.high();
157        let _ = hist.record(micros.min(high));
158    }
159
160    /// Record when no skill was selected.
161    pub fn record_no_match(&self, latency: Duration) {
162        self.total_selections.fetch_add(1, Ordering::Relaxed);
163        self.total_no_match.fetch_add(1, Ordering::Relaxed);
164
165        let mut by_conf = self.by_confidence.lock().unwrap();
166        *by_conf.entry("None".to_string()).or_default() += 1;
167
168        let mut hist = self.latency_histogram.lock().unwrap();
169        let micros = latency.as_micros() as u64;
170        let high = hist.high();
171        let _ = hist.record(micros.min(high));
172    }
173
174    /// Record a cache hit.
175    pub fn record_cache_hit(&self) {
176        self.cache_hits.fetch_add(1, Ordering::Relaxed);
177    }
178
179    /// Record a cache miss.
180    pub fn record_cache_miss(&self) {
181        self.cache_misses.fetch_add(1, Ordering::Relaxed);
182    }
183
184    /// Get a summary of metrics.
185    pub fn summary(&self) -> MetricsSummary {
186        let hist = self.latency_histogram.lock().unwrap();
187        let by_strategy = self.by_strategy.lock().unwrap().clone();
188        let total = self.total_selections.load(Ordering::Relaxed);
189
190        // Calculate hit rates
191        let trigger_count = by_strategy.get("trigger").copied().unwrap_or(0);
192        let semantic_count = by_strategy.get("semantic").copied().unwrap_or(0);
193        let llm_count = by_strategy.get("llm").copied().unwrap_or(0);
194
195        let total_f64 = total.max(1) as f64;
196
197        MetricsSummary {
198            total_selections: total,
199            total_timeouts: self.total_timeouts.load(Ordering::Relaxed),
200            total_no_match: self.total_no_match.load(Ordering::Relaxed),
201            by_confidence: self.by_confidence.lock().unwrap().clone(),
202            latency_p50: hist.value_at_quantile(0.5) as f64 / 1000.0,
203            latency_p95: hist.value_at_quantile(0.95) as f64 / 1000.0,
204            latency_p99: hist.value_at_quantile(0.99) as f64 / 1000.0,
205            latency_mean: hist.mean() / 1000.0,
206            latency_min: hist.min() as f64 / 1000.0,
207            latency_max: hist.max() as f64 / 1000.0,
208            by_strategy: by_strategy.clone(),
209            by_skill: self.by_skill.lock().unwrap().clone(),
210            trigger_hit_rate: trigger_count as f64 / total_f64,
211            semantic_hit_rate: semantic_count as f64 / total_f64,
212            llm_fallback_rate: llm_count as f64 / total_f64,
213        }
214    }
215
216    /// Reset all metrics.
217    pub fn reset(&self) {
218        self.total_selections.store(0, Ordering::Relaxed);
219        self.total_timeouts.store(0, Ordering::Relaxed);
220        self.total_no_match.store(0, Ordering::Relaxed);
221        self.cache_hits.store(0, Ordering::Relaxed);
222        self.cache_misses.store(0, Ordering::Relaxed);
223        self.by_confidence.lock().unwrap().clear();
224        self.by_strategy.lock().unwrap().clear();
225        self.by_skill.lock().unwrap().clear();
226        *self.latency_histogram.lock().unwrap() = Histogram::new(3).unwrap();
227    }
228
229    /// Export metrics in Prometheus text format.
230    ///
231    /// This format is compatible with Prometheus scraping and can be
232    /// exposed via the `/metrics` HTTP endpoint.
233    pub fn to_prometheus(&self) -> String {
234        let summary = self.summary();
235        let cache_hits = self.cache_hits.load(Ordering::Relaxed);
236        let cache_misses = self.cache_misses.load(Ordering::Relaxed);
237
238        let mut lines = Vec::new();
239
240        // Help and type annotations for better Prometheus integration
241        lines.push("# HELP skm_selections_total Total number of skill selections".to_string());
242        lines.push("# TYPE skm_selections_total counter".to_string());
243        lines.push(format!("skm_selections_total {}", summary.total_selections));
244
245        lines.push("# HELP skm_timeouts_total Total number of selection timeouts".to_string());
246        lines.push("# TYPE skm_timeouts_total counter".to_string());
247        lines.push(format!("skm_timeouts_total {}", summary.total_timeouts));
248
249        lines.push("# HELP skm_no_match_total Total selections with no matching skill".to_string());
250        lines.push("# TYPE skm_no_match_total counter".to_string());
251        lines.push(format!("skm_no_match_total {}", summary.total_no_match));
252
253        // Confidence breakdown
254        lines.push("# HELP skm_selections_by_confidence Selections by confidence level".to_string());
255        lines.push("# TYPE skm_selections_by_confidence counter".to_string());
256        for (conf, count) in &summary.by_confidence {
257            lines.push(format!(
258                "skm_selections_by_confidence{{confidence=\"{}\"}} {}",
259                conf, count
260            ));
261        }
262
263        // Strategy breakdown
264        lines.push("# HELP skm_selections_by_strategy Selections by strategy".to_string());
265        lines.push("# TYPE skm_selections_by_strategy counter".to_string());
266        for (strategy, count) in &summary.by_strategy {
267            lines.push(format!(
268                "skm_selections_by_strategy{{strategy=\"{}\"}} {}",
269                strategy, count
270            ));
271        }
272
273        // Per-skill breakdown (top 20 to avoid cardinality explosion)
274        lines.push("# HELP skm_skill_selections Selections per skill".to_string());
275        lines.push("# TYPE skm_skill_selections counter".to_string());
276        let mut skill_vec: Vec<_> = summary.by_skill.iter().collect();
277        skill_vec.sort_by(|a, b| b.1.cmp(a.1));
278        for (skill, count) in skill_vec.into_iter().take(20) {
279            lines.push(format!(
280                "skm_skill_selections{{skill=\"{}\"}} {}",
281                skill.as_str(),
282                count
283            ));
284        }
285
286        // Latency metrics
287        lines.push("# HELP skm_latency_milliseconds Selection latency in milliseconds".to_string());
288        lines.push("# TYPE skm_latency_milliseconds summary".to_string());
289        lines.push(format!(
290            "skm_latency_milliseconds{{quantile=\"0.5\"}} {:.3}",
291            summary.latency_p50
292        ));
293        lines.push(format!(
294            "skm_latency_milliseconds{{quantile=\"0.95\"}} {:.3}",
295            summary.latency_p95
296        ));
297        lines.push(format!(
298            "skm_latency_milliseconds{{quantile=\"0.99\"}} {:.3}",
299            summary.latency_p99
300        ));
301        lines.push(format!(
302            "skm_latency_milliseconds_sum {:.3}",
303            summary.latency_mean * summary.total_selections as f64
304        ));
305        lines.push(format!(
306            "skm_latency_milliseconds_count {}",
307            summary.total_selections
308        ));
309
310        // Hit rates
311        lines.push("# HELP skm_trigger_hit_rate Fraction of queries resolved by trigger matching".to_string());
312        lines.push("# TYPE skm_trigger_hit_rate gauge".to_string());
313        lines.push(format!("skm_trigger_hit_rate {:.4}", summary.trigger_hit_rate));
314
315        lines.push("# HELP skm_semantic_hit_rate Fraction of queries resolved by semantic search".to_string());
316        lines.push("# TYPE skm_semantic_hit_rate gauge".to_string());
317        lines.push(format!("skm_semantic_hit_rate {:.4}", summary.semantic_hit_rate));
318
319        lines.push("# HELP skm_llm_fallback_rate Fraction of queries requiring LLM fallback".to_string());
320        lines.push("# TYPE skm_llm_fallback_rate gauge".to_string());
321        lines.push(format!("skm_llm_fallback_rate {:.4}", summary.llm_fallback_rate));
322
323        // Cache metrics
324        lines.push("# HELP skm_cache_hits_total Total cache hits".to_string());
325        lines.push("# TYPE skm_cache_hits_total counter".to_string());
326        lines.push(format!("skm_cache_hits_total {}", cache_hits));
327
328        lines.push("# HELP skm_cache_misses_total Total cache misses".to_string());
329        lines.push("# TYPE skm_cache_misses_total counter".to_string());
330        lines.push(format!("skm_cache_misses_total {}", cache_misses));
331
332        let total_cache = cache_hits + cache_misses;
333        if total_cache > 0 {
334            let cache_hit_rate = cache_hits as f64 / total_cache as f64;
335            lines.push("# HELP skm_cache_hit_rate Cache hit rate".to_string());
336            lines.push("# TYPE skm_cache_hit_rate gauge".to_string());
337            lines.push(format!("skm_cache_hit_rate {:.4}", cache_hit_rate));
338        }
339
340        lines.join("\n")
341    }
342
343    /// Export metrics as JSON for dashboarding.
344    pub fn to_json(&self) -> serde_json::Value {
345        let summary = self.summary();
346        let cache_hits = self.cache_hits.load(Ordering::Relaxed);
347        let cache_misses = self.cache_misses.load(Ordering::Relaxed);
348
349        serde_json::json!({
350            "total_selections": summary.total_selections,
351            "total_timeouts": summary.total_timeouts,
352            "total_no_match": summary.total_no_match,
353            "latency": {
354                "p50_ms": summary.latency_p50,
355                "p95_ms": summary.latency_p95,
356                "p99_ms": summary.latency_p99,
357                "mean_ms": summary.latency_mean,
358                "min_ms": summary.latency_min,
359                "max_ms": summary.latency_max
360            },
361            "by_confidence": summary.by_confidence,
362            "by_strategy": summary.by_strategy,
363            "by_skill": summary.by_skill.iter()
364                .map(|(k, v)| (k.as_str().to_string(), *v))
365                .collect::<HashMap<String, u64>>(),
366            "hit_rates": {
367                "trigger": summary.trigger_hit_rate,
368                "semantic": summary.semantic_hit_rate,
369                "llm_fallback": summary.llm_fallback_rate
370            },
371            "cache": {
372                "hits": cache_hits,
373                "misses": cache_misses,
374                "hit_rate": if cache_hits + cache_misses > 0 {
375                    cache_hits as f64 / (cache_hits + cache_misses) as f64
376                } else {
377                    0.0
378                }
379            }
380        })
381    }
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387
388    #[test]
389    fn test_metrics_record() {
390        let metrics = SelectionMetrics::new();
391
392        let skill = SkillName::new("test-skill").unwrap();
393        metrics.record_selection(&skill, "trigger", Confidence::High, Duration::from_millis(10));
394        metrics.record_selection(&skill, "trigger", Confidence::High, Duration::from_millis(20));
395        metrics.record_selection(&skill, "semantic", Confidence::Medium, Duration::from_millis(100));
396
397        let summary = metrics.summary();
398
399        assert_eq!(summary.total_selections, 3);
400        assert_eq!(*summary.by_strategy.get("trigger").unwrap_or(&0), 2);
401        assert_eq!(*summary.by_strategy.get("semantic").unwrap_or(&0), 1);
402        assert_eq!(*summary.by_skill.get(&skill).unwrap_or(&0), 3);
403    }
404
405    #[test]
406    fn test_metrics_timeout() {
407        let metrics = SelectionMetrics::new();
408
409        metrics.record_timeout(Duration::from_secs(5));
410
411        let summary = metrics.summary();
412        assert_eq!(summary.total_timeouts, 1);
413    }
414
415    #[test]
416    fn test_metrics_no_match() {
417        let metrics = SelectionMetrics::new();
418
419        metrics.record_no_match(Duration::from_millis(50));
420
421        let summary = metrics.summary();
422        assert_eq!(summary.total_selections, 1);
423        assert_eq!(summary.total_no_match, 1);
424        assert!(summary.by_confidence.contains_key("None"));
425    }
426
427    #[test]
428    fn test_metrics_reset() {
429        let metrics = SelectionMetrics::new();
430
431        let skill = SkillName::new("test").unwrap();
432        metrics.record_selection(&skill, "trigger", Confidence::High, Duration::from_millis(10));
433
434        assert_eq!(metrics.summary().total_selections, 1);
435
436        metrics.reset();
437
438        assert_eq!(metrics.summary().total_selections, 0);
439    }
440
441    #[test]
442    fn test_prometheus_export() {
443        let metrics = SelectionMetrics::new();
444
445        let skill = SkillName::new("test").unwrap();
446        metrics.record_selection(&skill, "trigger", Confidence::High, Duration::from_millis(10));
447
448        let prometheus = metrics.to_prometheus();
449
450        assert!(prometheus.contains("skm_selections_total 1"));
451        assert!(prometheus.contains("skm_selections_by_strategy{strategy=\"trigger\"}"));
452        assert!(prometheus.contains("# TYPE skm_selections_total counter"));
453        assert!(prometheus.contains("skm_trigger_hit_rate"));
454    }
455
456    #[test]
457    fn test_json_export() {
458        let metrics = SelectionMetrics::new();
459
460        let skill = SkillName::new("test").unwrap();
461        metrics.record_selection(&skill, "trigger", Confidence::High, Duration::from_millis(10));
462
463        let json = metrics.to_json();
464
465        assert_eq!(json["total_selections"], 1);
466        assert!(json["by_strategy"]["trigger"].as_u64().is_some());
467    }
468
469    #[test]
470    fn test_cache_metrics() {
471        let metrics = SelectionMetrics::new();
472
473        metrics.record_cache_hit();
474        metrics.record_cache_hit();
475        metrics.record_cache_miss();
476
477        let prometheus = metrics.to_prometheus();
478
479        assert!(prometheus.contains("skm_cache_hits_total 2"));
480        assert!(prometheus.contains("skm_cache_misses_total 1"));
481    }
482
483    #[test]
484    fn test_hit_rates() {
485        let metrics = SelectionMetrics::new();
486
487        let skill = SkillName::new("test").unwrap();
488        // 2 trigger hits, 1 semantic hit, 1 llm fallback
489        metrics.record_selection(&skill, "trigger", Confidence::High, Duration::from_millis(10));
490        metrics.record_selection(&skill, "trigger", Confidence::High, Duration::from_millis(10));
491        metrics.record_selection(&skill, "semantic", Confidence::Medium, Duration::from_millis(50));
492        metrics.record_selection(&skill, "llm", Confidence::Low, Duration::from_millis(500));
493
494        let summary = metrics.summary();
495
496        assert!((summary.trigger_hit_rate - 0.5).abs() < 0.01);
497        assert!((summary.semantic_hit_rate - 0.25).abs() < 0.01);
498        assert!((summary.llm_fallback_rate - 0.25).abs() < 0.01);
499    }
500}