diff --git a/src/adaptiverg_qec/autocorr.py b/src/adaptiverg_qec/autocorr.py index 5ff84eb..25493e9 100644 --- a/src/adaptiverg_qec/autocorr.py +++ b/src/adaptiverg_qec/autocorr.py @@ -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. @@ -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() @@ -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 @@ -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: @@ -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]}") diff --git a/src/adaptiverg_qec/clt.py b/src/adaptiverg_qec/clt.py index 1a23e15..db48f4e 100644 --- a/src/adaptiverg_qec/clt.py +++ b/src/adaptiverg_qec/clt.py @@ -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", @@ -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) @@ -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) @@ -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)) diff --git a/tests/test_autocorr.py b/tests/test_autocorr.py index 5ba7cb0..deb74c3 100644 --- a/tests/test_autocorr.py +++ b/tests/test_autocorr.py @@ -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) diff --git a/tests/test_clt.py b/tests/test_clt.py index a5ae538..f1ecd6b 100644 --- a/tests/test_clt.py +++ b/tests/test_clt.py @@ -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]