ruqu_algorithms/qaoa.rs
1//! Quantum Approximate Optimization Algorithm (QAOA) for MaxCut
2//!
3//! QAOA is a hybrid classical-quantum algorithm for combinatorial optimization.
4//! This module implements the **MaxCut** variant: given an undirected weighted
5//! graph, find a partition of vertices into two sets that maximizes the total
6//! weight of edges crossing the partition.
7//!
8//! # Circuit structure
9//!
10//! A depth-p QAOA circuit has the form:
11//!
12//! ```text
13//! |+>^n --[C(gamma_1)][B(beta_1)]--...--[C(gamma_p)][B(beta_p)]-- measure
14//! ```
15//!
16//! where:
17//! - **Phase separator** C(gamma) = prod_{(i,j) in E} exp(-i * gamma * w_ij * Z_i Z_j)
18//! is implemented with Rzz gates.
19//! - **Mixer** B(beta) = prod_i exp(-i * beta * X_i) is implemented with Rx gates.
20//!
21//! The 2p parameters (gamma_1..gamma_p, beta_1..beta_p) are optimized
22//! classically to maximize the expected cut value.
23
24use ruqu_core::circuit::QuantumCircuit;
25use ruqu_core::simulator::{SimConfig, Simulator};
26use ruqu_core::types::{PauliOp, PauliString};
27
28// ---------------------------------------------------------------------------
29// Graph representation
30// ---------------------------------------------------------------------------
31
32/// Simple undirected weighted graph for MaxCut problems.
33#[derive(Debug, Clone)]
34pub struct Graph {
35 /// Number of vertices (each mapped to one qubit).
36 pub num_nodes: u32,
37 /// Edges as `(node_i, node_j, weight)` triples. Both directions are
38 /// represented by a single entry (undirected).
39 pub edges: Vec<(u32, u32, f64)>,
40}
41
42impl Graph {
43 /// Create an empty graph with the given number of nodes.
44 pub fn new(num_nodes: u32) -> Self {
45 Self {
46 num_nodes,
47 edges: Vec::new(),
48 }
49 }
50
51 /// Add an undirected weighted edge between nodes `i` and `j`.
52 ///
53 /// # Panics
54 ///
55 /// Panics if `i` or `j` is out of range.
56 pub fn add_edge(&mut self, i: u32, j: u32, weight: f64) {
57 assert!(i < self.num_nodes, "node index {} out of range", i);
58 assert!(j < self.num_nodes, "node index {} out of range", j);
59 self.edges.push((i, j, weight));
60 }
61
62 /// Convenience constructor for an unweighted graph (all weights = 1.0).
63 pub fn unweighted(num_nodes: u32, edges: Vec<(u32, u32)>) -> Self {
64 let weighted: Vec<(u32, u32, f64)> = edges.into_iter().map(|(i, j)| (i, j, 1.0)).collect();
65 Self {
66 num_nodes,
67 edges: weighted,
68 }
69 }
70
71 /// Return the total number of edges.
72 pub fn num_edges(&self) -> usize {
73 self.edges.len()
74 }
75}
76
77// ---------------------------------------------------------------------------
78// Configuration and result types
79// ---------------------------------------------------------------------------
80
81/// Configuration for a QAOA MaxCut run.
82pub struct QaoaConfig {
83 /// The graph instance to solve MaxCut on.
84 pub graph: Graph,
85 /// QAOA depth (number of alternating phase-separation / mixing layers).
86 pub p: u32,
87 /// Maximum number of classical optimizer iterations.
88 pub max_iterations: u32,
89 /// Step size for gradient ascent.
90 pub learning_rate: f64,
91 /// Optional RNG seed for reproducible simulation.
92 pub seed: Option<u64>,
93}
94
95/// Result of a QAOA MaxCut run.
96pub struct QaoaResult {
97 /// Highest expected cut value found.
98 pub best_cut_value: f64,
99 /// Bitstring that achieves (or approximates) `best_cut_value`.
100 /// `best_bitstring[v]` is `true` when vertex `v` belongs to partition S1.
101 pub best_bitstring: Vec<bool>,
102 /// Optimized gamma parameters (phase-separation angles).
103 pub optimal_gammas: Vec<f64>,
104 /// Optimized beta parameters (mixer angles).
105 pub optimal_betas: Vec<f64>,
106 /// Expected cut value at each iteration.
107 pub energy_history: Vec<f64>,
108 /// Whether the optimizer converged.
109 pub converged: bool,
110}
111
112// ---------------------------------------------------------------------------
113// Circuit construction
114// ---------------------------------------------------------------------------
115
116/// Build a QAOA circuit for the MaxCut problem on `graph`.
117///
118/// The circuit starts with Hadamard on every qubit (equal superposition),
119/// then applies `p` alternating layers:
120///
121/// 1. **Phase separation**: `Rzz(2 * gamma * w)` on each edge `(i, j, w)`.
122/// 2. **Mixing**: `Rx(2 * beta)` on each qubit.
123///
124/// `gammas` and `betas` must each have length `p`.
125pub fn build_qaoa_circuit(graph: &Graph, gammas: &[f64], betas: &[f64]) -> QuantumCircuit {
126 assert_eq!(gammas.len(), betas.len(), "gammas and betas must have equal length");
127 let n = graph.num_nodes;
128 let p = gammas.len();
129 let mut circuit = QuantumCircuit::new(n);
130
131 // Initial equal superposition
132 for q in 0..n {
133 circuit.h(q);
134 }
135
136 // QAOA layers
137 for layer in 0..p {
138 // Phase separator: Rzz for each edge
139 for &(i, j, w) in &graph.edges {
140 circuit.rzz(i, j, 2.0 * gammas[layer] * w);
141 }
142 // Mixer: Rx on each qubit
143 for q in 0..n {
144 circuit.rx(q, 2.0 * betas[layer]);
145 }
146 }
147
148 circuit
149}
150
151// ---------------------------------------------------------------------------
152// Cost evaluation
153// ---------------------------------------------------------------------------
154
155/// Compute the classical MaxCut value for a given bitstring.
156///
157/// An edge (i, j, w) contributes `w` to the cut if `bitstring[i] != bitstring[j]`.
158pub fn cut_value(graph: &Graph, bitstring: &[bool]) -> f64 {
159 graph
160 .edges
161 .iter()
162 .filter(|(i, j, _)| bitstring[*i as usize] != bitstring[*j as usize])
163 .map(|(_, _, w)| w)
164 .sum()
165}
166
167/// Evaluate the expected MaxCut cost from a QAOA state.
168///
169/// For each edge (i, j) with weight w:
170/// ```text
171/// C_{ij} = w * 0.5 * (1 - <Z_i Z_j>)
172/// ```
173///
174/// The total expected cost is the sum over all edges.
175pub fn evaluate_qaoa_cost(
176 graph: &Graph,
177 gammas: &[f64],
178 betas: &[f64],
179 seed: Option<u64>,
180) -> ruqu_core::error::Result<f64> {
181 let circuit = build_qaoa_circuit(graph, gammas, betas);
182 let sim_config = SimConfig {
183 seed,
184 noise: None,
185 shots: None,
186 };
187 let result = Simulator::run_with_config(&circuit, &sim_config)?;
188
189 let mut cost = 0.0;
190 for &(i, j, w) in &graph.edges {
191 let zz = result.state.expectation_value(&PauliString {
192 ops: vec![(i, PauliOp::Z), (j, PauliOp::Z)],
193 });
194 cost += w * 0.5 * (1.0 - zz);
195 }
196 Ok(cost)
197}
198
199// ---------------------------------------------------------------------------
200// QAOA optimizer
201// ---------------------------------------------------------------------------
202
203/// Run QAOA optimization for MaxCut using gradient ascent with the
204/// parameter-shift rule.
205///
206/// The optimizer maximizes the expected cut value by adjusting gamma and beta
207/// parameters. Convergence is declared when the absolute change in cost
208/// between successive iterations drops below 1e-6.
209///
210/// # Errors
211///
212/// Returns a [`ruqu_core::error::QuantumError`] on simulator failures.
213pub fn run_qaoa(config: &QaoaConfig) -> ruqu_core::error::Result<QaoaResult> {
214 let p = config.p as usize;
215
216 // Initialize parameters at reasonable starting values.
217 let mut gammas = vec![0.5_f64; p];
218 let mut betas = vec![0.5_f64; p];
219 let mut energy_history: Vec<f64> = Vec::with_capacity(config.max_iterations as usize);
220 let mut best_cost = f64::NEG_INFINITY;
221 let mut best_bitstring = vec![false; config.graph.num_nodes as usize];
222 let mut converged = false;
223
224 for iter in 0..config.max_iterations {
225 // ------------------------------------------------------------------
226 // Evaluate current expected cost
227 // ------------------------------------------------------------------
228 let cost = evaluate_qaoa_cost(&config.graph, &gammas, &betas, config.seed)?;
229 energy_history.push(cost);
230
231 // ------------------------------------------------------------------
232 // Track best solution: sample the most probable bitstring
233 // ------------------------------------------------------------------
234 if cost > best_cost {
235 best_cost = cost;
236 let circuit = build_qaoa_circuit(&config.graph, &gammas, &betas);
237 let sim_result = Simulator::run_with_config(
238 &circuit,
239 &SimConfig {
240 seed: config.seed,
241 noise: None,
242 shots: None,
243 },
244 )?;
245 let probs = sim_result.state.probabilities();
246 let best_idx = probs
247 .iter()
248 .enumerate()
249 .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
250 .map(|(i, _)| i)
251 .unwrap_or(0);
252 best_bitstring = (0..config.graph.num_nodes)
253 .map(|q| (best_idx >> q) & 1 == 1)
254 .collect();
255 }
256
257 // ------------------------------------------------------------------
258 // Convergence check
259 // ------------------------------------------------------------------
260 if iter > 0 {
261 let prev = energy_history[iter as usize - 1];
262 if (cost - prev).abs() < 1e-6 {
263 converged = true;
264 break;
265 }
266 }
267
268 // ------------------------------------------------------------------
269 // Gradient ascent via parameter-shift rule
270 // ------------------------------------------------------------------
271 let shift = std::f64::consts::FRAC_PI_2;
272
273 // Update gamma parameters
274 for i in 0..p {
275 let mut gp = gammas.clone();
276 gp[i] += shift;
277 let mut gm = gammas.clone();
278 gm[i] -= shift;
279 let cp = evaluate_qaoa_cost(&config.graph, &gp, &betas, config.seed)?;
280 let cm = evaluate_qaoa_cost(&config.graph, &gm, &betas, config.seed)?;
281 gammas[i] += config.learning_rate * (cp - cm) / 2.0;
282 }
283
284 // Update beta parameters
285 for i in 0..p {
286 let mut bp = betas.clone();
287 bp[i] += shift;
288 let mut bm = betas.clone();
289 bm[i] -= shift;
290 let cp = evaluate_qaoa_cost(&config.graph, &gammas, &bp, config.seed)?;
291 let cm = evaluate_qaoa_cost(&config.graph, &gammas, &bm, config.seed)?;
292 betas[i] += config.learning_rate * (cp - cm) / 2.0;
293 }
294 }
295
296 Ok(QaoaResult {
297 best_cut_value: best_cost,
298 best_bitstring,
299 optimal_gammas: gammas,
300 optimal_betas: betas,
301 energy_history,
302 converged,
303 })
304}
305
306// ---------------------------------------------------------------------------
307// Graph construction helpers
308// ---------------------------------------------------------------------------
309
310/// Create a triangle graph (3 nodes, 3 edges, all weight 1).
311///
312/// The optimal MaxCut is 2 (any partition has exactly one edge within a
313/// group and two edges crossing).
314pub fn triangle_graph() -> Graph {
315 Graph::unweighted(3, vec![(0, 1), (1, 2), (0, 2)])
316}
317
318/// Create a 4-node ring graph (cycle C4, all weight 1).
319///
320/// The optimal MaxCut is 4 (bipartition {0,2} vs {1,3} cuts all edges).
321pub fn ring4_graph() -> Graph {
322 Graph::unweighted(4, vec![(0, 1), (1, 2), (2, 3), (3, 0)])
323}
324
325// ---------------------------------------------------------------------------
326// Tests
327// ---------------------------------------------------------------------------
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332
333 #[test]
334 fn test_graph_construction() {
335 let g = triangle_graph();
336 assert_eq!(g.num_nodes, 3);
337 assert_eq!(g.num_edges(), 3);
338 }
339
340 #[test]
341 fn test_graph_add_edge() {
342 let mut g = Graph::new(4);
343 g.add_edge(0, 1, 2.5);
344 g.add_edge(2, 3, 1.0);
345 assert_eq!(g.num_edges(), 2);
346 }
347
348 #[test]
349 #[should_panic(expected = "node index 5 out of range")]
350 fn test_graph_add_edge_out_of_range() {
351 let mut g = Graph::new(4);
352 g.add_edge(0, 5, 1.0);
353 }
354
355 #[test]
356 fn test_cut_value_triangle() {
357 let g = triangle_graph();
358 // Partition {0} vs {1,2}: edges (0,1) and (0,2) are cut, (1,2) is not.
359 assert_eq!(cut_value(&g, &[true, false, false]), 2.0);
360 // All same partition: no cut.
361 assert_eq!(cut_value(&g, &[false, false, false]), 0.0);
362 }
363
364 #[test]
365 fn test_cut_value_ring4() {
366 let g = ring4_graph();
367 // Optimal: alternate partitions {0,2} vs {1,3} -> cut all 4 edges.
368 assert_eq!(cut_value(&g, &[true, false, true, false]), 4.0);
369 }
370
371 #[test]
372 fn test_build_qaoa_circuit_gate_count() {
373 let g = triangle_graph();
374 let gammas = vec![0.5];
375 let betas = vec![0.3];
376 let circuit = build_qaoa_circuit(&g, &gammas, &betas);
377 assert_eq!(circuit.num_qubits(), 3);
378 // 3 H + 3 Rzz + 3 Rx = 9 gates
379 assert_eq!(circuit.gates().len(), 9);
380 }
381
382 #[test]
383 fn test_cut_value_weighted() {
384 let mut g = Graph::new(3);
385 g.add_edge(0, 1, 2.0);
386 g.add_edge(1, 2, 3.0);
387 // Partition {0,2} vs {1}: cuts both edges -> 2.0 + 3.0 = 5.0
388 assert_eq!(cut_value(&g, &[true, false, true]), 5.0);
389 }
390}