quantrs2_tytan/sampler/genetic_algorithm.rs
1//! # Genetic Algorithm (GA) Sampler
2//!
3//! The Genetic Algorithm (GA) sampler evolves a population of binary solutions
4//! through successive generations of selection, crossover, and mutation to find
5//! low-energy configurations of QUBO/HOBO problems.
6//!
7//! ## Algorithm
8//!
9//! Each generation:
10//!
11//! 1. **Evaluation**: compute the QUBO energy for every individual in the population.
12//! 2. **Selection**: tournament selection — two random individuals compete; the
13//! one with lower energy survives as a parent.
14//! 3. **Crossover**: produce offspring from two parents using one of:
15//! - *Uniform* — each gene is taken from parent 1 or 2 with equal probability.
16//! - *Single-point* — split at a random point and swap the tails.
17//! - *Two-point* — swap the middle segment between two random split points.
18//! - *Adaptive* — strategy chosen based on Hamming distance of parents.
19//! 4. **Mutation**: flip bits with probability `p_mut` (fixed, annealed, or
20//! adaptive based on population diversity).
21//! 5. **Elitism**: the best individual of the current generation is always
22//! preserved in the next generation.
23//!
24//! ## Mathematical Formulation
25//!
26//! Given a QUBO matrix Q, the objective is to minimise:
27//!
28//! ```text
29//! E(x) = Σ_{i,j} Q[i,j] · x[i] · x[j], x[i] ∈ {0, 1}
30//! ```
31//!
32//! Fitness of individual x equals E(x) (lower is better).
33//!
34//! ## Citation
35//!
36//! Holland, J. H. (1975). *Adaptation in Natural and Artificial Systems*.
37//! University of Michigan Press. ISBN: 978-0-472-08460-9.
38//!
39//! ## When to Use
40//!
41//! - **Best for**: medium-size problems (n ≤ 200) where crossover is constructive.
42//! - **Strengths**: maintains population diversity, explores multiple basins.
43//! - **Limitations**: slower per-iteration than SA; may converge prematurely
44//! without sufficient population size.
45//!
46//! ## Usage
47//!
48//! ```
49//! use quantrs2_tytan::sampler::{GASampler, Sampler};
50//! use scirs2_core::ndarray::Array;
51//! use std::collections::HashMap;
52//!
53//! // Minimise: -x0 - x1 - x2 + 2*x0*x1 + 2*x0*x2 (independence problem)
54//! let mut q = Array::<f64, _>::zeros((3, 3));
55//! q[[0, 0]] = -1.0;
56//! q[[1, 1]] = -1.0;
57//! q[[2, 2]] = -1.0;
58//! q[[0, 1]] = 2.0;
59//! q[[0, 2]] = 2.0;
60//!
61//! let mut var_map = HashMap::new();
62//! var_map.insert("x0".to_string(), 0);
63//! var_map.insert("x1".to_string(), 1);
64//! var_map.insert("x2".to_string(), 2);
65//!
66//! // Use small population/generations for a fast doc-test
67//! let sampler = GASampler::with_params(Some(42), 20, 20);
68//! let results = sampler.run_qubo(&(q, var_map), 5).expect("GA sampler failed");
69//! assert!(!results.is_empty());
70//! println!("Best energy: {}", results[0].energy);
71//! ```
72
73use scirs2_core::ndarray::{Array, Dimension, Ix2};
74use scirs2_core::random::prelude::*;
75use scirs2_core::random::rngs::StdRng;
76use std::collections::HashMap;
77
78use super::energy::hobo_energy_full_dispatch;
79use super::{SampleResult, Sampler, SamplerResult};
80
81/// Genetic Algorithm Sampler
82///
83/// Evolutionary metaheuristic for QUBO/HOBO optimisation that maintains a
84/// population of binary solutions and evolves them through selection,
85/// crossover (uniform, single-point, two-point, or adaptive), and mutation.
86///
87/// # Example
88///
89/// ```
90/// use quantrs2_tytan::sampler::{GASampler, Sampler};
91/// use scirs2_core::ndarray::Array;
92/// use std::collections::HashMap;
93///
94/// // Minimise: -x0 - x1 - x2 + 2*x0*x1 + 2*x0*x2
95/// let mut q = Array::<f64, _>::zeros((3, 3));
96/// q[[0, 0]] = -1.0;
97/// q[[1, 1]] = -1.0;
98/// q[[2, 2]] = -1.0;
99/// q[[0, 1]] = 2.0;
100/// q[[0, 2]] = 2.0;
101///
102/// let mut var_map = HashMap::new();
103/// var_map.insert("x0".to_string(), 0);
104/// var_map.insert("x1".to_string(), 1);
105/// var_map.insert("x2".to_string(), 2);
106///
107/// // Small population/generations for a fast doc-test
108/// let sampler = GASampler::with_params(Some(42), 20, 20);
109/// let results = sampler.run_qubo(&(q, var_map), 5).expect("GA sampler failed");
110/// assert!(!results.is_empty());
111/// println!("Best energy: {}", results[0].energy);
112/// ```
113pub struct GASampler {
114 /// Random number generator seed
115 seed: Option<u64>,
116 /// Maximum number of generations
117 max_generations: usize,
118 /// Population size
119 population_size: usize,
120 /// Crossover strategy used by `run_qubo`/`run_hobo`
121 crossover_strategy: CrossoverStrategy,
122 /// Mutation strategy used by `run_qubo`/`run_hobo`
123 mutation_strategy: MutationStrategy,
124}
125
126/// Crossover strategy for genetic algorithm
127#[derive(Debug, Clone, Copy)]
128pub enum CrossoverStrategy {
129 /// Uniform crossover (random gene selection from each parent)
130 Uniform,
131 /// Single-point crossover (split at random point)
132 SinglePoint,
133 /// Two-point crossover (swap middle section)
134 TwoPoint,
135 /// Adaptive crossover (choice based on parent similarity)
136 Adaptive,
137}
138
139/// Mutation strategy for genetic algorithm
140#[derive(Debug, Clone, Copy)]
141pub enum MutationStrategy {
142 /// Flip bits with fixed probability
143 FixedRate(f64),
144 /// Mutate bits with decreasing rate over generations
145 Annealing(f64, f64), // (initial_rate, final_rate)
146 /// Adaptive mutation based on population diversity
147 Adaptive(f64, f64), // (min_rate, max_rate)
148}
149
150impl GASampler {
151 /// Create a new Genetic Algorithm sampler
152 ///
153 /// # Arguments
154 ///
155 /// * `seed` - An optional random seed for reproducibility
156 #[must_use]
157 pub const fn new(seed: Option<u64>) -> Self {
158 Self {
159 seed,
160 max_generations: 1000,
161 population_size: 100,
162 crossover_strategy: CrossoverStrategy::Adaptive,
163 mutation_strategy: MutationStrategy::Annealing(0.1, 0.01),
164 }
165 }
166
167 /// Create a new Genetic Algorithm sampler with custom parameters
168 ///
169 /// # Arguments
170 ///
171 /// * `seed` - An optional random seed for reproducibility
172 /// * `max_generations` - Maximum number of generations to evolve
173 /// * `population_size` - Size of the population
174 #[must_use]
175 pub const fn with_params(
176 seed: Option<u64>,
177 max_generations: usize,
178 population_size: usize,
179 ) -> Self {
180 Self {
181 seed,
182 max_generations,
183 population_size,
184 crossover_strategy: CrossoverStrategy::Adaptive,
185 mutation_strategy: MutationStrategy::Annealing(0.1, 0.01),
186 }
187 }
188
189 /// Set population size
190 pub const fn with_population_size(mut self, size: usize) -> Self {
191 self.population_size = size;
192 self
193 }
194
195 /// Set elite fraction (placeholder method)
196 pub const fn with_elite_fraction(self, _fraction: f64) -> Self {
197 // Note: Elite fraction not currently implemented in struct
198 // This is a placeholder to satisfy compilation
199 self
200 }
201
202 /// Set mutation rate (placeholder method)
203 pub const fn with_mutation_rate(self, _rate: f64) -> Self {
204 // Note: Mutation rate not currently implemented in struct
205 // This is a placeholder to satisfy compilation
206 self
207 }
208
209 /// Create a new enhanced Genetic Algorithm sampler
210 ///
211 /// # Arguments
212 ///
213 /// * `seed` - An optional random seed for reproducibility
214 /// * `max_generations` - Maximum number of generations to evolve
215 /// * `population_size` - Size of the population
216 /// * `crossover` - Crossover strategy to use
217 /// * `mutation` - Mutation strategy to use
218 pub const fn with_advanced_params(
219 seed: Option<u64>,
220 max_generations: usize,
221 population_size: usize,
222 crossover: CrossoverStrategy,
223 mutation: MutationStrategy,
224 ) -> Self {
225 Self {
226 seed,
227 max_generations,
228 population_size,
229 crossover_strategy: crossover,
230 mutation_strategy: mutation,
231 }
232 }
233
234 /// Perform crossover between two parents
235 fn crossover(
236 &self,
237 parent1: &[bool],
238 parent2: &[bool],
239 strategy: CrossoverStrategy,
240 rng: &mut impl Rng,
241 ) -> (Vec<bool>, Vec<bool>) {
242 let n_vars = parent1.len();
243 let mut child1 = vec![false; n_vars];
244 let mut child2 = vec![false; n_vars];
245
246 match strategy {
247 CrossoverStrategy::Uniform => {
248 // Uniform crossover
249 for i in 0..n_vars {
250 if rng.random_bool(0.5) {
251 child1[i] = parent1[i];
252 child2[i] = parent2[i];
253 } else {
254 child1[i] = parent2[i];
255 child2[i] = parent1[i];
256 }
257 }
258 }
259 CrossoverStrategy::SinglePoint => {
260 // Single-point crossover
261 let crossover_point = rng.random_range(1..n_vars);
262
263 for i in 0..n_vars {
264 if i < crossover_point {
265 child1[i] = parent1[i];
266 child2[i] = parent2[i];
267 } else {
268 child1[i] = parent2[i];
269 child2[i] = parent1[i];
270 }
271 }
272 }
273 CrossoverStrategy::TwoPoint => {
274 // Two-point crossover
275 let point1 = rng.random_range(1..(n_vars - 1));
276 let point2 = rng.random_range((point1 + 1)..n_vars);
277
278 for i in 0..n_vars {
279 if i < point1 || i >= point2 {
280 child1[i] = parent1[i];
281 child2[i] = parent2[i];
282 } else {
283 child1[i] = parent2[i];
284 child2[i] = parent1[i];
285 }
286 }
287 }
288 CrossoverStrategy::Adaptive => {
289 // Calculate Hamming distance between parents
290 let mut hamming_distance = 0;
291 for i in 0..n_vars {
292 if parent1[i] != parent2[i] {
293 hamming_distance += 1;
294 }
295 }
296
297 // Normalized distance
298 let similarity = 1.0 - (hamming_distance as f64 / n_vars as f64);
299
300 if similarity > 0.8 {
301 // Parents are very similar - use uniform with high mixing
302 for i in 0..n_vars {
303 if rng.random_bool(0.5) {
304 child1[i] = parent1[i];
305 child2[i] = parent2[i];
306 } else {
307 child1[i] = parent2[i];
308 child2[i] = parent1[i];
309 }
310 }
311 } else if similarity > 0.4 {
312 // Moderate similarity - use two-point
313 let point1 = rng.random_range(1..(n_vars - 1));
314 let point2 = rng.random_range((point1 + 1)..n_vars);
315
316 for i in 0..n_vars {
317 if i < point1 || i >= point2 {
318 child1[i] = parent1[i];
319 child2[i] = parent2[i];
320 } else {
321 child1[i] = parent2[i];
322 child2[i] = parent1[i];
323 }
324 }
325 } else {
326 // Low similarity - use single point
327 let crossover_point = rng.random_range(1..n_vars);
328
329 for i in 0..n_vars {
330 if i < crossover_point {
331 child1[i] = parent1[i];
332 child2[i] = parent2[i];
333 } else {
334 child1[i] = parent2[i];
335 child2[i] = parent1[i];
336 }
337 }
338 }
339 }
340 }
341
342 (child1, child2)
343 }
344
345 /// Mutate an individual
346 fn mutate(
347 &self,
348 individual: &mut [bool],
349 strategy: MutationStrategy,
350 generation: usize,
351 max_generations: usize,
352 diversity: Option<f64>,
353 rng: &mut impl Rng,
354 ) {
355 match strategy {
356 MutationStrategy::FixedRate(rate) => {
357 // Simple fixed mutation rate
358 for bit in individual.iter_mut() {
359 if rng.random_bool(rate) {
360 *bit = !*bit;
361 }
362 }
363 }
364 MutationStrategy::Annealing(initial_rate, final_rate) => {
365 // Annealing mutation (decreasing rate)
366 let progress = generation as f64 / max_generations as f64;
367 let current_rate = (final_rate - initial_rate).mul_add(progress, initial_rate);
368
369 for bit in individual.iter_mut() {
370 if rng.random_bool(current_rate) {
371 *bit = !*bit;
372 }
373 }
374 }
375 MutationStrategy::Adaptive(min_rate, max_rate) => {
376 // Adaptive mutation based on diversity
377 if let Some(diversity) = diversity {
378 // High diversity -> low mutation rate, low diversity -> high mutation rate
379 let rate = (max_rate - min_rate).mul_add(1.0 - diversity, min_rate);
380
381 for bit in individual.iter_mut() {
382 if rng.random_bool(rate) {
383 *bit = !*bit;
384 }
385 }
386 } else {
387 // Default to average if no diversity metric available
388 let rate = f64::midpoint(min_rate, max_rate);
389 for bit in individual.iter_mut() {
390 if rng.random_bool(rate) {
391 *bit = !*bit;
392 }
393 }
394 }
395 }
396 }
397 }
398
399 /// Calculate population diversity (normalized hamming distance)
400 fn calculate_diversity(&self, population: &[Vec<bool>]) -> f64 {
401 if population.len() <= 1 {
402 return 0.0;
403 }
404
405 let n_individuals = population.len();
406 let n_vars = population[0].len();
407 let mut sum_distances = 0;
408 let mut pair_count = 0;
409
410 for i in 0..n_individuals {
411 for j in (i + 1)..n_individuals {
412 let mut distance = 0;
413 for k in 0..n_vars {
414 if population[i][k] != population[j][k] {
415 distance += 1;
416 }
417 }
418 sum_distances += distance;
419 pair_count += 1;
420 }
421 }
422
423 // Average normalized Hamming distance
424 if pair_count > 0 {
425 (sum_distances as f64) / (pair_count as f64 * n_vars as f64)
426 } else {
427 0.0
428 }
429 }
430}
431
432impl Sampler for GASampler {
433 fn run_hobo(
434 &self,
435 hobo: &(
436 Array<f64, scirs2_core::ndarray::IxDyn>,
437 HashMap<String, usize>,
438 ),
439 shots: usize,
440 ) -> SamplerResult<Vec<SampleResult>> {
441 // Extract matrix and variable mapping
442 let (tensor, var_map) = hobo;
443
444 // Make sure shots is reasonable
445 let actual_shots = std::cmp::max(shots, 10);
446
447 // Get the problem dimension
448 let n_vars = var_map.len();
449
450 // Map from indices back to variable names
451 let idx_to_var: HashMap<usize, String> = var_map
452 .iter()
453 .map(|(var, &idx)| (idx, var.clone()))
454 .collect();
455
456 // Initialize random number generator
457 let mut rng = if let Some(seed) = self.seed {
458 StdRng::seed_from_u64(seed)
459 } else {
460 let seed: u64 = thread_rng().random();
461 StdRng::seed_from_u64(seed)
462 };
463
464 // Handle small population size cases to avoid empty range errors
465 if self.population_size <= 2 || n_vars == 0 {
466 // Return a simple result for trivial cases
467 let mut assignments = HashMap::new();
468 for var in var_map.keys() {
469 assignments.insert(var.clone(), false);
470 }
471
472 return Ok(vec![SampleResult {
473 assignments,
474 energy: 0.0,
475 occurrences: 1,
476 }]);
477 }
478
479 // For simplicity, if the tensor is 2D, convert to QUBO and use that implementation
480 if tensor.ndim() == 2 && tensor.shape() == [n_vars, n_vars] {
481 // Create a view as a 2D matrix and convert to owned matrix
482 let matrix = tensor
483 .clone()
484 .into_dimensionality::<scirs2_core::ndarray::Ix2>()
485 .map_err(|e| {
486 super::SamplerError::InvalidModel(format!(
487 "Failed to convert tensor to 2D matrix: {}",
488 e
489 ))
490 })?;
491 let qubo = (matrix, var_map.clone());
492
493 return self.run_qubo(&qubo, shots);
494 }
495
496 // Otherwise, implement the full HOBO genetic algorithm here
497 // Define a function to evaluate the energy of a solution
498 let evaluate_energy = |state: &[bool]| -> f64 { hobo_energy_full_dispatch(state, tensor) };
499
500 // Solution map with frequencies
501 let mut solution_counts: HashMap<Vec<bool>, (f64, usize)> = HashMap::new();
502
503 // Create a minimal, functional GA implementation
504 let pop_size = self.population_size.clamp(10, 100);
505
506 // Initialize random population
507 let mut population: Vec<Vec<bool>> = (0..pop_size)
508 .map(|_| (0..n_vars).map(|_| rng.random_bool(0.5)).collect())
509 .collect();
510
511 // Evaluate initial population
512 let mut fitness: Vec<f64> = population
513 .iter()
514 .map(|indiv| evaluate_energy(indiv))
515 .collect();
516
517 // Find best solution
518 let mut best_solution = population[0].clone();
519 let mut best_fitness = fitness[0];
520
521 for (idx, fit) in fitness.iter().enumerate() {
522 if *fit < best_fitness {
523 best_fitness = *fit;
524 best_solution = population[idx].clone();
525 }
526 }
527
528 // Genetic algorithm loop
529 const HOBO_GENERATIONS: usize = 30; // Reduced number of generations for faster results
530 for generation in 0..HOBO_GENERATIONS {
531 // Diversity metric for the Adaptive mutation strategy, computed
532 // the same way as in `run_qubo`.
533 let diversity = self.calculate_diversity(&population);
534
535 // Create next generation
536 let mut next_population = Vec::with_capacity(pop_size);
537
538 // Elitism - keep best solution
539 next_population.push(best_solution.clone());
540
541 // Fill population with new individuals
542 while next_population.len() < pop_size {
543 // Select parents via tournament selection
544 let parent1_idx = tournament_selection(&fitness, 3, &mut rng);
545 let parent2_idx = tournament_selection(&fitness, 3, &mut rng);
546
547 // Crossover, respecting the configured strategy (this used
548 // to always use a hardcoded single-point crossover here,
549 // ignoring `with_advanced_params`).
550 let (mut child1, mut child2) = self.crossover(
551 &population[parent1_idx],
552 &population[parent2_idx],
553 self.crossover_strategy,
554 &mut rng,
555 );
556
557 // Mutation, respecting the configured strategy (this used
558 // to always use a hardcoded fixed 0.05 rate here).
559 self.mutate(
560 &mut child1,
561 self.mutation_strategy,
562 generation,
563 HOBO_GENERATIONS,
564 Some(diversity),
565 &mut rng,
566 );
567 self.mutate(
568 &mut child2,
569 self.mutation_strategy,
570 generation,
571 HOBO_GENERATIONS,
572 Some(diversity),
573 &mut rng,
574 );
575
576 // Add children
577 next_population.push(child1);
578 if next_population.len() < pop_size {
579 next_population.push(child2);
580 }
581 }
582
583 // Evaluate new population
584 population = next_population;
585 fitness = population
586 .iter()
587 .map(|indiv| evaluate_energy(indiv))
588 .collect();
589
590 // Update best solution
591 for (idx, fit) in fitness.iter().enumerate() {
592 if *fit < best_fitness {
593 best_fitness = *fit;
594 best_solution = population[idx].clone();
595 }
596 }
597
598 // Update solution counts
599 for (idx, indiv) in population.iter().enumerate() {
600 let entry = solution_counts
601 .entry(indiv.clone())
602 .or_insert((fitness[idx], 0));
603 entry.1 += 1;
604 }
605 }
606
607 // Convert solutions to SampleResult
608 let mut results: Vec<SampleResult> = solution_counts
609 .into_iter()
610 .filter_map(|(state, (energy, count))| {
611 // Convert to variable assignments
612 let assignments: HashMap<String, bool> = state
613 .iter()
614 .enumerate()
615 .filter_map(|(idx, &value)| {
616 idx_to_var
617 .get(&idx)
618 .map(|var_name| (var_name.clone(), value))
619 })
620 .collect();
621
622 // Skip solutions with missing variable mappings
623 if assignments.len() != state.len() {
624 return None;
625 }
626
627 Some(SampleResult {
628 assignments,
629 energy,
630 occurrences: count,
631 })
632 })
633 .collect();
634
635 // Sort by energy (best solutions first)
636 // Use unwrap_or for NaN handling - treat NaN as equal to any value
637 results.sort_by(|a, b| {
638 a.energy
639 .partial_cmp(&b.energy)
640 .unwrap_or(std::cmp::Ordering::Equal)
641 });
642
643 // Limit to requested number of shots if we have more
644 if results.len() > actual_shots {
645 results.truncate(actual_shots);
646 }
647
648 Ok(results)
649 }
650
651 fn run_qubo(
652 &self,
653 qubo: &(
654 Array<f64, scirs2_core::ndarray::Ix2>,
655 HashMap<String, usize>,
656 ),
657 shots: usize,
658 ) -> SamplerResult<Vec<SampleResult>> {
659 // Extract matrix and variable mapping
660 let (matrix, var_map) = qubo;
661
662 // Make sure shots is reasonable
663 let actual_shots = std::cmp::max(shots, 10);
664
665 // Get the problem dimension
666 let n_vars = var_map.len();
667
668 // Map from indices back to variable names
669 let idx_to_var: HashMap<usize, String> = var_map
670 .iter()
671 .map(|(var, &idx)| (idx, var.clone()))
672 .collect();
673
674 // Initialize random number generator
675 let mut rng = if let Some(seed) = self.seed {
676 StdRng::seed_from_u64(seed)
677 } else {
678 let seed: u64 = thread_rng().random();
679 StdRng::seed_from_u64(seed)
680 };
681
682 // Handle edge cases
683 if self.population_size <= 2 || n_vars == 0 {
684 let mut assignments = HashMap::new();
685 for var in var_map.keys() {
686 assignments.insert(var.clone(), false);
687 }
688
689 return Ok(vec![SampleResult {
690 assignments,
691 energy: 0.0,
692 occurrences: 1,
693 }]);
694 }
695
696 // Use the strategies configured via `with_advanced_params` (or the
697 // Adaptive/Annealing defaults from `new`/`with_params`), instead of
698 // silently ignoring whatever the caller configured.
699 let crossover_strategy = self.crossover_strategy;
700 let mutation_strategy = self.mutation_strategy;
701 let selection_pressure = 3; // Tournament size
702 let use_elitism = true;
703
704 // Initialize population with random bitstrings
705 let mut population: Vec<Vec<bool>> = (0..self.population_size)
706 .map(|_| (0..n_vars).map(|_| rng.random_bool(0.5)).collect())
707 .collect();
708
709 // Initialize fitness scores (energy values)
710 let mut fitness: Vec<f64> = population
711 .iter()
712 .map(|indiv| calculate_energy(indiv, matrix))
713 .collect();
714
715 // Keep track of best solution in current population
716 let mut best_idx = 0;
717 let mut best_fitness = fitness[0];
718 for (idx, &fit) in fitness.iter().enumerate() {
719 if fit < best_fitness {
720 best_idx = idx;
721 best_fitness = fit;
722 }
723 }
724 let mut best_individual = population[best_idx].clone();
725 let mut best_individual_fitness = best_fitness;
726
727 // Track solutions and their frequencies
728 let mut solution_counts: HashMap<Vec<bool>, usize> = HashMap::new();
729
730 // Main GA loop
731 for generation in 0..self.max_generations {
732 // Calculate population diversity for adaptive operators
733 let diversity = self.calculate_diversity(&population);
734
735 // Create next generation
736 let mut next_population = Vec::with_capacity(self.population_size);
737 let mut next_fitness = Vec::with_capacity(self.population_size);
738
739 // Elitism - copy best individual
740 if use_elitism {
741 next_population.push(best_individual.clone());
742 next_fitness.push(best_individual_fitness);
743 }
744
745 // Fill rest of population through selection, crossover, mutation
746 while next_population.len() < self.population_size {
747 // Tournament selection for parents
748 let parent1_idx = tournament_selection(&fitness, selection_pressure, &mut rng);
749 let parent2_idx = tournament_selection(&fitness, selection_pressure, &mut rng);
750
751 let parent1 = &population[parent1_idx];
752 let parent2 = &population[parent2_idx];
753
754 // Crossover
755 let (mut child1, mut child2) =
756 self.crossover(parent1, parent2, crossover_strategy, &mut rng);
757
758 // Mutation
759 self.mutate(
760 &mut child1,
761 mutation_strategy,
762 generation,
763 self.max_generations,
764 Some(diversity),
765 &mut rng,
766 );
767 self.mutate(
768 &mut child2,
769 mutation_strategy,
770 generation,
771 self.max_generations,
772 Some(diversity),
773 &mut rng,
774 );
775
776 // Evaluate fitness of new children
777 let child1_fitness = calculate_energy(&child1, matrix);
778 let child2_fitness = calculate_energy(&child2, matrix);
779
780 // Add first child
781 next_population.push(child1);
782 next_fitness.push(child1_fitness);
783
784 // Add second child if there's room
785 if next_population.len() < self.population_size {
786 next_population.push(child2);
787 next_fitness.push(child2_fitness);
788 }
789 }
790
791 // Update population
792 population = next_population;
793 fitness = next_fitness;
794
795 // Update best solution
796 best_idx = 0;
797 best_fitness = fitness[0];
798 for (idx, &fit) in fitness.iter().enumerate() {
799 if fit < best_fitness {
800 best_idx = idx;
801 best_fitness = fit;
802 }
803 }
804
805 // Update global best if needed
806 if best_fitness < best_individual_fitness {
807 best_individual = population[best_idx].clone();
808 best_individual_fitness = best_fitness;
809 }
810
811 // Update solution counts
812 for individual in &population {
813 *solution_counts.entry(individual.clone()).or_insert(0) += 1;
814 }
815 }
816
817 // Collect results
818 let mut results = Vec::new();
819
820 // Convert the solutions to SampleResult format
821 for (solution, count) in &solution_counts {
822 // Only include solutions that appeared multiple times
823 if *count < 2 {
824 continue;
825 }
826
827 // Calculate energy one more time
828 let energy = calculate_energy(solution, matrix);
829
830 // Convert to variable assignments, skipping any missing mappings
831 let assignments: HashMap<String, bool> = solution
832 .iter()
833 .enumerate()
834 .filter_map(|(idx, &value)| {
835 idx_to_var
836 .get(&idx)
837 .map(|var_name| (var_name.clone(), value))
838 })
839 .collect();
840
841 // Skip solutions with incomplete variable mappings
842 if assignments.len() != solution.len() {
843 continue;
844 }
845
846 // Create result and add to collection
847 results.push(SampleResult {
848 assignments,
849 energy,
850 occurrences: *count,
851 });
852 }
853
854 // Sort by energy (best solutions first)
855 // Use unwrap_or for NaN handling - treat NaN as equal to any value
856 results.sort_by(|a, b| {
857 a.energy
858 .partial_cmp(&b.energy)
859 .unwrap_or(std::cmp::Ordering::Equal)
860 });
861
862 // Trim to requested number of shots
863 if results.len() > actual_shots {
864 results.truncate(actual_shots);
865 }
866
867 Ok(results)
868 }
869}
870
871// Helper function to calculate energy for a solution
872fn calculate_energy(solution: &[bool], matrix: &Array<f64, Ix2>) -> f64 {
873 let n = solution.len();
874 let mut energy = 0.0;
875
876 // Calculate from diagonal terms (linear)
877 for i in 0..n {
878 if solution[i] {
879 energy += matrix[[i, i]];
880 }
881 }
882
883 // Calculate from off-diagonal terms (quadratic)
884 for i in 0..n {
885 if solution[i] {
886 for j in (i + 1)..n {
887 if solution[j] {
888 energy += matrix[[i, j]];
889 }
890 }
891 }
892 }
893
894 energy
895}
896
897// Helper function for tournament selection
898fn tournament_selection(fitness: &[f64], tournament_size: usize, rng: &mut impl Rng) -> usize {
899 // Handle edge cases
900 assert!(
901 !fitness.is_empty(),
902 "Cannot perform tournament selection on an empty fitness array"
903 );
904
905 if fitness.len() == 1 || tournament_size <= 1 {
906 return 0; // Only one choice available
907 }
908
909 // Ensure tournament_size is not larger than the population
910 let effective_tournament_size = std::cmp::min(tournament_size, fitness.len());
911
912 let mut best_idx = rng.random_range(0..fitness.len());
913 let mut best_fitness = fitness[best_idx];
914
915 for _ in 1..(effective_tournament_size) {
916 let candidate_idx = rng.random_range(0..fitness.len());
917 let candidate_fitness = fitness[candidate_idx];
918
919 // Lower fitness is better (minimization problem)
920 if candidate_fitness < best_fitness {
921 best_idx = candidate_idx;
922 best_fitness = candidate_fitness;
923 }
924 }
925
926 best_idx
927}
928
929#[cfg(test)]
930mod tests {
931 use super::*;
932 use crate::sampler::Sampler;
933 use scirs2_core::ndarray::Array2;
934 use std::collections::HashMap;
935
936 #[test]
937 fn test_with_advanced_params_stores_configured_strategies() {
938 let sampler = GASampler::with_advanced_params(
939 Some(1),
940 50,
941 20,
942 CrossoverStrategy::TwoPoint,
943 MutationStrategy::Adaptive(0.01, 0.5),
944 );
945
946 // The old fabricated implementation silently discarded these two
947 // constructor arguments (the struct had no fields to store them in)
948 // and `run_qubo`/`run_hobo` always behaved as if
949 // CrossoverStrategy::Adaptive + MutationStrategy::Annealing(0.1,
950 // 0.01) had been requested, regardless of what was passed here.
951 assert!(matches!(
952 sampler.crossover_strategy,
953 CrossoverStrategy::TwoPoint
954 ));
955 match sampler.mutation_strategy {
956 MutationStrategy::Adaptive(min_rate, max_rate) => {
957 assert!((min_rate - 0.01).abs() < 1e-12);
958 assert!((max_rate - 0.5).abs() < 1e-12);
959 }
960 other => panic!("expected MutationStrategy::Adaptive, got {other:?}"),
961 }
962 }
963
964 #[test]
965 fn test_default_constructors_use_adaptive_annealing_defaults() {
966 let sampler = GASampler::new(Some(1));
967 assert!(matches!(
968 sampler.crossover_strategy,
969 CrossoverStrategy::Adaptive
970 ));
971 match sampler.mutation_strategy {
972 MutationStrategy::Annealing(initial, final_) => {
973 assert!((initial - 0.1).abs() < 1e-12);
974 assert!((final_ - 0.01).abs() < 1e-12);
975 }
976 other => panic!("expected MutationStrategy::Annealing, got {other:?}"),
977 }
978 }
979
980 #[test]
981 fn test_run_qubo_with_custom_strategy_produces_valid_results() {
982 let qubo = Array2::from_shape_fn((4, 4), |(i, j)| if i == j { -1.0 } else { 0.1 });
983 let mut var_map = HashMap::new();
984 for i in 0..4 {
985 var_map.insert(format!("x{i}"), i);
986 }
987
988 let sampler = GASampler::with_advanced_params(
989 Some(7),
990 30,
991 20,
992 CrossoverStrategy::SinglePoint,
993 MutationStrategy::FixedRate(0.2),
994 );
995
996 let results = sampler
997 .run_qubo(&(qubo, var_map), 5)
998 .expect("GA sampler should succeed with a configured strategy");
999 assert!(!results.is_empty());
1000 for result in &results {
1001 assert_eq!(result.assignments.len(), 4);
1002 }
1003 }
1004}