quantrs2_anneal/
hobo.rs

1//! Higher-Order Binary Optimization (HOBO) support
2//!
3//! This module provides support for optimization problems with interactions
4//! between more than two variables. These higher-order terms must be reduced
5//! to quadratic form for quantum annealing hardware.
6
7use crate::compression::CompressedQubo;
8use crate::ising::{IsingError, IsingResult};
9use std::collections::{HashMap, HashSet};
10
11/// Represents a higher-order term with its coefficient
12#[derive(Debug, Clone)]
13pub struct HigherOrderTerm {
14    /// Variables involved in this term (sorted)
15    pub variables: Vec<usize>,
16    /// Coefficient of the term
17    pub coefficient: f64,
18}
19
20impl HigherOrderTerm {
21    /// Create a new higher-order term
22    #[must_use]
23    pub fn new(mut variables: Vec<usize>, coefficient: f64) -> Self {
24        variables.sort_unstable();
25        variables.dedup();
26        Self {
27            variables,
28            coefficient,
29        }
30    }
31
32    /// Get the order of this term (number of variables)
33    #[must_use]
34    pub fn order(&self) -> usize {
35        self.variables.len()
36    }
37
38    /// Check if this term contains a specific variable
39    #[must_use]
40    pub fn contains(&self, var: usize) -> bool {
41        self.variables.binary_search(&var).is_ok()
42    }
43}
44
45/// Higher-Order Binary Optimization problem
46#[derive(Debug, Clone)]
47pub struct HoboProblem {
48    /// Number of variables
49    pub num_vars: usize,
50    /// Linear terms (order 1)
51    pub linear_terms: HashMap<usize, f64>,
52    /// Quadratic terms (order 2)
53    pub quadratic_terms: HashMap<(usize, usize), f64>,
54    /// Higher-order terms (order >= 3)
55    pub higher_order_terms: Vec<HigherOrderTerm>,
56    /// Constant offset
57    pub offset: f64,
58}
59
60impl HoboProblem {
61    /// Create a new HOBO problem
62    #[must_use]
63    pub fn new(num_vars: usize) -> Self {
64        Self {
65            num_vars,
66            linear_terms: HashMap::new(),
67            quadratic_terms: HashMap::new(),
68            higher_order_terms: Vec::new(),
69            offset: 0.0,
70        }
71    }
72
73    /// Add a linear term
74    pub fn add_linear(&mut self, var: usize, coefficient: f64) {
75        if var >= self.num_vars {
76            self.num_vars = var + 1;
77        }
78        *self.linear_terms.entry(var).or_insert(0.0) += coefficient;
79    }
80
81    /// Add a quadratic term
82    pub fn add_quadratic(&mut self, var1: usize, var2: usize, coefficient: f64) {
83        let max_var = var1.max(var2);
84        if max_var >= self.num_vars {
85            self.num_vars = max_var + 1;
86        }
87
88        if var1 == var2 {
89            self.add_linear(var1, coefficient);
90        } else {
91            let (i, j) = if var1 < var2 {
92                (var1, var2)
93            } else {
94                (var2, var1)
95            };
96            *self.quadratic_terms.entry((i, j)).or_insert(0.0) += coefficient;
97        }
98    }
99
100    /// Add a higher-order term
101    pub fn add_higher_order(&mut self, variables: Vec<usize>, coefficient: f64) {
102        if variables.len() < 3 {
103            // Handle as linear or quadratic
104            match variables.len() {
105                0 => self.offset += coefficient,
106                1 => self.add_linear(variables[0], coefficient),
107                2 => self.add_quadratic(variables[0], variables[1], coefficient),
108                _ => unreachable!(),
109            }
110        } else {
111            let term = HigherOrderTerm::new(variables, coefficient);
112
113            // Update num_vars if needed
114            if let Some(&max_var) = term.variables.last() {
115                if max_var >= self.num_vars {
116                    self.num_vars = max_var + 1;
117                }
118            }
119
120            self.higher_order_terms.push(term);
121        }
122    }
123
124    /// Get the maximum order of terms in this problem
125    pub fn max_order(&self) -> usize {
126        self.higher_order_terms
127            .iter()
128            .map(HigherOrderTerm::order)
129            .max()
130            .unwrap_or(2)
131            .max(if self.quadratic_terms.is_empty() {
132                0
133            } else {
134                2
135            })
136            .max(usize::from(!self.linear_terms.is_empty()))
137    }
138
139    /// Check if this is already a QUBO (no higher-order terms)
140    #[must_use]
141    pub fn is_qubo(&self) -> bool {
142        self.higher_order_terms.is_empty()
143    }
144
145    /// Convert to QUBO using auxiliary variables
146    pub fn to_qubo(&self, reduction_method: ReductionMethod) -> IsingResult<QuboReduction> {
147        match reduction_method {
148            ReductionMethod::SubstitutionMethod => self.reduce_by_substitution(),
149            ReductionMethod::MinimumVertexCover => self.reduce_by_mvc(),
150            ReductionMethod::BooleanProduct => self.reduce_by_boolean_product(),
151        }
152    }
153
154    /// Reduce using substitution method
155    fn reduce_by_substitution(&self) -> IsingResult<QuboReduction> {
156        let mut reduction = QuboReduction::new(self.num_vars);
157
158        // Copy linear and quadratic terms
159        for (&var, &coeff) in &self.linear_terms {
160            reduction.qubo.add_linear(var, coeff);
161        }
162        for (&(i, j), &coeff) in &self.quadratic_terms {
163            reduction.qubo.add_quadratic(i, j, coeff);
164        }
165        reduction.qubo.offset = self.offset;
166
167        // Process each higher-order term
168        for hot in &self.higher_order_terms {
169            if hot.order() < 3 {
170                continue;
171            }
172
173            // Recursively reduce the term
174            self.reduce_term_substitution(hot, &mut reduction)?;
175        }
176
177        Ok(reduction)
178    }
179
180    /// Reduce a single term using substitution
181    fn reduce_term_substitution(
182        &self,
183        term: &HigherOrderTerm,
184        reduction: &mut QuboReduction,
185    ) -> IsingResult<()> {
186        let mut current_vars = term.variables.clone();
187        let mut current_coeff = term.coefficient;
188
189        // Reduce order iteratively
190        while current_vars.len() > 2 {
191            // Take first two variables
192            let v1 = current_vars[0];
193            let v2 = current_vars[1];
194
195            // Create auxiliary variable for v1 * v2
196            let aux_var = reduction.qubo.num_vars;
197            reduction.qubo.num_vars += 1;
198
199            // Add reduction info
200            reduction.auxiliary_vars.push(AuxiliaryVariable {
201                index: aux_var,
202                reduction_type: ReductionType::Product(v1, v2),
203                penalty_weight: 3.0 * current_coeff.abs(),
204            });
205
206            // Add penalty term: 3*v1*v2 - 2*v1*aux - 2*v2*aux + aux
207            let penalty = 3.0 * current_coeff.abs();
208            reduction.qubo.add_quadratic(v1, v2, penalty * 3.0);
209            reduction.qubo.add_quadratic(v1, aux_var, -penalty * 2.0);
210            reduction.qubo.add_quadratic(v2, aux_var, -penalty * 2.0);
211            reduction.qubo.add_linear(aux_var, penalty);
212
213            // Replace v1 and v2 with aux_var in the term
214            current_vars = current_vars[2..].to_vec();
215            current_vars.push(aux_var);
216            current_vars.sort_unstable();
217        }
218
219        // Add the final quadratic term
220        if current_vars.len() == 2 {
221            reduction
222                .qubo
223                .add_quadratic(current_vars[0], current_vars[1], current_coeff);
224        } else if current_vars.len() == 1 {
225            reduction.qubo.add_linear(current_vars[0], current_coeff);
226        }
227
228        Ok(())
229    }
230
231    /// Reduce using minimum vertex cover method
232    fn reduce_by_mvc(&self) -> IsingResult<QuboReduction> {
233        // This is a more advanced reduction method that finds an optimal
234        // set of auxiliary variables to cover all higher-order terms
235        // For now, fall back to substitution method
236        self.reduce_by_substitution()
237    }
238
239    /// Reduce using boolean product method
240    fn reduce_by_boolean_product(&self) -> IsingResult<QuboReduction> {
241        let mut reduction = QuboReduction::new(self.num_vars);
242
243        // Copy linear and quadratic terms
244        for (&var, &coeff) in &self.linear_terms {
245            reduction.qubo.add_linear(var, coeff);
246        }
247        for (&(i, j), &coeff) in &self.quadratic_terms {
248            reduction.qubo.add_quadratic(i, j, coeff);
249        }
250        reduction.qubo.offset = self.offset;
251
252        // Process each higher-order term
253        for hot in &self.higher_order_terms {
254            if hot.order() < 3 {
255                continue;
256            }
257
258            // Create a single auxiliary variable for the entire term
259            let aux_var = reduction.qubo.num_vars;
260            reduction.qubo.num_vars += 1;
261
262            // Add reduction info
263            reduction.auxiliary_vars.push(AuxiliaryVariable {
264                index: aux_var,
265                reduction_type: ReductionType::MultiProduct(hot.variables.clone()),
266                penalty_weight: hot.coefficient.abs() * (hot.order() as f64),
267            });
268
269            // Use generalized penalty for boolean product
270            // aux = prod(x_i) is enforced by:
271            // (k-1)*aux + sum_i(x_i) - 2*aux*sum_i(x_i) >= 0
272            // with equality when constraint is satisfied
273            let k = hot.order();
274            let penalty = hot.coefficient.abs() * (k as f64);
275
276            // Add penalty terms
277            reduction.qubo.add_linear(aux_var, penalty * (k - 1) as f64);
278
279            for &var in &hot.variables {
280                reduction.qubo.add_linear(var, penalty);
281                reduction.qubo.add_quadratic(aux_var, var, -2.0 * penalty);
282            }
283
284            // Add the original coefficient
285            reduction.qubo.add_linear(aux_var, hot.coefficient);
286        }
287
288        Ok(reduction)
289    }
290
291    /// Evaluate the energy for a given solution
292    #[must_use]
293    pub fn evaluate(&self, solution: &[bool]) -> f64 {
294        let mut energy = self.offset;
295
296        // Linear terms
297        for (&var, &coeff) in &self.linear_terms {
298            if var < solution.len() && solution[var] {
299                energy += coeff;
300            }
301        }
302
303        // Quadratic terms
304        for (&(i, j), &coeff) in &self.quadratic_terms {
305            if i < solution.len() && j < solution.len() && solution[i] && solution[j] {
306                energy += coeff;
307            }
308        }
309
310        // Higher-order terms
311        for term in &self.higher_order_terms {
312            let mut product = true;
313            for &var in &term.variables {
314                if var >= solution.len() || !solution[var] {
315                    product = false;
316                    break;
317                }
318            }
319            if product {
320                energy += term.coefficient;
321            }
322        }
323
324        energy
325    }
326}
327
328/// Methods for reducing HOBO to QUBO
329#[derive(Debug, Clone, Copy, PartialEq, Eq)]
330pub enum ReductionMethod {
331    /// Simple substitution method (easy but may use many auxiliary variables)
332    SubstitutionMethod,
333    /// Minimum vertex cover method (more efficient)
334    MinimumVertexCover,
335    /// Boolean product with generalized penalties
336    BooleanProduct,
337}
338
339/// Type of reduction for an auxiliary variable
340#[derive(Debug, Clone)]
341pub enum ReductionType {
342    /// Auxiliary variable represents product of two variables
343    Product(usize, usize),
344    /// Auxiliary variable represents product of multiple variables
345    MultiProduct(Vec<usize>),
346    /// Auxiliary variable for other reduction types
347    Custom(String),
348}
349
350/// Information about an auxiliary variable
351#[derive(Debug, Clone)]
352pub struct AuxiliaryVariable {
353    /// Index of the auxiliary variable
354    pub index: usize,
355    /// Type of reduction this variable represents
356    pub reduction_type: ReductionType,
357    /// Penalty weight used for this reduction
358    pub penalty_weight: f64,
359}
360
361/// Result of HOBO to QUBO reduction
362#[derive(Debug, Clone)]
363pub struct QuboReduction {
364    /// The resulting QUBO problem
365    pub qubo: CompressedQubo,
366    /// Information about auxiliary variables
367    pub auxiliary_vars: Vec<AuxiliaryVariable>,
368    /// Mapping from original to new variables
369    pub variable_mapping: HashMap<usize, usize>,
370}
371
372impl QuboReduction {
373    /// Create a new QUBO reduction
374    fn new(original_vars: usize) -> Self {
375        let mut variable_mapping = HashMap::new();
376        for i in 0..original_vars {
377            variable_mapping.insert(i, i);
378        }
379
380        Self {
381            qubo: CompressedQubo::new(original_vars),
382            auxiliary_vars: Vec::new(),
383            variable_mapping,
384        }
385    }
386
387    /// Extract solution for original variables from QUBO solution
388    #[must_use]
389    pub fn extract_original_solution(&self, qubo_solution: &[bool]) -> Vec<bool> {
390        let mut original_solution = vec![false; self.variable_mapping.len()];
391
392        for (&orig_var, &new_var) in &self.variable_mapping {
393            if new_var < qubo_solution.len() {
394                original_solution[orig_var] = qubo_solution[new_var];
395            }
396        }
397
398        original_solution
399    }
400
401    /// Verify that auxiliary variable constraints are satisfied
402    #[must_use]
403    pub fn verify_constraints(&self, solution: &[bool]) -> ConstraintViolations {
404        let mut violations = ConstraintViolations::default();
405
406        for aux in &self.auxiliary_vars {
407            if aux.index >= solution.len() {
408                violations.missing_variables += 1;
409                continue;
410            }
411
412            let aux_value = solution[aux.index];
413
414            match &aux.reduction_type {
415                ReductionType::Product(v1, v2) => {
416                    if *v1 < solution.len() && *v2 < solution.len() {
417                        let expected = solution[*v1] && solution[*v2];
418                        if aux_value != expected {
419                            violations.product_violations += 1;
420                        }
421                    }
422                }
423                ReductionType::MultiProduct(vars) => {
424                    let expected = vars.iter().all(|&v| v < solution.len() && solution[v]);
425                    if aux_value != expected {
426                        violations.multi_product_violations += 1;
427                    }
428                }
429                ReductionType::Custom(_) => {}
430            }
431        }
432
433        violations.total = violations.product_violations
434            + violations.multi_product_violations
435            + violations.missing_variables;
436
437        violations
438    }
439}
440
441/// Statistics about constraint violations
442#[derive(Debug, Clone, Default)]
443pub struct ConstraintViolations {
444    /// Total number of violations
445    pub total: usize,
446    /// Product constraint violations
447    pub product_violations: usize,
448    /// Multi-product constraint violations
449    pub multi_product_violations: usize,
450    /// Missing variables in solution
451    pub missing_variables: usize,
452}
453
454/// HOBO problem analyzer
455pub struct HoboAnalyzer;
456
457impl HoboAnalyzer {
458    /// Analyze a HOBO problem and provide statistics
459    #[must_use]
460    pub fn analyze(problem: &HoboProblem) -> HoboStats {
461        let mut stats = HoboStats::default();
462
463        stats.num_variables = problem.num_vars;
464        stats.num_linear_terms = problem.linear_terms.len();
465        stats.num_quadratic_terms = problem.quadratic_terms.len();
466        stats.num_higher_order_terms = problem.higher_order_terms.len();
467
468        // Analyze orders
469        let mut order_counts = HashMap::new();
470        for term in &problem.higher_order_terms {
471            *order_counts.entry(term.order()).or_insert(0) += 1;
472        }
473
474        if !order_counts.is_empty() {
475            // Safety: we just checked that order_counts is not empty
476            stats.max_order = order_counts.keys().copied().max().unwrap_or(0);
477            stats.order_distribution = order_counts;
478        } else if !problem.quadratic_terms.is_empty() {
479            stats.max_order = 2;
480        } else if !problem.linear_terms.is_empty() {
481            stats.max_order = 1;
482        }
483
484        // Estimate reduction complexity
485        stats.estimated_aux_vars_substitution = problem
486            .higher_order_terms
487            .iter()
488            .map(|term| term.order().saturating_sub(2))
489            .sum();
490
491        stats.estimated_aux_vars_boolean = problem.higher_order_terms.len();
492
493        stats
494    }
495}
496
497/// Statistics about a HOBO problem
498#[derive(Debug, Clone, Default)]
499pub struct HoboStats {
500    /// Number of variables
501    pub num_variables: usize,
502    /// Number of linear terms
503    pub num_linear_terms: usize,
504    /// Number of quadratic terms
505    pub num_quadratic_terms: usize,
506    /// Number of higher-order terms
507    pub num_higher_order_terms: usize,
508    /// Maximum order of any term
509    pub max_order: usize,
510    /// Distribution of term orders
511    pub order_distribution: HashMap<usize, usize>,
512    /// Estimated auxiliary variables needed (substitution method)
513    pub estimated_aux_vars_substitution: usize,
514    /// Estimated auxiliary variables needed (boolean product)
515    pub estimated_aux_vars_boolean: usize,
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521
522    #[test]
523    fn test_hobo_creation() {
524        let mut hobo = HoboProblem::new(4);
525
526        // Add various order terms
527        hobo.add_linear(0, 1.0);
528        hobo.add_quadratic(0, 1, -2.0);
529        hobo.add_higher_order(vec![0, 1, 2], 3.0);
530        hobo.add_higher_order(vec![1, 2, 3], -1.5);
531
532        assert_eq!(hobo.num_vars, 4);
533        assert_eq!(hobo.linear_terms.len(), 1);
534        assert_eq!(hobo.quadratic_terms.len(), 1);
535        assert_eq!(hobo.higher_order_terms.len(), 2);
536        assert_eq!(hobo.max_order(), 3);
537    }
538
539    #[test]
540    fn test_substitution_reduction() {
541        let mut hobo = HoboProblem::new(3);
542        hobo.add_higher_order(vec![0, 1, 2], 1.0);
543
544        let reduction = hobo
545            .to_qubo(ReductionMethod::SubstitutionMethod)
546            .expect("substitution reduction should succeed");
547
548        // Should have created 1 auxiliary variable
549        assert_eq!(reduction.auxiliary_vars.len(), 1);
550        assert_eq!(reduction.qubo.num_vars, 4); // 3 original + 1 auxiliary
551
552        // Test that the reduction preserves the problem
553        let test_solution = vec![true, true, true, true]; // All variables true
554        let violations = reduction.verify_constraints(&test_solution);
555        assert_eq!(violations.total, 0);
556    }
557
558    #[test]
559    fn test_boolean_product_reduction() {
560        let mut hobo = HoboProblem::new(4);
561        hobo.add_higher_order(vec![0, 1, 2, 3], 2.0);
562
563        let reduction = hobo
564            .to_qubo(ReductionMethod::BooleanProduct)
565            .expect("boolean product reduction should succeed");
566
567        // Should have created 1 auxiliary variable for the 4-way term
568        assert_eq!(reduction.auxiliary_vars.len(), 1);
569        assert_eq!(reduction.qubo.num_vars, 5); // 4 original + 1 auxiliary
570    }
571
572    #[test]
573    fn test_hobo_evaluation() {
574        let mut hobo = HoboProblem::new(3);
575        hobo.add_linear(0, 1.0);
576        hobo.add_quadratic(0, 1, -2.0);
577        hobo.add_higher_order(vec![0, 1, 2], 3.0);
578
579        // Test various solutions
580        assert_eq!(hobo.evaluate(&[false, false, false]), 0.0);
581        assert_eq!(hobo.evaluate(&[true, false, false]), 1.0);
582        assert_eq!(hobo.evaluate(&[true, true, false]), 1.0 - 2.0);
583        assert_eq!(hobo.evaluate(&[true, true, true]), 1.0 - 2.0 + 3.0);
584    }
585
586    #[test]
587    fn test_hobo_analyzer() {
588        let mut hobo = HoboProblem::new(5);
589        hobo.add_linear(0, 1.0);
590        hobo.add_quadratic(0, 1, -1.0);
591        hobo.add_higher_order(vec![0, 1, 2], 1.0);
592        hobo.add_higher_order(vec![1, 2, 3], 1.0);
593        hobo.add_higher_order(vec![0, 1, 2, 3, 4], 1.0);
594
595        let stats = HoboAnalyzer::analyze(&hobo);
596
597        assert_eq!(stats.num_variables, 5);
598        assert_eq!(stats.num_higher_order_terms, 3);
599        assert_eq!(stats.max_order, 5);
600        assert_eq!(stats.estimated_aux_vars_substitution, 5); // 1 + 1 + 3
601        assert_eq!(stats.estimated_aux_vars_boolean, 3);
602    }
603}