Skip to content

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
feat/sacc-2-sacc-iofrom
feat/sacc-6-blinding
Draft

Smokescreen-fork blinding: per-part-at-birth blind, hash-commitment custody, CAMB↔CCL cross-check (PR 6)#253
cailmdaley wants to merge 17 commits into
feat/sacc-2-sacc-iofrom
feat/sacc-6-blinding

Conversation

@cailmdaley

@cailmdaley cailmdaley commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

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 SHA 588a6b9). 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-committable commitment.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 matching theory_fn backend 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, blind label, blind_commitment, blind_config_digest; seed_smokescreen stripped.

unblind (per part, or the assembled file via grid-tag selection) — verifies both sha256(seed) and the config digest against commitment.json before 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 stored seed_sha256 matches 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 assemblysacc_io.gather assembles 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 to blinding.assert_consistent_blind, which fails the gather closed unless every blindable part carries the identical blind_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.7255 are 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.

No sp_validation/cosmology.py. develop deleted the local cosmology module (#223), moving cosmology to cs_util.cosmo, and this PR does not resurrect it. Per the UNIONS layering — cs_util = the cosmology library (generic machinery), sp_validation = configuration + survey-specific implementationsTheoryConfig (config) and the three part backends in blinding.py (coarse/fine ξ±, pseudo-Cℓ; survey-specific) are correctly homed in the blinding namespace. The generic pieces here (CCL ξ± path, CAMB P(k)→Pk2D path, σ8/A_s rescale) live in blinding_theory.py for now but carry an explicit module-docstring note that they are cosmology-library code destined for cs_util.cosmo (tracked in cs_util#80).

sp_validation/blinding.py

  • Envelope calibration: the intended (|ΔS8| ≤ 0.075, |ΔΩm| ≤ 0.1) box maps to {sigma8, Omega_c} half-widths at the fiducial (σ8 = S8/√(Ωm/0.3); Ω_c one-to-one). Half-widths are config, not code.
  • Three theory_fn backends, one per part: coarse ξ±, fine ξ± (grid tags), and pseudo-Cℓ (W @ Cℓ_EE on the stored BandpowerWindows; Δ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.
  • Per-part conceal: extract the part's blindable block into a sub-SACC (exactly the rows the theory_fn spans — the fork's length guard enforces the agreement), drive ConcealDataVector, 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.
  • Custody: blind_init / blind_part / unblind_part as above, plus assert_consistent_blind for the gather. A part with no blindable block (ρ/τ diagnostics) refuses a blind call loudly.

sp_validation/sacc_io.py — gains gather (terminal part assembly, one blind-aware call site as above).

sp_validation/b_modes.py — gains the values-only estimator seams cosebis_from_xi and pure_eb_from_xi, exposing the same cosmo_numba kernels calculate_cosebis / calculate_pure_eb_correlation drive 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.pyblind-init / blind-part / unblind / verify CLI; blind-init refuses to overwrite an existing blind's state before drawing anything.

Tests (all observed in the shared-venv run on candide)

test_blinding.py covers the per-part acceptance criteria AC1–AC9 (33 tests: 25 fast + 8 slow); test_camb_ccl_crosscheck.py covers AC10–14 unchanged. Heavy precision tests are @pytest.mark.slow; the derived-statistics tests importorskip("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 carries cosmo_numba (proven by the blinding-stack import smoke test), so these ACs gate publication.

Observed numbers from the runs:

Quantity Observed Bound
AC2 on-file shift vs theory difference (all 3 parts, 2-bin; one hidden cosmology across parts asserted) 6.4e-14 rel ≤ 1e-10
AC3 realized shift vs independently written CCL reference 1.0e-20 abs (coarse ξ±) < 1e-8
AC4 ΔBₙ (born-blinded COSEBIs) amplitude-independence 1.5e-10 of |ΔBₙ| ≤ 1e-9
AC4 leakage magnitude ΔBₙ/Bₙ = 4.9e-6 (B=2e-5), 1.4e-5 (B=2e-6); Δξ⁺_B = 1.5e-9 = 0.08% of a 2e-6 B-mode reported, not asserted
AC4 Δξ⁺_B amplitude-independence 8.7e-14 (5.8e-05 of the leakage) ≤ 5% of leakage
AC9 pure-E/B NaN pattern (born from blinded vs true parts) identical (finite values shifted) exact parity
AC10 nominal-A_s σ8 offset 3.4% > 2% (the offset is real)
AC10 σ8-matched residual 1.1e-16 < 1e-4
AC11 ξ± floor at fiducial ξ+ 0.24%, ξ− 0.10% < 0.5% / 1.0%
AC12 ξ± floor off-fiducial (S8+0.075, Ωm−0.05) ξ+ 0.28%, ξ− 0.12% < 0.5% / 1.0%

The AC4/AC9 comparands run the pipeline's own downstream convention — measured coarse reporting ξ± + fine integration grid + edge-based bounds, through the b_modes seams — 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, where blind-init fires 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 blinding extra 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_numba by its numpy-2.x fork commit, and NumbaQuadpack at top level (it is a direct-URL transitive dep of cosmo_numba that pip/uv will not auto-install, so without it import cosmo_numba succeeds while the COSEBIs/pure-E/B kernels die at import NumbaQuadpack). The CI blinding-stack smoke test imports the fork-only draw_param_shifts and 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_numba installed from Cail's fork), running the same suite CI runs in the image.

Fork-pin duplication is transitional. This branch's blinding extra 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 onto develop drops the now-redundant extra pin. The PR base stays feat/sacc-2-sacc-io (a formal stack onto #243 was considered and declined: the branches have diverged architecturally — #243's base already moved cosmology to cs_util.cosmo, which this branch's blinding_theory.py layering 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

cailmdaley and others added 4 commits July 11, 2026 00:41
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
@cailmdaley cailmdaley changed the title Smokescreen blinding: seeded data-vector blind, hash-commitment custody, blinded-COSEBIs derivation (PR 6) Smokescreen-fork blinding: seeded master-SACC blind, hash-commitment custody, CAMB↔CCL cross-check (PR 6) Jul 10, 2026
@cailmdaley cailmdaley force-pushed the feat/sacc-6-blinding branch from 971767b to 11b8895 Compare July 10, 2026 22:52
@cailmdaley cailmdaley changed the base branch from feat/sacc-5-firecrown-likelihood to feat/sacc-2-sacc-io July 10, 2026 22:52
cailmdaley and others added 10 commits July 11, 2026 12:27
…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 cailmdaley changed the title Smokescreen-fork blinding: seeded master-SACC blind, hash-commitment custody, CAMB↔CCL cross-check (PR 6) Smokescreen-fork blinding: per-part-at-birth blind, hash-commitment custody, CAMB↔CCL cross-check (PR 6) Jul 11, 2026
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>
cailmdaley and others added 3 commits July 11, 2026 14:34
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant