Skip to main content

hermes_core/segment/
ann_disk.rs

1//! Merge-native ANN segment format.
2//!
3//! ANN payloads are split into immutable cluster runs. A normal segment merge
4//! copies the three run columns (doc IDs, ordinals, codes) byte-for-byte and
5//! rewrites only the compact run directory with an adjusted document base.
6//! No centroid assignment, payload deserialization, or reserialization occurs
7//! on the merge path.
8
9#[cfg(feature = "native")]
10use std::cmp::Reverse;
11#[cfg(feature = "native")]
12use std::collections::BinaryHeap;
13use std::io;
14#[cfg(feature = "native")]
15use std::io::Write;
16use std::ops::Range;
17
18#[cfg(feature = "native")]
19use byteorder::WriteBytesExt;
20use byteorder::{LittleEndian, ReadBytesExt};
21
22use crate::directories::OwnedBytes;
23use crate::dsl::IvfRoutingMode;
24
25#[cfg(feature = "native")]
26use crate::structures::BinaryIvfIndex;
27use crate::structures::vector::index::{BoundedAnnCollector, BoundedUniqueAnnCollector};
28
29/// Combined binary candidates: the retained documents, plus the exact
30/// per-ordinal `(doc_id, ordinal, score)` scores behind them.
31type CombinedBinaryCandidates = (Vec<AnnDocumentCandidate>, Vec<(u32, u16, f32)>);
32
33const ANN_HEADER_MAGIC: u32 = 0x3152_4e41; // "ANR1"
34const ANN_FOOTER_MAGIC: u32 = 0x3146_4e41; // "ANF1"
35const ANN_DISK_VERSION: u16 = 1;
36const ANN_HEADER_SIZE: usize = 56;
37const ANN_RUN_SIZE: usize = 48;
38const ANN_FOOTER_SIZE: usize = 24;
39#[cfg(feature = "native")]
40const COPY_CHUNK: usize = 8 * 1024 * 1024;
41#[cfg(feature = "native")]
42const PREFETCH_COALESCE_GAP: usize = 4 * 1024;
43const BINARY_SCORE_BATCH: usize = 8_192;
44/// Upper bound on the TQ leaf estimate `est⟨q̂,r̂⟩` for a unit residual
45/// direction: `base ≤ ‖recon‖ ≈ 1` plus the QJL term `≤ √(π/2)·γ ≤ 1.26·γ`
46/// with `γ < 1`, kept with slack so pruning can never drop a candidate the
47/// unpruned scan would have kept (pinned by a test).
48const TQ_PRUNE_ESTIMATE_BOUND: f32 = 1.3;
49/// Flat TQ scans fan out across Rayon above this vector count. Rayon folds
50/// chunks into one collector per worker before reducing those collectors, so
51/// temporary top-k memory is bounded by the active worker count rather than
52/// the number of chunks. Small segments stay sequential because fan-out
53/// overhead would dominate.
54#[cfg(feature = "native")]
55const TQ_PARALLEL_SCAN_MIN_VECTORS: usize = 65_536;
56#[cfg(feature = "native")]
57const TQ_PARALLEL_SCAN_CHUNK_BLOCKS: usize = 512;
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub(crate) enum AnnKind {
61    // Discriminant 1 was IVF-PQ, removed after IVF-TQ superseded it
62    // (docs/turboquant-quantization.md). Never reuse it.
63    BinaryIvf = 2,
64    /// TurboQuant flat scan: single logical cluster, block-packed codes.
65    TqFlat = 3,
66    /// Trained IVF router with TurboQuant-coded centroid residuals.
67    IvfTq = 4,
68}
69
70impl AnnKind {
71    fn from_u8(value: u8) -> io::Result<Self> {
72        match value {
73            1 => Err(invalid_data(
74                "ANN kind 1 (IVF-PQ) is no longer supported; recreate the index \
75                 with `ivf_tq` and reindex",
76            )),
77            2 => Ok(Self::BinaryIvf),
78            3 => Ok(Self::TqFlat),
79            4 => Ok(Self::IvfTq),
80            _ => Err(invalid_data(format!("unknown ANN kind {value}"))),
81        }
82    }
83}
84
85/// Codes-column byte length for one run. TQ packs vectors into 16-lane
86/// blocks (gammas + dimension-major nibbles), so its column is block-padded
87/// rather than `count * code_size`.
88fn expected_codes_column_len(kind: AnnKind, count: usize, code_size: usize) -> io::Result<usize> {
89    match kind {
90        AnnKind::BinaryIvf => count
91            .checked_mul(code_size)
92            .ok_or_else(|| invalid_data("ANN code column size overflows usize")),
93        // Single source of truth for the block-packed layouts lives in tq.rs.
94        AnnKind::TqFlat => {
95            crate::structures::vector::quantization::tq_codes_column_len_checked(count, code_size)
96                .ok_or_else(|| invalid_data("TQ code column size overflows usize"))
97        }
98        AnnKind::IvfTq => crate::structures::vector::quantization::tq_ivf_codes_column_len_checked(
99            count, code_size,
100        )
101        .ok_or_else(|| invalid_data("IVF-TQ code column size overflows usize")),
102    }
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub(crate) struct AnnDiskHeader {
107    pub kind: AnnKind,
108    pub routing: IvfRoutingMode,
109    pub dim: usize,
110    pub code_size: usize,
111    pub num_clusters: u32,
112    pub quantizer_version: u64,
113    pub codebook_version: u64,
114    pub vector_count: usize,
115}
116
117#[derive(Debug)]
118struct AnnRun {
119    cluster_id: u32,
120    doc_base: u32,
121    max_doc_id: u32,
122    count: usize,
123    doc_ids: Range<usize>,
124    ordinals: Range<usize>,
125    codes: Range<usize>,
126}
127
128/// Mmap-backed searchable ANN payload. Only the fixed-size run directory is
129/// heap-resident; all corpus-sized columns remain zero-copy file slices.
130pub(crate) struct AnnDiskIndex {
131    // Drop locks before the directory allocation they reference.
132    #[cfg(feature = "native")]
133    heap_pins: crate::segment::pin::HeapPinSet,
134    raw: OwnedBytes,
135    header: AnnDiskHeader,
136    runs: Vec<AnnRun>,
137}
138
139/// Cheap structural health of one ANN payload, computed from the in-memory
140/// run directory in O(runs) — payload bytes are never touched.
141///
142/// Two production failure modes motivated every field here (see
143/// `docs/diagnostics.md`): a 31%-of-vectors leaf built from degenerate
144/// embeddings, and probe read-amplification from byte-copy merges that leave
145/// one logical cluster scattered across many physical extents.
146#[derive(Debug, Clone, Copy, PartialEq)]
147pub struct AnnHealth {
148    /// Vectors across all runs.
149    pub vectors: u64,
150    /// Distinct cluster IDs holding at least one posting.
151    pub clusters_nonempty: u32,
152    /// Codebook size (`num_clusters` from the header).
153    pub clusters_total: u32,
154    /// Run-directory entries; each is one physical extent on disk.
155    pub runs: u32,
156    /// Vectors in the most populated cluster, with its ID.
157    pub largest_cluster: u32,
158    pub largest_cluster_vectors: u64,
159    /// Faiss `imbalance_factor` over non-empty clusters:
160    /// `K · Σ nᵢ² / N²`. 1.0 is perfectly balanced; a value of γ means a
161    /// fixed-`nprobe` probe computes γ× the distances of the balanced
162    /// baseline in expectation.
163    pub imbalance: f64,
164    /// Bytes of the codes columns (what a probe of every leaf would read).
165    pub payload_bytes: u64,
166}
167
168impl AnnHealth {
169    /// Physical extents per non-empty cluster. 1.0 after a rebuild; each
170    /// byte-copy merge multiplies it, and every extent is a potential seek
171    /// when the index is cold.
172    pub fn fragmentation(&self) -> f64 {
173        if self.clusters_nonempty == 0 {
174            return 0.0;
175        }
176        f64::from(self.runs) / f64::from(self.clusters_nonempty)
177    }
178
179    /// Share of all vectors held by the single largest cluster.
180    pub fn largest_cluster_share(&self) -> f64 {
181        if self.vectors == 0 {
182            return 0.0;
183        }
184        self.largest_cluster_vectors as f64 / self.vectors as f64
185    }
186}
187
188/// `largest_cluster_share` above this, on at least [`ANN_SKEW_WARN_MIN_VECTORS`]
189/// vectors, is a scan cliff worth a warning: the production incident value was
190/// 0.31, and a healthy 100k+-cluster codebook sits orders of magnitude lower.
191const ANN_SKEW_WARN_SHARE: f64 = 0.05;
192const ANN_SKEW_WARN_MIN_VECTORS: u64 = 100_000;
193/// A rebuilt segment has fragmentation 1.0; a single 32-way byte-copy merge
194/// can reach 32. Warn once probes pay ~an order of magnitude extra seeks.
195const ANN_FRAGMENTATION_WARN: f64 = 8.0;
196
197/// A document selected by a combiner-aware compressed scan.
198///
199/// This intentionally has no ordinal: `score` combines every value belonging
200/// to the document and must never be reused as an individual vector score by
201/// an exact reranker.
202#[derive(Debug, Clone, Copy, PartialEq)]
203pub(crate) struct AnnDocumentCandidate {
204    pub(crate) doc_id: u32,
205    pub(crate) score: f32,
206}
207
208impl AnnDiskIndex {
209    pub(crate) fn open(
210        raw: OwnedBytes,
211        expected_kind: AnnKind,
212        total_docs: u32,
213    ) -> io::Result<Self> {
214        if raw.len() < ANN_HEADER_SIZE + ANN_FOOTER_SIZE {
215            return Err(invalid_data("ANN payload is shorter than header + footer"));
216        }
217        let bytes = raw.as_slice();
218        let mut header_cursor = std::io::Cursor::new(&bytes[..ANN_HEADER_SIZE]);
219        if header_cursor.read_u32::<LittleEndian>()? != ANN_HEADER_MAGIC {
220            return Err(invalid_data("ANN payload has unsupported header magic"));
221        }
222        let kind = AnnKind::from_u8(header_cursor.read_u8()?)?;
223        if kind != expected_kind {
224            return Err(invalid_data(format!(
225                "ANN payload kind {kind:?} does not match expected {expected_kind:?}"
226            )));
227        }
228        let routing = routing_from_u8(header_cursor.read_u8()?)?;
229        if header_cursor.read_u16::<LittleEndian>()? != ANN_DISK_VERSION {
230            return Err(invalid_data("ANN payload has unsupported format version"));
231        }
232        let dim = header_cursor.read_u32::<LittleEndian>()? as usize;
233        let code_size = header_cursor.read_u32::<LittleEndian>()? as usize;
234        let num_clusters = header_cursor.read_u32::<LittleEndian>()?;
235        if header_cursor.read_u32::<LittleEndian>()? != 0 {
236            return Err(invalid_data("ANN header reserved field is non-zero"));
237        }
238        let quantizer_version = header_cursor.read_u64::<LittleEndian>()?;
239        let codebook_version = header_cursor.read_u64::<LittleEndian>()?;
240        let vector_count = usize::try_from(header_cursor.read_u64::<LittleEndian>()?)
241            .map_err(|_| invalid_data("ANN vector count exceeds usize"))?;
242        if header_cursor.read_u64::<LittleEndian>()? != 0 {
243            return Err(invalid_data("ANN header tail is non-zero"));
244        }
245        let header = AnnDiskHeader {
246            kind,
247            routing,
248            dim,
249            code_size,
250            num_clusters,
251            quantizer_version,
252            codebook_version,
253            vector_count,
254        };
255        validate_header(&header)?;
256
257        let footer_start = bytes.len() - ANN_FOOTER_SIZE;
258        let mut footer_cursor = std::io::Cursor::new(&bytes[footer_start..]);
259        let directory_offset = usize::try_from(footer_cursor.read_u64::<LittleEndian>()?)
260            .map_err(|_| invalid_data("ANN directory offset exceeds usize"))?;
261        let num_runs = usize::try_from(footer_cursor.read_u64::<LittleEndian>()?)
262            .map_err(|_| invalid_data("ANN run count exceeds usize"))?;
263        if footer_cursor.read_u32::<LittleEndian>()? != ANN_FOOTER_MAGIC
264            || footer_cursor.read_u32::<LittleEndian>()? != u32::from(ANN_DISK_VERSION)
265        {
266            return Err(invalid_data("ANN payload has unsupported footer"));
267        }
268        if num_runs == 0 {
269            return Err(invalid_data("ANN payload has no cluster runs"));
270        }
271        let directory_len = num_runs
272            .checked_mul(ANN_RUN_SIZE)
273            .ok_or_else(|| invalid_data("ANN directory size overflows usize"))?;
274        if directory_offset < ANN_HEADER_SIZE
275            || directory_offset.checked_add(directory_len) != Some(footer_start)
276        {
277            return Err(invalid_data("ANN directory does not end at the footer"));
278        }
279
280        let mut runs = Vec::with_capacity(num_runs);
281        let mut directory_cursor = std::io::Cursor::new(&bytes[directory_offset..footer_start]);
282        let mut previous_cluster = None;
283        let mut counted_vectors = 0usize;
284        for _ in 0..num_runs {
285            let cluster_id = directory_cursor.read_u32::<LittleEndian>()?;
286            let doc_base = directory_cursor.read_u32::<LittleEndian>()?;
287            let count = directory_cursor.read_u32::<LittleEndian>()? as usize;
288            let max_doc_id = directory_cursor.read_u32::<LittleEndian>()?;
289            let doc_ids_offset = usize::try_from(directory_cursor.read_u64::<LittleEndian>()?)
290                .map_err(|_| invalid_data("ANN doc-ID offset exceeds usize"))?;
291            let ordinals_offset = usize::try_from(directory_cursor.read_u64::<LittleEndian>()?)
292                .map_err(|_| invalid_data("ANN ordinal offset exceeds usize"))?;
293            let codes_offset = usize::try_from(directory_cursor.read_u64::<LittleEndian>()?)
294                .map_err(|_| invalid_data("ANN code offset exceeds usize"))?;
295            let codes_len = usize::try_from(directory_cursor.read_u64::<LittleEndian>()?)
296                .map_err(|_| invalid_data("ANN code length exceeds usize"))?;
297            if count == 0
298                || cluster_id >= num_clusters
299                || previous_cluster.is_some_and(|previous| previous > cluster_id)
300                || doc_base
301                    .checked_add(max_doc_id)
302                    .is_none_or(|doc_id| doc_id >= total_docs)
303            {
304                return Err(invalid_data("ANN run metadata is invalid"));
305            }
306            previous_cluster = Some(cluster_id);
307            let doc_ids_len = count
308                .checked_mul(std::mem::size_of::<u32>())
309                .ok_or_else(|| invalid_data("ANN doc-ID column size overflows usize"))?;
310            let ordinals_len = count
311                .checked_mul(std::mem::size_of::<u16>())
312                .ok_or_else(|| invalid_data("ANN ordinal column size overflows usize"))?;
313            let expected_codes_len = expected_codes_column_len(kind, count, code_size)?;
314            let doc_ids_end = doc_ids_offset
315                .checked_add(doc_ids_len)
316                .ok_or_else(|| invalid_data("ANN doc-ID range overflows usize"))?;
317            let ordinals_end = ordinals_offset
318                .checked_add(ordinals_len)
319                .ok_or_else(|| invalid_data("ANN ordinal range overflows usize"))?;
320            let codes_end = codes_offset
321                .checked_add(codes_len)
322                .ok_or_else(|| invalid_data("ANN code range overflows usize"))?;
323            if doc_ids_offset < ANN_HEADER_SIZE
324                || ordinals_offset != doc_ids_end
325                || codes_offset != ordinals_end
326                || codes_len != expected_codes_len
327                || codes_end > directory_offset
328            {
329                return Err(invalid_data("ANN run columns are not contiguous/in bounds"));
330            }
331            runs.push(AnnRun {
332                cluster_id,
333                doc_base,
334                max_doc_id,
335                count,
336                doc_ids: doc_ids_offset..doc_ids_end,
337                ordinals: ordinals_offset..ordinals_end,
338                codes: codes_offset..codes_end,
339            });
340            counted_vectors = counted_vectors
341                .checked_add(count)
342                .ok_or_else(|| invalid_data("ANN run vector count overflows usize"))?;
343        }
344        let mut payload_order: Vec<usize> = (0..runs.len()).collect();
345        payload_order.sort_unstable_by_key(|&index| runs[index].doc_ids.start);
346        let mut expected_payload_offset = ANN_HEADER_SIZE;
347        for index in payload_order {
348            let run = &runs[index];
349            if run.doc_ids.start != expected_payload_offset {
350                return Err(invalid_data("ANN payload runs overlap or contain gaps"));
351            }
352            expected_payload_offset = run.codes.end;
353        }
354        if expected_payload_offset != directory_offset || counted_vectors != vector_count {
355            return Err(invalid_data(
356                "ANN payload coverage/vector count is inconsistent",
357            ));
358        }
359
360        // Clustered queries visit a small set of runs at unrelated offsets.
361        // Disable default mmap readahead for those corpus-sized payloads:
362        // without this, each small run can pull in ~128 KiB and amplify
363        // cold-query IO by an order of magnitude. Their search methods issue
364        // exact WILLNEED ranges before scoring. Flat TQ deliberately scans its
365        // sole cluster and therefore retains a sequential access policy.
366        #[cfg(feature = "native")]
367        raw.madvise_range(
368            ANN_HEADER_SIZE..directory_offset,
369            if kind == AnnKind::TqFlat {
370                libc::MADV_SEQUENTIAL
371            } else {
372                libc::MADV_RANDOM
373            },
374        );
375
376        Ok(Self {
377            #[cfg(feature = "native")]
378            heap_pins: Default::default(),
379            raw,
380            header,
381            runs,
382        })
383    }
384
385    /// Structural health from the run directory alone. O(runs), no payload
386    /// reads — safe to call at every open.
387    pub(crate) fn health(&self) -> AnnHealth {
388        let mut vectors = 0u64;
389        let mut clusters_nonempty = 0u32;
390        let mut payload_bytes = 0u64;
391        let mut largest = (0u32, 0u64);
392        let mut sum_squares = 0f64;
393        // Runs are sorted by cluster ID, so one pass groups them.
394        let mut index = 0usize;
395        while index < self.runs.len() {
396            let cluster_id = self.runs[index].cluster_id;
397            let mut cluster_vectors = 0u64;
398            while index < self.runs.len() && self.runs[index].cluster_id == cluster_id {
399                let run = &self.runs[index];
400                cluster_vectors += run.count as u64;
401                payload_bytes += (run.codes.end - run.codes.start) as u64;
402                index += 1;
403            }
404            vectors += cluster_vectors;
405            clusters_nonempty += 1;
406            sum_squares += (cluster_vectors as f64) * (cluster_vectors as f64);
407            if cluster_vectors > largest.1 {
408                largest = (cluster_id, cluster_vectors);
409            }
410        }
411        let imbalance = if vectors == 0 || clusters_nonempty == 0 {
412            0.0
413        } else {
414            f64::from(clusters_nonempty) * sum_squares / ((vectors as f64) * (vectors as f64))
415        };
416        AnnHealth {
417            vectors,
418            clusters_nonempty,
419            clusters_total: self.header.num_clusters,
420            runs: self.runs.len() as u32,
421            largest_cluster: largest.0,
422            largest_cluster_vectors: largest.1,
423            imbalance,
424            payload_bytes,
425        }
426    }
427
428    /// Log this payload's health, warning on the two known cliff shapes.
429    ///
430    /// Called once per segment open; the caller supplies identity because the
431    /// payload itself does not know its index or field.
432    pub(crate) fn report_health(&self, index_label: &str, field_id: u32, segment_id: u128) {
433        let health = self.health();
434        let share = health.largest_cluster_share();
435        let fragmentation = health.fragmentation();
436        log::info!(
437            "[ann_health] index={index_label} field={field_id} segment={segment_id:016x}: \
438             vectors={} clusters={}/{} runs={} fragmentation={fragmentation:.2} \
439             imbalance={:.2} largest_leaf={:.2}% payload={}",
440            health.vectors,
441            health.clusters_nonempty,
442            health.clusters_total,
443            health.runs,
444            health.imbalance,
445            100.0 * share,
446            crate::format_bytes(health.payload_bytes),
447        );
448        crate::observe::ann_health(
449            index_label,
450            field_id,
451            health.imbalance,
452            fragmentation,
453            share,
454        );
455        if share >= ANN_SKEW_WARN_SHARE && health.vectors >= ANN_SKEW_WARN_MIN_VECTORS {
456            log::warn!(
457                "[ann_health] index={index_label} field={field_id} segment={segment_id:016x}: \
458                 leaf {} holds {:.1}% of {} vectors — every query probing it scans that leaf \
459                 in full; degenerate embeddings collapse into one leaf exactly like this",
460                health.largest_cluster,
461                100.0 * share,
462                health.vectors,
463            );
464        }
465        if fragmentation >= ANN_FRAGMENTATION_WARN {
466            log::warn!(
467                "[ann_health] index={index_label} field={field_id} segment={segment_id:016x}: \
468                 {fragmentation:.1} extents per probed cluster ({} runs / {} clusters) — \
469                 cold probes pay that many seeks; the next merge or vector-generation rewrite \
470                 compacts to 1.0",
471                health.runs,
472                health.clusters_nonempty,
473            );
474        }
475    }
476
477    pub(crate) fn header(&self) -> &AnnDiskHeader {
478        &self.header
479    }
480
481    pub(crate) fn estimated_heap_bytes(&self) -> usize {
482        std::mem::size_of::<Self>() + self.runs.capacity() * std::mem::size_of::<AnnRun>()
483    }
484
485    #[cfg(feature = "native")]
486    pub(crate) fn pin_lookup_directory(
487        &mut self,
488        mode: crate::segment::pin::PinMode,
489        remaining: &mut u64,
490        report: &mut crate::segment::pin::PinReport,
491    ) {
492        let before = self.heap_pins.report();
493        self.heap_pins
494            .pin_slice(&self.runs, "ANN cluster-run directory", mode, remaining);
495        let after = self.heap_pins.report();
496        report.intended_bytes += after.intended_bytes - before.intended_bytes;
497        report.pinned_bytes += after.pinned_bytes - before.pinned_bytes;
498        report.skipped_budget_bytes += after.skipped_budget_bytes - before.skipped_budget_bytes;
499        report.failed_bytes += after.failed_bytes - before.failed_bytes;
500        report.heap_copy_bytes += after.heap_copy_bytes - before.heap_copy_bytes;
501    }
502
503    fn cluster_runs(&self, cluster_id: u32) -> &[AnnRun] {
504        let start = self.runs.partition_point(|run| run.cluster_id < cluster_id);
505        let end = self
506            .runs
507            .partition_point(|run| run.cluster_id <= cluster_id);
508        &self.runs[start..end]
509    }
510
511    /// Prefetch exactly the mmap ranges needed by the selected IVF leaves.
512    ///
513    /// A pure-copy merge preserves each source payload as one physical extent,
514    /// so runs for one logical cluster can be far apart. Sorting by file offset
515    /// lets us coalesce overlaps and page-near ranges without reading through
516    /// unrelated clusters.
517    #[cfg(feature = "native")]
518    fn prefetch_cluster_runs(&self, cluster_ids: &[u32]) {
519        if cluster_ids.is_empty() || !self.raw.is_mmap() {
520            return;
521        }
522        let mut ranges = Vec::with_capacity(cluster_ids.len());
523        for &cluster_id in cluster_ids {
524            ranges.extend(
525                self.cluster_runs(cluster_id)
526                    .iter()
527                    .map(|run| run.doc_ids.start..run.codes.end),
528            );
529        }
530        coalesce_prefetch_ranges(&mut ranges);
531        for range in ranges {
532            self.raw.madvise_range(range, libc::MADV_WILLNEED);
533        }
534    }
535
536    /// Score a flat TQ payload while every value of a document is still in
537    /// hand, combine those approximate scores, and retain document-level
538    /// top-k. TQ build runs preserve `(doc_id, ordinal)` input order, so the
539    /// scratch space is bounded by one document plus the retained heap.
540    pub(crate) fn search_tq_combined_documents(
541        &self,
542        k: usize,
543        plan: &crate::structures::TqQueryPlan,
544        combiner: crate::query::MultiValueCombiner,
545    ) -> io::Result<Vec<AnnDocumentCandidate>> {
546        use crate::structures::vector::quantization::{TQ_BLOCK_LANES, tq_block_bytes};
547
548        combiner
549            .validate()
550            .map_err(|message| io::Error::new(io::ErrorKind::InvalidInput, message))?;
551        if plan.padded_dim() != self.header.code_size * 2 {
552            return Err(io::Error::new(
553                io::ErrorKind::InvalidInput,
554                "TQ query plan does not match the payload dimension",
555            ));
556        }
557        if k == 0 {
558            return Ok(Vec::new());
559        }
560
561        let block_bytes = tq_block_bytes(self.header.code_size);
562        let bytes = self.raw.as_slice();
563        let mut top_documents = BoundedAnnCollector::<true, true>::new(k);
564        let mut ordinal_scores = Vec::new();
565        let mut scores = [0.0f32; TQ_BLOCK_LANES];
566
567        for run in &self.runs {
568            let mut current_doc = None;
569            let codes = &bytes[run.codes.clone()];
570            for (block_index, block) in codes.chunks_exact(block_bytes).enumerate() {
571                crate::structures::vector::quantization::tq_score_block(plan, block, &mut scores);
572                let lane_base = block_index * TQ_BLOCK_LANES;
573                let lanes = TQ_BLOCK_LANES.min(run.count.saturating_sub(lane_base));
574                for (lane, &score) in scores.iter().enumerate().take(lanes) {
575                    let index = lane_base + lane;
576                    let doc_id = run_doc_id(bytes, run, index)?;
577                    if current_doc.is_some_and(|previous| doc_id < previous) {
578                        return Err(invalid_data("flat TQ run is not grouped by document ID"));
579                    }
580                    if current_doc.is_some_and(|previous| doc_id != previous) {
581                        retain_combined_document(
582                            &mut top_documents,
583                            current_doc.expect("current document is present"),
584                            &ordinal_scores,
585                            combiner,
586                        );
587                        ordinal_scores.clear();
588                    }
589                    current_doc = Some(doc_id);
590                    if score.is_finite() {
591                        ordinal_scores.push((
592                            u32::from(read_u16(bytes, run.ordinals.start + index * 2)),
593                            score,
594                        ));
595                    }
596                }
597            }
598            if let Some(doc_id) = current_doc {
599                retain_combined_document(&mut top_documents, doc_id, &ordinal_scores, combiner);
600                ordinal_scores.clear();
601            }
602        }
603
604        Ok(top_documents
605            .into_sorted_results()
606            .into_iter()
607            .map(|(doc_id, _, score)| AnnDocumentCandidate { doc_id, score })
608            .collect())
609    }
610
611    /// Score every posting in the probed IVF-TQ leaves, deduplicate SOAR
612    /// assignments by `(doc_id, ordinal)`, combine every approximate ordinal
613    /// present in the probed leaves, and retain at most `k` documents.
614    ///
615    /// Unlike the Max path, this deliberately performs no individual-score
616    /// pruning: an ordinal that cannot enter vector top-k may still change a
617    /// Sum, Avg, LogSumExp, or WeightedTopK document score.
618    pub(crate) fn search_ivf_tq_combined_documents(
619        &self,
620        k: usize,
621        plan: &crate::structures::TqIvfQueryPlan,
622        combiner: crate::query::MultiValueCombiner,
623    ) -> io::Result<Vec<AnnDocumentCandidate>> {
624        use crate::structures::vector::quantization::{
625            TQ_BLOCK_LANES, tq_ivf_block_bytes, tq_score_ivf_block,
626        };
627
628        validate_combined_search(combiner)?;
629        let tq_plan = plan.tq_plan();
630        self.validate_ivf_tq_query_plan(plan)?;
631        if k == 0 {
632            return Ok(Vec::new());
633        }
634        #[cfg(feature = "native")]
635        self.prefetch_cluster_runs(&plan.cluster_ids);
636
637        let block_bytes = tq_ivf_block_bytes(self.header.code_size);
638        let bytes = self.raw.as_slice();
639        let mut ordinal_scores = Vec::new();
640        ordinal_scores
641            .try_reserve_exact(probed_posting_count(self, &plan.cluster_ids)?)
642            .map_err(|_| invalid_data("IVF-TQ combined score buffer allocation failed"))?;
643        let mut scores = [0.0f32; TQ_BLOCK_LANES];
644        for (cluster_id, cluster_dot) in plan.cluster_dots() {
645            for run in self.cluster_runs(cluster_id) {
646                let codes = &bytes[run.codes.clone()];
647                for (block_index, block) in codes.chunks_exact(block_bytes).enumerate() {
648                    tq_score_ivf_block(tq_plan, block, cluster_dot, &mut scores);
649                    let lane_base = block_index * TQ_BLOCK_LANES;
650                    let lanes = TQ_BLOCK_LANES.min(run.count.saturating_sub(lane_base));
651                    for (lane, &score) in scores.iter().enumerate().take(lanes) {
652                        if !score.is_finite() {
653                            continue;
654                        }
655                        let index = lane_base + lane;
656                        ordinal_scores.push((
657                            run_doc_id(bytes, run, index)?,
658                            read_u16(bytes, run.ordinals.start + index * 2),
659                            score,
660                        ));
661                    }
662                }
663            }
664        }
665        Ok(combine_scored_ordinals(ordinal_scores, k, combiner))
666    }
667
668    /// Score packed codes in the selected binary-IVF leaves, deduplicate
669    /// repeated `(doc_id, ordinal)` assignments, combine per document, and
670    /// retain at most `k` approximate document candidates.
671    ///
672    /// The second return value carries the per-ordinal scores behind the
673    /// retained documents. Binary leaves store the original packed codes, so
674    /// those scores are *exact*, and reranking can reuse them instead of
675    /// re-reading the same codes from flat storage.
676    pub(crate) fn search_binary_combined_documents(
677        &self,
678        k: usize,
679        query: &[u8],
680        cluster_ids: &[u32],
681        combiner: crate::query::MultiValueCombiner,
682    ) -> io::Result<CombinedBinaryCandidates> {
683        validate_combined_search(combiner)?;
684        if query.len() != self.header.code_size {
685            return Err(io::Error::new(
686                io::ErrorKind::InvalidInput,
687                "binary ANN query has the wrong byte length",
688            ));
689        }
690        if k == 0 {
691            return Ok((Vec::new(), Vec::new()));
692        }
693        #[cfg(feature = "native")]
694        self.prefetch_cluster_runs(cluster_ids);
695
696        let bytes = self.raw.as_slice();
697        let mut score_batch = vec![0.0f32; BINARY_SCORE_BATCH.min(self.header.vector_count)];
698        let mut ordinal_scores = Vec::new();
699        ordinal_scores
700            .try_reserve_exact(probed_posting_count(self, cluster_ids)?)
701            .map_err(|_| invalid_data("binary combined score buffer allocation failed"))?;
702        score_binary_cluster_runs(
703            self,
704            bytes,
705            query,
706            cluster_ids,
707            &mut score_batch,
708            &mut ordinal_scores,
709        )?;
710        Ok(combine_scored_ordinals_retaining(
711            ordinal_scores,
712            k,
713            combiner,
714        ))
715    }
716
717    /// Score every TQ block against the query plan and keep the top `k`
718    /// distinct documents by estimated similarity.
719    pub(crate) fn search_tq_distinct(
720        &self,
721        k: usize,
722        plan: &crate::structures::TqQueryPlan,
723    ) -> io::Result<Vec<(u32, u16, f32)>> {
724        use crate::structures::vector::quantization::{TQ_BLOCK_LANES, tq_block_bytes};
725
726        if plan.padded_dim() != self.header.code_size * 2 {
727            return Err(io::Error::new(
728                io::ErrorKind::InvalidInput,
729                "TQ query plan does not match the payload dimension",
730            ));
731        }
732        let block_bytes = tq_block_bytes(self.header.code_size);
733        let bytes = self.raw.as_slice();
734
735        // Large flat scans are CPU-bound on the LUT16 kernel. Fold chunks into
736        // worker-local collectors and reduce them directly: materializing one
737        // top-k Vec per chunk would make temporary memory O(chunks * k) on
738        // large segments instead of O(workers * k).
739        #[cfg(feature = "native")]
740        if self.header.vector_count >= TQ_PARALLEL_SCAN_MIN_VECTORS {
741            use rayon::prelude::*;
742            let collector = self
743                .runs
744                .par_iter()
745                .flat_map(|run| {
746                    let codes = &bytes[run.codes.clone()];
747                    let blocks = codes.len() / block_bytes;
748                    (0..blocks.div_ceil(TQ_PARALLEL_SCAN_CHUNK_BLOCKS))
749                        .into_par_iter()
750                        .map(move |chunk| (run, chunk * TQ_PARALLEL_SCAN_CHUNK_BLOCKS, blocks))
751                })
752                .try_fold(
753                    || BoundedAnnCollector::<true, true>::new(k),
754                    |mut collector, (run, first_block, total_blocks)| {
755                        let codes = &bytes[run.codes.clone()];
756                        let last_block =
757                            (first_block + TQ_PARALLEL_SCAN_CHUNK_BLOCKS).min(total_blocks);
758                        let mut scores = [0.0f32; TQ_BLOCK_LANES];
759                        for block_index in first_block..last_block {
760                            let block = &codes[block_index * block_bytes..][..block_bytes];
761                            crate::structures::vector::quantization::tq_score_block(
762                                plan,
763                                block,
764                                &mut scores,
765                            );
766                            let lane_base = block_index * TQ_BLOCK_LANES;
767                            let lanes = TQ_BLOCK_LANES.min(run.count.saturating_sub(lane_base));
768                            for (lane, &score) in scores.iter().enumerate().take(lanes) {
769                                let index = lane_base + lane;
770                                collector.insert(
771                                    run_doc_id(bytes, run, index)?,
772                                    read_u16(bytes, run.ordinals.start + index * 2),
773                                    score,
774                                );
775                            }
776                        }
777                        Ok::<_, io::Error>(collector)
778                    },
779                )
780                .try_reduce(
781                    || BoundedAnnCollector::<true, true>::new(k),
782                    |mut collector, partial| {
783                        collector.merge_from(partial);
784                        Ok(collector)
785                    },
786                )?;
787            return Ok(collector.into_sorted_results());
788        }
789
790        let mut collector = BoundedAnnCollector::<true, true>::new(k);
791        let mut scores = [0.0f32; TQ_BLOCK_LANES];
792        for run in &self.runs {
793            let codes = &bytes[run.codes.clone()];
794            for (block_index, block) in codes.chunks_exact(block_bytes).enumerate() {
795                crate::structures::vector::quantization::tq_score_block(plan, block, &mut scores);
796                let lane_base = block_index * TQ_BLOCK_LANES;
797                let lanes = TQ_BLOCK_LANES.min(run.count.saturating_sub(lane_base));
798                for (lane, &score) in scores.iter().enumerate().take(lanes) {
799                    let index = lane_base + lane;
800                    collector.insert(
801                        run_doc_id(bytes, run, index)?,
802                        read_u16(bytes, run.ordinals.start + index * 2),
803                        score,
804                    );
805                }
806            }
807        }
808        Ok(collector.into_sorted_results())
809    }
810
811    /// Score the probed IVF-TQ leaves and keep the top `k` distinct
812    /// documents by estimated cosine similarity
813    /// (`⟨q̂,c⟩ + scale·⟨q̂,r̂⟩`).
814    ///
815    /// Blocks whose best possible estimate cannot beat the running k-th score
816    /// terminate their run. The supported cosine generation guarantees that
817    /// residual scales are stored in descending order.
818    pub(crate) fn search_ivf_tq_distinct(
819        &self,
820        k: usize,
821        plan: &crate::structures::TqIvfQueryPlan,
822    ) -> io::Result<Vec<(u32, u16, f32)>> {
823        use crate::structures::vector::quantization::{
824            TQ_BLOCK_LANES, tq_ivf_block_bytes, tq_score_ivf_block,
825        };
826
827        let tq_plan = plan.tq_plan();
828        self.validate_ivf_tq_query_plan(plan)?;
829        #[cfg(feature = "native")]
830        self.prefetch_cluster_runs(&plan.cluster_ids);
831        let block_bytes = tq_ivf_block_bytes(self.header.code_size);
832        let bytes = self.raw.as_slice();
833        let mut collector = BoundedAnnCollector::<true, true>::new(k);
834        let mut scores = [0.0f32; TQ_BLOCK_LANES];
835        let mut pruned_blocks = 0usize;
836        let mut scored_blocks = 0usize;
837        for (cluster_id, cluster_dot) in plan.cluster_dots() {
838            for run in self.cluster_runs(cluster_id) {
839                let codes = &bytes[run.codes.clone()];
840                let block_count = codes.len() / block_bytes;
841                for (block_index, block) in codes.chunks_exact(block_bytes).enumerate() {
842                    if let Some(threshold) = collector.pruning_threshold() {
843                        let max_scale = tq_ivf_block_max_scale(block);
844                        if cluster_dot + max_scale * TQ_PRUNE_ESTIMATE_BOUND <= threshold {
845                            pruned_blocks += block_count - block_index;
846                            break;
847                        }
848                    }
849                    scored_blocks += 1;
850                    tq_score_ivf_block(tq_plan, block, cluster_dot, &mut scores);
851                    let lane_base = block_index * TQ_BLOCK_LANES;
852                    let lanes = TQ_BLOCK_LANES.min(run.count.saturating_sub(lane_base));
853                    for (lane, &score) in scores.iter().enumerate().take(lanes) {
854                        let index = lane_base + lane;
855                        collector.insert(
856                            run_doc_id(bytes, run, index)?,
857                            read_u16(bytes, run.ordinals.start + index * 2),
858                            score,
859                        );
860                    }
861                }
862            }
863        }
864        if pruned_blocks > 0 {
865            log::debug!(
866                "[search_ivf_tq] pruned {pruned_blocks} of {} blocks via scale bounds",
867                pruned_blocks + scored_blocks,
868            );
869        }
870        Ok(collector.into_sorted_results())
871    }
872
873    fn validate_ivf_tq_query_plan(
874        &self,
875        plan: &crate::structures::TqIvfQueryPlan,
876    ) -> io::Result<()> {
877        if self.header.kind != AnnKind::IvfTq
878            || !crate::structures::is_ivf_tq_cosine_generation(self.header.quantizer_version)
879        {
880            return Err(invalid_data(
881                "legacy raw IVF-TQ payloads cannot be searched; rebuild the index",
882            ));
883        }
884        if plan.tq_plan().padded_dim() != self.header.code_size * 2
885            || plan.quantizer_version != self.header.quantizer_version
886            || plan.fingerprint != self.header.codebook_version
887        {
888            return Err(io::Error::new(
889                io::ErrorKind::InvalidInput,
890                "IVF-TQ query plan does not match the payload generation",
891            ));
892        }
893        Ok(())
894    }
895
896    pub(crate) fn search_binary_clusters<const BY_DOCUMENT: bool>(
897        &self,
898        query: &[u8],
899        k: usize,
900        cluster_ids: &[u32],
901    ) -> io::Result<Vec<(u32, u16, f32)>> {
902        if query.len() != self.header.code_size {
903            return Err(io::Error::new(
904                io::ErrorKind::InvalidInput,
905                "binary ANN query has the wrong byte length",
906            ));
907        }
908        #[cfg(feature = "native")]
909        self.prefetch_cluster_runs(cluster_ids);
910        let bytes = self.raw.as_slice();
911        let mut scores = vec![0.0f32; BINARY_SCORE_BATCH.min(self.header.vector_count)];
912        if BY_DOCUMENT {
913            let mut collector = BoundedAnnCollector::<true, true>::new(k);
914            score_binary_cluster_runs(
915                self,
916                bytes,
917                query,
918                cluster_ids,
919                &mut scores,
920                &mut collector,
921            )?;
922            return Ok(collector.into_sorted_results());
923        }
924
925        // A probe plan returns each leaf once and every indexed vector belongs
926        // to exactly one leaf, so `(doc_id, ordinal)` keys are unique here.
927        // Avoid the deduplication hash map in the common single-value path.
928        debug_assert!(
929            {
930                let mut seen = rustc_hash::FxHashSet::default();
931                cluster_ids
932                    .iter()
933                    .all(|cluster_id| seen.insert(*cluster_id))
934            },
935            "an IVF probe plan must not repeat a cluster",
936        );
937        let mut collector = BoundedUniqueAnnCollector::<true>::new(k);
938        score_binary_cluster_runs(self, bytes, query, cluster_ids, &mut scores, &mut collector)?;
939        Ok(collector.into_sorted_results())
940    }
941}
942
943#[cfg(feature = "native")]
944fn coalesce_prefetch_ranges(ranges: &mut Vec<Range<usize>>) {
945    if ranges.len() < 2 {
946        return;
947    }
948    ranges.sort_unstable_by_key(|range| range.start);
949    let mut output_len = 1usize;
950    for input_index in 1..ranges.len() {
951        let next_start = ranges[input_index].start;
952        let next_end = ranges[input_index].end;
953        let previous = &mut ranges[output_len - 1];
954        if next_start <= previous.end.saturating_add(PREFETCH_COALESCE_GAP) {
955            previous.end = previous.end.max(next_end);
956        } else {
957            ranges[output_len] = next_start..next_end;
958            output_len += 1;
959        }
960    }
961    ranges.truncate(output_len);
962}
963
964trait AnnScoreSink {
965    fn insert_score(&mut self, doc_id: u32, ordinal: u16, score: f32);
966}
967
968impl<const BY_DOCUMENT: bool> AnnScoreSink for BoundedAnnCollector<BY_DOCUMENT, true> {
969    #[inline]
970    fn insert_score(&mut self, doc_id: u32, ordinal: u16, score: f32) {
971        self.insert(doc_id, ordinal, score);
972    }
973}
974
975impl AnnScoreSink for BoundedUniqueAnnCollector<true> {
976    #[inline]
977    fn insert_score(&mut self, doc_id: u32, ordinal: u16, score: f32) {
978        self.insert(doc_id, ordinal, score);
979    }
980}
981
982impl AnnScoreSink for Vec<(u32, u16, f32)> {
983    #[inline]
984    fn insert_score(&mut self, doc_id: u32, ordinal: u16, score: f32) {
985        if score.is_finite() {
986            self.push((doc_id, ordinal, score));
987        }
988    }
989}
990
991fn probed_posting_count(index: &AnnDiskIndex, cluster_ids: &[u32]) -> io::Result<usize> {
992    cluster_ids.iter().try_fold(0usize, |count, &cluster_id| {
993        index
994            .cluster_runs(cluster_id)
995            .iter()
996            .try_fold(count, |count, run| {
997                count
998                    .checked_add(run.count)
999                    .ok_or_else(|| invalid_data("ANN probed posting count overflows usize"))
1000            })
1001    })
1002}
1003
1004fn score_binary_cluster_runs(
1005    index: &AnnDiskIndex,
1006    bytes: &[u8],
1007    query: &[u8],
1008    cluster_ids: &[u32],
1009    scores: &mut [f32],
1010    collector: &mut impl AnnScoreSink,
1011) -> io::Result<()> {
1012    for &cluster_id in cluster_ids {
1013        for run in index.cluster_runs(cluster_id) {
1014            score_binary_run(
1015                bytes,
1016                run,
1017                query,
1018                index.header.dim,
1019                index.header.code_size,
1020                scores,
1021                collector,
1022            )?;
1023        }
1024    }
1025    Ok(())
1026}
1027
1028fn score_binary_run(
1029    bytes: &[u8],
1030    run: &AnnRun,
1031    query: &[u8],
1032    dim_bits: usize,
1033    code_size: usize,
1034    scores: &mut [f32],
1035    collector: &mut impl AnnScoreSink,
1036) -> io::Result<()> {
1037    for batch_start in (0..run.count).step_by(BINARY_SCORE_BATCH) {
1038        let batch_count = BINARY_SCORE_BATCH.min(run.count - batch_start);
1039        let code_start = run.codes.start + batch_start * code_size;
1040        let code_end = code_start + batch_count * code_size;
1041        crate::structures::simd::batch_hamming_scores(
1042            query,
1043            &bytes[code_start..code_end],
1044            code_size,
1045            dim_bits,
1046            &mut scores[..batch_count],
1047        );
1048        for (batch_index, &score) in scores.iter().enumerate().take(batch_count) {
1049            let index = batch_start + batch_index;
1050            collector.insert_score(
1051                run_doc_id(bytes, run, index)?,
1052                read_u16(bytes, run.ordinals.start + index * 2),
1053                score,
1054            );
1055        }
1056    }
1057    Ok(())
1058}
1059
1060#[inline]
1061fn retain_combined_document(
1062    collector: &mut BoundedAnnCollector<true, true>,
1063    doc_id: u32,
1064    ordinal_scores: &[(u32, f32)],
1065    combiner: crate::query::MultiValueCombiner,
1066) {
1067    if !ordinal_scores.is_empty() {
1068        collector.insert(doc_id, 0, combiner.combine(ordinal_scores));
1069    }
1070}
1071
1072fn validate_combined_search(combiner: crate::query::MultiValueCombiner) -> io::Result<()> {
1073    combiner
1074        .validate()
1075        .map_err(|message| io::Error::new(io::ErrorKind::InvalidInput, message))
1076}
1077
1078/// Sort arbitrary probed-run output into complete documents, retain the best
1079/// estimate for every duplicated `(doc_id, ordinal)` SOAR assignment, then
1080/// apply the requested combiner. The corpus-dependent scratch is one compact
1081/// tuple per probed posting and never expands to unprobed leaves or raw
1082/// vectors; final retained state is O(k).
1083fn combine_scored_ordinals(
1084    scores: Vec<(u32, u16, f32)>,
1085    k: usize,
1086    combiner: crate::query::MultiValueCombiner,
1087) -> Vec<AnnDocumentCandidate> {
1088    combine_scored_ordinals_retaining(scores, k, combiner).0
1089}
1090
1091/// As [`combine_scored_ordinals`], additionally returning the deduplicated
1092/// per-ordinal scores of the retained documents, sorted by `(doc_id, ordinal)`.
1093///
1094/// Only callers whose leaf scores are exact may reuse those numbers; TQ block
1095/// scores are estimates and must still be reranked against raw vectors.
1096fn combine_scored_ordinals_retaining(
1097    mut scores: Vec<(u32, u16, f32)>,
1098    k: usize,
1099    combiner: crate::query::MultiValueCombiner,
1100) -> CombinedBinaryCandidates {
1101    if k == 0 || scores.is_empty() {
1102        return (Vec::new(), Vec::new());
1103    }
1104    scores.retain(|entry| entry.2.is_finite());
1105    scores.sort_unstable_by(|left, right| left.0.cmp(&right.0).then_with(|| left.1.cmp(&right.1)));
1106
1107    // Compact duplicates in place. IVF-TQ SOAR copies can have slightly
1108    // different residual estimates; preserving the highest one matches the
1109    // legacy Max collector's duplicate semantics without double-counting it
1110    // for additive combiners.
1111    let mut unique_len = 0usize;
1112    for read_index in 0..scores.len() {
1113        let candidate = scores[read_index];
1114        if unique_len > 0
1115            && scores[unique_len - 1].0 == candidate.0
1116            && scores[unique_len - 1].1 == candidate.1
1117        {
1118            if candidate.2.total_cmp(&scores[unique_len - 1].2).is_gt() {
1119                scores[unique_len - 1].2 = candidate.2;
1120            }
1121        } else {
1122            scores[unique_len] = candidate;
1123            unique_len += 1;
1124        }
1125    }
1126    scores.truncate(unique_len);
1127
1128    let mut top_documents = BoundedAnnCollector::<true, true>::new(k);
1129    let mut current_doc = None;
1130    let mut ordinal_scores = Vec::new();
1131    for &(doc_id, ordinal, score) in &scores {
1132        if current_doc.is_some_and(|current| current != doc_id) {
1133            retain_combined_document(
1134                &mut top_documents,
1135                current_doc.expect("current document is present"),
1136                &ordinal_scores,
1137                combiner,
1138            );
1139            ordinal_scores.clear();
1140        }
1141        current_doc = Some(doc_id);
1142        ordinal_scores.push((u32::from(ordinal), score));
1143    }
1144    if let Some(doc_id) = current_doc {
1145        retain_combined_document(&mut top_documents, doc_id, &ordinal_scores, combiner);
1146    }
1147
1148    let candidates: Vec<AnnDocumentCandidate> = top_documents
1149        .into_sorted_results()
1150        .into_iter()
1151        .map(|(doc_id, _, score)| AnnDocumentCandidate { doc_id, score })
1152        .collect();
1153
1154    // Narrow the per-ordinal scores to the retained documents. `scores` is
1155    // already sorted by document, so this is one pass over a sorted ID set
1156    // rather than a hash lookup per posting.
1157    let mut retained_ids: Vec<u32> = candidates
1158        .iter()
1159        .map(|candidate| candidate.doc_id)
1160        .collect();
1161    retained_ids.sort_unstable();
1162    let mut retained = Vec::with_capacity(scores.len().min(retained_ids.len().saturating_mul(4)));
1163    let mut cursor = 0usize;
1164    for entry in scores {
1165        while cursor < retained_ids.len() && retained_ids[cursor] < entry.0 {
1166            cursor += 1;
1167        }
1168        if retained_ids.get(cursor) == Some(&entry.0) {
1169            retained.push(entry);
1170        }
1171    }
1172    (candidates, retained)
1173}
1174
1175#[cfg(feature = "native")]
1176pub(crate) fn write_built_binary_ivf(
1177    index: &BinaryIvfIndex,
1178    routing: IvfRoutingMode,
1179    writer: &mut (impl Write + ?Sized),
1180) -> io::Result<u64> {
1181    let runs: Vec<_> = index
1182        .clusters
1183        .iter()
1184        .map(|(cluster_id, cluster)| BuildRun {
1185            cluster_id: *cluster_id,
1186            doc_ids: &cluster.doc_ids,
1187            ordinals: &cluster.ordinals,
1188            codes: &cluster.codes,
1189        })
1190        .collect();
1191    write_built_runs(
1192        AnnDiskHeader {
1193            kind: AnnKind::BinaryIvf,
1194            routing,
1195            dim: index.dim_bits,
1196            code_size: index.dim_bits.div_ceil(8),
1197            num_clusters: index.num_clusters,
1198            quantizer_version: index.quantizer_version,
1199            codebook_version: 0,
1200            vector_count: index.len(),
1201        },
1202        &runs,
1203        writer,
1204    )
1205}
1206
1207/// Serialize a populated IVF-TQ build: one run per non-empty leaf, codes
1208/// block-packed per run (scales + gammas + dimension-major nibbles).
1209#[cfg(feature = "native")]
1210pub(crate) fn write_built_ivf_tq(
1211    index: &crate::structures::IvfTqIndex,
1212    num_clusters: u32,
1213    writer: &mut (impl Write + ?Sized),
1214) -> io::Result<u64> {
1215    use crate::structures::vector::quantization::{TQ_BLOCK_LANES, tq_pack_ivf_block};
1216
1217    if !crate::structures::is_ivf_tq_cosine_generation(index.centroids_version) {
1218        return Err(invalid_data(
1219            "legacy raw IVF-TQ generations cannot be serialized; rebuild the index",
1220        ));
1221    }
1222    let codec = index.codec();
1223    let padded_dim = codec.padded_dim();
1224    let mut clusters: Vec<_> = index.clusters.iter().collect();
1225    clusters.sort_unstable_by_key(|(cluster_id, _)| **cluster_id);
1226    // Emit every cluster in descending residual-scale order: block maxima
1227    // then decrease monotonically, so the scan's per-block score bound can
1228    // stop a run at the first block that cannot beat the running k-th score.
1229    struct PackedCluster {
1230        cluster_id: u32,
1231        doc_ids: Vec<u32>,
1232        ordinals: Vec<u16>,
1233        codes: Vec<u8>,
1234    }
1235    let packed: Vec<PackedCluster> = clusters
1236        .iter()
1237        .map(|&(&cluster_id, cluster)| {
1238            let count = cluster.doc_ids.len();
1239            let mut order: Vec<usize> = (0..count).collect();
1240            order.sort_by(|&a, &b| {
1241                cluster.scales[b]
1242                    .total_cmp(&cluster.scales[a])
1243                    .then_with(|| a.cmp(&b))
1244            });
1245            let doc_ids: Vec<u32> = order.iter().map(|&i| cluster.doc_ids[i]).collect();
1246            let ordinals: Vec<u16> = order.iter().map(|&i| cluster.ordinals[i]).collect();
1247            let scales: Vec<f32> = order.iter().map(|&i| cluster.scales[i]).collect();
1248            let gammas: Vec<f32> = order.iter().map(|&i| cluster.gammas[i]).collect();
1249            let mut codes = Vec::with_capacity(
1250                crate::structures::vector::quantization::tq_ivf_codes_column_len_checked(
1251                    count,
1252                    codec.code_size(),
1253                )
1254                .unwrap_or_default(),
1255            );
1256            for block_start in (0..count).step_by(TQ_BLOCK_LANES) {
1257                let lanes = TQ_BLOCK_LANES.min(count - block_start);
1258                let rows: Vec<&[u8]> = order[block_start..block_start + lanes]
1259                    .iter()
1260                    .map(|&row| &cluster.rows[row * padded_dim..(row + 1) * padded_dim])
1261                    .collect();
1262                tq_pack_ivf_block(
1263                    &rows,
1264                    &scales[block_start..block_start + lanes],
1265                    &gammas[block_start..block_start + lanes],
1266                    padded_dim,
1267                    &mut codes,
1268                );
1269            }
1270            PackedCluster {
1271                cluster_id,
1272                doc_ids,
1273                ordinals,
1274                codes,
1275            }
1276        })
1277        .collect();
1278    let runs: Vec<_> = packed
1279        .iter()
1280        .map(|cluster| BuildRun {
1281            cluster_id: cluster.cluster_id,
1282            doc_ids: &cluster.doc_ids,
1283            ordinals: &cluster.ordinals,
1284            codes: &cluster.codes,
1285        })
1286        .collect();
1287    write_built_runs(
1288        AnnDiskHeader {
1289            kind: AnnKind::IvfTq,
1290            routing: index.routing,
1291            dim: index.dim,
1292            code_size: codec.code_size(),
1293            num_clusters,
1294            quantizer_version: index.centroids_version,
1295            codebook_version: codec.fingerprint(),
1296            vector_count: index.len(),
1297        },
1298        &runs,
1299        writer,
1300    )
1301}
1302
1303/// View a populated TQ builder as a single extra merge run (cluster 0).
1304#[cfg(feature = "native")]
1305pub(crate) fn tq_builder_extra_run(builder: &crate::structures::TqFlatBuilder) -> BuildRun<'_> {
1306    BuildRun {
1307        cluster_id: 0,
1308        doc_ids: &builder.doc_ids,
1309        ordinals: &builder.ordinals,
1310        codes: &builder.codes,
1311    }
1312}
1313
1314/// Serialize a populated TQ builder as a single-run payload.
1315#[cfg(feature = "native")]
1316pub(crate) fn write_built_tq_flat(
1317    builder: &crate::structures::TqFlatBuilder,
1318    writer: &mut (impl Write + ?Sized),
1319) -> io::Result<u64> {
1320    let codec = builder.codec();
1321    let runs = [BuildRun {
1322        cluster_id: 0,
1323        doc_ids: &builder.doc_ids,
1324        ordinals: &builder.ordinals,
1325        codes: &builder.codes,
1326    }];
1327    write_built_runs(
1328        AnnDiskHeader {
1329            kind: AnnKind::TqFlat,
1330            routing: IvfRoutingMode::Flat,
1331            dim: codec.dim(),
1332            code_size: codec.code_size(),
1333            num_clusters: 1,
1334            quantizer_version: codec.fingerprint(),
1335            codebook_version: 0,
1336            vector_count: builder.len(),
1337        },
1338        &runs,
1339        writer,
1340    )
1341}
1342
1343#[cfg(feature = "native")]
1344pub(crate) struct BuildRun<'a> {
1345    cluster_id: u32,
1346    doc_ids: &'a [u32],
1347    ordinals: &'a [u16],
1348    codes: &'a [u8],
1349}
1350
1351#[cfg(feature = "native")]
1352struct RunRecord {
1353    cluster_id: u32,
1354    doc_base: u32,
1355    count: u32,
1356    max_doc_id: u32,
1357    doc_ids_offset: u64,
1358    ordinals_offset: u64,
1359    codes_offset: u64,
1360    codes_len: u64,
1361}
1362
1363#[cfg(feature = "native")]
1364fn write_built_runs(
1365    header: AnnDiskHeader,
1366    runs: &[BuildRun<'_>],
1367    writer: &mut (impl Write + ?Sized),
1368) -> io::Result<u64> {
1369    if runs.is_empty() || header.vector_count == 0 {
1370        return Err(invalid_data("cannot write an empty ANN payload"));
1371    }
1372    validate_header(&header)?;
1373    write_header(writer, &header)?;
1374    let mut offset = ANN_HEADER_SIZE as u64;
1375    let mut records = Vec::with_capacity(runs.len());
1376    let mut counted = 0usize;
1377    let mut scratch = Vec::new();
1378    let mut previous_cluster = None;
1379    for run in runs {
1380        let count = run.doc_ids.len();
1381        if count == 0
1382            || run.cluster_id >= header.num_clusters
1383            || previous_cluster.is_some_and(|cluster| cluster >= run.cluster_id)
1384            || run.ordinals.len() != count
1385            || run.codes.len() != expected_codes_column_len(header.kind, count, header.code_size)?
1386        {
1387            return Err(invalid_data("ANN build run columns are inconsistent"));
1388        }
1389        previous_cluster = Some(run.cluster_id);
1390        let count_u32 = u32::try_from(count)
1391            .map_err(|_| invalid_data("ANN cluster run exceeds u32 vectors"))?;
1392        let max_doc_id = run.doc_ids.iter().copied().max().unwrap_or(0);
1393        let doc_ids_offset = offset;
1394        write_u32_column(writer, run.doc_ids, &mut scratch)?;
1395        offset = offset
1396            .checked_add(
1397                u64::try_from(count)
1398                    .ok()
1399                    .and_then(|count| count.checked_mul(4))
1400                    .ok_or_else(|| invalid_data("ANN doc-ID output size overflows u64"))?,
1401            )
1402            .ok_or_else(|| invalid_data("ANN output offset overflow"))?;
1403        let ordinals_offset = offset;
1404        write_u16_column(writer, run.ordinals, &mut scratch)?;
1405        offset = offset
1406            .checked_add(
1407                u64::try_from(count)
1408                    .ok()
1409                    .and_then(|count| count.checked_mul(2))
1410                    .ok_or_else(|| invalid_data("ANN ordinal output size overflows u64"))?,
1411            )
1412            .ok_or_else(|| invalid_data("ANN output offset overflow"))?;
1413        let codes_offset = offset;
1414        writer.write_all(run.codes)?;
1415        offset = offset
1416            .checked_add(
1417                u64::try_from(run.codes.len())
1418                    .map_err(|_| invalid_data("ANN code output size exceeds u64"))?,
1419            )
1420            .ok_or_else(|| invalid_data("ANN output offset overflow"))?;
1421        records.push(RunRecord {
1422            cluster_id: run.cluster_id,
1423            doc_base: 0,
1424            count: count_u32,
1425            max_doc_id,
1426            doc_ids_offset,
1427            ordinals_offset,
1428            codes_offset,
1429            codes_len: u64::try_from(run.codes.len())
1430                .map_err(|_| invalid_data("ANN code output size exceeds u64"))?,
1431        });
1432        counted = counted
1433            .checked_add(count)
1434            .ok_or_else(|| invalid_data("ANN vector count overflow"))?;
1435    }
1436    if counted != header.vector_count {
1437        return Err(invalid_data("ANN header/build vector counts disagree"));
1438    }
1439    finish_layout(writer, offset, &records)
1440}
1441
1442/// Pure-copy normal merge. Corpus-sized source columns are never decoded or
1443/// rewritten; only this compact directory is regenerated with adjusted bases.
1444#[cfg(all(feature = "native", test))]
1445pub(crate) fn write_merged_ann(
1446    sources: &[(&AnnDiskIndex, u32)],
1447    writer: &mut (impl Write + ?Sized),
1448) -> io::Result<u64> {
1449    write_merged_ann_impl(sources, &[], writer, None)
1450}
1451
1452#[cfg(feature = "native")]
1453pub(crate) fn write_merged_ann_cancellable(
1454    sources: &[(&AnnDiskIndex, u32)],
1455    writer: &mut (impl Write + ?Sized),
1456    cancellation: Option<&std::sync::atomic::AtomicBool>,
1457) -> io::Result<u64> {
1458    write_merged_ann_impl(sources, &[], writer, cancellation)
1459}
1460
1461/// Physical extents per non-empty cluster the byte-copy merge of `sources`
1462/// would produce.
1463///
1464/// Byte-copy preserves every source run, so the merged fragmentation is
1465/// `total runs / distinct non-empty clusters` — computable exactly from the
1466/// in-memory directories before writing a byte. The merge policy compacts
1467/// when this crosses its threshold instead of letting probe read
1468/// amplification grow another generation.
1469#[cfg(feature = "native")]
1470pub(crate) fn predicted_merge_fragmentation(sources: &[(&AnnDiskIndex, u32)]) -> f64 {
1471    let mut total_runs = 0usize;
1472    // Distinct clusters via a k-way sorted walk over the (already
1473    // cluster-sorted) directories — no allocation proportional to clusters.
1474    let mut cursors: Vec<std::iter::Peekable<std::slice::Iter<'_, AnnRun>>> = sources
1475        .iter()
1476        .map(|(source, _)| {
1477            total_runs += source.runs.len();
1478            source.runs.iter().peekable()
1479        })
1480        .collect();
1481    let mut distinct = 0usize;
1482    while let Some(cluster) = cursors
1483        .iter_mut()
1484        .filter_map(|cursor| cursor.peek().map(|run| run.cluster_id))
1485        .min()
1486    {
1487        distinct += 1;
1488        for cursor in &mut cursors {
1489            while cursor.peek().is_some_and(|run| run.cluster_id == cluster) {
1490                cursor.next();
1491            }
1492        }
1493    }
1494    if distinct == 0 {
1495        0.0
1496    } else {
1497        total_runs as f64 / distinct as f64
1498    }
1499}
1500
1501/// Doc IDs rewritten per scratch flush during compaction (256 KiB of u32s).
1502#[cfg(feature = "native")]
1503const DOC_ID_REWRITE_CHUNK: usize = 64 * 1024;
1504
1505/// Cluster-major compacting merge for binary IVF payloads.
1506///
1507/// The byte-copy merge keeps each source payload as one physical extent, so a
1508/// logical cluster's postings scatter across up to `sources.len()` extents —
1509/// and another factor per earlier merge generation. Every extent is a
1510/// potential seek when the index is cold; production measured the array
1511/// IOPS-bound at 32 KB/read from exactly this. This writer instead gathers
1512/// each cluster's runs from all sources and emits **one contiguous run per
1513/// cluster**, restoring the freshly-built layout (fragmentation 1.0).
1514///
1515/// Cost: the same total payload bytes the byte-copy merge already streams,
1516/// plus one `u32` add per posting — document IDs are rewritten absolute
1517/// (`doc_base = 0`) because runs from different sources cannot share a single
1518/// directory entry otherwise. Codes and ordinals are copied verbatim.
1519///
1520/// Binary only: binary code columns are exactly `count × code_size`, so
1521/// concatenating runs is trivially valid. TQ payloads pack codes into
1522/// fixed-lane blocks with per-run tail padding; concatenating those without
1523/// re-packing would corrupt block boundaries, so TQ merges stay byte-copy.
1524///
1525/// Every binary merge whose prediction is fragmented takes this path.
1526/// Measured (interleaved best-of-3, pre-faulted buffers, aarch64): byte-copy
1527/// 38.0 GiB/s vs compaction 32.4 GiB/s — ~17% more CPU on a stage that is a
1528/// rounding error of merge wall-clock (a production dense stage is ~0.7s of
1529/// a 20s+ merge), so there is no threshold below which byte-copy is worth
1530/// the fragmentation it leaves behind.
1531#[cfg(feature = "native")]
1532pub(crate) fn write_compacted_ann_cancellable(
1533    sources: &[(&AnnDiskIndex, u32)],
1534    writer: &mut (impl Write + ?Sized),
1535    cancellation: Option<&std::sync::atomic::AtomicBool>,
1536) -> io::Result<u64> {
1537    let Some((first, _)) = sources.first() else {
1538        return Err(invalid_data("cannot compact an empty ANN source list"));
1539    };
1540    if first.header.kind != AnnKind::BinaryIvf {
1541        return Err(invalid_data(
1542            "ANN run compaction is only defined for binary IVF payloads",
1543        ));
1544    }
1545    let mut header = first.header.clone();
1546    header.vector_count = 0;
1547    for &(source, _) in sources {
1548        if !headers_compatible(&first.header, &source.header) {
1549            return Err(invalid_data(
1550                "ANN compaction sources use incompatible generations",
1551            ));
1552        }
1553        header.vector_count = header
1554            .vector_count
1555            .checked_add(source.header.vector_count)
1556            .ok_or_else(|| invalid_data("compacted ANN vector count overflows usize"))?;
1557    }
1558    validate_header(&header)?;
1559    write_header(writer, &header)?;
1560
1561    let code_size = header.code_size;
1562    let mut offset = ANN_HEADER_SIZE as u64;
1563    let mut records: Vec<RunRecord> = Vec::new();
1564    let mut scratch = Vec::new();
1565    let mut cursors: Vec<usize> = vec![0; sources.len()];
1566
1567    loop {
1568        if cancellation.is_some_and(|flag| flag.load(std::sync::atomic::Ordering::Relaxed)) {
1569            return Err(io::Error::new(
1570                io::ErrorKind::Interrupted,
1571                "ANN compaction cancelled",
1572            ));
1573        }
1574        // Next cluster = minimum un-consumed cluster ID across sources.
1575        let Some(cluster_id) = sources
1576            .iter()
1577            .zip(&cursors)
1578            .filter_map(|((source, _), &cursor)| source.runs.get(cursor).map(|run| run.cluster_id))
1579            .min()
1580        else {
1581            break;
1582        };
1583
1584        // Pass 1: doc IDs, rewritten absolute. Sources are visited in
1585        // segment order and each source's same-cluster runs in directory
1586        // order, which is ascending document ranges — so the output column
1587        // stays sorted like a built segment's.
1588        let mut count = 0usize;
1589        let mut max_doc_id = 0u32;
1590        let doc_ids_offset = offset;
1591        for (source_index, &(source, segment_base)) in sources.iter().enumerate() {
1592            let mut cursor = cursors[source_index];
1593            while let Some(run) = source
1594                .runs
1595                .get(cursor)
1596                .filter(|run| run.cluster_id == cluster_id)
1597            {
1598                let base = run
1599                    .doc_base
1600                    .checked_add(segment_base)
1601                    .ok_or_else(|| invalid_data("compacted ANN document base overflows u32"))?;
1602                let bytes = source.raw.as_slice();
1603                // Chunked rewrite: peak scratch stays at 256 KiB no matter how
1604                // large the run — the production incident had a single run of
1605                // 20M postings, and buffering it whole would be an 80 MB spike
1606                // in the middle of a merge.
1607                for chunk_start in (0..run.count).step_by(DOC_ID_REWRITE_CHUNK) {
1608                    let chunk_end = (chunk_start + DOC_ID_REWRITE_CHUNK).min(run.count);
1609                    scratch.clear();
1610                    scratch.reserve((chunk_end - chunk_start) * 4);
1611                    for index in chunk_start..chunk_end {
1612                        let doc_id = run_doc_id_with_base(bytes, run, index, base)?;
1613                        max_doc_id = max_doc_id.max(doc_id);
1614                        scratch.extend_from_slice(&doc_id.to_le_bytes());
1615                    }
1616                    writer.write_all(&scratch)?;
1617                    if cancellation
1618                        .is_some_and(|flag| flag.load(std::sync::atomic::Ordering::Relaxed))
1619                    {
1620                        return Err(io::Error::new(
1621                            io::ErrorKind::Interrupted,
1622                            "ANN compaction cancelled",
1623                        ));
1624                    }
1625                }
1626                offset = checked_advance(offset, run.count * 4)?;
1627                count = count
1628                    .checked_add(run.count)
1629                    .ok_or_else(|| invalid_data("compacted ANN run count overflows usize"))?;
1630                cursor += 1;
1631            }
1632        }
1633
1634        // Pass 2: ordinals, verbatim.
1635        let ordinals_offset = offset;
1636        for (source_index, &(source, _)) in sources.iter().enumerate() {
1637            let mut cursor = cursors[source_index];
1638            while let Some(run) = source
1639                .runs
1640                .get(cursor)
1641                .filter(|run| run.cluster_id == cluster_id)
1642            {
1643                copy_range(writer, &source.raw, run.ordinals.clone(), cancellation)?;
1644                offset = checked_advance(offset, run.ordinals.len())?;
1645                cursor += 1;
1646            }
1647        }
1648
1649        // Pass 3: codes, verbatim — the dominant bytes.
1650        let codes_offset = offset;
1651        for (source_index, &(source, _)) in sources.iter().enumerate() {
1652            let mut cursor = cursors[source_index];
1653            while let Some(run) = source
1654                .runs
1655                .get(cursor)
1656                .filter(|run| run.cluster_id == cluster_id)
1657            {
1658                copy_range(writer, &source.raw, run.codes.clone(), cancellation)?;
1659                offset = checked_advance(offset, run.codes.len())?;
1660                cursor += 1;
1661            }
1662        }
1663
1664        // Consume this cluster's runs from every cursor.
1665        for (source_index, &(source, _)) in sources.iter().enumerate() {
1666            while source
1667                .runs
1668                .get(cursors[source_index])
1669                .is_some_and(|run| run.cluster_id == cluster_id)
1670            {
1671                cursors[source_index] += 1;
1672            }
1673        }
1674
1675        records.push(RunRecord {
1676            cluster_id,
1677            doc_base: 0,
1678            count: u32::try_from(count)
1679                .map_err(|_| invalid_data("compacted ANN run exceeds u32 vectors"))?,
1680            max_doc_id,
1681            doc_ids_offset,
1682            ordinals_offset,
1683            codes_offset,
1684            codes_len: u64::try_from(expected_codes_column_len(
1685                AnnKind::BinaryIvf,
1686                count,
1687                code_size,
1688            )?)
1689            .map_err(|_| invalid_data("compacted ANN code length exceeds u64"))?,
1690        });
1691    }
1692
1693    if records.is_empty() {
1694        return Err(invalid_data("cannot compact an ANN payload with no runs"));
1695    }
1696    finish_layout(writer, offset, &records)
1697}
1698
1699/// [`run_doc_id`] against an explicit base, for rewriting IDs absolute.
1700#[cfg(feature = "native")]
1701fn run_doc_id_with_base(bytes: &[u8], run: &AnnRun, index: usize, base: u32) -> io::Result<u32> {
1702    let local_doc_id = read_u32(bytes, run.doc_ids.start + index * 4);
1703    if local_doc_id > run.max_doc_id {
1704        return Err(invalid_data(
1705            "ANN run contains a document above its declared maximum",
1706        ));
1707    }
1708    base.checked_add(local_doc_id)
1709        .ok_or_else(|| invalid_data("compacted ANN document ID overflows u32"))
1710}
1711
1712/// [`write_merged_ann`] plus freshly built runs appended to the payload —
1713/// used when some merge sources predate the field's current format and were
1714/// re-encoded while every compatible source is still byte-copied.
1715#[cfg(feature = "native")]
1716pub(crate) fn write_merged_ann_with_extra(
1717    sources: &[(&AnnDiskIndex, u32)],
1718    extra: &[BuildRun<'_>],
1719    writer: &mut (impl Write + ?Sized),
1720    cancellation: Option<&std::sync::atomic::AtomicBool>,
1721) -> io::Result<u64> {
1722    write_merged_ann_impl(sources, extra, writer, cancellation)
1723}
1724
1725#[cfg(feature = "native")]
1726fn write_merged_ann_impl(
1727    sources: &[(&AnnDiskIndex, u32)],
1728    extra: &[BuildRun<'_>],
1729    writer: &mut (impl Write + ?Sized),
1730    cancellation: Option<&std::sync::atomic::AtomicBool>,
1731) -> io::Result<u64> {
1732    let Some((first, _)) = sources.first() else {
1733        return Err(invalid_data("cannot merge an empty ANN source list"));
1734    };
1735    if first.header.kind == AnnKind::IvfTq
1736        && !crate::structures::is_ivf_tq_cosine_generation(first.header.quantizer_version)
1737    {
1738        return Err(invalid_data(
1739            "legacy raw IVF-TQ generations cannot be merged; rebuild the index",
1740        ));
1741    }
1742    let mut header = first.header.clone();
1743    header.vector_count = 0;
1744    for &(source, _) in sources {
1745        if !headers_compatible(&first.header, &source.header) {
1746            return Err(invalid_data(
1747                "ANN merge sources use incompatible generations",
1748            ));
1749        }
1750        header.vector_count = header
1751            .vector_count
1752            .checked_add(source.header.vector_count)
1753            .ok_or_else(|| invalid_data("merged ANN vector count overflows usize"))?;
1754    }
1755    for run in extra {
1756        if run.doc_ids.is_empty()
1757            || run.cluster_id >= header.num_clusters
1758            || run.ordinals.len() != run.doc_ids.len()
1759            || run.codes.len()
1760                != expected_codes_column_len(header.kind, run.doc_ids.len(), header.code_size)?
1761        {
1762            return Err(invalid_data("extra ANN merge run columns are inconsistent"));
1763        }
1764        header.vector_count = header
1765            .vector_count
1766            .checked_add(run.doc_ids.len())
1767            .ok_or_else(|| invalid_data("merged ANN vector count overflows usize"))?;
1768    }
1769    validate_header(&header)?;
1770    write_header(writer, &header)?;
1771    let mut offset = ANN_HEADER_SIZE as u64;
1772    let run_capacity = sources.iter().try_fold(extra.len(), |count, (source, _)| {
1773        count
1774            .checked_add(source.runs.len())
1775            .ok_or_else(|| invalid_data("merged ANN run count overflows usize"))
1776    })?;
1777    let mut output_payload_starts = Vec::with_capacity(sources.len());
1778    for &(source, _) in sources {
1779        let payload_end = source
1780            .runs
1781            .iter()
1782            .map(|run| run.codes.end)
1783            .max()
1784            .ok_or_else(|| invalid_data("ANN source has no payload runs"))?;
1785        let output_payload_start = offset;
1786        output_payload_starts.push(output_payload_start);
1787        copy_range(
1788            writer,
1789            &source.raw,
1790            ANN_HEADER_SIZE..payload_end,
1791            cancellation,
1792        )?;
1793        offset = checked_advance(offset, payload_end - ANN_HEADER_SIZE)?;
1794    }
1795
1796    // Extra runs' columns are appended after the copied extents so the
1797    // payload region stays contiguous for open()'s coverage validation.
1798    let mut extra_records = Vec::with_capacity(extra.len());
1799    let mut scratch = Vec::new();
1800    for run in extra {
1801        let count = run.doc_ids.len();
1802        let doc_ids_offset = offset;
1803        write_u32_column(writer, run.doc_ids, &mut scratch)?;
1804        offset = checked_advance(offset, count * 4)?;
1805        let ordinals_offset = offset;
1806        write_u16_column(writer, run.ordinals, &mut scratch)?;
1807        offset = checked_advance(offset, count * 2)?;
1808        let codes_offset = offset;
1809        writer.write_all(run.codes)?;
1810        offset = checked_advance(offset, run.codes.len())?;
1811        extra_records.push(RunRecord {
1812            cluster_id: run.cluster_id,
1813            doc_base: 0,
1814            count: u32::try_from(count)
1815                .map_err(|_| invalid_data("extra ANN run exceeds u32 vectors"))?,
1816            max_doc_id: run.doc_ids.iter().copied().max().unwrap_or(0),
1817            doc_ids_offset,
1818            ordinals_offset,
1819            codes_offset,
1820            codes_len: u64::try_from(run.codes.len())
1821                .map_err(|_| invalid_data("extra ANN code length exceeds u64"))?,
1822        });
1823    }
1824
1825    // Every source directory is already cluster-sorted. Merge those compact
1826    // directories (plus the extra records) directly into the output with
1827    // O(source count) heap memory; the corpus payload extents above remain
1828    // untouched and source-contiguous. Extra records use pseudo source index
1829    // `sources.len()` so ties stay deterministic.
1830    let directory_offset = offset;
1831    let mut pending = BinaryHeap::with_capacity(sources.len() + 1);
1832    for (source_index, (source, _)) in sources.iter().enumerate() {
1833        pending.push(Reverse((source.runs[0].cluster_id, source_index, 0usize)));
1834    }
1835    if let Some(first_extra) = extra_records.first() {
1836        pending.push(Reverse((first_extra.cluster_id, sources.len(), 0usize)));
1837    }
1838    let mut written_runs = 0usize;
1839    while let Some(Reverse((_, source_index, run_index))) = pending.pop() {
1840        if source_index == sources.len() {
1841            write_run_record(writer, &extra_records[run_index])?;
1842            written_runs += 1;
1843            if let Some(next) = extra_records.get(run_index + 1) {
1844                pending.push(Reverse((next.cluster_id, source_index, run_index + 1)));
1845            }
1846            continue;
1847        }
1848        let (source, segment_base) = sources[source_index];
1849        let run = &source.runs[run_index];
1850        write_run_record(
1851            writer,
1852            &RunRecord {
1853                cluster_id: run.cluster_id,
1854                doc_base: run
1855                    .doc_base
1856                    .checked_add(segment_base)
1857                    .ok_or_else(|| invalid_data("merged ANN document base overflows u32"))?,
1858                count: u32::try_from(run.count)
1859                    .map_err(|_| invalid_data("ANN source run exceeds u32 vectors"))?,
1860                max_doc_id: run.max_doc_id,
1861                doc_ids_offset: relocate_payload_offset(
1862                    output_payload_starts[source_index],
1863                    run.doc_ids.start,
1864                )?,
1865                ordinals_offset: relocate_payload_offset(
1866                    output_payload_starts[source_index],
1867                    run.ordinals.start,
1868                )?,
1869                codes_offset: relocate_payload_offset(
1870                    output_payload_starts[source_index],
1871                    run.codes.start,
1872                )?,
1873                codes_len: u64::try_from(run.codes.len())
1874                    .map_err(|_| invalid_data("ANN source code length exceeds u64"))?,
1875            },
1876        )?;
1877        written_runs = written_runs
1878            .checked_add(1)
1879            .ok_or_else(|| invalid_data("merged ANN run count overflows usize"))?;
1880        let next_run_index = run_index + 1;
1881        if let Some(next_run) = source.runs.get(next_run_index) {
1882            pending.push(Reverse((next_run.cluster_id, source_index, next_run_index)));
1883        }
1884    }
1885    if written_runs != run_capacity {
1886        return Err(invalid_data("merged ANN directory lost source runs"));
1887    }
1888    finish_footer(writer, directory_offset, written_runs)
1889}
1890
1891#[cfg(feature = "native")]
1892fn relocate_payload_offset(output_payload_start: u64, source_offset: usize) -> io::Result<u64> {
1893    let relative = source_offset
1894        .checked_sub(ANN_HEADER_SIZE)
1895        .ok_or_else(|| invalid_data("ANN source offset precedes its payload"))?;
1896    output_payload_start
1897        .checked_add(
1898            u64::try_from(relative)
1899                .map_err(|_| invalid_data("ANN source relative offset exceeds u64"))?,
1900        )
1901        .ok_or_else(|| invalid_data("merged ANN payload offset overflows u64"))
1902}
1903
1904#[cfg(feature = "native")]
1905fn headers_compatible(left: &AnnDiskHeader, right: &AnnDiskHeader) -> bool {
1906    left.kind == right.kind
1907        && left.routing == right.routing
1908        && left.dim == right.dim
1909        && left.code_size == right.code_size
1910        && left.num_clusters == right.num_clusters
1911        && left.quantizer_version == right.quantizer_version
1912        && left.codebook_version == right.codebook_version
1913}
1914
1915#[cfg(feature = "native")]
1916fn finish_layout(
1917    writer: &mut (impl Write + ?Sized),
1918    directory_offset: u64,
1919    records: &[RunRecord],
1920) -> io::Result<u64> {
1921    for record in records {
1922        write_run_record(writer, record)?;
1923    }
1924    finish_footer(writer, directory_offset, records.len())
1925}
1926
1927#[cfg(feature = "native")]
1928fn write_run_record(writer: &mut (impl Write + ?Sized), record: &RunRecord) -> io::Result<()> {
1929    writer.write_u32::<LittleEndian>(record.cluster_id)?;
1930    writer.write_u32::<LittleEndian>(record.doc_base)?;
1931    writer.write_u32::<LittleEndian>(record.count)?;
1932    writer.write_u32::<LittleEndian>(record.max_doc_id)?;
1933    writer.write_u64::<LittleEndian>(record.doc_ids_offset)?;
1934    writer.write_u64::<LittleEndian>(record.ordinals_offset)?;
1935    writer.write_u64::<LittleEndian>(record.codes_offset)?;
1936    writer.write_u64::<LittleEndian>(record.codes_len)?;
1937    Ok(())
1938}
1939
1940#[cfg(feature = "native")]
1941fn finish_footer(
1942    writer: &mut (impl Write + ?Sized),
1943    directory_offset: u64,
1944    num_records: usize,
1945) -> io::Result<u64> {
1946    writer.write_u64::<LittleEndian>(directory_offset)?;
1947    writer.write_u64::<LittleEndian>(
1948        u64::try_from(num_records).map_err(|_| invalid_data("ANN run count exceeds u64"))?,
1949    )?;
1950    writer.write_u32::<LittleEndian>(ANN_FOOTER_MAGIC)?;
1951    writer.write_u32::<LittleEndian>(u32::from(ANN_DISK_VERSION))?;
1952    let tail_size = num_records
1953        .checked_mul(ANN_RUN_SIZE)
1954        .and_then(|size| size.checked_add(ANN_FOOTER_SIZE))
1955        .and_then(|size| u64::try_from(size).ok())
1956        .ok_or_else(|| invalid_data("ANN final tail size overflows u64"))?;
1957    directory_offset
1958        .checked_add(tail_size)
1959        .ok_or_else(|| invalid_data("ANN final size overflows u64"))
1960}
1961
1962#[cfg(feature = "native")]
1963fn write_header(writer: &mut (impl Write + ?Sized), header: &AnnDiskHeader) -> io::Result<()> {
1964    writer.write_u32::<LittleEndian>(ANN_HEADER_MAGIC)?;
1965    writer.write_u8(header.kind as u8)?;
1966    writer.write_u8(routing_to_u8(header.routing))?;
1967    writer.write_u16::<LittleEndian>(ANN_DISK_VERSION)?;
1968    writer.write_u32::<LittleEndian>(
1969        u32::try_from(header.dim).map_err(|_| invalid_data("ANN dimension exceeds u32"))?,
1970    )?;
1971    writer.write_u32::<LittleEndian>(
1972        u32::try_from(header.code_size).map_err(|_| invalid_data("ANN code size exceeds u32"))?,
1973    )?;
1974    writer.write_u32::<LittleEndian>(header.num_clusters)?;
1975    writer.write_u32::<LittleEndian>(0)?;
1976    writer.write_u64::<LittleEndian>(header.quantizer_version)?;
1977    writer.write_u64::<LittleEndian>(header.codebook_version)?;
1978    writer.write_u64::<LittleEndian>(
1979        u64::try_from(header.vector_count)
1980            .map_err(|_| invalid_data("ANN vector count exceeds u64"))?,
1981    )?;
1982    writer.write_u64::<LittleEndian>(0)?;
1983    Ok(())
1984}
1985
1986#[cfg(feature = "native")]
1987fn write_u32_column(
1988    writer: &mut (impl Write + ?Sized),
1989    values: &[u32],
1990    scratch: &mut Vec<u8>,
1991) -> io::Result<()> {
1992    for chunk in values.chunks(64 * 1024) {
1993        scratch.clear();
1994        scratch.reserve(chunk.len() * 4);
1995        for value in chunk {
1996            scratch.extend_from_slice(&value.to_le_bytes());
1997        }
1998        writer.write_all(scratch)?;
1999    }
2000    Ok(())
2001}
2002
2003#[cfg(feature = "native")]
2004fn write_u16_column(
2005    writer: &mut (impl Write + ?Sized),
2006    values: &[u16],
2007    scratch: &mut Vec<u8>,
2008) -> io::Result<()> {
2009    for chunk in values.chunks(64 * 1024) {
2010        scratch.clear();
2011        scratch.reserve(chunk.len() * 2);
2012        for value in chunk {
2013            scratch.extend_from_slice(&value.to_le_bytes());
2014        }
2015        writer.write_all(scratch)?;
2016    }
2017    Ok(())
2018}
2019
2020#[cfg(feature = "native")]
2021fn copy_range(
2022    writer: &mut (impl Write + ?Sized),
2023    bytes: &OwnedBytes,
2024    range: Range<usize>,
2025    cancellation: Option<&std::sync::atomic::AtomicBool>,
2026) -> io::Result<()> {
2027    if range.is_empty() {
2028        return Ok(());
2029    }
2030    let range_end = range.end;
2031    let mut chunk_start = range.start;
2032    let first_end = chunk_start.saturating_add(COPY_CHUNK).min(range_end);
2033    bytes.madvise_range(chunk_start..first_end, libc::MADV_WILLNEED);
2034    while chunk_start < range_end {
2035        if cancellation
2036            .is_some_and(|cancelled| cancelled.load(std::sync::atomic::Ordering::Relaxed))
2037        {
2038            return Err(io::Error::new(
2039                io::ErrorKind::Interrupted,
2040                "ANN merge copy cancelled",
2041            ));
2042        }
2043        let chunk_end = chunk_start.saturating_add(COPY_CHUNK).min(range_end);
2044        let next_end = chunk_end.saturating_add(COPY_CHUNK).min(range_end);
2045        if chunk_end < next_end {
2046            // Keep one bounded window of IO in flight while the current
2047            // window is copied. The query mapping remains MADV_RANDOM.
2048            bytes.madvise_range(chunk_end..next_end, libc::MADV_WILLNEED);
2049        }
2050        writer.write_all(&bytes.as_slice()[chunk_start..chunk_end])?;
2051        chunk_start = chunk_end;
2052    }
2053    Ok(())
2054}
2055
2056#[cfg(feature = "native")]
2057fn checked_advance(offset: u64, length: usize) -> io::Result<u64> {
2058    offset
2059        .checked_add(
2060            u64::try_from(length).map_err(|_| invalid_data("ANN copy length exceeds u64"))?,
2061        )
2062        .ok_or_else(|| invalid_data("ANN output offset overflows u64"))
2063}
2064
2065fn validate_header(header: &AnnDiskHeader) -> io::Result<()> {
2066    if header.kind == AnnKind::IvfTq
2067        && !crate::structures::is_ivf_tq_cosine_generation(header.quantizer_version)
2068    {
2069        return Err(invalid_data(
2070            "IVF-TQ payload uses an unsupported legacy generation; rebuild the index",
2071        ));
2072    }
2073    if header.dim == 0
2074        || header.code_size == 0
2075        || header.num_clusters == 0
2076        || header.quantizer_version == 0
2077        || header.vector_count == 0
2078        || (header.kind == AnnKind::BinaryIvf
2079            && (header.codebook_version != 0
2080                || !header.dim.is_multiple_of(8)
2081                || header.code_size != header.dim.div_ceil(8)))
2082        || (header.kind == AnnKind::TqFlat
2083            && (header.codebook_version != 0
2084                || header.num_clusters != 1
2085                || header.routing != IvfRoutingMode::Flat
2086                || header.code_size * 2
2087                    != crate::structures::vector::quantization::tq_padded_dim(header.dim)))
2088        // IVF-TQ: quantizer_version is the trained centroid generation and
2089        // codebook_version carries the (nonzero) TQ codec fingerprint.
2090        || (header.kind == AnnKind::IvfTq
2091            && (header.codebook_version == 0
2092                || header.code_size * 2
2093                    != crate::structures::vector::quantization::tq_padded_dim(header.dim)))
2094    {
2095        return Err(invalid_data("ANN header contains invalid metadata"));
2096    }
2097    Ok(())
2098}
2099
2100fn read_u32(bytes: &[u8], offset: usize) -> u32 {
2101    u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap())
2102}
2103
2104fn read_u16(bytes: &[u8], offset: usize) -> u16 {
2105    u16::from_le_bytes(bytes[offset..offset + 2].try_into().unwrap())
2106}
2107
2108#[inline]
2109fn tq_ivf_block_max_scale(block: &[u8]) -> f32 {
2110    // Supported cosine-generation writers sort the complete run by descending
2111    // residual scale, so lane zero is both this block's maximum and an upper
2112    // bound for every following block.
2113    f32::from_le_bytes(
2114        block[..size_of::<f32>()]
2115            .try_into()
2116            .expect("scale is one f32"),
2117    )
2118}
2119
2120fn run_doc_id(bytes: &[u8], run: &AnnRun, index: usize) -> io::Result<u32> {
2121    let local_doc_id = read_u32(bytes, run.doc_ids.start + index * 4);
2122    if local_doc_id > run.max_doc_id {
2123        return Err(invalid_data(
2124            "ANN run contains a document above its declared maximum",
2125        ));
2126    }
2127    run.doc_base
2128        .checked_add(local_doc_id)
2129        .ok_or_else(|| invalid_data("ANN run document ID overflows u32"))
2130}
2131
2132#[cfg(feature = "native")]
2133fn routing_to_u8(routing: IvfRoutingMode) -> u8 {
2134    match routing {
2135        IvfRoutingMode::Auto => 0,
2136        IvfRoutingMode::Flat => 1,
2137        IvfRoutingMode::TwoLevel => 2,
2138        IvfRoutingMode::Hnsw => 3,
2139    }
2140}
2141
2142fn routing_from_u8(value: u8) -> io::Result<IvfRoutingMode> {
2143    match value {
2144        0 => Ok(IvfRoutingMode::Auto),
2145        1 => Ok(IvfRoutingMode::Flat),
2146        2 => Ok(IvfRoutingMode::TwoLevel),
2147        3 => Ok(IvfRoutingMode::Hnsw),
2148        _ => Err(invalid_data(format!("unknown ANN routing mode {value}"))),
2149    }
2150}
2151
2152fn invalid_data(message: impl Into<String>) -> io::Error {
2153    io::Error::new(io::ErrorKind::InvalidData, message.into())
2154}
2155
2156#[cfg(all(test, feature = "native"))]
2157mod tests {
2158    use super::*;
2159
2160    /// Compaction must produce a payload indistinguishable from a fresh
2161    /// build: one run per cluster, fragmentation 1.0, absolute doc IDs — and
2162    /// return exactly the results the byte-copy merge of the same sources
2163    /// returns, for every cluster.
2164    #[test]
2165    fn compacted_merge_matches_byte_copy_and_resets_fragmentation() {
2166        // Segment A: clusters {0: 3 docs, 5: 2 docs}. Segment B: {0: 2, 2: 1}.
2167        // Merging A+B and then merging that result with A again produces
2168        // multi-generation fragmentation (cluster 0 in three extents).
2169        let a0_docs = [0u32, 1, 2];
2170        let a0_ords = [0u16, 0, 1];
2171        let a0_codes = [0x11u8, 0x22, 0x33];
2172        let a5_docs = [3u32, 4];
2173        let a5_ords = [0u16; 2];
2174        let a5_codes = [0x44u8, 0x55];
2175        let a_runs = [
2176            BuildRun {
2177                cluster_id: 0,
2178                doc_ids: &a0_docs,
2179                ordinals: &a0_ords,
2180                codes: &a0_codes,
2181            },
2182            BuildRun {
2183                cluster_id: 5,
2184                doc_ids: &a5_docs,
2185                ordinals: &a5_ords,
2186                codes: &a5_codes,
2187            },
2188        ];
2189        let mut header = binary_header(5);
2190        header.num_clusters = 8;
2191        let mut a_bytes = Vec::new();
2192        write_built_runs(header.clone(), &a_runs, &mut a_bytes).unwrap();
2193        let a = AnnDiskIndex::open(OwnedBytes::new(a_bytes), AnnKind::BinaryIvf, 5).unwrap();
2194
2195        let b0_docs = [0u32, 2];
2196        let b0_ords = [1u16, 0];
2197        let b0_codes = [0x66u8, 0x77];
2198        let b2_docs = [1u32];
2199        let b2_ords = [0u16];
2200        let b2_codes = [0x88u8];
2201        let b_runs = [
2202            BuildRun {
2203                cluster_id: 0,
2204                doc_ids: &b0_docs,
2205                ordinals: &b0_ords,
2206                codes: &b0_codes,
2207            },
2208            BuildRun {
2209                cluster_id: 2,
2210                doc_ids: &b2_docs,
2211                ordinals: &b2_ords,
2212                codes: &b2_codes,
2213            },
2214        ];
2215        let mut header_b = binary_header(3);
2216        header_b.num_clusters = 8;
2217        let mut b_bytes = Vec::new();
2218        write_built_runs(header_b, &b_runs, &mut b_bytes).unwrap();
2219        let b = AnnDiskIndex::open(OwnedBytes::new(b_bytes), AnnKind::BinaryIvf, 3).unwrap();
2220
2221        // Generation 1: byte-copy A (docs 0..5) + B (docs 5..8).
2222        let mut gen1_bytes = Vec::new();
2223        write_merged_ann(&[(&a, 0), (&b, 5)], &mut gen1_bytes).unwrap();
2224        let gen1 = AnnDiskIndex::open(OwnedBytes::new(gen1_bytes), AnnKind::BinaryIvf, 8).unwrap();
2225
2226        // Generation 2 sources: gen1 (docs 0..8) + A again (docs 8..13).
2227        let sources: [(&AnnDiskIndex, u32); 2] = [(&gen1, 0), (&a, 8)];
2228        let predicted = predicted_merge_fragmentation(&sources);
2229        // 4 runs (gen1) + 2 runs (a) over 3 distinct clusters {0, 2, 5}.
2230        assert!((predicted - 2.0).abs() < 1e-9, "{predicted}");
2231
2232        let mut copied_bytes = Vec::new();
2233        write_merged_ann(&sources, &mut copied_bytes).unwrap();
2234        let copied =
2235            AnnDiskIndex::open(OwnedBytes::new(copied_bytes), AnnKind::BinaryIvf, 13).unwrap();
2236        let mut compacted_bytes = Vec::new();
2237        write_compacted_ann_cancellable(&sources, &mut compacted_bytes, None).unwrap();
2238        let compacted =
2239            AnnDiskIndex::open(OwnedBytes::new(compacted_bytes), AnnKind::BinaryIvf, 13).unwrap();
2240
2241        // Byte-copy carries the fragmentation forward; compaction resets it.
2242        let copied_health = copied.health();
2243        let compacted_health = compacted.health();
2244        assert!((copied_health.fragmentation() - 2.0).abs() < 1e-9);
2245        assert!((compacted_health.fragmentation() - 1.0).abs() < 1e-9);
2246        assert_eq!(compacted_health.runs, 3, "one run per non-empty cluster");
2247        assert_eq!(copied_health.vectors, compacted_health.vectors);
2248        assert_eq!(copied_health.payload_bytes, compacted_health.payload_bytes);
2249        assert_eq!(
2250            copied_health.largest_cluster_vectors,
2251            compacted_health.largest_cluster_vectors
2252        );
2253
2254        // Every cluster returns identical (doc, ordinal, score) results.
2255        for cluster in 0..8u32 {
2256            let query = [0x5Au8];
2257            let from_copy = copied
2258                .search_binary_clusters::<false>(&query, 16, &[cluster])
2259                .unwrap();
2260            let from_compact = compacted
2261                .search_binary_clusters::<false>(&query, 16, &[cluster])
2262                .unwrap();
2263            assert_eq!(from_copy, from_compact, "cluster {cluster} diverged");
2264        }
2265
2266        // Doc IDs are absolute now: every directory entry has doc_base 0.
2267        assert!(compacted.runs.iter().all(|run| run.doc_base == 0));
2268
2269        // A compacted payload is indistinguishable from a built one, so it
2270        // must remain a valid source for future ordinary byte-copy merges.
2271        let mut generation3 = Vec::new();
2272        write_merged_ann(&[(&compacted, 0), (&b, 13)], &mut generation3).unwrap();
2273        let generation3 =
2274            AnnDiskIndex::open(OwnedBytes::new(generation3), AnnKind::BinaryIvf, 16).unwrap();
2275        assert_eq!(generation3.health().vectors, 16);
2276        // And the third A copy's docs landed at offset 8.
2277        let all: Vec<(u32, u16, f32)> = compacted
2278            .search_binary_clusters::<false>(&[0x5A], 32, &[0, 2, 5])
2279            .unwrap();
2280        let mut docs: Vec<u32> = all.iter().map(|&(doc, _, _)| doc).collect();
2281        docs.sort_unstable();
2282        assert_eq!(docs, (0..=12).collect::<Vec<u32>>());
2283    }
2284
2285    /// Throughput comparison, prod-shaped: 320-byte codes, 4 sources.
2286    /// Ignored: run with `cargo test --release -- --ignored ann_merge_throughput --nocapture`.
2287    #[test]
2288    #[ignore]
2289    fn ann_merge_throughput_byte_copy_vs_compaction() {
2290        let code_size = 320usize;
2291        let clusters = 4_096u32;
2292        let vectors_per_source = 262_144usize;
2293        let sources_count = 4usize;
2294
2295        let mut sources_bytes = Vec::new();
2296        for source_index in 0..sources_count {
2297            let mut per_cluster: Vec<(Vec<u32>, Vec<u16>, Vec<u8>)> = Vec::new();
2298            let vectors_per_cluster = vectors_per_source / clusters as usize;
2299            let mut doc = 0u32;
2300            for cluster in 0..clusters {
2301                let mut docs = Vec::with_capacity(vectors_per_cluster);
2302                let mut ords = Vec::with_capacity(vectors_per_cluster);
2303                let mut codes = Vec::with_capacity(vectors_per_cluster * code_size);
2304                for _ in 0..vectors_per_cluster {
2305                    docs.push(doc);
2306                    ords.push(0u16);
2307                    codes.extend(std::iter::repeat_n(
2308                        (doc ^ cluster ^ source_index as u32) as u8,
2309                        code_size,
2310                    ));
2311                    doc += 1;
2312                }
2313                per_cluster.push((docs, ords, codes));
2314            }
2315            let runs: Vec<BuildRun<'_>> = per_cluster
2316                .iter()
2317                .enumerate()
2318                .map(|(cluster, (docs, ords, codes))| BuildRun {
2319                    cluster_id: cluster as u32,
2320                    doc_ids: docs,
2321                    ordinals: ords,
2322                    codes,
2323                })
2324                .collect();
2325            let header = AnnDiskHeader {
2326                kind: AnnKind::BinaryIvf,
2327                routing: IvfRoutingMode::Hnsw,
2328                dim: code_size * 8,
2329                code_size,
2330                num_clusters: clusters,
2331                quantizer_version: 42,
2332                codebook_version: 0,
2333                vector_count: vectors_per_source,
2334            };
2335            let mut bytes = Vec::new();
2336            write_built_runs(header, &runs, &mut bytes).unwrap();
2337            sources_bytes.push(bytes);
2338        }
2339        let sources_open: Vec<AnnDiskIndex> = sources_bytes
2340            .iter()
2341            .map(|bytes| {
2342                AnnDiskIndex::open(
2343                    OwnedBytes::new(bytes.clone()),
2344                    AnnKind::BinaryIvf,
2345                    (vectors_per_source * sources_count) as u32,
2346                )
2347                .unwrap()
2348            })
2349            .collect();
2350        let sources: Vec<(&AnnDiskIndex, u32)> = sources_open
2351            .iter()
2352            .enumerate()
2353            .map(|(index, source)| (source, (index * vectors_per_source) as u32))
2354            .collect();
2355        let payload_bytes = sources_bytes.iter().map(Vec::len).sum::<usize>();
2356
2357        // Fault the output buffer in before timing anything: the first pass
2358        // over a fresh Vec pays demand paging + kernel page zeroing for the
2359        // whole capacity, which the earlier version of this bench silently
2360        // charged to whichever writer ran first (making compaction look 2×
2361        // faster than the byte-copy purely by running second).
2362        let mut out = vec![0u8; payload_bytes + (1 << 20)];
2363        out.clear();
2364        // Interleave rounds and keep the best of each so neither path is
2365        // systematically first.
2366        let mut copy_secs = f64::INFINITY;
2367        let mut compact_secs = f64::INFINITY;
2368        let mut compacted_bytes = Vec::new();
2369        for _ in 0..3 {
2370            out.clear();
2371            let start = std::time::Instant::now();
2372            write_merged_ann(&sources, &mut out).unwrap();
2373            copy_secs = copy_secs.min(start.elapsed().as_secs_f64());
2374
2375            out.clear();
2376            let start = std::time::Instant::now();
2377            write_compacted_ann_cancellable(&sources, &mut out, None).unwrap();
2378            compact_secs = compact_secs.min(start.elapsed().as_secs_f64());
2379            compacted_bytes = out.clone();
2380        }
2381        let compacted = AnnDiskIndex::open(
2382            OwnedBytes::new(compacted_bytes),
2383            AnnKind::BinaryIvf,
2384            (vectors_per_source * sources_count) as u32,
2385        )
2386        .unwrap();
2387        assert!((compacted.health().fragmentation() - 1.0).abs() < 1e-9);
2388
2389        let gib = payload_bytes as f64 / (1u64 << 30) as f64;
2390        println!(
2391            "ann merge {:.2} GiB: byte-copy {:.3}s ({:.2} GiB/s), compaction {:.3}s \
2392             ({:.2} GiB/s), overhead {:.1}%",
2393            gib,
2394            copy_secs,
2395            gib / copy_secs,
2396            compact_secs,
2397            gib / compact_secs,
2398            100.0 * (compact_secs - copy_secs) / copy_secs,
2399        );
2400    }
2401
2402    /// Compaction is undefined for block-packed TQ codes and must refuse.
2403    #[test]
2404    fn compaction_refuses_non_binary_payloads() {
2405        // A binary payload whose header is rewritten to the TQ kind would not
2406        // validate, so exercise the guard through the real gate: any source
2407        // list whose first header is not BinaryIvf is refused before a byte
2408        // is written. Reuse a TQ payload from the flat-TQ writer used by the
2409        // pruning tests.
2410        let codec = std::sync::Arc::new(crate::structures::TqCodec::new(8));
2411        let mut builder = crate::structures::TqFlatBuilder::new(codec);
2412        builder
2413            .add_batch(
2414                &[(0, 0), (1, 0)],
2415                &[
2416                    1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, //
2417                    0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
2418                ],
2419            )
2420            .unwrap();
2421        builder.finish();
2422        let mut bytes = Vec::new();
2423        write_built_tq_flat(&builder, &mut bytes).unwrap();
2424        let disk = AnnDiskIndex::open(OwnedBytes::new(bytes), AnnKind::TqFlat, 2).unwrap();
2425        let error = write_compacted_ann_cancellable(&[(&disk, 0)], &mut Vec::new(), None)
2426            .expect_err("TQ payloads must not compact");
2427        assert!(error.to_string().contains("binary"), "{error}");
2428    }
2429
2430    /// Health math on a hand-built payload: two runs of one cluster (a
2431    /// byte-copy merge shape) plus one dominant leaf, checked against the
2432    /// Faiss imbalance definition computed by hand.
2433    #[test]
2434    fn ann_health_measures_skew_and_fragmentation() {
2435        // Segment A: 6 vectors in cluster 0 and 2 in cluster 3; segment B: 2
2436        // more in cluster 0. A byte-copy merge preserves each source extent,
2437        // producing the fragmented shape (two physical runs for cluster 0)
2438        // that build alone can never emit.
2439        let a0_docs = [0u32, 1, 2, 3, 4, 5];
2440        let a0_ords = [0u16; 6];
2441        let a0_codes = [0xAAu8; 6];
2442        let a3_docs = [6u32, 7];
2443        let a3_ords = [0u16; 2];
2444        let a3_codes = [0x0Fu8; 2];
2445        let a_runs = [
2446            BuildRun {
2447                cluster_id: 0,
2448                doc_ids: &a0_docs,
2449                ordinals: &a0_ords,
2450                codes: &a0_codes,
2451            },
2452            BuildRun {
2453                cluster_id: 3,
2454                doc_ids: &a3_docs,
2455                ordinals: &a3_ords,
2456                codes: &a3_codes,
2457            },
2458        ];
2459        let mut header = binary_header(8);
2460        header.num_clusters = 8;
2461        let mut a_bytes = Vec::new();
2462        write_built_runs(header, &a_runs, &mut a_bytes).unwrap();
2463        let a = AnnDiskIndex::open(OwnedBytes::new(a_bytes), AnnKind::BinaryIvf, 8).unwrap();
2464
2465        let b0_docs = [0u32, 1];
2466        let b0_ords = [0u16; 2];
2467        let b0_codes = [0xBBu8; 2];
2468        let b_runs = [BuildRun {
2469            cluster_id: 0,
2470            doc_ids: &b0_docs,
2471            ordinals: &b0_ords,
2472            codes: &b0_codes,
2473        }];
2474        let mut header_b = binary_header(2);
2475        header_b.num_clusters = 8;
2476        let mut b_bytes = Vec::new();
2477        write_built_runs(header_b, &b_runs, &mut b_bytes).unwrap();
2478        let b = AnnDiskIndex::open(OwnedBytes::new(b_bytes), AnnKind::BinaryIvf, 2).unwrap();
2479
2480        let mut merged_bytes = Vec::new();
2481        write_merged_ann(&[(&a, 0), (&b, 8)], &mut merged_bytes).unwrap();
2482        let disk =
2483            AnnDiskIndex::open(OwnedBytes::new(merged_bytes), AnnKind::BinaryIvf, 10).unwrap();
2484
2485        let health = disk.health();
2486        assert_eq!(health.vectors, 10);
2487        assert_eq!(health.clusters_nonempty, 2);
2488        assert_eq!(health.clusters_total, 8);
2489        assert_eq!(health.runs, 3);
2490        assert_eq!(health.largest_cluster, 0);
2491        assert_eq!(health.largest_cluster_vectors, 8);
2492        assert!((health.largest_cluster_share() - 0.8).abs() < 1e-9);
2493        // 3 runs over 2 non-empty clusters.
2494        assert!((health.fragmentation() - 1.5).abs() < 1e-9);
2495        // Faiss: K * sum(n_i^2) / N^2 = 2 * (64 + 4) / 100 = 1.36
2496        assert!(
2497            (health.imbalance - 1.36).abs() < 1e-9,
2498            "{}",
2499            health.imbalance
2500        );
2501        // codes columns: 6 + 2 + 2 bytes at code_size 1.
2502        assert_eq!(health.payload_bytes, 10);
2503    }
2504
2505    #[test]
2506    fn ann_health_is_balanced_at_one() {
2507        let docs: Vec<Vec<u32>> = (0..4).map(|c| vec![c * 2, c * 2 + 1]).collect();
2508        let ords = [0u16; 2];
2509        let codes = [0x55u8; 2];
2510        let runs: Vec<BuildRun<'_>> = docs
2511            .iter()
2512            .enumerate()
2513            .map(|(cluster, doc_ids)| BuildRun {
2514                cluster_id: cluster as u32,
2515                doc_ids,
2516                ordinals: &ords,
2517                codes: &codes,
2518            })
2519            .collect();
2520        let mut header = binary_header(8);
2521        header.num_clusters = 4;
2522        let mut bytes = Vec::new();
2523        write_built_runs(header, &runs, &mut bytes).unwrap();
2524        let disk = AnnDiskIndex::open(OwnedBytes::new(bytes), AnnKind::BinaryIvf, 8).unwrap();
2525        let health = disk.health();
2526        assert!((health.imbalance - 1.0).abs() < 1e-9);
2527        assert!((health.fragmentation() - 1.0).abs() < 1e-9);
2528        assert!((health.largest_cluster_share() - 0.25).abs() < 1e-9);
2529    }
2530
2531    fn binary_header(vector_count: usize) -> AnnDiskHeader {
2532        AnnDiskHeader {
2533            kind: AnnKind::BinaryIvf,
2534            routing: IvfRoutingMode::Hnsw,
2535            dim: 8,
2536            code_size: 1,
2537            num_clusters: 2,
2538            quantizer_version: 42,
2539            codebook_version: 0,
2540            vector_count,
2541        }
2542    }
2543
2544    fn payload_end(index: &AnnDiskIndex) -> usize {
2545        index.runs.iter().map(|run| run.codes.end).max().unwrap()
2546    }
2547
2548    #[test]
2549    fn ann_prefetch_ranges_are_sorted_and_only_merge_page_near_extents() {
2550        let mut ranges = vec![
2551            15_000..16_000,
2552            0..1_000,
2553            9_000..10_000,
2554            1_000..2_000,
2555            7_000..8_000,
2556        ];
2557        coalesce_prefetch_ranges(&mut ranges);
2558        assert_eq!(ranges, [0..2_000, 7_000..10_000, 15_000..16_000]);
2559    }
2560
2561    #[test]
2562    fn normal_merge_copies_ann_payload_columns_byte_for_byte() {
2563        let first_doc_0 = [0u32];
2564        let first_doc_1 = [1u32];
2565        let first_ord_0 = [0u16];
2566        let first_ord_1 = [2u16];
2567        let first_code_0 = [0x00u8];
2568        let first_code_1 = [0xffu8];
2569        let first_runs = [
2570            BuildRun {
2571                cluster_id: 0,
2572                doc_ids: &first_doc_0,
2573                ordinals: &first_ord_0,
2574                codes: &first_code_0,
2575            },
2576            BuildRun {
2577                cluster_id: 1,
2578                doc_ids: &first_doc_1,
2579                ordinals: &first_ord_1,
2580                codes: &first_code_1,
2581            },
2582        ];
2583        let mut first_bytes = Vec::new();
2584        write_built_runs(binary_header(2), &first_runs, &mut first_bytes).unwrap();
2585        let first = AnnDiskIndex::open(OwnedBytes::new(first_bytes.clone()), AnnKind::BinaryIvf, 2)
2586            .unwrap();
2587
2588        let second_docs = [0u32, 1u32];
2589        let second_ords = [1u16, 0u16];
2590        let second_codes = [0x0fu8, 0xf0u8];
2591        let second_runs = [BuildRun {
2592            cluster_id: 0,
2593            doc_ids: &second_docs,
2594            ordinals: &second_ords,
2595            codes: &second_codes,
2596        }];
2597        let mut second_bytes = Vec::new();
2598        write_built_runs(binary_header(2), &second_runs, &mut second_bytes).unwrap();
2599        let second =
2600            AnnDiskIndex::open(OwnedBytes::new(second_bytes.clone()), AnnKind::BinaryIvf, 2)
2601                .unwrap();
2602
2603        let mut merged_bytes = Vec::new();
2604        write_merged_ann(&[(&first, 0), (&second, 2)], &mut merged_bytes).unwrap();
2605        let merged =
2606            AnnDiskIndex::open(OwnedBytes::new(merged_bytes.clone()), AnnKind::BinaryIvf, 4)
2607                .unwrap();
2608
2609        let mut expected_payload = first_bytes[ANN_HEADER_SIZE..payload_end(&first)].to_vec();
2610        expected_payload.extend_from_slice(&second_bytes[ANN_HEADER_SIZE..payload_end(&second)]);
2611        assert_eq!(
2612            &merged_bytes[ANN_HEADER_SIZE..payload_end(&merged)],
2613            expected_payload.as_slice(),
2614            "normal merge must not decode or rewrite any corpus-sized ANN column",
2615        );
2616
2617        let mut docs: Vec<u32> = merged
2618            .search_binary_clusters::<false>(&[0], 4, &[0, 1])
2619            .unwrap()
2620            .into_iter()
2621            .map(|result| result.0)
2622            .collect();
2623        docs.sort_unstable();
2624        assert_eq!(docs, [0, 1, 2, 3]);
2625
2626        // A merged source's directory is cluster-sorted while its payload is
2627        // source-order. A later merge must follow physical offsets and still
2628        // preserve every source column byte-for-byte.
2629        let mut second_merge_bytes = Vec::new();
2630        write_merged_ann(&[(&merged, 0), (&first, 4)], &mut second_merge_bytes).unwrap();
2631        let second_merge = AnnDiskIndex::open(
2632            OwnedBytes::new(second_merge_bytes.clone()),
2633            AnnKind::BinaryIvf,
2634            6,
2635        )
2636        .unwrap();
2637        let mut expected_second_payload =
2638            merged_bytes[ANN_HEADER_SIZE..payload_end(&merged)].to_vec();
2639        expected_second_payload
2640            .extend_from_slice(&first_bytes[ANN_HEADER_SIZE..payload_end(&first)]);
2641        assert_eq!(
2642            &second_merge_bytes[ANN_HEADER_SIZE..payload_end(&second_merge)],
2643            expected_second_payload.as_slice(),
2644        );
2645        let mut docs: Vec<u32> = second_merge
2646            .search_binary_clusters::<false>(&[0], 6, &[0, 1])
2647            .unwrap()
2648            .into_iter()
2649            .map(|result| result.0)
2650            .collect();
2651        docs.sort_unstable();
2652        assert_eq!(docs, [0, 1, 2, 3, 4, 5]);
2653    }
2654
2655    #[test]
2656    fn legacy_ivf_tq_payload_is_rejected_while_opening() {
2657        let dim = 8;
2658        let marked_version = crate::structures::mark_ivf_tq_cosine_generation(7);
2659        let centroids = crate::structures::CoarseCentroids {
2660            num_clusters: 1,
2661            dim,
2662            centroids: vec![0.0; dim],
2663            version: marked_version,
2664            soar_config: None,
2665            routing_index: None,
2666        };
2667        let mut bytes = crate::segment::ann_build::build_ivf_tq(
2668            dim,
2669            IvfRoutingMode::Flat,
2670            &centroids,
2671            &[(0, 0)],
2672            &[1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
2673        )
2674        .unwrap();
2675
2676        // The fixed header stores quantizer_version at bytes 24..32.
2677        bytes[24..32].copy_from_slice(&7u64.to_le_bytes());
2678        let error = AnnDiskIndex::open(OwnedBytes::new(bytes), AnnKind::IvfTq, 1)
2679            .err()
2680            .expect("legacy IVF-TQ payload must fail while opening")
2681            .to_string();
2682        assert!(error.contains("unsupported legacy generation"), "{error}");
2683    }
2684
2685    #[test]
2686    fn binary_combined_search_deduplicates_soar_and_bounds_document_results() {
2687        // doc 0 / ordinal 0 occurs in both leaves, as it can under SOAR.
2688        // Without `(doc, ordinal)` dedup it would beat doc 1 for both Sum and
2689        // default LogSumExp; with correct dedup doc 1's two distinct values win.
2690        let cluster_0_docs = [0u32, 0, 1];
2691        let cluster_0_ordinals = [0u16, 1, 0];
2692        let cluster_0_codes = [0x00u8, 0xff, 0x03];
2693        let cluster_1_docs = [0u32, 1, 2];
2694        let cluster_1_ordinals = [0u16, 1, 0];
2695        let cluster_1_codes = [0x00u8, 0x0c, 0xf0];
2696        let runs = [
2697            BuildRun {
2698                cluster_id: 0,
2699                doc_ids: &cluster_0_docs,
2700                ordinals: &cluster_0_ordinals,
2701                codes: &cluster_0_codes,
2702            },
2703            BuildRun {
2704                cluster_id: 1,
2705                doc_ids: &cluster_1_docs,
2706                ordinals: &cluster_1_ordinals,
2707                codes: &cluster_1_codes,
2708            },
2709        ];
2710        let mut bytes = Vec::new();
2711        write_built_runs(binary_header(6), &runs, &mut bytes).unwrap();
2712        let disk = AnnDiskIndex::open(OwnedBytes::new(bytes), AnnKind::BinaryIvf, 3).unwrap();
2713
2714        // Under Sum the SOAR duplicate decides the winner outright: doc 0
2715        // counted twice (2.0) would beat doc 1's two distinct values (1.5);
2716        // deduplicated, doc 1 wins.
2717        let sum = crate::query::MultiValueCombiner::Sum;
2718        let (result, probed) = disk
2719            .search_binary_combined_documents(1, &[0], &[0, 1], sum)
2720            .unwrap();
2721        assert_eq!(result.len(), 1, "combined search must honor k");
2722        assert_eq!(
2723            result[0].doc_id, 1,
2724            "SOAR duplicate changed {sum:?} ranking: {result:?}",
2725        );
2726        // Exact leaf scores are handed back only for the retained document,
2727        // deduplicated and sorted, so reranking can skip re-reading them.
2728        assert_eq!(
2729            probed
2730                .iter()
2731                .map(|&(doc_id, ordinal, _)| (doc_id, ordinal))
2732                .collect::<Vec<_>>(),
2733            vec![(1, 0), (1, 1)],
2734            "{sum:?}",
2735        );
2736
2737        // Under the default smooth-max combiner doc 0's perfect ordinal wins
2738        // regardless of the duplicate, so dedup is pinned through the exact
2739        // score instead: it must equal the combiner over the two *distinct*
2740        // ordinals (1.0 and 0.0) — a double-counted (doc 0, ordinal 0) would
2741        // inflate it.
2742        let smooth_max = crate::query::MultiValueCombiner::default();
2743        let (result, probed) = disk
2744            .search_binary_combined_documents(1, &[0], &[0, 1], smooth_max)
2745            .unwrap();
2746        assert_eq!(result.len(), 1, "combined search must honor k");
2747        assert_eq!(result[0].doc_id, 0, "{result:?}");
2748        let expected = smooth_max.combine(&[(0, 1.0), (1, 0.0)]);
2749        assert!(
2750            (result[0].score - expected).abs() < 1e-6,
2751            "SOAR duplicate leaked into {smooth_max:?}: got {}, expected {expected}",
2752            result[0].score,
2753        );
2754        assert_eq!(
2755            probed
2756                .iter()
2757                .map(|&(doc_id, ordinal, _)| (doc_id, ordinal))
2758                .collect::<Vec<_>>(),
2759            vec![(0, 0), (0, 1)],
2760            "{smooth_max:?}",
2761        );
2762
2763        let (top_two, probed) = disk
2764            .search_binary_combined_documents(
2765                2,
2766                &[0],
2767                &[0, 1],
2768                crate::query::MultiValueCombiner::Sum,
2769            )
2770            .unwrap();
2771        assert_eq!(
2772            top_two
2773                .iter()
2774                .map(|candidate| candidate.doc_id)
2775                .collect::<Vec<_>>(),
2776            vec![1, 0],
2777        );
2778        assert_eq!(top_two.len(), 2, "full probing must still return at most k");
2779        // doc 0 ordinal 0 was probed twice (a SOAR duplicate) and must appear
2780        // once, with the two retained documents in ascending order.
2781        assert_eq!(
2782            probed
2783                .iter()
2784                .map(|&(doc_id, ordinal, _)| (doc_id, ordinal))
2785                .collect::<Vec<_>>(),
2786            vec![(0, 0), (0, 1), (1, 0), (1, 1)],
2787        );
2788    }
2789
2790    #[test]
2791    fn combined_ordinal_reduction_handles_out_of_order_runs_for_every_combiner() {
2792        let out_of_order_with_duplicate = vec![
2793            (7, 1, 0.4),
2794            (3, 0, 0.8),
2795            (7, 0, 0.6),
2796            (3, 1, 0.2),
2797            (7, 1, 0.5), // higher SOAR estimate replaces 0.4, never adds to it
2798        ];
2799        for combiner in [
2800            crate::query::MultiValueCombiner::Max,
2801            crate::query::MultiValueCombiner::Sum,
2802            crate::query::MultiValueCombiner::Avg,
2803            crate::query::MultiValueCombiner::default(),
2804            crate::query::MultiValueCombiner::WeightedTopK { k: 2, decay: 0.7 },
2805        ] {
2806            let actual = combine_scored_ordinals(out_of_order_with_duplicate.clone(), 2, combiner);
2807            let mut expected = vec![
2808                AnnDocumentCandidate {
2809                    doc_id: 3,
2810                    score: combiner.combine(&[(0, 0.8), (1, 0.2)]),
2811                },
2812                AnnDocumentCandidate {
2813                    doc_id: 7,
2814                    score: combiner.combine(&[(0, 0.6), (1, 0.5)]),
2815                },
2816            ];
2817            expected.sort_unstable_by(|left, right| {
2818                right
2819                    .score
2820                    .total_cmp(&left.score)
2821                    .then_with(|| left.doc_id.cmp(&right.doc_id))
2822            });
2823            assert_eq!(actual, expected, "combiner {combiner:?}");
2824        }
2825    }
2826
2827    fn build_tq_payload(dim: usize, count: usize, seed: u64) -> (Vec<u8>, Vec<Vec<f32>>) {
2828        let codec = std::sync::Arc::new(crate::structures::TqCodec::new(dim));
2829        let mut builder = crate::structures::TqFlatBuilder::new(std::sync::Arc::clone(&codec));
2830        let mut state = seed;
2831        let mut vectors = Vec::new();
2832        let mut flat = Vec::new();
2833        for _ in 0..count {
2834            let vector: Vec<f32> = (0..dim)
2835                .map(|_| {
2836                    state = state
2837                        .wrapping_mul(6364136223846793005)
2838                        .wrapping_add(1442695040888963407);
2839                    ((state >> 33) as f32 / (1u64 << 31) as f32) - 0.5
2840                })
2841                .collect();
2842            flat.extend_from_slice(&vector);
2843            vectors.push(vector);
2844        }
2845        let labels: Vec<(u32, u16)> = (0..count).map(|index| (index as u32, 0)).collect();
2846        builder.add_batch(&labels, &flat).unwrap();
2847        builder.finish();
2848        let mut bytes = Vec::new();
2849        write_built_tq_flat(&builder, &mut bytes).unwrap();
2850        (bytes, vectors)
2851    }
2852
2853    #[test]
2854    fn tq_combined_scan_ranks_complete_documents_instead_of_individual_values() {
2855        let dim = 8;
2856        let codec = std::sync::Arc::new(crate::structures::TqCodec::new(dim));
2857        let mut builder = crate::structures::TqFlatBuilder::new(std::sync::Arc::clone(&codec));
2858        let labels = [(0u32, 0u16), (1, 0), (1, 1)];
2859        let vectors = [
2860            1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, // doc 0: best single value
2861            0.8, 0.6, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, // doc 1: two good values
2862            0.8, -0.6, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
2863        ];
2864        builder.add_batch(&labels, &vectors).unwrap();
2865        builder.finish();
2866        let mut bytes = Vec::new();
2867        write_built_tq_flat(&builder, &mut bytes).unwrap();
2868        let disk = AnnDiskIndex::open(OwnedBytes::new(bytes), AnnKind::TqFlat, 2).unwrap();
2869        let query = [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
2870        let plan = crate::structures::TqQueryPlan::build(&codec, &query);
2871
2872        let max = disk.search_tq_distinct(1, &plan).unwrap();
2873        let sum = disk
2874            .search_tq_combined_documents(1, &plan, crate::query::MultiValueCombiner::Sum)
2875            .unwrap();
2876        let default_combiner = disk
2877            .search_tq_combined_documents(1, &plan, crate::query::MultiValueCombiner::default())
2878            .unwrap();
2879        assert_eq!(max[0].0, 0, "fixture must favor doc 0 by Max: {max:?}");
2880        assert_eq!(
2881            sum[0].doc_id, 1,
2882            "two complete values must make doc 1 win by Sum: {sum:?}"
2883        );
2884        // The default combiner is a smooth maximum: doc 0's single best value
2885        // (1.0) outranks doc 1's two 0.8 values — value count alone no longer
2886        // wins. Sum above is what pins that both of doc 1's values were seen.
2887        assert_eq!(
2888            default_combiner[0].doc_id, 0,
2889            "default smooth-max must follow the best value: {default_combiner:?}"
2890        );
2891        assert!(sum[0].score > max[0].2);
2892
2893        // Pure-copy upgrade merges may append a re-encoded older segment
2894        // after copied runs, so global run order need not follow doc IDs.
2895        // Documents remain complete within each run and must still combine.
2896        let (single_bytes, _) = build_tq_payload(dim, 1, 91);
2897        let single = AnnDiskIndex::open(OwnedBytes::new(single_bytes), AnnKind::TqFlat, 1).unwrap();
2898        let mut merged_bytes = Vec::new();
2899        write_merged_ann(&[(&disk, 1), (&single, 0)], &mut merged_bytes).unwrap();
2900        let merged = AnnDiskIndex::open(OwnedBytes::new(merged_bytes), AnnKind::TqFlat, 3).unwrap();
2901        let merged_sum = merged
2902            .search_tq_combined_documents(1, &plan, crate::query::MultiValueCombiner::Sum)
2903            .unwrap();
2904        assert_eq!(merged_sum[0].doc_id, 2);
2905    }
2906
2907    #[test]
2908    fn tq_payload_roundtrip_search_and_pure_copy_merge() {
2909        let dim = 20; // pads to 32; exercises padding + partial final block
2910        let count = 21;
2911        let (bytes, vectors) = build_tq_payload(dim, count, 42);
2912        let index = AnnDiskIndex::open(
2913            OwnedBytes::new(bytes.clone()),
2914            AnnKind::TqFlat,
2915            count as u32,
2916        )
2917        .unwrap();
2918        assert_eq!(index.header().vector_count, count);
2919
2920        // The stored estimate must rank an exact-duplicate query's own row first.
2921        let codec = crate::structures::TqCodec::new(dim);
2922        for target in [0usize, 7, 20] {
2923            let plan = crate::structures::TqQueryPlan::build(&codec, &vectors[target]);
2924            let results = index.search_tq_distinct(3, &plan).unwrap();
2925            assert_eq!(
2926                results[0].0, target as u32,
2927                "query duplicating vector {target} must rank it first: {results:?}"
2928            );
2929        }
2930
2931        // Ordinary merge must not decode or rewrite the corpus columns.
2932        let (second_bytes, _) = build_tq_payload(dim, 5, 77);
2933        let second =
2934            AnnDiskIndex::open(OwnedBytes::new(second_bytes.clone()), AnnKind::TqFlat, 5).unwrap();
2935        let mut merged_bytes = Vec::new();
2936        write_merged_ann(&[(&index, 0), (&second, count as u32)], &mut merged_bytes).unwrap();
2937        let merged = AnnDiskIndex::open(
2938            OwnedBytes::new(merged_bytes.clone()),
2939            AnnKind::TqFlat,
2940            count as u32 + 5,
2941        )
2942        .unwrap();
2943        let mut expected_payload = bytes[ANN_HEADER_SIZE..payload_end(&index)].to_vec();
2944        expected_payload.extend_from_slice(&second_bytes[ANN_HEADER_SIZE..payload_end(&second)]);
2945        assert_eq!(
2946            &merged_bytes[ANN_HEADER_SIZE..payload_end(&merged)],
2947            expected_payload.as_slice(),
2948            "TQ merge must be a pure byte copy of the source columns",
2949        );
2950        let plan = crate::structures::TqQueryPlan::build(&codec, &vectors[7]);
2951        let results = merged.search_tq_distinct(1, &plan).unwrap();
2952        assert_eq!(results[0].0, 7, "merged payload must keep doc bases");
2953    }
2954
2955    #[test]
2956    fn tq_parallel_fold_matches_a_sequential_scan() {
2957        use crate::structures::vector::quantization::{
2958            TQ_BLOCK_LANES, tq_block_bytes, tq_score_block,
2959        };
2960
2961        // Exactly the production fan-out threshold exercises the parallel
2962        // fold/reduce path without relying on a test-only configuration.
2963        let dim = 8;
2964        let count = TQ_PARALLEL_SCAN_MIN_VECTORS;
2965        let (bytes, vectors) = build_tq_payload(dim, count, 87);
2966        let disk =
2967            AnnDiskIndex::open(OwnedBytes::new(bytes), AnnKind::TqFlat, count as u32).unwrap();
2968        let codec = crate::structures::TqCodec::new(dim);
2969        let plan = crate::structures::TqQueryPlan::build(&codec, &vectors[count / 3]);
2970        let k = 31;
2971
2972        let block_bytes = tq_block_bytes(disk.header().code_size);
2973        assert_eq!(
2974            disk.runs.len(),
2975            1,
2976            "the regression must parallelize chunks inside one run"
2977        );
2978        assert!(
2979            disk.runs[0].codes.len() / block_bytes > TQ_PARALLEL_SCAN_CHUNK_BLOCKS,
2980            "the single run must span multiple parallel chunks"
2981        );
2982        let raw = disk.raw.as_slice();
2983        let mut reference = BoundedAnnCollector::<true, true>::new(k);
2984        let mut scores = [0.0f32; TQ_BLOCK_LANES];
2985        for run in &disk.runs {
2986            let codes = &raw[run.codes.clone()];
2987            for (block_index, block) in codes.chunks_exact(block_bytes).enumerate() {
2988                tq_score_block(&plan, block, &mut scores);
2989                let lane_base = block_index * TQ_BLOCK_LANES;
2990                let lanes = TQ_BLOCK_LANES.min(run.count.saturating_sub(lane_base));
2991                for (lane, &score) in scores.iter().enumerate().take(lanes) {
2992                    let index = lane_base + lane;
2993                    reference.insert(
2994                        run_doc_id(raw, run, index).unwrap(),
2995                        read_u16(raw, run.ordinals.start + index * 2),
2996                        score,
2997                    );
2998                }
2999            }
3000        }
3001
3002        assert_eq!(
3003            disk.search_tq_distinct(k, &plan).unwrap(),
3004            reference.into_sorted_results(),
3005        );
3006    }
3007
3008    #[test]
3009    fn ivf_tq_scale_pruning_matches_the_unpruned_scan() {
3010        use crate::structures::vector::ivf::{CoarseCentroids, CoarseConfig};
3011        use crate::structures::vector::quantization::{
3012            TQ_BLOCK_LANES, tq_ivf_block_bytes, tq_score_ivf_block,
3013        };
3014        use crate::structures::{IvfTqIndex, TqCodec, TqIvfEncodeScratch, TqIvfQueryPlan};
3015
3016        let dim = 32;
3017        let count = 400usize;
3018        let codec = std::sync::Arc::new(TqCodec::new(dim));
3019        let mut state = 5u64;
3020        let mut next = move || {
3021            state = state
3022                .wrapping_mul(6364136223846793005)
3023                .wrapping_add(1442695040888963407);
3024            ((state >> 33) as f32 / (1u64 << 31) as f32) - 0.5
3025        };
3026        let vectors: Vec<Vec<f32>> = (0..count)
3027            .map(|_| {
3028                let mut v: Vec<f32> = (0..dim).map(|_| next()).collect();
3029                let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
3030                v.iter_mut().for_each(|x| *x /= norm);
3031                v
3032            })
3033            .collect();
3034        let mut centroids = CoarseCentroids::train(&CoarseConfig::new(dim, 8), &vectors, "test");
3035        centroids.version = crate::structures::mark_ivf_tq_cosine_generation(centroids.version);
3036        let mut index = IvfTqIndex::new(
3037            dim,
3038            crate::dsl::IvfRoutingMode::Flat,
3039            centroids.version,
3040            std::sync::Arc::clone(&codec),
3041        );
3042        let mut scratch = TqIvfEncodeScratch::default();
3043        for (i, vector) in vectors.iter().enumerate() {
3044            index.add_vector(
3045                &centroids,
3046                (i / 2) as u32,
3047                (i % 2) as u16,
3048                vector,
3049                &mut scratch,
3050            );
3051        }
3052        let mut bytes = Vec::new();
3053        write_built_ivf_tq(&index, centroids.num_clusters, &mut bytes).unwrap();
3054        let disk =
3055            AnnDiskIndex::open(OwnedBytes::new(bytes), AnnKind::IvfTq, count as u32).unwrap();
3056
3057        // The supported cosine generation promises descending residual scales,
3058        // enabling the reader to terminate the run at the first losing block.
3059        let block_bytes = tq_ivf_block_bytes(disk.header().code_size);
3060        let raw = disk.raw.as_slice();
3061        for run in &disk.runs {
3062            let codes = &raw[run.codes.clone()];
3063            let mut previous_scale = f32::INFINITY;
3064            for (block_index, block) in codes.chunks_exact(block_bytes).enumerate() {
3065                let lane_base = block_index * TQ_BLOCK_LANES;
3066                let lanes = TQ_BLOCK_LANES.min(run.count.saturating_sub(lane_base));
3067                let mut block_scales = block[..TQ_BLOCK_LANES * size_of::<f32>()]
3068                    .chunks_exact(size_of::<f32>())
3069                    .take(lanes)
3070                    .map(|lane| f32::from_le_bytes(lane.try_into().unwrap()));
3071                let first_scale = block_scales.next().unwrap();
3072                assert_eq!(tq_ivf_block_max_scale(block), first_scale);
3073                assert!(first_scale <= previous_scale);
3074                previous_scale = first_scale;
3075                for scale in block_scales {
3076                    assert!(scale <= previous_scale);
3077                    previous_scale = scale;
3078                }
3079            }
3080        }
3081        let k = 10;
3082        for query_seed in [1u64, 9, 42] {
3083            let mut qstate = query_seed;
3084            let mut qnext = move || {
3085                qstate = qstate
3086                    .wrapping_mul(6364136223846793005)
3087                    .wrapping_add(1442695040888963407);
3088                ((qstate >> 33) as f32 / (1u64 << 31) as f32) - 0.5
3089            };
3090            let query: Vec<f32> = (0..dim).map(|_| qnext()).collect();
3091            let plan = TqIvfQueryPlan::build(
3092                &centroids,
3093                &codec,
3094                &query,
3095                8,
3096                crate::dsl::IvfRoutingMode::Flat,
3097            );
3098            // Unpruned reference: score every block of every probed run with
3099            // the identical kernel and collector.
3100            let mut reference = BoundedAnnCollector::<true, true>::new(k);
3101            let mut unpruned_scores = Vec::new();
3102            let mut scores = [0.0f32; TQ_BLOCK_LANES];
3103            for (cluster_id, cluster_dot) in plan.cluster_dots() {
3104                for run in disk.cluster_runs(cluster_id) {
3105                    let codes = &raw[run.codes.clone()];
3106                    for (block_index, block) in codes.chunks_exact(block_bytes).enumerate() {
3107                        tq_score_ivf_block(plan.tq_plan(), block, cluster_dot, &mut scores);
3108                        let lane_base = block_index * TQ_BLOCK_LANES;
3109                        let lanes = TQ_BLOCK_LANES.min(run.count.saturating_sub(lane_base));
3110                        for (lane, &score) in scores.iter().enumerate().take(lanes) {
3111                            let idx = lane_base + lane;
3112                            reference.insert(
3113                                run_doc_id(raw, run, idx).unwrap(),
3114                                read_u16(raw, run.ordinals.start + idx * 2),
3115                                score,
3116                            );
3117                            unpruned_scores.push((
3118                                run_doc_id(raw, run, idx).unwrap(),
3119                                read_u16(raw, run.ordinals.start + idx * 2),
3120                                score,
3121                            ));
3122                        }
3123                    }
3124                }
3125            }
3126
3127            let pruned = disk.search_ivf_tq_distinct(k, &plan).unwrap();
3128            let reference = reference.into_sorted_results();
3129            assert_eq!(
3130                pruned, reference,
3131                "scale-bound pruning must not change the estimated top-k (seed {query_seed})"
3132            );
3133            for combiner in [
3134                crate::query::MultiValueCombiner::Max,
3135                crate::query::MultiValueCombiner::Sum,
3136                crate::query::MultiValueCombiner::Avg,
3137                crate::query::MultiValueCombiner::default(),
3138                crate::query::MultiValueCombiner::WeightedTopK { k: 3, decay: 0.7 },
3139            ] {
3140                let expected = combine_scored_ordinals(unpruned_scores.clone(), k, combiner);
3141                let combined = disk
3142                    .search_ivf_tq_combined_documents(k, &plan, combiner)
3143                    .unwrap();
3144                assert_eq!(
3145                    combined, expected,
3146                    "combined IVF-TQ scan diverged from the unpruned reference \
3147                     for {combiner:?} (seed {query_seed})",
3148                );
3149                assert!(combined.len() <= k);
3150            }
3151        }
3152    }
3153
3154    #[test]
3155    fn open_rejects_tq_payload_with_inconsistent_geometry() {
3156        let (bytes, _) = build_tq_payload(20, 4, 9);
3157        // code_size (header bytes 12..16) is P/2 = 16 for dim 20; corrupt to 15.
3158        let mut corrupted = bytes.clone();
3159        corrupted[12..16].copy_from_slice(&15u32.to_le_bytes());
3160        assert!(
3161            AnnDiskIndex::open(OwnedBytes::new(corrupted), AnnKind::TqFlat, 4).is_err(),
3162            "TQ header with code_size != padded_dim/2 must be refused"
3163        );
3164
3165        // A block-padded TQ column must not validate under another kind.
3166        let (short_bytes, _) = build_tq_payload(20, 4, 9);
3167        let mut wrong_kind = short_bytes.clone();
3168        wrong_kind[4] = AnnKind::BinaryIvf as u8;
3169        assert!(
3170            AnnDiskIndex::open(OwnedBytes::new(wrong_kind), AnnKind::BinaryIvf, 4).is_err(),
3171            "TQ block-padded columns must not validate under another kind"
3172        );
3173
3174        // The retired IVF-PQ discriminant must be refused loudly.
3175        let (legacy_bytes, _) = build_tq_payload(20, 4, 9);
3176        let mut legacy_kind = legacy_bytes.clone();
3177        legacy_kind[4] = 1;
3178        let Err(error) = AnnDiskIndex::open(OwnedBytes::new(legacy_kind), AnnKind::TqFlat, 4)
3179        else {
3180            panic!("retired IVF-PQ payloads must not open");
3181        };
3182        assert!(
3183            error.to_string().contains("IVF-PQ"),
3184            "error must name the retired format: {error}"
3185        );
3186        assert!(AnnDiskIndex::open(OwnedBytes::new(bytes), AnnKind::TqFlat, 4).is_ok());
3187    }
3188
3189    #[test]
3190    fn open_rejects_old_or_out_of_range_ann_payloads() {
3191        let mut legacy = vec![0u8; ANN_HEADER_SIZE + ANN_FOOTER_SIZE];
3192        legacy[..4].copy_from_slice(b"old!");
3193        assert!(AnnDiskIndex::open(OwnedBytes::new(legacy), AnnKind::BinaryIvf, 1).is_err());
3194
3195        let docs = [0u32];
3196        let ordinals = [0u16];
3197        let codes = [0u8];
3198        let runs = [BuildRun {
3199            cluster_id: 0,
3200            doc_ids: &docs,
3201            ordinals: &ordinals,
3202            codes: &codes,
3203        }];
3204        let mut bytes = Vec::new();
3205        write_built_runs(binary_header(1), &runs, &mut bytes).unwrap();
3206        let footer = bytes.len() - ANN_FOOTER_SIZE;
3207        let directory = usize::try_from(u64::from_le_bytes(
3208            bytes[footer..footer + 8].try_into().unwrap(),
3209        ))
3210        .unwrap();
3211        bytes[directory + 12..directory + 16].copy_from_slice(&10u32.to_le_bytes());
3212        assert!(AnnDiskIndex::open(OwnedBytes::new(bytes), AnnKind::BinaryIvf, 1).is_err());
3213    }
3214}