Skip to main content

sc_neurocore_engine/
quantum.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Commercial license available
3// © Concepts 1996–2026 Miroslav Šotek. All rights reserved.
4// © Code 2020–2026 Miroslav Šotek. All rights reserved.
5// ORCID: 0009-0009-3560-0851
6// Contact: www.anulum.li | protoscience@anulum.li
7// SC-NeuroCore — Rust Quantum Annealing Acceleration
8
9//! High-performance quantum annealing primitives.
10//!
11//! Accelerates the hot paths in the Python `quantum_annealing` bridge:
12//! - **Simulated annealing**: Metropolis-Hastings with exponential schedule
13//! - **Ising energy**: Vectorized energy evaluation
14//! - **Gauge transform**: Batch gauge generation and application
15//! - **Problem decomposition**: Greedy graph partitioning
16
17pub(crate) mod bindings;
18
19use rayon::prelude::*;
20use std::collections::HashMap;
21
22// ── Ising energy ─────────────────────────────────────────────────────
23
24/// Compute Ising energy for a spin configuration.
25///
26/// H = Σ h_i·s_i + Σ J_ij·s_i·s_j + offset
27pub fn ising_energy(
28    h: &[(usize, f64)],
29    j: &[((usize, usize), f64)],
30    spins: &[i8],
31    offset: f64,
32) -> f64 {
33    let mut e = offset;
34    for &(i, hi) in h {
35        if i < spins.len() {
36            e += hi * spins[i] as f64;
37        }
38    }
39    for &((i, j_idx), jij) in j {
40        if i < spins.len() && j_idx < spins.len() {
41            e += jij * spins[i] as f64 * spins[j_idx] as f64;
42        }
43    }
44    e
45}
46
47/// Batch evaluate energies for many configurations (rayon-parallelized).
48pub fn batch_ising_energy(
49    h: &[(usize, f64)],
50    j: &[((usize, usize), f64)],
51    configs: &[Vec<i8>],
52    offset: f64,
53) -> Vec<f64> {
54    configs
55        .par_iter()
56        .map(|spins| ising_energy(h, j, spins, offset))
57        .collect()
58}
59
60// ── Simulated annealing ──────────────────────────────────────────────
61
62/// Delta energy ΔE = E_after - E_before for flipping spin `qubit`
63/// from `s_q` to `-s_q` in the Ising model H = Σ h_i s_i + Σ J_ij s_i s_j.
64///
65/// ΔE = −2·s_q·(h_q + Σ_k J_qk·s_k).
66#[inline]
67fn delta_energy(
68    h: &[(usize, f64)],
69    j_by_qubit: &[Vec<(usize, f64)>],
70    spins: &[i8],
71    qubit: usize,
72) -> f64 {
73    let s = spins[qubit] as f64;
74    let h_q = h
75        .iter()
76        .find(|&&(i, _)| i == qubit)
77        .map_or(0.0, |&(_, hi)| hi);
78    let mut local_field = h_q;
79
80    for &(other, jij) in &j_by_qubit[qubit] {
81        local_field += jij * spins[other] as f64;
82    }
83    -2.0 * s * local_field
84}
85
86/// Build adjacency index for fast delta-energy lookup.
87fn build_j_index(j: &[((usize, usize), f64)], n: usize) -> Vec<Vec<(usize, f64)>> {
88    let mut idx = vec![Vec::new(); n];
89    for &((i, j_idx), jij) in j {
90        if i < n {
91            idx[i].push((j_idx, jij));
92        }
93        if j_idx < n {
94            idx[j_idx].push((i, jij));
95        }
96    }
97    idx
98}
99
100/// Run simulated annealing on an Ising model.
101///
102/// Returns (best_spins, best_energy, all_energies, all_samples).
103pub fn simulated_annealing(
104    h: &[(usize, f64)],
105    j: &[((usize, usize), f64)],
106    n_qubits: usize,
107    offset: f64,
108    n_sweeps: usize,
109    num_reads: usize,
110    beta_start: f64,
111    beta_end: f64,
112    seed: u64,
113) -> (Vec<i8>, f64, Vec<f64>, Vec<Vec<i8>>) {
114    let j_index = build_j_index(j, n_qubits);
115
116    // Parallelized multi-read SA
117    let results: Vec<(Vec<i8>, f64)> = (0..num_reads)
118        .into_par_iter()
119        .map(|read_idx| {
120            let mut rng = Xoshiro256pp::new(seed.wrapping_add(read_idx as u64));
121
122            // Random initial config
123            let mut spins: Vec<i8> = (0..n_qubits)
124                .map(|_| if rng.next_bit() { 1 } else { -1 })
125                .collect();
126            let mut energy = ising_energy(h, j, &spins, offset);
127
128            for sweep in 0..n_sweeps {
129                let beta = beta_start
130                    * ((beta_end / beta_start).powf(sweep as f64 / (n_sweeps - 1).max(1) as f64));
131
132                for qubit in 0..n_qubits {
133                    let de = delta_energy(h, &j_index, &spins, qubit);
134
135                    if de < 0.0 || rng.next_f64() < (-beta * de).exp() {
136                        spins[qubit] *= -1;
137                        energy += de;
138                    }
139                }
140            }
141
142            (spins, energy)
143        })
144        .collect();
145
146    let mut best_energy = f64::INFINITY;
147    let mut best_spins = vec![1i8; n_qubits];
148    let mut all_energies = Vec::with_capacity(num_reads);
149    let mut all_samples = Vec::with_capacity(num_reads);
150
151    for (spins, energy) in results {
152        all_energies.push(energy);
153        if energy < best_energy {
154            best_energy = energy;
155            best_spins = spins.clone();
156        }
157        all_samples.push(spins);
158    }
159
160    (best_spins, best_energy, all_energies, all_samples)
161}
162
163// ── Gauge transform ──────────────────────────────────────────────────
164
165/// Apply a gauge transform to Ising biases and couplings.
166///
167/// h'_i = g_i · h_i, J'_ij = g_i · g_j · J_ij
168#[allow(clippy::type_complexity)] // tuple shape mirrors Python's QUBO format
169pub fn gauge_transform(
170    h: &[(usize, f64)],
171    j: &[((usize, usize), f64)],
172    gauge: &[i8],
173) -> (Vec<(usize, f64)>, Vec<((usize, usize), f64)>) {
174    let h_new: Vec<(usize, f64)> = h
175        .iter()
176        .map(|&(i, hi)| {
177            let g = if i < gauge.len() {
178                gauge[i] as f64
179            } else {
180                1.0
181            };
182            (i, g * hi)
183        })
184        .collect();
185
186    let j_new: Vec<((usize, usize), f64)> = j
187        .iter()
188        .map(|&((i, j_idx), jij)| {
189            let gi = if i < gauge.len() {
190                gauge[i] as f64
191            } else {
192                1.0
193            };
194            let gj = if j_idx < gauge.len() {
195                gauge[j_idx] as f64
196            } else {
197                1.0
198            };
199            ((i, j_idx), gi * gj * jij)
200        })
201        .collect();
202
203    (h_new, j_new)
204}
205
206/// Generate n_gauges random gauge vectors.
207pub fn generate_gauges(n_qubits: usize, n_gauges: usize, seed: u64) -> Vec<Vec<i8>> {
208    (0..n_gauges)
209        .into_par_iter()
210        .map(|g| {
211            let mut rng = Xoshiro256pp::new(seed.wrapping_add(g as u64 * 7919));
212            (0..n_qubits)
213                .map(|_| if rng.next_bit() { 1 } else { -1 })
214                .collect()
215        })
216        .collect()
217}
218
219// ── Graph partitioning ───────────────────────────────────────────────
220
221/// Greedy graph partitioning for problem decomposition.
222///
223/// Returns list of partitions, each a Vec of qubit indices.
224pub fn greedy_partition(
225    n_qubits: usize,
226    j: &[((usize, usize), f64)],
227    max_partition_size: usize,
228) -> Vec<Vec<usize>> {
229    // Build adjacency
230    let mut neighbors: HashMap<usize, Vec<(usize, f64)>> = HashMap::new();
231    for &((i, j_idx), jij) in j {
232        neighbors.entry(i).or_default().push((j_idx, jij.abs()));
233        neighbors.entry(j_idx).or_default().push((i, jij.abs()));
234    }
235
236    let mut remaining: Vec<bool> = vec![true; n_qubits];
237    let mut partitions: Vec<Vec<usize>> = Vec::new();
238
239    let mut n_remaining = n_qubits;
240    while n_remaining > 0 {
241        // Find first remaining
242        let seed = remaining.iter().position(|&r| r).unwrap();
243        let mut partition = vec![seed];
244        remaining[seed] = false;
245        n_remaining -= 1;
246
247        while partition.len() < max_partition_size && n_remaining > 0 {
248            let mut best: Option<usize> = None;
249            let mut best_score = -1.0f64;
250
251            for &q in &partition {
252                if let Some(nbrs) = neighbors.get(&q) {
253                    for &(n, score) in nbrs {
254                        if n < n_qubits && remaining[n] && score > best_score {
255                            best = Some(n);
256                            best_score = score;
257                        }
258                    }
259                }
260            }
261
262            let chosen = best.unwrap_or_else(|| remaining.iter().position(|&r| r).unwrap());
263
264            partition.push(chosen);
265            remaining[chosen] = false;
266            n_remaining -= 1;
267        }
268
269        partitions.push(partition);
270    }
271
272    partitions
273}
274
275// ── Minimal xoshiro256++ PRNG ────────────────────────────────────────
276
277struct Xoshiro256pp {
278    s: [u64; 4],
279}
280
281impl Xoshiro256pp {
282    fn new(seed: u64) -> Self {
283        // SplitMix64 seeding
284        let mut s = [0u64; 4];
285        let mut z = seed;
286        for slot in &mut s {
287            z = z.wrapping_add(0x9e3779b97f4a7c15);
288            z = (z ^ (z >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
289            z = (z ^ (z >> 27)).wrapping_mul(0x94d049bb133111eb);
290            *slot = z ^ (z >> 31);
291        }
292        Self { s }
293    }
294
295    #[inline]
296    fn next_u64(&mut self) -> u64 {
297        let result = (self.s[0].wrapping_add(self.s[3]))
298            .rotate_left(23)
299            .wrapping_add(self.s[0]);
300        let t = self.s[1] << 17;
301        self.s[2] ^= self.s[0];
302        self.s[3] ^= self.s[1];
303        self.s[1] ^= self.s[2];
304        self.s[0] ^= self.s[3];
305        self.s[2] ^= t;
306        self.s[3] = self.s[3].rotate_left(45);
307        result
308    }
309
310    #[inline]
311    fn next_f64(&mut self) -> f64 {
312        (self.next_u64() >> 11) as f64 * (1.0 / (1u64 << 53) as f64)
313    }
314
315    #[inline]
316    fn next_bit(&mut self) -> bool {
317        self.next_u64() & 1 == 1
318    }
319}
320
321// ── Tests ────────────────────────────────────────────────────────────
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    type HTerms = Vec<(usize, f64)>;
328    type JTerms = Vec<((usize, usize), f64)>;
329
330    fn simple_model() -> (HTerms, JTerms) {
331        let h = vec![(0, 0.1), (1, -0.2), (2, 0.0)];
332        let j = vec![((0, 1), -1.0), ((1, 2), 0.5)];
333        (h, j)
334    }
335
336    #[test]
337    fn test_ising_energy_all_up() {
338        let (h, j) = simple_model();
339        let spins = vec![1, 1, 1];
340        let e = ising_energy(&h, &j, &spins, 0.0);
341        // 0.1 + (-0.2) + 0.0 + (-1.0)*1*1 + 0.5*1*1 = -0.6
342        assert!((e - (-0.6)).abs() < 1e-10);
343    }
344
345    #[test]
346    fn test_ising_energy_mixed() {
347        let (h, j) = simple_model();
348        let spins = vec![1, -1, 1];
349        let e = ising_energy(&h, &j, &spins, 0.0);
350        // 0.1 + 0.2 + 0 + (-1.0)*1*(-1) + 0.5*(-1)*1 = 0.3 + 1.0 - 0.5 = 0.8
351        assert!((e - 0.8).abs() < 1e-10);
352    }
353
354    #[test]
355    fn test_batch_energy() {
356        let (h, j) = simple_model();
357        let configs = vec![vec![1, 1, 1], vec![1, -1, 1], vec![-1, -1, -1]];
358        let energies = batch_ising_energy(&h, &j, &configs, 0.0);
359        assert_eq!(energies.len(), 3);
360        assert!((energies[0] - (-0.6)).abs() < 1e-10);
361    }
362
363    #[test]
364    fn test_sa_finds_ground_state() {
365        // Simple ferromagnetic 2-qubit: J < 0
366        let h = vec![(0, 0.0), (1, 0.0)];
367        let j = vec![((0, 1), -1.0)];
368        let (best_spins, best_energy, _, _) =
369            simulated_annealing(&h, &j, 2, 0.0, 5000, 100, 0.1, 20.0, 42);
370        // Ground state: aligned → energy = -1.0
371        assert!(best_energy <= -0.99, "energy = {}", best_energy);
372        // Energy tracker must match recomputed energy of the
373        // returned spin configuration. This catches sign bugs in
374        // delta_energy that previously let the tracker diverge from
375        // the true energy.
376        let recomputed = ising_energy(&h, &j, &best_spins, 0.0);
377        assert!(
378            (best_energy - recomputed).abs() < 1e-9,
379            "tracker {} != recomputed {}",
380            best_energy,
381            recomputed
382        );
383    }
384
385    #[test]
386    fn test_sa_planted_ferromagnetic_8q() {
387        // 8-qubit fully-ferromagnetic (h_i = -1, J_ij = -1 ∀ edges).
388        // Planted GS = all +1; energy = -n - n_edges = -8 - 28 = -36.
389        // With the wrong-sign delta bug, the tracker drifts below
390        // -36 (impossible) while the returned spins have higher
391        // real energy. The recomputed-vs-tracker check pins this.
392        let n = 8;
393        let h: Vec<(usize, f64)> = (0..n).map(|i| (i, -1.0)).collect();
394        let mut j: Vec<((usize, usize), f64)> = Vec::new();
395        for i in 0..n {
396            for k in (i + 1)..n {
397                j.push(((i, k), -1.0));
398            }
399        }
400        let (best_spins, best_energy, _, _) =
401            simulated_annealing(&h, &j, n, 0.0, 2000, 50, 0.1, 10.0, 42);
402        let recomputed = ising_energy(&h, &j, &best_spins, 0.0);
403        assert!(
404            (best_energy - recomputed).abs() < 1e-9,
405            "tracker {} != recomputed {}",
406            best_energy,
407            recomputed
408        );
409        // Best energy must be ≥ planted GS (cannot be lower).
410        assert!(
411            best_energy >= -36.0 - 1e-9,
412            "best_energy={} below planted GS=-36",
413            best_energy
414        );
415        // SA should land at or near the planted GS.
416        assert!(
417            best_energy <= -35.0,
418            "SA failed to reach planted GS: {}",
419            best_energy
420        );
421    }
422
423    #[test]
424    fn test_sa_returns_correct_counts() {
425        let h = vec![(0, 0.0)];
426        let j = vec![];
427        let (_, _, energies, samples) = simulated_annealing(&h, &j, 1, 0.0, 100, 5, 0.1, 5.0, 42);
428        assert_eq!(energies.len(), 5);
429        assert_eq!(samples.len(), 5);
430    }
431
432    #[test]
433    fn test_gauge_transform_preserves_magnitudes() {
434        let h = vec![(0, 1.0), (1, -2.0)];
435        let j = vec![((0, 1), 0.5)];
436        let gauge = vec![1, -1];
437        let (h_new, j_new) = gauge_transform(&h, &j, &gauge);
438        assert!((h_new[0].1 - 1.0).abs() < 1e-10);
439        assert!((h_new[1].1 - 2.0).abs() < 1e-10); // -1 * -2 = 2
440        assert!((j_new[0].1 - (-0.5)).abs() < 1e-10); // 1 * -1 * 0.5 = -0.5
441    }
442
443    #[test]
444    fn test_generate_gauges() {
445        let gauges = generate_gauges(5, 10, 42);
446        assert_eq!(gauges.len(), 10);
447        for g in &gauges {
448            assert_eq!(g.len(), 5);
449            for &v in g {
450                assert!(v == 1 || v == -1);
451            }
452        }
453    }
454
455    #[test]
456    fn test_greedy_partition_small() {
457        let j = vec![((0, 1), -1.0), ((1, 2), 0.5)];
458        let parts = greedy_partition(3, &j, 100);
459        assert_eq!(parts.len(), 1); // fits in one partition
460    }
461
462    #[test]
463    fn test_greedy_partition_forced_split() {
464        let j = vec![((0, 1), -1.0), ((1, 2), 0.5), ((2, 3), 0.8), ((3, 4), 0.3)];
465        let parts = greedy_partition(5, &j, 2);
466        assert!(parts.len() >= 3);
467        for p in &parts {
468            assert!(p.len() <= 2);
469        }
470    }
471
472    #[test]
473    fn test_xoshiro_deterministic() {
474        let mut rng1 = Xoshiro256pp::new(42);
475        let mut rng2 = Xoshiro256pp::new(42);
476        for _ in 0..100 {
477            assert_eq!(rng1.next_u64(), rng2.next_u64());
478        }
479    }
480
481    #[test]
482    fn test_xoshiro_f64_range() {
483        let mut rng = Xoshiro256pp::new(123);
484        for _ in 0..1000 {
485            let v = rng.next_f64();
486            assert!((0.0..1.0).contains(&v));
487        }
488    }
489}