rlevo_evolution/algorithms/de.rs
1//! Differential Evolution.
2//!
3//! Classical DE over `Tensor<B, 2>` populations with all common
4//! mutation/crossover variants enumerated in [`DeVariant`].
5//!
6//! # Variants
7//!
8//! | Variant | Mutation formula |
9//! |---|---|
10//! | [`DeVariant::Rand1Bin`], [`DeVariant::Rand1Exp`] | `v = x_{r1} + F · (x_{r2} − x_{r3})` |
11//! | [`DeVariant::Best1Bin`] | `v = x_{best} + F · (x_{r2} − x_{r3})` |
12//! | [`DeVariant::CurrentToBest1Bin`] | `v = x_i + F · (x_{best} − x_i) + F · (x_{r1} − x_{r2})` |
13//! | [`DeVariant::Rand2Bin`] | `v = x_{r1} + F · (x_{r2} − x_{r3}) + F · (x_{r4} − x_{r5})` |
14//!
15//! The suffix `Bin`/`Exp` selects between binomial and exponential
16//! crossover. All index draws reject repeated and self-referential
17//! indices.
18//!
19//! # Hot path
20//!
21//! A fused `CubeCL` kernel for trial-vector construction is tracked as
22//! follow-up work (see [`crate::ops::kernels`]). Until then this module
23//! uses host-sampled indices and composes the update from primitive
24//! tensor ops.
25//!
26//! # Reference
27//!
28//! - Storn & Price (1997), *Differential Evolution — A Simple and
29//! Efficient Heuristic for Global Optimization over Continuous
30//! Spaces*.
31
32use std::marker::PhantomData;
33
34use burn::tensor::{Int, Tensor, TensorData, backend::Backend};
35use rand::{Rng, RngExt};
36
37use crate::rng::{SeedPurpose, seed_stream};
38use crate::strategy::{Strategy, StrategyMetrics};
39
40/// Mutation + crossover variant for differential evolution.
41///
42/// # Convergence caveats
43///
44/// Not every variant converges to machine precision on every landscape
45/// within the same budget. On unimodal landscapes like Sphere,
46/// [`Best1Bin`](DeVariant::Best1Bin) and
47/// [`CurrentToBest1Bin`](DeVariant::CurrentToBest1Bin) tend to
48/// **converge prematurely**: the population collapses around the
49/// current best before the differential search has fully explored, and
50/// the per-generation variance `F · (x_{r2} − x_{r3})` shrinks to zero.
51/// Classical DE literature documents this as the core trade-off of
52/// best-biased variants. The crate's integration tests therefore only
53/// require strong *reduction* from the random baseline for those
54/// variants, not optimality — see
55/// `algorithms::de::tests::all_variants_converge_on_sphere_d10` for the
56/// per-variant tolerance choice.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum DeVariant {
59 /// `x_{r1} + F · (x_{r2} − x_{r3})`, binomial crossover. Balanced
60 /// exploration / exploitation; reaches machine precision on Sphere
61 /// within a few hundred generations.
62 Rand1Bin,
63 /// `x_{best} + F · (x_{r2} − x_{r3})`, binomial crossover.
64 ///
65 /// Strong exploitation — the mutation base is always the current
66 /// best, so the population concentrates quickly. Prone to
67 /// **premature convergence** on landscapes where the current best
68 /// is far from the global optimum; on Sphere-D10 with 500 gens this
69 /// variant stalls around `best_fitness ≈ 1` while `Rand1Bin` reaches
70 /// `< 1e-20`.
71 Best1Bin,
72 /// `x_i + F · (x_{best} − x_i) + F · (x_{r1} − x_{r2})`, binomial.
73 ///
74 /// Hybrid of the current individual and the best-so-far. Still
75 /// **prone to premature convergence** because the
76 /// `F · (x_{best} − x_i)` term dominates once the population is
77 /// near the best. Useful on multimodal landscapes where pure-best
78 /// variants get stuck in local basins, less useful on Sphere.
79 CurrentToBest1Bin,
80 /// `x_{r1} + F · (x_{r2} − x_{r3}) + F · (x_{r4} − x_{r5})`,
81 /// binomial. Higher variance than `Rand1Bin` thanks to two
82 /// difference vectors; converges on Sphere but more slowly.
83 Rand2Bin,
84 /// `x_{r1} + F · (x_{r2} − x_{r3})`, exponential crossover.
85 /// Identical mutation to `Rand1Bin`, different crossover mask shape.
86 /// Performance comparable to `Rand1Bin` in practice.
87 Rand1Exp,
88}
89
90impl DeVariant {
91 /// Number of distinct random indices the variant needs (in
92 /// addition to the current individual `i`).
93 const fn random_indices(self) -> usize {
94 match self {
95 DeVariant::Rand1Bin | DeVariant::Rand1Exp => 3,
96 DeVariant::Best1Bin | DeVariant::CurrentToBest1Bin => 2,
97 DeVariant::Rand2Bin => 5,
98 }
99 }
100
101 /// Whether this variant uses exponential crossover.
102 const fn is_exponential(self) -> bool {
103 matches!(self, DeVariant::Rand1Exp)
104 }
105}
106
107/// Static configuration for a [`DifferentialEvolution`] run.
108#[derive(Debug, Clone)]
109pub struct DeConfig {
110 /// Population size (≥ 5 for `Rand2Bin`, ≥ 4 otherwise).
111 pub pop_size: usize,
112 /// Genome dimensionality.
113 pub genome_dim: usize,
114 /// Search-space bounds (initialization and clamping).
115 pub bounds: (f32, f32),
116 /// Differential weight (F). Typical range [0.4, 0.9].
117 pub f: f32,
118 /// Crossover probability (CR). Typical range [0.1, 0.9].
119 pub cr: f32,
120 /// Variant.
121 pub variant: DeVariant,
122}
123
124impl DeConfig {
125 /// Default configuration (`Rand1Bin`, F = 0.5, CR = 0.9) for a given
126 /// dimensionality.
127 #[must_use]
128 pub fn default_for(pop_size: usize, genome_dim: usize) -> Self {
129 Self {
130 pop_size,
131 genome_dim,
132 bounds: (-5.12, 5.12),
133 f: 0.5,
134 cr: 0.9,
135 variant: DeVariant::Rand1Bin,
136 }
137 }
138}
139
140/// Generation state for [`DifferentialEvolution`].
141///
142/// The two-phase ask/tell handshake uses `fitness.is_empty()` as a
143/// sentinel: on the very first [`Strategy::ask`] call the initial
144/// population is returned unchanged; on the very first
145/// [`Strategy::tell`] call `fitness` is populated and
146/// `best_genome`/`best_fitness` are initialized. Subsequent
147/// ask/tell cycles produce and evaluate trial vectors.
148#[derive(Debug, Clone)]
149pub struct DeState<B: Backend> {
150 /// Current population, shape `(pop_size, D)`.
151 pub population: Tensor<B, 2>,
152 /// Host-side fitness cache for the current population.
153 ///
154 /// Empty before the first [`Strategy::tell`] call; length `pop_size`
155 /// thereafter. The `is_empty()` check is the sentinel that
156 /// distinguishes the initial evaluation phase from subsequent
157 /// trial-vector generations.
158 pub fitness: Vec<f32>,
159 /// Index of the current best individual within `population`.
160 pub best_index: usize,
161 /// Best-so-far genome, shape `(1, D)`.
162 ///
163 /// `None` before the first [`Strategy::tell`] call.
164 pub best_genome: Option<Tensor<B, 2>>,
165 /// Best-so-far fitness across all completed generations.
166 ///
167 /// `f32::INFINITY` before the first [`Strategy::tell`] call.
168 pub best_fitness: f32,
169 /// Number of completed `tell` calls (zero-based generation index + 1).
170 pub generation: usize,
171}
172
173/// Classical DE/rand/1/bin (and friends).
174///
175/// # Example
176///
177/// ```no_run
178/// use burn::backend::Flex;
179/// use rlevo_evolution::algorithms::de::{DeConfig, DeVariant, DifferentialEvolution};
180///
181/// let strategy = DifferentialEvolution::<Flex>::new();
182/// let mut params = DeConfig::default_for(30, 10);
183/// params.variant = DeVariant::Rand1Bin;
184/// let _ = (strategy, params);
185/// ```
186#[derive(Debug, Clone, Copy, Default)]
187pub struct DifferentialEvolution<B: Backend> {
188 _backend: PhantomData<fn() -> B>,
189}
190
191impl<B: Backend> DifferentialEvolution<B> {
192 /// Builds a new (stateless) strategy object.
193 #[must_use]
194 pub fn new() -> Self {
195 Self {
196 _backend: PhantomData,
197 }
198 }
199
200 fn sample_initial_population(
201 params: &DeConfig,
202 rng: &mut dyn Rng,
203 device: &<B as burn::tensor::backend::BackendTypes>::Device,
204 ) -> Tensor<B, 2> {
205 let (lo, hi) = params.bounds;
206 // Host-sample the initial population from a deterministic
207 // `seed_stream` rather than the process-wide Flex RNG (`B::seed` +
208 // `Tensor::random`), whose draws interleave with sibling tests under
209 // the parallel runner and are not reproducible across schedules.
210 let pop = params.pop_size;
211 let genome_dim = params.genome_dim;
212 let mut stream = seed_stream(rng.next_u64(), 0, SeedPurpose::Init);
213 let mut rows = Vec::with_capacity(pop * genome_dim);
214 for _ in 0..pop * genome_dim {
215 rows.push(lo + (hi - lo) * stream.random::<f32>());
216 }
217 Tensor::<B, 2>::from_data(TensorData::new(rows, [pop, genome_dim]), device)
218 }
219
220 /// Samples `k` indices from `0..pop_size`, all distinct and all
221 /// different from `self_idx`.
222 ///
223 /// # Panics
224 ///
225 /// Panics if `pop_size <= k`, since the rejection loop cannot make
226 /// progress without enough candidates outside `self_idx`.
227 fn sample_distinct_excluding(
228 self_idx: usize,
229 pop_size: usize,
230 k: usize,
231 rng: &mut dyn Rng,
232 ) -> Vec<usize> {
233 assert!(
234 pop_size > k,
235 "DE: pop_size must exceed the number of distinct indices required"
236 );
237 let mut chosen = Vec::with_capacity(k);
238 while chosen.len() < k {
239 let candidate = rng.random_range(0..pop_size);
240 if candidate != self_idx && !chosen.contains(&candidate) {
241 chosen.push(candidate);
242 }
243 }
244 chosen
245 }
246}
247
248impl<B: Backend> Strategy<B> for DifferentialEvolution<B>
249where
250 B::Device: Clone,
251{
252 type Params = DeConfig;
253 type State = DeState<B>;
254 type Genome = Tensor<B, 2>;
255
256 /// Samples the initial population uniformly within `params.bounds`
257 /// and returns a [`DeState`] with an empty fitness cache, signalling
258 /// that the first ask/tell cycle should evaluate the initial
259 /// population rather than generate trial vectors.
260 ///
261 /// Initial sampling goes through [`seed_stream`] rather than
262 /// `B::seed + Tensor::random` to keep results reproducible across
263 /// parallel test threads.
264 fn init(&self, params: &DeConfig, rng: &mut dyn Rng, device: &<B as burn::tensor::backend::BackendTypes>::Device) -> DeState<B> {
265 let population = Self::sample_initial_population(params, rng, device);
266 DeState {
267 population,
268 fitness: Vec::new(),
269 best_index: 0,
270 best_genome: None,
271 best_fitness: f32::INFINITY,
272 generation: 0,
273 }
274 }
275
276 /// Proposes the next population of candidate solutions.
277 ///
278 /// **First call (fitness cache empty):** returns the initial
279 /// population from [`DeState::population`] unchanged so the caller
280 /// can evaluate it before any mutation/crossover step.
281 ///
282 /// **Subsequent calls:** for each individual `i` in `0..pop_size`:
283 ///
284 /// 1. Sample the required number of distinct random indices
285 /// (excluding `i`) via [`seed_stream`] with [`SeedPurpose::Trial`].
286 /// 2. Compute the mutant vector `v_i` according to
287 /// [`DeConfig::variant`].
288 /// 3. Apply binomial or exponential crossover (also seeded through
289 /// [`seed_stream`] with [`SeedPurpose::Crossover`]) to blend `v_i`
290 /// with the current individual, ensuring at least one gene comes
291 /// from `v_i` (`j_rand` guarantee).
292 /// 4. Clamp the trial genome to `params.bounds`.
293 ///
294 /// The returned state is a clone of the input state; no fitness
295 /// update occurs here — that happens in [`Strategy::tell`].
296 #[allow(clippy::too_many_lines, clippy::many_single_char_names)]
297 fn ask(
298 &self,
299 params: &DeConfig,
300 state: &DeState<B>,
301 rng: &mut dyn Rng,
302 device: &<B as burn::tensor::backend::BackendTypes>::Device,
303 ) -> (Tensor<B, 2>, DeState<B>) {
304 // First call: evaluate the initial population.
305 if state.fitness.is_empty() {
306 return (state.population.clone(), state.clone());
307 }
308
309 let DeConfig {
310 pop_size,
311 genome_dim,
312 f,
313 cr,
314 variant,
315 ..
316 } = *params;
317
318 let mut trial_rng =
319 seed_stream(rng.next_u64(), state.generation as u64, SeedPurpose::Trial);
320
321 // ------------------------------------------------------------------
322 // 1. Build the mutant vector v_i for every i, host-side gathers.
323 // We assemble three index tensors (a, b, c [and d, e for rand2])
324 // and do the arithmetic on-device in one sweep.
325 // ------------------------------------------------------------------
326 let k = variant.random_indices();
327 let mut rand_indices: Vec<Vec<usize>> =
328 (0..k).map(|_| Vec::with_capacity(pop_size)).collect();
329 for i in 0..pop_size {
330 let chosen = Self::sample_distinct_excluding(i, pop_size, k, &mut trial_rng);
331 for (j, idx) in chosen.into_iter().enumerate() {
332 rand_indices[j].push(idx);
333 }
334 }
335
336 let gather = |idxs: &[usize]| -> Tensor<B, 2> {
337 #[allow(clippy::cast_possible_wrap)]
338 let v: Vec<i64> = idxs.iter().map(|&i| i as i64).collect();
339 let t = Tensor::<B, 1, Int>::from_data(TensorData::new(v, [pop_size]), device);
340 state.population.clone().select(0, t)
341 };
342
343 let v = match variant {
344 DeVariant::Rand1Bin | DeVariant::Rand1Exp => {
345 let a = gather(&rand_indices[0]);
346 let b = gather(&rand_indices[1]);
347 let c = gather(&rand_indices[2]);
348 a + (b - c).mul_scalar(f)
349 }
350 DeVariant::Best1Bin => {
351 #[allow(clippy::single_range_in_vec_init)]
352 let best = state
353 .population
354 .clone()
355 .slice([state.best_index..state.best_index + 1])
356 .expand([pop_size, genome_dim]);
357 let b = gather(&rand_indices[0]);
358 let c = gather(&rand_indices[1]);
359 best + (b - c).mul_scalar(f)
360 }
361 DeVariant::CurrentToBest1Bin => {
362 #[allow(clippy::single_range_in_vec_init)]
363 let best = state
364 .population
365 .clone()
366 .slice([state.best_index..state.best_index + 1])
367 .expand([pop_size, genome_dim]);
368 let current = state.population.clone();
369 let a = gather(&rand_indices[0]);
370 let b = gather(&rand_indices[1]);
371 current.clone() + (best - current).mul_scalar(f) + (a - b).mul_scalar(f)
372 }
373 DeVariant::Rand2Bin => {
374 let a = gather(&rand_indices[0]);
375 let b = gather(&rand_indices[1]);
376 let c = gather(&rand_indices[2]);
377 let d = gather(&rand_indices[3]);
378 let e = gather(&rand_indices[4]);
379 a + (b - c).mul_scalar(f) + (d - e).mul_scalar(f)
380 }
381 };
382
383 // ------------------------------------------------------------------
384 // 2. Crossover: binomial or exponential. Always preserve at
385 // least one mutant gene per row (j_rand).
386 // ------------------------------------------------------------------
387 let mut cross_rng = seed_stream(
388 rng.next_u64(),
389 state.generation as u64,
390 SeedPurpose::Crossover,
391 );
392 let mut cross_mask = vec![false; pop_size * genome_dim];
393 if variant.is_exponential() {
394 for row in 0..pop_size {
395 let start = cross_rng.random_range(0..genome_dim);
396 let mut len = 1;
397 while len < genome_dim && cross_rng.random::<f32>() < cr {
398 len += 1;
399 }
400 for k in 0..len {
401 let j = (start + k) % genome_dim;
402 cross_mask[row * genome_dim + j] = true;
403 }
404 }
405 } else {
406 for row in 0..pop_size {
407 let j_rand = cross_rng.random_range(0..genome_dim);
408 for j in 0..genome_dim {
409 if j == j_rand || cross_rng.random::<f32>() < cr {
410 cross_mask[row * genome_dim + j] = true;
411 }
412 }
413 }
414 }
415 #[allow(clippy::cast_possible_wrap)]
416 let mask_int: Vec<i64> = cross_mask.iter().map(|&b| i64::from(b)).collect();
417 let mask_tensor = Tensor::<B, 2, Int>::from_data(
418 TensorData::new(mask_int, [pop_size, genome_dim]),
419 device,
420 );
421 let mask_bool = mask_tensor.equal_elem(1);
422
423 // Where cross_mask == 1, take from v; otherwise from state.population.
424 let trial = state.population.clone().mask_where(mask_bool, v);
425 let (lo, hi) = params.bounds;
426 let trial = trial.clamp(lo, hi);
427
428 (trial, state.clone())
429 }
430
431 /// Consumes the evaluated trial population and advances the state.
432 ///
433 /// **First call (fitness cache empty):** stores the initial
434 /// population's fitness, initializes `best_genome`/`best_fitness`,
435 /// and increments the generation counter. No replacement occurs
436 /// because there are no previous individuals to compare against.
437 ///
438 /// **Subsequent calls:** applies greedy per-slot replacement — each
439 /// trial individual replaces its corresponding current individual if
440 /// and only if `trial_fitness[i] <= state.fitness[i]`. The best-ever
441 /// genome and fitness are updated if the new generation improves on
442 /// `state.best_fitness`.
443 ///
444 /// Returns the updated [`DeState`] and a [`StrategyMetrics`] snapshot
445 /// covering the current generation's fitness distribution.
446 fn tell(
447 &self,
448 _params: &DeConfig,
449 trial: Tensor<B, 2>,
450 fitness: Tensor<B, 1>,
451 mut state: DeState<B>,
452 _rng: &mut dyn Rng,
453 ) -> (DeState<B>, StrategyMetrics) {
454 let fitness_host = fitness.into_data().into_vec::<f32>().unwrap_or_default();
455
456 // First `tell`: stash fitness for the initial population.
457 if state.fitness.is_empty() {
458 state.fitness.clone_from(&fitness_host);
459 state.best_index = argmin(&fitness_host);
460 state.generation += 1;
461 update_best(&mut state, &trial, &fitness_host);
462 let m = StrategyMetrics::from_host_fitness(
463 state.generation,
464 &fitness_host,
465 state.best_fitness,
466 );
467 state.best_fitness = m.best_fitness_ever;
468 state.population = trial;
469 return (state, m);
470 }
471
472 // Greedy per-slot replacement: trial replaces current iff
473 // trial is at least as good.
474 let device = trial.device();
475 let pop_size = state.fitness.len();
476 let mut replace_mask = vec![0i64; pop_size];
477 let mut new_fit = state.fitness.clone();
478 for i in 0..pop_size {
479 if fitness_host[i] <= state.fitness[i] {
480 replace_mask[i] = 1;
481 new_fit[i] = fitness_host[i];
482 }
483 }
484
485 let mask_int =
486 Tensor::<B, 1, Int>::from_data(TensorData::new(replace_mask, [pop_size]), &device);
487 let mask_bool_row = mask_int.equal_elem(1);
488 let genome_dim = state.population.dims()[1];
489 let mask_bool = mask_bool_row
490 .unsqueeze_dim::<2>(1)
491 .expand([pop_size, genome_dim]);
492 let next_pop = state
493 .population
494 .clone()
495 .mask_where(mask_bool, trial.clone());
496
497 state.population = next_pop;
498 state.fitness.clone_from(&new_fit);
499 state.best_index = argmin(&new_fit);
500 state.generation += 1;
501 update_best(&mut state, &trial, &fitness_host);
502 let m = StrategyMetrics::from_host_fitness(state.generation, &new_fit, state.best_fitness);
503 state.best_fitness = m.best_fitness_ever;
504 (state, m)
505 }
506
507 /// Returns the best-so-far genome and its raw (minimization) fitness.
508 ///
509 /// Returns `None` before the first [`Strategy::tell`] call, when
510 /// `DeState::best_genome` is still `None`.
511 fn best(&self, state: &DeState<B>) -> Option<(Tensor<B, 2>, f32)> {
512 state
513 .best_genome
514 .as_ref()
515 .map(|g| (g.clone(), state.best_fitness))
516 }
517}
518
519fn argmin(xs: &[f32]) -> usize {
520 let mut best_idx = 0usize;
521 let mut best = f32::INFINITY;
522 for (i, &v) in xs.iter().enumerate() {
523 if v < best {
524 best = v;
525 best_idx = i;
526 }
527 }
528 best_idx
529}
530
531fn update_best<B: Backend>(state: &mut DeState<B>, pop: &Tensor<B, 2>, fitness: &[f32]) {
532 if fitness.is_empty() {
533 return;
534 }
535 let best_idx = argmin(fitness);
536 let best_f = fitness[best_idx];
537 if best_f < state.best_fitness {
538 let device = pop.device();
539 #[allow(clippy::cast_possible_wrap)]
540 let idx =
541 Tensor::<B, 1, Int>::from_data(TensorData::new(vec![best_idx as i64], [1]), &device);
542 state.best_genome = Some(pop.clone().select(0, idx));
543 state.best_fitness = best_f;
544 }
545}
546
547#[cfg(test)]
548mod tests {
549 use super::*;
550 use crate::fitness::FromFitnessEvaluable;
551 use crate::strategy::EvolutionaryHarness;
552 use burn::backend::Flex;
553 use rlevo_core::fitness::FitnessEvaluable;
554 type TestBackend = Flex;
555
556 struct Sphere;
557 struct SphereFit;
558 impl FitnessEvaluable for SphereFit {
559 type Individual = Vec<f64>;
560 type Landscape = Sphere;
561 fn evaluate(&self, x: &Self::Individual, _: &Self::Landscape) -> f64 {
562 x.iter().map(|v| v * v).sum()
563 }
564 }
565
566 fn run_de(variant: DeVariant, dim: usize, gens: usize) -> f32 {
567 let device = Default::default();
568 let mut params = DeConfig::default_for(30, dim);
569 params.variant = variant;
570 let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
571 let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
572 DifferentialEvolution::<TestBackend>::new(),
573 params,
574 fitness_fn,
575 11,
576 device,
577 gens,
578 );
579 harness.reset();
580 loop {
581 if harness.step(()).done {
582 break;
583 }
584 }
585 harness.latest_metrics().unwrap().best_fitness_ever
586 }
587
588 /// All five DE variants converge on Sphere-D10 within budget.
589 ///
590 /// The Burn Flex backend seeds its RNG through a process-wide
591 /// mutex, so separate `#[test]` functions that call `Tensor::random`
592 /// race on seeding and produce non-deterministic trajectories. This
593 /// single test runs the variants sequentially inside one function
594 /// so their seed state is not contended.
595 ///
596 /// Per-variant tolerance reflects classical characterizations:
597 /// `rand1`/`rand2` converge to optimum, `best1` / current-to-best
598 /// suffer from premature convergence on unimodal landscapes.
599 #[test]
600 fn all_variants_converge_on_sphere_d10() {
601 let rand1bin = run_de(DeVariant::Rand1Bin, 10, 500);
602 assert!(rand1bin < 1e-6, "DE/rand/1/bin best={rand1bin}");
603
604 let rand2bin = run_de(DeVariant::Rand2Bin, 10, 800);
605 assert!(rand2bin < 1e-6, "DE/rand/2/bin best={rand2bin}");
606
607 let rand1exp = run_de(DeVariant::Rand1Exp, 10, 500);
608 assert!(rand1exp < 1e-6, "DE/rand/1/exp best={rand1exp}");
609
610 let best1bin = run_de(DeVariant::Best1Bin, 10, 500);
611 assert!(best1bin < 1.0, "DE/best/1/bin best={best1bin}");
612
613 let c2b = run_de(DeVariant::CurrentToBest1Bin, 10, 500);
614 assert!(c2b < 2.0, "DE/current-to-best/1/bin best={c2b}");
615 }
616}