diff --git a/autofit/non_linear/analysis/latent.py b/autofit/non_linear/analysis/latent.py index d09a1b14e..562a0d79a 100644 --- a/autofit/non_linear/analysis/latent.py +++ b/autofit/non_linear/analysis/latent.py @@ -28,6 +28,7 @@ import numpy as np from autofit import exc +from autofit.non_linear.jax_compile import log_on_first_compile from autofit.non_linear.samples.sample import Sample from autofit.non_linear.samples.samples import Samples from autofit.non_linear.samples.util import simple_model_for_kwargs @@ -144,18 +145,21 @@ def latent_samples_from( if analysis._use_jax: import jax import jax.numpy as jnp - start = time.time() if batch_mode == "vmap": - logger.info("JAX: Applying vmap and jit to likelihood function for latent variables -- may take a few seconds.") # vmap traces `variables` once for the whole batch, so a # per-sample try/except is not possible here — latent functions # on the vmap path must express failures as NaN (e.g. # `jnp.where`), never by raising. The `jit` and numpy paths # below do guard per sample. - batched_compute_latent = jax.jit(jax.vmap(compute_latent_for_model)) + batched_compute_latent = log_on_first_compile( + jax.jit(jax.vmap(compute_latent_for_model)), + "latent variable function (vmap)", + ) elif batch_mode == "jit": - logger.info("JAX: Applying per-sample jit to latent variables (LATENT_BATCH_MODE='jit') -- may take a few seconds on first sample.") - jitted_compute_latent = jax.jit(compute_latent_for_model) + jitted_compute_latent = log_on_first_compile( + jax.jit(compute_latent_for_model), + "latent variable function (per-sample jit)", + ) n_latents = len(keys) nan_tuple = tuple(jnp.nan for _ in range(n_latents)) @@ -185,7 +189,6 @@ def batched_compute_latent(parameters_batch): raise ValueError( f"Unknown LATENT_BATCH_MODE={batch_mode!r}; expected 'vmap' or 'jit'." ) - logger.info(f"JAX: {batch_mode} dispatch applied in {time.time() - start} seconds.") else: n_latents = len(keys) nan_row = np.full(n_latents, np.nan) diff --git a/autofit/non_linear/fitness.py b/autofit/non_linear/fitness.py index fa46c7793..4cb4307d2 100644 --- a/autofit/non_linear/fitness.py +++ b/autofit/non_linear/fitness.py @@ -16,6 +16,7 @@ from autofit.mapper.prior_model.abstract import AbstractPriorModel +from autofit.non_linear.jax_compile import log_on_first_compile from autofit.non_linear.paths.abstract import AbstractPaths from autofit.non_linear.analysis import Analysis @@ -425,8 +426,15 @@ def manage_quick_update(self, parameters, log_likelihood): "Live display update raised an exception (ignored)." ) + # Searches hand their parameters over in whatever type they hold them: + # ndarray (Nautilus), JAX array, or a plain Python list (Dynesty's + # initializer). `np.asarray` normalizes all three -- calling `.tolist()` + # directly assumed the array case, which held only while Nautilus was + # the sole search wired up to quick updates (PyAutoFit#1434). result_info = text_util.result_max_lh_info_from( - max_log_likelihood_sample=self.quick_update_max_lh_parameters.tolist(), + max_log_likelihood_sample=np.asarray( + self.quick_update_max_lh_parameters + ).tolist(), max_log_likelihood=self.quick_update_max_lh, model=self.model, ) @@ -504,11 +512,11 @@ def _vmap(self): after its first creation, avoiding repeated JIT compilation overhead. """ import jax - start = time.time() - logger.info("JAX: Applying vmap and jit to likelihood function -- may take a few seconds.") - func = jax.vmap(jax.jit(self.call)) - logger.info(f"JAX: vmap and jit applied in {time.time() - start} seconds.") - return func + + return log_on_first_compile( + jax.vmap(jax.jit(self.call)), + "vectorized (vmap) likelihood function", + ) @cached_property def _jit(self): @@ -524,11 +532,11 @@ def _jit(self): first use, so JIT compilation only occurs once. """ import jax - start = time.time() - logger.info("JAX: Applying jit to likelihood function -- may take a few seconds.") - func = jax.jit(self.call) - logger.info(f"JAX: jit applied in {time.time() - start} seconds.") - return func + + return log_on_first_compile( + jax.jit(self.call), + "likelihood function", + ) @cached_property def _grad(self): @@ -545,11 +553,11 @@ def _grad(self): only once. """ import jax - start = time.time() - logger.info("JAX: Applying grad to likelihood function -- may take a few seconds.") - func = jax.grad(self.call) - logger.info(f"JAX: grad applied in {time.time() - start} seconds.") - return func + + return log_on_first_compile( + jax.grad(self.call), + "likelihood function gradient", + ) def grad(self, *args, **kwargs): return self._grad(*args, **kwargs) diff --git a/autofit/non_linear/jax_compile.py b/autofit/non_linear/jax_compile.py new file mode 100644 index 000000000..69db63df3 --- /dev/null +++ b/autofit/non_linear/jax_compile.py @@ -0,0 +1,70 @@ +import logging +import time + +logger = logging.getLogger(__name__) + + +def log_on_first_compile(func, description): + """ + Wrap `func` so that its first invocation reports the JAX compilation it + triggers. + + `jax.jit`, `jax.vmap` and `jax.grad` all return immediately -- they only + build a wrapper. Tracing, lowering and XLA compilation happen on the *first + call* to the function they return, and that call is where the user waits: + seconds for a small model, minutes for a large one. Logging at + wrapper-construction time therefore announced a wait that had not started + yet and reported a duration of roughly zero, leaving the real wait that + followed unexplained. + + The one-shot flag lives in a closure rather than on an object because the + callers cache these wrappers on attributes that are stripped for pickling; a + fresh process rebuilds the wrapper and genuinely does recompile, so it + should log again. + + Parameters + ---------- + func + The JAX-transformed callable whose first call triggers compilation. + description + What is being compiled, as it should read mid-sentence in the log. + + Returns + ------- + A callable with the same signature as `func`. + """ + state = {"compiled": False} + + def wrapper(*args, **kwargs): + if state["compiled"]: + return func(*args, **kwargs) + + logger.info( + f"JAX jit compiling {description}, could take seconds or minutes..." + ) + start = time.time() + + try: + result = func(*args, **kwargs) + + # JAX dispatches asynchronously, so `result` may be a future that is + # not yet materialized. Block once, on this first call only, so the + # duration logged below is the wait the user actually sat through + # rather than the dispatch latency. + try: + import jax + + jax.block_until_ready(result) + except Exception: + pass + finally: + state["compiled"] = True + + logger.info( + f"JAX jit compilation of {description} complete in " + f"{time.time() - start:.1f} seconds." + ) + + return result + + return wrapper diff --git a/autofit/non_linear/search/abstract_search.py b/autofit/non_linear/search/abstract_search.py index 4c8744c84..819a97444 100644 --- a/autofit/non_linear/search/abstract_search.py +++ b/autofit/non_linear/search/abstract_search.py @@ -57,6 +57,14 @@ logger = logging.getLogger(__name__) +#: Iteration cadences at or above this mean "never". The packaged config default +#: for both ``iterations_per_quick_update`` and ``iterations_per_full_update`` is +#: the inf-like ``1e99`` sentinel documented on ``_steps_until_full_update`` -- +#: kept as a float so ``search.json`` stores a readable ``1e99`` rather than a +#: 99-digit integer. Compared against a threshold rather than ``1e99`` exactly so +#: a hand-set ``1e100`` in a workspace config reads as "never" too. +ITERATIONS_NEVER = 1e90 + def check_cores(func): """ @@ -444,6 +452,35 @@ def paths(self, paths: Optional[AbstractPaths]): paths.search = self self._paths = paths + @property + def quick_update_message(self) -> str: + """ + One line, logged at the start of every search, telling the user the real + cadence of the on-the-fly maximum-likelihood updates. + + The cadence is worth stating because it is the only thing that explains + the terminal's behaviour during a long fit: either updates appear every + N iterations, or nothing appears at all until the search finishes. The + packaged default is the ``ITERATIONS_NEVER`` sentinel, so "nothing at + all" is what most users get -- and a message that reported that as + ``1e+99 iterations`` would be technically true and practically useless. + Hence the two-branch wording, and hence naming the config key: the + disabled branch is the one a user is most likely to want to act on. + """ + iterations = self.iterations_per_quick_update + + if not np.isfinite(iterations) or iterations >= ITERATIONS_NEVER: + return ( + "On-the-fly updates of the maximum likelihood model are disabled. " + "Set `updates: iterations_per_quick_update` in config/general.yaml " + "to a finite number of iterations to enable them." + ) + + return ( + "On-the-fly updates of the maximum likelihood model every " + f"{int(iterations)} iterations." + ) + def copy_with_paths(self, paths): self.logger.debug(f"Creating a copy of {self._paths.name}") search_instance = copy.copy(self) @@ -507,6 +544,7 @@ class represented by model M and gives a score for their fitness. logger.info("Starting non-linear search with JAX.") else: logger.info(f"Starting non-linear search with {self.number_of_cores} cores.") + logger.info(self.quick_update_message) self._log_process_state() model = analysis.modify_model(model) diff --git a/autofit/non_linear/search/mcmc/blackjax/nuts/search.py b/autofit/non_linear/search/mcmc/blackjax/nuts/search.py index 005b514fa..3ea0a06bc 100644 --- a/autofit/non_linear/search/mcmc/blackjax/nuts/search.py +++ b/autofit/non_linear/search/mcmc/blackjax/nuts/search.py @@ -201,6 +201,9 @@ def _fit(self, model: AbstractPriorModel, analysis): paths=self.paths, fom_is_log_likelihood=False, # log-posterior target for NUTS resample_figure_of_merit=-jnp.inf, + iterations_per_quick_update=self.iterations_per_quick_update, + background_quick_update=self.quick_update_background, + live_visual_update=self.live_visual_update, ) # Initial position: borrow the standard initializer machinery so users diff --git a/autofit/non_linear/search/mcmc/emcee/search.py b/autofit/non_linear/search/mcmc/emcee/search.py index 02e26cb93..22aa7c32c 100644 --- a/autofit/non_linear/search/mcmc/emcee/search.py +++ b/autofit/non_linear/search/mcmc/emcee/search.py @@ -136,6 +136,9 @@ def _fit(self, model: AbstractPriorModel, analysis): paths=self.paths, fom_is_log_likelihood=False, resample_figure_of_merit=-np.inf, + iterations_per_quick_update=self.iterations_per_quick_update, + background_quick_update=self.quick_update_background, + live_visual_update=self.live_visual_update, ) pool = self.make_sneaky_pool(fitness) diff --git a/autofit/non_linear/search/mcmc/zeus/search.py b/autofit/non_linear/search/mcmc/zeus/search.py index 352b47c20..7de5d748c 100644 --- a/autofit/non_linear/search/mcmc/zeus/search.py +++ b/autofit/non_linear/search/mcmc/zeus/search.py @@ -173,6 +173,9 @@ def _fit(self, model: AbstractPriorModel, analysis): paths=self.paths, fom_is_log_likelihood=False, resample_figure_of_merit=-np.inf, + iterations_per_quick_update=self.iterations_per_quick_update, + background_quick_update=self.quick_update_background, + live_visual_update=self.live_visual_update, ) try: diff --git a/autofit/non_linear/search/mle/bfgs/search.py b/autofit/non_linear/search/mle/bfgs/search.py index da76d5a08..afdfe4167 100644 --- a/autofit/non_linear/search/mle/bfgs/search.py +++ b/autofit/non_linear/search/mle/bfgs/search.py @@ -124,7 +124,10 @@ def _fit( fom_is_log_likelihood=False, resample_figure_of_merit=-np.inf, convert_to_chi_squared=True, - store_history=self.should_plot_start_point + store_history=self.should_plot_start_point, + iterations_per_quick_update=self.iterations_per_quick_update, + background_quick_update=self.quick_update_background, + live_visual_update=self.live_visual_update, ) try: diff --git a/autofit/non_linear/search/mle/drawer/search.py b/autofit/non_linear/search/mle/drawer/search.py index ad794d20d..570e73a80 100644 --- a/autofit/non_linear/search/mle/drawer/search.py +++ b/autofit/non_linear/search/mle/drawer/search.py @@ -114,6 +114,9 @@ def _fit(self, model: AbstractPriorModel, analysis): fom_is_log_likelihood=False, resample_figure_of_merit=-np.inf, convert_to_chi_squared=False, + iterations_per_quick_update=self.iterations_per_quick_update, + background_quick_update=self.quick_update_background, + live_visual_update=self.live_visual_update, ) total_draws = self.total_draws diff --git a/autofit/non_linear/search/nest/dynesty/search/abstract.py b/autofit/non_linear/search/nest/dynesty/search/abstract.py index 4b1f3cb0e..f7c54db8f 100644 --- a/autofit/non_linear/search/nest/dynesty/search/abstract.py +++ b/autofit/non_linear/search/nest/dynesty/search/abstract.py @@ -182,6 +182,9 @@ def _fit( paths=self.paths, fom_is_log_likelihood=True, resample_figure_of_merit=-1.0e99, + iterations_per_quick_update=self.iterations_per_quick_update, + background_quick_update=self.quick_update_background, + live_visual_update=self.live_visual_update, use_jax_jit=getattr(analysis, "_use_jax", False) and self.use_jax_jit, ) diff --git a/test_autofit/non_linear/search/test_quick_update_message.py b/test_autofit/non_linear/search/test_quick_update_message.py new file mode 100644 index 000000000..d5a857477 --- /dev/null +++ b/test_autofit/non_linear/search/test_quick_update_message.py @@ -0,0 +1,70 @@ +import pytest + +import autofit as af + +# ``quick_update_message`` is logged once at the start of every search. It is the +# only thing that explains the terminal's behaviour during a long fit: either +# on-the-fly maximum-likelihood updates appear every N iterations, or nothing +# appears until the search finishes. +# +# It exists because the workspace example scripts used to print the *name* of the +# knob rather than its value ("On-the-fly updates every +# iterations_per_quick_update are printed to the notebook"). Interpolating the +# value alone would not have fixed it: the packaged default is the inf-like +# ``1e99`` sentinel, so the honest message for most users is not a number at all. +# +# NumPy-only, like the rest of the library suite. + +pytestmark = pytest.mark.filterwarnings("ignore::FutureWarning") + + +def test__real_cadence_renders_as_a_plain_integer(): + # The knob is stored as a float (so ``search.json`` keeps a readable + # ``1e99`` rather than a 99-digit integer), so the message has to cast -- + # otherwise a user configuring 250000 is told "every 250000.0 iterations". + search = af.MultiStartAdam(n_steps=3000, iterations_per_quick_update=250000) + + assert isinstance(search.iterations_per_quick_update, float) + + message = search.quick_update_message + + assert "every 250000 iterations" in message + assert "250000.0" not in message + assert "disabled" not in message + + +def test__default_sentinel_says_disabled_and_names_the_config_key(): + # The packaged default is 1e99 — updates never fire. Reporting that as + # "every 1e+99 iterations" would be true and useless, so the disabled branch + # says so plainly and names the key that turns them on, because that is the + # branch a user is most likely to want to act on. + search = af.MultiStartAdam(n_steps=300) + + assert search.iterations_per_quick_update == pytest.approx(1e99) + + message = search.quick_update_message + + assert "disabled" in message + assert "iterations_per_quick_update" in message + assert "config/general.yaml" in message + assert "1e+99" not in message + + +@pytest.mark.parametrize("never", [1e99, 1e100, float("inf")]) +def test__any_never_sized_cadence_reads_as_disabled(never): + # The threshold is compared against, not equality-tested on 1e99, so a + # hand-set 1e100 in a workspace config reads as "never" too. ``inf`` is + # covered separately because it is not comparable-then-castable: ``int(inf)`` + # raises, so the finite check has to come first. + search = af.MultiStartAdam(n_steps=300) + search.iterations_per_quick_update = never + + assert "disabled" in search.quick_update_message + + +def test__a_cadence_of_one_still_reads_as_a_number(): + # Guards the boundary from the other side: the disabled branch must not + # swallow small real cadences. + search = af.MultiStartAdam(n_steps=300, iterations_per_quick_update=1) + + assert "every 1 iterations" in search.quick_update_message diff --git a/test_autofit/non_linear/search/test_quick_update_wiring.py b/test_autofit/non_linear/search/test_quick_update_wiring.py new file mode 100644 index 000000000..cc0e62286 --- /dev/null +++ b/test_autofit/non_linear/search/test_quick_update_wiring.py @@ -0,0 +1,79 @@ +import ast +from pathlib import Path + +import pytest + +# Every search builds its own `Fitness`, and the on-the-fly quick-update machinery +# only runs if that construction forwards `iterations_per_quick_update`. Forgetting +# it fails *silently*: `Fitness.manage_quick_update` returns at its `is None` guard, +# so the search runs perfectly and simply never updates. +# +# That is not hypothetical. Before PyAutoFit#1434 only Nautilus forwarded it, so +# setting a real cadence did nothing under Dynesty, Emcee, Zeus, BlackJAX NUTS, +# BFGS or Drawer — and once the cadence is announced in the startup log, a missing +# forward turns a dead feature into a false claim in the CLI. +# +# Checked structurally rather than by running each search: the samplers are +# optional dependencies and their `_fit` bodies need real data, while the defect +# is entirely visible in the call site. + +SEARCH_ROOT = Path(__file__).parents[3] / "autofit" / "non_linear" / "search" + +REQUIRED_KWARG = "iterations_per_quick_update" + +# path suffix -> why this construction site legitimately omits the kwarg. +EXEMPT = { + "mle/multi_start_gradient/search.py": ( + "MultiStartGradient differentiates `fitness.call` inside its own " + "jit/vmap step loop, so the Python-side counter in `call_wrap` would run " + "once at trace time rather than per step. Its progress reporting is " + "handled separately -- see PyAutoFit#1433." + ), +} + + +def _fitness_call_sites(): + """Every `Fitness(...)` construction under the search tree, as + (path-relative-to-search-root, set-of-keyword-names).""" + for path in sorted(SEARCH_ROOT.rglob("*.py")): + tree = ast.parse(path.read_text(encoding="utf-8")) + + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + + func = node.func + name = func.id if isinstance(func, ast.Name) else getattr(func, "attr", None) + + if name != "Fitness": + continue + + keywords = {kw.arg for kw in node.keywords if kw.arg is not None} + yield path.relative_to(SEARCH_ROOT).as_posix(), keywords + + +def test__the_scan_finds_the_call_sites_it_is_meant_to_guard(): + # Without this, a refactor that renamed or re-exported `Fitness` would make + # every assertion below vacuously pass. + sites = list(_fitness_call_sites()) + + assert len(sites) >= 8, sites + assert any("nautilus" in path for path, _ in sites) + assert any("dynesty" in path for path, _ in sites) + + +@pytest.mark.parametrize( + "relative_path, keywords", + list(_fitness_call_sites()), + ids=lambda value: value if isinstance(value, str) else "", +) +def test__every_search_forwards_the_quick_update_cadence(relative_path, keywords): + if relative_path in EXEMPT: + pytest.skip(EXEMPT[relative_path]) + + assert REQUIRED_KWARG in keywords, ( + f"{relative_path} builds a Fitness without `{REQUIRED_KWARG}`, so " + "quick updates silently never fire for this search while the startup " + "log still announces a cadence. Forward it, or add the site to EXEMPT " + "with the reason." + ) diff --git a/test_autofit/non_linear/test_jax_compile.py b/test_autofit/non_linear/test_jax_compile.py new file mode 100644 index 000000000..22862f2c8 --- /dev/null +++ b/test_autofit/non_linear/test_jax_compile.py @@ -0,0 +1,82 @@ +import logging + +from autofit.non_linear.jax_compile import log_on_first_compile + +# ``log_on_first_compile`` wraps the jax.jit / jax.vmap / jax.grad callables so the +# "this is compiling" line is emitted where the user actually waits -- on the first +# call -- rather than at wrapper-construction time, where the old logging sat and +# reported ~0 seconds immediately before an unexplained multi-minute pause. +# +# Tested against plain callables: the contract is about *when* it logs, which does +# not need JAX installed (the library suite is numpy-only). + +LOGGER = "autofit.non_linear.jax_compile" + + +def test_compile_message_is_logged_on_the_first_call_not_at_wrap_time(caplog): + calls = [] + wrapped = log_on_first_compile( + lambda x: calls.append(x) or x * 2, "likelihood function" + ) + + # Wrapping alone must say nothing -- this is the bug being fixed. + assert caplog.records == [] + assert calls == [] + + with caplog.at_level(logging.INFO, logger=LOGGER): + assert wrapped(3) == 6 + + messages = [record.getMessage() for record in caplog.records] + + assert ( + "JAX jit compiling likelihood function, could take seconds or minutes..." + in messages + ) + assert any("compilation of likelihood function complete in" in m for m in messages) + + +def test_compile_message_is_logged_only_once(caplog): + wrapped = log_on_first_compile(lambda x: x * 2, "likelihood function") + + with caplog.at_level(logging.INFO, logger=LOGGER): + assert wrapped(1) == 2 + assert wrapped(2) == 4 + assert wrapped(3) == 6 + + compiling = [ + record + for record in caplog.records + if "could take seconds or minutes" in record.getMessage() + ] + # Every later evaluation reuses the compiled trace, so announcing a compile + # again would be false -- and there are millions of evaluations per fit. + assert len(compiling) == 1 + + +def test_wrapped_function_passes_through_args_kwargs_and_return_value(): + wrapped = log_on_first_compile( + lambda a, b, scale=1: (a + b) * scale, "likelihood function" + ) + + assert wrapped(2, 3, scale=10) == 50 + assert wrapped(2, 3) == 5 + + +def test_a_raising_first_call_propagates_and_does_not_claim_completion(caplog): + def boom(_): + raise ValueError("compile failed") + + wrapped = log_on_first_compile(boom, "likelihood function") + + with caplog.at_level(logging.INFO, logger=LOGGER): + try: + wrapped(1) + except ValueError: + pass + + messages = [record.getMessage() for record in caplog.records] + + # The announcement is fine -- the compile really was attempted. Claiming it + # "completed in N seconds" after it raised would not be. + assert any("could take seconds or minutes" in m for m in messages) + assert not any("complete in" in m for m in messages) diff --git a/test_autofit/non_linear/test_quick_update_parameter_types.py b/test_autofit/non_linear/test_quick_update_parameter_types.py new file mode 100644 index 000000000..24b165a3d --- /dev/null +++ b/test_autofit/non_linear/test_quick_update_parameter_types.py @@ -0,0 +1,73 @@ +import numpy as np +import pytest + +import autofit as af +from autofit.non_linear.fitness import Fitness + +# `manage_quick_update` receives whatever type the calling search holds its +# parameters in. Nautilus passes an ndarray, so `self.quick_update_max_lh_parameters +# .tolist()` worked for as long as Nautilus was the only search wired up to quick +# updates. Dynesty's initializer passes a plain Python list, and the moment the +# other searches were wired up (PyAutoFit#1434) that call raised +# `AttributeError: 'list' object has no attribute 'tolist'` mid-fit. + + +class RecordingPaths: + """Captures the rendered result info instead of writing it to disk.""" + + def __init__(self): + self.results = [] + + def output_model_results(self, result_info): + self.results.append(result_info) + + +def _fitness(iterations_per_quick_update): + model = af.Model(af.ex.Gaussian) + data = np.array( + af.ex.Gaussian(centre=50.0, normalization=25.0, sigma=10.0)( + xvalues=np.arange(30) + ) + ) + analysis = af.ex.Analysis(data=data, noise_map=np.ones(30)) + fitness = Fitness( + model=model, + analysis=analysis, + iterations_per_quick_update=iterations_per_quick_update, + ) + fitness.paths = RecordingPaths() + return fitness + + +@pytest.mark.parametrize( + "parameters", + [ + [50.0, 25.0, 10.0], # Dynesty's initializer -- the case that crashed + np.array([50.0, 25.0, 10.0]), # Nautilus + (50.0, 25.0, 10.0), + ], + ids=["list", "ndarray", "tuple"], +) +def test__a_quick_update_survives_any_parameter_container(parameters): + fitness = _fitness(iterations_per_quick_update=1) + + # A cadence of 1 means this single call crosses the threshold and runs the + # whole update body, which is where the `.tolist()` call lives. + fitness.manage_quick_update(parameters=parameters, log_likelihood=-10.0) + + assert fitness.quick_update_count == 0 # reset after the update fired + + # The rendered result info is what `.tolist()` feeds, so a non-empty record + # proves the update body ran to completion rather than dying mid-way. + assert len(fitness.paths.results) == 1 + assert "centre" in fitness.paths.results[0] + + +def test__no_update_body_runs_when_the_cadence_is_not_reached(): + # Guards the other side: the update must not fire early, or the parameter + # handling above would never be the thing under test. + fitness = _fitness(iterations_per_quick_update=1000) + + fitness.manage_quick_update(parameters=[50.0, 25.0, 10.0], log_likelihood=-10.0) + + assert fitness.quick_update_count == 1