Skip to main content

zer_compare/similarity/
numeric.rs

1use zer_core::{record::FieldValue, schema::FieldKind};
2
3use crate::similarity::SimilarityFn;
4
5// ── NumericBucketedSimilarity ─────────────────────────────────────────────────
6
7/// Similarity for numeric fields based on relative difference, bucketed into
8/// four bands.
9///
10/// Extracts f64 values from Text (parsed), Int, or Float field values and
11/// computes `relative_diff = |a - b| / max(|a|, |b|, 1.0)`:
12///   relative_diff == 0.0      : 1.0  (exact)
13///   relative_diff <= 0.05     : 0.85 (<= 5% difference)
14///   relative_diff <= 0.20     : 0.6  (<= 20% difference)
15///   relative_diff <= 0.50     : 0.3  (<= 50% difference)
16///   otherwise                 : 0.0
17pub struct NumericBucketedSimilarity;
18
19fn extract_numeric(v: &FieldValue) -> Option<f64> {
20    match v {
21        FieldValue::Float(f) => Some(*f),
22        FieldValue::Int(i) => Some(*i as f64),
23        FieldValue::Text(s) => s.trim().parse::<f64>().ok(),
24        _ => None,
25    }
26}
27
28fn numeric_score(va: f64, vb: f64) -> f32 {
29    let diff = (va - vb).abs();
30    if diff == 0.0 {
31        return 1.0;
32    }
33    let denom = va.abs().max(vb.abs()).max(1.0);
34    let rel_diff = diff / denom;
35    if rel_diff <= 0.05 {
36        0.85
37    } else if rel_diff <= 0.20 {
38        0.6
39    } else if rel_diff <= 0.50 {
40        0.3
41    } else {
42        0.0
43    }
44}
45
46impl SimilarityFn for NumericBucketedSimilarity {
47    fn similarity(&self, a: &FieldValue, b: &FieldValue) -> f32 {
48        match (extract_numeric(a), extract_numeric(b)) {
49            (Some(va), Some(vb)) => numeric_score(va, vb),
50            _ => 0.0,
51        }
52    }
53    fn similarity_str(&self, a: &str, b: &str) -> f32 {
54        match (a.trim().parse::<f64>(), b.trim().parse::<f64>()) {
55            (Ok(va), Ok(vb)) => numeric_score(va, vb),
56            _ => 0.0,
57        }
58    }
59    fn field_kind(&self) -> FieldKind {
60        FieldKind::Numeric
61    }
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67
68    fn ti(n: i64) -> FieldValue {
69        FieldValue::Int(n)
70    }
71    fn tf(f: f64) -> FieldValue {
72        FieldValue::Float(f)
73    }
74    fn tv(s: &str) -> FieldValue {
75        FieldValue::Text(s.into())
76    }
77
78    #[test]
79    fn exact_int_match() {
80        let sim = NumericBucketedSimilarity;
81        assert_eq!(sim.similarity(&ti(180), &ti(180)), 1.0);
82    }
83
84    #[test]
85    fn close_within_5_percent() {
86        let sim = NumericBucketedSimilarity;
87        // 180 vs 183 → diff 3, denom 183 → 1.6% → bucket 0.85
88        assert_eq!(sim.similarity(&ti(180), &ti(183)), 0.85);
89    }
90
91    #[test]
92    fn medium_within_20_percent() {
93        let sim = NumericBucketedSimilarity;
94        // 100 vs 115 → diff 15, denom 115 → 13% → bucket 0.6
95        assert_eq!(sim.similarity(&ti(100), &ti(115)), 0.6);
96    }
97
98    #[test]
99    fn large_within_50_percent() {
100        let sim = NumericBucketedSimilarity;
101        // 100 vs 140 → diff 40, denom 140 → 28.6% → bucket 0.3
102        assert_eq!(sim.similarity(&ti(100), &ti(140)), 0.3);
103    }
104
105    #[test]
106    fn very_different() {
107        let sim = NumericBucketedSimilarity;
108        assert_eq!(sim.similarity(&ti(100), &ti(300)), 0.0);
109    }
110
111    #[test]
112    fn float_parsing_from_text() {
113        let sim = NumericBucketedSimilarity;
114        // GPS-style: "52.345" vs "52.346", nearly identical
115        assert_eq!(sim.similarity(&tv("52.345"), &tv("52.346")), 0.85);
116    }
117
118    #[test]
119    fn mixed_int_float() {
120        let sim = NumericBucketedSimilarity;
121        assert_eq!(sim.similarity(&ti(100), &tf(100.0)), 1.0);
122    }
123
124    #[test]
125    fn null_returns_zero() {
126        let sim = NumericBucketedSimilarity;
127        assert_eq!(sim.similarity(&FieldValue::Null, &ti(100)), 0.0);
128    }
129}