Skip to main content

lattice_embed/simd/
tier.rs

1//! Quantization tiers, prepared queries, and unified distance dispatch.
2//!
3//! Tiers trade storage for fidelity; prepared queries avoid repeated
4//! quantization in homogeneous candidate searches.
5//!
6//! See docs/simd.md for tier selection and dispatch semantics.
7
8use super::binary::BinaryVector;
9use super::int4::Int4Vector;
10use super::quantized::{QuantizedVector, cosine_similarity_i8_trusted, dot_product_i8_trusted};
11use super::{cosine_similarity, dot_product};
12use crate::error::{EmbedError, Result};
13
14/// Caller assertion that a vector is L2-unit-normalized (norm ≈ 1).
15///
16/// When both query and stored vectors carry `UnitNorm`, cosine similarity equals
17/// the dot product — the norm division can be skipped entirely.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum NormalizationHint {
20    /// No guarantee — full cosine (with norm division) is required.
21    Unknown,
22    /// Caller asserts this vector is L2-unit-normalized (norm ≈ 1 within 1e-4).
23    Unit,
24}
25
26/// **Unstable**: tier design is under active iteration; tier boundaries may change.
27///
28/// Quantization precision tier, ordered from highest to lowest fidelity.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
30pub enum QuantizationTier {
31    /// Full f32 precision (4 bytes/dim, 1x baseline).
32    Full,
33    /// INT8 symmetric quantization (1 byte/dim, 4x compression).
34    Int8,
35    /// INT4 packed nibble quantization (0.5 bytes/dim, 8x compression).
36    Int4,
37    /// Binary sign-bit quantization (0.125 bytes/dim, 32x compression).
38    Binary,
39}
40
41impl QuantizationTier {
42    /// **Unstable**: bytes-per-dimension constant; may change with new tiers.
43    pub fn bytes_per_dim(&self) -> f32 {
44        match self {
45            Self::Full => 4.0,
46            Self::Int8 => 1.0,
47            Self::Int4 => 0.5,
48            Self::Binary => 0.125,
49        }
50    }
51
52    /// **Unstable**: compression ratio; derived from `bytes_per_dim`, may be removed.
53    pub fn compression_ratio(&self) -> f32 {
54        4.0 / self.bytes_per_dim()
55    }
56
57    /// **Unstable**: storage byte computation; may change with new tiers.
58    pub fn storage_bytes(&self, dims: usize) -> usize {
59        match self {
60            Self::Full => dims * 4,
61            Self::Int8 => dims,
62            Self::Int4 => dims.div_ceil(2),
63            Self::Binary => dims.div_ceil(8),
64        }
65    }
66
67    /// **Warning**: this is a placeholder storage policy, not evidence that older vectors
68    /// tolerate lower precision. Callers should measure retrieval quality for their workload.
69    ///
70    /// **Unstable**: tier boundaries may be tuned.
71    pub fn from_age_seconds(age_secs: u64) -> Self {
72        const HOUR: u64 = 3600;
73        const DAY: u64 = 86400;
74        const WEEK: u64 = 604800;
75
76        if age_secs < HOUR {
77            Self::Full
78        } else if age_secs < DAY {
79            Self::Int8
80        } else if age_secs < WEEK {
81            Self::Int4
82        } else {
83            Self::Binary
84        }
85    }
86}
87
88/// **Unstable**: unified quantized data container; variants may change with tier redesign.
89///
90/// Wraps the tier-specific vector types into a single enum for
91/// uniform storage and distance dispatch.
92#[derive(Debug, Clone)]
93pub enum QuantizedData {
94    /// Full-precision f32 vector.
95    Full(Vec<f32>),
96    /// INT8 quantized vector.
97    Int8(QuantizedVector),
98    /// INT4 packed quantized vector.
99    Int4(Int4Vector),
100    /// Binary sign-bit vector.
101    Binary(BinaryVector),
102}
103
104impl QuantizedData {
105    /// **Unstable**: returns `QuantizationTier` which is itself Unstable.
106    pub fn tier(&self) -> QuantizationTier {
107        match self {
108            Self::Full(_) => QuantizationTier::Full,
109            Self::Int8(_) => QuantizationTier::Int8,
110            Self::Int4(_) => QuantizationTier::Int4,
111            Self::Binary(_) => QuantizationTier::Binary,
112        }
113    }
114
115    /// **Unstable**: dimension accessor; may be removed if `QuantizedData` gains a dims field.
116    pub fn dims(&self) -> usize {
117        match self {
118            Self::Full(v) => v.len(),
119            Self::Int8(q) => q.len(),
120            Self::Int4(q) => q.dims,
121            Self::Binary(q) => q.dims,
122        }
123    }
124
125    /// **Unstable**: storage byte count; may change with tier redesign.
126    pub fn storage_bytes(&self) -> usize {
127        match self {
128            Self::Full(v) => v.len() * 4,
129            Self::Int8(q) => q.len(),
130            Self::Int4(q) => q.data.len(),
131            Self::Binary(q) => q.data.len(),
132        }
133    }
134
135    /// **Unstable**: quantization factory; tier dispatch logic may change.
136    pub fn from_f32(vector: &[f32], tier: QuantizationTier) -> Self {
137        match tier {
138            QuantizationTier::Full => Self::Full(vector.to_vec()),
139            QuantizationTier::Int8 => Self::Int8(QuantizedVector::from_f32(vector)),
140            QuantizationTier::Int4 => Self::Int4(Int4Vector::from_f32(vector)),
141            QuantizationTier::Binary => Self::Binary(BinaryVector::from_f32(vector)),
142        }
143    }
144
145    /// **Unstable**: dequantization; output precision is tier-dependent.
146    pub fn to_f32(&self) -> Vec<f32> {
147        match self {
148            Self::Full(v) => v.clone(),
149            Self::Int8(q) => q.to_f32(),
150            Self::Int4(q) => q.to_f32(),
151            Self::Binary(q) => q.to_f32(),
152        }
153    }
154
155    /// **Unstable**: re-quantizes through `f32`; lost information is not recovered.
156    pub fn promote(&self, target: QuantizationTier) -> Self {
157        let f32_data = self.to_f32();
158        Self::from_f32(&f32_data, target)
159    }
160
161    /// **Unstable**: tier demotion; delegates to `promote`; may be removed.
162    pub fn demote(&self, target: QuantizationTier) -> Self {
163        self.promote(target) // Same operation, just going the other direction
164    }
165}
166
167/// **Unstable**: pre-quantized query for repeated same-tier distance computation.
168#[derive(Debug, Clone)]
169pub enum PreparedQuery {
170    /// Full f32 query.
171    Full(Vec<f32>),
172    /// INT8 quantized query.
173    Int8(QuantizedVector),
174    /// INT4 packed quantized query.
175    Int4(Int4Vector),
176    /// Binary sign-bit query.
177    Binary(BinaryVector),
178}
179
180impl PreparedQuery {
181    /// Quantize a query at the given tier for repeated distance calls.
182    #[inline]
183    pub fn from_f32(query_f32: &[f32], tier: QuantizationTier) -> Self {
184        match tier {
185            QuantizationTier::Full => Self::Full(query_f32.to_vec()),
186            QuantizationTier::Int8 => Self::Int8(QuantizedVector::from_f32(query_f32)),
187            QuantizationTier::Int4 => Self::Int4(Int4Vector::from_f32(query_f32)),
188            QuantizationTier::Binary => Self::Binary(BinaryVector::from_f32(query_f32)),
189        }
190    }
191
192    /// Returns the quantization tier of this prepared query.
193    #[inline]
194    pub fn tier(&self) -> QuantizationTier {
195        match self {
196            Self::Full(_) => QuantizationTier::Full,
197            Self::Int8(_) => QuantizationTier::Int8,
198            Self::Int4(_) => QuantizationTier::Int4,
199            Self::Binary(_) => QuantizationTier::Binary,
200        }
201    }
202
203    /// Returns the number of dimensions.
204    #[inline]
205    pub fn dims(&self) -> usize {
206        match self {
207            Self::Full(v) => v.len(),
208            Self::Int8(q) => q.len(),
209            Self::Int4(q) => q.dims,
210            Self::Binary(q) => q.dims,
211        }
212    }
213}
214
215/// Prepare a query vector for repeated distance computation against a homogeneous tier.
216#[inline]
217pub fn prepare_query(query_f32: &[f32], tier: QuantizationTier) -> PreparedQuery {
218    PreparedQuery::from_f32(query_f32, tier)
219}
220
221/// A prepared query with caller-provided normalization metadata.
222#[derive(Debug, Clone)]
223pub struct PreparedQueryWithMeta {
224    /// The quantized query (owns the data).
225    pub query: PreparedQuery,
226    /// Caller assertion about the query vector's normalization state.
227    pub norm: NormalizationHint,
228}
229
230impl PreparedQueryWithMeta {
231    /// Create a prepared query from an f32 vector, asserting its normalization state.
232    #[inline]
233    pub fn from_f32(query_f32: &[f32], tier: QuantizationTier, norm: NormalizationHint) -> Self {
234        Self {
235            query: PreparedQuery::from_f32(query_f32, tier),
236            norm,
237        }
238    }
239
240    /// Returns the quantization tier.
241    #[inline]
242    pub fn tier(&self) -> QuantizationTier {
243        self.query.tier()
244    }
245
246    /// Returns the number of dimensions.
247    #[inline]
248    pub fn dims(&self) -> usize {
249        self.query.dims()
250    }
251}
252
253/// Returns `true` when the squared norm of `v` is within 1e-4 of 1.0.
254///
255/// Uses the SIMD-dispatched [`dot_product`] for the self-dot rather than a plain
256/// scalar reduction. This helper is no longer called on the cosine hot path
257/// (`approximate_cosine_distance_prepared_with_meta` delegates to the fused
258/// path instead of guarding a hint-selected shortcut), but any caller checking
259/// norms per candidate gets the SIMD cost model, not a scalar one.
260#[inline]
261pub fn is_unit_norm(v: &[f32]) -> bool {
262    let sq = dot_product(v, v);
263    (sq - 1.0).abs() < 1e-4
264}
265
266/// Prepare a query annotated with the given normalization hint.
267#[inline]
268pub fn prepare_query_with_norm(
269    query_f32: &[f32],
270    tier: QuantizationTier,
271    norm: NormalizationHint,
272) -> PreparedQueryWithMeta {
273    PreparedQueryWithMeta::from_f32(query_f32, tier, norm)
274}
275
276/// **Unstable**: computes prepared cosine distance in `[0, 2]` for matching tiers.
277///
278/// Returns [`EmbedError::TierMismatch`] for a different stored tier.
279/// See [`docs/simd.md`](../../docs/simd.md#prepared-queries-and-tier-matching) for the per-tier paths.
280#[inline]
281pub fn approximate_cosine_distance_prepared(
282    query: &PreparedQuery,
283    stored: &QuantizedData,
284) -> Result<f32> {
285    match (query, stored) {
286        (PreparedQuery::Full(q), QuantizedData::Full(s)) => Ok(1.0 - cosine_similarity(q, s)),
287        (PreparedQuery::Int8(q), QuantizedData::Int8(s)) => {
288            Ok(1.0 - cosine_similarity_i8_trusted(s, q))
289        }
290        (PreparedQuery::Int4(q), QuantizedData::Int4(s)) => Ok(s.cosine_distance(q)),
291        (PreparedQuery::Binary(q), QuantizedData::Binary(s)) => Ok(s.cosine_distance_approx(q)),
292        _ => Err(EmbedError::TierMismatch {
293            op: "approximate_cosine_distance_prepared",
294            expected: stored.tier(),
295            actual: query.tier(),
296        }),
297    }
298}
299
300/// Alias for [`approximate_cosine_distance_prepared`] retained for compatibility.
301#[inline]
302pub fn try_approximate_cosine_distance_prepared(
303    query: &PreparedQuery,
304    stored: &QuantizedData,
305) -> Result<f32> {
306    approximate_cosine_distance_prepared(query, stored)
307}
308
309/// Alias for [`approximate_dot_product_prepared`] retained for compatibility.
310#[inline]
311pub fn try_approximate_dot_product_prepared(
312    query: &PreparedQuery,
313    stored: &QuantizedData,
314) -> Result<f32> {
315    approximate_dot_product_prepared(query, stored)
316}
317
318/// Computes prepared cosine distance; hints are accepted but do not select a
319/// separate code path.
320///
321/// The former `Full` unit-norm "fast path" (skip norm division when both sides
322/// assert unit norm) was measurably slower than the general path it guarded:
323/// verifying the stored side's norm plus the query dot takes two O(d) passes,
324/// while [`cosine_similarity`] computes the dot and both norms in one fused
325/// pass. With the guard it was also a correctness risk, trusting release-time
326/// hints. Delegating unconditionally is both the fastest and the safest shape.
327///
328/// Returns [`EmbedError::TierMismatch`] for a tier mismatch.
329/// See [`docs/simd.md`](../../docs/simd.md#prepared-queries-and-tier-matching) for hint semantics.
330#[inline]
331pub fn approximate_cosine_distance_prepared_with_meta(
332    meta: &PreparedQueryWithMeta,
333    stored: &QuantizedData,
334    _stored_norm: NormalizationHint,
335) -> Result<f32> {
336    approximate_cosine_distance_prepared(&meta.query, stored)
337}
338
339/// **Unstable**: computes a prepared dot product for matching non-binary tiers.
340///
341/// Returns [`EmbedError::TierMismatch`] for different tiers or [`EmbedError::Internal`] for binary.
342/// See [`docs/simd.md`](../../docs/simd.md#prepared-queries-and-tier-matching) for supported paths.
343#[inline]
344pub fn approximate_dot_product_prepared(
345    query: &PreparedQuery,
346    stored: &QuantizedData,
347) -> Result<f32> {
348    match (query, stored) {
349        (PreparedQuery::Full(q), QuantizedData::Full(s)) => Ok(dot_product(q, s)),
350        (PreparedQuery::Int8(q), QuantizedData::Int8(s)) => Ok(dot_product_i8_trusted(q, s)),
351        (PreparedQuery::Int4(q), QuantizedData::Int4(s)) => Ok(s.dot_product(q)),
352        (PreparedQuery::Binary(_), QuantizedData::Binary(_)) => Err(EmbedError::Internal(
353            "Binary has no prepared dot product; use approximate_cosine_distance_prepared".into(),
354        )),
355        _ => Err(EmbedError::TierMismatch {
356            op: "approximate_dot_product_prepared",
357            expected: stored.tier(),
358            actual: query.tier(),
359        }),
360    }
361}
362
363/// Computes distances from one prepared query to all stored vectors.
364///
365/// Returns [`EmbedError::TierMismatch`] if any stored tier differs.
366#[inline]
367pub fn batch_approximate_cosine_distance_prepared(
368    query: &PreparedQuery,
369    stored: &[QuantizedData],
370) -> Result<Vec<f32>> {
371    stored
372        .iter()
373        .map(|item| approximate_cosine_distance_prepared(query, item))
374        .collect()
375}
376
377/// Writes prepared-query distances into a reusable buffer, clearing it on error.
378///
379/// Returns [`EmbedError::TierMismatch`] if any stored tier differs.
380/// See [`docs/simd.md`](../../docs/simd.md#prepared-queries-and-tier-matching) for buffer semantics.
381#[inline]
382pub fn batch_approximate_cosine_distance_prepared_into(
383    query: &PreparedQuery,
384    stored: &[QuantizedData],
385    out: &mut Vec<f32>,
386) -> Result<()> {
387    out.clear();
388    out.reserve(stored.len());
389    for item in stored {
390        match approximate_cosine_distance_prepared(query, item) {
391            Ok(distance) => out.push(distance),
392            Err(e) => {
393                out.clear();
394                return Err(e);
395            }
396        }
397    }
398    Ok(())
399}
400
401/// Computes distances from one prepared INT8 query without re-quantizing it.
402///
403/// Returns [`EmbedError::TierMismatch`] unless the query is INT8.
404#[inline]
405pub fn approximate_int8_batch_prepared(
406    query: &PreparedQuery,
407    candidates: &[QuantizedVector],
408) -> Result<Vec<f32>> {
409    let PreparedQuery::Int8(q) = query else {
410        return Err(EmbedError::TierMismatch {
411            op: "approximate_int8_batch_prepared",
412            expected: QuantizationTier::Int8,
413            actual: query.tier(),
414        });
415    };
416    Ok(candidates
417        .iter()
418        .map(|candidate| 1.0 - cosine_similarity_i8_trusted(candidate, q))
419        .collect())
420}
421
422/// Writes prepared INT8 distances into a reusable buffer, clearing it on error.
423///
424/// Returns [`EmbedError::TierMismatch`] unless the query is INT8.
425#[inline]
426pub fn approximate_int8_batch_prepared_into(
427    query: &PreparedQuery,
428    candidates: &[QuantizedVector],
429    out: &mut Vec<f32>,
430) -> Result<()> {
431    out.clear();
432    let PreparedQuery::Int8(q) = query else {
433        return Err(EmbedError::TierMismatch {
434            op: "approximate_int8_batch_prepared_into",
435            expected: QuantizationTier::Int8,
436            actual: query.tier(),
437        });
438    };
439    out.reserve(candidates.len());
440    out.extend(
441        candidates
442            .iter()
443            .map(|candidate| 1.0 - cosine_similarity_i8_trusted(candidate, q)),
444    );
445    Ok(())
446}
447
448/// Computes distances from one prepared INT4 query without re-quantizing it.
449///
450/// Returns [`EmbedError::TierMismatch`] unless the query is INT4.
451#[inline]
452pub fn approximate_int4_batch_prepared(
453    query: &PreparedQuery,
454    candidates: &[Int4Vector],
455) -> Result<Vec<f32>> {
456    let PreparedQuery::Int4(q) = query else {
457        return Err(EmbedError::TierMismatch {
458            op: "approximate_int4_batch_prepared",
459            expected: QuantizationTier::Int4,
460            actual: query.tier(),
461        });
462    };
463    Ok(candidates
464        .iter()
465        .map(|candidate| candidate.cosine_distance(q))
466        .collect())
467}
468
469/// Writes prepared INT4 distances into a reusable buffer, clearing it on error.
470///
471/// Returns [`EmbedError::TierMismatch`] unless the query is INT4.
472#[inline]
473pub fn approximate_int4_batch_prepared_into(
474    query: &PreparedQuery,
475    candidates: &[Int4Vector],
476    out: &mut Vec<f32>,
477) -> Result<()> {
478    out.clear();
479    let PreparedQuery::Int4(q) = query else {
480        return Err(EmbedError::TierMismatch {
481            op: "approximate_int4_batch_prepared_into",
482            expected: QuantizationTier::Int4,
483            actual: query.tier(),
484        });
485    };
486    out.reserve(candidates.len());
487    out.extend(
488        candidates
489            .iter()
490            .map(|candidate| candidate.cosine_distance(q)),
491    );
492    Ok(())
493}
494
495/// **Unstable**: quantizes an `f32` query and computes tiered cosine distance.
496///
497/// `query_f32.len()` must match stored dimensionality.
498/// See [`docs/simd.md`](../../docs/simd.md#prepared-queries-and-tier-matching) for hot-loop guidance.
499pub fn approximate_cosine_distance(query_f32: &[f32], stored: &QuantizedData) -> f32 {
500    debug_assert_eq!(
501        query_f32.len(),
502        stored.dims(),
503        "approximate_cosine_distance: query length {} != stored dims {}",
504        query_f32.len(),
505        stored.dims(),
506    );
507    match stored {
508        QuantizedData::Full(v) => {
509            // Exact cosine distance
510            1.0 - cosine_similarity(query_f32, v)
511        }
512        QuantizedData::Int8(q) => {
513            let query_q = QuantizedVector::from_f32(query_f32);
514            1.0 - q.cosine_similarity(&query_q)
515        }
516        QuantizedData::Int4(q) => {
517            let query_q = Int4Vector::from_f32(query_f32);
518            q.cosine_distance(&query_q)
519        }
520        QuantizedData::Binary(q) => {
521            let query_q = BinaryVector::from_f32(query_f32);
522            q.cosine_distance_approx(&query_q)
523        }
524    }
525}
526
527/// **Unstable**: approximate tiered dot-product dispatch.
528pub fn approximate_dot_product(query_f32: &[f32], stored: &QuantizedData) -> f32 {
529    match stored {
530        QuantizedData::Full(v) => dot_product(query_f32, v),
531        QuantizedData::Int8(q) => {
532            let query_q = QuantizedVector::from_f32(query_f32);
533            q.dot_product(&query_q)
534        }
535        QuantizedData::Int4(q) => {
536            let query_q = Int4Vector::from_f32(query_f32);
537            q.dot_product(&query_q)
538        }
539        QuantizedData::Binary(_q) => {
540            // Binary doesn't have a meaningful dot product; fall back to dequantize
541            let stored_f32 = _q.to_f32();
542            dot_product(query_f32, &stored_f32)
543        }
544    }
545}
546
547#[cfg(test)]
548mod tests {
549    use super::*;
550
551    fn generate_vector(dim: usize, seed: u64) -> Vec<f32> {
552        let mut state = seed ^ ((dim as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15));
553        (0..dim)
554            .map(|i| {
555                state = state
556                    .wrapping_mul(6364136223846793005)
557                    .wrapping_add(1442695040888963407)
558                    .wrapping_add(i as u64);
559                let unit = ((state >> 32) as u32) as f32 / u32::MAX as f32;
560                unit * 2.0 - 1.0
561            })
562            .collect()
563    }
564
565    fn scalar_cosine_f64(a: &[f32], b: &[f32]) -> f64 {
566        assert_eq!(a.len(), b.len());
567        let mut dot = 0.0f64;
568        let mut norm_a = 0.0f64;
569        let mut norm_b = 0.0f64;
570        for (&a, &b) in a.iter().zip(b) {
571            let a = f64::from(a);
572            let b = f64::from(b);
573            dot += a * b;
574            norm_a += a * a;
575            norm_b += b * b;
576        }
577        let denom = norm_a.sqrt() * norm_b.sqrt();
578        if denom == 0.0 { 0.0 } else { dot / denom }
579    }
580
581    fn reference_ranking(query: &[f32], corpus: &[Vec<f32>]) -> Vec<usize> {
582        let mut ranked: Vec<_> = corpus
583            .iter()
584            .enumerate()
585            .map(|(index, candidate)| (index, scalar_cosine_f64(query, candidate)))
586            .collect();
587        ranked.sort_unstable_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
588        ranked.into_iter().map(|(index, _)| index).collect()
589    }
590
591    fn tier_ranking(query: &[f32], stored: &[QuantizedData], tier: QuantizationTier) -> Vec<usize> {
592        let prepared = PreparedQuery::from_f32(query, tier);
593        assert_eq!(prepared.tier(), tier);
594        let mut ranked: Vec<_> = stored
595            .iter()
596            .enumerate()
597            .map(|(index, candidate)| {
598                (
599                    index,
600                    approximate_cosine_distance_prepared(&prepared, candidate).unwrap(),
601                )
602            })
603            .collect();
604        ranked.sort_unstable_by(|a, b| a.1.total_cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
605        ranked.into_iter().map(|(index, _)| index).collect()
606    }
607
608    fn recall_hits_at(reference: &[usize], actual: &[usize], k: usize) -> usize {
609        actual[..k]
610            .iter()
611            .filter(|candidate| reference[..k].contains(candidate))
612            .count()
613    }
614
615    fn recall_at(reference: &[usize], actual: &[usize], k: usize) -> f64 {
616        recall_hits_at(reference, actual, k) as f64 / k as f64
617    }
618
619    fn pairwise_ranking_agreements(reference: &[usize], actual: &[usize]) -> usize {
620        assert_eq!(reference.len(), actual.len());
621        let mut actual_position = vec![0usize; actual.len()];
622        for (position, &candidate) in actual.iter().enumerate() {
623            actual_position[candidate] = position;
624        }
625        let mut agreements = 0usize;
626        for (position, &left) in reference.iter().enumerate() {
627            for &right in &reference[position + 1..] {
628                agreements += usize::from(actual_position[left] < actual_position[right]);
629            }
630        }
631        agreements
632    }
633
634    fn pairwise_ranking_agreement(reference: &[usize], actual: &[usize]) -> f64 {
635        let pairs = reference.len() * (reference.len() - 1) / 2;
636        pairwise_ranking_agreements(reference, actual) as f64 / pairs as f64
637    }
638
639    fn retrieval_quality_counts(
640        reference: &[usize],
641        actual: &[usize],
642        top_k: usize,
643    ) -> (usize, usize) {
644        (
645            recall_hits_at(reference, actual, top_k),
646            pairwise_ranking_agreements(reference, actual),
647        )
648    }
649
650    fn index_order_surrogate_quality(
651        corpus: &[Vec<f32>],
652        queries: &[Vec<f32>],
653        top_k: usize,
654    ) -> (f64, f64) {
655        let index_order: Vec<_> = (0..corpus.len()).collect();
656        let mut recall = 0.0;
657        let mut agreement = 0.0;
658        for query in queries {
659            let reference = reference_ranking(query, corpus);
660            recall += recall_at(&reference, &index_order, top_k);
661            agreement += pairwise_ranking_agreement(&reference, &index_order);
662        }
663        (
664            recall / queries.len() as f64,
665            agreement / queries.len() as f64,
666        )
667    }
668
669    fn retrieval_quality_floor(tier: QuantizationTier) -> (f64, f64) {
670        match tier {
671            QuantizationTier::Full => (1.0, 0.999),
672            QuantizationTier::Int8 => (0.98, 0.995),
673            QuantizationTier::Int4 => (0.85, 0.95),
674            QuantizationTier::Binary => (0.30, 0.70),
675        }
676    }
677
678    /// A floor includes equality; one epsilon recovers equality lost while averaging.
679    fn meets_retrieval_quality_floor(value: f64, minimum: f64) -> bool {
680        value.is_finite() && value + f64::EPSILON >= minimum
681    }
682
683    /// Bounds each query so a collapsed subset cannot hide behind healthy queries.
684    ///
685    /// The fixed 16-query fixture produces these per-query ranges at Recall@10:
686    ///
687    /// | Tier | Recall@10 hits | Agreeing pairs |
688    /// | --- | --- | --- |
689    /// | Full | 10..=10 | 32,640..=32,640 |
690    /// | Int8 | 10..=10 | 32,567..=32,604 |
691    /// | Int4 | 8..=10 | 31,531..=31,773 |
692    /// | Binary | 3..=7 | 24,423..=25,705 |
693    ///
694    /// Recall gets one additional miss beyond the observed minimum, exactly one
695    /// Recall@10 step. Agreement gets one full observed min-to-max range below
696    /// the observed minimum. This fixture-derived slack rejects whole-query
697    /// collapse without making ordinary variation in the healthy fixture fatal.
698    /// Full agreement has no observed spread and remains exact.
699    fn retrieval_quality_per_query_floor(tier: QuantizationTier) -> (usize, usize) {
700        match tier {
701            QuantizationTier::Full => (9, 32_640),
702            QuantizationTier::Int8 => (9, 32_530),
703            QuantizationTier::Int4 => (7, 31_289),
704            QuantizationTier::Binary => (2, 23_141),
705        }
706    }
707
708    /// Pins known-good metrics as Recall@10 hits and agreeing pairs out of 32,640.
709    ///
710    /// Each metric may move by one observed min-to-max span in total across all
711    /// 16 queries, or one sixteenth of that span under a uniform shift. Absolute
712    /// movement prevents cross-query cancellation and makes a broader path change
713    /// require deliberate recalibration. A zero-spread metric remains exact.
714    /// Recall-hit and agreeing-pair counts can collide across different rankings. The
715    /// exercised path breaks equal distances by candidate index, so this remains a
716    /// metric limitation. A future top-k identity check would detect membership changes;
717    /// a rank fingerprint would also detect position changes that preserve both counts.
718    const HEALTHY_FULL_QUERY_QUALITY: [(usize, usize); 16] = [(10, 32_640); 16];
719    const HEALTHY_INT8_QUERY_QUALITY: [(usize, usize); 16] = [
720        (10, 32_588),
721        (10, 32_575),
722        (10, 32_597),
723        (10, 32_590),
724        (10, 32_595),
725        (10, 32_592),
726        (10, 32_580),
727        (10, 32_604),
728        (10, 32_587),
729        (10, 32_573),
730        (10, 32_580),
731        (10, 32_579),
732        (10, 32_567),
733        (10, 32_578),
734        (10, 32_576),
735        (10, 32_588),
736    ];
737    const HEALTHY_INT4_QUERY_QUALITY: [(usize, usize); 16] = [
738        (8, 31_582),
739        (9, 31_531),
740        (9, 31_642),
741        (8, 31_656),
742        (10, 31_773),
743        (8, 31_638),
744        (10, 31_744),
745        (9, 31_758),
746        (10, 31_661),
747        (10, 31_651),
748        (10, 31_662),
749        (8, 31_727),
750        (10, 31_531),
751        (10, 31_625),
752        (9, 31_655),
753        (9, 31_653),
754    ];
755    const HEALTHY_BINARY_QUERY_QUALITY: [(usize, usize); 16] = [
756        (7, 25_430),
757        (3, 25_031),
758        (6, 25_033),
759        (5, 25_339),
760        (4, 25_534),
761        (3, 25_166),
762        (3, 25_156),
763        (6, 25_705),
764        (5, 25_677),
765        (4, 25_419),
766        (4, 25_040),
767        (3, 25_150),
768        (4, 24_965),
769        (5, 25_190),
770        (5, 24_423),
771        (4, 25_355),
772    ];
773
774    fn healthy_query_quality(tier: QuantizationTier) -> &'static [(usize, usize); 16] {
775        match tier {
776            QuantizationTier::Full => &HEALTHY_FULL_QUERY_QUALITY,
777            QuantizationTier::Int8 => &HEALTHY_INT8_QUERY_QUALITY,
778            QuantizationTier::Int4 => &HEALTHY_INT4_QUERY_QUALITY,
779            QuantizationTier::Binary => &HEALTHY_BINARY_QUERY_QUALITY,
780        }
781    }
782
783    fn retrieval_quality_movement_budget(healthy: &[(usize, usize)]) -> (usize, usize) {
784        let minimum_recall_hits = healthy.iter().map(|quality| quality.0).min().unwrap();
785        let maximum_recall_hits = healthy.iter().map(|quality| quality.0).max().unwrap();
786        let minimum_agreements = healthy.iter().map(|quality| quality.1).min().unwrap();
787        let maximum_agreements = healthy.iter().map(|quality| quality.1).max().unwrap();
788        (
789            maximum_recall_hits - minimum_recall_hits,
790            maximum_agreements - minimum_agreements,
791        )
792    }
793
794    /// Bounds concentration relative to each query's own healthy counts.
795    ///
796    /// Each cap is the ceiling of one sixteenth of the full healthy span, the smallest
797    /// integer allowance that admits the total budget's uniform per-query share. Binary
798    /// therefore permits one Recall@10 hit or 81 agreeing pairs to move on one query.
799    /// This catches concentrated cliffs; the retained L1 budget catches broad movement.
800    fn retrieval_quality_concentration_budget(healthy: &[(usize, usize)]) -> (usize, usize) {
801        let movement_budget = retrieval_quality_movement_budget(healthy);
802        (
803            movement_budget.0.div_ceil(healthy.len()),
804            movement_budget.1.div_ceil(healthy.len()),
805        )
806    }
807
808    fn validate_tier_retrieval_quality(
809        tier: QuantizationTier,
810        query_quality: &[(usize, usize)],
811        top_k: usize,
812    ) -> std::result::Result<(f64, f64), String> {
813        if query_quality.is_empty() {
814            return Err(format!("{tier:?} retrieval quality has zero queries"));
815        }
816
817        let healthy = healthy_query_quality(tier);
818        if query_quality.len() != healthy.len() {
819            return Err(format!(
820                "{tier:?} retrieval quality has {} queries, expected {}",
821                query_quality.len(),
822                healthy.len()
823            ));
824        }
825
826        let (minimum_query_recall, minimum_query_agreement) =
827            retrieval_quality_per_query_floor(tier);
828        for (query_index, &(recall_hits, agreements)) in query_quality.iter().enumerate() {
829            if recall_hits < minimum_query_recall || agreements < minimum_query_agreement {
830                return Err(format!(
831                    "{tier:?} query {query_index} fails the per-query floor: Recall@{top_k}=\
832                     {:.6} (minimum {:.6}), pairwise ranking agreement=\
833                     {:.6} (minimum {:.6})",
834                    recall_hits as f64 / top_k as f64,
835                    minimum_query_recall as f64 / top_k as f64,
836                    agreements as f64 / 32_640.0,
837                    minimum_query_agreement as f64 / 32_640.0,
838                ));
839            }
840        }
841
842        let recall_hits = query_quality.iter().map(|quality| quality.0).sum::<usize>();
843        let agreements = query_quality.iter().map(|quality| quality.1).sum::<usize>();
844        let recall = recall_hits as f64 / (query_quality.len() * top_k) as f64;
845        let agreement = agreements as f64 / (query_quality.len() * 32_640) as f64;
846        let (minimum_recall, minimum_agreement) = retrieval_quality_floor(tier);
847        if !meets_retrieval_quality_floor(recall, minimum_recall) {
848            return Err(format!(
849                "{tier:?} Recall@{top_k} {recall:.6} is below the measured-data floor \
850                 {minimum_recall:.3}"
851            ));
852        }
853        if !meets_retrieval_quality_floor(agreement, minimum_agreement) {
854            return Err(format!(
855                "{tier:?} pairwise ranking agreement {agreement:.6} is below the measured-data \
856                 floor {minimum_agreement:.3}"
857            ));
858        }
859
860        let (recall_movement, agreement_movement) = query_quality.iter().zip(healthy).fold(
861            (0usize, 0usize),
862            |(recall_movement, agreement_movement), (actual, expected)| {
863                (
864                    recall_movement + actual.0.abs_diff(expected.0),
865                    agreement_movement + actual.1.abs_diff(expected.1),
866                )
867            },
868        );
869        let (recall_budget, agreement_budget) = retrieval_quality_movement_budget(healthy);
870        if recall_movement > recall_budget || agreement_movement > agreement_budget {
871            return Err(format!(
872                "{tier:?} retrieval quality exceeds the fixture-relative movement budget: \
873                 total absolute Recall@{top_k} movement={recall_movement} hit(s) \
874                 (maximum {recall_budget}), total absolute pairwise-agreement movement=\
875                 {agreement_movement} pair(s) (maximum {agreement_budget})"
876            ));
877        }
878
879        let (maximum_query_recall_movement, maximum_query_agreement_movement) =
880            retrieval_quality_concentration_budget(healthy);
881        for (query_index, (&(recall_hits, agreements), &(healthy_hits, healthy_agreements))) in
882            query_quality.iter().zip(healthy).enumerate()
883        {
884            let recall_movement = recall_hits.abs_diff(healthy_hits);
885            let agreement_movement = agreements.abs_diff(healthy_agreements);
886            if recall_movement > maximum_query_recall_movement
887                || agreement_movement > maximum_query_agreement_movement
888            {
889                return Err(format!(
890                    "{tier:?} query {query_index} exceeds the fixture-relative concentration \
891                     bound: Recall@{top_k} movement={recall_movement} hit(s) \
892                     (maximum {maximum_query_recall_movement}), pairwise-agreement \
893                     movement={agreement_movement} pair(s) \
894                     (maximum {maximum_query_agreement_movement})"
895                ));
896            }
897        }
898        Ok((recall, agreement))
899    }
900
901    /// Refuses to let a retrieval-fidelity fixture be scored when it cannot
902    /// distinguish quality tiers from each other.
903    ///
904    /// Only the independent f64 reference ranking is checked here — ties
905    /// among *quantized* tier scores are the exact behaviour under test
906    /// (e.g. Binary legitimately collapses many candidates to the same
907    /// Hamming distance) and must never be rejected.
908    fn validate_retrieval_fixture(
909        corpus: &[Vec<f32>],
910        queries: &[Vec<f32>],
911        top_k: usize,
912    ) -> std::result::Result<(), String> {
913        if top_k == 0 {
914            return Err("retrieval fixture has top_k=0".to_string());
915        }
916        if queries.is_empty() {
917            return Err("retrieval fixture has zero queries".to_string());
918        }
919        if corpus.len() < top_k {
920            return Err(format!(
921                "retrieval fixture corpus size {} is smaller than top_k={top_k}",
922                corpus.len()
923            ));
924        }
925        for (query_index, query) in queries.iter().enumerate() {
926            let mut ranked_scores = Vec::with_capacity(corpus.len());
927            for (candidate_index, candidate) in corpus.iter().enumerate() {
928                let score = scalar_cosine_f64(query, candidate);
929                if !score.is_finite() {
930                    return Err(format!(
931                        "retrieval fixture query {query_index} candidate {candidate_index} has \
932                         non-finite reference score {score}"
933                    ));
934                }
935                ranked_scores.push((candidate_index, score));
936            }
937            ranked_scores.sort_unstable_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
938
939            let mut distinct_scores: Vec<_> =
940                ranked_scores.iter().map(|(_, score)| *score).collect();
941            distinct_scores.sort_unstable_by(f64::total_cmp);
942            distinct_scores.dedup();
943            if distinct_scores.len() <= top_k {
944                return Err(format!(
945                    "retrieval fixture query {query_index} is non-discriminating: only \
946                     {} distinct finite reference score(s) across {} candidates, need \
947                     more than top_k={top_k}",
948                    distinct_scores.len(),
949                    corpus.len()
950                ));
951            }
952
953            for (rank, boundary) in ranked_scores.windows(2).enumerate() {
954                let higher_score = boundary[0].1;
955                let lower_score = boundary[1].1;
956                if higher_score as f32 <= lower_score as f32 {
957                    return Err(format!(
958                        "retrieval fixture query {query_index} has a near-tied ranking boundary \
959                         at ranks {rank} and {}: reference scores {higher_score} and \
960                         {lower_score} do not remain ordered at f32 precision",
961                        rank + 1
962                    ));
963                }
964            }
965        }
966
967        let (surrogate_recall, surrogate_agreement) =
968            index_order_surrogate_quality(corpus, queries, top_k);
969        for tier in [
970            QuantizationTier::Full,
971            QuantizationTier::Int8,
972            QuantizationTier::Int4,
973            QuantizationTier::Binary,
974        ] {
975            let (minimum_recall, minimum_agreement) = retrieval_quality_floor(tier);
976            if meets_retrieval_quality_floor(surrogate_recall, minimum_recall)
977                && meets_retrieval_quality_floor(surrogate_agreement, minimum_agreement)
978            {
979                return Err(format!(
980                    "retrieval fixture is non-discriminating: an all-tied index-order surrogate \
981                     passes the {tier:?} floor with Recall@{top_k}={surrogate_recall:.6} and \
982                     pairwise ranking agreement={surrogate_agreement:.6}"
983                ));
984            }
985        }
986        Ok(())
987    }
988
989    fn fixed_retrieval_fixture() -> (Vec<Vec<f32>>, Vec<Vec<f32>>) {
990        const DIMS: usize = 384;
991        const CORPUS_SIZE: usize = 256;
992        const QUERY_COUNT: usize = 16;
993        let corpus = (0..CORPUS_SIZE)
994            .map(|index| generate_vector(DIMS, 0xC0A5_0000 + index as u64))
995            .collect();
996        let queries = (0..QUERY_COUNT)
997            .map(|index| generate_vector(DIMS, 0x0A11_0000 + index as u64))
998            .collect();
999        (corpus, queries)
1000    }
1001
1002    #[test]
1003    fn test_tier_retrieval_quality_against_independent_f64_ranking() {
1004        const TOP_K: usize = 10;
1005        let (corpus, queries) = fixed_retrieval_fixture();
1006        validate_retrieval_fixture(&corpus, &queries, TOP_K)
1007            .expect("retrieval fixture must be discriminating before scoring tiers against it");
1008
1009        for tier in [
1010            QuantizationTier::Full,
1011            QuantizationTier::Int8,
1012            QuantizationTier::Int4,
1013            QuantizationTier::Binary,
1014        ] {
1015            let stored: Vec<_> = corpus
1016                .iter()
1017                .map(|candidate| QuantizedData::from_f32(candidate, tier))
1018                .collect();
1019            assert!(
1020                stored.iter().all(|candidate| candidate.tier() == tier),
1021                "{tier:?} conversion was bypassed or routed to another tier"
1022            );
1023            assert!(
1024                stored
1025                    .iter()
1026                    .all(|candidate| candidate.storage_bytes()
1027                        == tier.storage_bytes(candidate.dims())),
1028                "{tier:?} conversion produced the wrong representation size"
1029            );
1030
1031            if tier != QuantizationTier::Full {
1032                assert!(
1033                    stored.iter().zip(&corpus).any(|(quantized, original)| {
1034                        quantized
1035                            .to_f32()
1036                            .iter()
1037                            .zip(original)
1038                            .any(|(actual, expected)| (actual - expected).abs() > 1e-4)
1039                    }),
1040                    "{tier:?} conversion did not exercise a lossy representation"
1041                );
1042            }
1043
1044            let mut query_quality = Vec::with_capacity(queries.len());
1045            let mut quantized_distance_witness = false;
1046            for query in &queries {
1047                let reference = reference_ranking(query, &corpus);
1048                let actual = tier_ranking(query, &stored, tier);
1049                query_quality.push(retrieval_quality_counts(&reference, &actual, TOP_K));
1050
1051                if tier != QuantizationTier::Full {
1052                    let prepared = PreparedQuery::from_f32(query, tier);
1053                    quantized_distance_witness |=
1054                        stored.iter().zip(&corpus).any(|(quantized, original)| {
1055                            let actual =
1056                                approximate_cosine_distance_prepared(&prepared, quantized).unwrap();
1057                            let reference = 1.0 - scalar_cosine_f64(query, original) as f32;
1058                            (actual - reference).abs() > 1e-4
1059                        });
1060                }
1061            }
1062            let (recall, agreement) = validate_tier_retrieval_quality(tier, &query_quality, TOP_K)
1063                .unwrap_or_else(|error| panic!("{error}"));
1064            eprintln!(
1065                "{tier:?}: Recall@{TOP_K}={recall:.6}, pairwise ranking agreement={agreement:.6}"
1066            );
1067
1068            if tier != QuantizationTier::Full {
1069                assert!(
1070                    quantized_distance_witness,
1071                    "{tier:?} distance path did not differ from the independent f32 reference"
1072                );
1073            }
1074        }
1075    }
1076
1077    #[test]
1078    fn test_tier_retrieval_quality_rejects_concentrated_binary_query_collapse() {
1079        const TOP_K: usize = 10;
1080        const COLLAPSED_QUERY_COUNT: usize = 10;
1081        let (corpus, queries) = fixed_retrieval_fixture();
1082        let index_order: Vec<_> = (0..corpus.len()).collect();
1083        let query_quality: Vec<_> = queries
1084            .iter()
1085            .enumerate()
1086            .map(|(query_index, query)| {
1087                let reference = reference_ranking(query, &corpus);
1088                let actual = if query_index < COLLAPSED_QUERY_COUNT {
1089                    index_order.clone()
1090                } else {
1091                    reference.clone()
1092                };
1093                retrieval_quality_counts(&reference, &actual, TOP_K)
1094            })
1095            .collect();
1096
1097        let mean_recall = query_quality.iter().map(|quality| quality.0).sum::<usize>() as f64
1098            / (query_quality.len() * TOP_K) as f64;
1099        let mean_agreement = query_quality.iter().map(|quality| quality.1).sum::<usize>() as f64
1100            / (query_quality.len() * 32_640) as f64;
1101        assert_eq!(format!("{mean_recall:.6}"), "0.381250");
1102        assert_eq!(format!("{mean_agreement:.6}"), "0.705356");
1103
1104        let error = validate_tier_retrieval_quality(
1105            QuantizationTier::Binary,
1106            &query_quality,
1107            TOP_K,
1108        )
1109        .expect_err(
1110            "collapsing fixed fixture queries 0 through 9 must fail despite passing both means",
1111        );
1112        assert!(error.contains("query 0"), "unexpected error: {error}");
1113    }
1114
1115    #[test]
1116    fn test_tier_retrieval_quality_rejects_single_binary_query_concentration() {
1117        const TOP_K: usize = 10;
1118        const SUFFIX_INVERSIONS: usize = 7_161;
1119        let (corpus, queries) = fixed_retrieval_fixture();
1120        let reference = reference_ranking(&queries[0], &corpus);
1121        let mut remaining = reference[3..10]
1122            .iter()
1123            .chain(&reference[17..])
1124            .copied()
1125            .collect::<Vec<_>>();
1126        let mut suffix = Vec::with_capacity(remaining.len());
1127        let mut inversions = SUFFIX_INVERSIONS;
1128        while !remaining.is_empty() {
1129            let index = inversions.min(remaining.len() - 1);
1130            inversions -= index;
1131            suffix.push(remaining.remove(index));
1132        }
1133        assert_eq!(inversions, 0);
1134
1135        let mut actual = Vec::with_capacity(reference.len());
1136        actual.extend_from_slice(&reference[..3]);
1137        actual.extend_from_slice(&reference[10..17]);
1138        actual.extend(suffix);
1139        assert_eq!(actual.len(), reference.len());
1140
1141        let collapsed_quality = retrieval_quality_counts(&reference, &actual, TOP_K);
1142        assert_eq!(collapsed_quality, (3, 25_430));
1143
1144        let mut query_quality = healthy_query_quality(QuantizationTier::Binary).to_vec();
1145        query_quality[0] = collapsed_quality;
1146
1147        let error =
1148            validate_tier_retrieval_quality(QuantizationTier::Binary, &query_quality, TOP_K)
1149                .expect_err("one Binary query must not spend the complete fixture movement budget");
1150        assert!(
1151            error.contains("query 0") && error.contains("concentration"),
1152            "unexpected error: {error}"
1153        );
1154    }
1155
1156    #[test]
1157    fn test_tier_retrieval_quality_rejects_single_binary_query_agreement_concentration() {
1158        const TOP_K: usize = 10;
1159        let mut query_quality = healthy_query_quality(QuantizationTier::Binary).to_vec();
1160        query_quality[0].1 -= 1_281;
1161
1162        let error =
1163            validate_tier_retrieval_quality(QuantizationTier::Binary, &query_quality, TOP_K)
1164                .expect_err("one Binary query must not spend nearly the complete pair budget");
1165        assert!(
1166            error.contains("query 0") && error.contains("concentration"),
1167            "unexpected error: {error}"
1168        );
1169    }
1170
1171    #[test]
1172    fn test_tier_retrieval_quality_rejects_distributed_binary_query_collapse() {
1173        const TOP_K: usize = 10;
1174        let (corpus, queries) = fixed_retrieval_fixture();
1175        let query_quality: Vec<_> = queries
1176            .iter()
1177            .map(|query| {
1178                let reference = reference_ranking(query, &corpus);
1179                let mut actual = Vec::with_capacity(reference.len());
1180                actual.extend_from_slice(&reference[..3]);
1181                actual.extend_from_slice(&reference[10..17]);
1182                actual.extend_from_slice(&reference[3..10]);
1183                actual.extend(reference[17..154].iter().rev().copied());
1184                actual.extend_from_slice(&reference[154..]);
1185                assert_eq!(actual.len(), reference.len());
1186                retrieval_quality_counts(&reference, &actual, TOP_K)
1187            })
1188            .collect();
1189
1190        assert!(
1191            query_quality
1192                .iter()
1193                .all(|&(recall, agreement)| { recall == 3 && agreement == 32_640 - 9_365 })
1194        );
1195
1196        let error =
1197            validate_tier_retrieval_quality(QuantizationTier::Binary, &query_quality, TOP_K)
1198                .expect_err("a distributed 70% Recall@10 loss across every query must fail");
1199        assert!(
1200            error.contains("movement budget"),
1201            "unexpected error: {error}"
1202        );
1203    }
1204
1205    #[test]
1206    fn test_tier_retrieval_quality_rejects_shallow_all_query_movement() {
1207        const TOP_K: usize = 10;
1208        let (corpus, queries) = fixed_retrieval_fixture();
1209        let query_quality: Vec<_> = queries
1210            .iter()
1211            .map(|query| {
1212                let reference = reference_ranking(query, &corpus);
1213                let mut actual = reference.clone();
1214                let last = actual.len() - 1;
1215                actual.swap(TOP_K - 1, last);
1216                retrieval_quality_counts(&reference, &actual, TOP_K)
1217            })
1218            .collect();
1219
1220        assert!(
1221            query_quality
1222                .iter()
1223                .all(|&(recall, agreement)| { recall == 9 && agreement == 32_640 - 491 })
1224        );
1225
1226        for (tier, result) in [
1227            (
1228                QuantizationTier::Int4,
1229                validate_tier_retrieval_quality(QuantizationTier::Int4, &query_quality, TOP_K),
1230            ),
1231            (
1232                QuantizationTier::Binary,
1233                validate_tier_retrieval_quality(QuantizationTier::Binary, &query_quality, TOP_K),
1234            ),
1235        ] {
1236            let error = result.unwrap_err();
1237            assert!(
1238                error.contains("movement budget"),
1239                "unexpected {tier:?} error: {error}"
1240            );
1241        }
1242    }
1243
1244    #[test]
1245    fn test_tier_retrieval_quality_bounds_stated_binary_uniform_movement() {
1246        const TOP_K: usize = 10;
1247        let query_quality_with_pair_loss = |pair_loss: u16| {
1248            healthy_query_quality(QuantizationTier::Binary)
1249                .iter()
1250                .map(|&(recall_hits, agreements)| {
1251                    (recall_hits, agreements - usize::from(pair_loss))
1252                })
1253                .collect::<Vec<_>>()
1254        };
1255
1256        validate_tier_retrieval_quality(
1257            QuantizationTier::Binary,
1258            &query_quality_with_pair_loss(80),
1259            TOP_K,
1260        )
1261        .expect("1,280 total agreement-pair changes are within the 1,282-pair budget");
1262
1263        let error = validate_tier_retrieval_quality(
1264            QuantizationTier::Binary,
1265            &query_quality_with_pair_loss(81),
1266            TOP_K,
1267        )
1268        .expect_err("1,296 total agreement-pair changes must exceed the 1,282-pair budget");
1269        assert!(
1270            error.contains("movement budget"),
1271            "unexpected error: {error}"
1272        );
1273    }
1274
1275    #[test]
1276    fn test_tier_retrieval_quality_accepts_non_uniform_exact_binary_movement_boundary() {
1277        const TOP_K: usize = 10;
1278        let query_quality = healthy_query_quality(QuantizationTier::Binary)
1279            .iter()
1280            .enumerate()
1281            .map(|(query_index, &(recall_hits, agreements))| {
1282                let pair_loss = if query_index < 14 { 81 } else { 74 };
1283                (recall_hits, agreements - pair_loss)
1284            })
1285            .collect::<Vec<_>>();
1286
1287        validate_tier_retrieval_quality(QuantizationTier::Binary, &query_quality, TOP_K)
1288            .expect("the exact 1,282-pair non-uniform movement boundary must be accepted");
1289    }
1290
1291    #[test]
1292    fn test_validate_retrieval_fixture_rejects_non_discriminating_corpus() {
1293        const TOP_K: usize = 10;
1294        const DIMS: usize = 384;
1295        const CORPUS_SIZE: usize = 256;
1296        const QUERY_COUNT: usize = 16;
1297
1298        // Every candidate is the *same* lossy, non-constant vector: the
1299        // independent f64 reference score ties across the whole corpus, so
1300        // recall@10 and pairwise agreement would collapse to a meaningless
1301        // 1.0 for every tier if this fixture were ever scored.
1302        let repeated_vector = generate_vector(DIMS, 0xC0A5_0000);
1303        let corpus: Vec<Vec<f32>> = std::iter::repeat_n(repeated_vector, CORPUS_SIZE).collect();
1304        let queries: Vec<Vec<f32>> = (0..QUERY_COUNT)
1305            .map(|index| generate_vector(DIMS, 0x0A11_0000 + index as u64))
1306            .collect();
1307
1308        let err = validate_retrieval_fixture(&corpus, &queries, TOP_K)
1309            .expect_err("a corpus of identical vectors ties every reference score; the guard must refuse rather than let it be scored");
1310        eprintln!("guard refused as expected: {err}");
1311    }
1312
1313    #[test]
1314    fn test_validate_retrieval_fixture_rejects_zero_queries() {
1315        let (corpus, _queries) = fixed_retrieval_fixture();
1316        let err = validate_retrieval_fixture(&corpus, &[], 10)
1317            .expect_err("zero queries must be refused rather than panic or silently pass");
1318        eprintln!("guard refused as expected: {err}");
1319    }
1320
1321    #[test]
1322    fn test_validate_retrieval_fixture_rejects_corpus_smaller_than_top_k() {
1323        let (corpus, queries) = fixed_retrieval_fixture();
1324        let small_corpus = corpus[..5].to_vec();
1325        let err = validate_retrieval_fixture(&small_corpus, &queries, 10).expect_err(
1326            "a corpus smaller than top_k must be refused rather than panic or return NaN",
1327        );
1328        eprintln!("guard refused as expected: {err}");
1329    }
1330
1331    #[test]
1332    fn test_validate_retrieval_fixture_accepts_the_real_fixture() {
1333        let (corpus, queries) = fixed_retrieval_fixture();
1334        validate_retrieval_fixture(&corpus, &queries, 10)
1335            .expect("the real fixture is discriminating and must not be rejected");
1336
1337        let (recall, agreement) = index_order_surrogate_quality(&corpus, &queries, 10);
1338        assert!((recall - 0.025).abs() < f64::EPSILON);
1339        assert!((agreement - 0.526_646_752_450_980_4).abs() < 1e-15);
1340        for (tier, minimum_recall, minimum_agreement) in [
1341            ("Full", 1.0, 0.999),
1342            ("Int8", 0.98, 0.995),
1343            ("Int4", 0.85, 0.95),
1344            ("Binary", 0.30, 0.70),
1345        ] {
1346            assert!(
1347                !meets_retrieval_quality_floor(recall, minimum_recall)
1348                    || !meets_retrieval_quality_floor(agreement, minimum_agreement),
1349                "index-order surrogate must fail the pinned {tier} floor"
1350            );
1351        }
1352    }
1353
1354    #[test]
1355    fn test_validate_retrieval_fixture_rejects_binary_index_aligned_collapse() {
1356        const TOP_K: usize = 10;
1357        let query = vec![1.0, 0.0];
1358        let corpus: Vec<_> = (0..21)
1359            .map(|index| vec![21.0 - index as f32, 1.0])
1360            .collect();
1361        let queries = vec![query.clone()];
1362
1363        let mut scores: Vec<_> = corpus
1364            .iter()
1365            .map(|candidate| scalar_cosine_f64(&query, candidate))
1366            .collect();
1367        scores.sort_unstable_by(f64::total_cmp);
1368        scores.dedup();
1369        assert_eq!(scores.len(), 21);
1370
1371        let reference = reference_ranking(&query, &corpus);
1372        let binary: Vec<_> = corpus
1373            .iter()
1374            .map(|candidate| QuantizedData::from_f32(candidate, QuantizationTier::Binary))
1375            .collect();
1376        let prepared = PreparedQuery::from_f32(&query, QuantizationTier::Binary);
1377        let first_code = match &binary[0] {
1378            QuantizedData::Binary(value) => &value.data,
1379            _ => unreachable!("binary conversion must produce the Binary variant"),
1380        };
1381        assert!(binary.iter().all(|candidate| {
1382            let QuantizedData::Binary(value) = candidate else {
1383                return false;
1384            };
1385            value.data == *first_code
1386                && approximate_cosine_distance_prepared(&prepared, candidate).unwrap() == 0.0
1387        }));
1388        let actual = tier_ranking(&query, &binary, QuantizationTier::Binary);
1389        assert_eq!(reference, (0..21).collect::<Vec<_>>());
1390        assert_eq!(actual, reference);
1391        assert_eq!(recall_at(&reference, &actual, TOP_K), 1.0);
1392        assert_eq!(pairwise_ranking_agreement(&reference, &actual), 1.0);
1393
1394        let err = validate_retrieval_fixture(&corpus, &queries, TOP_K).expect_err(
1395            "an index-aligned reference must not let a totally collapsed tier pass its floors",
1396        );
1397        assert!(
1398            err.contains("index-order surrogate"),
1399            "unexpected error: {err}"
1400        );
1401    }
1402
1403    #[test]
1404    fn test_validate_retrieval_fixture_rejects_mixed_non_finite_scores() {
1405        for non_finite in [f32::NAN, f32::INFINITY] {
1406            let (mut corpus, queries) = fixed_retrieval_fixture();
1407            corpus[0][0] = non_finite;
1408            let err = validate_retrieval_fixture(&corpus, &queries, 10)
1409                .expect_err("the first non-finite reference score must invalidate the oracle");
1410            assert!(
1411                err.contains("query 0 candidate 0"),
1412                "unexpected error: {err}"
1413            );
1414            assert!(err.contains("non-finite"), "unexpected error: {err}");
1415        }
1416    }
1417
1418    #[test]
1419    fn test_validate_retrieval_fixture_rejects_zero_top_k() {
1420        let (corpus, queries) = fixed_retrieval_fixture();
1421        let err = validate_retrieval_fixture(&corpus, &queries, 0)
1422            .expect_err("top_k=0 must be refused before recall divides by zero");
1423        assert!(err.contains("top_k=0"), "unexpected error: {err}");
1424    }
1425
1426    #[test]
1427    fn test_validate_retrieval_fixture_rejects_near_tied_top_k_boundary() {
1428        let query = vec![1.0, 0.0];
1429        let corpus = vec![
1430            vec![0.0, 1.0],
1431            vec![1.0, 0.0],
1432            vec![1.0, 0.0001],
1433            vec![-1.0, 0.0],
1434        ];
1435        let best = scalar_cosine_f64(&query, &corpus[1]);
1436        let runner_up = scalar_cosine_f64(&query, &corpus[2]);
1437        let boundary_gap = best - runner_up;
1438        assert!(boundary_gap > 0.0 && boundary_gap < 1e-6);
1439
1440        let err = validate_retrieval_fixture(&corpus, &[query], 1)
1441            .expect_err("an f64-only near-tie at the evaluated cutoff must be refused");
1442        assert!(err.contains("near-tied"), "unexpected error: {err}");
1443    }
1444
1445    #[test]
1446    fn test_validate_retrieval_fixture_rejects_near_tied_pairwise_boundary() {
1447        let query = vec![1.0, 0.0];
1448        let corpus = vec![
1449            vec![-1.0, 0.0],
1450            vec![1.0, 0.0],
1451            vec![100.0, 1.0],
1452            vec![100.0, 1.0001],
1453        ];
1454        let second = scalar_cosine_f64(&query, &corpus[2]);
1455        let third = scalar_cosine_f64(&query, &corpus[3]);
1456        assert!(second > third);
1457        assert_eq!(second as f32, third as f32);
1458
1459        let err = validate_retrieval_fixture(&corpus, &[query], 1)
1460            .expect_err("an f64-only near-tie evaluated by pairwise agreement must be refused");
1461        assert!(err.contains("near-tied"), "unexpected error: {err}");
1462    }
1463
1464    #[test]
1465    fn test_tier_bytes_per_dim() {
1466        assert_eq!(QuantizationTier::Full.bytes_per_dim(), 4.0);
1467        assert_eq!(QuantizationTier::Int8.bytes_per_dim(), 1.0);
1468        assert_eq!(QuantizationTier::Int4.bytes_per_dim(), 0.5);
1469        assert_eq!(QuantizationTier::Binary.bytes_per_dim(), 0.125);
1470    }
1471
1472    #[test]
1473    fn test_tier_compression_ratios() {
1474        assert_eq!(QuantizationTier::Full.compression_ratio(), 1.0);
1475        assert_eq!(QuantizationTier::Int8.compression_ratio(), 4.0);
1476        assert_eq!(QuantizationTier::Int4.compression_ratio(), 8.0);
1477        assert_eq!(QuantizationTier::Binary.compression_ratio(), 32.0);
1478    }
1479
1480    #[test]
1481    fn test_tier_storage_bytes() {
1482        assert_eq!(QuantizationTier::Full.storage_bytes(384), 1536);
1483        assert_eq!(QuantizationTier::Int8.storage_bytes(384), 384);
1484        assert_eq!(QuantizationTier::Int4.storage_bytes(384), 192);
1485        assert_eq!(QuantizationTier::Binary.storage_bytes(384), 48);
1486    }
1487
1488    #[test]
1489    fn test_tier_from_age() {
1490        assert_eq!(
1491            QuantizationTier::from_age_seconds(0),
1492            QuantizationTier::Full
1493        );
1494        assert_eq!(
1495            QuantizationTier::from_age_seconds(1800),
1496            QuantizationTier::Full
1497        ); // 30 min
1498        assert_eq!(
1499            QuantizationTier::from_age_seconds(7200),
1500            QuantizationTier::Int8
1501        ); // 2 hours
1502        assert_eq!(
1503            QuantizationTier::from_age_seconds(172800),
1504            QuantizationTier::Int4
1505        ); // 2 days
1506        assert_eq!(
1507            QuantizationTier::from_age_seconds(1_000_000),
1508            QuantizationTier::Binary
1509        ); // ~11 days
1510    }
1511
1512    #[test]
1513    fn test_quantized_data_from_f32_all_tiers() {
1514        let v = generate_vector(384, 42);
1515
1516        for tier in [
1517            QuantizationTier::Full,
1518            QuantizationTier::Int8,
1519            QuantizationTier::Int4,
1520            QuantizationTier::Binary,
1521        ] {
1522            let data = QuantizedData::from_f32(&v, tier);
1523            assert_eq!(data.tier(), tier, "tier mismatch for {tier:?}");
1524            assert_eq!(data.dims(), 384, "dims mismatch for {tier:?}");
1525
1526            // Verify storage bytes match expected
1527            let expected_bytes = tier.storage_bytes(384);
1528            assert_eq!(
1529                data.storage_bytes(),
1530                expected_bytes,
1531                "storage bytes mismatch for {tier:?}"
1532            );
1533        }
1534    }
1535
1536    #[test]
1537    fn test_approximate_cosine_distance_ordering() {
1538        // Vectors a and b should be "closer" than a and c.
1539        let a = generate_vector(384, 1);
1540        // b = a + small noise
1541        let b: Vec<f32> = a
1542            .iter()
1543            .enumerate()
1544            .map(|(i, &x)| x + 0.05 * (i as f32 * 0.3).sin())
1545            .collect();
1546        // c = random, uncorrelated
1547        let c = generate_vector(384, 999);
1548
1549        for tier in [
1550            QuantizationTier::Full,
1551            QuantizationTier::Int8,
1552            QuantizationTier::Int4,
1553            QuantizationTier::Binary,
1554        ] {
1555            let stored_b = QuantizedData::from_f32(&b, tier);
1556            let stored_c = QuantizedData::from_f32(&c, tier);
1557
1558            let dist_ab = approximate_cosine_distance(&a, &stored_b);
1559            let dist_ac = approximate_cosine_distance(&a, &stored_c);
1560
1561            // a should be closer to b than to c at all tiers
1562            assert!(
1563                dist_ab < dist_ac,
1564                "{tier:?}: dist(a,b)={dist_ab} should be < dist(a,c)={dist_ac}"
1565            );
1566        }
1567    }
1568
1569    #[test]
1570    fn test_promote_demote_roundtrip() {
1571        let v = generate_vector(384, 42);
1572        let binary = QuantizedData::from_f32(&v, QuantizationTier::Binary);
1573
1574        // Promote Binary -> Int4 -> Int8 -> Full
1575        let int4 = binary.promote(QuantizationTier::Int4);
1576        assert_eq!(int4.tier(), QuantizationTier::Int4);
1577
1578        let int8 = int4.promote(QuantizationTier::Int8);
1579        assert_eq!(int8.tier(), QuantizationTier::Int8);
1580
1581        let full = int8.promote(QuantizationTier::Full);
1582        assert_eq!(full.tier(), QuantizationTier::Full);
1583        assert_eq!(full.dims(), 384);
1584    }
1585
1586    #[test]
1587    fn test_int8_batch_prepared_matches_per_item_prepared() {
1588        let query = generate_vector(384, 42);
1589        let prepared = PreparedQuery::from_f32(&query, QuantizationTier::Int8);
1590        let candidates: Vec<QuantizedVector> = (0..32)
1591            .map(|i| QuantizedVector::from_f32(&generate_vector(384, i + 1)))
1592            .collect();
1593        let wrapped: Vec<QuantizedData> = candidates
1594            .iter()
1595            .cloned()
1596            .map(QuantizedData::Int8)
1597            .collect();
1598
1599        let got = approximate_int8_batch_prepared(&prepared, &candidates).unwrap();
1600        for (i, item) in wrapped.iter().enumerate() {
1601            let expected = approximate_cosine_distance_prepared(&prepared, item).unwrap();
1602            assert!(
1603                (got[i] - expected).abs() < 1e-6,
1604                "int8 batch prepared mismatch at candidate {i}: got={}, expected={}",
1605                got[i],
1606                expected
1607            );
1608        }
1609    }
1610
1611    #[test]
1612    fn test_int4_batch_prepared_matches_per_item_prepared() {
1613        let query = generate_vector(384, 42);
1614        let prepared = PreparedQuery::from_f32(&query, QuantizationTier::Int4);
1615        let candidates: Vec<Int4Vector> = (0..32)
1616            .map(|i| Int4Vector::from_f32(&generate_vector(384, i + 1)))
1617            .collect();
1618        let wrapped: Vec<QuantizedData> = candidates
1619            .iter()
1620            .cloned()
1621            .map(QuantizedData::Int4)
1622            .collect();
1623
1624        let got = approximate_int4_batch_prepared(&prepared, &candidates).unwrap();
1625        for (i, item) in wrapped.iter().enumerate() {
1626            let expected = approximate_cosine_distance_prepared(&prepared, item).unwrap();
1627            assert!(
1628                (got[i] - expected).abs() < 1e-5,
1629                "int4 batch prepared mismatch at candidate {i}: got={}, expected={}",
1630                got[i],
1631                expected
1632            );
1633        }
1634    }
1635
1636    #[test]
1637    fn test_int4_batch_prepared_api_dispatch_parity() {
1638        // Verify that approximate_int4_batch_prepared produces the same cosine distance
1639        // as approximate_cosine_distance_prepared for each candidate. On aarch64 both
1640        // sides dispatch to NEON; on other targets both use the packed scalar fallback.
1641        // For direct scalar-vs-NEON integer parity, see int4::tests::test_packed_scalar_matches_neon_exact.
1642        for dim in [1usize, 3, 31, 127, 383, 384] {
1643            let query = generate_vector(dim, 700 + dim as u64);
1644            let candidate = generate_vector(dim, 800 + dim as u64);
1645            let prepared = PreparedQuery::from_f32(&query, QuantizationTier::Int4);
1646            let q_cand = Int4Vector::from_f32(&candidate);
1647            let wrapped = QuantizedData::Int4(q_cand.clone());
1648
1649            let batch_result = approximate_int4_batch_prepared(&prepared, &[q_cand]).unwrap();
1650            let per_item_result =
1651                approximate_cosine_distance_prepared(&prepared, &wrapped).unwrap();
1652
1653            assert!(
1654                (batch_result[0] - per_item_result).abs() < 1e-5,
1655                "int4 batch prepared dispatch mismatch at dim={dim}: batch={}, per_item={}",
1656                batch_result[0],
1657                per_item_result
1658            );
1659        }
1660    }
1661
1662    #[test]
1663    fn test_quantized_data_to_f32_roundtrip() {
1664        let v = generate_vector(384, 55);
1665
1666        // Full tier should be lossless
1667        let full_data = QuantizedData::from_f32(&v, QuantizationTier::Full);
1668        let full_rt = full_data.to_f32();
1669        for (a, b) in v.iter().zip(full_rt.iter()) {
1670            assert!((a - b).abs() < 1e-10, "Full tier should be lossless");
1671        }
1672    }
1673
1674    // ------------------------------------------------------------------
1675    // Regression tests for issue #210: tier-mismatch in prepared SIMD
1676    // dispatch must return a typed error, not panic.
1677    // ------------------------------------------------------------------
1678
1679    #[test]
1680    fn test_cosine_distance_prepared_tier_mismatch_returns_typed_error() {
1681        let v = generate_vector(64, 1);
1682        let query = PreparedQuery::from_f32(&v, QuantizationTier::Int8);
1683        let stored = QuantizedData::from_f32(&v, QuantizationTier::Int4);
1684
1685        let err = approximate_cosine_distance_prepared(&query, &stored).unwrap_err();
1686        match err {
1687            EmbedError::TierMismatch {
1688                op,
1689                expected,
1690                actual,
1691            } => {
1692                assert_eq!(op, "approximate_cosine_distance_prepared");
1693                assert_eq!(expected, QuantizationTier::Int4);
1694                assert_eq!(actual, QuantizationTier::Int8);
1695            }
1696            other => panic!("expected TierMismatch, got {other:?}"),
1697        }
1698
1699        // try_ alias must agree.
1700        assert!(try_approximate_cosine_distance_prepared(&query, &stored).is_err());
1701    }
1702
1703    #[test]
1704    fn test_dot_product_prepared_tier_mismatch_returns_typed_error() {
1705        let v = generate_vector(64, 2);
1706        let query = PreparedQuery::from_f32(&v, QuantizationTier::Full);
1707        let stored = QuantizedData::from_f32(&v, QuantizationTier::Int8);
1708
1709        let err = approximate_dot_product_prepared(&query, &stored).unwrap_err();
1710        assert!(
1711            matches!(
1712                err,
1713                EmbedError::TierMismatch {
1714                    op: "approximate_dot_product_prepared",
1715                    ..
1716                }
1717            ),
1718            "unexpected error variant: {err:?}"
1719        );
1720
1721        assert!(try_approximate_dot_product_prepared(&query, &stored).is_err());
1722    }
1723
1724    #[test]
1725    fn test_dot_product_prepared_binary_returns_typed_error_not_panic() {
1726        let v = generate_vector(64, 3);
1727        let query = PreparedQuery::from_f32(&v, QuantizationTier::Binary);
1728        let stored = QuantizedData::from_f32(&v, QuantizationTier::Binary);
1729
1730        let err = approximate_dot_product_prepared(&query, &stored).unwrap_err();
1731        assert!(
1732            matches!(err, EmbedError::Internal(_)),
1733            "unexpected error variant: {err:?}"
1734        );
1735    }
1736
1737    #[test]
1738    fn test_cosine_distance_prepared_with_meta_tier_mismatch_returns_typed_error() {
1739        let v = generate_vector(64, 4);
1740        let meta =
1741            PreparedQueryWithMeta::from_f32(&v, QuantizationTier::Full, NormalizationHint::Unknown);
1742        let stored = QuantizedData::from_f32(&v, QuantizationTier::Int8);
1743
1744        let err = approximate_cosine_distance_prepared_with_meta(
1745            &meta,
1746            &stored,
1747            NormalizationHint::Unknown,
1748        )
1749        .unwrap_err();
1750        assert!(matches!(err, EmbedError::TierMismatch { .. }));
1751    }
1752
1753    #[test]
1754    fn test_cosine_distance_prepared_with_meta_validates_stored_unit_norm() {
1755        let query = vec![std::f32::consts::FRAC_1_SQRT_2; 2];
1756        let meta = PreparedQueryWithMeta::from_f32(
1757            &query,
1758            QuantizationTier::Full,
1759            NormalizationHint::Unit,
1760        );
1761        let stored = QuantizedData::Full(vec![2.0, 0.0]);
1762
1763        let got =
1764            approximate_cosine_distance_prepared_with_meta(&meta, &stored, NormalizationHint::Unit)
1765                .unwrap();
1766        let expected = approximate_cosine_distance_prepared(&meta.query, &stored).unwrap();
1767
1768        assert!(
1769            (got - expected).abs() < 1e-6,
1770            "got={got}, expected={expected}"
1771        );
1772    }
1773
1774    #[test]
1775    fn test_batch_cosine_distance_prepared_tier_mismatch_returns_typed_error() {
1776        let v = generate_vector(64, 5);
1777        let query = PreparedQuery::from_f32(&v, QuantizationTier::Int8);
1778        let stored = vec![
1779            QuantizedData::from_f32(&v, QuantizationTier::Int8),
1780            QuantizedData::from_f32(&v, QuantizationTier::Int4), // mismatched
1781        ];
1782
1783        let err = batch_approximate_cosine_distance_prepared(&query, &stored).unwrap_err();
1784        assert!(matches!(err, EmbedError::TierMismatch { .. }));
1785
1786        let mut out = vec![9.0, 9.0, 9.0]; // pre-populated, must be cleared even on error
1787        let err =
1788            batch_approximate_cosine_distance_prepared_into(&query, &stored, &mut out).unwrap_err();
1789        assert!(matches!(err, EmbedError::TierMismatch { .. }));
1790        assert!(
1791            out.is_empty(),
1792            "buffer must be cleared, not left with stale data"
1793        );
1794    }
1795
1796    #[test]
1797    fn test_int8_batch_prepared_wrong_tier_returns_typed_error() {
1798        let v = generate_vector(64, 6);
1799        let query = PreparedQuery::from_f32(&v, QuantizationTier::Int4); // not Int8
1800        let candidates = vec![QuantizedVector::from_f32(&v)];
1801
1802        let err = approximate_int8_batch_prepared(&query, &candidates).unwrap_err();
1803        match err {
1804            EmbedError::TierMismatch {
1805                op,
1806                expected,
1807                actual,
1808            } => {
1809                assert_eq!(op, "approximate_int8_batch_prepared");
1810                assert_eq!(expected, QuantizationTier::Int8);
1811                assert_eq!(actual, QuantizationTier::Int4);
1812            }
1813            other => panic!("expected TierMismatch, got {other:?}"),
1814        }
1815
1816        let mut out = vec![9.0];
1817        let err = approximate_int8_batch_prepared_into(&query, &candidates, &mut out).unwrap_err();
1818        assert!(matches!(err, EmbedError::TierMismatch { .. }));
1819        assert!(
1820            out.is_empty(),
1821            "buffer must be cleared, not left with stale data"
1822        );
1823    }
1824
1825    #[test]
1826    fn test_int4_batch_prepared_wrong_tier_returns_typed_error() {
1827        let v = generate_vector(64, 7);
1828        let query = PreparedQuery::from_f32(&v, QuantizationTier::Int8); // not Int4
1829        let candidates = vec![Int4Vector::from_f32(&v)];
1830
1831        let err = approximate_int4_batch_prepared(&query, &candidates).unwrap_err();
1832        match err {
1833            EmbedError::TierMismatch {
1834                op,
1835                expected,
1836                actual,
1837            } => {
1838                assert_eq!(op, "approximate_int4_batch_prepared");
1839                assert_eq!(expected, QuantizationTier::Int4);
1840                assert_eq!(actual, QuantizationTier::Int8);
1841            }
1842            other => panic!("expected TierMismatch, got {other:?}"),
1843        }
1844
1845        let mut out = vec![9.0];
1846        let err = approximate_int4_batch_prepared_into(&query, &candidates, &mut out).unwrap_err();
1847        assert!(matches!(err, EmbedError::TierMismatch { .. }));
1848        assert!(
1849            out.is_empty(),
1850            "buffer must be cleared, not left with stale data"
1851        );
1852    }
1853}