Skip to content

Commit 339cb28

Browse files
Jammy2211claude
authored andcommitted
fix: preserve the cause when analysis wraps a fit failure as FitException
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 <noreply@anthropic.com>
1 parent b3e2e88 commit 339cb28

5 files changed

Lines changed: 80 additions & 3 deletions

File tree

autolens/analysis/exceptions.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import os
2+
from typing import NoReturn
3+
4+
import autofit as af
5+
6+
7+
def raise_fit_exception(exception: Exception) -> NoReturn:
8+
"""
9+
Re-raise a numpy-path fit failure as an ``af.exc.FitException`` so the
10+
non-linear search discards and resamples the model.
11+
12+
This is path-parity with the JAX branch: there, a pathological model (e.g. a
13+
non-positive-definite inversion) yields ``NaN`` which ``autofit`` maps to a
14+
resample, whereas the numpy branch raises (e.g. ``numpy.linalg.LinAlgError``).
15+
Wrapping the raise in ``FitException`` — which ``autofit`` catches and
16+
resamples — makes both branches behave identically for real fits.
17+
18+
The original exception is preserved as the ``__cause__`` (via ``raise ...
19+
from``), so the true failure remains visible in the traceback rather than
20+
being masked behind a bare ``FitException``.
21+
22+
Set the environment variable ``PYAUTO_RAISE_ANALYSIS_EXCEPTIONS=1`` to
23+
re-raise the original exception unchanged instead of wrapping it. This is a
24+
debugging aid for surfacing the real cause of a masked failure — for example
25+
under ``PYAUTO_TEST_MODE``, where a single likelihood evaluation is not
26+
absorbed by a sampler and the wrapped ``FitException`` would otherwise hide
27+
the underlying error. Off by default so production searches keep resampling.
28+
29+
Parameters
30+
----------
31+
exception
32+
The original exception raised while evaluating the numpy-path fit.
33+
"""
34+
if os.environ.get("PYAUTO_RAISE_ANALYSIS_EXCEPTIONS", "0") == "1":
35+
raise exception
36+
raise af.exc.FitException from exception

autolens/imaging/model/analysis.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from autonerves.fitsable import hdu_list_for_output_from
2222

2323
from autolens.analysis.analysis.dataset import AnalysisDataset
24+
from autolens.analysis.exceptions import raise_fit_exception
2425
from autolens.analysis.latent import LatentLens
2526
from autolens.imaging.model.result import ResultImaging
2627
from autolens.imaging.model.visualizer import VisualizerImaging
@@ -141,7 +142,7 @@ def log_likelihood_function(self, instance: af.ModelInstance, shared=None) -> fl
141142
- log_likelihood_penalty
142143
)
143144
except Exception as e:
144-
raise af.exc.FitException
145+
raise_fit_exception(e)
145146

146147
def shared_state_from(self, instance: af.ModelInstance):
147148
"""

autolens/interferometer/model/analysis.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import autogalaxy as ag
2525

2626
from autolens.analysis.analysis.dataset import AnalysisDataset
27+
from autolens.analysis.exceptions import raise_fit_exception
2728
from autolens.analysis.positions import PositionsLH
2829
from autolens.interferometer.model.result import ResultInterferometer
2930
from autolens.interferometer.model.visualizer import VisualizerInterferometer
@@ -179,7 +180,7 @@ def log_likelihood_function(self, instance, shared=None):
179180
- log_likelihood_penalty
180181
)
181182
except Exception as e:
182-
raise af.exc.FitException
183+
raise_fit_exception(e)
183184

184185
def shared_state_from(self, instance: af.ModelInstance):
185186
"""

autolens/point/model/analysis.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from autogalaxy.analysis.analysis.analysis import Analysis as AgAnalysis
2323

2424
from autolens.analysis.analysis.lens import AnalysisLens
25+
from autolens.analysis.exceptions import raise_fit_exception
2526
from autolens.point.fit.positions.image.pair_repeat import FitPositionsImagePairRepeat
2627
from autolens.point.fit.dataset import FitPointDataset
2728
from autolens.point.dataset import PointDataset
@@ -136,7 +137,7 @@ def log_likelihood_function(self, instance):
136137
try:
137138
return self.fit_from(instance=instance).log_likelihood
138139
except Exception as e:
139-
raise af.exc.FitException
140+
raise_fit_exception(e)
140141

141142
def fit_from(
142143
self,
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import pytest
2+
3+
import autofit as af
4+
from autolens.analysis.exceptions import raise_fit_exception
5+
6+
7+
class _Sentinel(Exception):
8+
"""A distinctive error standing in for a real numpy-path fit failure."""
9+
10+
11+
def test__wraps_as_fit_exception_and_preserves_cause():
12+
sentinel = _Sentinel("non-PD inversion")
13+
14+
with pytest.raises(af.exc.FitException) as exc_info:
15+
raise_fit_exception(sentinel)
16+
17+
# The search resamples on FitException, but the true failure must remain
18+
# visible in the traceback via __cause__ rather than being masked.
19+
assert exc_info.value.__cause__ is sentinel
20+
21+
22+
def test__env_flag_reraises_original_unwrapped(monkeypatch):
23+
monkeypatch.setenv("PYAUTO_RAISE_ANALYSIS_EXCEPTIONS", "1")
24+
sentinel = _Sentinel("non-PD inversion")
25+
26+
with pytest.raises(_Sentinel) as exc_info:
27+
raise_fit_exception(sentinel)
28+
29+
assert exc_info.value is sentinel
30+
31+
32+
def test__env_flag_zero_still_wraps(monkeypatch):
33+
# Only "1" enables raise-through; "0" must keep wrapping (guards against the
34+
# "0"-is-a-truthy-string trap).
35+
monkeypatch.setenv("PYAUTO_RAISE_ANALYSIS_EXCEPTIONS", "0")
36+
37+
with pytest.raises(af.exc.FitException):
38+
raise_fit_exception(_Sentinel())

0 commit comments

Comments
 (0)