From 5c6b1be9892138e32eac5adf582a9d5b0641a117 Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Fri, 30 Oct 2026 05:09:29 +0100 Subject: [PATCH 1/5] Filter impossible value scales before full-tree blind leak matching --- src/sp_validation/tests/blind_scan.py | 15 ++- src/sp_validation/tests/test_blind_scan.py | 132 ++++++--------------- 2 files changed, 52 insertions(+), 95 deletions(-) diff --git a/src/sp_validation/tests/blind_scan.py b/src/sp_validation/tests/blind_scan.py index ba9b88aa..f888ba1c 100644 --- a/src/sp_validation/tests/blind_scan.py +++ b/src/sp_validation/tests/blind_scan.py @@ -104,10 +104,21 @@ def check(source, candidates, rtol=1e-10): raw = reference if expected is not None: raw = raw[~np.isclose(raw, expected[name], rtol=rtol, atol=1e-25)] + if not len(raw): + continue + # isclose(a, b) requires |a| within the extrema of + # |b| ± (atol + rtol*|b|). Geometry maps often contain millions + # of zeros/counts but no values on the shear signal's scale. + absolute = np.abs(np.asarray(raw, dtype=float)) + lower = np.nextafter( + max(0.0, absolute.min() * (1 - rtol) - 1e-25), -np.inf + ) + upper = np.nextafter(absolute.max() * (1 + rtol) + 1e-25, np.inf) + possible = values[(np.abs(values) >= lower) & (np.abs(values) <= upper)] # Chunk large FITS columns so a future larger fixture stays bounded. - for start in range(0, len(values), 4096): + for start in range(0, len(possible), 4096): match = np.isclose( - values[start : start + 4096, None], + possible[start : start + 4096, None], raw[None, :], rtol=rtol, atol=1e-25, diff --git a/src/sp_validation/tests/test_blind_scan.py b/src/sp_validation/tests/test_blind_scan.py index cf2fc7a3..00966f7c 100644 --- a/src/sp_validation/tests/test_blind_scan.py +++ b/src/sp_validation/tests/test_blind_scan.py @@ -1,109 +1,55 @@ -"""Exercise the leak scanner itself with trusted synthetic artifacts.""" - -import json -import pickle +"""Scale filtering speeds geometry scans without weakening the leak oracle.""" import numpy as np import pytest -from astropy.io import fits -from sp_validation import blinding, sacc_io from sp_validation.tests.blind_scan import assert_no_raw_values -RAW = np.array([1.23456789e-7, -7.65432198e-8, 4.32198765e-9]) +def test_geometry_values_are_filtered_before_pairwise_matching(tmp_path, monkeypatch): + np.save(tmp_path / "geometry.npy", np.tile([0.0, 1.0, 1000.0], 100_000)) + original = np.isclose + sizes = [] -@pytest.mark.parametrize( - "suffix", [".npz", ".npy", ".txt", ".json", ".pickle", ".fits", ".sacc"] -) -@pytest.mark.parametrize("indices", [[2, 0], [1]]) -def test_scanner_catches_subsets_and_reorderings(tmp_path, suffix, indices): - path = tmp_path / f"accidental{suffix}" - value = RAW[indices] - if suffix == ".npz": - np.savez(path, unrelated=value) - elif suffix == ".npy": - np.save(path, value) - elif suffix == ".txt": - np.savetxt(path, value, fmt="%.6e") - elif suffix == ".json": - path.write_text(json.dumps({"nested": {"unknown": value.tolist()}})) - elif suffix == ".pickle": - path.write_bytes(pickle.dumps({"nested": value})) - elif suffix == ".fits": - fits.HDUList( - [ - fits.PrimaryHDU(), - fits.BinTableHDU.from_columns( - [fits.Column(name="unexpected", format="D", array=value)] - ), - ] - ).writeto(path) - else: - part = sacc_io.new_sacc({0: ([0.1, 1.0], [1.0, 1.0])}) - sacc_io.add_xi( - part, (0, 0), np.arange(len(value)) + 1, value, value, grid="reporting" - ) - sacc_io.save(part, path, blind=blinding.NONE) - with pytest.raises( - AssertionError, match=f"Unblinded xi value in accidental{suffix}" - ): - assert_no_raw_values(tmp_path, {"xi": RAW}) + def observed(a, b, **kwargs): + sizes.append(np.asarray(a).size) + return original(a, b, **kwargs) + monkeypatch.setattr(np, "isclose", observed) + assert_no_raw_values( + tmp_path, {"EE": np.array([2e-11])}, expected={"EE": np.array([2.2e-11])} + ) + assert max(sizes) == 1 # The expected-twin comparison, no geometry chunks. -def test_scanner_catches_logs_and_returns_and_accepts_shifted_values(tmp_path): - np.savez(tmp_path / "sealed.npz", vector=RAW + 1e-6) - assert_no_raw_values(tmp_path, {"xi": RAW}) - with pytest.raises(AssertionError, match="captured stdout"): - assert_no_raw_values(tmp_path, {"xi": RAW}, logs=f"xi = {RAW[0]:.6e}") - with pytest.raises(AssertionError, match="held return"): + data = np.zeros(100_001) + data[-1] = 2e-11 + np.save(tmp_path / "single_leak.npy", data) + with pytest.raises(AssertionError, match="Unblinded EE value"): assert_no_raw_values( - tmp_path, {"xi": RAW}, objects=[("held return", RAW[::-1])] + tmp_path, + {"EE": np.array([2e-11])}, + expected={"EE": np.array([2.2e-11])}, ) -@pytest.mark.parametrize("persist", [False, True]) -def test_scanner_catches_raw_treecorr_objects(tmp_path, persist): - import treecorr - - gg = treecorr.GGCorrelation(min_sep=1, max_sep=10, nbins=3) - gg.xip[:] = RAW - objects = [("raw TreeCorr return", gg)] - if persist: - (tmp_path / "raw_correlation.pickle").write_bytes(pickle.dumps(gg)) - objects = [] - with pytest.raises(AssertionError, match="Unblinded xi value"): - assert_no_raw_values(tmp_path, {"xi": RAW}, objects=objects) - - -def test_scanner_keeps_weakly_shifted_binary_bins(tmp_path): - shifted = RAW * (1 + 1e-7) - expected = {"xi": shifted} - np.save(tmp_path / "sealed.npy", shifted) - # At six printed digits these twins cannot be distinguished. This must not - # hide their distinguishable binary values or falsely accuse safe text. - np.savetxt(tmp_path / "rounded.txt", shifted, fmt="%.6e") - assert_no_raw_values(tmp_path, {"xi": RAW}, expected=expected) - np.save(tmp_path / "single_raw_bin.npy", RAW[1:2]) - with pytest.raises(AssertionError, match="single_raw_bin.npy"): - assert_no_raw_values(tmp_path, {"xi": RAW}, expected=expected) - - -def test_scanner_catches_real_cosebis_log_precision(tmp_path): - references = {"cosebis/(5, 100)/chi2_E": np.array([12.345, 1e-4])} - expected = {"cosebis/(5, 100)/chi2_E": np.array([14.567, 2e-4])} - # The second pair both print 0.00: no scalar text oracle can distinguish it. - assert_no_raw_values(tmp_path, references, expected=expected, logs="chi2(E) = 0.00") - assert_no_raw_values( - tmp_path, references, expected=expected, logs="chi2(E) = 14.57" - ) - with pytest.raises(AssertionError, match="two-decimal chi2_E"): +@pytest.mark.parametrize("dtype", [np.float32, np.float64]) +@pytest.mark.parametrize("sign", [-1, 1]) +@pytest.mark.parametrize("fraction", [-1.001, -1.0, -0.999, 0, 0.999, 1.0, 1.001]) +def test_filter_preserves_isclose_boundary(tmp_path, dtype, sign, fraction): + reference = np.array([sign * 2e-11], dtype=dtype) + raw = float(reference[0]) + tolerance = 1e-25 + 1e-10 * abs(raw) + candidate = raw + fraction * tolerance + np.save(tmp_path / "value.npy", [candidate]) + matches = np.isclose( + np.array([candidate], dtype=float), reference, rtol=1e-10, atol=1e-25 + )[0] + if matches: + with pytest.raises(AssertionError, match="Unblinded EE value"): + assert_no_raw_values( + tmp_path, {"EE": reference}, expected={"EE": reference * 1.1} + ) + else: assert_no_raw_values( - tmp_path, references, expected=expected, logs="chi2(E) = 12.35" + tmp_path, {"EE": reference}, expected={"EE": reference * 1.1} ) - - -def test_scanner_refuses_unknown_binary_format(tmp_path): - (tmp_path / "unexpected.cache").write_bytes(b"\xff\xfe\x00\x80") - with pytest.raises(AssertionError, match="No leak decoder"): - assert_no_raw_values(tmp_path, {"xi": RAW}) From 968d995451b9262a04c11581e8970cff17300520 Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Fri, 30 Oct 2026 05:12:02 +0100 Subject: [PATCH 2/5] Keep existing leak-decoder coverage alongside scale-filter tests --- src/sp_validation/tests/test_blind_scan.py | 132 ++++++++++++------ .../tests/test_blind_scan_scales.py | 55 ++++++++ 2 files changed, 148 insertions(+), 39 deletions(-) create mode 100644 src/sp_validation/tests/test_blind_scan_scales.py diff --git a/src/sp_validation/tests/test_blind_scan.py b/src/sp_validation/tests/test_blind_scan.py index 00966f7c..cf2fc7a3 100644 --- a/src/sp_validation/tests/test_blind_scan.py +++ b/src/sp_validation/tests/test_blind_scan.py @@ -1,55 +1,109 @@ -"""Scale filtering speeds geometry scans without weakening the leak oracle.""" +"""Exercise the leak scanner itself with trusted synthetic artifacts.""" + +import json +import pickle import numpy as np import pytest +from astropy.io import fits +from sp_validation import blinding, sacc_io from sp_validation.tests.blind_scan import assert_no_raw_values +RAW = np.array([1.23456789e-7, -7.65432198e-8, 4.32198765e-9]) -def test_geometry_values_are_filtered_before_pairwise_matching(tmp_path, monkeypatch): - np.save(tmp_path / "geometry.npy", np.tile([0.0, 1.0, 1000.0], 100_000)) - original = np.isclose - sizes = [] - def observed(a, b, **kwargs): - sizes.append(np.asarray(a).size) - return original(a, b, **kwargs) +@pytest.mark.parametrize( + "suffix", [".npz", ".npy", ".txt", ".json", ".pickle", ".fits", ".sacc"] +) +@pytest.mark.parametrize("indices", [[2, 0], [1]]) +def test_scanner_catches_subsets_and_reorderings(tmp_path, suffix, indices): + path = tmp_path / f"accidental{suffix}" + value = RAW[indices] + if suffix == ".npz": + np.savez(path, unrelated=value) + elif suffix == ".npy": + np.save(path, value) + elif suffix == ".txt": + np.savetxt(path, value, fmt="%.6e") + elif suffix == ".json": + path.write_text(json.dumps({"nested": {"unknown": value.tolist()}})) + elif suffix == ".pickle": + path.write_bytes(pickle.dumps({"nested": value})) + elif suffix == ".fits": + fits.HDUList( + [ + fits.PrimaryHDU(), + fits.BinTableHDU.from_columns( + [fits.Column(name="unexpected", format="D", array=value)] + ), + ] + ).writeto(path) + else: + part = sacc_io.new_sacc({0: ([0.1, 1.0], [1.0, 1.0])}) + sacc_io.add_xi( + part, (0, 0), np.arange(len(value)) + 1, value, value, grid="reporting" + ) + sacc_io.save(part, path, blind=blinding.NONE) + with pytest.raises( + AssertionError, match=f"Unblinded xi value in accidental{suffix}" + ): + assert_no_raw_values(tmp_path, {"xi": RAW}) - monkeypatch.setattr(np, "isclose", observed) - assert_no_raw_values( - tmp_path, {"EE": np.array([2e-11])}, expected={"EE": np.array([2.2e-11])} - ) - assert max(sizes) == 1 # The expected-twin comparison, no geometry chunks. - data = np.zeros(100_001) - data[-1] = 2e-11 - np.save(tmp_path / "single_leak.npy", data) - with pytest.raises(AssertionError, match="Unblinded EE value"): +def test_scanner_catches_logs_and_returns_and_accepts_shifted_values(tmp_path): + np.savez(tmp_path / "sealed.npz", vector=RAW + 1e-6) + assert_no_raw_values(tmp_path, {"xi": RAW}) + with pytest.raises(AssertionError, match="captured stdout"): + assert_no_raw_values(tmp_path, {"xi": RAW}, logs=f"xi = {RAW[0]:.6e}") + with pytest.raises(AssertionError, match="held return"): assert_no_raw_values( - tmp_path, - {"EE": np.array([2e-11])}, - expected={"EE": np.array([2.2e-11])}, + tmp_path, {"xi": RAW}, objects=[("held return", RAW[::-1])] ) -@pytest.mark.parametrize("dtype", [np.float32, np.float64]) -@pytest.mark.parametrize("sign", [-1, 1]) -@pytest.mark.parametrize("fraction", [-1.001, -1.0, -0.999, 0, 0.999, 1.0, 1.001]) -def test_filter_preserves_isclose_boundary(tmp_path, dtype, sign, fraction): - reference = np.array([sign * 2e-11], dtype=dtype) - raw = float(reference[0]) - tolerance = 1e-25 + 1e-10 * abs(raw) - candidate = raw + fraction * tolerance - np.save(tmp_path / "value.npy", [candidate]) - matches = np.isclose( - np.array([candidate], dtype=float), reference, rtol=1e-10, atol=1e-25 - )[0] - if matches: - with pytest.raises(AssertionError, match="Unblinded EE value"): - assert_no_raw_values( - tmp_path, {"EE": reference}, expected={"EE": reference * 1.1} - ) - else: +@pytest.mark.parametrize("persist", [False, True]) +def test_scanner_catches_raw_treecorr_objects(tmp_path, persist): + import treecorr + + gg = treecorr.GGCorrelation(min_sep=1, max_sep=10, nbins=3) + gg.xip[:] = RAW + objects = [("raw TreeCorr return", gg)] + if persist: + (tmp_path / "raw_correlation.pickle").write_bytes(pickle.dumps(gg)) + objects = [] + with pytest.raises(AssertionError, match="Unblinded xi value"): + assert_no_raw_values(tmp_path, {"xi": RAW}, objects=objects) + + +def test_scanner_keeps_weakly_shifted_binary_bins(tmp_path): + shifted = RAW * (1 + 1e-7) + expected = {"xi": shifted} + np.save(tmp_path / "sealed.npy", shifted) + # At six printed digits these twins cannot be distinguished. This must not + # hide their distinguishable binary values or falsely accuse safe text. + np.savetxt(tmp_path / "rounded.txt", shifted, fmt="%.6e") + assert_no_raw_values(tmp_path, {"xi": RAW}, expected=expected) + np.save(tmp_path / "single_raw_bin.npy", RAW[1:2]) + with pytest.raises(AssertionError, match="single_raw_bin.npy"): + assert_no_raw_values(tmp_path, {"xi": RAW}, expected=expected) + + +def test_scanner_catches_real_cosebis_log_precision(tmp_path): + references = {"cosebis/(5, 100)/chi2_E": np.array([12.345, 1e-4])} + expected = {"cosebis/(5, 100)/chi2_E": np.array([14.567, 2e-4])} + # The second pair both print 0.00: no scalar text oracle can distinguish it. + assert_no_raw_values(tmp_path, references, expected=expected, logs="chi2(E) = 0.00") + assert_no_raw_values( + tmp_path, references, expected=expected, logs="chi2(E) = 14.57" + ) + with pytest.raises(AssertionError, match="two-decimal chi2_E"): assert_no_raw_values( - tmp_path, {"EE": reference}, expected={"EE": reference * 1.1} + tmp_path, references, expected=expected, logs="chi2(E) = 12.35" ) + + +def test_scanner_refuses_unknown_binary_format(tmp_path): + (tmp_path / "unexpected.cache").write_bytes(b"\xff\xfe\x00\x80") + with pytest.raises(AssertionError, match="No leak decoder"): + assert_no_raw_values(tmp_path, {"xi": RAW}) diff --git a/src/sp_validation/tests/test_blind_scan_scales.py b/src/sp_validation/tests/test_blind_scan_scales.py new file mode 100644 index 00000000..00966f7c --- /dev/null +++ b/src/sp_validation/tests/test_blind_scan_scales.py @@ -0,0 +1,55 @@ +"""Scale filtering speeds geometry scans without weakening the leak oracle.""" + +import numpy as np +import pytest + +from sp_validation.tests.blind_scan import assert_no_raw_values + + +def test_geometry_values_are_filtered_before_pairwise_matching(tmp_path, monkeypatch): + np.save(tmp_path / "geometry.npy", np.tile([0.0, 1.0, 1000.0], 100_000)) + original = np.isclose + sizes = [] + + def observed(a, b, **kwargs): + sizes.append(np.asarray(a).size) + return original(a, b, **kwargs) + + monkeypatch.setattr(np, "isclose", observed) + assert_no_raw_values( + tmp_path, {"EE": np.array([2e-11])}, expected={"EE": np.array([2.2e-11])} + ) + assert max(sizes) == 1 # The expected-twin comparison, no geometry chunks. + + data = np.zeros(100_001) + data[-1] = 2e-11 + np.save(tmp_path / "single_leak.npy", data) + with pytest.raises(AssertionError, match="Unblinded EE value"): + assert_no_raw_values( + tmp_path, + {"EE": np.array([2e-11])}, + expected={"EE": np.array([2.2e-11])}, + ) + + +@pytest.mark.parametrize("dtype", [np.float32, np.float64]) +@pytest.mark.parametrize("sign", [-1, 1]) +@pytest.mark.parametrize("fraction", [-1.001, -1.0, -0.999, 0, 0.999, 1.0, 1.001]) +def test_filter_preserves_isclose_boundary(tmp_path, dtype, sign, fraction): + reference = np.array([sign * 2e-11], dtype=dtype) + raw = float(reference[0]) + tolerance = 1e-25 + 1e-10 * abs(raw) + candidate = raw + fraction * tolerance + np.save(tmp_path / "value.npy", [candidate]) + matches = np.isclose( + np.array([candidate], dtype=float), reference, rtol=1e-10, atol=1e-25 + )[0] + if matches: + with pytest.raises(AssertionError, match="Unblinded EE value"): + assert_no_raw_values( + tmp_path, {"EE": reference}, expected={"EE": reference * 1.1} + ) + else: + assert_no_raw_values( + tmp_path, {"EE": reference}, expected={"EE": reference * 1.1} + ) From 6a4fd3609e9d9f4aabaf6670bbd0d2d9a256a4d2 Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Sat, 31 Oct 2026 00:07:51 +0100 Subject: [PATCH 3/5] blind_scan: non-finite reference entries no longer disable the scale filter Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JK7o9H5tLNFzcwRnYwMFEA --- src/sp_validation/tests/blind_scan.py | 2 ++ src/sp_validation/tests/test_blind_scan_scales.py | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/src/sp_validation/tests/blind_scan.py b/src/sp_validation/tests/blind_scan.py index f888ba1c..1b0867a1 100644 --- a/src/sp_validation/tests/blind_scan.py +++ b/src/sp_validation/tests/blind_scan.py @@ -104,6 +104,8 @@ def check(source, candidates, rtol=1e-10): raw = reference if expected is not None: raw = raw[~np.isclose(raw, expected[name], rtol=rtol, atol=1e-25)] + # A non-finite reference entry would make the scale window NaN. + raw = raw[np.isfinite(raw)] if not len(raw): continue # isclose(a, b) requires |a| within the extrema of diff --git a/src/sp_validation/tests/test_blind_scan_scales.py b/src/sp_validation/tests/test_blind_scan_scales.py index 00966f7c..579ade44 100644 --- a/src/sp_validation/tests/test_blind_scan_scales.py +++ b/src/sp_validation/tests/test_blind_scan_scales.py @@ -53,3 +53,9 @@ def test_filter_preserves_isclose_boundary(tmp_path, dtype, sign, fraction): assert_no_raw_values( tmp_path, {"EE": reference}, expected={"EE": reference * 1.1} ) + + +def test_non_finite_reference_entries_do_not_disable_the_scan(tmp_path): + np.save(tmp_path / "leak.npy", np.array([0.0, 2e-11])) + with pytest.raises(AssertionError, match="Unblinded EE value"): + assert_no_raw_values(tmp_path, {"EE": np.array([np.nan, 2e-11, np.inf])}) From cbd47aca7b354e94aedd56dedc9d9f6491e0e081 Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Sat, 31 Oct 2026 05:17:45 +0100 Subject: [PATCH 4/5] Judge JSON numbers in the blind scan at their written precision json.dumps writes floats at full precision, so a JSON value is a binary value unless its writer rounded it. Treating every JSON number as rounded text (rtol 5e-6) made coincidences fire: a rho/tau EIV fit residual on the GLASS mock matched an unrelated integration xi- bin to 8e-9. Numbers with more than 10 written digits now use the binary tolerance. Co-Authored-By: Claude Opus 5.5 --- src/sp_validation/tests/blind_scan.py | 40 ++++++++++++++-------- src/sp_validation/tests/test_blind_scan.py | 9 +++++ 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/src/sp_validation/tests/blind_scan.py b/src/sp_validation/tests/blind_scan.py index 1b0867a1..750e039d 100644 --- a/src/sp_validation/tests/blind_scan.py +++ b/src/sp_validation/tests/blind_scan.py @@ -83,6 +83,19 @@ def file_arrays(path): raise AssertionError(f"No leak decoder for {path}") from error +def _json_precision(text): + """Split a JSON document's numbers into rounded (<=10 digits) and exact.""" + rounded, exact = [], [] + + def parse(token): + digits = token.lower().split("e")[0].replace("-", "").replace(".", "") + (rounded if len(digits.lstrip("0")) <= 10 else exact).append(float(token)) + return float(token) + + json.loads(text, parse_float=parse, parse_int=parse) + return np.array(rounded), np.array(exact) + + def assert_no_raw_values(root, references, *, expected=None, objects=(), logs=""): """Find even reordered/single-value subsets using scale-sensitive allclose. @@ -131,20 +144,19 @@ def check(source, candidates, rtol=1e-10): ) for path in sorted(Path(root).rglob("*")): - if path.is_file(): - text = path.suffix.lower() in ( - ".txt", - ".dat", - ".csv", - ".json", - ".log", - ".yaml", - ) - check( - str(path.relative_to(root)), - file_arrays(path), - rtol=5e-6 if text else 1e-10, - ) + if not path.is_file(): + continue + source = str(path.relative_to(root)) + suffix = path.suffix.lower() + if suffix == ".json": + # JSON keeps each float's written digits: full-precision floats are + # binary values, short ones were rounded by their writer. + rounded, exact = _json_precision(path.read_text()) + check(source, [("rounded JSON numbers", rounded)], rtol=5e-6) + check(source, [("JSON numbers", exact)], rtol=1e-10) + continue + text = suffix in (".txt", ".dat", ".csv", ".log", ".yaml") + check(source, file_arrays(path), rtol=5e-6 if text else 1e-10) for label, value in objects: check(label, arrays(value)) check("captured stdout/stderr/logging", arrays(logs), rtol=5e-6) diff --git a/src/sp_validation/tests/test_blind_scan.py b/src/sp_validation/tests/test_blind_scan.py index cf2fc7a3..bafbf902 100644 --- a/src/sp_validation/tests/test_blind_scan.py +++ b/src/sp_validation/tests/test_blind_scan.py @@ -107,3 +107,12 @@ def test_scanner_refuses_unknown_binary_format(tmp_path): (tmp_path / "unexpected.cache").write_bytes(b"\xff\xfe\x00\x80") with pytest.raises(AssertionError, match="No leak decoder"): assert_no_raw_values(tmp_path, {"xi": RAW}) + + +def test_scanner_judges_json_numbers_at_their_written_precision(tmp_path): + near = RAW * (1 + 1e-8) # a coincidence no binary leak could produce + (tmp_path / "fit.json").write_text(json.dumps({"residual": near.tolist()})) + assert_no_raw_values(tmp_path, {"xi": RAW}) + (tmp_path / "rounded.json").write_text(json.dumps([round(float(RAW[0]), 13)])) + with pytest.raises(AssertionError, match="rounded JSON numbers"): + assert_no_raw_values(tmp_path, {"xi": RAW}) From 97999bbf0985217defeabcaea08ccde9e158a480 Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Sat, 31 Oct 2026 05:22:28 +0100 Subject: [PATCH 5/5] Treat float32 values in JSON as rounded in the blind scan A float32 value prints 16-17 digits but holds ~7, so a raw xi read from a float32 column and dumped would escape the binary tolerance. Co-Authored-By: Claude Opus 5.5 --- src/sp_validation/tests/blind_scan.py | 13 ++++++++++--- src/sp_validation/tests/test_blind_scan.py | 8 ++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/sp_validation/tests/blind_scan.py b/src/sp_validation/tests/blind_scan.py index 750e039d..d136f4c8 100644 --- a/src/sp_validation/tests/blind_scan.py +++ b/src/sp_validation/tests/blind_scan.py @@ -84,13 +84,20 @@ def file_arrays(path): def _json_precision(text): - """Split a JSON document's numbers into rounded (<=10 digits) and exact.""" + """Split a JSON document's numbers into rounded and exact. + + Rounded: at most 10 written digits, or a float32 value printed in full + (float32 holds ~7 digits however many repr prints). Exact: the rest. + """ rounded, exact = [], [] def parse(token): + value = float(token) digits = token.lower().split("e")[0].replace("-", "").replace(".", "") - (rounded if len(digits.lstrip("0")) <= 10 else exact).append(float(token)) - return float(token) + short = len(digits.lstrip("0")) <= 10 + single = np.isfinite(value) and float(np.float32(value)) == value + (rounded if short or single else exact).append(value) + return value json.loads(text, parse_float=parse, parse_int=parse) return np.array(rounded), np.array(exact) diff --git a/src/sp_validation/tests/test_blind_scan.py b/src/sp_validation/tests/test_blind_scan.py index bafbf902..5acffb00 100644 --- a/src/sp_validation/tests/test_blind_scan.py +++ b/src/sp_validation/tests/test_blind_scan.py @@ -116,3 +116,11 @@ def test_scanner_judges_json_numbers_at_their_written_precision(tmp_path): (tmp_path / "rounded.json").write_text(json.dumps([round(float(RAW[0]), 13)])) with pytest.raises(AssertionError, match="rounded JSON numbers"): assert_no_raw_values(tmp_path, {"xi": RAW}) + + +def test_scanner_catches_float32_values_in_json(tmp_path): + single = [float(np.float32(value)) for value in RAW[:1]] + assert len(json.dumps(single)) > 18 # printed in full, not rounded + (tmp_path / "column.json").write_text(json.dumps(single)) + with pytest.raises(AssertionError, match="rounded JSON numbers"): + assert_no_raw_values(tmp_path, {"xi": RAW})