1pub 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#[derive(Clone, Debug)]
36pub struct QueryHit {
37 pub embedding: Vec<f32>,
39 pub timestamp_secs: u64,
41 pub query_id: u64,
43}
44
45#[derive(Clone, Debug)]
49pub struct HotspotRegion {
50 pub center: Vec<f32>,
52 pub hit_count: u64,
54 pub radius: f32,
56 pub last_hit_secs: u64,
58}
59
60impl HotspotRegion {
61 #[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#[derive(Clone, Debug)]
74pub struct HotspotConfig {
75 pub similarity_threshold: f32,
78 pub min_hits_to_report: u64,
80 pub max_regions: usize,
83 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#[derive(Clone, Debug)]
103pub struct HotspotStats {
104 pub total_hits: u64,
106 pub active_regions: usize,
108 pub hottest_region_hits: u64,
110 pub avg_hits_per_region: f64,
112}
113
114pub struct SemanticHotspotDetector {
124 pub regions: Vec<HotspotRegion>,
126 pub config: HotspotConfig,
128 pub total_hits: u64,
130}
131
132impl SemanticHotspotDetector {
133 pub fn new(config: HotspotConfig) -> Self {
135 Self {
136 regions: Vec::new(),
137 config,
138 total_hits: 0,
139 }
140 }
141
142 pub fn record_hit(&mut self, hit: QueryHit) {
149 let match_idx = self.regions.iter().position(|region| {
151 cosine_sim(&hit.embedding, ®ion.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 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 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 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 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 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#[cfg(test)]
236mod tests {
237 use super::*;
238
239 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 #[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 #[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 #[test]
279 fn test_record_hit_merges_similar() {
280 let mut det = SemanticHotspotDetector::new(default_config());
281 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 #[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 #[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 #[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 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 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 #[test]
337 fn test_different_embedding_new_region() {
338 let mut det = SemanticHotspotDetector::new(default_config());
339 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 #[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 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)); det.record_hit(make_hit(e0.clone(), 110, 2)); det.record_hit(make_hit(e0.clone(), 120, 3)); det.record_hit(make_hit(e1.clone(), 200, 4)); det.record_hit(make_hit(e1.clone(), 210, 5)); det.record_hit(make_hit(e2.clone(), 300, 6)); assert_eq!(det.regions.len(), 3);
370
371 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 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 #[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 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 #[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 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 #[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 det.evict_stale(700);
445 assert!(det.regions.is_empty(), "stale region should be removed");
446 }
447
448 #[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 det.evict_stale(700);
458 assert_eq!(det.regions.len(), 1, "fresh region should survive eviction");
459 }
460
461 #[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 assert!(!region.is_stale(1200, 500));
472 }
473
474 #[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 assert!(region.is_stale(1500, 100));
485 }
486
487 #[test]
489 fn test_stats_total_hits() {
490 let mut det = SemanticHotspotDetector::new(default_config());
491 for i in 0..7u64 {
492 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 #[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 #[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 #[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 #[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 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 #[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 #[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); assert_eq!(det.total_hits, 2, "total_hits must not be decremented");
564 }
565
566 #[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 #[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}