DSA DSA In Depth
0%
case sat-solverscdclpackage-managers

Production Case Study: Modern SAT Solvers & Package Dependency Resolution (DPLL, Unit Propagation & Two-Watched Literals)

7 min read Module 6.1: Recursion, Backtracking, Pruning & Greedy Exchange Arguments Updated Oct 23, 2026
📄 Cheat Sheet
A Peek Inside SAT Solvers — DPLL, Unit Propagation & CDCL
ClojureTV (Jon Smock) A Peek Inside SAT Solvers — DPLL, Unit Propagation & CDCL Watch on YouTube ↗

Explains how industrial SAT solvers scale from 100 variables to millions of variables using Two-Watched Literals and Conflict-Driven Clause Learning.

0:00 / --:--

1. From Naive Backtracking to DPLL & Boolean Constraint Propagation (BCP)

A Boolean formula in Conjunctive Normal Form (CNF) is an AND ($\wedge$) of clauses, where each clause is an OR ($\vee$) of literals ($x_i$ or $\neg x_i$). Package managers encode dependencies directly as CNF clauses:

The DPLL Algorithm (Davis–Putnam–Logemann–Loveland, 1962) enhances backtracking with Unit Propagation (Boolean Constraint Propagation / BCP): Whenever all literals in a clause except one are assigned FALSE, the remaining unassigned literal $l$ is a unit clause and must be assigned TRUE immediately. Assigning $l = \text{TRUE}$ may create new unit clauses in a cascade (fixed-point queue) before any branching decision is made.

Core Invariant — Chaff's Two-Watched Literals (2WL) Invariant

In a naive DPLL solver, checking whether an assignment makes a clause unit requires maintaining a counter of unassigned literals in every clause containing $\neg l$, and decrementing/incrementing all those counters on every step and every backtrack.
Instead, Two-Watched Literals (Moskewicz et al., Chaff 2001) watches only two literals $w_1, w_2$ in each clause $C$ (with $|C| \ge 2$), maintaining the invariant: As long as the clause is not already satisfied, neither $w_1$ nor $w_2$ is assigned FALSE.
Consequently, a clause can never become a unit clause or falsified unless one of its two watched literals becomes FALSE! Better yet: when backtracking (unassigning variables), $w_1$ and $w_2$ remain valid watches—backtracking requires ZERO watch-list updates ($O(1)$ time)!

2. How Two-Watched Literals & CDCL Work Together

When literal $l$ is assigned TRUE, its negation $\neg l$ becomes FALSE. We inspect only the watch list watches[~l]. For each clause $C$ watching $\neg l$ (with other watch $w_{other}$):

  1. If $w_{other}$ is already TRUE, $C$ is satisfied—do nothing.
  2. Scan the non-watched literals of $C$ for any literal $l_{new}$ that is not FALSE. If found, replace watch $\neg l$ with $l_{new}$ and move $C$ to watches[l_new] (no propagation needed).
  3. If all non-watched literals are FALSE:
    • If $w_{other}$ is FALSE, $C$ is a Conflict Clause (all literals FALSE)—trigger conflict analysis / backtrack!
    • If $w_{other}$ is UNASSIGNED, $C$ is now a Unit Clause—enqueue $w_{other} = \text{TRUE}$ into the BCP trail!

In modern CDCL (Conflict-Driven Clause Learning) solvers, when a conflict occurs, the solver resolves the conflict along the implication graph to learn a new conflict clause (a nogood) and performs a non-chronological backjump directly to the decision level responsible for the conflict.

flowchart TD Assign["Assign Literal l = TRUE
(So ~l becomes FALSE)"] --> Scan["Visit only clauses in watches[~l]"] Scan --> Check{"Inspect clause C:
Can we swap watch ~l to another non-FALSE literal k?"} Check -- "Yes (Found k != FALSE)" --> Swap["Move C from watches[~l] to watches[k]
Clause still has 2 non-FALSE watches!"] Check -- "No (All other literals FALSE)" --> Other{"Check the 2nd watched literal w_other"} Other -- "w_other is UNASSIGNED" --> Unit["UNIT PROPAGATION:
Force w_other = TRUE on Trail!"] Other -- "w_other is FALSE" --> Conflict["CONFLICT DETECTED!
Backtrack / Learn Clause"]

Hardware Reality — Why 80% of SAT Solver CPU Time Is Spent in propagate()

Empirical profiling of MiniSat and CaDiCaL shows that 80%–90% of total runtime is spent scanning watches[~l] inside Boolean Constraint Propagation. Because Two-Watched Literals never touches watch lists during backtracking, unassigning 1,000 variables on backtrack is a simple array index reset trail_head = level_pos[lvl]! Furthermore, industrial solvers inline the blocker literal ($w_{other}$) directly inside the 8-byte watch-list entry struct Watch { int clause_id; int blocker; } so if blocker is already TRUE, the CPU skips dereferencing the clause pointer entirely—saving an L3 cache miss.

3. Complexity Matrix

Regret-Greedy Heap Rollback, Matroid Basis & CDCL SAT Implication Graph
O(N log N) Regret / 1-UIP CDCL

Trace Moore-Hodgson Regret-Greedy max-heap job eviction, weighted Matroid greedy basis construction, and CDCL SAT unit propagation + 1-UIP conflict clause learning.

Step 1 / 8
Custom Input:
Timeline

Invariant & State

Moore-Hodgson Regret Invariant: Accepting J2 pushes total time to 4+3=7 > d2=6. Pop max-duration job J1 (p=4) from heap to restore feasibility!
🌳 Live 2D Hierarchical Tree Topology — Regret-Greedy Heap Rollback, Matroid Basis & CDCL SAT Implication Graph Visited/Active Pivot/Root Resolved
Max p=4 J1 (p… [0] Total ti J2 (p… [1] Next J3 (p… [2]
[0]J1 (p=4, d=4)Max p=4 in Heap
[1]J2 (p=3, d=6)Total time=7 > d=6!
[2]J3 (p=2, d=7)Next
Before Eviction
Time = 7 > Deadline 6
Max-Heap Eviction
Drop J1 (p=4)
After Eviction
Time = 3 ≤ 6 (Feasible)
[Step 1] Deadline overflow at J2; max-heap evicts longest accepted job J1 (p=4), reducing total time from 7 to 3.
SAT Solver Mechanism Assign + Propagate Backtrack Cost Worst-Case Time Space Complexity
Naive Counter-Based DPLL O(occ(~l) + occ(l)) O(occ(~l) + occ(l)) O(2^N * L) O(N + L)
DPLL + Two-Watched Literals (2WL) O(|watches[~l]|) O(1) per variable O(2^N * L) O(N + L)
2-SAT (All clauses $|C| \le 2$) O(1) per edge Tarjan SCC (No backtrack) O(N + M) O(N + M)
HORN-SAT (At most 1 positive literal/clause) Unit Propagation Only Zero Backtracking O(N + L) O(N + L)

4. Reference C++20 Implementation

#include <cstdint>
#include <vector>

// Compact DPLL SAT Solver with Unit Propagation and Trail-Based Undo
// Literals for variable v in [0..n-1] are encoded as: pos = 2*v, neg = 2*v ^ 1
class DpllSatSolver {
    int num_vars;
    std::vector<std::vector<int>> clauses;
    std::vector<std::vector<int>> watches; // watches[lit] = list of clause IDs watching lit
    std::vector<std::int8_t> val;          // -1: unassigned, 0: false, 1: true (indexed by literal)
    std::vector<int> trail;                // Stack of assigned literals
    int qhead = 0;                         // BCP queue pointer inside trail

    bool enqueue(int lit) {
        if (val[lit] == 1) return true;    // Already satisfied
        if (val[lit] == 0) return false;   // Conflict!
        val[lit] = 1;
        val[lit ^ 1] = 0;
        trail.push_back(lit);
        return true;
    }

    // Boolean Constraint Propagation (BCP) using Two-Watched Literals
    bool propagate() {
        while (qhead < static_cast<int>(trail.size())) {
            int falsified_lit = trail[qhead++] ^ 1; // ~lit just became FALSE
            auto& wl = watches[falsified_lit];
            std::size_t j = 0;
            for (std::size_t i = 0; i < wl.size(); ++i) {
                int cid = wl[i];
                auto& c = clauses[cid];
                // Ensure c[1] is the falsified watched literal, c[0] is the other watch
                if (c[0] == falsified_lit) std::swap(c[0], c[1]);

                if (val[c[0]] == 1) { // Other watch is already TRUE -> clause satisfied
                    wl[j++] = cid;
                    continue;
                }
                // Try to find a replacement watch in c[2..end] that is not FALSE
                bool moved = false;
                for (std::size_t k = 2; k < c.size(); ++k) {
                    if (val[c[k]] != 0) {
                        std::swap(c[1], c[k]);
                        watches[c[1]].push_back(cid);
                        moved = true;
                        break;
                    }
                }
                if (moved) continue; // Watch moved to c[1]; do NOT keep cid in wl

                // No replacement found: keep watching falsified_lit and unit-propagate c[0]
                wl[j++] = cid;
                if (!enqueue(c[0])) {
                    while (++i < wl.size()) wl[j++] = wl[i];
                    wl.resize(j);
                    return false; // Conflict!
                }
            }
            wl.resize(j);
        }
        return true;
    }

    void backtrack_to(int trail_pos) {
        while (static_cast<int>(trail.size()) > trail_pos) {
            int lit = trail.back();
            val[lit] = -1;
            val[lit ^ 1] = -1;
            trail.pop_back();
        }
        qhead = trail_pos; // Notice: ZERO watch-list updates required on backtrack!
    }

public:
    explicit DpllSatSolver(int n) : num_vars(n), watches(2 * n), val(2 * n, -1) {}

    bool add_clause(std::vector<int> lits) {
        if (lits.empty()) return false;
        if (lits.size() == 1) return enqueue(lits[0]) && propagate();
        int cid = static_cast<int>(clauses.size());
        watches[lits[0]].push_back(cid);
        watches[lits[1]].push_back(cid);
        clauses.push_back(std::move(lits));
        return true;
    }

    bool solve() {
        if (!propagate()) return false;
        // Find first unassigned variable
        int branch_var = -1;
        for (int v = 0; v < num_vars; ++v) {
            if (val[2 * v] == -1) { branch_var = v; break; }
        }
        if (branch_var == -1) return true; // All variables assigned without conflict -> SAT!

        int saved_pos = static_cast<int>(trail.size());
        for (int polarity : {0, 1}) {
            if (enqueue(2 * branch_var ^ polarity) && solve()) return true;
            backtrack_to(saved_pos);
        }
        return false;
    }
};

Production Pitfall — Corrupting the Watch Vector While Iterating Over watches[~l]

Inside propagate(), when a conflict occurs mid-loop while compacting watches[falsified_lit] in-place from index i to index j, if you return false immediately without copying the remaining untouched elements wl[i+1..end] down to wl[j..] and resizing, you will permanently lose watch entries for the rest of the clauses! Always finish compacting the tail of wl before returning on conflict.

Check yourself Question 1 of 4
Concept Check Two-Watched Literals (2WL) Backtrack Complexity recall

In a SAT solver using Chaff's Two-Watched Literals (2WL) scheme, how many watch-list pointer updates are required when backtracking (unassigning $k$ variables on the assignment trail)?

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

Analyze the invariant, asymptotic bound, and hardware behavior before selecting.

Why do industrial CDCL solvers (such as MiniSat and CaDiCaL) store a `blocker` literal directly inside each 8-byte entry `{ clause_id, blocker }` of the `watches[lit]` vector?

Key Takeaways

DPLL combines backtracking search with Boolean Constraint Propagation (BCP): any clause with a single unassigned literal forces that literal to TRUE.
Two-Watched Literals (2WL) watches only 2 non-falsified literals per clause, ensuring watch lists only update when a watched literal becomes FALSE.
Because watched literals remain valid when variables become unassigned, backtracking under 2WL requires zero watch-list modifications (O(1) per variable).
Package managers (Cargo, APT, Conda) encode version dependencies and conflicts as SAT clauses and use unit propagation + conflict clause learning (CDCL/PubGrub).
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue