From 2dec9afb4c3b2b91a80a3afd6d1f7bb9ce4d8eff Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Mon, 4 May 2026 19:42:36 +0100 Subject: [PATCH] Refresh cached SearchUpdater when AbstractSearch.paths is reassigned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``AbstractSearch._updater`` lazily caches a ``SearchUpdater`` on first access and previously never invalidated the cache. When the EP loop reuses a single search across factors and iterations, ``AbstractSearch.optimise(factor_approx)`` mutates ``self.paths`` to a fresh ``SubDirectoryPaths`` per factor and per EP iteration — but the updater stayed pinned to the first paths it ever saw, so all subsequent samples / visualizations / profiles wrote under the wrong directory. The user-visible symptom is per-EP-iteration visualisations overwriting each other in the first iteration's image folder. Identity-comparing the cached updater's ``_paths`` against ``self.paths`` makes the property recreate the updater whenever the search has been pointed at a new paths object. Co-Authored-By: Claude Opus 4.7 (1M context) --- autofit/non_linear/search/abstract_search.py | 16 ++++++++++- .../non_linear/search/test_abstract_search.py | 27 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/autofit/non_linear/search/abstract_search.py b/autofit/non_linear/search/abstract_search.py index b15062ce1..b2b72eb9b 100644 --- a/autofit/non_linear/search/abstract_search.py +++ b/autofit/non_linear/search/abstract_search.py @@ -936,7 +936,21 @@ def output_search_internal(self, search_internal): @property def _updater(self): - if not hasattr(self, "_search_updater") or self._search_updater is None: + # The cached ``SearchUpdater`` must be invalidated whenever + # ``self.paths`` is reassigned to a new object — otherwise the + # updater holds a stale reference to the old paths and writes + # output (samples, visualizations, profiles) under the wrong + # directory. This happens routinely when a single search + # instance is reused across factor optimisations in the EP + # loop: ``AbstractSearch.optimise(factor_approx)`` mutates + # ``self.paths = SubDirectoryPaths(...)`` per factor and per EP + # iteration, but the updater would otherwise stay pinned to + # whichever paths were live the first time ``_updater`` was + # accessed. Identity comparison (``is not``) is the right test: + # the search instance receives a freshly-constructed + # ``SubDirectoryPaths`` each time, never an in-place mutation. + cached = getattr(self, "_search_updater", None) + if cached is None or cached._paths is not self.paths: from autofit.non_linear.search.updater import SearchUpdater self._search_updater = SearchUpdater( diff --git a/test_autofit/non_linear/search/test_abstract_search.py b/test_autofit/non_linear/search/test_abstract_search.py index 4d1678b7b..75018de9c 100644 --- a/test_autofit/non_linear/search/test_abstract_search.py +++ b/test_autofit/non_linear/search/test_abstract_search.py @@ -112,6 +112,33 @@ def test__identifier_fields_differ_across_searches(self): assert "nlive" in dynesty.__identifier_fields__ +class TestUpdaterPathsRefresh: + """ + The cached ``SearchUpdater`` must be invalidated when ``self.paths`` + is reassigned to a new object — otherwise output (samples, viz, + profiling) keeps writing under the FIRST paths the search ever saw. + The EP loop does this routinely: + ``AbstractSearch.optimise(factor_approx)`` reassigns ``self.paths`` + to a fresh ``SubDirectoryPaths`` per factor and per EP iteration. + """ + + def test__updater_refreshes_when_paths_reassigned(self): + search = af.DynestyStatic(name="updater_paths_test_a") + first_updater = search._updater + first_paths = search.paths + # Sanity: cache hit on second access with no path change. + assert search._updater is first_updater + + search.paths = af.DirectoryPaths(name="updater_paths_test_b") + second_updater = search._updater + + assert second_updater is not first_updater, ( + "Expected a fresh SearchUpdater after self.paths was reassigned" + ) + assert second_updater._paths is search.paths + assert second_updater._paths is not first_paths + + class TestLabels: def test_param_names(self): model = af.Model(af.m.MockClassx4)