Conflict analysis and no-good cuts#
Branch-and-bound spends most of its effort re-discovering infeasibility: the same hopeless combination of binary choices is fixed, propagated, and rejected again and again in different parts of the tree. Conflict analysis turns each such dead end into a reusable fact. When a partial assignment of binaries is proven infeasible — no feasible solution sets those binaries that way — discopt derives a no-good cut that forbids exactly that assignment, so the same conflict is never re-explored elsewhere in the tree [Achterberg, 2007].
For an assignment \(a\) over a subset \(S\) of binaries, the no-good cut is
It is violated only by the exact assignment \(a\) (where the left-hand side is \(0\)) and is satisfied by every other \(0/1\) combination of the involved variables. It therefore removes one proven-dead leaf and nothing else — the global optimum is untouched.
This is the mixed-integer analogue of clause learning in modern SAT and constraint solvers: a discovered conflict is recorded as a learned clause that prunes the rest of the search [Marques-Silva and Sakallah, 1999]. Achterberg adapted this idea to mixed-integer programming, where conflict constraints generalise learned clauses to the LP/MIP setting [Achterberg, 2007, Achterberg et al., 2020]. A no-good cut is the simplest such conflict constraint: a single clause over binaries.
import discopt.modeling as dm
from discopt.conflict import find_conflict_cuts, add_conflict_cuts, no_good_cut
Detecting conflicts#
Consider three binaries under a set-packing constraint \(x_0 + x_1 + x_2 \le 1\):
at most one may be on. Every assignment that turns two of them on is jointly
infeasible, even though no single variable is fixed. find_conflict_cuts probes
subsets of binaries up to max_order, fixes each candidate assignment, and runs
FBBT as the infeasibility oracle. Each FBBT-proven conflict yields a no-good
cut. The cuts are returned (not yet added to the model) so you can inspect them.
m = dm.Model("packing")
x = [m.binary(f"x{i}") for i in range(3)]
m.maximize(x[0] + 2 * x[1] + 3 * x[2])
m.subject_to(x[0] + x[1] + x[2] <= 1) # at most one binary may be on
cuts = find_conflict_cuts(m, max_order=2)
print(f"{len(cuts)} no-good cut(s) derived:")
for c in cuts:
print(" ", c)
3 no-good cut(s) derived:
(1 - ((1 - x0) + (1 - x1))) <= 0.0
(1 - ((1 - x0) + (1 - x2))) <= 0.0
(1 - ((1 - x1) + (1 - x2))) <= 0.0
Each derived cut forbids one of the three pairwise “both on” assignments — \((x_0{=}1, x_1{=}1)\), \((x_0{=}1, x_2{=}1)\), \((x_1{=}1, x_2{=}1)\) — written in the no-good form \((1 - x_i) + (1 - x_j) \ge 1\). The detector keeps only minimal conflicts: an assignment is skipped if a shorter, already-found conflict is a subset of it, so it never reports a redundant superset of a conflict it has already learned.
Building a no-good cut by hand#
If you already know an assignment is bad — say from a callback, a heuristic, or
domain knowledge — you can construct the cut directly with no_good_cut. It
takes a list of (variable, value) pairs and returns the corresponding
constraint, which you then add to the model like any other. Here we forbid the
specific assignment \(a = 1,\ b = 0\).
m2 = dm.Model("by_hand")
a = m2.binary("a")
b = m2.binary("b")
cut = no_good_cut([(a, 1), (b, 0)]) # forbids exactly (a=1, b=0)
print("derived cut:", cut)
m2.subject_to(cut, name="forbid_a1_b0")
print("constraints now in model:", m2.num_constraints)
derived cut: (1 - ((1 - a) + b)) <= 0.0
constraints now in model: 1
The cut \((1 - a) + b \ge 1\) is violated only by \(a = 1, b = 0\) and satisfied by the other three combinations — exactly the clause that excludes a single leaf.
Detecting and injecting in one step#
add_conflict_cuts combines the two operations: it detects conflicts and adds
the resulting no-good cuts straight to the model, returning the number added.
Because every cut excludes only an FBBT-proven infeasible assignment, the
feasible region — and the optimum — is unchanged; only dead subtrees are pruned.
m3 = dm.Model("inject")
y = [m3.binary(f"y{i}") for i in range(3)]
m3.maximize(y[0] + 2 * y[1] + 3 * y[2])
m3.subject_to(y[0] + y[1] + y[2] <= 1)
before = m3.num_constraints
n_added = add_conflict_cuts(m3, max_order=2)
print(f"cuts added: {n_added}")
print(f"constraints: {before} -> {m3.num_constraints}")
cuts added: 3
constraints: 1 -> 4
Relationship to IIS and FBBT#
Conflict analysis sits between two tools documented elsewhere in this book.
Versus IIS. An Irreducible Infeasible Subsystem
answers why an already-infeasible model has no solution: it returns a minimal
set of constraints and bounds that conflict, computed by deletion filtering
[Chinneck, 2008]. Conflict analysis answers a different question on a
feasible model: which partial assignments of binaries can never extend to a
solution? Its output is a set of no-good cuts over variables, meant to be
added back to accelerate the search rather than to explain a failure. IIS is a
diagnostic you run once when solve() returns infeasible; conflict cuts are
valid inequalities you add to a feasible model to prune the tree. Both rest on a
minimality principle — IIS over constraints, conflict analysis over the fixed
binaries in each assignment.
Built on FBBT. The infeasibility oracle for conflict detection is
feasibility-based bound tightening. When a candidate
assignment is fixed, find_conflict_cuts runs fbbt_box; if propagation
collapses some variable’s interval to empty, the assignment is provably
infeasible [Puranik and Sahinidis, 2017]. Because FBBT only ever derives valid
bounds, it never reports a feasible subproblem as infeasible — so a no-good cut
emitted from an FBBT-proven conflict never excludes a feasible point, and in
particular never cuts off the global optimum.
Summary#
Conflict analysis converts proven-dead binary assignments into reusable no-good
cuts, so a contradiction discovered once need never be re-explored. no_good_cut
builds a single clause by hand, find_conflict_cuts discovers minimal conflicts
via FBBT, and add_conflict_cuts injects them in one call — each cut sound by
construction, pruning the search without altering the feasible region
[Achterberg, 2007].