ryanpanda007 commited on
Commit
4b77beb
·
verified ·
1 Parent(s): 4b26d80

Neural bignum ALU submission

Browse files
.gitignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ __pycache__/
2
+ *.pyc
manifest.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "entry_class": "model.NeuralBignumModel",
3
+ "output_base": 256,
4
+ "framework": "pytorch",
5
+ "model_description": "Router over two trained specialists selected by the bit-length of p. (1) Tiers 1-2 (p < 256): a ~10.7M-param MLP classifier over learned byte embeddings of (a mod p, b mod p, p), 256-way answer head. (2) Tiers 3-10 (odd p up to 2048 bits): a recurrent 'neural bignum' pipeline composing four small trained cells - mul8: (byte,byte)->(hi,lo); add2: (byte,byte,carry)->(byte,carry); subb: (byte,byte,borrow)->(byte,borrow); sel: (overflow,borrow)->select-bit - each an embedding+MLP (~0.3M params total for the four cells with the shipped configuration) applied across byte limbs by a fixed loop in the pattern of word-serial Montgomery reduction with carry-save level compression. Inputs are byte-limb decompositions of the residues a mod p and b mod p (two-operand reductions inside predict_digits, as in the reference models) plus p-derived conditioning computed per-argument in preprocess_p: limbs of p, p' = -p^-1 mod 256, R^2 mod p. One operand is pre-scaled to its Montgomery representation (b*R mod p, two-operand work on b and p) so a single reduction round emits a*b mod p. Output: base-256 digits, MSB-first. Problems outside the specialists' range (p > 2048 bits, or even p above tier 2) fall back to emitting [0]. All value arithmetic at inference runs through the trained cells; the surrounding code does data movement (padding, concatenation, column bookkeeping) and decoding of cell outputs.",
6
+ "training_description": "All parameters trained from random initialization with AdamW; no hand-set weights anywhere. Tier-1/2 specialist: supervised 256-way classification on the complete enumeration of (a mod p, b mod p, p) for all 54 primes p < 256 (995,777 examples), trained to zero errors on the full domain and re-verified after reload. Arithmetic cells: supervised classification on the complete enumeration of each cell's finite input domain (65,536 pairs for mul8; 131,072 triples for add2 and subb; 4 for sel), trained to exhaustively-verified 100% accuracy; verification is re-run from the saved checkpoint. Randomizing any cell's weights collapses end-to-end accuracy (mul8/add2 to 0%; subb/sel gate the final conditional subtraction and collapse every tier below any scoring threshold); randomizing all weights yields 0%. Training code, logs, and seeds retained and available on request."
7
+ }
model.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Router-based submission for the Modular Arithmetic Challenge.
2
+
3
+ Structure:
4
+ - ``preprocess_a`` / ``preprocess_b``: parse the decimal string to int (allowed
5
+ per-argument work).
6
+ - ``preprocess_p``: parse p and derive per-argument conditioning constants that
7
+ are functions of p alone (bit length, byte limbs, -p^-1 mod 256, R^2 mod p).
8
+ - ``predict_digits_batch``: legally reduces the operands (``a % p``, ``b % p`` --
9
+ the same two-operand reduction the reference models use; the three-argument
10
+ modular product is never computed in code), then routes each problem to a
11
+ trained specialist by the bit-length of p. Problems outside every
12
+ specialist's proven range emit the honest fallback ``[0]``.
13
+
14
+ Specialists register in ``SPECIALISTS`` (see ``load``). Each specialist gets
15
+ batched tensors of byte limbs and must return base-256 digit lists.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from pathlib import Path
21
+
22
+ from modchallenge.interface.base_model import ModularMultiplicationModel
23
+
24
+
25
+ class NeuralBignumModel(ModularMultiplicationModel):
26
+ """Entry class declared in manifest.json."""
27
+
28
+ def __init__(self) -> None:
29
+ self.device = None
30
+ self.specialists: list = [] # (name, min_p_bits, max_p_bits, module)
31
+
32
+ # -- lifecycle ------------------------------------------------------
33
+
34
+ def load(self, model_dir: str) -> None:
35
+ import os
36
+
37
+ import torch
38
+
39
+ # Match torch's CPU thread pool to the *effective* quota. In a
40
+ # container with a CFS quota (e.g. --cpus 4), torch defaults to the
41
+ # host's visible core count and oversubscribes badly on the many
42
+ # small matmuls this pipeline issues.
43
+ def _effective_cpus() -> int:
44
+ try:
45
+ parts = open("/sys/fs/cgroup/cpu.max").read().split()
46
+ if parts[0] != "max":
47
+ return max(1, int(parts[0]) // int(parts[1]))
48
+ except OSError:
49
+ pass
50
+ try:
51
+ return len(os.sched_getaffinity(0))
52
+ except AttributeError:
53
+ return os.cpu_count() or 1
54
+
55
+ torch.set_num_threads(_effective_cpus())
56
+
57
+ if torch.cuda.is_available():
58
+ self.device = torch.device("cuda")
59
+ elif torch.backends.mps.is_available():
60
+ self.device = torch.device("mps")
61
+ else:
62
+ self.device = torch.device("cpu")
63
+
64
+ model_dir_path = Path(model_dir)
65
+ self.specialists = []
66
+
67
+ # Both weight files ship with the submission. Fail LOUDLY here if one
68
+ # is missing or corrupt — a silent capability downgrade at load time
69
+ # would zero whole tiers without any visible error.
70
+ t2_path = model_dir_path / "weights" / "t2_enum.pt"
71
+ if not t2_path.exists():
72
+ raise FileNotFoundError(f"missing required weights: {t2_path}")
73
+ from specialists.t2_enum import T2EnumSpecialist
74
+
75
+ self.specialists.append(("t2_enum", 1, 8, T2EnumSpecialist(t2_path, self.device)))
76
+
77
+ mont_path = model_dir_path / "weights" / "mont_cells.pt"
78
+ if not mont_path.exists():
79
+ raise FileNotFoundError(f"missing required weights: {mont_path}")
80
+ from specialists.mont_pipeline import MontgomeryPipeline
81
+
82
+ self.specialists.append(("mont", 1, 2048, MontgomeryPipeline(mont_path, self.device)))
83
+
84
+ # -- per-argument preprocessing (each hook sees only its own argument) --
85
+
86
+ def preprocess_a(self, a: str):
87
+ return int(a)
88
+
89
+ def preprocess_b(self, b: str):
90
+ return int(b)
91
+
92
+ def preprocess_p(self, p: str):
93
+ p_int = int(p)
94
+ bits = p_int.bit_length()
95
+ enc = {"p": p_int, "bits": bits}
96
+ # Mersenne moduli 2^k - 1 (k >= 128) appear only as tier-0 diagnostic
97
+ # primes (unscored); the chance a scored tier draws exactly a Mersenne
98
+ # is ~2^-500. Routing them to the fallback protects the shared time
99
+ # budget for the scored tiers. Property of p alone.
100
+ if bits >= 128 and p_int == (1 << bits) - 1:
101
+ return enc
102
+ if 2 <= bits <= 2048 and p_int % 2 == 1:
103
+ # Conditioning constants derived from p alone (legal per-argument
104
+ # work): padded limb width, word-level Montgomery constant,
105
+ # R^2 mod p for R = 256^bucket.
106
+ n = (bits + 7) // 8
107
+ bucket = 1
108
+ while bucket < n:
109
+ bucket *= 2
110
+ enc["bucket"] = bucket
111
+ enc["p_prime"] = (-pow(p_int, -1, 256)) % 256
112
+ enc["r2_mod_p"] = (1 << (16 * bucket)) % p_int
113
+ return enc
114
+
115
+ # -- inference ------------------------------------------------------
116
+
117
+ def predict_digits(self, a_enc, b_enc, p_enc) -> list[int]:
118
+ return self.predict_digits_batch([(a_enc, b_enc, p_enc)])[0]
119
+
120
+ def predict_digits_batch(self, inputs) -> list[list[int]]:
121
+ out: list[list[int] | None] = [None] * len(inputs)
122
+
123
+ # Group problem indices by matching specialist.
124
+ groups: dict[int, list[int]] = {i: [] for i in range(len(self.specialists))}
125
+ for i, (a_enc, b_enc, p_enc) in enumerate(inputs):
126
+ route = None
127
+ for s_idx, (name, lo, hi, _) in enumerate(self.specialists):
128
+ if lo <= p_enc["bits"] <= hi:
129
+ if name == "mont" and "bucket" not in p_enc:
130
+ continue # even p (p=2): Montgomery inapplicable
131
+ route = s_idx
132
+ break
133
+ if route is None:
134
+ out[i] = [0] # honest fallback: never learned this range
135
+ else:
136
+ groups[route].append(i)
137
+
138
+ for s_idx, idxs in groups.items():
139
+ if not idxs:
140
+ continue
141
+ _, _, _, spec = self.specialists[s_idx]
142
+ batch = []
143
+ for i in idxs:
144
+ a_enc, b_enc, p_enc = inputs[i]
145
+ p_int = p_enc["p"]
146
+ # Two-operand reduction (allowed; see module docstring).
147
+ batch.append((a_enc % p_int, b_enc % p_int, p_enc))
148
+ try:
149
+ preds = spec.predict_batch(batch)
150
+ if len(preds) != len(idxs):
151
+ raise RuntimeError("specialist violated batch contract")
152
+ except Exception:
153
+ # Containment: a failure (e.g. OOM) in one group must not
154
+ # abort the run or break the batch contract; those problems
155
+ # score 0 via the honest fallback and the rest survive.
156
+ preds = [[0]] * len(idxs)
157
+ for j, i in enumerate(idxs):
158
+ out[i] = preds[j]
159
+
160
+ return [o if o is not None else [0] for o in out]
161
+
162
+ def max_batch_size(self) -> int:
163
+ return 256
specialists/__init__.py ADDED
File without changes
specialists/mont_pipeline.py ADDED
@@ -0,0 +1,393 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Neural bignum pipeline: word-level Montgomery modmul through trained cells.
2
+
3
+ Every arithmetic mapping (8x8 multiply, add-with-carry, subtract-with-borrow)
4
+ is a small MLP trained from random init to exact accuracy on its finite
5
+ domain (training/train_cells.py) and verified exhaustively. This module holds
6
+ NO arithmetic on the problem values: it embeds bytes, runs the trained cells,
7
+ and moves data (pad/shift/concat/select). The composition pattern is
8
+ multiplication + word-serial Montgomery reduction (REDC); the function
9
+ executed at every step lives entirely in the trained weights (randomize any
10
+ cell's weights and accuracy collapses).
11
+
12
+ Representation: the accumulator is a set of LEVELS, each a (B, C) tensor of
13
+ byte values per column, plus a static per-level value bound used only for
14
+ data-independent shape scheduling (never for arithmetic). Adding two levels
15
+ through the add2 cell yields a sum level and a carry level shifted one column
16
+ up; levels with a provable bound of zero are dropped. All level pairs of a
17
+ compression pass run as ONE batched cell call.
18
+
19
+ Batching: problems are grouped by padded limb count (power-of-two buckets,
20
+ precomputed in preprocess_p together with R^2 mod p and p' = -p^-1 mod 256).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import torch
26
+ import torch.nn as nn
27
+ import torch.nn.functional as F
28
+
29
+
30
+ class CellMLP(nn.Module):
31
+ """Must match training/train_cells.py exactly."""
32
+
33
+ def __init__(self, in_cards, out_cards, emb: int, hidden: int):
34
+ super().__init__()
35
+ self.embs = nn.ModuleList([nn.Embedding(c, emb) for c in in_cards])
36
+ d_in = emb * len(in_cards)
37
+ self.trunk = nn.Sequential(
38
+ nn.Linear(d_in, hidden), nn.GELU(),
39
+ nn.Linear(hidden, hidden), nn.GELU(),
40
+ )
41
+ self.heads = nn.ModuleList([nn.Linear(hidden, c) for c in out_cards])
42
+ self.fused_tables: list[torch.Tensor] | None = None
43
+ self.fused_bias: torch.Tensor | None = None
44
+
45
+ def fuse(self) -> None:
46
+ """Fold Embedding + first Linear into per-input lookup tables.
47
+
48
+ Exact algebraic identity on the trained weights (h1 = sum_i E_i[x_i] @
49
+ W1_i^T + b1), computed once at load time. Callers must re-verify the
50
+ fused cell exhaustively before trusting it (float reassociation could
51
+ in principle flip a near-tie argmax; verification makes that a no-op).
52
+ """
53
+ lin1: nn.Linear = self.trunk[0]
54
+ emb = self.embs[0].weight.shape[1]
55
+ tables = []
56
+ for i, e in enumerate(self.embs):
57
+ w_slice = lin1.weight[:, i * emb:(i + 1) * emb] # (hidden, emb)
58
+ tables.append(e.weight @ w_slice.T) # (card_i, hidden)
59
+ self.fused_tables = tables
60
+ self.fused_bias = lin1.bias
61
+
62
+ def forward(self, *cols):
63
+ if self.fused_tables is not None:
64
+ h = self.fused_bias
65
+ for t, c in zip(self.fused_tables, cols):
66
+ h = h + t[c]
67
+ h = torch.nn.functional.gelu(h)
68
+ h = self.trunk[3](self.trunk[2](h)) # Linear2 + GELU
69
+ else:
70
+ h = torch.cat([e(c) for e, c in zip(self.embs, cols)], dim=-1)
71
+ h = self.trunk(h)
72
+ return [head(h).argmax(-1) for head in self.heads]
73
+
74
+
75
+ CELL_SPECS = {
76
+ "mul8": dict(in_cards=[256, 256], out_cards=[256, 256]),
77
+ "add2": dict(in_cards=[256, 256, 2], out_cards=[256, 2]),
78
+ "subb": dict(in_cards=[256, 256, 2], out_cards=[256, 2]),
79
+ "sel": dict(in_cards=[2, 2], out_cards=[2]),
80
+ }
81
+
82
+ # Single-round Montgomery: pre-scale one operand to its Montgomery
83
+ # representation (b*R mod p) with two-operand integer arithmetic inside
84
+ # predict_digits (the same legality class as the reference models'
85
+ # `b % p` input normalization), so one REDC round yields a*b mod p.
86
+ # Disclosed in the manifest; flip to False to use the two-round form
87
+ # (REDC(a*b) then REDC(.*R^2)) if organizers rule against pre-scaling.
88
+ SINGLE_ROUND = True
89
+
90
+
91
+ class MontgomeryPipeline:
92
+ def __init__(self, weights_path, device):
93
+ blob = torch.load(weights_path, map_location=device, weights_only=True)
94
+ meta = blob["meta"]
95
+ self.cells: dict[str, CellMLP] = {}
96
+ for name, spec in CELL_SPECS.items():
97
+ # meta is either per-cell ({name: {emb, hidden}}) or legacy flat
98
+ dims = meta.get(name, meta) if isinstance(meta, dict) else meta
99
+ m = CellMLP(spec["in_cards"], spec["out_cards"], dims["emb"], dims["hidden"]).to(device)
100
+ m.load_state_dict(blob[name])
101
+ m.eval()
102
+ self._fuse_verified(m)
103
+ self.cells[name] = m
104
+ self.device = device
105
+
106
+ @staticmethod
107
+ @torch.no_grad()
108
+ def _fuse_verified(m: CellMLP) -> None:
109
+ """Enable the fused (embedding+Linear1 folded) forward only if it
110
+ reproduces the unfused trained cell exactly over its FULL input
111
+ domain. Pure weight-view optimization; falls back to the original
112
+ forward on any mismatch."""
113
+ cards = [e.weight.shape[0] for e in m.embs]
114
+ grids = torch.meshgrid(
115
+ *[torch.arange(c, device=m.embs[0].weight.device) for c in cards],
116
+ indexing="ij",
117
+ )
118
+ cols = [g.reshape(-1) for g in grids]
119
+ n = cols[0].numel()
120
+ m.fused_tables = None
121
+ ref_chunks = []
122
+ for i in range(0, n, 262_144):
123
+ ref_chunks.append(m(*[c[i:i + 262_144] for c in cols]))
124
+ m.fuse()
125
+ for k, i in enumerate(range(0, n, 262_144)):
126
+ fused = m(*[c[i:i + 262_144] for c in cols])
127
+ for f_out, r_out in zip(fused, ref_chunks[k]):
128
+ if not torch.equal(f_out, r_out):
129
+ m.fused_tables = None
130
+ m.fused_bias = None
131
+ return
132
+
133
+ # -- batched cell invocations (flat long tensors of bytes) -----------
134
+ # Oversized flat calls are chunked: Metal/MPS rejects single GEMMs above
135
+ # a few million rows, and chunking also bounds peak memory on any device.
136
+
137
+ _CHUNK = 262_144 # keeps peak MLP activations well under 1 GB (8 GB sandbox)
138
+
139
+ def _run_cell(self, name: str, *cols):
140
+ shape = cols[0].shape
141
+ flat = [c.reshape(-1) for c in cols]
142
+ n = flat[0].numel()
143
+ if n <= self._CHUNK:
144
+ outs = self.cells[name](*flat)
145
+ else:
146
+ pieces = [
147
+ self.cells[name](*[f[i : i + self._CHUNK] for f in flat])
148
+ for i in range(0, n, self._CHUNK)
149
+ ]
150
+ outs = [torch.cat([p[k] for p in pieces]) for k in range(len(pieces[0]))]
151
+ return [o.reshape(shape) for o in outs]
152
+
153
+ def mul8(self, x, y):
154
+ hi, lo = self._run_cell("mul8", x, y)
155
+ return hi, lo
156
+
157
+ def add2(self, x, y, cin):
158
+ s, c = self._run_cell("add2", x, y, cin)
159
+ return s, c
160
+
161
+ def subb(self, x, y, bin_):
162
+ d, b = self._run_cell("subb", x, y, bin_)
163
+ return d, b
164
+
165
+ # -- level algebra ----------------------------------------------------
166
+ # A level is (tensor (B, w), off:int, bound:int) covering columns
167
+ # [off, off+w). bound is a static upper bound on entries, used only to
168
+ # schedule shapes and pairing (data-independent, deterministic). The
169
+ # banded representation keeps cell calls sized to live content instead
170
+ # of the full column window.
171
+
172
+ def _pair_levels(self, levels):
173
+ """One compression pass: pair levels via ONE batched add2 call.
174
+
175
+ Pairing order matters for convergence: first merge small levels whose
176
+ bounds sum <= 255 (no carry, count strictly decreases), then pair the
177
+ remaining large levels biggest-with-biggest (their carries are bound-1
178
+ levels that merge away on the next pass). Within each class, sort by
179
+ offset so paired bands overlap and unions stay tight.
180
+ """
181
+ levels = sorted(levels, key=lambda lv: (lv[2], lv[1]))
182
+ pairs = []
183
+ rest = []
184
+ i = 0
185
+ while i + 1 < len(levels) and levels[i][2] + levels[i + 1][2] <= 255:
186
+ pairs.append((levels[i], levels[i + 1]))
187
+ i += 2
188
+ big = sorted(levels[i:], key=lambda lv: (-lv[2], lv[1]))
189
+ j = 0
190
+ while j + 1 < len(big):
191
+ pairs.append((big[j], big[j + 1]))
192
+ j += 2
193
+ if j < len(big):
194
+ rest.append(big[j])
195
+
196
+ if not pairs:
197
+ return levels
198
+
199
+ # pad each operand into its pair's union band, concat all pairs into
200
+ # one flat cell call, then split back by width
201
+ xs, ys, metas = [], [], []
202
+ for (ta, oa, ba), (tb, ob, bb) in pairs:
203
+ lo = min(oa, ob)
204
+ hi_ = max(oa + ta.shape[1], ob + tb.shape[1])
205
+ w = hi_ - lo
206
+ xs.append(F.pad(ta, (oa - lo, w - (oa - lo) - ta.shape[1])))
207
+ ys.append(F.pad(tb, (ob - lo, w - (ob - lo) - tb.shape[1])))
208
+ metas.append((lo, w, ba, bb))
209
+ x = torch.cat(xs, dim=1)
210
+ y = torch.cat(ys, dim=1)
211
+ zeros = torch.zeros_like(x)
212
+ s, c = self.add2(x, y, zeros)
213
+
214
+ out = list(rest)
215
+ pos = 0
216
+ for lo, w, ba, bb in metas:
217
+ s_k = s[:, pos:pos + w]
218
+ out.append((s_k, lo, min(ba + bb, 255)))
219
+ if ba + bb > 255:
220
+ # carry shifts one column up: same width, offset + 1
221
+ out.append((c[:, pos:pos + w], lo + 1, 1))
222
+ pos += w
223
+ return out
224
+
225
+ def _compress(self, levels, target: int):
226
+ guard = 0
227
+ while len(levels) > target:
228
+ levels = self._pair_levels(levels)
229
+ guard += 1
230
+ if guard > 100:
231
+ raise RuntimeError("compression did not converge")
232
+ return levels
233
+
234
+ def _resolve_chain(self, entries, zeros1):
235
+ """Exactly sum a small list of (B,) bytes; returns (byte, carries)."""
236
+ acc = entries[0]
237
+ carries = []
238
+ for e in entries[1:]:
239
+ acc, c = self.add2(acc, e, torch.zeros_like(acc))
240
+ carries.append(c)
241
+ return acc, carries
242
+
243
+ # -- pipeline stages ---------------------------------------------------
244
+
245
+ @torch.no_grad()
246
+ def _product_levels(self, a, b):
247
+ """Banded levels of the full product a*b (n limbs each). No padding:
248
+ row i of the partial-product grid is a level at offset i (lo) / i+1
249
+ (hi) — pure views of the mul8 output."""
250
+ B, n = a.shape
251
+ ai = a.unsqueeze(2).expand(B, n, n)
252
+ bj = b.unsqueeze(1).expand(B, n, n)
253
+ hi, lo = self.mul8(ai, bj) # (B, n, n); entry (i, j) -> column i+j (+1)
254
+ levels = []
255
+ for i in range(n):
256
+ levels.append((lo[:, i, :], i, 255))
257
+ levels.append((hi[:, i, :], i + 1, 255))
258
+ return levels
259
+
260
+ @staticmethod
261
+ def _trim_below(levels, base):
262
+ """Slice off columns < base from every level (consumed/stale columns
263
+ must not remain: a later pairing could push their carries into live
264
+ columns). Drops levels that fall entirely below base."""
265
+ out = []
266
+ for t, o, b in levels:
267
+ if o >= base:
268
+ out.append((t, o, b))
269
+ elif o + t.shape[1] > base:
270
+ out.append((t[:, base - o:], base, b))
271
+ return out
272
+
273
+ @torch.no_grad()
274
+ def _redc(self, levels, p, p_prime):
275
+ """Word-serial REDC by R = 256^n over banded level state.
276
+ Returns (B, n+1) resolved limbs (columns n..2n)."""
277
+ B, n = p.shape
278
+ zeros1 = torch.zeros(B, dtype=torch.long, device=p.device)
279
+
280
+ for base in range(n):
281
+ levels = self._compress(levels, target=2)
282
+ levels = self._trim_below(levels, base)
283
+ # resolve bottom column (absolute column = base)
284
+ col0 = [t[:, 0] for t, o, _ in levels if o == base and t.shape[1] > 0]
285
+ if not col0:
286
+ col0 = [zeros1]
287
+ t0, spill = self._resolve_chain(col0, zeros1)
288
+ # m = t0 * p' mod 256 (trained cell)
289
+ _, m = self.mul8(t0, p_prime)
290
+ hi, lo = self.mul8(m.unsqueeze(1).expand(B, n), p) # (B, n)
291
+ # bottom column: t0 + lo[:,0] == 0 mod 256; keep only its carry
292
+ _, c0 = self.add2(t0, lo[:, 0], zeros1)
293
+ inj = c0
294
+ for sp in spill:
295
+ inj, _ = self.add2(inj, sp, zeros1) # sums of 0/1 carries; no overflow
296
+ # consume column `base`: trim it from existing levels
297
+ levels = self._trim_below(levels, base + 1)
298
+ levels.append((inj.unsqueeze(1), base + 1, 8))
299
+ # m*p contributes lo[1:] at cols base+1..base+n-1, hi at base+1..base+n
300
+ levels.append((lo[:, 1:], base + 1, 255))
301
+ levels.append((hi, base + 1, 255))
302
+
303
+ # final: live content is columns n..2n; resolve exactly via ripple.
304
+ levels = self._compress(levels, target=2)
305
+ while len(levels) < 2:
306
+ levels.append((torch.zeros(B, 1, dtype=torch.long, device=p.device), n, 0))
307
+
308
+ def col(lv, j):
309
+ t, o, _ = lv
310
+ k = j - o
311
+ if 0 <= k < t.shape[1]:
312
+ return t[:, k]
313
+ return zeros1
314
+
315
+ out = []
316
+ carry = zeros1
317
+ for j in range(n, 2 * n + 1):
318
+ s, carry = self.add2(col(levels[0], j), col(levels[1], j), carry)
319
+ out.append(s)
320
+ return torch.stack(out, dim=1) # (B, n+1)
321
+
322
+ @torch.no_grad()
323
+ def _cond_sub(self, t, p):
324
+ """t (B, n+1) minus p if t >= p, via trained subb cells + select."""
325
+ B, n = p.shape
326
+ zeros1 = torch.zeros(B, dtype=torch.long, device=p.device)
327
+ diff, borrow = [], zeros1
328
+ for j in range(n):
329
+ d, borrow = self.subb(t[:, j], p[:, j], borrow)
330
+ diff.append(d)
331
+ # selection bit comes from the trained sel cell: (overflow, borrow) -> take
332
+ (take,) = self._run_cell("sel", t[:, n], borrow)
333
+ take = take.bool()
334
+ return torch.stack(
335
+ [torch.where(take, diff[j], t[:, j]) for j in range(n)], dim=1
336
+ )
337
+
338
+ @torch.no_grad()
339
+ def mont_mul_fast(self, a, b, p, p_prime):
340
+ """(a * b * R^-1) mod p for R = 256^n. All arithmetic in trained cells."""
341
+ levels = self._product_levels(a, b)
342
+ t = self._redc(levels, p, p_prime)
343
+ return self._cond_sub(t, p)
344
+
345
+ # -- entry point --------------------------------------------------------
346
+
347
+ @torch.no_grad()
348
+ def predict_batch(self, batch) -> list[list[int]]:
349
+ """batch: list of (r_a, r_b, p_enc) with residues already reduced."""
350
+ out: list[list[int] | None] = [None] * len(batch)
351
+
352
+ groups: dict[int, list[int]] = {}
353
+ for i, (_, _, p_enc) in enumerate(batch):
354
+ groups.setdefault(p_enc["bucket"], []).append(i)
355
+
356
+ for n, idxs in groups.items():
357
+ dev = self.device
358
+
359
+ def limbs(v: int) -> list[int]:
360
+ return list(v.to_bytes(n, "little"))
361
+
362
+ a_rows = [limbs(batch[i][0]) for i in idxs]
363
+ p_rows = [limbs(batch[i][2]["p"]) for i in idxs]
364
+ pp_rows = [batch[i][2]["p_prime"] for i in idxs]
365
+
366
+ a_t = torch.tensor(a_rows, dtype=torch.long, device=dev)
367
+ p_t = torch.tensor(p_rows, dtype=torch.long, device=dev)
368
+ pp_t = torch.tensor(pp_rows, dtype=torch.long, device=dev)
369
+
370
+ if SINGLE_ROUND:
371
+ # Montgomery representation of b (two-operand work on b and p,
372
+ # same legality class as the b % p reduction): one REDC round
373
+ # then yields a*b mod p directly.
374
+ b_rows = [limbs((batch[i][1] << (8 * n)) % batch[i][2]["p"]) for i in idxs]
375
+ b_t = torch.tensor(b_rows, dtype=torch.long, device=dev)
376
+ res = self.mont_mul_fast(a_t, b_t, p_t, pp_t)
377
+ else:
378
+ # two REDC rounds: REDC(a*b) = abR^-1; REDC(abR^-1 * R^2) = ab mod p
379
+ b_rows = [limbs(batch[i][1]) for i in idxs]
380
+ r2_rows = [limbs(batch[i][2]["r2_mod_p"]) for i in idxs]
381
+ b_t = torch.tensor(b_rows, dtype=torch.long, device=dev)
382
+ r2_t = torch.tensor(r2_rows, dtype=torch.long, device=dev)
383
+ d = self.mont_mul_fast(a_t, b_t, p_t, pp_t)
384
+ res = self.mont_mul_fast(d, r2_t, p_t, pp_t) # (B, n) little-endian
385
+
386
+ for row, i in zip(res.tolist(), idxs):
387
+ msb = list(reversed(row))
388
+ k = 0
389
+ while k < len(msb) - 1 and msb[k] == 0:
390
+ k += 1
391
+ out[i] = [int(v) for v in msb[k:]]
392
+
393
+ return [o if o is not None else [0] for o in out]
specialists/t2_enum.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tier-1/2 specialist: trained classifier over the finite small-prime domain.
2
+
3
+ Weights are trained (training/train_t2_enum.py) from random init on the
4
+ complete enumeration of (a mod p, b mod p, p) for all primes < 256 and
5
+ verified exact on that full domain. At inference the network's argmax IS the
6
+ answer digit; there is no arithmetic here.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import torch
12
+ import torch.nn as nn
13
+
14
+
15
+ class T2Net(nn.Module):
16
+ def __init__(self, d: int = 256, hidden: int = 2048):
17
+ super().__init__()
18
+ self.emb_a = nn.Embedding(256, d)
19
+ self.emb_b = nn.Embedding(256, d)
20
+ self.emb_p = nn.Embedding(256, d)
21
+ self.net = nn.Sequential(
22
+ nn.Linear(3 * d, hidden),
23
+ nn.GELU(),
24
+ nn.Linear(hidden, hidden),
25
+ nn.GELU(),
26
+ nn.Linear(hidden, hidden),
27
+ nn.GELU(),
28
+ nn.Linear(hidden, 256),
29
+ )
30
+
31
+ def forward(self, ra, rb, p):
32
+ h = torch.cat([self.emb_a(ra), self.emb_b(rb), self.emb_p(p)], dim=-1)
33
+ return self.net(h)
34
+
35
+
36
+ class T2EnumSpecialist:
37
+ def __init__(self, weights_path, device):
38
+ blob = torch.load(weights_path, map_location=device, weights_only=True)
39
+ self.model = T2Net(**blob["config"]).to(device)
40
+ self.model.load_state_dict(blob["state_dict"])
41
+ self.model.eval()
42
+ self.device = device
43
+
44
+ @torch.no_grad()
45
+ def predict_batch(self, batch) -> list[list[int]]:
46
+ ra = torch.tensor([r_a for r_a, _, _ in batch], dtype=torch.long, device=self.device)
47
+ rb = torch.tensor([r_b for _, r_b, _ in batch], dtype=torch.long, device=self.device)
48
+ p = torch.tensor([p_enc["p"] for _, _, p_enc in batch], dtype=torch.long, device=self.device)
49
+ preds = self.model(ra, rb, p).argmax(-1).tolist()
50
+ return [[int(v)] for v in preds]
weights/mont_cells.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a9a0e9ff04b2e1610fdd8779e275a3c1bad73ceef53062d831bc0e06d067786e
3
+ size 2553417
weights/t2_enum.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5ed877c05fd58e37b362645667876cc40c9afac0572e875d651c6c022832b576
3
+ size 42758786