diff --git a/autofit/non_linear/search/abstract_search.py b/autofit/non_linear/search/abstract_search.py index 933bbacd1..46fc5e731 100644 --- a/autofit/non_linear/search/abstract_search.py +++ b/autofit/non_linear/search/abstract_search.py @@ -872,9 +872,24 @@ def _fit_bypass_test_mode( log_likelihood = -1.0e99 if call_likelihood: instance = model.instance_from_vector(vector=parameter_vector) - log_likelihood = float( - analysis.log_likelihood_function(instance) - ) + try: + log_likelihood = float( + analysis.log_likelihood_function(instance) + ) + except exc.FitException as e: + # A `FitException` means this particular instance is pathological + # (e.g. a non-positive-definite inversion, or a degenerate mesh + # that yields NaN vertices). In a real search the sampler absorbs + # this by resampling; test mode has no sampler, so a single + # unlucky verification eval must not hard-fail the run. Keep the + # `-1.0e99` sentinel — the same effect a resample-to-reject has — + # and log the cause so a genuinely broken likelihood stays visible. + # Only `FitException` is caught: real code errors still propagate. + logger.warning( + "TEST MODE 2: likelihood verification raised FitException " + f"({e.__cause__ or e!r}); treating as a resample-rejected " + "instance and continuing with the sentinel log likelihood." + ) sample_list = self._build_fake_samples( model=model, diff --git a/test_autofit/non_linear/search/test_abstract_search.py b/test_autofit/non_linear/search/test_abstract_search.py index e9fb7af4e..4a2e3bb5f 100644 --- a/test_autofit/non_linear/search/test_abstract_search.py +++ b/test_autofit/non_linear/search/test_abstract_search.py @@ -420,3 +420,56 @@ def _poison(*args, **kwargs): ) assert result is not None + + +class _FitExceptionAnalysis(af.m.MockAnalysis): + """Analysis whose likelihood always raises `FitException`, mimicking a + pathological instance (non-PD inversion / NaN Delaunay mesh) that a real + sampler would resample away.""" + + def log_likelihood_function(self, instance): + raise af.exc.FitException("pathological instance (test)") + + +class TestBypassToleratesFitException: + """ + In test mode 2 the bypass calls the likelihood once to verify it works. A + single unlucky instance raising `FitException` (a resample signal a real + sampler absorbs) must NOT hard-fail the run — otherwise flaky pixelization + scripts intermittently break CI smoke (autolens_workspace#307, PyAutoLens#640). + Only `FitException` is tolerated; genuine errors still propagate. + """ + + def test__test_mode_2__fitexception_tolerated__fit_completes_with_sentinel( + self, monkeypatch + ): + monkeypatch.setenv("PYAUTO_TEST_MODE", "2") + + search = af.DynestyStatic( + name="bypass_fitexception", unique_tag="bypass_fitexception_test" + ) + + result = search.fit( + model=af.Model(af.m.MockClassx2), analysis=_FitExceptionAnalysis() + ) + + assert result is not None + # The failed verification eval falls back to the -1.0e99 sentinel, the + # same effect as a resample-to-reject. + assert float(result.samples.max_log_likelihood_sample.log_likelihood) == pytest.approx( + -1.0e99 + ) + + def test__test_mode_2__non_fitexception_still_propagates(self, monkeypatch): + monkeypatch.setenv("PYAUTO_TEST_MODE", "2") + + class _BrokenAnalysis(af.m.MockAnalysis): + def log_likelihood_function(self, instance): + raise ValueError("a genuine bug, not a resample signal") + + search = af.DynestyStatic( + name="bypass_broken", unique_tag="bypass_broken_test" + ) + + with pytest.raises(ValueError): + search.fit(model=af.Model(af.m.MockClassx2), analysis=_BrokenAnalysis())