Skip to main content

quantrs2_tytan/sampler/
population_annealing.rs

1//! # Population Annealing (PA) Sampler
2//!
3//! Population Annealing (PA) maintains a population of R replicas that are
4//! annealed simultaneously through a sequence of inverse temperatures β₁ < β₂ < … < β_K.
5//! After each temperature step the replicas are resampled by importance weights,
6//! concentrating computational effort on the lowest-energy region of the search space
7//! while preserving the correct thermodynamic ensemble.
8//!
9//! ## Algorithm
10//!
11//! 1. **Initialise**: create R replicas with independent random binary assignments.
12//! 2. **For each β_k in the schedule**, run `sweeps_per_step` Metropolis sweeps on
13//!    every replica, then:
14//!    - Compute importance weights `w_r = exp(-(β_{k+1} - β_k) * E_r)`.
15//!    - Compute ESS = `(Σ w_r)² / Σ w_r²`.
16//!    - If `ESS / R < resample_threshold`, multinomial-resample R replicas by weight.
17//! 3. Return `shots` samples drawn from the final population.
18//!
19//! ## Mathematical Formulation
20//!
21//! Given a QUBO matrix Q, the objective is to minimise:
22//!
23//! ```text
24//! E(x) = sum_{i,j} Q[i,j] * x[i] * x[j],   x[i] in {0, 1}
25//! ```
26//!
27//! The Metropolis acceptance probability at inverse temperature β for a move
28//! with energy change ΔE:
29//!
30//! ```text
31//! P(accept) = min(1, exp(-β * ΔE))
32//! ```
33//!
34//! ## Citation
35//!
36//! Hukushima, K., & Iba, Y. (2003).
37//! Population Annealing and Its Application to a Spin Glass.
38//! *AIP Conference Proceedings*, 690(1), 200–206.
39//! <https://doi.org/10.1063/1.1632130>
40//!
41//! ## Parameters
42//!
43//! See [`PAParams`] for all tunable parameters (`population`, `beta_schedule`,
44//! `sweeps_per_step`, `resample_threshold`).
45//!
46//! ## When to Use
47//!
48//! - **Best for**: problems where you need an ensemble of near-ground-state solutions
49//!   or accurate estimates of low-temperature thermodynamic observables.
50//! - **Strengths**: provides diversity across the low-energy landscape; ESS-based
51//!   resampling avoids weight collapse.
52//! - **Limitations**: higher memory usage (R replicas stored simultaneously);
53//!   slower per-shot than SA for single-solution queries.
54//!
55//! ## Usage
56//!
57//! ```
58//! use quantrs2_tytan::sampler::{PopulationAnnealingSampler, Sampler};
59//! use scirs2_core::ndarray::Array;
60//! use std::collections::HashMap;
61//!
62//! // K3 Max-Cut QUBO
63//! let mut q = Array::<f64, _>::zeros((3, 3));
64//! q[[0, 0]] = -1.0;
65//! q[[1, 1]] = -1.0;
66//! q[[2, 2]] = -1.0;
67//! q[[0, 1]] = 2.0;
68//! q[[0, 2]] = 2.0;
69//! q[[1, 2]] = 2.0;
70//!
71//! let mut var_map = HashMap::new();
72//! var_map.insert("x0".to_string(), 0);
73//! var_map.insert("x1".to_string(), 1);
74//! var_map.insert("x2".to_string(), 2);
75//!
76//! // Small schedule for a fast doc-test
77//! let betas: Vec<f64> = (0..10).map(|i| 0.1 + 2.9 * i as f64 / 9.0).collect();
78//! let sampler = PopulationAnnealingSampler::new()
79//!     .with_seed(42)
80//!     .with_population(20)
81//!     .with_beta_schedule(betas);
82//!
83//! let results = sampler.run_qubo(&(q, var_map), 5).expect("PA sampler failed");
84//! assert!(!results.is_empty());
85//! println!("Best energy: {}", results[0].energy);
86//! ```
87
88use scirs2_core::ndarray::{Array, ArrayD, Ix2};
89use scirs2_core::random::prelude::*;
90use scirs2_core::random::rngs::StdRng;
91use std::collections::HashMap;
92
93use super::energy::{compute_influence_simd, energy_full_simd, update_influence_simd};
94use super::{SampleResult, Sampler, SamplerError, SamplerResult};
95
96/// Parameters for the Population Annealing algorithm
97#[derive(Debug, Clone)]
98pub struct PAParams {
99    /// Number of replicas in the population
100    pub population: usize,
101    /// Inverse temperature schedule (ascending from low to high β)
102    pub beta_schedule: Vec<f64>,
103    /// Number of Metropolis sweeps per temperature step
104    pub sweeps_per_step: usize,
105    /// ESS/population threshold below which to resample
106    pub resample_threshold: f64,
107}
108
109impl Default for PAParams {
110    fn default() -> Self {
111        // Linspace(0.1, 3.0, 50)
112        let betas: Vec<f64> = (0..50)
113            .map(|i| 0.1 + (3.0 - 0.1) * i as f64 / 49.0)
114            .collect();
115        Self {
116            population: 100,
117            beta_schedule: betas,
118            sweeps_per_step: 5,
119            resample_threshold: 0.5,
120        }
121    }
122}
123
124/// Population Annealing Sampler
125///
126/// Uses the Population Annealing algorithm to solve QUBO/HOBO problems.
127/// Maintains a population of replicas that evolve through a temperature
128/// schedule, with resampling to concentrate on low-energy regions.
129///
130/// # Example
131///
132/// ```
133/// use quantrs2_tytan::sampler::{PopulationAnnealingSampler, Sampler};
134/// use std::collections::HashMap;
135/// use scirs2_core::ndarray::Array;
136///
137/// let mut q = Array::<f64, _>::zeros((3, 3));
138/// q[[0, 0]] = -1.0;
139/// q[[1, 1]] = -1.0;
140/// q[[2, 2]] = -1.0;
141/// q[[0, 1]] = 2.0;
142/// q[[0, 2]] = 2.0;
143/// q[[1, 2]] = 2.0;
144///
145/// let mut var_map = HashMap::new();
146/// var_map.insert("x0".to_string(), 0);
147/// var_map.insert("x1".to_string(), 1);
148/// var_map.insert("x2".to_string(), 2);
149///
150/// // Small schedule for a fast doc-test
151/// let betas: Vec<f64> = (0..10).map(|i| 0.1 + 2.9 * i as f64 / 9.0).collect();
152/// let sampler = PopulationAnnealingSampler::new()
153///     .with_seed(42)
154///     .with_population(20)
155///     .with_beta_schedule(betas);
156///
157/// let results = sampler.run_qubo(&(q, var_map), 5).expect("Population annealing failed");
158/// assert!(!results.is_empty());
159/// println!("Best energy: {}", results[0].energy);
160/// ```
161#[derive(Debug, Clone)]
162pub struct PopulationAnnealingSampler {
163    /// Random number generator seed
164    pub seed: Option<u64>,
165    /// PA algorithm parameters
166    pub params: PAParams,
167}
168
169impl PopulationAnnealingSampler {
170    /// Create a new Population Annealing sampler with default parameters
171    #[must_use]
172    pub fn new() -> Self {
173        Self {
174            seed: None,
175            params: PAParams::default(),
176        }
177    }
178
179    /// Set the random seed for reproducibility
180    #[must_use]
181    pub fn with_seed(mut self, seed: u64) -> Self {
182        self.seed = Some(seed);
183        self
184    }
185
186    /// Set the population size (number of replicas)
187    #[must_use]
188    pub fn with_population(mut self, population: usize) -> Self {
189        self.params.population = population;
190        self
191    }
192
193    /// Set the number of sweeps per temperature step
194    #[must_use]
195    pub fn with_sweeps_per_step(mut self, sweeps: usize) -> Self {
196        self.params.sweeps_per_step = sweeps;
197        self
198    }
199
200    /// Set the ESS/population resampling threshold
201    #[must_use]
202    pub fn with_resample_threshold(mut self, threshold: f64) -> Self {
203        self.params.resample_threshold = threshold;
204        self
205    }
206
207    /// Set a custom beta (inverse temperature) schedule
208    #[must_use]
209    pub fn with_beta_schedule(mut self, schedule: Vec<f64>) -> Self {
210        self.params.beta_schedule = schedule;
211        self
212    }
213
214    /// Compute QUBO energy: E(x) = sum_{i,j} Q[i,j] * x[i] * x[j]
215    ///
216    /// Delegates to [`energy_full_simd`] which uses 4-wide f64 SIMD for n >= 32.
217    #[inline]
218    fn compute_qubo_energy_flat(q_matrix: &[f64], state: &[bool], n: usize) -> f64 {
219        energy_full_simd(state, q_matrix, n)
220    }
221
222    /// Compute influence vector g[i] = Q[i,i] + sum_{j!=i} (Q[i,j] + Q[j,i]) * x[j]
223    ///
224    /// ΔE from flipping bit i = (1 - 2*x[i]) * g[i]
225    ///
226    /// Delegates to [`compute_influence_simd`] which uses SIMD for n >= 32.
227    #[inline]
228    fn compute_influence_flat(q_matrix: &[f64], state: &[bool], n: usize) -> Vec<f64> {
229        compute_influence_simd(state, q_matrix, n)
230    }
231
232    /// Update influence vector after flipping bit k.
233    ///
234    /// Delegates to [`update_influence_simd`] which uses SIMD for n >= 32.
235    #[inline]
236    fn update_influence_flat(g: &mut [f64], q_matrix: &[f64], k: usize, new_val: bool, n: usize) {
237        update_influence_simd(g, q_matrix, n, k, new_val);
238    }
239
240    /// Evaluate HOBO energy for a generic tensor
241    fn evaluate_hobo_energy<D>(tensor: &Array<f64, D>, state: &[bool], n_vars: usize) -> f64
242    where
243        D: scirs2_core::ndarray::Dimension + 'static,
244    {
245        // Convert to dynamic dimensionality to allow slice-based indexing
246        let dyn_tensor: ArrayD<f64> = tensor.to_owned().into_dyn();
247        Self::evaluate_hobo_energy_dyn(&dyn_tensor, state, n_vars)
248    }
249
250    /// Evaluate HOBO energy for a dynamic-dimension tensor
251    fn evaluate_hobo_energy_dyn(tensor: &ArrayD<f64>, state: &[bool], _n_vars: usize) -> f64 {
252        super::energy::hobo_energy_full_dispatch(state, tensor)
253    }
254
255    /// Perform multinomial resampling of the population
256    ///
257    /// Given a population and weights, draw `n` samples with replacement.
258    fn multinomial_resample(
259        population: &[Vec<bool>],
260        weights: &[f64],
261        n: usize,
262        rng: &mut StdRng,
263    ) -> Vec<Vec<bool>> {
264        let total_weight: f64 = weights.iter().sum();
265        if total_weight <= 0.0 || population.is_empty() {
266            // Fallback: uniform resampling
267            return (0..n)
268                .map(|_| {
269                    let idx = rng.random_range(0..population.len());
270                    population[idx].clone()
271                })
272                .collect();
273        }
274
275        // Build cumulative weight distribution
276        let mut cumulative = Vec::with_capacity(weights.len());
277        let mut running = 0.0;
278        for &w in weights {
279            running += w / total_weight;
280            cumulative.push(running);
281        }
282        // Ensure last entry is exactly 1.0
283        if let Some(last) = cumulative.last_mut() {
284            *last = 1.0;
285        }
286
287        // Sample n indices
288        (0..n)
289            .map(|_| {
290                let u: f64 = rng.random_range(0.0f64..1.0f64);
291                let idx = cumulative
292                    .iter()
293                    .position(|&c| u <= c)
294                    .unwrap_or(cumulative.len().saturating_sub(1));
295                population[idx.min(population.len() - 1)].clone()
296            })
297            .collect()
298    }
299
300    /// Run population annealing on a QUBO problem (flat matrix)
301    fn run_pa_qubo(
302        &self,
303        q_flat: &[f64],
304        n: usize,
305        shots: usize,
306        seed: u64,
307    ) -> Vec<(Vec<bool>, f64)> {
308        let pop_size = self.params.population;
309        let beta_schedule = &self.params.beta_schedule;
310        let sweeps_per_step = self.params.sweeps_per_step;
311        let resample_threshold = self.params.resample_threshold;
312
313        let mut rng = StdRng::seed_from_u64(seed);
314
315        if beta_schedule.is_empty() || pop_size == 0 || n == 0 {
316            return vec![];
317        }
318
319        // Initialize population: each replica is a random binary state
320        let mut population: Vec<Vec<bool>> = (0..pop_size)
321            .map(|_| (0..n).map(|_| rng.random_bool(0.5)).collect())
322            .collect();
323
324        // Initialize energies for each replica
325        let mut energies: Vec<f64> = population
326            .iter()
327            .map(|s| Self::compute_qubo_energy_flat(q_flat, s, n))
328            .collect();
329
330        // Track current beta (initialized at 0 before the first step)
331        let mut beta_prev = 0.0;
332
333        for &beta_new in beta_schedule {
334            let delta_beta = beta_new - beta_prev;
335
336            // MC sweeps on each replica
337            for r in 0..pop_size {
338                let state = &mut population[r];
339                let mut energy = energies[r];
340                let mut g = Self::compute_influence_flat(q_flat, state, n);
341
342                for _ in 0..sweeps_per_step {
343                    for i in 0..n {
344                        let delta_e = (1.0 - 2.0 * if state[i] { 1.0 } else { 0.0 }) * g[i];
345                        // Metropolis acceptance: flip with probability 1 / (1 + exp(beta * delta_e))
346                        let accept = if delta_e <= 0.0 {
347                            true
348                        } else {
349                            let threshold = 1.0 / (1.0 + (beta_new * delta_e).exp());
350                            rng.random_range(0.0f64..1.0f64) < threshold
351                        };
352
353                        if accept {
354                            let new_val = !state[i];
355                            Self::update_influence_flat(&mut g, q_flat, i, new_val, n);
356                            state[i] = new_val;
357                            energy += delta_e;
358                        }
359                    }
360                }
361                energies[r] = energy;
362            }
363
364            // Compute importance weights w_r = exp(-delta_beta * E_r)
365            // Use log-sum-exp trick for numerical stability
366            let log_weights: Vec<f64> = energies.iter().map(|&e| -delta_beta * e).collect();
367            let max_log_w = log_weights
368                .iter()
369                .cloned()
370                .fold(f64::NEG_INFINITY, f64::max);
371
372            let weights: Vec<f64> = log_weights
373                .iter()
374                .map(|&lw| (lw - max_log_w).exp())
375                .collect();
376
377            // Compute ESS = (Σw_r)² / Σw_r²
378            let sum_w: f64 = weights.iter().sum();
379            let sum_w2: f64 = weights.iter().map(|&w| w * w).sum();
380
381            let ess = if sum_w2 > 0.0 {
382                sum_w * sum_w / sum_w2
383            } else {
384                0.0
385            };
386
387            // Resample if ESS/N < threshold
388            if (ess / pop_size as f64) < resample_threshold {
389                let new_population =
390                    Self::multinomial_resample(&population, &weights, pop_size, &mut rng);
391                // Recompute energies after resampling
392                let new_energies: Vec<f64> = new_population
393                    .iter()
394                    .map(|s| Self::compute_qubo_energy_flat(q_flat, s, n))
395                    .collect();
396                population = new_population;
397                energies = new_energies;
398            }
399
400            beta_prev = beta_new;
401        }
402
403        // Return shots samples from final population
404        let pop_size_actual = population.len();
405        (0..shots)
406            .map(|i| {
407                let idx = i % pop_size_actual;
408                (population[idx].clone(), energies[idx])
409            })
410            .collect()
411    }
412
413    /// Run population annealing on a HOBO tensor
414    fn run_pa_hobo<D>(
415        &self,
416        tensor: &Array<f64, D>,
417        n_vars: usize,
418        shots: usize,
419        seed: u64,
420    ) -> Vec<(Vec<bool>, f64)>
421    where
422        D: scirs2_core::ndarray::Dimension + 'static,
423    {
424        // Convert once to dynamic dimensionality for repeated indexing
425        let dyn_tensor: ArrayD<f64> = tensor.to_owned().into_dyn();
426        self.run_pa_hobo_dyn(&dyn_tensor, n_vars, shots, seed)
427    }
428
429    /// Run population annealing on a dynamic-dimension HOBO tensor
430    fn run_pa_hobo_dyn(
431        &self,
432        tensor: &ArrayD<f64>,
433        n_vars: usize,
434        shots: usize,
435        seed: u64,
436    ) -> Vec<(Vec<bool>, f64)> {
437        let pop_size = self.params.population;
438        let beta_schedule = &self.params.beta_schedule;
439        let sweeps_per_step = self.params.sweeps_per_step;
440        let resample_threshold = self.params.resample_threshold;
441
442        let mut rng = StdRng::seed_from_u64(seed);
443
444        if beta_schedule.is_empty() || pop_size == 0 || n_vars == 0 {
445            return vec![];
446        }
447
448        let mut population: Vec<Vec<bool>> = (0..pop_size)
449            .map(|_| (0..n_vars).map(|_| rng.random_bool(0.5)).collect())
450            .collect();
451
452        let mut energies: Vec<f64> = population
453            .iter()
454            .map(|s| Self::evaluate_hobo_energy_dyn(tensor, s, n_vars))
455            .collect();
456
457        let mut beta_prev = 0.0;
458
459        for &beta_new in beta_schedule {
460            let delta_beta = beta_new - beta_prev;
461
462            for r in 0..pop_size {
463                let state = &mut population[r];
464                let mut energy = energies[r];
465
466                for _ in 0..sweeps_per_step {
467                    for i in 0..n_vars {
468                        state[i] = !state[i];
469                        let new_energy = Self::evaluate_hobo_energy_dyn(tensor, state, n_vars);
470                        let delta_e = new_energy - energy;
471                        state[i] = !state[i]; // Restore for now
472
473                        let accept = if delta_e <= 0.0 {
474                            true
475                        } else {
476                            let threshold = 1.0 / (1.0 + (beta_new * delta_e).exp());
477                            rng.random_range(0.0f64..1.0f64) < threshold
478                        };
479
480                        if accept {
481                            state[i] = !state[i];
482                            energy = new_energy;
483                        }
484                    }
485                }
486                energies[r] = energy;
487            }
488
489            let log_weights: Vec<f64> = energies.iter().map(|&e| -delta_beta * e).collect();
490            let max_log_w = log_weights
491                .iter()
492                .cloned()
493                .fold(f64::NEG_INFINITY, f64::max);
494
495            let weights: Vec<f64> = log_weights
496                .iter()
497                .map(|&lw| (lw - max_log_w).exp())
498                .collect();
499
500            let sum_w: f64 = weights.iter().sum();
501            let sum_w2: f64 = weights.iter().map(|&w| w * w).sum();
502
503            let ess = if sum_w2 > 0.0 {
504                sum_w * sum_w / sum_w2
505            } else {
506                0.0
507            };
508
509            if (ess / pop_size as f64) < resample_threshold {
510                let new_population =
511                    Self::multinomial_resample(&population, &weights, pop_size, &mut rng);
512                let new_energies: Vec<f64> = new_population
513                    .iter()
514                    .map(|s| Self::evaluate_hobo_energy_dyn(tensor, s, n_vars))
515                    .collect();
516                population = new_population;
517                energies = new_energies;
518            }
519
520            beta_prev = beta_new;
521        }
522
523        let pop_size_actual = population.len();
524        (0..shots)
525            .map(|i| {
526                let idx = i % pop_size_actual;
527                (population[idx].clone(), energies[idx])
528            })
529            .collect()
530    }
531
532    /// Run generic sampler
533    fn run_generic<D>(
534        &self,
535        matrix_or_tensor: &Array<f64, D>,
536        var_map: &HashMap<String, usize>,
537        shots: usize,
538    ) -> SamplerResult<Vec<SampleResult>>
539    where
540        D: scirs2_core::ndarray::Dimension + 'static,
541    {
542        let shots = shots.max(1);
543        let n_vars = var_map.len();
544        if n_vars == 0 {
545            return Err(SamplerError::InvalidParameter(
546                "Variable map is empty".to_string(),
547            ));
548        }
549
550        if self.params.beta_schedule.is_empty() {
551            return Err(SamplerError::InvalidParameter(
552                "Beta schedule is empty".to_string(),
553            ));
554        }
555
556        let idx_to_var: HashMap<usize, String> = var_map
557            .iter()
558            .map(|(var, &idx)| (idx, var.clone()))
559            .collect();
560
561        // Determine seed for this run
562        let run_seed = match self.seed {
563            Some(s) => s,
564            None => {
565                let mut rng_tmp = thread_rng();
566                rng_tmp.random()
567            }
568        };
569
570        // Run population annealing
571        let raw_results = if matrix_or_tensor.ndim() == 2 {
572            let q2 = matrix_or_tensor
573                .to_owned()
574                .into_dimensionality::<Ix2>()
575                .map_err(|e| SamplerError::InvalidParameter(format!("Array cast error: {e}")))?;
576
577            let n = q2.dim().0;
578            if n != q2.dim().1 {
579                return Err(SamplerError::InvalidParameter(
580                    "QUBO matrix must be square".to_string(),
581                ));
582            }
583
584            let q_flat: Vec<f64> = q2
585                .as_slice()
586                .ok_or_else(|| {
587                    SamplerError::InvalidParameter("Non-contiguous QUBO matrix".to_string())
588                })?
589                .to_vec();
590
591            self.run_pa_qubo(&q_flat, n, shots, run_seed)
592        } else {
593            self.run_pa_hobo(matrix_or_tensor, n_vars, shots, run_seed)
594        };
595
596        if raw_results.is_empty() {
597            return Ok(vec![]);
598        }
599
600        // Aggregate results: count occurrences of identical states
601        let mut solution_counts: HashMap<Vec<bool>, (f64, usize)> = HashMap::new();
602        for (state, energy) in raw_results {
603            let entry = solution_counts.entry(state).or_insert((energy, 0));
604            entry.1 += 1;
605        }
606
607        // Sort by (energy, state) for deterministic ordering when energies are equal
608        let mut pairs: Vec<(Vec<bool>, SampleResult)> = solution_counts
609            .into_iter()
610            .map(|(state, (energy, count))| {
611                let assignments: HashMap<String, bool> = state
612                    .iter()
613                    .enumerate()
614                    .filter_map(|(idx, &value)| {
615                        idx_to_var.get(&idx).map(|name| (name.clone(), value))
616                    })
617                    .collect();
618                let result = SampleResult {
619                    assignments,
620                    energy,
621                    occurrences: count,
622                };
623                (state, result)
624            })
625            .collect();
626
627        pairs.sort_by(|(state_a, a), (state_b, b)| {
628            a.energy
629                .partial_cmp(&b.energy)
630                .unwrap_or(std::cmp::Ordering::Equal)
631                .then_with(|| state_a.cmp(state_b))
632        });
633
634        let results: Vec<SampleResult> = pairs.into_iter().map(|(_, r)| r).collect();
635        Ok(results)
636    }
637}
638
639impl Sampler for PopulationAnnealingSampler {
640    fn run_qubo(
641        &self,
642        qubo: &(Array<f64, Ix2>, HashMap<String, usize>),
643        shots: usize,
644    ) -> SamplerResult<Vec<SampleResult>> {
645        self.run_generic(&qubo.0, &qubo.1, shots)
646    }
647
648    fn run_hobo(
649        &self,
650        hobo: &(ArrayD<f64>, HashMap<String, usize>),
651        shots: usize,
652    ) -> SamplerResult<Vec<SampleResult>> {
653        self.run_generic(&hobo.0, &hobo.1, shots)
654    }
655}
656
657#[cfg(test)]
658mod tests {
659    use super::*;
660    use scirs2_core::ndarray::Array2;
661
662    /// Build K3 Max-Cut QUBO matrix.
663    /// Optimal energy: -2.0
664    fn build_k3_maxcut_qubo() -> (Array2<f64>, HashMap<String, usize>) {
665        let mut q = Array2::<f64>::zeros((3, 3));
666        q[[0, 0]] = -2.0;
667        q[[1, 1]] = -2.0;
668        q[[2, 2]] = -2.0;
669        q[[0, 1]] = 2.0;
670        q[[0, 2]] = 2.0;
671        q[[1, 2]] = 2.0;
672
673        let mut var_map = HashMap::new();
674        var_map.insert("x0".to_string(), 0);
675        var_map.insert("x1".to_string(), 1);
676        var_map.insert("x2".to_string(), 2);
677
678        (q, var_map)
679    }
680
681    #[test]
682    fn test_pa_3var_maxcut() {
683        let (q, var_map) = build_k3_maxcut_qubo();
684        let sampler = PopulationAnnealingSampler::new()
685            .with_seed(42)
686            .with_population(50)
687            .with_sweeps_per_step(3);
688
689        let results = sampler
690            .run_qubo(&(q, var_map), 50)
691            .expect("PA run_qubo failed");
692
693        assert!(!results.is_empty(), "Expected non-empty results");
694        let best_energy = results[0].energy;
695        assert!(
696            best_energy <= -2.0 + 1e-9,
697            "Expected optimal energy <= -2.0, got {best_energy}"
698        );
699    }
700
701    #[test]
702    fn test_pa_determinism() {
703        let (q, var_map) = build_k3_maxcut_qubo();
704
705        let s1 = PopulationAnnealingSampler::new()
706            .with_seed(42)
707            .with_population(20);
708        let s2 = PopulationAnnealingSampler::new()
709            .with_seed(42)
710            .with_population(20);
711
712        let r1 = s1
713            .run_qubo(&(q.clone(), var_map.clone()), 10)
714            .expect("Run 1 failed");
715        let r2 = s2.run_qubo(&(q, var_map), 10).expect("Run 2 failed");
716
717        assert_eq!(r1.len(), r2.len(), "Result lengths differ");
718        for (a, b) in r1.iter().zip(r2.iter()) {
719            assert!(
720                (a.energy - b.energy).abs() < 1e-12,
721                "Energies differ: {} vs {}",
722                a.energy,
723                b.energy
724            );
725            assert_eq!(
726                a.assignments, b.assignments,
727                "Assignments differ for same seed"
728            );
729        }
730    }
731
732    #[test]
733    fn test_pa_hobo_smoke() {
734        // Simple 2D HOBO smoke test
735        let mut q = Array2::<f64>::zeros((2, 2));
736        q[[0, 0]] = -1.0;
737        q[[1, 1]] = -1.0;
738
739        let mut var_map = HashMap::new();
740        var_map.insert("a".to_string(), 0);
741        var_map.insert("b".to_string(), 1);
742
743        let sampler = PopulationAnnealingSampler::new()
744            .with_seed(7)
745            .with_population(20);
746        let q_dyn = q.into_dyn();
747        let results = sampler
748            .run_hobo(&(q_dyn, var_map), 10)
749            .expect("HOBO PA run failed");
750
751        assert!(!results.is_empty());
752        assert!(results[0].energy <= -2.0 + 1e-9);
753    }
754
755    #[test]
756    fn test_pa_results_sorted_ascending() {
757        let (q, var_map) = build_k3_maxcut_qubo();
758        let sampler = PopulationAnnealingSampler::new()
759            .with_seed(5)
760            .with_population(30);
761
762        let results = sampler.run_qubo(&(q, var_map), 30).expect("PA run failed");
763
764        // Verify ascending energy order
765        for window in results.windows(2) {
766            assert!(
767                window[0].energy <= window[1].energy + 1e-12,
768                "Results not sorted: {} > {}",
769                window[0].energy,
770                window[1].energy
771            );
772        }
773    }
774
775    #[test]
776    fn test_pa_custom_schedule() {
777        let (q, var_map) = build_k3_maxcut_qubo();
778        // Coarser beta schedule
779        let betas: Vec<f64> = (0..10).map(|i| 0.2 + 2.8 * i as f64 / 9.0).collect();
780        let sampler = PopulationAnnealingSampler::new()
781            .with_seed(42)
782            .with_population(30)
783            .with_beta_schedule(betas);
784
785        let results = sampler
786            .run_qubo(&(q, var_map), 20)
787            .expect("PA custom schedule failed");
788
789        assert!(!results.is_empty());
790        // Should still find a reasonable solution
791        assert!(results[0].energy <= 0.0 + 1e-9);
792    }
793}