Dependency Resolver Comparison
Architecture Overview
All four resolvers solve the same problem: given a set of requested packages, figure out which concrete versions to install and in what order. Where they differ is in how they handle conflicts — situations where the first choice turns out to be wrong.
Each subsection below describes the resolver’s strategy and illustrates its conflict-resolution loop.
Portage (Python)
Portage takes the most straightforward approach. It builds a dependency graph by walking every dependency and picking the newest stable candidate for each. If two packages end up claiming the same slot, Portage detects the conflict after the graph is already built.
Its recovery strategy is blunt: mask the conflicting package so it
won’t be picked again, throw away the entire graph, and rebuild it
from scratch. Each retry adds one more mask. The masks accumulate
across retries, but no other information carries over — the graph
starts clean every time. Portage allows up to 20 retries by default
(configurable with --backtrack=N).
{width=40%}
Because each retry rebuilds everything, this approach is the slowest of the four. Complex dependency tangles — like the OCaml Jane Street ecosystem — can require more than a dozen retries before Portage finds a consistent graph.
pkgcore (Python)
pkgcore’s pmerge resolver is also Python, but it does not copy
Portage’s rebuild-with-masks loop. Resolution is a depth-first walk
over an explicit frame stack (resolver_stack /
resolver_frame in pkgcore.resolver.plan): each atom pushes a
frame, tries a choice, and walks that choice’s dependency set.
When a choice fails — inserting it into the plan state fails, or a
dependency under it cannot be satisfied — pkgcore backtracks to the
frame’s checkpoint (state.backtrack(start_point)), advances to the
next remaining package for that atom (force_next_pkg), and continues
inside the same merge_plan. Failed alternatives can also be pruned
from the choice set (reduce_solutions). There is no global mask
list carried into a fresh graph, and no Paludis-style preload that
names the winning candidate for the next full restart.
{width=40%}
Relative to Portage, this is a real improvement: work already done above the failing frame is kept, and only the open choice point is revisited. Relative to Paludis and portage-ng, the guidance is still mostly negative and local — “try the next candidate” — rather than a positively learned domain or a computed “use this package next time.” Deep, blocked search spaces can still explore a large fraction of the choice tree (and historically could blow the Python recursion limit before the frame rewrite moved the stack out of the call stack).
Paludis (C++)
Paludis is smarter about what it remembers. Instead of masking wrong candidates, it identifies the right one. When a new constraint conflicts with an earlier decision, Paludis evaluates all accumulated constraints for that package simultaneously and determines which candidate satisfies them all.
It then records a preload — an instruction that says “use this specific candidate next time.” The resolver is discarded and a fresh one is created, but the preloads travel with it. This means the next attempt starts with positive guidance rather than just a list of things to avoid.
{width=40%}
Because Paludis carries forward the right answer instead of just rejecting the wrong one, it typically needs fewer restarts than Portage. However, each restart still creates a brand-new resolver, so the dependency walk itself is repeated.
portage-ng (SWI-Prolog)
portage-ng avoids the restart-from-scratch pattern altogether. It uses a depth-first proof search: each dependency becomes a proof obligation, and selecting a candidate adds constraints to a global store. Constraint guards monitor the store and fire immediately when a conflict appears.
When a guard fires, three things happen in sequence:
- The conflicting domain is learned — the version set for that package is narrowed to exclude impossible choices.
- The current candidate is rejected so it won’t be tried again.
- Only the affected subtree is retried, with the learned domain already in place to guide candidate selection.
{width=40%}
For the vast majority of packages (over 99%), no conflict arises at all and the proof completes in a single pass. When conflicts do occur, the combination of learned domains (positive guidance) and rejects (negative filtering) resolves them without rebuilding the entire proof tree. This makes portage-ng the fastest of the four resolvers.
Comparison Table
| Aspect | Portage | pkgcore | Paludis | portage-ng |
|---|---|---|---|---|
| Language | Python | Python | C++ | SWI-Prolog |
| Conflict detection | Post-hoc (after graph built) | Incremental (during frame / choice walk) | Incremental (on constraint add) | Incremental (constraint guard) |
| What carries across retries | Masks (negative) | Remaining choices in the frame (negative pruning) | Preloads (positive) | Learned domains (positive) + Rejects (negative) |
| Fresh state each retry? | Yes (new depgraph) | No — backtrack to frame checkpoint | Yes (new Resolver) | Partial (reject set accumulates, learned store accumulates) |
| Finding the right candidate | Brute force (mask+retry) | force_next_pkg after backtrack |
_try_to_find_decision_for with ALL constraints |
Domain narrowing (Zeller) + priority resolution (Vermeir) |
| Performance | Slowest (full rebuild) | Faster than Portage (keeps parent frames) | Fast (targeted restarts) | Fastest (single-pass for most targets) |
| Package-specific code | None | None | None | None |
Slot Allocation: Pigeonhole Reasoning
Gentoo slots turn part of dependency resolution into an allocation
problem: every selected version occupies exactly one hole — its
(package, slot) pair — no hole may host two occupants, and different
holes of the same package may legitimately coexist (gcc:12 next to
gcc:13). pkgcore names this structure literally: its slot tracker
is a class called
PigeonHoledSlots.
Constraint-programming solvers such as the Glasgow Constraint Solver
use the pigeonhole principle as a first-class reasoning device.
All three systems compared below enforce the same invariant, but at
three different strengths: pkgcore detects a collision when it
happens, CP propagators preclude whole families of collisions
before search branches, and portage-ng detects and learns from
each collision. (Portage sits before all three: as described above,
it notices two packages claiming the same slot only after the graph
is fully built, then masks and rebuilds.)
pkgcore: the hole as an occupancy table
PigeonHoledSlots is a mutable registry consulted by merge_plan
during its frame-stack walk. A dictionary keyed by package maps to
the current occupants; fill_slotting(obj) scans for an existing
occupant with the same slot and, on a hit, returns the conflicting
objects instead of inserting. Blockers reuse the same structure as
limiters — anti-pigeons registered per key via add_limiter, which
poison the hole against any matching occupant. On a returned
conflict the resolver backtracks to the frame checkpoint and advances
to the next candidate.
The name is the metaphor, not the mathematical principle. Detection is eager but pairwise: a conflict is noticed only when the second pigeon arrives at the hole. The knowledge gained is negative and local: the colliding objects are reported, the choice list is pruned, and nothing narrows future candidate selection.
Constraint programming: the hole as a counting argument
In CP solvers the pigeonhole principle appears as the propagation
semantics of global constraints such as allDifferent: "these five
jobs have only four time slots between them, so by a pigeonhole
argument the problem is infeasible." Régin's matching-based
propagator (AAAI 1994) and Puget's Hall-interval bounds consistency
detect that k variables collectively reach fewer than k values in
polynomial time, before the search tree branches. This counting
argument is exactly where resolution-based SAT solvers struggle —
pigeonhole formulas require exponential resolution proofs (Haken
1985) — which is why the CP community treats allDifferent
propagation, and the Glasgow Constraint Solver's proof-logging work
certifying it, as a genuinely different reasoning class rather than
an implementation detail.
portage-ng: the hole as a feature dimension
portage-ng has neither an occupancy table nor a cardinality propagator. Slot allocation emerges from three mechanisms of the proof search:
Slots are a dimension of the version domain. Every
version_domain(Slots, Bounds) carries a slot set next to its
version bounds, and domain_meet intersects both dimensions at once
(Chapter 10). Two requirements on the same package whose slot sets
are disjoint meet to slots([]), which is structurally inconsistent
— the proof fails before any candidate is enumerated:
version_domain:meet_slot_domains(slots(S1), slots(S2), slots(S)) :-
ord_intersection(S1, S2, S).
version_domain:domain_inconsistent(version_domain(slots([]), _Bounds)).
This is a small pigeonhole-style cut in the CP spirit — infeasibility derived by set algebra rather than by attempting an insertion — though unary (per package), not a cross-package counting argument.
Occupancy is a constraint guard, not a table. The counterpart of
fill_slotting is the selected_cn(C,N) ordset accumulated in the
constraint store; cnselect:selected_cn_unique_or_reprove/4 enforces
at most one concrete entry per (C,N) — or per (C,N,slot) hole where
multislot coexistence applies — each time feature unification merges
a new selection into the store.
A collision is converted into knowledge. Where pkgcore returns
the colliding objects, the portage-ng guard learns: it stores a
narrowed cn_domain(C,N,Slot) via prover:learn/3, rejects the
conflicting candidate, and re-proves only the affected subtree with
the narrowed domain already applied to candidate selection. If the
conflict survives all retries it is memoized (memo:slot_conflict_/3)
and surfaces as a slot_conflict domain assumption — a negative,
blocking outcome — rather than a silent failure. Blockers get the
same treatment as pkgcore's limiters conceptually, but are tracked as
constraints with source snapshots and degrade to actionable blocker
assumptions.
Why portage-ng skips cardinality propagation
portage-ng does not perform Glasgow-style cross-package counting: a
hypothetical "five packages competing for four holes" is discovered
through the collide–learn–retry loop, not refuted up front by a
matching argument. The Gentoo domain almost never presents that
structure. Slots are scoped per package — gcc:12 and gcc:13 are
holes belonging to sys-devel/gcc alone, never a pool that unrelated
packages compete for — so the allDifferent pattern (many variables
drawing from one shared value set) essentially does not arise. The
hole structure is also ragged: which slot a candidate occupies is
metadata of the chosen version, so the pigeon determines its own
hole; and sub-slot (:=) rebuilds make occupancy dynamic, beyond
static allocation entirely. What the domain actually needs is
per-package unary domains with slot as a feature dimension, pairwise
consistency for slot operators, and good conflict recovery — which is
precisely the narrowing-plus-learning design described above.
| Aspect | pkgcore | CP (Glasgow-style) | portage-ng |
|---|---|---|---|
| Pigeonhole meaning | Occupancy table (metaphor) | Counting principle (Hall sets, matching) | Guard invariant + slot-set algebra |
| Detection moment | Insertion of second occupant | Before branching (propagation) | Constraint merge / guard evaluation |
| Knowledge from a conflict | List of colliding objects | Pruned domains, certified cuts | Learned cn_domain + reject set |
| Recovery | Backtrack, next candidate | Pruned before search (else backjump) | Re-prove subtree with narrowed domain |
| Cross-package counting | No | Yes | No (domain rarely needs it) |
See Chapter 10 for the domain algebra and Chapter 9 for the learning and reprove mechanics used above.
Academic Foundations
Zeller & Snelting: Feature Logic (ESEC 1995, TOSEM 1997)
"Handling Version Sets through Feature Logic" (ESEC 1995, LNCS 989) and its
expanded journal version "Unified Versioning Through Feature Logic" (TOSEM
1997, Vol. 6 No. 4) — version sets are identified by feature terms and
configured by incrementally narrowing the set until each component resolves
to a single version. portage-ng's version_domain with domain_meet
(intersection) is essentially Zeller's feature term narrowing. The learned
constraint store implements Zeller's feature implication propagation:
constraints discovered in one proof attempt propagate to narrow version
sets in the next attempt.
Vermeir & Van Nieuwenborgh: Ordered Logic Programs (JELIA 2002)
"Preferred Answer Sets for Ordered Logic Programs" — when rules conflict,
a partial order determines which yields. portage-ng's find_adjustable_origin
implements this: when a domain is inconsistent (two bounds that can't be
simultaneously satisfied), the bound from the "adjustable" origin (the
package that already has a learned constraint) is dropped, and the origin
is narrowed further.
CDCL / PubGrub / SAT-based approaches
Modern package resolvers (libsolv, Resolvo, PubGrub) encode version constraints as boolean satisfiability problems. portage-ng's approach is different: it uses proof search with domain narrowing rather than SAT encoding. The learned constraint store is analogous to CDCL's learned clauses, but expressed as version domains rather than boolean clauses.
Any-of (||) arm preference
Portage’s dep_zapdeps choice_bins and portage-ng’s
ranking:prioritize_deps_keep_all/3 multi-key sort are compared in
detail in Chapter 12, Any-of (||) arm selection
(including why overlapping-|| DNF, virtual expand, and circular
demotion inside || are not mirrored as ranking keys).