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 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)