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())