1use std::cmp::Ordering;
8use std::collections::VecDeque;
9
10#[inline]
19pub fn xorshift64(state: &mut u64) -> u64 {
20 let mut x = *state;
21 x ^= x << 13;
22 x ^= x >> 7;
23 x ^= x << 17;
24 *state = x;
25 x
26}
27
28#[inline]
30pub fn xorshift_f64(state: &mut u64) -> f64 {
31 (xorshift64(state) >> 11) as f64 / (1u64 << 53) as f64
32}
33
34fn flat_query_cost(n: usize, d: usize) -> f64 {
40 n as f64 * d as f64 * 0.001
41}
42
43fn ivf_query_cost(n: usize, d: usize, n_clusters: usize, k: usize) -> f64 {
45 let n_probe = (n_clusters as f64).sqrt() as usize;
46 let cells_per_cluster = if n_clusters == 0 {
47 1
48 } else {
49 n / n_clusters.max(1)
50 };
51 (n_probe as f64 * cells_per_cluster as f64 + n_probe as f64 * d as f64) * 0.001
52 + k as f64 * 0.0001
53}
54
55fn hnsw_query_cost(n: usize, d: usize, m: usize, ef: usize) -> f64 {
57 let _ = n; (ef as f64 * m as f64 * d as f64).ln().max(1.0) * 0.01
59}
60
61fn lsh_query_cost(d: usize, n_tables: usize, n_bits: u8) -> f64 {
63 n_tables as f64 * n_bits as f64 * 0.001 + d as f64 * 0.0001
64}
65
66fn memory_cost(structure: &IndexStructure, n: usize, d: usize) -> u64 {
68 let base = (n * d * 4) as u64; match structure {
70 IndexStructure::Flat => base,
71 IndexStructure::IvfFlat { n_clusters } => base + (*n_clusters * d * 4) as u64,
72 IndexStructure::HnswLike { m, .. } => base + (n * m * 8) as u64,
73 IndexStructure::Lsh { n_tables, n_bits } => {
74 (n * *n_tables) as u64 * (*n_bits as u64 / 8 + 1)
75 }
76 _ => base,
77 }
78}
79
80#[derive(Clone, Debug, PartialEq)]
86pub enum IndexStructure {
87 Flat,
89 IvfFlat {
91 n_clusters: usize,
93 },
94 PQ {
96 m: usize,
98 bits: u8,
100 },
101 HnswLike {
103 m: usize,
105 ef: usize,
107 },
108 Lsh {
110 n_tables: usize,
112 n_bits: u8,
114 },
115 Tree {
117 max_leaf: usize,
119 },
120}
121
122impl IndexStructure {
123 pub fn name(&self) -> &'static str {
125 match self {
126 Self::Flat => "Flat",
127 Self::IvfFlat { .. } => "IvfFlat",
128 Self::PQ { .. } => "PQ",
129 Self::HnswLike { .. } => "HnswLike",
130 Self::Lsh { .. } => "Lsh",
131 Self::Tree { .. } => "Tree",
132 }
133 }
134}
135
136#[derive(Clone, Debug)]
138pub struct WorkloadProfile {
139 pub query_count: u64,
141 pub insert_count: u64,
143 pub delete_count: u64,
145 pub avg_query_k: usize,
147 pub dataset_size: usize,
149 pub embedding_dim: usize,
151 pub recall_requirement: f64,
153 pub latency_budget_us: u64,
155}
156
157impl Default for WorkloadProfile {
158 fn default() -> Self {
159 Self {
160 query_count: 0,
161 insert_count: 0,
162 delete_count: 0,
163 avg_query_k: 10,
164 dataset_size: 100_000,
165 embedding_dim: 128,
166 recall_requirement: 0.9,
167 latency_budget_us: 10_000,
168 }
169 }
170}
171
172#[derive(Clone, Debug)]
174pub struct IndexRecommendation {
175 pub structure: IndexStructure,
177 pub estimated_build_time_us: u64,
179 pub estimated_query_time_us: u64,
181 pub estimated_recall: f64,
183 pub memory_bytes: u64,
185 pub reason: String,
187}
188
189#[derive(Clone, Debug, PartialEq)]
191pub enum OptimizationCriterion {
192 MinLatency,
194 MaxRecall,
196 MinMemory,
198 Balanced,
200 CostBudget(u64),
202}
203
204#[derive(Clone, Debug)]
206pub struct IndexStats {
207 pub structure_name: String,
209 pub query_count: u64,
211 pub avg_latency_us: f64,
213 pub p99_latency_us: f64,
215 pub recall_estimate: f64,
217 pub memory_bytes: u64,
219 pub last_rebuilt_at: u64,
221}
222
223impl Default for IndexStats {
224 fn default() -> Self {
225 Self {
226 structure_name: "Unknown".to_string(),
227 query_count: 0,
228 avg_latency_us: 0.0,
229 p99_latency_us: 0.0,
230 recall_estimate: 1.0,
231 memory_bytes: 0,
232 last_rebuilt_at: 0,
233 }
234 }
235}
236
237#[derive(Clone, Debug, PartialEq)]
239pub enum MaintenanceAction {
240 Rebuild {
242 reason: String,
244 },
245 Rebalance,
247 AddClusters(usize),
249 PruneDead,
251 MergeSegments,
253}
254
255#[derive(Clone, Debug)]
257pub struct OptimizerConfig {
258 pub criterion: OptimizationCriterion,
260 pub rebuild_threshold: f64,
262 pub profile_window: usize,
264 pub max_memory_bytes: u64,
266}
267
268impl Default for OptimizerConfig {
269 fn default() -> Self {
270 Self {
271 criterion: OptimizationCriterion::Balanced,
272 rebuild_threshold: 0.80,
273 profile_window: 100,
274 max_memory_bytes: u64::MAX,
275 }
276 }
277}
278
279#[derive(Clone, Debug, Default)]
281pub struct OptimizerStats {
282 pub recommendations_made: u64,
284 pub rebuilds_triggered: u64,
286 pub avg_recall_improvement: f64,
288 pub profiling_queries: u64,
290}
291
292#[derive(Clone, Debug, PartialEq)]
294pub enum OptimizerError {
295 InsufficientData(usize),
297 IndexNotFound(String),
299 MaintenanceFailed(String),
301 ConfigurationError(String),
303}
304
305impl std::fmt::Display for OptimizerError {
306 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
307 match self {
308 Self::InsufficientData(n) => {
309 write!(f, "Insufficient data: only {} queries observed", n)
310 }
311 Self::IndexNotFound(name) => write!(f, "Index not found: {}", name),
312 Self::MaintenanceFailed(msg) => write!(f, "Maintenance failed: {}", msg),
313 Self::ConfigurationError(msg) => write!(f, "Configuration error: {}", msg),
314 }
315 }
316}
317
318impl std::error::Error for OptimizerError {}
319
320#[derive(Debug)]
326pub(crate) struct LatencyWindow {
327 data: VecDeque<f64>,
328 capacity: usize,
329 sum: f64,
330}
331
332impl LatencyWindow {
333 fn new(capacity: usize) -> Self {
334 Self {
335 data: VecDeque::with_capacity(capacity),
336 capacity,
337 sum: 0.0,
338 }
339 }
340
341 fn push(&mut self, value: f64) {
342 if self.data.len() >= self.capacity {
343 if let Some(old) = self.data.pop_front() {
344 self.sum -= old;
345 }
346 }
347 self.sum += value;
348 self.data.push_back(value);
349 }
350
351 #[allow(dead_code)]
355 pub(crate) fn mean(&self) -> f64 {
356 if self.data.is_empty() {
357 0.0
358 } else {
359 self.sum / self.data.len() as f64
360 }
361 }
362
363 pub(crate) fn p99(&self) -> f64 {
365 if self.data.is_empty() {
366 return 0.0;
367 }
368 let mut sorted: Vec<f64> = self.data.iter().copied().collect();
369 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
370 let idx = ((sorted.len() as f64 * 0.99) as usize).min(sorted.len() - 1);
371 sorted[idx]
372 }
373
374 fn len(&self) -> usize {
375 self.data.len()
376 }
377}
378
379#[derive(Debug)]
384struct RecallWindow {
385 data: VecDeque<f64>,
386 capacity: usize,
387 sum: f64,
388}
389
390impl RecallWindow {
391 fn new(capacity: usize) -> Self {
392 Self {
393 data: VecDeque::with_capacity(capacity),
394 capacity,
395 sum: 0.0,
396 }
397 }
398
399 fn push(&mut self, value: f64) {
400 if self.data.len() >= self.capacity {
401 if let Some(old) = self.data.pop_front() {
402 self.sum -= old;
403 }
404 }
405 self.sum += value;
406 self.data.push_back(value);
407 }
408
409 pub(crate) fn mean(&self) -> f64 {
410 if self.data.is_empty() {
411 1.0
412 } else {
413 self.sum / self.data.len() as f64
414 }
415 }
416
417 pub(crate) fn len(&self) -> usize {
418 self.data.len()
419 }
420}
421
422pub struct VectorIndexOptimizer {
432 config: OptimizerConfig,
433 query_count: u64,
434 insert_count: u64,
435 delete_count: u64,
436 total_k: u64,
437 k_samples: u64,
438 latency_window: LatencyWindow,
439 recall_window: RecallWindow,
440 recommendations_made: u64,
441 rebuilds_triggered: u64,
442 recall_improvement_sum: f64,
443 recall_improvement_count: u64,
444 pre_rebuild_recall: Option<f64>,
446}
447
448impl VectorIndexOptimizer {
449 pub fn new(config: OptimizerConfig) -> Self {
451 let window = config.profile_window.max(10);
452 Self {
453 config,
454 query_count: 0,
455 insert_count: 0,
456 delete_count: 0,
457 total_k: 0,
458 k_samples: 0,
459 latency_window: LatencyWindow::new(window * 4),
460 recall_window: RecallWindow::new(window * 4),
461 recommendations_made: 0,
462 rebuilds_triggered: 0,
463 recall_improvement_sum: 0.0,
464 recall_improvement_count: 0,
465 pre_rebuild_recall: None,
466 }
467 }
468
469 pub fn with_defaults() -> Self {
471 Self::new(OptimizerConfig::default())
472 }
473
474 pub fn record_query(&mut self, k: usize, latency_us: u64, recall: f64, _current_ts: u64) {
485 self.query_count += 1;
486 self.total_k += k as u64;
487 self.k_samples += 1;
488 self.latency_window.push(latency_us as f64);
489 let clamped_recall = recall.clamp(0.0, 1.0);
490 self.recall_window.push(clamped_recall);
491 }
492
493 pub fn record_insert(&mut self, _current_ts: u64) {
497 self.insert_count += 1;
498 }
499
500 pub fn record_delete(&mut self, _current_ts: u64) {
502 self.delete_count += 1;
503 }
504
505 pub fn workload_profile(&self, dataset_size: usize, dim: usize) -> WorkloadProfile {
511 let avg_k = self.total_k.checked_div(self.k_samples).unwrap_or(10) as usize;
512 let latency_budget_us = self.latency_window.p99() as u64 + 1;
515 WorkloadProfile {
516 query_count: self.query_count,
517 insert_count: self.insert_count,
518 delete_count: self.delete_count,
519 avg_query_k: avg_k,
520 dataset_size,
521 embedding_dim: dim,
522 recall_requirement: self.recall_window.mean().max(self.config.rebuild_threshold),
523 latency_budget_us,
524 }
525 }
526
527 pub fn recommend(
536 &mut self,
537 dataset_size: usize,
538 dim: usize,
539 ) -> Result<IndexRecommendation, OptimizerError> {
540 let observed = self.latency_window.len();
541 if observed < self.config.profile_window {
542 return Err(OptimizerError::InsufficientData(observed));
543 }
544
545 let profile = self.workload_profile(dataset_size, dim);
546 let candidates = self.generate_candidates(&profile);
547
548 let best = candidates
549 .into_iter()
550 .filter(|r| self.passes_budget(r))
551 .max_by(|a, b| self.compare_recommendations(a, b, &profile));
552
553 let rec = best.ok_or_else(|| {
554 OptimizerError::ConfigurationError(
555 "No valid index structure found within memory budget".to_string(),
556 )
557 })?;
558
559 self.recommendations_made += 1;
560 Ok(rec)
561 }
562
563 fn passes_budget(&self, rec: &IndexRecommendation) -> bool {
565 match &self.config.criterion {
566 OptimizationCriterion::CostBudget(budget) => rec.memory_bytes <= *budget,
567 _ => rec.memory_bytes <= self.config.max_memory_bytes,
568 }
569 }
570
571 fn compare_recommendations(
574 &self,
575 a: &IndexRecommendation,
576 b: &IndexRecommendation,
577 _profile: &WorkloadProfile,
578 ) -> Ordering {
579 match &self.config.criterion {
580 OptimizationCriterion::MinLatency => {
581 b.estimated_query_time_us.cmp(&a.estimated_query_time_us)
583 }
584 OptimizationCriterion::MaxRecall => a
585 .estimated_recall
586 .partial_cmp(&b.estimated_recall)
587 .unwrap_or(Ordering::Equal),
588 OptimizationCriterion::MinMemory => b.memory_bytes.cmp(&a.memory_bytes),
589 OptimizationCriterion::Balanced | OptimizationCriterion::CostBudget(_) => {
590 let score_a = self.balanced_score(a);
591 let score_b = self.balanced_score(b);
592 score_a.partial_cmp(&score_b).unwrap_or(Ordering::Equal)
593 }
594 }
595 }
596
597 fn balanced_score(&self, rec: &IndexRecommendation) -> f64 {
599 let recall_score = rec.estimated_recall;
604 let latency_score = 1.0 / (1.0 + rec.estimated_query_time_us as f64 / 1_000.0);
605 let mem_gb = rec.memory_bytes as f64 / 1_073_741_824.0;
606 let memory_score = 1.0 / (1.0 + mem_gb);
607 0.45 * recall_score + 0.35 * latency_score + 0.20 * memory_score
608 }
609
610 fn generate_candidates(&self, profile: &WorkloadProfile) -> Vec<IndexRecommendation> {
612 let n = profile.dataset_size.max(1);
613 let d = profile.embedding_dim.max(1);
614 let k = profile.avg_query_k.max(1);
615
616 let mut candidates = Vec::with_capacity(6);
617
618 {
620 let structure = IndexStructure::Flat;
621 let q_cost = flat_query_cost(n, d);
622 let mem = memory_cost(&structure, n, d);
623 let recall = 1.0;
624 candidates.push(IndexRecommendation {
625 structure,
626 estimated_build_time_us: (n * d) as u64 / 100,
627 estimated_query_time_us: q_cost as u64,
628 estimated_recall: recall,
629 memory_bytes: mem,
630 reason: format!("Flat exact search — 100% recall, {}µs/query", q_cost as u64),
631 });
632 }
633
634 {
636 let n_clusters = ((n as f64).sqrt() as usize).max(4).min(n);
637 let structure = IndexStructure::IvfFlat { n_clusters };
638 let q_cost = ivf_query_cost(n, d, n_clusters, k);
639 let mem = memory_cost(&structure, n, d);
640 let recall = Self::estimate_recall_static(&structure, n, k);
641 candidates.push(IndexRecommendation {
642 structure,
643 estimated_build_time_us: (n * d / 1000 + n_clusters * 100) as u64,
644 estimated_query_time_us: q_cost as u64,
645 estimated_recall: recall,
646 memory_bytes: mem,
647 reason: format!(
648 "IVF-Flat ({} clusters) — recall≈{:.2}, {}µs/query",
649 n_clusters, recall, q_cost as u64
650 ),
651 });
652 }
653
654 {
656 let m = (d / 4).clamp(2, 64);
657 let bits: u8 = 8;
658 let structure = IndexStructure::PQ { m, bits };
659 let mem = memory_cost(&structure, n, d);
660 let recall = Self::estimate_recall_static(&structure, n, k);
661 let q_cost = flat_query_cost(n, m) * 0.25;
663 candidates.push(IndexRecommendation {
664 structure,
665 estimated_build_time_us: (n * d / 500) as u64,
666 estimated_query_time_us: q_cost as u64,
667 estimated_recall: recall,
668 memory_bytes: mem,
669 reason: format!(
670 "PQ (m={}, bits={}) — memory-efficient, recall≈{:.2}",
671 m, bits, recall
672 ),
673 });
674 }
675
676 {
678 let m: usize = 16;
679 let ef = (k * 4).max(m * 2).min(512);
680 let structure = IndexStructure::HnswLike { m, ef };
681 let q_cost = hnsw_query_cost(n, d, m, ef);
682 let mem = memory_cost(&structure, n, d);
683 let recall = Self::estimate_recall_static(&structure, n, k);
684 candidates.push(IndexRecommendation {
685 structure,
686 estimated_build_time_us: (n as f64 * (n as f64).ln() * d as f64 / 1000.0) as u64,
687 estimated_query_time_us: q_cost as u64,
688 estimated_recall: recall,
689 memory_bytes: mem,
690 reason: format!(
691 "HNSW-like (m={}, ef={}) — low latency, recall≈{:.2}",
692 m, ef, recall
693 ),
694 });
695 }
696
697 {
699 let n_tables: usize = 8;
700 let n_bits: u8 = 8;
701 let structure = IndexStructure::Lsh { n_tables, n_bits };
702 let q_cost = lsh_query_cost(d, n_tables, n_bits);
703 let mem = memory_cost(&structure, n, d);
704 let recall = Self::estimate_recall_static(&structure, n, k);
705 candidates.push(IndexRecommendation {
706 structure,
707 estimated_build_time_us: (n * n_tables) as u64 / 100,
708 estimated_query_time_us: q_cost as u64,
709 estimated_recall: recall,
710 memory_bytes: mem,
711 reason: format!(
712 "LSH ({} tables, {} bits) — very fast, recall≈{:.2}",
713 n_tables, n_bits, recall
714 ),
715 });
716 }
717
718 {
720 let max_leaf = 50;
721 let structure = IndexStructure::Tree { max_leaf };
722 let mem = memory_cost(&structure, n, d);
723 let recall = Self::estimate_recall_static(&structure, n, k);
724 let q_cost = (n as f64).log2() * d as f64 * 0.01;
726 candidates.push(IndexRecommendation {
727 structure,
728 estimated_build_time_us: (n as f64 * (n as f64).log2() * 0.01) as u64,
729 estimated_query_time_us: q_cost as u64,
730 estimated_recall: recall,
731 memory_bytes: mem,
732 reason: format!(
733 "Tree (max_leaf={}) — exact for k=1, recall≈{:.2}",
734 max_leaf, recall
735 ),
736 });
737 }
738
739 candidates
740 }
741
742 pub fn estimate_recall(structure: &IndexStructure, n: usize, k: usize) -> f64 {
748 Self::estimate_recall_static(structure, n, k)
749 }
750
751 fn estimate_recall_static(structure: &IndexStructure, n: usize, k: usize) -> f64 {
752 match structure {
753 IndexStructure::Flat => 1.0,
754 IndexStructure::IvfFlat { n_clusters } => {
755 let n_clusters = n_clusters.max(&1);
756 let penalty = (n / (*n_clusters * 100).max(1)) as f64 * 0.1;
757 (0.95 - penalty).clamp(0.70, 0.99)
758 }
759 IndexStructure::HnswLike { ef, .. } => {
760 let ef = ef.max(&1);
761 let penalty = (k as f64 / *ef as f64) * 0.1;
762 (0.99 - penalty).clamp(0.80, 0.99)
763 }
764 IndexStructure::Lsh { .. } => 0.85,
765 IndexStructure::PQ { .. } => 0.90,
766 IndexStructure::Tree { .. } => {
767 if k == 1 {
768 1.0
769 } else {
770 let decay = (k as f64 - 1.0) * 0.01;
772 (1.0 - decay).clamp(0.70, 1.0)
773 }
774 }
775 }
776 }
777
778 pub fn should_rebuild(&mut self, current_stats: &IndexStats) -> Option<MaintenanceAction> {
786 if current_stats.recall_estimate < self.config.rebuild_threshold {
788 self.rebuilds_triggered += 1;
789 self.pre_rebuild_recall = Some(current_stats.recall_estimate);
791 return Some(MaintenanceAction::Rebuild {
792 reason: format!(
793 "Recall {:.3} dropped below threshold {:.3}",
794 current_stats.recall_estimate, self.config.rebuild_threshold
795 ),
796 });
797 }
798
799 let queries_since_rebuild = current_stats.query_count;
801 if queries_since_rebuild > 1_000_000 {
802 self.rebuilds_triggered += 1;
803 self.pre_rebuild_recall = Some(current_stats.recall_estimate);
804 return Some(MaintenanceAction::Rebuild {
805 reason: format!(
806 "High query count {} since last rebuild — proactive maintenance",
807 queries_since_rebuild
808 ),
809 });
810 }
811
812 None
813 }
814
815 pub fn record_post_rebuild_recall(&mut self, new_recall: f64) {
817 if let Some(pre) = self.pre_rebuild_recall.take() {
818 let improvement = (new_recall - pre).max(0.0);
819 self.recall_improvement_sum += improvement;
820 self.recall_improvement_count += 1;
821 }
822 }
823
824 pub fn maintenance_actions(
826 &mut self,
827 stats: &IndexStats,
828 dataset_size: usize,
829 dim: usize,
830 ) -> Vec<MaintenanceAction> {
831 let mut actions = Vec::new();
832
833 if stats.recall_estimate < self.config.rebuild_threshold {
835 self.rebuilds_triggered += 1;
836 self.pre_rebuild_recall = Some(stats.recall_estimate);
837 actions.push(MaintenanceAction::Rebuild {
838 reason: format!(
839 "Recall {:.3} < threshold {:.3}",
840 stats.recall_estimate, self.config.rebuild_threshold
841 ),
842 });
843 return actions; }
845
846 if stats.p99_latency_us > stats.avg_latency_us * 4.0 && stats.avg_latency_us > 0.0 {
848 actions.push(MaintenanceAction::Rebalance);
849 }
850
851 let est_dead = dataset_size / 20; if self.delete_count > est_dead as u64 {
854 actions.push(MaintenanceAction::PruneDead);
855 }
856
857 let base_mem = (dataset_size * dim * 4) as u64;
859 if stats.memory_bytes > base_mem * 3 {
860 actions.push(MaintenanceAction::MergeSegments);
861 }
862
863 if stats.structure_name == "IvfFlat" && self.insert_count > 100_000 {
865 let extra = ((self.insert_count as f64).sqrt() as usize).max(1);
866 actions.push(MaintenanceAction::AddClusters(extra));
867 }
868
869 actions
870 }
871
872 pub fn compare_structures(
880 &self,
881 a: &IndexStructure,
882 b: &IndexStructure,
883 profile: &WorkloadProfile,
884 ) -> Ordering {
885 let n = profile.dataset_size.max(1);
886 let d = profile.embedding_dim.max(1);
887 let k = profile.avg_query_k.max(1);
888
889 let query_cost_a = self.structure_query_cost(a, n, d, k);
890 let query_cost_b = self.structure_query_cost(b, n, d, k);
891 let recall_a = Self::estimate_recall_static(a, n, k);
892 let recall_b = Self::estimate_recall_static(b, n, k);
893 let mem_a = memory_cost(a, n, d);
894 let mem_b = memory_cost(b, n, d);
895
896 match &self.config.criterion {
897 OptimizationCriterion::MinLatency => {
898 query_cost_b
900 .partial_cmp(&query_cost_a)
901 .unwrap_or(Ordering::Equal)
902 }
903 OptimizationCriterion::MaxRecall => {
904 recall_a.partial_cmp(&recall_b).unwrap_or(Ordering::Equal)
905 }
906 OptimizationCriterion::MinMemory => mem_b.cmp(&mem_a),
907 OptimizationCriterion::Balanced | OptimizationCriterion::CostBudget(_) => {
908 let score_a = self.balanced_score_raw(query_cost_a, recall_a, mem_a);
909 let score_b = self.balanced_score_raw(query_cost_b, recall_b, mem_b);
910 score_a.partial_cmp(&score_b).unwrap_or(Ordering::Equal)
911 }
912 }
913 }
914
915 fn balanced_score_raw(&self, query_cost_us: f64, recall: f64, mem_bytes: u64) -> f64 {
916 let latency_score = 1.0 / (1.0 + query_cost_us / 1_000.0);
917 let mem_gb = mem_bytes as f64 / 1_073_741_824.0;
918 let memory_score = 1.0 / (1.0 + mem_gb);
919 0.45 * recall + 0.35 * latency_score + 0.20 * memory_score
920 }
921
922 fn structure_query_cost(
923 &self,
924 structure: &IndexStructure,
925 n: usize,
926 d: usize,
927 k: usize,
928 ) -> f64 {
929 match structure {
930 IndexStructure::Flat => flat_query_cost(n, d),
931 IndexStructure::IvfFlat { n_clusters } => ivf_query_cost(n, d, *n_clusters, k),
932 IndexStructure::HnswLike { m, ef } => hnsw_query_cost(n, d, *m, *ef),
933 IndexStructure::Lsh { n_tables, n_bits } => lsh_query_cost(d, *n_tables, *n_bits),
934 IndexStructure::PQ { m, .. } => flat_query_cost(n, *m) * 0.25,
935 IndexStructure::Tree { .. } => (n as f64).log2() * d as f64 * 0.01,
936 }
937 }
938
939 pub fn stats(&self) -> OptimizerStats {
945 let avg_recall_improvement = if self.recall_improvement_count == 0 {
946 0.0
947 } else {
948 self.recall_improvement_sum / self.recall_improvement_count as f64
949 };
950 OptimizerStats {
951 recommendations_made: self.recommendations_made,
952 rebuilds_triggered: self.rebuilds_triggered,
953 avg_recall_improvement,
954 profiling_queries: self.query_count,
955 }
956 }
957
958 pub fn profiling_progress(&self) -> (usize, usize) {
964 let ready = self.latency_window.len().min(self.recall_window.len());
967 (ready, self.config.profile_window)
968 }
969}
970
971pub type VioIndexStats = IndexStats;
979
980pub type VioOptimizerConfig = OptimizerConfig;
983
984pub type VioOptimizerError = OptimizerError;
987
988pub type VioOptimizerStats = OptimizerStats;
991
992#[cfg(test)]
997mod tests {
998 use super::*;
999
1000 fn make_optimizer(criterion: OptimizationCriterion) -> VectorIndexOptimizer {
1002 VectorIndexOptimizer::new(OptimizerConfig {
1003 criterion,
1004 rebuild_threshold: 0.80,
1005 profile_window: 5,
1006 max_memory_bytes: u64::MAX,
1007 })
1008 }
1009
1010 fn fill_window(opt: &mut VectorIndexOptimizer, n: usize, latency_us: u64, recall: f64) {
1012 for i in 0..n {
1013 opt.record_query(10, latency_us, recall, 1_000_000 + i as u64);
1014 }
1015 }
1016
1017 #[test]
1022 fn test_record_query_increments_count() {
1023 let mut opt = make_optimizer(OptimizationCriterion::Balanced);
1024 opt.record_query(5, 100, 0.95, 0);
1025 opt.record_query(10, 200, 0.90, 1);
1026 let s = opt.stats();
1027 assert_eq!(s.profiling_queries, 2);
1028 }
1029
1030 #[test]
1031 fn test_record_query_updates_latency_window() {
1032 let mut opt = make_optimizer(OptimizationCriterion::Balanced);
1033 fill_window(&mut opt, 5, 1000, 0.95);
1034 let (current, total) = opt.profiling_progress();
1035 assert!(current >= 5);
1036 assert_eq!(total, 5);
1037 }
1038
1039 #[test]
1040 fn test_record_query_clamps_recall() {
1041 let mut opt = make_optimizer(OptimizationCriterion::Balanced);
1042 opt.record_query(1, 50, 1.5, 0); opt.record_query(1, 50, -0.1, 0); assert_eq!(opt.stats().profiling_queries, 2);
1045 }
1046
1047 #[test]
1048 fn test_record_query_k_averaging() {
1049 let mut opt = make_optimizer(OptimizationCriterion::Balanced);
1050 opt.record_query(10, 100, 0.9, 0);
1051 opt.record_query(20, 100, 0.9, 0);
1052 let profile = opt.workload_profile(1000, 64);
1053 assert_eq!(profile.avg_query_k, 15);
1054 }
1055
1056 #[test]
1061 fn test_record_insert_increments() {
1062 let mut opt = make_optimizer(OptimizationCriterion::Balanced);
1063 opt.record_insert(0);
1064 opt.record_insert(1);
1065 opt.record_insert(2);
1066 let profile = opt.workload_profile(100, 32);
1067 assert_eq!(profile.insert_count, 3);
1068 }
1069
1070 #[test]
1071 fn test_record_insert_does_not_affect_query_count() {
1072 let mut opt = make_optimizer(OptimizationCriterion::Balanced);
1073 opt.record_insert(0);
1074 assert_eq!(opt.stats().profiling_queries, 0);
1075 }
1076
1077 #[test]
1078 fn test_record_insert_large_batch() {
1079 let mut opt = make_optimizer(OptimizationCriterion::Balanced);
1080 for i in 0..1000_u64 {
1081 opt.record_insert(i);
1082 }
1083 let profile = opt.workload_profile(1000, 128);
1084 assert_eq!(profile.insert_count, 1000);
1085 }
1086
1087 #[test]
1092 fn test_recommend_insufficient_data() {
1093 let mut opt = make_optimizer(OptimizationCriterion::Balanced);
1094 opt.record_query(10, 500, 0.9, 0); match opt.recommend(10_000, 128) {
1096 Err(OptimizerError::InsufficientData(n)) => assert!(n < 5),
1097 other => panic!("expected InsufficientData, got {:?}", other),
1098 }
1099 }
1100
1101 #[test]
1102 fn test_recommend_balanced() {
1103 let mut opt = make_optimizer(OptimizationCriterion::Balanced);
1104 fill_window(&mut opt, 10, 500, 0.92);
1105 let rec = opt.recommend(50_000, 128).expect("should recommend");
1106 assert!(rec.estimated_recall >= 0.0 && rec.estimated_recall <= 1.0);
1107 assert!(!rec.reason.is_empty());
1108 }
1109
1110 #[test]
1111 fn test_recommend_min_latency() {
1112 let mut opt = make_optimizer(OptimizationCriterion::MinLatency);
1113 fill_window(&mut opt, 10, 2000, 0.90);
1114 let rec = opt.recommend(100_000, 128).expect("should recommend");
1115 assert!(rec.estimated_query_time_us < 1_000_000);
1117 }
1118
1119 #[test]
1120 fn test_recommend_max_recall() {
1121 let mut opt = make_optimizer(OptimizationCriterion::MaxRecall);
1122 fill_window(&mut opt, 10, 1000, 0.85);
1123 let rec = opt.recommend(10_000, 64).expect("should recommend");
1124 assert!(rec.estimated_recall >= 0.85);
1126 }
1127
1128 #[test]
1129 fn test_recommend_min_memory() {
1130 let mut opt = make_optimizer(OptimizationCriterion::MinMemory);
1131 fill_window(&mut opt, 10, 500, 0.90);
1132 let rec = opt.recommend(50_000, 128).expect("should recommend");
1133 assert!(rec.memory_bytes > 0);
1134 }
1135
1136 #[test]
1137 fn test_recommend_cost_budget_respected() {
1138 let budget: u64 = 1_000_000; let mut opt = VectorIndexOptimizer::new(OptimizerConfig {
1140 criterion: OptimizationCriterion::CostBudget(budget),
1141 profile_window: 5,
1142 rebuild_threshold: 0.80,
1143 max_memory_bytes: u64::MAX,
1144 });
1145 fill_window(&mut opt, 10, 100, 0.95);
1146 match opt.recommend(100, 4) {
1148 Ok(rec) => assert!(rec.memory_bytes <= budget),
1149 Err(OptimizerError::ConfigurationError(_)) => {
1150 }
1152 Err(e) => panic!("unexpected error: {:?}", e),
1153 }
1154 }
1155
1156 #[test]
1157 fn test_recommend_increments_stats() {
1158 let mut opt = make_optimizer(OptimizationCriterion::Balanced);
1159 fill_window(&mut opt, 10, 300, 0.90);
1160 let _ = opt.recommend(10_000, 64);
1161 assert_eq!(opt.stats().recommendations_made, 1);
1162 }
1163
1164 #[test]
1165 fn test_recommend_multiple_calls() {
1166 let mut opt = make_optimizer(OptimizationCriterion::Balanced);
1167 fill_window(&mut opt, 20, 400, 0.91);
1168 let r1 = opt.recommend(10_000, 64).expect("rec 1");
1169 let r2 = opt.recommend(10_000, 64).expect("rec 2");
1170 assert_eq!(r1.structure.name(), r2.structure.name());
1171 assert_eq!(opt.stats().recommendations_made, 2);
1172 }
1173
1174 #[test]
1179 fn test_estimate_recall_flat() {
1180 assert_eq!(
1181 VectorIndexOptimizer::estimate_recall(&IndexStructure::Flat, 1_000, 10),
1182 1.0
1183 );
1184 }
1185
1186 #[test]
1187 fn test_estimate_recall_ivf_clamped_high() {
1188 let s = IndexStructure::IvfFlat { n_clusters: 1000 };
1190 let r = VectorIndexOptimizer::estimate_recall(&s, 1_000, 10);
1191 assert!((0.70..=0.99).contains(&r), "recall={}", r);
1192 }
1193
1194 #[test]
1195 fn test_estimate_recall_ivf_low_clusters() {
1196 let s = IndexStructure::IvfFlat { n_clusters: 1 };
1198 let r = VectorIndexOptimizer::estimate_recall(&s, 1_000_000, 10);
1199 assert!((0.70..=0.99).contains(&r), "recall={}", r);
1200 }
1201
1202 #[test]
1203 fn test_estimate_recall_hnsw_high_ef() {
1204 let s = IndexStructure::HnswLike { m: 16, ef: 500 };
1205 let r = VectorIndexOptimizer::estimate_recall(&s, 100_000, 10);
1206 assert!((0.80..=0.99).contains(&r), "recall={}", r);
1207 }
1208
1209 #[test]
1210 fn test_estimate_recall_hnsw_low_ef() {
1211 let s = IndexStructure::HnswLike { m: 8, ef: 5 };
1212 let r = VectorIndexOptimizer::estimate_recall(&s, 100_000, 100);
1213 assert!((0.80..=0.99).contains(&r), "recall={}", r);
1215 }
1216
1217 #[test]
1218 fn test_estimate_recall_lsh() {
1219 let s = IndexStructure::Lsh {
1220 n_tables: 8,
1221 n_bits: 8,
1222 };
1223 assert_eq!(VectorIndexOptimizer::estimate_recall(&s, 10_000, 10), 0.85);
1224 }
1225
1226 #[test]
1227 fn test_estimate_recall_pq() {
1228 let s = IndexStructure::PQ { m: 8, bits: 8 };
1229 assert_eq!(VectorIndexOptimizer::estimate_recall(&s, 10_000, 10), 0.90);
1230 }
1231
1232 #[test]
1233 fn test_estimate_recall_tree_k1() {
1234 let s = IndexStructure::Tree { max_leaf: 50 };
1235 assert_eq!(VectorIndexOptimizer::estimate_recall(&s, 10_000, 1), 1.0);
1236 }
1237
1238 #[test]
1239 fn test_estimate_recall_tree_large_k() {
1240 let s = IndexStructure::Tree { max_leaf: 50 };
1241 let r = VectorIndexOptimizer::estimate_recall(&s, 10_000, 50);
1242 assert!((0.70..=1.0).contains(&r), "recall={}", r);
1243 }
1244
1245 #[test]
1250 fn test_should_rebuild_low_recall() {
1251 let mut opt = make_optimizer(OptimizationCriterion::Balanced);
1252 let stats = IndexStats {
1253 structure_name: "HnswLike".to_string(),
1254 query_count: 100,
1255 avg_latency_us: 200.0,
1256 p99_latency_us: 400.0,
1257 recall_estimate: 0.60, memory_bytes: 1024,
1259 last_rebuilt_at: 0,
1260 };
1261 match opt.should_rebuild(&stats) {
1262 Some(MaintenanceAction::Rebuild { reason }) => {
1263 assert!(reason.contains("Recall"));
1264 }
1265 other => panic!("expected Rebuild, got {:?}", other),
1266 }
1267 }
1268
1269 #[test]
1270 fn test_should_rebuild_high_query_count() {
1271 let mut opt = make_optimizer(OptimizationCriterion::Balanced);
1272 let stats = IndexStats {
1273 structure_name: "Flat".to_string(),
1274 query_count: 2_000_000, avg_latency_us: 100.0,
1276 p99_latency_us: 150.0,
1277 recall_estimate: 0.95, memory_bytes: 4096,
1279 last_rebuilt_at: 0,
1280 };
1281 match opt.should_rebuild(&stats) {
1282 Some(MaintenanceAction::Rebuild { .. }) => {}
1283 other => panic!("expected Rebuild, got {:?}", other),
1284 }
1285 }
1286
1287 #[test]
1288 fn test_should_rebuild_no_action_needed() {
1289 let mut opt = make_optimizer(OptimizationCriterion::Balanced);
1290 let stats = IndexStats {
1291 structure_name: "HnswLike".to_string(),
1292 query_count: 100,
1293 avg_latency_us: 200.0,
1294 p99_latency_us: 300.0,
1295 recall_estimate: 0.95,
1296 memory_bytes: 1024,
1297 last_rebuilt_at: 0,
1298 };
1299 assert!(opt.should_rebuild(&stats).is_none());
1300 }
1301
1302 #[test]
1303 fn test_should_rebuild_increments_counter() {
1304 let mut opt = make_optimizer(OptimizationCriterion::Balanced);
1305 let stats = IndexStats {
1306 recall_estimate: 0.50,
1307 query_count: 10,
1308 ..Default::default()
1309 };
1310 let _ = opt.should_rebuild(&stats);
1311 assert_eq!(opt.stats().rebuilds_triggered, 1);
1312 }
1313
1314 #[test]
1319 fn test_maintenance_actions_rebuild_priority() {
1320 let mut opt = make_optimizer(OptimizationCriterion::Balanced);
1321 let stats = IndexStats {
1322 recall_estimate: 0.60,
1323 avg_latency_us: 100.0,
1324 p99_latency_us: 500.0,
1325 ..Default::default()
1326 };
1327 let actions = opt.maintenance_actions(&stats, 10_000, 128);
1328 assert!(!actions.is_empty());
1329 assert!(matches!(actions[0], MaintenanceAction::Rebuild { .. }));
1330 }
1331
1332 #[test]
1333 fn test_maintenance_actions_rebalance_on_high_p99() {
1334 let mut opt = make_optimizer(OptimizationCriterion::Balanced);
1335 let stats = IndexStats {
1336 recall_estimate: 0.90,
1337 avg_latency_us: 100.0,
1338 p99_latency_us: 600.0, memory_bytes: 0,
1340 ..Default::default()
1341 };
1342 let actions = opt.maintenance_actions(&stats, 1000, 32);
1343 assert!(actions
1344 .iter()
1345 .any(|a| matches!(a, MaintenanceAction::Rebalance)));
1346 }
1347
1348 #[test]
1349 fn test_maintenance_actions_prune_dead_on_many_deletes() {
1350 let mut opt = make_optimizer(OptimizationCriterion::Balanced);
1351 for i in 0..2000_u64 {
1352 opt.record_delete(i);
1353 }
1354 let stats = IndexStats {
1355 recall_estimate: 0.90,
1356 avg_latency_us: 100.0,
1357 p99_latency_us: 150.0,
1358 ..Default::default()
1359 };
1360 let actions = opt.maintenance_actions(&stats, 10_000, 64);
1361 assert!(actions
1362 .iter()
1363 .any(|a| matches!(a, MaintenanceAction::PruneDead)));
1364 }
1365
1366 #[test]
1367 fn test_maintenance_actions_merge_on_high_memory() {
1368 let mut opt = make_optimizer(OptimizationCriterion::Balanced);
1369 let dataset_size = 1000;
1370 let dim = 64;
1371 let base_mem = (dataset_size * dim * 4) as u64;
1372 let stats = IndexStats {
1373 recall_estimate: 0.92,
1374 avg_latency_us: 100.0,
1375 p99_latency_us: 200.0,
1376 memory_bytes: base_mem * 5, ..Default::default()
1378 };
1379 let actions = opt.maintenance_actions(&stats, dataset_size, dim);
1380 assert!(actions
1381 .iter()
1382 .any(|a| matches!(a, MaintenanceAction::MergeSegments)));
1383 }
1384
1385 #[test]
1386 fn test_maintenance_actions_add_clusters_for_ivf() {
1387 let mut opt = make_optimizer(OptimizationCriterion::Balanced);
1388 for i in 0..200_000_u64 {
1389 opt.record_insert(i);
1390 }
1391 let stats = IndexStats {
1392 structure_name: "IvfFlat".to_string(),
1393 recall_estimate: 0.92,
1394 avg_latency_us: 100.0,
1395 p99_latency_us: 200.0,
1396 ..Default::default()
1397 };
1398 let actions = opt.maintenance_actions(&stats, 50_000, 128);
1399 assert!(actions
1400 .iter()
1401 .any(|a| matches!(a, MaintenanceAction::AddClusters(_))));
1402 }
1403
1404 #[test]
1405 fn test_maintenance_actions_empty_when_healthy() {
1406 let mut opt = make_optimizer(OptimizationCriterion::Balanced);
1407 let stats = IndexStats {
1408 recall_estimate: 0.95,
1409 avg_latency_us: 100.0,
1410 p99_latency_us: 120.0, memory_bytes: 1024,
1412 ..Default::default()
1413 };
1414 let actions = opt.maintenance_actions(&stats, 100, 4);
1415 assert!(actions.is_empty(), "expected no actions, got {:?}", actions);
1417 }
1418
1419 #[test]
1424 fn test_compare_structures_max_recall_prefers_flat() {
1425 let opt = VectorIndexOptimizer::new(OptimizerConfig {
1426 criterion: OptimizationCriterion::MaxRecall,
1427 ..Default::default()
1428 });
1429 let profile = WorkloadProfile {
1430 dataset_size: 10_000,
1431 embedding_dim: 64,
1432 avg_query_k: 10,
1433 ..Default::default()
1434 };
1435 let flat = IndexStructure::Flat;
1436 let lsh = IndexStructure::Lsh {
1437 n_tables: 4,
1438 n_bits: 8,
1439 };
1440 assert_eq!(
1442 opt.compare_structures(&flat, &lsh, &profile),
1443 Ordering::Greater
1444 );
1445 }
1446
1447 #[test]
1448 fn test_compare_structures_min_memory() {
1449 let opt = VectorIndexOptimizer::new(OptimizerConfig {
1450 criterion: OptimizationCriterion::MinMemory,
1451 ..Default::default()
1452 });
1453 let profile = WorkloadProfile {
1454 dataset_size: 10_000,
1455 embedding_dim: 64,
1456 avg_query_k: 10,
1457 ..Default::default()
1458 };
1459 let flat = IndexStructure::Flat;
1460 let hnsw = IndexStructure::HnswLike { m: 32, ef: 200 };
1461 assert_eq!(
1463 opt.compare_structures(&flat, &hnsw, &profile),
1464 Ordering::Greater
1465 );
1466 }
1467
1468 #[test]
1469 fn test_compare_structures_min_latency() {
1470 let opt = VectorIndexOptimizer::new(OptimizerConfig {
1471 criterion: OptimizationCriterion::MinLatency,
1472 ..Default::default()
1473 });
1474 let profile = WorkloadProfile {
1475 dataset_size: 1_000_000,
1476 embedding_dim: 256,
1477 avg_query_k: 10,
1478 ..Default::default()
1479 };
1480 let flat = IndexStructure::Flat;
1481 let hnsw = IndexStructure::HnswLike { m: 16, ef: 100 };
1482 assert_eq!(
1484 opt.compare_structures(&hnsw, &flat, &profile),
1485 Ordering::Greater
1486 );
1487 }
1488
1489 #[test]
1490 fn test_compare_structures_balanced() {
1491 let opt = VectorIndexOptimizer::new(OptimizerConfig {
1492 criterion: OptimizationCriterion::Balanced,
1493 ..Default::default()
1494 });
1495 let profile = WorkloadProfile {
1496 dataset_size: 50_000,
1497 embedding_dim: 128,
1498 avg_query_k: 10,
1499 ..Default::default()
1500 };
1501 let a = IndexStructure::HnswLike { m: 16, ef: 100 };
1502 let b = IndexStructure::Flat;
1503 let ord = opt.compare_structures(&a, &b, &profile);
1504 assert!(ord == Ordering::Less || ord == Ordering::Equal || ord == Ordering::Greater);
1506 }
1507
1508 #[test]
1509 fn test_compare_structures_symmetry() {
1510 let opt = VectorIndexOptimizer::new(OptimizerConfig {
1511 criterion: OptimizationCriterion::MinMemory,
1512 ..Default::default()
1513 });
1514 let profile = WorkloadProfile {
1515 dataset_size: 5_000,
1516 embedding_dim: 32,
1517 avg_query_k: 5,
1518 ..Default::default()
1519 };
1520 let a = IndexStructure::Flat;
1521 let b = IndexStructure::IvfFlat { n_clusters: 64 };
1522 let ord_ab = opt.compare_structures(&a, &b, &profile);
1523 let ord_ba = opt.compare_structures(&b, &a, &profile);
1524 match (ord_ab, ord_ba) {
1526 (Ordering::Greater, Ordering::Less)
1527 | (Ordering::Less, Ordering::Greater)
1528 | (Ordering::Equal, Ordering::Equal) => {}
1529 pair => panic!("unexpected ordering pair: {:?}", pair),
1530 }
1531 }
1532
1533 #[test]
1538 fn test_memory_cost_flat() {
1539 let s = IndexStructure::Flat;
1540 assert_eq!(memory_cost(&s, 1000, 128), 1000 * 128 * 4);
1541 }
1542
1543 #[test]
1544 fn test_memory_cost_ivf() {
1545 let s = IndexStructure::IvfFlat { n_clusters: 50 };
1546 let expected = (1000 * 128 * 4 + 50 * 128 * 4) as u64;
1547 assert_eq!(memory_cost(&s, 1000, 128), expected);
1548 }
1549
1550 #[test]
1551 fn test_memory_cost_hnsw() {
1552 let m = 16;
1553 let s = IndexStructure::HnswLike { m, ef: 100 };
1554 let expected = (1000 * 128 * 4 + 1000 * m * 8) as u64;
1555 assert_eq!(memory_cost(&s, 1000, 128), expected);
1556 }
1557
1558 #[test]
1559 fn test_memory_cost_lsh() {
1560 let n_tables = 8_usize;
1561 let n_bits: u8 = 8;
1562 let s = IndexStructure::Lsh { n_tables, n_bits };
1563 let expected = (1000 * n_tables) as u64 * (n_bits as u64 / 8 + 1);
1564 assert_eq!(memory_cost(&s, 1000, 128), expected);
1565 }
1566
1567 #[test]
1568 fn test_memory_cost_pq_equals_base() {
1569 let s = IndexStructure::PQ { m: 8, bits: 8 };
1570 assert_eq!(memory_cost(&s, 1000, 128), 1000 * 128 * 4);
1571 }
1572
1573 #[test]
1574 fn test_memory_cost_tree_equals_base() {
1575 let s = IndexStructure::Tree { max_leaf: 50 };
1576 assert_eq!(memory_cost(&s, 1000, 128), 1000 * 128 * 4);
1577 }
1578
1579 #[test]
1584 fn test_stats_initial_zeros() {
1585 let opt = make_optimizer(OptimizationCriterion::Balanced);
1586 let s = opt.stats();
1587 assert_eq!(s.recommendations_made, 0);
1588 assert_eq!(s.rebuilds_triggered, 0);
1589 assert_eq!(s.avg_recall_improvement, 0.0);
1590 assert_eq!(s.profiling_queries, 0);
1591 }
1592
1593 #[test]
1594 fn test_stats_after_queries() {
1595 let mut opt = make_optimizer(OptimizationCriterion::Balanced);
1596 fill_window(&mut opt, 10, 200, 0.93);
1597 let s = opt.stats();
1598 assert_eq!(s.profiling_queries, 10);
1599 }
1600
1601 #[test]
1602 fn test_stats_recall_improvement_tracking() {
1603 let mut opt = make_optimizer(OptimizationCriterion::Balanced);
1604 let bad_stats = IndexStats {
1606 recall_estimate: 0.60,
1607 ..Default::default()
1608 };
1609 let _ = opt.should_rebuild(&bad_stats);
1610 opt.record_post_rebuild_recall(0.95); let s = opt.stats();
1612 assert!(s.avg_recall_improvement > 0.0);
1613 }
1614
1615 #[test]
1616 fn test_stats_multiple_rebuilds() {
1617 let mut opt = make_optimizer(OptimizationCriterion::Balanced);
1618 for _ in 0..3 {
1619 let bad = IndexStats {
1620 recall_estimate: 0.50,
1621 ..Default::default()
1622 };
1623 let _ = opt.should_rebuild(&bad);
1624 opt.record_post_rebuild_recall(0.92);
1625 }
1626 let s = opt.stats();
1627 assert_eq!(s.rebuilds_triggered, 3);
1628 assert!((s.avg_recall_improvement - 0.42).abs() < 0.01);
1629 }
1630
1631 #[test]
1636 fn test_insufficient_data_zero_queries() {
1637 let mut opt = make_optimizer(OptimizationCriterion::Balanced);
1638 match opt.recommend(10_000, 128) {
1639 Err(OptimizerError::InsufficientData(0)) => {}
1640 other => panic!("expected InsufficientData(0), got {:?}", other),
1641 }
1642 }
1643
1644 #[test]
1645 fn test_insufficient_data_partial_window() {
1646 let mut opt = make_optimizer(OptimizationCriterion::Balanced);
1647 opt.record_query(5, 100, 0.9, 0);
1648 opt.record_query(5, 100, 0.9, 1);
1649 match opt.recommend(10_000, 128) {
1650 Err(OptimizerError::InsufficientData(n)) => assert_eq!(n, 2),
1651 other => panic!("expected InsufficientData(2), got {:?}", other),
1652 }
1653 }
1654
1655 #[test]
1656 fn test_insufficient_data_exact_window() {
1657 let mut opt = make_optimizer(OptimizationCriterion::Balanced);
1658 fill_window(&mut opt, 5, 300, 0.90);
1659 assert!(opt.recommend(10_000, 128).is_ok());
1661 }
1662
1663 #[test]
1668 fn test_index_structure_name_flat() {
1669 assert_eq!(IndexStructure::Flat.name(), "Flat");
1670 }
1671
1672 #[test]
1673 fn test_index_structure_name_ivf() {
1674 assert_eq!(IndexStructure::IvfFlat { n_clusters: 64 }.name(), "IvfFlat");
1675 }
1676
1677 #[test]
1678 fn test_index_structure_name_pq() {
1679 assert_eq!(IndexStructure::PQ { m: 8, bits: 8 }.name(), "PQ");
1680 }
1681
1682 #[test]
1683 fn test_index_structure_name_hnsw() {
1684 assert_eq!(
1685 IndexStructure::HnswLike { m: 16, ef: 100 }.name(),
1686 "HnswLike"
1687 );
1688 }
1689
1690 #[test]
1691 fn test_index_structure_name_lsh() {
1692 assert_eq!(
1693 IndexStructure::Lsh {
1694 n_tables: 8,
1695 n_bits: 8
1696 }
1697 .name(),
1698 "Lsh"
1699 );
1700 }
1701
1702 #[test]
1703 fn test_index_structure_name_tree() {
1704 assert_eq!(IndexStructure::Tree { max_leaf: 50 }.name(), "Tree");
1705 }
1706
1707 #[test]
1712 fn test_xorshift64_nonzero() {
1713 let mut state = 12345_u64;
1714 let v = xorshift64(&mut state);
1715 assert_ne!(v, 0);
1716 }
1717
1718 #[test]
1719 fn test_xorshift_f64_in_range() {
1720 let mut state = 99999_u64;
1721 for _ in 0..1000 {
1722 let f = xorshift_f64(&mut state);
1723 assert!((0.0..1.0).contains(&f), "f64 out of range: {}", f);
1724 }
1725 }
1726
1727 #[test]
1728 fn test_xorshift64_deterministic() {
1729 let mut s1 = 42_u64;
1730 let mut s2 = 42_u64;
1731 for _ in 0..100 {
1732 assert_eq!(xorshift64(&mut s1), xorshift64(&mut s2));
1733 }
1734 }
1735
1736 #[test]
1741 fn test_workload_profile_default() {
1742 let p = WorkloadProfile::default();
1743 assert_eq!(p.avg_query_k, 10);
1744 assert_eq!(p.dataset_size, 100_000);
1745 assert!(p.recall_requirement > 0.0 && p.recall_requirement <= 1.0);
1746 }
1747
1748 #[test]
1753 fn test_optimizer_config_default() {
1754 let c = OptimizerConfig::default();
1755 assert_eq!(c.criterion, OptimizationCriterion::Balanced);
1756 assert_eq!(c.rebuild_threshold, 0.80);
1757 assert_eq!(c.profile_window, 100);
1758 }
1759
1760 #[test]
1765 fn test_error_display_insufficient_data() {
1766 let e = OptimizerError::InsufficientData(3);
1767 assert!(e.to_string().contains("3"));
1768 }
1769
1770 #[test]
1771 fn test_error_display_index_not_found() {
1772 let e = OptimizerError::IndexNotFound("my_index".to_string());
1773 assert!(e.to_string().contains("my_index"));
1774 }
1775
1776 #[test]
1777 fn test_error_display_maintenance_failed() {
1778 let e = OptimizerError::MaintenanceFailed("disk full".to_string());
1779 assert!(e.to_string().contains("disk full"));
1780 }
1781
1782 #[test]
1783 fn test_error_display_configuration_error() {
1784 let e = OptimizerError::ConfigurationError("bad param".to_string());
1785 assert!(e.to_string().contains("bad param"));
1786 }
1787
1788 #[test]
1793 fn test_latency_window_p99() {
1794 let mut w = LatencyWindow::new(100);
1795 for i in 1..=100_u64 {
1796 w.push(i as f64);
1797 }
1798 let p99 = w.p99();
1799 assert!((98.0..=100.0).contains(&p99), "p99={}", p99);
1800 }
1801
1802 #[test]
1803 fn test_latency_window_mean() {
1804 let mut w = LatencyWindow::new(10);
1805 for i in 0..10_u64 {
1806 w.push(i as f64);
1807 }
1808 let mean = w.mean();
1809 assert!((mean - 4.5).abs() < 0.01, "mean={}", mean);
1810 }
1811
1812 #[test]
1817 fn test_profiling_progress_initially_zero() {
1818 let opt = make_optimizer(OptimizationCriterion::Balanced);
1819 let (current, total) = opt.profiling_progress();
1820 assert_eq!(current, 0);
1821 assert_eq!(total, 5);
1822 }
1823
1824 #[test]
1825 fn test_profiling_progress_fills() {
1826 let mut opt = make_optimizer(OptimizationCriterion::Balanced);
1827 fill_window(&mut opt, 5, 300, 0.9);
1828 let (current, _) = opt.profiling_progress();
1829 assert!(current >= 5);
1830 }
1831
1832 #[test]
1837 fn test_flat_query_cost_proportional() {
1838 let c1 = flat_query_cost(1000, 128);
1840 let c2 = flat_query_cost(2000, 128);
1841 assert!((c2 - c1 * 2.0).abs() < 0.01, "c1={} c2={}", c1, c2);
1842 }
1843
1844 #[test]
1845 fn test_ivf_query_cost_less_than_flat() {
1846 let n = 100_000;
1847 let d = 128;
1848 let k = 10;
1849 let flat = flat_query_cost(n, d);
1850 let ivf = ivf_query_cost(n, d, 1000, k);
1851 assert!(ivf < flat, "ivf={} should be < flat={}", ivf, flat);
1852 }
1853
1854 #[test]
1855 fn test_hnsw_query_cost_sublinear_in_ef() {
1856 let c1 = hnsw_query_cost(100_000, 128, 16, 50);
1857 let c2 = hnsw_query_cost(100_000, 128, 16, 100);
1858 assert!(c2 >= c1 * 0.9, "c1={} c2={}", c1, c2);
1860 }
1861
1862 #[test]
1863 fn test_lsh_query_cost_positive() {
1864 let c = lsh_query_cost(128, 8, 8);
1865 assert!(c > 0.0, "lsh cost must be positive");
1866 }
1867}