I was working through an example CP-SAT program on sports team scheduling, and I found a bit of AddBoolOr logic that I didn’t understand. So I wrote up a short program to explore what was going on.
Stringing together multiple AddBoolOr
s
I was examining the logic in
sports_scheduling_sat.cc,
and came across the breaks logic. The idea of a break in the code is
a sequence of two home or two away games in a row. Breaks are
identified with a binary variable, and that break variable is reified
(if that is the right term) by using four AddBoolOr
constraints.
a = model.NewBoolVar('home day 1')
b = model.NewBoolVar('home day 2')
c = model.NewBoolVar('break')
model.AddBoolOr([a.Not(), b.Not(), c])
model.AddBoolOr([a, b, c])
model.AddBoolOr([a.Not(), b, c.Not()])
model.AddBoolOr([a, b.Not(), c.Not()])
This will generates the truth table:
home day 1=1 home day 2=0 break=0
home day 1=0 home day 2=1 break=0
home day 1=1 home day 2=1 break=1
home day 1=0 home day 2=0 break=1
I really didn’t understand this at all, so I broke it down. First of
all, AddBoolOr
is defined as requiring that at least one of the
passed array of boolean variables is true. So line by line:
model.AddBoolOr([a.Not(), b.Not(), c])
This says if c==True
then one or both of a.Not()
or b.Not()
can
be False
, because the requirement that one value is true is taken
care of by c
. On the other hand, if c==False
, then one of
a.Not()
or b.Not()
must be true; they can’t both be false. Just
this constraint results in the following truth table:
home day 1=0 home day 2=0 break=0
home day 1=0 home day 2=1 break=0
home day 1=1 home day 2=0 break=0
home day 1=1 home day 2=0 break=1
home day 1=1 home day 2=1 break=1
home day 1=0 home day 2=1 break=1
home day 1=0 home day 2=0 break=1
If break is true, then Not(home day 1) and Not(home day 2) can take on any value; if break is false, then the case of both Not(home day 1) and Not(home day 2) being false is forbidden. Removing the “Not”, that means that the case of both home day 1 and home day 2 being true is forbidden if break is false.
Similarly
model.AddBoolOr([a, b, c])
says if c==True
then one or both of a
or b
can
be False
, because the requirement that one value is true is taken
care of by c
. And again, if c==False
, then one of
a
or b
must be true; they can’t both be false. The resulting
truth table is the same when break is true, but slightly different for
when break is false:
home day 1=0 home day 2=1 break=0
home day 1=1 home day 2=1 break=0
home day 1=1 home day 2=0 break=0
home day 1=1 home day 2=0 break=1
home day 1=1 home day 2=1 break=1
home day 1=0 home day 2=1 break=1
home day 1=0 home day 2=0 break=1
When break is false, then it cannot be that both home day 1 and home day 2 are also false.
So combining these two constraints at the same time, I would expect that when break is false, it will not be possible to have both home day 1 and home day 2 be true, nor to have them both be false. Which we see is the case. The partially commented code
model.AddBoolOr([a.Not(), b.Not(), c])
model.AddBoolOr([a, b, c])
# model.AddBoolOr([a.Not(), b, c.Not()])
# model.AddBoolOr([a, b.Not(), c.Not()])
generates the truth table
home day 1=0 home day 2=1 break=0
home day 1=1 home day 2=0 break=0
home day 1=1 home day 2=0 break=1
home day 1=1 home day 2=1 break=1
home day 1=0 home day 2=1 break=1
home day 1=0 home day 2=0 break=1
These two constraints have no impact whatsoever on the cases in which break is true, as both home day 1 and home day 2 can take on any value in any combination.
Moving on to the break=False cases,
model.AddBoolOr([a.Not(), b, c.Not()])
generates the truth table:
home day 1=0 home day 2=0 break=0
home day 1=0 home day 2=1 break=0
home day 1=1 home day 2=1 break=0
home day 1=1 home day 2=0 break=0
home day 1=1 home day 2=1 break=1
home day 1=0 home day 2=1 break=1
home day 1=0 home day 2=0 break=1
All the embeded Not()
calls make this tricky to reason over, but
what it is saying is that if c==True
(this period is a break) then
c.Not()==False
, which means that at least one of the other variables
in the array must be true: [a.Not(),b]
If a.Not()==True
, that
means a==False
. So the resulting cases can either be a==False
and
b==False
, or b==True
and a==True
, or both a==False
and
b==True
, but not a==True
and b==False
.
The next expression eliminates the case of a==False
and b=True
:
model.AddBoolOr([a, b.Not(), c.Not()])
home day 1=0 home day 2=0 break=0
home day 1=0 home day 2=1 break=0
home day 1=1 home day 2=1 break=0
home day 1=1 home day 2=0 break=0
home day 1=1 home day 2=0 break=1
home day 1=1 home day 2=1 break=1
home day 1=0 home day 2=0 break=1
Both of these c.Not()
constraints leave untouched the case of
c==True
, and both of them leave alone the case of both a==True, b==True
or a==False, b==False
. So taken together, the resulting
truth table gets rid of the cases in which a and b are different when
c is true.
# model.AddBoolOr([a.Not(), b.Not(), c])
# model.AddBoolOr([a, b, c])
model.AddBoolOr([a.Not(), b, c.Not()])
model.AddBoolOr([a, b.Not(), c.Not()])
home day 1=0 home day 2=0 break=0
home day 1=0 home day 2=1 break=0
home day 1=1 home day 2=1 break=0
home day 1=1 home day 2=0 break=0
home day 1=1 home day 2=1 break=1
home day 1=0 home day 2=0 break=1
So when you combine all four AddBoolOr
constraints at once, the
first pair get rid of two cases when c==False
, the second pair get
rid of two cases when c==True
, and we get the truth table we want:
# eliminates conditions when c is false
model.AddBoolOr([a.Not(), b.Not(), c])
model.AddBoolOr([a, b, c])
# eliminates conditions when c is true
model.AddBoolOr([a.Not(), b, c.Not()])
model.AddBoolOr([a, b.Not(), c.Not()])
home day 1=0 home day 2=1 break=0
home day 1=1 home day 2=0 break=0
home day 1=1 home day 2=1 break=1
home day 1=0 home day 2=0 break=1
Why not use OnlyEnforceIf
?
As I stared at the code, I thought to myself that it would be much
easier to understand if the constraint was simply written using
OnlyEnforceIf
.
model.Add(a == b).OnlyEnforceIf(c)
model.Add(a != b).OnlyEnforceIf(c.Not())
This logic is pretty clear to me. It is saying that if c==True
,
then a
and b
must be the same—both true, or both false. On the
other hand, if c==False
, then a
and b
must be different.
So I ran that code, and lo and behold the truth table is exactly the same:
# model.AddBoolOr([a.Not(), b.Not(), c])
# model.AddBoolOr([a, b, c])
# model.AddBoolOr([a.Not(), b, c.Not()])
# model.AddBoolOr([a, b.Not(), c.Not()])
model.Add(a == b).OnlyEnforceIf(c)
model.Add(a != b).OnlyEnforceIf(c.Not())
home day 1=0 home day 2=1 break=0
home day 1=1 home day 2=0 break=0
home day 1=1 home day 2=1 break=1
home day 1=0 home day 2=0 break=1
So why would the example be written in terms of AddBoolOr
when
OnlyEnforceIf
makes the logic so much easier to understand?
What happens when just one of the constraints is on?
model.Add(a == b).OnlyEnforceIf(c)
# model.Add(a != b).OnlyEnforceIf(c.Not())
home day 1=0 home day 2=0 break=0
home day 1=0 home day 2=1 break=0
home day 1=1 home day 2=1 break=0
home day 1=1 home day 2=0 break=0
home day 1=1 home day 2=1 break=1
home day 1=0 home day 2=0 break=1
The same as the combination of the two AddBoolOr([...,c.Not()])
constraints. Similarly, just OnlyEnforceIf(c.Not())
has the same
effect as the two AddBoolOr([..,c])
constraints.
Flipping on logging shows that this is what is going on behind the
scenes somewhere. The log output for the AddBoolOr
version is as
follows:
Parameters: log_search_progress: true search_branching: FIXED_SEARCH enumerate_all_solutions: true
Satisfaction model '':
Search strategy: on 1 variables, CHOOSE_FIRST, SELECT_MIN_VALUE
#Variables: 3
- 3 in [0,1]
#kBoolOr: 4 (#literals: 12)
*** starting model expansion at 0.00s
*** starting model presolve at 0.00s
- 0 affine relations were detected.
- 0 variable equivalence relations were detected.
Satisfaction model '':
Search strategy: on 1 variables, CHOOSE_FIRST, SELECT_MIN_VALUE
#Variables: 3
- 3 in [0,1]
#kBoolOr: 4 (#literals: 12)
*** starting to load the model at 0.00s
...
The output for the OnlyEnforceIf
version is slightly different.
The model as stated is getting simplified by rules:
Parameters: log_search_progress: true search_branching: FIXED_SEARCH enumerate_all_solutions: true
Satisfaction model '':
Search strategy: on 1 variables, CHOOSE_FIRST, SELECT_MIN_VALUE
#Variables: 3
- 3 in [0,1]
#kLinear2: 2 (#enforced: 2)
*** starting model expansion at 0.00s
*** starting model presolve at 0.00s
- 0 affine relations were detected.
- 0 variable equivalence relations were detected.
- rule 'bool_or: removed enforcement literal' was applied 4 times.
- rule 'linear: simplified rhs' was applied 1 time.
- rule 'linear: small Boolean expression' was applied 2 times.
Satisfaction model '':
Search strategy: on 1 variables, CHOOSE_FIRST, SELECT_MIN_VALUE
#Variables: 3
- 3 in [0,1]
#kBoolOr: 4 (#literals: 12)
*** starting to load the model at 0.00s
...
The log output indicates that the enforcement literal is being replaced
by ‘bool_or’ four times, and then once it had finished up analyzing
the problem, there are 3 variables and 4 kBoolOr constraints in both
cases. So somehow, the two OnlyEnforceIf
constraints are just syntactic sugar that get
boiled down to the combination of the four AddBoolOr
constraints.
So I guess the lesson is to use what makes sense, and hope that the
solver can figure it out. The one caveat is that the inline
documentation for OnlyEnforceIf
states contains the following
caveat:
// Important: as of September 2018, only a few constraint support enforcement:
// - bool_or, bool_and, linear: fully supported.
// - interval: only support a single enforcement literal.
// - other: no support (but can be added on a per-demand basis).