iqdb-quantize 1.0.0

Vector quantization (scalar, product, binary) for memory-efficient vector search - part of the iQDB family.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
# iqdb-quantize — API Reference

> Complete reference for **every** public item in `iqdb-quantize` as of
> **v1.0.0**: what it is, its parameters and return shape, the traits it
> implements, and worked examples for each use case.
>
> **Status: stable (1.0).** The public API is committed under SemVer for the 1.x
> series — no breaking changes until 2.0. The frozen surface is recorded in
> `dev/ROADMAP.md`; only additive, non-breaking changes are made within 1.x.

## Table of Contents

- [Overview]#overview
- [Crate constants]#crate-constants
  - [`VERSION`]#version
- [The `Quantizer` trait]#the-quantizer-trait
  - [`train`]#quantizertrain
  - [`quantize`]#quantizerquantize
  - [`dequantize`]#quantizerdequantize
  - [`distance`]#quantizerdistance
- [The quantizers]#the-quantizers
  - [`ScalarQuantizer` (SQ8)]#scalarquantizer-sq8
  - [`BinaryQuantizer` (BQ)]#binaryquantizer-bq
  - [`ProductQuantizer` (PQ)]#productquantizer-pq
- [The code types]#the-code-types
  - [`Sq8Code`]#sq8code
  - [`BqCode`]#bqcode
  - [`PqCode`]#pqcode
- [Batch ADC]#batch-adc
  - [`PqAdcTables`]#pqadctables
- [Metric support matrix]#metric-support-matrix
- [Errors]#errors
- [Feature flags]#feature-flags
- [Trait implementation matrix]#trait-implementation-matrix

---

## Overview

`iqdb-quantize` compresses `f32` embedding vectors into compact codes that
preserve similarity-search quality. A million 768-dim vectors drop from ~3 GB to
as little as ~96 MB, trading a controlled amount of recall for memory. Three
schemes share one [`Quantizer`](#the-quantizer-trait) trait:

| Scheme | Type | Code | Compression | Metrics |
|---|---|---|---|---|
| Scalar (SQ8) | [`ScalarQuantizer`]#scalarquantizer-sq8 | one `u8` / dim | ~4× | every metric (asymmetric) |
| Product (PQ) | [`ProductQuantizer`]#productquantizer-pq | `M` bytes | up to ~192× | Euclidean, DotProduct, Manhattan |
| Binary (BQ) | [`BinaryQuantizer`]#binaryquantizer-bq | one bit / dim | ~32× | Hamming only |

Two rules make quantization behave:

1. **Train on representative data.** Per-dimension calibration is only as good as
   the sample it was learned from — train on the embeddings you intend to index.
2. **Search quantized, then rerank with full `f32`.** Quantized distance narrows
   the candidate set cheaply; the final ranking should use the original vectors.
   Skipping the rerank is the most common cause of "quantization broke recall".

**No panics.** Every fallible method returns `iqdb_types::Result`. Empty,
non-finite, dimension-mismatched, untrained, and unsupported-metric inputs all
surface as a typed [`IqdbError`](#errors).

```rust
use iqdb_quantize::{Quantizer, ScalarQuantizer};
use iqdb_types::DistanceMetric;

let training = [
    vec![0.10_f32, 0.20, 0.30],
    vec![0.15, 0.18, 0.32],
    vec![0.12, 0.22, 0.28],
];
let refs: Vec<&[f32]> = training.iter().map(Vec::as_slice).collect();

let mut sq = ScalarQuantizer::new();
sq.train(&refs).expect("non-empty, consistent dims, finite values");

let code = sq.quantize(&[0.11_f32, 0.21, 0.29]).expect("dim matches training");
let d = sq
    .distance(&[0.10_f32, 0.20, 0.30], &code, DistanceMetric::Cosine)
    .expect("dim matches");
assert!(d.is_finite());
```

---

## Crate constants

### `VERSION`

```rust
pub const VERSION: &str;
```

The crate's compile-time version (`CARGO_PKG_VERSION`), a `major.minor.patch`
SemVer core. Use it to report the exact `iqdb-quantize` build a binary links
against — useful in diagnostics and version-skew checks across the iQDB crate
family.

```rust
let v = iqdb_quantize::VERSION;
assert_eq!(v.split('.').count(), 3);
assert!(v.split('.').all(|part| !part.is_empty()));
```

---

## The `Quantizer` trait

```rust
pub trait Quantizer {
    type Quantized;

    fn train(&mut self, vectors: &[&[f32]]) -> Result<()>;
    fn quantize(&self, vector: &[f32]) -> Result<Self::Quantized>;
    fn dequantize(&self, quantized: &Self::Quantized) -> Result<Vec<f32>>;
    fn distance(
        &self,
        query: &[f32],
        quantized: &Self::Quantized,
        metric: DistanceMetric,
    ) -> Result<f32>;
}
```

The single contract every scheme implements. The associated type `Quantized` is
the scheme's code: [`Sq8Code`](#sq8code) for [`ScalarQuantizer`](#scalarquantizer-sq8),
[`BqCode`](#bqcode) for [`BinaryQuantizer`](#binaryquantizer-bq),
[`PqCode`](#pqcode) for [`ProductQuantizer`](#productquantizer-pq).

> **Calibration contract.** A quantizer **must** be trained before any of
> `quantize`, `dequantize`, or `distance` is called. Calling a hot method on an
> untrained quantizer returns [`IqdbError::InvalidConfig`]#errors rather than
> panicking. A trained quantizer is immutable and `Send + Sync` — it owns its
> calibration by value and exposes no interior mutability, so it can be shared
> across threads.

### `Quantizer::train`

```rust
fn train(&mut self, vectors: &[&[f32]]) -> Result<()>;
```

Learn the scheme's calibration from a representative sample — per-dimension
`(min, max)` for SQ8, per-dimension means for BQ, per-subvector k-means
codebooks for PQ.

- **`vectors`** — the training sample, as a slice of `f32` slices. Must be
  non-empty, every vector non-empty and finite, and all the same length.
- **Returns** `Ok(())`, or:
  - [`Err(IqdbError::InvalidConfig)`]#errors if `vectors` is empty (or, for PQ,
    the configured shape is invalid for the data — see
    [`with_config`]#productquantizer-pq).
  - [`Err(IqdbError::InvalidVector)`]#errors if any training vector is empty or
    has a `NaN`/`±∞` component.
  - [`Err(IqdbError::DimensionMismatch)`]#errors if the training vectors
    disagree on length.

```rust
use iqdb_quantize::{Quantizer, ScalarQuantizer};

let mut sq = ScalarQuantizer::new();
sq.train(&[&[0.0_f32, 1.0, 2.0][..], &[1.0_f32, 0.0, 1.0][..]])
    .expect("two non-empty, finite vectors of equal dim");
assert_eq!(sq.dim(), Some(3));
```

### `Quantizer::quantize`

```rust
fn quantize(&self, vector: &[f32]) -> Result<Self::Quantized>;
```

Encode `vector` into the scheme's compact code.

- **`vector`** — the vector to compress; must be non-empty, finite, and match the
  trained dimension.
- **Returns** `Ok(Self::Quantized)`, or [`InvalidConfig`]#errors if untrained,
  [`InvalidVector`]#errors if empty/non-finite, [`DimensionMismatch`]#errors
  if the length differs from training.

```rust
use iqdb_quantize::{Quantizer, ScalarQuantizer};

let mut sq = ScalarQuantizer::new();
sq.train(&[&[0.0_f32, 10.0][..], &[10.0_f32, 0.0][..]]).expect("ok");
let code = sq.quantize(&[5.0_f32, 5.0]).expect("dim matches");
assert_eq!(code.len(), 2);
```

### `Quantizer::dequantize`

```rust
fn dequantize(&self, quantized: &Self::Quantized) -> Result<Vec<f32>>;
```

Decode a code back to an `f32` vector. The result is an approximation —
quantization is lossy.

- **`quantized`** — a code produced by this scheme.
- **Returns** `Ok(Vec<f32>)` of the trained dimension, or [`InvalidConfig`]#errors
  if untrained / [`DimensionMismatch`]#errors if the code was produced under a
  different trained dimension.

```rust
use iqdb_quantize::{Quantizer, ScalarQuantizer};

let mut sq = ScalarQuantizer::new();
sq.train(&[&[0.0_f32, 10.0][..], &[10.0_f32, 0.0][..]]).expect("ok");
let code = sq.quantize(&[5.0_f32, 5.0]).expect("ok");
let approx = sq.dequantize(&code).expect("ok");
assert_eq!(approx.len(), 2);
assert!((approx[0] - 5.0).abs() < 0.1); // close, not exact — lossy
```

### `Quantizer::distance`

```rust
fn distance(
    &self,
    query: &[f32],
    quantized: &Self::Quantized,
    metric: DistanceMetric,
) -> Result<f32>;
```

Compute the **asymmetric** distance between a raw `f32` query and a stored code:
the query stays full precision, only the candidate is compressed. "Smaller is
nearer", matching the rest of the iQDB spine.

- **`query`** — the full-precision query vector (non-empty, finite, trained dim).
- **`quantized`** — the stored code to score against.
- **`metric`** — which [`DistanceMetric`] to use. Support is scheme-specific —
  see the [metric support matrix]#metric-support-matrix. An unsupported metric
  returns [`InvalidMetric`]#errors.
- **Returns** `Ok(f32)`, or the typed errors above.

```rust
use iqdb_quantize::{Quantizer, ScalarQuantizer};
use iqdb_types::DistanceMetric;

let mut sq = ScalarQuantizer::new();
sq.train(&[&[0.0_f32, 1.0][..], &[1.0_f32, 0.0][..]]).expect("ok");
let code = sq.quantize(&[0.5_f32, 0.5]).expect("ok");
let d = sq
    .distance(&[0.5_f32, 0.5], &code, DistanceMetric::Euclidean)
    .expect("supported");
assert!(d.is_finite());
```

[`DistanceMetric`]: iqdb_types::DistanceMetric

---

## The quantizers

### `ScalarQuantizer` (SQ8)

```rust
pub struct ScalarQuantizer { /* … */ }

impl ScalarQuantizer {
    pub fn new() -> Self;
    pub fn dim(&self) -> Option<usize>;
}
impl Default for ScalarQuantizer { /* = new() */ }
impl Quantizer for ScalarQuantizer { type Quantized = Sq8Code; }
```

Scalar quantization: one `u8` per dimension, ~4× compression. The calibration is
a per-dimension affine map — each dimension stores its trained `min` and a
`scale = (max - min) / 255`. Encoding clamps the input into `[min, max]`, scales
onto `[0, 255]`, and rounds; decoding reverses it. A zero-range dimension
(`max == min`) collapses to a `scale = 0` lane: every code byte there is `0` and
`dequantize` returns `min`, so there is no division by zero. Distance is
**asymmetric** and supports **every** [`DistanceMetric`] — the candidate is
dequantized to a temporary buffer and routed through
[`iqdb_distance::compute`](iqdb_distance::compute).

- **`new()`** — build an untrained quantizer. `#[must_use]`. Equivalent to
  [`Default`].
- **`dim()`** — the trained dimension, or `None` before training.

```rust
use iqdb_quantize::{Quantizer, ScalarQuantizer};
use iqdb_types::DistanceMetric;

let mut sq = ScalarQuantizer::new();
assert_eq!(sq.dim(), None);
sq.train(&[&[0.0_f32, 1.0, 2.0][..], &[1.0_f32, 0.0, 1.0][..]]).expect("ok");
assert_eq!(sq.dim(), Some(3));

let code = sq.quantize(&[0.5_f32, 0.5, 1.5]).expect("dim matches");
let d = sq.distance(&[0.5_f32, 0.5, 1.5], &code, DistanceMetric::Cosine).expect("ok");
assert!(d.is_finite());
```

### `BinaryQuantizer` (BQ)

```rust
pub struct BinaryQuantizer { /* … */ }

impl BinaryQuantizer {
    pub fn new() -> Self;
    pub fn dim(&self) -> Option<usize>;
}
impl Default for BinaryQuantizer { /* = new() */ }
impl Quantizer for BinaryQuantizer { type Quantized = BqCode; }
```

Binary quantization: one bit per dimension, ~32× compression. Bit `i` is `1` when
`vector[i] >= mean[i]` (the trained per-dimension mean), `0` otherwise; bits pack
into `u64` words with the trailing word's unused high bits zeroed so they cannot
contribute to Hamming. The query path binarizes against the **same** trained
means, so query and code bits share one space.

BQ supports [`DistanceMetric::Hamming`] **only** — every other metric returns
[`InvalidMetric`](#errors). A one-bit code carries no magnitude, so a cosine or
Euclidean comparison over ±1 codes would be a roundabout Hamming in misleading
units; the contract rejects that rather than mislead (matching the Faiss
`IndexBinary` convention).

- **`new()`** — build an untrained quantizer. `#[must_use]`. Equivalent to
  [`Default`].
- **`dim()`** — the trained dimension, or `None` before training.

```rust
use iqdb_quantize::{BinaryQuantizer, Quantizer};
use iqdb_types::DistanceMetric;

let mut bq = BinaryQuantizer::new();
bq.train(&[&[0.0_f32, 1.0, 2.0][..], &[2.0_f32, 1.0, 0.0][..]]).expect("ok");

let code = bq.quantize(&[0.5_f32, 1.5, 2.5]).expect("dim matches");
assert_eq!(code.dim(), 3);
let d = bq.distance(&[0.5_f32, 1.5, 2.5], &code, DistanceMetric::Hamming).expect("ok");
assert_eq!(d, 0.0); // self-distance is zero

// Any non-Hamming metric is rejected.
use iqdb_types::IqdbError;
let err = bq.distance(&[0.5_f32, 1.5, 2.5], &code, DistanceMetric::Cosine).unwrap_err();
assert_eq!(err, IqdbError::InvalidMetric);
```

[`DistanceMetric::Hamming`]: iqdb_types::DistanceMetric::Hamming

### `ProductQuantizer` (PQ)

```rust
pub struct ProductQuantizer { /* … */ }

impl ProductQuantizer {
    pub fn new() -> Self;                                          // M = 8, K = 256, seed = 0
    pub fn with_config(n_subvectors: usize, n_centroids: usize, seed: u64) -> Self;
    pub fn dim(&self) -> Option<usize>;
    pub fn n_subvectors(&self) -> usize;                          // M
    pub fn n_centroids(&self) -> usize;                           // K
    pub fn seed(&self) -> u64;
    pub fn build_query_tables(&self, query: &[f32], metric: DistanceMetric) -> Result<PqAdcTables>;
}
impl Default for ProductQuantizer { /* = new() */ }
impl Quantizer for ProductQuantizer { type Quantized = PqCode; }
```

Product quantization: split each vector into `M = n_subvectors` equal-length
subvectors, learn a `K = n_centroids`-centroid codebook per subvector via
k-means (k-means++ seeding, Lloyd's iterations), and store one `u8` centroid
index per subvector — `M` bytes total. At `M = 16, K = 256` a 768-dim vector
shrinks from 3072 bytes to 16 (192×). Distance uses **asymmetric distance
computation (ADC)**: the query stays `f32`, a per-subvector query-to-centroid
table is precomputed, and a stored code is scored by `M` lookups plus a sum.

PQ supports [`DistanceMetric::Euclidean`], [`DistanceMetric::DotProduct`], and
[`DistanceMetric::Manhattan`] — each decomposes into a per-subvector sum.
[`DistanceMetric::Cosine`] (no global norm recoverable per subvector;
L2-normalize and use `DotProduct`) and [`DistanceMetric::Hamming`] (wrong code
space) return [`InvalidMetric`](#errors).

- **`new()`** — standard `M = 8, K = 256, seed = 0`. `M = 8` divides the common
  embedding dims (128, 256, 384, 512, 768, 1024). `#[must_use]`.
- **`with_config(n_subvectors, n_centroids, seed)`** — pick `M`, `K` (`K ≤ 256`,
  codes are `u8`), and the training seed. The constructor is infallible; invalid
  combinations (`n_centroids == 0` or `> 256`, training dim not divisible by `M`)
  surface as [`InvalidConfig`]#errors from [`train`]#quantizertrain.
  `#[must_use]`.
- **`dim()` / `n_subvectors()` / `n_centroids()` / `seed()`** — report the
  trained dimension (or `None`) and the configured `M`, `K`, and seed.
- **`build_query_tables(query, metric)`** — see [Batch ADC]#batch-adc.

> **Determinism.** The same `seed` + the same training data produce
> byte-identical codebooks and codes on every supported platform. ADC is exact:
> [`distance`]#quantizerdistance equals [`dequantize`]#quantizerdequantize +
> [`iqdb_distance::compute`]iqdb_distance::compute within floating-point
> reduction tolerance — both are property-tested.

```rust
use iqdb_quantize::{ProductQuantizer, Quantizer};
use iqdb_types::DistanceMetric;

let mut pq = ProductQuantizer::with_config(2, 4, 7); // M = 2, K = 4, seed = 7
let training: Vec<Vec<f32>> = (0..16)
    .map(|i| { let f = i as f32; vec![f, f + 1.0, f + 2.0, f + 3.0] })
    .collect();
let refs: Vec<&[f32]> = training.iter().map(Vec::as_slice).collect();
pq.train(&refs).expect("dim divisible by M, K <= 256");

let code = pq.quantize(&[1.0_f32, 2.0, 3.0, 4.0]).expect("quantize");
assert_eq!(code.n_subvectors(), 2);
let d = pq.distance(&[1.0_f32, 2.0, 3.0, 4.0], &code, DistanceMetric::Euclidean).expect("ok");
assert!(d.is_finite());
```

[`DistanceMetric::Euclidean`]: iqdb_types::DistanceMetric::Euclidean
[`DistanceMetric::DotProduct`]: iqdb_types::DistanceMetric::DotProduct
[`DistanceMetric::Manhattan`]: iqdb_types::DistanceMetric::Manhattan
[`DistanceMetric::Cosine`]: iqdb_types::DistanceMetric::Cosine

---

## The code types

All three codes are owned, immutable newtypes — `Debug`, `Clone`, `PartialEq`,
`Eq`, no public mutators. Each is produced **only** by its owning quantizer, so a
code's contents always match the calibrated quantizer that made it; a caller
cannot fabricate one.

### `Sq8Code`

```rust
pub struct Sq8Code { /* … */ }

impl Sq8Code {
    pub fn len(&self) -> usize;       // one byte per dimension
    pub fn is_empty(&self) -> bool;
    pub fn as_bytes(&self) -> &[u8];
}
```

A scalar-quantized code: one `u8` per dimension. Byte `i` is the linear `u8`
encoding of component `i` under that dimension's affine calibration — not useful
on its own; decode with [`dequantize`](#quantizerdequantize) or compare via
[`distance`](#quantizerdistance).

```rust
use iqdb_quantize::{Quantizer, ScalarQuantizer};

let mut sq = ScalarQuantizer::new();
sq.train(&[&[0.0_f32, 1.0, 2.0][..]]).expect("ok");
let code = sq.quantize(&[0.5_f32, 0.5, 0.5]).expect("ok");
assert_eq!(code.len(), 3);
assert!(!code.is_empty());
assert_eq!(code.as_bytes().len(), 3);
```

### `BqCode`

```rust
pub struct BqCode { /* … */ }

impl BqCode {
    pub fn dim(&self) -> usize;       // original vector dimension
    pub fn is_empty(&self) -> bool;
    pub fn as_words(&self) -> &[u64]; // packed bits
}
```

A binary-quantized code: one bit per dimension, packed into `u64` words. `dim` is
the number of meaningful bits; the trailing word's unused high bits are always
zero. The word count is `dim.div_ceil(64)`.

```rust
use iqdb_quantize::{BinaryQuantizer, Quantizer};

let mut bq = BinaryQuantizer::new();
bq.train(&[&[0.0_f32; 65][..], &[1.0_f32; 65][..]]).expect("ok");
let code = bq.quantize(&[0.5_f32; 65]).expect("ok");
assert_eq!(code.dim(), 65);
assert_eq!(code.as_words().len(), 2); // 65 bits → two u64 words
```

### `PqCode`

```rust
pub struct PqCode { /* … */ }

impl PqCode {
    pub fn dim(&self) -> usize;          // original vector dimension
    pub fn n_subvectors(&self) -> usize; // M
    pub fn len(&self) -> usize;          // == n_subvectors
    pub fn is_empty(&self) -> bool;
    pub fn as_bytes(&self) -> &[u8];     // one centroid index per subvector
}
```

A product-quantized code: one `u8` centroid index per subvector. Byte `m` is the
index (in `0..n_centroids`, `≤ 256`) of the centroid in codebook `m` that best
approximates the `m`-th subvector. `len()` equals `n_subvectors()`.

```rust
use iqdb_quantize::{ProductQuantizer, Quantizer};

let mut pq = ProductQuantizer::with_config(2, 4, 42);
let training: Vec<Vec<f32>> = (0..8)
    .map(|i| vec![i as f32, (i * 2) as f32, (i * 3) as f32, (i * 4) as f32])
    .collect();
let refs: Vec<&[f32]> = training.iter().map(Vec::as_slice).collect();
pq.train(&refs).expect("ok");

let code = pq.quantize(&[1.0_f32, 2.0, 3.0, 4.0]).expect("ok");
assert_eq!(code.n_subvectors(), 2);
assert_eq!(code.dim(), 4);
assert_eq!(code.as_bytes().len(), 2);
```

---

## Batch ADC

### `PqAdcTables`

```rust
pub struct PqAdcTables { /* … */ } // Debug, Clone

impl PqAdcTables {
    pub fn distance(&self, code: &PqCode) -> Result<f32>;
    pub fn metric(&self) -> DistanceMetric;
    pub fn n_subvectors(&self) -> usize;
    pub fn n_centroids(&self) -> usize;
    pub fn dim(&self) -> usize;
}
```

Per-`(query, metric)` precomputed ADC lookup tables, built once with
[`ProductQuantizer::build_query_tables`](#productquantizer-pq) and reused to score
many [`PqCode`](#pqcode)s. Row `m` holds the distances from query subvector `q_m`
to each of the `K` centroids of codebook `m`. For
[`DistanceMetric::Euclidean`](iqdb_types::DistanceMetric::Euclidean) the rows hold
**squared L2** values (so they sum decomposably) and `distance` takes `sqrt` of
the total exactly once; `DotProduct` and `Manhattan` sum directly.

This is the primitive `iqdb-ivf`'s IVF-PQ intra-cluster scan is built on: build
the table once per query, then score every code in every probed cluster against
it. [`ProductQuantizer::distance`](#quantizerdistance) is itself a thin wrapper
over `build_query_tables` + `distance`, so the single-shot result is
byte-for-byte identical to the batch path.

- **`distance(code)`** — score one code against the prepared tables. Returns
  [`DimensionMismatch`]#errors if `code` came from a quantizer with a different
  `M` or trained `dim`.
- **`metric()` / `n_subvectors()` / `n_centroids()` / `dim()`** — the metric and
  geometry the tables were built for.

```rust
use iqdb_quantize::{ProductQuantizer, Quantizer};
use iqdb_types::DistanceMetric;

let mut pq = ProductQuantizer::with_config(2, 4, 7);
let training: Vec<Vec<f32>> = (0..16)
    .map(|i| { let f = i as f32; vec![f, f + 1.0, f + 2.0, f + 3.0] })
    .collect();
let refs: Vec<&[f32]> = training.iter().map(Vec::as_slice).collect();
pq.train(&refs).expect("ok");

let code_a = pq.quantize(&[1.0_f32, 2.0, 3.0, 4.0]).expect("ok");
let code_b = pq.quantize(&[5.0_f32, 6.0, 7.0, 8.0]).expect("ok");

// Build the table ONCE, then score many codes.
let query = [1.0_f32, 2.0, 3.0, 4.0];
let tables = pq.build_query_tables(&query, DistanceMetric::Euclidean).expect("supported");
let d_a = tables.distance(&code_a).expect("matching shape");
let d_b = tables.distance(&code_b).expect("matching shape");
assert!(d_a.is_finite() && d_b.is_finite());

// Identical to the single-shot path.
let single = pq.distance(&query, &code_a, DistanceMetric::Euclidean).expect("ok");
assert_eq!(d_a.to_bits(), single.to_bits());
```

---

## Metric support matrix

`distance` and `build_query_tables` accept the metric at runtime; what each
scheme supports differs. An unsupported metric returns
[`IqdbError::InvalidMetric`](#errors) — never a panic — which keeps callers
working as `iqdb-types` adds `#[non_exhaustive]` `DistanceMetric` variants.

| Metric | `ScalarQuantizer` | `ProductQuantizer` | `BinaryQuantizer` |
|---|:---:|:---:|:---:|
| `Euclidean` ||||
| `DotProduct` ||||
| `Manhattan` ||||
| `Cosine` || ❌¹ ||
| `Hamming` | ✅² |||

<sub>¹ PQ needs a global norm it cannot recover per subvector — L2-normalize and
use `DotProduct`. ² SQ8 routes through `iqdb-distance`, which defines Hamming on
the dequantized `f32` components.</sub>

---

## Errors

`iqdb-quantize` returns the shared [`iqdb_types::IqdbError`] / `Result`
vocabulary — it adds no error type of its own. The variants it produces:

| Variant | When |
|---|---|
| `InvalidConfig { reason }` | A hot method (`quantize` / `dequantize` / `distance` / `build_query_tables`) called before `train`; an empty training set; or a PQ shape invalid for the data (`n_centroids` 0 or > 256, dim not divisible by `M`). |
| `InvalidVector` | An input (training vector, query, or candidate) is empty or has a `NaN`/`±∞` component. |
| `DimensionMismatch { expected, found }` | Training vectors disagree on length, or a query / code does not match the trained dimension. |
| `InvalidMetric` | A metric the scheme does not support — see the [matrix]#metric-support-matrix — including unimplemented `#[non_exhaustive]` `DistanceMetric` variants. |

`IqdbError` is `Copy` and `#[non_exhaustive]`; match it with a wildcard arm. See
the `iqdb-types` API reference for `Display`, `kind()`, and `caption()`.

```rust
use iqdb_quantize::{Quantizer, ScalarQuantizer};
use iqdb_types::{DistanceMetric, IqdbError};

let sq = ScalarQuantizer::new(); // untrained

// Quantizing before training is a typed error, not a panic.
assert!(matches!(sq.quantize(&[1.0, 2.0]), Err(IqdbError::InvalidConfig { .. })));

let mut trained = ScalarQuantizer::new();
trained.train(&[&[0.0_f32, 1.0][..]]).expect("ok");
let code = trained.quantize(&[0.5_f32, 0.5]).expect("ok");

// Wrong query dimension.
let err = trained.distance(&[0.5_f32, 0.5, 0.5], &code, DistanceMetric::Euclidean).unwrap_err();
assert_eq!(err, IqdbError::DimensionMismatch { expected: 2, found: 3 });
```

[`iqdb_types::IqdbError`]: iqdb_types::IqdbError

---

## Feature flags

The crate has **no optional features** — `default = []`. It is `std`-only and
always pulls its four dependencies: `iqdb-types` (the shared `DistanceMetric` /
`IqdbError` / `Result` vocabulary), `iqdb-distance` (the f32 distance kernels SQ8
and PQ delegate to), `error-forge` (the `ForgeError` trait behind `IqdbError`'s
`kind()` / `caption()`), and `tracing` (instrumentation at the training
boundary). SIMD acceleration (AVX2 / NEON) lands transparently through
`iqdb-distance`, so there is no SIMD feature to toggle here.

---

## Trait implementation matrix

| Type | `Debug` | `Clone` | `Default` | `PartialEq` | `Eq` | `Quantizer` |
|---|:---:|:---:|:---:|:---:|:---:|:---:|
| `ScalarQuantizer` |||||| ✅ (`= Sq8Code`) |
| `BinaryQuantizer` |||||| ✅ (`= BqCode`) |
| `ProductQuantizer` |||||| ✅ (`= PqCode`) |
| `Sq8Code` |||||||
| `BqCode` |||||||
| `PqCode` |||||||
| `PqAdcTables` |||||||

The quantizers hold `f32` calibration, so they are `PartialEq` but not `Eq`. The
code types hold only integer storage, so they are fully `Eq`.

---

<sub>Copyright &copy; 2026 <strong>James Gober</strong>.</sub>