diff --git a/autofit/non_linear/search/abstract_search.py b/autofit/non_linear/search/abstract_search.py index 2005074c2..e529e13d9 100644 --- a/autofit/non_linear/search/abstract_search.py +++ b/autofit/non_linear/search/abstract_search.py @@ -65,6 +65,11 @@ #: a hand-set ``1e100`` in a workspace config reads as "never" too. ITERATIONS_NEVER = 1e90 +# A reduced test-mode fit needs a valid representative instance for result +# construction and search chaining. Keep fallback sampling bounded so an +# impossible model fails clearly instead of hanging a smoke-test worker. +TEST_MODE_REPRESENTATIVE_MAX_ATTEMPTS = 100 + def check_cores(func): """ @@ -747,8 +752,22 @@ def start_resume_fit(self, analysis: Analysis, model: AbstractPriorModel) -> Res during_analysis=False, ) + samples_summary = samples.summary() + + if mode == 1: + try: + samples_summary.instance + except exc.FitException as error: + samples = self._test_mode_samples_after_rejected_fit( + model=model, + error=error, + ) + samples_summary = samples.summary() + self.paths.save_samples_summary(samples_summary=samples_summary) + self.paths.save_samples(samples=samples) + result = analysis.make_result( - samples_summary=samples.summary(), + samples_summary=samples_summary, paths=self.paths, samples=samples, search_internal=search_internal, @@ -763,6 +782,82 @@ def start_resume_fit(self, analysis: Analysis, model: AbstractPriorModel) -> Res return result + def _test_mode_samples_after_rejected_fit( + self, + model: AbstractPriorModel, + error: exc.FitException, + ) -> Samples: + """Build valid representative samples after a mode-1 rejected result. + + ``Fitness`` maps :class:`FitException` to the sampler's rejection + sentinel. A production search naturally moves on to another point, + but ``PYAUTO_TEST_MODE=1`` may stop after that first evaluation. Its + posterior can therefore contain only the rejected point, which cannot + be reconstructed while finalizing the result. + + Try the prior medians first, then a deterministic sequence of prior + draws. Every synthetic sample is validated before it is returned so + result construction and downstream chaining cannot reconstruct another + rejected point. The fixed seed keeps smoke tests reproducible without + changing the application's global random state. + """ + from autofit.non_linear.samples.pdf import SamplesPDF + + logger.warning( + "TEST MODE 1: the reduced search's final sample raised " + f"FitException ({error.__cause__ or error!r}); replacing it with " + "a valid representative sample for result construction." + ) + + rng = np.random.default_rng(seed=0) + last_error = error + + for attempt in range(TEST_MODE_REPRESENTATIVE_MAX_ATTEMPTS): + unit_vector = ( + [0.5] * model.prior_count + if attempt == 0 + else rng.random(model.prior_count).tolist() + ) + + try: + parameter_vector = [ + float(value) + for value in model.vector_from_unit_vector( + unit_vector=unit_vector, + ) + ] + sample_list = self._build_fake_samples( + model=model, + parameter_vector=parameter_vector, + log_likelihood=-1.0e99, + ) + + for sample in sample_list: + model.instance_from_vector( + vector=sample.parameter_lists_for_model(model) + ) + except exc.FitException as candidate_error: + last_error = candidate_error + continue + + samples_info = { + "total_iterations": 1, + "time": 0.0, + "log_evidence": -1.0e99, + } + samples_info.update(self._test_mode_samples_info()) + + return SamplesPDF( + model=model, + sample_list=sample_list, + samples_info=samples_info, + ) + + raise exc.FitException( + "TEST MODE 1 could not construct a valid representative result " + f"after {TEST_MODE_REPRESENTATIVE_MAX_ATTEMPTS} attempts." + ) from last_error + def result_via_completed_fit( self, analysis: Analysis, diff --git a/test_autofit/non_linear/search/test_abstract_search.py b/test_autofit/non_linear/search/test_abstract_search.py index 871d4afe5..7ef8c1840 100644 --- a/test_autofit/non_linear/search/test_abstract_search.py +++ b/test_autofit/non_linear/search/test_abstract_search.py @@ -5,6 +5,7 @@ import autofit as af from autonerves import conf +from autofit.non_linear.paths.null import NullPaths pytestmark = pytest.mark.filterwarnings("ignore::FutureWarning") @@ -473,3 +474,120 @@ def log_likelihood_function(self, instance): with pytest.raises(ValueError): search.fit(model=af.Model(af.m.MockClassx2), analysis=_BrokenAnalysis()) + + +class _RejectsLowValue: + def __init__(self, value): + if value < 0.75: + raise af.exc.FitException("value must be at least 0.75") + self.value = value + + +class _RejectsLowValueUnexpectedly: + def __init__(self, value): + if value < 0.75: + raise ValueError("unexpected constructor failure") + self.value = value + + +class _AlwaysRejects: + def __init__(self, value): + raise af.exc.FitException("no valid instance exists") + + +class _RejectedFinalSampleSearch(af.mock.MockSearch): + """Search double whose reduced sampler returns one rejected region.""" + + def __init__(self, samples): + super().__init__(fit_fast=False, paths=NullPaths()) + self._rejected_samples = samples + + def _fit(self, model, analysis): + return object(), object() + + def perform_update( + self, + model, + analysis, + during_analysis, + fitness=None, + search_internal=None, + ): + return self._rejected_samples + + +def _model_and_rejected_samples(cls): + model = af.Model(cls) + model.value = af.UniformPrior(lower_limit=0.0, upper_limit=1.0) + sample_list = af.DynestyStatic._build_fake_samples( + model=model, + parameter_vector=[0.1], + log_likelihood=-1.0e99, + ) + samples = af.SamplesPDF( + model=model, + sample_list=sample_list, + samples_info={"log_evidence": -1.0e99}, + ) + return model, samples + + +class TestReducedModeRejectedFinalSample: + def test__test_mode_1__fitexception_gets_valid_representative(self, monkeypatch): + monkeypatch.setenv("PYAUTO_TEST_MODE", "1") + model, rejected_samples = _model_and_rejected_samples(_RejectsLowValue) + + result = _RejectedFinalSampleSearch(samples=rejected_samples).fit( + model=model, + analysis=af.m.MockAnalysis(), + ) + + assert result.samples_summary.instance.value >= 0.75 + assert result.samples.max_log_likelihood_sample.log_likelihood == pytest.approx( + -1.0e99 + ) + assert all(instance.value >= 0.75 for instance in result.samples.instances) + + def test__normal_mode__fitexception_still_propagates(self, monkeypatch): + monkeypatch.delenv("PYAUTO_TEST_MODE", raising=False) + model, rejected_samples = _model_and_rejected_samples(_RejectsLowValue) + + result = _RejectedFinalSampleSearch(samples=rejected_samples).fit( + model=model, + analysis=af.m.MockAnalysis(), + ) + + with pytest.raises(af.exc.FitException): + result.samples_summary.instance + + def test__test_mode_1__non_fitexception_still_propagates(self, monkeypatch): + monkeypatch.setenv("PYAUTO_TEST_MODE", "1") + model, rejected_samples = _model_and_rejected_samples( + _RejectsLowValueUnexpectedly + ) + + with pytest.raises(ValueError, match="unexpected constructor failure"): + _RejectedFinalSampleSearch(samples=rejected_samples).fit( + model=model, + analysis=af.m.MockAnalysis(), + ) + + def test__test_mode_1__bounded_failure_is_clear(self, monkeypatch): + from autofit.non_linear.search import abstract_search + + monkeypatch.setenv("PYAUTO_TEST_MODE", "1") + monkeypatch.setattr( + abstract_search, + "TEST_MODE_REPRESENTATIVE_MAX_ATTEMPTS", + 2, + ) + model, rejected_samples = _model_and_rejected_samples(_AlwaysRejects) + + with pytest.raises( + af.exc.FitException, + match="could not construct a valid representative result after 2 attempts", + ): + _RejectedFinalSampleSearch(samples=rejected_samples).fit( + model=model, + analysis=af.m.MockAnalysis(), + )