Skip to main content

uqa_scoring/
bayesian.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Legacy composite-prior ranking transform.
8//!
9//! This module preserves the arithmetic from the original paper-facing API
10//! for explicit compatibility. Its sigmoid output is a bounded score signal,
11//! not a class-conditional likelihood, and the term-frequency, document-length,
12//! and corpus-rate inputs act as log-odds biases. Consequently the result is a
13//! ranking score, not a calibrated posterior probability. New query paths use
14//! [`crate::BayesianBM25Scorer`], which calibrates the complete BM25 query score
15//! exactly once.
16
17use crate::prob::{clamp_prob, sigmoid};
18use crate::{error::invalid_input, ScoringResult};
19
20/// Historical score transform that combines several dependent BM25-derived
21/// signals. It is intentionally named `Legacy` so callers do not mistake the
22/// output for a probability-model contract.
23#[derive(Debug, Clone, Copy)]
24pub struct LegacyCompositePriorTransform {
25    pub alpha: f64,
26    pub beta: f64,
27    /// Optional corpus-level base rate. `None` is equivalent to `0.5`
28    /// (logit = 0, i.e. no base-rate correction).
29    pub base_rate: Option<f64>,
30}
31
32impl Default for LegacyCompositePriorTransform {
33    fn default() -> Self {
34        Self {
35            alpha: 1.0,
36            beta: 0.0,
37            base_rate: None,
38        }
39    }
40}
41
42impl LegacyCompositePriorTransform {
43    pub fn new(alpha: f64, beta: f64, base_rate: Option<f64>) -> ScoringResult<Self> {
44        if !alpha.is_finite() || !beta.is_finite() {
45            return Err(invalid_input(format!(
46                "legacy transform alpha and beta must be finite, got alpha={alpha}, beta={beta}"
47            )));
48        }
49        if let Some(br) = base_rate {
50            if !br.is_finite() || !(0.0..1.0).contains(&br) || br == 0.0 {
51                return Err(invalid_input(format!(
52                    "base_rate must be finite and in (0, 1), got {br}"
53                )));
54            }
55        }
56        Ok(Self {
57            alpha,
58            beta,
59            base_rate,
60        })
61    }
62
63    /// Bounded monotone score signal: `sigma(alpha * (score - beta))`.
64    ///
65    /// This value is not a normalized `P(score | relevant)` likelihood.
66    #[inline]
67    pub fn score_signal(&self, score: f64) -> f64 {
68        sigmoid(self.alpha * (score - self.beta))
69    }
70
71    /// Term-frequency prior (Eq. 25):
72    /// `P_tf(tf) = 0.2 + 0.7 * min(1, tf / 10)`.
73    #[inline]
74    pub fn tf_prior(tf: f64) -> f64 {
75        0.2 + 0.7 * (tf / 10.0).min(1.0)
76    }
77
78    /// Document-length normalisation prior (Eq. 26):
79    /// `P_norm(r) = 0.3 + 0.6 * (1 - min(1, |r - 0.5| * 2))`,
80    /// peaks at 0.9 when `r = 0.5`, floor 0.3 outside `[0, 1]`.
81    #[inline]
82    pub fn norm_prior(doc_len_ratio: f64) -> f64 {
83        0.3 + 0.6 * (1.0 - ((doc_len_ratio - 0.5).abs() * 2.0).min(1.0))
84    }
85
86    /// Composite prior (Eq. 27):
87    /// `clamp(0.7 * P_tf + 0.3 * P_norm, 0.1, 0.9)`.
88    #[inline]
89    pub fn composite_prior(tf: f64, doc_len_ratio: f64) -> f64 {
90        let p_tf = Self::tf_prior(tf);
91        let p_norm = Self::norm_prior(doc_len_ratio);
92        (0.7 * p_tf + 0.3 * p_norm).clamp(0.1, 0.9)
93    }
94
95    /// Historical no-match floor obtained from zero term frequency and the
96    /// document-length normalization floor. This is a ranking-policy value,
97    /// not a corpus relevance prior.
98    pub fn no_match_floor() -> f64 {
99        Self::composite_prior(0.0, 1.0)
100    }
101
102    /// Combine a bounded score signal and log-odds biases using the legacy
103    /// two-stage probability-space arithmetic.
104    ///
105    /// Without `base_rate`:
106    /// `P = L*p / (L*p + (1-L)*(1-p))`.
107    ///
108    /// With `base_rate` (the second update is equivalent to adding
109    /// `logit(base_rate)` in log-odds space):
110    /// `Step 1: p1 = L*p / (L*p + (1-L)*(1-p))`
111    /// `Step 2: P  = p1*br / (p1*br + (1-p1)*(1-br))`.
112    pub fn combined_score(score_signal: f64, prior: f64, base_rate: Option<f64>) -> f64 {
113        let l = score_signal;
114        let p = prior;
115        let num = l * p;
116        let denom = num + (1.0 - l) * (1.0 - p);
117        let mut result = clamp_prob(num / denom);
118        if let Some(br) = base_rate {
119            let n2 = result * br;
120            let d2 = n2 + (1.0 - result) * (1.0 - br);
121            result = clamp_prob(n2 / d2);
122        }
123        result
124    }
125
126    /// Convert a BM25 score to the legacy bounded ranking score. `tf` is term
127    /// frequency and `doc_len_ratio` is `doc_length / avg_doc_length`.
128    pub fn transform_score(&self, score: f64, tf: f64, doc_len_ratio: f64) -> f64 {
129        let l = self.score_signal(score);
130        let prior = Self::composite_prior(tf, doc_len_ratio);
131        Self::combined_score(l, prior, self.base_rate)
132    }
133
134    /// Monotone upper bound for this transform, given a BM25 upper bound and
135    /// an upper bound on the composite bias.
136    pub fn heuristic_upper_bound(&self, bm25_upper_bound: f64, p_max: f64) -> f64 {
137        let l_max = self.score_signal(bm25_upper_bound);
138        Self::combined_score(l_max, p_max, self.base_rate)
139    }
140}
141
142#[cfg(test)]
143mod no_match_floor_tests {
144    use super::*;
145
146    #[test]
147    fn no_match_floor_matches_zero_evidence_composite_bias() {
148        let floor = LegacyCompositePriorTransform::no_match_floor();
149        assert!((floor - 0.23).abs() < 1e-12, "{floor}");
150    }
151
152    #[test]
153    fn matched_scores_stay_above_the_no_match_floor_under_defaults() {
154        let transform = LegacyCompositePriorTransform::default();
155        let floor = LegacyCompositePriorTransform::no_match_floor();
156        for score_tenths in 0..=100 {
157            let score = f64::from(score_tenths) / 10.0;
158            for tf in 1..=10 {
159                for ratio_tenths in 0..=30 {
160                    let ratio = f64::from(ratio_tenths) / 10.0;
161                    let combined_score = transform.transform_score(score, f64::from(tf), ratio);
162                    assert!(
163                        combined_score >= floor,
164                        "combined_score {combined_score} fell below floor {floor} \
165                         at score={score} tf={tf} ratio={ratio}",
166                    );
167                }
168            }
169        }
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use crate::prob::{logit, sigmoid};
177
178    fn approx_eq(a: f64, b: f64, eps: f64) {
179        assert!((a - b).abs() < eps, "expected {a} ~ {b} within {eps}");
180    }
181
182    #[test]
183    fn score_signal_at_beta_is_half() {
184        let t = LegacyCompositePriorTransform::new(1.0, 5.0, None).unwrap();
185        approx_eq(t.score_signal(5.0), 0.5, 1e-12);
186    }
187
188    #[test]
189    fn constructor_rejects_non_finite_parameters_and_invalid_base_rates() {
190        assert!(LegacyCompositePriorTransform::new(f64::NAN, 0.0, None).is_err());
191        assert!(LegacyCompositePriorTransform::new(1.0, f64::INFINITY, None).is_err());
192        assert!(LegacyCompositePriorTransform::new(1.0, 0.0, Some(0.0)).is_err());
193        assert!(LegacyCompositePriorTransform::new(1.0, 0.0, Some(1.0)).is_err());
194        assert!(LegacyCompositePriorTransform::new(1.0, 0.0, Some(f64::NAN)).is_err());
195    }
196
197    #[test]
198    fn tf_prior_floor_and_ceiling() {
199        approx_eq(LegacyCompositePriorTransform::tf_prior(0.0), 0.2, 1e-12);
200        approx_eq(LegacyCompositePriorTransform::tf_prior(10.0), 0.9, 1e-12);
201        approx_eq(LegacyCompositePriorTransform::tf_prior(100.0), 0.9, 1e-12);
202    }
203
204    #[test]
205    fn norm_prior_peaks_at_half() {
206        approx_eq(LegacyCompositePriorTransform::norm_prior(0.5), 0.9, 1e-12);
207        approx_eq(LegacyCompositePriorTransform::norm_prior(0.0), 0.3, 1e-12);
208        approx_eq(LegacyCompositePriorTransform::norm_prior(1.0), 0.3, 1e-12);
209        approx_eq(LegacyCompositePriorTransform::norm_prior(2.0), 0.3, 1e-12);
210    }
211
212    #[test]
213    fn composite_prior_in_clamp_window() {
214        for tf in [0.0, 1.0, 5.0, 50.0] {
215            for r in [0.0, 0.5, 1.0, 2.0] {
216                let p = LegacyCompositePriorTransform::composite_prior(tf, r);
217                assert!((0.1..=0.9).contains(&p), "p={p} for tf={tf} r={r}");
218            }
219        }
220    }
221
222    #[test]
223    fn combined_score_matches_three_term_logit_form() {
224        // sigmoid(logit(L) + logit(p) + logit(br)) should equal combined_score(L, p, br).
225        let l = 0.7;
226        let p = 0.4;
227        let br = 0.3;
228
229        let two_step = LegacyCompositePriorTransform::combined_score(l, p, Some(br));
230        let logit_form = sigmoid(logit(l) + logit(p) + logit(br));
231        approx_eq(two_step, logit_form, 1e-9);
232    }
233
234    #[test]
235    fn combined_score_without_base_rate_matches_logit_form() {
236        let l = 0.6;
237        let p = 0.3;
238        let two_step = LegacyCompositePriorTransform::combined_score(l, p, None);
239        let logit_form = sigmoid(logit(l) + logit(p));
240        approx_eq(two_step, logit_form, 1e-9);
241    }
242
243    #[test]
244    fn combined_score_is_monotone_in_score_signal() {
245        let p = 0.5;
246        let prev = LegacyCompositePriorTransform::combined_score(0.1, p, None);
247        let mut last = prev;
248        for l in [0.2, 0.3, 0.5, 0.7, 0.9] {
249            let cur = LegacyCompositePriorTransform::combined_score(l, p, None);
250            assert!(
251                cur > last,
252                "combined_score should rise with L: {last} -> {cur}"
253            );
254            last = cur;
255        }
256    }
257
258    #[test]
259    fn transform_score_pipeline() {
260        let t = LegacyCompositePriorTransform::new(1.0, 0.0, None).unwrap();
261        // For score=0 the score_signal is 0.5, combined_score collapses to the prior.
262        let p_zero = t.transform_score(0.0, 0.0, 1.0);
263        approx_eq(
264            p_zero,
265            LegacyCompositePriorTransform::composite_prior(0.0, 1.0),
266            1e-12,
267        );
268    }
269
270    #[test]
271    fn heuristic_upper_bound_dominates_actual_score() {
272        let t = LegacyCompositePriorTransform::new(1.0, 0.0, None).unwrap();
273        let upper = t.heuristic_upper_bound(5.0, 0.9);
274        // Any actual combined_score with score <= 5.0 must not exceed `upper`.
275        for tf in [0.0, 1.0, 10.0] {
276            for r in [0.1, 0.5, 1.0, 2.0] {
277                let actual = t.transform_score(5.0, tf, r);
278                assert!(actual <= upper + 1e-12, "actual {actual} > upper {upper}");
279            }
280        }
281    }
282}