uqa_scoring/
external_prior.rs1use std::collections::BTreeMap;
27use std::sync::Arc;
28
29use uqa_core::{IndexStats, Value};
30
31use crate::bayesian_bm25::{BayesianBM25Params, BayesianBM25Scorer};
32use crate::error::invalid_input;
33use crate::ScoringResult;
34
35pub type PriorFn = Arc<dyn Fn(&BTreeMap<String, Value>) -> f64 + Send + Sync>;
37
38pub struct ExternalPriorScorer {
39 pub params: BayesianBM25Params,
40 pub bm25: BayesianBM25Scorer,
41 prior_fn: PriorFn,
42}
43
44impl ExternalPriorScorer {
45 pub fn new(
46 params: BayesianBM25Params,
47 index_stats: Arc<IndexStats>,
48 prior_fn: PriorFn,
49 ) -> ScoringResult<Self> {
50 let bm25 = BayesianBM25Scorer::new(params, index_stats)?;
51 Ok(Self {
52 params,
53 bm25,
54 prior_fn,
55 })
56 }
57
58 pub fn score_with_prior(
60 &self,
61 term_freq: u64,
62 doc_length: u64,
63 doc_freq: u64,
64 doc_fields: &BTreeMap<String, Value>,
65 ) -> ScoringResult<f64> {
66 let likelihood = self.bm25.score(term_freq, doc_length, doc_freq);
67 let prior = (self.prior_fn)(doc_fields);
68 if !prior.is_finite() || !(0.0..1.0).contains(&prior) || prior == 0.0 {
69 return Err(invalid_input(format!(
70 "external prior must be finite and in (0, 1), got {prior}"
71 )));
72 }
73
74 let logit_likelihood = if likelihood > 0.0 && likelihood < 1.0 {
75 (likelihood / (1.0 - likelihood)).ln()
76 } else if likelihood >= 1.0 {
77 10.0
78 } else {
79 -10.0
80 };
81 let logit_prior = (prior / (1.0 - prior)).ln();
82 let logit_posterior = logit_likelihood + logit_prior;
83 let posterior = 1.0 / (1.0 + (-logit_posterior).exp());
84 if posterior.is_finite() {
85 Ok(posterior)
86 } else {
87 Err(invalid_input("external-prior posterior is non-finite"))
88 }
89 }
90}
91
92pub fn recency_prior(field: impl Into<String>, decay_days: f64) -> PriorFn {
101 let field = field.into();
102 Arc::new(move |fields: &BTreeMap<String, Value>| -> f64 {
103 let Some(val) = fields.get(&field) else {
104 return 0.5;
105 };
106 let Some(ts) = parse_timestamp(val) else {
107 return 0.5;
108 };
109 let now = chrono::Utc::now();
110 let age_days = ((now - ts).num_milliseconds() as f64 / 1000.0 / 86_400.0).max(0.0);
111 0.5 + 0.4 * (-age_days / decay_days).exp()
112 })
113}
114
115pub fn authority_prior(field: impl Into<String>, levels: Option<BTreeMap<String, f64>>) -> PriorFn {
120 let field = field.into();
121 let mapping = levels.unwrap_or_else(|| {
122 let mut m = BTreeMap::new();
123 m.insert("high".to_string(), 0.8);
124 m.insert("medium".to_string(), 0.6);
125 m.insert("low".to_string(), 0.4);
126 m
127 });
128 Arc::new(move |fields: &BTreeMap<String, Value>| -> f64 {
129 let Some(val) = fields.get(&field) else {
130 return 0.5;
131 };
132 let key = match val {
133 Value::Str(s) => s.clone(),
134 Value::Bool(b) => b.to_string(),
135 Value::Int(i) => i.to_string(),
136 Value::Float(f) => f.to_string(),
137 _ => return 0.5,
138 };
139 mapping.get(&key).copied().unwrap_or(0.5)
140 })
141}
142
143fn parse_timestamp(v: &Value) -> Option<chrono::DateTime<chrono::Utc>> {
144 match v {
145 Value::Str(s) => chrono::DateTime::parse_from_rfc3339(s)
146 .ok()
147 .map(|dt| dt.with_timezone(&chrono::Utc)),
148 _ => None,
149 }
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155
156 fn stats(n: u64, avgdl: f64) -> Arc<IndexStats> {
157 let mut s = IndexStats::default();
158 s.total_docs = n;
159 s.avg_doc_length = avgdl;
160 Arc::new(s)
161 }
162
163 #[test]
164 fn prior_higher_than_neutral_lifts_posterior() {
165 let prior = Arc::new(|_: &BTreeMap<String, Value>| 0.9_f64);
166 let s = ExternalPriorScorer::new(BayesianBM25Params::default(), stats(1000, 10.0), prior)
167 .unwrap();
168 let map = BTreeMap::new();
169 let with_prior = s.score_with_prior(3, 10, 50, &map).unwrap();
170 let without = s.bm25.score(3, 10, 50);
171 assert!(with_prior > without, "{with_prior} <= {without}");
172 }
173
174 #[test]
175 fn neutral_prior_recovers_likelihood() {
176 let prior = Arc::new(|_: &BTreeMap<String, Value>| 0.5_f64);
177 let s = ExternalPriorScorer::new(BayesianBM25Params::default(), stats(1000, 10.0), prior)
178 .unwrap();
179 let map = BTreeMap::new();
180 let with_prior = s.score_with_prior(3, 10, 50, &map).unwrap();
181 let without = s.bm25.score(3, 10, 50);
182 assert!((with_prior - without).abs() < 1e-9);
183 }
184
185 #[test]
186 fn invalid_external_prior_is_an_error() {
187 for invalid in [f64::NAN, f64::NEG_INFINITY, 0.0, 1.0, 2.0] {
188 let prior = Arc::new(move |_: &BTreeMap<String, Value>| invalid);
189 let scorer =
190 ExternalPriorScorer::new(BayesianBM25Params::default(), stats(1000, 10.0), prior)
191 .unwrap();
192 assert!(scorer
193 .score_with_prior(3, 10, 50, &BTreeMap::new())
194 .is_err());
195 }
196 }
197
198 #[test]
199 fn authority_prior_maps_known_levels() {
200 let p = authority_prior("rank", None);
201 let mut row = BTreeMap::new();
202 row.insert("rank".to_string(), Value::Str("high".into()));
203 assert!((p(&row) - 0.8).abs() < 1e-9);
204 row.insert("rank".to_string(), Value::Str("low".into()));
205 assert!((p(&row) - 0.4).abs() < 1e-9);
206 row.insert("rank".to_string(), Value::Str("unknown".into()));
207 assert!((p(&row) - 0.5).abs() < 1e-9);
208 }
209}