Skip to main content

problemreductions/rules/
ilp_qubo.rs

1//! Reduction from binary ILP to QUBO.
2//!
3//! Binary ILP: optimize c^T x s.t. Ax {<=,>=,=} b, x ∈ {0,1}^n.
4//!
5//! Formulation (following qubogen):
6//! 1. Normalize constraints to Ax = b by adding slack variables
7//! 2. QUBO = -diag(c + 2·P·b·A) + P·A^T·A
8//!
9//! For Minimize sense, c is negated (convert to maximization).
10//! Slack variables: ceil(log2(slack_range + 1)) bits for a nonnegative range.
11//! The custom aggregate restores the omitted constant and both objective senses;
12//! only zero-penalty configurations certify a feasible source assignment.
13
14use crate::models::algebraic::{Comparison, ObjectiveSense, ILP, QUBO};
15use crate::reduction;
16use crate::rules::traits::{ReduceTo, ReductionResult};
17
18/// Result of reducing binary ILP to QUBO.
19#[derive(Debug, Clone)]
20pub struct ReductionILPToQUBO {
21    target: QUBO<i64>,
22    num_original_vars: usize,
23    sense: ObjectiveSense,
24    penalty_constant: i64,
25    feasible_energy_lower: i64,
26    feasible_energy_upper: i64,
27}
28
29impl ReductionResult for ReductionILPToQUBO {
30    type Source = ILP<bool>;
31    type Target = QUBO<i64>;
32
33    fn target_problem(&self) -> &Self::Target {
34        &self.target
35    }
36
37    /// Extract only the original variables (discard slack).
38    fn extract_solution(
39        &self,
40        target_solution: &<Self::Target as crate::traits::Problem>::Solution,
41    ) -> crate::rules::ExtractionResult<<Self::Source as crate::traits::Problem>::Solution> {
42        crate::rules::traits::validate_target_witness(
43            self.target_problem(),
44            target_solution,
45            |value| crate::rules::AggregateReductionResult::extract_value(self, value).is_valid(),
46            "target QUBO configuration does not certify a feasible ILP assignment",
47        )?;
48
49        Ok(target_solution[..self.num_original_vars]
50            .iter()
51            .map(|&value| i64::from(value))
52            .collect())
53    }
54}
55
56#[crate::aggregate_reduction]
57impl crate::rules::AggregateReductionResult for ReductionILPToQUBO {
58    type Source = ILP<bool>;
59    type Target = QUBO<i64>;
60
61    fn target_problem(&self) -> &Self::Target {
62        &self.target
63    }
64
65    fn extract_value(&self, value: crate::types::Min<i64>) -> crate::types::Extremum<i64> {
66        let objective = value
67            .0
68            .filter(|&energy| {
69                self.feasible_energy_lower <= energy && energy <= self.feasible_energy_upper
70            })
71            // The checked energy interval guarantees this sum is representable.
72            .map(|energy| energy + self.penalty_constant);
73        match self.sense {
74            ObjectiveSense::Minimize => crate::types::Extremum::minimize(objective),
75            // The strict penalty bound excludes i64::MIN from this interval.
76            ObjectiveSense::Maximize => crate::types::Extremum::maximize(objective.map(|v| -v)),
77        }
78    }
79}
80
81// With n variables and magnitude bits h, each positive slack range is less
82// than (n+1)*2^h <= 2^(n+h), so each row adds at most n+h bits. Squaring the
83// resulting variable bound covers every off-diagonal pair, even with cancellation.
84#[reduction(transform = upper_bound {
85    num_vars = "num_vars + num_constraints * (num_vars + max_constraint_magnitude_bits)",
86    num_quadratic_terms = "(num_vars + num_constraints * (num_vars + max_constraint_magnitude_bits))^2",
87})]
88impl ReduceTo<QUBO<i64>> for ILP<bool> {
89    type Result = ReductionILPToQUBO;
90
91    fn reduce_to(&self) -> Result<Self::Result, crate::rules::ReductionError> {
92        let n = self.num_vars();
93
94        // All variables are binary by type — no runtime check needed.
95
96        // Build dense constraint matrix A and rhs vector b
97        // Also compute slack sizes for inequality constraints
98        let num_constraints = self.constraints().len();
99        let mut a_dense = vec![vec![0_i64; n]; num_constraints];
100        let mut b_vec = vec![0_i64; num_constraints];
101        let mut slack_sizes = vec![0usize; num_constraints];
102
103        for (k, constraint) in self.constraints().iter().enumerate() {
104            for &(var, coef) in constraint.terms() {
105                a_dense[k][var] = coef;
106            }
107            b_vec[k] = constraint.rhs();
108
109            // Compute slack variable count: ceil(log2(slack_range + 1)) bits
110            // to represent integer values 0..slack_range with binary encoding.
111            // For binary variables, min_lhs = Σ min(0, a_i), max_lhs = Σ max(0, a_i).
112            match constraint.comparison() {
113                Comparison::Eq => {} // no slack needed
114                Comparison::Le => {
115                    // Ax <= b → Ax + s = b, s ∈ {0, ..., b - min_lhs}
116                    let min_lhs = a_dense[k]
117                        .iter()
118                        .try_fold(0_i64, |sum, &coefficient| {
119                            sum.checked_add(coefficient.min(0))
120                        })
121                        .ok_or_else(|| {
122                            crate::rules::ReductionError::integer_overflow::<ILP<bool>, QUBO<i64>>(
123                                "computing an inequality's minimum left-hand side",
124                            )
125                        })?;
126                    let slack_range = constraint.rhs().checked_sub(min_lhs).ok_or_else(|| {
127                        crate::rules::ReductionError::integer_overflow::<ILP<bool>, QUBO<i64>>(
128                            "computing a less-than inequality's slack range",
129                        )
130                    })?;
131                    if slack_range > 0 {
132                        slack_sizes[k] = i64::BITS as usize - slack_range.leading_zeros() as usize;
133                    }
134                }
135                Comparison::Ge => {
136                    // Ax >= b → Ax - s = b, s ∈ {0, ..., max_lhs - b}
137                    let max_lhs = a_dense[k]
138                        .iter()
139                        .try_fold(0_i64, |sum, &coefficient| {
140                            sum.checked_add(coefficient.max(0))
141                        })
142                        .ok_or_else(|| {
143                            crate::rules::ReductionError::integer_overflow::<ILP<bool>, QUBO<i64>>(
144                                "computing an inequality's maximum left-hand side",
145                            )
146                        })?;
147                    let slack_range = max_lhs.checked_sub(constraint.rhs()).ok_or_else(|| {
148                        crate::rules::ReductionError::integer_overflow::<ILP<bool>, QUBO<i64>>(
149                            "computing a greater-than inequality's slack range",
150                        )
151                    })?;
152                    if slack_range > 0 {
153                        slack_sizes[k] = i64::BITS as usize - slack_range.leading_zeros() as usize;
154                    }
155                }
156            }
157        }
158
159        let total_slack = slack_sizes.iter().try_fold(0_usize, |total, &size| {
160            total.checked_add(size).ok_or_else(|| {
161                crate::rules::ReductionError::integer_overflow::<ILP<bool>, QUBO<i64>>(
162                    "counting QUBO slack variables",
163                )
164            })
165        })?;
166        let nq = qubo_num_variables(n, total_slack)?;
167
168        // Extend A with slack columns
169        let mut a_ext = vec![vec![0_i64; nq]; num_constraints];
170        for k in 0..num_constraints {
171            for j in 0..n {
172                a_ext[k][j] = a_dense[k][j];
173            }
174        }
175
176        // Add slack variable columns
177        let mut slack_col = n;
178        for (k, &ns) in slack_sizes.iter().enumerate() {
179            if ns > 0 {
180                let sign = match self.constraints()[k].comparison() {
181                    Comparison::Le => 1,  // Ax + s = b
182                    Comparison::Ge => -1, // Ax - s = b
183                    Comparison::Eq => 0,
184                };
185                for s in 0..ns {
186                    let bit = u32::try_from(s).map_err(|_| {
187                        crate::rules::ReductionError::integer_overflow::<ILP<bool>, QUBO<i64>>(
188                            "encoding a QUBO slack bit",
189                        )
190                    })?;
191                    a_ext[k][slack_col + s] = 1_i64.checked_shl(bit).ok_or_else(|| {
192                        crate::rules::ReductionError::integer_overflow::<ILP<bool>, QUBO<i64>>(
193                            "encoding a QUBO slack bit",
194                        )
195                    })? * sign;
196                }
197                slack_col += ns;
198            }
199        }
200
201        // Build dense cost vector (nq elements)
202        let mut c_vec = vec![0_i64; nq];
203        for &(var, coef) in self.objective() {
204            c_vec[var] = coef;
205        }
206
207        // For Minimize sense, negate the cost (formula assumes maximization)
208        if self.sense() == ObjectiveSense::Minimize {
209            for c in c_vec.iter_mut() {
210                *c = c.checked_neg().ok_or_else(|| {
211                    crate::rules::ReductionError::integer_overflow::<ILP<bool>, QUBO<i64>>(
212                        "negating an ILP objective coefficient",
213                    )
214                })?;
215            }
216        }
217
218        // Penalty: must be large enough to enforce constraints
219        let objective_magnitude = c_vec.iter().try_fold(0_i64, |total, &coefficient| {
220            let magnitude = coefficient.checked_abs().ok_or_else(|| {
221                crate::rules::ReductionError::integer_overflow::<ILP<bool>, QUBO<i64>>(
222                    "taking the magnitude of an ILP objective coefficient",
223                )
224            })?;
225            total.checked_add(magnitude).ok_or_else(|| {
226                crate::rules::ReductionError::integer_overflow::<ILP<bool>, QUBO<i64>>(
227                    "summing ILP objective coefficient magnitudes",
228                )
229            })
230        })?;
231        let rhs_magnitude = b_vec.iter().try_fold(0_i64, |total, &rhs| {
232            let magnitude = rhs.checked_abs().ok_or_else(|| {
233                crate::rules::ReductionError::integer_overflow::<ILP<bool>, QUBO<i64>>(
234                    "taking the magnitude of an ILP right-hand side",
235                )
236            })?;
237            total.checked_add(magnitude).ok_or_else(|| {
238                crate::rules::ReductionError::integer_overflow::<ILP<bool>, QUBO<i64>>(
239                    "summing ILP right-hand-side magnitudes",
240                )
241            })
242        })?;
243        let penalty = objective_magnitude
244            .checked_add(rhs_magnitude)
245            .and_then(|sum| sum.checked_add(1))
246            .ok_or_else(|| {
247                crate::rules::ReductionError::integer_overflow::<ILP<bool>, QUBO<i64>>(
248                    "computing the QUBO constraint penalty",
249                )
250            })?;
251
252        let (penalty_constant, feasible_energy_lower, feasible_energy_upper) =
253            feasible_energy_range(&c_vec, &b_vec, penalty)?;
254
255        // QUBO = -diag(c + 2·P·b·A) + P·A^T·A
256        let mut matrix = vec![vec![0_i64; nq]; nq];
257
258        // Compute b·A (b_vec dot each column of a_ext)
259        let mut ba = vec![0_i64; nq];
260        for (j, ba_j) in ba.iter_mut().enumerate() {
261            for (k, &b_k) in b_vec.iter().enumerate() {
262                let term = b_k.checked_mul(a_ext[k][j]).ok_or_else(|| {
263                    crate::rules::ReductionError::integer_overflow::<ILP<bool>, QUBO<i64>>(
264                        "multiplying a right-hand side by a row coefficient",
265                    )
266                })?;
267                *ba_j = ba_j.checked_add(term).ok_or_else(|| {
268                    crate::rules::ReductionError::integer_overflow::<ILP<bool>, QUBO<i64>>(
269                        "computing the right-hand-side row product",
270                    )
271                })?;
272            }
273        }
274
275        // Diagonal: -(c_j + 2·P·(b·A)_j)
276        for j in 0..nq {
277            let penalty_term = penalty
278                .checked_mul(ba[j])
279                .and_then(|value| value.checked_mul(2))
280                .ok_or_else(|| {
281                    crate::rules::ReductionError::integer_overflow::<ILP<bool>, QUBO<i64>>(
282                        "computing a QUBO diagonal penalty",
283                    )
284                })?;
285            matrix[j][j] = c_vec[j]
286                .checked_add(penalty_term)
287                .and_then(i64::checked_neg)
288                .ok_or_else(|| {
289                    crate::rules::ReductionError::integer_overflow::<ILP<bool>, QUBO<i64>>(
290                        "computing a QUBO diagonal coefficient",
291                    )
292                })?;
293        }
294
295        // A^T·A contribution (upper-triangular convention)
296        // Diagonal: P · Σ_k a_{ki}²
297        // Off-diagonal (i<j): 2·P · Σ_k a_{ki}·a_{kj}
298        for row in &a_ext {
299            for (i, row_i) in matrix.iter_mut().enumerate() {
300                if row[i] == 0 {
301                    continue;
302                }
303                // Diagonal
304                let diagonal = penalty
305                    .checked_mul(row[i])
306                    .and_then(|value| value.checked_mul(row[i]))
307                    .ok_or_else(|| {
308                        crate::rules::ReductionError::integer_overflow::<ILP<bool>, QUBO<i64>>(
309                            "computing a quadratic QUBO diagonal penalty",
310                        )
311                    })?;
312                row_i[i] = row_i[i].checked_add(diagonal).ok_or_else(|| {
313                    crate::rules::ReductionError::integer_overflow::<ILP<bool>, QUBO<i64>>(
314                        "adding a quadratic QUBO diagonal penalty",
315                    )
316                })?;
317                // Off-diagonal
318                for j in (i + 1)..nq {
319                    let interaction = penalty
320                        .checked_mul(row[i])
321                        .and_then(|value| value.checked_mul(row[j]))
322                        .and_then(|value| value.checked_mul(2))
323                        .ok_or_else(|| {
324                            crate::rules::ReductionError::integer_overflow::<ILP<bool>, QUBO<i64>>(
325                                "computing a quadratic QUBO interaction penalty",
326                            )
327                        })?;
328                    row_i[j] = row_i[j].checked_add(interaction).ok_or_else(|| {
329                        crate::rules::ReductionError::integer_overflow::<ILP<bool>, QUBO<i64>>(
330                            "adding a quadratic QUBO interaction penalty",
331                        )
332                    })?;
333                }
334            }
335        }
336
337        Ok(ReductionILPToQUBO {
338            target: QUBO::from_matrix(matrix)
339                .map_err(crate::rules::ReductionError::construction::<ILP<bool>, QUBO<i64>>)?,
340            num_original_vars: n,
341            sense: self.sense(),
342            penalty_constant,
343            feasible_energy_lower,
344            feasible_energy_upper,
345        })
346    }
347}
348
349/// Check the dense QUBO dimensions before allocating either extended matrix.
350fn qubo_num_variables(n: usize, slack: usize) -> Result<usize, crate::rules::ReductionError> {
351    let overflow = || {
352        crate::rules::ReductionError::integer_overflow::<ILP<bool>, QUBO<i64>>(
353            "counting dense QUBO entries",
354        )
355    };
356    let total = n.checked_add(slack).ok_or_else(overflow)?;
357    total.checked_mul(total).ok_or_else(overflow)?;
358    Ok(total)
359}
360
361/// Restore the omitted squared-residual constant and bound zero-penalty energies.
362/// `cost` is the maximization-oriented objective used in the QUBO expansion.
363fn feasible_energy_range(
364    cost: &[i64],
365    rhs: &[i64],
366    penalty: i64,
367) -> Result<(i64, i64, i64), crate::rules::ReductionError> {
368    let overflow = || {
369        crate::rules::ReductionError::integer_overflow::<ILP<bool>, QUBO<i64>>(
370            "encoding the QUBO feasible energy interval",
371        )
372    };
373    let constant = rhs.iter().try_fold(0_i64, |sum, &b| {
374        b.checked_mul(b)
375            .and_then(|square| square.checked_mul(penalty))
376            .and_then(|term| sum.checked_add(term))
377            .ok_or_else(overflow)
378    })?;
379    let (lower, upper) = cost.iter().try_fold((0_i64, 0_i64), |(low, high), &c| {
380        Ok::<_, crate::rules::ReductionError>((
381            low.checked_sub(c.max(0)).ok_or_else(overflow)?,
382            high.checked_sub(c.min(0)).ok_or_else(overflow)?,
383        ))
384    })?;
385    Ok((
386        constant,
387        lower.checked_sub(constant).ok_or_else(overflow)?,
388        upper.checked_sub(constant).ok_or_else(overflow)?,
389    ))
390}
391
392#[cfg(feature = "example-db")]
393pub(crate) fn canonical_rule_example_specs() -> Vec<crate::example_db::specs::RuleExampleSpec> {
394    use crate::export::SolutionPair;
395    use crate::models::algebraic::{LinearConstraint, ObjectiveSense};
396
397    vec![crate::example_db::specs::RuleExampleSpec {
398        id: "ilp_to_qubo",
399        build: || {
400            let source = ILP::new(
401                6,
402                vec![
403                    LinearConstraint::le(vec![(0, 3), (1, 2), (2, 5), (3, 4), (4, 2), (5, 3)], 10),
404                    LinearConstraint::le(vec![(0, 1), (1, 1), (2, 1)], 2),
405                    LinearConstraint::le(vec![(3, 1), (4, 1), (5, 1)], 2),
406                ],
407                vec![(0, 10), (1, 7), (2, 12), (3, 8), (4, 6), (5, 9)],
408                ObjectiveSense::Maximize,
409            )
410            .expect("canonical ILP example must satisfy construction invariants");
411            crate::example_db::specs::rule_example_with_witness::<_, QUBO<i64>>(
412                source,
413                SolutionPair {
414                    source_config: serde_json::json!(vec![1, 1, 0, 0, 1, 1]),
415                    target_config: serde_json::json!(vec![
416                        true, true, false, false, true, true, false, false, false, false, false,
417                        false, false, false
418                    ]),
419                },
420            )
421        },
422    }]
423}
424
425#[cfg(test)]
426#[path = "../unit_tests/rules/ilp_qubo.rs"]
427mod tests;