Skip to content

fix: MultiStart gradient step loop crashes on a real iterations_per_full_update cadence #1420

Description

@Jammy2211

Overview

AbstractSearch.__init__ unconditionally coerces iterations_per_full_update to
float (abstract_search.py:219), so the MultiStart gradient step loop builds
its per-chunk step count as a float and range(float) raises
TypeError: 'float' object cannot be interpreted as an integer. The crash fires
only when the checkpoint cadence is below the remaining step budget — the
packaged config default is 1e99, so min(1e99, steps_remaining) always returns
the int operand and every default run took the safe branch. Any user who passes a
real cadence (e.g. iterations_per_full_update=50 with n_steps=3000) crashes on
step-loop entry. Six RAL chain jobs (331182–331190) died on it back-to-back during
the wsdev#117 Pix-Prodigy CPU campaign.

Reproduced on main (2026-07-27):

>>> s = af.MultiStartAdam(n_steps=3000, iterations_per_full_update=50)
>>> s.iterations_per_full_update
50.0
>>> range(min(s.iterations_per_full_update or s.n_steps, 3000))
TypeError: 'float' object cannot be interpreted as an integer

Plan

  • Cast the per-chunk step count to int at the consumer in the MultiStart
    gradient _fit, rather than changing the shared float coercion in
    abstract_search.py — that coercion exists to accept the inf-like 1e99
    config default and is shared by every other search, so changing it there is the
    regression-prone option.
  • Put the computation behind a tiny named helper on the abstract MultiStart class
    so the defect is unit-testable without JAX_fit itself requires jax,
    optax and a JAX-traceable Analysis, and the library unit suite is NumPy-only
    by standing rule.
  • Add NumPy-only unit tests: a real cadence below the budget returns an int
    usable by range(); the 1e99 default still yields the remaining-steps branch;
    the iterations_per_full_update=None fallback to n_steps is preserved.
  • Run the full PyAutoFit suite (Python 3.12/3.13 on CI) — no public API change,
    so there is no downstream workspace impact.
  • Two follow-ups are recorded below rather than bundled into this PR (Mind rule:
    one prompt = one task = one PR).
Detailed implementation plan

Affected Repositories

  • PyAutoFit (primary, and only)

Branch Survey

Repository Current Branch Dirty?
./PyAutoFit main clean

Worktree claim on PyAutoFit by task testmode-env-drift was verified stale
(PyAutoFit#1417 merged, PyAutoCTI#96 merged, PyAutoCTI#95 closed) and released
into complete/2026/07/testmode-env-drift.md (PyAutoMind 3a99904) before this
task started. No live conflict.

Suggested branch: feature/multistart-cadence-int-cast

Implementation Steps

  1. In autofit/non_linear/search/mle/multi_start_gradient/search.py, add a
    method to AbstractMultiStartGradient:

    def _steps_in_chunk(self, steps_remaining: int) -> int:
        """
        Number of gradient steps to run before the next ``perform_update``
        checkpoint boundary.
    
        ``iterations_per_full_update`` is stored as a **float** by
        ``AbstractSearch.__init__`` so the inf-like ``1e99`` config default is
        representable; ``range`` needs an ``int``, so the chunk size is cast
        here at the consumer. Without the cast the loop only survives because
        ``min(1e99, steps_remaining)`` returns the int operand — a real cadence
        below the remaining budget raises ``TypeError``.
        """
        return int(min(self.iterations_per_full_update or self.n_steps, steps_remaining))
  2. Replace the inline computation in _fit (currently search.py:284-286) with
    iterations = self._steps_in_chunk(steps_remaining).

  3. Add unit tests to
    test_autofit/non_linear/search/mle/test_multi_start_gradient.py (NumPy-only,
    consistent with that file's stated JAX-free contract):

    • iterations_per_full_update=50, n_steps=3000_steps_in_chunk(3000) is
      50 and isinstance(..., int); range() over it yields 50 items (the
      assertion that actually pins the reported crash).
    • the same search near the end of its budget → chunk clamps to
      steps_remaining.
    • default (config 1e99) → returns steps_remaining as an int.
    • iterations_per_full_update=None → falls back to n_steps.
  4. Run python -m pytest test_autofit/ in the task worktree.

Key Files

  • autofit/non_linear/search/mle/multi_start_gradient/search.py — the crash site
    (_fit step loop) and the new _steps_in_chunk helper.
  • autofit/non_linear/search/abstract_search.py:219 — the shared float()
    coercion; deliberately unchanged.
  • test_autofit/non_linear/search/mle/test_multi_start_gradient.py — the
    NumPy-only test surface.

Explicitly out of scope (follow-ups, not this PR)

  1. Sibling searches share the same latent float-cadence class. A sweep of
    every consumer of self.iterations_per_full_update found five more sites that
    feed the float straight into an iteration budget, each masked today by the
    same 1e99 default:

    • autofit/non_linear/search/mcmc/emcee/search.py:203-206 → float iterations
      into EnsembleSampler.sample(iterations=...)
    • autofit/non_linear/search/mcmc/zeus/search.py:239-242 → same shape
    • autofit/non_linear/search/mcmc/blackjax/nuts/search.py:291
      chunk_n = min(float, int) into the chunked NUTS runner
    • autofit/non_linear/search/mle/bfgs/search.py:171 → float options["maxiter"]
      into scipy.optimize.minimize
    • autofit/non_linear/search/nest/nautilus/search.py:477 and
      nest/dynesty/search/abstract.py:365 → float iteration budgets returned

    None of these is reproduced yet — each needs its own repro before a fix, and
    bundling six unverified changes into a hotfix PR is the wrong trade. Filed as
    a separate Mind prompt.

  2. The workspace hotfix must be removed once this merges. The post-construction
    int overwrite in
    autolens_workspace_developer/searches_minimal/pix_prodigy.py (branch
    feature/pix-prodigy-cpu, task pix-prodigy-cpu, wsdev#117) exists only to
    work around this bug. That repo is claimed by a live task, so the removal
    belongs to it — noted on wsdev#117 rather than done here.

Original Prompt

Click to expand starting prompt
# MultiStart gradient step loop crashes when iterations_per_full_update < remaining steps

Type: bug
Target: autofit
Repos:
- PyAutoFit
Difficulty: small
Autonomy: safe
Priority: high
Status: draft

## The bug (hit live on RAL, 2026-07-27, wsdev#117 campaign)

`abstract_search.py:219` unconditionally coerces `iterations_per_full_update`
to **float** (`float(iterations_per_full_update or conf...)`). The multi-start
gradient `_fit` (`autofit/non_linear/search/mle/multi_start_gradient/search.py:284`)
then computes

    iterations = min(self.iterations_per_full_update or self.n_steps, steps_remaining)
    ...
    for _ in range(iterations):   # line 298

`range(float)` → `TypeError: 'float' object cannot be interpreted as an
integer` — but **only when the cadence is below the remaining budget**, because
`min(float, int)` returns the int operand otherwise. The config default is huge,
so every existing run took the `steps_remaining` branch and the crash never
fired; any user who passes a real checkpoint cadence (e.g.
`iterations_per_full_update=50` with `n_steps=3000`) crashes on step-loop entry.
Six RAL chain jobs (331182-331190) died on it back-to-back.

Evidence: `/mnt/ral/jnightin/pixgrad_logs/pix_prod_*-33118[5-9].err`; workspace
hotfix (post-construction int overwrite) in
`autolens_workspace_developer/searches_minimal/pix_prodigy.py` (branch
`feature/pix-prodigy-cpu`) — remove it when this ships.

## Fix

`iterations = int(min(...))` in `_fit` (the float coercion in abstract_search
serves inf-like config values and is shared by other searches — casting at the
consumer is the minimal, non-regressing change). Add a unit test that runs a
MultiStart search with `iterations_per_full_update` smaller than `n_steps`
(numpy objective — library unit tests stay JAX-free) and asserts the step loop
executes + checkpoints.

Autonomy: launched with --auto. Prompt header is Autonomy: safe, but
AUTONOMY.md caps work-type bug at supervised, so the effective level is
min(safe, supervised) = supervised. This run proceeds through
implementation and tests, then parks at ship sign-off with a question on this
issue rather than opening the PR unattended.

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