bit-serial learned reducer (L=256): tiers 1-7 exact on local eval
Browse files- README.md +35 -0
- manifest.json +7 -0
- model.py +161 -0
- weights.pt +3 -0
README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# bitserial-modmul — learned modular multiplication
|
| 2 |
+
|
| 3 |
+
Submission for the SAIR Modular Arithmetic Challenge. One shared, p-conditioned
|
| 4 |
+
recurrent cell applied in a fixed bit-serial (Horner) loop computes `(a * b) mod p`.
|
| 5 |
+
The cell learns the per-step transition `s' = (2*s + d*x) mod p`, including the modular
|
| 6 |
+
wrap; the loop only sequences bits. Answers are emitted as base-2 digits and the
|
| 7 |
+
harness decoder reconstructs the integer.
|
| 8 |
+
|
| 9 |
+
## Local evaluation
|
| 10 |
+
|
| 11 |
+
`modchallenge evaluate`, 1100 problems, secret-seed unseen primes (tiers 2+):
|
| 12 |
+
|
| 13 |
+
- Tiers 1-7: exact-match 1.00 each.
|
| 14 |
+
- highest_tier_above_90 = 7, overall_accuracy = 0.706.
|
| 15 |
+
- Identical on two independent seeds.
|
| 16 |
+
|
| 17 |
+
## Provenance
|
| 18 |
+
|
| 19 |
+
The capability is in the trained parameters: randomizing the weights collapses every
|
| 20 |
+
solved tier to 0.00 (overall 0.006). No symbolic-math libraries, no big-integer modular
|
| 21 |
+
arithmetic in Python, no lookup tables. The reduction and the multiplication are
|
| 22 |
+
performed by the trained cell; the Python loop performs no arithmetic (only bit
|
| 23 |
+
indexing and feeding the cell). The static-analysis check passes. Each preprocessing
|
| 24 |
+
hook sees only its own argument.
|
| 25 |
+
|
| 26 |
+
The cell is a ~471K-parameter bidirectional GRU. It was trained from random
|
| 27 |
+
initialization on one-step modular transitions (modulus bit-length stratified,
|
| 28 |
+
wrap-boundary cases oversampled), with lr warmup + cosine decay. A single L=256 cell
|
| 29 |
+
covers tiers 1-7. See `manifest.json` for the full architecture and training summary.
|
| 30 |
+
|
| 31 |
+
## Interface
|
| 32 |
+
|
| 33 |
+
- `entry_class`: `model.BitSerialReducer`
|
| 34 |
+
- `output_base`: 2
|
| 35 |
+
- Files: `model.py`, `manifest.json`, `weights.pt`.
|
manifest.json
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"entry_class": "model.BitSerialReducer",
|
| 3 |
+
"output_base": 2,
|
| 4 |
+
"framework": "pytorch",
|
| 5 |
+
"model_description": "One shared, p-conditioned recurrent transition cell (~471K parameters: a bidirectional 2-layer GRU over three bit-channels, a control-bit embedding, a per-bit output head) applied in a fixed bit-serial Horner loop, at 256-bit state width. Each operand is tokenised per-argument into its MSB-first bit list; the modulus is fed as its 256-bit binary form (extracted via 32-bit limbs so values above 2^63 do not overflow). The cell maps (state_bits, multiplicand_bits, modulus_bits, control_bit) to the next state bits and is used with shared weights to reduce a mod p, reduce b mod p (multiplicand 1), and multiply the two residues (multiplicand a mod p, control bits scanning b mod p). Answers are emitted as base-2 digits and reconstructed by the harness decoder. State is carried as bits between steps; no integer reconstruction or modular product happens in Python. A single cell handles tiers 1-7 (primes below 2^256, operands up to 1024 bits); outside that regime it abstains and emits a single zero. Same architecture as bit-serial-v1/v2/v3, widened to 256 bits.",
|
| 6 |
+
"training_description": "Trained from random initialisation on one-step transitions s' = (2*s + d*x) mod p sampled over moduli covering tiers 1-7 (modulus bit-length stratified across 1-256 bits, wrap-boundary transitions oversampled). Objective is per-output-bit binary cross-entropy; optimiser AdamW (peak lr 1.5e-3, weight decay 0.01, gradient clipping) with lr warmup and cosine decay, on an NVIDIA H100; the reported weights are the best-by-validation checkpoint. No precomputed tables, no hand-coded reduction or multiplication: the per-step modular transition (including the conditional wrap, at most two subtractions of p since 2*s + d*x < 3*p) is what is learned; the loop only sequences the bits. The capability lives in the weights, so randomising them collapses exact-match accuracy to chance. Evaluation primes are unseen during training (secret seed)."
|
| 7 |
+
}
|
model.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Bit-serial learned reducer (general width) for the Modular Arithmetic Challenge.
|
| 2 |
+
|
| 3 |
+
Same design as bit-serial-v1/v2: one shared, p-conditioned transition cell that
|
| 4 |
+
learned s' = (2*s + d*x) mod p, applied in a fixed bit-serial Horner loop (reduce a,
|
| 5 |
+
reduce b, multiply). The arithmetic is in the trained cell; the loop only sequences
|
| 6 |
+
bits. Randomising the weights collapses accuracy to chance.
|
| 7 |
+
|
| 8 |
+
This version generalises the state width to L (read from the checkpoint), so it
|
| 9 |
+
covers tiers up to whatever L the weights were trained for. Bit extraction uses
|
| 10 |
+
32-bit limbs (`to_bits_limbs`) so a modulus p >= 2^63 never overflows an int64
|
| 11 |
+
tensor (needed at L >= 64). State is carried as bits between steps; the harness
|
| 12 |
+
decoder reconstructs the integer answer from the emitted base-2 digits.
|
| 13 |
+
|
| 14 |
+
Regime: primes p < 2^L and operands up to 4*L bits. Outside it the model abstains
|
| 15 |
+
and emits [0] -- the honest fallback.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
|
| 22 |
+
import torch
|
| 23 |
+
from torch import nn
|
| 24 |
+
|
| 25 |
+
from modchallenge.interface.base_model import ModularMultiplicationModel
|
| 26 |
+
|
| 27 |
+
_MASK32 = (1 << 32) - 1
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _to_bits_small(vals: torch.Tensor, width: int) -> torch.Tensor:
|
| 31 |
+
shifts = torch.arange(width - 1, -1, -1, device=vals.device)
|
| 32 |
+
return (vals[:, None] >> shifts[None, :]) & 1
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def to_bits_limbs(ints, dev, width: int) -> torch.Tensor:
|
| 36 |
+
"""List of python ints (< 2^width) -> (N, width) MSB-first bit tensor via 32-bit limbs.
|
| 37 |
+
|
| 38 |
+
Overflow-safe for any width: no int64 tensor ever holds a value >= 2^32."""
|
| 39 |
+
nl = (width + 31) // 32
|
| 40 |
+
cols = []
|
| 41 |
+
for k in range(nl - 1, -1, -1): # most-significant limb first
|
| 42 |
+
limb = torch.tensor([(v >> (32 * k)) & _MASK32 for v in ints],
|
| 43 |
+
dtype=torch.int64, device=dev)
|
| 44 |
+
cols.append(_to_bits_small(limb, 32))
|
| 45 |
+
bits = torch.cat(cols, dim=1)
|
| 46 |
+
return bits[:, nl * 32 - width:] if width < nl * 32 else bits
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class Cell(nn.Module):
|
| 50 |
+
def __init__(self, dmodel: int = 96, hidden: int = 128):
|
| 51 |
+
super().__init__()
|
| 52 |
+
self.in_proj = nn.Linear(3, dmodel)
|
| 53 |
+
self.d_emb = nn.Embedding(2, dmodel)
|
| 54 |
+
self.gru = nn.GRU(dmodel, hidden, num_layers=2, batch_first=True, bidirectional=True)
|
| 55 |
+
self.head = nn.Linear(2 * hidden, 1)
|
| 56 |
+
|
| 57 |
+
def forward(self, feat, d):
|
| 58 |
+
x = self.in_proj(feat) + self.d_emb(d)[:, None, :]
|
| 59 |
+
h, _ = self.gru(x)
|
| 60 |
+
return self.head(h).squeeze(-1)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _bits_of(n: int) -> list[int]:
|
| 64 |
+
if n <= 0:
|
| 65 |
+
return [0]
|
| 66 |
+
out: list[int] = []
|
| 67 |
+
while n > 0:
|
| 68 |
+
out.append(n & 1)
|
| 69 |
+
n >>= 1
|
| 70 |
+
out.reverse()
|
| 71 |
+
return out
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class BitSerialReducer(ModularMultiplicationModel):
|
| 75 |
+
def __init__(self) -> None:
|
| 76 |
+
self.model: Cell | None = None
|
| 77 |
+
self.device: torch.device | None = None
|
| 78 |
+
self.L = 32
|
| 79 |
+
|
| 80 |
+
def load(self, model_dir: str) -> None:
|
| 81 |
+
if torch.cuda.is_available():
|
| 82 |
+
self.device = torch.device("cuda")
|
| 83 |
+
elif torch.backends.mps.is_available():
|
| 84 |
+
self.device = torch.device("mps")
|
| 85 |
+
else:
|
| 86 |
+
self.device = torch.device("cpu")
|
| 87 |
+
ckpt = torch.load(Path(model_dir) / "weights.pt", map_location=self.device, weights_only=True)
|
| 88 |
+
self.L = int(ckpt.get("L", 32))
|
| 89 |
+
self.model = Cell(**ckpt.get("config", {}))
|
| 90 |
+
self.model.load_state_dict(ckpt["state_dict"])
|
| 91 |
+
self.model.to(self.device)
|
| 92 |
+
self.model.eval()
|
| 93 |
+
|
| 94 |
+
def preprocess_a(self, a):
|
| 95 |
+
return _bits_of(int(a))
|
| 96 |
+
|
| 97 |
+
def preprocess_b(self, b):
|
| 98 |
+
return _bits_of(int(b))
|
| 99 |
+
|
| 100 |
+
def preprocess_p(self, p):
|
| 101 |
+
return int(p)
|
| 102 |
+
|
| 103 |
+
@torch.no_grad()
|
| 104 |
+
def predict_digits(self, a_enc, b_enc, p_enc):
|
| 105 |
+
return self.predict_digits_batch([(a_enc, b_enc, p_enc)])[0]
|
| 106 |
+
|
| 107 |
+
@torch.no_grad()
|
| 108 |
+
def predict_digits_batch(self, inputs):
|
| 109 |
+
L = self.L
|
| 110 |
+
max_op = 4 * L
|
| 111 |
+
out: list[list[int]] = [[0] for _ in inputs]
|
| 112 |
+
idx, a_lists, b_lists, p_vals = [], [], [], []
|
| 113 |
+
for i, (a_enc, b_enc, p_enc) in enumerate(inputs):
|
| 114 |
+
p = int(p_enc)
|
| 115 |
+
a_bits = list(a_enc)
|
| 116 |
+
b_bits = list(b_enc)
|
| 117 |
+
if p < 2 or p >= (1 << L) or len(a_bits) > max_op or len(b_bits) > max_op:
|
| 118 |
+
continue
|
| 119 |
+
idx.append(i)
|
| 120 |
+
a_lists.append(a_bits)
|
| 121 |
+
b_lists.append(b_bits)
|
| 122 |
+
p_vals.append(p)
|
| 123 |
+
if not idx:
|
| 124 |
+
return out
|
| 125 |
+
dev = self.device
|
| 126 |
+
p_bits = to_bits_limbs(p_vals, dev, L).float()
|
| 127 |
+
ra = self._reduce(a_lists, p_bits, dev)
|
| 128 |
+
rb = self._reduce(b_lists, p_bits, dev)
|
| 129 |
+
prod = self._mul(ra, rb, p_bits)
|
| 130 |
+
prod_list = prod.long().tolist()
|
| 131 |
+
for j, i in enumerate(idx):
|
| 132 |
+
out[i] = [int(x) for x in prod_list[j]]
|
| 133 |
+
return out
|
| 134 |
+
|
| 135 |
+
def max_batch_size(self) -> int:
|
| 136 |
+
return 256
|
| 137 |
+
|
| 138 |
+
def _step(self, s_bits, x_bits, p_bits, d):
|
| 139 |
+
feat = torch.stack([s_bits, x_bits, p_bits], dim=-1)
|
| 140 |
+
return (torch.sigmoid(self.model(feat, d)) > 0.5).float()
|
| 141 |
+
|
| 142 |
+
def _reduce(self, bit_lists, p_bits, dev):
|
| 143 |
+
n = len(bit_lists)
|
| 144 |
+
width = max(len(b) for b in bit_lists)
|
| 145 |
+
padded = torch.zeros((n, width), dtype=torch.long, device=dev)
|
| 146 |
+
for r, bl in enumerate(bit_lists):
|
| 147 |
+
if bl:
|
| 148 |
+
padded[r, width - len(bl):] = torch.tensor(bl, dtype=torch.long, device=dev)
|
| 149 |
+
s_bits = torch.zeros((n, self.L), device=dev)
|
| 150 |
+
x_bits = to_bits_limbs([1] * n, dev, self.L).float()
|
| 151 |
+
for pos in range(width):
|
| 152 |
+
s_bits = self._step(s_bits, x_bits, p_bits, padded[:, pos])
|
| 153 |
+
return s_bits
|
| 154 |
+
|
| 155 |
+
def _mul(self, ra_bits, rb_bits, p_bits):
|
| 156 |
+
n = ra_bits.shape[0]
|
| 157 |
+
s_bits = torch.zeros((n, self.L), device=ra_bits.device)
|
| 158 |
+
rb_long = rb_bits.long()
|
| 159 |
+
for k in range(self.L):
|
| 160 |
+
s_bits = self._step(s_bits, ra_bits, p_bits, rb_long[:, k])
|
| 161 |
+
return s_bits
|
weights.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:c6a9faead2b4574be35e05caab65194d762405fb52bfcb7137323cf1774364cc
|
| 3 |
+
size 1887610
|