From 3fad86e23b4e51bca0855ac196b26b3e08b5d29e Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Mon, 6 Jan 2025 20:03:46 -0500 Subject: [PATCH 1/4] Optimistic Backjumping --- src/resolvelib/resolvers/resolution.py | 53 ++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/src/resolvelib/resolvers/resolution.py b/src/resolvelib/resolvers/resolution.py index da3c66e..8ffac0b 100644 --- a/src/resolvelib/resolvers/resolution.py +++ b/src/resolvelib/resolvers/resolution.py @@ -77,6 +77,10 @@ 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 + @property def state(self) -> State[RT, CT, KT]: try: @@ -324,11 +328,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 = { @@ -448,12 +465,32 @@ 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 + ) + + if failed_optimistic_backjumping and self._save_states: + self._optimistic_backjumping = False + self._states = self._save_states + self._save_states = 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) From fee81a01a8618507decc9b1c52acb6cf92d1de20 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Mon, 6 Jan 2025 20:17:13 -0500 Subject: [PATCH 2/4] Increase allowed complexity --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 4ae90f5..ba28dbf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -98,7 +98,7 @@ exclude = [ ] [tool.ruff.lint.mccabe] -max-complexity = 12 +max-complexity = 15 [tool.mypy] warn_unused_configs = true From 1508f0fdf91e62e72e20a956b6ebe6131cbd42bc Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Thu, 8 May 2025 11:26:30 -0400 Subject: [PATCH 3/4] New backjumping tests --- .../python/inputs/case/backjump-test-3.json | 17 ++++++ .../python/inputs/case/backjump-test-4.json | 23 ++++++++ .../python/inputs/index/backjump-test-3.json | 42 ++++++++++++++ .../python/inputs/index/backjump-test-4.json | 58 +++++++++++++++++++ 4 files changed, 140 insertions(+) create mode 100644 tests/functional/python/inputs/case/backjump-test-3.json create mode 100644 tests/functional/python/inputs/case/backjump-test-4.json create mode 100644 tests/functional/python/inputs/index/backjump-test-3.json create mode 100644 tests/functional/python/inputs/index/backjump-test-4.json diff --git a/tests/functional/python/inputs/case/backjump-test-3.json b/tests/functional/python/inputs/case/backjump-test-3.json new file mode 100644 index 0000000..545611d --- /dev/null +++ b/tests/functional/python/inputs/case/backjump-test-3.json @@ -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"] + } +} \ No newline at end of file diff --git a/tests/functional/python/inputs/case/backjump-test-4.json b/tests/functional/python/inputs/case/backjump-test-4.json new file mode 100644 index 0000000..8de9b4c --- /dev/null +++ b/tests/functional/python/inputs/case/backjump-test-4.json @@ -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"] + } +} \ No newline at end of file diff --git a/tests/functional/python/inputs/index/backjump-test-3.json b/tests/functional/python/inputs/index/backjump-test-3.json new file mode 100644 index 0000000..67ba19e --- /dev/null +++ b/tests/functional/python/inputs/index/backjump-test-3.json @@ -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" + ] + } + } +} \ No newline at end of file diff --git a/tests/functional/python/inputs/index/backjump-test-4.json b/tests/functional/python/inputs/index/backjump-test-4.json new file mode 100644 index 0000000..ee02782 --- /dev/null +++ b/tests/functional/python/inputs/index/backjump-test-4.json @@ -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": [] + } + } +} \ No newline at end of file From 5fdc6115cbc9cb152795e5041409715bbcbc3d99 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Thu, 8 May 2025 23:24:46 -0400 Subject: [PATCH 4/4] there be work to do --- src/resolvelib/resolvers/resolution.py | 25 ++++++ .../python/test_resolvers_python.py | 36 +++++++- tests/test_optimistic_backjumping.py | 88 +++++++++++++++++++ 3 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 tests/test_optimistic_backjumping.py diff --git a/src/resolvelib/resolvers/resolution.py b/src/resolvelib/resolvers/resolution.py index 8ffac0b..da2771d 100644 --- a/src/resolvelib/resolvers/resolution.py +++ b/src/resolvelib/resolvers/resolution.py @@ -26,6 +26,8 @@ ResolverException, ) +OPTIMISTIC_BACKJUMPING_RATIO = 0.5 + if TYPE_CHECKING: from ..providers import AbstractProvider, Preference from ..reporters import BaseReporter @@ -80,6 +82,8 @@ def __init__( # 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]: @@ -414,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() @@ -481,10 +501,15 @@ def resolve(self, requirements: Iterable[RT], max_rounds: int) -> State[RT, CT, 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 diff --git a/tests/functional/python/test_resolvers_python.py b/tests/functional/python/test_resolvers_python.py index c1e3038..590762e 100644 --- a/tests/functional/python/test_resolvers_python.py +++ b/tests/functional/python/test_resolvers_python.py @@ -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") @@ -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 ], ) @@ -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: diff --git a/tests/test_optimistic_backjumping.py b/tests/test_optimistic_backjumping.py new file mode 100644 index 0000000..8a51b26 --- /dev/null +++ b/tests/test_optimistic_backjumping.py @@ -0,0 +1,88 @@ +import collections +import pytest + +from resolvelib import Resolver +from resolvelib.resolvers.resolution import Resolution + +# Create a simple dummy provider instead of importing from conftest +class DummyProvider: + def identify(self, requirement_or_candidate): + return requirement_or_candidate + + def get_preference(self, identifier, resolutions, candidates, information, backtrack_causes): + return 0 + + def find_matches(self, identifier, requirements, incompatibilities): + return [identifier] + + def is_satisfied_by(self, requirement, candidate): + return True + + def get_dependencies(self, candidate): + return [] + + +class ConflictingRequirementsProvider(DummyProvider): + """Provider that creates conflicts requiring backjumping.""" + + def identify(self, requirement_or_candidate): + return requirement_or_candidate + + def get_dependencies(self, candidate): + # Create a dependency tree that will require backjumping + if candidate == 'root': + return ['A', 'B'] + elif candidate == 'A': + return ['C==1'] + elif candidate == 'B': + return ['C==2'] + return [] + + def find_matches(self, identifier, requirements, incompatibilities): + # Return candidates in a way that forces backjumping + if identifier == 'C': + # Return in an order that will cause backjumping + return ['C==2', 'C==1'] + return [identifier] + + def is_satisfied_by(self, requirement, candidate): + # C==1 and C==2 are incompatible + if requirement == 'C==1' and candidate == 'C==2': + return False + if requirement == 'C==2' and candidate == 'C==1': + return False + return True + + +def test_optimistic_backjumping_timeout(): + """Test that optimistic backjumping respects the round-based timeout.""" + + # Create a minimal resolution object to test the timeout logic + resolution = Resolution(None, None) + + # Use different ratio values and verify behavior + + # Test that the default ratio exists + assert hasattr(resolution, "_optimistic_backjumping_ratio") + assert resolution._optimistic_backjumping_ratio == 0.5 + + # Simulate optimistic backjumping starting on round 50 with max_rounds=150 + resolution._optimistic_backjumping_start_round = 50 + max_rounds = 150 + + # Calculate optimistic backjumping limits for various round indices + remaining_rounds = max_rounds - resolution._optimistic_backjumping_start_round + max_optimistic_rounds = int(remaining_rounds * resolution._optimistic_backjumping_ratio) + + # With default ratio (0.5), optimistic backjumping should be allowed for half of remaining rounds + assert max_optimistic_rounds == 50 # (150 - 50) * 0.5 = 50 + + # Setting ratio to 0 should disable optimistic backjumping immediately + resolution._optimistic_backjumping_ratio = 0.0 + max_optimistic_rounds = int(remaining_rounds * resolution._optimistic_backjumping_ratio) + assert max_optimistic_rounds == 0 + + # Setting ratio to 1.0 should allow optimistic backjumping for all remaining rounds + resolution._optimistic_backjumping_ratio = 1.0 + max_optimistic_rounds = int(remaining_rounds * resolution._optimistic_backjumping_ratio) + assert max_optimistic_rounds == 100 # All remaining rounds: 150 - 50 = 100 \ No newline at end of file