-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy path_solver.py
More file actions
177 lines (149 loc) · 7.21 KB
/
Copy path_solver.py
File metadata and controls
177 lines (149 loc) · 7.21 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
# SPDX-License-Identifier: MIT
from __future__ import annotations
import logging
from itertools import chain
from typing import TYPE_CHECKING
import cvxpy as cp
import numpy as np
from ._gamestate import GameState
from ._types import DEFAULT_MILP_BACKENDS, MILPSolver, SolverMode, SolverSolution
if TYPE_CHECKING:
from ._ruleset import RuleSet
_logger = logging.getLogger(__name__)
class RummikubSolver:
"""Solvers for finding possible tile placements in Rummikub games.
Builds on the approach described by D. Den Hertog, P. B. Hulshof,
Solving Rummikub Problems by Integer Linear Programming, The Computer
Journal, Volume 49, Issue 6, November 2006, Pages 665-669,
https://doi.org/10.1093/comjnl/bxl033
Adapted to work with different Rummikub rulesets, including having
a different number of possible jokers from repeated number tiles within
the same colour, as well as much better initial tile placement and
joker handling in general.
Creates cvxpy solvers *once* and use parameters to improve efficiency.
"""
def __init__(self, ruleset: RuleSet, backend: MILPSolver | None = None) -> None:
if backend is None:
supported = MILPSolver.supported()
backend = next(d for d in DEFAULT_MILP_BACKENDS if d in supported)
self.backend = backend
# set membership matrix; how many copies of a given tile are present in
# a given set. Each column is a set, each row a tile
slen = len(ruleset.sets)
smatrix = np.zeros((ruleset.tile_count, slen), dtype=np.uint8)
np.add.at( # pyright: ignore[reportUnknownMemberType]
smatrix,
(
np.fromiter(chain.from_iterable(ruleset.sets), np.uint8) - 1,
np.repeat( # pyright: ignore[reportUnknownMemberType]
np.arange(slen), np.fromiter(map(len, ruleset.sets), np.uint16)
),
),
1,
)
# Input parameters: counts for each tile on the table and on the rack
table = self.table = cp.Parameter(ruleset.tile_count, "table", integer=True)
rack = self.rack = cp.Parameter(ruleset.tile_count, "rack", integer=True)
# Output variables: counts per resulting set, and counts per
# tile taken from the rack to be added to the table.
sets = self.sets = cp.Variable(len(ruleset.sets), "sets", integer=True)
tiles = self.tiles = cp.Variable(ruleset.tile_count, "tiles", integer=True)
# Constraints for the optimisation problem
numbertiles = tiles
joker_constraints = []
if ruleset.jokers:
numbertiles, jokers = tiles[:-1], tiles[-1]
joker_constraints = [
# You can place multiple jokers from your rack, but there are
# never more than *ruleset.jokers* of them.
jokers >= 0,
jokers <= ruleset.jokers,
]
constraints: list[cp.Constraint] = [
# Both the table and the rack are non-negative
table >= 0,
rack >= 0,
# placed sets can only be taken from selected rack tiles and what
# was already placed on the table.
smatrix @ sets == table + tiles,
# the selected tiles must all come from your rack
tiles <= rack,
# A given set could appear multiple times, but never more than
# *repeats* times.
sets >= 0,
sets <= ruleset.repeats,
# You can place multiple tiles with the same colour and number
# but there are never more than *ruleset.repeats* of them.
numbertiles >= 0,
numbertiles <= ruleset.repeats,
# variable joker constraints for the current ruleset
*joker_constraints,
]
p: dict[SolverMode, cp.Problem] = {}
# Problem solver maximising number of tiles placed
p[SolverMode.TILE_COUNT] = cp.Problem(cp.Maximize(cp.sum(tiles)), constraints) # type: ignore[reportUnknownMemberType]
# Problem solver maximising the total value of tiles placed
tilevalue = np.tile( # pyright: ignore[reportUnknownMemberType]
np.arange(ruleset.numbers, dtype=np.uint16) + 1, ruleset.colours
)
if ruleset.jokers:
tilevalue = np.append(tilevalue, 0) # pyright: ignore[reportUnknownMemberType]
p[SolverMode.TOTAL_VALUE] = cp.Problem(
cp.Maximize(cp.sum(tiles[:, np.newaxis] @ tilevalue[np.newaxis, :])), # type: ignore[reportUnknownMemberType]
constraints,
)
# Problem solver used for the opening move ("initial meld").
# Initial meld scoring is based entirely on the sets formed, and must
# be equal to or higher than the minimal score. Maximize the tile count
# _without jokers_.
setvalue = np.array(ruleset.set_values, dtype=np.uint16)
initial_constraints = [
*constraints,
sets @ setvalue >= ruleset.min_initial_value,
]
p[SolverMode.INITIAL] = cp.Problem(
cp.Maximize(cp.sum(numbertiles)), # type: ignore[reportUnknownMemberType]
initial_constraints,
)
self._problems = p
def __call__(self, mode: SolverMode, state: GameState) -> SolverSolution:
"""Find a solution for the given game state.
Uses the appropriate objective for the given solver mode, and takes
the rack tile count and table tile count from state.
"""
# set parameters
self.rack.value = state.rack_array
if mode is SolverMode.INITIAL:
# can't use tiles on the table, set all counts to 0
self.table.value = np.zeros_like(state.table_array)
else:
self.table.value = state.table_array
prob = self._problems[mode]
try:
value = prob.solve(solver=self.backend) # type: ignore[reportUnknownMemberType]
except cp.SolverError: # pragma: no cover
# solver threw a hissyfit, treat as 'no solution'
_logger.debug(
f"{self.backend} threw an error while trying to solve, treating as no solution"
)
value = float("-inf")
if TYPE_CHECKING:
assert isinstance(value, float)
if np.isinf(value):
# no solution for the problem (e.g. no combination of tiles on
# the rack leads to a valid set or has enough points when opening)
return SolverSolution((), ())
# convert index counts to repeated indices, as Python scalars
# similar to what Counts.elements() produces.
if TYPE_CHECKING:
assert self.tiles.value is not None
tiles = np.rint(self.tiles.value).astype(int)
(tidx,) = tiles.nonzero()
# add 1 to the indices to get tile numbers
selected_tiles = np.repeat(tidx + 1, tiles[tidx]).tolist() # pyright: ignore[reportUnknownMemberType]
if TYPE_CHECKING:
assert self.sets.value is not None
sets = np.rint(self.sets.value).astype(int)
(sidx,) = sets.nonzero()
selected_sets = np.repeat(sidx, sets[sidx]).tolist() # pyright: ignore[reportUnknownMemberType]
return SolverSolution(selected_tiles, selected_sets)