Fix correctness bugs in the evolutionary optimisation core - #295
Merged
Conversation
Collaborator
Author
|
Second commit (3d7acba) — four more fixes from the continued audit, same method: each reproduced on the unfixed code first; each regression test fails before and passes after.
With the second commit the suite is 285 passed ( |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #295 +/- ##
==========================================
+ Coverage 72.06% 72.71% +0.64%
==========================================
Files 142 142
Lines 8485 8450 -35
==========================================
+ Hits 6115 6144 +29
+ Misses 2370 2306 -64 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Seven fixes, each reproduced experimentally against main before fixing and covered by a regression test: * keep_n_best elitism froze evolution once the archive reached the population size (remain_n was never floored at zero; a larger archive also inflated the population via negative slicing). At most pop_size - 1 elites are kept now, so offspring always survive. * Extending the initial population up to pop_size silently cost one evolutionary generation: both setup appends incremented the generation counter the stop condition reads. The extension append is now bookkeeping (evolutionary_step=False) - the archive is updated, the counters are not. * one_point_crossover's sink guard compared a node to graph.root_node with `is not`, but root_node returns a list for multi-root graphs, so the guard silently accepted every pair. Identity is now checked against each element of root_nodes(). * A crossover that gave up (sink filter or depth guard) returned the parents unchanged; the unchanged copies trivially passed verification, short-circuiting the max_num_of_operator_attempts retry budget on the first draw and forcing duplicate evaluations of already-evaluated graphs wrapped in fresh Individuals with null fitness. Unchanged offspring now spend the attempt budget on fresh draws, and the original evaluated individuals are returned when nothing new is bred. * The base _extend_population shared one mutable graph object between all extension individuals; it deep-copies now. * The stagnation-timeout stop condition raised TypeError when both timeout and early_stopping_timeout are None; it is now guarded the same way as the iteration-based condition. * Selection treated an explicit pop_size=0 as "use the default size" due to a falsy-zero `or`.
* are_subtrees_the_same: `(node, node2) or (node2, node) in match_set` parsed as `(node, node2) or ((node2, node) in match_set)` - a non-empty tuple is always truthy, so every child pair counted as matched and equivalent_subtree reported structurally different subtrees as equivalent, degrading one-point crossover into a random exchange. * The "set mutation probability to 1.0" overrides in EvoGraphOptimizer._extend_population and AgentTrainer.fit assigned the probability to GraphRequirements, but Mutation reads it from the algorithm parameters - the override was silently ineffective. Both sites now override the parameters and restore them afterwards. * AgentTrainer.validate_on_rollouts unpacked TrajectoryStep as (_, reward, _), which binds the action (a string) instead of the reward, so any non-empty trajectory raised TypeError on sum(). * ExperienceBuffer.split inverted the split for tiny buffers: int(len * ratio) == 0 made the mask slice [-0:] cover the whole array, handing everything to train and nothing to validation. Each fix is covered by a regression test that fails on the unfixed code.
nicl-nno
force-pushed
the
fix/evolution-correctness
branch
from
August 19, 2026 15:41
b3b716d to
3d7acba
Compare
* python_requires >=3.10; classifiers and both CI matrices now cover 3.10 through 3.14 (3.8 and 3.9 are dropped). * numpy < 2 and scipy < 1.13 have no builds for Python 3.13+, so the old pins are kept via environment markers for python_version < '3.13' (where they still guard downstream compatibility) and replaced with numpy >= 2.1 / scipy >= 1.14.1 on newer interpreters. Same split for MarkupSafe (2.1.1 has no cp313+ wheels). * The 'typing' PyPI backport is removed from requirements: it is the standard library since Python 3.5 and breaks installs on new versions. * graph_viz: np.cross dropped support for 2-dimensional vectors in numpy 2.0; the edge-curvature code now computes the z-component of the 2D cross product explicitly. Verified experimentally on a local Python 3.14.7 venv: a clean `pip install .` resolves (numpy 2.5.2, scipy 1.18.0, pandas 3.0.5, iOpt 0.2.22) and the optimizer/dag/adaptive unit suites pass 285/285, same as on 3.10.
The 3.13 CI job died resolving dependencies: karateclub's transitive gensim pin requires numpy < 2, which has no builds for Python 3.13+, so pip backtracked across the whole graph until it hit an old Pillow sdist whose setup.py crashes on new interpreters. karateclub is therefore limited to python_version < '3.13' (the feather_graph context agent already degrades gracefully without it), and the tests parametrized with feather_graph skip when karateclub is absent. Two more incompatibilities surfaced by running the full unit suite on a local Python 3.14 venv with all extras installed: * iOpt 0.2.22 still calls np.infty, removed in numpy 2.0, so IOptTuner cannot work on 3.13+ until iOpt catches up; its test cases skip when numpy lacks infty. * test_load_mab asserted `loaded_mab.__eq__(mab)` - neither class defines __eq__, so the call always returned NotImplemented, which was truthy (with a warning) before Python 3.14 and is a TypeError in a boolean context since. The vacuous line is dropped; the field-wise asserts below it are the actual check. The unit-build matrix also sets fail-fast: false, so one failing interpreter no longer cancels the others. With these changes the full test/unit suite passes on the local 3.14 venv with .[docs], .[profilers] and .[adaptive] installed: 473 passed, 7 skipped.
The 3.11/3.12 CI jobs showed that karateclub's unmaintained dependency caps (gensim -> numpy < 2, pandas <= 1.3.5) only resolve into installable wheels up to Python 3.10: on 3.11 pip backtracked to the pandas 1.3.5 sdist, whose build fails on modern setuptools. The extra is therefore limited to python_version < '3.11' - matching the versions it was actually tested on before - and the feather_graph test skips follow the runtime availability check, so they need no version logic.
* the Python-versions badge is now a static 3.10-3.14 badge: the pypi/pyversions one keeps showing the versions of the latest *released* wheel, which would misreport the supported range until 0.5.0 ships (and lag behind again on every future change); * the license badge carried a copy-pasted "Supported Python Versions" alt text; * the pypi and python badges linked to their own image URLs instead of the project page.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
A batch of correctness fixes for the evolutionary optimisation core. Each bug was first reproduced experimentally against
main, then fixed, and each fix carries a regression test.1.
keep_n_bestelitism could freeze evolution (elitism.py)remain_n = len(new_population) - len(best_individuals)was never floored at zero. Once the archive reaches the population size, the next generation is composed of the same elite individuals verbatim, generation after generation — no error, no warning, the run just silently stops exploring. With an archive larger than the population the negative slice also let the population grow beyondpop_size(reproduced: 8 individuals in a population of 4).Now at most
len(new_population) - 1elites are kept, so at least one offspring always survives elitism.2. Extending the initial population silently cost one generation (gp_optimizer.py, generation_keeper.py)
_initial_populationappends twice when the initial individuals are fewer thanpop_size(initial_assumptions+extended_initial_assumptions), and each append incremented the generation counter that the stop condition reads. Any run that started from fewer assumptions thanpop_size— the common case — executednum_of_generations - 1real evolutionary steps. Reproduced end-to-end: 3/4 generations with extension vs 4/4 without; the history looks complete because the extension record masquerades as a generation.GenerationKeeper.appendnow takesevolutionary_step: bool = True; the extension append passesFalse, so the archive is still updated but the generation counter and stagnation trackers are untouched. BothEvoGraphOptimizerandPopulationalRandomMutationOptimizerare covered.3. One-point crossover's sink guard was a no-op for multi-root graphs (crossover.py)
first is not root_firstcompared a node against whatevergraph.root_nodereturns — which is a list for graphs with zero or several roots, so the identity check was always true and the sink filter silently accepted every pair. Multi-root graphs reach this code:DEFAULT_DAG_RULESincludeshas_rootbut nothas_one_root. The check now tests identity against each element ofroot_nodes().4. A crossover that gave up short-circuited the retry budget and bred duplicates (crossover.py)
When the sink-filter search finds no valid pick, the crossover functions return the parents unchanged. Unchanged graphs trivially pass verification, so
Crossover._crossoveraccepted them on the first outer attempt — collapsingmax_num_of_operator_attemptsindependent draws into one — and wrapped them into freshIndividuals with null fitness, forcing a duplicate evaluation of already-evaluated graphs. The same waste existed before the sink filter wheneverreplace_subtreesrejected a swap by the depth guard._crossovernow detects structurally unchanged offspring, spends the attempt budget on fresh random draws, and falls back to the original (already evaluated) individuals.5. Individuals from
_extend_populationshared one mutable graph (populational_optimizer.py)The base-class implementation passed
choice(pop).graphby reference, so all extension individuals aliased a single graph object (reproduced: 5 individuals, 1 uniqueid()). Live forPopulationalRandomMutationOptimizerand any subclass that does not override the method;EvoGraphOptimizeroverrides it and was not affected. Now deep-copies.6. Crash in the stagnation-timeout stop condition (populational_optimizer.py)
With
timeout=Noneandearly_stopping_timeout=Nonethe fallbackearly_stopping_timeout or self.timer.timeoutstill yieldsNone, and the condition raisesTypeError: '>=' not supported between 'float' and 'NoneType'. The adjacent iteration-based condition already guards againstNone; the timeout condition now does the same.7.
Selectiontreated an explicitpop_size=0as "use default" (selection.py)pop_size or self.parameters.pop_size— falsy-zero. Nowif pop_size is not None.Second commit — continued audit
8.
are_subtrees_the_samecounted every child pair as matched (gp_operators.py)if (node, node2) or (node2, node) in match_set:parses as(node, node2) or ((node2, node) in match_set)— a non-empty tuple is always truthy, so the membership test never ran.equivalent_subtreetherefore reported structurally different subtrees as equivalent, degrading one-point crossover into a random subtree exchange. Fixed the operator precedence.9. The "mutation probability = 1.0" overrides were silently ineffective (gp_optimizer.py, agent_trainer.py)
Both
EvoGraphOptimizer._extend_populationandAgentTrainer.fitassignedmutation_prob = 1.0toGraphRequirements— a stray attribute, becauseMutation._will_mutation_be_appliedreadsself.parameters.mutation_probfromGPAlgorithmParameters. The extension of the initial population (which runs in nearly every optimization) was wasting ~20% of its capped attempts on guaranteed no-ops with the default 0.8 probability, and the trainer's documented "always mutate" mode never happened (reproduced: 0/1000 mutations applied withparameters.mutation_prob=0). Both sites now override the algorithm parameters and restore them afterwards.10.
validate_on_rolloutssummed the action instead of the reward (agent_trainer.py)TrajectoryStepis(individual, action, reward), but the code unpacked(_, reward, _)— binding the action, a string, so any non-empty trajectory raisedTypeErroronsum().11.
ExperienceBuffer.splitinverted the split for tiny buffers (experience_buffer.py)int(len * ratio) == 0made the mask slice[-0:]cover the whole array, handing everything to train and nothing to validation — the opposite of intended.Verification
mainfirst (scripted, not by inspection);test_generation_count.py,test_experience_buffer.py(new), plus cases intest_crossover.py,test_elitism.py,test_selection.py,test_generation_keeper.py,test_gp_operators.py— each fails on the unfixed code;test/unit/optimizers/+test/unit/dag/suite passes (285 passed; the pre-existingtest/unit/adaptiveMAB failures reproduce onmainand are unrelated).Related
The sink-filter hooks from #294 (
can_be_sink/get_final_node) are still no-ops for FEDOT until itsPipelineChangeAdvisor/PipelineOptNodeFactoryoverride them — that needs a follow-up PR in the FEDOT repo; fixes 3 and 4 here make the GOLEM side behave correctly once that lands.