Skip to content

sacc_io: SACC read/write layer for the standard data-product layout#245

Draft
cailmdaley wants to merge 6 commits into
feat/sacc-1-depsfrom
feat/sacc-2-sacc-io
Draft

sacc_io: SACC read/write layer for the standard data-product layout#245
cailmdaley wants to merge 6 commits into
feat/sacc-1-depsfrom
feat/sacc-2-sacc-io

Conversation

@cailmdaley

@cailmdaley cailmdaley commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

Closes #244. Row 2 of the PRD (#241 §7): the sacc_io module — every data product written to and read from SACC, with round-trip tests. This is the layout contract PRs 3 (converter), 4 (migration), and 6 (blinding) build on. Draft, for review against the PRD.

What's in the file

Product SACC data type Tracers Point tags
ξ± (coarse + fine grids) galaxy_shear_xi_plus/minus (standard) (source_i, source_j) theta (arcmin, TreeCorr meanr), theta_nom, npairs, weight, grid
pseudo-Cℓ EE/BB/EB galaxy_shear_cl_ee/bb/eb (standard) (source_i, source_j) ell + shared BandpowerWindow
COSEBIs galaxy_shear_cosebi_ee/bb (standard) (source_i, source_j) n, theta_min, theta_max
pure E/B galaxy_shear_xiPure{E,B,Amb}_{plus,minus} (custom) (source_i, source_j) theta
ρ₀…ρ₅ psf_rho{k}_xi_{plus,minus} (custom) (psf_stars, psf_stars) theta
τ₀,τ₂,τ₅ galaxyPsf_tau{k}_xi_{plus,minus} (custom) (source_i, psf_stars) theta
n(z) NZ tracers source_0, source_1, …

Tomography-native throughout: every writer/reader takes a bin pair (i, j); the current single-bin analysis is the (0, 0) case.

One file per catalogue version — the PRD §1 letter

{version}.sacc carries everything: the analysis vector (coarse ξ±, pseudo-Cℓ, COSEBIs, pure-E/B, ρ/τ, n(z)) and the fine-grid ξ± integration input for COSEBIs / pure-E/B, tagged grid='fine'. The covariance is assembled block-diagonally (zero cross-blocks — the same independence assumption today's cosmosis_fitting.py assembly makes): the per-statistic analysis blocks first, then a dense per-pair fine-ξ block.

The design point that makes this work: the production fine grid is 1000 θ bins (0.5–300′, the B-modes paper's integration binning), where a dense fine block is 2000×2000 ≈ 32 MB per pair — and a real covariance exists at exactly that binning: the Gaussian analytic covariance computed on the integration grid (OneCovariance in this pipeline; Paper II used CosmoCov), which is load-bearing downstream (it is the input to the pure-E/B MC error propagation and the COSEBIs covariance transform). When no analytic covariance exists, the fine block degrades to the TreeCorr varxip/varxim diagonal. Extreme convergence-check grids (a one-off 10 000-bin run) likewise take the diagonal fallback — they degrade the covariance, not the file layout. (This supersedes an earlier two-file amendment on this PR, which was premised on 10k bins being an operating point; it isn't.)

Beyond the PRD's row-2 list, pure-E/B writers are included: §3 derives pure-E/B from blinded ξ± and PR 6's acceptance test measures them, so they need a home in the layout.

Design notes

  • Insertion order is the covariance order (sacc preserves it bitwise through FITS save/load — verified), and alignment holds by construction: writers enforce strictly ascending θ/ℓ grids (loud ValueError otherwise) and readers return insertion order, so reader output, covariance sub-blocks, and bandpower-window columns can never silently diverge. Bin pairs normalize to i≤j. Insertion is pair-major ([pair₀ ξ+; pair₀ ξ−; pair₁ ξ+; …]); type-major consumers (DES 2pt-FITS) permute explicitly via Sacc.indices. Analysis points insert before fine points so covariance blocks tile the mean contiguously.
  • An independent adversarial review ran against the layout contract before this PR opened; its findings (reader-side sorting vs insertion-order divergence, unnormalized bin pairs) are fixed with regression tests for each.
  • assemble_covariance(s, blocks) places per-statistic covariance blocks with zero cross-blocks and fails loud on any gap, overlap, or size mismatch.
  • extract() (copy + keep_selection) pulls a subset with its correctly-aligned covariance sub-block — the building block for PR 3's converter and PR 6's blinding passes (blinding the fine block is its own extract→blind→merge pass inside the same file).
  • The pseudo-Cℓ writer takes the bandpower window matrix as explicit input: NaMaster computes it today but nothing saves it; PR 4 wires get_bandpower_windows() through.

Testing

27 tests, all synthetic and fast: per-writer round-trips (values, tags, windows, n(z) bitwise), covariance block alignment incl. zero cross-blocks, assemble_covariance failure modes, DiagonalCovariance round-trip, extract sub-covariance alignment, a 2-bin/3-pair tomographic case, reader/writer mirroring on a mixed file, the end-to-end one-file layout (analysis + dense fine block, extract of the fine selection, zero analysis↔fine cross-blocks), and the varxi-diagonal fine fallback. Fast suite: no regressions.

Not in scope here: cosmo_val mixin migration (PR 4), SACC→2pt-FITS converter and OneCovariance glue (PR 3).

— Claude (Fable) on behalf of Cail.

cailmdaley and others added 4 commits July 10, 2026 01:49
PRD #241 row 1. sacc>=2.4 joins core dependencies (sp_validation.sacc_io
will be core library code). firecrown v1.15.1 + smokescreen 1.5.6 + an
exact pyccl pin form the new [blinding] extra: the blind must be exactly
recomputable from the seed at unblinding time, so the theory stack is
pinned as a set.

firecrown is not on PyPI and hard-depends on numcosmo-py (conda-forge
only) plus the cosmosis/cobaya sampler connectors we never import — the
new uv-overrides.txt drops those three from resolution; the Dockerfile
and README carry the --overrides invocation. Plain installs without the
blinding extra are unaffected.

requires-python moves 3.11 → 3.12: Smokescreen and firecrown both set a
3.12 floor, and the container base (shapepipe:develop) already runs
python:3.12-slim-bookworm — this aligns pyproject with the actual
runtime. ruff target-version follows.

Validated: uv resolution of '.[test,glass,blinding]' with overrides
(235 packages, py3.12) and the full firecrown-core + smokescreen import
chain in a fresh venv on candide. The CI image build on this branch is
the container-side proof.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019bGhVMAuhTy1gF6DdUc1dQ
Follow-up hardening after empirical validation of the blinding stack on
candide. Two facts surfaced that the resolve-only check couldn't see:

- firecrown 1.15.1 subclasses npt.NDArray (DataVector); numpy 2.5 made
  that a non-subclassable typing alias, so firecrown breaks at import on
  the numpy>=2.0 resolution (2.5.1). The [blinding] extra now carries
  numpy>=2.2,<2.5 (2.4.3 verified against the full compiled stack + the
  fast suite), and the Dockerfile requests the bound explicitly to dodge
  uv #8410 non-movement of an already-installed numpy.

- pip-installed firecrown hits NumCosmo (conda-forge-only) at import
  time through two paths unrelated to cosmic shear: eager re-export of
  LSST predefined n(z) bins in generators/__init__ (defeating upstream's
  own lazy __getattr__), and the cluster likelihoods -> lsstdesc-crow ->
  Ncm.IntegralND C-subclass at module load. scripts/patch_firecrown.py
  makes the bin re-export lazy, the cluster imports optional, and ships
  a loud numcosmo_py shim (raises on any real use). Exact-string surgery
  against pinned v1.15.1, idempotent, fails loudly on a version bump,
  ends with an import check. Verified: fresh application on a pristine
  install, idempotent re-run, and the full toy Smokescreen blind
  end-to-end after patching.

CI now also smoke-tests the blinding imports in the built image - the
fast suite never imports firecrown, so a broken blinding stack would
otherwise ship green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019bGhVMAuhTy1gF6DdUc1dQ
Add sp_validation.sacc_io: the writer/reader layer for the two-file SACC
layout that becomes the package's standard data-product format.

  {version}.sacc       analysis vector — NZ tracers, coarse xi+/-, pseudo-Cl
                       (EE/BB/EB) with a shared BandpowerWindow, COSEBIs,
                       pure E/B, rho/tau PSF diagnostics; one FullCovariance
                       assembled block-diagonally (zero cross-blocks).
  {version}_xi_fine    COSEBIs/pure-EB integration input — same NZ tracers,
                       fine-grid xi+/-, DiagonalCovariance from TreeCorr
                       varxip/varxim.

Covariance order is point-insertion order (SACC preserves it bitwise
through FITS). Writers insert in the canonical order — xi+ then xi-, Cl
(ee, bb, eb), COSEBIs (all En then all Bn), pure E/B in _EB_KEYS order
(xip_E, xim_E, xip_B, xim_B, xip_amb, xim_amb, matching
b_modes.calculate_eb_statistics), rho, then tau — but readers never assume
global order: every getter resolves indices through s.indices(dtype,
tracers, **tags). assemble_covariance validates that blocks are contiguous,
ascending and tile the data vector exactly, failing loud otherwise.

Custom data types (pure E/B, rho, tau) all parse under
sacc.parse_data_type_name. Tag filters are plain kwargs; the tags={...}
form silently selects nothing and is never used.

Test suite (test_sacc_io.py, all synthetic and fast): per-writer
round-trips (arrays/tags/windows/NZ bitwise), covariance block alignment
and zero cross-blocks, assemble_covariance failure modes, DiagonalCovariance
round-trip, extract() sub-covariance alignment, a tomographic multi-pair
case, reader/writer mirroring on a mixed file, and the end-to-end two-file
layout. 20 passed.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Fresh-eyes review caught a correctness bug: readers re-sorted selections by
theta/ell/n, but covariance blocks and bandpower windows stay in insertion
order. On a non-ascending grid the reader output silently desynchronised
from its covariance, and get_pseudo_cl returned sorted cl arrays against
unsorted window columns — internally inconsistent within one return tuple.

Fix by construction, not by sort:
  - Writers validate their grids. add_xi/add_pure_eb/add_rho/add_tau require
    strictly ascending theta; add_pseudo_cl requires strictly ascending
    ell_eff (add_cosebis is inherently safe — it enumerates the mode index).
    Out-of-order grids raise a loud ValueError naming the argument.
  - Readers drop the sort entirely and return in s.indices (insertion) order,
    so every getter is covariance- and window-aligned for ANY file, and
    ascending for canonical files. The _sorted_* helpers are replaced by
    plain insertion-order accessors (_mean/_tag).

Also:
  - _pair normalises (i, j) -> sorted, so get_xi(s, (1, 0)) addresses the same
    symmetric shear-shear pair as (0, 1) instead of a silent empty read.
  - Module docstring documents the tomographic ξ covariance ordering:
    insertion is pair-major ([pair0 xip; pair0 xim; pair1 xip; …]), supplied
    to assemble_covariance as one contiguous block matching add_xi call order;
    type-major converters (DES 2pt-FITS) permute explicitly via s.indices.
  - extract() docstring states tracers takes SACC names, not integer bins.

New tests (26 total, was 20): writers reject non-ascending theta/ell;
(1, 0) == (0, 1) round-trip; a 3-pair tomographic ξ covariance assembled as
one contiguous pair-major block with per-pair sub-blocks recovered via
extract(); get_pseudo_cl window column j <-> ell_eff[j] via window_ind tags.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
The two-file split (analysis + {version}_xi_fine.sacc) was premised on a
10000-bin fine grid; the production operating point (Paper II B-modes) is
1000 bins, where a dense per-pair fine covariance block is ~32 MB and the
CosmoCov integration-binning covariance — which feeds pure-E/B and COSEBIs
error propagation — has a natural home as a BlockDiagonal block alongside
the analysis blocks. Layout test replaced with the one-file end-to-end
case (dense fine block, extract() sub-covariance alignment, zero
cross-blocks) plus a varxi-diagonal fallback test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019R5eiy11Lihkgn4MKufXSp
cailmdaley added a commit that referenced this pull request Jul 11, 2026
…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
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