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    base_lut_i8: Vec<i8>,
519    qjl_lut_i8: Vec<i8>,
520    base_dequant: f32,
521    qjl_dequant: f32,
522    /// Full-precision tables, retained for the reference estimator in tests.
523    #[cfg(test)]
524    reference_luts: (Vec<f32>, Vec<f32>),
525}
526
527impl std::fmt::Debug for TqQueryPlan {
528    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
529        formatter
530            .debug_struct("TqQueryPlan")
531            .field("padded_dim", &self.padded_dim)
532            .field("fingerprint", &self.fingerprint)
533            .finish()
534    }
535}
536
537impl TqQueryPlan {
538    pub fn build(codec: &TqCodec, query: &[f32]) -> Self {
539        assert_eq!(query.len(), codec.dim, "TQ query dimension mismatch");
540        let padded_dim = codec.padded_dim;
541        let norm = crate::structures::simd::dot_product_f32(query, query, query.len()).sqrt();
542        let inverse_norm = if norm.is_finite() && norm > 0.0 {
543            1.0 / norm
544        } else {
545            0.0
546        };
547        let normalized: Vec<f32> = query.iter().map(|value| value * inverse_norm).collect();
548        let mut fwht = Vec::with_capacity(padded_dim);
549        let mut rotated = vec![0.0f32; padded_dim];
550        codec
551            .stage1_rotation
552            .apply(&normalized, &mut fwht, &mut rotated);
553        let mut qjl_rotated = vec![0.0f32; padded_dim];
554        codec
555            .qjl_rotation
556            .apply(&rotated, &mut fwht, &mut qjl_rotated);
557
558        let mut base_lut = vec![0.0f32; padded_dim * 16];
559        let mut qjl_lut = vec![0.0f32; padded_dim * 16];
560        for dim in 0..padded_dim {
561            for nibble in 0..16 {
562                let level = codec.codebook.levels[nibble >> 1];
563                let sign = if nibble & 1 == 1 { 1.0 } else { -1.0 };
564                base_lut[dim * 16 + nibble] = rotated[dim] * level;
565                qjl_lut[dim * 16 + nibble] = sign * codec.qjl_scale * qjl_rotated[dim];
566            }
567        }
568        let (base_lut_i8, base_dequant) = quantize_lut(&base_lut);
569        let (qjl_lut_i8, qjl_dequant) = quantize_lut(&qjl_lut);
570        Self {
571            padded_dim,
572            fingerprint: codec.fingerprint,
573            base_lut_i8,
574            qjl_lut_i8,
575            base_dequant,
576            qjl_dequant,
577            #[cfg(test)]
578            reference_luts: (base_lut, qjl_lut),
579        }
580    }
581
582    #[inline]
583    pub fn padded_dim(&self) -> usize {
584        self.padded_dim
585    }
586
587    #[inline]
588    pub fn fingerprint(&self) -> u64 {
589        self.fingerprint
590    }
591
592    /// Reference f32 estimator over one unpacked nibble row (test oracle for
593    /// the quantized block path).
594    #[cfg(test)]
595    pub(crate) fn estimate_row(&self, nibbles: &[u8], gamma: f32) -> f32 {
596        debug_assert_eq!(nibbles.len(), self.padded_dim);
597        let (base_lut, qjl_lut) = &self.reference_luts;
598        let mut base = 0.0f32;
599        let mut qjl = 0.0f32;
600        for (dim, &nibble) in nibbles.iter().enumerate() {
601            base += base_lut[dim * 16 + nibble as usize];
602            qjl += qjl_lut[dim * 16 + nibble as usize];
603        }
604        base + gamma * qjl
605    }
606}
607
608fn quantize_lut(values: &[f32]) -> (Vec<i8>, f32) {
609    let max_abs = values
610        .iter()
611        .fold(0.0f32, |acc, &value| acc.max(value.abs()));
612    if !max_abs.is_finite() || max_abs <= 0.0 {
613        return (vec![0i8; values.len()], 0.0);
614    }
615    let quantize_scale = 127.0 / max_abs;
616    let quantized = values
617        .iter()
618        .map(|&value| (value * quantize_scale).round().clamp(-127.0, 127.0) as i8)
619        .collect();
620    (quantized, max_abs / 127.0)
621}
622
623/// Score one block (16 lanes) into `scores`. `block` is
624/// `[16 × f32 gamma][padded_dim × 8 packed nibbles]`; lanes past the run's
625/// vector count hold zero padding and must be ignored by the caller.
626pub fn tq_score_block(plan: &TqQueryPlan, block: &[u8], scores: &mut [f32; TQ_BLOCK_LANES]) {
627    debug_assert_eq!(block.len(), tq_block_bytes(plan.padded_dim / 2));
628    let (gamma_bytes, nibble_bytes) = block.split_at(TQ_BLOCK_LANES * size_of::<f32>());
629    let mut base = [0i32; TQ_BLOCK_LANES];
630    let mut qjl = [0i32; TQ_BLOCK_LANES];
631    lut16::accumulate_block(
632        &plan.base_lut_i8,
633        &plan.qjl_lut_i8,
634        nibble_bytes,
635        plan.padded_dim,
636        &mut base,
637        &mut qjl,
638    );
639    for lane in 0..TQ_BLOCK_LANES {
640        let gamma = f32::from_le_bytes(
641            gamma_bytes[lane * 4..lane * 4 + 4]
642                .try_into()
643                .expect("gamma slice is 4 bytes"),
644        );
645        scores[lane] =
646            base[lane] as f32 * plan.base_dequant + gamma * qjl[lane] as f32 * plan.qjl_dequant;
647    }
648}
649
650/// Pack up to 16 nibble rows (+ gammas) into one block. Missing lanes are
651/// zero-filled. `rows` are `padded_dim`-length 0..=15 values.
652pub fn tq_pack_block(rows: &[&[u8]], gammas: &[f32], padded_dim: usize, output: &mut Vec<u8>) {
653    assert!(rows.len() <= TQ_BLOCK_LANES && rows.len() == gammas.len());
654    for lane in 0..TQ_BLOCK_LANES {
655        let gamma = gammas.get(lane).copied().unwrap_or(0.0);
656        output.extend_from_slice(&gamma.to_le_bytes());
657    }
658    pack_nibble_rows(rows, padded_dim, output);
659}
660
661/// Pack an IVF-TQ block: per-lane residual scales, gammas, then nibbles.
662pub fn tq_pack_ivf_block(
663    rows: &[&[u8]],
664    scales: &[f32],
665    gammas: &[f32],
666    padded_dim: usize,
667    output: &mut Vec<u8>,
668) {
669    assert!(rows.len() <= TQ_BLOCK_LANES && rows.len() == gammas.len());
670    assert_eq!(scales.len(), gammas.len());
671    for lane in 0..TQ_BLOCK_LANES {
672        let scale = scales.get(lane).copied().unwrap_or(0.0);
673        output.extend_from_slice(&scale.to_le_bytes());
674    }
675    for lane in 0..TQ_BLOCK_LANES {
676        let gamma = gammas.get(lane).copied().unwrap_or(0.0);
677        output.extend_from_slice(&gamma.to_le_bytes());
678    }
679    pack_nibble_rows(rows, padded_dim, output);
680}
681
682fn pack_nibble_rows(rows: &[&[u8]], padded_dim: usize, output: &mut Vec<u8>) {
683    for dim in 0..padded_dim {
684        for byte_index in 0..TQ_BLOCK_LANES / 2 {
685            let low = rows.get(byte_index).map_or(0, |row| row[dim] & 0x0F);
686            let high = rows
687                .get(byte_index + TQ_BLOCK_LANES / 2)
688                .map_or(0, |row| row[dim] & 0x0F);
689            output.push(low | (high << 4));
690        }
691    }
692}
693
694/// Score one IVF-TQ block: `score[lane] = cluster_dot + scale · (base +
695/// gamma · qjl)`, where `cluster_dot = ⟨normalized query, centroid⟩` is the
696/// probed cluster's shared contribution and `scale = ‖residual‖`.
697pub fn tq_score_ivf_block(
698    plan: &TqQueryPlan,
699    block: &[u8],
700    cluster_dot: f32,
701    scores: &mut [f32; TQ_BLOCK_LANES],
702) {
703    debug_assert_eq!(block.len(), tq_ivf_block_bytes(plan.padded_dim() / 2));
704    let lane_f32 = TQ_BLOCK_LANES * size_of::<f32>();
705    let (scale_bytes, rest) = block.split_at(lane_f32);
706    let (gamma_bytes, nibble_bytes) = rest.split_at(lane_f32);
707    let mut base = [0i32; TQ_BLOCK_LANES];
708    let mut qjl = [0i32; TQ_BLOCK_LANES];
709    lut16::accumulate_block(
710        &plan.base_lut_i8,
711        &plan.qjl_lut_i8,
712        nibble_bytes,
713        plan.padded_dim,
714        &mut base,
715        &mut qjl,
716    );
717    for lane in 0..TQ_BLOCK_LANES {
718        let scale = f32::from_le_bytes(
719            scale_bytes[lane * 4..lane * 4 + 4]
720                .try_into()
721                .expect("scale slice is 4 bytes"),
722        );
723        let gamma = f32::from_le_bytes(
724            gamma_bytes[lane * 4..lane * 4 + 4]
725                .try_into()
726                .expect("gamma slice is 4 bytes"),
727        );
728        scores[lane] = cluster_dot
729            + scale
730                * (base[lane] as f32 * plan.base_dequant
731                    + gamma * qjl[lane] as f32 * plan.qjl_dequant);
732    }
733}
734
735mod lut16 {
736    #[cfg(any(target_arch = "aarch64", target_arch = "x86_64"))]
737    use super::TQ_ACCUMULATE_CHUNK_DIMS;
738    use super::TQ_BLOCK_LANES;
739
740    /// Accumulate both LUT sums for 16 lanes over all dimensions.
741    /// `nibble_bytes` is dimension-major: 8 bytes per dimension, byte `j`
742    /// holding lane `j` (low nibble) and lane `j + 8` (high nibble).
743    pub(super) fn accumulate_block(
744        base_lut: &[i8],
745        qjl_lut: &[i8],
746        nibble_bytes: &[u8],
747        padded_dim: usize,
748        base: &mut [i32; TQ_BLOCK_LANES],
749        qjl: &mut [i32; TQ_BLOCK_LANES],
750    ) {
751        #[cfg(target_arch = "aarch64")]
752        {
753            // NEON is baseline on aarch64.
754            unsafe { accumulate_block_neon(base_lut, qjl_lut, nibble_bytes, padded_dim, base, qjl) }
755            return;
756        }
757        #[cfg(target_arch = "x86_64")]
758        {
759            if std::arch::is_x86_feature_detected!("avx2") {
760                unsafe {
761                    accumulate_block_avx2(base_lut, qjl_lut, nibble_bytes, padded_dim, base, qjl)
762                }
763                return;
764            }
765            if std::arch::is_x86_feature_detected!("ssse3") {
766                unsafe {
767                    accumulate_block_ssse3(base_lut, qjl_lut, nibble_bytes, padded_dim, base, qjl)
768                }
769                return;
770            }
771        }
772        #[allow(unreachable_code)]
773        accumulate_block_scalar(base_lut, qjl_lut, nibble_bytes, padded_dim, base, qjl);
774    }
775
776    /// Scalar fallback mirroring the SIMD integer arithmetic exactly
777    /// (i8 lookups, i32 sums), so all paths agree bit-for-bit.
778    pub(super) fn accumulate_block_scalar(
779        base_lut: &[i8],
780        qjl_lut: &[i8],
781        nibble_bytes: &[u8],
782        padded_dim: usize,
783        base: &mut [i32; TQ_BLOCK_LANES],
784        qjl: &mut [i32; TQ_BLOCK_LANES],
785    ) {
786        for dim in 0..padded_dim {
787            let row = &nibble_bytes[dim * 8..dim * 8 + 8];
788            let base_table = &base_lut[dim * 16..dim * 16 + 16];
789            let qjl_table = &qjl_lut[dim * 16..dim * 16 + 16];
790            for (lane, &byte) in row.iter().enumerate() {
791                let low = (byte & 0x0F) as usize;
792                let high = (byte >> 4) as usize;
793                base[lane] += i32::from(base_table[low]);
794                base[lane + 8] += i32::from(base_table[high]);
795                qjl[lane] += i32::from(qjl_table[low]);
796                qjl[lane + 8] += i32::from(qjl_table[high]);
797            }
798        }
799    }
800
801    #[cfg(target_arch = "aarch64")]
802    #[target_feature(enable = "neon")]
803    unsafe fn accumulate_block_neon(
804        base_lut: &[i8],
805        qjl_lut: &[i8],
806        nibble_bytes: &[u8],
807        padded_dim: usize,
808        base: &mut [i32; TQ_BLOCK_LANES],
809        qjl: &mut [i32; TQ_BLOCK_LANES],
810    ) {
811        use std::arch::aarch64::*;
812        unsafe {
813            let mask = vdup_n_u8(0x0F);
814            let mut base_lo_i32 = [vdupq_n_s32(0); 4];
815            let mut qjl_lo_i32 = [vdupq_n_s32(0); 4];
816            let mut dim = 0;
817            while dim < padded_dim {
818                let chunk_end = (dim + TQ_ACCUMULATE_CHUNK_DIMS).min(padded_dim);
819                let mut base_acc = [vdupq_n_s16(0); 2];
820                let mut qjl_acc = [vdupq_n_s16(0); 2];
821                while dim < chunk_end {
822                    let row = vld1_u8(nibble_bytes.as_ptr().add(dim * 8));
823                    let low = vand_u8(row, mask);
824                    let high = vshr_n_u8::<4>(row);
825                    let lanes = vcombine_u8(low, high);
826                    let base_table = vld1q_s8(base_lut.as_ptr().add(dim * 16));
827                    let qjl_table = vld1q_s8(qjl_lut.as_ptr().add(dim * 16));
828                    let base_values =
829                        vreinterpretq_s8_u8(vqtbl1q_u8(vreinterpretq_u8_s8(base_table), lanes));
830                    let qjl_values =
831                        vreinterpretq_s8_u8(vqtbl1q_u8(vreinterpretq_u8_s8(qjl_table), lanes));
832                    base_acc[0] = vaddw_s8(base_acc[0], vget_low_s8(base_values));
833                    base_acc[1] = vaddw_s8(base_acc[1], vget_high_s8(base_values));
834                    qjl_acc[0] = vaddw_s8(qjl_acc[0], vget_low_s8(qjl_values));
835                    qjl_acc[1] = vaddw_s8(qjl_acc[1], vget_high_s8(qjl_values));
836                    dim += 1;
837                }
838                for half in 0..2 {
839                    base_lo_i32[half * 2] =
840                        vaddw_s16(base_lo_i32[half * 2], vget_low_s16(base_acc[half]));
841                    base_lo_i32[half * 2 + 1] =
842                        vaddw_s16(base_lo_i32[half * 2 + 1], vget_high_s16(base_acc[half]));
843                    qjl_lo_i32[half * 2] =
844                        vaddw_s16(qjl_lo_i32[half * 2], vget_low_s16(qjl_acc[half]));
845                    qjl_lo_i32[half * 2 + 1] =
846                        vaddw_s16(qjl_lo_i32[half * 2 + 1], vget_high_s16(qjl_acc[half]));
847                }
848            }
849            for quarter in 0..4 {
850                vst1q_s32(base.as_mut_ptr().add(quarter * 4), base_lo_i32[quarter]);
851                vst1q_s32(qjl.as_mut_ptr().add(quarter * 4), qjl_lo_i32[quarter]);
852            }
853        }
854    }
855
856    /// AVX2: two dimensions per iteration. Adjacent dims' packed rows are
857    /// contiguous (8 bytes each) and so are their 16-entry LUTs, so one 16-byte
858    /// row load + one 32-byte LUT load + a 256-bit `vpshufb` covers both.
859    #[cfg(target_arch = "x86_64")]
860    #[target_feature(enable = "avx2")]
861    unsafe fn accumulate_block_avx2(
862        base_lut: &[i8],
863        qjl_lut: &[i8],
864        nibble_bytes: &[u8],
865        padded_dim: usize,
866        base: &mut [i32; TQ_BLOCK_LANES],
867        qjl: &mut [i32; TQ_BLOCK_LANES],
868    ) {
869        use std::arch::x86_64::*;
870        unsafe {
871            let mask = _mm_set1_epi8(0x0F);
872            let zero256 = _mm256_setzero_si256();
873            let mut base_i32 = [zero256; 2];
874            let mut qjl_i32 = [zero256; 2];
875            let mut dim = 0;
876            while dim < padded_dim {
877                let chunk_end = (dim + TQ_ACCUMULATE_CHUNK_DIMS).min(padded_dim);
878                let mut base_acc = zero256;
879                let mut qjl_acc = zero256;
880                while dim + 2 <= chunk_end {
881                    // Bytes [dim*8, dim*8+16): rows for `dim` and `dim + 1`.
882                    let rows = _mm_loadu_si128(nibble_bytes.as_ptr().add(dim * 8).cast());
883                    let low = _mm_and_si128(rows, mask);
884                    let high = _mm_and_si128(_mm_srli_epi16(rows, 4), mask);
885                    // Lanes 0..16 of each dim: [low.q0 | high.q0], [low.q1 | high.q1].
886                    let lanes_first = _mm_unpacklo_epi64(low, high);
887                    let lanes_second = _mm_unpackhi_epi64(low, high);
888                    let lanes = _mm256_inserti128_si256(
889                        _mm256_castsi128_si256(lanes_first),
890                        lanes_second,
891                        1,
892                    );
893                    let base_tables = _mm256_loadu_si256(base_lut.as_ptr().add(dim * 16).cast());
894                    let qjl_tables = _mm256_loadu_si256(qjl_lut.as_ptr().add(dim * 16).cast());
895                    let base_values = _mm256_shuffle_epi8(base_tables, lanes);
896                    let qjl_values = _mm256_shuffle_epi8(qjl_tables, lanes);
897                    base_acc = _mm256_add_epi16(
898                        base_acc,
899                        _mm256_cvtepi8_epi16(_mm256_castsi256_si128(base_values)),
900                    );
901                    base_acc = _mm256_add_epi16(
902                        base_acc,
903                        _mm256_cvtepi8_epi16(_mm256_extracti128_si256(base_values, 1)),
904                    );
905                    qjl_acc = _mm256_add_epi16(
906                        qjl_acc,
907                        _mm256_cvtepi8_epi16(_mm256_castsi256_si128(qjl_values)),
908                    );
909                    qjl_acc = _mm256_add_epi16(
910                        qjl_acc,
911                        _mm256_cvtepi8_epi16(_mm256_extracti128_si256(qjl_values, 1)),
912                    );
913                    dim += 2;
914                }
915                // Odd remainder dim within the chunk.
916                while dim < chunk_end {
917                    let row = _mm_loadl_epi64(nibble_bytes.as_ptr().add(dim * 8).cast());
918                    let low = _mm_and_si128(row, mask);
919                    let high = _mm_and_si128(_mm_srli_epi16(row, 4), mask);
920                    let lanes = _mm_unpacklo_epi64(low, high);
921                    let base_table = _mm_loadu_si128(base_lut.as_ptr().add(dim * 16).cast());
922                    let qjl_table = _mm_loadu_si128(qjl_lut.as_ptr().add(dim * 16).cast());
923                    base_acc = _mm256_add_epi16(
924                        base_acc,
925                        _mm256_cvtepi8_epi16(_mm_shuffle_epi8(base_table, lanes)),
926                    );
927                    qjl_acc = _mm256_add_epi16(
928                        qjl_acc,
929                        _mm256_cvtepi8_epi16(_mm_shuffle_epi8(qjl_table, lanes)),
930                    );
931                    dim += 1;
932                }
933                for (accumulators, chunk) in [(&mut base_i32, base_acc), (&mut qjl_i32, qjl_acc)] {
934                    accumulators[0] = _mm256_add_epi32(
935                        accumulators[0],
936                        _mm256_cvtepi16_epi32(_mm256_castsi256_si128(chunk)),
937                    );
938                    accumulators[1] = _mm256_add_epi32(
939                        accumulators[1],
940                        _mm256_cvtepi16_epi32(_mm256_extracti128_si256(chunk, 1)),
941                    );
942                }
943            }
944            _mm256_storeu_si256(base.as_mut_ptr().cast(), base_i32[0]);
945            _mm256_storeu_si256(base.as_mut_ptr().add(8).cast(), base_i32[1]);
946            _mm256_storeu_si256(qjl.as_mut_ptr().cast(), qjl_i32[0]);
947            _mm256_storeu_si256(qjl.as_mut_ptr().add(8).cast(), qjl_i32[1]);
948        }
949    }
950
951    #[cfg(target_arch = "x86_64")]
952    #[target_feature(enable = "ssse3")]
953    unsafe fn accumulate_block_ssse3(
954        base_lut: &[i8],
955        qjl_lut: &[i8],
956        nibble_bytes: &[u8],
957        padded_dim: usize,
958        base: &mut [i32; TQ_BLOCK_LANES],
959        qjl: &mut [i32; TQ_BLOCK_LANES],
960    ) {
961        use std::arch::x86_64::*;
962        unsafe {
963            let mask = _mm_set1_epi8(0x0F);
964            let zero = _mm_setzero_si128();
965            let mut base_i32 = [zero; 4];
966            let mut qjl_i32 = [zero; 4];
967            let mut dim = 0;
968            while dim < padded_dim {
969                let chunk_end = (dim + TQ_ACCUMULATE_CHUNK_DIMS).min(padded_dim);
970                let mut base_acc = [zero; 2];
971                let mut qjl_acc = [zero; 2];
972                while dim < chunk_end {
973                    let row = _mm_loadl_epi64(nibble_bytes.as_ptr().add(dim * 8).cast());
974                    let low = _mm_and_si128(row, mask);
975                    let high = _mm_and_si128(_mm_srli_epi16(row, 4), mask);
976                    let lanes = _mm_unpacklo_epi64(low, high);
977                    let base_table = _mm_loadu_si128(base_lut.as_ptr().add(dim * 16).cast());
978                    let qjl_table = _mm_loadu_si128(qjl_lut.as_ptr().add(dim * 16).cast());
979                    let base_values = _mm_shuffle_epi8(base_table, lanes);
980                    let qjl_values = _mm_shuffle_epi8(qjl_table, lanes);
981                    // Sign-extend i8 → i16 without SSE4.1: compare-based sign mask.
982                    let base_sign = _mm_cmpgt_epi8(zero, base_values);
983                    let qjl_sign = _mm_cmpgt_epi8(zero, qjl_values);
984                    base_acc[0] =
985                        _mm_add_epi16(base_acc[0], _mm_unpacklo_epi8(base_values, base_sign));
986                    base_acc[1] =
987                        _mm_add_epi16(base_acc[1], _mm_unpackhi_epi8(base_values, base_sign));
988                    qjl_acc[0] = _mm_add_epi16(qjl_acc[0], _mm_unpacklo_epi8(qjl_values, qjl_sign));
989                    qjl_acc[1] = _mm_add_epi16(qjl_acc[1], _mm_unpackhi_epi8(qjl_values, qjl_sign));
990                    dim += 1;
991                }
992                for half in 0..2 {
993                    let base_sign = _mm_cmpgt_epi16(zero, base_acc[half]);
994                    let qjl_sign = _mm_cmpgt_epi16(zero, qjl_acc[half]);
995                    base_i32[half * 2] = _mm_add_epi32(
996                        base_i32[half * 2],
997                        _mm_unpacklo_epi16(base_acc[half], base_sign),
998                    );
999                    base_i32[half * 2 + 1] = _mm_add_epi32(
1000                        base_i32[half * 2 + 1],
1001                        _mm_unpackhi_epi16(base_acc[half], base_sign),
1002                    );
1003                    qjl_i32[half * 2] = _mm_add_epi32(
1004                        qjl_i32[half * 2],
1005                        _mm_unpacklo_epi16(qjl_acc[half], qjl_sign),
1006                    );
1007                    qjl_i32[half * 2 + 1] = _mm_add_epi32(
1008                        qjl_i32[half * 2 + 1],
1009                        _mm_unpackhi_epi16(qjl_acc[half], qjl_sign),
1010                    );
1011                }
1012            }
1013            for quarter in 0..4 {
1014                _mm_storeu_si128(base.as_mut_ptr().add(quarter * 4).cast(), base_i32[quarter]);
1015                _mm_storeu_si128(qjl.as_mut_ptr().add(quarter * 4).cast(), qjl_i32[quarter]);
1016            }
1017        }
1018    }
1019}
1020
1021// ---------------------------------------------------------------------------
1022// Segment build support
1023// ---------------------------------------------------------------------------
1024
1025/// Streaming builder for one segment's TQ payload: doc/ordinal columns plus a
1026/// block-packed codes column ready for `ann_disk` serialization.
1027#[cfg(feature = "native")]
1028pub struct TqFlatBuilder {
1029    codec: std::sync::Arc<TqCodec>,
1030    pub doc_ids: Vec<u32>,
1031    pub ordinals: Vec<u16>,
1032    pub codes: Vec<u8>,
1033    pending_rows: Vec<Vec<u8>>,
1034    pending_gammas: Vec<f32>,
1035}
1036
1037#[cfg(feature = "native")]
1038impl TqFlatBuilder {
1039    pub fn new(codec: std::sync::Arc<TqCodec>) -> Self {
1040        Self {
1041            codec,
1042            doc_ids: Vec::new(),
1043            ordinals: Vec::new(),
1044            codes: Vec::new(),
1045            pending_rows: Vec::with_capacity(TQ_BLOCK_LANES),
1046            pending_gammas: Vec::with_capacity(TQ_BLOCK_LANES),
1047        }
1048    }
1049
1050    #[inline]
1051    pub fn codec(&self) -> &TqCodec {
1052        &self.codec
1053    }
1054
1055    #[inline]
1056    pub fn len(&self) -> usize {
1057        self.doc_ids.len()
1058    }
1059
1060    #[inline]
1061    pub fn is_empty(&self) -> bool {
1062        self.doc_ids.is_empty()
1063    }
1064
1065    /// Encode one contiguous `(labels, vectors)` batch in parallel while
1066    /// preserving input order (lane order must match the doc-ID column).
1067    pub fn add_batch(
1068        &mut self,
1069        labels: &[(u32, u16)],
1070        vectors: &[f32],
1071    ) -> Result<(), &'static str> {
1072        use rayon::prelude::*;
1073
1074        let dim = self.codec.dim();
1075        let vector_count = labels.len();
1076        let expected = vector_count
1077            .checked_mul(dim)
1078            .ok_or("TQ input size overflow")?;
1079        if vectors.len() != expected {
1080            return Err("TQ vector and label matrices are inconsistent");
1081        }
1082        let padded_dim = self.codec.padded_dim();
1083        let codec = std::sync::Arc::clone(&self.codec);
1084        // One contiguous nibble matrix instead of a Vec per vector: each
1085        // Rayon task writes its disjoint row range (allocation hygiene on
1086        // the ingest/merge path).
1087        let mut rows = vec![0u8; vector_count * padded_dim];
1088        let mut gammas = vec![0.0f32; vector_count];
1089        vectors
1090            .par_chunks_exact(dim)
1091            .zip(rows.par_chunks_exact_mut(padded_dim))
1092            .zip(gammas.par_iter_mut())
1093            .for_each_init(
1094                TqEncodeScratch::default,
1095                |scratch, ((vector, row), gamma)| {
1096                    *gamma = codec.encode_into(vector, row, scratch);
1097                },
1098            );
1099
1100        // Top up a carried-over partial block, pack full blocks straight from
1101        // the contiguous matrix (no per-row copies), and carry the tail.
1102        let mut index = 0;
1103        while index < vector_count && !self.pending_rows.is_empty() {
1104            let (doc_id, ordinal) = labels[index];
1105            self.doc_ids.push(doc_id);
1106            self.ordinals.push(ordinal);
1107            self.pending_rows
1108                .push(rows[index * padded_dim..(index + 1) * padded_dim].to_vec());
1109            self.pending_gammas.push(gammas[index]);
1110            if self.pending_rows.len() == TQ_BLOCK_LANES {
1111                self.flush_block();
1112            }
1113            index += 1;
1114        }
1115        while vector_count - index >= TQ_BLOCK_LANES {
1116            let row_refs: Vec<&[u8]> = (0..TQ_BLOCK_LANES)
1117                .map(|lane| {
1118                    let row = index + lane;
1119                    &rows[row * padded_dim..(row + 1) * padded_dim]
1120                })
1121                .collect();
1122            tq_pack_block(
1123                &row_refs,
1124                &gammas[index..index + TQ_BLOCK_LANES],
1125                padded_dim,
1126                &mut self.codes,
1127            );
1128            for &(doc_id, ordinal) in &labels[index..index + TQ_BLOCK_LANES] {
1129                self.doc_ids.push(doc_id);
1130                self.ordinals.push(ordinal);
1131            }
1132            index += TQ_BLOCK_LANES;
1133        }
1134        for row in index..vector_count {
1135            let (doc_id, ordinal) = labels[row];
1136            self.doc_ids.push(doc_id);
1137            self.ordinals.push(ordinal);
1138            self.pending_rows
1139                .push(rows[row * padded_dim..(row + 1) * padded_dim].to_vec());
1140            self.pending_gammas.push(gammas[row]);
1141        }
1142        Ok(())
1143    }
1144
1145    fn flush_block(&mut self) {
1146        let rows: Vec<&[u8]> = self.pending_rows.iter().map(Vec::as_slice).collect();
1147        tq_pack_block(
1148            &rows,
1149            &self.pending_gammas,
1150            self.codec.padded_dim(),
1151            &mut self.codes,
1152        );
1153        self.pending_rows.clear();
1154        self.pending_gammas.clear();
1155    }
1156
1157    /// Flush the trailing partial block (zero-padded lanes).
1158    pub fn finish(&mut self) {
1159        if !self.pending_rows.is_empty() {
1160            self.flush_block();
1161        }
1162        debug_assert_eq!(
1163            self.codes.len(),
1164            tq_codes_column_len(self.doc_ids.len(), self.codec.code_size())
1165        );
1166    }
1167}
1168
1169#[cfg(test)]
1170mod tests {
1171    use super::*;
1172
1173    fn seeded_unit_vector(dim: usize, seed: u64) -> Vec<f32> {
1174        // Box-Muller from splitmix64 for an isotropic direction.
1175        let mut state = seed;
1176        let mut values: Vec<f32> = (0..dim)
1177            .map(|_| {
1178                let a = (splitmix64(&mut state) >> 11) as f64 / (1u64 << 53) as f64;
1179                let b = (splitmix64(&mut state) >> 11) as f64 / (1u64 << 53) as f64;
1180                let gaussian = (-2.0 * (1.0 - a).max(f64::MIN_POSITIVE).ln()).sqrt()
1181                    * (2.0 * std::f64::consts::PI * b).cos();
1182                gaussian as f32
1183            })
1184            .collect();
1185        let norm = values.iter().map(|v| v * v).sum::<f32>().sqrt();
1186        values.iter_mut().for_each(|v| *v /= norm);
1187        values
1188    }
1189
1190    #[test]
1191    fn rotation_is_orthonormal_and_deterministic() {
1192        let rotation = TqRotation::new(100, 42);
1193        assert_eq!(rotation.padded_dim(), 100);
1194        let mut fwht_scratch = Vec::new();
1195        let mut probe = vec![0.0f32; 100];
1196        // Odd dims round up by one zero coordinate.
1197        assert_eq!(TqRotation::new(99, 42).padded_dim(), 100);
1198        TqRotation::new(99, 42).apply(&vec![1.0; 99], &mut fwht_scratch, &mut probe);
1199        let input = seeded_unit_vector(100, 7);
1200        let mut fwht = Vec::new();
1201        let mut output = vec![0.0f32; 100];
1202        rotation.apply(&input, &mut fwht, &mut output);
1203        let norm: f32 = output.iter().map(|v| v * v).sum();
1204        assert!(
1205            (norm - 1.0).abs() < 1e-5,
1206            "rotation must preserve norm, got {norm}"
1207        );
1208
1209        let mut second = vec![0.0f32; 100];
1210        TqRotation::new(100, 42).apply(&input, &mut fwht, &mut second);
1211        assert_eq!(output, second, "rotation must be deterministic");
1212
1213        // Distinct inputs keep their inner product (isometry).
1214        let other = seeded_unit_vector(100, 8);
1215        let mut other_rotated = vec![0.0f32; 100];
1216        rotation.apply(&other, &mut fwht, &mut other_rotated);
1217        let dot_before: f32 = input.iter().zip(&other).map(|(a, b)| a * b).sum();
1218        let dot_after: f32 = output.iter().zip(&other_rotated).map(|(a, b)| a * b).sum();
1219        assert!(
1220            (dot_before - dot_after).abs() < 1e-4,
1221            "rotation must preserve inner products: {dot_before} vs {dot_after}"
1222        );
1223    }
1224
1225    #[test]
1226    fn analytic_codebook_is_symmetric_and_monotonic() {
1227        for padded_dim in [8, 128, 1024] {
1228            let codebook = TqCodebook::analytic(padded_dim);
1229            let levels = &codebook.levels;
1230            for pair in levels.windows(2) {
1231                assert!(pair[0] < pair[1], "levels must be strictly increasing");
1232            }
1233            for index in 0..TQ_STAGE1_LEVELS / 2 {
1234                assert_eq!(
1235                    levels[index],
1236                    -levels[TQ_STAGE1_LEVELS - 1 - index],
1237                    "levels must be exactly symmetric for P={padded_dim}: {levels:?}"
1238                );
1239            }
1240            assert!(levels[TQ_STAGE1_LEVELS - 1] < 1.0);
1241            // Coordinates concentrate near ±1/sqrt(P); the top level must be
1242            // on that scale, not at the interval edge.
1243            let scale = 1.0 / (padded_dim as f32).sqrt();
1244            assert!(
1245                levels[TQ_STAGE1_LEVELS - 1] < 6.0 * scale,
1246                "top level {} is implausibly large for P={padded_dim}",
1247                levels[TQ_STAGE1_LEVELS - 1]
1248            );
1249        }
1250    }
1251
1252    #[test]
1253    fn encode_coordinate_matches_nearest_level() {
1254        let codebook = TqCodebook::analytic(256);
1255        for step in -1000i32..=1000 {
1256            let value = step as f32 / 1000.0;
1257            let code = codebook.encode_coordinate(value) as usize;
1258            let nearest = codebook
1259                .levels
1260                .iter()
1261                .enumerate()
1262                .min_by(|a, b| (a.1 - value).abs().total_cmp(&(b.1 - value).abs()))
1263                .unwrap()
1264                .0;
1265            assert_eq!(
1266                code, nearest,
1267                "value {value} coded {code}, nearest {nearest}"
1268            );
1269        }
1270    }
1271
1272    #[test]
1273    fn estimator_is_unbiased_and_tight() {
1274        let dim = 96;
1275        let codec = TqCodec::new(dim);
1276        let mut scratch = TqEncodeScratch::default();
1277        let mut nibbles = vec![0u8; codec.padded_dim()];
1278
1279        let pairs = 512;
1280        let mut signed_error_sum = 0.0f64;
1281        let mut squared_error_sum = 0.0f64;
1282        let mut stage1_signed_error_sum = 0.0f64;
1283        for pair in 0..pairs {
1284            let vector = seeded_unit_vector(dim, 1000 + pair);
1285            let query = seeded_unit_vector(dim, 900_000 + pair);
1286            let gamma = codec.encode_into(&vector, &mut nibbles, &mut scratch);
1287            let plan = TqQueryPlan::build(&codec, &query);
1288            let estimate = plan.estimate_row(&nibbles, gamma);
1289            let stage1_only = plan.estimate_row(&nibbles, 0.0);
1290            let truth: f32 = vector.iter().zip(&query).map(|(a, b)| a * b).sum();
1291            signed_error_sum += f64::from(estimate - truth);
1292            squared_error_sum += f64::from(estimate - truth).powi(2);
1293            stage1_signed_error_sum += f64::from(stage1_only - truth);
1294        }
1295        let mean_error = signed_error_sum / pairs as f64;
1296        let rmse = (squared_error_sum / pairs as f64).sqrt();
1297        let stage1_mean_error = stage1_signed_error_sum / pairs as f64;
1298        assert!(
1299            mean_error.abs() < 3e-3,
1300            "QJL-corrected estimator must be unbiased: mean error {mean_error}"
1301        );
1302        assert!(rmse < 0.05, "estimator RMSE too large: {rmse}");
1303        assert!(
1304            mean_error.abs() <= stage1_mean_error.abs() + 1e-4,
1305            "QJL correction must not increase bias: {mean_error} vs stage-1 {stage1_mean_error}"
1306        );
1307    }
1308
1309    #[test]
1310    fn block_scoring_matches_row_estimates() {
1311        let dim = 100;
1312        let codec = TqCodec::new(dim);
1313        let mut scratch = TqEncodeScratch::default();
1314        let query = seeded_unit_vector(dim, 3);
1315        let plan = TqQueryPlan::build(&codec, &query);
1316
1317        let lanes = 13; // deliberately partial block
1318        let mut rows = Vec::new();
1319        let mut gammas = Vec::new();
1320        for lane in 0..lanes {
1321            let vector = seeded_unit_vector(dim, 50 + lane as u64);
1322            let mut nibbles = vec![0u8; codec.padded_dim()];
1323            let gamma = codec.encode_into(&vector, &mut nibbles, &mut scratch);
1324            rows.push(nibbles);
1325            gammas.push(gamma);
1326        }
1327        let row_refs: Vec<&[u8]> = rows.iter().map(Vec::as_slice).collect();
1328        let mut block = Vec::new();
1329        tq_pack_block(&row_refs, &gammas, codec.padded_dim(), &mut block);
1330        assert_eq!(block.len(), tq_block_bytes(codec.code_size()));
1331
1332        let mut scores = [0.0f32; TQ_BLOCK_LANES];
1333        tq_score_block(&plan, &block, &mut scores);
1334        for lane in 0..lanes {
1335            let expected = plan.estimate_row(&rows[lane], gammas[lane]);
1336            // The block path uses i8 LUTs; agreement is approximate.
1337            assert!(
1338                (scores[lane] - expected).abs() < 0.02,
1339                "lane {lane}: block score {} vs row estimate {expected}",
1340                scores[lane]
1341            );
1342        }
1343    }
1344
1345    #[test]
1346    fn simd_accumulation_matches_scalar_reference_exactly() {
1347        let padded_dim = 192; // not a multiple of the widen chunk
1348        let mut state = 99u64;
1349        let mut base_lut = vec![0i8; padded_dim * 16];
1350        let mut qjl_lut = vec![0i8; padded_dim * 16];
1351        for value in base_lut.iter_mut().chain(qjl_lut.iter_mut()) {
1352            *value = (splitmix64(&mut state) as i32 % 255 - 127) as i8;
1353        }
1354        let mut nibble_bytes = vec![0u8; padded_dim * 8];
1355        for byte in nibble_bytes.iter_mut() {
1356            *byte = splitmix64(&mut state) as u8;
1357        }
1358
1359        let mut base_reference = [0i32; TQ_BLOCK_LANES];
1360        let mut qjl_reference = [0i32; TQ_BLOCK_LANES];
1361        lut16::accumulate_block_scalar(
1362            &base_lut,
1363            &qjl_lut,
1364            &nibble_bytes,
1365            padded_dim,
1366            &mut base_reference,
1367            &mut qjl_reference,
1368        );
1369        let mut base_dispatch = [0i32; TQ_BLOCK_LANES];
1370        let mut qjl_dispatch = [0i32; TQ_BLOCK_LANES];
1371        lut16::accumulate_block(
1372            &base_lut,
1373            &qjl_lut,
1374            &nibble_bytes,
1375            padded_dim,
1376            &mut base_dispatch,
1377            &mut qjl_dispatch,
1378        );
1379        assert_eq!(
1380            base_reference, base_dispatch,
1381            "base sums must match exactly"
1382        );
1383        assert_eq!(qjl_reference, qjl_dispatch, "qjl sums must match exactly");
1384    }
1385
1386    #[test]
1387    fn fingerprint_pins_codec_constants() {
1388        let codec = TqCodec::new(768);
1389        assert_eq!(codec.padded_dim(), 768, "codec v2 is padding-free");
1390        assert_eq!(codec.code_size(), 384);
1391        assert_ne!(codec.fingerprint(), 0);
1392        assert_eq!(codec.fingerprint(), TqCodec::new(768).fingerprint());
1393        assert_ne!(codec.fingerprint(), TqCodec::new(769).fingerprint());
1394        // Golden value: the fingerprint is persisted as quantizer_version in
1395        // every TQ segment. Any change to the hash, seeds, or codec constants
1396        // MUST bump TQ_CODEC_VERSION — never silently re-derive.
1397        assert_eq!(
1398            codec.fingerprint(),
1399            GOLDEN_FINGERPRINT_768,
1400            "TQ fingerprint for dim 768 changed; existing segments would be \
1401             rejected. Bump TQ_CODEC_VERSION deliberately instead."
1402        );
1403    }
1404
1405    const GOLDEN_FINGERPRINT_768: u64 = 7026088428300072418;
1406
1407    #[cfg(feature = "native")]
1408    #[test]
1409    fn builder_packs_blocks_in_input_order() {
1410        let dim = 32;
1411        let codec = std::sync::Arc::new(TqCodec::new(dim));
1412        let mut builder = TqFlatBuilder::new(std::sync::Arc::clone(&codec));
1413        let count = 37; // two full blocks + partial
1414        let labels: Vec<(u32, u16)> = (0..count).map(|i| (i as u32, (i % 3) as u16)).collect();
1415        let mut vectors = Vec::new();
1416        for i in 0..count {
1417            vectors.extend(seeded_unit_vector(dim, 7_000 + i as u64));
1418        }
1419        builder.add_batch(&labels, &vectors).unwrap();
1420        builder.finish();
1421        assert_eq!(builder.doc_ids.len(), count);
1422        assert_eq!(
1423            builder.codes.len(),
1424            tq_codes_column_len(count, codec.code_size())
1425        );
1426
1427        // Every lane must score identically to encoding the row directly.
1428        let query = seeded_unit_vector(dim, 1);
1429        let plan = TqQueryPlan::build(&codec, &query);
1430        let block_bytes = tq_block_bytes(codec.code_size());
1431        let mut scratch = TqEncodeScratch::default();
1432        let mut scores = [0.0f32; TQ_BLOCK_LANES];
1433        for index in 0..count {
1434            let block = &builder.codes[(index / TQ_BLOCK_LANES) * block_bytes..][..block_bytes];
1435            tq_score_block(&plan, block, &mut scores);
1436            let mut nibbles = vec![0u8; codec.padded_dim()];
1437            let gamma = codec.encode_into(
1438                &vectors[index * dim..(index + 1) * dim],
1439                &mut nibbles,
1440                &mut scratch,
1441            );
1442            let expected = plan.estimate_row(&nibbles, gamma);
1443            assert!(
1444                (scores[index % TQ_BLOCK_LANES] - expected).abs() < 0.02,
1445                "vector {index} scored {} expected {expected}",
1446                scores[index % TQ_BLOCK_LANES]
1447            );
1448        }
1449    }
1450}