Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions src/adaptiverg_qec/autocorr.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,18 @@
]


def _require_finite(values: np.ndarray, name: str) -> None:
"""Fail closed on NaN/inf at a public entry (Issue #46).

Geordnete Vergleiche mit NaN sind immer False; ein Waechter der Form
``if stat <= 0: raise`` liesse eine NaN-Reihe als "gueltig" durch und das
Ergebnis waere still NaN. Deshalb wird die Endlichkeit an JEDEM oeffentlichen
Eingang geprueft, nicht nur dort, wo sie zufaellig ueber einen Hilfsaufruf laeuft.
"""
if not np.all(np.isfinite(values)):
raise ValueError(f"{name} must be finite (NaN/inf found)")


def autocorr_function_fft(x: np.ndarray) -> np.ndarray:
"""Normierte Autokorrelationsfunktion rho(t), t=0..N-1, via FFT.

Expand All @@ -74,9 +86,13 @@ def autocorr_function_fft(x: np.ndarray) -> np.ndarray:
n = x.size
if n < 2:
raise ValueError(f"need >=2 samples for an autocorrelation, got {n}")
_require_finite(x, "samples")
xc = x - x.mean()
var0 = float(np.dot(xc, xc) / n) # = Var(x) (biased, = gamma(0))
if var0 <= 0.0:
if not np.isfinite(var0):
# endliche Samples, aber |x| ~ 1e308: die Quadratsumme laeuft ueber
raise ValueError("variance overflows float64; rescale the series")
if not (var0 > 0.0): # positiv formuliert: jeder nicht-positive Fall faellt hier
raise ValueError("constant series: Var(x)=0, autocorrelation undefined")
# Null-Padding auf >= 2N-1 (verhindert zyklische Faltung); naechste 2er-Potenz.
nfft = 1 << (2 * n - 1).bit_length()
Expand Down Expand Up @@ -143,10 +159,14 @@ def integrated_autocorr_time(
"""
x = np.asarray(x, dtype=np.float64).ravel()
n = x.size
if c_window <= 0:
_require_finite(x, "samples")
if not (c_window > 0): # NaN-sicher (Issue #46)
raise ValueError(f"c_window must be > 0, got {c_window}")
if rho is None:
rho = autocorr_function_fft(x)
else:
rho = np.asarray(rho, dtype=np.float64)
_require_finite(rho, "rho")
# Kumulative tau_int(W) = 0.5 + cumsum(rho[1:]); tau_of_w[k] = tau_int(W=k+1).
tau_of_w = 0.5 + np.cumsum(rho[1:])
n_w = tau_of_w.size
Expand Down Expand Up @@ -231,6 +251,7 @@ def binning_error(x: np.ndarray, *, max_block: int | None = None) -> BinningResu
"""
x = np.asarray(x, dtype=np.float64).ravel()
n = x.size
_require_finite(x, "samples")
if n < 64:
raise ValueError(f"need >=64 samples for a meaningful binning curve, got {n}")
if max_block is None:
Expand Down Expand Up @@ -321,6 +342,8 @@ def jackknife_ratio(
den_terms = np.asarray(den_terms, dtype=np.float64)
if num_terms.ndim != 2 or den_terms.ndim != 2:
raise ValueError("num_terms and den_terms must be 2D (N, k)")
_require_finite(num_terms, "num_terms")
_require_finite(den_terms, "den_terms")
n = num_terms.shape[0]
if den_terms.shape[0] != n:
raise ValueError(f"num/den sample count mismatch: {n} vs {den_terms.shape[0]}")
Expand Down
16 changes: 10 additions & 6 deletions src/adaptiverg_qec/clt.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@
import numpy as np
from scipy import stats

from .autocorr import integrated_autocorr_time
from .autocorr import _require_finite, integrated_autocorr_time

__all__ = [
"CLTResult",
Expand Down Expand Up @@ -133,6 +133,7 @@ def obm_variance(x: np.ndarray, *, batch_size: int | None = None) -> float:
n = x.size
if n < 4:
raise ValueError(f"need >=4 samples for OBM, got {n}")
_require_finite(x, "samples")
if batch_size is None:
batch_size = max(1, int(np.floor(np.sqrt(n))))
b = int(batch_size)
Expand Down Expand Up @@ -195,10 +196,13 @@ def confidence_interval(
"""
if not (0.0 < alpha < 1.0):
raise ValueError(f"alpha must be in (0,1), got {alpha}")
if n < 1:
if not (n >= 1): # NaN-sicher (#46)
raise ValueError(f"n must be >= 1, got {n}")
if sigma2_g < 0.0:
raise ValueError(f"sigma2_g must be >= 0, got {sigma2_g}")
# Issue #46: positiv formuliert und endlich -- ``sigma2_g < 0.0`` liess NaN durch.
if not (np.isfinite(sigma2_g) and sigma2_g >= 0.0):
raise ValueError(f"sigma2_g must be finite and >= 0, got {sigma2_g}")
if not np.isfinite(mean):
raise ValueError(f"mean must be finite, got {mean}")
z = float(stats.norm.ppf(1.0 - alpha / 2.0))
half = z * np.sqrt(sigma2_g / n)
return (mean - half, mean + half)
Expand All @@ -221,8 +225,8 @@ def ar1_clt_variance(phi: float, sigma_eps: float = 1.0) -> dict[str, float]:
"""
if not (-1.0 < phi < 1.0):
raise ValueError(f"AR(1) requires |phi| < 1, got {phi}")
if sigma_eps <= 0.0:
raise ValueError(f"sigma_eps must be > 0, got {sigma_eps}")
if not (np.isfinite(sigma_eps) and sigma_eps > 0.0): # NaN/inf-sicher (#46)
raise ValueError(f"sigma_eps must be finite and > 0, got {sigma_eps}")
var_marg = sigma_eps**2 / (1.0 - phi**2)
sigma2_g = sigma_eps**2 / (1.0 - phi) ** 2
tau_int = (1.0 + phi) / (2.0 * (1.0 - phi))
Expand Down
64 changes: 64 additions & 0 deletions tests/test_autocorr.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,3 +170,67 @@ def test_record_configs_default_off() -> None:
def test_invalid_inputs_raise(bad) -> None:
with pytest.raises((ValueError, TypeError)):
bad()


# --- Issue #46: non-finite samples must fail closed ---------------------------
# ``if var0 <= 0.0: raise`` rejected a constant series but let NaN through
# (nan <= 0.0 is False); integrated_autocorr_time then returned all-NaN results.
@pytest.mark.parametrize("bad", [float("nan"), float("inf"), float("-inf")])
@pytest.mark.parametrize("position", [0, -1])
def test_autocorr_rejects_non_finite_samples(bad: float, position: int) -> None:
x = np.random.default_rng(46).standard_normal(64)
x[position] = bad
with pytest.raises(ValueError, match="finite"):
autocorr.autocorr_function_fft(x)
with pytest.raises(ValueError, match="finite"):
autocorr.integrated_autocorr_time(x)


@pytest.mark.parametrize("c_window", [float("nan"), 0.0, -1.0])
def test_integrated_autocorr_time_rejects_invalid_window(c_window: float) -> None:
x = np.random.default_rng(46).standard_normal(64)
with pytest.raises(ValueError, match="c_window"):
autocorr.integrated_autocorr_time(x, c_window=c_window)


# --- #46/#47 review: every public entry, not only autocorr_function_fft -------
def _series_with(bad: float, n: int = 256) -> np.ndarray:
x = np.random.default_rng(47).standard_normal(n)
x[n // 2] = bad
return x


@pytest.mark.parametrize("bad", [float("nan"), float("inf")])
def test_binning_error_rejects_non_finite_samples(bad: float) -> None:
with pytest.raises(ValueError, match="finite"):
autocorr.binning_error(_series_with(bad))


def test_integrated_autocorr_time_checks_x_even_with_precomputed_rho() -> None:
good = np.random.default_rng(47).standard_normal(256)
rho = autocorr.autocorr_function_fft(good)
with pytest.raises(ValueError, match="finite"):
autocorr.integrated_autocorr_time(_series_with(float("nan")), rho=rho)


def test_integrated_autocorr_time_rejects_non_finite_rho() -> None:
x = np.random.default_rng(47).standard_normal(256)
rho = autocorr.autocorr_function_fft(x)
rho[5] = float("nan")
with pytest.raises(ValueError, match="finite"):
autocorr.integrated_autocorr_time(x, rho=rho)


def test_jackknife_ratio_rejects_non_finite_terms() -> None:
num = np.random.default_rng(47).standard_normal((64, 1))
den = np.ones((64, 1))
num[10, 0] = float("nan")
with pytest.raises(ValueError, match="finite"):
autocorr.jackknife_ratio(num, den, block_size=4, combine=lambda a, b: a[0] / b[0])


def test_autocorr_overflowing_series_is_not_reported_as_constant() -> None:
x = np.array([1e308, -1e308] * 32)
with pytest.raises(ValueError) as exc:
autocorr.autocorr_function_fft(x)
assert "constant" not in str(exc.value)
35 changes: 35 additions & 0 deletions tests/test_clt.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,38 @@ def test_clt_edge_inputs_raise() -> None:
clt.ar1_clt_variance(1.0) # |phi|>=1
with pytest.raises(ValueError):
clt.ar1_clt_variance(0.5, sigma_eps=0.0) # sigma_eps<=0


# --- Issue #46: NaN must not pass the sigma2_g guard -------------------------
@pytest.mark.parametrize(
("mean", "sigma2_g"),
[(0.0, float("nan")), (0.0, -1.0), (float("nan"), 1.0), (0.0, float("inf"))],
)
def test_confidence_interval_rejects_non_finite_or_negative_inputs(
mean: float, sigma2_g: float
) -> None:
with pytest.raises(ValueError):
clt.confidence_interval(mean, sigma2_g, 10)


def test_confidence_interval_accepts_zero_variance() -> None:
assert clt.confidence_interval(1.5, 0.0, 10) == (1.5, 1.5)


# --- #46/#47 review: remaining silent-NaN entries ---------------------------
def test_obm_variance_rejects_non_finite_samples() -> None:
x = np.random.default_rng(47).standard_normal(256)
x[100] = float("nan")
with pytest.raises(ValueError, match="finite"):
clt.obm_variance(x)


@pytest.mark.parametrize("sigma_eps", [float("nan"), float("inf"), 0.0, -1.0])
def test_ar1_clt_variance_rejects_invalid_sigma_eps(sigma_eps: float) -> None:
with pytest.raises(ValueError, match="sigma_eps"):
clt.ar1_clt_variance(0.5, sigma_eps)


def test_confidence_interval_rejects_nan_n() -> None:
with pytest.raises(ValueError):
clt.confidence_interval(0.0, 1.0, float("nan")) # type: ignore[arg-type]
Loading