Skip to content
Open
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
13 changes: 12 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ pub use id::{
};
use itertools::Itertools;
pub use requirement::Requirement;
pub use solver::{EmptySolvables, Problem, Solver, SolverCache, UnsolvableOrCancelled};
pub use solver::{
EmptySolvables, Problem, Solver, SolverCache, SolverConfig, UnsolvableOrCancelled,
};
pub use solver_id::{DenseId, IdMap, IdSet, SolverId, SparseId};
pub use utils::{IndexedSet, Mapping, MappingIter};

Expand Down Expand Up @@ -158,6 +160,15 @@ pub trait DependencyProvider: Sized + Interner {
fn should_cancel_with_value(&self) -> Option<Box<dyn Any>> {
None
}

/// Returns the solver configuration for this dependency provider.
///
/// Override this to customize solver behavior. The returned config is used
/// by [`Solver::new`] unless explicitly overridden with
/// [`Solver::with_config`].
fn solver_config(&self) -> SolverConfig {
SolverConfig::default()
}
}

/// A list of candidate solvables for a specific package. This is returned from
Expand Down
34 changes: 33 additions & 1 deletion src/solver/encoding.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
use std::{any::Any, collections::VecDeque};

use super::{SolverState, clause::WatchedLiterals, conditions};
use super::{
SolverConfig, SolverState,
clause::{Clause, WatchedLiterals},
conditions,
};
use crate::{
Candidates, ConditionId, ConditionalRequirement, DenseIndex, Dependencies, DependencyProvider,
Requirement, SolverCache, StringId, VariableId, VersionSetId,
Expand Down Expand Up @@ -35,6 +39,7 @@ type RequirementCondition<'a, S> = Option<(ConditionId, Vec<Vec<DisjunctionCompl
pub(crate) struct Encoder<'a, 'cache, D: DependencyProvider> {
state: &'a mut SolverState<D>,
cache: &'cache SolverCache<D>,
config: &'a SolverConfig,
level: u32,

/// The dependencies of the root solvable.
Expand Down Expand Up @@ -156,12 +161,14 @@ impl<'a, 'cache, D: DependencyProvider> Encoder<'a, 'cache, D> {
pub fn new(
state: &'a mut SolverState<D>,
cache: &'cache SolverCache<D>,
config: &'a SolverConfig,
root_dependencies: &'cache Dependencies,
level: u32,
) -> Self {
Self {
state,
cache,
config,
root_dependencies,
pending_futures: FuturesUnordered::new(),
conflicting_clauses: Vec::new(),
Expand Down Expand Up @@ -643,6 +650,12 @@ impl<'a, 'cache, D: DependencyProvider> Encoder<'a, 'cache, D> {
// Pairwise encoding: one (¬parent ∨ ¬candidate) clause per
// excluded candidate.
for &forbidden_candidate in candidates {
if SolvableIdOrRoot::from(forbidden_candidate) == solvable_id {
if self.config.forbid_self_conflicts {
self.add_self_conflict_clause(variable, constraint);
}
continue;
}
let forbidden_candidate_var =
self.state.variable_map.intern_solvable(forbidden_candidate);
let (watched_literals, conflict, kind) = WatchedLiterals::constrains(
Expand Down Expand Up @@ -675,6 +688,12 @@ impl<'a, 'cache, D: DependencyProvider> Encoder<'a, 'cache, D> {
.insert(constraint, aux_variable);

for &forbidden_candidate in candidates {
if SolvableIdOrRoot::from(forbidden_candidate) == solvable_id {
if self.config.forbid_self_conflicts {
self.add_self_conflict_clause(variable, constraint);
}
continue;
}
let forbidden_candidate_var =
self.state.variable_map.intern_solvable(forbidden_candidate);
let (watched_literals, kind) = WatchedLiterals::constrains_excluded(
Expand Down Expand Up @@ -765,6 +784,19 @@ impl<'a, 'cache, D: DependencyProvider> Encoder<'a, 'cache, D> {
variable
}

/// Adds a unary constrains clause that forbids a solvable that conflicts
/// with something it also provides (a self-conflict).
fn add_self_conflict_clause(&mut self, variable: VariableId, constraint: VersionSetId) {
let kind = Clause::Constrains(variable, variable, constraint);
let clause_id = self.state.add_clause(None, kind);

self.state.negative_assertions.push((variable, clause_id));

if self.state.decision_tracker.assigned_value(variable) == Some(true) {
self.conflicting_clauses.push(clause_id);
}
}

/// Enqueues retrieving the dependencies for a solvable.
///
/// This method requests the dependencies for the given solvable in an
Expand Down
33 changes: 31 additions & 2 deletions src/solver/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,23 @@ impl<N> Clauses<N> {

type RequirementCandidateVariables = Vec<Vec<VariableId>>;

/// Configuration options for the solver.
#[derive(Debug, Clone)]
pub struct SolverConfig {
/// When `true`, a package that conflicts with something it also provides
/// (i.e. it conflicts with itself) is marked uninstallable. When `false`,
/// self-conflicts are silently ignored.
pub forbid_self_conflicts: bool,
}

@dralley dralley Jun 27, 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.

@baszalmstra Do you think this is reasonable?

Libsolv offers a couple such options (e.g. 1, 2), but I know your intention is to stay as generic as possible.

I also have a Candidates based implementation, but I don't think it actually ends up simpler and is probably less performant, since it is treated as a a global option so if you were to emulate it with a map per-nameid or per-solvable then it would require a lot of hashmap insertions and subsequent lookups where the answer will always be the same. The additional flexibility isn't needed, at least not by RPM.


impl Default for SolverConfig {
fn default() -> Self {
Self {
forbid_self_conflicts: true,
}
}
}

/// Drives the SAT solving process.
pub struct Solver<D: DependencyProvider, RT: AsyncRuntime = NowOrNeverRuntime> {
/// The runtime to use for async operations.
Expand All @@ -187,6 +204,9 @@ pub struct Solver<D: DependencyProvider, RT: AsyncRuntime = NowOrNeverRuntime> {
/// Holds the current state of the solver.
pub(crate) state: SolverState<D>,

/// Solver configuration options.
config: SolverConfig,

/// The activity add factor. This is a value that is added to the activity
/// score of each package that is part of a conflict.
activity_add: f32,
Expand Down Expand Up @@ -368,10 +388,12 @@ impl<D: DependencyProvider> Solver<D, NowOrNeverRuntime> {
/// Creates a single threaded block solver, using the provided
/// [`DependencyProvider`].
pub fn new(provider: D) -> Self {
let config = provider.solver_config();

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.

Uses the config provided by DependencyProvider by default, which implementors can optionally define (has default impl) but can be overridden manually on the solver (makes testing easier)

Self {
cache: SolverCache::new(provider),
async_runtime: NowOrNeverRuntime,
state: SolverState::default(),
config,
activity_add: 1.0,
activity_decay: 0.95,
}
Expand Down Expand Up @@ -448,6 +470,7 @@ impl<D: DependencyProvider, RT: AsyncRuntime> Solver<D, RT> {
async_runtime: runtime,
cache: self.cache,
state: self.state,
config: self.config,
activity_decay: self.activity_decay,
activity_add: self.activity_add,
}
Expand All @@ -464,6 +487,12 @@ impl<D: DependencyProvider, RT: AsyncRuntime> Solver<D, RT> {
}
}

/// Set the solver configuration.
#[must_use]
pub fn with_config(self, config: SolverConfig) -> Self {
Self { config, ..self }
}

/// Solves the given [`Problem`].
///
/// The solver first solves for the root requirements and constraints, and
Expand Down Expand Up @@ -634,7 +663,7 @@ impl<D: DependencyProvider, RT: AsyncRuntime> Solver<D, RT> {
#[cfg(feature = "diagnostics")]
let encoding_start = std::time::Instant::now();
let conflicting_clauses = self.async_runtime.block_on(
Encoder::new(&mut self.state, &self.cache, root_deps, level)
Encoder::new(&mut self.state, &self.cache, &self.config, root_deps, level)
.encode([root_solvable]),
)?;
#[cfg(feature = "diagnostics")]
Expand Down Expand Up @@ -789,7 +818,7 @@ impl<D: DependencyProvider, RT: AsyncRuntime> Solver<D, RT> {
#[cfg(feature = "diagnostics")]
let encoding_start = std::time::Instant::now();
let conflicting_clauses = self.async_runtime.block_on(
Encoder::new(&mut self.state, &self.cache, root_deps, level)
Encoder::new(&mut self.state, &self.cache, &self.config, root_deps, level)
.encode_with_deferred(solvable_ids.iter().copied(), deferred_to_encode),
)?;
#[cfg(feature = "diagnostics")]
Expand Down
73 changes: 72 additions & 1 deletion tests/solver/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use insta::assert_snapshot;
use itertools::Itertools;
use resolvo::{
ConditionalRequirement, DependencyProvider, Interner, Problem, SolvableId, Solver,
UnsolvableOrCancelled, VersionSetId,
SolverConfig, UnsolvableOrCancelled, VersionSetId,
};
use tracing_test::traced_test;

Expand Down Expand Up @@ -2109,6 +2109,77 @@ fn test_constrains_multiple_parents() {
x=1
"###);
}
mod test_self_conflict {
use super::*;

/// When `forbid_self_conflicts` is false, a package that constrains itself
/// is silently allowed. Some ecosystems (e.g. RPM) explicitly support this.
/// The real-world examples are structured a bit differently however.
#[test]
fn test_self_conflict_allowed() {
let mut provider = BundleBoxProvider::new();
// a=1 constrains "a" to [2,100) — version 1 is NOT in that range,
// so a=1 appears as a non-matching candidate for its own constraint
// (i.e. a self-conflict).
provider.add_package("a", 1.into(), &[], &["a 2..100"]);

let requirements = provider.requirements(&["a"]);
let config = SolverConfig {
forbid_self_conflicts: false,
};
let mut solver = Solver::new(provider).with_config(config);
let problem = Problem::new().requirements(requirements);
let solved = solver.solve(problem).unwrap();
let result = transaction_to_string(solver.provider(), &solved);
assert_snapshot!(result, @r"
a=1
");
}

/// When `forbid_self_conflicts` is true (the default), a package that
/// constrains itself is marked uninstallable.
#[test]
fn test_self_conflict_forbidden() {
let mut provider = BundleBoxProvider::new();
provider.add_package("a", 1.into(), &[], &["a 2..100"]);

let requirements = provider.requirements(&["a"]);
let mut solver = Solver::new(provider);
let problem = Problem::new().requirements(requirements);
match solver.solve(problem) {
Ok(_) => panic!("expected unsat due to self-conflict"),
Err(UnsolvableOrCancelled::Unsolvable(_)) => {}
Err(UnsolvableOrCancelled::Cancelled(_)) => {
panic!("expected unsolvable, not cancelled")
}
}
}

/// `allow_self_conflicts` works correctly when the self-conflicting
/// package is a transitive dependency discovered in a later solver pass.
#[test]
fn test_self_conflict_allowed_transitive() {
let mut provider = BundleBoxProvider::new();
// "a" has a self-conflict and is a transitive dep of "app" via "lib".
provider.add_package("a", 1.into(), &[], &["a 2..100"]);
provider.add_package("lib", 1.into(), &["a"], &[]);
provider.add_package("app", 1.into(), &["lib"], &[]);

let requirements = provider.requirements(&["app"]);
let config = SolverConfig {
forbid_self_conflicts: false,
};
let mut solver = Solver::new(provider).with_config(config);
let problem = Problem::new().requirements(requirements);
let solved = solver.solve(problem).unwrap();
let result = transaction_to_string(solver.provider(), &solved);
assert_snapshot!(result, @r"
a=1
app=1
lib=1
");
}
}

// ============================================================================
// Decide-queue wake-up scenarios
Expand Down
Loading