Skip to main content

problemreductions/rules/
ksatisfiability_qubo.rs

1//! Reduction from KSatisfiability to QUBO (Max-K-SAT).
2//!
3//! For K=2 (quadratic penalty), each clause contributes to Q based on literal signs:
4//! - (x_i ∨ x_j): penalty (1-x_i)(1-x_j) → Q[i][i]-=1, Q[j][j]-=1, Q[i][j]+=1, const+=1
5//! - (¬x_i ∨ x_j): penalty x_i(1-x_j) → Q[i][i]+=1, Q[i][j]-=1
6//! - (x_i ∨ ¬x_j): penalty (1-x_i)x_j → Q[j][j]+=1, Q[i][j]-=1
7//! - (¬x_i ∨ ¬x_j): penalty x_i·x_j → Q[i][j]+=1
8//!
9//! For K≥3, we use the Rosenberg quadratization to reduce degree-K penalty terms
10//! to quadratic form by introducing auxiliary variables. Each clause of K literals
11//! requires K−2 auxiliary variables.
12//!
13//! CNFClause uses 1-indexed signed integers: positive = variable, negative = negated.
14
15use crate::models::algebraic::QUBO;
16use crate::models::formula::KSatisfiability;
17use crate::reduction;
18use crate::rules::traits::{ReduceTo, ReductionResult};
19use crate::variant::{K2, K3};
20/// Result of reducing KSatisfiability to QUBO.
21#[derive(Debug, Clone)]
22pub struct ReductionKSatToQUBO {
23    target: QUBO<i64>,
24    source_num_vars: usize,
25    zero_penalty_energy: i64,
26}
27
28impl ReductionResult for ReductionKSatToQUBO {
29    type Source = KSatisfiability<K2>;
30    type Target = QUBO<i64>;
31
32    fn target_problem(&self) -> &Self::Target {
33        &self.target
34    }
35
36    fn extract_solution(
37        &self,
38        target_solution: &<Self::Target as crate::traits::Problem>::Solution,
39    ) -> crate::rules::ExtractionResult<<Self::Source as crate::traits::Problem>::Solution> {
40        let value =
41            crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?;
42        if !crate::rules::AggregateReductionResult::extract_value(self, value).0 {
43            return Err(crate::rules::ExtractionError::invalid(
44                "QUBO energy does not meet the SAT zero-penalty threshold",
45            ));
46        }
47        Ok(target_solution[..self.source_num_vars].to_vec())
48    }
49}
50
51/// Result of reducing `KSatisfiability<K3>` to QUBO.
52#[derive(Debug, Clone)]
53pub struct Reduction3SATToQUBO {
54    target: QUBO<i64>,
55    source_num_vars: usize,
56    zero_penalty_energy: i64,
57}
58
59impl ReductionResult for Reduction3SATToQUBO {
60    type Source = KSatisfiability<K3>;
61    type Target = QUBO<i64>;
62
63    fn target_problem(&self) -> &Self::Target {
64        &self.target
65    }
66
67    fn extract_solution(
68        &self,
69        target_solution: &<Self::Target as crate::traits::Problem>::Solution,
70    ) -> crate::rules::ExtractionResult<<Self::Source as crate::traits::Problem>::Solution> {
71        let value =
72            crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?;
73        if !crate::rules::AggregateReductionResult::extract_value(self, value).0 {
74            return Err(crate::rules::ExtractionError::invalid(
75                "QUBO energy does not meet the SAT zero-penalty threshold",
76            ));
77        }
78        Ok(target_solution[..self.source_num_vars].to_vec())
79    }
80}
81
82/// Add the quadratic penalty term for a 2-literal clause to the QUBO matrix.
83///
84/// For clause (l_i ∨ l_j), the penalty for the clause being unsatisfied is
85/// the product of the complemented literals.
86fn add_coefficient(
87    matrix: &mut [Vec<i64>],
88    row: usize,
89    column: usize,
90    coefficient: i64,
91) -> Result<(), &'static str> {
92    matrix[row][column] = matrix[row][column]
93        .checked_add(coefficient)
94        .ok_or("adding a SAT QUBO coefficient")?;
95    Ok(())
96}
97
98fn add_2sat_clause_penalty(
99    matrix: &mut [Vec<i64>],
100    lits: &[(usize, bool)],
101) -> Result<(), &'static str> {
102    assert_eq!(lits.len(), 2, "Expected 2-literal clause");
103
104    let (var_i, neg_i) = lits[0];
105    let (var_j, neg_j) = lits[1];
106
107    // Ensure i <= j for upper-triangular form
108    let (i, j, ni, nj) = if var_i <= var_j {
109        (var_i, var_j, neg_i, neg_j)
110    } else {
111        (var_j, var_i, neg_j, neg_i)
112    };
113
114    match (ni, nj) {
115        (false, false) => {
116            // (x_i ∨ x_j): penalty = (1-x_i)(1-x_j) = 1 - x_i - x_j + x_i·x_j
117            add_coefficient(matrix, i, i, -1)?;
118            add_coefficient(matrix, j, j, -1)?;
119            add_coefficient(matrix, i, j, 1)?;
120        }
121        (true, false) => {
122            // (¬x_i ∨ x_j): penalty = x_i(1-x_j) = x_i - x_i·x_j
123            add_coefficient(matrix, i, i, 1)?;
124            add_coefficient(matrix, i, j, -1)?;
125        }
126        (false, true) => {
127            // (x_i ∨ ¬x_j): penalty = (1-x_i)x_j = x_j - x_i·x_j
128            add_coefficient(matrix, j, j, 1)?;
129            add_coefficient(matrix, i, j, -1)?;
130        }
131        (true, true) => {
132            // (¬x_i ∨ ¬x_j): penalty = x_i·x_j
133            add_coefficient(matrix, i, j, 1)?;
134        }
135    }
136    Ok(())
137}
138
139/// Add the QUBO terms for a 3-literal clause using Rosenberg quadratization.
140///
141/// For clause (l1 ∨ l2 ∨ l3), the penalty for not satisfying the clause is:
142///   P = (1-l1)(1-l2)(1-l3) = y1·y2·y3
143/// where yi = 1 - li (complement of literal).
144///
145/// We introduce one auxiliary variable `a` and quadratize using the substitution
146/// a = y1·y2, adding penalty M·(y1·y2 - 2·y1·a - 2·y2·a + 3·a) where M is a
147/// sufficiently large penalty (M = 2 suffices for Max-SAT).
148///
149/// The resulting quadratic form is:
150///   H = a·y3 + M·(y1·y2 - 2·y1·a - 2·y2·a + 3·a)
151///
152/// `aux_var` is the 0-indexed auxiliary variable.
153fn add_3sat_clause_penalty(
154    matrix: &mut [Vec<i64>],
155    lits: &[(usize, bool)],
156    aux_var: usize,
157) -> Result<(), &'static str> {
158    assert_eq!(lits.len(), 3, "Expected 3-literal clause");
159    let penalty = 2; // Rosenberg penalty weight
160
161    let (v1, n1) = lits[0];
162    let (v2, n2) = lits[1];
163    let (v3, n3) = lits[2];
164    let a = aux_var;
165
166    // We need to express yi = (1 - li) in terms of xi:
167    //   If literal is positive (li = xi): yi = 1 - xi
168    //   If literal is negated (li = 1 - xi): yi = xi
169    //
170    // So yi = xi if negated, yi = 1 - xi if positive.
171    //
172    // We compute the QUBO terms for:
173    //   H = a·y3 + M·(y1·y2 - 2·y1·a - 2·y2·a + 3·a)
174    //
175    // Each term is expanded using yi = xi (if negated) or yi = 1-xi (if positive).
176
177    // Helper: add coefficient * yi * yj to the matrix
178    // where yi depends on variable vi and negation ni
179    let add_yy = |matrix: &mut [Vec<i64>],
180                  vi: usize,
181                  ni: bool,
182                  vj: usize,
183                  nj: bool,
184                  coeff: i64|
185     -> Result<(), &'static str> {
186        // yi = xi if ni (negated literal), yi = 1 - xi if !ni (positive literal)
187        // yi * yj expansion:
188        if vi == vj {
189            // Same variable: yi * yj
190            // Both complemented the same way means yi = yj, so yi*yj = yi (binary)
191            // If ni == nj: yi*yj = yi^2 = yi (binary), add coeff * yi
192            // If ni != nj: yi * yj = xi * (1-xi) = 0 (always), add nothing
193            if ni == nj {
194                // yi * yi = yi (binary)
195                if ni {
196                    // yi = xi, add coeff * xi
197                    add_coefficient(matrix, vi, vi, coeff)?;
198                } else {
199                    // yi = 1 - xi, add coeff * (1 - xi) = coeff - coeff * xi
200                    // constant term ignored in QUBO (offset), diagonal:
201                    add_coefficient(matrix, vi, vi, -coeff)?;
202                }
203            }
204            // else: xi * (1-xi) = 0, nothing to add
205            return Ok(());
206        }
207        // Different variables: yi * yj
208        let (lo, hi, lo_neg, hi_neg) = if vi < vj {
209            (vi, vj, ni, nj)
210        } else {
211            (vj, vi, nj, ni)
212        };
213        // yi = xi if neg, else 1-xi
214        // yj = xj if neg, else 1-xj
215        // yi*yj = (xi if neg_i else 1-xi) * (xj if neg_j else 1-xj)
216        match (lo_neg, hi_neg) {
217            (true, true) => {
218                // xi * xj
219                add_coefficient(matrix, lo, hi, coeff)?;
220            }
221            (true, false) => {
222                // xi * (1 - xj) = xi - xi*xj
223                add_coefficient(matrix, lo, lo, coeff)?;
224                add_coefficient(matrix, lo, hi, -coeff)?;
225            }
226            (false, true) => {
227                // (1 - xi) * xj = xj - xi*xj
228                add_coefficient(matrix, hi, hi, coeff)?;
229                add_coefficient(matrix, lo, hi, -coeff)?;
230            }
231            (false, false) => {
232                // (1-xi)(1-xj) = 1 - xi - xj + xi*xj
233                // constant 1 ignored (offset)
234                add_coefficient(matrix, lo, lo, -coeff)?;
235                add_coefficient(matrix, hi, hi, -coeff)?;
236                add_coefficient(matrix, lo, hi, coeff)?;
237            }
238        }
239        Ok(())
240    };
241
242    // Helper: add coefficient * yi * a to the matrix
243    // where yi depends on variable vi and negation ni, a is aux variable
244    let add_ya = |matrix: &mut [Vec<i64>],
245                  vi: usize,
246                  ni: bool,
247                  a: usize,
248                  coeff: i64|
249     -> Result<(), &'static str> {
250        // yi = xi if ni (negated literal), yi = 1-xi if !ni (positive literal)
251        // yi * a:
252        let (lo, hi) = if vi < a { (vi, a) } else { (a, vi) };
253        if ni {
254            // yi = xi, so yi * a = xi * a
255            add_coefficient(matrix, lo, hi, coeff)?;
256        } else {
257            // yi = 1 - xi, so yi * a = a - xi * a
258            add_coefficient(matrix, a, a, coeff)?;
259            add_coefficient(matrix, lo, hi, -coeff)?;
260        }
261        Ok(())
262    };
263
264    // Term 1: a * y3 (coefficient = 1)
265    add_ya(matrix, v3, n3, a, 1)?;
266
267    // Term 2: M * y1 * y2
268    add_yy(matrix, v1, n1, v2, n2, penalty)?;
269
270    // Term 3: -2M * y1 * a
271    add_ya(matrix, v1, n1, a, -2 * penalty)?;
272
273    // Term 4: -2M * y2 * a
274    add_ya(matrix, v2, n2, a, -2 * penalty)?;
275
276    // Term 5: 3M * a (linear)
277    // a is a binary variable, a^2 = a, so linear a → diagonal
278    add_coefficient(matrix, a, a, 3 * penalty)?;
279
280    Ok(())
281}
282
283/// Expand clause penalties and retain the constant omitted by QUBO.
284/// K3 reserves one auxiliary per clause, including free auxiliaries for short
285/// clauses; K2 reserves none. The source constructor validates clause widths.
286fn build_qubo_matrix(
287    num_vars: usize,
288    clauses: &[crate::models::formula::CNFClause],
289    num_aux: usize,
290) -> Result<(Vec<Vec<i64>>, i64), &'static str> {
291    let total = num_vars
292        .checked_add(num_aux)
293        .ok_or("computing the number of SAT QUBO variables")?;
294    total
295        .checked_mul(total)
296        .ok_or("computing the SAT QUBO dense matrix entry count")?;
297    let mut matrix = vec![vec![0; total]; total];
298    let mut constant = 0i64;
299    for (idx, clause) in clauses.iter().enumerate() {
300        let literals: Vec<_> = clause
301            .variables()
302            .into_iter()
303            .zip(&clause.literals)
304            .map(|(variable, &literal)| (variable, literal < 0))
305            .collect();
306        let offset = match literals.as_slice() {
307            [] => 1,
308            &[(v, neg)] => {
309                add_coefficient(&mut matrix, v, v, if neg { 1 } else { -1 })?;
310                i64::from(!neg)
311            }
312            &[(_, n1), (_, n2)] => {
313                add_2sat_clause_penalty(&mut matrix, &literals)?;
314                i64::from(!n1 && !n2)
315            }
316            &[(_, n1), (_, n2), _] => {
317                add_3sat_clause_penalty(&mut matrix, &literals, num_vars + idx)?;
318                2 * i64::from(!n1 && !n2)
319            }
320            _ => unreachable!("the source validates clause width at most three"),
321        };
322        constant = constant
323            .checked_add(offset)
324            .ok_or("accumulating the SAT QUBO constant")?;
325    }
326    Ok((matrix, constant))
327}
328
329impl crate::rules::AggregateReductionResult for ReductionKSatToQUBO {
330    type Source = KSatisfiability<K2>;
331    type Target = QUBO<i64>;
332    fn target_problem(&self) -> &Self::Target {
333        &self.target
334    }
335    fn extract_value(&self, value: crate::types::Min<i64>) -> crate::types::Or {
336        crate::types::Or(value.0 == Some(self.zero_penalty_energy))
337    }
338}
339
340impl crate::rules::AggregateReductionResult for Reduction3SATToQUBO {
341    type Source = KSatisfiability<K3>;
342    type Target = QUBO<i64>;
343    fn target_problem(&self) -> &Self::Target {
344        &self.target
345    }
346    fn extract_value(&self, value: crate::types::Min<i64>) -> crate::types::Or {
347        crate::types::Or(value.0 == Some(self.zero_penalty_energy))
348    }
349}
350
351#[reduction(
352    aggregate = custom,
353    transform = exact {
354        num_vars = "num_vars",
355    }
356)]
357impl ReduceTo<QUBO<i64>> for KSatisfiability<K2> {
358    type Result = ReductionKSatToQUBO;
359
360    fn reduce_to(&self) -> Result<Self::Result, crate::rules::ReductionError> {
361        let n = self.num_vars();
362        let (matrix, constant) = build_qubo_matrix(n, self.clauses(), 0).map_err(|operation| {
363            crate::rules::ReductionError::integer_overflow::<KSatisfiability<K2>, QUBO<i64>>(
364                operation,
365            )
366        })?;
367
368        Ok(ReductionKSatToQUBO {
369            target: QUBO::from_matrix(matrix).map_err(|message| {
370                crate::rules::ReductionError::construction::<KSatisfiability<K2>, QUBO<i64>>(
371                    message,
372                )
373            })?,
374            source_num_vars: n,
375            zero_penalty_energy: -constant,
376        })
377    }
378}
379
380#[reduction(
381    aggregate = custom,
382    transform = exact {
383        num_vars = "num_vars + num_clauses",
384    }
385)]
386impl ReduceTo<QUBO<i64>> for KSatisfiability<K3> {
387    type Result = Reduction3SATToQUBO;
388
389    fn reduce_to(&self) -> Result<Self::Result, crate::rules::ReductionError> {
390        let n = self.num_vars();
391        let (matrix, constant) =
392            build_qubo_matrix(n, self.clauses(), self.num_clauses()).map_err(|operation| {
393                crate::rules::ReductionError::integer_overflow::<KSatisfiability<K3>, QUBO<i64>>(
394                    operation,
395                )
396            })?;
397
398        Ok(Reduction3SATToQUBO {
399            target: QUBO::from_matrix(matrix).map_err(|message| {
400                crate::rules::ReductionError::construction::<KSatisfiability<K3>, QUBO<i64>>(
401                    message,
402                )
403            })?,
404            source_num_vars: n,
405            zero_penalty_energy: -constant,
406        })
407    }
408}
409
410#[cfg(feature = "example-db")]
411pub(crate) fn canonical_rule_example_specs() -> Vec<crate::example_db::specs::RuleExampleSpec> {
412    use crate::export::SolutionPair;
413    use crate::models::algebraic::QUBO;
414    use crate::models::formula::CNFClause;
415
416    vec![
417        crate::example_db::specs::RuleExampleSpec {
418            id: "ksatisfiability_k2_to_qubo",
419            build: || {
420                let source = KSatisfiability::<K2>::new(
421                    4,
422                    vec![
423                        CNFClause::new(vec![1, 2]),
424                        CNFClause::new(vec![-1, 3]),
425                        CNFClause::new(vec![-2, 4]),
426                        CNFClause::new(vec![-3, -4]),
427                    ],
428                );
429                crate::example_db::specs::rule_example_with_witness::<_, QUBO<i64>>(
430                    source,
431                    SolutionPair {
432                        source_config: serde_json::json!(vec![false, true, false, true]),
433                        target_config: serde_json::json!(vec![false, true, false, true]),
434                    },
435                )
436            },
437        },
438        crate::example_db::specs::RuleExampleSpec {
439            id: "ksatisfiability_to_qubo",
440            build: || {
441                let source = KSatisfiability::<K3>::new(
442                    5,
443                    vec![
444                        CNFClause::new(vec![1, 2, -3]),
445                        CNFClause::new(vec![-1, 3, 4]),
446                        CNFClause::new(vec![2, -4, 5]),
447                        CNFClause::new(vec![-2, 3, -5]),
448                        CNFClause::new(vec![1, -3, 5]),
449                        CNFClause::new(vec![-1, -2, 4]),
450                        CNFClause::new(vec![3, -4, -5]),
451                    ],
452                );
453                crate::example_db::specs::rule_example_with_witness::<_, QUBO<i64>>(
454                    source,
455                    SolutionPair {
456                        source_config: serde_json::json!(vec![false, false, false, false, false]),
457                        target_config: serde_json::json!(vec![
458                            false, false, false, false, false, true, false, false, false, false,
459                            false, false
460                        ]),
461                    },
462                )
463            },
464        },
465    ]
466}
467
468#[cfg(test)]
469#[path = "../unit_tests/rules/ksatisfiability_qubo.rs"]
470mod tests;