use crate::math;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AdmissibilityEnvelope {
pub rho: f64,
pub boundary_frac: f64,
pub delta_s: f64,
}
impl AdmissibilityEnvelope {
#[inline]
#[must_use]
pub const fn new(rho: f64) -> Self {
Self { rho, boundary_frac: 0.5, delta_s: 0.05 }
}
#[inline]
#[must_use]
pub const fn with_params(rho: f64, boundary_frac: f64, delta_s: f64) -> Self {
Self { rho, boundary_frac, delta_s }
}
#[inline]
#[must_use]
pub fn effective_rho(&self, platform_multiplier: f64) -> f64 {
debug_assert!(self.rho >= 0.0, "envelope radius must be non-negative");
debug_assert!(platform_multiplier >= 0.0, "multiplier must be non-negative");
self.rho * platform_multiplier
}
#[inline]
#[must_use]
pub fn is_violation(&self, norm: f64, platform_multiplier: f64) -> bool {
let rho_eff = self.effective_rho(platform_multiplier);
norm > rho_eff
}
#[inline]
#[must_use]
pub fn is_boundary_approach(&self, norm: f64, platform_multiplier: f64) -> bool {
debug_assert!((0.0..=1.0).contains(&self.boundary_frac), "boundary_frac out of [0,1]");
let rho_eff = self.effective_rho(platform_multiplier);
norm > self.boundary_frac * rho_eff
}
#[must_use]
pub fn calibrate_from_window(healthy_norms: &[f64]) -> Option<Self> {
let mean = math::finite_mean(healthy_norms)?;
let variance = math::finite_variance(healthy_norms)?;
let std_dev = math::sqrt_f64(variance)?;
let rho = mean + 3.0 * std_dev;
debug_assert!(rho.is_finite(), "calibrated rho must be finite");
debug_assert!(rho >= 0.0, "calibrated rho must be non-negative");
Some(Self::new(rho))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn calibration_constant_window_gives_mean_rho() {
let norms = [0.05_f64; 100];
let env = AdmissibilityEnvelope::calibrate_from_window(&norms).expect("non-empty");
assert!((env.rho - 0.05).abs() < 1e-12);
}
#[test]
fn calibration_uses_mean_plus_three_sigma() {
let norms: [f64; 6] = [-0.1, -0.1, 0.0, 0.0, 0.1, 0.1];
let env = AdmissibilityEnvelope::calibrate_from_window(&norms).expect("non-empty");
assert!(env.rho > 0.0);
assert!(env.rho <= 0.35);
}
#[test]
fn violation_boundary_are_strict_inequalities() {
let env = AdmissibilityEnvelope::new(0.1);
assert!(!env.is_violation(0.05, 1.0));
assert!(!env.is_violation(0.1, 1.0), "norm == rho must not count as violation");
assert!(env.is_violation(0.101, 1.0));
}
#[test]
fn commissioning_suppresses_all_violations() {
let env = AdmissibilityEnvelope::new(0.1);
assert!(!env.is_violation(1e9, f64::INFINITY));
assert!(!env.is_boundary_approach(1e9, f64::INFINITY));
}
#[test]
fn boundary_band_is_outer_half_by_default() {
let env = AdmissibilityEnvelope::new(0.1);
assert!(!env.is_boundary_approach(0.04, 1.0));
assert!(env.is_boundary_approach(0.06, 1.0));
assert!(env.is_boundary_approach(0.099, 1.0));
}
#[test]
fn empty_calibration_returns_none() {
assert!(AdmissibilityEnvelope::calibrate_from_window(&[]).is_none());
assert!(AdmissibilityEnvelope::calibrate_from_window(&[f64::NAN, f64::NAN]).is_none());
}
}