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<f64>,
24    source_num_vars: usize,
25}
26
27impl ReductionResult for ReductionKSatToQUBO {
28    type Source = KSatisfiability<K2>;
29    type Target = QUBO<f64>;
30
31    fn target_problem(&self) -> &Self::Target {
32        &self.target
33    }
34
35    fn extract_solution(&self, target_solution: &[usize]) -> Vec<usize> {
36        target_solution[..self.source_num_vars].to_vec()
37    }
38}
39
40/// Result of reducing `KSatisfiability<K3>` to QUBO.
41#[derive(Debug, Clone)]
42pub struct Reduction3SATToQUBO {
43    target: QUBO<f64>,
44    source_num_vars: usize,
45}
46
47impl ReductionResult for Reduction3SATToQUBO {
48    type Source = KSatisfiability<K3>;
49    type Target = QUBO<f64>;
50
51    fn target_problem(&self) -> &Self::Target {
52        &self.target
53    }
54
55    fn extract_solution(&self, target_solution: &[usize]) -> Vec<usize> {
56        target_solution[..self.source_num_vars].to_vec()
57    }
58}
59
60/// Convert a signed literal to (0-indexed variable, is_negated).
61fn lit_to_var(lit: i32) -> (usize, bool) {
62    let var = (lit.unsigned_abs() as usize) - 1;
63    let neg = lit < 0;
64    (var, neg)
65}
66
67/// Add the quadratic penalty term for a 2-literal clause to the QUBO matrix.
68///
69/// For clause (l_i ∨ l_j), the penalty for the clause being unsatisfied is
70/// the product of the complemented literals.
71fn add_2sat_clause_penalty(matrix: &mut [Vec<f64>], lits: &[i32]) {
72    assert_eq!(lits.len(), 2, "Expected 2-literal clause");
73
74    let (var_i, neg_i) = lit_to_var(lits[0]);
75    let (var_j, neg_j) = lit_to_var(lits[1]);
76
77    // Ensure i <= j for upper-triangular form
78    let (i, j, ni, nj) = if var_i <= var_j {
79        (var_i, var_j, neg_i, neg_j)
80    } else {
81        (var_j, var_i, neg_j, neg_i)
82    };
83
84    match (ni, nj) {
85        (false, false) => {
86            // (x_i ∨ x_j): penalty = (1-x_i)(1-x_j) = 1 - x_i - x_j + x_i·x_j
87            matrix[i][i] -= 1.0;
88            matrix[j][j] -= 1.0;
89            matrix[i][j] += 1.0;
90        }
91        (true, false) => {
92            // (¬x_i ∨ x_j): penalty = x_i(1-x_j) = x_i - x_i·x_j
93            matrix[i][i] += 1.0;
94            matrix[i][j] -= 1.0;
95        }
96        (false, true) => {
97            // (x_i ∨ ¬x_j): penalty = (1-x_i)x_j = x_j - x_i·x_j
98            matrix[j][j] += 1.0;
99            matrix[i][j] -= 1.0;
100        }
101        (true, true) => {
102            // (¬x_i ∨ ¬x_j): penalty = x_i·x_j
103            matrix[i][j] += 1.0;
104        }
105    }
106}
107
108/// Add the QUBO terms for a 3-literal clause using Rosenberg quadratization.
109///
110/// For clause (l1 ∨ l2 ∨ l3), the penalty for not satisfying the clause is:
111///   P = (1-l1)(1-l2)(1-l3) = y1·y2·y3
112/// where yi = 1 - li (complement of literal).
113///
114/// We introduce one auxiliary variable `a` and quadratize using the substitution
115/// a = y1·y2, adding penalty M·(y1·y2 - 2·y1·a - 2·y2·a + 3·a) where M is a
116/// sufficiently large penalty (M = 2 suffices for Max-SAT).
117///
118/// The resulting quadratic form is:
119///   H = a·y3 + M·(y1·y2 - 2·y1·a - 2·y2·a + 3·a)
120///
121/// `aux_var` is the 0-indexed auxiliary variable.
122fn add_3sat_clause_penalty(matrix: &mut [Vec<f64>], lits: &[i32], aux_var: usize) {
123    assert_eq!(lits.len(), 3, "Expected 3-literal clause");
124    let penalty = 2.0; // Rosenberg penalty weight
125
126    let (v1, n1) = lit_to_var(lits[0]);
127    let (v2, n2) = lit_to_var(lits[1]);
128    let (v3, n3) = lit_to_var(lits[2]);
129    let a = aux_var;
130
131    // We need to express yi = (1 - li) in terms of xi:
132    //   If literal is positive (li = xi): yi = 1 - xi
133    //   If literal is negated (li = 1 - xi): yi = xi
134    //
135    // So yi = xi if negated, yi = 1 - xi if positive.
136    //
137    // We compute the QUBO terms for:
138    //   H = a·y3 + M·(y1·y2 - 2·y1·a - 2·y2·a + 3·a)
139    //
140    // Each term is expanded using yi = xi (if negated) or yi = 1-xi (if positive).
141
142    // Helper: add coefficient * yi * yj to the matrix
143    // where yi depends on variable vi and negation ni
144    let add_yy = |matrix: &mut [Vec<f64>], vi: usize, ni: bool, vj: usize, nj: bool, coeff: f64| {
145        // yi = xi if ni (negated literal), yi = 1 - xi if !ni (positive literal)
146        // yi * yj expansion:
147        if vi == vj {
148            // Same variable: yi * yj
149            // Both complemented the same way means yi = yj, so yi*yj = yi (binary)
150            // If ni == nj: yi*yj = yi^2 = yi (binary), add coeff * yi
151            // If ni != nj: yi * yj = xi * (1-xi) = 0 (always), add nothing
152            if ni == nj {
153                // yi * yi = yi (binary)
154                if ni {
155                    // yi = xi, add coeff * xi
156                    matrix[vi][vi] += coeff;
157                } else {
158                    // yi = 1 - xi, add coeff * (1 - xi) = coeff - coeff * xi
159                    // constant term ignored in QUBO (offset), diagonal:
160                    matrix[vi][vi] -= coeff;
161                }
162            }
163            // else: xi * (1-xi) = 0, nothing to add
164            return;
165        }
166        // Different variables: yi * yj
167        let (lo, hi, lo_neg, hi_neg) = if vi < vj {
168            (vi, vj, ni, nj)
169        } else {
170            (vj, vi, nj, ni)
171        };
172        // yi = xi if neg, else 1-xi
173        // yj = xj if neg, else 1-xj
174        // yi*yj = (xi if neg_i else 1-xi) * (xj if neg_j else 1-xj)
175        match (lo_neg, hi_neg) {
176            (true, true) => {
177                // xi * xj
178                matrix[lo][hi] += coeff;
179            }
180            (true, false) => {
181                // xi * (1 - xj) = xi - xi*xj
182                matrix[lo][lo] += coeff;
183                matrix[lo][hi] -= coeff;
184            }
185            (false, true) => {
186                // (1 - xi) * xj = xj - xi*xj
187                matrix[hi][hi] += coeff;
188                matrix[lo][hi] -= coeff;
189            }
190            (false, false) => {
191                // (1-xi)(1-xj) = 1 - xi - xj + xi*xj
192                // constant 1 ignored (offset)
193                matrix[lo][lo] -= coeff;
194                matrix[hi][hi] -= coeff;
195                matrix[lo][hi] += coeff;
196            }
197        }
198    };
199
200    // Helper: add coefficient * yi * a to the matrix
201    // where yi depends on variable vi and negation ni, a is aux variable
202    let add_ya = |matrix: &mut [Vec<f64>], vi: usize, ni: bool, a: usize, coeff: f64| {
203        // yi = xi if ni (negated literal), yi = 1-xi if !ni (positive literal)
204        // yi * a:
205        let (lo, hi) = if vi < a { (vi, a) } else { (a, vi) };
206        if ni {
207            // yi = xi, so yi * a = xi * a
208            matrix[lo][hi] += coeff;
209        } else {
210            // yi = 1 - xi, so yi * a = a - xi * a
211            matrix[a][a] += coeff;
212            matrix[lo][hi] -= coeff;
213        }
214    };
215
216    // Helper: add coefficient * yi to the matrix (linear term)
217    let add_y = |matrix: &mut [Vec<f64>], vi: usize, ni: bool, coeff: f64| {
218        if ni {
219            // yi = xi
220            matrix[vi][vi] += coeff;
221        } else {
222            // yi = 1 - xi, linear part: -coeff * xi (constant coeff ignored)
223            matrix[vi][vi] -= coeff;
224        }
225    };
226
227    // Term 1: a * y3 (coefficient = 1.0)
228    add_ya(matrix, v3, n3, a, 1.0);
229
230    // Term 2: M * y1 * y2
231    add_yy(matrix, v1, n1, v2, n2, penalty);
232
233    // Term 3: -2M * y1 * a
234    add_ya(matrix, v1, n1, a, -2.0 * penalty);
235
236    // Term 4: -2M * y2 * a
237    add_ya(matrix, v2, n2, a, -2.0 * penalty);
238
239    // Term 5: 3M * a (linear)
240    // a is a binary variable, a^2 = a, so linear a → diagonal
241    matrix[a][a] += 3.0 * penalty;
242
243    // We also need to add linear terms that come from constant offsets in products
244    // Actually, let's verify: the full expansion of
245    //   H = a·y3 + M·(y1·y2 - 2·y1·a - 2·y2·a + 3·a)
246    // All terms are handled above.
247    //
248    // However, we need to account for the case where "add_ya" with !ni adds
249    // a linear term in `a`. Let me verify the add_ya logic handles this correctly.
250    //
251    // add_ya with ni=false: yi = 1-xi, yi*a = a - xi*a
252    //   matrix[a][a] += coeff (linear in a)
253    //   matrix[min(vi,a)][max(vi,a)] -= coeff (quadratic xi*a)
254    // This is correct.
255
256    // Note: We ignore constant terms (don't affect QUBO optimization).
257    let _ = add_y; // suppress unused warning - linear terms in y handled via products
258}
259
260/// Build a QUBO matrix from a KSatisfiability instance.
261///
262/// For K=2, directly encodes quadratic penalties.
263/// For K=3, uses Rosenberg quadratization with one auxiliary variable per clause.
264///
265/// Returns (matrix, num_source_vars) where matrix is (n + aux) x (n + aux).
266fn build_qubo_matrix(
267    num_vars: usize,
268    clauses: &[crate::models::formula::CNFClause],
269    k: usize,
270) -> Vec<Vec<f64>> {
271    match k {
272        2 => {
273            let mut matrix = vec![vec![0.0; num_vars]; num_vars];
274            for clause in clauses {
275                add_2sat_clause_penalty(&mut matrix, &clause.literals);
276            }
277            matrix
278        }
279        3 => {
280            let num_aux = clauses.len(); // one auxiliary per clause
281            let total = num_vars + num_aux;
282            let mut matrix = vec![vec![0.0; total]; total];
283            for (idx, clause) in clauses.iter().enumerate() {
284                let aux_var = num_vars + idx;
285                add_3sat_clause_penalty(&mut matrix, &clause.literals, aux_var);
286            }
287            matrix
288        }
289        _ => unimplemented!("KSatisfiability to QUBO only supports K=2 and K=3"),
290    }
291}
292
293#[reduction(
294    overhead = { num_vars = "num_vars" }
295)]
296impl ReduceTo<QUBO<f64>> for KSatisfiability<K2> {
297    type Result = ReductionKSatToQUBO;
298
299    fn reduce_to(&self) -> Self::Result {
300        let n = self.num_vars();
301        let matrix = build_qubo_matrix(n, self.clauses(), 2);
302
303        ReductionKSatToQUBO {
304            target: QUBO::from_matrix(matrix),
305            source_num_vars: n,
306        }
307    }
308}
309
310#[reduction(
311    overhead = { num_vars = "num_vars + num_clauses" }
312)]
313impl ReduceTo<QUBO<f64>> for KSatisfiability<K3> {
314    type Result = Reduction3SATToQUBO;
315
316    fn reduce_to(&self) -> Self::Result {
317        let n = self.num_vars();
318        let matrix = build_qubo_matrix(n, self.clauses(), 3);
319
320        Reduction3SATToQUBO {
321            target: QUBO::from_matrix(matrix),
322            source_num_vars: n,
323        }
324    }
325}
326
327#[cfg(test)]
328#[path = "../unit_tests/rules/ksatisfiability_qubo.rs"]
329mod tests;