Skip to main content

zeppelin_embed/quant/
rescore.rs

1//! Deterministic two-stage coarse selection and exact f32 rescoring.
2
3use std::convert::Infallible;
4
5use crate::kernels::dot_f32;
6use crate::scan::ScanCandidate;
7use crate::scan::topk::BoundedTopK;
8
9/// Stored-data bytes touched by one two-stage query.
10#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
11pub struct SearchByteCounts {
12    /// Encoded row bytes and per-row factor bytes read by the coarse scan.
13    pub coarse: usize,
14    /// Full-precision row bytes read for exact candidate rescoring.
15    pub rescore: usize,
16    total: usize,
17}
18
19impl SearchByteCounts {
20    /// Returns coarse plus exact-rescore bytes.
21    #[must_use]
22    pub const fn total(self) -> usize {
23        self.total
24    }
25}
26
27/// One exact hit returned from the rescore stage.
28#[derive(Clone, Copy, Debug, PartialEq)]
29pub struct RescoreHit {
30    /// Zero-based row position in the supplied matrix.
31    pub row_index: usize,
32    /// Exact larger-is-better score under the requested full-precision metric.
33    pub score: f64,
34}
35
36/// Full-precision ranking applied to the retained coarse pool.
37#[derive(Clone, Copy, Debug, Eq, PartialEq)]
38pub enum RescoreMetric {
39    /// Runtime-dispatched task-03 f32 inner product.
40    InnerProduct,
41    /// Negative squared-L2 over the stored f32 row; larger remains better.
42    SquaredL2,
43}
44
45#[derive(Clone, Copy, Debug)]
46enum PoolWidth {
47    DenseOversample(usize),
48    Retained,
49}
50
51/// Describes either dense coarse scores or a graph traversal's retained sparse pool.
52#[derive(Clone, Copy, Debug)]
53pub struct RescorePool<'a> {
54    row_indices: Option<&'a [u32]>,
55    coarse_scores: &'a [f32],
56    width: PoolWidth,
57    metric: RescoreMetric,
58    coarse_rows_touched: usize,
59    coarse_bytes_per_row: usize,
60    prefetch: bool,
61}
62
63impl<'a> RescorePool<'a> {
64    /// Selects `k * oversample` rows from one dense coarse score per stored row.
65    #[must_use]
66    pub const fn dense(
67        coarse_scores: &'a [f32],
68        oversample: usize,
69        coarse_bytes_per_row: usize,
70    ) -> Self {
71        Self {
72            row_indices: None,
73            coarse_scores,
74            width: PoolWidth::DenseOversample(oversample),
75            metric: RescoreMetric::InnerProduct,
76            coarse_rows_touched: coarse_scores.len(),
77            coarse_bytes_per_row,
78            prefetch: false,
79        }
80    }
81
82    /// Rescores every row retained by a sparse graph traversal.
83    #[must_use]
84    pub const fn retained(
85        row_indices: &'a [u32],
86        coarse_scores: &'a [f32],
87        metric: RescoreMetric,
88        coarse_rows_touched: usize,
89        coarse_bytes_per_row: usize,
90    ) -> Self {
91        Self {
92            row_indices: Some(row_indices),
93            coarse_scores,
94            width: PoolWidth::Retained,
95            metric,
96            coarse_rows_touched,
97            coarse_bytes_per_row,
98            prefetch: false,
99        }
100    }
101
102    /// Selects whether exact-row reads issue the platform prefetch hint.
103    #[must_use]
104    pub const fn with_prefetch(mut self, prefetch: bool) -> Self {
105        self.prefetch = prefetch;
106        self
107    }
108}
109
110/// Exact top-k hits, candidate count, and byte accounting for both stages.
111#[derive(Clone, Debug, PartialEq)]
112pub struct RescoreResult {
113    /// Exact hits ordered by descending score and then ascending row index.
114    pub hits: Vec<RescoreHit>,
115    /// Number of coarse candidates read from full-precision storage.
116    pub candidates_rescored: usize,
117    /// Stored-data byte counters for this query.
118    pub bytes: SearchByteCounts,
119}
120
121/// Typed failure from the two-stage rescore helper.
122#[derive(Clone, Debug, Eq, PartialEq)]
123pub enum RescoreError {
124    /// The declared dimension was zero.
125    ZeroDimension,
126    /// The query width differed from the declared row width.
127    QueryDimension {
128        /// Declared row width.
129        expected: usize,
130        /// Supplied query width.
131        actual: usize,
132    },
133    /// The flat row matrix was not an exact multiple of the dimension.
134    RowDataLength {
135        /// Declared row width.
136        dimension: usize,
137        /// Supplied scalar count.
138        actual: usize,
139    },
140    /// The coarse score count differed from the matrix row count.
141    CoarseScoreCount {
142        /// Number of matrix rows.
143        expected: usize,
144        /// Supplied coarse score count.
145        actual: usize,
146    },
147    /// A retained graph pool supplied a different number of ids and scores.
148    CandidateRowCount {
149        /// Number of retained coarse scores.
150        expected: usize,
151        /// Number of retained row ids.
152        actual: usize,
153    },
154    /// A retained graph-pool row id was outside the f32 matrix.
155    CandidateRowOutOfRange {
156        /// Position inside the retained pool.
157        position: usize,
158        /// Invalid stored row index.
159        row_index: usize,
160        /// Available stored rows.
161        row_count: usize,
162    },
163    /// Exact top-k must request at least one hit.
164    ZeroK,
165    /// Oversampling must retain at least `k` coarse candidates.
166    ZeroOversample,
167    /// A per-segment candidate limit was zero.
168    ZeroCandidateLimit,
169    /// The requested frontier or its boundary ties exceeded the explicit limit.
170    CandidateLimitExceeded {
171        /// Candidate rows that would require exact scoring.
172        requested: usize,
173        /// Caller-supplied maximum per segment.
174        maximum: usize,
175    },
176    /// A coarse score was NaN or infinite.
177    NonFiniteCoarseScore {
178        /// Zero-based row position of the invalid score.
179        index: usize,
180    },
181    /// Exact f32-row scoring produced NaN or infinity.
182    NonFiniteExactScore {
183        /// Stored row whose exact score was invalid.
184        row_index: usize,
185    },
186    /// The supplied pool held fewer than `k` rows.
187    InsufficientCandidates {
188        /// Requested result count.
189        k: usize,
190        /// Rows available for exact rescore.
191        candidates: usize,
192    },
193    /// A candidate or byte-count multiplication overflowed `usize`.
194    ArithmeticOverflow,
195}
196
197impl std::fmt::Display for RescoreError {
198    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
199        match self {
200            Self::ZeroDimension => formatter.write_str("rescore dimension must not be zero"),
201            Self::QueryDimension { expected, actual } => write!(
202                formatter,
203                "rescore query dimension mismatch: expected {expected}, got {actual}"
204            ),
205            Self::RowDataLength { dimension, actual } => write!(
206                formatter,
207                "rescore row data length {actual} is not divisible by dimension {dimension}"
208            ),
209            Self::CoarseScoreCount { expected, actual } => write!(
210                formatter,
211                "rescore coarse score count mismatch: expected {expected}, got {actual}"
212            ),
213            Self::CandidateRowCount { expected, actual } => write!(
214                formatter,
215                "rescore retained row id count mismatch: expected {expected}, got {actual}"
216            ),
217            Self::CandidateRowOutOfRange {
218                position,
219                row_index,
220                row_count,
221            } => write!(
222                formatter,
223                "rescore retained row at position {position} is {row_index}, outside {row_count} rows"
224            ),
225            Self::ZeroK => formatter.write_str("rescore top-k must not be zero"),
226            Self::ZeroOversample => formatter.write_str("rescore oversample must not be zero"),
227            Self::ZeroCandidateLimit => {
228                formatter.write_str("rescore candidate limit must not be zero")
229            }
230            Self::CandidateLimitExceeded { requested, maximum } => write!(
231                formatter,
232                "rescore candidate count {requested} exceeds per-segment limit {maximum}"
233            ),
234            Self::NonFiniteCoarseScore { index } => {
235                write!(
236                    formatter,
237                    "rescore coarse score is non-finite at row {index}"
238                )
239            }
240            Self::NonFiniteExactScore { row_index } => {
241                write!(
242                    formatter,
243                    "rescore exact score is non-finite at row {row_index}"
244                )
245            }
246            Self::InsufficientCandidates { k, candidates } => write!(
247                formatter,
248                "rescore pool has {candidates} candidates, fewer than requested top-k {k}"
249            ),
250            Self::ArithmeticOverflow => formatter.write_str("rescore byte count overflowed usize"),
251        }
252    }
253}
254
255impl std::error::Error for RescoreError {}
256
257#[derive(Clone, Debug, Eq, PartialEq)]
258pub(crate) enum RescoreCheckError<E> {
259    Rescore(RescoreError),
260    Check(E),
261}
262
263impl<E> From<RescoreError> for RescoreCheckError<E> {
264    fn from(error: RescoreError) -> Self {
265        Self::Rescore(error)
266    }
267}
268
269fn validate_rescore_request(
270    query: &[f32],
271    rows: &[f32],
272    dimension: usize,
273    pool: &RescorePool<'_>,
274    k: usize,
275) -> Result<(usize, usize), RescoreError> {
276    if dimension == 0 {
277        return Err(RescoreError::ZeroDimension);
278    }
279    if query.len() != dimension {
280        return Err(RescoreError::QueryDimension {
281            expected: dimension,
282            actual: query.len(),
283        });
284    }
285    if !rows.len().is_multiple_of(dimension) {
286        return Err(RescoreError::RowDataLength {
287            dimension,
288            actual: rows.len(),
289        });
290    }
291    let row_count = rows.len() / dimension;
292    match pool.row_indices {
293        None if pool.coarse_scores.len() != row_count => {
294            return Err(RescoreError::CoarseScoreCount {
295                expected: row_count,
296                actual: pool.coarse_scores.len(),
297            });
298        }
299        Some(row_indices) if row_indices.len() != pool.coarse_scores.len() => {
300            return Err(RescoreError::CandidateRowCount {
301                expected: pool.coarse_scores.len(),
302                actual: row_indices.len(),
303            });
304        }
305        None | Some(_) => {}
306    }
307    if k == 0 {
308        return Err(RescoreError::ZeroK);
309    }
310    let candidate_count = match pool.width {
311        PoolWidth::DenseOversample(0) => return Err(RescoreError::ZeroOversample),
312        PoolWidth::DenseOversample(oversample) => {
313            let requested = k
314                .checked_mul(oversample)
315                .ok_or(RescoreError::ArithmeticOverflow)?;
316            pool.coarse_scores.len().min(requested)
317        }
318        PoolWidth::Retained => pool.coarse_scores.len(),
319    };
320    if candidate_count < k {
321        return Err(RescoreError::InsufficientCandidates {
322            k,
323            candidates: candidate_count,
324        });
325    }
326    if let Some((index, _)) = pool
327        .coarse_scores
328        .iter()
329        .enumerate()
330        .find(|(_, score)| !score.is_finite())
331    {
332        return Err(RescoreError::NonFiniteCoarseScore { index });
333    }
334    Ok((row_count, candidate_count))
335}
336
337/// Selects a coarse frontier and exactly rescores its best `k` rows.
338///
339/// `rows` is a contiguous row-major f32 matrix. A dense pool contains one
340/// larger-is-better score per row and retains `min(row_count, k * oversample)`.
341/// A sparse graph pool names the already-retained rows and rescores all of them.
342/// Row index is the deterministic tie-breaker in both modes.
343///
344/// Byte counters measure stored row data: the coarse stage touches
345/// `coarse_rows_touched * coarse_bytes_per_row`; the exact stage touches
346/// `frontier * dimension * 4`. Query bytes and output metadata are deliberately
347/// excluded because they are shared across schemes and are not corpus-row I/O.
348///
349/// # Errors
350///
351/// Returns [`RescoreError`] for invalid shapes, zero controls, non-finite
352/// coarse scores, or arithmetic overflow.
353pub fn rescore_top_k(
354    query: &[f32],
355    rows: &[f32],
356    dimension: usize,
357    pool: RescorePool<'_>,
358    k: usize,
359) -> Result<RescoreResult, RescoreError> {
360    match rescore_top_k_with_check(query, rows, dimension, pool, k, |_, _| {
361        Ok::<(), Infallible>(())
362    }) {
363        Ok(result) => Ok(result),
364        Err(RescoreCheckError::Rescore(error)) => Err(error),
365        Err(RescoreCheckError::Check(error)) => match error {},
366    }
367}
368
369pub(crate) fn rescore_top_k_with_check<E>(
370    query: &[f32],
371    rows: &[f32],
372    dimension: usize,
373    pool: RescorePool<'_>,
374    k: usize,
375    check: impl FnMut(usize, bool) -> Result<(), E>,
376) -> Result<RescoreResult, RescoreCheckError<E>> {
377    rescore_top_k_reusing(query, rows, dimension, pool, k, check, &mut NoScoreReuse)
378}
379
380/// The owner binds this cache to one query, physical source, revision, epoch
381/// and metric. Values retain f64 precision through ranking.
382pub(crate) trait ExactScoreReuse<E> {
383    fn get(&mut self, row: usize) -> Result<Option<f64>, E>;
384    fn insert(&mut self, row: usize, score: f64) -> Result<(), E>;
385}
386
387struct NoScoreReuse;
388
389impl<E> ExactScoreReuse<E> for NoScoreReuse {
390    fn get(&mut self, _row: usize) -> Result<Option<f64>, E> {
391        Ok(None)
392    }
393
394    fn insert(&mut self, _row: usize, _score: f64) -> Result<(), E> {
395        Ok(())
396    }
397}
398
399#[allow(clippy::too_many_arguments)]
400pub(crate) fn rescore_top_k_reusing<E>(
401    query: &[f32],
402    rows: &[f32],
403    dimension: usize,
404    pool: RescorePool<'_>,
405    k: usize,
406    mut check: impl FnMut(usize, bool) -> Result<(), E>,
407    reuse: &mut impl ExactScoreReuse<E>,
408) -> Result<RescoreResult, RescoreCheckError<E>> {
409    let (row_count, candidate_count) = validate_rescore_request(query, rows, dimension, &pool, k)?;
410
411    let candidates = match pool.width {
412        PoolWidth::DenseOversample(_) => {
413            let mut coarse_top_k = BoundedTopK::new(candidate_count);
414            for (row_id, &score) in pool.coarse_scores.iter().enumerate() {
415                coarse_top_k.push(ScanCandidate { row_id, score });
416            }
417            // Dense rescore consumes exactly `candidate_count` positions, so
418            // this physical drain deliberately discards boundary extras.
419            Some(coarse_top_k.into_sorted())
420        }
421        PoolWidth::Retained => None,
422    };
423
424    let mut exact = Vec::with_capacity(candidate_count);
425    let mut batch: Vec<(usize, &[f32])> = Vec::with_capacity(ROW_BATCH);
426    let mut scores: Vec<f64> = Vec::with_capacity(ROW_BATCH);
427    let mut candidates_rescored = 0_usize;
428    let mut position = 0;
429    while position < candidate_count {
430        let batch_end = position.saturating_add(ROW_BATCH).min(candidate_count);
431        batch.clear();
432        // A malformed pool is reported at the position that names the bad
433        // row, exactly as the row-at-a-time loop did: the batch keeps only
434        // the rows before it and the error is raised after their checks.
435        let mut resolve_error = None;
436        for batch_position in position..batch_end {
437            match resolve_row(
438                candidates.as_deref(),
439                pool.row_indices,
440                batch_position,
441                row_count,
442                rows,
443                dimension,
444            ) {
445                Ok(resolved) => batch.push(resolved),
446                Err(error) => {
447                    resolve_error = Some(error);
448                    break;
449                }
450            }
451        }
452        if pool.prefetch {
453            let ahead_end = batch_end.saturating_add(ROW_BATCH).min(candidate_count);
454            for ahead_position in batch_end..ahead_end {
455                if let Ok(ahead) = selected_row_index(
456                    candidates.as_deref(),
457                    pool.row_indices,
458                    ahead_position,
459                    row_count,
460                ) {
461                    prefetch_f32_row(rows, ahead, dimension);
462                }
463            }
464        }
465        // Scoring is pure, so hoisting the whole batch ahead of the checks
466        // is invisible: the checks, the non-finite rejection, and the pushes
467        // below still run in strict position order.
468        let mut prior = [None; ROW_BATCH];
469        let mut missing = [(0, &[][..]); ROW_BATCH];
470        let mut missing_count = 0;
471        for (resolved, cached) in batch.iter().zip(&mut prior) {
472            *cached = reuse.get(resolved.0).map_err(RescoreCheckError::Check)?;
473            if cached.is_none() {
474                *missing
475                    .get_mut(missing_count)
476                    .ok_or(RescoreError::ArithmeticOverflow)? = *resolved;
477                missing_count += 1;
478            }
479        }
480        let missing = missing
481            .get(..missing_count)
482            .ok_or(RescoreError::ArithmeticOverflow)?;
483        score_batch(query, missing, pool.metric, &mut scores);
484        candidates_rescored = candidates_rescored
485            .checked_add(missing.len())
486            .ok_or(RescoreError::ArithmeticOverflow)?;
487        let mut new_scores = scores.iter();
488        for (offset, ((row_id, _), cached)) in batch.iter().zip(prior).enumerate() {
489            let row_id = *row_id;
490            let score = match cached {
491                Some(score) => score,
492                None => *new_scores.next().ok_or(RescoreError::ArithmeticOverflow)?,
493            };
494            let scored_position = position.saturating_add(offset);
495            check(row_id, scored_position.is_multiple_of(64)).map_err(RescoreCheckError::Check)?;
496            if !score.is_finite() {
497                return Err(RescoreError::NonFiniteExactScore { row_index: row_id }.into());
498            }
499            if cached.is_none() {
500                reuse
501                    .insert(row_id, score)
502                    .map_err(RescoreCheckError::Check)?;
503            }
504            exact.push(RescoreHit {
505                row_index: row_id,
506                score,
507            });
508        }
509        if let Some(error) = resolve_error {
510            return Err(error.into());
511        }
512        position = batch_end;
513    }
514    if exact.len() > k {
515        let _ = exact.select_nth_unstable_by(k, rescore_hit_best_first);
516        exact.truncate(k);
517    }
518    exact.sort_unstable_by(rescore_hit_best_first);
519
520    let coarse = pool
521        .coarse_rows_touched
522        .checked_mul(pool.coarse_bytes_per_row)
523        .ok_or(RescoreError::ArithmeticOverflow)?;
524    let rescore = candidates_rescored
525        .checked_mul(dimension)
526        .and_then(|values| values.checked_mul(std::mem::size_of::<f32>()))
527        .ok_or(RescoreError::ArithmeticOverflow)?;
528    let total = coarse
529        .checked_add(rescore)
530        .ok_or(RescoreError::ArithmeticOverflow)?;
531
532    Ok(RescoreResult {
533        hits: exact,
534        candidates_rescored,
535        bytes: SearchByteCounts {
536            coarse,
537            rescore,
538            total,
539        },
540    })
541}
542
543/// Stream validated alive rows without a coarse-score or complete result buffer.
544/// Scoring is pure; checks and the sink still execute in physical input order.
545pub(crate) fn exact_squared_l2_with_sink<'a, E>(
546    query: &[f32],
547    mut rows: impl Iterator<Item = (usize, &'a [f32])>,
548    mut check: impl FnMut(usize, bool) -> Result<(), E>,
549    mut sink: impl FnMut(usize, f64) -> Result<(), E>,
550) -> Result<usize, RescoreCheckError<E>> {
551    let mut position = 0_usize;
552    let (mut current, mut len) = next_exact_batch(&mut rows);
553    loop {
554        if len == 0 {
555            return Ok(position);
556        }
557        // Read the next four eligible rows once, then reuse those references on
558        // the next iteration. Lookahead and score scratch stay on the stack.
559        let (next, next_len) = next_exact_batch(&mut rows);
560        for (_, row) in next.iter().take(next_len) {
561            prefetch_f32_row(row, 0, query.len());
562        }
563        let batch = current.get(..len).ok_or(RescoreError::ArithmeticOverflow)?;
564        let mut scores = [0.0_f64; ROW_BATCH];
565        if let [(_, first), (_, second), (_, third), (_, fourth)] = batch {
566            let (a, b, c, d) = squared_l2_f64_x4(query, first, second, third, fourth);
567            scores = [-a, -b, -c, -d];
568        } else {
569            for (score, (_, row)) in scores.iter_mut().zip(batch) {
570                *score = -squared_l2_f64(query, row);
571            }
572        }
573        for ((row_id, _), score) in batch.iter().zip(scores) {
574            check(*row_id, position.is_multiple_of(64)).map_err(RescoreCheckError::Check)?;
575            if !score.is_finite() {
576                return Err(RescoreError::NonFiniteExactScore { row_index: *row_id }.into());
577            }
578            sink(*row_id, score).map_err(RescoreCheckError::Check)?;
579            position += 1;
580        }
581        current = next;
582        len = next_len;
583    }
584}
585
586fn next_exact_batch<'a>(
587    rows: &mut impl Iterator<Item = (usize, &'a [f32])>,
588) -> ([(usize, &'a [f32]); ROW_BATCH], usize) {
589    let mut batch: [(usize, &[f32]); ROW_BATCH] = [(0, &[]); ROW_BATCH];
590    let mut len = 0;
591    for slot in &mut batch {
592        let Some(row) = rows.next() else {
593            break;
594        };
595        *slot = row;
596        len += 1;
597    }
598    (batch, len)
599}
600
601/// Rows scored in one call so that four independent f64 accumulator chains
602/// overlap. One row's exact squared-L2 sum is a strictly sequential chain of
603/// dependent `fadd`s that no compiler may reassociate, so a single row cannot
604/// fill the floating-point pipeline; four rows can. Every row still
605/// accumulates its own terms left to right, so a batched score is bit-identical
606/// to the row-at-a-time definition.
607const ROW_BATCH: usize = 4;
608
609fn resolve_row<'a>(
610    selected: Option<&[ScanCandidate]>,
611    retained: Option<&[u32]>,
612    position: usize,
613    row_count: usize,
614    rows: &'a [f32],
615    dimension: usize,
616) -> Result<(usize, &'a [f32]), RescoreError> {
617    let row_id = selected_row_index(selected, retained, position, row_count)?;
618    let start = row_id
619        .checked_mul(dimension)
620        .ok_or(RescoreError::ArithmeticOverflow)?;
621    let end = start
622        .checked_add(dimension)
623        .ok_or(RescoreError::ArithmeticOverflow)?;
624    let row = rows.get(start..end).ok_or(RescoreError::RowDataLength {
625        dimension,
626        actual: rows.len(),
627    })?;
628    Ok((row_id, row))
629}
630
631fn score_batch(
632    query: &[f32],
633    batch: &[(usize, &[f32])],
634    metric: RescoreMetric,
635    scores: &mut Vec<f64>,
636) {
637    #[cfg(any(test, feature = "test-seams"))]
638    for (row, _) in batch {
639        crate::graph::search::observe_exact_score(*row);
640    }
641    scores.clear();
642    match metric {
643        RescoreMetric::InnerProduct => {
644            for (_, row) in batch {
645                scores.push(f64::from(dot_f32(query, row)));
646            }
647        }
648        RescoreMetric::SquaredL2 => {
649            let mut rest = batch;
650            while let [(_, first), (_, second), (_, third), (_, fourth), tail @ ..] = rest {
651                let (first, second, third, fourth) =
652                    squared_l2_f64_x4(query, first, second, third, fourth);
653                scores.push(-first);
654                scores.push(-second);
655                scores.push(-third);
656                scores.push(-fourth);
657                rest = tail;
658            }
659            for (_, row) in rest {
660                scores.push(-squared_l2_f64(query, row));
661            }
662        }
663    }
664}
665
666fn selected_row_index(
667    selected: Option<&[ScanCandidate]>,
668    retained: Option<&[u32]>,
669    position: usize,
670    row_count: usize,
671) -> Result<usize, RescoreError> {
672    match selected {
673        Some(selected) => selected
674            .get(position)
675            .map(|candidate| candidate.row_id)
676            .ok_or(RescoreError::ArithmeticOverflow),
677        None => retained_row_index(retained, position, row_count),
678    }
679}
680
681fn rescore_hit_best_first(left: &RescoreHit, right: &RescoreHit) -> std::cmp::Ordering {
682    right
683        .score
684        .total_cmp(&left.score)
685        .then_with(|| left.row_index.cmp(&right.row_index))
686}
687
688fn retained_row_index(
689    row_indices: Option<&[u32]>,
690    position: usize,
691    row_count: usize,
692) -> Result<usize, RescoreError> {
693    let row_index = match row_indices {
694        None => position,
695        Some(row_indices) => usize::try_from(*row_indices.get(position).ok_or(
696            RescoreError::CandidateRowCount {
697                expected: position.saturating_add(1),
698                actual: row_indices.len(),
699            },
700        )?)
701        .map_err(|_| RescoreError::ArithmeticOverflow)?,
702    };
703    if row_index >= row_count {
704        return Err(RescoreError::CandidateRowOutOfRange {
705            position,
706            row_index,
707            row_count,
708        });
709    }
710    Ok(row_index)
711}
712
713/// The one exact squared-L2 definition. Every exact vector score in the
714/// engine, including a hybrid cross-fill, comes from here so a bounded leg
715/// and an unbounded one cannot disagree in the last bits.
716pub(crate) fn squared_l2_f64(left: &[f32], right: &[f32]) -> f64 {
717    left.iter()
718        .zip(right)
719        .map(|(left, right)| {
720            let delta = f64::from(*left) - f64::from(*right);
721            delta * delta
722        })
723        .sum()
724}
725
726/// Four rows of the one squared-L2 definition above, scored together.
727///
728/// Each row keeps its own accumulator and adds its own terms in the same
729/// left-to-right order as `squared_l2_f64`, so every returned sum is bit
730/// identical to that definition applied to that row. Nothing is reassociated
731/// and nothing is contracted into a fused multiply-add: the only thing that
732/// changes is that four independent dependency chains are in flight instead
733/// of one, which is what lets the floating-point pipeline issue more than one
734/// add per chain latency.
735pub(crate) fn squared_l2_f64_x4(
736    query: &[f32],
737    first: &[f32],
738    second: &[f32],
739    third: &[f32],
740    fourth: &[f32],
741) -> (f64, f64, f64, f64) {
742    let mut first_sum = 0.0_f64;
743    let mut second_sum = 0.0_f64;
744    let mut third_sum = 0.0_f64;
745    let mut fourth_sum = 0.0_f64;
746    for ((((query, first), second), third), fourth) in
747        query.iter().zip(first).zip(second).zip(third).zip(fourth)
748    {
749        let query = f64::from(*query);
750        let first = query - f64::from(*first);
751        let second = query - f64::from(*second);
752        let third = query - f64::from(*third);
753        let fourth = query - f64::from(*fourth);
754        first_sum += first * first;
755        second_sum += second * second;
756        third_sum += third * third;
757        fourth_sum += fourth * fourth;
758    }
759    (first_sum, second_sum, third_sum, fourth_sum)
760}
761
762fn prefetch_f32_row(base: &[f32], row_id: usize, dimensions: usize) {
763    let Some(start) = row_id.checked_mul(dimensions) else {
764        return;
765    };
766    let Some(value) = base.get(start) else {
767        return;
768    };
769    let address = std::ptr::from_ref(value);
770    #[cfg(target_arch = "aarch64")]
771    {
772        const CACHE_LINE_BYTES: usize = 128;
773        let Some(row_bytes) = dimensions.checked_mul(std::mem::size_of::<f32>()) else {
774            return;
775        };
776        let cache_line_count = row_bytes.div_ceil(CACHE_LINE_BYTES);
777        for cache_line in 0..cache_line_count {
778            let Some(byte_offset) = cache_line.checked_mul(CACHE_LINE_BYTES) else {
779                return;
780            };
781            let line_address = address.cast::<u8>().wrapping_add(byte_offset);
782            // SAFETY: the address points into the live immutable f32 rescore mapping.
783            unsafe {
784                std::arch::asm!(
785                    "prfm pldl1keep, [{line_address}]",
786                    line_address = in(reg) line_address,
787                    options(readonly, nostack)
788                );
789            }
790        }
791    }
792    #[cfg(not(target_arch = "aarch64"))]
793    let _ = address;
794}
795
796#[cfg(test)]
797mod exact_sink_tests {
798    use super::*;
799
800    #[test]
801    fn astra_04_sink_checks_precede_nonfinite_and_count_eligible_rows() {
802        let mut values = [1.0_f32; 67];
803        if let Some(value) = values.get_mut(65) {
804            *value = f32::NAN;
805        }
806        for cancel in [true, false] {
807            let mut checks = Vec::new();
808            let mut emitted = Vec::new();
809            let result = exact_squared_l2_with_sink(
810                &[0.0],
811                values.chunks_exact(1).enumerate(),
812                |row, checkpoint| {
813                    checks.push((row, checkpoint));
814                    if cancel && row == 65 { Err(65) } else { Ok(()) }
815                },
816                |row, score| {
817                    emitted.push((row, score.to_bits()));
818                    Ok(())
819                },
820            );
821            if cancel {
822                assert!(matches!(result, Err(RescoreCheckError::Check(65))));
823            } else {
824                assert!(matches!(
825                    result,
826                    Err(RescoreCheckError::Rescore(
827                        RescoreError::NonFiniteExactScore { row_index: 65 }
828                    ))
829                ));
830            }
831            assert_eq!(
832                checks,
833                (0..66).map(|row| (row, row % 64 == 0)).collect::<Vec<_>>()
834            );
835            assert_eq!(
836                emitted,
837                (0..65)
838                    .map(|row| (row, (-1.0_f64).to_bits()))
839                    .collect::<Vec<_>>()
840            );
841        }
842    }
843}
844
845#[cfg(test)]
846#[allow(clippy::expect_used, clippy::indexing_slicing, clippy::panic)]
847mod reuse_tests {
848    use super::*;
849
850    #[derive(Default)]
851    struct Cache(std::collections::BTreeMap<usize, f64>);
852
853    impl ExactScoreReuse<usize> for Cache {
854        fn get(&mut self, row: usize) -> Result<Option<f64>, usize> {
855            Ok(self.0.get(&row).copied())
856        }
857        fn insert(&mut self, row: usize, score: f64) -> Result<(), usize> {
858            assert!(
859                self.0.insert(row, score).is_none(),
860                "only new rows are retained"
861            );
862            Ok(())
863        }
864    }
865
866    #[test]
867    fn astra_09_reused_rescores_preserve_f64_order_checks_and_new_row_work() {
868        let rows = [
869            1.0_f32,
870            2.0_f32.powi(-13),
871            1.0,
872            0.0,
873            2.0,
874            0.0,
875            3.0,
876            0.0,
877            4.0,
878            0.0,
879            5.0,
880            0.0,
881        ];
882        let query = [0.0_f32; 2];
883        let mut cache = Cache::default();
884        for (ids, expected_calls) in [
885            (&[0_u32, 1, 2, 3][..], 4),
886            (&[0, 1, 2, 3, 4, 5][..], 2),
887            (&[0, 1, 2, 3, 4, 5][..], 0),
888        ] {
889            let coarse = vec![0.0; ids.len()];
890            let pool = RescorePool::retained(ids, &coarse, RescoreMetric::SquaredL2, ids.len(), 13);
891            let mut checked = Vec::new();
892            let result = rescore_top_k_reusing(
893                &query,
894                &rows,
895                2,
896                pool,
897                2,
898                |row, _| {
899                    checked.push(row);
900                    Ok(())
901                },
902                &mut cache,
903            )
904            .expect("valid retained pool");
905            assert_eq!(
906                checked,
907                ids.iter().map(|row| *row as usize).collect::<Vec<_>>()
908            );
909            assert_eq!(result.candidates_rescored, expected_calls);
910            assert_eq!(result.bytes.rescore, expected_calls * 2 * 4);
911            assert_eq!(
912                result
913                    .hits
914                    .iter()
915                    .map(|hit| hit.row_index)
916                    .collect::<Vec<_>>(),
917                vec![1, 0]
918            );
919            assert_eq!(result.hits[0].score, -1.0);
920            assert_eq!(result.hits[1].score, -1.0 - 2.0_f64.powi(-26));
921            assert_eq!(
922                (result.hits[0].score as f32).to_bits(),
923                (result.hits[1].score as f32).to_bits(),
924                "a premature f32 cache would choose the wrong row on the next round"
925            );
926            let fresh = rescore_top_k(&query, &rows, 2, pool, 2).expect("fresh control");
927            assert_eq!(fresh.hits, result.hits);
928        }
929        let cancelled = rescore_top_k_reusing(
930            &query,
931            &rows,
932            2,
933            RescorePool::retained(&[0, 1], &[0.0, 0.0], RescoreMetric::SquaredL2, 2, 13),
934            1,
935            |row, _| if row == 1 { Err(row) } else { Ok(()) },
936            &mut cache,
937        );
938        assert!(
939            matches!(cancelled, Err(RescoreCheckError::Check(1))),
940            "cached rows still run checks"
941        );
942        let mut checked = Vec::new();
943        let malformed = rescore_top_k_reusing(
944            &query,
945            &rows,
946            2,
947            RescorePool::retained(&[0, 99], &[0.0, 0.0], RescoreMetric::SquaredL2, 2, 13),
948            1,
949            |row, _| {
950                checked.push(row);
951                Ok(())
952            },
953            &mut cache,
954        );
955        assert_eq!(checked, vec![0]);
956        assert!(matches!(
957            malformed,
958            Err(RescoreCheckError::Rescore(
959                RescoreError::CandidateRowOutOfRange {
960                    position: 1,
961                    row_index: 99,
962                    row_count: 6
963                }
964            ))
965        ));
966    }
967}