Skip to main content

sciforge_hub/tools/
validation.rs

1//! Scientific validation pipeline: reference cases, monotonicity checks,
2//! NaN safety, blocking thresholds, and exports (Markdown, CSV, LaTeX, TSV).
3
4use crate::engine::experience::experiment::{DomainType, Experiment, ParameterValue};
5use crate::engine::experience::runner::{ExperimentRunner, RunOutput};
6
7/// Unit validation case comparing a computed result to a reference value.
8#[derive(Debug, Clone)]
9pub struct ValidationCase {
10    /// Name of the validation case.
11    pub name: String,
12    /// Target scientific domain.
13    pub domain: DomainType,
14    /// Name of the tested function.
15    pub function: String,
16    /// Input parameters.
17    pub params: Vec<(String, ParameterValue)>,
18    /// Expected reference value.
19    pub expected: f64,
20    /// Accepted relative tolerance.
21    pub tolerance: f64,
22    /// Source of the reference value.
23    pub source: String,
24}
25
26impl ValidationCase {
27    pub fn new(
28        name: &str,
29        domain: DomainType,
30        function: &str,
31        params: Vec<(&str, f64)>,
32        expected: f64,
33        tolerance: f64,
34        source: &str,
35    ) -> Self {
36        Self {
37            name: name.into(),
38            domain,
39            function: function.into(),
40            params: params
41                .into_iter()
42                .map(|(k, v)| (k.to_string(), ParameterValue::Scalar(v)))
43                .collect(),
44            expected,
45            tolerance,
46            source: source.into(),
47        }
48    }
49}
50
51/// Result of an individual validation case.
52#[derive(Debug, Clone)]
53pub struct ValidationResult {
54    /// Case name.
55    pub name: String,
56    /// `true` if the relative error is below the tolerance.
57    pub passed: bool,
58    /// Value computed by the engine.
59    pub computed: f64,
60    /// Reference value.
61    pub expected: f64,
62    /// Observed relative error.
63    pub relative_error: f64,
64    /// Tolerance used.
65    pub tolerance: f64,
66    /// Optional error message (unexpected type, execution failure).
67    pub error_message: Option<String>,
68}
69
70/// Validation report aggregating all results.
71#[derive(Debug, Clone)]
72pub struct ValidationReport {
73    /// List of individual results.
74    pub results: Vec<ValidationResult>,
75}
76
77impl ValidationReport {
78    /// Number of passed cases.
79    pub fn passed_count(&self) -> usize {
80        self.results.iter().filter(|r| r.passed).count()
81    }
82
83    /// Number of failed cases.
84    pub fn failed_count(&self) -> usize {
85        self.results.iter().filter(|r| !r.passed).count()
86    }
87
88    /// Total number of cases.
89    pub fn total(&self) -> usize {
90        self.results.len()
91    }
92
93    /// `true` if all cases passed.
94    pub fn all_passed(&self) -> bool {
95        self.results.iter().all(|r| r.passed)
96    }
97
98    /// Returns the failed results.
99    pub fn failures(&self) -> Vec<&ValidationResult> {
100        self.results.iter().filter(|r| !r.passed).collect()
101    }
102
103    /// Returns the result with the largest relative error.
104    pub fn worst_error(&self) -> Option<&ValidationResult> {
105        self.results.iter().max_by(|a, b| {
106            a.relative_error
107                .partial_cmp(&b.relative_error)
108                .unwrap_or(std::cmp::Ordering::Equal)
109        })
110    }
111
112    /// Exports the report in Markdown format.
113    pub fn to_markdown(&self) -> String {
114        let mut out = String::from("# Validation Report\n\n");
115        out.push_str(&format!(
116            "**{}/{} passed**\n\n",
117            self.passed_count(),
118            self.total()
119        ));
120        out.push_str("| Test | Computed | Expected | Rel. Error | Tol | Status |\n");
121        out.push_str("|------|----------|----------|------------|-----|--------|\n");
122        for r in &self.results {
123            let status = if r.passed { "✓" } else { "✗" };
124            out.push_str(&format!(
125                "| {} | {:.6e} | {:.6e} | {:.2e} | {:.0e} | {} |\n",
126                r.name, r.computed, r.expected, r.relative_error, r.tolerance, status,
127            ));
128        }
129        if let Some(w) = self.worst_error() {
130            out.push_str(&format!(
131                "\nWorst error: {} (rel={:.2e})\n",
132                w.name, w.relative_error
133            ));
134        }
135        out
136    }
137
138    /// Exports the report in CSV format.
139    pub fn to_csv(&self) -> String {
140        let mut out = String::from("name,computed,expected,relative_error,tolerance,passed\n");
141        for r in &self.results {
142            out.push_str(&format!(
143                "{},{:.10e},{:.10e},{:.6e},{:.0e},{}\n",
144                r.name, r.computed, r.expected, r.relative_error, r.tolerance, r.passed,
145            ));
146        }
147        out
148    }
149}
150
151/// Runs all validation cases and returns a report.
152pub fn run_validation(cases: &[ValidationCase]) -> ValidationReport {
153    let runner = ExperimentRunner::new();
154    let mut results = Vec::with_capacity(cases.len());
155
156    for case in cases {
157        let mut exp = Experiment::new(case.domain.clone(), &case.function);
158        for (k, v) in &case.params {
159            exp = exp.param(k, v.clone());
160        }
161
162        let vr = match runner.run(&exp) {
163            Ok(RunOutput::Scalar(v)) => {
164                let rel_err = if case.expected == 0.0 {
165                    v.abs()
166                } else {
167                    ((v - case.expected) / case.expected).abs()
168                };
169                ValidationResult {
170                    name: case.name.clone(),
171                    passed: rel_err <= case.tolerance,
172                    computed: v,
173                    expected: case.expected,
174                    relative_error: rel_err,
175                    tolerance: case.tolerance,
176                    error_message: None,
177                }
178            }
179            Ok(other) => ValidationResult {
180                name: case.name.clone(),
181                passed: false,
182                computed: f64::NAN,
183                expected: case.expected,
184                relative_error: f64::INFINITY,
185                tolerance: case.tolerance,
186                error_message: Some(format!("expected Scalar, got {:?}", other)),
187            },
188            Err(e) => ValidationResult {
189                name: case.name.clone(),
190                passed: false,
191                computed: f64::NAN,
192                expected: case.expected,
193                relative_error: f64::INFINITY,
194                tolerance: case.tolerance,
195                error_message: Some(format!("{e}")),
196            },
197        };
198        results.push(vr);
199    }
200
201    ValidationReport { results }
202}
203
204/// Checks that a function does not return `NaN` for the given parameters.
205pub fn check_nan_safety(domain: DomainType, function: &str, params: Vec<(&str, f64)>) -> bool {
206    let runner = ExperimentRunner::new();
207    let mut exp = Experiment::new(domain, function);
208    for (k, v) in params {
209        exp = exp.param(k, ParameterValue::Scalar(v));
210    }
211    match runner.run(&exp) {
212        Ok(RunOutput::Scalar(v)) => !v.is_nan(),
213        _ => true,
214    }
215}
216
217/// Checks the monotonicity of a function over a set of values.
218pub fn check_monotonicity(
219    domain: DomainType,
220    function: &str,
221    base_params: Vec<(&str, f64)>,
222    vary_param: &str,
223    values: &[f64],
224    increasing: bool,
225) -> bool {
226    let runner = ExperimentRunner::new();
227    let mut prev: Option<f64> = None;
228
229    for &v in values {
230        let mut exp = Experiment::new(domain.clone(), function);
231        for &(k, val) in &base_params {
232            if k == vary_param {
233                exp = exp.param(k, ParameterValue::Scalar(v));
234            } else {
235                exp = exp.param(k, ParameterValue::Scalar(val));
236            }
237        }
238        if let Ok(RunOutput::Scalar(out)) = runner.run(&exp) {
239            if let Some(p) = prev {
240                if increasing && out < p {
241                    return false;
242                }
243                if !increasing && out > p {
244                    return false;
245                }
246            }
247            prev = Some(out);
248        }
249    }
250    true
251}
252
253/// Exports the report in LaTeX tabular format.
254pub fn report_to_latex(report: &ValidationReport) -> String {
255    let mut out =
256        String::from("\\begin{table}[h]\n\\centering\n\\begin{tabular}{|l|r|r|r|c|}\n\\hline\n");
257    out.push_str("Test & Computed & Expected & Rel. Error & Pass \\\\\n\\hline\n");
258    for r in &report.results {
259        let status = if r.passed { "\\checkmark" } else { "\\times" };
260        out.push_str(&format!(
261            "{} & {:.6e} & {:.6e} & {:.2e} & ${status}$ \\\\\n",
262            r.name.replace('_', "\\_"),
263            r.computed,
264            r.expected,
265            r.relative_error,
266        ));
267    }
268    out.push_str("\\hline\n\\end{tabular}\n");
269    out.push_str(&format!(
270        "\\caption{{Validation: {}/{} passed}}\n",
271        report.passed_count(),
272        report.total()
273    ));
274    out.push_str("\\end{table}\n");
275    out
276}
277
278/// Exports the report in TSV format.
279pub fn report_to_tsv(report: &ValidationReport) -> String {
280    let mut out = String::from("name\tcomputed\texpected\trelative_error\ttolerance\tpassed\n");
281    for r in &report.results {
282        out.push_str(&format!(
283            "{}\t{:.10e}\t{:.10e}\t{:.6e}\t{:.0e}\t{}\n",
284            r.name, r.computed, r.expected, r.relative_error, r.tolerance, r.passed,
285        ));
286    }
287    out
288}
289
290/// Blocking thresholds for the validation pipeline.
291#[derive(Debug, Clone)]
292pub struct ValidationThresholds {
293    /// Maximum number of allowed failures.
294    pub max_failures: usize,
295    /// Maximum allowed relative error.
296    pub max_relative_error: f64,
297}
298
299impl Default for ValidationThresholds {
300    fn default() -> Self {
301        Self {
302            max_failures: 0,
303            max_relative_error: 1e-6,
304        }
305    }
306}
307
308/// Parameterized monotonicity check.
309#[derive(Debug, Clone)]
310pub struct MonotonicityCheck {
311    /// Descriptive label.
312    pub label: String,
313    /// Scientific domain.
314    pub domain: DomainType,
315    /// Function name.
316    pub function: String,
317    /// Base parameters (excluding the varied parameter).
318    pub base_params: Vec<(String, f64)>,
319    /// Name of the parameter to vary.
320    pub vary_param: String,
321    /// Successive values for the varied parameter.
322    pub values: Vec<f64>,
323    /// `true` if the function should be increasing, `false` if decreasing.
324    pub increasing: bool,
325}
326
327impl MonotonicityCheck {
328    /// Creates a monotonicity check.
329    pub fn new(
330        label: &str,
331        domain: DomainType,
332        function: &str,
333        base_params: Vec<(&str, f64)>,
334        vary_param: &str,
335        values: Vec<f64>,
336        increasing: bool,
337    ) -> Self {
338        Self {
339            label: label.into(),
340            domain,
341            function: function.into(),
342            base_params: base_params
343                .into_iter()
344                .map(|(k, v)| (k.to_string(), v))
345                .collect(),
346            vary_param: vary_param.into(),
347            values,
348            increasing,
349        }
350    }
351}
352
353/// NaN safety check for a function with given parameters.
354#[derive(Debug, Clone)]
355pub struct NanSafetyCheck {
356    /// Descriptive label.
357    pub label: String,
358    /// Scientific domain.
359    pub domain: DomainType,
360    /// Function name.
361    pub function: String,
362    /// Input parameters.
363    pub params: Vec<(String, f64)>,
364}
365
366impl NanSafetyCheck {
367    /// Creates a NaN safety check.
368    pub fn new(label: &str, domain: DomainType, function: &str, params: Vec<(&str, f64)>) -> Self {
369        Self {
370            label: label.into(),
371            domain,
372            function: function.into(),
373            params: params
374                .into_iter()
375                .map(|(k, v)| (k.to_string(), v))
376                .collect(),
377        }
378    }
379}
380
381/// Result of a monotonicity check.
382#[derive(Debug, Clone)]
383pub struct MonotonicityResult {
384    /// Check label.
385    pub label: String,
386    /// `true` if monotonicity is satisfied.
387    pub passed: bool,
388}
389
390/// Result of a NaN safety check.
391#[derive(Debug, Clone)]
392pub struct NanSafetyResult {
393    /// Check label.
394    pub label: String,
395    /// `true` if no NaN was produced.
396    pub passed: bool,
397}
398
399/// Overall outcome of the validation pipeline.
400#[derive(Debug, Clone)]
401pub struct PipelineOutcome {
402    /// `true` if all checks passed.
403    pub passed: bool,
404    /// Validation case report.
405    pub report: ValidationReport,
406    /// `true` if the failure count exceeds the threshold.
407    pub blocked_by_failures: bool,
408    /// `true` if the maximum relative error exceeds the threshold.
409    pub blocked_by_error: bool,
410    /// Worst observed relative error.
411    pub worst_relative_error: f64,
412    /// Monotonicity check results.
413    pub monotonicity_results: Vec<MonotonicityResult>,
414    /// NaN safety check results.
415    pub nan_safety_results: Vec<NanSafetyResult>,
416    /// `true` if all monotonicity checks passed.
417    pub monotonicity_passed: bool,
418    /// `true` if all NaN safety checks passed.
419    pub nan_safety_passed: bool,
420}
421
422/// Validation pipeline orchestrating cases, monotonicity, and NaN safety.
423pub struct ValidationPipeline {
424    cases: Vec<ValidationCase>,
425    thresholds: ValidationThresholds,
426    monotonicity_checks: Vec<MonotonicityCheck>,
427    nan_checks: Vec<NanSafetyCheck>,
428}
429
430impl ValidationPipeline {
431    /// Creates an empty pipeline with the specified thresholds.
432    pub fn new(thresholds: ValidationThresholds) -> Self {
433        Self {
434            cases: Vec::new(),
435            thresholds,
436            monotonicity_checks: Vec::new(),
437            nan_checks: Vec::new(),
438        }
439    }
440
441    /// Adds a validation case.
442    pub fn add_case(mut self, case: ValidationCase) -> Self {
443        self.cases.push(case);
444        self
445    }
446
447    /// Adds multiple validation cases.
448    pub fn add_cases(mut self, cases: Vec<ValidationCase>) -> Self {
449        self.cases.extend(cases);
450        self
451    }
452
453    /// Adds a monotonicity check.
454    pub fn add_monotonicity(mut self, check: MonotonicityCheck) -> Self {
455        self.monotonicity_checks.push(check);
456        self
457    }
458
459    /// Adds a NaN safety check.
460    pub fn add_nan_check(mut self, check: NanSafetyCheck) -> Self {
461        self.nan_checks.push(check);
462        self
463    }
464
465    /// Loads the default validation cases.
466    pub fn with_default_cases(self) -> Self {
467        self.add_cases(default_cases())
468    }
469
470    /// Loads the default monotonicity checks.
471    pub fn with_default_monotonicity(self) -> Self {
472        let checks = default_monotonicity_checks();
473        let mut s = self;
474        for c in checks {
475            s = s.add_monotonicity(c);
476        }
477        s
478    }
479
480    /// Loads the default NaN safety checks.
481    pub fn with_default_nan_safety(self) -> Self {
482        let checks = default_nan_safety_checks();
483        let mut s = self;
484        for c in checks {
485            s = s.add_nan_check(c);
486        }
487        s
488    }
489
490    /// Creates a full pipeline with all default cases and checks.
491    pub fn full_default(thresholds: ValidationThresholds) -> Self {
492        Self::new(thresholds)
493            .with_default_cases()
494            .with_default_monotonicity()
495            .with_default_nan_safety()
496    }
497
498    /// Runs the full pipeline and returns the overall outcome.
499    pub fn run(&self) -> PipelineOutcome {
500        let report = run_validation(&self.cases);
501
502        let worst_relative_error = report
503            .results
504            .iter()
505            .map(|r| r.relative_error)
506            .filter(|e| e.is_finite())
507            .fold(0.0_f64, f64::max);
508
509        let blocked_by_failures = report.failed_count() > self.thresholds.max_failures;
510        let blocked_by_error = worst_relative_error > self.thresholds.max_relative_error;
511
512        let monotonicity_results: Vec<MonotonicityResult> = self
513            .monotonicity_checks
514            .iter()
515            .map(|mc| {
516                let ok = check_monotonicity(
517                    mc.domain.clone(),
518                    &mc.function,
519                    mc.base_params
520                        .iter()
521                        .map(|(k, v)| (k.as_str(), *v))
522                        .collect(),
523                    &mc.vary_param,
524                    &mc.values,
525                    mc.increasing,
526                );
527                MonotonicityResult {
528                    label: mc.label.clone(),
529                    passed: ok,
530                }
531            })
532            .collect();
533
534        let nan_safety_results: Vec<NanSafetyResult> = self
535            .nan_checks
536            .iter()
537            .map(|nc| {
538                let ok = check_nan_safety(
539                    nc.domain.clone(),
540                    &nc.function,
541                    nc.params.iter().map(|(k, v)| (k.as_str(), *v)).collect(),
542                );
543                NanSafetyResult {
544                    label: nc.label.clone(),
545                    passed: ok,
546                }
547            })
548            .collect();
549
550        let monotonicity_passed = monotonicity_results.iter().all(|r| r.passed);
551        let nan_safety_passed = nan_safety_results.iter().all(|r| r.passed);
552        let passed =
553            !blocked_by_failures && !blocked_by_error && monotonicity_passed && nan_safety_passed;
554
555        PipelineOutcome {
556            passed,
557            report,
558            blocked_by_failures,
559            blocked_by_error,
560            worst_relative_error,
561            monotonicity_results,
562            nan_safety_results,
563            monotonicity_passed,
564            nan_safety_passed,
565        }
566    }
567
568    /// Number of registered validation cases.
569    pub fn case_count(&self) -> usize {
570        self.cases.len()
571    }
572
573    /// Number of registered monotonicity checks.
574    pub fn monotonicity_count(&self) -> usize {
575        self.monotonicity_checks.len()
576    }
577
578    /// Number of registered NaN checks.
579    pub fn nan_check_count(&self) -> usize {
580        self.nan_checks.len()
581    }
582}
583
584/// Returns the default validation cases covering all domains.
585pub fn default_cases() -> Vec<ValidationCase> {
586    let cases: [ValidationCase; _] = [
587        #[cfg(feature = "physics")]
588        ValidationCase::new(
589            "physics_carnot_efficiency",
590            DomainType::Physics,
591            "carnot_efficiency",
592            vec![("t_hot", 600.0), ("t_cold", 300.0)],
593            0.5,
594            1e-12,
595            "Carnot theorem",
596        ),
597        #[cfg(feature = "chemistry")]
598        ValidationCase::new(
599            "chemistry_strong_acid_ph",
600            DomainType::Chemistry,
601            "ph_strong_acid",
602            vec![("concentration", 0.01)],
603            2.0,
604            1e-12,
605            "pH = -log10(c)",
606        ),
607        #[cfg(feature = "biology")]
608        ValidationCase::new(
609            "biology_michaelis_menten",
610            DomainType::Biology,
611            "michaelis_menten",
612            vec![("s", 10.0), ("vmax", 100.0), ("km", 5.0)],
613            100.0 * 10.0 / 15.0,
614            1e-12,
615            "Michaelis-Menten equation",
616        ),
617        #[cfg(feature = "astronomy")]
618        ValidationCase::new(
619            "astronomy_escape_velocity_earth",
620            DomainType::Astronomy,
621            "escape_velocity",
622            vec![("mu", 3.986e14), ("r", 6.371e6)],
623            11_186.0,
624            2e-2,
625            "Earth escape velocity reference",
626        ),
627        #[cfg(feature = "geology")]
628        ValidationCase::new(
629            "geology_half_life",
630            DomainType::Geology,
631            "half_life",
632            vec![("lambda", std::f64::consts::LN_2 / 5730.0)],
633            5730.0,
634            1e-12,
635            "Half-life definition",
636        ),
637        #[cfg(feature = "meteorology")]
638        ValidationCase::new(
639            "meteorology_relative_humidity",
640            DomainType::Meteorology,
641            "relative_humidity",
642            vec![("e", 10.0), ("es", 20.0)],
643            50.0,
644            1e-12,
645            "Relative humidity percentage",
646        ),
647        #[cfg(feature = "maths")]
648        ValidationCase::new(
649            "maths_uniform_cdf",
650            DomainType::Maths,
651            "prob_uniform_cdf",
652            vec![("x", 0.5), ("a", 0.0), ("b", 1.0)],
653            0.5,
654            1e-12,
655            "Uniform CDF on [0,1]",
656        ),
657        #[cfg(feature = "cross_domain")]
658        ValidationCase::new(
659            "astrophysics_schwarzschild_radius_sun",
660            DomainType::Astrophysics,
661            "schwarzschild_radius",
662            vec![("mass", 1.989e30)],
663            2.0 * 6.674_30e-11 * 1.989e30 / (299_792_458.0 * 299_792_458.0),
664            1e-6,
665            "r_s = 2GM/c^2",
666        ),
667        #[cfg(feature = "cross_domain")]
668        ValidationCase::new(
669            "biochemistry_gibbs_free_energy",
670            DomainType::Biochemistry,
671            "gibbs_free_energy",
672            vec![
673                ("delta_h", -100_000.0),
674                ("delta_s", -200.0),
675                ("temperature", 298.15),
676            ],
677            -100_000.0 + 200.0 * 298.15,
678            1e-12,
679            "G = H - TS",
680        ),
681        #[cfg(feature = "cross_domain")]
682        ValidationCase::new(
683            "geochemistry_partition_coefficient",
684            DomainType::Geochemistry,
685            "partition_coefficient",
686            vec![("c_solid", 50.0), ("c_liquid", 10.0)],
687            5.0,
688            1e-12,
689            "Kd = Cs/Cl",
690        ),
691        #[cfg(feature = "cross_domain")]
692        ValidationCase::new(
693            "atmospheric_chemistry_photolysis_rate",
694            DomainType::AtmosphericChemistry,
695            "photolysis_rate",
696            vec![
697                ("cross_section", 1e-20),
698                ("quantum_yield", 0.5),
699                ("actinic_flux", 1e15),
700            ],
701            1e-20 * 0.5 * 1e15,
702            1e-12,
703            "J = sigma * phi * F",
704        ),
705        #[cfg(feature = "cross_domain")]
706        ValidationCase::new(
707            "atmospheric_physics_stefan_boltzmann",
708            DomainType::AtmosphericPhysics,
709            "stefan_boltzmann_flux",
710            vec![("temperature", 255.0)],
711            5.670_374_419e-8 * 255.0_f64.powi(4),
712            1e-6,
713            "F = sigma * T^4",
714        ),
715        #[cfg(feature = "cross_domain")]
716        ValidationCase::new(
717            "planetary_geology_impact_energy",
718            DomainType::PlanetaryGeology,
719            "impact_energy",
720            vec![("projectile_mass", 1e6), ("impact_velocity", 2e4)],
721            0.5 * 1e6 * 2e4 * 2e4,
722            1e-12,
723            "KE = 0.5 * m * v^2",
724        ),
725        #[cfg(feature = "cross_domain")]
726        ValidationCase::new(
727            "biomathematics_logistic_growth_zero_at_capacity",
728            DomainType::Biomathematics,
729            "logistic_growth_rate",
730            vec![
731                ("r", 0.5),
732                ("carrying_capacity", 1000.0),
733                ("population", 1000.0),
734            ],
735            0.0,
736            1e-12,
737            "dN/dt = 0 when N = K",
738        ),
739        #[cfg(feature = "cross_domain")]
740        ValidationCase::new(
741            "mathematical_physics_de_broglie",
742            DomainType::MathematicalPhysics,
743            "de_broglie_wavelength",
744            vec![("momentum", 1e-24)],
745            6.626_070_15e-34 / 1e-24,
746            1e-6,
747            "lambda = h/p",
748        ),
749        #[cfg(feature = "cross_domain")]
750        ValidationCase::new(
751            "biophysics_stokes_drag",
752            DomainType::Biophysics,
753            "stokes_drag_force",
754            vec![("viscosity", 1e-3), ("radius", 1e-6), ("velocity", 1e-4)],
755            6.0 * std::f64::consts::PI * 1e-3 * 1e-6 * 1e-4,
756            1e-6,
757            "F = 6*pi*eta*r*v",
758        ),
759        #[cfg(feature = "cross_domain")]
760        ValidationCase::new(
761            "geophysics_bouguer_anomaly_zero_elevation",
762            DomainType::Geophysics,
763            "bouguer_anomaly",
764            vec![
765                ("observed_gravity", 9.81),
766                ("reference_gravity", 9.80),
767                ("elevation", 0.0),
768                ("slab_density", 2670.0),
769            ],
770            9.81 - 9.80,
771            1e-6,
772            "At zero elevation: anomaly = g_obs - g_ref",
773        ),
774        #[cfg(feature = "cross_domain")]
775        ValidationCase::new(
776            "astrochemistry_freefall_time",
777            DomainType::Astrochemistry,
778            "freefall_time",
779            vec![("number_density", 1e4), ("mean_molecular_weight", 2.33)],
780            (3.0 * std::f64::consts::PI
781                / (32.0 * 6.674_30e-11 * 1e4 * 1e6 * 2.33 * 1.672_621_9e-27))
782                .sqrt(),
783            5e-2,
784            "t_ff = sqrt(3*pi/(32*G*rho))",
785        ),
786        #[cfg(feature = "cross_domain")]
787        ValidationCase::new(
788            "astrobiology_habitable_zone_sun",
789            DomainType::Astrobiology,
790            "habitable_zone_inner",
791            vec![("luminosity", 3.828e26)],
792            (3.828e26 / (4.0 * std::f64::consts::PI * 1.0e3)).sqrt()
793                * (3.828e26 / (16.0 * std::f64::consts::PI * 5.670_374_419e-8 * 373.0_f64.powi(4)))
794                    .sqrt()
795                    .recip()
796                * (3.828e26 / (4.0 * std::f64::consts::PI * 1.0e3)).sqrt(),
797            5e-1,
798            "Inner edge ~0.95 AU for Sun-like star",
799        ),
800    ];
801    cases.into_iter().collect()
802}
803
804/// Returns the default monotonicity checks.
805pub fn default_monotonicity_checks() -> Vec<MonotonicityCheck> {
806    let checks: [MonotonicityCheck; _] = [
807        #[cfg(feature = "physics")]
808        MonotonicityCheck::new(
809            "gamma_increases_with_velocity",
810            DomainType::Physics,
811            "lorentz_gamma",
812            vec![],
813            "v",
814            vec![0.0, 1e7, 5e7, 1e8, 2e8, 2.5e8, 2.9e8],
815            true,
816        ),
817        #[cfg(feature = "chemistry")]
818        MonotonicityCheck::new(
819            "ph_decreases_with_concentration",
820            DomainType::Chemistry,
821            "ph_strong_acid",
822            vec![],
823            "concentration",
824            vec![1e-6, 1e-5, 1e-4, 1e-3, 1e-2, 0.1, 1.0],
825            false,
826        ),
827        #[cfg(feature = "biology")]
828        MonotonicityCheck::new(
829            "michaelis_menten_increases_with_substrate",
830            DomainType::Biology,
831            "michaelis_menten",
832            vec![("vmax", 100.0), ("km", 5.0)],
833            "s",
834            vec![0.1, 0.5, 1.0, 5.0, 10.0, 50.0, 100.0],
835            true,
836        ),
837        #[cfg(feature = "physics")]
838        MonotonicityCheck::new(
839            "carnot_efficiency_increases_with_t_hot",
840            DomainType::Physics,
841            "carnot_efficiency",
842            vec![("t_cold", 300.0)],
843            "t_hot",
844            vec![301.0, 400.0, 500.0, 1000.0, 2000.0, 5000.0],
845            true,
846        ),
847        #[cfg(feature = "astronomy")]
848        MonotonicityCheck::new(
849            "escape_velocity_decreases_with_radius",
850            DomainType::Astronomy,
851            "escape_velocity",
852            vec![("mu", 3.986e14)],
853            "r",
854            vec![6.371e6, 1e7, 2e7, 5e7, 1e8],
855            false,
856        ),
857    ];
858    checks.into_iter().collect()
859}
860
861/// Returns the default NaN safety checks.
862pub fn default_nan_safety_checks() -> Vec<NanSafetyCheck> {
863    let checks: [NanSafetyCheck; _] = [
864        #[cfg(feature = "physics")]
865        NanSafetyCheck::new(
866            "carnot_zero_temps",
867            DomainType::Physics,
868            "carnot_efficiency",
869            vec![("t_hot", 300.0), ("t_cold", 0.0)],
870        ),
871        #[cfg(feature = "biology")]
872        NanSafetyCheck::new(
873            "michaelis_menten_zero_substrate",
874            DomainType::Biology,
875            "michaelis_menten",
876            vec![("s", 0.0), ("vmax", 100.0), ("km", 5.0)],
877        ),
878        #[cfg(feature = "meteorology")]
879        NanSafetyCheck::new(
880            "relative_humidity_zero_es",
881            DomainType::Meteorology,
882            "relative_humidity",
883            vec![("e", 0.0), ("es", 0.0)],
884        ),
885        #[cfg(feature = "geology")]
886        NanSafetyCheck::new(
887            "half_life_zero_lambda",
888            DomainType::Geology,
889            "half_life",
890            vec![("lambda", 0.0)],
891        ),
892        #[cfg(feature = "cross_domain")]
893        NanSafetyCheck::new(
894            "logistic_growth_zero_population",
895            DomainType::Biomathematics,
896            "logistic_growth_rate",
897            vec![
898                ("r", 0.5),
899                ("carrying_capacity", 1000.0),
900                ("population", 0.0),
901            ],
902        ),
903    ];
904    checks.into_iter().collect()
905}