Skip to main content

quantrs2_core/
rl_circuit_optimization.rs

1//! Reinforcement Learning-Based Quantum Circuit Optimization
2//!
3//! This module implements advanced circuit optimization using reinforcement learning (RL).
4//! The RL agent learns optimal gate sequences, placement strategies, and circuit
5//! transformations by interacting with quantum circuits and receiving rewards based
6//! on circuit quality metrics (depth, gate count, fidelity, etc.).
7
8use crate::error::{QuantRS2Error, QuantRS2Result};
9use crate::gate::GateOp;
10use crate::qubit::QubitId;
11use scirs2_core::ndarray::{Array1, Array2};
12use scirs2_core::random::{thread_rng, Rng};
13use std::collections::HashMap;
14use std::sync::{Arc, RwLock};
15
16/// Actions the RL agent can take to optimize circuits
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
18pub enum OptimizationAction {
19    /// Merge two consecutive single-qubit gates
20    MergeSingleQubitGates { gate_index: usize },
21    /// Cancel inverse gate pairs
22    CancelInversePairs { gate_index: usize },
23    /// Apply commutation rules to reorder gates
24    CommuteGates {
25        gate1_index: usize,
26        gate2_index: usize,
27    },
28    /// Replace gate sequence with equivalent but more efficient sequence
29    ReplaceSequence {
30        start_index: usize,
31        end_index: usize,
32    },
33    /// Optimize two-qubit gate using decomposition
34    OptimizeTwoQubitGate { gate_index: usize },
35    /// No operation (skip this step)
36    NoOp,
37}
38
39/// State representation for the RL agent
40#[derive(Debug, Clone)]
41pub struct CircuitState {
42    /// Current circuit depth
43    pub depth: usize,
44    /// Total gate count
45    pub gate_count: usize,
46    /// Two-qubit gate count (more expensive)
47    pub two_qubit_count: usize,
48    /// Estimated fidelity (0.0 to 1.0)
49    pub fidelity: f64,
50    /// Number of qubits
51    pub qubit_count: usize,
52    /// Circuit connectivity graph density
53    pub connectivity_density: f64,
54    /// Entanglement complexity measure
55    pub entanglement_measure: f64,
56}
57
58impl CircuitState {
59    /// Convert state to feature vector for Q-learning
60    pub fn to_features(&self) -> Vec<f64> {
61        vec![
62            self.depth as f64 / 100.0, // Normalize
63            self.gate_count as f64 / 1000.0,
64            self.two_qubit_count as f64 / 500.0,
65            self.fidelity,
66            self.qubit_count as f64 / 50.0,
67            self.connectivity_density,
68            self.entanglement_measure,
69        ]
70    }
71
72    /// Extract state from a circuit
73    pub fn from_circuit(gates: &[Box<dyn GateOp>], num_qubits: usize) -> Self {
74        let mut depth_map: HashMap<QubitId, usize> = HashMap::new();
75        let mut two_qubit_count = 0;
76        let mut connectivity_edges = 0;
77
78        for gate in gates {
79            let qubits = gate.qubits();
80
81            if qubits.len() == 2 {
82                two_qubit_count += 1;
83                connectivity_edges += 1;
84            }
85
86            // Update depth
87            let max_depth = qubits
88                .iter()
89                .map(|q| *depth_map.get(q).unwrap_or(&0))
90                .max()
91                .unwrap_or(0);
92
93            for qubit in qubits {
94                depth_map.insert(qubit, max_depth + 1);
95            }
96        }
97
98        let depth = depth_map.values().max().copied().unwrap_or(0);
99        let gate_count = gates.len();
100
101        // Estimate fidelity based on gate count and type
102        let fidelity = 0.9999_f64.powi(gate_count as i32 - two_qubit_count as i32)
103            * 0.99_f64.powi(two_qubit_count as i32);
104
105        // Calculate connectivity density
106        let max_edges = num_qubits * (num_qubits - 1) / 2;
107        let connectivity_density = if max_edges > 0 {
108            connectivity_edges as f64 / max_edges as f64
109        } else {
110            0.0
111        };
112
113        // Simplified entanglement measure based on two-qubit gates
114        let entanglement_measure = (two_qubit_count as f64 / num_qubits as f64).min(1.0);
115
116        Self {
117            depth,
118            gate_count,
119            two_qubit_count,
120            fidelity,
121            qubit_count: num_qubits,
122            connectivity_density,
123            entanglement_measure,
124        }
125    }
126}
127
128/// Q-learning agent for circuit optimization
129pub struct QLearningOptimizer {
130    /// Q-table: maps (state, action) -> expected reward
131    q_table: Arc<RwLock<HashMap<(Vec<u8>, OptimizationAction), f64>>>,
132    /// Learning rate (alpha)
133    learning_rate: f64,
134    /// Discount factor (gamma)
135    discount_factor: f64,
136    /// Exploration rate (epsilon)
137    epsilon: f64,
138    /// Epsilon decay rate
139    epsilon_decay: f64,
140    /// Minimum epsilon
141    min_epsilon: f64,
142    /// Episode counter
143    episodes: Arc<RwLock<usize>>,
144    /// Performance history
145    performance_history: Arc<RwLock<Vec<OptimizationEpisode>>>,
146}
147
148/// Record of a single optimization episode
149#[derive(Debug, Clone)]
150pub struct OptimizationEpisode {
151    pub initial_depth: usize,
152    pub final_depth: usize,
153    pub initial_gate_count: usize,
154    pub final_gate_count: usize,
155    pub reward: f64,
156    pub steps_taken: usize,
157}
158
159impl QLearningOptimizer {
160    /// Create a new Q-learning optimizer
161    ///
162    /// # Arguments
163    /// * `learning_rate` - How quickly the agent learns (0.0 to 1.0)
164    /// * `discount_factor` - How much future rewards matter (0.0 to 1.0)
165    /// * `initial_epsilon` - Initial exploration rate (0.0 to 1.0)
166    pub fn new(learning_rate: f64, discount_factor: f64, initial_epsilon: f64) -> Self {
167        Self {
168            q_table: Arc::new(RwLock::new(HashMap::new())),
169            learning_rate,
170            discount_factor,
171            epsilon: initial_epsilon,
172            epsilon_decay: 0.995,
173            min_epsilon: 0.01,
174            episodes: Arc::new(RwLock::new(0)),
175            performance_history: Arc::new(RwLock::new(Vec::new())),
176        }
177    }
178
179    /// Choose action using epsilon-greedy policy
180    ///
181    /// # Arguments
182    /// * `state` - Current circuit state
183    /// * `available_actions` - List of actions that can be taken
184    pub fn choose_action(
185        &self,
186        state: &CircuitState,
187        available_actions: &[OptimizationAction],
188    ) -> OptimizationAction {
189        if available_actions.is_empty() {
190            return OptimizationAction::NoOp;
191        }
192
193        let mut rng = thread_rng();
194
195        // Epsilon-greedy: explore vs exploit
196        if rng.random::<f64>() < self.epsilon {
197            // Explore: random action
198            available_actions[rng.random_range(0..available_actions.len())]
199        } else {
200            // Exploit: best known action
201            self.get_best_action(state, available_actions)
202        }
203    }
204
205    /// Get the best action according to current Q-values
206    fn get_best_action(
207        &self,
208        state: &CircuitState,
209        available_actions: &[OptimizationAction],
210    ) -> OptimizationAction {
211        let state_key = self.state_to_key(state);
212        let q_table = self.q_table.read().unwrap_or_else(|e| e.into_inner());
213
214        let mut best_action = available_actions[0];
215        let mut best_q_value = f64::NEG_INFINITY;
216
217        for &action in available_actions {
218            let q_value = *q_table.get(&(state_key.clone(), action)).unwrap_or(&0.0);
219            if q_value > best_q_value {
220                best_q_value = q_value;
221                best_action = action;
222            }
223        }
224
225        best_action
226    }
227
228    /// Update Q-value based on observed reward
229    ///
230    /// # Arguments
231    /// * `state` - Previous state
232    /// * `action` - Action taken
233    /// * `reward` - Reward received
234    /// * `next_state` - New state after action
235    /// * `next_actions` - Actions available in next state
236    pub fn update_q_value(
237        &mut self,
238        state: &CircuitState,
239        action: OptimizationAction,
240        reward: f64,
241        next_state: &CircuitState,
242        next_actions: &[OptimizationAction],
243    ) {
244        let state_key = self.state_to_key(state);
245        let next_state_key = self.state_to_key(next_state);
246
247        // Find max Q-value for next state
248        let q_table = self.q_table.read().unwrap_or_else(|e| e.into_inner());
249        let max_next_q = if next_actions.is_empty() {
250            0.0
251        } else {
252            next_actions
253                .iter()
254                .map(|&a| *q_table.get(&(next_state_key.clone(), a)).unwrap_or(&0.0))
255                .fold(f64::NEG_INFINITY, f64::max)
256        };
257        drop(q_table);
258
259        // Q-learning update rule:
260        // Q(s,a) = Q(s,a) + α * [r + γ * max Q(s',a') - Q(s,a)]
261        let mut q_table = self.q_table.write().unwrap_or_else(|e| e.into_inner());
262        let current_q = *q_table.get(&(state_key.clone(), action)).unwrap_or(&0.0);
263        let new_q = self.learning_rate.mul_add(
264            self.discount_factor.mul_add(max_next_q, reward) - current_q,
265            current_q,
266        );
267        q_table.insert((state_key, action), new_q);
268    }
269
270    /// Calculate reward for a state transition
271    ///
272    /// Reward is based on improvements in circuit metrics:
273    /// - Reduced depth (+reward)
274    /// - Reduced gate count (+reward)
275    /// - Increased fidelity (+reward)
276    pub fn calculate_reward(&self, old_state: &CircuitState, new_state: &CircuitState) -> f64 {
277        let mut reward = 0.0;
278
279        // Reward for depth reduction (most important)
280        let depth_improvement = old_state.depth as f64 - new_state.depth as f64;
281        reward += depth_improvement * 2.0;
282
283        // Reward for gate count reduction
284        let gate_improvement = old_state.gate_count as f64 - new_state.gate_count as f64;
285        reward += gate_improvement * 1.0;
286
287        // Reward for two-qubit gate reduction (expensive gates)
288        let two_qubit_improvement =
289            old_state.two_qubit_count as f64 - new_state.two_qubit_count as f64;
290        reward += two_qubit_improvement * 3.0;
291
292        // Penalty for fidelity loss
293        let fidelity_change = new_state.fidelity - old_state.fidelity;
294        reward += fidelity_change * 100.0; // Heavily weight fidelity
295
296        // Small penalty for NoOp to encourage action
297        if reward == 0.0 {
298            reward = -0.1;
299        }
300
301        reward
302    }
303
304    /// Complete an optimization episode
305    pub fn finish_episode(&mut self, episode: OptimizationEpisode) {
306        // Decay epsilon
307        self.epsilon = (self.epsilon * self.epsilon_decay).max(self.min_epsilon);
308
309        // Record episode
310        {
311            let mut episodes = self.episodes.write().unwrap_or_else(|e| e.into_inner());
312            *episodes += 1;
313
314            let mut history = self
315                .performance_history
316                .write()
317                .unwrap_or_else(|e| e.into_inner());
318            history.push(episode);
319
320            // Keep last 1000 episodes
321            if history.len() > 1000 {
322                let len = history.len();
323                history.drain(0..len - 1000);
324            }
325        }
326    }
327
328    /// Get optimization statistics
329    pub fn get_statistics(&self) -> OptimizationStatistics {
330        let history = self
331            .performance_history
332            .read()
333            .unwrap_or_else(|e| e.into_inner());
334
335        if history.is_empty() {
336            return OptimizationStatistics {
337                total_episodes: 0,
338                average_depth_improvement: 0.0,
339                average_gate_reduction: 0.0,
340                average_reward: 0.0,
341                current_epsilon: self.epsilon,
342                q_table_size: self.q_table.read().unwrap_or_else(|e| e.into_inner()).len(),
343            };
344        }
345
346        let total_episodes = history.len();
347        let avg_depth_improvement: f64 = history
348            .iter()
349            .map(|e| (e.initial_depth - e.final_depth) as f64)
350            .sum::<f64>()
351            / total_episodes as f64;
352
353        let avg_gate_reduction: f64 = history
354            .iter()
355            .map(|e| (e.initial_gate_count - e.final_gate_count) as f64)
356            .sum::<f64>()
357            / total_episodes as f64;
358
359        let avg_reward: f64 = history.iter().map(|e| e.reward).sum::<f64>() / total_episodes as f64;
360
361        OptimizationStatistics {
362            total_episodes,
363            average_depth_improvement: avg_depth_improvement,
364            average_gate_reduction: avg_gate_reduction,
365            average_reward: avg_reward,
366            current_epsilon: self.epsilon,
367            q_table_size: self.q_table.read().unwrap_or_else(|e| e.into_inner()).len(),
368        }
369    }
370
371    /// Convert state to hashable key (discretization)
372    fn state_to_key(&self, state: &CircuitState) -> Vec<u8> {
373        // Discretize continuous features into bins
374        let features = state.to_features();
375        features
376            .iter()
377            .map(|&f| ((f * 10.0).round() as i32).clamp(0, 255) as u8)
378            .collect()
379    }
380
381    /// Save the learned Q-table to `path` as JSON.
382    ///
383    /// The Q-table is `HashMap<(Vec<u8>, OptimizationAction), f64>`. JSON object keys must
384    /// be strings, so entries are serialized as a flat list of
385    /// `((state_key, action), q_value)` tuples. Returns an honest error on serialization
386    /// or I/O failure rather than silently dropping the data.
387    pub fn save_q_table(&self, path: &str) -> QuantRS2Result<()> {
388        let q_table = self.q_table.read().unwrap_or_else(|e| e.into_inner());
389        let entries: Vec<((Vec<u8>, OptimizationAction), f64)> = q_table
390            .iter()
391            .map(|((state, action), value)| ((state.clone(), *action), *value))
392            .collect();
393        drop(q_table);
394
395        let json = serde_json::to_string(&entries).map_err(|e| {
396            QuantRS2Error::RuntimeError(format!("failed to serialize Q-table: {e}"))
397        })?;
398        std::fs::write(path, json).map_err(|e| {
399            QuantRS2Error::RuntimeError(format!("failed to write Q-table to '{path}': {e}"))
400        })?;
401        Ok(())
402    }
403
404    /// Load a Q-table from `path`, replacing the current table.
405    ///
406    /// Reads the JSON written by [`Self::save_q_table`]. Returns an honest error on I/O or
407    /// deserialization failure.
408    pub fn load_q_table(&mut self, path: &str) -> QuantRS2Result<()> {
409        let json = std::fs::read_to_string(path).map_err(|e| {
410            QuantRS2Error::RuntimeError(format!("failed to read Q-table from '{path}': {e}"))
411        })?;
412        let entries: Vec<((Vec<u8>, OptimizationAction), f64)> = serde_json::from_str(&json)
413            .map_err(|e| {
414                QuantRS2Error::RuntimeError(format!("failed to deserialize Q-table: {e}"))
415            })?;
416
417        let mut q_table = self.q_table.write().unwrap_or_else(|e| e.into_inner());
418        q_table.clear();
419        for ((state, action), value) in entries {
420            q_table.insert((state, action), value);
421        }
422        Ok(())
423    }
424}
425
426/// Statistics about optimization performance
427#[derive(Debug, Clone)]
428pub struct OptimizationStatistics {
429    pub total_episodes: usize,
430    pub average_depth_improvement: f64,
431    pub average_gate_reduction: f64,
432    pub average_reward: f64,
433    pub current_epsilon: f64,
434    pub q_table_size: usize,
435}
436
437impl Default for QLearningOptimizer {
438    fn default() -> Self {
439        Self::new(0.1, 0.95, 0.3)
440    }
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446
447    #[test]
448    fn test_circuit_state_creation() {
449        let state = CircuitState {
450            depth: 10,
451            gate_count: 50,
452            two_qubit_count: 15,
453            fidelity: 0.95,
454            qubit_count: 5,
455            connectivity_density: 0.6,
456            entanglement_measure: 0.8,
457        };
458
459        let features = state.to_features();
460        assert_eq!(features.len(), 7);
461        assert!(features.iter().all(|&f| f >= 0.0 && f <= 1.1)); // Allow small overflow
462    }
463
464    #[test]
465    fn test_q_learning_optimizer_creation() {
466        let optimizer = QLearningOptimizer::new(0.1, 0.95, 0.3);
467        assert_eq!(optimizer.learning_rate, 0.1);
468        assert_eq!(optimizer.discount_factor, 0.95);
469        assert_eq!(optimizer.epsilon, 0.3);
470    }
471
472    #[test]
473    fn test_action_selection() {
474        let optimizer = QLearningOptimizer::new(0.1, 0.95, 0.0); // No exploration
475
476        let state = CircuitState {
477            depth: 10,
478            gate_count: 50,
479            two_qubit_count: 15,
480            fidelity: 0.95,
481            qubit_count: 5,
482            connectivity_density: 0.6,
483            entanglement_measure: 0.8,
484        };
485
486        let actions = vec![
487            OptimizationAction::MergeSingleQubitGates { gate_index: 0 },
488            OptimizationAction::CancelInversePairs { gate_index: 1 },
489        ];
490
491        let action = optimizer.choose_action(&state, &actions);
492        assert!(actions.contains(&action));
493    }
494
495    #[test]
496    fn test_reward_calculation() {
497        let optimizer = QLearningOptimizer::new(0.1, 0.95, 0.3);
498
499        let old_state = CircuitState {
500            depth: 10,
501            gate_count: 50,
502            two_qubit_count: 15,
503            fidelity: 0.95,
504            qubit_count: 5,
505            connectivity_density: 0.6,
506            entanglement_measure: 0.8,
507        };
508
509        let new_state = CircuitState {
510            depth: 8,
511            gate_count: 45,
512            two_qubit_count: 12,
513            fidelity: 0.96,
514            qubit_count: 5,
515            connectivity_density: 0.6,
516            entanglement_measure: 0.8,
517        };
518
519        let reward = optimizer.calculate_reward(&old_state, &new_state);
520        assert!(reward > 0.0); // Should be positive for improvements
521    }
522
523    #[test]
524    fn test_q_value_update() {
525        let mut optimizer = QLearningOptimizer::new(0.1, 0.95, 0.3);
526
527        let state = CircuitState {
528            depth: 10,
529            gate_count: 50,
530            two_qubit_count: 15,
531            fidelity: 0.95,
532            qubit_count: 5,
533            connectivity_density: 0.6,
534            entanglement_measure: 0.8,
535        };
536
537        let action = OptimizationAction::MergeSingleQubitGates { gate_index: 0 };
538
539        let next_state = CircuitState {
540            depth: 9,
541            gate_count: 48,
542            two_qubit_count: 15,
543            fidelity: 0.95,
544            qubit_count: 5,
545            connectivity_density: 0.6,
546            entanglement_measure: 0.8,
547        };
548
549        optimizer.update_q_value(&state, action, 5.0, &next_state, &[]);
550
551        // Q-value should have been updated
552        let q_table = optimizer
553            .q_table
554            .read()
555            .expect("Failed to acquire Q-table read lock");
556        assert!(!q_table.is_empty());
557    }
558
559    #[test]
560    fn test_epsilon_decay() {
561        let mut optimizer = QLearningOptimizer::new(0.1, 0.95, 0.5);
562        let initial_epsilon = optimizer.epsilon;
563
564        let episode = OptimizationEpisode {
565            initial_depth: 10,
566            final_depth: 8,
567            initial_gate_count: 50,
568            final_gate_count: 45,
569            reward: 10.0,
570            steps_taken: 5,
571        };
572
573        optimizer.finish_episode(episode);
574
575        assert!(optimizer.epsilon < initial_epsilon);
576        assert!(optimizer.epsilon >= optimizer.min_epsilon);
577    }
578
579    #[test]
580    fn test_statistics() {
581        let mut optimizer = QLearningOptimizer::new(0.1, 0.95, 0.3);
582
583        let episode1 = OptimizationEpisode {
584            initial_depth: 10,
585            final_depth: 8,
586            initial_gate_count: 50,
587            final_gate_count: 45,
588            reward: 10.0,
589            steps_taken: 5,
590        };
591
592        let episode2 = OptimizationEpisode {
593            initial_depth: 12,
594            final_depth: 9,
595            initial_gate_count: 60,
596            final_gate_count: 52,
597            reward: 15.0,
598            steps_taken: 7,
599        };
600
601        optimizer.finish_episode(episode1);
602        optimizer.finish_episode(episode2);
603
604        let stats = optimizer.get_statistics();
605        assert_eq!(stats.total_episodes, 2);
606        assert!(stats.average_depth_improvement > 0.0);
607        assert!(stats.average_gate_reduction > 0.0);
608    }
609
610    #[test]
611    fn test_q_table_save_load_roundtrip() {
612        let mut optimizer = QLearningOptimizer::new(0.1, 0.95, 0.3);
613
614        // Populate the Q-table with a few real updates.
615        let state = CircuitState {
616            depth: 10,
617            gate_count: 50,
618            two_qubit_count: 15,
619            fidelity: 0.95,
620            qubit_count: 5,
621            connectivity_density: 0.6,
622            entanglement_measure: 0.8,
623        };
624        let next_state = CircuitState {
625            depth: 9,
626            gate_count: 48,
627            two_qubit_count: 15,
628            fidelity: 0.95,
629            qubit_count: 5,
630            connectivity_density: 0.6,
631            entanglement_measure: 0.8,
632        };
633        optimizer.update_q_value(
634            &state,
635            OptimizationAction::MergeSingleQubitGates { gate_index: 0 },
636            5.0,
637            &next_state,
638            &[],
639        );
640        optimizer.update_q_value(
641            &state,
642            OptimizationAction::CancelInversePairs { gate_index: 2 },
643            -1.5,
644            &next_state,
645            &[],
646        );
647
648        let original: HashMap<(Vec<u8>, OptimizationAction), f64> = optimizer
649            .q_table
650            .read()
651            .unwrap_or_else(|e| e.into_inner())
652            .clone();
653        assert!(!original.is_empty());
654
655        // Save to a temp file.
656        let mut path = std::env::temp_dir();
657        path.push(format!("quantrs_qtable_test_{}.json", std::process::id()));
658        let path_str = path.to_string_lossy().to_string();
659        optimizer
660            .save_q_table(&path_str)
661            .expect("saving Q-table should succeed");
662
663        // Load into a fresh optimizer.
664        let mut loaded_optimizer = QLearningOptimizer::new(0.1, 0.95, 0.3);
665        loaded_optimizer
666            .load_q_table(&path_str)
667            .expect("loading Q-table should succeed");
668
669        let loaded: HashMap<(Vec<u8>, OptimizationAction), f64> = loaded_optimizer
670            .q_table
671            .read()
672            .unwrap_or_else(|e| e.into_inner())
673            .clone();
674
675        assert_eq!(loaded.len(), original.len());
676        for (key, value) in &original {
677            let loaded_value = loaded
678                .get(key)
679                .expect("loaded Q-table must contain every original key");
680            assert!((loaded_value - value).abs() < 1e-12);
681        }
682
683        // Clean up the temp file.
684        let _ = std::fs::remove_file(&path);
685    }
686
687    #[test]
688    fn test_load_q_table_missing_file_errors() {
689        let mut optimizer = QLearningOptimizer::new(0.1, 0.95, 0.3);
690        let mut path = std::env::temp_dir();
691        path.push("quantrs_qtable_definitely_missing_xyz.json");
692        let _ = std::fs::remove_file(&path);
693        let result = optimizer.load_q_table(&path.to_string_lossy());
694        assert!(result.is_err(), "loading a missing file must be an error");
695    }
696}