pycgt 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.
- pycgt/__init__.py +130 -0
- pycgt/_piecewise.py +208 -0
- pycgt/game.py +341 -0
- pycgt/notation.py +185 -0
- pycgt/py.typed +1 -0
- pycgt/reduced.py +64 -0
- pycgt/rulesets/__init__.py +10 -0
- pycgt/rulesets/cram.py +34 -0
- pycgt/rulesets/domineering.py +42 -0
- pycgt/rulesets/grid.py +188 -0
- pycgt/rulesets/nim.py +62 -0
- pycgt/stops.py +108 -0
- pycgt/thermal.py +205 -0
- pycgt/values.py +202 -0
- pycgt-0.1.0.dist-info/METADATA +153 -0
- pycgt-0.1.0.dist-info/RECORD +19 -0
- pycgt-0.1.0.dist-info/WHEEL +5 -0
- pycgt-0.1.0.dist-info/licenses/LICENSE +21 -0
- pycgt-0.1.0.dist-info/top_level.txt +1 -0
pycgt/__init__.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""pycgt: combinatorial game theory in pure Python.
|
|
2
|
+
|
|
3
|
+
Short games are exact objects built from the empty game up, so nothing here
|
|
4
|
+
approximates: two values are equal, or one is greater, or they are *confused*
|
|
5
|
+
with each other. There is no tolerance to set and no rounding.
|
|
6
|
+
|
|
7
|
+
>>> from pycgt import parse, render
|
|
8
|
+
>>> render(parse("{1|-1}") + parse("{1|-1}"))
|
|
9
|
+
'0'
|
|
10
|
+
>>> from pycgt.rulesets import domineering
|
|
11
|
+
>>> render(domineering.rectangle(2, 4))
|
|
12
|
+
'Miny(2)'
|
|
13
|
+
>>> from pycgt import temperature
|
|
14
|
+
>>> temperature(domineering.rectangle(2, 3))
|
|
15
|
+
Fraction(5, 4)
|
|
16
|
+
|
|
17
|
+
Every :class:`Game` returned by this package is in canonical form, so ``==``
|
|
18
|
+
means value equality.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from .game import (
|
|
22
|
+
ZERO,
|
|
23
|
+
Game,
|
|
24
|
+
Outcome,
|
|
25
|
+
Relation,
|
|
26
|
+
add,
|
|
27
|
+
birthday,
|
|
28
|
+
canonical,
|
|
29
|
+
compare,
|
|
30
|
+
confused,
|
|
31
|
+
equals,
|
|
32
|
+
game,
|
|
33
|
+
geq,
|
|
34
|
+
greater,
|
|
35
|
+
leq,
|
|
36
|
+
multiple,
|
|
37
|
+
negate,
|
|
38
|
+
outcome,
|
|
39
|
+
)
|
|
40
|
+
from .notation import parse, render
|
|
41
|
+
from .reduced import is_reduced, ish, reduced_canonical_form
|
|
42
|
+
from .stops import (
|
|
43
|
+
confusion_interval,
|
|
44
|
+
is_hot,
|
|
45
|
+
is_infinitesimal,
|
|
46
|
+
is_tepid,
|
|
47
|
+
left_stop,
|
|
48
|
+
number_part,
|
|
49
|
+
right_stop,
|
|
50
|
+
stops,
|
|
51
|
+
)
|
|
52
|
+
from .thermal import cool, heat, mean, overheat, temperature, thermograph
|
|
53
|
+
from .values import (
|
|
54
|
+
DOWN,
|
|
55
|
+
STAR,
|
|
56
|
+
UP,
|
|
57
|
+
as_number,
|
|
58
|
+
integer,
|
|
59
|
+
is_number,
|
|
60
|
+
miny,
|
|
61
|
+
nimber,
|
|
62
|
+
number,
|
|
63
|
+
plus_minus,
|
|
64
|
+
simplest_between,
|
|
65
|
+
switch,
|
|
66
|
+
tiny,
|
|
67
|
+
up_multiple,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
__version__ = "0.1.0"
|
|
71
|
+
|
|
72
|
+
__all__ = [
|
|
73
|
+
# core
|
|
74
|
+
"Game",
|
|
75
|
+
"Outcome",
|
|
76
|
+
"Relation",
|
|
77
|
+
"ZERO",
|
|
78
|
+
"game",
|
|
79
|
+
"canonical",
|
|
80
|
+
"add",
|
|
81
|
+
"negate",
|
|
82
|
+
"multiple",
|
|
83
|
+
"birthday",
|
|
84
|
+
# order
|
|
85
|
+
"geq",
|
|
86
|
+
"leq",
|
|
87
|
+
"equals",
|
|
88
|
+
"greater",
|
|
89
|
+
"confused",
|
|
90
|
+
"compare",
|
|
91
|
+
"outcome",
|
|
92
|
+
# numbers and named values
|
|
93
|
+
"number",
|
|
94
|
+
"integer",
|
|
95
|
+
"as_number",
|
|
96
|
+
"is_number",
|
|
97
|
+
"simplest_between",
|
|
98
|
+
"STAR",
|
|
99
|
+
"UP",
|
|
100
|
+
"DOWN",
|
|
101
|
+
"nimber",
|
|
102
|
+
"up_multiple",
|
|
103
|
+
"switch",
|
|
104
|
+
"plus_minus",
|
|
105
|
+
"tiny",
|
|
106
|
+
"miny",
|
|
107
|
+
# stops
|
|
108
|
+
"left_stop",
|
|
109
|
+
"right_stop",
|
|
110
|
+
"stops",
|
|
111
|
+
"confusion_interval",
|
|
112
|
+
"is_infinitesimal",
|
|
113
|
+
"is_hot",
|
|
114
|
+
"is_tepid",
|
|
115
|
+
"number_part",
|
|
116
|
+
# reduced canonical form
|
|
117
|
+
"reduced_canonical_form",
|
|
118
|
+
"ish",
|
|
119
|
+
"is_reduced",
|
|
120
|
+
# thermography
|
|
121
|
+
"heat",
|
|
122
|
+
"overheat",
|
|
123
|
+
"cool",
|
|
124
|
+
"temperature",
|
|
125
|
+
"mean",
|
|
126
|
+
"thermograph",
|
|
127
|
+
# notation
|
|
128
|
+
"render",
|
|
129
|
+
"parse",
|
|
130
|
+
]
|
pycgt/_piecewise.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Exact piecewise-linear functions on ``[0, inf)``.
|
|
2
|
+
|
|
3
|
+
Internal support for :mod:`pycgt.thermal`. A thermograph boundary is a
|
|
4
|
+
piecewise-linear function of the cooling temperature, so computing
|
|
5
|
+
temperatures exactly means doing exact arithmetic on such functions: pointwise
|
|
6
|
+
maxima and minima, shifts by ``+-t``, and analytic intersections.
|
|
7
|
+
|
|
8
|
+
Breakpoints, values and slopes are all :class:`~fractions.Fraction`, so nothing
|
|
9
|
+
here is approximate. Every operation preserves that.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Callable, Iterable
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from fractions import Fraction
|
|
17
|
+
|
|
18
|
+
__all__ = ["PiecewiseLinear"]
|
|
19
|
+
|
|
20
|
+
#: ``(start, value_at_start, slope_after_start)``. The final segment runs to
|
|
21
|
+
#: infinity; the first must start at 0.
|
|
22
|
+
Segment = tuple[Fraction, Fraction, Fraction]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class PiecewiseLinear:
|
|
27
|
+
"""A continuous piecewise-linear function defined for ``t >= 0``."""
|
|
28
|
+
|
|
29
|
+
segments: tuple[Segment, ...]
|
|
30
|
+
|
|
31
|
+
def __post_init__(self) -> None:
|
|
32
|
+
if not self.segments:
|
|
33
|
+
raise ValueError("a piecewise-linear function needs a segment")
|
|
34
|
+
if self.segments[0][0] != 0:
|
|
35
|
+
raise ValueError("the first segment must start at t = 0")
|
|
36
|
+
|
|
37
|
+
# -- construction ----------------------------------------------------
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def constant(value: Fraction) -> PiecewiseLinear:
|
|
41
|
+
return PiecewiseLinear(((Fraction(0), Fraction(value), Fraction(0)),))
|
|
42
|
+
|
|
43
|
+
# -- evaluation ------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
def _segment_at(self, t: Fraction) -> Segment:
|
|
46
|
+
chosen = self.segments[0]
|
|
47
|
+
for segment in self.segments:
|
|
48
|
+
if segment[0] <= t:
|
|
49
|
+
chosen = segment
|
|
50
|
+
else:
|
|
51
|
+
break
|
|
52
|
+
return chosen
|
|
53
|
+
|
|
54
|
+
def __call__(self, t: Fraction) -> Fraction:
|
|
55
|
+
start, value, slope = self._segment_at(t)
|
|
56
|
+
return value + slope * (t - start)
|
|
57
|
+
|
|
58
|
+
def slope_at(self, t: Fraction) -> Fraction:
|
|
59
|
+
"""The slope just after ``t``."""
|
|
60
|
+
return self._segment_at(t)[2]
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def breakpoints(self) -> tuple[Fraction, ...]:
|
|
64
|
+
return tuple(s[0] for s in self.segments)
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def final_value(self) -> Fraction:
|
|
68
|
+
return self.segments[-1][1]
|
|
69
|
+
|
|
70
|
+
# -- shifts ----------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
def minus_t(self) -> PiecewiseLinear:
|
|
73
|
+
"""``f(t) - t``."""
|
|
74
|
+
return PiecewiseLinear(
|
|
75
|
+
tuple(
|
|
76
|
+
(start, value - start, slope - 1)
|
|
77
|
+
for start, value, slope in self.segments
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def plus_t(self) -> PiecewiseLinear:
|
|
82
|
+
"""``f(t) + t``."""
|
|
83
|
+
return PiecewiseLinear(
|
|
84
|
+
tuple(
|
|
85
|
+
(start, value + start, slope + 1)
|
|
86
|
+
for start, value, slope in self.segments
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def negated(self) -> PiecewiseLinear:
|
|
91
|
+
return PiecewiseLinear(
|
|
92
|
+
tuple((start, -value, -slope) for start, value, slope in self.segments)
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# -- combination -----------------------------------------------------
|
|
96
|
+
|
|
97
|
+
def _crossings_with(self, other: PiecewiseLinear) -> list[Fraction]:
|
|
98
|
+
"""Where the two functions cross, as extra breakpoints."""
|
|
99
|
+
points = sorted(set(self.breakpoints) | set(other.breakpoints))
|
|
100
|
+
found: list[Fraction] = []
|
|
101
|
+
for i, a in enumerate(points):
|
|
102
|
+
b = points[i + 1] if i + 1 < len(points) else None
|
|
103
|
+
fa, fs = self(a), self.slope_at(a)
|
|
104
|
+
ga, gs = other(a), other.slope_at(a)
|
|
105
|
+
if fs == gs:
|
|
106
|
+
continue
|
|
107
|
+
t = a + (ga - fa) / (fs - gs)
|
|
108
|
+
if t > a and (b is None or t < b):
|
|
109
|
+
found.append(t)
|
|
110
|
+
return found
|
|
111
|
+
|
|
112
|
+
def _combine(
|
|
113
|
+
self, other: PiecewiseLinear, choose: Callable[[Fraction, Fraction], Fraction]
|
|
114
|
+
) -> PiecewiseLinear:
|
|
115
|
+
points = sorted(
|
|
116
|
+
set(self.breakpoints)
|
|
117
|
+
| set(other.breakpoints)
|
|
118
|
+
| set(self._crossings_with(other))
|
|
119
|
+
)
|
|
120
|
+
segments: list[Segment] = []
|
|
121
|
+
for i, a in enumerate(points):
|
|
122
|
+
b = points[i + 1] if i + 1 < len(points) else None
|
|
123
|
+
# Decide which function wins on the open interval by probing
|
|
124
|
+
# inside it, which sidesteps ties at the endpoints.
|
|
125
|
+
probe = (a + b) / 2 if b is not None else a + 1
|
|
126
|
+
winner = self if choose(self(probe), other(probe)) == self(probe) else other
|
|
127
|
+
segments.append((a, winner(a), winner.slope_at(a)))
|
|
128
|
+
return PiecewiseLinear(_simplify(tuple(segments)))
|
|
129
|
+
|
|
130
|
+
def maximum(self, other: PiecewiseLinear) -> PiecewiseLinear:
|
|
131
|
+
return self._combine(other, max)
|
|
132
|
+
|
|
133
|
+
def minimum(self, other: PiecewiseLinear) -> PiecewiseLinear:
|
|
134
|
+
return self._combine(other, min)
|
|
135
|
+
|
|
136
|
+
def minus(self, other: PiecewiseLinear) -> PiecewiseLinear:
|
|
137
|
+
"""``self - other``, exactly."""
|
|
138
|
+
points = sorted(set(self.breakpoints) | set(other.breakpoints))
|
|
139
|
+
segments = tuple(
|
|
140
|
+
(a, self(a) - other(a), self.slope_at(a) - other.slope_at(a))
|
|
141
|
+
for a in points
|
|
142
|
+
)
|
|
143
|
+
return PiecewiseLinear(_simplify(segments))
|
|
144
|
+
|
|
145
|
+
@staticmethod
|
|
146
|
+
def max_of(functions: Iterable[PiecewiseLinear]) -> PiecewiseLinear:
|
|
147
|
+
result: PiecewiseLinear | None = None
|
|
148
|
+
for f in functions:
|
|
149
|
+
result = f if result is None else result.maximum(f)
|
|
150
|
+
if result is None:
|
|
151
|
+
raise ValueError("max_of needs at least one function")
|
|
152
|
+
return result
|
|
153
|
+
|
|
154
|
+
@staticmethod
|
|
155
|
+
def min_of(functions: Iterable[PiecewiseLinear]) -> PiecewiseLinear:
|
|
156
|
+
result: PiecewiseLinear | None = None
|
|
157
|
+
for f in functions:
|
|
158
|
+
result = f if result is None else result.minimum(f)
|
|
159
|
+
if result is None:
|
|
160
|
+
raise ValueError("min_of needs at least one function")
|
|
161
|
+
return result
|
|
162
|
+
|
|
163
|
+
# -- freezing --------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
def frozen_from(self, tau: Fraction, value: Fraction) -> PiecewiseLinear:
|
|
166
|
+
"""Follow ``self`` below ``tau``, then hold ``value`` forever.
|
|
167
|
+
|
|
168
|
+
This is what forms the mast of a thermograph.
|
|
169
|
+
"""
|
|
170
|
+
kept = [s for s in self.segments if s[0] < tau]
|
|
171
|
+
if not kept:
|
|
172
|
+
return PiecewiseLinear(((Fraction(0), value, Fraction(0)),))
|
|
173
|
+
return PiecewiseLinear(_simplify((*kept, (tau, value, Fraction(0)))))
|
|
174
|
+
|
|
175
|
+
def first_at_or_below(self, other: PiecewiseLinear) -> Fraction:
|
|
176
|
+
"""The least ``t >= 0`` with ``self(t) <= other(t)``.
|
|
177
|
+
|
|
178
|
+
Used to find where a thermograph's two boundaries meet, which is the
|
|
179
|
+
temperature.
|
|
180
|
+
"""
|
|
181
|
+
difference = self.minus(other)
|
|
182
|
+
for i, (start, value, slope) in enumerate(difference.segments):
|
|
183
|
+
if value <= 0:
|
|
184
|
+
return start
|
|
185
|
+
end = (
|
|
186
|
+
difference.segments[i + 1][0]
|
|
187
|
+
if i + 1 < len(difference.segments)
|
|
188
|
+
else None
|
|
189
|
+
)
|
|
190
|
+
if slope < 0:
|
|
191
|
+
crossing = start + value / -slope
|
|
192
|
+
if end is None or crossing <= end:
|
|
193
|
+
return crossing
|
|
194
|
+
raise ArithmeticError("boundaries never meet; not a short game?")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _simplify(segments: tuple[Segment, ...]) -> tuple[Segment, ...]:
|
|
198
|
+
"""Drop breakpoints that do not actually break anything."""
|
|
199
|
+
out: list[Segment] = []
|
|
200
|
+
for segment in segments:
|
|
201
|
+
if out:
|
|
202
|
+
prev_start, prev_value, prev_slope = out[-1]
|
|
203
|
+
start, value, slope = segment
|
|
204
|
+
continuous = prev_value + prev_slope * (start - prev_start) == value
|
|
205
|
+
if prev_slope == slope and continuous:
|
|
206
|
+
continue
|
|
207
|
+
out.append(segment)
|
|
208
|
+
return tuple(out)
|
pycgt/game.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""Short games: the core object, its order, its arithmetic, and canonical form.
|
|
2
|
+
|
|
3
|
+
A *short game* is a pair of finite sets of short games -- Left's options and
|
|
4
|
+
Right's options -- and nothing else. Everything in combinatorial game theory is
|
|
5
|
+
built from the empty game up, so nothing here is approximate: two games are
|
|
6
|
+
equal, or one is greater, or they are *confused* with each other. There is no
|
|
7
|
+
tolerance to set and no rounding to worry about.
|
|
8
|
+
|
|
9
|
+
The load-bearing operation is :func:`canonical`. Two games of the same value
|
|
10
|
+
have *identical* canonical forms, so once canonicalised, structural equality is
|
|
11
|
+
value equality and comparison becomes a dictionary lookup. Without it, the
|
|
12
|
+
values arising in real rulesets become unprintable and uncomparable within a
|
|
13
|
+
few moves.
|
|
14
|
+
|
|
15
|
+
**Invariant.** Every :class:`Game` returned by this package's public API is in
|
|
16
|
+
canonical form. ``==`` therefore means value equality for such games. If you
|
|
17
|
+
build a :class:`Game` by calling the constructor directly, that invariant is
|
|
18
|
+
yours to maintain -- use :func:`game`, which canonicalises, or call
|
|
19
|
+
:func:`equals` if unsure.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from collections.abc import Iterable
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from enum import Enum
|
|
27
|
+
from functools import cache
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"ZERO",
|
|
31
|
+
"Game",
|
|
32
|
+
"Outcome",
|
|
33
|
+
"add",
|
|
34
|
+
"birthday",
|
|
35
|
+
"canonical",
|
|
36
|
+
"compare",
|
|
37
|
+
"confused",
|
|
38
|
+
"equals",
|
|
39
|
+
"game",
|
|
40
|
+
"geq",
|
|
41
|
+
"greater",
|
|
42
|
+
"leq",
|
|
43
|
+
"negate",
|
|
44
|
+
"outcome",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class Game:
|
|
50
|
+
"""The game ``{left | right}``.
|
|
51
|
+
|
|
52
|
+
Instances are hashable and immutable. Prefer the :func:`game` factory,
|
|
53
|
+
which returns the canonical form.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
left: frozenset[Game]
|
|
57
|
+
right: frozenset[Game]
|
|
58
|
+
|
|
59
|
+
# -- construction ----------------------------------------------------
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def of(left: Iterable[Game] = (), right: Iterable[Game] = ()) -> Game:
|
|
63
|
+
"""The canonical game with the given options. Alias of :func:`game`."""
|
|
64
|
+
return game(left, right)
|
|
65
|
+
|
|
66
|
+
# -- arithmetic ------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
def __neg__(self) -> Game:
|
|
69
|
+
return negate(self)
|
|
70
|
+
|
|
71
|
+
def __add__(self, other: Game) -> Game:
|
|
72
|
+
return add(self, other)
|
|
73
|
+
|
|
74
|
+
def __sub__(self, other: Game) -> Game:
|
|
75
|
+
return add(self, negate(other))
|
|
76
|
+
|
|
77
|
+
def __mul__(self, count: int) -> Game:
|
|
78
|
+
"""``n`` copies summed. Not the Norton or ordinal product."""
|
|
79
|
+
if not isinstance(count, int):
|
|
80
|
+
return NotImplemented
|
|
81
|
+
return multiple(self, count)
|
|
82
|
+
|
|
83
|
+
__rmul__ = __mul__
|
|
84
|
+
|
|
85
|
+
# -- order -----------------------------------------------------------
|
|
86
|
+
# Games are only partially ordered, so <= and >= are genuine but < and >
|
|
87
|
+
# are deliberately *not* defined: "not (G <= H)" does not imply "G > H".
|
|
88
|
+
# Use compare() for the four-way answer.
|
|
89
|
+
|
|
90
|
+
def __le__(self, other: Game) -> bool:
|
|
91
|
+
return leq(self, other)
|
|
92
|
+
|
|
93
|
+
def __ge__(self, other: Game) -> bool:
|
|
94
|
+
return geq(self, other)
|
|
95
|
+
|
|
96
|
+
# -- display ---------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
def __str__(self) -> str:
|
|
99
|
+
from .notation import render
|
|
100
|
+
|
|
101
|
+
return render(self)
|
|
102
|
+
|
|
103
|
+
def __repr__(self) -> str:
|
|
104
|
+
return f"Game({self})"
|
|
105
|
+
|
|
106
|
+
# -- convenience -----------------------------------------------------
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def canonical(self) -> Game:
|
|
110
|
+
"""The canonical form. A no-op for games from the public API."""
|
|
111
|
+
return canonical(self)
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def birthday(self) -> int:
|
|
115
|
+
return birthday(self)
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def outcome(self) -> Outcome:
|
|
119
|
+
"""Who wins under normal play."""
|
|
120
|
+
return outcome(self)
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def is_zero(self) -> bool:
|
|
124
|
+
return canonical(self) == ZERO
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def game(left: Iterable[Game] = (), right: Iterable[Game] = ()) -> Game:
|
|
128
|
+
"""The canonical game ``{left | right}``."""
|
|
129
|
+
return canonical(Game(frozenset(left), frozenset(right)))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
#: The empty game. Second player wins.
|
|
133
|
+
ZERO = Game(frozenset(), frozenset())
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
# Order
|
|
138
|
+
# ---------------------------------------------------------------------------
|
|
139
|
+
# Conway's definition: G >= H unless some H^L >= G, or some G^R <= H. The
|
|
140
|
+
# recursion terminates because every option is strictly simpler than its
|
|
141
|
+
# parent.
|
|
142
|
+
|
|
143
|
+
_geq_cache: dict[tuple[Game, Game], bool] = {}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def geq(g: Game, h: Game) -> bool:
|
|
147
|
+
"""``g >= h``: Left wins ``g - h`` moving second."""
|
|
148
|
+
key = (g, h)
|
|
149
|
+
cached = _geq_cache.get(key)
|
|
150
|
+
if cached is not None:
|
|
151
|
+
return cached
|
|
152
|
+
result = not any(geq(hl, g) for hl in h.left) and not any(
|
|
153
|
+
geq(h, gr) for gr in g.right
|
|
154
|
+
)
|
|
155
|
+
_geq_cache[key] = result
|
|
156
|
+
return result
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def leq(g: Game, h: Game) -> bool:
|
|
160
|
+
"""``g <= h``."""
|
|
161
|
+
return geq(h, g)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def equals(g: Game, h: Game) -> bool:
|
|
165
|
+
"""Value equality, whether or not either argument is canonical."""
|
|
166
|
+
return geq(g, h) and geq(h, g)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def greater(g: Game, h: Game) -> bool:
|
|
170
|
+
"""``g > h``: strictly greater."""
|
|
171
|
+
return geq(g, h) and not geq(h, g)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def confused(g: Game, h: Game) -> bool:
|
|
175
|
+
"""Neither ``>=`` nor ``<=``: whoever moves first in ``g - h`` wins."""
|
|
176
|
+
return not geq(g, h) and not geq(h, g)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class Outcome(Enum):
|
|
180
|
+
"""Who wins under normal play, where a player unable to move loses."""
|
|
181
|
+
|
|
182
|
+
LEFT = "Left"
|
|
183
|
+
RIGHT = "Right"
|
|
184
|
+
FIRST = "first player"
|
|
185
|
+
SECOND = "second player"
|
|
186
|
+
|
|
187
|
+
def __str__(self) -> str:
|
|
188
|
+
return self.value
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def outcome(g: Game) -> Outcome:
|
|
192
|
+
"""Classify ``g`` as positive, negative, zero, or confused with zero."""
|
|
193
|
+
positive, negative = geq(g, ZERO), geq(ZERO, g)
|
|
194
|
+
if positive and negative:
|
|
195
|
+
return Outcome.SECOND
|
|
196
|
+
if positive:
|
|
197
|
+
return Outcome.LEFT
|
|
198
|
+
if negative:
|
|
199
|
+
return Outcome.RIGHT
|
|
200
|
+
return Outcome.FIRST
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class Relation(Enum):
|
|
204
|
+
"""The four possible relations between two games."""
|
|
205
|
+
|
|
206
|
+
EQUAL = "="
|
|
207
|
+
GREATER = ">"
|
|
208
|
+
LESS = "<"
|
|
209
|
+
CONFUSED = "||"
|
|
210
|
+
|
|
211
|
+
def __str__(self) -> str:
|
|
212
|
+
return self.value
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def compare(g: Game, h: Game) -> Relation:
|
|
216
|
+
"""The four-way comparison. Use this instead of ``<`` and ``>``."""
|
|
217
|
+
ge, le = geq(g, h), leq(g, h)
|
|
218
|
+
if ge and le:
|
|
219
|
+
return Relation.EQUAL
|
|
220
|
+
if ge:
|
|
221
|
+
return Relation.GREATER
|
|
222
|
+
if le:
|
|
223
|
+
return Relation.LESS
|
|
224
|
+
return Relation.CONFUSED
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ---------------------------------------------------------------------------
|
|
228
|
+
# Arithmetic
|
|
229
|
+
# ---------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@cache
|
|
233
|
+
def negate(g: Game) -> Game:
|
|
234
|
+
"""``-g``: the same game with the players' roles exchanged."""
|
|
235
|
+
return Game(
|
|
236
|
+
frozenset(negate(r) for r in g.right),
|
|
237
|
+
frozenset(negate(l) for l in g.left),
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@cache
|
|
242
|
+
def _add_raw(g: Game, h: Game) -> Game:
|
|
243
|
+
return Game(
|
|
244
|
+
frozenset(
|
|
245
|
+
[_add_raw(gl, h) for gl in g.left] + [_add_raw(g, hl) for hl in h.left]
|
|
246
|
+
),
|
|
247
|
+
frozenset(
|
|
248
|
+
[_add_raw(gr, h) for gr in g.right] + [_add_raw(g, hr) for hr in h.right]
|
|
249
|
+
),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def add(g: Game, h: Game) -> Game:
|
|
254
|
+
"""Disjunctive sum, canonicalised.
|
|
255
|
+
|
|
256
|
+
Canonicalising here rather than lazily matters: letting raw sums
|
|
257
|
+
accumulate makes the trees explode, and summing many components is the
|
|
258
|
+
common case.
|
|
259
|
+
"""
|
|
260
|
+
return canonical(_add_raw(g, h))
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def multiple(g: Game, count: int) -> Game:
|
|
264
|
+
"""``count`` copies of ``g`` summed, for any integer ``count``."""
|
|
265
|
+
total = ZERO
|
|
266
|
+
for _ in range(abs(count)):
|
|
267
|
+
total = add(total, g)
|
|
268
|
+
return negate(total) if count < 0 else total
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# ---------------------------------------------------------------------------
|
|
272
|
+
# Canonical form
|
|
273
|
+
# ---------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _undominated_left(options: frozenset[Game]) -> frozenset[Game]:
|
|
277
|
+
"""Left never needs an option that another of Left's options beats."""
|
|
278
|
+
return frozenset(
|
|
279
|
+
l for l in options if not any(o is not l and geq(o, l) for o in options)
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _undominated_right(options: frozenset[Game]) -> frozenset[Game]:
|
|
284
|
+
return frozenset(
|
|
285
|
+
r for r in options if not any(o is not r and leq(o, r) for o in options)
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _bypass_left(options: frozenset[Game], g: Game) -> frozenset[Game]:
|
|
290
|
+
"""Replace each reversible Left option by what it reverses to.
|
|
291
|
+
|
|
292
|
+
If Left moves to ``G^L`` and Right can answer ``G^L -> G^{LR}`` with
|
|
293
|
+
``G^{LR} <= G``, Right will always take that answer, so the detour is
|
|
294
|
+
worth nothing and Left's real choices are those available after it.
|
|
295
|
+
"""
|
|
296
|
+
out: set[Game] = set()
|
|
297
|
+
for l in options:
|
|
298
|
+
reverser = next((lr for lr in l.right if leq(lr, g)), None)
|
|
299
|
+
if reverser is None:
|
|
300
|
+
out.add(l)
|
|
301
|
+
else:
|
|
302
|
+
out.update(reverser.left)
|
|
303
|
+
return frozenset(out)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _bypass_right(options: frozenset[Game], g: Game) -> frozenset[Game]:
|
|
307
|
+
out: set[Game] = set()
|
|
308
|
+
for r in options:
|
|
309
|
+
reverser = next((rl for rl in r.left if geq(rl, g)), None)
|
|
310
|
+
if reverser is None:
|
|
311
|
+
out.add(r)
|
|
312
|
+
else:
|
|
313
|
+
out.update(reverser.right)
|
|
314
|
+
return frozenset(out)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@cache
|
|
318
|
+
def canonical(g: Game) -> Game:
|
|
319
|
+
"""The unique simplest game of the same value.
|
|
320
|
+
|
|
321
|
+
Options are canonicalised first, then dominated options are dropped and
|
|
322
|
+
reversible ones bypassed, repeatedly, until nothing changes. The result is
|
|
323
|
+
unique: two games are equal in value precisely when their canonical forms
|
|
324
|
+
are identical.
|
|
325
|
+
"""
|
|
326
|
+
left = frozenset(canonical(x) for x in g.left)
|
|
327
|
+
right = frozenset(canonical(x) for x in g.right)
|
|
328
|
+
while True:
|
|
329
|
+
current = Game(left, right)
|
|
330
|
+
new_left = _undominated_left(_bypass_left(left, current))
|
|
331
|
+
new_right = _undominated_right(_bypass_right(right, current))
|
|
332
|
+
if new_left == left and new_right == right:
|
|
333
|
+
return current
|
|
334
|
+
left, right = new_left, new_right
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
@cache
|
|
338
|
+
def birthday(g: Game) -> int:
|
|
339
|
+
"""The day ``g`` is born: 0 for the empty game, else one past its options."""
|
|
340
|
+
options = list(g.left) + list(g.right)
|
|
341
|
+
return 1 + max((birthday(x) for x in options), default=-1)
|