-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathmodpoly.py
More file actions
223 lines (183 loc) · 8.41 KB
/
Copy pathmodpoly.py
File metadata and controls
223 lines (183 loc) · 8.41 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
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
"""Polynomial over ℤ / 2³² (issue #78).
:class:`ModPoly` is a width-tracking variant of :class:`poly.Poly` with
integer coefficients reduced mod 2³² after every arithmetic op. Used by
the bit-fragment catalog entries where the WASM i32 wraparound matters
semantically — e.g. overflow-driven identities.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from fractions import Fraction
from typing import Dict, List, Mapping, Tuple
from isa import MASK32
from poly import Poly, Monomial, _norm_coeff, _mono_mul, _mono_str
# ─── ModPoly — polynomial over ℤ / 2³² (issue #78) ────────────────
#
# Option (b) of issue #78: carry i32 wrap semantics through the
# polynomial algebra rather than only at the boundary via
# :func:`ff_symbolic.range_check`. ``ModPoly`` mirrors :class:`Poly` but
# reduces every coefficient modulo 2³² after every operation, matching
# the ``& MASK32`` the compiled transformer's FF layer applies to the
# results of ADD / SUB / MUL.
#
# The motivating gap: ``Poly`` arithmetic computes over ℤ, so the
# equivalence theorem from issue #69 carries a range assumption
# (:func:`ff_symbolic.range_check`) rather than a proof. Every catalog
# input happens to fit well inside ``[I32_MIN, I32_MAX]`` — max is
# ``factorial(10) = 3,628,800`` — so a bug in either direction on the
# overflow boundary would pass silently. ``ModPoly`` closes that:
# evaluations agree with ``NumPyExecutor`` bit-for-bit under wrap, and
# the equivalence theorem becomes *structural over ℤ/2³²* rather than
# *numeric on in-range inputs over ℤ*.
#
# Structural note: ℤ/2³² is a ring but not a field (2 divides the
# modulus, so the ring has zero divisors). That matters for DIV_S /
# REM_S — division is not well-defined — but issue #78 scope is only
# ADD / SUB / MUL, which are the ring operations. Comparisons /
# bitwise / rationals stay in their existing wrappers (``IndicatorPoly``,
# ``BitVec``, ``RationalPoly``) whose boundary evaluators already apply
# the appropriate wrap / truncation.
_MOD32 = 1 << 32
_I31 = 1 << 31 # signed / unsigned split
def _reduce_u32(c) -> int:
"""Reduce a coefficient (possibly negative / Fraction-with-denom-1) to [0, 2³²)."""
if isinstance(c, Fraction):
if c.denominator != 1:
raise ValueError(
f"ModPoly cannot carry non-integer coefficients; got {c}"
)
c = int(c.numerator)
return int(c) & MASK32
@dataclass(frozen=True)
class ModPoly:
"""Multivariate polynomial with coefficients in ℤ / 2³².
Sibling of :class:`Poly`. Closed under ``+``, ``-``, ``*`` (the ring
operations); every operation reduces coefficients modulo 2³² so the
canonical form is unique per congruence class. Structural equality
(``==``) is therefore well-defined per the ring.
Lift a pure-integer :class:`Poly` with :meth:`from_poly`; the result
is the same polynomial under the ring homomorphism ℤ → ℤ/2³².
Evaluation with :meth:`eval_at` agrees bit-for-bit with i32-masked
integer evaluation of the lifted :class:`Poly` (the homomorphism
property).
"""
terms: Mapping[Monomial, int]
@staticmethod
def _normalise(terms: Mapping[Monomial, Union[int, Fraction]]
) -> Dict[Monomial, int]:
out: Dict[Monomial, int] = {}
for m, c in terms.items():
r = _reduce_u32(c)
if r != 0:
out[m] = r
return out
def __post_init__(self):
object.__setattr__(self, "terms", self._normalise(dict(self.terms)))
# ── Constructors ──────────────────────────────────────────
@classmethod
def constant(cls, c: int) -> "ModPoly":
if _reduce_u32(c) == 0:
return cls({})
return cls({(): _reduce_u32(c)})
@classmethod
def variable(cls, idx: int) -> "ModPoly":
return cls({((int(idx), 1),): 1})
@classmethod
def from_poly(cls, p: "Poly") -> "ModPoly":
"""Lift a :class:`Poly` with integer coefficients via ℤ → ℤ/2³².
Raises ``ValueError`` on rational (non-integer) coefficients —
those belong to the rational-extension fragment (``RationalPoly``
from issue #75) whose boundary truncator already handles wrap.
"""
return cls({m: _reduce_u32(c) for m, c in p.terms.items()})
# ── Arithmetic ────────────────────────────────────────────
#
# The addition / subtraction / multiplication formulas are
# identical to ``Poly``; what changes is the post-op coefficient
# reduction in ``__post_init__``, which lands every result back
# inside [0, 2³²).
def __add__(self, other: "ModPoly") -> "ModPoly":
out: Dict[Monomial, int] = dict(self.terms)
for m, c in other.terms.items():
out[m] = out.get(m, 0) + c
return ModPoly(out)
def __sub__(self, other: "ModPoly") -> "ModPoly":
out: Dict[Monomial, int] = dict(self.terms)
for m, c in other.terms.items():
out[m] = out.get(m, 0) - c
return ModPoly(out)
def __neg__(self) -> "ModPoly":
return ModPoly({m: -c for m, c in self.terms.items()})
def __mul__(self, other: "ModPoly") -> "ModPoly":
out: Dict[Monomial, int] = {}
for ma, ca in self.terms.items():
for mb, cb in other.terms.items():
m = _mono_mul(ma, mb)
out[m] = out.get(m, 0) + ca * cb
return ModPoly(out)
# ── Inspection ────────────────────────────────────────────
def n_monomials(self) -> int:
return len(self.terms)
def variables(self) -> List[int]:
seen = set()
for m in self.terms:
for v, _ in m:
seen.add(v)
return sorted(seen)
def eval_at(self, bindings: Mapping[int, int]) -> int:
"""Evaluate as an integer in ``[0, 2³²)`` — the i32-wrapped result.
Uses the ring homomorphism: evaluate over ℤ (Python's arbitrary-
precision ints) and reduce once at the end. Equivalent to
reducing after every multiply / add thanks to the homomorphism;
the bulk form is simpler.
"""
total = 0
for mono, coeff in self.terms.items():
term = coeff
for v, p in mono:
term *= int(bindings[v]) ** p
total += term
return int(total) & MASK32
def eval_at_signed(self, bindings: Mapping[int, int]) -> int:
"""Evaluate and reinterpret as signed i32 (``[-2³¹, 2³¹)``)."""
u = self.eval_at(bindings)
return u - _MOD32 if u >= _I31 else u
# ── Equality / display ────────────────────────────────────
def __eq__(self, other) -> bool:
if not isinstance(other, ModPoly):
return NotImplemented
return self.terms == other.terms
def __hash__(self) -> int:
return hash(tuple(sorted(self.terms.items())))
def __repr__(self) -> str:
if not self.terms:
return "0 (mod 2³²)"
def _key(item):
m, _ = item
return (sum(p for _, p in m), m)
def _signed(c: int) -> int:
# Display coefficients near the upper boundary as negative
# — so ``-1 mod 2³² = 4_294_967_295`` prints as ``-1`` instead
# of the (correct but unreadable) u32 form. Makes catalog-
# sized polynomials read identically to their ``Poly`` twin.
return c - _MOD32 if c >= _I31 else c
pieces = []
for mono, coeff in sorted(self.terms.items(), key=_key):
ms = _mono_str(mono)
disp = _signed(coeff)
if ms == "1":
pieces.append(str(disp))
continue
if disp == 1:
pieces.append(ms)
elif disp == -1:
pieces.append(f"-{ms}")
else:
pieces.append(f"{disp}·{ms}")
out = pieces[0]
for p in pieces[1:]:
if p.startswith("-"):
out += f" - {p[1:]}"
else:
out += f" + {p}"
return f"{out} (mod 2³²)"
__all__ = ["ModPoly"]