From c9a4f025b4ca07b1fc2addcc68a1f11adb8b7776 Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Thu, 16 Jul 2026 11:40:12 +0200 Subject: [PATCH 1/9] refactor(blinding): stack on PR-2's canonical sacc_io; sweep grid vocabulary Rebuild the per-part-at-birth blinding stack on top of feat/sacc-2-sacc-io. sacc_io.py is now PR-2's canonical module verbatim + a single appended gather(): the terminal assembly delegates its tracer/point/covariance assembly to PR-2's merge() and adds only the blind-custody call (assert_consistent_blind) and shared-stamp write. The fail-closed load gate lives in sacc_io.load() per the PRD ("sacc_io fails closed on load"); the blinding tooling uses allow_unblinded=True explicitly where it must read a not-yet-blinded real vector (blind_part). save() now carries the required type= (inherited from each part's provenance). Grid vocabulary swept coarse->reporting, fine->integration across blinding.py, blinding_theory.py, b_modes.py, blind_data_vector.py, and the blinding tests. _extract_block/_set_values stay index-based (a code comment says why): Smokescreen's ConcealDataVector aligns theory_fn output to the sub-SACC mean by row position, so the block must be carved/written by contiguous integer index, not by PR-2's tag-matching extract()/update_statistic(). Escrow/custody/CAMB<->CCL machinery is preserved exactly. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WzUt7VbtXwr2SCHUdiQTyt --- scripts/blind_data_vector.py | 173 +++ src/sp_validation/b_modes.py | 73 ++ src/sp_validation/blinding.py | 819 ++++++++++++ src/sp_validation/blinding_theory.py | 424 +++++++ src/sp_validation/sacc_io.py | 47 + src/sp_validation/tests/test_blinding.py | 1110 +++++++++++++++++ .../tests/test_camb_ccl_crosscheck.py | 174 +++ 7 files changed, 2820 insertions(+) create mode 100644 scripts/blind_data_vector.py create mode 100644 src/sp_validation/blinding.py create mode 100644 src/sp_validation/blinding_theory.py create mode 100644 src/sp_validation/tests/test_blinding.py create mode 100644 src/sp_validation/tests/test_camb_ccl_crosscheck.py diff --git a/scripts/blind_data_vector.py b/scripts/blind_data_vector.py new file mode 100644 index 00000000..9b41109d --- /dev/null +++ b/scripts/blind_data_vector.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 + +"""Script blind_data_vector.py + +Per-part data-vector blinding with :mod:`sp_validation.blinding` +(Smokescreen-fork concealment, hash-commitment custody). + +``blind-init`` runs once per catalogue version: it draws an OS-entropy seed +(never printed, never written in plaintext), publishes a repo-committable +``commitment.json`` (``sha256(seed)`` + config digest), and encrypts the seed +into a Fernet bundle. ``blind-part`` blinds one intermediate part SACC +(reporting ξ±, integration ξ±, or pseudo-Cℓ) under that fixed state, escrows +the true vector into a per-part encrypted bundle beside the blinded output, +and deletes the plaintext part. ``unblind`` verifies both commitment hashes +and restores a true part (bit-for-bit when the part's escrow bundle is beside +it); it also works on the assembled ``{version}.sacc`` (integration rows +selected by the ``grid`` tag). ``verify`` is a cheap, seedless check that a +blinded file matches a commitment. + +:Authors: Cail Daley + +Examples +-------- +Once per catalogue version:: + + blind_data_vector.py blind-init blinded/ + +Per intermediate part, at birth:: + + blind_data_vector.py blind-part parts/xi_integration.fits --blind-dir blinded/ + +Unblind one part:: + + blind_data_vector.py unblind parts/xi_integration_blinded.fits \\ + --blind-dir blinded/ -o parts/xi_integration.fits + +Verify:: + + blind_data_vector.py verify parts/xi_integration_blinded.fits \\ + blinded/commitment.json +""" + +import argparse +import json +import pathlib +import sys + +from sp_validation import blinding, sacc_io + + +def _config_from_args(args): + """A :class:`blinding.BlindingConfig` from optional CLI overrides.""" + overrides = {} + if args.s8_half_width is not None: + overrides["s8_half_width"] = args.s8_half_width + if args.omega_m_half_width is not None: + overrides["omega_m_half_width"] = args.omega_m_half_width + return blinding.BlindingConfig.from_overrides(overrides) + + +def _blind_init(args): + config = _config_from_args(args) + blind_dir = pathlib.Path(args.blind_dir) + blind_dir.mkdir(parents=True, exist_ok=True) + # Refuse before drawing anything: a blind is a one-shot custody event and + # silently overwriting a previous blind's state would destroy the record + # tying that blind to its seed. + clashes = [ + p + for p in blinding.init_paths(str(blind_dir)).values() + if pathlib.Path(p).exists() + ] + if clashes: + raise SystemExit( + "refusing to overwrite existing blind state:\n " + + "\n ".join(clashes) + + "\nPick a fresh --blind-dir (never overwrite a blind)." + ) + blinding.blind_init(str(blind_dir), config=config, label=args.label) + print( + "Commit the commitment JSON to the repo; keep the bundle + key safe " + "and separated (colocation in the blind dir is not at-rest protection)." + ) + + +def _blind_part(args): + blinding.blind_part( + args.part, + args.blind_dir, + config=_config_from_args(args), + keep_input=args.keep_input, + ) + + +def _unblind(args): + blinding.unblind_part( + args.blinded, + args.blind_dir, + args.output, + config=_config_from_args(args), + ) + + +def _verify(args): + """Seedless check: blinded-file metadata ↔ commitment JSON.""" + s = sacc_io.load(args.blinded) + with open(args.commitment, encoding="utf-8") as f: + commitment = json.load(f) + problems = [] + if not s.metadata.get("concealed"): + problems.append("file is not marked concealed") + if s.metadata.get("blind_commitment") != commitment["seed_sha256"]: + problems.append("blind_commitment does not match the committed sha256(seed)") + if s.metadata.get("blind_config_digest") != commitment["config_digest"]: + problems.append("blind_config_digest does not match the committed digest") + if "seed_smokescreen" in s.metadata: + problems.append("PLAINTEXT SEED LEAKED into file metadata (seed_smokescreen)") + if problems: + raise SystemExit("verification FAILED:\n " + "\n ".join(problems)) + print( + f"OK: {args.blinded} matches {args.commitment} " + f"(blind {s.metadata.get('blind')!r})" + ) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[1]) + sub = parser.add_subparsers(dest="mode", required=True) + + for name in ("blind-init", "blind-part", "unblind"): + p = sub.add_parser(name) + p.add_argument("--s8-half-width", type=float, default=None) + p.add_argument("--omega-m-half-width", type=float, default=None) + if name == "blind-init": + p.add_argument( + "blind_dir", + help="directory for the blind's fixed state (commitment + " + "encrypted seed bundle)", + ) + p.add_argument("--label", default="A", help="blind label (default A)") + p.set_defaults(func=_blind_init) + elif name == "blind-part": + p.add_argument("part", help="intermediate part SACC file to blind") + p.add_argument( + "--blind-dir", required=True, help="blind-init state directory" + ) + p.add_argument( + "--keep-input", + action="store_true", + help="retain the plaintext input part (default: delete it " + "after blinding — the true vector is escrowed beside the " + "blinded output)", + ) + p.set_defaults(func=_blind_part) + else: + p.add_argument("blinded", help="blinded part (or assembled) SACC file") + p.add_argument( + "--blind-dir", required=True, help="blind-init state directory" + ) + p.add_argument("-o", "--output", required=True, help="output SACC path") + p.set_defaults(func=_unblind) + + p = sub.add_parser("verify") + p.add_argument("blinded", help="blinded SACC file") + p.add_argument("commitment", help="commitment JSON") + p.set_defaults(func=_verify) + + args = parser.parse_args(argv) + args.func(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/sp_validation/b_modes.py b/src/sp_validation/b_modes.py index 13f4c2e9..97251c3f 100644 --- a/src/sp_validation/b_modes.py +++ b/src/sp_validation/b_modes.py @@ -255,6 +255,79 @@ def pure_EB(corrs): return results +def cosebis_from_xi(theta, xip, xim, nmodes, scale_cut=None): + """COSEBIs (Eₙ, Bₙ) from ξ± arrays through the pipeline kernel (values only). + + The values-only seam of :func:`calculate_cosebis`, for callers holding + ξ± arrays rather than a TreeCorr ``GGCorrelation`` — e.g. deriving + born-blinded COSEBIs from a blinded integration-ξ± SACC part. Calls the same + ``cosmo_numba`` kernel (``COSEBIS.cosebis_from_xipm``) directly on the + values; the covariance/χ² machinery stays with :func:`calculate_cosebis`. + + ``scale_cut`` follows the :func:`sacc_io.add_cosebis` writer contract: + ``(theta_min, theta_max)`` are min/max of the *retained* bin centres + after the pipeline's ``scale_cut_to_bins``. The cut is contiguous in an + ascending grid, so selecting ``theta_min ≤ θ ≤ theta_max`` inclusively + reproduces exactly the retained set, and the kernel is built on that + set's min/max support and fed only the retained ξ± — bit-matching + :func:`calculate_cosebis`'s ``theta_cut``/``xip_cut``/``xim_cut`` path. + Identical inputs ⇒ identical numbers. + """ + from cosmo_numba.B_modes.cosebis import COSEBIS + + theta, xip, xim = (np.asarray(a) for a in (theta, xip, xim)) + tmin, tmax = scale_cut if scale_cut is not None else (theta.min(), theta.max()) + cut = (theta >= tmin) & (theta <= tmax) + theta_cut, xip_cut, xim_cut = theta[cut], xip[cut], xim[cut] + cosebis = COSEBIS( + theta_min=np.min(theta_cut), + theta_max=np.max(theta_cut), + N_max=nmodes, + precision=120, + ) + En, Bn = cosebis.cosebis_from_xipm(theta_cut, xip_cut, xim_cut, parallel=True) + return np.asarray(En), np.asarray(Bn) + + +def pure_eb_from_xi( + theta_report, xip_report, xim_report, theta_int, xip_int, xim_int, tmin, tmax +): + """Pure-E/B correlation functions from ξ± arrays through the pipeline kernel. + + The values-only seam of :func:`calculate_pure_eb_correlation`, for + callers holding ξ± arrays rather than TreeCorr correlations — e.g. + deriving born-blinded pure-E/B from blinded SACC parts. Calls the same + ``cosmo_numba`` kernel (``get_pure_EB_modes``) directly on the values. + The reporting grid must be a strict sub-range of the integration grid; + ``tmin``/``tmax`` are the reporting correlation's TreeCorr *bin edges* + (``gg.left_edges[0]`` / ``gg.right_edges[-1]``) — the pipeline's + convention, carried on SACC files by ``sacc_io.add_pure_eb``. A + reporting point coinciding with the integration boundary is degenerate + (no interior support) and comes back NaN, exactly as + :func:`calculate_pure_eb_correlation` returns it — never a spurious + finite value. + + Returns + ------- + dict + Keyed by ``_EB_KEYS`` (xip_E, xim_E, xip_B, xim_B, xip_amb, xim_amb). + """ + from cosmo_numba.B_modes.schneider2022 import get_pure_EB_modes + + modes = get_pure_EB_modes( + theta=np.asarray(theta_report), + xip=np.asarray(xip_report), + xim=np.asarray(xim_report), + theta_int=np.asarray(theta_int), + xip_int=np.asarray(xip_int), + xim_int=np.asarray(xim_int), + tmin=tmin, + tmax=tmax, + parallel=True, + ) + return dict(zip(_EB_KEYS, (np.asarray(m) for m in modes))) + + def calculate_cosebis(gg, nmodes=10, scale_cuts=None, cov_path=None): """ Calculate COSEBIs modes from a correlation function for multiple scale cuts. diff --git a/src/sp_validation/blinding.py b/src/sp_validation/blinding.py new file mode 100644 index 00000000..972fb704 --- /dev/null +++ b/src/sp_validation/blinding.py @@ -0,0 +1,819 @@ +"""Blinding — conceal each intermediate data product behind a hidden cosmology. + +:Name: blinding.py + +:Description: Smokescreen blinding wiring, per part, at birth. Each blindable + intermediate SACC product — reporting ξ±, integration ξ±, pseudo-Cℓ — is shifted, + the moment the pipeline computes it, by a difference of theory vectors + between the fiducial cosmology and a *hidden* cosmology drawn inside a + fixed amplitude envelope, so no one can read S8 off the data until the + collaboration agrees to unblind (Muir et al. 2019: + ``d → d + t(hidden) − t(fiducial)``). Only blinded parts persist on disk. + + **The fork is the concealment engine.** The hidden cosmology is drawn by + the ``UNIONS-WL/Smokescreen`` fork's own CCL-native, per-key-independent + draw; each part goes through its ``ConcealDataVector(fiducial_params, + shifts_dict, sacc_data, *, seed, theory_fn)`` entry point. This module + supplies the two sp_validation-specific pieces: the three ``theory_fn`` + backends matching each part's row layout (reporting ξ±, integration ξ±, pseudo-Cℓ) + and the SACC handling around the concealed vectors. + + **Envelope calibration.** The blinding intent is an amplitude smear of a + chosen (S8, Ωm) box. The fork draws in CCL-native primitives, so + :meth:`BlindingConfig.shifts_dict` maps the intended box to + ``{sigma8, Omega_c}`` half-widths evaluated at the fiducial — + ``σ8 = S8/√(Ωm/0.3)``, ``Ω_c = Ωm − Ω_b − Ω_ν``. The same seed yields + the same hidden cosmology across all part passes (the fork's draw depends + only on ``(key, seed)``), so the blinded parts are mutually consistent by + construction. + + **Derived statistics are born blinded.** COSEBIs and pure-E/B are never + touched by this module: the pipeline's own estimators + (:mod:`sp_validation.b_modes`) run in their normal downstream place on + the already-blinded integration ξ± part, so their outputs are born blinded. + Covariance and ρ/τ PSF diagnostics are never blinded — blinding hides + the vector, not the uncertainty, and the shift is pure E-mode so the + B-mode null tests stay honest under the blind. + + **Custody: hash commitment, no keyholder.** :func:`blind_init` runs once + per catalogue version: it draws an OS-entropy seed, publishes + ``sha256(seed)`` plus a canonical config digest as a repo-committable + ``commitment.json``, and encrypts the seed into a Fernet bundle + (``smokescreen.encryption``) — the plaintext seed is never written. + Each :func:`blind_part` call reads that fixed state, conceals one part, + escrows the part's true vector into its own encrypted bundle beside the + blinded output, and deletes the plaintext part. Terminal assembly + (:func:`sp_validation.sacc_io.gather`) calls + :func:`assert_consistent_blind` to fail closed unless every blindable + part carries the identical ``blind_commitment``. :func:`unblind_part` + verifies both hashes against the commitment *before* subtracting + anything, then restores the true part. +""" + +import dataclasses +import hashlib +import json +import os +import secrets +import warnings + +import numpy as np + +from . import sacc_io +from .blinding_theory import TheoryConfig, cl_ee, xi_ccl, xi_ell_grid + + +# --------------------------------------------------------------------------- # +# Configuration surface — the blinding envelope +# --------------------------------------------------------------------------- # +@dataclasses.dataclass(frozen=True) +class BlindingConfig: + """Blinding envelope and fiducial. + + The hidden cosmology is drawn (by the fork) uniformly and independently + per key inside ``shifts_dict()``'s half-widths about the fiducial. The + half-widths are the deliberate, configurable size of the blind — config, + not code; the group may resize the envelope. ``theory`` carries the + fiducial :class:`TheoryConfig` whose defaults *are* the blinding + fiducial. + """ + + s8_half_width: float = 0.075 + omega_m_half_width: float = 0.1 + theory: TheoryConfig = dataclasses.field(default_factory=TheoryConfig) + + def shifts_dict(self): + """The (S8, Ωm) envelope as CCL-native ``{sigma8, Omega_c}`` half-widths. + + Evaluated at the fiducial: a ΔS8 half-width maps to + ``ΔS8/√(Ωm_fid/0.3)`` in σ8 (at fixed Ωm), and a ΔΩm half-width maps + one-to-one to Ω_c (Ω_b and Ω_ν are fixed). Exact enough for a + blinding smear — the target is a characteristic amplitude, not a + precise (S8, Ωm) posterior. The fork draws each key independently as + ``U(fid − h, fid + h)``. + """ + return { + "sigma8": self.s8_half_width / np.sqrt(self.theory.Omega_m / 0.3), + "Omega_c": self.omega_m_half_width, + } + + def config_digest(self): + """sha256 of a canonical serialization of the full blinding config. + + Binds the envelope half-widths, the complete fiducial + :class:`TheoryConfig` (cosmology + the two ``halofit_version`` + tokens + IA fields) into one digest: JSON with sorted keys over the + full ordered field set. Every ``float``-declared field is coerced + through :func:`float` before serialization, so the digest depends on + the numeric *value*, not on whether a config wrote ``-1`` or ``-1.0`` + — an int-vs-float literal difference (routine when configs come from + YAML/CLI/humans) can no longer split one physical cosmology into two + digests and deny a legitimate unblind. Python's ``json`` then emits + each float via its shortest round-trip ``repr``, so two runs of the + same config produce byte-identical digests. Checked (with + ``sha256(seed)``) at unblind, so a wrong envelope or a mismatched + P(k) recipe cannot silently subtract a wrong shift. + """ + payload = { + "s8_half_width": float(self.s8_half_width), + "omega_m_half_width": float(self.omega_m_half_width), + "theory": { + f.name: ( + float(getattr(self.theory, f.name)) + if f.type in (float, "float") + else getattr(self.theory, f.name) + ) + for f in dataclasses.fields(self.theory) + }, + } + return hashlib.sha256( + json.dumps(payload, sort_keys=True).encode("utf-8") + ).hexdigest() + + @classmethod + def from_overrides(cls, overrides): + """Build from a mapping of field overrides (fail loud on unknown keys). + + ``theory`` may be given as a :class:`TheoryConfig` or a mapping of + TheoryConfig overrides, mirroring :meth:`TheoryConfig.from_overrides`. + """ + by_name = {f.name: f for f in dataclasses.fields(cls)} + unknown = set(overrides) - set(by_name) + if unknown: + raise ValueError( + f"unknown BlindingConfig fields {sorted(unknown)}; " + f"valid fields are {sorted(by_name)}" + ) + overrides = { + name: (float(v) if by_name[name].type in (float, "float") else v) + for name, v in overrides.items() + } + theory = overrides.get("theory") + if theory is not None and not isinstance(theory, TheoryConfig): + overrides["theory"] = TheoryConfig.from_overrides(dict(theory)) + return cls(**overrides) + + +# --------------------------------------------------------------------------- # +# Custody primitives +# --------------------------------------------------------------------------- # +def seed_commitment(seed): + """Public commitment for a seed: its sha256 hex digest. + + Safe to publish and commit to the repo — it ties a blinded file to its + blind without revealing the seed, and lets unblind refuse a wrong seed. + """ + return hashlib.sha256(seed.encode("utf-8")).hexdigest() + + +def hidden_params(seed, config): + """The hidden CCL parameter point the fork realizes for ``(seed, config)``. + + Re-runs the fork's own draw (``smokescreen.param_shifts.draw_param_shifts`` + — per-key-independent, local RNG) on the calibrated envelope and overlays + the deltas on the fiducial, exactly as ``ConcealDataVector`` does + internally. Deterministic: same ``(seed, config)`` ⇒ same hidden point, + forever — the reproducibility contract unblinding relies on, and what + makes every part share one hidden cosmology under one seed. + """ + from smokescreen.param_shifts import draw_param_shifts + + deltas = draw_param_shifts(config.shifts_dict(), seed) + params = dict(config.theory.ccl_params()) + for key, delta in deltas.items(): + params[key] += delta + return params + + +# --------------------------------------------------------------------------- # +# Block discovery on a SACC (a standalone part, or the assembled file) +# --------------------------------------------------------------------------- # +def source_bins(s): + """Sorted source-bin indices present in ``s`` (from ``source_i`` tracers).""" + return sorted( + int(name.split("_", 1)[1]) for name in s.tracers if name.startswith("source_") + ) + + +def xi_pairs(s, grid): + """Unordered source-bin pairs ``(i ≤ j)`` carrying ξ+ on ``grid``.""" + bins = source_bins(s) + return [ + (i, j) + for a, i in enumerate(bins) + for j in bins[a:] + if len(s.indices(sacc_io.XI_PLUS, sacc_io._pair((i, j)), grid=grid)) + ] + + +def cl_pairs(s): + """Source-bin pairs ``(i ≤ j)`` carrying pseudo-Cℓ_EE.""" + bins = source_bins(s) + return [ + (i, j) + for a, i in enumerate(bins) + for j in bins[a:] + if len(s.indices(sacc_io.CL_EE, sacc_io._pair((i, j)))) + ] + + +def _xi_indices(s, grid): + """Row indices of the ξ± block on ``grid`` (ascending).""" + return np.sort( + np.concatenate( + [ + s.indices(sacc_io.XI_PLUS, grid=grid), + s.indices(sacc_io.XI_MINUS, grid=grid), + ] + ) + ).astype(int) + + +def _cl_ee_indices(s): + """Row indices of the pseudo-Cℓ_EE block (ascending). + + Only EE: a pure E-mode cosmology shift leaves BB and EB identically + zero, so those blocks are never extracted, never concealed. + """ + return np.sort(s.indices(sacc_io.CL_EE)).astype(int) + + +def _pair_nz(s, i, j): + """The two per-bin n(z) for pair ``(i, j)`` as ``((z_i, n_i), (z_j, n_j))``.""" + z_i, n_i = sacc_io.get_nz(s, i) + z_j, n_j = sacc_io.get_nz(s, j) + return (np.asarray(z_i), np.asarray(n_i)), (np.asarray(z_j), np.asarray(n_j)) + + +# --------------------------------------------------------------------------- # +# The three theory backends — callables aligned to a sub-SACC block's rows +# --------------------------------------------------------------------------- # +def xi_theory_fn(block, theory, grid): + """``theory_fn`` for a ξ± sub-SACC block (reporting or integration grid). + + Reads each bin's n(z) directly from the block's own tracers and lays the + output out to match the block's SACC rows element-for-element: for every + pair, ξ± is computed at that pair's stored θ (the ``theta`` tag, + arcmin) and scattered to the rows ``block.indices`` reports — the + block's own row order, never an assumed pairing. IA enters from + ``theory``'s NLA fields, identically at every parameter point. + """ + pairs = xi_pairs(block, grid) + layout = [] + for i, j in pairs: + tr = sacc_io._pair((i, j)) + idx_p = block.indices(sacc_io.XI_PLUS, tr, grid=grid) + idx_m = block.indices(sacc_io.XI_MINUS, tr, grid=grid) + theta = sacc_io._tag(block, sacc_io.XI_PLUS, tr, "theta", grid=grid) + layout.append(((i, j), idx_p, idx_m, np.asarray(theta, dtype=float))) + ell = xi_ell_grid() + + def theory_fn(params): + out = np.full(len(block.mean), np.nan) + for (i, j), idx_p, idx_m, theta in layout: + nz_i, nz_j = _pair_nz(block, i, j) + xip, xim = xi_ccl(params, theory, nz_i, nz_j, theta, ell) + out[idx_p] = xip + out[idx_m] = xim + return out + + return theory_fn + + +def cl_theory_fn(block, theory): + """``theory_fn`` for the pseudo-Cℓ_EE sub-SACC block. + + Per pair: theory Cℓ_EE on the stored ``BandpowerWindows`` support, + binned by the same window matrix the measurement used (``W @ Cℓ_EE``), + scattered to the block's own rows. The concealing factor the fork forms + from this is ``W @ ΔCℓ_EE`` — the shift lands in the measured + bandpowers; ΔBB = ΔEB ≡ 0 by construction (pure E-mode shift), so those + blocks are simply not part of this backend's rows. + """ + layout = [] + for i, j in cl_pairs(block): + tr = sacc_io._pair((i, j)) + idx = block.indices(sacc_io.CL_EE, tr) + window = block.get_bandpower_windows(idx) + layout.append( + ( + (i, j), + np.asarray(idx), + np.asarray(window.values, dtype=float), # (n_ell,) + np.asarray(window.weight, dtype=float), # (n_ell, n_bp) + ) + ) + + def theory_fn(params): + out = np.full(len(block.mean), np.nan) + for (i, j), idx, w_ell, w_mat in layout: + nz_i, nz_j = _pair_nz(block, i, j) + out[idx] = w_mat.T @ cl_ee(params, theory, nz_i, nz_j, w_ell) + return out + + return theory_fn + + +# --------------------------------------------------------------------------- # +# Extract → conceal → merge, per block +# --------------------------------------------------------------------------- # +def _blindable_blocks(s): + """The blindable blocks of a SACC as ``(name, indices, factory)``. + + Works identically on a standalone part (which carries exactly one block) + and on the assembled one-file product (whose integration rows are selected by + the ``grid`` tag — the layout contract's per-block tag selection). + ``indices`` are each block's recorded row indices (ascending, so the + extracted sub-SACC preserves row order); ``factory`` builds the matching + ``theory_fn`` from the extracted sub-SACC. Blocks absent from the file + are simply not listed. + """ + blocks = [] + for grid in ("reporting", "integration"): + idx = _xi_indices(s, grid) + if len(idx): + blocks.append( + ( + f"{grid} ξ±", + idx, + lambda sub, theory, grid=grid: xi_theory_fn(sub, theory, grid), + ) + ) + idx = _cl_ee_indices(s) + if len(idx): + blocks.append(("pseudo-Cℓ_EE", idx, cl_theory_fn)) + return blocks + + +def _extract_block(s, indices): + """Extract rows ``indices`` (ascending) into a sub-SACC, order preserved. + + Deliberately index-based rather than ``sacc_io.extract`` / + ``update_statistic`` (which select and merge by ``(data_type, tracers, + tags)``): Smokescreen's ``ConcealDataVector`` aligns its ``theory_fn`` + output to the sub-SACC's ``mean`` element-for-element by row position, so + the block must be carved and written back by contiguous integer index, not + by tag-matching. This is the one place the row-index path is load-bearing. + """ + sub = s.copy() + sub.keep_indices(np.asarray(indices, dtype=int)) + return sub + + +def _concealing_factor(s, indices, factory, config, seed): + """The fork-computed additive concealing factor for one block. + + Extracts the block into a sub-SACC containing exactly the rows the + ``theory_fn`` spans (the fork's length guard enforces the agreement), + drives ``ConcealDataVector`` with the fiducial point, the calibrated + envelope, and the block's ``theory_fn``, and returns the factor + ``t(hidden) − t(fiducial)`` aligned to ``indices``. + """ + from smokescreen import ConcealDataVector + + sub = _extract_block(s, indices) + smoke = ConcealDataVector( + config.theory.ccl_params(), + config.shifts_dict(), + sub, + seed=seed, + theory_fn=factory(sub, config.theory), + ) + smoke.calculate_concealing_factor(factor_type="add") + concealed = smoke.apply_concealing_to_likelihood_datavec() + return np.asarray(concealed, dtype=float) - np.asarray(sub.mean, dtype=float) + + +def _set_values(s, indices, values): + """Overwrite ``s.data[i].value`` for ``indices`` with ``values`` (aligned).""" + for i, v in zip(indices, values): + s.data[int(i)].value = float(v) + + +def _concealed(s): + """Whether ``s`` is already a blinded file (its ``concealed`` mark is set).""" + return bool(s.metadata.get("concealed")) + + +def blind_sacc(part, seed, config=None, label="A", log=print): + """Return a blinded copy of a part SACC (covariance and tags untouched). + + Per blindable block present (a standalone part carries exactly one — + reporting ξ±, integration ξ±, or pseudo-Cℓ_EE): extract the block into a sub-SACC, + conceal through the fork, write the shifted values back at their + recorded indices (row order preserved). Provenance is stamped and any + leaked seed key stripped. A file with no blindable block (e.g. a ρ/τ + diagnostic part) is refused loudly — it should never see a blind call. + """ + config = config or BlindingConfig() + if _concealed(part): + raise ValueError("already concealed — unblind first") + blocks = _blindable_blocks(part) + if not blocks: + raise ValueError( + "no blindable block (reporting/integration ξ± or pseudo-Cℓ_EE) in this SACC " + "— ρ/τ diagnostic parts are never blinded" + ) + + blinded = part.copy() + for name, indices, factory in blocks: + factor = _concealing_factor(part, indices, factory, config, seed) + _set_values(blinded, indices, np.asarray(blinded.mean)[indices] + factor) + log(f"[blind] {name}: shifted {len(indices)} points via Smokescreen fork") + + _stamp_provenance(blinded, seed_commitment(seed), label, config.config_digest()) + return blinded + + +def unblind_sacc(blinded, seed, config=None, log=print): + """Recover the true part SACC from a blinded one + the revealed ``seed``. + + Verifies ``sha256(seed)`` and the config digest against the stamped + metadata (loud failure on either mismatch — verification precedes + subtraction), recomputes each block's shift from the seed through the + same backends, and subtracts it. Works on a standalone part or on the + assembled file (integration rows selected by the ``grid`` tag). Derived + statistics, if present (assembled file), are *not* recomputed here — the + pipeline's own estimators re-derive them from the unblinded integration ξ±. + """ + config = config or BlindingConfig() + if not _concealed(blinded): + raise ValueError("file is not concealed — nothing to unblind") + if seed_commitment(seed) != blinded.metadata["blind_commitment"]: + raise ValueError( + "seed does not match blind_commitment — refusing to unblind " + "(a wrong seed would silently produce a wrong data vector)" + ) + if config.config_digest() != blinded.metadata["blind_config_digest"]: + raise ValueError( + "blinding config does not match blind_config_digest — refusing to " + "unblind (this config would subtract a different shift than was " + "added)" + ) + + hidden = hidden_params(seed, config) + fiducial = config.theory.ccl_params() + part = blinded.copy() + for name, indices, factory in _blindable_blocks(blinded): + theory = factory(_extract_block(blinded, indices), config.theory) + factor = theory(hidden) - theory(fiducial) + _set_values(part, indices, np.asarray(part.mean)[indices] - factor) + log(f"[unblind] {name}: subtracted {len(indices)} shifts") + + for key in ("concealed", "blind", "blind_commitment", "blind_config_digest"): + part.metadata.pop(key, None) + return part + + +def _stamp_provenance(s, commitment, label, config_digest): + """Stamp the blind's public provenance; strip any leaked seed. + + ``blind_commitment`` (sha256 of the seed) ties the file to its blind + without revealing it; ``blind_config_digest`` pins the envelope + + fiducial the shift was drawn against; ``concealed``/``blind`` mark the + file. ``seed_smokescreen`` — the raw seed Smokescreen's own writer would + stamp — is popped defensively: the seed must never ride a kept file. + """ + s.metadata.pop("seed_smokescreen", None) + s.metadata["concealed"] = True + s.metadata["blind"] = label + s.metadata["blind_commitment"] = commitment + s.metadata["blind_config_digest"] = config_digest + + +# --------------------------------------------------------------------------- # +# Assembly-time custody: one blind across all parts +# --------------------------------------------------------------------------- # +def assert_consistent_blind(parts): + """Assert every blindable part shares one blind; return the shared stamp. + + Custody logic for the terminal :func:`sp_validation.sacc_io.gather`: a + part is *blindable* if it carries a blindable block (ξ± or pseudo-Cℓ_EE); + ρ/τ diagnostic and covariance-only parts are exempt. Fails closed — + ``ValueError`` — if blinded and plaintext blindable parts are mixed, or + if two parts carry different ``blind_commitment``/``blind_config_digest`` + (they were blinded under different seeds or configs and must never be + combined). The consistency key is ``(commitment, digest)`` only — the + ``blind`` *label* is informational provenance, not custody state, so + parts blinded under one seed+config but tagged with different ``--label`` + values assemble cleanly (a distinct warning is logged, not a failure). + + **Unconcealed parts must be declared mocks** (PRD §4, "Mocks vs data"): + when no blindable part is concealed, every blindable part's metadata must + carry ``type == "mock"`` — the tag :mod:`sp_validation.sacc_io` stamps + when the data vector is computed. An unconcealed ``type == "data"`` part, + or one missing the tag, fails assembly closed: skipping the blind can + never silently expose real data. A concealed+plaintext mix fails closed + regardless of ``type`` (see above). A fully-mock plaintext assembly + returns ``None``. + + Returns + ------- + dict or None + The shared blind metadata (``concealed``, ``blind``, + ``blind_commitment``, ``blind_config_digest``) for the gather to + stamp on the assembled file, or ``None`` when nothing is blinded. + """ + blindable = [p for p in parts if _blindable_blocks(p)] + concealed = [p for p in blindable if _concealed(p)] + if not concealed: + # `.get` is deliberate here: a missing `type` tag must count as + # not-a-mock and fail closed, not KeyError with less context. + exposed = sorted( + {str(p.metadata.get("type", "")) for p in blindable} - {"mock"} + ) + if exposed: + raise ValueError( + f"unconcealed blindable parts with type {exposed} in assembly " + "— only parts declared `type: mock` may assemble without a " + "blind (an unconcealed data part exposes the real vector)" + ) + return None + if len(concealed) != len(blindable): + raise ValueError( + f"blinded and plaintext blindable parts mixed in one assembly " + f"({len(concealed)} of {len(blindable)} blinded) — refusing to " + "combine (a plaintext part beside blinded ones leaks the shift)" + ) + # Custody state is (commitment, digest) only — the label is provenance. + stamps = { + (p.metadata["blind_commitment"], p.metadata["blind_config_digest"]) + for p in concealed + } + if len(stamps) != 1: + raise ValueError( + "parts carry different blind commitments — they were blinded " + "under different seeds or configs and must never be combined: " + + "; ".join(f"({c[:12]}…, {d[:12]}…)" for c, d in stamps) + ) + ((commitment, digest),) = stamps + labels = sorted({p.metadata["blind"] for p in concealed}) + if len(labels) != 1: + warnings.warn( + f"blindable parts share one blind (commitment {commitment[:12]}…, " + f"config {digest[:12]}…) but carry different labels {labels} — " + "assembling anyway; the label is provenance, not custody state. " + f"Stamping the assembled file with label {labels[0]!r}." + ) + return { + "concealed": True, + "blind": labels[0], + "blind_commitment": commitment, + "blind_config_digest": digest, + } + + +# --------------------------------------------------------------------------- # +# File-level custody: blind-init / blind-part / unblind +# --------------------------------------------------------------------------- # +def init_paths(blind_dir): + """The fixed custody state written by :func:`blind_init` in ``blind_dir``.""" + return { + "commitment": os.path.join(blind_dir, "commitment.json"), + "bundle": os.path.join(blind_dir, "blind_seed.encrpt"), + "key": os.path.join(blind_dir, "blind_seed.key"), + } + + +def part_paths(part_path): + """Blinded-output and escrow-bundle paths beside a part file.""" + stem, ext = os.path.splitext(part_path) + return { + "blinded": f"{stem}_blinded{ext or '.fits'}", + "escrow": f"{stem}_escrow.encrpt", + "escrow_key": f"{stem}_escrow.key", + } + + +def blind_init(blind_dir, config=None, label="A", log=print): + """Fix the blind for one catalogue version: seed, commitment, seed bundle. + + Runs once per catalogue version: + + 1. Draw an OS-entropy seed (never written in plaintext, never returned). + 2. Write ``commitment.json`` (repo-committable): ``sha256(seed)`` + the + canonical config digest + the blind label. + 3. Encrypt the seed into a Fernet bundle (``smokescreen.encryption``); + the temporary plaintext is deleted by the encryptor. + + These outputs are the blind's fixed state; every :func:`blind_part` and + :func:`unblind_part` call reads them. + + Custody caveat: the bundle and its Fernet key land in the *same* + ``blind_dir``. Fernet is only as protective as the key's separation — + anyone with both files can decrypt the seed. Keep the key out-of-band; + colocation is convenience, not at-rest protection. + + Returns + ------- + dict + Paths written: ``commitment``, ``bundle``, ``key``. + """ + config = config or BlindingConfig() + paths = init_paths(blind_dir) + for path in paths.values(): + if os.path.exists(path): + raise FileExistsError( + f"refusing to overwrite existing blind state {path} — a blind " + "is a one-shot custody event; choose another directory" + ) + + seed = secrets.token_hex(16) + commitment = { + "label": label, + "seed_sha256": seed_commitment(seed), + "config_digest": config.config_digest(), + } + with open(paths["commitment"], "w", encoding="utf-8") as f: + json.dump(commitment, f, indent=2, sort_keys=True) + _write_encrypted_json(paths["bundle"], {"label": label, "seed": seed}) + + log(f"[blind-init] commitment (repo-committable): {paths['commitment']}") + log(f"[blind-init] encrypted seed bundle + key: {paths['bundle']}, {paths['key']}") + log( + "[blind-init] custody: keep the bundle key out-of-band from the bundle " + "(colocation in the blind dir is not at-rest protection)" + ) + return paths + + +def _read_seed(blind_dir, config): + """Decrypt the seed bundle and verify it against the commitment. + + Both ``sha256(seed)`` and the config digest are checked before the seed + is handed to any caller — a tampered bundle or a drifted config fails + loud here, whether the caller is about to blind or to unblind. + + Returns + ------- + tuple + ``(seed, commitment_dict)``. + """ + paths = init_paths(blind_dir) + bundle = _read_encrypted_json(paths["bundle"], paths["key"]) + with open(paths["commitment"], encoding="utf-8") as f: + commitment = json.load(f) + if seed_commitment(bundle["seed"]) != commitment["seed_sha256"]: + raise ValueError( + "bundle seed does not match the committed sha256(seed) — refusing " + "to proceed" + ) + if config.config_digest() != commitment["config_digest"]: + raise ValueError( + "blinding config does not match the committed config digest — " + "refusing to proceed (a wrong envelope or P(k) recipe would " + "silently produce a wrong shift)" + ) + return bundle["seed"], commitment + + +def blind_part(part_path, blind_dir, config=None, keep_input=False, log=print): + """Blind one intermediate part SACC at birth, under the fixed blind state. + + Reads the encrypted seed and ``commitment.json`` written by + :func:`blind_init` (verifying both hashes), conceals the part through its + matching backend (:func:`blind_sacc`), writes the blinded part beside the + input with the part's true vector escrowed into a per-part Fernet bundle, + and deletes the plaintext part — only the blinded part persists on disk. + Each part's escrow is self-contained: restoring it needs only that + part's bundle plus the seed bundle; corruption of one bundle loses one + part, not all. Pass ``keep_input=True`` to retain the plaintext part + (and own the custody implication). + + Returns + ------- + dict + Paths written: ``blinded``, ``escrow``, ``escrow_key``. + """ + config = config or BlindingConfig() + seed, commitment = _read_seed(blind_dir, config) + paths = part_paths(part_path) + for path in paths.values(): + if os.path.exists(path): + raise FileExistsError( + f"refusing to overwrite existing blind output {path} — a blind " + "is a one-shot custody event" + ) + + # The plaintext part is real, unblinded data — the blinding step is the one + # legitimate reader of the true vector, so it passes the load escape hatch. + part = sacc_io.load(part_path, allow_unblinded=True) + blinded = blind_sacc(part, seed, config=config, label=commitment["label"], log=log) + + _write_encrypted_json( + paths["escrow"], + { + "label": commitment["label"], + "seed_sha256": commitment["seed_sha256"], + "true_mean": np.asarray(part.mean, dtype=float).tolist(), + }, + ) + # The blinded file inherits the part's provenance (data vs mock); it also + # carries concealed=True (stamped by blind_sacc), so it loads without the + # escape hatch. + sacc_io.save(blinded, paths["blinded"], type=part.metadata["type"]) + if not keep_input: + os.remove(part_path) + log(f"[blind-part] deleted plaintext part {part_path}") + else: + log(f"[blind-part] plaintext part RETAINED at {part_path} (keep_input=True)") + log(f"[blind-part] wrote {paths['blinded']} (escrow beside it)") + return paths + + +def unblind_part(blinded_path, blind_dir, out_path, config=None, log=print): + """Unblind one blinded part (or the assembled file), verifying first. + + Decrypts the seed bundle and verifies both ``sha256(seed)`` and the + config digest against ``commitment.json`` (fail closed on either — + verification precedes subtraction), recomputes the part's shift from the + seed and subtracts it (:func:`unblind_sacc`). **The seed-subtracted + vector is the authority** — the seed plus its commitment is the custody + root of trust, and the returned vector is what the seed math produced. + + When the part's escrow bundle exists beside the blinded file it serves + two subordinate roles, and only after its stored ``seed_sha256`` is + verified to match the same commitment (so an escrow from a different + blind can never be trusted): (1) a *tighter equality check* — the seed + subtraction and the escrowed truth must agree to ``1e-6`` relative or the + unblind fails closed; (2) *ulp-residue removal* — float add-then-subtract + leaves ~ulp residue against the pre-blind truth, and once the escrow is + bound to this commitment its exact value clears that residue so the + restore is bit-for-bit. The escrow is never the source of correctness: + if it disagrees materially the guard raises, and a mismatched or + unbound escrow never determines the output. On the assembled file — no + escrow — the subtraction stands alone, with integration rows selected by the + ``grid`` tag. + """ + config = config or BlindingConfig() + seed, commitment = _read_seed(blind_dir, config) + blinded = sacc_io.load(blinded_path) + part = unblind_sacc(blinded, seed, config=config, log=log) + + stem, ext = os.path.splitext(blinded_path) + unblinded_stem = os.path.join( + os.path.dirname(stem), os.path.basename(stem).replace("_blinded", "") + ) + escrow = part_paths(unblinded_stem + ext) + if os.path.exists(escrow["escrow"]): + bundle = _read_encrypted_json(escrow["escrow"], escrow["escrow_key"]) + if bundle.get("seed_sha256") != commitment["seed_sha256"]: + raise ValueError( + "escrow bundle beside the blinded file was written under a " + "different seed than the commitment — refusing to trust it " + "(the seed subtraction is authoritative; this escrow is not " + "bound to this blind)" + ) + true_mean = np.asarray(bundle["true_mean"], dtype=float) + recovered = np.asarray(part.mean, dtype=float) + residual = np.nanmax( + np.abs(recovered - true_mean) / (np.abs(true_mean) + 1e-30) + ) + if residual > 1e-6: + raise ValueError( + f"unblinded vector disagrees with the escrowed true vector " + f"(max rel {residual:.2e}) — wrong escrow for this part?" + ) + # Seed-bound escrow: clear the add-then-subtract ulp residue so the + # restore is bit-for-bit. Correctness already came from the seed + # subtraction above; the guard proved the escrow agrees with it. + _set_values(part, np.arange(len(true_mean)), true_mean) + log(f"[unblind] escrow verified (subtraction residual {residual:.2e})") + # unblind_sacc stripped the concealed/blind stamps, so this is the true + # revealed vector; it inherits the blinded file's provenance (data vs mock). + sacc_io.save(part, out_path, type=blinded.metadata["type"]) + log(f"[unblind] wrote {out_path}") + return out_path + + +def _write_encrypted_json(encrpt_path, payload): + """Encrypt ``payload`` (JSON) to ``encrpt_path`` + sibling ``.key``; no + plaintext survives. + + We drive ``smokescreen.encryption.encrypt_file`` (Fernet) for the crypto + but write the ciphertext and key at the exact names we control. Its + ``save_file`` mode names outputs from ``basename.split('.')[0]`` — it + truncates at the first dot — so for a dotted stem (the canonical + catalogue-version case, e.g. ``v1.4.6.3_xi_integration_escrow``) it would land at + ``v1.encrpt``/``v1.key``, diverging from what :func:`part_paths` declares + and colliding across parts that share a first-dot prefix. Instead we take + the returned ``(ciphertext, key)`` and write them ourselves. + """ + from smokescreen.encryption import encrypt_file + + key_path = encrpt_path.replace(".encrpt", ".key") + plaintext = encrpt_path.replace(".encrpt", ".json") + with open(plaintext, "w", encoding="utf-8") as f: + json.dump(payload, f) + ciphertext, key = encrypt_file(plaintext, save_file=False, keep_original=False) + with open(encrpt_path, "wb") as f: + f.write(ciphertext) + with open(key_path, "wb") as f: + f.write(key) + + +def _read_encrypted_json(encrpt_path, key_path): + """Decrypt and parse a Fernet-encrypted JSON bundle.""" + from smokescreen.encryption import decrypt_file + + return json.loads(decrypt_file(encrpt_path, key_path).decode("utf-8")) diff --git a/src/sp_validation/blinding_theory.py b/src/sp_validation/blinding_theory.py new file mode 100644 index 00000000..b44f7e73 --- /dev/null +++ b/src/sp_validation/blinding_theory.py @@ -0,0 +1,424 @@ +"""Blinding theory: fiducial configuration and the two ξ± theory paths. + +:Name: blinding_theory.py + +:Description: The blinding backend's theory surface — the fiducial + configuration (:class:`TheoryConfig`) and two independent routes to the + tomographic shear two-point prediction. + + **Division of responsibility** (per the UNIONS layering: ``cs_util`` is + the cosmology *library* — generic machinery; ``sp_validation`` holds + *configuration* and *survey-specific implementations*). This module lives + in the blinding namespace because :class:`TheoryConfig` is configuration + and the master-layout theory backends in :mod:`sp_validation.blinding` + (reporting ξ±, integration ξ±, pseudo-Cℓ) are survey-specific. The **generic** + theory machinery below — the CCL-native ξ± path (:func:`xi_ccl`, + :func:`cl_ee`), the independent CAMB P(k)→``Pk2D`` path (:func:`xi_camb`), + and the σ8/A_s rescale (:func:`camb_As_for_sigma8`) — lives here **for + now** but is destined for ``cs_util.cosmo`` (tracked in cs_util#80): it is + cosmology-library code, not blinding-specific. There is deliberately **no** + ``sp_validation/cosmology.py`` — ``develop`` removed the local cosmology + module (#223) and moved cosmology to ``cs_util.cosmo``; this module does + not resurrect it. + + Two independent routes to the shear two-point prediction: + + - **CCL-native path** (:func:`xi_ccl`, :func:`cl_ee`): CCL builds the + nonlinear P(k) through its Boltzmann-CAMB HMCode2020 route + (``matter_power_spectrum='camb'`` + ``extra_parameters``) and projects + to Cℓ/ξ± via its own Limber (``angular_cl``) + FFTLog + (``correlation``). This is the recipe the blinding theory backends use. + - **Independent-CAMB path** (:func:`xi_camb`): a direct ``pycamb`` run + produces the HMCode2020 ``P(k, z)`` (σ8-matched via the closed-form + A_s rescale of :func:`camb_As_for_sigma8`), wrapped in a ``ccl.Pk2D`` + and projected through the same CCL Limber + FFTLog machinery. + + Because both paths route their nonlinear P(k) through CAMB's HMCode2020 + and both project through CCL, a common Limber+FFTLog bug cancels between + them: the CAMB↔CCL cross-check test built on these two paths validates + the **P(k) recipe** and the **σ8/A_s amplitude convention**, not the + projection machinery. + + This module imports only ``numpy`` at module level; CCL and CAMB are + imported inside the functions that need them, so importing + :class:`TheoryConfig` never drags in a theory backend. +""" + +import dataclasses + +import numpy as np + +# Fixed constants of the fiducial — load-bearing for the CAMB↔CCL amplitude +# match, so they are emitted explicitly to both stacks rather than left to +# either stack's default. Not user-facing TheoryConfig fields. +NEFF = 3.046 +T_CMB = 2.7255 + + +# --------------------------------------------------------------------------- # +# Configuration surface — the ONE place fiducial cosmology + model choices live +# --------------------------------------------------------------------------- # +@dataclasses.dataclass(frozen=True) +class TheoryConfig: + """Fiducial cosmology and model configuration for the theory paths. + + Every field is a deliberate, configurable choice. The defaults mirror the + ``cosmo_inference`` CosmoSIS fiducial (the ``SP_v1.4.6.3_A_cell`` pipeline + + ``values_ia.ini`` central values), so the CCL theory computed here and + the CAMB theory CosmoSIS computes agree to the level the CAMB↔CCL + cross-check test asserts. Adopting a different named group fiducial is a + change to these *values*, not to any code. + + Cosmology is parametrised by the blind axes ``S8`` and ``Omega_m`` and + converted to CCL's native ``sigma8``/``Omega_c`` by :meth:`sigma8` / + :meth:`omega_c`. + + **Nonlinear-model tokens (load-bearing).** The HMCode2020+feedback recipe + is named by a token in each stack's API: CCL takes + ``extra_parameters['camb']['halofit_version']``, CAMB takes + ``NonLinearModel.set_params(halofit_version=…)``. :class:`TheoryConfig` + carries **two** tokens (``ccl_halofit_version``, ``camb_halofit_version``) + denoting one recipe, and each stack is fed its own — a single shared + string invites a silent stack disagreement the moment the two APIs name + the recipe differently (``mead2020`` vs ``mead2020_feedback`` differ by + several % at k ≳ 1/Mpc). Today both stacks accept the same string for the + feedback recipe, so the two defaults coincide; the cross-check test pins + the CCL token against the inference config independently. + """ + + # Cosmological parameters (blind axes S8, Omega_m + the rest). + S8: float = 0.80 # values_ia.ini S_8_input central + Omega_m: float = 0.30 + Omega_b: float = 0.0469 # ombh2=0.023 at h=0.7 -> 0.023/0.7^2 + h: float = 0.70 + n_s: float = 0.96 + m_nu: float = 0.06 # Σm_ν in eV, distributed under `mass_split` + w0: float = -1.0 + wa: float = 0.0 + + # Neutrino mass split: normal hierarchy (CosmoSIS `neutrino_hierarchy=normal`). + mass_split: str = "normal" + + # Boltzmann/transfer-function backend for the CCL path (#280). The default + # `boltzmann_camb` makes CCL call CAMB for the linear P(k), so blinding + # theory and the CosmoSIS+CAMB inference stack share one power-spectrum + # path. The halofit route follows the choice (see `ccl_cosmology`): only + # under `boltzmann_camb` can the nonlinear P(k) run through CAMB's HMCode + # (`matter_power_spectrum="camb"` + the tokens below); any other backend + # falls back to CCL's own halofit — consistent, but a different recipe, + # so a non-default backend is a deliberate cross-check tool, not a + # production setting. + transfer_function: str = "boltzmann_camb" + + # One nonlinear recipe (CAMB HMCode2020 + baryonic feedback), two + # stack-specific tokens — see the class docstring. + ccl_halofit_version: str = "mead2020_feedback" + camb_halofit_version: str = "mead2020_feedback" + hmcode_logT_AGN: float = 7.5 # values_ia.ini logT_AGN central + + # Intrinsic alignments: NLA. The fiducial defaults IA OFF (ia_bias=0) — + # the blinding shift is a difference of two theory vectors at the same IA, + # so IA nearly cancels there, and IA-off keeps the CAMB↔CCL cross-check a + # clean test of the shear calculation. Set `ia_bias` nonzero (CosmoSIS + # central A=1.0) to include NLA. + ia_bias: float = 0.0 + ia_z_piv: float = 0.62 + ia_alphaz: float = 0.0 + + def sigma8(self): + """CCL ``sigma8`` implied by ``S8`` and ``Omega_m``. + + ``S8 ≡ σ8 √(Ωm / 0.3)`` — the standard weak-lensing definition — so + ``σ8 = S8 / √(Ωm / 0.3)``. At the fiducial (S8=0.80, Ωm=0.30), + σ8 = 0.80. + """ + return self.S8 / np.sqrt(self.Omega_m / 0.3) + + def omega_c(self): + """CCL cold-dark-matter density ``Omega_c = Omega_m − Omega_b − Ω_ν``. + + The neutrino density ``Ω_ν h² = Σm_ν / 93.14 eV`` is subtracted so + the *total* matter density is exactly ``Omega_m`` (CCL treats massive + neutrinos as a separate species, not part of ``Omega_c``). + """ + omega_nu = self.m_nu / (93.14 * self.h**2) + return self.Omega_m - self.Omega_b - omega_nu + + def ccl_params(self): + """The fiducial point as a plain CCL-native parameter mapping. + + Exactly the keys ``Omega_c, Omega_b, h, n_s, sigma8, m_nu, + mass_split, w0, wa, Neff, T_CMB`` and no others — no CCL default + rides along. ``Neff``/``T_CMB`` are the fixed module constants. This + mapping is what the Smokescreen fork receives as ``fiducial_params`` + and what every ``theory_fn`` receives back (possibly with + ``sigma8``/``Omega_c`` overlaid by the hidden draw). + """ + return { + "Omega_c": self.omega_c(), + "Omega_b": self.Omega_b, + "h": self.h, + "n_s": self.n_s, + "sigma8": self.sigma8(), + "m_nu": self.m_nu, + "mass_split": self.mass_split, + "w0": self.w0, + "wa": self.wa, + "Neff": NEFF, + "T_CMB": T_CMB, + } + + @classmethod + def from_overrides(cls, overrides): + """Build from a mapping of field overrides (fail loud on unknown keys). + + Numeric overrides are coerced to ``float`` per the field's declared + type, so a YAML/CLI ``w0: -1`` (int) yields the same value — and the + same :meth:`config_digest` — as the float default ``-1.0``. + """ + by_name = {f.name: f for f in dataclasses.fields(cls)} + unknown = set(overrides) - set(by_name) + if unknown: + raise ValueError( + f"unknown TheoryConfig fields {sorted(unknown)}; " + f"valid fields are {sorted(by_name)}" + ) + coerced = { + name: (float(v) if by_name[name].type in (float, "float") else v) + for name, v in overrides.items() + } + return cls(**coerced) + + +# --------------------------------------------------------------------------- # +# CCL-native path: cosmology construction, Cℓ_EE, ξ± +# --------------------------------------------------------------------------- # +# The two cosmologies of a blind (fiducial + hidden) are evaluated by three +# theory backends over multiple blocks; caching the ccl.Cosmology per parameter +# point avoids re-running the CAMB P(k) computation for every block. +_COSMO_CACHE = {} + + +def ccl_cosmology(params, config): + """A ``pyccl.Cosmology`` at ``params`` with ``config``'s nonlinear recipe. + + ``params`` is a plain CCL-native mapping (:meth:`TheoryConfig.ccl_params`, + possibly with keys overlaid by the hidden draw); ``config`` supplies only + the non-sampled recipe tokens (``transfer_function``, + ``ccl_halofit_version``, ``hmcode_logT_AGN``). The Boltzmann backend is + ``config.transfer_function`` (#280); the halofit route stays consistent + with that choice: under ``boltzmann_camb`` the nonlinear P(k) runs + through CAMB's HMCode2020 (``matter_power_spectrum="camb"`` + the CAMB + tokens), while any other backend has no CAMB run to hand tokens to, so it + takes CCL's own halofit (``matter_power_spectrum="halofit"``). Either + way, the same recipe sits on both sides of any theory difference. + Cosmology objects are cached per parameter point (CCL memoises its P(k) + on the object, so the cache saves repeated Boltzmann runs across blocks). + """ + import pyccl as ccl + + key = ( + tuple(sorted(params.items())), + config.transfer_function, + config.ccl_halofit_version, + config.hmcode_logT_AGN, + ) + if key not in _COSMO_CACHE: + nonlinear = ( + { + "matter_power_spectrum": "camb", + "extra_parameters": { + "camb": { + "halofit_version": config.ccl_halofit_version, + "HMCode_logT_AGN": config.hmcode_logT_AGN, + } + }, + } + if config.transfer_function == "boltzmann_camb" + else {"matter_power_spectrum": "halofit"} + ) + _COSMO_CACHE[key] = ccl.Cosmology( + **params, + transfer_function=config.transfer_function, + **nonlinear, + ) + return _COSMO_CACHE[key] + + +def xi_ell_grid(): + """The ℓ grid the ξ± Hankel projection integrates over. + + Integers 2…49, then 200 log-spaced multipoles up to 6·10⁴ — dense enough + at low ℓ (where ξ± at large θ lives) and wide enough for the small-θ + tail. ``ccl.correlation`` interpolates C(ℓ) internally, so this fixes the + resolution of every ξ± this module produces. + """ + return np.unique( + np.concatenate([np.arange(2, 50), np.geomspace(50, 6e4, 200)]).astype(float) + ) + + +def _tracer(cosmo, z, nz, config): + """A ``WeakLensingTracer`` for one bin's n(z), NLA from ``config``. + + With the fiducial ``ia_bias = 0`` the tracer is built bare — no IA term. + A nonzero ``ia_bias`` enters as the NLA amplitude + ``A(z) = ia_bias · ((1+z)/(1+z_piv))^alphaz``. + """ + import pyccl as ccl + + z = np.asarray(z) + if config.ia_bias == 0.0: + return ccl.WeakLensingTracer(cosmo, dndz=(z, np.asarray(nz))) + a_ia = config.ia_bias * ((1 + z) / (1 + config.ia_z_piv)) ** config.ia_alphaz + return ccl.WeakLensingTracer( + cosmo, dndz=(z, np.asarray(nz)), ia_bias=(z, a_ia), use_A_ia=True + ) + + +def cl_ee(params, config, nz_i, nz_j, ell): + """Cross Cℓ_EE at ``ell`` for the bin pair with n(z) ``nz_i``, ``nz_j``. + + Two-tracer: one :class:`~pyccl.WeakLensingTracer` per bin from that bin's + own ``(z, nz)``, then ``angular_cl(cosmo, tracer_i, tracer_j, ell)`` — + the cross-spectrum for i ≠ j, the auto-spectrum when the two n(z) are the + same bin. The shear ``angular_cl`` is the E-mode spectrum; B and EB are + zero in theory, which is why only Cℓ_EE ever receives a blinding shift. + """ + import pyccl as ccl + + cosmo = ccl_cosmology(params, config) + tracer_i = _tracer(cosmo, *nz_i, config) + tracer_j = _tracer(cosmo, *nz_j, config) + return ccl.angular_cl(cosmo, tracer_i, tracer_j, np.asarray(ell, dtype=float)) + + +def xi_ccl(params, config, nz_i, nz_j, theta_arcmin, ell=None): + """CCL-native ξ± at ``theta_arcmin`` for one bin pair (Path A). + + Cross Cℓ_EE on :func:`xi_ell_grid` (or ``ell``), then ``ccl.correlation`` + (FFTLog Hankel transform) at θ in degrees, ``type="GG+"`` / ``"GG-"``. + + Returns + ------- + (np.ndarray, np.ndarray) + ``(xip, xim)`` aligned to ``theta_arcmin``. + """ + import pyccl as ccl + + ell = xi_ell_grid() if ell is None else np.asarray(ell, dtype=float) + cosmo = ccl_cosmology(params, config) + cl = cl_ee(params, config, nz_i, nz_j, ell) + theta_deg = np.asarray(theta_arcmin) / 60.0 + xip = ccl.correlation(cosmo, ell=ell, C_ell=cl, theta=theta_deg, type="GG+") + xim = ccl.correlation(cosmo, ell=ell, C_ell=cl, theta=theta_deg, type="GG-") + return xip, xim + + +# --------------------------------------------------------------------------- # +# Independent-CAMB path: A_s reconciliation + P(k) → Pk2D → CCL projection +# --------------------------------------------------------------------------- # +def make_camb_params(config, As, *, nonlinear, zmax=3.0, n_z=48, kmax=20.0): + """A ``CAMBparams`` at ``config``'s background with amplitude ``As``. + + Every :class:`TheoryConfig` field CCL sees is fed to CAMB from the same + source — ``w0``/``wa`` via ``set_dark_energy``, ``Neff``/``T_CMB`` as the + module constants, ``m_nu``/``mass_split`` through ``set_cosmology`` — so + the independent path differs from the CCL path only in who computes P(k), + never in an unmatched background parameter. + """ + import camb + + p = camb.CAMBparams() + p.set_cosmology( + H0=config.h * 100, + ombh2=config.Omega_b * config.h**2, + omch2=config.omega_c() * config.h**2, + mnu=config.m_nu, + num_massive_neutrinos=1, + neutrino_hierarchy=config.mass_split, + nnu=NEFF, + TCMB=T_CMB, + ) + p.set_dark_energy(w=config.w0, wa=config.wa, dark_energy_model="ppf") + p.InitPower.set_params(As=As, ns=config.n_s) + p.set_matter_power(redshifts=list(np.linspace(0.0, zmax, n_z)), kmax=kmax) + if nonlinear: + p.NonLinear = camb.model.NonLinear_both + p.NonLinearModel.set_params( + halofit_version=config.camb_halofit_version, + HMCode_logT_AGN=config.hmcode_logT_AGN, + ) + else: + p.NonLinear = camb.model.NonLinear_none + return p + + +def camb_linear_sigma8(config, As, **kwargs): + """CAMB's linear σ8(z=0) at amplitude ``As``.""" + import camb + + results = camb.get_results(make_camb_params(config, As, nonlinear=False, **kwargs)) + return float(results.get_sigma8_0()) + + +def camb_As_for_sigma8(config, sigma8_target, As_seed=2.1e-9, **kwargs): + """The CAMB ``A_s`` whose linear σ8 equals ``sigma8_target``. + + Closed-form: linear σ8² ∝ A_s exactly, so one CAMB linear-σ8 evaluation + at ``As_seed`` and one rescale ``As_seed · (σ8_target/σ8_seed)²`` land on + the target — no iteration. This settles the convention subtlety that our + fiducial fixes σ8 for CCL but A_s for CAMB: a nominal ``A_s = 2.1e-9`` + leaves CAMB's σ8 ≈3% off target, enough to blow a ξ± comparison to + ~9–10%. + """ + sigma8_seed = camb_linear_sigma8(config, As_seed, **kwargs) + return As_seed * (sigma8_target / sigma8_seed) ** 2 + + +def xi_camb(config, nz, theta_arcmin, *, n_ell=300, ell_max=60000, kmax=20.0, n_k=400): + """Independent-CAMB ξ± for one bin (Path B): CAMB P(k) → Pk2D → CCL. + + A direct pycamb run produces the HMCode2020 nonlinear ``P(k, z)`` at a + σ8-matched ``A_s`` (:func:`camb_As_for_sigma8`), extracted through + ``get_matter_power_interpolator(hubble_units=False, k_hunit=False)`` so + it comes out in CCL's native units (k in 1/Mpc, P in Mpc³) — **no** + ``·h`` / ``/h³`` conversion is applied (applying one would double-count + an h³ amplitude error). Both ``Pk2D`` axes are arranged ascending + (log-k ascending; scale factor ascending, i.e. CAMB's z-ascending grid + reversed). Projection is CCL's own Limber + FFTLog with a bare tracer + (IA off — this path exists for the cross-check). + + Returns + ------- + (np.ndarray, np.ndarray, float) + ``(xip, xim, As)`` — the σ8-matched amplitude is returned for + assertion by the cross-check test. + """ + import camb + import pyccl as ccl + + sigma8 = config.sigma8() + As = camb_As_for_sigma8(config, sigma8, kmax=kmax) + results = camb.get_results(make_camb_params(config, As, nonlinear=True, kmax=kmax)) + interp = results.get_matter_power_interpolator( + nonlinear=True, hubble_units=False, k_hunit=False + ) + k = np.geomspace(1e-4, kmax * config.h, n_k) # 1/Mpc + z = np.linspace(0.0, 3.0, 48) + pk = interp.P(z, k) # (n_z, n_k), Mpc^3 + a = 1.0 / (1.0 + z) + order = np.argsort(a) # Pk2D wants ascending scale factor + pk2d = ccl.Pk2D( + a_arr=a[order], lk_arr=np.log(k), pk_arr=np.log(pk[order]), is_logp=True + ) + + cosmo = ccl_cosmology(config.ccl_params(), config) + z_nz, nz_vals = nz + lens = ccl.WeakLensingTracer(cosmo, dndz=(np.asarray(z_nz), np.asarray(nz_vals))) + ells = np.unique(np.geomspace(2, ell_max, n_ell).astype(int)).astype(float) + cl = ccl.angular_cl(cosmo, lens, lens, ells, p_of_k_a=pk2d) + theta_deg = np.asarray(theta_arcmin) / 60.0 + xip = ccl.correlation(cosmo, ell=ells, C_ell=cl, theta=theta_deg, type="GG+") + xim = ccl.correlation(cosmo, ell=ells, C_ell=cl, theta=theta_deg, type="GG-") + return xip, xim, As diff --git a/src/sp_validation/sacc_io.py b/src/sp_validation/sacc_io.py index 21872d0e..fd2e8df8 100644 --- a/src/sp_validation/sacc_io.py +++ b/src/sp_validation/sacc_io.py @@ -773,3 +773,50 @@ def load(path, *, allow_unblinded=False): "allow_unblinded=True." ) return s + + +# --------------------------------------------------------------------------- # +# Terminal assembly — added on top of PR-2's canonical module (PR-6 blinding). +# Everything above this banner is byte-identical to feat/sacc-2-sacc-io; only +# gather() and its blind-custody call site live here. +# --------------------------------------------------------------------------- # +def gather(parts, metadata=None): + """Assemble standalone part SACCs into the one-file ``{version}.sacc``. + + Each part is an intermediate product as it came off its producing rule + (reporting ξ±, integration ξ±, pseudo-Cℓ, ρ/τ, …). Assembly itself — the + first-wins tracer union, in-order point concatenation with all tags + (bandpower windows included), and the block-diagonal covariance — is + exactly :func:`merge`, so gather delegates to it and adds only the one + thing merge cannot know about: **blind custody.** + + **Blind custody (the module's one blind-aware call site):** + :func:`sp_validation.blinding.assert_consistent_blind` runs before the + merge — it fails closed unless every blindable part carries the identical + ``blind_commitment``/``blind_config_digest`` (or, when nothing is blinded, + every blindable part is declared ``type='mock'``). Its returned shared + stamp is written onto the assembled file so the one-file product carries + the blind it was built from; the blinded parts already carry those keys, + so merge preserves them and this stamp is a consistent (idempotent) + re-affirmation. + + Parameters + ---------- + parts : sequence of sacc.Sacc + The part SACCs, in the assembly (covariance) order. + metadata : dict, optional + Extra key/value pairs to store on the assembled file's metadata. + + Returns + ------- + sacc.Sacc + The assembled file. + """ + from . import blinding + + parts = list(parts) + stamp = blinding.assert_consistent_blind(parts) + s = merge(parts) + for key, value in {**(metadata or {}), **(stamp or {})}.items(): + s.metadata[key] = value + return s diff --git a/src/sp_validation/tests/test_blinding.py b/src/sp_validation/tests/test_blinding.py new file mode 100644 index 00000000..7013a81a --- /dev/null +++ b/src/sp_validation/tests/test_blinding.py @@ -0,0 +1,1110 @@ +"""Tests for :mod:`sp_validation.blinding` — per-part Smokescreen blinding. + +Acceptance criteria AC1–AC9 of the blinding PRD, plus fast unit coverage of +the config/custody surface. Fast tests (no CCL import — the envelope +calibration, digest, commitment, fork-draw determinism, blind-init custody, +the merge alignment against a monkeypatched concealing factor, and the +assembly hash assertion) run in the default suite; the theory tests (fork + +CCL) are marked ``slow``; the derived-statistics tests additionally +``importorskip`` ``cosmo_numba``. + +All fixtures are synthetic and deterministic. Each blindable intermediate is +its own standalone part SACC (reporting ξ±, integration ξ±, pseudo-Cℓ), as in the +per-part-at-birth architecture; derived statistics (COSEBIs, pure-E/B) are +never stored in parts — they are computed downstream from the (blinded) integration +ξ± through the pipeline seams ``b_modes.cosebis_from_xi`` / +``b_modes.pure_eb_from_xi``, exactly as the pipeline does. The fork's +``ConcealDataVector`` carries no data-vector consistency check (only the +length guard), so fixture ξ± values are smooth synthetic templates — no +theory fill is needed to blind. +""" + +import json +import pathlib + +import numpy as np +import pytest + +from sp_validation import blinding as bd +from sp_validation import sacc_io as sio +from sp_validation.blinding_theory import TheoryConfig + +_NOLOG = lambda *a, **k: None # noqa: E731 + + +# --------------------------------------------------------------------------- # +# Synthetic part fixtures +# --------------------------------------------------------------------------- # +def _gauss_nz(z0, sigma, n=200): + z = np.linspace(0.0, 3.0, n) + nz = np.exp(-0.5 * ((z - z0) / sigma) ** 2) + return z, nz / np.trapezoid(nz, z) + + +def _reporting_theta(n=8): + return np.geomspace(5.0, 250.0, n) + + +def _integration_theta(n=80): + # The integration grid is the pure-E/B INTEGRATION grid, so it spans wider than + # the reporting range on both ends (production: ~0.08–300 arcmin). + return np.geomspace(0.1, 300.0, n) + + +def _xi_template(theta, k=0): + """Smooth synthetic ξ± for pair index ``k`` (no CCL needed).""" + theta = np.asarray(theta) + xip = 1e-4 * (1 + 0.1 * k) * (theta / 10.0) ** -0.6 + xim = 0.5e-4 * (1 + 0.1 * k) * (theta / 10.0) ** -0.9 + return xip, xim + + +def _b_mode_template(theta, amplitude): + """A smooth ξ_B(θ) template. B contributes +ξ_B to ξ+, −ξ_B to ξ−.""" + return amplitude * np.exp(-((np.log(np.asarray(theta) / 30.0)) ** 2) / 2.0) + + +def _nz_dict(nbins): + return {i: _gauss_nz(0.5 + 0.3 * i, 0.15 + 0.02 * i) for i in range(nbins)} + + +def _pairs(nbins): + return [(i, j) for i in range(nbins) for j in range(i, nbins)] + + +def make_xi_part(grid, nbins=1, b_amplitude=0.0): + """A standalone ξ± part SACC (one grid), synthetic values, eye covariance. + + ``b_amplitude`` injects a pure B-mode (+ξ_B to ξ+, −ξ_B to ξ−; + b_modes.py sign convention) — used on the integration part for AC4/AC9. + """ + theta = _reporting_theta() if grid == "reporting" else _integration_theta() + s = sio.new_sacc( + _nz_dict(nbins), metadata={"catalogue_version": "vTEST", "type": "mock"} + ) + blocks = [] + for k, (i, j) in enumerate(_pairs(nbins)): + xip, xim = _xi_template(theta, k) + xi_b = _b_mode_template(theta, b_amplitude) + sio.add_xi(s, (i, j), theta, xip + xi_b, xim - xi_b, grid=grid) + tr = sio._pair((i, j)) + idx = np.concatenate( + [ + s.indices(sio.XI_PLUS, tr, grid=grid), + s.indices(sio.XI_MINUS, tr, grid=grid), + ] + ) + blocks.append((idx, np.eye(len(idx)) * 1e-12)) + sio.assemble_covariance(s, blocks) + return s + + +def make_cl_part(nbins=1): + """A standalone pseudo-Cℓ part SACC (EE/BB/EB + bandpower windows).""" + s = sio.new_sacc( + _nz_dict(nbins), metadata={"catalogue_version": "vTEST", "type": "mock"} + ) + ell_eff = np.array([30.0, 80.0, 150.0, 280.0, 450.0]) + w_ell = np.arange(2, 501).astype(float) + w_mat = np.zeros((len(w_ell), len(ell_eff))) + for b, le in enumerate(ell_eff): + w_mat[:, b] = np.exp(-0.5 * ((w_ell - le) / 40.0) ** 2) + w_mat[:, b] /= w_mat[:, b].sum() + blocks = [] + for k, (i, j) in enumerate(_pairs(nbins)): + cl_ee = 1e-8 * (1 + 0.1 * k) * (ell_eff / 100.0) ** -1.2 + sio.add_pseudo_cl( + s, + (i, j), + ell_eff, + cl_ee, + np.zeros(5), + np.zeros(5), + window_ells=w_ell, + window_weights=w_mat, + ) + tr = sio._pair((i, j)) + idx = np.concatenate( + [s.indices(dt, tr) for dt in (sio.CL_EE, sio.CL_BB, sio.CL_EB)] + ) + blocks.append((idx, np.eye(len(idx)) * 1e-16)) + sio.assemble_covariance(s, blocks) + return s + + +def make_rho_part(): + """A standalone ρ/τ PSF-diagnostics part SACC — never blindable.""" + ctheta = _reporting_theta() + s = sio.new_sacc( + _nz_dict(1), metadata={"catalogue_version": "vTEST", "type": "mock"} + ) + blocks = [] + for k in range(2): + sio.add_rho( + s, k, ctheta, np.arange(len(ctheta)) * 1e-7, np.arange(len(ctheta)) * 2e-7 + ) + idx = np.concatenate( + [s.indices(sio.RHO_PLUS.format(k=k)), s.indices(sio.RHO_MINUS.format(k=k))] + ) + blocks.append((idx, np.eye(len(idx)) * 1e-18)) + sio.add_tau( + s, (0,), 0, ctheta, np.arange(len(ctheta)) * 3e-7, np.arange(len(ctheta)) * 4e-7 + ) + idx = np.concatenate( + [s.indices(sio.TAU_PLUS.format(k=0)), s.indices(sio.TAU_MINUS.format(k=0))] + ) + blocks.append((idx, np.eye(len(idx)) * 1e-18)) + sio.assemble_covariance(s, blocks) + return s + + +def make_parts(nbins=1, b_amplitude=0.0, with_rho=True): + """All intermediate parts of one catalogue version, keyed by name.""" + parts = { + "xi_reporting": make_xi_part("reporting", nbins), + "xi_integration": make_xi_part("integration", nbins, b_amplitude=b_amplitude), + "cl": make_cl_part(nbins), + } + if with_rho: + parts["rho_tau"] = make_rho_part() + return parts + + +def _derive_downstream(reporting_part, integration_part, nmodes=6): + """COSEBIs + pure-E/B the way the pipeline derives them downstream. + + COSEBIs from the integration ξ± (full-range scale cut); pure-E/B from the + measured reporting reporting ξ± + the integration integration ξ±, with the + edge-based bounds set to the reporting grid's span — the outermost + reporting point sits at tmax with no interior support and comes back + NaN (the AC9 boundary case). + """ + from sp_validation import b_modes + + theta_f, xip_f, xim_f = sio.get_xi(integration_part, (0, 0), grid="integration") + theta_c, xip_c, xim_c = sio.get_xi(reporting_part, (0, 0), grid="reporting") + En, Bn = b_modes.cosebis_from_xi( + theta_f, xip_f, xim_f, nmodes, scale_cut=(theta_f.min(), theta_f.max()) + ) + modes = b_modes.pure_eb_from_xi( + theta_c, + xip_c, + xim_c, + theta_f, + xip_f, + xim_f, + float(theta_c[0]), + float(theta_c[-1]), + ) + return En, Bn, modes + + +# --------------------------------------------------------------------------- # +# BlindingConfig: envelope calibration + digest (fast) +# --------------------------------------------------------------------------- # +def test_blinding_config_defaults(): + c = bd.BlindingConfig() + assert c.s8_half_width == 0.075 + assert c.omega_m_half_width == 0.1 + assert c.theory.S8 == 0.80 # fiducial TheoryConfig defaults + + +def test_blinding_config_overrides_fail_loud(): + c = bd.BlindingConfig.from_overrides({"s8_half_width": 0.05}) + assert c.s8_half_width == 0.05 + with pytest.raises(ValueError, match="unknown BlindingConfig fields"): + bd.BlindingConfig.from_overrides({"s8_half_width": 0.05, "bogus": 1}) + with pytest.raises(ValueError, match="unknown TheoryConfig fields"): + bd.BlindingConfig.from_overrides({"theory": {"nope": 1}}) + + +def test_blinding_config_is_frozen(): + with pytest.raises(Exception): + bd.BlindingConfig().s8_half_width = 0.2 + + +def test_envelope_calibration_maps_s8_box_to_ccl_halfwidths(): + """(S8, Ωm) half-widths → {sigma8, Omega_c} at the fiducial (exact forms).""" + c = bd.BlindingConfig() + shifts = c.shifts_dict() + assert set(shifts) == {"sigma8", "Omega_c"} + assert shifts["sigma8"] == pytest.approx( + c.s8_half_width / np.sqrt(c.theory.Omega_m / 0.3) + ) + assert shifts["Omega_c"] == c.omega_m_half_width + # every shift key must exist in the fiducial point (fork contract) + assert set(shifts) <= set(c.theory.ccl_params()) + + +def test_config_digest_stable_and_sensitive(): + """Canonical digest: byte-stable across runs, moves with every bound field.""" + c = bd.BlindingConfig() + assert c.config_digest() == bd.BlindingConfig().config_digest() + assert len(c.config_digest()) == 64 + assert bd.BlindingConfig(s8_half_width=0.05).config_digest() != c.config_digest() + assert ( + bd.BlindingConfig.from_overrides({"theory": {"S8": 0.79}}).config_digest() + != c.config_digest() + ) + # the P(k) recipe tokens are bound: a different halofit token = new digest + assert ( + bd.BlindingConfig.from_overrides( + {"theory": {"ccl_halofit_version": "takahashi"}} + ).config_digest() + != c.config_digest() + ) + # the Boltzmann backend (#280) is bound too — a different transfer + # function is a different P(k) path, so a different blind + assert ( + bd.BlindingConfig.from_overrides( + {"theory": {"transfer_function": "eisenstein_hu"}} + ).config_digest() + != c.config_digest() + ) + + +def test_theory_config_transfer_function_default_and_override(): + """#280: the Boltzmann backend is one config knob, CAMB by default (the + inference pipeline's Boltzmann code), overridable like any other field.""" + assert TheoryConfig().transfer_function == "boltzmann_camb" + cfg = TheoryConfig.from_overrides({"transfer_function": "eisenstein_hu"}) + assert cfg.transfer_function == "eisenstein_hu" + + +def test_config_digest_int_float_canonical(): + """One physical cosmology has one digest, regardless of int-vs-float literals. + + Configs come from YAML/CLI/humans, so a field can arrive as ``-1`` (int) or + ``-1.0`` (float). The digest must depend on the numeric *value*, not the + literal's Python type — otherwise an int-vs-float mismatch between the blind + and a later unblind would raise "config digest mismatch" and deny a + legitimate unblind. Every declared-float field must be canonical this way. + """ + for field, int_val, float_val in [ + ("w0", -1, -1.0), + ("wa", 0, 0.0), + ("Omega_m", 1, 1.0), + ("m_nu", 0, 0.0), + ("S8", 1, 1.0), + ]: + assert ( + bd.BlindingConfig.from_overrides( + {"theory": {field: int_val}} + ).config_digest() + == bd.BlindingConfig.from_overrides( + {"theory": {field: float_val}} + ).config_digest() + ), f"int-vs-float digest split on theory.{field}" + # the envelope half-widths (BlindingConfig's own float fields) too + assert ( + bd.BlindingConfig.from_overrides({"s8_half_width": 1}).config_digest() + == bd.BlindingConfig.from_overrides({"s8_half_width": 1.0}).config_digest() + ) + # and a full round-trip: an all-int override matches the float default digest + assert ( + bd.BlindingConfig.from_overrides( + {"theory": {"w0": -1, "Omega_m": 0, "wa": 0}} + ).config_digest() + == bd.BlindingConfig.from_overrides( + {"theory": {"w0": -1.0, "Omega_m": 0.0, "wa": 0.0}} + ).config_digest() + ) + + +def test_theory_config_ccl_params_exact_keyset(): + """ccl_params() carries exactly the contracted keys — nothing rides along.""" + params = TheoryConfig().ccl_params() + assert set(params) == { + "Omega_c", + "Omega_b", + "h", + "n_s", + "sigma8", + "m_nu", + "mass_split", + "w0", + "wa", + "Neff", + "T_CMB", + } + assert params["sigma8"] == pytest.approx(0.80) # S8=0.80 at Ωm=0.30 + assert params["Neff"] == 3.046 and params["T_CMB"] == 2.7255 + + +# --------------------------------------------------------------------------- # +# Commitment + the fork's draw (fast; smokescreen import is light) +# --------------------------------------------------------------------------- # +def test_commitment_is_sha256_of_seed(): + import hashlib + + seed = "the-secret" + assert bd.seed_commitment(seed) == hashlib.sha256(seed.encode()).hexdigest() + assert bd.seed_commitment("right") != bd.seed_commitment("wrong") + + +def test_hidden_params_deterministic_and_in_envelope(): + """Same (seed, config) ⇒ same hidden point; draws respect the envelope.""" + import secrets + + c = bd.BlindingConfig() + fid = c.theory.ccl_params() + h1, h2 = bd.hidden_params("a-seed", c), bd.hidden_params("a-seed", c) + assert h1 == h2 + assert bd.hidden_params("другой", c) != h1 + shifts = c.shifts_dict() + for _ in range(50): + h = bd.hidden_params(secrets.token_hex(8), c) + for key, half in shifts.items(): + assert abs(h[key] - fid[key]) <= half + # only the enveloped keys move + assert all(h[k] == fid[k] for k in fid if k not in shifts) + + +def test_hidden_params_no_global_rng_state(): + """The fork's draw uses a local RNG — global numpy state is untouched.""" + np.random.seed(0) + before = np.random.get_state()[1].copy() + bd.hidden_params("whatever", bd.BlindingConfig()) + assert np.array_equal(before, np.random.get_state()[1]) + + +# --------------------------------------------------------------------------- # +# Per-part merge + provenance with a monkeypatched factor (fast — no CCL) +# --------------------------------------------------------------------------- # +def _patch_constant_factor(monkeypatch, value=1e-6): + def fake(part, indices, factory, config, seed): + return np.arange(len(indices), dtype=float) * value + value + + monkeypatch.setattr(bd, "_concealing_factor", fake) + return fake + + +def test_merge_places_shift_at_recorded_indices_only(monkeypatch): + """AC5 (merge half), per part: the shift lands exactly on the blindable + rows, in stored order; covariance, n(z), and every tag are untouched.""" + _patch_constant_factor(monkeypatch) + for name, part in make_parts(nbins=2, with_rho=False).items(): + orig = np.array(part.mean) + orig_cov = part.covariance.dense.copy() + orig_nz = part.tracers["source_0"].nz.copy() + + blinded = bd.blind_sacc(part, "seed", log=_NOLOG) + + blocks = bd._blindable_blocks(part) + assert len(blocks) == 1, f"{name}: a part carries exactly one block" + shifted = np.zeros(len(orig), dtype=bool) + for _, indices, _ in blocks: + expected = orig[indices] + (np.arange(len(indices)) * 1e-6 + 1e-6) + assert np.array_equal(np.array(blinded.mean)[indices], expected) + shifted[indices] = True + assert np.array_equal(np.array(blinded.mean)[~shifted], orig[~shifted]) + assert np.array_equal(blinded.covariance.dense, orig_cov) + assert np.array_equal(blinded.tracers["source_0"].nz, orig_nz) + # row-order preservation: type/tracers/tags sequence is bitwise unchanged + for a, b in zip(part.data, blinded.data): + assert a.data_type == b.data_type + assert a.tracers == b.tracers + assert a.tags == b.tags + + +def test_provenance_metadata_contract(monkeypatch): + """Blinded parts carry concealed/blind/commitment/digest; no seed key.""" + _patch_constant_factor(monkeypatch) + s = make_xi_part("reporting") + s.metadata["seed_smokescreen"] = "leaked!" # must be stripped + c = bd.BlindingConfig() + blinded = bd.blind_sacc(s, "seed", config=c, label="B", log=_NOLOG) + assert blinded.metadata["concealed"] is True + assert blinded.metadata["blind"] == "B" + assert blinded.metadata["blind_commitment"] == bd.seed_commitment("seed") + assert blinded.metadata["blind_config_digest"] == c.config_digest() + assert "seed_smokescreen" not in blinded.metadata + assert blinded.metadata["catalogue_version"] == "vTEST" + + +def test_blind_refuses_double_blind(monkeypatch): + _patch_constant_factor(monkeypatch) + s = make_xi_part("reporting") + blinded = bd.blind_sacc(s, "seed", log=_NOLOG) + with pytest.raises(ValueError, match="already concealed"): + bd.blind_sacc(blinded, "seed2", log=_NOLOG) + + +def test_blind_refuses_non_blindable_part(): + """A ρ/τ diagnostic part must never see a blind call — loud refusal.""" + with pytest.raises(ValueError, match="no blindable block"): + bd.blind_sacc(make_rho_part(), "seed", log=_NOLOG) + + +def test_unblind_fails_closed_on_wrong_seed_or_config(monkeypatch): + """AC6 (in-memory half): wrong seed and wrong config both refuse loudly.""" + _patch_constant_factor(monkeypatch) + s = make_xi_part("reporting") + blinded = bd.blind_sacc(s, "right-seed", log=_NOLOG) + with pytest.raises(ValueError, match="blind_commitment"): + bd.unblind_sacc(blinded, "wrong-seed", log=_NOLOG) + with pytest.raises(ValueError, match="blind_config_digest"): + bd.unblind_sacc( + blinded, + "right-seed", + config=bd.BlindingConfig(s8_half_width=0.01), + log=_NOLOG, + ) + with pytest.raises(ValueError, match="not concealed"): + bd.unblind_sacc(s, "right-seed", log=_NOLOG) + + +# --------------------------------------------------------------------------- # +# blind-init custody + assembly hash assertion (fast — encryption only) +# --------------------------------------------------------------------------- # +def test_blind_init_writes_commitment_and_encrypted_bundle_only(tmp_path): + """AC6 (init): commitment.json + encrypted bundle; never a plaintext seed.""" + paths = bd.blind_init(str(tmp_path), log=_NOLOG) + with open(paths["commitment"], encoding="utf-8") as f: + commitment = json.load(f) + assert set(commitment) == {"label", "seed_sha256", "config_digest"} + assert len(commitment["seed_sha256"]) == 64 + assert commitment["config_digest"] == bd.BlindingConfig().config_digest() + # exactly the three custody outputs, no plaintext bundle + assert {p.name for p in tmp_path.iterdir()} == { + "commitment.json", + "blind_seed.encrpt", + "blind_seed.key", + } + # the decrypted seed matches the public commitment + bundle = bd._read_encrypted_json(paths["bundle"], paths["key"]) + assert bd.seed_commitment(bundle["seed"]) == commitment["seed_sha256"] + # one-shot custody: a second init in the same dir refuses + with pytest.raises(FileExistsError, match="refusing to overwrite"): + bd.blind_init(str(tmp_path), log=_NOLOG) + + +def test_read_seed_fails_closed_on_drifted_config(tmp_path): + bd.blind_init(str(tmp_path), log=_NOLOG) + with pytest.raises(ValueError, match="config digest"): + bd._read_seed(str(tmp_path), bd.BlindingConfig(s8_half_width=0.01)) + + +def _stamp(s, seed="s", label="A", config=None): + bd._stamp_provenance( + s, + bd.seed_commitment(seed), + label, + (config or bd.BlindingConfig()).config_digest(), + ) + return s + + +def test_assert_consistent_blind_shared_stamp(): + """One commitment across all blindable parts ⇒ the shared stamp returns; + ρ/τ parts are exempt from the assertion.""" + parts = make_parts(nbins=1) + for name in ("xi_reporting", "xi_integration", "cl"): + _stamp(parts[name]) + stamp = bd.assert_consistent_blind(list(parts.values())) + assert stamp == { + "concealed": True, + "blind": "A", + "blind_commitment": bd.seed_commitment("s"), + "blind_config_digest": bd.BlindingConfig().config_digest(), + } + + +def test_assert_consistent_blind_fails_closed(): + """AC6 (assembly): mismatched commitments and mixed states both refuse.""" + parts = make_parts(nbins=1, with_rho=False) + _stamp(parts["xi_reporting"], seed="one") + _stamp(parts["xi_integration"], seed="one") + _stamp(parts["cl"], seed="two") # different seed ⇒ different commitment + with pytest.raises(ValueError, match="different blind commitments"): + bd.assert_consistent_blind(list(parts.values())) + + parts = make_parts(nbins=1, with_rho=False) + _stamp(parts["xi_reporting"]) # blinded beside plaintext blindable parts + with pytest.raises(ValueError, match="mixed"): + bd.assert_consistent_blind(list(parts.values())) + + +def test_assert_consistent_blind_all_plaintext_is_none(): + """A declared-mock plaintext assembly (nothing blinded) asserts nothing.""" + assert bd.assert_consistent_blind(list(make_parts().values())) is None + + +def test_assert_consistent_blind_unconcealed_data_fails_closed(): + """PRD §4 "Mocks vs data": an unconcealed blindable part may assemble only + if its metadata declares ``type == "mock"`` — an unconcealed ``data`` part, + or one missing the tag, fails closed (skipping the blind can never + silently expose real data). Mixed concealed/plaintext still fails on the + mixed guard regardless of type.""" + parts = make_parts(nbins=1, with_rho=False) + parts["xi_integration"].metadata["type"] = "data" + with pytest.raises(ValueError, match="type"): + bd.assert_consistent_blind(list(parts.values())) + + parts = make_parts(nbins=1, with_rho=False) + del parts["cl"].metadata["type"] # missing tag counts as not-a-mock + with pytest.raises(ValueError, match=""): + bd.assert_consistent_blind(list(parts.values())) + + # concealed data parts assemble integration (that is the whole point of the blind) + parts = make_parts(nbins=1, with_rho=False) + for p in parts.values(): + p.metadata["type"] = "data" + _stamp(p) + assert bd.assert_consistent_blind(list(parts.values()))["concealed"] is True + + # mixed concealed/plaintext fails on the mixed guard even for mocks + parts = make_parts(nbins=1, with_rho=False) + _stamp(parts["xi_reporting"]) + with pytest.raises(ValueError, match="mixed"): + bd.assert_consistent_blind(list(parts.values())) + + +def test_gather_fails_closed_on_unconcealed_data_part(): + """The type guard reaches the terminal gather surface too.""" + parts = make_parts(nbins=1, with_rho=False) + parts["xi_reporting"].metadata["type"] = "data" + with pytest.raises(ValueError, match="type"): + sio.gather(list(parts.values())) + + +def test_assert_consistent_blind_differing_labels_warn_not_fail(): + """Same seed+config, different --label ⇒ assemble cleanly with a warning. + + The label is provenance, not custody state: parts blinded under one blind + but tagged with different labels must not be misread as different blinds. + The assembly succeeds (keyed on commitment+digest); a distinct warning + surfaces the label divergence rather than a false "different commitments". + """ + parts = make_parts(nbins=1, with_rho=False) + _stamp(parts["xi_reporting"], seed="one", label="A") + _stamp(parts["xi_integration"], seed="one", label="A") + _stamp(parts["cl"], seed="one", label="B") # same blind, different label + with pytest.warns(UserWarning, match="different labels"): + stamp = bd.assert_consistent_blind(list(parts.values())) + assert stamp["blind_commitment"] == bd.seed_commitment("one") + assert stamp["blind"] == "A" # deterministic: sorted-first label + + +def test_gather_assembles_parts_and_stamps_blind(): + """The sacc_io gather combines parts (points, tags, covariance blocks), + calls the assembly assertion, and stamps the shared blind.""" + parts = make_parts(nbins=1) + for name in ("xi_reporting", "xi_integration", "cl"): + _stamp(parts[name]) + ordered = [parts[k] for k in ("xi_reporting", "cl", "rho_tau", "xi_integration")] + s = sio.gather(ordered, metadata={"catalogue_version": "vTEST", "type": "mock"}) + + assert len(s.mean) == sum(len(p.mean) for p in ordered) + assert np.array_equal(np.array(s.mean), np.concatenate([p.mean for p in ordered])) + # covariance: block-diagonal of the parts, in order + cursor = 0 + for p in ordered: + n = len(p.mean) + assert np.array_equal( + s.covariance.dense[cursor : cursor + n, cursor : cursor + n], + p.covariance.dense, + ) + cursor += n + # the integration rows are addressable by the grid tag in the assembled file + assert len(s.indices(sio.XI_PLUS, grid="integration")) == len( + parts["xi_integration"].indices(sio.XI_PLUS, grid="integration") + ) + # bandpower windows survive assembly + assert s.get_bandpower_windows(s.indices(sio.CL_EE)) is not None + # blind stamp on the assembled file + assert s.metadata["concealed"] is True + assert s.metadata["blind_commitment"] == bd.seed_commitment("s") + assert s.metadata["catalogue_version"] == "vTEST" + + +def test_gather_fails_closed_on_mismatched_blinds(): + parts = make_parts(nbins=1, with_rho=False) + _stamp(parts["xi_reporting"], seed="one") + _stamp(parts["xi_integration"], seed="two") + _stamp(parts["cl"], seed="one") + with pytest.raises(ValueError, match="different blind commitments"): + sio.gather(list(parts.values())) + + +def test_cli_blind_init_refuses_existing_state(tmp_path): + """The blind-init CLI refuses to overwrite a previous blind's state.""" + import importlib.util + + script = ( + pathlib.Path(__file__).resolve().parents[3] / "scripts" / "blind_data_vector.py" + ) + spec = importlib.util.spec_from_file_location("_blind_cli", script) + cli = importlib.util.module_from_spec(spec) + spec.loader.exec_module(cli) + + (tmp_path / "commitment.json").write_text("{}") + with pytest.raises(SystemExit, match="refusing to overwrite"): + cli.main(["blind-init", str(tmp_path)]) + + +# --------------------------------------------------------------------------- # +# AC2 + AC3 + AC7: the shift itself (slow — fork + CCL) +# --------------------------------------------------------------------------- # +@pytest.mark.slow +@pytest.mark.parametrize( + "transfer_function", + ["boltzmann_camb", "eisenstein_hu"], + ids=["default-camb", "non-default-eh"], +) +def test_ac2_on_file_shift_equals_theory_difference_per_part(transfer_function): + """AC2: per-row shift on each blinded part == theory_fn(hidden) − + theory_fn(fiducial), hidden recovered by re-running the fork's draw — + for all three parts, on a two-bin fixture; and the recovered hidden + cosmology is identical across the three parts (one seed → one hidden). + + Scope: this verifies fork-draw recovery + placement (the shift on the + file is exactly what re-running the same backend at the recovered + hidden/fiducial points predicts, at the recorded rows). It is NOT a + backend-correctness test: both sides run the identical ``theory_fn``, so + any wrong-cosmology dependence cancels and a wrong backend would still + pass here. Backend correctness is carried by AC3. + + Parametrized over the Boltzmann backend (#280): the default CAMB route + and one non-default (Eisenstein–Hu — cheap, no CAMB run) both thread the + same ``transfer_function`` knob through all three theory backends.""" + cfg = bd.BlindingConfig.from_overrides( + {"theory": {"transfer_function": transfer_function}} + ) + seed = "ac2-seed" + parts = make_parts(nbins=2, with_rho=False) + + hiddens, worst = [], 0.0 + for name, part in parts.items(): + blinded = bd.blind_sacc(part, seed, config=cfg, log=_NOLOG) + hidden = bd.hidden_params(seed, cfg) # recovered per part + hiddens.append(hidden) + fiducial = cfg.theory.ccl_params() + ((block_name, indices, factory),) = bd._blindable_blocks(part) + theory = factory(bd._extract_block(part, indices), cfg.theory) + expected = theory(hidden) - theory(fiducial) + actual = np.array(blinded.mean)[indices] - np.array(part.mean)[indices] + gap = np.max(np.abs(actual - expected)) + scale = np.max(np.abs(expected)) + worst = max(worst, gap / scale) + assert gap <= 1e-10 * max(scale, 1e-30), f"{name}/{block_name}: |Δ|={gap:.3e}" + # one seed → one hidden cosmology across all parts + assert hiddens[0] == hiddens[1] == hiddens[2] + print(f"\nAC2 max relative shift mismatch across parts: {worst:.3e}") + + +@pytest.mark.slow +def test_ac3_cross_backend_against_independent_ccl_reference(): + """AC3: the realized shift matches an independently written direct-CCL + reference (self-contained here; does not touch the blinding backends).""" + import pyccl as ccl + + cfg = bd.BlindingConfig() + seed = "ac3-seed" + reporting = make_xi_part("reporting", nbins=2) + cl_part = make_cl_part(nbins=2) + blinded_xi = bd.blind_sacc(reporting, seed, config=cfg, log=_NOLOG) + blinded_cl = bd.blind_sacc(cl_part, seed, config=cfg, log=_NOLOG) + hidden = bd.hidden_params(seed, cfg) + fiducial = cfg.theory.ccl_params() + + # ----- independent reference (from scratch; same fixture n(z), θ, ℓ) ---- + def ref_cosmo(params): + return ccl.Cosmology( + **params, + matter_power_spectrum="camb", + extra_parameters={ + "camb": {"halofit_version": "mead2020_feedback", "HMCode_logT_AGN": 7.5} + }, + ) + + ell = np.unique( + np.concatenate([np.arange(2, 50), np.geomspace(50, 6e4, 200)]).astype(float) + ) + + def ref_xi(params, nz_i, nz_j, theta): + cosmo = ref_cosmo(params) + ti = ccl.WeakLensingTracer(cosmo, dndz=nz_i) + tj = ccl.WeakLensingTracer(cosmo, dndz=nz_j) + cl = ccl.angular_cl(cosmo, ti, tj, ell) + xip = ccl.correlation(cosmo, ell=ell, C_ell=cl, theta=theta / 60.0, type="GG+") + xim = ccl.correlation(cosmo, ell=ell, C_ell=cl, theta=theta / 60.0, type="GG-") + return xip, xim + + worst = 0.0 + for i, j in bd.xi_pairs(reporting, "reporting"): + tr = sio._pair((i, j)) + theta = sio._tag(reporting, sio.XI_PLUS, tr, "theta", grid="reporting") + nz_i, nz_j = sio.get_nz(reporting, i), sio.get_nz(reporting, j) + xip_h, xim_h = ref_xi(hidden, nz_i, nz_j, theta) + xip_f, xim_f = ref_xi(fiducial, nz_i, nz_j, theta) + for dt, ref_shift in ( + (sio.XI_PLUS, xip_h - xip_f), + (sio.XI_MINUS, xim_h - xim_f), + ): + idx = reporting.indices(dt, tr, grid="reporting") + realized = np.array(blinded_xi.mean)[idx] - np.array(reporting.mean)[idx] + worst = max(worst, np.max(np.abs(realized - ref_shift))) + print(f"\nAC3 max |realized − independent reference| (reporting ξ±): {worst:.3e}") + assert worst < 1e-8 # observed ~1e-10; factor magnitudes ~1e-6 + + # pseudo-Cℓ: W @ ΔCℓ_EE against the same independent reference + for i, j in bd.cl_pairs(cl_part): + tr = sio._pair((i, j)) + idx = cl_part.indices(sio.CL_EE, tr) + window = cl_part.get_bandpower_windows(idx) + w_ell = np.asarray(window.values, dtype=float) + w_mat = np.asarray(window.weight, dtype=float) + nz_i, nz_j = sio.get_nz(cl_part, i), sio.get_nz(cl_part, j) + + def ref_cl(params): + cosmo = ref_cosmo(params) + ti = ccl.WeakLensingTracer(cosmo, dndz=nz_i) + tj = ccl.WeakLensingTracer(cosmo, dndz=nz_j) + return ccl.angular_cl(cosmo, ti, tj, w_ell) + + ref_shift = w_mat.T @ (ref_cl(hidden) - ref_cl(fiducial)) + realized = np.array(blinded_cl.mean)[idx] - np.array(cl_part.mean)[idx] + assert np.max(np.abs(realized - ref_shift)) < 1e-12 # bandpowers ~1e-9 + + +@pytest.mark.slow +def test_ac7_reproducibility_same_seed_same_shift(): + """AC7: two blind runs of the same part with the same (seed, config) + produce identical shifts; a different seed produces a different one.""" + part = make_xi_part("reporting") + b1 = bd.blind_sacc(part, "repro-seed", log=_NOLOG) + b2 = bd.blind_sacc(part, "repro-seed", log=_NOLOG) + assert np.array_equal(np.array(b1.mean), np.array(b2.mean)) + b3 = bd.blind_sacc(part, "other-seed", log=_NOLOG) + assert not np.array_equal(np.array(b1.mean), np.array(b3.mean)) + + +# --------------------------------------------------------------------------- # +# AC1, AC4, AC5, AC9: born-blinded derived statistics (slow + cosmo_numba) +# --------------------------------------------------------------------------- # +@pytest.mark.slow +def test_ac1_zero_shift_is_identity(): + """AC1: a zero envelope reproduces every part exactly, and the integration part + run downstream through the b_modes seams yields COSEBIs and pure-E/B + identical to the unblinded run — the per-part plumbing and the + born-blinded derivation path are the identity at zero shift.""" + pytest.importorskip("cosmo_numba") + zero = bd.BlindingConfig(s8_half_width=0.0, omega_m_half_width=0.0) + parts = make_parts(nbins=1, with_rho=False) + blinded = { + name: bd.blind_sacc(p, "any-seed", config=zero, log=_NOLOG) + for name, p in parts.items() + } + for name, part in parts.items(): + assert np.array_equal(np.array(part.mean), np.array(blinded[name].mean)), ( + f"zero-shift blind changed {name}" + ) + # derived statistics downstream: identical inputs ⇒ identical numbers + En_t, Bn_t, modes_t = _derive_downstream( + parts["xi_reporting"], parts["xi_integration"] + ) + En_b, Bn_b, modes_b = _derive_downstream( + blinded["xi_reporting"], blinded["xi_integration"] + ) + assert np.array_equal(En_t, En_b) and np.array_equal(Bn_t, Bn_b) + for key in modes_t: + t, b = modes_t[key], modes_b[key] + both_nan = np.isnan(t) & np.isnan(b) + assert np.array_equal(t[~both_nan], b[~both_nan]), key + assert np.array_equal(np.isnan(t), np.isnan(b)), key + + +@pytest.mark.slow +def test_ac4_b_mode_invariance_and_leakage_floor(): + """AC4: the ΔBₙ induced by deriving B-modes from the blinded integration ξ± is + independent of the injected B amplitude (a fixed absolute E→B leakage + offset, not fractional). The magnitude is measured and reported, never + asserted against a constant.""" + pytest.importorskip("cosmo_numba") + seed = "ac4-seed" + deltas, reports = [], [] + for amp in (2e-6, 2e-5): + reporting = make_xi_part("reporting") + integration = make_xi_part("integration", b_amplitude=amp) + blinded_reporting = bd.blind_sacc(reporting, seed, log=_NOLOG) + blinded_integration = bd.blind_sacc(integration, seed, log=_NOLOG) + _, Bn_t, modes_t = _derive_downstream(reporting, integration) + _, Bn_b, modes_b = _derive_downstream(blinded_reporting, blinded_integration) + d_bn = Bn_b - Bn_t + d_xib = modes_b["xip_B"] - modes_t["xip_B"] + finite = np.isfinite(d_xib) + deltas.append((d_bn, d_xib[finite])) + reports.append( + f"B={amp:.0e}: max|ΔBₙ|={np.max(np.abs(d_bn)):.3e} " + f"(ΔBₙ/Bₙ={np.max(np.abs(d_bn)) / np.max(np.abs(Bn_t)):.2e}), " + f"max|Δξ+_B|={np.max(np.abs(d_xib[finite])):.3e} " + f"({np.max(np.abs(d_xib[finite])) / amp:.2%} of injected B)" + ) + print("\nAC4 " + "\nAC4 ".join(reports)) + + (d_bn_1, d_xib_1), (d_bn_2, d_xib_2) = deltas + scale = max(np.max(np.abs(d_bn_1)), 1e-30) + gap = np.max(np.abs(d_bn_1 - d_bn_2)) + print( + f"AC4 ΔBₙ amplitude-independence: max|ΔBₙ(2e-6) − ΔBₙ(2e-5)| = " + f"{gap:.3e} ({gap / scale:.2e} of |ΔBₙ|)" + ) + assert gap <= 1e-9 * scale + 1e-24, ( + "ΔBₙ depends on the injected B amplitude — the shift is not pure E" + ) + # The pure-ξ_B leakage is also amplitude-independent, but only to the + # adaptive-quadrature floor: cosmo_numba's Schneider integrals subdivide + # adaptively, so the estimator is not bit-linear in its inputs and the + # two runs differ at a small fraction of the (tiny) leakage itself. The + # COSEBIs assertion above carries the exact-identity criterion; this one + # bounds the quadrature wobble. + scale_x = max(np.max(np.abs(d_xib_1)), 1e-30) + gap_x = np.max(np.abs(d_xib_1 - d_xib_2)) + print( + f"AC4 Δξ+_B amplitude-independence: {gap_x:.3e} " + f"({gap_x / scale_x:.2e} of the leakage)" + ) + assert gap_x <= 0.05 * scale_x + + +@pytest.mark.slow +def test_ac5_untouched_blocks_and_row_order(): + """AC5: each part's covariance byte-identical; the Cℓ BB/EB rows and the + ρ/τ part never blinded; every part's shifted rows land at their original + within-part indices (order-preservation).""" + parts = make_parts(nbins=1) + seed = "ac5-seed" + blinded = { + name: bd.blind_sacc(parts[name], seed, log=_NOLOG) + for name in ("xi_reporting", "xi_integration", "cl") + } + for name, b in blinded.items(): + part = parts[name] + assert np.array_equal(b.covariance.dense, part.covariance.dense), name + # row order: the identity of every row (type/tracers/tags) unchanged + for a, c in zip(part.data, b.data): + assert (a.data_type, a.tracers, a.tags) == (c.data_type, c.tracers, c.tags) + # BB/EB rows of the Cℓ part untouched (pure E-mode shift) + for dt in (sio.CL_BB, sio.CL_EB): + idx = parts["cl"].indices(dt) + assert np.array_equal( + np.array(blinded["cl"].mean)[idx], np.array(parts["cl"].mean)[idx] + ), f"{dt} was touched by the blind" + # the blindable rows did move (the blind actually blinded) + for name, grid in ( + ("xi_reporting", "reporting"), + ("xi_integration", "integration"), + ): + idx = bd._xi_indices(parts[name], grid) + assert not np.allclose( + np.array(blinded[name].mean)[idx], np.array(parts[name].mean)[idx], atol=0 + ) + # ρ/τ: refused by blind_sacc (test_blind_refuses_non_blindable_part) and + # exempt in assembly — pass through gather untouched + s = sio.gather( + [ + blinded["xi_reporting"], + parts["rho_tau"], + blinded["cl"], + blinded["xi_integration"], + ] + ) + idx = s.indices(sio.RHO_PLUS.format(k=0)) + assert np.array_equal( + np.array(s.mean)[idx], + np.array(parts["rho_tau"].mean)[ + parts["rho_tau"].indices(sio.RHO_PLUS.format(k=0)) + ], + ) + + +@pytest.mark.slow +def test_ac9_pure_eb_nan_parity_under_blind(): + """AC9: the pure-E/B NaN pattern born from the blinded parts is identical + to the true parts' — blinding never moves a NaN. + + The Schneider estimator returns NaN wherever a reporting point lacks + interior support against the edge-based integration bounds. Whatever + that pattern is on the true parts (the fixture puts the outermost + reporting point at the boundary, so it is non-empty here; on production + files it is empty), the blinded derivation must reproduce it bit-for-bit: + the blind is a pure shift of the estimator's inputs, not a change of + estimator support. The finite values move (the ξ± shifted); the NaN mask + does not.""" + pytest.importorskip("cosmo_numba") + seed = "ac9-seed" + reporting, integration = make_xi_part("reporting"), make_xi_part("integration") + _, _, modes_t = _derive_downstream(reporting, integration) + _, _, modes_b = _derive_downstream( + bd.blind_sacc(reporting, seed, log=_NOLOG), + bd.blind_sacc(integration, seed, log=_NOLOG), + ) + for key in modes_t: + t, b = modes_t[key], modes_b[key] + assert np.array_equal(np.isnan(t), np.isnan(b)), ( + f"blinding moved the pure-E/B NaN pattern for {key}" + ) + # the finite values did move (the blind actually shifted the ξ±) + t, b = modes_t["xip_E"], modes_b["xip_E"] + finite = np.isfinite(t) + assert finite.any() and not np.allclose(t[finite], b[finite], atol=0) + + +# --------------------------------------------------------------------------- # +# AC6 + AC8: end-to-end custody through the file surface (slow + cosmo_numba) +# --------------------------------------------------------------------------- # +@pytest.mark.slow +def test_ac6_ac8_end_to_end_init_parts_gather_unblind(tmp_path): + """AC8: blind-init → blind-part on each intermediate part → terminal + gather (hash assertion passes, stamp lands) → unblind restores each part + bit-for-bit, and the derived statistics re-derived from the unblinded + integration part reproduce the truth. AC6: no plaintext part or seed survives + on disk, no ``seed_smokescreen`` key; unblind fails closed on a tampered + commitment.""" + pytest.importorskip("cosmo_numba") + parts = make_parts(nbins=1) + En_true, Bn_true, modes_true = _derive_downstream( + parts["xi_reporting"], parts["xi_integration"] + ) + + blind_dir = tmp_path / "blind" + blind_dir.mkdir() + init = bd.blind_init(str(blind_dir), log=_NOLOG) + + part_files, out_paths = {}, {} + for name in ("xi_reporting", "xi_integration", "cl"): + path = tmp_path / f"{name}.fits" + sio.save(parts[name], str(path)) + out_paths[name] = bd.blind_part(str(path), str(blind_dir), log=_NOLOG) + part_files[name] = path + + # -- custody hygiene (AC6) --------------------------------------------- -- + for name, path in part_files.items(): + assert not path.exists(), f"plaintext part {name} was not deleted" + blinded = sio.load(out_paths[name]["blinded"]) + assert blinded.metadata["concealed"] is True + assert "seed_smokescreen" not in blinded.metadata + assert pathlib.Path(out_paths[name]["escrow"]).exists() + assert not np.array_equal(np.array(blinded.mean), np.array(parts[name].mean)) + with open(init["commitment"], encoding="utf-8") as f: + commitment = json.load(f) + assert set(commitment) == {"label", "seed_sha256", "config_digest"} + blinded_parts = {n: sio.load(p["blinded"]) for n, p in out_paths.items()} + for b in blinded_parts.values(): + assert b.metadata["blind_commitment"] == commitment["seed_sha256"] + # no plaintext json anywhere beside the blind outputs + assert not list(tmp_path.rglob("*escrow.json")) + assert not (blind_dir / "blind_seed.json").exists() + + # -- terminal assembly: hash assertion + stamp (AC8) -------------------- -- + assembled = sio.gather( + [ + blinded_parts["xi_reporting"], + blinded_parts["cl"], + parts["rho_tau"], + blinded_parts["xi_integration"], + ], + metadata={"catalogue_version": "vTEST", "type": "mock"}, + ) + assert assembled.metadata["blind_commitment"] == commitment["seed_sha256"] + # born-blinded derived statistics from the blinded parts differ from truth + En_b, Bn_b, _ = _derive_downstream( + blinded_parts["xi_reporting"], blinded_parts["xi_integration"] + ) + assert not np.allclose(En_b, En_true, atol=0) + + # -- fail-closed on tampered commitment (AC6) --------------------------- -- + tampered = dict(commitment, seed_sha256="0" * 64) + with open(init["commitment"], "w", encoding="utf-8") as f: + json.dump(tampered, f) + with pytest.raises(ValueError, match="sha256"): + bd.unblind_part( + out_paths["xi_integration"]["blinded"], + str(blind_dir), + str(tmp_path / "never.fits"), + log=_NOLOG, + ) + with open(init["commitment"], "w", encoding="utf-8") as f: + json.dump(commitment, f) + with pytest.raises(ValueError, match="config digest"): + bd.unblind_part( + out_paths["xi_integration"]["blinded"], + str(blind_dir), + str(tmp_path / "never.fits"), + config=bd.BlindingConfig(s8_half_width=0.01), + log=_NOLOG, + ) + + # -- bit-for-bit restoration per part (AC8) ----------------------------- -- + restored = {} + for name in ("xi_reporting", "xi_integration", "cl"): + out = tmp_path / f"{name}_restored.fits" + bd.unblind_part( + out_paths[name]["blinded"], str(blind_dir), str(out), log=_NOLOG + ) + restored[name] = sio.load(str(out)) + assert np.array_equal( + np.array(restored[name].mean), np.array(parts[name].mean) + ), f"{name} not restored bit-for-bit" + assert not restored[name].metadata.get("concealed", False) + assert "blind_commitment" not in restored[name].metadata + + # unblinding then re-deriving reproduces the true derived statistics + En_r, Bn_r, modes_r = _derive_downstream( + restored["xi_reporting"], restored["xi_integration"] + ) + assert np.array_equal(En_r, En_true) and np.array_equal(Bn_r, Bn_true) + for key in modes_true: + t, r = modes_true[key], modes_r[key] + both_nan = np.isnan(t) & np.isnan(r) + assert np.array_equal(t[~both_nan], r[~both_nan]), key + assert np.array_equal(np.isnan(t), np.isnan(r)), key + + +def test_ac8_dotted_versioned_part_names_escrow_and_restore(tmp_path): + """AC8 under the canonical catalogue-version naming (dotted stems). + + Production part files carry the versioned name ``v1.4.6.3_xi_reporting.fits`` + etc. ``smokescreen.encryption.encrypt_file`` names its outputs from + ``basename.split('.')[0]``, so both these parts would misfile onto + ``v1.encrpt``/``v1.key`` and the second would silently overwrite the + first's escrowed truth. Guard: the escrow lands at the exact + :func:`part_paths` name, two dot-prefix-sharing parts do not collide, and + each restores bit-for-bit.""" + parts = make_parts(nbins=1) + blind_dir = tmp_path / "blind" + blind_dir.mkdir() + bd.blind_init(str(blind_dir), log=_NOLOG) + + version = "v1.4.6.3" + out_paths, part_files = {}, {} + for name in ("xi_reporting", "xi_integration"): + path = tmp_path / f"{version}_{name}.fits" + sio.save(parts[name], str(path)) + out_paths[name] = bd.blind_part(str(path), str(blind_dir), log=_NOLOG) + part_files[name] = path + + # escrow bundles landed at the declared names (no split('.') truncation), + # and the two dot-prefix-sharing parts did not collide onto one bundle. + escrow_files = {n: p["escrow"] for n, p in out_paths.items()} + assert escrow_files["xi_reporting"] != escrow_files["xi_integration"] + for name, path in part_files.items(): + assert not path.exists(), f"plaintext part {name} was not deleted" + assert pathlib.Path(out_paths[name]["escrow"]).exists(), name + assert pathlib.Path(out_paths[name]["escrow_key"]).exists(), name + # the truncated-name collision target must not exist + assert not (tmp_path / "v1.encrpt").exists() + assert not (tmp_path / "v1.key").exists() + assert not list(tmp_path.rglob("*escrow.json")) + + # each part restores bit-for-bit via its own escrow (not subtraction-only) + for name in ("xi_reporting", "xi_integration"): + out = tmp_path / f"{version}_{name}_restored.fits" + bd.unblind_part( + out_paths[name]["blinded"], str(blind_dir), str(out), log=_NOLOG + ) + restored = sio.load(str(out)) + assert np.array_equal(np.array(restored.mean), np.array(parts[name].mean)), ( + f"{name} not restored bit-for-bit" + ) diff --git a/src/sp_validation/tests/test_camb_ccl_crosscheck.py b/src/sp_validation/tests/test_camb_ccl_crosscheck.py new file mode 100644 index 00000000..b4cb3b3f --- /dev/null +++ b/src/sp_validation/tests/test_camb_ccl_crosscheck.py @@ -0,0 +1,174 @@ +"""CAMB↔CCL theory cross-check (blinding PRD AC10–14). + +The blinding shift is a difference of CCL theory vectors; downstream +inference runs CAMB (CosmoSIS). The shift only means what it is intended to +mean if CCL and CAMB predict the same ξ± at a fixed cosmology on our θ grid. +This module asserts that agreement between the two independent ξ± paths in +:mod:`sp_validation.blinding_theory`: + +- **Path A** (:func:`~sp_validation.blinding_theory.xi_ccl`): CCL-native — CCL's + Boltzmann-CAMB HMCode2020 P(k) route, projected by CCL Limber + FFTLog. +- **Path B** (:func:`~sp_validation.blinding_theory.xi_camb`): an independent + pycamb run produces the HMCode2020 ``P(k, z)`` (σ8-matched ``A_s``), + wrapped in a ``ccl.Pk2D`` and projected through the same CCL machinery. + +Because both paths route their nonlinear P(k) through CAMB's HMCode2020 and +both project through CCL, a common Limber+FFTLog bug cancels: this test +validates the **P(k) recipe** and the **σ8/A_s amplitude convention**, not +the projection. The one convention subtlety it settles: the fiducial fixes +σ8 for CCL but A_s for CAMB; a nominal ``A_s = 2.1e-9`` leaves CAMB's σ8 +≈3% off target — enough to blow a ξ± comparison to ~9–10%. +""" + +import pathlib +import re + +import numpy as np +import pytest + +from sp_validation import blinding_theory as cm + +# Tolerances (AC11/AC12). Observed floor on this fixture: see the printed +# numbers in the slow tests — the tolerances sit above the floor with +# headroom; version bumps move the floor and that is not a regression. +XIP_RTOL = 0.005 # 0.5 % +XIM_RTOL = 0.010 # 1.0 % +# ξ− crosses zero on this grid: the relative assertion applies only where +# |ξ−| exceeds an absolute floor set from the fixture's peak |ξ−|. +XIM_FLOOR_FRAC = 0.05 + + +# --------------------------------------------------------------------------- # +# Deterministic fixture: one Gaussian source bin, 12-point θ grid +# --------------------------------------------------------------------------- # +def _gauss_nz(n=400): + z = np.linspace(0.01, 3.0, n) + nz = np.exp(-0.5 * ((z - 0.7) / 0.2) ** 2) + return z, nz / np.trapezoid(nz, z) + + +THETA_ARCMIN = np.geomspace(5.0, 250.0, 12) + + +def _both_paths(config, **camb_kwargs): + z, nz = _gauss_nz() + xip_a, xim_a = cm.xi_ccl( + config.ccl_params(), config, (z, nz), (z, nz), THETA_ARCMIN + ) + xip_b, xim_b, As = cm.xi_camb(config, (z, nz), THETA_ARCMIN, **camb_kwargs) + return (xip_a, xim_a), (xip_b, xim_b), As + + +def _assert_xi_agreement(a, b, label): + (xip_a, xim_a), (xip_b, xim_b) = a, b + assert np.all(xip_a > 0) and np.all(xip_b > 0) # sensible cosmic shear + rel_p = np.abs(xip_b - xip_a) / np.abs(xip_a) + assert rel_p.max() < XIP_RTOL, ( + f"{label}: ξ+ max rel diff {rel_p.max():.3%} ≥ {XIP_RTOL:.1%}" + ) + floor = XIM_FLOOR_FRAC * np.max(np.abs(xim_a)) + above = np.abs(xim_a) > floor + rel_m = np.abs(xim_b - xim_a)[above] / np.abs(xim_a)[above] + assert rel_m.max() < XIM_RTOL, ( + f"{label}: ξ− max rel diff {rel_m.max():.3%} ≥ {XIM_RTOL:.1%} (on |ξ−| > floor)" + ) + # near the zero crossing: absolute agreement at the floor scale + abs_m = np.abs(xim_b - xim_a)[~above] + if len(abs_m): + assert abs_m.max() < XIM_RTOL * floor, ( + f"{label}: ξ− absolute diff {abs_m.max():.3e} near zero crossing" + ) + print( + f"\n{label}: ξ+ max rel {rel_p.max():.3%}; " + f"ξ− max rel {rel_m.max():.3%} (above floor, " + f"{above.sum()}/{len(above)} points)" + ) + + +# --------------------------------------------------------------------------- # +# AC10: σ8/A_s reconciliation +# --------------------------------------------------------------------------- # +@pytest.mark.slow +def test_ac10_sigma8_As_reconciliation(): + """(a) nominal A_s leaves CAMB's σ8 >2% off target — the convention + offset is real; (b) the closed-form rescale lands on target to <1e-4.""" + cfg = cm.TheoryConfig() + target = cfg.sigma8() + + nominal = cm.camb_linear_sigma8(cfg, 2.1e-9) + offset = abs(nominal / target - 1) + print(f"\nAC10 nominal-A_s σ8 offset: {offset:.4f}") + assert offset > 0.02 + + As = cm.camb_As_for_sigma8(cfg, target) + matched = cm.camb_linear_sigma8(cfg, As) + print(f"AC10 σ8-matched residual: {abs(matched - target):.2e} (A_s={As:.4e})") + assert abs(matched - target) < 1e-4 + + +# --------------------------------------------------------------------------- # +# AC11 + AC12: ξ± agreement at and off the fiducial +# --------------------------------------------------------------------------- # +@pytest.mark.slow +def test_ac11_xi_agreement_at_fiducial(): + cfg = cm.TheoryConfig() + a, b, _ = _both_paths(cfg) + _assert_xi_agreement(a, b, "AC11 fiducial") + + +@pytest.mark.slow +def test_ac12_xi_agreement_off_fiducial(): + """A representative in-envelope offset — the *shift* (a difference of two + theory vectors) must not inherit a stack-disagreement bias.""" + cfg = cm.TheoryConfig.from_overrides({"S8": 0.80 + 0.075, "Omega_m": 0.30 - 0.05}) + a, b, _ = _both_paths(cfg) + _assert_xi_agreement(a, b, "AC12 off-fiducial") + + +# --------------------------------------------------------------------------- # +# AC13: halofit token pinned to the inference config (fast) +# --------------------------------------------------------------------------- # +def test_ac13_halofit_token_matches_inference_config(): + """The blinding fiducial's CCL halofit token equals the CosmoSIS + inference config's ``halofit_version`` — asserted against the config + file itself. All three blinding backends share one recipe by + construction and would agree with each other while jointly diverging + from the inference stack, so this cannot be caught by the cross-backend + test and is asserted independently here.""" + ini = ( + pathlib.Path(__file__).resolve().parents[3] + / "cosmo_inference" + / "cosmosis_config" + / "cosmosis_pipeline_A_ia_cell.ini" + ) + match = re.search(r"^halofit_version\s*=\s*(\S+)", ini.read_text(), re.MULTILINE) + assert match, f"no halofit_version in {ini}" + inference_token = match.group(1) + cfg = cm.TheoryConfig() + assert cfg.ccl_halofit_version == inference_token + # the two stack tokens denote ONE recipe; a divergence is a config bug + assert cfg.camb_halofit_version == cfg.ccl_halofit_version + # #280: the shipped Boltzmann backend is CAMB-through-CCL, matching the + # CosmoSIS+CAMB inference stack — one power-spectrum path. The cross-check + # tests above (AC10–12, 14) all run at this default configuration. + assert cfg.transfer_function == "boltzmann_camb" + + +# --------------------------------------------------------------------------- # +# AC14: fast smoke — broken wiring caught in the fast suite +# --------------------------------------------------------------------------- # +def test_ac14_crosscheck_smoke(): + """Both paths run at coarse resolution: finite, positive, + few-percent-agreeing ξ+, and a σ8-matched A_s in a sane range.""" + cfg = cm.TheoryConfig() + z, nz = _gauss_nz(n=150) + theta = np.geomspace(10.0, 100.0, 4) + xip_a, _ = cm.xi_ccl(cfg.ccl_params(), cfg, (z, nz), (z, nz), theta) + xip_b, _, As = cm.xi_camb( + cfg, (z, nz), theta, n_ell=120, ell_max=30000, kmax=10.0, n_k=200 + ) + assert np.all(np.isfinite(xip_a)) and np.all(np.isfinite(xip_b)) + assert np.all(xip_a > 0) and np.all(xip_b > 0) + assert 1e-9 < As < 3e-9 + rel = np.abs(xip_b - xip_a) / np.abs(xip_a) + assert rel.max() < 0.05, f"smoke ξ+ rel diff {rel.max():.3%} unexpectedly large" From c09b063f2c46d89d7ced93d438e2e6394e1a0607 Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Thu, 16 Jul 2026 11:50:22 +0200 Subject: [PATCH 2/9] test(blinding): adapt to canonical sacc_io save/load and relocated config Three integration-drift fixes surfaced by the reconciled base: - test_ac6_ac8 / test_ac8_dotted: the end-to-end fixtures now pass type="mock" to sio.save (PR-2 requires the provenance stamp); the parts are mocks, blind_part re-saves inheriting that type. - test_ac13: the CosmoSIS halofit config moved to cosmo_inference/cosmosis_config/templates/ on develop; point the AC13 assertion at the new path (token unchanged: mead2020_feedback). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WzUt7VbtXwr2SCHUdiQTyt --- src/sp_validation/tests/test_blinding.py | 4 ++-- src/sp_validation/tests/test_camb_ccl_crosscheck.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/sp_validation/tests/test_blinding.py b/src/sp_validation/tests/test_blinding.py index 7013a81a..5cd0f0f7 100644 --- a/src/sp_validation/tests/test_blinding.py +++ b/src/sp_validation/tests/test_blinding.py @@ -975,7 +975,7 @@ def test_ac6_ac8_end_to_end_init_parts_gather_unblind(tmp_path): part_files, out_paths = {}, {} for name in ("xi_reporting", "xi_integration", "cl"): path = tmp_path / f"{name}.fits" - sio.save(parts[name], str(path)) + sio.save(parts[name], str(path), type="mock") out_paths[name] = bd.blind_part(str(path), str(blind_dir), log=_NOLOG) part_files[name] = path @@ -1081,7 +1081,7 @@ def test_ac8_dotted_versioned_part_names_escrow_and_restore(tmp_path): out_paths, part_files = {}, {} for name in ("xi_reporting", "xi_integration"): path = tmp_path / f"{version}_{name}.fits" - sio.save(parts[name], str(path)) + sio.save(parts[name], str(path), type="mock") out_paths[name] = bd.blind_part(str(path), str(blind_dir), log=_NOLOG) part_files[name] = path diff --git a/src/sp_validation/tests/test_camb_ccl_crosscheck.py b/src/sp_validation/tests/test_camb_ccl_crosscheck.py index b4cb3b3f..3f286474 100644 --- a/src/sp_validation/tests/test_camb_ccl_crosscheck.py +++ b/src/sp_validation/tests/test_camb_ccl_crosscheck.py @@ -139,6 +139,7 @@ def test_ac13_halofit_token_matches_inference_config(): pathlib.Path(__file__).resolve().parents[3] / "cosmo_inference" / "cosmosis_config" + / "templates" / "cosmosis_pipeline_A_ia_cell.ini" ) match = re.search(r"^halofit_version\s*=\s*(\S+)", ini.read_text(), re.MULTILINE) From 9ee6eb9e3bb80f3e2ea16079e1e3251a5bb8c07a Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Tue, 21 Jul 2026 15:41:11 +0200 Subject: [PATCH 3/9] feat(blinding): wire blind-at-birth custody into the SACC migration DAG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Author the Snakemake rules that realise sp_validation.blinding's three-verb custody surface on the migrated cosmo_val workflow (issues #247/#252). - rule blind_init (once per catalogue version): draws the seed, publishes commitment.json + the encrypted seed bundle. Refuses to overwrite state. - rule blind_part (generic over the three blindable stems — reporting ξ±, integration ξ±, analysis pseudo-Cℓ): conceals the part at birth, escrows the true vector beside the blinded output. The plaintext part is a temp() output of its producer on a data run, so only its blinded sibling persists. - common.py: run-type-aware path helpers (blindable_part / maybe_temp / blind_state_paths / version_of) so a data run binds ξ-derived consumers to the blinded siblings and a mock run bypasses blinding entirely — the whole blinding subgraph appears or vanishes with RUN_TYPE. - assemble_sacc: assert_consistent_blind across parts (assembly-time commitment check of #252), stamping the shared blind on the terminal file. - Constrain the npatch wildcard to \d+ so a producer's ξ± output cannot absorb the _blinded suffix (which made rule xi ambiguous with blind_part). The cosmo_val assemble DAG dry-runs: blind_init x2, blind_part x5, assemble consuming the blinded ξ± + pseudo-Cℓ parts. Born-blinded COSEBIs/pure-E/B and the ρ/τ concealed pass-through follow in the next commit. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EaL7prmKUHwJQcDyW3LoxD --- workflow/Snakefile | 4 ++ workflow/common.py | 97 ++++++++++++++++++++++++++++++- workflow/rules/blinding.smk | 74 +++++++++++++++++++++++ workflow/rules/cosmo_val.smk | 13 ++++- workflow/rules/twopoint.smk | 14 ++++- workflow/scripts/assemble_sacc.py | 13 +++++ workflow/scripts/blind_init.py | 12 ++++ workflow/scripts/blind_part.py | 19 ++++++ 8 files changed, 240 insertions(+), 6 deletions(-) create mode 100644 workflow/rules/blinding.smk create mode 100644 workflow/scripts/blind_init.py create mode 100644 workflow/scripts/blind_part.py diff --git a/workflow/Snakefile b/workflow/Snakefile index 6f59827b..0a1f3258 100644 --- a/workflow/Snakefile +++ b/workflow/Snakefile @@ -37,6 +37,10 @@ wildcard_constraints: # Compute rules (infrastructure — raw outputs, no evidence.json) include: "rules/twopoint.smk" +# Smokescreen blind-at-birth custody (blind_init / blind_part). Generic over the +# blindable parts twopoint.smk produces; dormant unless a data run requests a +# blinded part. Included before covariance/cosmo_val so their consumers resolve. +include: "rules/blinding.smk" include: "rules/covariance.smk" include: "rules/inference.smk" include: "rules/masks.smk" diff --git a/workflow/common.py b/workflow/common.py index 1a168041..530b9212 100644 --- a/workflow/common.py +++ b/workflow/common.py @@ -5,6 +5,8 @@ import re from pathlib import Path +from snakemake.io import temp + # Absolute path to the generic workflow's scripts, anchored on this module's own # location (common.py lives in workflow/, is `from common import *`'d into every # Snakefile, and so resolves to the generic workflow dir of the running checkout @@ -51,6 +53,10 @@ # glass-mock A/B/C variant, not Smokescreen blinding — see BLINDS above. "blind": r"[ABC]", "nbins": r"\d+", + # Constrained so a producer's ξ± output pattern cannot greedily absorb the + # "_blinded" suffix into npatch (which would make it ambiguous with the + # blind_part rule's {stem}_blinded output). npatch is always an integer. + "npatch": r"\d+", "min_sep": r"[0-9.]+", "max_sep": r"[0-9.]+", "gaussian": r"(g|ng)", @@ -65,16 +71,22 @@ DEFAULT_MASK_SUFFIX = "" CATALOG_CONFIG = None PLANCK18 = None +# Run type gates Smokescreen blind-at-birth (see the blind custody section +# below): "data" blinds the three blindable parts and binds ξ-derived consumers +# to the blinded siblings; "mock" bypasses blinding entirely. Set from +# config["cosmo_val"]["type"] in configure(); "data" is the production default. +RUN_TYPE = "data" def configure(workflow_config): """Install config-derived values after Snakemake has loaded configfiles.""" - global CATALOG_CONFIG, DEFAULT_MASK_SUFFIX, FIDUCIAL, PLANCK18 + global CATALOG_CONFIG, DEFAULT_MASK_SUFFIX, FIDUCIAL, PLANCK18, RUN_TYPE CATALOG_CONFIG = workflow_config FIDUCIAL = workflow_config["fiducial"] DEFAULT_MASK_SUFFIX = ( "_masked" if workflow_config["covariance"].get("default_masked", False) else "" ) + RUN_TYPE = workflow_config.get("cosmo_val", {}).get("type", "data") with open(COSMOLOGY_PARAMS) as f: PLANCK18 = json.load(f) @@ -196,6 +208,89 @@ def get_shear_catalog(wildcards): return str(Path(subdir) / shear_path) +# --------------------------------------------------------------------------- +# Smokescreen blind-at-birth custody (issues #247/#252, PR #253) +# --------------------------------------------------------------------------- +# Distinct from the glass-mock A/B/C `blind` wildcard above: this is Smokescreen +# concealment (the concealed=True SACC stamp). Blinding is per part, at birth. +# The three blindable parts (reporting ξ±, integration ξ±, pseudo-Cℓ) are each +# concealed the moment they are computed, so only blinded parts persist on disk. +# A `data` run binds every ξ-derived consumer (the terminal assemble, and the +# born-blinded COSEBIs / pure-E/B) to the *_blinded parts, which pulls the +# blind_part → blind_init subgraph into the DAG. A `mock` run bypasses blinding +# entirely and binds to the plaintext parts, so the subgraph never appears. +# RUN_TYPE is the single switch that flips which files exist. +# +# The path helpers below MIRROR sp_validation.blinding.init_paths / part_paths +# by hand rather than importing blinding (which pulls in numpy + smokescreen) at +# DAG-build time. test_blinding_wiring asserts the two stay in lockstep. + + +def is_data_run(): + """True when blinding is active (production data runs); False for mocks.""" + return RUN_TYPE == "data" + + +def blind_state_dir(version): + """Per-version blind-init custody directory (commitment + encrypted seed).""" + return str(COSMO_VAL / "blind" / version) + + +def blind_state_paths(version): + """The fixed custody-state files blind_init writes for a version. + + Mirrors sp_validation.blinding.init_paths(blind_state_dir(version)). + """ + d = blind_state_dir(version) + return { + "commitment": os.path.join(d, "commitment.json"), + "bundle": os.path.join(d, "blind_seed.encrpt"), + "key": os.path.join(d, "blind_seed.key"), + } + + +def blinded_path(part_path): + """The *_blinded sibling blind_part writes beside a plaintext part. + + Mirrors sp_validation.blinding.part_paths(part_path)["blinded"]. + """ + stem, ext = os.path.splitext(str(part_path)) + return f"{stem}_blinded{ext or '.fits'}" + + +def version_of(stem): + """Extract the catalogue version embedded in a blindable part's stem. + + Every blindable stem carries the version (as {version}_xi_… or + pseudo_cl_{version}_…); blind_part needs it to locate the version's blind + state. Matches the shared `version` wildcard pattern. + """ + m = re.search(WILDCARD_CONSTRAINTS["version"], stem) + if m is None: + raise ValueError(f"no catalogue version found in part stem {stem!r}") + return m.group(0) + + +def blindable_part(part_path): + """On-disk path a run persists for one blindable part. + + Data run -> the blinded sibling (binding it pulls blind_part + blind_init + into the DAG); mock run -> the plaintext part (blinding bypassed). + """ + return blinded_path(part_path) if is_data_run() else str(part_path) + + +def maybe_temp(part_path): + """Wrap a producer's blindable plaintext part temp() on data runs. + + On a data run the plaintext part's only consumer is blind_part, which + escrows the true vector before Snakemake removes the temp file — so no + plaintext blindable part persists. On a mock run the part is the real + product downstream binds to, so it is left persistent. + """ + return temp(str(part_path)) if is_data_run() else str(part_path) + + # --------------------------------------------------------------------------- # CosmologyValidation diagnostic suite (cosmo_val.py) # --------------------------------------------------------------------------- diff --git a/workflow/rules/blinding.smk b/workflow/rules/blinding.smk new file mode 100644 index 00000000..bf79433f --- /dev/null +++ b/workflow/rules/blinding.smk @@ -0,0 +1,74 @@ +# Smokescreen blind-at-birth custody rules (issues #247/#252, PR #253). +# +# Two rules realise the three-verb custody surface of sp_validation.blinding on +# the DAG. They only enter the graph on a `data` run, and only when a consumer +# binds to a *_blinded part through common.blindable_part — a `mock` run never +# requests a blinded file, so blind_part and blind_init stay dormant. +# +# blind_init (once per catalogue version) draws the seed, publishes +# commitment.json + the encrypted seed bundle. +# blind_part (once per blindable part, at birth) conceals the part, escrows +# the true vector beside the blinded output, and lets Snakemake +# remove the plaintext (temp()) once it is the sole consumer. +# +# The terminal assemble_sacc rule (cosmo_val.smk) asserts the shared commitment +# across parts — the assembly-time custody check of #252. + +# The three blindable stems: reporting ξ± (rule xi), integration ξ± (xi_highres), +# and the analysis pseudo-Cℓ (pseudo_cl). None contains "_blinded", so the +# generic blind_part rule can never blind its own output twice. The version +# pattern is the shared one from common.WILDCARD_CONSTRAINTS. +_V = WILDCARD_CONSTRAINTS["version"] +BLINDABLE_STEM = ( + rf"(?:{_V}_xi_reporting_minsep=[0-9.]+_maxsep=[0-9.]+_nbins=\d+_npatch=\d+" + rf"|{_V}_xi_integration" + rf"|pseudo_cl_{_V}_blind=[ABC]_[a-z]+_nbins=\d+)" +) + + +rule blind_init: + """Fix the blind for one catalogue version (blind-init). + + Draws an OS-entropy seed, writes the repo-committable commitment.json + (sha256(seed) + config digest) and the Fernet-encrypted seed bundle. Runs + once per version and refuses to overwrite existing state — a blind is a + one-shot custody event. + """ + output: + commitment=str(COSMO_VAL / "blind" / "{version}" / "commitment.json"), + bundle=str(COSMO_VAL / "blind" / "{version}" / "blind_seed.encrpt"), + key=str(COSMO_VAL / "blind" / "{version}" / "blind_seed.key"), + params: + blind_dir=lambda w: blind_state_dir(w.version), + resources: + runtime=5, + script: + "../scripts/blind_init.py" + + +rule blind_part: + """Blind one intermediate part SACC at birth (blind-part). + + Conceals the plaintext part through its matching theory backend, escrows the + true vector into a per-part encrypted bundle beside the blinded output, and + leaves the plaintext for Snakemake to remove (it is a temp() output of the + producing rule, and this is its only consumer on a data run). Generic over + the three blindable stems. + """ + input: + part=str(COSMO_VAL / "{stem}.sacc"), + commitment=lambda w: blind_state_paths(version_of(w.stem))["commitment"], + bundle=lambda w: blind_state_paths(version_of(w.stem))["bundle"], + key=lambda w: blind_state_paths(version_of(w.stem))["key"], + output: + blinded=str(COSMO_VAL / "{stem}_blinded.sacc"), + escrow=str(COSMO_VAL / "{stem}_escrow.encrpt"), + escrow_key=str(COSMO_VAL / "{stem}_escrow.key"), + wildcard_constraints: + stem=BLINDABLE_STEM, + params: + blind_dir=lambda w: blind_state_dir(version_of(w.stem)), + resources: + runtime=10, + script: + "../scripts/blind_part.py" diff --git a/workflow/rules/cosmo_val.smk b/workflow/rules/cosmo_val.smk index 73ec22c7..7a7e62c6 100644 --- a/workflow/rules/cosmo_val.smk +++ b/workflow/rules/cosmo_val.smk @@ -482,8 +482,15 @@ def cv_assemble_inputs(version): fiducial harmonic tag). pseudo_cl (+ its cov) is included only when the config toggles the harmonic-space BB into the analysis. """ + # blindable_part binds the raw-signal parts (reporting/integration ξ±, + # analysis pseudo-Cℓ) to their blinded siblings on a data run and to the + # plaintext on a mock run. COSEBIs and pure-E/B are born blinded (their + # writers derive them from the blinded integration ξ± and stamp + # concealed=True), so they bind by their own name in both cases; ρ/τ is a + # diagnostic carrying no cosmological vector but is stamped concealed + # pass-through so the fail-closed load gate admits it on a data run. parts = dict( - xi_reporting=cv_xi_reporting_sacc(version), + xi_reporting=blindable_part(cv_xi_reporting_sacc(version)), cosebis=cv_cosebis_sacc(version), pure_eb=cv_pure_eb_sacc(version), rho_tau=cv_rho_tau_sacc(version), @@ -493,9 +500,9 @@ def cv_assemble_inputs(version): # fiducial version's terminal file alone; other versions' {version}.sacc omit # the integration rows rather than trigger a job with no output to bind. if version == config["fiducial"]["version"]: - parts["xi_integration"] = cv_xi_integration_sacc(version) + parts["xi_integration"] = blindable_part(cv_xi_integration_sacc(version)) if CV.get("include_pseudo_cl", False): - parts["pseudo_cl"] = cv_pseudo_cl_analysis_sacc(version) + parts["pseudo_cl"] = blindable_part(cv_pseudo_cl_analysis_sacc(version)) parts["pseudo_cl_cov"] = cv_pseudo_cl_cov(version) return parts diff --git a/workflow/rules/twopoint.smk b/workflow/rules/twopoint.smk index 9eb90c64..f4a644ee 100644 --- a/workflow/rules/twopoint.smk +++ b/workflow/rules/twopoint.smk @@ -14,7 +14,9 @@ rule xi: # rule to share one wildcard set, and it keeps the reporting .sacc name # self-describing so requesting it binds the xi job unambiguously. txt=str(COSMO_VAL / "{version}_xi_minsep={min_sep}_maxsep={max_sep}_nbins={nbins}_npatch={npatch}.txt"), - xi_reporting=str(COSMO_VAL / "{version}_xi_reporting_minsep={min_sep}_maxsep={max_sep}_nbins={nbins}_npatch={npatch}.sacc"), + # Blindable part: temp() on a data run so only its blinded sibling + # persists (blind_part escrows the true vector first). See common.maybe_temp. + xi_reporting=maybe_temp(str(COSMO_VAL / "{version}_xi_reporting_minsep={min_sep}_maxsep={max_sep}_nbins={nbins}_npatch={npatch}.sacc")), threads: 24 params: ver="{version}", @@ -42,7 +44,8 @@ rule xi_highres: container: None output: txt=str(COSMO_VAL / f"{FIDUCIAL['version']}_xi_minsep={FIDUCIAL['min_sep_int']}_maxsep={FIDUCIAL['max_sep_int']}_nbins=10000_npatch=1.txt"), - xi_integration=str(COSMO_VAL / f"{FIDUCIAL['version']}_xi_integration.sacc"), + # Blindable part: temp() on a data run (see rule xi / common.maybe_temp). + xi_integration=maybe_temp(str(COSMO_VAL / f"{FIDUCIAL['version']}_xi_integration.sacc")), resources: tasks=30, cpus_per_task=12, @@ -116,6 +119,13 @@ rule pseudo_cl: concealed=True stamp is a separate axis on the SACC file. """ output: + # This generic rule produces every pseudo-Cℓ variant — the analysis part + # (blind=A, powspace, nbins=32) folded into {version}.sacc, plus the fine + # (COSEBIS) and glass-mock variants. Only the analysis part is a terminal + # blindable, and a data run blinds it via a requested _blinded sibling + # (blind_part reads this plaintext); the fine/mock variants are B-mode / + # validation intermediates left untouched here. The output is therefore + # not temp()'d — see the PR note on residual unblinded pseudo-Cℓ. pseudo_cl=str(COSMO_VAL / "pseudo_cl_{version}_blind={blind}_{binning}_nbins={nbins}.sacc"), wildcard_constraints: blind="[ABC]", # glass-mock variant, not Smokescreen blinding diff --git a/workflow/scripts/assemble_sacc.py b/workflow/scripts/assemble_sacc.py index 4eec725d..84e5c97d 100644 --- a/workflow/scripts/assemble_sacc.py +++ b/workflow/scripts/assemble_sacc.py @@ -187,7 +187,20 @@ def assemble_sacc( ) if not parts: raise ValueError(f"no parts found for {version}: {part_paths}") + # Assembly-time custody assertion (#252): every blindable part (ξ± / pseudo-Cℓ + # EE) must share one blind commitment + config digest, or assembly fails + # closed — mixed blinded/plaintext parts and divergent-seed parts both raise. + # ρ/τ and covariance-only parts are exempt. Returns the shared blind stamp to + # carry onto the assembled file (or None for a fully-mock plaintext assembly). + from sp_validation import blinding + + shared = blinding.assert_consistent_blind(parts) s = assemble_analysis_sacc(nz, metadata, parts) + if shared is not None: + # Stamp the assembled file with the shared blind so it, too, reads as + # concealed (its parts already carried the stamp into `metadata` above; + # this makes the custody state explicit and authoritative on the union). + s.metadata.update(shared) # Assembly preserves its parts' provenance: every part was written by # sacc_io.save and therefore carries the type=data|mock stamp in its # metadata (copied into the assembled file above). diff --git a/workflow/scripts/blind_init.py b/workflow/scripts/blind_init.py new file mode 100644 index 00000000..f9e4b682 --- /dev/null +++ b/workflow/scripts/blind_init.py @@ -0,0 +1,12 @@ +"""Rule blind_init: fix the blind for one catalogue version. + +Thin wrapper over :func:`sp_validation.blinding.blind_init`. Draws the seed, +writes commitment.json + the encrypted seed bundle into the version's blind +directory. The plaintext seed is never written (the encryptor deletes it). +""" + +from snakemake.script import snakemake + +from sp_validation import blinding + +blinding.blind_init(snakemake.params["blind_dir"]) diff --git a/workflow/scripts/blind_part.py b/workflow/scripts/blind_part.py new file mode 100644 index 00000000..b93313fb --- /dev/null +++ b/workflow/scripts/blind_part.py @@ -0,0 +1,19 @@ +"""Rule blind_part: blind one intermediate part SACC at birth. + +Thin wrapper over :func:`sp_validation.blinding.blind_part`. Conceals the part, +escrows the true vector beside the blinded output, and leaves the plaintext in +place: it is a temp() output of the producing rule, so Snakemake removes it once +this (its only consumer on a data run) finishes. keep_input=True hands that +lifecycle to Snakemake rather than deleting inside the blind step, which keeps +the blinded output and its temp input in one consistent DAG accounting. +""" + +from snakemake.script import snakemake + +from sp_validation import blinding + +blinding.blind_part( + snakemake.input["part"], + snakemake.params["blind_dir"], + keep_input=True, +) From be38cd5df43f8d6ec9cacb733c740600fb74e879 Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Tue, 21 Jul 2026 16:03:27 +0200 Subject: [PATCH 4/9] test(blinding): wiring tests for the blind-at-birth DAG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - common.py path helpers mirror sp_validation.blinding init_paths/part_paths (drift guard), version_of on all three blindable stems, run-type switch. - Data-run fail-closed assembly: assemble_sacc refuses an unblinded type=data part, passes when every part is concealed under one commitment, and refuses a blinded/plaintext mix and divergent commitments. - Candide-only: the blinding subgraph (blind_init/blind_part) resolves in the cosmo_val assemble dry-run and binds the blinded ξ± + pseudo-Cℓ siblings. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EaL7prmKUHwJQcDyW3LoxD --- .../tests/test_blinding_wiring.py | 263 ++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 src/sp_validation/tests/test_blinding_wiring.py diff --git a/src/sp_validation/tests/test_blinding_wiring.py b/src/sp_validation/tests/test_blinding_wiring.py new file mode 100644 index 00000000..77beb7ee --- /dev/null +++ b/src/sp_validation/tests/test_blinding_wiring.py @@ -0,0 +1,263 @@ +"""Tests for the Snakemake blind-at-birth wiring (issues #247/#252, PR #253). + +Two seams are covered here, both independent of a live cluster: + +1. **The path helpers in ``workflow/common.py``** must stay in lockstep with + ``sp_validation.blinding`` — common mirrors ``init_paths`` / ``part_paths`` by + hand (to keep the DAG build from importing the heavy blinding module), so a + drift between them would silently mis-wire ``blind_part``. These tests are the + guard. +2. **The data-run fail-closed assembly**: ``assemble_sacc`` must refuse an + unblinded ``type='data'`` part and succeed once every part is concealed under + one commitment — the terminal custody gate of #252. + +A candide-only test additionally asserts the blinding subgraph resolves in the +cosmo_val DAG dry-run. +""" + +import importlib.util +import os +import subprocess +import sys +from pathlib import Path + +import numpy as np +import pytest + +from sp_validation import blinding +from sp_validation import sacc_io as sio +from sp_validation.cosmo_val import sacc_writers as sw + + +def _repo_root(): + return next( + p for p in Path(__file__).resolve().parents if (p / "pyproject.toml").exists() + ) + + +def _load_module(rel_path, name): + """Import a workflow module/script by file path (off the package path).""" + path = _repo_root() / rel_path + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +common = _load_module("workflow/common.py", "wf_common") +asm = _load_module("workflow/scripts/assemble_sacc.py", "assemble_sacc") + + +# --------------------------------------------------------------------------- # +# 1. common.py path helpers mirror sp_validation.blinding (drift guard) +# --------------------------------------------------------------------------- # +_STEMS = [ + "SP_v1.4.6.3_xi_reporting_minsep=1.0_maxsep=250.0_nbins=20_npatch=100", + "SP_v1.4.6.3_leak_corr_xi_integration", + "pseudo_cl_SP_v1.4.6.3_blind=A_powspace_nbins=32", + "pseudo_cl_SP_v1.4.6.3_leak_corr_blind=A_powspace_nbins=32", +] + + +@pytest.mark.parametrize("stem", _STEMS) +def test_blinded_path_mirrors_blinding_part_paths(stem): + part = f"/out/{stem}.sacc" + assert common.blinded_path(part) == blinding.part_paths(part)["blinded"] + + +def test_blind_state_paths_mirror_blinding_init_paths(): + version = "SP_v1.4.6.3_leak_corr" + common_paths = common.blind_state_paths(version) + ref = blinding.init_paths(common.blind_state_dir(version)) + assert common_paths == ref + + +@pytest.mark.parametrize( + "stem,expected", + [ + (_STEMS[0], "SP_v1.4.6.3"), + (_STEMS[1], "SP_v1.4.6.3_leak_corr"), + (_STEMS[2], "SP_v1.4.6.3"), + (_STEMS[3], "SP_v1.4.6.3_leak_corr"), + ], +) +def test_version_of_extracts_catalogue_version(stem, expected): + assert common.version_of(stem) == expected + + +def test_version_of_raises_without_version(): + with pytest.raises(ValueError, match="no catalogue version"): + common.version_of("cosebis_no_version_here") + + +def test_blindable_part_switches_on_run_type(monkeypatch): + part = "/out/SP_v1.4.6.3_xi_integration.sacc" + monkeypatch.setattr(common, "RUN_TYPE", "data") + assert common.blindable_part(part) == common.blinded_path(part) + monkeypatch.setattr(common, "RUN_TYPE", "mock") + assert common.blindable_part(part) == part + + +# --------------------------------------------------------------------------- # +# 2. Data-run fail-closed assembly (#252 terminal custody gate) +# --------------------------------------------------------------------------- # +META = {"catalogue_version": "vSYNTH", "npatch": 1} +# Two arbitrary-but-consistent hex stamps standing in for a real blind's +# sha256(seed) / config digest; the assembly only checks they agree across parts. +_COMMIT = "a" * 64 +_DIGEST = "b" * 64 + + +def _nz(): + return np.linspace(0.01, 2.0, 40), np.random.default_rng(0).uniform(0.1, 1.0, 40) + + +def _spd(n, seed): + a = np.random.default_rng(seed).normal(size=(n, n)) + return a @ a.T + n * np.eye(n) + + +def _data_parts(tmp_path, *, conceal, one_plaintext=False): + """Write the five per-statistic parts as ``type='data'``. + + ``conceal`` stamps every part with the shared blind (concealed=True). With + ``one_plaintext`` the ξ± reporting part is left unconcealed — a blinded / + plaintext mix the assembly must refuse. + """ + nz = {0: _nz()} + theta = np.geomspace(1.0, 100.0, 6) + + xi = sw.xi_to_sacc( + nz, META, theta, np.arange(6) * 1e-5, np.arange(6) * 2e-5, grid="reporting" + ) + xi.add_covariance(_spd(len(xi.mean), 1)) + co = sw.cosebis_to_sacc( + nz, + META, + { + "En": np.arange(1, 6) * 1e-6, + "Bn": np.arange(1, 6) * 1e-7, + "cov": _spd(10, 3), + }, + (1.0, 100.0), + ) + eb_arrays = {k: np.arange(6) * (i + 1) * 1e-6 for i, k in enumerate(sio.PURE_KEYS)} + eb = sw.pure_eb_to_sacc(nz, META, theta, eb_arrays, covariance=_spd(36, 4)) + rho = {"theta": theta} + tau = {"theta": theta} + rng = np.random.default_rng(5) + for k in sw.RHO_K: + for s in ("p", "m"): + rho[f"rho_{k}_{s}"] = rng.normal(size=6) * 1e-6 + rho[f"varrho_{k}_{s}"] = rng.uniform(1e-14, 1e-13, 6) + for k in sw.TAU_K: + for s in ("p", "m"): + tau[f"tau_{k}_{s}"] = rng.normal(size=6) * 1e-6 + tau[f"vartau_{k}_{s}"] = rng.uniform(1e-14, 1e-13, 6) + rt = sw.rho_tau_to_sacc(nz, META, rho, tau) + + parts = {"xi_reporting": xi, "cosebis": co, "pure_eb": eb, "rho_tau": rt} + paths = {} + for name, part in parts.items(): + if conceal and not (one_plaintext and name == "xi_reporting"): + blinding._stamp_provenance(part, _COMMIT, "A", _DIGEST) + p = tmp_path / f"{name}.sacc" + sio.save(part, str(p), type="data") + paths[name] = str(p) + return paths + + +def test_data_assemble_fails_closed_on_unblinded_part(tmp_path): + """A data run refuses to assemble an unconcealed real part (fail closed).""" + paths = _data_parts(tmp_path, conceal=False) + with pytest.raises(ValueError, match="refusing to load an unblinded"): + asm.assemble_sacc( + "vSYNTH", paths, str(tmp_path / "vSYNTH.sacc"), placeholder_var=1.0 + ) + + +def test_data_assemble_passes_on_blinded_parts(tmp_path): + """With every part concealed under one blind, the data-run assembly succeeds + and stamps the shared commitment on the terminal file.""" + paths = _data_parts(tmp_path, conceal=True) + out = tmp_path / "vSYNTH.sacc" + s = asm.assemble_sacc("vSYNTH", paths, str(out), placeholder_var=1.0) + assert s.metadata["concealed"] is True + assert s.metadata["blind_commitment"] == _COMMIT + assert s.metadata["blind_config_digest"] == _DIGEST + # Round-trips through the fail-closed load gate without an escape hatch. + assert sio.load(str(out)).metadata["concealed"] is True + + +def test_data_assemble_refuses_blinded_plaintext_mix(tmp_path): + """A concealed ξ± beside a plaintext one is a custody violation — refuse.""" + paths = _data_parts(tmp_path, conceal=True, one_plaintext=True) + # The plaintext ξ± reporting part fails the load gate first (data + not + # concealed), so the mix can never even reach assembly. + with pytest.raises(ValueError, match="refusing to load an unblinded"): + asm.assemble_sacc( + "vSYNTH", paths, str(tmp_path / "vSYNTH.sacc"), placeholder_var=1.0 + ) + + +def test_assert_consistent_blind_rejects_divergent_commitments(tmp_path): + """Two ξ± parts blinded under different commitments must never combine.""" + nz = {0: _nz()} + theta = np.geomspace(1.0, 100.0, 6) + a = sw.xi_to_sacc( + nz, META, theta, np.arange(6) * 1e-5, np.arange(6) * 2e-5, grid="reporting" + ) + b = sw.xi_to_sacc( + nz, META, theta, np.arange(6) * 1e-5, np.arange(6) * 2e-5, grid="integration" + ) + blinding._stamp_provenance(a, _COMMIT, "A", _DIGEST) + blinding._stamp_provenance(b, "c" * 64, "A", _DIGEST) + with pytest.raises(ValueError, match="different blind commitments"): + blinding.assert_consistent_blind([a, b]) + + +# --------------------------------------------------------------------------- # +# 3. The blinding subgraph resolves in the cosmo_val DAG (candide-only) +# --------------------------------------------------------------------------- # +requires_candide_data = pytest.mark.skipif( + not Path("/n17data/cdaley/unions").exists(), + reason="candide-local workflow config/data (/n17data) absent — off-cluster", +) + + +@requires_candide_data +def test_blinding_subgraph_in_cosmo_val_dry_run(): + """A data-run cosmo_val assemble pulls blind_init + blind_part, and binds the + ξ± / pseudo-Cℓ parts to their *_blinded siblings.""" + env = os.environ | {"PYTHONNOUSERSITE": "1", "PYTHONUNBUFFERED": "1"} + env.pop("SNAKEMAKE_PROFILE", None) + result = subprocess.run( + [ + sys.executable, + "-m", + "snakemake", + "assemble_sacc_all", + "--dry-run", + "--cores", + "1", + "--configfile", + "config/config.yaml", + ], + cwd=_repo_root() / "papers/cosmo_val", + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + timeout=180, + check=False, + ) + assert result.returncode == 0, result.stdout + out = result.stdout + assert "rule blind_init:" in out, out + assert "rule blind_part:" in out, out + # assemble consumes the blinded ξ± reporting + integration and pseudo-Cℓ. + assert ( + "_xi_reporting_minsep=1.0_maxsep=250.0_nbins=20_npatch=100_blinded.sacc" in out + ) + assert "_xi_integration_blinded.sacc" in out + assert "_blind=A_powspace_nbins=32_blinded.sacc" in out From 251f4f51c3f8b9edaa7b2b0a854b1b08bc894b75 Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Mon, 17 Aug 2026 20:21:43 +0200 Subject: [PATCH 5/9] Blind through the fork's vector core, and bind the draw scheme into custody MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes to blinding.py, both about the seam with Smokescreen. **The concealing factor is a vector, so ask for a vector.** `_concealing_factor` carved a sub-SACC per block (`s.copy()` + `keep_indices`, covariance and all), handed it to `ConcealDataVector`, applied the blind, and subtracted the result back off to recover the shift it wanted in the first place. The fork now exposes `concealing_factor(fiducial_params, shifts_dict, *, seed, theory_fn)` — the pure theory difference, no SACC, no data vector — so call that. The theory factories already read their layout off whatever SACC they are handed, so they run on the part directly and return a full-length vector, NaN outside their own block; the factor is that vector at the block's rows. `unblind_sacc` differenced the two theory vectors itself. It now goes through the same `_concealing_factor` call, so the shift added and the shift subtracted cannot drift apart. `_extract_block` is gone with its last caller. A backend that leaves a row of its *own* block unfilled would have shifted that point by NaN, silently. The slice is now checked finite and refuses instead. **A blind is (seed, config, draw scheme) — not (seed, config).** The fork versions its shift-draw semantics as `smokescreen.DRAW_SCHEME`: upstream's one global RNG stream over sorted keys is scheme 1, the fork's per-key `(seed, key)` RNG is scheme 2. Blind under one and unblind under the other and the same seed draws a *different* hidden cosmology — so the unblind subtracts a shift that was never added, leaving a smooth residual in the "revealed" vector while the seed hash, the config digest and the escrow equality check all still pass. It is the one blinding failure with no symptom. The scheme is now custody state. `blind_init` writes it into commitment.json; `_stamp_provenance` stamps `blind_draw_scheme` on every blinded file; and it is re-checked against the installed fork wherever a shift is drawn or subtracted — `_read_seed` (so a scheme change between blinding part 1 and part 2 is caught), `unblind_sacc` before any subtraction, `assert_consistent_blind` at the terminal, `stamp_concealed_passthrough`, and the CLI's seedless `verify`. A missing record fails closed: a blind whose scheme is unknown cannot be shown reproducible. Docstrings that promised same-(seed, config) reproducibility "forever", or per-key draw independence, now say what guarantees it. Closes review findings 1, 2 and 4 on PR #253. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BRYg9fjMevgcsvgjh3KN6x --- scripts/blind_data_vector.py | 24 ++ src/sp_validation/blinding.py | 336 ++++++++++++++++------- src/sp_validation/tests/test_blinding.py | 186 ++++++++++++- 3 files changed, 433 insertions(+), 113 deletions(-) diff --git a/scripts/blind_data_vector.py b/scripts/blind_data_vector.py index 9b41109d..d9aeb502 100644 --- a/scripts/blind_data_vector.py +++ b/scripts/blind_data_vector.py @@ -113,6 +113,30 @@ def _verify(args): problems.append("blind_commitment does not match the committed sha256(seed)") if s.metadata.get("blind_config_digest") != commitment["config_digest"]: problems.append("blind_config_digest does not match the committed digest") + # The draw scheme is checked three ways: file ↔ commitment, and both against + # the installed fork. A scheme mismatch is the one blinding failure with no + # numerical symptom — the unblind would subtract a different shift than was + # added and every other check here would still pass. + installed = blinding.draw_scheme() + file_scheme = s.metadata.get("blind_draw_scheme") + committed = commitment.get("draw_scheme") + if file_scheme is None or committed is None: + problems.append( + "no draw-scheme record on the file and/or in the commitment — " + "this blind predates draw-scheme binding and cannot be verified " + "reproducible" + ) + elif int(file_scheme) != int(committed): + problems.append( + f"blind_draw_scheme {int(file_scheme)} does not match the committed " + f"draw_scheme {int(committed)}" + ) + elif int(file_scheme) != installed: + problems.append( + f"blind was drawn under Smokescreen DRAW_SCHEME={int(file_scheme)} " + f"but the installed fork implements DRAW_SCHEME={installed} — this " + "install cannot reproduce the shift" + ) if "seed_smokescreen" in s.metadata: problems.append("PLAINTEXT SEED LEAKED into file metadata (seed_smokescreen)") if problems: diff --git a/src/sp_validation/blinding.py b/src/sp_validation/blinding.py index 97a220f1..c2ff148f 100644 --- a/src/sp_validation/blinding.py +++ b/src/sp_validation/blinding.py @@ -12,20 +12,25 @@ **The fork is the concealment engine.** The hidden cosmology is drawn by the ``UNIONS-WL/Smokescreen`` fork's own CCL-native, per-key-independent - draw; each part goes through its ``ConcealDataVector(fiducial_params, - shifts_dict, sacc_data, *, seed, theory_fn)`` entry point. This module - supplies the two sp_validation-specific pieces: the three ``theory_fn`` - backends matching each part's row layout (reporting ξ±, integration ξ±, pseudo-Cℓ) - and the SACC handling around the concealed vectors. + draw. Blinding is a vector operation, so this module calls the fork's + vector core directly — ``smokescreen.concealing_factor(fiducial_params, + shifts_dict, *, seed, theory_fn)``, which returns + ``t(hidden) − t(fiducial)`` and never sees a SACC. This module supplies + the two sp_validation-specific pieces: the three ``theory_fn`` backends + matching each part's row layout (reporting ξ±, integration ξ±, pseudo-Cℓ) + and the SACC handling around the returned factors. **Envelope calibration.** The blinding intent is an amplitude smear of a chosen (S8, Ωm) box. The fork draws in CCL-native primitives, so :meth:`BlindingConfig.shifts_dict` maps the intended box to ``{sigma8, Omega_c}`` half-widths evaluated at the fiducial — - ``σ8 = S8/√(Ωm/0.3)``, ``Ω_c = Ωm − Ω_b − Ω_ν``. The same seed yields - the same hidden cosmology across all part passes (the fork's draw depends - only on ``(key, seed)``), so the blinded parts are mutually consistent by - construction. + ``σ8 = S8/√(Ωm/0.3)``, ``Ω_c = Ωm − Ω_b − Ω_ν``. Under the installed draw + scheme the deltas depend only on ``(key, seed, shift_distr)``, so one seed + yields one hidden cosmology across all part passes and the blinded parts + are mutually consistent by construction. That "only" is a property of the + *draw scheme*, not of the seed: :func:`draw_scheme` records which scheme a + blind was drawn under and every custody gate refuses an install that + disagrees (see **Custody** below). **Derived statistics are born blinded.** COSEBIs and pure-E/B are never touched by this module: the pipeline's own estimators @@ -37,17 +42,23 @@ **Custody: hash commitment, no keyholder.** :func:`blind_init` runs once per catalogue version: it draws an OS-entropy seed, publishes - ``sha256(seed)`` plus a canonical config digest as a repo-committable - ``commitment.json``, and encrypts the seed into a Fernet bundle - (``smokescreen.encryption``) — the plaintext seed is never written. + ``sha256(seed)``, a canonical config digest, and the installed fork's + ``DRAW_SCHEME`` as a repo-committable ``commitment.json``, and encrypts + the seed into a Fernet bundle (``smokescreen.encryption``) — the plaintext + seed is never written. The three together, not the seed alone, are what + reproduces a blind: seed and config fix *which* shift, the draw scheme + fixes *how* the seed becomes that shift. + Each :func:`blind_part` call reads that fixed state, conceals one part, escrows the part's true vector into its own encrypted bundle beside the - blinded output, and deletes the plaintext part. Terminal assembly - (:func:`sp_validation.sacc_io.gather`) calls - :func:`assert_consistent_blind` to fail closed unless every blindable - part carries the identical ``blind_commitment``. :func:`unblind_part` - verifies both hashes against the commitment *before* subtracting - anything, then restores the true part. + blinded output, and deletes the plaintext part. Every path that assembles + parts into the one-file product goes through + :func:`sp_validation.sacc_io.gather` — the production assembler is passed + *into* it rather than wrapped around it — and gather calls + :func:`assert_consistent_blind` to fail closed unless every blindable part + carries the identical ``blind_commitment``, ``blind_config_digest`` and + ``blind_draw_scheme``. :func:`unblind_part` verifies all three against the + commitment *before* subtracting anything, then restores the true part. """ import dataclasses @@ -89,8 +100,11 @@ def shifts_dict(self): ``ΔS8/√(Ωm_fid/0.3)`` in σ8 (at fixed Ωm), and a ΔΩm half-width maps one-to-one to Ω_c (Ω_b and Ω_ν are fixed). Exact enough for a blinding smear — the target is a characteristic amplitude, not a - precise (S8, Ωm) posterior. The fork draws each key independently as - ``U(fid − h, fid + h)``. + precise (S8, Ωm) posterior. Under the installed draw scheme the fork + draws each key independently as ``U(fid − h, fid + h)``, so adding or + resizing one key never moves another; :func:`draw_scheme` is what + makes that independence a guarantee rather than a hope, by refusing an + install whose draw semantics differ from the blind's. """ return { "sigma8": self.s8_half_width / np.sqrt(self.theory.Omega_m / 0.3), @@ -166,15 +180,68 @@ def seed_commitment(seed): return hashlib.sha256(seed.encode("utf-8")).hexdigest() +def draw_scheme(): + """The installed Smokescreen fork's shift-draw semantics version. + + ``smokescreen.DRAW_SCHEME`` is the fork's own version number for *how* a + seed becomes a set of parameter deltas — scheme 1 is upstream DESC's one + global RNG stream consumed over the sorted keys, scheme 2 (this fork) a + per-key RNG derived from ``(seed, key)``. Two installs that agree on + ``(seed, config)`` but disagree on this number draw *different* hidden + cosmologies from the same inputs. + + That is the one blinding failure with no loud symptom: a blind added under + one scheme and subtracted under another leaves a residual smooth + cosmological shift in the "unblinded" vector, which every hash, digest and + escrow check would still pass. So the scheme is bound into the blind's + custody state — ``commitment.json`` and every blinded file's + ``blind_draw_scheme`` — and re-checked against this function wherever a + shift is drawn or subtracted (:func:`_assert_draw_scheme`). + """ + from smokescreen import DRAW_SCHEME + + return int(DRAW_SCHEME) + + +def _assert_draw_scheme(recorded, what): + """Fail closed unless ``recorded`` is the installed fork's draw scheme. + + ``what`` names the surface the scheme was read from, for the message. + A missing record (``None``) is a failure, not a pass: a blind whose scheme + is unknown cannot be shown to be reproducible by this install. + """ + installed = draw_scheme() + if recorded is None: + raise ValueError( + f"{what} carries no draw-scheme record — refusing to proceed. " + f"It predates draw-scheme binding, so there is no way to tell " + f"whether the installed Smokescreen (DRAW_SCHEME={installed}) " + f"reproduces the shift it was blinded with." + ) + if int(recorded) != installed: + raise ValueError( + f"{what} was drawn under Smokescreen DRAW_SCHEME={int(recorded)} " + f"but the installed fork implements DRAW_SCHEME={installed} — " + f"refusing to proceed. The same seed draws a different hidden " + f"cosmology under a different scheme, so this install would " + f"subtract the wrong shift and pass every other check. Install " + f"the Smokescreen the blind was made with." + ) + + def hidden_params(seed, config): """The hidden CCL parameter point the fork realizes for ``(seed, config)``. Re-runs the fork's own draw (``smokescreen.param_shifts.draw_param_shifts`` — per-key-independent, local RNG) on the calibrated envelope and overlays - the deltas on the fiducial, exactly as ``ConcealDataVector`` does - internally. Deterministic: same ``(seed, config)`` ⇒ same hidden point, - forever — the reproducibility contract unblinding relies on, and what - makes every part share one hidden cosmology under one seed. + the deltas on the fiducial, exactly as + ``smokescreen.concealing_factor`` does internally. Deterministic under a + fixed draw scheme: same ``(seed, config)`` ⇒ same hidden point on any + install whose :func:`draw_scheme` matches — the reproducibility contract + unblinding relies on, and what makes every part share one hidden cosmology + under one seed. This is an introspection helper (what *was* the hidden + cosmology, once revealed); the blinding path itself never calls it, and so + does not check the scheme here — every gate that acts on a shift does. """ from smokescreen.param_shifts import draw_param_shifts @@ -315,7 +382,7 @@ def theory_fn(params): # --------------------------------------------------------------------------- # -# Extract → conceal → merge, per block +# The concealing factor, per block # --------------------------------------------------------------------------- # def _blindable_blocks(s): """The blindable blocks of a SACC as ``(name, indices, factory)``. @@ -323,10 +390,9 @@ def _blindable_blocks(s): Works identically on a standalone part (which carries exactly one block) and on the assembled one-file product (whose integration rows are selected by the ``grid`` tag — the layout contract's per-block tag selection). - ``indices`` are each block's recorded row indices (ascending, so the - extracted sub-SACC preserves row order); ``factory`` builds the matching - ``theory_fn`` from the extracted sub-SACC. Blocks absent from the file - are simply not listed. + ``indices`` are each block's recorded row indices (ascending); ``factory`` + builds the matching ``theory_fn`` from the SACC those indices point into. + Blocks absent from the file are simply not listed. """ blocks = [] for grid in ("reporting", "integration"): @@ -345,43 +411,54 @@ def _blindable_blocks(s): return blocks -def _extract_block(s, indices): - """Extract rows ``indices`` (ascending) into a sub-SACC, order preserved. - - Deliberately index-based rather than ``sacc_io.extract`` / - ``update_statistic`` (which select and merge by ``(data_type, tracers, - tags)``): Smokescreen's ``ConcealDataVector`` aligns its ``theory_fn`` - output to the sub-SACC's ``mean`` element-for-element by row position, so - the block must be carved and written back by contiguous integer index, not - by tag-matching. This is the one place the row-index path is load-bearing. - """ - sub = s.copy() - sub.keep_indices(np.asarray(indices, dtype=int)) - return sub - - def _concealing_factor(s, indices, factory, config, seed): - """The fork-computed additive concealing factor for one block. + """The fork-computed additive concealing factor for one block of ``s``. + + Blinding is a vector operation, so this goes straight to the fork's vector + core: ``smokescreen.concealing_factor`` draws the hidden deltas from + ``seed``, overlays them on the fiducial, evaluates the block's + ``theory_fn`` at both points and differences them. No data vector and no + SACC reach the fork — nothing is carved out of ``s``, and ``s`` itself is + not modified. + + The factory reads its layout off ``s`` directly, so the returned vector is + full-length: a block's ``theory_fn`` fills only its own rows and leaves + every other row NaN. Slicing to ``indices`` drops those NaNs by + construction; the finite check then proves the converse — that the + factory filled *all* of this block's rows. A row the block claims but the + factory cannot cover (a pair carrying ξ− without ξ+, say) would otherwise + write NaN into the data vector, silently. + + Both :func:`blind_sacc` and :func:`unblind_sacc` reach the fork through + this one function, so the added and the subtracted shift cannot drift + apart. - Extracts the block into a sub-SACC containing exactly the rows the - ``theory_fn`` spans (the fork's length guard enforces the agreement), - drives ``ConcealDataVector`` with the fiducial point, the calibrated - envelope, and the block's ``theory_fn``, and returns the factor - ``t(hidden) − t(fiducial)`` aligned to ``indices``. + Returns + ------- + np.ndarray + ``t(hidden) − t(fiducial)``, aligned to ``indices``. """ - from smokescreen import ConcealDataVector - - sub = _extract_block(s, indices) - smoke = ConcealDataVector( - config.theory.ccl_params(), - config.shifts_dict(), - sub, - seed=seed, - theory_fn=factory(sub, config.theory), + from smokescreen import concealing_factor + + full = np.asarray( + concealing_factor( + config.theory.ccl_params(), + config.shifts_dict(), + seed=seed, + theory_fn=factory(s, config.theory), + factor_type="add", + ), + dtype=float, ) - smoke.calculate_concealing_factor(factor_type="add") - concealed = smoke.apply_concealing_to_likelihood_datavec() - return np.asarray(concealed, dtype=float) - np.asarray(sub.mean, dtype=float) + factor = full[indices] + if not np.all(np.isfinite(factor)): + raise ValueError( + f"the theory backend left {int(np.sum(~np.isfinite(factor)))} of " + f"{len(indices)} blindable rows unfilled — refusing to blind " + "(these rows would be shifted by NaN). The block's row layout is " + "not fully covered by its theory_fn." + ) + return factor def _set_values(s, indices, values): @@ -399,11 +476,13 @@ def blind_sacc(part, seed, config=None, label="A", log=print): """Return a blinded copy of a part SACC (covariance and tags untouched). Per blindable block present (a standalone part carries exactly one — - reporting ξ±, integration ξ±, or pseudo-Cℓ_EE): extract the block into a sub-SACC, - conceal through the fork, write the shifted values back at their - recorded indices (row order preserved). Provenance is stamped and any - leaked seed key stripped. A file with no blindable block (e.g. a ρ/τ - diagnostic part) is refused loudly — it should never see a blind call. + reporting ξ±, integration ξ±, or pseudo-Cℓ_EE): ask the fork for the + block's concealing factor (:func:`_concealing_factor`) and add it at the + block's recorded indices. Row order, tags, n(z) and covariance are + untouched — only ``value`` changes, and only on blindable rows. + Provenance is stamped and any leaked seed key stripped. A file with no + blindable block (e.g. a ρ/τ diagnostic part) is refused loudly — it should + never see a blind call. """ config = config or BlindingConfig() if _concealed(part): @@ -428,17 +507,20 @@ def blind_sacc(part, seed, config=None, label="A", log=print): def unblind_sacc(blinded, seed, config=None, log=print): """Recover the true part SACC from a blinded one + the revealed ``seed``. - Verifies ``sha256(seed)`` and the config digest against the stamped - metadata (loud failure on either mismatch — verification precedes - subtraction), recomputes each block's shift from the seed through the - same backends, and subtracts it. Works on a standalone part or on the - assembled file (integration rows selected by the ``grid`` tag). Derived - statistics, if present (assembled file), are *not* recomputed here — the - pipeline's own estimators re-derive them from the unblinded integration ξ±. + Verifies the stamped draw scheme against the installed fork, then + ``sha256(seed)`` and the config digest against the stamped metadata (loud + failure on any of the three — verification precedes subtraction), then + recomputes each block's shift through :func:`_concealing_factor`, the same + call :func:`blind_sacc` added it with, and subtracts it. Works on a + standalone part or on the assembled file (integration rows selected by the + ``grid`` tag). Derived statistics, if present (assembled file), are *not* + recomputed here — the pipeline's own estimators re-derive them from the + unblinded integration ξ±. """ config = config or BlindingConfig() if not _concealed(blinded): raise ValueError("file is not concealed — nothing to unblind") + _assert_draw_scheme(blinded.metadata.get("blind_draw_scheme"), "this blinded file") if seed_commitment(seed) != blinded.metadata["blind_commitment"]: raise ValueError( "seed does not match blind_commitment — refusing to unblind " @@ -451,34 +533,46 @@ def unblind_sacc(blinded, seed, config=None, log=print): "added)" ) - hidden = hidden_params(seed, config) - fiducial = config.theory.ccl_params() part = blinded.copy() for name, indices, factory in _blindable_blocks(blinded): - theory = factory(_extract_block(blinded, indices), config.theory) - factor = theory(hidden) - theory(fiducial) + factor = _concealing_factor(blinded, indices, factory, config, seed) _set_values(part, indices, np.asarray(part.mean)[indices] - factor) log(f"[unblind] {name}: subtracted {len(indices)} shifts") - for key in ("concealed", "blind", "blind_commitment", "blind_config_digest"): + for key in ( + "concealed", + "blind", + "blind_commitment", + "blind_config_digest", + "blind_draw_scheme", + ): part.metadata.pop(key, None) return part -def _stamp_provenance(s, commitment, label, config_digest): +def _stamp_provenance(s, commitment, label, config_digest, scheme=None): """Stamp the blind's public provenance; strip any leaked seed. ``blind_commitment`` (sha256 of the seed) ties the file to its blind without revealing it; ``blind_config_digest`` pins the envelope + - fiducial the shift was drawn against; ``concealed``/``blind`` mark the - file. ``seed_smokescreen`` — the raw seed Smokescreen's own writer would - stamp — is popped defensively: the seed must never ride a kept file. + fiducial the shift was drawn against; ``blind_draw_scheme`` pins the + Smokescreen draw semantics that turned the seed into that shift (see + :func:`draw_scheme`); ``concealed``/``blind`` mark the file. The three + together are what an unblind must reproduce. ``seed_smokescreen`` — the + raw seed upstream Smokescreen's writer would stamp — is popped + defensively: the seed must never ride a kept file. + + ``scheme`` defaults to the installed fork's, which is the right answer + whenever this call is stamping a blind that was just computed. Callers + propagating an existing blind's provenance pass that blind's recorded + scheme instead. """ s.metadata.pop("seed_smokescreen", None) s.metadata["concealed"] = True s.metadata["blind"] = label s.metadata["blind_commitment"] = commitment s.metadata["blind_config_digest"] = config_digest + s.metadata["blind_draw_scheme"] = draw_scheme() if scheme is None else int(scheme) def stamp_concealed_passthrough(s, commitment_path): @@ -489,12 +583,16 @@ def stamp_concealed_passthrough(s, commitment_path): values are blind by construction and only the provenance stamp is missing. ρ/τ carries no cosmological vector at all — the stamp merely clears it for assembly under the blind (values unchanged either way). Both cases need the - four custody keys the fail-closed load gate and :func:`assert_consistent_blind` + custody keys the fail-closed load gate and :func:`assert_consistent_blind` check: ``concealed``, ``blind`` (label), ``blind_commitment`` - (= ``seed_sha256``), ``blind_config_digest``. This reads those from the - version's ``commitment.json`` (written by :func:`blind_init`) and stamps them - via :func:`_stamp_provenance`, so a pass-through part shares the exact same - ``(commitment, digest)`` custody state as the blinded ξ±/pseudo-Cℓ parts. + (= ``seed_sha256``), ``blind_config_digest``, ``blind_draw_scheme``. This + reads those from the version's ``commitment.json`` (written by + :func:`blind_init`) and stamps them via :func:`_stamp_provenance`, so a + pass-through part shares the exact same custody state as the blinded + ξ±/pseudo-Cℓ parts. The committed draw scheme is checked against the + installed fork first: a pass-through part is blind because it was derived + from parts blinded under that scheme, so stamping it from an install that + draws differently would mint a custody claim this install cannot honour. Unlike :func:`blind_sacc`, this shifts nothing and does not require a blindable block — it is the seam for parts blinded (or made blind-irrelevant) @@ -502,8 +600,15 @@ def stamp_concealed_passthrough(s, commitment_path): """ with open(commitment_path, encoding="utf-8") as f: commitment = json.load(f) + _assert_draw_scheme( + commitment.get("draw_scheme"), f"the blind at {commitment_path}" + ) _stamp_provenance( - s, commitment["seed_sha256"], commitment["label"], commitment["config_digest"] + s, + commitment["seed_sha256"], + commitment["label"], + commitment["config_digest"], + scheme=commitment["draw_scheme"], ) return s @@ -518,9 +623,12 @@ def assert_consistent_blind(parts): part is *blindable* if it carries a blindable block (ξ± or pseudo-Cℓ_EE); ρ/τ diagnostic and covariance-only parts are exempt. Fails closed — ``ValueError`` — if blinded and plaintext blindable parts are mixed, or - if two parts carry different ``blind_commitment``/``blind_config_digest`` - (they were blinded under different seeds or configs and must never be - combined). The consistency key is ``(commitment, digest)`` only — the + if two parts carry different + ``blind_commitment``/``blind_config_digest``/``blind_draw_scheme`` (they + were blinded under different seeds, configs or draw semantics and must + never be combined), or if the shared draw scheme is not the installed + fork's (this install could not unblind what it is about to assemble). + The consistency key is ``(commitment, digest, scheme)`` — the ``blind`` *label* is informational provenance, not custody state, so parts blinded under one seed+config but tagged with different ``--label`` values assemble cleanly (a distinct warning is logged, not a failure). @@ -538,8 +646,9 @@ def assert_consistent_blind(parts): ------- dict or None The shared blind metadata (``concealed``, ``blind``, - ``blind_commitment``, ``blind_config_digest``) for the gather to - stamp on the assembled file, or ``None`` when nothing is blinded. + ``blind_commitment``, ``blind_config_digest``, ``blind_draw_scheme``) + for the gather to stamp on the assembled file, or ``None`` when + nothing is blinded. """ blindable = [p for p in parts if _blindable_blocks(p)] concealed = [p for p in blindable if _concealed(p)] @@ -562,18 +671,26 @@ def assert_consistent_blind(parts): f"({len(concealed)} of {len(blindable)} blinded) — refusing to " "combine (a plaintext part beside blinded ones leaks the shift)" ) - # Custody state is (commitment, digest) only — the label is provenance. + # Custody state is (commitment, digest, scheme) — the label is provenance. + # `.get` on the scheme so a part predating scheme binding reads as None and + # fails at _assert_draw_scheme with its explanation, not with a KeyError. stamps = { - (p.metadata["blind_commitment"], p.metadata["blind_config_digest"]) + ( + p.metadata["blind_commitment"], + p.metadata["blind_config_digest"], + p.metadata.get("blind_draw_scheme"), + ) for p in concealed } if len(stamps) != 1: raise ValueError( "parts carry different blind commitments — they were blinded " - "under different seeds or configs and must never be combined: " - + "; ".join(f"({c[:12]}…, {d[:12]}…)" for c, d in stamps) + "under different seeds, configs or draw schemes and must never be " + "combined: " + + "; ".join(f"({c[:12]}…, {d[:12]}…, scheme {v})" for c, d, v in stamps) ) - ((commitment, digest),) = stamps + ((commitment, digest, scheme),) = stamps + _assert_draw_scheme(scheme, "the blind these parts share") labels = sorted({p.metadata["blind"] for p in concealed}) if len(labels) != 1: warnings.warn( @@ -587,6 +704,7 @@ def assert_consistent_blind(parts): "blind": labels[0], "blind_commitment": commitment, "blind_config_digest": digest, + "blind_draw_scheme": int(scheme), } @@ -619,7 +737,9 @@ def blind_init(blind_dir, config=None, label="A", log=print): 1. Draw an OS-entropy seed (never written in plaintext, never returned). 2. Write ``commitment.json`` (repo-committable): ``sha256(seed)`` + the - canonical config digest + the blind label. + canonical config digest + the installed fork's draw scheme + the blind + label. Those first three are the full reproducibility statement — see + :func:`draw_scheme` for why the seed and config alone are not. 3. Encrypt the seed into a Fernet bundle (``smokescreen.encryption``); the temporary plaintext is deleted by the encryptor. @@ -650,6 +770,7 @@ def blind_init(blind_dir, config=None, label="A", log=print): "label": label, "seed_sha256": seed_commitment(seed), "config_digest": config.config_digest(), + "draw_scheme": draw_scheme(), } with open(paths["commitment"], "w", encoding="utf-8") as f: json.dump(commitment, f, indent=2, sort_keys=True) @@ -667,9 +788,12 @@ def blind_init(blind_dir, config=None, label="A", log=print): def _read_seed(blind_dir, config): """Decrypt the seed bundle and verify it against the commitment. - Both ``sha256(seed)`` and the config digest are checked before the seed - is handed to any caller — a tampered bundle or a drifted config fails - loud here, whether the caller is about to blind or to unblind. + ``sha256(seed)``, the config digest, and the committed draw scheme are all + checked before the seed is handed to any caller — a tampered bundle, a + drifted config or a Smokescreen that draws differently from the one that + fixed this blind all fail loud here, whether the caller is about to blind + or to unblind. The scheme check is what stops a re-blind of a later part + from landing a different hidden cosmology than the earlier parts got. Returns ------- @@ -680,6 +804,7 @@ def _read_seed(blind_dir, config): bundle = _read_encrypted_json(paths["bundle"], paths["key"]) with open(paths["commitment"], encoding="utf-8") as f: commitment = json.load(f) + _assert_draw_scheme(commitment.get("draw_scheme"), f"the blind in {blind_dir}") if seed_commitment(bundle["seed"]) != commitment["seed_sha256"]: raise ValueError( "bundle seed does not match the committed sha256(seed) — refusing " @@ -751,8 +876,9 @@ def blind_part(part_path, blind_dir, config=None, keep_input=False, log=print): def unblind_part(blinded_path, blind_dir, out_path, config=None, log=print): """Unblind one blinded part (or the assembled file), verifying first. - Decrypts the seed bundle and verifies both ``sha256(seed)`` and the - config digest against ``commitment.json`` (fail closed on either — + Decrypts the seed bundle and verifies ``sha256(seed)``, the config digest + and the draw scheme against ``commitment.json``, and the same three + against the blinded file's own stamps (fail closed on any — verification precedes subtraction), recomputes the part's shift from the seed and subtracts it (:func:`unblind_sacc`). **The seed-subtracted vector is the authority** — the seed plus its commitment is the custody diff --git a/src/sp_validation/tests/test_blinding.py b/src/sp_validation/tests/test_blinding.py index 5cd0f0f7..a3cd9880 100644 --- a/src/sp_validation/tests/test_blinding.py +++ b/src/sp_validation/tests/test_blinding.py @@ -13,10 +13,10 @@ per-part-at-birth architecture; derived statistics (COSEBIs, pure-E/B) are never stored in parts — they are computed downstream from the (blinded) integration ξ± through the pipeline seams ``b_modes.cosebis_from_xi`` / -``b_modes.pure_eb_from_xi``, exactly as the pipeline does. The fork's -``ConcealDataVector`` carries no data-vector consistency check (only the -length guard), so fixture ξ± values are smooth synthetic templates — no -theory fill is needed to blind. +``b_modes.pure_eb_from_xi``, exactly as the pipeline does. The blinding path +hands the fork no data vector at all (``smokescreen.concealing_factor`` is a +pure theory difference), so fixture ξ± values are smooth synthetic templates — +no theory fill is needed to blind. """ import json @@ -418,6 +418,7 @@ def test_provenance_metadata_contract(monkeypatch): assert blinded.metadata["blind"] == "B" assert blinded.metadata["blind_commitment"] == bd.seed_commitment("seed") assert blinded.metadata["blind_config_digest"] == c.config_digest() + assert blinded.metadata["blind_draw_scheme"] == bd.draw_scheme() assert "seed_smokescreen" not in blinded.metadata assert blinded.metadata["catalogue_version"] == "vTEST" @@ -454,6 +455,170 @@ def test_unblind_fails_closed_on_wrong_seed_or_config(monkeypatch): bd.unblind_sacc(s, "right-seed", log=_NOLOG) +# --------------------------------------------------------------------------- # +# The vector core: full-length theory in, block slice out (fast — fake backend) +# --------------------------------------------------------------------------- # +def _fake_factory(indices, hole=None): + """A backend that fills ``indices`` with ``sigma8 * arange`` and nothing else. + + Mimics the real backends' contract — a block's ``theory_fn`` reads its + layout off the SACC it is given and returns a full-length vector, NaN on + every row outside its own block — without importing CCL. ``hole`` leaves + one row of the block itself unfilled. + """ + + def factory(s, theory): + def theory_fn(params): + out = np.full(len(s.mean), np.nan) + out[indices] = params["sigma8"] * np.arange(len(indices)) + if hole is not None: + out[hole] = np.nan + return out + + return theory_fn + + return factory + + +def test_concealing_factor_slices_its_own_block_from_a_full_length_vector(): + """The factor is the theory difference at the block's rows, and the rows + the backend does not fill are never read. + + This is the shape of the whole path after the sub-SACC carving came out: + the backend is driven off the assembled SACC directly, returns a + full-length vector that is NaN everywhere but its own block, and + ``_concealing_factor`` returns exactly that block's rows. Checked against + :func:`hidden_params`, which reaches the same hidden point by an + independent route. + """ + ordered = ["xi_reporting", "cl", "rho_tau", "xi_integration"] + s = sio.gather([make_parts(nbins=1)[k] for k in ordered]) + blocks = bd._blindable_blocks(s) + assert len(blocks) == 3, "assembled file carries all three blindable blocks" + + cfg = bd.BlindingConfig() + delta = bd.hidden_params("seed", cfg)["sigma8"] - cfg.theory.ccl_params()["sigma8"] + assert delta != 0.0 + for _, indices, _ in blocks: + factor = bd._concealing_factor(s, indices, _fake_factory(indices), cfg, "seed") + assert factor.shape == (len(indices),) + assert np.allclose(factor, delta * np.arange(len(indices))) + + +def test_concealing_factor_refuses_a_row_its_backend_cannot_fill(): + """A NaN on a row the block *claims* is a layout the backend cannot cover. + + Slicing to the block drops the NaNs outside it by construction; this is + the converse guard, and it has to be explicit — without it a row the + backend silently skipped would be shifted by NaN, destroying that point + with no error anywhere. + """ + part = make_xi_part("reporting") + ((_, indices, _),) = bd._blindable_blocks(part) + with pytest.raises(ValueError, match="unfilled"): + bd._concealing_factor( + part, + indices, + _fake_factory(indices, hole=indices[0]), + bd.BlindingConfig(), + "seed", + ) + + +# --------------------------------------------------------------------------- # +# Draw-scheme binding: the blind is (seed, config, draw semantics) +# --------------------------------------------------------------------------- # +def test_draw_scheme_is_the_installed_fork_constant(): + """The recorded scheme is read from Smokescreen, not hardcoded here.""" + import smokescreen + + assert bd.draw_scheme() == int(smokescreen.DRAW_SCHEME) + assert isinstance(bd.draw_scheme(), int) + + +def test_assert_draw_scheme_message_names_both_versions(monkeypatch): + """A scheme mismatch says which scheme made the blind and which is installed.""" + monkeypatch.setattr(bd, "draw_scheme", lambda: 2) + bd._assert_draw_scheme(2, "the blind") # matching scheme is silent + with pytest.raises(ValueError, match=r"DRAW_SCHEME=1.*DRAW_SCHEME=2"): + bd._assert_draw_scheme(1, "the blind") + with pytest.raises(ValueError, match="no draw-scheme record"): + bd._assert_draw_scheme(None, "the blind") + + +def test_unblind_refuses_a_blind_drawn_under_another_scheme(monkeypatch): + """The finding this closes: a blind added under one draw scheme and + subtracted under another silently produces a wrong data vector, because + the seed hash, the config digest and the escrow check all still pass. The + scheme must be part of the custody state, checked before any subtraction. + """ + _patch_constant_factor(monkeypatch) + blinded = bd.blind_sacc(make_xi_part("reporting"), "seed", log=_NOLOG) + assert blinded.metadata["blind_draw_scheme"] == bd.draw_scheme() + + # everything else about this file is valid — only the draw semantics moved + monkeypatch.setattr( + bd, "draw_scheme", lambda: blinded.metadata["blind_draw_scheme"] + 1 + ) + with pytest.raises(ValueError, match="DRAW_SCHEME"): + bd.unblind_sacc(blinded, "seed", log=_NOLOG) + + +def test_unblind_refuses_a_file_predating_scheme_binding(monkeypatch): + """A blinded file with no scheme record fails closed, not open.""" + _patch_constant_factor(monkeypatch) + blinded = bd.blind_sacc(make_xi_part("reporting"), "seed", log=_NOLOG) + del blinded.metadata["blind_draw_scheme"] + with pytest.raises(ValueError, match="no draw-scheme record"): + bd.unblind_sacc(blinded, "seed", log=_NOLOG) + + +def test_unblind_strips_the_scheme_stamp_with_the_rest(monkeypatch): + _patch_constant_factor(monkeypatch) + blinded = bd.blind_sacc(make_xi_part("reporting"), "seed", log=_NOLOG) + part = bd.unblind_sacc(blinded, "seed", log=_NOLOG) + assert "blind_draw_scheme" not in part.metadata + + +def test_read_seed_fails_closed_on_scheme_drift(tmp_path, monkeypatch): + """blind_part reads the seed through _read_seed, so a scheme change between + blinding part 1 and part 2 is caught before the second part is shifted.""" + bd.blind_init(str(tmp_path), log=_NOLOG) + monkeypatch.setattr(bd, "draw_scheme", lambda: 99) + with pytest.raises(ValueError, match="DRAW_SCHEME"): + bd._read_seed(str(tmp_path), bd.BlindingConfig()) + + +def test_stamp_passthrough_carries_the_committed_scheme(tmp_path, monkeypatch): + """A pass-through part inherits the blind's scheme, and cannot be stamped + from an install that draws differently.""" + paths = bd.blind_init(str(tmp_path), log=_NOLOG) + s = bd.stamp_concealed_passthrough(make_rho_part(), paths["commitment"]) + assert s.metadata["blind_draw_scheme"] == bd.draw_scheme() + + monkeypatch.setattr(bd, "draw_scheme", lambda: 99) + with pytest.raises(ValueError, match="DRAW_SCHEME"): + bd.stamp_concealed_passthrough(make_rho_part(), paths["commitment"]) + + +def test_assert_consistent_blind_refuses_divergent_or_foreign_schemes(monkeypatch): + """Parts blinded under different schemes never assemble; nor does a set + that agrees with itself but not with the installed fork.""" + parts = make_parts(nbins=1, with_rho=False) + for p in parts.values(): + _stamp(p) + parts["cl"].metadata["blind_draw_scheme"] = bd.draw_scheme() + 1 + with pytest.raises(ValueError, match="different blind commitments"): + bd.assert_consistent_blind(list(parts.values())) + + parts = make_parts(nbins=1, with_rho=False) + for p in parts.values(): + _stamp(p) + p.metadata["blind_draw_scheme"] = bd.draw_scheme() + 1 + with pytest.raises(ValueError, match="DRAW_SCHEME"): + bd.assert_consistent_blind(list(parts.values())) + + # --------------------------------------------------------------------------- # # blind-init custody + assembly hash assertion (fast — encryption only) # --------------------------------------------------------------------------- # @@ -462,7 +627,7 @@ def test_blind_init_writes_commitment_and_encrypted_bundle_only(tmp_path): paths = bd.blind_init(str(tmp_path), log=_NOLOG) with open(paths["commitment"], encoding="utf-8") as f: commitment = json.load(f) - assert set(commitment) == {"label", "seed_sha256", "config_digest"} + assert set(commitment) == {"label", "seed_sha256", "config_digest", "draw_scheme"} assert len(commitment["seed_sha256"]) == 64 assert commitment["config_digest"] == bd.BlindingConfig().config_digest() # exactly the three custody outputs, no plaintext bundle @@ -507,6 +672,7 @@ def test_assert_consistent_blind_shared_stamp(): "blind": "A", "blind_commitment": bd.seed_commitment("s"), "blind_config_digest": bd.BlindingConfig().config_digest(), + "blind_draw_scheme": bd.draw_scheme(), } @@ -681,8 +847,12 @@ def test_ac2_on_file_shift_equals_theory_difference_per_part(transfer_function): hiddens.append(hidden) fiducial = cfg.theory.ccl_params() ((block_name, indices, factory),) = bd._blindable_blocks(part) - theory = factory(bd._extract_block(part, indices), cfg.theory) - expected = theory(hidden) - theory(fiducial) + # Independent of the blinding path: the factory is driven directly off + # the part, at the hidden point recovered by hidden_params, and the two + # theory vectors are differenced here rather than by the fork. The + # factory fills only its own block, so slice to it. + theory = factory(part, cfg.theory) + expected = (theory(hidden) - theory(fiducial))[indices] actual = np.array(blinded.mean)[indices] - np.array(part.mean)[indices] gap = np.max(np.abs(actual - expected)) scale = np.max(np.abs(expected)) @@ -989,7 +1159,7 @@ def test_ac6_ac8_end_to_end_init_parts_gather_unblind(tmp_path): assert not np.array_equal(np.array(blinded.mean), np.array(parts[name].mean)) with open(init["commitment"], encoding="utf-8") as f: commitment = json.load(f) - assert set(commitment) == {"label", "seed_sha256", "config_digest"} + assert set(commitment) == {"label", "seed_sha256", "config_digest", "draw_scheme"} blinded_parts = {n: sio.load(p["blinded"]) for n, p in out_paths.items()} for b in blinded_parts.values(): assert b.metadata["blind_commitment"] == commitment["seed_sha256"] From 7cbfe7300c2b709a447d66fb1e045d09623c5011 Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Mon, 17 Aug 2026 20:22:12 +0200 Subject: [PATCH 6/9] One terminal seam: route the production assembly through sacc_io.gather MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sacc_io.gather` was the documented custody terminal — it asserts one blind across all blindable parts, then assembles — but nothing in production called it. `workflow/scripts/assemble_sacc.py` inlined its own copy of the same wrapper (assert, assemble, stamp) around a *different* assembler, and blinding.py's docstrings claimed gather guarded the terminal. Two custody implementations, one of them dead, and a docstring true of neither. The difference between them is only the assembly: `merge` unions parts that already carry covariance; `assemble_analysis_sacc` rebuilds from an n(z) and requires one covariance block per part, which is what the production terminal needs (its ξ± and pseudo-Cℓ parts are born cov-less and have blocks injected first). That is a real difference and both are worth keeping — so the assembler becomes an argument, `gather(parts, metadata=None, assemble=None)`, and assemble_sacc.py passes its own in rather than wrapping its own guard around it. The custody wrapper now exists once and cannot be routed around. Tests: the production path gets the case the fail-closed load gate cannot catch — every part concealed, so every part loads, and only `assert_consistent_blind` can see that the install's draw scheme is not the blind's. Plus the terminal file's draw-scheme stamp. Closes review finding 3 on PR #253. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BRYg9fjMevgcsvgjh3KN6x --- src/sp_validation/sacc_io.py | 43 ++++++++++++------- .../tests/test_blinding_wiring.py | 28 ++++++++++++ workflow/scripts/assemble_sacc.py | 35 +++++++-------- 3 files changed, 74 insertions(+), 32 deletions(-) diff --git a/src/sp_validation/sacc_io.py b/src/sp_validation/sacc_io.py index 0923326e..7dcb72a7 100644 --- a/src/sp_validation/sacc_io.py +++ b/src/sp_validation/sacc_io.py @@ -994,25 +994,35 @@ def load(path, *, allow_unblinded=False): # Everything above this banner is byte-identical to feat/sacc-2-sacc-io; only # gather() and its blind-custody call site live here. # --------------------------------------------------------------------------- # -def gather(parts, metadata=None): +def gather(parts, metadata=None, assemble=None): """Assemble standalone part SACCs into the one-file ``{version}.sacc``. Each part is an intermediate product as it came off its producing rule - (reporting ξ±, integration ξ±, pseudo-Cℓ, ρ/τ, …). Assembly itself — the - first-wins tracer union, in-order point concatenation with all tags - (bandpower windows included), and the block-diagonal covariance — is - exactly :func:`merge`, so gather delegates to it and adds only the one - thing merge cannot know about: **blind custody.** + (reporting ξ±, integration ξ±, pseudo-Cℓ, ρ/τ, …). Gather is **the** terminal + seam: every path that combines parts into the one-file product goes + through here, because this is where the one thing an assembler cannot know + about is enforced — **blind custody.** - **Blind custody (the module's one blind-aware call site):** + **Blind custody.** :func:`sp_validation.blinding.assert_consistent_blind` runs before the - merge — it fails closed unless every blindable part carries the identical - ``blind_commitment``/``blind_config_digest`` (or, when nothing is blinded, - every blindable part is declared ``type='mock'``). Its returned shared - stamp is written onto the assembled file so the one-file product carries - the blind it was built from; the blinded parts already carry those keys, - so merge preserves them and this stamp is a consistent (idempotent) - re-affirmation. + assembly — it fails closed unless every blindable part carries the + identical ``blind_commitment``/``blind_config_digest``/``blind_draw_scheme`` + (or, when nothing is blinded, every blindable part is declared + ``type='mock'``). Its returned shared stamp is written onto the assembled + file so the one-file product carries the blind it was built from; the + blinded parts already carry those keys, so the assembly preserves them and + this stamp is a consistent (idempotent) re-affirmation. + + **The assembly itself is the caller's.** Two exist and both are real: + :func:`merge` (the default) does the first-wins tracer union, in-order + point concatenation with all tags, and a covariance built from whatever the + parts carry — the right thing when parts are already covariance-bearing. + :func:`sp_validation.cosmo_val.sacc_writers.assemble_analysis_sacc` rebuilds + from a given n(z) + metadata and *requires* one covariance block per part — + the right thing for the production terminal, where the ξ± and pseudo-Cℓ + parts are born cov-less and have their blocks injected first. Passing the + assembler in, rather than duplicating the custody wrapper around each one, + is what keeps the guard un-bypassable. Parameters ---------- @@ -1020,6 +1030,9 @@ def gather(parts, metadata=None): The part SACCs, in the assembly (covariance) order. metadata : dict, optional Extra key/value pairs to store on the assembled file's metadata. + assemble : callable, optional + ``assemble(parts) -> sacc.Sacc``. Defaults to :func:`merge`. Bind any + further arguments (n(z), metadata) into the callable. Returns ------- @@ -1030,7 +1043,7 @@ def gather(parts, metadata=None): parts = list(parts) stamp = blinding.assert_consistent_blind(parts) - s = merge(parts) + s = (assemble or merge)(parts) for key, value in {**(metadata or {}), **(stamp or {})}.items(): s.metadata[key] = value return s diff --git a/src/sp_validation/tests/test_blinding_wiring.py b/src/sp_validation/tests/test_blinding_wiring.py index 2eac1988..706200cf 100644 --- a/src/sp_validation/tests/test_blinding_wiring.py +++ b/src/sp_validation/tests/test_blinding_wiring.py @@ -200,6 +200,34 @@ def test_data_assemble_refuses_blinded_plaintext_mix(tmp_path): ) +def test_data_assemble_runs_behind_the_custody_guard(tmp_path, monkeypatch): + """The custody guard is reached *through* the production assembly. + + Every part here is concealed, so every part clears the fail-closed load + gate — the earlier tests all stop there. Only + ``blinding.assert_consistent_blind`` can catch what is wrong with this + assembly: the install's Smokescreen draws shifts under a different scheme + than the one that made the blind, so it could never unblind what it is + about to write. That ``assemble_sacc`` raises is the check that it runs + through :func:`sacc_io.gather` like every other assembly path, rather than + reimplementing the custody wrapper around its own assembler. + """ + paths = _data_parts(tmp_path, conceal=True) + monkeypatch.setattr(blinding, "draw_scheme", lambda: 99) + with pytest.raises(ValueError, match="DRAW_SCHEME"): + asm.assemble_sacc( + "vSYNTH", paths, str(tmp_path / "vSYNTH.sacc"), placeholder_var=1.0 + ) + + +def test_data_assemble_stamps_the_draw_scheme_on_the_terminal_file(tmp_path): + """The assembled file carries the blind's draw scheme, like its parts.""" + paths = _data_parts(tmp_path, conceal=True) + out = tmp_path / "vSYNTH.sacc" + asm.assemble_sacc("vSYNTH", paths, str(out), placeholder_var=1.0) + assert sio.load(str(out)).metadata["blind_draw_scheme"] == blinding.draw_scheme() + + def test_assert_consistent_blind_rejects_divergent_commitments(tmp_path): """Two ξ± parts blinded under different commitments must never combine.""" nz = {0: _nz()} diff --git a/workflow/scripts/assemble_sacc.py b/workflow/scripts/assemble_sacc.py index 118ec5b8..4c7d41a1 100644 --- a/workflow/scripts/assemble_sacc.py +++ b/workflow/scripts/assemble_sacc.py @@ -7,9 +7,13 @@ Each per-statistic ``*.sacc`` *part* (written born-as-SACC by the mixins and the run_2pcf / generate_pseudo_cl scripts) holds one statistic. The assembler loads them in canonical order — ξ± reporting, pseudo-Cℓ, COSEBIs, pure-E/B, ρ/τ — and -calls :func:`sacc_writers.assemble_analysis_sacc`, which rebuilds one Sacc with a -single ``BlockDiagonalCovariance`` (point-insertion order = block order, -validated by ``sacc_io.assemble_covariance``). +hands them to :func:`sacc_io.gather` along with +:func:`sacc_writers.assemble_analysis_sacc` as the assembly, which rebuilds one +Sacc with a single ``BlockDiagonalCovariance`` (point-insertion order = block +order, validated by ``sacc_io.assemble_covariance``). Going through ``gather`` +rather than calling the assembler directly is what puts this path behind the +blind-custody gate (``blinding.assert_consistent_blind``) — there is one +terminal seam, not one per assembler. Covariance sourcing (the part-by-part decision) ----------------------------------------------- @@ -176,20 +180,17 @@ def assemble_sacc( ) if not parts: raise ValueError(f"no parts found for {version}: {part_paths}") - # Assembly-time custody assertion (#252): every blindable part (ξ± / pseudo-Cℓ - # EE) must share one blind commitment + config digest, or assembly fails - # closed — mixed blinded/plaintext parts and divergent-seed parts both raise. - # ρ/τ and covariance-only parts are exempt. Returns the shared blind stamp to - # carry onto the assembled file (or None for a fully-mock plaintext assembly). - from sp_validation import blinding - - shared = blinding.assert_consistent_blind(parts) - s = assemble_analysis_sacc(nz, metadata, parts) - if shared is not None: - # Stamp the assembled file with the shared blind so it, too, reads as - # concealed (its parts already carried the stamp into `metadata` above; - # this makes the custody state explicit and authoritative on the union). - s.metadata.update(shared) + # Assembly-time custody assertion (#252) runs inside sacc_io.gather, the one + # terminal seam: every blindable part (ξ± / pseudo-Cℓ EE) must share one + # blind commitment + config digest + draw scheme, or assembly fails closed — + # mixed blinded/plaintext parts and divergent-seed parts both raise. ρ/τ and + # covariance-only parts are exempt. Gather also stamps the shared blind onto + # the assembled file, so the union reads as concealed in its own right. + # The production assembly (n(z) + per-part covariance blocks) is passed in; + # gather's own default merge() is for parts that already carry covariance. + s = sacc_io.gather( + parts, assemble=lambda ordered: assemble_analysis_sacc(nz, metadata, ordered) + ) # Assembly preserves its parts' provenance: every part was written by # sacc_io.save and therefore carries the type=data|mock stamp in its # metadata (copied into the assembled file above). From 646349c97da6e1905938603a07a961fc879410e8 Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Mon, 17 Aug 2026 21:42:14 +0200 Subject: [PATCH 7/9] Take the commitment from the fork, so it stops publishing the seed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local sha256(seed) commitment leaked the blind. Smokescreen derives its per-key RNG base seed from the first 8 bytes of the *same* undomained digest, so the published commitment carried that base seed verbatim in its first 16 hex characters. With the commitment and the (public) fiducial config, anyone could redraw the hidden cosmology and subtract the shift — from the artifact introduced to protect it. seed_commitment now delegates to smokescreen.seed_commitment, which hashes COMMITMENT_DOMAIN + str(seed). One definition of the commitment across the stack, in a different hash domain from the seed normalizer. A regression test asserts the commitment never embeds _normalize_seed(seed). The commitment.json key is renamed seed_sha256 -> seed_commitment: it is no longer sha256 of the seed, and the old name claimed otherwise. No real blind exists, so nothing migrates. Also, four honesty fixes the same review found: - The NaN refusal fires on both blind and unblind (they share one call), so its message no longer says "refusing to blind". - The CLI module docstring describes the three-way commitment (seed, config, draw scheme), and _verify's docstring says that its draw-scheme check makes the result environment-dependent by design. - _verify loads with allow_unblinded=True, so its "file is not marked concealed" diagnostic is reachable instead of an uncaught load exception. - The module docstring states what a pre-draw_scheme blind costs: recoverable only by hand from escrow. None exists; the CLI offers no override. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BRYg9fjMevgcsvgjh3KN6x --- scripts/blind_data_vector.py | 44 ++++++++++----- src/sp_validation/blinding.py | 68 +++++++++++++++--------- src/sp_validation/tests/test_blinding.py | 61 +++++++++++++++++---- workflow/rules/blinding.smk | 6 +-- 4 files changed, 129 insertions(+), 50 deletions(-) diff --git a/scripts/blind_data_vector.py b/scripts/blind_data_vector.py index d9aeb502..692ebd94 100644 --- a/scripts/blind_data_vector.py +++ b/scripts/blind_data_vector.py @@ -7,15 +7,20 @@ ``blind-init`` runs once per catalogue version: it draws an OS-entropy seed (never printed, never written in plaintext), publishes a repo-committable -``commitment.json`` (``sha256(seed)`` + config digest), and encrypts the seed -into a Fernet bundle. ``blind-part`` blinds one intermediate part SACC -(reporting ξ±, integration ξ±, or pseudo-Cℓ) under that fixed state, escrows -the true vector into a per-part encrypted bundle beside the blinded output, -and deletes the plaintext part. ``unblind`` verifies both commitment hashes -and restores a true part (bit-for-bit when the part's escrow bundle is beside -it); it also works on the assembled ``{version}.sacc`` (integration rows -selected by the ``grid`` tag). ``verify`` is a cheap, seedless check that a -blinded file matches a commitment. +``commitment.json`` (the seed commitment + the config digest + the installed +Smokescreen fork's draw scheme), and encrypts the seed into a Fernet bundle. +Those three, not the seed alone, are what reproduces a blind: seed and config +fix *which* shift, the draw scheme fixes *how* the seed becomes that shift. +``blind-part`` blinds one intermediate part SACC (reporting ξ±, integration ξ±, +or pseudo-Cℓ) under that fixed state, escrows the true vector into a per-part +encrypted bundle beside the blinded output, and deletes the plaintext part. +``unblind`` verifies all three commitments and restores a true part +(bit-for-bit when the part's escrow bundle is beside it); it also works on the +assembled ``{version}.sacc`` (integration rows selected by the ``grid`` tag). +``verify`` is a cheap, seedless check that a blinded file matches a commitment +— seedless, but not environment-independent: it also compares both recorded +draw schemes against the installed fork, so it reports a problem on a machine +whose Smokescreen draws differently even when file and commitment agree. :Authors: Cail Daley @@ -102,15 +107,28 @@ def _unblind(args): def _verify(args): - """Seedless check: blinded-file metadata ↔ commitment JSON.""" - s = sacc_io.load(args.blinded) + """Seedless check: blinded-file metadata ↔ commitment JSON ↔ this install. + + No seed is read, so this cannot confirm that the blind is *subtractable* — + only that the file's three custody stamps agree with the commitment, and + that the recorded draw scheme is the one this install implements. That last + check makes the result environment-dependent by design: a machine carrying a + different Smokescreen reports a problem even when file and commitment agree + perfectly, because that machine could not unblind the file. + + Loads with ``allow_unblinded=True``: the whole job here is to report on a + file's custody state, including the state where the file is not concealed at + all, which the fail-closed loader would otherwise raise on before any + diagnostic could be assembled. + """ + s = sacc_io.load(args.blinded, allow_unblinded=True) with open(args.commitment, encoding="utf-8") as f: commitment = json.load(f) problems = [] if not s.metadata.get("concealed"): problems.append("file is not marked concealed") - if s.metadata.get("blind_commitment") != commitment["seed_sha256"]: - problems.append("blind_commitment does not match the committed sha256(seed)") + if s.metadata.get("blind_commitment") != commitment["seed_commitment"]: + problems.append("blind_commitment does not match the committed seed commitment") if s.metadata.get("blind_config_digest") != commitment["config_digest"]: problems.append("blind_config_digest does not match the committed digest") # The draw scheme is checked three ways: file ↔ commitment, and both against diff --git a/src/sp_validation/blinding.py b/src/sp_validation/blinding.py index c2ff148f..34b648d2 100644 --- a/src/sp_validation/blinding.py +++ b/src/sp_validation/blinding.py @@ -42,12 +42,13 @@ **Custody: hash commitment, no keyholder.** :func:`blind_init` runs once per catalogue version: it draws an OS-entropy seed, publishes - ``sha256(seed)``, a canonical config digest, and the installed fork's - ``DRAW_SCHEME`` as a repo-committable ``commitment.json``, and encrypts - the seed into a Fernet bundle (``smokescreen.encryption``) — the plaintext - seed is never written. The three together, not the seed alone, are what - reproduces a blind: seed and config fix *which* shift, the draw scheme - fixes *how* the seed becomes that shift. + the seed commitment (:func:`seed_commitment`), a canonical config digest, + and the installed fork's ``DRAW_SCHEME`` as a repo-committable + ``commitment.json``, and encrypts the seed into a Fernet bundle + (``smokescreen.encryption``) — the plaintext seed is never written. The + three together, not the seed alone, are what reproduces a blind: seed and + config fix *which* shift, the draw scheme fixes *how* the seed becomes that + shift. Each :func:`blind_part` call reads that fixed state, conceals one part, escrows the part's true vector into its own encrypted bundle beside the @@ -59,6 +60,15 @@ carries the identical ``blind_commitment``, ``blind_config_digest`` and ``blind_draw_scheme``. :func:`unblind_part` verifies all three against the commitment *before* subtracting anything, then restores the true part. + + Custody has no back door, including for its own history: a blind fixed + before the draw scheme was bound into ``commitment.json`` carries no + scheme record, and :func:`_assert_draw_scheme` refuses it ahead of every + seed read, so neither :func:`blind_part` nor :func:`unblind_part` will run + on it. Such a blind is recoverable only by hand, by decrypting its per-part + escrow bundles (:func:`_read_encrypted_json`) and reading ``true_mean`` + back out. No such blind exists — the scheme has been bound since before + the first real blind — and the CLI deliberately offers no override. """ import dataclasses @@ -125,7 +135,7 @@ def config_digest(self): digests and deny a legitimate unblind. Python's ``json`` then emits each float via its shortest round-trip ``repr``, so two runs of the same config produce byte-identical digests. Checked (with - ``sha256(seed)``) at unblind, so a wrong envelope or a mismatched + the seed commitment) at unblind, so a wrong envelope or a mismatched P(k) recipe cannot silently subtract a wrong shift. """ payload = { @@ -172,12 +182,22 @@ def from_overrides(cls, overrides): # Custody primitives # --------------------------------------------------------------------------- # def seed_commitment(seed): - """Public commitment for a seed: its sha256 hex digest. + """Public commitment for a seed: the fork's domain-separated sha256 digest. Safe to publish and commit to the repo — it ties a blinded file to its blind without revealing the seed, and lets unblind refuse a wrong seed. + + This delegates to :func:`smokescreen.seed_commitment` so the commitment has + exactly one definition across the blinding stack. That function hashes + ``smokescreen.COMMITMENT_DOMAIN + str(seed)``, *not* the bare seed, and the + domain prefix is load-bearing: the fork derives the RNG base seed from the + bare sha256 of the same string, so an undomained commitment would publish + that base seed verbatim in its first 16 hex characters and the blind would + be recoverable from the artifact meant to protect it. """ - return hashlib.sha256(seed.encode("utf-8")).hexdigest() + from smokescreen import seed_commitment as _fork_seed_commitment + + return _fork_seed_commitment(seed) def draw_scheme(): @@ -454,9 +474,9 @@ def _concealing_factor(s, indices, factory, config, seed): if not np.all(np.isfinite(factor)): raise ValueError( f"the theory backend left {int(np.sum(~np.isfinite(factor)))} of " - f"{len(indices)} blindable rows unfilled — refusing to blind " - "(these rows would be shifted by NaN). The block's row layout is " - "not fully covered by its theory_fn." + f"{len(indices)} blindable rows unfilled — refusing to apply the " + "concealing factor (these rows would be shifted by NaN). The " + "block's row layout is not fully covered by its theory_fn." ) return factor @@ -508,7 +528,7 @@ def unblind_sacc(blinded, seed, config=None, log=print): """Recover the true part SACC from a blinded one + the revealed ``seed``. Verifies the stamped draw scheme against the installed fork, then - ``sha256(seed)`` and the config digest against the stamped metadata (loud + the seed commitment and the config digest against the stamped metadata (loud failure on any of the three — verification precedes subtraction), then recomputes each block's shift through :func:`_concealing_factor`, the same call :func:`blind_sacc` added it with, and subtracts it. Works on a @@ -585,7 +605,7 @@ def stamp_concealed_passthrough(s, commitment_path): assembly under the blind (values unchanged either way). Both cases need the custody keys the fail-closed load gate and :func:`assert_consistent_blind` check: ``concealed``, ``blind`` (label), ``blind_commitment`` - (= ``seed_sha256``), ``blind_config_digest``, ``blind_draw_scheme``. This + (= ``seed_commitment``), ``blind_config_digest``, ``blind_draw_scheme``. This reads those from the version's ``commitment.json`` (written by :func:`blind_init`) and stamps them via :func:`_stamp_provenance`, so a pass-through part shares the exact same custody state as the blinded @@ -605,7 +625,7 @@ def stamp_concealed_passthrough(s, commitment_path): ) _stamp_provenance( s, - commitment["seed_sha256"], + commitment["seed_commitment"], commitment["label"], commitment["config_digest"], scheme=commitment["draw_scheme"], @@ -736,7 +756,7 @@ def blind_init(blind_dir, config=None, label="A", log=print): Runs once per catalogue version: 1. Draw an OS-entropy seed (never written in plaintext, never returned). - 2. Write ``commitment.json`` (repo-committable): ``sha256(seed)`` + the + 2. Write ``commitment.json`` (repo-committable): the seed commitment + the canonical config digest + the installed fork's draw scheme + the blind label. Those first three are the full reproducibility statement — see :func:`draw_scheme` for why the seed and config alone are not. @@ -768,7 +788,7 @@ def blind_init(blind_dir, config=None, label="A", log=print): seed = secrets.token_hex(16) commitment = { "label": label, - "seed_sha256": seed_commitment(seed), + "seed_commitment": seed_commitment(seed), "config_digest": config.config_digest(), "draw_scheme": draw_scheme(), } @@ -788,7 +808,7 @@ def blind_init(blind_dir, config=None, label="A", log=print): def _read_seed(blind_dir, config): """Decrypt the seed bundle and verify it against the commitment. - ``sha256(seed)``, the config digest, and the committed draw scheme are all + The seed commitment, the config digest, and the committed draw scheme are all checked before the seed is handed to any caller — a tampered bundle, a drifted config or a Smokescreen that draws differently from the one that fixed this blind all fail loud here, whether the caller is about to blind @@ -805,9 +825,9 @@ def _read_seed(blind_dir, config): with open(paths["commitment"], encoding="utf-8") as f: commitment = json.load(f) _assert_draw_scheme(commitment.get("draw_scheme"), f"the blind in {blind_dir}") - if seed_commitment(bundle["seed"]) != commitment["seed_sha256"]: + if seed_commitment(bundle["seed"]) != commitment["seed_commitment"]: raise ValueError( - "bundle seed does not match the committed sha256(seed) — refusing " + "bundle seed does not match the committed seed commitment — refusing " "to proceed" ) if config.config_digest() != commitment["config_digest"]: @@ -856,7 +876,7 @@ def blind_part(part_path, blind_dir, config=None, keep_input=False, log=print): paths["escrow"], { "label": commitment["label"], - "seed_sha256": commitment["seed_sha256"], + "seed_commitment": commitment["seed_commitment"], "true_mean": np.asarray(part.mean, dtype=float).tolist(), }, ) @@ -876,7 +896,7 @@ def blind_part(part_path, blind_dir, config=None, keep_input=False, log=print): def unblind_part(blinded_path, blind_dir, out_path, config=None, log=print): """Unblind one blinded part (or the assembled file), verifying first. - Decrypts the seed bundle and verifies ``sha256(seed)``, the config digest + Decrypts the seed bundle and verifies the seed commitment, the config digest and the draw scheme against ``commitment.json``, and the same three against the blinded file's own stamps (fail closed on any — verification precedes subtraction), recomputes the part's shift from the @@ -885,7 +905,7 @@ def unblind_part(blinded_path, blind_dir, out_path, config=None, log=print): root of trust, and the returned vector is what the seed math produced. When the part's escrow bundle exists beside the blinded file it serves - two subordinate roles, and only after its stored ``seed_sha256`` is + two subordinate roles, and only after its stored ``seed_commitment`` is verified to match the same commitment (so an escrow from a different blind can never be trusted): (1) a *tighter equality check* — the seed subtraction and the escrowed truth must agree to ``1e-6`` relative or the @@ -910,7 +930,7 @@ def unblind_part(blinded_path, blind_dir, out_path, config=None, log=print): escrow = part_paths(unblinded_stem + ext) if os.path.exists(escrow["escrow"]): bundle = _read_encrypted_json(escrow["escrow"], escrow["escrow_key"]) - if bundle.get("seed_sha256") != commitment["seed_sha256"]: + if bundle.get("seed_commitment") != commitment["seed_commitment"]: raise ValueError( "escrow bundle beside the blinded file was written under a " "different seed than the commitment — refusing to trust it " diff --git a/src/sp_validation/tests/test_blinding.py b/src/sp_validation/tests/test_blinding.py index a3cd9880..6d808895 100644 --- a/src/sp_validation/tests/test_blinding.py +++ b/src/sp_validation/tests/test_blinding.py @@ -334,14 +334,45 @@ def test_theory_config_ccl_params_exact_keyset(): # --------------------------------------------------------------------------- # # Commitment + the fork's draw (fast; smokescreen import is light) # --------------------------------------------------------------------------- # -def test_commitment_is_sha256_of_seed(): +def test_commitment_is_the_forks_domain_separated_digest(): + """One definition of the commitment, and it is the fork's.""" import hashlib + import smokescreen + seed = "the-secret" - assert bd.seed_commitment(seed) == hashlib.sha256(seed.encode()).hexdigest() + assert bd.seed_commitment(seed) == smokescreen.seed_commitment(seed) + assert ( + bd.seed_commitment(seed) + == hashlib.sha256( + smokescreen.COMMITMENT_DOMAIN + seed.encode("utf-8") + ).hexdigest() + ) assert bd.seed_commitment("right") != bd.seed_commitment("wrong") +def test_commitment_does_not_embed_the_rng_seed(): + """The published commitment must not carry the effective RNG seed. + + The fork derives the base seed for its per-key RNG from the *undomained* + sha256 of the seed string, taking the digest's first 8 bytes. A commitment + hashed over the bare seed would therefore publish that base seed verbatim in + its first 16 hex characters, and anyone holding the (public) commitment and + the (public) fiducial config could redraw the hidden cosmology and subtract + the blind. The domain prefix is what breaks that identity — this is the + regression guard for it. + """ + import secrets + + from smokescreen.param_shifts import _normalize_seed + + # The third seed is drawn exactly as blind_init draws a production one. + for seed in ("my_secret_seed", "the-secret", secrets.token_hex(16)): + commitment = bd.seed_commitment(seed) + assert int(commitment[:16], 16) != _normalize_seed(seed) + assert str(_normalize_seed(seed)) not in commitment + + def test_hidden_params_deterministic_and_in_envelope(): """Same (seed, config) ⇒ same hidden point; draws respect the envelope.""" import secrets @@ -627,8 +658,13 @@ def test_blind_init_writes_commitment_and_encrypted_bundle_only(tmp_path): paths = bd.blind_init(str(tmp_path), log=_NOLOG) with open(paths["commitment"], encoding="utf-8") as f: commitment = json.load(f) - assert set(commitment) == {"label", "seed_sha256", "config_digest", "draw_scheme"} - assert len(commitment["seed_sha256"]) == 64 + assert set(commitment) == { + "label", + "seed_commitment", + "config_digest", + "draw_scheme", + } + assert len(commitment["seed_commitment"]) == 64 assert commitment["config_digest"] == bd.BlindingConfig().config_digest() # exactly the three custody outputs, no plaintext bundle assert {p.name for p in tmp_path.iterdir()} == { @@ -638,7 +674,7 @@ def test_blind_init_writes_commitment_and_encrypted_bundle_only(tmp_path): } # the decrypted seed matches the public commitment bundle = bd._read_encrypted_json(paths["bundle"], paths["key"]) - assert bd.seed_commitment(bundle["seed"]) == commitment["seed_sha256"] + assert bd.seed_commitment(bundle["seed"]) == commitment["seed_commitment"] # one-shot custody: a second init in the same dir refuses with pytest.raises(FileExistsError, match="refusing to overwrite"): bd.blind_init(str(tmp_path), log=_NOLOG) @@ -1159,10 +1195,15 @@ def test_ac6_ac8_end_to_end_init_parts_gather_unblind(tmp_path): assert not np.array_equal(np.array(blinded.mean), np.array(parts[name].mean)) with open(init["commitment"], encoding="utf-8") as f: commitment = json.load(f) - assert set(commitment) == {"label", "seed_sha256", "config_digest", "draw_scheme"} + assert set(commitment) == { + "label", + "seed_commitment", + "config_digest", + "draw_scheme", + } blinded_parts = {n: sio.load(p["blinded"]) for n, p in out_paths.items()} for b in blinded_parts.values(): - assert b.metadata["blind_commitment"] == commitment["seed_sha256"] + assert b.metadata["blind_commitment"] == commitment["seed_commitment"] # no plaintext json anywhere beside the blind outputs assert not list(tmp_path.rglob("*escrow.json")) assert not (blind_dir / "blind_seed.json").exists() @@ -1177,7 +1218,7 @@ def test_ac6_ac8_end_to_end_init_parts_gather_unblind(tmp_path): ], metadata={"catalogue_version": "vTEST", "type": "mock"}, ) - assert assembled.metadata["blind_commitment"] == commitment["seed_sha256"] + assert assembled.metadata["blind_commitment"] == commitment["seed_commitment"] # born-blinded derived statistics from the blinded parts differ from truth En_b, Bn_b, _ = _derive_downstream( blinded_parts["xi_reporting"], blinded_parts["xi_integration"] @@ -1185,10 +1226,10 @@ def test_ac6_ac8_end_to_end_init_parts_gather_unblind(tmp_path): assert not np.allclose(En_b, En_true, atol=0) # -- fail-closed on tampered commitment (AC6) --------------------------- -- - tampered = dict(commitment, seed_sha256="0" * 64) + tampered = dict(commitment, seed_commitment="0" * 64) with open(init["commitment"], "w", encoding="utf-8") as f: json.dump(tampered, f) - with pytest.raises(ValueError, match="sha256"): + with pytest.raises(ValueError, match="committed seed commitment"): bd.unblind_part( out_paths["xi_integration"]["blinded"], str(blind_dir), diff --git a/workflow/rules/blinding.smk b/workflow/rules/blinding.smk index bf79433f..a5f0dedb 100644 --- a/workflow/rules/blinding.smk +++ b/workflow/rules/blinding.smk @@ -30,9 +30,9 @@ rule blind_init: """Fix the blind for one catalogue version (blind-init). Draws an OS-entropy seed, writes the repo-committable commitment.json - (sha256(seed) + config digest) and the Fernet-encrypted seed bundle. Runs - once per version and refuses to overwrite existing state — a blind is a - one-shot custody event. + (seed commitment + config digest + the installed fork's draw scheme) and the + Fernet-encrypted seed bundle. Runs once per version and refuses to overwrite + existing state — a blind is a one-shot custody event. """ output: commitment=str(COSMO_VAL / "blind" / "{version}" / "commitment.json"), From 81af22795d1b98778bfdb167a691a81c77204a55 Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Mon, 17 Aug 2026 21:42:54 +0200 Subject: [PATCH 8/9] Make the run type real, and stamp rho/tau for the blind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two claims the code could not cash, both at the terminal assembly. The mock branch of assert_consistent_blind was unreachable. Every part writer hardcoded type="data", so a mock campaign's parts declared themselves data and a mock terminal assembly failed closed — only test fixtures ever built a type="mock" part. The run type is now plumbed to all six writers: CosmologyValidation carries run_type (set from config cosmo_val.type via cv_init_params) and its four writers stamp it; run_2pcf takes run_type= and a --run-type flag; run_2pcf_highres takes --run-type. papers/cosmo_val declares `type: data` explicitly, so the switch is visible where it is made. rho/tau was never stamped. The docstring said it passed through stamp_concealed_passthrough, but nothing called it for rho/tau — psf_systematics wrote the part with no commitment, so a data run's assemble_sacc died at sacc_io.load(allow_unblinded=False) on that part, before custody was ever checked. The rho_tau_stats rule now binds commitment.json on a data run and run_rho_tau passes it through calculate_rho_tau_stats, exactly as cv_cosebis and cv_pure_eb do. rho/tau carries no cosmological vector; the stamp shifts nothing and only clears the load gate. Tests: a mock campaign's parts assemble unconcealed, and the real rho/tau writer emits a part that loads without the escape hatch. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BRYg9fjMevgcsvgjh3KN6x --- papers/cosmo_val/config/config.yaml | 9 ++ src/sp_validation/cosmo_val/core.py | 9 ++ src/sp_validation/cosmo_val/cosebis.py | 2 +- src/sp_validation/cosmo_val/pseudo_cl.py | 2 +- .../cosmo_val/psf_systematics.py | 36 ++++++- src/sp_validation/cosmo_val/pure_eb.py | 9 +- .../tests/test_blinding_wiring.py | 95 ++++++++++++++++++- workflow/common.py | 16 ++++ workflow/rules/twopoint.smk | 25 ++++- workflow/scripts/run_2pcf.py | 16 +++- workflow/scripts/run_2pcf_highres.py | 15 ++- workflow/scripts/run_rho_tau.py | 9 +- 12 files changed, 224 insertions(+), 19 deletions(-) diff --git a/papers/cosmo_val/config/config.yaml b/papers/cosmo_val/config/config.yaml index 232513c7..a2124302 100644 --- a/papers/cosmo_val/config/config.yaml +++ b/papers/cosmo_val/config/config.yaml @@ -19,6 +19,15 @@ versions: [ # CosmologyValidation suite parameters (cosmo_val.py) # --------------------------------------------------------------------------- cosmo_val: + # Campaign run type: "data" or "mock". One switch, two effects. It gates + # Smokescreen blind-at-birth (a data run blinds the three blindable parts and + # binds every ξ-derived consumer to the blinded siblings; a mock run bypasses + # blinding), and it is stamped as the SACC `type` of every part written. The + # two are the same decision: an unconcealed blindable part only enters the + # terminal assembly when it declares itself a mock, so a mock campaign must + # say so here for its parts to assemble at all. + type: data + # CosmologyValidation constructor npatch: 100 theta_min: 1.0 diff --git a/src/sp_validation/cosmo_val/core.py b/src/sp_validation/cosmo_val/core.py index 0c9273d8..401d52af 100644 --- a/src/sp_validation/cosmo_val/core.py +++ b/src/sp_validation/cosmo_val/core.py @@ -98,6 +98,13 @@ class CosmologyValidation( noise debiasing, making those realizations reproducible run-to-run. cosmo_params : dict, optional Cosmological parameters to pass to get_cosmo(). If None, uses Planck 2018. + run_type : {'data', 'mock'}, default 'data' + The campaign's run type, stamped as the SACC ``type`` of every part this + object writes. Custody state, not decoration: + ``blinding.assert_consistent_blind`` admits an unconcealed blindable part + into a terminal assembly only when it declares itself a mock, so a mock + campaign must be built with ``run_type='mock'`` for its parts to + assemble at all. Attributes ---------- @@ -224,6 +231,7 @@ def __init__( path_onecovariance=None, cosmo_params=None, blind=None, + run_type="data", ): self.rho_tau_method = rho_tau_method self.cov_estimate_method = cov_estimate_method @@ -253,6 +261,7 @@ def __init__( self.nside_mask = nside_mask self.path_onecovariance = path_onecovariance self.blind = blind + self.run_type = run_type assert self.cell_method in ["map", "catalog"], ( "cell_method must be 'map' or 'catalog'" diff --git a/src/sp_validation/cosmo_val/cosebis.py b/src/sp_validation/cosmo_val/cosebis.py index 8ff2b18d..178bd971 100644 --- a/src/sp_validation/cosmo_val/cosebis.py +++ b/src/sp_validation/cosmo_val/cosebis.py @@ -203,7 +203,7 @@ def cosebis_to_sacc_part( from ..blinding import stamp_concealed_passthrough stamp_concealed_passthrough(s, commitment_path) - sacc_io.save(s, out_path, type="data") + sacc_io.save(s, out_path, type=self.run_type) def plot_cosebis( self, diff --git a/src/sp_validation/cosmo_val/pseudo_cl.py b/src/sp_validation/cosmo_val/pseudo_cl.py index 2a55934a..cfaa349e 100644 --- a/src/sp_validation/cosmo_val/pseudo_cl.py +++ b/src/sp_validation/cosmo_val/pseudo_cl.py @@ -708,7 +708,7 @@ def pseudo_cl_to_sacc_part(self, version, out_path, ell_eff, cl_all, wsp): cl_all, wsp, ) - sacc_io.save(s, out_path, type="data") + sacc_io.save(s, out_path, type=self.run_type) def plot_pseudo_cl(self): """ diff --git a/src/sp_validation/cosmo_val/psf_systematics.py b/src/sp_validation/cosmo_val/psf_systematics.py index 6f72ac1f..b81d8b1c 100644 --- a/src/sp_validation/cosmo_val/psf_systematics.py +++ b/src/sp_validation/cosmo_val/psf_systematics.py @@ -25,7 +25,12 @@ class PSFSystematicsMixin: - def calculate_rho_tau_stats(self): + def calculate_rho_tau_stats(self, commitment_path=None): + """Measure ρ/τ statistics per version and write each version's SACC part. + + ``commitment_path`` is custody plumbing forwarded to + :meth:`rho_tau_to_sacc_part`; see it for what it does. + """ out_dir = f"{self.cc['paths']['output']}/rho_tau_stats" if not os.path.exists(out_dir): os.mkdir(out_dir) @@ -44,7 +49,12 @@ def calculate_rho_tau_stats(self): npatch=self.npatch, ) self.rho_tau_to_sacc_part( - ver, out_dir, base, rho_stat_handler, tau_stat_handler + ver, + out_dir, + base, + rho_stat_handler, + tau_stat_handler, + commitment_path=commitment_path, ) self.print_done("Rho stats finished") @@ -52,10 +62,24 @@ def calculate_rho_tau_stats(self): self._tau_stat_handler = tau_stat_handler def rho_tau_to_sacc_part( - self, version, out_dir, base, rho_stat_handler, tau_stat_handler + self, + version, + out_dir, + base, + rho_stat_handler, + tau_stat_handler, + commitment_path=None, ): """Write the ρ/τ SACC part for one version. + ρ/τ is a PSF diagnostic carrying no cosmological vector, so it is never + blinded — but on a data run it still has to pass the fail-closed load + gate that ``assemble_sacc`` opens every part through. + ``commitment_path`` is that seam: it stamps the part concealed under the + version's blind (:func:`blinding.stamp_concealed_passthrough`), values + untouched, so the assembly admits it. A mock run passes ``None`` and the + part stays unstamped. + ρ_0…ρ_5 autos and τ_0/τ_2/τ_5 leakage from the handler tables. The ``CovTauTh`` theory covariance ``cov_tau_{base}_th.npy`` — a ``(3·nbin, 3·nbin)`` plus-folded k-major block over ``{τ0, τ2, τ5}`` — is @@ -79,8 +103,12 @@ def rho_tau_to_sacc_part( tau_stat_handler.tau_stats, tau_cov_th=tau_cov_th, ) + if commitment_path is not None: + from ..blinding import stamp_concealed_passthrough + + stamp_concealed_passthrough(s, commitment_path) out_path = os.path.join(out_dir, f"rho_tau_{base}.sacc") - sacc_io.save(s, out_path, type="data") + sacc_io.save(s, out_path, type=self.run_type) @property def rho_stat_handler(self): diff --git a/src/sp_validation/cosmo_val/pure_eb.py b/src/sp_validation/cosmo_val/pure_eb.py index 5424adf8..f0b41642 100644 --- a/src/sp_validation/cosmo_val/pure_eb.py +++ b/src/sp_validation/cosmo_val/pure_eb.py @@ -135,7 +135,12 @@ def calculate_pure_eb( return results def pure_eb_to_sacc_part( - self, version, out_path, results, eb_override=None, commitment_path=None + self, + version, + out_path, + results, + eb_override=None, + commitment_path=None, ): """Write the pure-E/B SACC part (six ``PURE_KEYS`` blocks + covariance). @@ -166,7 +171,7 @@ def pure_eb_to_sacc_part( from ..blinding import stamp_concealed_passthrough stamp_concealed_passthrough(s, commitment_path) - sacc_io.save(s, out_path, type="data") + sacc_io.save(s, out_path, type=self.run_type) def plot_pure_eb( self, diff --git a/src/sp_validation/tests/test_blinding_wiring.py b/src/sp_validation/tests/test_blinding_wiring.py index 706200cf..5bf5b0d7 100644 --- a/src/sp_validation/tests/test_blinding_wiring.py +++ b/src/sp_validation/tests/test_blinding_wiring.py @@ -16,9 +16,11 @@ """ import importlib.util +import json import os import subprocess import sys +import types from pathlib import Path import numpy as np @@ -103,7 +105,7 @@ def test_blindable_part_switches_on_run_type(monkeypatch): # --------------------------------------------------------------------------- # META = {"catalogue_version": "vSYNTH", "npatch": 1} # Two arbitrary-but-consistent hex stamps standing in for a real blind's -# sha256(seed) / config digest; the assembly only checks they agree across parts. +# seed commitment / config digest; the assembly only checks they agree across parts. _COMMIT = "a" * 64 _DIGEST = "b" * 64 @@ -117,12 +119,14 @@ def _spd(n, seed): return a @ a.T + n * np.eye(n) -def _data_parts(tmp_path, *, conceal, one_plaintext=False): - """Write the five per-statistic parts as ``type='data'``. +def _data_parts(tmp_path, *, conceal, one_plaintext=False, run_type="data"): + """Write the five per-statistic parts, stamped ``type=run_type``. ``conceal`` stamps every part with the shared blind (concealed=True). With ``one_plaintext`` the ξ± reporting part is left unconcealed — a blinded / - plaintext mix the assembly must refuse. + plaintext mix the assembly must refuse. ``run_type='mock'`` writes the parts + as a mock campaign's producers do, which is the only way an unconcealed + blindable part is allowed through the assembly. """ nz = {0: _nz()} theta = np.geomspace(1.0, 100.0, 6) @@ -162,7 +166,7 @@ def _data_parts(tmp_path, *, conceal, one_plaintext=False): if conceal and not (one_plaintext and name == "xi_reporting"): blinding._stamp_provenance(part, _COMMIT, "A", _DIGEST) p = tmp_path / f"{name}.sacc" - sio.save(part, str(p), type="data") + sio.save(part, str(p), type=run_type) paths[name] = str(p) return paths @@ -228,6 +232,87 @@ def test_data_assemble_stamps_the_draw_scheme_on_the_terminal_file(tmp_path): assert sio.load(str(out)).metadata["blind_draw_scheme"] == blinding.draw_scheme() +def test_mock_assemble_succeeds_without_a_blind(tmp_path): + """A mock campaign assembles its plaintext parts — the gate's mock branch is + reachable from real producers, not only from test fixtures. + + Every part writer stamps the campaign's run type (CosmologyValidation's + ``run_type``, ``run_2pcf``'s ``run_type=``, ``run_2pcf_highres``'s + ``--run-type``), so a ``mock`` campaign's parts declare themselves mocks and + ``assert_consistent_blind`` lets them through unconcealed. The same parts + stamped ``type='data'`` fail closed — that is + ``test_data_assemble_fails_closed_on_unblinded_part``. + """ + paths = _data_parts(tmp_path, conceal=False, run_type="mock") + out = tmp_path / "vSYNTH.sacc" + s = asm.assemble_sacc("vSYNTH", paths, str(out), placeholder_var=1.0) + assert "concealed" not in s.metadata + # No escape hatch: a mock part is not gated by the fail-closed loader. + assert sio.load(str(out)).metadata["type"] == "mock" + + +def test_rho_tau_part_is_stamped_concealed_from_the_commitment(tmp_path): + """ρ/τ carries no cosmological vector, but a data run's assembly still opens + it through the fail-closed load gate — so its writer must stamp it. + + This drives the real writer (``PSFSystematicsMixin.rho_tau_to_sacc_part``) + with the commitment the ``rho_tau_stats`` rule binds on a data run, and + checks the emitted part loads without the escape hatch. Without the stamp a + data run's ``assemble_sacc`` dies on the ρ/τ part before custody is ever + checked. + """ + from sp_validation.cosmo_val.psf_systematics import PSFSystematicsMixin + + blind_dir = tmp_path / "blind" + blind_dir.mkdir() + commitment = blinding.blind_init(str(blind_dir), log=lambda *_: None)["commitment"] + theta = np.geomspace(1.0, 100.0, 6) + rng = np.random.default_rng(11) + rho = {"theta": theta} + tau = {"theta": theta} + for k in sw.RHO_K: + for sign in ("p", "m"): + rho[f"rho_{k}_{sign}"] = rng.normal(size=6) * 1e-6 + rho[f"varrho_{k}_{sign}"] = rng.uniform(1e-14, 1e-13, 6) + for k in sw.TAU_K: + for sign in ("p", "m"): + tau[f"tau_{k}_{sign}"] = rng.normal(size=6) * 1e-6 + tau[f"vartau_{k}_{sign}"] = rng.uniform(1e-14, 1e-13, 6) + + class _Writer(PSFSystematicsMixin): + """The writer's collaborators, stubbed — the method under test is real.""" + + run_type = "data" + + def sacc_nz(self, version): + return {0: _nz()} + + def sacc_metadata(self, version): + return dict(META) + + def print_magenta(self, *args, **kwargs): + pass + + out_dir = tmp_path / "rho_tau_stats" + out_dir.mkdir() + _Writer().rho_tau_to_sacc_part( + "vSYNTH", + str(out_dir), + "vSYNTH", + types.SimpleNamespace(rho_stats=rho), + types.SimpleNamespace(tau_stats=tau), + commitment_path=commitment, + ) + + written = sio.load(str(out_dir / "rho_tau_vSYNTH.sacc")) + assert written.metadata["concealed"] is True + assert written.metadata["blind_draw_scheme"] == blinding.draw_scheme() + with open(commitment, encoding="utf-8") as f: + committed = json.load(f) + assert written.metadata["blind_commitment"] == committed["seed_commitment"] + assert written.metadata["blind_config_digest"] == committed["config_digest"] + + def test_assert_consistent_blind_rejects_divergent_commitments(tmp_path): """Two ξ± parts blinded under different commitments must never combine.""" nz = {0: _nz()} diff --git a/workflow/common.py b/workflow/common.py index 530b9212..d558773e 100644 --- a/workflow/common.py +++ b/workflow/common.py @@ -226,6 +226,19 @@ def get_shear_catalog(wildcards): # DAG-build time. test_blinding_wiring asserts the two stay in lockstep. +def run_type(): + """The campaign's run type, ``"data"`` or ``"mock"``. + + Every part writer stamps this as the SACC ``type`` metadata, which is what + ``blinding.assert_consistent_blind`` reads at assembly: a plaintext + blindable part may only assemble when it declares itself a mock. A function + rather than the ``RUN_TYPE`` global because ``from common import *`` binds + names before ``configure()`` runs, so only a call reads the configured + value. + """ + return RUN_TYPE + + def is_data_run(): """True when blinding is active (production data runs); False for mocks.""" return RUN_TYPE == "data" @@ -349,6 +362,9 @@ def cv_init_params(config, version_list=None): nrandom_cell=cv["nrandom_cell"], cell_method=cv["cell_method"], nside_mask=cv["nside_mask"], + # Stamped as the SACC `type` of every part the cv writes; RUN_TYPE reads + # from this same key (see configure()). + run_type=cv.get("type", "data"), ) if cv.get("path_onecovariance"): params["path_onecovariance"] = cv["path_onecovariance"] diff --git a/workflow/rules/twopoint.smk b/workflow/rules/twopoint.smk index bdfa1107..fe697893 100644 --- a/workflow/rules/twopoint.smk +++ b/workflow/rules/twopoint.smk @@ -24,6 +24,10 @@ rule xi: max_sep="{max_sep}", nbins="{nbins}", npatch="{npatch}", + # Stamped as the part's SACC `type`. An unconcealed blindable part only + # assembles when it declares itself a mock (assert_consistent_blind), so + # this is what makes a mock campaign's terminal assembly possible at all. + type=run_type(), resources: mem_mb=30000, disk_mb=20000, @@ -82,6 +86,7 @@ rule xi_highres: nbins=_INTEGRATION["nbins"], out=str(COSMO_VAL), scripts=WORKFLOW_SCRIPTS, + run_type=run_type(), threads: 24 resources: mem_mb=40000, @@ -90,7 +95,8 @@ rule xi_highres: "python {params.scripts}/run_2pcf_highres.py " "--version {params.version} --cat-config {params.cat_config} " "--min-sep {params.min_sep} --max-sep {params.max_sep} " - "--nbins {params.nbins} --npatch 1 --out {params.out}" + "--nbins {params.nbins} --npatch 1 --out {params.out} " + "--run-type {params.run_type}" rule run_cosmo_val: @@ -111,7 +117,23 @@ rule run_cosmo_val: """ +def rho_tau_inputs(w): + """ρ/τ has no blindable input; on a data run it binds the commitment. + + ρ/τ carries no cosmological vector, so it is never shifted — but the + fail-closed load gate assemble_sacc opens every part through admits only + concealed parts on a data run. Binding commitment.json lets the writer stamp + the part concealed pass-through (values untouched). A mock run binds nothing + and the part stays plaintext. + """ + if not is_data_run(): + return {} + return {"commitment": blind_state_paths(w.version)["commitment"]} + + rule rho_tau_stats: + input: + unpack(rho_tau_inputs), output: rho_stats=str(COSMO_VAL / "rho_tau_stats/rho_stats_{version}_minsep={min_sep}_maxsep={max_sep}_nbins={nbins}_npatch={npatch}.fits"), tau_stats=str(COSMO_VAL / "rho_tau_stats/tau_stats_{version}_minsep={min_sep}_maxsep={max_sep}_nbins={nbins}_npatch={npatch}.fits"), @@ -127,6 +149,7 @@ rule rho_tau_stats: max_sep="{max_sep}", nbins="{nbins}", npatch="{npatch}", + type=run_type(), resources: mem_mb=30000, disk_mb=20000, diff --git a/workflow/scripts/run_2pcf.py b/workflow/scripts/run_2pcf.py index 3e513479..b6281b74 100644 --- a/workflow/scripts/run_2pcf.py +++ b/workflow/scripts/run_2pcf.py @@ -39,6 +39,7 @@ def run_2pcf( cat_config, output_dir, sacc_out=None, + run_type="data", ): """Measure ξ±(θ) for ``ver`` and write its reporting SACC part. @@ -49,7 +50,10 @@ def run_2pcf( ``cat_config['paths']['output']`` so the ``.txt`` byproduct lands where lc expects. ``sacc_out`` is the exact destination for the reporting ξ± SACC part (the Snakemake-declared output); it defaults to ``{ver}_xi_reporting.sacc`` - under the resolved output directory for the CLI path. + under the resolved output directory for the CLI path. ``run_type`` + (``"data"`` or ``"mock"``) is stamped as the part's SACC ``type``: a + plaintext blindable part may only enter an assembly when it declares itself + a mock, so a mock campaign must say so here. Returns ------- @@ -85,7 +89,7 @@ def run_2pcf( out_path = sacc_out or os.path.join( output_dir or cv.cc["paths"]["output"], f"{ver}_xi_reporting.sacc" ) - sacc_io.save(s, out_path, type="data") + sacc_io.save(s, out_path, type=run_type) print(f"Wrote reporting ξ± SACC part: {out_path}") return gg @@ -107,6 +111,7 @@ def _from_snakemake(smk): # Write the SACC part exactly where the rule declares it (the .txt # byproduct still lands under the resolved output dir via _output_path). sacc_out=smk.output["xi_reporting"], + run_type=p.get("type", "data"), ) @@ -133,6 +138,12 @@ def _from_cli(argv=None): "--cat-config", required=True, help="Absolute path to cat_config.yaml" ) ap.add_argument("--out", required=True, help="Output directory (lc {output})") + ap.add_argument( + "--run-type", + default="data", + choices=("data", "mock"), + help="Campaign run type stamped as the part's SACC `type`", + ) a = ap.parse_args(argv) run_2pcf( ver=a.ver, @@ -142,6 +153,7 @@ def _from_cli(argv=None): npatch=a.npatch, cat_config=a.cat_config, output_dir=a.out, + run_type=a.run_type, ) diff --git a/workflow/scripts/run_2pcf_highres.py b/workflow/scripts/run_2pcf_highres.py index 6049e105..c5c546f5 100644 --- a/workflow/scripts/run_2pcf_highres.py +++ b/workflow/scripts/run_2pcf_highres.py @@ -91,6 +91,10 @@ NPATCH = None OUTPUT_DIR = None PATCH_FILE = None +# Campaign run type, stamped as the part's SACC `type`. Custody state, not +# decoration: a plaintext blindable part may only enter an assembly when it +# declares itself a mock (blinding.assert_consistent_blind). +RUN_TYPE = "data" def parse_args(argv=None): @@ -121,6 +125,12 @@ def parse_args(argv=None): "--max-sep", type=float, default=300.0, help="Max separation [arcmin]" ) ap.add_argument("--out", required=True, help="Output directory (lc {output})") + ap.add_argument( + "--run-type", + default="data", + choices=("data", "mock"), + help="Campaign run type stamped as the part's SACC `type`", + ) return ap.parse_args(argv) @@ -233,7 +243,7 @@ def write_xi_integration_sacc(gg): variances=np.concatenate([gg.varxip, gg.varxim]), ) out_path = os.path.join(OUTPUT_DIR, f"{VERSION}_xi_integration.sacc") - sacc_io.save(s, out_path, type="data") + sacc_io.save(s, out_path, type=RUN_TYPE) log(f" Wrote {out_path}") @@ -297,10 +307,11 @@ def resolve_paths(ver): def main(): global CAT_PATH, VERSION, E1_COL, E2_COL, W_COL, REDSHIFT_PATH - global TMIN, TMAX, NBINS, NPATCH, OUTPUT_DIR, PATCH_FILE + global TMIN, TMAX, NBINS, NPATCH, OUTPUT_DIR, PATCH_FILE, RUN_TYPE args = parse_args() VERSION = args.version + RUN_TYPE = args.run_type NBINS = args.nbins NPATCH = args.npatch TMIN = args.min_sep diff --git a/workflow/scripts/run_rho_tau.py b/workflow/scripts/run_rho_tau.py index 9df0bbfd..7b64c418 100644 --- a/workflow/scripts/run_rho_tau.py +++ b/workflow/scripts/run_rho_tau.py @@ -44,9 +44,16 @@ theta_max=float(params["max_sep"]), nbins=int(params["nbins"]), npatch=int(params["npatch"]), + run_type=params.get("type", "data"), ) -cv.calculate_rho_tau_stats() +# On a data run the rule binds the version's commitment.json, which stamps the +# emitted ρ/τ part concealed pass-through — values untouched (ρ/τ carries no +# cosmological vector) but clearing the fail-closed load gate at assembly. A mock +# run binds no commitment and the part stays plaintext, stamped type='mock'. +commitment_path = snakemake.input.get("commitment") # type: ignore + +cv.calculate_rho_tau_stats(commitment_path=commitment_path) # Confirm CosmologyValidation produced the requested outputs. calculate_rho_tau_stats # writes the rho/tau FITS *and* the born-as-SACC rho_tau part (via From 334047a5fa9cbd6baa2f2abc928d021736d93fdf Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Sun, 30 Aug 2026 02:05:41 +0200 Subject: [PATCH 9/9] Simplify the blinding rework: one home per fact sacc_io.save grows a commitment= kwarg and owns the passthrough stamp (three copy-pasted writer tails collapse); commitment_input() in workflow/common.py is the one place a data run binds commitment.json into rule inputs; the CLI's verify delegates draw-scheme checks to _assert_draw_scheme instead of re-wording them; _stamp_provenance loses its derivable scheme parameter. Draw-scheme rationale stated once (draw_scheme), mock-assembly rule once (assert_consistent_blind); dead escrow-recovery prose cut; doubled test assertions deduped; is_data_run reads run_type(). Container suite: 70/70 blinding+wiring+dry-run, 282 passed overall; 3 failures pre-existing and environmental (path checks on other users' scratch, stale-container glass import, a pure-eb value pin that fails identically on the unedited base). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01G9MahwJEQ1t9EuvUXijmy3 --- papers/cosmo_val/config/config.yaml | 10 ++--- scripts/blind_data_vector.py | 37 ++++++---------- src/sp_validation/blinding.py | 43 ++++++++----------- src/sp_validation/cosmo_val/core.py | 8 ++-- src/sp_validation/cosmo_val/cosebis.py | 12 ++---- .../cosmo_val/psf_systematics.py | 21 +++------ src/sp_validation/cosmo_val/pure_eb.py | 17 ++------ src/sp_validation/sacc_io.py | 12 +++++- src/sp_validation/tests/test_blinding.py | 9 ---- workflow/common.py | 18 ++++++-- workflow/rules/cosmo_val.smk | 19 +++----- workflow/rules/twopoint.smk | 23 +++------- workflow/scripts/run_2pcf.py | 5 +-- workflow/scripts/run_2pcf_highres.py | 3 +- 14 files changed, 89 insertions(+), 148 deletions(-) diff --git a/papers/cosmo_val/config/config.yaml b/papers/cosmo_val/config/config.yaml index a2124302..f3ead5ee 100644 --- a/papers/cosmo_val/config/config.yaml +++ b/papers/cosmo_val/config/config.yaml @@ -19,13 +19,9 @@ versions: [ # CosmologyValidation suite parameters (cosmo_val.py) # --------------------------------------------------------------------------- cosmo_val: - # Campaign run type: "data" or "mock". One switch, two effects. It gates - # Smokescreen blind-at-birth (a data run blinds the three blindable parts and - # binds every ξ-derived consumer to the blinded siblings; a mock run bypasses - # blinding), and it is stamped as the SACC `type` of every part written. The - # two are the same decision: an unconcealed blindable part only enters the - # terminal assembly when it declares itself a mock, so a mock campaign must - # say so here for its parts to assemble at all. + # Campaign run type: "data" or "mock". One switch: it gates Smokescreen + # blind-at-birth and is stamped as the SACC `type` of every part written + # (custody state at assembly — see blinding.assert_consistent_blind). type: data # CosmologyValidation constructor diff --git a/scripts/blind_data_vector.py b/scripts/blind_data_vector.py index 692ebd94..1050d226 100644 --- a/scripts/blind_data_vector.py +++ b/scripts/blind_data_vector.py @@ -9,9 +9,8 @@ (never printed, never written in plaintext), publishes a repo-committable ``commitment.json`` (the seed commitment + the config digest + the installed Smokescreen fork's draw scheme), and encrypts the seed into a Fernet bundle. -Those three, not the seed alone, are what reproduces a blind: seed and config -fix *which* shift, the draw scheme fixes *how* the seed becomes that shift. -``blind-part`` blinds one intermediate part SACC (reporting ξ±, integration ξ±, +Those three, not the seed alone, are what reproduces a blind (see +``blinding.draw_scheme``). ``blind-part`` blinds one intermediate part SACC (reporting ξ±, integration ξ±, or pseudo-Cℓ) under that fixed state, escrows the true vector into a per-part encrypted bundle beside the blinded output, and deletes the plaintext part. ``unblind`` verifies all three commitments and restores a true part @@ -113,8 +112,7 @@ def _verify(args): only that the file's three custody stamps agree with the commitment, and that the recorded draw scheme is the one this install implements. That last check makes the result environment-dependent by design: a machine carrying a - different Smokescreen reports a problem even when file and commitment agree - perfectly, because that machine could not unblind the file. + different Smokescreen could not unblind the file, so it reports a problem. Loads with ``allow_unblinded=True``: the whole job here is to report on a file's custody state, including the state where the file is not concealed at @@ -131,30 +129,19 @@ def _verify(args): problems.append("blind_commitment does not match the committed seed commitment") if s.metadata.get("blind_config_digest") != commitment["config_digest"]: problems.append("blind_config_digest does not match the committed digest") - # The draw scheme is checked three ways: file ↔ commitment, and both against - # the installed fork. A scheme mismatch is the one blinding failure with no - # numerical symptom — the unblind would subtract a different shift than was - # added and every other check here would still pass. - installed = blinding.draw_scheme() + # The draw scheme is checked two ways: file ↔ commitment, and the file's + # against the installed fork (see blinding.draw_scheme). file_scheme = s.metadata.get("blind_draw_scheme") committed = commitment.get("draw_scheme") - if file_scheme is None or committed is None: + if file_scheme != committed: problems.append( - "no draw-scheme record on the file and/or in the commitment — " - "this blind predates draw-scheme binding and cannot be verified " - "reproducible" - ) - elif int(file_scheme) != int(committed): - problems.append( - f"blind_draw_scheme {int(file_scheme)} does not match the committed " - f"draw_scheme {int(committed)}" - ) - elif int(file_scheme) != installed: - problems.append( - f"blind was drawn under Smokescreen DRAW_SCHEME={int(file_scheme)} " - f"but the installed fork implements DRAW_SCHEME={installed} — this " - "install cannot reproduce the shift" + f"blind_draw_scheme {file_scheme!r} does not match the committed " + f"draw_scheme {committed!r}" ) + try: + blinding._assert_draw_scheme(file_scheme, "the blinded file") + except ValueError as exc: + problems.append(str(exc)) if "seed_smokescreen" in s.metadata: problems.append("PLAINTEXT SEED LEAKED into file metadata (seed_smokescreen)") if problems: diff --git a/src/sp_validation/blinding.py b/src/sp_validation/blinding.py index 34b648d2..247b20e9 100644 --- a/src/sp_validation/blinding.py +++ b/src/sp_validation/blinding.py @@ -46,9 +46,8 @@ and the installed fork's ``DRAW_SCHEME`` as a repo-committable ``commitment.json``, and encrypts the seed into a Fernet bundle (``smokescreen.encryption``) — the plaintext seed is never written. The - three together, not the seed alone, are what reproduces a blind: seed and - config fix *which* shift, the draw scheme fixes *how* the seed becomes that - shift. + three together, not the seed alone, are what reproduces a blind (see + :func:`draw_scheme`). Each :func:`blind_part` call reads that fixed state, conceals one part, escrows the part's true vector into its own encrypted bundle beside the @@ -61,14 +60,10 @@ ``blind_draw_scheme``. :func:`unblind_part` verifies all three against the commitment *before* subtracting anything, then restores the true part. - Custody has no back door, including for its own history: a blind fixed - before the draw scheme was bound into ``commitment.json`` carries no - scheme record, and :func:`_assert_draw_scheme` refuses it ahead of every - seed read, so neither :func:`blind_part` nor :func:`unblind_part` will run - on it. Such a blind is recoverable only by hand, by decrypting its per-part - escrow bundles (:func:`_read_encrypted_json`) and reading ``true_mean`` - back out. No such blind exists — the scheme has been bound since before - the first real blind — and the CLI deliberately offers no override. + Custody has no back door, including for its own history: a blind carrying no + scheme record is refused by :func:`_assert_draw_scheme` ahead of every seed + read, with no CLI override — none exists, the scheme having been bound + before the first real blind. """ import dataclasses @@ -228,7 +223,8 @@ def _assert_draw_scheme(recorded, what): ``what`` names the surface the scheme was read from, for the message. A missing record (``None``) is a failure, not a pass: a blind whose scheme - is unknown cannot be shown to be reproducible by this install. + is unknown cannot be shown to be reproducible by this install (see + :func:`draw_scheme`). """ installed = draw_scheme() if recorded is None: @@ -570,7 +566,7 @@ def unblind_sacc(blinded, seed, config=None, log=print): return part -def _stamp_provenance(s, commitment, label, config_digest, scheme=None): +def _stamp_provenance(s, commitment, label, config_digest): """Stamp the blind's public provenance; strip any leaked seed. ``blind_commitment`` (sha256 of the seed) ties the file to its blind @@ -582,17 +578,16 @@ def _stamp_provenance(s, commitment, label, config_digest, scheme=None): raw seed upstream Smokescreen's writer would stamp — is popped defensively: the seed must never ride a kept file. - ``scheme`` defaults to the installed fork's, which is the right answer - whenever this call is stamping a blind that was just computed. Callers - propagating an existing blind's provenance pass that blind's recorded - scheme instead. + The stamped scheme is always the installed fork's: every caller has already + checked the blind's recorded scheme against it (:func:`_assert_draw_scheme`), + so the two agree by the time this runs. """ s.metadata.pop("seed_smokescreen", None) s.metadata["concealed"] = True s.metadata["blind"] = label s.metadata["blind_commitment"] = commitment s.metadata["blind_config_digest"] = config_digest - s.metadata["blind_draw_scheme"] = draw_scheme() if scheme is None else int(scheme) + s.metadata["blind_draw_scheme"] = draw_scheme() def stamp_concealed_passthrough(s, commitment_path): @@ -628,7 +623,6 @@ def stamp_concealed_passthrough(s, commitment_path): commitment["seed_commitment"], commitment["label"], commitment["config_digest"], - scheme=commitment["draw_scheme"], ) return s @@ -808,12 +802,11 @@ def blind_init(blind_dir, config=None, label="A", log=print): def _read_seed(blind_dir, config): """Decrypt the seed bundle and verify it against the commitment. - The seed commitment, the config digest, and the committed draw scheme are all - checked before the seed is handed to any caller — a tampered bundle, a - drifted config or a Smokescreen that draws differently from the one that - fixed this blind all fail loud here, whether the caller is about to blind - or to unblind. The scheme check is what stops a re-blind of a later part - from landing a different hidden cosmology than the earlier parts got. + The seed commitment, the config digest, and the committed draw scheme (see + :func:`draw_scheme`) are all checked before the seed is handed to any caller + — a tampered bundle, a drifted config or a Smokescreen that draws + differently from the one that fixed this blind all fail loud here, whether + the caller is about to blind or to unblind. Returns ------- diff --git a/src/sp_validation/cosmo_val/core.py b/src/sp_validation/cosmo_val/core.py index 401d52af..05ded922 100644 --- a/src/sp_validation/cosmo_val/core.py +++ b/src/sp_validation/cosmo_val/core.py @@ -100,11 +100,9 @@ class CosmologyValidation( Cosmological parameters to pass to get_cosmo(). If None, uses Planck 2018. run_type : {'data', 'mock'}, default 'data' The campaign's run type, stamped as the SACC ``type`` of every part this - object writes. Custody state, not decoration: - ``blinding.assert_consistent_blind`` admits an unconcealed blindable part - into a terminal assembly only when it declares itself a mock, so a mock - campaign must be built with ``run_type='mock'`` for its parts to - assemble at all. + object writes. Custody state, not decoration: a mock campaign must be + built with ``run_type='mock'`` for its parts to assemble at all (see + ``blinding.assert_consistent_blind``). Attributes ---------- diff --git a/src/sp_validation/cosmo_val/cosebis.py b/src/sp_validation/cosmo_val/cosebis.py index 178bd971..4fbec2ba 100644 --- a/src/sp_validation/cosmo_val/cosebis.py +++ b/src/sp_validation/cosmo_val/cosebis.py @@ -185,10 +185,8 @@ def cosebis_to_sacc_part( to the part in place of ``result["En"]`` — re-derived from the integration ξ± SACC part at the fiducial scale cut (Bn and the covariance stay from the raw estimator ``result``). ``commitment_path`` stamps the part concealed - under that version's blind (via - :func:`blinding.stamp_concealed_passthrough`) before save, so a data run's - part clears the fail-closed load gate. With both ``None`` (mock runs) the - behaviour is unchanged. + under that version's blind (see :func:`sacc_io.save`). With both ``None`` + (mock runs) the behaviour is unchanged. """ result, scale_cut = self._fiducial_cosebis_result(results, fiducial_scale_cut) if en_override is not None: @@ -199,11 +197,7 @@ def cosebis_to_sacc_part( result, scale_cut, ) - if commitment_path is not None: - from ..blinding import stamp_concealed_passthrough - - stamp_concealed_passthrough(s, commitment_path) - sacc_io.save(s, out_path, type=self.run_type) + sacc_io.save(s, out_path, type=self.run_type, commitment=commitment_path) def plot_cosebis( self, diff --git a/src/sp_validation/cosmo_val/psf_systematics.py b/src/sp_validation/cosmo_val/psf_systematics.py index b81d8b1c..c7984c26 100644 --- a/src/sp_validation/cosmo_val/psf_systematics.py +++ b/src/sp_validation/cosmo_val/psf_systematics.py @@ -26,11 +26,7 @@ class PSFSystematicsMixin: def calculate_rho_tau_stats(self, commitment_path=None): - """Measure ρ/τ statistics per version and write each version's SACC part. - - ``commitment_path`` is custody plumbing forwarded to - :meth:`rho_tau_to_sacc_part`; see it for what it does. - """ + """Measure ρ/τ statistics per version and write each version's SACC part.""" out_dir = f"{self.cc['paths']['output']}/rho_tau_stats" if not os.path.exists(out_dir): os.mkdir(out_dir) @@ -73,12 +69,9 @@ def rho_tau_to_sacc_part( """Write the ρ/τ SACC part for one version. ρ/τ is a PSF diagnostic carrying no cosmological vector, so it is never - blinded — but on a data run it still has to pass the fail-closed load - gate that ``assemble_sacc`` opens every part through. - ``commitment_path`` is that seam: it stamps the part concealed under the - version's blind (:func:`blinding.stamp_concealed_passthrough`), values - untouched, so the assembly admits it. A mock run passes ``None`` and the - part stays unstamped. + blinded; ``commitment_path`` stamps the part concealed under the + version's blind, values untouched (see :func:`sacc_io.save`), so a data + run's assembly admits it. A mock run passes ``None``. ρ_0…ρ_5 autos and τ_0/τ_2/τ_5 leakage from the handler tables. The ``CovTauTh`` theory covariance ``cov_tau_{base}_th.npy`` — a @@ -103,12 +96,8 @@ def rho_tau_to_sacc_part( tau_stat_handler.tau_stats, tau_cov_th=tau_cov_th, ) - if commitment_path is not None: - from ..blinding import stamp_concealed_passthrough - - stamp_concealed_passthrough(s, commitment_path) out_path = os.path.join(out_dir, f"rho_tau_{base}.sacc") - sacc_io.save(s, out_path, type=self.run_type) + sacc_io.save(s, out_path, type=self.run_type, commitment=commitment_path) @property def rho_stat_handler(self): diff --git a/src/sp_validation/cosmo_val/pure_eb.py b/src/sp_validation/cosmo_val/pure_eb.py index f0b41642..e7220c47 100644 --- a/src/sp_validation/cosmo_val/pure_eb.py +++ b/src/sp_validation/cosmo_val/pure_eb.py @@ -135,12 +135,7 @@ def calculate_pure_eb( return results def pure_eb_to_sacc_part( - self, - version, - out_path, - results, - eb_override=None, - commitment_path=None, + self, version, out_path, results, eb_override=None, commitment_path=None ): """Write the pure-E/B SACC part (six ``PURE_KEYS`` blocks + covariance). @@ -154,8 +149,8 @@ def pure_eb_to_sacc_part( — re-derived from the reporting + integration ξ± SACC parts (the covariance stays blind-invariant from the raw estimator ``results``). ``commitment_path`` stamps the part concealed under that version's blind - (via :func:`blinding.stamp_concealed_passthrough`) before save. With both - ``None`` (mock runs) the behaviour is unchanged. + (see :func:`sacc_io.save`). With both ``None`` (mock runs) the behaviour + is unchanged. """ theta = results["gg"].meanr source = eb_override if eb_override is not None else results @@ -167,11 +162,7 @@ def pure_eb_to_sacc_part( eb, covariance=results["cov"], ) - if commitment_path is not None: - from ..blinding import stamp_concealed_passthrough - - stamp_concealed_passthrough(s, commitment_path) - sacc_io.save(s, out_path, type=self.run_type) + sacc_io.save(s, out_path, type=self.run_type, commitment=commitment_path) def plot_pure_eb( self, diff --git a/src/sp_validation/sacc_io.py b/src/sp_validation/sacc_io.py index 7dcb72a7..8d14e6ca 100644 --- a/src/sp_validation/sacc_io.py +++ b/src/sp_validation/sacc_io.py @@ -924,7 +924,7 @@ def update_statistic(s, sub): s.data[idx[0]].value = point.value -def save(s, path, *, type): +def save(s, path, *, type, commitment=None): """Write ``s`` to ``path`` (FITS), overwriting any existing file. Parameters @@ -937,6 +937,12 @@ def save(s, path, *, type): the pipeline computing the data vector — knows whether its input catalogue is a mock; there is deliberately no default. ``load`` refuses ``type='data'`` files that are not blinded. + commitment : str, optional + Path to the version's ``commitment.json``. When given, the file is + stamped concealed under that blind + (:func:`sp_validation.blinding.stamp_concealed_passthrough`, values + untouched) before writing — the seam every born-blinded or + blind-irrelevant part uses to clear the fail-closed load gate. """ if type not in ("data", "mock"): raise ValueError(f"type must be 'data' or 'mock'; got {type!r}") @@ -946,6 +952,10 @@ def save(s, path, *, type): f"refusing to re-stamp as {type!r}" ) s.metadata["type"] = type + if commitment is not None: + from . import blinding + + blinding.stamp_concealed_passthrough(s, commitment) s.save_fits(path, overwrite=True) diff --git a/src/sp_validation/tests/test_blinding.py b/src/sp_validation/tests/test_blinding.py index 6d808895..f4aa7d58 100644 --- a/src/sp_validation/tests/test_blinding.py +++ b/src/sp_validation/tests/test_blinding.py @@ -336,18 +336,10 @@ def test_theory_config_ccl_params_exact_keyset(): # --------------------------------------------------------------------------- # def test_commitment_is_the_forks_domain_separated_digest(): """One definition of the commitment, and it is the fork's.""" - import hashlib - import smokescreen seed = "the-secret" assert bd.seed_commitment(seed) == smokescreen.seed_commitment(seed) - assert ( - bd.seed_commitment(seed) - == hashlib.sha256( - smokescreen.COMMITMENT_DOMAIN + seed.encode("utf-8") - ).hexdigest() - ) assert bd.seed_commitment("right") != bd.seed_commitment("wrong") @@ -370,7 +362,6 @@ def test_commitment_does_not_embed_the_rng_seed(): for seed in ("my_secret_seed", "the-secret", secrets.token_hex(16)): commitment = bd.seed_commitment(seed) assert int(commitment[:16], 16) != _normalize_seed(seed) - assert str(_normalize_seed(seed)) not in commitment def test_hidden_params_deterministic_and_in_envelope(): diff --git a/workflow/common.py b/workflow/common.py index d558773e..78224d02 100644 --- a/workflow/common.py +++ b/workflow/common.py @@ -230,8 +230,7 @@ def run_type(): """The campaign's run type, ``"data"`` or ``"mock"``. Every part writer stamps this as the SACC ``type`` metadata, which is what - ``blinding.assert_consistent_blind`` reads at assembly: a plaintext - blindable part may only assemble when it declares itself a mock. A function + ``blinding.assert_consistent_blind`` reads at assembly. A function rather than the ``RUN_TYPE`` global because ``from common import *`` binds names before ``configure()`` runs, so only a call reads the configured value. @@ -241,7 +240,7 @@ def run_type(): def is_data_run(): """True when blinding is active (production data runs); False for mocks.""" - return RUN_TYPE == "data" + return run_type() == "data" def blind_state_dir(version): @@ -262,6 +261,19 @@ def blind_state_paths(version): } +def commitment_input(version): + """Input mapping binding a version's commitment.json, on data runs only. + + A part writer stamps its output concealed from that file (sacc_io.save's + `commitment=`), which is what lets a born-blinded or blind-irrelevant part + clear the fail-closed load gate the terminal assembly opens every part + through. A mock run binds nothing and the part stays plaintext. + """ + if not is_data_run(): + return {} + return {"commitment": blind_state_paths(version)["commitment"]} + + def blinded_path(part_path): """The *_blinded sibling blind_part writes beside a plaintext part. diff --git a/workflow/rules/cosmo_val.smk b/workflow/rules/cosmo_val.smk index 705ac9bc..049b30b8 100644 --- a/workflow/rules/cosmo_val.smk +++ b/workflow/rules/cosmo_val.smk @@ -370,29 +370,24 @@ rule cv_pseudo_cl: # On a data run the COSEBIs / pure-E/B parts re-derive their E-mode vector from # the *blinded* integration ξ± (COSEBIs) or blinded reporting + integration ξ± -# (pure-E/B), and stamp the derived part concealed from the version's -# commitment.json. blindable_part returns the plaintext part on a mock run and -# the blinded part on a data run, so the ξ± inputs bind unconditionally for every -# version; only the commitment is gated on is_data_run(). +# (pure-E/B). blindable_part returns the plaintext part on a mock run and the +# blinded part on a data run, so the ξ± inputs bind unconditionally for every +# version; see common.commitment_input for the commitment. def cv_cosebis_inputs(w): - inputs = { + return { "xi": cv_xi_txt(w.version), "xi_integration": blindable_part(cv_xi_integration_sacc(w.version)), + **commitment_input(w.version), } - if is_data_run(): - inputs["commitment"] = blind_state_paths(w.version)["commitment"] - return inputs def cv_pure_eb_inputs(w): - inputs = { + return { "xi": cv_xi_txt(w.version), "xi_reporting": blindable_part(cv_xi_reporting_sacc(w.version)), "xi_integration": blindable_part(cv_xi_integration_sacc(w.version)), + **commitment_input(w.version), } - if is_data_run(): - inputs["commitment"] = blind_state_paths(w.version)["commitment"] - return inputs rule cv_pure_eb: diff --git a/workflow/rules/twopoint.smk b/workflow/rules/twopoint.smk index fe697893..31b1dd5a 100644 --- a/workflow/rules/twopoint.smk +++ b/workflow/rules/twopoint.smk @@ -24,9 +24,8 @@ rule xi: max_sep="{max_sep}", nbins="{nbins}", npatch="{npatch}", - # Stamped as the part's SACC `type`. An unconcealed blindable part only - # assembles when it declares itself a mock (assert_consistent_blind), so - # this is what makes a mock campaign's terminal assembly possible at all. + # Stamped as the part's SACC `type` — custody state at assembly + # (see blinding.assert_consistent_blind). type=run_type(), resources: mem_mb=30000, @@ -117,23 +116,11 @@ rule run_cosmo_val: """ -def rho_tau_inputs(w): - """ρ/τ has no blindable input; on a data run it binds the commitment. - - ρ/τ carries no cosmological vector, so it is never shifted — but the - fail-closed load gate assemble_sacc opens every part through admits only - concealed parts on a data run. Binding commitment.json lets the writer stamp - the part concealed pass-through (values untouched). A mock run binds nothing - and the part stays plaintext. - """ - if not is_data_run(): - return {} - return {"commitment": blind_state_paths(w.version)["commitment"]} - - rule rho_tau_stats: + # ρ/τ has no blindable input; it binds only the commitment, to stamp its + # part concealed pass-through (see common.commitment_input). input: - unpack(rho_tau_inputs), + unpack(lambda w: commitment_input(w.version)), output: rho_stats=str(COSMO_VAL / "rho_tau_stats/rho_stats_{version}_minsep={min_sep}_maxsep={max_sep}_nbins={nbins}_npatch={npatch}.fits"), tau_stats=str(COSMO_VAL / "rho_tau_stats/tau_stats_{version}_minsep={min_sep}_maxsep={max_sep}_nbins={nbins}_npatch={npatch}.fits"), diff --git a/workflow/scripts/run_2pcf.py b/workflow/scripts/run_2pcf.py index b6281b74..7f39660c 100644 --- a/workflow/scripts/run_2pcf.py +++ b/workflow/scripts/run_2pcf.py @@ -51,9 +51,8 @@ def run_2pcf( expects. ``sacc_out`` is the exact destination for the reporting ξ± SACC part (the Snakemake-declared output); it defaults to ``{ver}_xi_reporting.sacc`` under the resolved output directory for the CLI path. ``run_type`` - (``"data"`` or ``"mock"``) is stamped as the part's SACC ``type``: a - plaintext blindable part may only enter an assembly when it declares itself - a mock, so a mock campaign must say so here. + (``"data"`` or ``"mock"``) is stamped as the part's SACC ``type`` — custody + state at assembly (see ``blinding.assert_consistent_blind``). Returns ------- diff --git a/workflow/scripts/run_2pcf_highres.py b/workflow/scripts/run_2pcf_highres.py index c5c546f5..985c0ef4 100644 --- a/workflow/scripts/run_2pcf_highres.py +++ b/workflow/scripts/run_2pcf_highres.py @@ -92,8 +92,7 @@ OUTPUT_DIR = None PATCH_FILE = None # Campaign run type, stamped as the part's SACC `type`. Custody state, not -# decoration: a plaintext blindable part may only enter an assembly when it -# declares itself a mock (blinding.assert_consistent_blind). +# decoration (see blinding.assert_consistent_blind). RUN_TYPE = "data"