Skip to content

Commit 5031e2d

Browse files
feat: homework sheet 5
1 parent 683a3f0 commit 5031e2d

14 files changed

Lines changed: 470 additions & 0 deletions

homework/HW05/HW05.pdf

122 KB
Binary file not shown.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
.venv/
2+
__pycache__/
3+
.pytest_cache/
4+
.ruff_cache/
5+
*.pyc
6+
dist/
7+
build/
8+
*.egg-info/

homework/HW05/exercise01/README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# n queens problem SAT/CP-SAT solver
2+
By running `pip install -e .` in a suitably-configured environment (containing pip),
3+
you should be able to install the basic solver skeleton (which is incomplete).
4+
Running tests can be done by running `pytest` in this directory.
5+
6+
## Running the solvers on some n
7+
```queens-sat 123 --time-limit=300```
8+
```queens-cpsat 123 --time-limit=300```
9+
10+
## Implementation notes
11+
For the SAT version, you essentially only have to edit the `NQueensSATModeler` class in `sat_model.py`.
12+
For the CP-SAT version, you have to edit the `NQueensCPSAT` class in `cpsat_model.py`.
13+
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
[build-system]
2+
requires = ["hatchling>=1.25"]
3+
build-backend = "hatchling.build"
4+
5+
[project]
6+
name = "queens"
7+
version = "0.1.0"
8+
description = "Teaching scaffold for solving the N-queens problem with a SAT solver (PySAT) and a CP solver (OR-Tools)."
9+
readme = "README.md"
10+
requires-python = ">=3.11"
11+
license = { text = "MIT" }
12+
authors = [
13+
{ name = "Phillip Keldenich" },
14+
{ name = "Dominik Krupke" },
15+
]
16+
dependencies = [
17+
"python-sat>=1.9.dev5",
18+
"ortools>=9.14.6206",
19+
"pytest"
20+
]
21+
22+
[project.optional-dependencies]
23+
dev = [
24+
"pytest>=9.0",
25+
"ruff>=0.11",
26+
]
27+
28+
[project.scripts]
29+
queens-sat = "queens.cli:sat_main"
30+
queens-cpsat = "queens.cli:cpsat_main"
31+
32+
[tool.hatch.build.targets.wheel]
33+
packages = ["src/queens"]
34+
35+
[tool.pytest]
36+
minversion = "9.0"
37+
testpaths = ["tests"]
38+
39+
[tool.ruff]
40+
line-length = 100
41+
target-version = "py311"
42+
43+
[tool.ruff.lint]
44+
select = ["E", "F", "I", "B"]
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""N-queens solvers using a SAT solver (PySAT) and a CP solver (OR-Tools)."""
2+
3+
__version__ = "0.1.0"
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import argparse
2+
from collections.abc import Sequence
3+
4+
from queens.model_cpsat import NQueensCPSAT
5+
from queens.model_sat import solve_with_sat
6+
7+
8+
def _positive_int(value: str) -> int:
9+
try:
10+
n = int(value)
11+
except ValueError as exc:
12+
raise argparse.ArgumentTypeError("n must be an integer") from exc
13+
if n < 1:
14+
raise argparse.ArgumentTypeError("n must be a positive integer")
15+
return n
16+
17+
18+
def _positive_float(value: str) -> float:
19+
try:
20+
time_limit = float(value)
21+
except ValueError as exc:
22+
raise argparse.ArgumentTypeError("time limit must be a number") from exc
23+
if time_limit <= 0:
24+
raise argparse.ArgumentTypeError("time limit must be positive")
25+
return time_limit
26+
27+
28+
def _build_parser(program_name: str) -> argparse.ArgumentParser:
29+
parser = argparse.ArgumentParser(prog=program_name)
30+
parser.add_argument("n", type=_positive_int, help="size of the n x n board")
31+
parser.add_argument(
32+
"--time-limit",
33+
type=_positive_float,
34+
default=None,
35+
help="optional solver time limit in seconds",
36+
)
37+
return parser
38+
39+
40+
def _print_result(
41+
n: int, result: list[list[bool]] | bool | None, build_time: float, solve_time: float
42+
):
43+
if isinstance(result, list):
44+
status = f"solution found for n={n}"
45+
elif result is False:
46+
status = f"n={n} is unsatisfiable"
47+
else:
48+
status = f"timeout occurred for n={n}"
49+
print(f"Status: {status}")
50+
print(f"Model build time: {build_time:.6f}s")
51+
print(f"Solve time: {solve_time:.6f}s")
52+
53+
54+
def sat_main(argv: Sequence[str] | None = None) -> int:
55+
args = _build_parser("queens-sat").parse_args(argv)
56+
times: dict[str, float] = {}
57+
result = solve_with_sat(args.n, time_limit=args.time_limit, times=times)
58+
_print_result(args.n, result, times["build_time"], times["solve_time"])
59+
return 0
60+
61+
62+
def cpsat_main(argv: Sequence[str] | None = None) -> int:
63+
args = _build_parser("queens-cpsat").parse_args(argv)
64+
model = NQueensCPSAT(args.n)
65+
result = model.solve(time_limit=args.time_limit)
66+
_print_result(args.n, result, model.model_build_time, model.solve_time)
67+
return 0
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from time import perf_counter
2+
3+
from ortools.sat.python import cp_model
4+
5+
6+
class NQueensCPSAT:
7+
def _build_model(self):
8+
"""
9+
Build the model (i.e., create a list of variables via
10+
`self.model.new_bool_var` and add constraints like
11+
`self.model.add_exactly_one` or
12+
`self.model.add_at_most_one`).
13+
"""
14+
# TODO
15+
raise NotImplementedError()
16+
17+
def _decode_solution(self):
18+
"""
19+
Decode the solution found by and stored in self.solver;
20+
you can access the value of some boolean variable like
21+
`self.solver.boolean_value(x)`.
22+
"""
23+
# TODO
24+
raise NotImplementedError()
25+
26+
def __init__(self, n):
27+
self.n = n
28+
self.model = cp_model.CpModel()
29+
before = perf_counter()
30+
self._build_model()
31+
after = perf_counter()
32+
self.model_build_time = after - before
33+
34+
def solve(self, time_limit: float | None = None) -> bool | None | list[list[bool]]:
35+
before = perf_counter()
36+
self.solver = cp_model.CpSolver()
37+
if time_limit is not None:
38+
self.solver.parameters.max_time_in_seconds = time_limit
39+
status = self.solver.solve(self.model)
40+
after = perf_counter()
41+
self.solve_time = after - before
42+
if status in (cp_model.CpSolverStatus.FEASIBLE, cp_model.CpSolverStatus.OPTIMAL):
43+
return self._decode_solution()
44+
if status == cp_model.CpSolverStatus.INFEASIBLE:
45+
return False
46+
return None
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
from threading import Timer
2+
from time import perf_counter
3+
4+
from pysat.formula import CNF
5+
from pysat.solvers import Solver
6+
7+
8+
class NQueensSATModeler:
9+
"""
10+
Modeler class for the n queens problem using SAT.
11+
"""
12+
13+
def __init__(self, n):
14+
self.n = n
15+
self.cnf: CNF | None = None
16+
self._cnf_breaks_symmetries = False
17+
18+
def create_formula(self, break_symmetries=False) -> CNF:
19+
"""
20+
Create a CNF formula representing the N-queens problem.
21+
If break_symmetries is True, this method may add additional
22+
constraints that break a mirror symmetry of the problem,
23+
which may help some SAT solvers find solutions faster.
24+
The only hard requirement is that, if break_symmetries is False,
25+
the returned formula must be satisfied by all valid N-queens solutions.
26+
"""
27+
if self.cnf is not None and self._cnf_breaks_symmetries == break_symmetries:
28+
return self.cnf
29+
self.cnf = CNF()
30+
self._cnf_breaks_symmetries = break_symmetries
31+
# TODO: add clauses to self.cnf (via self.cnf.append or self.cnf.extend);
32+
# e.g., self.cnf.append([1, -2, 3, 4]) for (x1 OR NOT x2 OR x3 OR x4)
33+
raise NotImplementedError()
34+
return self.cnf
35+
36+
def decode_solution(self, model: list[int]) -> list[list[bool]]:
37+
"""
38+
Decodes a model (list of literals like [1, -2, 3, -4, ...])
39+
as returned by a PySAT SAT solver into a 2D list of booleans.
40+
"""
41+
# TODO
42+
raise NotImplementedError()
43+
44+
def encode_assignment(self, assignment: list[list[bool]]) -> list[int]:
45+
"""
46+
Encodes a 2D list of booleans representing an N-queens solution
47+
into a list of literals (like [1, -2, 3, -4, ...]) that can be used
48+
as input to a PySAT SAT solver or be used by debug/test methods to
49+
check that the formula is satisfied by what should be a satisfying assignment.
50+
As long as the formula created by create_formula was created with
51+
break_symmetries=False, the returned list of literals should satisfy
52+
the formula if and only if the input assignment is a valid N-queens solution.
53+
"""
54+
# TODO
55+
raise NotImplementedError()
56+
57+
58+
def solve_with_sat(
59+
n,
60+
break_symmetries=False,
61+
solver_name="Cadical300",
62+
time_limit: float | None = None,
63+
times: dict | None = None,
64+
) -> list[list[bool]] | bool | None:
65+
"""
66+
Run the named SAT solver on the given n (potentially with symmetry breaking
67+
constraints, if implemented) and with a time limit.
68+
This method should not need to be changed; all necessary changes should be
69+
local to the NQueensSATModeler class.
70+
"""
71+
before = perf_counter()
72+
modeler = NQueensSATModeler(n)
73+
formula = modeler.create_formula(break_symmetries=break_symmetries)
74+
solver = Solver(name=solver_name)
75+
timer = None
76+
77+
def interrupt():
78+
solver.interrupt()
79+
80+
try:
81+
solver.append_formula(formula.clauses, no_return=True)
82+
build = perf_counter()
83+
if times is not None:
84+
times["build_time"] = build - before
85+
if time_limit is not None:
86+
timer = Timer(time_limit, interrupt, [solver])
87+
timer.start()
88+
r = solver.solve_limited(expect_interrupt=True)
89+
else:
90+
r = solver.solve()
91+
after = perf_counter()
92+
if times is not None:
93+
times["solve_time"] = after - build
94+
if r is None:
95+
return None
96+
if r is False:
97+
return False
98+
model: list[int] = solver.get_model() # type: ignore
99+
return modeler.decode_solution(model)
100+
finally:
101+
if timer is not None:
102+
timer.cancel()
103+
solver.delete()
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
def verify_assignment(n, assignment: list[list[bool]]):
2+
for row_index, row in enumerate(assignment):
3+
s = sum(row)
4+
if s != 1:
5+
raise ValueError(f"Row {row_index} has {s} queens, expected 1")
6+
7+
for col_index in range(n):
8+
s = sum(assignment[row_index][col_index] for row_index in range(n))
9+
if s != 1:
10+
raise ValueError(f"Column {col_index} has {s} queens, expected 1")
11+
12+
for row_index, row in enumerate(assignment):
13+
for col_index, cell in enumerate(row):
14+
if not cell:
15+
continue
16+
for direction in [(-1, -1), (-1, 1), (1, -1), (1, 1)]:
17+
for step in range(1, n):
18+
r = row_index + direction[0] * step
19+
c = col_index + direction[1] * step
20+
if r < 0 or r >= n or c < 0 or c >= n:
21+
break
22+
if assignment[r][c]:
23+
raise ValueError(
24+
f"Queens at ({row_index}, {col_index}) and ({r}, {c}) "
25+
"are attacking each other diagonally"
26+
)
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from queens.cli import cpsat_main, sat_main
2+
3+
4+
def test_sat_cli_reports_solution(capsys):
5+
assert sat_main(["4"]) == 0
6+
out = capsys.readouterr().out
7+
assert "Status: solution found for n=4" in out
8+
assert "Model build time:" in out
9+
assert "Solve time:" in out
10+
11+
12+
def test_cpsat_cli_reports_unsatisfiable(capsys):
13+
assert cpsat_main(["2"]) == 0
14+
out = capsys.readouterr().out
15+
assert "Status: n=2 is unsatisfiable" in out
16+
assert "Model build time:" in out
17+
assert "Solve time:" in out

0 commit comments

Comments
 (0)