scirs2_spatial/quantum_inspired/classical_adaptation.rs
1//! Classical Adaptation of Quantum-Inspired Algorithms
2//!
3//! This module bridges the gap between full quantum simulation and classical
4//! computation by providing quantum-inspired enhancements to classical algorithms.
5//! Rather than simulating quantum circuits exactly (which is exponentially costly),
6//! these adaptations borrow key ideas — controlled randomness, tunnelling noise,
7//! and quantum annealing schedules — to improve classical algorithm performance.
8//!
9//! # Overview
10//!
11//! The primary abstraction is [`ClassicalAdaptation`], which offers:
12//!
13//! - **Coherent noise injection** (`adapt`): adds Gaussian noise scaled by the
14//! adaptation rate and quantum noise level to a parameter vector, simulating
15//! quantum-state fluctuation to escape local optima.
16//! - **Quantum annealing step** (`anneal`): computes a quantum tunnelling
17//! acceptance probability that allows uphill moves with a probability that
18//! decays as the system temperature drops, analogous to the transverse-field
19//! Ising model's ground-state search.
20//! - **Decoherence modelling** (`decohere`): applies an exponential amplitude
21//! decay to a state vector, modelling T₂ relaxation in a quantum system.
22//! - **Parameter landscape smoothing** (`smooth_landscape`): convolves a
23//! 1-D energy landscape with a Gaussian kernel to remove spurious minima,
24//! emulating the quantum superposition effect over neighbouring states.
25//!
26//! # Theoretical Motivation
27//!
28//! Quantum annealing replaces the thermal fluctuations of classical simulated
29//! annealing with quantum tunnelling through energy barriers. For the transverse-
30//! field Ising Hamiltonian H = -J Σ σᵢᶻσⱼᶻ - Γ Σ σᵢˣ, the tunnelling term Γ
31//! allows transitions through barriers of height ΔE that would be exponentially
32//! suppressed classically. This module emulates that effect on a classical
33//! computer by using temperature-scaled, energy-aware acceptance probabilities.
34
35use crate::error::{SpatialError, SpatialResult};
36use scirs2_core::ndarray::Array1;
37use scirs2_core::random::{Rng, RngExt};
38use std::f64::consts::PI;
39
40/// Quantum-Inspired Classical Adaptation Engine
41///
42/// Provides quantum-inspired enhancements to classical optimisation algorithms
43/// by injecting controlled randomness (coherent noise), modelling quantum
44/// annealing acceptance criteria, and simulating decoherence effects.
45///
46/// # Fields
47/// - `adaptation_rate` — scales the amplitude of coherent noise injected into
48/// parameter vectors (analogous to the magnitude of quantum fluctuations).
49/// - `quantum_noise_level` — baseline noise variance; combined with
50/// `adaptation_rate` to determine the actual noise standard deviation.
51///
52/// # Example
53/// ```rust
54/// use scirs2_core::ndarray::Array1;
55/// use scirs2_spatial::quantum_inspired::classical_adaptation::ClassicalAdaptation;
56///
57/// let adapter = ClassicalAdaptation::new(0.05, 0.01);
58///
59/// // Add quantum-inspired noise to a parameter vector
60/// let params = Array1::from_vec(vec![1.0, 2.0, 3.0]);
61/// let noisy = adapter.adapt(¶ms);
62/// assert_eq!(noisy.len(), params.len());
63///
64/// // Quantum annealing acceptance for an uphill move
65/// let accept_prob = adapter.anneal(1.5, 2.0);
66/// assert!(accept_prob >= 0.0 && accept_prob <= 1.0);
67/// ```
68#[derive(Debug, Clone)]
69pub struct ClassicalAdaptation {
70 /// Controls the amplitude of quantum coherent noise added during adaptation.
71 /// Larger values allow wider exploration of the parameter landscape.
72 adaptation_rate: f64,
73 /// Baseline quantum noise level (variance contribution).
74 /// Simulates intrinsic noise from a quantum environment.
75 quantum_noise_level: f64,
76}
77
78impl ClassicalAdaptation {
79 /// Construct a new `ClassicalAdaptation` engine.
80 ///
81 /// # Arguments
82 /// * `adaptation_rate` — Amplitude scale for coherent noise (> 0 recommended).
83 /// * `quantum_noise_level` — Baseline quantum noise variance (≥ 0).
84 pub fn new(adaptation_rate: f64, quantum_noise_level: f64) -> Self {
85 Self {
86 adaptation_rate,
87 quantum_noise_level,
88 }
89 }
90
91 /// Return the adaptation rate.
92 pub fn adaptation_rate(&self) -> f64 {
93 self.adaptation_rate
94 }
95
96 /// Return the quantum noise level.
97 pub fn quantum_noise_level(&self) -> f64 {
98 self.quantum_noise_level
99 }
100
101 /// Inject quantum-inspired coherent noise into a parameter vector.
102 ///
103 /// Each component `pᵢ` is perturbed as:
104 /// ```text
105 /// p̃ᵢ = pᵢ + σ · ηᵢ, ηᵢ ~ N(0, 1)
106 /// ```
107 /// where `σ = adaptation_rate · √(1 + quantum_noise_level)`.
108 ///
109 /// The resulting perturbations model quantum fluctuations around the current
110 /// parameter values and can help classical optimisers escape local minima.
111 ///
112 /// # Arguments
113 /// * `params` — Parameter vector to perturb.
114 ///
115 /// # Returns
116 /// A new `Array1<f64>` with the perturbed parameters.
117 pub fn adapt(&self, params: &Array1<f64>) -> Array1<f64> {
118 let sigma = self.adaptation_rate * (1.0 + self.quantum_noise_level).sqrt();
119 let mut rng = scirs2_core::random::rng();
120 let mut result = params.clone();
121
122 for val in result.iter_mut() {
123 let noise = gaussian_noise(&mut rng, sigma);
124 *val += noise;
125 }
126 result
127 }
128
129 /// Compute a quantum annealing acceptance probability for an energy transition.
130 ///
131 /// This models the probability that the quantum annealer accepts a move from
132 /// the current state to a new state with the given `energy` at the given
133 /// `temperature`. The formula is:
134 ///
135 /// ```text
136 /// P(accept) = exp(-energy / (temperature · (1 + Γ)))
137 /// ```
138 ///
139 /// where `Γ = quantum_noise_level` represents the transverse field strength.
140 /// Compared with classical Metropolis, the denominator is larger (because
141 /// Γ > 0), giving a *higher* acceptance probability — this models quantum
142 /// tunnelling that can traverse energy barriers classical Metropolis cannot.
143 ///
144 /// # Arguments
145 /// * `energy` — Current energy (or energy difference) of the proposed state.
146 /// Positive values correspond to uphill moves.
147 /// * `temperature` — Current annealing temperature. Must be > 0 for
148 /// non-trivial acceptance; returns `0.0` for `temperature ≤ 0`.
149 ///
150 /// # Returns
151 /// Acceptance probability in `[0, 1]`.
152 pub fn anneal(&self, energy: f64, temperature: f64) -> f64 {
153 if temperature <= 0.0 {
154 // At absolute zero, only downhill moves are accepted
155 if energy <= 0.0 {
156 1.0
157 } else {
158 0.0
159 }
160 } else {
161 let transverse_field = 1.0 + self.quantum_noise_level;
162 let exponent = -energy / (temperature * transverse_field);
163 // Clamp to avoid overflow
164 exponent.exp().clamp(0.0, 1.0)
165 }
166 }
167
168 /// Simulate decoherence on a quantum-state amplitude vector.
169 ///
170 /// Applies an exponential T₂ decay to each amplitude element:
171 /// ```text
172 /// ψ̃ᵢ = ψᵢ · exp(-t / T₂)
173 /// ```
174 /// where `T₂ = 1 / (adaptation_rate · quantum_noise_level + ε)` and
175 /// `t` is the elapsed (normalised) time.
176 ///
177 /// # Arguments
178 /// * `amplitudes` — Quantum-state amplitude vector.
179 /// * `elapsed_time` — Normalised elapsed time in `[0, ∞)`.
180 ///
181 /// # Returns
182 /// Decayed amplitude vector. Returns `amplitudes` unchanged if both
183 /// `adaptation_rate` and `quantum_noise_level` are zero.
184 ///
185 /// # Errors
186 /// Returns [`SpatialError::InvalidInput`] if `elapsed_time < 0`.
187 pub fn decohere(
188 &self,
189 amplitudes: &Array1<f64>,
190 elapsed_time: f64,
191 ) -> SpatialResult<Array1<f64>> {
192 if elapsed_time < 0.0 {
193 return Err(SpatialError::InvalidInput(
194 "elapsed_time must be non-negative".to_string(),
195 ));
196 }
197
198 let decoherence_rate = self.adaptation_rate * self.quantum_noise_level + 1e-12;
199 let t2 = 1.0 / decoherence_rate;
200 let decay = (-elapsed_time / t2).exp();
201
202 Ok(amplitudes.mapv(|a| a * decay))
203 }
204
205 /// Smooth a 1-D energy landscape using a Gaussian kernel.
206 ///
207 /// Convolves the energy array with a Gaussian of standard deviation
208 /// `sigma_smooth = adaptation_rate * (landscape.len() as f64).sqrt()`,
209 /// using a finite-support approximation (kernel half-width = 3σ).
210 /// This emulates the quantum superposition effect: the effective energy
211 /// at each point is averaged over nearby states weighted by the quantum
212 /// probability of tunnelling to them.
213 ///
214 /// # Arguments
215 /// * `landscape` — 1-D energy landscape to smooth.
216 ///
217 /// # Returns
218 /// Smoothed energy array of the same length.
219 ///
220 /// # Errors
221 /// Returns [`SpatialError::InvalidInput`] if the landscape is empty.
222 pub fn smooth_landscape(&self, landscape: &Array1<f64>) -> SpatialResult<Array1<f64>> {
223 let n = landscape.len();
224 if n == 0 {
225 return Err(SpatialError::InvalidInput(
226 "landscape must be non-empty".to_string(),
227 ));
228 }
229
230 let sigma = (self.adaptation_rate * (n as f64).sqrt()).max(0.5);
231 let half_width = (3.0 * sigma).ceil() as usize;
232
233 // Build 1-D Gaussian kernel (not pre-normalised; we normalise below)
234 let kernel_len = 2 * half_width + 1;
235 let mut kernel = vec![0.0f64; kernel_len];
236 let mut kernel_sum = 0.0f64;
237 for (k, kval) in kernel.iter_mut().enumerate() {
238 let offset = k as f64 - half_width as f64;
239 let g = (-0.5 * (offset / sigma).powi(2)).exp() / (sigma * (2.0 * PI).sqrt());
240 *kval = g;
241 kernel_sum += g;
242 }
243 // Normalise
244 if kernel_sum > 1e-12 {
245 for kval in kernel.iter_mut() {
246 *kval /= kernel_sum;
247 }
248 }
249
250 // Convolve landscape with kernel (reflect padding at boundaries)
251 let mut smoothed = Array1::<f64>::zeros(n);
252 for i in 0..n {
253 let mut acc = 0.0f64;
254 for (k, &kval) in kernel.iter().enumerate() {
255 let offset = k as isize - half_width as isize;
256 let idx = (i as isize + offset).clamp(0, n as isize - 1) as usize;
257 acc += kval * landscape[idx];
258 }
259 smoothed[i] = acc;
260 }
261
262 Ok(smoothed)
263 }
264}
265
266/// Draw a single Gaussian-distributed sample via Box-Muller transform.
267///
268/// Uses two uniform samples from `rng` to produce one N(0, sigma) variate.
269fn gaussian_noise(rng: &mut impl Rng, sigma: f64) -> f64 {
270 if sigma.abs() < 1e-15 {
271 return 0.0;
272 }
273 let u1: f64 = rng.random_range(1e-10_f64..1.0_f64);
274 let u2: f64 = rng.random_range(0.0_f64..1.0_f64);
275 sigma * (-2.0 * u1.ln()).sqrt() * (2.0 * PI * u2).cos()
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281 use scirs2_core::ndarray::Array1;
282
283 #[test]
284 fn test_adapt_output_shape_and_finite() {
285 let adapter = ClassicalAdaptation::new(0.1, 0.05);
286 let params = Array1::from_vec(vec![1.0, 2.0, -1.0, 0.5]);
287 let adapted = adapter.adapt(¶ms);
288 assert_eq!(adapted.len(), params.len());
289 for &v in adapted.iter() {
290 assert!(v.is_finite(), "adapted value must be finite");
291 }
292 }
293
294 #[test]
295 fn test_anneal_probability_bounds() {
296 let adapter = ClassicalAdaptation::new(0.1, 0.05);
297
298 // Uphill move: probability in (0, 1)
299 let p_uphill = adapter.anneal(10.0, 1.0);
300 assert!(p_uphill > 0.0 && p_uphill < 1.0);
301
302 // Downhill / zero move: probability = 1.0 (since exp(0) = 1)
303 let p_down = adapter.anneal(-5.0, 1.0);
304 assert!(p_down >= 1.0 - 1e-9, "downhill acceptance should be ≥ 1");
305
306 // Temperature → 0: uphill rejected, downhill accepted
307 let p_cold_up = adapter.anneal(1.0, 0.0);
308 assert_eq!(p_cold_up, 0.0);
309 let p_cold_down = adapter.anneal(-1.0, 0.0);
310 assert_eq!(p_cold_down, 1.0);
311 }
312
313 #[test]
314 fn test_decohere_decay() {
315 let adapter = ClassicalAdaptation::new(1.0, 1.0);
316 let amps = Array1::from_vec(vec![1.0, 0.5, -0.5]);
317
318 // At t=0, amplitudes are unchanged
319 let t0 = adapter.decohere(&s, 0.0).expect("t=0 is valid");
320 for (&a, &b) in amps.iter().zip(t0.iter()) {
321 assert!((a - b).abs() < 1e-12);
322 }
323
324 // At t>0, amplitudes are strictly reduced in magnitude
325 let t1 = adapter.decohere(&s, 1.0).expect("t=1 is valid");
326 for (&a, &b) in amps.iter().zip(t1.iter()) {
327 assert!(b.abs() < a.abs() + 1e-12);
328 }
329
330 // Negative time is rejected
331 assert!(adapter.decohere(&s, -1.0).is_err());
332 }
333
334 #[test]
335 fn test_smooth_landscape_energy_preservation() {
336 let adapter = ClassicalAdaptation::new(0.5, 0.1);
337 let landscape = Array1::from_vec(vec![0.0, 1.0, 5.0, 1.0, 0.0, 2.0, 0.0]);
338 let smoothed = adapter
339 .smooth_landscape(&landscape)
340 .expect("smoothing should succeed");
341
342 assert_eq!(smoothed.len(), landscape.len());
343
344 // All values should be finite
345 for &v in smoothed.iter() {
346 assert!(v.is_finite());
347 }
348
349 // The global maximum in the smoothed landscape must occur near where the
350 // original maximum was (index 2), within a small neighbourhood
351 let max_idx = smoothed
352 .iter()
353 .enumerate()
354 .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
355 .map(|(i, _)| i)
356 .unwrap_or(0);
357 assert!(
358 max_idx <= 4,
359 "smoothed peak should remain near original spike, got index {max_idx}"
360 );
361
362 // Empty landscape should error
363 let empty = Array1::<f64>::zeros(0);
364 assert!(adapter.smooth_landscape(&empty).is_err());
365 }
366}