use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct UtilityPosterior {
pub alpha: f64,
pub beta: f64,
}
impl Default for UtilityPosterior {
fn default() -> Self {
Self::uniform()
}
}
impl UtilityPosterior {
pub fn uniform() -> Self {
Self {
alpha: 1.0,
beta: 1.0,
}
}
pub fn from_counts(success: u64, fail: u64) -> Self {
Self {
alpha: success as f64 + 1.0,
beta: fail as f64 + 1.0,
}
}
pub fn record_success(&mut self) {
self.alpha += 1.0;
}
pub fn record_failure(&mut self) {
self.beta += 1.0;
}
pub fn mean(&self) -> f64 {
self.alpha / (self.alpha + self.beta)
}
pub fn uncertainty(&self) -> f64 {
let s = self.alpha + self.beta;
((self.alpha * self.beta) / (s * s * (s + 1.0))).sqrt()
}
pub fn ucb(&self, exploration: f64) -> f64 {
(self.mean() + exploration * self.uncertainty()).clamp(0.0, 1.0)
}
pub fn thompson(&self, draw: impl FnOnce(f64, f64) -> f64) -> f64 {
draw(self.alpha, self.beta).clamp(0.0, 1.0)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Candidate {
pub id: String,
pub relevance: f64,
#[serde(default)]
pub posterior: UtilityPosterior,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScoredCandidate {
pub id: String,
pub score: f64,
pub relevance: f64,
pub utility: f64,
}
fn blend(relevance: f64, utility: f64, utility_weight: f64) -> f64 {
let w = utility_weight.clamp(0.0, 1.0);
relevance * (1.0 - w) + utility * w
}
pub fn rank_ucb(
candidates: &[Candidate],
exploration: f64,
utility_weight: f64,
) -> Vec<ScoredCandidate> {
let mut scored: Vec<ScoredCandidate> = candidates
.iter()
.map(|c| {
let utility = c.posterior.ucb(exploration);
ScoredCandidate {
id: c.id.clone(),
score: blend(c.relevance, utility, utility_weight),
relevance: c.relevance,
utility,
}
})
.collect();
sort_desc(&mut scored);
scored
}
pub fn rank_thompson(
candidates: &[Candidate],
utility_weight: f64,
mut draw: impl FnMut(f64, f64) -> f64,
) -> Vec<ScoredCandidate> {
let mut scored: Vec<ScoredCandidate> = candidates
.iter()
.map(|c| {
let utility = c.posterior.thompson(&mut draw);
ScoredCandidate {
id: c.id.clone(),
score: blend(c.relevance, utility, utility_weight),
relevance: c.relevance,
utility,
}
})
.collect();
sort_desc(&mut scored);
scored
}
fn sort_desc(scored: &mut [ScoredCandidate]) {
scored.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.id.cmp(&b.id))
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn uniform_prior_is_uncertain_with_mean_half() {
let p = UtilityPosterior::uniform();
assert!((p.mean() - 0.5).abs() < 1e-9);
assert!(p.uncertainty() > 0.2, "cold start should be very uncertain");
}
#[test]
fn evidence_shrinks_uncertainty_and_moves_mean() {
let proven = UtilityPosterior::from_counts(20, 0);
let cold = UtilityPosterior::uniform();
assert!(proven.mean() > 0.9);
assert!(
proven.uncertainty() < cold.uncertainty(),
"more evidence = less uncertainty"
);
}
#[test]
fn record_outcomes_updates_posterior() {
let mut p = UtilityPosterior::uniform();
p.record_success();
p.record_success();
p.record_failure();
assert!((p.mean() - 0.6).abs() < 1e-9);
}
#[test]
fn ucb_gives_coldstart_an_exploration_bonus() {
let cold = UtilityPosterior::uniform(); let seen = UtilityPosterior::from_counts(10, 10); assert!((cold.mean() - seen.mean()).abs() < 1e-9);
assert!(
cold.ucb(1.0) > seen.ucb(1.0),
"cold start is explored under UCB"
);
assert!((cold.ucb(0.0) - seen.ucb(0.0)).abs() < 1e-9);
}
fn cand(id: &str, relevance: f64, success: u64, fail: u64) -> Candidate {
Candidate {
id: id.to_string(),
relevance,
posterior: UtilityPosterior::from_counts(success, fail),
}
}
#[test]
fn weight_zero_is_pure_relevance_order() {
let cands = vec![cand("a", 0.4, 50, 0), cand("b", 0.9, 0, 50)];
let ranked = rank_ucb(&cands, 0.0, 0.0);
assert_eq!(ranked[0].id, "b");
}
#[test]
fn utility_can_outrank_relevance_when_weighted() {
let cands = vec![cand("a", 0.5, 50, 0), cand("b", 0.7, 0, 50)];
let ranked = rank_ucb(&cands, 0.0, 0.8); assert_eq!(
ranked[0].id, "a",
"proven-useful memory outranks more-relevant useless one"
);
}
#[test]
fn thompson_uses_injected_draw_deterministically() {
let cands = vec![cand("a", 0.5, 9, 0), cand("b", 0.5, 0, 9)];
let ranked = rank_thompson(&cands, 1.0, |a, b| a / (a + b)); assert_eq!(ranked[0].id, "a");
}
}