Skip to main content

hermes_core/structures/vector/scann/
ah.rs

1//! Residual asymmetric hashing for float ScaNN.
2//!
3//! The anisotropic encoding objective is adapted from Google ScaNN 1.4.2,
4//! `asymmetric_hashing_impl.cc`, Apache-2.0. See the adjacent `NOTICE` file.
5
6use rand::{Rng, SeedableRng};
7
8use super::{ScannAhCodebook, ScannAhCodebookRef, ScannFormatError, ScannResult};
9
10pub const CENTERS_PER_BLOCK: usize = 16;
11pub const DEFAULT_ANISOTROPIC_THRESHOLD: f32 = 0.2;
12
13#[derive(Clone, Debug, PartialEq)]
14pub struct AhCodebook {
15    dimension: usize,
16    dimensions_per_block: usize,
17    /// Padded block-major, center-major, coordinate-major values.
18    centers: Vec<f32>,
19}
20
21#[derive(Clone, Debug, PartialEq)]
22pub struct AhQuery {
23    /// Block-major lookup table, 16 scores per block.
24    values: Vec<f32>,
25}
26
27/// Reusable per-thread AH encoding workspace.
28#[derive(Clone, Debug, Default)]
29pub struct AhEncodeScratch {
30    local_errors: Vec<[f32; CENTERS_PER_BLOCK]>,
31    projections: Vec<[f32; CENTERS_PER_BLOCK]>,
32}
33
34impl AhCodebook {
35    pub fn train(
36        residuals: &[f32],
37        points: usize,
38        dimension: usize,
39        dimensions_per_block: usize,
40        iterations: usize,
41        seed: u64,
42    ) -> ScannResult<Self> {
43        if points < CENTERS_PER_BLOCK
44            || dimension == 0
45            || dimensions_per_block == 0
46            || dimensions_per_block > dimension
47            || dimensions_per_block > u16::MAX as usize
48            || residuals.len() != points.saturating_mul(dimension)
49            || residuals.iter().any(|value| !value.is_finite())
50        {
51            return Err(ScannFormatError::new(
52                "invalid ScaNN AH training data or block geometry",
53            ));
54        }
55        let blocks = dimension.div_ceil(dimensions_per_block);
56        let mut centers = Vec::with_capacity(blocks * CENTERS_PER_BLOCK * dimensions_per_block);
57        for block in 0..blocks {
58            let start = block * dimensions_per_block;
59            let block_dimension = dimensions_per_block.min(dimension - start);
60            let mut block_data = Vec::with_capacity(points * block_dimension);
61            for row in residuals.chunks_exact(dimension) {
62                block_data.extend_from_slice(&row[start..start + block_dimension]);
63            }
64            let trained = train_subspace(
65                &block_data,
66                points,
67                block_dimension,
68                iterations,
69                seed.wrapping_add(0x517c_c1b7_2722_0a95u64.wrapping_mul(block as u64 + 1)),
70            );
71            for center in trained.chunks_exact(block_dimension) {
72                centers.extend_from_slice(center);
73                centers.resize(centers.len() + dimensions_per_block - block_dimension, 0.0);
74            }
75        }
76        Ok(Self {
77            dimension,
78            dimensions_per_block,
79            centers,
80        })
81    }
82
83    /// Train residual AH codebooks directly from the original vectors and
84    /// their terminal routing assignments. Only one AH subspace is
85    /// materialized at a time, avoiding a second `points * dimension` float
86    /// matrix beside the caller-owned training sample.
87    #[allow(clippy::too_many_arguments)]
88    pub(crate) fn train_from_assigned_vectors(
89        data: &[f32],
90        assignments: &[u32],
91        leaf_centroids: &[f32],
92        points: usize,
93        dimension: usize,
94        dimensions_per_block: usize,
95        iterations: usize,
96        seed: u64,
97    ) -> ScannResult<Self> {
98        if points < CENTERS_PER_BLOCK
99            || dimension == 0
100            || dimensions_per_block == 0
101            || dimensions_per_block > dimension
102            || dimensions_per_block > u16::MAX as usize
103            || data.len() != points.saturating_mul(dimension)
104            || assignments.len() != points
105            || leaf_centroids.is_empty()
106            || !leaf_centroids.len().is_multiple_of(dimension)
107            || data.iter().any(|value| !value.is_finite())
108            || leaf_centroids.iter().any(|value| !value.is_finite())
109        {
110            return Err(ScannFormatError::new(
111                "invalid assigned ScaNN AH training data or block geometry",
112            ));
113        }
114        let leaves = leaf_centroids.len() / dimension;
115        if assignments.iter().any(|&leaf| leaf as usize >= leaves) {
116            return Err(ScannFormatError::new(
117                "ScaNN AH training assignment references a missing leaf",
118            ));
119        }
120        Self::assigned_training_workspace_bytes(points, dimension, dimensions_per_block)?;
121
122        let blocks = dimension.div_ceil(dimensions_per_block);
123        let mut centers = Vec::with_capacity(blocks * CENTERS_PER_BLOCK * dimensions_per_block);
124        for block in 0..blocks {
125            let start = block * dimensions_per_block;
126            let block_dimension = dimensions_per_block.min(dimension - start);
127            let mut block_data = Vec::with_capacity(points * block_dimension);
128            for (point, &leaf) in data.chunks_exact(dimension).zip(assignments) {
129                let centroid =
130                    &leaf_centroids[leaf as usize * dimension..(leaf as usize + 1) * dimension];
131                block_data.extend(
132                    (start..start + block_dimension)
133                        .map(|coordinate| point[coordinate] - centroid[coordinate]),
134                );
135            }
136            let trained = train_subspace(
137                &block_data,
138                points,
139                block_dimension,
140                iterations,
141                seed.wrapping_add(0x517c_c1b7_2722_0a95u64.wrapping_mul(block as u64 + 1)),
142            );
143            for center in trained.chunks_exact(block_dimension) {
144                centers.extend_from_slice(center);
145                centers.resize(centers.len() + dimensions_per_block - block_dimension, 0.0);
146            }
147        }
148        Ok(Self {
149            dimension,
150            dimensions_per_block,
151            centers,
152        })
153    }
154
155    /// Conservative peak temporary allocation for
156    /// [`Self::train_from_assigned_vectors`], excluding caller-owned samples,
157    /// routing assignments, centroids, and the returned codebook.
158    pub(crate) fn assigned_training_workspace_bytes(
159        points: usize,
160        dimension: usize,
161        dimensions_per_block: usize,
162    ) -> ScannResult<usize> {
163        if points < CENTERS_PER_BLOCK
164            || dimension == 0
165            || dimensions_per_block == 0
166            || dimensions_per_block > dimension
167        {
168            return Err(ScannFormatError::new("invalid ScaNN AH workspace geometry"));
169        }
170        let block_dimension = dimensions_per_block.min(dimension);
171        let block_values = points.checked_mul(block_dimension).ok_or_else(|| {
172            ScannFormatError::new("ScaNN AH block workspace size overflows usize")
173        })?;
174        let center_values = CENTERS_PER_BLOCK
175            .checked_mul(block_dimension)
176            .ok_or_else(|| ScannFormatError::new("ScaNN AH center workspace overflows usize"))?;
177        let nearest_bytes = points
178            .checked_mul(std::mem::size_of::<f32>())
179            .ok_or_else(|| ScannFormatError::new("ScaNN AH nearest workspace overflows usize"))?;
180        let assignment_bytes = points
181            .checked_mul(std::mem::size_of::<usize>())
182            .ok_or_else(|| {
183                ScannFormatError::new("ScaNN AH assignment workspace overflows usize")
184            })?;
185        let center_bytes = center_values
186            .checked_mul(2 * std::mem::size_of::<f32>())
187            .ok_or_else(|| ScannFormatError::new("ScaNN AH center workspace overflows usize"))?;
188        let selected_and_count_bytes = 2 * CENTERS_PER_BLOCK * std::mem::size_of::<usize>();
189        block_values
190            .checked_mul(std::mem::size_of::<f32>())
191            .and_then(|bytes| bytes.checked_add(nearest_bytes))
192            .and_then(|bytes| bytes.checked_add(assignment_bytes))
193            .and_then(|bytes| bytes.checked_add(center_bytes))
194            .and_then(|bytes| bytes.checked_add(selected_and_count_bytes))
195            .ok_or_else(|| ScannFormatError::new("ScaNN AH workspace size overflows usize"))
196    }
197
198    pub fn from_artifact(dimension: usize, artifact: &ScannAhCodebook) -> ScannResult<Self> {
199        if artifact.centers_per_block as usize != CENTERS_PER_BLOCK {
200            return Err(ScannFormatError::new(
201                "ScaNN AH artifact must have sixteen centers per block",
202            ));
203        }
204        let dimensions_per_block = artifact.dimensions_per_block as usize;
205        let blocks = dimension.div_ceil(dimensions_per_block.max(1));
206        if dimension == 0
207            || dimensions_per_block == 0
208            || dimensions_per_block > dimension
209            || artifact.centers.len() != blocks * CENTERS_PER_BLOCK * dimensions_per_block
210            || artifact.centers.iter().any(|value| !value.is_finite())
211        {
212            return Err(ScannFormatError::new("invalid ScaNN AH artifact shape"));
213        }
214        Ok(Self {
215            dimension,
216            dimensions_per_block,
217            centers: artifact.centers.clone(),
218        })
219    }
220
221    /// Decode only the small AH codebook from a borrowed global-artifact view.
222    /// Routing centroid planes remain mmap-backed in the executable model.
223    pub fn from_artifact_ref(
224        dimension: usize,
225        artifact: ScannAhCodebookRef<'_>,
226    ) -> ScannResult<Self> {
227        if artifact.centers_per_block as usize != CENTERS_PER_BLOCK {
228            return Err(ScannFormatError::new(
229                "ScaNN AH artifact must have sixteen centers per block",
230            ));
231        }
232        let dimensions_per_block = artifact.dimensions_per_block as usize;
233        let blocks = dimension.div_ceil(dimensions_per_block.max(1));
234        let centers: Vec<f32> = artifact.centers().collect();
235        if dimension == 0
236            || dimensions_per_block == 0
237            || dimensions_per_block > dimension
238            || centers.len() != blocks * CENTERS_PER_BLOCK * dimensions_per_block
239            || centers.iter().any(|value| !value.is_finite())
240        {
241            return Err(ScannFormatError::new("invalid ScaNN AH artifact shape"));
242        }
243        Ok(Self {
244            dimension,
245            dimensions_per_block,
246            centers,
247        })
248    }
249
250    pub fn to_artifact(&self) -> ScannAhCodebook {
251        ScannAhCodebook {
252            dimensions_per_block: self.dimensions_per_block as u16,
253            centers_per_block: CENTERS_PER_BLOCK as u16,
254            centers: self.centers.clone(),
255        }
256    }
257
258    pub fn dimension(&self) -> usize {
259        self.dimension
260    }
261
262    pub fn dimensions_per_block(&self) -> usize {
263        self.dimensions_per_block
264    }
265
266    pub fn blocks(&self) -> usize {
267        self.dimension.div_ceil(self.dimensions_per_block)
268    }
269
270    pub fn code_bytes(&self) -> usize {
271        self.blocks().div_ceil(2)
272    }
273
274    pub fn estimated_memory_bytes(&self) -> usize {
275        self.centers.len() * std::mem::size_of::<f32>()
276    }
277
278    /// Encode one residual with ScaNN's anisotropic direction-aware objective.
279    /// Codes are unpacked nibbles to make streaming FastScan transposition cheap.
280    pub fn encode(
281        &self,
282        residual: &[f32],
283        original: &[f32],
284        anisotropic_threshold: f32,
285        codes: &mut [u8],
286    ) -> ScannResult<()> {
287        self.encode_with_scratch(
288            residual,
289            original,
290            anisotropic_threshold,
291            codes,
292            &mut AhEncodeScratch::default(),
293        )
294    }
295
296    pub fn encode_with_scratch(
297        &self,
298        residual: &[f32],
299        original: &[f32],
300        anisotropic_threshold: f32,
301        codes: &mut [u8],
302        scratch: &mut AhEncodeScratch,
303    ) -> ScannResult<()> {
304        if residual.len() != self.dimension
305            || original.len() != self.dimension
306            || codes.len() != self.blocks()
307            || residual.iter().any(|value| !value.is_finite())
308            || original.iter().any(|value| !value.is_finite())
309            || !anisotropic_threshold.is_finite()
310            || !(0.0..1.0).contains(&anisotropic_threshold)
311        {
312            return Err(ScannFormatError::new(
313                "invalid ScaNN AH encode input or anisotropic threshold",
314            ));
315        }
316        let norm = dot(original, original).sqrt();
317        let inverse_norm = if norm.is_finite() && norm > f32::EPSILON {
318            norm.recip()
319        } else {
320            0.0
321        };
322        scratch
323            .local_errors
324            .resize(self.blocks(), [0.0; CENTERS_PER_BLOCK]);
325        scratch
326            .projections
327            .resize(self.blocks(), [0.0; CENTERS_PER_BLOCK]);
328        let mut total_projection = 0.0f32;
329        for (block, code) in codes.iter_mut().enumerate() {
330            let (start, block_dimension) = self.block_shape(block);
331            let mut best = (0usize, f32::INFINITY);
332            for center in 0..CENTERS_PER_BLOCK {
333                let center_values = self.center(block, center);
334                let mut squared_error = 0.0;
335                let mut projection = 0.0;
336                for coordinate in 0..block_dimension {
337                    let error = residual[start + coordinate] - center_values[coordinate];
338                    squared_error += error * error;
339                    projection += error * original[start + coordinate] * inverse_norm;
340                }
341                scratch.local_errors[block][center] = squared_error;
342                scratch.projections[block][center] = projection;
343                if squared_error < best.1 {
344                    best = (center, squared_error);
345                }
346            }
347            *code = best.0 as u8;
348            total_projection += scratch.projections[block][best.0];
349        }
350
351        if inverse_norm == 0.0 || anisotropic_threshold == 0.0 {
352            return Ok(());
353        }
354        let parallel_weight = anisotropic_threshold * anisotropic_threshold
355            / (1.0 - anisotropic_threshold * anisotropic_threshold).max(f32::EPSILON)
356            * self.dimension as f32;
357        for _ in 0..3 {
358            let mut changed = false;
359            for (block, code) in codes.iter_mut().enumerate() {
360                let current = *code as usize;
361                let without_current = total_projection - scratch.projections[block][current];
362                let mut best = current;
363                let mut best_objective = scratch.local_errors[block][current]
364                    + parallel_weight * total_projection * total_projection;
365                for center in 0..CENTERS_PER_BLOCK {
366                    let candidate_projection = without_current + scratch.projections[block][center];
367                    let objective = scratch.local_errors[block][center]
368                        + parallel_weight * candidate_projection * candidate_projection;
369                    if objective < best_objective {
370                        best = center;
371                        best_objective = objective;
372                    }
373                }
374                if best != current {
375                    total_projection = without_current + scratch.projections[block][best];
376                    *code = best as u8;
377                    changed = true;
378                }
379            }
380            if !changed {
381                break;
382            }
383        }
384        Ok(())
385    }
386
387    pub fn encode_packed(
388        &self,
389        residual: &[f32],
390        original: &[f32],
391        anisotropic_threshold: f32,
392        output: &mut [u8],
393    ) -> ScannResult<()> {
394        if output.len() != self.code_bytes() {
395            return Err(ScannFormatError::new("invalid packed ScaNN AH code length"));
396        }
397        let mut codes = vec![0u8; self.blocks()];
398        self.encode(residual, original, anisotropic_threshold, &mut codes)?;
399        output.fill(0);
400        for (block, &code) in codes.iter().enumerate() {
401            output[block / 2] |= code << ((block % 2) * 4);
402        }
403        Ok(())
404    }
405
406    pub fn query_dot_product(&self, query: &[f32]) -> ScannResult<AhQuery> {
407        if query.len() != self.dimension || query.iter().any(|value| !value.is_finite()) {
408            return Err(ScannFormatError::new("invalid ScaNN AH query vector"));
409        }
410        let mut values = Vec::with_capacity(self.blocks() * CENTERS_PER_BLOCK);
411        for block in 0..self.blocks() {
412            let (start, block_dimension) = self.block_shape(block);
413            for center in 0..CENTERS_PER_BLOCK {
414                values.push(dot(
415                    &query[start..start + block_dimension],
416                    &self.center(block, center)[..block_dimension],
417                ));
418            }
419        }
420        Ok(AhQuery { values })
421    }
422
423    fn block_shape(&self, block: usize) -> (usize, usize) {
424        let start = block * self.dimensions_per_block;
425        (start, self.dimensions_per_block.min(self.dimension - start))
426    }
427
428    fn center(&self, block: usize, center: usize) -> &[f32] {
429        let offset = (block * CENTERS_PER_BLOCK + center) * self.dimensions_per_block;
430        &self.centers[offset..offset + self.dimensions_per_block]
431    }
432}
433
434impl AhQuery {
435    pub fn blocks(&self) -> usize {
436        self.values.len() / CENTERS_PER_BLOCK
437    }
438
439    pub fn score_unpacked(&self, codes: &[u8], centroid_dot: f32) -> ScannResult<f32> {
440        if codes.len() != self.blocks()
441            || codes.iter().any(|&code| code as usize >= CENTERS_PER_BLOCK)
442        {
443            return Err(ScannFormatError::new("invalid unpacked ScaNN AH codes"));
444        }
445        Ok(codes
446            .iter()
447            .enumerate()
448            .fold(centroid_dot, |score, (block, &code)| {
449                score.algebraic_add(self.values[block * CENTERS_PER_BLOCK + code as usize])
450            }))
451    }
452
453    pub fn score_packed(&self, codes: &[u8], centroid_dot: f32) -> ScannResult<f32> {
454        if codes.len() != self.blocks().div_ceil(2) {
455            return Err(ScannFormatError::new("invalid packed ScaNN AH codes"));
456        }
457        let mut score = centroid_dot;
458        for block in 0..self.blocks() {
459            let code = (codes[block / 2] >> ((block % 2) * 4)) & 0x0f;
460            score = score.algebraic_add(self.values[block * CENTERS_PER_BLOCK + code as usize]);
461        }
462        Ok(score)
463    }
464
465    pub(crate) fn values(&self) -> &[f32] {
466        &self.values
467    }
468}
469
470fn train_subspace(
471    data: &[f32],
472    points: usize,
473    dimension: usize,
474    iterations: usize,
475    seed: u64,
476) -> Vec<f32> {
477    let mut rng = rand::rngs::StdRng::seed_from_u64(seed);
478    let mut selected = Vec::with_capacity(CENTERS_PER_BLOCK);
479    selected.push(rng.random_range(0..points));
480    let mut nearest = vec![f32::INFINITY; points];
481    while selected.len() < CENTERS_PER_BLOCK {
482        let last = &data[selected[selected.len() - 1] * dimension..][..dimension];
483        for (row, point) in data.chunks_exact(dimension).enumerate() {
484            nearest[row] = nearest[row].min(squared_l2(point, last));
485        }
486        let total: f64 = nearest.iter().map(|&value| f64::from(value)).sum();
487        let next = if total > 0.0 && total.is_finite() {
488            let target = rng.random::<f64>() * total;
489            let mut sum = 0.0;
490            nearest
491                .iter()
492                .position(|&value| {
493                    sum += f64::from(value);
494                    sum > target
495                })
496                .unwrap_or(points - 1)
497        } else {
498            (0..points)
499                .find(|candidate| !selected.contains(candidate))
500                .unwrap_or(0)
501        };
502        selected.push(next);
503    }
504    let mut centers: Vec<f32> = selected
505        .iter()
506        .flat_map(|&row| data[row * dimension..(row + 1) * dimension].iter().copied())
507        .collect();
508    let mut assignments = vec![0usize; points];
509    for _ in 0..iterations.max(1) {
510        for (row, point) in data.chunks_exact(dimension).enumerate() {
511            assignments[row] = centers
512                .chunks_exact(dimension)
513                .enumerate()
514                .map(|(center, values)| (center, squared_l2(point, values)))
515                .min_by(|left, right| {
516                    left.1
517                        .total_cmp(&right.1)
518                        .then_with(|| left.0.cmp(&right.0))
519                })
520                .unwrap()
521                .0;
522        }
523        let mut sums = vec![0.0f32; centers.len()];
524        let mut counts = [0usize; CENTERS_PER_BLOCK];
525        for (point, &center) in data.chunks_exact(dimension).zip(&assignments) {
526            counts[center] += 1;
527            for coordinate in 0..dimension {
528                sums[center * dimension + coordinate] += point[coordinate];
529            }
530        }
531        for center in 0..CENTERS_PER_BLOCK {
532            if counts[center] == 0 {
533                let replacement = selected[center];
534                sums[center * dimension..(center + 1) * dimension]
535                    .copy_from_slice(&data[replacement * dimension..(replacement + 1) * dimension]);
536            } else {
537                let inverse = (counts[center] as f32).recip();
538                for value in &mut sums[center * dimension..(center + 1) * dimension] {
539                    *value *= inverse;
540                }
541            }
542        }
543        centers = sums;
544    }
545    centers
546}
547
548#[inline]
549fn squared_l2(left: &[f32], right: &[f32]) -> f32 {
550    crate::structures::simd::squared_l2_f32(left, right)
551}
552
553#[inline]
554fn dot(left: &[f32], right: &[f32]) -> f32 {
555    crate::structures::simd::dot_product_f32(left, right, left.len())
556}
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561
562    fn training_data() -> Vec<f32> {
563        (0..256)
564            .flat_map(|row| {
565                (0..7)
566                    .map(move |coordinate| ((row * 17 + coordinate * 29) % 101) as f32 / 50.0 - 1.0)
567            })
568            .collect()
569    }
570
571    #[test]
572    fn ah_training_and_artifact_roundtrip_are_deterministic() {
573        let data = training_data();
574        let first = AhCodebook::train(&data, 256, 7, 2, 6, 42).unwrap();
575        let second = AhCodebook::train(&data, 256, 7, 2, 6, 42).unwrap();
576        assert_eq!(first, second);
577        assert_eq!(
578            AhCodebook::from_artifact(7, &first.to_artifact()).unwrap(),
579            first
580        );
581    }
582
583    #[test]
584    fn assigned_vector_training_matches_materialized_residual_training() {
585        let data = training_data();
586        let dimension = 7usize;
587        let points = 256usize;
588        let leaves = 4usize;
589        let leaf_centroids: Vec<f32> = (0..leaves)
590            .flat_map(|leaf| {
591                (0..dimension)
592                    .map(move |coordinate| (leaf as f32 - 1.5) * 0.1 + coordinate as f32 * 0.003)
593            })
594            .collect();
595        let assignments: Vec<u32> = (0..points).map(|row| (row % leaves) as u32).collect();
596        let residuals: Vec<f32> = data
597            .chunks_exact(dimension)
598            .zip(&assignments)
599            .flat_map(|(point, &leaf)| {
600                let centroid =
601                    &leaf_centroids[leaf as usize * dimension..(leaf as usize + 1) * dimension];
602                point
603                    .iter()
604                    .zip(centroid)
605                    .map(|(&value, &center)| value - center)
606            })
607            .collect();
608
609        let materialized = AhCodebook::train(&residuals, points, dimension, 2, 6, 42).unwrap();
610        let blockwise = AhCodebook::train_from_assigned_vectors(
611            &data,
612            &assignments,
613            &leaf_centroids,
614            points,
615            dimension,
616            2,
617            6,
618            42,
619        )
620        .unwrap();
621        assert_eq!(blockwise, materialized);
622    }
623
624    #[test]
625    fn assigned_training_workspace_is_block_bounded_at_default_billion_scale_budget() {
626        let points = 1_048_576usize;
627        let dimension = 1_024usize;
628        let dimensions_per_block = 2usize;
629        let sample_bytes = points * dimension * std::mem::size_of::<f32>();
630        let workspace =
631            AhCodebook::assigned_training_workspace_bytes(points, dimension, dimensions_per_block)
632                .unwrap();
633
634        assert!(workspace < sample_bytes / 100);
635        assert!(workspace < 24 * 1024 * 1024);
636    }
637
638    #[test]
639    fn packed_and_unpacked_scores_match() {
640        let data = training_data();
641        let codebook = AhCodebook::train(&data, 256, 7, 2, 5, 9).unwrap();
642        let vector = &data[..7];
643        let mut unpacked = vec![0u8; codebook.blocks()];
644        codebook
645            .encode(vector, vector, DEFAULT_ANISOTROPIC_THRESHOLD, &mut unpacked)
646            .unwrap();
647        let mut packed = vec![0u8; codebook.code_bytes()];
648        codebook
649            .encode_packed(vector, vector, DEFAULT_ANISOTROPIC_THRESHOLD, &mut packed)
650            .unwrap();
651        let query = codebook.query_dot_product(vector).unwrap();
652        assert_eq!(
653            query.score_unpacked(&unpacked, 0.25).unwrap(),
654            query.score_packed(&packed, 0.25).unwrap()
655        );
656    }
657}