Skip to main content

ipfrs_semantic/
stats.rs

1//! Index statistics and monitoring
2//!
3//! This module provides comprehensive statistics collection and monitoring
4//! for vector indexes, enabling performance analysis and optimization.
5
6use serde::{Deserialize, Serialize};
7use std::collections::VecDeque;
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::sync::{Arc, RwLock};
10use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
11
12/// Index statistics collector
13#[derive(Default)]
14pub struct IndexStats {
15    /// Total number of inserts
16    insert_count: AtomicU64,
17    /// Total number of deletes
18    delete_count: AtomicU64,
19    /// Total number of searches
20    search_count: AtomicU64,
21    /// Search latency histogram
22    search_latencies: Arc<RwLock<LatencyHistogram>>,
23    /// Insert latency histogram
24    insert_latencies: Arc<RwLock<LatencyHistogram>>,
25    /// Cache hit count
26    cache_hits: AtomicU64,
27    /// Cache miss count
28    cache_misses: AtomicU64,
29    /// Timestamp when stats started collecting
30    start_time: u64,
31    /// Recent query log for analysis
32    recent_queries: Arc<RwLock<VecDeque<QueryRecord>>>,
33    /// Maximum recent queries to keep
34    max_recent_queries: usize,
35}
36
37impl IndexStats {
38    /// Create a new stats collector
39    pub fn new() -> Self {
40        Self {
41            insert_count: AtomicU64::new(0),
42            delete_count: AtomicU64::new(0),
43            search_count: AtomicU64::new(0),
44            search_latencies: Arc::new(RwLock::new(LatencyHistogram::new())),
45            insert_latencies: Arc::new(RwLock::new(LatencyHistogram::new())),
46            cache_hits: AtomicU64::new(0),
47            cache_misses: AtomicU64::new(0),
48            start_time: SystemTime::now()
49                .duration_since(UNIX_EPOCH)
50                .unwrap_or_default()
51                .as_secs(),
52            recent_queries: Arc::new(RwLock::new(VecDeque::new())),
53            max_recent_queries: 1000,
54        }
55    }
56
57    /// Record an insert operation
58    pub fn record_insert(&self, duration: Duration) {
59        self.insert_count.fetch_add(1, Ordering::Relaxed);
60        self.insert_latencies
61            .write()
62            .unwrap_or_else(|e| e.into_inner())
63            .record(duration.as_micros() as u64);
64    }
65
66    /// Record a delete operation
67    pub fn record_delete(&self) {
68        self.delete_count.fetch_add(1, Ordering::Relaxed);
69    }
70
71    /// Record a search operation
72    pub fn record_search(&self, duration: Duration, k: usize, result_count: usize) {
73        self.search_count.fetch_add(1, Ordering::Relaxed);
74        self.search_latencies
75            .write()
76            .unwrap_or_else(|e| e.into_inner())
77            .record(duration.as_micros() as u64);
78
79        // Record query details
80        let mut queries = self
81            .recent_queries
82            .write()
83            .unwrap_or_else(|e| e.into_inner());
84        if queries.len() >= self.max_recent_queries {
85            queries.pop_front();
86        }
87        queries.push_back(QueryRecord {
88            timestamp: SystemTime::now()
89                .duration_since(UNIX_EPOCH)
90                .unwrap_or_default()
91                .as_secs(),
92            latency_us: duration.as_micros() as u64,
93            k,
94            result_count,
95        });
96    }
97
98    /// Record a cache hit
99    pub fn record_cache_hit(&self) {
100        self.cache_hits.fetch_add(1, Ordering::Relaxed);
101    }
102
103    /// Record a cache miss
104    pub fn record_cache_miss(&self) {
105        self.cache_misses.fetch_add(1, Ordering::Relaxed);
106    }
107
108    /// Get a snapshot of current statistics
109    pub fn snapshot(&self) -> StatsSnapshot {
110        let search_latencies = self
111            .search_latencies
112            .read()
113            .unwrap_or_else(|e| e.into_inner());
114        let insert_latencies = self
115            .insert_latencies
116            .read()
117            .unwrap_or_else(|e| e.into_inner());
118
119        let cache_hits = self.cache_hits.load(Ordering::Relaxed);
120        let cache_misses = self.cache_misses.load(Ordering::Relaxed);
121        let total_cache = cache_hits + cache_misses;
122
123        StatsSnapshot {
124            insert_count: self.insert_count.load(Ordering::Relaxed),
125            delete_count: self.delete_count.load(Ordering::Relaxed),
126            search_count: self.search_count.load(Ordering::Relaxed),
127            search_latency_p50: search_latencies.percentile(50),
128            search_latency_p90: search_latencies.percentile(90),
129            search_latency_p99: search_latencies.percentile(99),
130            search_latency_avg: search_latencies.average(),
131            insert_latency_avg: insert_latencies.average(),
132            cache_hit_rate: if total_cache > 0 {
133                cache_hits as f64 / total_cache as f64
134            } else {
135                0.0
136            },
137            uptime_seconds: SystemTime::now()
138                .duration_since(UNIX_EPOCH)
139                .unwrap_or_default()
140                .as_secs()
141                - self.start_time,
142        }
143    }
144
145    /// Reset all statistics
146    pub fn reset(&self) {
147        self.insert_count.store(0, Ordering::Relaxed);
148        self.delete_count.store(0, Ordering::Relaxed);
149        self.search_count.store(0, Ordering::Relaxed);
150        self.search_latencies
151            .write()
152            .unwrap_or_else(|e| e.into_inner())
153            .reset();
154        self.insert_latencies
155            .write()
156            .unwrap_or_else(|e| e.into_inner())
157            .reset();
158        self.cache_hits.store(0, Ordering::Relaxed);
159        self.cache_misses.store(0, Ordering::Relaxed);
160        self.recent_queries
161            .write()
162            .unwrap_or_else(|e| e.into_inner())
163            .clear();
164    }
165
166    /// Get recent query records
167    pub fn recent_queries(&self) -> Vec<QueryRecord> {
168        self.recent_queries
169            .read()
170            .unwrap_or_else(|e| e.into_inner())
171            .iter()
172            .cloned()
173            .collect()
174    }
175
176    /// Calculate queries per second (QPS)
177    pub fn qps(&self) -> f64 {
178        let uptime = SystemTime::now()
179            .duration_since(UNIX_EPOCH)
180            .unwrap_or_default()
181            .as_secs()
182            - self.start_time;
183
184        if uptime > 0 {
185            self.search_count.load(Ordering::Relaxed) as f64 / uptime as f64
186        } else {
187            0.0
188        }
189    }
190}
191
192/// Statistics snapshot at a point in time
193#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct StatsSnapshot {
195    /// Total insert operations
196    pub insert_count: u64,
197    /// Total delete operations
198    pub delete_count: u64,
199    /// Total search operations
200    pub search_count: u64,
201    /// Search latency P50 (microseconds)
202    pub search_latency_p50: u64,
203    /// Search latency P90 (microseconds)
204    pub search_latency_p90: u64,
205    /// Search latency P99 (microseconds)
206    pub search_latency_p99: u64,
207    /// Average search latency (microseconds)
208    pub search_latency_avg: u64,
209    /// Average insert latency (microseconds)
210    pub insert_latency_avg: u64,
211    /// Cache hit rate (0.0 to 1.0)
212    pub cache_hit_rate: f64,
213    /// Uptime in seconds
214    pub uptime_seconds: u64,
215}
216
217impl StatsSnapshot {
218    /// Format latency as human-readable string
219    pub fn format_latency(us: u64) -> String {
220        if us < 1000 {
221            format!("{}µs", us)
222        } else if us < 1_000_000 {
223            format!("{:.2}ms", us as f64 / 1000.0)
224        } else {
225            format!("{:.2}s", us as f64 / 1_000_000.0)
226        }
227    }
228
229    /// Get a summary string
230    pub fn summary(&self) -> String {
231        format!(
232            "Searches: {} (P50: {}, P99: {}), Inserts: {}, Cache: {:.1}%",
233            self.search_count,
234            Self::format_latency(self.search_latency_p50),
235            Self::format_latency(self.search_latency_p99),
236            self.insert_count,
237            self.cache_hit_rate * 100.0
238        )
239    }
240}
241
242/// Query record for analysis
243#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct QueryRecord {
245    /// Unix timestamp
246    pub timestamp: u64,
247    /// Latency in microseconds
248    pub latency_us: u64,
249    /// K parameter (number of results requested)
250    pub k: usize,
251    /// Actual result count
252    pub result_count: usize,
253}
254
255/// Latency histogram for percentile calculations
256#[derive(Default)]
257pub struct LatencyHistogram {
258    /// Sorted latencies (in microseconds)
259    values: Vec<u64>,
260    /// Sum for average calculation
261    sum: u64,
262    /// Count
263    count: u64,
264}
265
266impl LatencyHistogram {
267    /// Create a new histogram
268    pub fn new() -> Self {
269        Self::default()
270    }
271
272    /// Record a latency value
273    pub fn record(&mut self, value_us: u64) {
274        // Keep sorted for percentile calculation
275        let pos = self.values.binary_search(&value_us).unwrap_or_else(|i| i);
276        self.values.insert(pos, value_us);
277
278        self.sum += value_us;
279        self.count += 1;
280
281        // Keep bounded to avoid memory growth
282        if self.values.len() > 10000 {
283            // Remove oldest values (this is approximate)
284            self.values.drain(0..1000);
285        }
286    }
287
288    /// Get percentile value
289    pub fn percentile(&self, p: u8) -> u64 {
290        if self.values.is_empty() {
291            return 0;
292        }
293
294        let idx = ((p as usize) * self.values.len() / 100).min(self.values.len() - 1);
295        self.values[idx]
296    }
297
298    /// Get average value
299    pub fn average(&self) -> u64 {
300        if self.count == 0 {
301            return 0;
302        }
303        self.sum / self.count
304    }
305
306    /// Reset the histogram
307    pub fn reset(&mut self) {
308        self.values.clear();
309        self.sum = 0;
310        self.count = 0;
311    }
312
313    /// Get total count
314    pub fn count(&self) -> u64 {
315        self.count
316    }
317}
318
319/// Index health metrics
320#[derive(Debug, Clone, Serialize, Deserialize)]
321pub struct IndexHealth {
322    /// Index size (number of vectors)
323    pub size: usize,
324    /// Estimated memory usage (bytes)
325    pub memory_bytes: usize,
326    /// Vector dimension
327    pub dimension: usize,
328    /// Average connectivity (HNSW specific)
329    pub avg_connectivity: Option<f32>,
330    /// Search recall estimate (if available)
331    pub recall_estimate: Option<f32>,
332    /// Overall health score (0.0 to 1.0)
333    pub health_score: f32,
334    /// Issues detected
335    pub issues: Vec<HealthIssue>,
336}
337
338/// Health issue description
339#[derive(Debug, Clone, Serialize, Deserialize)]
340pub struct HealthIssue {
341    /// Issue severity (0 = info, 1 = warning, 2 = error)
342    pub severity: u8,
343    /// Issue description
344    pub message: String,
345    /// Recommendation
346    pub recommendation: String,
347}
348
349impl IndexHealth {
350    /// Create health metrics for an index
351    pub fn analyze(size: usize, dimension: usize, stats: Option<&StatsSnapshot>) -> Self {
352        let mut issues = Vec::new();
353        let mut health_score = 1.0;
354
355        // Estimate memory usage (HNSW overhead ~= 4 * dimension * M bytes per vector)
356        let memory_bytes = size * dimension * 4 + size * dimension * 4 * 16;
357
358        // Check for potential issues
359        if size == 0 {
360            issues.push(HealthIssue {
361                severity: 0,
362                message: "Index is empty".to_string(),
363                recommendation: "Add vectors to enable semantic search".to_string(),
364            });
365            health_score *= 0.9;
366        }
367
368        if let Some(s) = stats {
369            // Check latency
370            if s.search_latency_p99 > 100_000 {
371                // > 100ms
372                issues.push(HealthIssue {
373                    severity: 2,
374                    message: format!(
375                        "High P99 search latency: {}",
376                        StatsSnapshot::format_latency(s.search_latency_p99)
377                    ),
378                    recommendation: "Consider reducing ef_search or optimizing index parameters"
379                        .to_string(),
380                });
381                health_score *= 0.7;
382            } else if s.search_latency_p99 > 10_000 {
383                // > 10ms
384                issues.push(HealthIssue {
385                    severity: 1,
386                    message: format!(
387                        "Elevated P99 search latency: {}",
388                        StatsSnapshot::format_latency(s.search_latency_p99)
389                    ),
390                    recommendation: "Monitor latency trends".to_string(),
391                });
392                health_score *= 0.9;
393            }
394
395            // Check cache hit rate
396            if s.cache_hit_rate < 0.5 && s.search_count > 100 {
397                issues.push(HealthIssue {
398                    severity: 1,
399                    message: format!("Low cache hit rate: {:.1}%", s.cache_hit_rate * 100.0),
400                    recommendation: "Consider increasing cache size".to_string(),
401                });
402                health_score *= 0.95;
403            }
404        }
405
406        // Check size for performance
407        if size > 1_000_000 {
408            issues.push(HealthIssue {
409                severity: 1,
410                message: format!("Large index size: {} vectors", size),
411                recommendation:
412                    "Consider using DiskANN or quantization for better memory efficiency"
413                        .to_string(),
414            });
415        }
416
417        Self {
418            size,
419            memory_bytes,
420            dimension,
421            avg_connectivity: None,
422            recall_estimate: None,
423            health_score,
424            issues,
425        }
426    }
427}
428
429/// Performance timer for measuring operation latencies
430pub struct PerfTimer {
431    start: Instant,
432}
433
434impl PerfTimer {
435    /// Start a new timer
436    pub fn start() -> Self {
437        Self {
438            start: Instant::now(),
439        }
440    }
441
442    /// Get elapsed duration
443    pub fn elapsed(&self) -> Duration {
444        self.start.elapsed()
445    }
446
447    /// Stop and return duration
448    pub fn stop(self) -> Duration {
449        self.start.elapsed()
450    }
451}
452
453/// Memory usage tracker
454#[derive(Debug, Clone, Serialize, Deserialize)]
455pub struct MemoryUsage {
456    /// Vector data memory (bytes)
457    pub vectors_bytes: usize,
458    /// Index structure memory (bytes)
459    pub index_bytes: usize,
460    /// Metadata memory (bytes)
461    pub metadata_bytes: usize,
462    /// Cache memory (bytes)
463    pub cache_bytes: usize,
464    /// Total memory (bytes)
465    pub total_bytes: usize,
466}
467
468impl MemoryUsage {
469    /// Estimate memory usage
470    pub fn estimate(
471        num_vectors: usize,
472        dimension: usize,
473        metadata_count: usize,
474        cache_size: usize,
475    ) -> Self {
476        // Vector storage: num_vectors * dimension * 4 bytes (f32)
477        let vectors_bytes = num_vectors * dimension * 4;
478
479        // HNSW index overhead: approximately M * num_vectors * 4 * 2 bytes for graph
480        // Assuming M = 16
481        let index_bytes = 16 * num_vectors * 4 * 2;
482
483        // Metadata: rough estimate of 200 bytes per entry
484        let metadata_bytes = metadata_count * 200;
485
486        // Cache: cached vectors + overhead
487        let cache_bytes = cache_size * dimension * 4 * 2;
488
489        let total_bytes = vectors_bytes + index_bytes + metadata_bytes + cache_bytes;
490
491        Self {
492            vectors_bytes,
493            index_bytes,
494            metadata_bytes,
495            cache_bytes,
496            total_bytes,
497        }
498    }
499
500    /// Format as human-readable string
501    pub fn format_bytes(bytes: usize) -> String {
502        if bytes < 1024 {
503            format!("{} B", bytes)
504        } else if bytes < 1024 * 1024 {
505            format!("{:.2} KB", bytes as f64 / 1024.0)
506        } else if bytes < 1024 * 1024 * 1024 {
507            format!("{:.2} MB", bytes as f64 / (1024.0 * 1024.0))
508        } else {
509            format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
510        }
511    }
512
513    /// Get formatted summary
514    pub fn summary(&self) -> String {
515        format!(
516            "Total: {} (Vectors: {}, Index: {}, Metadata: {}, Cache: {})",
517            Self::format_bytes(self.total_bytes),
518            Self::format_bytes(self.vectors_bytes),
519            Self::format_bytes(self.index_bytes),
520            Self::format_bytes(self.metadata_bytes),
521            Self::format_bytes(self.cache_bytes),
522        )
523    }
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529
530    #[test]
531    fn test_stats_recording() {
532        let stats = IndexStats::new();
533
534        // Record some operations
535        stats.record_insert(Duration::from_micros(100));
536        stats.record_insert(Duration::from_micros(200));
537        stats.record_search(Duration::from_micros(50), 10, 10);
538        stats.record_search(Duration::from_micros(150), 10, 8);
539        stats.record_cache_hit();
540        stats.record_cache_miss();
541
542        let snapshot = stats.snapshot();
543
544        assert_eq!(snapshot.insert_count, 2);
545        assert_eq!(snapshot.search_count, 2);
546        assert!(snapshot.cache_hit_rate > 0.4 && snapshot.cache_hit_rate < 0.6);
547    }
548
549    #[test]
550    fn test_latency_histogram() {
551        let mut histogram = LatencyHistogram::new();
552
553        for i in 1..=100 {
554            histogram.record(i);
555        }
556
557        assert_eq!(histogram.count(), 100);
558        // Percentile 50 should be around 50-51 (0-indexed array, so idx 50 = value 51)
559        let p50 = histogram.percentile(50);
560        assert!((50..=52).contains(&p50), "P50 was {}", p50);
561        assert!(histogram.percentile(99) >= 99);
562        // Average of 1..=100 is 50.5, rounded to 50
563        assert!(histogram.average() >= 50 && histogram.average() <= 51);
564    }
565
566    #[test]
567    fn test_index_health() {
568        let health = IndexHealth::analyze(1000, 768, None);
569
570        assert!(health.health_score > 0.0);
571        assert_eq!(health.size, 1000);
572        assert_eq!(health.dimension, 768);
573    }
574
575    #[test]
576    fn test_memory_usage() {
577        let usage = MemoryUsage::estimate(10000, 768, 10000, 1000);
578
579        // Should be in MB range for this size
580        assert!(usage.total_bytes > 1024 * 1024);
581        assert!(usage.vectors_bytes > 0);
582    }
583
584    #[test]
585    fn test_perf_timer() {
586        let timer = PerfTimer::start();
587        std::thread::sleep(Duration::from_millis(10));
588        let elapsed = timer.stop();
589
590        assert!(elapsed >= Duration::from_millis(10));
591    }
592}