#[derive(Debug, Clone, PartialEq)]
pub struct Confidence {
pub recognition: f32,
pub rule: f32,
pub region: Option<f32>,
pub runner_up_ratio: Option<f32>,
pub features: Vec<FeatureContribution>,
}
impl Confidence {
#[inline]
pub fn strict(rule_confidence: f32) -> Self {
assert!(
(0.0..=1.0).contains(&rule_confidence) && !rule_confidence.is_nan(),
"Confidence::strict rule confidence must be in [0.0, 1.0] and not NaN, got {rule_confidence}"
);
Self {
recognition: 1.0,
rule: rule_confidence,
region: None,
runner_up_ratio: None,
features: Vec::new(),
}
}
#[inline]
pub fn combined(&self) -> f32 {
self.recognition * self.rule
}
pub fn validate(&self) -> Result<(), String> {
let check_unit = |label: &str, v: f32| -> Result<(), String> {
if v.is_nan() || !(0.0..=1.0).contains(&v) {
Err(format!(
"Confidence.{label} must be in [0.0, 1.0] and not NaN, got {v}"
))
} else {
Ok(())
}
};
let check_finite = |label: &str, v: f32| -> Result<(), String> {
if v.is_nan() || !v.is_finite() {
Err(format!(
"Confidence.{label} must be finite and not NaN, got {v}"
))
} else {
Ok(())
}
};
check_unit("recognition", self.recognition)?;
check_unit("rule", self.rule)?;
if let Some(r) = self.region {
check_unit("region", r)?;
}
if let Some(r) = self.runner_up_ratio {
check_finite("runner_up_ratio", r)?;
}
for (i, feature) in self.features.iter().enumerate() {
if feature.delta.is_nan() || !feature.delta.is_finite() {
return Err(format!(
"Confidence.features[{i}].delta must be finite and not NaN, got {}",
feature.delta
));
}
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FeatureContribution {
pub id: FeatureId,
pub delta: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FeatureId {
EditDistance1,
EditDistance2,
TokenReorder,
SupersededToken,
BaseRateCommonMarking,
StrictContextClassification,
CorpusOverrideInEffect,
}
impl FeatureId {
#[inline]
pub const fn as_str(self) -> &'static str {
match self {
FeatureId::EditDistance1 => "EditDistance1",
FeatureId::EditDistance2 => "EditDistance2",
FeatureId::TokenReorder => "TokenReorder",
FeatureId::SupersededToken => "SupersededToken",
FeatureId::BaseRateCommonMarking => "BaseRateCommonMarking",
FeatureId::StrictContextClassification => "StrictContextClassification",
FeatureId::CorpusOverrideInEffect => "CorpusOverrideInEffect",
}
}
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::*;
#[test]
fn strict_pins_recognition_at_one() {
let c = Confidence::strict(0.85);
assert_eq!(c.recognition, 1.0);
assert_eq!(c.rule, 0.85);
assert!(c.region.is_none());
assert!(c.runner_up_ratio.is_none());
assert!(c.features.is_empty());
}
#[test]
fn combined_is_product_of_axes() {
let c = Confidence::strict(0.9);
assert!((c.combined() - 0.9).abs() < 1e-6);
let c2 = Confidence {
recognition: 0.8,
rule: 0.5,
region: None,
runner_up_ratio: None,
features: Vec::new(),
};
assert!((c2.combined() - 0.4).abs() < 1e-6);
}
#[test]
#[should_panic(expected = "Confidence::strict rule confidence")]
fn strict_panics_on_nan() {
let _ = Confidence::strict(f32::NAN);
}
#[test]
#[should_panic(expected = "Confidence::strict rule confidence")]
fn strict_panics_above_one() {
let _ = Confidence::strict(1.01);
}
#[test]
fn feature_id_as_str_matches_audit_contract() {
let cases: &[(FeatureId, &str)] = &[
(FeatureId::EditDistance1, "EditDistance1"),
(FeatureId::EditDistance2, "EditDistance2"),
(FeatureId::TokenReorder, "TokenReorder"),
(FeatureId::SupersededToken, "SupersededToken"),
(FeatureId::BaseRateCommonMarking, "BaseRateCommonMarking"),
(
FeatureId::StrictContextClassification,
"StrictContextClassification",
),
(FeatureId::CorpusOverrideInEffect, "CorpusOverrideInEffect"),
];
for (id, expected) in cases {
assert_eq!(id.as_str(), *expected, "label drift for {id:?}");
}
}
#[test]
fn feature_contribution_roundtrip() {
let fc = FeatureContribution {
id: FeatureId::EditDistance1,
delta: -0.3,
};
assert_eq!(fc.id, FeatureId::EditDistance1);
assert!((fc.delta - (-0.3)).abs() < 1e-6);
}
#[test]
fn validate_accepts_well_formed_record() {
assert!(Confidence::strict(0.85).validate().is_ok());
assert!(
Confidence {
recognition: 0.9,
rule: 0.8,
region: Some(0.5),
runner_up_ratio: Some(2.7),
features: vec![FeatureContribution {
id: FeatureId::EditDistance1,
delta: -0.5,
}],
}
.validate()
.is_ok()
);
}
#[test]
fn validate_rejects_out_of_range_recognition() {
let c = Confidence {
recognition: 1.5,
rule: 0.5,
region: None,
runner_up_ratio: None,
features: Vec::new(),
};
let err = c.validate().unwrap_err();
assert!(
err.contains("recognition"),
"error should name the offending axis, got: {err}"
);
}
#[test]
fn validate_rejects_out_of_range_rule() {
let c = Confidence {
recognition: 0.5,
rule: -0.1,
region: None,
runner_up_ratio: None,
features: Vec::new(),
};
let err = c.validate().unwrap_err();
assert!(err.contains("rule"), "got: {err}");
}
#[test]
fn validate_rejects_out_of_range_region() {
let c = Confidence {
recognition: 0.5,
rule: 0.5,
region: Some(1.5),
runner_up_ratio: None,
features: Vec::new(),
};
let err = c.validate().unwrap_err();
assert!(err.contains("region"), "got: {err}");
}
#[test]
fn validate_rejects_non_finite_runner_up_ratio() {
for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
let c = Confidence {
recognition: 0.5,
rule: 0.5,
region: None,
runner_up_ratio: Some(bad),
features: Vec::new(),
};
assert!(
c.validate().is_err(),
"runner_up_ratio = {bad:?} should fail validation"
);
}
}
#[test]
fn validate_accepts_finite_runner_up_ratio_of_any_magnitude() {
let c = Confidence {
recognition: 0.5,
rule: 0.5,
region: None,
runner_up_ratio: Some(0.01),
features: Vec::new(),
};
assert!(c.validate().is_ok());
}
#[test]
fn validate_rejects_non_finite_feature_delta() {
for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
let c = Confidence {
recognition: 0.5,
rule: 0.5,
region: None,
runner_up_ratio: None,
features: vec![FeatureContribution {
id: FeatureId::EditDistance1,
delta: bad,
}],
};
assert!(
c.validate().is_err(),
"feature delta = {bad:?} should fail validation"
);
}
}
#[test]
fn validate_accepts_zero_axes() {
let c = Confidence {
recognition: 0.0,
rule: 0.0,
region: Some(0.0),
runner_up_ratio: None,
features: Vec::new(),
};
assert!(c.validate().is_ok());
}
}