Skip to main content

forge_core/algo/
pt.rs

1//! Parallel tempering (replica exchange) for combinatorial problems.
2//!
3//! Instead of independent restarts, `K` replicas run on a geometric temperature
4//! ladder from cold (exploits) to hot (explores). Each replica advances by
5//! Metropolis moves at its temperature; periodically, adjacent replicas attempt
6//! to **exchange** configurations with probability `min(1, exp((β_i − β_j)(E_i −
7//! E_j)))`. Good configurations found at high temperature flow down to the cold
8//! replica, which escapes local optima better than annealing with restarts. The
9//! whole ladder is itself slowly cooled over the run (*annealed* replica
10//! exchange), so the cold replica freezes and refines while the hot ones keep
11//! feeding it.
12//!
13//! Built on the same [`Anneal`] trait as [`Sa`](super::Sa). Determinism under
14//! parallelism: each replica carries its own RNG seeded independently of
15//! execution order, and the exchange phase is sequential with a separate RNG —
16//! so the result is identical whether replicas advance on one thread or many,
17//! and whether or not the `rayon` feature is enabled.
18
19use super::sa::{adaptive_schedule, Anneal, Schedule};
20use super::SaResult;
21use crate::rng::{mix_seed, Rng};
22
23/// Parallel tempering configuration.
24#[derive(Debug, Clone, Copy)]
25pub struct ParallelTempering {
26    /// Number of replicas (clamped to ≥ 2).
27    pub replicas: usize,
28    /// Total Metropolis moves per replica (split into sweeps of
29    /// [`Anneal::sweep_len`]).
30    pub iterations: usize,
31    /// Temperature schedule; `t_start` is the hot end, `t_end` the cold end.
32    pub schedule: Schedule,
33    /// RNG seed; same seed + same problem ⇒ same result.
34    pub seed: u64,
35}
36
37impl Default for ParallelTempering {
38    fn default() -> Self {
39        ParallelTempering {
40            replicas: 8,
41            iterations: 100_000,
42            schedule: Schedule::adaptive(),
43            seed: 0,
44        }
45    }
46}
47
48/// Outcome of a parallel-tempering run.
49#[derive(Debug, Clone)]
50pub struct TemperingResult<S> {
51    /// Best result across all replicas (lowest energy; ties to the colder).
52    pub best: SaResult<S>,
53    /// Each replica's own best, ordered cold → hot. Useful for a per-replica
54    /// post-processing quench that exploits the diversity of basins explored.
55    pub replicas: Vec<SaResult<S>>,
56}
57
58/// One replica: state at a (time-varying) temperature, with its own RNG.
59struct Replica<S> {
60    state: S,
61    energy: f64,
62    temp: f64,
63    rng: Rng,
64    best_state: S,
65    best_energy: f64,
66}
67
68impl ParallelTempering {
69    /// Runs parallel tempering on `problem`, returning every replica's best plus
70    /// the global best.
71    pub fn optimize<A>(&self, problem: &A) -> TemperingResult<A::State>
72    where
73        A: Anneal + Sync,
74        A::State: Send,
75    {
76        let k = self.replicas.max(2);
77        // Separate RNG for adaptive sampling and exchanges (deterministic order).
78        let mut swap_rng = Rng::new(self.seed);
79        let (t_hot, t_cold) = match self.schedule {
80            Schedule::Geometric { t_start, t_end } => (t_start, t_end),
81            Schedule::Adaptive { fallback } => adaptive_schedule(problem, fallback, &mut swap_rng),
82        };
83
84        // Geometric ladder: temps[0] = cold … temps[k-1] = hot.
85        let temps: Vec<f64> = (0..k)
86            .map(|i| {
87                let frac = i as f64 / (k - 1) as f64;
88                t_cold * (t_hot / t_cold).powf(frac)
89            })
90            .collect();
91
92        let mut replicas: Vec<Replica<A::State>> = (0..k)
93            .map(|i| {
94                // Per-replica seed, well separated and independent of scheduling.
95                let mut rng = Rng::new(mix_seed(self.seed, i as u64 + 1));
96                let state = problem.initial(&mut rng);
97                let energy = problem.energy(&state);
98                Replica {
99                    best_state: state.clone(),
100                    best_energy: energy,
101                    state,
102                    energy,
103                    temp: temps[i],
104                    rng,
105                }
106            })
107            .collect();
108
109        let sweep_len = problem.sweep_len();
110        if sweep_len > 0 && self.iterations > 0 {
111            let n_sweeps = (self.iterations / sweep_len).max(1);
112            // Annealed replica exchange: cool the whole ladder by `FLOOR` over
113            // the run so the cold replica ends frozen (fine refinement).
114            const FLOOR: f64 = 1e-2;
115            let cool = FLOOR.powf(1.0 / n_sweeps as f64);
116            let mut scale = 1.0;
117            for _ in 0..n_sweeps {
118                for (r, &base) in replicas.iter_mut().zip(&temps) {
119                    r.temp = base * scale;
120                }
121                advance_all(problem, &mut replicas, sweep_len);
122                attempt_swaps(&mut replicas, &mut swap_rng);
123                scale *= cool;
124            }
125        }
126
127        let replica_results: Vec<SaResult<A::State>> = replicas
128            .into_iter()
129            .map(|r| SaResult {
130                best: r.best_state,
131                energy: r.best_energy,
132            })
133            .collect();
134
135        // Global best: lowest energy, ties broken toward the colder replica
136        // (lower index) so the choice is order-independent.
137        let best_idx = (0..replica_results.len())
138            .min_by(|&a, &b| {
139                replica_results[a]
140                    .energy
141                    .partial_cmp(&replica_results[b].energy)
142                    .unwrap_or(std::cmp::Ordering::Equal)
143                    .then(a.cmp(&b))
144            })
145            .expect("at least two replicas");
146        let best = replica_results[best_idx].clone();
147
148        TemperingResult {
149            best,
150            replicas: replica_results,
151        }
152    }
153}
154
155/// Advances every replica `sweep_len` moves. Replicas are disjoint and each
156/// uses only its own RNG, so the outcome is independent of execution order —
157/// parallel (with `rayon`) or sequential give identical results.
158#[cfg(feature = "rayon")]
159fn advance_all<A>(problem: &A, replicas: &mut [Replica<A::State>], sweep_len: usize)
160where
161    A: Anneal + Sync,
162    A::State: Send,
163{
164    use rayon::prelude::*;
165    replicas
166        .par_iter_mut()
167        .for_each(|r| advance(problem, r, sweep_len));
168}
169
170#[cfg(not(feature = "rayon"))]
171fn advance_all<A: Anneal>(problem: &A, replicas: &mut [Replica<A::State>], sweep_len: usize) {
172    for r in replicas.iter_mut() {
173        advance(problem, r, sweep_len);
174    }
175}
176
177/// Metropolis moves at the replica's fixed temperature.
178fn advance<A: Anneal>(problem: &A, r: &mut Replica<A::State>, n_moves: usize) {
179    for _ in 0..n_moves {
180        let mv = problem.propose(&r.state, &mut r.rng);
181        let delta = problem.delta(&r.state, &mv);
182        if delta < 0.0 || r.rng.uniform() < (-delta / r.temp).exp() {
183            problem.apply(&mut r.state, mv);
184            r.energy += delta;
185            if r.energy < r.best_energy {
186                r.best_energy = r.energy;
187                r.best_state = r.state.clone();
188            }
189        }
190    }
191}
192
193/// Sequential exchange sweep over adjacent replica pairs (cold→hot), accepting
194/// each swap with probability `min(1, exp((β_cold − β_hot)(E_cold − E_hot)))`.
195fn attempt_swaps<S>(replicas: &mut [Replica<S>], swap_rng: &mut Rng) {
196    for c in 0..replicas.len() - 1 {
197        let beta_cold = 1.0 / replicas[c].temp;
198        let beta_hot = 1.0 / replicas[c + 1].temp;
199        let arg = (beta_cold - beta_hot) * (replicas[c].energy - replicas[c + 1].energy);
200        if arg >= 0.0 || swap_rng.uniform() < arg.exp() {
201            let (left, right) = replicas.split_at_mut(c + 1);
202            std::mem::swap(&mut left[c].state, &mut right[0].state);
203            std::mem::swap(&mut left[c].energy, &mut right[0].energy);
204        }
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    /// Number partitioning (same as the SA tests): minimize the squared
213    /// difference of the two group sums.
214    struct Partition {
215        weights: Vec<f64>,
216    }
217    impl Partition {
218        fn diff(&self, x: &[bool]) -> f64 {
219            self.weights
220                .iter()
221                .zip(x)
222                .map(|(w, &b)| if b { *w } else { -*w })
223                .sum()
224        }
225    }
226    impl Anneal for Partition {
227        type State = Vec<bool>;
228        type Move = usize;
229        fn initial(&self, _r: &mut Rng) -> Vec<bool> {
230            vec![false; self.weights.len()]
231        }
232        fn energy(&self, x: &Vec<bool>) -> f64 {
233            self.diff(x).powi(2)
234        }
235        fn propose(&self, x: &Vec<bool>, r: &mut Rng) -> usize {
236            r.index(x.len())
237        }
238        fn delta(&self, x: &Vec<bool>, &i: &usize) -> f64 {
239            let d = self.diff(x);
240            let step = if x[i] { -2.0 } else { 2.0 } * self.weights[i];
241            (d + step).powi(2) - d.powi(2)
242        }
243        fn apply(&self, x: &mut Vec<bool>, i: usize) {
244            x[i] = !x[i];
245        }
246        fn sweep_len(&self) -> usize {
247            self.weights.len()
248        }
249    }
250
251    #[test]
252    fn finds_perfect_partition() {
253        let p = Partition {
254            weights: vec![8.0, 7.0, 6.0, 5.0, 4.0, 9.0, 3.0, 2.0],
255        };
256        let pt = ParallelTempering {
257            replicas: 6,
258            iterations: 8000,
259            schedule: Schedule::adaptive(),
260            seed: 1,
261        };
262        let res = pt.optimize(&p);
263        assert!(res.best.energy < 1e-9, "energy {}", res.best.energy);
264        assert_eq!(res.replicas.len(), 6);
265    }
266
267    #[test]
268    fn is_deterministic() {
269        let p = Partition {
270            weights: vec![5.0, 3.0, 9.0, 7.0, 1.0, 8.0, 4.0],
271        };
272        let pt = ParallelTempering {
273            replicas: 5,
274            iterations: 5000,
275            schedule: Schedule::Geometric {
276                t_start: 50.0,
277                t_end: 0.01,
278            },
279            seed: 7,
280        };
281        let a = pt.optimize(&p);
282        let b = pt.optimize(&p);
283        assert_eq!(a.best.best, b.best.best);
284        assert_eq!(a.best.energy, b.best.energy);
285    }
286}