Skip to content

🎉 Release: merge develop into master - #243

Merged
henrikjacobsenfys merged 6 commits into
masterfrom
develop
Aug 17, 2026
Merged

🎉 Release: merge develop into master#243
henrikjacobsenfys merged 6 commits into
masterfrom
develop

Conversation

@easyscience

@easyscience easyscience Bot commented Aug 17, 2026

Copy link
Copy Markdown

This PR is created automatically to trigger the release pipeline. It merges the accumulated changes from develop into master.

⚠️ It is labeled [bot] release and is excluded from release notes and version bump logic.

easyscience[bot] and others added 6 commits August 11, 2026 08:07
* Add Bayesian posterior sampling to Analysis1d

Expose the EasyScience Fitter on Analysis1d and add MCMC posterior
sampling on top of it, using the BUMPS DREAM sampler introduced in
easyscience 2.5.1 (easyscience.fitting.Sampler).

Least-squares fitting reports a single point with a curvature-derived
uncertainty, which is only trustworthy when parameters are uncorrelated
and roughly Gaussian. Sampling maps the whole posterior instead, so
correlated and skewed parameters get honest credible intervals.

The sampling machinery lives in a mixin with three hooks (build the
fitter, bind the data, list the chain parameters) so that Analysis and
ParameterAnalysis can reuse it. ParameterAnalysis is not an AnalysisBase
and builds a MultiFitter over binding models rather than over itself,
so a shared base class would not have worked.

Notable details:

- fit() now uses a cached Fitter instead of building one per call, and
  the cache is invalidated through the existing dirty-flag pattern.
- Bounds are the prior in DREAM, so sampling refuses to run with any
  infinite bound. suggest_bounds() proposes finite ones from the fitted
  values and uncertainties; it is advisory until .apply() is called and
  never loosens a bound that is already finite, so physical limits
  survive. A zero-width suggestion is flagged rather than invented.
- Sampling restores parameter values afterwards, since BUMPS leaves them
  wherever the last likelihood evaluation put them.
- Chains are reported under Parameter.name, not the internal unique_name.
  Those names are per-session, so save_chain() writes a sidecar mapping
  them to stable names and load_chain() uses it; loading without one
  warns rather than mislabelling the columns.
- After sampling, a warning fires when the posterior has piled up against
  a bound, which catches both bounds that are too tight and degenerate
  parameters that drift until a bound stops them.
- BUMPS crashes with a bare IndexError inside its own outlier removal
  when chains scatter, which in practice means a degenerate model. That
  is re-raised with the likely cause and a workaround.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Compose the posterior sampler instead of mixing it in

Review feedback: bayesian_sampling.py had a lot in it that belonged
elsewhere, and it was unclear why it was a mixin at all.

It was a mixin because ParameterAnalysis is not an AnalysisBase and fits
its binding models rather than itself, so a shared base class does not
work. That was a reason, not a good one: it injected some forty methods
into every Analysis class.

The sampler is now composed. An Analysis exposes one `bayesian` property,
and hands the sampler the few things that differ between the Analysis
classes -- the data, the free parameters, their labels, and a hook to
refresh cached computation -- so PosteriorSampler needs no knowledge of
how any Analysis is built, and no Analysis inherits sampling machinery it
does not use.

Labelling moves to posterior_labels.py. Building it once for a fixed set
of parameters also removes the quadratic cost the old code needed a
scoped cache to avoid: the counts and lookups are computed in the
constructor rather than per column.

Plotting stays in posterior_plotting.py, where it already lived. The
sampler keeps three short delegates so a chain can still be plotted from
the object holding it, but none of the drawing happens there.

The public API becomes analysis.bayesian.sample() and friends, and the
explicit suggest_bounds().apply() step stays: in DREAM the bounds are the
prior, and an unbounded parameter gives a confident-looking interval set
by nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Warm the tutorial data cache before running notebooks in parallel

The notebook tests run with '-n auto', and five of the notebooks fetch
vanadium_data_example.h5 through pooch. On a cold cache the workers race:
one is still writing the file into the cache while another opens it,
which fails on Windows with "PermissionError: Permission denied". This
failed twice in a row on windows-latest, always on that file, always
with the other sixteen notebooks passing.

The race is pre-existing, but adding a fifth notebook that wants the same
file, and lengthening tutorial 1, made it reliable rather than rare.

Fetching every tutorial data file once, before the parallel run starts,
leaves the workers with nothing to do but read, which is safe. The
prefetch reads the URLs and hashes out of the notebooks themselves, so it
cannot drift from what they actually download, and it never fails the
run: a file it cannot fetch is left to the notebook that needs it, which
reports the problem with far more context.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 46d745a)

* Mark setup, action and expectation apart in the new tests

The sampling tests labelled the action WHEN and had no THEN, so a reader
could not see where the arrangement stopped and the call under test
began. Setup is WHEN, the action is THEN, the assertions are EXPECT, and
steps that genuinely collapse onto one statement carry one combined
marker instead.

Comments only; no test changed what it does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Give the sampler its own test file

Tests were split by feature rather than by the file they exercise, so
posterior_sampling.py had no test file of its own and Analysis1d had
two. The sampler's tests now live in test_posterior_sampling.py under
one TestPosteriorSampler, with the old class names as section banners,
and the four tests that are really about Analysis1d's cached fitter move
into TestAnalysis1d.

No test changed what it does; the same 31 + 4 tests run as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Refuse silent chain corruption and harden the posterior sampler

- extend() now verifies the chain holds the same parameters, not just
  the same number, and refuses to resume after a failed run or after
  the model or data changed
- Parameter objects passed to sample(parameters=...) are validated
  against the free set the same way strings are
- sampling with no free parameters and degenerate (min >= max) bounds
  raise clear errors before reaching BUMPS
- parameters_at_bounds keys by unique_name so same-named per-Q
  parameters no longer collide, and guards empty draws
- suggest_bounds flags non-finite fitted uncertainties for attention
- save() refuses to write an empty label sidecar; loading one warns
  like a missing sidecar
- colliding display labels get positional suffixes in the sidecar so
  save/load resolves each column to its own parameter
- plot_posterior_predictive omits error bars when the data carries no
  variances (new Experiment.has_variances)
- posterior plots validate draws/logp up front, name NaN columns, and
  share x-limits per corner column
- document that sampling runs are not seedable

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add marginal posteriors, correlation heatmaps and sampling progress

- plot_marginal(parameter) renders one parameter's posterior histogram
  with the median and the 16/84 percentile interval summary() reports,
  resolving labels the same way sample(parameters=...) does
- plot_correlations() renders the Pearson correlation matrix of the
  chain with annotated cells, a diverging colormap and masked cells for
  constant columns
- sample(progress=True) and extend(progress=True) report sampling
  progress through the Sampler's progress_callback, closing the line
  with an explicit done marker because BUMPS' own step estimate assumes
  the wrong chain count
- the 95 percent predictive band needed no change: credible_interval
  already exists on plot_posterior_predictive

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Write the progress line through sys.stdout

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Apply the formatting fixes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Satisfy the docstring and formatting checks

The progress reporter closes through try/finally instead of a bare
re-raise, and the plotting validation errors are documented in the
form the docstring linter expects.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Add Bayesian posterior sampling to Analysis1d

Expose the EasyScience Fitter on Analysis1d and add MCMC posterior
sampling on top of it, using the BUMPS DREAM sampler introduced in
easyscience 2.5.1 (easyscience.fitting.Sampler).

Least-squares fitting reports a single point with a curvature-derived
uncertainty, which is only trustworthy when parameters are uncorrelated
and roughly Gaussian. Sampling maps the whole posterior instead, so
correlated and skewed parameters get honest credible intervals.

The sampling machinery lives in a mixin with three hooks (build the
fitter, bind the data, list the chain parameters) so that Analysis and
ParameterAnalysis can reuse it. ParameterAnalysis is not an AnalysisBase
and builds a MultiFitter over binding models rather than over itself,
so a shared base class would not have worked.

Notable details:

- fit() now uses a cached Fitter instead of building one per call, and
  the cache is invalidated through the existing dirty-flag pattern.
- Bounds are the prior in DREAM, so sampling refuses to run with any
  infinite bound. suggest_bounds() proposes finite ones from the fitted
  values and uncertainties; it is advisory until .apply() is called and
  never loosens a bound that is already finite, so physical limits
  survive. A zero-width suggestion is flagged rather than invented.
- Sampling restores parameter values afterwards, since BUMPS leaves them
  wherever the last likelihood evaluation put them.
- Chains are reported under Parameter.name, not the internal unique_name.
  Those names are per-session, so save_chain() writes a sidecar mapping
  them to stable names and load_chain() uses it; loading without one
  warns rather than mislabelling the columns.
- After sampling, a warning fires when the posterior has piled up against
  a bound, which catches both bounds that are too tight and degenerate
  parameters that drift until a bound stops them.
- BUMPS crashes with a bare IndexError inside its own outlier removal
  when chains scatter, which in practice means a degenerate model. That
  is re-raised with the likely cause and a workaround.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Add Bayesian posterior sampling to Analysis and ParameterAnalysis

Extends the sampling introduced for Analysis1d to the remaining two
Analysis classes, using the mixin hooks added with it. No new sampling
machinery: each class supplies its fitter, its data, and its chain
parameters, and everything else is shared.

Analysis gains sample_posterior(fit_method=...), mirroring fit():

- 'independent' gives each Q index its own chain, delegating to the
  Analysis1d objects, and returns one result per Q (or a single result
  when a Q_index is given).
- 'simultaneous' runs one chain over every Q at once through a
  MultiFitter, refreshing each per-Q convolver against its masked energy
  grid first, exactly as the simultaneous fit does.

ParameterAnalysis samples the binding models. Its fit() built the
MultiFitter inline, so the per-target data, functions, and models are
now resolved by a shared _build_fit_inputs() that both paths use, which
also guarantees fitting and sampling see the same targets in the same
order with the same unit conversions.

Parameter labels needed rethinking. A multi-Q analysis holds one copy of
each parameter per Q, all sharing a name, so a summary showed several
identical rows and a name could not pick a parameter out. Labels are now
produced by an overridable parameter_label(): Analysis qualifies by Q
index, ParameterAnalysis by binding model, and both only when the bare
name is actually ambiguous, so single-Q and single-binding cases keep
their short names. The summary and bounds tables size themselves to the
longest label rather than truncating.

Also fixes Analysis.fit's docstring, which promised a single FitResults
for a simultaneous fit. MultiFitter splits its combined result back up
by dataset, so a list has always been returned.

Tutorial 1 gains a Bayesian section on the two-step diffusion fit, where
the posterior turns out to be about twelve times tighter than the
reported least-squares uncertainties. That gap is real and worth
explaining: the width fit has a reduced chi-squared near 150, so lmfit
inflates its uncertainties by the square root of that, while the sampler
takes the stated uncertainties at face value. Sampling the full
simultaneous diffusion model was measured at over ten minutes, so the
tutorial uses the ParameterAnalysis step instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Label the posterior plot axes with units and quantities

The summary table already reported each parameter's unit, but the plots
did not, so a diffusion coefficient came out as a bare number. Units are
now threaded through to plot_trace and plot_corner, and the posterior
predictive plot gets axis labels taken from the analysis' own energy and
intensity units.

Details that needed care:

- Matplotlib parks a shared exponent at the end of the axis, on top of
  the axis label. It is now folded into the label, sharing one set of
  parentheses with the unit, so a diffusion coefficient reads
  "diffusion_coefficient (1e-8 m^2/s)" rather than stacking two
  parentheticals or overlapping.
- Dimensionless and empty units are skipped. A polynomial coefficient
  labelled "dimensionless" is noise.
- The top-left panel of a corner plot is a histogram, so its vertical
  axis counts draws rather than carrying a parameter. It is now labelled
  "counts" instead of being left blank, which read as an omission.
- Corner tick counts are capped, since four labelled ticks per panel is
  as much as a small panel can carry legibly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Qualify parameter labels by model name, and cover the remaining branches

Two fixes found by writing the tests codecov asked for.

ParameterAnalysis qualified an ambiguous parameter with the owning
model's display_name, but for several models -- the diffusion models
among them -- display_name is the class name, so two models constructed
as name='Diffusion A' and name='Diffusion B' both came back as
"BrownianTranslationalDiffusion" and the label did not disambiguate
anything. It now uses the model's name, matching the choice to report
parameters under their name rather than their display name, and falls
back to the unique name only when the names collide too.

The rest is test coverage for branches that were reachable but untested:
the label fallbacks, the BUMPS outlier crash being re-raised as a
degeneracy hint, a chain column that matches no parameter, loading a
chain through its sidecar, the mixin's unimplemented hooks, and the
scientific-notation exponent being folded into an axis label.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Warm the tutorial data cache before running notebooks in parallel

The notebook tests run with '-n auto', and five of the notebooks fetch
vanadium_data_example.h5 through pooch. On a cold cache the workers race:
one is still writing the file into the cache while another opens it,
which fails on Windows with "PermissionError: Permission denied". This
failed twice in a row on windows-latest, always on that file, always
with the other sixteen notebooks passing.

The race is pre-existing, but adding a fifth notebook that wants the same
file, and lengthening tutorial 1, made it reliable rather than rare.

Fetching every tutorial data file once, before the parallel run starts,
leaves the workers with nothing to do but read, which is safe. The
prefetch reads the URLs and hashes out of the notebooks themselves, so it
cannot drift from what they actually download, and it never fails the
run: a file it cannot fetch is left to the notebook that needs it, which
reports the problem with far more context.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Rebuild the fitter when a binding changes shape, and stabilise the integration tests

Two problems found while reviewing the previous commits.

Caching the MultiFitter on ParameterAnalysis introduced a regression. A
FitBinding can be edited in place -- binding.targets = ... -- which
ParameterAnalysis cannot observe. Changing the number of targets left the
cached fitter holding one fit function against two datasets, and fit()
died with "FitError: list index out of range". It rebuilt every call
before, so this worked previously. The targets the fitter was built for
are now recorded and compared, which is enough to catch an edit that
cannot be observed directly.

The integration tests then failed in CI on macOS, inside BUMPS' outlier
removal, on an identifiable model. That matters beyond the test: the
error message claimed the crash means degenerate parameters, and this
shows short chains do it too. The message now names both causes, and the
integration tests switch the outlier removal off, as they already do for
the burn-point trimming.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Address the review findings on the sampling API

Six issues found reviewing the previous commits.

The sidecar could be written with the wrong labels. A subset run built
the name map inside the block that holds the other parameters fixed,
where nothing looks ambiguous, so a multi-Q chain recorded unqualified
names that no longer matched on reload. The map is now built outside that
block, where the free set is the user's real one.

extend_sampling() accepted a different parameter subset. BUMPS resumes
from a stored chain whose width is fixed, so that could only fail deep
inside the sampler; it is now refused up front.

The IndexError relabelling was unconditional, so an IndexError from this
package would have been reported as a BUMPS modelling problem. It now
only applies when the traceback passes through bumps.

Labelling a chain was quadratic in the parameter count: collecting the
parameters and scanning for their owner both happened per parameter, and
each walks every sub-model. 75 parameters took 0.39 s, and every summary
and plot pays it. The parameters are now collected once per pass, and
Analysis keeps an owner index alongside its analysis list. The same case
now measures at 0.00 s.

Asking an Analysis for a summary after sampling independently reported
that nothing had been sampled, moments after it had. It now says where
the chains actually are.

Applying bounds many orders of magnitude wider than the parameter is
still allowed -- it is what the fit implied -- but no longer silent, so a
scripted apply() cannot hide a degeneracy the table would have shown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Cover the review fixes, and drop a redundant guard

Three lines the review fixes added were not reachable from the unit
tests. Two are now covered: extending after a run that died before
storing results, where the chain-shape guard has nothing to compare
against, and a parameter shared across every Q index, which is left out
of the owner map because no single Q identifies it.

The third was the non-finite check in the absurd-width test, and it was
redundant rather than untested: an infinite width already compares
greater than any threshold, and the zero-scale case returns before it.
Removed, so the behaviour is unchanged and there is no dead branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Gather the per-Q chains on Analysis after independent sampling

Sampling with fit_method='independent' left the results only on the
Analysis1d objects, so the Analysis that produced them could not report
on them. It now gathers them, but only where gathering is sound.

posterior_summary() collects every Q into one table, labelled by Q index,
and set_parameters_to_posterior_median() applies each chain to its own Q.
Both are per-parameter marginal operations, and a marginal is well
defined within its own chain, so combining them across separate chains
says nothing that was not sampled.

plot_corner() deliberately does not aggregate. Independent sampling draws
each Q separately, so no draw pairs a parameter at one Q with a parameter
at another, and a corner plot built from them would show correlations
that are an artefact of how the sampling was run rather than anything
measured. It says so and points at the per-Q corner plots, which are
real. plot_trace() likewise, the chains being separate runs of different
lengths rather than one trace.

posterior_results exposes the per-Q chains directly, and a simultaneous
chain still takes precedence over stale per-Q ones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Step through the per-Q corner plots with a slider

Independent chains share no draws, so there is no joint distribution
across Q to plot, and combining them would show correlations that came
from how the sampling was run rather than from the data. Refusing
outright was correct but unhelpful: the correlations within each Q are
real and worth looking at.

Analysis.plot_corner() now shows one Q at a time. Pass Q_index for a
particular one, or leave it out in a notebook for a slider across the Q
values that were sampled. A simultaneous chain is unaffected; it already
covers every Q in one figure. Outside a notebook the error names the
sampled Q indices rather than only saying no.

The slider is built with append_display_data rather than the Output
widget's context manager. The context manager is the obvious choice and
captures nothing under some kernels, which would have shipped a slider
with a permanently blank panel beside it. Verified by executing a
notebook against a real kernel, and the test asserts the panel actually
holds a figure, since an empty panel is the regression that matters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Show the per-Q corner slider in the Bayesian tutorial

The slider was described in the tutorial's caveats but never
demonstrated: every notebook call to plot_corner() went through the
single-chain path, because the Bayesian tutorial used Analysis1d and
tutorial 1 used ParameterAnalysis, neither of which has a Q dimension.
So the only things exercising it were the unit tests.

The tutorial now builds the full multi-Q Analysis, samples a few Q
values, gathers them with posterior_summary(), and shows the slider.

It samples Q indices 4, 8 and 12 rather than all sixteen. Sampling every
Q measured at 70 s against 16 s for three, and the subset also shows two
things worth showing: that sampling is slow enough to be worth trying a
few Q values first, and that the slider offers only the Q values that
were actually sampled.

Verified against a real kernel that the cell emits a widget view, rather
than only that the notebook ran without raising.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Put the corner slider under the figure

Matches where plopp puts its slicer controls, which is also where the
existing slicerplot_with_residuals puts them via the figure's bottom bar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Compose the posterior sampler instead of mixing it in

Review feedback: bayesian_sampling.py had a lot in it that belonged
elsewhere, and it was unclear why it was a mixin at all.

It was a mixin because ParameterAnalysis is not an AnalysisBase and fits
its binding models rather than itself, so a shared base class does not
work. That was a reason, not a good one: it injected some forty methods
into every Analysis class.

The sampler is now composed. An Analysis exposes one `bayesian` property,
and hands the sampler the few things that differ between the Analysis
classes -- the data, the free parameters, their labels, and a hook to
refresh cached computation -- so PosteriorSampler needs no knowledge of
how any Analysis is built, and no Analysis inherits sampling machinery it
does not use.

Labelling moves to posterior_labels.py. Building it once for a fixed set
of parameters also removes the quadratic cost the old code needed a
scoped cache to avoid: the counts and lookups are computed in the
constructor rather than per column.

Plotting stays in posterior_plotting.py, where it already lived. The
sampler keeps three short delegates so a chain can still be plotted from
the object holding it, but none of the drawing happens there.

The public API becomes analysis.bayesian.sample() and friends, and the
explicit suggest_bounds().apply() step stays: in DREAM the bounds are the
prior, and an unbounded parameter gives a confident-looking interval set
by nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Export the multi-Q sampler and drop the mixin's name

The section headers still pointed at a class that no longer exists, and
MultiQPosteriorSampler was reachable only through Analysis.bayesian.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Warm the tutorial data cache before running notebooks in parallel

The notebook tests run with '-n auto', and five of the notebooks fetch
vanadium_data_example.h5 through pooch. On a cold cache the workers race:
one is still writing the file into the cache while another opens it,
which fails on Windows with "PermissionError: Permission denied". This
failed twice in a row on windows-latest, always on that file, always
with the other sixteen notebooks passing.

The race is pre-existing, but adding a fifth notebook that wants the same
file, and lengthening tutorial 1, made it reliable rather than rare.

Fetching every tutorial data file once, before the parallel run starts,
leaves the workers with nothing to do but read, which is safe. The
prefetch reads the URLs and hashes out of the notebooks themselves, so it
cannot drift from what they actually download, and it never fails the
run: a file it cannot fetch is left to the notebook that needs it, which
reports the problem with far more context.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 46d745a)

* Mark setup, action and expectation apart in the new tests

The sampling tests labelled the action WHEN and had no THEN, so a reader
could not see where the arrangement stopped and the call under test
began. Setup is WHEN, the action is THEN, the assertions are EXPECT, and
steps that genuinely collapse onto one statement carry one combined
marker instead.

Comments only; no test changed what it does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Mark setup, action and expectation apart in the multi-Q tests

Same pass as on the single-Q tests: setup is WHEN, the action is THEN,
the assertions are EXPECT, and a step that collapses onto one statement
carries one combined marker.

Comments only; no test changed what it does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Give the sampler its own test file

Tests were split by feature rather than by the file they exercise, so
posterior_sampling.py had no test file of its own and Analysis1d had
two. The sampler's tests now live in test_posterior_sampling.py under
one TestPosteriorSampler, with the old class names as section banners,
and the four tests that are really about Analysis1d's cached fitter move
into TestAnalysis1d.

No test changed what it does; the same 31 + 4 tests run as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Put each test in the file of the class it exercises

Analysis and ParameterAnalysis each had a second test file, and the
sampler had none of its own. The sampler's tests, whichever analysis
drives them, now live in test_posterior_sampling.py under
TestPosteriorSampler and TestMultiQPosteriorSampler; the fitter, chain
parameter and label tests move into TestAnalysis and
TestParameterAnalysis. Old class names became section banners.

The multi-Q and ParameterAnalysis helpers keep distinct names in the
merged file, since their signatures differ from the single-Q ones.

The same 1660 tests run as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Refuse silent chain corruption and harden the posterior sampler

- extend() now verifies the chain holds the same parameters, not just
  the same number, and refuses to resume after a failed run or after
  the model or data changed
- Parameter objects passed to sample(parameters=...) are validated
  against the free set the same way strings are
- sampling with no free parameters and degenerate (min >= max) bounds
  raise clear errors before reaching BUMPS
- parameters_at_bounds keys by unique_name so same-named per-Q
  parameters no longer collide, and guards empty draws
- suggest_bounds flags non-finite fitted uncertainties for attention
- save() refuses to write an empty label sidecar; loading one warns
  like a missing sidecar
- colliding display labels get positional suffixes in the sidecar so
  save/load resolves each column to its own parameter
- plot_posterior_predictive omits error bars when the data carries no
  variances (new Experiment.has_variances)
- posterior plots validate draws/logp up front, name NaN columns, and
  share x-limits per corner column
- document that sampling runs are not seedable

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Keep the multi-Q sampler pointed at the chain the user actually ran

- sampling one Q index independently now clears a stale simultaneous
  chain, so summary(), set_parameters_to_median() and plot_corner()
  report the run the user just made instead of the old one
- extend() and save() after an independent run explain that the chains
  live on the per-Q analyses instead of resuming or saving the stale
  simultaneous chain; a genuinely failed run keeps its own message
- Q_index arguments are validated like every Analysis method, so a
  negative index raises instead of silently wrapping
- the gathered summary resolves each per-Q chain through its own saved
  labels, so chains loaded from disk keep names and units
- warnings are attributed to the caller on both the single-Q and
  multi-Q paths, and the corner-plot slider forwards plot kwargs
- the multi-Q integration tests share one independent sampling run,
  assert the straight line is actually recovered, and the extend test
  no longer mutates the shared fixture

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add marginal posteriors, correlation heatmaps and sampling progress

- plot_marginal(parameter) renders one parameter's posterior histogram
  with the median and the 16/84 percentile interval summary() reports,
  resolving labels the same way sample(parameters=...) does
- plot_correlations() renders the Pearson correlation matrix of the
  chain with annotated cells, a diverging colormap and masked cells for
  constant columns
- sample(progress=True) and extend(progress=True) report sampling
  progress through the Sampler's progress_callback, closing the line
  with an explicit done marker because BUMPS' own step estimate assumes
  the wrong chain count
- the 95 percent predictive band needed no change: credible_interval
  already exists on plot_posterior_predictive

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Give every posterior plot a Q slider over independent chains

After independent per-Q sampling the multi-Q sampler now presents a Q
slider instead of refusing:

- plot_posterior_predictive builds the per-Q data, median and credible
  band into a scipp DataGroup and renders it through plopp exactly like
  plot_data_and_model; plopp cannot shade a band on sliced lines, so
  the slider view draws labelled band edges while the Q_index path
  keeps the shaded band
- plot_trace, plot_marginal and plot_correlations take Q_index for a
  single figure, show a slider in a notebook, and otherwise name the
  sampled Q indices
- the matplotlib sliders render every figure once up front and only
  swap PNG bytes on a move, so dragging tracks smoothly with
  continuous updates instead of re-rendering per change
- per-Q energy grids are NaN-padded onto the common grid through the
  finite mask, so masked points draw as gaps

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Write the progress line through sys.stdout

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Show the new posterior plots in the Bayesian tutorial

The tutorial now demonstrates plot_marginal and plot_correlations from
the sampled chain, progress=True on the sampling call, the 95 percent
predictive band option, the Q slider that every posterior plot offers
over independent chains, and notes that runs are not seedable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Apply the formatting fixes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Satisfy the docstring and formatting checks

The progress reporter closes through try/finally instead of a bare
re-raise, and the plotting validation errors are documented in the
form the docstring linter expects.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Document propagated exceptions the way the docstring linter expects

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Give the Bayesian tutorial the widget backend its sliders need

The Q-slider cells go through the plopp slicer, which refuses the
inline backend; every plopp-using tutorial already runs %matplotlib
widget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Add Bayesian posterior sampling to Analysis1d

Expose the EasyScience Fitter on Analysis1d and add MCMC posterior
sampling on top of it, using the BUMPS DREAM sampler introduced in
easyscience 2.5.1 (easyscience.fitting.Sampler).

Least-squares fitting reports a single point with a curvature-derived
uncertainty, which is only trustworthy when parameters are uncorrelated
and roughly Gaussian. Sampling maps the whole posterior instead, so
correlated and skewed parameters get honest credible intervals.

The sampling machinery lives in a mixin with three hooks (build the
fitter, bind the data, list the chain parameters) so that Analysis and
ParameterAnalysis can reuse it. ParameterAnalysis is not an AnalysisBase
and builds a MultiFitter over binding models rather than over itself,
so a shared base class would not have worked.

Notable details:

- fit() now uses a cached Fitter instead of building one per call, and
  the cache is invalidated through the existing dirty-flag pattern.
- Bounds are the prior in DREAM, so sampling refuses to run with any
  infinite bound. suggest_bounds() proposes finite ones from the fitted
  values and uncertainties; it is advisory until .apply() is called and
  never loosens a bound that is already finite, so physical limits
  survive. A zero-width suggestion is flagged rather than invented.
- Sampling restores parameter values afterwards, since BUMPS leaves them
  wherever the last likelihood evaluation put them.
- Chains are reported under Parameter.name, not the internal unique_name.
  Those names are per-session, so save_chain() writes a sidecar mapping
  them to stable names and load_chain() uses it; loading without one
  warns rather than mislabelling the columns.
- After sampling, a warning fires when the posterior has piled up against
  a bound, which catches both bounds that are too tight and degenerate
  parameters that drift until a bound stops them.
- BUMPS crashes with a bare IndexError inside its own outlier removal
  when chains scatter, which in practice means a degenerate model. That
  is re-raised with the likely cause and a workaround.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Add Bayesian posterior sampling to Analysis and ParameterAnalysis

Extends the sampling introduced for Analysis1d to the remaining two
Analysis classes, using the mixin hooks added with it. No new sampling
machinery: each class supplies its fitter, its data, and its chain
parameters, and everything else is shared.

Analysis gains sample_posterior(fit_method=...), mirroring fit():

- 'independent' gives each Q index its own chain, delegating to the
  Analysis1d objects, and returns one result per Q (or a single result
  when a Q_index is given).
- 'simultaneous' runs one chain over every Q at once through a
  MultiFitter, refreshing each per-Q convolver against its masked energy
  grid first, exactly as the simultaneous fit does.

ParameterAnalysis samples the binding models. Its fit() built the
MultiFitter inline, so the per-target data, functions, and models are
now resolved by a shared _build_fit_inputs() that both paths use, which
also guarantees fitting and sampling see the same targets in the same
order with the same unit conversions.

Parameter labels needed rethinking. A multi-Q analysis holds one copy of
each parameter per Q, all sharing a name, so a summary showed several
identical rows and a name could not pick a parameter out. Labels are now
produced by an overridable parameter_label(): Analysis qualifies by Q
index, ParameterAnalysis by binding model, and both only when the bare
name is actually ambiguous, so single-Q and single-binding cases keep
their short names. The summary and bounds tables size themselves to the
longest label rather than truncating.

Also fixes Analysis.fit's docstring, which promised a single FitResults
for a simultaneous fit. MultiFitter splits its combined result back up
by dataset, so a list has always been returned.

Tutorial 1 gains a Bayesian section on the two-step diffusion fit, where
the posterior turns out to be about twelve times tighter than the
reported least-squares uncertainties. That gap is real and worth
explaining: the width fit has a reduced chi-squared near 150, so lmfit
inflates its uncertainties by the square root of that, while the sampler
takes the stated uncertainties at face value. Sampling the full
simultaneous diffusion model was measured at over ten minutes, so the
tutorial uses the ParameterAnalysis step instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Label the posterior plot axes with units and quantities

The summary table already reported each parameter's unit, but the plots
did not, so a diffusion coefficient came out as a bare number. Units are
now threaded through to plot_trace and plot_corner, and the posterior
predictive plot gets axis labels taken from the analysis' own energy and
intensity units.

Details that needed care:

- Matplotlib parks a shared exponent at the end of the axis, on top of
  the axis label. It is now folded into the label, sharing one set of
  parentheses with the unit, so a diffusion coefficient reads
  "diffusion_coefficient (1e-8 m^2/s)" rather than stacking two
  parentheticals or overlapping.
- Dimensionless and empty units are skipped. A polynomial coefficient
  labelled "dimensionless" is noise.
- The top-left panel of a corner plot is a histogram, so its vertical
  axis counts draws rather than carrying a parameter. It is now labelled
  "counts" instead of being left blank, which read as an omission.
- Corner tick counts are capped, since four labelled ticks per panel is
  as much as a small panel can carry legibly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Qualify parameter labels by model name, and cover the remaining branches

Two fixes found by writing the tests codecov asked for.

ParameterAnalysis qualified an ambiguous parameter with the owning
model's display_name, but for several models -- the diffusion models
among them -- display_name is the class name, so two models constructed
as name='Diffusion A' and name='Diffusion B' both came back as
"BrownianTranslationalDiffusion" and the label did not disambiguate
anything. It now uses the model's name, matching the choice to report
parameters under their name rather than their display name, and falls
back to the unique name only when the names collide too.

The rest is test coverage for branches that were reachable but untested:
the label fallbacks, the BUMPS outlier crash being re-raised as a
degeneracy hint, a chain column that matches no parameter, loading a
chain through its sidecar, the mixin's unimplemented hooks, and the
scientific-notation exponent being folded into an axis label.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Warm the tutorial data cache before running notebooks in parallel

The notebook tests run with '-n auto', and five of the notebooks fetch
vanadium_data_example.h5 through pooch. On a cold cache the workers race:
one is still writing the file into the cache while another opens it,
which fails on Windows with "PermissionError: Permission denied". This
failed twice in a row on windows-latest, always on that file, always
with the other sixteen notebooks passing.

The race is pre-existing, but adding a fifth notebook that wants the same
file, and lengthening tutorial 1, made it reliable rather than rare.

Fetching every tutorial data file once, before the parallel run starts,
leaves the workers with nothing to do but read, which is safe. The
prefetch reads the URLs and hashes out of the notebooks themselves, so it
cannot drift from what they actually download, and it never fails the
run: a file it cannot fetch is left to the notebook that needs it, which
reports the problem with far more context.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Rebuild the fitter when a binding changes shape, and stabilise the integration tests

Two problems found while reviewing the previous commits.

Caching the MultiFitter on ParameterAnalysis introduced a regression. A
FitBinding can be edited in place -- binding.targets = ... -- which
ParameterAnalysis cannot observe. Changing the number of targets left the
cached fitter holding one fit function against two datasets, and fit()
died with "FitError: list index out of range". It rebuilt every call
before, so this worked previously. The targets the fitter was built for
are now recorded and compared, which is enough to catch an edit that
cannot be observed directly.

The integration tests then failed in CI on macOS, inside BUMPS' outlier
removal, on an identifiable model. That matters beyond the test: the
error message claimed the crash means degenerate parameters, and this
shows short chains do it too. The message now names both causes, and the
integration tests switch the outlier removal off, as they already do for
the burn-point trimming.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Address the review findings on the sampling API

Six issues found reviewing the previous commits.

The sidecar could be written with the wrong labels. A subset run built
the name map inside the block that holds the other parameters fixed,
where nothing looks ambiguous, so a multi-Q chain recorded unqualified
names that no longer matched on reload. The map is now built outside that
block, where the free set is the user's real one.

extend_sampling() accepted a different parameter subset. BUMPS resumes
from a stored chain whose width is fixed, so that could only fail deep
inside the sampler; it is now refused up front.

The IndexError relabelling was unconditional, so an IndexError from this
package would have been reported as a BUMPS modelling problem. It now
only applies when the traceback passes through bumps.

Labelling a chain was quadratic in the parameter count: collecting the
parameters and scanning for their owner both happened per parameter, and
each walks every sub-model. 75 parameters took 0.39 s, and every summary
and plot pays it. The parameters are now collected once per pass, and
Analysis keeps an owner index alongside its analysis list. The same case
now measures at 0.00 s.

Asking an Analysis for a summary after sampling independently reported
that nothing had been sampled, moments after it had. It now says where
the chains actually are.

Applying bounds many orders of magnitude wider than the parameter is
still allowed -- it is what the fit implied -- but no longer silent, so a
scripted apply() cannot hide a degeneracy the table would have shown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Cover the review fixes, and drop a redundant guard

Three lines the review fixes added were not reachable from the unit
tests. Two are now covered: extending after a run that died before
storing results, where the chain-shape guard has nothing to compare
against, and a parameter shared across every Q index, which is left out
of the owner map because no single Q identifies it.

The third was the non-finite check in the absurd-width test, and it was
redundant rather than untested: an infinite width already compares
greater than any threshold, and the zero-scale case returns before it.
Removed, so the behaviour is unchanged and there is no dead branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Gather the per-Q chains on Analysis after independent sampling

Sampling with fit_method='independent' left the results only on the
Analysis1d objects, so the Analysis that produced them could not report
on them. It now gathers them, but only where gathering is sound.

posterior_summary() collects every Q into one table, labelled by Q index,
and set_parameters_to_posterior_median() applies each chain to its own Q.
Both are per-parameter marginal operations, and a marginal is well
defined within its own chain, so combining them across separate chains
says nothing that was not sampled.

plot_corner() deliberately does not aggregate. Independent sampling draws
each Q separately, so no draw pairs a parameter at one Q with a parameter
at another, and a corner plot built from them would show correlations
that are an artefact of how the sampling was run rather than anything
measured. It says so and points at the per-Q corner plots, which are
real. plot_trace() likewise, the chains being separate runs of different
lengths rather than one trace.

posterior_results exposes the per-Q chains directly, and a simultaneous
chain still takes precedence over stale per-Q ones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Step through the per-Q corner plots with a slider

Independent chains share no draws, so there is no joint distribution
across Q to plot, and combining them would show correlations that came
from how the sampling was run rather than from the data. Refusing
outright was correct but unhelpful: the correlations within each Q are
real and worth looking at.

Analysis.plot_corner() now shows one Q at a time. Pass Q_index for a
particular one, or leave it out in a notebook for a slider across the Q
values that were sampled. A simultaneous chain is unaffected; it already
covers every Q in one figure. Outside a notebook the error names the
sampled Q indices rather than only saying no.

The slider is built with append_display_data rather than the Output
widget's context manager. The context manager is the obvious choice and
captures nothing under some kernels, which would have shipped a slider
with a permanently blank panel beside it. Verified by executing a
notebook against a real kernel, and the test asserts the panel actually
holds a figure, since an empty panel is the regression that matters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Show the per-Q corner slider in the Bayesian tutorial

The slider was described in the tutorial's caveats but never
demonstrated: every notebook call to plot_corner() went through the
single-chain path, because the Bayesian tutorial used Analysis1d and
tutorial 1 used ParameterAnalysis, neither of which has a Q dimension.
So the only things exercising it were the unit tests.

The tutorial now builds the full multi-Q Analysis, samples a few Q
values, gathers them with posterior_summary(), and shows the slider.

It samples Q indices 4, 8 and 12 rather than all sixteen. Sampling every
Q measured at 70 s against 16 s for three, and the subset also shows two
things worth showing: that sampling is slow enough to be worth trying a
few Q values first, and that the slider offers only the Q values that
were actually sampled.

Verified against a real kernel that the cell emits a widget view, rather
than only that the notebook ran without raising.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Put the corner slider under the figure

Matches where plopp puts its slicer controls, which is also where the
existing slicerplot_with_residuals puts them via the figure's bottom bar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Reach the whole library through one namespace

Review feedback: the import style was inconsistent enough that a reader
had to scroll back to the imports cell to find out where a name came
from. Surveying it, the tutorials used four styles, and the last two
existed only because there was no other way to reach those names:

    import easydynamics as edyn                                32 uses
    import easydynamics.sample_model as sm                    151 uses
    from easydynamics.convolution import Convolution          forced
    from easydynamics.utils.utils import hbar                 forced

easydynamics.__all__ held six names, so Analysis1d, Convolution,
detailed_balance_factor and hbar could only be had by importing the
module that defines them. The inconsistency was structural rather than
careless, and no amount of tidying the notebooks alone would have fixed
it.

Everything public is now re-exported from easydynamics, 37 names, so
`import easydynamics as edyn` reaches all of it. The sub-packages stay
importable and the internal layout is untouched: only the front door is
flat. Flat is comfortable at this size, there were no name collisions,
and the sample_model grouping was already imprecise, holding
InstrumentModel, ResolutionModel and BackgroundModel.

The tutorials and the docstring examples that render into the API
reference now use that one style throughout. A test keeps the front door
in step with the sub-packages and the notebooks in step with the
convention, which is also written down in CONTRIBUTING.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Compose the posterior sampler instead of mixing it in

Review feedback: bayesian_sampling.py had a lot in it that belonged
elsewhere, and it was unclear why it was a mixin at all.

It was a mixin because ParameterAnalysis is not an AnalysisBase and fits
its binding models rather than itself, so a shared base class does not
work. That was a reason, not a good one: it injected some forty methods
into every Analysis class.

The sampler is now composed. An Analysis exposes one `bayesian` property,
and hands the sampler the few things that differ between the Analysis
classes -- the data, the free parameters, their labels, and a hook to
refresh cached computation -- so PosteriorSampler needs no knowledge of
how any Analysis is built, and no Analysis inherits sampling machinery it
does not use.

Labelling moves to posterior_labels.py. Building it once for a fixed set
of parameters also removes the quadratic cost the old code needed a
scoped cache to avoid: the counts and lookups are computed in the
constructor rather than per column.

Plotting stays in posterior_plotting.py, where it already lived. The
sampler keeps three short delegates so a chain can still be plotted from
the object holding it, but none of the drawing happens there.

The public API becomes analysis.bayesian.sample() and friends, and the
explicit suggest_bounds().apply() step stays: in DREAM the bounds are the
prior, and an unbounded parameter gives a confident-looking interval set
by nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Export the multi-Q sampler and drop the mixin's name

The section headers still pointed at a class that no longer exists, and
MultiQPosteriorSampler was reachable only through Analysis.bayesian.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Point the front door at the composed sampler

The flat namespace still re-exported the mixin that the refactor
removed, and not the sampler classes that replaced it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Unwrap the security-issue line again

Prettier 3.9, which CI installs, measures the shield emoji differently
from the older release cached here and wants the line whole.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Warm the tutorial data cache before running notebooks in parallel

The notebook tests run with '-n auto', and five of the notebooks fetch
vanadium_data_example.h5 through pooch. On a cold cache the workers race:
one is still writing the file into the cache while another opens it,
which fails on Windows with "PermissionError: Permission denied". This
failed twice in a row on windows-latest, always on that file, always
with the other sixteen notebooks passing.

The race is pre-existing, but adding a fifth notebook that wants the same
file, and lengthening tutorial 1, made it reliable rather than rare.

Fetching every tutorial data file once, before the parallel run starts,
leaves the workers with nothing to do but read, which is safe. The
prefetch reads the URLs and hashes out of the notebooks themselves, so it
cannot drift from what they actually download, and it never fails the
run: a file it cannot fetch is left to the notebook that needs it, which
reports the problem with far more context.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 46d745a)

* Mark setup, action and expectation apart in the new tests

The sampling tests labelled the action WHEN and had no THEN, so a reader
could not see where the arrangement stopped and the call under test
began. Setup is WHEN, the action is THEN, the assertions are EXPECT, and
steps that genuinely collapse onto one statement carry one combined
marker instead.

Comments only; no test changed what it does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Mark setup, action and expectation apart in the multi-Q tests

Same pass as on the single-Q tests: setup is WHEN, the action is THEN,
the assertions are EXPECT, and a step that collapses onto one statement
carries one combined marker.

Comments only; no test changed what it does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Mark setup, action and expectation apart in the namespace tests

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Give the sampler its own test file

Tests were split by feature rather than by the file they exercise, so
posterior_sampling.py had no test file of its own and Analysis1d had
two. The sampler's tests now live in test_posterior_sampling.py under
one TestPosteriorSampler, with the old class names as section banners,
and the four tests that are really about Analysis1d's cached fitter move
into TestAnalysis1d.

No test changed what it does; the same 31 + 4 tests run as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Put each test in the file of the class it exercises

Analysis and ParameterAnalysis each had a second test file, and the
sampler had none of its own. The sampler's tests, whichever analysis
drives them, now live in test_posterior_sampling.py under
TestPosteriorSampler and TestMultiQPosteriorSampler; the fitter, chain
parameter and label tests move into TestAnalysis and
TestParameterAnalysis. Old class names became section banners.

The multi-Q and ParameterAnalysis helpers keep distinct names in the
merged file, since their signatures differ from the single-Q ones.

The same 1660 tests run as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Refuse silent chain corruption and harden the posterior sampler

- extend() now verifies the chain holds the same parameters, not just
  the same number, and refuses to resume after a failed run or after
  the model or data changed
- Parameter objects passed to sample(parameters=...) are validated
  against the free set the same way strings are
- sampling with no free parameters and degenerate (min >= max) bounds
  raise clear errors before reaching BUMPS
- parameters_at_bounds keys by unique_name so same-named per-Q
  parameters no longer collide, and guards empty draws
- suggest_bounds flags non-finite fitted uncertainties for attention
- save() refuses to write an empty label sidecar; loading one warns
  like a missing sidecar
- colliding display labels get positional suffixes in the sidecar so
  save/load resolves each column to its own parameter
- plot_posterior_predictive omits error bars when the data carries no
  variances (new Experiment.has_variances)
- posterior plots validate draws/logp up front, name NaN columns, and
  share x-limits per corner column
- document that sampling runs are not seedable

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Keep the multi-Q sampler pointed at the chain the user actually ran

- sampling one Q index independently now clears a stale simultaneous
  chain, so summary(), set_parameters_to_median() and plot_corner()
  report the run the user just made instead of the old one
- extend() and save() after an independent run explain that the chains
  live on the per-Q analyses instead of resuming or saving the stale
  simultaneous chain; a genuinely failed run keeps its own message
- Q_index arguments are validated like every Analysis method, so a
  negative index raises instead of silently wrapping
- the gathered summary resolves each per-Q chain through its own saved
  labels, so chains loaded from disk keep names and units
- warnings are attributed to the caller on both the single-Q and
  multi-Q paths, and the corner-plot slider forwards plot kwargs
- the multi-Q integration tests share one independent sampling run,
  assert the straight line is actually recovered, and the extend test
  no longer mutates the shared fixture

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add marginal posteriors, correlation heatmaps and sampling progress

- plot_marginal(parameter) renders one parameter's posterior histogram
  with the median and the 16/84 percentile interval summary() reports,
  resolving labels the same way sample(parameters=...) does
- plot_correlations() renders the Pearson correlation matrix of the
  chain with annotated cells, a diverging colormap and masked cells for
  constant columns
- sample(progress=True) and extend(progress=True) report sampling
  progress through the Sampler's progress_callback, closing the line
  with an explicit done marker because BUMPS' own step estimate assumes
  the wrong chain count
- the 95 percent predictive band needed no change: credible_interval
  already exists on plot_posterior_predictive

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Give every posterior plot a Q slider over independent chains

After independent per-Q sampling the multi-Q sampler now presents a Q
slider instead of refusing:

- plot_posterior_predictive builds the per-Q data, median and credible
  band into a scipp DataGroup and renders it through plopp exactly like
  plot_data_and_model; plopp cannot shade a band on sliced lines, so
  the slider view draws labelled band edges while the Q_index path
  keeps the shaded band
- plot_trace, plot_marginal and plot_correlations take Q_index for a
  single figure, show a slider in a notebook, and otherwise name the
  sampled Q indices
- the matplotlib sliders render every figure once up front and only
  swap PNG bytes on a move, so dragging tracks smoothly with
  continuous updates instead of re-rendering per change
- per-Q energy grids are NaN-padded onto the common grid through the
  finite mask, so masked points draw as gaps

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Write the progress line through sys.stdout

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Show the new posterior plots in the Bayesian tutorial

The tutorial now demonstrates plot_marginal and plot_correlations from
the sampled chain, progress=True on the sampling call, the 95 percent
predictive band option, the Q slider that every posterior plot offers
over independent chains, and notes that runs are not seedable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Apply the formatting fixes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Satisfy the docstring and formatting checks

The progress reporter closes through try/finally instead of a bare
re-raise, and the plotting validation errors are documented in the
form the docstring linter expects.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Document propagated exceptions the way the docstring linter expects

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Give the Bayesian tutorial the widget backend its sliders need

The Q-slider cells go through the plopp slicer, which refuses the
inline backend; every plopp-using tutorial already runs %matplotlib
widget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Add Bayesian posterior sampling to Analysis1d

Expose the EasyScience Fitter on Analysis1d and add MCMC posterior
sampling on top of it, using the BUMPS DREAM sampler introduced in
easyscience 2.5.1 (easyscience.fitting.Sampler).

Least-squares fitting reports a single point with a curvature-derived
uncertainty, which is only trustworthy when parameters are uncorrelated
and roughly Gaussian. Sampling maps the whole posterior instead, so
correlated and skewed parameters get honest credible intervals.

The sampling machinery lives in a mixin with three hooks (build the
fitter, bind the data, list the chain parameters) so that Analysis and
ParameterAnalysis can reuse it. ParameterAnalysis is not an AnalysisBase
and builds a MultiFitter over binding models rather than over itself,
so a shared base class would not have worked.

Notable details:

- fit() now uses a cached Fitter instead of building one per call, and
  the cache is invalidated through the existing dirty-flag pattern.
- Bounds are the prior in DREAM, so sampling refuses to run with any
  infinite bound. suggest_bounds() proposes finite ones from the fitted
  values and uncertainties; it is advisory until .apply() is called and
  never loosens a bound that is already finite, so physical limits
  survive. A zero-width suggestion is flagged rather than invented.
- Sampling restores parameter values afterwards, since BUMPS leaves them
  wherever the last likelihood evaluation put them.
- Chains are reported under Parameter.name, not the internal unique_name.
  Those names are per-session, so save_chain() writes a sidecar mapping
  them to stable names and load_chain() uses it; loading without one
  warns rather than mislabelling the columns.
- After sampling, a warning fires when the posterior has piled up against
  a bound, which catches both bounds that are too tight and degenerate
  parameters that drift until a bound stops them.
- BUMPS crashes with a bare IndexError inside its own outlier removal
  when chains scatter, which in practice means a degenerate model. That
  is re-raised with the likely cause and a workaround.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Add Bayesian posterior sampling to Analysis and ParameterAnalysis

Extends the sampling introduced for Analysis1d to the remaining two
Analysis classes, using the mixin hooks added with it. No new sampling
machinery: each class supplies its fitter, its data, and its chain
parameters, and everything else is shared.

Analysis gains sample_posterior(fit_method=...), mirroring fit():

- 'independent' gives each Q index its own chain, delegating to the
  Analysis1d objects, and returns one result per Q (or a single result
  when a Q_index is given).
- 'simultaneous' runs one chain over every Q at once through a
  MultiFitter, refreshing each per-Q convolver against its masked energy
  grid first, exactly as the simultaneous fit does.

ParameterAnalysis samples the binding models. Its fit() built the
MultiFitter inline, so the per-target data, functions, and models are
now resolved by a shared _build_fit_inputs() that both paths use, which
also guarantees fitting and sampling see the same targets in the same
order with the same unit conversions.

Parameter labels needed rethinking. A multi-Q analysis holds one copy of
each parameter per Q, all sharing a name, so a summary showed several
identical rows and a name could not pick a parameter out. Labels are now
produced by an overridable parameter_label(): Analysis qualifies by Q
index, ParameterAnalysis by binding model, and both only when the bare
name is actually ambiguous, so single-Q and single-binding cases keep
their short names. The summary and bounds tables size themselves to the
longest label rather than truncating.

Also fixes Analysis.fit's docstring, which promised a single FitResults
for a simultaneous fit. MultiFitter splits its combined result back up
by dataset, so a list has always been returned.

Tutorial 1 gains a Bayesian section on the two-step diffusion fit, where
the posterior turns out to be about twelve times tighter than the
reported least-squares uncertainties. That gap is real and worth
explaining: the width fit has a reduced chi-squared near 150, so lmfit
inflates its uncertainties by the square root of that, while the sampler
takes the stated uncertainties at face value. Sampling the full
simultaneous diffusion model was measured at over ten minutes, so the
tutorial uses the ParameterAnalysis step instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Label the posterior plot axes with units and quantities

The summary table already reported each parameter's unit, but the plots
did not, so a diffusion coefficient came out as a bare number. Units are
now threaded through to plot_trace and plot_corner, and the posterior
predictive plot gets axis labels taken from the analysis' own energy and
intensity units.

Details that needed care:

- Matplotlib parks a shared exponent at the end of the axis, on top of
  the axis label. It is now folded into the label, sharing one set of
  parentheses with the unit, so a diffusion coefficient reads
  "diffusion_coefficient (1e-8 m^2/s)" rather than stacking two
  parentheticals or overlapping.
- Dimensionless and empty units are skipped. A polynomial coefficient
  labelled "dimensionless" is noise.
- The top-left panel of a corner plot is a histogram, so its vertical
  axis counts draws rather than carrying a parameter. It is now labelled
  "counts" instead of being left blank, which read as an omission.
- Corner tick counts are capped, since four labelled ticks per panel is
  as much as a small panel can carry legibly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Qualify parameter labels by model name, and cover the remaining branches

Two fixes found by writing the tests codecov asked for.

ParameterAnalysis qualified an ambiguous parameter with the owning
model's display_name, but for several models -- the diffusion models
among them -- display_name is the class name, so two models constructed
as name='Diffusion A' and name='Diffusion B' both came back as
"BrownianTranslationalDiffusion" and the label did not disambiguate
anything. It now uses the model's name, matching the choice to report
parameters under their name rather than their display name, and falls
back to the unique name only when the names collide too.

The rest is test coverage for branches that were reachable but untested:
the label fallbacks, the BUMPS outlier crash being re-raised as a
degeneracy hint, a chain column that matches no parameter, loading a
chain through its sidecar, the mixin's unimplemented hooks, and the
scientific-notation exponent being folded into an axis label.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Warm the tutorial data cache before running notebooks in parallel

The notebook tests run with '-n auto', and five of the notebooks fetch
vanadium_data_example.h5 through pooch. On a cold cache the workers race:
one is still writing the file into the cache while another opens it,
which fails on Windows with "PermissionError: Permission denied". This
failed twice in a row on windows-latest, always on that file, always
with the other sixteen notebooks passing.

The race is pre-existing, but adding a fifth notebook that wants the same
file, and lengthening tutorial 1, made it reliable rather than rare.

Fetching every tutorial data file once, before the parallel run starts,
leaves the workers with nothing to do but read, which is safe. The
prefetch reads the URLs and hashes out of the notebooks themselves, so it
cannot drift from what they actually download, and it never fails the
run: a file it cannot fetch is left to the notebook that needs it, which
reports the problem with far more context.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Rebuild the fitter when a binding changes shape, and stabilise the integration tests

Two problems found while reviewing the previous commits.

Caching the MultiFitter on ParameterAnalysis introduced a regression. A
FitBinding can be edited in place -- binding.targets = ... -- which
ParameterAnalysis cannot observe. Changing the number of targets left the
cached fitter holding one fit function against two datasets, and fit()
died with "FitError: list index out of range". It rebuilt every call
before, so this worked previously. The targets the fitter was built for
are now recorded and compared, which is enough to catch an edit that
cannot be observed directly.

The integration tests then failed in CI on macOS, inside BUMPS' outlier
removal, on an identifiable model. That matters beyond the test: the
error message claimed the crash means degenerate parameters, and this
shows short chains do it too. The message now names both causes, and the
integration tests switch the outlier removal off, as they already do for
the burn-point trimming.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Address the review findings on the sampling API

Six issues found reviewing the previous commits.

The sidecar could be written with the wrong labels. A subset run built
the name map inside the block that holds the other parameters fixed,
where nothing looks ambiguous, so a multi-Q chain recorded unqualified
names that no longer matched on reload. The map is now built outside that
block, where the free set is the user's real one.

extend_sampling() accepted a different parameter subset. BUMPS resumes
from a stored chain whose width is fixed, so that could only fail deep
inside the sampler; it is now refused up front.

The IndexError relabelling was unconditional, so an IndexError from this
package would have been reported as a BUMPS modelling problem. It now
only applies when the traceback passes through bumps.

Labelling a chain was quadratic in the parameter count: collecting the
parameters and scanning for their owner both happened per parameter, and
each walks every sub-model. 75 parameters took 0.39 s, and every summary
and plot pays it. The parameters are now collected once per pass, and
Analysis keeps an owner index alongside its analysis list. The same case
now measures at 0.00 s.

Asking an Analysis for a summary after sampling independently reported
that nothing had been sampled, moments after it had. It now says where
the chains actually are.

Applying bounds many orders of magnitude wider than the parameter is
still allowed -- it is what the fit implied -- but no longer silent, so a
scripted apply() cannot hide a degeneracy the table would have shown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Cover the review fixes, and drop a redundant guard

Three lines the review fixes added were not reachable from the unit
tests. Two are now covered: extending after a run that died before
storing results, where the chain-shape guard has nothing to compare
against, and a parameter shared across every Q index, which is left out
of the owner map because no single Q identifies it.

The third was the non-finite check in the absurd-width test, and it was
redundant rather than untested: an infinite width already compares
greater than any threshold, and the zero-scale case returns before it.
Removed, so the behaviour is unchanged and there is no dead branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Gather the per-Q chains on Analysis after independent sampling

Sampling with fit_method='independent' left the results only on the
Analysis1d objects, so the Analysis that produced them could not report
on them. It now gathers them, but only where gathering is sound.

posterior_summary() collects every Q into one table, labelled by Q index,
and set_parameters_to_posterior_median() applies each chain to its own Q.
Both are per-parameter marginal operations, and a marginal is well
defined within its own chain, so combining them across separate chains
says nothing that was not sampled.

plot_corner() deliberately does not aggregate. Independent sampling draws
each Q separately, so no draw pairs a parameter at one Q with a parameter
at another, and a corner plot built from them would show correlations
that are an artefact of how the sampling was run rather than anything
measured. It says so and points at the per-Q corner plots, which are
real. plot_trace() likewise, the chains being separate runs of different
lengths rather than one trace.

posterior_results exposes the per-Q chains directly, and a simultaneous
chain still takes precedence over stale per-Q ones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Step through the per-Q corner plots with a slider

Independent chains share no draws, so there is no joint distribution
across Q to plot, and combining them would show correlations that came
from how the sampling was run rather than from the data. Refusing
outright was correct but unhelpful: the correlations within each Q are
real and worth looking at.

Analysis.plot_corner() now shows one Q at a time. Pass Q_index for a
particular one, or leave it out in a notebook for a slider across the Q
values that were sampled. A simultaneous chain is unaffected; it already
covers every Q in one figure. Outside a notebook the error names the
sampled Q indices rather than only saying no.

The slider is built with append_display_data rather than the Output
widget's context manager. The context manager is the obvious choice and
captures nothing under some kernels, which would have shipped a slider
with a permanently blank panel beside it. Verified by executing a
notebook against a real kernel, and the test asserts the panel actually
holds a figure, since an empty panel is the regression that matters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Show the per-Q corner slider in the Bayesian tutorial

The slider was described in the tutorial's caveats but never
demonstrated: every notebook call to plot_corner() went through the
single-chain path, because the Bayesian tutorial used Analysis1d and
tutorial 1 used ParameterAnalysis, neither of which has a Q dimension.
So the only things exercising it were the unit tests.

The tutorial now builds the full multi-Q Analysis, samples a few Q
values, gathers them with posterior_summary(), and shows the slider.

It samples Q indices 4, 8 and 12 rather than all sixteen. Sampling every
Q measured at 70 s against 16 s for three, and the subset also shows two
things worth showing: that sampling is slow enough to be worth trying a
few Q values first, and that the slider offers only the Q values that
were actually sampled.

Verified against a real kernel that the cell emits a widget view, rather
than only that the notebook ran without raising.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Put the corner slider under the figure

Matches where plopp puts its slicer controls, which is also where the
existing slicerplot_with_residuals puts them via the figure's bottom bar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Reach the whole library through one namespace

Review feedback: the import style was inconsistent enough that a reader
had to scroll back to the imports cell to find out where a name came
from. Surveying it, the tutorials used four styles, and the last two
existed only because there was no other way to reach those names:

    import easydynamics as edyn                                32 uses
    import easydynamics.sample_model as sm                    151 uses
    from easydynamics.convolution import Convolution          forced
    from easydynamics.utils.utils import hbar                 forced

easydynamics.__all__ held six names, so Analysis1d, Convolution,
detailed_balance_factor and hbar could only be had by importing the
module that defines them. The inconsistency was structural rather than
careless, and no amount of tidying the notebooks alone would have fixed
it.

Everything public is now re-exported from easydynamics, 37 names, so
`import easydynamics as edyn` reaches all of it. The sub-packages stay
importable and the internal layout is untouched: only the front door is
flat. Flat is comfortable at this size, there were no name collisions,
and the sample_model grouping was already imprecise, holding
InstrumentModel, ResolutionModel and BackgroundModel.

The tutorials and the docstring examples that render into the API
reference now use that one style throughout. A test keeps the front door
in step with the sub-packages and the notebooks in step with the
convention, which is also written down in CONTRIBUTING.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Compose the posterior sampler instead of mixing it in

Review feedback: bayesian_sampling.py had a lot in it that belonged
elsewhere, and it was unclear why it was a mixin at all.

It was a mixin because ParameterAnalysis is not an AnalysisBase and fits
its binding models rather than itself, so a shared base class does not
work. That was a reason, not a good one: it injected some forty methods
into every Analysis class.

The sampler is now composed. An Analysis exposes one `bayesian` property,
and hands the sampler the few things that differ between the Analysis
classes -- the data, the free parameters, their labels, and a hook to
refresh cached computation -- so PosteriorSampler needs no knowledge of
how any Analysis is built, and no Analysis inherits sampling machinery it
does not use.

Labelling moves to posterior_labels.py. Building it once for a fixed set
of parameters also removes the quadratic cost the old code needed a
scoped cache to avoid: the counts and lookups are computed in the
constructor rather than per column.

Plotting stays in posterior_plotting.py, where it already lived. The
sampler keeps three short delegates so a chain can still be plotted from
the object holding it, but none of the drawing happens there.

The public API becomes analysis.bayesian.sample() and friends, and the
explicit suggest_bounds().apply() step stays: in DREAM the bounds are the
prior, and an unbounded parameter gives a confident-looking interval set
by nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Export the multi-Q sampler and drop the mixin's name

The section headers still pointed at a class that no longer exists, and
MultiQPosteriorSampler was reachable only through Analysis.bayesian.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Point the front door at the composed sampler

The flat namespace still re-exported the mixin that the refactor
removed, and not the sampler classes that replaced it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Unwrap the security-issue line again

Prettier 3.9, which CI installs, measures the shield emoji differently
from the older release cached here and wants the line whole.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Warm the tutorial data cache before running notebooks in parallel

The notebook tests run with '-n auto', and five of the notebooks fetch
vanadium_data_example.h5 through pooch. On a cold cache the workers race:
one is still writing the file into the cache while another opens it,
which fails on Windows with "PermissionError: Permission denied". This
failed twice in a row on windows-latest, always on that file, always
with the other sixteen notebooks passing.

The race is pre-existing, but adding a fifth notebook that wants the same
file, and lengthening tutorial 1, made it reliable rather than rare.

Fetching every tutorial data file once, before the parallel run starts,
leaves the workers with nothing to do but read, which is safe. The
prefetch reads the URLs and hashes out of the notebooks themselves, so it
cannot drift from what they actually download, and it never fails the
run: a file it cannot fetch is left to the notebook that needs it, which
reports the problem with far more context.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 46d745a)

* Mark setup, action and expectation apart in the new tests

The sampling tests labelled the action WHEN and had no THEN, so a reader
could not see where the arrangement stopped and the call under test
began. Setup is WHEN, the action is THEN, the assertions are EXPECT, and
steps that genuinely collapse onto one statement carry one combined
marker instead.

Comments only; no test changed what it does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Mark setup, action and expectation apart in the multi-Q tests

Same pass as on the single-Q tests: setup is WHEN, the action is THEN,
the assertions are EXPECT, and a step that collapses onto one statement
carries one combined marker.

Comments only; no test changed what it does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Mark setup, action and expectation apart in the namespace tests

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Give the sampler its own test file

Tests were split by feature rather than by the file they exercise, so
posterior_sampling.py had no test file of its own and Analysis1d had
two. The sampler's tests now live in test_posterior_sampling.py under
one TestPosteriorSampler, with the old class names as section banners,
and the four tests that are really about Analysis1d's cached fitter move
into TestAnalysis1d.

No test changed what it does; the same 31 + 4 tests run as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Put each test in the file of the class it exercises

Analysis and ParameterAnalysis each had a second test file, and the
sampler had none of its own. The sampler's tests, whichever analysis
drives them, now live in test_posterior_sampling.py under
TestPosteriorSampler and TestMultiQPosteriorSampler; the fitter, chain
parameter and label tests move into TestAnalysis and
TestParameterAnalysis. Old class names became section banners.

The multi-Q and ParameterAnalysis helpers keep distinct names in the
merged file, since their signatures differ from the single-Q ones.

The same 1660 tests run as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Refuse silent chain corruption and harden the posterior sampler

- extend() now verifies the chain holds the same parameters, not just
  the same number, and refuses to resume after a failed run or after
  the model or data changed
- Parameter objects passed to sample(parameters=...) are validated
  against the free set the same way strings are
- sampling with no free parameters and degenerate (min >= max) bounds
  raise clear errors before reaching BUMPS
- parameters_at_bounds keys by unique_name so same-named per-Q
  parameters no longer collide, and guards empty draws
- suggest_bounds flags non-finite fitted uncertainties for attention
- save() refuses to write an empty label sidecar; loading one warns
  like a missing sidecar
- colliding display labels get positional suffixes in the sidecar so
  save/load resolves each column to its own parameter
- plot_posterior_predictive omits error bars when the data carries no
  variances (new Experiment.has_variances)
- posterior plots validate draws/logp up front, name NaN columns, and
  share x-limits per corner column
- document that sampling runs are not seedable

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Version collections so stale plans and caches rebuild

Mutations made through a live ComponentCollection were invisible to the
boolean dirty flags: an appended component was silently left out of the
convolution, and a mutated template never reached evaluate().

- EasyDynamicsList gains a version counter bumped by every mutation;
  ModelBase exposes state_version combining it with its own changes
- convolution plans snapshot collection versions, the detailed-balance
  settings version and the energy_offset identity, so in-place edits,
  settings toggles and offset rebinds all rebuild the plan
- the plan invalidation set no longer watches two nonexistent
  attributes, and superseded plan objects are pruned from the global
  registry instead of leaking
- energy assignment with a mismatched scipp unit is refused, collection
  x_units are validated at construction, slicing a ComponentCollection
  works, delta-in-resolution is enforced on every path with ValueError,
  empty resolutions and single-point grids raise clearly
- normalize_area rejects negative areas, empty-collection evaluate
  validates output, duplicate names raise AmbiguousNameError

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Repair detailed balance, diffusion collections and bound handling

- detailed balance now works with scipp output mode and DataArray
  input, and its docstring matches the errors it actually raises
- diffusion models install the collections create_component_collections
  builds, so the returned parameters are the live ones; DeltaLorentz no
  longer orphans its per-Q parameter lists, and its amplitudes carry
  the model name so Q tracking keeps working
- Polynomial unit conversion rescales bounds with the values instead of
  silently clamping bounded coefficients
- component setters raise on values that violate parameter bounds
  rather than storing the clamped value; construction keeps its warning
- a calibrated ResolutionModel refuses mutations that would silently
  rebuild it from the unfitted template, and from_sample_model strips
  elastic delta functions with a warning instead of refusing the
  standard QENS model
- SampleModel validates before mutating caller-owned diffusion models,
  normalizes temperature units to str, and single-point DeltaFunction
  evaluation raises instead of inventing a bin width
- ExpressionComponent rejects symbols that shadow class attributes and
  documents the sympify trust requirement

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Track model versions per consumer and wire missing invalidation

The shared component_collections_is_dirty flag was consumed by its
first reader, so in an independent multi-Q fit only Q0 rebuilt its
convolver and every later Q fitted through a stale one.

- each Analysis1d records the state_version of its sample and
  resolution models and rebuilds its convolver on mismatch
- the detailed_balance_settings setter fires a change hook like every
  sibling setter, reaching per-Q children and cached convolvers
- Analysis.rebin invalidates the fitter and sampler like Analysis1d
- simultaneous fits run through the configured fitter instead of a
  throwaway MultiFitter
- ParameterAnalysis fitter staleness includes target names and dataset
  keys, binding mutations invalidate, and the bindings list is copied
- Analysis.get_all_variables override removes property side effects
  from bounds checks and includes extra parameters
- residuals are omitted with a warning on a custom energy grid,
  plot(names=[]) raises clearly and only needed bindings are evaluated,
  the no-variance path masks non-finite values, verify_Q_index rejects
  bools and Experiment.rebin no longer mutates the caller's dict

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Declare runtime dependencies and align packaging, docs and tooling

- declare h5py, numpy, scipy and scipp, which the package imports
  directly but only received transitively
- export Analysis1d and FitBinding from easydynamics.analysis, hbar
  from easydynamics.utils and DeltaLorentz from its own sub-package,
  as the front-door docstring promises
- test_public_api covers the nested sub-packages and absorbs the
  import smoke test
- the tutorial-data prefetch parses each pooch.retrieve call site
  instead of zipping url and hash lists, and covers nested notebooks,
  as does notebook-strip; notebook-exec waits for the prefetch
- fix the mkdocs edit_uri path, refresh stale noqa prose in the
  delta_lorentz tutorial, replace the placeholder functional test with
  a real smoke test, and give the network-bound integration test a
  marker and a meaningful 3-sigma tolerance

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Evaluate expressions in a coherent scale and express them in y_unit

An ExpressionComponent whose derived output unit was convertible to
y_unit warned and mislabelled instead of converting, and evaluation at
raw parameter values silently mixed unit scales inside sums: with D in
m^2/s, x in 1/angstrom and tau in ps, the jump-diffusion denominator
1 + D*x**2*tau evaluated as 1 + 1e-9 where the physical value is 1.1.

When the output unit is convertible to y_unit, every symbol value is
now scaled by its unit's SI multiplier before evaluation and the result
is expressed in y_unit. Scale-homogeneous expressions are unchanged;
dimensionally incompatible output units still warn.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Cover counts-bearing units in the expression conversion tests

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Keep the multi-Q sampler pointed at the chain the user actually ran

- sampling one Q index independently now clears a stale simultaneous
  chain, so summary(), set_parameters_to_median() and plot_corner()
  report the run the user just made instead of the old one
- extend() and save() after an independent run explain that the chains
  live on the per-Q analyses instead of resuming or saving the stale
  simultaneous chain; a genuinely failed run keeps its own message
- Q_index arguments are validated like every Analysis method, so a
  negative index raises instead of silently wrapping
- the gathered summary resolves each per-Q chain through its own saved
  labels, so chains loaded from disk keep names and units
- warnings are attributed to the caller on both the single-Q and
  multi-Q paths, and the corner-plot slider forwards plot kwargs
- the multi-Q integration tests share one independent sampling run,
  assert the straight line is actually recovered, and the extend test
  no longer mutates the shared fixture

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add marginal posteriors, correlation heatmaps and sampling progress

- plot_marginal(parameter) renders one parameter's posterior histogram
  with the median and the 16/84 percentile interval summary() reports,
  resolving labels the same way sample(parameters=...) does
- plot_correlations() renders the Pearson correlation matrix of the
  chain with annotated cells, a diverging colormap and masked cells for
  constant columns
- sample(progress=True) and extend(progress=True) report sampling
  progress through the Sampler's progress_callback, closing the line
  with an explicit done marker because BUMPS' own step estimate assumes
  the wrong chain count
- the 95 percent predictive band needed no change: credible_interval
  already exists on plot_posterior_predictive

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Align test files with the layout conventions and hoist imports

- one test class per class under test: the topic-split classes in the
  posterior, expression-component and fit-binding test files are
  consolidated under banner sections, and stray module-level tests move
  into their classes with WHEN/THEN/EXPECT markers
- tests exercising the Analysis and ParameterAnalysis contracts move
  from test_posterior_sampling.py into their own files
- the labelling cost test counts property reads instead of asserting
  wall-clock time, so a quadratic regression fails deterministically
  and a loaded CI runner cannot flake it
- plot_corner's y-axis offset path and corner_with_slider's empty-chain
  error gain the tests they were missing
- function-level imports move to the top of their files across tests
  and src; the deliberate lazy imports that guard the analysis-utils
  import cycle now say so in comments

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Justify the exact no-op comparisons in the coefficient rescale

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Move the remaining stray tests into their classes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Give every posterior plot a Q slider over independent chains

After independent per-Q sampling the multi-Q sampler now presents a Q
slider instead of refusing:

- plot_posterior_predictive builds the per-Q data, median and credible
  band into a scipp DataGroup and renders it through plopp exactly like
  plot_data_and_model; plopp cannot shade a band on sliced lines, so
  the slider view draws labelled band edges while the Q_index path
  keeps the shaded band
- plot_trace, plot_marginal and plot_correlations take Q_index for a
  single figure, show a slider in a notebook, and otherwise name the
  sampled Q indices
- the matplotlib sliders render every figure once up front and only
  swap PNG bytes on a move, so dragging tracks smoothly with
  continuous updates instead of re-rendering per change
- per-Q energy grids are NaN-padded onto the common grid through the
  finite mask, so masked points draw as gaps

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Write the progress line through sys.stdout

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Show the new posterior plots in the Bayesian tutorial

The tutorial now demonstrates plot_marginal and plot_correlations from
the sampled chain, progress=True on the sampling call, the 95 percent
predictive band option, the Q slider that every posterior plot offers
over independent chains, and notes that runs are not seedable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Apply the formatting fixes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Satisfy the docstring and formatting checks

The progress reporter closes through try/finally instead of a bare
re-raise, and the plotting validation errors are documented in the
form the docstring linter expects.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Document propagated exceptions the way the docstring linter expects

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Satisfy the linters across the merged fix branch

Formatting from pixi run fix, docstring Raises sections limited to
directly raised exceptions with propagated ones described in prose,
and the polynomial rollback restructured as try/finally so its Raises
contract stays honest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Keep prettier current and contained to the repository

nonpy-format-check disagreed between machines because npm, finding no
package.json here, walked up the directory tree and resolved prettier
against whatever a parent directory happened to pin, while CI installed
the latest. The prettier tasks now refresh the install before every
run, with --prefix . so the resolution cannot leave the repository and
@latest so local runs track the same version CI gets. The files the
old local version had skipped are reformatted once, and in-repo git
worktrees are excluded from the sweep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Give the Bayesian tutorial the widget backend its sliders need

The Q-slider cells go through the plopp slicer, which refuses the
inline backend; every plopp-using tutorial already runs %matplotlib
widget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Drop the duplicate line and unused import the tie-merge reintroduced

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@easyscience easyscience Bot added the [bot] release Automated release PR. Excluded from changelog/versioning label Aug 17, 2026
@codecov

codecov Bot commented Aug 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.80651% with 51 lines in your changes missing coverage. Please review.
✅ Project coverage is 98.47%. Comparing base (19f68ab) to head (f941728).
⚠️ Report is 1 commits behind head on master.

Files with missing lines Patch % Lines
src/easydynamics/analysis/posterior_sampling.py 95.42% 12 Missing and 11 partials ⚠️
...easydynamics/sample_model/components/polynomial.py 76.31% 6 Missing and 3 partials ⚠️
...easydynamics/convolution/analytical_convolution.py 78.94% 2 Missing and 2 partials ⚠️
...cs/sample_model/components/expression_component.py 90.47% 4 Missing ⚠️
src/easydynamics/utils/posterior_plotting.py 98.74% 0 Missing and 3 partials ⚠️
src/easydynamics/convolution/convolution_base.py 90.90% 1 Missing and 1 partial ⚠️
src/easydynamics/sample_model/resolution_model.py 94.87% 1 Missing and 1 partial ⚠️
src/easydynamics/analysis/parameter_analysis.py 98.71% 0 Missing and 1 partial ⚠️
src/easydynamics/analysis/posterior.py 99.40% 0 Missing and 1 partial ⚠️
src/easydynamics/base_classes/easydynamics_list.py 96.87% 0 Missing and 1 partial ⚠️
... and 1 more
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##           master     #243      +/-   ##
==========================================
- Coverage   99.01%   98.47%   -0.54%     
==========================================
  Files          53       57       +4     
  Lines        4060     5512    +1452     
  Branches      706      952     +246     
==========================================
+ Hits         4020     5428    +1408     
- Misses         19       42      +23     
- Partials       21       42      +21     
Flag Coverage Δ
unittests 98.47% <96.80%> (-0.54%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/easydynamics/__init__.py 100.00% <100.00%> (ø)
src/easydynamics/analysis/__init__.py 100.00% <100.00%> (ø)
src/easydynamics/analysis/analysis.py 97.68% <100.00%> (+1.26%) ⬆️
src/easydynamics/analysis/analysis1d.py 99.25% <100.00%> (+0.16%) ⬆️
src/easydynamics/analysis/analysis_base.py 100.00% <100.00%> (ø)
src/easydynamics/analysis/fit_binding.py 95.23% <ø> (ø)
src/easydynamics/analysis/posterior_labels.py 100.00% <100.00%> (ø)
src/easydynamics/base_classes/name_mixin.py 100.00% <100.00%> (ø)
...dynamics/convolution/numerical_convolution_base.py 98.63% <100.00%> (+1.07%) ⬆️
src/easydynamics/exceptions.py 100.00% <100.00%> (ø)
... and 35 more
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@henrikjacobsenfys
henrikjacobsenfys merged commit 9867ea6 into master Aug 17, 2026
46 of 51 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[bot] release Automated release PR. Excluded from changelog/versioning

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant