Smokescreen-fork blinding: per-part-at-birth blind, hash-commitment custody, CAMB↔CCL cross-check (PR 6)#253
Draft
cailmdaley wants to merge 17 commits into
Draft
Smokescreen-fork blinding: per-part-at-birth blind, hash-commitment custody, CAMB↔CCL cross-check (PR 6)#253cailmdaley wants to merge 17 commits into
cailmdaley wants to merge 17 commits into
Conversation
This was referenced Jul 10, 2026
The single home for the blinding/inference fiducial (TheoryConfig, frozen, fail-fast overrides; two stack-specific halofit tokens denoting one HMCode2020+feedback recipe; Neff/T_CMB emitted explicitly as module constants) and two ξ± routes: the CCL-native path (Boltzmann-CAMB P(k) + CCL Limber/FFTLog — the blinding backends' recipe) and an independent pycamb P(k) → Pk2D → CCL projection with the closed-form σ8-matched A_s rescale. numpy-only at module level; CCL/CAMB are function-local imports. Cosmology objects are cached per parameter point so the three blinding backends share two CAMB runs per blind. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KpaRHk3QwN13myduQ3hJyf
Rework of the blinding box onto the UNIONS-WL/Smokescreen fork protocol:
the fork's ConcealDataVector(fiducial_params, shifts_dict, sacc_data, *,
seed, theory_fn) is the concealment engine for every block, with its
internal per-key-independent CCL-native draw — no local draw, no
firecrown, no likelihood object.
- Envelope calibration: the (S8, Ωm) box maps to {sigma8, Omega_c}
half-widths at the fiducial (σ8 = S8/√(Ωm/0.3); Ω_c one-to-one).
- Three theory_fn backends matching the master layout: coarse ξ±, fine ξ±
(grid tags), pseudo-Cℓ_EE (W @ Cℓ_EE on the stored BandpowerWindows;
ΔBB = ΔEB ≡ 0). Two-tracer everywhere; rows aligned to each extracted
block's own order.
- Extract → conceal → merge per block, with recorded master indices;
COSEBIs/pure-E/B re-derived through the pipeline estimators
(cosmo_numba kernels) on the blinded fine ξ±; covariance and ρ/τ
untouched.
- Custody: sha256(seed) + canonical config digest as a repo-committable
commitment; seed + true vector escrowed in a Fernet bundle
(smokescreen.encryption); seed_smokescreen stripped; unblind verifies
both hashes before subtracting and restores the truth bit-for-bit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KpaRHk3QwN13myduQ3hJyf
One-shot custody semantics: refuses to overwrite any existing blind output before touching theory code; the seed is drawn from OS entropy and never printed. verify is a seedless metadata↔commitment check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KpaRHk3QwN13myduQ3hJyf
…10–14 test_blinding.py: fast coverage of the envelope calibration, canonical digest, commitment, fork-draw determinism/purity, merge alignment and guards (monkeypatched factor, no CCL); slow coverage of AC2 (on-file shift == theory difference, hidden recovered via the fork's draw, all three blocks, two-bin), AC3 (independently written direct-CCL reference), AC7 (seed reproducibility); slow+cosmo_numba coverage of AC1 (zero-shift identity through the estimators), AC4 (ΔBₙ independent of injected B — COSEBIs exact to ~1e-10; the pure-ξ_B leakage bounded at the adaptive- quadrature floor), AC5 (covariance/ρ/τ/row order untouched), AC9 (boundary-degenerate pure-EB point NaN in both re-derivations), and AC6+AC8 (end-to-end custody: no plaintext seed on disk, fail-closed on tampered commitment or wrong config, bit-for-bit restoration). test_camb_ccl_crosscheck.py: written fresh against sp_validation.cosmology — AC10 σ8/A_s reconciliation (nominal offset 3.4%; closed-form rescale lands to 1e-16), AC11/AC12 ξ± agreement at and off the fiducial (observed floor ξ+ 0.24%/0.28%, ξ− 0.10%/0.12% against 0.5%/1.0% tolerances), AC13 halofit token pinned to the CosmoSIS inference ini, AC14 fast smoke. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KpaRHk3QwN13myduQ3hJyf
971767b to
11b8895
Compare
…COSEBI writer contracts The `blinding` extra pinned `smokescreen==1.5.6`, which resolves to the upstream LSSTDESC PyPI package — the fork self-reports the same version, so the version pin silently installed a package without `param_shifts. draw_param_shifts` or `ConcealDataVector`'s `fiducial_params`/`theory_fn` signature, and CI failed at import. Pin the UNIONS-WL fork by commit, and add cosmo_numba (Cail's numpy-2.x fork commit) as a declared dependency so the COSEBIs / pure-E/B re-derivation kernels are present in the image. sacc_io: make the derived-statistics writer contracts explicit so the blind re-derives on the SAME support the pipeline used, not a private convention. `add_cosebis`' scale_cut tags are the retained-bin support (min/max of the cut meanr, per b_modes.scale_cut_to_bins), not the requested cut. `add_pure_eb` now requires `bounds=(tmin, tmax)` — the pipeline's TreeCorr edge-based integration range (left_edges[0]/right_edges[-1]), which is not reconstructible from meanr — stored as tmin/tmax tags; and documents that the reporting grid is the coarse ξ± meanr (the measured reporting-binned ξ±), which blinding reconstructs from the stored coarse block rather than interpolating the fine grid. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KpaRHk3QwN13myduQ3hJyf
…delete the plaintext master Two confirmed adversarial-review findings on blinding.py. Re-derivation seam (findings 4/6/7). The old rederive_eb_from_fine_xi rebuilt the pure-E/B reporting ξ± by np.interp of the fine grid and reconstructed TreeCorr edges from stored θ via a geometric-mean pseudo-edge helper (_grid_edges) — a private convention that does NOT match what b_modes.calculate_pure_eb_correlation feeds the Schneider kernel (the MEASURED coarse-binned ξ± at gg.meanr, with gg.left_edges[0]/right_edges[-1] bounds), so on a real pipeline master a zero-shift blind would not reproduce the stored values. Now: the reporting ξ± are read from the master's own coarse ξ± block (fail loud if the coarse grid ≠ the stored reporting grid, since the measured reporting ξ± can't otherwise be reconstructed), the integration bounds are read from the tmin/tmax tags the writer stamps, and _grid_edges is removed. rederive_cosebis masks ξ± by the stored scale_cut (the retained-bin support) before building the COSEBI kernel, matching b_modes' theta_cut/xip_cut path rather than passing the full fine grid with tag-derived tmin/tmax. Custody (finding 1). blind_file left the input master — the full plaintext true vector — on disk indefinitely, contradicting the PRD criterion that "the plaintext true vector is deleted at blinding time". It now deletes master_path after a successful blind (the true vector survives only inside the encrypted bundle); --keep-input retains it with the custody implication documented. The module docstring's custody claim and the colocated-key caveat (bundle + Fernet key in one dir is not at-rest protection) are stated explicitly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KpaRHk3QwN13myduQ3hJyf
… full suite in CI; correct AC2 framing Test- and CI-side of four confirmed findings. Finding 4 (fixture tautology). make_master seeded the pure-E/B blocks with the blinding module's own interp + _grid_edges seam — the exact code under test — so AC1/4/9 were self-consistent by construction and blind to any divergence from the pipeline. The fixture now seeds pure-E/B through the pipeline convention the re-derivation reconstructs: reporting grid = the coarse ξ± grid, reporting ξ± = the MEASURED coarse ξ± (not an interpolation of the fine grid), integration ξ± = the fine grid, with edge-based bounds passed via the new add_pure_eb(bounds=...) contract. The outermost reporting point sits at tmax (no interior support) → NaN, the AC9 boundary case. This makes AC1/4/9 a real check of the re-derivation against the pipeline convention. Finding 3 (CI never ran the ACs). deploy-image.yml ran `pytest -m "not slow"`, so every blinding AC (all @slow, including the cosmo_numba-gated AC1/4/5/9) was deselected in CI — contradicting the PRD's "CI runs it in the freshly built image before publish". The image already carries the full stack (the smoke test imports cosmo_numba), so CI now runs the FULL suite before pushing. Finding 2 (AC2 framing). AC2 runs the identical theory_fn on both sides, so a wrong cosmology cancels and a wrong backend still passes — it verifies fork-draw recovery + merge placement, not backend correctness (that is AC3, the independent CCL reference). The test docstring said "central correctness"; it now states the real scope and points at AC3. Finding 1 (custody, test/CLI side). The e2e custody test asserts the plaintext input master is deleted at blinding time. The CLI grows --keep-input and its docstring no longer claims the true vector never survives when it did. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KpaRHk3QwN13myduQ3hJyf
With the re-derivation corrected to feed the measured coarse reporting ξ± and the fine integration grid (not the old interp + pseudo-edge seam), the Schneider estimator no longer degenerates at the reporting boundary — interior support exists at every reporting point, matching the PRD's own statement that on production files (reporting grid a strict sub-range of the integration range) the degeneracy never fires. The old AC9 asserted a NaN MUST appear at the outermost point, which was an artifact of the pre-rework seam; that assertion now fails against the corrected fixture. AC9 now asserts the real invariant: the re-derived NaN pattern is identical on the true and blinded files (blinding shifts inputs, never moves estimator support), whether or not any point is degenerate. Verified with cosmo_numba installed: full blinding suite 25 passed, 0 skipped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KpaRHk3QwN13myduQ3hJyf
…rnels load in CI Turning on the full suite in CI surfaced a real gap the fast suite hid: the image had cosmo_numba but not NumbaQuadpack, so the COSEBIs / pure-E/B kernels (cosmo_numba.B_modes.cosebis / schneider2022, which `from NumbaQuadpack import dqags`) died at import and the five kernel-backed ACs (AC1/4/5/9 + e2e AC6/8) failed. cosmo_numba declares NumbaQuadpack as a direct-URL (git+) dependency, and pip/uv refuse to install a direct-URL *transitive* dep — so `import cosmo_numba` (light top-level) succeeded while the kernels were absent. Name NumbaQuadpack at top level in the `blinding` extra so the Dockerfile install resolves it, and harden the CI blinding-stack smoke test to import the kernel modules (COSEBIS, get_pure_EB_modes) rather than just the top-level package — so a missing NumbaQuadpack can never again pass the smoke test and only blow up deep in the slow ACs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KpaRHk3QwN13myduQ3hJyf
…o sp_validation/cosmology.py `develop` deliberately deleted src/sp_validation/cosmology.py (#223), moving cosmology to cs_util.cosmo; this branch must not resurrect it. Per the UNIONS layering — cs_util is the cosmology *library* (generic machinery), sp_validation holds *configuration* and *survey-specific implementations* — the blinding theory surface belongs in the blinding namespace, not a local cosmology module. Rename cosmology.py → blinding_theory.py. TheoryConfig (configuration) and the three master-layout backends in blinding.py (coarse/fine ξ±, pseudo-Cℓ — survey-specific) are correctly homed here. The generic pieces — the CCL-native ξ± path (xi_ccl/cl_ee), the independent CAMB P(k)→Pk2D path (xi_camb), the σ8/A_s rescale (camb_As_for_sigma8) — live here FOR NOW with an explicit module-docstring note that they are cosmology-library code destined for cs_util.cosmo. Module-scope imports stay numpy-only (dataclasses + numpy). Updated all importers: blinding.py, test_blinding.py, and test_camb_ccl_crosscheck.py (module ref + alias). No sp_validation/cosmology.py anywhere. Affected tests (blinding + CAMB↔CCL cross-check) green with cosmo_numba: 30 passed, 0 skipped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KpaRHk3QwN13myduQ3hJyf
…ding_theory
The generic theory machinery destined for cs_util.cosmo is tracked in
cs_util#80 ("cosmo: absorb generic theory machinery from sp_validation
blinding"); name it in the module docstring so the migration has an explicit
anchor.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KpaRHk3QwN13myduQ3hJyf
…compat The finding-6/7 commit made add_pure_eb's `bounds` a required keyword-only argument, which broke three test_sacc_io.py call sites (plain pure-E/B writes for storage/roundtrip and the non-ascending-theta guard) with a TypeError — caught by the newly-enabled full CI suite, not the blinding-only local runs. add_pure_eb is PR-2's writer surface (feat/sacc-2-sacc-io / #245 owns it). Make `bounds` optional (default None): a plain write omits the tmin/tmax tags, exactly the pre-existing behavior. The tags are stamped only when the caller supplies bounds — which the blinding path does, because blinding re-derivation needs the pipeline's edge-based integration range and TreeCorr's edges are not reconstructible from meanr. A file written without bounds simply cannot be blinded: blinding.rederive_eb_from_fine_xi fails loud on the missing tags rather than reconstructing a pseudo-edge that would not match the pipeline (the finding-4/7 contract). No pseudo-edge default is reintroduced. PR-2 surface note: this adds an optional `bounds` kwarg to add_pure_eb; #245's owner should be aware the writer now carries the pure-E/B integration-bound contract for blinding, opt-in and backward-compatible. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KpaRHk3QwN13myduQ3hJyf
cosebis_from_xi and pure_eb_from_xi expose the cosmo_numba kernels (COSEBIS.cosebis_from_xipm, get_pure_EB_modes) to callers holding xi+/- arrays rather than TreeCorr GGCorrelations — the seam the pipeline uses to derive born-blinded COSEBIs / pure-E/B downstream from a blinded fine-xi SACC part. Bit-matches calculate_cosebis' theta_cut path (add_cosebis retained-centre scale-cut contract) and calculate_pure_eb_correlation's edge-based tmin/tmax convention. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KpaRHk3QwN13myduQ3hJyf
Replace the terminal master-blind (master in -> extract/conceal/merge ->
re-derive -> delete master) with the three-verb per-part surface ruled in
the PRD:
- blind_init (once per catalogue version): OS-entropy seed, repo-committable
commitment.json (sha256(seed) + canonical config digest), encrypted seed
bundle (smokescreen.encryption, Fernet); plaintext seed never written.
- blind_part (per intermediate, at birth): conceal one part SACC (coarse
xi, fine xi, or pseudo-Cl) through its own theory_fn backend under the
shared seed, write the blinded part with a self-contained per-part escrow
bundle beside it, delete the plaintext part, stamp concealed /
blind_commitment / blind_config_digest, strip seed_smokescreen.
- unblind_part (per part, or the assembled file via grid-tag selection):
verify both sha256(seed) and the config digest against commitment.json
BEFORE subtracting; restore bit-for-bit from the escrow when present.
- assert_consistent_blind: assembly-time custody assertion (identical
blind_commitment across all blindable parts, fails closed on mismatch or
mixed blinded/plaintext), exported for the sacc_io gather.
sacc_io gains the terminal gather that assembles standalone part SACCs into
the one-file {version}.sacc (tracer merge, ordered points, block-diagonal
covariance) — its single blind-aware touch is the assert_consistent_blind
call + stamp. Derived statistics are no longer re-derived inside blinding
code: COSEBIs and pure-E/B are computed downstream from the already-blinded
fine xi part by the pipeline's own estimators (b_modes seams), so they are
born blinded. The three theory backends, envelope calibration, custody
primitives, and metadata keys are unchanged.
Tests reworked to the per-part AC1-9 (AC2 = draw-recovery + placement +
one hidden cosmology across parts; AC9 = NaN-parity); CAMB<->CCL
cross-check tests untouched. CLI reworked to blind-init / blind-part /
unblind (+ seedless verify).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KpaRHk3QwN13myduQ3hJyf
cailmdaley
added a commit
that referenced
this pull request
Jul 11, 2026
…ll image from lock (#266) * felt: fork-implementation — #243 green, #253 rework under fix; fork PRs reviewed Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KpaRHk3QwN13myduQ3hJyf * felt: fork-implementation — constitution absorbs #241 re-rulings (one-file layout, per-part blinding at birth) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KpaRHk3QwN13myduQ3hJyf * deps: declare cosmo_numba + numba, adopt committed uv.lock, install image from lock Make sp_validation's environment reproducible so an image build can never re-resolve numpy past numba's ceiling — the drift that silently upgraded numpy to 2.5 and broke numba/ngmix. - Declare cosmo-numba (aguinot/cosmo-numba@main; not on PyPI) — the numba B-mode kernels b_modes.py imports. main carries numpy-2 FFT support via its rocket-fft dep and declares its own deps, so numba's numpy window reaches the resolver. Also pin numba directly: the one load-bearing constraint, made visible and resilient to cosmo-numba's dep metadata (which has emptied out between refs). - Declare the other imported-but-undeclared deps the audit found: matplotlib, pandas, pyyaml (core, src/); fitsio (glass extra); a new `workflow` extra for snakemake + mpi4py. cv_runner and unions_wl left undeclared (no resolvable source) with a NOTE. - requires-python and ruff target -> 3.12 (the container's Python; cosmo-numba's floor). - Commit uv.lock (un-ignored) as the SSOT; scope it to Linux via [tool.uv]. numpy resolves to 2.4.6, inside numba 0.66's <2.5 window. - Dockerfile installs from the lock: `uv sync --frozen --inexact` into the base image's /app/.venv (--inexact keeps the ShapePipe stack). Drops the ad-hoc snakemake layer and the cs_util `--upgrade` workaround — the lock pins cs_util 0.2.2 (with cs_util.size), decoupling us from the base image's cs_util. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vtw1qcgTrQ6YuMvxwYzNup * felt: uv-lock-cosmo-numba — @main resolution, install model, cosmo-numba gotcha finding Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vtw1qcgTrQ6YuMvxwYzNup * test: don't load base image's stale pytest-pydocstyle/pycodestyle plugins uv sync installs a newer pytest than the base ShapePipe image's pytest-pydocstyle/pytest-pycodestyle (>=2.4) expect; their pytest_collect_file hooks use the removed `path` arg and crash collection. sp_validation lints with ruff, so disable both via addopts (`-p no:`). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vtw1qcgTrQ6YuMvxwYzNup --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
smokescreen's encrypt_file names its save-file outputs from
basename.split('.')[0] — truncating at the first dot. For the canonical
versioned part name (v1.4.6.3_xi_fine_escrow) that wrote v1.encrpt/v1.key
instead of the *_escrow.encrpt/.key that part_paths() declares, so:
- unblind_part's escrow branch never fired (declared path absent),
silently falling back to subtraction-only — not bit-for-bit (AC #8);
- two parts sharing a first-dot prefix collided onto one bundle, the
second overwriting the first's escrowed truth — and since blind_part
deletes the plaintext, that truth became unrecoverable, breaking the
per-part custody guarantee (AC #6).
_write_encrypted_json now drives encrypt_file with save_file=False, takes
its returned (ciphertext, key), and writes them at the exact encrpt_path /
.key names we control. Robust for any stem; hardens the seed bundle too.
Plaintext is still deleted (keep_original=False runs unconditionally).
New test_ac8_dotted_versioned_part_names_escrow_and_restore blinds two
dot-prefix-sharing production names (v1.4.6.3_xi_coarse/xi_fine) into one
dir and asserts distinct escrow bundles at the declared names, no v1.encrpt
collision target, and bit-for-bit restore. It fails against the old code.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KpaRHk3QwN13myduQ3hJyf
Config values arrive from YAML/CLI/humans, so a declared-float field can be written -1 (int) or -1.0 (float) for one physical cosmology. The digest was json.dumps of the raw field values, so those two literals produced different sha256 digests (e0b60d7f… vs df6be45e…). An int-vs-float mismatch between blind_init and a later blind_part/unblind then made _read_seed and unblind_sacc both raise "config digest mismatch" and refuse a correctly blinded file — a denial-of-unblind that falsified the PRD's byte-identical / round-trip-exact digest guarantee. Normalize at the digest boundary: every float-declared TheoryConfig field (and BlindingConfig's two half-widths) is coerced through float() before serialization, so the digest depends on the numeric value, not the literal's Python type. Coerce in TheoryConfig/BlindingConfig.from_overrides too, per the field's declared type (strings pass through untouched), so a value is canonical the moment it enters a config. Regression test asserts digest(w0=-1) == digest(w0=-1.0), same for Omega_m/m_nu/wa/S8 and the half-widths, plus an all-int round-trip against the float default. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KpaRHk3QwN13myduQ3hJyf
… state
Three custody-hardening fixes to unblind_part and the assembly assertion,
keeping the seed+commitment as the single root of trust:
- unblind_part made the escrow authoritative on the success branch: on
residual<=1e-6 it overwrote the seed-subtracted vector with the escrow's
true_mean, so the escrow (not the seed math) decided the output whenever
it happened to agree. Now the seed-subtracted vector is authoritative; the
escrow is used only as a tighter equality CHECK, and its stored seed_sha256
must match the commitment before it is trusted at all (an escrow from a
different blind can no longer determine the output). The ulp-residue
overwrite that AC8's bit-for-bit restore relies on is retained, but only
once the escrow is cryptographically bound to this commitment.
- The escrow path was derived by blinded_path.replace("_blinded", ""), a
global substring replace: a directory containing "_blinded" (e.g.
/runs/2024_blinded/...) corrupted the derived escrow path and silently
dropped the bit-for-bit cross-check. Anchor the replace to the basename.
- assert_consistent_blind keyed its consistency set on (label, commitment,
digest), so two parts with the same seed+config but different --label
falsely raised "different blind commitments" and blocked a legitimate
assembly. Key on (commitment, digest) only; the label is informational
provenance — differing labels now emit a distinct warning and the assembly
proceeds, stamping the sorted-first label. Still fails closed on differing
commitment or digest.
Regression test: same seed+config, different labels assemble with a
UserWarning rather than a failure. AC8 bit-for-bit restore (including the
dotted-versioned-part escrow test) still passes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KpaRHk3QwN13myduQ3hJyf
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #252.
Blinds the tomographic data vector per part, at birth, through the UNIONS-WL/Smokescreen fork: each blindable intermediate SACC product — coarse ξ±, fine ξ±, pseudo-Cℓ — is shifted by
t(hidden) − t(fiducial)(Muir et al. 2019) the moment the pipeline computes it, with the hidden cosmology drawn inside a fixed amplitude envelope by the fork's own CCL-native, per-key-independent draw. One seed per catalogue version drives every part's draw, so all parts share one hidden cosmology by construction. Only blinded parts persist on disk; derived statistics (COSEBIs, pure-E/B) are computed downstream from the already-blinded fine ξ± by the pipeline's own estimators, so they are born blinded — there is no re-derivation step inside the blinding code. No one can read S8 off the data until the collaboration unblinds; both the blind and the unblind are verifiable without a trusted keyholder.Stacks on #245 (
sacc_io); depends on the fork protocol (ConcealDataVector(fiducial_params, shifts_dict, sacc_data, *, seed, theory_fn), pinned fork SHA588a6b9). No firecrown anywhere in this PR's diff — the fork is the concealment engine, and every theory vector is a direct CCL computation.The three-verb surface
blind-init(once per catalogue version) — draws an OS-entropy seed, writes a repo-committablecommitment.json(sha256(seed)+ a canonical config digest binding the envelope and fiducial), and encrypts the seed into a Fernet bundle (smokescreen.encryption). The plaintext seed is never written. These outputs are the blind's fixed state; every other call reads them.blind-part(per intermediate, at birth) — reads the encrypted seed (verifying both hashes against the commitment first), conceals one part SACC through its matchingtheory_fnbackend under the shared seed, writes the blinded part with the part's true vector escrowed into a per-part encrypted bundle beside it, and deletes the plaintext part. Each escrow is self-contained: restoring a part needs only that part's bundle plus the seed bundle — corruption of one bundle loses one part, not all. Blinded metadata:concealed=True,blindlabel,blind_commitment,blind_config_digest;seed_smokescreenstripped.unblind(per part, or the assembled file viagrid-tag selection) — verifies bothsha256(seed)and the config digest againstcommitment.jsonbefore subtracting anything, recomputes the part's shift from the seed through the same backend, and subtracts — the seed-subtracted vector is the authority. When the part's escrow bundle is beside it and its storedseed_sha256matches the commitment, the escrow serves two subordinate roles: a tighter equality check (fail closed if it disagrees) and clearing the add-then-subtract ulp residue so the restore is bit-for-bit. An escrow bound to a different seed, or one that disagrees materially, never determines the output.Terminal assembly —
sacc_io.gatherassembles the standalone part SACCs into the one-file{version}.sacc(tracer merge, ordered points with all tags including bandpower windows, block-diagonal covariance). Its single blind-aware touch is one call toblinding.assert_consistent_blind, which fails the gather closed unless every blindable part carries the identicalblind_commitment(mixed blinded/plaintext assemblies also refuse), and stamps the shared commitment on the assembled file. ρ/τ diagnostic parts are exempt and pass through untouched.What's in the box
sp_validation/blinding_theory.py— the blinding backend's theory surface.TheoryConfig(frozen dataclass; fail-fast overrides), the home of the blinding/inference fiducial (CosmoSIS v1.4.6.3 IA-fiducial values: S8=0.80, Ωm=0.30, h=0.70, n_s=0.96, Σm_ν=0.06 eV, HMCode2020+feedback with logT_AGN=7.5). Carries two halofit tokens (ccl_halofit_version,camb_halofit_version) naming the one recipe per stack;Neff=3.046/T_CMB=2.7255are emitted explicitly to both stacks. Two independent ξ± paths live here: the CCL-native route (Boltzmann-CAMB P(k) → CCL Limber + FFTLog — the blinding recipe) and an independent pycamb P(k) →Pk2D→ CCL projection with a closed-form σ8-matched A_s rescale. numpy-only at module scope.sp_validation/blinding.py—{sigma8, Omega_c}half-widths at the fiducial (σ8 = S8/√(Ωm/0.3); Ω_c one-to-one). Half-widths are config, not code.theory_fnbackends, one per part: coarse ξ±, fine ξ± (gridtags), and pseudo-Cℓ (W @ Cℓ_EEon the storedBandpowerWindows; ΔBB = ΔEB ≡ 0 — the shift is pure E-mode). Two-tracer cross-pairs; rows aligned to each part's own stored order, never an assumed pairing.theory_fnspans — the fork's length guard enforces the agreement), driveConcealDataVector, write the shifted values back at their recorded indices (row order preserved). Covariance (including the fine part's dense per-pair block) and BB/EB rows are byte-identical to the input.blind_init/blind_part/unblind_partas above, plusassert_consistent_blindfor the gather. A part with no blindable block (ρ/τ diagnostics) refuses a blind call loudly.sp_validation/sacc_io.py— gainsgather(terminal part assembly, one blind-aware call site as above).sp_validation/b_modes.py— gains the values-only estimator seamscosebis_from_xiandpure_eb_from_xi, exposing the samecosmo_numbakernelscalculate_cosebis/calculate_pure_eb_correlationdrive to callers holding ξ± arrays (e.g. a blinded fine-ξ± SACC part) rather than TreeCorr correlations. This is where born-blinded derived statistics come from — the estimators stay in the pipeline's namespace, not the blinding module's.scripts/blind_data_vector.py—blind-init/blind-part/unblind/verifyCLI;blind-initrefuses to overwrite an existing blind's state before drawing anything.Tests (all observed in the shared-venv run on candide)
test_blinding.pycovers the per-part acceptance criteria AC1–AC9 (33 tests: 25 fast + 8 slow);test_camb_ccl_crosscheck.pycovers AC10–14 unchanged. Heavy precision tests are@pytest.mark.slow; the derived-statistics testsimportorskip("cosmo_numba"). Full suite on candide: 183 passed with only the four known environmental failures (config/catalog paths, workflow dry-run, pure-EB pinned reference — all outside blinding scope). CI runs the full suite inside the freshly built image, which carriescosmo_numba(proven by the blinding-stack import smoke test), so these ACs gate publication.Observed numbers from the runs:
The AC4/AC9 comparands run the pipeline's own downstream convention — measured coarse reporting ξ± + fine integration grid + edge-based bounds, through the
b_modesseams — on blinded vs true parts, so they check the born-blinded derivation path against the pipeline convention rather than any blinding-internal seam. AC2 is fork-draw recovery + placement (both sides share the backend; backend correctness is AC3's independently written reference). AC1 (zero-shift identity, per part + downstream derivation), AC5 (covariance/row-order/BB-EB/ρτ untouched), AC6 (custody: init writes only commitment + encrypted bundle; plaintext parts deleted; gather fails closed on mismatched or mixed blinds; unblind fails closed on either tampered hash), AC7 (same seed ⇒ identical shifts), and AC8 (end-to-end init → three blind-parts → gather → per-part bit-for-bit unblind → true derived statistics reproduced) are asserted exactly.Not in this PR
Snakemake wiring — which pipeline rule invokes
blind-part, whereblind-initfires per catalogue version — belongs to the Snakemake-parts row; the per-part surface here is designed for that wiring, but the DAG edges are authored there. Covariance blinding, unblinding-criteria policy, and the fiducial-cosmology group decision are likewise out of scope (config values, not code).CI note
The
blindingextra here pins the theory stack by git ref — the UNIONS-WL Smokescreen fork by commit (never==1.5.6, which resolves to the incompatible upstream PyPI package that self-reports the same version),cosmo_numbaby its numpy-2.x fork commit, andNumbaQuadpackat top level (it is a direct-URL transitive dep ofcosmo_numbathat pip/uv will not auto-install, so without itimport cosmo_numbasucceeds while the COSEBIs/pure-E/B kernels die atimport NumbaQuadpack). The CI blinding-stack smoke test imports the fork-onlydraw_param_shiftsand the actual kernel modules (cosmo_numba.B_modes.cosebis.COSEBIS,schneider2022.get_pure_EB_modes), so neither an upstream Smokescreen package nor a missing NumbaQuadpack can ship green. The numbers above were observed on candide (shared fork venv +cosmo_numbainstalled from Cail's fork), running the same suite CI runs in the image.Fork-pin duplication is transitional. This branch's
blindingextra pins the Smokescreen fork so the image is CI-self-sufficient before the deps PR (#243) lands. #243 carries the same fork as the core dependency (no[blinding]extra). The duplication resolves at whichever of the two merges second — the second merge's rebase ontodevelopdrops the now-redundant extra pin. The PR base staysfeat/sacc-2-sacc-io(a formal stack onto #243 was considered and declined: the branches have diverged architecturally — #243's base already moved cosmology tocs_util.cosmo, which this branch'sblinding_theory.pylayering now matches — so the rebase is a deliberate reconciliation for whichever merges second, not a mechanical restack now).🤖 Generated with Claude Code
https://claude.ai/code/session_01KpaRHk3QwN13myduQ3hJyf