1use std::sync::Arc;
10
11use uqa_core::IndexStats;
12
13use crate::bm25::{BM25Params, BM25Scorer};
14use crate::error::invalid_input;
15use crate::prob::{logit, sigmoid};
16use crate::{PosteriorProbability, RawBm25Score, ScoringResult};
17
18const MIN_SIGMA_SCALE: f64 = 0.25;
22
23#[derive(Debug, Clone, Copy, PartialEq)]
24pub struct BayesianBM25Params {
25 pub bm25: BM25Params,
26 pub alpha: f64,
27 pub beta: f64,
28 pub base_rate: f64,
34 pub calibration_tokens: f64,
38 pub beta_slope: f64,
42 pub sigma_slope: f64,
44}
45
46impl BayesianBM25Params {
47 pub fn evidence_params(&self) -> Self {
53 if self.base_rate <= 0.0 {
54 return *self;
55 }
56 Self {
57 beta: self.beta + logit(self.base_rate) / self.alpha,
58 base_rate: 0.0,
59 ..*self
60 }
61 }
62
63 pub fn scaled_for_query_terms(&self, term_count: usize) -> Self {
77 if self.calibration_tokens <= 0.0 || term_count == 0 {
78 return *self;
79 }
80 let delta = term_count as f64 - self.calibration_tokens;
81 if delta == 0.0 {
82 return *self;
83 }
84 let sigma_reference = self.alpha.recip();
85 let sigma =
86 (sigma_reference + self.sigma_slope * delta).max(sigma_reference * MIN_SIGMA_SCALE);
87 Self {
88 beta: self.beta + self.beta_slope * delta,
89 alpha: sigma.recip(),
90 ..*self
91 }
92 }
93}
94
95impl Default for BayesianBM25Params {
96 fn default() -> Self {
97 Self {
98 bm25: BM25Params::default(),
99 alpha: 1.0,
100 beta: 0.0,
101 base_rate: 0.0,
102 calibration_tokens: 0.0,
103 beta_slope: 0.0,
104 sigma_slope: 0.0,
105 }
106 }
107}
108
109#[derive(Debug, Clone)]
110pub struct BayesianBM25Scorer {
111 pub params: BayesianBM25Params,
112 pub bm25: BM25Scorer,
113}
114
115impl BayesianBM25Scorer {
116 pub fn new(params: BayesianBM25Params, stats: Arc<IndexStats>) -> ScoringResult<Self> {
117 validate_params(params, &stats)?;
118 Ok(Self {
119 params,
120 bm25: BM25Scorer::new(params.bm25, stats),
121 })
122 }
123
124 pub fn idf(&self, doc_freq: u64) -> f64 {
125 self.bm25.idf(doc_freq)
126 }
127
128 pub fn score(&self, term_freq: u64, doc_length: u64, doc_freq: u64) -> f64 {
130 let idf_val = self.bm25.idf(doc_freq);
131 self.score_with_idf(term_freq, doc_length, idf_val)
132 }
133
134 pub fn score_with_idf(&self, term_freq: u64, doc_length: u64, idf_val: f64) -> f64 {
135 let raw = self.bm25.score_with_idf(term_freq, doc_length, idf_val);
136 self.calibrate_raw_value(raw)
137 }
138
139 pub fn calibrate_raw_score(&self, raw_score: RawBm25Score) -> PosteriorProbability {
148 PosteriorProbability::new(self.calibrate_raw_value(raw_score.value()))
149 .expect("sigmoid always produces a valid posterior probability")
150 }
151
152 pub fn combine_scores(
154 &self,
155 raw_term_scores: &[RawBm25Score],
156 ) -> ScoringResult<PosteriorProbability> {
157 let combined = raw_term_scores.iter().map(|score| score.value()).sum();
158 Ok(self.calibrate_raw_score(RawBm25Score::new(combined)?))
159 }
160
161 pub fn upper_bound(&self, doc_freq: u64) -> f64 {
164 let bm25_ub = self.bm25.upper_bound(doc_freq);
165 self.calibrate_raw_value(bm25_ub)
166 }
167
168 pub(crate) fn calibrate_raw_value(&self, raw_score: f64) -> f64 {
169 sigmoid(self.params.alpha * (raw_score - self.params.beta))
170 }
171}
172
173fn validate_params(params: BayesianBM25Params, stats: &IndexStats) -> ScoringResult<()> {
174 if !params.alpha.is_finite() || params.alpha <= 0.0 {
175 return Err(invalid_input(format!(
176 "alpha must be a positive finite value, got {}",
177 params.alpha
178 )));
179 }
180 if !params.beta.is_finite() {
181 return Err(invalid_input(format!(
182 "beta must be a finite value, got {}",
183 params.beta
184 )));
185 }
186 if !params.base_rate.is_finite() || !(0.0..1.0).contains(¶ms.base_rate) {
187 return Err(invalid_input(format!(
188 "base_rate must be in [0, 1), got {}",
189 params.base_rate
190 )));
191 }
192 if !params.calibration_tokens.is_finite() || params.calibration_tokens < 0.0 {
193 return Err(invalid_input(format!(
194 "calibration_tokens must be finite and non-negative, got {}",
195 params.calibration_tokens
196 )));
197 }
198 if !params.beta_slope.is_finite() || !params.sigma_slope.is_finite() {
199 return Err(invalid_input(
200 "Bayesian BM25 calibration slopes must be finite".to_string(),
201 ));
202 }
203 params.bm25.validate()?;
204 if !stats.avg_doc_length.is_finite() || stats.avg_doc_length < 0.0 {
205 return Err(invalid_input(format!(
206 "average document length must be finite and non-negative, got {}",
207 stats.avg_doc_length
208 )));
209 }
210 Ok(())
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216
217 fn stats(n: u64, avgdl: f64) -> Arc<IndexStats> {
218 let mut s = IndexStats::default();
219 s.total_docs = n;
220 s.avg_doc_length = avgdl;
221 Arc::new(s)
222 }
223
224 #[test]
225 fn score_in_unit_interval() {
226 let s = stats(1000, 10.0);
227 let scorer = BayesianBM25Scorer::new(BayesianBM25Params::default(), s.clone()).unwrap();
228 let p = scorer.score(3, 10, 50);
229 assert!(p > 0.0 && p < 1.0, "got {p}");
230 }
231
232 #[test]
233 fn constructor_rejects_invalid_parameters_and_statistics() {
234 let invalid_alpha = BayesianBM25Params {
235 alpha: f64::NAN,
236 ..BayesianBM25Params::default()
237 };
238 assert!(BayesianBM25Scorer::new(invalid_alpha, stats(1000, 10.0)).is_err());
239
240 let invalid_bm25 = BayesianBM25Params {
241 bm25: BM25Params {
242 k1: 0.0,
243 ..BM25Params::default()
244 },
245 ..BayesianBM25Params::default()
246 };
247 assert!(BayesianBM25Scorer::new(invalid_bm25, stats(1000, 10.0)).is_err());
248 assert!(
249 BayesianBM25Scorer::new(BayesianBM25Params::default(), stats(1000, f64::INFINITY),)
250 .is_err()
251 );
252 }
253
254 #[test]
255 fn score_monotone_in_tf() {
256 let s = stats(1000, 10.0);
257 let scorer = BayesianBM25Scorer::new(BayesianBM25Params::default(), s.clone()).unwrap();
258 let mut last = scorer.score(0, 10, 50);
259 for tf in 1..20 {
260 let cur = scorer.score(tf, 10, 50);
261 assert!(cur > last, "tf {tf}: {last} -> {cur}");
262 last = cur;
263 }
264 }
265
266 #[test]
267 fn upper_bound_dominates_observed_scores() {
268 let s = stats(1000, 10.0);
269 let scorer = BayesianBM25Scorer::new(BayesianBM25Params::default(), s.clone()).unwrap();
270 let ub = scorer.upper_bound(50);
271 for tf in [1, 5, 10, 100] {
272 for dl in [1, 5, 50, 500] {
273 let p = scorer.score(tf, dl, 50);
274 assert!(p <= ub + 1e-12, "tf={tf} dl={dl}: {p} > {ub}");
275 }
276 }
277 }
278
279 #[test]
280 fn query_level_calibration_uses_the_bm25_sum() {
281 let scorer =
282 BayesianBM25Scorer::new(BayesianBM25Params::default(), stats(1000, 10.0)).unwrap();
283 let combined = scorer
284 .combine_scores(&[
285 RawBm25Score::new(0.7).unwrap(),
286 RawBm25Score::new(0.4).unwrap(),
287 ])
288 .unwrap()
289 .value();
290 assert!((combined - sigmoid(1.1)).abs() < 1e-12);
291 }
292
293 #[test]
294 fn query_level_calibration_preserves_raw_ranking() {
295 let scorer =
296 BayesianBM25Scorer::new(BayesianBM25Params::default(), stats(1000, 10.0)).unwrap();
297 let lower = scorer
298 .combine_scores(&[
299 RawBm25Score::new(0.7).unwrap(),
300 RawBm25Score::new(0.4).unwrap(),
301 ])
302 .unwrap()
303 .value();
304 let higher = scorer
305 .combine_scores(&[
306 RawBm25Score::new(0.8).unwrap(),
307 RawBm25Score::new(0.5).unwrap(),
308 ])
309 .unwrap()
310 .value();
311 assert!(higher > lower, "{higher} must exceed {lower}");
312 }
313
314 #[test]
315 fn base_rate_never_enters_the_posterior() {
316 let with_prior = BayesianBM25Scorer::new(
317 BayesianBM25Params {
318 base_rate: 0.1,
319 ..BayesianBM25Params::default()
320 },
321 stats(1000, 10.0),
322 )
323 .unwrap();
324 let without_prior =
325 BayesianBM25Scorer::new(BayesianBM25Params::default(), stats(1000, 10.0)).unwrap();
326 let raw_scores = [
327 RawBm25Score::new(0.7).unwrap(),
328 RawBm25Score::new(0.4).unwrap(),
329 ];
330 let combined = with_prior.combine_scores(&raw_scores).unwrap().value();
331 assert!(
332 (combined - without_prior.combine_scores(&raw_scores).unwrap().value()).abs() < 1e-12
333 );
334 assert!((combined - sigmoid(1.1)).abs() < 1e-12);
335 }
336
337 #[test]
338 fn raw_beta_maps_to_half_independently_of_base_rate() {
339 for base_rate in [0.0, 0.01, 0.2, 0.8] {
340 let params = BayesianBM25Params {
341 alpha: 2.5,
342 beta: 3.75,
343 base_rate,
344 ..BayesianBM25Params::default()
345 };
346 let scorer = BayesianBM25Scorer::new(params, stats(1000, 10.0)).unwrap();
347 let midpoint = scorer
348 .calibrate_raw_score(RawBm25Score::new(params.beta).unwrap())
349 .value();
350 assert_eq!(midpoint, 0.5, "base_rate={base_rate}");
351 }
352 }
353
354 #[test]
355 fn query_length_scaling_translates_the_calibration() {
356 let params = BayesianBM25Params {
357 alpha: 0.5,
358 beta: 6.0,
359 calibration_tokens: 5.0,
360 beta_slope: 1.2,
361 sigma_slope: 0.3,
362 ..BayesianBM25Params::default()
363 };
364 let scaled = params.scaled_for_query_terms(15);
365 assert!((scaled.beta - (6.0 + 1.2 * 10.0)).abs() < 1e-12);
366 assert!((scaled.alpha - (2.0_f64 + 0.3 * 10.0).recip()).abs() < 1e-12);
367 assert!((scaled.calibration_tokens - 5.0).abs() < 1e-12);
369 assert!((scaled.beta_slope - 1.2).abs() < 1e-12);
370 }
371
372 #[test]
373 fn query_length_scaling_is_inert_without_a_reference() {
374 let params = BayesianBM25Params {
375 alpha: 1.7,
376 beta: 0.8,
377 base_rate: 0.08,
378 ..BayesianBM25Params::default()
379 };
380 let scaled = params.scaled_for_query_terms(12);
381 assert!((scaled.alpha - params.alpha).abs() < 1e-12);
382 assert!((scaled.beta - params.beta).abs() < 1e-12);
383 let reference = BayesianBM25Params {
384 calibration_tokens: 5.0,
385 ..params
386 };
387 let same_length = reference.scaled_for_query_terms(5);
388 assert!((same_length.beta - reference.beta).abs() < 1e-12);
389 }
390
391 #[test]
392 fn query_length_scaling_floors_the_spread() {
393 let params = BayesianBM25Params {
394 alpha: 1.0,
395 beta: 6.0,
396 calibration_tokens: 5.0,
397 beta_slope: 1.2,
398 sigma_slope: 0.3,
399 ..BayesianBM25Params::default()
400 };
401 let scaled = params.scaled_for_query_terms(1);
404 assert!((scaled.alpha - 4.0).abs() < 1e-12, "got {}", scaled.alpha);
405 }
406
407 #[test]
408 fn evidence_params_subtract_the_prior_in_logit_space() {
409 let params = BayesianBM25Params {
410 alpha: 2.0,
411 beta: 3.0,
412 base_rate: 0.05,
413 ..BayesianBM25Params::default()
414 };
415 let posterior_scorer = BayesianBM25Scorer::new(params, stats(1000, 10.0)).unwrap();
416 let evidence_scorer =
417 BayesianBM25Scorer::new(params.evidence_params(), stats(1000, 10.0)).unwrap();
418 for raw in [0.0, 1.5, 3.0, 6.0] {
419 let raw = RawBm25Score::new(raw).unwrap();
420 let posterior = posterior_scorer.calibrate_raw_score(raw).value();
421 let evidence = evidence_scorer.calibrate_raw_score(raw).value();
422 let expected = sigmoid(logit(posterior) - logit(0.05));
423 assert!(
424 (evidence - expected).abs() < 1e-12,
425 "raw {}: {evidence} vs {expected}",
426 raw.value()
427 );
428 }
429 let plain = BayesianBM25Params::default();
430 assert!((plain.evidence_params().beta - plain.beta).abs() < 1e-12);
431 }
432}