Skip to main content

lattice_embed/simd/
binary.rs

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