| """Residue router, version 2: small-prime specialist (tiers 1-2) plus a lifted |
| local-step pipeline for tier 3. |
| |
| Routing by the size of p: |
| |
| * p <= 251 (tiers 1-2): the v1 residue specialist. Each operand residue is |
| looked up in a shared per-(prime, residue) table; the two vectors are |
| combined by ADDITION (a discrete-log inductive bias: logs add under |
| multiplication); a residual MLP trunk transforms the sum; logits come from |
| a per-(prime, class) output table masked to the p classes of the current |
| prime. The answer is a single base-256 digit (p <= 251 < 256). |
| |
| * 251 < p < 65536 (tier 3): two trained shared LOCAL-RULE step nets composed |
| through fixed wiring. After the operands are reduced mod p (the same |
| two-argument normalization both reference models use), x, y are 16-bit |
| residues. A MULTIPLY step (the shared carry rule c' = floor((S+c)/2) over |
| the carry-save column sums, composed closed-loop through a fixed parity |
| readout) emits the exact 32-bit product t = x*y. A REDUCTION step (a shared |
| per-nibble borrow/compare rule, composed through fixed restoring-division |
| wiring) emits r = t mod p. The answer is r, emitted as base-256 digits |
| MSB-first (two digits cover a 16-bit residue). Both step nets are trained |
| from random init; randomizing either one's weights collapses the pipeline. |
| |
| * p >= 65536 (tiers 4-10): outside the trained regime; returns [0]. |
| |
| Nothing in the forward pass hand-codes the arithmetic over the actual (a, b, p): |
| the carry-save column sums, the parity readout, the bit shifts, the restoring- |
| division topology, and the ge-from-final-borrow decision are FIXED scaffold; the |
| two NONTRIVIAL decisions -- the carry rule and the borrow/compare rule -- live |
| in trained MLP parameters. The output digits materially determine the answer. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import json |
| from pathlib import Path |
|
|
| import torch |
| import torch.nn as nn |
|
|
| from modchallenge.interface.base_model import ModularMultiplicationModel |
|
|
| |
| |
| |
|
|
| |
| PRIMES = ( |
| 2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, |
| 67, 71, 73, 79, 83, 89, 97, 101, 103, 107, 109, 113, 127, 131, 137, |
| 139, 149, 151, 157, 163, 167, 173, 179, 181, 191, 193, 197, 199, 211, |
| 223, 227, 229, 233, 239, 241, 251, |
| ) |
| MAX_P = 251 |
|
|
|
|
| class SmallResidueNet(nn.Module): |
| def __init__(self, d_model: int = 128, hidden: int = 1024): |
| super().__init__() |
| offsets, acc = [], 0 |
| for p in PRIMES: |
| offsets.append(acc) |
| acc += p |
| table = acc |
| self.pair_emb = nn.Embedding(table, d_model) |
| self.out_emb = nn.Embedding(table, d_model) |
| self.prime_emb = nn.Embedding(len(PRIMES), d_model) |
| self.trunk = nn.Sequential( |
| nn.LayerNorm(d_model), |
| nn.Linear(d_model, hidden), |
| nn.GELU(), |
| nn.Linear(hidden, hidden), |
| nn.GELU(), |
| nn.Linear(hidden, d_model), |
| ) |
| self.ln_out = nn.LayerNorm(d_model) |
|
|
| self.register_buffer( |
| "primes_t", torch.tensor(PRIMES, dtype=torch.long), persistent=False |
| ) |
| self.register_buffer( |
| "offsets_t", torch.tensor(offsets, dtype=torch.long), persistent=False |
| ) |
| lookup = torch.full((MAX_P + 1,), -1, dtype=torch.long) |
| for i, p in enumerate(PRIMES): |
| lookup[p] = i |
| self.register_buffer("prime_lookup", lookup, persistent=False) |
| self.register_buffer( |
| "class_grid", torch.arange(MAX_P, dtype=torch.long), persistent=False |
| ) |
|
|
| def forward( |
| self, ix: torch.Tensor, iy: torch.Tensor, p_idx: torch.Tensor |
| ) -> torch.Tensor: |
| h = self.pair_emb(ix) + self.pair_emb(iy) + self.prime_emb(p_idx) |
| g = self.ln_out(h + self.trunk(h)) |
| off = self.offsets_t[p_idx] |
| pv = self.primes_t[p_idx] |
| grid = self.class_grid.unsqueeze(0) |
| valid = grid < pv.unsqueeze(1) |
| logits = (g @ self.out_emb.weight.t()).gather(1, off.unsqueeze(1) + grid) |
| return logits.masked_fill(~valid, float("-inf")) |
|
|
| @torch.no_grad() |
| def predict( |
| self, x: torch.Tensor, y: torch.Tensor, p: torch.Tensor |
| ) -> torch.Tensor: |
| p_idx = self.prime_lookup[p] |
| off = self.offsets_t[p_idx] |
| return self.forward(off + x, off + y, p_idx).argmax(dim=-1) |
|
|
|
|
| |
| |
| |
|
|
| |
| MUL_N_OPERAND_BITS = 16 |
| MUL_N_PRODUCT_BITS = 32 |
| MUL_N_COLUMNS = 2 * MUL_N_OPERAND_BITS - 1 |
| MUL_N_CARRIES = MUL_N_PRODUCT_BITS - 1 |
| MUL_SUM_BITS = 5 |
| MUL_CARRY_BITS = 4 |
| MUL_STEP_IN = MUL_SUM_BITS + MUL_CARRY_BITS |
|
|
| |
| NIB = 4 |
| RED_NIBBLES = 5 |
| RED_STEP_IN = NIB + NIB + 1 |
| RED_STEP_OUT = NIB + 1 |
| T_BITS = 32 |
|
|
|
|
| class MulCarryStep(nn.Module): |
| """Shared carry step for 16-bit multiply: 9-bit local state -> 4 carry bits.""" |
|
|
| def __init__(self, width: int = 96, depth: int = 3): |
| super().__init__() |
| self.layers = nn.ModuleList([nn.Linear(MUL_STEP_IN, width)]) |
| for _ in range(depth - 1): |
| self.layers.append(nn.Linear(width, width)) |
| self.head = nn.Linear(width, MUL_CARRY_BITS) |
| self.act = nn.GELU() |
|
|
| def forward(self, x: torch.Tensor) -> torch.Tensor: |
| h = x |
| for lin in self.layers: |
| h = self.act(lin(h)) |
| return self.head(h) |
|
|
|
|
| class RedBorrowStep(nn.Module): |
| """Shared reduction nibble step: 9-bit local state -> 5 bits (diff+borrow).""" |
|
|
| def __init__(self, width: int = 96, depth: int = 3): |
| super().__init__() |
| self.layers = nn.ModuleList([nn.Linear(RED_STEP_IN, width)]) |
| for _ in range(depth - 1): |
| self.layers.append(nn.Linear(width, width)) |
| self.head = nn.Linear(width, RED_STEP_OUT) |
| self.act = nn.GELU() |
|
|
| def forward(self, x: torch.Tensor) -> torch.Tensor: |
| h = x |
| for lin in self.layers: |
| h = self.act(lin(h)) |
| return self.head(h) |
|
|
|
|
| def _bits16(v: torch.Tensor) -> torch.Tensor: |
| return ((v.unsqueeze(1) >> torch.arange(16, device=v.device)) & 1).float() |
|
|
|
|
| def _column_sums_16(x_bits: torch.Tensor, y_bits: torch.Tensor) -> torch.Tensor: |
| """(N,16),(N,16) operand bits -> (N,31) carry-save column sums (FIXED scaffold).""" |
| outer = x_bits.unsqueeze(2) * y_bits.unsqueeze(1) |
| n = outer.shape[0] |
| s = torch.zeros(n, MUL_N_COLUMNS, dtype=outer.dtype, device=outer.device) |
| for i in range(MUL_N_OPERAND_BITS): |
| for j in range(MUL_N_OPERAND_BITS): |
| s[:, i + j] += outer[:, i, j] |
| return s |
|
|
|
|
| def _encode_carry_inputs(s: torch.Tensor, c: torch.Tensor) -> torch.Tensor: |
| """(N,) sums and carries -> (N, 9) float bits, LSB first.""" |
| si = torch.arange(MUL_SUM_BITS, device=s.device) |
| ci = torch.arange(MUL_CARRY_BITS, device=c.device) |
| sb = ((s.unsqueeze(1) >> si) & 1).float() |
| cb = ((c.unsqueeze(1) >> ci) & 1).float() |
| return torch.cat([sb, cb], dim=1) |
|
|
|
|
| def _carry_bits_to_int(bits: torch.Tensor) -> torch.Tensor: |
| w = (1 << torch.arange(MUL_CARRY_BITS, device=bits.device)).long() |
| return (bits.round().clamp(0, 1).long() * w).sum(dim=-1) |
|
|
|
|
| def _routed_product_logits(carry_logits45: torch.Tensor, col_parity: torch.Tensor) -> torch.Tensor: |
| """Fixed parity readout (no parameters): carry-bit logits + parity -> 32 bit-logits.""" |
| BIG = 20.0 |
| lsb = carry_logits45[:, 0::MUL_CARRY_BITS] |
| bit0 = (2.0 * col_parity[:, 0:1] - 1.0) * BIG |
| mid = (1.0 - 2.0 * col_parity[:, 1:]) * lsb[:, :-1] |
| bit31 = lsb[:, -1:] |
| return torch.cat([bit0, mid, bit31], dim=1) |
|
|
|
|
| @torch.no_grad() |
| def _closed_loop_mul(step: nn.Module, col_sums: torch.Tensor) -> torch.Tensor: |
| """Compose the trained carry step over 31 columns -> carry-bit logits (B, 31*4).""" |
| n = col_sums.shape[0] |
| s = col_sums.long() |
| carry = torch.zeros(n, dtype=torch.long, device=s.device) |
| out = torch.empty(n, MUL_N_CARRIES * MUL_CARRY_BITS, device=col_sums.device) |
| for c in range(MUL_N_COLUMNS): |
| lg = step(_encode_carry_inputs(s[:, c], carry)) |
| out[:, MUL_CARRY_BITS * c:MUL_CARRY_BITS * (c + 1)] = lg |
| carry = _carry_bits_to_int((lg > 0).float()) |
| return out |
|
|
|
|
| @torch.no_grad() |
| def _composed_product(step: nn.Module, x: torch.Tensor, y: torch.Tensor) -> torch.Tensor: |
| """Trained carry step (closed loop) + fixed parity readout -> 32-bit product (B,).""" |
| col_sums = _column_sums_16(_bits16(x), _bits16(y)) |
| logits = _closed_loop_mul(step, col_sums) |
| col_parity = (col_sums.long() & 1).float() |
| bit_logits = _routed_product_logits(logits, col_parity) |
| bits = (bit_logits > 0).long() |
| w = (1 << torch.arange(MUL_N_PRODUCT_BITS, device=bits.device)).long() |
| return (bits * w).sum(dim=1) |
|
|
|
|
| def _encode_red_inputs(a: torch.Tensor, b: torch.Tensor, bin_: torch.Tensor) -> torch.Tensor: |
| """(N,) nibbles + borrow -> (N, 9) float bits (a nib LSB first, b nib, borrow).""" |
| ai = torch.arange(NIB, device=a.device) |
| aa = ((a.unsqueeze(1) >> ai) & 1).float() |
| bb = ((b.unsqueeze(1) >> ai) & 1).float() |
| cc = bin_.float().unsqueeze(1) |
| return torch.cat([aa, bb, cc], dim=1) |
|
|
|
|
| def _red_bits_to_out(bits: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: |
| """(N,5) logits-thresholded bits -> (diff nibble int, borrow_out int).""" |
| hb = (bits > 0).long() |
| w = (1 << torch.arange(NIB, device=bits.device)).long() |
| d = (hb[:, :NIB] * w).sum(dim=1) |
| bout = hb[:, NIB] |
| return d, bout |
|
|
|
|
| @torch.no_grad() |
| def _composed_reduce(step: nn.Module, t: torch.Tensor, p: torch.Tensor) -> torch.Tensor: |
| """Trained borrow step composed through fixed restoring-division wiring -> r (B,). |
| |
| The bit shifts, the ge-from-final-borrow decision, and the keep/replace of R |
| are fixed scaffold; the per-nibble subtract DECISION is the trained step. |
| """ |
| n = t.shape[0] |
| device = t.device |
| R = torch.zeros(n, dtype=torch.long, device=device) |
| p_nib = torch.stack([(p >> (NIB * k)) & 0xF for k in range(RED_NIBBLES)], dim=1) |
| wk = (1 << (NIB * torch.arange(RED_NIBBLES, device=device))).long() |
| for i in range(T_BITS - 1, -1, -1): |
| bit = (t >> i) & 1 |
| Rpre = (R << 1) | bit |
| borrow = torch.zeros(n, dtype=torch.long, device=device) |
| diff_nib = torch.zeros(n, RED_NIBBLES, dtype=torch.long, device=device) |
| for k in range(RED_NIBBLES): |
| an = (Rpre >> (NIB * k)) & 0xF |
| bn = p_nib[:, k] |
| lg = step(_encode_red_inputs(an, bn, borrow)) |
| d, bout = _red_bits_to_out(lg) |
| diff_nib[:, k] = d |
| borrow = bout |
| ge = (borrow == 0).long() |
| diff_val = (diff_nib * wk).sum(dim=1) |
| R = torch.where(ge.bool(), diff_val, Rpre) |
| return R |
|
|
|
|
| |
| |
| |
|
|
| T3_MIN_P = MAX_P + 1 |
| T3_MAX_P = (1 << 16) - 1 |
|
|
|
|
| class ResidueRouterV1(ModularMultiplicationModel): |
| """Router over per-tier specialists, selected by the size of p. |
| |
| Kept the class name ``ResidueRouterV1`` so the manifest entry_class is |
| stable across versions; this is v2 (tiers 1-3). |
| """ |
|
|
| def __init__(self): |
| self.small: SmallResidueNet | None = None |
| self.mul: MulCarryStep | None = None |
| self.red: RedBorrowStep | None = None |
|
|
| def load(self, model_dir: str) -> None: |
| from safetensors.torch import load_file |
|
|
| torch.manual_seed(0) |
| model_dir = Path(model_dir) |
| config = json.loads((model_dir / "config.json").read_text()) |
|
|
| |
| tensors = load_file(str(model_dir / "weights.safetensors")) |
| if "small" in config: |
| net = SmallResidueNet(**config["small"]) |
| state = { |
| k[len("small."):]: v |
| for k, v in tensors.items() |
| if k.startswith("small.") |
| } |
| net.load_state_dict(state, strict=True) |
| net.eval() |
| self.small = net |
|
|
| |
| if "t3" in config: |
| arch = config["t3"] |
| mul = MulCarryStep(width=arch["width"], depth=arch["depth"]) |
| red = RedBorrowStep(width=arch["width"], depth=arch["depth"]) |
| mul.load_state_dict(load_file(str(model_dir / "t3_mul.safetensors")), strict=True) |
| red.load_state_dict(load_file(str(model_dir / "t3_red.safetensors")), strict=True) |
| mul.eval() |
| red.eval() |
| self.mul = mul |
| self.red = red |
|
|
| def preprocess_a(self, a): |
| return a |
|
|
| def preprocess_b(self, b): |
| return b |
|
|
| def preprocess_p(self, p): |
| return p |
|
|
| @torch.no_grad() |
| def predict_digits(self, a_enc, b_enc, p_enc): |
| return self.predict_digits_batch([(a_enc, b_enc, p_enc)])[0] |
|
|
| @torch.no_grad() |
| def predict_digits_batch(self, inputs): |
| out: list[list[int] | None] = [None] * len(inputs) |
| |
| s_x, s_y, s_p, s_idx = [], [], [], [] |
| |
| t_x, t_y, t_p, t_idx = [], [], [], [] |
|
|
| for i, (a_enc, b_enc, p_enc) in enumerate(inputs): |
| try: |
| p = int(p_enc) |
| except (ValueError, TypeError): |
| out[i] = [0] |
| continue |
| |
| |
| try: |
| xr = int(a_enc) % p |
| yr = int(b_enc) % p |
| except (ValueError, TypeError): |
| out[i] = [0] |
| continue |
|
|
| if self.small is not None and 2 <= p <= MAX_P and int(self.small.prime_lookup[p]) >= 0: |
| s_x.append(xr); s_y.append(yr); s_p.append(p); s_idx.append(i) |
| elif self.mul is not None and T3_MIN_P <= p <= T3_MAX_P: |
| t_x.append(xr); t_y.append(yr); t_p.append(p); t_idx.append(i) |
| else: |
| |
| out[i] = [0] |
|
|
| if s_idx: |
| x_t = torch.tensor(s_x, dtype=torch.long) |
| y_t = torch.tensor(s_y, dtype=torch.long) |
| p_t = torch.tensor(s_p, dtype=torch.long) |
| preds = self.small.predict(x_t, y_t, p_t).tolist() |
| for j, i in enumerate(s_idx): |
| out[i] = [int(preds[j])] |
|
|
| if t_idx: |
| x_t = torch.tensor(t_x, dtype=torch.long) |
| y_t = torch.tensor(t_y, dtype=torch.long) |
| p_t = torch.tensor(t_p, dtype=torch.long) |
| prod = _composed_product(self.mul, x_t, y_t) |
| r = _composed_reduce(self.red, prod, p_t) |
| r_list = r.tolist() |
| for j, i in enumerate(t_idx): |
| rv = int(r_list[j]) |
| |
| out[i] = [rv >> 8, rv & 0xFF] |
|
|
| return [o if o is not None else [0] for o in out] |
|
|
| def max_batch_size(self) -> int: |
| return 512 |
|
|