Strategical decision making in a DependencyProvider

In PubGrub, decision making is the process of choosing the next package and version that will be appended to the solution being built. Every time such a decision must be made, potential valid packages are preselected with corresponding valid ranges of versions. Then, there is some freedom regarding which of those package versions to choose.

The strategy employed to choose such package and version cannot change the existence of a solution or not, but can drastically change the performances of the solver, or the properties of the solution. The documentation of Pub (Dart's package manager that uses PubGrub under the hood) states the following:

Pub chooses the latest matching version of the package with the fewest versions that match the outstanding constraint. This tends to find conflicts earlier if any exist, since these packages will run out of versions to try more quickly. But there's likely room for improvement in these heuristics.

In our implementation of PubGrub, decision making responsibility is divided into two pieces. The resolver takes care of making a preselection for potential packages and corresponding ranges of versions. Then it's the dependency provider that has the freedom of employing the strategy of picking a single package version within the choose_package_version method.


#![allow(unused)]
fn main() {
fn choose_package_version<T: Borrow<P>, U: Borrow<Range<V>>>(
    &self,
    potential_packages: impl Iterator<Item = (T, U)>,
) -> Result<(T, Option<V>), Box<dyn Error>>;
}

Picking a package

Potential packages basically are packages that appeared in the dependencies of a package we've already added to the solution. So once a package appear in potential packages, it will continue to be proposed as such until we pick it, or a conflict shows up and the solution is backtracked before needing it.

Imagine that one such potential package is limited to a range containing no existing version, we are heading directly to a conflict! So we have better dealing with that conflict as soon as possible, instead of delaying it for later since we will have to backtrack anyway. Consequently, we always want to pick first a conflictual potential package with no valid version. Similarly, potential packages with only one valid version give us no choice and limit options going forward, so we might want to pick such potential packages before others with more version choices. Generalizing this strategy to picking the potential package with the lowest number of valid versions is a rather good heuristic performance-wise.

This strategy is the one employed by the OfflineDependencyProvider. For convenience, we also provide a helper function choose_package_with_fewest_versions directly embedding this strategy. It can be used directly in choose_package_version if provided a helper function to retrieve existing versions of a package list_available_versions: Fn(&P) -> Iterator<Item = V>.

Picking a version

By default, the version returned by the helper function choose_package_with_fewest_versions is the first compatible one in the iterator returned by list_available_versions for the chosen package. So you can order the iterator with preferred versions first and they will be picked by the solver. This is very convenient to easily switch between a dependency provider that picks the most recent compatible packages and one that chooses instead the oldest compatible versions. Such behavior may be desirable for checking that dependencies lower bounds still pass the code tests for example.

In general, letting the dependency provider choose a version in choose_package_version provides a great deal of flexibility and enables things like

  • choosing the newest versions,
  • choosing the oldest versions,
  • choosing already downloaded versions,
  • choosing versions specified in a lock file,

and many other desirable behaviors for the resolver, controlled directly by the dependency provider.