-
Notifications
You must be signed in to change notification settings - Fork 0
feat(validation): recovery metrics and SE-aware Monte Carlo gates #19
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
f0a2e6d
feat(validation): add recovery metrics and SE-aware Monte Carlo gates
seonghobae 2d7cad6
merge(main): integrate corpus_split into validation_core branch
seonghobae 3a6f87f
merge(main): resolve docs after tepp_simulation
seonghobae f4a41ed
merge(main): resolve docs after relation_graph
seonghobae 0cf3472
ci: re-run after undraft and main merges
seonghobae d406526
ci: re-trigger cancelled CodeQL suite after runner preemption
seonghobae 3147362
ci: restore CodeQL suite cancelled during queue prioritization
seonghobae 50aacca
fix(validation): fail closed on non-finite recovery arithmetic
seonghobae 134e93c
fix(validation): cover non-finite recovery arithmetic paths
seonghobae File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| //! Signed mean bias recovery metric. | ||
|
|
||
| use crate::ValidationError; | ||
| use crate::input::{require_finite, require_paired_finite}; | ||
|
|
||
| /// Mean signed bias `mean(recovered − truth)`. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// 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<f64, ValidationError> { | ||
| require_paired_finite(truth, recovered)?; | ||
| 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, `n < 2`, or | ||
| /// non-finite intermediate bias arithmetic. | ||
| pub fn bias_standard_error(truth: &[f64], recovered: &[f64]) -> Result<f64, ValidationError> { | ||
| if truth.len() < 2 { | ||
| return Err(ValidationError::InvalidInput); | ||
| } | ||
| require_paired_finite(truth, recovered)?; | ||
| 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::<f64>() / 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; | ||
| } | ||
| let variance = variance_sum / (diffs.len() as f64 - 1.0); | ||
| require_finite(require_finite(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); | ||
| 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) | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn overflow_and_nonfinite_intermediates_fail_closed() { | ||
| assert_eq!( | ||
| mean_bias(&[0.0, 0.0], &[f64::MAX, f64::MAX]), | ||
| Err(ValidationError::InvalidInput) | ||
| ); | ||
| assert_eq!( | ||
| mean_bias(&[-f64::MAX], &[f64::MAX]), | ||
| Err(ValidationError::InvalidInput) | ||
| ); | ||
| assert_eq!( | ||
| bias_standard_error(&[0.0, 0.0], &[f64::MAX, -f64::MAX]), | ||
| Err(ValidationError::InvalidInput) | ||
| ); | ||
| // Squared deviation overflows for extreme residuals. | ||
| let huge = 1e200; | ||
| assert_eq!( | ||
| bias_standard_error(&[0.0, 0.0], &[huge, -huge]), | ||
| Err(ValidationError::InvalidInput) | ||
| ); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,160 @@ | ||
| //! 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<f64, ValidationError> { | ||
| 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; | ||
| if !z2.is_finite() { | ||
| return Err(ValidationError::InvalidConfiguration); | ||
| } | ||
| let denominator = 1.0 + z2 / n; | ||
| let center = p + z2 / (2.0 * n); | ||
| let radical = (p * (1.0 - p) / n) + z2 / (4.0 * n * n); | ||
| // With finite z² and coverage p in [0,1], Wilson terms remain finite. | ||
| let margin = z * radical.sqrt(); | ||
| // radical and z are finite and non-negative; margin/bounds stay finite in [0,1]. | ||
| 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) | ||
| ); | ||
| assert_eq!( | ||
| wilson_coverage_interval(&truth, &lower, &upper, f64::MAX), | ||
| Err(ValidationError::InvalidConfiguration) | ||
| ); | ||
| // Finite z whose scaled Wilson terms still overflow. | ||
| assert_eq!( | ||
| wilson_coverage_interval(&truth, &lower, &upper, 1e200), | ||
| 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); | ||
| } | ||
|
|
||
| #[test] | ||
| fn wilson_nonfinite_guards() { | ||
| let truth = [0.0]; | ||
| let lower = [-1.0]; | ||
| let upper = [1.0]; | ||
| assert_eq!( | ||
| wilson_coverage_interval(&truth, &lower, &upper, f64::MAX), | ||
| Err(ValidationError::InvalidConfiguration) | ||
| ); | ||
| // Finite z whose scaled Wilson terms still overflow. | ||
| assert_eq!( | ||
| wilson_coverage_interval(&truth, &lower, &upper, 1e200), | ||
| Err(ValidationError::InvalidConfiguration) | ||
| ); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" | ||
| ); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.