Skip to content
Draft
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
108 changes: 90 additions & 18 deletions 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 @@ -70,6 +74,7 @@ async fn get_requirement_candidates<D: DependencyProvider>(
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 @@ -192,13 +197,15 @@ 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,
future_queue_mode: FutureQueueMode,
) -> Self {
Self {
state,
cache,
config,
root_dependencies,
pending_futures: FuturesUnordered::new(),
conflicting_clauses: Vec::new(),
Expand Down Expand Up @@ -693,30 +700,33 @@ impl<'a, 'cache, D: DependencyProvider> Encoder<'a, 'cache, D> {

let variable = self.state.variable_map.intern_solvable_or_root(solvable_id);

// Whether the constraining solvable is itself one of the forbidden
// (non-matching) candidates: i.e. it conflicts with a capability it
// also provides (a self-conflict).
let self_conflict = candidates
.iter()
.any(|&c| SolvableIdOrRoot::from(c) == solvable_id);

if candidates.len() < CONSTRAINS_AUX_ENCODING_THRESHOLD {
// Pairwise encoding: one (¬parent ∨ ¬candidate) clause per
// excluded candidate.
for &forbidden_candidate in candidates {
let forbidden_candidate_var =
self.state.variable_map.intern_solvable(forbidden_candidate);
let (watched_literals, conflict, kind) = WatchedLiterals::constrains(
variable,
forbidden_candidate_var,
constraint,
&self.state.decision_tracker,
);

let clause_id = self.state.add_clause(watched_literals, kind);

if conflict {
self.conflicting_clauses.push(clause_id);
}
// excluded candidate, skipping the parent itself.
self.add_constrains_pairwise_for_parent(variable, solvable_id, constraint, candidates);
if self_conflict && self.config.forbid_self_conflicts {
self.add_self_conflict_clause(variable, constraint);
}
return;
}

// Shared encoding: the (¬candidate ∨ aux) clauses are emitted once per
// version set, each parent only adds a single (¬parent ∨ ¬aux) clause.
// version set, each non-self parent only adds a single (¬parent ∨ ¬aux)
// clause.
//
// The aux variable means "some forbidden candidate of this constraint
// is installed" and MUST be defined over *all* candidates, so its
// meaning does not depend on which parent happens to build it. Skipping
// a candidate here (e.g. a self-conflicting parent) would both leave
// that candidate un-forbidden for every *other* parent and make every
// other self-conflicting provider that reuses the aux uninstallable.
let aux_variable = match self.state.constrains_aux_vars.get(&constraint) {
Some(&aux_variable) => aux_variable,
None => {
Expand Down Expand Up @@ -761,6 +771,25 @@ impl<'a, 'cache, D: DependencyProvider> Encoder<'a, 'cache, D> {
}
};

// A self-conflicting parent is itself part of the aux definition
// (parent -> aux), so the shared (parent -> ¬aux) clause would make it
// uninstallable. Encode such a parent pairwise instead (forbidding the
// other providers but not itself), or, if self-conflicts are forbidden,
// mark it uninstallable directly.
if self_conflict {
if self.config.forbid_self_conflicts {
self.add_self_conflict_clause(variable, constraint);
} else {
self.add_constrains_pairwise_for_parent(
variable,
solvable_id,
constraint,
candidates,
);
}
return;
}

let (watched_literals, conflict, kind) = WatchedLiterals::constrains_parent(
variable,
aux_variable,
Expand All @@ -773,6 +802,36 @@ impl<'a, 'cache, D: DependencyProvider> Encoder<'a, 'cache, D> {
}
}

/// Emits the pairwise constrains clauses `(¬parent ∨ ¬candidate)` for a
/// single parent, skipping the parent itself (a self-conflict).
fn add_constrains_pairwise_for_parent(
&mut self,
variable: VariableId,
solvable_id: SolvableIdOrRoot<D::SolvableId>,
constraint: VersionSetId,
candidates: &[D::SolvableId],
) {
for &forbidden_candidate in candidates {
if SolvableIdOrRoot::from(forbidden_candidate) == solvable_id {
continue;
}
let forbidden_candidate_var =
self.state.variable_map.intern_solvable(forbidden_candidate);
let (watched_literals, conflict, kind) = WatchedLiterals::constrains(
variable,
forbidden_candidate_var,
constraint,
&self.state.decision_tracker,
);

let clause_id = self.state.add_clause(watched_literals, kind);

if conflict {
self.conflicting_clauses.push(clause_id);
}
}
}

/// Adds clauses to forbid any other clauses than the locked solvable to be
/// installed.
fn add_locked_package_clauses(
Expand Down Expand Up @@ -819,6 +878,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
31 changes: 31 additions & 0 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 @@ -191,6 +208,9 @@ pub struct Solver<D: DependencyProvider, RT: AsyncRuntime = NowOrNeverRuntime> {
/// runtime remains pending-capable without extending the public runtime contract.
future_queue_mode: FutureQueueMode,

/// 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 @@ -372,11 +392,13 @@ 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(),
future_queue_mode: FutureQueueMode::Immediate,
config,
activity_add: 1.0,
activity_decay: 0.95,
}
Expand Down Expand Up @@ -454,6 +476,7 @@ impl<D: DependencyProvider, RT: AsyncRuntime> Solver<D, RT> {
cache: self.cache,
state: self.state,
future_queue_mode: FutureQueueMode::PendingCapable,
config: self.config,
activity_decay: self.activity_decay,
activity_add: self.activity_add,
}
Expand All @@ -470,6 +493,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 @@ -643,6 +672,7 @@ impl<D: DependencyProvider, RT: AsyncRuntime> Solver<D, RT> {
Encoder::new(
&mut self.state,
&self.cache,
&self.config,
root_deps,
level,
self.future_queue_mode,
Expand Down Expand Up @@ -804,6 +834,7 @@ impl<D: DependencyProvider, RT: AsyncRuntime> Solver<D, RT> {
Encoder::new(
&mut self.state,
&self.cache,
&self.config,
root_deps,
level,
self.future_queue_mode,
Expand Down
Loading