Skip to main content

lattice_embed/simd/
binary.rs

1//! Binary quantization for ultra-fast pre-filtering.
2//!
3//! Sign-bit quantization: 1 bit per dimension (32x compression vs f32).
4//! Distance via Hamming distance using hardware popcount.
5//!
6//! ## Format
7//!
8//! `b[i] = 1 if v[i] >= 0 else 0`. Pack 8 dimensions into one byte
9//! (bit 7 = dimension 0, bit 6 = dimension 1, etc.).
10//! For D dimensions, storage is `ceil(D / 8)` bytes.
11//!
12//! ## Distance
13//!
14//! Hamming distance counts differing bits between two binary vectors.
15//! Approximate cosine distance: `1.0 - (1.0 - 2.0 * hamming / dims)`
16//! i.e. `2.0 * hamming / dims`.
17
18#[cfg(target_arch = "aarch64")]
19use std::arch::aarch64::*;
20
21use super::simd_config;
22
23/// **Unstable**: binary quantization format and struct layout are under active design.
24///
25/// Binary quantized vector with packed bit storage.
26#[derive(Debug, Clone)]
27pub struct BinaryVector {
28    /// **Unstable**: packed bit data; bit layout may change with format revision.
29    pub data: Vec<u8>,
30    /// **Unstable**: number of original dimensions.
31    pub dims: usize,
32    /// **Unstable**: L2 norm of the original float vector; field may be removed.
33    pub norm: f32,
34}
35
36impl BinaryVector {
37    /// **Unstable**: quantization API; threshold default may change.
38    ///
39    /// Values >= threshold map to 1, values < threshold map to 0.
40    /// Default threshold is 0.0 (sign bit).
41    pub fn from_f32(vector: &[f32]) -> Self {
42        Self::from_f32_with_threshold(vector, 0.0)
43    }
44
45    /// **Unstable**: custom-threshold variant; may be merged into a config struct.
46    pub fn from_f32_with_threshold(vector: &[f32], threshold: f32) -> Self {
47        let dims = vector.len();
48
49        // Compute norm
50        let mut norm_sq = 0.0f32;
51        for &v in vector {
52            if v.is_finite() {
53                norm_sq += v * v;
54            }
55        }
56        let norm = norm_sq.sqrt();
57
58        let packed_len = dims.div_ceil(8);
59        let mut data = vec![0u8; packed_len];
60
61        for (i, &v) in vector.iter().enumerate() {
62            let val = if v.is_finite() { v } else { 0.0 };
63            if val >= threshold {
64                let byte_idx = i / 8;
65                let bit_idx = 7 - (i % 8); // bit 7 = first dimension in byte
66                data[byte_idx] |= 1 << bit_idx;
67            }
68        }
69
70        Self { data, dims, norm }
71    }
72
73    /// **Unstable**: dequantize to float32; output semantics may change.
74    ///
75    /// Binary quantization is lossy: 1 -> +1.0, 0 -> -1.0.
76    ///
77    /// Returns an empty `Vec` if the packed buffer is shorter than `dims.div_ceil(8)`
78    /// bytes — i.e. the vector was constructed with mismatched fields.
79    pub fn to_f32(&self) -> Vec<f32> {
80        let required_bytes = self.dims.div_ceil(8);
81        if self.data.len() < required_bytes {
82            return Vec::new();
83        }
84        let mut result = Vec::with_capacity(self.dims);
85        for i in 0..self.dims {
86            let byte_idx = i / 8;
87            let bit_idx = 7 - (i % 8);
88            let bit = (self.data[byte_idx] >> bit_idx) & 1;
89            result.push(if bit == 1 { 1.0 } else { -1.0 });
90        }
91        result
92    }
93
94    /// **Unstable**: Hamming dispatch; delegates to NEON or scalar based on runtime detection.
95    ///
96    /// Returns the number of differing bits (dimensions with different signs).
97    #[inline]
98    pub fn hamming_distance(&self, other: &BinaryVector) -> u32 {
99        hamming_distance_binary(self, other)
100    }
101
102    /// **Unstable**: approximation formula may be revised; do not use in latency-sensitive production paths.
103    ///
104    /// The relationship between Hamming distance and angular distance:
105    /// `cos_approx = 1.0 - 2.0 * hamming / dims`
106    /// `cosine_distance_approx = 2.0 * hamming / dims`
107    #[inline]
108    pub fn cosine_distance_approx(&self, other: &BinaryVector) -> f32 {
109        if self.dims == 0 {
110            return 0.0;
111        }
112        let hamming = self.hamming_distance(other) as f32;
113        2.0 * hamming / self.dims as f32
114    }
115
116    /// **Unstable**: approximation formula may be revised; complement of `cosine_distance_approx`.
117    #[inline]
118    pub fn cosine_similarity_approx(&self, other: &BinaryVector) -> f32 {
119        1.0 - self.cosine_distance_approx(other)
120    }
121}
122
123/// **Unstable**: free function dispatches to NEON or scalar; may become private in a future cleanup.
124///
125/// Uses popcount on XOR of the packed bytes.
126///
127/// Returns `u32::MAX` if dimensions differ or if either packed buffer is shorter than
128/// `dims.div_ceil(8)` bytes (malformed public-field construction).
129#[inline]
130pub fn hamming_distance_binary(a: &BinaryVector, b: &BinaryVector) -> u32 {
131    if a.dims != b.dims {
132        return u32::MAX;
133    }
134
135    let required_bytes = a.dims.div_ceil(8);
136    if a.data.len() < required_bytes || b.data.len() < required_bytes {
137        return u32::MAX;
138    }
139
140    let config = simd_config();
141
142    #[cfg(target_arch = "aarch64")]
143    {
144        if config.neon_enabled {
145            // SAFETY: NEON is available on aarch64. Both slices have been verified above
146            // to contain at least `required_bytes` elements, which is the exact packed
147            // length for `dims` bits. The callee uses unaligned loads and chunk/remainder
148            // bounds strictly within those slices; no out-of-bounds read is possible.
149            return unsafe {
150                hamming_distance_neon(&a.data[..required_bytes], &b.data[..required_bytes], a.dims)
151            };
152        }
153    }
154
155    #[cfg(not(target_arch = "aarch64"))]
156    {
157        let _ = config;
158    }
159
160    hamming_distance_scalar(&a.data[..required_bytes], &b.data[..required_bytes], a.dims)
161}
162
163/// Scalar Hamming distance using u64 chunks and count_ones().
164///
165/// `dims` is the true dimension count; the slice `a`/`b` has length `dims.div_ceil(8)`.
166/// When `dims % 8 != 0` the final byte contains `8 - (dims % 8)` padding bits that must
167/// not be counted. Bit 7 (MSB) holds the first dimension of each byte (see
168/// `from_f32_with_threshold`), so the `r = dims % 8` valid bits are the top `r` bits:
169/// mask = `0xFF << (8 - r)`.
170fn hamming_distance_scalar(a: &[u8], b: &[u8], dims: usize) -> u32 {
171    let mut total: u32 = 0;
172
173    // Number of fully-populated 8-byte chunks (all bits valid).
174    let full_bytes = dims / 8; // bytes where every bit is a real dimension
175    let chunks = full_bytes / 8;
176
177    // Process 8 bytes at a time as u64
178    for c in 0..chunks {
179        let offset = c * 8;
180        let a_u64 = u64::from_ne_bytes([
181            a[offset],
182            a[offset + 1],
183            a[offset + 2],
184            a[offset + 3],
185            a[offset + 4],
186            a[offset + 5],
187            a[offset + 6],
188            a[offset + 7],
189        ]);
190        let b_u64 = u64::from_ne_bytes([
191            b[offset],
192            b[offset + 1],
193            b[offset + 2],
194            b[offset + 3],
195            b[offset + 4],
196            b[offset + 5],
197            b[offset + 6],
198            b[offset + 7],
199        ]);
200        total += (a_u64 ^ b_u64).count_ones();
201    }
202
203    // Handle the remaining full bytes (between the last u64 chunk and the partial byte)
204    let remainder_start = chunks * 8;
205    for i in remainder_start..full_bytes {
206        total += (a[i] ^ b[i]).count_ones();
207    }
208
209    // Handle the single partial byte (if dims is not a multiple of 8).
210    // The `r` valid bits occupy the top `r` bits of the byte (MSB = dim 0 within byte).
211    let r = dims % 8;
212    if r != 0 {
213        let mask = 0xFFu8 << (8 - r); // top `r` bits set, bottom `(8-r)` clear
214        total += ((a[full_bytes] ^ b[full_bytes]) & mask).count_ones();
215    }
216
217    total
218}
219
220/// NEON Hamming distance using `vcnt` (per-byte population count).
221///
222/// `vcnt` counts set bits in each byte of a NEON register.
223/// We XOR the two vectors, apply vcnt, then horizontally sum.
224///
225/// `dims` is the true dimension count. When `dims % 8 != 0`, the final byte holds
226/// padding bits in its low bits; only the top `dims % 8` bits (MSB = first dim) are
227/// counted, matching the scalar path's masking logic.
228///
229/// # Safety
230///
231/// Caller must ensure running on aarch64 (NEON is mandatory).
232/// `a` and `b` must have equal length == `dims.div_ceil(8)`.
233#[cfg(target_arch = "aarch64")]
234#[inline]
235unsafe fn hamming_distance_neon(a: &[u8], b: &[u8], dims: usize) -> u32 {
236    // SA-163/164: verify equal-length backing slices before the SIMD loop.
237    debug_assert_eq!(
238        a.len(),
239        b.len(),
240        "hamming_distance_neon: slice lengths differ ({} vs {})",
241        a.len(),
242        b.len()
243    );
244
245    // Only full bytes (where every bit is a real dimension) go through the SIMD loop.
246    let full_bytes = dims / 8;
247    const SIMD_WIDTH: usize = 16;
248    let chunks = full_bytes / SIMD_WIDTH;
249
250    // Accumulate popcount bytes into u16 to avoid overflow
251    // (max 8 bits per byte, 16 bytes per register = 128 per chunk, fits u8 for ~1 chunk)
252    // Use vpaddlq to widen: u8 -> u16 -> u32 -> u64
253    let mut sum_u64 = vdupq_n_u64(0);
254
255    for c in 0..chunks {
256        let base = c * SIMD_WIDTH;
257        let va = vld1q_u8(a.as_ptr().add(base));
258        let vb = vld1q_u8(b.as_ptr().add(base));
259
260        // XOR to find differing bits
261        let xor = veorq_u8(va, vb);
262
263        // Population count per byte
264        let popcnt = vcntq_u8(xor);
265
266        // Widen and accumulate: u8 -> u16 -> u32 -> u64
267        let sum_u16 = vpaddlq_u8(popcnt);
268        let sum_u32 = vpaddlq_u16(sum_u16);
269        sum_u64 = vaddq_u64(sum_u64, vpaddlq_u32(sum_u32));
270    }
271
272    // Extract final sum
273    let total = vgetq_lane_u64(sum_u64, 0) + vgetq_lane_u64(sum_u64, 1);
274    let mut result = total as u32;
275
276    // Handle remaining full bytes (between last SIMD chunk and the partial byte)
277    let remainder_start = chunks * SIMD_WIDTH;
278    for i in remainder_start..full_bytes {
279        result += (a[i] ^ b[i]).count_ones();
280    }
281
282    // Handle the single partial byte (if dims is not a multiple of 8).
283    // Top `r` bits are real dimensions; bottom `(8 - r)` bits are padding.
284    let r = dims % 8;
285    if r != 0 {
286        let mask = 0xFFu8 << (8 - r); // top `r` bits set, bottom `(8-r)` clear
287        result += ((a[full_bytes] ^ b[full_bytes]) & mask).count_ones();
288    }
289
290    result
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    fn generate_vector(dim: usize, seed: u64) -> Vec<f32> {
298        let mut state = seed ^ ((dim as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15));
299        (0..dim)
300            .map(|i| {
301                state = state
302                    .wrapping_mul(6364136223846793005)
303                    .wrapping_add(1442695040888963407)
304                    .wrapping_add(i as u64);
305                let unit = ((state >> 32) as u32) as f32 / u32::MAX as f32;
306                unit * 2.0 - 1.0
307            })
308            .collect()
309    }
310
311    #[test]
312    fn test_binary_quantize_basic() {
313        let v = vec![0.5, -0.3, 0.0, -1.0, 1.0, 0.1, -0.1, 0.9];
314        let bv = BinaryVector::from_f32(&v);
315        assert_eq!(bv.data.len(), 1); // 8 dims -> 1 byte
316        assert_eq!(bv.dims, 8);
317
318        // Expected bits (MSB first): 1, 0, 1, 0, 1, 1, 0, 1 = 0b10101101 = 0xAD
319        assert_eq!(bv.data[0], 0xAD, "packed bits: {:08b}", bv.data[0]);
320    }
321
322    #[test]
323    fn test_binary_roundtrip() {
324        let v = vec![0.5, -0.3, 0.0, -1.0, 1.0, 0.1, -0.1, 0.9];
325        let bv = BinaryVector::from_f32(&v);
326        let deq = bv.to_f32();
327
328        // Binary: positive -> +1.0, negative -> -1.0
329        assert_eq!(deq, vec![1.0, -1.0, 1.0, -1.0, 1.0, 1.0, -1.0, 1.0]);
330    }
331
332    #[test]
333    fn test_binary_hamming_distance() {
334        // Same vector should have 0 Hamming distance
335        let v = generate_vector(384, 42);
336        let bv = BinaryVector::from_f32(&v);
337        assert_eq!(bv.hamming_distance(&bv), 0);
338
339        // Opposite sign vector should have max Hamming distance
340        let neg_v: Vec<f32> = v.iter().map(|x| -x).collect();
341        let neg_bv = BinaryVector::from_f32(&neg_v);
342        // Some values might be exactly 0.0, which maps to +1 in both cases
343        // So Hamming may not be exactly 384
344        let hamming = bv.hamming_distance(&neg_bv);
345        // But it should be close to 384 for random vectors
346        assert!(hamming > 350, "hamming={hamming}, expected close to 384");
347    }
348
349    #[test]
350    fn test_binary_cosine_approx_identical() {
351        let v = generate_vector(384, 55);
352        let bv = BinaryVector::from_f32(&v);
353        let cos_dist = bv.cosine_distance_approx(&bv);
354        assert!(
355            cos_dist.abs() < 1e-5,
356            "Identical binary vectors should have 0 cosine distance, got {cos_dist}"
357        );
358    }
359
360    #[test]
361    fn test_binary_cosine_approx_quality() {
362        let a = generate_vector(384, 101);
363        let b = generate_vector(384, 202);
364
365        // f32 reference cosine
366        let dot: f32 = a.iter().zip(b.iter()).map(|(&x, &y)| x * y).sum();
367        let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
368        let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
369        let f32_cos = dot / (norm_a * norm_b);
370
371        let ba = BinaryVector::from_f32(&a);
372        let bb = BinaryVector::from_f32(&b);
373        let bin_cos = ba.cosine_similarity_approx(&bb);
374
375        // Binary is a rough approximation -- within 0.3 is acceptable for pre-filtering
376        assert!(
377            (f32_cos - bin_cos).abs() < 0.35,
378            "Binary cosine too far from f32: f32={f32_cos}, binary={bin_cos}"
379        );
380    }
381
382    #[test]
383    fn test_binary_memory_savings() {
384        let v = generate_vector(384, 999);
385        let bv = BinaryVector::from_f32(&v);
386
387        // f32: 384 * 4 = 1536 bytes
388        // Binary: ceil(384/8) = 48 bytes = 32x compression
389        assert_eq!(bv.data.len(), 48);
390    }
391
392    #[test]
393    fn test_binary_non_multiple_of_8_dims() {
394        // 385 dims -> ceil(385/8) = 49 bytes
395        let v = generate_vector(385, 77);
396        let bv = BinaryVector::from_f32(&v);
397        assert_eq!(bv.data.len(), 49);
398        assert_eq!(bv.dims, 385);
399
400        // Roundtrip should preserve all 385 values
401        let deq = bv.to_f32();
402        assert_eq!(deq.len(), 385);
403    }
404
405    #[test]
406    fn test_binary_with_threshold() {
407        let v = vec![0.5, 0.3, 0.1, -0.1, -0.3, -0.5, 0.7, 0.2];
408        // With threshold 0.25, only values >= 0.25 map to 1
409        let bv = BinaryVector::from_f32_with_threshold(&v, 0.25);
410        let deq = bv.to_f32();
411        // Expected: 1, 1, -1, -1, -1, -1, 1, -1
412        assert_eq!(deq, vec![1.0, 1.0, -1.0, -1.0, -1.0, -1.0, 1.0, -1.0]);
413    }
414
415    #[test]
416    fn test_binary_nan_inf_handling() {
417        let v = vec![
418            f32::NAN,
419            f32::INFINITY,
420            f32::NEG_INFINITY,
421            1.0,
422            -1.0,
423            0.0,
424            0.5,
425            -0.5,
426        ];
427        let bv = BinaryVector::from_f32(&v);
428        let deq = bv.to_f32();
429        assert_eq!(deq.len(), 8);
430        for &val in &deq {
431            assert!(val == 1.0 || val == -1.0, "Binary should produce +/-1.0");
432        }
433    }
434
435    #[test]
436    fn test_hamming_scalar_vs_neon_parity() {
437        // Generate two distinct binary vectors and verify both paths give same result
438        let a = generate_vector(384, 111);
439        let b = generate_vector(384, 222);
440        let ba = BinaryVector::from_f32(&a);
441        let bb = BinaryVector::from_f32(&b);
442
443        let scalar_result = hamming_distance_scalar(&ba.data, &bb.data, ba.dims);
444        let dispatch_result = ba.hamming_distance(&bb);
445
446        assert_eq!(
447            scalar_result, dispatch_result,
448            "Scalar and dispatched Hamming should match"
449        );
450    }
451
452    // --- Issue #211 regression tests -------------------------------------------------
453
454    #[test]
455    fn test_hamming_short_data_returns_max() {
456        // dims=128 requires 16 bytes; supply only 4 — must return u32::MAX, not OOB.
457        let a = BinaryVector {
458            dims: 128,
459            data: vec![0xFFu8; 4],
460            norm: 1.0,
461        };
462        let b = BinaryVector {
463            dims: 128,
464            data: vec![0x00u8; 4],
465            norm: 1.0,
466        };
467        assert_eq!(
468            hamming_distance_binary(&a, &b),
469            u32::MAX,
470            "Short data must yield u32::MAX, not an OOB read"
471        );
472    }
473
474    #[test]
475    fn test_hamming_one_side_short_returns_max() {
476        // a is correct length, b is too short.
477        let a = BinaryVector {
478            dims: 128,
479            data: vec![0xFFu8; 16],
480            norm: 1.0,
481        };
482        let b = BinaryVector {
483            dims: 128,
484            data: vec![0x00u8; 8],
485            norm: 1.0,
486        };
487        assert_eq!(hamming_distance_binary(&a, &b), u32::MAX);
488    }
489
490    #[test]
491    fn test_hamming_correct_data_still_works() {
492        // Verify the guard does not break the normal path.
493        let v = generate_vector(128, 42);
494        let bv = BinaryVector::from_f32(&v);
495        assert_eq!(bv.hamming_distance(&bv), 0);
496    }
497
498    #[test]
499    fn test_binary_to_f32_short_data_returns_empty() {
500        // dims=128 requires 16 bytes; supply only 4.
501        let bv = BinaryVector {
502            dims: 128,
503            data: vec![0xFFu8; 4],
504            norm: 1.0,
505        };
506        let result = bv.to_f32();
507        assert!(
508            result.is_empty(),
509            "to_f32 on malformed BinaryVector must return empty Vec"
510        );
511    }
512
513    #[test]
514    fn test_binary_to_f32_exact_length_works() {
515        // Exactly the right number of bytes — must succeed.
516        let v = generate_vector(128, 7);
517        let bv = BinaryVector::from_f32(&v);
518        let deq = bv.to_f32();
519        assert_eq!(deq.len(), 128);
520    }
521
522    // --- Issue #249 regression: padding-bit masking in Hamming distance -------------
523
524    /// Vectors with 12 dimensions use 2 bytes, with 4 padding bits in byte 1 (the low
525    /// nibble). Two vectors that agree on all 12 real dimensions but differ in their
526    /// padding bits must report Hamming distance 0, not 4.
527    ///
528    /// Before the fix, XOR-popcount over the raw final byte counted those 4 spurious
529    /// differing padding bits. After the fix, the mask `0xFF << (8 - 4) = 0xF0` zeroes
530    /// the low nibble before counting, giving the correct answer.
531    #[test]
532    fn test_hamming_ignores_padding_bits() {
533        // 12 dims → 2 bytes; the last 4 bits of byte 1 are padding.
534        // Build via pub fields so we can inject arbitrary padding.
535        let clean = BinaryVector {
536            dims: 12,
537            // byte 1: top nibble = 4 valid dims, low nibble = 0 (zero padding)
538            data: vec![0b10101010u8, 0b11110000u8],
539            norm: 1.0,
540        };
541        let dirty = BinaryVector {
542            dims: 12,
543            // same valid bits in top nibble of byte 1, non-zero garbage in low-nibble padding
544            data: vec![0b10101010u8, 0b11111111u8],
545            norm: 1.0,
546        };
547
548        // Both vectors agree on all 12 real dimensions; Hamming distance must be 0.
549        assert_eq!(
550            hamming_distance_scalar(&clean.data, &dirty.data, 12),
551            0,
552            "scalar: padding bits must not be counted"
553        );
554        assert_eq!(
555            clean.hamming_distance(&dirty),
556            0,
557            "dispatch: padding bits must not be counted"
558        );
559
560        // Cosine distance approximation must also be 0.0 for identical valid bits.
561        assert_eq!(
562            clean.cosine_distance_approx(&dirty),
563            0.0,
564            "cosine_distance_approx: padding bits must not be counted"
565        );
566    }
567
568    /// Verifies that the partial-byte mask counts real differing bits correctly.
569    ///
570    /// byte 0: 0b10101010 ^ 0b01010101 = 0b11111111 → 8 differing bits.
571    /// byte 1 (masked with 0xF0): (0b11110000 ^ 0b00000000) & 0xF0 = 0b11110000 → 4 bits.
572    /// Total: 12, which equals `dims` (every real dimension differs).
573    #[test]
574    fn test_hamming_partial_byte_count() {
575        let a = BinaryVector {
576            dims: 12,
577            data: vec![0b10101010u8, 0b11110000u8],
578            norm: 1.0,
579        };
580        let b = BinaryVector {
581            dims: 12,
582            data: vec![0b01010101u8, 0b00000000u8],
583            norm: 1.0,
584        };
585
586        assert_eq!(hamming_distance_scalar(&a.data, &b.data, 12), 12);
587        assert_eq!(a.hamming_distance(&b), 12);
588    }
589}