Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,12 @@ quickcheck-tests.json
/dist/
/build/

# Python (for pre-commit)
# Python
__pycache__/
*.py[cod]
*$py.class
.Python
.venv/
venv/
env/
ENV/
Expand Down
7 changes: 6 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Makefile for problemreductions

.PHONY: help build test fmt clippy doc mdbook paper clean coverage rust-export compare
.PHONY: help build test fmt clippy doc mdbook paper clean coverage rust-export compare qubo-testdata

# Default target
help:
Expand All @@ -18,6 +18,7 @@ help:
@echo " check - Quick check (fmt + clippy + test)"
@echo " rust-export - Generate Rust mapping JSON exports"
@echo " compare - Generate and compare Rust mapping exports"
@echo " qubo-testdata - Regenerate QUBO test data (requires uv)"

# Build the project
build:
Expand Down Expand Up @@ -65,6 +66,10 @@ clean:
check: fmt-check clippy test
@echo "✅ All checks passed!"

# Regenerate QUBO test data from Python (requires uv)
qubo-testdata:
cd scripts && uv run python generate_qubo_tests.py

# Generate Rust mapping JSON exports for all graphs and modes
GRAPHS := diamond bull house petersen
MODES := unweighted weighted triangular
Expand Down
1 change: 1 addition & 0 deletions scripts/.python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.12
263 changes: 263 additions & 0 deletions scripts/generate_qubo_tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
"""Generate QUBO test datasets using qubogen.

For each supported problem type, creates a small instance, reduces it to QUBO
via qubogen, brute-force solves both sides, and exports JSON ground truth
to tests/data/qubo/.

Usage:
uv run python scripts/generate_qubo_tests.py
"""

import json
import os
from itertools import product
from pathlib import Path

import numpy as np

# Monkey-patch for qubogen compatibility with numpy >= 1.24
np.float = np.float64
np.int = np.int_
np.bool = np.bool_

import qubogen


def brute_force_qubo(Q: np.ndarray) -> dict:
"""Brute-force solve a QUBO: minimize x^T Q x over binary x."""
n = Q.shape[0]
best_val = float("inf")
best_configs = []
for bits in product(range(2), repeat=n):
x = np.array(bits, dtype=float)
val = float(x @ Q @ x)
if val < best_val - 1e-9:
best_val = val
best_configs = [list(bits)]
elif abs(val - best_val) < 1e-9:
best_configs.append(list(bits))
return {"value": best_val, "configs": best_configs}


def save_test(name: str, data: dict, outdir: Path):
"""Save test data as compact JSON."""
path = outdir / f"{name}.json"
with open(path, "w") as f:
json.dump(data, f, separators=(",", ":"))
print(f" wrote {path} ({path.stat().st_size} bytes)")


def generate_maxcut(outdir: Path):
"""MaxCut on a small graph (4 nodes, 4 edges)."""
edges = [(0, 1), (1, 2), (2, 3), (0, 3)]
n_nodes = 4
g = qubogen.Graph(edges=np.array(edges), n_nodes=n_nodes)
Q = qubogen.qubo_max_cut(g)

qubo_result = brute_force_qubo(Q)

# MaxCut maximizes cut edges; QUBO minimizes, so optimal QUBO value
# corresponds to maximum cut (negated).
save_test("maxcut_to_qubo", {
"problem": "MaxCut",
"source": {"num_vertices": n_nodes, "edges": edges},
"qubo_matrix": Q.tolist(),
"qubo_num_vars": int(Q.shape[0]),
"qubo_optimal": qubo_result,
}, outdir)


def generate_vertex_covering(outdir: Path):
"""Minimum Vertex Cover on a small graph (4 nodes, 5 edges)."""
edges = [(0, 1), (1, 2), (2, 3), (0, 3), (0, 2)]
n_nodes = 4
penalty = 8.0
g = qubogen.Graph(edges=np.array(edges), n_nodes=n_nodes)
Q = qubogen.qubo_mvc(g, penalty=penalty)

qubo_result = brute_force_qubo(Q)

save_test("vertexcovering_to_qubo", {
"problem": "VertexCovering",
"source": {"num_vertices": n_nodes, "edges": edges, "penalty": penalty},
"qubo_matrix": Q.tolist(),
"qubo_num_vars": int(Q.shape[0]),
"qubo_optimal": qubo_result,
}, outdir)


def generate_independent_set(outdir: Path):
"""Independent Set on a small graph.

IndependentSet is the complement of VertexCover: maximize |S| s.t. no
two adjacent vertices are in S. We formulate as QUBO by negating the
linear terms of MVC (minimize -|S| + penalty * constraint violations).
"""
edges = [(0, 1), (1, 2), (2, 3), (0, 3)]
n_nodes = 4
penalty = 8.0
g = qubogen.Graph(edges=np.array(edges), n_nodes=n_nodes)

# Independent set QUBO: maximize sum(x_i) s.t. x_i*x_j = 0 for edges
# = minimize -sum(x_i) + P * sum_{(i,j)} x_i*x_j
Q = np.zeros((n_nodes, n_nodes))
for i in range(n_nodes):
Q[i][i] = -1.0
for i, j in edges:
Q[i][j] += penalty

qubo_result = brute_force_qubo(Q)

save_test("independentset_to_qubo", {
"problem": "IndependentSet",
"source": {"num_vertices": n_nodes, "edges": edges, "penalty": penalty},
"qubo_matrix": Q.tolist(),
"qubo_num_vars": int(Q.shape[0]),
"qubo_optimal": qubo_result,
}, outdir)


def generate_graph_coloring(outdir: Path):
"""Graph Coloring on a small graph (3 nodes triangle, 3 colors)."""
edges = [(0, 1), (1, 2), (0, 2)]
n_nodes = 3
n_color = 3
penalty = 10.0
g = qubogen.Graph(edges=np.array(edges), n_nodes=n_nodes)
Q = qubogen.qubo_graph_coloring(g, n_color=n_color, penalty=penalty)

qubo_result = brute_force_qubo(Q)

# QUBO variables: n_nodes * n_color (one-hot encoding)
save_test("coloring_to_qubo", {
"problem": "Coloring",
"source": {
"num_vertices": n_nodes,
"edges": edges,
"num_colors": n_color,
"penalty": penalty,
},
"qubo_matrix": Q.tolist(),
"qubo_num_vars": int(Q.shape[0]),
"qubo_optimal": qubo_result,
}, outdir)


def generate_set_packing(outdir: Path):
"""Set Packing: select maximum-weight non-overlapping sets."""
# 3 sets over 4 elements
# set 0: {0, 2}
# set 1: {1, 2}
# set 2: {0, 3}
sets = [[0, 2], [1, 2], [0, 3]]
n_elements = 4
n_sets = len(sets)
weights = [1.0, 2.0, 1.5]
penalty = 8.0

# Build incidence matrix (elements x sets)
a = np.zeros((n_elements, n_sets))
for j, s in enumerate(sets):
for i in s:
a[i][j] = 1

Q = qubogen.qubo_set_pack(a, np.array(weights), penalty=penalty)

qubo_result = brute_force_qubo(Q)

save_test("setpacking_to_qubo", {
"problem": "SetPacking",
"source": {
"sets": sets,
"num_elements": n_elements,
"weights": weights,
"penalty": penalty,
},
"qubo_matrix": Q.tolist(),
"qubo_num_vars": int(Q.shape[0]),
"qubo_optimal": qubo_result,
}, outdir)


def generate_max2sat(outdir: Path):
"""Max 2-SAT: maximize satisfied clauses."""
# 3 variables, 4 clauses:
# (x0 OR x1), (NOT x0 OR x2), (x1 OR NOT x2), (NOT x1 OR NOT x2)
literals = np.array([[0, 1], [0, 2], [1, 2], [1, 2]])
signs = np.array(
[[True, True], [False, True], [True, False], [False, False]]
)

c = qubogen.Clauses(literals=literals, signs=signs)
Q = qubogen.qubo_max2sat(c)

qubo_result = brute_force_qubo(Q)

# Convert to list-of-clauses format matching our KSatisfiability model
clauses = []
for i in range(len(literals)):
clause = []
for j in range(2):
var = int(literals[i][j])
negated = not bool(signs[i][j])
clause.append({"variable": var, "negated": negated})
clauses.append(clause)

save_test("ksatisfiability_to_qubo", {
"problem": "KSatisfiability",
"source": {"num_variables": 3, "clauses": clauses},
"qubo_matrix": Q.tolist(),
"qubo_num_vars": int(Q.shape[0]),
"qubo_optimal": qubo_result,
}, outdir)


def generate_ilp(outdir: Path):
"""Binary ILP (General 0/1 Programming): min c^T x, s.t. Ax <= b."""
# 3 variables
# minimize: x0 + 2*x1 + 3*x2
# s.t.: x0 + x1 <= 1
# x1 + x2 <= 1
cost = np.array([1.0, 2.0, 3.0])
A = np.array([[1.0, 1.0, 0.0], [0.0, 1.0, 1.0]])
b = np.array([1.0, 1.0])
sign = np.array([-1, -1]) # -1 means <=
penalty = 10.0

Q = qubogen.qubo_general01(cost, A, b, sign, penalty=penalty)

qubo_result = brute_force_qubo(Q)

save_test("ilp_to_qubo", {
"problem": "ILP",
"source": {
"num_variables": 3,
"objective": cost.tolist(),
"constraints_lhs": A.tolist(),
"constraints_rhs": b.tolist(),
"constraint_signs": sign.tolist(),
"penalty": penalty,
},
"qubo_matrix": Q.tolist(),
"qubo_num_vars": int(Q.shape[0]),
"qubo_optimal": qubo_result,
}, outdir)


def main():
outdir = Path(__file__).resolve().parent.parent / "tests" / "data" / "qubo"
outdir.mkdir(parents=True, exist_ok=True)

print("Generating QUBO test datasets...")
generate_maxcut(outdir)
generate_vertex_covering(outdir)
generate_independent_set(outdir)
generate_graph_coloring(outdir)
generate_set_packing(outdir)
generate_max2sat(outdir)
generate_ilp(outdir)
print("Done.")


if __name__ == "__main__":
main()
9 changes: 9 additions & 0 deletions scripts/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[project]
name = "scripts"
version = "0.1.0"
description = "Test data generation scripts for problem-reductions"
requires-python = ">=3.12"
dependencies = [
"numpy>=1.26,<2",
"qubogen>=0.1.1",
]
55 changes: 55 additions & 0 deletions scripts/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions tests/data/qubo/coloring_to_qubo.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"problem":"Coloring","source":{"num_vertices":3,"edges":[[0,1],[1,2],[0,2]],"num_colors":3,"penalty":10.0},"qubo_matrix":[[-10.0,10.0,10.0,5.0,0.0,0.0,5.0,0.0,0.0],[10.0,-10.0,10.0,0.0,5.0,0.0,0.0,5.0,0.0],[10.0,10.0,-10.0,0.0,0.0,5.0,0.0,0.0,5.0],[5.0,0.0,0.0,-10.0,10.0,10.0,5.0,0.0,0.0],[0.0,5.0,0.0,10.0,-10.0,10.0,0.0,5.0,0.0],[0.0,0.0,5.0,10.0,10.0,-10.0,0.0,0.0,5.0],[5.0,0.0,0.0,5.0,0.0,0.0,-10.0,10.0,10.0],[0.0,5.0,0.0,0.0,5.0,0.0,10.0,-10.0,10.0],[0.0,0.0,5.0,0.0,0.0,5.0,10.0,10.0,-10.0]],"qubo_num_vars":9,"qubo_optimal":{"value":-30.0,"configs":[[0,0,1,0,1,0,1,0,0],[0,0,1,1,0,0,0,1,0],[0,1,0,0,0,1,1,0,0],[0,1,0,1,0,0,0,0,1],[1,0,0,0,0,1,0,1,0],[1,0,0,0,1,0,0,0,1]]}}
1 change: 1 addition & 0 deletions tests/data/qubo/ilp_to_qubo.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"problem":"ILP","source":{"num_variables":3,"objective":[1.0,2.0,3.0],"constraints_lhs":[[1.0,1.0,0.0],[0.0,1.0,1.0]],"constraints_rhs":[1.0,1.0],"constraint_signs":[-1,-1],"penalty":10.0},"qubo_matrix":[[-11.0,10.0,0.0],[10.0,-22.0,10.0],[0.0,10.0,-13.0]],"qubo_num_vars":3,"qubo_optimal":{"value":-24.0,"configs":[[1,0,1]]}}
1 change: 1 addition & 0 deletions tests/data/qubo/independentset_to_qubo.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"problem":"IndependentSet","source":{"num_vertices":4,"edges":[[0,1],[1,2],[2,3],[0,3]],"penalty":8.0},"qubo_matrix":[[-1.0,8.0,0.0,8.0],[0.0,-1.0,8.0,0.0],[0.0,0.0,-1.0,8.0],[0.0,0.0,0.0,-1.0]],"qubo_num_vars":4,"qubo_optimal":{"value":-2.0,"configs":[[0,1,0,1],[1,0,1,0]]}}
1 change: 1 addition & 0 deletions tests/data/qubo/ksatisfiability_to_qubo.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"problem":"KSatisfiability","source":{"num_variables":3,"clauses":[[{"variable":0,"negated":false},{"variable":1,"negated":false}],[{"variable":0,"negated":true},{"variable":2,"negated":false}],[{"variable":1,"negated":false},{"variable":2,"negated":true}],[{"variable":1,"negated":true},{"variable":2,"negated":true}]]},"qubo_matrix":[[0.0,0.5,-0.5],[0.5,-1.0,0.0],[-0.5,0.0,1.0]],"qubo_num_vars":3,"qubo_optimal":{"value":-1.0,"configs":[[0,1,0]]}}
1 change: 1 addition & 0 deletions tests/data/qubo/maxcut_to_qubo.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"problem":"MaxCut","source":{"num_vertices":4,"edges":[[0,1],[1,2],[2,3],[0,3]]},"qubo_matrix":[[-2.0,1.0,0.0,1.0],[1.0,-2.0,1.0,0.0],[0.0,1.0,-2.0,1.0],[1.0,0.0,1.0,-2.0]],"qubo_num_vars":4,"qubo_optimal":{"value":-4.0,"configs":[[0,1,0,1],[1,0,1,0]]}}
1 change: 1 addition & 0 deletions tests/data/qubo/setpacking_to_qubo.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"problem":"SetPacking","source":{"sets":[[0,2],[1,2],[0,3]],"num_elements":4,"weights":[1.0,2.0,1.5],"penalty":8.0},"qubo_matrix":[[-1.0,4.0,4.0],[4.0,-2.0,0.0],[4.0,0.0,-1.5]],"qubo_num_vars":3,"qubo_optimal":{"value":-3.5,"configs":[[0,1,1]]}}
1 change: 1 addition & 0 deletions tests/data/qubo/vertexcovering_to_qubo.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"problem":"VertexCovering","source":{"num_vertices":4,"edges":[[0,1],[1,2],[2,3],[0,3],[0,2]],"penalty":8.0},"qubo_matrix":[[-23.0,4.0,4.0,4.0],[4.0,-15.0,4.0,0.0],[4.0,4.0,-23.0,4.0],[4.0,0.0,4.0,-15.0]],"qubo_num_vars":4,"qubo_optimal":{"value":-38.0,"configs":[[1,0,1,0]]}}