TrickyRex commited on
Commit
68af9e7
·
verified ·
1 Parent(s): c01145c

bit-serial learned reducer (L=256): tiers 1-7 exact on local eval

Browse files
Files changed (4) hide show
  1. README.md +35 -0
  2. manifest.json +7 -0
  3. model.py +161 -0
  4. 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