Overview
Python 3.14 support is blocked by a single reproducible failure: the factor-graph tutorial (autofit_workspace/scripts/overview/overview_1_the_basics.py) dies with TypeError: 'Gaussian' object is not iterable inside a dynesty pool worker. Root cause is now confirmed empirically: Python 3.14 changed the default multiprocessing start method on Linux from fork to forkserver (workspace scripts are unguarded and forkserver preloads __main__, so worker state is re-derived/pickled instead of fork-inherited). Forcing the fork start method makes the identical script complete cleanly on 3.14.4. The fix is to pin an explicit fork context at PyAutoFit's pool-creation sites on POSIX, restoring pre-3.14 semantics, then re-add 3.14 to the evidence matrix and classifiers.
Plan
- Add one central fork-context helper to PyAutoFit's parallel machinery (POSIX-only; platform default retained on Windows).
- Use it at every pool/process creation site: dynesty (via a small
Pool subclass, since upstream hardcodes mp.Pool), make_pool, SneakyPool, the raw Process/Queue layer, EP optimiser, and pass nautilus a fork-context pool object instead of an int.
- Add a numpy-only unit test asserting autofit pools use the fork context on POSIX.
- Verify the failing tutorial passes on a Python 3.14 venv, and the suite stays green on 3.12/3.13.
- Follow-up (separate small PRs once this merges): re-add 3.14 to PyAutoHands
python_matrix.yml and library classifiers; retarget the PyAutoNerves 3.14 experimental banner.
Detailed implementation plan
Affected Repositories
- PyAutoFit (primary — this PR)
- PyAutoHands + library classifiers + PyAutoNerves banner (follow-up PRs, gated on this fix)
Branch Survey
| Repository |
Current Branch |
Dirty? |
| ./PyAutoFit |
main (== origin/main, 09a134d) |
clean |
Three existing worktrees (point-source-defaults-campaign, potential-correction-validation, start-here-other-feature-sections) contain PyAutoFit but all sit on undiverged main — no conflict.
Suggested branch: feature/py314-fork-context
Root-cause evidence (py3.14.4 venv, full editable stack)
- Full dependency stack installs cleanly on 3.14 (jax 0.10.2, numba 0.66, numpy 2.4.6, dynesty 2.1.5 all have wheels) — no dependency blocker.
- In-process factor-graph likelihood and pickle round-trips of
FactorGraphModel / global prior model are correct on 3.14 — the prompt's hypotheses 1 (iteration protocol) and 2 (model flattening) are ruled out.
- Unmodified tutorial under the 3.14 default (
forkserver): reproduces the exact CI failure ('Gaussian' object is not iterable raised through dynesty/pool.py map).
- Identical tutorial with
multiprocessing.set_start_method("fork", force=True) (runpy wrapper): completes end-to-end.
- A
__main__-guarded replication of autofit's exact dynesty Pool(...) call works under both fork and forkserver — the breakage is the unguarded-script + forkserver combination, and workspace scripts are unguarded by design.
- Second silent failure mode: on 3.14 the dynesty pool creation can raise
RuntimeError, which abstract.py swallows into the misleading "Your operating system does not support Python multiprocessing" single-CPU fallback — scripts that "pass" silently lose parallelism.
Implementation Steps
- New helper in
autofit/non_linear/parallel/ (e.g. context.py): mp_context() returning multiprocessing.get_context("fork") on POSIX and the default context on Windows. One place to document the 3.14 rationale.
autofit/non_linear/search/nest/dynesty/search/abstract.py:214 — dynesty's Pool.__enter__ hardcodes mp.Pool(...); add a small subclass overriding __enter__ to build the pool from the fork context (identical initializer/initargs), and use it in the with Pool(...) block.
autofit/non_linear/search/abstract_search.py:1319 (make_pool) — mp.Pool(...) → fork-context pool.
autofit/non_linear/parallel/sneaky.py:394 (SneakyPool, covers emcee/zeus) — mp.Pool(...) → fork-context pool.
autofit/non_linear/parallel/process.py — multiprocessing.Process/Queue → fork-context equivalents.
autofit/graphical/expectation_propagation/optimiser.py:463 — multiprocessing.Pool(...) → fork-context pool.
autofit/non_linear/search/nest/nautilus/search.py:331 — nautilus builds its own default-context pool when passed pool=int; pass a fork-context pool object instead (nautilus accepts pool objects).
- Unit test in
test_autofit/ (numpy-only, no JAX): assert pools produced by the helper / make_pool use the fork context on POSIX; skip on Windows.
- Validation: full pytest on 3.12/3.13; on a 3.14 venv run the reproducer from the prompt (
PYAUTO_TEST_MODE=1 PYAUTO_SMALL_DATASETS=1 python3.14 autofit_workspace/scripts/overview/overview_1_the_basics.py) and confirm clean completion.
Key Files
autofit/non_linear/parallel/context.py — new fork-context helper (name TBD)
autofit/non_linear/search/nest/dynesty/search/abstract.py — dynesty pool subclass + use
autofit/non_linear/search/abstract_search.py — make_pool
autofit/non_linear/parallel/sneaky.py, autofit/non_linear/parallel/process.py — SneakyPool / Process / Queue
autofit/graphical/expectation_propagation/optimiser.py — EP pool
autofit/non_linear/search/nest/nautilus/search.py — pass pool object
Trade-offs / risks
- CPython warns on fork-with-threads (since 3.12); pinning fork is the pragmatic fix that preserves long-standing semantics — guarding every workspace script is not viable. Revisit if CPython restricts fork further.
- fork context is unavailable on Windows: helper falls back to the platform default there (spawn), which is today's behavior.
- The micro-mechanism of the instance flattening inside forkserver workers was not fully traced; the fix is justified by restoring the exact pre-3.14 execution model, verified end-to-end.
Original Prompt
Click to expand starting prompt
Investigate FactorGraphModel instance shape on Python 3.14
Type: bug
Target: PyAutoFit
Difficulty: too-large
Autonomy: supervised
Priority: normal
Status: formalised
Problem
autofit_workspace/scripts/overview/overview_1_the_basics.py runs cleanly
on Python 3.9–3.13 but fails on Python 3.14 with:
TypeError: 'Gaussian' object is not iterable
This was surfaced by the python_matrix.yml evidence run (issue
PyAutoLabs/PyAutoBuild#74, run 25208343008). Same script, same library
code — only the Python version differs.
3.14 was dropped from advertised support (classifiers + python_matrix.yml
matrix) until this is understood. requires-python still allows >=3.9,
so users who install on 3.14 anyway will see the import-time banner warn
that 3.14 isn't first-class.
Reproducer
Run on Python 3.14 in a venv with the libraries installed editable:
python3.14 -m venv /tmp/py314
/tmp/py314/bin/pip install -e PyAutoConf -e PyAutoArray -e PyAutoFit \
-e PyAutoGalaxy -e "PyAutoLens[optional]"
PYAUTO_TEST_MODE=1 PYAUTO_SMALL_DATASETS=1 \
/tmp/py314/bin/python autofit_workspace/scripts/overview/overview_1_the_basics.py
Reproduces immediately when the script reaches the factor_graph = af.FactorGraphModel(*analysis_factor_list) block (~line 545).
Stack trace (abridged)
File "autofit_workspace/scripts/overview/overview_1_the_basics.py", line 813
[profile_1d.model_data_from(xvalues=xvalues) for profile_1d in instance]
^^^^^^^^
TypeError: 'Gaussian' object is not iterable
The above exception was the direct cause of the following exception:
File "autofit_workspace/scripts/overview/overview_1_the_basics.py", line 563
result_list = search.fit(model=factor_graph.global_prior_model,
analysis=factor_graph)
File "PyAutoFit/autofit/non_linear/search/abstract_search.py", line 668
search_internal, fitness = self._fit(...)
File "PyAutoFit/autofit/graphical/declarative/collection.py", line 105
log_likelihood += model_factor.log_likelihood_function(instance_)
File "PyAutoFit/autofit/graphical/declarative/factor/analysis.py", line 189
return self.analysis.log_likelihood_function(instance)
The workspace Analysis.log_likelihood_function expects instance to be
a Collection of profiles (since the model was built as
af.Collection(gaussian=Gaussian(), exponential=Exponential())). On
3.9–3.13 it receives a Collection; on 3.14 it receives a single
Gaussian object directly.
What we know
model = af.Collection(gaussian=Gaussian(), exponential=Exponential())
- Each
AnalysisFactor is built with model.copy() — so each factor's
prior_model is itself a Collection of two profiles.
FactorGraphModel(*analysis_factor_list).global_prior_model is the
combined model passed to the search.
- On 3.9–3.13:
zip(self.model_factors, instance) in
collection.py:104 yields (factor, sub_instance) pairs where
sub_instance is the per-factor Collection instance — iterable.
- On 3.14: the same iteration yields a single
Gaussian instance — not
iterable.
So either:
ModelInstance.__iter__ (which falls back to __getitem__ since
ModelInstance has no explicit __iter__) yields different child
types on 3.14, OR
FactorGraphModel.global_prior_model constructs a flatter structure
on 3.14 (collapses nested Collections into scalars), OR
- dynesty's multiprocessing pickling round-trips the model differently
on 3.14 (the trace shows multiprocessing.pool.RemoteTraceback,
suggesting the worker process saw a different structure than the
main process).
Where to start investigating
-
Print instance and type(instance) at the top of the workspace
Analysis.log_likelihood_function on a 3.14 venv. Compare against
3.13. Specifically:
- Is
instance a ModelInstance, a Collection, or a raw
Gaussian?
- What does
instance.__dict__ look like on each version?
- What does
list(instance) do?
-
autofit/mapper/model.py:385 ModelInstance has no explicit
__iter__ — Python falls back to the legacy sequence protocol via
__getitem__. ModelInstance.__getitem__(int) returns
list(self.values())[item]. On 3.14, check whether values() and
the resulting iteration yield different types than on 3.13.
-
FactorGraphModel.global_prior_model — trace how the per-factor
Collection structures get composed into the global model. If 3.14
flattens Collection -> [profile_1, profile_2] into bare profiles
(because the dict ordering or attribute lookup behaves differently),
the per-factor instance_ would be a single profile.
-
Check whether dynesty.pool (from the failure trace) round-trips
the model object correctly on 3.14. The error is wrapped in a
multiprocessing.pool.RemoteTraceback, so the failure is happening
in a worker process. Try dynesty(parallel=False) to isolate.
-
Python 3.14 release notes worth scanning for relevant behavior
changes:
- PEP 768: safe external debugger interface
- PEP 749: late-bound default values (annotations)
- PEP 765: disallow
return/break/continue in finally
- Behavior changes around
dict ordering, __init_subclass__,
__set_name__, descriptor lookup
Constraints when fixing
- Don't modify the workspace tutorial script just to paper over the
symptom. The script worked on 3.9–3.13 because the library produced
the right shape; it should produce that same shape on 3.14.
- Library unit tests must remain numpy-only — don't add jax-dependent
tests for this.
- If the fix requires a workspace-side change too (e.g. a defensive
helper), keep it minimal and add a comment pointing back to this
prompt.
Done when
python3.14 autofit_workspace/scripts/overview/overview_1_the_basics.py
runs cleanly under PYAUTO_TEST_MODE=1.
- 3.14 can be re-added to PyAutoBuild's
python_matrix.yml matrix and
to each library's pyproject.toml classifiers.
- The change is unit-tested in
test_autofit/ with a numpy-only
factor-graph round-trip test that would have caught the 3.14 shape
collapse on 3.13 too if the structure had been wrong there.
Overview
Python 3.14 support is blocked by a single reproducible failure: the factor-graph tutorial (
autofit_workspace/scripts/overview/overview_1_the_basics.py) dies withTypeError: 'Gaussian' object is not iterableinside a dynesty pool worker. Root cause is now confirmed empirically: Python 3.14 changed the default multiprocessing start method on Linux fromforktoforkserver(workspace scripts are unguarded and forkserver preloads__main__, so worker state is re-derived/pickled instead of fork-inherited). Forcing theforkstart method makes the identical script complete cleanly on 3.14.4. The fix is to pin an explicitforkcontext at PyAutoFit's pool-creation sites on POSIX, restoring pre-3.14 semantics, then re-add 3.14 to the evidence matrix and classifiers.Plan
Poolsubclass, since upstream hardcodesmp.Pool),make_pool, SneakyPool, the rawProcess/Queuelayer, EP optimiser, and pass nautilus a fork-context pool object instead of an int.python_matrix.ymland library classifiers; retarget the PyAutoNerves 3.14 experimental banner.Detailed implementation plan
Affected Repositories
Branch Survey
Three existing worktrees (
point-source-defaults-campaign,potential-correction-validation,start-here-other-feature-sections) contain PyAutoFit but all sit on undivergedmain— no conflict.Suggested branch:
feature/py314-fork-contextRoot-cause evidence (py3.14.4 venv, full editable stack)
FactorGraphModel/ global prior model are correct on 3.14 — the prompt's hypotheses 1 (iteration protocol) and 2 (model flattening) are ruled out.forkserver): reproduces the exact CI failure ('Gaussian' object is not iterableraised throughdynesty/pool.py map).multiprocessing.set_start_method("fork", force=True)(runpy wrapper): completes end-to-end.__main__-guarded replication of autofit's exact dynestyPool(...)call works under both fork and forkserver — the breakage is the unguarded-script + forkserver combination, and workspace scripts are unguarded by design.RuntimeError, whichabstract.pyswallows into the misleading "Your operating system does not support Python multiprocessing" single-CPU fallback — scripts that "pass" silently lose parallelism.Implementation Steps
autofit/non_linear/parallel/(e.g.context.py):mp_context()returningmultiprocessing.get_context("fork")on POSIX and the default context on Windows. One place to document the 3.14 rationale.autofit/non_linear/search/nest/dynesty/search/abstract.py:214— dynesty'sPool.__enter__hardcodesmp.Pool(...); add a small subclass overriding__enter__to build the pool from the fork context (identical initializer/initargs), and use it in thewith Pool(...)block.autofit/non_linear/search/abstract_search.py:1319(make_pool) —mp.Pool(...)→ fork-context pool.autofit/non_linear/parallel/sneaky.py:394(SneakyPool, covers emcee/zeus) —mp.Pool(...)→ fork-context pool.autofit/non_linear/parallel/process.py—multiprocessing.Process/Queue→ fork-context equivalents.autofit/graphical/expectation_propagation/optimiser.py:463—multiprocessing.Pool(...)→ fork-context pool.autofit/non_linear/search/nest/nautilus/search.py:331— nautilus builds its own default-context pool when passedpool=int; pass a fork-context pool object instead (nautilus accepts pool objects).test_autofit/(numpy-only, no JAX): assert pools produced by the helper /make_pooluse the fork context on POSIX; skip on Windows.PYAUTO_TEST_MODE=1 PYAUTO_SMALL_DATASETS=1 python3.14 autofit_workspace/scripts/overview/overview_1_the_basics.py) and confirm clean completion.Key Files
autofit/non_linear/parallel/context.py— new fork-context helper (name TBD)autofit/non_linear/search/nest/dynesty/search/abstract.py— dynesty pool subclass + useautofit/non_linear/search/abstract_search.py—make_poolautofit/non_linear/parallel/sneaky.py,autofit/non_linear/parallel/process.py— SneakyPool / Process / Queueautofit/graphical/expectation_propagation/optimiser.py— EP poolautofit/non_linear/search/nest/nautilus/search.py— pass pool objectTrade-offs / risks
Original Prompt
Click to expand starting prompt
Investigate FactorGraphModel instance shape on Python 3.14
Type: bug
Target: PyAutoFit
Difficulty: too-large
Autonomy: supervised
Priority: normal
Status: formalised
Problem
autofit_workspace/scripts/overview/overview_1_the_basics.pyruns cleanlyon Python 3.9–3.13 but fails on Python 3.14 with:
This was surfaced by the
python_matrix.ymlevidence run (issuePyAutoLabs/PyAutoBuild#74, run 25208343008). Same script, same librarycode — only the Python version differs.
3.14 was dropped from advertised support (classifiers +
python_matrix.ymlmatrix) until this is understood.
requires-pythonstill allows>=3.9,so users who install on 3.14 anyway will see the import-time banner warn
that 3.14 isn't first-class.
Reproducer
Run on Python 3.14 in a venv with the libraries installed editable:
python3.14 -m venv /tmp/py314 /tmp/py314/bin/pip install -e PyAutoConf -e PyAutoArray -e PyAutoFit \ -e PyAutoGalaxy -e "PyAutoLens[optional]" PYAUTO_TEST_MODE=1 PYAUTO_SMALL_DATASETS=1 \ /tmp/py314/bin/python autofit_workspace/scripts/overview/overview_1_the_basics.pyReproduces immediately when the script reaches the
factor_graph = af.FactorGraphModel(*analysis_factor_list)block (~line 545).Stack trace (abridged)
The workspace
Analysis.log_likelihood_functionexpectsinstanceto bea
Collectionof profiles (since the model was built asaf.Collection(gaussian=Gaussian(), exponential=Exponential())). On3.9–3.13 it receives a
Collection; on 3.14 it receives a singleGaussianobject directly.What we know
model = af.Collection(gaussian=Gaussian(), exponential=Exponential())AnalysisFactoris built withmodel.copy()— so each factor'sprior_modelis itself a Collection of two profiles.FactorGraphModel(*analysis_factor_list).global_prior_modelis thecombined model passed to the search.
zip(self.model_factors, instance)incollection.py:104yields(factor, sub_instance)pairs wheresub_instanceis the per-factorCollectioninstance — iterable.Gaussianinstance — notiterable.
So either:
ModelInstance.__iter__(which falls back to__getitem__sinceModelInstancehas no explicit__iter__) yields different childtypes on 3.14, OR
FactorGraphModel.global_prior_modelconstructs a flatter structureon 3.14 (collapses nested Collections into scalars), OR
on 3.14 (the trace shows
multiprocessing.pool.RemoteTraceback,suggesting the worker process saw a different structure than the
main process).
Where to start investigating
Print
instanceandtype(instance)at the top of the workspaceAnalysis.log_likelihood_functionon a 3.14 venv. Compare against3.13. Specifically:
instanceaModelInstance, aCollection, or a rawGaussian?instance.__dict__look like on each version?list(instance)do?autofit/mapper/model.py:385 ModelInstancehas no explicit__iter__— Python falls back to the legacy sequence protocol via__getitem__.ModelInstance.__getitem__(int)returnslist(self.values())[item]. On 3.14, check whethervalues()andthe resulting iteration yield different types than on 3.13.
FactorGraphModel.global_prior_model— trace how the per-factorCollection structures get composed into the global model. If 3.14
flattens
Collection -> [profile_1, profile_2]into bare profiles(because the dict ordering or attribute lookup behaves differently),
the per-factor
instance_would be a single profile.Check whether
dynesty.pool(from the failure trace) round-tripsthe model object correctly on 3.14. The error is wrapped in a
multiprocessing.pool.RemoteTraceback, so the failure is happeningin a worker process. Try
dynesty(parallel=False)to isolate.Python 3.14 release notes worth scanning for relevant behavior
changes:
return/break/continueinfinallydictordering,__init_subclass__,__set_name__, descriptor lookupConstraints when fixing
symptom. The script worked on 3.9–3.13 because the library produced
the right shape; it should produce that same shape on 3.14.
tests for this.
helper), keep it minimal and add a comment pointing back to this
prompt.
Done when
python3.14 autofit_workspace/scripts/overview/overview_1_the_basics.pyruns cleanly under
PYAUTO_TEST_MODE=1.python_matrix.ymlmatrix andto each library's
pyproject.tomlclassifiers.test_autofit/with a numpy-onlyfactor-graph round-trip test that would have caught the 3.14 shape
collapse on 3.13 too if the structure had been wrong there.