Skip to main content

scirs2_core/validation/
numerical_validation.rs

1//! Numerical validation framework for scientific computing libraries.
2//!
3//! Provides composable validators for distribution functions, numerical algorithms,
4//! and mathematical functions. Designed for cross-ecosystem reuse by NumRS2,
5//! SkleaRS, and other COOLJAPAN crates.
6//!
7//! ## Overview
8//!
9//! The framework centers on the `NumericalValidator` trait, which every concrete
10//! validator implements. Callers supply a *computed* function and a *reference*
11//! function; the harness sweeps over the input grid, records per-point
12//! `ValidationOutcome`s, and aggregates them into a `NumericalValidationReport`.
13//!
14//! Three ready-made validators are provided:
15//!
16//! - `ComparisonValidator` — general-purpose point comparison
17//! - `MonotonicityChecker` — asserts strict/weak monotonic ordering
18//! - `BoundaryChecker` — validates domain-boundary values
19//! - `SymmetryChecker` — asserts even or odd function symmetry
20//!
21//! ## Example
22//!
23//! ```rust
24//! use scirs2_core::validation::numerical_validation::{
25//!     ComparisonValidator, NumericalValidator, NumericalValidationConfig,
26//! };
27//!
28//! let validator = ComparisonValidator::new("sin_approximation");
29//! let config = NumericalValidationConfig::default();
30//! let inputs: Vec<f64> = (0..=10).map(|i| i as f64 * 0.1).collect();
31//! let report = validator.run_validation(
32//!     &|x| x.sin(),          // computed
33//!     &|x| x.sin(),          // reference (identity)
34//!     &inputs,
35//!     &config,
36//! );
37//! assert!(report.all_passed());
38//! ```
39
40use std::fmt;
41use std::time::Instant;
42
43// ---------------------------------------------------------------------------
44// ValidationOutcome
45// ---------------------------------------------------------------------------
46
47/// Result of a single validation check at one input point.
48#[derive(Debug, Clone)]
49pub struct ValidationOutcome {
50    /// Whether the validation passed.
51    pub passed: bool,
52    /// Input value that was tested.
53    pub input: f64,
54    /// Expected (reference) value.
55    pub expected: f64,
56    /// Computed value.
57    pub computed: f64,
58    /// Absolute error |computed − expected|.
59    pub abs_error: f64,
60    /// Relative error |computed − expected| / |expected|, or `None` when
61    /// |expected| ≤ `f64::EPSILON`.
62    pub rel_error: Option<f64>,
63}
64
65impl ValidationOutcome {
66    /// Create a [`ValidationOutcome`] by comparing `computed` against `expected`
67    /// using the supplied tolerances.
68    ///
69    /// Passing criterion (either condition is sufficient):
70    /// - `abs_error ≤ tol_abs`
71    /// - `rel_error ≤ tol_rel` (when the relative error is defined)
72    pub fn new(input: f64, expected: f64, computed: f64, tol_rel: f64, tol_abs: f64) -> Self {
73        let abs_error = (computed - expected).abs();
74        let rel_error = if expected.abs() > f64::EPSILON {
75            Some(abs_error / expected.abs())
76        } else {
77            None
78        };
79        let passed = abs_error <= tol_abs || rel_error.is_some_and(|r| r <= tol_rel);
80        Self {
81            passed,
82            input,
83            expected,
84            computed,
85            abs_error,
86            rel_error,
87        }
88    }
89}
90
91impl fmt::Display for ValidationOutcome {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        let rel = self
94            .rel_error
95            .map_or_else(|| "N/A".to_string(), |r| format!("{r:.3e}"));
96        write!(
97            f,
98            "x={:.6e} | expected={:.6e} | computed={:.6e} | abs_err={:.3e} | rel_err={} | {}",
99            self.input,
100            self.expected,
101            self.computed,
102            self.abs_error,
103            rel,
104            if self.passed { "PASS" } else { "FAIL" }
105        )
106    }
107}
108
109// ---------------------------------------------------------------------------
110// NumericalValidationReport
111// ---------------------------------------------------------------------------
112
113/// Summary report produced by a [`NumericalValidator`] after processing all
114/// test points.
115#[derive(Debug, Clone)]
116pub struct NumericalValidationReport {
117    /// Name of the function or method under test.
118    pub function_name: String,
119    /// Total number of checks performed.
120    pub total_checks: usize,
121    /// Number of checks that passed.
122    pub passed_checks: usize,
123    /// All failing [`ValidationOutcome`]s (empty when all checks pass).
124    pub failures: Vec<ValidationOutcome>,
125    /// Wall-clock time in nanoseconds for the entire validation run.
126    pub timing_ns: u64,
127    /// Maximum absolute error observed across all test points.
128    pub max_abs_error: f64,
129    /// Maximum relative error observed (0.0 when none are defined).
130    pub max_rel_error: f64,
131}
132
133impl NumericalValidationReport {
134    /// Pass rate in [0, 1].  Returns 1.0 when `total_checks == 0`.
135    pub fn pass_rate(&self) -> f64 {
136        if self.total_checks == 0 {
137            1.0
138        } else {
139            self.passed_checks as f64 / self.total_checks as f64
140        }
141    }
142
143    /// Returns `true` when there are no failures.
144    pub fn all_passed(&self) -> bool {
145        self.failures.is_empty()
146    }
147
148    /// Render the report as a Markdown section with a summary table and, when
149    /// failures exist, a detail table listing each failed check.
150    pub fn to_markdown(&self) -> String {
151        let mut md = String::new();
152
153        md.push_str(&format!(
154            "## Validation Report: `{}`\n\n",
155            self.function_name
156        ));
157        md.push_str("| Metric | Value |\n");
158        md.push_str("|--------|-------|\n");
159        md.push_str(&format!("| Total checks | {} |\n", self.total_checks));
160        md.push_str(&format!("| Passed checks | {} |\n", self.passed_checks));
161        md.push_str(&format!(
162            "| Pass rate | {:.1}% |\n",
163            self.pass_rate() * 100.0
164        ));
165        md.push_str(&format!(
166            "| Max absolute error | {:.3e} |\n",
167            self.max_abs_error
168        ));
169        md.push_str(&format!(
170            "| Max relative error | {:.3e} |\n",
171            self.max_rel_error
172        ));
173        md.push_str(&format!("| Timing | {} ns |\n", self.timing_ns));
174        md.push_str(&format!(
175            "| Status | {} |\n\n",
176            if self.all_passed() {
177                "✓ PASS"
178            } else {
179                "✗ FAIL"
180            }
181        ));
182
183        if !self.failures.is_empty() {
184            md.push_str("### Failures\n\n");
185            md.push_str("| Input | Expected | Computed | Abs Error | Rel Error |\n");
186            md.push_str("|-------|----------|----------|-----------|-----------|\n");
187            for f in &self.failures {
188                let rel = f
189                    .rel_error
190                    .map_or_else(|| "N/A".to_string(), |r| format!("{r:.3e}"));
191                md.push_str(&format!(
192                    "| {:.6e} | {:.6e} | {:.6e} | {:.3e} | {} |\n",
193                    f.input, f.expected, f.computed, f.abs_error, rel
194                ));
195            }
196        }
197
198        md
199    }
200}
201
202impl fmt::Display for NumericalValidationReport {
203    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
204        write!(
205            f,
206            "{}: {}/{} passed ({:.1}%), max_abs={:.3e}, max_rel={:.3e}",
207            self.function_name,
208            self.passed_checks,
209            self.total_checks,
210            self.pass_rate() * 100.0,
211            self.max_abs_error,
212            self.max_rel_error,
213        )
214    }
215}
216
217// ---------------------------------------------------------------------------
218// NumericalValidationConfig
219// ---------------------------------------------------------------------------
220
221/// Configuration knobs shared by all validators.
222#[derive(Debug, Clone)]
223pub struct NumericalValidationConfig {
224    /// Relative tolerance for the pass/fail decision.
225    pub relative_tolerance: f64,
226    /// Absolute tolerance for the pass/fail decision.
227    pub absolute_tolerance: f64,
228    /// Number of test points to sample when generating grids internally.
229    pub num_test_points: usize,
230    /// Random seed for reproducible sampling.
231    pub seed: u64,
232}
233
234impl Default for NumericalValidationConfig {
235    fn default() -> Self {
236        Self {
237            relative_tolerance: 1e-6,
238            absolute_tolerance: 1e-10,
239            num_test_points: 100,
240            seed: 42,
241        }
242    }
243}
244
245// ---------------------------------------------------------------------------
246// NumericalValidator trait
247// ---------------------------------------------------------------------------
248
249/// Core trait for numerical validators.
250///
251/// Implementors compare a *computed* function against a *reference* function
252/// over a supplied grid of inputs and produce a [`NumericalValidationReport`].
253pub trait NumericalValidator: Send + Sync {
254    /// Human-readable name for this validator instance.
255    fn name(&self) -> &str;
256
257    /// Validate a single (input, computed, reference) triple.
258    fn validate(
259        &self,
260        input: f64,
261        computed: f64,
262        reference: f64,
263        config: &NumericalValidationConfig,
264    ) -> ValidationOutcome;
265
266    /// Sweep over `inputs`, calling `computed_fn` and `reference_fn` at each
267    /// point, and aggregate the results into a [`NumericalValidationReport`].
268    fn run_validation(
269        &self,
270        computed_fn: &dyn Fn(f64) -> f64,
271        reference_fn: &dyn Fn(f64) -> f64,
272        inputs: &[f64],
273        config: &NumericalValidationConfig,
274    ) -> NumericalValidationReport;
275}
276
277// ---------------------------------------------------------------------------
278// ComparisonValidator
279// ---------------------------------------------------------------------------
280
281/// General-purpose point-wise comparison validator.
282///
283/// Applies the tolerances from [`NumericalValidationConfig`] at every input,
284/// accumulates statistics, and returns a [`NumericalValidationReport`].
285#[derive(Debug, Clone)]
286pub struct ComparisonValidator {
287    name: String,
288}
289
290impl ComparisonValidator {
291    /// Create a new [`ComparisonValidator`] with the given display name.
292    pub fn new(name: impl Into<String>) -> Self {
293        Self { name: name.into() }
294    }
295}
296
297impl NumericalValidator for ComparisonValidator {
298    fn name(&self) -> &str {
299        &self.name
300    }
301
302    fn validate(
303        &self,
304        input: f64,
305        computed: f64,
306        reference: f64,
307        config: &NumericalValidationConfig,
308    ) -> ValidationOutcome {
309        ValidationOutcome::new(
310            input,
311            reference,
312            computed,
313            config.relative_tolerance,
314            config.absolute_tolerance,
315        )
316    }
317
318    fn run_validation(
319        &self,
320        computed_fn: &dyn Fn(f64) -> f64,
321        reference_fn: &dyn Fn(f64) -> f64,
322        inputs: &[f64],
323        config: &NumericalValidationConfig,
324    ) -> NumericalValidationReport {
325        let start = Instant::now();
326        let mut outcomes: Vec<ValidationOutcome> = Vec::with_capacity(inputs.len());
327
328        for &x in inputs {
329            let computed = computed_fn(x);
330            let reference = reference_fn(x);
331            outcomes.push(self.validate(x, computed, reference, config));
332        }
333
334        let elapsed_ns = start.elapsed().as_nanos() as u64;
335        let total = outcomes.len();
336        let passed = outcomes.iter().filter(|o| o.passed).count();
337        let failures: Vec<ValidationOutcome> =
338            outcomes.iter().filter(|o| !o.passed).cloned().collect();
339
340        let max_abs_error = outcomes.iter().map(|o| o.abs_error).fold(0.0_f64, f64::max);
341        let max_rel_error = outcomes
342            .iter()
343            .filter_map(|o| o.rel_error)
344            .fold(0.0_f64, f64::max);
345
346        NumericalValidationReport {
347            function_name: self.name.clone(),
348            total_checks: total,
349            passed_checks: passed,
350            failures,
351            timing_ns: elapsed_ns,
352            max_abs_error,
353            max_rel_error,
354        }
355    }
356}
357
358// ---------------------------------------------------------------------------
359// MonotonicityChecker
360// ---------------------------------------------------------------------------
361
362/// Checks that a function is monotonically increasing or decreasing over a
363/// sorted grid of inputs.
364///
365/// A violation is recorded when `f(x[i+1]) < f(x[i]) - tolerance` (increasing
366/// mode) or `f(x[i+1]) > f(x[i]) + tolerance` (decreasing mode).
367#[derive(Debug, Clone)]
368pub struct MonotonicityChecker {
369    /// `true` → assert increasing; `false` → assert decreasing.
370    pub increasing: bool,
371    /// Tolerance for strict/weak monotonicity.  Values within this margin of
372    /// their predecessor are not considered violations.
373    pub tolerance: f64,
374}
375
376impl MonotonicityChecker {
377    /// Build a checker that asserts `f` is monotonically increasing.
378    pub fn new_increasing(tolerance: f64) -> Self {
379        Self {
380            increasing: true,
381            tolerance,
382        }
383    }
384
385    /// Build a checker that asserts `f` is monotonically decreasing.
386    pub fn new_decreasing(tolerance: f64) -> Self {
387        Self {
388            increasing: false,
389            tolerance,
390        }
391    }
392
393    /// Returns `true` when `f` is monotone over all consecutive pairs in
394    /// `inputs`.  `inputs` need not be pre-sorted; consecutive pairs are
395    /// evaluated in the order given.
396    pub fn check(&self, f: &dyn Fn(f64) -> f64, inputs: &[f64]) -> bool {
397        if inputs.len() < 2 {
398            return true;
399        }
400        let values: Vec<f64> = inputs.iter().map(|&x| f(x)).collect();
401        for window in values.windows(2) {
402            let (prev, next) = (window[0], window[1]);
403            if self.increasing {
404                // next should be >= prev − tol
405                if next < prev - self.tolerance {
406                    return false;
407                }
408            } else {
409                // next should be <= prev + tol
410                if next > prev + self.tolerance {
411                    return false;
412                }
413            }
414        }
415        true
416    }
417
418    /// Like [`Self::check`], but additionally returns the index of the first
419    /// violation (if any).
420    pub fn check_with_first_violation(
421        &self,
422        f: &dyn Fn(f64) -> f64,
423        inputs: &[f64],
424    ) -> (bool, Option<usize>) {
425        if inputs.len() < 2 {
426            return (true, None);
427        }
428        let values: Vec<f64> = inputs.iter().map(|&x| f(x)).collect();
429        for (i, window) in values.windows(2).enumerate() {
430            let (prev, next) = (window[0], window[1]);
431            let violated = if self.increasing {
432                next < prev - self.tolerance
433            } else {
434                next > prev + self.tolerance
435            };
436            if violated {
437                return (false, Some(i));
438            }
439        }
440        (true, None)
441    }
442}
443
444// ---------------------------------------------------------------------------
445// BoundaryChecker
446// ---------------------------------------------------------------------------
447
448/// Validates function values at domain boundaries.
449///
450/// Useful for CDFs (`F(−∞) == 0`, `F(+∞) == 1`), PDFs (`f(0) == 0` for
451/// half-bounded domains), and similar mathematical requirements.
452#[derive(Debug, Clone)]
453pub struct BoundaryChecker {
454    /// Display name used in error messages.
455    pub name: String,
456}
457
458impl BoundaryChecker {
459    /// Create a new [`BoundaryChecker`] with the given display name.
460    pub fn new(name: impl Into<String>) -> Self {
461        Self { name: name.into() }
462    }
463
464    /// Check that `f(lower)` matches `expected_lower` and `f(upper)` matches
465    /// `expected_upper` within `tolerance`.
466    ///
467    /// Returns `(lower_ok, upper_ok)`.
468    pub fn check_bounds(
469        &self,
470        f: &dyn Fn(f64) -> f64,
471        lower: f64,
472        upper: f64,
473        expected_lower: f64,
474        expected_upper: f64,
475        tolerance: f64,
476    ) -> (bool, bool) {
477        let lower_ok = (f(lower) - expected_lower).abs() <= tolerance;
478        let upper_ok = (f(upper) - expected_upper).abs() <= tolerance;
479        (lower_ok, upper_ok)
480    }
481
482    /// Check a single boundary point.
483    pub fn check_point(
484        &self,
485        f: &dyn Fn(f64) -> f64,
486        point: f64,
487        expected: f64,
488        tolerance: f64,
489    ) -> bool {
490        (f(point) - expected).abs() <= tolerance
491    }
492
493    /// Check a collection of (point, expected) pairs.  Returns the number of
494    /// points that satisfied the tolerance.
495    pub fn check_multiple(
496        &self,
497        f: &dyn Fn(f64) -> f64,
498        points: &[(f64, f64)],
499        tolerance: f64,
500    ) -> usize {
501        points
502            .iter()
503            .filter(|&&(x, expected)| (f(x) - expected).abs() <= tolerance)
504            .count()
505    }
506}
507
508// ---------------------------------------------------------------------------
509// SymmetryChecker
510// ---------------------------------------------------------------------------
511
512/// Validates even or odd function symmetry: `f(−x) == f(x)` (even) or
513/// `f(−x) == −f(x)` (odd).
514///
515/// Only positive inputs need be passed; the checker evaluates both `x` and
516/// `−x` internally.
517#[derive(Debug, Clone)]
518pub struct SymmetryChecker {
519    /// `true` → even symmetry `f(−x) == f(x)`;
520    /// `false` → odd symmetry `f(−x) == −f(x)`.
521    pub even: bool,
522    /// Absolute tolerance for the symmetry assertion.
523    pub tolerance: f64,
524}
525
526impl SymmetryChecker {
527    /// Build a checker for even-symmetric functions.
528    pub fn new_even(tolerance: f64) -> Self {
529        Self {
530            even: true,
531            tolerance,
532        }
533    }
534
535    /// Build a checker for odd-symmetric functions.
536    pub fn new_odd(tolerance: f64) -> Self {
537        Self {
538            even: false,
539            tolerance,
540        }
541    }
542
543    /// Returns `true` when `f` satisfies the configured symmetry at every
544    /// input in `inputs`.
545    ///
546    /// `inputs` may contain positive, negative, or zero values.  For each `x`
547    /// the checker evaluates both `f(x)` and `f(−x)`.
548    pub fn check(&self, f: &dyn Fn(f64) -> f64, inputs: &[f64]) -> bool {
549        for &x in inputs {
550            let fx = f(x);
551            let fnx = f(-x);
552            let expected = if self.even { fx } else { -fx };
553            if (fnx - expected).abs() > self.tolerance {
554                return false;
555            }
556        }
557        true
558    }
559
560    /// Like [`Self::check`] but returns the index of the first violation.
561    pub fn check_with_first_violation(
562        &self,
563        f: &dyn Fn(f64) -> f64,
564        inputs: &[f64],
565    ) -> (bool, Option<usize>) {
566        for (i, &x) in inputs.iter().enumerate() {
567            let fx = f(x);
568            let fnx = f(-x);
569            let expected = if self.even { fx } else { -fx };
570            if (fnx - expected).abs() > self.tolerance {
571                return (false, Some(i));
572            }
573        }
574        (true, None)
575    }
576}
577
578// ---------------------------------------------------------------------------
579// Tests
580// ---------------------------------------------------------------------------
581
582#[cfg(test)]
583mod tests {
584    use super::*;
585    use std::f64::consts::PI;
586
587    // -----------------------------------------------------------------------
588    // ValidationOutcome
589    // -----------------------------------------------------------------------
590
591    #[test]
592    fn test_outcome_pass_within_abs_tolerance() {
593        let outcome = ValidationOutcome::new(1.0, 2.0, 2.0 + 1e-11, 1e-6, 1e-10);
594        assert!(outcome.passed);
595        assert!(outcome.abs_error < 1e-10);
596    }
597
598    #[test]
599    fn test_outcome_pass_within_rel_tolerance() {
600        let outcome = ValidationOutcome::new(1.0, 1000.0, 1000.0 * (1.0 + 5e-7), 1e-6, 1e-10);
601        assert!(outcome.passed);
602    }
603
604    #[test]
605    fn test_outcome_fail() {
606        let outcome = ValidationOutcome::new(1.0, 1.0, 2.0, 1e-6, 1e-10);
607        assert!(!outcome.passed);
608        assert!((outcome.abs_error - 1.0).abs() < 1e-12);
609    }
610
611    #[test]
612    fn test_outcome_rel_error_undefined_near_zero() {
613        let outcome = ValidationOutcome::new(0.0, 0.0, 1e-15, 1e-6, 1e-10);
614        // expected is 0.0, so rel_error should be None
615        assert!(outcome.rel_error.is_none());
616    }
617
618    #[test]
619    fn test_outcome_display() {
620        let outcome = ValidationOutcome::new(0.5, 0.5, 0.5, 1e-6, 1e-10);
621        let s = format!("{outcome}");
622        assert!(s.contains("PASS"));
623    }
624
625    // -----------------------------------------------------------------------
626    // NumericalValidationReport
627    // -----------------------------------------------------------------------
628
629    #[test]
630    fn test_report_pass_rate_empty() {
631        let report = NumericalValidationReport {
632            function_name: "empty".to_string(),
633            total_checks: 0,
634            passed_checks: 0,
635            failures: vec![],
636            timing_ns: 0,
637            max_abs_error: 0.0,
638            max_rel_error: 0.0,
639        };
640        assert!((report.pass_rate() - 1.0).abs() < 1e-12);
641    }
642
643    #[test]
644    fn test_report_pass_rate_partial() {
645        let report = NumericalValidationReport {
646            function_name: "partial".to_string(),
647            total_checks: 4,
648            passed_checks: 3,
649            failures: vec![ValidationOutcome::new(0.0, 1.0, 2.0, 1e-6, 1e-10)],
650            timing_ns: 1000,
651            max_abs_error: 1.0,
652            max_rel_error: 1.0,
653        };
654        assert!((report.pass_rate() - 0.75).abs() < 1e-12);
655        assert!(!report.all_passed());
656    }
657
658    #[test]
659    fn test_report_markdown_format() {
660        let report = NumericalValidationReport {
661            function_name: "cosine".to_string(),
662            total_checks: 10,
663            passed_checks: 10,
664            failures: vec![],
665            timing_ns: 12345,
666            max_abs_error: 1e-12,
667            max_rel_error: 1e-11,
668        };
669        let md = report.to_markdown();
670        assert!(md.contains("## Validation Report: `cosine`"));
671        assert!(md.contains("PASS"));
672        assert!(md.contains("100.0%"));
673    }
674
675    #[test]
676    fn test_report_markdown_includes_failure_table() {
677        let report = NumericalValidationReport {
678            function_name: "broken_fn".to_string(),
679            total_checks: 2,
680            passed_checks: 1,
681            failures: vec![ValidationOutcome::new(
682                std::f64::consts::PI,
683                0.0,
684                1.0,
685                1e-6,
686                1e-10,
687            )],
688            timing_ns: 500,
689            max_abs_error: 1.0,
690            max_rel_error: 0.0,
691        };
692        let md = report.to_markdown();
693        assert!(md.contains("### Failures"));
694        assert!(md.contains("FAIL"));
695    }
696
697    // -----------------------------------------------------------------------
698    // ComparisonValidator
699    // -----------------------------------------------------------------------
700
701    #[test]
702    fn test_comparison_validator_identity() {
703        let v = ComparisonValidator::new("identity");
704        let config = NumericalValidationConfig::default();
705        let inputs: Vec<f64> = (0..=20).map(|i| i as f64 * 0.1 - 1.0).collect();
706        let report = v.run_validation(&|x| x, &|x| x, &inputs, &config);
707        assert!(report.all_passed(), "Identity should pass: {report}");
708        assert_eq!(report.total_checks, inputs.len());
709    }
710
711    #[test]
712    fn test_comparison_validator_sin_reference() {
713        let v = ComparisonValidator::new("sin_vs_itself");
714        let config = NumericalValidationConfig::default();
715        let inputs: Vec<f64> = (0..50).map(|i| i as f64 * PI / 50.0).collect();
716        let report = v.run_validation(&|x| x.sin(), &|x| x.sin(), &inputs, &config);
717        assert!(report.all_passed());
718    }
719
720    #[test]
721    fn test_comparison_validator_detects_errors() {
722        let v = ComparisonValidator::new("wrong_fn");
723        let config = NumericalValidationConfig {
724            relative_tolerance: 1e-3,
725            absolute_tolerance: 1e-3,
726            ..Default::default()
727        };
728        let inputs = vec![1.0, 2.0, 3.0];
729        // computed deviates by 1.0, which is >> tolerance
730        let report = v.run_validation(&|x| x + 1.0, &|x| x, &inputs, &config);
731        assert!(!report.all_passed());
732        assert_eq!(report.failures.len(), 3);
733    }
734
735    #[test]
736    fn test_comparison_validator_name() {
737        let v = ComparisonValidator::new("my_validator");
738        assert_eq!(v.name(), "my_validator");
739    }
740
741    // -----------------------------------------------------------------------
742    // MonotonicityChecker
743    // -----------------------------------------------------------------------
744
745    #[test]
746    fn test_monotonicity_increasing_ok() {
747        let checker = MonotonicityChecker::new_increasing(1e-12);
748        let inputs: Vec<f64> = (0..=20).map(|i| i as f64 * 0.1).collect();
749        assert!(checker.check(&|x: f64| x.powi(2), &inputs));
750    }
751
752    #[test]
753    fn test_monotonicity_decreasing_ok() {
754        let checker = MonotonicityChecker::new_decreasing(1e-12);
755        let inputs: Vec<f64> = (0..=20).map(|i| i as f64 * 0.1).collect();
756        assert!(checker.check(&|x: f64| -x, &inputs));
757    }
758
759    #[test]
760    fn test_monotonicity_violation_detected() {
761        let checker = MonotonicityChecker::new_increasing(1e-12);
762        // x.sin() is not monotone on [0, 2π]
763        let inputs: Vec<f64> = (0..=100).map(|i| i as f64 * 2.0 * PI / 100.0).collect();
764        let (ok, idx) = checker.check_with_first_violation(&|x: f64| x.sin(), &inputs);
765        assert!(!ok);
766        assert!(idx.is_some());
767    }
768
769    #[test]
770    fn test_monotonicity_single_point_always_passes() {
771        let checker = MonotonicityChecker::new_increasing(0.0);
772        assert!(checker.check(&|x| x, &[1.0]));
773    }
774
775    #[test]
776    fn test_monotonicity_cdf_like() {
777        // A logistic CDF is strictly increasing.
778        let checker = MonotonicityChecker::new_increasing(1e-12);
779        let logistic = |x: f64| 1.0 / (1.0 + (-x).exp());
780        let inputs: Vec<f64> = (-50..=50).map(|i| i as f64 * 0.1).collect();
781        assert!(checker.check(&logistic, &inputs));
782    }
783
784    // -----------------------------------------------------------------------
785    // BoundaryChecker
786    // -----------------------------------------------------------------------
787
788    #[test]
789    fn test_boundary_check_bounds_pass() {
790        let bc = BoundaryChecker::new("logistic_cdf");
791        let logistic = |x: f64| 1.0 / (1.0 + (-x).exp());
792        let (lo_ok, hi_ok) = bc.check_bounds(&logistic, -100.0, 100.0, 0.0, 1.0, 1e-6);
793        assert!(lo_ok);
794        assert!(hi_ok);
795    }
796
797    #[test]
798    fn test_boundary_check_point() {
799        let bc = BoundaryChecker::new("identity");
800        assert!(bc.check_point(&|x| x, 0.0, 0.0, 1e-12));
801        assert!(!bc.check_point(&|x| x + 1.0, 0.0, 0.0, 0.5));
802    }
803
804    #[test]
805    fn test_boundary_check_multiple() {
806        let bc = BoundaryChecker::new("quadratic");
807        let points = vec![(0.0, 0.0), (1.0, 1.0), (2.0, 4.0), (3.0, 9.0)];
808        let ok = bc.check_multiple(&|x| x * x, &points, 1e-12);
809        assert_eq!(ok, 4);
810    }
811
812    #[test]
813    fn test_boundary_fail() {
814        let bc = BoundaryChecker::new("bad");
815        let (lo_ok, hi_ok) = bc.check_bounds(&|_| 0.5, -1.0, 1.0, 0.0, 1.0, 1e-6);
816        assert!(!lo_ok);
817        assert!(!hi_ok);
818    }
819
820    // -----------------------------------------------------------------------
821    // SymmetryChecker
822    // -----------------------------------------------------------------------
823
824    #[test]
825    fn test_symmetry_even_cosine() {
826        let sc = SymmetryChecker::new_even(1e-12);
827        let inputs: Vec<f64> = (1..=20).map(|i| i as f64 * 0.1).collect();
828        assert!(sc.check(&|x: f64| x.cos(), &inputs));
829    }
830
831    #[test]
832    fn test_symmetry_odd_sine() {
833        let sc = SymmetryChecker::new_odd(1e-12);
834        let inputs: Vec<f64> = (1..=20).map(|i| i as f64 * 0.1).collect();
835        assert!(sc.check(&|x: f64| x.sin(), &inputs));
836    }
837
838    #[test]
839    fn test_symmetry_even_violation() {
840        let sc = SymmetryChecker::new_even(1e-12);
841        // x.sin() is odd, not even → should fail
842        let inputs = vec![0.5, 1.0];
843        assert!(!sc.check(&|x: f64| x.sin(), &inputs));
844    }
845
846    #[test]
847    fn test_symmetry_odd_violation() {
848        let sc = SymmetryChecker::new_odd(1e-12);
849        // x.cos() is even, not odd → should fail
850        let inputs = vec![0.5, 1.0];
851        assert!(!sc.check(&|x: f64| x.cos(), &inputs));
852    }
853
854    #[test]
855    fn test_symmetry_with_first_violation() {
856        let sc = SymmetryChecker::new_even(1e-12);
857        let (ok, idx) = sc.check_with_first_violation(&|x: f64| x.sin(), &[0.5, 1.0]);
858        assert!(!ok);
859        assert_eq!(idx, Some(0));
860    }
861
862    #[test]
863    fn test_symmetry_empty_input() {
864        let sc = SymmetryChecker::new_even(1e-12);
865        assert!(sc.check(&|x| x, &[]));
866    }
867
868    // -----------------------------------------------------------------------
869    // NumericalValidationConfig defaults
870    // -----------------------------------------------------------------------
871
872    #[test]
873    fn test_config_default_values() {
874        let cfg = NumericalValidationConfig::default();
875        assert_eq!(cfg.relative_tolerance, 1e-6);
876        assert_eq!(cfg.absolute_tolerance, 1e-10);
877        assert_eq!(cfg.num_test_points, 100);
878        assert_eq!(cfg.seed, 42);
879    }
880}