1use std::collections::BTreeSet;
4
5use eredu_core::{
6 ObservationSelector, ObservationSet, ObservationValue, TensorObservation, TensorObservationData,
7};
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
12pub struct NumericTolerance {
13 pub absolute_max: f64,
15 pub relative_l2_max: f64,
17 pub cosine_similarity_min: f64,
19}
20
21impl NumericTolerance {
22 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#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
55pub struct LogitTolerance {
56 pub relative_l2_max: f64,
58 pub cosine_similarity_min: f64,
60 pub top_k: usize,
62 pub top_k_overlap_min: usize,
64 pub require_unambiguous_argmax_match: bool,
66 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
99#[serde(tag = "comparison", rename_all = "snake_case")]
100pub enum ParityComparison {
101 Exact,
103 Numeric {
105 tolerance: NumericTolerance,
107 },
108 Logits {
110 tolerance: LogitTolerance,
112 },
113}
114
115#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
117pub struct ParityRule {
118 pub selector: ObservationSelector,
120 pub comparison: ParityComparison,
122}
123
124#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
126pub struct ParityPolicy {
127 pub default: ParityComparison,
129 pub rules: Vec<ParityRule>,
131 pub require_same_paths: bool,
133}
134
135impl ParityPolicy {
136 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
177pub struct NumericMetrics {
178 pub finite: bool,
180 pub relative_l2: Option<f64>,
182 pub cosine_similarity: Option<f64>,
184 pub max_absolute_error: Option<f64>,
186 pub mean_absolute_error: Option<f64>,
188}
189
190#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
192pub struct LogitRowMetrics {
193 pub index: usize,
195 pub numeric: NumericMetrics,
197 pub top_k_overlap: usize,
199 pub actual_argmax: Option<usize>,
201 pub reference_argmax: Option<usize>,
203 pub reference_argmax_margin: Option<f32>,
205 pub argmax_required: bool,
207 pub argmax_match: bool,
209 pub passed: bool,
211}
212
213#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
215#[serde(tag = "kind", rename_all = "snake_case")]
216pub enum ParityMetrics {
217 Exact,
219 Numeric {
221 metrics: NumericMetrics,
223 },
224 Logits {
226 vocabulary_size: usize,
228 rows: Vec<LogitRowMetrics>,
230 },
231}
232
233#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
235pub struct ObservationParity {
236 pub path: String,
238 pub passed: bool,
240 pub failure: Option<String>,
242 pub metrics: Option<ParityMetrics>,
244}
245
246#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
248pub struct ParityReport {
249 pub format_version: u32,
251 pub passed: bool,
253 pub observations: Vec<ObservationParity>,
255 pub failures: Vec<String>,
257}
258
259pub 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#[derive(Debug, Clone, Eq, PartialEq, thiserror::Error)]
601pub enum ParityError {
602 #[error("invalid parity policy: {0}")]
604 InvalidPolicy(String),
605 #[error("multiple parity rules match observation {0:?}")]
607 AmbiguousRule(String),
608 #[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}