use std::fmt;
pub const DEFAULT_ALPHA: f64 = 0.10;
pub const DEFAULT_EVT_Q: f64 = 0.999;
pub const DEFAULT_POT_FRAC: f64 = 0.15;
pub const MIN_EXCEEDANCES: usize = 8;
const SHAPE_ZERO_EPS: f64 = 1e-8;
const DENOM_EPS: f64 = 1e-12;
const GATE_SLACK: f64 = 1e-12;
#[derive(Debug, Clone, PartialEq)]
pub enum TailRiskError {
Empty,
NonFinite(f64),
OutOfRange(f64),
BadAlpha(f64),
BadEvtQ(f64),
BadPotFrac(f64),
}
impl fmt::Display for TailRiskError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => write!(f, "no CER values provided"),
Self::NonFinite(v) => write!(f, "non-finite CER value: {v}"),
Self::OutOfRange(v) => write!(f, "CER out of [0,1]: {v} (CER is a rate)"),
Self::BadAlpha(a) => write!(f, "alpha must be in (0, 1], got {a}"),
Self::BadEvtQ(q) => write!(f, "evt_q must be in (0, 1), got {q}"),
Self::BadPotFrac(p) => write!(f, "pot_frac must be in (0, 1), got {p}"),
}
}
}
impl std::error::Error for TailRiskError {}
pub type TailRiskResult<T> = Result<T, TailRiskError>;
fn fsum<I: IntoIterator<Item = f64>>(it: I) -> f64 {
let mut sum = 0.0_f64;
let mut c = 0.0_f64; for x in it {
let t = sum + x;
if sum.abs() >= x.abs() {
c += (sum - t) + x;
} else {
c += (x - t) + sum;
}
sum = t;
}
sum + c
}
#[must_use]
pub fn empirical_quantile(sorted_vals: &[f64], q: f64) -> Option<f64> {
let n = sorted_vals.len();
if n == 0 {
return None;
}
if n == 1 {
return Some(sorted_vals[0]);
}
let q = q.clamp(0.0, 1.0);
let pos = q * (n as f64 - 1.0);
let lo = pos.floor() as usize;
let hi = (lo + 1).min(n - 1);
let frac = pos - lo as f64;
Some(sorted_vals[lo] * (1.0 - frac) + sorted_vals[hi] * frac)
}
#[must_use]
fn mean(vals: &[f64]) -> f64 {
fsum(vals.iter().copied()) / vals.len() as f64
}
pub fn cvar(vals: &[f64], alpha: f64) -> TailRiskResult<f64> {
validate_samples(vals)?;
let n = vals.len();
if !(alpha > 0.0 && alpha <= 1.0) {
return Err(TailRiskError::BadAlpha(alpha));
}
let mut ordered: Vec<f64> = vals.to_vec();
ordered.sort_by(|a, b| b.partial_cmp(a).expect("CER values are finite"));
let target = alpha * n as f64;
let mut k = (target - 1e-12).ceil() as i64;
k = k.clamp(1, n as i64);
let k = k as usize;
let full = if k >= 1 {
fsum(ordered[..k - 1].iter().copied())
} else {
0.0
};
let boundary_weight = (target - (k as f64 - 1.0)).clamp(0.0, 1.0);
let weighted_sum = full + boundary_weight * ordered[k - 1];
Ok(weighted_sum / target)
}
#[must_use]
pub fn value_at_risk(sorted_vals: &[f64], alpha: f64) -> Option<f64> {
empirical_quantile(sorted_vals, 1.0 - alpha)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GpdMethod {
Pwm,
EmpiricalFallback,
}
impl GpdMethod {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Pwm => "pwm",
Self::EmpiricalFallback => "empirical-fallback",
}
}
#[must_use]
pub fn is_fallback(self) -> bool {
matches!(self, Self::EmpiricalFallback)
}
}
impl fmt::Display for GpdMethod {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct GpdFit {
pub threshold: f64,
pub scale: f64,
pub shape: f64,
pub n_exceed: usize,
pub n_total: usize,
pub method: GpdMethod,
}
impl GpdFit {
#[must_use]
pub fn exceed_rate(&self) -> f64 {
if self.n_total == 0 {
0.0
} else {
self.n_exceed as f64 / self.n_total as f64
}
}
#[must_use]
pub fn quantile(&self, q: f64) -> f64 {
let zeta = self.exceed_rate();
if zeta <= 0.0 {
return self.threshold;
}
let mut ratio = (1.0 - q) / zeta;
ratio = ratio.max(1e-300);
if self.shape.abs() < SHAPE_ZERO_EPS {
self.threshold - self.scale * ratio.ln()
} else {
self.threshold + (self.scale / self.shape) * (ratio.powf(-self.shape) - 1.0)
}
}
}
pub fn fit_gpd_pwm(sorted_vals: &[f64], pot_frac: f64) -> TailRiskResult<GpdFit> {
validate_samples(sorted_vals)?;
let n = sorted_vals.len();
let u = empirical_quantile(sorted_vals, 1.0 - pot_frac).expect("non-empty");
let mut exceed: Vec<f64> = sorted_vals
.iter()
.copied()
.filter(|&v| v > u)
.map(|v| v - u)
.collect();
let m = exceed.len();
let fallback = |m: usize| GpdFit {
threshold: u,
scale: 0.0,
shape: 0.0,
n_exceed: m,
n_total: n,
method: GpdMethod::EmpiricalFallback,
};
if m < MIN_EXCEEDANCES {
return Ok(fallback(m));
}
exceed.sort_by(|a, b| a.partial_cmp(b).expect("finite"));
let a0 = fsum(exceed.iter().copied()) / m as f64;
let mf = m as f64;
let a1 = fsum(
exceed
.iter()
.enumerate()
.map(|(j, &y)| (1.0 - (((j + 1) as f64) - 0.35) / mf) * y),
) / mf;
let denom = a0 - 2.0 * a1;
if denom.abs() < DENOM_EPS || a0 <= 0.0 {
return Ok(fallback(m));
}
let shape = 2.0 - a0 / denom;
let scale = 2.0 * a0 * a1 / denom;
if !(shape.is_finite() && scale.is_finite()) || scale <= 0.0 {
return Ok(fallback(m));
}
Ok(GpdFit {
threshold: u,
scale,
shape,
n_exceed: m,
n_total: n,
method: GpdMethod::Pwm,
})
}
pub fn evt_quantile(sorted_vals: &[f64], q: f64, pot_frac: f64) -> TailRiskResult<(f64, GpdFit)> {
let n = sorted_vals.len();
if n == 0 {
return Err(TailRiskError::Empty);
}
let emp = empirical_quantile(sorted_vals, q).expect("non-empty");
let fit = fit_gpd_pwm(sorted_vals, pot_frac)?;
let mut x_q = if fit.method.is_fallback() {
emp
} else {
let v = fit.quantile(q);
if v.is_finite() { v } else { emp }
};
x_q = x_q.max(emp);
x_q = x_q.clamp(0.0, 1.0);
Ok((x_q, fit))
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LossMatrix {
pub ship_bad: f64,
pub reject_good: f64,
}
impl Default for LossMatrix {
fn default() -> Self {
Self {
ship_bad: 10.0,
reject_good: 1.0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GateVerdict {
Pass,
Fail,
NoBaseline,
}
impl GateVerdict {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Pass => "pass",
Self::Fail => "fail",
Self::NoBaseline => "no-baseline",
}
}
}
impl fmt::Display for GateVerdict {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct GateCheck {
pub name: String,
pub candidate: f64,
pub baseline: f64,
pub limit: f64,
pub pass: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Gate {
pub budget: f64,
pub checks: Vec<GateCheck>,
pub verdict: GateVerdict,
pub fallback: Option<String>,
}
pub const GATE_FALLBACK_MSG: &str = "Tail bound exceeds the ledgered budget: keep the \
tail-offending tensor one precision tier higher (int4->int8 or int8->bf16) and re-measure \
(plan section 9.7 AF-2 fallback).";
#[derive(Debug, Clone, PartialEq)]
pub struct TailReport {
pub n: usize,
pub alpha: f64,
pub mean: f64,
pub var_alpha: f64,
pub cvar_alpha: f64,
pub evt_q: f64,
pub evt_quantile: f64,
pub pot_frac: f64,
pub fit: GpdFit,
pub max_cer: f64,
pub min_cer: f64,
pub gate: Option<Gate>,
}
impl TailReport {
#[must_use]
pub fn coverage_floor(&self, sorted_vals: &[f64]) -> f64 {
if sorted_vals.is_empty() {
return 0.0;
}
let at_or_below = sorted_vals
.iter()
.filter(|&&v| v <= self.evt_quantile)
.count();
at_or_below as f64 / sorted_vals.len() as f64
}
#[must_use]
pub fn used_fallback(&self) -> bool {
self.fit.method.is_fallback()
}
}
#[must_use]
pub fn fmt_frac(alpha: f64) -> String {
let s = format!("{alpha:.6}");
let trimmed = s.trim_end_matches('0').trim_end_matches('.');
if trimmed.is_empty() {
"0".to_string()
} else {
trimmed.to_string()
}
}
#[must_use]
pub fn fmt_pctile(q: f64) -> String {
let s = format!("{:.4}", q * 100.0);
let trimmed = s.trim_end_matches('0').trim_end_matches('.');
trimmed.replace('.', "")
}
fn validate_samples(vals: &[f64]) -> TailRiskResult<()> {
if vals.is_empty() {
return Err(TailRiskError::Empty);
}
for &v in vals {
if !v.is_finite() {
return Err(TailRiskError::NonFinite(v));
}
if !(0.0..=1.0).contains(&v) {
return Err(TailRiskError::OutOfRange(v));
}
}
Ok(())
}
pub fn compute_report(
vals: &[f64],
alpha: f64,
evt_q: f64,
pot_frac: f64,
) -> TailRiskResult<TailReport> {
validate_samples(vals)?;
if !(alpha > 0.0 && alpha <= 1.0) {
return Err(TailRiskError::BadAlpha(alpha));
}
if !(evt_q > 0.0 && evt_q < 1.0) {
return Err(TailRiskError::BadEvtQ(evt_q));
}
if !(pot_frac > 0.0 && pot_frac < 1.0) {
return Err(TailRiskError::BadPotFrac(pot_frac));
}
let mut sorted_vals: Vec<f64> = vals.to_vec();
sorted_vals.sort_by(|a, b| a.partial_cmp(b).expect("finite"));
let (evt_q_val, fit) = evt_quantile(&sorted_vals, evt_q, pot_frac)?;
Ok(TailReport {
n: vals.len(),
alpha,
mean: mean(vals),
var_alpha: value_at_risk(&sorted_vals, alpha).expect("non-empty"),
cvar_alpha: cvar(vals, alpha)?,
evt_q,
evt_quantile: evt_q_val,
pot_frac,
fit,
max_cer: sorted_vals[sorted_vals.len() - 1],
min_cer: sorted_vals[0],
gate: None,
})
}
pub fn compute_report_default(vals: &[f64]) -> TailRiskResult<TailReport> {
compute_report(vals, DEFAULT_ALPHA, DEFAULT_EVT_Q, DEFAULT_POT_FRAC)
}
#[must_use]
pub fn apply_gate(
report: &TailReport,
baseline_cvar: Option<f64>,
baseline_evt: Option<f64>,
budget: f64,
) -> Gate {
let mut checks: Vec<GateCheck> = Vec::new();
let mut passed = true;
let mut have_baseline = false;
if let Some(b_cvar) = baseline_cvar {
have_baseline = true;
let limit = b_cvar + budget;
let ok = report.cvar_alpha <= limit + GATE_SLACK;
passed = passed && ok;
checks.push(GateCheck {
name: format!("cvar_{}", fmt_frac(report.alpha)),
candidate: report.cvar_alpha,
baseline: b_cvar,
limit,
pass: ok,
});
}
if let Some(b_evt) = baseline_evt {
have_baseline = true;
let limit = b_evt + budget;
let ok = report.evt_quantile <= limit + GATE_SLACK;
passed = passed && ok;
checks.push(GateCheck {
name: format!("evt_p{}", fmt_pctile(report.evt_q)),
candidate: report.evt_quantile,
baseline: b_evt,
limit,
pass: ok,
});
}
let (verdict, fallback) = if !have_baseline {
(GateVerdict::NoBaseline, None)
} else if passed {
(GateVerdict::Pass, None)
} else {
(GateVerdict::Fail, Some(GATE_FALLBACK_MSG.to_string()))
};
Gate {
budget,
checks,
verdict,
fallback,
}
}
#[cfg(test)]
mod tests {
use super::*;
const TOL: f64 = 1e-9;
fn approx(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() <= tol
}
#[test]
fn empirical_quantile_type7_matches_numpy_default() {
let v = [0.0, 1.0, 2.0, 3.0, 4.0];
assert!(approx(empirical_quantile(&v, 0.5).unwrap(), 2.0, TOL));
assert!(approx(empirical_quantile(&v, 0.25).unwrap(), 1.0, TOL));
assert!(approx(empirical_quantile(&v, 0.0).unwrap(), 0.0, TOL));
assert!(approx(empirical_quantile(&v, 1.0).unwrap(), 4.0, TOL));
assert!(approx(empirical_quantile(&v, 0.1).unwrap(), 0.4, TOL));
}
#[test]
fn empirical_quantile_single_and_empty() {
assert!(approx(empirical_quantile(&[0.7], 0.999).unwrap(), 0.7, TOL));
assert!(empirical_quantile(&[], 0.5).is_none());
}
#[test]
fn cvar_full_fraction_equals_mean() {
let v = [0.1, 0.2, 0.3, 0.4];
assert!(approx(cvar(&v, 1.0).unwrap(), 0.25, TOL));
}
#[test]
fn cvar_geq_var_geq_mean_invariant() {
let v = [0.0, 0.0, 0.01, 0.02, 0.05, 0.1, 0.3, 0.6, 0.9, 1.0];
let mut s = v.to_vec();
s.sort_by(|a, b| a.partial_cmp(b).unwrap());
let m = mean(&v);
let var = value_at_risk(&s, 0.1).unwrap();
let cv = cvar(&v, 0.1).unwrap();
assert!(cv >= var - TOL, "CVaR {cv} >= VaR {var}");
assert!(var >= m - TOL, "VaR {var} >= mean {m}");
}
#[test]
fn cvar_matches_python_reference_value() {
let v = [0.0, 0.0, 0.5, 0.9];
assert!(
approx(cvar(&v, 0.5).unwrap(), 0.7, TOL),
"{}",
cvar(&v, 0.5).unwrap()
);
}
#[test]
fn cvar_fractional_boundary_continuity() {
let v = [0.0, 0.1, 0.2, 1.0];
assert!(approx(cvar(&v, 0.1).unwrap(), 1.0, TOL));
}
#[test]
fn cvar_rejects_bad_alpha_and_empty() {
assert_eq!(cvar(&[0.1], 0.0), Err(TailRiskError::BadAlpha(0.0)));
assert_eq!(cvar(&[0.1], 1.5), Err(TailRiskError::BadAlpha(1.5)));
assert_eq!(cvar(&[], 0.1), Err(TailRiskError::Empty));
}
#[test]
fn cvar_rejects_invalid_samples_without_panicking() {
assert!(matches!(
cvar(&[f64::NAN], 0.1),
Err(TailRiskError::NonFinite(v)) if v.is_nan()
));
assert_eq!(cvar(&[-0.1], 0.1), Err(TailRiskError::OutOfRange(-0.1)));
assert_eq!(cvar(&[1.1], 0.1), Err(TailRiskError::OutOfRange(1.1)));
}
#[test]
fn gpd_pwm_fit_matches_python_reference() {
let mut vals: Vec<f64> = (0..48)
.map(|i| (0.001 * i as f64 * 10000.0).round() / 10000.0)
.collect();
vals.extend_from_slice(&[
0.06, 0.07, 0.08, 0.09, 0.10, 0.11, 0.13, 0.16, 0.20, 0.26, 0.34, 0.45,
]);
let mut s = vals.clone();
s.sort_by(|a, b| a.partial_cmp(b).unwrap());
let fit = fit_gpd_pwm(&s, 0.15).unwrap();
assert_eq!(fit.method, GpdMethod::Pwm);
assert_eq!(fit.n_exceed, 9);
assert!(
approx(fit.shape, 0.1530800545257287, 1e-9),
"shape {}",
fit.shape
);
assert!(
approx(fit.scale, 0.10412410218525352, 1e-9),
"scale {}",
fit.scale
);
assert!(
approx(fit.threshold, 0.08149999999999999, 1e-9),
"thr {}",
fit.threshold
);
let (evt, _) = evt_quantile(&s, 0.999, 0.15).unwrap();
assert!(approx(evt, 0.8660067126095988, 1e-8), "evt {evt}");
}
#[test]
fn gpd_pwm_plotting_position_is_the_fixed_estimator() {
let mut vals: Vec<f64> = (0..48).map(|i| 0.001 * i as f64).collect();
vals.extend((0..12).map(|k| 0.20 + 0.04 * k as f64));
let mut s = vals.clone();
s.sort_by(|a, b| a.partial_cmp(b).unwrap());
let u = empirical_quantile(&s, 1.0 - DEFAULT_POT_FRAC).unwrap();
let mut exc: Vec<f64> = s
.iter()
.copied()
.filter(|&v| v > u)
.map(|v| v - u)
.collect();
exc.sort_by(|a, b| a.partial_cmp(b).unwrap());
let m = exc.len();
assert!(
m >= MIN_EXCEEDANCES,
"need a real tail for this guard, m={m}"
);
let mf = m as f64;
let a0 = fsum(exc.iter().copied()) / mf;
let a1_corr = fsum(
exc.iter()
.enumerate()
.map(|(j, &y)| (1.0 - (((j + 1) as f64) - 0.35) / mf) * y),
) / mf;
let denom_corr = a0 - 2.0 * a1_corr;
let scale_corr = 2.0 * a0 * a1_corr / denom_corr;
let a1_bug = fsum(
exc.iter()
.enumerate()
.map(|(j, &y)| ((j as f64) / (mf - 1.0)) * y),
) / mf;
let denom_bug = a0 - 2.0 * a1_bug;
let scale_bug = 2.0 * a0 * a1_bug / denom_bug;
let fit = fit_gpd_pwm(&s, DEFAULT_POT_FRAC).unwrap();
assert_eq!(
fit.method,
GpdMethod::Pwm,
"plotting-position must give a real fit"
);
assert!(
scale_corr > 0.0,
"correct scale must be positive, got {scale_corr}"
);
assert!(
approx(fit.scale, scale_corr, 1e-9),
"fit uses the correct weight"
);
assert!(
(denom_corr > 0.0) != (denom_bug > 0.0),
"bugged weight must flip denom sign: corr={denom_corr} bug={denom_bug}"
);
assert!(
scale_bug < 0.0,
"bugged weight must drive scale negative, got {scale_bug}"
);
}
#[test]
fn fallback_fires_on_too_few_exceedances() {
let v = [0.0, 0.1, 0.2, 0.3, 0.5];
let report = compute_report_default(&v).unwrap();
assert!(
report.used_fallback(),
"too-few-samples must trigger fallback"
);
assert_eq!(report.fit.method, GpdMethod::EmpiricalFallback);
assert_eq!(report.fit.scale, 0.0);
assert_eq!(report.fit.shape, 0.0);
assert!(
approx(report.evt_quantile, 0.4992, 1e-9),
"evt {}",
report.evt_quantile
);
assert!(
approx(report.cvar_alpha, 0.5, TOL),
"cvar {}",
report.cvar_alpha
);
assert!(
approx(report.fit.threshold, 0.38, TOL),
"thr {}",
report.fit.threshold
);
}
#[test]
fn fit_gpd_rejects_invalid_samples_without_panicking() {
assert!(matches!(
fit_gpd_pwm(&[0.0, f64::NAN], DEFAULT_POT_FRAC),
Err(TailRiskError::NonFinite(v)) if v.is_nan()
));
assert_eq!(
fit_gpd_pwm(&[0.0, 1.2], DEFAULT_POT_FRAC),
Err(TailRiskError::OutOfRange(1.2))
);
}
#[test]
fn fallback_evt_never_below_empirical_and_clamped() {
let v = [0.0, 0.05, 0.1, 0.2, 0.4, 0.6];
let report = compute_report_default(&v).unwrap();
assert!(report.used_fallback());
let mut s = v.to_vec();
s.sort_by(|a, b| a.partial_cmp(b).unwrap());
let emp = empirical_quantile(&s, 0.999).unwrap();
assert!(approx(report.evt_quantile, emp.clamp(0.0, 1.0), TOL));
assert!((0.0..=1.0).contains(&report.evt_quantile));
}
#[test]
fn fallback_fires_on_degenerate_constant_exceedances() {
let mut v = vec![0.0; 40];
v.extend(std::iter::repeat_n(0.5, 10));
let mut s = v.clone();
s.sort_by(|a, b| a.partial_cmp(b).unwrap());
let fit = fit_gpd_pwm(&s, 0.15).unwrap();
assert_eq!(
fit.method,
GpdMethod::EmpiricalFallback,
"degenerate (constant) tail must fall back, shape={} scale={}",
fit.shape,
fit.scale
);
}
#[test]
fn degenerate_fit_does_not_invent_a_bound() {
let mut v = vec![0.0; 40];
v.extend(std::iter::repeat_n(0.5, 10));
let report = compute_report_default(&v).unwrap();
if report.used_fallback() {
let mut s = v.clone();
s.sort_by(|a, b| a.partial_cmp(b).unwrap());
let emp = empirical_quantile(&s, 0.999).unwrap();
assert!(approx(report.evt_quantile, emp.clamp(0.0, 1.0), TOL));
}
}
#[test]
fn evt_clamped_to_unit_interval_on_heavy_tail() {
let mut v = vec![
0.0, 0.0, 0.0, 0.0, 0.0, 0.01, 0.01, 0.02, 0.02, 0.03, 0.03, 0.04, 0.05, 0.06, 0.08,
0.10, 0.12, 0.15, 0.20, 0.25, 0.30, 0.40, 0.50, 0.60, 0.70, 0.80, 0.90, 0.95, 0.98,
1.0,
];
v.extend_from_slice(&[
0.0, 0.0, 0.0, 0.01, 0.02, 0.03, 0.04, 0.05, 0.06, 0.07, 0.08, 0.09, 0.10, 0.11, 0.12,
0.13, 0.14, 0.15, 0.16, 0.17,
]);
let report = compute_report_default(&v).unwrap();
assert_eq!(report.fit.method, GpdMethod::Pwm);
assert_eq!(report.n, 50);
assert!(
approx(report.evt_quantile, 1.0, TOL),
"evt {}",
report.evt_quantile
);
assert!(
approx(report.cvar_alpha, 0.9259999999999999, 1e-9),
"cvar {}",
report.cvar_alpha
);
assert!(approx(report.mean, 0.1966, 1e-9), "mean {}", report.mean);
assert!(
approx(report.var_alpha, 0.7100000000000002, 1e-9),
"var {}",
report.var_alpha
);
assert!(approx(report.fit.shape, -1.0695172023219603, 1e-9));
assert!(approx(report.fit.scale, 0.7010489522865643, 1e-9));
assert_eq!(report.fit.n_exceed, 8);
}
#[test]
fn evt_never_under_states_empirical_quantile() {
let v = [
0.0, 0.0, 0.01, 0.02, 0.04, 0.08, 0.16, 0.32, 0.64, 0.9, 0.95, 1.0,
];
let report = compute_report_default(&v).unwrap();
let mut s = v.to_vec();
s.sort_by(|a, b| a.partial_cmp(b).unwrap());
let emp = empirical_quantile(&s, 0.999).unwrap().clamp(0.0, 1.0);
assert!(
report.evt_quantile >= emp - TOL,
"bound {} >= emp {emp}",
report.evt_quantile
);
}
#[test]
fn coverage_floor_is_at_least_nominal_for_trusted_fit() {
let mut v: Vec<f64> = (0..48).map(|i| 0.001 * i as f64).collect();
v.extend_from_slice(&[
0.06, 0.07, 0.08, 0.09, 0.10, 0.11, 0.13, 0.16, 0.20, 0.26, 0.34, 0.45,
]);
let report = compute_report_default(&v).unwrap();
let mut s = v.clone();
s.sort_by(|a, b| a.partial_cmp(b).unwrap());
let cov = report.coverage_floor(&s);
assert!(
cov >= report.evt_q - 1e-9 || cov >= (s.len() - 1) as f64 / s.len() as f64,
"coverage {cov} should be conservative vs q {}",
report.evt_q
);
}
#[test]
fn gate_fails_when_bound_exceeds_budget() {
let v = [0.0, 0.0, 0.5, 0.9];
let report = compute_report(&v, 0.5, DEFAULT_EVT_Q, DEFAULT_POT_FRAC).unwrap();
let gate = apply_gate(&report, Some(0.05), None, 0.01);
assert_eq!(gate.verdict, GateVerdict::Fail);
assert_eq!(gate.checks.len(), 1);
assert_eq!(gate.checks[0].name, "cvar_0.5");
assert!(!gate.checks[0].pass);
assert!(approx(gate.checks[0].limit, 0.06, 1e-9));
assert_eq!(gate.fallback.as_deref(), Some(GATE_FALLBACK_MSG));
}
#[test]
fn gate_passes_within_budget() {
let v = [0.0, 0.01, 0.02, 0.03, 0.05, 0.08, 0.1, 0.12];
let report = compute_report_default(&v).unwrap();
let gate = apply_gate(&report, Some(report.cvar_alpha + 0.1), Some(1.0), 0.0);
assert_eq!(gate.verdict, GateVerdict::Pass);
assert!(gate.fallback.is_none());
assert!(gate.checks.iter().all(|c| c.pass));
}
#[test]
fn gate_no_baseline_is_informational_not_fail() {
let v = [0.0, 0.1, 0.2, 0.9];
let report = compute_report_default(&v).unwrap();
let gate = apply_gate(&report, None, None, 0.0);
assert_eq!(gate.verdict, GateVerdict::NoBaseline);
assert!(gate.checks.is_empty());
assert!(gate.fallback.is_none());
}
#[test]
fn gate_both_bounds_checked() {
let v = [0.0, 0.01, 0.02, 0.5, 0.9, 0.95];
let report = compute_report(&v, 0.5, DEFAULT_EVT_Q, DEFAULT_POT_FRAC).unwrap();
let gate = apply_gate(&report, Some(10.0), Some(0.0), 0.0);
assert_eq!(gate.checks.len(), 2);
assert_eq!(gate.verdict, GateVerdict::Fail);
let evt_check = gate
.checks
.iter()
.find(|c| c.name.starts_with("evt_"))
.unwrap();
assert!(!evt_check.pass);
assert_eq!(evt_check.name, "evt_p999");
}
#[test]
fn rejects_non_finite_and_out_of_range() {
assert_eq!(
compute_report_default(&[]).unwrap_err(),
TailRiskError::Empty
);
assert!(matches!(
compute_report_default(&[0.1, f64::NAN]).unwrap_err(),
TailRiskError::NonFinite(v) if v.is_nan()
));
assert!(matches!(
compute_report_default(&[0.1, 1.5]).unwrap_err(),
TailRiskError::OutOfRange(_)
));
assert!(matches!(
compute_report_default(&[-0.1, 0.2]).unwrap_err(),
TailRiskError::OutOfRange(_)
));
}
#[test]
fn rejects_bad_parameters() {
let v = [0.1, 0.2, 0.3];
assert!(matches!(
compute_report(&v, 0.0, 0.999, 0.15).unwrap_err(),
TailRiskError::BadAlpha(_)
));
assert!(matches!(
compute_report(&v, 0.1, 1.0, 0.15).unwrap_err(),
TailRiskError::BadEvtQ(_)
));
assert!(matches!(
compute_report(&v, 0.1, 0.999, 1.0).unwrap_err(),
TailRiskError::BadPotFrac(_)
));
}
#[test]
fn frac_and_pctile_formatting_matches_python() {
assert_eq!(fmt_frac(0.1), "0.1");
assert_eq!(fmt_frac(0.10), "0.1");
assert_eq!(fmt_frac(0.5), "0.5");
assert_eq!(fmt_frac(1.0), "1");
assert_eq!(fmt_pctile(0.999), "999");
assert_eq!(fmt_pctile(0.99), "99");
assert_eq!(fmt_pctile(0.95), "95");
}
#[test]
fn fsum_is_compensated() {
let xs = [1.0, 1e16, -1e16, -1.0, 0.5];
assert!(approx(fsum(xs.iter().copied()), 0.5, 1e-9));
}
}