SAT Solvers¶
Overview¶
A SAT Solver is tool for finding solutions to problems that can be specified using propositional logic formulas.
Here’s an example from Delmar E. Searls.
Start with a problem statement, “If the tire is flat then I will have to remove it and take it to the gas station.”
Translate each basic statement to a variable so that P means “the tire is flat”, Q means “I have to remove the tire”, and R means “I have to take the tire to the gas station.
Next, write the statement in symbolic form: P → (Q ∧ R) and make a truth table that shows when the statements is satisfied:
P Q R P → (Q ∧ R) T T T T T T F F T F T F T F F F F T T T F T F T F F T T F F F T
Since there is at least one row that evaluates to True, we say the expression is satisfiable.
What a SAT solver does is determine whether an expression is satisfiable and will generate one, some, or all of the rows that evaluate to true.
Why Do We Care?¶
There are many otherwise difficult problems that can be expressed in propositional logic and computers have gotten good at solving them.
While the above example was simple, the problem grows exponentially with the number of variables. The amazing fact of the early 21st century is that a number of heuristics have been found that make the problem tractable for many practical examples, even ones with tens of thousands of variables.
One example is finding a layout to wire a printed circuit board in a way that doesn’t generate electrical interference.
Tools¶
A tool that is available to Pythonistas is pycosat which is itself a front-end for a PicoSAT which is a popular SAT solver written by Armin Biere in pure C.
The first step is to rewrite the expression into Conjunctive Normal Form (CNF) sometimes called a “product of sums”:
(¬P ∨ Q) ∧ (¬P ∨ R)
In pycosat, P
is written as 1
because it is the first
variable, and ¬P
is written as -1
using a negative
value to indicate negation. We pass in a list of tuples with
each disjunction was a separate tuple:
>>> pprint(list(pycosat.itersolve([(-1, 2), (-1, 3)])), width=20)
[[-1, 2, 3],
[-1, 2, -3],
[-1, -2, 3],
[-1, -2, -3],
[1, 2, 3]]
The Core Challenge¶
With a powerful SAT solver at you disposal, the remaining challenge is to express your problem in a way that pycosat can understand.
I wrote some utility functions that can help. For starters, we
want to write our expressions with symbols instead of numbers
and with ~
for negation instead of a minus sign:
>>> translate([['~P', 'Q'],['~P', 'R']])[0]
[(-1, 2), (-1, 3)]
Often, it is easier to express constraints in Disjunctive normal form
¬P ∨ (Q ∧ R)
and then convert to the CNF required by pycosat:
>>> from_dnf([['~P'], ['Q', 'R']])
[('~P', 'Q'), ('~P', 'R')]
Lastly, there is function, solve_all, that takes care of the round-trip between human readable and pycosat readable:
>>> cnf = from_dnf([['~P'], ['Q', 'R']])
>>> solve_all(cnf, True)
[['~P', 'Q', 'R'],
['~P', 'Q', '~R'],
['~P', '~Q', 'R'],
['~P', '~Q', '~R'],
['P', 'Q', 'R']]
It’s even more readable with humanistic variable names:
>>> cnf = from_dnf([['~FlatTire'], ['NeedToRemove', 'GotoGasStation']])
>>> solve_all(cnf, True)
[['~FlatTire', 'NeedToRemove', 'GotoGasStation'],
['~FlatTire', 'NeedToRemove', '~GotoGasStation'],
['~FlatTire', '~NeedToRemove', 'GotoGasStation'],
['~FlatTire', '~NeedToRemove', '~GotoGasStation'],
['FlatTire', 'NeedToRemove', 'GotoGasStation']]
Higher Level Convenience Functions¶
DNFs can be easy to reason about but are sometimes tedious to write-out.
I have some convenience functions that build a CNF directly from constraints expressed with quantifiers:
# Express that at least one of A, B, and C are true
>>> some_of(['A', 'B', 'C'])
[['A', 'B', 'C']]
# Express that at exactly one of A, B, and C is true
>>> one_of(['A', 'B', 'C'])
[('A', 'B', 'C'),
('~A', '~B'),
('~A', '~C'),
('~B', '~C')]
# Express that no more than one of A, B, and C are true
>>> Q(['A', 'B', 'C']) <= 1
[('~A', '~B'),
('~A', '~C'),
('~B', '~C')]
With these tools as primitives, it is easier to write higher level functions to express problem constraints in conjunctive normal form.
Sudoku Puzzles¶
Given a puzzle in this form:
8 6|4 3|
5 | | 7
| 2 |
---+---+---
32 | 8 | 5
8| 5 |4
1 | 7 | 93
---+---+---
| 4 |
9 | | 4
|6 7|2 8
We enter it in Python as a single string row major order:
s = ('8 64 3 5 7 2 '
'32 8 5 8 5 4 1 7 93'
' 4 9 4 6 72 8')
And expect the computer to solve it:
876|413|529
452|968|371
931|725|684
---+---+---
329|184|756
768|359|412
145|276|893
---+---+---
283|541|967
697|832|145
514|697|238
Code for the Sudoku Solver¶
from sat_utils import solve_one, one_of, basic_fact
from sys import intern
from pprint import pprint
n = 3
grid = '''\
AA AB AC BA BB BC CA CB CC
AD AE AF BD BE BF CD CE CF
AG AH AI BG BH BI CG CH CI
DA DB DC EA EB EC FA FB FC
DD DE DF ED EE EF FD FE FF
DG DH DI EG EH EI FG FH FI
GA GB GC HA HB HC IA IB IC
GD GE GF HD HE HF ID IE IF
GG GH GI HG HH HI IG IH II
'''
values = list('123456789')
table = [row.split() for row in grid.splitlines()]
points = grid.split()
subsquares = dict()
for point in points:
subsquares.setdefault(point[0], []).append(point)
# Groups: rows + columns + subsquares
groups = table[:] + list(zip(*table)) + list(subsquares.values())
del grid, subsquares, table # analysis requires only: points, values, groups
def comb(point, value):
'Format a fact (a value assigned to a given point)'
return intern(f'{point} {value}')
def str_to_facts(s):
'Convert str in row major form to a list of facts'
return [comb(point, value) for point, value in zip(points, s) if value != ' ']
def facts_to_str(facts):
'Convert a list of facts to a string in row major order with blanks for unknowns'
point_to_value = dict(map(str.split, facts))
return ''.join(point_to_value.get(point, ' ') for point in points)
def show(flatline):
'Display grid from a string (values in row major order with blanks for unknowns)'
fmt = '|'.join(['%s' * n] * n)
sep = '+'.join(['-' * n] * n)
for i in range(n):
for j in range(n):
offset = (i * n + j) * n**2
print(fmt % tuple(flatline[offset:offset+n**2]))
if i != n - 1:
print(sep)
for given in [
'53 7 6 195 98 6 8 6 34 8 3 17 2 6 6 28 419 5 8 79',
' 75 4 5 8 17 6 36 2 7 1 5 1 1 5 8 96 1 82 3 4 9 48 ',
' 9 7 4 1 6 2 8 1 43 6 59 1 3 97 8 52 7 6 8 4 7 5 8 2 ',
'67 38 921 85 736 1 8 4 7 5 1 8 4 2 6 8 5 175 24 321 61 84',
'27 15 8 3 7 4 7 5 1 7 9 2 6 2 5 8 6 5 4 8 59 41',
'8 64 3 5 7 2 32 8 5 8 5 4 1 7 93 4 9 4 6 72 8',
]:
cnf = []
# each point assigned exactly one value
for point in points:
cnf += one_of(comb(point, value) for value in values)
# each value gets assigned to exactly one point in each group
for group in groups:
for value in values:
cnf += one_of(comb(point, value) for point in group)
# add facts for known values in a specific puzzle
for known in str_to_facts(given):
cnf += basic_fact(known)
# solve it and display the results
result = facts_to_str(solve_one(cnf))
show(given)
print()
show(result)
print('=-' * 20)
Einstein Puzzle¶
Reportedly this puzzle is from Albert Einstein who is said to have remarked that fewer than 2% of the population can solve this puzzle (this is lore, neither fact is true).
Entities¶
- There are five houses in unique colors: Blue, green, red, white and yellow.
- In each house lives a person of unique nationality: British, Danish, German, Norwegian and Swedish.
- Each person drinks a unique beverage: Beer, coffee, milk, tea and water.
- Each person smokes a unique cigar brand: Blue Master, Dunhill, Pall Mall, Prince and blend.
- Each person keeps a unique pet: Cats, birds, dogs, fish and horses.
Constraints¶
- The Brit lives in a red house.
- The Swede keeps dogs as pets.
- The Dane drinks tea.
- The green house is on the left of the white, next to it.
- The green house owner drinks coffee.
- The person who smokes Pall Mall rears birds.
- The owner of the yellow house smokes Dunhill.
- The man living in the house right in the center drinks milk.
- The Norwegian lives in the first house.
- The man who smokes blend lives next to the one who keeps cats.
- The man who keeps horses lives next to the man who smokes Dunhill.
- The owner who smokes Blue Master drinks beer.
- The German smokes Prince.
- The Norwegian lives next to the blue house.
- The man who smokes blend has a neighbor who drinks water.
Goal¶
The question you need to answer is: “Who keeps fish?”
Solution Techniques¶
Christian Stigen Larsen has a nice write-up on how to solve this problem by hand
Instead, we’ll use a computer to determine all of the relationships:
['1 norwegian', '1 yellow', '1 cat', '1 water', '1 dunhill',
'2 dane', '2 blue', '2 horse', '2 tea', '2 blends',
'3 brit', '3 red', '3 bird', '3 milk', '3 pall mall',
'4 german', '4 green', '4 fish', '4 coffee', '4 prince',
'5 swede', '5 white', '5 dog', '5 root beer', '5 blue master']
The fish is in green house owned by the german who drinks coffee and smokes Prince.
Code for the Einstein Puzzle¶
from sat_utils import solve_one, from_dnf, one_of
from sys import intern
from pprint import pprint
houses = ['1', '2', '3', '4', '5' ]
groups = [
['dane', 'brit', 'swede', 'norwegian', 'german' ],
['yellow', 'red', 'white', 'green', 'blue' ],
['horse', 'cat', 'bird', 'fish', 'dog' ],
['water', 'tea', 'milk', 'coffee', 'root beer'],
['pall mall', 'prince', 'blue master', 'dunhill', 'blends' ],
]
values = [value for group in groups for value in group]
def comb(value, house):
'Format how a value is shown at a given house'
return intern(f'{value} {house}')
def found_at(value, house):
'Value known to be at a specific house'
return [(comb(value, house),)]
def same_house(value1, value2):
'The two values occur in the same house: brit1 & red1 | brit2 & red2 ...'
return from_dnf([(comb(value1, i), comb(value2, i)) for i in houses])
def consecutive(value1, value2):
'The values are in consecutive houses: green1 & white2 | green2 & white3 ...'
return from_dnf([(comb(value1, i), comb(value2, j))
for i, j in zip(houses, houses[1:])])
def beside(value1, value2):
'The values occur side-by-side: blends1 & cat2 | blends2 & cat1 ...'
return from_dnf([(comb(value1, i), comb(value2, j))
for i, j in zip(houses, houses[1:])] +
[(comb(value2, i), comb(value1, j))
for i, j in zip(houses, houses[1:])]
)
cnf = []
# each house gets exactly one value from each attribute group
for house in houses:
for group in groups:
cnf += one_of(comb(value, house) for value in group)
# each value gets assigned to exactly one house
for value in values:
cnf += one_of(comb(value, house) for house in houses)
cnf += same_house('brit', 'red')
cnf += same_house('swede', 'dog')
cnf += same_house('dane', 'tea')
cnf += consecutive('green', 'white')
cnf += same_house('green', 'coffee')
cnf += same_house('pall mall', 'bird')
cnf += same_house('yellow', 'dunhill')
cnf += found_at('milk', 3)
cnf += found_at('norwegian', 1)
cnf += beside('blends', 'cat')
cnf += beside('horse', 'dunhill')
cnf += same_house('blue master', 'root beer')
cnf += same_house('german', 'prince')
cnf += beside('norwegian', 'blue')
cnf += beside('blends', 'water')
pprint(solve_one(cnf))
Essential Utilities for Humanization¶
'Utility functions to humanize interaction with pycosat'
__author__ = 'Raymond Hettinger'
import pycosat # https://pypi.python.org/pypi/pycosat
from itertools import combinations
from functools import lru_cache
from sys import intern
def make_translate(cnf):
"""Make translator from symbolic CNF to PycoSat's numbered clauses.
Return a literal to number dictionary and reverse lookup dict
>>> make_translate([['~a', 'b', '~c'], ['a', '~c']])
({'a': 1, 'c': 3, 'b': 2, '~a': -1, '~b': -2, '~c': -3},
{1: 'a', 2: 'b', 3: 'c', -1: '~a', -3: '~c', -2: '~b'})
"""
lit2num = {}
for clause in cnf:
for literal in clause:
if literal not in lit2num:
var = literal[1:] if literal[0] == '~' else literal
num = len(lit2num) // 2 + 1
lit2num[intern(var)] = num
lit2num[intern('~' + var)] = -num
num2var = {num:lit for lit, num in lit2num.items()}
return lit2num, num2var
def translate(cnf, uniquify=False):
'Translate a symbolic cnf to a numbered cnf and return a reverse mapping'
# DIMACS CNF file format:
# http://people.sc.fsu.edu/~jburkardt/data/cnf/cnf.html
if uniquify:
cnf = list(dict.fromkeys(cnf))
lit2num, num2var = make_translate(cnf)
numbered_cnf = [tuple([lit2num[lit] for lit in clause]) for clause in cnf]
return numbered_cnf, num2var
def itersolve(symbolic_cnf, include_neg=False):
numbered_cnf, num2var = translate(symbolic_cnf)
for solution in pycosat.itersolve(numbered_cnf):
yield [num2var[n] for n in solution if include_neg or n > 0]
def solve_all(symcnf, include_neg=False):
return list(itersolve(symcnf, include_neg))
def solve_one(symcnf, include_neg=False):
return next(itersolve(symcnf, include_neg))
############### Support for Building CNFs ##########################
@lru_cache(maxsize=None)
def neg(element) -> 'element':
'Negate a single element'
return intern(element[1:] if element.startswith('~') else '~' + element)
def from_dnf(groups) -> 'cnf':
'Convert from or-of-ands to and-of-ors'
cnf = {frozenset()}
for group in groups:
nl = {frozenset([literal]) : neg(literal) for literal in group}
# The "clause | literal" prevents dup lits: {x, x, y} -> {x, y}
# The nl check skips over identities: {x, ~x, y} -> True
cnf = {clause | literal for literal in nl for clause in cnf
if nl[literal] not in clause}
# The sc check removes clauses with superfluous terms:
# {{x}, {x, z}, {y, z}} -> {{x}, {y, z}}
# Should this be left until the end?
sc = min(cnf, key=len) # XXX not deterministic
cnf -= {clause for clause in cnf if clause > sc}
return list(map(tuple, cnf))
class Q:
'Quantifier for the number of elements that are true'
def __init__(self, elements):
self.elements = tuple(elements)
def __lt__(self, n: int) -> 'cnf':
return list(combinations(map(neg, self.elements), n))
def __le__(self, n: int) -> 'cnf':
return self < n + 1
def __gt__(self, n: int) -> 'cnf':
return list(combinations(self.elements, len(self.elements)-n))
def __ge__(self, n: int) -> 'cnf':
return self > n - 1
def __eq__(self, n: int) -> 'cnf':
return (self <= n) + (self >= n)
def __ne__(self, n) -> 'cnf':
raise NotImplementedError
def __repr__(self) -> str:
return f'{self.__class__.__name__}(elements={self.elements!r})'
def all_of(elements) -> 'cnf':
'Forces inclusion of matching rows on a truth table'
return Q(elements) == len(elements)
def some_of(elements) -> 'cnf':
'At least one of the elements must be true'
return Q(elements) >= 1
def one_of(elements) -> 'cnf':
'Exactly one of the elements is true'
return Q(elements) == 1
def basic_fact(element) -> 'cnf':
'Assert that this one element always matches'
return Q([element]) == 1
def none_of(elements) -> 'cnf':
'Forces exclusion of matching rows on a truth table'
return Q(elements) == 0