#![allow(clippy::cast_precision_loss)]
use crate::models::TrustClass;
pub const DEFAULT_PRIOR_ALPHA: f64 = 0.5;
pub const DEFAULT_PRIOR_BETA: f64 = 0.5;
pub const DEFAULT_HARMFUL_WEIGHT: f64 = 2.5;
const TRUST_TRANSITION_CI_LEVEL: f64 = 0.90;
const UTILITY_INVERSE_ENDPOINT_EPSILON: f64 = 1.0e-9;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FeedbackSignal {
Helpful,
Harmful,
Neutral,
}
impl FeedbackSignal {
#[must_use]
pub fn from_signal_str(raw: &str) -> Self {
match raw.trim().to_ascii_lowercase().as_str() {
"helpful" | "positive" | "confirmation" => Self::Helpful,
"harmful" | "negative" | "contradiction" | "inaccurate" | "outdated" | "stale" => {
Self::Harmful
}
_ => Self::Neutral,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BetaPosterior {
alpha: f64,
beta: f64,
}
impl BetaPosterior {
#[must_use]
pub fn new(alpha: f64, beta: f64) -> Option<Self> {
if alpha.is_finite() && beta.is_finite() && alpha > 0.0 && beta > 0.0 {
Some(Self { alpha, beta })
} else {
None
}
}
#[must_use]
pub const fn jeffreys() -> Self {
Self {
alpha: DEFAULT_PRIOR_ALPHA,
beta: DEFAULT_PRIOR_BETA,
}
}
#[must_use]
pub fn from_utility_inverse(confidence: f64, total_weight: f64) -> Option<Self> {
if !confidence.is_finite()
|| !total_weight.is_finite()
|| !(0.0..=1.0).contains(&confidence)
|| total_weight <= 0.0
{
return None;
}
let confidence = confidence.clamp(
UTILITY_INVERSE_ENDPOINT_EPSILON,
1.0 - UTILITY_INVERSE_ENDPOINT_EPSILON,
);
let alpha = confidence * total_weight;
let beta = (1.0 - confidence) * total_weight;
Self::new(alpha, beta)
}
#[must_use]
pub fn from_feedback_events(events: impl IntoIterator<Item = (FeedbackSignal, f64)>) -> Self {
let mut posterior = Self::jeffreys();
for (signal, weight) in events {
match signal {
FeedbackSignal::Helpful => {
posterior = posterior.update_helpful_weighted(valid_weight_or(weight, 1.0));
}
FeedbackSignal::Harmful => {
posterior =
posterior.update_harmful(valid_weight_or(weight, DEFAULT_HARMFUL_WEIGHT));
}
FeedbackSignal::Neutral => {}
}
}
posterior
}
#[must_use]
pub const fn alpha(&self) -> f64 {
self.alpha
}
#[must_use]
pub const fn beta(&self) -> f64 {
self.beta
}
#[must_use]
pub fn effective_sample_size(&self) -> f64 {
self.alpha + self.beta
}
#[must_use]
pub fn mean(&self) -> f64 {
self.alpha / (self.alpha + self.beta)
}
#[must_use]
pub fn variance(&self) -> f64 {
let sum = self.alpha + self.beta;
(self.alpha * self.beta) / (sum * sum * (sum + 1.0))
}
#[must_use]
pub fn update_helpful(self) -> Self {
self.update_helpful_weighted(1.0)
}
#[must_use]
pub fn update_helpful_weighted(self, weight: f64) -> Self {
let w = valid_weight_or(weight, 0.0);
if w == 0.0 {
return self;
}
Self {
alpha: self.alpha + w,
beta: self.beta,
}
}
#[must_use]
pub fn update_harmful(self, harmful_weight: f64) -> Self {
let w = if harmful_weight.is_finite() && harmful_weight > 0.0 {
harmful_weight
} else {
0.0
};
if w == 0.0 {
return self;
}
Self {
alpha: self.alpha,
beta: self.beta + w,
}
}
#[must_use]
pub fn credible_interval(&self, level: f64) -> Option<(f64, f64)> {
if !(level > 0.0 && level < 1.0) {
return None;
}
let tail = (1.0 - level) / 2.0;
let lo = beta_inv_cdf(tail, self.alpha, self.beta)?;
let hi = beta_inv_cdf(1.0 - tail, self.alpha, self.beta)?;
Some((lo, hi))
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TrustClassTransitionDirection {
Promote,
Demote,
Stable,
}
impl TrustClassTransitionDirection {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Promote => "promote",
Self::Demote => "demote",
Self::Stable => "stable",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TrustClassTransition {
pub previous_class: TrustClass,
pub next_class: TrustClass,
pub direction: TrustClassTransitionDirection,
pub ci90_lower: Option<f64>,
pub ci90_upper: Option<f64>,
pub effective_sample_size: f64,
pub validation_events: u64,
pub explicit_human_promotion: bool,
pub reason: &'static str,
pub audit_required: bool,
}
impl TrustClassTransition {
fn stable(
current_class: TrustClass,
posterior: &BetaPosterior,
ci90: Option<(f64, f64)>,
validation_events: u64,
explicit_human_promotion: bool,
reason: &'static str,
) -> Self {
Self::new(
current_class,
current_class,
TrustClassTransitionDirection::Stable,
posterior,
ci90,
validation_events,
explicit_human_promotion,
reason,
)
}
fn promote(
current_class: TrustClass,
next_class: TrustClass,
posterior: &BetaPosterior,
ci90: (f64, f64),
validation_events: u64,
explicit_human_promotion: bool,
reason: &'static str,
) -> Self {
Self::new(
current_class,
next_class,
TrustClassTransitionDirection::Promote,
posterior,
Some(ci90),
validation_events,
explicit_human_promotion,
reason,
)
}
fn demote(
current_class: TrustClass,
next_class: TrustClass,
posterior: &BetaPosterior,
ci90: (f64, f64),
validation_events: u64,
explicit_human_promotion: bool,
reason: &'static str,
) -> Self {
Self::new(
current_class,
next_class,
TrustClassTransitionDirection::Demote,
posterior,
Some(ci90),
validation_events,
explicit_human_promotion,
reason,
)
}
fn new(
previous_class: TrustClass,
next_class: TrustClass,
direction: TrustClassTransitionDirection,
posterior: &BetaPosterior,
ci90: Option<(f64, f64)>,
validation_events: u64,
explicit_human_promotion: bool,
reason: &'static str,
) -> Self {
let (ci90_lower, ci90_upper) = match ci90 {
Some((lower, upper)) => (Some(lower), Some(upper)),
None => (None, None),
};
Self {
previous_class,
next_class,
direction,
ci90_lower,
ci90_upper,
effective_sample_size: posterior.effective_sample_size(),
validation_events,
explicit_human_promotion,
reason,
audit_required: direction != TrustClassTransitionDirection::Stable,
}
}
}
#[must_use]
pub fn trust_class_transition(
current_class: TrustClass,
posterior: &BetaPosterior,
validation_events: u64,
explicit_human_promotion: bool,
) -> TrustClassTransition {
let ci90 = posterior.credible_interval(TRUST_TRANSITION_CI_LEVEL);
let Some((ci90_lower, ci90_upper)) = ci90 else {
return TrustClassTransition::stable(
current_class,
posterior,
None,
validation_events,
explicit_human_promotion,
"ci90_unavailable",
);
};
match current_class {
TrustClass::LegacyImport => {
if ci90_lower > 0.50 && validation_events > 0 {
TrustClassTransition::promote(
current_class,
TrustClass::CassEvidence,
posterior,
(ci90_lower, ci90_upper),
validation_events,
explicit_human_promotion,
"legacy_import_promote_ci90_lower_gt_0_50_with_validation",
)
} else if ci90_lower > 0.50 {
TrustClassTransition::stable(
current_class,
posterior,
Some((ci90_lower, ci90_upper)),
validation_events,
explicit_human_promotion,
"legacy_import_validation_required",
)
} else {
TrustClassTransition::stable(
current_class,
posterior,
Some((ci90_lower, ci90_upper)),
validation_events,
explicit_human_promotion,
"no_transition_threshold_crossed",
)
}
}
TrustClass::CassEvidence => {
if ci90_lower > 0.60 {
TrustClassTransition::promote(
current_class,
TrustClass::AgentAssertion,
posterior,
(ci90_lower, ci90_upper),
validation_events,
explicit_human_promotion,
"cass_evidence_promote_ci90_lower_gt_0_60",
)
} else if ci90_upper < 0.30 {
TrustClassTransition::demote(
current_class,
TrustClass::LegacyImport,
posterior,
(ci90_lower, ci90_upper),
validation_events,
explicit_human_promotion,
"cass_evidence_demote_ci90_upper_lt_0_30",
)
} else {
TrustClassTransition::stable(
current_class,
posterior,
Some((ci90_lower, ci90_upper)),
validation_events,
explicit_human_promotion,
"no_transition_threshold_crossed",
)
}
}
TrustClass::AgentAssertion => {
if ci90_lower > 0.70 && posterior.effective_sample_size() >= 6.0 {
TrustClassTransition::promote(
current_class,
TrustClass::AgentValidated,
posterior,
(ci90_lower, ci90_upper),
validation_events,
explicit_human_promotion,
"agent_assertion_promote_ci90_lower_gt_0_70_sample_size_ge_6",
)
} else if ci90_lower > 0.70 {
TrustClassTransition::stable(
current_class,
posterior,
Some((ci90_lower, ci90_upper)),
validation_events,
explicit_human_promotion,
"agent_assertion_sample_size_gate_unmet",
)
} else if ci90_upper < 0.35 {
TrustClassTransition::demote(
current_class,
TrustClass::CassEvidence,
posterior,
(ci90_lower, ci90_upper),
validation_events,
explicit_human_promotion,
"agent_assertion_demote_ci90_upper_lt_0_35",
)
} else {
TrustClassTransition::stable(
current_class,
posterior,
Some((ci90_lower, ci90_upper)),
validation_events,
explicit_human_promotion,
"no_transition_threshold_crossed",
)
}
}
TrustClass::AgentValidated => {
if explicit_human_promotion {
TrustClassTransition::promote(
current_class,
TrustClass::HumanExplicit,
posterior,
(ci90_lower, ci90_upper),
validation_events,
explicit_human_promotion,
"agent_validated_promote_explicit_human_operator",
)
} else if ci90_upper < 0.40 {
TrustClassTransition::demote(
current_class,
TrustClass::AgentAssertion,
posterior,
(ci90_lower, ci90_upper),
validation_events,
explicit_human_promotion,
"agent_validated_demote_ci90_upper_lt_0_40",
)
} else {
TrustClassTransition::stable(
current_class,
posterior,
Some((ci90_lower, ci90_upper)),
validation_events,
explicit_human_promotion,
"human_explicit_promotion_requires_operator",
)
}
}
TrustClass::PeerHumanAttested => {
if explicit_human_promotion {
TrustClassTransition::promote(
current_class,
TrustClass::HumanExplicit,
posterior,
(ci90_lower, ci90_upper),
validation_events,
explicit_human_promotion,
"peer_human_attested_promote_explicit_local_operator",
)
} else if ci90_upper < 0.45 {
TrustClassTransition::demote(
current_class,
TrustClass::AgentValidated,
posterior,
(ci90_lower, ci90_upper),
validation_events,
explicit_human_promotion,
"peer_human_attested_demote_ci90_upper_lt_0_45",
)
} else {
TrustClassTransition::stable(
current_class,
posterior,
Some((ci90_lower, ci90_upper)),
validation_events,
explicit_human_promotion,
"local_human_promotion_requires_operator",
)
}
}
TrustClass::HumanExplicit => {
if ci90_upper < 0.45 {
TrustClassTransition::demote(
current_class,
TrustClass::AgentValidated,
posterior,
(ci90_lower, ci90_upper),
validation_events,
explicit_human_promotion,
"human_explicit_demote_ci90_upper_lt_0_45",
)
} else {
TrustClassTransition::stable(
current_class,
posterior,
Some((ci90_lower, ci90_upper)),
validation_events,
explicit_human_promotion,
"no_transition_threshold_crossed",
)
}
}
}
}
fn valid_weight_or(weight: f64, default: f64) -> f64 {
if weight.is_finite() && weight > 0.0 {
weight
} else if default.is_finite() && default > 0.0 {
default
} else {
0.0
}
}
fn beta_inv_cdf(p: f64, alpha: f64, beta: f64) -> Option<f64> {
if !(0.0..=1.0).contains(&p) || alpha <= 0.0 || beta <= 0.0 {
return None;
}
if p == 0.0 {
return Some(0.0);
}
if p == 1.0 {
return Some(1.0);
}
let mut lo = 0.0;
let mut hi = 1.0;
for _ in 0..96 {
let mid = (lo + hi) * 0.5;
let cdf = regularized_incomplete_beta(mid, alpha, beta);
if !cdf.is_finite() {
return None;
}
if cdf < p {
lo = mid;
} else {
hi = mid;
}
if hi - lo < 1e-12 {
break;
}
}
Some(((lo + hi) * 0.5).clamp(0.0, 1.0))
}
fn ln_gamma(x: f64) -> f64 {
const G: f64 = 7.0;
const COEF: [f64; 9] = [
0.999_999_999_999_809_9,
676.520_368_121_885_1,
-1_259.139_216_722_402_8,
771.323_428_777_653_1,
-176.615_029_162_140_6,
12.507_343_278_686_905,
-0.138_571_095_265_720_12,
9.984_369_578_019_572e-6,
1.505_632_735_149_311_6e-7,
];
if x < 0.5 {
std::f64::consts::PI.ln() - (std::f64::consts::PI * x).sin().ln() - ln_gamma(1.0 - x)
} else {
let x = x - 1.0;
let mut a = COEF[0];
for (i, c) in COEF.iter().enumerate().skip(1) {
a += c / (x + i as f64);
}
let t = x + G + 0.5;
(2.0 * std::f64::consts::PI).sqrt().ln() + (x + 0.5) * t.ln() - t + a.ln()
}
}
fn regularized_incomplete_beta(x: f64, alpha: f64, beta: f64) -> f64 {
if x <= 0.0 {
return 0.0;
}
if x >= 1.0 {
return 1.0;
}
let log_bt = ln_gamma(alpha + beta) - ln_gamma(alpha) - ln_gamma(beta)
+ alpha * x.ln()
+ beta * (1.0 - x).ln();
let bt = log_bt.exp();
if x < (alpha + 1.0) / (alpha + beta + 2.0) {
bt * beta_continued_fraction(x, alpha, beta) / alpha
} else {
1.0 - bt * beta_continued_fraction(1.0 - x, beta, alpha) / beta
}
}
fn beta_continued_fraction(x: f64, a: f64, b: f64) -> f64 {
const MAX_ITER: usize = 200;
const EPS: f64 = 3.0e-15;
const FPMIN: f64 = 1.0e-300;
let qab = a + b;
let qap = a + 1.0;
let qam = a - 1.0;
let mut c = 1.0;
let mut d = 1.0 - qab * x / qap;
if d.abs() < FPMIN {
d = FPMIN;
}
d = 1.0 / d;
let mut h = d;
for m in 1..=MAX_ITER {
let m_f = m as f64;
let m2 = 2.0 * m_f;
let aa = m_f * (b - m_f) * x / ((qam + m2) * (a + m2));
d = 1.0 + aa * d;
if d.abs() < FPMIN {
d = FPMIN;
}
c = 1.0 + aa / c;
if c.abs() < FPMIN {
c = FPMIN;
}
d = 1.0 / d;
h *= d * c;
let aa = -(a + m_f) * (qab + m_f) * x / ((a + m2) * (qap + m2));
d = 1.0 + aa * d;
if d.abs() < FPMIN {
d = FPMIN;
}
c = 1.0 + aa / c;
if c.abs() < FPMIN {
c = FPMIN;
}
d = 1.0 / d;
let del = d * c;
h *= del;
if (del - 1.0).abs() < EPS {
return h;
}
}
h
}
#[cfg(test)]
mod tests {
use super::*;
fn approx_eq(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() < tol
}
#[test]
fn jeffreys_default_has_mean_half() {
let p = BetaPosterior::jeffreys();
assert!(approx_eq(p.mean(), 0.5, 1e-12));
assert_eq!(p.alpha(), 0.5);
assert_eq!(p.beta(), 0.5);
}
#[test]
fn new_rejects_non_positive() {
assert!(BetaPosterior::new(0.0, 1.0).is_none());
assert!(BetaPosterior::new(1.0, 0.0).is_none());
assert!(BetaPosterior::new(-1.0, 1.0).is_none());
assert!(BetaPosterior::new(f64::NAN, 1.0).is_none());
assert!(BetaPosterior::new(f64::INFINITY, 1.0).is_none());
}
#[test]
fn helpful_event_adds_one_to_alpha() {
let p = BetaPosterior::jeffreys().update_helpful();
assert_eq!(p.alpha(), 1.5);
assert_eq!(p.beta(), 0.5);
}
#[test]
fn harmful_event_adds_weight_to_beta() {
let p = BetaPosterior::jeffreys().update_harmful(2.5);
assert_eq!(p.alpha(), 0.5);
assert_eq!(p.beta(), 3.0);
}
#[test]
fn harmful_event_with_invalid_weight_is_noop() {
let p = BetaPosterior::jeffreys()
.update_harmful(-1.0)
.update_harmful(f64::NAN)
.update_harmful(f64::INFINITY);
assert_eq!(p, BetaPosterior::jeffreys());
}
#[test]
fn effective_sample_size_grows_with_evidence() {
let p = BetaPosterior::jeffreys();
assert!(approx_eq(p.effective_sample_size(), 1.0, 1e-12));
let p = p.update_helpful().update_helpful().update_harmful(2.5);
assert!(approx_eq(p.effective_sample_size(), 5.5, 1e-12));
}
#[test]
fn mean_with_observed_outcomes_matches_closed_form() {
let mut p = BetaPosterior::jeffreys();
for _ in 0..8 {
p = p.update_helpful();
}
for _ in 0..2 {
p = p.update_harmful(2.5);
}
assert!(approx_eq(p.alpha(), 8.5, 1e-12));
assert!(approx_eq(p.beta(), 5.5, 1e-12));
assert!(approx_eq(p.mean(), 8.5 / 14.0, 1e-12));
}
#[test]
fn utility_inverse_interval_matches_persisted_alpha_beta_reconstruction() {
for (confidence, total_weight, level) in [(0.3, 2.0, 0.90), (0.8, 2.0, 0.90)] {
let posterior = BetaPosterior::from_utility_inverse(confidence, total_weight)
.expect("valid utility-inverse posterior");
let restored = BetaPosterior::new(posterior.alpha(), posterior.beta())
.expect("stored alpha/beta must reconstruct");
assert!(approx_eq(posterior.mean(), confidence, 1e-12));
assert_eq!(posterior, restored);
let Some((lo, hi)) = posterior.credible_interval(level) else {
panic!("utility-inverse credible interval should compute");
};
let Some((restored_lo, restored_hi)) = restored.credible_interval(level) else {
panic!("restored credible interval should compute");
};
assert!(
approx_eq(lo, restored_lo, 1e-12),
"lo mismatch for confidence={confidence}, total_weight={total_weight}: {lo} != {restored_lo}"
);
assert!(
approx_eq(hi, restored_hi, 1e-12),
"hi mismatch for confidence={confidence}, total_weight={total_weight}: {hi} != {restored_hi}"
);
}
}
#[test]
fn credible_interval_90_contains_mean() {
let mut p = BetaPosterior::jeffreys();
for _ in 0..15 {
p = p.update_helpful();
}
for _ in 0..5 {
p = p.update_harmful(1.0);
}
let mean = p.mean();
let Some((lo, hi)) = p.credible_interval(0.90) else {
panic!("ci90 should compute for moderate-evidence posterior");
};
assert!(
lo > 0.0 && lo < mean,
"ci90.lo {lo} should be in (0, {mean})"
);
assert!(
hi > mean && hi < 1.0,
"ci90.hi {hi} should be in ({mean}, 1)"
);
assert!(hi - lo > 0.0, "interval must have positive width");
}
#[test]
fn credible_interval_50_narrower_than_90() {
let mut p = BetaPosterior::jeffreys();
for _ in 0..30 {
p = p.update_helpful();
}
for _ in 0..10 {
p = p.update_harmful(1.0);
}
let Some((lo50, hi50)) = p.credible_interval(0.50) else {
panic!("ci50 should compute for moderate-evidence posterior");
};
let Some((lo90, hi90)) = p.credible_interval(0.90) else {
panic!("ci90 should compute for moderate-evidence posterior");
};
assert!(
hi50 - lo50 < hi90 - lo90,
"50%% CI must be narrower than 90%%"
);
assert!(lo90 < lo50, "90%% CI extends further left than 50%%");
assert!(hi90 > hi50, "90%% CI extends further right than 50%%");
}
#[test]
fn credible_interval_rejects_invalid_level() {
let p = BetaPosterior::jeffreys();
assert!(p.credible_interval(0.0).is_none());
assert!(p.credible_interval(1.0).is_none());
assert!(p.credible_interval(-0.1).is_none());
assert!(p.credible_interval(1.1).is_none());
}
#[test]
fn beta_inv_cdf_jeffreys_quantiles() {
let Some(lo) = beta_inv_cdf(0.05, 0.5, 0.5) else {
panic!("Jeffreys 5% quantile should compute");
};
let Some(mid) = beta_inv_cdf(0.50, 0.5, 0.5) else {
panic!("Jeffreys 50% quantile should compute");
};
let Some(hi) = beta_inv_cdf(0.95, 0.5, 0.5) else {
panic!("Jeffreys 95% quantile should compute");
};
assert!(
approx_eq(lo, 0.00615, 0.005),
"lo ≈ 0.00615 expected, got {lo}"
);
assert!(approx_eq(mid, 0.5, 1e-3), "mid = 0.5 expected, got {mid}");
assert!(
approx_eq(hi, 0.99385, 0.005),
"hi ≈ 0.99385 expected, got {hi}"
);
}
#[test]
fn beta_inv_cdf_well_evidenced_posterior() {
let Some(lo) = beta_inv_cdf(0.05, 50.0, 50.0) else {
panic!("well-evidenced 5% quantile should compute");
};
let Some(hi) = beta_inv_cdf(0.95, 50.0, 50.0) else {
panic!("well-evidenced 95% quantile should compute");
};
assert!(approx_eq(lo, 0.4178, 0.01));
assert!(approx_eq(hi, 0.5822, 0.01));
}
#[test]
fn variance_matches_closed_form() {
let Some(p) = BetaPosterior::new(2.0, 3.0) else {
panic!("positive alpha/beta should construct a posterior");
};
let expected = (2.0 * 3.0) / (5.0_f64.powi(2) * 6.0);
assert!(approx_eq(p.variance(), expected, 1e-12));
}
}