use antigen_fingerprint::Fingerprint;
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
pub struct Affinity {
pub recall: f64,
pub precision: f64,
}
impl<'de> serde::Deserialize<'de> for Affinity {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where D: serde::Deserializer<'de> {
#[derive(serde::Deserialize)]
struct RawAffinity {
recall: f64,
precision: f64,
}
let raw = RawAffinity::deserialize(deserializer)?;
Ok(Self::new(raw.recall, raw.precision))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Objective {
Recall,
Precision,
Balanced,
}
impl Affinity {
pub const PERFECT: Self = Self {
recall: 1.0,
precision: 1.0,
};
#[must_use]
pub const fn new(recall: f64, precision: f64) -> Self {
Self {
recall: clamp_rate(recall),
precision: clamp_rate(precision),
}
}
#[must_use]
pub fn measure(draft: &Fingerprint, cluster: &[syn::Item], clean_corpus: &[syn::Item]) -> Self {
let recall = rate(
cluster.iter().filter(|item| draft.matches(item)).count(),
cluster.len(),
);
let spared = clean_corpus
.iter()
.filter(|item| !draft.matches(item))
.count();
let precision = rate(spared, clean_corpus.len());
Self { recall, precision }
}
#[must_use]
pub const fn dominates(&self, other: &Self) -> bool {
let ge_both = self.recall >= other.recall && self.precision >= other.precision;
let gt_one = self.recall > other.recall || self.precision > other.precision;
ge_both && gt_one
}
#[must_use]
pub const fn pareto_improves_on(&self, prev: &Self) -> bool {
self.dominates(prev)
}
#[must_use]
pub const fn favors(&self) -> Objective {
if self.recall > self.precision {
Objective::Recall
} else if self.precision > self.recall {
Objective::Precision
} else {
Objective::Balanced
}
}
}
impl PartialOrd for Affinity {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
use std::cmp::Ordering;
let r = self.recall.partial_cmp(&other.recall)?;
let p = self.precision.partial_cmp(&other.precision)?;
match (r, p) {
(Ordering::Equal, Ordering::Equal) => Some(Ordering::Equal),
(Ordering::Less | Ordering::Equal, Ordering::Less | Ordering::Equal) => {
Some(Ordering::Less)
},
(Ordering::Greater | Ordering::Equal, Ordering::Greater | Ordering::Equal) => {
Some(Ordering::Greater)
},
_ => None,
}
}
}
const fn clamp_rate(x: f64) -> f64 {
if x.is_nan() { 0.0 } else { x.clamp(0.0, 1.0) }
}
#[allow(clippy::cast_precision_loss)]
fn rate(matched: usize, total: usize) -> f64 {
if total == 0 {
0.0
} else {
clamp_rate(matched as f64 / total as f64)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn aff(recall: f64, precision: f64) -> Affinity {
Affinity { recall, precision }
}
#[test]
fn trades_off_points_are_incomparable() {
let catchy = aff(0.9, 0.4);
let sparing = aff(0.4, 0.9);
assert!(!catchy.dominates(&sparing));
assert!(!sparing.dominates(&catchy));
assert_eq!(catchy.partial_cmp(&sparing), None);
assert_eq!(sparing.partial_cmp(&catchy), None);
}
#[test]
fn dominance_requires_no_axis_worsened() {
let base = aff(0.5, 0.5);
assert!(aff(0.6, 0.6).dominates(&base));
assert!(aff(0.6, 0.5).dominates(&base));
assert!(aff(0.5, 0.6).dominates(&base));
assert!(!base.dominates(&base));
assert!(!aff(0.9, 0.1).dominates(&base));
}
#[test]
fn perfect_dominates_everything_below() {
assert!(Affinity::PERFECT.dominates(&aff(0.99, 1.0)));
assert!(Affinity::PERFECT.dominates(&aff(0.0, 0.0)));
assert!(!Affinity::PERFECT.dominates(&Affinity::PERFECT));
}
#[test]
fn partial_ord_orders_dominated_pairs_and_refuses_the_frontier() {
use std::cmp::Ordering;
assert_eq!(
aff(0.5, 0.5).partial_cmp(&aff(0.5, 0.5)),
Some(Ordering::Equal)
);
assert_eq!(
aff(0.4, 0.4).partial_cmp(&aff(0.6, 0.6)),
Some(Ordering::Less)
);
assert_eq!(
aff(0.6, 0.6).partial_cmp(&aff(0.4, 0.4)),
Some(Ordering::Greater)
);
assert_eq!(
aff(0.4, 0.5).partial_cmp(&aff(0.6, 0.5)),
Some(Ordering::Less)
);
assert_eq!(aff(0.9, 0.1).partial_cmp(&aff(0.1, 0.9)), None);
}
#[test]
fn pareto_improves_is_the_stopping_rule() {
let draft = aff(0.6, 0.7);
assert!(aff(0.6, 0.8).pareto_improves_on(&draft));
assert!(!aff(0.8, 0.5).pareto_improves_on(&draft));
assert!(!draft.pareto_improves_on(&draft));
}
#[test]
fn favors_reports_the_pole() {
assert_eq!(aff(0.9, 0.4).favors(), Objective::Recall);
assert_eq!(aff(0.4, 0.9).favors(), Objective::Precision);
assert_eq!(aff(0.7, 0.7).favors(), Objective::Balanced);
}
#[test]
fn new_clamps_out_of_range_and_nan() {
assert_eq!(Affinity::new(1.5, -0.5), aff(1.0, 0.0));
assert_eq!(Affinity::new(f64::NAN, 0.5), aff(0.0, 0.5));
assert_eq!(
Affinity::new(f64::INFINITY, f64::NEG_INFINITY),
aff(1.0, 0.0)
);
}
#[test]
fn measure_a_fresh_draft_binds_its_cluster_and_spares_clean() {
let guard_a: syn::Item = syn::parse_quote! {
impl Drop for GuardA { fn drop(&mut self) { self.0.take().unwrap(); } }
};
let guard_b: syn::Item = syn::parse_quote! {
impl Drop for GuardB { fn drop(&mut self) { self.0.take().expect("x"); } }
};
let clean: syn::Item = syn::parse_quote! {
impl Drop for CleanGuard { fn drop(&mut self) { self.0.take().ok(); } }
};
let cluster = [guard_a, guard_b];
let clean_corpus = [clean];
let draft = crate::learn::propose::anti_unify(&cluster)
.expect("the homogeneous Drop cluster anti-unifies");
let a = Affinity::measure(&draft, &cluster, &clean_corpus);
assert_eq!(a, Affinity::PERFECT);
}
#[test]
fn measure_empty_axes_are_conservative_zero() {
let draft = Fingerprint::parse("item = struct").expect("trivial fingerprint parses");
let empty: [syn::Item; 0] = [];
let a = Affinity::measure(&draft, &empty, &empty);
assert_eq!(a, aff(0.0, 0.0));
}
#[test]
fn measure_counts_an_autoimmune_bind_as_precision_loss() {
let draft = Fingerprint::parse("item = struct").expect("trivial fingerprint parses");
let bound: syn::Item = syn::parse_quote! { struct Anything; };
let other: syn::Item = syn::parse_quote! { struct AlsoAnything; };
let clean_corpus = [bound, other];
let a = Affinity::measure(&draft, &[], &clean_corpus);
assert_eq!(a, aff(0.0, 0.0));
}
#[test]
fn serde_roundtrips() {
let a = aff(0.625, 0.875);
let json = serde_json::to_string(&a).expect("serialize");
let back: Affinity = serde_json::from_str(&json).expect("deserialize");
assert_eq!(a, back);
}
#[test]
fn deserialize_enforces_the_clamp_invariant() {
let raw = serde_json::json!({ "recall": 5.0, "precision": -2.0 });
let back: Affinity = serde_json::from_value(raw).expect("deserialize clamps, never errors");
assert_eq!(
back,
aff(1.0, 0.0),
"out-of-range affinity must clamp on deserialize (recall 5.0→1.0, \
precision -2.0→0.0), not load raw — the invariant is enforced at the \
type boundary, not just at new().",
);
let clamped_via_new = Affinity::new(f64::NAN, f64::INFINITY);
assert_eq!(clamped_via_new, aff(0.0, 1.0));
}
}