pathos-ai 0.1.0__py3-none-any.whl
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.
- pathos/__init__.py +5 -0
- pathos/algorithms/__init__.py +7 -0
- pathos/algorithms/adversarial.py +240 -0
- pathos/algorithms/base.py +65 -0
- pathos/algorithms/csp.py +196 -0
- pathos/algorithms/evolutionary.py +360 -0
- pathos/algorithms/informed.py +413 -0
- pathos/algorithms/local.py +183 -0
- pathos/algorithms/uninformed.py +219 -0
- pathos/core/__init__.py +3 -0
- pathos/core/cancel.py +34 -0
- pathos/core/capabilities.py +14 -0
- pathos/core/parallel.py +10 -0
- pathos/core/result.py +52 -0
- pathos/core/solver.py +122 -0
- pathos/core/space.py +172 -0
- pathos/spaces/__init__.py +4 -0
- pathos/spaces/csp.py +89 -0
- pathos/spaces/game.py +14 -0
- pathos/spaces/graph.py +41 -0
- pathos/spaces/tour.py +32 -0
- pathos_ai-0.1.0.dist-info/METADATA +265 -0
- pathos_ai-0.1.0.dist-info/RECORD +25 -0
- pathos_ai-0.1.0.dist-info/WHEEL +4 -0
- pathos_ai-0.1.0.dist-info/licenses/LICENSE +21 -0
pathos/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
from pathos.algorithms.base import Algorithm
|
|
2
|
+
from pathos.algorithms.uninformed import BFS, DFS, IDDFS, UCS
|
|
3
|
+
from pathos.algorithms.informed import AStar, IDAstar, GreedyBestFirst, WeightedAStar, BidirectionalAStar
|
|
4
|
+
from pathos.algorithms.local import HillClimbing, TabuSearch, LocalBeamSearch
|
|
5
|
+
from pathos.algorithms.evolutionary import SimulatedAnnealing, GeneticAlgorithm, DifferentialEvolution
|
|
6
|
+
from pathos.algorithms.adversarial import Minimax, AlphaBeta, Negamax, MCTS
|
|
7
|
+
from pathos.algorithms.csp import Backtracking, ForwardChecking, AC3, MinConflicts
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import time
|
|
3
|
+
import math
|
|
4
|
+
import random
|
|
5
|
+
from typing import Any
|
|
6
|
+
from pathos.algorithms.base import Algorithm
|
|
7
|
+
from pathos.core.capabilities import Capability
|
|
8
|
+
from pathos.core.result import SearchResult
|
|
9
|
+
from pathos.core.solver import register
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@register
|
|
13
|
+
class Minimax(Algorithm):
|
|
14
|
+
"""Minimax search for two-player zero-sum games.
|
|
15
|
+
|
|
16
|
+
Requires: successors, terminal, utility.
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
requires: Capability set needed.
|
|
20
|
+
power_rank: 40.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
requires = frozenset({Capability.SUCCESSORS, Capability.TERMINAL, Capability.UTILITY})
|
|
24
|
+
power_rank = 40
|
|
25
|
+
|
|
26
|
+
def __init__(self, space: Any, max_depth: int = 100) -> None:
|
|
27
|
+
super().__init__(space)
|
|
28
|
+
self.max_depth = max_depth
|
|
29
|
+
|
|
30
|
+
def _minimax(self, state: Any, depth: int, is_max: bool) -> tuple[float, Any]:
|
|
31
|
+
if self.space._terminal(state) or depth == 0:
|
|
32
|
+
player = self.space._maximizing_player
|
|
33
|
+
return self.space._utility(state, player), state
|
|
34
|
+
moves = list(self.space._successors(state))
|
|
35
|
+
if not moves:
|
|
36
|
+
return self.space._utility(state, self.space._maximizing_player), state
|
|
37
|
+
if is_max:
|
|
38
|
+
best_val, best_state = -math.inf, None
|
|
39
|
+
for _, child in moves:
|
|
40
|
+
val, leaf = self._minimax(child, depth - 1, False)
|
|
41
|
+
if val > best_val:
|
|
42
|
+
best_val, best_state = val, leaf
|
|
43
|
+
return best_val, best_state
|
|
44
|
+
else:
|
|
45
|
+
best_val, best_state = math.inf, None
|
|
46
|
+
for _, child in moves:
|
|
47
|
+
val, leaf = self._minimax(child, depth - 1, True)
|
|
48
|
+
if val < best_val:
|
|
49
|
+
best_val, best_state = val, leaf
|
|
50
|
+
return best_val, best_state
|
|
51
|
+
|
|
52
|
+
def solve(self) -> SearchResult:
|
|
53
|
+
t0 = time.perf_counter()
|
|
54
|
+
val, state = self._minimax(self.space._initial, self.max_depth, True)
|
|
55
|
+
return SearchResult(state, None, val, "Minimax", 0, time.perf_counter() - t0, state is not None)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@register
|
|
59
|
+
class AlphaBeta(Algorithm):
|
|
60
|
+
"""Alpha-Beta pruning — Minimax with branch pruning for efficiency.
|
|
61
|
+
|
|
62
|
+
Requires: successors, terminal, utility.
|
|
63
|
+
|
|
64
|
+
Attributes:
|
|
65
|
+
requires: Capability set needed.
|
|
66
|
+
power_rank: 45 (preferred over Minimax when available).
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
requires = frozenset({Capability.SUCCESSORS, Capability.TERMINAL, Capability.UTILITY})
|
|
70
|
+
power_rank = 45
|
|
71
|
+
|
|
72
|
+
def __init__(self, space: Any, max_depth: int = 100) -> None:
|
|
73
|
+
super().__init__(space)
|
|
74
|
+
self.max_depth = max_depth
|
|
75
|
+
|
|
76
|
+
def _ab(self, state: Any, depth: int, alpha: float, beta: float, is_max: bool) -> tuple[float, Any]:
|
|
77
|
+
if self.space._terminal(state) or depth == 0:
|
|
78
|
+
return self.space._utility(state, self.space._maximizing_player), state
|
|
79
|
+
moves = list(self.space._successors(state))
|
|
80
|
+
if not moves:
|
|
81
|
+
return self.space._utility(state, self.space._maximizing_player), state
|
|
82
|
+
best_state = None
|
|
83
|
+
if is_max:
|
|
84
|
+
val = -math.inf
|
|
85
|
+
for _, child in moves:
|
|
86
|
+
child_val, child_leaf = self._ab(child, depth - 1, alpha, beta, False)
|
|
87
|
+
if child_val > val:
|
|
88
|
+
val, best_state = child_val, child_leaf
|
|
89
|
+
alpha = max(alpha, val)
|
|
90
|
+
if alpha >= beta:
|
|
91
|
+
break
|
|
92
|
+
else:
|
|
93
|
+
val = math.inf
|
|
94
|
+
for _, child in moves:
|
|
95
|
+
child_val, child_leaf = self._ab(child, depth - 1, alpha, beta, True)
|
|
96
|
+
if child_val < val:
|
|
97
|
+
val, best_state = child_val, child_leaf
|
|
98
|
+
beta = min(beta, val)
|
|
99
|
+
if alpha >= beta:
|
|
100
|
+
break
|
|
101
|
+
return val, best_state
|
|
102
|
+
|
|
103
|
+
def solve(self) -> SearchResult:
|
|
104
|
+
t0 = time.perf_counter()
|
|
105
|
+
val, state = self._ab(self.space._initial, self.max_depth, -math.inf, math.inf, True)
|
|
106
|
+
return SearchResult(state, None, val, "AlphaBeta", 0, time.perf_counter() - t0, state is not None)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@register
|
|
110
|
+
class Negamax(Algorithm):
|
|
111
|
+
"""Negamax — Minimax variant using score negation for multi-player support.
|
|
112
|
+
|
|
113
|
+
Requires: successors, terminal, utility.
|
|
114
|
+
|
|
115
|
+
Attributes:
|
|
116
|
+
requires: Capability set needed.
|
|
117
|
+
power_rank: 42.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
requires = frozenset({Capability.SUCCESSORS, Capability.TERMINAL, Capability.UTILITY})
|
|
121
|
+
power_rank = 42
|
|
122
|
+
|
|
123
|
+
def __init__(self, space: Any, max_depth: int = 100) -> None:
|
|
124
|
+
super().__init__(space)
|
|
125
|
+
self.max_depth = max_depth
|
|
126
|
+
|
|
127
|
+
def _negamax(self, state: Any, depth: int, alpha: float, beta: float, player: int) -> tuple[float, Any]:
|
|
128
|
+
if self.space._terminal(state) or depth == 0:
|
|
129
|
+
val = self.space._utility(state, player)
|
|
130
|
+
return val, state
|
|
131
|
+
moves = list(self.space._successors(state))
|
|
132
|
+
if not moves:
|
|
133
|
+
return self.space._utility(state, player), state
|
|
134
|
+
best_val, best_state = -math.inf, moves[0][1]
|
|
135
|
+
next_player = (player + 1) % self.space._players
|
|
136
|
+
for _, child in moves:
|
|
137
|
+
child_val, _ = self._negamax(child, depth - 1, -beta, -alpha, next_player)
|
|
138
|
+
if -child_val > best_val:
|
|
139
|
+
best_val, best_state = -child_val, child
|
|
140
|
+
alpha = max(alpha, best_val)
|
|
141
|
+
if alpha >= beta:
|
|
142
|
+
break
|
|
143
|
+
return best_val, best_state
|
|
144
|
+
|
|
145
|
+
def solve(self) -> SearchResult:
|
|
146
|
+
t0 = time.perf_counter()
|
|
147
|
+
val, state = self._negamax(
|
|
148
|
+
self.space._initial, self.max_depth, -math.inf, math.inf,
|
|
149
|
+
self.space._maximizing_player
|
|
150
|
+
)
|
|
151
|
+
return SearchResult(state, None, val, "Negamax", 0, time.perf_counter() - t0, state is not None)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class _MCTSNode:
|
|
155
|
+
__slots__ = ("state", "parent", "children", "visits", "value", "untried")
|
|
156
|
+
|
|
157
|
+
def __init__(self, state: Any, parent: _MCTSNode | None, space: Any) -> None:
|
|
158
|
+
self.state = state
|
|
159
|
+
self.parent = parent
|
|
160
|
+
self.children: list[_MCTSNode] = []
|
|
161
|
+
self.visits = 0
|
|
162
|
+
self.value = 0.0
|
|
163
|
+
self.untried = list(space._successors(state)) if not space._terminal(state) else []
|
|
164
|
+
|
|
165
|
+
def uct_score(self, c: float = 1.414) -> float:
|
|
166
|
+
if self.visits == 0:
|
|
167
|
+
return math.inf
|
|
168
|
+
assert self.parent is not None
|
|
169
|
+
return self.value / self.visits + c * math.sqrt(math.log(self.parent.visits) / self.visits)
|
|
170
|
+
|
|
171
|
+
def best_child(self) -> _MCTSNode:
|
|
172
|
+
return max(self.children, key=lambda n: n.uct_score())
|
|
173
|
+
|
|
174
|
+
def is_fully_expanded(self) -> bool:
|
|
175
|
+
return len(self.untried) == 0
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@register
|
|
179
|
+
class MCTS(Algorithm):
|
|
180
|
+
"""Monte Carlo Tree Search — simulation-based game tree exploration.
|
|
181
|
+
|
|
182
|
+
Requires: successors, terminal, utility.
|
|
183
|
+
|
|
184
|
+
Attributes:
|
|
185
|
+
requires: Capability set needed.
|
|
186
|
+
power_rank: 43.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
requires = frozenset({Capability.SUCCESSORS, Capability.TERMINAL, Capability.UTILITY})
|
|
190
|
+
power_rank = 43
|
|
191
|
+
|
|
192
|
+
def __init__(self, space: Any, iterations: int = 1000) -> None:
|
|
193
|
+
super().__init__(space)
|
|
194
|
+
self.iterations = iterations
|
|
195
|
+
|
|
196
|
+
def solve(self) -> SearchResult:
|
|
197
|
+
t0 = time.perf_counter()
|
|
198
|
+
root = _MCTSNode(self.space._initial, None, self.space)
|
|
199
|
+
|
|
200
|
+
for _ in range(self.iterations):
|
|
201
|
+
node = self._select(root)
|
|
202
|
+
if not self.space._terminal(node.state):
|
|
203
|
+
node = self._expand(node)
|
|
204
|
+
reward = self._simulate(node.state)
|
|
205
|
+
self._backprop(node, reward)
|
|
206
|
+
|
|
207
|
+
best = max(root.children, key=lambda n: n.visits) if root.children else root
|
|
208
|
+
return SearchResult(
|
|
209
|
+
best.state, None,
|
|
210
|
+
self.space._utility(best.state, self.space._maximizing_player),
|
|
211
|
+
"MCTS", self.iterations, time.perf_counter() - t0, True,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
def _select(self, node: _MCTSNode) -> _MCTSNode:
|
|
215
|
+
while not self.space._terminal(node.state) and node.is_fully_expanded():
|
|
216
|
+
node = node.best_child()
|
|
217
|
+
return node
|
|
218
|
+
|
|
219
|
+
def _expand(self, node: _MCTSNode) -> _MCTSNode:
|
|
220
|
+
action, child_state = node.untried.pop()
|
|
221
|
+
child = _MCTSNode(child_state, node, self.space)
|
|
222
|
+
node.children.append(child)
|
|
223
|
+
return child
|
|
224
|
+
|
|
225
|
+
def _simulate(self, state: Any) -> float:
|
|
226
|
+
depth = 0
|
|
227
|
+
while not self.space._terminal(state) and depth < 50:
|
|
228
|
+
moves = list(self.space._successors(state))
|
|
229
|
+
if not moves:
|
|
230
|
+
break
|
|
231
|
+
_, state = random.choice(moves)
|
|
232
|
+
depth += 1
|
|
233
|
+
result: float = self.space._utility(state, self.space._maximizing_player)
|
|
234
|
+
return result
|
|
235
|
+
|
|
236
|
+
def _backprop(self, node: _MCTSNode | None, reward: float) -> None:
|
|
237
|
+
while node is not None:
|
|
238
|
+
node.visits += 1
|
|
239
|
+
node.value += reward
|
|
240
|
+
node = node.parent
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from typing import Any, TYPE_CHECKING
|
|
4
|
+
from pathos.core.capabilities import Capability
|
|
5
|
+
from pathos.core.result import SearchResult
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from pathos.core.space import Space
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Algorithm(ABC):
|
|
12
|
+
requires: frozenset[Capability] = frozenset()
|
|
13
|
+
power_rank: int = 0 # higher = preferred when multiple are compatible
|
|
14
|
+
|
|
15
|
+
def __init__(self, space: Space) -> None:
|
|
16
|
+
missing = self.requires - space.capabilities
|
|
17
|
+
if missing:
|
|
18
|
+
raise ValueError(
|
|
19
|
+
f"{self.__class__.__name__} requires capabilities: "
|
|
20
|
+
f"{', '.join(c.name for c in missing)}"
|
|
21
|
+
)
|
|
22
|
+
self.space = space
|
|
23
|
+
self._n_workers: int = space._n_workers
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def solve(self) -> SearchResult:
|
|
27
|
+
...
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def compatible_with(cls, space: Space) -> bool:
|
|
31
|
+
return cls.requires <= space.capabilities
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def score_for(cls, space: Space) -> float:
|
|
35
|
+
"""Context-aware preference score for `space`. Higher = preferred.
|
|
36
|
+
|
|
37
|
+
The auto-solver picks the compatible algorithm with the highest
|
|
38
|
+
`score_for(space)`. Defaults to `float(power_rank)`, so any
|
|
39
|
+
algorithm that doesn't care about problem context keeps its
|
|
40
|
+
existing fixed-rank behavior.
|
|
41
|
+
|
|
42
|
+
Override to encode self-knowledge that isn't captured by the
|
|
43
|
+
coarse static rank — for example, "I work well on small problems
|
|
44
|
+
but lose to a sibling on large ones", or "without `@successors`
|
|
45
|
+
I degenerate to a no-op, so cede to siblings that don't need it".
|
|
46
|
+
|
|
47
|
+
Returning a value below `power_rank` lets the algorithm cede to
|
|
48
|
+
better-suited siblings without changing its global ordering.
|
|
49
|
+
"""
|
|
50
|
+
return float(cls.power_rank)
|
|
51
|
+
|
|
52
|
+
def _goal_reached(self, state: Any) -> bool:
|
|
53
|
+
"""Whether `state` satisfies the space's declared goal predicate.
|
|
54
|
+
|
|
55
|
+
If the space has no GOAL capability, returns True — the algorithm
|
|
56
|
+
terminated by its own stopping rule and there's no goal to check.
|
|
57
|
+
Otherwise consults `space._goal(state)`.
|
|
58
|
+
|
|
59
|
+
Used by local-search / metaheuristic algorithms (HC, Tabu, Beam, SA,
|
|
60
|
+
GA, DE) so they can't report `found=True` on a goal-bearing problem
|
|
61
|
+
when the best state they reached doesn't actually satisfy the goal.
|
|
62
|
+
"""
|
|
63
|
+
if Capability.GOAL in self.space.capabilities:
|
|
64
|
+
return bool(self.space._goal(state))
|
|
65
|
+
return True
|
pathos/algorithms/csp.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import time
|
|
3
|
+
import random
|
|
4
|
+
from typing import Any, cast
|
|
5
|
+
from pathos.algorithms.base import Algorithm
|
|
6
|
+
from pathos.core.capabilities import Capability
|
|
7
|
+
from pathos.core.result import SearchResult
|
|
8
|
+
from pathos.core.solver import register
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _is_csp_shaped(space: Any) -> bool:
|
|
12
|
+
"""True if state is a partial-assignment dict — the precondition for the
|
|
13
|
+
incremental-extension recursion used by Backtracking / ForwardChecking /
|
|
14
|
+
MinConflicts. Without this guard they're offered for any
|
|
15
|
+
successors+goal space and recurse forever (8-puzzle: RecursionError) or
|
|
16
|
+
silent-fail (MinConflicts on plain Space)."""
|
|
17
|
+
return isinstance(space._initial, dict)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@register
|
|
21
|
+
class Backtracking(Algorithm):
|
|
22
|
+
"""Backtracking search — systematic recursive CSP solver.
|
|
23
|
+
|
|
24
|
+
Uses the space's successors as CSP expansion (typically via CSPSpace).
|
|
25
|
+
|
|
26
|
+
Requires: successors, goal.
|
|
27
|
+
|
|
28
|
+
Attributes:
|
|
29
|
+
requires: Capability set needed.
|
|
30
|
+
power_rank: 9.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
requires = frozenset({Capability.SUCCESSORS, Capability.GOAL})
|
|
34
|
+
power_rank = 9
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def compatible_with(cls, space: Any) -> bool:
|
|
38
|
+
return super().compatible_with(space) and _is_csp_shaped(space)
|
|
39
|
+
|
|
40
|
+
def _bt(self, state: Any, expanded: list[int]) -> Any | None:
|
|
41
|
+
if self.space._goal(state):
|
|
42
|
+
return state
|
|
43
|
+
for _, child in self.space._successors(state):
|
|
44
|
+
expanded[0] += 1
|
|
45
|
+
result = self._bt(child, expanded)
|
|
46
|
+
if result is not None:
|
|
47
|
+
return result
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
def solve(self) -> SearchResult:
|
|
51
|
+
t0 = time.perf_counter()
|
|
52
|
+
expanded = [0]
|
|
53
|
+
result = self._bt(self.space._initial, expanded)
|
|
54
|
+
elapsed = time.perf_counter() - t0
|
|
55
|
+
if result is not None:
|
|
56
|
+
return SearchResult(result, None, None, "Backtracking", expanded[0], elapsed, True)
|
|
57
|
+
return SearchResult.not_found("Backtracking", expanded[0], elapsed)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@register
|
|
61
|
+
class ForwardChecking(Algorithm):
|
|
62
|
+
"""Forward Checking — Backtracking with look-ahead pruning.
|
|
63
|
+
|
|
64
|
+
Before recursing, checks that each child has at least one valid successor.
|
|
65
|
+
|
|
66
|
+
The current look-ahead is shallow (a child is pruned only when it has no
|
|
67
|
+
successors and is not itself a goal), so node counts match plain
|
|
68
|
+
Backtracking and the constant factor is higher. Until real domain-level
|
|
69
|
+
pruning is implemented, FC is ranked below Backtracking so the auto-
|
|
70
|
+
solver picks the faster of the two.
|
|
71
|
+
|
|
72
|
+
Requires: successors, goal.
|
|
73
|
+
|
|
74
|
+
Attributes:
|
|
75
|
+
requires: Capability set needed.
|
|
76
|
+
power_rank: 8 (below Backtracking's 9).
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
requires = frozenset({Capability.SUCCESSORS, Capability.GOAL})
|
|
80
|
+
power_rank = 8
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def compatible_with(cls, space: Any) -> bool:
|
|
84
|
+
return super().compatible_with(space) and _is_csp_shaped(space)
|
|
85
|
+
|
|
86
|
+
def _fc(self, state: Any, expanded: list[int]) -> Any | None:
|
|
87
|
+
if self.space._goal(state):
|
|
88
|
+
return state
|
|
89
|
+
children = list(self.space._successors(state))
|
|
90
|
+
if not children:
|
|
91
|
+
return None
|
|
92
|
+
for _, child in children:
|
|
93
|
+
expanded[0] += 1
|
|
94
|
+
# check if any successor exists from child (look-ahead)
|
|
95
|
+
future = list(self.space._successors(child))
|
|
96
|
+
if future or self.space._goal(child):
|
|
97
|
+
result = self._fc(child, expanded)
|
|
98
|
+
if result is not None:
|
|
99
|
+
return result
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
def solve(self) -> SearchResult:
|
|
103
|
+
t0 = time.perf_counter()
|
|
104
|
+
expanded = [0]
|
|
105
|
+
result = self._fc(self.space._initial, expanded)
|
|
106
|
+
elapsed = time.perf_counter() - t0
|
|
107
|
+
if result is not None:
|
|
108
|
+
return SearchResult(result, None, None, "ForwardChecking", expanded[0], elapsed, True)
|
|
109
|
+
return SearchResult.not_found("ForwardChecking", expanded[0], elapsed)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class AC3(Algorithm):
|
|
113
|
+
"""AC-3 arc consistency — domain-pruning preprocessor for CSPs.
|
|
114
|
+
|
|
115
|
+
AC-3 is not a stand-alone CSP solver: it iterates over arcs and prunes
|
|
116
|
+
domain values that can't satisfy any binary constraint, then hands off
|
|
117
|
+
a (possibly smaller) problem to Backtracking / ForwardChecking. It is
|
|
118
|
+
intentionally NOT @register'd so the auto-selector won't pick it as a
|
|
119
|
+
terminal solver — its `solve()` returns the pruned domains, not an
|
|
120
|
+
assignment, so power_rank=22 would mis-rank it above MinConflicts.
|
|
121
|
+
|
|
122
|
+
Use directly: `AC3(csp_space).solve()` returns a SearchResult whose
|
|
123
|
+
`.solution` is the pruned-domain dict. Production code typically
|
|
124
|
+
composes AC-3 with a Backtracking-style solver — see issue tracker
|
|
125
|
+
for the planned `pathos.algorithms.preprocessors` API.
|
|
126
|
+
"""
|
|
127
|
+
requires = frozenset({Capability.VARIABLES, Capability.DOMAINS, Capability.CONSTRAINTS})
|
|
128
|
+
power_rank = 22
|
|
129
|
+
|
|
130
|
+
def solve(self) -> SearchResult:
|
|
131
|
+
from pathos.spaces.csp import CSPSpace
|
|
132
|
+
t0 = time.perf_counter()
|
|
133
|
+
# This is invoked by CSPSpace which sets up the variables/domains/constraints
|
|
134
|
+
csp = cast(CSPSpace, self.space)
|
|
135
|
+
variables = csp._variables()
|
|
136
|
+
domains: dict[Any, list[Any]] = {v: list(csp._domain(v)) for v in variables}
|
|
137
|
+
arcs = [(xi, xj) for xi in variables for xj in variables if xi != xj]
|
|
138
|
+
queue = list(arcs)
|
|
139
|
+
|
|
140
|
+
while queue:
|
|
141
|
+
xi, xj = queue.pop(0)
|
|
142
|
+
if self._revise(domains, xi, xj, csp):
|
|
143
|
+
if not domains[xi]:
|
|
144
|
+
return SearchResult.not_found("AC3", 0, time.perf_counter() - t0)
|
|
145
|
+
for xk in variables:
|
|
146
|
+
if xk != xi and xk != xj:
|
|
147
|
+
queue.append((xk, xi))
|
|
148
|
+
|
|
149
|
+
return SearchResult(domains, None, None, "AC3", 0, time.perf_counter() - t0, True)
|
|
150
|
+
|
|
151
|
+
def _revise(self, domains: dict[Any, list[Any]], xi: Any, xj: Any, csp: Any) -> bool:
|
|
152
|
+
revised = False
|
|
153
|
+
for x in domains[xi][:]:
|
|
154
|
+
if not any(csp._constraints({xi: x, xj: y}) for y in domains[xj]):
|
|
155
|
+
domains[xi].remove(x)
|
|
156
|
+
revised = True
|
|
157
|
+
return revised
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@register
|
|
161
|
+
class MinConflicts(Algorithm):
|
|
162
|
+
"""Min-Conflicts heuristic — local repair CSP solver.
|
|
163
|
+
|
|
164
|
+
Repeatedly selects the neighbor that minimizes constraint violations.
|
|
165
|
+
|
|
166
|
+
Requires: successors, goal, evaluate.
|
|
167
|
+
|
|
168
|
+
Attributes:
|
|
169
|
+
requires: Capability set needed.
|
|
170
|
+
power_rank: 19.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
requires = frozenset({Capability.SUCCESSORS, Capability.GOAL, Capability.EVALUATE})
|
|
174
|
+
power_rank = 19
|
|
175
|
+
|
|
176
|
+
@classmethod
|
|
177
|
+
def compatible_with(cls, space: Any) -> bool:
|
|
178
|
+
return super().compatible_with(space) and _is_csp_shaped(space)
|
|
179
|
+
|
|
180
|
+
def __init__(self, space: Any, max_iter: int = 1000) -> None:
|
|
181
|
+
super().__init__(space)
|
|
182
|
+
self.max_iter = max_iter
|
|
183
|
+
|
|
184
|
+
def solve(self) -> SearchResult:
|
|
185
|
+
t0 = time.perf_counter()
|
|
186
|
+
current = self.space._initial
|
|
187
|
+
|
|
188
|
+
for i in range(self.max_iter):
|
|
189
|
+
if self.space._goal(current):
|
|
190
|
+
return SearchResult(current, None, 0.0, "MinConflicts", i, time.perf_counter() - t0, True)
|
|
191
|
+
neighbors = list(self.space._successors(current))
|
|
192
|
+
if not neighbors:
|
|
193
|
+
break
|
|
194
|
+
_, current = min(neighbors, key=lambda ac: self.space._evaluate(ac[1]))
|
|
195
|
+
|
|
196
|
+
return SearchResult.not_found("MinConflicts", self.max_iter, time.perf_counter() - t0)
|