Skip to main content

batuta/oracle/rag/
profiling.rs

1//! RAG Profiling
2//!
3//! Tracing spans and histogram metrics for RAG query performance.
4
5use std::collections::HashMap;
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::sync::Mutex;
8use std::time::{Duration, Instant};
9
10/// Histogram bucket for latency measurements
11#[derive(Debug, Clone, Copy)]
12pub struct HistogramBucket {
13    /// Upper bound in milliseconds
14    pub le: f64,
15    /// Count of observations
16    pub count: u64,
17}
18
19/// A simple histogram for latency measurements
20#[derive(Debug)]
21pub struct Histogram {
22    /// Bucket boundaries in milliseconds
23    buckets: Vec<f64>,
24    /// Counts per bucket
25    counts: Vec<AtomicU64>,
26    /// Sum of all observations
27    sum: AtomicU64,
28    /// Total count
29    total: AtomicU64,
30}
31
32impl Histogram {
33    /// Create a new histogram with default latency buckets (in ms)
34    pub fn new() -> Self {
35        // Standard latency buckets: 1ms, 5ms, 10ms, 25ms, 50ms, 100ms, 250ms, 500ms, 1s, 2.5s, 5s, 10s
36        let buckets =
37            vec![1.0, 5.0, 10.0, 25.0, 50.0, 100.0, 250.0, 500.0, 1000.0, 2500.0, 5000.0, 10000.0];
38        let counts = buckets.iter().map(|_| AtomicU64::new(0)).collect();
39
40        Self { buckets, counts, sum: AtomicU64::new(0), total: AtomicU64::new(0) }
41    }
42
43    /// Create a histogram with custom buckets
44    pub fn with_buckets(buckets: Vec<f64>) -> Self {
45        let counts = buckets.iter().map(|_| AtomicU64::new(0)).collect();
46
47        Self { buckets, counts, sum: AtomicU64::new(0), total: AtomicU64::new(0) }
48    }
49
50    /// Observe a duration
51    pub fn observe(&self, duration: Duration) {
52        let ms = duration.as_secs_f64() * 1000.0;
53
54        // Update sum (storing as microseconds for precision)
55        let us = (ms * 1000.0) as u64;
56        self.sum.fetch_add(us, Ordering::Relaxed);
57        self.total.fetch_add(1, Ordering::Relaxed);
58
59        // Update bucket counts
60        for (i, &le) in self.buckets.iter().enumerate() {
61            if ms <= le {
62                self.counts[i].fetch_add(1, Ordering::Relaxed);
63            }
64        }
65    }
66
67    /// Get the current bucket counts
68    pub fn get_buckets(&self) -> Vec<HistogramBucket> {
69        self.buckets
70            .iter()
71            .zip(self.counts.iter())
72            .map(|(&le, count)| HistogramBucket { le, count: count.load(Ordering::Relaxed) })
73            .collect()
74    }
75
76    /// Get the total count
77    pub fn count(&self) -> u64 {
78        self.total.load(Ordering::Relaxed)
79    }
80
81    /// Get the sum in milliseconds
82    pub fn sum_ms(&self) -> f64 {
83        let us = self.sum.load(Ordering::Relaxed);
84        us as f64 / 1000.0
85    }
86
87    /// Calculate approximate percentile (p50, p90, p99, etc.)
88    pub fn percentile(&self, p: f64) -> f64 {
89        let total = self.count();
90        if total == 0 {
91            return 0.0;
92        }
93
94        let target = (total as f64 * p / 100.0).ceil() as u64;
95        let buckets = self.get_buckets();
96
97        for bucket in &buckets {
98            if bucket.count >= target {
99                return bucket.le;
100            }
101        }
102
103        // Return the largest bucket boundary
104        self.buckets.last().copied().unwrap_or(0.0)
105    }
106
107    /// Get p50 latency
108    pub fn p50(&self) -> f64 {
109        self.percentile(50.0)
110    }
111
112    /// Get p90 latency
113    pub fn p90(&self) -> f64 {
114        self.percentile(90.0)
115    }
116
117    /// Get p99 latency
118    pub fn p99(&self) -> f64 {
119        self.percentile(99.0)
120    }
121
122    /// Get mean latency in milliseconds
123    pub fn mean(&self) -> f64 {
124        let count = self.count();
125        if count == 0 {
126            return 0.0;
127        }
128        self.sum_ms() / count as f64
129    }
130
131    /// Reset all counters
132    pub fn reset(&self) {
133        self.sum.store(0, Ordering::Relaxed);
134        self.total.store(0, Ordering::Relaxed);
135        for count in &self.counts {
136            count.store(0, Ordering::Relaxed);
137        }
138    }
139}
140
141impl Default for Histogram {
142    fn default() -> Self {
143        Self::new()
144    }
145}
146
147/// A simple counter metric
148#[derive(Debug, Default)]
149pub struct Counter {
150    value: AtomicU64,
151}
152
153impl Counter {
154    /// Create a new counter
155    pub fn new() -> Self {
156        Self { value: AtomicU64::new(0) }
157    }
158
159    /// Increment by 1
160    pub fn inc(&self) {
161        self.value.fetch_add(1, Ordering::Relaxed);
162    }
163
164    /// Increment by a specific amount
165    pub fn inc_by(&self, n: u64) {
166        self.value.fetch_add(n, Ordering::Relaxed);
167    }
168
169    /// Get the current value
170    pub fn get(&self) -> u64 {
171        self.value.load(Ordering::Relaxed)
172    }
173
174    /// Reset the counter
175    pub fn reset(&self) {
176        self.value.store(0, Ordering::Relaxed);
177    }
178}
179
180/// RAG metrics collector
181#[derive(Debug)]
182pub struct RagMetrics {
183    /// Query latency histogram
184    pub query_latency: Histogram,
185    /// Index load latency histogram
186    pub index_load_latency: Histogram,
187    /// Cache hit counter
188    pub cache_hits: Counter,
189    /// Cache miss counter
190    pub cache_misses: Counter,
191    /// Total queries counter
192    pub total_queries: Counter,
193    /// Documents retrieved counter
194    pub docs_retrieved: Counter,
195    /// Custom spans
196    spans: Mutex<HashMap<String, SpanStats>>,
197}
198
199/// Statistics for a named span
200#[derive(Debug, Clone, Default)]
201pub struct SpanStats {
202    /// Number of invocations
203    pub count: u64,
204    /// Total duration in microseconds
205    pub total_us: u64,
206    /// Min duration in microseconds
207    pub min_us: u64,
208    /// Max duration in microseconds
209    pub max_us: u64,
210}
211
212impl RagMetrics {
213    /// Create new metrics collector
214    pub fn new() -> Self {
215        Self {
216            query_latency: Histogram::new(),
217            index_load_latency: Histogram::new(),
218            cache_hits: Counter::new(),
219            cache_misses: Counter::new(),
220            total_queries: Counter::new(),
221            docs_retrieved: Counter::new(),
222            spans: Mutex::new(HashMap::new()),
223        }
224    }
225
226    /// Record a span's duration
227    pub fn record_span(&self, name: &str, duration: Duration) {
228        let us = duration.as_micros() as u64;
229
230        let mut spans = self.spans.lock().unwrap_or_else(|e| e.into_inner());
231        let stats = spans.entry(name.to_string()).or_default();
232
233        stats.count += 1;
234        stats.total_us += us;
235
236        if stats.min_us == 0 || us < stats.min_us {
237            stats.min_us = us;
238        }
239        if us > stats.max_us {
240            stats.max_us = us;
241        }
242    }
243
244    /// Get span statistics
245    pub fn get_span_stats(&self, name: &str) -> Option<SpanStats> {
246        let spans = self.spans.lock().unwrap_or_else(|e| e.into_inner());
247        spans.get(name).cloned()
248    }
249
250    /// Get all span statistics
251    pub fn all_span_stats(&self) -> HashMap<String, SpanStats> {
252        let spans = self.spans.lock().unwrap_or_else(|e| e.into_inner());
253        spans.clone()
254    }
255
256    /// Get cache hit rate
257    pub fn cache_hit_rate(&self) -> f64 {
258        let hits = self.cache_hits.get();
259        let misses = self.cache_misses.get();
260        let total = hits + misses;
261        if total == 0 {
262            return 0.0;
263        }
264        hits as f64 / total as f64
265    }
266
267    /// Reset all metrics
268    pub fn reset(&self) {
269        self.query_latency.reset();
270        self.index_load_latency.reset();
271        self.cache_hits.reset();
272        self.cache_misses.reset();
273        self.total_queries.reset();
274        self.docs_retrieved.reset();
275        self.spans.lock().unwrap_or_else(|e| e.into_inner()).clear();
276    }
277
278    /// Generate a summary report
279    pub fn summary(&self) -> MetricsSummary {
280        MetricsSummary {
281            total_queries: self.total_queries.get(),
282            query_latency_p50_ms: self.query_latency.p50(),
283            query_latency_p90_ms: self.query_latency.p90(),
284            query_latency_p99_ms: self.query_latency.p99(),
285            query_latency_mean_ms: self.query_latency.mean(),
286            cache_hit_rate: self.cache_hit_rate(),
287            cache_hits: self.cache_hits.get(),
288            cache_misses: self.cache_misses.get(),
289            docs_retrieved: self.docs_retrieved.get(),
290            spans: self.all_span_stats(),
291        }
292    }
293}
294
295impl Default for RagMetrics {
296    fn default() -> Self {
297        Self::new()
298    }
299}
300
301/// Summary of RAG metrics
302#[derive(Debug, Clone)]
303pub struct MetricsSummary {
304    /// Total queries executed
305    pub total_queries: u64,
306    /// Query latency p50 in milliseconds
307    pub query_latency_p50_ms: f64,
308    /// Query latency p90 in milliseconds
309    pub query_latency_p90_ms: f64,
310    /// Query latency p99 in milliseconds
311    pub query_latency_p99_ms: f64,
312    /// Query latency mean in milliseconds
313    pub query_latency_mean_ms: f64,
314    /// Cache hit rate (0.0 - 1.0)
315    pub cache_hit_rate: f64,
316    /// Total cache hits
317    pub cache_hits: u64,
318    /// Total cache misses
319    pub cache_misses: u64,
320    /// Total documents retrieved
321    pub docs_retrieved: u64,
322    /// Span statistics
323    pub spans: HashMap<String, SpanStats>,
324}
325
326impl std::fmt::Display for MetricsSummary {
327    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
328        writeln!(f, "RAG Metrics Summary")?;
329        writeln!(f, "===================")?;
330        writeln!(f, "Total Queries: {}", self.total_queries)?;
331        writeln!(f)?;
332        writeln!(f, "Query Latency:")?;
333        writeln!(f, "  p50:  {:.2}ms", self.query_latency_p50_ms)?;
334        writeln!(f, "  p90:  {:.2}ms", self.query_latency_p90_ms)?;
335        writeln!(f, "  p99:  {:.2}ms", self.query_latency_p99_ms)?;
336        writeln!(f, "  mean: {:.2}ms", self.query_latency_mean_ms)?;
337        writeln!(f)?;
338        writeln!(f, "Cache:")?;
339        writeln!(f, "  Hit Rate: {:.1}%", self.cache_hit_rate * 100.0)?;
340        writeln!(f, "  Hits:     {}", self.cache_hits)?;
341        writeln!(f, "  Misses:   {}", self.cache_misses)?;
342        writeln!(f)?;
343        writeln!(f, "Documents Retrieved: {}", self.docs_retrieved)?;
344
345        if !self.spans.is_empty() {
346            writeln!(f)?;
347            writeln!(f, "Spans:")?;
348            for (name, stats) in &self.spans {
349                let avg_us = if stats.count > 0 { stats.total_us / stats.count } else { 0 };
350                writeln!(
351                    f,
352                    "  {}: count={}, avg={:.2}ms, min={:.2}ms, max={:.2}ms",
353                    name,
354                    stats.count,
355                    avg_us as f64 / 1000.0,
356                    stats.min_us as f64 / 1000.0,
357                    stats.max_us as f64 / 1000.0
358                )?;
359            }
360        }
361
362        Ok(())
363    }
364}
365
366/// A timed span that records duration on drop
367pub struct TimedSpan<'a> {
368    name: String,
369    start: Instant,
370    metrics: &'a RagMetrics,
371}
372
373impl<'a> TimedSpan<'a> {
374    /// Create a new timed span
375    pub fn new(name: &str, metrics: &'a RagMetrics) -> Self {
376        Self { name: name.to_string(), start: crate::timing::start_timer(), metrics }
377    }
378
379    /// Get elapsed time without finishing
380    pub fn elapsed(&self) -> Duration {
381        self.start.elapsed()
382    }
383}
384
385impl Drop for TimedSpan<'_> {
386    fn drop(&mut self) {
387        let duration = self.start.elapsed();
388        self.metrics.record_span(&self.name, duration);
389    }
390}
391
392/// Global metrics instance (thread-safe)
393pub static GLOBAL_METRICS: std::sync::LazyLock<RagMetrics> =
394    std::sync::LazyLock::new(RagMetrics::new);
395
396/// Start a timed span using global metrics
397pub fn span(name: &str) -> TimedSpan<'static> {
398    TimedSpan::new(name, &GLOBAL_METRICS)
399}
400
401/// Record a query latency using global metrics
402pub fn record_query_latency(duration: Duration) {
403    GLOBAL_METRICS.query_latency.observe(duration);
404    GLOBAL_METRICS.total_queries.inc();
405}
406
407/// Record a cache hit using global metrics
408pub fn record_cache_hit() {
409    GLOBAL_METRICS.cache_hits.inc();
410}
411
412/// Record a cache miss using global metrics
413pub fn record_cache_miss() {
414    GLOBAL_METRICS.cache_misses.inc();
415}
416
417/// Get global metrics summary
418pub fn get_summary() -> MetricsSummary {
419    GLOBAL_METRICS.summary()
420}
421
422/// Reset global metrics
423pub fn reset_metrics() {
424    GLOBAL_METRICS.reset();
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430
431    #[test]
432    fn test_histogram_creation() {
433        let hist = Histogram::new();
434        assert_eq!(hist.count(), 0);
435        assert_eq!(hist.sum_ms(), 0.0);
436    }
437
438    #[test]
439    fn test_histogram_observe() {
440        let hist = Histogram::new();
441        hist.observe(Duration::from_millis(5));
442        hist.observe(Duration::from_millis(10));
443        hist.observe(Duration::from_millis(50));
444
445        assert_eq!(hist.count(), 3);
446        // Sum should be approximately 65ms
447        assert!((hist.sum_ms() - 65.0).abs() < 1.0);
448    }
449
450    #[test]
451    fn test_histogram_percentiles() {
452        let hist = Histogram::new();
453
454        // Add 100 observations spread across buckets
455        for i in 1..=100 {
456            hist.observe(Duration::from_millis(i));
457        }
458
459        // p50 should be around 50ms bucket
460        let p50 = hist.p50();
461        assert!(p50 >= 50.0, "p50 should be >= 50ms, got {}", p50);
462
463        // p99 should be higher
464        let p99 = hist.p99();
465        assert!(p99 >= p50, "p99 should be >= p50");
466    }
467
468    #[test]
469    fn test_histogram_mean() {
470        let hist = Histogram::new();
471        hist.observe(Duration::from_millis(10));
472        hist.observe(Duration::from_millis(20));
473        hist.observe(Duration::from_millis(30));
474
475        let mean = hist.mean();
476        assert!((mean - 20.0).abs() < 1.0, "mean should be ~20ms, got {}", mean);
477    }
478
479    #[test]
480    fn test_histogram_reset() {
481        let hist = Histogram::new();
482        hist.observe(Duration::from_millis(10));
483        assert_eq!(hist.count(), 1);
484
485        hist.reset();
486        assert_eq!(hist.count(), 0);
487        assert_eq!(hist.sum_ms(), 0.0);
488    }
489
490    #[test]
491    fn test_counter_basic() {
492        let counter = Counter::new();
493        assert_eq!(counter.get(), 0);
494
495        counter.inc();
496        assert_eq!(counter.get(), 1);
497
498        counter.inc_by(5);
499        assert_eq!(counter.get(), 6);
500    }
501
502    #[test]
503    fn test_counter_reset() {
504        let counter = Counter::new();
505        counter.inc_by(100);
506        assert_eq!(counter.get(), 100);
507
508        counter.reset();
509        assert_eq!(counter.get(), 0);
510    }
511
512    #[test]
513    fn test_rag_metrics_creation() {
514        let metrics = RagMetrics::new();
515        assert_eq!(metrics.total_queries.get(), 0);
516        assert_eq!(metrics.cache_hits.get(), 0);
517    }
518
519    #[test]
520    fn test_rag_metrics_record_span() {
521        let metrics = RagMetrics::new();
522
523        metrics.record_span("test_span", Duration::from_millis(10));
524        metrics.record_span("test_span", Duration::from_millis(20));
525
526        let stats = metrics.get_span_stats("test_span").expect("unexpected failure");
527        assert_eq!(stats.count, 2);
528        assert_eq!(stats.total_us, 30_000);
529        assert_eq!(stats.min_us, 10_000);
530        assert_eq!(stats.max_us, 20_000);
531    }
532
533    #[test]
534    fn test_rag_metrics_cache_hit_rate() {
535        let metrics = RagMetrics::new();
536
537        // No hits or misses
538        assert_eq!(metrics.cache_hit_rate(), 0.0);
539
540        // 3 hits, 2 misses = 60% hit rate
541        metrics.cache_hits.inc_by(3);
542        metrics.cache_misses.inc_by(2);
543        assert!((metrics.cache_hit_rate() - 0.6).abs() < 0.001);
544    }
545
546    #[test]
547    fn test_rag_metrics_summary() {
548        let metrics = RagMetrics::new();
549
550        metrics.total_queries.inc_by(100);
551        metrics.cache_hits.inc_by(80);
552        metrics.cache_misses.inc_by(20);
553        metrics.docs_retrieved.inc_by(500);
554
555        // Add some query latencies
556        for _ in 0..50 {
557            metrics.query_latency.observe(Duration::from_millis(15));
558        }
559        for _ in 0..50 {
560            metrics.query_latency.observe(Duration::from_millis(25));
561        }
562
563        let summary = metrics.summary();
564        assert_eq!(summary.total_queries, 100);
565        assert_eq!(summary.cache_hits, 80);
566        assert_eq!(summary.cache_misses, 20);
567        assert!((summary.cache_hit_rate - 0.8).abs() < 0.001);
568        assert_eq!(summary.docs_retrieved, 500);
569    }
570
571    #[test]
572    fn test_rag_metrics_reset() {
573        let metrics = RagMetrics::new();
574
575        metrics.total_queries.inc_by(100);
576        metrics.cache_hits.inc_by(50);
577        metrics.record_span("span1", Duration::from_millis(10));
578
579        metrics.reset();
580
581        assert_eq!(metrics.total_queries.get(), 0);
582        assert_eq!(metrics.cache_hits.get(), 0);
583        assert!(metrics.all_span_stats().is_empty());
584    }
585
586    #[test]
587    fn test_timed_span() {
588        let metrics = RagMetrics::new();
589
590        {
591            let _span = TimedSpan::new("test", &metrics);
592            std::thread::sleep(Duration::from_millis(5));
593        }
594
595        let stats = metrics.get_span_stats("test").expect("unexpected failure");
596        assert_eq!(stats.count, 1);
597        assert!(stats.total_us >= 5_000, "should be at least 5ms");
598    }
599
600    #[test]
601    fn test_metrics_summary_display() {
602        let metrics = RagMetrics::new();
603        metrics.total_queries.inc_by(10);
604        metrics.cache_hits.inc_by(8);
605        metrics.cache_misses.inc_by(2);
606
607        let summary = metrics.summary();
608        let display = format!("{}", summary);
609
610        assert!(display.contains("RAG Metrics Summary"));
611        assert!(display.contains("Total Queries: 10"));
612        assert!(display.contains("Hit Rate: 80.0%"));
613    }
614
615    #[test]
616    fn test_histogram_custom_buckets() {
617        let hist = Histogram::with_buckets(vec![1.0, 10.0, 100.0]);
618        hist.observe(Duration::from_millis(5));
619
620        let buckets = hist.get_buckets();
621        assert_eq!(buckets.len(), 3);
622        assert_eq!(buckets[0].le, 1.0);
623        assert_eq!(buckets[1].le, 10.0);
624        assert_eq!(buckets[2].le, 100.0);
625    }
626
627    #[test]
628    fn test_global_metrics() {
629        // Reset first to ensure clean state
630        reset_metrics();
631
632        record_cache_hit();
633        record_cache_hit();
634        record_cache_miss();
635
636        let summary = get_summary();
637        assert_eq!(summary.cache_hits, 2);
638        assert_eq!(summary.cache_misses, 1);
639
640        reset_metrics();
641        let summary = get_summary();
642        assert_eq!(summary.cache_hits, 0);
643    }
644
645    #[test]
646    fn test_span_helper() {
647        reset_metrics();
648
649        {
650            let _s = span("helper_test");
651            std::thread::sleep(Duration::from_millis(1));
652        }
653
654        let stats = GLOBAL_METRICS.get_span_stats("helper_test");
655        assert!(stats.is_some());
656        assert_eq!(stats.expect("unexpected failure").count, 1);
657
658        reset_metrics();
659    }
660
661    #[test]
662    fn test_histogram_percentile() {
663        let hist = Histogram::new();
664
665        // Add values so we know the distribution
666        for _ in 0..10 {
667            hist.observe(Duration::from_millis(5));
668        }
669        for _ in 0..90 {
670            hist.observe(Duration::from_millis(50));
671        }
672
673        // p10 should be in the low bucket
674        let p10 = hist.percentile(0.10);
675        assert!(p10 <= 10.0, "p10 should be <= 10ms, got {}", p10);
676    }
677
678    #[test]
679    fn test_histogram_p90() {
680        let hist = Histogram::new();
681        for i in 1..=100 {
682            hist.observe(Duration::from_millis(i));
683        }
684
685        let p90 = hist.p90();
686        assert!(p90 >= 90.0, "p90 should be >= 90ms, got {}", p90);
687    }
688
689    #[test]
690    fn test_timed_span_elapsed() {
691        let metrics = RagMetrics::new();
692        let span = TimedSpan::new("elapsed_test", &metrics);
693        let elapsed = span.elapsed();
694        // Elapsed is always non-negative; avoid wall-clock assertions
695        assert!(elapsed >= Duration::ZERO);
696    }
697
698    #[test]
699    #[ignore = "flaky: global metrics state races with parallel tests (reset_metrics/get_summary)"]
700    fn test_record_query_latency() {
701        reset_metrics();
702        record_query_latency(Duration::from_millis(10));
703        record_query_latency(Duration::from_millis(20));
704
705        let summary = get_summary();
706        assert_eq!(summary.total_queries, 2);
707        assert!(summary.query_latency_p50_ms >= 10.0);
708
709        reset_metrics();
710    }
711
712    #[test]
713    fn test_histogram_default() {
714        let hist = Histogram::default();
715        assert_eq!(hist.count(), 0);
716    }
717
718    #[test]
719    fn test_counter_default() {
720        let counter = Counter::default();
721        assert_eq!(counter.get(), 0);
722    }
723
724    #[test]
725    fn test_span_stats_default() {
726        let stats = SpanStats::default();
727        assert_eq!(stats.count, 0);
728        assert_eq!(stats.total_us, 0);
729        // Default uses 0 for all fields
730        assert_eq!(stats.min_us, 0);
731        assert_eq!(stats.max_us, 0);
732    }
733
734    #[test]
735    fn test_metrics_summary_fields() {
736        let metrics = RagMetrics::new();
737        let summary = metrics.summary();
738        assert_eq!(summary.total_queries, 0);
739        assert_eq!(summary.cache_hits, 0);
740        assert_eq!(summary.query_latency_p50_ms, 0.0);
741        assert_eq!(summary.query_latency_p99_ms, 0.0);
742    }
743
744    #[test]
745    fn test_all_span_stats() {
746        let metrics = RagMetrics::new();
747        metrics.record_span("span_a", Duration::from_millis(10));
748        metrics.record_span("span_b", Duration::from_millis(20));
749
750        let all = metrics.all_span_stats();
751        assert!(all.contains_key("span_a"));
752        assert!(all.contains_key("span_b"));
753        assert_eq!(all.len(), 2);
754    }
755
756    #[test]
757    fn test_histogram_empty_percentile() {
758        let hist = Histogram::new();
759        // Empty histogram should return 0
760        assert_eq!(hist.p50(), 0.0);
761    }
762
763    #[test]
764    fn test_histogram_empty_mean() {
765        let hist = Histogram::new();
766        // Empty histogram should return 0
767        assert_eq!(hist.mean(), 0.0);
768    }
769
770    #[test]
771    fn test_histogram_bucket_fields() {
772        let bucket = HistogramBucket { le: 100.0, count: 42 };
773        assert_eq!(bucket.le, 100.0);
774        assert_eq!(bucket.count, 42);
775    }
776
777    #[test]
778    fn test_histogram_bucket_copy() {
779        let bucket = HistogramBucket { le: 50.0, count: 10 };
780        let copied = bucket;
781        assert_eq!(copied.le, bucket.le);
782        assert_eq!(copied.count, bucket.count);
783    }
784
785    #[test]
786    fn test_span_stats_clone() {
787        let stats = SpanStats { count: 5, total_us: 5000, min_us: 100, max_us: 2000 };
788        let cloned = stats.clone();
789        assert_eq!(cloned.count, 5);
790        assert_eq!(cloned.total_us, 5000);
791    }
792
793    #[test]
794    fn test_get_span_stats_none() {
795        let metrics = RagMetrics::new();
796        assert!(metrics.get_span_stats("nonexistent").is_none());
797    }
798
799    #[test]
800    fn test_metrics_summary_display_with_spans() {
801        let metrics = RagMetrics::new();
802        metrics.record_span("tokenize", Duration::from_millis(10));
803        metrics.record_span("retrieve", Duration::from_millis(50));
804
805        let summary = metrics.summary();
806        let display = format!("{}", summary);
807
808        assert!(display.contains("Spans:"));
809        assert!(display.contains("tokenize"));
810        assert!(display.contains("retrieve"));
811    }
812
813    #[test]
814    fn test_metrics_summary_clone() {
815        let metrics = RagMetrics::new();
816        metrics.total_queries.inc_by(5);
817        let summary = metrics.summary();
818        let cloned = summary.clone();
819        assert_eq!(cloned.total_queries, 5);
820    }
821
822    #[test]
823    fn test_histogram_percentile_returns_first_matching_bucket() {
824        let hist = Histogram::with_buckets(vec![1.0, 2.0, 3.0]);
825        // Add observation that fits in first bucket (0.5ms <= 1.0)
826        hist.observe(Duration::from_micros(500)); // 0.5ms
827
828        // p50 with 1 observation: target = ceil(1 * 50/100) = 1
829        // First bucket with count >= 1 is bucket 1.0
830        let p50 = hist.percentile(50.0);
831        assert_eq!(p50, 1.0);
832    }
833
834    #[test]
835    fn test_rag_metrics_default() {
836        let metrics = RagMetrics::default();
837        assert_eq!(metrics.total_queries.get(), 0);
838    }
839
840    #[test]
841    fn test_histogram_observe_large_values() {
842        let hist = Histogram::new();
843        hist.observe(Duration::from_secs(15)); // 15000ms, beyond last bucket
844
845        assert_eq!(hist.count(), 1);
846        // p99 for single large value should be the largest bucket
847        let p99 = hist.p99();
848        assert_eq!(p99, 10000.0); // Last bucket is 10s
849    }
850
851    #[test]
852    fn test_histogram_debug() {
853        let hist = Histogram::new();
854        let debug = format!("{:?}", hist);
855        assert!(debug.contains("Histogram"));
856    }
857
858    #[test]
859    fn test_counter_debug() {
860        let counter = Counter::new();
861        let debug = format!("{:?}", counter);
862        assert!(debug.contains("Counter"));
863    }
864
865    #[test]
866    fn test_rag_metrics_debug() {
867        let metrics = RagMetrics::new();
868        let debug = format!("{:?}", metrics);
869        assert!(debug.contains("RagMetrics"));
870    }
871
872    #[test]
873    fn test_span_stats_debug() {
874        let stats = SpanStats::default();
875        let debug = format!("{:?}", stats);
876        assert!(debug.contains("SpanStats"));
877    }
878
879    #[test]
880    fn test_metrics_summary_debug() {
881        let metrics = RagMetrics::new();
882        let summary = metrics.summary();
883        let debug = format!("{:?}", summary);
884        assert!(debug.contains("MetricsSummary"));
885    }
886}