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/polynomial.py ADDED
@@ -0,0 +1,431 @@
1
+ """Polynomials over the rationals and minimal tools for studying real roots."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from fractions import Fraction
7
+ from functools import total_ordering
8
+ from itertools import zip_longest
9
+ from typing import Iterator, cast
10
+
11
+ from .integer import Z_ONE, Integer
12
+ from .natural_number import N_ONE, N_ZERO, NaturalNumber
13
+ from .rational import (
14
+ Q_ONE,
15
+ Q_ZERO,
16
+ Rational,
17
+ cast2r,
18
+ n2r,
19
+ rational,
20
+ z2r,
21
+ )
22
+ from .utils import LogMessage, log
23
+
24
+
25
+ @total_ordering
26
+ @dataclass(frozen=True, slots=True, init=False, eq=False, repr=False)
27
+ class Polynomial:
28
+ """Treat a finite sequence ``(a0, ..., an)`` as a polynomial over Q.
29
+
30
+ Coefficients are ordered from the constant term upward. Trailing zeroes
31
+ are removed and every coefficient is reduced, giving each polynomial,
32
+ including zero, one canonical representation.
33
+ """
34
+
35
+ _coefficients: tuple[Rational, ...]
36
+
37
+ def __init__(self, *coefficients: Rational) -> None:
38
+ if not coefficients:
39
+ coefficients = (Q_ZERO,)
40
+ if any(not isinstance(value, Rational) for value in coefficients):
41
+ raise TypeError("Polynomial coefficients must be Rational")
42
+
43
+ normalized = [value.reduction() for value in coefficients]
44
+ while len(normalized) > 1 and normalized[-1] == Q_ZERO:
45
+ normalized.pop()
46
+ object.__setattr__(self, "_coefficients", tuple(normalized))
47
+
48
+ @property
49
+ def k(self) -> tuple[Rational, ...]:
50
+ """Return the coefficient sequence exposed by the original API."""
51
+
52
+ return self._coefficients
53
+
54
+ @property
55
+ def coefficients(self) -> tuple[Rational, ...]:
56
+ return self._coefficients
57
+
58
+ @property
59
+ def degree(self) -> int:
60
+ """Return the degree, using -1 for the zero polynomial."""
61
+
62
+ return -1 if not self else len(self.coefficients) - 1
63
+
64
+ @property
65
+ def leading_coefficient(self) -> Rational:
66
+ if not self:
67
+ raise ValueError("the zero polynomial has no leading coefficient")
68
+ return self.coefficients[-1]
69
+
70
+ def __eq__(self, other: object) -> bool:
71
+ converted = _coerce_polynomial(other)
72
+ if converted is None:
73
+ return cast(bool, NotImplemented)
74
+ return self.coefficients == converted.coefficients
75
+
76
+ def _order_key(self) -> tuple[int, tuple[Rational, ...]]:
77
+ return self.degree, tuple(reversed(self.coefficients))
78
+
79
+ def __lt__(self, other: object) -> bool:
80
+ converted = _coerce_polynomial(other)
81
+ if converted is None:
82
+ return cast(bool, NotImplemented)
83
+ return self._order_key() < converted._order_key()
84
+
85
+ def __le__(self, other: object) -> bool:
86
+ converted = _coerce_polynomial(other)
87
+ if converted is None:
88
+ return cast(bool, NotImplemented)
89
+ return self._order_key() <= converted._order_key()
90
+
91
+ def __add__(self, other: object) -> Polynomial:
92
+ converted = _coerce_polynomial(other)
93
+ if converted is None:
94
+ return cast(Polynomial, NotImplemented)
95
+ coefficients = [
96
+ a + b
97
+ for a, b in zip_longest(
98
+ self.coefficients,
99
+ converted.coefficients,
100
+ fillvalue=Q_ZERO,
101
+ )
102
+ ]
103
+ return Polynomial(*coefficients)
104
+
105
+ def __radd__(self, other: object) -> Polynomial:
106
+ return self + other
107
+
108
+ def __neg__(self) -> Polynomial:
109
+ return Polynomial(*(-coefficient for coefficient in self))
110
+
111
+ def __sub__(self, other: object) -> Polynomial:
112
+ converted = _coerce_polynomial(other)
113
+ if converted is None:
114
+ return cast(Polynomial, NotImplemented)
115
+ return self + -converted
116
+
117
+ def __rsub__(self, other: object) -> Polynomial:
118
+ converted = _coerce_polynomial(other)
119
+ if converted is None:
120
+ return cast(Polynomial, NotImplemented)
121
+ return converted - self
122
+
123
+ def __mul__(self, other: object) -> Polynomial:
124
+ converted = _coerce_polynomial(other)
125
+ if converted is None:
126
+ return cast(Polynomial, NotImplemented)
127
+ if not self or not converted:
128
+ return P_ZERO
129
+ result = [Q_ZERO] * (len(self.coefficients) + len(converted.coefficients) - 1)
130
+ for i, left in enumerate(self.coefficients):
131
+ for j, right in enumerate(converted.coefficients):
132
+ product = (left * right).reduction()
133
+ result[i + j] = (result[i + j] + product).reduction()
134
+ return Polynomial(*result)
135
+
136
+ def __rmul__(self, other: object) -> Polynomial:
137
+ return self * other
138
+
139
+ def __divmod__(self, other: object) -> tuple[Polynomial, Polynomial]:
140
+ divisor = _coerce_polynomial(other)
141
+ if divisor is None:
142
+ return cast(tuple[Polynomial, Polynomial], NotImplemented)
143
+ if not divisor:
144
+ raise ZeroDivisionError("cannot divide by the zero polynomial")
145
+ if self.degree < divisor.degree:
146
+ return P_ZERO, self
147
+
148
+ quotient = [Q_ZERO] * (self.degree - divisor.degree + 1)
149
+ remainder = self
150
+ while remainder and remainder.degree >= divisor.degree:
151
+ degree_difference = remainder.degree - divisor.degree
152
+ leading = (
153
+ remainder.leading_coefficient / divisor.leading_coefficient
154
+ ).reduction()
155
+ quotient[degree_difference] = leading
156
+ term = Polynomial(*([Q_ZERO] * degree_difference + [leading]))
157
+ remainder = remainder - divisor * term
158
+ return Polynomial(*quotient), remainder
159
+
160
+ def __rdivmod__(self, other: object) -> tuple[Polynomial, Polynomial]:
161
+ dividend = _coerce_polynomial(other)
162
+ if dividend is None:
163
+ return cast(tuple[Polynomial, Polynomial], NotImplemented)
164
+ return divmod(dividend, self)
165
+
166
+ def __floordiv__(self, other: object) -> Polynomial:
167
+ divisor = _coerce_polynomial(other)
168
+ if divisor is None:
169
+ return cast(Polynomial, NotImplemented)
170
+ result = self.__divmod__(divisor)
171
+ quotient, _ = result
172
+ return quotient
173
+
174
+ def __rfloordiv__(self, other: object) -> Polynomial:
175
+ dividend = _coerce_polynomial(other)
176
+ if dividend is None:
177
+ return cast(Polynomial, NotImplemented)
178
+ return dividend // self
179
+
180
+ def __mod__(self, other: object) -> Polynomial:
181
+ divisor = _coerce_polynomial(other)
182
+ if divisor is None:
183
+ return cast(Polynomial, NotImplemented)
184
+ result = self.__divmod__(divisor)
185
+ _, remainder = result
186
+ return remainder
187
+
188
+ def __rmod__(self, other: object) -> Polynomial:
189
+ dividend = _coerce_polynomial(other)
190
+ if dividend is None:
191
+ return cast(Polynomial, NotImplemented)
192
+ return dividend % self
193
+
194
+ def __pow__(self, exponent: object) -> Polynomial:
195
+ if not isinstance(exponent, NaturalNumber):
196
+ return cast(Polynomial, NotImplemented)
197
+ if exponent == N_ZERO:
198
+ return P_ONE
199
+ return (self ** (exponent - N_ONE)) * self
200
+
201
+ @log(log_level=31)
202
+ def evaluate(self, value: object) -> tuple[Rational, LogMessage]:
203
+ """Evaluate at ``x=value`` with Horner's method."""
204
+
205
+ point = cast2r(value)
206
+ result = Q_ZERO
207
+ for coefficient in reversed(self.coefficients):
208
+ result = (result * point + coefficient).reduction()
209
+ return result, lambda: f"{self!r}: x={point!r} -> {result!r}"
210
+
211
+ def sign_at(self, value: object) -> int:
212
+ """Return the exact sign at a point as ``-1``, ``0``, or ``1``.
213
+
214
+ Denominators grow exponentially while refining a root interval. When
215
+ only the sign is needed, there is no educational value in constructing
216
+ enormous intermediate Peano values. The same ratios are therefore
217
+ mapped to Python's arbitrary-precision integers and evaluated exactly.
218
+ """
219
+
220
+ point = _as_fraction(cast2r(value))
221
+ result = Fraction(0)
222
+ for coefficient in reversed(self.coefficients):
223
+ result = result * point + _as_fraction(coefficient)
224
+ return (result > 0) - (result < 0)
225
+
226
+ def derivative(self) -> Polynomial:
227
+ """Return the formal derivative."""
228
+
229
+ if self.degree <= 0:
230
+ return P_ZERO
231
+ return Polynomial(
232
+ *(
233
+ coefficient * rational(power, 1)
234
+ for power, coefficient in enumerate(self.coefficients[1:], start=1)
235
+ )
236
+ )
237
+
238
+ def monic(self) -> Polynomial:
239
+ """Return a copy whose leading coefficient is one."""
240
+
241
+ if not self:
242
+ return P_ZERO
243
+ leading = self.leading_coefficient
244
+ return Polynomial(*(coefficient / leading for coefficient in self.coefficients))
245
+
246
+ def gcd(self, other: Polynomial) -> Polynomial:
247
+ """Return the monic greatest common divisor using Euclid's algorithm."""
248
+
249
+ if not isinstance(other, Polynomial):
250
+ raise TypeError("gcd expects a Polynomial")
251
+ left, right = self, other
252
+ while right:
253
+ left, right = right, left % right
254
+ return left.monic()
255
+
256
+ def square_free(self) -> Polynomial:
257
+ """Return the square-free part with repeated roots removed."""
258
+
259
+ if self.degree <= 0:
260
+ return self
261
+ common = self.gcd(self.derivative())
262
+ return (self // common).monic()
263
+
264
+ def reduction(self) -> Polynomial:
265
+ return Polynomial(*self.coefficients)
266
+
267
+ def __len__(self) -> int:
268
+ return len(self.coefficients)
269
+
270
+ def __bool__(self) -> bool:
271
+ return not (len(self.coefficients) == 1 and self.coefficients[0] == Q_ZERO)
272
+
273
+ def __int__(self) -> int:
274
+ if self.degree > 0:
275
+ raise TypeError("only constant polynomials can be converted to int")
276
+ coefficient = self.coefficients[0]
277
+ if coefficient.q != Z_ONE:
278
+ raise TypeError("a non-integral constant cannot be converted to int")
279
+ return int(coefficient.p)
280
+
281
+ def __hash__(self) -> int:
282
+ if self.degree <= 0:
283
+ # Equal values across the numeric tower must have equal hashes.
284
+ return hash(self.coefficients[0])
285
+ return hash(("Polynomial", self.coefficients))
286
+
287
+ def __iter__(self) -> Iterator[Rational]:
288
+ return iter(self.coefficients)
289
+
290
+ def __pos__(self) -> Polynomial:
291
+ return self
292
+
293
+ def __str__(self) -> str:
294
+ terms: list[tuple[bool, str]] = []
295
+ for power, coefficient in enumerate(self.coefficients):
296
+ if coefficient == Q_ZERO:
297
+ continue
298
+ negative = coefficient < Q_ZERO
299
+ magnitude = abs(coefficient).reduction()
300
+ coefficient_text = _format_rational(magnitude)
301
+ if power == 0:
302
+ body = coefficient_text
303
+ else:
304
+ variable = "x" if power == 1 else f"x^{power}"
305
+ body = (
306
+ variable if magnitude == Q_ONE else f"{coefficient_text}{variable}"
307
+ )
308
+ terms.append((negative, body))
309
+
310
+ if not terms:
311
+ return "0"
312
+ first_negative, first_body = terms[0]
313
+ rendered = f"-{first_body}" if first_negative else first_body
314
+ for negative, body in terms[1:]:
315
+ rendered += f" {'-' if negative else '+'} {body}"
316
+ return rendered
317
+
318
+ def __repr__(self) -> str:
319
+ return f"<P({self})>"
320
+
321
+
322
+ class PolynomialIterator:
323
+ """A simple coefficient iterator retained for API compatibility."""
324
+
325
+ def __init__(self, *coefficients: Rational) -> None:
326
+ self._iterator = iter(Polynomial(*coefficients).coefficients)
327
+
328
+ def __iter__(self) -> PolynomialIterator:
329
+ return self
330
+
331
+ def __next__(self) -> Rational:
332
+ return next(self._iterator)
333
+
334
+
335
+ def polynomial(*coefficients: tuple[int, int]) -> Polynomial:
336
+ """Construct a polynomial from ``(numerator, denominator)`` pairs."""
337
+
338
+ return Polynomial(*(rational(p, q) for p, q in coefficients))
339
+
340
+
341
+ def n2p(value: NaturalNumber) -> Polynomial:
342
+ return Polynomial(n2r(value))
343
+
344
+
345
+ def z2p(value: Integer) -> Polynomial:
346
+ return Polynomial(z2r(value))
347
+
348
+
349
+ def r2p(value: Rational) -> Polynomial:
350
+ if not isinstance(value, Rational):
351
+ raise TypeError("r2p expects a Rational")
352
+ return Polynomial(value)
353
+
354
+
355
+ def _coerce_polynomial(value: object) -> Polynomial | None:
356
+ if isinstance(value, NaturalNumber):
357
+ return n2p(value)
358
+ if isinstance(value, Integer):
359
+ return z2p(value)
360
+ if isinstance(value, Rational):
361
+ return r2p(value)
362
+ if isinstance(value, Polynomial):
363
+ return value
364
+ return None
365
+
366
+
367
+ def cast2p(value: object) -> Polynomial:
368
+ converted = _coerce_polynomial(value)
369
+ if converted is None:
370
+ raise TypeError(f"{value!r} is not a Polynomial")
371
+ return converted
372
+
373
+
374
+ def sturm_sequence(value: Polynomial) -> tuple[Polynomial, ...]:
375
+ """Return the Sturm sequence used to count real roots."""
376
+
377
+ if not isinstance(value, Polynomial):
378
+ raise TypeError("sturm_sequence expects a Polynomial")
379
+ if value.degree <= 0:
380
+ raise ValueError("a constant polynomial has no Sturm sequence")
381
+
382
+ square_free = value.square_free()
383
+ sequence = [square_free, square_free.derivative()]
384
+ while sequence[-1]:
385
+ remainder = sequence[-2] % sequence[-1]
386
+ if not remainder:
387
+ break
388
+ sequence.append(-remainder)
389
+ return tuple(sequence)
390
+
391
+
392
+ def sign_variations(sequence: tuple[Polynomial, ...], point: Rational) -> int:
393
+ """Count nonzero sign changes after evaluating a Sturm sequence."""
394
+
395
+ signs: list[bool] = []
396
+ for value in sequence:
397
+ sign = value.sign_at(point)
398
+ if sign == 0:
399
+ continue
400
+ signs.append(sign < 0)
401
+ return sum(left != right for left, right in zip(signs, signs[1:]))
402
+
403
+
404
+ def count_real_roots(value: Polynomial, lower: Rational, upper: Rational) -> int:
405
+ """Count distinct real roots in the open interval ``(lower, upper)``."""
406
+
407
+ if not isinstance(value, Polynomial):
408
+ raise TypeError("value must be a Polynomial")
409
+ lower = cast2r(lower)
410
+ upper = cast2r(upper)
411
+ if lower >= upper:
412
+ raise ValueError("lower must be less than upper")
413
+ if value.sign_at(lower) == 0 or value.sign_at(upper) == 0:
414
+ raise ValueError("interval endpoints must not be roots")
415
+ sequence = sturm_sequence(value)
416
+ return sign_variations(sequence, lower) - sign_variations(sequence, upper)
417
+
418
+
419
+ def _format_rational(value: Rational) -> str:
420
+ reduced = value.reduction()
421
+ if reduced.q == Z_ONE:
422
+ return str(reduced.p)
423
+ return str(reduced)
424
+
425
+
426
+ def _as_fraction(value: Rational) -> Fraction:
427
+ return Fraction(int(value.p), int(value.q))
428
+
429
+
430
+ P_ZERO = Polynomial(Q_ZERO)
431
+ P_ONE = Polynomial(Q_ONE)
peano/py.typed ADDED
@@ -0,0 +1 @@
1
+ # PEP 561 marker