Skip to content
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
- `relation_graph` forward-only state-transition DAG with past-pointing provenance edges and cycle rejection.
- `tepp_simulation` deterministic truth-corpus generator with delayed reporting, multilevel memberships, method-effect variants, relation noise, and digest-bound truth manifests.
- `corpus_split` leakage-safe knowledge-cutoff snapshots, relation-connected co-partition groups, rolling-origin windows, and group-normalized ESS weight contracts.
Expand Down
4 changes: 4 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions crates/validation_core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
119 changes: 119 additions & 0 deletions crates/validation_core/src/bias.rs
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)
);
}
}
160 changes: 160 additions & 0 deletions crates/validation_core/src/coverage.rs
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))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

#[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)
);
}
}
42 changes: 42 additions & 0 deletions crates/validation_core/src/error.rs
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"
);
}
}
Loading
Loading