quantrs2_anneal/
qubo.rs

1//! QUBO problem formulation for quantum annealing
2//!
3//! This module provides utilities for formulating optimization problems as
4//! Quadratic Unconstrained Binary Optimization (QUBO) problems.
5
6use std::collections::HashMap;
7use thiserror::Error;
8
9use crate::ising::{IsingError, QuboModel};
10
11/// Errors that can occur when formulating QUBO problems
12#[derive(Error, Debug)]
13pub enum QuboError {
14    /// Error in the underlying Ising model
15    #[error("Ising error: {0}")]
16    IsingError(#[from] IsingError),
17
18    /// Error when formulating a constraint
19    #[error("Constraint error: {0}")]
20    ConstraintError(String),
21
22    /// Error when a variable is already defined
23    #[error("Variable {0} is already defined")]
24    DuplicateVariable(String),
25
26    /// Error when a variable is not found
27    #[error("Variable {0} not found")]
28    VariableNotFound(String),
29}
30
31/// Result type for QUBO problem operations
32pub type QuboResult<T> = Result<T, QuboError>;
33
34/// A variable in a QUBO problem formulation
35#[derive(Debug, Clone, PartialEq, Eq, Hash)]
36pub struct Variable {
37    /// Name of the variable
38    pub name: String,
39    /// Index of the variable in the QUBO model
40    pub index: usize,
41}
42
43impl Variable {
44    /// Create a new variable with the given name and index
45    pub fn new(name: impl Into<String>, index: usize) -> Self {
46        Self {
47            name: name.into(),
48            index,
49        }
50    }
51}
52
53/// A builder for creating QUBO problems
54///
55/// This provides a more convenient interface for formulating optimization problems
56/// than directly working with the `QuboModel`.
57#[derive(Debug, Clone)]
58pub struct QuboBuilder {
59    /// Current number of variables
60    num_vars: usize,
61
62    /// Mapping from variable names to indices
63    var_map: HashMap<String, usize>,
64
65    /// The underlying QUBO model
66    model: QuboModel,
67
68    /// Penalty weight for constraint violations
69    constraint_weight: f64,
70}
71
72impl QuboBuilder {
73    /// Create a new empty QUBO builder
74    #[must_use]
75    pub fn new() -> Self {
76        Self {
77            num_vars: 0,
78            var_map: HashMap::new(),
79            model: QuboModel::new(0),
80            constraint_weight: 10.0,
81        }
82    }
83
84    /// Set the penalty weight for constraint violations
85    pub fn set_constraint_weight(&mut self, weight: f64) -> QuboResult<()> {
86        if !weight.is_finite() || weight <= 0.0 {
87            return Err(QuboError::ConstraintError(format!(
88                "Constraint weight must be positive and finite, got {weight}"
89            )));
90        }
91
92        self.constraint_weight = weight;
93        Ok(())
94    }
95
96    /// Add a new binary variable to the problem
97    pub fn add_variable(&mut self, name: impl Into<String>) -> QuboResult<Variable> {
98        let name = name.into();
99
100        // Check if the variable already exists
101        if self.var_map.contains_key(&name) {
102            return Err(QuboError::DuplicateVariable(name));
103        }
104
105        // Add the variable
106        let index = self.num_vars;
107        self.var_map.insert(name.clone(), index);
108        self.num_vars += 1;
109
110        // Update the QUBO model
111        self.model = QuboModel::new(self.num_vars);
112
113        Ok(Variable::new(name, index))
114    }
115
116    /// Add multiple binary variables to the problem
117    pub fn add_variables(
118        &mut self,
119        names: impl IntoIterator<Item = impl Into<String>>,
120    ) -> QuboResult<Vec<Variable>> {
121        let mut variables = Vec::new();
122        for name in names {
123            variables.push(self.add_variable(name)?);
124        }
125        Ok(variables)
126    }
127
128    /// Get a variable by name
129    pub fn get_variable(&self, name: &str) -> QuboResult<Variable> {
130        match self.var_map.get(name) {
131            Some(&index) => Ok(Variable::new(name, index)),
132            None => Err(QuboError::VariableNotFound(name.to_string())),
133        }
134    }
135
136    /// Set the linear coefficient for a variable
137    pub fn set_linear_term(&mut self, var: &Variable, value: f64) -> QuboResult<()> {
138        // Ensure the variable exists in the model
139        if var.index >= self.num_vars {
140            return Err(QuboError::VariableNotFound(var.name.clone()));
141        }
142
143        Ok(self.model.set_linear(var.index, value)?)
144    }
145
146    /// Set the quadratic coefficient for a pair of variables
147    pub fn set_quadratic_term(
148        &mut self,
149        var1: &Variable,
150        var2: &Variable,
151        value: f64,
152    ) -> QuboResult<()> {
153        // Ensure the variables exist in the model
154        if var1.index >= self.num_vars {
155            return Err(QuboError::VariableNotFound(var1.name.clone()));
156        }
157        if var2.index >= self.num_vars {
158            return Err(QuboError::VariableNotFound(var2.name.clone()));
159        }
160
161        // Check if the variables are the same
162        if var1.index == var2.index {
163            return Err(QuboError::ConstraintError(format!(
164                "Cannot set quadratic term for the same variable: {}",
165                var1.name
166            )));
167        }
168
169        Ok(self.model.set_quadratic(var1.index, var2.index, value)?)
170    }
171
172    /// Set the offset term in the QUBO model
173    pub fn set_offset(&mut self, offset: f64) -> QuboResult<()> {
174        if !offset.is_finite() {
175            return Err(QuboError::ConstraintError(format!(
176                "Offset must be finite, got {offset}"
177            )));
178        }
179
180        self.model.offset = offset;
181        Ok(())
182    }
183
184    /// Add a bias term to a variable (linear coefficient)
185    pub fn add_bias(&mut self, var_index: usize, bias: f64) -> QuboResult<()> {
186        if var_index >= self.num_vars {
187            return Err(QuboError::VariableNotFound(format!(
188                "Variable index {var_index}"
189            )));
190        }
191        let current = self.model.get_linear(var_index)?;
192        self.model.set_linear(var_index, current + bias)?;
193        Ok(())
194    }
195
196    /// Add a coupling term between two variables (quadratic coefficient)
197    pub fn add_coupling(
198        &mut self,
199        var1_index: usize,
200        var2_index: usize,
201        coupling: f64,
202    ) -> QuboResult<()> {
203        if var1_index >= self.num_vars {
204            return Err(QuboError::VariableNotFound(format!(
205                "Variable index {var1_index}"
206            )));
207        }
208        if var2_index >= self.num_vars {
209            return Err(QuboError::VariableNotFound(format!(
210                "Variable index {var2_index}"
211            )));
212        }
213        let current = self.model.get_quadratic(var1_index, var2_index)?;
214        self.model
215            .set_quadratic(var1_index, var2_index, current + coupling)?;
216        Ok(())
217    }
218
219    /// Add a linear objective term to minimize
220    pub fn minimize_linear(&mut self, var: &Variable, coeff: f64) -> QuboResult<()> {
221        self.set_linear_term(var, self.model.get_linear(var.index)? + coeff)
222    }
223
224    /// Add a quadratic objective term to minimize
225    pub fn minimize_quadratic(
226        &mut self,
227        var1: &Variable,
228        var2: &Variable,
229        coeff: f64,
230    ) -> QuboResult<()> {
231        let current = self.model.get_quadratic(var1.index, var2.index)?;
232        self.set_quadratic_term(var1, var2, current + coeff)
233    }
234
235    /// Add a constraint that two variables must be equal
236    ///
237    /// This adds a penalty term: weight * (x1 - x2)^2
238    pub fn constrain_equal(&mut self, var1: &Variable, var2: &Variable) -> QuboResult<()> {
239        // Penalty term: weight * (x1 - x2)^2 = weight * (x1 + x2 - 2*x1*x2)
240        let weight = self.constraint_weight;
241
242        // Add weight to var1's linear term
243        self.set_linear_term(var1, self.model.get_linear(var1.index)? + weight)?;
244
245        // Add weight to var2's linear term
246        self.set_linear_term(var2, self.model.get_linear(var2.index)? + weight)?;
247
248        // Add -2*weight to the quadratic term
249        let current = self.model.get_quadratic(var1.index, var2.index)?;
250        self.set_quadratic_term(var1, var2, 2.0f64.mul_add(-weight, current))
251    }
252
253    /// Add a constraint that two variables must be different
254    ///
255    /// This adds a penalty term: weight * (1 - (x1 - x2)^2)
256    pub fn constrain_different(&mut self, var1: &Variable, var2: &Variable) -> QuboResult<()> {
257        // Penalty term: weight * (1 - (x1 - x2)^2) = weight * (1 - x1 - x2 + 2*x1*x2)
258        let weight = self.constraint_weight;
259
260        // Add -weight to var1's linear term
261        self.set_linear_term(var1, self.model.get_linear(var1.index)? - weight)?;
262
263        // Add -weight to var2's linear term
264        self.set_linear_term(var2, self.model.get_linear(var2.index)? - weight)?;
265
266        // Add 2*weight to the quadratic term
267        let current = self.model.get_quadratic(var1.index, var2.index)?;
268        self.set_quadratic_term(var1, var2, 2.0f64.mul_add(weight, current))?;
269
270        // Add weight to the offset
271        self.model.offset += weight;
272
273        Ok(())
274    }
275
276    /// Add a constraint that exactly one of the variables must be 1
277    ///
278    /// This adds a penalty term: weight * (`sum(x_i)` - 1)^2
279    pub fn constrain_one_hot(&mut self, vars: &[Variable]) -> QuboResult<()> {
280        if vars.is_empty() {
281            return Err(QuboError::ConstraintError(
282                "Empty one-hot constraint".to_string(),
283            ));
284        }
285
286        // Penalty term: weight * (sum(x_i) - 1)^2
287        // = weight * (sum(x_i)^2 - 2*sum(x_i) + 1)
288        // = weight * (sum(x_i) + sum(x_i*x_j for i!=j) - 2*sum(x_i) + 1)
289        // = weight * (sum(x_i*x_j for i!=j) - sum(x_i) + 1)
290        let weight = self.constraint_weight;
291
292        // Add -weight to each variable's linear term
293        for var in vars {
294            self.set_linear_term(var, self.model.get_linear(var.index)? - weight)?;
295        }
296
297        // Add weight to each pair of variables' quadratic term
298        for i in 0..vars.len() {
299            for j in (i + 1)..vars.len() {
300                let current = self.model.get_quadratic(vars[i].index, vars[j].index)?;
301                self.set_quadratic_term(&vars[i], &vars[j], 2.0f64.mul_add(weight, current))?;
302            }
303        }
304
305        // Add weight to the offset
306        self.model.offset += weight;
307
308        Ok(())
309    }
310
311    /// Add a constraint that at most one of the variables can be 1
312    ///
313    /// This adds a penalty term: weight * max(0, `sum(x_i)` - 1)^2
314    pub fn constrain_at_most_one(&mut self, vars: &[Variable]) -> QuboResult<()> {
315        if vars.is_empty() {
316            return Err(QuboError::ConstraintError(
317                "Empty at-most-one constraint".to_string(),
318            ));
319        }
320
321        // Penalty term: weight * max(0, sum(x_i) - 1)^2
322        // For binary variables, this simplifies to:
323        // weight * sum(x_i*x_j for i!=j)
324        let weight = self.constraint_weight;
325
326        // Add weight to each pair of variables' quadratic term
327        for i in 0..vars.len() {
328            for j in (i + 1)..vars.len() {
329                let current = self.model.get_quadratic(vars[i].index, vars[j].index)?;
330                self.set_quadratic_term(&vars[i], &vars[j], 2.0f64.mul_add(weight, current))?;
331            }
332        }
333
334        Ok(())
335    }
336
337    /// Add a constraint that at least one of the variables must be 1
338    ///
339    /// This adds a penalty term: weight * (1 - `sum(x_i))^2`
340    pub fn constrain_at_least_one(&mut self, vars: &[Variable]) -> QuboResult<()> {
341        if vars.is_empty() {
342            return Err(QuboError::ConstraintError(
343                "Empty at-least-one constraint".to_string(),
344            ));
345        }
346
347        // Penalty term: weight * (1 - sum(x_i))^2
348        // = weight * (1 - 2*sum(x_i) + sum(x_i)^2)
349        // = weight * (1 - 2*sum(x_i) + sum(x_i) + sum(x_i*x_j for i!=j))
350        // = weight * (1 - sum(x_i) + sum(x_i*x_j for i!=j))
351        let weight = self.constraint_weight;
352
353        // Add -weight to each variable's linear term
354        for var in vars {
355            self.set_linear_term(
356                var,
357                2.0f64.mul_add(-weight, self.model.get_linear(var.index)?),
358            )?;
359        }
360
361        // Add weight to each pair of variables' quadratic term
362        for i in 0..vars.len() {
363            for j in (i + 1)..vars.len() {
364                let current = self.model.get_quadratic(vars[i].index, vars[j].index)?;
365                self.set_quadratic_term(&vars[i], &vars[j], 2.0f64.mul_add(weight, current))?;
366            }
367        }
368
369        // Add weight to the offset
370        self.model.offset += weight;
371
372        Ok(())
373    }
374
375    /// Add a constraint that the sum of variables equals a target value
376    ///
377    /// This adds a penalty term: weight * (`sum(x_i)` - target)^2
378    pub fn constrain_sum_equal(&mut self, vars: &[Variable], target: f64) -> QuboResult<()> {
379        if vars.is_empty() {
380            return Err(QuboError::ConstraintError(
381                "Empty sum constraint".to_string(),
382            ));
383        }
384
385        // Penalty term: weight * (sum(x_i) - target)^2
386        let weight = self.constraint_weight;
387
388        // Add linear terms: weight * (2*target - 2*sum(x_i))
389        for var in vars {
390            let current = self.model.get_linear(var.index)?;
391            self.set_linear_term(var, weight.mul_add(2.0f64.mul_add(-target, 1.0), current))?;
392        }
393
394        // Add quadratic terms between all pairs: weight * 2*x_i*x_j
395        for i in 0..vars.len() {
396            for j in (i + 1)..vars.len() {
397                let current = self.model.get_quadratic(vars[i].index, vars[j].index)?;
398                self.set_quadratic_term(&vars[i], &vars[j], 2.0f64.mul_add(weight, current))?;
399            }
400        }
401
402        // Add offset: weight * target^2
403        self.model.offset += weight * target * target;
404
405        Ok(())
406    }
407
408    /// Build the final QUBO model
409    #[must_use]
410    pub fn build(&self) -> QuboModel {
411        self.model.clone()
412    }
413
414    /// Get a map of variable names to indices
415    #[must_use]
416    pub fn variable_map(&self) -> HashMap<String, usize> {
417        self.var_map.clone()
418    }
419
420    /// Get the total number of variables
421    #[must_use]
422    pub const fn num_variables(&self) -> usize {
423        self.num_vars
424    }
425
426    /// Get a list of all variables
427    #[must_use]
428    pub fn variables(&self) -> Vec<Variable> {
429        self.var_map
430            .iter()
431            .map(|(name, &index)| Variable::new(name, index))
432            .collect()
433    }
434}
435
436/// Default implementation for `QuboBuilder`
437impl Default for QuboBuilder {
438    fn default() -> Self {
439        Self::new()
440    }
441}
442
443/// Trait for problems that can be formulated as QUBO
444pub trait QuboFormulation {
445    /// Formulate the problem as a QUBO
446    fn to_qubo(&self) -> QuboResult<(QuboModel, HashMap<String, usize>)>;
447
448    /// Interpret the solution to the QUBO in the context of the original problem
449    fn interpret_solution(&self, binary_vars: &[bool]) -> QuboResult<Vec<(String, bool)>>;
450}
451
452/// Implementation of `QuboFormulation` for `QuboModel`
453impl QuboFormulation for QuboModel {
454    fn to_qubo(&self) -> QuboResult<(QuboModel, HashMap<String, usize>)> {
455        // QuboModel is already a QUBO, so we just return a clone
456        let mut var_map = HashMap::new();
457        for i in 0..self.num_variables {
458            var_map.insert(format!("x_{i}"), i);
459        }
460        Ok((self.clone(), var_map))
461    }
462
463    fn interpret_solution(&self, binary_vars: &[bool]) -> QuboResult<Vec<(String, bool)>> {
464        if binary_vars.len() != self.num_variables {
465            return Err(QuboError::ConstraintError(format!(
466                "Solution length {} does not match number of variables {}",
467                binary_vars.len(),
468                self.num_variables
469            )));
470        }
471
472        let mut result = Vec::new();
473        for (i, &value) in binary_vars.iter().enumerate() {
474            result.push((format!("x_{i}"), value));
475        }
476        Ok(result)
477    }
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483
484    #[test]
485    fn test_qubo_builder_basic() {
486        let mut builder = QuboBuilder::new();
487
488        // Add variables
489        let x1 = builder
490            .add_variable("x1")
491            .expect("failed to add variable x1");
492        let x2 = builder
493            .add_variable("x2")
494            .expect("failed to add variable x2");
495        let x3 = builder
496            .add_variable("x3")
497            .expect("failed to add variable x3");
498
499        // Set coefficients
500        builder
501            .set_linear_term(&x1, 2.0)
502            .expect("failed to set linear term for x1");
503        builder
504            .set_linear_term(&x2, -1.0)
505            .expect("failed to set linear term for x2");
506        builder
507            .set_quadratic_term(&x1, &x2, -4.0)
508            .expect("failed to set quadratic term for x1-x2");
509        builder
510            .set_quadratic_term(&x2, &x3, 2.0)
511            .expect("failed to set quadratic term for x2-x3");
512        builder.set_offset(1.5).expect("failed to set offset");
513
514        // Build the QUBO model
515        let model = builder.build();
516
517        // Check linear terms
518        assert_eq!(
519            model.get_linear(0).expect("failed to get linear term 0"),
520            2.0
521        );
522        assert_eq!(
523            model.get_linear(1).expect("failed to get linear term 1"),
524            -1.0
525        );
526        assert_eq!(
527            model.get_linear(2).expect("failed to get linear term 2"),
528            0.0
529        );
530
531        // Check quadratic terms
532        assert_eq!(
533            model
534                .get_quadratic(0, 1)
535                .expect("failed to get quadratic term 0-1"),
536            -4.0
537        );
538        assert_eq!(
539            model
540                .get_quadratic(1, 2)
541                .expect("failed to get quadratic term 1-2"),
542            2.0
543        );
544
545        // Check offset
546        assert_eq!(model.offset, 1.5);
547    }
548
549    #[test]
550    fn test_qubo_builder_objective() {
551        let mut builder = QuboBuilder::new();
552
553        // Add variables
554        let x1 = builder
555            .add_variable("x1")
556            .expect("failed to add variable x1");
557        let x2 = builder
558            .add_variable("x2")
559            .expect("failed to add variable x2");
560
561        // Add objective terms
562        builder
563            .minimize_linear(&x1, 2.0)
564            .expect("failed to minimize linear x1");
565        builder
566            .minimize_linear(&x2, -1.0)
567            .expect("failed to minimize linear x2");
568        builder
569            .minimize_quadratic(&x1, &x2, -4.0)
570            .expect("failed to minimize quadratic x1-x2");
571
572        // Build the QUBO model
573        let model = builder.build();
574
575        // Check linear terms
576        assert_eq!(
577            model.get_linear(0).expect("failed to get linear term 0"),
578            2.0
579        );
580        assert_eq!(
581            model.get_linear(1).expect("failed to get linear term 1"),
582            -1.0
583        );
584
585        // Check quadratic terms
586        assert_eq!(
587            model
588                .get_quadratic(0, 1)
589                .expect("failed to get quadratic term 0-1"),
590            -4.0
591        );
592    }
593
594    #[test]
595    fn test_qubo_builder_constraints() {
596        let mut builder = QuboBuilder::new();
597
598        // Add variables
599        let x1 = builder
600            .add_variable("x1")
601            .expect("failed to add variable x1");
602        let x2 = builder
603            .add_variable("x2")
604            .expect("failed to add variable x2");
605        let x3 = builder
606            .add_variable("x3")
607            .expect("failed to add variable x3");
608
609        // Set constraint weight
610        builder
611            .set_constraint_weight(5.0)
612            .expect("failed to set constraint weight");
613
614        // Add equality constraint
615        builder
616            .constrain_equal(&x1, &x2)
617            .expect("failed to add equality constraint");
618
619        // Add inequality constraint
620        builder
621            .constrain_different(&x2, &x3)
622            .expect("failed to add inequality constraint");
623
624        // Build the QUBO model
625        let model = builder.build();
626
627        // Check the model
628        // x1 = x2 constraint adds: 5 * (x1 - x2)^2 = 5 * (x1 + x2 - 2*x1*x2)
629        // x2 != x3 constraint adds: 5 * (1 - (x2 - x3)^2) = 5 * (1 - x2 - x3 + 2*x2*x3)
630
631        // Check linear terms
632        assert_eq!(
633            model.get_linear(0).expect("failed to get linear term 0"),
634            5.0
635        ); // x1: +5 from equality
636        assert_eq!(
637            model.get_linear(1).expect("failed to get linear term 1"),
638            5.0 - 5.0
639        ); // x2: +5 from equality, -5 from inequality
640        assert_eq!(
641            model.get_linear(2).expect("failed to get linear term 2"),
642            -5.0
643        ); // x3: -5 from inequality
644
645        // Check quadratic terms
646        assert_eq!(
647            model
648                .get_quadratic(0, 1)
649                .expect("failed to get quadratic term 0-1"),
650            -10.0
651        ); // x1*x2: -2*5 from equality
652        assert_eq!(
653            model
654                .get_quadratic(1, 2)
655                .expect("failed to get quadratic term 1-2"),
656            10.0
657        ); // x2*x3: +2*5 from inequality
658
659        // Check offset
660        assert_eq!(model.offset, 5.0); // +5 from inequality
661    }
662
663    #[test]
664    fn test_qubo_builder_one_hot() {
665        let mut builder = QuboBuilder::new();
666
667        // Add variables
668        let x1 = builder
669            .add_variable("x1")
670            .expect("failed to add variable x1");
671        let x2 = builder
672            .add_variable("x2")
673            .expect("failed to add variable x2");
674        let x3 = builder
675            .add_variable("x3")
676            .expect("failed to add variable x3");
677
678        // Set constraint weight
679        builder
680            .set_constraint_weight(5.0)
681            .expect("failed to set constraint weight");
682
683        // Add one-hot constraint
684        builder
685            .constrain_one_hot(&[x1.clone(), x2.clone(), x3.clone()])
686            .expect("failed to add one-hot constraint");
687
688        // Build the QUBO model
689        let model = builder.build();
690
691        // Check the model
692        // One-hot constraint adds: 5 * (x1 + x2 + x3 - 1)^2
693        // = 5 * (x1 + x2 + x3 + x1*x2 + x1*x3 + x2*x3 - 2*(x1 + x2 + x3) + 1)
694        // = 5 * (x1*x2 + x1*x3 + x2*x3 - x1 - x2 - x3 + 1)
695
696        // Check linear terms
697        assert_eq!(
698            model.get_linear(0).expect("failed to get linear term 0"),
699            -5.0
700        ); // x1: -5 from one-hot
701        assert_eq!(
702            model.get_linear(1).expect("failed to get linear term 1"),
703            -5.0
704        ); // x2: -5 from one-hot
705        assert_eq!(
706            model.get_linear(2).expect("failed to get linear term 2"),
707            -5.0
708        ); // x3: -5 from one-hot
709
710        // Check quadratic terms
711        assert_eq!(
712            model
713                .get_quadratic(0, 1)
714                .expect("failed to get quadratic term 0-1"),
715            10.0
716        ); // x1*x2: +2*5 from one-hot
717        assert_eq!(
718            model
719                .get_quadratic(0, 2)
720                .expect("failed to get quadratic term 0-2"),
721            10.0
722        ); // x1*x3: +2*5 from one-hot
723        assert_eq!(
724            model
725                .get_quadratic(1, 2)
726                .expect("failed to get quadratic term 1-2"),
727            10.0
728        ); // x2*x3: +2*5 from one-hot
729
730        // Check offset
731        assert_eq!(model.offset, 5.0); // +5 from one-hot
732    }
733}