Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/source/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@ array instead of a file path, and nothing is written to disk unless asked.
and with no error. Pointing vectors are now built per field.
* A transposed ``(ncomponents, npix)`` mask is refused instead of silently
reducing every array to the wrong pixels.
* On a harmonic basis, the consumers that need the pixel projector
(``get_covariance``, ``get_inverse``, ``to_basis``, ``projector``, m-block
compression and PICSLike ``do_cross``) now raise a ``RuntimeError`` naming
the contract when called after ``release_pixel_projector()``, instead of an
opaque ``ValueError`` from numpy.

Version 1.0.1 (2026-05-21)
--------------------------
Expand Down
20 changes: 19 additions & 1 deletion src/cosmoforge.cosmocore/cosmocore/basis/harmonic.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ def __init__(
self.dim = self.n_modes_total
self._compress = compress
self._delta_m = delta_m
self._V_released = False

if self._compress:
# Multi-field + compress not yet supported
Expand All @@ -135,6 +136,18 @@ def method(self) -> str:
"""Computation basis name."""
return "harmonic"

@property
def _V(self) -> np.ndarray:
if self._V_released:
raise RuntimeError(
"The pixel projector V was dropped by release_pixel_projector(); "
"get_covariance, get_inverse, to_basis, the projector property, "
"m-block compression and PICSLike do_cross all read V and are "
"unsupported afterwards. Skip release_pixel_projector() if any "
"of them are needed downstream."
)
return self._harmonic_basis._V

@property
def projector(self) -> np.ndarray:
"""Projection matrix V (n_modes × n_pix)."""
Expand All @@ -147,14 +160,19 @@ def release_pixel_projector(self) -> None:
``_V_N_inv`` and ``_V_Ninv_VT``. The remaining V consumers —
``get_covariance``/``get_inverse``,
``to_basis``, m-block compression, and PICSLike ``do_cross``
with the harmonic basis — must not be invoked after this call.
with the harmonic basis — raise :exc:`RuntimeError` after this
call. Release is one-way.
Raises if m-block compression was requested at construction.
"""
if self._compress:
raise RuntimeError(
"release_pixel_projector incompatible with m-block compression"
)
self._harmonic_basis._V = None
# V N V^T is lazy; drop any materialised copy so get_covariance fails
# the contract check rather than silently serving a primed cache.
self.__dict__.pop("_V_N_VT", None)
self._V_released = True

def setup(self) -> None:
"""
Expand Down
47 changes: 47 additions & 0 deletions src/cosmoforge.cosmocore/tests/test_harmonic_basis.py
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,53 @@ def test_unit_beam_no_effect(self, simple_compression_setup):
assert_allclose(C_bar_no_beam, C_bar_unit_beam, rtol=1e-10)


class TestReleasePixelProjector:
"""The documented post-release contract must fail loudly, not opaquely."""

@staticmethod
def _released_basis(setup):
from cosmocore.basis import HarmonicBasis

hc = HarmonicBasis(
N=setup["N"],
theta=setup["theta"],
phi=setup["phi"],
lmax_signal=setup["lmax"],
)
hc.setup()
hc.release_pixel_projector()
return hc

@pytest.mark.parametrize(
"call",
[
pytest.param(lambda hc, C_ell: hc.get_covariance(C_ell), id="get_covariance"),
pytest.param(lambda hc, C_ell: hc.get_inverse(C_ell), id="get_inverse"),
pytest.param(lambda hc, C_ell: hc.to_basis(np.ones(hc.n_pix)), id="to_basis"),
pytest.param(lambda hc, C_ell: hc.projector, id="projector"),
],
)
def test_v_consumers_raise_contract_error(self, uniform_sky_setup, call):
"""Every V consumer names the contract instead of failing in numpy."""
setup = uniform_sky_setup
hc = self._released_basis(setup)
C_ell = np.ones(setup["lmax"] + 1) * 1e-6

with pytest.raises(RuntimeError, match="release_pixel_projector"):
call(hc, C_ell)

def test_v_free_consumers_survive_release(self, uniform_sky_setup):
"""The QML hot path reads only the SMW intermediates, so it still works."""
setup = uniform_sky_setup
hc = self._released_basis(setup)
C_ell = np.ones(setup["lmax"] + 1) * 1e-6

assert hc.get_projected_inverse(C_ell).shape == (hc.n_modes, hc.n_modes)
assert hc.get_weighted_data(np.ones(hc.n_pix), C_ell).shape == (hc.n_modes,)
assert hc.get_full_inverse(C_ell).shape == (hc.n_pix, hc.n_pix)
assert np.isfinite(hc.get_full_logdet(C_ell))


# =============================================================================
# Coverage-focused tests for untested HarmonicBasis operations
# =============================================================================
Expand Down
Loading