Skip to main content

uqa_scoring/
multi_field.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Per-field Bayesian BM25 scoring with weighted log-odds fusion
8//! across fields (Section 12.2 #1, Paper 3).
9//!
10//! Each field has independent calibration parameters (`alpha, beta,
11//! base_rate`) plus a fusion weight. Per-field posteriors are unwrapped
12//! into prior-free evidence logits (`logit(p_i) - logit(r_i)`), floored
13//! by softplus (Remark 6.5.4: a matching field never counts against a
14//! document beyond the prior), the weighted evidence is
15//! confidence-scaled by `sqrt(n)`, and the weighted prior enters
16//! exactly once. An absent field contributes zero evidence, so a
17//! document matching nothing rests at the prior.
18
19use std::sync::Arc;
20
21use uqa_core::IndexStats;
22
23use crate::bayesian_bm25::{BayesianBM25Params, BayesianBM25Scorer};
24use crate::error::invalid_input;
25use crate::prob::sigmoid;
26use crate::ScoringResult;
27
28/// One scored field configuration.
29#[derive(Debug, Clone)]
30pub struct FieldConfig {
31    pub field: String,
32    pub params: BayesianBM25Params,
33    pub weight: f64,
34}
35
36pub struct MultiFieldBayesianScorer {
37    fields: Vec<String>,
38    scorers: Vec<BayesianBM25Scorer>,
39    weights: Vec<f64>,
40}
41
42impl MultiFieldBayesianScorer {
43    pub fn new(field_configs: Vec<FieldConfig>, stats: &Arc<IndexStats>) -> ScoringResult<Self> {
44        let total_weight = field_configs.iter().try_fold(0.0, |total, config| {
45            if config.field.is_empty() {
46                return Err(invalid_input("multi-field field name must not be empty"));
47            }
48            if !config.weight.is_finite() || config.weight < 0.0 {
49                return Err(invalid_input(format!(
50                    "multi-field weight for {:?} must be finite and non-negative, got {}",
51                    config.field, config.weight
52                )));
53            }
54            let next = total + config.weight;
55            if next.is_finite() {
56                Ok(next)
57            } else {
58                Err(invalid_input("multi-field weight sum overflowed"))
59            }
60        })?;
61        if !field_configs.is_empty() && total_weight <= 0.0 {
62            return Err(invalid_input(
63                "multi-field weights must have a positive finite sum",
64            ));
65        }
66        let mut fields = Vec::with_capacity(field_configs.len());
67        let mut scorers = Vec::with_capacity(field_configs.len());
68        let mut weights = Vec::with_capacity(field_configs.len());
69        for cfg in field_configs {
70            fields.push(cfg.field);
71            scorers.push(BayesianBM25Scorer::new(cfg.params, stats.clone())?);
72            weights.push(cfg.weight / total_weight);
73        }
74        Ok(Self {
75            fields,
76            scorers,
77            weights,
78        })
79    }
80
81    /// Score one document. Each `*_per_field` map keys on field name
82    /// and yields the term frequency / document length / document
83    /// frequency for that field. Missing fields contribute sparse
84    /// absence rather than a synthetic probability.
85    pub fn score_document(
86        &self,
87        term_freq_per_field: &std::collections::BTreeMap<String, u64>,
88        doc_length_per_field: &std::collections::BTreeMap<String, u64>,
89        doc_freq_per_field: &std::collections::BTreeMap<String, u64>,
90    ) -> f64 {
91        if self.fields.is_empty() {
92            return 0.5;
93        }
94        let mut probabilities: Vec<Option<f64>> = Vec::with_capacity(self.fields.len());
95        for (i, name) in self.fields.iter().enumerate() {
96            let tf = *term_freq_per_field.get(name).unwrap_or(&0);
97            let dl = *doc_length_per_field.get(name).unwrap_or(&1);
98            let df = *doc_freq_per_field.get(name).unwrap_or(&1);
99            if tf == 0 {
100                probabilities.push(None);
101            } else {
102                probabilities.push(Some(self.scorers[i].score(tf, dl, df)));
103            }
104        }
105        if probabilities.len() == 1 {
106            return probabilities[0].unwrap_or(0.5);
107        }
108        let evidence_sum: f64 = probabilities
109            .iter()
110            .zip(&self.weights)
111            .zip(&self.scorers)
112            .filter_map(|((probability, weight), scorer)| {
113                probability.map(|probability| {
114                    weight * softplus(lucene_logit(probability) - field_prior_logit(scorer))
115                })
116            })
117            .sum();
118        let prior_logit: f64 = self
119            .weights
120            .iter()
121            .zip(&self.scorers)
122            .map(|(weight, scorer)| weight * field_prior_logit(scorer))
123            .sum();
124        sigmoid(evidence_sum * (probabilities.len() as f64).sqrt() + prior_logit)
125    }
126}
127
128fn softplus(value: f64) -> f64 {
129    if value > 20.0 {
130        value
131    } else {
132        value.exp().ln_1p()
133    }
134}
135
136/// `logit(base_rate)` of a field's calibration; zero when the field's
137/// prior is disabled (`base_rate == 0`), i.e. the posterior is already
138/// prior-free evidence.
139fn field_prior_logit(scorer: &BayesianBM25Scorer) -> f64 {
140    let base_rate = scorer.params.base_rate;
141    if base_rate > 0.0 {
142        lucene_logit(base_rate)
143    } else {
144        0.0
145    }
146}
147
148fn lucene_logit(probability: f64) -> f64 {
149    let clamped = probability.clamp(1e-7, 1.0 - 1e-7);
150    (clamped / (1.0 - clamped)).ln()
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    #[test]
158    fn single_field_returns_scorer_output() {
159        let mut base = IndexStats::default();
160        base.total_docs = 100;
161        base.avg_doc_length = 50.0;
162        let stats = Arc::new(base);
163        let stats = &stats;
164        let scorer = MultiFieldBayesianScorer::new(
165            vec![FieldConfig {
166                field: "title".into(),
167                params: BayesianBM25Params::default(),
168                weight: 1.0,
169            }],
170            stats,
171        )
172        .unwrap();
173        let tf = std::collections::BTreeMap::from([("title".into(), 5u64)]);
174        let dl = std::collections::BTreeMap::from([("title".into(), 50u64)]);
175        let df = std::collections::BTreeMap::from([("title".into(), 10u64)]);
176        let s = scorer.score_document(&tf, &dl, &df);
177        assert!((0.0..=1.0).contains(&s));
178        assert!(s > 0.5);
179    }
180
181    #[test]
182    fn missing_field_contributes_sparse_absence() {
183        let mut base = IndexStats::default();
184        base.total_docs = 100;
185        base.avg_doc_length = 50.0;
186        let stats = Arc::new(base);
187        let stats = &stats;
188        let scorer = MultiFieldBayesianScorer::new(
189            vec![
190                FieldConfig {
191                    field: "title".into(),
192                    params: BayesianBM25Params::default(),
193                    weight: 1.0,
194                },
195                FieldConfig {
196                    field: "body".into(),
197                    params: BayesianBM25Params::default(),
198                    weight: 1.0,
199                },
200            ],
201            stats,
202        )
203        .unwrap();
204        // Only `title` has frequency data; `body` contributes zero.
205        let tf = std::collections::BTreeMap::from([("title".into(), 5u64)]);
206        let dl = std::collections::BTreeMap::from([("title".into(), 50u64)]);
207        let df = std::collections::BTreeMap::from([("title".into(), 10u64)]);
208        let s = scorer.score_document(&tf, &dl, &df);
209        assert!((0.0..=1.0).contains(&s));
210    }
211
212    #[test]
213    fn constructor_rejects_invalid_field_weights() {
214        let stats = Arc::new(IndexStats::default());
215        let config = |field: &str, weight: f64| FieldConfig {
216            field: field.to_string(),
217            params: BayesianBM25Params::default(),
218            weight,
219        };
220        assert!(MultiFieldBayesianScorer::new(vec![config("", 1.0)], &stats).is_err());
221        assert!(MultiFieldBayesianScorer::new(vec![config("title", f64::NAN)], &stats).is_err());
222        assert!(MultiFieldBayesianScorer::new(vec![config("title", -1.0)], &stats).is_err());
223        assert!(MultiFieldBayesianScorer::new(
224            vec![config("title", 0.0), config("body", 0.0)],
225            &stats,
226        )
227        .is_err());
228    }
229}