File size: 7,775 Bytes
bf2e332 | 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 | """Bit-serial learned reducer for the Modular Arithmetic Challenge.
A single shared, p-conditioned transition cell, applied in a fixed bit loop,
computes ``(a * b) mod p``. The cell learns the per-step transition
s' = (2*s + d*x) mod p
(state ``s``, multiplicand ``x``, control bit ``d``, modulus ``p``). The Python
loop only sequences the bits most-significant-first (Horner form) -- the
explicitly-allowed recurrent / looped structure. No modular product is computed
in Python or in hand-coded tensor arithmetic: every reduction and the multiply
itself are produced by the trained cell. Randomising the weights collapses
accuracy to chance, which is the operational provenance test.
Pipeline per problem (a, b, p):
reduce(a) = scan bits of a MSB-first with x=1 -> a mod p
reduce(b) = scan bits of b MSB-first with x=1 -> b mod p
multiply = scan bits of (b mod p) MSB-first with x=(a mod p) -> (a*b) mod p
State is carried as bits between steps (no integer reconstruction inside the
loop); the harness decoder reconstructs the integer answer from the emitted
base-2 digits.
Regime: the cell is trained for primes ``p < 2^32`` and operands up to 96 bits
(tiers 1-4). Outside that regime the model abstains and emits ``[0]`` -- the
honest fallback -- rather than running the cell out of distribution.
"""
from __future__ import annotations
from pathlib import Path
import torch
from torch import nn
from modchallenge.interface.base_model import ModularMultiplicationModel
# State / modulus bit-width. Covers tiers 1-4: every prime there is < 2^32, and
# every residue is < p, so 32 bits hold both the state and the modulus features.
L = 32
# Tiers 1-4 operands are at most 96 bits. Beyond this we are out of regime.
MAX_OP_BITS = 96
def to_bits(vals: torch.Tensor, width: int = L) -> torch.Tensor:
"""Small non-negative ints -> (N, width) bit tensor, MSB-first.
Used only on the modulus and the constant multiplicand x=1 (both small);
this is representation, not arithmetic on the operands.
"""
shifts = torch.arange(width - 1, -1, -1, device=vals.device)
return (vals[:, None] >> shifts[None, :]) & 1
class Cell(nn.Module):
"""Shared per-step transition: (s_bits, x_bits, p_bits, d) -> next s_bits.
The three bit-channels are read as a length-L sequence by a bidirectional
GRU; the control bit d is injected as an embedding. The head emits one
logit per output bit position. The same weights are used for the reduce
steps (x=1) and the multiply steps (x = a mod p)."""
def __init__(self, dmodel: int = 96, hidden: int = 128):
super().__init__()
self.in_proj = nn.Linear(3, dmodel)
self.d_emb = nn.Embedding(2, dmodel)
self.gru = nn.GRU(
dmodel, hidden, num_layers=2, batch_first=True, bidirectional=True
)
self.head = nn.Linear(2 * hidden, 1)
def forward(self, feat: torch.Tensor, d: torch.Tensor) -> torch.Tensor:
x = self.in_proj(feat) + self.d_emb(d)[:, None, :]
h, _ = self.gru(x)
return self.head(h).squeeze(-1)
def _bits_of(n: int) -> list[int]:
"""Non-negative int -> MSB-first bit list. Per-argument tokenisation."""
if n <= 0:
return [0]
out: list[int] = []
while n > 0:
out.append(n & 1)
n >>= 1
out.reverse()
return out
class BitSerialReducer(ModularMultiplicationModel):
def __init__(self) -> None:
self.model: Cell | None = None
self.device: torch.device | None = None
# -- lifecycle ------------------------------------------------------
def load(self, model_dir: str) -> None:
if torch.backends.mps.is_available():
self.device = torch.device("mps")
elif torch.cuda.is_available():
self.device = torch.device("cuda")
else:
self.device = torch.device("cpu")
ckpt = torch.load(
Path(model_dir) / "weights.pt",
map_location=self.device,
weights_only=True,
)
self.model = Cell(**ckpt.get("config", {}))
self.model.load_state_dict(ckpt["state_dict"])
self.model.to(self.device)
self.model.eval()
# -- per-argument tokenisation (each hook sees only its own argument) --
def preprocess_a(self, a):
return _bits_of(int(a))
def preprocess_b(self, b):
return _bits_of(int(b))
def preprocess_p(self, p):
return int(p)
# -- inference ------------------------------------------------------
@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]] = [[0] for _ in inputs]
idx, a_lists, b_lists, p_vals = [], [], [], []
for i, (a_enc, b_enc, p_enc) in enumerate(inputs):
p = int(p_enc)
a_bits = list(a_enc)
b_bits = list(b_enc)
# Out of the trained regime (tiers 4+, or tier-0 giant operands):
# abstain instead of running the cell out of distribution.
if (
p < 2
or p >= (1 << L)
or len(a_bits) > MAX_OP_BITS
or len(b_bits) > MAX_OP_BITS
):
continue
idx.append(i)
a_lists.append(a_bits)
b_lists.append(b_bits)
p_vals.append(p)
if not idx:
return out
dev = self.device
p_bits = to_bits(torch.tensor(p_vals, device=dev)).float()
ra = self._reduce(a_lists, p_bits, dev) # (N, L) residue bits
rb = self._reduce(b_lists, p_bits, dev)
prod = self._mul(ra, rb, p_bits) # (N, L) answer bits
prod_list = prod.long().tolist()
for j, i in enumerate(idx):
out[i] = [int(x) for x in prod_list[j]]
return out
def max_batch_size(self) -> int:
return 256
# -- internals ------------------------------------------------------
def _step(self, s_bits, x_bits, p_bits, d):
feat = torch.stack([s_bits, x_bits, p_bits], dim=-1)
logits = self.model(feat, d)
return (torch.sigmoid(logits) > 0.5).float()
def _reduce(self, bit_lists, p_bits, dev):
"""Free-running Horner reduction of each operand to its residue mod p.
x is fixed to 1; the control bit at each step is the operand bit.
Leading zeros (from padding shorter operands to the batch width) keep
the state at 0, so padding is harmless."""
n = len(bit_lists)
width = max(len(b) for b in bit_lists)
padded = torch.zeros((n, width), dtype=torch.long, device=dev)
for r, bl in enumerate(bit_lists):
if bl:
padded[r, width - len(bl):] = torch.tensor(
bl, dtype=torch.long, device=dev
)
s_bits = torch.zeros((n, L), device=dev)
x_bits = to_bits(torch.ones(n, dtype=torch.long, device=dev)).float()
for pos in range(width): # MSB-first
s_bits = self._step(s_bits, x_bits, p_bits, padded[:, pos])
return s_bits
def _mul(self, ra_bits, rb_bits, p_bits):
"""(a mod p) * (b mod p) mod p via Horner over the residue's L bits.
x is fixed to (a mod p); the control bit at each step is a bit of
(b mod p), scanned MSB-first."""
n = ra_bits.shape[0]
s_bits = torch.zeros((n, L), device=ra_bits.device)
rb_long = rb_bits.long()
for k in range(L): # MSB-first over the 16-bit residue
s_bits = self._step(s_bits, ra_bits, p_bits, rb_long[:, k])
return s_bits
|