Skip to main content

quantrs2_anneal/
population_annealing.rs

1//! Population annealing with MPI support
2//!
3//! This module implements population annealing, a parallel sampling algorithm
4//! that maintains a population of configurations and periodically resamples
5//! based on Boltzmann weights.
6
7use scirs2_core::random::prelude::*;
8use scirs2_core::random::ChaCha8Rng;
9use scirs2_core::random::{Rng, SeedableRng};
10use std::collections::HashMap;
11use std::time::{Duration, Instant};
12use thiserror::Error;
13
14use crate::ising::{IsingError, IsingModel};
15use crate::simulator::{
16    AnnealingParams, AnnealingSolution, TemperatureSchedule, TransverseFieldSchedule,
17};
18
19/// Errors that can occur during population annealing
20#[derive(Error, Debug)]
21pub enum PopulationAnnealingError {
22    /// Ising model error
23    #[error("Ising error: {0}")]
24    IsingError(#[from] IsingError),
25
26    /// Invalid parameter
27    #[error("Invalid parameter: {0}")]
28    InvalidParameter(String),
29
30    /// MPI communication error
31    #[error("MPI error: {0}")]
32    MpiError(String),
33
34    /// Population evolution error
35    #[error("Population evolution error: {0}")]
36    EvolutionError(String),
37}
38
39/// Result type for population annealing operations
40pub type PopulationAnnealingResult<T> = Result<T, PopulationAnnealingError>;
41
42/// Configuration for population annealing
43#[derive(Debug, Clone)]
44pub struct PopulationAnnealingConfig {
45    /// Population size
46    pub population_size: usize,
47
48    /// Temperature schedule
49    pub temperature_schedule: TemperatureSchedule,
50
51    /// Initial temperature
52    pub initial_temperature: f64,
53
54    /// Final temperature
55    pub final_temperature: f64,
56
57    /// Number of temperature steps
58    pub num_temperature_steps: usize,
59
60    /// Number of Monte Carlo sweeps per temperature step
61    pub sweeps_per_step: usize,
62
63    /// Resampling frequency (in temperature steps)
64    pub resampling_frequency: usize,
65
66    /// Effective sample size threshold for resampling
67    pub ess_threshold: f64,
68
69    /// Random seed
70    pub seed: Option<u64>,
71
72    /// Maximum runtime
73    pub timeout: Option<Duration>,
74
75    /// MPI configuration
76    pub mpi_config: Option<MpiConfig>,
77}
78
79impl Default for PopulationAnnealingConfig {
80    fn default() -> Self {
81        Self {
82            population_size: 1000,
83            temperature_schedule: TemperatureSchedule::Exponential(3.0),
84            initial_temperature: 10.0,
85            final_temperature: 0.01,
86            num_temperature_steps: 100,
87            sweeps_per_step: 100,
88            resampling_frequency: 5,
89            ess_threshold: 0.5,
90            seed: None,
91            timeout: Some(Duration::from_secs(3600)), // 1 hour
92            mpi_config: None,
93        }
94    }
95}
96
97/// MPI configuration for distributed population annealing
98#[derive(Debug, Clone)]
99pub struct MpiConfig {
100    /// Number of MPI processes
101    pub num_processes: usize,
102
103    /// Current process rank
104    pub rank: usize,
105
106    /// Enable load balancing
107    pub load_balancing: bool,
108
109    /// Communication frequency (in temperature steps)
110    pub communication_frequency: usize,
111}
112
113/// Individual configuration in the population
114#[derive(Debug, Clone)]
115pub struct PopulationMember {
116    /// Spin configuration
117    pub configuration: Vec<i8>,
118
119    /// Energy of the configuration
120    pub energy: f64,
121
122    /// Weight (for resampling)
123    pub weight: f64,
124
125    /// Ancestor lineage (for tracking)
126    pub lineage: Vec<usize>,
127}
128
129impl PopulationMember {
130    /// Create a new population member
131    #[must_use]
132    pub const fn new(configuration: Vec<i8>, energy: f64) -> Self {
133        Self {
134            configuration,
135            energy,
136            weight: 1.0,
137            lineage: vec![],
138        }
139    }
140}
141
142/// Population annealing results
143#[derive(Debug, Clone)]
144pub struct PopulationAnnealingSolution {
145    /// Best configuration found
146    pub best_configuration: Vec<i8>,
147
148    /// Best energy found
149    pub best_energy: f64,
150
151    /// Final population
152    pub final_population: Vec<PopulationMember>,
153
154    /// Energy statistics over time
155    pub energy_history: Vec<EnergyStatistics>,
156
157    /// Effective sample size history
158    pub ess_history: Vec<f64>,
159
160    /// Total runtime
161    pub runtime: Duration,
162
163    /// Number of resampling events
164    pub num_resamplings: usize,
165
166    /// Additional info
167    pub info: String,
168}
169
170/// Energy statistics for the population
171#[derive(Debug, Clone)]
172pub struct EnergyStatistics {
173    /// Temperature at this step
174    pub temperature: f64,
175
176    /// Minimum energy in population
177    pub min_energy: f64,
178
179    /// Maximum energy in population
180    pub max_energy: f64,
181
182    /// Mean energy
183    pub mean_energy: f64,
184
185    /// Energy standard deviation
186    pub energy_std: f64,
187
188    /// Effective sample size
189    pub effective_sample_size: f64,
190}
191
192/// Population annealing simulator
193pub struct PopulationAnnealingSimulator {
194    /// Configuration
195    config: PopulationAnnealingConfig,
196
197    /// Random number generator
198    rng: ChaCha8Rng,
199
200    /// MPI interface (optional)
201    mpi_interface: Option<MpiInterface>,
202}
203
204impl PopulationAnnealingSimulator {
205    /// Create a new population annealing simulator
206    pub fn new(config: PopulationAnnealingConfig) -> PopulationAnnealingResult<Self> {
207        // Validate configuration
208        Self::validate_config(&config)?;
209
210        // Initialize RNG
211        let rng = match config.seed {
212            Some(seed) => ChaCha8Rng::seed_from_u64(seed),
213            None => ChaCha8Rng::seed_from_u64(thread_rng().random()),
214        };
215
216        // Initialize MPI interface if configured
217        let mpi_interface = config
218            .mpi_config
219            .as_ref()
220            .map(|mpi_config| MpiInterface::new(mpi_config.clone()))
221            .transpose()?;
222
223        Ok(Self {
224            config,
225            rng,
226            mpi_interface,
227        })
228    }
229
230    /// Validate configuration parameters
231    fn validate_config(config: &PopulationAnnealingConfig) -> PopulationAnnealingResult<()> {
232        if config.population_size == 0 {
233            return Err(PopulationAnnealingError::InvalidParameter(
234                "Population size must be positive".to_string(),
235            ));
236        }
237
238        if config.initial_temperature <= 0.0 || config.final_temperature <= 0.0 {
239            return Err(PopulationAnnealingError::InvalidParameter(
240                "Temperatures must be positive".to_string(),
241            ));
242        }
243
244        if config.initial_temperature <= config.final_temperature {
245            return Err(PopulationAnnealingError::InvalidParameter(
246                "Initial temperature must be greater than final temperature".to_string(),
247            ));
248        }
249
250        if config.num_temperature_steps == 0 {
251            return Err(PopulationAnnealingError::InvalidParameter(
252                "Number of temperature steps must be positive".to_string(),
253            ));
254        }
255
256        if config.ess_threshold <= 0.0 || config.ess_threshold > 1.0 {
257            return Err(PopulationAnnealingError::InvalidParameter(
258                "ESS threshold must be in (0, 1]".to_string(),
259            ));
260        }
261
262        Ok(())
263    }
264
265    /// Solve an Ising model using population annealing
266    pub fn solve(
267        &mut self,
268        model: &IsingModel,
269    ) -> PopulationAnnealingResult<PopulationAnnealingSolution> {
270        let start_time = Instant::now();
271
272        // Initialize population
273        let mut population = self.initialize_population(model)?;
274
275        // Track statistics
276        let mut energy_history = Vec::new();
277        let mut ess_history = Vec::new();
278        let mut best_energy = f64::INFINITY;
279        let mut best_configuration = vec![];
280        let mut num_resamplings = 0;
281
282        // Temperature schedule
283        let temperatures = self.generate_temperature_schedule();
284
285        // Main annealing loop
286        for (step, &temperature) in temperatures.iter().enumerate() {
287            // Check timeout
288            if let Some(timeout) = self.config.timeout {
289                if start_time.elapsed() > timeout {
290                    break;
291                }
292            }
293
294            // Perform Monte Carlo sweeps
295            self.monte_carlo_step(model, &mut population, temperature)?;
296
297            // Calculate weights and statistics
298            let stats = self.calculate_statistics(&population, temperature);
299            energy_history.push(stats.clone());
300            ess_history.push(stats.effective_sample_size);
301
302            // Update best solution
303            for member in &population {
304                if member.energy < best_energy {
305                    best_energy = member.energy;
306                    best_configuration = member.configuration.clone();
307                }
308            }
309
310            // Resampling decision
311            let should_resample = (step + 1) % self.config.resampling_frequency == 0
312                || stats.effective_sample_size
313                    < self.config.ess_threshold * self.config.population_size as f64;
314
315            if should_resample {
316                self.resample_population(&mut population, temperature)?;
317                num_resamplings += 1;
318
319                // MPI communication if enabled
320                if let Some(mpi) = &mut self.mpi_interface {
321                    mpi.exchange_populations(&mut population)?;
322                }
323            }
324        }
325
326        let runtime = start_time.elapsed();
327
328        Ok(PopulationAnnealingSolution {
329            best_configuration,
330            best_energy,
331            final_population: population,
332            energy_history,
333            ess_history,
334            runtime,
335            num_resamplings,
336            info: format!(
337                "Population annealing completed: {} temperature steps, {} resamplings, runtime: {:?}",
338                temperatures.len(), num_resamplings, runtime
339            ),
340        })
341    }
342
343    /// Initialize the population with random configurations
344    fn initialize_population(
345        &mut self,
346        model: &IsingModel,
347    ) -> PopulationAnnealingResult<Vec<PopulationMember>> {
348        let mut population = Vec::with_capacity(self.config.population_size);
349
350        for _ in 0..self.config.population_size {
351            // Generate random configuration
352            let configuration: Vec<i8> = (0..model.num_qubits)
353                .map(|_| if self.rng.random_bool(0.5) { 1 } else { -1 })
354                .collect();
355
356            // Calculate energy
357            let energy = model.energy(&configuration)?;
358
359            population.push(PopulationMember::new(configuration, energy));
360        }
361
362        Ok(population)
363    }
364
365    /// Generate temperature schedule
366    fn generate_temperature_schedule(&self) -> Vec<f64> {
367        let mut temperatures = Vec::with_capacity(self.config.num_temperature_steps);
368
369        for i in 0..self.config.num_temperature_steps {
370            let t = i as f64 / (self.config.num_temperature_steps - 1) as f64;
371            let temperature =
372                self.config
373                    .temperature_schedule
374                    .calculate(t, 1.0, self.config.initial_temperature);
375
376            // Ensure we don't go below final temperature
377            let clamped_temp = temperature.max(self.config.final_temperature);
378            temperatures.push(clamped_temp);
379        }
380
381        temperatures
382    }
383
384    /// Perform Monte Carlo step for the entire population
385    fn monte_carlo_step(
386        &mut self,
387        model: &IsingModel,
388        population: &mut [PopulationMember],
389        temperature: f64,
390    ) -> PopulationAnnealingResult<()> {
391        for member in population.iter_mut() {
392            for _ in 0..self.config.sweeps_per_step {
393                // Choose random spin to flip
394                let spin_idx = self.rng.random_range(0..model.num_qubits);
395
396                // Calculate energy change
397                let old_spin = member.configuration[spin_idx];
398                member.configuration[spin_idx] = -old_spin;
399
400                let new_energy = model.energy(&member.configuration)?;
401                let delta_e = new_energy - member.energy;
402
403                // Metropolis acceptance
404                let accept = delta_e <= 0.0 || {
405                    let prob = (-delta_e / temperature).exp();
406                    self.rng.random::<f64>() < prob
407                };
408
409                if accept {
410                    member.energy = new_energy;
411                } else {
412                    // Revert the change
413                    member.configuration[spin_idx] = old_spin;
414                }
415            }
416        }
417
418        Ok(())
419    }
420
421    /// Calculate population statistics
422    fn calculate_statistics(
423        &self,
424        population: &[PopulationMember],
425        temperature: f64,
426    ) -> EnergyStatistics {
427        let energies: Vec<f64> = population.iter().map(|m| m.energy).collect();
428
429        let min_energy = energies.iter().copied().fold(f64::INFINITY, f64::min);
430        let max_energy = energies.iter().copied().fold(f64::NEG_INFINITY, f64::max);
431        let mean_energy = energies.iter().sum::<f64>() / energies.len() as f64;
432
433        let variance = energies
434            .iter()
435            .map(|&e| (e - mean_energy).powi(2))
436            .sum::<f64>()
437            / energies.len() as f64;
438        let energy_std = variance.sqrt();
439
440        // Calculate effective sample size
441        let min_e = min_energy;
442        let weights: Vec<f64> = energies
443            .iter()
444            .map(|&e| (-(e - min_e) / temperature).exp())
445            .collect();
446
447        let sum_weights = weights.iter().sum::<f64>();
448        let sum_weights_squared = weights.iter().map(|&w| w * w).sum::<f64>();
449
450        let effective_sample_size = if sum_weights_squared > 0.0 {
451            sum_weights * sum_weights / sum_weights_squared
452        } else {
453            population.len() as f64
454        };
455
456        EnergyStatistics {
457            temperature,
458            min_energy,
459            max_energy,
460            mean_energy,
461            energy_std,
462            effective_sample_size,
463        }
464    }
465
466    /// Resample population based on Boltzmann weights
467    fn resample_population(
468        &mut self,
469        population: &mut Vec<PopulationMember>,
470        temperature: f64,
471    ) -> PopulationAnnealingResult<()> {
472        if population.is_empty() {
473            return Ok(());
474        }
475
476        // Calculate Boltzmann weights
477        let min_energy = population
478            .iter()
479            .map(|m| m.energy)
480            .fold(f64::INFINITY, f64::min);
481        let weights: Vec<f64> = population
482            .iter()
483            .map(|m| (-(m.energy - min_energy) / temperature).exp())
484            .collect();
485
486        let total_weight: f64 = weights.iter().sum();
487        if total_weight <= 0.0 {
488            return Err(PopulationAnnealingError::EvolutionError(
489                "Total weight is zero or negative".to_string(),
490            ));
491        }
492
493        // Normalize weights
494        let normalized_weights: Vec<f64> = weights.iter().map(|w| w / total_weight).collect();
495
496        // Cumulative distribution
497        let mut cumulative_weights = vec![0.0; normalized_weights.len()];
498        cumulative_weights[0] = normalized_weights[0];
499        for i in 1..normalized_weights.len() {
500            cumulative_weights[i] = cumulative_weights[i - 1] + normalized_weights[i];
501        }
502
503        // Resample
504        let mut new_population = Vec::with_capacity(population.len());
505        for _ in 0..population.len() {
506            let r = self.rng.random::<f64>();
507            let idx = cumulative_weights
508                .iter()
509                .position(|&w| r <= w)
510                .unwrap_or(population.len() - 1);
511
512            let mut new_member = population[idx].clone();
513            new_member.lineage.push(idx);
514            new_population.push(new_member);
515        }
516
517        *population = new_population;
518        Ok(())
519    }
520}
521
522/// MPI interface for distributed population annealing
523struct MpiInterface {
524    config: MpiConfig,
525}
526
527impl MpiInterface {
528    /// Attempt to initialize a distributed-population-annealing MPI interface.
529    ///
530    /// This crate has no real MPI backend: there is no `mpi`-crate dependency
531    /// (or equivalent) wired up, and standing up genuine multi-process
532    /// communication requires an actual MPI runtime/cluster, which is outside
533    /// what a single-process library can honestly provide. Silently
534    /// accepting `mpi_config` and running ordinary single-process annealing
535    /// would misrepresent the requested distributed run as having occurred,
536    /// so this always returns an honest error instead of fabricating success.
537    fn new(config: MpiConfig) -> PopulationAnnealingResult<Self> {
538        Err(PopulationAnnealingError::MpiError(format!(
539            "Distributed population annealing (num_processes={}, rank={}) was requested via \
540             `mpi_config`, but this build has no real MPI backend wired up (no `mpi`-crate \
541             dependency or equivalent). Real MPI support has not been implemented; refusing to \
542             silently fall back to single-process annealing under a distributed configuration. \
543             Construct `PopulationAnnealingSimulator` with `mpi_config: None` to run \
544             single-process.",
545            config.num_processes, config.rank
546        )))
547    }
548
549    /// Would exchange population members across MPI ranks in a real
550    /// distributed backend. Unreachable in practice: [`Self::new`] always
551    /// errors, so no [`MpiInterface`] value can ever be constructed to call
552    /// this on. Kept honest (rather than a silent no-op) in case that
553    /// invariant ever changes.
554    fn exchange_populations(
555        &self,
556        _population: &mut Vec<PopulationMember>,
557    ) -> PopulationAnnealingResult<()> {
558        Err(PopulationAnnealingError::MpiError(format!(
559            "Real MPI population exchange is not implemented (rank={})",
560            self.config.rank
561        )))
562    }
563}
564
565/// Convert population annealing result to standard annealing solution
566impl From<PopulationAnnealingSolution> for AnnealingSolution {
567    fn from(result: PopulationAnnealingSolution) -> Self {
568        Self {
569            best_spins: result.best_configuration,
570            best_energy: result.best_energy,
571            repetitions: 1,
572            total_sweeps: 0, // Would need to track this
573            runtime: result.runtime,
574            info: result.info,
575        }
576    }
577}
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582
583    #[test]
584    fn test_population_annealing_config() {
585        let config = PopulationAnnealingConfig::default();
586        assert!(PopulationAnnealingSimulator::validate_config(&config).is_ok());
587
588        // Test invalid configuration
589        let mut invalid_config = config.clone();
590        invalid_config.population_size = 0;
591        assert!(PopulationAnnealingSimulator::validate_config(&invalid_config).is_err());
592    }
593
594    #[test]
595    fn test_temperature_schedule() {
596        let config = PopulationAnnealingConfig {
597            initial_temperature: 10.0,
598            final_temperature: 0.1,
599            num_temperature_steps: 5,
600            ..Default::default()
601        };
602
603        let mut simulator =
604            PopulationAnnealingSimulator::new(config).expect("failed to create simulator in test");
605        let temperatures = simulator.generate_temperature_schedule();
606
607        assert_eq!(temperatures.len(), 5);
608        assert!(temperatures[0] >= temperatures[4]);
609        assert!(temperatures[4] >= 0.1);
610    }
611
612    #[test]
613    fn test_population_initialization() {
614        let mut model = IsingModel::new(4);
615        model
616            .set_coupling(0, 1, -1.0)
617            .expect("failed to set coupling in test");
618
619        let config = PopulationAnnealingConfig {
620            population_size: 10,
621            seed: Some(42),
622            ..Default::default()
623        };
624
625        let mut simulator =
626            PopulationAnnealingSimulator::new(config).expect("failed to create simulator in test");
627        let population = simulator
628            .initialize_population(&model)
629            .expect("failed to initialize population in test");
630
631        assert_eq!(population.len(), 10);
632        for member in &population {
633            assert_eq!(member.configuration.len(), 4);
634            assert!(member.configuration.iter().all(|&s| s == 1 || s == -1));
635        }
636    }
637
638    #[test]
639    fn test_simple_population_annealing() {
640        let mut model = IsingModel::new(3);
641        model
642            .set_coupling(0, 1, -1.0)
643            .expect("failed to set coupling in test");
644        model
645            .set_coupling(1, 2, -1.0)
646            .expect("failed to set coupling in test");
647
648        let config = PopulationAnnealingConfig {
649            population_size: 50,
650            num_temperature_steps: 10,
651            sweeps_per_step: 10,
652            seed: Some(42),
653            ..Default::default()
654        };
655
656        let mut simulator =
657            PopulationAnnealingSimulator::new(config).expect("failed to create simulator in test");
658        let result = simulator
659            .solve(&model)
660            .expect("failed to solve model in test");
661
662        assert!(result.best_energy <= 0.0); // Should find good solution
663        assert_eq!(result.final_population.len(), 50);
664        assert_eq!(result.energy_history.len(), 10);
665    }
666
667    #[test]
668    fn mpi_config_is_honestly_rejected_instead_of_silently_running_single_process() {
669        // Requesting distributed annealing via `mpi_config` must fail loudly
670        // (there is no real MPI backend in this crate) rather than silently
671        // constructing a working single-process simulator.
672        let config = PopulationAnnealingConfig {
673            mpi_config: Some(MpiConfig {
674                num_processes: 4,
675                rank: 0,
676                load_balancing: true,
677                communication_frequency: 5,
678            }),
679            ..Default::default()
680        };
681
682        let result = PopulationAnnealingSimulator::new(config);
683        assert!(
684            result.is_err(),
685            "constructing a simulator with mpi_config set must be a real, honest error"
686        );
687        match result {
688            Err(PopulationAnnealingError::MpiError(message)) => {
689                assert!(message.contains("MPI"));
690            }
691            Err(other) => panic!("expected PopulationAnnealingError::MpiError, got {other:?}"),
692            Ok(_) => panic!("expected an error, but simulator construction succeeded"),
693        }
694    }
695
696    #[test]
697    fn no_mpi_config_still_constructs_a_working_single_process_simulator() {
698        // Sanity check that the honest MPI rejection didn't collaterally
699        // break the default (non-distributed) construction path.
700        let config = PopulationAnnealingConfig {
701            mpi_config: None,
702            ..Default::default()
703        };
704        assert!(PopulationAnnealingSimulator::new(config).is_ok());
705    }
706}