use std::collections::VecDeque;
use gam_linalg::utils::KahanSum;
pub const DEFAULT_ASYMPTOTE_WINDOW: usize = 12;
pub const MIN_TAIL_SAMPLES: usize = 3;
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum AsymptoteSide {
Upper,
Lower,
}
impl AsymptoteSide {
pub fn from_gradient(grad: f64, interior_grad_tol: f64) -> Option<Self> {
if !grad.is_finite() || grad.abs() <= interior_grad_tol {
None
} else if grad < 0.0 {
Some(AsymptoteSide::Upper)
} else {
Some(AsymptoteSide::Lower)
}
}
pub fn tail_constant(self, rho: f64, grad: f64) -> f64 {
match self {
AsymptoteSide::Upper => -rho.exp() * grad,
AsymptoteSide::Lower => (-rho).exp() * grad,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct AsymptoteSample {
pub rho: f64,
pub grad: f64,
pub coef_step_norm: f64,
}
#[derive(Clone, Debug)]
pub struct AsymptoteWindow {
capacity: usize,
ring: VecDeque<AsymptoteSample>,
}
impl Default for AsymptoteWindow {
fn default() -> Self {
Self::with_capacity(DEFAULT_ASYMPTOTE_WINDOW)
}
}
impl AsymptoteWindow {
pub fn new() -> Self {
Self::default()
}
pub fn with_capacity(capacity: usize) -> Self {
let capacity = capacity.max(1);
Self {
capacity,
ring: VecDeque::with_capacity(capacity),
}
}
pub fn push(&mut self, sample: AsymptoteSample) {
if self.ring.len() == self.capacity {
self.ring.pop_front();
}
self.ring.push_back(sample);
}
pub fn samples(&self) -> impl Iterator<Item = &AsymptoteSample> {
self.ring.iter()
}
pub fn len(&self) -> usize {
self.ring.len()
}
pub fn is_empty(&self) -> bool {
self.ring.is_empty()
}
pub fn latest(&self) -> Option<&AsymptoteSample> {
self.ring.back()
}
}
#[derive(Clone, Copy, Debug)]
pub struct AsymptoteTolerances {
pub interior_grad_tol: f64,
pub tail_noise_floor: f64,
pub tail_drift_rel: f64,
pub estimand_tol: f64,
}
impl AsymptoteTolerances {
pub const EXP4_INTERIOR_GRAD_TOL: f64 = 1.0e-8;
pub const EXP4_TAIL_NOISE_FLOOR: f64 = 1.0e-6;
pub const EXP4_TAIL_DRIFT_REL: f64 = 1.0e-3;
pub fn exp4_rail_bands(estimand_tol: f64) -> Self {
Self {
interior_grad_tol: Self::EXP4_INTERIOR_GRAD_TOL,
tail_noise_floor: Self::EXP4_TAIL_NOISE_FLOOR,
tail_drift_rel: Self::EXP4_TAIL_DRIFT_REL,
estimand_tol,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum AsymptoteVerdict {
CertifiedAtAsymptote {
side: AsymptoteSide,
tail_constant: f64,
value_gap: f64,
estimand_travel_bound: f64,
},
OnTailNotYetEquivalent {
side: AsymptoteSide,
estimand_travel_bound: f64,
},
NoAsymptote {
reason: String,
},
}
fn mean_and_rel_spread(values: &[f64]) -> Option<(f64, f64)> {
if values.is_empty() {
return None;
}
let mut sum = KahanSum::default();
let mut lo = f64::INFINITY;
let mut hi = f64::NEG_INFINITY;
for &v in values {
if !v.is_finite() {
return None;
}
sum.add(v);
lo = lo.min(v);
hi = hi.max(v);
}
let mean = sum.sum() / values.len() as f64;
if !(mean.abs() > 0.0) {
return None;
}
Some((mean, (hi - lo) / mean.abs()))
}
fn coef_step_ratio(samples: &[AsymptoteSample]) -> Option<f64> {
let positives: Vec<f64> = samples
.iter()
.map(|s| s.coef_step_norm)
.filter(|&d| d.is_finite() && d > 0.0)
.collect();
if positives.len() < 2 {
return None;
}
let last = positives[positives.len() - 1];
let prev = positives[positives.len() - 2];
if !(prev > 0.0) {
return None;
}
Some(last / prev)
}
pub fn assess_coordinate(
window: &AsymptoteWindow,
tol: &AsymptoteTolerances,
) -> AsymptoteVerdict {
let latest = match window.latest() {
Some(s) => *s,
None => {
return AsymptoteVerdict::NoAsymptote {
reason: "empty window".to_string(),
}
}
};
let side = match AsymptoteSide::from_gradient(latest.grad, tol.interior_grad_tol) {
Some(s) => s,
None => {
return AsymptoteVerdict::NoAsymptote {
reason: format!(
"interior-stationary in this coordinate: |grad|={:.3e} ≤ tol {:.3e}",
latest.grad.abs(),
tol.interior_grad_tol
),
}
}
};
let samples: Vec<AsymptoteSample> = window.samples().copied().collect();
if samples.len() < MIN_TAIL_SAMPLES {
return AsymptoteVerdict::NoAsymptote {
reason: format!(
"too few samples to confirm a tail: {} < {MIN_TAIL_SAMPLES}",
samples.len()
),
};
}
let constants: Vec<f64> = samples
.iter()
.map(|s| side.tail_constant(s.rho, s.grad))
.collect();
if constants.iter().any(|&c| !(c > 0.0)) {
return AsymptoteVerdict::NoAsymptote {
reason: "pencil constant ĉ not uniformly positive across the window (not a single tail)"
.to_string(),
};
}
let (mean_c, spread) = match mean_and_rel_spread(&constants) {
Some(v) => v,
None => {
return AsymptoteVerdict::NoAsymptote {
reason: "pencil constant ĉ has no usable mean".to_string(),
}
}
};
if mean_c <= tol.tail_noise_floor {
return AsymptoteVerdict::NoAsymptote {
reason: format!(
"pencil constant ĉ={mean_c:.3e} below the noise floor {:.3e} \
(finite-difference-dominated, not a confirmed tail)",
tol.tail_noise_floor
),
};
}
if spread > tol.tail_drift_rel {
return AsymptoteVerdict::NoAsymptote {
reason: format!(
"pencil constant ĉ drifts {spread:.3e} > band {:.3e} (still in the curved region)",
tol.tail_drift_rel
),
};
}
let value_gap = latest.grad.abs();
let q = match coef_step_ratio(&samples) {
Some(q) if q.is_finite() && q >= 0.0 && q < 1.0 => q,
_ => {
return AsymptoteVerdict::NoAsymptote {
reason: "coefficient moves not geometrically contracting (estimand not settling)"
.to_string(),
}
}
};
let last_step = samples
.iter()
.rev()
.map(|s| s.coef_step_norm)
.find(|&d| d.is_finite() && d > 0.0)
.unwrap_or(0.0);
let estimand_travel_bound = last_step * q / (1.0 - q);
if estimand_travel_bound <= tol.estimand_tol {
AsymptoteVerdict::CertifiedAtAsymptote {
side,
tail_constant: mean_c,
value_gap,
estimand_travel_bound,
}
} else {
AsymptoteVerdict::OnTailNotYetEquivalent {
side,
estimand_travel_bound,
}
}
}
#[cfg(test)]
mod asymptote_certificate_tests {
use super::*;
fn lower_tail_window(c: f64, a: f64, rho0: f64, drho: f64, n: usize) -> AsymptoteWindow {
let mut w = AsymptoteWindow::with_capacity(n);
for k in 0..n {
let rho = rho0 - (k as f64) * drho;
w.push(AsymptoteSample {
rho,
grad: c * rho.exp(),
coef_step_norm: a * rho.exp(),
});
}
w
}
fn tol(estimand_tol: f64) -> AsymptoteTolerances {
AsymptoteTolerances {
interior_grad_tol: 1.0e-8,
tail_noise_floor: 1.0e-6,
tail_drift_rel: 1.0e-3,
estimand_tol,
}
}
#[test]
fn lower_tail_recovers_constant_and_value_gap() {
let c = 6723.0;
let window = lower_tail_window(c, 1.0e-3, -7.0, 0.5, 8);
match assess_coordinate(&window, &tol(1.0)) {
AsymptoteVerdict::CertifiedAtAsymptote {
side,
tail_constant,
value_gap,
..
} => {
assert_eq!(side, AsymptoteSide::Lower);
assert!(
(tail_constant - c).abs() / c < 1.0e-9,
"recovered ĉ={tail_constant} should equal c={c}"
);
let latest = window.latest().unwrap();
assert!(
(value_gap - latest.grad.abs()).abs() <= f64::EPSILON * value_gap.max(1.0),
"value_gap must be the exact tail integral |grad|"
);
}
other => panic!("expected CertifiedAtAsymptote on a pure tail, got {other:?}"),
}
}
#[test]
fn estimand_gate_decides_certify_vs_on_tail() {
let window = lower_tail_window(6723.0, 1.0e-3, -7.0, 0.5, 8);
let last_step = window.latest().unwrap().coef_step_norm; let q = (-0.5_f64).exp();
let expected_travel = last_step * q / (1.0 - q);
match assess_coordinate(&window, &tol(expected_travel * 2.0)) {
AsymptoteVerdict::CertifiedAtAsymptote {
estimand_travel_bound,
..
} => {
assert!(
(estimand_travel_bound - expected_travel).abs()
<= 1.0e-9 * expected_travel.max(1.0),
"travel bound {estimand_travel_bound} should match the geometric tail sum \
{expected_travel}"
);
}
other => panic!("expected Certified under a loose estimand tol, got {other:?}"),
}
match assess_coordinate(&window, &tol(expected_travel * 0.5)) {
AsymptoteVerdict::OnTailNotYetEquivalent {
estimand_travel_bound,
side,
} => {
assert_eq!(side, AsymptoteSide::Lower);
assert!(estimand_travel_bound > expected_travel * 0.5);
}
other => panic!("expected OnTailNotYetEquivalent under a tight estimand tol, got {other:?}"),
}
}
#[test]
fn upper_tail_side_and_constant() {
let c = 42.0;
let mut w = AsymptoteWindow::with_capacity(6);
for k in 0..6 {
let rho = 8.0 + (k as f64) * 0.5; w.push(AsymptoteSample {
rho,
grad: -c * (-rho).exp(), coef_step_norm: 1.0e-4 * (-rho).exp(),
});
}
match assess_coordinate(&w, &tol(1.0)) {
AsymptoteVerdict::CertifiedAtAsymptote {
side,
tail_constant,
..
} => {
assert_eq!(side, AsymptoteSide::Upper);
assert!((tail_constant - c).abs() / c < 1.0e-9);
}
other => panic!("expected Certified Upper tail, got {other:?}"),
}
}
#[test]
fn drifting_constant_is_rejected_as_noise_regime() {
let rhos = [-9.0, -9.5, -10.0, -10.5];
let cs = [6723.0, 6731.0, 9111.0, 4200.0]; let mut w = AsymptoteWindow::with_capacity(4);
for (rho, c) in rhos.iter().zip(cs.iter()) {
w.push(AsymptoteSample {
rho: *rho,
grad: c * rho.exp(),
coef_step_norm: 1.0e-3 * rho.exp(),
});
}
match assess_coordinate(&w, &tol(1.0)) {
AsymptoteVerdict::NoAsymptote { reason } => {
assert!(reason.contains("drift"), "should reject on drift: {reason}");
}
other => panic!("drifting ĉ must not certify, got {other:?}"),
}
}
#[test]
fn interior_stationary_is_not_an_asymptote() {
let mut w = AsymptoteWindow::new();
for k in 0..4 {
w.push(AsymptoteSample {
rho: 1.0 + k as f64 * 0.1,
grad: 1.0e-12, coef_step_norm: 1.0e-9,
});
}
match assess_coordinate(&w, &tol(1.0)) {
AsymptoteVerdict::NoAsymptote { reason } => {
assert!(reason.contains("interior-stationary"), "{reason}");
}
other => panic!("interior point must not be an asymptote, got {other:?}"),
}
}
#[test]
fn exp4_verified_tail_certifies_and_noise_floor_refuses() {
let confirmed: [(f64, f64); 6] = [
(14.0, -5.589576e-03),
(16.0, -7.565987e-04),
(18.0, -1.023966e-04),
(20.0, -1.385843e-05),
(22.0, -1.874980e-06),
(24.0, -2.538059e-07),
];
let mut tail = AsymptoteWindow::with_capacity(confirmed.len());
let coef = [6.196e-05, 8.398e-06, 1.137e-06, 1.539e-07, 2.082e-08, 2.818e-09];
for (i, (rho, grad)) in confirmed.iter().enumerate() {
tail.push(AsymptoteSample {
rho: *rho,
grad: *grad,
coef_step_norm: coef[i],
});
}
let constants: Vec<f64> = tail
.samples()
.map(|s| AsymptoteSide::Upper.tail_constant(s.rho, s.grad))
.collect();
for c in &constants {
assert!(
(c - 6723.0).abs() / 6723.0 < 5.0e-3,
"exp4 pencil constant ĉ={c} should be ≈ 6723"
);
}
match assess_coordinate(&tail, &tol(1.0)) {
AsymptoteVerdict::CertifiedAtAsymptote { side, tail_constant, .. } => {
assert_eq!(side, AsymptoteSide::Upper);
assert!((tail_constant - 6723.0).abs() / 6723.0 < 5.0e-3);
}
other => panic!("exp4 confirmed-tail rows must certify, got {other:?}"),
}
let noise: [(f64, f64); 3] = [
(28.0, -4.547474e-09),
(30.0, -8.526513e-10),
(32.0, 1.421085e-10),
];
let mut floor = AsymptoteWindow::with_capacity(noise.len());
for (rho, grad) in noise.iter() {
floor.push(AsymptoteSample {
rho: *rho,
grad: *grad,
coef_step_norm: 1.0e-11,
});
}
let noise_tol = AsymptoteTolerances {
interior_grad_tol: 1.0e-13,
..tol(1.0)
};
match assess_coordinate(&floor, &noise_tol) {
AsymptoteVerdict::NoAsymptote { .. } => {}
other => panic!("the FD noise floor must NOT certify as a tail, got {other:?}"),
}
}
#[test]
fn too_few_samples_never_certify() {
let mut w = AsymptoteWindow::new();
w.push(AsymptoteSample {
rho: -7.0,
grad: 6723.0 * (-7.0_f64).exp(),
coef_step_norm: 1.0e-3,
});
w.push(AsymptoteSample {
rho: -7.5,
grad: 6723.0 * (-7.5_f64).exp(),
coef_step_norm: 6.0e-4,
});
match assess_coordinate(&w, &tol(1.0)) {
AsymptoteVerdict::NoAsymptote { reason } => {
assert!(reason.contains("too few samples"), "{reason}");
}
other => panic!("two samples must not certify, got {other:?}"),
}
}
}