diff --git a/nab-python/src/nab_python/_provider/lookahead.py b/nab-python/src/nab_python/_provider/lookahead.py index 08db1b413..941ea66db 100644 --- a/nab-python/src/nab_python/_provider/lookahead.py +++ b/nab-python/src/nab_python/_provider/lookahead.py @@ -103,8 +103,13 @@ def look_ahead_ok( def _widen_or_singleton( provider: Provider, package: str, version: Version ) -> VersionRange: - """Return ``version``'s widened gap, or its singleton without one.""" - widened = provider.widen_decision(package, version) + """Return ``version``'s widened neighbor gap, or its singleton without one. + + Look-ahead needs the gap rather than a ``widen_decision`` span: the gap + contains ``version`` and no other listed version, so unions and merge + keys name exactly the versions the scan rejected. + """ + widened = provider.widen_decision_gap(package, version) return VersionRange.singleton(version) if widened is None else widened @@ -113,9 +118,9 @@ def flush_pending_blocks(provider: Provider) -> None: For each ``(candidate_pkg, blocker_pkg, blocker_version)`` group we add ``{candidate_pkg in {v1,v2,...}, blocker_pkg==w}``, with each version - widened through ``widen_decision``: a version's open neighbor gap holds - no other listed version, so adjacent gaps coalesce without changing which - versions the clause names. Sound across backjumps because the blocker + widened through ``widen_decision_gap``: a version's open neighbor gap + holds no other listed version, so adjacent gaps coalesce without changing + which versions the clause names. Sound across backjumps because the blocker term goes UNDETERMINED when the supporting decision is reverted, so the candidate range can be reconsidered. """ diff --git a/nab-python/src/nab_python/provider.py b/nab-python/src/nab_python/provider.py index 26e450646..b9786ec6c 100644 --- a/nab-python/src/nab_python/provider.py +++ b/nab-python/src/nab_python/provider.py @@ -736,9 +736,11 @@ def __init__( # noqa: PLR0913, PLR0915 - resolver config is wide; bundling all self.wheel_by_version_cache: dict[str, dict[Version, DistFile]] = {} # Widening state: the ascending versions_only view per normalized - # name, and the widened parent range per decided (name, version). + # name, the span-widened parent range per decided (name, version), + # and the pure neighbor-gap range per (name, version). self._ascending_versions_cache: dict[str, list[Version]] = {} self._widened_ranges: dict[tuple[str, Version], VersionRange] = {} + self._gap_widened_ranges: dict[tuple[str, Version], VersionRange] = {} self.solution_ranges: Mapping[str, RangeProtocol[Version]] = {} self.solution_decisions: Mapping[str, Version] = {} @@ -1848,14 +1850,45 @@ def _ascending_versions( def widen_decision(self, package: str, version: Version) -> VersionRange | None: """Return the widened parent range for a decided ``version``, or None. - The widened range spans the open gap between ``version``'s listed - neighbors, so it contains no other selectable version (see - ``ResolverProvider.widen_decision`` for the contract). Extras - proxies share the base package's universe. Local, VCS, and archive - sources (synthesized single-version listings) and packages whose - listing is not cached are not widened. + For a base package the range spans adjacent listed versions whose + cached dependency dicts equal the decided version's, then widens to + the open gap around that span, so every selectable version inside + has exactly the dependencies being recorded (see + ``ResolverProvider.widen_decision`` for the contract). Under a + lowest preference the upward half is capped and that side keeps + the plain neighbor gap (see ``_span_identical_deps``); otherwise + the span runs both ways. Extras proxies keep the pure neighbor + gap over the base package's universe: their dependency sets are + per-extra-context. Local, VCS, and archive sources (synthesized + single-version listings) and packages whose listing is not cached + are not widened. + + The span is computed once from ``deps_cache`` and memoized. Later + fetches cannot invalidate it: metadata is immutable and the + universe never grows mid-resolve, so recomputing could only widen + the span. The cap is not part of the memo key: the strategy and + the direct-package set are both fixed at construction, so + ``wants_lowest`` gives one answer per package for the provider's + whole life. + """ + _, extra, normalized = self.split_and_normalize(package) + return self._widen(normalized, version, span=extra is None) + + def widen_decision_gap(self, package: str, version: Version) -> VersionRange | None: + """Return ``version``'s pure neighbor-gap range, or None. + + Contract: the gap contains ``version`` and no other listed version, + so a term built from it names exactly ``version``. Look-ahead + terms widen through this path: a ``widen_decision`` span does not + meet that contract. Gating matches ``widen_decision``. """ _, _, normalized = self.split_and_normalize(package) + return self._widen(normalized, version, span=False) + + def _widen( + self, normalized: str, version: Version, *, span: bool + ) -> VersionRange | None: + """Widen ``version`` over ``normalized``'s universe; memoized per shape.""" if ( normalized in self.local_sources or normalized in self.vcs_sources @@ -1867,20 +1900,60 @@ def widen_decision(self, package: str, version: Version) -> VersionRange | None: return None key = (normalized, version) - widened = self._widened_ranges.get(key) + memo = self._widened_ranges if span else self._gap_widened_ranges + widened = memo.get(key) if widened is None: universe = self._ascending_versions(normalized, version_list) below = bisect.bisect_left(universe, version) above = bisect.bisect_right(universe, version) + if span: + below, above = self._span_identical_deps( + normalized, version, universe, below, above + ) prev = universe[below - 1] if below else None nxt = universe[above] if above < len(universe) else None widened = VersionRange.from_bounds( prev, nxt, include_lower=False, include_upper=False ) - self._widened_ranges[key] = widened + memo[key] = widened return widened + def _span_identical_deps( + self, + normalized: str, + version: Version, + universe: list[Version], + below: int, + above: int, + ) -> tuple[int, int]: + """Extend ``[below, above)`` across neighbors with equal cached deps. + + Reads ``deps_cache`` only, never fetches. A neighbor whose cached + dependency dict is missing or differs fences the span; so does a + decided version whose own deps are not cached. + + The upward half is capped when ``wants_lowest`` picks the minimum + for this package, the same per-package answer ``choose_version`` + orders candidates by. Under a lowest preference the answer sits + near the floor, so an upward span carries the search away from it + a whole run at a time; capping leaves the plain neighbor gap + there, and the search resumes at the adjacent listed version. + Every other package spans both ways. + """ + cached = self.deps_cache + deps = cached.get((normalized, version)) + if deps is None: + return below, above + while below and cached.get((normalized, universe[below - 1])) == deps: + below -= 1 + if self.wants_lowest(normalized): + return below, above + top = len(universe) + while above < top and cached.get((normalized, universe[above])) == deps: + above += 1 + return below, above + def narrow_for_display( self, package: object, constraint: RangeProtocol[Version] ) -> RangeProtocol[Version]: diff --git a/nab-python/tests/test_widen_decision.py b/nab-python/tests/test_widen_decision.py index 5575f6cf3..e5baa838b 100644 --- a/nab-python/tests/test_widen_decision.py +++ b/nab-python/tests/test_widen_decision.py @@ -1,15 +1,20 @@ -"""Tests for ``Provider.widen_decision`` and ``Provider.narrow_for_display``. +"""Tests for the ``Provider`` widening hooks and ``narrow_for_display``. The widening universe is the post-filter listing for the normalized base package: ascending, including pre-release, dev, post, and local versions. -Local, VCS, and archive sources (synthesized single-version listings) and -packages with no cached listing are never widened, and display narrowing -reads caches only. +``widen_decision`` spans base packages across adjacent versions with equal +cached deps before widening to the surrounding gap; under a lowest +preference the upward half of that span is capped and keeps the plain +neighbor gap. Extras proxies keep the pure neighbor gap, and +``widen_decision_gap`` keeps it for every package. Local, VCS, and +archive sources (synthesized single-version listings) and packages with +no cached listing are never widened, and display narrowing reads caches +only. """ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast import pytest @@ -24,6 +29,7 @@ BuildPolicy, LocalSource, Provider, + ResolutionStrategy, VcsSource, ) from nab_python.target import ResolveTarget @@ -79,6 +85,28 @@ def _listing_provider(package: str, versions: list[str]) -> Provider: return provider +def _deps_provider( + graph: dict[str, dict[str, list[str]]], + package: str, + fetched: list[str], + *, + strategy: ResolutionStrategy = ResolutionStrategy.HIGHEST, + direct: frozenset[str] | None = None, +) -> Provider: + """Provider with ``package``'s listing and ``fetched`` versions' deps cached.""" + coordinator = _graph_coordinator(graph) + provider = Provider( + coordinator, + target=ResolveTarget.for_host_python("3.12.0"), + resolution_strategy=strategy, + direct_packages=direct, + ) + provider.fetch_versions(package) + for version in fetched: + provider.get_dependencies(package, V(version)) + return provider + + class TestWidenDecision: def test_none_before_listing_is_cached(self) -> None: coordinator = _graph_coordinator({"p": {"1.0": []}}) @@ -109,7 +137,12 @@ def test_extras_proxy_uses_base_universe(self) -> None: base = provider.widen_decision("foo", V("2.0")) proxy = provider.widen_decision("foo[bar]", V("2.0")) assert base is not None - assert proxy is base + assert proxy is not None + assert proxy == base + assert V("2.0") in proxy + assert V("2.5") in proxy + assert V("1.0") not in proxy + assert V("3.0") not in proxy def test_memoized_per_package_version(self) -> None: provider = _listing_provider("p", ["3.0", "2.0", "1.0"]) @@ -159,6 +192,311 @@ def test_none_for_archive_source(self) -> None: assert provider.widen_decision("baz", V("1.0")) is None +_RUN_GRAPH = { + "p": { + "4.0": [], + "3.0": ["d>=1"], + "2.0": ["d>=1"], + "1.0": ["d>=1"], + "0.5": [], + }, + "d": {"1.0": []}, +} + + +class TestSameDepsSpan: + """Base packages span adjacent versions with equal cached dependency + dicts before widening to the open gap around the span. These fixtures + run the default HIGHEST strategy, which spans both ways.""" + + def test_spans_cached_neighbors_with_equal_deps(self) -> None: + provider = _deps_provider(_RUN_GRAPH, "p", ["0.5", "1.0", "2.0", "3.0", "4.0"]) + widened = provider.widen_decision("p", V("2.0")) + assert widened is not None + assert V("1.0") in widened + assert V("2.0") in widened + assert V("3.0") in widened + assert V("0.7") in widened + assert V("3.5") in widened + assert V("0.5") not in widened + assert V("4.0") not in widened + + def test_differing_deps_neighbor_fences(self) -> None: + graph = { + "p": {"3.0": ["d>=2"], "2.0": ["d>=1"], "1.0": ["d>=1"]}, + "d": {"2.0": [], "1.0": []}, + } + provider = _deps_provider(graph, "p", ["1.0", "2.0", "3.0"]) + widened = provider.widen_decision("p", V("2.0")) + assert widened is not None + assert V("1.0") in widened + assert V("2.0") in widened + assert V("0.1") in widened + assert V("2.5") in widened + assert V("3.0") not in widened + + def test_uncached_neighbor_fences(self) -> None: + graph = { + "p": {"3.0": ["d>=1"], "2.0": ["d>=1"], "1.0": ["d>=1"]}, + "d": {"1.0": []}, + } + provider = _deps_provider(graph, "p", ["1.0", "2.0"]) + widened = provider.widen_decision("p", V("2.0")) + assert widened is not None + assert V("1.0") in widened + assert V("2.0") in widened + assert V("3.0") not in widened + + def test_uncached_decided_version_keeps_gap(self) -> None: + graph = { + "p": {"3.0": ["d>=1"], "2.0": ["d>=1"], "1.0": ["d>=1"]}, + "d": {"1.0": []}, + } + provider = _deps_provider(graph, "p", ["1.0", "3.0"]) + widened = provider.widen_decision("p", V("2.0")) + assert widened is not None + assert V("2.0") in widened + assert V("1.0") not in widened + assert V("3.0") not in widened + + def test_identical_run_covering_whole_listing_spans_fully(self) -> None: + graph = { + "p": {"3.0": ["d>=1"], "2.0": ["d>=1"], "1.0": ["d>=1"]}, + "d": {"1.0": []}, + } + provider = _deps_provider(graph, "p", ["1.0", "2.0", "3.0"]) + widened = provider.widen_decision("p", V("2.0")) + assert widened is not None + assert V("0.1") in widened + assert V("99") in widened + + def test_extras_proxy_keeps_gap_widening(self) -> None: + graph = { + "foo": {"3.0": ["d>=1"], "2.0": ["d>=1"], "1.0": ["d>=1"]}, + "d": {"1.0": []}, + } + provider = _deps_provider(graph, "foo", ["1.0", "2.0", "3.0"]) + base = provider.widen_decision("foo", V("2.0")) + proxy = provider.widen_decision("foo[bar]", V("2.0")) + assert base is not None + assert proxy is not None + assert V("1.0") in base + assert V("3.0") in base + assert V("2.0") in proxy + assert V("2.5") in proxy + assert V("1.0") not in proxy + assert V("3.0") not in proxy + assert provider.widen_decision("foo[bar]", V("2.0")) is proxy + + def test_span_memo_returns_identical_object(self) -> None: + provider = _deps_provider(_RUN_GRAPH, "p", ["0.5", "1.0", "2.0", "3.0", "4.0"]) + first = provider.widen_decision("p", V("2.0")) + second = provider.widen_decision("p", V("2.0")) + assert first is not None + assert second is first + + def test_span_is_fixed_at_first_computation(self) -> None: + graph = { + "p": {"3.0": ["d>=1"], "2.0": ["d>=1"], "1.0": ["d>=1"]}, + "d": {"1.0": []}, + } + provider = _deps_provider(graph, "p", ["1.0", "2.0"]) + first = provider.widen_decision("p", V("2.0")) + assert first is not None + assert V("3.0") not in first + provider.get_dependencies("p", V("3.0")) + second = provider.widen_decision("p", V("2.0")) + assert second is not None + assert second is first + assert V("3.0") not in second + + def test_span_never_fetches(self) -> None: + provider = _deps_provider(_RUN_GRAPH, "p", ["0.5", "1.0", "2.0", "3.0", "4.0"]) + coordinator = cast("MagicMock", provider.coordinator) + for method in ( + coordinator.request_listing, + coordinator.request_metadata, + coordinator.request_metadata_batch, + ): + method.side_effect = AssertionError("widen_decision fetched") + assert provider.widen_decision("p", V("2.0")) is not None + + +_SPLIT_GRAPH = { + "p": {"4.0": [], "3.0": ["d>=1"], "2.0": ["d>=1"], "1.0": ["d>=1"], "0.5": []}, + "q": {"4.0": [], "3.0": ["d>=1"], "2.0": ["d>=1"], "1.0": ["d>=1"], "0.5": []}, + "d": {"1.0": []}, +} + + +class TestSpanDirection: + """A lowest preference caps the upward half of the span; every other + package spans both ways. ``wants_lowest`` is the same per-package + answer ``choose_version`` orders candidates by.""" + + def test_lowest_caps_the_upward_half(self) -> None: + provider = _deps_provider( + _RUN_GRAPH, + "p", + ["0.5", "1.0", "2.0", "3.0", "4.0"], + strategy=ResolutionStrategy.LOWEST, + ) + widened = provider.widen_decision("p", V("2.0")) + assert widened is not None + assert V("1.0") in widened + assert V("2.0") in widened + assert V("2.5") in widened + assert V("3.0") not in widened + assert V("0.5") not in widened + + def test_highest_spans_both_ways(self) -> None: + provider = _deps_provider( + _RUN_GRAPH, + "p", + ["0.5", "1.0", "2.0", "3.0", "4.0"], + strategy=ResolutionStrategy.HIGHEST, + ) + widened = provider.widen_decision("p", V("2.0")) + assert widened is not None + assert V("1.0") in widened + assert V("2.0") in widened + assert V("3.0") in widened + assert V("0.5") not in widened + assert V("4.0") not in widened + + def test_lowest_span_reaching_the_floor_drops_the_lower_bound(self) -> None: + graph = { + "p": {"3.0": [], "2.0": ["d>=1"], "1.0": ["d>=1"]}, + "d": {"1.0": []}, + } + provider = _deps_provider( + graph, "p", ["1.0", "2.0", "3.0"], strategy=ResolutionStrategy.LOWEST + ) + widened = provider.widen_decision("p", V("2.0")) + assert widened is not None + assert V("0.1") in widened + assert V("1.0") in widened + assert V("3.0") not in widened + + def test_lowest_direct_caps_direct_packages_only(self) -> None: + provider = _deps_provider( + _SPLIT_GRAPH, + "p", + ["0.5", "1.0", "2.0", "3.0", "4.0"], + strategy=ResolutionStrategy.LOWEST_DIRECT, + direct=frozenset({"p"}), + ) + provider.fetch_versions("q") + for version in ("0.5", "1.0", "2.0", "3.0", "4.0"): + provider.get_dependencies("q", V(version)) + + direct = provider.widen_decision("p", V("2.0")) + transitive = provider.widen_decision("q", V("2.0")) + assert direct is not None + assert transitive is not None + assert V("1.0") in direct + assert V("3.0") not in direct + assert V("1.0") in transitive + assert V("3.0") in transitive + + def test_memo_holds_one_shape_per_provider(self) -> None: + fetched = ["0.5", "1.0", "2.0", "3.0", "4.0"] + lowest = _deps_provider( + _RUN_GRAPH, "p", fetched, strategy=ResolutionStrategy.LOWEST + ) + highest = _deps_provider(_RUN_GRAPH, "p", fetched) + low = lowest.widen_decision("p", V("2.0")) + high = highest.widen_decision("p", V("2.0")) + assert low is not None + assert high is not None + assert low != high + assert lowest.widen_decision("p", V("2.0")) is low + assert highest.widen_decision("p", V("2.0")) is high + + def test_gap_path_is_direction_free(self) -> None: + fetched = ["0.5", "1.0", "2.0", "3.0", "4.0"] + lowest = _deps_provider( + _RUN_GRAPH, "p", fetched, strategy=ResolutionStrategy.LOWEST + ) + highest = _deps_provider(_RUN_GRAPH, "p", fetched) + gap = lowest.widen_decision_gap("p", V("2.0")) + assert gap is not None + assert gap == highest.widen_decision_gap("p", V("2.0")) + assert V("2.0") in gap + assert V("1.0") not in gap + assert V("3.0") not in gap + + +class TestGapSpanComposition: + """``widen_decision`` spans equal-deps runs for decided parent terms + while look-ahead terms keep pure gaps via ``widen_decision_gap``.""" + + def test_run_member_spans_parent_but_lookahead_keeps_gaps(self) -> None: + graph = { + "foo": { + "3.0": ["bar>=5.0"], + "2.0": ["bar>=5.0"], + "1.0": ["bar>=5.0"], + "0.5": ["bar>=5.0"], + }, + "bar": {"5.0": [], "3.0": [], "1.0": []}, + } + coordinator = _graph_coordinator(graph) + provider = Provider(coordinator, target=ResolveTarget.for_host_python("3.12.0")) + provider.fetch_versions("foo") + provider.fetch_versions("bar") + for version in ("0.5", "1.0", "2.0", "3.0"): + provider.get_dependencies("foo", V(version)) + for version in ("1.0", "3.0", "5.0"): + provider.get_dependencies("bar", V(version)) + provider.receive_partial_solution_hint({}, {"bar": V("3.0")}) + assert provider.choose_version("foo", SpecifierSet(">=1.0").to_range()) is None + span = provider.widen_decision("foo", V("1.0")) + assert span is not None + assert V("0.5") in span + assert V("3.0") in span + clauses = provider.consume_pending_clauses() + assert len(clauses) == 1 + candidate, blocker = clauses[0].terms + assert candidate.package == "foo" + assert V("1.0") in candidate.constraint + assert V("2.0") in candidate.constraint + assert V("3.0") in candidate.constraint + assert V("0.5") not in candidate.constraint + assert blocker.package == "bar" + assert V("3.0") in blocker.constraint + assert V("1.0") not in blocker.constraint + assert V("5.0") not in blocker.constraint + + def test_gap_path_ignores_spans_for_base_packages(self) -> None: + provider = _deps_provider(_RUN_GRAPH, "p", ["0.5", "1.0", "2.0", "3.0", "4.0"]) + span = provider.widen_decision("p", V("2.0")) + gap = provider.widen_decision_gap("p", V("2.0")) + assert span is not None + assert gap is not None + assert V("1.0") in span + assert V("3.0") in span + assert V("2.0") in gap + assert V("1.0") not in gap + assert V("3.0") not in gap + + def test_extras_proxy_gets_gaps_in_both_paths(self) -> None: + provider = _deps_provider(_RUN_GRAPH, "p", ["0.5", "1.0", "2.0", "3.0", "4.0"]) + proxy = provider.widen_decision("p[x]", V("2.0")) + gap = provider.widen_decision_gap("p[x]", V("2.0")) + assert proxy is not None + assert gap is proxy + assert V("2.0") in proxy + assert V("1.0") not in proxy + assert V("3.0") not in proxy + assert provider.widen_decision_gap("p", V("2.0")) is proxy + + def test_gap_path_none_before_listing_is_cached(self) -> None: + coordinator = _graph_coordinator({"p": {"1.0": []}}) + provider = Provider(coordinator, target=ResolveTarget.for_host_python("3.12.0")) + assert provider.widen_decision_gap("p", V("1.0")) is None + + class TestNarrowForDisplay: def test_root_sentinel_returns_constraint_unchanged(self) -> None: provider = _listing_provider("p", ["1.0"]) diff --git a/nab-resolver/src/nab_resolver/resolver.py b/nab-resolver/src/nab_resolver/resolver.py index f71aacff4..b28c9a88d 100644 --- a/nab-resolver/src/nab_resolver/resolver.py +++ b/nab-resolver/src/nab_resolver/resolver.py @@ -160,12 +160,18 @@ def widen_decision( ) -> RangeProtocol[VersionType] | None: """Return a widened stand-in for ``version`` in dependency clauses, or None. - Soundness contract: the returned range must contain ``version`` and - no other version that could ever be chosen for ``package`` in this - resolution; versions inside it that can never be selected are - harmless. ``None`` keeps the exact singleton. Widening lets - dependency clauses for adjacent rejected versions merge into - contiguous ranges instead of accumulating one hole per version. + Called at most once per decision, after ``get_dependencies`` + returned a non-empty mapping for ``version``, so a provider may + answer from what that call cached. + + Soundness contract: the returned range must contain ``version``, and + every version inside it that could ever be chosen for ``package`` in + this resolution must have exactly the dependencies being recorded + for ``version``; versions inside it that can never be selected are + harmless. ``None`` keeps the exact singleton. Widening merges + dependency clauses for adjacent rejected versions into contiguous + ranges instead of one hole per version, and lets a single clause + reject a whole run of same-dependency versions. """ ... diff --git a/nab-resolver/tests/test_widen_hook.py b/nab-resolver/tests/test_widen_hook.py index c6097bac2..f7e0577b9 100644 --- a/nab-resolver/tests/test_widen_hook.py +++ b/nab-resolver/tests/test_widen_hook.py @@ -1,10 +1,12 @@ """Tests for the decision-widening provider hooks. ``widen_decision`` lets a provider replace the exact singleton parent term -of a cross-package dependency clause with a wider range containing no other -selectable version, so adjacent clauses merge contiguously instead of -accumulating one hole per rejected version. ``narrow_for_display`` maps -possibly-widened constraints back onto known versions at error-render time. +of a cross-package dependency clause with a wider range in which every +selectable version has exactly the dependencies being recorded: adjacent +clauses merge contiguously instead of one hole per rejected version, and +one clause can reject a whole run of same-dependency versions. +``narrow_for_display`` maps possibly-widened constraints back onto known +versions at error-render time. """ from __future__ import annotations @@ -122,6 +124,29 @@ def widen_decision(self, package: str, version: int) -> RangeProtocol[int] | Non return widened +class _SpanWideningProvider(_BaseProvider): + """Widens a decided version across adjacent versions with equal deps.""" + + def widen_decision(self, package: str, version: int) -> RangeProtocol[int] | None: + versions_map = self._packages.get(package, {}) + universe = sorted(versions_map.keys()) + if version not in universe: + return None + deps = versions_map[version] + low = universe.index(version) + high = low + while low > 0 and versions_map[universe[low - 1]] == deps: + low -= 1 + while high < len(universe) - 1 and versions_map[universe[high + 1]] == deps: + high += 1 + widened: Range[int] = Range.full() + if low > 0: + widened = widened & Range.greater_than(universe[low - 1]) + if high < len(universe) - 1: + widened = widened & Range.less_than(universe[high + 1]) + return widened + + class _NarrowingProvider(_BaseProvider): """Narrows displayed constraints for one package to a fixed range.""" @@ -219,6 +244,54 @@ def test_widening_does_not_change_unsat_outcome(self) -> None: Resolver(_WideningProvider(packages)).resolve({"root": Range.singleton(1)}) +class TestSpanWideningContract: + """A range spanning several same-dependency versions is a valid + ``widen_decision`` result: one clause rejects the whole run.""" + + def test_span_rejects_identical_run_with_one_clause(self) -> None: + packages = { + "root": {1: {"a": Range.full()}}, + "a": { + 3: {"b": Range.singleton(5)}, + 2: {"b": Range.singleton(5)}, + 1: {"b": Range.singleton(5)}, + }, + "b": {1: {}}, + } + resolver = Resolver(_SpanWideningProvider(packages)) + with pytest.raises(ResolutionError): + resolver.resolve({"root": Range.singleton(1)}) + + dep_clauses = [ + inc + for inc in resolver.incompatibilities + if inc.cause is IncompatibilityCause.DEPENDENCY + and len(inc.terms) == 2 + and inc.terms[0].package == "a" + and inc.terms[1].package == "b" + ] + assert len(dep_clauses) == 1 + constraint = dep_clauses[0].terms[0].constraint + assert 1 in constraint + assert 2 in constraint + assert 3 in constraint + + def test_span_outcome_matches_unwidened(self) -> None: + packages = { + "root": {1: {"a": Range.full()}}, + "a": { + 3: {"b": Range.singleton(5)}, + 2: {"b": Range.singleton(5)}, + 1: {"b": Range.singleton(1)}, + }, + "b": {1: {}}, + } + base = Resolver(_BaseProvider(packages)).resolve({"root": Range.singleton(1)}) + span_resolver = Resolver(_SpanWideningProvider(packages)) + span = span_resolver.resolve({"root": Range.singleton(1)}) + assert span == base == {"root": 1, "a": 1, "b": 1} + + class TestSelfDependencyStaysExact: def test_self_dep_clause_keeps_singleton_term(self) -> None: """foo@2 depends on foo=={1}: the single-term clause asserts exactly @@ -239,6 +312,36 @@ def test_self_dep_clause_keeps_singleton_term(self) -> None: assert self_clauses[0].terms[0].constraint == Range.singleton(2) assert self_clauses[0].terms[0].is_positive() + def test_self_dep_clause_keeps_singleton_with_span_provider(self) -> None: + """A same-deps run must not leak the spanned range into self-dep + clauses; they are built resolver-side from the exact singleton.""" + provider = _SpanWideningProvider( + { + "foo": { + 3: {"foo": Range.singleton(1)}, + 2: {"foo": Range.singleton(1)}, + 1: {}, + } + } + ) + resolver = Resolver(provider, max_iterations=100) + result = resolver.resolve({"foo": Range.full()}) + assert result == {"foo": 1} + + self_clauses = [ + inc + for inc in resolver.incompatibilities + if inc.cause is IncompatibilityCause.DEPENDENCY + and len(inc.terms) == 1 + and inc.terms[0].package == "foo" + ] + assert {c.terms[0].constraint for c in self_clauses} <= { + Range.singleton(3), + Range.singleton(2), + } + assert self_clauses + assert all(c.terms[0].is_positive() for c in self_clauses) + class TestRootIsNeverWidened: def test_widen_decision_never_called_with_root_sentinel(self) -> None: