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