Skip to main content

ipfrs_semantic/
hotspot_detector.rs

1//! Semantic Hotspot Detector
2//!
3//! Detects "hotspot" regions in an embedding space — clusters of frequently
4//! queried vectors — enabling pre-warming, caching priority, and index
5//! rebalancing signals.
6
7// ── Cosine similarity helper ─────────────────────────────────────────────────
8
9/// Compute cosine similarity between two vectors.
10///
11/// Returns `0.0` if either vector has zero magnitude.
12pub fn cosine_sim(a: &[f32], b: &[f32]) -> f32 {
13    debug_assert_eq!(a.len(), b.len(), "cosine_sim: dimension mismatch");
14    if a.len() != b.len() || a.is_empty() {
15        return 0.0;
16    }
17
18    let (mut dot, mut mag_a, mut mag_b) = (0.0_f32, 0.0_f32, 0.0_f32);
19    for (x, y) in a.iter().zip(b.iter()) {
20        dot += x * y;
21        mag_a += x * x;
22        mag_b += y * y;
23    }
24
25    if mag_a == 0.0 || mag_b == 0.0 {
26        return 0.0;
27    }
28
29    dot / (mag_a.sqrt() * mag_b.sqrt())
30}
31
32// ── QueryHit ─────────────────────────────────────────────────────────────────
33
34/// A single query event recorded in the embedding space.
35#[derive(Clone, Debug)]
36pub struct QueryHit {
37    /// The query embedding vector.
38    pub embedding: Vec<f32>,
39    /// Unix timestamp (seconds) when the query was made.
40    pub timestamp_secs: u64,
41    /// Unique identifier for the query.
42    pub query_id: u64,
43}
44
45// ── HotspotRegion ─────────────────────────────────────────────────────────────
46
47/// A cluster of frequently queried vectors in embedding space.
48#[derive(Clone, Debug)]
49pub struct HotspotRegion {
50    /// Centroid of the queries assigned to this region.
51    pub center: Vec<f32>,
52    /// Number of query hits accumulated by this region.
53    pub hit_count: u64,
54    /// Maximum distance (1.0 − cosine_sim) from the center to any member query.
55    pub radius: f32,
56    /// Unix timestamp (seconds) of the most recent hit.
57    pub last_hit_secs: u64,
58}
59
60impl HotspotRegion {
61    /// Returns `true` when the region has not been hit within `ttl_secs`.
62    ///
63    /// A region is stale when `last_hit_secs + ttl_secs < now_secs`.
64    #[inline]
65    pub fn is_stale(&self, now_secs: u64, ttl_secs: u64) -> bool {
66        self.last_hit_secs.saturating_add(ttl_secs) < now_secs
67    }
68}
69
70// ── HotspotConfig ─────────────────────────────────────────────────────────────
71
72/// Configuration for [`SemanticHotspotDetector`].
73#[derive(Clone, Debug)]
74pub struct HotspotConfig {
75    /// Cosine similarity threshold: a hit is merged into an existing region
76    /// when `cosine_sim(hit, center) >= similarity_threshold`.
77    pub similarity_threshold: f32,
78    /// Minimum `hit_count` required for a region to appear in [`SemanticHotspotDetector::hotspots`].
79    pub min_hits_to_report: u64,
80    /// Maximum number of regions to maintain.  When exceeded the region with
81    /// the lowest `hit_count` is evicted.
82    pub max_regions: usize,
83    /// Regions whose `last_hit_secs` is older than this many seconds are
84    /// eligible for removal via [`SemanticHotspotDetector::evict_stale`].
85    pub ttl_secs: u64,
86}
87
88impl Default for HotspotConfig {
89    fn default() -> Self {
90        Self {
91            similarity_threshold: 0.85,
92            min_hits_to_report: 3,
93            max_regions: 50,
94            ttl_secs: 3600,
95        }
96    }
97}
98
99// ── HotspotStats ──────────────────────────────────────────────────────────────
100
101/// Aggregate statistics for the hotspot detector.
102#[derive(Clone, Debug)]
103pub struct HotspotStats {
104    /// Total number of query hits recorded since creation.
105    pub total_hits: u64,
106    /// Current number of active (non-evicted) regions.
107    pub active_regions: usize,
108    /// `hit_count` of the hottest region, or `0` if there are no regions.
109    pub hottest_region_hits: u64,
110    /// Mean `hit_count` across all regions, or `0.0` if there are no regions.
111    pub avg_hits_per_region: f64,
112}
113
114// ── SemanticHotspotDetector ───────────────────────────────────────────────────
115
116/// Detects frequently queried regions in an embedding space.
117///
118/// Incoming [`QueryHit`]s are merged into existing [`HotspotRegion`]s when
119/// the cosine similarity between the hit and the region's centroid exceeds
120/// [`HotspotConfig::similarity_threshold`].  When no existing region matches a
121/// new one is created.  Regions that exceed [`HotspotConfig::max_regions`] or
122/// have not been hit recently are evicted.
123pub struct SemanticHotspotDetector {
124    /// Active hotspot regions.
125    pub regions: Vec<HotspotRegion>,
126    /// Detector configuration.
127    pub config: HotspotConfig,
128    /// Cumulative hit counter (never decremented on eviction).
129    pub total_hits: u64,
130}
131
132impl SemanticHotspotDetector {
133    /// Create a new detector with the given configuration.
134    pub fn new(config: HotspotConfig) -> Self {
135        Self {
136            regions: Vec::new(),
137            config,
138            total_hits: 0,
139        }
140    }
141
142    /// Record a new query hit.
143    ///
144    /// The hit is merged into the first region whose center has a cosine
145    /// similarity ≥ `config.similarity_threshold`.  If no such region exists a
146    /// new one is created.  When the number of regions exceeds `max_regions`
147    /// the region with the lowest `hit_count` is evicted.
148    pub fn record_hit(&mut self, hit: QueryHit) {
149        // Find first matching region.
150        let match_idx = self.regions.iter().position(|region| {
151            cosine_sim(&hit.embedding, &region.center) >= self.config.similarity_threshold
152        });
153
154        if let Some(idx) = match_idx {
155            let sim = cosine_sim(&hit.embedding, &self.regions[idx].center);
156            let distance = 1.0_f32 - sim;
157            let region = &mut self.regions[idx];
158            region.hit_count += 1;
159            if hit.timestamp_secs > region.last_hit_secs {
160                region.last_hit_secs = hit.timestamp_secs;
161            }
162            if distance > region.radius {
163                region.radius = distance;
164            }
165        } else {
166            // Create a new region centred on this hit.
167            self.regions.push(HotspotRegion {
168                center: hit.embedding,
169                hit_count: 1,
170                radius: 0.0,
171                last_hit_secs: hit.timestamp_secs,
172            });
173
174            // Enforce max_regions by evicting the coldest region.
175            if self.regions.len() > self.config.max_regions {
176                let coldest = self
177                    .regions
178                    .iter()
179                    .enumerate()
180                    .min_by_key(|(_, r)| r.hit_count)
181                    .map(|(i, _)| i);
182
183                if let Some(evict_idx) = coldest {
184                    self.regions.swap_remove(evict_idx);
185                }
186            }
187        }
188
189        self.total_hits += 1;
190    }
191
192    /// Return references to regions with `hit_count >= min_hits_to_report`,
193    /// sorted by `hit_count` descending.
194    pub fn hotspots(&self) -> Vec<&HotspotRegion> {
195        let mut result: Vec<&HotspotRegion> = self
196            .regions
197            .iter()
198            .filter(|r| r.hit_count >= self.config.min_hits_to_report)
199            .collect();
200
201        result.sort_by_key(|r| std::cmp::Reverse(r.hit_count));
202        result
203    }
204
205    /// Remove regions that are stale relative to `now_secs`.
206    pub fn evict_stale(&mut self, now_secs: u64) {
207        self.regions
208            .retain(|r| !r.is_stale(now_secs, self.config.ttl_secs));
209    }
210
211    /// Return aggregate statistics for the detector.
212    pub fn stats(&self) -> HotspotStats {
213        let active_regions = self.regions.len();
214
215        let hottest_region_hits = self.regions.iter().map(|r| r.hit_count).max().unwrap_or(0);
216
217        let avg_hits_per_region = if active_regions == 0 {
218            0.0
219        } else {
220            let total: u64 = self.regions.iter().map(|r| r.hit_count).sum();
221            total as f64 / active_regions as f64
222        };
223
224        HotspotStats {
225            total_hits: self.total_hits,
226            active_regions,
227            hottest_region_hits,
228            avg_hits_per_region,
229        }
230    }
231}
232
233// ── Tests ─────────────────────────────────────────────────────────────────────
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    /// Normalised unit vector in a given dimension (for predictable cosine sims).
240    fn unit_vec(dim: usize, active: usize) -> Vec<f32> {
241        let mut v = vec![0.0_f32; dim];
242        if active < dim {
243            v[active] = 1.0;
244        }
245        v
246    }
247
248    fn default_config() -> HotspotConfig {
249        HotspotConfig::default()
250    }
251
252    fn make_hit(embedding: Vec<f32>, ts: u64, id: u64) -> QueryHit {
253        QueryHit {
254            embedding,
255            timestamp_secs: ts,
256            query_id: id,
257        }
258    }
259
260    // 1. new() starts with no regions
261    #[test]
262    fn test_new_starts_empty() {
263        let det = SemanticHotspotDetector::new(default_config());
264        assert!(det.regions.is_empty());
265        assert_eq!(det.total_hits, 0);
266    }
267
268    // 2. record_hit creates a region for a new embedding
269    #[test]
270    fn test_record_hit_creates_region() {
271        let mut det = SemanticHotspotDetector::new(default_config());
272        det.record_hit(make_hit(unit_vec(4, 0), 100, 1));
273        assert_eq!(det.regions.len(), 1);
274        assert_eq!(det.regions[0].hit_count, 1);
275    }
276
277    // 3. record_hit merges a similar hit into an existing region
278    #[test]
279    fn test_record_hit_merges_similar() {
280        let mut det = SemanticHotspotDetector::new(default_config());
281        // Both vectors identical → cosine_sim == 1.0 ≥ 0.85
282        let emb = unit_vec(4, 0);
283        det.record_hit(make_hit(emb.clone(), 100, 1));
284        det.record_hit(make_hit(emb.clone(), 200, 2));
285        assert_eq!(det.regions.len(), 1, "identical hits should merge");
286    }
287
288    // 4. record_hit increments hit_count correctly
289    #[test]
290    fn test_record_hit_increments_count() {
291        let mut det = SemanticHotspotDetector::new(default_config());
292        let emb = unit_vec(4, 0);
293        for i in 0..5 {
294            det.record_hit(make_hit(emb.clone(), 100 + i, i));
295        }
296        assert_eq!(det.regions[0].hit_count, 5);
297    }
298
299    // 5. record_hit updates last_hit_secs
300    #[test]
301    fn test_record_hit_updates_last_hit_secs() {
302        let mut det = SemanticHotspotDetector::new(default_config());
303        let emb = unit_vec(4, 0);
304        det.record_hit(make_hit(emb.clone(), 100, 1));
305        det.record_hit(make_hit(emb.clone(), 999, 2));
306        assert_eq!(det.regions[0].last_hit_secs, 999);
307    }
308
309    // 6. record_hit updates radius (distance = 1.0 - sim)
310    #[test]
311    fn test_record_hit_updates_radius() {
312        let mut det = SemanticHotspotDetector::new(HotspotConfig {
313            similarity_threshold: 0.5,
314            ..default_config()
315        });
316        // Create region with e0 = [1,0,0,0]
317        let e0 = unit_vec(4, 0);
318        det.record_hit(make_hit(e0.clone(), 100, 1));
319        assert_eq!(det.regions[0].radius, 0.0);
320
321        // A vector at 45° to e0: [1,1,0,0] / sqrt(2)
322        let root2 = 2.0_f32.sqrt();
323        let e45 = vec![1.0 / root2, 1.0 / root2, 0.0, 0.0];
324        let sim = cosine_sim(&e45, &e0);
325        let expected_radius = 1.0 - sim;
326
327        det.record_hit(make_hit(e45, 200, 2));
328        let actual_radius = det.regions[0].radius;
329        assert!(
330            (actual_radius - expected_radius).abs() < 1e-5,
331            "radius={actual_radius} expected≈{expected_radius}"
332        );
333    }
334
335    // 7. Different embedding creates a separate region
336    #[test]
337    fn test_different_embedding_new_region() {
338        let mut det = SemanticHotspotDetector::new(default_config());
339        // e0 and e1 are orthogonal → cosine_sim == 0.0 < 0.85
340        det.record_hit(make_hit(unit_vec(4, 0), 100, 1));
341        det.record_hit(make_hit(unit_vec(4, 1), 200, 2));
342        assert_eq!(det.regions.len(), 2);
343    }
344
345    // 8. max_regions evicts the region with the lowest hit_count
346    #[test]
347    fn test_max_regions_evicts_coldest() {
348        let mut det = SemanticHotspotDetector::new(HotspotConfig {
349            max_regions: 3,
350            ..default_config()
351        });
352
353        // Create 3 orthogonal regions and bump the first two counts.
354        let e0 = unit_vec(8, 0);
355        let e1 = unit_vec(8, 1);
356        let e2 = unit_vec(8, 2);
357        let e3 = unit_vec(8, 3);
358
359        det.record_hit(make_hit(e0.clone(), 100, 1)); // region 0: hits=1
360        det.record_hit(make_hit(e0.clone(), 110, 2)); // region 0: hits=2
361        det.record_hit(make_hit(e0.clone(), 120, 3)); // region 0: hits=3
362
363        det.record_hit(make_hit(e1.clone(), 200, 4)); // region 1: hits=1
364        det.record_hit(make_hit(e1.clone(), 210, 5)); // region 1: hits=2
365
366        det.record_hit(make_hit(e2.clone(), 300, 6)); // region 2: hits=1
367
368        // At this point we have 3 regions (= max_regions).
369        assert_eq!(det.regions.len(), 3);
370
371        // Adding a 4th orthogonal vector triggers eviction of region with hit_count=1 (e2 or e3).
372        det.record_hit(make_hit(e3.clone(), 400, 7));
373        assert_eq!(
374            det.regions.len(),
375            3,
376            "after eviction we should still have max_regions"
377        );
378
379        // The region with the highest hit_count (3) must still be present.
380        let max_hits = det.regions.iter().map(|r| r.hit_count).max().unwrap_or(0);
381        assert_eq!(max_hits, 3, "hottest region must survive eviction");
382    }
383
384    // 9. hotspots filters by min_hits_to_report
385    #[test]
386    fn test_hotspots_filters_by_min_hits() {
387        let mut det = SemanticHotspotDetector::new(HotspotConfig {
388            min_hits_to_report: 3,
389            ..default_config()
390        });
391        let e0 = unit_vec(4, 0);
392        let e1 = unit_vec(4, 1);
393
394        // e0 gets 5 hits, e1 gets 2
395        for i in 0..5u64 {
396            det.record_hit(make_hit(e0.clone(), 100 + i, i));
397        }
398        for i in 0..2u64 {
399            det.record_hit(make_hit(e1.clone(), 200 + i, 10 + i));
400        }
401
402        let hot = det.hotspots();
403        assert_eq!(hot.len(), 1, "only regions with hit_count>=3 should appear");
404        assert_eq!(hot[0].hit_count, 5);
405    }
406
407    // 10. hotspots sorted by hit_count descending
408    #[test]
409    fn test_hotspots_sorted_descending() {
410        let mut det = SemanticHotspotDetector::new(HotspotConfig {
411            min_hits_to_report: 1,
412            ..default_config()
413        });
414        let vecs = (0..4).map(|i| unit_vec(8, i)).collect::<Vec<_>>();
415
416        // Give them 4, 1, 3, 2 hits respectively.
417        let hit_counts = [4u64, 1, 3, 2];
418        for (i, &count) in hit_counts.iter().enumerate() {
419            for j in 0..count {
420                det.record_hit(make_hit(
421                    vecs[i].clone(),
422                    100 + j,
423                    (i * 10 + j as usize) as u64,
424                ));
425            }
426        }
427
428        let hot = det.hotspots();
429        let counts: Vec<u64> = hot.iter().map(|r| r.hit_count).collect();
430        for window in counts.windows(2) {
431            assert!(window[0] >= window[1], "hotspots must be sorted desc");
432        }
433    }
434
435    // 11. evict_stale removes old regions
436    #[test]
437    fn test_evict_stale_removes_old() {
438        let mut det = SemanticHotspotDetector::new(HotspotConfig {
439            ttl_secs: 100,
440            ..default_config()
441        });
442        det.record_hit(make_hit(unit_vec(4, 0), 500, 1));
443        // Region last_hit=500; now=700 → 500+100=600 < 700 → stale
444        det.evict_stale(700);
445        assert!(det.regions.is_empty(), "stale region should be removed");
446    }
447
448    // 12. evict_stale keeps fresh regions
449    #[test]
450    fn test_evict_stale_keeps_fresh() {
451        let mut det = SemanticHotspotDetector::new(HotspotConfig {
452            ttl_secs: 1000,
453            ..default_config()
454        });
455        det.record_hit(make_hit(unit_vec(4, 0), 500, 1));
456        // last_hit=500; now=700 → 500+1000=1500 > 700 → fresh
457        det.evict_stale(700);
458        assert_eq!(det.regions.len(), 1, "fresh region should survive eviction");
459    }
460
461    // 13. is_stale returns false when within TTL
462    #[test]
463    fn test_is_stale_false_within_ttl() {
464        let region = HotspotRegion {
465            center: vec![1.0],
466            hit_count: 1,
467            radius: 0.0,
468            last_hit_secs: 1000,
469        };
470        // last_hit(1000) + ttl(500) = 1500 >= now(1200) → not stale
471        assert!(!region.is_stale(1200, 500));
472    }
473
474    // 14. is_stale returns true when past TTL
475    #[test]
476    fn test_is_stale_true_past_ttl() {
477        let region = HotspotRegion {
478            center: vec![1.0],
479            hit_count: 1,
480            radius: 0.0,
481            last_hit_secs: 1000,
482        };
483        // last_hit(1000) + ttl(100) = 1100 < now(1500) → stale
484        assert!(region.is_stale(1500, 100));
485    }
486
487    // 15. stats: total_hits accumulates correctly
488    #[test]
489    fn test_stats_total_hits() {
490        let mut det = SemanticHotspotDetector::new(default_config());
491        for i in 0..7u64 {
492            // Alternate between two orthogonal vectors to stress merging path.
493            let emb = unit_vec(4, (i % 2) as usize);
494            det.record_hit(make_hit(emb, 100 + i, i));
495        }
496        assert_eq!(det.stats().total_hits, 7);
497    }
498
499    // 16. stats: active_regions count
500    #[test]
501    fn test_stats_active_regions() {
502        let mut det = SemanticHotspotDetector::new(default_config());
503        det.record_hit(make_hit(unit_vec(4, 0), 100, 1));
504        det.record_hit(make_hit(unit_vec(4, 1), 200, 2));
505        assert_eq!(det.stats().active_regions, 2);
506    }
507
508    // 17. stats: hottest_region_hits is 0 when no regions exist
509    #[test]
510    fn test_stats_hottest_region_hits_empty() {
511        let det = SemanticHotspotDetector::new(default_config());
512        assert_eq!(det.stats().hottest_region_hits, 0);
513    }
514
515    // 17b. stats: hottest_region_hits reflects the most-hit region
516    #[test]
517    fn test_stats_hottest_region_hits() {
518        let mut det = SemanticHotspotDetector::new(default_config());
519        let e0 = unit_vec(4, 0);
520        let e1 = unit_vec(4, 1);
521        for i in 0..4u64 {
522            det.record_hit(make_hit(e0.clone(), 100 + i, i));
523        }
524        det.record_hit(make_hit(e1.clone(), 200, 99));
525        assert_eq!(det.stats().hottest_region_hits, 4);
526    }
527
528    // 18. stats: avg_hits_per_region
529    #[test]
530    fn test_stats_avg_hits_per_region() {
531        let mut det = SemanticHotspotDetector::new(default_config());
532        let e0 = unit_vec(4, 0);
533        let e1 = unit_vec(4, 1);
534
535        // e0 → 3 hits, e1 → 1 hit: avg = (3+1)/2 = 2.0
536        for i in 0..3u64 {
537            det.record_hit(make_hit(e0.clone(), 100 + i, i));
538        }
539        det.record_hit(make_hit(e1.clone(), 200, 99));
540
541        let stats = det.stats();
542        assert_eq!(stats.active_regions, 2);
543        assert!((stats.avg_hits_per_region - 2.0).abs() < 1e-9);
544    }
545
546    // 19. stats: avg_hits_per_region is 0.0 when no regions
547    #[test]
548    fn test_stats_avg_hits_empty() {
549        let det = SemanticHotspotDetector::new(default_config());
550        assert_eq!(det.stats().avg_hits_per_region, 0.0);
551    }
552
553    // 20. total_hits is not decremented after eviction
554    #[test]
555    fn test_total_hits_not_decremented_on_evict() {
556        let mut det = SemanticHotspotDetector::new(HotspotConfig {
557            ttl_secs: 10,
558            ..default_config()
559        });
560        det.record_hit(make_hit(unit_vec(4, 0), 100, 1));
561        det.record_hit(make_hit(unit_vec(4, 0), 105, 2));
562        det.evict_stale(200); // evicts the region
563        assert_eq!(det.total_hits, 2, "total_hits must not be decremented");
564    }
565
566    // 21. cosine_sim returns 0.0 for zero-magnitude vectors
567    #[test]
568    fn test_cosine_sim_zero_magnitude() {
569        let zero = vec![0.0_f32, 0.0, 0.0];
570        let unit = vec![1.0_f32, 0.0, 0.0];
571        assert_eq!(cosine_sim(&zero, &unit), 0.0);
572        assert_eq!(cosine_sim(&unit, &zero), 0.0);
573        assert_eq!(cosine_sim(&zero, &zero), 0.0);
574    }
575
576    // 22. cosine_sim returns 1.0 for identical unit vectors
577    #[test]
578    fn test_cosine_sim_identical() {
579        let v = vec![1.0_f32, 0.0, 0.0];
580        assert!((cosine_sim(&v, &v) - 1.0).abs() < 1e-6);
581    }
582}