Skip to main content

hermes_core/structures/vector/quantization/
tq.rs

1//! TurboQuant (TQ): training-free dense-vector codec.
2//!
3//! Design: `docs/turboquant-quantization.md`. Per padded coordinate the codec
4//! stores a 3-bit scalar code (analytic Lloyd-Max levels for the unit-sphere
5//! coordinate density) plus a 1-bit QJL sign of the rotated stage-1 residual;
6//! a per-vector f32 `gamma = ‖residual‖₂` makes the inner-product estimator
7//! unbiased. Everything is derived from `(dim, codec constants)` — no trained
8//! artifacts and no cross-segment generations.
9//!
10//! References: Zandieh, Daliri et al., "TurboQuant: Online Vector
11//! Quantization with Near-optimal Distortion Rate" (arXiv 2504.19874);
12//! layout and LUT16 scoring follow the FastScan pattern (Faiss,
13//! mayflower/pg_turboquant, both MIT).
14
15/// Bumping this refuses to mix payloads across incompatible codec revisions.
16pub const TQ_CODEC_VERSION: u32 = 1;
17/// Bits per padded coordinate: 3-bit stage-1 code + 1-bit QJL sign.
18pub const TQ_BITS: u32 = 4;
19/// Vectors per scoring block; one lane per vector.
20pub const TQ_BLOCK_LANES: usize = 16;
21/// Smallest supported padded dimension. Below this the coordinate density
22/// exponent `(P-3)/2` degenerates and LUT rows would not fill a SIMD lane.
23pub const TQ_MIN_PADDED_DIM: usize = 8;
24
25const TQ_STAGE1_LEVELS: usize = 8;
26const TQ_STAGE1_SEED: u64 = 0x7154_5354_4147_4531; // "qTSTAGE1"
27const TQ_QJL_SEED: u64 = 0x7154_514a_4c53_4b31; // "qTQJLSK1"
28const TQ_LLOYD_GRID: usize = 8192;
29const TQ_LLOYD_MAX_ITERATIONS: usize = 64;
30const TQ_LLOYD_TOLERANCE: f64 = 1e-9;
31/// i16 lane accumulators are widened to i32 at least every this many
32/// dimensions: 128 * 127 = 16256 stays far from i16 saturation.
33#[cfg(any(target_arch = "aarch64", target_arch = "x86_64"))]
34const TQ_ACCUMULATE_CHUNK_DIMS: usize = 128;
35
36/// Padded (power-of-two, ≥ [`TQ_MIN_PADDED_DIM`]) dimension for an input
37/// dimension. Cheap; usable for header validation without building a codec.
38#[inline]
39pub fn tq_padded_dim(dim: usize) -> usize {
40    dim.next_power_of_two().max(TQ_MIN_PADDED_DIM)
41}
42
43/// Fingerprint every payload built for `dim` must carry (no codebook build).
44#[inline]
45pub fn tq_expected_fingerprint(dim: usize) -> u64 {
46    tq_fingerprint(dim, tq_padded_dim(dim))
47}
48
49/// Bytes of one scoring block: 16 f32 gammas + 16 packed nibble rows.
50#[inline]
51pub const fn tq_block_bytes(code_size: usize) -> usize {
52    TQ_BLOCK_LANES * (size_of::<f32>() + code_size)
53}
54
55/// Total codes-column bytes for `count` vectors (final block zero-padded).
56#[inline]
57pub const fn tq_codes_column_len(count: usize, code_size: usize) -> usize {
58    count.div_ceil(TQ_BLOCK_LANES) * tq_block_bytes(code_size)
59}
60
61/// Overflow-checked [`tq_codes_column_len`] for untrusted header values.
62#[inline]
63pub fn tq_codes_column_len_checked(count: usize, code_size: usize) -> Option<usize> {
64    count
65        .div_ceil(TQ_BLOCK_LANES)
66        .checked_mul(tq_block_bytes(code_size))
67}
68
69/// Bytes of one IVF-TQ scoring block: 16 f32 residual scales + 16 f32 gammas
70/// + 16 packed nibble rows.
71#[inline]
72pub const fn tq_ivf_block_bytes(code_size: usize) -> usize {
73    TQ_BLOCK_LANES * (2 * size_of::<f32>() + code_size)
74}
75
76/// Overflow-checked IVF-TQ codes-column length for untrusted header values.
77#[inline]
78pub fn tq_ivf_codes_column_len_checked(count: usize, code_size: usize) -> Option<usize> {
79    count
80        .div_ceil(TQ_BLOCK_LANES)
81        .checked_mul(tq_ivf_block_bytes(code_size))
82}
83
84#[inline]
85fn splitmix64(state: &mut u64) -> u64 {
86    *state = state.wrapping_add(0x9e37_79b9_7f4a_7c15);
87    let mut z = *state;
88    z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
89    z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
90    z ^ (z >> 31)
91}
92
93/// Seeded structured rotation: sign flips → normalized FWHT → permutation.
94/// Orthonormal on `R^padded_dim`; inputs shorter than `padded_dim` are
95/// zero-padded, which embeds them isometrically.
96#[derive(Debug, Clone)]
97pub struct TqRotation {
98    input_dim: usize,
99    padded_dim: usize,
100    /// +1.0 / -1.0 per padded coordinate.
101    signs: Vec<f32>,
102    /// `output[i] = fwht(signs * input)[perm[i]]`.
103    perm: Vec<u32>,
104}
105
106impl TqRotation {
107    pub fn new(input_dim: usize, seed: u64) -> Self {
108        let padded_dim = tq_padded_dim(input_dim);
109        let mut state = seed;
110        let signs = (0..padded_dim)
111            .map(|_| {
112                if splitmix64(&mut state) & 1 == 1 {
113                    1.0
114                } else {
115                    -1.0
116                }
117            })
118            .collect();
119        let mut perm: Vec<u32> = (0..padded_dim as u32).collect();
120        for i in (1..padded_dim).rev() {
121            let j = (splitmix64(&mut state) % (i as u64 + 1)) as usize;
122            perm.swap(i, j);
123        }
124        Self {
125            input_dim,
126            padded_dim,
127            signs,
128            perm,
129        }
130    }
131
132    #[inline]
133    pub fn padded_dim(&self) -> usize {
134        self.padded_dim
135    }
136
137    /// Rotate `input` (length `input_dim`, or `padded_dim` for already-padded
138    /// residuals) into `output` (length `padded_dim`). `scratch` is reused
139    /// across calls to keep encoding allocation-free.
140    pub fn apply(&self, input: &[f32], scratch: &mut Vec<f32>, output: &mut [f32]) {
141        debug_assert!(input.len() == self.input_dim || input.len() == self.padded_dim);
142        debug_assert_eq!(output.len(), self.padded_dim);
143        scratch.clear();
144        scratch.extend(
145            self.signs
146                .iter()
147                .enumerate()
148                .map(|(index, sign)| input.get(index).copied().unwrap_or(0.0) * sign),
149        );
150        fwht_normalized(scratch);
151        for (slot, &source) in output.iter_mut().zip(&self.perm) {
152            *slot = scratch[source as usize];
153        }
154    }
155}
156
157/// In-place normalized fast Walsh-Hadamard transform (`len` a power of two).
158fn fwht_normalized(values: &mut [f32]) {
159    let len = values.len();
160    debug_assert!(len.is_power_of_two());
161    let mut step = 1;
162    while step < len {
163        let mut base = 0;
164        while base < len {
165            for offset in base..base + step {
166                let left = values[offset];
167                let right = values[offset + step];
168                values[offset] = left + right;
169                values[offset + step] = left - right;
170            }
171            base += step * 2;
172        }
173        step *= 2;
174    }
175    let scale = 1.0 / (len as f32).sqrt();
176    for value in values.iter_mut() {
177        *value *= scale;
178    }
179}
180
181/// Analytic 3-bit Lloyd-Max codebook for the marginal density of one
182/// coordinate of a uniform unit vector in `R^padded_dim`:
183/// `f(t) ∝ (1 - t²)^((padded_dim - 3) / 2)` on `[-1, 1]`.
184#[derive(Debug, Clone)]
185pub struct TqCodebook {
186    levels: [f32; TQ_STAGE1_LEVELS],
187    /// Decision boundaries between adjacent levels (midpoints).
188    boundaries: [f32; TQ_STAGE1_LEVELS - 1],
189}
190
191impl TqCodebook {
192    pub fn analytic(padded_dim: usize) -> Self {
193        assert!(
194            padded_dim >= TQ_MIN_PADDED_DIM,
195            "TQ codebook requires padded_dim >= {TQ_MIN_PADDED_DIM}, got {padded_dim}"
196        );
197        let exponent = (padded_dim as f64 - 3.0) / 2.0;
198        let cell = 2.0 / TQ_LLOYD_GRID as f64;
199        // Grid midpoints and their density weights over [-1, 1].
200        // Heap-allocated: two [f64; 8192] frames (~128 KiB) would risk stack
201        // overflow on constrained runtimes (WASM readers, worker threads).
202        let mut weights = vec![0.0f64; TQ_LLOYD_GRID];
203        let mut positions = vec![0.0f64; TQ_LLOYD_GRID];
204        for index in 0..TQ_LLOYD_GRID {
205            let t = -1.0 + (index as f64 + 0.5) * cell;
206            positions[index] = t;
207            let log_density = exponent * (1.0 - t * t).max(f64::MIN_POSITIVE).ln();
208            weights[index] = log_density.exp();
209        }
210
211        // Initialize boundaries at equal-mass quantiles.
212        let total_mass: f64 = weights.iter().sum();
213        let mut levels = [0.0f64; TQ_STAGE1_LEVELS];
214        let mut boundaries = [0.0f64; TQ_STAGE1_LEVELS - 1];
215        let mut accumulated = 0.0f64;
216        let mut next_boundary = 0usize;
217        for index in 0..TQ_LLOYD_GRID {
218            accumulated += weights[index];
219            while next_boundary < TQ_STAGE1_LEVELS - 1
220                && accumulated
221                    >= total_mass * (next_boundary as f64 + 1.0) / TQ_STAGE1_LEVELS as f64
222            {
223                boundaries[next_boundary] = positions[index];
224                next_boundary += 1;
225            }
226        }
227
228        // Lloyd-Max: centroids are density-weighted means of their cell,
229        // boundaries are midpoints of adjacent centroids.
230        for _ in 0..TQ_LLOYD_MAX_ITERATIONS {
231            let mut mass = [0.0f64; TQ_STAGE1_LEVELS];
232            let mut moment = [0.0f64; TQ_STAGE1_LEVELS];
233            let mut bucket = 0usize;
234            for index in 0..TQ_LLOYD_GRID {
235                let t = positions[index];
236                while bucket < TQ_STAGE1_LEVELS - 1 && t > boundaries[bucket] {
237                    bucket += 1;
238                }
239                mass[bucket] += weights[index];
240                moment[bucket] += weights[index] * t;
241            }
242            let mut shift = 0.0f64;
243            for level in 0..TQ_STAGE1_LEVELS {
244                if mass[level] > 0.0 {
245                    let updated = moment[level] / mass[level];
246                    shift = shift.max((updated - levels[level]).abs());
247                    levels[level] = updated;
248                }
249            }
250            for boundary in 0..TQ_STAGE1_LEVELS - 1 {
251                boundaries[boundary] = 0.5 * (levels[boundary] + levels[boundary + 1]);
252            }
253            if shift < TQ_LLOYD_TOLERANCE {
254                break;
255            }
256        }
257
258        // The density is even, so the optimal codebook is exactly symmetric;
259        // grid discretization leaves ~1e-4 asymmetry. Symmetrize so the
260        // central decision boundary is exactly zero.
261        for index in 0..TQ_STAGE1_LEVELS / 2 {
262            let magnitude = 0.5 * (levels[TQ_STAGE1_LEVELS - 1 - index] - levels[index]);
263            levels[index] = -magnitude;
264            levels[TQ_STAGE1_LEVELS - 1 - index] = magnitude;
265        }
266        for boundary in 0..TQ_STAGE1_LEVELS - 1 {
267            boundaries[boundary] = 0.5 * (levels[boundary] + levels[boundary + 1]);
268        }
269
270        Self {
271            levels: levels.map(|level| level as f32),
272            boundaries: boundaries.map(|boundary| boundary as f32),
273        }
274    }
275
276    /// 3-bit code of the nearest level.
277    #[inline]
278    pub fn encode_coordinate(&self, value: f32) -> u8 {
279        let mut code = 0u8;
280        for &boundary in &self.boundaries {
281            code += u8::from(value > boundary);
282        }
283        code
284    }
285}
286
287/// Complete TQ codec for one field dimension. Cheap to build (sub-millisecond)
288/// and immutable; share via `Arc` per open segment.
289#[derive(Debug, Clone)]
290pub struct TqCodec {
291    dim: usize,
292    padded_dim: usize,
293    stage1_rotation: TqRotation,
294    qjl_rotation: TqRotation,
295    codebook: TqCodebook,
296    /// `sqrt(π/2) / sqrt(padded_dim)`: QJL correction for an orthonormal sketch.
297    qjl_scale: f32,
298    fingerprint: u64,
299}
300
301impl TqCodec {
302    pub fn new(dim: usize) -> Self {
303        assert!(dim > 0, "TQ codec requires a non-zero dimension");
304        let stage1_rotation = TqRotation::new(dim, TQ_STAGE1_SEED);
305        let padded_dim = stage1_rotation.padded_dim();
306        let qjl_rotation = TqRotation::new(padded_dim, TQ_QJL_SEED);
307        debug_assert_eq!(qjl_rotation.padded_dim(), padded_dim);
308        let codebook = TqCodebook::analytic(padded_dim);
309        let qjl_scale = (std::f64::consts::PI / 2.0).sqrt() as f32 / (padded_dim as f32).sqrt();
310        let fingerprint = tq_fingerprint(dim, padded_dim);
311        Self {
312            dim,
313            padded_dim,
314            stage1_rotation,
315            qjl_rotation,
316            codebook,
317            qjl_scale,
318            fingerprint,
319        }
320    }
321
322    #[inline]
323    pub fn dim(&self) -> usize {
324        self.dim
325    }
326
327    #[inline]
328    pub fn padded_dim(&self) -> usize {
329        self.padded_dim
330    }
331
332    /// Logical bytes per vector (two 4-bit coordinates per byte).
333    #[inline]
334    pub fn code_size(&self) -> usize {
335        self.padded_dim / 2
336    }
337
338    /// Deterministic compatibility fingerprint carried as `quantizer_version`.
339    #[inline]
340    pub fn fingerprint(&self) -> u64 {
341        self.fingerprint
342    }
343
344    /// Heap footprint: two rotations (signs f32 + perm u32 per padded coord)
345    /// plus the fixed-size codebook.
346    pub fn estimated_memory_bytes(&self) -> usize {
347        2 * self.padded_dim * (size_of::<f32>() + size_of::<u32>()) + size_of::<TqCodebook>()
348    }
349
350    /// Encode one vector into `nibbles` (one 0..=15 value per padded
351    /// coordinate) and return `gamma`. The vector is normalized internally;
352    /// zero vectors encode as all-zero nibbles with `gamma = 0`.
353    pub fn encode_into(
354        &self,
355        vector: &[f32],
356        nibbles: &mut [u8],
357        scratch: &mut TqEncodeScratch,
358    ) -> f32 {
359        self.encode_residual_into(vector, nibbles, scratch).1
360    }
361
362    /// Encode one (possibly non-unit) vector as `scale · unit_direction` and
363    /// return `(scale = ‖vector‖₂, gamma)`. IVF leaves store centroid
364    /// residuals, whose norms carry ranking information; `scale` restores it
365    /// at score time. Zero vectors encode as all-zero nibbles with
366    /// `scale = gamma = 0`.
367    pub fn encode_residual_into(
368        &self,
369        vector: &[f32],
370        nibbles: &mut [u8],
371        scratch: &mut TqEncodeScratch,
372    ) -> (f32, f32) {
373        assert_eq!(vector.len(), self.dim, "TQ encode dimension mismatch");
374        assert_eq!(nibbles.len(), self.padded_dim, "TQ nibble buffer mismatch");
375        let norm = crate::structures::simd::dot_product_f32(vector, vector, vector.len()).sqrt();
376        if !norm.is_finite() || norm <= 0.0 {
377            nibbles.fill(0);
378            return (0.0, 0.0);
379        }
380        scratch.normalized.clear();
381        scratch
382            .normalized
383            .extend(vector.iter().map(|value| value / norm));
384
385        scratch.rotated.resize(self.padded_dim, 0.0);
386        let (normalized, rotated, fwht) =
387            (&scratch.normalized, &mut scratch.rotated, &mut scratch.fwht);
388        self.stage1_rotation.apply(normalized, fwht, rotated);
389
390        // Stage-1 codes and residual (in stage-1 rotated space).
391        scratch.residual.resize(self.padded_dim, 0.0);
392        let mut residual_norm_sq = 0.0f32;
393        for ((&value, nibble), residual_slot) in scratch
394            .rotated
395            .iter()
396            .zip(nibbles.iter_mut())
397            .zip(scratch.residual.iter_mut())
398        {
399            let code = self.codebook.encode_coordinate(value);
400            *nibble = code << 1;
401            let residual = value - self.codebook.levels[code as usize];
402            *residual_slot = residual;
403            residual_norm_sq += residual * residual;
404        }
405
406        // QJL sign bits of the rotated residual.
407        scratch.rotated_residual.resize(self.padded_dim, 0.0);
408        let (residual, rotated_residual, fwht) = (
409            &scratch.residual,
410            &mut scratch.rotated_residual,
411            &mut scratch.fwht,
412        );
413        self.qjl_rotation.apply(residual, fwht, rotated_residual);
414        for (nibble, &rotated) in nibbles.iter_mut().zip(scratch.rotated_residual.iter()) {
415            *nibble |= u8::from(rotated >= 0.0);
416        }
417        (norm, residual_norm_sq.sqrt())
418    }
419}
420
421/// Reusable per-thread encode buffers (hot-path allocation hygiene).
422#[derive(Debug, Default)]
423pub struct TqEncodeScratch {
424    normalized: Vec<f32>,
425    rotated: Vec<f32>,
426    residual: Vec<f32>,
427    rotated_residual: Vec<f32>,
428    fwht: Vec<f32>,
429}
430
431fn tq_fingerprint(dim: usize, padded_dim: usize) -> u64 {
432    let mut hash = 0xcbf2_9ce4_8422_2325u64; // FNV-1a offset basis
433    let mut mix = |bytes: &[u8]| {
434        for &byte in bytes {
435            hash ^= u64::from(byte);
436            hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
437        }
438    };
439    mix(b"hermes-tq");
440    mix(&TQ_CODEC_VERSION.to_le_bytes());
441    mix(&TQ_BITS.to_le_bytes());
442    mix(&(dim as u64).to_le_bytes());
443    mix(&(padded_dim as u64).to_le_bytes());
444    mix(&TQ_STAGE1_SEED.to_le_bytes());
445    mix(&TQ_QJL_SEED.to_le_bytes());
446    if hash == 0 { 1 } else { hash }
447}
448
449// ---------------------------------------------------------------------------
450// Query plan and block scoring
451// ---------------------------------------------------------------------------
452
453/// Per-query LUTs: `padded_dim × 16` i8 tables (globally-scaled
454/// quantizations) for the block kernels. The intermediate f32 tables are
455/// dropped after quantization — they are not read on the search path.
456pub struct TqQueryPlan {
457    padded_dim: usize,
458    fingerprint: u64,
459    base_lut_i8: Vec<i8>,
460    qjl_lut_i8: Vec<i8>,
461    base_dequant: f32,
462    qjl_dequant: f32,
463    /// Full-precision tables, retained for the reference estimator in tests.
464    #[cfg(test)]
465    reference_luts: (Vec<f32>, Vec<f32>),
466}
467
468impl std::fmt::Debug for TqQueryPlan {
469    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
470        formatter
471            .debug_struct("TqQueryPlan")
472            .field("padded_dim", &self.padded_dim)
473            .field("fingerprint", &self.fingerprint)
474            .finish()
475    }
476}
477
478impl TqQueryPlan {
479    pub fn build(codec: &TqCodec, query: &[f32]) -> Self {
480        assert_eq!(query.len(), codec.dim, "TQ query dimension mismatch");
481        let padded_dim = codec.padded_dim;
482        let norm = crate::structures::simd::dot_product_f32(query, query, query.len()).sqrt();
483        let inverse_norm = if norm.is_finite() && norm > 0.0 {
484            1.0 / norm
485        } else {
486            0.0
487        };
488        let normalized: Vec<f32> = query.iter().map(|value| value * inverse_norm).collect();
489        let mut fwht = Vec::with_capacity(padded_dim);
490        let mut rotated = vec![0.0f32; padded_dim];
491        codec
492            .stage1_rotation
493            .apply(&normalized, &mut fwht, &mut rotated);
494        let mut qjl_rotated = vec![0.0f32; padded_dim];
495        codec
496            .qjl_rotation
497            .apply(&rotated, &mut fwht, &mut qjl_rotated);
498
499        let mut base_lut = vec![0.0f32; padded_dim * 16];
500        let mut qjl_lut = vec![0.0f32; padded_dim * 16];
501        for dim in 0..padded_dim {
502            for nibble in 0..16 {
503                let level = codec.codebook.levels[nibble >> 1];
504                let sign = if nibble & 1 == 1 { 1.0 } else { -1.0 };
505                base_lut[dim * 16 + nibble] = rotated[dim] * level;
506                qjl_lut[dim * 16 + nibble] = sign * codec.qjl_scale * qjl_rotated[dim];
507            }
508        }
509        let (base_lut_i8, base_dequant) = quantize_lut(&base_lut);
510        let (qjl_lut_i8, qjl_dequant) = quantize_lut(&qjl_lut);
511        Self {
512            padded_dim,
513            fingerprint: codec.fingerprint,
514            base_lut_i8,
515            qjl_lut_i8,
516            base_dequant,
517            qjl_dequant,
518            #[cfg(test)]
519            reference_luts: (base_lut, qjl_lut),
520        }
521    }
522
523    #[inline]
524    pub fn padded_dim(&self) -> usize {
525        self.padded_dim
526    }
527
528    #[inline]
529    pub fn fingerprint(&self) -> u64 {
530        self.fingerprint
531    }
532
533    /// Reference f32 estimator over one unpacked nibble row (test oracle for
534    /// the quantized block path).
535    #[cfg(test)]
536    pub(crate) fn estimate_row(&self, nibbles: &[u8], gamma: f32) -> f32 {
537        debug_assert_eq!(nibbles.len(), self.padded_dim);
538        let (base_lut, qjl_lut) = &self.reference_luts;
539        let mut base = 0.0f32;
540        let mut qjl = 0.0f32;
541        for (dim, &nibble) in nibbles.iter().enumerate() {
542            base += base_lut[dim * 16 + nibble as usize];
543            qjl += qjl_lut[dim * 16 + nibble as usize];
544        }
545        base + gamma * qjl
546    }
547}
548
549fn quantize_lut(values: &[f32]) -> (Vec<i8>, f32) {
550    let max_abs = values
551        .iter()
552        .fold(0.0f32, |acc, &value| acc.max(value.abs()));
553    if !max_abs.is_finite() || max_abs <= 0.0 {
554        return (vec![0i8; values.len()], 0.0);
555    }
556    let quantize_scale = 127.0 / max_abs;
557    let quantized = values
558        .iter()
559        .map(|&value| (value * quantize_scale).round().clamp(-127.0, 127.0) as i8)
560        .collect();
561    (quantized, max_abs / 127.0)
562}
563
564/// Score one block (16 lanes) into `scores`. `block` is
565/// `[16 × f32 gamma][padded_dim × 8 packed nibbles]`; lanes past the run's
566/// vector count hold zero padding and must be ignored by the caller.
567pub fn tq_score_block(plan: &TqQueryPlan, block: &[u8], scores: &mut [f32; TQ_BLOCK_LANES]) {
568    debug_assert_eq!(block.len(), tq_block_bytes(plan.padded_dim / 2));
569    let (gamma_bytes, nibble_bytes) = block.split_at(TQ_BLOCK_LANES * size_of::<f32>());
570    let mut base = [0i32; TQ_BLOCK_LANES];
571    let mut qjl = [0i32; TQ_BLOCK_LANES];
572    lut16::accumulate_block(
573        &plan.base_lut_i8,
574        &plan.qjl_lut_i8,
575        nibble_bytes,
576        plan.padded_dim,
577        &mut base,
578        &mut qjl,
579    );
580    for lane in 0..TQ_BLOCK_LANES {
581        let gamma = f32::from_le_bytes(
582            gamma_bytes[lane * 4..lane * 4 + 4]
583                .try_into()
584                .expect("gamma slice is 4 bytes"),
585        );
586        scores[lane] =
587            base[lane] as f32 * plan.base_dequant + gamma * qjl[lane] as f32 * plan.qjl_dequant;
588    }
589}
590
591/// Pack up to 16 nibble rows (+ gammas) into one block. Missing lanes are
592/// zero-filled. `rows` are `padded_dim`-length 0..=15 values.
593pub fn tq_pack_block(rows: &[&[u8]], gammas: &[f32], padded_dim: usize, output: &mut Vec<u8>) {
594    assert!(rows.len() <= TQ_BLOCK_LANES && rows.len() == gammas.len());
595    for lane in 0..TQ_BLOCK_LANES {
596        let gamma = gammas.get(lane).copied().unwrap_or(0.0);
597        output.extend_from_slice(&gamma.to_le_bytes());
598    }
599    pack_nibble_rows(rows, padded_dim, output);
600}
601
602/// Pack an IVF-TQ block: per-lane residual scales, gammas, then nibbles.
603pub fn tq_pack_ivf_block(
604    rows: &[&[u8]],
605    scales: &[f32],
606    gammas: &[f32],
607    padded_dim: usize,
608    output: &mut Vec<u8>,
609) {
610    assert!(rows.len() <= TQ_BLOCK_LANES && rows.len() == gammas.len());
611    assert_eq!(scales.len(), gammas.len());
612    for lane in 0..TQ_BLOCK_LANES {
613        let scale = scales.get(lane).copied().unwrap_or(0.0);
614        output.extend_from_slice(&scale.to_le_bytes());
615    }
616    for lane in 0..TQ_BLOCK_LANES {
617        let gamma = gammas.get(lane).copied().unwrap_or(0.0);
618        output.extend_from_slice(&gamma.to_le_bytes());
619    }
620    pack_nibble_rows(rows, padded_dim, output);
621}
622
623fn pack_nibble_rows(rows: &[&[u8]], padded_dim: usize, output: &mut Vec<u8>) {
624    for dim in 0..padded_dim {
625        for byte_index in 0..TQ_BLOCK_LANES / 2 {
626            let low = rows.get(byte_index).map_or(0, |row| row[dim] & 0x0F);
627            let high = rows
628                .get(byte_index + TQ_BLOCK_LANES / 2)
629                .map_or(0, |row| row[dim] & 0x0F);
630            output.push(low | (high << 4));
631        }
632    }
633}
634
635/// Score one IVF-TQ block: `score[lane] = cluster_dot + scale · (base +
636/// gamma · qjl)`, where `cluster_dot = ⟨normalized query, centroid⟩` is the
637/// probed cluster's shared contribution and `scale = ‖residual‖`.
638pub fn tq_score_ivf_block(
639    plan: &TqQueryPlan,
640    block: &[u8],
641    cluster_dot: f32,
642    scores: &mut [f32; TQ_BLOCK_LANES],
643) {
644    debug_assert_eq!(block.len(), tq_ivf_block_bytes(plan.padded_dim() / 2));
645    let lane_f32 = TQ_BLOCK_LANES * size_of::<f32>();
646    let (scale_bytes, rest) = block.split_at(lane_f32);
647    let (gamma_bytes, nibble_bytes) = rest.split_at(lane_f32);
648    let mut base = [0i32; TQ_BLOCK_LANES];
649    let mut qjl = [0i32; TQ_BLOCK_LANES];
650    lut16::accumulate_block(
651        &plan.base_lut_i8,
652        &plan.qjl_lut_i8,
653        nibble_bytes,
654        plan.padded_dim,
655        &mut base,
656        &mut qjl,
657    );
658    for lane in 0..TQ_BLOCK_LANES {
659        let scale = f32::from_le_bytes(
660            scale_bytes[lane * 4..lane * 4 + 4]
661                .try_into()
662                .expect("scale slice is 4 bytes"),
663        );
664        let gamma = f32::from_le_bytes(
665            gamma_bytes[lane * 4..lane * 4 + 4]
666                .try_into()
667                .expect("gamma slice is 4 bytes"),
668        );
669        scores[lane] = cluster_dot
670            + scale
671                * (base[lane] as f32 * plan.base_dequant
672                    + gamma * qjl[lane] as f32 * plan.qjl_dequant);
673    }
674}
675
676mod lut16 {
677    #[cfg(any(target_arch = "aarch64", target_arch = "x86_64"))]
678    use super::TQ_ACCUMULATE_CHUNK_DIMS;
679    use super::TQ_BLOCK_LANES;
680
681    /// Accumulate both LUT sums for 16 lanes over all dimensions.
682    /// `nibble_bytes` is dimension-major: 8 bytes per dimension, byte `j`
683    /// holding lane `j` (low nibble) and lane `j + 8` (high nibble).
684    pub(super) fn accumulate_block(
685        base_lut: &[i8],
686        qjl_lut: &[i8],
687        nibble_bytes: &[u8],
688        padded_dim: usize,
689        base: &mut [i32; TQ_BLOCK_LANES],
690        qjl: &mut [i32; TQ_BLOCK_LANES],
691    ) {
692        #[cfg(target_arch = "aarch64")]
693        {
694            // NEON is baseline on aarch64.
695            unsafe { accumulate_block_neon(base_lut, qjl_lut, nibble_bytes, padded_dim, base, qjl) }
696            return;
697        }
698        #[cfg(target_arch = "x86_64")]
699        {
700            if std::arch::is_x86_feature_detected!("ssse3") {
701                unsafe {
702                    accumulate_block_ssse3(base_lut, qjl_lut, nibble_bytes, padded_dim, base, qjl)
703                }
704                return;
705            }
706        }
707        #[allow(unreachable_code)]
708        accumulate_block_scalar(base_lut, qjl_lut, nibble_bytes, padded_dim, base, qjl);
709    }
710
711    /// Scalar fallback mirroring the SIMD integer arithmetic exactly
712    /// (i8 lookups, i32 sums), so all paths agree bit-for-bit.
713    pub(super) fn accumulate_block_scalar(
714        base_lut: &[i8],
715        qjl_lut: &[i8],
716        nibble_bytes: &[u8],
717        padded_dim: usize,
718        base: &mut [i32; TQ_BLOCK_LANES],
719        qjl: &mut [i32; TQ_BLOCK_LANES],
720    ) {
721        for dim in 0..padded_dim {
722            let row = &nibble_bytes[dim * 8..dim * 8 + 8];
723            let base_table = &base_lut[dim * 16..dim * 16 + 16];
724            let qjl_table = &qjl_lut[dim * 16..dim * 16 + 16];
725            for (lane, &byte) in row.iter().enumerate() {
726                let low = (byte & 0x0F) as usize;
727                let high = (byte >> 4) as usize;
728                base[lane] += i32::from(base_table[low]);
729                base[lane + 8] += i32::from(base_table[high]);
730                qjl[lane] += i32::from(qjl_table[low]);
731                qjl[lane + 8] += i32::from(qjl_table[high]);
732            }
733        }
734    }
735
736    #[cfg(target_arch = "aarch64")]
737    #[target_feature(enable = "neon")]
738    unsafe fn accumulate_block_neon(
739        base_lut: &[i8],
740        qjl_lut: &[i8],
741        nibble_bytes: &[u8],
742        padded_dim: usize,
743        base: &mut [i32; TQ_BLOCK_LANES],
744        qjl: &mut [i32; TQ_BLOCK_LANES],
745    ) {
746        use std::arch::aarch64::*;
747        unsafe {
748            let mask = vdup_n_u8(0x0F);
749            let mut base_lo_i32 = [vdupq_n_s32(0); 4];
750            let mut qjl_lo_i32 = [vdupq_n_s32(0); 4];
751            let mut dim = 0;
752            while dim < padded_dim {
753                let chunk_end = (dim + TQ_ACCUMULATE_CHUNK_DIMS).min(padded_dim);
754                let mut base_acc = [vdupq_n_s16(0); 2];
755                let mut qjl_acc = [vdupq_n_s16(0); 2];
756                while dim < chunk_end {
757                    let row = vld1_u8(nibble_bytes.as_ptr().add(dim * 8));
758                    let low = vand_u8(row, mask);
759                    let high = vshr_n_u8::<4>(row);
760                    let lanes = vcombine_u8(low, high);
761                    let base_table = vld1q_s8(base_lut.as_ptr().add(dim * 16));
762                    let qjl_table = vld1q_s8(qjl_lut.as_ptr().add(dim * 16));
763                    let base_values =
764                        vreinterpretq_s8_u8(vqtbl1q_u8(vreinterpretq_u8_s8(base_table), lanes));
765                    let qjl_values =
766                        vreinterpretq_s8_u8(vqtbl1q_u8(vreinterpretq_u8_s8(qjl_table), lanes));
767                    base_acc[0] = vaddw_s8(base_acc[0], vget_low_s8(base_values));
768                    base_acc[1] = vaddw_s8(base_acc[1], vget_high_s8(base_values));
769                    qjl_acc[0] = vaddw_s8(qjl_acc[0], vget_low_s8(qjl_values));
770                    qjl_acc[1] = vaddw_s8(qjl_acc[1], vget_high_s8(qjl_values));
771                    dim += 1;
772                }
773                for half in 0..2 {
774                    base_lo_i32[half * 2] =
775                        vaddw_s16(base_lo_i32[half * 2], vget_low_s16(base_acc[half]));
776                    base_lo_i32[half * 2 + 1] =
777                        vaddw_s16(base_lo_i32[half * 2 + 1], vget_high_s16(base_acc[half]));
778                    qjl_lo_i32[half * 2] =
779                        vaddw_s16(qjl_lo_i32[half * 2], vget_low_s16(qjl_acc[half]));
780                    qjl_lo_i32[half * 2 + 1] =
781                        vaddw_s16(qjl_lo_i32[half * 2 + 1], vget_high_s16(qjl_acc[half]));
782                }
783            }
784            for quarter in 0..4 {
785                vst1q_s32(base.as_mut_ptr().add(quarter * 4), base_lo_i32[quarter]);
786                vst1q_s32(qjl.as_mut_ptr().add(quarter * 4), qjl_lo_i32[quarter]);
787            }
788        }
789    }
790
791    #[cfg(target_arch = "x86_64")]
792    #[target_feature(enable = "ssse3")]
793    unsafe fn accumulate_block_ssse3(
794        base_lut: &[i8],
795        qjl_lut: &[i8],
796        nibble_bytes: &[u8],
797        padded_dim: usize,
798        base: &mut [i32; TQ_BLOCK_LANES],
799        qjl: &mut [i32; TQ_BLOCK_LANES],
800    ) {
801        use std::arch::x86_64::*;
802        unsafe {
803            let mask = _mm_set1_epi8(0x0F);
804            let zero = _mm_setzero_si128();
805            let mut base_i32 = [zero; 4];
806            let mut qjl_i32 = [zero; 4];
807            let mut dim = 0;
808            while dim < padded_dim {
809                let chunk_end = (dim + TQ_ACCUMULATE_CHUNK_DIMS).min(padded_dim);
810                let mut base_acc = [zero; 2];
811                let mut qjl_acc = [zero; 2];
812                while dim < chunk_end {
813                    let row = _mm_loadl_epi64(nibble_bytes.as_ptr().add(dim * 8).cast());
814                    let low = _mm_and_si128(row, mask);
815                    let high = _mm_and_si128(_mm_srli_epi16(row, 4), mask);
816                    let lanes = _mm_unpacklo_epi64(low, high);
817                    let base_table = _mm_loadu_si128(base_lut.as_ptr().add(dim * 16).cast());
818                    let qjl_table = _mm_loadu_si128(qjl_lut.as_ptr().add(dim * 16).cast());
819                    let base_values = _mm_shuffle_epi8(base_table, lanes);
820                    let qjl_values = _mm_shuffle_epi8(qjl_table, lanes);
821                    // Sign-extend i8 → i16 without SSE4.1: compare-based sign mask.
822                    let base_sign = _mm_cmpgt_epi8(zero, base_values);
823                    let qjl_sign = _mm_cmpgt_epi8(zero, qjl_values);
824                    base_acc[0] =
825                        _mm_add_epi16(base_acc[0], _mm_unpacklo_epi8(base_values, base_sign));
826                    base_acc[1] =
827                        _mm_add_epi16(base_acc[1], _mm_unpackhi_epi8(base_values, base_sign));
828                    qjl_acc[0] = _mm_add_epi16(qjl_acc[0], _mm_unpacklo_epi8(qjl_values, qjl_sign));
829                    qjl_acc[1] = _mm_add_epi16(qjl_acc[1], _mm_unpackhi_epi8(qjl_values, qjl_sign));
830                    dim += 1;
831                }
832                for half in 0..2 {
833                    let base_sign = _mm_cmpgt_epi16(zero, base_acc[half]);
834                    let qjl_sign = _mm_cmpgt_epi16(zero, qjl_acc[half]);
835                    base_i32[half * 2] = _mm_add_epi32(
836                        base_i32[half * 2],
837                        _mm_unpacklo_epi16(base_acc[half], base_sign),
838                    );
839                    base_i32[half * 2 + 1] = _mm_add_epi32(
840                        base_i32[half * 2 + 1],
841                        _mm_unpackhi_epi16(base_acc[half], base_sign),
842                    );
843                    qjl_i32[half * 2] = _mm_add_epi32(
844                        qjl_i32[half * 2],
845                        _mm_unpacklo_epi16(qjl_acc[half], qjl_sign),
846                    );
847                    qjl_i32[half * 2 + 1] = _mm_add_epi32(
848                        qjl_i32[half * 2 + 1],
849                        _mm_unpackhi_epi16(qjl_acc[half], qjl_sign),
850                    );
851                }
852            }
853            for quarter in 0..4 {
854                _mm_storeu_si128(base.as_mut_ptr().add(quarter * 4).cast(), base_i32[quarter]);
855                _mm_storeu_si128(qjl.as_mut_ptr().add(quarter * 4).cast(), qjl_i32[quarter]);
856            }
857        }
858    }
859}
860
861// ---------------------------------------------------------------------------
862// Segment build support
863// ---------------------------------------------------------------------------
864
865/// Streaming builder for one segment's TQ payload: doc/ordinal columns plus a
866/// block-packed codes column ready for `ann_disk` serialization.
867#[cfg(feature = "native")]
868pub struct TqFlatBuilder {
869    codec: std::sync::Arc<TqCodec>,
870    pub doc_ids: Vec<u32>,
871    pub ordinals: Vec<u16>,
872    pub codes: Vec<u8>,
873    pending_rows: Vec<Vec<u8>>,
874    pending_gammas: Vec<f32>,
875}
876
877#[cfg(feature = "native")]
878impl TqFlatBuilder {
879    pub fn new(codec: std::sync::Arc<TqCodec>) -> Self {
880        Self {
881            codec,
882            doc_ids: Vec::new(),
883            ordinals: Vec::new(),
884            codes: Vec::new(),
885            pending_rows: Vec::with_capacity(TQ_BLOCK_LANES),
886            pending_gammas: Vec::with_capacity(TQ_BLOCK_LANES),
887        }
888    }
889
890    #[inline]
891    pub fn codec(&self) -> &TqCodec {
892        &self.codec
893    }
894
895    #[inline]
896    pub fn len(&self) -> usize {
897        self.doc_ids.len()
898    }
899
900    #[inline]
901    pub fn is_empty(&self) -> bool {
902        self.doc_ids.is_empty()
903    }
904
905    /// Encode one contiguous `(labels, vectors)` batch in parallel while
906    /// preserving input order (lane order must match the doc-ID column).
907    pub fn add_batch(
908        &mut self,
909        labels: &[(u32, u16)],
910        vectors: &[f32],
911    ) -> Result<(), &'static str> {
912        use rayon::prelude::*;
913
914        let dim = self.codec.dim();
915        let expected = labels
916            .len()
917            .checked_mul(dim)
918            .ok_or("TQ input size overflow")?;
919        if vectors.len() != expected {
920            return Err("TQ vector and label matrices are inconsistent");
921        }
922        let padded_dim = self.codec.padded_dim();
923        let codec = std::sync::Arc::clone(&self.codec);
924        let rows: Vec<(Vec<u8>, f32)> = vectors
925            .par_chunks_exact(dim)
926            .map_init(TqEncodeScratch::default, |scratch, vector| {
927                let mut nibbles = vec![0u8; padded_dim];
928                let gamma = codec.encode_into(vector, &mut nibbles, scratch);
929                (nibbles, gamma)
930            })
931            .collect();
932        for (&(doc_id, ordinal), (nibbles, gamma)) in labels.iter().zip(rows) {
933            self.doc_ids.push(doc_id);
934            self.ordinals.push(ordinal);
935            self.pending_rows.push(nibbles);
936            self.pending_gammas.push(gamma);
937            if self.pending_rows.len() == TQ_BLOCK_LANES {
938                self.flush_block();
939            }
940        }
941        Ok(())
942    }
943
944    fn flush_block(&mut self) {
945        let rows: Vec<&[u8]> = self.pending_rows.iter().map(Vec::as_slice).collect();
946        tq_pack_block(
947            &rows,
948            &self.pending_gammas,
949            self.codec.padded_dim(),
950            &mut self.codes,
951        );
952        self.pending_rows.clear();
953        self.pending_gammas.clear();
954    }
955
956    /// Flush the trailing partial block (zero-padded lanes).
957    pub fn finish(&mut self) {
958        if !self.pending_rows.is_empty() {
959            self.flush_block();
960        }
961        debug_assert_eq!(
962            self.codes.len(),
963            tq_codes_column_len(self.doc_ids.len(), self.codec.code_size())
964        );
965    }
966}
967
968#[cfg(test)]
969mod tests {
970    use super::*;
971
972    fn seeded_unit_vector(dim: usize, seed: u64) -> Vec<f32> {
973        // Box-Muller from splitmix64 for an isotropic direction.
974        let mut state = seed;
975        let mut values: Vec<f32> = (0..dim)
976            .map(|_| {
977                let a = (splitmix64(&mut state) >> 11) as f64 / (1u64 << 53) as f64;
978                let b = (splitmix64(&mut state) >> 11) as f64 / (1u64 << 53) as f64;
979                let gaussian = (-2.0 * (1.0 - a).max(f64::MIN_POSITIVE).ln()).sqrt()
980                    * (2.0 * std::f64::consts::PI * b).cos();
981                gaussian as f32
982            })
983            .collect();
984        let norm = values.iter().map(|v| v * v).sum::<f32>().sqrt();
985        values.iter_mut().for_each(|v| *v /= norm);
986        values
987    }
988
989    #[test]
990    fn rotation_is_orthonormal_and_deterministic() {
991        let rotation = TqRotation::new(100, 42);
992        assert_eq!(rotation.padded_dim(), 128);
993        let input = seeded_unit_vector(100, 7);
994        let mut fwht = Vec::new();
995        let mut output = vec![0.0f32; 128];
996        rotation.apply(&input, &mut fwht, &mut output);
997        let norm: f32 = output.iter().map(|v| v * v).sum();
998        assert!(
999            (norm - 1.0).abs() < 1e-5,
1000            "rotation must preserve norm, got {norm}"
1001        );
1002
1003        let mut second = vec![0.0f32; 128];
1004        TqRotation::new(100, 42).apply(&input, &mut fwht, &mut second);
1005        assert_eq!(output, second, "rotation must be deterministic");
1006
1007        // Distinct inputs keep their inner product (isometry).
1008        let other = seeded_unit_vector(100, 8);
1009        let mut other_rotated = vec![0.0f32; 128];
1010        rotation.apply(&other, &mut fwht, &mut other_rotated);
1011        let dot_before: f32 = input.iter().zip(&other).map(|(a, b)| a * b).sum();
1012        let dot_after: f32 = output.iter().zip(&other_rotated).map(|(a, b)| a * b).sum();
1013        assert!(
1014            (dot_before - dot_after).abs() < 1e-4,
1015            "rotation must preserve inner products: {dot_before} vs {dot_after}"
1016        );
1017    }
1018
1019    #[test]
1020    fn analytic_codebook_is_symmetric_and_monotonic() {
1021        for padded_dim in [8, 128, 1024] {
1022            let codebook = TqCodebook::analytic(padded_dim);
1023            let levels = &codebook.levels;
1024            for pair in levels.windows(2) {
1025                assert!(pair[0] < pair[1], "levels must be strictly increasing");
1026            }
1027            for index in 0..TQ_STAGE1_LEVELS / 2 {
1028                assert_eq!(
1029                    levels[index],
1030                    -levels[TQ_STAGE1_LEVELS - 1 - index],
1031                    "levels must be exactly symmetric for P={padded_dim}: {levels:?}"
1032                );
1033            }
1034            assert!(levels[TQ_STAGE1_LEVELS - 1] < 1.0);
1035            // Coordinates concentrate near ±1/sqrt(P); the top level must be
1036            // on that scale, not at the interval edge.
1037            let scale = 1.0 / (padded_dim as f32).sqrt();
1038            assert!(
1039                levels[TQ_STAGE1_LEVELS - 1] < 6.0 * scale,
1040                "top level {} is implausibly large for P={padded_dim}",
1041                levels[TQ_STAGE1_LEVELS - 1]
1042            );
1043        }
1044    }
1045
1046    #[test]
1047    fn encode_coordinate_matches_nearest_level() {
1048        let codebook = TqCodebook::analytic(256);
1049        for step in -1000i32..=1000 {
1050            let value = step as f32 / 1000.0;
1051            let code = codebook.encode_coordinate(value) as usize;
1052            let nearest = codebook
1053                .levels
1054                .iter()
1055                .enumerate()
1056                .min_by(|a, b| (a.1 - value).abs().total_cmp(&(b.1 - value).abs()))
1057                .unwrap()
1058                .0;
1059            assert_eq!(
1060                code, nearest,
1061                "value {value} coded {code}, nearest {nearest}"
1062            );
1063        }
1064    }
1065
1066    #[test]
1067    fn estimator_is_unbiased_and_tight() {
1068        let dim = 96;
1069        let codec = TqCodec::new(dim);
1070        let mut scratch = TqEncodeScratch::default();
1071        let mut nibbles = vec![0u8; codec.padded_dim()];
1072
1073        let pairs = 512;
1074        let mut signed_error_sum = 0.0f64;
1075        let mut squared_error_sum = 0.0f64;
1076        let mut stage1_signed_error_sum = 0.0f64;
1077        for pair in 0..pairs {
1078            let vector = seeded_unit_vector(dim, 1000 + pair);
1079            let query = seeded_unit_vector(dim, 900_000 + pair);
1080            let gamma = codec.encode_into(&vector, &mut nibbles, &mut scratch);
1081            let plan = TqQueryPlan::build(&codec, &query);
1082            let estimate = plan.estimate_row(&nibbles, gamma);
1083            let stage1_only = plan.estimate_row(&nibbles, 0.0);
1084            let truth: f32 = vector.iter().zip(&query).map(|(a, b)| a * b).sum();
1085            signed_error_sum += f64::from(estimate - truth);
1086            squared_error_sum += f64::from(estimate - truth).powi(2);
1087            stage1_signed_error_sum += f64::from(stage1_only - truth);
1088        }
1089        let mean_error = signed_error_sum / pairs as f64;
1090        let rmse = (squared_error_sum / pairs as f64).sqrt();
1091        let stage1_mean_error = stage1_signed_error_sum / pairs as f64;
1092        assert!(
1093            mean_error.abs() < 3e-3,
1094            "QJL-corrected estimator must be unbiased: mean error {mean_error}"
1095        );
1096        assert!(rmse < 0.05, "estimator RMSE too large: {rmse}");
1097        assert!(
1098            mean_error.abs() <= stage1_mean_error.abs() + 1e-4,
1099            "QJL correction must not increase bias: {mean_error} vs stage-1 {stage1_mean_error}"
1100        );
1101    }
1102
1103    #[test]
1104    fn block_scoring_matches_row_estimates() {
1105        let dim = 100; // padded to 128; exercises zero-padding
1106        let codec = TqCodec::new(dim);
1107        let mut scratch = TqEncodeScratch::default();
1108        let query = seeded_unit_vector(dim, 3);
1109        let plan = TqQueryPlan::build(&codec, &query);
1110
1111        let lanes = 13; // deliberately partial block
1112        let mut rows = Vec::new();
1113        let mut gammas = Vec::new();
1114        for lane in 0..lanes {
1115            let vector = seeded_unit_vector(dim, 50 + lane as u64);
1116            let mut nibbles = vec![0u8; codec.padded_dim()];
1117            let gamma = codec.encode_into(&vector, &mut nibbles, &mut scratch);
1118            rows.push(nibbles);
1119            gammas.push(gamma);
1120        }
1121        let row_refs: Vec<&[u8]> = rows.iter().map(Vec::as_slice).collect();
1122        let mut block = Vec::new();
1123        tq_pack_block(&row_refs, &gammas, codec.padded_dim(), &mut block);
1124        assert_eq!(block.len(), tq_block_bytes(codec.code_size()));
1125
1126        let mut scores = [0.0f32; TQ_BLOCK_LANES];
1127        tq_score_block(&plan, &block, &mut scores);
1128        for lane in 0..lanes {
1129            let expected = plan.estimate_row(&rows[lane], gammas[lane]);
1130            // The block path uses i8 LUTs; agreement is approximate.
1131            assert!(
1132                (scores[lane] - expected).abs() < 0.02,
1133                "lane {lane}: block score {} vs row estimate {expected}",
1134                scores[lane]
1135            );
1136        }
1137    }
1138
1139    #[test]
1140    fn simd_accumulation_matches_scalar_reference_exactly() {
1141        let padded_dim = 192; // not a multiple of the widen chunk
1142        let mut state = 99u64;
1143        let mut base_lut = vec![0i8; padded_dim * 16];
1144        let mut qjl_lut = vec![0i8; padded_dim * 16];
1145        for value in base_lut.iter_mut().chain(qjl_lut.iter_mut()) {
1146            *value = (splitmix64(&mut state) as i32 % 255 - 127) as i8;
1147        }
1148        let mut nibble_bytes = vec![0u8; padded_dim * 8];
1149        for byte in nibble_bytes.iter_mut() {
1150            *byte = splitmix64(&mut state) as u8;
1151        }
1152
1153        let mut base_reference = [0i32; TQ_BLOCK_LANES];
1154        let mut qjl_reference = [0i32; TQ_BLOCK_LANES];
1155        lut16::accumulate_block_scalar(
1156            &base_lut,
1157            &qjl_lut,
1158            &nibble_bytes,
1159            padded_dim,
1160            &mut base_reference,
1161            &mut qjl_reference,
1162        );
1163        let mut base_dispatch = [0i32; TQ_BLOCK_LANES];
1164        let mut qjl_dispatch = [0i32; TQ_BLOCK_LANES];
1165        lut16::accumulate_block(
1166            &base_lut,
1167            &qjl_lut,
1168            &nibble_bytes,
1169            padded_dim,
1170            &mut base_dispatch,
1171            &mut qjl_dispatch,
1172        );
1173        assert_eq!(
1174            base_reference, base_dispatch,
1175            "base sums must match exactly"
1176        );
1177        assert_eq!(qjl_reference, qjl_dispatch, "qjl sums must match exactly");
1178    }
1179
1180    #[test]
1181    fn fingerprint_pins_codec_constants() {
1182        let codec = TqCodec::new(768);
1183        assert_eq!(codec.padded_dim(), 1024);
1184        assert_eq!(codec.code_size(), 512);
1185        assert_ne!(codec.fingerprint(), 0);
1186        assert_eq!(codec.fingerprint(), TqCodec::new(768).fingerprint());
1187        assert_ne!(codec.fingerprint(), TqCodec::new(769).fingerprint());
1188        // Golden value: the fingerprint is persisted as quantizer_version in
1189        // every TQ segment. Any change to the hash, seeds, or codec constants
1190        // MUST bump TQ_CODEC_VERSION — never silently re-derive.
1191        assert_eq!(
1192            codec.fingerprint(),
1193            GOLDEN_FINGERPRINT_768,
1194            "TQ fingerprint for dim 768 changed; existing segments would be \
1195             rejected. Bump TQ_CODEC_VERSION deliberately instead."
1196        );
1197    }
1198
1199    const GOLDEN_FINGERPRINT_768: u64 = 4674713535241508736;
1200
1201    #[cfg(feature = "native")]
1202    #[test]
1203    fn builder_packs_blocks_in_input_order() {
1204        let dim = 32;
1205        let codec = std::sync::Arc::new(TqCodec::new(dim));
1206        let mut builder = TqFlatBuilder::new(std::sync::Arc::clone(&codec));
1207        let count = 37; // two full blocks + partial
1208        let labels: Vec<(u32, u16)> = (0..count).map(|i| (i as u32, (i % 3) as u16)).collect();
1209        let mut vectors = Vec::new();
1210        for i in 0..count {
1211            vectors.extend(seeded_unit_vector(dim, 7_000 + i as u64));
1212        }
1213        builder.add_batch(&labels, &vectors).unwrap();
1214        builder.finish();
1215        assert_eq!(builder.doc_ids.len(), count);
1216        assert_eq!(
1217            builder.codes.len(),
1218            tq_codes_column_len(count, codec.code_size())
1219        );
1220
1221        // Every lane must score identically to encoding the row directly.
1222        let query = seeded_unit_vector(dim, 1);
1223        let plan = TqQueryPlan::build(&codec, &query);
1224        let block_bytes = tq_block_bytes(codec.code_size());
1225        let mut scratch = TqEncodeScratch::default();
1226        let mut scores = [0.0f32; TQ_BLOCK_LANES];
1227        for index in 0..count {
1228            let block = &builder.codes[(index / TQ_BLOCK_LANES) * block_bytes..][..block_bytes];
1229            tq_score_block(&plan, block, &mut scores);
1230            let mut nibbles = vec![0u8; codec.padded_dim()];
1231            let gamma = codec.encode_into(
1232                &vectors[index * dim..(index + 1) * dim],
1233                &mut nibbles,
1234                &mut scratch,
1235            );
1236            let expected = plan.estimate_row(&nibbles, gamma);
1237            assert!(
1238                (scores[index % TQ_BLOCK_LANES] - expected).abs() < 0.02,
1239                "vector {index} scored {} expected {expected}",
1240                scores[index % TQ_BLOCK_LANES]
1241            );
1242        }
1243    }
1244}