Skip to main content

ruvector_temporal_tensor/
agentdb.rs

1//! AgentDB adapter for pattern-aware tiering.
2//!
3//! Provides a bridge between the TieredStore and an external HNSW
4//! vector index. When connected, tiering decisions can be influenced
5//! by semantic similarity to frequently-accessed patterns.
6//!
7//! # Overview
8//!
9//! Block metadata is converted into a compact 4-dimensional embedding
10//! via [`pattern_from_meta`], then stored in a [`PatternIndex`]. The
11//! [`AdaptiveTiering`] struct combines the index with a
12//! [`TierConfig`](crate::tiering::TierConfig) to produce tier
13//! suggestions based on weighted neighbor voting.
14//!
15//! The default [`InMemoryPatternIndex`] uses brute-force linear scan
16//! with cosine similarity, suitable for up to ~10K blocks. A real
17//! deployment would swap in an HNSW-backed implementation.
18
19use crate::store::{BlockKey, BlockMeta, Tier};
20use crate::tiering::TierConfig;
21use std::collections::HashMap;
22
23// ---------------------------------------------------------------------------
24// PatternVector
25// ---------------------------------------------------------------------------
26
27/// A block's access-pattern embedding for similarity search.
28#[derive(Clone, Debug)]
29pub struct PatternVector {
30    /// The block this vector represents.
31    pub key: BlockKey,
32    /// Access-pattern embedding (typically 4 dimensions).
33    pub embedding: Vec<f32>,
34    /// Tiering score at the time of insertion.
35    pub score: f32,
36}
37
38// ---------------------------------------------------------------------------
39// PatternIndex trait
40// ---------------------------------------------------------------------------
41
42/// Trait for a vector index over access-pattern embeddings.
43///
44/// Implementations range from a simple brute-force scan
45/// ([`InMemoryPatternIndex`]) to an HNSW-backed production index.
46pub trait PatternIndex {
47    /// Insert (or replace) a pattern vector.
48    fn insert(&mut self, vec: &PatternVector);
49
50    /// Return the `k` nearest neighbors to `query`, sorted by
51    /// descending cosine similarity. Each result is `(key, similarity)`.
52    fn search_nearest(&self, query: &[f32], k: usize) -> Vec<(BlockKey, f32)>;
53
54    /// Remove the pattern for `key`, if present.
55    fn remove(&mut self, key: BlockKey);
56
57    /// Number of pattern vectors currently stored.
58    fn len(&self) -> usize;
59
60    /// Returns `true` if the index contains no vectors.
61    fn is_empty(&self) -> bool {
62        self.len() == 0
63    }
64}
65
66// ---------------------------------------------------------------------------
67// Cosine similarity
68// ---------------------------------------------------------------------------
69
70/// Compute the cosine similarity between two vectors.
71///
72/// Returns 0.0 if either vector has zero magnitude or they differ in length.
73fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
74    if a.len() != b.len() || a.is_empty() {
75        return 0.0;
76    }
77
78    let mut dot = 0.0f32;
79    let mut norm_a_sq = 0.0f32;
80    let mut norm_b_sq = 0.0f32;
81
82    for (&x, &y) in a.iter().zip(b.iter()) {
83        dot += x * y;
84        norm_a_sq += x * x;
85        norm_b_sq += y * y;
86    }
87
88    let denom = norm_a_sq.sqrt() * norm_b_sq.sqrt();
89    if denom == 0.0 {
90        0.0
91    } else {
92        dot / denom
93    }
94}
95
96// ---------------------------------------------------------------------------
97// InMemoryPatternIndex
98// ---------------------------------------------------------------------------
99
100/// Brute-force in-memory implementation of [`PatternIndex`].
101///
102/// Uses a `Vec<PatternVector>` with linear-scan cosine similarity.
103/// Adequate for small collections (<10K blocks); a real AgentDB
104/// deployment would use HNSW for sub-linear search.
105pub struct InMemoryPatternIndex {
106    vectors: Vec<PatternVector>,
107}
108
109impl InMemoryPatternIndex {
110    /// Create a new empty index.
111    pub fn new() -> Self {
112        Self {
113            vectors: Vec::new(),
114        }
115    }
116}
117
118impl Default for InMemoryPatternIndex {
119    fn default() -> Self {
120        Self::new()
121    }
122}
123
124impl PatternIndex for InMemoryPatternIndex {
125    fn insert(&mut self, vec: &PatternVector) {
126        // Remove any existing entry for the same key, then append.
127        self.vectors.retain(|v| v.key != vec.key);
128        self.vectors.push(vec.clone());
129    }
130
131    fn search_nearest(&self, query: &[f32], k: usize) -> Vec<(BlockKey, f32)> {
132        if k == 0 || self.vectors.is_empty() {
133            return Vec::new();
134        }
135
136        let mut scored: Vec<(BlockKey, f32)> = self
137            .vectors
138            .iter()
139            .map(|v| (v.key, cosine_similarity(query, &v.embedding)))
140            .collect();
141
142        // Sort by descending similarity.
143        scored.sort_by(|a, b| {
144            b.1.partial_cmp(&a.1)
145                .unwrap_or(core::cmp::Ordering::Equal)
146        });
147        scored.truncate(k);
148        scored
149    }
150
151    fn remove(&mut self, key: BlockKey) {
152        self.vectors.retain(|v| v.key != key);
153    }
154
155    fn len(&self) -> usize {
156        self.vectors.len()
157    }
158}
159
160// ---------------------------------------------------------------------------
161// pattern_from_meta
162// ---------------------------------------------------------------------------
163
164/// Convert block metadata into a 4-dimensional pattern vector.
165///
166/// The dimensions encode access-pattern features that are useful for
167/// clustering blocks with similar tiering behaviour:
168///
169/// | Index | Feature          | Range   | Description                              |
170/// |-------|------------------|---------|------------------------------------------|
171/// | 0     | `ema_rate`       | [0, 1]  | Exponential moving average of access rate|
172/// | 1     | `popcount/64`    | [0, 1]  | Fraction of recent ticks with access     |
173/// | 2     | `recency_decay`  | (0, 1]  | `1 / (1 + tier_age)` -- inverse staleness|
174/// | 3     | `access_count_log` | [0, 1] | `log2(1 + count) / 32` -- normalized log |
175pub fn pattern_from_meta(meta: &BlockMeta) -> Vec<f32> {
176    let ema = meta.ema_rate.clamp(0.0, 1.0);
177    let pop = meta.window.count_ones() as f32 / 64.0;
178    let recency = 1.0 / (1.0 + meta.tier_age as f32);
179    let count_log = ((1.0 + meta.access_count as f32).log2() / 32.0).clamp(0.0, 1.0);
180
181    vec![ema, pop, recency, count_log]
182}
183
184// ---------------------------------------------------------------------------
185// AdaptiveTiering
186// ---------------------------------------------------------------------------
187
188/// Pattern-aware tiering advisor.
189///
190/// Combines a [`PatternIndex`] with a [`TierConfig`] to suggest tier
191/// assignments based on the tiers of semantically similar blocks.
192///
193/// # Algorithm
194///
195/// Given a block's metadata and a set of nearest neighbors (from the
196/// pattern index), each neighbor's known tier contributes a weighted
197/// vote proportional to its cosine similarity. The tier with the
198/// highest cumulative vote is suggested, unless it matches the block's
199/// current tier (in which case `None` is returned).
200pub struct AdaptiveTiering<I: PatternIndex> {
201    /// The underlying pattern vector index.
202    pub index: I,
203    /// Tiering configuration (thresholds, hysteresis, etc.).
204    pub config: TierConfig,
205    /// Known tier for each block, updated via [`register_block`].
206    block_tiers: HashMap<BlockKey, Tier>,
207}
208
209impl<I: PatternIndex> AdaptiveTiering<I> {
210    /// Create a new `AdaptiveTiering` with the given index and config.
211    pub fn new(index: I, config: TierConfig) -> Self {
212        Self {
213            index,
214            config,
215            block_tiers: HashMap::new(),
216        }
217    }
218
219    /// Register (or update) the known tier for a block.
220    ///
221    /// This must be called whenever a block changes tier so that
222    /// [`suggest_tier`](Self::suggest_tier) can use accurate neighbor
223    /// tier information for voting.
224    pub fn register_block(&mut self, key: BlockKey, tier: Tier) {
225        self.block_tiers.insert(key, tier);
226    }
227
228    /// Remove a block from the tier registry and the pattern index.
229    pub fn remove_block(&mut self, key: BlockKey) {
230        self.block_tiers.remove(&key);
231        self.index.remove(key);
232    }
233
234    /// Number of blocks registered in the tier map.
235    pub fn registered_count(&self) -> usize {
236        self.block_tiers.len()
237    }
238
239    /// Suggest a tier for `meta` based on its nearest neighbors.
240    ///
241    /// `neighbors` should be the output of
242    /// [`PatternIndex::search_nearest`]: a list of `(BlockKey, similarity)`
243    /// pairs. Each neighbor whose tier is known contributes a weighted
244    /// vote. The tier with the highest total vote is returned, unless it
245    /// matches the block's current tier.
246    ///
247    /// Returns `None` if:
248    /// - `neighbors` is empty,
249    /// - no neighbors have known tiers, or
250    /// - the consensus tier matches the block's current tier.
251    pub fn suggest_tier(
252        &self,
253        meta: &BlockMeta,
254        neighbors: &[(BlockKey, f32)],
255    ) -> Option<Tier> {
256        if neighbors.is_empty() {
257            return None;
258        }
259
260        // Accumulate weighted votes per tier.
261        // Index 0 = Tier0, 1 = Tier1, 2 = Tier2, 3 = Tier3.
262        let mut votes = [0.0f32; 4];
263        let mut total_weight = 0.0f32;
264
265        for &(key, similarity) in neighbors {
266            if let Some(&tier) = self.block_tiers.get(&key) {
267                let weight = similarity.max(0.0);
268                votes[tier as u8 as usize] += weight;
269                total_weight += weight;
270            }
271        }
272
273        if total_weight == 0.0 {
274            return None;
275        }
276
277        // Find the tier with the highest vote. On ties, prefer the
278        // hotter tier (lower index) since it was found first.
279        let mut best_idx = 0usize;
280        let mut best_vote = votes[0];
281        for i in 1..4 {
282            if votes[i] > best_vote {
283                best_vote = votes[i];
284                best_idx = i;
285            }
286        }
287
288        let suggested = match best_idx {
289            0 => Tier::Tier0,
290            1 => Tier::Tier1,
291            2 => Tier::Tier2,
292            3 => Tier::Tier3,
293            _ => unreachable!(),
294        };
295
296        if suggested == meta.tier {
297            None
298        } else {
299            Some(suggested)
300        }
301    }
302}
303
304// ---------------------------------------------------------------------------
305// Tests
306// ---------------------------------------------------------------------------
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use crate::store::{DType, ReconstructPolicy};
312
313    fn make_key(tid: u128, idx: u32) -> BlockKey {
314        BlockKey {
315            tensor_id: tid,
316            block_index: idx,
317        }
318    }
319
320    fn make_store_meta(
321        key: BlockKey,
322        tier: Tier,
323        ema_rate: f32,
324        window: u64,
325        access_count: u32,
326        tier_age: u32,
327    ) -> BlockMeta {
328        BlockMeta {
329            key,
330            dtype: DType::F32,
331            tier,
332            bits: 8,
333            scale: 1.0,
334            zero_point: 0,
335            created_at: 0,
336            last_access_at: 100,
337            access_count,
338            ema_rate,
339            window,
340            checksum: 0,
341            reconstruct: ReconstructPolicy::None,
342            tier_age,
343            lineage_parent: None,
344            block_bytes: 1024,
345        }
346    }
347
348    // -- cosine_similarity -------------------------------------------------
349
350    #[test]
351    fn cosine_identical_vectors() {
352        let v = vec![1.0, 2.0, 3.0, 4.0];
353        let sim = cosine_similarity(&v, &v);
354        assert!((sim - 1.0).abs() < 1e-6, "sim={sim}");
355    }
356
357    #[test]
358    fn cosine_orthogonal_vectors() {
359        let a = vec![1.0, 0.0];
360        let b = vec![0.0, 1.0];
361        let sim = cosine_similarity(&a, &b);
362        assert!(sim.abs() < 1e-6, "sim={sim}");
363    }
364
365    #[test]
366    fn cosine_opposite_vectors() {
367        let a = vec![1.0, 0.0, 0.0];
368        let b = vec![-1.0, 0.0, 0.0];
369        let sim = cosine_similarity(&a, &b);
370        assert!((sim - (-1.0)).abs() < 1e-6, "sim={sim}");
371    }
372
373    #[test]
374    fn cosine_zero_vector() {
375        let a = vec![1.0, 2.0];
376        let b = vec![0.0, 0.0];
377        assert_eq!(cosine_similarity(&a, &b), 0.0);
378    }
379
380    #[test]
381    fn cosine_different_lengths() {
382        let a = vec![1.0, 2.0];
383        let b = vec![1.0, 2.0, 3.0];
384        assert_eq!(cosine_similarity(&a, &b), 0.0);
385    }
386
387    #[test]
388    fn cosine_empty() {
389        let a: Vec<f32> = vec![];
390        let b: Vec<f32> = vec![];
391        assert_eq!(cosine_similarity(&a, &b), 0.0);
392    }
393
394    #[test]
395    fn cosine_known_value() {
396        // cos([1,1], [1,0]) = 1/sqrt(2) ~ 0.7071
397        let a = vec![1.0, 1.0];
398        let b = vec![1.0, 0.0];
399        let sim = cosine_similarity(&a, &b);
400        let expected = 1.0 / 2.0f32.sqrt();
401        assert!((sim - expected).abs() < 1e-6, "sim={sim}, expected={expected}");
402    }
403
404    // -- InMemoryPatternIndex ----------------------------------------------
405
406    #[test]
407    fn index_insert_and_len() {
408        let mut idx = InMemoryPatternIndex::new();
409        assert!(idx.is_empty());
410
411        idx.insert(&PatternVector {
412            key: make_key(1, 0),
413            embedding: vec![1.0, 0.0, 0.0, 0.0],
414            score: 0.5,
415        });
416        assert_eq!(idx.len(), 1);
417        assert!(!idx.is_empty());
418    }
419
420    #[test]
421    fn index_insert_replaces_duplicate_key() {
422        let mut idx = InMemoryPatternIndex::new();
423        let key = make_key(1, 0);
424
425        idx.insert(&PatternVector {
426            key,
427            embedding: vec![1.0, 0.0, 0.0, 0.0],
428            score: 0.5,
429        });
430        idx.insert(&PatternVector {
431            key,
432            embedding: vec![0.0, 1.0, 0.0, 0.0],
433            score: 0.8,
434        });
435
436        assert_eq!(idx.len(), 1);
437
438        // The search should find the updated embedding.
439        let results = idx.search_nearest(&[0.0, 1.0, 0.0, 0.0], 1);
440        assert_eq!(results.len(), 1);
441        assert_eq!(results[0].0, key);
442        // Similarity should be ~1.0 since embeddings match.
443        assert!((results[0].1 - 1.0).abs() < 1e-6);
444    }
445
446    #[test]
447    fn index_remove() {
448        let mut idx = InMemoryPatternIndex::new();
449        let key = make_key(1, 0);
450
451        idx.insert(&PatternVector {
452            key,
453            embedding: vec![1.0, 0.0, 0.0, 0.0],
454            score: 0.5,
455        });
456        assert_eq!(idx.len(), 1);
457
458        idx.remove(key);
459        assert_eq!(idx.len(), 0);
460    }
461
462    #[test]
463    fn index_remove_nonexistent() {
464        let mut idx = InMemoryPatternIndex::new();
465        idx.remove(make_key(99, 0)); // should not panic
466        assert_eq!(idx.len(), 0);
467    }
468
469    #[test]
470    fn index_search_nearest_ordering() {
471        let mut idx = InMemoryPatternIndex::new();
472
473        // Insert three vectors with known geometry.
474        idx.insert(&PatternVector {
475            key: make_key(1, 0),
476            embedding: vec![1.0, 0.0, 0.0, 0.0],
477            score: 0.0,
478        });
479        idx.insert(&PatternVector {
480            key: make_key(2, 0),
481            embedding: vec![0.7, 0.7, 0.0, 0.0],
482            score: 0.0,
483        });
484        idx.insert(&PatternVector {
485            key: make_key(3, 0),
486            embedding: vec![0.0, 1.0, 0.0, 0.0],
487            score: 0.0,
488        });
489
490        // Query close to [1, 0, 0, 0].
491        let results = idx.search_nearest(&[1.0, 0.1, 0.0, 0.0], 3);
492        assert_eq!(results.len(), 3);
493
494        // Closest should be key 1 (nearly identical direction).
495        assert_eq!(results[0].0, make_key(1, 0));
496        // Second should be key 2 (partial overlap).
497        assert_eq!(results[1].0, make_key(2, 0));
498        // Third should be key 3 (mostly orthogonal).
499        assert_eq!(results[2].0, make_key(3, 0));
500
501        // Similarities should be descending.
502        assert!(results[0].1 >= results[1].1);
503        assert!(results[1].1 >= results[2].1);
504    }
505
506    #[test]
507    fn index_search_nearest_k_larger_than_size() {
508        let mut idx = InMemoryPatternIndex::new();
509        idx.insert(&PatternVector {
510            key: make_key(1, 0),
511            embedding: vec![1.0, 0.0],
512            score: 0.0,
513        });
514
515        let results = idx.search_nearest(&[1.0, 0.0], 10);
516        assert_eq!(results.len(), 1);
517    }
518
519    #[test]
520    fn index_search_nearest_k_zero() {
521        let mut idx = InMemoryPatternIndex::new();
522        idx.insert(&PatternVector {
523            key: make_key(1, 0),
524            embedding: vec![1.0],
525            score: 0.0,
526        });
527
528        let results = idx.search_nearest(&[1.0], 0);
529        assert!(results.is_empty());
530    }
531
532    #[test]
533    fn index_search_nearest_empty() {
534        let idx = InMemoryPatternIndex::new();
535        let results = idx.search_nearest(&[1.0, 0.0], 5);
536        assert!(results.is_empty());
537    }
538
539    // -- pattern_from_meta -------------------------------------------------
540
541    #[test]
542    fn pattern_from_meta_dimensions() {
543        let meta = make_store_meta(make_key(1, 0), Tier::Tier1, 0.5, 0xFFFF, 100, 10);
544        let pat = pattern_from_meta(&meta);
545        assert_eq!(pat.len(), 4);
546    }
547
548    #[test]
549    fn pattern_from_meta_ema_component() {
550        let meta = make_store_meta(make_key(1, 0), Tier::Tier1, 0.8, 0, 0, 0);
551        let pat = pattern_from_meta(&meta);
552        assert!((pat[0] - 0.8).abs() < 1e-6, "ema={}", pat[0]);
553    }
554
555    #[test]
556    fn pattern_from_meta_popcount_component() {
557        // All 64 bits set.
558        let meta = make_store_meta(make_key(1, 0), Tier::Tier1, 0.0, u64::MAX, 0, 0);
559        let pat = pattern_from_meta(&meta);
560        assert!((pat[1] - 1.0).abs() < 1e-6, "pop={}", pat[1]);
561
562        // No bits set.
563        let meta2 = make_store_meta(make_key(1, 0), Tier::Tier1, 0.0, 0, 0, 0);
564        let pat2 = pattern_from_meta(&meta2);
565        assert!((pat2[1]).abs() < 1e-6, "pop={}", pat2[1]);
566
567        // 32 bits set.
568        let meta3 = make_store_meta(make_key(1, 0), Tier::Tier1, 0.0, 0xFFFF_FFFF, 0, 0);
569        let pat3 = pattern_from_meta(&meta3);
570        assert!((pat3[1] - 0.5).abs() < 1e-6, "pop={}", pat3[1]);
571    }
572
573    #[test]
574    fn pattern_from_meta_recency_component() {
575        // tier_age = 0 => recency = 1.0 / (1.0 + 0) = 1.0
576        let meta = make_store_meta(make_key(1, 0), Tier::Tier1, 0.0, 0, 0, 0);
577        let pat = pattern_from_meta(&meta);
578        assert!((pat[2] - 1.0).abs() < 1e-6, "recency={}", pat[2]);
579
580        // tier_age = 9 => recency = 1.0 / 10.0 = 0.1
581        let meta2 = make_store_meta(make_key(1, 0), Tier::Tier1, 0.0, 0, 0, 9);
582        let pat2 = pattern_from_meta(&meta2);
583        assert!((pat2[2] - 0.1).abs() < 1e-6, "recency={}", pat2[2]);
584    }
585
586    #[test]
587    fn pattern_from_meta_access_count_log_component() {
588        // access_count = 0 => log2(1) / 32 = 0
589        let meta = make_store_meta(make_key(1, 0), Tier::Tier1, 0.0, 0, 0, 0);
590        let pat = pattern_from_meta(&meta);
591        assert!(pat[3].abs() < 1e-6, "count_log={}", pat[3]);
592
593        // access_count = 1 => log2(2) / 32 = 1/32 ~ 0.03125
594        let meta2 = make_store_meta(make_key(1, 0), Tier::Tier1, 0.0, 0, 1, 0);
595        let pat2 = pattern_from_meta(&meta2);
596        assert!((pat2[3] - 1.0 / 32.0).abs() < 1e-4, "count_log={}", pat2[3]);
597    }
598
599    #[test]
600    fn pattern_from_meta_values_in_unit_range() {
601        // Use extreme values to verify clamping.
602        let meta = make_store_meta(
603            make_key(1, 0),
604            Tier::Tier1,
605            2.0,       // ema > 1, should be clamped
606            u64::MAX,  // all bits set
607            u32::MAX,  // max access count
608            u32::MAX,  // max tier age
609        );
610        let pat = pattern_from_meta(&meta);
611        for (i, &v) in pat.iter().enumerate() {
612            assert!(
613                v >= 0.0 && v <= 1.0,
614                "dim {i} out of [0,1]: {v}"
615            );
616        }
617    }
618
619    // -- AdaptiveTiering ---------------------------------------------------
620
621    #[test]
622    fn adaptive_new_and_register() {
623        let idx = InMemoryPatternIndex::new();
624        let config = TierConfig::default();
625        let mut at = AdaptiveTiering::new(idx, config);
626
627        assert_eq!(at.registered_count(), 0);
628
629        at.register_block(make_key(1, 0), Tier::Tier1);
630        assert_eq!(at.registered_count(), 1);
631
632        at.register_block(make_key(1, 0), Tier::Tier2);
633        assert_eq!(at.registered_count(), 1); // same key, updated
634    }
635
636    #[test]
637    fn adaptive_remove_block() {
638        let mut idx = InMemoryPatternIndex::new();
639        let key = make_key(1, 0);
640        idx.insert(&PatternVector {
641            key,
642            embedding: vec![1.0, 0.0, 0.0, 0.0],
643            score: 0.5,
644        });
645
646        let config = TierConfig::default();
647        let mut at = AdaptiveTiering::new(idx, config);
648        at.register_block(key, Tier::Tier1);
649        assert_eq!(at.registered_count(), 1);
650        assert_eq!(at.index.len(), 1);
651
652        at.remove_block(key);
653        assert_eq!(at.registered_count(), 0);
654        assert_eq!(at.index.len(), 0);
655    }
656
657    #[test]
658    fn suggest_tier_empty_neighbors() {
659        let idx = InMemoryPatternIndex::new();
660        let config = TierConfig::default();
661        let at = AdaptiveTiering::new(idx, config);
662
663        let meta = make_store_meta(make_key(1, 0), Tier::Tier1, 0.5, 0, 10, 5);
664        let result = at.suggest_tier(&meta, &[]);
665        assert_eq!(result, None);
666    }
667
668    #[test]
669    fn suggest_tier_no_known_neighbors() {
670        let idx = InMemoryPatternIndex::new();
671        let config = TierConfig::default();
672        let at = AdaptiveTiering::new(idx, config);
673
674        let meta = make_store_meta(make_key(1, 0), Tier::Tier1, 0.5, 0, 10, 5);
675        // Neighbors exist but their tiers are not registered.
676        let neighbors = vec![(make_key(2, 0), 0.9), (make_key(3, 0), 0.8)];
677        let result = at.suggest_tier(&meta, &neighbors);
678        assert_eq!(result, None);
679    }
680
681    #[test]
682    fn suggest_tier_unanimous_vote() {
683        let idx = InMemoryPatternIndex::new();
684        let config = TierConfig::default();
685        let mut at = AdaptiveTiering::new(idx, config);
686
687        // Register three neighbors all in Tier3.
688        at.register_block(make_key(2, 0), Tier::Tier3);
689        at.register_block(make_key(3, 0), Tier::Tier3);
690        at.register_block(make_key(4, 0), Tier::Tier3);
691
692        let meta = make_store_meta(make_key(1, 0), Tier::Tier1, 0.5, 0, 10, 5);
693        let neighbors = vec![
694            (make_key(2, 0), 0.9),
695            (make_key(3, 0), 0.8),
696            (make_key(4, 0), 0.7),
697        ];
698
699        let result = at.suggest_tier(&meta, &neighbors);
700        assert_eq!(result, Some(Tier::Tier3));
701    }
702
703    #[test]
704    fn suggest_tier_same_as_current_returns_none() {
705        let idx = InMemoryPatternIndex::new();
706        let config = TierConfig::default();
707        let mut at = AdaptiveTiering::new(idx, config);
708
709        // Neighbors all in Tier1, same as the block.
710        at.register_block(make_key(2, 0), Tier::Tier1);
711        at.register_block(make_key(3, 0), Tier::Tier1);
712
713        let meta = make_store_meta(make_key(1, 0), Tier::Tier1, 0.5, 0, 10, 5);
714        let neighbors = vec![(make_key(2, 0), 0.9), (make_key(3, 0), 0.8)];
715
716        let result = at.suggest_tier(&meta, &neighbors);
717        assert_eq!(result, None);
718    }
719
720    #[test]
721    fn suggest_tier_weighted_majority() {
722        let idx = InMemoryPatternIndex::new();
723        let config = TierConfig::default();
724        let mut at = AdaptiveTiering::new(idx, config);
725
726        // Two neighbors in Tier1 with moderate similarity.
727        at.register_block(make_key(2, 0), Tier::Tier1);
728        at.register_block(make_key(3, 0), Tier::Tier1);
729        // One neighbor in Tier3 with very high similarity.
730        at.register_block(make_key(4, 0), Tier::Tier3);
731
732        let meta = make_store_meta(make_key(1, 0), Tier::Tier2, 0.5, 0, 10, 5);
733        let neighbors = vec![
734            (make_key(2, 0), 0.3),  // votes Tier1 with weight 0.3
735            (make_key(3, 0), 0.3),  // votes Tier1 with weight 0.3
736            (make_key(4, 0), 0.9),  // votes Tier3 with weight 0.9
737        ];
738        // Tier1 total = 0.6, Tier3 total = 0.9. Tier3 wins.
739        let result = at.suggest_tier(&meta, &neighbors);
740        assert_eq!(result, Some(Tier::Tier3));
741    }
742
743    #[test]
744    fn suggest_tier_negative_similarity_ignored() {
745        let idx = InMemoryPatternIndex::new();
746        let config = TierConfig::default();
747        let mut at = AdaptiveTiering::new(idx, config);
748
749        at.register_block(make_key(2, 0), Tier::Tier3);
750        at.register_block(make_key(3, 0), Tier::Tier1);
751
752        let meta = make_store_meta(make_key(1, 0), Tier::Tier2, 0.5, 0, 10, 5);
753        let neighbors = vec![
754            (make_key(2, 0), -0.5), // negative similarity, weight clamped to 0
755            (make_key(3, 0), 0.5),  // positive similarity, votes Tier1
756        ];
757        // Tier3 gets 0 weight (clamped), Tier1 gets 0.5. Tier1 wins.
758        let result = at.suggest_tier(&meta, &neighbors);
759        assert_eq!(result, Some(Tier::Tier1));
760    }
761
762    #[test]
763    fn suggest_tier_zero_similarity_all() {
764        let idx = InMemoryPatternIndex::new();
765        let config = TierConfig::default();
766        let mut at = AdaptiveTiering::new(idx, config);
767
768        at.register_block(make_key(2, 0), Tier::Tier3);
769
770        let meta = make_store_meta(make_key(1, 0), Tier::Tier1, 0.5, 0, 10, 5);
771        let neighbors = vec![(make_key(2, 0), 0.0)];
772
773        // Zero similarity means zero weight => total_weight == 0 => None.
774        let result = at.suggest_tier(&meta, &neighbors);
775        assert_eq!(result, None);
776    }
777
778    // -- Integration: pattern_from_meta + index + adaptive -----------------
779
780    #[test]
781    fn integration_end_to_end() {
782        let mut idx = InMemoryPatternIndex::new();
783        let config = TierConfig::default();
784
785        // Create several blocks with different access patterns.
786        let hot_key = make_key(1, 0);
787        let warm_key = make_key(2, 0);
788        let cold_key = make_key(3, 0);
789
790        let hot_meta =
791            make_store_meta(hot_key, Tier::Tier1, 0.9, u64::MAX, 1000, 2);
792        let warm_meta =
793            make_store_meta(warm_key, Tier::Tier2, 0.5, 0xFFFF_FFFF, 100, 10);
794        let cold_meta =
795            make_store_meta(cold_key, Tier::Tier3, 0.05, 0x0F, 5, 100);
796
797        // Build embeddings and insert into index.
798        let hot_emb = pattern_from_meta(&hot_meta);
799        let warm_emb = pattern_from_meta(&warm_meta);
800        let cold_emb = pattern_from_meta(&cold_meta);
801
802        idx.insert(&PatternVector {
803            key: hot_key,
804            embedding: hot_emb.clone(),
805            score: 0.9,
806        });
807        idx.insert(&PatternVector {
808            key: warm_key,
809            embedding: warm_emb.clone(),
810            score: 0.5,
811        });
812        idx.insert(&PatternVector {
813            key: cold_key,
814            embedding: cold_emb.clone(),
815            score: 0.1,
816        });
817
818        let mut at = AdaptiveTiering::new(idx, config);
819        at.register_block(hot_key, Tier::Tier1);
820        at.register_block(warm_key, Tier::Tier2);
821        at.register_block(cold_key, Tier::Tier3);
822
823        // Query: a new block with a hot-like pattern.
824        let new_key = make_key(4, 0);
825        let new_meta =
826            make_store_meta(new_key, Tier::Tier3, 0.85, u64::MAX, 800, 3);
827        let new_emb = pattern_from_meta(&new_meta);
828
829        let neighbors = at.index.search_nearest(&new_emb, 3);
830        assert!(!neighbors.is_empty());
831
832        let suggestion = at.suggest_tier(&new_meta, &neighbors);
833        // The new block's pattern is closest to the hot block, so
834        // the suggestion should be to promote it (away from Tier3).
835        assert!(
836            suggestion.is_some(),
837            "expected a tier suggestion for a hot-like pattern in Tier3"
838        );
839        let suggested = suggestion.unwrap();
840        assert_ne!(suggested, Tier::Tier3, "should not stay cold");
841    }
842}