Summary
test_autofit/interpolator/test_covariance.py::test_variable_and_constant is
non-deterministic and fails roughly 1 CI run in 60. It is the sole reason the
2026.8.2.1 live release died (PyAutoHands run 30736527569,
release_test_pypi (3.12, PyAutoFit, main), step 9: 1641 passed, 1 failed,
assert 30.121646313498022 == 25.0 ± 5).
The prompt attributed this to the unseeded np.random.random() calls in the test
body. Measurement shows that is only a minor contributor, and that a second
test in the same file is flakier still.
Branch: claude/covariance-interpolator-rng-seed-8zexp3
Measured pre-fix failure rate
2000 independent runs of the test body under the same conditions CI uses (the
autouse limit_maxcall fixture caps the search at maxcall=1):
| tolerance |
failures |
rate |
95% CI (Wilson) |
abs=5.0 (the assertion) |
33 / 2000 |
1.65% |
1.18% – 2.31% |
abs=4.0 |
109 / 2000 |
5.45% |
4.54% – 6.53% |
abs=3.0 |
293 / 2000 |
14.65% |
13.17% – 16.27% |
Recovered value: mean 25.24, std 2.05, range 17.82 – 32.04. The observed CI
failure (30.12) sits comfortably inside that distribution.
This is percent-level, not one-in-thousands — the case the prompt flagged as
"seeding alone would be hiding a genuinely mis-calibrated assertion".
Root cause — the tolerance is fine, the search is crippled
With a full (untruncated) Dynesty search the same test recovers
25.0496 ± 0.0039 (n=20) — 500× tighter and nowhere near the abs=5.0
boundary. So abs=5.0 is not mis-calibrated; the estimator is.
The limit_maxcall fixture (added with #1386 for speed) caps every search in
this module at a single likelihood call, so the "recovered" value is an
unconverged draw rather than a converged estimate. Its spread is then set
entirely by the search's own randomness.
Stage-wise check with all seeds fixed: the interpolator's inputs (x, y, the
inverse covariance matrix) are bit-identical across runs. All the variance is
inside search.fit(...).
Three independent generators feed it, which is why seeding any one is not enough:
numpy.random — the two np.random.random() calls in the test body.
Replacing them with a fixed-seed local Generator leaves std at 1.95 (from
2.05) and still fails abs=5.0. Not the dominant term.
- the stdlib
random module — autofit/non_linear/initializer.py:301 draws the
search's initial unit values with random.uniform.
- dynesty's
rstate — NestedSampler defaults it to
np.random.Generator(PCG64(None)), seeded from OS entropy and reachable from
neither of the above. This is the dominant term.
The sibling is worse
test_single_variable contains no np.random call at all, yet over 500 runs
it produced 500 distinct values and missed its abs=2.0 tolerance
18/500 = 3.6% of the time (std 0.88, range 22.26 – 29.66). Seeding
np.random would not have touched it. It fails on source (3) alone.
Explicitly ruled out: the scipy.linalg.LinAlgError try/except on
test_interpolate / test_relationships / test_interpolate_other_field /
test_linear_analysis_for_value (added by e29c69ef2) is not the same
nondeterminism in disguise. The interpolator fixture in conftest.py uses no
RNG; its covariance matrix is bit-identical across builds and rank-deficient
(rank 6 of 9; each 3×3 block rank 2 of 3, condition number 1.3e17). Those guards
protect against inverting a singular matrix — deterministic input, platform-LAPACK
dependent — which is a separate issue.
High-level plan
- Measure the pre-fix failure rate before changing anything, so seeding is not
applied over a genuinely wrong tolerance. Done: 1.65%.
- Establish which generator actually drives the variance, rather than assuming.
- Seed all three sources from a single autouse fixture in the test module, so
both flaky tests become deterministic.
- Restore global generator state on teardown so nothing leaks into later tests in
the same process.
- Confirm the recovered values are bit-identical across repeated runs, across
separate processes, and whether the module is run whole or filtered.
- Record the numbers, and file the underlying library gap as a follow-up.
Detailed plan
Single file: test_autofit/interpolator/test_covariance.py. Test-only; no
library source or public API changes.
- Add
import random and a module-level SEED = 20260802.
- Add an autouse
seed_search_randomness(monkeypatch) fixture beside the
existing limit_maxcall:
monkeypatch.setattr(dynesty.dynesty, "get_random_generator", lambda seed=None: np.random.default_rng(SEED if seed is None else seed))
— dynesty/dynesty.py binds the name at import (line 18) and calls it at
lines 608/814 to build rstate, so patching that binding covers both
NestedSampler and DynamicNestedSampler.
- save
random.getstate() / np.random.get_state(), seed both with SEED,
and restore them in a finally after yield — this is why a bare
np.random.seed at module level was not used.
- In
test_variable_and_constant, build the samples from a local
rng = np.random.default_rng(SEED) instead of the global np.random.
limit_maxcall is left as-is; the module keeps running in ~1s.
Testing
- Recovered values are bit-identical across 8 pytest invocations and in both
selection contexts (whole module vs -k filtered):
test_single_variable → 24.577316739353 (dev 0.42 vs tol 2.0);
test_variable_and_constant → 22.718814166116 (dev 2.28 vs tol 5.0).
Both pass with margin, and now pass or fail identically every time.
- Full
pytest test_autofit/: 1642 passed, 6 skipped, plus one pre-existing
environment failure (test_nautilus.py::test__single_core_builds_no_pool,
ModuleNotFoundError: No module named 'nautilus' — optional dep absent in the
sandbox; reproduced identically with this change stashed).
Known limitation / follow-up
Under maxcall=1 these two assertions are now frozen-RNG regression checks
rather than accuracy checks — a seeded draw 2.28 away from the true 25.05 still
"passes" at abs=5.0. Making them meaningful again needs either a real search
budget (measured: maxcall=200 → std 1.06 at 0.76 s/draw; maxcall=1000 → std
0.46 at 2.67 s/draw; full → std 0.004 at ~25 s/draw) or a supported way to seed a
search.
That second option is the real gap: PyAutoFit has no way to make a search
reproducible. AbstractDynesty.search_kwargs is a closed dict, so rstate
cannot be passed through even though dynesty accepts it — which is precisely
why this fix has to monkeypatch a third-party module from a test. A seed /
rstate option on the search classes would let this fixture become a one-liner
and would give users reproducible fits. Filed separately rather than folded in
here, to keep the release-unblocking change test-only.
Summary
test_autofit/interpolator/test_covariance.py::test_variable_and_constantisnon-deterministic and fails roughly 1 CI run in 60. It is the sole reason the
2026.8.2.1 live release died (PyAutoHands run 30736527569,
release_test_pypi (3.12, PyAutoFit, main), step 9: 1641 passed, 1 failed,assert 30.121646313498022 == 25.0 ± 5).The prompt attributed this to the unseeded
np.random.random()calls in the testbody. Measurement shows that is only a minor contributor, and that a second
test in the same file is flakier still.
Branch:
claude/covariance-interpolator-rng-seed-8zexp3Measured pre-fix failure rate
2000 independent runs of the test body under the same conditions CI uses (the
autouse
limit_maxcallfixture caps the search atmaxcall=1):abs=5.0(the assertion)abs=4.0abs=3.0Recovered value: mean 25.24, std 2.05, range 17.82 – 32.04. The observed CI
failure (30.12) sits comfortably inside that distribution.
This is percent-level, not one-in-thousands — the case the prompt flagged as
"seeding alone would be hiding a genuinely mis-calibrated assertion".
Root cause — the tolerance is fine, the search is crippled
With a full (untruncated) Dynesty search the same test recovers
25.0496 ± 0.0039 (n=20) — 500× tighter and nowhere near the
abs=5.0boundary. So
abs=5.0is not mis-calibrated; the estimator is.The
limit_maxcallfixture (added with #1386 for speed) caps every search inthis module at a single likelihood call, so the "recovered" value is an
unconverged draw rather than a converged estimate. Its spread is then set
entirely by the search's own randomness.
Stage-wise check with all seeds fixed: the interpolator's inputs (
x,y, theinverse covariance matrix) are bit-identical across runs. All the variance is
inside
search.fit(...).Three independent generators feed it, which is why seeding any one is not enough:
numpy.random— the twonp.random.random()calls in the test body.Replacing them with a fixed-seed local
Generatorleaves std at 1.95 (from2.05) and still fails
abs=5.0. Not the dominant term.randommodule —autofit/non_linear/initializer.py:301draws thesearch's initial unit values with
random.uniform.rstate—NestedSamplerdefaults it tonp.random.Generator(PCG64(None)), seeded from OS entropy and reachable fromneither of the above. This is the dominant term.
The sibling is worse
test_single_variablecontains nonp.randomcall at all, yet over 500 runsit produced 500 distinct values and missed its
abs=2.0tolerance18/500 = 3.6% of the time (std 0.88, range 22.26 – 29.66). Seeding
np.randomwould not have touched it. It fails on source (3) alone.Explicitly ruled out: the
scipy.linalg.LinAlgErrortry/except ontest_interpolate/test_relationships/test_interpolate_other_field/test_linear_analysis_for_value(added bye29c69ef2) is not the samenondeterminism in disguise. The
interpolatorfixture inconftest.pyuses noRNG; its covariance matrix is bit-identical across builds and rank-deficient
(rank 6 of 9; each 3×3 block rank 2 of 3, condition number 1.3e17). Those guards
protect against inverting a singular matrix — deterministic input, platform-LAPACK
dependent — which is a separate issue.
High-level plan
applied over a genuinely wrong tolerance. Done: 1.65%.
both flaky tests become deterministic.
the same process.
separate processes, and whether the module is run whole or filtered.
Detailed plan
Single file:
test_autofit/interpolator/test_covariance.py. Test-only; nolibrary source or public API changes.
import randomand a module-levelSEED = 20260802.seed_search_randomness(monkeypatch)fixture beside theexisting
limit_maxcall:monkeypatch.setattr(dynesty.dynesty, "get_random_generator", lambda seed=None: np.random.default_rng(SEED if seed is None else seed))—
dynesty/dynesty.pybinds the name at import (line 18) and calls it atlines 608/814 to build
rstate, so patching that binding covers bothNestedSamplerandDynamicNestedSampler.random.getstate()/np.random.get_state(), seed both withSEED,and restore them in a
finallyafteryield— this is why a barenp.random.seedat module level was not used.test_variable_and_constant, build the samples from a localrng = np.random.default_rng(SEED)instead of the globalnp.random.limit_maxcallis left as-is; the module keeps running in ~1s.Testing
selection contexts (whole module vs
-kfiltered):test_single_variable→24.577316739353(dev 0.42 vs tol 2.0);test_variable_and_constant→22.718814166116(dev 2.28 vs tol 5.0).Both pass with margin, and now pass or fail identically every time.
pytest test_autofit/: 1642 passed, 6 skipped, plus one pre-existingenvironment failure (
test_nautilus.py::test__single_core_builds_no_pool,ModuleNotFoundError: No module named 'nautilus'— optional dep absent in thesandbox; reproduced identically with this change stashed).
Known limitation / follow-up
Under
maxcall=1these two assertions are now frozen-RNG regression checksrather than accuracy checks — a seeded draw 2.28 away from the true 25.05 still
"passes" at
abs=5.0. Making them meaningful again needs either a real searchbudget (measured:
maxcall=200→ std 1.06 at 0.76 s/draw;maxcall=1000→ std0.46 at 2.67 s/draw; full → std 0.004 at ~25 s/draw) or a supported way to seed a
search.
That second option is the real gap: PyAutoFit has no way to make a search
reproducible.
AbstractDynesty.search_kwargsis a closed dict, sorstatecannot be passed through even though
dynestyaccepts it — which is preciselywhy this fix has to monkeypatch a third-party module from a test. A
seed/rstateoption on the search classes would let this fixture become a one-linerand would give users reproducible fits. Filed separately rather than folded in
here, to keep the release-unblocking change test-only.