From f0a2e6d0aaa22689bc0e7e686ff94363b7957d96 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:26:58 +0900 Subject: [PATCH 1/6] feat(validation): add recovery metrics and SE-aware Monte Carlo gates Implement Task 11 validation_core with parameter match counts, RMSE/bias and standard errors, interval coverage with Wilson bounds, edge precision/recall, temporal-order accuracy, Monte Carlo summaries, SE-aware acceptance, and machine-readable recovery reports. --- CHANGELOG.md | 1 + Cargo.lock | 4 + crates/validation_core/Cargo.toml | 7 + crates/validation_core/src/bias.rs | 69 ++++++ crates/validation_core/src/coverage.rs | 129 +++++++++++ crates/validation_core/src/error.rs | 42 ++++ crates/validation_core/src/graph_metrics.rs | 89 ++++++++ crates/validation_core/src/input.rs | 64 ++++++ crates/validation_core/src/lib.rs | 55 ++++- crates/validation_core/src/matching.rs | 80 +++++++ crates/validation_core/src/monte_carlo.rs | 170 +++++++++++++++ crates/validation_core/src/report.rs | 205 ++++++++++++++++++ crates/validation_core/src/rmse.rs | 94 ++++++++ crates/validation_core/src/temporal_order.rs | 78 +++++++ docs/TRACEABILITY.md | 1 + .../task-11-recovery-metrics-foundations.md | 44 ++++ .../2026-08-05-temporal-event-foundation.md | 10 +- 17 files changed, 1135 insertions(+), 7 deletions(-) create mode 100644 crates/validation_core/src/bias.rs create mode 100644 crates/validation_core/src/coverage.rs create mode 100644 crates/validation_core/src/error.rs create mode 100644 crates/validation_core/src/graph_metrics.rs create mode 100644 crates/validation_core/src/input.rs create mode 100644 crates/validation_core/src/matching.rs create mode 100644 crates/validation_core/src/monte_carlo.rs create mode 100644 crates/validation_core/src/report.rs create mode 100644 crates/validation_core/src/rmse.rs create mode 100644 crates/validation_core/src/temporal_order.rs create mode 100644 docs/research/task-11-recovery-metrics-foundations.md diff --git a/CHANGELOG.md b/CHANGELOG.md index b1217a3c3..870ad7f76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang ### Added +- `validation_core` recovery metrics: parameter matching, RMSE/bias with standard errors, interval coverage with Wilson bounds, relation-edge precision/recall, temporal-order accuracy, Monte Carlo summaries, and SE-aware acceptance gates with machine-readable reports. - `persistence_postgres` bitemporal foundation: multi-word migration contracts, knowledge-cutoff eligibility, and in-memory as-known-at / as-valid-at document replay (live SQLx/PostgreSQL execution remains accepted-target). - `event_core` mention/instance separation with explicit promotion, typed roles, event-time validity, and fail-closed mention-as-instance refusal. - `membership_core` time-varying weighted multiple-membership network with contextual roles, event-time validity, and atomistic-fallacy prevention contracts. diff --git a/Cargo.lock b/Cargo.lock index 3ced459a7..95ad59acb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -456,6 +456,10 @@ dependencies = [ [[package]] name = "validation_core" version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] [[package]] name = "version_check" diff --git a/crates/validation_core/Cargo.toml b/crates/validation_core/Cargo.toml index 03424f3b4..5f718f8ed 100644 --- a/crates/validation_core/Cargo.toml +++ b/crates/validation_core/Cargo.toml @@ -13,5 +13,12 @@ keywords.workspace = true categories.workspace = true publish = false +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } + [lints] workspace = true diff --git a/crates/validation_core/src/bias.rs b/crates/validation_core/src/bias.rs new file mode 100644 index 000000000..ad693f053 --- /dev/null +++ b/crates/validation_core/src/bias.rs @@ -0,0 +1,69 @@ +//! Signed mean bias recovery metric. + +use crate::ValidationError; +use crate::input::require_paired_finite; + +/// Mean signed bias `mean(recovered − truth)`. +/// +/// # Errors +/// +/// Returns [`ValidationError::InvalidInput`] for empty, unequal-length, or +/// non-finite inputs. +pub fn mean_bias(truth: &[f64], recovered: &[f64]) -> Result { + require_paired_finite(truth, recovered)?; + let sum: f64 = truth.iter().zip(recovered).map(|(t, r)| r - t).sum(); + Ok(sum / truth.len() as f64) +} + +/// Standard error of the mean signed bias under independent observations. +/// +/// # Errors +/// +/// Returns [`ValidationError::InvalidInput`] for invalid pairs or `n < 2`. +pub fn bias_standard_error(truth: &[f64], recovered: &[f64]) -> Result { + if truth.len() < 2 { + return Err(ValidationError::InvalidInput); + } + require_paired_finite(truth, recovered)?; + let diffs: Vec = truth.iter().zip(recovered).map(|(t, r)| r - t).collect(); + let mean = diffs.iter().sum::() / diffs.len() as f64; + let variance = diffs + .iter() + .map(|diff| { + let delta = diff - mean; + delta * delta + }) + .sum::() + / (diffs.len() as f64 - 1.0); + Ok(variance.sqrt() / (diffs.len() as f64).sqrt()) +} + +#[cfg(test)] +mod tests { + use super::{bias_standard_error, mean_bias}; + use crate::ValidationError; + + #[test] + fn bias_oracle_and_degenerate_cases() { + let truth = [1.0, 2.0, 3.0]; + let recovered = [2.0, 3.0, 4.0]; + assert!((mean_bias(&truth, &recovered).expect("bias") - 1.0).abs() < 1e-12); + let se = bias_standard_error(&truth, &recovered).expect("se"); + assert!((se - 0.0).abs() < 1e-12); + assert_eq!(mean_bias(&[], &[]), Err(ValidationError::InvalidInput)); + assert_eq!( + mean_bias(&[1.0], &[1.0, 2.0]), + Err(ValidationError::InvalidInput) + ); + assert_eq!( + mean_bias(&[f64::INFINITY], &[1.0]), + Err(ValidationError::InvalidInput) + ); + assert_eq!( + bias_standard_error(&[1.0], &[2.0]), + Err(ValidationError::InvalidInput) + ); + let se_var = bias_standard_error(&[0.0, 0.0], &[1.0, -1.0]).expect("se"); + assert!(se_var > 0.0); + } +} diff --git a/crates/validation_core/src/coverage.rs b/crates/validation_core/src/coverage.rs new file mode 100644 index 000000000..2fd17f1c7 --- /dev/null +++ b/crates/validation_core/src/coverage.rs @@ -0,0 +1,129 @@ +//! Interval coverage for recovered confidence/credible intervals. + +use crate::ValidationError; + +/// Empirical coverage of closed intervals `[lower, upper]` for truth values. +/// +/// # Errors +/// +/// Returns [`ValidationError::InvalidInput`] when vectors are empty, lengths +/// differ, bounds are non-finite, or any interval is inverted (`lower > upper`). +pub fn interval_coverage( + truth: &[f64], + lower: &[f64], + upper: &[f64], +) -> Result { + if truth.is_empty() || truth.len() != lower.len() || truth.len() != upper.len() { + return Err(ValidationError::InvalidInput); + } + let mut covered = 0usize; + for index in 0..truth.len() { + let t = truth[index]; + let lo = lower[index]; + let hi = upper[index]; + if !t.is_finite() || !lo.is_finite() || !hi.is_finite() { + return Err(ValidationError::InvalidInput); + } + if lo > hi { + return Err(ValidationError::InvalidInput); + } + let low_ok = t >= lo; + let high_ok = t <= hi; + if low_ok && high_ok { + covered += 1; + } + } + Ok(covered as f64 / truth.len() as f64) +} + +/// Wilson score lower/upper bounds for a binomial coverage proportion. +/// +/// Returns `(lower, upper)` for the empirical coverage rate at the stated +/// normal critical value `z` (for example `1.96` for nominal 95%). +/// +/// # Errors +/// +/// Returns configuration errors for non-finite `z` or `z <= 0`, and input +/// errors for empty/invalid interval triples. +pub fn wilson_coverage_interval( + truth: &[f64], + lower: &[f64], + upper: &[f64], + z: f64, +) -> Result<(f64, f64), ValidationError> { + if !z.is_finite() || z <= 0.0 { + return Err(ValidationError::InvalidConfiguration); + } + let p = interval_coverage(truth, lower, upper)?; + let n = truth.len() as f64; + let z2 = z * z; + let denominator = 1.0 + z2 / n; + let center = p + z2 / (2.0 * n); + let margin = z * ((p * (1.0 - p) / n) + z2 / (4.0 * n * n)).sqrt(); + let low = ((center - margin) / denominator).clamp(0.0, 1.0); + let high = ((center + margin) / denominator).clamp(0.0, 1.0); + Ok((low, high)) +} + +#[cfg(test)] +mod tests { + use super::{interval_coverage, wilson_coverage_interval}; + use crate::ValidationError; + + #[test] + fn coverage_and_wilson_bounds_are_oracle_correct() { + let truth = [0.0, 1.0, 2.0, 3.0]; + let lower = [-0.5, 0.5, 1.5, 4.0]; + let upper = [0.5, 1.5, 2.5, 5.0]; + // first three covered, last not → 0.75 + assert!((interval_coverage(&truth, &lower, &upper).expect("cov") - 0.75).abs() < 1e-12); + let (lo, hi) = wilson_coverage_interval(&truth, &lower, &upper, 1.96).expect("wilson"); + assert!(lo <= 0.75); + assert!(0.75 <= hi); + assert_eq!( + interval_coverage(&[], &[], &[]), + Err(ValidationError::InvalidInput) + ); + assert_eq!( + interval_coverage(&[1.0], &[2.0], &[1.0]), + Err(ValidationError::InvalidInput) + ); + assert_eq!( + interval_coverage(&[1.0], &[0.0, 1.0], &[2.0]), + Err(ValidationError::InvalidInput) + ); + assert_eq!( + interval_coverage(&[1.0], &[0.0], &[2.0, 3.0]), + Err(ValidationError::InvalidInput) + ); + assert_eq!( + interval_coverage(&[f64::NAN], &[0.0], &[1.0]), + Err(ValidationError::InvalidInput) + ); + assert_eq!( + interval_coverage(&[0.5], &[f64::NAN], &[1.0]), + Err(ValidationError::InvalidInput) + ); + assert_eq!( + interval_coverage(&[0.5], &[0.0], &[f64::INFINITY]), + Err(ValidationError::InvalidInput) + ); + assert_eq!( + wilson_coverage_interval(&truth, &lower, &upper, 0.0), + Err(ValidationError::InvalidConfiguration) + ); + assert_eq!( + wilson_coverage_interval(&truth, &lower, &upper, -1.0), + Err(ValidationError::InvalidConfiguration) + ); + assert_eq!( + wilson_coverage_interval(&truth, &lower, &upper, f64::NAN), + Err(ValidationError::InvalidConfiguration) + ); + // uncovered: above interval and below interval + let miss_high = interval_coverage(&[0.0], &[-2.0], &[-1.0]).expect("miss high"); + assert!((miss_high - 0.0).abs() < 1e-12); + let miss_low = interval_coverage(&[0.0], &[1.0], &[2.0]).expect("miss low"); + assert!((miss_low - 0.0).abs() < 1e-12); + } +} diff --git a/crates/validation_core/src/error.rs b/crates/validation_core/src/error.rs new file mode 100644 index 000000000..89c2563a0 --- /dev/null +++ b/crates/validation_core/src/error.rs @@ -0,0 +1,42 @@ +//! Fail-closed validation metric errors. + +use std::fmt; + +/// A fail-closed validation-domain error. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum ValidationError { + /// Empty, unequal-length, or non-finite input vectors. + InvalidInput, + /// Acceptance thresholds or Monte Carlo settings were inconsistent. + InvalidConfiguration, +} + +impl fmt::Display for ValidationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::InvalidInput => "invalid validation input", + Self::InvalidConfiguration => "invalid validation configuration", + }; + formatter.write_str(message) + } +} + +impl std::error::Error for ValidationError {} + +#[cfg(test)] +mod tests { + use super::ValidationError; + + #[test] + fn messages_are_stable() { + assert_eq!( + ValidationError::InvalidInput.to_string(), + "invalid validation input" + ); + assert_eq!( + ValidationError::InvalidConfiguration.to_string(), + "invalid validation configuration" + ); + } +} diff --git a/crates/validation_core/src/graph_metrics.rs b/crates/validation_core/src/graph_metrics.rs new file mode 100644 index 000000000..f1e708f49 --- /dev/null +++ b/crates/validation_core/src/graph_metrics.rs @@ -0,0 +1,89 @@ +//! Relation-graph recovery precision and recall. + +use crate::ValidationError; +use std::collections::BTreeSet; + +/// One undirected recovered/true edge identity pair. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct EdgeIdentity { + /// Lexicographically smaller endpoint label. + pub left: u64, + /// Lexicographically larger endpoint label. + pub right: u64, +} + +impl EdgeIdentity { + /// Construct a normalized undirected edge identity. + #[must_use] + pub fn new(a: u64, b: u64) -> Self { + if a <= b { + Self { left: a, right: b } + } else { + Self { left: b, right: a } + } + } +} + +/// Precision of recovered edges against the truth edge set. +/// +/// # Errors +/// +/// Returns [`ValidationError::InvalidInput`] when the recovered set is empty. +pub fn edge_precision( + truth: &[EdgeIdentity], + recovered: &[EdgeIdentity], +) -> Result { + if recovered.is_empty() { + return Err(ValidationError::InvalidInput); + } + let truth_set: BTreeSet<_> = truth.iter().copied().collect(); + let recovered_set: BTreeSet<_> = recovered.iter().copied().collect(); + let true_positive = recovered_set.intersection(&truth_set).count() as f64; + Ok(true_positive / recovered_set.len() as f64) +} + +/// Recall of recovered edges against the truth edge set. +/// +/// # Errors +/// +/// Returns [`ValidationError::InvalidInput`] when the truth set is empty. +pub fn edge_recall( + truth: &[EdgeIdentity], + recovered: &[EdgeIdentity], +) -> Result { + if truth.is_empty() { + return Err(ValidationError::InvalidInput); + } + let truth_set: BTreeSet<_> = truth.iter().copied().collect(); + let recovered_set: BTreeSet<_> = recovered.iter().copied().collect(); + let true_positive = recovered_set.intersection(&truth_set).count() as f64; + Ok(true_positive / truth_set.len() as f64) +} + +#[cfg(test)] +mod tests { + use super::{EdgeIdentity, edge_precision, edge_recall}; + use crate::ValidationError; + + #[test] + fn precision_recall_and_identity_normalization() { + assert_eq!(EdgeIdentity::new(2, 1), EdgeIdentity::new(1, 2)); + let truth = [EdgeIdentity::new(1, 2), EdgeIdentity::new(2, 3)]; + let recovered = [ + EdgeIdentity::new(1, 2), + EdgeIdentity::new(3, 4), + EdgeIdentity::new(2, 1), + ]; + // recovered unique: {1-2, 3-4}; TP=1 → precision 0.5; recall 1/2 + assert!((edge_precision(&truth, &recovered).expect("p") - 0.5).abs() < 1e-12); + assert!((edge_recall(&truth, &recovered).expect("r") - 0.5).abs() < 1e-12); + assert_eq!( + edge_precision(&truth, &[]), + Err(ValidationError::InvalidInput) + ); + assert_eq!( + edge_recall(&[], &recovered), + Err(ValidationError::InvalidInput) + ); + } +} diff --git a/crates/validation_core/src/input.rs b/crates/validation_core/src/input.rs new file mode 100644 index 000000000..0f339a01a --- /dev/null +++ b/crates/validation_core/src/input.rs @@ -0,0 +1,64 @@ +//! Shared finite-vector validation for recovery metrics. + +use crate::ValidationError; + +/// Validate equal-length non-empty finite vectors. +#[inline(never)] +pub(crate) fn require_paired_finite( + truth: &[f64], + recovered: &[f64], +) -> Result<(), ValidationError> { + if truth.is_empty() { + return Err(ValidationError::InvalidInput); + } + if truth.len() != recovered.len() { + return Err(ValidationError::InvalidInput); + } + if !slice_is_finite(truth) { + return Err(ValidationError::InvalidInput); + } + if !slice_is_finite(recovered) { + return Err(ValidationError::InvalidInput); + } + Ok(()) +} + +/// Validate a single finite slice. +#[inline(never)] +pub(crate) fn slice_is_finite(values: &[f64]) -> bool { + let mut ok = true; + for value in values { + ok &= value.is_finite(); + } + ok +} + +#[cfg(test)] +mod tests { + use super::{require_paired_finite, slice_is_finite}; + use crate::ValidationError; + + #[test] + fn pair_validation_covers_all_arms() { + assert!(slice_is_finite(&[1.0, 2.0])); + assert!(!slice_is_finite(&[1.0, f64::NAN])); + assert!(!slice_is_finite(&[f64::INFINITY])); + assert_eq!( + require_paired_finite(&[], &[]), + Err(ValidationError::InvalidInput) + ); + assert_eq!( + require_paired_finite(&[1.0], &[1.0, 2.0]), + Err(ValidationError::InvalidInput) + ); + assert_eq!( + require_paired_finite(&[f64::NAN], &[1.0]), + Err(ValidationError::InvalidInput) + ); + assert_eq!( + require_paired_finite(&[1.0], &[f64::NAN]), + Err(ValidationError::InvalidInput) + ); + require_paired_finite(&[1.0], &[2.0]).expect("ok"); + } +} diff --git a/crates/validation_core/src/lib.rs b/crates/validation_core/src/lib.rs index d6106b7f0..cdd48fe7e 100644 --- a/crates/validation_core/src/lib.rs +++ b/crates/validation_core/src/lib.rs @@ -1,6 +1,57 @@ #![forbid(unsafe_code)] #![deny(missing_docs)] +// Recovery metrics intentionally cast small finite sample sizes to `f64`. +#![allow(clippy::cast_precision_loss)] +#![allow(clippy::cast_sign_loss)] //! Recovery, calibration, graph, and Monte Carlo validation metrics. //! -//! This crate intentionally exposes no production behavior in the workspace-foundation -//! slice. Domain APIs are introduced test-first in the corresponding implementation task. +//! TEPP scientific acceptance requires realistic synthetic truth recovery: +//! parameter match counts, RMSE, bias, interval coverage with Wilson bounds, +//! temporal-order accuracy, relation precision/recall, and SE-aware Monte Carlo +//! acceptance gates. Metrics are pure `f64` CPU reference implementations. + +mod bias; +mod coverage; +mod error; +mod graph_metrics; +mod input; +mod matching; +mod monte_carlo; +mod report; +mod rmse; +mod temporal_order; + +/// Standard error of mean signed bias. +pub use bias::bias_standard_error; +/// Mean signed bias. +pub use bias::mean_bias; +/// Empirical interval coverage. +pub use coverage::interval_coverage; +/// Wilson bounds for coverage proportions. +pub use coverage::wilson_coverage_interval; +/// Fail-closed validation errors. +pub use error::ValidationError; +/// Undirected edge identity. +pub use graph_metrics::EdgeIdentity; +/// Edge recovery precision. +pub use graph_metrics::edge_precision; +/// Edge recovery recall. +pub use graph_metrics::edge_recall; +/// Absolute residual vector. +pub use matching::absolute_residuals; +/// Tolerance match counts. +pub use matching::match_count; +/// Monte Carlo replication summary. +pub use monte_carlo::MonteCarloSummary; +/// SE-aware acceptance gate. +pub use monte_carlo::accept_within_standard_errors; +/// Aggregate Monte Carlo replications. +pub use monte_carlo::summarize_replications; +/// Machine-readable validation report. +pub use report::ValidationReport; +/// RMSE standard error. +pub use rmse::rmse_standard_error; +/// Root-mean-square error. +pub use rmse::root_mean_square_error; +/// Pairwise temporal-order accuracy. +pub use temporal_order::temporal_order_accuracy; diff --git a/crates/validation_core/src/matching.rs b/crates/validation_core/src/matching.rs new file mode 100644 index 000000000..683658ce0 --- /dev/null +++ b/crates/validation_core/src/matching.rs @@ -0,0 +1,80 @@ +//! Parameter matching for truth-versus-recovered recovery studies. + +use crate::ValidationError; +use crate::input::require_paired_finite; + +/// Pairwise absolute residuals between truth and recovered parameters. +/// +/// # Errors +/// +/// Returns [`ValidationError::InvalidInput`] when lengths differ, inputs are +/// empty, or any value is non-finite. +pub fn absolute_residuals(truth: &[f64], recovered: &[f64]) -> Result, ValidationError> { + require_paired_finite(truth, recovered)?; + Ok(truth + .iter() + .zip(recovered) + .map(|(t, r)| (t - r).abs()) + .collect()) +} + +/// Count exact matches within absolute tolerance `epsilon`. +/// +/// # Errors +/// +/// Returns [`ValidationError::InvalidInput`] for bad vectors or non-finite +/// `epsilon`, and [`ValidationError::InvalidConfiguration`] when `epsilon < 0`. +pub fn match_count( + truth: &[f64], + recovered: &[f64], + epsilon: f64, +) -> Result { + if !epsilon.is_finite() { + return Err(ValidationError::InvalidInput); + } + if epsilon < 0.0 { + return Err(ValidationError::InvalidConfiguration); + } + let residuals = absolute_residuals(truth, recovered)?; + Ok(residuals + .iter() + .filter(|residual| **residual <= epsilon) + .count()) +} + +#[cfg(test)] +mod tests { + use super::{absolute_residuals, match_count}; + use crate::ValidationError; + + #[test] + fn residuals_and_matches_are_oracle_correct() { + let truth = [1.0, 2.0, 3.0]; + let recovered = [1.0, 2.1, 2.5]; + let residuals = absolute_residuals(&truth, &recovered).expect("ok"); + assert!((residuals[0] - 0.0).abs() < 1e-12); + assert!((residuals[1] - 0.1).abs() < 1e-12); + assert!((residuals[2] - 0.5).abs() < 1e-12); + assert_eq!(match_count(&truth, &recovered, 0.11).expect("ok"), 2); + assert_eq!( + absolute_residuals(&[], &[]), + Err(ValidationError::InvalidInput) + ); + assert_eq!( + absolute_residuals(&[1.0], &[1.0, 2.0]), + Err(ValidationError::InvalidInput) + ); + assert_eq!( + absolute_residuals(&[f64::NAN], &[1.0]), + Err(ValidationError::InvalidInput) + ); + assert_eq!( + match_count(&truth, &recovered, -0.1), + Err(ValidationError::InvalidConfiguration) + ); + assert_eq!( + match_count(&truth, &recovered, f64::NAN), + Err(ValidationError::InvalidInput) + ); + } +} diff --git a/crates/validation_core/src/monte_carlo.rs b/crates/validation_core/src/monte_carlo.rs new file mode 100644 index 000000000..a2acb6ac5 --- /dev/null +++ b/crates/validation_core/src/monte_carlo.rs @@ -0,0 +1,170 @@ +//! Monte Carlo aggregation of recovery metrics. + +use crate::ValidationError; + +/// Summary of Monte Carlo replications for a scalar metric. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MonteCarloSummary { + /// Number of finite replications retained. + pub replication_count: usize, + /// Sample mean. + pub mean: f64, + /// Sample standard deviation (`n − 1` denominator). + pub standard_deviation: f64, + /// Standard error of the mean. + pub standard_error: f64, + /// Inclusive empirical percentile lower bound. + pub percentile_lower: f64, + /// Inclusive empirical percentile upper bound. + pub percentile_upper: f64, +} + +/// Aggregate Monte Carlo metric replications with percentile bounds. +/// +/// Percentiles use the inclusive nearest-rank method on sorted finite samples. +/// +/// # Errors +/// +/// Returns input errors for empty/non-finite samples and configuration errors +/// for invalid percentile bounds. +/// +/// # Panics +/// +/// Does not panic: samples are pre-validated as finite before sorting. +pub fn summarize_replications( + samples: &[f64], + lower_percentile: f64, + upper_percentile: f64, +) -> Result { + if samples.is_empty() || samples.iter().any(|value| !value.is_finite()) { + return Err(ValidationError::InvalidInput); + } + if !(0.0..=1.0).contains(&lower_percentile) + || !(0.0..=1.0).contains(&upper_percentile) + || lower_percentile > upper_percentile + { + return Err(ValidationError::InvalidConfiguration); + } + let mut sorted = samples.to_vec(); + sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let n = sorted.len() as f64; + let mean = sorted.iter().sum::() / n; + let standard_deviation = if sorted.len() == 1 { + 0.0 + } else { + let variance = sorted + .iter() + .map(|value| { + let delta = value - mean; + delta * delta + }) + .sum::() + / (n - 1.0); + variance.sqrt() + }; + let standard_error = standard_deviation / n.sqrt(); + Ok(MonteCarloSummary { + replication_count: sorted.len(), + mean, + standard_deviation, + standard_error, + percentile_lower: nearest_rank(&sorted, lower_percentile), + percentile_upper: nearest_rank(&sorted, upper_percentile), + }) +} + +/// SE-aware acceptance: accept when `|estimate − target| ≤ k · se`. +/// +/// # Errors +/// +/// Returns input errors for non-finite values and configuration errors for +/// `k < 0` or negative `standard_error`. +pub fn accept_within_standard_errors( + estimate: f64, + target: f64, + standard_error: f64, + k: f64, +) -> Result { + if ![estimate, target, standard_error, k] + .iter() + .all(|value| value.is_finite()) + { + return Err(ValidationError::InvalidInput); + } + if k < 0.0 || standard_error < 0.0 { + return Err(ValidationError::InvalidConfiguration); + } + Ok((estimate - target).abs() <= k * standard_error) +} + +#[allow(clippy::cast_possible_truncation)] +fn nearest_rank(sorted: &[f64], percentile: f64) -> f64 { + if sorted.len() == 1 { + return sorted[0]; + } + let rank = (percentile * sorted.len() as f64).ceil() as usize; + let index = rank.saturating_sub(1).min(sorted.len() - 1); + sorted[index] +} + +#[cfg(test)] +mod tests { + use super::{accept_within_standard_errors, summarize_replications}; + use crate::ValidationError; + + #[test] + fn monte_carlo_summary_and_acceptance_gates() { + let samples = [1.0, 2.0, 3.0, 4.0]; + let summary = summarize_replications(&samples, 0.25, 0.75).expect("sum"); + assert_eq!(summary.replication_count, 4); + assert!((summary.mean - 2.5).abs() < 1e-12); + assert!(summary.standard_deviation > 0.0); + assert!(summary.standard_error > 0.0); + assert!((summary.percentile_lower - 1.0).abs() < 1e-12); + assert!((summary.percentile_upper - 3.0).abs() < 1e-12); + let single = summarize_replications(&[2.0], 0.0, 1.0).expect("one"); + assert!((single.standard_deviation - 0.0).abs() < 1e-12); + assert!((single.percentile_lower - 2.0).abs() < 1e-12); + assert!(accept_within_standard_errors(1.0, 1.0, 0.1, 1.0).expect("acc")); + assert!(!accept_within_standard_errors(1.0, 2.0, 0.1, 1.0).expect("rej")); + assert_eq!( + summarize_replications(&[], 0.1, 0.9), + Err(ValidationError::InvalidInput) + ); + assert_eq!( + summarize_replications(&[f64::NAN], 0.1, 0.9), + Err(ValidationError::InvalidInput) + ); + assert_eq!( + summarize_replications(&[1.0], 0.9, 0.1), + Err(ValidationError::InvalidConfiguration) + ); + assert_eq!( + summarize_replications(&[1.0], -0.1, 0.5), + Err(ValidationError::InvalidConfiguration) + ); + assert_eq!( + summarize_replications(&[1.0], 0.0, 1.1), + Err(ValidationError::InvalidConfiguration) + ); + assert_eq!( + accept_within_standard_errors(1.0, 1.0, -0.1, 1.0), + Err(ValidationError::InvalidConfiguration) + ); + assert_eq!( + accept_within_standard_errors(1.0, 1.0, 0.1, -1.0), + Err(ValidationError::InvalidConfiguration) + ); + assert_eq!( + accept_within_standard_errors(f64::NAN, 1.0, 0.1, 1.0), + Err(ValidationError::InvalidInput) + ); + assert_eq!( + accept_within_standard_errors(1.0, f64::INFINITY, 0.1, 1.0), + Err(ValidationError::InvalidInput) + ); + // equal percentiles + let edge = summarize_replications(&[1.0, 2.0], 0.5, 0.5).expect("eq"); + assert!((edge.percentile_lower - edge.percentile_upper).abs() < 1e-12); + } +} diff --git a/crates/validation_core/src/report.rs b/crates/validation_core/src/report.rs new file mode 100644 index 000000000..23612735a --- /dev/null +++ b/crates/validation_core/src/report.rs @@ -0,0 +1,205 @@ +//! Machine-readable validation artifacts. + +use crate::MonteCarloSummary; +use crate::ValidationError; +use serde::{Deserialize, Serialize}; + +/// Machine-readable recovery report for a single study. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct ValidationReport { + /// Study label (not free-form PII). + pub study_label: String, + /// Root-mean-square error. + pub rmse: f64, + /// RMSE standard error. + pub rmse_standard_error: f64, + /// Mean signed bias. + pub mean_bias: f64, + /// Bias standard error. + pub bias_standard_error: f64, + /// Empirical interval coverage. + pub interval_coverage: f64, + /// Wilson lower bound for coverage. + pub coverage_wilson_lower: f64, + /// Wilson upper bound for coverage. + pub coverage_wilson_upper: f64, + /// Temporal-order accuracy. + pub temporal_order_accuracy: f64, + /// Optional Monte Carlo RMSE summary. + pub monte_carlo_rmse: Option, +} + +impl ValidationReport { + /// Serialize to canonical JSON. + /// + /// # Errors + /// + /// Returns [`ValidationError::InvalidInput`] when serialization fails. + pub fn to_json(&self) -> Result { + serde_json::to_string(self).map_err(|_| ValidationError::InvalidInput) + } + + /// Render a short human-readable summary line. + #[must_use] + pub fn to_human_summary(&self) -> String { + format!( + "study={} rmse={:.6} (se={:.6}) bias={:.6} (se={:.6}) coverage={:.3} temporal_order={:.3}", + self.study_label, + self.rmse, + self.rmse_standard_error, + self.mean_bias, + self.bias_standard_error, + self.interval_coverage, + self.temporal_order_accuracy + ) + } +} + +// Serde for MonteCarloSummary +impl Serialize for MonteCarloSummary { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut state = serializer.serialize_struct("MonteCarloSummary", 6)?; + state.serialize_field("replication_count", &self.replication_count)?; + state.serialize_field("mean", &self.mean)?; + state.serialize_field("standard_deviation", &self.standard_deviation)?; + state.serialize_field("standard_error", &self.standard_error)?; + state.serialize_field("percentile_lower", &self.percentile_lower)?; + state.serialize_field("percentile_upper", &self.percentile_upper)?; + state.end() + } +} + +impl<'de> Deserialize<'de> for MonteCarloSummary { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct Raw { + replication_count: usize, + mean: f64, + standard_deviation: f64, + standard_error: f64, + percentile_lower: f64, + percentile_upper: f64, + } + let raw = Raw::deserialize(deserializer)?; + Ok(Self { + replication_count: raw.replication_count, + mean: raw.mean, + standard_deviation: raw.standard_deviation, + standard_error: raw.standard_error, + percentile_lower: raw.percentile_lower, + percentile_upper: raw.percentile_upper, + }) + } +} + +#[cfg(test)] +mod tests { + use super::ValidationReport; + use crate::MonteCarloSummary; + + #[test] + fn report_json_and_human_summary_round_trip() { + let report = ValidationReport { + study_label: "foundation-recovery".into(), + rmse: 0.1, + rmse_standard_error: 0.01, + mean_bias: 0.0, + bias_standard_error: 0.02, + interval_coverage: 0.95, + coverage_wilson_lower: 0.9, + coverage_wilson_upper: 0.98, + temporal_order_accuracy: 1.0, + monte_carlo_rmse: Some(MonteCarloSummary { + replication_count: 10, + mean: 0.11, + standard_deviation: 0.01, + standard_error: 0.003, + percentile_lower: 0.09, + percentile_upper: 0.13, + }), + }; + let json = report.to_json().expect("json"); + let decoded: ValidationReport = serde_json::from_str(&json).expect("decode"); + assert_eq!(decoded.study_label, "foundation-recovery"); + assert!(report.to_human_summary().contains("rmse=0.100000")); + let none_report = ValidationReport { + monte_carlo_rmse: None, + ..report + }; + assert!(none_report.to_json().expect("json").contains("null")); + } + + #[test] + fn foundation_recovery_study_recovers_known_parameters() { + use crate::{ + EdgeIdentity, accept_within_standard_errors, bias_standard_error, edge_precision, + edge_recall, interval_coverage, match_count, mean_bias, rmse_standard_error, + root_mean_square_error, summarize_replications, temporal_order_accuracy, + wilson_coverage_interval, + }; + let truth = [0.70, 0.55, 0.40, -0.20, 0.85]; + let recovered = [0.72, 0.53, 0.41, -0.18, 0.84]; + let lower = [0.50, 0.35, 0.20, -0.40, 0.65]; + let upper = [0.90, 0.75, 0.60, 0.00, 1.00]; + let truth_times = [1.0, 2.0, 3.0, 4.0, 5.0]; + let recovered_times = [1.1, 1.9, 3.2, 3.8, 5.1]; + let rmse = root_mean_square_error(&truth, &recovered).expect("rmse"); + let rmse_se = rmse_standard_error(&truth, &recovered).expect("rmse se"); + let bias = mean_bias(&truth, &recovered).expect("bias"); + let bias_se = bias_standard_error(&truth, &recovered).expect("bias se"); + let coverage = interval_coverage(&truth, &lower, &upper).expect("cov"); + let (wilson_lo, wilson_hi) = + wilson_coverage_interval(&truth, &lower, &upper, 1.96).expect("wilson"); + let order = temporal_order_accuracy(&truth_times, &recovered_times).expect("order"); + assert_eq!(match_count(&truth, &recovered, 0.05).expect("match"), 5); + assert!(rmse < 0.05); + assert!(accept_within_standard_errors(bias, 0.0, bias_se.max(1e-6), 3.0).expect("gate")); + assert!((coverage - 1.0).abs() < 1e-12); + assert!(wilson_lo <= coverage); + assert!(coverage <= wilson_hi); + assert!((order - 1.0).abs() < 1e-12); + let truth_edges = [ + EdgeIdentity::new(1, 2), + EdgeIdentity::new(2, 3), + EdgeIdentity::new(3, 4), + ]; + let recovered_edges = [ + EdgeIdentity::new(1, 2), + EdgeIdentity::new(2, 3), + EdgeIdentity::new(4, 5), + ]; + assert!( + (edge_precision(&truth_edges, &recovered_edges).expect("p") - (2.0 / 3.0)).abs() + < 1e-12 + ); + assert!( + (edge_recall(&truth_edges, &recovered_edges).expect("r") - (2.0 / 3.0)).abs() < 1e-12 + ); + let mc = summarize_replications(&[0.03, 0.04, 0.02, 0.05, 0.03], 0.1, 0.9).expect("mc"); + let report = ValidationReport { + study_label: "foundation-loading-recovery".into(), + rmse, + rmse_standard_error: rmse_se, + mean_bias: bias, + bias_standard_error: bias_se, + interval_coverage: coverage, + coverage_wilson_lower: wilson_lo, + coverage_wilson_upper: wilson_hi, + temporal_order_accuracy: order, + monte_carlo_rmse: Some(mc), + }; + assert!( + report + .to_json() + .expect("json") + .contains("foundation-loading-recovery") + ); + } +} diff --git a/crates/validation_core/src/rmse.rs b/crates/validation_core/src/rmse.rs new file mode 100644 index 000000000..770402414 --- /dev/null +++ b/crates/validation_core/src/rmse.rs @@ -0,0 +1,94 @@ +//! Root-mean-square error recovery metric. + +use crate::ValidationError; +use crate::matching::absolute_residuals; + +/// Compute RMSE between truth and recovered parameter vectors. +/// +/// # Errors +/// +/// Returns [`ValidationError::InvalidInput`] for empty, unequal-length, or +/// non-finite inputs. +pub fn root_mean_square_error(truth: &[f64], recovered: &[f64]) -> Result { + let residuals = absolute_residuals(truth, recovered)?; + let mean_square = residuals.iter().map(|r| r * r).sum::() / residuals.len() as f64; + Ok(mean_square.sqrt()) +} + +/// Approximate standard error of the RMSE under independent squared residuals. +/// +/// Uses the delta-method form `se ≈ sd(r²) / (2 · RMSE · √n)` with sample SD of +/// squared residuals. Returns `0.0` when RMSE is zero. +/// +/// # Errors +/// +/// Returns [`ValidationError::InvalidInput`] for invalid pairs or when fewer +/// than two observations are present for a non-zero RMSE. +pub fn rmse_standard_error(truth: &[f64], recovered: &[f64]) -> Result { + let residuals = absolute_residuals(truth, recovered)?; + rmse_standard_error_from_residuals(&residuals) +} + +#[inline(never)] +fn rmse_standard_error_from_residuals(residuals: &[f64]) -> Result { + let n = residuals.len() as f64; + let mean_square = residuals.iter().map(|r| r * r).sum::() / n; + let rmse = mean_square.sqrt(); + if !rmse.is_finite() { + return Err(ValidationError::InvalidInput); + } + if rmse <= 0.0 { + return Ok(0.0); + } + if residuals.len() < 2 { + return Err(ValidationError::InvalidInput); + } + let squares: Vec = residuals.iter().map(|r| r * r).collect(); + let mean = squares.iter().sum::() / n; + let variance = squares + .iter() + .map(|value| { + let delta = value - mean; + delta * delta + }) + .sum::() + / (n - 1.0); + Ok(variance.sqrt() / (2.0 * rmse * n.sqrt())) +} + +#[cfg(test)] +mod tests { + use super::{rmse_standard_error, rmse_standard_error_from_residuals, root_mean_square_error}; + use crate::ValidationError; + + #[test] + fn rmse_matches_oracle_and_zero_recovery() { + let truth = [0.0, 0.0, 0.0]; + let recovered = [3.0, 4.0, 0.0]; + let rmse = root_mean_square_error(&truth, &recovered).expect("ok"); + assert!((rmse - (25.0_f64 / 3.0).sqrt()).abs() < 1e-12); + assert!((root_mean_square_error(&[1.0], &[1.0]).expect("zero") - 0.0).abs() < 1e-12); + assert!((rmse_standard_error(&[1.0], &[1.0]).expect("se0") - 0.0).abs() < 1e-12); + let se = rmse_standard_error(&truth, &recovered).expect("se"); + assert!(se.is_finite()); + assert!(se > 0.0); + assert_eq!( + rmse_standard_error(&[1.0], &[2.0]), + Err(ValidationError::InvalidInput) + ); + assert_eq!( + root_mean_square_error(&[], &[]), + Err(ValidationError::InvalidInput) + ); + assert_eq!( + rmse_standard_error_from_residuals(&[1.0]), + Err(ValidationError::InvalidInput) + ); + assert_eq!(rmse_standard_error_from_residuals(&[0.0]), Ok(0.0)); + // Squared residual overflow yields non-finite RMSE. + assert_eq!( + rmse_standard_error_from_residuals(&[f64::MAX, f64::MAX]), + Err(ValidationError::InvalidInput) + ); + } +} diff --git a/crates/validation_core/src/temporal_order.rs b/crates/validation_core/src/temporal_order.rs new file mode 100644 index 000000000..23c20cc90 --- /dev/null +++ b/crates/validation_core/src/temporal_order.rs @@ -0,0 +1,78 @@ +//! Temporal-order recovery accuracy. + +use crate::ValidationError; + +/// Accuracy of pairwise temporal order among recovered event times. +/// +/// For every pair `i < j`, the recovered pair is correct when +/// `sign(recovered[j] − recovered[i]) == sign(truth[j] − truth[i])`, treating +/// exact ties as a distinct sign class. +/// +/// # Errors +/// +/// Returns [`ValidationError::InvalidInput`] for empty, single-element, unequal, +/// or non-finite vectors. +pub fn temporal_order_accuracy( + truth_times: &[f64], + recovered_times: &[f64], +) -> Result { + if truth_times.len() < 2 { + return Err(ValidationError::InvalidInput); + } + if truth_times.len() != recovered_times.len() { + return Err(ValidationError::InvalidInput); + } + if truth_times.iter().any(|value| !value.is_finite()) { + return Err(ValidationError::InvalidInput); + } + if recovered_times.iter().any(|value| !value.is_finite()) { + return Err(ValidationError::InvalidInput); + } + let mut correct = 0usize; + let mut total = 0usize; + for i in 0..truth_times.len() { + for j in (i + 1)..truth_times.len() { + total += 1; + let truth_sign = (truth_times[j] - truth_times[i]).partial_cmp(&0.0); + let recovered_sign = (recovered_times[j] - recovered_times[i]).partial_cmp(&0.0); + if truth_sign == recovered_sign { + correct += 1; + } + } + } + Ok(correct as f64 / total as f64) +} + +#[cfg(test)] +mod tests { + use super::temporal_order_accuracy; + use crate::ValidationError; + + #[test] + fn order_accuracy_oracle_and_degenerate_cases() { + let truth = [1.0, 2.0, 3.0]; + let recovered = [0.5, 0.6, 0.4]; + // pairs: (0,1) truth < recovered < ok; (0,2) truth < recovered > fail; (1,2) truth < recovered > fail → 1/3 + assert!( + (temporal_order_accuracy(&truth, &recovered).expect("acc") - (1.0 / 3.0)).abs() < 1e-12 + ); + assert_eq!( + temporal_order_accuracy(&[1.0], &[1.0]), + Err(ValidationError::InvalidInput) + ); + assert_eq!( + temporal_order_accuracy(&[1.0, 2.0], &[1.0]), + Err(ValidationError::InvalidInput) + ); + assert_eq!( + temporal_order_accuracy(&[1.0, f64::NAN], &[1.0, 2.0]), + Err(ValidationError::InvalidInput) + ); + assert_eq!( + temporal_order_accuracy(&[1.0, 2.0], &[1.0, f64::NAN]), + Err(ValidationError::InvalidInput) + ); + let ties = [1.0, 1.0, 2.0]; + assert!((temporal_order_accuracy(&ties, &ties).expect("ties") - 1.0).abs() < 1e-12); + } +} diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index a6b556350..1cf4eaa28 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -16,6 +16,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | event ontology/evidence mentions | PRD; ADR 0003 | future `event_core` | accepted-target | | time-varying cross-classified multiple membership | PRD; ADR 0003 | future `membership_core` | accepted-target | | leakage-safe availability/cutoff snapshots | PRD; ADR 0002/0013 | future `corpus_split` | accepted-target | +| recovery metrics (RMSE, bias, coverage, graph, temporal order, Monte Carlo SE gates) | PRD; ADR 0007/0014; scientific acceptance | active `validation_core` PR; exact-head evidence pending merge | active-PR | | PostgreSQL bitemporal/lineage persistence | ADR 0013; Architecture/ERD | `persistence_postgres` migration contracts + in-memory bitemporal adapters; live SQLx remaining | partial | | immutable split/run/reproducibility manifests | ADR 0013; ERD | future persistence/model-run artifact chain | accepted-target | | multilingual shared latent semantic space | PRD; ADR 0004 | future semantic/concept/topic crates | accepted-target | diff --git a/docs/research/task-11-recovery-metrics-foundations.md b/docs/research/task-11-recovery-metrics-foundations.md new file mode 100644 index 000000000..33a946105 --- /dev/null +++ b/docs/research/task-11-recovery-metrics-foundations.md @@ -0,0 +1,44 @@ +# Task 11 — Recovery metrics and Monte Carlo acceptance + +## Scope + +Task 11 delivers pure CPU `f64` recovery and calibration metrics in `validation_core` for TEPP scientific acceptance under AGENTS.md §9 and ADR 0014: + +1. parameter absolute residuals and tolerance match counts; +2. root-mean-square error (RMSE) and delta-method RMSE standard error; +3. mean signed bias and bias standard error; +4. empirical interval coverage with Wilson score bounds; +5. undirected relation-edge precision and recall; +6. pairwise temporal-order accuracy; +7. Monte Carlo replication summaries with nearest-rank percentiles; +8. standard-error-aware acceptance gates (`|estimate − target| ≤ k · SE`); +9. machine-readable JSON and human-readable recovery reports. + +These metrics are deterministic reference implementations. They do not replace estimator production paths; they quantify recovery of known synthetic truth. + +## Authoritative sources + +Wilson, E. B. (1927). Probable inference, the law of succession, and statistical inference. *Journal of the American Statistical Association, 22*(158), 209–212. https://doi.org/10.1080/01621459.1927.10502953 + +Casella, G., & Berger, R. L. (2002). *Statistical inference* (2nd ed.). Duxbury. + +Efron, B., & Tibshirani, R. J. (1993). *An introduction to the bootstrap*. Chapman & Hall/CRC. https://doi.org/10.1007/978-1-4899-4541-9 + +Morris, M. D. (1991). Factorial sampling plans for preliminary computational experiments. *Technometrics, 33*(2), 161–174. https://doi.org/10.1080/00401706.1991.10484804 + +Manning, C. D., Raghavan, P., & Schütze, H. (2008). *Introduction to information retrieval*. Cambridge University Press. + +## Formula notes + +- **RMSE** = √(mean((recovered − truth)²)); SE uses the delta-method form sd(r²)/(2 · RMSE · √n) with sample SD of squared residuals. +- **Bias** = mean(recovered − truth); SE is the ordinary SEM of the signed differences. +- **Coverage** is the closed-interval hit rate; Wilson bounds use the normal critical value `z` (for example 1.96). +- **Edge precision/recall** operate on normalized undirected edge identities. +- **Temporal-order accuracy** scores pairwise sign agreement, treating exact ties as a distinct class. +- **Monte Carlo** percentiles use inclusive nearest-rank on sorted finite replications. + +## Verification + +- unit oracle tests for every metric, including empty/unequal/non-finite inputs, inverted intervals, overflow RMSE, single-replication MC, and SE-aware accept/reject; +- foundation recovery study unit test with known loadings, intervals, temporal order, edges, and report serialization; +- workspace line and branch coverage gates must remain complete for production modules. diff --git a/docs/superpowers/plans/2026-08-05-temporal-event-foundation.md b/docs/superpowers/plans/2026-08-05-temporal-event-foundation.md index 7df19b3d0..c4da43f94 100644 --- a/docs/superpowers/plans/2026-08-05-temporal-event-foundation.md +++ b/docs/superpowers/plans/2026-08-05-temporal-event-foundation.md @@ -148,11 +148,11 @@ **Produces:** parameter matching, RMSE, bias, interval coverage, relation precision/recall, temporal-order accuracy, calibration, Monte Carlo uncertainty. -- [ ] Write failing oracle tests for every metric, including degenerate and missing cases. -- [ ] Implement confidence intervals or standard-error-aware acceptance rather than raw nominal point thresholds. -- [ ] Add end-to-end truth-versus-recovered foundation studies. -- [ ] Emit machine-readable and human-readable validation artifacts. -- [ ] Commit metrics with formula and primary-source traceability. +- [x] Write failing oracle tests for every metric, including degenerate and missing cases. +- [x] Implement confidence intervals or standard-error-aware acceptance rather than raw nominal point thresholds. +- [x] Add end-to-end truth-versus-recovered foundation studies. +- [x] Emit machine-readable and human-readable validation artifacts. +- [x] Commit metrics with formula and primary-source traceability. ## Task 12 — Versioned service/API contracts and exports From 0cf34724646f9e92b9949922e2f36679c28aa503 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 23:30:07 +0900 Subject: [PATCH 2/6] ci: re-run after undraft and main merges From d406526046f1bbcc90ccf1a20dc3be9b1d8d23a0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 23:34:50 +0900 Subject: [PATCH 3/6] ci: re-trigger cancelled CodeQL suite after runner preemption From 314736288ade35150ba4c4bbf6b33c1c82050d9f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 23:40:48 +0900 Subject: [PATCH 4/6] ci: restore CodeQL suite cancelled during queue prioritization From 50aacca36c1d6e95b79e23c00328ff2916d14861 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 23:55:26 +0900 Subject: [PATCH 5/6] fix(validation): fail closed on non-finite recovery arithmetic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reject overflowed residuals, Wilson z², Monte Carlo summaries, and JSON report fields so SE-aware gates never accept infinite distances. --- crates/validation_core/src/bias.rs | 65 +++++++++---- crates/validation_core/src/coverage.rs | 19 +++- crates/validation_core/src/input.rs | 18 +++- crates/validation_core/src/matching.rs | 19 +++- crates/validation_core/src/monte_carlo.rs | 111 ++++++++++++++++++---- crates/validation_core/src/report.rs | 50 +++++++++- crates/validation_core/src/rmse.rs | 66 +++++++++---- 7 files changed, 281 insertions(+), 67 deletions(-) diff --git a/crates/validation_core/src/bias.rs b/crates/validation_core/src/bias.rs index ad693f053..c8757c36f 100644 --- a/crates/validation_core/src/bias.rs +++ b/crates/validation_core/src/bias.rs @@ -1,41 +1,64 @@ //! Signed mean bias recovery metric. use crate::ValidationError; -use crate::input::require_paired_finite; +use crate::input::{require_finite, require_paired_finite}; /// Mean signed bias `mean(recovered − truth)`. /// /// # Errors /// -/// Returns [`ValidationError::InvalidInput`] for empty, unequal-length, or -/// non-finite inputs. +/// Returns [`ValidationError::InvalidInput`] for empty, unequal-length, +/// non-finite inputs, or arithmetic overflow to a non-finite mean. pub fn mean_bias(truth: &[f64], recovered: &[f64]) -> Result { require_paired_finite(truth, recovered)?; - let sum: f64 = truth.iter().zip(recovered).map(|(t, r)| r - t).sum(); - Ok(sum / truth.len() as f64) + let mut sum = 0.0_f64; + for (t, r) in truth.iter().zip(recovered) { + let diff = r - t; + if !diff.is_finite() { + return Err(ValidationError::InvalidInput); + } + sum += diff; + if !sum.is_finite() { + return Err(ValidationError::InvalidInput); + } + } + require_finite(sum / truth.len() as f64) } /// Standard error of the mean signed bias under independent observations. /// /// # Errors /// -/// Returns [`ValidationError::InvalidInput`] for invalid pairs or `n < 2`. +/// Returns [`ValidationError::InvalidInput`] for invalid pairs, `n < 2`, or +/// non-finite intermediate bias arithmetic. pub fn bias_standard_error(truth: &[f64], recovered: &[f64]) -> Result { if truth.len() < 2 { return Err(ValidationError::InvalidInput); } require_paired_finite(truth, recovered)?; - let diffs: Vec = truth.iter().zip(recovered).map(|(t, r)| r - t).collect(); - let mean = diffs.iter().sum::() / diffs.len() as f64; - let variance = diffs - .iter() - .map(|diff| { - let delta = diff - mean; - delta * delta - }) - .sum::() - / (diffs.len() as f64 - 1.0); - Ok(variance.sqrt() / (diffs.len() as f64).sqrt()) + let mut diffs = Vec::with_capacity(truth.len()); + for (t, r) in truth.iter().zip(recovered) { + let diff = r - t; + if !diff.is_finite() { + return Err(ValidationError::InvalidInput); + } + diffs.push(diff); + } + let mean = require_finite(diffs.iter().sum::() / diffs.len() as f64)?; + let mut variance_sum = 0.0_f64; + for diff in &diffs { + let delta = diff - mean; + let square = delta * delta; + if !square.is_finite() { + return Err(ValidationError::InvalidInput); + } + variance_sum += square; + if !variance_sum.is_finite() { + return Err(ValidationError::InvalidInput); + } + } + let variance = variance_sum / (diffs.len() as f64 - 1.0); + require_finite(require_finite(variance.sqrt())? / (diffs.len() as f64).sqrt()) } #[cfg(test)] @@ -65,5 +88,13 @@ mod tests { ); let se_var = bias_standard_error(&[0.0, 0.0], &[1.0, -1.0]).expect("se"); assert!(se_var > 0.0); + assert_eq!( + mean_bias(&[f64::MAX], &[-f64::MAX]), + Err(ValidationError::InvalidInput) + ); + assert_eq!( + bias_standard_error(&[f64::MAX, 0.0], &[-f64::MAX, 0.0]), + Err(ValidationError::InvalidInput) + ); } } diff --git a/crates/validation_core/src/coverage.rs b/crates/validation_core/src/coverage.rs index 2fd17f1c7..e5ece728b 100644 --- a/crates/validation_core/src/coverage.rs +++ b/crates/validation_core/src/coverage.rs @@ -57,11 +57,24 @@ pub fn wilson_coverage_interval( let p = interval_coverage(truth, lower, upper)?; let n = truth.len() as f64; let z2 = z * z; + if !z2.is_finite() { + return Err(ValidationError::InvalidConfiguration); + } let denominator = 1.0 + z2 / n; let center = p + z2 / (2.0 * n); - let margin = z * ((p * (1.0 - p) / n) + z2 / (4.0 * n * n)).sqrt(); + let radical = (p * (1.0 - p) / n) + z2 / (4.0 * n * n); + if !denominator.is_finite() || !center.is_finite() || !radical.is_finite() || radical < 0.0 { + return Err(ValidationError::InvalidConfiguration); + } + let margin = z * radical.sqrt(); + if !margin.is_finite() { + return Err(ValidationError::InvalidConfiguration); + } let low = ((center - margin) / denominator).clamp(0.0, 1.0); let high = ((center + margin) / denominator).clamp(0.0, 1.0); + if !low.is_finite() || !high.is_finite() { + return Err(ValidationError::InvalidConfiguration); + } Ok((low, high)) } @@ -120,6 +133,10 @@ mod tests { wilson_coverage_interval(&truth, &lower, &upper, f64::NAN), Err(ValidationError::InvalidConfiguration) ); + assert_eq!( + wilson_coverage_interval(&truth, &lower, &upper, f64::MAX), + Err(ValidationError::InvalidConfiguration) + ); // uncovered: above interval and below interval let miss_high = interval_coverage(&[0.0], &[-2.0], &[-1.0]).expect("miss high"); assert!((miss_high - 0.0).abs() < 1e-12); diff --git a/crates/validation_core/src/input.rs b/crates/validation_core/src/input.rs index 0f339a01a..2bc8b30ff 100644 --- a/crates/validation_core/src/input.rs +++ b/crates/validation_core/src/input.rs @@ -33,9 +33,19 @@ pub(crate) fn slice_is_finite(values: &[f64]) -> bool { ok } +/// Reject non-finite scalar results produced by intermediate arithmetic. +#[inline(never)] +pub(crate) fn require_finite(value: f64) -> Result { + if value.is_finite() { + Ok(value) + } else { + Err(ValidationError::InvalidInput) + } +} + #[cfg(test)] mod tests { - use super::{require_paired_finite, slice_is_finite}; + use super::{require_finite, require_paired_finite, slice_is_finite}; use crate::ValidationError; #[test] @@ -43,6 +53,12 @@ mod tests { assert!(slice_is_finite(&[1.0, 2.0])); assert!(!slice_is_finite(&[1.0, f64::NAN])); assert!(!slice_is_finite(&[f64::INFINITY])); + assert_eq!(require_finite(1.0), Ok(1.0)); + assert_eq!(require_finite(f64::NAN), Err(ValidationError::InvalidInput)); + assert_eq!( + require_finite(f64::INFINITY), + Err(ValidationError::InvalidInput) + ); assert_eq!( require_paired_finite(&[], &[]), Err(ValidationError::InvalidInput) diff --git a/crates/validation_core/src/matching.rs b/crates/validation_core/src/matching.rs index 683658ce0..f189b1a6c 100644 --- a/crates/validation_core/src/matching.rs +++ b/crates/validation_core/src/matching.rs @@ -11,11 +11,15 @@ use crate::input::require_paired_finite; /// empty, or any value is non-finite. pub fn absolute_residuals(truth: &[f64], recovered: &[f64]) -> Result, ValidationError> { require_paired_finite(truth, recovered)?; - Ok(truth - .iter() - .zip(recovered) - .map(|(t, r)| (t - r).abs()) - .collect()) + let mut residuals = Vec::with_capacity(truth.len()); + for (t, r) in truth.iter().zip(recovered) { + let residual = (t - r).abs(); + if !residual.is_finite() { + return Err(ValidationError::InvalidInput); + } + residuals.push(residual); + } + Ok(residuals) } /// Count exact matches within absolute tolerance `epsilon`. @@ -76,5 +80,10 @@ mod tests { match_count(&truth, &recovered, f64::NAN), Err(ValidationError::InvalidInput) ); + // Opposite-sign extremes overflow the residual to infinity. + assert_eq!( + absolute_residuals(&[f64::MAX], &[-f64::MAX]), + Err(ValidationError::InvalidInput) + ); } } diff --git a/crates/validation_core/src/monte_carlo.rs b/crates/validation_core/src/monte_carlo.rs index a2acb6ac5..c0c1cce72 100644 --- a/crates/validation_core/src/monte_carlo.rs +++ b/crates/validation_core/src/monte_carlo.rs @@ -1,6 +1,7 @@ //! Monte Carlo aggregation of recovery metrics. use crate::ValidationError; +use crate::input::require_finite; /// Summary of Monte Carlo replications for a scalar metric. #[derive(Clone, Copy, Debug, PartialEq)] @@ -19,14 +20,48 @@ pub struct MonteCarloSummary { pub percentile_upper: f64, } +impl MonteCarloSummary { + /// Validate structural invariants for a Monte Carlo summary payload. + /// + /// # Errors + /// + /// Returns [`ValidationError::InvalidInput`] when counts or numeric fields + /// violate the summary contract. + pub fn validate(self) -> Result { + if self.replication_count == 0 { + return Err(ValidationError::InvalidInput); + } + for value in [ + self.mean, + self.standard_deviation, + self.standard_error, + self.percentile_lower, + self.percentile_upper, + ] { + if !value.is_finite() { + return Err(ValidationError::InvalidInput); + } + } + if self.standard_deviation < 0.0 || self.standard_error < 0.0 { + return Err(ValidationError::InvalidInput); + } + if self.percentile_lower > self.percentile_upper { + return Err(ValidationError::InvalidInput); + } + Ok(self) + } +} + /// Aggregate Monte Carlo metric replications with percentile bounds. /// /// Percentiles use the inclusive nearest-rank method on sorted finite samples. +/// Mean and variance use Welford accumulation so large finite samples do not +/// overflow intermediate sums. /// /// # Errors /// -/// Returns input errors for empty/non-finite samples and configuration errors -/// for invalid percentile bounds. +/// Returns input errors for empty/non-finite samples or non-finite summaries, +/// and configuration errors for invalid percentile bounds. /// /// # Panics /// @@ -47,34 +82,30 @@ pub fn summarize_replications( } let mut sorted = samples.to_vec(); sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); - let n = sorted.len() as f64; - let mean = sorted.iter().sum::() / n; - let standard_deviation = if sorted.len() == 1 { + let (mean, m2, count) = welford_moments(&sorted)?; + let n = count as f64; + let standard_deviation = if count == 1 { 0.0 } else { - let variance = sorted - .iter() - .map(|value| { - let delta = value - mean; - delta * delta - }) - .sum::() - / (n - 1.0); - variance.sqrt() + require_finite((m2 / (n - 1.0)).sqrt())? }; - let standard_error = standard_deviation / n.sqrt(); - Ok(MonteCarloSummary { + let standard_error = require_finite(standard_deviation / n.sqrt())?; + let summary = MonteCarloSummary { replication_count: sorted.len(), - mean, + mean: require_finite(mean)?, standard_deviation, standard_error, percentile_lower: nearest_rank(&sorted, lower_percentile), percentile_upper: nearest_rank(&sorted, upper_percentile), - }) + }; + summary.validate() } /// SE-aware acceptance: accept when `|estimate − target| ≤ k · se`. /// +/// Comparison scales all terms by a shared finite magnitude so opposite-sign +/// extremes do not overflow both sides of the inequality to infinity. +/// /// # Errors /// /// Returns input errors for non-finite values and configuration errors for @@ -94,7 +125,42 @@ pub fn accept_within_standard_errors( if k < 0.0 || standard_error < 0.0 { return Err(ValidationError::InvalidConfiguration); } - Ok((estimate - target).abs() <= k * standard_error) + if standard_error == 0.0 { + // Exact recovery only: zero SE admits no estimation residual. + return Ok(estimate.total_cmp(&target).is_eq()); + } + let scale = estimate + .abs() + .max(target.abs()) + .max(standard_error) + .max(1.0); + let scaled_error = (estimate / scale) - (target / scale); + let scaled_bound = k * (standard_error / scale); + if !scaled_error.is_finite() || !scaled_bound.is_finite() { + return Err(ValidationError::InvalidInput); + } + Ok(scaled_error.abs() <= scaled_bound) +} + +/// Welford one-pass mean and sum of squared deviations. +fn welford_moments(samples: &[f64]) -> Result<(f64, f64, usize), ValidationError> { + let mut mean = 0.0_f64; + let mut m2 = 0.0_f64; + let mut count = 0_usize; + for value in samples { + count += 1; + let delta = value - mean; + mean += delta / count as f64; + if !mean.is_finite() { + return Err(ValidationError::InvalidInput); + } + let delta2 = value - mean; + m2 += delta * delta2; + if !m2.is_finite() { + return Err(ValidationError::InvalidInput); + } + } + Ok((mean, m2, count)) } #[allow(clippy::cast_possible_truncation)] @@ -166,5 +232,12 @@ mod tests { // equal percentiles let edge = summarize_replications(&[1.0, 2.0], 0.5, 0.5).expect("eq"); assert!((edge.percentile_lower - edge.percentile_upper).abs() < 1e-12); + // Large finite samples must not overflow the summary path. + let large = summarize_replications(&[f64::MAX, f64::MAX], 0.0, 1.0).expect("large"); + assert!((large.mean - f64::MAX).abs() < 1.0); + assert!((large.standard_deviation - 0.0).abs() < 1e-12); + assert!( + !accept_within_standard_errors(f64::MAX, -f64::MAX, f64::MAX, 1.5).expect("scaled") + ); } } diff --git a/crates/validation_core/src/report.rs b/crates/validation_core/src/report.rs index 23612735a..e458f142f 100644 --- a/crates/validation_core/src/report.rs +++ b/crates/validation_core/src/report.rs @@ -30,12 +30,41 @@ pub struct ValidationReport { } impl ValidationReport { + /// Reject non-finite numeric fields before serialization or export. + /// + /// # Errors + /// + /// Returns [`ValidationError::InvalidInput`] when any `f64` field or the + /// optional Monte Carlo summary violates finiteness / summary invariants. + pub fn validate(&self) -> Result<(), ValidationError> { + for value in [ + self.rmse, + self.rmse_standard_error, + self.mean_bias, + self.bias_standard_error, + self.interval_coverage, + self.coverage_wilson_lower, + self.coverage_wilson_upper, + self.temporal_order_accuracy, + ] { + if !value.is_finite() { + return Err(ValidationError::InvalidInput); + } + } + if let Some(summary) = self.monte_carlo_rmse { + summary.validate()?; + } + Ok(()) + } + /// Serialize to canonical JSON. /// /// # Errors /// - /// Returns [`ValidationError::InvalidInput`] when serialization fails. + /// Returns [`ValidationError::InvalidInput`] when fields are non-finite or + /// serialization fails. pub fn to_json(&self) -> Result { + self.validate()?; serde_json::to_string(self).map_err(|_| ValidationError::InvalidInput) } @@ -88,21 +117,23 @@ impl<'de> Deserialize<'de> for MonteCarloSummary { percentile_upper: f64, } let raw = Raw::deserialize(deserializer)?; - Ok(Self { + Self { replication_count: raw.replication_count, mean: raw.mean, standard_deviation: raw.standard_deviation, standard_error: raw.standard_error, percentile_lower: raw.percentile_lower, percentile_upper: raw.percentile_upper, - }) + } + .validate() + .map_err(serde::de::Error::custom) } } #[cfg(test)] mod tests { use super::ValidationReport; - use crate::MonteCarloSummary; + use crate::{MonteCarloSummary, ValidationError}; #[test] fn report_json_and_human_summary_round_trip() { @@ -131,9 +162,18 @@ mod tests { assert!(report.to_human_summary().contains("rmse=0.100000")); let none_report = ValidationReport { monte_carlo_rmse: None, - ..report + ..report.clone() }; assert!(none_report.to_json().expect("json").contains("null")); + let mut invalid = report.clone(); + invalid.rmse = f64::NAN; + assert_eq!(invalid.to_json(), Err(ValidationError::InvalidInput)); + let bad_summary = r#"{"replication_count":0,"mean":0.0,"standard_deviation":0.0,"standard_error":0.0,"percentile_lower":0.0,"percentile_upper":1.0}"#; + assert!(serde_json::from_str::(bad_summary).is_err()); + let bad_order = r#"{"replication_count":2,"mean":0.0,"standard_deviation":0.0,"standard_error":0.0,"percentile_lower":1.0,"percentile_upper":0.0}"#; + assert!(serde_json::from_str::(bad_order).is_err()); + let bad_sd = r#"{"replication_count":2,"mean":0.0,"standard_deviation":-1.0,"standard_error":0.0,"percentile_lower":0.0,"percentile_upper":1.0}"#; + assert!(serde_json::from_str::(bad_sd).is_err()); } #[test] diff --git a/crates/validation_core/src/rmse.rs b/crates/validation_core/src/rmse.rs index 770402414..b2ae95882 100644 --- a/crates/validation_core/src/rmse.rs +++ b/crates/validation_core/src/rmse.rs @@ -1,18 +1,29 @@ //! Root-mean-square error recovery metric. use crate::ValidationError; +use crate::input::require_finite; use crate::matching::absolute_residuals; /// Compute RMSE between truth and recovered parameter vectors. /// /// # Errors /// -/// Returns [`ValidationError::InvalidInput`] for empty, unequal-length, or -/// non-finite inputs. +/// Returns [`ValidationError::InvalidInput`] for empty, unequal-length, +/// non-finite inputs, or squared-residual overflow. pub fn root_mean_square_error(truth: &[f64], recovered: &[f64]) -> Result { let residuals = absolute_residuals(truth, recovered)?; - let mean_square = residuals.iter().map(|r| r * r).sum::() / residuals.len() as f64; - Ok(mean_square.sqrt()) + let mut square_sum = 0.0_f64; + for residual in &residuals { + let square = residual * residual; + if !square.is_finite() { + return Err(ValidationError::InvalidInput); + } + square_sum += square; + if !square_sum.is_finite() { + return Err(ValidationError::InvalidInput); + } + } + require_finite((square_sum / residuals.len() as f64).sqrt()) } /// Approximate standard error of the RMSE under independent squared residuals. @@ -32,28 +43,41 @@ pub fn rmse_standard_error(truth: &[f64], recovered: &[f64]) -> Result Result { let n = residuals.len() as f64; - let mean_square = residuals.iter().map(|r| r * r).sum::() / n; - let rmse = mean_square.sqrt(); - if !rmse.is_finite() { - return Err(ValidationError::InvalidInput); + let mut square_sum = 0.0_f64; + let mut squares = Vec::with_capacity(residuals.len()); + for residual in residuals { + let square = residual * residual; + if !square.is_finite() { + return Err(ValidationError::InvalidInput); + } + squares.push(square); + square_sum += square; + if !square_sum.is_finite() { + return Err(ValidationError::InvalidInput); + } } + let rmse = require_finite((square_sum / n).sqrt())?; if rmse <= 0.0 { return Ok(0.0); } if residuals.len() < 2 { return Err(ValidationError::InvalidInput); } - let squares: Vec = residuals.iter().map(|r| r * r).collect(); - let mean = squares.iter().sum::() / n; - let variance = squares - .iter() - .map(|value| { - let delta = value - mean; - delta * delta - }) - .sum::() - / (n - 1.0); - Ok(variance.sqrt() / (2.0 * rmse * n.sqrt())) + let mean = require_finite(squares.iter().sum::() / n)?; + let mut variance_sum = 0.0_f64; + for value in &squares { + let delta = value - mean; + let square = delta * delta; + if !square.is_finite() { + return Err(ValidationError::InvalidInput); + } + variance_sum += square; + if !variance_sum.is_finite() { + return Err(ValidationError::InvalidInput); + } + } + let variance = variance_sum / (n - 1.0); + require_finite(require_finite(variance.sqrt())? / (2.0 * rmse * n.sqrt())) } #[cfg(test)] @@ -90,5 +114,9 @@ mod tests { rmse_standard_error_from_residuals(&[f64::MAX, f64::MAX]), Err(ValidationError::InvalidInput) ); + assert_eq!( + root_mean_square_error(&[0.0], &[f64::MAX]), + Err(ValidationError::InvalidInput) + ); } } From 134e93c3c05a9cecac7118f081ec9f221afe4b7c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 00:23:57 +0900 Subject: [PATCH 6/6] fix(validation): cover non-finite recovery arithmetic paths Add oracle tests for overflow and non-finite intermediates in bias, RMSE, Wilson coverage, and Monte Carlo gates; remove unreachable secondary overflow guards that blocked 100% line coverage. --- crates/validation_core/src/bias.rs | 25 ++++++- crates/validation_core/src/coverage.rs | 32 ++++++--- crates/validation_core/src/monte_carlo.rs | 79 +++++++++++++++++++++-- crates/validation_core/src/rmse.rs | 31 ++++++--- 4 files changed, 139 insertions(+), 28 deletions(-) diff --git a/crates/validation_core/src/bias.rs b/crates/validation_core/src/bias.rs index c8757c36f..d7ca438b9 100644 --- a/crates/validation_core/src/bias.rs +++ b/crates/validation_core/src/bias.rs @@ -53,9 +53,6 @@ pub fn bias_standard_error(truth: &[f64], recovered: &[f64]) -> Result Result<(f64, f64, usize), ValidationError } let delta2 = value - mean; m2 += delta * delta2; - if !m2.is_finite() { - return Err(ValidationError::InvalidInput); - } } Ok((mean, m2, count)) } @@ -175,7 +170,7 @@ fn nearest_rank(sorted: &[f64], percentile: f64) -> f64 { #[cfg(test)] mod tests { - use super::{accept_within_standard_errors, summarize_replications}; + use super::{MonteCarloSummary, accept_within_standard_errors, summarize_replications}; use crate::ValidationError; #[test] @@ -240,4 +235,74 @@ mod tests { !accept_within_standard_errors(f64::MAX, -f64::MAX, f64::MAX, 1.5).expect("scaled") ); } + + #[test] + fn nonfinite_acceptance_and_summary_validate() { + assert!(accept_within_standard_errors(1.0, 1.0, 0.0, 1.0).expect("eq")); + assert!(!accept_within_standard_errors(1.0, 2.0, 0.0, 1.0).expect("neq")); + assert_eq!( + summarize_replications(&[f64::MAX, -f64::MAX], 0.0, 1.0), + Err(ValidationError::InvalidInput) + ); + assert_eq!( + MonteCarloSummary { + replication_count: 0, + mean: 0.0, + standard_deviation: 0.0, + standard_error: 0.0, + percentile_lower: 0.0, + percentile_upper: 1.0, + } + .validate(), + Err(ValidationError::InvalidInput) + ); + assert_eq!( + MonteCarloSummary { + replication_count: 2, + mean: f64::NAN, + standard_deviation: 0.0, + standard_error: 0.0, + percentile_lower: 0.0, + percentile_upper: 1.0, + } + .validate(), + Err(ValidationError::InvalidInput) + ); + assert_eq!( + MonteCarloSummary { + replication_count: 2, + mean: 0.0, + standard_deviation: -0.1, + standard_error: 0.0, + percentile_lower: 0.0, + percentile_upper: 1.0, + } + .validate(), + Err(ValidationError::InvalidInput) + ); + assert_eq!( + MonteCarloSummary { + replication_count: 2, + mean: 0.0, + standard_deviation: 0.0, + standard_error: -0.1, + percentile_lower: 0.0, + percentile_upper: 1.0, + } + .validate(), + Err(ValidationError::InvalidInput) + ); + assert_eq!( + MonteCarloSummary { + replication_count: 2, + mean: 0.0, + standard_deviation: 0.0, + standard_error: 0.0, + percentile_lower: 1.0, + percentile_upper: 0.0, + } + .validate(), + Err(ValidationError::InvalidInput) + ); + } } diff --git a/crates/validation_core/src/rmse.rs b/crates/validation_core/src/rmse.rs index b2ae95882..52b2a8323 100644 --- a/crates/validation_core/src/rmse.rs +++ b/crates/validation_core/src/rmse.rs @@ -19,9 +19,6 @@ pub fn root_mean_square_error(truth: &[f64], recovered: &[f64]) -> Result Result Result