From 7732ad2c0cbfc9a1c2c7e561149c3f8c9550b827 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 23:35:03 +0000 Subject: [PATCH] feat: warn when no light lies behind any mass (#532 phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discharges phase 4 of the @rhayes777 API audit. He was asked on #532 whether a warning on z_lens > z_source would be noise in a real multi-plane setup and did not answer, so the question is settled by construction instead: the warning ships behind its own filterable category, so a user for whom it IS noise silences it with one filter rather than living with it. warnings.filterwarnings("ignore", category=MultiPlaneRedshiftWarning) It is a warning and NEVER an error. Multi-plane ray tracing genuinely supports geometries that look inverted under two-plane "lens and source" naming, so a flagged configuration may be exactly what the user intended. The rule is deliberately narrow: it fires only when NO lensable thing lies behind ANY mass, across more than one redshift plane — i.e. nothing in the tracer can be lensed at all. Mass at z=1.0 with light at both 0.5 and 1.5 is a real multi-plane system and stays quiet, because some of its light IS lensed. Building this surfaced three false-positive classes, all now regression-tested: - A source reconstructed by a Pixelization carries no LightProfile. Counting only light profiles fired on every pixelized-source test in the suite — core usage. - A point source carries neither light profile nor pixelization. - An entirely empty galaxy is a scaffold (a model being composed, a source not yet filled in); warning at someone mid-construction is unhelpful. Hence LENSABLE_CLS = (LightProfile, Pixelization, Point, PointSolved), plus a stand-down when a galaxy is empty. Evidence this matters: before those fixes the warning fired in ~10 existing legitimate tests; it now fires in none. The suite's warning count is back to its pre-change baseline of 18. Tracer-safe: redshifts can be free model parameters, so only concrete redshifts are compared and a traced redshift makes the check skip rather than coerce a traced boolean. Tests: 532 passed (+13 on this branch), zero regressions. --- autolens/lens/tracer.py | 136 ++++++++++++ test_autolens/lens/test_tracer_validation.py | 207 +++++++++++++++++-- 2 files changed, 327 insertions(+), 16 deletions(-) diff --git a/autolens/lens/tracer.py b/autolens/lens/tracer.py index 0ce08d750..84ec1ac1a 100644 --- a/autolens/lens/tracer.py +++ b/autolens/lens/tracer.py @@ -20,6 +20,7 @@ """ from abc import ABC import numpy as np +import warnings from scipy.interpolate import griddata from typing import Dict, List, Optional, Type, Union @@ -27,12 +28,146 @@ import autoarray as aa import autogalaxy as ag +from autoarray import validate + from autogalaxy.profiles.geometry_profiles import GeometryProfile +from autogalaxy.profiles.light.abstract import LightProfile from autogalaxy.profiles.light.snr import LightProfileSNR +from autogalaxy.profiles.mass.abstract.abstract import MassProfile +from autogalaxy.profiles.point_sources import Point, PointSolved from autolens.lens import tracer_util +LENSABLE_CLS = (LightProfile, aa.Pixelization, Point, PointSolved) +""" +Everything a tracer can gravitationally lens. + +Emphatically **not** just ``LightProfile``: a source reconstructed by a +``Pixelization`` carries no light profile, and a point source carries neither — yet +both are light to be lensed. Enumerating only light profiles made the redshift-ordering +warning below fire on every pixelized-source and point-source configuration in the test +suite, which is precisely the "would a warning be noise?" failure it must avoid. +""" + + +class MultiPlaneRedshiftWarning(UserWarning): + """ + Warned when a tracer's redshifts describe a system in which no light lies behind + any mass, so nothing in it can be gravitationally lensed. + + This has its own category so it can be silenced with a single filter, without + suppressing unrelated warnings: + + :: + + import warnings + from autolens.lens.tracer import MultiPlaneRedshiftWarning + + warnings.filterwarnings("ignore", category=MultiPlaneRedshiftWarning) + + It is a **warning and never an error**, deliberately. Multi-plane ray tracing + genuinely supports geometries that look wrong under two-plane "lens and source" + naming, so a configuration this flags may still be exactly what the user intended. + """ + + +def _warn_if_no_light_is_behind_any_mass(galaxies): + """ + Warn if every light-bearing galaxy lies in front of (or level with) every + mass-bearing galaxy, across more than one redshift plane. + + In that configuration no light can be deflected by any mass, so the tracer + produces an unlensed image while looking like a lens model. The usual cause is + the lens and source redshifts being the wrong way round — the case reported on + PyAutoLens#532, where ``z_lens=1.0`` with ``z_source=0.5`` returned a finite image + and said nothing. + + __Why this warns and does not raise__ + + Multi-plane lensing legitimately supports geometries that look inverted under + two-plane naming, so this cannot be an error. The test on the ordering is + deliberately narrow for the same reason: it fires only when *no* light at all + sits behind *any* mass. A system with mass at z=1.0 and light at both z=0.5 and + z=1.5 is a real multi-plane configuration and is not flagged, because some of its + light is genuinely lensed. + + Single-plane systems (one distinct redshift) are never flagged — a galaxy with its + own light and mass at one redshift is ordinary PyAutoGalaxy usage, not a mistake. + + __Tracer safety__ + + Redshifts can be free model parameters (e.g. a subhalo redshift under + ``jax.jit``), so only concrete redshifts are compared; a traced redshift makes the + check skip rather than coerce a traced boolean. + + Parameters + ---------- + galaxies + The galaxies the tracer was constructed with. + """ + try: + galaxy_list = list(galaxies) + except TypeError: + return + + mass_redshifts = [] + light_redshifts = [] + all_redshifts = [] + + for galaxy in galaxy_list: + redshift = getattr(galaxy, "redshift", None) + + if not validate.is_concrete_scalar(redshift): + return + + all_redshifts.append(redshift) + + has = getattr(galaxy, "has", None) + + if has is None: + return + + is_mass = has(cls=MassProfile) + is_lensable = any(has(cls=cls) for cls in LENSABLE_CLS) + + # An entirely empty galaxy is a scaffold — a placeholder in a model being + # composed, or a source not yet filled in. We cannot judge the geometry of a + # system that is still being built, so say nothing rather than warn someone + # mid-construction. + if not is_mass and not is_lensable: + return + + if is_mass: + mass_redshifts.append(redshift) + + if is_lensable: + light_redshifts.append(redshift) + + if not mass_redshifts or not light_redshifts: + return + + if len(set(all_redshifts)) < 2: + return + + if max(light_redshifts) > min(mass_redshifts): + return + + warnings.warn( + f"No light in this tracer lies behind any mass, so nothing in it is " + f"gravitationally lensed: the light-bearing galaxies are at redshifts " + f"{sorted(set(light_redshifts))} and the mass-bearing galaxies at " + f"{sorted(set(mass_redshifts))}. The usual cause is the lens and source " + f"redshifts being the wrong way round.\n\n" + f"This is a warning, not an error — multi-plane ray tracing supports " + f"geometries that look inverted under two-plane naming, so this may be " + f"intended. Silence it with:\n\n" + f" warnings.filterwarnings('ignore', category=MultiPlaneRedshiftWarning)", + MultiPlaneRedshiftWarning, + stacklevel=3, + ) + + def _validate_galaxies(galaxies): """ Raise if ``galaxies`` is not something the tracer can treat as a collection of @@ -131,6 +266,7 @@ def __init__( # galaxies = list(galaxies.values()) _validate_galaxies(galaxies=galaxies) + _warn_if_no_light_is_behind_any_mass(galaxies=galaxies) self.galaxies = galaxies diff --git a/test_autolens/lens/test_tracer_validation.py b/test_autolens/lens/test_tracer_validation.py index e477825dc..1bdc53f3e 100644 --- a/test_autolens/lens/test_tracer_validation.py +++ b/test_autolens/lens/test_tracer_validation.py @@ -10,16 +10,19 @@ The negative-redshift half of #532 is guarded in PyAutoGalaxy, where `Galaxy` and its `redshift` assignment actually live (`al.Galaxy` IS `ag.Galaxy`), and is tested there. -`z_lens > z_source` is phase 4 of the audit and explicitly NOT implemented — it is -held pending the reporter's answer. The control at the bottom pins today's permissive -behaviour so phase 4 cannot regress it silently. +`z_lens > z_source` (phase 4) is implemented at the bottom of this module as a +*warning* under its own filterable category — never an error, because multi-plane +lensing genuinely supports geometries that look inverted under two-plane naming. """ +import warnings + import numpy as np import pytest import autofit as af import autolens as al +from autolens.lens.tracer import MultiPlaneRedshiftWarning @pytest.fixture(name="grid") @@ -107,25 +110,197 @@ def test__control__a_model_instance_of_galaxies_is_accepted(lens_galaxy, source_ # ====================================================================================== -# Phase 4 guard-rail — z_lens > z_source must NOT raise +# Phase 4 — z_lens > z_source WARNS, and must never raise # ====================================================================================== +# +# Resolved 2026-08-09. @rhayes777 was asked on #532 whether a warning here would be +# noise in a real multi-plane setup and did not answer; the campaign was closed with +# the warning implemented behind its own filterable category, so a user for whom it +# IS noise can silence it with one filter rather than living with it. +# +# The rule is deliberately narrow — it fires only when NO light lies behind ANY mass, +# i.e. when nothing in the tracer can be lensed at all. Genuine multi-plane systems, +# where some light is lensed and some is not, stay quiet. -def test__control__lens_redshift_above_source_redshift_still_constructs_and_evaluates( - grid, -): - """ - PHASE 4 GUARD-RAIL — deliberately pinning today's permissive behaviour. +def _mass_galaxy(redshift): + return al.Galaxy( + redshift=redshift, mass=al.mp.IsothermalSph(einstein_radius=1.0) + ) + + +def _light_galaxy(redshift): + return al.Galaxy(redshift=redshift, bulge=al.lp.Sersic(intensity=1.0)) + - Multi-plane lensing genuinely supports geometries that look inverted under - two-plane naming, so this must not raise. Whether it should even *warn* is the - open question put to @rhayes777 on #532. This test exists so phase 4 cannot - quietly turn it into an error. +def test__phase4__inverted_redshifts_warn_but_still_construct_and_evaluate(grid): """ - lens = al.Galaxy(redshift=1.0, mass=al.mp.IsothermalSph(einstein_radius=1.0)) - source = al.Galaxy(redshift=0.5, bulge=al.lp.Sersic(intensity=1.0)) + The reported case. It must WARN — and must still produce a finite image, because + multi-plane genuinely supports geometries that look inverted under two-plane + naming. A warning, never an error. + """ + galaxies = [_mass_galaxy(1.0), _light_galaxy(0.5)] + + with pytest.warns(MultiPlaneRedshiftWarning, match="lies behind"): + tracer = al.Tracer(galaxies=galaxies) - image = al.Tracer(galaxies=[lens, source]).image_2d_from(grid=grid) + image = tracer.image_2d_from(grid=grid) assert np.isfinite(np.asarray(image)).all() assert np.asarray(image).sum() > 0.0 + + +def test__phase4__the_warning_names_both_sets_of_redshifts(): + with pytest.warns(MultiPlaneRedshiftWarning) as record: + al.Tracer(galaxies=[_mass_galaxy(1.0), _light_galaxy(0.5)]) + + message = str(record[0].message) + + assert "0.5" in message + assert "1.0" in message + + +def test__phase4__the_warning_can_be_silenced_by_its_own_category(): + """ + The whole point of a dedicated category: a user for whom this is noise silences + exactly this, and nothing else. + """ + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + warnings.filterwarnings("ignore", category=MultiPlaneRedshiftWarning) + + al.Tracer(galaxies=[_mass_galaxy(1.0), _light_galaxy(0.5)]) + + assert [w for w in caught if issubclass(w.category, MultiPlaneRedshiftWarning)] == [] + + +@pytest.mark.parametrize( + "label,galaxies_fn", + [ + ("normal lens/source ordering", lambda: [_mass_galaxy(0.5), _light_galaxy(1.0)]), + ( + "lens with its own light, plus a source behind it", + lambda: [ + al.Galaxy( + redshift=0.5, + mass=al.mp.IsothermalSph(einstein_radius=1.0), + bulge=al.lp.Sersic(intensity=1.0), + ), + _light_galaxy(1.0), + ], + ), + ( + "genuine multi-plane: mass at 1.0, light at 0.5 AND 1.5", + lambda: [_mass_galaxy(1.0), _light_galaxy(0.5), _light_galaxy(1.5)], + ), + ("everything on one plane", lambda: [_mass_galaxy(0.5), _light_galaxy(0.5)]), + ("mass only, no light to lens", lambda: [_mass_galaxy(1.0), _mass_galaxy(0.5)]), + ("light only, no mass", lambda: [_light_galaxy(1.0), _light_galaxy(0.5)]), + ("empty tracer", lambda: []), + ], +) +def test__phase4__legitimate_configurations_stay_quiet(label, galaxies_fn): + """ + The noise question, answered by construction. The genuine multi-plane case is the + one that matters most: mass at z=1.0 with light at BOTH 0.5 and 1.5 has some of + its light lensed, so it is a real configuration and must not be flagged. + """ + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + + al.Tracer(galaxies=galaxies_fn()) + + assert [ + w for w in caught if issubclass(w.category, MultiPlaneRedshiftWarning) + ] == [], label + + +@pytest.mark.parametrize( + "label,source_galaxy_fn", + [ + ( + "pixelized source — carries NO LightProfile but is still light to lens", + lambda: al.Galaxy( + redshift=1.0, + pixelization=al.Pixelization( + mesh=al.mesh.RectangularUniform(shape=(3, 3)), + regularization=al.reg.Constant(coefficient=1.0), + ), + ), + ), + ( + "point source — carries neither light profile nor pixelization", + lambda: al.Galaxy(redshift=1.0, point_0=al.ps.Point(centre=(0.0, 0.0))), + ), + ], +) +def test__phase4__lensable_sources_without_a_light_profile_do_not_warn( + label, source_galaxy_fn +): + """ + Regression for the false-positive classes found while building this warning. + + Counting only `LightProfile` made this fire on every pixelized-source and + point-source configuration in the suite — core PyAutoLens usage, and exactly the + noise the reporter asked about. `LENSABLE_CLS` is the fix; this pins it. + """ + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + + al.Tracer(galaxies=[_mass_galaxy(0.5), source_galaxy_fn()]) + + assert [ + w for w in caught if issubclass(w.category, MultiPlaneRedshiftWarning) + ] == [], label + + +def test__phase4__an_empty_placeholder_galaxy_suppresses_the_warning(): + """ + A galaxy with nothing in it is a scaffold — a model being composed, or a source + not yet filled in. Judging the geometry of a half-built system and warning about + it is unhelpful, so the check stands down. + """ + lens = al.Galaxy( + redshift=0.5, + light=al.lp.SersicSph(intensity=2.0), + mass=al.mp.IsothermalSph(einstein_radius=1.0), + ) + empty_source = al.Galaxy(redshift=1.0) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + + al.Tracer(galaxies=[lens, empty_source]) + + assert [ + w for w in caught if issubclass(w.category, MultiPlaneRedshiftWarning) + ] == [] + + +def test__phase4__a_non_concrete_redshift_skips_the_check_entirely(): + """ + Redshifts can be free model parameters (a traced subhalo redshift under jax.jit), + so the check must skip rather than coerce a traced boolean. + """ + + class _TracerLikeRedshift: + def __bool__(self): + raise AssertionError("the warning compared a non-concrete redshift") + + def __lt__(self, other): + return self + + def __gt__(self, other): + return self + + galaxy = al.Galaxy(redshift=0.5, mass=al.mp.IsothermalSph(einstein_radius=1.0)) + galaxy.redshift = _TracerLikeRedshift() + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + + al.Tracer(galaxies=[galaxy, _light_galaxy(0.5)]) + + assert [ + w for w in caught if issubclass(w.category, MultiPlaneRedshiftWarning) + ] == []