Skip to main content

uqa_scoring/
score_domain.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Semantic score-domain types.
8//!
9//! The scoring pipeline uses several numerically identical `f64` values with
10//! different algebraic meanings. These wrappers make the conversions explicit
11//! at public mathematical boundaries so raw BM25 scores, evidence logits,
12//! priors, and posterior probabilities cannot be combined accidentally.
13
14use crate::error::{require_finite, require_probability};
15use crate::prob::{logit, sigmoid};
16use crate::ScoringResult;
17
18/// An uncalibrated, complete BM25 query score.
19#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
20#[repr(transparent)]
21pub struct RawBm25Score(f64);
22
23impl RawBm25Score {
24    pub fn new(value: f64) -> ScoringResult<Self> {
25        require_finite(value, "raw BM25 score")?;
26        Ok(Self(value))
27    }
28
29    pub const fn value(self) -> f64 {
30        self.0
31    }
32}
33
34impl TryFrom<f64> for RawBm25Score {
35    type Error = crate::ScoringError;
36
37    fn try_from(value: f64) -> Result<Self, Self::Error> {
38        Self::new(value)
39    }
40}
41
42impl From<RawBm25Score> for f64 {
43    fn from(score: RawBm25Score) -> Self {
44        score.value()
45    }
46}
47
48/// Signed prior-free log-likelihood-ratio evidence.
49///
50/// Zero is neutral. Positive values support relevance and negative values
51/// oppose it.
52#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
53#[repr(transparent)]
54pub struct EvidenceLogit(f64);
55
56impl EvidenceLogit {
57    pub fn new(value: f64) -> ScoringResult<Self> {
58        require_finite(value, "evidence logit")?;
59        Ok(Self(value))
60    }
61
62    /// Convert a prior-free probability-like evidence value into logit space.
63    ///
64    /// Calling this method is an explicit assertion that `probability` does
65    /// not already contain a relevance prior.
66    pub fn from_prior_free_probability(probability: f64) -> ScoringResult<Self> {
67        require_probability(probability, "prior-free evidence probability")?;
68        Ok(Self(logit(probability)))
69    }
70
71    pub const fn neutral() -> Self {
72        Self(0.0)
73    }
74
75    pub const fn value(self) -> f64 {
76        self.0
77    }
78}
79
80impl TryFrom<f64> for EvidenceLogit {
81    type Error = crate::ScoringError;
82
83    fn try_from(value: f64) -> Result<Self, Self::Error> {
84        Self::new(value)
85    }
86}
87
88impl From<EvidenceLogit> for f64 {
89    fn from(logit: EvidenceLogit) -> Self {
90        logit.value()
91    }
92}
93
94/// A relevance prior represented in log-odds space.
95#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
96#[repr(transparent)]
97pub struct PriorLogit(f64);
98
99impl PriorLogit {
100    pub fn new(value: f64) -> ScoringResult<Self> {
101        require_finite(value, "prior logit")?;
102        Ok(Self(value))
103    }
104
105    pub fn from_probability(probability: f64) -> ScoringResult<Self> {
106        require_probability(probability, "prior probability")?;
107        if probability == 0.0 || probability == 1.0 {
108            return Err(crate::ScoringError::InvalidInput(format!(
109                "prior probability must be strictly between 0 and 1, got {probability}"
110            )));
111        }
112        Ok(Self(logit(probability)))
113    }
114
115    pub const fn neutral() -> Self {
116        Self(0.0)
117    }
118
119    pub const fn value(self) -> f64 {
120        self.0
121    }
122}
123
124impl TryFrom<f64> for PriorLogit {
125    type Error = crate::ScoringError;
126
127    fn try_from(value: f64) -> Result<Self, Self::Error> {
128        Self::new(value)
129    }
130}
131
132impl From<PriorLogit> for f64 {
133    fn from(logit: PriorLogit) -> Self {
134        logit.value()
135    }
136}
137
138/// A probability in the closed unit interval that is explicitly interpreted
139/// as a posterior relevance probability.
140///
141/// The wrapper enforces the numeric and algebraic domain; it does not certify
142/// empirical calibration. Parameters fitted without labels must still be
143/// evaluated on held-out judgments before their outputs are described as
144/// calibrated probabilities.
145#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
146#[repr(transparent)]
147pub struct PosteriorProbability(f64);
148
149impl PosteriorProbability {
150    pub fn new(value: f64) -> ScoringResult<Self> {
151        require_probability(value, "posterior probability")?;
152        Ok(Self(value))
153    }
154
155    pub fn from_logit(value: f64) -> ScoringResult<Self> {
156        require_finite(value, "posterior logit")?;
157        Self::new(sigmoid(value))
158    }
159
160    pub const fn value(self) -> f64 {
161        self.0
162    }
163}
164
165impl TryFrom<f64> for PosteriorProbability {
166    type Error = crate::ScoringError;
167
168    fn try_from(value: f64) -> Result<Self, Self::Error> {
169        Self::new(value)
170    }
171}
172
173impl From<PosteriorProbability> for f64 {
174    fn from(probability: PosteriorProbability) -> Self {
175        probability.value()
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn domain_constructors_reject_cross_domain_invalid_values() {
185        assert!(RawBm25Score::new(f64::NAN).is_err());
186        assert!(EvidenceLogit::new(f64::INFINITY).is_err());
187        assert!(PriorLogit::from_probability(0.0).is_err());
188        assert!(PriorLogit::from_probability(1.0).is_err());
189        assert!(PosteriorProbability::new(-0.1).is_err());
190        assert!(PosteriorProbability::new(1.1).is_err());
191    }
192
193    #[test]
194    fn neutral_evidence_and_prior_have_zero_logit() {
195        assert_eq!(EvidenceLogit::neutral().value(), 0.0);
196        assert_eq!(PriorLogit::neutral().value(), 0.0);
197        assert_eq!(
198            EvidenceLogit::from_prior_free_probability(0.5)
199                .unwrap()
200                .value(),
201            0.0
202        );
203        assert_eq!(PriorLogit::from_probability(0.5).unwrap().value(), 0.0);
204    }
205
206    #[test]
207    fn posterior_logit_conversion_is_explicit() {
208        let posterior = PosteriorProbability::from_logit(0.0).unwrap();
209        assert_eq!(posterior.value(), 0.5);
210    }
211}