Skip to main content

summa_core/structures/vector/scann/
binary.rs

1//! Binary ScaNN routing and exact leaf scanning.
2//!
3//! Binary embeddings stay packed from training through serving. Routing uses
4//! a configurable one-to-three-level k-majority tree and leaf scans compute
5//! exact Hamming distances with Summa' resolved AVX-512/AVX2/NEON/scalar
6//! kernel. The trained tree is global; segment objects contain only leaf-local
7//! document columns and exact packed codes, so compatible merges never train.
8
9use std::cmp::Reverse;
10use std::collections::BinaryHeap;
11use std::ops::Range;
12
13use rand::SeedableRng;
14
15use super::{
16    MAX_SCANN_TREE_LEVELS, MIN_PARTITION_TRAINING_POINTS_PER_LEAF, MIN_POINTS_FOR_PARTITIONING,
17    ScannConfig, ScannEncoding, ScannFormatError, ScannGeometry, ScannLeafRun, ScannResult,
18    ScannRoutingLevel, ScannSegmentPayload, ScannTrainedArtifact, ScannTrainedArtifactView,
19    ScannTrainingState, desired_training_sample,
20};
21use crate::dsl::IvfRoutingMode;
22use crate::structures::simd::HammingKernel;
23use crate::structures::vector::index::{BinaryIvfConfig, train_binary_k_majority_codebook};
24use crate::structures::vector::ivf::SoarConfig;
25use crate::structures::vector::ivf::routing::allocate_child_clusters;
26
27const HAMMING_SCAN_BLOCK: usize = 1_024;
28const MAX_LOCAL_K_MAJORITY_BRANCHES: usize = 64;
29const BINARY_SPILL_ASSIGNMENT_CANDIDATES: usize = 8;
30
31#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
32pub struct BinaryScannTrainingStats {
33    pub splits: usize,
34    pub max_split_clusters: usize,
35    pub max_depth: usize,
36    pub retained_groups: usize,
37    /// Largest temporary packed code matrix materialized for a non-contiguous
38    /// row group. The complete retained sample is borrowed in place.
39    pub max_materialized_training_bytes: usize,
40}
41
42/// Training controls for a global binary ScaNN tree.
43///
44/// Readiness and sample size are intentionally absent: both are hardcoded and
45/// derived from `geometry`, matching the float ScaNN builder contract.
46#[derive(Clone, Debug)]
47pub struct BinaryScannTraining {
48    pub dim_bits: u32,
49    pub geometry: ScannGeometry,
50    pub train_iters: usize,
51    pub seed: u64,
52}
53
54impl BinaryScannTraining {
55    pub fn validate(&self) -> ScannResult<()> {
56        if self.dim_bits == 0 || !self.dim_bits.is_multiple_of(8) {
57            return Err(ScannFormatError::new(
58                "binary ScaNN dimension must be a positive multiple of eight bits",
59            ));
60        }
61        let levels = usize::from(self.geometry.centroid_levels);
62        if levels == 0
63            || self.geometry.centroid_levels > MAX_SCANN_TREE_LEVELS
64            || self.geometry.level_counts.len() != levels
65            || self.geometry.level_counts.last().copied() != Some(self.geometry.num_leaves)
66            || self.geometry.level_counts.contains(&0)
67            || self
68                .geometry
69                .level_counts
70                .windows(2)
71                .any(|counts| counts[0] > counts[1])
72        {
73            return Err(ScannFormatError::new(
74                "binary ScaNN needs a valid one-to-three-level cumulative geometry",
75            ));
76        }
77        if self.train_iters == 0 {
78            return Err(ScannFormatError::new(
79                "binary ScaNN k-majority iterations must be positive",
80            ));
81        }
82        Ok(())
83    }
84
85    /// The corpus floor is fixed in code and raised only when the chosen
86    /// geometry needs the hardcoded minimum sample coverage for every
87    /// terminal leaf.
88    pub fn training_state(&self, observed: u64) -> ScannResult<ScannTrainingState> {
89        self.validate()?;
90        let geometry_required = u64::from(self.geometry.num_leaves)
91            .checked_mul(MIN_PARTITION_TRAINING_POINTS_PER_LEAF)
92            .ok_or_else(|| {
93                ScannFormatError::new("binary ScaNN minimum training sample overflows u64")
94            })?;
95        let required = MIN_POINTS_FOR_PARTITIONING.max(geometry_required);
96        Ok(if observed < required {
97            ScannTrainingState::AwaitingData { observed, required }
98        } else {
99            ScannTrainingState::Ready { observed, required }
100        })
101    }
102
103    pub fn desired_training_vectors(&self, observed: u64) -> ScannResult<u64> {
104        self.validate()?;
105        Ok(desired_training_sample(observed, self.geometry.num_leaves))
106    }
107}
108
109#[derive(Clone, Debug)]
110struct BinaryRoutingLevel {
111    /// Packed child centroids, ordered by parent node.
112    centroids: Vec<u8>,
113    /// `parent_offsets[p]..parent_offsets[p + 1]` is parent `p`'s child run.
114    parent_offsets: Vec<u32>,
115}
116
117/// Index-generation-scoped packed Hamming routing model.
118#[derive(Clone, Debug)]
119pub struct BinaryScannModel {
120    dim_bits: u32,
121    num_leaves: u32,
122    levels: Vec<BinaryRoutingLevel>,
123    fingerprint: u64,
124}
125
126#[derive(Clone, Debug, PartialEq, Eq)]
127struct QuantizedBinaryRoutingLevel {
128    centroid_count: usize,
129    centroid_codes: Range<usize>,
130    parent_offsets: Vec<u32>,
131}
132
133/// Small executable metadata for mmap-backed packed-Hamming routing. The
134/// potentially multi-gigabyte centroid planes remain in artifact storage.
135#[derive(Clone, Debug, PartialEq, Eq)]
136pub struct QuantizedBinaryScannModel {
137    dim_bits: u32,
138    num_leaves: u32,
139    artifact_id: u64,
140    artifact_len: usize,
141    levels: Vec<QuantizedBinaryRoutingLevel>,
142    fingerprint: u64,
143}
144
145#[derive(Clone, Copy, Debug)]
146pub struct QuantizedBinaryScannModelView<'a> {
147    model: &'a QuantizedBinaryScannModel,
148    artifact_bytes: &'a [u8],
149}
150
151impl BinaryScannModel {
152    pub fn to_artifact(
153        &self,
154        generation: u64,
155        trained_vectors: u64,
156    ) -> ScannResult<ScannTrainedArtifact> {
157        self.validate()?;
158        let levels = self
159            .levels
160            .iter()
161            .enumerate()
162            .map(|(index, level)| ScannRoutingLevel {
163                centroid_count: (level.centroids.len() / self.byte_len()) as u32,
164                centroid_codes: level.centroids.clone(),
165                minimums: Vec::new(),
166                steps: Vec::new(),
167                child_offsets: self
168                    .levels
169                    .get(index + 1)
170                    .map_or_else(Vec::new, |next| next.parent_offsets.clone()),
171            })
172            .collect();
173        ScannTrainedArtifact::new(
174            generation,
175            trained_vectors,
176            ScannConfig {
177                dimension: self.dim_bits,
178                tree_levels: self.levels.len() as u8,
179                num_leaves: self.num_leaves,
180                encoding: ScannEncoding::BinaryHamming,
181            },
182            levels,
183            None,
184        )
185    }
186
187    pub fn from_artifact(artifact: &ScannTrainedArtifact) -> ScannResult<Self> {
188        artifact.validate()?;
189        if artifact.config.encoding != ScannEncoding::BinaryHamming {
190            return Err(ScannFormatError::new(
191                "float ScaNN artifact cannot be opened as a binary model",
192            ));
193        }
194        let byte_len = artifact.config.dimension as usize / 8;
195        let mut levels = Vec::with_capacity(artifact.levels.len());
196        for (index, level) in artifact.levels.iter().enumerate() {
197            let parent_offsets = if index == 0 {
198                vec![0, level.centroid_count]
199            } else {
200                artifact.levels[index - 1].child_offsets.clone()
201            };
202            if level.centroid_codes.len() != level.centroid_count as usize * byte_len {
203                return Err(ScannFormatError::new(
204                    "binary ScaNN artifact centroid plane is inconsistent",
205                ));
206            }
207            levels.push(BinaryRoutingLevel {
208                centroids: level.centroid_codes.clone(),
209                parent_offsets,
210            });
211        }
212        let mut model = Self {
213            dim_bits: artifact.config.dimension,
214            num_leaves: artifact.config.num_leaves,
215            levels,
216            fingerprint: 0,
217        };
218        model.fingerprint = model.compute_fingerprint();
219        model.validate()?;
220        Ok(model)
221    }
222
223    pub fn train(
224        training: &BinaryScannTraining,
225        codes: &[u8],
226        num_vectors: usize,
227        index_label: &str,
228    ) -> ScannResult<Self> {
229        Self::train_with_stats(training, codes, num_vectors, index_label).map(|(model, _)| model)
230    }
231
232    pub fn train_with_stats(
233        training: &BinaryScannTraining,
234        codes: &[u8],
235        num_vectors: usize,
236        index_label: &str,
237    ) -> ScannResult<(Self, BinaryScannTrainingStats)> {
238        training.validate()?;
239        match training.training_state(num_vectors as u64)? {
240            ScannTrainingState::AwaitingData { observed, required } => {
241                return Err(ScannFormatError::new(format!(
242                    "binary ScaNN training deferred: geometry requires {required} vectors, observed {observed}"
243                )));
244            }
245            ScannTrainingState::Ready { .. } => {}
246        }
247        let byte_len = usize::try_from(training.dim_bits / 8)
248            .map_err(|_| ScannFormatError::new("binary ScaNN row size exceeds usize"))?;
249        let expected = num_vectors
250            .checked_mul(byte_len)
251            .ok_or_else(|| ScannFormatError::new("binary ScaNN training matrix overflows"))?;
252        if codes.len() != expected {
253            return Err(ScannFormatError::new(format!(
254                "binary ScaNN training matrix is truncated: expected {expected} bytes, got {}",
255                codes.len()
256            )));
257        }
258
259        let sample_count = usize::try_from(training.desired_training_vectors(num_vectors as u64)?)
260            .map_err(|_| ScannFormatError::new("binary ScaNN sample count exceeds usize"))?;
261        let mut groups = vec![deterministic_sample_rows(
262            num_vectors,
263            sample_count,
264            training.seed,
265        )];
266        let mut levels = Vec::with_capacity(training.geometry.level_counts.len());
267        let mut stats = BinaryScannTrainingStats::default();
268
269        for (level_index, &level_count) in training.geometry.level_counts.iter().enumerate() {
270            let group_sizes: Vec<usize> = groups.iter().map(BinaryTrainingRows::len).collect();
271            let child_counts = allocate_child_clusters(&group_sizes, level_count as usize);
272            if child_counts.iter().sum::<usize>() != level_count as usize {
273                return Err(ScannFormatError::new(format!(
274                    "binary ScaNN geometry level {level_index} cannot allocate {level_count} centroids from {sample_count} samples"
275                )));
276            }
277
278            let mut parent_offsets = Vec::with_capacity(groups.len() + 1);
279            let centroid_bytes = (level_count as usize)
280                .checked_mul(byte_len)
281                .ok_or_else(|| ScannFormatError::new("binary ScaNN centroid matrix overflows"))?;
282            let mut centroids = Vec::with_capacity(centroid_bytes);
283            let mut next_groups = Vec::with_capacity(level_count as usize);
284            parent_offsets.push(0);
285            let current_groups = std::mem::take(&mut groups);
286            for (parent, (group, &children)) in
287                current_groups.into_iter().zip(&child_counts).enumerate()
288            {
289                if children > 0 {
290                    let partition = train_binary_partition(
291                        codes,
292                        &group,
293                        byte_len,
294                        training.dim_bits,
295                        children,
296                        training.train_iters,
297                        derived_seed(training.seed, level_index, parent),
298                        0,
299                        level_index + 1 < training.geometry.level_counts.len(),
300                        index_label,
301                        &mut stats,
302                    )?;
303                    centroids.extend_from_slice(&partition.centroids);
304                    if level_index + 1 < training.geometry.level_counts.len() {
305                        next_groups.extend(partition.groups);
306                    }
307                }
308                parent_offsets.push(u32::try_from(centroids.len() / byte_len).map_err(|_| {
309                    ScannFormatError::new("binary ScaNN centroid identifier exceeds u32")
310                })?);
311            }
312            debug_assert_eq!(centroids.len(), centroid_bytes);
313
314            let is_leaf_level = level_index + 1 == training.geometry.level_counts.len();
315            if !is_leaf_level {
316                groups = next_groups;
317            }
318            levels.push(BinaryRoutingLevel {
319                centroids,
320                parent_offsets,
321            });
322        }
323
324        let mut model = Self {
325            dim_bits: training.dim_bits,
326            num_leaves: training.geometry.num_leaves,
327            levels,
328            fingerprint: 0,
329        };
330        model.fingerprint = model.compute_fingerprint();
331        model.validate()?;
332        Ok((model, stats))
333    }
334
335    pub fn dim_bits(&self) -> u32 {
336        self.dim_bits
337    }
338
339    pub fn num_leaves(&self) -> u32 {
340        self.num_leaves
341    }
342
343    pub fn fingerprint(&self) -> u64 {
344        self.fingerprint
345    }
346
347    pub fn validate(&self) -> ScannResult<()> {
348        if self.dim_bits == 0
349            || !self.dim_bits.is_multiple_of(8)
350            || self.levels.is_empty()
351            || self.levels.len() > usize::from(MAX_SCANN_TREE_LEVELS)
352        {
353            return Err(ScannFormatError::new("invalid binary ScaNN model header"));
354        }
355        let byte_len = self.byte_len();
356        let mut parents = 1usize;
357        for level in &self.levels {
358            if level.parent_offsets.len() != parents + 1
359                || level.parent_offsets.first() != Some(&0)
360                || level
361                    .parent_offsets
362                    .windows(2)
363                    .any(|pair| pair[0] > pair[1])
364            {
365                return Err(ScannFormatError::new(
366                    "invalid binary ScaNN parent directory",
367                ));
368            }
369            let children = level.centroids.len() / byte_len;
370            if level.centroids.len() % byte_len != 0
371                || level.parent_offsets.last().copied() != Some(children as u32)
372            {
373                return Err(ScannFormatError::new(
374                    "invalid binary ScaNN centroid matrix",
375                ));
376            }
377            parents = children;
378        }
379        if parents != self.num_leaves as usize || self.compute_fingerprint() != self.fingerprint {
380            return Err(ScannFormatError::new(
381                "binary ScaNN leaf count or fingerprint is inconsistent",
382            ));
383        }
384        Ok(())
385    }
386
387    /// Route once against the global tree. The resulting plan can be reused
388    /// across every immutable segment in the active generation.
389    pub fn probe(
390        &self,
391        query: &[u8],
392        nprobe: usize,
393        beam_width: usize,
394        scratch: &mut BinaryScannSearchScratch,
395    ) -> ScannResult<BinaryScannProbePlan> {
396        if query.len() != self.byte_len() {
397            return Err(ScannFormatError::new(
398                "binary ScaNN query dimension does not match the model",
399            ));
400        }
401        if nprobe == 0 || beam_width == 0 {
402            return Err(ScannFormatError::new(
403                "binary ScaNN nprobe and beam width must be positive",
404            ));
405        }
406        let mut views = [BinaryLevelView::EMPTY; MAX_SCANN_TREE_LEVELS as usize];
407        for (view, level) in views.iter_mut().zip(&self.levels) {
408            *view = BinaryLevelView {
409                centroids: &level.centroids,
410                parent_offsets: &level.parent_offsets,
411            };
412        }
413        let leaf_ids = probe_binary_tree(
414            &views[..self.levels.len()],
415            self.byte_len(),
416            self.num_leaves as usize,
417            query,
418            nprobe,
419            beam_width,
420            scratch,
421        )?;
422        Ok(BinaryScannProbePlan {
423            model_fingerprint: self.fingerprint,
424            leaf_ids,
425        })
426    }
427
428    pub fn assign(&self, code: &[u8], scratch: &mut BinaryScannSearchScratch) -> ScannResult<u32> {
429        self.probe(code, 1, 1, scratch)?
430            .leaf_ids
431            .first()
432            .copied()
433            .ok_or_else(|| ScannFormatError::new("binary ScaNN assignment returned no leaf"))
434    }
435
436    /// Choose the normal primary leaf and the best alternate leaf reachable by
437    /// a small widened tree probe. Packed bits do not have a meaningful float
438    /// residual projection, so binary spilling uses exact centroid Hamming
439    /// distance while retaining SOAR's one-secondary storage policy.
440    pub fn spill_assignment(
441        &self,
442        code: &[u8],
443        scratch: &mut BinaryScannSearchScratch,
444    ) -> ScannResult<BinaryScannSpillAssignment> {
445        let primary_leaf = self.assign(code, scratch)?;
446        let candidate_count = BINARY_SPILL_ASSIGNMENT_CANDIDATES
447            .min(self.num_leaves as usize)
448            .max(1);
449        let plan = self.probe(code, candidate_count, candidate_count, scratch)?;
450        let kernel = HammingKernel::resolve();
451        let leaf_centroids = &self
452            .levels
453            .last()
454            .expect("validated binary ScaNN model has a terminal level")
455            .centroids;
456        let centroid = |leaf_id: u32| {
457            let start = leaf_id as usize * self.byte_len();
458            &leaf_centroids[start..start + self.byte_len()]
459        };
460        let primary_distance = kernel.distance(code, centroid(primary_leaf));
461        let secondary_leaf = plan
462            .leaf_ids
463            .into_iter()
464            .filter(|&leaf_id| leaf_id != primary_leaf)
465            .map(|leaf_id| (kernel.distance(code, centroid(leaf_id)), leaf_id))
466            .min()
467            .map(|(_, leaf_id)| leaf_id);
468        Ok(BinaryScannSpillAssignment {
469            primary_leaf,
470            secondary_leaf,
471            primary_distance,
472        })
473    }
474
475    /// Search any number of compatible segments with one shared routing plan.
476    /// `doc_base` rebases segment-local IDs without touching their payload.
477    pub fn search_segments(
478        &self,
479        query: &[u8],
480        k: usize,
481        nprobe: usize,
482        beam_width: usize,
483        segments: &[(&BinaryScannSegment, u32)],
484        scratch: &mut BinaryScannSearchScratch,
485    ) -> ScannResult<Vec<BinaryScannHit>> {
486        let plan = self.probe(query, nprobe, beam_width, scratch)?;
487        scratch.best_hit_keys.clear();
488        let mut best = BinaryHeap::with_capacity(k.min(8_192));
489        for &(segment, doc_base) in segments {
490            segment.validate_for(self)?;
491            segment.scan(query, &plan, doc_base, k, &mut best, scratch)?;
492        }
493        let mut hits = best.into_vec();
494        hits.sort_unstable();
495        Ok(hits)
496    }
497
498    fn byte_len(&self) -> usize {
499        self.dim_bits as usize / 8
500    }
501
502    fn compute_fingerprint(&self) -> u64 {
503        let mut hash = Fingerprint::new();
504        hash.write(&self.dim_bits.to_le_bytes());
505        hash.write(&self.num_leaves.to_le_bytes());
506        hash.write(&(self.levels.len() as u32).to_le_bytes());
507        for level in &self.levels {
508            for offset in &level.parent_offsets {
509                hash.write(&offset.to_le_bytes());
510            }
511            hash.write(&level.centroids);
512        }
513        hash.finish()
514    }
515}
516
517impl QuantizedBinaryScannModel {
518    pub fn from_artifact_view(artifact: &ScannTrainedArtifactView<'_>) -> ScannResult<Self> {
519        if artifact.config.encoding != ScannEncoding::BinaryHamming {
520            return Err(ScannFormatError::new(
521                "float ScaNN artifact cannot be opened as a binary mmap model",
522            ));
523        }
524        let byte_len = artifact.config.dimension as usize / 8;
525        let mut levels = Vec::with_capacity(artifact.level_count());
526        for index in 0..artifact.level_count() {
527            let level = artifact.level(index).ok_or_else(|| {
528                ScannFormatError::new("binary ScaNN artifact routing level disappeared")
529            })?;
530            let centroid_codes = artifact.level_centroid_codes_range(index).ok_or_else(|| {
531                ScannFormatError::new("binary ScaNN artifact centroid range disappeared")
532            })?;
533            if centroid_codes.len() != level.centroid_count as usize * byte_len {
534                return Err(ScannFormatError::new(
535                    "binary ScaNN artifact centroid plane is inconsistent",
536                ));
537            }
538            let parent_offsets = if index == 0 {
539                vec![0, level.centroid_count]
540            } else {
541                artifact
542                    .level(index - 1)
543                    .expect("previous validated routing level exists")
544                    .child_offsets()
545                    .collect()
546            };
547            levels.push(QuantizedBinaryRoutingLevel {
548                centroid_count: level.centroid_count as usize,
549                centroid_codes,
550                parent_offsets,
551            });
552        }
553        let mut model = Self {
554            dim_bits: artifact.config.dimension,
555            num_leaves: artifact.config.num_leaves,
556            artifact_id: artifact.artifact_id,
557            artifact_len: artifact.bytes().len(),
558            levels,
559            fingerprint: 0,
560        };
561        model.fingerprint = model.compute_fingerprint(artifact.bytes());
562        model.validate_metadata()?;
563        Ok(model)
564    }
565
566    pub fn view<'a>(
567        &'a self,
568        artifact_bytes: &'a [u8],
569    ) -> ScannResult<QuantizedBinaryScannModelView<'a>> {
570        let stored_id = artifact_bytes
571            .get(12..20)
572            .and_then(|bytes| <[u8; 8]>::try_from(bytes).ok())
573            .map(u64::from_le_bytes);
574        if artifact_bytes.len() != self.artifact_len || stored_id != Some(self.artifact_id) {
575            return Err(ScannFormatError::new(
576                "quantized binary ScaNN model was paired with a different artifact mapping",
577            ));
578        }
579        Ok(QuantizedBinaryScannModelView {
580            model: self,
581            artifact_bytes,
582        })
583    }
584
585    pub fn dim_bits(&self) -> u32 {
586        self.dim_bits
587    }
588
589    pub fn num_leaves(&self) -> u32 {
590        self.num_leaves
591    }
592
593    pub fn fingerprint(&self) -> u64 {
594        self.fingerprint
595    }
596
597    pub fn estimated_memory_bytes(&self) -> usize {
598        self.levels.iter().fold(0usize, |total, level| {
599            total.saturating_add(level.parent_offsets.len() * std::mem::size_of::<u32>())
600        })
601    }
602
603    fn byte_len(&self) -> usize {
604        self.dim_bits as usize / 8
605    }
606
607    fn validate_metadata(&self) -> ScannResult<()> {
608        if self.dim_bits == 0
609            || !self.dim_bits.is_multiple_of(8)
610            || self.levels.is_empty()
611            || self.levels.len() > usize::from(MAX_SCANN_TREE_LEVELS)
612            || self.levels.last().map(|level| level.centroid_count)
613                != Some(self.num_leaves as usize)
614        {
615            return Err(ScannFormatError::new(
616                "invalid quantized binary ScaNN model header",
617            ));
618        }
619        let mut parents = 1usize;
620        for level in &self.levels {
621            if level.centroid_codes.end > self.artifact_len
622                || level.centroid_codes.len()
623                    != level.centroid_count.saturating_mul(self.byte_len())
624                || level.parent_offsets.len() != parents + 1
625                || level.parent_offsets.first() != Some(&0)
626                || level.parent_offsets.last().copied() != Some(level.centroid_count as u32)
627                || level
628                    .parent_offsets
629                    .windows(2)
630                    .any(|pair| pair[0] > pair[1])
631            {
632                return Err(ScannFormatError::new(
633                    "invalid quantized binary ScaNN routing level",
634                ));
635            }
636            parents = level.centroid_count;
637        }
638        Ok(())
639    }
640
641    fn compute_fingerprint(&self, artifact_bytes: &[u8]) -> u64 {
642        let mut hash = Fingerprint::new();
643        hash.write(&self.dim_bits.to_le_bytes());
644        hash.write(&self.num_leaves.to_le_bytes());
645        hash.write(&(self.levels.len() as u32).to_le_bytes());
646        for level in &self.levels {
647            for offset in &level.parent_offsets {
648                hash.write(&offset.to_le_bytes());
649            }
650            hash.write(&artifact_bytes[level.centroid_codes.clone()]);
651        }
652        hash.finish()
653    }
654}
655
656impl QuantizedBinaryScannModelView<'_> {
657    pub fn dim_bits(&self) -> u32 {
658        self.model.dim_bits
659    }
660
661    pub fn num_leaves(&self) -> u32 {
662        self.model.num_leaves
663    }
664
665    pub fn fingerprint(&self) -> u64 {
666        self.model.fingerprint
667    }
668
669    pub fn probe(
670        &self,
671        query: &[u8],
672        nprobe: usize,
673        beam_width: usize,
674        scratch: &mut BinaryScannSearchScratch,
675    ) -> ScannResult<BinaryScannProbePlan> {
676        if query.len() != self.model.byte_len() {
677            return Err(ScannFormatError::new(
678                "binary ScaNN query dimension does not match the model",
679            ));
680        }
681        if nprobe == 0 || beam_width == 0 {
682            return Err(ScannFormatError::new(
683                "binary ScaNN nprobe and beam width must be positive",
684            ));
685        }
686        let mut views = [BinaryLevelView::EMPTY; MAX_SCANN_TREE_LEVELS as usize];
687        for (view, level) in views.iter_mut().zip(&self.model.levels) {
688            *view = BinaryLevelView {
689                centroids: &self.artifact_bytes[level.centroid_codes.clone()],
690                parent_offsets: &level.parent_offsets,
691            };
692        }
693        let leaf_ids = probe_binary_tree(
694            &views[..self.model.levels.len()],
695            self.model.byte_len(),
696            self.model.num_leaves as usize,
697            query,
698            nprobe,
699            beam_width,
700            scratch,
701        )?;
702        Ok(BinaryScannProbePlan {
703            model_fingerprint: self.model.fingerprint,
704            leaf_ids,
705        })
706    }
707
708    pub fn assign(&self, code: &[u8], scratch: &mut BinaryScannSearchScratch) -> ScannResult<u32> {
709        self.probe(code, 1, 1, scratch)?
710            .leaf_ids
711            .first()
712            .copied()
713            .ok_or_else(|| ScannFormatError::new("binary ScaNN assignment returned no leaf"))
714    }
715
716    /// Mmap-backed equivalent of [`BinaryScannModel::spill_assignment`]. The
717    /// centroid plane remains borrowed from the artifact mapping.
718    pub fn spill_assignment(
719        &self,
720        code: &[u8],
721        scratch: &mut BinaryScannSearchScratch,
722    ) -> ScannResult<BinaryScannSpillAssignment> {
723        let primary_leaf = self.assign(code, scratch)?;
724        let candidate_count = BINARY_SPILL_ASSIGNMENT_CANDIDATES
725            .min(self.model.num_leaves as usize)
726            .max(1);
727        let plan = self.probe(code, candidate_count, candidate_count, scratch)?;
728        let kernel = HammingKernel::resolve();
729        let terminal = self
730            .model
731            .levels
732            .last()
733            .expect("validated binary ScaNN model has a terminal level");
734        let leaf_centroids = &self.artifact_bytes[terminal.centroid_codes.clone()];
735        let centroid = |leaf_id: u32| {
736            let start = leaf_id as usize * self.model.byte_len();
737            &leaf_centroids[start..start + self.model.byte_len()]
738        };
739        let primary_distance = kernel.distance(code, centroid(primary_leaf));
740        let secondary_leaf = plan
741            .leaf_ids
742            .into_iter()
743            .filter(|&leaf_id| leaf_id != primary_leaf)
744            .map(|leaf_id| (kernel.distance(code, centroid(leaf_id)), leaf_id))
745            .min()
746            .map(|(_, leaf_id)| leaf_id);
747        Ok(BinaryScannSpillAssignment {
748            primary_leaf,
749            secondary_leaf,
750            primary_distance,
751        })
752    }
753}
754
755#[derive(Clone, Debug, Eq, PartialEq)]
756pub struct BinaryScannProbePlan {
757    pub model_fingerprint: u64,
758    pub leaf_ids: Vec<u32>,
759}
760
761/// One routing level as borrowed slices, so the owned and mmap-backed models
762/// share a single beam-search implementation.
763#[derive(Clone, Copy)]
764struct BinaryLevelView<'a> {
765    centroids: &'a [u8],
766    parent_offsets: &'a [u32],
767}
768
769impl BinaryLevelView<'_> {
770    const EMPTY: Self = Self {
771        centroids: &[],
772        parent_offsets: &[],
773    };
774}
775
776/// Hierarchical Hamming beam search over `levels`, returning the selected
777/// terminal leaves in ascending `(distance, node)` order.
778///
779/// Each level keeps only the frontier it needs: a `select_nth_unstable`
780/// partition followed by sorting the kept prefix, instead of a full sort of
781/// every scored child. Intermediate levels start from `beam_width` ranked
782/// nodes and double the ranked prefix only while their children cannot yet
783/// cover `nprobe` leaves, which is the same widening rule the full sort fed
784/// into `routing_prefix_for_child_coverage`.
785fn probe_binary_tree(
786    levels: &[BinaryLevelView<'_>],
787    byte_len: usize,
788    num_leaves: usize,
789    query: &[u8],
790    nprobe: usize,
791    beam_width: usize,
792    scratch: &mut BinaryScannSearchScratch,
793) -> ScannResult<Vec<u32>> {
794    let kernel = HammingKernel::resolve();
795    scratch.frontier.clear();
796    scratch.frontier.push(0);
797    let mut leaf_ids = Vec::new();
798    for (level_index, level) in levels.iter().enumerate() {
799        scratch.candidates.clear();
800        for &parent in &scratch.frontier {
801            let parent = parent as usize;
802            let start = level.parent_offsets[parent] as usize;
803            let end = level.parent_offsets[parent + 1] as usize;
804            let rows = end - start;
805            scratch.distances.clear();
806            scratch.distances.resize(rows, 0);
807            kernel.distances(
808                query,
809                &level.centroids[start * byte_len..end * byte_len],
810                byte_len,
811                &mut scratch.distances,
812            );
813            scratch
814                .candidates
815                .extend(
816                    scratch
817                        .distances
818                        .iter()
819                        .enumerate()
820                        .map(|(local, &distance)| RouteCandidate {
821                            node: (start + local) as u32,
822                            distance,
823                        }),
824                );
825        }
826        let is_leaf_level = level_index + 1 == levels.len();
827        let width = if is_leaf_level {
828            let width = nprobe.min(num_leaves).min(scratch.candidates.len());
829            rank_best_candidates(&mut scratch.candidates, width);
830            width
831        } else {
832            let child_offsets = levels[level_index + 1].parent_offsets;
833            select_intermediate_frontier(&mut scratch.candidates, child_offsets, beam_width, nprobe)
834        };
835        if width == 0 {
836            return Err(ScannFormatError::new(
837                "binary ScaNN routing reached an empty branch",
838            ));
839        }
840        let selected = scratch.candidates[..width]
841            .iter()
842            .map(|candidate| candidate.node);
843        if is_leaf_level {
844            leaf_ids.reserve_exact(width);
845            leaf_ids.extend(selected);
846        } else {
847            scratch.frontier.clear();
848            scratch.frontier.extend(selected);
849        }
850    }
851    Ok(leaf_ids)
852}
853
854/// Move the `width` best candidates to the front, sorted; the rejected tail
855/// stays unsorted.
856fn rank_best_candidates(candidates: &mut [RouteCandidate], width: usize) {
857    if width < candidates.len() {
858        candidates.select_nth_unstable(width);
859    }
860    candidates[..width].sort_unstable();
861}
862
863/// Rank an intermediate level lazily: start with the recall beam and double
864/// the ranked prefix until its children can cover `nprobe` leaves (or every
865/// candidate is ranked). Returns the frontier width within the ranked prefix.
866fn select_intermediate_frontier(
867    candidates: &mut [RouteCandidate],
868    child_offsets: &[u32],
869    beam_width: usize,
870    nprobe: usize,
871) -> usize {
872    let total = candidates.len();
873    if total == 0 {
874        return 0;
875    }
876    let mut ranked = beam_width.clamp(1, total);
877    loop {
878        rank_best_candidates(candidates, ranked);
879        let width = super::routing_prefix_for_child_coverage(
880            &candidates[..ranked],
881            child_offsets,
882            beam_width,
883            nprobe,
884            |candidate| candidate.node as usize,
885        );
886        let covered = candidates[..width].iter().fold(0usize, |total, candidate| {
887            let node = candidate.node as usize;
888            total.saturating_add((child_offsets[node + 1] - child_offsets[node]) as usize)
889        });
890        if covered >= nprobe || ranked == total {
891            return width;
892        }
893        ranked = ranked.saturating_mul(2).min(total);
894    }
895}
896
897#[derive(Clone, Copy, Debug, Eq, PartialEq)]
898struct RouteCandidate {
899    node: u32,
900    distance: u32,
901}
902
903impl Ord for RouteCandidate {
904    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
905        self.distance
906            .cmp(&other.distance)
907            .then_with(|| self.node.cmp(&other.node))
908    }
909}
910
911impl PartialOrd for RouteCandidate {
912    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
913        Some(self.cmp(other))
914    }
915}
916
917/// Per-query allocations retained by the caller and reused across segments.
918#[derive(Default, Debug)]
919pub struct BinaryScannSearchScratch {
920    frontier: Vec<u32>,
921    candidates: Vec<RouteCandidate>,
922    distances: Vec<u32>,
923    /// Logical vector IDs currently represented in the top-k heap. Tracking
924    /// only retained hits keeps secondary-posting deduplication bounded by k,
925    /// rather than by the number of postings scanned.
926    best_hit_keys: rustc_hash::FxHashSet<(u32, u16)>,
927}
928
929/// Deterministic packed-Hamming primary and optional secondary candidate.
930/// Policy code decides whether to retain the secondary under its storage cap.
931#[derive(Clone, Copy, Debug, Eq, PartialEq)]
932pub struct BinaryScannSpillAssignment {
933    pub primary_leaf: u32,
934    pub secondary_leaf: Option<u32>,
935    pub primary_distance: u32,
936}
937
938#[derive(Clone, Debug)]
939struct BinaryScannLeaf {
940    leaf_id: u32,
941    doc_ids: Vec<u32>,
942    ordinals: Vec<u16>,
943    codes: Vec<u8>,
944}
945
946fn push_binary_posting(
947    grouped: &mut rustc_hash::FxHashMap<u32, BinaryScannLeaf>,
948    leaf_id: u32,
949    doc_id: u32,
950    ordinal: u16,
951    code: &[u8],
952) {
953    let leaf = grouped.entry(leaf_id).or_insert_with(|| BinaryScannLeaf {
954        leaf_id,
955        doc_ids: Vec::new(),
956        ordinals: Vec::new(),
957        codes: Vec::new(),
958    });
959    leaf.doc_ids.push(doc_id);
960    leaf.ordinals.push(ordinal);
961    leaf.codes.extend_from_slice(code);
962}
963
964/// Immutable segment-local exact binary payload.
965#[derive(Clone, Debug)]
966pub struct BinaryScannSegment {
967    dim_bits: u32,
968    model_fingerprint: u64,
969    num_leaves: u32,
970    leaves: Vec<BinaryScannLeaf>,
971    /// Logical vectors before optional secondary posting expansion.
972    len: usize,
973    /// Physical postings, bounded to at most two per logical vector.
974    stored_len: usize,
975}
976
977impl BinaryScannSegment {
978    pub fn build(
979        model: &BinaryScannModel,
980        codes: &[u8],
981        doc_id_ordinals: &[(u32, u16)],
982        scratch: &mut BinaryScannSearchScratch,
983    ) -> ScannResult<Self> {
984        Self::build_internal(model, codes, doc_id_ordinals, None, scratch)
985    }
986
987    /// Build with deterministic one-secondary binary spilling.
988    ///
989    /// A negative `spill_threshold` keeps `SoarConfig`'s target-fraction tag:
990    /// the most poorly represented primary assignments are retained up to a
991    /// strict segment-local storage budget. Explicit non-negative thresholds
992    /// use the same primary residual rule as float SOAR, with squared L2 over
993    /// bits represented exactly by Hamming distance.
994    pub fn build_with_soar(
995        model: &BinaryScannModel,
996        codes: &[u8],
997        doc_id_ordinals: &[(u32, u16)],
998        soar: &SoarConfig,
999        scratch: &mut BinaryScannSearchScratch,
1000    ) -> ScannResult<Self> {
1001        Self::build_internal(model, codes, doc_id_ordinals, Some(soar), scratch)
1002    }
1003
1004    fn build_internal(
1005        model: &BinaryScannModel,
1006        codes: &[u8],
1007        doc_id_ordinals: &[(u32, u16)],
1008        soar: Option<&SoarConfig>,
1009        scratch: &mut BinaryScannSearchScratch,
1010    ) -> ScannResult<Self> {
1011        let expected = doc_id_ordinals
1012            .len()
1013            .checked_mul(model.byte_len())
1014            .ok_or_else(|| ScannFormatError::new("binary ScaNN segment size overflows"))?;
1015        if codes.len() != expected {
1016            return Err(ScannFormatError::new(
1017                "binary ScaNN segment code and label columns are inconsistent",
1018            ));
1019        }
1020        if soar.is_some_and(|config| !config.spill_threshold.is_finite()) {
1021            return Err(ScannFormatError::new(
1022                "binary ScaNN spill threshold must be finite",
1023            ));
1024        }
1025
1026        let spill_enabled =
1027            soar.is_some_and(|config| config.num_secondary > 0) && model.num_leaves > 1;
1028        if !spill_enabled {
1029            // Preserve the allocation profile of the established primary-only
1030            // builder. Spill ranking state is paid only when spilling is
1031            // explicitly enabled for this segment.
1032            let mut grouped = rustc_hash::FxHashMap::<u32, BinaryScannLeaf>::default();
1033            for (&(doc_id, ordinal), code) in doc_id_ordinals
1034                .iter()
1035                .zip(codes.chunks_exact(model.byte_len()))
1036            {
1037                let primary_leaf = model.assign(code, scratch)?;
1038                push_binary_posting(&mut grouped, primary_leaf, doc_id, ordinal, code);
1039            }
1040            let mut leaves: Vec<_> = grouped.into_values().collect();
1041            leaves.sort_unstable_by_key(|leaf| leaf.leaf_id);
1042            let segment = Self {
1043                dim_bits: model.dim_bits,
1044                model_fingerprint: model.fingerprint,
1045                num_leaves: model.num_leaves,
1046                leaves,
1047                len: doc_id_ordinals.len(),
1048                stored_len: doc_id_ordinals.len(),
1049            };
1050            segment.validate_for(model)?;
1051            return Ok(segment);
1052        }
1053
1054        let mut assignments = Vec::with_capacity(doc_id_ordinals.len());
1055        for code in codes.chunks_exact(model.byte_len()) {
1056            assignments.push(model.spill_assignment(code, scratch)?);
1057        }
1058
1059        if let Some(config) = soar {
1060            if let Some(target_fraction) = config.calibration_target() {
1061                // Floor, rather than round, makes the target a strict storage
1062                // ceiling even for tiny streaming segments.
1063                let spill_budget = ((assignments.len() as f64 * f64::from(target_fraction)).floor()
1064                    as usize)
1065                    .min(assignments.len());
1066                let mut ranked: Vec<usize> = assignments
1067                    .iter()
1068                    .enumerate()
1069                    .filter_map(|(row, assignment)| assignment.secondary_leaf.map(|_| row))
1070                    .collect();
1071                ranked.sort_unstable_by(|&left, &right| {
1072                    assignments[right]
1073                        .primary_distance
1074                        .cmp(&assignments[left].primary_distance)
1075                        .then_with(|| left.cmp(&right))
1076                });
1077                for &row in ranked.iter().skip(spill_budget) {
1078                    assignments[row].secondary_leaf = None;
1079                }
1080            } else if config.selective {
1081                let threshold_sq = f64::from(config.spill_threshold).powi(2);
1082                for assignment in &mut assignments {
1083                    if f64::from(assignment.primary_distance) < threshold_sq {
1084                        assignment.secondary_leaf = None;
1085                    }
1086                }
1087            }
1088        }
1089
1090        let mut grouped = rustc_hash::FxHashMap::<u32, BinaryScannLeaf>::default();
1091        for ((&(doc_id, ordinal), code), assignment) in doc_id_ordinals
1092            .iter()
1093            .zip(codes.chunks_exact(model.byte_len()))
1094            .zip(assignments)
1095        {
1096            push_binary_posting(&mut grouped, assignment.primary_leaf, doc_id, ordinal, code);
1097            if let Some(secondary_leaf) = assignment.secondary_leaf {
1098                push_binary_posting(&mut grouped, secondary_leaf, doc_id, ordinal, code);
1099            }
1100        }
1101        let mut leaves: Vec<_> = grouped.into_values().collect();
1102        leaves.sort_unstable_by_key(|leaf| leaf.leaf_id);
1103        let stored_len = leaves.iter().map(|leaf| leaf.doc_ids.len()).sum();
1104        let segment = Self {
1105            dim_bits: model.dim_bits,
1106            model_fingerprint: model.fingerprint,
1107            num_leaves: model.num_leaves,
1108            leaves,
1109            len: doc_id_ordinals.len(),
1110            stored_len,
1111        };
1112        segment.validate_for(model)?;
1113        Ok(segment)
1114    }
1115
1116    /// Leaf-wise compatible merge. Codes are copied verbatim and no routing or
1117    /// training runs; only segment-local document IDs are rebased.
1118    pub fn merge_compatible(
1119        model: &BinaryScannModel,
1120        segments: &[(&Self, u32)],
1121    ) -> ScannResult<Self> {
1122        for &(segment, _) in segments {
1123            segment.validate_for(model)?;
1124        }
1125        let mut cursors = vec![0usize; segments.len()];
1126        let mut queue = BinaryHeap::new();
1127        for (segment_index, (segment, _)) in segments.iter().enumerate() {
1128            if let Some(first) = segment.leaves.first() {
1129                queue.push(Reverse((first.leaf_id, segment_index)));
1130            }
1131        }
1132        let mut leaves: Vec<BinaryScannLeaf> = Vec::new();
1133        let len = segments.iter().try_fold(0usize, |total, (segment, _)| {
1134            total
1135                .checked_add(segment.len)
1136                .ok_or_else(|| ScannFormatError::new("binary ScaNN merge count overflows"))
1137        })?;
1138        let mut stored_len = 0usize;
1139        while let Some(Reverse((leaf_id, segment_index))) = queue.pop() {
1140            let (segment, doc_base) = segments[segment_index];
1141            let source = &segment.leaves[cursors[segment_index]];
1142            if leaves.last().is_none_or(|leaf| leaf.leaf_id != leaf_id) {
1143                leaves.push(BinaryScannLeaf {
1144                    leaf_id,
1145                    doc_ids: Vec::new(),
1146                    ordinals: Vec::new(),
1147                    codes: Vec::new(),
1148                });
1149            }
1150            let target = leaves.last_mut().expect("leaf was just inserted");
1151            target.doc_ids.reserve(source.doc_ids.len());
1152            for &doc_id in &source.doc_ids {
1153                target
1154                    .doc_ids
1155                    .push(doc_id.checked_add(doc_base).ok_or_else(|| {
1156                        ScannFormatError::new("binary ScaNN merge document ID overflows u32")
1157                    })?);
1158            }
1159            target.ordinals.extend_from_slice(&source.ordinals);
1160            target.codes.extend_from_slice(&source.codes);
1161            stored_len = stored_len
1162                .checked_add(source.doc_ids.len())
1163                .ok_or_else(|| ScannFormatError::new("binary ScaNN merge count overflows"))?;
1164            cursors[segment_index] += 1;
1165            if let Some(next) = segment.leaves.get(cursors[segment_index]) {
1166                queue.push(Reverse((next.leaf_id, segment_index)));
1167            }
1168        }
1169        let merged = Self {
1170            dim_bits: model.dim_bits,
1171            model_fingerprint: model.fingerprint,
1172            num_leaves: model.num_leaves,
1173            leaves,
1174            len,
1175            stored_len,
1176        };
1177        merged.validate_for(model)?;
1178        Ok(merged)
1179    }
1180
1181    pub fn len(&self) -> usize {
1182        self.len
1183    }
1184
1185    pub fn is_empty(&self) -> bool {
1186        self.len == 0
1187    }
1188
1189    /// Number of physical leaf postings after secondary spill expansion.
1190    pub fn stored_len(&self) -> usize {
1191        self.stored_len
1192    }
1193
1194    pub fn to_payload(
1195        &self,
1196        model: &BinaryScannModel,
1197        artifact: &ScannTrainedArtifact,
1198        doc_count: u32,
1199    ) -> ScannResult<ScannSegmentPayload> {
1200        self.validate_for(model)?;
1201        let reopened = BinaryScannModel::from_artifact(artifact)?;
1202        if reopened.fingerprint != model.fingerprint {
1203            return Err(ScannFormatError::new(
1204                "binary ScaNN segment model does not match the persisted artifact",
1205            ));
1206        }
1207        let mut runs = Vec::with_capacity(self.leaves.len());
1208        for leaf in &self.leaves {
1209            runs.push(ScannLeafRun::from_rows(
1210                leaf.leaf_id,
1211                0,
1212                &leaf.doc_ids,
1213                &leaf.ordinals,
1214                leaf.codes.clone(),
1215                ScannEncoding::BinaryHamming,
1216                self.dim_bits,
1217            )?);
1218        }
1219        ScannSegmentPayload::new(artifact, doc_count, runs)
1220    }
1221
1222    fn validate_for(&self, model: &BinaryScannModel) -> ScannResult<()> {
1223        if self.dim_bits != model.dim_bits
1224            || self.model_fingerprint != model.fingerprint
1225            || self.num_leaves != model.num_leaves
1226        {
1227            return Err(ScannFormatError::new(
1228                "binary ScaNN segment belongs to a different trained generation",
1229            ));
1230        }
1231        let byte_len = model.byte_len();
1232        let mut previous = None;
1233        let mut total = 0usize;
1234        for leaf in &self.leaves {
1235            if leaf.leaf_id >= self.num_leaves
1236                || previous.is_some_and(|previous| previous >= leaf.leaf_id)
1237                || leaf.doc_ids.len() != leaf.ordinals.len()
1238                || leaf.codes.len() != leaf.doc_ids.len().saturating_mul(byte_len)
1239            {
1240                return Err(ScannFormatError::new(
1241                    "binary ScaNN leaf directory or columns are inconsistent",
1242                ));
1243            }
1244            previous = Some(leaf.leaf_id);
1245            total = total
1246                .checked_add(leaf.doc_ids.len())
1247                .ok_or_else(|| ScannFormatError::new("binary ScaNN segment count overflows"))?;
1248        }
1249        let maximum_stored = self
1250            .len
1251            .checked_mul(2)
1252            .ok_or_else(|| ScannFormatError::new("binary ScaNN segment count overflows"))?;
1253        if total != self.stored_len
1254            || self.stored_len < self.len
1255            || self.stored_len > maximum_stored
1256        {
1257            return Err(ScannFormatError::new(
1258                "binary ScaNN segment vector count is inconsistent",
1259            ));
1260        }
1261        Ok(())
1262    }
1263
1264    fn scan(
1265        &self,
1266        query: &[u8],
1267        plan: &BinaryScannProbePlan,
1268        doc_base: u32,
1269        k: usize,
1270        best: &mut BinaryHeap<BinaryScannHit>,
1271        scratch: &mut BinaryScannSearchScratch,
1272    ) -> ScannResult<()> {
1273        if plan.model_fingerprint != self.model_fingerprint {
1274            return Err(ScannFormatError::new(
1275                "binary ScaNN probe plan belongs to a different trained generation",
1276            ));
1277        }
1278        let byte_len = self.dim_bits as usize / 8;
1279        let kernel = HammingKernel::resolve();
1280        for &leaf_id in &plan.leaf_ids {
1281            let Ok(position) = self
1282                .leaves
1283                .binary_search_by_key(&leaf_id, |leaf| leaf.leaf_id)
1284            else {
1285                continue;
1286            };
1287            let leaf = &self.leaves[position];
1288            for start in (0..leaf.doc_ids.len()).step_by(HAMMING_SCAN_BLOCK) {
1289                let rows = HAMMING_SCAN_BLOCK.min(leaf.doc_ids.len() - start);
1290                scratch.distances.clear();
1291                scratch.distances.resize(rows, 0);
1292                kernel.distances(
1293                    query,
1294                    &leaf.codes[start * byte_len..(start + rows) * byte_len],
1295                    byte_len,
1296                    &mut scratch.distances,
1297                );
1298                for (local, &distance) in scratch.distances.iter().enumerate() {
1299                    if k == 0 {
1300                        continue;
1301                    }
1302                    let index = start + local;
1303                    let doc_id = leaf.doc_ids[index].checked_add(doc_base).ok_or_else(|| {
1304                        ScannFormatError::new("binary ScaNN query document ID overflows u32")
1305                    })?;
1306                    let ordinal = leaf.ordinals[index];
1307                    if scratch.best_hit_keys.contains(&(doc_id, ordinal)) {
1308                        continue;
1309                    }
1310                    let hit = BinaryScannHit {
1311                        doc_id,
1312                        ordinal,
1313                        distance,
1314                    };
1315                    if best.len() < k {
1316                        best.push(hit);
1317                        scratch.best_hit_keys.insert((doc_id, ordinal));
1318                    } else if best.peek().is_some_and(|worst| hit < *worst) {
1319                        let evicted = best.pop().expect("non-empty top-k heap has a worst hit");
1320                        scratch
1321                            .best_hit_keys
1322                            .remove(&(evicted.doc_id, evicted.ordinal));
1323                        best.push(hit);
1324                        scratch.best_hit_keys.insert((doc_id, ordinal));
1325                    }
1326                }
1327            }
1328        }
1329        Ok(())
1330    }
1331}
1332
1333/// Exact Hamming result. Lower distance wins; ties are stable by document and
1334/// ordinal so segment layout and merge order cannot change the answer.
1335#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1336pub struct BinaryScannHit {
1337    pub doc_id: u32,
1338    pub ordinal: u16,
1339    pub distance: u32,
1340}
1341
1342impl Ord for BinaryScannHit {
1343    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
1344        self.distance
1345            .cmp(&other.distance)
1346            .then_with(|| self.doc_id.cmp(&other.doc_id))
1347            .then_with(|| self.ordinal.cmp(&other.ordinal))
1348    }
1349}
1350
1351impl PartialOrd for BinaryScannHit {
1352    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
1353        Some(self.cmp(other))
1354    }
1355}
1356
1357struct BinaryPartition {
1358    centroids: Vec<u8>,
1359    groups: Vec<BinaryTrainingRows>,
1360}
1361
1362/// A partition is an ordered view into the caller-owned packed code matrix.
1363/// The initial complete sample is a range and costs no additional memory;
1364/// child partitions retain only row identifiers instead of cloning codes.
1365#[derive(Debug)]
1366enum BinaryTrainingRows {
1367    Contiguous(Range<usize>),
1368    Indexed(Vec<usize>),
1369}
1370
1371impl BinaryTrainingRows {
1372    fn len(&self) -> usize {
1373        match self {
1374            Self::Contiguous(range) => range.len(),
1375            Self::Indexed(indices) => indices.len(),
1376        }
1377    }
1378
1379    fn source_row(&self, position: usize) -> usize {
1380        match self {
1381            Self::Contiguous(range) => range.start + position,
1382            Self::Indexed(indices) => indices[position],
1383        }
1384    }
1385
1386    fn from_indices(indices: Vec<usize>) -> Self {
1387        let Some(&first) = indices.first() else {
1388            return Self::Indexed(indices);
1389        };
1390        if indices
1391            .iter()
1392            .enumerate()
1393            .all(|(offset, &row)| row == first + offset)
1394        {
1395            Self::Contiguous(first..first + indices.len())
1396        } else {
1397            Self::Indexed(indices)
1398        }
1399    }
1400}
1401
1402#[allow(clippy::too_many_arguments)]
1403fn train_binary_partition(
1404    source_codes: &[u8],
1405    rows: &BinaryTrainingRows,
1406    byte_len: usize,
1407    dim_bits: u32,
1408    clusters: usize,
1409    train_iters: usize,
1410    seed: u64,
1411    depth: usize,
1412    retain_groups: bool,
1413    index_label: &str,
1414    stats: &mut BinaryScannTrainingStats,
1415) -> ScannResult<BinaryPartition> {
1416    let row_count = rows.len();
1417    if row_count == 0 || byte_len == 0 || clusters == 0 || clusters > row_count {
1418        return Err(ScannFormatError::new(
1419            "invalid recursive binary ScaNN partition shape",
1420        ));
1421    }
1422    stats.max_depth = stats.max_depth.max(depth);
1423    if clusters == row_count {
1424        let groups = if retain_groups {
1425            let groups: Vec<BinaryTrainingRows> = (0..row_count)
1426                .map(|position| {
1427                    let row = rows.source_row(position);
1428                    BinaryTrainingRows::Contiguous(row..row + 1)
1429                })
1430                .collect();
1431            stats.retained_groups = stats.retained_groups.saturating_add(groups.len());
1432            groups
1433        } else {
1434            Vec::new()
1435        };
1436        return Ok(BinaryPartition {
1437            centroids: materialize_training_rows(source_codes, rows, byte_len)?,
1438            groups,
1439        });
1440    }
1441
1442    let branches = training_branch_factor(clusters).min(row_count);
1443    let mut config = BinaryIvfConfig::new(dim_bits as usize, branches);
1444    config.routing = IvfRoutingMode::Flat;
1445    config.train_iters = train_iters;
1446    config.max_train_samples = row_count;
1447    config.seed = seed;
1448    let local_centroids =
1449        train_binary_codebook_for_rows(&config, source_codes, rows, byte_len, index_label, stats)?;
1450    stats.splits = stats.splits.saturating_add(1);
1451    stats.max_split_clusters = stats.max_split_clusters.max(branches);
1452    if branches == clusters {
1453        let groups = if retain_groups {
1454            let groups =
1455                partition_one_group_nonempty(source_codes, rows, &local_centroids, byte_len);
1456            stats.retained_groups = stats.retained_groups.saturating_add(groups.len());
1457            groups
1458        } else {
1459            Vec::new()
1460        };
1461        return Ok(BinaryPartition {
1462            groups,
1463            centroids: local_centroids,
1464        });
1465    }
1466
1467    let local_groups = partition_one_group_nonempty(source_codes, rows, &local_centroids, byte_len);
1468    let sizes: Vec<usize> = local_groups.iter().map(BinaryTrainingRows::len).collect();
1469    let allocations = allocate_child_clusters(&sizes, clusters);
1470    if allocations.iter().sum::<usize>() != clusters || allocations.contains(&0) {
1471        return Err(ScannFormatError::new(
1472            "recursive binary ScaNN centroid allocation is inconsistent",
1473        ));
1474    }
1475    let mut centroids = Vec::with_capacity(clusters.saturating_mul(byte_len));
1476    let mut groups = Vec::with_capacity(clusters);
1477    for (branch, (group, &allocation)) in local_groups.iter().zip(&allocations).enumerate() {
1478        let child = train_binary_partition(
1479            source_codes,
1480            group,
1481            byte_len,
1482            dim_bits,
1483            allocation,
1484            train_iters,
1485            derived_seed(seed, depth, branch),
1486            depth + 1,
1487            retain_groups,
1488            index_label,
1489            stats,
1490        )?;
1491        centroids.extend_from_slice(&child.centroids);
1492        if retain_groups {
1493            groups.extend(child.groups);
1494        }
1495    }
1496    Ok(BinaryPartition { centroids, groups })
1497}
1498
1499fn training_branch_factor(clusters: usize) -> usize {
1500    if clusters <= MAX_LOCAL_K_MAJORITY_BRANCHES {
1501        clusters
1502    } else {
1503        ((clusters as f64).sqrt().ceil() as usize).clamp(2, MAX_LOCAL_K_MAJORITY_BRANCHES)
1504    }
1505}
1506
1507fn deterministic_sample_rows(
1508    num_vectors: usize,
1509    sample_count: usize,
1510    seed: u64,
1511) -> BinaryTrainingRows {
1512    if sample_count >= num_vectors {
1513        return BinaryTrainingRows::Contiguous(0..num_vectors);
1514    }
1515    let mut rng = rand::rngs::StdRng::seed_from_u64(seed);
1516    let mut indices = rand::seq::index::sample(&mut rng, num_vectors, sample_count).into_vec();
1517    indices.sort_unstable();
1518    BinaryTrainingRows::from_indices(indices)
1519}
1520
1521fn materialize_training_rows(
1522    source_codes: &[u8],
1523    rows: &BinaryTrainingRows,
1524    byte_len: usize,
1525) -> ScannResult<Vec<u8>> {
1526    let capacity = rows
1527        .len()
1528        .checked_mul(byte_len)
1529        .ok_or_else(|| ScannFormatError::new("binary ScaNN group matrix overflows"))?;
1530    let mut packed = Vec::with_capacity(capacity);
1531    for position in 0..rows.len() {
1532        let row = rows.source_row(position);
1533        let start = row
1534            .checked_mul(byte_len)
1535            .ok_or_else(|| ScannFormatError::new("binary ScaNN source row overflows"))?;
1536        let code = source_codes
1537            .get(start..start + byte_len)
1538            .ok_or_else(|| ScannFormatError::new("binary ScaNN source row is truncated"))?;
1539        packed.extend_from_slice(code);
1540    }
1541    Ok(packed)
1542}
1543
1544fn train_binary_codebook_for_rows(
1545    config: &BinaryIvfConfig,
1546    source_codes: &[u8],
1547    rows: &BinaryTrainingRows,
1548    byte_len: usize,
1549    index_label: &str,
1550    stats: &mut BinaryScannTrainingStats,
1551) -> ScannResult<Vec<u8>> {
1552    match rows {
1553        BinaryTrainingRows::Contiguous(range) => {
1554            let start = range
1555                .start
1556                .checked_mul(byte_len)
1557                .ok_or_else(|| ScannFormatError::new("binary ScaNN group offset overflows"))?;
1558            let end = range
1559                .end
1560                .checked_mul(byte_len)
1561                .ok_or_else(|| ScannFormatError::new("binary ScaNN group offset overflows"))?;
1562            let codes = source_codes
1563                .get(start..end)
1564                .ok_or_else(|| ScannFormatError::new("binary ScaNN group is truncated"))?;
1565            train_binary_k_majority_codebook(config, codes, rows.len(), index_label)
1566                .map_err(|error| ScannFormatError::new(error.to_string()))
1567        }
1568        BinaryTrainingRows::Indexed(_) => {
1569            let packed = materialize_training_rows(source_codes, rows, byte_len)?;
1570            stats.max_materialized_training_bytes =
1571                stats.max_materialized_training_bytes.max(packed.len());
1572            train_binary_k_majority_codebook(config, &packed, rows.len(), index_label)
1573                .map_err(|error| ScannFormatError::new(error.to_string()))
1574        }
1575    }
1576}
1577
1578fn derived_seed(seed: u64, level: usize, parent: usize) -> u64 {
1579    seed ^ (level as u64).wrapping_mul(0xd6e8_feb8_6659_fd93)
1580        ^ (parent as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15)
1581}
1582
1583fn partition_one_group_nonempty(
1584    source_codes: &[u8],
1585    rows: &BinaryTrainingRows,
1586    centroids: &[u8],
1587    byte_len: usize,
1588) -> Vec<BinaryTrainingRows> {
1589    let kernel = HammingKernel::resolve();
1590    let child_count = centroids.len() / byte_len;
1591    let row_count = rows.len();
1592    let mut assignments = vec![0usize; row_count];
1593    let mut assignment_distances = vec![0u32; row_count];
1594    let mut counts = vec![0usize; child_count];
1595    let mut distances = vec![0u32; child_count];
1596    for position in 0..row_count {
1597        let source_row = rows.source_row(position);
1598        let offset = source_row * byte_len;
1599        let code = &source_codes[offset..offset + byte_len];
1600        kernel.distances(code, centroids, byte_len, &mut distances);
1601        let (child, &distance) = distances
1602            .iter()
1603            .enumerate()
1604            .min_by_key(|&(child, distance)| (*distance, child))
1605            .expect("a populated routing parent has children");
1606        assignments[position] = child;
1607        assignment_distances[position] = distance;
1608        counts[child] += 1;
1609    }
1610    for empty in 0..child_count {
1611        if counts[empty] != 0 {
1612            continue;
1613        }
1614        let replacement = (0..row_count)
1615            .filter(|&row| counts[assignments[row]] > 1)
1616            .max_by_key(|&row| (assignment_distances[row], Reverse(row)))
1617            .expect("training readiness guarantees one sample per centroid");
1618        counts[assignments[replacement]] -= 1;
1619        assignments[replacement] = empty;
1620        assignment_distances[replacement] = 0;
1621        counts[empty] = 1;
1622    }
1623    let mut groups: Vec<Vec<usize>> = counts
1624        .iter()
1625        .map(|&count| Vec::with_capacity(count))
1626        .collect();
1627    for (position, &child) in assignments.iter().enumerate() {
1628        groups[child].push(rows.source_row(position));
1629    }
1630    groups
1631        .into_iter()
1632        .map(BinaryTrainingRows::from_indices)
1633        .collect()
1634}
1635
1636struct Fingerprint(u64);
1637
1638impl Fingerprint {
1639    fn new() -> Self {
1640        Self(0xcbf2_9ce4_8422_2325)
1641    }
1642
1643    fn write(&mut self, bytes: &[u8]) {
1644        for &byte in bytes {
1645            self.0 ^= u64::from(byte);
1646            self.0 = self.0.wrapping_mul(0x0000_0100_0000_01b3);
1647        }
1648    }
1649
1650    fn finish(self) -> u64 {
1651        self.0
1652    }
1653}
1654
1655#[cfg(test)]
1656mod tests {
1657    use super::*;
1658    use crate::structures::simd::hamming_distance;
1659
1660    fn corpus(rows: usize) -> Vec<u8> {
1661        let anchors = [
1662            [0x00, 0x00],
1663            [0xff, 0xff],
1664            [0x0f, 0x0f],
1665            [0xf0, 0xf0],
1666            [0xaa, 0xaa],
1667            [0x55, 0x55],
1668            [0x33, 0xcc],
1669            [0xcc, 0x33],
1670        ];
1671        let mut codes = Vec::with_capacity(rows * 2);
1672        for row in 0..rows {
1673            let mut code = anchors[row % anchors.len()];
1674            code[(row / anchors.len()) % 2] ^= 1 << ((row / 16) % 8);
1675            codes.extend_from_slice(&code);
1676        }
1677        codes
1678    }
1679
1680    fn training() -> BinaryScannTraining {
1681        BinaryScannTraining {
1682            dim_bits: 16,
1683            geometry: ScannGeometry {
1684                centroid_levels: 3,
1685                num_leaves: 8,
1686                level_counts: vec![2, 4, 8],
1687            },
1688            train_iters: 2,
1689            seed: 73,
1690        }
1691    }
1692
1693    fn two_leaf_model() -> BinaryScannModel {
1694        let mut model = BinaryScannModel {
1695            dim_bits: 8,
1696            num_leaves: 2,
1697            levels: vec![BinaryRoutingLevel {
1698                centroids: vec![0x00, 0xff],
1699                parent_offsets: vec![0, 2],
1700            }],
1701            fingerprint: 0,
1702        };
1703        model.fingerprint = model.compute_fingerprint();
1704        model.validate().unwrap();
1705        model
1706    }
1707
1708    #[test]
1709    fn readiness_is_derived_from_geometry_not_user_configuration() {
1710        let training = training();
1711        assert_eq!(
1712            training.training_state(99_999).unwrap(),
1713            ScannTrainingState::AwaitingData {
1714                observed: 99_999,
1715                required: 100_000,
1716            }
1717        );
1718        assert_eq!(training.desired_training_vectors(500_000).unwrap(), 100_000);
1719    }
1720
1721    #[test]
1722    fn large_binary_partition_never_flat_trains_terminal_leaf_count() {
1723        let codes = corpus(100_000);
1724        let training = BinaryScannTraining {
1725            dim_bits: 16,
1726            geometry: ScannGeometry {
1727                centroid_levels: 1,
1728                num_leaves: 1_024,
1729                level_counts: vec![1_024],
1730            },
1731            train_iters: 1,
1732            seed: 91,
1733        };
1734        let (model, stats) =
1735            BinaryScannModel::train_with_stats(&training, &codes, 100_000, "test").unwrap();
1736        assert_eq!(model.num_leaves(), 1_024);
1737        assert!(stats.splits > 1);
1738        assert!(
1739            stats.max_split_clusters <= MAX_LOCAL_K_MAJORITY_BRANCHES,
1740            "binary local split widened to {} clusters",
1741            stats.max_split_clusters,
1742        );
1743        assert!(stats.max_split_clusters < 1_024);
1744        assert_eq!(
1745            stats.retained_groups, 0,
1746            "terminal training must not allocate one Vec per leaf",
1747        );
1748        assert!(
1749            stats.max_materialized_training_bytes < codes.len(),
1750            "binary training cloned the complete {}-byte retained sample",
1751            codes.len(),
1752        );
1753    }
1754
1755    #[test]
1756    fn full_probe_widens_past_the_legacy_sixty_four_parent_beam() {
1757        let root_count = 65usize;
1758        let leaf_count = root_count * root_count;
1759        let mut leaf_offsets = Vec::with_capacity(root_count + 1);
1760        for parent in 0..=root_count {
1761            leaf_offsets.push((parent * root_count) as u32);
1762        }
1763        let mut model = BinaryScannModel {
1764            dim_bits: 8,
1765            num_leaves: leaf_count as u32,
1766            levels: vec![
1767                BinaryRoutingLevel {
1768                    centroids: vec![0; root_count],
1769                    parent_offsets: vec![0, root_count as u32],
1770                },
1771                BinaryRoutingLevel {
1772                    centroids: vec![0; leaf_count],
1773                    parent_offsets: leaf_offsets,
1774                },
1775            ],
1776            fingerprint: 0,
1777        };
1778        model.fingerprint = model.compute_fingerprint();
1779        model.validate().unwrap();
1780
1781        let mut scratch = BinaryScannSearchScratch::default();
1782        let owned = model.probe(&[0], leaf_count, 64, &mut scratch).unwrap();
1783        assert_eq!(owned.leaf_ids.len(), leaf_count);
1784        assert_eq!(owned.leaf_ids, (0..leaf_count as u32).collect::<Vec<_>>());
1785
1786        let artifact = model.to_artifact(7, 100_000).unwrap();
1787        let bytes = artifact.to_bytes().unwrap();
1788        let artifact = ScannTrainedArtifactView::parse(&bytes).unwrap();
1789        let quantized = QuantizedBinaryScannModel::from_artifact_view(&artifact).unwrap();
1790        let view = quantized.view(&bytes).unwrap();
1791        let mapped = view.probe(&[0], leaf_count, 64, &mut scratch).unwrap();
1792        assert_eq!(mapped.leaf_ids, owned.leaf_ids);
1793    }
1794
1795    #[test]
1796    fn packed_hamming_search_is_exact_and_merge_independent() {
1797        let training_codes = corpus(100_000);
1798        let model = BinaryScannModel::train(&training(), &training_codes, 100_000, "test").unwrap();
1799        let rebuilt =
1800            BinaryScannModel::train(&training(), &training_codes, 100_000, "test").unwrap();
1801        assert_eq!(rebuilt.fingerprint(), model.fingerprint());
1802        let mut scratch = BinaryScannSearchScratch::default();
1803        let query = [0b1010_1011, 0b1010_1010];
1804        let first = model.probe(&query, 4, 2, &mut scratch).unwrap();
1805        let second = rebuilt.probe(&query, 4, 2, &mut scratch).unwrap();
1806        assert_eq!(first, second);
1807        let artifact = model.to_artifact(11, 100_000).unwrap();
1808        let artifact_bytes = artifact.to_bytes().unwrap();
1809        let artifact_view = ScannTrainedArtifactView::parse(&artifact_bytes).unwrap();
1810        let quantized = QuantizedBinaryScannModel::from_artifact_view(&artifact_view).unwrap();
1811        let quantized_view = quantized.view(&artifact_bytes).unwrap();
1812        let range_backed = quantized_view.probe(&query, 4, 2, &mut scratch).unwrap();
1813        assert_eq!(range_backed, first);
1814        assert_eq!(quantized.fingerprint(), model.fingerprint());
1815        assert!(quantized.estimated_memory_bytes() < artifact_bytes.len());
1816
1817        let codes = corpus(512);
1818        let labels: Vec<_> = (0..512).map(|doc_id| (doc_id, 0)).collect();
1819        let monolith = BinaryScannSegment::build(&model, &codes, &labels, &mut scratch).unwrap();
1820
1821        let split = 193;
1822        let left_labels: Vec<_> = (0..split as u32).map(|doc_id| (doc_id, 0)).collect();
1823        let right_labels: Vec<_> = (0..(512 - split) as u32)
1824            .map(|doc_id| (doc_id, 0))
1825            .collect();
1826        let left =
1827            BinaryScannSegment::build(&model, &codes[..split * 2], &left_labels, &mut scratch)
1828                .unwrap();
1829        let right =
1830            BinaryScannSegment::build(&model, &codes[split * 2..], &right_labels, &mut scratch)
1831                .unwrap();
1832        let merged =
1833            BinaryScannSegment::merge_compatible(&model, &[(&left, 0), (&right, split as u32)])
1834                .unwrap();
1835
1836        let expected = model
1837            .search_segments(
1838                &query,
1839                25,
1840                model.num_leaves() as usize,
1841                8,
1842                &[(&monolith, 0)],
1843                &mut scratch,
1844            )
1845            .unwrap();
1846        let split_hits = model
1847            .search_segments(
1848                &query,
1849                25,
1850                model.num_leaves() as usize,
1851                8,
1852                &[(&left, 0), (&right, split as u32)],
1853                &mut scratch,
1854            )
1855            .unwrap();
1856        let merged_hits = model
1857            .search_segments(
1858                &query,
1859                25,
1860                model.num_leaves() as usize,
1861                8,
1862                &[(&merged, 0)],
1863                &mut scratch,
1864            )
1865            .unwrap();
1866        assert_eq!(split_hits, expected);
1867        assert_eq!(merged_hits, expected);
1868
1869        let mut brute_force: Vec<_> = codes
1870            .chunks_exact(2)
1871            .enumerate()
1872            .map(|(doc_id, code)| BinaryScannHit {
1873                doc_id: doc_id as u32,
1874                ordinal: 0,
1875                distance: hamming_distance(&query, code),
1876            })
1877            .collect();
1878        brute_force.sort_unstable();
1879        brute_force.truncate(25);
1880        assert_eq!(expected, brute_force);
1881    }
1882
1883    #[test]
1884    fn selective_binary_spill_is_bounded_deterministic_and_deduplicated() {
1885        let model = two_leaf_model();
1886        let artifact = model.to_artifact(17, 100_000).unwrap();
1887        let codes = vec![0x00, 0x01, 0x03, 0x07, 0x0f, 0xff, 0xfe, 0xfc, 0xf8, 0xf0];
1888        let labels: Vec<_> = (0..codes.len() as u32).map(|doc_id| (doc_id, 0)).collect();
1889        let soar = SoarConfig::new().target_spill_fraction(0.30);
1890        let mut scratch = BinaryScannSearchScratch::default();
1891
1892        let artifact_bytes = artifact.to_bytes().unwrap();
1893        let artifact_view = ScannTrainedArtifactView::parse(&artifact_bytes).unwrap();
1894        let quantized = QuantizedBinaryScannModel::from_artifact_view(&artifact_view).unwrap();
1895        let quantized = quantized.view(&artifact_bytes).unwrap();
1896        for code in &codes {
1897            let code = std::slice::from_ref(code);
1898            assert_eq!(
1899                model.spill_assignment(code, &mut scratch).unwrap(),
1900                quantized.spill_assignment(code, &mut scratch).unwrap(),
1901            );
1902        }
1903
1904        let first =
1905            BinaryScannSegment::build_with_soar(&model, &codes, &labels, &soar, &mut scratch)
1906                .unwrap();
1907        let second =
1908            BinaryScannSegment::build_with_soar(&model, &codes, &labels, &soar, &mut scratch)
1909                .unwrap();
1910        assert_eq!(first.len(), codes.len());
1911        assert_eq!(first.stored_len(), codes.len() + 3);
1912
1913        let first_payload = first
1914            .to_payload(&model, &artifact, codes.len() as u32)
1915            .unwrap();
1916        let second_payload = second
1917            .to_payload(&model, &artifact, codes.len() as u32)
1918            .unwrap();
1919        assert_eq!(first_payload, second_payload);
1920        let encoded = first_payload.to_bytes().unwrap();
1921        let decoded = ScannSegmentPayload::from_bytes(&encoded).unwrap();
1922        assert_eq!(decoded, first_payload);
1923        decoded.validate_against(&artifact).unwrap();
1924
1925        let query = [0x0f];
1926        let hits = model
1927            .search_segments(&query, codes.len(), 2, 2, &[(&first, 0)], &mut scratch)
1928            .unwrap();
1929        assert_eq!(
1930            hits.len(),
1931            codes.len(),
1932            "secondary postings must not duplicate hits"
1933        );
1934        let mut expected: Vec<_> = codes
1935            .iter()
1936            .enumerate()
1937            .map(|(doc_id, code)| BinaryScannHit {
1938                doc_id: doc_id as u32,
1939                ordinal: 0,
1940                distance: hamming_distance(&query, std::slice::from_ref(code)),
1941            })
1942            .collect();
1943        expected.sort_unstable();
1944        assert_eq!(hits, expected);
1945
1946        let split = 5;
1947        let local_labels: Vec<_> = (0..split as u32).map(|doc_id| (doc_id, 0)).collect();
1948        let left = BinaryScannSegment::build_with_soar(
1949            &model,
1950            &codes[..split],
1951            &local_labels,
1952            &soar,
1953            &mut scratch,
1954        )
1955        .unwrap();
1956        let right = BinaryScannSegment::build_with_soar(
1957            &model,
1958            &codes[split..],
1959            &local_labels,
1960            &soar,
1961            &mut scratch,
1962        )
1963        .unwrap();
1964        let fingerprint = model.fingerprint();
1965        let merged =
1966            BinaryScannSegment::merge_compatible(&model, &[(&left, 0), (&right, split as u32)])
1967                .unwrap();
1968        assert_eq!(model.fingerprint(), fingerprint, "merge must not retrain");
1969        assert_eq!(merged.len(), codes.len());
1970        assert_eq!(merged.stored_len(), codes.len() + 2);
1971        let merged_hits = model
1972            .search_segments(&query, codes.len(), 2, 2, &[(&merged, 0)], &mut scratch)
1973            .unwrap();
1974        assert_eq!(merged_hits, expected);
1975
1976        // A boundary vector assigned primarily to leaf zero remains reachable
1977        // when a nearby query routes only to leaf one.
1978        let boundary_code = [0x0f];
1979        let boundary_label = [(42, 0)];
1980        let primary_only =
1981            BinaryScannSegment::build(&model, &boundary_code, &boundary_label, &mut scratch)
1982                .unwrap();
1983        let fully_spilled = BinaryScannSegment::build_with_soar(
1984            &model,
1985            &boundary_code,
1986            &boundary_label,
1987            &SoarConfig::full(),
1988            &mut scratch,
1989        )
1990        .unwrap();
1991        let nearby_query = [0x1f];
1992        assert!(
1993            model
1994                .search_segments(&nearby_query, 1, 1, 1, &[(&primary_only, 0)], &mut scratch)
1995                .unwrap()
1996                .is_empty()
1997        );
1998        assert_eq!(
1999            model
2000                .search_segments(&nearby_query, 1, 1, 1, &[(&fully_spilled, 0)], &mut scratch)
2001                .unwrap(),
2002            vec![BinaryScannHit {
2003                doc_id: 42,
2004                ordinal: 0,
2005                distance: 1,
2006            }],
2007        );
2008    }
2009}