Skip to content
Closed
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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ exclude = [
]

[tool.ruff.lint.mccabe]
max-complexity = 12
max-complexity = 15

[tool.mypy]
warn_unused_configs = true
Expand Down
78 changes: 70 additions & 8 deletions src/resolvelib/resolvers/resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
ResolverException,
)

OPTIMISTIC_BACKJUMPING_RATIO = 0.5

if TYPE_CHECKING:
from ..providers import AbstractProvider, Preference
from ..reporters import BaseReporter
Expand Down Expand Up @@ -77,6 +79,12 @@ def __init__(
self._r = reporter
self._states: list[State[RT, CT, KT]] = []

# Optimistic backjumping variables
self._optimistic_backjumping = True
self._save_states: list[State[RT, CT, KT]] | None = None
self._optimistic_backjumping_start_round: int | None = None
self._optimistic_backjumping_ratio = OPTIMISTIC_BACKJUMPING_RATIO

@property
def state(self) -> State[RT, CT, KT]:
try:
Expand Down Expand Up @@ -324,11 +332,24 @@ def _backjump(self, causes: list[RequirementInformation[RT, CT]]) -> bool:
except (IndexError, KeyError):
raise ResolutionImpossible(causes) from None

# Only backjump if the current broken state is
# an incompatible dependency
if name not in incompatible_deps:
# If optimistic backjumping has switched off only backjump
# when the current candidate is in the incompatible dependencies
safe_backjump = name in incompatible_deps
if not self._optimistic_backjumping and not safe_backjump:
break

# On the first time a non-regular backjump is done the state
# is saved so we can restore it later if the resolution fails
if not safe_backjump and self._save_states is None:
self._save_states = [
State(
mapping=s.mapping.copy(),
criteria=s.criteria.copy(),
backtrack_causes=s.backtrack_causes[:],
)
for s in self._states
]

# If the current dependencies and the incompatible dependencies
# are overlapping then we have found a cause of the incompatibility
current_dependencies = {
Expand Down Expand Up @@ -397,6 +418,22 @@ def resolve(self, requirements: Iterable[RT], max_rounds: int) -> State[RT, CT,
for round_index in range(max_rounds):
self._r.starting_round(index=round_index)

# Check if optimistic backjumping has been running for too long
if (self._optimistic_backjumping
and self._save_states is not None
and self._optimistic_backjumping_start_round is not None):
remaining_rounds_at_start = max_rounds - self._optimistic_backjumping_start_round
max_optimistic_rounds = int(remaining_rounds_at_start * self._optimistic_backjumping_ratio)
optimistic_rounds_spent = round_index - self._optimistic_backjumping_start_round

if optimistic_rounds_spent > max_optimistic_rounds:
self._optimistic_backjumping = False
self._states = self._save_states
self._save_states = None
self._optimistic_backjumping_start_round = None
# Continue with the next round after reverting
continue

unsatisfied_names = [
key
for key, criterion in self.state.criteria.items()
Expand Down Expand Up @@ -448,12 +485,37 @@ def resolve(self, requirements: Iterable[RT], max_rounds: int) -> State[RT, CT,
# Backjump if pinning fails. The backjump process puts us in
# an unpinned state, so we can work on it in the next round.
self._r.resolving_conflicts(causes=causes)
success = self._backjump(causes)
self.state.backtrack_causes[:] = causes

# Dead ends everywhere. Give up.
if not success:
raise ResolutionImpossible(self.state.backtrack_causes)
# If optimistic backjumping fails restore previous state
try:
success = self._backjump(causes)
except ResolutionImpossible:
if self._optimistic_backjumping and self._save_states:
failed_optimistic_backjumping = True
else:
raise
else:
failed_optimistic_backjumping = bool(
not success
and self._optimistic_backjumping
and self._save_states
)

# Record the round when optimistic backjumping starts
if self._optimistic_backjumping and self._save_states and self._optimistic_backjumping_start_round is None:
self._optimistic_backjumping_start_round = round_index

if failed_optimistic_backjumping and self._save_states:
self._optimistic_backjumping = False
self._states = self._save_states
self._save_states = None
self._optimistic_backjumping_start_round = None
else:
self.state.backtrack_causes[:] = causes

# Dead ends everywhere. Give up.
if not success:
raise ResolutionImpossible(self.state.backtrack_causes)
else:
# discard as information sources any invalidated names
# (unsatisfied names that were previously satisfied)
Expand Down
17 changes: 17 additions & 0 deletions tests/functional/python/inputs/case/backjump-test-3.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"index": "backjump-test-3",
"requested": [
"a==1",
"b",
"c"
],
"resolved": {
"a": "1",
"b": "1",
"c": "2",
"d": "1"
},
"unvisited": {
"c": ["1"]
}
}
23 changes: 23 additions & 0 deletions tests/functional/python/inputs/case/backjump-test-4.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"index": "backjump-test-4",
"requested": [
"a==1",
"b",
"c",
"e",
"f"
],
"resolved": {
"a": "1",
"b": "1",
"c": "2",
"d": "1",
"e": "2",
"f": "2"
},
"unvisited": {
"c": ["1"],
"e": ["1"],
"f": ["1"]
}
}
42 changes: 42 additions & 0 deletions tests/functional/python/inputs/index/backjump-test-3.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"a": {
"1": {
"dependencies": []
},
"2": {
"dependencies": []
}
},
"b": {
"2": {
"dependencies": [
"d==2"
]
},
"1": {
"dependencies": [
"d==1"
]
}
},
"c": {
"1": {
"dependencies": []
},
"2": {
"dependencies": []
}
},
"d": {
"2": {
"dependencies": [
"a==2"
]
},
"1": {
"dependencies": [
"a==1"
]
}
}
}
58 changes: 58 additions & 0 deletions tests/functional/python/inputs/index/backjump-test-4.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
{
"a": {
"1": {
"dependencies": []
},
"2": {
"dependencies": []
}
},
"b": {
"2": {
"dependencies": [
"d==2"
]
},
"1": {
"dependencies": [
"d==1"
]
}
},
"c": {
"1": {
"dependencies": []
},
"2": {
"dependencies": []
}
},
"d": {
"2": {
"dependencies": [
"a==2"
]
},
"1": {
"dependencies": [
"a==1"
]
}
},
"e": {
"1": {
"dependencies": []
},
"2": {
"dependencies": []
}
},
"f": {
"1": {
"dependencies": []
},
"2": {
"dependencies": []
}
}
}
36 changes: 35 additions & 1 deletion tests/functional/python/test_resolvers_python.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,35 @@ def narrow_requirement_selection(
return identifiers


class PythonInputProviderNoOptimisticBackjumping(PythonInputProvider):
"""Provider that disables optimistic backjumping by setting ratio to 0.

This provider is used to test the time-based limits for optimistic backjumping.
By setting the ratio to 0, we force an immediate switch from optimistic to
regular backjumping, causing the resolver to visit candidates that would
normally be skipped.
"""
def customize_resolver(self, resolver):
# We need to use a custom resolver subclass that sets the ratio to 0
# Pass the reporter from the test function
return NoOptimisticBackjumpingResolver(self, resolver.reporter)


from resolvelib.resolvers.resolution import Resolution, _build_result

class NoOptimisticBackjumpingResolver(Resolver):
"""A resolver subclass that disables optimistic backjumping by setting ratio to 0."""

def resolve(self, requirements, max_rounds=100):
# Create the resolution object
resolution = Resolution(self.provider, self.reporter)
# Set the ratio to 0 to force immediate fallback from optimistic backjumping
resolution._optimistic_backjumping_ratio = 0.0
# Resolve and build the result
state = resolution.resolve(requirements, max_rounds=max_rounds)
return _build_result(state)


INPUTS_DIR = os.path.abspath(os.path.join(__file__, "..", "inputs"))

CASE_DIR = os.path.join(INPUTS_DIR, "case")
Expand Down Expand Up @@ -167,10 +196,11 @@ def create_params(provider_class):
params=[
*create_params(PythonInputProvider),
*create_params(PythonInputProviderNarrowRequirements),
*create_params(PythonInputProviderNoOptimisticBackjumping),
],
ids=[
f"{n[:-5]}-{cls.__name__}"
for cls in [PythonInputProvider, PythonInputProviderNarrowRequirements]
for cls in [PythonInputProvider, PythonInputProviderNarrowRequirements, PythonInputProviderNoOptimisticBackjumping]
for n in CASE_NAMES
],
)
Expand All @@ -196,6 +226,10 @@ def _format_resolution(result):

def test_resolver(provider, reporter):
resolver = Resolver(provider, reporter)

# Allow provider to customize the resolver if needed
if hasattr(provider, "customize_resolver"):
resolver = provider.customize_resolver(resolver)

if provider.expected_confliction:
with pytest.raises(ResolutionImpossible) as ctx:
Expand Down
Loading
Loading