Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions cpp/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ fn main() -> anyhow::Result<()> {
"resolvo_string_clone".into(),
"resolvo_string_from_bytes".into(),
]);
config.export.body.insert(
"Candidates".to_owned(),
r"
Candidates() : candidates{}, favored(nullptr), locked(nullptr), hint_dependencies_available{}, excluded{}, allow_multiple(false) {}
"
.to_owned(),
);
config.export.body.insert(
"Slice".to_owned(),
r"
Expand Down
8 changes: 8 additions & 0 deletions cpp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,11 @@ pub struct Candidates {
/// consider these solvables when forming a solution but will use
/// them in the error message if no solution could be found.
pub excluded: Vector<ExcludedSolvable>,

/// When `true`, the solver allows multiple solvables of this package to
/// appear in the solution simultaneously. By default only one version of
/// each package can be selected.
pub allow_multiple: bool,
}

impl Default for Candidates {
Expand All @@ -378,6 +383,7 @@ impl Default for Candidates {
locked: std::ptr::null(),
hint_dependencies_available: Vector::default(),
excluded: Vector::default(),
allow_multiple: false,
}
}
}
Expand Down Expand Up @@ -599,6 +605,7 @@ impl resolvo::DependencyProvider for &DependencyProvider {
locked: std::ptr::null(),
hint_dependencies_available: Vector::default(),
excluded: Vector::default(),
allow_multiple: false,
};
unsafe { (self.get_candidates)(self.data, name.into(), NonNull::from(&mut candidates)) };

Expand All @@ -619,6 +626,7 @@ impl resolvo::DependencyProvider for &DependencyProvider {
.iter()
.map(|excluded| (excluded.solvable.into(), excluded.reason.into()))
.collect(),
allow_multiple: candidates.allow_multiple,
})
}
}
Expand Down
1 change: 1 addition & 0 deletions examples/sudoku.rs
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ impl DependencyProvider for SudokuProvider {
favored: None,
hint_dependencies_available: HintDependenciesAvailable::All,
excluded: Vec::new(),
allow_multiple: false,
})
}

Expand Down
5 changes: 5 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,10 @@ pub struct Candidates<S = SolvableId> {
/// consider these solvables when forming a solution but will use
/// them in the error message if no solution could be found.
pub excluded: Vec<(S, StringId)>,
/// When `true`, the solver allows multiple solvables of this package to
/// appear in the solution simultaneously. By default only one version of
/// each package can be selected.
pub allow_multiple: bool,
}

impl<S> Default for Candidates<S> {
Expand All @@ -210,6 +214,7 @@ impl<S> Default for Candidates<S> {
locked: None,
hint_dependencies_available: HintDependenciesAvailable::None,
excluded: Vec::new(),
allow_multiple: false,
}
}
}
Expand Down
1 change: 1 addition & 0 deletions src/snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,7 @@ impl DependencyProvider for SnapshotProvider<'_> {
.filter(|&s| self.solvable(s).hint_dependencies_available)
.collect(),
),
allow_multiple: false,
})
}

Expand Down
8 changes: 8 additions & 0 deletions src/solver/encoding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,10 @@ impl<'a, 'cache, D: DependencyProvider> Encoder<'a, 'cache, D> {
self.cache.provider().display_name(name_id)
);

if package_candidates.allow_multiple {
self.state.allow_multiple_names.insert(name_id);
}

// If there is a locked solvable, forbid all other candidates
if let Some(locked_solvable_id) = package_candidates.locked {
self.add_locked_package_clauses(locked_solvable_id, &package_candidates.candidates);
Expand Down Expand Up @@ -1070,7 +1074,11 @@ impl<'a, 'cache, D: DependencyProvider> Encoder<'a, 'cache, D> {
/// Record `variable` as a candidate for a forbid-multiple clause under
/// `name_id`. Returns silently if the variable has already been registered;
/// see [`Self::forbid_seen`] for why globally-deduplicating is safe.
/// Also skips registration for packages that allow multiple versions.
fn register_forbid_target(&mut self, name_id: D::NameId, variable: VariableId) {
if self.state.allow_multiple_names.contains(name_id) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It likely doesn't matter much but you could probably give the branch predictor a hint here that this path is unlikely to be taken.

return;
}
if self.forbid_seen.insert(variable) {
self.pending_forbid_clauses
.entry(name_id)
Expand Down
2 changes: 2 additions & 0 deletions src/solver/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ pub(crate) struct SolverState<D: DependencyProvider> {

clauses_added_for_package: <D::NameId as SolverId>::Set,
clauses_added_for_solvable: WithRootSet<D::SolvableId>,
pub(crate) allow_multiple_names: <D::NameId as SolverId>::Set,
at_most_one_trackers: HashMap<D::NameId, AtMostOnceTracker<VariableId>>,

/// Keeps track of auxiliary variables that are used to encode at-least-one
Expand Down Expand Up @@ -290,6 +291,7 @@ impl<D: DependencyProvider> Default for SolverState<D> {
disjunctions: Default::default(),
clauses_added_for_package: Default::default(),
clauses_added_for_solvable: Default::default(),
allow_multiple_names: Default::default(),
at_most_one_trackers: Default::default(),
at_least_one_tracker: Default::default(),
constrains_aux_vars: Default::default(),
Expand Down
6 changes: 6 additions & 0 deletions tests/solver/bundle_box/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ pub struct BundleBoxProvider {
favored: HashMap<String, Pack>,
locked: HashMap<String, Pack>,
excluded: HashMap<String, HashMap<Pack, String>>,
allow_multiple: HashSet<String>,
cancel_solving: Cell<bool>,
// TODO: simplify?
concurrent_requests: Arc<AtomicUsize>,
Expand Down Expand Up @@ -205,6 +206,10 @@ impl BundleBoxProvider {
.insert(Pack::new(version), reason.into());
}

pub fn set_allow_multiple(&mut self, package_name: &str) {
self.allow_multiple.insert(package_name.to_owned());
}

pub fn set_locked(&mut self, package_name: &str, version: u32) {
self.locked
.insert(package_name.to_owned(), Pack::new(version));
Expand Down Expand Up @@ -418,6 +423,7 @@ impl DependencyProvider for BundleBoxProvider {
} else {
HintDependenciesAvailable::None
},
allow_multiple: self.allow_multiple.contains(package_name),
..Candidates::default()
};
let favor = self.favored.get(package_name);
Expand Down
140 changes: 140 additions & 0 deletions tests/solver/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2379,3 +2379,143 @@ fn shared_requires_gate_survives_backtrack() {
}
}
}

mod allow_multiple_versions {
use super::*;

/// Non-overlapping version requirements are unsolvable without `allow_multiple`.
#[test]
fn test_non_overlapping_requirements_unsat_without_allow_multiple() {
let mut provider = BundleBoxProvider::new();
provider.add_package("kernel", 1.into(), &[], &[]);
provider.add_package("kernel", 2.into(), &[], &[]);
provider.add_package("kernel", 3.into(), &[], &[]);
provider.add_package("kmod-a", 1.into(), &["kernel 1..2"], &[]);
provider.add_package("kmod-b", 1.into(), &["kernel 3..4"], &[]);

let result = solve_unsat(provider, &["kmod-a", "kmod-b"]);
assert_snapshot!(result);
}

/// When `allow_multiple` is set, non-overlapping version requirements
/// from different dependents are solved by installing multiple versions.
#[test]
fn test_non_overlapping_requirements() {
let mut provider = BundleBoxProvider::new();
provider.add_package("kernel", 1.into(), &[], &[]);
provider.add_package("kernel", 2.into(), &[], &[]);
provider.add_package("kernel", 3.into(), &[], &[]);
provider.set_allow_multiple("kernel");
provider.add_package("kmod-a", 1.into(), &["kernel 1..2"], &[]);
provider.add_package("kmod-b", 1.into(), &["kernel 3..4"], &[]);

let requirements = provider.requirements(&["kmod-a", "kmod-b"]);
let mut solver = Solver::new(provider);
let problem = Problem::new().requirements(requirements);
let solved = solver.solve(problem).unwrap();
let result = transaction_to_string(solver.provider(), &solved);
assert_snapshot!(result, @r"
kernel=1
kernel=3
kmod-a=1
kmod-b=1
");
}

/// When dependents' version requirements overlap, each requirement independently
/// selects its best (highest) candidate. Here kmod-a picks kernel=2 (highest in 1..3)
/// and kmod-b picks kernel=3 (highest in 2..4), so both are installed even though
/// kernel=2 alone would satisfy both requirements.
///
/// This is actually NOT ideal - installing only kernel=2 would be optimal - but resolving
/// overlapping ranges to a single common version would require additional optimization in
/// the solver (e.g. soft forbid clauses or a post-solve minimization pass).

@dralley dralley Jun 15, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Notable. I'm not particularly fussed about this at the moment but it is probably worth doing eventually.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unless you want me to do it now.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@baszalmstra Let me know your thoughts.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@baszalmstra Can I at least get feedback on whether I need to deal with this now or if it can be deferred until later? I'd like to get this PR out of the way so that I can rebase the other work

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah sorry, this slipped of my radar. I think we can deal with the optimal solution later.

#[test]
fn test_overlapping_requirements() {
let mut provider = BundleBoxProvider::new();
provider.add_package("kernel", 1.into(), &[], &[]);
provider.add_package("kernel", 2.into(), &[], &[]);
provider.add_package("kernel", 3.into(), &[], &[]);
provider.set_allow_multiple("kernel");
provider.add_package("kmod-a", 1.into(), &["kernel 1..3"], &[]);
provider.add_package("kmod-b", 1.into(), &["kernel 2..4"], &[]);

let requirements = provider.requirements(&["kmod-a", "kmod-b"]);
let mut solver = Solver::new(provider);
let problem = Problem::new().requirements(requirements);
let solved = solver.solve(problem).unwrap();
let result = transaction_to_string(solver.provider(), &solved);
assert_snapshot!(result, @r"
kernel=2
kernel=3
kmod-a=1
kmod-b=1
");
}

/// `allow_multiple` works correctly when the multiversion package is required by a
/// transitive dependency discovered in a later solver pass (i.e. a different
/// Encoder invocation than the one that first processed the package's candidates).
/// Uses two `allow_multiple` packages with version-pinned dependencies between them:
/// each kmod version is built against a specific kernel, so installing multiple kmod
/// versions transitively pulls in multiple kernels.
///
/// The explanation may sound a bit tormented, but it exists to catch a regression
/// encountered with the original implementation.
#[test]
fn test_allow_multiple_transitive() {
let mut provider = BundleBoxProvider::new();
provider.add_package("kernel", 1.into(), &[], &[]);
provider.add_package("kernel", 2.into(), &[], &[]);
provider.add_package("kernel", 3.into(), &[], &[]);
provider.set_allow_multiple("kernel");
provider.add_package("kmod", 1.into(), &["kernel 1..2"], &[]);
provider.add_package("kmod", 2.into(), &["kernel 2..3"], &[]);
provider.add_package("kmod", 3.into(), &["kernel 3..4"], &[]);
provider.set_allow_multiple("kmod");
provider.add_package("app-old", 1.into(), &["kmod 1..2"], &[]);
provider.add_package("app-new", 1.into(), &["kmod 3..4"], &[]);

let requirements = provider.requirements(&["app-old", "app-new"]);
let mut solver = Solver::new(provider);
let problem = Problem::new().requirements(requirements);
let solved = solver.solve(problem).unwrap();
let result = transaction_to_string(solver.provider(), &solved);
assert_snapshot!(result, @r"
app-new=1
app-old=1
kernel=1
kernel=3
kmod=1
kmod=3
");
}

/// Soft requirements model the "already installed" scenario: the solver tries to keep
/// each requested version, but when one is excluded the solve still succeeds with the
/// multiple remaining versions.
#[test]
fn test_soft_requirements_with_allow_multiple() {
let mut provider = BundleBoxProvider::new();
provider.add_package("kernel", 1.into(), &[], &[]);
provider.add_package("kernel", 2.into(), &[], &[]);
provider.add_package("kernel", 3.into(), &[], &[]);
provider.set_allow_multiple("kernel");
provider.exclude("kernel", 1, "retracted due to security issue");

let k1 = provider.solvable_id("kernel", 1u32);
let k2 = provider.solvable_id("kernel", 2u32);
let k3 = provider.solvable_id("kernel", 3u32);
let requirements = provider.requirements(&["kernel"]);
let mut solver = Solver::new(provider);
let problem = Problem::new()
.requirements(requirements)
.soft_requirements(vec![k1, k2, k3]);
let solved = solver.solve(problem).unwrap();
let result = transaction_to_string(solver.provider(), &solved);
assert_snapshot!(result, @r"
kernel=2
kernel=3
");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
source: tests/solver/main.rs
expression: result
---
The following packages are incompatible
├─ kmod-a * can be installed with any of the following options:
│ └─ kmod-a 1 would require
│ └─ kernel >=1, <2, which can be installed with any of the following options:
│ └─ kernel 1
└─ kmod-b * cannot be installed because there are no viable options:
└─ kmod-b 1 would require
└─ kernel >=3, <4, which cannot be installed because there are no viable options:
└─ kernel 3, which conflicts with the versions reported above.