sudoku-smt-solvers 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ from .benchmarks import BenchmarkRunner, SudokuGenerator
2
+ from .solvers import CVC5Solver, DPLLSolver, Z3Solver
3
+
4
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ from .benchmark_runner import BenchmarkRunner
2
+ from .sudoku_generator.dfs_solver import DFSSolver
3
+ from .sudoku_generator.sudoku_generator import SudokuGenerator
4
+ from .sudoku_generator.las_vegas import LasVegasGenerator
5
+ from .sudoku_generator.hole_digger import HoleDigger
@@ -0,0 +1,211 @@
1
+ import json
2
+ import os
3
+ import time
4
+ import multiprocessing
5
+ from typing import Dict, List, Optional
6
+
7
+ from ..solvers import CVC5Solver, DPLLSolver, DPLLTSolver, Z3Solver
8
+
9
+
10
+ class BenchmarkRunner:
11
+ """A benchmark runner for comparing different Sudoku solver implementations.
12
+
13
+ This class manages running performance benchmarks across multiple Sudoku solvers,
14
+ collecting metrics like solve time and propagation counts, and saving results
15
+ to CSV files.
16
+
17
+ Attributes:
18
+ puzzles_dir (str): Directory containing puzzle JSON files
19
+ results_dir (str): Directory where benchmark results are saved
20
+ timeout (int): Maximum time in seconds allowed for each solver attempt
21
+ solvers (dict): Dictionary mapping solver names to solver classes
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ puzzles_dir: str = "benchmarks/puzzles",
27
+ results_dir: str = "benchmarks/results",
28
+ timeout: int = 120,
29
+ ):
30
+ """Initialize the benchmark runner.
31
+
32
+ Args:
33
+ puzzles_dir: Directory containing puzzle JSON files
34
+ results_dir: Directory where benchmark results will be saved
35
+ timeout: Maximum time in seconds allowed for each solver attempt
36
+ """
37
+ self.puzzles_dir = puzzles_dir
38
+ self.results_dir = results_dir
39
+ self.timeout = timeout
40
+ self.solvers = {
41
+ "CVC5": CVC5Solver,
42
+ "DPLL": DPLLSolver,
43
+ "DPLL(T)": DPLLTSolver,
44
+ "Z3": Z3Solver,
45
+ }
46
+ os.makedirs(results_dir, exist_ok=True)
47
+
48
+ def load_puzzle(self, puzzle_id: str) -> Optional[List[List[int]]]:
49
+ puzzle_path = os.path.join(self.puzzles_dir, f"{puzzle_id}.json")
50
+ try:
51
+ with open(puzzle_path, "r") as f:
52
+ data = json.load(f)
53
+ for key in ["grid", "puzzle", "gridc"]:
54
+ if key in data:
55
+ return data[key]
56
+ print(
57
+ f"No valid grid found in {puzzle_id}. Available keys: {list(data.keys())}"
58
+ )
59
+ return None
60
+ except Exception as e:
61
+ print(f"Error loading puzzle {puzzle_id}: {e}")
62
+ return None
63
+
64
+ def _solve_with_timeout(self, solver_class, puzzle, queue):
65
+ solver = solver_class(puzzle)
66
+ result = solver.solve()
67
+ # Pack both the result and propagation count
68
+ queue.put((result, getattr(solver, "propagated_clauses", 0)))
69
+
70
+ def run_solver(self, solver_name: str, puzzle: List[List[int]]) -> Dict:
71
+ """Run a single solver on a puzzle and collect results with timeout.
72
+
73
+ Args:
74
+ solver_name: Name of the solver to use
75
+ puzzle: 2D list representing the Sudoku puzzle
76
+
77
+ Returns:
78
+ Dict containing:
79
+ status: 'sat', 'unsat', 'timeout', or 'error'
80
+ solve_time: Time taken in seconds
81
+ propagations: Number of clause propagations (if available)
82
+ """
83
+ solver_class = self.solvers[solver_name]
84
+
85
+ # Create queue for getting results
86
+ ctx = multiprocessing.get_context("spawn")
87
+ queue = ctx.Queue()
88
+
89
+ # Create process for solving
90
+ process = ctx.Process(
91
+ target=self._solve_with_timeout, args=(solver_class, puzzle, queue)
92
+ )
93
+
94
+ start_time = time.time()
95
+ process.start()
96
+ process.join(timeout=self.timeout)
97
+
98
+ solve_time = time.time() - start_time
99
+
100
+ if process.is_alive():
101
+ process.terminate()
102
+ process.join()
103
+ return {"status": "timeout", "solve_time": self.timeout, "propagations": 0}
104
+
105
+ # Get result and propagation count from queue
106
+ try:
107
+ result, propagations = queue.get_nowait()
108
+ return {
109
+ "status": "sat" if result else "unsat",
110
+ "solve_time": solve_time,
111
+ "propagations": propagations,
112
+ }
113
+ except:
114
+ return {"status": "error", "solve_time": solve_time, "propagations": 0}
115
+
116
+ def run_benchmarks(self) -> None:
117
+ """Run all solvers on all puzzles and save results.
118
+
119
+ Executes benchmarks for each solver on each puzzle, collecting performance
120
+ metrics and saving results to a timestamped CSV file.
121
+
122
+ The CSV output includes:
123
+ - Solver name
124
+ - Puzzle unique ID
125
+ - Solution status
126
+ - Solve time
127
+ - Propagation count
128
+
129
+ Also calculates and stores aggregate statistics per solver:
130
+ - Total puzzles attempted
131
+ - Number of puzzles solved
132
+ - Total and average solving times
133
+ - Total and average propagation counts
134
+ """
135
+ results = {
136
+ solver_name: {
137
+ "puzzles": {},
138
+ "stats": {
139
+ "total_puzzles": 0,
140
+ "solved_count": 0,
141
+ "total_time": 0,
142
+ "total_propagations": 0,
143
+ "avg_time": 0,
144
+ "avg_propagations": 0,
145
+ },
146
+ }
147
+ for solver_name in self.solvers
148
+ }
149
+
150
+ puzzle_files = [f for f in os.listdir(self.puzzles_dir) if f.endswith(".json")]
151
+ print(f"Found {len(puzzle_files)} puzzle files") # Debug
152
+
153
+ for puzzle_file in puzzle_files:
154
+ puzzle_id = puzzle_file[:-5]
155
+ puzzle = self.load_puzzle(puzzle_id)
156
+
157
+ if not puzzle:
158
+ print(f"Failed to load puzzle: {puzzle_id}") # Debug
159
+ continue
160
+
161
+ for solver_name in self.solvers:
162
+ print(f"Running {solver_name} on puzzle {puzzle_id}")
163
+ result = self.run_solver(solver_name, puzzle)
164
+ print(f"Result: {result}") # Debug
165
+
166
+ results[solver_name]["puzzles"][puzzle_id] = result
167
+
168
+ stats = results[solver_name]["stats"]
169
+ stats["total_puzzles"] += 1
170
+ if result["status"] == "sat":
171
+ stats["solved_count"] += 1
172
+ stats["total_time"] += result["solve_time"]
173
+ stats["total_propagations"] += result["propagations"]
174
+
175
+ # Calculate averages
176
+ for solver_name, solver_stats in results.items():
177
+ stats = solver_stats["stats"]
178
+ total_puzzles = stats["total_puzzles"]
179
+ if total_puzzles > 0:
180
+ stats["avg_time"] = stats["total_time"] / total_puzzles
181
+ stats["avg_propagations"] = stats["total_propagations"] / total_puzzles
182
+ print(f"Stats for {solver_name}: {stats}") # Debug
183
+
184
+ # Save results
185
+ timestamp = time.strftime("%Y%m%d_%H%M%S")
186
+
187
+ # Debug CSV data
188
+ csv_data = []
189
+ for solver_name, solver_results in results.items():
190
+ for puzzle_id, puzzle_result in solver_results["puzzles"].items():
191
+ row = {
192
+ "solver": solver_name,
193
+ "puzzle_id": puzzle_id,
194
+ "status": puzzle_result["status"],
195
+ "solve_time": puzzle_result["solve_time"],
196
+ "propagations": puzzle_result["propagations"],
197
+ }
198
+ csv_data.append(row)
199
+ print(f"Adding CSV row: {row}") # Debug
200
+
201
+ csv_path = os.path.join(self.results_dir, f"benchmark_{timestamp}.csv")
202
+ print(f"Writing {len(csv_data)} rows to CSV") # Debug
203
+
204
+ with open(csv_path, "w") as f:
205
+ if csv_data:
206
+ headers = csv_data[0].keys()
207
+ f.write(",".join(headers) + "\n")
208
+ for row in csv_data:
209
+ f.write(",".join(str(row[h]) for h in headers) + "\n")
210
+
211
+ print(f"Benchmark results saved to {csv_path}")
@@ -0,0 +1,4 @@
1
+ from .cvc5_solver import CVC5Solver
2
+ from .dpll_solver import DPLLSolver
3
+ from .dpllt_solver import DPLLTSolver
4
+ from .z3_solver import Z3Solver
@@ -0,0 +1,207 @@
1
+ from typing import List, Optional
2
+ import atexit
3
+ from cvc5 import Kind, Solver
4
+ from .sudoku_error import SudokuError
5
+
6
+
7
+ class CVC5Solver:
8
+ """CVC5-based Sudoku solver using SMT encoding.
9
+
10
+ Solves 25x25 Sudoku puzzles by encoding the problem as an SMT formula and using
11
+ CVC5 to find a satisfying assignment.
12
+
13
+ Attributes:
14
+ sudoku (List[List[int]]): Input puzzle as 25x25 grid
15
+ size (int): Grid size (always 25)
16
+ solver (Solver): CVC5 solver instance
17
+ variables (List[List[Term]]): SMT variables for each cell
18
+ propagated_clauses (int): Counter for clause assertions
19
+
20
+ Example:
21
+ >>> puzzle = [[0 for _ in range(25)] for _ in range(25)] # Empty puzzle
22
+ >>> solver = CVC5Solver(puzzle)
23
+ >>> solution = solver.solve()
24
+ """
25
+
26
+ def __init__(self, sudoku):
27
+ """Initialize CVC5 Sudoku solver.
28
+
29
+ Args:
30
+ sudoku: 25x25 grid with values 0-25 (0 for empty cells)
31
+
32
+ Raises:
33
+ SudokuError: If puzzle format is invalid
34
+ """
35
+ if not sudoku or not isinstance(sudoku, list) or len(sudoku) != 25:
36
+ raise SudokuError("Invalid Sudoku puzzle: must be a 25x25 grid")
37
+
38
+ self._validate_input(sudoku)
39
+ self.sudoku = sudoku
40
+ self.size = len(sudoku)
41
+ self.solver = None
42
+ self.variables = None
43
+ self.propagated_clauses = 0
44
+
45
+ def _validate_input(self, sudoku):
46
+ for i, row in enumerate(sudoku):
47
+ if not isinstance(row, list) or len(row) != 25:
48
+ raise SudokuError(f"Invalid Sudoku puzzle: row {i} must have 25 cells")
49
+ for j, val in enumerate(row):
50
+ if not isinstance(val, int) or not (0 <= val <= 25):
51
+ raise SudokuError(
52
+ f"Invalid value at position ({i},{j}): must be between 0 and 25"
53
+ )
54
+
55
+ def _count_assertion(self):
56
+ self.propagated_clauses += 1
57
+
58
+ def create_variables(self):
59
+ self.solver = Solver()
60
+
61
+ # Configure CVC5 solver options
62
+ self.solver.setOption("produce-models", "true")
63
+ self.solver.setOption("incremental", "true")
64
+ self.solver.setLogic("QF_LIA") # Quantifier-Free Linear Integer Arithmetic
65
+
66
+ integer_sort = self.solver.getIntegerSort()
67
+ self.variables = [
68
+ [self.solver.mkConst(integer_sort, f"x_{i}_{j}") for j in range(25)]
69
+ for i in range(25)
70
+ ]
71
+ atexit.register(self.cleanup)
72
+
73
+ def encode_rules(self):
74
+ # Domain constraints
75
+ for i in range(25):
76
+ for j in range(25):
77
+ self.solver.assertFormula(
78
+ self.solver.mkTerm(
79
+ Kind.AND,
80
+ self.solver.mkTerm(
81
+ Kind.LEQ, self.solver.mkInteger(1), self.variables[i][j]
82
+ ),
83
+ self.solver.mkTerm(
84
+ Kind.LEQ, self.variables[i][j], self.solver.mkInteger(25)
85
+ ),
86
+ )
87
+ )
88
+ self._count_assertion()
89
+
90
+ # Row constraints
91
+ for i in range(25):
92
+ self.solver.assertFormula(
93
+ self.solver.mkTerm(
94
+ Kind.DISTINCT, *[self.variables[i][j] for j in range(25)]
95
+ )
96
+ )
97
+ self._count_assertion()
98
+
99
+ # Column constraints
100
+ for j in range(25):
101
+ self.solver.assertFormula(
102
+ self.solver.mkTerm(
103
+ Kind.DISTINCT, *[self.variables[i][j] for i in range(25)]
104
+ )
105
+ )
106
+ self._count_assertion()
107
+
108
+ # 5x5 subgrid constraints
109
+ for block_row in range(0, 25, 5):
110
+ for block_col in range(0, 25, 5):
111
+ block_vars = [
112
+ self.variables[i][j]
113
+ for i in range(block_row, block_row + 5)
114
+ for j in range(block_col, block_col + 5)
115
+ ]
116
+ self.solver.assertFormula(
117
+ self.solver.mkTerm(Kind.DISTINCT, *block_vars)
118
+ )
119
+ self._count_assertion()
120
+
121
+ def encode_puzzle(self):
122
+ for i in range(25):
123
+ for j in range(25):
124
+ if self.sudoku[i][j] != 0: # Pre-filled cell
125
+ self.solver.assertFormula(
126
+ self.solver.mkTerm(
127
+ Kind.EQUAL,
128
+ self.variables[i][j],
129
+ self.solver.mkInteger(self.sudoku[i][j]),
130
+ )
131
+ )
132
+ self._count_assertion()
133
+
134
+ def extract_solution(self):
135
+ solution = [[0 for _ in range(25)] for _ in range(25)]
136
+ for i in range(25):
137
+ for j in range(25):
138
+ solution[i][j] = self.solver.getValue(
139
+ self.variables[i][j]
140
+ ).getIntegerValue()
141
+ return solution
142
+
143
+ def cleanup(self):
144
+ if self.solver:
145
+ self.solver = None
146
+
147
+ def validate_solution(self, solution):
148
+ if not solution:
149
+ return False
150
+
151
+ # Check dimensions
152
+ if len(solution) != self.size or any(len(row) != self.size for row in solution):
153
+ return False
154
+
155
+ valid_nums = set(range(1, self.size + 1))
156
+
157
+ # Check rows
158
+ if any(set(row) != valid_nums for row in solution):
159
+ return False
160
+
161
+ # Check columns
162
+ for col in range(self.size):
163
+ if set(solution[row][col] for row in range(self.size)) != valid_nums:
164
+ return False
165
+
166
+ # Check 5x5 subgrids
167
+ subgrid_size = 5
168
+ for box_row in range(0, self.size, subgrid_size):
169
+ for box_col in range(0, self.size, subgrid_size):
170
+ numbers = set()
171
+ for i in range(subgrid_size):
172
+ for j in range(subgrid_size):
173
+ numbers.add(solution[box_row + i][box_col + j])
174
+ if numbers != valid_nums:
175
+ return False
176
+
177
+ return True
178
+
179
+ def solve(self):
180
+ """Solve the Sudoku puzzle using CVC5.
181
+
182
+ Returns:
183
+ Solved 25x25 grid if satisfiable, None if unsatisfiable
184
+
185
+ Raises:
186
+ Exception: If solver encounters an error
187
+
188
+ Note:
189
+ Always cleans up solver resources, even on failure
190
+ """
191
+ try:
192
+ self.create_variables()
193
+ self.encode_rules()
194
+ self.encode_puzzle()
195
+
196
+ result = self.solver.checkSat()
197
+
198
+ if result.isSat():
199
+ solution = self.extract_solution()
200
+ if self.validate_solution(solution):
201
+ return solution
202
+ return None
203
+
204
+ except Exception as e:
205
+ raise
206
+ finally:
207
+ self.cleanup()
@@ -0,0 +1,175 @@
1
+ from typing import List, Optional
2
+ from pysat.solvers import Solver
3
+ from pysat.formula import CNF
4
+ from .sudoku_error import SudokuError
5
+
6
+
7
+ class DPLLSolver:
8
+ """DPLL-based Sudoku solver using SAT encoding.
9
+
10
+ Solves 25x25 Sudoku puzzles by converting them to CNF (Conjunctive Normal Form)
11
+ and using DPLL to find a satisfying assignment.
12
+
13
+ Attributes:
14
+ sudoku (List[List[int]]): Input puzzle as 25x25 grid
15
+ size (int): Grid size (25)
16
+ cnf (CNF): PySAT CNF formula object
17
+ solver (Solver): PySAT Glucose3 solver instance
18
+ propagated_clauses (int): Counter for clause additions
19
+
20
+ Example:
21
+ >>> puzzle = [[0 for _ in range(25)] for _ in range(25)]
22
+ >>> solver = DPLLSolver(puzzle)
23
+ >>> solution = solver.solve()
24
+ """
25
+
26
+ def __init__(self, sudoku: List[List[int]]) -> None:
27
+ """Initialize DPLL Sudoku solver.
28
+
29
+ Args:
30
+ sudoku: 25x25 grid with values 0-25 (0 for empty cells)
31
+
32
+ Raises:
33
+ SudokuError: If puzzle format is invalid
34
+ """
35
+ if not sudoku or not isinstance(sudoku, list) or len(sudoku) != 25:
36
+ raise SudokuError("Invalid Sudoku puzzle: must be a 25x25 grid")
37
+
38
+ self.sudoku = sudoku
39
+ self.size = 25
40
+ self.cnf = CNF() # CNF object to store Boolean clauses
41
+ self.solver = Solver(name="glucose3") # Low-level SAT solver
42
+ self.propagated_clauses = 0 # Add clause counter
43
+
44
+ def _count_clause(self) -> None:
45
+ self.propagated_clauses += 1
46
+
47
+ def add_sudoku_clauses(self) -> None:
48
+ size = self.size
49
+ block_size = int(size**0.5)
50
+
51
+ def get_var(row, col, num):
52
+ return row * size * size + col * size + num
53
+
54
+ # At least one number in each cell
55
+ for row in range(size):
56
+ for col in range(size):
57
+ self.cnf.append([get_var(row, col, num) for num in range(1, size + 1)])
58
+ self._count_clause()
59
+
60
+ # At most one number in each cell
61
+ for num1 in range(1, size + 1):
62
+ for num2 in range(num1 + 1, size + 1):
63
+ self.cnf.append(
64
+ [-get_var(row, col, num1), -get_var(row, col, num2)]
65
+ )
66
+ self._count_clause()
67
+
68
+ # Add row constraints
69
+ for row in range(size):
70
+ for num in range(1, size + 1):
71
+ self.cnf.append([get_var(row, col, num) for col in range(size)])
72
+ self._count_clause()
73
+
74
+ # Add column constraints
75
+ for col in range(size):
76
+ for num in range(1, size + 1):
77
+ self.cnf.append([get_var(row, col, num) for row in range(size)])
78
+ self._count_clause()
79
+
80
+ # Add block constraints
81
+ for block_row in range(block_size):
82
+ for block_col in range(block_size):
83
+ for num in range(1, size + 1):
84
+ self.cnf.append(
85
+ [
86
+ get_var(
87
+ block_row * block_size + i,
88
+ block_col * block_size + j,
89
+ num,
90
+ )
91
+ for i in range(block_size)
92
+ for j in range(block_size)
93
+ ]
94
+ )
95
+ self._count_clause()
96
+
97
+ # Add initial assignments from the puzzle
98
+ for row in range(size):
99
+ for col in range(size):
100
+ if self.sudoku[row][col] != 0:
101
+ num = self.sudoku[row][col]
102
+ self.cnf.append([get_var(row, col, num)])
103
+ self._count_clause()
104
+
105
+ def extract_solution(self, model: List[int]) -> List[List[int]]:
106
+ solution = [[0 for _ in range(self.size)] for _ in range(self.size)]
107
+ for var in model:
108
+ if var > 0: # Only consider positive assignments
109
+ var -= 1
110
+ num = var % self.size + 1
111
+ col = (var // self.size) % self.size
112
+ row = var // (self.size * self.size)
113
+ solution[row][col] = num
114
+ return solution
115
+
116
+ def validate_solution(self, solution: List[List[int]]) -> bool:
117
+ size = self.size
118
+ block_size = int(size**0.5)
119
+
120
+ # Validate rows
121
+ for row in solution:
122
+ if len(set(row)) != size or not all(1 <= num <= size for num in row):
123
+ return False
124
+
125
+ # Validate columns
126
+ for col in range(size):
127
+ column = [solution[row][col] for row in range(size)]
128
+ if len(set(column)) != size:
129
+ return False
130
+
131
+ # Validate blocks
132
+ for block_row in range(block_size):
133
+ for block_col in range(block_size):
134
+ block = [
135
+ solution[block_row * block_size + i][block_col * block_size + j]
136
+ for i in range(block_size)
137
+ for j in range(block_size)
138
+ ]
139
+ if len(set(block)) != size:
140
+ return False
141
+
142
+ return True
143
+
144
+ def solve(self) -> Optional[List[List[int]]]:
145
+ """Solve Sudoku puzzle using DPLL SAT solver.
146
+
147
+ Returns:
148
+ Solved 25x25 grid if satisfiable, None if unsatisfiable
149
+
150
+ Raises:
151
+ SudokuError: If solver produces invalid solution
152
+ Exception: For other solver errors
153
+
154
+ Note:
155
+ Uses Glucose3 SAT solver from PySAT
156
+ """
157
+ self.add_sudoku_clauses()
158
+ self.solver.append_formula(self.cnf.clauses)
159
+
160
+ try:
161
+ if self.solver.solve():
162
+ # Extract and validate the solution
163
+ model = self.solver.get_model()
164
+ solution = self.extract_solution(model)
165
+
166
+ if self.validate_solution(solution):
167
+ return solution
168
+ else:
169
+ raise SudokuError("Invalid solution generated.")
170
+ else:
171
+ # If unsat, return None
172
+ return None
173
+
174
+ except Exception as e:
175
+ raise
@@ -0,0 +1,211 @@
1
+ from typing import List, Optional
2
+ from pysat.solvers import Solver
3
+ from pysat.formula import CNF
4
+ from .sudoku_error import SudokuError
5
+
6
+
7
+ class DPLLTSolver:
8
+ """DPLL(T) solver combining SAT solving with theory propagation.
9
+
10
+ Extends basic DPLL SAT solving with theory propagation for Sudoku rules,
11
+ enabling more efficient pruning of the search space.
12
+
13
+ Attributes:
14
+ sudoku (List[List[int]]): Input puzzle as 25x25 grid
15
+ size (int): Grid size (25)
16
+ cnf (CNF): PySAT CNF formula object
17
+ solver (Solver): PySAT Glucose3 solver
18
+ theory_state (dict): Dynamic tracking of theory constraints
19
+ decision_level (int): Current depth in decision tree
20
+ propagated_clauses (int): Counter for clause additions
21
+
22
+ Example:
23
+ >>> puzzle = [[0 for _ in range(25)] for _ in range(25)]
24
+ >>> solver = DPLLTSolver(puzzle)
25
+ >>> solution = solver.solve()
26
+ """
27
+
28
+ def __init__(self, sudoku: List[List[int]]) -> None:
29
+ """Initialize DPLL(T) solver with theory support.
30
+
31
+ Args:
32
+ sudoku: 25x25 grid with values 0-25 (0 for empty cells)
33
+
34
+ Raises:
35
+ SudokuError: If puzzle format is invalid
36
+ """
37
+ if not sudoku or not isinstance(sudoku, list) or len(sudoku) != 25:
38
+ raise SudokuError("Invalid Sudoku puzzle: must be a 25x25 grid")
39
+
40
+ self.sudoku = sudoku
41
+ self.size = 25
42
+ self.cnf = CNF() # CNF object to store Boolean clauses
43
+ self.solver = Solver(name="glucose3") # Low-level SAT solver
44
+ self.theory_state = {} # Store theory constraints dynamically
45
+ self.decision_level = 0
46
+ self.propagated_clauses = 0
47
+
48
+ def _count_clause(self) -> None:
49
+ self.propagated_clauses += 1
50
+
51
+ def add_sudoku_clauses(self) -> None:
52
+ size = self.size
53
+ block_size = int(size**0.5)
54
+
55
+ def get_var(row, col, num):
56
+ return row * size * size + col * size + num
57
+
58
+ # At least one number in each cell
59
+ for row in range(size):
60
+ for col in range(size):
61
+ self.cnf.append([get_var(row, col, num) for num in range(1, size + 1)])
62
+ self._count_clause()
63
+
64
+ # At most one number in each cell
65
+ for num1 in range(1, size + 1):
66
+ for num2 in range(num1 + 1, size + 1):
67
+ self.cnf.append(
68
+ [-get_var(row, col, num1), -get_var(row, col, num2)]
69
+ )
70
+ self._count_clause()
71
+
72
+ # Add row constraints
73
+ for row in range(size):
74
+ for num in range(1, size + 1):
75
+ self.cnf.append([get_var(row, col, num) for col in range(size)])
76
+ self._count_clause()
77
+
78
+ # Add column constraints
79
+ for col in range(size):
80
+ for num in range(1, size + 1):
81
+ self.cnf.append([get_var(row, col, num) for row in range(size)])
82
+ self._count_clause()
83
+
84
+ # Add block constraints
85
+ for block_row in range(block_size):
86
+ for block_col in range(block_size):
87
+ for num in range(1, size + 1):
88
+ self.cnf.append(
89
+ [
90
+ get_var(
91
+ block_row * block_size + i,
92
+ block_col * block_size + j,
93
+ num,
94
+ )
95
+ for i in range(block_size)
96
+ for j in range(block_size)
97
+ ]
98
+ )
99
+ self._count_clause()
100
+
101
+ # Add initial assignments from the puzzle
102
+ for row in range(size):
103
+ for col in range(size):
104
+ if self.sudoku[row][col] != 0:
105
+ num = self.sudoku[row][col]
106
+ self.cnf.append([get_var(row, col, num)])
107
+ self._count_clause()
108
+
109
+ def theory_propagation(self) -> Optional[List[int]]:
110
+ block_size = int(self.size**0.5)
111
+
112
+ def block_index(row, col):
113
+ return (row // block_size) * block_size + (col // block_size)
114
+
115
+ # Track constraints dynamically
116
+ for row in range(self.size):
117
+ for col in range(self.size):
118
+ if self.sudoku[row][col] != 0:
119
+ num = self.sudoku[row][col]
120
+ # Check row, column, and block constraints
121
+ if num in self.theory_state.get((row, "row"), set()):
122
+ return [-self.get_var(row, col, num)]
123
+ if num in self.theory_state.get((col, "col"), set()):
124
+ return [-self.get_var(row, col, num)]
125
+ if num in self.theory_state.get(
126
+ (block_index(row, col), "block"), set()
127
+ ):
128
+ return [-self.get_var(row, col, num)]
129
+
130
+ # Add constraints to theory state
131
+ self.theory_state.setdefault((row, "row"), set()).add(num)
132
+ self.theory_state.setdefault((col, "col"), set()).add(num)
133
+ self.theory_state.setdefault(
134
+ (block_index(row, col), "block"), set()
135
+ ).add(num)
136
+ return None
137
+
138
+ def extract_solution(self, model: List[int]) -> List[List[int]]:
139
+ """Convert SAT model to Sudoku grid."""
140
+ solution = [[0 for _ in range(self.size)] for _ in range(self.size)]
141
+ for var in model:
142
+ if var > 0: # Only consider positive assignments
143
+ var -= 1
144
+ num = var % self.size + 1
145
+ col = (var // self.size) % self.size
146
+ row = var // (self.size * self.size)
147
+ solution[row][col] = num
148
+ return solution
149
+
150
+ def validate_solution(self, solution: List[List[int]]) -> bool:
151
+ size = self.size
152
+ block_size = int(size**0.5)
153
+
154
+ # Validate rows
155
+ for row in solution:
156
+ if len(set(row)) != size or not all(1 <= num <= size for num in row):
157
+ return False
158
+
159
+ # Validate columns
160
+ for col in range(size):
161
+ column = [solution[row][col] for row in range(size)]
162
+ if len(set(column)) != size:
163
+ return False
164
+
165
+ # Validate blocks
166
+ for block_row in range(block_size):
167
+ for block_col in range(block_size):
168
+ block = [
169
+ solution[block_row * block_size + i][block_col * block_size + j]
170
+ for i in range(block_size)
171
+ for j in range(block_size)
172
+ ]
173
+ if len(set(block)) != size:
174
+ return False
175
+
176
+ return True
177
+
178
+ def solve(self) -> Optional[List[List[int]]]:
179
+ """Solve Sudoku using DPLL(T) algorithm.
180
+
181
+ Returns:
182
+ Solved 25x25 grid if satisfiable, None if unsatisfiable
183
+
184
+ Raises:
185
+ SudokuError: If solver produces invalid solution
186
+
187
+ Note:
188
+ Combines SAT solving with theory propagation in DPLL(T) style
189
+ """
190
+ """Solve the Sudoku puzzle using DPLL(T)."""
191
+ self.add_sudoku_clauses()
192
+ self.solver.append_formula(self.cnf.clauses)
193
+
194
+ while self.solver.solve():
195
+ # Perform theory propagation
196
+ conflict_clause = self.theory_propagation()
197
+ if conflict_clause:
198
+ # Add conflict clause and continue solving
199
+ self.solver.add_clause(conflict_clause)
200
+ self._count_clause()
201
+ else:
202
+ # Extract and validate the solution
203
+ model = self.solver.get_model()
204
+ solution = self.extract_solution(model)
205
+ if self.validate_solution(solution):
206
+ return solution
207
+ else:
208
+ raise SudokuError("Invalid solution generated.")
209
+
210
+ # If UNSAT, return None
211
+ return None
@@ -0,0 +1,8 @@
1
+ class SudokuError(Exception):
2
+ """An exception class for errors in the sudoku solver"""
3
+
4
+ def __init__(self, message):
5
+ self.message = message
6
+
7
+ def __str__(self):
8
+ return self.message
@@ -0,0 +1,160 @@
1
+ from z3 import Solver, Int, Distinct, sat
2
+ from sudoku_smt_solvers.solvers.sudoku_error import SudokuError
3
+
4
+
5
+ class Z3Solver:
6
+ """Z3-based SMT solver for Sudoku puzzles.
7
+
8
+ Uses integer variables and distinct constraints to encode Sudoku rules.
9
+ Tracks constraint propagation for performance analysis.
10
+
11
+ Attributes:
12
+ sudoku (List[List[int]]): Input puzzle as 25x25 grid
13
+ size (int): Grid size (25)
14
+ solver (z3.Solver): Z3 solver instance
15
+ variables (List[List[z3.Int]]): SMT variables for grid
16
+ propagated_clauses (int): Counter for constraint additions
17
+
18
+ Example:
19
+ >>> puzzle = [[0 for _ in range(25)] for _ in range(25)]
20
+ >>> solver = Z3Solver(puzzle)
21
+ >>> solution = solver.solve()
22
+ """
23
+
24
+ def __init__(self, sudoku):
25
+ """Initialize Z3 Sudoku solver.
26
+
27
+ Args:
28
+ sudoku: 25x25 grid with values 0-25 (0 for empty cells)
29
+
30
+ Raises:
31
+ SudokuError: If puzzle format is invalid
32
+ """
33
+ if not sudoku or not isinstance(sudoku, list) or len(sudoku) != 25:
34
+ raise SudokuError("Invalid Sudoku puzzle: must be a 25x25 grid")
35
+
36
+ self.sudoku = sudoku
37
+ self.size = len(sudoku)
38
+ self.solver = None
39
+ self.variables = None
40
+ self.propagated_clauses = 0
41
+
42
+ def create_variables(self):
43
+ self.variables = [
44
+ [Int(f"x_{i}_{j}") for j in range(self.size)] for i in range(self.size)
45
+ ]
46
+
47
+ def _count_clause(self):
48
+ self.propagated_clauses += 1
49
+
50
+ def encode_rules(self):
51
+ # Cell range constraints
52
+ cell_constraints = []
53
+ for i in range(self.size):
54
+ for j in range(self.size):
55
+ cell_constraints.append(1 <= self.variables[i][j])
56
+ cell_constraints.append(self.variables[i][j] <= 25)
57
+ self._count_clause()
58
+ self._count_clause()
59
+ self.solver.add(cell_constraints)
60
+
61
+ # Row constraints
62
+ row_constraints = [Distinct(self.variables[i]) for i in range(self.size)]
63
+ self.solver.add(row_constraints)
64
+ for _ in range(self.size):
65
+ self._count_clause()
66
+
67
+ # Column constraints
68
+ col_constraints = [
69
+ Distinct([self.variables[i][j] for i in range(self.size)])
70
+ for j in range(self.size)
71
+ ]
72
+ self.solver.add(col_constraints)
73
+ for _ in range(self.size):
74
+ self._count_clause()
75
+
76
+ # Box constraints
77
+ box_constraints = [
78
+ Distinct(
79
+ [
80
+ self.variables[5 * box_i + i][5 * box_j + j]
81
+ for i in range(5)
82
+ for j in range(5)
83
+ ]
84
+ )
85
+ for box_i in range(5)
86
+ for box_j in range(5)
87
+ ]
88
+ self.solver.add(box_constraints)
89
+ for _ in range(25):
90
+ self._count_clause()
91
+
92
+ def encode_puzzle(self):
93
+ initial_values = []
94
+ for i in range(self.size):
95
+ for j in range(self.size):
96
+ if self.sudoku[i][j] != 0:
97
+ initial_values.append(self.variables[i][j] == self.sudoku[i][j])
98
+ self._count_clause()
99
+ self.solver.add(initial_values)
100
+
101
+ def extract_solution(self, model):
102
+ return [
103
+ [model.evaluate(self.variables[i][j]).as_long() for j in range(self.size)]
104
+ for i in range(self.size)
105
+ ]
106
+
107
+ def validate_solution(self, solution):
108
+ # Check range
109
+ for row in solution:
110
+ if not all(1 <= num <= 25 for num in row):
111
+ return False
112
+
113
+ # Check rows
114
+ for row in solution:
115
+ if len(set(row)) != self.size:
116
+ return False
117
+
118
+ # Check columns
119
+ for j in range(self.size):
120
+ col = [solution[i][j] for i in range(self.size)]
121
+ if len(set(col)) != self.size:
122
+ return False
123
+
124
+ # Check boxes
125
+ for box_i in range(5):
126
+ for box_j in range(5):
127
+ box = [
128
+ solution[5 * box_i + i][5 * box_j + j]
129
+ for i in range(5)
130
+ for j in range(5)
131
+ ]
132
+ if len(set(box)) != self.size:
133
+ return False
134
+
135
+ return True
136
+
137
+ def solve(self):
138
+ """Solve Sudoku using Z3 SMT solver.
139
+
140
+ Returns:
141
+ Solved 25x25 grid if satisfiable, None if unsatisfiable
142
+
143
+ Note:
144
+ Validates solution before returning to ensure correctness
145
+ """
146
+ self.solver = Solver()
147
+ self.create_variables()
148
+ self.encode_rules()
149
+ self.encode_puzzle()
150
+
151
+ result = self.solver.check()
152
+
153
+ if result == sat:
154
+ model = self.solver.model()
155
+ solution = self.extract_solution(model)
156
+
157
+ if self.validate_solution(solution):
158
+ return solution
159
+
160
+ return None
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 liamjdavis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,161 @@
1
+ Metadata-Version: 2.1
2
+ Name: sudoku_smt_solvers
3
+ Version: 0.1.0
4
+ Summary: A collection of SAT and SMT solvers for solving Sudoku puzzles
5
+ Home-page: https://liamjdavis.github.io/sudoku-smt-solvers
6
+ Author: Liam Davis
7
+ Author-email: ljdavis27@amherst.edu
8
+ License: MIT
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Requires-Python: >=3.9
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: cvc5
16
+ Requires-Dist: pysat
17
+ Requires-Dist: z3-solver
18
+
19
+ # Sudoku-SMT-Solvers
20
+
21
+ [![Pytest + CI/CD](https://github.com/liamjdavis/Sudoku-SMT-Solvers/actions/workflows/test.yml/badge.svg)](ttps://github.com/liamjdavis/Sudoku-SMT-Solvers/actions/workflows/test.yml)
22
+ [![Coverage Status](https://coveralls.io/repos/github/liamjdavis/Sudoku-SMT-Solvers/badge.svg)](https://coveralls.io/github/liamjdavis/Sudoku-SMT-Solvers)
23
+ [![Docs Build Deployment](https://github.com/liamjdavis/Sudoku-SMT-Solvers/actions/workflows/docs.yml/badge.svg)](https://github.com/liamjdavis/Sudoku-SMT-Solvers/actions/workflows/docs.yml)
24
+ [![Documentation](https://img.shields.io/badge/docs-latest-blue.svg)](https://liamjdavis.github.io/sudoku-smt-solvers)
25
+
26
+
27
+ ## About
28
+ This repository contains the code for the study "Evaluating SMT-Based Solvers on Sudoku". Created by Liam Davis (@liamjdavis) and Ryan Ji (@TairanJ) as their for COSC-241 Artificial Intelligence at Amherst College, it evaluates the efficacy of SMT-Based Solvers by benchmarking three modern SMT solvers (DPLL(T), Z3, and CVC5) against the DPLL algorithm on a collection of 100 25x25 Sudoku puzzles of varying difficulty.
29
+
30
+ Along with the study, we also published `sudoku-smt-solvers`, a Python package that provides the various SMT-based Sudoku solvers and benchmarking tools we built for this study. The package features DPLL(T), Z3, and CVC5 solvers optimized for 25x25 Sudoku puzzles, a puzzle generator for creating test cases, and a comprehensive benchmarking suite. Available through pip, it offers a simple API for solving Sudoku puzzles using state-of-the-art SMT solvers while facilitating performance comparisons between different solving approaches.
31
+
32
+ The study aims to answer three research questions:
33
+ 1. How have logical solvers evolved over time in terms of performance and capability?
34
+ 2. How do different encodings of Sudoku affect the efficiency and scalability of these solvers?
35
+ 3. Are there specific features or optimizations in SMT solvers that provide a significant advantage over traditional SAT solvers for this class of problem?
36
+
37
+ ## Getting started
38
+ ### Installation
39
+ To run the code locally, you can install with `pip`
40
+
41
+ ```bash
42
+ pip install sudoku-smt-solvers
43
+ ```
44
+
45
+ ### Solvers
46
+ This package includes the DPLL solver and three modern SMT solvers:
47
+ * DPLL(T)
48
+ * CVC5
49
+ * Z3
50
+
51
+ To run any of the solvers on a 25x25 Sudoku puzzle, you can create an instance of the solver class and call the solve method in a file at the root (Sudoku-smt-solvers). Here is an example using Z3:
52
+
53
+ ```python
54
+ from sudoku_smt_solvers.solvers.z3_solver import Z3Solver
55
+
56
+ # Example grid (25x25)
57
+ grid = [[0] * 25 for _ in range(25)]
58
+ solver = Z3Solver(grid)
59
+ solution = solver.solve()
60
+
61
+ if solution:
62
+ print(f"Solution:\n\n{solution}")
63
+ else:
64
+ print("No solution exists.")
65
+ ```
66
+
67
+ ### Sudoku Generator
68
+ This package also includes a generator for creating Sudoku puzzles to be used as benchmarks. To generate a puzzle, create an instance of the `SudokuGenerator` class and call the `generate` method. Here is an example:
69
+
70
+ ```python
71
+ from sudoku_smt_solvers.benchmarks.sudoku_generator.sudoku_generator import SudokuGenerator
72
+
73
+ generator = SudokuGenerator(size = 25, givens = 80, timeout = 5, difficulty = "Medium", puzzles_dir = "benchmarks/puzzles", solutions_dir = "benchmarks/solutions")
74
+
75
+ generator.generate()
76
+ ```
77
+
78
+ Due to the computational complexity of generating large sudoku puzzles, it is recommended that you run multiple generator instances in parallel to create benchmarks.
79
+
80
+ ### Benchmark Runner
81
+ To run the benchmarks you created on all four solvers, create an instance of the `BenchmarkRunner` class and call the `run_benchmarks` method. Here is an example:
82
+
83
+ ```python
84
+ from sudoku_smt_solvers.benchmarks.benchmark_runner import BenchmarkRunner
85
+
86
+ runner = BenchmarkRunner(
87
+ puzzles_dir='resources/benchmarks/puzzles/',
88
+ solutions_dir='resources/benchmarks/solutions/',
89
+ results_dir='results/'
90
+ )
91
+ runner.run_benchmarks()
92
+ ```
93
+
94
+ ## Contributing
95
+
96
+ We welcome contributions in the form of new solvers, additions to our benchmark suite, or anything that improves the tool! Here's how to get started:
97
+
98
+ ### Development Setup
99
+
100
+ 1. **Fork and Clone**:
101
+ Begin by forking the repository and cloning your fork locally:
102
+ ```bash
103
+ git clone https://github.com/yourusername/Sudoku-SMT-Solvers.git
104
+ cd Sudoku-SMT-Solvers
105
+ ```
106
+
107
+ 2. **Create and Activate a Virtual Environment**:
108
+ Set up a Python virtual environment to isolate your dependencies:
109
+ ```bash
110
+ python3 -m venv venv
111
+ source venv/bin/activate # On Windows, use `venv\Scripts\activate`
112
+ ```
113
+
114
+ 3. **Install Dependencies**:
115
+ Install the required dependencies from the `requirements.txt` file:
116
+ ```bash
117
+ pip install -r requirements.txt
118
+ ```
119
+
120
+ 4. **Set Up Pre-Commit Hooks**:
121
+ Install and configure pre-commit hooks to maintain code quality:
122
+ ```bash
123
+ pip install pre-commit
124
+ pre-commit install
125
+ ```
126
+
127
+ To manually run the hooks and verify code compliance, use:
128
+ ```bash
129
+ pre-commit run
130
+ ```
131
+
132
+ 5. **Testing and Coverage Requirements**:
133
+ - Write tests for any new code or modifications.
134
+ - Use `pytest` for running tests:
135
+ ```bash
136
+ pytest
137
+ ```
138
+ - Ensure the test coverage is at least 90%:
139
+
140
+ 6. **Add and Commit Your Changes**:
141
+ - Follow the existing code style and structure.
142
+ - Verify that all pre-commit hooks pass and the test coverage meets the minimum requirement.
143
+ ```bash
144
+ git add .
145
+ git commit -m "Description of your changes"
146
+ ```
147
+
148
+ 7. **Push Your Branch**:
149
+ Push your changes to your forked repository:
150
+ ```bash
151
+ git push origin your-branch-name
152
+ ```
153
+
154
+ 8. **Open a PR for us to review**
155
+ ---
156
+
157
+ Thank you for your interest in contributing to Sudoku-SMT-Solvers! Your efforts help make this project better for everyone.
158
+
159
+
160
+ ## Contact Us
161
+ For any questions or support, please reach out to Liam at ljdavis27 at amherst.edu and Ryan at tji26 at amherst.edu
@@ -0,0 +1,14 @@
1
+ sudoku_smt_solvers/__init__.py,sha256=vTwJLa8Yp7q-OdzA44zhFIK-LhK2o8aDt-4qOJJ1M7M,134
2
+ sudoku_smt_solvers/benchmarks/__init__.py,sha256=lMrK_yj_otywN4dMvvVFtTzyTdamS_4nUgjm-k07obU,271
3
+ sudoku_smt_solvers/benchmarks/benchmark_runner.py,sha256=Mc87ul-6VkWMomtlmOMc9GXmC4AwfQUwIWKDjeFvSVA,7712
4
+ sudoku_smt_solvers/solvers/__init__.py,sha256=5qFfWzKN2WPDoFLN5ye6Ly5BqEaUdk2ks_jPP3c53l8,142
5
+ sudoku_smt_solvers/solvers/cvc5_solver.py,sha256=DfLlgzhhgSaI3tV_4mzy3vdpxY5tISSFhs0xhRWcMYk,6849
6
+ sudoku_smt_solvers/solvers/dpll_solver.py,sha256=ThLT1v87oNnzDMpYmoPcmTeeXijVIgk6A6qULWVID28,6138
7
+ sudoku_smt_solvers/solvers/dpllt_solver.py,sha256=4UXcod7EnJGlD7OlakmPHOuIyWga5yN9rMtKMvuEAc8,7986
8
+ sudoku_smt_solvers/solvers/sudoku_error.py,sha256=iUcv1QCgQ7anov0b-AtIB1fbfZ3yWfci4eTp_8RuUJg,208
9
+ sudoku_smt_solvers/solvers/z3_solver.py,sha256=awQ3tzEMIy2woFmATMiwQsC2YtktxfJlx55MudB1SN0,4922
10
+ sudoku_smt_solvers-0.1.0.dist-info/LICENSE,sha256=PbuZlvluV1l4HMMfPAVe5yjVvFGBK9DFp20JNhoJ8bI,1067
11
+ sudoku_smt_solvers-0.1.0.dist-info/METADATA,sha256=lbOf7Pgg4nsMlchZ54WHWcJoGa98BvgCMHCMieULSrw,6414
12
+ sudoku_smt_solvers-0.1.0.dist-info/WHEEL,sha256=A3WOREP4zgxI0fKrHUG8DC8013e3dK3n7a6HDbcEIwE,91
13
+ sudoku_smt_solvers-0.1.0.dist-info/top_level.txt,sha256=Ww9vs8KC4aujzfGfddMl_X8Qzh-Cywn9aBTLQgemi5A,19
14
+ sudoku_smt_solvers-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (75.7.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ sudoku_smt_solvers