From 8cdcff3a0db471ea2976947816daae835a9f8d45 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 20:56:54 +0000 Subject: [PATCH 1/2] fix: forward autofit.plot **kwargs instead of silently discarding them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All five public `autofit.plot` functions declared `**kwargs` and never referenced it, so a caller's customization was accepted without error and had no effect. `autofit_workspace/scripts/plot/*.py` and `docs/cookbooks/search.md` both teach the idiom and state the kwargs are forwarded; that claim was false. The kwargs are now forwarded to the library each function wraps, through a strict signature filter. Plain forwarding would not have been enough: `corner.corner` funnels what it does not name into `corner.core.hist2d`, whose body reads only `extent` and discards the rest — so an unrecognised name would have stayed silently ignored one layer deeper. `checked_kwargs` validates against the target's *named* parameters (a `**kwargs` sink is deliberately not read as "accepts anything") and raises `PlotKwargsError`, a `TypeError`, naming what it rejected with a difflib hint. Also fixes a second silent defect in the same call: `corner_cornerpy` passed `weight_list=` to `corner.corner`, which has no such parameter — it is `weights`. The sample weights landed in the hist2d sink, so every weighted posterior has been plotted unweighted, with no error. - plot_util: `PlotKwargsError`, `accepted_kwarg_names`, `checked_kwargs`; `log_plot_exception` re-raises `PlotKwargsError` so a rejected kwarg is not reported as an unconverged posterior. - corner_cornerpy: `weights=` fix; library-computed `weights` / `labels` / `range` become defaults a caller overrides. A caller `None` against one of those keeps the computed value — notably for `range`, where blanking it would hand `corner` back the degenerate columns `_corner_range_from` widens. - corner_anesthetic: kwargs split between `make_2d_axes` and `plot_2d` (`figsize` / `facecolor` / `dpi` route to the figure — anesthetic takes them through its own `**fig_kw` rather than declaring them). - mle_plotters: the three trace plots forward `Line2D` properties, validated against the artist's own setters and aliases. Verified: `bins=5`, `show_titles=True` and weighting each change the rendered figure (RGBA buffer hash); degenerate-column input still renders. Full suite: 2088 passed, 36 skipped. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G8EXazf2FEqf4S9UpTwMGV --- autofit/non_linear/plot/mle_plotters.py | 43 +++++- autofit/non_linear/plot/nest_plotters.py | 40 +++++- autofit/non_linear/plot/plot_util.py | 98 +++++++++++++ autofit/non_linear/plot/samples_plotters.py | 29 +++- docs/api/plot.rst | 12 ++ docs/cookbooks/search.md | 4 +- .../non_linear/plot/test_mle_plotters.py | 92 ++++++++++++ .../non_linear/plot/test_nest_plotters.py | 80 +++++++++++ .../non_linear/plot/test_plot_util.py | 84 +++++++++++ .../non_linear/plot/test_samples_plotters.py | 136 ++++++++++++++++++ 10 files changed, 603 insertions(+), 15 deletions(-) create mode 100644 test_autofit/non_linear/plot/test_mle_plotters.py create mode 100644 test_autofit/non_linear/plot/test_nest_plotters.py create mode 100644 test_autofit/non_linear/plot/test_plot_util.py diff --git a/autofit/non_linear/plot/mle_plotters.py b/autofit/non_linear/plot/mle_plotters.py index e7df552c4..3f6d92f5e 100644 --- a/autofit/non_linear/plot/mle_plotters.py +++ b/autofit/non_linear/plot/mle_plotters.py @@ -1,4 +1,29 @@ -from autofit.non_linear.plot.plot_util import skip_in_test_mode, output_figure +from autofit.non_linear.plot.plot_util import ( + skip_in_test_mode, + output_figure, + checked_kwargs, +) + + +def _line_kwargs(kwargs, defaults): + """ + Merge caller ``kwargs`` into the trace-line defaults these plots draw with. + + The traces are plain matplotlib lines, so a caller's ``**kwargs`` are + ``Line2D`` properties. Validating against the artist's own setters (aliases + included) means a mistyped property is named here rather than surfacing as a + ``Line2D.set()`` error from deep inside matplotlib. + """ + from matplotlib.artist import ArtistInspector + from matplotlib.lines import Line2D + + inspector = ArtistInspector(Line2D) + accepts = set(inspector.get_setters()) + accepts |= {alias for aliases in inspector.aliasd.values() for alias in aliases} + + settings = dict(defaults) + settings.update(checked_kwargs(kwargs, accepts=accepts, target="matplotlib")) + return settings @skip_in_test_mode @@ -13,6 +38,8 @@ def subplot_parameters( ): import matplotlib.pyplot as plt + line_kwargs = _line_kwargs(kwargs, {"c": "k"}) + model = samples.model parameter_lists = samples.parameters_extract @@ -28,9 +55,9 @@ def subplot_parameters( parameters = parameters[int(len(parameters) / 2) :] if use_log_y: - plt.semilogy(iteration_list, parameters, c="k") + plt.semilogy(iteration_list, parameters, **line_kwargs) else: - plt.plot(iteration_list, parameters, c="k") + plt.plot(iteration_list, parameters, **line_kwargs) plt.xlabel("Iteration", fontsize=16) plt.ylabel(model.parameter_labels_with_superscripts_latex[i], fontsize=16) @@ -58,6 +85,8 @@ def log_likelihood_vs_iteration( ): import matplotlib.pyplot as plt + line_kwargs = _line_kwargs(kwargs, {"c": "k"}) + log_likelihood_list = samples.log_likelihood_list iteration_list = range(len(log_likelihood_list)) @@ -68,9 +97,9 @@ def log_likelihood_vs_iteration( plt.figure(figsize=(12, 12)) if use_log_y: - plt.semilogy(iteration_list, log_likelihood_list, c="k") + plt.semilogy(iteration_list, log_likelihood_list, **line_kwargs) else: - plt.plot(iteration_list, log_likelihood_list, c="k") + plt.plot(iteration_list, log_likelihood_list, **line_kwargs) plt.xlabel("Iteration", fontsize=16) plt.ylabel("Log Likelihood", fontsize=16) @@ -118,10 +147,12 @@ def figure_of_merit_vs_iteration( import matplotlib.pyplot as plt + line_kwargs = _line_kwargs(kwargs, {"c": "k"}) + iteration_list = range(len(fom_history)) plt.figure(figsize=(12, 12)) - plt.plot(iteration_list, fom_history, c="k") + plt.plot(iteration_list, fom_history, **line_kwargs) plt.xlabel("Step", fontsize=16) plt.ylabel("Global-Best Figure of Merit (-2 ln posterior)", fontsize=16) diff --git a/autofit/non_linear/plot/nest_plotters.py b/autofit/non_linear/plot/nest_plotters.py index 55dfe83a8..64867ed90 100644 --- a/autofit/non_linear/plot/nest_plotters.py +++ b/autofit/non_linear/plot/nest_plotters.py @@ -7,6 +7,8 @@ skip_in_test_mode, log_plot_exception, output_figure, + accepted_kwarg_names, + checked_kwargs, ) @@ -43,10 +45,41 @@ def corner_anesthetic(samples, path=None, filename="corner_anesthetic", format=" if SettingWithCopyWarning is not None: warnings.filterwarnings("ignore", category=SettingWithCopyWarning) + # ``kwargs`` splits by destination: what shapes the figure goes to + # ``make_2d_axes``, everything else styles the plot via ``plot_2d``, which + # forwards what it does not name straight to matplotlib — matplotlib raises + # on an unknown property, so unlike ``corner`` there is no silent sink here + # to guard against and the only check needed is that the caller is not + # overriding the sample array. + # + # ``figsize`` / ``facecolor`` / ``dpi`` are named explicitly because + # ``make_2d_axes`` takes them through its own ``**fig_kw`` rather than + # declaring them — including the two this function computes below, which a + # caller must be able to override. + axes_names = (accepted_kwarg_names(make_2d_axes) - {"params"}) | { + "figsize", + "facecolor", + "dpi", + } + kwargs = checked_kwargs( + kwargs, + reserved=("data", "weights", "columns"), + target="anesthetic", + ) + + axes_settings = dict(figsize=figsize, facecolor=config_dict["facecolor"]) + axes_settings.update( + {key: value for key, value in kwargs.items() if key in axes_names} + ) + + plot_settings = dict(alpha=config_dict["alpha"], label="posterior") + plot_settings.update( + {key: value for key, value in kwargs.items() if key not in axes_names} + ) + fig, axes = make_2d_axes( model.parameter_labels_with_superscripts_latex, - figsize=figsize, - facecolor=config_dict["facecolor"], + **axes_settings, ) if SettingWithCopyWarning is not None: @@ -54,8 +87,7 @@ def corner_anesthetic(samples, path=None, filename="corner_anesthetic", format=" nested_samples.plot_2d( axes, - alpha=config_dict["alpha"], - label="posterior", + **plot_settings, ) axes.iloc[-1, 0].legend( bbox_to_anchor=(len(axes) / 2, len(axes)), diff --git a/autofit/non_linear/plot/plot_util.py b/autofit/non_linear/plot/plot_util.py index 0670ca68d..8dc55a171 100644 --- a/autofit/non_linear/plot/plot_util.py +++ b/autofit/non_linear/plot/plot_util.py @@ -1,3 +1,5 @@ +import difflib +import inspect import logging import os from functools import wraps @@ -10,6 +12,98 @@ logger = logging.getLogger(__name__) +class PlotKwargsError(TypeError): + """ + Raised when a plot function is handed a ``**kwargs`` entry its underlying + plotting library cannot honour. + + A ``TypeError`` subclass so it reads like the error Python raises for an + unexpected keyword argument, while staying distinguishable from the + ``TypeError``s ``log_plot_exception`` swallows. + """ + + +def accepted_kwarg_names(*funcs): + """ + The keyword-argument names ``funcs`` genuinely honour. + + Only *named* parameters count. A function's own ``**kwargs`` is deliberately + not read as "accepts anything": ``corner.corner`` funnels everything it does + not name into ``corner.core.hist2d``, whose body reads only ``extent`` and + silently discards the rest, so treating that sink as permissive would leave + the very failure this guard exists to catch. + """ + names = set() + for func in funcs: + for name, parameter in inspect.signature(func).parameters.items(): + if parameter.kind in ( + inspect.Parameter.VAR_POSITIONAL, + inspect.Parameter.VAR_KEYWORD, + ): + continue + names.add(name) + return names + + +def checked_kwargs(kwargs, *, accepts=None, reserved=(), target): + """ + Validate caller ``kwargs`` against what ``target`` accepts, before forwarding. + + The plot functions pass their ``**kwargs`` on to ``corner`` / ``anesthetic`` + / ``matplotlib``. Anything those libraries would drop on the floor is raised + here instead, so a mis-typed or wrong-library argument fails loudly rather + than producing a figure that quietly ignores it. + + Parameters + ---------- + kwargs + The caller's keyword arguments. + accepts + Names ``target`` honours — see ``accepted_kwarg_names``. ``None`` when + ``target`` has a genuine open pass-through that raises on what it cannot + use (``anesthetic`` hands unknown names to matplotlib, which rejects + them), so only ``reserved`` is enforced. + reserved + Names this wrapper sets itself and the caller may not override (e.g. the + sample array ``corner`` is being asked to plot). + target + Human-readable name of the receiving function, for the error message. + + Raises + ------ + PlotKwargsError + If any name is reserved or is not accepted by ``target``. Unknown names + get a "did you mean" hint where a close match exists. + """ + reserved = set(reserved) + + overridden = sorted(name for name in kwargs if name in reserved) + if overridden: + raise PlotKwargsError( + f"{', '.join(overridden)} is set by PyAutoFit and cannot be " + f"overridden when forwarding to {target}." + if len(overridden) == 1 + else f"{', '.join(overridden)} are set by PyAutoFit and cannot be " + f"overridden when forwarding to {target}." + ) + + if accepts is None: + return dict(kwargs) + + unknown = sorted(name for name in kwargs if name not in accepts) + if unknown: + hints = [] + for name in unknown: + close = difflib.get_close_matches(name, sorted(accepts), n=1) + hints.append(f"{name!r}" + (f" (did you mean {close[0]!r}?)" if close else "")) + raise PlotKwargsError( + f"{target} does not accept {', '.join(hints)}. These would be " + f"silently ignored, so they are rejected instead." + ) + + return dict(kwargs) + + def skip_in_test_mode(func): @wraps(func) def wrapper(*args, **kwargs): @@ -25,6 +119,10 @@ def log_plot_exception(func): def wrapper(*args, **kwargs): try: return func(*args, **kwargs) + except PlotKwargsError: + # A rejected kwarg is the caller's mistake, not an unconverged + # posterior — never downgrade it to the info log below. + raise except ( ValueError, KeyError, diff --git a/autofit/non_linear/plot/samples_plotters.py b/autofit/non_linear/plot/samples_plotters.py index c4fd4d00a..2a2c1500c 100644 --- a/autofit/non_linear/plot/samples_plotters.py +++ b/autofit/non_linear/plot/samples_plotters.py @@ -4,7 +4,12 @@ from autonerves import conf -from autofit.non_linear.plot.plot_util import skip_in_test_mode, output_figure +from autofit.non_linear.plot.plot_util import ( + skip_in_test_mode, + output_figure, + accepted_kwarg_names, + checked_kwargs, +) logger = logging.getLogger(__name__) @@ -50,12 +55,28 @@ def corner_cornerpy(samples, path=None, filename="corner", format="show", **kwar pylab.rcParams.update(params) import corner + from corner.core import hist2d as corner_hist2d - corner.corner( - data=data, - weight_list=samples.weight_list, + settings = dict( + weights=samples.weight_list, labels=samples.model.parameter_labels_with_superscripts_latex, range=_corner_range_from(data), ) + # ``hist2d``'s named parameters are a documented corner pass-through, so they + # stay valid; ``x`` / ``y`` are its positional sample arrays, not options. + accepts = accepted_kwarg_names(corner.corner, corner_hist2d) - {"x", "y"} + + for key, value in checked_kwargs( + kwargs, accepts=accepts, reserved=("data",), target="corner.corner" + ).items(): + # ``None`` against a value PyAutoFit computes means "use the default" — + # notably for ``range``, where blanking it would hand ``corner`` back the + # degenerate columns ``_corner_range_from`` exists to widen. + if value is None and key in settings: + continue + settings[key] = value + + corner.corner(data=data, **settings) + output_figure(path=path, filename=filename, format=format) diff --git a/docs/api/plot.rst b/docs/api/plot.rst index 3849dcef4..0b8498dde 100644 --- a/docs/api/plot.rst +++ b/docs/api/plot.rst @@ -14,6 +14,18 @@ The plotting API is **functional**: import ``autofit.plot`` and call a plot func aplt.corner_cornerpy(samples=result.samples) +Every plot function forwards its ``**kwargs`` to the library it wraps (``corner.py``, ``anesthetic``, +``matplotlib``), so the wrapped library's own options are available directly: + +.. code-block:: python + + aplt.corner_cornerpy(samples=result.samples, bins=5, show_titles=True) + +Values **PyAutoFit** computes — the parameter labels, the sample weights, and the plot range that keeps +``corner.py`` from raising "no dynamic range" on a converged parameter — act as defaults a keyword argument +overrides. A name the wrapped library does not accept raises a ``TypeError`` naming it, rather than being +silently ignored. + **Examples / Tutorials:** - `readthedocs: non-linear search example `_ diff --git a/docs/cookbooks/search.md b/docs/cookbooks/search.md index 1924803d6..ec011007f 100644 --- a/docs/cookbooks/search.md +++ b/docs/cookbooks/search.md @@ -155,7 +155,9 @@ search = af.Emcee(number_of_cores=4) The results of a model-fit are plotted and inspected via the plot functions of `autofit.plot`, which wrap the in-built visualization libraries of the searches. These are fully described in the `plot` package of the workspace. -For example, `corner_cornerpy` wraps `corner.py` and is used as follows. +For example, `corner_cornerpy` wraps `corner.py` and is used as follows. Any keyword argument `corner.py` +accepts can be passed straight through — they are forwarded to `corner.corner`, and a name `corner.py` does not +recognise raises a `TypeError` naming it rather than being silently ignored. Checkout the `plot` package for a complete description of the plots that can be made for a given search. diff --git a/test_autofit/non_linear/plot/test_mle_plotters.py b/test_autofit/non_linear/plot/test_mle_plotters.py new file mode 100644 index 000000000..b53871bbe --- /dev/null +++ b/test_autofit/non_linear/plot/test_mle_plotters.py @@ -0,0 +1,92 @@ +import matplotlib +import numpy as np +import pytest + +matplotlib.use("Agg") + +from autofit.non_linear.plot import mle_plotters +from autofit.non_linear.plot.plot_util import PlotKwargsError + + +class MockModel: + def __init__(self, labels): + self.parameter_labels_with_superscripts_latex = labels + self.total_free_parameters = len(labels) + + +class MockSamples: + def __init__(self): + rng = np.random.default_rng(0) + self.model = MockModel(["x", "y"]) + self.parameters_extract = rng.normal(size=(2, 30)).tolist() + self.log_likelihood_list = rng.normal(size=30).tolist() + self.samples_info = {"fom_history": rng.random(30).tolist()} + + +@pytest.fixture +def samples(): + return MockSamples() + + +@pytest.fixture(autouse=True) +def no_output(monkeypatch): + monkeypatch.setattr(mle_plotters, "output_figure", lambda *args, **kwargs: None) + + +@pytest.mark.parametrize( + "plot", + [ + mle_plotters.subplot_parameters, + mle_plotters.log_likelihood_vs_iteration, + mle_plotters.figure_of_merit_vs_iteration, + ], +) +def test__line_kwarg_is_forwarded_to_the_trace(plot, samples): + plot(samples=samples, linewidth=3.0) + + +@pytest.mark.parametrize( + "plot", + [ + mle_plotters.subplot_parameters, + mle_plotters.log_likelihood_vs_iteration, + mle_plotters.figure_of_merit_vs_iteration, + ], +) +def test__unknown_line_kwarg_is_rejected_and_named(plot, samples): + with pytest.raises(PlotKwargsError) as error: + plot(samples=samples, yticksize=16) + + assert "yticksize" in str(error.value) + + +def test__caller_colour_overrides_the_default_black_trace(samples, monkeypatch): + import matplotlib.pyplot as plt + + captured = {} + + def fake_plot(*args, **kwargs): + captured.update(kwargs) + return [] + + monkeypatch.setattr(plt, "plot", fake_plot) + + mle_plotters.log_likelihood_vs_iteration(samples=samples, c="red") + + assert captured["c"] == "red" + + +def test__default_trace_colour_is_black_when_no_kwarg_given(samples, monkeypatch): + import matplotlib.pyplot as plt + + captured = {} + + def fake_plot(*args, **kwargs): + captured.update(kwargs) + return [] + + monkeypatch.setattr(plt, "plot", fake_plot) + + mle_plotters.log_likelihood_vs_iteration(samples=samples) + + assert captured["c"] == "k" diff --git a/test_autofit/non_linear/plot/test_nest_plotters.py b/test_autofit/non_linear/plot/test_nest_plotters.py new file mode 100644 index 000000000..94c811e5e --- /dev/null +++ b/test_autofit/non_linear/plot/test_nest_plotters.py @@ -0,0 +1,80 @@ +import matplotlib +import numpy as np +import pytest + +matplotlib.use("Agg") + +from autofit.non_linear.plot import nest_plotters +from autofit.non_linear.plot.plot_util import PlotKwargsError + + +class MockModel: + def __init__(self, labels): + self.parameter_labels_with_superscripts_latex = labels + self.total_free_parameters = len(labels) + + +class MockSamples: + def __init__(self): + rng = np.random.default_rng(0) + self.model = MockModel(["x", "y"]) + self.parameter_lists = rng.normal(size=(60, 2)).tolist() + self.weight_list = rng.random(60).tolist() + + +@pytest.fixture +def samples(): + return MockSamples() + + +@pytest.fixture(autouse=True) +def no_output(monkeypatch): + monkeypatch.setattr(nest_plotters, "output_figure", lambda *args, **kwargs: None) + + +@pytest.fixture +def routed(monkeypatch): + """Capture which of anesthetic's two entry points each kwarg reached.""" + import anesthetic + from anesthetic.samples import NestedSamples + + captured = {"axes": {}, "plot": {}} + + real_make_2d_axes = anesthetic.make_2d_axes + + def fake_make_2d_axes(params, **kwargs): + captured["axes"].update(kwargs) + return real_make_2d_axes(params) + + def fake_plot_2d(self, axes=None, **kwargs): + captured["plot"].update(kwargs) + + monkeypatch.setattr(anesthetic, "make_2d_axes", fake_make_2d_axes) + monkeypatch.setattr(NestedSamples, "plot_2d", fake_plot_2d) + return captured + + +def test__corner_anesthetic__axes_kwarg_is_routed_to_the_figure(samples, routed): + # `figsize` is named by `make_2d_axes`, so it shapes the figure rather than + # being handed to `plot_2d`. + nest_plotters.corner_anesthetic(samples=samples, figsize=(4, 4)) + + assert routed["axes"]["figsize"] == (4, 4) + assert "figsize" not in routed["plot"] + + +def test__corner_anesthetic__style_kwarg_is_routed_to_the_plot(samples, routed): + nest_plotters.corner_anesthetic(samples=samples, alpha=0.25) + + assert routed["plot"]["alpha"] == 0.25 + assert "alpha" not in routed["axes"] + + +def test__corner_anesthetic__reserved_kwarg_is_not_swallowed_by_the_decorator(samples): + # `corner_anesthetic` is wrapped in `@log_plot_exception`, which catches + # `TypeError`. Without the `PlotKwargsError` re-raise this would be reported + # as an unconverged posterior and the caller would never see their mistake. + with pytest.raises(PlotKwargsError) as error: + nest_plotters.corner_anesthetic(samples=samples, weights=[1.0]) + + assert "weights" in str(error.value) diff --git a/test_autofit/non_linear/plot/test_plot_util.py b/test_autofit/non_linear/plot/test_plot_util.py new file mode 100644 index 000000000..f1893f97a --- /dev/null +++ b/test_autofit/non_linear/plot/test_plot_util.py @@ -0,0 +1,84 @@ +import pytest + +from autofit.non_linear.plot.plot_util import ( + PlotKwargsError, + accepted_kwarg_names, + checked_kwargs, + log_plot_exception, +) + + +def target(alpha=None, beta=None, *args, **kwargs): + pass + + +def test__accepted_kwarg_names__ignores_the_var_keyword_sink(): + # `corner.corner` funnels what it does not name into `hist2d`, which reads + # only `extent` — so a `**kwargs` must never be read as "accepts anything". + assert accepted_kwarg_names(target) == {"alpha", "beta"} + + +def test__accepted_kwarg_names__unions_across_targets(): + def other(gamma=None): + pass + + assert accepted_kwarg_names(target, other) == {"alpha", "beta", "gamma"} + + +def test__checked_kwargs__accepted_names_pass_through(): + assert checked_kwargs( + {"alpha": 1}, accepts={"alpha", "beta"}, target="target" + ) == {"alpha": 1} + + +def test__checked_kwargs__unknown_name_is_rejected_and_named(): + with pytest.raises(PlotKwargsError) as error: + checked_kwargs({"gamma": 1}, accepts={"alpha", "beta"}, target="target") + + assert "gamma" in str(error.value) + assert "target" in str(error.value) + + +def test__checked_kwargs__close_match_is_offered_as_a_hint(): + with pytest.raises(PlotKwargsError) as error: + checked_kwargs({"alpah": 1}, accepts={"alpha"}, target="target") + + assert "did you mean 'alpha'?" in str(error.value) + + +def test__checked_kwargs__reserved_name_is_rejected(): + with pytest.raises(PlotKwargsError) as error: + checked_kwargs( + {"alpha": 1}, accepts={"alpha"}, reserved=("alpha",), target="target" + ) + + assert "cannot be overridden" in str(error.value) + + +def test__checked_kwargs__open_pass_through_enforces_reserved_only(): + # `accepts=None` is for a target that raises on what it cannot use, so only + # the arguments PyAutoFit owns are guarded. + assert checked_kwargs({"anything": 1}, target="target") == {"anything": 1} + + with pytest.raises(PlotKwargsError): + checked_kwargs({"data": 1}, reserved=("data",), target="target") + + +def test__log_plot_exception__swallows_an_unconverged_posterior_error(): + @log_plot_exception + def plot(): + raise ValueError("not enough samples") + + assert plot() is None + + +def test__log_plot_exception__never_swallows_a_rejected_kwarg(): + # A `PlotKwargsError` is a `TypeError`, which this decorator catches. Left + # unguarded it would surface as "posterior estimate not yet sufficient", + # hiding the caller's mistake behind a misleading info log. + @log_plot_exception + def plot(): + raise PlotKwargsError("bad kwarg") + + with pytest.raises(PlotKwargsError): + plot() diff --git a/test_autofit/non_linear/plot/test_samples_plotters.py b/test_autofit/non_linear/plot/test_samples_plotters.py index 7ca9c2773..2beec0366 100644 --- a/test_autofit/non_linear/plot/test_samples_plotters.py +++ b/test_autofit/non_linear/plot/test_samples_plotters.py @@ -1,5 +1,8 @@ import numpy as np +import pytest +from autofit.non_linear.plot import samples_plotters +from autofit.non_linear.plot.plot_util import PlotKwargsError from autofit.non_linear.plot.samples_plotters import _corner_range_from @@ -41,3 +44,136 @@ def test__corner_range__degenerate_columns_widened_to_nonzero_span(): # The real-spread column is left untouched. assert plot_range[3] == (1.0, 3.0) + + +class MockModel: + def __init__(self, labels): + self.parameter_labels_with_superscripts_latex = labels + self.total_free_parameters = len(labels) + + +class MockSamples: + def __init__(self, parameter_lists, weight_list=None): + self.parameter_lists = parameter_lists + self.weight_list = ( + weight_list if weight_list is not None else [1.0] * len(parameter_lists) + ) + self.model = MockModel(["x", "y"]) + + +@pytest.fixture +def samples(): + rng = np.random.default_rng(0) + return MockSamples( + parameter_lists=rng.normal(size=(50, 2)).tolist(), + weight_list=rng.random(50).tolist(), + ) + + +@pytest.fixture +def corner_call(monkeypatch): + """Capture the arguments `corner_cornerpy` hands to `corner.corner`.""" + import corner + import functools + + captured = {} + + # `functools.wraps` keeps the real signature reachable via `__wrapped__`, so + # the guard still validates against corner's genuine parameter list rather + # than against this stub's bare `**kwargs`. + @functools.wraps(corner.corner) + def fake_corner(**kwargs): + captured.update(kwargs) + + monkeypatch.setattr(corner, "corner", fake_corner) + monkeypatch.setattr( + samples_plotters, "output_figure", lambda *args, **kwargs: None + ) + return captured + + +def test__corner_cornerpy__weights_reach_corner_under_its_own_name( + samples, corner_call +): + # Regression: the call passed `weight_list=`, which `corner.corner` does not + # name — it landed in the hist2d sink and every weighted posterior was + # plotted unweighted, with no error. + samples_plotters.corner_cornerpy(samples=samples) + + assert "weight_list" not in corner_call + assert corner_call["weights"] == samples.weight_list + + +def test__corner_cornerpy__caller_kwarg_is_forwarded(samples, corner_call): + samples_plotters.corner_cornerpy(samples=samples, bins=5, show_titles=True) + + assert corner_call["bins"] == 5 + assert corner_call["show_titles"] is True + + +def test__corner_cornerpy__hist2d_pass_through_kwarg_is_accepted(samples, corner_call): + # `plot_datapoints` is named by `corner.core.hist2d`, not `corner.corner` — + # a documented pass-through, so it must survive the guard. + samples_plotters.corner_cornerpy(samples=samples, plot_datapoints=False) + + assert corner_call["plot_datapoints"] is False + + +def test__corner_cornerpy__unknown_kwarg_raises_naming_it(samples, corner_call): + with pytest.raises(PlotKwargsError) as error: + samples_plotters.corner_cornerpy(samples=samples, panelsize=3.5) + + assert "panelsize" in str(error.value) + + +def test__corner_cornerpy__wrong_library_kwarg_hints_the_right_name( + samples, corner_call +): + # What `autofit_workspace/scripts/plot/zeus_plotter.py` passed: zeus's name + # for the weights. It must not be silently dropped, and the error should + # point at corner's spelling. + with pytest.raises(PlotKwargsError) as error: + samples_plotters.corner_cornerpy(samples=samples, weight_list=None) + + assert "weight_list" in str(error.value) + assert "weights" in str(error.value) + + +def test__corner_cornerpy__reserved_kwarg_cannot_be_overridden(samples, corner_call): + with pytest.raises(PlotKwargsError) as error: + samples_plotters.corner_cornerpy(samples=samples, data=[[1.0, 2.0]]) + + assert "data" in str(error.value) + + +def test__corner_cornerpy__range_none_keeps_the_degenerate_column_guard( + samples, corner_call +): + # `emcee_plotter.py` passes `range=None`. Honouring that literally would hand + # `corner` back the degenerate columns `_corner_range_from` exists to widen. + samples_plotters.corner_cornerpy(samples=samples, range=None) + + assert corner_call["range"] == _corner_range_from( + np.asarray(samples.parameter_lists) + ) + + +def test__corner_cornerpy__explicit_range_wins(samples, corner_call): + samples_plotters.corner_cornerpy(samples=samples, range=[(0.0, 1.0), (0.0, 1.0)]) + + assert corner_call["range"] == [(0.0, 1.0), (0.0, 1.0)] + + +def test__corner_cornerpy__degenerate_columns_still_render(monkeypatch): + # The real `corner`, not the fake: constant columns must not reintroduce the + # "no dynamic range" crash now that `range` is caller-overridable. + import matplotlib + + matplotlib.use("Agg") + monkeypatch.setattr( + samples_plotters, "output_figure", lambda *args, **kwargs: None + ) + + degenerate = MockSamples(parameter_lists=[[1.0, 5.0]] * 20) + + samples_plotters.corner_cornerpy(samples=degenerate, bins=5) From 5acbd83fc39c8ff550c80e75aa88566283a0751c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 21:02:18 +0000 Subject: [PATCH 2/2] Export PlotKwargsError from autofit.plot A caller told their kwarg "raises PlotKwargsError" should be able to catch it as `aplt.PlotKwargsError`, without reaching into `autofit.non_linear`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G8EXazf2FEqf4S9UpTwMGV --- autofit/plot/__init__.py | 2 +- test_autofit/non_linear/plot/test_plot_util.py | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/autofit/plot/__init__.py b/autofit/plot/__init__.py index d581e9dd7..e72ba0e44 100644 --- a/autofit/plot/__init__.py +++ b/autofit/plot/__init__.py @@ -1,4 +1,4 @@ from autofit.non_linear.plot.samples_plotters import corner_cornerpy from autofit.non_linear.plot.nest_plotters import corner_anesthetic from autofit.non_linear.plot.mle_plotters import subplot_parameters, log_likelihood_vs_iteration -from autofit.non_linear.plot.plot_util import output_figure +from autofit.non_linear.plot.plot_util import output_figure, PlotKwargsError diff --git a/test_autofit/non_linear/plot/test_plot_util.py b/test_autofit/non_linear/plot/test_plot_util.py index f1893f97a..004e3a165 100644 --- a/test_autofit/non_linear/plot/test_plot_util.py +++ b/test_autofit/non_linear/plot/test_plot_util.py @@ -82,3 +82,12 @@ def plot(): with pytest.raises(PlotKwargsError): plot() + + +def test__plot_kwargs_error__is_importable_from_the_public_plot_module(): + # A caller told their kwarg "raises PlotKwargsError" needs to be able to + # catch it without reaching into `autofit.non_linear`. + import autofit.plot as aplt + + assert aplt.PlotKwargsError is PlotKwargsError + assert issubclass(aplt.PlotKwargsError, TypeError)