1use 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#[derive(Debug, Clone)]
98pub struct PAParams {
99 pub population: usize,
101 pub beta_schedule: Vec<f64>,
103 pub sweeps_per_step: usize,
105 pub resample_threshold: f64,
107}
108
109impl Default for PAParams {
110 fn default() -> Self {
111 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#[derive(Debug, Clone)]
162pub struct PopulationAnnealingSampler {
163 pub seed: Option<u64>,
165 pub params: PAParams,
167}
168
169impl PopulationAnnealingSampler {
170 #[must_use]
172 pub fn new() -> Self {
173 Self {
174 seed: None,
175 params: PAParams::default(),
176 }
177 }
178
179 #[must_use]
181 pub fn with_seed(mut self, seed: u64) -> Self {
182 self.seed = Some(seed);
183 self
184 }
185
186 #[must_use]
188 pub fn with_population(mut self, population: usize) -> Self {
189 self.params.population = population;
190 self
191 }
192
193 #[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 #[must_use]
202 pub fn with_resample_threshold(mut self, threshold: f64) -> Self {
203 self.params.resample_threshold = threshold;
204 self
205 }
206
207 #[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 #[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 #[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 #[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 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 let dyn_tensor: ArrayD<f64> = tensor.to_owned().into_dyn();
247 Self::evaluate_hobo_energy_dyn(&dyn_tensor, state, n_vars)
248 }
249
250 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 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 return (0..n)
268 .map(|_| {
269 let idx = rng.random_range(0..population.len());
270 population[idx].clone()
271 })
272 .collect();
273 }
274
275 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 if let Some(last) = cumulative.last_mut() {
284 *last = 1.0;
285 }
286
287 (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 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 let mut population: Vec<Vec<bool>> = (0..pop_size)
321 .map(|_| (0..n).map(|_| rng.random_bool(0.5)).collect())
322 .collect();
323
324 let mut energies: Vec<f64> = population
326 .iter()
327 .map(|s| Self::compute_qubo_energy_flat(q_flat, s, n))
328 .collect();
329
330 let mut beta_prev = 0.0;
332
333 for &beta_new in beta_schedule {
334 let delta_beta = beta_new - beta_prev;
335
336 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 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 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 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 if (ess / pop_size as f64) < resample_threshold {
389 let new_population =
390 Self::multinomial_resample(&population, &weights, pop_size, &mut rng);
391 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 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 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 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 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]; 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 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 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 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 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 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 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 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 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 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 assert!(results[0].energy <= 0.0 + 1e-9);
792 }
793}