From 256e1140d0a5617e0cc49020185f0750a954d690 Mon Sep 17 00:00:00 2001 From: Daniel Alley Date: Fri, 5 Jun 2026 14:13:14 -0400 Subject: [PATCH] Add allow_multiple flag to support multiversion packages Add `allow_multiple: bool` to `Candidates`, allowing dependency providers to indicate that multiple versions of a package may coexist in the solution (e.g. kernel packages on RPM systems). When set, the solver skips the at-most-one constraint encoding for that package name. Co-Authored-By: Claude Opus 4.6 --- cpp/build.rs | 7 + cpp/src/lib.rs | 8 + examples/sudoku.rs | 1 + src/lib.rs | 5 + src/snapshot.rs | 1 + src/solver/encoding.rs | 8 + src/solver/mod.rs | 2 + tests/solver/bundle_box/mod.rs | 6 + tests/solver/main.rs | 140 ++++++++++++++++++ ...irements_unsat_without_allow_multiple.snap | 13 ++ 10 files changed, 191 insertions(+) create mode 100644 tests/solver/snapshots/solver__allow_multiple_versions__non_overlapping_requirements_unsat_without_allow_multiple.snap diff --git a/cpp/build.rs b/cpp/build.rs index c545bc7f..fb1c6a08 100644 --- a/cpp/build.rs +++ b/cpp/build.rs @@ -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" diff --git a/cpp/src/lib.rs b/cpp/src/lib.rs index 46565ada..f9551049 100644 --- a/cpp/src/lib.rs +++ b/cpp/src/lib.rs @@ -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, + + /// 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 { @@ -378,6 +383,7 @@ impl Default for Candidates { locked: std::ptr::null(), hint_dependencies_available: Vector::default(), excluded: Vector::default(), + allow_multiple: false, } } } @@ -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)) }; @@ -619,6 +626,7 @@ impl resolvo::DependencyProvider for &DependencyProvider { .iter() .map(|excluded| (excluded.solvable.into(), excluded.reason.into())) .collect(), + allow_multiple: candidates.allow_multiple, }) } } diff --git a/examples/sudoku.rs b/examples/sudoku.rs index 818b4fde..100bcef1 100644 --- a/examples/sudoku.rs +++ b/examples/sudoku.rs @@ -260,6 +260,7 @@ impl DependencyProvider for SudokuProvider { favored: None, hint_dependencies_available: HintDependenciesAvailable::All, excluded: Vec::new(), + allow_multiple: false, }) } diff --git a/src/lib.rs b/src/lib.rs index 8a0552b6..fbda8fab 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -200,6 +200,10 @@ 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: 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 Default for Candidates { @@ -210,6 +214,7 @@ impl Default for Candidates { locked: None, hint_dependencies_available: HintDependenciesAvailable::None, excluded: Vec::new(), + allow_multiple: false, } } } diff --git a/src/snapshot.rs b/src/snapshot.rs index ff3ecf0c..43627d0b 100644 --- a/src/snapshot.rs +++ b/src/snapshot.rs @@ -539,6 +539,7 @@ impl DependencyProvider for SnapshotProvider<'_> { .filter(|&s| self.solvable(s).hint_dependencies_available) .collect(), ), + allow_multiple: false, }) } diff --git a/src/solver/encoding.rs b/src/solver/encoding.rs index 78988c07..2c774db6 100644 --- a/src/solver/encoding.rs +++ b/src/solver/encoding.rs @@ -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); @@ -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) { + return; + } if self.forbid_seen.insert(variable) { self.pending_forbid_clauses .entry(name_id) diff --git a/src/solver/mod.rs b/src/solver/mod.rs index 51d4c42b..72fb440c 100644 --- a/src/solver/mod.rs +++ b/src/solver/mod.rs @@ -217,6 +217,7 @@ pub(crate) struct SolverState { clauses_added_for_package: ::Set, clauses_added_for_solvable: WithRootSet, + pub(crate) allow_multiple_names: ::Set, at_most_one_trackers: HashMap>, /// Keeps track of auxiliary variables that are used to encode at-least-one @@ -290,6 +291,7 @@ impl Default for SolverState { 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(), diff --git a/tests/solver/bundle_box/mod.rs b/tests/solver/bundle_box/mod.rs index 857379a3..2bb1e8f1 100644 --- a/tests/solver/bundle_box/mod.rs +++ b/tests/solver/bundle_box/mod.rs @@ -84,6 +84,7 @@ pub struct BundleBoxProvider { favored: HashMap, locked: HashMap, excluded: HashMap>, + allow_multiple: HashSet, cancel_solving: Cell, // TODO: simplify? concurrent_requests: Arc, @@ -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)); @@ -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); diff --git a/tests/solver/main.rs b/tests/solver/main.rs index cdb95c3c..7dae87c7 100644 --- a/tests/solver/main.rs +++ b/tests/solver/main.rs @@ -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). + #[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 + "); + } +} diff --git a/tests/solver/snapshots/solver__allow_multiple_versions__non_overlapping_requirements_unsat_without_allow_multiple.snap b/tests/solver/snapshots/solver__allow_multiple_versions__non_overlapping_requirements_unsat_without_allow_multiple.snap new file mode 100644 index 00000000..a8564a66 --- /dev/null +++ b/tests/solver/snapshots/solver__allow_multiple_versions__non_overlapping_requirements_unsat_without_allow_multiple.snap @@ -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.