Skip to content

fix(test): seed the flaky interpolator covariance tests (1.65% flake blocked 2026.8.2.1) #1450

Description

@Jammy2211

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:

  1. 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.
  2. the stdlib random module — autofit/non_linear/initializer.py:301 draws the
    search's initial unit values with random.uniform.
  3. dynesty's rstateNestedSampler 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.

  1. Add import random and a module-level SEED = 20260802.
  2. 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.
  3. 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_variable24.577316739353 (dev 0.42 vs tol 2.0);
    test_variable_and_constant22.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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions