pythonic-peano-arithmetic 0.3.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.
peano/rational.py ADDED
@@ -0,0 +1,295 @@
1
+ """Construct rational numbers as equivalence classes of integer ratios."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from functools import total_ordering
7
+ from typing import cast
8
+
9
+ from .integer import Z_ONE, Z_ZERO, Integer, integer, n2z
10
+ from .natural_number import N_ONE, N_ZERO, NaturalNumber
11
+ from .utils import LogMessage, log
12
+
13
+
14
+ @total_ordering
15
+ @dataclass(frozen=True, slots=True, eq=False, repr=False)
16
+ class Rational:
17
+ """Represent a rational number as an integer ratio ``p / q``, ``q != 0``.
18
+
19
+ ``p/q ~ r/s`` is defined by the cross products ``p*s = q*r``. Input
20
+ representatives are preserved; ``reduction`` normalizes the denominator
21
+ and reduces the ratio only when explicitly requested.
22
+ """
23
+
24
+ p: Integer
25
+ q: Integer
26
+
27
+ def __post_init__(self) -> None:
28
+ if not isinstance(self.p, Integer) or not isinstance(self.q, Integer):
29
+ raise TypeError("Rational.p and Rational.q must be Integer values")
30
+ if self.q == Z_ZERO:
31
+ raise ZeroDivisionError("the denominator cannot be zero")
32
+
33
+ def __repr__(self) -> str:
34
+ return f"<Q({self})>"
35
+
36
+ def __str__(self) -> str:
37
+ return f"{self.p}/{self.q}"
38
+
39
+ @log(log_level=21)
40
+ def __eq__(self, other: object) -> tuple[bool, LogMessage]:
41
+ converted = _coerce_rational(other)
42
+ if converted is None:
43
+ return (
44
+ cast(bool, NotImplemented),
45
+ lambda: f"{self!r} == {other!r} = NotImplemented",
46
+ )
47
+ result = self.p * converted.q == self.q * converted.p
48
+ return (
49
+ result,
50
+ lambda: (
51
+ f"{self!r} == {converted!r} ⇔ "
52
+ f"{self.p!r} * {converted.q!r} == "
53
+ f"{self.q!r} * {converted.p!r}"
54
+ ),
55
+ )
56
+
57
+ @log(log_level=22)
58
+ def __lt__(self, other: object) -> tuple[bool, LogMessage]:
59
+ converted = _coerce_rational(other)
60
+ if converted is None:
61
+ return (
62
+ cast(bool, NotImplemented),
63
+ lambda: f"{self!r} < {other!r} = NotImplemented",
64
+ )
65
+ left = self.p * converted.q
66
+ right = self.q * converted.p
67
+ denominators_have_different_signs = (self.q < Z_ZERO) != (converted.q < Z_ZERO)
68
+ result = left > right if denominators_have_different_signs else left < right
69
+ operator = ">" if denominators_have_different_signs else "<"
70
+ return (
71
+ result,
72
+ lambda: f"{self!r} < {converted!r} ⇔ {left!r} {operator} {right!r}",
73
+ )
74
+
75
+ @log(log_level=22)
76
+ def __le__(self, other: object) -> tuple[bool, LogMessage]:
77
+ converted = _coerce_rational(other)
78
+ if converted is None:
79
+ return (
80
+ cast(bool, NotImplemented),
81
+ lambda: f"{self!r} <= {other!r} = NotImplemented",
82
+ )
83
+ left = self.p * converted.q
84
+ right = self.q * converted.p
85
+ denominators_have_different_signs = (self.q < Z_ZERO) != (converted.q < Z_ZERO)
86
+ result = left >= right if denominators_have_different_signs else left <= right
87
+ operator = ">=" if denominators_have_different_signs else "<="
88
+ return (
89
+ result,
90
+ lambda: f"{self!r} <= {converted!r} ⇔ {left!r} {operator} {right!r}",
91
+ )
92
+
93
+ @log(log_level=24)
94
+ def __add__(self, other: object) -> tuple[Rational, LogMessage]:
95
+ converted = _coerce_rational(other)
96
+ if converted is None:
97
+ return (
98
+ cast(Rational, NotImplemented),
99
+ lambda: f"{self!r} + {other!r} = NotImplemented",
100
+ )
101
+ result = Rational(
102
+ self.p * converted.q + self.q * converted.p,
103
+ self.q * converted.q,
104
+ )
105
+ return (
106
+ result,
107
+ lambda: (
108
+ f"{self!r} + {converted!r} = "
109
+ f"({self.p!r} * {converted.q!r} + "
110
+ f"{self.q!r} * {converted.p!r}) / "
111
+ f"({self.q!r} * {converted.q!r})"
112
+ ),
113
+ )
114
+
115
+ def __radd__(self, other: object) -> Rational:
116
+ return self + other
117
+
118
+ @log(log_level=24)
119
+ def __neg__(self) -> tuple[Rational, LogMessage]:
120
+ result = Rational(-self.p, self.q)
121
+ return result, lambda: f"-{self!r} = (-{self.p!r}) / {self.q!r}"
122
+
123
+ @log(log_level=24)
124
+ def __sub__(self, other: object) -> tuple[Rational, LogMessage]:
125
+ converted = _coerce_rational(other)
126
+ if converted is None:
127
+ return (
128
+ cast(Rational, NotImplemented),
129
+ lambda: f"{self!r} - {other!r} = NotImplemented",
130
+ )
131
+ return (
132
+ self + -converted,
133
+ lambda: f"{self!r} - {converted!r} = {self!r} + (-{converted!r})",
134
+ )
135
+
136
+ def __rsub__(self, other: object) -> Rational:
137
+ converted = _coerce_rational(other)
138
+ if converted is None:
139
+ return cast(Rational, NotImplemented)
140
+ return converted - self
141
+
142
+ @log(log_level=25)
143
+ def __mul__(self, other: object) -> tuple[Rational, LogMessage]:
144
+ converted = _coerce_rational(other)
145
+ if converted is None:
146
+ return (
147
+ cast(Rational, NotImplemented),
148
+ lambda: f"{self!r} * {other!r} = NotImplemented",
149
+ )
150
+ result = Rational(self.p * converted.p, self.q * converted.q)
151
+ return (
152
+ result,
153
+ lambda: (
154
+ f"{self!r} * {converted!r} = "
155
+ f"({self.p!r} * {converted.p!r}) / "
156
+ f"({self.q!r} * {converted.q!r})"
157
+ ),
158
+ )
159
+
160
+ def __rmul__(self, other: object) -> Rational:
161
+ return self * other
162
+
163
+ @log(log_level=25)
164
+ def __truediv__(self, other: object) -> tuple[Rational, LogMessage]:
165
+ converted = _coerce_rational(other)
166
+ if converted is None:
167
+ return (
168
+ cast(Rational, NotImplemented),
169
+ lambda: f"{self!r} / {other!r} = NotImplemented",
170
+ )
171
+ if not converted:
172
+ raise ZeroDivisionError("division by zero")
173
+ result = Rational(self.p * converted.q, self.q * converted.p)
174
+ return (
175
+ result,
176
+ lambda: (
177
+ f"{self!r} / {converted!r} = "
178
+ f"({self.p!r} * {converted.q!r}) / "
179
+ f"({self.q!r} * {converted.p!r})"
180
+ ),
181
+ )
182
+
183
+ def __rtruediv__(self, other: object) -> Rational:
184
+ converted = _coerce_rational(other)
185
+ if converted is None:
186
+ return cast(Rational, NotImplemented)
187
+ return converted / self
188
+
189
+ @log(log_level=26)
190
+ def __pow__(self, exponent: object) -> tuple[Rational, LogMessage]:
191
+ if not isinstance(exponent, NaturalNumber):
192
+ return (
193
+ cast(Rational, NotImplemented),
194
+ lambda: f"{self!r} ** {exponent!r} = NotImplemented",
195
+ )
196
+ if exponent == N_ZERO:
197
+ return Q_ONE, lambda: f"{self!r} ** {exponent!r} = {Q_ONE!r}"
198
+ return (
199
+ (self ** (exponent - N_ONE)) * self,
200
+ lambda: (
201
+ f"{self!r} ** {exponent!r} = "
202
+ f"({self!r} ** ({exponent!r} - {N_ONE!r})) * {self!r}"
203
+ ),
204
+ )
205
+
206
+ def __bool__(self) -> bool:
207
+ return self.p != Z_ZERO
208
+
209
+ def __hash__(self) -> int:
210
+ reduced = self.reduction()
211
+ if reduced.q == Z_ONE:
212
+ # Equal NaturalNumber and Integer values must share the same hash.
213
+ return hash(reduced.p)
214
+ return hash(("Rational", int(reduced.p), int(reduced.q)))
215
+
216
+ def __pos__(self) -> Rational:
217
+ return self
218
+
219
+ def __abs__(self) -> Rational:
220
+ return Rational(Integer(abs(self.p), N_ZERO), Integer(abs(self.q), N_ZERO))
221
+
222
+ def as_integer_ratio(self) -> tuple[int, int]:
223
+ """Return a reduced Python integer ratio with a positive denominator.
224
+
225
+ This boundary API avoids constructing equivalent but enormous Peano
226
+ intermediates when displaying or validating large rational values.
227
+ """
228
+
229
+ from math import gcd
230
+
231
+ numerator, denominator = int(self.p), int(self.q)
232
+ if denominator < 0:
233
+ numerator, denominator = -numerator, -denominator
234
+ divisor = gcd(abs(numerator), denominator)
235
+ return numerator // divisor, denominator // divisor
236
+
237
+ def reduction(self) -> Rational:
238
+ """Return a reduced representative with a positive denominator."""
239
+
240
+ numerator = self.p.normalize()
241
+ denominator = self.q.normalize()
242
+ if denominator < Z_ZERO:
243
+ numerator, denominator = -numerator, -denominator
244
+
245
+ a, b = abs(numerator), abs(denominator)
246
+ while b:
247
+ a, b = b, a % b
248
+ divisor = Integer(a, N_ZERO)
249
+ return Rational(numerator // divisor, denominator // divisor)
250
+
251
+
252
+ def rational(numerator: int, denominator: int) -> Rational:
253
+ """Construct a rational number from two Python integers."""
254
+
255
+ return Rational(integer(numerator), integer(denominator))
256
+
257
+
258
+ def n2r(value: NaturalNumber) -> Rational:
259
+ """Embed a natural number into the rationals."""
260
+
261
+ if not isinstance(value, NaturalNumber):
262
+ raise TypeError("n2r expects a NaturalNumber")
263
+ return Rational(n2z(value), Z_ONE)
264
+
265
+
266
+ def z2r(value: Integer) -> Rational:
267
+ """Embed an integer into the rationals."""
268
+
269
+ if not isinstance(value, Integer):
270
+ raise TypeError("z2r expects an Integer")
271
+ return Rational(value, Z_ONE)
272
+
273
+
274
+ def _coerce_rational(value: object) -> Rational | None:
275
+ if isinstance(value, NaturalNumber):
276
+ return n2r(value)
277
+ if isinstance(value, Integer):
278
+ return z2r(value)
279
+ if isinstance(value, Rational):
280
+ return value
281
+ return None
282
+
283
+
284
+ def cast2r(value: object) -> Rational:
285
+ """Coerce a supported numeric value to ``Rational``."""
286
+
287
+ converted = _coerce_rational(value)
288
+ if converted is None:
289
+ raise TypeError(f"{value!r} is not a Rational")
290
+ return converted
291
+
292
+
293
+ Q_ZERO = Rational(Z_ZERO, Z_ONE)
294
+ Q_ONE = Rational(Z_ONE, Z_ONE)
295
+ Q_MINUS_ONE = Rational(-Z_ONE, Z_ONE)
peano/utils.py ADDED
@@ -0,0 +1,189 @@
1
+ """Lightweight logging support for observing symbolic evaluation."""
2
+
3
+ from copy import copy
4
+ from functools import wraps
5
+ from inspect import signature
6
+ from logging import (
7
+ Filter,
8
+ Formatter,
9
+ Logger,
10
+ LogRecord,
11
+ NullHandler,
12
+ StreamHandler,
13
+ getLogger,
14
+ )
15
+ from typing import Callable, Literal, ParamSpec, TypeVar, get_args, get_origin
16
+
17
+ logger = getLogger(__name__)
18
+ logger.addHandler(NullHandler())
19
+ logger.propagate = False
20
+ logger.disabled = True
21
+
22
+ P = ParamSpec("P")
23
+ T = TypeVar("T")
24
+ LogMessage = str | Callable[[], str]
25
+ _PEANO_HANDLER_MARKER = "_peano_handler"
26
+ _LOG_LIMIT_NOTICE = "_peano_log_limit_notice"
27
+ _locale: Literal["en", "ja"] = "en"
28
+
29
+
30
+ def localized(english: str, japanese: str) -> str:
31
+ """Return a localized runtime message."""
32
+
33
+ return japanese if _locale == "ja" else english
34
+
35
+
36
+ class _LineLimitFormatter(Formatter):
37
+ """Format a copied record when emitting the truncation notice."""
38
+
39
+ def format(self, record: LogRecord) -> str:
40
+ max_lines = getattr(record, _LOG_LIMIT_NOTICE, None)
41
+ if not isinstance(max_lines, int):
42
+ return super().format(record)
43
+ notice = copy(record)
44
+ notice.msg = localized(
45
+ f"…Log output was truncated after {max_lines} lines. "
46
+ "Use smaller inputs and run the cell again.",
47
+ f"…ログは{max_lines}行で省略しました。入力を小さくして再実行してください。",
48
+ )
49
+ notice.args = ()
50
+ return super().format(notice)
51
+
52
+
53
+ class _LineLimitFilter(Filter):
54
+ """Emit at most ``max_lines`` messages plus one truncation notice."""
55
+
56
+ def __init__(self, max_lines: int | None) -> None:
57
+ super().__init__(__name__)
58
+ self.max_lines = max_lines
59
+ self.emitted_lines = 0
60
+
61
+ def filter(self, record: LogRecord) -> bool:
62
+ if not super().filter(record):
63
+ return False
64
+ if self.max_lines is None:
65
+ return True
66
+ if self.emitted_lines < self.max_lines:
67
+ self.emitted_lines += 1
68
+ return True
69
+ if self.emitted_lines == self.max_lines:
70
+ self.emitted_lines += 1
71
+ setattr(record, _LOG_LIMIT_NOTICE, self.max_lines)
72
+ return True
73
+ return False
74
+
75
+
76
+ def log(
77
+ log_level: int,
78
+ ) -> Callable[[Callable[P, tuple[T, LogMessage]]], Callable[P, T]]:
79
+ """Turn an operation returning a result and message into a public operation.
80
+
81
+ The implementation returns ``(result, formula)``. Callers receive only
82
+ ``result`` while ``formula`` is logged at the requested level. Callable
83
+ messages are evaluated lazily only when logging is enabled.
84
+ """
85
+
86
+ def outer(func: Callable[P, tuple[T, LogMessage]]) -> Callable[P, T]:
87
+ @wraps(func)
88
+ def inner(*args: P.args, **kwargs: P.kwargs) -> T:
89
+ result, message = func(*args, **kwargs)
90
+ if logger.isEnabledFor(log_level):
91
+ logger.log(
92
+ log_level, message if isinstance(message, str) else message()
93
+ )
94
+ return result
95
+
96
+ public_return = _public_return_annotation(
97
+ signature(func, follow_wrapped=False).return_annotation
98
+ )
99
+ inner.__annotations__ = {
100
+ **func.__annotations__,
101
+ "return": public_return,
102
+ }
103
+ setattr(
104
+ inner,
105
+ "__signature__",
106
+ signature(func, follow_wrapped=False).replace(
107
+ return_annotation=public_return
108
+ ),
109
+ )
110
+ return inner
111
+
112
+ return outer
113
+
114
+
115
+ def _public_return_annotation(annotation: object) -> object:
116
+ """Extract the public result type from the internal tuple annotation."""
117
+
118
+ if isinstance(annotation, str):
119
+ prefix = "tuple["
120
+ for suffix in (", str]", ", LogMessage]"):
121
+ if annotation.startswith(prefix) and annotation.endswith(suffix):
122
+ return annotation[len(prefix) : -len(suffix)]
123
+ elif get_origin(annotation) is tuple:
124
+ arguments = get_args(annotation)
125
+ if len(arguments) == 2 and arguments[1] in (str, LogMessage):
126
+ return arguments[0]
127
+ raise TypeError("functions decorated with log must return (result, LogMessage)")
128
+
129
+
130
+ def _remove_peano_handlers(*loggers: Logger) -> None:
131
+ """Remove only handlers installed by this module."""
132
+
133
+ for target in loggers:
134
+ for handler in tuple(target.handlers):
135
+ if getattr(handler, _PEANO_HANDLER_MARKER, False):
136
+ target.removeHandler(handler)
137
+ handler.close()
138
+
139
+
140
+ def config_log(
141
+ log_level: int = 0,
142
+ root: bool = False,
143
+ fmt: str = "%(message)s",
144
+ clear_handlers: bool = True,
145
+ max_lines: int | None = None,
146
+ locale: Literal["en", "ja"] = "en",
147
+ ) -> None:
148
+ """Write symbolic evaluation logs to standard error.
149
+
150
+ By default, only the library logger is configured. ``root=True`` preserves
151
+ existing root handlers and manages only the handler installed here.
152
+ ``max_lines`` emits one truncation notice after the limit. Pass
153
+ ``"Level %(levelno)s: %(message)s"`` as ``fmt`` to expose internal levels.
154
+ Runtime labels are English by default; ``locale="ja"`` selects Japanese.
155
+ """
156
+
157
+ global _locale
158
+ if max_lines is not None and (
159
+ isinstance(max_lines, bool) or not isinstance(max_lines, int) or max_lines < 1
160
+ ):
161
+ raise ValueError("max_lines must be a positive integer or None")
162
+ if locale not in ("en", "ja"):
163
+ raise ValueError("locale must be 'en' or 'ja'")
164
+ _locale = locale
165
+
166
+ root_logger = getLogger()
167
+ if clear_handlers:
168
+ _remove_peano_handlers(logger, root_logger)
169
+
170
+ handler = StreamHandler()
171
+ handler.setFormatter(_LineLimitFormatter(fmt))
172
+ handler.setLevel(log_level)
173
+ handler.addFilter(_LineLimitFilter(max_lines))
174
+ setattr(handler, _PEANO_HANDLER_MARKER, True)
175
+
176
+ logger.setLevel(log_level)
177
+ logger.disabled = False
178
+ if root:
179
+ logger.propagate = True
180
+ root_logger.addHandler(handler)
181
+ else:
182
+ logger.propagate = False
183
+ logger.addHandler(handler)
184
+
185
+
186
+ def explain(value: object) -> str:
187
+ """Render a value consistently inside symbolic log messages."""
188
+
189
+ return repr(value)