nab-resolver 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nab_resolver-0.0.1/LICENSE +21 -0
- nab_resolver-0.0.1/PKG-INFO +39 -0
- nab_resolver-0.0.1/README.md +16 -0
- nab_resolver-0.0.1/pyproject.toml +34 -0
- nab_resolver-0.0.1/src/nab_resolver/__init__.py +1 -0
- nab_resolver-0.0.1/src/nab_resolver/conflict.py +491 -0
- nab_resolver-0.0.1/src/nab_resolver/decide.py +138 -0
- nab_resolver-0.0.1/src/nab_resolver/errors.py +36 -0
- nab_resolver-0.0.1/src/nab_resolver/incompat_index.py +119 -0
- nab_resolver-0.0.1/src/nab_resolver/partial_solution.py +394 -0
- nab_resolver-0.0.1/src/nab_resolver/propagate.py +156 -0
- nab_resolver-0.0.1/src/nab_resolver/py.typed +0 -0
- nab_resolver-0.0.1/src/nab_resolver/ranges.py +419 -0
- nab_resolver-0.0.1/src/nab_resolver/report.py +179 -0
- nab_resolver-0.0.1/src/nab_resolver/resolver.py +456 -0
- nab_resolver-0.0.1/src/nab_resolver/result.py +79 -0
- nab_resolver-0.0.1/src/nab_resolver/root.py +32 -0
- nab_resolver-0.0.1/src/nab_resolver/types.py +257 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Damian Shaw
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nab-resolver
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Generic PubGrub dependency-resolver core
|
|
5
|
+
Project-URL: Homepage, https://github.com/notatallshaw/nab
|
|
6
|
+
Project-URL: Documentation, https://nab.readthedocs.io/
|
|
7
|
+
Project-URL: Issues, https://github.com/notatallshaw/nab/issues
|
|
8
|
+
Project-URL: Source, https://github.com/notatallshaw/nab
|
|
9
|
+
Project-URL: Changelog, https://github.com/notatallshaw/nab/blob/main/CHANGELOG.md
|
|
10
|
+
Author-email: Damian Shaw <damian.peter.shaw@gmail.com>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: typing-extensions>=4.6
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# nab-resolver
|
|
25
|
+
|
|
26
|
+
Generic PubGrub dependency resolver, parameterised over a
|
|
27
|
+
`ResolverProvider` protocol. No Python-specific knowledge: this
|
|
28
|
+
package is a SAT-style solver core. The Python provider lives in
|
|
29
|
+
[`nab-python`](https://pypi.org/project/nab-python/) and the
|
|
30
|
+
user-facing CLI in [`nab`](https://pypi.org/project/nab/).
|
|
31
|
+
|
|
32
|
+
## When to use it
|
|
33
|
+
|
|
34
|
+
Use `nab-resolver` when you are building some kind of package
|
|
35
|
+
resolver, Python or otherwise.
|
|
36
|
+
|
|
37
|
+
The public API is the `Resolver` class plus the
|
|
38
|
+
`ResolverProvider` protocol, the `Range` and `Term` types, and the
|
|
39
|
+
`ResolutionError` exception. Everything else is internal.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# nab-resolver
|
|
2
|
+
|
|
3
|
+
Generic PubGrub dependency resolver, parameterised over a
|
|
4
|
+
`ResolverProvider` protocol. No Python-specific knowledge: this
|
|
5
|
+
package is a SAT-style solver core. The Python provider lives in
|
|
6
|
+
[`nab-python`](https://pypi.org/project/nab-python/) and the
|
|
7
|
+
user-facing CLI in [`nab`](https://pypi.org/project/nab/).
|
|
8
|
+
|
|
9
|
+
## When to use it
|
|
10
|
+
|
|
11
|
+
Use `nab-resolver` when you are building some kind of package
|
|
12
|
+
resolver, Python or otherwise.
|
|
13
|
+
|
|
14
|
+
The public API is the `Resolver` class plus the
|
|
15
|
+
`ResolverProvider` protocol, the `Range` and `Term` types, and the
|
|
16
|
+
`ResolutionError` exception. Everything else is internal.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "nab-resolver"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "Generic PubGrub dependency-resolver core"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Damian Shaw", email = "damian.peter.shaw@gmail.com" },
|
|
10
|
+
]
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 4 - Beta",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3.10",
|
|
16
|
+
"Programming Language :: Python :: 3.11",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Typing :: Typed",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"typing_extensions>=4.6",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/notatallshaw/nab"
|
|
27
|
+
Documentation = "https://nab.readthedocs.io/"
|
|
28
|
+
Issues = "https://github.com/notatallshaw/nab/issues"
|
|
29
|
+
Source = "https://github.com/notatallshaw/nab"
|
|
30
|
+
Changelog = "https://github.com/notatallshaw/nab/blob/main/CHANGELOG.md"
|
|
31
|
+
|
|
32
|
+
[build-system]
|
|
33
|
+
requires = ["hatchling"]
|
|
34
|
+
build-backend = "hatchling.build"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""PubGrub dependency resolver."""
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
"""Conflict resolution and backtracking for the PubGrub resolver.
|
|
2
|
+
|
|
3
|
+
Owns the conflict-resolution loop, the most-recent-satisfier
|
|
4
|
+
search, the always-learn force-resolution gate, the targeted
|
|
5
|
+
backtrack queue, and the catastrophic restart handler.
|
|
6
|
+
|
|
7
|
+
Reference: https://github.com/dart-lang/pub/blob/master/doc/solver.md#conflict-resolution
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
from .errors import ResolutionError
|
|
15
|
+
from .incompat_index import add_incompatibility
|
|
16
|
+
from .partial_solution import PartialSolution
|
|
17
|
+
from .report import format_error, prior_cause
|
|
18
|
+
from .root import ROOT
|
|
19
|
+
from .types import Incompatibility, IncompatibilityCause
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from .partial_solution import Assignment
|
|
23
|
+
from .resolver import Resolver
|
|
24
|
+
from .types import Term
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"apply_targeted_backtrack",
|
|
29
|
+
"conflict_resolution",
|
|
30
|
+
"find_most_recent_satisfier",
|
|
31
|
+
"force_targeted_backtrack",
|
|
32
|
+
"is_terminal_incompatibility",
|
|
33
|
+
"iterate_force_resolution",
|
|
34
|
+
"maybe_restart",
|
|
35
|
+
"maybe_targeted_backtrack",
|
|
36
|
+
"recompute_previous_level",
|
|
37
|
+
"try_force_resolution_step",
|
|
38
|
+
"update_culprit_counts",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Soundness gate for try_force_resolution_step: a single-term
|
|
43
|
+
# incompatibility must resolve to >= 2 terms to keep the eliminated
|
|
44
|
+
# package's conditioning.
|
|
45
|
+
_SINGLE_TERM = 1
|
|
46
|
+
_MIN_RESOLVED_TERMS = 2
|
|
47
|
+
|
|
48
|
+
# Lowest decision level a targeted-backtrack can land above without
|
|
49
|
+
# removing ROOT (decided at level 1 in _add_root_requirements).
|
|
50
|
+
_TARGETED_BT_MIN_LEVEL = 2
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def conflict_resolution(
|
|
54
|
+
resolver: Resolver[Any, Any],
|
|
55
|
+
conflicting_incompatibility: Incompatibility[Any, Any],
|
|
56
|
+
) -> Incompatibility[Any, Any]:
|
|
57
|
+
"""Learn a new incompatibility and backjump to the appropriate level.
|
|
58
|
+
|
|
59
|
+
Implements PubGrub's conflict resolution algorithm: resolve
|
|
60
|
+
backwards through the assignment trail combining incompatibilities
|
|
61
|
+
until the learned clause has at most one term at the current
|
|
62
|
+
decision level.
|
|
63
|
+
|
|
64
|
+
Reference: https://github.com/dart-lang/pub/blob/master/doc/solver.md#conflict-resolution
|
|
65
|
+
"""
|
|
66
|
+
current_incompatibility = conflicting_incompatibility
|
|
67
|
+
is_derived = False
|
|
68
|
+
|
|
69
|
+
while True:
|
|
70
|
+
if is_terminal_incompatibility(current_incompatibility):
|
|
71
|
+
# Unreachable in practice: the backjump_target == 0 check below
|
|
72
|
+
# preempts this path. Kept as a safety net.
|
|
73
|
+
raise ResolutionError( # pragma: no cover
|
|
74
|
+
format_error(current_incompatibility),
|
|
75
|
+
incompatibility=current_incompatibility,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
satisfier_result = find_most_recent_satisfier(resolver, current_incompatibility)
|
|
79
|
+
most_recent_satisfier = satisfier_result[0]
|
|
80
|
+
most_recent_satisfier_term = satisfier_result[1]
|
|
81
|
+
previous_satisfier_level = satisfier_result[2]
|
|
82
|
+
|
|
83
|
+
can_backjump = (
|
|
84
|
+
most_recent_satisfier.is_decision
|
|
85
|
+
or previous_satisfier_level != most_recent_satisfier.decision_level
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
if can_backjump:
|
|
89
|
+
(
|
|
90
|
+
current_incompatibility,
|
|
91
|
+
most_recent_satisfier,
|
|
92
|
+
most_recent_satisfier_term,
|
|
93
|
+
previous_satisfier_level,
|
|
94
|
+
forced_any,
|
|
95
|
+
) = iterate_force_resolution(
|
|
96
|
+
resolver,
|
|
97
|
+
current_incompatibility,
|
|
98
|
+
most_recent_satisfier,
|
|
99
|
+
most_recent_satisfier_term,
|
|
100
|
+
previous_satisfier_level,
|
|
101
|
+
)
|
|
102
|
+
if forced_any:
|
|
103
|
+
is_derived = True
|
|
104
|
+
|
|
105
|
+
resolver.observer.on_conflict_step(
|
|
106
|
+
current_incompatibility,
|
|
107
|
+
satisfier_package=most_recent_satisfier.package,
|
|
108
|
+
satisfier_is_decision=most_recent_satisfier.is_decision,
|
|
109
|
+
satisfier_level=most_recent_satisfier.decision_level,
|
|
110
|
+
previous_level=previous_satisfier_level,
|
|
111
|
+
can_backjump=can_backjump,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
if can_backjump:
|
|
115
|
+
if is_derived:
|
|
116
|
+
add_incompatibility(resolver, current_incompatibility)
|
|
117
|
+
resolver.stats.incompatibilities_learned += 1
|
|
118
|
+
resolver.observer.on_learned(current_incompatibility)
|
|
119
|
+
|
|
120
|
+
backjump_target = previous_satisfier_level
|
|
121
|
+
if (
|
|
122
|
+
most_recent_satisfier.is_decision
|
|
123
|
+
and backjump_target >= most_recent_satisfier.decision_level
|
|
124
|
+
):
|
|
125
|
+
backjump_target = most_recent_satisfier.decision_level - 1
|
|
126
|
+
|
|
127
|
+
backjump_target = min(backjump_target, resolver.solution.decision_level)
|
|
128
|
+
backjump_target = max(backjump_target, 0)
|
|
129
|
+
|
|
130
|
+
if backjump_target == 0:
|
|
131
|
+
raise ResolutionError(
|
|
132
|
+
format_error(current_incompatibility),
|
|
133
|
+
incompatibility=current_incompatibility,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
from_level = resolver.solution.decision_level
|
|
137
|
+
resolver.solution.backtrack(backjump_target)
|
|
138
|
+
resolver.stats.backjumps += 1
|
|
139
|
+
resolver.observer.on_backjump(from_level, backjump_target)
|
|
140
|
+
|
|
141
|
+
# Count only the "affected" package so it gets decided first
|
|
142
|
+
# after restart and its dependencies constrain the culprit.
|
|
143
|
+
affected_package = most_recent_satisfier.package
|
|
144
|
+
resolver.stats.package_conflict_counts[affected_package] += 1
|
|
145
|
+
|
|
146
|
+
update_culprit_counts(
|
|
147
|
+
resolver,
|
|
148
|
+
current_incompatibility,
|
|
149
|
+
affected_package,
|
|
150
|
+
most_recent_satisfier,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
return current_incompatibility
|
|
154
|
+
|
|
155
|
+
# Can't backjump yet: resolve with the satisfier's cause.
|
|
156
|
+
assert most_recent_satisfier.cause is not None
|
|
157
|
+
assert most_recent_satisfier_term is not None
|
|
158
|
+
is_derived = True
|
|
159
|
+
|
|
160
|
+
resolved_terms = prior_cause(
|
|
161
|
+
current_incompatibility,
|
|
162
|
+
most_recent_satisfier.cause,
|
|
163
|
+
most_recent_satisfier_term.package,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
current_incompatibility = Incompatibility(
|
|
167
|
+
resolved_terms,
|
|
168
|
+
cause=IncompatibilityCause.DERIVED,
|
|
169
|
+
cause_left=current_incompatibility,
|
|
170
|
+
cause_right=most_recent_satisfier.cause,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def update_culprit_counts(
|
|
175
|
+
resolver: Resolver[Any, Any],
|
|
176
|
+
incompatibility: Incompatibility[Any, Any],
|
|
177
|
+
affected_package: Any,
|
|
178
|
+
satisfier: Assignment[Any, Any],
|
|
179
|
+
) -> None:
|
|
180
|
+
"""Credit non-affected packages in a learned clause as culprits.
|
|
181
|
+
|
|
182
|
+
Modelled on uv's ConflictTracker (PR #9843). Every non-affected,
|
|
183
|
+
non-root package in the clause is a culprit; for single-term NO_VERSIONS
|
|
184
|
+
clauses we walk the satisfier's cause chain instead. When a culprit
|
|
185
|
+
crosses ``CULPRIT_THRESHOLD`` it gets queued for targeted backtrack.
|
|
186
|
+
"""
|
|
187
|
+
culprit_packages: set[Any] = set()
|
|
188
|
+
for term in incompatibility.terms:
|
|
189
|
+
package = term.package
|
|
190
|
+
if package is ROOT or package == affected_package:
|
|
191
|
+
continue
|
|
192
|
+
culprit_packages.add(package)
|
|
193
|
+
|
|
194
|
+
# Single-term NO_VERSIONS clauses carry the antecedent decisions only
|
|
195
|
+
# via the satisfier's cause; without this, those decisions go uncredited.
|
|
196
|
+
if len(incompatibility.terms) == 1 and satisfier.cause is not None:
|
|
197
|
+
for term in satisfier.cause.terms:
|
|
198
|
+
package = term.package
|
|
199
|
+
if package is ROOT or package == affected_package:
|
|
200
|
+
continue
|
|
201
|
+
culprit_packages.add(package)
|
|
202
|
+
|
|
203
|
+
threshold = resolver.CULPRIT_THRESHOLD
|
|
204
|
+
for package in culprit_packages:
|
|
205
|
+
resolver.stats.package_culprit_counts[package] += 1
|
|
206
|
+
count = resolver.stats.package_culprit_counts[package]
|
|
207
|
+
if (
|
|
208
|
+
count >= threshold
|
|
209
|
+
and count % threshold == 0
|
|
210
|
+
and package not in resolver.pending_targeted_backtrack
|
|
211
|
+
):
|
|
212
|
+
resolver.pending_targeted_backtrack.append(package)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def iterate_force_resolution(
|
|
216
|
+
resolver: Resolver[Any, Any],
|
|
217
|
+
incompatibility: Incompatibility[Any, Any],
|
|
218
|
+
satisfier: Assignment[Any, Any],
|
|
219
|
+
satisfier_term: Term[Any, Any],
|
|
220
|
+
previous_satisfier_level: int,
|
|
221
|
+
) -> tuple[
|
|
222
|
+
Incompatibility[Any, Any],
|
|
223
|
+
Assignment[Any, Any],
|
|
224
|
+
Term[Any, Any],
|
|
225
|
+
int,
|
|
226
|
+
bool,
|
|
227
|
+
]:
|
|
228
|
+
"""Iterate :func:`try_force_resolution_step` while it succeeds.
|
|
229
|
+
|
|
230
|
+
Returns the (possibly-resolved) incompatibility, refreshed satisfier,
|
|
231
|
+
satisfier term, previous-satisfier level, and a ``forced_any`` flag
|
|
232
|
+
indicating whether at least one resolution step happened. Stops when
|
|
233
|
+
``try_force_resolution_step`` declines (returns ``None``).
|
|
234
|
+
"""
|
|
235
|
+
forced_any = False
|
|
236
|
+
|
|
237
|
+
while True:
|
|
238
|
+
forced = try_force_resolution_step(
|
|
239
|
+
resolver, incompatibility, satisfier, satisfier_term
|
|
240
|
+
)
|
|
241
|
+
if forced is None:
|
|
242
|
+
break
|
|
243
|
+
forced_any = True
|
|
244
|
+
incompatibility = forced
|
|
245
|
+
(
|
|
246
|
+
satisfier,
|
|
247
|
+
satisfier_term,
|
|
248
|
+
previous_satisfier_level,
|
|
249
|
+
) = find_most_recent_satisfier(resolver, forced)
|
|
250
|
+
|
|
251
|
+
return (
|
|
252
|
+
incompatibility,
|
|
253
|
+
satisfier,
|
|
254
|
+
satisfier_term,
|
|
255
|
+
previous_satisfier_level,
|
|
256
|
+
forced_any,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def try_force_resolution_step(
|
|
261
|
+
resolver: Resolver[Any, Any],
|
|
262
|
+
incompatibility: Incompatibility[Any, Any],
|
|
263
|
+
satisfier: Assignment[Any, Any],
|
|
264
|
+
satisfier_term: Term[Any, Any],
|
|
265
|
+
) -> Incompatibility[Any, Any] | None:
|
|
266
|
+
"""Resolve a single-term NO_VERSIONS clause once with a soundness gate.
|
|
267
|
+
|
|
268
|
+
Standard PubGrub backjumps to root for single-term clauses, losing the
|
|
269
|
+
supporting decisions; resolving once with the satisfier's cause exposes
|
|
270
|
+
them so later propagation can skip the bad decision.
|
|
271
|
+
|
|
272
|
+
Returns ``None`` when the resolved clause collapses to <2 terms (the
|
|
273
|
+
eliminated package's conditioning would be lost) or when it is not
|
|
274
|
+
assert-eligible.
|
|
275
|
+
"""
|
|
276
|
+
if (
|
|
277
|
+
satisfier.is_decision
|
|
278
|
+
or len(incompatibility.terms) != _SINGLE_TERM
|
|
279
|
+
or satisfier.cause is None
|
|
280
|
+
):
|
|
281
|
+
return None
|
|
282
|
+
|
|
283
|
+
resolved_terms = prior_cause(
|
|
284
|
+
incompatibility, satisfier.cause, satisfier_term.package
|
|
285
|
+
)
|
|
286
|
+
if len(resolved_terms) < _MIN_RESOLVED_TERMS:
|
|
287
|
+
return None
|
|
288
|
+
|
|
289
|
+
forced = Incompatibility(
|
|
290
|
+
resolved_terms,
|
|
291
|
+
cause=IncompatibilityCause.DERIVED,
|
|
292
|
+
cause_left=incompatibility,
|
|
293
|
+
cause_right=satisfier.cause,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
forced_recent, _, forced_prev_level = find_most_recent_satisfier(resolver, forced)
|
|
297
|
+
forced_can_backjump = (
|
|
298
|
+
forced_recent.is_decision or forced_prev_level != forced_recent.decision_level
|
|
299
|
+
)
|
|
300
|
+
if not forced_can_backjump:
|
|
301
|
+
return None
|
|
302
|
+
|
|
303
|
+
return forced
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def find_most_recent_satisfier(
|
|
307
|
+
resolver: Resolver[Any, Any], incompatibility: Incompatibility[Any, Any]
|
|
308
|
+
) -> tuple[Assignment[Any, Any], Term[Any, Any], int]:
|
|
309
|
+
"""Find the most recently assigned satisfier across all terms.
|
|
310
|
+
|
|
311
|
+
Returns ``(satisfier, term, previous_satisfier_level)``. The previous
|
|
312
|
+
level is the highest among the other terms' satisfiers and bounds how
|
|
313
|
+
far back the resolver can jump. Refined when the satisfier is partial
|
|
314
|
+
(earlier assignments also contributed).
|
|
315
|
+
"""
|
|
316
|
+
most_recent: Assignment[Any, Any] | None = None
|
|
317
|
+
most_recent_term: Term[Any, Any] | None = None
|
|
318
|
+
previous_level = 1 # root's level
|
|
319
|
+
|
|
320
|
+
for term in incompatibility.terms:
|
|
321
|
+
satisfier = resolver.solution.satisfier(term)
|
|
322
|
+
if satisfier is None: # pragma: no cover
|
|
323
|
+
unreachable = (
|
|
324
|
+
f"Bug: no satisfier for {term!r} in a satisfied incompatibility"
|
|
325
|
+
)
|
|
326
|
+
raise RuntimeError(unreachable)
|
|
327
|
+
satisfier_index = satisfier.trail_index
|
|
328
|
+
if most_recent is None or (satisfier_index > most_recent.trail_index):
|
|
329
|
+
if most_recent is not None:
|
|
330
|
+
previous_level = max(previous_level, most_recent.decision_level)
|
|
331
|
+
most_recent = satisfier
|
|
332
|
+
most_recent_term = term
|
|
333
|
+
else:
|
|
334
|
+
previous_level = max(previous_level, satisfier.decision_level)
|
|
335
|
+
|
|
336
|
+
if most_recent is None: # pragma: no cover
|
|
337
|
+
unreachable = "Bug: no satisfiers in a satisfied incompatibility"
|
|
338
|
+
raise RuntimeError(unreachable)
|
|
339
|
+
assert most_recent_term is not None
|
|
340
|
+
|
|
341
|
+
if not resolver.solution.satisfier_is_sole(most_recent, most_recent_term):
|
|
342
|
+
previous_level = recompute_previous_level(
|
|
343
|
+
resolver, most_recent, most_recent_term, previous_level
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
return most_recent, most_recent_term, previous_level
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def recompute_previous_level(
|
|
350
|
+
resolver: Resolver[Any, Any],
|
|
351
|
+
satisfier: Assignment[Any, Any],
|
|
352
|
+
satisfier_term: Term[Any, Any],
|
|
353
|
+
current_previous_level: int,
|
|
354
|
+
) -> int:
|
|
355
|
+
"""Refine previous_level when the satisfier is partial.
|
|
356
|
+
|
|
357
|
+
Isolates the satisfier's own contribution (from its cause), subtracts
|
|
358
|
+
it from the term, and folds the remainder's satisfier level in.
|
|
359
|
+
"""
|
|
360
|
+
if satisfier.cause is None:
|
|
361
|
+
return current_previous_level
|
|
362
|
+
|
|
363
|
+
cause_term = None
|
|
364
|
+
for term in satisfier.cause.terms:
|
|
365
|
+
if term.package == satisfier_term.package:
|
|
366
|
+
cause_term = term
|
|
367
|
+
break
|
|
368
|
+
if cause_term is None:
|
|
369
|
+
return current_previous_level
|
|
370
|
+
|
|
371
|
+
individual = cause_term.negate()
|
|
372
|
+
remainder = satisfier_term.intersect(individual.negate())
|
|
373
|
+
if remainder is None or remainder.constraint.is_empty:
|
|
374
|
+
return current_previous_level
|
|
375
|
+
|
|
376
|
+
remainder_satisfier = resolver.solution.satisfier(remainder)
|
|
377
|
+
if remainder_satisfier is not None:
|
|
378
|
+
return max(current_previous_level, remainder_satisfier.decision_level)
|
|
379
|
+
return current_previous_level
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def is_terminal_incompatibility(incompatibility: Incompatibility[Any, Any]) -> bool:
|
|
383
|
+
"""Check whether this incompatibility proves resolution is impossible."""
|
|
384
|
+
if not incompatibility.terms:
|
|
385
|
+
return True
|
|
386
|
+
return all(term.package is ROOT for term in incompatibility.terms)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def maybe_targeted_backtrack(resolver: Resolver[Any, Any]) -> Any | None:
|
|
390
|
+
"""Run :func:`apply_targeted_backtrack` if the gate is open.
|
|
391
|
+
|
|
392
|
+
Gate: pending non-empty AND total conflicts past
|
|
393
|
+
``TARGETED_BT_MIN_CONFLICTS``. Pending culprits are kept across rounds.
|
|
394
|
+
"""
|
|
395
|
+
if (
|
|
396
|
+
resolver.pending_targeted_backtrack
|
|
397
|
+
and resolver.stats.conflicts >= resolver.TARGETED_BT_MIN_CONFLICTS
|
|
398
|
+
):
|
|
399
|
+
return apply_targeted_backtrack(resolver)
|
|
400
|
+
return None
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def maybe_restart(
|
|
404
|
+
resolver: Resolver[Any, Any],
|
|
405
|
+
restart_threshold: int,
|
|
406
|
+
restarts_remaining: int,
|
|
407
|
+
) -> tuple[int, int, bool]:
|
|
408
|
+
"""Restart the solver if any package crossed ``restart_threshold``.
|
|
409
|
+
|
|
410
|
+
Returns ``(new_threshold, new_remaining, restarted)``. Preserves
|
|
411
|
+
``incompatibilities`` and ``package_conflict_counts`` across restart.
|
|
412
|
+
"""
|
|
413
|
+
if restarts_remaining <= 0:
|
|
414
|
+
return restart_threshold, restarts_remaining, False
|
|
415
|
+
|
|
416
|
+
max_count = max(resolver.stats.package_conflict_counts.values(), default=0)
|
|
417
|
+
if max_count < restart_threshold:
|
|
418
|
+
return restart_threshold, restarts_remaining, False
|
|
419
|
+
|
|
420
|
+
resolver.stats.restarts += 1
|
|
421
|
+
resolver.solution = PartialSolution(range_type=resolver.range_type)
|
|
422
|
+
resolver.solution.decide(ROOT, resolver.root_version)
|
|
423
|
+
resolver.pending_targeted_backtrack.clear()
|
|
424
|
+
resolver.stats.targeted_backtracks = 0
|
|
425
|
+
|
|
426
|
+
return restart_threshold * 2, restarts_remaining - 1, True
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def force_targeted_backtrack(
|
|
430
|
+
resolver: Resolver[Any, Any], packages: list[Any]
|
|
431
|
+
) -> Any | None:
|
|
432
|
+
"""Apply a targeted back-track without waiting for the normal gate.
|
|
433
|
+
|
|
434
|
+
Used when the provider supplies direct evidence that the named
|
|
435
|
+
packages are culprits. Bumps each package's culprit count past the
|
|
436
|
+
dominant-culprit threshold, queues it, and applies immediately.
|
|
437
|
+
|
|
438
|
+
Returns the back-jumped package, or ``None`` if the back-track did
|
|
439
|
+
not move the decision level.
|
|
440
|
+
"""
|
|
441
|
+
if not packages:
|
|
442
|
+
return None
|
|
443
|
+
|
|
444
|
+
threshold = resolver.CULPRIT_THRESHOLD
|
|
445
|
+
for package in packages:
|
|
446
|
+
current = resolver.stats.package_culprit_counts[package]
|
|
447
|
+
if current < threshold:
|
|
448
|
+
resolver.stats.package_culprit_counts[package] = threshold
|
|
449
|
+
if package not in resolver.pending_targeted_backtrack:
|
|
450
|
+
resolver.pending_targeted_backtrack.append(package)
|
|
451
|
+
|
|
452
|
+
return apply_targeted_backtrack(resolver)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def apply_targeted_backtrack(resolver: Resolver[Any, Any]) -> Any | None:
|
|
456
|
+
"""Backtrack to before the earliest pending-culprit assignment.
|
|
457
|
+
|
|
458
|
+
Picks the smallest decision-level among queued culprits' first assignments
|
|
459
|
+
(decision OR derivation; derivations count too so propagated culprits in
|
|
460
|
+
cluster conflicts still trigger a backtrack) and jumps to one before it.
|
|
461
|
+
Capped at ``MAX_TARGETED_BACKTRACKS`` per restart segment.
|
|
462
|
+
|
|
463
|
+
Level-1 assignments are skipped to preserve ROOT.
|
|
464
|
+
"""
|
|
465
|
+
if resolver.stats.targeted_backtracks >= resolver.MAX_TARGETED_BACKTRACKS:
|
|
466
|
+
resolver.pending_targeted_backtrack.clear()
|
|
467
|
+
return None
|
|
468
|
+
|
|
469
|
+
target_level = resolver.solution.decision_level
|
|
470
|
+
triggering_package: Any | None = None
|
|
471
|
+
|
|
472
|
+
for package in resolver.pending_targeted_backtrack:
|
|
473
|
+
package_entries = resolver.solution.assignments_for(package)
|
|
474
|
+
# Use the package's earliest non-level-1 assignment: jumping
|
|
475
|
+
# further than that would just undo unrelated work.
|
|
476
|
+
for assignment in package_entries:
|
|
477
|
+
if assignment.decision_level < _TARGETED_BT_MIN_LEVEL:
|
|
478
|
+
continue
|
|
479
|
+
candidate = assignment.decision_level - 1
|
|
480
|
+
if candidate < target_level:
|
|
481
|
+
target_level = candidate
|
|
482
|
+
triggering_package = package
|
|
483
|
+
break
|
|
484
|
+
|
|
485
|
+
resolver.pending_targeted_backtrack.clear()
|
|
486
|
+
if triggering_package is None or target_level >= resolver.solution.decision_level:
|
|
487
|
+
return None
|
|
488
|
+
|
|
489
|
+
resolver.solution.backtrack(target_level)
|
|
490
|
+
resolver.stats.targeted_backtracks += 1
|
|
491
|
+
return triggering_package
|