What happens
tests/test_diff_evolution.py::TestLearnedMutationSettings::test_ade_flushes_every_population_size_results asserts, at tests/test_diff_evolution.py:773:
assert len(proposals) == 3 and len(ade._trial_settings) == 3
len(proposals) is always 3 -- one proposal per got_result. len(ade._trial_settings) is 3 only when the three proposed candidates are three distinct PSets. About 0.5% of the time two of them are byte-identical, they collide on the _trial_settings key (it is keyed by the PSet, pybnf/algorithms/optimizers/differential_evolution.py:353), the dict keeps two entries, and the assertion fails with assert (3 == 3 and 2 == 3).
Why it is not deterministic
The test builds its config with config.Configuration(...) directly and never sets random_seed, so it defaults to None (pybnf/config_schema.py:312). Algorithm._init_rng then constructs the stream as np.random.default_rng(np.random.SeedSequence(None)) (pybnf/algorithms/base.py:278-279), and SeedSequence(None) draws fresh OS entropy on every construction. The _ade_config fixture sets mutation_rate = 1.0 -- the suite's standard guard against duplicate proposals (see the setup_class comment at tests/test_diff_evolution.py:20) -- but de_adapt_mutation = 1 overrides the rate with a draw from the success history, which can sit low enough that two candidates coincide, so that guard does not apply here.
Measured on a clean checkout (f3bd79f0, before any #708 work): 2 collisions in 400 constructions = 0.5%.
Reproduction (mirrors the test, run 400 times with fresh entropy):
base = { # the _ade_config fixture, with de_adapt_mutation on
'population_size': 3, 'max_iterations': 100, 'mutation_rate': 1.0,
'mutation_factor': 0.5, 'de_strategy': 'rand1', 'fit_type': 'ade',
'de_adapt_mutation': 1,
('uniform_var', 'v1__FREE'): [-100, 100], ('uniform_var', 'v2__FREE'): [-100, 100],
('uniform_var', 'v3__FREE'): [-100, 100],
'models': {'bngl_files/parabola.bngl'}, 'exp_data': {'bngl_files/par1.exp'},
'bngl_files/parabola.bngl': ['bngl_files/par1.exp'], 'output_dir': <tmp>}
ade = algorithms.AsynchronousDifferentialEvolution(config.Configuration(base))
start = ade.start_run()
ade.fitnesses = [5.0, 3.0, 7.0]
for ps, sc in zip(start, [5.0, 3.0, 7.0]):
r = algorithms.Result(ps, d1s, ps.name); r.score = sc
ade.got_result(r)
# count the runs where len(ade._trial_settings) != 3 -> ~2 in 400
Why this is a test bug, not a product bug
The production code deliberately tolerates the collision. _note_trial_result documents it in its own docstring (pybnf/algorithms/optimizers/differential_evolution.py:467-468): "A candidate this history did not build (the initial population, or a duplicate whose record another candidate overwrote) records nothing either." So the contract is that a duplicate candidate's learned-settings record may be overwritten and its outcome dropped; the fold still runs correctly on whatever records survive.
Island de goes further and perturbs a duplicate candidate by up to 1e-6 per parameter so it never collides (_perturb_duplicate, called at pybnf/algorithms/optimizers/differential_evolution.py:850 while new_pset in self.island_map), moving the record to the perturbed key. ade's proposal path (pybnf/algorithms/optimizers/differential_evolution.py:1020-1029) does not, by the same documented tolerance. So the == 3 assertion is stricter than the behaviour the code guarantees, and the gap is ade-only: the sibling island-de test at tests/test_diff_evolution.py:739 asserts the same == 3, but island de perturbs duplicates, so it holds there.
Where
tests/test_diff_evolution.py:773 (the assertion). Root cause is the unseeded fixture plus the ade-tolerated PSet collision, not any production defect.
Fix options
- Set
random_seed in the de_adapt_mutation test config(s) so the tests are deterministic and reproducible from the seed. This is the smaller change and also removes the entropy dependence from the other de_adapt_mutation = 1 tests in the file.
- Or relax the assertion to the contract the code actually guarantees: that the fold registers a record for each distinct candidate and folds the successes among them, without requiring all three proposals to be distinct.
The first is preferred: one key added to the fixture, deterministic thereafter, and no change to the assertion's intent.
Severity
Low. The test fails loudly (never a silent wrong answer), roughly 1 run in 200 of this test, and only under de_adapt_mutation = 1. It is worth fixing because an unseeded, entropy-dependent test reddens CI at random and is not reproducible from its own output.
What happens
tests/test_diff_evolution.py::TestLearnedMutationSettings::test_ade_flushes_every_population_size_resultsasserts, attests/test_diff_evolution.py:773:len(proposals)is always 3 -- one proposal pergot_result.len(ade._trial_settings)is 3 only when the three proposed candidates are three distinctPSets. About 0.5% of the time two of them are byte-identical, they collide on the_trial_settingskey (it is keyed by thePSet,pybnf/algorithms/optimizers/differential_evolution.py:353), the dict keeps two entries, and the assertion fails withassert (3 == 3 and 2 == 3).Why it is not deterministic
The test builds its config with
config.Configuration(...)directly and never setsrandom_seed, so it defaults toNone(pybnf/config_schema.py:312).Algorithm._init_rngthen constructs the stream asnp.random.default_rng(np.random.SeedSequence(None))(pybnf/algorithms/base.py:278-279), andSeedSequence(None)draws fresh OS entropy on every construction. The_ade_configfixture setsmutation_rate = 1.0-- the suite's standard guard against duplicate proposals (see thesetup_classcomment attests/test_diff_evolution.py:20) -- butde_adapt_mutation = 1overrides the rate with a draw from the success history, which can sit low enough that two candidates coincide, so that guard does not apply here.Measured on a clean checkout (
f3bd79f0, before any #708 work): 2 collisions in 400 constructions = 0.5%.Reproduction (mirrors the test, run 400 times with fresh entropy):
Why this is a test bug, not a product bug
The production code deliberately tolerates the collision.
_note_trial_resultdocuments it in its own docstring (pybnf/algorithms/optimizers/differential_evolution.py:467-468): "A candidate this history did not build (the initial population, or a duplicate whose record another candidate overwrote) records nothing either." So the contract is that a duplicate candidate's learned-settings record may be overwritten and its outcome dropped; the fold still runs correctly on whatever records survive.Island
degoes further and perturbs a duplicate candidate by up to 1e-6 per parameter so it never collides (_perturb_duplicate, called atpybnf/algorithms/optimizers/differential_evolution.py:850whilenew_pset in self.island_map), moving the record to the perturbed key.ade's proposal path (pybnf/algorithms/optimizers/differential_evolution.py:1020-1029) does not, by the same documented tolerance. So the== 3assertion is stricter than the behaviour the code guarantees, and the gap isade-only: the sibling island-detest attests/test_diff_evolution.py:739asserts the same== 3, but islanddeperturbs duplicates, so it holds there.Where
tests/test_diff_evolution.py:773(the assertion). Root cause is the unseeded fixture plus theade-toleratedPSetcollision, not any production defect.Fix options
random_seedin thede_adapt_mutationtest config(s) so the tests are deterministic and reproducible from the seed. This is the smaller change and also removes the entropy dependence from the otherde_adapt_mutation = 1tests in the file.The first is preferred: one key added to the fixture, deterministic thereafter, and no change to the assertion's intent.
Severity
Low. The test fails loudly (never a silent wrong answer), roughly 1 run in 200 of this test, and only under
de_adapt_mutation = 1. It is worth fixing because an unseeded, entropy-dependent test reddens CI at random and is not reproducible from its own output.