alstrup commited on
Commit
14bef4a
·
verified ·
1 Parent(s): a7f7d2a

modmul router v0: t12 + composed-t3b members (pre-t3g)

Browse files
composed_encoding.py ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Composed multiply+reduce CoT — the DEPLOYABLE surface.
2
+
3
+ divcot validates the reduction in isolation by feeding N=x*y as input; a real
4
+ submission only gets (x, y, p) with x,y < p. This encoding chains the two
5
+ validated scaffolds in one generated trace:
6
+
7
+ 1. schoolbook multiply as a running accumulator: after consuming each y-limb
8
+ y_j the model emits acc_j = acc_{j-1} + x*y_j*B^j (2W limbs, LSB-first so
9
+ the product+add carry chain is local). acc_{W-1} = N.
10
+ 2. copy-reverse: re-emit N MSB-first (attention reversal — staging the
11
+ dividend so the division block looks exactly like validated divcot).
12
+ 3. long division by p: quotient limbs MSB-first with running-remainder
13
+ install targets, then the answer (= final remainder) LSB-first.
14
+
15
+ Surface:
16
+ "A" x(W,LSB) "B" y(W,LSB) "M" p(W,MSB) "="
17
+ [ "a" acc_j(2W,LSB) ] * W
18
+ "N" N(2W,MSB) Q(2W) "R" ans(W,LSB) "\n"
19
+
20
+ Tokens/example = 2W^2 + 9W + 7 (W=5: 102; base-100 tier 5 W=10: 297).
21
+
22
+ Install targets (NTP-aligned):
23
+ acarry carry into each emitted acc limb (the multiply-accumulate state)
24
+ rem{j} MSB limb j of the running remainder at each quotient position
25
+ qhat leading-limbs quotient estimate at each quotient position
26
+ ans limb k of (x*y) mod p at each answer position
27
+
28
+ Compliance: identical status to divcot/karatsuba — fixed trace SHAPE, every
29
+ value generated by trained weights; randomising weights collapses accuracy.
30
+ """
31
+ from __future__ import annotations
32
+
33
+ from limbs import limb_char, limb_str, parse_limbs, to_limbs
34
+ from divcot2_encoding import long_division, qhat_estimate
35
+
36
+ AMARK, BMARK, DIVMARK, EQ, ACC, NMARK, REVMARK, NL = "A", "B", "M", "=", "a", "N", "R", "\n"
37
+ PLSB = "m"
38
+
39
+
40
+ def prompt_str(x: int, y: int, p: int, W: int, base: int, subpad: bool = False) -> str:
41
+ """subpad adds p LSB-first to the prompt: the MSB field serves quotient
42
+ estimation, the LSB field aligns with the LSB-emitted qd*p / remainder
43
+ chains (per-step diagnostic: subtraction digits need REVERSED access into
44
+ the MSB-only field — the aligned acc stage learned, the division didn't)."""
45
+ s = (AMARK + limb_str(x, W, base) + BMARK + limb_str(y, W, base)
46
+ + DIVMARK + limb_str(p, W, base, msb_first=True))
47
+ if subpad:
48
+ s += PLSB + limb_str(p, W, base)
49
+ return s + EQ
50
+
51
+
52
+ def gen_len(W: int, scratch: bool = False, cursor: bool = False,
53
+ subpad: bool = False) -> int:
54
+ """Tokens generated after '=': W acc blocks, N restage, quotient, answer.
55
+ scratch=True: each quotient limb is followed by the running remainder
56
+ (W limbs, LSB-first) — externalizes the division state, which the model
57
+ cannot hold internally past W~3 (composed tier-3 diagnostic: first
58
+ quotient digit right, then collapse).
59
+ cursor=True (requires scratch): each step block starts with the consumed
60
+ dividend limb, copied from the restaged N — an explicit progress anchor.
61
+ Diagnostic at scratch step-4k: mid-sequence remainder drift + the model
62
+ losing count of steps (9 blocks emitted instead of 2W); copying gives the
63
+ same position mechanism that put restage at 1.00.
64
+ subpad=True (requires cursor): each step also emits qd*p (W+1 limbs, LSB)
65
+ before the remainder — externalizes the multiply-subtract that the
66
+ per-step diagnostic showed failing (steps 0-4 = copies, perfect; real
67
+ division steps 5+ at 0.04-0.15 even teacher-forced). Tier-2's compact
68
+ success could memorize qd*p over 48 primes; tier 3's 400 primes need the
69
+ generic circuit, so generate it like the (perfectly learned) acc rows."""
70
+ per_step = (1 if cursor else 0) + 1 + ((W + 1) if subpad else 0) + (W if scratch else 0)
71
+ return W * (1 + 2 * W) + (1 + 2 * W) + 2 * W * per_step + 1 + W
72
+
73
+
74
+ def build_example(x: int, y: int, p: int, W: int, base: int, scratch: bool = False,
75
+ cursor: bool = False, subpad: bool = False):
76
+ """Return (text, ann). x,y < p < base**W."""
77
+ assert not cursor or scratch, "cursor requires scratch"
78
+ assert not subpad or cursor, "subpad requires cursor"
79
+ N = x * y
80
+ y_limbs = to_limbs(y, W, base)
81
+ x_limbs = to_limbs(x, W, base)
82
+ parts, ann_g = [], [] # ann_g: (gen_index, var, val)
83
+ pos = 0
84
+
85
+ acc = 0
86
+ for j, yj in enumerate(y_limbs): # multiply-accumulate rows
87
+ prev = acc
88
+ acc = prev + x * yj * base ** j
89
+ parts.append(ACC)
90
+ pos += 1
91
+ prev_l = to_limbs(prev, 2 * W, base)
92
+ c = 0
93
+ for i in range(2 * W): # carry into limb i of acc_j
94
+ ann_g.append((pos + i, "acarry", c))
95
+ xi = x_limbs[i - j] if 0 <= i - j < W else 0
96
+ c = (prev_l[i] + xi * yj + c) // base
97
+ parts.append(limb_str(acc, 2 * W, base))
98
+ pos += 2 * W
99
+ assert acc == N
100
+
101
+ parts.append(NMARK + limb_str(N, 2 * W, base, msb_first=True)) # restage MSB
102
+ pos += 1 + 2 * W
103
+
104
+ q_limbs, rems, answer = long_division(N, p, W, base)
105
+ n_msb = to_limbs(N, 2 * W, base, msb_first=True)
106
+ r_prev = 0
107
+ for i, (qd, r) in enumerate(zip(q_limbs, rems)):
108
+ if cursor: # copy the consumed dividend limb
109
+ parts.append(limb_char(n_msb[i]))
110
+ pos += 1
111
+ ann_g.append((pos, "qhat", qhat_estimate(r_prev, n_msb[i], p, base)))
112
+ if not scratch: # compact: remainder is internal state
113
+ rstr = to_limbs(r, W, base, msb_first=True)
114
+ for j in range(W):
115
+ ann_g.append((pos, f"rem{j}", rstr[j]))
116
+ parts.append(limb_char(qd))
117
+ pos += 1
118
+ if subpad: # emit qd*p LSB-first (generic mult)
119
+ qdp = qd * p
120
+ p_l = to_limbs(p, W, base) + [0]
121
+ c = 0
122
+ for k in range(W + 1): # carry into limb k of qd*p
123
+ ann_g.append((pos + k, "spcarry", c))
124
+ c = (qd * p_l[k] + c) // base
125
+ parts.append(limb_str(qdp, W + 1, base))
126
+ pos += W + 1
127
+ if scratch: # emit remainder LSB-first (local borrow)
128
+ if subpad: # borrow chain of num - qd*p
129
+ num_l = [n_msb[i]] + to_limbs(r_prev, W, base)
130
+ qdp_l = to_limbs(qd * p, W + 1, base)
131
+ b = 0
132
+ for k in range(W):
133
+ ann_g.append((pos + k, "sborrow", b))
134
+ b = 1 if num_l[k] - qdp_l[k] - b < 0 else 0
135
+ parts.append(limb_str(r, W, base))
136
+ pos += W
137
+ r_prev = r
138
+ parts.append(REVMARK)
139
+ pos += 1
140
+ ans_limbs = to_limbs(answer, W, base)
141
+ for k in range(W):
142
+ ann_g.append((pos + k, "ans", ans_limbs[k]))
143
+ parts.append(limb_str(answer, W, base) + NL)
144
+ pos += W + 1
145
+
146
+ prompt = prompt_str(x, y, p, W, base, subpad)
147
+ text = prompt + "".join(parts)
148
+ assert len(text) == len(prompt) + gen_len(W, scratch, cursor, subpad) + 1, \
149
+ (len(text), len(prompt), gen_len(W, scratch, cursor, subpad))
150
+ ann = [dict() for _ in range(len(text))]
151
+ base_i = len(prompt)
152
+ for gi, var, val in ann_g:
153
+ ann[base_i + gi - 1][var] = int(val) # NTP alignment
154
+ return text, ann
155
+
156
+
157
+ def var_specs(W: int, base: int, scratch: bool = False, subpad: bool = False):
158
+ specs = [("ans", base), ("qhat", base), ("acarry", base)]
159
+ if subpad: # multiply-carry + subtract-borrow chains
160
+ specs += [("spcarry", base), ("sborrow", 2)]
161
+ if not scratch: # scratch emits remainders: no probe
162
+ specs += [(f"rem{j}", base) for j in range(W)]
163
+ return specs
164
+
165
+
166
+ def decode_answer(gen_chars: str, W: int, base: int, scratch: bool = False,
167
+ cursor: bool = False, subpad: bool = False) -> int:
168
+ off = gen_len(W, scratch, cursor, subpad) - W # answer = last W generated limbs
169
+ return parse_limbs(gen_chars[off:off + W], base)
170
+
171
+
172
+ if __name__ == "__main__":
173
+ import random
174
+ rng = random.Random(3)
175
+ for base in (10, 100):
176
+ for scratch, cursor, subpad in ((False, False, False), (True, False, False),
177
+ (True, True, False), (True, True, True)):
178
+ for _ in range(4000):
179
+ W = rng.randint(1, 6)
180
+ p = rng.randrange(max(2, base ** (W - 1)), base ** W)
181
+ x, y = rng.randrange(p), rng.randrange(p)
182
+ text, ann = build_example(x, y, p, W, base, scratch, cursor, subpad)
183
+ plen = len(prompt_str(x, y, p, W, base, subpad))
184
+ assert decode_answer(text[plen:], W, base, scratch, cursor, subpad) \
185
+ == (x * y) % p, (x, y, p, W, base, scratch, cursor, subpad)
186
+ assert len(ann) == len(text)
187
+ print(f"composed base={base} scratch={scratch} cursor={cursor} "
188
+ f"subpad={subpad}: 4000/4000 decode OK")
189
+ print("\ntokens/example (prompt+gen+NL):")
190
+ for tier, Wd in [(3, 5), (4, 10), (5, 20), (6, 39), (7, 78)]:
191
+ for base, W in ((10, Wd), (100, (Wd + 1) // 2)):
192
+ print(f" tier {tier} base {base:>3} W={W:>3} compact {3 * W + 4 + gen_len(W) + 1:>6}"
193
+ f" scratch {3 * W + 4 + gen_len(W, True) + 1:>6}")
coppola_pretrain_tiny.py ADDED
The diff for this file is too large to render. See raw diff
 
coppola_pretraining.py ADDED
@@ -0,0 +1,613 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Utilities for full Coppola-typed pretraining in nanochat-style trainers.
3
+
4
+ This module is intentionally trainer-agnostic. It provides:
5
+
6
+ - basis extraction from current attention-output and MLP down-project weights
7
+ - typed gradient decomposition and projection
8
+ - a depth-aware scale policy
9
+ - a small controller that can refresh bases from a nanochat-style model
10
+
11
+ The intended integration is:
12
+
13
+ 1. instantiate a controller from the current model weights
14
+ 2. before each matrix optimizer step, project the raw gradient by family
15
+ 3. apply Muon / Newton-Schulz or another matrix optimizer
16
+ 4. re-project the transformed gradient before the final step
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass, field
22
+ from typing import Callable, Dict, List, Optional, Sequence, Tuple
23
+
24
+ import torch
25
+
26
+
27
+ Tensor = torch.Tensor
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class RankPolicy:
32
+ """Energy-based rank truncation policy."""
33
+
34
+ energy: float = 0.99
35
+ max_rank: Optional[int] = None
36
+ min_rank: int = 1
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class FamilyScales:
41
+ """Typed gradient multipliers for a layer."""
42
+
43
+ routing: float = 1.0
44
+ and_transport: float = 1.0
45
+ or_update: float = 1.0
46
+ remainder: float = 1.0
47
+ coupling: Optional[float] = None
48
+
49
+ def coupling_scale(self) -> float:
50
+ if self.coupling is not None:
51
+ return self.coupling
52
+ return 0.5 * (self.and_transport + self.or_update)
53
+
54
+
55
+ @dataclass
56
+ class LayerBases:
57
+ """Typed bases for one transformer layer."""
58
+
59
+ routing_head_bases: List[Tensor] = field(default_factory=list)
60
+ q_head_bases: List[Tensor] = field(default_factory=list)
61
+ k_head_bases: List[Tensor] = field(default_factory=list)
62
+ and_output_basis: Optional[Tensor] = None
63
+ or_input_basis: Optional[Tensor] = None
64
+
65
+
66
+ @dataclass
67
+ class ComponentNorms:
68
+ """Norms of the typed gradient components before scaling."""
69
+
70
+ routing: float = 0.0
71
+ and_transport: float = 0.0
72
+ or_update: float = 0.0
73
+ coupling: float = 0.0
74
+ remainder: float = 0.0
75
+
76
+
77
+ def _select_rank(singular_values: Tensor, policy: RankPolicy) -> int:
78
+ if singular_values.numel() == 0:
79
+ return policy.min_rank
80
+ total = float(singular_values.square().sum().item())
81
+ if total <= 0.0:
82
+ return policy.min_rank
83
+ cumulative = singular_values.square().cumsum(0) / total
84
+ rank = int(torch.searchsorted(cumulative, torch.tensor(policy.energy, device=cumulative.device)).item()) + 1
85
+ rank = max(policy.min_rank, rank)
86
+ if policy.max_rank is not None:
87
+ rank = min(rank, policy.max_rank)
88
+ return min(rank, singular_values.numel())
89
+
90
+
91
+ def _orthonormal_rows(basis: Tensor) -> Tensor:
92
+ """Return an orthonormal row basis spanning the same row space."""
93
+ if basis.ndim != 2:
94
+ raise ValueError(f"basis must be rank-2, got {tuple(basis.shape)}")
95
+ if basis.shape[0] == 0:
96
+ return basis
97
+ q, _ = torch.linalg.qr(basis.T, mode="reduced")
98
+ return q.T.contiguous()
99
+
100
+
101
+ def _project_left(grad: Tensor, basis: Tensor) -> Tensor:
102
+ basis = basis.to(device=grad.device, dtype=grad.dtype)
103
+ proj = basis.T @ basis
104
+ return proj @ grad
105
+
106
+
107
+ def _project_right(grad: Tensor, basis: Tensor) -> Tensor:
108
+ basis = basis.to(device=grad.device, dtype=grad.dtype)
109
+ proj = basis.T @ basis
110
+ return grad @ proj
111
+
112
+
113
+ def _zero_like(grad: Tensor) -> Tensor:
114
+ return torch.zeros_like(grad)
115
+
116
+
117
+ def compute_attention_output_bases(
118
+ weight: Tensor,
119
+ n_head: int,
120
+ policy: RankPolicy = RankPolicy(),
121
+ ) -> List[Tensor]:
122
+ """Build one output-space basis per attention head block.
123
+
124
+ Args:
125
+ weight: output projection weight of shape [hidden, hidden]
126
+ n_head: number of attention heads
127
+ policy: rank selection policy inside each head block
128
+ """
129
+ if weight.ndim != 2:
130
+ raise ValueError(f"attention output weight must be rank-2, got {tuple(weight.shape)}")
131
+ hidden, width = weight.shape
132
+ if width % n_head != 0:
133
+ raise ValueError(f"weight width {width} not divisible by n_head={n_head}")
134
+ head_dim = width // n_head
135
+ bases: List[Tensor] = []
136
+ with torch.no_grad():
137
+ w = weight.detach().float().cpu()
138
+ for h in range(n_head):
139
+ cols = w[:, h * head_dim:(h + 1) * head_dim]
140
+ u, s, _ = torch.linalg.svd(cols, full_matrices=False)
141
+ rank = _select_rank(s, policy)
142
+ basis = u[:, :rank].T.contiguous()
143
+ bases.append(_orthonormal_rows(basis))
144
+ return bases
145
+
146
+
147
+ def compute_attention_score_bases(
148
+ weight: Tensor,
149
+ n_head: int,
150
+ policy: RankPolicy = RankPolicy(),
151
+ ) -> List[Tensor]:
152
+ """Build one row-space basis per attention head for Q/K score-side blocks."""
153
+ if weight.ndim != 2:
154
+ raise ValueError(f"attention score weight must be rank-2, got {tuple(weight.shape)}")
155
+ width, hidden = weight.shape
156
+ if width % n_head != 0:
157
+ raise ValueError(f"weight height {width} not divisible by n_head={n_head}")
158
+ head_dim = width // n_head
159
+ bases: List[Tensor] = []
160
+ with torch.no_grad():
161
+ w = weight.detach().float().cpu()
162
+ for h in range(n_head):
163
+ rows = w[h * head_dim:(h + 1) * head_dim, :]
164
+ u, s, _ = torch.linalg.svd(rows, full_matrices=False)
165
+ rank = _select_rank(s, policy)
166
+ basis = u[:, :rank].T.contiguous()
167
+ bases.append(_orthonormal_rows(basis))
168
+ return bases
169
+
170
+
171
+ def compute_down_proj_bases(
172
+ weight: Tensor,
173
+ output_policy: RankPolicy = RankPolicy(),
174
+ input_policy: RankPolicy = RankPolicy(),
175
+ ) -> Tuple[Tensor, Tensor]:
176
+ """Build output-side AND basis and input-side OR basis for down_proj."""
177
+ if weight.ndim != 2:
178
+ raise ValueError(f"down projection weight must be rank-2, got {tuple(weight.shape)}")
179
+ with torch.no_grad():
180
+ w = weight.detach().float().cpu()
181
+ u, s, vh = torch.linalg.svd(w, full_matrices=False)
182
+ out_rank = _select_rank(s, output_policy)
183
+ in_rank = _select_rank(s, input_policy)
184
+ and_basis = _orthonormal_rows(u[:, :out_rank].T.contiguous())
185
+ or_basis = _orthonormal_rows(vh[:in_rank, :].contiguous())
186
+ return and_basis, or_basis
187
+
188
+
189
+ def decompose_down_proj_gradient(
190
+ grad: Tensor,
191
+ and_output_basis: Optional[Tensor],
192
+ or_input_basis: Optional[Tensor],
193
+ ) -> Dict[str, Tensor]:
194
+ """Decompose an MLP down_proj gradient into typed components.
195
+
196
+ The components are:
197
+ - and_transport_only
198
+ - or_update_only
199
+ - coupling: in both the output and input typed subspaces
200
+ - remainder
201
+ """
202
+ if grad.ndim != 2:
203
+ raise ValueError(f"gradient must be rank-2, got {tuple(grad.shape)}")
204
+
205
+ g_and = _zero_like(grad)
206
+ g_or = _zero_like(grad)
207
+ g_both = _zero_like(grad)
208
+
209
+ if and_output_basis is not None:
210
+ g_and = _project_left(grad, and_output_basis)
211
+ if or_input_basis is not None:
212
+ g_or = _project_right(grad, or_input_basis)
213
+ if and_output_basis is not None and or_input_basis is not None:
214
+ g_both = _project_right(g_and, or_input_basis)
215
+
216
+ g_and_only = g_and - g_both
217
+ g_or_only = g_or - g_both
218
+ g_remainder = grad - g_and_only - g_or_only - g_both
219
+
220
+ return {
221
+ "and_transport_only": g_and_only,
222
+ "or_update_only": g_or_only,
223
+ "coupling": g_both,
224
+ "remainder": g_remainder,
225
+ }
226
+
227
+
228
+ def project_down_proj_gradient(
229
+ grad: Tensor,
230
+ and_output_basis: Optional[Tensor],
231
+ or_input_basis: Optional[Tensor],
232
+ scales: FamilyScales,
233
+ ) -> Tensor:
234
+ parts = decompose_down_proj_gradient(grad, and_output_basis, or_input_basis)
235
+ return (
236
+ scales.and_transport * parts["and_transport_only"]
237
+ + scales.or_update * parts["or_update_only"]
238
+ + scales.coupling_scale() * parts["coupling"]
239
+ + scales.remainder * parts["remainder"]
240
+ )
241
+
242
+
243
+ def support_project_down_proj_gradient(
244
+ grad: Tensor,
245
+ and_output_basis: Optional[Tensor],
246
+ or_input_basis: Optional[Tensor],
247
+ ) -> Tensor:
248
+ """Project a down-proj update onto typed Coppola support, dropping remainder."""
249
+ parts = decompose_down_proj_gradient(grad, and_output_basis, or_input_basis)
250
+ return parts["and_transport_only"] + parts["or_update_only"] + parts["coupling"]
251
+
252
+
253
+ def down_proj_component_norms(
254
+ grad: Tensor,
255
+ and_output_basis: Optional[Tensor],
256
+ or_input_basis: Optional[Tensor],
257
+ ) -> ComponentNorms:
258
+ parts = decompose_down_proj_gradient(grad, and_output_basis, or_input_basis)
259
+ return ComponentNorms(
260
+ and_transport=float(parts["and_transport_only"].norm().item()),
261
+ or_update=float(parts["or_update_only"].norm().item()),
262
+ coupling=float(parts["coupling"].norm().item()),
263
+ remainder=float(parts["remainder"].norm().item()),
264
+ )
265
+
266
+
267
+ def project_attention_output_gradient(
268
+ grad: Tensor,
269
+ routing_head_bases: Sequence[Tensor],
270
+ scales: FamilyScales,
271
+ ) -> Tensor:
272
+ """Project an attention output gradient head-by-head on the left."""
273
+ if grad.ndim != 2:
274
+ raise ValueError(f"gradient must be rank-2, got {tuple(grad.shape)}")
275
+ if not routing_head_bases:
276
+ return grad
277
+ hidden, width = grad.shape
278
+ if width % len(routing_head_bases) != 0:
279
+ raise ValueError(f"gradient width {width} incompatible with {len(routing_head_bases)} head bases")
280
+ head_dim = width // len(routing_head_bases)
281
+ out = torch.empty_like(grad)
282
+ for h, basis in enumerate(routing_head_bases):
283
+ cols = grad[:, h * head_dim:(h + 1) * head_dim]
284
+ routed = _project_left(cols, basis)
285
+ remainder = cols - routed
286
+ out[:, h * head_dim:(h + 1) * head_dim] = scales.routing * routed + scales.remainder * remainder
287
+ return out
288
+
289
+
290
+ def project_attention_qk_gradient(
291
+ grad: Tensor,
292
+ q_head_bases: Sequence[Tensor],
293
+ k_head_bases: Sequence[Tensor],
294
+ scales: FamilyScales,
295
+ ) -> Tensor:
296
+ """Project fused Q/K/V gradient on Q and K row blocks only, leaving V unchanged."""
297
+ if grad.ndim != 2:
298
+ raise ValueError(f"gradient must be rank-2, got {tuple(grad.shape)}")
299
+ if not q_head_bases and not k_head_bases:
300
+ return grad
301
+ hidden3, width = grad.shape
302
+ if hidden3 % 3 != 0:
303
+ raise ValueError(f"expected fused qkv height divisible by 3, got {hidden3}")
304
+ hidden = hidden3 // 3
305
+ if q_head_bases and hidden % len(q_head_bases) != 0:
306
+ raise ValueError(f"q block height {hidden} incompatible with {len(q_head_bases)} q bases")
307
+ if k_head_bases and hidden % len(k_head_bases) != 0:
308
+ raise ValueError(f"k block height {hidden} incompatible with {len(k_head_bases)} k bases")
309
+ out = grad.clone()
310
+ if q_head_bases:
311
+ head_dim = hidden // len(q_head_bases)
312
+ for h, basis in enumerate(q_head_bases):
313
+ rows = grad[h * head_dim:(h + 1) * head_dim, :]
314
+ routed = _project_left(rows, basis)
315
+ remainder = rows - routed
316
+ out[h * head_dim:(h + 1) * head_dim, :] = scales.routing * routed + scales.remainder * remainder
317
+ if k_head_bases:
318
+ head_dim = hidden // len(k_head_bases)
319
+ offset = hidden
320
+ for h, basis in enumerate(k_head_bases):
321
+ start = offset + h * head_dim
322
+ stop = offset + (h + 1) * head_dim
323
+ rows = grad[start:stop, :]
324
+ routed = _project_left(rows, basis)
325
+ remainder = rows - routed
326
+ out[start:stop, :] = scales.routing * routed + scales.remainder * remainder
327
+ return out
328
+
329
+
330
+ def support_project_attention_output_gradient(
331
+ grad: Tensor,
332
+ routing_head_bases: Sequence[Tensor],
333
+ ) -> Tensor:
334
+ """Project an attention-output update onto routing support, dropping remainder."""
335
+ if grad.ndim != 2:
336
+ raise ValueError(f"gradient must be rank-2, got {tuple(grad.shape)}")
337
+ if not routing_head_bases:
338
+ return grad
339
+ hidden, width = grad.shape
340
+ if width % len(routing_head_bases) != 0:
341
+ raise ValueError(f"gradient width {width} incompatible with {len(routing_head_bases)} head bases")
342
+ head_dim = width // len(routing_head_bases)
343
+ out = torch.empty_like(grad)
344
+ for h, basis in enumerate(routing_head_bases):
345
+ cols = grad[:, h * head_dim:(h + 1) * head_dim]
346
+ out[:, h * head_dim:(h + 1) * head_dim] = _project_left(cols, basis)
347
+ return out
348
+
349
+
350
+ def support_project_attention_qk_gradient(
351
+ grad: Tensor,
352
+ q_head_bases: Sequence[Tensor],
353
+ k_head_bases: Sequence[Tensor],
354
+ ) -> Tensor:
355
+ """Project fused Q/K/V update on Q and K support only, leaving V unchanged."""
356
+ if grad.ndim != 2:
357
+ raise ValueError(f"gradient must be rank-2, got {tuple(grad.shape)}")
358
+ if not q_head_bases and not k_head_bases:
359
+ return grad
360
+ hidden3, width = grad.shape
361
+ if hidden3 % 3 != 0:
362
+ raise ValueError(f"expected fused qkv height divisible by 3, got {hidden3}")
363
+ hidden = hidden3 // 3
364
+ out = grad.clone()
365
+ if q_head_bases:
366
+ head_dim = hidden // len(q_head_bases)
367
+ for h, basis in enumerate(q_head_bases):
368
+ rows = grad[h * head_dim:(h + 1) * head_dim, :]
369
+ out[h * head_dim:(h + 1) * head_dim, :] = _project_left(rows, basis)
370
+ if k_head_bases:
371
+ head_dim = hidden // len(k_head_bases)
372
+ offset = hidden
373
+ for h, basis in enumerate(k_head_bases):
374
+ start = offset + h * head_dim
375
+ stop = offset + (h + 1) * head_dim
376
+ rows = grad[start:stop, :]
377
+ out[start:stop, :] = _project_left(rows, basis)
378
+ return out
379
+
380
+
381
+ def attention_component_norms(grad: Tensor, routing_head_bases: Sequence[Tensor]) -> ComponentNorms:
382
+ if not routing_head_bases:
383
+ return ComponentNorms(routing=float(grad.norm().item()))
384
+ hidden, width = grad.shape
385
+ head_dim = width // len(routing_head_bases)
386
+ routed_norm_sq = 0.0
387
+ remainder_norm_sq = 0.0
388
+ for h, basis in enumerate(routing_head_bases):
389
+ cols = grad[:, h * head_dim:(h + 1) * head_dim]
390
+ routed = _project_left(cols, basis)
391
+ remainder = cols - routed
392
+ routed_norm_sq += float(routed.square().sum().item())
393
+ remainder_norm_sq += float(remainder.square().sum().item())
394
+ return ComponentNorms(
395
+ routing=routed_norm_sq**0.5,
396
+ remainder=remainder_norm_sq**0.5,
397
+ )
398
+
399
+
400
+ def attention_qk_component_norms(
401
+ grad: Tensor,
402
+ q_head_bases: Sequence[Tensor],
403
+ k_head_bases: Sequence[Tensor],
404
+ ) -> ComponentNorms:
405
+ if not q_head_bases and not k_head_bases:
406
+ return ComponentNorms(routing=float(grad.norm().item()))
407
+ hidden3, width = grad.shape
408
+ if hidden3 % 3 != 0:
409
+ raise ValueError(f"expected fused qkv height divisible by 3, got {hidden3}")
410
+ hidden = hidden3 // 3
411
+ routed_norm_sq = 0.0
412
+ remainder_norm_sq = 0.0
413
+ if q_head_bases:
414
+ head_dim = hidden // len(q_head_bases)
415
+ for h, basis in enumerate(q_head_bases):
416
+ rows = grad[h * head_dim:(h + 1) * head_dim, :]
417
+ routed = _project_left(rows, basis)
418
+ remainder = rows - routed
419
+ routed_norm_sq += float(routed.square().sum().item())
420
+ remainder_norm_sq += float(remainder.square().sum().item())
421
+ if k_head_bases:
422
+ head_dim = hidden // len(k_head_bases)
423
+ offset = hidden
424
+ for h, basis in enumerate(k_head_bases):
425
+ start = offset + h * head_dim
426
+ stop = offset + (h + 1) * head_dim
427
+ rows = grad[start:stop, :]
428
+ routed = _project_left(rows, basis)
429
+ remainder = rows - routed
430
+ routed_norm_sq += float(routed.square().sum().item())
431
+ remainder_norm_sq += float(remainder.square().sum().item())
432
+ return ComponentNorms(
433
+ routing=routed_norm_sq**0.5,
434
+ remainder=remainder_norm_sq**0.5,
435
+ )
436
+
437
+
438
+ def default_zone_scales(layer_idx: int, n_layer: int) -> FamilyScales:
439
+ """Default full-Coppola depth policy for from-scratch pretraining."""
440
+ frac = layer_idx / max(n_layer - 1, 1)
441
+ if frac < 0.20:
442
+ return FamilyScales(routing=1.40, and_transport=1.10, or_update=0.70, remainder=0.0, coupling=0.90)
443
+ if frac < 0.65:
444
+ return FamilyScales(routing=0.90, and_transport=1.40, or_update=0.90, remainder=0.0, coupling=1.15)
445
+ if frac < 0.90:
446
+ return FamilyScales(routing=0.75, and_transport=0.95, or_update=1.40, remainder=0.0, coupling=1.15)
447
+ return FamilyScales(routing=0.60, and_transport=0.85, or_update=1.15, remainder=0.0, coupling=1.00)
448
+
449
+
450
+ @dataclass
451
+ class CoppolaPretrainingConfig:
452
+ n_head: int
453
+ attn_output_policy: RankPolicy = field(default_factory=RankPolicy)
454
+ attn_qk_policy: RankPolicy = field(default_factory=RankPolicy)
455
+ mlp_output_policy: RankPolicy = field(default_factory=RankPolicy)
456
+ mlp_input_policy: RankPolicy = field(default_factory=RankPolicy)
457
+ basis_update_interval: int = 250
458
+ uniform_scales: FamilyScales = field(default_factory=FamilyScales)
459
+ scale_fn: Callable[[int, int], FamilyScales] = default_zone_scales
460
+
461
+
462
+ class CoppolaPretrainingController:
463
+ """Refreshes typed bases and projects gradients for nanochat-style models."""
464
+
465
+ def __init__(self, config: CoppolaPretrainingConfig):
466
+ self.config = config
467
+ self.layer_bases: Dict[int, LayerBases] = {}
468
+
469
+ def refresh_from_model(self, model) -> None:
470
+ layers = self._resolve_layers(model)
471
+ n_layer = len(layers)
472
+ bases: Dict[int, LayerBases] = {}
473
+ for layer_idx, block in enumerate(layers):
474
+ attn_weight = self._resolve_attn_out_weight(block)
475
+ q_weight, k_weight = self._resolve_attn_qk_weights(block)
476
+ mlp_weight = self._resolve_mlp_down_weight(block)
477
+ routing = compute_attention_output_bases(
478
+ attn_weight, self.config.n_head, self.config.attn_output_policy
479
+ )
480
+ q_bases: List[Tensor] = []
481
+ k_bases: List[Tensor] = []
482
+ if q_weight is not None and k_weight is not None:
483
+ q_bases = compute_attention_score_bases(
484
+ q_weight, self.config.n_head, self.config.attn_qk_policy
485
+ )
486
+ k_bases = compute_attention_score_bases(
487
+ k_weight, self.config.n_head, self.config.attn_qk_policy
488
+ )
489
+ and_basis, or_basis = compute_down_proj_bases(
490
+ mlp_weight,
491
+ output_policy=self.config.mlp_output_policy,
492
+ input_policy=self.config.mlp_input_policy,
493
+ )
494
+ bases[layer_idx] = LayerBases(
495
+ routing_head_bases=routing,
496
+ q_head_bases=q_bases,
497
+ k_head_bases=k_bases,
498
+ and_output_basis=and_basis,
499
+ or_input_basis=or_basis,
500
+ )
501
+ self.layer_bases = bases
502
+ self._n_layer = n_layer
503
+
504
+ def scales_for_layer(self, layer_idx: int, mode: str = "zoned") -> FamilyScales:
505
+ if mode == "uniform":
506
+ return self.config.uniform_scales
507
+ if not hasattr(self, "_n_layer"):
508
+ raise RuntimeError("refresh_from_model() must be called before requesting zoned scales")
509
+ return self.config.scale_fn(layer_idx, self._n_layer)
510
+
511
+ def project_attn_out_grad(self, layer_idx: int, grad: Tensor, mode: str = "zoned") -> Tensor:
512
+ bases = self.layer_bases[layer_idx]
513
+ scales = self.scales_for_layer(layer_idx, mode=mode)
514
+ return project_attention_output_gradient(grad, bases.routing_head_bases, scales)
515
+
516
+ def project_attn_qk_grad(self, layer_idx: int, grad: Tensor, mode: str = "zoned") -> Tensor:
517
+ bases = self.layer_bases[layer_idx]
518
+ scales = self.scales_for_layer(layer_idx, mode=mode)
519
+ return project_attention_qk_gradient(grad, bases.q_head_bases, bases.k_head_bases, scales)
520
+
521
+ def project_mlp_down_grad(self, layer_idx: int, grad: Tensor, mode: str = "zoned") -> Tensor:
522
+ bases = self.layer_bases[layer_idx]
523
+ scales = self.scales_for_layer(layer_idx, mode=mode)
524
+ return project_down_proj_gradient(grad, bases.and_output_basis, bases.or_input_basis, scales)
525
+
526
+ def project_attn_out_support(self, layer_idx: int, grad: Tensor) -> Tensor:
527
+ bases = self.layer_bases[layer_idx]
528
+ return support_project_attention_output_gradient(grad, bases.routing_head_bases)
529
+
530
+ def project_attn_qk_support(self, layer_idx: int, grad: Tensor) -> Tensor:
531
+ bases = self.layer_bases[layer_idx]
532
+ return support_project_attention_qk_gradient(grad, bases.q_head_bases, bases.k_head_bases)
533
+
534
+ def project_mlp_down_support(self, layer_idx: int, grad: Tensor) -> Tensor:
535
+ bases = self.layer_bases[layer_idx]
536
+ return support_project_down_proj_gradient(grad, bases.and_output_basis, bases.or_input_basis)
537
+
538
+ def project_after_matrix_transform(
539
+ self,
540
+ layer_idx: int,
541
+ grad: Tensor,
542
+ param_kind: str,
543
+ transform: Callable[[Tensor], Tensor],
544
+ mode: str = "zoned",
545
+ ) -> Tensor:
546
+ """Project -> transform -> re-project for Muon-style optimizers."""
547
+ g = self.project_gradient(layer_idx, grad, param_kind=param_kind, mode=mode)
548
+ g = transform(g)
549
+ return self.project_gradient(layer_idx, g, param_kind=param_kind, mode=mode)
550
+
551
+ def project_gradient(self, layer_idx: int, grad: Tensor, param_kind: str, mode: str = "zoned") -> Tensor:
552
+ if param_kind == "attn_out":
553
+ return self.project_attn_out_grad(layer_idx, grad, mode=mode)
554
+ if param_kind == "attn_qk":
555
+ return self.project_attn_qk_grad(layer_idx, grad, mode=mode)
556
+ if param_kind == "mlp_down":
557
+ return self.project_mlp_down_grad(layer_idx, grad, mode=mode)
558
+ raise ValueError(f"unknown param_kind={param_kind!r}")
559
+
560
+ def project_update_support(self, layer_idx: int, grad: Tensor, param_kind: str) -> Tensor:
561
+ if param_kind == "attn_out":
562
+ return self.project_attn_out_support(layer_idx, grad)
563
+ if param_kind == "attn_qk":
564
+ return self.project_attn_qk_support(layer_idx, grad)
565
+ if param_kind == "mlp_down":
566
+ return self.project_mlp_down_support(layer_idx, grad)
567
+ raise ValueError(f"unknown param_kind={param_kind!r}")
568
+
569
+ def gradient_component_norms(self, layer_idx: int, grad: Tensor, param_kind: str) -> ComponentNorms:
570
+ bases = self.layer_bases[layer_idx]
571
+ if param_kind == "attn_out":
572
+ return attention_component_norms(grad, bases.routing_head_bases)
573
+ if param_kind == "attn_qk":
574
+ return attention_qk_component_norms(grad, bases.q_head_bases, bases.k_head_bases)
575
+ if param_kind == "mlp_down":
576
+ return down_proj_component_norms(grad, bases.and_output_basis, bases.or_input_basis)
577
+ raise ValueError(f"unknown param_kind={param_kind!r}")
578
+
579
+ @staticmethod
580
+ def _resolve_layers(model):
581
+ if hasattr(model, "transformer") and hasattr(model.transformer, "h"):
582
+ return list(model.transformer.h)
583
+ if hasattr(model, "model") and hasattr(model.model, "layers"):
584
+ return list(model.model.layers)
585
+ raise ValueError("could not resolve transformer layers from model")
586
+
587
+ @staticmethod
588
+ def _resolve_attn_out_weight(block) -> Tensor:
589
+ if hasattr(block, "attn") and hasattr(block.attn, "c_proj"):
590
+ return block.attn.c_proj.weight
591
+ if hasattr(block, "self_attn") and hasattr(block.self_attn, "o_proj"):
592
+ return block.self_attn.o_proj.weight
593
+ raise ValueError("could not resolve attention output weight")
594
+
595
+ @staticmethod
596
+ def _resolve_attn_qk_weights(block) -> Tuple[Optional[Tensor], Optional[Tensor]]:
597
+ if hasattr(block, "attn") and hasattr(block.attn, "c_attn"):
598
+ weight = block.attn.c_attn.weight
599
+ if weight.shape[0] % 3 != 0:
600
+ raise ValueError(f"expected fused qkv height divisible by 3, got {weight.shape[0]}")
601
+ hidden = weight.shape[0] // 3
602
+ return weight[:hidden, :], weight[hidden:2 * hidden, :]
603
+ if hasattr(block, "self_attn") and hasattr(block.self_attn, "q_proj") and hasattr(block.self_attn, "k_proj"):
604
+ return block.self_attn.q_proj.weight, block.self_attn.k_proj.weight
605
+ return None, None
606
+
607
+ @staticmethod
608
+ def _resolve_mlp_down_weight(block) -> Tensor:
609
+ if hasattr(block, "mlp") and hasattr(block.mlp, "c_proj"):
610
+ return block.mlp.c_proj.weight
611
+ if hasattr(block, "mlp") and hasattr(block.mlp, "down_proj"):
612
+ return block.mlp.down_proj.weight
613
+ raise ValueError("could not resolve MLP down projection weight")
divcot2_encoding.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generalized long-division reduction CoT — base-B limbs, estimated quotient,
2
+ optional scratchpad. Scales divcot_encoding.py (validated at tier 2, base 10)
3
+ to higher tiers.
4
+
5
+ Three levers over the validated v1:
6
+
7
+ 1. **Base-B limbs** (B=100 halves W): each limb is one byte (limbs.py), so
8
+ tier 5's W=20 decimal becomes 10 limbs, tier 7's W=78 becomes 39.
9
+ 2. **Estimated-quotient install** (`qhat`): the classic scaling trick. The
10
+ one-shot quotient digit qd = floor((r*B+d)/p) needs a W-limb comparison —
11
+ fine at W=3, strained at W>=5. Humans estimate qd from the LEADING limbs
12
+ of numerator and divisor (a constant-size table independent of W), then
13
+ correct by ±1. We install that estimate at an early block so the circuit
14
+ the model learns is estimate-then-correct, not a W-limb divide.
15
+ 3. **Scratchpad mode** (`scratch=True`): for large W the model can't hold the
16
+ running remainder internally — EMIT it (LSB-first, so the subtraction
17
+ borrow chain is local). Cost: O(W^2) tokens vs O(W) compact; use compact
18
+ while it trains, scratch when it stops.
19
+
20
+ Surface (compact): "N" N(2W,MSB) "M" p(W,MSB) "=" Q(2W) "R" ans(W,LSB) "\n"
21
+ Surface (scratch): ... "=" [qd r(W,LSB)]*2W "R" ans(W,LSB) "\n"
22
+
23
+ Install targets (NTP-aligned: label at position t supervises the residual
24
+ predicting byte t+1):
25
+ rem{j} compact only — MSB limb j of the running remainder at each
26
+ quotient-emission position (the division carry/state)
27
+ qhat leading-limbs quotient estimate at each quotient position
28
+ ans limb k of N mod p at each answer position
29
+ """
30
+ from __future__ import annotations
31
+
32
+ from limbs import limb_char, limb_str, parse_limbs, to_limbs
33
+
34
+ NMARK, DIVMARK, EQ, REVMARK, NL = "N", "M", "=", "R", "\n"
35
+
36
+
37
+ def long_division(N: int, p: int, W: int, base: int):
38
+ """(quotient_limbs[2W] MSB-first, remainders[2W], answer)."""
39
+ q_limbs, rems, r = [], [], 0
40
+ for d in to_limbs(N, 2 * W, base, msb_first=True):
41
+ r = r * base + d
42
+ qd = r // p # single limb 0..base-1 (r < base*p)
43
+ r -= qd * p
44
+ q_limbs.append(qd)
45
+ rems.append(r)
46
+ return q_limbs, rems, r # r == N % p
47
+
48
+
49
+ def qhat_estimate(r_prev: int, d: int, p: int, base: int) -> int:
50
+ """Leading-limbs estimate of floor((r*B+d)/p): numerator's top two limbs
51
+ over divisor's top limb — constant-size lookup regardless of W."""
52
+ num = r_prev * base + d
53
+ if num < p:
54
+ return 0
55
+ nw = 1
56
+ while base ** nw <= num:
57
+ nw += 1
58
+ pw = 1
59
+ while base ** pw <= p:
60
+ pw += 1
61
+ n_top = num // base ** max(0, nw - 2) # top 2 limbs of numerator
62
+ p_top = p // base ** (pw - 1) # top 1 limb of divisor
63
+ shift = (nw - 2) - (pw - 1)
64
+ est = (n_top // p_top) * base ** shift if shift >= 0 else n_top // (p_top * base ** -shift)
65
+ return min(base - 1, max(0, est))
66
+
67
+
68
+ def prompt_str(N: int, p: int, W: int, base: int) -> str:
69
+ return NMARK + limb_str(N, 2 * W, base, msb_first=True) \
70
+ + DIVMARK + limb_str(p, W, base, msb_first=True) + EQ
71
+
72
+
73
+ def gen_len(W: int, scratch: bool = False) -> int:
74
+ steps = 2 * W * (1 + W) if scratch else 2 * W
75
+ return steps + 1 + W
76
+
77
+
78
+ def build_example(N: int, p: int, W: int, base: int, scratch: bool = False):
79
+ q_limbs, rems, answer = long_division(N, p, W, base)
80
+ n_msb = to_limbs(N, 2 * W, base, msb_first=True)
81
+ prompt = prompt_str(N, p, W, base)
82
+ parts, ann_q = [], [] # ann_q: (char_index, var, val)
83
+ pos = len(prompt)
84
+ r_prev = 0
85
+ for i, (qd, r) in enumerate(zip(q_limbs, rems)):
86
+ ann_q.append((pos, "qhat", qhat_estimate(r_prev, n_msb[i], p, base)))
87
+ if not scratch: # compact: remainder is internal state
88
+ rstr = to_limbs(r, W, base, msb_first=True)
89
+ for j in range(W):
90
+ ann_q.append((pos, f"rem{j}", rstr[j]))
91
+ parts.append(limb_char(qd))
92
+ pos += 1
93
+ if scratch: # emit remainder LSB-first (local borrow)
94
+ parts.append(limb_str(r, W, base))
95
+ pos += W
96
+ r_prev = r
97
+ ans_limbs = to_limbs(answer, W, base) # LSB-first
98
+ parts.append(REVMARK + limb_str(answer, W, base) + NL)
99
+ text = prompt + "".join(parts)
100
+ ann = [dict() for _ in range(len(text))]
101
+ for idx, var, val in ann_q:
102
+ ann[idx - 1][var] = int(val) # NTP alignment
103
+ ans_base = pos + 1 # after 'R'
104
+ for k in range(W):
105
+ ann[ans_base + k - 1]["ans"] = ans_limbs[k]
106
+ return text, ann
107
+
108
+
109
+ def var_specs(W: int, base: int, scratch: bool = False):
110
+ """(name, n_classes) install vars; assign blocks in the trainer."""
111
+ specs = [("ans", base), ("qhat", base)]
112
+ if not scratch:
113
+ specs += [(f"rem{j}", base) for j in range(W)]
114
+ return specs
115
+
116
+
117
+ def decode_answer(gen_chars: str, W: int, base: int, scratch: bool = False) -> int:
118
+ off = (2 * W * (1 + W) if scratch else 2 * W) + 1 # skip quotient(+rems) and 'R'
119
+ return parse_limbs(gen_chars[off:off + W], base)
120
+
121
+
122
+ if __name__ == "__main__":
123
+ import random
124
+ rng = random.Random(1)
125
+ for base in (10, 100):
126
+ for scratch in (False, True):
127
+ for _ in range(4000):
128
+ W = rng.randint(1, 8)
129
+ p = rng.randrange(max(2, base ** (W - 1)), base ** W)
130
+ N = rng.randrange(p * p) if p > 1 else 0
131
+ text, ann = build_example(N, p, W, base, scratch)
132
+ plen = len(prompt_str(N, p, W, base))
133
+ assert len(text) == plen + gen_len(W, scratch) + 1, (len(text), plen, gen_len(W, scratch)) # +1: NL
134
+ assert decode_answer(text[plen:], W, base, scratch) == N % p
135
+ assert len(ann) == len(text)
136
+ print(f"divcot2 base={base} scratch={scratch}: 4000/4000 decode OK")
137
+ # tier-relevant lengths
138
+ print("\ntokens/example (prompt+gen):")
139
+ for tier, Wd in [(3, 5), (4, 10), (5, 20), (6, 39), (7, 78), (8, 155)]:
140
+ for base, W in ((10, Wd), (100, (Wd + 1) // 2)):
141
+ plen = 3 * W + 3
142
+ print(f" tier {tier} base {base:>3} W={W:>3} compact {plen + gen_len(W)}"
143
+ f" scratch {plen + gen_len(W, True)}")
encoding.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared sequence encoding for the modmul BP-install model.
2
+
3
+ The model learns the field-multiplication map (p, x, y) -> (x*y) mod p with
4
+ x, y in [0, p). At inference the legal two-operand reduction a%p, b%p produces
5
+ x, y (the same step the baselines use); training samples x, y in [0, p) directly,
6
+ which matches the (a mod p) distribution for p << operand range.
7
+
8
+ Fixed-width, base-10, reverse-LSB answer (the proven arithmetic recipe). The
9
+ prime p is an explicit in-sequence conditioning field so one model generalises
10
+ across primes:
11
+
12
+ p007*012,003=R120\n # W=3 example: p=7, x=12%7=5? no -- x,y already <p
13
+ # e.g. p=7,x=5,y=4 -> 20%7=6 -> "p7*5,4=R6\n" (W=1)
14
+
15
+ Layout (width W = max decimal digits of any prime in scope):
16
+ "p" P(W) "*" X(W) "," Y(W) "=R" REV_ANS(W) "\n"
17
+ zero-padded fields; REV_ANS is the W-digit answer reversed (least-significant
18
+ digit first), so digit k is emitted at a fixed position and the carry flows
19
+ left-to-right the way reverse-LSB addition does.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ MUL, SEP, EQ, REVMARK, NL = "*", ",", "=", "R", "\n"
24
+ NEWLINE_ID = ord("\n")
25
+
26
+
27
+ def width_for_primes(primes) -> int:
28
+ """Decimal digits needed to hold the largest residue (p-1)."""
29
+ return max(len(str(p - 1)) for p in primes)
30
+
31
+
32
+ def prompt_str(p: int, x: int, y: int, W: int) -> str:
33
+ return f"p{p:0{W}d}*{x:0{W}d},{y:0{W}d}={REVMARK}"
34
+
35
+
36
+ def build_example(p: int, x: int, y: int, W: int):
37
+ """Return (text, ann) where ann[t] holds the install label for the residual
38
+ at byte position t (which predicts byte t+1), NTP-aligned."""
39
+ ans = (x * y) % p
40
+ rev_ans = f"{ans:0{W}d}"[::-1] # LSB-first, width W
41
+ prompt = prompt_str(p, x, y, W)
42
+ text = prompt + rev_ans + NL
43
+ ann = [dict() for _ in range(len(text))]
44
+ base = len(prompt) # first answer char index
45
+ for k in range(W): # answer digit k (LSB-first)
46
+ pos = base + k
47
+ ann[pos - 1]["ans"] = int(rev_ans[k]) # residual at pos-1 predicts it
48
+ return text, ann
49
+
50
+
51
+ def answer_len(W: int) -> int:
52
+ return W
53
+
54
+
55
+ # --- Scratchpad ("chain-of-thought") inference variant -------------------
56
+ # The model GENERATES the product as reverse-LSB digits before the answer:
57
+ # "p" P(W) "*" X(W) "," Y(W) "=" REV_PROD(2W) "R" REV_ANS(W) "\n"
58
+ # At inference we feed up to '=' and greedily decode the full scratchpad; the
59
+ # answer is the last W generated digits. Python never computes x*y -- the model
60
+ # emits the product digits (a learned circuit). Only a%p, b%p is done in code.
61
+
62
+ def prompt_str_sp(p: int, x: int, y: int, W: int) -> str:
63
+ return f"p{p:0{W}d}*{x:0{W}d},{y:0{W}d}{EQ}"
64
+
65
+
66
+ def scratchpad_len(W: int) -> int:
67
+ return 2 * W + 1 + W
68
+
69
+
70
+ def decode_answer_sp(gen_chars: str, W: int) -> int:
71
+ """Generated suffix is 2W product digits, 'R', then W answer digits."""
72
+ return decode_answer(gen_chars[2 * W + 1 : 2 * W + 1 + W], W)
73
+
74
+
75
+ # --- Schoolbook ("partial products") inference variant -------------------
76
+ # Generated suffix: W partial products (W+1 wide), 'P', 2W product digits,
77
+ # 'R', W answer digits. The model emits the partial products and sums them
78
+ # (learned circuit); the answer is the final W digits.
79
+
80
+ def school_gen_len(W: int) -> int:
81
+ return W * (W + 1) + 1 + 2 * W + 1 + W
82
+
83
+
84
+ def decode_answer_school(gen_chars: str, W: int) -> int:
85
+ start = W * (W + 1) + 1 + 2 * W + 1
86
+ return decode_answer(gen_chars[start:start + W], W)
87
+
88
+
89
+ def decode_answer(gen_chars: str, W: int) -> int:
90
+ """Greedy-generated W chars are the reverse-LSB answer digits. Non-digits
91
+ (untrained junk) count as 0. Returns the integer answer."""
92
+ val = 0
93
+ for k in range(min(W, len(gen_chars))):
94
+ c = gen_chars[k]
95
+ d = int(c) if "0" <= c <= "9" else 0 # ASCII only ('³'.isdigit() is True but int() fails)
96
+ val += d * (10 ** k)
97
+ return val
kvgen.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """KV-cached batched greedy generation for ByteGPT — the wall-clock fix.
2
+
3
+ The naive loop re-forwards the full prefix for every generated token
4
+ (O(T·L²) attention work); harmless at tier-2's ~20-token CoT, fatal for
5
+ tier-3+ chains-of-thought (hundreds-thousands of tokens) under the
6
+ challenge's 5-min/1100-problem inference budget.
7
+
8
+ This module reuses the model's OWN weights and norm functions — it is a
9
+ faster schedule for the same computation, not a different model. Prefill
10
+ captures each block's K/V via a forward hook on `attn.c_attn` (one ordinary
11
+ forward over the prompt, which also yields the first generated token); each
12
+ subsequent token does one single-position pass per block against the cache.
13
+
14
+ Supported config (asserted): attn_kind=mha, attn_norm in {entmax15, softmax},
15
+ position_encoding in {alibi, learned, none}, no block gates / loop blocks /
16
+ attention residuals. Validated token-identical against the naive loop in
17
+ __main__ (random-init smoke + optional real checkpoint).
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import sys
22
+ from pathlib import Path
23
+
24
+ import torch
25
+
26
+ _HERE = Path(__file__).resolve().parent
27
+ sys.path.insert(0, str(_HERE)) # submission layout (vendored)
28
+ for _anc in Path(__file__).resolve().parents: # dev layout (repo experiments/)
29
+ if (_anc / "experiments" / "coppola_pretrain_tiny.py").exists():
30
+ sys.path.insert(0, str(_anc / "experiments"))
31
+ break
32
+
33
+ import coppola_pretrain_tiny as cpt # noqa: E402
34
+
35
+
36
+ def _norm_row(scores: torch.Tensor, mode: str) -> torch.Tensor:
37
+ """Attention norm over one query row (all cached keys are valid: no mask)."""
38
+ if mode == "softmax":
39
+ return torch.softmax(scores, dim=-1)
40
+ if mode == "entmax15":
41
+ return (cpt._pkg_entmax15(scores, dim=-1) if cpt._HAVE_ENTMAX_PKG
42
+ else cpt._entmax15(scores, dim=-1))
43
+ raise ValueError(f"kvgen: unsupported attn_norm {mode!r}")
44
+
45
+
46
+ def _assert_supported(model) -> None:
47
+ cfg = model.config
48
+ assert cfg.attn_kind == "mha", f"kvgen: attn_kind={cfg.attn_kind!r}"
49
+ assert cfg.attn_norm in ("entmax15", "softmax"), f"kvgen: attn_norm={cfg.attn_norm!r}"
50
+ assert cfg.position_encoding in ("alibi", "learned", "none"), \
51
+ f"kvgen: position_encoding={cfg.position_encoding!r}"
52
+ assert cfg.res_attn == "none", "kvgen: res_attn unsupported"
53
+ assert not cfg.loop_block_indices and not cfg.loop_unit_indices, "kvgen: loop blocks unsupported"
54
+ assert model._gate_values() is None, "kvgen: block gates unsupported"
55
+ assert cfg.dropout == 0.0 or not model.training, "kvgen: eval mode required"
56
+
57
+
58
+ @torch.no_grad()
59
+ def generate_kv(model, ids: torch.Tensor, n_gen: int) -> torch.Tensor:
60
+ """Append n_gen greedy tokens to each row of ids ([B, T0] long). Returns
61
+ the [B, n_gen] generated tokens. Total length must fit cfg.seq_len."""
62
+ _assert_supported(model)
63
+ cfg = model.config
64
+ B, T0 = ids.shape
65
+ assert T0 + n_gen <= cfg.seq_len, f"kvgen: {T0}+{n_gen} exceeds seq_len {cfg.seq_len}"
66
+ device = ids.device
67
+ blocks = model.transformer.h
68
+ L = len(blocks)
69
+ nh = [blk.attn.n_head for blk in blocks]
70
+ hd = [blk.attn.head_dim for blk in blocks]
71
+
72
+ # ---- prefill: one ordinary forward over the prompt, K/V captured by hook
73
+ kv: list[tuple[torch.Tensor, torch.Tensor] | None] = [None] * L
74
+
75
+ def _mk_hook(i):
76
+ def hook(_mod, _inp, qkv):
77
+ _q, k, v = qkv.chunk(3, dim=-1)
78
+ kv[i] = (k.view(B, -1, nh[i], hd[i]).transpose(1, 2).contiguous(),
79
+ v.view(B, -1, nh[i], hd[i]).transpose(1, 2).contiguous())
80
+ return hook
81
+
82
+ handles = [blk.attn.c_attn.register_forward_hook(_mk_hook(i))
83
+ for i, blk in enumerate(blocks)]
84
+ try:
85
+ logits, _ = model(ids)
86
+ finally:
87
+ for h in handles:
88
+ h.remove()
89
+ out = [logits[:, -1].argmax(dim=-1)] # first generated token
90
+
91
+ slopes = (model._alibi_slopes.to(device) if cfg.position_encoding == "alibi" else None)
92
+
93
+ # ---- incremental steps: one single-position pass per block per token
94
+ for step in range(1, n_gen):
95
+ t = T0 + step - 1 # position of the token we feed in
96
+ x = model.transformer.wte(out[-1]).unsqueeze(1) # [B, 1, E]
97
+ if cfg.position_encoding == "learned":
98
+ x = x + model.transformer.wpe(torch.tensor([t], device=device))[None, :, :]
99
+ for i, blk in enumerate(blocks):
100
+ h = blk.ln_1(x)
101
+ q, k, v = blk.attn.c_attn(h).chunk(3, dim=-1)
102
+ q = q.view(B, 1, nh[i], hd[i]).transpose(1, 2) # [B, nh, 1, hd]
103
+ k = k.view(B, 1, nh[i], hd[i]).transpose(1, 2)
104
+ v = v.view(B, 1, nh[i], hd[i]).transpose(1, 2)
105
+ K = torch.cat([kv[i][0], k], dim=2) # [B, nh, t+1, hd]
106
+ V = torch.cat([kv[i][1], v], dim=2)
107
+ kv[i] = (K, V)
108
+ # [B, nh, 1, t+1]: dot of the single query with every cached key
109
+ scores = torch.einsum("bhqd,bhjd->bhqj", q, K) / (hd[i] ** 0.5)
110
+ if slopes is not None: # ALiBi row: slope * -(t - j)
111
+ j = torch.arange(K.size(2), device=device, dtype=scores.dtype)
112
+ scores = scores + slopes.view(1, -1, 1, 1) * (-(t - j).abs()).view(1, 1, 1, -1)
113
+ attn = _norm_row(scores, cfg.attn_norm)
114
+ y = torch.einsum("bhqj,bhjd->bhqd", attn, V)
115
+ y = y.transpose(1, 2).contiguous().view(B, 1, -1)
116
+ x = x + blk.attn.c_proj(y)
117
+ x = x + blk.mlp(blk.ln_2(x))
118
+ logits1 = model.lm_head(model.transformer.ln_f(x)) # [B, 1, vocab]
119
+ out.append(logits1[:, -1].argmax(dim=-1))
120
+ return torch.stack(out, dim=1) # [B, n_gen]
121
+
122
+
123
+ @torch.no_grad()
124
+ def generate_naive(model, ids: torch.Tensor, n_gen: int) -> torch.Tensor:
125
+ """Reference loop (full re-forward per token), for validation/timing."""
126
+ cap = model.config.seq_len
127
+ for _ in range(n_gen):
128
+ logits, _ = model(ids[:, -cap:])
129
+ ids = torch.cat([ids, logits[:, -1].argmax(dim=-1, keepdim=True)], dim=1)
130
+ return ids[:, -n_gen:]
131
+
132
+
133
+ if __name__ == "__main__":
134
+ import argparse
135
+ import time
136
+
137
+ ap = argparse.ArgumentParser(description="validate kvgen vs naive loop")
138
+ ap.add_argument("--ckpt", default=None, help="optional trained checkpoint (.pt)")
139
+ ap.add_argument("--n-gen", type=int, default=40)
140
+ ap.add_argument("--batch", type=int, default=8)
141
+ args = ap.parse_args()
142
+ device = "cuda" if torch.cuda.is_available() else "cpu"
143
+
144
+ if args.ckpt:
145
+ sys.path.insert(0, str(_HERE))
146
+ from train_arith_bp_supervised import TrainConfig, build_model
147
+ ck = torch.load(args.ckpt, map_location=device, weights_only=False)
148
+ model = build_model(TrainConfig(**ck["config"]), device)
149
+ model.load_state_dict(ck["state_dict"])
150
+ else:
151
+ cfg = cpt.GPTConfig(vocab_size=256, n_layer=4, n_head=4, n_embd=128,
152
+ seq_len=256, dropout=0.0, attn_kind="mha",
153
+ attn_norm="entmax15", position_encoding="alibi",
154
+ loss_kind="bce")
155
+ torch.manual_seed(0)
156
+ model = cpt.ByteGPT(cfg).to(device)
157
+ model.eval()
158
+
159
+ torch.manual_seed(1)
160
+ ids = torch.randint(40, 70, (args.batch, 24), device=device)
161
+ t0 = time.time()
162
+ a = generate_naive(model, ids, args.n_gen)
163
+ t_naive = time.time() - t0
164
+ t0 = time.time()
165
+ b = generate_kv(model, ids, args.n_gen)
166
+ t_kv = time.time() - t0
167
+ same = (a == b).all().item()
168
+ n_diff = (a != b).sum().item()
169
+ print(f"identical={same} diff_tokens={n_diff}/{a.numel()} "
170
+ f"naive={t_naive:.2f}s kv={t_kv:.2f}s speedup={t_naive / max(t_kv, 1e-9):.1f}x")
171
+ if not same:
172
+ sys.exit(1)
limbs.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Limb codec shared by the scaled CoT encodings (divcot2, karatsuba).
2
+
3
+ A limb is one digit in base B (10 or 100), encoded as a SINGLE byte
4
+ 0x80+value (128..227) so sequence length scales with limb count, not decimal
5
+ digits. Base-100 halves W and roughly quarters quadratic CoT lengths.
6
+ Strings are latin1-safe: encode("latin1") round-trips; never use ascii.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ LIMB_OFFSET = 0x80 # limb v -> chr(0x80+v); supports base <= 128
11
+
12
+
13
+ def limb_char(v: int) -> str:
14
+ return chr(LIMB_OFFSET + v)
15
+
16
+
17
+ def char_limb(c: str) -> int:
18
+ """Inverse of limb_char; out-of-range chars decode to 0 (eval leniency)."""
19
+ v = ord(c) - LIMB_OFFSET
20
+ return v if v >= 0 else 0
21
+
22
+
23
+ def to_limbs(n: int, width: int, base: int, msb_first: bool = False) -> list[int]:
24
+ out = []
25
+ for _ in range(width):
26
+ out.append(n % base)
27
+ n //= base
28
+ assert n == 0, "width too small for value"
29
+ return out[::-1] if msb_first else out
30
+
31
+
32
+ def from_limbs(limbs, base: int, msb_first: bool = False) -> int:
33
+ seq = limbs[::-1] if msb_first else limbs
34
+ val = 0
35
+ for k, v in enumerate(seq):
36
+ val += v * base ** k
37
+ return val
38
+
39
+
40
+ def limb_str(n: int, width: int, base: int, msb_first: bool = False) -> str:
41
+ return "".join(limb_char(v) for v in to_limbs(n, width, base, msb_first))
42
+
43
+
44
+ def parse_limbs(s: str, base: int, msb_first: bool = False) -> int:
45
+ vals = [min(char_limb(c), base - 1) for c in s]
46
+ return from_limbs(vals, base, msb_first)
47
+
48
+
49
+ def width_for(p_max: int, base: int) -> int:
50
+ w = 1
51
+ while base ** w <= p_max - 1:
52
+ w += 1
53
+ return w
54
+
55
+
56
+ if __name__ == "__main__":
57
+ import random
58
+ rng = random.Random(0)
59
+ for base in (10, 100):
60
+ for _ in range(2000):
61
+ w = rng.randint(1, 12)
62
+ n = rng.randrange(base ** w)
63
+ for msb in (False, True):
64
+ s = limb_str(n, w, base, msb)
65
+ assert len(s) == w and parse_limbs(s, base, msb) == n
66
+ print("limbs.py: all round-trip tests pass")
manifest.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "entry_class": "model.ModMulRouter",
3
+ "output_base": 10,
4
+ "framework": "pytorch",
5
+ "model_description": "Prime-magnitude router over from-random-init ByteGPT members (entmax15+ALiBi, BCE+reg): tiers 1-2 scratchpad-CoT member; tier-3 composed multiply+long-division CoT member (model generates accumulator rows, restaged dividend, per-step qd*p and remainders, answer). Routing keys on the size of p only; all answers are generated digit sequences from trained weights.",
6
+ "training_description": "All members trained from random init with BCE(+reg) LM loss plus per-block BP-install probe losses on intermediate variables (carries, borrows, quotient estimates, remainder digits). Synthetic data from the official tier geometry with held-out seeds; tier-3 member trained on ~all ~6445 primes of the 9-16-bit range (full domain coverage), bit-uniform prime sampling, example-aligned batch rows. Randomising weights collapses accuracy. Training code: github.com/area9innovation/modmul-challenge (available to reviewers on request)."
7
+ }
model.py ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Challenge-interface wrapper for the modmul BP-install model.
2
+
3
+ Implements ModularMultiplicationModel. Inference:
4
+ 1. per-argument: parse a, b, p to ints (each hook sees only its own arg)
5
+ 2. predict_digits: reduce x=a%p, y=b%p (legal two-operand reduction), and if
6
+ p is within the model's trained width, greedy-decode the reverse-LSB answer
7
+ digits from the network; otherwise emit [0] (honest out-of-regime fallback).
8
+
9
+ The answer comes entirely from the trained network on in-regime primes:
10
+ randomising the weights collapses accuracy. output_base = 10.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ import torch
18
+
19
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
20
+ # Self-contained: ByteGPT + build_model + the entmax15 recipe are VENDORED into
21
+ # this directory (coppola_pretrain_tiny.py, coppola_pretraining.py,
22
+ # train_arith_bp_supervised.py), so the submission loads with only `torch`
23
+ # available and read access limited to its own dir (the eval sandbox contract).
24
+ # entmax15 falls back to a local forward-exact impl when the `entmax` pip
25
+ # package is absent.
26
+ from train_arith_bp_supervised import TrainConfig, build_model # noqa: E402
27
+ import encoding as enc # noqa: E402
28
+ import composed_encoding as cenc # noqa: E402 (composed multiply+reduce CoT)
29
+ import kvgen # noqa: E402 (KV-cached generation; falls back to naive loop)
30
+
31
+ from modchallenge.interface.base_model import ModularMultiplicationModel # noqa: E402
32
+
33
+
34
+ class ModMulBP(ModularMultiplicationModel):
35
+ def __init__(self):
36
+ self.model = None
37
+ self.W = 1
38
+ self.device = None
39
+ self.regime = 10 # max p exclusive = 10**W
40
+ self.scratchpad = False
41
+ self.school = False
42
+
43
+ def load(self, model_dir: str, weights: str = "weights.pt") -> None:
44
+ torch.manual_seed(0) # determinism is the model's responsibility (rules)
45
+ self.device = "cuda" if torch.cuda.is_available() else "cpu"
46
+ ckpt = torch.load(Path(model_dir) / weights,
47
+ map_location=self.device, weights_only=False)
48
+ tc = TrainConfig(**ckpt["config"])
49
+ self.model = build_model(tc, self.device)
50
+ self.model.load_state_dict(ckpt["state_dict"])
51
+ self.model.eval()
52
+ self.W = ckpt["W"]
53
+ self.scratchpad = bool(ckpt.get("scratchpad", False))
54
+ self.school = bool(ckpt.get("school", False))
55
+ self.composed = bool(ckpt.get("composed", False))
56
+ if self.composed:
57
+ self.base = ckpt["base"]
58
+ self.scratch = bool(ckpt.get("scratch", False))
59
+ self.cursor = bool(ckpt.get("cursor", False))
60
+ self.subpad = bool(ckpt.get("subpad", False))
61
+ self.regime = self.base ** self.W
62
+ else:
63
+ self.regime = 10 ** self.W
64
+ # Trained prime span (for router dispatch): derived from the ckpt's
65
+ # tier list via the official tier geometry; fallback = full regime.
66
+ self.p_lo, self.p_hi = 2, self.regime - 1
67
+ try:
68
+ from modchallenge.config import TIERS
69
+ spans = [(2 ** TIERS[t].min_bits, 2 ** TIERS[t].max_bits - 1)
70
+ for t in ckpt.get("tiers", [])]
71
+ if spans:
72
+ self.p_lo = min(lo for lo, _ in spans)
73
+ self.p_hi = min(max(hi for _, hi in spans), self.regime - 1)
74
+ except Exception:
75
+ pass
76
+
77
+ # per-argument preprocessing (each sees only its own argument)
78
+ def preprocess_a(self, a: str) -> int:
79
+ return int(a)
80
+
81
+ def preprocess_b(self, b: str) -> int:
82
+ return int(b)
83
+
84
+ def preprocess_p(self, p: str) -> int:
85
+ return int(p)
86
+
87
+ @torch.no_grad()
88
+ def predict_digits(self, a_enc, b_enc, p_enc):
89
+ return self.predict_digits_batch([(a_enc, b_enc, p_enc)])[0]
90
+
91
+ @torch.no_grad()
92
+ def predict_digits_batch(self, inputs):
93
+ out = [[0]] * len(inputs)
94
+ if self.composed:
95
+ prompt_fn = lambda p, x, y, W: cenc.prompt_str( # noqa: E731
96
+ x, y, p, W, self.base, self.subpad)
97
+ n_gen = cenc.gen_len(self.W, self.scratch, self.cursor, self.subpad)
98
+ decode_fn = lambda g, W: cenc.decode_answer( # noqa: E731
99
+ g, W, self.base, self.scratch, self.cursor, self.subpad)
100
+ else:
101
+ cot = self.scratchpad or self.school
102
+ prompt_fn = enc.prompt_str_sp if cot else enc.prompt_str
103
+ n_gen = (enc.school_gen_len(self.W) if self.school
104
+ else enc.scratchpad_len(self.W) if self.scratchpad
105
+ else enc.answer_len(self.W))
106
+ decode_fn = (enc.decode_answer_school if self.school
107
+ else enc.decode_answer_sp if self.scratchpad
108
+ else enc.decode_answer)
109
+ prompts, idx = [], []
110
+ for i, (a, b, p) in enumerate(inputs):
111
+ if p >= self.regime: # outside trained width -> honest 0
112
+ continue
113
+ x, y = a % p, b % p
114
+ prompts.append(prompt_fn(p, x, y, self.W))
115
+ idx.append((i, x, y, p))
116
+ if not prompts:
117
+ return out
118
+ # All in-regime prompts share the same length (fixed width) -> batchable.
119
+ # latin1: composed prompts carry limb bytes >127 (limbs.py codec).
120
+ ids = torch.tensor([list(s.encode("latin1")) for s in prompts],
121
+ dtype=torch.long, device=self.device)
122
+ plen = len(prompts[0])
123
+ # KV-cached generation: the naive loop re-forwards the whole prefix per
124
+ # token, which blows the 5-min/1100-problem budget on long CoTs. kvgen
125
+ # is the same computation on the same weights, token-identical
126
+ # (validated); fall back to the naive loop on unsupported configs.
127
+ try:
128
+ gens = kvgen.generate_kv(self.model, ids, n_gen).tolist()
129
+ except AssertionError:
130
+ seq_cap = self.model.config.seq_len
131
+ for _k in range(n_gen):
132
+ logits, _ = self.model(ids[:, -seq_cap:])
133
+ nxt = logits[:, -1].argmax(dim=-1, keepdim=True)
134
+ ids = torch.cat([ids, nxt], dim=1)
135
+ gens = ids[:, plen:].tolist()
136
+ for row, (i, x, y, p) in zip(gens, idx):
137
+ gen = bytes(b & 0xFF for b in row).decode("latin1")
138
+ # No arithmetic touch-up of the model's answer: a decoded value >= p
139
+ # would be malformed (scored incorrect anyway), so emit the honest
140
+ # [0] fallback instead of clamping with % p.
141
+ ans = decode_fn(gen, self.W)
142
+ if 0 <= ans < p:
143
+ out[i] = [int(c) for c in str(ans)]
144
+ return out
145
+
146
+ def max_batch_size(self) -> int:
147
+ return 512
148
+
149
+
150
+ class ModMulRouter(ModularMultiplicationModel):
151
+ """Routes each problem to the most specialized member model by prime
152
+ magnitude. Members are weights_r*.pt files (sorted name order); each is a
153
+ full ModMulBP checkpoint with its own trained regime. A problem goes to
154
+ the FIRST member whose regime covers its p; out-of-regime problems emit
155
+ the honest [0].
156
+
157
+ Compliance: routing keys on the SIZE of p only (per-argument
158
+ representation work, like base conversion); every answer comes from a
159
+ trained member's generated digits.
160
+ """
161
+
162
+ def __init__(self):
163
+ self.members: list[ModMulBP] = []
164
+
165
+ def load(self, model_dir: str) -> None:
166
+ torch.manual_seed(0)
167
+ for f in sorted(Path(model_dir).glob("weights_r*.pt")):
168
+ m = ModMulBP()
169
+ m.load(model_dir, weights=f.name)
170
+ self.members.append(m)
171
+ assert self.members, "router needs weights_r*.pt member checkpoints"
172
+ self.members.sort(key=lambda m: m.regime) # most specialized first
173
+
174
+ def preprocess_a(self, a: str) -> int:
175
+ return int(a)
176
+
177
+ def preprocess_b(self, b: str) -> int:
178
+ return int(b)
179
+
180
+ def preprocess_p(self, p: str) -> int:
181
+ return int(p)
182
+
183
+ @torch.no_grad()
184
+ def predict_digits(self, a_enc, b_enc, p_enc):
185
+ return self.predict_digits_batch([(a_enc, b_enc, p_enc)])[0]
186
+
187
+ @torch.no_grad()
188
+ def predict_digits_batch(self, inputs):
189
+ out = [[0]] * len(inputs)
190
+ groups: dict[int, list[int]] = {}
191
+ for i, (_a, _b, p) in enumerate(inputs):
192
+ # Prefer the member whose TRAINED prime span contains p; fall back
193
+ # to the most specialized member whose regime merely covers it.
194
+ mi = next((k for k, m in enumerate(self.members)
195
+ if m.p_lo <= p <= m.p_hi), None)
196
+ if mi is None:
197
+ mi = next((k for k, m in enumerate(self.members) if p < m.regime), None)
198
+ if mi is not None:
199
+ groups.setdefault(mi, []).append(i)
200
+ for mi, idxs in groups.items():
201
+ sub = [inputs[i] for i in idxs]
202
+ res = self.members[mi].predict_digits_batch(sub)
203
+ for i, r in zip(idxs, res):
204
+ out[i] = r
205
+ return out
206
+
207
+ def max_batch_size(self) -> int:
208
+ return 512
train_arith_bp_supervised.py ADDED
@@ -0,0 +1,633 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """BP-install supervised training on arithmetic.
3
+
4
+ Adds per-block probe-loss to the standard NTP training:
5
+ total = NTP_loss + λ_install · sum_V CE(probe_V(pre_blk_target_V), V_labels)
6
+
7
+ For arithmetic, BP variables are (carry_after_k, sum_digit_k) at known
8
+ positions. Target blocks default to Phase-A install pattern:
9
+ - carry_after_k -> block 2
10
+ - sum_digit_k -> block 3
11
+
12
+ The supervised loss pushes the model to install each BP variable at its
13
+ target block's INPUT (i.e., the residual just before block target_block).
14
+
15
+ Hypothesis (from user): forcing single-block install via supervision
16
+ will concentrate the per-head distributed signal from Phase A into a
17
+ cleaner BP message.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import json
23
+ import math
24
+ import random
25
+ import sys
26
+ from dataclasses import dataclass
27
+ from pathlib import Path
28
+ from typing import Dict, List, Tuple
29
+
30
+ import torch
31
+ import torch.nn as nn
32
+ import torch.nn.functional as F
33
+
34
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
35
+ from coppola_pretrain_tiny import (
36
+ ByteGPT, GPTConfig,
37
+ evaluate_arithmetic_accuracy,
38
+ bits_per_byte,
39
+ )
40
+
41
+
42
+ # ----------------------------- BP annotations ------------------------------
43
+
44
+ def _make_addition_with_annotations(max_digits: int,
45
+ surface: str = "reverse"
46
+ ) -> Tuple[str, List[Dict[str, int]]]:
47
+ """Generate an addition problem in one of two surfaces, with per-position
48
+ BP-variable annotations placed at the NTP predicting residual.
49
+
50
+ Surfaces:
51
+ - "reverse": `a+b=R<ans_lsb_first>\n`, e.g. 12+34 -> "12+34=R64\n"
52
+ - "forward": `a+b=F<ans_msb_first>\n`, e.g. 12+34 -> "12+34=F46\n"
53
+
54
+ Annotation IDENTITY (carry_after_k, sum_digit_k) is invariant across
55
+ surfaces; only the POSITION at which the predicting residual sits
56
+ differs. This is the test substrate for surface-form invariance of
57
+ BP-install location.
58
+ """
59
+ if surface not in ("reverse", "forward"):
60
+ raise ValueError(f"surface must be 'reverse' or 'forward', got {surface!r}")
61
+ n_a = random.randint(1, max_digits)
62
+ n_b = random.randint(1, max_digits)
63
+ a = random.randint(0, 10 ** n_a - 1)
64
+ b = random.randint(0, 10 ** n_b - 1)
65
+ ans_int = a + b
66
+ ans_forward = str(ans_int)
67
+ ans_emit = ans_forward[::-1] if surface == "reverse" else ans_forward
68
+ marker = "R" if surface == "reverse" else "F"
69
+ text = f"{a}+{b}={marker}{ans_emit}\n"
70
+ a_str = str(a)
71
+ b_str = str(b)
72
+ plus_pos = len(a_str)
73
+ eq_pos = plus_pos + 1 + len(b_str)
74
+ marker_pos = eq_pos + 1
75
+ ans_start = marker_pos + 1
76
+ ans_len = len(ans_emit)
77
+ ann = [{} for _ in range(len(text))]
78
+
79
+ # Compute carries: carry_after_k = carry out of column k (binary).
80
+ max_n = max(n_a, n_b)
81
+ carry = 0
82
+ carries = []
83
+ for k in range(max_n + 1):
84
+ da = (a // (10 ** k)) % 10 if k < n_a else 0
85
+ db = (b // (10 ** k)) % 10 if k < n_b else 0
86
+ tot = da + db + carry
87
+ carry = tot // 10
88
+ carries.append(carry)
89
+
90
+ # Sum digits and carries placed at predicting residuals (NTP-aligned).
91
+ # For position-of-sum_digit_k's EMISSION:
92
+ # reverse: sum_digit_k emitted at ans_start + k
93
+ # forward: sum_digit_k emitted at ans_start + (ans_len - 1 - k)
94
+ # The annotation goes at emission_pos - 1.
95
+ # carry_after_k is needed to PREDICT sum_digit_(k+1), so it sits at the
96
+ # residual that PRECEDES sum_digit_(k+1)'s emission position.
97
+ def emission_pos_for_sum_k(k: int) -> int:
98
+ # k=0 is LSB; emission position depends on surface
99
+ if surface == "reverse":
100
+ return ans_start + k
101
+ # forward: most-significant emitted first
102
+ # max emitted digit index = ans_len - 1 corresponds to LSB if forward
103
+ # The kth LSB digit is the (ans_len - 1 - k)-th emitted in forward
104
+ return ans_start + (ans_len - 1 - k)
105
+
106
+ for k in range(ans_len):
107
+ emit = emission_pos_for_sum_k(k)
108
+ tgt = emit - 1
109
+ if 0 <= tgt < len(text):
110
+ # Extract the actual digit value at that emission position
111
+ digit_char = text[emit]
112
+ if digit_char.isdigit():
113
+ ann[tgt][f"sum_digit_{k}"] = int(digit_char)
114
+
115
+ # carry_after_k is needed to predict sum_digit_(k+1).
116
+ for k, c in enumerate(carries):
117
+ if k + 1 >= ans_len:
118
+ continue # no next sum digit to predict
119
+ emit_next = emission_pos_for_sum_k(k + 1)
120
+ tgt = emit_next - 1
121
+ if 0 <= tgt < len(text):
122
+ ann[tgt][f"carry_after_{k}"] = c
123
+
124
+ return text, ann
125
+
126
+
127
+ # Variables we supervise and their target blocks.
128
+ # Per Phase A: carries install around block 2, sums around block 3.
129
+ # For 4L model: blocks 0..3.
130
+ def default_var_specs(max_digits: int, target_carry_block: int = 2,
131
+ target_sum_block: int = 3
132
+ ) -> List[Tuple[str, int, int]]:
133
+ """Return list of (var_name, n_classes, target_block_idx)."""
134
+ specs = []
135
+ # carry_after_k: 2-class (0 or 1) — for digits beyond highest column,
136
+ # carry could exceed 1 only in pathological cases, so binary is safe
137
+ # for 1-3 digit ops with values 0..999+0..999=0..1998.
138
+ for k in range(max_digits + 1):
139
+ specs.append((f"carry_after_{k}", 2, target_carry_block))
140
+ for k in range(max_digits + 1):
141
+ specs.append((f"sum_digit_{k}", 10, target_sum_block))
142
+ return specs
143
+
144
+
145
+ # ----------------------------- Corpus --------------------------------------
146
+
147
+ class ArithmeticBPCorpus:
148
+ """Arithmetic corpus emitting (x, y, bp_labels)."""
149
+
150
+ def __init__(self, max_digits: int, var_specs, surfaces=("reverse",),
151
+ surface_weights=None):
152
+ """surfaces: tuple of surface names to sample from.
153
+ surface_weights: matching list of relative weights (default uniform)."""
154
+ self.max_digits = max_digits
155
+ self.var_specs = var_specs
156
+ self.var_names = [name for name, _, _ in var_specs]
157
+ self.surfaces = tuple(surfaces)
158
+ if surface_weights is None:
159
+ surface_weights = [1.0] * len(self.surfaces)
160
+ if len(surface_weights) != len(self.surfaces):
161
+ raise ValueError("surface_weights length mismatch")
162
+ total = sum(surface_weights)
163
+ self._cum_weights = []
164
+ run = 0.0
165
+ for w in surface_weights:
166
+ run += w / total
167
+ self._cum_weights.append(run)
168
+
169
+ def _pick_surface(self) -> str:
170
+ r = random.random()
171
+ for s, c in zip(self.surfaces, self._cum_weights):
172
+ if r <= c:
173
+ return s
174
+ return self.surfaces[-1]
175
+
176
+ def sample_batch(self, batch_size: int, seq_len: int, device: str):
177
+ need = seq_len + 1
178
+ x_batch = torch.zeros((batch_size, seq_len), dtype=torch.long)
179
+ y_batch = torch.zeros((batch_size, seq_len), dtype=torch.long)
180
+ labels = {v: torch.full((batch_size, seq_len), -1, dtype=torch.long)
181
+ for v in self.var_names}
182
+ for b in range(batch_size):
183
+ buf_bytes: List[int] = []
184
+ buf_ann: List[Dict[str, int]] = []
185
+ while len(buf_bytes) < need:
186
+ surface = self._pick_surface()
187
+ text, ann = _make_addition_with_annotations(self.max_digits,
188
+ surface=surface)
189
+ buf_bytes.extend(text.encode("ascii"))
190
+ buf_ann.extend(ann)
191
+ ids = buf_bytes[:need]
192
+ x_batch[b] = torch.tensor(ids[:-1], dtype=torch.long)
193
+ y_batch[b] = torch.tensor(ids[1:], dtype=torch.long)
194
+ for t in range(seq_len):
195
+ if t < len(buf_ann):
196
+ for v, val in buf_ann[t].items():
197
+ if v in labels:
198
+ labels[v][b, t] = int(val)
199
+ return (
200
+ x_batch.to(device),
201
+ y_batch.to(device),
202
+ {v: t.to(device) for v, t in labels.items()},
203
+ )
204
+
205
+
206
+ # ----------------------------- Probes --------------------------------------
207
+
208
+ class BPProbeHeads(nn.Module):
209
+ """One linear probe per BP variable. Probes are applied to the residual
210
+ at the variable's target block. Optionally also at other blocks
211
+ (anti-spread)."""
212
+
213
+ def __init__(self, d_model: int, var_specs):
214
+ super().__init__()
215
+ self.var_specs = var_specs
216
+ self.probes = nn.ModuleDict({
217
+ name: nn.Linear(d_model, n_classes)
218
+ for name, n_classes, _ in var_specs
219
+ })
220
+
221
+ def loss(self, residuals_per_block: List[torch.Tensor],
222
+ labels: Dict[str, torch.Tensor],
223
+ anti_lambda: float = 0.0) -> Tuple[torch.Tensor, Dict[str, float]]:
224
+ """Install loss: CE at target block. Anti-spread (optional): for
225
+ other blocks, push the probe to NOT extract V (negative log-likelihood
226
+ of UNIFORM distribution = encourage low confidence)."""
227
+ total = torch.zeros((), device=residuals_per_block[0].device)
228
+ details = {}
229
+ for name, n_classes, target_block in self.var_specs:
230
+ res = residuals_per_block[target_block]
231
+ logits = self.probes[name](res) # [B, T, n_classes]
232
+ y = labels[name] # [B, T]
233
+ mask = y >= 0
234
+ if int(mask.sum()) == 0:
235
+ continue
236
+ ce = F.cross_entropy(
237
+ logits[mask],
238
+ y[mask],
239
+ reduction="mean",
240
+ )
241
+ total = total + ce
242
+ details[f"L_{name}_at_blk{target_block}"] = float(ce.item())
243
+
244
+ if anti_lambda > 0.0:
245
+ anti_terms = []
246
+ for b_idx, res_b in enumerate(residuals_per_block):
247
+ if b_idx == target_block:
248
+ continue
249
+ other_logits = self.probes[name](res_b)
250
+ # Want predicted distribution near uniform -> minimize KL
251
+ # to uniform = maximize entropy of softmax.
252
+ logp = F.log_softmax(other_logits, dim=-1)
253
+ p = logp.exp()
254
+ # Negative entropy of p (we want to MAXIMIZE entropy ->
255
+ # MINIMIZE negative entropy).
256
+ neg_ent = (p * logp).sum(dim=-1)
257
+ anti_terms.append(neg_ent[mask].mean())
258
+ if anti_terms:
259
+ anti = torch.stack(anti_terms).mean()
260
+ total = total + anti_lambda * anti
261
+ details[f"anti_{name}"] = float(anti.item())
262
+ return total, details
263
+
264
+ @torch.no_grad()
265
+ def probe_accs(self, residuals_per_block: List[torch.Tensor],
266
+ labels: Dict[str, torch.Tensor]) -> Dict[str, Dict[int, float]]:
267
+ out: Dict[str, Dict[int, float]] = {}
268
+ for name, n_classes, _ in self.var_specs:
269
+ y = labels[name]
270
+ mask = y >= 0
271
+ if int(mask.sum()) == 0:
272
+ continue
273
+ out[name] = {}
274
+ for b_idx, res in enumerate(residuals_per_block):
275
+ logits = self.probes[name](res)
276
+ pred = logits.argmax(dim=-1)
277
+ acc = float((pred[mask] == y[mask]).float().mean())
278
+ out[name][b_idx] = acc
279
+ return out
280
+
281
+
282
+ # ----------------------------- Forward helper ------------------------------
283
+
284
+ def head_entropy_loss(model: ByteGPT) -> torch.Tensor:
285
+ """Penalize entropy of head-norm distribution per row of c_proj.
286
+
287
+ For each block, c_proj has shape [d_model, n_head * head_dim]. Reshape to
288
+ [d_model, n_head, head_dim] and compute per-row squared norms across heads.
289
+ Normalize per-row to get a head-attribution distribution. Penalize its
290
+ entropy — minimum (0) means each row written by ONE head.
291
+ """
292
+ total = 0.0
293
+ n_blocks = 0
294
+ eps = 1e-9
295
+ n_head = model.config.n_head
296
+ for blk in model.transformer.h:
297
+ if not hasattr(blk.attn, "c_proj"):
298
+ continue
299
+ W = blk.attn.c_proj.weight
300
+ d_model = W.shape[0]
301
+ head_dim = W.shape[1] // n_head
302
+ if head_dim * n_head != W.shape[1]:
303
+ continue
304
+ blocks = W.view(d_model, n_head, head_dim)
305
+ head_norms_sq = (blocks ** 2).sum(dim=-1) # [d_model, n_head]
306
+ p = head_norms_sq / (head_norms_sq.sum(dim=-1, keepdim=True) + eps)
307
+ ent = -(p * (p + eps).log()).sum(dim=-1) # [d_model]
308
+ total = total + ent.mean()
309
+ n_blocks += 1
310
+ if n_blocks == 0:
311
+ return torch.zeros((), device=W.device)
312
+ return total / n_blocks
313
+
314
+
315
+ @torch.no_grad()
316
+ def evaluate_mixed_arithmetic(model: ByteGPT, device: str, n_problems: int,
317
+ max_digits: int, surfaces=("reverse", "forward"),
318
+ seed: int = 42) -> Dict[str, float]:
319
+ """Greedy-decode `a+b=<marker>` and compare against ground truth for each
320
+ surface. Returns per-surface accuracies and a combined number."""
321
+ random.seed(seed)
322
+ torch.manual_seed(seed)
323
+ nl_id = ord("\n")
324
+ results: Dict[str, Dict[str, list]] = {s: {"correct": [], "by_len": {}}
325
+ for s in surfaces}
326
+ per_surface_n = max(1, n_problems // len(surfaces))
327
+ for surface in surfaces:
328
+ marker = "R" if surface == "reverse" else "F"
329
+ for _ in range(per_surface_n):
330
+ n_a = random.randint(1, max_digits)
331
+ n_b = random.randint(1, max_digits)
332
+ a = random.randint(0, 10 ** n_a - 1)
333
+ b = random.randint(0, 10 ** n_b - 1)
334
+ ans_int = a + b
335
+ ans_forward = str(ans_int)
336
+ true_emit = ans_forward[::-1] if surface == "reverse" else ans_forward
337
+ prompt = f"{a}+{b}={marker}"
338
+ ids = torch.tensor([list(prompt.encode("ascii"))],
339
+ dtype=torch.long, device=device)
340
+ max_new = len(true_emit) + 2
341
+ gen = bytearray()
342
+ for _ in range(max_new):
343
+ logits, _ = model(ids)
344
+ nxt = int(logits[0, -1].argmax())
345
+ if nxt == nl_id:
346
+ break
347
+ gen.append(nxt)
348
+ ids = torch.cat([ids, torch.tensor([[nxt]], device=device)],
349
+ dim=1)
350
+ try:
351
+ got = gen.decode("ascii", errors="ignore")
352
+ except Exception:
353
+ got = ""
354
+ is_correct = (got == true_emit)
355
+ results[surface]["correct"].append(1 if is_correct else 0)
356
+ n_digits = max(n_a, n_b)
357
+ bucket = results[surface]["by_len"].setdefault(n_digits, [0, 0])
358
+ bucket[1] += 1
359
+ if is_correct:
360
+ bucket[0] += 1
361
+ out = {}
362
+ total_correct, total_n = 0, 0
363
+ for s in surfaces:
364
+ c = sum(results[s]["correct"])
365
+ n = max(1, len(results[s]["correct"]))
366
+ out[f"acc_{s}"] = c / n
367
+ for k, (cc, tt) in sorted(results[s]["by_len"].items()):
368
+ out[f"acc_{s}_{k}d"] = cc / max(1, tt)
369
+ total_correct += c
370
+ total_n += n
371
+ out["accuracy"] = total_correct / max(1, total_n)
372
+ return out
373
+
374
+
375
+ def forward_with_block_captures(model: ByteGPT, x: torch.Tensor, y: torch.Tensor):
376
+ """Run model and capture pre-block residuals + post-ln_f.
377
+ Returns (logits, lm_loss, residuals_per_block).
378
+ residuals_per_block[i] is the input to block i (= output of block i-1).
379
+ """
380
+ captures: List[torch.Tensor] = [None] * model.config.n_layer
381
+ handles = []
382
+ for i, blk in enumerate(model.transformer.h):
383
+ def make_hook(idx):
384
+ def hook(module, inputs):
385
+ captures[idx] = inputs[0]
386
+ return hook
387
+ handles.append(blk.register_forward_pre_hook(make_hook(i)))
388
+ try:
389
+ logits, lm_loss = model(x, y)
390
+ finally:
391
+ for h in handles:
392
+ h.remove()
393
+ return logits, lm_loss, captures
394
+
395
+
396
+ # ----------------------------- Training ------------------------------------
397
+
398
+ @dataclass
399
+ class TrainConfig:
400
+ n_layer: int = 4
401
+ n_head: int = 4
402
+ n_embd: int = 128
403
+ mlp_mult: int = 4
404
+ mlp_activation: str = "silu"
405
+ seq_len: int = 128
406
+ attn_kind: str = "mha"
407
+ attn_norm: str = "entmax15"
408
+ position_encoding: str = "alibi"
409
+ loss: str = "bce"
410
+ logit_rmsnorm_scale: float = 8.0
411
+ sparsity_lambda: float = 1e-3
412
+ # A4: Attention Residuals depth-router + entropy pressure, composed
413
+ # with the BP-install probe-loss. The entropy term is added to
414
+ # lm_loss inside ByteGPT.forward; install-loss supplies the correct
415
+ # support. See docs/attnres-bp-research-program.md A2′-result / A4.
416
+ res_attn: str = "none"
417
+ res_attn_norm: str = "softmax"
418
+ res_attn_temp: float = 1.0
419
+ res_attn_entropy_lambda: float = 0.0
420
+
421
+
422
+ def build_model(tc: TrainConfig, device: str) -> ByteGPT:
423
+ cfg = GPTConfig(
424
+ vocab_size=256,
425
+ n_layer=tc.n_layer,
426
+ n_head=tc.n_head,
427
+ n_embd=tc.n_embd,
428
+ seq_len=tc.seq_len,
429
+ mlp_mult=tc.mlp_mult,
430
+ mlp_activation=tc.mlp_activation,
431
+ attn_kind=tc.attn_kind,
432
+ attn_norm=tc.attn_norm,
433
+ position_encoding=tc.position_encoding,
434
+ loss_kind=tc.loss,
435
+ logit_rmsnorm_scale=tc.logit_rmsnorm_scale,
436
+ sparsity_lambda=tc.sparsity_lambda,
437
+ res_attn=tc.res_attn,
438
+ res_attn_norm=tc.res_attn_norm,
439
+ res_attn_temp=tc.res_attn_temp,
440
+ res_attn_entropy_lambda=tc.res_attn_entropy_lambda,
441
+ )
442
+ return ByteGPT(cfg).to(device)
443
+
444
+
445
+ def main() -> None:
446
+ ap = argparse.ArgumentParser(description=__doc__)
447
+ ap.add_argument("--steps", type=int, default=8000)
448
+ ap.add_argument("--batch-size", type=int, default=64)
449
+ ap.add_argument("--seq-len", type=int, default=128)
450
+ ap.add_argument("--lr", type=float, default=3e-3)
451
+ ap.add_argument("--lr-min", type=float, default=3e-4)
452
+ ap.add_argument("--warmup-steps", type=int, default=500)
453
+ ap.add_argument("--weight-decay", type=float, default=0.01)
454
+ ap.add_argument("--max-digits", type=int, default=3)
455
+ ap.add_argument("--seed", type=int, default=0)
456
+ ap.add_argument("--surfaces", nargs="+", default=["reverse"],
457
+ choices=["reverse", "forward"],
458
+ help="Surface forms to sample (uniform).")
459
+ ap.add_argument("--eval-every", type=int, default=1000)
460
+ ap.add_argument("--n-eval-arith", type=int, default=200)
461
+ ap.add_argument("--target-carry-block", type=int, default=2)
462
+ ap.add_argument("--target-sum-block", type=int, default=3)
463
+ ap.add_argument("--install-lambda", type=float, default=1.0,
464
+ help="Coefficient for BP-install probe loss.")
465
+ ap.add_argument("--anti-lambda", type=float, default=0.0,
466
+ help="Coefficient for anti-spread loss (push non-target "
467
+ "block probes to uniform).")
468
+ ap.add_argument("--head-entropy-lambda", type=float, default=0.0,
469
+ help="Coefficient for head specialization loss: entropy of "
470
+ "head-norm distribution across rows of c_proj. "
471
+ "Minimize -> each row written by one head.")
472
+ ap.add_argument("--res-attn", choices=["none", "full"], default="none",
473
+ help="A4: 'full' = block-granular Attention Residuals "
474
+ "(depth-router) composed with BP-install supervision.")
475
+ ap.add_argument("--res-attn-norm",
476
+ choices=["softmax", "entmax15", "sparsemax"],
477
+ default="softmax",
478
+ help="Depth-attention normalization (res-attn only).")
479
+ ap.add_argument("--res-attn-temp", type=float, default=1.0,
480
+ help="A2′ fixed depth-score temperature (res-attn only).")
481
+ ap.add_argument("--res-attn-entropy-lambda", type=float, default=0.0,
482
+ help="A2′ entropy penalty on α_depth (res-attn only); "
483
+ "supplies sparsity, install-loss supplies support.")
484
+ ap.add_argument("--n-layer", type=int, default=4)
485
+ ap.add_argument("--n-head", type=int, default=4)
486
+ ap.add_argument("--n-embd", type=int, default=128)
487
+ ap.add_argument("--checkpoint-dir", type=Path, required=True)
488
+ ap.add_argument("--output", type=Path, required=True)
489
+ ap.add_argument("--save-every", type=int, default=2000)
490
+ args = ap.parse_args()
491
+
492
+ random.seed(args.seed)
493
+ torch.manual_seed(args.seed)
494
+
495
+ device = "cuda" if torch.cuda.is_available() else "cpu"
496
+ tc = TrainConfig(seq_len=args.seq_len, n_layer=args.n_layer,
497
+ n_head=args.n_head, n_embd=args.n_embd,
498
+ res_attn=args.res_attn, res_attn_norm=args.res_attn_norm,
499
+ res_attn_temp=args.res_attn_temp,
500
+ res_attn_entropy_lambda=args.res_attn_entropy_lambda)
501
+ model = build_model(tc, device)
502
+ if tc.res_attn != "none":
503
+ print(f"# AttnRes: {tc.res_attn}/{tc.res_attn_norm} "
504
+ f"temp={tc.res_attn_temp} entropy_lambda={tc.res_attn_entropy_lambda}")
505
+
506
+ var_specs = default_var_specs(args.max_digits, args.target_carry_block,
507
+ args.target_sum_block)
508
+ probes = BPProbeHeads(tc.n_embd, var_specs).to(device)
509
+ corpus = ArithmeticBPCorpus(args.max_digits, var_specs=var_specs,
510
+ surfaces=tuple(args.surfaces))
511
+
512
+ # Optimizer: include both model and probe params
513
+ all_params = list(model.parameters()) + list(probes.parameters())
514
+ optimizer = torch.optim.AdamW(all_params, lr=args.lr,
515
+ weight_decay=args.weight_decay,
516
+ betas=(0.9, 0.95))
517
+
518
+ def cur_lr(step):
519
+ if step < args.warmup_steps:
520
+ return args.lr * step / max(1, args.warmup_steps)
521
+ progress = (step - args.warmup_steps) / max(1, args.steps - args.warmup_steps)
522
+ progress = min(1.0, max(0.0, progress))
523
+ return args.lr_min + 0.5 * (args.lr - args.lr_min) * (1 + math.cos(math.pi * progress))
524
+
525
+ args.checkpoint_dir.mkdir(parents=True, exist_ok=True)
526
+ history: List[Dict] = []
527
+
528
+ print(f"# device={device} n_layer={tc.n_layer} n_head={tc.n_head} n_embd={tc.n_embd}")
529
+ print(f"# BP supervision: λ_install={args.install_lambda} λ_anti={args.anti_lambda}")
530
+ print(f"# Targets: carry→blk{args.target_carry_block}, sum→blk{args.target_sum_block}")
531
+ print(f"# Variables supervised: {[s[0] for s in var_specs]}")
532
+
533
+ last_train_loss = None
534
+ last_lm_loss = None
535
+ last_install_loss = None
536
+ for step in range(1, args.steps + 1):
537
+ model.train()
538
+ probes.train()
539
+ lr = cur_lr(step)
540
+ for pg in optimizer.param_groups:
541
+ pg["lr"] = lr
542
+
543
+ x, y, bp_labels = corpus.sample_batch(args.batch_size, args.seq_len, device)
544
+ optimizer.zero_grad(set_to_none=True)
545
+ _, lm_loss, captures = forward_with_block_captures(model, x, y)
546
+ install_loss, details = probes.loss(captures, bp_labels,
547
+ anti_lambda=args.anti_lambda)
548
+ total = lm_loss + args.install_lambda * install_loss
549
+ if args.head_entropy_lambda > 0:
550
+ head_ent = head_entropy_loss(model)
551
+ total = total + args.head_entropy_lambda * head_ent
552
+ last_head_ent = float(head_ent.item())
553
+ else:
554
+ last_head_ent = 0.0
555
+ total.backward()
556
+ torch.nn.utils.clip_grad_norm_(all_params, 1.0)
557
+ optimizer.step()
558
+ last_train_loss = float(total.item())
559
+ last_lm_loss = float(lm_loss.item())
560
+ last_install_loss = float(install_loss.item())
561
+
562
+ if step == 1 or step % args.eval_every == 0 or step == args.steps:
563
+ model.eval()
564
+ probes.eval()
565
+ # Compute val loss + bpb on fresh batch
566
+ with torch.no_grad():
567
+ vx, vy, vbp = corpus.sample_batch(args.batch_size, args.seq_len, device)
568
+ vlogits, vlm_loss, vcaps = forward_with_block_captures(model, vx, vy)
569
+ vce = F.cross_entropy(vlogits.view(-1, vlogits.size(-1)),
570
+ vy.reshape(-1))
571
+ vbpb = float(bits_per_byte(vce))
572
+ # Probe accuracies across all blocks (diagnostic)
573
+ accs = probes.probe_accs(vcaps, vbp)
574
+ arith = evaluate_mixed_arithmetic(
575
+ model, device, n_problems=args.n_eval_arith,
576
+ max_digits=args.max_digits,
577
+ surfaces=tuple(args.surfaces),
578
+ )
579
+ entry = {
580
+ "step": step,
581
+ "lr": lr,
582
+ "train_loss": last_train_loss,
583
+ "lm_loss": last_lm_loss,
584
+ "install_loss": last_install_loss,
585
+ "head_ent": last_head_ent,
586
+ "val_lm": float(vlm_loss.item()),
587
+ "val_ce": float(vce.item()),
588
+ "val_bpb": vbpb,
589
+ "arith_acc": arith["accuracy"],
590
+ "probe_accs": accs,
591
+ }
592
+ history.append(entry)
593
+ line = (f"step={step:>5d} lr={lr:.2e} "
594
+ f"lm={last_lm_loss:.3f} install={last_install_loss:.3f} "
595
+ f"head_ent={last_head_ent:.3f} "
596
+ f"val_bpb={vbpb:.3f} arith_acc={arith['accuracy']:.3f}")
597
+ print(line)
598
+ # Compact probe summary: install variable at its target block + spread
599
+ for name, n_cls, tgt in var_specs:
600
+ if name in accs:
601
+ row = accs[name]
602
+ target_acc = row.get(tgt, float("nan"))
603
+ others = [v for k, v in row.items() if k != tgt]
604
+ spread = (sum(others) / len(others)) if others else 0.0
605
+ if step == args.steps or step % (args.eval_every * 4) == 1 or step == 1:
606
+ print(f" {name}: blk{tgt}={target_acc:.3f} "
607
+ f"avg-other={spread:.3f} delta={target_acc-spread:+.3f}")
608
+
609
+ if (args.save_every and step % args.save_every == 0) or step == args.steps:
610
+ ck = args.checkpoint_dir / f"bp_sup_step_{step}.pt"
611
+ torch.save({
612
+ "model_state": model.state_dict(),
613
+ "model_config": vars(model.config),
614
+ "probe_state": probes.state_dict(),
615
+ "var_specs": var_specs,
616
+ "step": step,
617
+ }, ck)
618
+ torch.save({"model_state": model.state_dict(),
619
+ "model_config": vars(model.config),
620
+ "probe_state": probes.state_dict(),
621
+ "var_specs": var_specs,
622
+ "step": step}, args.checkpoint_dir / "bp_sup_latest.pt")
623
+
624
+ args.output.parent.mkdir(parents=True, exist_ok=True)
625
+ args.output.write_text(json.dumps({
626
+ "config": vars(args) | {"checkpoint_dir": str(args.checkpoint_dir), "output": str(args.output)},
627
+ "var_specs": var_specs,
628
+ "history": history,
629
+ }, indent=2, default=str))
630
+
631
+
632
+ if __name__ == "__main__":
633
+ main()
weights_r0.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9451db167738bd8b84e25ffc1944af16f14c91a721f39f8257e23b0646ac7734
3
+ size 43512279
weights_r1.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6a405c8aeb8bc1d5beb451cc619e7e0c55bd0807b636fae9b7a438c6fe2c5dca
3
+ size 19709291