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

Sign up to get free protection for your applications and to get access to all the features.
@@ -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