From 6839d81ac111516a816da6848307405c67ef05d8 Mon Sep 17 00:00:00 2001 From: Raoul RAFFEL Date: Tue, 14 Apr 2026 18:25:07 +0200 Subject: [PATCH] fix(optimizers): fallback to seed_candidates in propose_new_candidates `EvolutionaryOptimizer.propose_new_candidates` calls `select_candidate` on `best_candidates`, which on the very first training step is still empty (it is populated later by `maybe_add_candidate`). The resulting `None` was then passed as `selected_candidate` into `mutate_candidate` / `merge_candidate`, crashing subclasses such as OMEGA on `selected_candidate.items()` in `omega.py`. Apply the same seed-candidate fallback that `on_batch_begin` already uses, so mutation/crossover always receives a valid starting point. Add a regression test covering the empty-`best_candidates` path. --- .../src/optimizers/evolutionary_optimizer.py | 10 +++ .../optimizers/evolutionary_optimizer_test.py | 71 +++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/synalinks/src/optimizers/evolutionary_optimizer.py b/synalinks/src/optimizers/evolutionary_optimizer.py index 9f99efaf..99220e62 100644 --- a/synalinks/src/optimizers/evolutionary_optimizer.py +++ b/synalinks/src/optimizers/evolutionary_optimizer.py @@ -199,6 +199,16 @@ async def propose_new_candidates( best_candidates = trainable_variable.get("best_candidates") selected_candidate = self.select_candidate(best_candidates) + # On the very first training step `best_candidates` is still + # empty (it is populated downstream by `maybe_add_candidate`). + # Fall back to a seed candidate so mutation/crossover has + # something to work with, mirroring the same fallback used in + # `on_batch_begin`. + if selected_candidate is None: + seed_candidates = trainable_variable.get("seed_candidates") + if seed_candidates: + selected_candidate = random.choice(seed_candidates) + if strategy == "mutation": new_candidate = await self.mutate_candidate( step, diff --git a/synalinks/src/optimizers/evolutionary_optimizer_test.py b/synalinks/src/optimizers/evolutionary_optimizer_test.py index e8da0e9c..78de5099 100644 --- a/synalinks/src/optimizers/evolutionary_optimizer_test.py +++ b/synalinks/src/optimizers/evolutionary_optimizer_test.py @@ -199,6 +199,77 @@ def test_sampling_temperature_default(self): optimizer = EvolutionaryOptimizer() self.assertEqual(optimizer.sampling_temperature, 0.3) + async def test_propose_new_candidates_falls_back_to_seed_when_best_empty( + self, + ): + """Regression test: on the very first training step `best_candidates` + is still empty (it is populated later by `maybe_add_candidate`). + `propose_new_candidates` used to pass the resulting `None` into + `mutate_candidate`, which crashed downstream (e.g. OMEGA's + `selected_candidate.items()` in `omega.py`). It must instead fall + back to a random seed candidate, mirroring `on_batch_begin`. + """ + seen = {} + + class _RecordingOptimizer(EvolutionaryOptimizer): + async def mutate_candidate( + self, + step, + trainable_variable, + selected_candidate, + x=None, + y=None, + y_pred=None, + training=False, + ): + seen["selected_candidate"] = selected_candidate + return None + + async def merge_candidate( + self, + step, + trainable_variable, + current_candidate, + other_candidate, + x=None, + y=None, + y_pred=None, + training=False, + ): + return None + + # merging_rate=0 keeps the strategy on "mutation" regardless of epoch. + optimizer = _RecordingOptimizer(selection="random", merging_rate=0.0) + + seed_candidate = {"prompt": "seed_prompt"} + trainable_variable = JsonDataModel( + json={ + "seed_candidates": [seed_candidate], + "best_candidates": [], + "nb_visit": 0, + "cumulative_reward": 0.0, + "prompt": "initial", + }, + schema={ + "type": "object", + "properties": { + "seed_candidates": {"type": "array"}, + "best_candidates": {"type": "array"}, + "nb_visit": {"type": "integer"}, + "cumulative_reward": {"type": "number"}, + "prompt": {"type": "string"}, + }, + }, + name="trainable_var", + ) + + await optimizer.propose_new_candidates( + step=0, + trainable_variables=[trainable_variable], + ) + + self.assertEqual(seen.get("selected_candidate"), seed_candidate) + async def test_select_variable_name_to_update_does_not_raise(self): """Regression test: `select_variable_name_to_update` on an EvolutionaryOptimizer used to raise