Skip to main content

warble_eval_compare/
lib.rs

1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6#[derive(Debug, Clone, Copy, Deserialize)]
7#[serde(rename_all = "lowercase")]
8pub enum MatchMode {
9    Scalar,
10    Set,
11    Ordered,
12}
13
14#[derive(Debug, Clone, Copy, Deserialize)]
15pub struct Tolerance {
16    #[serde(default)]
17    pub numeric: f64,
18}
19
20impl Default for Tolerance {
21    fn default() -> Self {
22        Tolerance { numeric: 0.0 }
23    }
24}
25
26#[derive(Debug, Clone, Deserialize)]
27pub struct Table {
28    #[serde(default)]
29    pub columns: Vec<String>,
30    #[serde(default)]
31    pub rows: Vec<Vec<Value>>,
32}
33
34#[derive(Debug, Deserialize)]
35pub struct CompareRequest {
36    #[serde(rename = "match")]
37    pub match_mode: MatchMode,
38    #[serde(default)]
39    pub tolerance: Tolerance,
40    pub expected: Table,
41    pub actual: Table,
42}
43
44#[derive(Debug, Serialize, PartialEq)]
45pub struct CompareResult {
46    pub pass: bool,
47    pub reason: String,
48}
49
50impl CompareResult {
51    fn pass() -> Self {
52        CompareResult {
53            pass: true,
54            reason: "match".to_string(),
55        }
56    }
57
58    fn fail(reason: impl Into<String>) -> Self {
59        CompareResult {
60            pass: false,
61            reason: reason.into(),
62        }
63    }
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
67enum NormCell {
68    Null,
69    Num(i64),
70    Str(String),
71}
72
73fn as_number(v: &Value) -> Option<f64> {
74    match v {
75        Value::Number(n) => n.as_f64(),
76        Value::String(s) => s.trim().parse::<f64>().ok(),
77        _ => None,
78    }
79}
80
81fn display_string(v: &Value) -> String {
82    match v {
83        Value::String(s) => s.clone(),
84        Value::Bool(b) => b.to_string(),
85        Value::Number(n) => n.to_string(),
86        other => other.to_string(),
87    }
88}
89
90/// Compare two cells for equality: numeric values within `tolerance` are
91/// equal, otherwise compare as trimmed, case-sensitive strings. `null` is
92/// only equal to `null`.
93pub fn cells_equal(a: &Value, b: &Value, tolerance: f64) -> bool {
94    let a_null = a.is_null();
95    let b_null = b.is_null();
96    if a_null || b_null {
97        return a_null && b_null;
98    }
99    if let (Some(na), Some(nb)) = (as_number(a), as_number(b)) {
100        return (na - nb).abs() <= tolerance;
101    }
102    display_string(a).trim() == display_string(b).trim()
103}
104
105/// Number of decimal places implied by a numeric tolerance, used to snap
106/// numeric cells onto a comparison grid before hashing/sorting a row.
107fn decimals_for_tolerance(tolerance: f64) -> i32 {
108    if tolerance <= 0.0 {
109        return 9;
110    }
111    (-tolerance.log10()).ceil().max(0.0) as i32
112}
113
114fn round_to_grid(value: f64, decimals: i32) -> i64 {
115    let multiplier = 10f64.powi(decimals);
116    (value * multiplier).round() as i64
117}
118
119fn normalize_cell(v: &Value, decimals: i32) -> NormCell {
120    if v.is_null() {
121        return NormCell::Null;
122    }
123    if let Some(n) = as_number(v) {
124        return NormCell::Num(round_to_grid(n, decimals));
125    }
126    NormCell::Str(display_string(v).trim().to_string())
127}
128
129/// Normalize a row to a sorted tuple of its cell values so that column
130/// naming/ordering differences don't affect comparison.
131fn normalize_row(row: &[Value], decimals: i32) -> Vec<NormCell> {
132    let mut cells: Vec<NormCell> = row.iter().map(|v| normalize_cell(v, decimals)).collect();
133    cells.sort();
134    cells
135}
136
137fn non_null_cells(table: &Table) -> Vec<&Value> {
138    table
139        .rows
140        .iter()
141        .flat_map(|row| row.iter())
142        .filter(|v| !v.is_null())
143        .collect()
144}
145
146fn compare_scalar(expected: &Table, actual: &Table, tolerance: f64) -> CompareResult {
147    if expected.rows.len() != 1 || expected.rows[0].len() != 1 {
148        return CompareResult::fail(format!(
149            "scalar match requires expected to be 1x1, got {} row(s) x {} col(s)",
150            expected.rows.len(),
151            expected.rows.first().map_or(0, |r| r.len())
152        ));
153    }
154    let expected_cell = &expected.rows[0][0];
155
156    let actual_cell = if actual.rows.len() == 1 && actual.rows[0].len() == 1 {
157        &actual.rows[0][0]
158    } else {
159        let cells = non_null_cells(actual);
160        match cells.len() {
161            0 => {
162                return CompareResult::fail(format!(
163                    "scalar match requires actual to reduce to one non-null cell, but actual has {} row(s) x up to {} col(s) with no non-null values",
164                    actual.rows.len(),
165                    actual.rows.iter().map(|r| r.len()).max().unwrap_or(0)
166                ));
167            }
168            1 => cells[0],
169            n => {
170                return CompareResult::fail(format!(
171                    "scalar match requires actual to reduce to one non-null cell, but found {} non-null cells across {} row(s)",
172                    n,
173                    actual.rows.len()
174                ));
175            }
176        }
177    };
178
179    if cells_equal(expected_cell, actual_cell, tolerance) {
180        CompareResult::pass()
181    } else {
182        CompareResult::fail(format!(
183            "scalar mismatch: expected {} but got {}",
184            display_string(expected_cell),
185            display_string(actual_cell)
186        ))
187    }
188}
189
190fn row_multiset(table: &Table, decimals: i32) -> BTreeMap<Vec<NormCell>, (usize, Vec<Value>)> {
191    let mut map: BTreeMap<Vec<NormCell>, (usize, Vec<Value>)> = BTreeMap::new();
192    for row in &table.rows {
193        let key = normalize_row(row, decimals);
194        let entry = map.entry(key).or_insert((0, row.clone()));
195        entry.0 += 1;
196    }
197    map
198}
199
200fn compare_set(expected: &Table, actual: &Table, tolerance: f64) -> CompareResult {
201    let decimals = decimals_for_tolerance(tolerance);
202    let expected_map = row_multiset(expected, decimals);
203    let actual_map = row_multiset(actual, decimals);
204
205    let counts_equal = expected_map.len() == actual_map.len()
206        && expected_map
207            .iter()
208            .all(|(key, (count, _))| actual_map.get(key).map(|(c, _)| c) == Some(count));
209    if counts_equal {
210        return CompareResult::pass();
211    }
212
213    let mut keys: Vec<&Vec<NormCell>> = expected_map.keys().chain(actual_map.keys()).collect();
214    keys.sort();
215    keys.dedup();
216
217    for key in keys {
218        let expected_count = expected_map.get(key).map_or(0, |(c, _)| *c);
219        let actual_count = actual_map.get(key).map_or(0, |(c, _)| *c);
220        if expected_count != actual_count {
221            let example = expected_map
222                .get(key)
223                .or_else(|| actual_map.get(key))
224                .map(|(_, row)| row.clone())
225                .unwrap_or_default();
226            return CompareResult::fail(format!(
227                "set mismatch: expected {} row(s) total, actual {} row(s) total; row {:?} appears {} time(s) in expected but {} time(s) in actual",
228                expected.rows.len(),
229                actual.rows.len(),
230                example,
231                expected_count,
232                actual_count
233            ));
234        }
235    }
236
237    CompareResult::fail("set mismatch: multisets differ")
238}
239
240fn compare_ordered(expected: &Table, actual: &Table, tolerance: f64) -> CompareResult {
241    if expected.rows.len() != actual.rows.len() {
242        return CompareResult::fail(format!(
243            "ordered mismatch: row count differs (expected {}, actual {})",
244            expected.rows.len(),
245            actual.rows.len()
246        ));
247    }
248
249    let decimals = decimals_for_tolerance(tolerance);
250    for (i, (expected_row, actual_row)) in expected.rows.iter().zip(actual.rows.iter()).enumerate()
251    {
252        let expected_norm = normalize_row(expected_row, decimals);
253        let actual_norm = normalize_row(actual_row, decimals);
254        if expected_norm != actual_norm {
255            return CompareResult::fail(format!(
256                "ordered mismatch at row index {}: expected {:?}, actual {:?}",
257                i, expected_row, actual_row
258            ));
259        }
260    }
261
262    CompareResult::pass()
263}
264
265pub fn compare(request: &CompareRequest) -> CompareResult {
266    match request.match_mode {
267        MatchMode::Scalar => compare_scalar(
268            &request.expected,
269            &request.actual,
270            request.tolerance.numeric,
271        ),
272        MatchMode::Set => compare_set(
273            &request.expected,
274            &request.actual,
275            request.tolerance.numeric,
276        ),
277        MatchMode::Ordered => compare_ordered(
278            &request.expected,
279            &request.actual,
280            request.tolerance.numeric,
281        ),
282    }
283}