use crate::query::{answer_query, AtomicScorer, Query, QueryConfig};
use crate::truth::Truth;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ConformalThreshold {
pub qhat: f32,
pub alpha: f32,
pub n_calibration: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConformalError {
InvalidAlpha,
NoCalibrationExamples,
AnswerOutOfRange,
}
impl std::fmt::Display for ConformalError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidAlpha => write!(f, "alpha must be in (0, 1)"),
Self::NoCalibrationExamples => write!(f, "calibration set is empty"),
Self::AnswerOutOfRange => write!(f, "calibration answer id out of range"),
}
}
}
impl std::error::Error for ConformalError {}
pub fn calibrate<T: Truth>(
scorer: &dyn AtomicScorer,
examples: &[(Query, usize)],
config: &QueryConfig,
alpha: f32,
) -> Result<ConformalThreshold, ConformalError> {
if !(alpha > 0.0 && alpha < 1.0) {
return Err(ConformalError::InvalidAlpha);
}
if examples.is_empty() {
return Err(ConformalError::NoCalibrationExamples);
}
let n_entities = scorer.num_entities();
let mut nonconformities = Vec::with_capacity(examples.len());
for (query, answer) in examples {
if *answer >= n_entities {
return Err(ConformalError::AnswerOutOfRange);
}
let degrees = answer_query::<T>(scorer, query, config);
nonconformities.push(1.0 - degrees[*answer]);
}
calibrate_scores(&nonconformities, alpha)
}
pub fn calibrate_scores(
nonconformities: &[f32],
alpha: f32,
) -> Result<ConformalThreshold, ConformalError> {
if !(alpha > 0.0 && alpha < 1.0) {
return Err(ConformalError::InvalidAlpha);
}
if nonconformities.is_empty() {
return Err(ConformalError::NoCalibrationExamples);
}
let mut scores: Vec<f32> = nonconformities.iter().map(|s| s.clamp(0.0, 1.0)).collect();
scores.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let n = scores.len();
let rank = ((n as f64 + 1.0) * (1.0 - alpha as f64)).ceil() as usize;
let qhat = if rank > n {
f32::INFINITY
} else {
scores[rank - 1]
};
Ok(ConformalThreshold {
qhat,
alpha,
n_calibration: n,
})
}
pub fn answer_set<T: Truth>(
scorer: &dyn AtomicScorer,
query: &Query,
config: &QueryConfig,
threshold: &ConformalThreshold,
) -> Vec<(usize, f32)> {
let degrees = answer_query::<T>(scorer, query, config);
answer_set_from_degrees(°rees, threshold)
}
pub fn answer_set_from_degrees(
degrees: &[f32],
threshold: &ConformalThreshold,
) -> Vec<(usize, f32)> {
let cutoff = 1.0 - threshold.qhat; let mut set: Vec<(usize, f32)> = degrees
.iter()
.copied()
.enumerate()
.filter(|(_, d)| *d >= cutoff)
.collect();
set.sort_unstable_by(|a, b| {
b.1.partial_cmp(&a.1)
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.0.cmp(&b.0))
});
set
}
pub fn answer_set_from_scored_pool(
scored: &[(usize, f32)],
threshold: &ConformalThreshold,
) -> Vec<(usize, f32)> {
let cutoff = 1.0 - threshold.qhat;
let mut best_by_id = std::collections::BTreeMap::new();
for &(entity, degree) in scored {
if degree >= cutoff {
best_by_id
.entry(entity)
.and_modify(|best| {
if degree > *best {
*best = degree;
}
})
.or_insert(degree);
}
}
let mut set: Vec<(usize, f32)> = best_by_id.into_iter().collect();
set.sort_unstable_by(|a, b| {
b.1.partial_cmp(&a.1)
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.0.cmp(&b.0))
});
set
}
pub fn empirical_coverage<T: Truth>(
scorer: &dyn AtomicScorer,
tests: &[(Query, usize)],
config: &QueryConfig,
threshold: &ConformalThreshold,
) -> f32 {
if tests.is_empty() {
return 0.0;
}
let hits = tests
.iter()
.filter(|(query, answer)| {
answer_set::<T>(scorer, query, config, threshold)
.iter()
.any(|(e, _)| e == answer)
})
.count();
hits as f32 / tests.len() as f32
}
#[cfg(test)]
mod tests {
use super::*;
use crate::kg::FuzzyKg;
use crate::truth::Godel;
fn kg() -> FuzzyKg {
let mut kg = FuzzyKg::new(6);
kg.add_edge(0, 0, 1, 0.9); kg.add_edge(2, 0, 3, 0.8); kg.add_edge(4, 0, 5, 0.7); kg.add_edge(1, 0, 3, 0.6); kg
}
fn calibration() -> Vec<(Query, usize)> {
vec![
(Query::anchor(0, 0), 1),
(Query::anchor(2, 0), 3),
(Query::anchor(4, 0), 5),
(Query::anchor(1, 0), 3),
]
}
#[test]
fn quantile_matches_hand_computation() {
let kg = kg();
let cfg = QueryConfig::default();
let t = calibrate::<Godel>(&kg, &calibration(), &cfg, 0.25).unwrap();
assert!((t.qhat - 0.4).abs() < 1e-6, "qhat {}", t.qhat);
let t = calibrate::<Godel>(&kg, &calibration(), &cfg, 0.5).unwrap();
assert!((t.qhat - 0.3).abs() < 1e-6, "qhat {}", t.qhat);
let t = calibrate::<Godel>(&kg, &calibration(), &cfg, 0.01).unwrap();
assert!(t.qhat.is_infinite());
}
#[test]
fn answer_sets_apply_the_cutoff() {
let kg = kg();
let cfg = QueryConfig::default();
let t = calibrate::<Godel>(&kg, &calibration(), &cfg, 0.5).unwrap();
let set = answer_set::<Godel>(&kg, &Query::anchor(0, 0), &cfg, &t);
assert_eq!(set.first().map(|(e, _)| *e), Some(1));
let set = answer_set::<Godel>(&kg, &Query::anchor(1, 0), &cfg, &t);
assert!(
!set.iter().any(|(e, _)| *e == 3),
"0.6 < cutoff 0.7: {set:?}"
);
let cov = empirical_coverage::<Godel>(&kg, &calibration(), &cfg, &t);
assert!((cov - 0.75).abs() < 1e-6, "coverage {cov}");
}
#[test]
fn infinite_threshold_returns_all_entities() {
let kg = kg();
let cfg = QueryConfig::default();
let t = calibrate::<Godel>(&kg, &calibration(), &cfg, 0.01).unwrap();
let set = answer_set::<Godel>(&kg, &Query::anchor(0, 0), &cfg, &t);
assert_eq!(set.len(), kg.num_entities());
let cov = empirical_coverage::<Godel>(&kg, &calibration(), &cfg, &t);
assert!((cov - 1.0).abs() < 1e-6);
}
#[test]
fn rejects_bad_inputs() {
let kg = kg();
let cfg = QueryConfig::default();
assert_eq!(
calibrate::<Godel>(&kg, &calibration(), &cfg, 0.0).unwrap_err(),
ConformalError::InvalidAlpha
);
assert_eq!(
calibrate::<Godel>(&kg, &calibration(), &cfg, 1.0).unwrap_err(),
ConformalError::InvalidAlpha
);
assert_eq!(
calibrate::<Godel>(&kg, &[], &cfg, 0.1).unwrap_err(),
ConformalError::NoCalibrationExamples
);
assert_eq!(
calibrate::<Godel>(&kg, &[(Query::anchor(0, 0), 99)], &cfg, 0.1).unwrap_err(),
ConformalError::AnswerOutOfRange
);
}
#[test]
fn calibrate_scores_matches_hand_computation() {
let nonconf = [0.1f32, 0.2, 0.3, 0.4];
assert!((calibrate_scores(&nonconf, 0.25).unwrap().qhat - 0.4).abs() < 1e-6);
assert!((calibrate_scores(&nonconf, 0.5).unwrap().qhat - 0.3).abs() < 1e-6);
assert!(calibrate_scores(&nonconf, 0.01).unwrap().qhat.is_infinite());
assert_eq!(
calibrate_scores(&[], 0.1).unwrap_err(),
ConformalError::NoCalibrationExamples
);
assert_eq!(
calibrate_scores(&nonconf, 1.0).unwrap_err(),
ConformalError::InvalidAlpha
);
}
#[test]
fn seam_calibrate_delegates_to_score_core() {
let kg = kg();
let cfg = QueryConfig::default();
let nonconf = [0.1f32, 0.2, 0.3, 0.4];
for &alpha in &[0.25f32, 0.5, 0.01] {
let seam = calibrate::<Godel>(&kg, &calibration(), &cfg, alpha).unwrap();
let core = calibrate_scores(&nonconf, alpha).unwrap();
assert_eq!(seam.qhat.is_infinite(), core.qhat.is_infinite());
if seam.qhat.is_finite() {
assert!((seam.qhat - core.qhat).abs() < 1e-6, "alpha {alpha}");
}
}
}
#[test]
fn answer_set_from_degrees_applies_cutoff() {
let thr = calibrate_scores(&[0.1, 0.2, 0.3, 0.4], 0.5).unwrap();
let degrees = [0.9f32, 0.75, 0.7, 0.6, 0.95];
let ids: Vec<usize> = answer_set_from_degrees(°rees, &thr)
.iter()
.map(|(e, _)| *e)
.collect();
assert_eq!(ids, vec![4, 0, 1, 2]);
}
#[test]
fn answer_set_from_scored_pool_applies_cutoff_to_sparse_candidates() {
let thr = calibrate_scores(&[0.1, 0.2, 0.3, 0.4], 0.5).unwrap();
let scored = [(10usize, 0.9f32), (5, 0.65), (7, 0.75), (10, 0.85)];
let set = answer_set_from_scored_pool(&scored, &thr);
assert_eq!(set, vec![(10, 0.9), (7, 0.75)]);
}
#[test]
fn answer_set_from_scored_pool_full_fallback_stays_inside_pool() {
let thr = ConformalThreshold {
qhat: f32::INFINITY,
alpha: 0.1,
n_calibration: 1,
};
let scored = [(9usize, 0.1f32), (3, 0.8)];
let set = answer_set_from_scored_pool(&scored, &thr);
assert_eq!(set, vec![(3, 0.8), (9, 0.1)]);
}
}