briscoooe commited on
Commit
0bcae1d
·
verified ·
1 Parent(s): 07bcccf

code: policy distillation trainer, tests and bootstrap mode

Browse files
code/test_policy_distill.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Smoke tests for train_policy_distill.py, runnable on CPU with no checkpoint.
3
+
4
+ The point is not coverage. It is that a rented GPU box must not be the place
5
+ where a shape error or a misaligned target index shows up: that costs metered
6
+ time, and a *silently* misaligned target costs a whole run that looks healthy.
7
+ So the alignment between the language-model sequence and the rollout is checked
8
+ against the string engine in gen_data, which is a completely separate
9
+ implementation from the numpy one in cube_ops.
10
+
11
+ python test_policy_distill.py
12
+ """
13
+ import sys, tempfile
14
+ from pathlib import Path
15
+
16
+ import numpy as np
17
+ import torch
18
+
19
+ sys.path.insert(0, str(Path(__file__).parent))
20
+ import cube_ops as C
21
+ import cube_tokenizer as T
22
+ import train_policy_distill as D
23
+ from gen_data import SOLVED, apply_sequence
24
+ from train_value_iteration import ValueNet
25
+
26
+
27
+ def make_teacher(tmp):
28
+ """A randomly initialised value net, saved in the value-iteration format."""
29
+ net = ValueNet(32, 2, 2)
30
+ path = Path(tmp) / "teacher.pt"
31
+ torch.save({"model": net.state_dict(), "hidden": 32, "layers": 2, "heads": 2,
32
+ "step": 0}, path)
33
+ return D.load_teacher(str(path), "cpu")
34
+
35
+
36
+ def test_token_tables():
37
+ assert len(D.ANSWER_IDS) == 19
38
+ assert D.MOVE_IDS == [T.STOI[m] for m in T.MOVES]
39
+ assert D.ANSWER_IDS[D.EOS_SLOT] == T.EOS
40
+ # The move a target index names must be the move cube_ops applies for it.
41
+ s = C.encode(SOLVED)
42
+ for i, mv in enumerate(T.MOVES):
43
+ arr = C.decode(C.apply_moves(s, [i]))[0]
44
+ assert arr == apply_sequence(SOLVED, [mv]), mv
45
+ print("ok token tables agree with both engines")
46
+
47
+
48
+ def test_pack_alignment(net):
49
+ """Every masked position must carry the state its prefix actually reaches."""
50
+ rng = np.random.default_rng(0)
51
+ states0, _ = C.random_walk(6, 12, rng)
52
+ batch = D.rollout_batch(net, states0, "cpu", 8, 0.35, 0.0, rng, amp=False)
53
+
54
+ ids = batch["ids"].numpy()
55
+ faces = batch["faces"].numpy()
56
+ mask = batch["posmask"].numpy()
57
+ order = batch["order"].numpy()
58
+
59
+ for b in range(len(states0)):
60
+ start = C.decode(states0[b:b + 1])[0]
61
+ assert list(ids[b, :T.STATE_LEN + 2]) == T.encode_state(start)
62
+ idx = np.where(mask[b])[0]
63
+ assert list(order[b, idx]) == list(range(len(idx)))
64
+ for t, pos in enumerate(idx):
65
+ # The prefix that reaches this position is the prompt plus t moves.
66
+ prefix = ids[b, D.PROMPT_LEN:D.PROMPT_LEN + t]
67
+ moves = [T.ITOS[int(i)] for i in prefix]
68
+ assert apply_sequence(start, moves) == C.decode(faces[b, pos:pos + 1])[0], (b, t)
69
+ print(f"ok pack alignment on {len(states0)} rollouts, string engine agrees")
70
+
71
+
72
+ def test_targets(net):
73
+ rng = np.random.default_rng(1)
74
+ states0, _ = C.random_walk(32, 6, rng)
75
+ batch = D.rollout_batch(net, states0, "cpu", 10, 0.35, 0.0, rng, amp=False)
76
+ soft = batch["soft"].numpy()
77
+ mask = batch["posmask"].numpy()
78
+ faces = batch["faces"].numpy()
79
+
80
+ # float32 before summing: the packed target is float16, so a float16
81
+ # accumulation over 19 entries has more error than the assertion allows.
82
+ rows = soft[mask].astype(np.float32)
83
+ assert np.allclose(rows.sum(axis=1), 1.0, atol=1e-3), rows.sum(axis=1)[:5]
84
+ # EOS carries all the mass exactly where the state is solved, and none anywhere else.
85
+ solved = C.is_solved(faces[mask])
86
+ assert np.allclose(rows[solved][:, D.EOS_SLOT], 1.0)
87
+ assert np.allclose(rows[~solved][:, D.EOS_SLOT], 0.0)
88
+ assert (soft[~mask] == 0).all()
89
+ print(f"ok targets normalised; EOS mass matches solved on {mask.sum()} positions")
90
+
91
+
92
+ def test_hard_targets(net):
93
+ rng = np.random.default_rng(2)
94
+ states0, _ = C.random_walk(8, 6, rng)
95
+ batch = D.rollout_batch(net, states0, "cpu", 6, 0.35, 0.0, rng, hard=True, amp=False)
96
+ rows = batch["soft"].numpy()[batch["posmask"].numpy()]
97
+ assert np.allclose(rows.max(axis=1), 1.0)
98
+ print("ok --hard-targets produces one-hot rows")
99
+
100
+
101
+ def test_train_step(net):
102
+ class Args:
103
+ hidden, layers, heads = 32, 2, 2
104
+
105
+ model = D.build_policy(Args())
106
+ head = D.StateHead(32)
107
+ rng = np.random.default_rng(3)
108
+ states0, _ = C.random_walk(8, 10, rng)
109
+ batch = D.rollout_batch(net, states0, "cpu", 8, 0.35, 0.2, rng, amp=False)
110
+ loss, mloss, floss, facc = D.losses(model, head, batch, "cpu", 1.0,
111
+ torch.bfloat16, False)
112
+ assert torch.isfinite(loss), loss
113
+ loss.backward()
114
+ grads = [q.grad for q in head.parameters() if q.grad is not None]
115
+ assert grads and any(g.abs().sum() > 0 for g in grads)
116
+ # The facelet probe must reach the trunk, not just its own linear layer.
117
+ trunk = model.model.layers[0].self_attn.q_proj.weight.grad
118
+ assert trunk is not None and trunk.abs().sum() > 0
119
+ print(f"ok one training step: move {mloss.item():.3f} face {floss.item():.3f} "
120
+ f"acc {facc.item():.1%}, gradients reach the trunk")
121
+ return model, head
122
+
123
+
124
+ def test_eval(net, model, head):
125
+ rates = D.student_greedy(model, "cpu", [3], 8, np.random.default_rng(4))
126
+ assert set(rates) == {3} and 0.0 <= rates[3] <= 1.0
127
+ ceil = D.teacher_greedy(net, "cpu", [3], 8, np.random.default_rng(5), max_steps=4,
128
+ amp=False)
129
+ assert 0.0 <= ceil[3] <= 1.0
130
+ per_pos, exact = D.state_accuracy(model, head, net, "cpu", [3], 8,
131
+ np.random.default_rng(6), 6, 0.35, False)
132
+ assert "0:" in per_pos and 0.0 <= exact <= 1.0
133
+ print(f"ok eval paths run: student {rates[3]:.0%}, teacher {ceil[3]:.0%}, "
134
+ f"state {per_pos}")
135
+
136
+
137
+ def test_solved_start(net):
138
+ """A start state that is already solved must produce EOS and nothing else."""
139
+ states0 = C.SOLVED_ARR[None, :].copy()
140
+ batch = D.rollout_batch(net, states0, "cpu", 8, 0.35, 0.0,
141
+ np.random.default_rng(7), amp=False)
142
+ mask = batch["posmask"].numpy()[0]
143
+ assert mask.sum() == 1
144
+ assert batch["soft"].numpy()[0, mask][0, D.EOS_SLOT] == 1.0
145
+ print("ok a solved start state yields exactly one EOS position")
146
+
147
+
148
+ if __name__ == "__main__":
149
+ torch.manual_seed(0)
150
+ with tempfile.TemporaryDirectory() as tmp:
151
+ teacher = make_teacher(tmp)
152
+ test_token_tables()
153
+ test_pack_alignment(teacher)
154
+ test_targets(teacher)
155
+ test_hard_targets(teacher)
156
+ test_solved_start(teacher)
157
+ model, head = test_train_step(teacher)
158
+ test_eval(teacher, model, head)
159
+ print("\nall smoke tests passed")
code/train_policy_distill.py ADDED
@@ -0,0 +1,644 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Trains the one-shot policy against the value model, with explicit state tracking.
3
+
4
+ The policy model (`train.py`) hits a hard reach wall at ~8 moves: 69% at depth 6,
5
+ 1.5% at depth 10, zero beyond. Four interventions moved it none of the way (see
6
+ `CLAUDE.md`). Two suspects survive, and this trainer attacks both at once.
7
+
8
+ ## Suspect 1 -- the labels punish correct moves
9
+
10
+ `train.py` trains on one Kociemba solution per state with hard cross-entropy. A
11
+ cube state usually has several optimal first moves, and Kociemba emits one path.
12
+ Every other optimal move is scored as an error, so the loss actively pushes down
13
+ moves that are perfectly good.
14
+
15
+ The fix here replaces the single hard label with a soft distribution supplied by
16
+ the value model. For a state s, score all 18 children with the teacher, take
17
+ `q(a) = 1 + J(s')`, and train against `softmax(-q / tau)`. Two optimal moves get
18
+ equal mass instead of one getting all of it. No search is needed to build this
19
+ target: it is 18 forward passes of a 33.6M net, the same expansion DAVI already
20
+ does every step in `train_value_iteration.py`.
21
+
22
+ ## Suspect 2 -- the decoder loses the cube
23
+
24
+ A one-shot policy must carry the cube in its activations. It emits move 1, then
25
+ must predict move 2 from a state it was never shown. Nothing in `train.py` ever
26
+ checks that the internal state is right, so a model can learn plausible-looking
27
+ move preferences while its internal cube drifts.
28
+
29
+ The fix is an auxiliary head. At every output position the decoder must also
30
+ predict the 54 facelets of the state it is currently in. The engine knows that
31
+ state exactly, so the supervision costs nothing. `state accuracy by position` in
32
+ the eval output is the direct read: if it falls away with position, the decoder
33
+ is losing the cube, and that is the wall.
34
+
35
+ ## What the numbers mean
36
+
37
+ The eval prints the student's greedy solve rate and the teacher's side by side.
38
+ This is the point of the experiment, not decoration:
39
+
40
+ - **teacher greedy** is the ceiling. The student is trained to imitate the
41
+ teacher's per-state preference, and the teacher descends with the real state
42
+ supplied by the engine at every step. A perfect student matches it and cannot
43
+ beat it. Measured on the published value-iteration weights that is ~34% at
44
+ depth 10 -- far below the 100% that beam 1024 reaches, because a beam is a
45
+ search and this is not.
46
+ - **student greedy** is the capability, since a one-shot policy is what the LLMs
47
+ on the board are scored on.
48
+ - **the gap between them** is the cost of internal state tracking, because the
49
+ two differ in nothing else.
50
+
51
+ So a student that lands near the teacher says the labels were the problem and
52
+ state tracking is fine. A student that stays far below it says the opposite. The
53
+ old run gives neither reading, which is why it stalled.
54
+
55
+ Three arms come out of one script:
56
+
57
+ --state-weight 0 soft targets only
58
+ --state-weight 1 soft targets + state tracking (the full proposal)
59
+ --hard-targets teacher argmin as a one-hot label, for the ablation
60
+ that separates "soft vs hard" from "value teacher vs
61
+ Kociemba"
62
+
63
+ The saved checkpoint is a plain `LlamaForCausalLM`, so `serve.py`,
64
+ `eval_canonical.py` and `run_benchmark.sh` all read it unchanged. The auxiliary
65
+ head is a diagnostic and is saved beside it, never needed at inference.
66
+ """
67
+ import argparse, json, math, os, sys, time
68
+ from pathlib import Path
69
+
70
+ import numpy as np
71
+ import torch
72
+ import torch.nn.functional as F
73
+
74
+ sys.path.insert(0, str(Path(__file__).parent))
75
+ import cube_ops as C
76
+ import cube_tokenizer as T
77
+ from train_value_iteration import encode_batch
78
+
79
+ # The 19 tokens the policy may emit on the answer span: 18 moves, then EOS.
80
+ # Derived from the tokenizer rather than by arithmetic on ids, because bare-face
81
+ # move tokens share their ids with colour tokens (see cube_tokenizer). Sharing is
82
+ # harmless here -- colours never appear as a target on the answer span -- but the
83
+ # ids are not contiguous, so they have to be looked up.
84
+ MOVE_IDS = [T.STOI[m] for m in T.MOVES]
85
+ ANSWER_IDS = MOVE_IDS + [T.EOS]
86
+ EOS_SLOT = len(MOVE_IDS) # index of EOS inside a 19-wide target vector
87
+ PROMPT_LEN = 1 + T.STATE_LEN + 1 # [BOS] + 54 facelets + [SEP]
88
+
89
+ assert len(set(ANSWER_IDS)) == len(ANSWER_IDS), "answer token ids must be distinct"
90
+ assert T.MOVES == list(C.MOVES), "tokenizer and cube_ops must agree on move order"
91
+
92
+
93
+ # --------------------------------------------------------------------------
94
+ # teacher
95
+ # --------------------------------------------------------------------------
96
+
97
+ def load_teacher(path, device):
98
+ """Loads either value-model flavour through search.load_value_model.
99
+
100
+ That function is the one place that tells a scalar head from a 27-way
101
+ distribution head, and reading a scalar head as a distribution silently
102
+ returns a constant. Do not bypass it (recorded trap in CLAUDE.md).
103
+ """
104
+ from search import load_value_model
105
+
106
+ net = load_value_model(path, device)
107
+ net.eval()
108
+ for q in net.parameters():
109
+ q.requires_grad_(False)
110
+ return net
111
+
112
+
113
+ @torch.no_grad()
114
+ def teacher_values(net, states: np.ndarray, device, batch_size=8192, amp=True):
115
+ """Estimated distance-to-solved for each state in an (N, 54) array."""
116
+ kind = getattr(net, "value_kind", "scalar")
117
+ out = np.empty(len(states), dtype=np.float32)
118
+ for i in range(0, len(states), batch_size):
119
+ chunk = states[i:i + batch_size]
120
+ ids = encode_batch(chunk, device)
121
+ with torch.autocast("cuda", dtype=torch.bfloat16,
122
+ enabled=amp and device == "cuda"):
123
+ raw = net(ids).float()
124
+ if kind == "scalar":
125
+ v = raw.clamp(min=0.0)
126
+ else:
127
+ probs = torch.softmax(raw, dim=-1)
128
+ grid = torch.arange(probs.shape[-1], device=probs.device, dtype=torch.float32)
129
+ v = (probs * grid).sum(-1)
130
+ out[i:i + len(chunk)] = v.cpu().numpy()
131
+ # A solved state is worth 0 by definition, never by prediction.
132
+ out[C.is_solved(states)] = 0.0
133
+ return out
134
+
135
+
136
+ def child_costs(net, states: np.ndarray, device, amp=True):
137
+ """(N, 54) -> (N, 18) cost `1 + J(child)` for every move, and the children."""
138
+ kids = C.all_children(states)
139
+ flat = kids.reshape(-1, 54)
140
+ v = teacher_values(net, flat, device, amp=amp).reshape(len(states), C.NUM_MOVES)
141
+ # A move onto solved costs exactly 1, whatever the net thinks of that state.
142
+ solved_kid = C.is_solved(flat).reshape(len(states), C.NUM_MOVES)
143
+ return np.where(solved_kid, 1.0, 1.0 + v).astype(np.float32), kids
144
+
145
+
146
+ # --------------------------------------------------------------------------
147
+ # rollouts -> training batch
148
+ # --------------------------------------------------------------------------
149
+
150
+ def rollout_batch(net, states0: np.ndarray, device, rollout_len, tau,
151
+ explore_frac, rng, hard=False, amp=True):
152
+ """Rolls the teacher out and returns one padded training batch.
153
+
154
+ Position t of the answer span sits at input index `PROMPT_LEN - 1 + t` and
155
+ predicts the move to make from state s_t. Its target is the teacher's
156
+ preference over the 18 children of s_t -- or all mass on EOS when s_t is
157
+ already solved.
158
+
159
+ Trajectories that never reach solved are kept, not discarded. Dropping them
160
+ would bias the whole dataset toward states the teacher can already descend
161
+ greedily, which are exactly the shallow ones, and would rebuild the reach
162
+ wall out of the training distribution. Every position is correct
163
+ supervision on its own -- "here is the best move from here" -- whether or not
164
+ the trajectory that produced it ever terminated. A truncated trajectory
165
+ simply carries no EOS target, which teaches nothing false.
166
+ """
167
+ b = len(states0)
168
+ cap = rollout_len + 1 # +1 for the position that emits EOS
169
+ tgt = np.zeros((b, cap, len(ANSWER_IDS)), dtype=np.float32)
170
+ seen = np.zeros((b, cap, T.STATE_LEN), dtype=np.uint8)
171
+ moves = np.full((b, cap), -1, dtype=np.int64)
172
+ npos = np.zeros(b, dtype=np.int64)
173
+
174
+ cur = states0.copy()
175
+ alive = np.ones(b, dtype=bool)
176
+
177
+ for t in range(cap):
178
+ idx = np.where(alive)[0]
179
+ if not len(idx):
180
+ break
181
+ seen[idx, t] = cur[idx]
182
+ npos[idx] = t + 1
183
+
184
+ solved_now = C.is_solved(cur[idx])
185
+ stop = idx[solved_now]
186
+ tgt[stop, t, EOS_SLOT] = 1.0
187
+ alive[stop] = False
188
+
189
+ go = idx[~solved_now]
190
+ if not len(go):
191
+ continue
192
+ if t == cap - 1:
193
+ # No room left to emit a move from here, so this position carries no
194
+ # target and must not be trained on.
195
+ npos[go] = t
196
+ alive[go] = False
197
+ continue
198
+
199
+ cost, kids = child_costs(net, cur[go], device, amp=amp)
200
+ best = cost.argmin(axis=1)
201
+ if hard:
202
+ tgt[go, t, best] = 1.0
203
+ else:
204
+ p = torch.softmax(torch.from_numpy(-cost / tau), dim=1).numpy()
205
+ tgt[go, t, :C.NUM_MOVES] = p
206
+
207
+ chosen = best
208
+ if explore_frac > 0:
209
+ take = rng.random(len(go)) < explore_frac
210
+ if take.any():
211
+ w = torch.softmax(torch.from_numpy(-cost[take] / tau), dim=1).numpy()
212
+ u = rng.random((int(take.sum()), 1))
213
+ chosen = chosen.copy()
214
+ chosen[take] = (w.cumsum(axis=1) < u).sum(axis=1).clip(0, C.NUM_MOVES - 1)
215
+
216
+ moves[go, t] = chosen
217
+ cur[go] = kids[np.arange(len(go)), chosen]
218
+
219
+ return pack(states0, tgt, seen, moves, npos)
220
+
221
+
222
+ def pack(states0, tgt, seen, moves, npos):
223
+ """Pads the rollouts into tensors aligned to the language-model sequence."""
224
+ b = len(states0)
225
+ keep = npos > 0
226
+ length = int(npos.max())
227
+ seq = PROMPT_LEN + max(length - 1, 0)
228
+
229
+ ids = np.full((b, seq), T.PAD, dtype=np.int64)
230
+ ids[:, 0] = T.BOS
231
+ ids[:, 1:1 + T.STATE_LEN] = np.asarray(T.COLOR_IDS, dtype=np.int64)[states0.astype(np.int64)]
232
+ ids[:, T.STATE_LEN + 1] = T.SEP
233
+
234
+ attn = np.zeros((b, seq), dtype=np.int64)
235
+ posmask = np.zeros((b, seq), dtype=bool)
236
+ # float16 / uint8 rather than float32 / int64: a packed batch is kept in a
237
+ # replay buffer, and the int64 facelets alone would be 9MB a batch.
238
+ soft = np.zeros((b, seq, tgt.shape[2]), dtype=np.float16)
239
+ faces = np.zeros((b, seq, T.STATE_LEN), dtype=np.uint8)
240
+ order = np.zeros((b, seq), dtype=np.int16)
241
+
242
+ for i in range(b):
243
+ n = int(npos[i])
244
+ if n <= 0:
245
+ continue
246
+ # The prefix that reaches position t is the prompt plus moves 1..t, so a
247
+ # trajectory with n scored positions contributes n-1 move tokens.
248
+ mv = moves[i, : n - 1]
249
+ ids[i, PROMPT_LEN:PROMPT_LEN + len(mv)] = np.asarray(MOVE_IDS, dtype=np.int64)[mv]
250
+ attn[i, : PROMPT_LEN + len(mv)] = 1
251
+ lo = PROMPT_LEN - 1
252
+ posmask[i, lo:lo + n] = True
253
+ soft[i, lo:lo + n] = tgt[i, :n]
254
+ faces[i, lo:lo + n] = seen[i, :n]
255
+ order[i, lo:lo + n] = np.arange(n)
256
+
257
+ return {
258
+ "ids": torch.from_numpy(ids),
259
+ "attn": torch.from_numpy(attn),
260
+ "posmask": torch.from_numpy(posmask),
261
+ "soft": torch.from_numpy(soft),
262
+ "faces": torch.from_numpy(faces),
263
+ "order": torch.from_numpy(order),
264
+ "solved": torch.from_numpy(keep & (tgt[:, :, EOS_SLOT].sum(axis=1) > 0)),
265
+ }
266
+
267
+
268
+ # --------------------------------------------------------------------------
269
+ # model
270
+ # --------------------------------------------------------------------------
271
+
272
+ def build_policy(args):
273
+ from transformers import LlamaConfig, LlamaForCausalLM
274
+
275
+ cfg = LlamaConfig(
276
+ vocab_size=T.VOCAB_SIZE,
277
+ hidden_size=args.hidden,
278
+ intermediate_size=args.hidden * 4,
279
+ num_hidden_layers=args.layers,
280
+ num_attention_heads=args.heads,
281
+ num_key_value_heads=args.heads,
282
+ max_position_embeddings=T.MAX_SEQ,
283
+ rms_norm_eps=1e-5,
284
+ pad_token_id=T.PAD,
285
+ bos_token_id=T.BOS,
286
+ eos_token_id=T.EOS,
287
+ tie_word_embeddings=True,
288
+ )
289
+ return LlamaForCausalLM(cfg)
290
+
291
+
292
+ class StateHead(torch.nn.Module):
293
+ """Predicts the 54 facelets of the state the decoder is currently in.
294
+
295
+ One linear layer on the hidden state, deliberately: a deep head could learn
296
+ to reconstruct the cube itself and let the trunk off the hook, which would
297
+ make the diagnostic meaningless. The question is whether the trunk carries
298
+ the cube, so the probe must be weak.
299
+ """
300
+
301
+ def __init__(self, hidden):
302
+ super().__init__()
303
+ self.proj = torch.nn.Linear(hidden, T.STATE_LEN * len(C.COLORS))
304
+
305
+ def forward(self, h):
306
+ return self.proj(h).view(*h.shape[:-1], T.STATE_LEN, len(C.COLORS))
307
+
308
+
309
+ def losses(model, head, batch, device, state_weight, amp_dtype, use_amp):
310
+ ids = batch["ids"].to(device)
311
+ attn = batch["attn"].to(device)
312
+ posmask = batch["posmask"].to(device)
313
+ soft = batch["soft"].to(device).float()
314
+ faces = batch["faces"].to(device).long()
315
+
316
+ with torch.autocast("cuda", dtype=amp_dtype, enabled=use_amp):
317
+ hidden = model.model(input_ids=ids, attention_mask=attn).last_hidden_state
318
+ logits = model.lm_head(hidden)
319
+ face_logits = head(hidden)
320
+
321
+ # Cross-entropy over the full vocabulary against a soft target that is zero
322
+ # off the answer tokens. Normalising over the whole vocabulary rather than
323
+ # over the 19 answer tokens keeps the remaining logits pushed down, so greedy
324
+ # decoding cannot wander into a colour token.
325
+ logp = torch.log_softmax(logits.float(), dim=-1)[..., ANSWER_IDS]
326
+ move_loss = -(soft * logp).sum(-1)[posmask].mean()
327
+
328
+ sel = face_logits.float()[posmask]
329
+ face_loss = F.cross_entropy(sel.reshape(-1, len(C.COLORS)), faces[posmask].reshape(-1))
330
+ with torch.no_grad():
331
+ face_acc = (sel.argmax(-1) == faces[posmask]).float().mean()
332
+ return move_loss + state_weight * face_loss, move_loss, face_loss, face_acc
333
+
334
+
335
+ # --------------------------------------------------------------------------
336
+ # evaluation
337
+ # --------------------------------------------------------------------------
338
+
339
+ def _walk_exact(n, depth, rng):
340
+ """n states, each from a walk of exactly `depth` moves off solved."""
341
+ states = np.repeat(C.SOLVED_ARR[None, :], n, axis=0)
342
+ prev = np.full(n, -1)
343
+ for _ in range(depth):
344
+ mv = rng.integers(0, C.NUM_MOVES, size=n)
345
+ clash = (mv // 3) == prev
346
+ while clash.any():
347
+ mv[clash] = rng.integers(0, C.NUM_MOVES, size=int(clash.sum()))
348
+ clash = (mv // 3) == prev
349
+ states = states[np.arange(n)[:, None], C.PERMS[mv]]
350
+ prev = mv // 3
351
+ return states
352
+
353
+
354
+ @torch.no_grad()
355
+ def teacher_greedy(net, device, depths, n, rng, max_steps=30, amp=True):
356
+ """The ceiling: descend to the lowest-cost child with the engine supplying
357
+ the state at every step."""
358
+ out = {}
359
+ for d in depths:
360
+ states = _walk_exact(n, d, rng)
361
+ done = C.is_solved(states)
362
+ for _ in range(max_steps):
363
+ live = np.where(~done)[0]
364
+ if not len(live):
365
+ break
366
+ cost, kids = child_costs(net, states[live], device, amp=amp)
367
+ best = cost.argmin(axis=1)
368
+ states[live] = kids[np.arange(len(live)), best]
369
+ done[live] = C.is_solved(states[live])
370
+ out[d] = float(done.mean())
371
+ return out
372
+
373
+
374
+ @torch.no_grad()
375
+ def student_greedy(model, device, depths, n, rng, max_new=T.MAX_SOLUTION + 1,
376
+ batch_size=256):
377
+ """The capability: one prompt, one greedy sequence, replayed by the engine.
378
+
379
+ Greedy is the honest number for a policy, unlike for the value model, where
380
+ greedy is a training signal and search is the system. Here the sequence is
381
+ the whole answer, and it is what every LLM on the board is scored on.
382
+ """
383
+ model.eval()
384
+ out = {}
385
+ for d in depths:
386
+ states = _walk_exact(n, d, rng)
387
+ solved = 0
388
+ for start in range(0, n, batch_size):
389
+ chunk = states[start:start + batch_size]
390
+ cur = chunk.copy()
391
+ ids = encode_batch(chunk, device)
392
+ # Decoding stops at EOS and only at EOS, then the whole emitted
393
+ # sequence is replayed. Stopping early because the cube happens to be
394
+ # solved mid-sequence would score a model the harness would mark
395
+ # wrong: the harness applies every move the model wrote, and moves
396
+ # after a solve unsolve it.
397
+ live = np.ones(len(chunk), dtype=bool)
398
+ for _ in range(max_new):
399
+ logits = model(input_ids=ids).logits[:, -1, :]
400
+ nxt = logits[:, ANSWER_IDS].argmax(-1).cpu().numpy()
401
+ live = live & (nxt != EOS_SLOT)
402
+ step = np.where(live)[0]
403
+ if not len(step):
404
+ break
405
+ cur[step] = C.apply_moves(cur[step], nxt[step])
406
+ tok = torch.full((len(chunk), 1), T.PAD, dtype=torch.long, device=device)
407
+ tok[step, 0] = torch.tensor(
408
+ [MOVE_IDS[m] for m in nxt[step]], dtype=torch.long, device=device)
409
+ ids = torch.cat([ids, tok], dim=1)
410
+ if ids.shape[1] >= T.MAX_SEQ:
411
+ break
412
+ solved += int(C.is_solved(cur).sum())
413
+ out[d] = solved / max(n, 1)
414
+ model.train()
415
+ return out
416
+
417
+
418
+ @torch.no_grad()
419
+ def state_accuracy(model, head, net, device, depths, n, rng, rollout_len, tau, amp):
420
+ """Facelet accuracy of the auxiliary probe, bucketed by output position.
421
+
422
+ The direct test of suspect 2. Teacher-forced, so the prefix is a real
423
+ trajectory and the true state at every position is known exactly.
424
+ """
425
+ model.eval()
426
+ head.eval()
427
+ bins = {"0": [0, 0], "1-4": [0, 0], "5-8": [0, 0], "9-12": [0, 0], "13+": [0, 0]}
428
+ exact = [0, 0]
429
+ for d in depths:
430
+ batch = rollout_batch(net, _walk_exact(n, d, rng), device, rollout_len, tau,
431
+ 0.0, rng, amp=amp)
432
+ hidden = model.model(input_ids=batch["ids"].to(device),
433
+ attention_mask=batch["attn"].to(device)).last_hidden_state
434
+ pred = head(hidden.float()).argmax(-1).cpu().to(torch.uint8)
435
+ ok = (pred == batch["faces"])
436
+ mask = batch["posmask"]
437
+ order = batch["order"]
438
+ for key, lo, hi in (("0", 0, 0), ("1-4", 1, 4), ("5-8", 5, 8),
439
+ ("9-12", 9, 12), ("13+", 13, 10**6)):
440
+ sel = mask & (order >= lo) & (order <= hi)
441
+ if sel.any():
442
+ bins[key][0] += int(ok[sel].sum())
443
+ bins[key][1] += int(sel.sum()) * T.STATE_LEN
444
+ exact[0] += int(ok.all(-1)[mask].sum())
445
+ exact[1] += int(mask.sum())
446
+ model.train()
447
+ head.train()
448
+ per_pos = " ".join(f"{k}:{v[0]/v[1]:.1%}" for k, v in bins.items() if v[1])
449
+ return per_pos, exact[0] / max(exact[1], 1)
450
+
451
+
452
+ # --------------------------------------------------------------------------
453
+
454
+ def main():
455
+ p = argparse.ArgumentParser(description=__doc__,
456
+ formatter_class=argparse.RawDescriptionHelpFormatter)
457
+ p.add_argument("--teacher", required=True,
458
+ help="Value-model checkpoint (.pt) from train_value_iteration.py.")
459
+ p.add_argument("--hidden", type=int, default=512)
460
+ p.add_argument("--layers", type=int, default=10)
461
+ p.add_argument("--heads", type=int, default=8)
462
+ p.add_argument("--batch-size", type=int, default=128,
463
+ help="Start states per step. Each contributes up to "
464
+ "--rollout-len scored positions, and costs 18 teacher "
465
+ "forward passes per position.")
466
+ p.add_argument("--rollout-len", type=int, default=16,
467
+ help="Cap on trajectory length. Must stay at or under 25, which "
468
+ "is where the sequence reaches cube_tokenizer.MAX_SEQ.")
469
+ p.add_argument("--scramble-depth", type=int, default=12,
470
+ help="States come from walks of 1..this many moves. Deliberately "
471
+ "NOT the value trainer's 30: the teacher's predicted distance "
472
+ "saturates near 12 moves, so past that every child scores the "
473
+ "same and the soft target is noise with a shape. Sampling "
474
+ "there would train the policy on the teacher's ignorance. "
475
+ "Raise it only with a teacher whose frontier reaches further.")
476
+ p.add_argument("--buffer", type=int, default=32,
477
+ help="Packed rollout batches kept for reuse. The rollouts come "
478
+ "from the teacher, not from the student, so the data "
479
+ "distribution does not move as the student learns and reuse "
480
+ "is plain caching rather than off-policy training.")
481
+ p.add_argument("--updates-per-rollout", type=int, default=4,
482
+ help="Gradient steps taken per batch of rollouts generated, "
483
+ "sampled from the buffer. Building a target costs 18 teacher "
484
+ "forward passes per position and a gradient step costs one, "
485
+ "so without reuse the teacher is ~95%% of the run and the "
486
+ "student sees too few steps to converge.")
487
+ p.add_argument("--tau", type=float, default=0.35, metavar="MOVES",
488
+ help="Softmax temperature on child costs, in moves. Costs "
489
+ "differ by whole moves, so tau=1 spreads ~30%% of the mass "
490
+ "onto the best child and the rest onto plainly worse ones; "
491
+ "0.35 keeps ties sharing equally while a one-move-worse "
492
+ "child takes ~5%%. Ties are what this is for, not noise.")
493
+ p.add_argument("--explore-frac", type=float, default=0.2,
494
+ help="Fraction of steps that sample from the target instead of "
495
+ "taking the argmin, to widen the visited states.")
496
+ p.add_argument("--state-weight", type=float, default=1.0,
497
+ help="Weight on the auxiliary facelet loss. 0 disables it, "
498
+ "which is the soft-targets-only arm.")
499
+ p.add_argument("--hard-targets", action="store_true",
500
+ help="One-hot on the teacher's argmin instead of a soft "
501
+ "distribution. The ablation for suspect 1.")
502
+ p.add_argument("--lr", type=float, default=3e-4)
503
+ p.add_argument("--warmup", type=int, default=200)
504
+ p.add_argument("--max-steps", type=int, default=20000)
505
+ p.add_argument("--eval-every", type=int, default=500)
506
+ p.add_argument("--eval-n", type=int, default=100)
507
+ p.add_argument("--eval-depths", default="3,6,10")
508
+ p.add_argument("--ceiling-only", action="store_true",
509
+ help="Measure the teacher's greedy solve rate per depth and "
510
+ "exit, training nothing. This is the upper limit on what "
511
+ "the student can score, so it is worth knowing before a "
512
+ "training run is paid for. Inference only.")
513
+ p.add_argument("--out", default="checkpoints/policy_distill")
514
+ p.add_argument("--hub-repo", default="")
515
+ p.add_argument("--seed", type=int, default=0)
516
+ args = p.parse_args()
517
+
518
+ device = "cuda" if torch.cuda.is_available() else "cpu"
519
+ amp = device == "cuda"
520
+ torch.manual_seed(args.seed)
521
+ rng = np.random.default_rng(args.seed)
522
+
523
+ net = load_teacher(args.teacher, device)
524
+
525
+ if args.ceiling_only:
526
+ depths = [int(d) for d in args.eval_depths.split(",")]
527
+ t = time.time()
528
+ rates = teacher_greedy(net, device, depths, args.eval_n,
529
+ np.random.default_rng(12345), amp=amp)
530
+ print(f"teacher greedy, n={args.eval_n} per depth, {time.time()-t:.0f}s")
531
+ for d in depths:
532
+ print(f" depth {d:>3}: {rates[d]:.0%}")
533
+ print(json.dumps({"teacher_greedy": rates, "n": args.eval_n}))
534
+ return
535
+
536
+ model = build_policy(args).to(device)
537
+ head = StateHead(args.hidden).to(device)
538
+ n_params = sum(q.numel() for q in model.parameters())
539
+ print(f"policy {n_params/1e6:.1f}M params | teacher {args.teacher} "
540
+ f"({getattr(net, 'value_kind', '?')}) | {device}", flush=True)
541
+ print(f"targets {'hard argmin' if args.hard_targets else f'soft tau={args.tau}'} | "
542
+ f"state weight {args.state_weight} | batch {args.batch_size} x "
543
+ f"rollout {args.rollout_len}", flush=True)
544
+
545
+ amp_dtype = torch.bfloat16
546
+ if amp and not torch.cuda.is_bf16_supported():
547
+ amp_dtype = torch.float16
548
+ print("bf16 unsupported on this GPU; using fp16", flush=True)
549
+
550
+ opt = torch.optim.AdamW(list(model.parameters()) + list(head.parameters()),
551
+ lr=args.lr, weight_decay=0.1, betas=(0.9, 0.95))
552
+ sched = torch.optim.lr_scheduler.LambdaLR(
553
+ opt, lambda s: min((s + 1) / max(args.warmup, 1), 1.0)
554
+ * 0.5 * (1 + math.cos(math.pi * min(s / max(args.max_steps, 1), 1.0))))
555
+
556
+ depths = [int(d) for d in args.eval_depths.split(",")]
557
+ out_dir = Path(args.out)
558
+ out_dir.mkdir(parents=True, exist_ok=True)
559
+
560
+ # The ceiling, measured once before training. Everything the student does is
561
+ # read against this, so it is printed first and stored with the history.
562
+ ceiling = teacher_greedy(net, device, depths, args.eval_n,
563
+ np.random.default_rng(12345), amp=amp)
564
+ print("teacher greedy (the ceiling): " +
565
+ " ".join(f"d{d}:{r:.0%}" for d, r in ceiling.items()), flush=True)
566
+
567
+ api = None
568
+ if args.hub_repo:
569
+ from huggingface_hub import HfApi
570
+ api = HfApi(token=os.environ.get("HF_TOKEN"))
571
+
572
+ run, best, t0 = [], -1.0, time.time()
573
+ buffer, solved_frac = [], 0.0
574
+ for step in range(1, args.max_steps + 1):
575
+ if step % args.updates_per_rollout == 1 or args.updates_per_rollout == 1:
576
+ states0, _ = C.random_walk(args.batch_size, args.scramble_depth, rng)
577
+ fresh = rollout_batch(net, states0, device, args.rollout_len, args.tau,
578
+ args.explore_frac, rng, hard=args.hard_targets,
579
+ amp=amp)
580
+ solved_frac = float(fresh["solved"].float().mean())
581
+ buffer.append(fresh)
582
+ if len(buffer) > args.buffer:
583
+ buffer.pop(0)
584
+ batch = buffer[rng.integers(len(buffer))]
585
+ loss, mloss, floss, facc = losses(model, head, batch, device,
586
+ args.state_weight, amp_dtype, amp)
587
+ opt.zero_grad(set_to_none=True)
588
+ loss.backward()
589
+ torch.nn.utils.clip_grad_norm_(
590
+ list(model.parameters()) + list(head.parameters()), 1.0)
591
+ opt.step()
592
+ sched.step()
593
+
594
+ if step % 50 == 0:
595
+ print(f"step {step}/{args.max_steps} move {mloss.item():.4f} "
596
+ f"face {floss.item():.4f} face_acc {facc.item():.1%} "
597
+ f"solved_rollouts {solved_frac:.0%} "
598
+ f"{step/(time.time()-t0):.2f} it/s", flush=True)
599
+
600
+ if step % args.eval_every == 0 or step == args.max_steps:
601
+ ev = np.random.default_rng(12345)
602
+ rates = student_greedy(model, device, depths, args.eval_n, ev)
603
+ ev = np.random.default_rng(999)
604
+ per_pos, exact = state_accuracy(model, head, net, device, depths,
605
+ max(args.eval_n // 4, 8), ev,
606
+ args.rollout_len, args.tau, amp)
607
+ mean = float(np.mean(list(rates.values())))
608
+ gap = {d: ceiling[d] - rates[d] for d in depths}
609
+ print(f" step {step} student greedy " +
610
+ " ".join(f"d{d}:{r:.0%}" for d, r in rates.items()) +
611
+ f" | mean {mean:.1%}", flush=True)
612
+ print(f" gap to teacher " +
613
+ " ".join(f"d{d}:{g:+.0%}" for d, g in gap.items()), flush=True)
614
+ print(f" state accuracy by position {per_pos} "
615
+ f"| whole cube exact {exact:.1%}", flush=True)
616
+ run.append({"step": step, "student": rates, "teacher": ceiling,
617
+ "gap": gap, "mean": mean, "state_by_pos": per_pos,
618
+ "state_exact": exact})
619
+ (out_dir / "history.json").write_text(json.dumps(run, indent=2))
620
+ # Latest always, best additionally -- a saturating metric must never
621
+ # be the only thing that chooses a checkpoint (CLAUDE.md).
622
+ model.save_pretrained(str(out_dir))
623
+ torch.save({"head": head.state_dict(), "hidden": args.hidden},
624
+ out_dir / "state_head.pt")
625
+ if mean >= best:
626
+ best = mean
627
+ model.save_pretrained(f"{args.out}-best")
628
+ torch.save({"head": head.state_dict(), "hidden": args.hidden},
629
+ Path(f"{args.out}-best") / "state_head.pt")
630
+ if api is not None:
631
+ try:
632
+ # Keyed on the output directory, so arms run back to
633
+ # back on one box do not overwrite each other's weights.
634
+ api.upload_folder(repo_id=args.hub_repo,
635
+ folder_path=f"{args.out}-best",
636
+ path_in_repo=f"checkpoints/{out_dir.name}")
637
+ except Exception as e:
638
+ print(f" upload failed: {type(e).__name__}: {e}", flush=True)
639
+
640
+ print(f"done. best mean student greedy {best:.1%}. weights in {out_dir}", flush=True)
641
+
642
+
643
+ if __name__ == "__main__":
644
+ main()
code/vast_bootstrap.sh CHANGED
@@ -49,7 +49,7 @@ echo "building venv from $PYBIN ($($PYBIN -V 2>&1))"
49
  # because a failed C-extension build falls back to an upstream-git rebuild --
50
  # observed on a run that then never imported the module at all.
51
  NEEDS_SOLVER=1
52
- if [ -n "${EVAL_VALUE_ONLY:-}${TRAIN_VALUE_ITER:-}" ]; then NEEDS_SOLVER=""; fi
53
 
54
  # kociemba's setup.py predates modern setuptools' removal of install_layout.
55
  /opt/venv/bin/pip install -q "setuptools<60" wheel
@@ -97,6 +97,93 @@ if [ -n "${EVAL_VALUE_ONLY:-}" ]; then
97
  exit 0
98
  fi
99
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  # Value iteration needs no dataset and no solver: it derives its own targets by
101
  # bootstrapping from the solved state. So this mode skips both the kociemba gate
102
  # and the multi-million-pair generation that dominates every other run's startup
 
49
  # because a failed C-extension build falls back to an upstream-git rebuild --
50
  # observed on a run that then never imported the module at all.
51
  NEEDS_SOLVER=1
52
+ if [ -n "${EVAL_VALUE_ONLY:-}${TRAIN_VALUE_ITER:-}${TRAIN_POLICY_DISTILL:-}" ]; then NEEDS_SOLVER=""; fi
53
 
54
  # kociemba's setup.py predates modern setuptools' removal of install_layout.
55
  /opt/venv/bin/pip install -q "setuptools<60" wheel
 
97
  exit 0
98
  fi
99
 
100
+ # Policy distillation from a trained value model. Needs no dataset and no solver:
101
+ # the targets are the teacher's scores for the 18 children of each visited state,
102
+ # and the auxiliary facelet targets come from applying moves. So, like value
103
+ # iteration, this goes straight from a GPU check to training.
104
+ #
105
+ # DISTILL_ARMS runs several arms back to back on one box, which is the point of
106
+ # the experiment rather than a convenience -- the arms differ only in the loss, so
107
+ # comparing them across two rentals would add a hardware variable to a difference
108
+ # of a few points:
109
+ # full soft targets + the state-tracking head (the whole proposal)
110
+ # soft soft targets only -- isolates what the head contributes
111
+ # hard teacher argmin, no soft mass -- isolates soft-vs-hard from the teacher
112
+ if [ -n "${TRAIN_POLICY_DISTILL:-}" ]; then
113
+ cd "${CODE_DIR:-/workspace/code}"
114
+ mkdir -p checkpoints
115
+ echo "=== GPU check ==="
116
+ POLICY_HIDDEN="${HIDDEN:-512}" POLICY_LAYERS="${LAYERS:-10}" POLICY_BATCH="${BATCH:-256}" \
117
+ /opt/venv/bin/python - <<'PYPD'
118
+ import os, sys, torch
119
+ if not torch.cuda.is_available():
120
+ sys.exit("ABORT: no CUDA device")
121
+ h, l, b = (int(os.environ[k]) for k in ("POLICY_HIDDEN", "POLICY_LAYERS", "POLICY_BATCH"))
122
+ vram = torch.cuda.get_device_properties(0).total_memory / 1e9
123
+ print(f"gpu: {torch.cuda.get_device_name(0)} "
124
+ f"sm_{''.join(map(str, torch.cuda.get_device_capability(0)))} {vram:.0f}GB "
125
+ f"| torch {torch.__version__}")
126
+ # The real config at the real batch, for the reason recorded in the main block: a
127
+ # scaled-down probe passes on a GPU that cannot hold the model, so the failure
128
+ # surfaces only once the run has been paid for.
129
+ try:
130
+ from transformers import LlamaConfig, LlamaForCausalLM
131
+ m = LlamaForCausalLM(LlamaConfig(
132
+ vocab_size=28, hidden_size=h, intermediate_size=h * 4, num_hidden_layers=l,
133
+ num_attention_heads=8, num_key_value_heads=8, max_position_embeddings=83,
134
+ tie_word_embeddings=True)).cuda()
135
+ opt = torch.optim.AdamW(m.parameters(), lr=1e-4)
136
+ ids = torch.randint(0, 28, (b, 83), device="cuda")
137
+ with torch.autocast("cuda", dtype=torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16):
138
+ m(input_ids=ids, labels=ids).loss.backward()
139
+ opt.step()
140
+ torch.cuda.synchronize()
141
+ print(f"gpu smoke test: full step at batch {b} OK, "
142
+ f"peak {torch.cuda.max_memory_allocated()/1e9:.1f}GB of {vram:.0f}GB")
143
+ except torch.OutOfMemoryError:
144
+ sys.exit(f"ABORT: batch {b} of a {l}-layer/{h}-wide model does not fit in {vram:.0f}GB")
145
+ except Exception as e:
146
+ sys.exit(f"ABORT: GPU cannot run the model ({type(e).__name__}: {e})")
147
+ PYPD
148
+
149
+ echo "=== fetching teacher ${TEACHER_REPO:-briscoooe/tiny-cube-value-iter} ==="
150
+ TEACHER="$(TR="${TEACHER_REPO:-briscoooe/tiny-cube-value-iter}" \
151
+ TF="${TEACHER_FILE:-checkpoints/value_iter/value_iter_best.pt}" \
152
+ /opt/venv/bin/python -c 'import os; from huggingface_hub import hf_hub_download; print(hf_hub_download(os.environ["TR"], os.environ["TF"], token=os.environ.get("HF_TOKEN")))')"
153
+ echo "teacher: $TEACHER"
154
+
155
+ # The highest score any arm can reach, printed before a single gradient step.
156
+ # The student copies the teacher's one-step choice while the teacher descends
157
+ # with the true state supplied by the engine, so the teacher's greedy rate caps
158
+ # the student. Cheap, and it makes an undertrained result impossible to confuse
159
+ # with a saturated one.
160
+ /opt/venv/bin/python train_policy_distill.py --teacher "$TEACHER" --ceiling-only \
161
+ --eval-depths "${EVAL_DEPTHS:-3,6,10}" --eval-n "${EVAL_N:-100}"
162
+
163
+ for ARM in $(echo "${DISTILL_ARMS:-full}" | tr ',' ' '); do
164
+ case "$ARM" in
165
+ full) ARM_FLAGS="--state-weight ${STATE_WEIGHT:-1.0}" ;;
166
+ soft) ARM_FLAGS="--state-weight 0" ;;
167
+ hard) ARM_FLAGS="--state-weight ${STATE_WEIGHT:-1.0} --hard-targets" ;;
168
+ *) echo "unknown arm: $ARM" >&2; exit 1 ;;
169
+ esac
170
+ echo "=== arm $ARM ($ARM_FLAGS) ==="
171
+ /opt/venv/bin/python train_policy_distill.py \
172
+ --teacher "$TEACHER" $ARM_FLAGS \
173
+ --hidden "${HIDDEN:-512}" --layers "${LAYERS:-10}" --heads 8 \
174
+ --batch-size "${BATCH:-256}" --lr "${LR:-3e-4}" --max-steps "${STEPS:-12000}" \
175
+ --rollout-len "${ROLLOUT_LEN:-16}" --scramble-depth "${WALK_DEPTH:-12}" \
176
+ --tau "${TAU:-0.35}" --explore-frac "${EXPLORE_FRAC:-0.2}" \
177
+ --buffer "${BUFFER:-32}" --updates-per-rollout "${UPDATES_PER_ROLLOUT:-4}" \
178
+ --eval-every "${EVAL_EVERY:-1000}" --eval-n "${EVAL_N:-100}" \
179
+ --eval-depths "${EVAL_DEPTHS:-3,6,10}" \
180
+ --out "checkpoints/policy_distill_$ARM" \
181
+ --hub-repo "${HUB_REPO:-}"
182
+ done
183
+ echo "=== done ==="
184
+ exit 0
185
+ fi
186
+
187
  # Value iteration needs no dataset and no solver: it derives its own targets by
188
  # bootstrapping from the solved state. So this mode skips both the kociemba gate
189
  # and the multi-million-pair generation that dominates every other run's startup