Skip to main content

hirn_engine/
index_advisor.rs

1//! Self-Optimizing Index Selection.
2//!
3//! `IndexAdvisor` tracks query patterns per dataset and recommends whether to
4//! rebuild, switch, or add secondary indices based on observed access patterns.
5
6use std::collections::HashMap;
7use std::sync::atomic::{AtomicU64, Ordering};
8
9use parking_lot::Mutex;
10
11/// Records query-pattern observations for a single dataset.
12#[derive(Debug)]
13struct DatasetMetrics {
14    /// Total number of vector searches observed.
15    vector_search_count: AtomicU64,
16    /// Total number of full-text searches observed.
17    fts_count: AtomicU64,
18    /// Total number of hybrid searches observed.
19    hybrid_count: AtomicU64,
20    /// Total number of full-table scans observed.
21    scan_count: AtomicU64,
22    /// Total latency (microseconds) across all queries.
23    total_latency_us: AtomicU64,
24    /// Number of queries with latency > p90 threshold (initially 100ms).
25    slow_query_count: AtomicU64,
26}
27
28impl DatasetMetrics {
29    fn new() -> Self {
30        Self {
31            vector_search_count: AtomicU64::new(0),
32            fts_count: AtomicU64::new(0),
33            hybrid_count: AtomicU64::new(0),
34            scan_count: AtomicU64::new(0),
35            total_latency_us: AtomicU64::new(0),
36            slow_query_count: AtomicU64::new(0),
37        }
38    }
39
40    fn total_queries(&self) -> u64 {
41        self.vector_search_count.load(Ordering::Relaxed)
42            + self.fts_count.load(Ordering::Relaxed)
43            + self.hybrid_count.load(Ordering::Relaxed)
44            + self.scan_count.load(Ordering::Relaxed)
45    }
46}
47
48/// The kind of query that was observed.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum QueryKind {
51    VectorSearch,
52    FullTextSearch,
53    HybridSearch,
54    Scan,
55}
56
57/// Recommendation produced by `IndexAdvisor::advise`.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum IndexRecommendation {
60    /// Current indices are adequate — no change needed.
61    KeepCurrent { reason: String },
62    /// Switch the primary index to a different type.
63    SwitchTo { index_type: String, reason: String },
64    /// Add a secondary index to complement the existing one.
65    AddSecondary { index_type: String, reason: String },
66}
67
68impl IndexRecommendation {
69    /// Human-readable reason for this recommendation.
70    pub fn reason(&self) -> &str {
71        match self {
72            Self::KeepCurrent { reason } => reason,
73            Self::SwitchTo { reason, .. } => reason,
74            Self::AddSecondary { reason, .. } => reason,
75        }
76    }
77}
78
79/// Snapshot of per-dataset metrics exposed for inspection.
80#[derive(Debug, Clone)]
81pub struct DatasetQueryStats {
82    pub vector_search_count: u64,
83    pub fts_count: u64,
84    pub hybrid_count: u64,
85    pub scan_count: u64,
86    pub total_queries: u64,
87    pub avg_latency_us: u64,
88    pub slow_query_count: u64,
89}
90
91/// Tracks query patterns per dataset and recommends index changes.
92///
93/// Thread-safe: all internal state is either atomic or behind a `Mutex`.
94#[derive(Debug)]
95pub struct IndexAdvisor {
96    metrics: Mutex<HashMap<String, DatasetMetrics>>,
97    /// Latency threshold (microseconds) above which a query is considered "slow".
98    slow_threshold_us: u64,
99    /// Whether to automatically apply recommendations.
100    pub auto_apply: bool,
101}
102
103/// Default slow-query threshold: 100 ms.
104const DEFAULT_SLOW_THRESHOLD_US: u64 = 100_000;
105
106impl Default for IndexAdvisor {
107    fn default() -> Self {
108        Self::new()
109    }
110}
111
112impl IndexAdvisor {
113    /// Create a new advisor with default thresholds.
114    pub fn new() -> Self {
115        Self {
116            metrics: Mutex::new(HashMap::new()),
117            slow_threshold_us: DEFAULT_SLOW_THRESHOLD_US,
118            auto_apply: false,
119        }
120    }
121
122    /// Create a new advisor with a custom slow-query threshold.
123    pub fn with_slow_threshold(threshold_us: u64) -> Self {
124        Self {
125            metrics: Mutex::new(HashMap::new()),
126            slow_threshold_us: threshold_us,
127            auto_apply: false,
128        }
129    }
130
131    /// Record a query observation for a dataset.
132    pub fn record_query(&self, dataset: &str, kind: QueryKind, latency: std::time::Duration) {
133        let latency_us = latency.as_micros() as u64;
134        let mut map = self.metrics.lock();
135        let m = map
136            .entry(dataset.to_string())
137            .or_insert_with(DatasetMetrics::new);
138        match kind {
139            QueryKind::VectorSearch => {
140                m.vector_search_count.fetch_add(1, Ordering::Relaxed);
141            }
142            QueryKind::FullTextSearch => {
143                m.fts_count.fetch_add(1, Ordering::Relaxed);
144            }
145            QueryKind::HybridSearch => {
146                m.hybrid_count.fetch_add(1, Ordering::Relaxed);
147            }
148            QueryKind::Scan => {
149                m.scan_count.fetch_add(1, Ordering::Relaxed);
150            }
151        }
152        m.total_latency_us.fetch_add(latency_us, Ordering::Relaxed);
153        if latency_us > self.slow_threshold_us {
154            m.slow_query_count.fetch_add(1, Ordering::Relaxed);
155        }
156    }
157
158    /// Produce a recommendation for the given dataset based on observed patterns.
159    pub fn advise(&self, dataset: &str) -> IndexRecommendation {
160        let map = self.metrics.lock();
161        let Some(m) = map.get(dataset) else {
162            return IndexRecommendation::KeepCurrent {
163                reason: "no query data observed for this dataset".into(),
164            };
165        };
166
167        let total = m.total_queries();
168        if total < 10 {
169            return IndexRecommendation::KeepCurrent {
170                reason: format!("insufficient data: only {total} queries observed (need ≥10)"),
171            };
172        }
173
174        let vec_count = m.vector_search_count.load(Ordering::Relaxed);
175        let fts_count = m.fts_count.load(Ordering::Relaxed);
176        let hybrid_count = m.hybrid_count.load(Ordering::Relaxed);
177        let scan_count = m.scan_count.load(Ordering::Relaxed);
178        let slow_count = m.slow_query_count.load(Ordering::Relaxed);
179
180        let vec_ratio = (vec_count + hybrid_count) as f64 / total as f64;
181        let scan_ratio = scan_count as f64 / total as f64;
182        let fts_ratio = (fts_count + hybrid_count) as f64 / total as f64;
183        let slow_ratio = slow_count as f64 / total as f64;
184
185        // Rule 1: If >80% of queries are vector/hybrid and slow query rate is
186        // high, recommend switching to IVF-HNSW for better ANN performance.
187        if vec_ratio > 0.8 && slow_ratio > 0.2 {
188            return IndexRecommendation::SwitchTo {
189                index_type: "IVF_HNSW".into(),
190                reason: format!(
191                    "vector-dominant workload ({:.0}% vector/hybrid) with {:.0}% slow queries — IVF-HNSW improves ANN latency",
192                    vec_ratio * 100.0,
193                    slow_ratio * 100.0
194                ),
195            };
196        }
197
198        // Rule 2: If >80% of queries are scans, recommend IVF-PQ or brute-force
199        // bypass for bulk workloads.
200        if scan_ratio > 0.8 {
201            return IndexRecommendation::SwitchTo {
202                index_type: "IVF_PQ".into(),
203                reason: format!(
204                    "scan-dominant workload ({:.0}% scans) — IVF-PQ provides efficient sequential access",
205                    scan_ratio * 100.0
206                ),
207            };
208        }
209
210        // Rule 3: If FTS is >30% but no FTS index advisory, recommend adding one.
211        if fts_ratio > 0.3 {
212            return IndexRecommendation::AddSecondary {
213                index_type: "FTS".into(),
214                reason: format!(
215                    "significant FTS workload ({:.0}% text/hybrid queries) — add FTS index to avoid brute-force text search",
216                    fts_ratio * 100.0
217                ),
218            };
219        }
220
221        // Rule 4: Mixed workload — keep current.
222        IndexRecommendation::KeepCurrent {
223            reason: format!(
224                "balanced workload (vec: {:.0}%, scan: {:.0}%, fts: {:.0}%) — current indices adequate",
225                vec_ratio * 100.0,
226                scan_ratio * 100.0,
227                fts_ratio * 100.0
228            ),
229        }
230    }
231
232    /// Get a snapshot of metrics for a dataset.
233    pub fn stats(&self, dataset: &str) -> Option<DatasetQueryStats> {
234        let map = self.metrics.lock();
235        let m = map.get(dataset)?;
236        let total = m.total_queries();
237        let total_latency = m.total_latency_us.load(Ordering::Relaxed);
238        Some(DatasetQueryStats {
239            vector_search_count: m.vector_search_count.load(Ordering::Relaxed),
240            fts_count: m.fts_count.load(Ordering::Relaxed),
241            hybrid_count: m.hybrid_count.load(Ordering::Relaxed),
242            scan_count: m.scan_count.load(Ordering::Relaxed),
243            total_queries: total,
244            avg_latency_us: total_latency.checked_div(total).unwrap_or(0),
245            slow_query_count: m.slow_query_count.load(Ordering::Relaxed),
246        })
247    }
248
249    /// List all datasets that have recorded metrics.
250    pub fn tracked_datasets(&self) -> Vec<String> {
251        self.metrics.lock().keys().cloned().collect()
252    }
253
254    /// Reset metrics for a dataset.
255    pub fn reset(&self, dataset: &str) {
256        self.metrics.lock().remove(dataset);
257    }
258
259    /// Reset all metrics.
260    pub fn reset_all(&self) {
261        self.metrics.lock().clear();
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use std::time::Duration;
269
270    #[test]
271    fn no_data_keeps_current() {
272        let advisor = IndexAdvisor::new();
273        let rec = advisor.advise("episodic");
274        assert!(matches!(rec, IndexRecommendation::KeepCurrent { .. }));
275    }
276
277    #[test]
278    fn insufficient_data_keeps_current() {
279        let advisor = IndexAdvisor::new();
280        for _ in 0..5 {
281            advisor.record_query(
282                "episodic",
283                QueryKind::VectorSearch,
284                Duration::from_millis(10),
285            );
286        }
287        let rec = advisor.advise("episodic");
288        assert!(matches!(rec, IndexRecommendation::KeepCurrent { .. }));
289        assert!(rec.reason().contains("insufficient"));
290    }
291
292    #[test]
293    fn vector_dominant_with_slow_queries_recommends_ivf_hnsw() {
294        let advisor = IndexAdvisor::new();
295        // 90 vector searches, 30 of which are slow (>100ms)
296        for i in 0..90 {
297            let latency = if i < 30 {
298                Duration::from_millis(200) // slow
299            } else {
300                Duration::from_millis(10) // fast
301            };
302            advisor.record_query("episodic", QueryKind::VectorSearch, latency);
303        }
304        // 10 scans (fast)
305        for _ in 0..10 {
306            advisor.record_query("episodic", QueryKind::Scan, Duration::from_millis(5));
307        }
308        let rec = advisor.advise("episodic");
309        match rec {
310            IndexRecommendation::SwitchTo { index_type, reason } => {
311                assert_eq!(index_type, "IVF_HNSW");
312                assert!(reason.contains("vector-dominant"));
313            }
314            other => panic!("expected SwitchTo, got {other:?}"),
315        }
316    }
317
318    #[test]
319    fn scan_dominant_recommends_ivf_pq() {
320        let advisor = IndexAdvisor::new();
321        for _ in 0..90 {
322            advisor.record_query("semantic", QueryKind::Scan, Duration::from_millis(5));
323        }
324        for _ in 0..10 {
325            advisor.record_query(
326                "semantic",
327                QueryKind::VectorSearch,
328                Duration::from_millis(10),
329            );
330        }
331        let rec = advisor.advise("semantic");
332        match rec {
333            IndexRecommendation::SwitchTo { index_type, reason } => {
334                assert_eq!(index_type, "IVF_PQ");
335                assert!(reason.contains("scan-dominant"));
336            }
337            other => panic!("expected SwitchTo, got {other:?}"),
338        }
339    }
340
341    #[test]
342    fn mixed_workload_keeps_current() {
343        let advisor = IndexAdvisor::new();
344        for _ in 0..40 {
345            advisor.record_query(
346                "episodic",
347                QueryKind::VectorSearch,
348                Duration::from_millis(10),
349            );
350        }
351        for _ in 0..30 {
352            advisor.record_query("episodic", QueryKind::Scan, Duration::from_millis(5));
353        }
354        for _ in 0..30 {
355            advisor.record_query(
356                "episodic",
357                QueryKind::FullTextSearch,
358                Duration::from_millis(8),
359            );
360        }
361        let rec = advisor.advise("episodic");
362        assert!(matches!(rec, IndexRecommendation::KeepCurrent { .. }));
363        assert!(rec.reason().contains("balanced"));
364    }
365
366    #[test]
367    fn metrics_correct_after_100_queries() {
368        let advisor = IndexAdvisor::new();
369        for _ in 0..60 {
370            advisor.record_query(
371                "episodic",
372                QueryKind::VectorSearch,
373                Duration::from_millis(10),
374            );
375        }
376        for _ in 0..30 {
377            advisor.record_query(
378                "episodic",
379                QueryKind::HybridSearch,
380                Duration::from_millis(15),
381            );
382        }
383        for _ in 0..10 {
384            advisor.record_query("episodic", QueryKind::Scan, Duration::from_millis(5));
385        }
386        let stats = advisor.stats("episodic").unwrap();
387        assert_eq!(stats.vector_search_count, 60);
388        assert_eq!(stats.hybrid_count, 30);
389        assert_eq!(stats.scan_count, 10);
390        assert_eq!(stats.total_queries, 100);
391    }
392
393    #[test]
394    fn recommendation_has_reason() {
395        let advisor = IndexAdvisor::new();
396        for _ in 0..100 {
397            advisor.record_query(
398                "episodic",
399                QueryKind::VectorSearch,
400                Duration::from_millis(200),
401            );
402        }
403        let rec = advisor.advise("episodic");
404        let reason = rec.reason();
405        assert!(!reason.is_empty());
406    }
407
408    #[test]
409    fn fts_heavy_recommends_secondary() {
410        let advisor = IndexAdvisor::new();
411        for _ in 0..50 {
412            advisor.record_query(
413                "episodic",
414                QueryKind::VectorSearch,
415                Duration::from_millis(10),
416            );
417        }
418        for _ in 0..50 {
419            advisor.record_query(
420                "episodic",
421                QueryKind::FullTextSearch,
422                Duration::from_millis(10),
423            );
424        }
425        let rec = advisor.advise("episodic");
426        match rec {
427            IndexRecommendation::AddSecondary { index_type, reason } => {
428                assert_eq!(index_type, "FTS");
429                assert!(reason.contains("FTS"));
430            }
431            other => panic!("expected AddSecondary, got {other:?}"),
432        }
433    }
434
435    #[test]
436    fn auto_apply_default_false() {
437        let advisor = IndexAdvisor::new();
438        assert!(!advisor.auto_apply);
439    }
440}