Skip to content

Commit 15c94c4

Browse files
zazabapclaudeGiggleLiu
authored
Fix #116: Add Knapsack to QUBO reduction (#172)
* Add plan for #116: Knapsack to QUBO reduction Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add plan for #114: Knapsack * feat: add Knapsack to QUBO reduction rule Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add Knapsack to QUBO example program Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: regenerate reduction graph after Knapsack->QUBO rule Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add Knapsack to QUBO reduction in paper Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style: fix formatting (cargo fmt) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address PR #172 review comments - Delete plan files per user request - Remove KS alias per user request (keep only lowercase "knapsack") - Fix num_slack_bits() to use integer bit-length instead of float log2 (avoids precision loss for large i64 capacities above 2^53) - Fix 1i64 << j to 1u64 << j in QUBO slack coefficients (avoids negative coefficient from signed overflow) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix Knapsack to QUBO review findings * Fix final review findings: remove README example, use nonempty check, fmt - Remove specific Knapsack→QUBO example from README (keep README high-level) - Replace hardcoded example count (43) with nonempty check in rule_builders test - Fix rustfmt formatting in example and test files Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Remove legacy example binary and its integration test The per-reduction example file examples/reduction_knapsack_to_qubo.rs follows a deprecated pattern. Canonical examples belong in src/example_db/rule_builders.rs (already registered there). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Derive paper example values from loaded data instead of hardcoding Replace hardcoded formulas and item selections in the Knapsack→QUBO paper extra section with data-driven computations from load-example. Constraint equation, penalty P, objective H, selected items, and weight/value sums are now all derived from the canonical example data. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: GiggleLiu <cacate0129@gmail.com>
1 parent 3e193a7 commit 15c94c4

9 files changed

Lines changed: 374 additions & 10 deletions

File tree

docs/paper/reductions.typ

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1626,6 +1626,47 @@ where $P$ is a penalty weight large enough that any constraint violation costs m
16261626
_Solution extraction._ Discard slack variables: return $bold(x)' [0..n]$.
16271627
]
16281628

1629+
#let ks_qubo = load-example("Knapsack", "QUBO")
1630+
#let ks_qubo_sol = ks_qubo.solutions.at(0)
1631+
#let ks_qubo_num_items = ks_qubo.source.instance.weights.len()
1632+
#let ks_qubo_num_slack = ks_qubo.target.instance.num_vars - ks_qubo_num_items
1633+
#let ks_qubo_penalty = 1 + ks_qubo.source.instance.values.fold(0, (a, b) => a + b)
1634+
#let ks_qubo_selected = ks_qubo_sol.source_config.enumerate().filter(((i, x)) => x == 1).map(((i, x)) => i)
1635+
#let ks_qubo_sel_weight = ks_qubo_selected.fold(0, (a, i) => a + ks_qubo.source.instance.weights.at(i))
1636+
#let ks_qubo_sel_value = ks_qubo_selected.fold(0, (a, i) => a + ks_qubo.source.instance.values.at(i))
1637+
#reduction-rule("Knapsack", "QUBO",
1638+
example: true,
1639+
example-caption: [$n = #ks_qubo_num_items$ items, capacity $C = #ks_qubo.source.instance.capacity$],
1640+
extra: [
1641+
*Step 1 -- Source instance.* The canonical knapsack instance has weights $(#ks_qubo.source.instance.weights.map(str).join(", "))$, values $(#ks_qubo.source.instance.values.map(str).join(", "))$, and capacity $C = #ks_qubo.source.instance.capacity$.
1642+
1643+
*Step 2 -- Introduce slack variables.* The inequality $sum_i w_i x_i lt.eq C$ becomes an equality by adding $B = #ks_qubo_num_slack$ binary slack bits that encode unused capacity:
1644+
$ #ks_qubo.source.instance.weights.enumerate().map(((i, w)) => $#w x_#i$).join($+$) + #range(ks_qubo_num_slack).map(j => $#calc.pow(2, j) s_#j$).join($+$) = #ks_qubo.source.instance.capacity $
1645+
This gives $n + B = #ks_qubo_num_items + #ks_qubo_num_slack = #ks_qubo.target.instance.num_vars$ QUBO variables.
1646+
1647+
*Step 3 -- Add the penalty objective.* With penalty $P = 1 + sum_i v_i = #ks_qubo_penalty$, the QUBO minimizes
1648+
$ H = -(#ks_qubo.source.instance.values.enumerate().map(((i, v)) => $#v x_#i$).join($+$)) + #ks_qubo_penalty (#ks_qubo.source.instance.weights.enumerate().map(((i, w)) => $#w x_#i$).join($+$) + #range(ks_qubo_num_slack).map(j => $#calc.pow(2, j) s_#j$).join($+$) - #ks_qubo.source.instance.capacity)^2 $
1649+
so any violation of the equality is more expensive than the entire knapsack value range.
1650+
1651+
*Step 4 -- Verify a solution.* The QUBO ground state $bold(z) = (#ks_qubo_sol.target_config.map(str).join(", "))$ extracts to the knapsack choice $bold(x) = (#ks_qubo_sol.source_config.map(str).join(", "))$. This selects items $\{#ks_qubo_selected.map(str).join(", ")\}$ with total weight $#ks_qubo_selected.map(i => str(ks_qubo.source.instance.weights.at(i))).join(" + ") = #ks_qubo_sel_weight$ and total value $#ks_qubo_selected.map(i => str(ks_qubo.source.instance.values.at(i))).join(" + ") = #ks_qubo_sel_value$, so the slack bits are all zero and the penalty term vanishes #sym.checkmark.
1652+
1653+
*Count:* #ks_qubo.solutions.len() optimal QUBO solution. The source optimum is unique because items $\{#ks_qubo_selected.map(str).join(", ")\}$ are the only feasible selection achieving value #ks_qubo_sel_value.
1654+
],
1655+
)[
1656+
For a standard 0-1 Knapsack instance with nonnegative weights, nonnegative values, and nonnegative capacity, the inequality $sum_i w_i x_i lt.eq C$ is converted to equality using binary slack variables that encode the unused capacity. When $C > 0$, one can take $B = floor(log_2 C) + 1$ slack bits; when $C = 0$, a single slack bit also suffices. The penalty method (@sec:penalty-method) combines the negated value objective with a quadratic constraint penalty, producing a QUBO with $n + B$ binary variables.
1657+
][
1658+
_Construction._ Given $n$ items with nonnegative weights $w_0, dots, w_(n-1)$, nonnegative values $v_0, dots, v_(n-1)$, and nonnegative capacity $C$, introduce $B = floor(log_2 C) + 1$ binary slack variables $s_0, dots, s_(B-1)$ when $C > 0$ (or one slack bit when $C = 0$) to convert the capacity inequality to equality:
1659+
$ sum_(i=0)^(n-1) w_i x_i + sum_(j=0)^(B-1) 2^j s_j = C $
1660+
Let $a_k$ denote the constraint coefficient of the $k$-th binary variable ($a_k = w_k$ for $k < n$, $a_(n+j) = 2^j$ for $j < B$). The QUBO objective is:
1661+
$ f(bold(z)) = -sum_(i=0)^(n-1) v_i x_i + P (sum_k a_k z_k - C)^2 $
1662+
where $bold(z) = (x_0, dots, x_(n-1), s_0, dots, s_(B-1))$ and $P = 1 + sum_i v_i$. Expanding the quadratic penalty using $z_k^2 = z_k$ (binary):
1663+
$ Q_(k k) = P a_k^2 - 2 P C a_k - [k < n] v_k, quad Q_(i j) = 2 P a_i a_j quad (i < j) $
1664+
1665+
_Correctness._ ($arrow.r.double$) If $bold(x)^*$ is a feasible knapsack solution with value $V^*$, then there exist slack values $bold(s)^*$ satisfying the equality constraint (encoding $C - sum w_i x_i^*$ in binary), so $f(bold(z)^*) = -V^*$. ($arrow.l.double$) If the equality constraint is violated, the penalty $(sum a_k z_k - C)^2 gt.eq 1$ contributes at least $P > sum_i v_i$ to the objective. Since all values are nonnegative, every feasible assignment has objective in the range $[-sum_i v_i, 0]$, so that penalty exceeds the entire feasible value range. Among feasible assignments (penalty zero), $f$ reduces to $-sum v_i x_i$, minimized at the knapsack optimum.
1666+
1667+
_Solution extraction._ Discard slack variables: return $bold(z)[0..n]$.
1668+
]
1669+
16291670
#let qubo_ilp = load-example("QUBO", "ILP")
16301671
#let qubo_ilp_sol = qubo_ilp.solutions.at(0)
16311672
#reduction-rule("QUBO", "ILP",

docs/src/reductions/problem_schemas.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -229,17 +229,17 @@
229229
{
230230
"name": "weights",
231231
"type_name": "Vec<i64>",
232-
"description": "Item weights w_i"
232+
"description": "Nonnegative item weights w_i"
233233
},
234234
{
235235
"name": "values",
236236
"type_name": "Vec<i64>",
237-
"description": "Item values v_i"
237+
"description": "Nonnegative item values v_i"
238238
},
239239
{
240240
"name": "capacity",
241241
"type_name": "i64",
242-
"description": "Knapsack capacity C"
242+
"description": "Nonnegative knapsack capacity C"
243243
}
244244
]
245245
},

docs/src/reductions/reduction_graph.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -727,6 +727,17 @@
727727
],
728728
"doc_path": "rules/sat_ksat/index.html"
729729
},
730+
{
731+
"source": 22,
732+
"target": 47,
733+
"overhead": [
734+
{
735+
"field": "num_vars",
736+
"formula": "num_items + num_slack_bits"
737+
}
738+
],
739+
"doc_path": "rules/knapsack_qubo/index.html"
740+
},
730741
{
731742
"source": 23,
732743
"target": 11,

src/example_db/rule_builders.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@ mod tests {
1212
use super::*;
1313

1414
#[test]
15-
fn builds_all_42_canonical_rule_examples() {
15+
fn builds_all_canonical_rule_examples() {
1616
let examples = build_rule_examples();
1717

18-
assert_eq!(examples.len(), 42);
18+
assert!(!examples.is_empty());
1919
assert!(examples
2020
.iter()
2121
.all(|example| !example.source.problem.is_empty()));

src/models/misc/knapsack.rs

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,17 @@ inventory::submit! {
1717
module_path: module_path!(),
1818
description: "Select items to maximize total value subject to weight capacity constraint",
1919
fields: &[
20-
FieldInfo { name: "weights", type_name: "Vec<i64>", description: "Item weights w_i" },
21-
FieldInfo { name: "values", type_name: "Vec<i64>", description: "Item values v_i" },
22-
FieldInfo { name: "capacity", type_name: "i64", description: "Knapsack capacity C" },
20+
FieldInfo { name: "weights", type_name: "Vec<i64>", description: "Nonnegative item weights w_i" },
21+
FieldInfo { name: "values", type_name: "Vec<i64>", description: "Nonnegative item values v_i" },
22+
FieldInfo { name: "capacity", type_name: "i64", description: "Nonnegative knapsack capacity C" },
2323
],
2424
}
2525
}
2626

2727
/// The 0-1 Knapsack problem.
2828
///
29-
/// Given `n` items, each with weight `w_i` and value `v_i`, and a capacity `C`,
29+
/// Given `n` items, each with nonnegative weight `w_i` and nonnegative value `v_i`,
30+
/// and a nonnegative capacity `C`,
3031
/// find a subset `S ⊆ {0, ..., n-1}` such that `∑_{i∈S} w_i ≤ C`,
3132
/// maximizing `∑_{i∈S} v_i`.
3233
///
@@ -47,22 +48,35 @@ inventory::submit! {
4748
/// ```
4849
#[derive(Debug, Clone, Serialize, Deserialize)]
4950
pub struct Knapsack {
51+
#[serde(deserialize_with = "nonnegative_i64_vec::deserialize")]
5052
weights: Vec<i64>,
53+
#[serde(deserialize_with = "nonnegative_i64_vec::deserialize")]
5154
values: Vec<i64>,
55+
#[serde(deserialize_with = "nonnegative_i64::deserialize")]
5256
capacity: i64,
5357
}
5458

5559
impl Knapsack {
5660
/// Create a new Knapsack instance.
5761
///
5862
/// # Panics
59-
/// Panics if `weights` and `values` have different lengths.
63+
/// Panics if `weights` and `values` have different lengths, or if any
64+
/// weight, value, or the capacity is negative.
6065
pub fn new(weights: Vec<i64>, values: Vec<i64>, capacity: i64) -> Self {
6166
assert_eq!(
6267
weights.len(),
6368
values.len(),
6469
"weights and values must have the same length"
6570
);
71+
assert!(
72+
weights.iter().all(|&weight| weight >= 0),
73+
"Knapsack weights must be nonnegative"
74+
);
75+
assert!(
76+
values.iter().all(|&value| value >= 0),
77+
"Knapsack values must be nonnegative"
78+
);
79+
assert!(capacity >= 0, "Knapsack capacity must be nonnegative");
6680
Self {
6781
weights,
6882
values,
@@ -89,6 +103,18 @@ impl Knapsack {
89103
pub fn num_items(&self) -> usize {
90104
self.weights.len()
91105
}
106+
107+
/// Returns the number of binary slack bits used by the QUBO encoding.
108+
///
109+
/// For positive capacity this is `floor(log2(C)) + 1`; for zero capacity we
110+
/// keep one slack bit so the encoding shape remains uniform.
111+
pub fn num_slack_bits(&self) -> usize {
112+
if self.capacity == 0 {
113+
1
114+
} else {
115+
(u64::BITS - (self.capacity as u64).leading_zeros()) as usize
116+
}
117+
}
92118
}
93119

94120
impl Problem for Knapsack {
@@ -141,6 +167,42 @@ crate::declare_variants! {
141167
default opt Knapsack => "2^(num_items / 2)",
142168
}
143169

170+
mod nonnegative_i64 {
171+
use serde::de::Error;
172+
use serde::{Deserialize, Deserializer};
173+
174+
pub fn deserialize<'de, D>(deserializer: D) -> Result<i64, D::Error>
175+
where
176+
D: Deserializer<'de>,
177+
{
178+
let value = i64::deserialize(deserializer)?;
179+
if value < 0 {
180+
return Err(D::Error::custom(format!(
181+
"expected nonnegative integer, got {value}"
182+
)));
183+
}
184+
Ok(value)
185+
}
186+
}
187+
188+
mod nonnegative_i64_vec {
189+
use serde::de::Error;
190+
use serde::{Deserialize, Deserializer};
191+
192+
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<i64>, D::Error>
193+
where
194+
D: Deserializer<'de>,
195+
{
196+
let values = Vec::<i64>::deserialize(deserializer)?;
197+
if let Some(value) = values.iter().copied().find(|value| *value < 0) {
198+
return Err(D::Error::custom(format!(
199+
"expected nonnegative integers, got {value}"
200+
)));
201+
}
202+
Ok(values)
203+
}
204+
}
205+
144206
#[cfg(test)]
145207
#[path = "../../unit_tests/models/misc/knapsack.rs"]
146208
mod tests;

src/rules/knapsack_qubo.rs

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
//! Reduction from Knapsack to QUBO.
2+
//!
3+
//! Converts a nonnegative 0-1 Knapsack instance into QUBO by turning the
4+
//! capacity inequality sum(w_i * x_i) <= C into equality using binary slack
5+
//! variables, then constructing a QUBO that combines the objective
6+
//! -sum(v_i * x_i) with a quadratic penalty
7+
//! P * (sum(w_i * x_i) + sum(2^j * s_j) - C)^2.
8+
//! For nonnegative values, penalty P > sum(v_i) ensures any infeasible solution
9+
//! costs more than any feasible one.
10+
//!
11+
//! Reference: Lucas, 2014, "Ising formulations of many NP problems".
12+
13+
use crate::models::algebraic::QUBO;
14+
use crate::models::misc::Knapsack;
15+
use crate::reduction;
16+
use crate::rules::traits::{ReduceTo, ReductionResult};
17+
18+
/// Result of reducing Knapsack to QUBO.
19+
#[derive(Debug, Clone)]
20+
pub struct ReductionKnapsackToQUBO {
21+
target: QUBO<f64>,
22+
num_items: usize,
23+
}
24+
25+
impl ReductionResult for ReductionKnapsackToQUBO {
26+
type Source = Knapsack;
27+
type Target = QUBO<f64>;
28+
29+
fn target_problem(&self) -> &Self::Target {
30+
&self.target
31+
}
32+
33+
fn extract_solution(&self, target_solution: &[usize]) -> Vec<usize> {
34+
target_solution[..self.num_items].to_vec()
35+
}
36+
}
37+
38+
#[reduction(overhead = { num_vars = "num_items + num_slack_bits" })]
39+
impl ReduceTo<QUBO<f64>> for Knapsack {
40+
type Result = ReductionKnapsackToQUBO;
41+
42+
fn reduce_to(&self) -> Self::Result {
43+
let n = self.num_items();
44+
let c = self.capacity();
45+
let b = self.num_slack_bits();
46+
let total = n + b;
47+
48+
// Penalty must exceed sum of all values
49+
let sum_values: i64 = self.values().iter().sum();
50+
let penalty = (sum_values + 1) as f64;
51+
52+
// Build QUBO matrix
53+
// H = -sum(v_i * x_i) + P * (sum(w_i * x_i) + sum(2^j * s_j) - C)^2
54+
//
55+
// Let a_k be the coefficient of variable k in the constraint:
56+
// a_k = w_k for k < n (item variables)
57+
// a_{n+j} = 2^j for j < B (slack variables)
58+
//
59+
// Expanding the penalty:
60+
// P * (sum(a_k * z_k) - C)^2 = P * sum_i sum_j a_i * a_j * z_i * z_j
61+
// - 2P * C * sum(a_k * z_k) + P * C^2
62+
// Since z_k is binary, z_k^2 = z_k, so diagonal terms become:
63+
// Q[k][k] = P * a_k^2 - 2P * C * a_k (from penalty)
64+
// Q[k][k] -= v_k (from objective, item vars only)
65+
// Off-diagonal terms (i < j):
66+
// Q[i][j] = 2P * a_i * a_j
67+
68+
let mut coeffs = vec![0.0f64; total];
69+
for (i, coeff) in coeffs.iter_mut().enumerate().take(n) {
70+
*coeff = self.weights()[i] as f64;
71+
}
72+
for j in 0..b {
73+
coeffs[n + j] = (1u64 << j) as f64;
74+
}
75+
76+
let c_f = c as f64;
77+
let mut matrix = vec![vec![0.0f64; total]; total];
78+
79+
// Diagonal: P * a_k^2 - 2P * C * a_k - v_k (for items)
80+
for k in 0..total {
81+
matrix[k][k] = penalty * coeffs[k] * coeffs[k] - 2.0 * penalty * c_f * coeffs[k];
82+
if k < n {
83+
matrix[k][k] -= self.values()[k] as f64;
84+
}
85+
}
86+
87+
// Off-diagonal (upper triangular): 2P * a_i * a_j
88+
for i in 0..total {
89+
for j in (i + 1)..total {
90+
matrix[i][j] = 2.0 * penalty * coeffs[i] * coeffs[j];
91+
}
92+
}
93+
94+
ReductionKnapsackToQUBO {
95+
target: QUBO::from_matrix(matrix),
96+
num_items: n,
97+
}
98+
}
99+
}
100+
101+
#[cfg(feature = "example-db")]
102+
pub(crate) fn canonical_rule_example_specs() -> Vec<crate::example_db::specs::RuleExampleSpec> {
103+
vec![crate::example_db::specs::RuleExampleSpec {
104+
id: "knapsack_to_qubo",
105+
build: || {
106+
crate::example_db::specs::direct_best_example::<_, QUBO<f64>, _>(
107+
Knapsack::new(vec![2, 3, 4, 5], vec![3, 4, 5, 7], 7),
108+
|_, _| true,
109+
)
110+
},
111+
}]
112+
}
113+
114+
#[cfg(test)]
115+
#[path = "../unit_tests/rules/knapsack_qubo.rs"]
116+
mod tests;

src/rules/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ pub(crate) mod coloring_qubo;
1111
pub(crate) mod factoring_circuit;
1212
mod graph;
1313
mod kcoloring_casts;
14+
mod knapsack_qubo;
1415
mod ksatisfiability_casts;
1516
pub(crate) mod ksatisfiability_qubo;
1617
pub(crate) mod ksatisfiability_subsetsum;
@@ -79,6 +80,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec<crate::example_db::specs::Ru
7980
specs.extend(circuit_spinglass::canonical_rule_example_specs());
8081
specs.extend(coloring_qubo::canonical_rule_example_specs());
8182
specs.extend(factoring_circuit::canonical_rule_example_specs());
83+
specs.extend(knapsack_qubo::canonical_rule_example_specs());
8284
specs.extend(ksatisfiability_qubo::canonical_rule_example_specs());
8385
specs.extend(ksatisfiability_subsetsum::canonical_rule_example_specs());
8486
specs.extend(maximumclique_maximumindependentset::canonical_rule_example_specs());

0 commit comments

Comments
 (0)