Skip to main content

clawft_kernel/
vector_hybrid.rs

1//! Hybrid vector backend: hot HNSW cache + cold DiskANN store.
2//!
3//! Implements the "map of maps" pattern: HNSW holds the frequently-accessed
4//! vectors for fast lookup, while DiskANN holds the full corpus. Vectors
5//! are promoted from cold to hot based on access frequency, and evicted
6//! from the hot tier using LRU when it reaches capacity.
7//!
8//! ## Hardening features (Cognitum Seed WS1)
9//!
10//! Epoch, tombstone, and capacity operations are delegated to the cold
11//! (authoritative) tier. The hot tier mirrors soft-deletes to keep search
12//! results consistent.
13//!
14//! Compiled only when the `ecc` feature is enabled.
15
16use std::collections::{HashMap, VecDeque};
17use std::sync::Mutex;
18
19use dashmap::DashMap;
20use serde::{Deserialize, Serialize};
21
22use crate::hnsw_service::HnswServiceConfig;
23use crate::vector_backend::{SearchResult, VectorBackend, VectorError, VectorResult};
24use crate::vector_diskann::{DiskAnnBackend, DiskAnnConfig};
25use crate::vector_hnsw::HnswBackend;
26
27// ── Configuration ────────────────────────────────────────────────────────
28
29/// Eviction policy for the hot tier.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
31#[serde(rename_all = "lowercase")]
32pub enum EvictionPolicy {
33    /// Least Recently Used -- evict the vector that was accessed longest ago.
34    #[default]
35    Lru,
36}
37
38/// Hybrid backend configuration.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct HybridConfig {
41    /// Maximum number of vectors in the hot (HNSW) tier.
42    #[serde(default = "default_hot_capacity")]
43    pub hot_capacity: usize,
44
45    /// Access count threshold before a cold vector is promoted to hot.
46    #[serde(default = "default_promotion_threshold")]
47    pub promotion_threshold: u32,
48
49    /// Eviction policy for the hot tier when it is full.
50    #[serde(default)]
51    pub eviction_policy: EvictionPolicy,
52}
53
54fn default_hot_capacity() -> usize {
55    50_000
56}
57fn default_promotion_threshold() -> u32 {
58    3
59}
60
61impl Default for HybridConfig {
62    fn default() -> Self {
63        Self {
64            hot_capacity: default_hot_capacity(),
65            promotion_threshold: default_promotion_threshold(),
66            eviction_policy: EvictionPolicy::default(),
67        }
68    }
69}
70
71// ── LRU tracker ──────────────────────────────────────────────────────────
72
73/// Simple LRU tracker backed by a `VecDeque` of IDs.
74///
75/// When an ID is "touched", it moves to the back (most recent).
76/// Eviction pops from the front (least recent).
77struct LruTracker {
78    order: VecDeque<u64>,
79    positions: HashMap<u64, usize>,
80}
81
82impl LruTracker {
83    fn new() -> Self {
84        Self {
85            order: VecDeque::new(),
86            positions: HashMap::new(),
87        }
88    }
89
90    /// Mark an ID as recently used. If new, push to back.
91    fn touch(&mut self, id: u64) {
92        if self.positions.contains_key(&id) {
93            // Remove from current position and push to back.
94            self.order.retain(|&x| x != id);
95        }
96        self.order.push_back(id);
97        self.rebuild_positions();
98    }
99
100    /// Evict the least recently used ID. Returns `None` if empty.
101    fn evict(&mut self) -> Option<u64> {
102        let id = self.order.pop_front()?;
103        self.positions.remove(&id);
104        self.rebuild_positions();
105        Some(id)
106    }
107
108    /// Remove a specific ID from the tracker.
109    fn remove(&mut self, id: u64) {
110        if self.positions.remove(&id).is_some() {
111            self.order.retain(|&x| x != id);
112            self.rebuild_positions();
113        }
114    }
115
116    fn len(&self) -> usize {
117        self.order.len()
118    }
119
120    fn rebuild_positions(&mut self) {
121        self.positions.clear();
122        for (i, &id) in self.order.iter().enumerate() {
123            self.positions.insert(id, i);
124        }
125    }
126}
127
128// ── Stored vector snapshot (for promotion) ───────────────────────────────
129
130/// Snapshot of a vector for transfer between tiers.
131struct VectorSnapshot {
132    key: String,
133    vector: Vec<f32>,
134    metadata: serde_json::Value,
135}
136
137// ── Cold-tier vector cache (needed for promotion) ────────────────────────
138
139/// Cache of raw vectors stored alongside the DiskANN backend so that
140/// we can promote vectors to the hot tier without re-embedding.
141struct ColdVectorCache {
142    vectors: HashMap<u64, VectorSnapshot>,
143}
144
145impl ColdVectorCache {
146    fn new() -> Self {
147        Self {
148            vectors: HashMap::new(),
149        }
150    }
151
152    fn insert(&mut self, id: u64, key: String, vector: Vec<f32>, metadata: serde_json::Value) {
153        self.vectors.insert(
154            id,
155            VectorSnapshot {
156                key,
157                vector,
158                metadata,
159            },
160        );
161    }
162
163    fn get(&self, id: u64) -> Option<&VectorSnapshot> {
164        self.vectors.get(&id)
165    }
166
167    fn remove(&mut self, id: u64) {
168        self.vectors.remove(&id);
169    }
170}
171
172// ── Hybrid backend ──────────────────────────────────────────────────────
173
174/// Hybrid vector backend combining HNSW (hot) + DiskANN (cold).
175///
176/// # Data flow
177///
178/// - **Insert**: vector goes to DiskANN (always) and HNSW (if under hot
179///   capacity).
180/// - **Search**: query both tiers, fuse ranks via Reciprocal Rank Fusion
181///   (RRF; WEFT-661), deduplicate by numeric ID.
182/// - **Promotion**: access counts are tracked per ID. When a cold-only
183///   vector exceeds `promotion_threshold` accesses, it is copied into
184///   the hot tier.
185/// - **Eviction**: when HNSW exceeds `hot_capacity`, the LRU entry is
186///   evicted (it remains in DiskANN).
187pub struct HybridBackend {
188    hot: HnswBackend,
189    cold: DiskAnnBackend,
190    config: HybridConfig,
191    /// Per-ID access counters for promotion decisions.
192    access_counts: DashMap<u64, u32>,
193    /// LRU tracker for the hot tier (protected by Mutex for ordered ops).
194    lru: Mutex<LruTracker>,
195    /// Cold vector cache for promotion (protected by Mutex).
196    cold_cache: Mutex<ColdVectorCache>,
197    /// Set of IDs currently in the hot tier.
198    hot_ids: DashMap<u64, ()>,
199}
200
201impl HybridBackend {
202    /// Create a new hybrid backend.
203    pub fn new(
204        hnsw_config: HnswServiceConfig,
205        diskann_config: DiskAnnConfig,
206        hybrid_config: HybridConfig,
207    ) -> Self {
208        Self {
209            hot: HnswBackend::new(hnsw_config),
210            cold: DiskAnnBackend::new(diskann_config),
211            config: hybrid_config,
212            access_counts: DashMap::new(),
213            lru: Mutex::new(LruTracker::new()),
214            cold_cache: Mutex::new(ColdVectorCache::new()),
215            hot_ids: DashMap::new(),
216        }
217    }
218
219    /// Create with all-default configurations.
220    pub fn with_defaults() -> Self {
221        Self::new(
222            HnswServiceConfig::default(),
223            DiskAnnConfig::default(),
224            HybridConfig::default(),
225        )
226    }
227
228    /// Borrow the hybrid configuration.
229    pub fn config(&self) -> &HybridConfig {
230        &self.config
231    }
232
233    /// Return the number of vectors in the hot tier.
234    pub fn hot_len(&self) -> usize {
235        self.hot_ids.len()
236    }
237
238    /// Return the number of vectors in the cold tier.
239    pub fn cold_len(&self) -> usize {
240        self.cold.len()
241    }
242
243    /// Try to promote a vector from cold to hot tier.
244    fn try_promote(&self, id: u64) {
245        // Already in hot tier?
246        if self.hot_ids.contains_key(&id) {
247            return;
248        }
249
250        // Check access count.
251        let count = self.access_counts.get(&id).map(|v| *v).unwrap_or(0);
252
253        if count < self.config.promotion_threshold {
254            return;
255        }
256
257        // Get the vector from cold cache.
258        let snapshot = {
259            let cache = self.cold_cache.lock().expect("cold_cache lock poisoned");
260            cache.get(id).map(|s| VectorSnapshot {
261                key: s.key.clone(),
262                vector: s.vector.clone(),
263                metadata: s.metadata.clone(),
264            })
265        };
266
267        let Some(snap) = snapshot else { return };
268
269        // Evict from hot tier if at capacity.
270        self.ensure_hot_capacity();
271
272        // Insert into hot tier.
273        if self
274            .hot
275            .insert(id, &snap.key, &snap.vector, snap.metadata)
276            .is_ok()
277        {
278            self.hot_ids.insert(id, ());
279            let mut lru = self.lru.lock().expect("LRU lock poisoned");
280            lru.touch(id);
281        }
282    }
283
284    /// Ensure the hot tier has room for one more entry.
285    fn ensure_hot_capacity(&self) {
286        let mut lru = self.lru.lock().expect("LRU lock poisoned");
287        while lru.len() >= self.config.hot_capacity {
288            if let Some(evict_id) = lru.evict() {
289                self.hot.remove(evict_id);
290                self.hot_ids.remove(&evict_id);
291            } else {
292                break;
293            }
294        }
295    }
296
297    /// Increment the access counter for a given ID.
298    fn record_access(&self, id: u64) {
299        self.access_counts
300            .entry(id)
301            .and_modify(|c| {
302                *c = c.saturating_add(1);
303            })
304            .or_insert(1);
305
306        // Touch LRU if in hot tier.
307        if self.hot_ids.contains_key(&id) {
308            let mut lru = self.lru.lock().expect("LRU lock poisoned");
309            lru.touch(id);
310        }
311    }
312
313    /// Cormack et al. RRF smoothing constant (standard IR default).
314    ///
315    /// Higher `k` reduces the weight gap between rank-1 and rank-N.
316    const RRF_K: f32 = 60.0;
317
318    /// Merge and deduplicate search results from hot and cold tiers.
319    ///
320    /// # Ranking policy — Reciprocal Rank Fusion (WEFT-661)
321    ///
322    /// Hot HNSW returns **cosine** distances (~[0, 2]); real DiskANN cold
323    /// returns **squared Euclidean** (L2²) on a different scale. Sorting
324    /// raw distances lets cosine hits structurally dominate regardless of
325    /// true proximity (bench recall@10 ≈ 0.113). RRF ranks each list
326    /// independently and fuses with:
327    ///
328    /// ```text
329    /// score(id) = Σ_lists  1 / (RRF_K + rank_list(id))   // rank is 0-based
330    /// ```
331    ///
332    /// so metric scale is irrelevant. Higher score wins.
333    ///
334    /// # Dedup (WEFT-660)
335    ///
336    /// Dedup key is the numeric [`SearchResult::id`]. An id present in both
337    /// lists accumulates RRF mass from both ranks. The retained payload
338    /// prefers the **hot** entry (fresher key/metadata); cold fills only
339    /// when the id is cold-only. Original tier `distance` is kept for
340    /// diagnostics — callers must not assume a single metric after merge.
341    fn merge_results(
342        hot_results: Vec<SearchResult>,
343        cold_results: Vec<SearchResult>,
344        k: usize,
345    ) -> Vec<SearchResult> {
346        if k == 0 {
347            return Vec::new();
348        }
349
350        // id → fused RRF score (higher = better)
351        let mut rrf: HashMap<u64, f32> = HashMap::new();
352        // id → retained SearchResult (hot wins on first insert)
353        let mut by_id: HashMap<u64, SearchResult> = HashMap::new();
354
355        for (rank, r) in hot_results.into_iter().enumerate() {
356            *rrf.entry(r.id).or_insert(0.0) += 1.0 / (Self::RRF_K + rank as f32);
357            by_id.entry(r.id).or_insert(r);
358        }
359        for (rank, r) in cold_results.into_iter().enumerate() {
360            *rrf.entry(r.id).or_insert(0.0) += 1.0 / (Self::RRF_K + rank as f32);
361            // Prefer hot payload when both tiers returned the same id.
362            by_id.entry(r.id).or_insert(r);
363        }
364
365        let mut merged: Vec<SearchResult> = by_id.into_values().collect();
366        // RRF desc, then native distance asc, then id for determinism.
367        merged.sort_by(|a, b| {
368            let sa = rrf.get(&a.id).copied().unwrap_or(0.0);
369            let sb = rrf.get(&b.id).copied().unwrap_or(0.0);
370            sb.partial_cmp(&sa)
371                .unwrap_or(std::cmp::Ordering::Equal)
372                .then_with(|| {
373                    a.distance
374                        .partial_cmp(&b.distance)
375                        .unwrap_or(std::cmp::Ordering::Equal)
376                })
377                .then_with(|| a.id.cmp(&b.id))
378        });
379        merged.truncate(k);
380        merged
381    }
382}
383
384impl VectorBackend for HybridBackend {
385    fn insert(
386        &self,
387        id: u64,
388        key: &str,
389        vector: &[f32],
390        metadata: serde_json::Value,
391    ) -> VectorResult<()> {
392        // Always insert into cold tier (authoritative for capacity).
393        self.cold.insert(id, key, vector, metadata.clone())?;
394
395        // Cache the raw vector for future promotion.
396        {
397            let mut cache = self.cold_cache.lock().expect("cold_cache lock poisoned");
398            cache.insert(id, key.to_owned(), vector.to_vec(), metadata.clone());
399        }
400
401        // Insert into hot tier if under capacity.
402        if self.hot_ids.len() < self.config.hot_capacity {
403            self.hot.insert(id, key, vector, metadata)?;
404            self.hot_ids.insert(id, ());
405            let mut lru = self.lru.lock().expect("LRU lock poisoned");
406            lru.touch(id);
407        }
408
409        Ok(())
410    }
411
412    fn search(&self, query: &[f32], k: usize) -> Vec<SearchResult> {
413        // Search both tiers.
414        let hot_results = self.hot.search(query, k);
415        let cold_results = self.cold.search(query, k);
416
417        let merged = Self::merge_results(hot_results, cold_results, k);
418
419        // Record access for all returned IDs and potentially promote.
420        for r in &merged {
421            self.record_access(r.id);
422            self.try_promote(r.id);
423        }
424
425        merged
426    }
427
428    fn len(&self) -> usize {
429        // Total unique vectors = cold tier (which holds everything).
430        self.cold.len()
431    }
432
433    fn contains(&self, id: u64) -> bool {
434        self.cold.contains(id)
435    }
436
437    fn remove(&self, id: u64) -> bool {
438        // Remove from both tiers.
439        let hot_removed = self.hot.remove(id);
440        if hot_removed {
441            self.hot_ids.remove(&id);
442            let mut lru = self.lru.lock().expect("LRU lock poisoned");
443            lru.remove(id);
444        }
445        let cold_removed = self.cold.remove(id);
446        self.access_counts.remove(&id);
447        {
448            let mut cache = self.cold_cache.lock().expect("cold_cache lock poisoned");
449            cache.remove(id);
450        }
451        cold_removed
452    }
453
454    fn flush(&self) -> VectorResult<()> {
455        self.hot.flush()?;
456        self.cold.flush()?;
457        Ok(())
458    }
459
460    fn backend_name(&self) -> &str {
461        "hybrid"
462    }
463
464    // ── Epoch (delegated to cold tier as source of truth) ───────────────
465
466    fn current_epoch(&self) -> u64 {
467        self.cold.current_epoch()
468    }
469
470    fn insert_with_epoch(
471        &self,
472        id: u64,
473        key: &str,
474        vector: &[f32],
475        metadata: serde_json::Value,
476        parent_epoch: u64,
477    ) -> VectorResult<()> {
478        let current = self.cold.current_epoch();
479        if parent_epoch < current {
480            return Err(VectorError::EpochConflict {
481                expected: parent_epoch,
482                actual: current,
483            });
484        }
485        self.insert(id, key, vector, metadata)
486    }
487
488    // ── Soft-delete + compaction (mirrored to both tiers) ───────────────
489
490    fn soft_delete(&self, id: u64) -> bool {
491        // Soft-delete in cold (authoritative).
492        let deleted = self.cold.soft_delete(id);
493        if deleted {
494            // Also soft-delete in hot tier so searches exclude it.
495            self.hot.soft_delete(id);
496            if self.hot_ids.contains_key(&id) {
497                self.hot_ids.remove(&id);
498                let mut lru = self.lru.lock().expect("LRU lock poisoned");
499                lru.remove(id);
500            }
501        }
502        deleted
503    }
504
505    fn compact(&self, older_than_epoch: u64) -> usize {
506        // Compact cold tier (authoritative).
507        let purged = self.cold.compact(older_than_epoch);
508        // Also compact hot tier to stay in sync.
509        self.hot.compact(older_than_epoch);
510        purged
511    }
512
513    fn tombstone_count(&self) -> usize {
514        self.cold.tombstone_count()
515    }
516
517    // ── Capacity limits (delegated to cold tier) ────────────────────────
518
519    fn max_vectors(&self) -> Option<usize> {
520        self.cold.max_vectors()
521    }
522
523    fn set_max_vectors(&self, limit: Option<usize>) {
524        self.cold.set_max_vectors(limit);
525    }
526}
527
528impl std::fmt::Debug for HybridBackend {
529    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
530        f.debug_struct("HybridBackend")
531            .field("hot_len", &self.hot_len())
532            .field("cold_len", &self.cold_len())
533            .field("epoch", &self.current_epoch())
534            .field("tombstones", &self.tombstone_count())
535            .field("config", &self.config)
536            .finish()
537    }
538}
539
540// ── Tests ────────────────────────────────────────────────────────────────
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545
546    fn small_hybrid() -> HybridBackend {
547        HybridBackend::new(
548            HnswServiceConfig::default(),
549            DiskAnnConfig {
550                max_points: 1000,
551                ..DiskAnnConfig::default()
552            },
553            HybridConfig {
554                hot_capacity: 3,
555                promotion_threshold: 2,
556                eviction_policy: EvictionPolicy::Lru,
557            },
558        )
559    }
560
561    #[test]
562    fn insert_populates_both_tiers_under_capacity() {
563        let h = small_hybrid();
564        h.insert(1, "a", &[1.0, 0.0, 0.0], serde_json::json!({}))
565            .unwrap();
566        h.insert(2, "b", &[0.0, 1.0, 0.0], serde_json::json!({}))
567            .unwrap();
568
569        assert_eq!(h.len(), 2);
570        assert_eq!(h.hot_len(), 2);
571        assert_eq!(h.cold_len(), 2);
572    }
573
574    #[test]
575    fn insert_beyond_hot_capacity_stays_cold_only() {
576        let h = small_hybrid();
577        h.insert(1, "a", &[1.0, 0.0, 0.0], serde_json::json!({}))
578            .unwrap();
579        h.insert(2, "b", &[0.0, 1.0, 0.0], serde_json::json!({}))
580            .unwrap();
581        h.insert(3, "c", &[0.0, 0.0, 1.0], serde_json::json!({}))
582            .unwrap();
583
584        // 4th vector exceeds hot capacity.
585        h.insert(4, "d", &[0.5, 0.5, 0.0], serde_json::json!({}))
586            .unwrap();
587
588        assert_eq!(h.len(), 4);
589        assert_eq!(h.hot_len(), 3);
590        assert_eq!(h.cold_len(), 4);
591    }
592
593    #[test]
594    fn search_returns_merged_results() {
595        let h = small_hybrid();
596        h.insert(1, "a", &[1.0, 0.0, 0.0], serde_json::json!({}))
597            .unwrap();
598        h.insert(2, "b", &[0.0, 1.0, 0.0], serde_json::json!({}))
599            .unwrap();
600
601        let results = h.search(&[1.0, 0.0, 0.0], 2);
602        assert_eq!(results.len(), 2);
603        assert_eq!(results[0].id, 1);
604        assert!(results[0].distance < 0.01);
605    }
606
607    #[test]
608    fn promotion_after_threshold() {
609        let h = small_hybrid();
610        h.insert(1, "a", &[1.0, 0.0, 0.0], serde_json::json!({}))
611            .unwrap();
612        h.insert(2, "b", &[0.0, 1.0, 0.0], serde_json::json!({}))
613            .unwrap();
614        h.insert(3, "c", &[0.0, 0.0, 1.0], serde_json::json!({}))
615            .unwrap();
616
617        // Insert cold-only vector.
618        h.insert(4, "d", &[0.5, 0.5, 0.0], serde_json::json!({}))
619            .unwrap();
620        assert!(!h.hot_ids.contains_key(&4));
621
622        h.record_access(4);
623        h.record_access(4);
624        h.try_promote(4);
625
626        assert!(h.hot_ids.contains_key(&4));
627        assert_eq!(h.hot_len(), 3);
628    }
629
630    #[test]
631    fn remove_cleans_both_tiers() {
632        let h = small_hybrid();
633        h.insert(1, "a", &[1.0, 0.0], serde_json::json!({}))
634            .unwrap();
635        assert!(h.contains(1));
636        assert!(h.remove(1));
637        assert!(!h.contains(1));
638        assert_eq!(h.hot_len(), 0);
639        assert_eq!(h.cold_len(), 0);
640    }
641
642    #[test]
643    fn merge_results_deduplicates() {
644        let hot = vec![
645            SearchResult::new(1, "a".into(), 0.1, serde_json::json!({})),
646            SearchResult::new(2, "b".into(), 0.3, serde_json::json!({})),
647        ];
648        let cold = vec![
649            SearchResult::new(1, "a".into(), 0.1, serde_json::json!({})),
650            SearchResult::new(3, "c".into(), 0.2, serde_json::json!({})),
651        ];
652
653        let merged = HybridBackend::merge_results(hot, cold, 3);
654        assert_eq!(merged.len(), 3);
655        // id=1 is in both lists → highest RRF (1/60 + 1/60).
656        assert_eq!(merged[0].id, 1);
657        // id=2 and id=3 both rank-1 in one list (score 1/61); distance
658        // tie-break: 0.2 < 0.3 → cold id=3 before hot id=2.
659        assert_eq!(merged[1].id, 3);
660        assert_eq!(merged[2].id, 2);
661    }
662
663    /// WEFT-661: cosine-scale hot distances must not drown L2² cold ranks.
664    #[test]
665    fn merge_results_rrf_not_raw_distance() {
666        // Hot: tiny cosine-like distances. Cold: large L2²-like distances.
667        // Raw-distance sort would return only hot ids for k=2.
668        let hot = vec![
669            SearchResult::new(10, "hot0".into(), 0.001, serde_json::Value::Null),
670            SearchResult::new(11, "hot1".into(), 0.002, serde_json::Value::Null),
671        ];
672        let cold = vec![
673            SearchResult::new(20, "cold0".into(), 100.0, serde_json::Value::Null),
674            SearchResult::new(21, "cold1".into(), 200.0, serde_json::Value::Null),
675        ];
676
677        let merged = HybridBackend::merge_results(hot, cold, 2);
678        assert_eq!(merged.len(), 2);
679        let ids: std::collections::HashSet<u64> = merged.iter().map(|r| r.id).collect();
680        // Rank-0 from each list share the top RRF band → both tiers in top-2.
681        assert!(ids.contains(&10), "hot rank-0 must survive RRF: {ids:?}");
682        assert!(ids.contains(&20), "cold rank-0 must survive RRF: {ids:?}");
683    }
684
685    /// WEFT-660/661: same numeric id from both tiers is one hit; hot payload wins.
686    #[test]
687    fn merge_results_dedup_prefers_hot_payload() {
688        let hot = vec![SearchResult::new(
689            42,
690            "hot-key".into(),
691            0.05,
692            serde_json::json!({"tier": "hot"}),
693        )];
694        let cold = vec![SearchResult::new(
695            42,
696            "cold-key".into(),
697            50.0,
698            serde_json::json!({"tier": "cold"}),
699        )];
700
701        let merged = HybridBackend::merge_results(hot, cold, 1);
702        assert_eq!(merged.len(), 1);
703        assert_eq!(merged[0].id, 42);
704        assert_eq!(merged[0].key, "hot-key");
705        assert_eq!(merged[0].metadata, serde_json::json!({"tier": "hot"}));
706        // Native hot distance retained (not rewritten to RRF).
707        assert!((merged[0].distance - 0.05).abs() < f32::EPSILON);
708    }
709
710    #[test]
711    fn merge_results_empty_and_k_zero() {
712        assert!(HybridBackend::merge_results(vec![], vec![], 5).is_empty());
713        let hot = vec![SearchResult::new(1, "a".into(), 0.1, serde_json::Value::Null)];
714        assert!(HybridBackend::merge_results(hot, vec![], 0).is_empty());
715    }
716
717    #[test]
718    fn merge_results_cold_only_ids_survive() {
719        let hot = vec![SearchResult::new(1, "a".into(), 0.01, serde_json::Value::Null)];
720        let cold = vec![
721            SearchResult::new(2, "b".into(), 99.0, serde_json::Value::Null),
722            SearchResult::new(3, "c".into(), 100.0, serde_json::Value::Null),
723        ];
724        let merged = HybridBackend::merge_results(hot, cold, 3);
725        assert_eq!(merged.len(), 3);
726        let ids: std::collections::HashSet<u64> = merged.iter().map(|r| r.id).collect();
727        assert_eq!(ids, [1, 2, 3].into_iter().collect());
728    }
729
730    #[test]
731    fn backend_name() {
732        let h = small_hybrid();
733        assert_eq!(h.backend_name(), "hybrid");
734    }
735
736    #[test]
737    fn flush_both_tiers() {
738        let h = small_hybrid();
739        h.flush().unwrap();
740    }
741
742    #[test]
743    fn eviction_policy_default() {
744        assert_eq!(EvictionPolicy::default(), EvictionPolicy::Lru);
745    }
746
747    #[test]
748    fn lru_tracker_basics() {
749        let mut lru = LruTracker::new();
750        lru.touch(1);
751        lru.touch(2);
752        lru.touch(3);
753        assert_eq!(lru.len(), 3);
754
755        lru.touch(1);
756        assert_eq!(lru.evict(), Some(2));
757        assert_eq!(lru.len(), 2);
758    }
759
760    // ── Epoch tests ─────────────────────────────────────────────────────
761
762    #[test]
763    fn hybrid_epoch_tracks_cold_tier() {
764        let h = small_hybrid();
765        assert_eq!(h.current_epoch(), 0);
766        h.insert(1, "a", &[1.0, 0.0, 0.0], serde_json::json!({}))
767            .unwrap();
768        assert!(h.current_epoch() > 0);
769    }
770
771    #[test]
772    fn hybrid_insert_with_epoch_rejects_stale() {
773        let h = small_hybrid();
774        h.insert(1, "a", &[1.0], serde_json::json!({})).unwrap();
775        let result = h.insert_with_epoch(2, "b", &[0.0], serde_json::json!({}), 0);
776        assert!(matches!(result, Err(VectorError::EpochConflict { .. })));
777    }
778
779    // ── Soft-delete tests ───────────────────────────────────────────────
780
781    #[test]
782    fn hybrid_soft_delete_hides_from_both_tiers() {
783        let h = small_hybrid();
784        h.insert(1, "a", &[1.0, 0.0, 0.0], serde_json::json!({}))
785            .unwrap();
786        h.insert(2, "b", &[0.0, 1.0, 0.0], serde_json::json!({}))
787            .unwrap();
788
789        assert!(h.soft_delete(1));
790        assert_eq!(h.tombstone_count(), 1);
791        assert_eq!(h.len(), 1);
792        assert!(!h.contains(1));
793    }
794
795    #[test]
796    fn hybrid_compact() {
797        let h = small_hybrid();
798        h.insert(1, "a", &[1.0], serde_json::json!({})).unwrap();
799        h.soft_delete(1);
800        let epoch = h.current_epoch();
801        let purged = h.compact(epoch + 1);
802        assert_eq!(purged, 1);
803        assert_eq!(h.tombstone_count(), 0);
804    }
805
806    // ── Capacity tests ──────────────────────────────────────────────────
807
808    #[test]
809    fn hybrid_capacity_delegates_to_cold() {
810        let h = small_hybrid();
811        assert_eq!(h.max_vectors(), Some(1000));
812        h.set_max_vectors(Some(5));
813        assert_eq!(h.max_vectors(), Some(5));
814    }
815}