From 339cb285c37f92eeababb33c8fc53d1a2b684ed1 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Tue, 21 Jul 2026 18:28:44 +0100 Subject: [PATCH] fix: preserve the cause when analysis wraps a fit failure as FitException MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The numpy-path log_likelihood_function in the imaging, interferometer and point analyses wrapped fit failures in `except Exception as e: raise af.exc.FitException`, discarding e. The FitException (path-parity with the JAX NaN-resample so real fits resample rather than crash) is correct, but the bare re-raise masked the true error — a genuine bug was indistinguishable from a pathological-model resample, and the traceback showed only FitException at analysis.py. Chain the cause via a shared raise_fit_exception helper (raise ... from e), and add an opt-in PYAUTO_RAISE_ANALYSIS_EXCEPTIONS=1 to re-raise the original exception unwrapped for debugging (e.g. under PYAUTO_TEST_MODE where a single eval is not absorbed by a sampler). Default behaviour unchanged; production searches still resample. Co-Authored-By: Claude Opus 4.8 --- autolens/analysis/exceptions.py | 36 +++++++++++++++++++++ autolens/imaging/model/analysis.py | 3 +- autolens/interferometer/model/analysis.py | 3 +- autolens/point/model/analysis.py | 3 +- test_autolens/analysis/test_exceptions.py | 38 +++++++++++++++++++++++ 5 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 autolens/analysis/exceptions.py create mode 100644 test_autolens/analysis/test_exceptions.py diff --git a/autolens/analysis/exceptions.py b/autolens/analysis/exceptions.py new file mode 100644 index 000000000..008dbe256 --- /dev/null +++ b/autolens/analysis/exceptions.py @@ -0,0 +1,36 @@ +import os +from typing import NoReturn + +import autofit as af + + +def raise_fit_exception(exception: Exception) -> NoReturn: + """ + Re-raise a numpy-path fit failure as an ``af.exc.FitException`` so the + non-linear search discards and resamples the model. + + This is path-parity with the JAX branch: there, a pathological model (e.g. a + non-positive-definite inversion) yields ``NaN`` which ``autofit`` maps to a + resample, whereas the numpy branch raises (e.g. ``numpy.linalg.LinAlgError``). + Wrapping the raise in ``FitException`` — which ``autofit`` catches and + resamples — makes both branches behave identically for real fits. + + The original exception is preserved as the ``__cause__`` (via ``raise ... + from``), so the true failure remains visible in the traceback rather than + being masked behind a bare ``FitException``. + + Set the environment variable ``PYAUTO_RAISE_ANALYSIS_EXCEPTIONS=1`` to + re-raise the original exception unchanged instead of wrapping it. This is a + debugging aid for surfacing the real cause of a masked failure — for example + under ``PYAUTO_TEST_MODE``, where a single likelihood evaluation is not + absorbed by a sampler and the wrapped ``FitException`` would otherwise hide + the underlying error. Off by default so production searches keep resampling. + + Parameters + ---------- + exception + The original exception raised while evaluating the numpy-path fit. + """ + if os.environ.get("PYAUTO_RAISE_ANALYSIS_EXCEPTIONS", "0") == "1": + raise exception + raise af.exc.FitException from exception diff --git a/autolens/imaging/model/analysis.py b/autolens/imaging/model/analysis.py index b9b592245..88c28c263 100644 --- a/autolens/imaging/model/analysis.py +++ b/autolens/imaging/model/analysis.py @@ -21,6 +21,7 @@ from autonerves.fitsable import hdu_list_for_output_from from autolens.analysis.analysis.dataset import AnalysisDataset +from autolens.analysis.exceptions import raise_fit_exception from autolens.analysis.latent import LatentLens from autolens.imaging.model.result import ResultImaging from autolens.imaging.model.visualizer import VisualizerImaging @@ -141,7 +142,7 @@ def log_likelihood_function(self, instance: af.ModelInstance, shared=None) -> fl - log_likelihood_penalty ) except Exception as e: - raise af.exc.FitException + raise_fit_exception(e) def shared_state_from(self, instance: af.ModelInstance): """ diff --git a/autolens/interferometer/model/analysis.py b/autolens/interferometer/model/analysis.py index 617858477..4fbdbb361 100644 --- a/autolens/interferometer/model/analysis.py +++ b/autolens/interferometer/model/analysis.py @@ -24,6 +24,7 @@ import autogalaxy as ag from autolens.analysis.analysis.dataset import AnalysisDataset +from autolens.analysis.exceptions import raise_fit_exception from autolens.analysis.positions import PositionsLH from autolens.interferometer.model.result import ResultInterferometer from autolens.interferometer.model.visualizer import VisualizerInterferometer @@ -179,7 +180,7 @@ def log_likelihood_function(self, instance, shared=None): - log_likelihood_penalty ) except Exception as e: - raise af.exc.FitException + raise_fit_exception(e) def shared_state_from(self, instance: af.ModelInstance): """ diff --git a/autolens/point/model/analysis.py b/autolens/point/model/analysis.py index ededb9193..9124a3994 100644 --- a/autolens/point/model/analysis.py +++ b/autolens/point/model/analysis.py @@ -22,6 +22,7 @@ from autogalaxy.analysis.analysis.analysis import Analysis as AgAnalysis from autolens.analysis.analysis.lens import AnalysisLens +from autolens.analysis.exceptions import raise_fit_exception from autolens.point.fit.positions.image.pair_repeat import FitPositionsImagePairRepeat from autolens.point.fit.dataset import FitPointDataset from autolens.point.dataset import PointDataset @@ -136,7 +137,7 @@ def log_likelihood_function(self, instance): try: return self.fit_from(instance=instance).log_likelihood except Exception as e: - raise af.exc.FitException + raise_fit_exception(e) def fit_from( self, diff --git a/test_autolens/analysis/test_exceptions.py b/test_autolens/analysis/test_exceptions.py new file mode 100644 index 000000000..2a9e0ba0b --- /dev/null +++ b/test_autolens/analysis/test_exceptions.py @@ -0,0 +1,38 @@ +import pytest + +import autofit as af +from autolens.analysis.exceptions import raise_fit_exception + + +class _Sentinel(Exception): + """A distinctive error standing in for a real numpy-path fit failure.""" + + +def test__wraps_as_fit_exception_and_preserves_cause(): + sentinel = _Sentinel("non-PD inversion") + + with pytest.raises(af.exc.FitException) as exc_info: + raise_fit_exception(sentinel) + + # The search resamples on FitException, but the true failure must remain + # visible in the traceback via __cause__ rather than being masked. + assert exc_info.value.__cause__ is sentinel + + +def test__env_flag_reraises_original_unwrapped(monkeypatch): + monkeypatch.setenv("PYAUTO_RAISE_ANALYSIS_EXCEPTIONS", "1") + sentinel = _Sentinel("non-PD inversion") + + with pytest.raises(_Sentinel) as exc_info: + raise_fit_exception(sentinel) + + assert exc_info.value is sentinel + + +def test__env_flag_zero_still_wraps(monkeypatch): + # Only "1" enables raise-through; "0" must keep wrapping (guards against the + # "0"-is-a-truthy-string trap). + monkeypatch.setenv("PYAUTO_RAISE_ANALYSIS_EXCEPTIONS", "0") + + with pytest.raises(af.exc.FitException): + raise_fit_exception(_Sentinel())