Skip to main content

eredu_evaluation/
parity.rs

1//! Backend-independent comparison of portable execution observations.
2
3use std::collections::BTreeSet;
4
5use eredu_core::{
6    ObservationSelector, ObservationSet, ObservationValue, TensorObservation, TensorObservationData,
7};
8use serde::{Deserialize, Serialize};
9
10/// Absolute and relative thresholds for ordinary floating-point observations.
11#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
12pub struct NumericTolerance {
13    /// Largest accepted absolute element error.
14    pub absolute_max: f64,
15    /// Largest accepted L2 error relative to the reference norm.
16    pub relative_l2_max: f64,
17    /// Smallest accepted cosine similarity.
18    pub cosine_similarity_min: f64,
19}
20
21impl NumericTolerance {
22    /// Exact floating-point equality after finite-value validation.
23    pub const fn exact() -> Self {
24        Self {
25            absolute_max: 0.0,
26            relative_l2_max: 0.0,
27            cosine_similarity_min: 1.0,
28        }
29    }
30
31    fn validate(self) -> Result<(), ParityError> {
32        if !self.absolute_max.is_finite() || self.absolute_max < 0.0 {
33            return Err(ParityError::InvalidPolicy(
34                "absolute_max must be finite and nonnegative".into(),
35            ));
36        }
37        if !self.relative_l2_max.is_finite() || self.relative_l2_max < 0.0 {
38            return Err(ParityError::InvalidPolicy(
39                "relative_l2_max must be finite and nonnegative".into(),
40            ));
41        }
42        if !self.cosine_similarity_min.is_finite()
43            || !(-1.0..=1.0).contains(&self.cosine_similarity_min)
44        {
45            return Err(ParityError::InvalidPolicy(
46                "cosine_similarity_min must be finite and within [-1, 1]".into(),
47            ));
48        }
49        Ok(())
50    }
51}
52
53/// Thresholds specialized for rows of vocabulary logits.
54#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
55pub struct LogitTolerance {
56    /// Largest accepted L2 error relative to the reference norm.
57    pub relative_l2_max: f64,
58    /// Smallest accepted cosine similarity.
59    pub cosine_similarity_min: f64,
60    /// Number of leading vocabulary entries considered for overlap.
61    pub top_k: usize,
62    /// Smallest accepted intersection of the two top-k sets.
63    pub top_k_overlap_min: usize,
64    /// Require equal argmax when the reference margin is unambiguous.
65    pub require_unambiguous_argmax_match: bool,
66    /// Reference top-1 minus top-2 margin above which argmax must agree.
67    pub argmax_margin_min: f32,
68}
69
70impl LogitTolerance {
71    fn validate(self) -> Result<(), ParityError> {
72        NumericTolerance {
73            absolute_max: 0.0,
74            relative_l2_max: self.relative_l2_max,
75            cosine_similarity_min: self.cosine_similarity_min,
76        }
77        .validate()?;
78        if self.top_k == 0 {
79            return Err(ParityError::InvalidPolicy(
80                "logit top_k must be positive".into(),
81            ));
82        }
83        if self.top_k_overlap_min > self.top_k {
84            return Err(ParityError::InvalidPolicy(
85                "top_k_overlap_min must not exceed top_k".into(),
86            ));
87        }
88        if !self.argmax_margin_min.is_finite() || self.argmax_margin_min < 0.0 {
89            return Err(ParityError::InvalidPolicy(
90                "argmax_margin_min must be finite and nonnegative".into(),
91            ));
92        }
93        Ok(())
94    }
95}
96
97/// Comparison applied to selected observation paths.
98#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
99#[serde(tag = "comparison", rename_all = "snake_case")]
100pub enum ParityComparison {
101    /// Values, variants, tensor shapes, and elements must be identical.
102    Exact,
103    /// Compare floating-point tensors or scalar values numerically.
104    Numeric {
105        /// Accepted numerical error.
106        tolerance: NumericTolerance,
107    },
108    /// Compare each last-dimension vocabulary row using logit metrics.
109    Logits {
110        /// Accepted logit differences.
111        tolerance: LogitTolerance,
112    },
113}
114
115/// One selector-specific comparison rule.
116#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
117pub struct ParityRule {
118    /// Observation paths covered by the rule.
119    pub selector: ObservationSelector,
120    /// Comparison applied to every selected path.
121    pub comparison: ParityComparison,
122}
123
124/// Complete policy for comparing two observation sets.
125#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
126pub struct ParityPolicy {
127    /// Comparison used when no rule matches a path.
128    pub default: ParityComparison,
129    /// Ordered overrides; a path matching multiple rules is invalid.
130    pub rules: Vec<ParityRule>,
131    /// Reject observations present on only one side.
132    pub require_same_paths: bool,
133}
134
135impl ParityPolicy {
136    /// Requires exact parity for every path and identical path sets.
137    pub const fn exact() -> Self {
138        Self {
139            default: ParityComparison::Exact,
140            rules: Vec::new(),
141            require_same_paths: true,
142        }
143    }
144
145    fn comparison_for(&self, path: &str) -> Result<&ParityComparison, ParityError> {
146        let matches = self
147            .rules
148            .iter()
149            .filter(|rule| rule.selector.matches(path))
150            .collect::<Vec<_>>();
151        match matches.as_slice() {
152            [] => Ok(&self.default),
153            [rule] => Ok(&rule.comparison),
154            _ => Err(ParityError::AmbiguousRule(path.into())),
155        }
156    }
157
158    fn validate(&self) -> Result<(), ParityError> {
159        validate_comparison(&self.default)?;
160        for rule in &self.rules {
161            validate_comparison(&rule.comparison)?;
162        }
163        Ok(())
164    }
165}
166
167fn validate_comparison(comparison: &ParityComparison) -> Result<(), ParityError> {
168    match comparison {
169        ParityComparison::Exact => Ok(()),
170        ParityComparison::Numeric { tolerance } => tolerance.validate(),
171        ParityComparison::Logits { tolerance } => tolerance.validate(),
172    }
173}
174
175/// Common metrics for floating-point vectors.
176#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
177pub struct NumericMetrics {
178    /// Whether every value on both sides is finite.
179    pub finite: bool,
180    /// L2 norm of the difference divided by the reference L2 norm.
181    pub relative_l2: Option<f64>,
182    /// Cosine similarity.
183    pub cosine_similarity: Option<f64>,
184    /// Largest absolute element error.
185    pub max_absolute_error: Option<f64>,
186    /// Mean absolute element error.
187    pub mean_absolute_error: Option<f64>,
188}
189
190/// Metrics for one vocabulary-logit row.
191#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
192pub struct LogitRowMetrics {
193    /// Flattened row ordinal.
194    pub index: usize,
195    /// Common numerical metrics.
196    pub numeric: NumericMetrics,
197    /// Number of shared entries in the configured top-k sets.
198    pub top_k_overlap: usize,
199    /// Actual top-1 vocabulary index.
200    pub actual_argmax: Option<usize>,
201    /// Reference top-1 vocabulary index.
202    pub reference_argmax: Option<usize>,
203    /// Reference top-1 minus top-2 logit margin.
204    pub reference_argmax_margin: Option<f32>,
205    /// Whether policy required argmax equality for this row.
206    pub argmax_required: bool,
207    /// Whether top-1 indices agree.
208    pub argmax_match: bool,
209    /// Whether this row passed.
210    pub passed: bool,
211}
212
213/// Metrics emitted for one compared observation.
214#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
215#[serde(tag = "kind", rename_all = "snake_case")]
216pub enum ParityMetrics {
217    /// Exact equality comparison.
218    Exact,
219    /// Ordinary numerical comparison.
220    Numeric {
221        /// Computed metrics.
222        metrics: NumericMetrics,
223    },
224    /// Per-row vocabulary-logit comparison.
225    Logits {
226        /// Last logical dimension.
227        vocabulary_size: usize,
228        /// Metrics for every flattened leading row.
229        rows: Vec<LogitRowMetrics>,
230    },
231}
232
233/// Comparison result for one path.
234#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
235pub struct ObservationParity {
236    /// Stable observation path.
237    pub path: String,
238    /// Whether the observation passed its rule.
239    pub passed: bool,
240    /// Shape or type failure when values were not comparable.
241    pub failure: Option<String>,
242    /// Metrics when comparison could be performed.
243    pub metrics: Option<ParityMetrics>,
244}
245
246/// Complete backend/reference parity report.
247#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
248pub struct ParityReport {
249    /// Report schema version.
250    pub format_version: u32,
251    /// Whether every required observation passed.
252    pub passed: bool,
253    /// Path-level results in stable order.
254    pub observations: Vec<ObservationParity>,
255    /// Missing, unexpected, or invalid observations.
256    pub failures: Vec<String>,
257}
258
259/// Compares two portable observation sets under one policy.
260pub fn compare_observations(
261    actual: &ObservationSet,
262    reference: &ObservationSet,
263    policy: &ParityPolicy,
264) -> Result<ParityReport, ParityError> {
265    policy.validate()?;
266    let actual_paths = actual.iter().map(|(path, _)| path).collect::<BTreeSet<_>>();
267    let reference_paths = reference
268        .iter()
269        .map(|(path, _)| path)
270        .collect::<BTreeSet<_>>();
271    let mut failures = Vec::new();
272    if policy.require_same_paths {
273        for path in reference_paths.difference(&actual_paths) {
274            failures.push(format!("actual observations are missing {path:?}"));
275        }
276        for path in actual_paths.difference(&reference_paths) {
277            failures.push(format!("actual observations unexpectedly contain {path:?}"));
278        }
279    }
280
281    let mut observations = Vec::new();
282    for path in actual_paths.intersection(&reference_paths) {
283        let actual_value = actual.get(path).expect("path came from actual set");
284        let reference_value = reference.get(path).expect("path came from reference set");
285        let comparison = policy.comparison_for(path)?;
286        let parity = compare_value(path, actual_value, reference_value, comparison)?;
287        if !parity.passed {
288            failures.push(format!("observation {path:?} failed parity"));
289        }
290        observations.push(parity);
291    }
292    Ok(ParityReport {
293        format_version: 1,
294        passed: failures.is_empty(),
295        observations,
296        failures,
297    })
298}
299
300fn compare_value(
301    path: &str,
302    actual: &ObservationValue,
303    reference: &ObservationValue,
304    comparison: &ParityComparison,
305) -> Result<ObservationParity, ParityError> {
306    match comparison {
307        ParityComparison::Exact => Ok(ObservationParity {
308            path: path.into(),
309            passed: actual == reference,
310            failure: (actual != reference).then(|| "values differ".into()),
311            metrics: Some(ParityMetrics::Exact),
312        }),
313        ParityComparison::Numeric { tolerance } => match numeric_values(actual, reference) {
314            Ok((actual, reference)) => compare_numeric(path, &actual, &reference, *tolerance),
315            Err(ParityError::Incomparable(failure)) => Ok(ObservationParity {
316                path: path.into(),
317                passed: false,
318                failure: Some(failure),
319                metrics: None,
320            }),
321            Err(error) => Err(error),
322        },
323        ParityComparison::Logits { tolerance } => match tensor_pair(actual, reference) {
324            Ok((actual, reference)) => compare_logits(path, actual, reference, *tolerance),
325            Err(ParityError::Incomparable(failure)) => Ok(ObservationParity {
326                path: path.into(),
327                passed: false,
328                failure: Some(failure),
329                metrics: None,
330            }),
331            Err(error) => Err(error),
332        },
333    }
334}
335
336fn numeric_values(
337    actual: &ObservationValue,
338    reference: &ObservationValue,
339) -> Result<(Vec<f64>, Vec<f64>), ParityError> {
340    match (actual, reference) {
341        (ObservationValue::Float(actual), ObservationValue::Float(reference)) => {
342            Ok((vec![*actual], vec![*reference]))
343        }
344        (ObservationValue::Tensor(actual), ObservationValue::Tensor(reference)) => {
345            if actual.shape() != reference.shape() {
346                return Err(ParityError::Incomparable(format!(
347                    "tensor shapes differ: actual {:?}, reference {:?}",
348                    actual.shape(),
349                    reference.shape()
350                )));
351            }
352            match (actual.data(), reference.data()) {
353                (TensorObservationData::F32(actual), TensorObservationData::F32(reference)) => {
354                    Ok((
355                        actual.iter().map(|value| f64::from(*value)).collect(),
356                        reference.iter().map(|value| f64::from(*value)).collect(),
357                    ))
358                }
359                _ => Err(ParityError::Incomparable(
360                    "numeric comparison requires matching F32 tensor values".into(),
361                )),
362            }
363        }
364        _ => Err(ParityError::Incomparable(
365            "numeric comparison requires two floats or two F32 tensors".into(),
366        )),
367    }
368}
369
370fn tensor_pair<'a>(
371    actual: &'a ObservationValue,
372    reference: &'a ObservationValue,
373) -> Result<(&'a TensorObservation, &'a TensorObservation), ParityError> {
374    match (actual, reference) {
375        (ObservationValue::Tensor(actual), ObservationValue::Tensor(reference)) => {
376            Ok((actual, reference))
377        }
378        _ => Err(ParityError::Incomparable(
379            "logit comparison requires two tensors".into(),
380        )),
381    }
382}
383
384fn compare_numeric(
385    path: &str,
386    actual: &[f64],
387    reference: &[f64],
388    tolerance: NumericTolerance,
389) -> Result<ObservationParity, ParityError> {
390    if actual.len() != reference.len() {
391        return Err(ParityError::Incomparable(format!(
392            "numeric value counts differ: actual {}, reference {}",
393            actual.len(),
394            reference.len()
395        )));
396    }
397    let metrics = numeric_metrics(actual, reference);
398    let passed = metrics.finite
399        && metrics
400            .max_absolute_error
401            .is_some_and(|value| value <= tolerance.absolute_max)
402        && metrics
403            .relative_l2
404            .is_some_and(|value| value <= tolerance.relative_l2_max)
405        && metrics
406            .cosine_similarity
407            .is_some_and(|value| value >= tolerance.cosine_similarity_min);
408    Ok(ObservationParity {
409        path: path.into(),
410        passed,
411        failure: (!passed).then(|| "numeric thresholds failed".into()),
412        metrics: Some(ParityMetrics::Numeric { metrics }),
413    })
414}
415
416fn numeric_metrics(actual: &[f64], reference: &[f64]) -> NumericMetrics {
417    let finite = actual
418        .iter()
419        .chain(reference)
420        .all(|value| value.is_finite());
421    if !finite {
422        return NumericMetrics {
423            finite: false,
424            relative_l2: None,
425            cosine_similarity: None,
426            max_absolute_error: None,
427            mean_absolute_error: None,
428        };
429    }
430    let difference_squared = actual
431        .iter()
432        .zip(reference)
433        .map(|(actual, reference)| (actual - reference).powi(2))
434        .sum::<f64>();
435    let actual_squared = actual.iter().map(|value| value * value).sum::<f64>();
436    let reference_squared = reference.iter().map(|value| value * value).sum::<f64>();
437    let dot = actual
438        .iter()
439        .zip(reference)
440        .map(|(actual, reference)| actual * reference)
441        .sum::<f64>();
442    let absolute_errors = actual
443        .iter()
444        .zip(reference)
445        .map(|(actual, reference)| (actual - reference).abs())
446        .collect::<Vec<_>>();
447    let denominator = actual_squared.sqrt() * reference_squared.sqrt();
448    let cosine = if denominator == 0.0 {
449        if actual == reference {
450            1.0
451        } else {
452            0.0
453        }
454    } else {
455        dot / denominator
456    };
457    NumericMetrics {
458        finite: true,
459        relative_l2: Some(difference_squared.sqrt() / reference_squared.sqrt().max(1e-12)),
460        cosine_similarity: Some(cosine),
461        max_absolute_error: Some(absolute_errors.iter().copied().fold(0.0, f64::max)),
462        mean_absolute_error: Some(
463            absolute_errors.iter().sum::<f64>() / absolute_errors.len().max(1) as f64,
464        ),
465    }
466}
467
468fn compare_logits(
469    path: &str,
470    actual: &TensorObservation,
471    reference: &TensorObservation,
472    tolerance: LogitTolerance,
473) -> Result<ObservationParity, ParityError> {
474    if actual.shape() != reference.shape() {
475        return Ok(ObservationParity {
476            path: path.into(),
477            passed: false,
478            failure: Some(format!(
479                "tensor shapes differ: actual {:?}, reference {:?}",
480                actual.shape(),
481                reference.shape()
482            )),
483            metrics: None,
484        });
485    }
486    let vocabulary_size = actual.shape().last().copied().unwrap_or(0);
487    if vocabulary_size == 0 {
488        return Err(ParityError::Incomparable(
489            "logit tensor must have a nonempty last dimension".into(),
490        ));
491    }
492    let (actual, reference) = match (actual.data(), reference.data()) {
493        (TensorObservationData::F32(actual), TensorObservationData::F32(reference)) => {
494            (actual, reference)
495        }
496        _ => {
497            return Err(ParityError::Incomparable(
498                "logit comparison requires F32 tensor values".into(),
499            ))
500        }
501    };
502    let mut rows = Vec::with_capacity(actual.len() / vocabulary_size);
503    for (index, (actual, reference)) in actual
504        .chunks_exact(vocabulary_size)
505        .zip(reference.chunks_exact(vocabulary_size))
506        .enumerate()
507    {
508        rows.push(compare_logit_row(index, actual, reference, tolerance));
509    }
510    let passed = rows.iter().all(|row| row.passed);
511    Ok(ObservationParity {
512        path: path.into(),
513        passed,
514        failure: (!passed).then(|| "logit thresholds failed".into()),
515        metrics: Some(ParityMetrics::Logits {
516            vocabulary_size,
517            rows,
518        }),
519    })
520}
521
522fn compare_logit_row(
523    index: usize,
524    actual: &[f32],
525    reference: &[f32],
526    tolerance: LogitTolerance,
527) -> LogitRowMetrics {
528    let actual_f64 = actual
529        .iter()
530        .map(|value| f64::from(*value))
531        .collect::<Vec<_>>();
532    let reference_f64 = reference
533        .iter()
534        .map(|value| f64::from(*value))
535        .collect::<Vec<_>>();
536    let numeric = numeric_metrics(&actual_f64, &reference_f64);
537    if !numeric.finite {
538        return LogitRowMetrics {
539            index,
540            numeric,
541            top_k_overlap: 0,
542            actual_argmax: None,
543            reference_argmax: None,
544            reference_argmax_margin: None,
545            argmax_required: false,
546            argmax_match: false,
547            passed: false,
548        };
549    }
550    let top_count = tolerance.top_k.min(reference.len());
551    let actual_top = top_indices(actual, top_count);
552    let reference_order = top_indices(reference, top_count.max(2).min(reference.len()));
553    let reference_top = &reference_order[..top_count];
554    let top_k_overlap = actual_top
555        .iter()
556        .filter(|index| reference_top.contains(index))
557        .count();
558    let actual_argmax = actual_top.first().copied();
559    let reference_argmax = reference_top.first().copied();
560    let reference_argmax_margin = reference_argmax.map(|argmax| {
561        let runner_up = reference_order.get(1).copied().unwrap_or(argmax);
562        reference[argmax] - reference[runner_up]
563    });
564    let argmax_required = tolerance.require_unambiguous_argmax_match
565        && reference_argmax_margin.is_some_and(|margin| margin > tolerance.argmax_margin_min);
566    let argmax_match = actual_argmax == reference_argmax;
567    let passed = numeric
568        .relative_l2
569        .is_some_and(|value| value <= tolerance.relative_l2_max)
570        && numeric
571            .cosine_similarity
572            .is_some_and(|value| value >= tolerance.cosine_similarity_min)
573        && top_k_overlap >= tolerance.top_k_overlap_min.min(top_count)
574        && (!argmax_required || argmax_match);
575    LogitRowMetrics {
576        index,
577        numeric,
578        top_k_overlap,
579        actual_argmax,
580        reference_argmax,
581        reference_argmax_margin,
582        argmax_required,
583        argmax_match,
584        passed,
585    }
586}
587
588fn top_indices(values: &[f32], count: usize) -> Vec<usize> {
589    let mut indexes = (0..values.len()).collect::<Vec<_>>();
590    indexes.sort_by(|left, right| {
591        values[*right]
592            .total_cmp(&values[*left])
593            .then_with(|| left.cmp(right))
594    });
595    indexes.truncate(count);
596    indexes
597}
598
599/// Invalid comparison policy or incompatible observations.
600#[derive(Debug, Clone, Eq, PartialEq, thiserror::Error)]
601pub enum ParityError {
602    /// A comparison threshold is invalid.
603    #[error("invalid parity policy: {0}")]
604    InvalidPolicy(String),
605    /// More than one rule matched the same path.
606    #[error("multiple parity rules match observation {0:?}")]
607    AmbiguousRule(String),
608    /// Selected values cannot be compared by the requested method.
609    #[error("incomparable observations: {0}")]
610    Incomparable(String),
611}
612
613#[cfg(test)]
614mod tests {
615    use super::*;
616
617    fn tensor(shape: Vec<usize>, values: Vec<f32>) -> ObservationValue {
618        ObservationValue::Tensor(
619            TensorObservation::new(shape, TensorObservationData::F32(values)).unwrap(),
620        )
621    }
622
623    fn tokens(values: Vec<i64>) -> ObservationValue {
624        ObservationValue::Tensor(
625            TensorObservation::new(vec![values.len()], TensorObservationData::I64(values)).unwrap(),
626        )
627    }
628
629    #[test]
630    fn exact_parity_handles_tokens_and_path_identity() {
631        let mut actual = ObservationSet::new();
632        actual.insert("decode.tokens", tokens(vec![1, 2])).unwrap();
633        let reference = actual.clone();
634        assert!(
635            compare_observations(&actual, &reference, &ParityPolicy::exact())
636                .unwrap()
637                .passed
638        );
639    }
640
641    #[test]
642    fn logit_parity_computes_shared_metrics_once() {
643        let mut actual = ObservationSet::new();
644        actual
645            .insert(
646                eredu_core::MODEL_LOGITS_OBSERVATION_PATH,
647                tensor(vec![1, 3], vec![0.0, 2.0, 1.0]),
648            )
649            .unwrap();
650        let mut reference = ObservationSet::new();
651        reference
652            .insert(
653                eredu_core::MODEL_LOGITS_OBSERVATION_PATH,
654                tensor(vec![1, 3], vec![0.0, 2.1, 0.9]),
655            )
656            .unwrap();
657        let policy = ParityPolicy {
658            default: ParityComparison::Logits {
659                tolerance: LogitTolerance {
660                    relative_l2_max: 0.1,
661                    cosine_similarity_min: 0.99,
662                    top_k: 2,
663                    top_k_overlap_min: 2,
664                    require_unambiguous_argmax_match: true,
665                    argmax_margin_min: 0.1,
666                },
667            },
668            rules: Vec::new(),
669            require_same_paths: true,
670        };
671        assert!(
672            compare_observations(&actual, &reference, &policy)
673                .unwrap()
674                .passed
675        );
676    }
677}