Constraint-satisfaction problems (and how to solve them)
(require csp) | package: csp |
This package is in development. I make no commitment to maintaining the public interface documented below.
Simple solvers for simple constraint-satisfaction problems. It uses the forward-checking + conflict-directed backjumping algorithm described in Hybrid Algorithms for the Constraint Satisfaction Problem by Patrick Prosser. Plus other improvements of my own devising.
1 Installation & usage
raco pkg install csp |
raco pkg update csp |
(require csp) |
2 Introduction
A constraint-satisfaction problem (often shortened to CSP) has two ingredients. The first is a set of variables, each associated with a set of possible values (called its domain). The other is a set of constraints — a fancy word for rules — that describe relationships among the variables.
When we select a value for each variable, we have what’s known as an assignment or a state. Solving a CSP means finding an assignment that satisfies all the constraints. A CSP may have any number of solution states (including zero).
Even if the name is new, the idea of a CSP is probably familiar. For instance, many brain teasers — like Sudoku or crosswords or logic puzzles — are really just constraint-satisfaction problems. (Indeed, you can use this package to ruin all of them.)
When the computer solves a CSP, it’s using an analogous process of deductive reasoning to eliminate impossible assignments, eventually converging on a solution (or determining that no solution exists).
3 First example
Suppose we wanted to find Pythagorean triples with sides between 10 and 49, inclusive.
First we create a new CSP called triples, using make-csp:
> (define triples (make-csp))
We use CSP variables to represent the values in the triple. We insert each one with add-var!, where each variable has a symbol for its name and a list of values for its domain:
> (add-var! triples 'a (range 10 50)) > (add-var! triples 'b (range 10 50)) > (add-var! triples 'c (range 10 50))
Then we need our constraint. We make a function called valid-triple? that tests three values to see if they qualify as a Pythagorean triple. Then we insert this function as a constraint using add-constraint!, passing as arguments 1) the function we want to use for the constraint, and 2) a list of variable names that the constraint applies to.
> (define (valid-triple? x y z) (= (expt z 2) (+ (expt x 2) (expt y 2)))) > (add-constraint! triples valid-triple? '(a b c))
Notice that the argument names used within the constraint function (x y z) have nothing to do with the CSP variable names that are passed to the function '(a b c). This makes sense — we might want constraints that apply the same function to different groups of CSP variables. What’s important is that the arity of the constraint function matches the number of variable names, and that the variable names are ordered correctly (the first variable will become the first argument to the constraint function, and so on).
Finally we call solve, which finds a solution (if it exists):
> (solve triples) '((a . 36) (b . 27) (c . 45))
“But that’s just the 5–12–13 triple, doubled.” True. Suppose we want to ensure that the values in our solution have no common factors. We add a new coprime? constraint:
> (require math/number-theory) > (add-constraint! triples coprime? '(a b c))
We solve again to see the new result:
> (solve triples) '((a . 12) (b . 35) (c . 37))
Perhaps we’re curious to see how many of these triples exist. We use solve* to find all four solutions:
> (solve* triples)
'(((a . 12) (b . 35) (c . 37))
((a . 21) (b . 20) (c . 29))
((a . 20) (b . 21) (c . 29))
((a . 35) (b . 12) (c . 37)))
“But really there’s only two solutions — the values for a and b are swapped in the other two.” Fair enough. We might say that this problem is symmetric relative to variables a and b, because they have the same domains and are constrained the same way. We can break the symmetry by adding a constraint that forces a to be less than or equal to b:
> (add-constraint! triples <= '(a b)) > (solve* triples) '(((a . 12) (b . 35) (c . 37)) ((a . 20) (b . 21) (c . 29)))
Now our list of solutions doesn’t have any symmetric duplicates.
By the way, what if we had accidentally included c in the last constraint?
> (add-constraint! triples <= '(a b c)) > (solve* triples) '(((a . 12) (b . 35) (c . 37)) ((a . 20) (b . 21) (c . 29)))
Nothing changes. Why not? Because of the existing valid-triple? constraint, c is necessarily going to be larger than a and b. So it always meets this constraint too. It’s good practice to not duplicate constraints between the same sets of variables — the “belt and suspenders” approach just adds work for no benefit.
We should use solve* with care. It can’t finish until the CSP solver examines every possible assignment of values in the problem, which can be a big number. Specifically, it’s the product of the domain sizes of each variable, which in this case is 40 × 40 × 40 = 64,000. This realm of possible assignments is also known as the CSP’s state space. We can also get this number from state-count:
> (state-count triples) 64000
It’s easy for a CSP to have a state count in the zillions. For this reason we can supply solve* with an optional argument that will only generate a certain number of solutions:
> (time (solve* triples)) cpu time: 142 real time: 142 gc time: 19
'(((a . 12) (b . 35) (c . 37)) ((a . 20) (b . 21) (c . 29)))
> (time (solve* triples 2)) cpu time: 64 real time: 64 gc time: 0
'(((a . 12) (b . 35) (c . 37)) ((a . 20) (b . 21) (c . 29)))
Here, the answers are the same. But the second call to solve* finishes sooner, because it quits as soon as it’s found two solutions.
Of course, even when we use ordinary solve, we don’t know how many assignments it will have to try before it finds a solution. If the problem is impossible, even solve will have to visit the entire state space before it knows for sure. For instance, let’s see what happens if we add a constraint that’s impossible to meet:
> (add-constraint! triples = '(a b c)) > (solve triples) #f
Disappointing but accurate.
The whole example in one block:
(require csp) (define triples (make-csp)) (add-var! triples 'a (range 10 50)) (add-var! triples 'b (range 10 50)) (add-var! triples 'c (range 10 50)) (define (valid-triple? x y z) (= (expt z 2) (+ (expt x 2) (expt y 2)))) (add-constraint! triples valid-triple? '(a b c)) (require math/number-theory) (add-constraint! triples coprime? '(a b c)) (add-constraint! triples <= '(a b)) (solve* triples 2)
4 Interlude
“Dude, are you kidding me? I can write a much shorter loop to do the same thing—"
> (for*/list ([a (in-range 10 50)] [b (in-range 10 50)] #:when (<= a b) [c (in-range 10 50)] #:when (and (coprime? a b c) (valid-triple? a b c))) (map cons '(a b c) (list a b c))) '(((a . 12) (b . 35) (c . 37)) ((a . 20) (b . 21) (c . 29)))
Yes, I agree that in this toy example, the CSP approach is overkill. The variables are few enough, the domains small enough, and the constraints simple enough, that a loop is more concise. Also, with only 64,000 possibilities in the state space, this sort of brute-force approach is cheap & cheerful.
5 Second example
But what about a more complicated problem — like a Sudoku? A Sudoku has 81 squares, each of which can hold the digits 1 through 9. The goal in Sudoku is to fill the grid so that no row, no column, and no “box” (a 3 × 3 subgroup of cells) has a duplicate digit. About 25 of the squares are filled in at the start, so the size of the state space is therefore:
> (expt 9 (- 81 25)) 273892744995340833777347939263771534786080723599733441
Well over a zillion, certainly. Let’s optimistically suppose that the 3.7GHz processor in your computer takes one cycle to check an assignment. There are 31,557,600 seconds in a year, so the brute-force method will only take this many years:
> (define states (expt 9 (- 81 25))) > (define states-per-second (* 3.7 1000000000.0)) > (define seconds-per-year 31557600) > (/ states states-per-second seconds-per-year) 2.3457127986588646e+36
6 Another interlude
“Dude, are you serious? The JMAXX Sudoku Solver runs three to four times faster—”
Yes, I agree that an algorithm custom-tailored to the problem will likely beat the CSP solver, which is necessarily general-purpose.
But let’s consider the labor involved. To write something like the JMAXX Sudoku Solver, we’d need a PhD in computer science, and the time to explain not just the rules of Sudoku to the computer, but the process for solving a Sudoku.
By contrast, when we use a CSP, all we need are the rules. The CSP solver does the rest. In this way, a CSP gives us an alternative, simpler way to explain Sudoku to the computer, just like regular expressions are an alternate way of expressing string patterns. And if the CSP solver is half a second slower, that seems like a reasonable tradeoff.
Daring minds might even consider a CSP solver to be a kind of domain-specific language.
7 Making & solving CSPs
struct
(struct csp (vars constraints) #:extra-constructor-name make-csp #:transparent) vars : (listof var?) constraints : (listof constraint?)
struct
(struct var (name domain) #:extra-constructor-name make-var #:transparent) name : name? domain : (listof any/c)
struct
(struct constraint (names proc) #:extra-constructor-name make-constraint #:transparent) names : (listof name?) proc : procedure?
procedure
prob : csp? name : name? domain : (or/c (listof any/c) procedure?) = empty
procedure
prob : csp? names : (listof name?) domain : (or/c (listof any/c) procedure?) = empty
procedure
(add-constraint! prob func names [func-name]) → void?
prob : csp? func : procedure? names : (listof name?) func-name : (or/c #false name?) = #f
procedure
(add-constraints! prob func namess [func-name]) → void?
prob : csp? func : procedure? namess : (listof (listof name?)) func-name : (or/c #false name?) = #f
procedure
(add-pairwise-constraint! prob func names [ func-name]) → void? prob : csp? func : procedure? names : (listof name?) func-name : (or/c #false name?) = #f
syntax
(in-solutions prob)
8 Sideshows
procedure
(state-count prob) → natural?
prob : csp?
procedure
(csp->graph prob) → graph?
prob : csp?
procedure
(csp->graphviz prob) → string?
prob : csp?
9 Parameters
parameter
(current-select-variable) → (or/c #false procedure?)
(current-select-variable val) → void? val : (or/c #false procedure?)
= #f
parameter
(current-order-values) → (or/c #false procedure?)
(current-order-values val) → void? val : (or/c #false procedure?)
= #f
parameter
(current-inference) → (or/c #false procedure?)
(current-inference val) → void? val : (or/c #false procedure?)
= #f
parameter
(current-solver) → (or/c #false procedure?)
(current-solver val) → void? val : (or/c #false procedure?)
= #f
parameter
(current-random) → (or/c #false procedure?)
(current-random val) → void? val : (or/c #false procedure?)
= #t
parameter
(current-decompose) → (or/c #false procedure?)
(current-decompose val) → void? val : (or/c #false procedure?)
= #t
parameter
(current-thread-count) → (or/c #false natural?)
(current-thread-count val) → void? val : (or/c #false natural?)
= 4
parameter
(current-node-consistency) → (or/c #false procedure?)
(current-node-consistency val) → void? val : (or/c #false procedure?)
= #f
parameter
(current-arity-reduction) → (or/c #false procedure?)
(current-arity-reduction val) → void? val : (or/c #false procedure?)
= #t
parameter
(current-learning) → (or/c #false procedure?)
(current-learning val) → void? val : (or/c #false procedure?)
= #f
10 License & source code
This module is licensed under the LGPL.
Source repository at http://github.com/mbutterick/csp. Suggestions & corrections welcome.