Each part is independently shippable: if one turns out to be wrong or larger than
it looks, it gets dropped and the rest ship.
Click to expand starting prompt
# Three MultiStart / cadence follow-ups from the PR#1421 review
Type: bug
Target: autofit
Repos:
- PyAutoFit
Difficulty: medium
Autonomy: safe
Priority: high
Status: draft
Combines three drafts filed on 2026-07-27 (PyAutoMind `8a3a4d1`) into one task,
at the human's explicit request — one issue, one branch, one PR. They share a
subsystem (`autofit/non_linear/search/`), a review provenance (the adversarial
Codex `gpt-5.6-sol` pass over
[PR#1421](https://github.com/PyAutoLabs/PyAutoFit/pull/1421), merged `e217292`)
and a test file, so the usual one-prompt-one-PR split would cost three round
trips for three small, independently-verified fixes. The superseded drafts are
`emcee_blackjax_iterations_per_full_update_float_crash.md`,
`multistart_duplicate_final_perform_update.md` and
`multistart_stale_stop_reason_on_resume.md`.
Each part below is independently shippable — if one turns out to be wrong or
larger than it looks, drop it and ship the rest rather than blocking the PR.
---
## Part 1 — Emcee and BlackJAX NUTS crash on a real cadence (highest value)
The defect class fixed for MultiStart in PR#1421, still live in two more
searches. `AbstractSearch.__init__` coerces `iterations_per_full_update` to
**float** (`abstract_search.py:219`); both searches feed that float straight into
something needing an `int`. Latent for everyone because the packaged default is
`1e99`, so the `min`/comparison always picks the int operand — only a cadence
*below* the remaining budget reaches the crash.
Both **verified empirically**, not inferred from the call shape:
- `mcmc/emcee/search.py:203-206` — the float reaches
`emcee.EnsembleSampler.sample(iterations=...)`, whose body does
`range(iterations)` with no cast.
(`inspect.getsource` contains `range(iterations)` and no `int(iterations)`.)
- `mcmc/blackjax/nuts/search.py:291` —
`chunk_n = min(self.iterations_per_full_update, iterations_remaining)` is a
float, passed at line 294 to `jax.random.split`, which raises
`TypeError: 'float' object cannot be interpreted as an integer` on `50.0`.
Reproducer for the emcee leg: `af.Emcee(nsteps=100, iterations_per_full_update=50)`.
### Explicitly NOT affected — verify before touching, do not "fix" these
An earlier pass claimed five affected searches by reading the call shape. Three
are fine:
- `mcmc/zeus/search.py:239-242` — **safe**: zeus casts internally
(`self.nsteps = int(iterations)`).
- `mle/bfgs/search.py:171` — tolerated: becomes SciPy's `maxiter`, comparisons only.
- `nest/dynesty/search/abstract.py:365`, `nest/nautilus/search.py:477` —
tolerated: returned as comparison limits, never a loop count.
### Preferred fix: the producer, not the consumer
PR#1421's rationale for casting at the consumer — "the shared `float()` coercion
exists so the inf-like `1e99` default is representable" — **is wrong**: Python
ints represent `1e99` fine. The coercion at `abstract_search.py:219-220` (and the
HPC branch at `:240-241`) protects nothing and is the source of the whole class.
Assess storing `iterations_per_full_update` / `iterations_per_quick_update` as
`int`. Audit every consumer first: `updater.py:183` does arithmetic with it,
`fitness.py:400` compares `quick_update_count >=` it,
`test_autofit/non_linear/search/test_updater.py:17` passes `1.0`, and
`test_autofit/non_linear/test_dict.py:26` asserts the serialised `1e99`. If a
consumer genuinely needs a float, say which and why, and fall back to casting at
the two broken consumers.
Reuse PR#1421's validation idiom rather than clamping: a cadence below 1 or a
fractional one raises a `ValueError` naming the value, never silently rounded.
---
## Part 2 — MultiStart runs the expensive final `perform_update` twice
`AbstractMultiStartGradient._fit` performs its own final update with
`during_analysis=False` (`multi_start_gradient/search.py:430-439`):
```python
is_final = converged or total_steps >= self.n_steps
self.perform_update(..., during_analysis=not is_final, ...)
AbstractSearch.start_resume_fit then unconditionally performs the same
final update right after _fit returns (abstract_search.py:704-710,
during_analysis=False). Final sample output, latent computation, visualization
and profiling therefore all run twice at the end of every MultiStart run — on a
pixelized or GPU fit, a large and entirely wasted cost.
This is MultiStart being an outlier, not the framework's pattern. Every other
search passes during_analysis=True unconditionally inside _fit, leaving the
single False call to start_resume_fit: mcmc/emcee/search.py:238,
mle/bfgs/search.py:219, nest/nautilus/search.py:438.
Fix: pass during_analysis=True unconditionally in the MultiStart step-loop
update and delete the is_final computation — unless _fit genuinely needs a
final-flavoured update before returning, in which case say why and fix the
duplication at the other end.
Check the early-stopping path: when the search converges, the in-loop update
carries converged/stop_reason into samples_info. Confirm that still reaches
the final samples via the search_internal dict _fit returns (it should —
start_resume_fit's update is built from that same dict).
Regression test: count perform_update calls with during_analysis=False across
one search. NumPy-only — stub/monkeypatch rather than running a JAX fit.
Part 3 — Stale stop_reason when a finished search is resumed with a larger n_steps
_fit restores stop_reason from the loaded search_internal on the resume
path, and only reassigns it inside the loop when one of two conditions fires
(multi_start_gradient/search.py:413-416):
if converged:
stop_reason = "converged"
elif total_steps >= self.n_steps:
stop_reason = "max_steps"
Resume a search that finished with stop_reason="max_steps" using a larger
n_steps (the documented way to extend a budget). The while guard lets the
loop continue (stop_reason != "converged"), but until the new ceiling is
reached neither branch fires — so the restored "max_steps" survives into every
intermediate checkpoint. A search that is actively running reports
samples_info["stop_reason"] == "max_steps", so anything reading results mid-run
(aggregator, results inspector, monitoring) sees a finished-looking search that
is not finished. The final checkpoint self-corrects, so this is a mid-run
reporting defect, not a wrong final answer — hence lowest priority of the three.
Fix: clear stop_reason when entering the loop on a resume that will run further
steps; any real stop reason is re-derived inside the loop. Guard the interaction
with the converged short-circuit — a genuinely converged search must still
refuse to resume into more steps, so the reset must not clear "converged".
Test: NumPy-only over the stop-reason state machine — build the search_internal
dict directly and assert stop_reason / converged via
samples_via_internal_from, rather than running a JAX fit.
Constraints
- Library unit tests stay NumPy-only —
_fit needs jax + optax + a
JAX-traceable Analysis and cannot be driven from the suite. Test the
extracted seams, and add a wiring guard where a test would otherwise still
pass if the call site regressed (the trap PR#1421 hit).
- Full suite green before ship:
python -m pytest test_autofit/.
- Independent adversarial review with Codex
gpt-5.6-sol at the end, per the
human's instruction, before the ship gate's review leg is settled.
Overview
Three bugs in
autofit/non_linear/search/, all surfaced by the adversarialreview of #1420 / PR#1421 (merged
e217292) and each independently verifiedbefore filing. They were filed as three PyAutoMind drafts and folded into one
task at the human's request: they share a subsystem, a provenance and a test
file, so three separate round trips would cost more than they'd buy.
iterations_per_full_updatecadence — the same defect class PR#1421 fixed for MultiStart.
perform_updatetwice.n_stepskeeps a stalestop_reason.Each part is independently shippable: if one turns out to be wrong or larger than
it looks, it gets dropped and the rest ship.
Plan
and justified it with "the shared
float()coercion exists so1e99isrepresentable". That reasoning was wrong — Python ints represent
1e99fine —so the coercion at
abstract_search.py:219protects nothing and is thedefect class. Audit every consumer of the two cadence attributes, then store
them as
intif nothing genuinely needs a float; fall back to casting at thetwo broken consumers only with a stated reason.
or tolerant, and an earlier five-search claim was falsified by probing the
actual callees.
during_analysis=Trueunconditionally, matching every sibling search, so the one final update is left
to
start_resume_fit.stop_reasonwhen a resume is going to runfurther steps, without disturbing the
convergedshort-circuit that stops aconverged search resuming.
if the call site regressed (the trap PR#1421 hit).
gpt-5.6-solat the end, beforethe review leg is settled.
Detailed implementation plan
Affected Repositories
Branch Survey
No worktree claim on PyAutoFit (the
multistart-cadence-int-castclaim wasreleased on merge).
Suggested branch:
feature/multistart-cadence-followupsKey Files
autofit/non_linear/search/abstract_search.py:219-220,:240-241— thefloat()coercion, the likely producer fix.autofit/non_linear/search/mcmc/emcee/search.py:203-206— crash site 1.autofit/non_linear/search/mcmc/blackjax/nuts/search.py:291— crash site 2.autofit/non_linear/search/mle/multi_start_gradient/search.py:413-439— theduplicate final update and the stale
stop_reason.autofit/non_linear/search/abstract_search.py:704-710— the framework's singlefinal update.
intswitch:updater.py:183(arithmetic),fitness.py:400(comparison),test_updater.py:17(passes1.0),test_dict.py:26(asserts serialised1e99).Work Classification
Library.
Worktree root
~/Code/PyAutoLabs-wt/multistart-cadence-followups/(created by/start_library).Original Prompt
Click to expand starting prompt
AbstractSearch.start_resume_fitthen unconditionally performs the samefinal update right after
_fitreturns (abstract_search.py:704-710,during_analysis=False). Final sample output, latent computation, visualizationand profiling therefore all run twice at the end of every MultiStart run — on a
pixelized or GPU fit, a large and entirely wasted cost.
This is MultiStart being an outlier, not the framework's pattern. Every other
search passes
during_analysis=Trueunconditionally inside_fit, leaving thesingle
Falsecall tostart_resume_fit:mcmc/emcee/search.py:238,mle/bfgs/search.py:219,nest/nautilus/search.py:438.Fix: pass
during_analysis=Trueunconditionally in the MultiStart step-loopupdate and delete the
is_finalcomputation — unless_fitgenuinely needs afinal-flavoured update before returning, in which case say why and fix the
duplication at the other end.
Check the early-stopping path: when the search converges, the in-loop update
carries
converged/stop_reasonintosamples_info. Confirm that still reachesthe final samples via the
search_internaldict_fitreturns (it should —start_resume_fit's update is built from that same dict).Regression test: count
perform_updatecalls withduring_analysis=Falseacrossone search. NumPy-only — stub/monkeypatch rather than running a JAX fit.
Part 3 — Stale
stop_reasonwhen a finished search is resumed with a largern_steps_fitrestoresstop_reasonfrom the loadedsearch_internalon the resumepath, and only reassigns it inside the loop when one of two conditions fires
(
multi_start_gradient/search.py:413-416):Resume a search that finished with
stop_reason="max_steps"using a largern_steps(the documented way to extend a budget). Thewhileguard lets theloop continue (
stop_reason != "converged"), but until the new ceiling isreached neither branch fires — so the restored
"max_steps"survives into everyintermediate checkpoint. A search that is actively running reports
samples_info["stop_reason"] == "max_steps", so anything reading results mid-run(aggregator, results inspector, monitoring) sees a finished-looking search that
is not finished. The final checkpoint self-corrects, so this is a mid-run
reporting defect, not a wrong final answer — hence lowest priority of the three.
Fix: clear
stop_reasonwhen entering the loop on a resume that will run furthersteps; any real stop reason is re-derived inside the loop. Guard the interaction
with the
convergedshort-circuit — a genuinely converged search must stillrefuse to resume into more steps, so the reset must not clear
"converged".Test: NumPy-only over the stop-reason state machine — build the
search_internaldict directly and assert
stop_reason/convergedviasamples_via_internal_from, rather than running a JAX fit.Constraints
_fitneeds jax + optax + aJAX-traceable
Analysisand cannot be driven from the suite. Test theextracted seams, and add a wiring guard where a test would otherwise still
pass if the call site regressed (the trap PR#1421 hit).
python -m pytest test_autofit/.gpt-5.6-solat the end, per thehuman's instruction, before the ship gate's review leg is settled.