Skip to main content

zeppelin_embed/quant/
bits4.rs

1//! Four-bit Extended-RaBitQ row encoding.
2//!
3//! The conceptual codebook is the normalized half-integer grid
4//! `{-7.5, -6.5, ..., -0.5, 0.5, ..., 6.5, 7.5}^d`. It is fixed by the bit
5//! width: there is no learned codebook, training set, or first-query setup.
6//! Random rotation is optional in Extended-RaBitQ and is off by default in v1;
7//! the recall harness must validate that identity-rotation assumption for each
8//! embedding model.
9//!
10//! For input `v`, Algorithm 1 selects `y` maximizing `<y,v>/||y||`. At scale
11//! zero each signed magnitude is `0.5`. Coordinate `i` then crosses its seven
12//! rounding boundaries at `t = level / |v_i|`, `level=1..=7`. Sorting all
13//! critical values, applying equal thresholds as one group, and retaining the
14//! best normalized projection enumerates every realizable rounded direction
15//! exactly in `O(2^B d log d)` time.
16//!
17//! The stored RaBitQ correction is `c = ||v||^2 / <y,v>`. The paper writes the
18//! estimator with normalized `y`; because `||y||` occurs in numerator and
19//! denominator it cancels, leaving `c * <y,q_hat>`. The companion norm is kept
20//! for reconstruction/error bounds and future distance bookkeeping.
21
22use crate::kernels::{MAX_DOT_I8_DIMENSION, dot_bit4_prepared, score_bit4_prepared_batch};
23
24use super::QuantError;
25
26const CODES_PER_BYTE: usize = 2;
27const MAX_MAGNITUDE_LEVEL: u8 = 7;
28
29/// Per-row scalars required by the four-bit unbiased dot estimator.
30#[derive(Clone, Copy, Debug, PartialEq)]
31#[repr(C)]
32pub struct Bit4Factors {
33    scale: f32,
34    normalized_norm: f32,
35    normalized_correction: f32,
36}
37
38impl Bit4Factors {
39    /// Reconstructs the permanent three-f32 persisted factor record.
40    #[must_use]
41    pub const fn from_persisted(
42        scale: f32,
43        normalized_norm: f32,
44        normalized_correction: f32,
45    ) -> Self {
46        Self {
47            scale,
48            normalized_norm,
49            normalized_correction,
50        }
51    }
52
53    /// Returns the permanent little-endian record fields in declaration order.
54    #[must_use]
55    pub const fn persisted_fields(self) -> [f32; 3] {
56        [self.scale, self.normalized_norm, self.normalized_correction]
57    }
58
59    /// Returns the original row's Euclidean norm.
60    ///
61    /// A finite `f32` row scale and finite normalized `f32` norm are expanded
62    /// in `f64`, so the logical norm remains usable above `f32::MAX` without
63    /// enlarging the three-scalar factor record.
64    #[must_use]
65    pub const fn norm(self) -> f64 {
66        self.scale as f64 * self.normalized_norm as f64
67    }
68
69    /// Returns `||v||^2 / <y, v>` for the selected grid direction.
70    ///
71    /// The stored normalized correction is multiplied by the finite row scale
72    /// in `f64`, avoiding overflow in persisted metadata.
73    #[must_use]
74    pub const fn correction(self) -> f64 {
75        self.scale as f64 * self.normalized_correction as f64
76    }
77
78    /// Returns the L2 error bound for norm-scaled code reconstruction.
79    ///
80    /// Every selected grid coordinate is sign-aligned with the input, hence
81    /// the direction cosine is non-negative and the deterministic bound is
82    /// `sqrt(2) ||v||`. The exact grid search normally gives a tighter error;
83    /// retaining this conservative bound avoids a fourth per-row scalar.
84    #[must_use]
85    pub fn reconstruction_error_bound(self) -> f64 {
86        std::f64::consts::SQRT_2 * self.norm()
87    }
88
89    pub(crate) const fn scoring_parts(self) -> (f32, f32) {
90        (self.scale, self.normalized_correction)
91    }
92}
93
94/// Query-side signed-byte representation prepared once and reused per row.
95///
96/// Coordinates are symmetrically quantized to `[-127, 127]` with seeded
97/// stochastic rounding, so their reconstruction is unbiased in expectation.
98/// The private kernel representation groups even then odd coordinates within
99/// each 32-value block, matching the packed row's high/low nibble vectors.
100#[derive(Clone, Debug, PartialEq)]
101pub struct Bit4Query {
102    /// Even coordinates followed by odd coordinates in each 32-value block.
103    codes: Vec<i8>,
104    code_sum: i32,
105    scale_half: f64,
106}
107
108impl Bit4Query {
109    /// Returns the query coordinate count.
110    #[must_use]
111    pub fn len(&self) -> usize {
112        self.codes.len()
113    }
114
115    /// Returns whether the query has zero coordinates.
116    #[must_use]
117    pub fn is_empty(&self) -> bool {
118        self.codes.is_empty()
119    }
120
121    pub(crate) fn resident_bytes(&self) -> usize {
122        self.codes.capacity()
123    }
124
125    /// Returns the prepared code bytes, pre-interleave sum, and half-scale
126    /// exactly as consumed by the scoring kernel.
127    #[cfg(any(test, feature = "test-support"))]
128    #[doc(hidden)]
129    #[must_use]
130    pub fn observation_parts(&self) -> (&[i8], i32, f64) {
131        (&self.codes, self.code_sum, self.scale_half)
132    }
133
134    pub(crate) fn kernel_parts(&self) -> (&[i8], i32, f64) {
135        (&self.codes, self.code_sum, self.scale_half)
136    }
137}
138
139#[derive(Clone, Copy, Debug)]
140struct SplitMix64 {
141    state: u64,
142}
143
144impl SplitMix64 {
145    const fn new(seed: u64) -> Self {
146        Self { state: seed }
147    }
148
149    fn next_open_unit_f64(&mut self) -> f64 {
150        self.state = self.state.wrapping_add(0x9e37_79b9_7f4a_7c15);
151        let mut value = self.state;
152        value = (value ^ (value >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
153        value = (value ^ (value >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
154        value ^= value >> 31;
155        ((value >> 11) as f64 + 0.5) * (1.0 / 9_007_199_254_740_992.0)
156    }
157}
158
159#[derive(Clone, Copy, Debug)]
160struct CriticalValue {
161    threshold: f64,
162    coordinate: usize,
163    level: u8,
164    magnitude: f64,
165}
166
167/// Quantizes one row to four-bit Extended-RaBitQ codes.
168///
169/// Two coordinates occupy each byte in coordinate order, high nibble first.
170/// Unsigned code `u` represents `u - 7.5`. For an odd dimension, the unused
171/// low nibble in the final byte is canonical zero padding.
172///
173/// # Errors
174///
175/// Returns [`QuantError::EmptyVector`] for an empty input,
176/// [`QuantError::DimensionTooLarge`] above the i8 scoring-kernel limit,
177/// [`QuantError::NonFinite`] for NaN or infinity, and
178/// [`QuantError::OutputLength`] unless `out.len() == ceil(v.len() / 2)`.
179/// Validation completes before `out` is modified.
180pub fn quantize_bit4(v: &[f32], out: &mut [u8]) -> Result<Bit4Factors, QuantError> {
181    validate_input(v, out.len())?;
182
183    let mut norm_squared = 0.0_f64;
184    let mut absolute_sum = 0.0_f64;
185    let mut row_scale = 0.0_f64;
186    let mut critical_values = Vec::with_capacity(v.len().saturating_mul(7));
187    for (coordinate, &value) in v.iter().enumerate() {
188        let value = f64::from(value);
189        let magnitude = value.abs();
190        norm_squared += value * value;
191        absolute_sum += magnitude;
192        row_scale = row_scale.max(magnitude);
193        if magnitude > 0.0 {
194            for level in 1..=MAX_MAGNITUDE_LEVEL {
195                critical_values.push(CriticalValue {
196                    threshold: f64::from(level) / magnitude,
197                    coordinate,
198                    level,
199                    magnitude,
200                });
201            }
202        }
203    }
204    critical_values.sort_unstable_by(|left, right| {
205        left.threshold
206            .total_cmp(&right.threshold)
207            .then_with(|| left.coordinate.cmp(&right.coordinate))
208            .then_with(|| left.level.cmp(&right.level))
209    });
210
211    let mut numerator = 0.5 * absolute_sum;
212    let mut grid_norm_squared = 0.25 * v.len() as f64;
213    let mut best_numerator = numerator;
214    let mut best_score_squared = normalized_score_squared(numerator, grid_norm_squared);
215    let mut best_event_count = 0_usize;
216    let mut event_count = 0_usize;
217    let mut events = critical_values.iter().peekable();
218    while let Some(event) = events.next() {
219        let threshold = event.threshold;
220        apply_event(event, &mut numerator, &mut grid_norm_squared);
221        event_count += 1;
222        while events
223            .peek()
224            .is_some_and(|next| next.threshold == threshold)
225        {
226            if let Some(tied) = events.next() {
227                apply_event(tied, &mut numerator, &mut grid_norm_squared);
228                event_count += 1;
229            }
230        }
231        let score_squared = normalized_score_squared(numerator, grid_norm_squared);
232        if score_squared > best_score_squared {
233            best_score_squared = score_squared;
234            best_numerator = numerator;
235            best_event_count = event_count;
236        }
237    }
238
239    let mut magnitudes = vec![0_u8; v.len()];
240    for event in critical_values.iter().take(best_event_count) {
241        if let Some(level) = magnitudes.get_mut(event.coordinate) {
242            *level = event.level;
243        }
244    }
245    pack(v, &magnitudes, out);
246
247    let (normalized_norm, normalized_correction) = if norm_squared == 0.0 {
248        (0.0, 0.0)
249    } else {
250        (
251            (norm_squared.sqrt() / row_scale) as f32,
252            (norm_squared / (row_scale * best_numerator)) as f32,
253        )
254    };
255    Ok(Bit4Factors {
256        scale: row_scale as f32,
257        normalized_norm,
258        normalized_correction,
259    })
260}
261
262/// Reconstructs the norm-scaled selected four-bit code direction.
263///
264/// The result is `||v|| * y / ||y||`. Serving uses [`est_dot_bit4`] directly;
265/// reconstruction exists to specify and verify the codec's geometric error.
266///
267/// # Errors
268///
269/// Returns the code-length and padding errors documented by
270/// [`est_dot_bit4`]. The output length defines the logical dimension of the
271/// final partial byte.
272pub fn dequantize_bit4(
273    codes: &[u8],
274    factors: Bit4Factors,
275    out: &mut [f32],
276) -> Result<(), QuantError> {
277    let unpacked = unpack(codes, out.len())?;
278    if factors.scale == 0.0 {
279        out.fill(0.0);
280        return Ok(());
281    }
282    let doubled_code_norm = unpacked
283        .iter()
284        .map(|&value| {
285            let value = f64::from(value);
286            value * value
287        })
288        .sum::<f64>()
289        .sqrt();
290    for (output, &value) in out.iter_mut().zip(&unpacked) {
291        *output = finite_f32(factors.norm() * f64::from(value) / doubled_code_norm);
292    }
293    Ok(())
294}
295
296/// Prepares one query for repeated four-bit row scoring.
297///
298/// The seed controls reproducible stochastic rounding only. Random rotation is
299/// not performed by this v1 API. A zero query receives scale zero and all-zero
300/// signed codes.
301///
302/// # Errors
303///
304/// Returns [`QuantError::EmptyVector`], [`QuantError::DimensionTooLarge`], or
305/// [`QuantError::NonFinite`] under the same input policy as
306/// [`quantize_bit4`].
307pub fn prepare_bit4_query(q: &[f32], seed: u64) -> Result<Bit4Query, QuantError> {
308    validate_vector(q)?;
309    #[cfg(any(test, feature = "test-support"))]
310    super::QUERY_PREPARATIONS.with(|calls| {
311        if let Some(calls) = calls.borrow_mut().as_mut() {
312            calls.bit4.push((q.len(), seed));
313        }
314    });
315    let max_absolute = q.iter().map(|value| value.abs()).fold(0.0_f32, f32::max);
316    if max_absolute == 0.0 {
317        return Ok(Bit4Query {
318            codes: vec![0_i8; q.len()],
319            code_sum: 0,
320            scale_half: 0.0,
321        });
322    }
323
324    let scale = f64::from(max_absolute) / 127.0;
325    let mut random = SplitMix64::new(seed);
326    let mut coordinate_codes = Vec::with_capacity(q.len());
327    for &value in q {
328        let scaled = f64::from(value) / scale;
329        let lower = scaled.floor();
330        let probability_up = scaled - lower;
331        let rounded = if random.next_open_unit_f64() < probability_up {
332            lower + 1.0
333        } else {
334            lower
335        };
336        coordinate_codes.push(rounded.clamp(-127.0, 127.0) as i8);
337    }
338    let code_sum = coordinate_codes.iter().map(|&code| i32::from(code)).sum();
339    let codes = interleave_bit4_query_blocks(&coordinate_codes);
340    Ok(Bit4Query {
341        codes,
342        code_sum,
343        scale_half: scale * 0.5,
344    })
345}
346
347/// Estimates a dot product directly from one packed four-bit row.
348///
349/// The native packed kernel scores unsigned nibbles directly, then applies
350/// `dot(z, 2*u - 15) = 2*dot(z, u) - 15*sum(z)` once per row. The query's
351/// block-interleaved representation pairs directly with high- and low-nibble
352/// vectors, so scoring needs no expanded row, ZIP interleave, or per-vector
353/// grid conversion. If the prepared query reconstructs as `scale*z`, the
354/// integer kernel gives `<y,q_hat> = scale * dot(2*y,z) / 2`; the stored
355/// correction then yields the Extended-RaBitQ estimate.
356///
357/// For an odd dimension, the unused low nibble must be canonical zero padding.
358///
359/// # Errors
360///
361/// Returns [`QuantError::CodeLength`] for a dimension mismatch and
362/// [`QuantError::NonZeroPadding`] for non-canonical trailing fields.
363pub fn est_dot_bit4(
364    query: &Bit4Query,
365    codes: &[u8],
366    factors: Bit4Factors,
367) -> Result<f32, QuantError> {
368    validate_code(codes, query.codes.len())?;
369    if factors.scale == 0.0 {
370        return Ok(0.0);
371    }
372    let integer_dot = dot_bit4_prepared(&query.codes, query.code_sum, codes);
373    Ok((factors.correction() * query.scale_half * f64::from(integer_dot)) as f32)
374}
375
376/// Estimates dot products for contiguous packed four-bit rows in one dispatch.
377///
378/// `factors` and `out` contain one entry per row. Row validation is performed
379/// before entering the dispatched microkernel, and the scalar table remains
380/// the bit-exact behavioral oracle on machines without the selected SIMD tier.
381///
382/// # Errors
383///
384/// Returns [`QuantError::OutputLength`] for a factor/output count mismatch,
385/// [`QuantError::CodeLength`] for a batch byte-length mismatch, or
386/// [`QuantError::NonZeroPadding`] for a non-canonical odd-dimension row.
387pub fn est_dot_bit4_batch(
388    query: &Bit4Query,
389    codes: &[u8],
390    factors: &[Bit4Factors],
391    out: &mut [f32],
392) -> Result<(), QuantError> {
393    if out.len() != factors.len() {
394        return Err(QuantError::OutputLength {
395            expected: factors.len(),
396            actual: out.len(),
397        });
398    }
399    let row_bytes = query.codes.len().div_ceil(CODES_PER_BYTE);
400    let expected = row_bytes.saturating_mul(factors.len());
401    if codes.len() != expected {
402        return Err(QuantError::CodeLength {
403            expected,
404            actual: codes.len(),
405        });
406    }
407    if !query.codes.len().is_multiple_of(CODES_PER_BYTE) {
408        for row in codes.chunks_exact(row_bytes) {
409            validate_padding(row, query.codes.len())?;
410        }
411    }
412    score_bit4_prepared_batch(
413        &query.codes,
414        query.code_sum,
415        query.scale_half,
416        codes,
417        query.codes.len(),
418        factors,
419        out,
420    );
421    Ok(())
422}
423
424fn interleave_bit4_query_blocks(codes: &[i8]) -> Vec<i8> {
425    let mut interleaved = Vec::with_capacity(codes.len());
426    for block in codes.chunks(32) {
427        interleaved.extend(block.iter().step_by(2).copied());
428        interleaved.extend(block.iter().skip(1).step_by(2).copied());
429    }
430    interleaved
431}
432
433fn validate_input(v: &[f32], output_len: usize) -> Result<(), QuantError> {
434    validate_vector(v)?;
435    let expected = v.len().div_ceil(CODES_PER_BYTE);
436    if output_len != expected {
437        return Err(QuantError::OutputLength {
438            expected,
439            actual: output_len,
440        });
441    }
442    Ok(())
443}
444
445fn validate_vector(v: &[f32]) -> Result<(), QuantError> {
446    if v.is_empty() {
447        return Err(QuantError::EmptyVector);
448    }
449    if v.len() > MAX_DOT_I8_DIMENSION {
450        return Err(QuantError::DimensionTooLarge {
451            actual: v.len(),
452            maximum: MAX_DOT_I8_DIMENSION,
453        });
454    }
455    if let Some((index, _)) = v.iter().enumerate().find(|(_, value)| !value.is_finite()) {
456        return Err(QuantError::NonFinite { index });
457    }
458    Ok(())
459}
460
461fn unpack(codes: &[u8], dimension: usize) -> Result<Vec<i8>, QuantError> {
462    validate_code(codes, dimension)?;
463
464    let mut unpacked = Vec::with_capacity(dimension);
465    for &byte in codes {
466        for shift in [4_u32, 0] {
467            if unpacked.len() == dimension {
468                break;
469            }
470            let unsigned = (byte >> shift) & 0b1111;
471            unpacked.push((i16::from(unsigned) * 2 - 15) as i8);
472        }
473    }
474    Ok(unpacked)
475}
476
477fn validate_code(codes: &[u8], dimension: usize) -> Result<(), QuantError> {
478    let expected = dimension.div_ceil(CODES_PER_BYTE);
479    if codes.len() != expected {
480        return Err(QuantError::CodeLength {
481            expected,
482            actual: codes.len(),
483        });
484    }
485    validate_padding(codes, dimension)?;
486    Ok(())
487}
488
489fn validate_padding(codes: &[u8], dimension: usize) -> Result<(), QuantError> {
490    if dimension.is_multiple_of(CODES_PER_BYTE) {
491        return Ok(());
492    }
493    let mask = 0x0f;
494    if let Some(&byte) = codes.last()
495        && byte & mask != 0
496    {
497        return Err(QuantError::NonZeroPadding { byte, mask });
498    }
499    Ok(())
500}
501
502fn normalized_score_squared(numerator: f64, norm_squared: f64) -> f64 {
503    numerator * numerator / norm_squared
504}
505
506fn finite_f32(value: f64) -> f32 {
507    value.clamp(f64::from(f32::MIN), f64::from(f32::MAX)) as f32
508}
509
510fn apply_event(event: &CriticalValue, numerator: &mut f64, norm_squared: &mut f64) {
511    *numerator += event.magnitude;
512    *norm_squared += 2.0 * f64::from(event.level);
513}
514
515fn pack(v: &[f32], magnitudes: &[u8], out: &mut [u8]) {
516    for ((values, levels), byte) in v
517        .chunks(CODES_PER_BYTE)
518        .zip(magnitudes.chunks(CODES_PER_BYTE))
519        .zip(out.iter_mut())
520    {
521        let mut packed = 0_u8;
522        for (field, (&value, &level)) in values.iter().zip(levels).enumerate() {
523            let unsigned = if value < 0.0 {
524                MAX_MAGNITUDE_LEVEL - level
525            } else {
526                MAX_MAGNITUDE_LEVEL + 1 + level
527            };
528            let shift = 4_u32.saturating_sub((field as u32) * 4);
529            packed |= unsigned << shift;
530        }
531        *byte = packed;
532    }
533}