Skip to main content

summa_core/query/candidate_scoring/
mod.rs

1//! Named scores over a bounded candidate union: preserve organic retrieval
2//! values, optionally backfill missing cells, and apply the compiled formula.
3mod execution;
4mod formula;
5mod model;
6mod retrieved;
7pub use formula::RankingModel;
8
9use super::{MultiValueCombiner, PhraseQuery, QueryDecomposition};
10use crate::{Error, Field, Result};
11
12/// Explicit alignment contract: fields declared Chunk share logical ordinals.
13/// A document feature is reduced by its query and broadcast to its passages.
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub enum ScoreScope {
16    Document,
17    Chunk,
18}
19
20#[derive(Clone, Debug)]
21pub(crate) enum ScoreComponent {
22    Text(Vec<(Vec<u8>, f32)>),
23    Phrase(PhraseQuery),
24    Sparse(Vec<(u32, f32)>),
25    Dense(Vec<f32>),
26    Binary(Vec<u8>),
27}
28
29/// Prepared from an ordinary query through its owning Query implementation.
30/// Scoring compositions must use one field; eligibility belongs in the common
31/// fusion filter. This avoids silently treating a profile ordinal as body zero.
32#[derive(Clone, Debug)]
33pub struct CandidateQuery {
34    pub(crate) field: Field,
35    pub(crate) components: Vec<(ScoreComponent, f32)>,
36    document: DocumentExpression,
37}
38
39/// Keep expression order for document features. In particular MAX(a)+MAX(b)
40/// and -MAX(a) cannot be flattened into MAX(a+b) and MAX(-a).
41#[derive(Clone, Debug)]
42enum DocumentExpression {
43    Component(usize, MultiValueCombiner),
44    Sum(Vec<Self>),
45    Boost(Box<Self>, f32),
46}
47impl DocumentExpression {
48    fn validate(&self) -> Result<()> {
49        match self {
50            Self::Component(_, combiner) => combiner.validate().map_err(Error::Query),
51            Self::Sum(children) => children.iter().try_for_each(Self::validate),
52            Self::Boost(child, boost) => {
53                if !boost.is_finite() {
54                    return Err(Error::Query("L1 query boost must be finite".into()));
55                }
56                child.validate()
57            }
58        }
59    }
60    fn rebase(&mut self, offset: usize) {
61        match self {
62            Self::Component(index, _) => *index += offset,
63            Self::Sum(children) => children.iter_mut().for_each(|child| child.rebase(offset)),
64            Self::Boost(child, _) => child.rebase(offset),
65        }
66    }
67    fn score(&self, components: &[Vec<f32>], locations: &[(u32, usize)]) -> Result<f32> {
68        let value = match self {
69            Self::Component(index, combiner) => {
70                let values: smallvec::SmallVec<[(u32, f32); 16]> = locations
71                    .iter()
72                    .map(|&(ordinal, position)| (ordinal, components[*index][position]))
73                    .collect();
74                combiner.combine(&values)
75            }
76            Self::Sum(children) => {
77                let mut value = 0.0;
78                for child in children {
79                    value += child.score(components, locations)?;
80                }
81                value
82            }
83            Self::Boost(child, boost) => child.score(components, locations)? * boost,
84        };
85        if !value.is_finite() {
86            return Err(Error::Query(
87                "L1 document feature reduction overflow".into(),
88            ));
89        }
90        Ok(value)
91    }
92}
93impl CandidateQuery {
94    pub fn field(&self) -> Field {
95        self.field
96    }
97    pub(crate) fn new(field: Field, component: ScoreComponent) -> Self {
98        Self {
99            field,
100            components: vec![(component, 1.0)],
101            document: DocumentExpression::Component(0, MultiValueCombiner::Max),
102        }
103    }
104    pub(crate) fn with_combiner(mut self, combiner: MultiValueCombiner) -> Self {
105        self.document = DocumentExpression::Component(0, combiner);
106        self
107    }
108    pub(crate) fn from_decomposition(decomposition: QueryDecomposition) -> Result<Self> {
109        match decomposition {
110            QueryDecomposition::TextTerm(term) if term.global_stats.is_none() => Ok(Self::new(term.field, ScoreComponent::Text(vec![(term.term, term.weight)]))),
111            QueryDecomposition::TextTerm(_) => Err(Error::Query("term carries query-owned global statistics; L1 backfill scores with index statistics, so drop `with_global_stats` on the backfilled term".into())),
112            QueryDecomposition::SparseTerms(infos) if !infos.is_empty() => {
113                let field = infos[0].field;
114                if infos.iter().any(|info| info.field != field) { return Err(Error::Query("L1 branch mixes sparse fields".into())); }
115                let combiner = infos[0].combiner;
116                if infos.iter().any(|info| info.combiner != combiner) { return Err(Error::Query("L1 sparse composition mixes document combiners".into())); }
117                Ok(Self::new(field, ScoreComponent::Sparse(infos.into_iter().map(|info| (info.dim_id, info.weight)).collect())).with_combiner(combiner))
118            }
119            _ => Err(Error::Query("query cannot be backfilled as an L1 score; use text/phrase/vector scoring branches and the common fusion filter for eligibility".into())),
120        }
121    }
122    pub(crate) fn boosted(mut self, weight: f32) -> Result<Self> {
123        self.document = DocumentExpression::Boost(Box::new(self.document), weight);
124        for (_, boost) in &mut self.components {
125            *boost *= weight;
126            if !boost.is_finite() {
127                return Err(Error::Query("L1 query boost overflow".into()));
128            }
129        }
130        Ok(self)
131    }
132    pub(crate) fn sum(queries: impl IntoIterator<Item = Result<Self>>) -> Result<Self> {
133        let mut result: Option<Self> = None;
134        for query in queries {
135            let mut query = query?;
136            if let Some(current) = &mut result {
137                if current.field != query.field {
138                    return Err(Error::Query("L1 scoring branch must use one field; name separate branches for separate fields".into()));
139                }
140                query.document.rebase(current.components.len());
141                current.components.extend(query.components);
142                let previous =
143                    std::mem::replace(&mut current.document, DocumentExpression::Sum(Vec::new()));
144                current.document = match previous {
145                    DocumentExpression::Sum(mut children) => {
146                        children.push(query.document);
147                        DocumentExpression::Sum(children)
148                    }
149                    previous => DocumentExpression::Sum(vec![previous, query.document]),
150                };
151            } else {
152                result = Some(query);
153            }
154        }
155        result.ok_or_else(|| Error::Query("L1 scoring branch is empty".into()))
156    }
157    pub fn text_terms(&self, out: &mut Vec<(Field, Vec<u8>)>) {
158        for (component, _) in &self.components {
159            match component {
160                ScoreComponent::Text(terms) => {
161                    out.extend(terms.iter().map(|(term, _)| (self.field, term.clone())))
162                }
163                ScoreComponent::Phrase(query) => {
164                    out.extend(query.terms.iter().map(|term| (self.field, term.clone())))
165                }
166                _ => {}
167            }
168        }
169    }
170}
171
172#[derive(Clone, Debug)]
173pub struct CandidateFeature {
174    pub name: String,
175    pub scope: ScoreScope,
176    pub query: CandidateQuery,
177}
178
179#[derive(Clone, Debug)]
180pub struct CandidateScoringPlan {
181    pub features: Vec<CandidateFeature>,
182    /// Probe only cells missing from retrieval. False preserves missing values.
183    pub backfill: bool,
184    /// None exports raw features; Some ranks directly with this model.
185    pub model: Option<RankingModel>,
186    /// Bounds only response feature rows; every candidate passage is scored
187    /// before top passages are selected. This does not change indexed chunks.
188    pub export_passages: usize,
189    /// Diagnostics may request every stored passage. Search normally scores
190    /// only the union of nominated passage ordinals, plus document context.
191    pub all_passages: bool,
192    /// Score real body ordinals only when a candidate has no nominated passage.
193    pub seed_document_passages: bool,
194    /// Reduce all final passage predictions before export truncation/top-K.
195    pub document_combiner: MultiValueCombiner,
196}
197
198#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
199pub struct PassageFeatures {
200    pub ordinal: u16,
201    pub score: f32,
202    pub values: Vec<Option<f32>>,
203}
204#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
205pub struct CandidateScores {
206    /// Values in declared branch order. Entries for chunk features are None.
207    pub document: Vec<Option<f32>>,
208    pub passages: Vec<PassageFeatures>,
209    pub scored_passages: usize,
210}
211#[derive(Clone, Debug)]
212pub struct ScoredCandidate {
213    pub result: super::SearchResult,
214    pub features: CandidateScores,
215}
216
217#[cfg(test)]
218mod tests;