Skip to main content

uqa_scoring/
prob.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Numerically stable sigmoid / logit and probabilistic AND/OR/NOT in log
8//! space. The explicitly named `confidence_scaled_log_odds_pool` implements
9//! the robust `n^alpha` ranking heuristic; exact signed single-prior evidence
10//! fusion lives in `uqa-fusion::BayesianEvidenceFusion`.
11
12/// Probability clamp epsilon (Eq. 40, Paper 3).
13pub const PROB_EPSILON: f64 = 1e-10;
14
15#[inline]
16pub fn clamp_prob(p: f64) -> f64 {
17    p.clamp(PROB_EPSILON, 1.0 - PROB_EPSILON)
18}
19
20/// Numerically stable sigmoid:
21/// - `x >= 0`: `1 / (1 + exp(-x))`
22/// - `x <  0`: `exp(x) / (1 + exp(x))`
23#[inline]
24pub fn sigmoid(x: f64) -> f64 {
25    if x >= 0.0 {
26        1.0 / (1.0 + (-x).exp())
27    } else {
28        let e = x.exp();
29        e / (1.0 + e)
30    }
31}
32
33/// Inverse sigmoid: `log(p / (1 - p))`. Input is clamped to
34/// `(epsilon, 1 - epsilon)` first.
35#[inline]
36pub fn logit(p: f64) -> f64 {
37    let p = clamp_prob(p);
38    (p / (1.0 - p)).ln()
39}
40
41/// Cosine similarity to probability: `(1 + score) / 2` clamped to
42/// `(epsilon, 1 - epsilon)` (Definition 7.1.2, Paper 3).
43#[inline]
44pub fn cosine_to_probability(score: f64) -> f64 {
45    clamp_prob(f64::midpoint(1.0, score))
46}
47
48/// Probabilistic NOT: `1 - p`.
49#[inline]
50pub fn prob_not(p: f64) -> f64 {
51    clamp_prob(1.0 - clamp_prob(p))
52}
53
54/// Probabilistic AND in log space: `exp(sum(ln p_i))`.
55pub fn prob_and(probs: &[f64]) -> f64 {
56    if probs.is_empty() {
57        return 1.0;
58    }
59    let s: f64 = probs.iter().map(|&p| clamp_prob(p).ln()).sum();
60    s.exp()
61}
62
63/// Probabilistic OR in log space: `1 - exp(sum(ln(1 - p_i)))`.
64pub fn prob_or(probs: &[f64]) -> f64 {
65    if probs.is_empty() {
66        return 0.0;
67    }
68    let s: f64 = probs.iter().map(|&p| (1.0 - clamp_prob(p)).ln()).sum();
69    1.0 - s.exp()
70}
71
72/// Confidence-scaled log-odds ranking pool.
73///
74/// `P_final = sigmoid((1 / n^(1-alpha)) * sum(logit(p_i)))`
75///
76/// Rearranged in implementation form: `sigmoid(n^alpha * mean(logit p_i))`.
77/// Default `alpha = 0.5` yields the `sqrt(n)` law. This confidence scaling is
78/// a ranking heuristic, not the exact single-prior Bayesian evidence theorem.
79pub fn confidence_scaled_log_odds_pool(probs: &[f64], alpha: f64) -> f64 {
80    if probs.is_empty() {
81        return 0.5;
82    }
83    let n = probs.len() as f64;
84    let mean_logit: f64 = probs.iter().map(|&p| logit(p)).sum::<f64>() / n;
85    sigmoid(mean_logit * n.powf(alpha))
86}
87
88/// Weighted confidence-scaled log-odds ranking pool.
89///
90/// `sigmoid(n^alpha * sum(w_i * logit(p_i)))`
91///
92/// `weights` must be non-negative and sum to ~1. Returns `Err` otherwise.
93pub fn confidence_scaled_log_odds_pool_weighted(
94    probs: &[f64],
95    weights: &[f64],
96    alpha: f64,
97) -> Result<f64, &'static str> {
98    if probs.len() != weights.len() {
99        return Err("probs and weights must have the same length");
100    }
101    if probs.is_empty() {
102        return Ok(0.5);
103    }
104    if weights.iter().any(|w| *w < 0.0) {
105        return Err("weights must be non-negative");
106    }
107    let sum_w: f64 = weights.iter().sum();
108    if (sum_w - 1.0).abs() > 1e-6 {
109        return Err("weights must sum to 1");
110    }
111    let n = probs.len() as f64;
112    let weighted_logit: f64 = probs.iter().zip(weights).map(|(&p, &w)| w * logit(p)).sum();
113    Ok(sigmoid(n.powf(alpha) * weighted_logit))
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    fn approx_eq(a: f64, b: f64) {
121        assert!((a - b).abs() < 1e-9, "expected {a} ~ {b}");
122    }
123
124    #[test]
125    fn sigmoid_logit_round_trip() {
126        for p in [0.01, 0.1, 0.3, 0.5, 0.7, 0.9, 0.99] {
127            approx_eq(sigmoid(logit(p)), p);
128        }
129    }
130
131    #[test]
132    fn sigmoid_handles_extremes() {
133        assert!(sigmoid(50.0) > 1.0 - 1e-10);
134        assert!(sigmoid(-50.0) < 1e-10);
135        assert!(sigmoid(0.0) - 0.5 < 1e-12);
136    }
137
138    #[test]
139    fn cosine_maps_to_unit_interval() {
140        approx_eq(cosine_to_probability(1.0), 1.0 - PROB_EPSILON);
141        approx_eq(cosine_to_probability(-1.0), PROB_EPSILON);
142        approx_eq(cosine_to_probability(0.0), 0.5);
143    }
144
145    #[test]
146    fn prob_and_log_space_matches_product() {
147        approx_eq(prob_and(&[0.5, 0.5, 0.5]), 0.125);
148        approx_eq(prob_and(&[0.9, 0.8]), 0.72);
149    }
150
151    #[test]
152    fn prob_or_log_space_matches_inclusion_exclusion() {
153        approx_eq(prob_or(&[0.5, 0.5]), 0.75);
154        approx_eq(prob_or(&[0.0, 0.0]), 0.0);
155    }
156
157    #[test]
158    fn confidence_scaled_log_odds_pool_n1_identity() {
159        approx_eq(confidence_scaled_log_odds_pool(&[0.7], 0.5), 0.7);
160    }
161
162    #[test]
163    fn confidence_scaled_log_odds_pool_scale_neutral_at_alpha_zero() {
164        // alpha = 0 is the only setting that gives scale neutrality
165        // (P_final = p when all P_i = p). The default alpha = 0.5
166        // intentionally amplifies agreement away from the mean.
167        for p in [0.2, 0.5, 0.8] {
168            for n in 1..6 {
169                let probs = vec![p; n];
170                let got = confidence_scaled_log_odds_pool(&probs, 0.0);
171                approx_eq(got, p);
172            }
173        }
174    }
175
176    #[test]
177    fn confidence_scaled_log_odds_pool_amplifies_agreement_at_alpha_half() {
178        // With alpha = 0.5 and all-equal P_i > 0.5, P_final pushes the
179        // probability further away from 0.5 as n grows (Theorem 4.3.x in
180        // Paper 4: agreement amplification). Symmetric on the
181        // irrelevance side: P_i < 0.5 -> P_final < P_i.
182        let p = 0.7;
183        let p1 = confidence_scaled_log_odds_pool(&[p], 0.5);
184        let p3 = confidence_scaled_log_odds_pool(&[p; 3], 0.5);
185        let p5 = confidence_scaled_log_odds_pool(&[p; 5], 0.5);
186        assert!(p1 < p3 && p3 < p5, "amplification: {p1} < {p3} < {p5}");
187    }
188
189    #[test]
190    fn confidence_scaled_log_odds_pool_irrelevance_preserving() {
191        // All P_i < 0.5 implies P_final < 0.5.
192        let probs = [0.2, 0.3, 0.4];
193        let got = confidence_scaled_log_odds_pool(&probs, 0.5);
194        assert!(got < 0.5, "got {got}");
195    }
196
197    #[test]
198    fn confidence_scaled_log_odds_pool_relevance_preserving() {
199        let probs = [0.6, 0.7, 0.8];
200        let got = confidence_scaled_log_odds_pool(&probs, 0.5);
201        assert!(got > 0.5, "got {got}");
202    }
203
204    #[test]
205    fn confidence_scaled_log_odds_pool_symmetric_disagreement_collapses_to_half() {
206        // logit(0.3) and logit(0.7) cancel.
207        let got = confidence_scaled_log_odds_pool(&[0.3, 0.7], 0.5);
208        approx_eq(got, 0.5);
209    }
210
211    #[test]
212    fn weighted_log_odds_rejects_bad_weights() {
213        assert!(confidence_scaled_log_odds_pool_weighted(&[0.5, 0.5], &[0.5, 0.6], 0.0).is_err());
214        assert!(confidence_scaled_log_odds_pool_weighted(&[0.5, 0.5], &[-0.1, 1.1], 0.0).is_err());
215    }
216}