1use crate::prob::{clamp_prob, sigmoid};
18use crate::{error::invalid_input, ScoringResult};
19
20#[derive(Debug, Clone, Copy)]
24pub struct LegacyCompositePriorTransform {
25 pub alpha: f64,
26 pub beta: f64,
27 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 #[inline]
67 pub fn score_signal(&self, score: f64) -> f64 {
68 sigmoid(self.alpha * (score - self.beta))
69 }
70
71 #[inline]
74 pub fn tf_prior(tf: f64) -> f64 {
75 0.2 + 0.7 * (tf / 10.0).min(1.0)
76 }
77
78 #[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 #[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 pub fn no_match_floor() -> f64 {
99 Self::composite_prior(0.0, 1.0)
100 }
101
102 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 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 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 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 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 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}