Skip to main content

summa_core/query/candidate_scoring/
execution.rs

1use super::*;
2use crate::directories::Directory;
3use crate::index::Searcher;
4use crate::query::{GlobalStats, GlobalStatsBuilder, ScoredPosition, SearchResult};
5use crate::segment::SegmentReader;
6use std::collections::{BTreeMap, BTreeSet};
7use std::sync::Arc;
8
9const MAX_FEATURES: usize = crate::query::MAX_FUSION_SUB_QUERIES;
10const MAX_FEATURE_VALUES: usize = 2_000_000;
11const MAX_VECTOR_BYTES: usize = 1024 * 1024 * 1024;
12
13struct CandidateProbeState {
14    sparse: crate::segment::reader::SparseProbeBudget,
15    payload_remaining: u64,
16    text_scratch: crate::structures::postings::PostingDecodeScratch,
17}
18
19impl Default for CandidateProbeState {
20    fn default() -> Self {
21        Self {
22            sparse: Default::default(),
23            payload_remaining: 256 * 1024 * 1024,
24            text_scratch: Default::default(),
25        }
26    }
27}
28
29#[derive(Default)]
30struct ComponentPreparation {
31    sparse: crate::query::bmp::CandidateBmpPreparation,
32    vector: Option<crate::query::reranker::CandidateVectorPreparation>,
33}
34
35impl CandidateScoringPlan {
36    pub fn validate(&self, schema: &crate::Schema) -> Result<()> {
37        self.document_combiner.validate().map_err(Error::Query)?;
38        if self.seed_document_passages
39            && (!self.backfill || !self.features.iter().any(|f| f.scope == ScoreScope::Chunk))
40        {
41            return Err(Error::Query(
42                "seed_document_passages requires backfill and a chunk-scoped feature".into(),
43            ));
44        }
45        if self.all_passages && !self.backfill {
46            return Err(Error::Query(
47                "all_passages diagnostics require backfill".into(),
48            ));
49        }
50        if self.features.is_empty()
51            || self.features.len() > MAX_FEATURES
52            || self.export_passages == 0
53            || self.export_passages > u16::MAX as usize + 1
54        {
55            return Err(Error::Query("candidate scoring needs 1..16 branches and 1..65536 exported passages per document".into()));
56        }
57        let mut names = BTreeSet::new();
58        for feature in &self.features {
59            feature.query.document.validate()?;
60            if feature.name.is_empty()
61                || feature.name.len() > 128
62                || !feature
63                    .name
64                    .bytes()
65                    .all(|c| c.is_ascii_alphanumeric() || b"._-".contains(&c))
66                || !names.insert(feature.name.as_str())
67            {
68                return Err(Error::Query("candidate scoring requires unique query names (1..128 ASCII letters, digits, '.', '_' or '-')".into()));
69            }
70            let entry = schema
71                .get_field_entry(feature.query.field)
72                .ok_or_else(|| Error::FieldNotFound(feature.query.field.0.to_string()))?;
73            if !entry.indexed {
74                return Err(Error::Query(format!(
75                    "L1 branch '{}' needs an indexed field",
76                    feature.name
77                )));
78            }
79            if feature.scope == ScoreScope::Chunk
80                && entry.field_type == crate::FieldType::Text
81                && !entry.chunked
82            {
83                return Err(Error::Query(format!(
84                    "L1 chunk branch '{}' needs chunked text; plain text is document scope",
85                    feature.name
86                )));
87            }
88            if feature.query.components.is_empty()
89                || feature.query.components.len() > crate::query::MAX_QUERY_TERMS
90            {
91                return Err(Error::Query(
92                    "L1 branch has an invalid component count".into(),
93                ));
94            }
95            for (component, boost) in &feature.query.components {
96                if !boost.is_finite() {
97                    return Err(Error::Query("non-finite L1 query boost".into()));
98                }
99                match component {
100                    ScoreComponent::Text(terms) => {
101                        if entry.field_type != crate::FieldType::Text
102                            || terms.len() > crate::query::MAX_QUERY_TERMS
103                            || terms.iter().any(|(_, w)| !w.is_finite())
104                        {
105                            return Err(Error::Query("invalid L1 text feature".into()));
106                        }
107                    }
108                    ScoreComponent::Phrase(query) => {
109                        let max_terms = schema.max_l1_phrase_terms();
110                        if query.terms.len() > max_terms {
111                            return Err(Error::Query(format!(
112                                "L1 phrase feature '{}' has {} terms; maximum is {max_terms}",
113                                feature.name,
114                                query.terms.len(),
115                            )));
116                        }
117                        if entry.field_type != crate::FieldType::Text
118                            || query.field != feature.query.field
119                            || query.offsets.len() != query.terms.len()
120                            || !query.offsets.windows(2).all(|p| p[0] < p[1])
121                            || (query.terms.len() > 1 && entry.positions.is_none())
122                        {
123                            return Err(Error::Query(
124                                "invalid L1 phrase feature or missing positions".into(),
125                            ));
126                        }
127                    }
128                    ScoreComponent::Sparse(terms) => {
129                        if entry.field_type != crate::FieldType::SparseVector
130                            || terms.len() > crate::query::MAX_QUERY_TERMS
131                            || terms.iter().any(|(_, w)| !w.is_finite())
132                        {
133                            return Err(Error::Query("invalid L1 sparse feature".into()));
134                        }
135                    }
136                    ScoreComponent::Dense(vector) => {
137                        let Some(config) = &entry.dense_vector_config else {
138                            return Err(Error::Query(
139                                "L1 dense feature requires a dense field".into(),
140                            ));
141                        };
142                        if vector.len() != config.dim
143                            || vector.is_empty()
144                            || vector.iter().any(|v| !v.is_finite())
145                        {
146                            return Err(Error::Query(
147                                "invalid L1 dense dimensions or values".into(),
148                            ));
149                        }
150                    }
151                    ScoreComponent::Binary(vector) => {
152                        let Some(config) = &entry.binary_dense_vector_config else {
153                            return Err(Error::Query(
154                                "L1 binary feature requires a binary field".into(),
155                            ));
156                        };
157                        if vector.is_empty() || vector.len() != config.byte_len() {
158                            return Err(Error::Query("invalid L1 binary dimension".into()));
159                        }
160                    }
161                }
162            }
163        }
164        if let Some(model) = &self.model {
165            model.validate(
166                &self
167                    .features
168                    .iter()
169                    .map(|f| f.name.as_str())
170                    .collect::<Vec<_>>(),
171            )?;
172        }
173        Ok(())
174    }
175}
176
177async fn score_field<D: Directory + 'static>(
178    searcher: &Searcher<D>,
179    reader: &SegmentReader,
180    feature: &CandidateFeature,
181    locations: &[crate::segment::reader::candidate_lookup::CandidateLocation],
182    stats: &Arc<GlobalStats>,
183    budget: &mut CandidateProbeState,
184    preparation: &mut [ComponentPreparation],
185) -> Result<(Vec<f32>, Vec<Vec<f32>>)> {
186    let query = &feature.query;
187    let document_scope = feature.scope == ScoreScope::Document;
188    let targets: Vec<u32> = locations.iter().map(|location| location.physical).collect();
189    let targets = targets.as_slice();
190    let mut result = vec![0.0; targets.len()];
191    let mut components = Vec::new();
192    for ((component, boost), preparation) in query.components.iter().zip(preparation) {
193        let values = match component {
194            ScoreComponent::Text(terms) => {
195                for (term, _) in terms {
196                    reader
197                        .reserve_candidate_text_reads(
198                            query.field,
199                            term,
200                            false,
201                            &mut budget.payload_remaining,
202                        )
203                        .await?;
204                }
205                crate::query::term::score_term_candidates(
206                    reader,
207                    query.field,
208                    terms,
209                    targets,
210                    Some(stats),
211                    &mut budget.text_scratch,
212                )
213                .await?
214            }
215            ScoreComponent::Phrase(phrase) => {
216                for term in &phrase.terms {
217                    reader
218                        .reserve_candidate_text_reads(
219                            query.field,
220                            term,
221                            phrase.terms.len() > 1,
222                            &mut budget.payload_remaining,
223                        )
224                        .await?;
225                }
226                crate::query::phrase::score_phrase_candidates(reader, phrase, targets, Some(stats))
227                    .await?
228            }
229            ScoreComponent::Sparse(terms) => {
230                if let Some(index) = reader.seismic_index(query.field) {
231                    // Charge exact candidate payloads before touching values.
232                    for &row in targets {
233                        let bytes = index.vector_byte_len(row)? as u64;
234                        budget.payload_remaining =
235                            budget.payload_remaining.checked_sub(bytes).ok_or_else(|| {
236                                Error::Query("candidate sparse payload read budget exceeded".into())
237                            })?;
238                    }
239                    searcher.install_search_cpu(|| {
240                        crate::query::seismic::score_candidates(index, terms, targets)
241                    })?
242                } else if let Some(index) = reader.bmp_index(query.field) {
243                    reader.reserve_candidate_bmp_reads(
244                        query.field,
245                        targets,
246                        &mut budget.payload_remaining,
247                    )?;
248                    searcher
249                        .install_search_cpu(|| preparation.sparse.score(index, terms, targets))?
250                } else {
251                    let index = reader.sparse_index(query.field).ok_or_else(|| {
252                        Error::Corruption("L1 sparse locations lack a sparse index".into())
253                    })?;
254                    let mut documents: Vec<_> = locations.iter().map(|l| l.doc).collect();
255                    documents.dedup();
256                    let mut scores = vec![0.0f32; locations.len()];
257                    for &(dimension, weight) in terms {
258                        index
259                            .probe_candidates(
260                                &documents,
261                                Some((dimension, weight)),
262                                &mut budget.sparse,
263                                |doc, ordinal, value| {
264                                    if let Ok(i) = locations
265                                        .binary_search_by_key(&(doc, ordinal), |l| {
266                                            (l.doc, l.ordinal)
267                                        })
268                                    {
269                                        scores[i] += value;
270                                        if !scores[i].is_finite() {
271                                            return Err(Error::Query(
272                                                "L1 sparse score overflow".into(),
273                                            ));
274                                        }
275                                    }
276                                    Ok(())
277                                },
278                            )
279                            .await?;
280                    }
281                    scores
282                }
283            }
284            ScoreComponent::Dense(vector) => {
285                let flat = reader.flat_vectors().get(&query.field.0).ok_or_else(|| {
286                    Error::Corruption("L1 dense locations lack stored vectors".into())
287                })?;
288                let unit_norm = reader
289                    .schema()
290                    .get_field_entry(query.field)
291                    .and_then(|e| e.dense_vector_config.as_ref())
292                    .is_some_and(|c| c.unit_norm);
293                crate::query::reranker::score_vector_candidates(
294                    searcher,
295                    flat,
296                    vector,
297                    &[],
298                    unit_norm,
299                    targets,
300                    &mut preparation.vector,
301                )
302                .await?
303            }
304            ScoreComponent::Binary(vector) => {
305                let flat = reader.flat_vectors().get(&query.field.0).ok_or_else(|| {
306                    Error::Corruption("L1 binary locations lack stored vectors".into())
307                })?;
308                crate::query::reranker::score_vector_candidates(
309                    searcher,
310                    flat,
311                    &[],
312                    vector,
313                    false,
314                    targets,
315                    &mut preparation.vector,
316                )
317                .await?
318            }
319        };
320        for (total, value) in result.iter_mut().zip(&values) {
321            *total += value * boost;
322            if !total.is_finite() {
323                return Err(Error::Query("L1 feature score overflow".into()));
324            }
325        }
326        if document_scope {
327            components.push(values);
328        }
329    }
330    Ok((result, components))
331}
332
333impl<D: Directory + 'static> Searcher<D> {
334    /// Complete BM25 statistics over this immutable snapshot, including on
335    /// native-async and WASM where the synchronous lazy cache is unavailable.
336    pub async fn candidate_text_stats(
337        &self,
338        plan: &CandidateScoringPlan,
339    ) -> Result<Arc<GlobalStats>> {
340        let mut terms = Vec::new();
341        for feature in &plan.features {
342            feature.query.text_terms(&mut terms);
343        }
344        terms.sort_unstable_by(|a, b| (a.0.0, &a.1).cmp(&(b.0.0, &b.1)));
345        terms.dedup();
346        let mut builder = GlobalStatsBuilder::new();
347        let mut fields = BTreeSet::new();
348        for reader in self.segment_readers() {
349            builder.add_segment(reader);
350        }
351        for (field, term) in terms {
352            if fields.insert(field.0) {
353                let mut corpus_size = 0u64;
354                let mut total_length = 0.0f64;
355                for reader in self.segment_readers() {
356                    let size = reader.text_corpus_size(field) as u64;
357                    corpus_size += size;
358                    total_length += f64::from(reader.avg_field_len(field)) * size as f64;
359                }
360                builder.set_text_corpus_size(field, corpus_size);
361                builder.set_avg_field_len(field, (total_length / corpus_size.max(1) as f64) as f32);
362            }
363            for reader in self.segment_readers() {
364                let count = reader.text_doc_freq(field, &term).await?;
365                builder.add_text_df(
366                    field,
367                    String::from_utf8_lossy(&term).into_owned(),
368                    u64::from(count),
369                );
370            }
371        }
372        Ok(Arc::new(builder.build(0)))
373    }
374
375    /// Score all requested documents against every named branch, including
376    /// fields which did not nominate them. Addresses must belong to this
377    /// snapshot; stale addresses and budget overflows fail explicitly.
378    pub async fn score_candidates(
379        &self,
380        candidates: &[SearchResult],
381        plan: &CandidateScoringPlan,
382        stats: Option<Arc<GlobalStats>>,
383    ) -> Result<Vec<ScoredCandidate>> {
384        self.score_candidates_with_retrieved(candidates, plan, stats, &[])
385            .await
386    }
387
388    /// Preserve scores from named retrieval branches, then optionally probe
389    /// only missing logical cells. Branch indices refer to `plan.features`.
390    pub async fn score_candidates_with_retrieved(
391        &self,
392        candidates: &[SearchResult],
393        plan: &CandidateScoringPlan,
394        stats: Option<Arc<GlobalStats>>,
395        retrieved: &[(usize, &[SearchResult])],
396    ) -> Result<Vec<ScoredCandidate>> {
397        self.score_candidates_with_retrieved_and_rrf(candidates, plan, stats, retrieved, None)
398            .await
399    }
400
401    /// Supply organic RRF features to the same L1 scorer before any top-k.
402    pub async fn score_candidates_with_retrieved_and_rrf(
403        &self,
404        candidates: &[SearchResult],
405        plan: &CandidateScoringPlan,
406        stats: Option<Arc<GlobalStats>>,
407        retrieved: &[(usize, &[SearchResult])],
408        rrf: Option<&[crate::query::RrfScore]>,
409    ) -> Result<Vec<ScoredCandidate>> {
410        plan.validate(self.schema())?;
411        if rrf.is_some_and(|scores| scores.len() != candidates.len() || plan.model.is_none())
412            || (plan.model.as_ref().is_some_and(RankingModel::needs_rrf) && rrf.is_none())
413        {
414            return Err(Error::Query(
415                "invalid or missing RRF L1 candidate features".into(),
416            ));
417        }
418        if candidates.len() > crate::query::MAX_FUSION_CANDIDATE_SLOTS {
419            return Err(Error::Query(
420                "candidate scoring document budget exceeded".into(),
421            ));
422        }
423        self.run_search_cpu(self.score_candidate_features(candidates, plan, stats, retrieved, rrf))
424            .await
425    }
426
427    async fn score_candidate_features(
428        &self,
429        candidates: &[SearchResult],
430        plan: &CandidateScoringPlan,
431        stats: Option<Arc<GlobalStats>>,
432        retrieved: &[(usize, &[SearchResult])],
433        rrf: Option<&[crate::query::RrfScore]>,
434    ) -> Result<Vec<ScoredCandidate>> {
435        let mut groups: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
436        let mut addresses = BTreeSet::new();
437        for (i, candidate) in candidates.iter().enumerate() {
438            let &segment = self
439                .segment_map()
440                .get(&candidate.segment_id)
441                .ok_or_else(|| {
442                    Error::Query("candidate address is stale or belongs to another snapshot".into())
443                })?;
444            if !self.segment_readers()[segment].is_alive(candidate.doc_id)
445                || !addresses.insert((candidate.segment_id, candidate.doc_id))
446            {
447                return Err(Error::Query(
448                    "candidate addresses must be valid and unique".into(),
449                ));
450            }
451            groups.entry(segment).or_default().push(i);
452        }
453        let organic = super::retrieved::RetrievedScores::new(retrieved, plan, &addresses)?;
454        let stats = if plan.backfill {
455            Some(match stats {
456                Some(stats) => stats,
457                None => self.candidate_text_stats(plan).await?,
458            })
459        } else {
460            None
461        };
462        let names: Vec<&str> = plan.features.iter().map(|f| f.name.as_str()).collect();
463        let count = names.len();
464        let chunk_fields: BTreeSet<u32> = plan
465            .features
466            .iter()
467            .filter(|feature| feature.scope == ScoreScope::Chunk)
468            .map(|feature| feature.query.field.0)
469            .collect();
470        let mut output = Vec::with_capacity(candidates.len());
471        let mut scored_values = 0usize;
472        let mut vector_bytes = 0usize;
473        let mut matrix_values = 0usize;
474        let mut probe_budget = CandidateProbeState::default();
475        let mut preparations: Vec<Vec<ComponentPreparation>> = plan
476            .features
477            .iter()
478            .map(|feature| {
479                std::iter::repeat_with(ComponentPreparation::default)
480                    .take(feature.query.components.len())
481                    .collect()
482            })
483            .collect();
484        let mut reduction_locations = Vec::new();
485        let mut seed_locations = 0usize;
486        for (segment, mut candidate_indices) in groups {
487            let reader = &self.segment_readers()[segment];
488            candidate_indices.sort_unstable_by_key(|&i| candidates[i].doc_id);
489            let documents: Vec<u32> = candidate_indices
490                .iter()
491                .map(|&i| candidates[i].doc_id)
492                .collect();
493            matrix_values = matrix_values
494                .checked_add(documents.len().saturating_mul(count))
495                .ok_or_else(|| Error::Query("L1 feature matrix size overflow".into()))?;
496            if matrix_values > MAX_FEATURE_VALUES {
497                return Err(Error::Query("L1 feature matrix budget exceeded".into()));
498            }
499            let mut doc_values = vec![vec![None; count]; documents.len()];
500            let mut passages: BTreeMap<(u32, u16), Vec<Option<f32>>> = BTreeMap::new();
501            let mut nominated = Vec::new();
502            if !plan.all_passages && retrieved.is_empty() {
503                for &i in &candidate_indices {
504                    for (field, positions) in &candidates[i].positions {
505                        if !chunk_fields.contains(field) {
506                            continue;
507                        }
508                        for position in positions {
509                            nominated.push(crate::segment::logical_address::LogicalUnit {
510                                doc: candidates[i].doc_id,
511                                ordinal: u16::try_from(position.position).map_err(|_| {
512                                    Error::Query("invalid nominated passage ordinal".into())
513                                })?,
514                            });
515                            if nominated.len() > crate::query::MAX_FUSION_CHUNK_SLOTS {
516                                return Err(Error::Query("too many nominated passages".into()));
517                            }
518                        }
519                    }
520                }
521                nominated.sort_unstable();
522                nominated.dedup();
523            }
524            // Seed the matrix with organic scores, retaining real zero and
525            // negative values. Scope determines whether a hit nominates a passage.
526            for (feature_index, feature) in plan.features.iter().enumerate() {
527                for (doc_index, &candidate_index) in candidate_indices.iter().enumerate() {
528                    let candidate = &candidates[candidate_index];
529                    let Some(hit) =
530                        organic.get(feature_index, candidate.segment_id, candidate.doc_id)
531                    else {
532                        continue;
533                    };
534                    if feature.scope == ScoreScope::Document {
535                        doc_values[doc_index][feature_index] = Some(hit.score);
536                    } else {
537                        for (field, positions) in &hit.positions {
538                            if *field != feature.query.field.0 {
539                                continue;
540                            }
541                            for position in positions {
542                                let key = (hit.doc_id, position.position as u16);
543                                if let std::collections::btree_map::Entry::Vacant(entry) =
544                                    passages.entry(key)
545                                {
546                                    matrix_values = matrix_values.saturating_add(count);
547                                    if matrix_values > MAX_FEATURE_VALUES {
548                                        return Err(Error::Query(
549                                            "L1 feature matrix budget exceeded".into(),
550                                        ));
551                                    }
552                                    entry.insert(vec![None; count]);
553                                }
554                                let value = &mut passages.get_mut(&key).expect("inserted passage")
555                                    [feature_index];
556                                if value.is_some() {
557                                    return Err(Error::Query(
558                                        "duplicate organic L1 passage score".into(),
559                                    ));
560                                }
561                                *value = Some(position.score);
562                            }
563                        }
564                    }
565                }
566            }
567            nominated.extend(passages.keys().map(|&(doc, ordinal)| {
568                crate::segment::logical_address::LogicalUnit { doc, ordinal }
569            }));
570            nominated.sort_unstable();
571            nominated.dedup();
572            for key in &nominated {
573                if let std::collections::btree_map::Entry::Vacant(entry) =
574                    passages.entry((key.doc, key.ordinal))
575                {
576                    matrix_values = matrix_values.saturating_add(count);
577                    if matrix_values > MAX_FEATURE_VALUES {
578                        return Err(Error::Query("L1 feature matrix budget exceeded".into()));
579                    }
580                    entry.insert(vec![None; count]);
581                }
582            }
583            if plan.seed_document_passages && !plan.all_passages {
584                let missing_documents: Vec<_> = documents
585                    .iter()
586                    .copied()
587                    .filter(|doc| {
588                        let index = nominated.partition_point(|key| key.doc < *doc);
589                        nominated.get(index).is_none_or(|key| key.doc != *doc)
590                    })
591                    .collect();
592                for &field in &chunk_fields {
593                    let locations = reader
594                        .candidate_locations(
595                            crate::dsl::Field(field),
596                            &missing_documents,
597                            MAX_FEATURE_VALUES.saturating_sub(seed_locations),
598                            &mut probe_budget.sparse,
599                        )
600                        .await?;
601                    seed_locations += locations.len();
602                    for location in locations {
603                        if let std::collections::btree_map::Entry::Vacant(entry) =
604                            passages.entry((location.doc, location.ordinal))
605                        {
606                            matrix_values = matrix_values.saturating_add(count);
607                            if matrix_values > MAX_FEATURE_VALUES {
608                                return Err(Error::Query(
609                                    "L1 feature matrix budget exceeded".into(),
610                                ));
611                            }
612                            entry.insert(vec![None; count]);
613                        }
614                    }
615                }
616                nominated = passages
617                    .keys()
618                    .map(
619                        |&(doc, ordinal)| crate::segment::logical_address::LogicalUnit {
620                            doc,
621                            ordinal,
622                        },
623                    )
624                    .collect();
625            }
626            for (feature_index, feature) in plan.features.iter().enumerate() {
627                if !plan.backfill {
628                    continue;
629                }
630                let missing_documents: Vec<_> = documents
631                    .iter()
632                    .enumerate()
633                    .filter_map(|(i, &doc)| {
634                        (feature.scope == ScoreScope::Chunk
635                            || doc_values[i][feature_index].is_none())
636                        .then_some(doc)
637                    })
638                    .collect();
639                let missing_passages: Vec<_> = nominated
640                    .iter()
641                    .copied()
642                    .filter(|key| passages[&(key.doc, key.ordinal)][feature_index].is_none())
643                    .collect();
644                let mut locations = if feature.scope == ScoreScope::Chunk && !plan.all_passages {
645                    reader
646                        .candidate_passage_locations(
647                            feature.query.field,
648                            &missing_passages,
649                            &mut probe_budget.sparse,
650                        )
651                        .await?
652                } else {
653                    reader
654                        .candidate_locations(
655                            feature.query.field,
656                            &missing_documents,
657                            MAX_FEATURE_VALUES.saturating_sub(scored_values),
658                            &mut probe_budget.sparse,
659                        )
660                        .await?
661                };
662                if feature.scope == ScoreScope::Chunk {
663                    locations.retain(|location| {
664                        passages
665                            .get(&(location.doc, location.ordinal))
666                            .is_none_or(|row| row[feature_index].is_none())
667                    });
668                }
669                if locations.len() > MAX_FEATURE_VALUES.saturating_sub(scored_values) {
670                    return Err(Error::Query("L1 scored-value budget exceeded".into()));
671                }
672                let work = locations
673                    .len()
674                    .checked_mul(feature.query.components.len())
675                    .ok_or_else(|| Error::Query("L1 feature work overflow".into()))?;
676                scored_values = scored_values
677                    .checked_add(work)
678                    .filter(|&n| n <= MAX_FEATURE_VALUES)
679                    .ok_or_else(|| Error::Query("L1 scored-component budget exceeded".into()))?;
680                if locations.is_empty() {
681                    continue;
682                }
683                if let Some(flat) = reader.flat_vectors().get(&feature.query.field.0) {
684                    vector_bytes = vector_bytes
685                        .checked_add(
686                            locations
687                                .len()
688                                .checked_mul(feature.query.components.len())
689                                .and_then(|n| n.checked_mul(flat.vector_byte_size()))
690                                .ok_or_else(|| {
691                                    Error::Query("L1 vector byte count overflow".into())
692                                })?,
693                        )
694                        .ok_or_else(|| Error::Query("L1 vector byte count overflow".into()))?;
695                    if vector_bytes > MAX_VECTOR_BYTES {
696                        return Err(Error::Query("L1 exceeds stored vector read budget".into()));
697                    }
698                }
699                locations.sort_unstable_by_key(|location| location.physical);
700                let document_scope = feature.scope == ScoreScope::Document;
701                let (scores, components) = score_field(
702                    self,
703                    reader,
704                    feature,
705                    &locations,
706                    stats.as_ref().expect("backfill statistics"),
707                    &mut probe_budget,
708                    &mut preparations[feature_index],
709                )
710                .await?;
711                if document_scope {
712                    reduction_locations.clear();
713                    reduction_locations.extend(
714                        locations
715                            .iter()
716                            .enumerate()
717                            .map(|(i, location)| (u32::from(location.ordinal), i)),
718                    );
719                    // Physical reordering must not alter floating-point reductions.
720                    reduction_locations
721                        .sort_unstable_by_key(|&(ordinal, i)| (locations[i].doc, ordinal));
722                    for selected in reduction_locations
723                        .chunk_by(|a, b| locations[a.1].doc == locations[b.1].doc)
724                    {
725                        let doc = locations[selected[0].1].doc;
726                        let doc_index = documents
727                            .binary_search(&doc)
728                            .expect("resolved selected doc");
729                        doc_values[doc_index][feature_index] =
730                            Some(feature.query.document.score(&components, selected)?);
731                    }
732                    continue;
733                }
734                for (location, score) in locations.into_iter().zip(scores) {
735                    {
736                        let values = match passages.entry((location.doc, location.ordinal)) {
737                            std::collections::btree_map::Entry::Occupied(entry) => entry.into_mut(),
738                            std::collections::btree_map::Entry::Vacant(entry) => {
739                                matrix_values += count;
740                                if matrix_values > MAX_FEATURE_VALUES {
741                                    return Err(Error::Query(
742                                        "L1 feature matrix budget exceeded".into(),
743                                    ));
744                                }
745                                entry.insert(vec![None; count])
746                            }
747                        };
748                        values[feature_index] = Some(score);
749                    }
750                }
751            }
752            let mut passages = passages.into_iter().peekable();
753            for (doc_index, &candidate_index) in candidate_indices.iter().enumerate() {
754                let candidate = &candidates[candidate_index];
755                let document = std::mem::take(&mut doc_values[doc_index]);
756                let mut rows = Vec::new();
757                while passages
758                    .peek()
759                    .is_some_and(|((doc, _), _)| *doc == candidate.doc_id)
760                {
761                    let ((_, ordinal), values) = passages.next().expect("peeked row");
762                    rows.push(PassageFeatures {
763                        ordinal,
764                        score: candidate.score,
765                        values,
766                    });
767                }
768                let scored_passages = rows.len();
769                let mut result = SearchResult {
770                    doc_id: candidate.doc_id,
771                    segment_id: candidate.segment_id,
772                    score: candidate.score,
773                    positions: if plan.model.is_some() {
774                        Vec::new()
775                    } else {
776                        candidate.positions.clone()
777                    },
778                };
779                let mut features = CandidateScores {
780                    document,
781                    passages: rows,
782                    scored_passages,
783                };
784                if let Some(model) = &plan.model {
785                    result.score = model.score_candidate(
786                        &names,
787                        &mut features,
788                        plan.document_combiner,
789                        rrf.map(|scores| &scores[candidate_index]),
790                    )?;
791                }
792                let CandidateScores {
793                    document,
794                    passages: mut rows,
795                    ..
796                } = features;
797                if plan.model.is_none() && rows.len() > plan.export_passages {
798                    return Err(Error::Query(format!(
799                        "feature export would omit {} passages of document {}; increase export_passages or supply l1",
800                        rows.len() - plan.export_passages,
801                        candidate.doc_id
802                    )));
803                }
804                rows.sort_unstable_by(|a, b| {
805                    b.score
806                        .total_cmp(&a.score)
807                        .then_with(|| a.ordinal.cmp(&b.ordinal))
808                });
809                rows.truncate(plan.export_passages);
810                // A document-only feature never creates an ordinal-zero row.
811                if plan.model.is_some() {
812                    result.positions = chunk_fields
813                        .iter()
814                        .map(|&field| {
815                            (
816                                field,
817                                rows.iter()
818                                    .map(|row| {
819                                        ScoredPosition::new(u32::from(row.ordinal), row.score)
820                                    })
821                                    .collect(),
822                            )
823                        })
824                        .collect();
825                }
826                output.push(ScoredCandidate {
827                    result,
828                    features: CandidateScores {
829                        document,
830                        passages: rows,
831                        scored_passages,
832                    },
833                });
834            }
835        }
836        output.sort_unstable_by(|a, b| {
837            crate::query::compare_search_results_desc(&a.result, &b.result)
838        });
839        Ok(output)
840    }
841}
842
843#[cfg(all(test, feature = "native"))]
844mod tests {
845    use super::*;
846    use crate::query::{Query, SparseVectorQuery};
847    use crate::structures::{SparseFormat, SparseVectorConfig};
848    use crate::{Document, Index, IndexConfig, IndexWriter, RamDirectory, Schema};
849
850    #[tokio::test]
851    async fn sparse_backfill_admits_payload_bytes_before_scoring_for_each_precision() {
852        let mut schema = Schema::builder();
853        let fields: Vec<_> = [
854            crate::structures::WeightQuantization::Float32,
855            crate::structures::WeightQuantization::UInt8,
856        ]
857        .into_iter()
858        .map(|quantization| {
859            schema.add_sparse_vector_field_with_config(
860                &format!("sparse_{quantization:?}"),
861                true,
862                false,
863                SparseVectorConfig {
864                    format: SparseFormat::Seismic,
865                    dims: Some(16),
866                    weight_quantization: quantization,
867                    ..Default::default()
868                },
869            )
870        })
871        .collect();
872        let directory = RamDirectory::new();
873        let config = IndexConfig::default();
874        let mut writer = IndexWriter::create(directory.clone(), schema.build(), config.clone())
875            .await
876            .unwrap();
877        let mut document = Document::new();
878        for &field in &fields {
879            document.add_sparse_vector(field, vec![(0, 1.0)]);
880        }
881        writer.add_document(document).unwrap();
882        writer.commit().await.unwrap();
883        let index = Index::open(directory, config).await.unwrap();
884        let searcher = index.reader().await.unwrap().searcher().await.unwrap();
885        let reader = &searcher.segment_readers()[0];
886        let stats = Arc::new(GlobalStatsBuilder::new().build(0));
887        for field in fields {
888            let query = SparseVectorQuery::new(field, vec![(0, 1.0)])
889                .candidate_query()
890                .unwrap();
891            let locations = reader
892                .candidate_locations(field, &[0], 1, &mut Default::default())
893                .await
894                .unwrap();
895            let mut budget = CandidateProbeState {
896                payload_remaining: 0,
897                ..Default::default()
898            };
899            let error = score_field(
900                &searcher,
901                reader,
902                &CandidateFeature {
903                    name: "sparse".into(),
904                    scope: ScoreScope::Chunk,
905                    query,
906                },
907                &locations,
908                &stats,
909                &mut budget,
910                &mut [ComponentPreparation::default()],
911            )
912            .await
913            .unwrap_err();
914            assert!(matches!(error, Error::Query(_)), "{error}");
915            assert!(error.to_string().contains("payload read budget"), "{error}");
916        }
917    }
918}