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        // Report exact energies of the best states, not the per-replica
128        // accumulators (which drift by f64 rounding and migrate across swaps).
129        let replica_results: Vec<SaResult<A::State>> = replicas
130            .into_iter()
131            .map(|r| {
132                let energy = problem.energy(&r.best_state);
133                SaResult {
134                    best: r.best_state,
135                    energy,
136                }
137            })
138            .collect();
139
140        // Global best: lowest energy, ties broken toward the colder replica
141        // (lower index) so the choice is order-independent.
142        let best_idx = (0..replica_results.len())
143            .min_by(|&a, &b| {
144                replica_results[a]
145                    .energy
146                    .partial_cmp(&replica_results[b].energy)
147                    .unwrap_or(std::cmp::Ordering::Equal)
148                    .then(a.cmp(&b))
149            })
150            .expect("at least two replicas");
151        let best = replica_results[best_idx].clone();
152
153        TemperingResult {
154            best,
155            replicas: replica_results,
156        }
157    }
158}
159
160/// Advances every replica `sweep_len` moves. Replicas are disjoint and each
161/// uses only its own RNG, so the outcome is independent of execution order —
162/// parallel (with `rayon`) or sequential give identical results.
163#[cfg(feature = "rayon")]
164fn advance_all<A>(problem: &A, replicas: &mut [Replica<A::State>], sweep_len: usize)
165where
166    A: Anneal + Sync,
167    A::State: Send,
168{
169    use rayon::prelude::*;
170    replicas
171        .par_iter_mut()
172        .for_each(|r| advance(problem, r, sweep_len));
173}
174
175#[cfg(not(feature = "rayon"))]
176fn advance_all<A: Anneal>(problem: &A, replicas: &mut [Replica<A::State>], sweep_len: usize) {
177    for r in replicas.iter_mut() {
178        advance(problem, r, sweep_len);
179    }
180}
181
182/// Metropolis moves at the replica's fixed temperature.
183fn advance<A: Anneal>(problem: &A, r: &mut Replica<A::State>, n_moves: usize) {
184    for _ in 0..n_moves {
185        let mv = problem.propose(&r.state, &mut r.rng);
186        let delta = problem.delta(&r.state, &mv);
187        if delta < 0.0 || r.rng.uniform() < (-delta / r.temp).exp() {
188            problem.apply(&mut r.state, mv);
189            r.energy += delta;
190            if r.energy < r.best_energy {
191                r.best_energy = r.energy;
192                r.best_state = r.state.clone();
193            }
194        }
195    }
196}
197
198/// Sequential exchange sweep over adjacent replica pairs (cold→hot), accepting
199/// each swap with probability `min(1, exp((β_cold − β_hot)(E_cold − E_hot)))`.
200///
201/// Note this full in-order sweep is a valid Metropolis scheme but differs from
202/// the literature-standard even-odd (DEO) alternation: a configuration can
203/// ratchet several rungs in one sweep, and round-trip statistics are not
204/// directly comparable to DEO-based published results.
205fn attempt_swaps<S>(replicas: &mut [Replica<S>], swap_rng: &mut Rng) {
206    for c in 0..replicas.len() - 1 {
207        let beta_cold = 1.0 / replicas[c].temp;
208        let beta_hot = 1.0 / replicas[c + 1].temp;
209        let arg = (beta_cold - beta_hot) * (replicas[c].energy - replicas[c + 1].energy);
210        if arg >= 0.0 || swap_rng.uniform() < arg.exp() {
211            let (left, right) = replicas.split_at_mut(c + 1);
212            std::mem::swap(&mut left[c].state, &mut right[0].state);
213            std::mem::swap(&mut left[c].energy, &mut right[0].energy);
214        }
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    /// Number partitioning (same as the SA tests): minimize the squared
223    /// difference of the two group sums.
224    struct Partition {
225        weights: Vec<f64>,
226    }
227    impl Partition {
228        fn diff(&self, x: &[bool]) -> f64 {
229            self.weights
230                .iter()
231                .zip(x)
232                .map(|(w, &b)| if b { *w } else { -*w })
233                .sum()
234        }
235    }
236    impl Anneal for Partition {
237        type State = Vec<bool>;
238        type Move = usize;
239        fn initial(&self, _r: &mut Rng) -> Vec<bool> {
240            vec![false; self.weights.len()]
241        }
242        fn energy(&self, x: &Vec<bool>) -> f64 {
243            self.diff(x).powi(2)
244        }
245        fn propose(&self, x: &Vec<bool>, r: &mut Rng) -> usize {
246            r.index(x.len())
247        }
248        fn delta(&self, x: &Vec<bool>, &i: &usize) -> f64 {
249            let d = self.diff(x);
250            let step = if x[i] { -2.0 } else { 2.0 } * self.weights[i];
251            (d + step).powi(2) - d.powi(2)
252        }
253        fn apply(&self, x: &mut Vec<bool>, i: usize) {
254            x[i] = !x[i];
255        }
256        fn sweep_len(&self) -> usize {
257            self.weights.len()
258        }
259    }
260
261    #[test]
262    fn finds_perfect_partition() {
263        let p = Partition {
264            weights: vec![8.0, 7.0, 6.0, 5.0, 4.0, 9.0, 3.0, 2.0],
265        };
266        let pt = ParallelTempering {
267            replicas: 6,
268            iterations: 8000,
269            schedule: Schedule::adaptive(),
270            seed: 1,
271        };
272        let res = pt.optimize(&p);
273        assert!(res.best.energy < 1e-9, "energy {}", res.best.energy);
274        assert_eq!(res.replicas.len(), 6);
275    }
276
277    #[test]
278    fn is_deterministic() {
279        let p = Partition {
280            weights: vec![5.0, 3.0, 9.0, 7.0, 1.0, 8.0, 4.0],
281        };
282        let pt = ParallelTempering {
283            replicas: 5,
284            iterations: 5000,
285            schedule: Schedule::Geometric {
286                t_start: 50.0,
287                t_end: 0.01,
288            },
289            seed: 7,
290        };
291        let a = pt.optimize(&p);
292        let b = pt.optimize(&p);
293        assert_eq!(a.best.best, b.best.best);
294        assert_eq!(a.best.energy, b.best.energy);
295    }
296}