Skip to main content
  (Week 2)

Practice

From puzzles to engineering: encoding real problems as SAT, optimizing solutions, and resolving conflicts.

Where We Left Off

Last week you saw solvers find bugs, prove correctness, and even synthesize code. The key idea was reduction: take a problem, encode it as constraints, and let the solver do the work.

This week's question: how do you encode a real engineering problem? Not a puzzle with a clean answer, but a system with dependencies, conflicts, and tradeoffs?

Configuration Management

Every software engineer has dealt with dependency hell. You want to install a package, but it needs other packages, which need other packages, and some of them conflict with each other.

What most engineers don't know: the good package managers solve this with SAT solvers. Fedora, SUSE, and Dart all use SAT-based dependency resolution internally.

We are going to build a small package manager from scratch. Same idea, same tool, just a few lines of Z3. Three problems, three solver capabilities:

  1. Can a package be installed? Given dependencies and conflicts, is there a valid installation? This is a SAT problem.
  2. What is the cheapest installation? When there are multiple valid options, which one minimizes total size? This is pseudo-boolean optimization.
  3. What to remove to make room? When a new package conflicts with something already installed, what is the least-disruptive fix? This is partial MaxSAT.

Each problem builds on the previous one. The encoding barely changes between them.

The Package Graph

Here is our setup. Nine packages with dependencies and one conflict:

graph TD
    accTitle: Package dependency graph
    accDescr: Nine packages with dependencies and one conflict. webapp depends on django, analytics, and numpy. django depends on postgres. analytics needs a database (postgres or mysql) and a plotter (matplotlib or seaborn). celery depends on numpy. postgres and mysql conflict.
    webapp --> django
    webapp --> analytics
    webapp --> numpy
    django --> postgres
    analytics --> db_choice(( ))
    db_choice --> postgres
    db_choice --> mysql
    analytics --> plot_choice(( ))
    plot_choice --> matplotlib
    plot_choice --> seaborn
    celery --> numpy

    postgres <-- "conflict" --> mysql

    style numpy fill:#d4edda,stroke:#28a745
    style db_choice fill:#000,stroke:#000,color:#000
    style plot_choice fill:#000,stroke:#000,color:#000

Solid arrows are required dependencies. Black dots are choice points: at least one option below the dot must be installed. The green node (numpy) is already installed. postgres and mysql cannot both be installed.

We want to install webapp. Can we?

Demo 1: Can It Be Installed? (SAT)

The encoding is a direct translation of the graph. Each package is a boolean variable. Dependencies become implications. Conflicts become mutual exclusions.

config-install.py
from z3 import *

webapp, django, analytics = Bools('webapp django analytics')
postgres, mysql           = Bools('postgres mysql')
matplotlib, seaborn       = Bools('matplotlib seaborn')
celery, numpy             = Bools('celery numpy')

s = Solver()

# Dependencies: "a needs b" becomes "if a then b"
s.add(Implies(webapp, django))
s.add(Implies(webapp, analytics))
s.add(Implies(webapp, numpy))
s.add(Implies(django, postgres))
s.add(Implies(analytics, Or(postgres, mysql)))
s.add(Implies(analytics, Or(matplotlib, seaborn)))
s.add(Implies(celery, numpy))

# Conflict: postgres and mysql cannot both be installed
s.add(Not(And(postgres, mysql)))

# Already installed
s.add(numpy)

# Desired package
s.add(webapp)

result = s.check()
print("Can webapp be installed?", result)

if result == sat:
    m = s.model()
    installed = [str(p) for p in [webapp, django, analytics, postgres,
                                   mysql, matplotlib, seaborn, celery, numpy]
                 if is_true(m.evaluate(p))]
    print("Install:", installed)

Output:

Can webapp be installed? sat
Install: ['webapp', 'django', 'analytics', 'postgres', 'matplotlib', 'numpy']

The solver found a valid installation. The encoding is a direct translation of the graph into logic:

What you mean Z3 code CNF
a depends on b Implies(a, b) ¬ab
a needs b or c Implies(a, Or(b, c)) ¬abc
a and b conflict Not(And(a, b)) ¬a¬b
a is installed s.add(a) a (unit clause)

Every row is the same idea: a fact about the system becomes a clause. The solver takes all the clauses together and finds an assignment that satisfies every one of them.

This is a reduction. We took a package management problem and turned it into a SAT problem. The solver gives us back a satisfying assignment, which we read as an installation plan.

Demo 2: What Is the Cheapest Installation? (Pseudo-Boolean Optimization)

The solver found a valid installation. But there might be several. The choice dependencies on analytics mean we could pick postgres or mysql, and matplotlib or seaborn. Which combination is cheapest?

Vanilla SAT can only answer yes or no. It cannot minimize. For that, we need pseudo-boolean optimization: assign weights to variables and minimize a linear function over them.

In Z3, this means switching from Solver() to Optimize() and adding a minimize() objective. The constraints stay the same.

config-optimal.py
from z3 import *

webapp, django, analytics = Bools('webapp django analytics')
postgres, mysql           = Bools('postgres mysql')
matplotlib, seaborn       = Bools('matplotlib seaborn')
celery, numpy             = Bools('celery numpy')

o = Optimize()

# Same dependencies and conflict as before
o.add(Implies(webapp, django))
o.add(Implies(webapp, analytics))
o.add(Implies(webapp, numpy))
o.add(Implies(django, postgres))
o.add(Implies(analytics, Or(postgres, mysql)))
o.add(Implies(analytics, Or(matplotlib, seaborn)))
o.add(Implies(celery, numpy))
o.add(Not(And(postgres, mysql)))
o.add(numpy)
o.add(webapp)

# Package sizes in MB
# matplotlib is 5MB, seaborn is 2MB, everything else is 1MB
sizes = {
    webapp: 1, django: 1, analytics: 1,
    postgres: 1, mysql: 1,
    matplotlib: 5, seaborn: 2,
    celery: 1, numpy: 0,  # already installed
}

total_size = Sum([If(pkg, cost, 0) for pkg, cost in sizes.items()])
o.minimize(total_size)

result = o.check()
print("Optimal installation:", result)

if result == sat:
    m = o.model()
    installed = [str(p) for p in sizes if is_true(m.evaluate(p))]
    print("Install:", installed)
    print("Total size:", m.evaluate(total_size), "MB")

Output:

Optimal installation: sat
Install: ['webapp', 'django', 'analytics', 'postgres', 'seaborn', 'numpy']
Total size: 6 MB

The optimizer picked seaborn (2MB) over matplotlib (5MB). Same constraints, different question, different answer. The total dropped from 9MB to 6MB.

The only code change: Solver() became Optimize(), and we added a minimize() call. Everything else is identical. The line If(pkg, cost, 0) treats each boolean as 0 or 1, so the sum 1*webapp + 1*django + ... + 5*matplotlib + 2*seaborn + ... is exactly the linear objective c1x1++cnxn from the pseudo-boolean formulation. The constraints are the same clauses as before, just expressed as inequalities internally.

Demo 3: Resolving Conflicts (Partial MaxSAT)

New scenario: mysql is now also already installed. Can we still install webapp?

The problem: webapp needs django, which needs postgres. But postgres conflicts with mysql, which is already installed. As a regular SAT problem, this is unsatisfiable. There is no way to satisfy all the constraints at once.

The issue is that our encoding is too strong. In Demo 1, we added s.add(numpy) as a hard fact: numpy IS installed, period. But if we want the solver to consider uninstalling packages, we need to relax that. Installed packages should be preferences, not requirements.

This is a partial MaxSAT problem. It splits the constraints into two kinds:

Any soft constraint the solver drops corresponds to a package to uninstall. The solver maximizes the number of soft constraints it keeps, so it finds the minimum-disruption solution.

config-maxsat.py
from z3 import *

webapp, django, analytics = Bools('webapp django analytics')
postgres, mysql           = Bools('postgres mysql')
matplotlib, seaborn       = Bools('matplotlib seaborn')
celery, numpy             = Bools('celery numpy')

o = Optimize()

# Dependencies and conflict are HARD (the rules of the system)
o.add(Implies(webapp, django))
o.add(Implies(webapp, analytics))
o.add(Implies(webapp, numpy))
o.add(Implies(django, postgres))
o.add(Implies(analytics, Or(postgres, mysql)))
o.add(Implies(analytics, Or(matplotlib, seaborn)))
o.add(Implies(celery, numpy))
o.add(Not(And(postgres, mysql)))

# Desired package is HARD (we must install webapp)
o.add(webapp)

# Already installed packages are SOFT (can be dropped if needed)
o.add_soft(numpy)
o.add_soft(mysql)

result = o.check()
print("Install webapp with minimum disruption:", result)

if result == sat:
    m = o.model()
    all_pkgs = [webapp, django, analytics, postgres,
                mysql, matplotlib, seaborn, celery, numpy]
    installed = [str(p) for p in all_pkgs if is_true(m.evaluate(p))]
    removed   = [str(p) for p in [numpy, mysql]
                 if is_false(m.evaluate(p))]
    print("Install:", installed)
    if removed:
        print("Remove:", removed)

Output:

Install webapp with minimum disruption: sat
Install: ['webapp', 'django', 'analytics', 'postgres', 'matplotlib', 'numpy']
Remove: ['mysql']

The solver kept numpy (no reason to remove it) and dropped mysql (because postgres is required and they conflict). One uninstallation, minimum disruption.

The only code change from Demo 1: installed packages use add_soft() instead of add(). That single change turns SAT into MaxSAT.

The Problem Hierarchy

These three demos show a natural progression:

Each extends the previous, and each can simulate the ones below it.

Formal Definitions

SAT. Given a set of CNF clauses C, find an assignment to the variables that satisfies every clause in C, or report that no such assignment exists.

MaxSAT. Given a set of CNF clauses C, find an assignment that satisfies a maximum subset CC. If C is satisfiable, MaxSAT reduces to SAT (satisfy all clauses). If C is unsatisfiable, MaxSAT minimizes the number of violated clauses.

Partial MaxSAT (pMaxSAT). Given hard clauses H and soft clauses S, find an assignment that satisfies all of H and maximizes the number of satisfied clauses in S. A MaxSAT problem is a pMaxSAT problem where all clauses are soft. A SAT problem is a pMaxSAT problem where all clauses are hard.

Partial Weighted MaxSAT (pwMaxSAT). Given hard clauses H and weighted soft clauses W, find an assignment that satisfies all of H and maximizes the total weight of satisfied clauses in W. A pMaxSAT problem is a pwMaxSAT problem where all soft clauses have weight 1.

Pseudo-Boolean Optimization (PB). Given linear inequalities over {0,1} variables and a linear objective, find an assignment satisfying all inequalities that minimizes the objective:

minimizec1x1++cnxn subject toai1x1++ainxnbifor each constraint i

PB and pwMaxSAT can simulate each other.

Notice how much work went into encoding the package graph. We had to think carefully about what each constraint means, translate it to logic, and get every clause right. Directly programming SAT is a lot like programming in assembly: the solver is powerful, but the encoding is manual and low-level. Later in the course (starting next week) we will see richer logics that make this easier.

Limits: The Pigeonhole Problem

Solvers are powerful, but they are not magic. Some problems are structurally hard.

The pigeonhole principle: if you have n+1 pigeons and n holes, at least two pigeons must share a hole. This is obviously true, but proving it with a SAT solver requires exponential time (Haken, 1985). No resolution-based solver can avoid this.

The encoding uses one boolean variable pi,j for "pigeon i is in hole j." Two constraints: every pigeon must go in some hole, and no hole can hold two pigeons. The formula is unsatisfiable for any n, but the solver has to work exponentially hard to prove it.

pigeonhole.py
from z3 import *
import time

def pigeonhole(n):
    """Encode: can n+1 pigeons fit in n holes, one per hole?"""
    pigeons = n + 1
    holes   = n
    p = [[Bool(f"p_{i}_{j}") for j in range(holes)]
         for i in range(pigeons)]

    s = Solver()
    # Every pigeon goes in some hole
    for i in range(pigeons):
        s.add(Or(p[i]))
    # No hole holds two pigeons
    for j in range(holes):
        for i1 in range(pigeons):
            for i2 in range(i1 + 1, pigeons):
                s.add(Not(And(p[i1][j], p[i2][j])))
    return s

for n in [3, 5, 7, 8, 9, 10, 11]:
    s = pigeonhole(n)
    s.set("timeout", 30000)
    start = time.time()
    result = s.check()
    elapsed = time.time() - start
    status = "TIMEOUT" if result == unknown else str(result)
    print(f"n={n:2d}  ({n+1} pigeons, {n} holes)  "
          f"{status:7s}  {elapsed:.3f}s")

Output:

n= 3  (4 pigeons, 3 holes)  unsat    0.000s
n= 5  (6 pigeons, 5 holes)  unsat    0.001s
n= 7  (8 pigeons, 7 holes)  unsat    0.016s
n= 8  (9 pigeons, 8 holes)  unsat    0.099s
n= 9  (10 pigeons, 9 holes)  unsat    1.125s
n=10  (11 pigeons, 10 holes)  unsat    3.462s
n=11  (12 pigeons, 11 holes)  TIMEOUT  30.006s

Each step is roughly 3 to 10 times slower than the last. By n=11, Z3 (a state-of-the-art solver) cannot finish in 30 seconds. This is not a bug in Z3. It is a proven mathematical limitation of the approach.

Knowing where solvers hit limits is part of the engineering skill. Most real-world problems are not pigeonhole. But when your solver suddenly slows down, this is the kind of thing to consider.

What We Learned

Encoding is an engineering skill. Today's key ideas:

After the break, we look under the hood at how modern solvers work. They cannot beat pigeonhole, but they are much smarter than what we saw last week.

Demo Code

All demo files for this lecture are in the course code repo. Clone the repo and run them locally with python3 config-install.py etc. (requires z3-solver; see Setup).