Skip to main content

clawft_core/embeddings/
quantization.rs

1//! Temperature-based quantization for vector storage (H2.7).
2//!
3//! Provides storage-layer quantization that reduces disk/memory usage
4//! for less frequently accessed vectors without rebuilding the HNSW index.
5//!
6//! # Temperature tiers
7//!
8//! | Tier | Storage             | Access pattern     |
9//! |------|---------------------|--------------------|
10//! | Hot  | Full `Vec<f32>`     | Frequently accessed |
11//! | Warm | fp16 on disk        | Moderate access     |
12//! | Cold | Product-quantized   | Rarely accessed     |
13//!
14//! The HNSW index always uses full-precision (`f32`) vectors. Quantization
15//! applies only to the storage/serialization layer. When a warm or cold
16//! vector is accessed, it is decompressed to `f32` transparently.
17//!
18//! Tier transitions are driven by access frequency and recency.
19//!
20//! This module is gated behind the `rvf` feature flag.
21
22use serde::{Deserialize, Serialize};
23
24// ── Temperature tier ────────────────────────────────────────────────
25
26/// Temperature tier for a stored vector.
27#[non_exhaustive]
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29pub enum Temperature {
30    /// Full-precision `f32` in memory and on disk.
31    Hot,
32    /// fp16 on disk, decompressed to `f32` on access.
33    Warm,
34    /// Product-quantized on disk, decompressed to `f32` on access.
35    Cold,
36}
37
38impl std::fmt::Display for Temperature {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        match self {
41            Temperature::Hot => write!(f, "hot"),
42            Temperature::Warm => write!(f, "warm"),
43            Temperature::Cold => write!(f, "cold"),
44        }
45    }
46}
47
48// ── fp16 conversion ─────────────────────────────────────────────────
49
50/// Convert an `f32` to IEEE 754 half-precision (fp16) stored as `u16`.
51///
52/// Uses a simplified conversion that handles normal numbers, zero,
53/// and clamps denormals/overflows. Not suitable for NaN preservation.
54pub fn f32_to_fp16(value: f32) -> u16 {
55    let bits = value.to_bits();
56    let sign = (bits >> 16) & 0x8000;
57    let exponent = ((bits >> 23) & 0xFF) as i32;
58    let mantissa = bits & 0x007F_FFFF;
59
60    if exponent == 0 {
61        // Zero or denormal -> fp16 zero.
62        return sign as u16;
63    }
64
65    if exponent == 0xFF {
66        // Inf or NaN -> fp16 inf.
67        return (sign | 0x7C00) as u16;
68    }
69
70    let new_exp = exponent - 127 + 15;
71
72    if new_exp >= 31 {
73        // Overflow -> fp16 inf.
74        return (sign | 0x7C00) as u16;
75    }
76
77    if new_exp <= 0 {
78        // Underflow -> fp16 zero.
79        return sign as u16;
80    }
81
82    let new_mantissa = mantissa >> 13;
83    (sign | ((new_exp as u32) << 10) | new_mantissa) as u16
84}
85
86/// Convert an fp16 (`u16`) back to `f32`.
87pub fn fp16_to_f32(half: u16) -> f32 {
88    let sign = ((half as u32) & 0x8000) << 16;
89    let exponent = ((half >> 10) & 0x1F) as u32;
90    let mantissa = (half & 0x03FF) as u32;
91
92    if exponent == 0 {
93        if mantissa == 0 {
94            // Zero.
95            return f32::from_bits(sign);
96        }
97        // Denormal fp16 -> normalize to f32.
98        let mut e = 1i32;
99        let mut m = mantissa;
100        while (m & 0x0400) == 0 {
101            m <<= 1;
102            e -= 1;
103        }
104        m &= 0x03FF;
105        let f32_exp = ((127 - 15 + e) as u32) << 23;
106        let f32_mantissa = m << 13;
107        return f32::from_bits(sign | f32_exp | f32_mantissa);
108    }
109
110    if exponent == 31 {
111        // Inf or NaN.
112        return f32::from_bits(sign | 0x7F80_0000 | (mantissa << 13));
113    }
114
115    let f32_exp = (exponent + 127 - 15) << 23;
116    let f32_mantissa = mantissa << 13;
117    f32::from_bits(sign | f32_exp | f32_mantissa)
118}
119
120// ── Product quantization (simplified) ───────────────────────────────
121
122/// Number of subvectors for product quantization.
123const PQ_SUBVECTORS: usize = 8;
124
125/// Codebook size per subvector (256 = 8-bit codes).
126const PQ_CODEBOOK_SIZE: usize = 256;
127
128/// A product-quantized vector: stores compact codes and a codebook
129/// reference for decompression.
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct PqVector {
132    /// Quantization codes (one per subvector).
133    pub codes: Vec<u8>,
134    /// Dimension of the original vector.
135    pub original_dim: usize,
136}
137
138/// A simple product quantization codebook.
139///
140/// Each subvector has 256 centroids. During encoding, each subvector
141/// is mapped to its nearest centroid. During decoding, the centroid
142/// values are concatenated to reconstruct the approximation.
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct PqCodebook {
145    /// Centroids indexed by [subvector_index][centroid_index].
146    /// Each centroid is a flat vector of subvector_dim floats.
147    pub centroids: Vec<Vec<Vec<f32>>>,
148    /// Number of subvectors.
149    pub num_subvectors: usize,
150    /// Dimension per subvector.
151    pub subvector_dim: usize,
152}
153
154impl PqCodebook {
155    /// Build a trivial codebook from a set of vectors.
156    ///
157    /// This is a simplified implementation that uses uniform quantization
158    /// (divides the range into 256 equal bins per dimension). A production
159    /// implementation would use k-means clustering.
160    pub fn build(vectors: &[Vec<f32>], dim: usize) -> Self {
161        let num_sub = PQ_SUBVECTORS.min(dim);
162        let sub_dim = dim / num_sub;
163        let remainder = dim % num_sub;
164
165        let mut centroids = Vec::with_capacity(num_sub);
166
167        for s in 0..num_sub {
168            let start = s * sub_dim + s.min(remainder);
169            let actual_sub_dim = sub_dim + if s < remainder { 1 } else { 0 };
170
171            // Find min/max per dimension in this subvector.
172            let mut mins = vec![f32::MAX; actual_sub_dim];
173            let mut maxs = vec![f32::MIN; actual_sub_dim];
174
175            for vec in vectors {
176                for d in 0..actual_sub_dim {
177                    if start + d < vec.len() {
178                        let v = vec[start + d];
179                        if v < mins[d] {
180                            mins[d] = v;
181                        }
182                        if v > maxs[d] {
183                            maxs[d] = v;
184                        }
185                    }
186                }
187            }
188
189            // Build 256 uniformly spaced centroids.
190            let mut sub_centroids = Vec::with_capacity(PQ_CODEBOOK_SIZE);
191            for c in 0..PQ_CODEBOOK_SIZE {
192                let t = c as f32 / (PQ_CODEBOOK_SIZE - 1).max(1) as f32;
193                let centroid: Vec<f32> = (0..actual_sub_dim)
194                    .map(|d| mins[d] + t * (maxs[d] - mins[d]))
195                    .collect();
196                sub_centroids.push(centroid);
197            }
198            centroids.push(sub_centroids);
199        }
200
201        PqCodebook {
202            centroids,
203            num_subvectors: num_sub,
204            subvector_dim: sub_dim,
205        }
206    }
207
208    /// Encode a vector into PQ codes.
209    pub fn encode(&self, vector: &[f32]) -> PqVector {
210        let dim = vector.len();
211        let remainder = dim % self.num_subvectors;
212        let mut codes = Vec::with_capacity(self.num_subvectors);
213
214        for s in 0..self.num_subvectors {
215            let start = s * self.subvector_dim + s.min(remainder);
216            let actual_sub_dim = self.subvector_dim
217                + if s < remainder { 1 } else { 0 };
218            let end = (start + actual_sub_dim).min(dim);
219
220            let sub = &vector[start..end];
221
222            // Find nearest centroid.
223            let mut best_code: u8 = 0;
224            let mut best_dist = f32::MAX;
225
226            for (c, centroid) in self.centroids[s].iter().enumerate() {
227                let dist: f32 = sub
228                    .iter()
229                    .zip(centroid.iter())
230                    .map(|(a, b)| (a - b) * (a - b))
231                    .sum();
232                if dist < best_dist {
233                    best_dist = dist;
234                    best_code = c as u8;
235                }
236            }
237            codes.push(best_code);
238        }
239
240        PqVector {
241            codes,
242            original_dim: dim,
243        }
244    }
245
246    /// Decode PQ codes back to an approximate vector.
247    pub fn decode(&self, pq: &PqVector) -> Vec<f32> {
248        let dim = pq.original_dim;
249        let mut result = Vec::with_capacity(dim);
250
251        for s in 0..self.num_subvectors {
252            let code = pq.codes[s] as usize;
253            let centroid = &self.centroids[s][code];
254            result.extend_from_slice(centroid);
255        }
256
257        result.truncate(dim);
258        result
259    }
260}
261
262// ── Quantized storage entry ─────────────────────────────────────────
263
264/// A vector stored with temperature-based quantization.
265#[non_exhaustive]
266#[derive(Debug, Clone, Serialize, Deserialize)]
267pub enum QuantizedVector {
268    /// Full-precision f32 vector (hot tier).
269    Hot(Vec<f32>),
270    /// fp16-compressed vector (warm tier).
271    Warm(Vec<u16>),
272    /// Product-quantized vector (cold tier).
273    Cold(PqVector),
274}
275
276impl QuantizedVector {
277    /// Decompress to full-precision f32, regardless of tier.
278    pub fn decompress(&self, codebook: Option<&PqCodebook>) -> Vec<f32> {
279        match self {
280            QuantizedVector::Hot(v) => v.clone(),
281            QuantizedVector::Warm(fp16s) => {
282                fp16s.iter().map(|&h| fp16_to_f32(h)).collect()
283            }
284            QuantizedVector::Cold(pq) => {
285                if let Some(cb) = codebook {
286                    cb.decode(pq)
287                } else {
288                    // Cannot decode without codebook; return zeros.
289                    vec![0.0; pq.original_dim]
290                }
291            }
292        }
293    }
294
295    /// Return the temperature tier.
296    pub fn temperature(&self) -> Temperature {
297        match self {
298            QuantizedVector::Hot(_) => Temperature::Hot,
299            QuantizedVector::Warm(_) => Temperature::Warm,
300            QuantizedVector::Cold(_) => Temperature::Cold,
301        }
302    }
303
304    /// Compress a full-precision vector to the specified tier.
305    pub fn compress(
306        vector: &[f32],
307        tier: Temperature,
308        codebook: Option<&PqCodebook>,
309    ) -> Self {
310        match tier {
311            Temperature::Hot => QuantizedVector::Hot(vector.to_vec()),
312            Temperature::Warm => {
313                let fp16s: Vec<u16> =
314                    vector.iter().map(|&v| f32_to_fp16(v)).collect();
315                QuantizedVector::Warm(fp16s)
316            }
317            Temperature::Cold => {
318                if let Some(cb) = codebook {
319                    QuantizedVector::Cold(cb.encode(vector))
320                } else {
321                    // Fall back to warm if no codebook.
322                    let fp16s: Vec<u16> =
323                        vector.iter().map(|&v| f32_to_fp16(v)).collect();
324                    QuantizedVector::Warm(fp16s)
325                }
326            }
327        }
328    }
329}
330
331// ── Access tracker ──────────────────────────────────────────────────
332
333/// Tracks access patterns for temperature-based tier transitions.
334#[derive(Debug, Clone, Serialize, Deserialize)]
335pub struct AccessTracker {
336    /// Total number of accesses.
337    pub access_count: u64,
338    /// Unix timestamp of the last access (seconds since epoch).
339    pub last_access: u64,
340    /// Current temperature tier.
341    pub temperature: Temperature,
342}
343
344impl AccessTracker {
345    /// Create a new tracker for a hot entry.
346    pub fn new() -> Self {
347        Self {
348            access_count: 0,
349            last_access: now_secs(),
350            temperature: Temperature::Hot,
351        }
352    }
353
354    /// Record an access.
355    pub fn record_access(&mut self) {
356        self.access_count += 1;
357        self.last_access = now_secs();
358    }
359
360    /// Determine the recommended tier based on access patterns.
361    ///
362    /// - Hot: accessed within the last hour or > 10 accesses
363    /// - Warm: accessed within the last day or > 3 accesses
364    /// - Cold: everything else
365    pub fn recommended_tier(&self) -> Temperature {
366        let now = now_secs();
367        let age_secs = now.saturating_sub(self.last_access);
368
369        if age_secs < 3600 || self.access_count > 10 {
370            Temperature::Hot
371        } else if age_secs < 86400 || self.access_count > 3 {
372            Temperature::Warm
373        } else {
374            Temperature::Cold
375        }
376    }
377}
378
379impl Default for AccessTracker {
380    fn default() -> Self {
381        Self::new()
382    }
383}
384
385fn now_secs() -> u64 {
386    std::time::SystemTime::now()
387        .duration_since(std::time::UNIX_EPOCH)
388        .unwrap_or_default()
389        .as_secs()
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395
396    // ── fp16 conversion tests ────────────────────────────────────
397
398    #[test]
399    fn fp16_roundtrip_zero() {
400        let h = f32_to_fp16(0.0);
401        let v = fp16_to_f32(h);
402        assert!((v - 0.0).abs() < f32::EPSILON);
403    }
404
405    #[test]
406    fn fp16_roundtrip_one() {
407        let h = f32_to_fp16(1.0);
408        let v = fp16_to_f32(h);
409        assert!((v - 1.0).abs() < 0.001);
410    }
411
412    #[test]
413    fn fp16_roundtrip_negative() {
414        let h = f32_to_fp16(-0.5);
415        let v = fp16_to_f32(h);
416        assert!((v - (-0.5)).abs() < 0.001);
417    }
418
419    #[test]
420    fn fp16_roundtrip_small_value() {
421        let h = f32_to_fp16(0.1);
422        let v = fp16_to_f32(h);
423        assert!((v - 0.1).abs() < 0.01);
424    }
425
426    #[test]
427    fn fp16_overflow_becomes_inf() {
428        let h = f32_to_fp16(100_000.0);
429        let v = fp16_to_f32(h);
430        assert!(v.is_infinite());
431    }
432
433    // ── Quantized vector tests ───────────────────────────────────
434
435    #[test]
436    fn hot_decompress_is_identity() {
437        let v = vec![1.0, 2.0, 3.0];
438        let qv = QuantizedVector::Hot(v.clone());
439        assert_eq!(qv.decompress(None), v);
440        assert_eq!(qv.temperature(), Temperature::Hot);
441    }
442
443    #[test]
444    fn warm_decompress_approximate() {
445        let v = vec![0.5, -0.3, 0.7];
446        let qv = QuantizedVector::compress(&v, Temperature::Warm, None);
447        let decompressed = qv.decompress(None);
448
449        assert_eq!(qv.temperature(), Temperature::Warm);
450        assert_eq!(decompressed.len(), v.len());
451
452        for (orig, dec) in v.iter().zip(decompressed.iter()) {
453            assert!(
454                (orig - dec).abs() < 0.01,
455                "fp16 roundtrip error too large: {orig} vs {dec}"
456            );
457        }
458    }
459
460    #[test]
461    fn cold_with_codebook_roundtrip() {
462        let vectors = vec![
463            vec![1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
464            vec![0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
465            vec![0.5, 0.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
466        ];
467        let codebook = PqCodebook::build(&vectors, 8);
468
469        let qv = QuantizedVector::compress(
470            &vectors[0],
471            Temperature::Cold,
472            Some(&codebook),
473        );
474        assert_eq!(qv.temperature(), Temperature::Cold);
475
476        let decompressed = qv.decompress(Some(&codebook));
477        assert_eq!(decompressed.len(), 8);
478
479        // PQ is lossy but should preserve rough structure.
480        assert!(decompressed[0] > decompressed[1]);
481    }
482
483    #[test]
484    fn cold_without_codebook_fallback() {
485        let v = vec![1.0, 2.0, 3.0];
486        let qv = QuantizedVector::compress(&v, Temperature::Cold, None);
487        // Should fall back to Warm.
488        assert_eq!(qv.temperature(), Temperature::Warm);
489    }
490
491    // ── Access tracker tests ─────────────────────────────────────
492
493    #[test]
494    fn new_tracker_is_hot() {
495        let tracker = AccessTracker::new();
496        assert_eq!(tracker.temperature, Temperature::Hot);
497        assert_eq!(tracker.access_count, 0);
498    }
499
500    #[test]
501    fn record_access_increments_count() {
502        let mut tracker = AccessTracker::new();
503        tracker.record_access();
504        tracker.record_access();
505        assert_eq!(tracker.access_count, 2);
506    }
507
508    #[test]
509    fn recent_access_stays_hot() {
510        let tracker = AccessTracker {
511            access_count: 1,
512            last_access: now_secs(),
513            temperature: Temperature::Hot,
514        };
515        assert_eq!(tracker.recommended_tier(), Temperature::Hot);
516    }
517
518    #[test]
519    fn many_accesses_stays_hot() {
520        let tracker = AccessTracker {
521            access_count: 15,
522            last_access: now_secs() - 7200, // 2 hours ago.
523            temperature: Temperature::Hot,
524        };
525        assert_eq!(tracker.recommended_tier(), Temperature::Hot);
526    }
527
528    #[test]
529    fn moderate_access_becomes_warm() {
530        let tracker = AccessTracker {
531            access_count: 5,
532            last_access: now_secs() - 7200, // 2 hours ago.
533            temperature: Temperature::Hot,
534        };
535        assert_eq!(tracker.recommended_tier(), Temperature::Warm);
536    }
537
538    #[test]
539    fn old_low_access_becomes_cold() {
540        let tracker = AccessTracker {
541            access_count: 1,
542            last_access: now_secs() - 200_000, // > 2 days ago.
543            temperature: Temperature::Hot,
544        };
545        assert_eq!(tracker.recommended_tier(), Temperature::Cold);
546    }
547
548    // ── Temperature display ──────────────────────────────────────
549
550    #[test]
551    fn temperature_display() {
552        assert_eq!(format!("{}", Temperature::Hot), "hot");
553        assert_eq!(format!("{}", Temperature::Warm), "warm");
554        assert_eq!(format!("{}", Temperature::Cold), "cold");
555    }
556
557    // ── Codebook tests ───────────────────────────────────────────
558
559    #[test]
560    fn codebook_build_creates_centroids() {
561        let vectors = vec![
562            vec![1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
563            vec![0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
564        ];
565        let codebook = PqCodebook::build(&vectors, 8);
566        assert_eq!(codebook.num_subvectors, PQ_SUBVECTORS);
567        assert_eq!(codebook.centroids.len(), PQ_SUBVECTORS);
568        for sub in &codebook.centroids {
569            assert_eq!(sub.len(), PQ_CODEBOOK_SIZE);
570        }
571    }
572
573    #[test]
574    fn codebook_encode_produces_correct_code_count() {
575        let vectors = vec![vec![1.0; 8], vec![0.0; 8]];
576        let codebook = PqCodebook::build(&vectors, 8);
577        let pq = codebook.encode(&[0.5; 8]);
578        assert_eq!(pq.codes.len(), PQ_SUBVECTORS);
579        assert_eq!(pq.original_dim, 8);
580    }
581
582    #[test]
583    fn codebook_decode_correct_length() {
584        let vectors = vec![vec![1.0; 8], vec![0.0; 8]];
585        let codebook = PqCodebook::build(&vectors, 8);
586        let pq = codebook.encode(&[0.5; 8]);
587        let decoded = codebook.decode(&pq);
588        assert_eq!(decoded.len(), 8);
589    }
590
591    #[test]
592    fn default_access_tracker() {
593        let tracker = AccessTracker::default();
594        assert_eq!(tracker.access_count, 0);
595    }
596}