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