Skip to main content

ipfrs_semantic/
vector_index_optimizer.rs

1//! Vector Index Optimizer
2//!
3//! Selects and maintains optimal vector index structures based on workload analysis.
4//! Supports Flat, IVF-Flat, Product Quantization, HNSW-like, LSH, and Tree structures.
5//! Uses pure-Rust cost models to recommend and maintain the best index for a given workload.
6
7use std::cmp::Ordering;
8use std::collections::VecDeque;
9
10// ---------------------------------------------------------------------------
11// PRNG helpers (no external RNG dependency)
12// These are used in tests; the `#[cfg(test)]` visibility annotation is kept
13// here but they are also useful as public utilities for callers who wish to
14// generate deterministic synthetic workloads without pulling in rand.
15// ---------------------------------------------------------------------------
16
17/// Xorshift64 PRNG — advances `state` and returns the next pseudo-random u64.
18#[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/// Returns a pseudo-random f64 in `[0, 1)` using `xorshift64`.
29#[inline]
30pub fn xorshift_f64(state: &mut u64) -> f64 {
31    (xorshift64(state) >> 11) as f64 / (1u64 << 53) as f64
32}
33
34// ---------------------------------------------------------------------------
35// Cost models
36// ---------------------------------------------------------------------------
37
38/// Estimated microseconds for a flat (brute-force) query over `n` vectors of dimension `d`.
39fn flat_query_cost(n: usize, d: usize) -> f64 {
40    n as f64 * d as f64 * 0.001
41}
42
43/// Estimated microseconds for an IVF-Flat query.
44fn 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
55/// Estimated microseconds for an HNSW-like query.
56fn hnsw_query_cost(n: usize, d: usize, m: usize, ef: usize) -> f64 {
57    let _ = n; // graph size influences build not per-query for HNSW
58    (ef as f64 * m as f64 * d as f64).ln().max(1.0) * 0.01
59}
60
61/// Estimated microseconds for an LSH query.
62fn 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
66/// Estimated memory bytes for a given index structure.
67fn memory_cost(structure: &IndexStructure, n: usize, d: usize) -> u64 {
68    let base = (n * d * 4) as u64; // 4 bytes per f32
69    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// ---------------------------------------------------------------------------
81// Public enums and structs
82// ---------------------------------------------------------------------------
83
84/// Supported index structures for approximate nearest-neighbour search.
85#[derive(Clone, Debug, PartialEq)]
86pub enum IndexStructure {
87    /// Brute-force exact search — O(n·d) per query.
88    Flat,
89    /// Inverted File Index with flat scan inside each cluster.
90    IvfFlat {
91        /// Number of Voronoi cells.
92        n_clusters: usize,
93    },
94    /// Product Quantization — lossy compression with sub-space codebooks.
95    PQ {
96        /// Number of sub-spaces.
97        m: usize,
98        /// Bits per sub-space.
99        bits: u8,
100    },
101    /// HNSW-inspired hierarchical navigable small-world graph.
102    HnswLike {
103        /// Max connections per node.
104        m: usize,
105        /// Candidate list size during search.
106        ef: usize,
107    },
108    /// Locality Sensitive Hashing with multiple hash tables.
109    Lsh {
110        /// Number of hash tables.
111        n_tables: usize,
112        /// Bits per table projection.
113        n_bits: u8,
114    },
115    /// KD-tree / ball-tree style partition with bounded leaf size.
116    Tree {
117        /// Maximum number of vectors in a leaf node.
118        max_leaf: usize,
119    },
120}
121
122impl IndexStructure {
123    /// Human-readable name for this structure.
124    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/// Workload characteristics used to drive index selection.
137#[derive(Clone, Debug)]
138pub struct WorkloadProfile {
139    /// Number of search queries observed.
140    pub query_count: u64,
141    /// Number of insert operations observed.
142    pub insert_count: u64,
143    /// Number of delete operations observed.
144    pub delete_count: u64,
145    /// Average k (top-k) across queries.
146    pub avg_query_k: usize,
147    /// Current dataset size (number of vectors).
148    pub dataset_size: usize,
149    /// Vector dimensionality.
150    pub embedding_dim: usize,
151    /// Minimum acceptable recall (0–1).
152    pub recall_requirement: f64,
153    /// Maximum acceptable query latency in microseconds.
154    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/// Recommendation produced by the optimizer.
173#[derive(Clone, Debug)]
174pub struct IndexRecommendation {
175    /// Recommended index structure with tuned parameters.
176    pub structure: IndexStructure,
177    /// Estimated time to (re)build the index in microseconds.
178    pub estimated_build_time_us: u64,
179    /// Estimated per-query time in microseconds.
180    pub estimated_query_time_us: u64,
181    /// Estimated recall@k (0–1).
182    pub estimated_recall: f64,
183    /// Estimated memory footprint in bytes.
184    pub memory_bytes: u64,
185    /// Human-readable rationale for the recommendation.
186    pub reason: String,
187}
188
189/// Criterion used to compare and select index structures.
190#[derive(Clone, Debug, PartialEq)]
191pub enum OptimizationCriterion {
192    /// Minimise query latency.
193    MinLatency,
194    /// Maximise recall@k.
195    MaxRecall,
196    /// Minimise memory usage.
197    MinMemory,
198    /// Balanced trade-off between latency, recall, and memory.
199    Balanced,
200    /// Stay within a hard memory budget (bytes).
201    CostBudget(u64),
202}
203
204/// Snapshot of a live index's runtime statistics.
205#[derive(Clone, Debug)]
206pub struct IndexStats {
207    /// Human-readable name for the index/structure.
208    pub structure_name: String,
209    /// Total queries processed since last rebuild.
210    pub query_count: u64,
211    /// Mean query latency in microseconds.
212    pub avg_latency_us: f64,
213    /// 99th-percentile query latency in microseconds.
214    pub p99_latency_us: f64,
215    /// Current estimated recall.
216    pub recall_estimate: f64,
217    /// Current memory footprint in bytes.
218    pub memory_bytes: u64,
219    /// Unix timestamp (seconds) of the last rebuild.
220    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/// Maintenance operation to be applied to an index.
238#[derive(Clone, Debug, PartialEq)]
239pub enum MaintenanceAction {
240    /// Tear down and rebuild the index.
241    Rebuild {
242        /// Reason for the rebuild.
243        reason: String,
244    },
245    /// Rebalance cluster assignments (e.g., IVF).
246    Rebalance,
247    /// Grow the number of IVF clusters.
248    AddClusters(usize),
249    /// Remove tombstoned/deleted entries.
250    PruneDead,
251    /// Merge small segments into fewer larger ones.
252    MergeSegments,
253}
254
255/// Configuration for the `VectorIndexOptimizer`.
256#[derive(Clone, Debug)]
257pub struct OptimizerConfig {
258    /// Criterion used when comparing index structures.
259    pub criterion: OptimizationCriterion,
260    /// If estimated recall falls below this threshold a rebuild is triggered (0–1).
261    pub rebuild_threshold: f64,
262    /// Minimum number of queries to observe before making a recommendation.
263    pub profile_window: usize,
264    /// Hard upper bound on memory usage in bytes (used for `CostBudget` filtering).
265    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/// Aggregate statistics for the optimizer itself.
280#[derive(Clone, Debug, Default)]
281pub struct OptimizerStats {
282    /// Total number of index recommendations issued.
283    pub recommendations_made: u64,
284    /// Total number of rebuilds triggered.
285    pub rebuilds_triggered: u64,
286    /// Average recall improvement observed after a rebuild.
287    pub avg_recall_improvement: f64,
288    /// Number of queries fed into the profiling window.
289    pub profiling_queries: u64,
290}
291
292/// Errors produced by the optimizer.
293#[derive(Clone, Debug, PartialEq)]
294pub enum OptimizerError {
295    /// Not enough query data yet; contains current observation count.
296    InsufficientData(usize),
297    /// No index with the given name found.
298    IndexNotFound(String),
299    /// A maintenance operation failed.
300    MaintenanceFailed(String),
301    /// Invalid configuration.
302    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// ---------------------------------------------------------------------------
321// Rolling latency window
322// ---------------------------------------------------------------------------
323
324/// Fixed-capacity sliding window for computing online statistics.
325#[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    /// Returns the mean of all values in the current window.
352    ///
353    /// Used internally and exposed for testing / external monitoring.
354    #[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    /// Returns the 99th-percentile value from the current window.
364    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// ---------------------------------------------------------------------------
380// Rolling recall window
381// ---------------------------------------------------------------------------
382
383#[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
422// ---------------------------------------------------------------------------
423// VectorIndexOptimizer
424// ---------------------------------------------------------------------------
425
426/// Production-quality vector index optimizer.
427///
428/// Observes a stream of query/insert events, builds a `WorkloadProfile`,
429/// and recommends the best `IndexStructure` given an `OptimizationCriterion`.
430/// It also monitors live `IndexStats` and emits `MaintenanceAction`s when needed.
431pub 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    /// Last recall seen before triggering a rebuild (for improvement tracking).
445    pre_rebuild_recall: Option<f64>,
446}
447
448impl VectorIndexOptimizer {
449    /// Create a new optimizer with the provided configuration.
450    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    /// Create a new optimizer with default configuration.
470    pub fn with_defaults() -> Self {
471        Self::new(OptimizerConfig::default())
472    }
473
474    // ------------------------------------------------------------------
475    // Event recording
476    // ------------------------------------------------------------------
477
478    /// Record a completed query.
479    ///
480    /// - `k`: requested top-k
481    /// - `latency_us`: measured query latency in microseconds
482    /// - `recall`: observed recall@k (0–1)
483    /// - `current_ts`: current Unix timestamp in seconds (for time-series tracking)
484    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    /// Record an insert operation.
494    ///
495    /// - `current_ts`: current Unix timestamp in seconds
496    pub fn record_insert(&mut self, _current_ts: u64) {
497        self.insert_count += 1;
498    }
499
500    /// Record a delete operation.
501    pub fn record_delete(&mut self, _current_ts: u64) {
502        self.delete_count += 1;
503    }
504
505    // ------------------------------------------------------------------
506    // Workload profiling
507    // ------------------------------------------------------------------
508
509    /// Snapshot the current workload profile.
510    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        // Use p99 latency as the budget: ensures we recommend structures that can
513        // serve tail latency, not just average latency.
514        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    // ------------------------------------------------------------------
528    // Recommendation
529    // ------------------------------------------------------------------
530
531    /// Recommend the best index structure for the given dataset and dimensionality.
532    ///
533    /// Returns `Err(OptimizerError::InsufficientData)` until `profile_window` queries
534    /// have been observed.
535    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    /// Check whether a recommendation stays within the configured memory budget.
564    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    /// Compare two recommendations under the active criterion.
572    /// Returns the ordering such that `max_by` chooses the *better* one.
573    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                // lower latency → prefer a
582                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    /// Composite score for `Balanced`/`CostBudget` criterion (higher = better).
598    fn balanced_score(&self, rec: &IndexRecommendation) -> f64 {
599        // Normalise each dimension:
600        //   recall:   0–1 (higher better)
601        //   latency:  invert with log (lower latency → higher score)
602        //   memory:   invert (lower memory → higher score), clamped
603        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    /// Generate one recommendation per candidate structure with sensible defaults.
611    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        // Flat
619        {
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        // IvfFlat — rule of thumb: sqrt(n) clusters
635        {
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        // PQ (approximate, low memory)
655        {
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            // PQ uses flat scan over compressed codes then rerank — faster than flat
662            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        // HnswLike — m=16, ef scales with k
677        {
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        // Lsh
698        {
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        // Tree
719        {
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            // Tree: O(log n) average query
725            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    // ------------------------------------------------------------------
743    // Recall estimation
744    // ------------------------------------------------------------------
745
746    /// Heuristic recall estimate for a structure at a given dataset size and k.
747    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                    // Recall degrades for larger k in tree structures (curse of dimensionality)
771                    let decay = (k as f64 - 1.0) * 0.01;
772                    (1.0 - decay).clamp(0.70, 1.0)
773                }
774            }
775        }
776    }
777
778    // ------------------------------------------------------------------
779    // Maintenance
780    // ------------------------------------------------------------------
781
782    /// Check whether a rebuild or other maintenance is required.
783    ///
784    /// Returns `Some(action)` when a rebuild is recommended, `None` otherwise.
785    pub fn should_rebuild(&mut self, current_stats: &IndexStats) -> Option<MaintenanceAction> {
786        // Rebuild if recall dropped below threshold
787        if current_stats.recall_estimate < self.config.rebuild_threshold {
788            self.rebuilds_triggered += 1;
789            // Track pre-rebuild recall for improvement calculation
790            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        // Rebuild if query count since last rebuild is very high (staleness heuristic)
800        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    /// Observe a post-rebuild recall to update improvement tracking.
816    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    /// Comprehensive maintenance check — returns all applicable actions.
825    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        // Recall-based rebuild
834        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; // rebuild supersedes everything else
844        }
845
846        // High p99 latency — rebalance to improve locality
847        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        // Many deleted vectors — prune dead entries
852        let est_dead = dataset_size / 20; // assume up to 5% churn
853        if self.delete_count > est_dead as u64 {
854            actions.push(MaintenanceAction::PruneDead);
855        }
856
857        // Memory growing significantly — consider merge
858        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        // High insert pressure on IVF — consider adding clusters
864        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    // ------------------------------------------------------------------
873    // Structure comparison
874    // ------------------------------------------------------------------
875
876    /// Compare two index structures under the active criterion and profile.
877    ///
878    /// Returns `Ordering::Greater` if `a` is preferred over `b`.
879    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                // lower latency better → b < a means a is preferred
899                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    // ------------------------------------------------------------------
940    // Optimizer statistics
941    // ------------------------------------------------------------------
942
943    /// Return aggregate statistics for this optimizer instance.
944    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    /// Current profiling window fill level (0 to `profile_window`).
959    ///
960    /// Returns `(observed_queries, required_for_recommendation)`.
961    /// Both the latency and recall windows must reach `profile_window` before
962    /// `recommend` returns a valid result.
963    pub fn profiling_progress(&self) -> (usize, usize) {
964        // Use the minimum of both windows so we correctly represent the
965        // readiness of both signals.
966        let ready = self.latency_window.len().min(self.recall_window.len());
967        (ready, self.config.profile_window)
968    }
969}
970
971// ---------------------------------------------------------------------------
972// Type aliases for collision avoidance
973// (These are exported from lib.rs under `Vio*` names)
974// ---------------------------------------------------------------------------
975
976/// Type alias for `IndexStats` (re-exported as `VioIndexStats` to avoid conflict with
977/// `stats::IndexStats` which is already exported at crate level).
978pub type VioIndexStats = IndexStats;
979
980/// Type alias for `OptimizerConfig` (re-exported as `VioOptimizerConfig` to avoid conflict with
981/// `semantic_query_optimizer::OptimizerConfig` which is already exported at crate level).
982pub type VioOptimizerConfig = OptimizerConfig;
983
984/// Type alias for `OptimizerError` (re-exported as `VioOptimizerError` to avoid conflict with
985/// `semantic_query_optimizer::OptimizerError` which is already exported at crate level).
986pub type VioOptimizerError = OptimizerError;
987
988/// Type alias for `OptimizerStats` (re-exported as `VioOptimizerStats` to avoid conflict with
989/// `semantic_query_optimizer::OptimizerStats` which is already exported at crate level).
990pub type VioOptimizerStats = OptimizerStats;
991
992// ---------------------------------------------------------------------------
993// Tests
994// ---------------------------------------------------------------------------
995
996#[cfg(test)]
997mod tests {
998    use super::*;
999
1000    // Helper: create an optimizer with a tiny profile window for fast tests
1001    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    // Helper: fill the profile window with synthetic observations
1011    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    // ---------------------------------------------------------------------------
1018    // record_query tests
1019    // ---------------------------------------------------------------------------
1020
1021    #[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); // above 1.0 — should clamp
1043        opt.record_query(1, 50, -0.1, 0); // below 0.0 — should clamp
1044        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    // ---------------------------------------------------------------------------
1057    // record_insert tests
1058    // ---------------------------------------------------------------------------
1059
1060    #[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    // ---------------------------------------------------------------------------
1088    // recommend — per criterion
1089    // ---------------------------------------------------------------------------
1090
1091    #[test]
1092    fn test_recommend_insufficient_data() {
1093        let mut opt = make_optimizer(OptimizationCriterion::Balanced);
1094        opt.record_query(10, 500, 0.9, 0); // only 1, needs 5
1095        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        // Under MinLatency, the recommended structure should have low query time
1116        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        // MaxRecall should pick the best recall candidate
1125        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; // 1 MB
1139        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        // tiny dataset so something fits in 1 MB
1147        match opt.recommend(100, 4) {
1148            Ok(rec) => assert!(rec.memory_bytes <= budget),
1149            Err(OptimizerError::ConfigurationError(_)) => {
1150                // acceptable if no structure fits in 1 MB for chosen sizes
1151            }
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    // ---------------------------------------------------------------------------
1175    // estimate_recall per structure
1176    // ---------------------------------------------------------------------------
1177
1178    #[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        // small dataset, large n_clusters → should be near 0.95
1189        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        // n=1M, n_clusters=1 → heavy penalty
1197        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        // ef < k → large penalty
1214        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    // ---------------------------------------------------------------------------
1246    // should_rebuild
1247    // ---------------------------------------------------------------------------
1248
1249    #[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, // below 0.80 threshold
1258            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, // over threshold
1275            avg_latency_us: 100.0,
1276            p99_latency_us: 150.0,
1277            recall_estimate: 0.95, // fine recall
1278            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    // ---------------------------------------------------------------------------
1315    // maintenance_actions
1316    // ---------------------------------------------------------------------------
1317
1318    #[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, // > 4x avg
1339            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, // 5× base → triggers merge
1377            ..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, // < 4x avg
1411            memory_bytes: 1024,
1412            ..Default::default()
1413        };
1414        let actions = opt.maintenance_actions(&stats, 100, 4);
1415        // delete_count = 0 and memory is fine → no actions
1416        assert!(actions.is_empty(), "expected no actions, got {:?}", actions);
1417    }
1418
1419    // ---------------------------------------------------------------------------
1420    // compare_structures
1421    // ---------------------------------------------------------------------------
1422
1423    #[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        // Flat recall = 1.0, Lsh recall = 0.85 → Flat should be preferred
1441        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        // Flat uses less memory than HNSW with m=32
1462        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        // On a large dataset, HNSW should be faster than flat
1483        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        // Both are valid orderings — just verify it returns an Ordering without panicking
1505        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        // They should be opposite (or equal)
1525        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    // ---------------------------------------------------------------------------
1534    // memory_cost
1535    // ---------------------------------------------------------------------------
1536
1537    #[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    // ---------------------------------------------------------------------------
1580    // stats
1581    // ---------------------------------------------------------------------------
1582
1583    #[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        // Simulate a rebuild event
1605        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); // 0.35 improvement
1611        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    // ---------------------------------------------------------------------------
1632    // Error cases — InsufficientData
1633    // ---------------------------------------------------------------------------
1634
1635    #[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        // exactly at the window — should succeed
1660        assert!(opt.recommend(10_000, 128).is_ok());
1661    }
1662
1663    // ---------------------------------------------------------------------------
1664    // IndexStructure name
1665    // ---------------------------------------------------------------------------
1666
1667    #[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    // ---------------------------------------------------------------------------
1708    // PRNG helpers
1709    // ---------------------------------------------------------------------------
1710
1711    #[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    // ---------------------------------------------------------------------------
1737    // WorkloadProfile default
1738    // ---------------------------------------------------------------------------
1739
1740    #[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    // ---------------------------------------------------------------------------
1749    // OptimizerConfig default
1750    // ---------------------------------------------------------------------------
1751
1752    #[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    // ---------------------------------------------------------------------------
1761    // Error Display
1762    // ---------------------------------------------------------------------------
1763
1764    #[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    // ---------------------------------------------------------------------------
1789    // Latency window p99
1790    // ---------------------------------------------------------------------------
1791
1792    #[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    // ---------------------------------------------------------------------------
1813    // profiling_progress
1814    // ---------------------------------------------------------------------------
1815
1816    #[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    // ---------------------------------------------------------------------------
1833    // Cost model direct tests
1834    // ---------------------------------------------------------------------------
1835
1836    #[test]
1837    fn test_flat_query_cost_proportional() {
1838        // doubling n should double cost
1839        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        // Cost should be somewhat larger with ef=100 vs ef=50
1859        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}