viai957 commited on
Commit
4951e76
·
verified ·
1 Parent(s): f5ec01c

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. code/align/assets/tokenizer/goedel-prover-v2-8b/PROVENANCE.md +7 -0
  2. code/align/assets/tokenizer/goedel-prover-v2-8b/special_tokens_map.json +31 -0
  3. code/align/assets/tokenizer/goedel-prover-v2-8b/tokenizer_config.json +241 -0
  4. code/align/model/artifacts/logit_noise_floor.json +20 -0
  5. code/align/model/artifacts/sdpa_baseline.json +17 -0
  6. code/align/model/artifacts/triton_fa2_equivalence.json +25 -0
  7. code/align/rl/envs/__init__.py +35 -0
  8. code/align/rl/envs/lean_multiturn.py +538 -0
  9. code/align/rl/envs/subgoal_repair.py +694 -0
  10. code/align/rl/lean_rl.py +659 -0
  11. code/align/rl/lean_rl_run.py +716 -0
  12. code/align/rl/lean_rl_testing.py +174 -0
  13. code/align/rl/multiturn_source.py +454 -0
  14. code/align/rl/ops.py +99 -0
  15. code/align/rl/rewards.py +98 -0
  16. code/align/rl/stage_config.py +253 -0
  17. code/align/rl/tripwire.py +202 -0
  18. code/align/rl/types.py +84 -0
  19. code/align/rollout/__init__.py +74 -0
  20. code/align/rollout/client.py +234 -0
  21. code/align/rollout/engine.py +674 -0
  22. code/align/rollout/native.py +372 -0
  23. code/align/rollout/paged.py +494 -0
  24. code/align/rollout/protocol.py +227 -0
  25. code/align/rollout/tp_engine.py +169 -0
  26. code/align/tokenizer/__init__.py +85 -0
  27. code/align/tokenizer/_qwen2_unicode.py +875 -0
  28. code/align/tokenizer/bytelevel.py +693 -0
  29. code/align/tokenizer/core.py +615 -0
  30. code/align/train/__init__.py +16 -0
  31. code/align/train/adamw8bit.py +630 -0
  32. code/align/train/ckpt_format.py +124 -0
  33. code/align/train/dpo_loss.py +237 -0
  34. code/align/train/dpo_stage.py +1362 -0
  35. code/align/train/fsdp.py +124 -0
  36. code/align/train/lora_scale.py +110 -0
  37. code/align/train/moe_module.py +691 -0
  38. code/align/train/offload_adamw.py +680 -0
  39. code/align/train/probes.py +627 -0
  40. code/align/train/quant8.py +193 -0
  41. code/align/train/qwen3_stage.py +466 -0
  42. code/align/train/ref_cache.py +834 -0
  43. code/align/train/sft_loss.py +150 -0
  44. code/align/train/sharded_ckpt.py +1285 -0
  45. code/align/train/stage_gates.py +1002 -0
  46. code/align/train/stage_smoke.py +530 -0
  47. code/align/train/train_module.py +283 -0
  48. code/align/train/trainer.py +0 -0
  49. code/align/utils/__init__.py +0 -0
  50. code/align/utils/dist.py +391 -0
code/align/assets/tokenizer/goedel-prover-v2-8b/PROVENANCE.md ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ Goedel-LM/Goedel-Prover-V2-8B tokenizer files (Qwen3 byte-level BPE; Apache-2.0), vendored so
2
+ P2 tokenisation does not need the vLLM venv or the 16 GB model snapshot. Copied byte-for-byte
3
+ from the HF snapshot dfd02e6271a58375dfbf3ece0175277cf6b6a89a. sha256:
4
+ tokenizer.json aeb13307a71acd8fe81861d94ad54ab689df773318809eed3cbe794b4492dae4
5
+ tokenizer_config.json 5d27a191a7ba6f93bcc2a8e78bc633764bbaaee263e9f3d9225e2c8621a5dd72
6
+ special_tokens_map.json 76862e765266b85aa9459767e33cbaf13970f327a0e88d1c65846c2ddd3a1ecd
7
+ Tokenizer only: no config.json, no weights, no vocab.json/merges.txt (redundant with tokenizer.json).
code/align/assets/tokenizer/goedel-prover-v2-8b/special_tokens_map.json ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "additional_special_tokens": [
3
+ "<|im_start|>",
4
+ "<|im_end|>",
5
+ "<|object_ref_start|>",
6
+ "<|object_ref_end|>",
7
+ "<|box_start|>",
8
+ "<|box_end|>",
9
+ "<|quad_start|>",
10
+ "<|quad_end|>",
11
+ "<|vision_start|>",
12
+ "<|vision_end|>",
13
+ "<|vision_pad|>",
14
+ "<|image_pad|>",
15
+ "<|video_pad|>"
16
+ ],
17
+ "eos_token": {
18
+ "content": "<|im_end|>",
19
+ "lstrip": false,
20
+ "normalized": false,
21
+ "rstrip": false,
22
+ "single_word": false
23
+ },
24
+ "pad_token": {
25
+ "content": "<|endoftext|>",
26
+ "lstrip": false,
27
+ "normalized": false,
28
+ "rstrip": false,
29
+ "single_word": false
30
+ }
31
+ }
code/align/assets/tokenizer/goedel-prover-v2-8b/tokenizer_config.json ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "add_bos_token": false,
3
+ "add_prefix_space": false,
4
+ "added_tokens_decoder": {
5
+ "151643": {
6
+ "content": "<|endoftext|>",
7
+ "lstrip": false,
8
+ "normalized": false,
9
+ "rstrip": false,
10
+ "single_word": false,
11
+ "special": true
12
+ },
13
+ "151644": {
14
+ "content": "<|im_start|>",
15
+ "lstrip": false,
16
+ "normalized": false,
17
+ "rstrip": false,
18
+ "single_word": false,
19
+ "special": true
20
+ },
21
+ "151645": {
22
+ "content": "<|im_end|>",
23
+ "lstrip": false,
24
+ "normalized": false,
25
+ "rstrip": false,
26
+ "single_word": false,
27
+ "special": true
28
+ },
29
+ "151646": {
30
+ "content": "<|object_ref_start|>",
31
+ "lstrip": false,
32
+ "normalized": false,
33
+ "rstrip": false,
34
+ "single_word": false,
35
+ "special": true
36
+ },
37
+ "151647": {
38
+ "content": "<|object_ref_end|>",
39
+ "lstrip": false,
40
+ "normalized": false,
41
+ "rstrip": false,
42
+ "single_word": false,
43
+ "special": true
44
+ },
45
+ "151648": {
46
+ "content": "<|box_start|>",
47
+ "lstrip": false,
48
+ "normalized": false,
49
+ "rstrip": false,
50
+ "single_word": false,
51
+ "special": true
52
+ },
53
+ "151649": {
54
+ "content": "<|box_end|>",
55
+ "lstrip": false,
56
+ "normalized": false,
57
+ "rstrip": false,
58
+ "single_word": false,
59
+ "special": true
60
+ },
61
+ "151650": {
62
+ "content": "<|quad_start|>",
63
+ "lstrip": false,
64
+ "normalized": false,
65
+ "rstrip": false,
66
+ "single_word": false,
67
+ "special": true
68
+ },
69
+ "151651": {
70
+ "content": "<|quad_end|>",
71
+ "lstrip": false,
72
+ "normalized": false,
73
+ "rstrip": false,
74
+ "single_word": false,
75
+ "special": true
76
+ },
77
+ "151652": {
78
+ "content": "<|vision_start|>",
79
+ "lstrip": false,
80
+ "normalized": false,
81
+ "rstrip": false,
82
+ "single_word": false,
83
+ "special": true
84
+ },
85
+ "151653": {
86
+ "content": "<|vision_end|>",
87
+ "lstrip": false,
88
+ "normalized": false,
89
+ "rstrip": false,
90
+ "single_word": false,
91
+ "special": true
92
+ },
93
+ "151654": {
94
+ "content": "<|vision_pad|>",
95
+ "lstrip": false,
96
+ "normalized": false,
97
+ "rstrip": false,
98
+ "single_word": false,
99
+ "special": true
100
+ },
101
+ "151655": {
102
+ "content": "<|image_pad|>",
103
+ "lstrip": false,
104
+ "normalized": false,
105
+ "rstrip": false,
106
+ "single_word": false,
107
+ "special": true
108
+ },
109
+ "151656": {
110
+ "content": "<|video_pad|>",
111
+ "lstrip": false,
112
+ "normalized": false,
113
+ "rstrip": false,
114
+ "single_word": false,
115
+ "special": true
116
+ },
117
+ "151657": {
118
+ "content": "<tool_call>",
119
+ "lstrip": false,
120
+ "normalized": false,
121
+ "rstrip": false,
122
+ "single_word": false,
123
+ "special": false
124
+ },
125
+ "151658": {
126
+ "content": "</tool_call>",
127
+ "lstrip": false,
128
+ "normalized": false,
129
+ "rstrip": false,
130
+ "single_word": false,
131
+ "special": false
132
+ },
133
+ "151659": {
134
+ "content": "<|fim_prefix|>",
135
+ "lstrip": false,
136
+ "normalized": false,
137
+ "rstrip": false,
138
+ "single_word": false,
139
+ "special": false
140
+ },
141
+ "151660": {
142
+ "content": "<|fim_middle|>",
143
+ "lstrip": false,
144
+ "normalized": false,
145
+ "rstrip": false,
146
+ "single_word": false,
147
+ "special": false
148
+ },
149
+ "151661": {
150
+ "content": "<|fim_suffix|>",
151
+ "lstrip": false,
152
+ "normalized": false,
153
+ "rstrip": false,
154
+ "single_word": false,
155
+ "special": false
156
+ },
157
+ "151662": {
158
+ "content": "<|fim_pad|>",
159
+ "lstrip": false,
160
+ "normalized": false,
161
+ "rstrip": false,
162
+ "single_word": false,
163
+ "special": false
164
+ },
165
+ "151663": {
166
+ "content": "<|repo_name|>",
167
+ "lstrip": false,
168
+ "normalized": false,
169
+ "rstrip": false,
170
+ "single_word": false,
171
+ "special": false
172
+ },
173
+ "151664": {
174
+ "content": "<|file_sep|>",
175
+ "lstrip": false,
176
+ "normalized": false,
177
+ "rstrip": false,
178
+ "single_word": false,
179
+ "special": false
180
+ },
181
+ "151665": {
182
+ "content": "<tool_response>",
183
+ "lstrip": false,
184
+ "normalized": false,
185
+ "rstrip": false,
186
+ "single_word": false,
187
+ "special": false
188
+ },
189
+ "151666": {
190
+ "content": "</tool_response>",
191
+ "lstrip": false,
192
+ "normalized": false,
193
+ "rstrip": false,
194
+ "single_word": false,
195
+ "special": false
196
+ },
197
+ "151667": {
198
+ "content": "<think>",
199
+ "lstrip": false,
200
+ "normalized": false,
201
+ "rstrip": false,
202
+ "single_word": false,
203
+ "special": false
204
+ },
205
+ "151668": {
206
+ "content": "</think>",
207
+ "lstrip": false,
208
+ "normalized": false,
209
+ "rstrip": false,
210
+ "single_word": false,
211
+ "special": false
212
+ }
213
+ },
214
+ "additional_special_tokens": [
215
+ "<|im_start|>",
216
+ "<|im_end|>",
217
+ "<|object_ref_start|>",
218
+ "<|object_ref_end|>",
219
+ "<|box_start|>",
220
+ "<|box_end|>",
221
+ "<|quad_start|>",
222
+ "<|quad_end|>",
223
+ "<|vision_start|>",
224
+ "<|vision_end|>",
225
+ "<|vision_pad|>",
226
+ "<|image_pad|>",
227
+ "<|video_pad|>"
228
+ ],
229
+ "bos_token": null,
230
+ "chat_template": "{%- if tools %}\n {{- '<|im_start|>system\\n' }}\n {%- if messages[0].role == 'system' %}\n {{- messages[0].content + '\\n\\n' }}\n {%- endif %}\n {{- \"# Tools\\n\\nYou may call one or more functions to assist with the user query.\\n\\nYou are provided with function signatures within <tools></tools> XML tags:\\n<tools>\" }}\n {%- for tool in tools %}\n {{- \"\\n\" }}\n {{- tool | tojson }}\n {%- endfor %}\n {{- \"\\n</tools>\\n\\nFor each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:\\n<tool_call>\\n{\\\"name\\\": <function-name>, \\\"arguments\\\": <args-json-object>}\\n</tool_call><|im_end|>\\n\" }}\n{%- else %}\n {%- if messages[0].role == 'system' %}\n {{- '<|im_start|>system\\n' + messages[0].content + '<|im_end|>\\n' }}\n {%- endif %}\n{%- endif %}\n{%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %}\n{%- for message in messages[::-1] %}\n {%- set index = (messages|length - 1) - loop.index0 %}\n {%- if ns.multi_step_tool and message.role == \"user\" and message.content is string and not(message.content.startswith('<tool_response>') and message.content.endswith('</tool_response>')) %}\n {%- set ns.multi_step_tool = false %}\n {%- set ns.last_query_index = index %}\n {%- endif %}\n{%- endfor %}\n{%- for message in messages %}\n {%- if message.content is string %}\n {%- set content = message.content %}\n {%- else %}\n {%- set content = '' %}\n {%- endif %}\n {%- if (message.role == \"user\") or (message.role == \"system\" and not loop.first) %}\n {{- '<|im_start|>' + message.role + '\\n' + content + '<|im_end|>' + '\\n' }}\n {%- elif message.role == \"assistant\" %}\n {%- set reasoning_content = '' %}\n {%- if message.reasoning_content is string %}\n {%- set reasoning_content = message.reasoning_content %}\n {%- else %}\n {%- if '</think>' in content %}\n {%- set reasoning_content = content.split('</think>')[0].rstrip('\\n').split('<think>')[-1].lstrip('\\n') %}\n {%- set content = content.split('</think>')[-1].lstrip('\\n') %}\n {%- endif %}\n {%- endif %}\n {%- if loop.index0 > ns.last_query_index %}\n {%- if loop.last or (not loop.last and reasoning_content) %}\n {{- '<|im_start|>' + message.role + '\\n<think>\\n' + reasoning_content.strip('\\n') + '\\n</think>\\n\\n' + content.lstrip('\\n') }}\n {%- else %}\n {{- '<|im_start|>' + message.role + '\\n' + content }}\n {%- endif %}\n {%- else %}\n {{- '<|im_start|>' + message.role + '\\n' + content }}\n {%- endif %}\n {%- if message.tool_calls %}\n {%- for tool_call in message.tool_calls %}\n {%- if (loop.first and content) or (not loop.first) %}\n {{- '\\n' }}\n {%- endif %}\n {%- if tool_call.function %}\n {%- set tool_call = tool_call.function %}\n {%- endif %}\n {{- '<tool_call>\\n{\"name\": \"' }}\n {{- tool_call.name }}\n {{- '\", \"arguments\": ' }}\n {%- if tool_call.arguments is string %}\n {{- tool_call.arguments }}\n {%- else %}\n {{- tool_call.arguments | tojson }}\n {%- endif %}\n {{- '}\\n</tool_call>' }}\n {%- endfor %}\n {%- endif %}\n {{- '<|im_end|>\\n' }}\n {%- elif message.role == \"tool\" %}\n {%- if loop.first or (messages[loop.index0 - 1].role != \"tool\") %}\n {{- '<|im_start|>user' }}\n {%- endif %}\n {{- '\\n<tool_response>\\n' }}\n {{- content }}\n {{- '\\n</tool_response>' }}\n {%- if loop.last or (messages[loop.index0 + 1].role != \"tool\") %}\n {{- '<|im_end|>\\n' }}\n {%- endif %}\n {%- endif %}\n{%- endfor %}\n{%- if add_generation_prompt %}\n {{- '<|im_start|>assistant\\n' }}\n {%- if enable_thinking is defined and enable_thinking is false %}\n {{- '<think>\\n\\n</think>\\n\\n' }}\n {%- endif %}\n{%- endif %}",
231
+ "clean_up_tokenization_spaces": false,
232
+ "eos_token": "<|im_end|>",
233
+ "errors": "replace",
234
+ "extra_special_tokens": {},
235
+ "model_max_length": 131072,
236
+ "pad_token": "<|endoftext|>",
237
+ "padding_side": "right",
238
+ "split_special_tokens": false,
239
+ "tokenizer_class": "Qwen2Tokenizer",
240
+ "unk_token": null
241
+ }
code/align/model/artifacts/logit_noise_floor.json ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "2": {
3
+ "max_abs_dlogit": 0.75586,
4
+ "mean_abs_dlogit": 0.015027,
5
+ "argmax_flips": 1,
6
+ "positions": 794
7
+ },
8
+ "4": {
9
+ "max_abs_dlogit": 0.78516,
10
+ "mean_abs_dlogit": 0.01436,
11
+ "argmax_flips": 1,
12
+ "positions": 794
13
+ },
14
+ "8": {
15
+ "max_abs_dlogit": 0.78516,
16
+ "mean_abs_dlogit": 0.014431,
17
+ "argmax_flips": 1,
18
+ "positions": 794
19
+ }
20
+ }
code/align/model/artifacts/sdpa_baseline.json ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "512": {
3
+ "FLASH_ATTENTION": 0.18939885194413364,
4
+ "EFFICIENT_ATTENTION": NaN,
5
+ "MATH": 4.349638050189242
6
+ },
7
+ "1024": {
8
+ "FLASH_ATTENTION": 0.6236543995328248,
9
+ "EFFICIENT_ATTENTION": NaN,
10
+ "MATH": 16.252079099649563
11
+ },
12
+ "2048": {
13
+ "FLASH_ATTENTION": 2.281787298852578,
14
+ "EFFICIENT_ATTENTION": NaN,
15
+ "MATH": 61.56193719943985
16
+ }
17
+ }
code/align/model/artifacts/triton_fa2_equivalence.json ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "environment": {
3
+ "compute_capability": "8.6",
4
+ "device_name": "NVIDIA A10G",
5
+ "torch": "2.11.0+cu128",
6
+ "triton": "3.6.0"
7
+ },
8
+ "kernel_sha256": "5f2cbe2088a7e583895268c59895366e39a4eb79d4dc8b719cbca2e57c2eed4b",
9
+ "schema_version": 1,
10
+ "shapes_covered": [
11
+ "gqa (n_q_heads, n_kv_heads) in [(8,2), (32,8)] plus MHA-degenerate (4,4)",
12
+ "head_dim in [64, 128]",
13
+ "seq in [192, 256, 512, 1000] (block-divisible AND non-divisible)",
14
+ "causal and non-causal",
15
+ "key_padding_mask, right-padded, non-block-aligned",
16
+ "every autotune config individually pinned (fwd + both bwd kernels)",
17
+ "dtype bfloat16 only (fp16/fp32 rejected)"
18
+ ],
19
+ "tolerances": {
20
+ "absolute_floor": 0.02,
21
+ "policy": "max |err| vs fp32 reference <= max(3.0 x torch-SDPA-bf16 error, 2e-2)",
22
+ "slack_over_sdpa": 3.0
23
+ },
24
+ "verified_at": "2026-08-18T13:18:04+00:00"
25
+ }
code/align/rl/envs/__init__.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """RL environments (opt-in; nothing in `align.rl` imports this package).
2
+
3
+ lean_multiturn.py multi-turn Lean prover episodes with Goedel correction feedback,
4
+ multi-turn loss masks and per-turn advantage assignment.
5
+ """
6
+
7
+ from .lean_multiturn import (
8
+ EpisodeRecord,
9
+ EpisodeSpec,
10
+ GenRequest,
11
+ Generation,
12
+ LeanMultiTurnConfig,
13
+ LeanMultiTurnEnv,
14
+ PrefixBreak,
15
+ Problem,
16
+ SandboxNotWired,
17
+ TrainingRow,
18
+ TurnRecord,
19
+ assign_advantages,
20
+ )
21
+
22
+ __all__ = [
23
+ "EpisodeRecord",
24
+ "EpisodeSpec",
25
+ "GenRequest",
26
+ "Generation",
27
+ "LeanMultiTurnConfig",
28
+ "LeanMultiTurnEnv",
29
+ "PrefixBreak",
30
+ "Problem",
31
+ "SandboxNotWired",
32
+ "TrainingRow",
33
+ "TurnRecord",
34
+ "assign_advantages",
35
+ ]
code/align/rl/envs/lean_multiturn.py ADDED
@@ -0,0 +1,538 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Multi-turn Lean prover environment (P4-B): proof attempt -> compiler feedback -> retry.
2
+
3
+ An EPISODE is up to K turns on one formal statement. Turn 0 is the Goedel round-0 prompt.
4
+ If the verifier rejects the proof with compile errors, turn k+1's prompt is the Goedel
5
+ CORRECTION prompt: the history, the failed attempt as an assistant message, and a user
6
+ message carrying the `<error></error>`-annotated compiler output. The episode ends at the
7
+ first verified turn, at K turns, or at the first rejection that feedback cannot repair.
8
+
9
+ Everything below exists to prevent one named failure; each is pinned by a test.
10
+
11
+ * PROMPT DRIFT. The prompts are NOT re-expressed here. `extras/vllm_rollout/lean_prompts.py`
12
+ (byte-faithful to Goedel-Prover-V2) is loaded BY FILE PATH, read-only, the way
13
+ tests/test_lean_gen_contract.py and scripts/p2/p2_common.py do. A second copy of the
14
+ correction template would drift by one newline and silently change the policy sampled.
15
+
16
+ * LOSS ON FEEDBACK TOKENS. The compiler's error text is environment output, not policy
17
+ output. If it carried loss the model would be trained to *write* error messages, and the
18
+ gradient would reward predicting them. Rows carry `response_mask` that is 1 ONLY on tokens
19
+ the generator returned; prompt, chat-template glue and feedback are 0. `turn_ids` labels
20
+ each token with its turn (-1 unlabeled) so tests and the loss can audit it.
21
+
22
+ * RE-TOKENIZATION DRIFT. Token ids are never re-derived from text for generated tokens.
23
+ layout="concatenated" builds ONE row per episode: context_k = context_{k-1} + generated_{k-1}
24
+ + encode(template delta). That is only valid when the chat template renders turn k's
25
+ prompt as a pure extension of (turn k-1's prompt + completion text); templates that rewrite
26
+ earlier assistant content (Qwen3 strips old <think> blocks) break it. We CHECK the prefix
27
+ property and raise `PrefixBreak` rather than train on tokens the policy never saw.
28
+ layout="per_turn" (the default) emits one row per turn from that turn's exact prompt ids;
29
+ it is always exact, at the price of re-running the shared prefix.
30
+
31
+ * STATEMENT REWRITING IS NOT A SOLVE. `problem_check` re-inserts the ORIGINAL statement over
32
+ whatever theorem the model displayed, so a model that states an easier theorem and proves
33
+ that is *accepted* if its tactics happen to close the original. Goedel's evaluation credits
34
+ it; a TRAINING reward must not (it teaches the model to misstate the problem). Before the
35
+ verifier is consulted, `scripts/p2/p2_common.displayed_statement_matches` (loaded by path)
36
+ must hold, else the turn is terminal with reason `statement_rewrite` and reward 0.
37
+
38
+ * TRUNCATION IS NOT A SUBMISSION. A generation that hit the length limit is rejected
39
+ (`truncated`) even if its plan contains an earlier closed code fence; the final answer was
40
+ cut off, and verifying a stale fence rewards an accident.
41
+
42
+ * SPARSE REWARD, NO LENGTH IN THE LOSS. Episode reward = turn_discount ** solved_turn
43
+ (0 if never solved) plus an optional DAPO soft-overlong term on TOTAL generated tokens,
44
+ weighted by `overlong_weight`. The penalty lives in reward composition (reusing
45
+ `align.rl.rewards.soft_overlong_punishment`), never in the loss.
46
+
47
+ * CREDIT ASSIGNMENT. advantage_mode="episode": every generated token gets the Dr.GRPO
48
+ group-centered episode reward (the repo's `compute_group_advantages`, reused).
49
+ advantage_mode="per_turn": turn t gets G_t - mean_group(G_t) with
50
+ G_t = sum_{s>=t} credit_gamma^(s-t) * u_s, u_s the turn's own outcome reward. With
51
+ credit_gamma=0 a failed first attempt gets no credit for the repair that followed (the
52
+ failure mode of episode-level sharing: rewarding the bad first draft); with gamma>0 it
53
+ shares in it, because its error context is what made the repair possible. The baseline for
54
+ turn t averages only episodes that HAVE a turn t.
55
+
56
+ * DEAD VERIFIER != ZERO REWARD. `LeanInfraError` from the verifier propagates unchanged. A
57
+ verifier that is dying SOFTLY (every proof comes back `crash`/`protocol`/`memory`/
58
+ `output_too_large` instead of raising) would otherwise be turned by this env into
59
+ `rejected:crash`, reward 0, a batch of zeros that reads as "the model got worse".
60
+ `LeanReward.accuracy_batch` refuses that; the env bypasses `LeanReward`, so it carries the
61
+ same guard: once at least `min_infra_batch` verifications have run in a `run_group`, a
62
+ cumulative infra-failure share above `max_infra_fraction` raises `LeanInfraError`.
63
+
64
+ * ENGINE FINISH REASONS. The P3 engine reports `eos|stop_token|stop_string|max_new_tokens|
65
+ max_seq_len`, the lean_gen contract reports `stop|length`. Only `length` used to count as
66
+ truncated, so an engine-capped generation (`max_new_tokens`/`max_seq_len`) was VERIFIED as
67
+ if it were a finished answer. Both vocabularies are accepted; an unknown reason raises
68
+ (a new engine reason must be classified by a human, not guessed).
69
+
70
+ * CONTEXT BUDGET. Compiler feedback is unbounded and each repair turn re-sends the whole
71
+ history. With `max_prompt_tokens` set, a repair prompt over the budget ends the episode
72
+ (`context_overflow`, reward 0 for the unsolved episode) instead of being handed to an
73
+ engine that would refuse it (or silently truncate the prompt from the left).
74
+
75
+ * EPISODE KEY COLLISIONS. `generate` adapters key seeds / KV state by `episode_key`; two
76
+ specs with the same (problem_id, sample_idx, statement) would share one stream. Refused.
77
+
78
+ Sandbox: `LeanMultiTurnConfig.sandbox=True` is a pass-through request for the
79
+ `align/verify/lean/sandbox.py` REPL. The pool is not wired to it yet, so the env raises
80
+ `SandboxNotWired` unless the pool itself advertises a truthy `sandbox` attribute.
81
+
82
+ Error positions: the Goedel format needs REPL message dicts (`pos`, `endPos`, `data`)
83
+ indexing `compile_view(code)`. `StrictResult.diagnostics["repl_errors"]` is the contract for
84
+ those; `StrictVerifier` does not populate it yet (it keeps only 3 truncated message strings).
85
+ Absent positions the env degrades per `unpositioned_feedback` ("message_only" renders the
86
+ messages with no code excerpt; "raise" refuses) instead of inventing an `<error>` location.
87
+
88
+ stdlib-only (+ align.rl.rewards / align.rl.advantages); no torch tensors are built here.
89
+ """
90
+
91
+ from __future__ import annotations
92
+
93
+ import hashlib
94
+ import importlib.util
95
+ import json
96
+ from dataclasses import asdict, dataclass, field
97
+ from pathlib import Path
98
+ from typing import Any, Callable, Optional, Sequence
99
+
100
+ from align.rl.advantages import compute_group_advantages
101
+ from align.rl.rewards import soft_overlong_punishment
102
+
103
+ _REPO = Path(__file__).resolve().parents[3]
104
+
105
+ _MODS: dict[str, Any] = {}
106
+
107
+
108
+ def _load_by_path(key: str, rel: str):
109
+ if key not in _MODS:
110
+ spec = importlib.util.spec_from_file_location(key, _REPO / rel)
111
+ mod = importlib.util.module_from_spec(spec)
112
+ spec.loader.exec_module(mod)
113
+ _MODS[key] = mod
114
+ return _MODS[key]
115
+
116
+
117
+ def _lean_prompts():
118
+ return _load_by_path("align_envs_lean_prompts", "extras/vllm_rollout/lean_prompts.py")
119
+
120
+
121
+ def _p2_common():
122
+ return _load_by_path("align_envs_p2_common", "scripts/p2/p2_common.py")
123
+
124
+
125
+ #: finish reasons that mean "the generator ended on its own" / "the generator was cut off"
126
+ STOP_REASONS = frozenset({"stop", "eos", "stop_token", "stop_string"})
127
+ TRUNCATED_REASONS = frozenset({"length", "max_new_tokens", "max_seq_len"})
128
+
129
+
130
+ class SandboxNotWired(RuntimeError):
131
+ """sandbox=True was requested but the verifier pool has no sandboxed REPL."""
132
+
133
+
134
+ class PrefixBreak(RuntimeError):
135
+ """The chat template did not render turn k's prompt as an extension of turn k-1's
136
+ prompt + completion, so one concatenated token row would not be what the policy saw."""
137
+
138
+
139
+ @dataclass(frozen=True)
140
+ class Problem:
141
+ problem_id: str
142
+ formal_statement: str
143
+
144
+
145
+ @dataclass(frozen=True)
146
+ class EpisodeSpec:
147
+ problem: Problem
148
+ sample_idx: int = 0
149
+
150
+
151
+ @dataclass(frozen=True)
152
+ class GenRequest:
153
+ episode_key: str
154
+ turn: int
155
+ prompt_ids: tuple
156
+ prompt_text: str
157
+
158
+
159
+ @dataclass(frozen=True)
160
+ class Generation:
161
+ """`token_ids`: exactly what the engine sampled (incl. a trailing EOS if sampled).
162
+ `text`: the decoded completion WITHOUT the EOS string. `logprobs`: per generated token."""
163
+
164
+ token_ids: tuple
165
+ text: str
166
+ logprobs: Optional[tuple] = None
167
+ finish_reason: str = "stop"
168
+
169
+
170
+ @dataclass(frozen=True)
171
+ class LeanMultiTurnConfig:
172
+ max_turns: int = 2
173
+ layout: str = "per_turn" # "per_turn" | "concatenated"
174
+ turn_discount: float = 1.0
175
+ overlong_max_len: Optional[int] = None
176
+ overlong_cache_len: Optional[int] = None
177
+ overlong_weight: float = 1.0
178
+ advantage_mode: str = "episode" # "episode" | "per_turn"
179
+ credit_gamma: float = 0.0
180
+ retry_reasons: tuple = ("compile_error",)
181
+ reject_truncated: bool = True
182
+ #: ABLATION ONLY: accept a proof whose displayed theorem differs from the statement.
183
+ allow_statement_rewrite: bool = False
184
+ error_thres: bool = True
185
+ unpositioned_feedback: str = "message_only" # "message_only" | "raise"
186
+ sandbox: bool = False
187
+ #: ceiling on the repair prompt length in tokens (None = unbounded, the old behaviour)
188
+ max_prompt_tokens: Optional[int] = None
189
+ #: soft-infra-failure guard (see module docstring); same defaults as LeanReward
190
+ max_infra_fraction: float = 0.5
191
+ min_infra_batch: int = 8
192
+ eos_token_id: Optional[int] = None
193
+ eos_text: str = "<|im_end|>"
194
+
195
+ def __post_init__(self) -> None:
196
+ if self.max_turns < 1:
197
+ raise ValueError(f"max_turns must be >= 1, got {self.max_turns}")
198
+ if self.layout not in ("per_turn", "concatenated"):
199
+ raise ValueError(f"layout must be 'per_turn' or 'concatenated', got {self.layout!r}")
200
+ if not 0.0 < self.turn_discount <= 1.0:
201
+ raise ValueError(f"turn_discount must be in (0, 1], got {self.turn_discount}")
202
+ if self.advantage_mode not in ("episode", "per_turn"):
203
+ raise ValueError(f"advantage_mode must be 'episode' or 'per_turn', got {self.advantage_mode!r}")
204
+ if not 0.0 <= self.credit_gamma <= 1.0:
205
+ raise ValueError(f"credit_gamma must be in [0, 1], got {self.credit_gamma}")
206
+ if self.max_prompt_tokens is not None and self.max_prompt_tokens < 1:
207
+ raise ValueError(f"max_prompt_tokens must be None or >= 1, got {self.max_prompt_tokens}")
208
+ if not 0.0 <= self.max_infra_fraction <= 1.0:
209
+ raise ValueError(f"max_infra_fraction must be in [0, 1], got {self.max_infra_fraction}")
210
+ if self.min_infra_batch < 1:
211
+ raise ValueError(f"min_infra_batch must be >= 1, got {self.min_infra_batch}")
212
+ if self.unpositioned_feedback not in ("message_only", "raise"):
213
+ raise ValueError(f"unpositioned_feedback must be 'message_only' or 'raise'")
214
+ # same construction-time refusals as CompositeReward: a half-armed penalty is silently off
215
+ if self.overlong_max_len is None and self.overlong_cache_len is not None:
216
+ raise ValueError("overlong_cache_len given without overlong_max_len: penalty would be silently OFF")
217
+ if self.overlong_max_len is not None:
218
+ if self.overlong_cache_len is None or not 0 < self.overlong_cache_len < self.overlong_max_len:
219
+ raise ValueError("need 0 < overlong_cache_len < overlong_max_len")
220
+
221
+
222
+ @dataclass
223
+ class TurnRecord:
224
+ turn: int
225
+ prompt_text: str
226
+ prompt_ids: list
227
+ completion_ids: list
228
+ completion_text: str
229
+ finish_reason: str
230
+ lean_code: Optional[str] = None
231
+ code_error: Optional[str] = None
232
+ displayed_statement_ok: Optional[bool] = None
233
+ reasons: tuple = ()
234
+ verified: bool = False
235
+ credited: bool = False
236
+ feedback_text: Optional[str] = None
237
+ feedback_positioned: Optional[bool] = None
238
+
239
+
240
+ @dataclass
241
+ class TrainingRow:
242
+ input_ids: list
243
+ #: 1 ONLY on tokens the generator returned (input space, like RolloutBatch.response_mask)
244
+ response_mask: list
245
+ #: turn index of each generated token, -1 elsewhere
246
+ turn_ids: list
247
+ #: per-token engine logprobs in input space (0.0 where unlabeled); None if not supplied
248
+ logprobs: Optional[list] = None
249
+ #: per-token advantage (0.0 where unlabeled); filled by `assign_advantages`
250
+ advantages: Optional[list] = None
251
+
252
+
253
+ @dataclass
254
+ class EpisodeRecord:
255
+ episode_key: str
256
+ problem_id: str
257
+ sample_idx: int
258
+ turns: list
259
+ rows: list
260
+ terminal: str = ""
261
+ solved_turn: Optional[int] = None
262
+ reward: dict = field(default_factory=dict)
263
+ turn_rewards: list = field(default_factory=list) # u_t, the per-turn outcome rewards
264
+ n_generated: int = 0
265
+
266
+ def to_json(self) -> str:
267
+ return json.dumps(asdict(self), sort_keys=True, ensure_ascii=False)
268
+
269
+
270
+ # ------------------------------------------------------------------------- reward composition
271
+
272
+
273
+ def compose_episode_reward(cfg: LeanMultiTurnConfig, solved_turn: Optional[int], n_turns: int, n_generated: int):
274
+ """(terms, u): itemised episode reward terms and the per-turn outcome rewards u_t.
275
+ The overlong term is attached to the LAST turn's u (the turn that ended the episode)."""
276
+ u = [0.0] * n_turns
277
+ acc = 0.0
278
+ if solved_turn is not None:
279
+ acc = cfg.turn_discount ** solved_turn
280
+ u[solved_turn] = acc
281
+ ov = 0.0
282
+ if cfg.overlong_max_len is not None:
283
+ ov = cfg.overlong_weight * soft_overlong_punishment(n_generated, cfg.overlong_max_len, cfg.overlong_cache_len)
284
+ u[-1] += ov
285
+ terms = {
286
+ "reward/accuracy": 1.0 if solved_turn is not None else 0.0,
287
+ "reward/turn_discount": acc,
288
+ "reward/overlong": ov,
289
+ "reward/total": acc + ov,
290
+ }
291
+ return terms, u
292
+
293
+
294
+ def assign_advantages(episodes: Sequence[EpisodeRecord], cfg: LeanMultiTurnConfig) -> None:
295
+ """Fill `row.advantages` in place. Episodes of one problem_id form a group (input order
296
+ preserved). Groups must be equal-sized in "episode" mode (the shared helper's layout)."""
297
+ groups: dict[str, list] = {}
298
+ for ep in episodes:
299
+ groups.setdefault(ep.problem_id, []).append(ep)
300
+ for eps in groups.values():
301
+ n_turn_max = max(len(e.turns) for e in eps)
302
+ if cfg.advantage_mode == "episode":
303
+ import numpy as np
304
+
305
+ adv = compute_group_advantages(np.array([e.reward["reward/total"] for e in eps]), len(eps), "centered")
306
+ per_turn = [[float(a)] * n_turn_max for a in adv]
307
+ else:
308
+ G = []
309
+ for e in eps:
310
+ g, acc = [0.0] * len(e.turns), 0.0
311
+ for t in range(len(e.turns) - 1, -1, -1):
312
+ acc = e.turn_rewards[t] + cfg.credit_gamma * acc
313
+ g[t] = acc
314
+ G.append(g)
315
+ per_turn = [[0.0] * n_turn_max for _ in eps]
316
+ for t in range(n_turn_max):
317
+ have = [i for i, e in enumerate(eps) if t < len(e.turns)]
318
+ base = sum(G[i][t] for i in have) / len(have)
319
+ for i in have:
320
+ per_turn[i][t] = G[i][t] - base
321
+ for e, adv_t in zip(eps, per_turn):
322
+ for row in e.rows:
323
+ row.advantages = [adv_t[tid] if tid >= 0 else 0.0 for tid in row.turn_ids]
324
+
325
+
326
+ # ------------------------------------------------------------------------- the environment
327
+
328
+
329
+ class _Ep:
330
+ def __init__(self, spec: EpisodeSpec, key: str):
331
+ self.spec, self.key = spec, key
332
+ self.turns: list[TurnRecord] = []
333
+ self.messages: list[dict] = []
334
+ self.prompt_text = ""
335
+ self.prompt_ids: list = []
336
+ self.ctx_ids: list = [] # concatenated layout: running context
337
+ self.gens: list[Generation] = []
338
+ self.done = False
339
+ self.terminal = ""
340
+ self.solved_turn: Optional[int] = None
341
+ self.pending: Optional[tuple] = None
342
+
343
+
344
+ class LeanMultiTurnEnv:
345
+ """`verifier`: anything with `verify(statement, code) -> StrictResult` (and optionally
346
+ `verify_batch(items)`), e.g. `StrictVerifier`. `tokenizer`: `apply_chat_template(messages,
347
+ tokenize=False, add_generation_prompt=True)`. `encode`: text -> ids."""
348
+
349
+ def __init__(self, verifier, tokenizer, encode: Callable[[str], list], cfg: LeanMultiTurnConfig = LeanMultiTurnConfig()):
350
+ if cfg.sandbox and not getattr(getattr(verifier, "pool", verifier), "sandbox", None):
351
+ raise SandboxNotWired(
352
+ "sandbox=True requested but the verifier pool exposes no sandboxed REPL "
353
+ "(align/verify/lean/sandbox.py is not wired into repl_pool yet)"
354
+ )
355
+ self.verifier, self.tok, self.encode, self.cfg = verifier, tokenizer, encode, cfg
356
+
357
+ # ---- public
358
+ def run_episode(self, spec: EpisodeSpec, generate: Callable[[list], list]) -> EpisodeRecord:
359
+ return self.run_group([spec], generate)[0]
360
+
361
+ def run_group(self, specs: Sequence[EpisodeSpec], generate: Callable[[list], list]) -> list:
362
+ """Lockstep over episodes: each round, ONE `generate(list[GenRequest]) -> list[Generation]`
363
+ call for all active episodes, then one batched verification. Output order = input order."""
364
+ eps = [_Ep(s, self._key(s)) for s in specs]
365
+ if len({e.key for e in eps}) != len(eps):
366
+ dup = sorted({e.key for e in eps if sum(x.key == e.key for x in eps) > 1})
367
+ raise ValueError(f"duplicate episode keys {dup}: specs must differ in problem_id/sample_idx/statement")
368
+ n_ver = n_infra = 0
369
+ for ep in eps:
370
+ self._start(ep)
371
+ active = list(eps)
372
+ while active:
373
+ reqs = [GenRequest(e.key, len(e.turns), tuple(e.prompt_ids), e.prompt_text) for e in active]
374
+ gens = generate(reqs)
375
+ if len(gens) != len(active):
376
+ raise ValueError(f"generate returned {len(gens)} generations for {len(active)} requests")
377
+ for e, g in zip(active, gens):
378
+ self._absorb(e, g)
379
+ todo = [e for e in active if not e.done]
380
+ if todo:
381
+ items = [e.pending for e in todo]
382
+ vb = getattr(self.verifier, "verify_batch", None)
383
+ results = vb(items) if vb is not None else [self.verifier.verify(s, c) for s, c in items]
384
+ if len(results) != len(todo):
385
+ raise ValueError(f"verifier returned {len(results)} results for {len(todo)} items")
386
+ n_ver += len(results)
387
+ n_infra += sum(1 for r in results if getattr(r, "infra_failure", False))
388
+ self._check_infra(n_infra, n_ver)
389
+ for e, r in zip(todo, results):
390
+ self._judge(e, r)
391
+ active = [e for e in active if not e.done]
392
+ return [self._finish(e) for e in eps]
393
+
394
+ # ---- internals
395
+ def _check_infra(self, n_infra: int, n_ver: int) -> None:
396
+ cfg = self.cfg
397
+ if n_ver >= cfg.min_infra_batch and n_infra / n_ver > cfg.max_infra_fraction:
398
+ from align.verify.lean.strict import LeanInfraError
399
+
400
+ raise LeanInfraError(
401
+ f"{n_infra}/{n_ver} verifications died for infrastructure reasons: either the pool is "
402
+ f"dead or the policy is emitting REPL-killing proofs; refusing to emit episodes of "
403
+ f"reward 0 that read as 'model got worse'"
404
+ )
405
+
406
+ def _key(self, s: EpisodeSpec) -> str:
407
+ h = hashlib.sha256(f"{s.problem.problem_id}\0{s.sample_idx}\0{s.problem.formal_statement}".encode()).hexdigest()
408
+ return h[:16]
409
+
410
+ def _render(self, messages: list) -> str:
411
+ return self.tok.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
412
+
413
+ def _start(self, ep: _Ep) -> None:
414
+ text, msgs = _lean_prompts().initial_prompt(ep.spec.problem.formal_statement, self.tok)
415
+ ep.messages, ep.prompt_text = list(msgs), text
416
+ ep.prompt_ids = list(self.encode(text))
417
+ ep.ctx_ids = list(ep.prompt_ids)
418
+
419
+ def _absorb(self, ep: _Ep, g: Generation) -> None:
420
+ cfg, lp = self.cfg, _lean_prompts()
421
+ if g.logprobs is not None and len(g.logprobs) != len(g.token_ids):
422
+ raise ValueError(f"{len(g.logprobs)} logprobs for {len(g.token_ids)} generated tokens")
423
+ t = TurnRecord(len(ep.turns), ep.prompt_text, list(ep.prompt_ids), list(g.token_ids), g.text, g.finish_reason)
424
+ ep.turns.append(t)
425
+ ep.gens.append(g)
426
+ stmt = ep.spec.problem.formal_statement
427
+ if g.finish_reason not in STOP_REASONS and g.finish_reason not in TRUNCATED_REASONS:
428
+ raise ValueError(
429
+ f"unknown finish_reason {g.finish_reason!r}; classify it in STOP_REASONS or TRUNCATED_REASONS"
430
+ )
431
+ if cfg.reject_truncated and g.finish_reason in TRUNCATED_REASONS:
432
+ return self._end(ep, "truncated")
433
+ code, err = lp.lean_code_for_record(stmt, g.text)
434
+ t.lean_code, t.code_error = code, err
435
+ if code is None:
436
+ return self._end(ep, f"no_code:{err}")
437
+ if not cfg.allow_statement_rewrite:
438
+ t.displayed_statement_ok = bool(_p2_common().displayed_statement_matches(stmt, g.text))
439
+ if not t.displayed_statement_ok:
440
+ return self._end(ep, "statement_rewrite")
441
+ ep.pending = (stmt, code)
442
+
443
+ def _judge(self, ep: _Ep, r) -> None:
444
+ cfg, t = self.cfg, ep.turns[-1]
445
+ t.reasons, t.verified = tuple(r.reasons), bool(r.accepted)
446
+ if r.accepted:
447
+ t.credited, ep.solved_turn = True, t.turn
448
+ return self._end(ep, "verified")
449
+ if t.turn + 1 >= cfg.max_turns:
450
+ return self._end(ep, "max_turns")
451
+ if not all(x in cfg.retry_reasons for x in t.reasons):
452
+ return self._end(ep, f"rejected:{t.reasons[0] if t.reasons else 'unknown'}")
453
+ fb = self._feedback(ep, r)
454
+ if fb is None:
455
+ return self._end(ep, "no_feedback")
456
+ err_str, positioned = fb
457
+ t.feedback_text, t.feedback_positioned = err_str, positioned
458
+ lp = _lean_prompts()
459
+ text, msgs = lp.DeepSeekCoTHandler().generate_correction_prompt(
460
+ lp.goedel_statement(ep.spec.problem.formal_statement), ep.messages, t.completion_text,
461
+ err_str, self.tok, t.turn + 1,
462
+ )
463
+ if cfg.max_prompt_tokens is not None and len(self.encode(text)) > cfg.max_prompt_tokens:
464
+ return self._end(ep, "context_overflow")
465
+ ep.messages = msgs
466
+ prev_end = ep.prompt_text + t.completion_text
467
+ if cfg.layout == "concatenated":
468
+ if not text.startswith(prev_end):
469
+ raise PrefixBreak(
470
+ f"turn {t.turn + 1} prompt does not extend turn {t.turn} prompt + completion "
471
+ f"(template rewrites earlier assistant content, e.g. Qwen3 <think> stripping); "
472
+ f"use layout='per_turn'"
473
+ )
474
+ delta = text[len(prev_end):]
475
+ gen = ep.gens[-1]
476
+ if (cfg.eos_token_id is not None and gen.token_ids and gen.token_ids[-1] == cfg.eos_token_id
477
+ and delta.startswith(cfg.eos_text)):
478
+ delta = delta[len(cfg.eos_text):] # the sampled EOS is already in the context
479
+ ep.ctx_ids = ep.ctx_ids + list(gen.token_ids) + list(self.encode(delta))
480
+ ep.prompt_ids = list(ep.ctx_ids)
481
+ else:
482
+ ep.prompt_ids = list(self.encode(text))
483
+ ep.prompt_text = text
484
+
485
+ def _feedback(self, ep: _Ep, r) -> Optional[tuple]:
486
+ lp = _lean_prompts()
487
+ diag = r.diagnostics or {}
488
+ t = ep.turns[-1]
489
+ errs = diag.get("repl_errors")
490
+ if errs and all(isinstance(e, dict) and e.get("pos") and "data" in e for e in errs):
491
+ view = diag.get("error_code_view") or lp.compile_view(t.lean_code)
492
+ return lp.get_error_str(view, errs, self.cfg.error_thres), True
493
+ msgs = diag.get("errors")
494
+ if not msgs and not errs:
495
+ return None
496
+ if self.cfg.unpositioned_feedback == "raise":
497
+ raise ValueError("verifier result has no positioned REPL errors (diagnostics['repl_errors'])")
498
+ raw = [e["data"] if isinstance(e, dict) else str(e) for e in (errs or msgs)]
499
+ return "".join(f"\nError {i + 1}:\n\nError Message: {m}\n" for i, m in enumerate(raw)), False
500
+
501
+ def _end(self, ep: _Ep, why: str) -> None:
502
+ ep.done, ep.terminal = True, why
503
+
504
+ def _finish(self, ep: _Ep) -> EpisodeRecord:
505
+ n_gen = sum(len(g.token_ids) for g in ep.gens)
506
+ terms, u = compose_episode_reward(self.cfg, ep.solved_turn, len(ep.turns), n_gen)
507
+ rows = self._rows(ep)
508
+ s = ep.spec
509
+ return EpisodeRecord(ep.key, s.problem.problem_id, s.sample_idx, ep.turns, rows, ep.terminal,
510
+ ep.solved_turn, terms, u, n_gen)
511
+
512
+ def _rows(self, ep: _Ep) -> list:
513
+ if self.cfg.layout == "per_turn":
514
+ out = []
515
+ for t, g in zip(ep.turns, ep.gens):
516
+ n = len(g.token_ids)
517
+ out.append(TrainingRow(
518
+ list(t.prompt_ids) + list(g.token_ids),
519
+ [0] * len(t.prompt_ids) + [1] * n,
520
+ [-1] * len(t.prompt_ids) + [t.turn] * n,
521
+ ([0.0] * len(t.prompt_ids) + list(g.logprobs)) if g.logprobs is not None else None,
522
+ ))
523
+ return out
524
+ ids, mask, tids, lp, have_lp = [], [], [], [], all(g.logprobs is not None for g in ep.gens)
525
+ for t, g in zip(ep.turns, ep.gens):
526
+ n_ctx = len(t.prompt_ids) - len(ids) # new context tokens (prompt / feedback glue)
527
+ ids += list(t.prompt_ids[len(ids):])
528
+ mask += [0] * n_ctx
529
+ tids += [-1] * n_ctx
530
+ lp += [0.0] * n_ctx
531
+ ids += list(g.token_ids)
532
+ mask += [1] * len(g.token_ids)
533
+ tids += [t.turn] * len(g.token_ids)
534
+ if have_lp:
535
+ lp += list(g.logprobs)
536
+ else:
537
+ lp += [0.0] * len(g.token_ids)
538
+ return [TrainingRow(ids, mask, tids, lp if have_lp else None)]
code/align/rl/envs/subgoal_repair.py ADDED
@@ -0,0 +1,694 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """APOLLO-style subgoal extraction and reassembly for failed Lean 4 proofs (K5 agentic arm, docs/K5_LEVERS.md).
2
+
3
+ Pure Python (stdlib only) plus read-only Lean calls through the existing `align.verify.lean` API. Nothing here
4
+ changes the verifier: the reassembled proof is only a CANDIDATE; the strict verifier (`StrictVerifier`) is the sole
5
+ judge of acceptance, against the ORIGINAL statement. So a lemma that drifted from the goal it was cut from (the
6
+ pretty-printer is not a round trip) can cost a wasted attempt, never a false accept.
7
+
8
+ Pipeline (APOLLO, arXiv 2505.05758, section 3; DeepSeek-Prover-V2, arXiv 2504.21801, section 2.1: "decompose, prove the
9
+ subgoals separately, recombine"):
10
+
11
+ 1. `locate_targets` map every Lean error of a failed proof to the smallest repairable unit that contains it:
12
+ a failing `have name : T := <rhs>` (kind "have", innermost wins), a failing main-line tactic
13
+ (kind "tactic"), or "the proof ended with goals left" (kind "tail"). Errors on a `have`
14
+ HEADER (the statement itself does not elaborate) are not repairable and are reported.
15
+ 2. `build_skeleton` replace each target's text by `sorry` ("sorrifier"). Targets that contain another target are
16
+ dropped (the inner failure is the finer cut; the outer one is picked up by the next round).
17
+ 3. `plan_subgoals` run the skeleton in Lean once: its `sorries` carry the exact goal state (hypotheses and
18
+ goal) at each gap. Each state becomes a standalone `theorem k5_sg_<hash> (hyps) : goal`,
19
+ which is checked to elaborate. These are ordinary problems for the prover.
20
+ 4. `reassemble` splice a prover-supplied tactic body back into the skeleton in place of the `sorry`
21
+ (inline: the lemma's hypotheses carry the context's names; inaccessible ones are
22
+ re-introduced with `rename_i`). Unproven gaps get their original text back.
23
+
24
+ Coordinates. Lean errors index `compile_view(code)` (header lines removed, `p2_common.compile_view`), not the full
25
+ code. `view_line_map` translates; all text surgery happens on the FULL code so the result can be verified as is.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import hashlib
31
+ import re
32
+ from dataclasses import dataclass, field
33
+ from typing import Any, Callable, Optional
34
+
35
+ # --------------------------------------------------------------------------- lexical masking
36
+
37
+ _OPEN = "([{⟨"
38
+ _CLOSE = ")]}⟩"
39
+
40
+
41
+ def mask(text: str) -> str:
42
+ """`text` with comments and string literals blanked (newlines kept), so indentation and bracket scans cannot be
43
+ fooled by prose. Same length as the input: offsets and (line, column) positions are preserved."""
44
+ out = list(text)
45
+ i, n = 0, len(text)
46
+ while i < n:
47
+ c = text[i]
48
+ if c == "-" and text.startswith("--", i):
49
+ j = text.find("\n", i)
50
+ j = n if j < 0 else j
51
+ for k in range(i, j):
52
+ out[k] = " "
53
+ i = j
54
+ elif c == "/" and text.startswith("/-", i):
55
+ depth, j = 1, i + 2
56
+ while j < n and depth:
57
+ if text.startswith("/-", j):
58
+ depth += 1
59
+ j += 2
60
+ elif text.startswith("-/", j):
61
+ depth -= 1
62
+ j += 2
63
+ else:
64
+ j += 1
65
+ for k in range(i, min(j, n)):
66
+ if out[k] != "\n":
67
+ out[k] = " "
68
+ i = j
69
+ elif c == '"':
70
+ j = i + 1
71
+ while j < n and text[j] != '"':
72
+ j += 2 if text[j] == "\\" else 1
73
+ for k in range(i + 1, min(j, n)):
74
+ if out[k] != "\n":
75
+ out[k] = " "
76
+ i = j + 1
77
+ else:
78
+ i += 1
79
+ return "".join(out)
80
+
81
+
82
+ def strip_comments(text: str) -> str:
83
+ """`text` with block and line comments deleted (used on headers only, which hold no string literals)."""
84
+ text = re.sub(r"/-.*?-/", "", text, flags=re.S)
85
+ return re.sub(r"--[^\n]*", "", text)
86
+
87
+
88
+ def view_line_map(code: str) -> list[int]:
89
+ """`map[i]` = index in `code.split("\\n")` of line `i` of `compile_view(code)` (the REPL's coordinate system).
90
+ `compile_view` (the reference's `handle`) drops every line whose stripped text starts with import / set_option /
91
+ open, nothing else."""
92
+ out = []
93
+ for i, line in enumerate(code.split("\n")):
94
+ s = line.strip()
95
+ if s.startswith("import") or s.startswith("set_option") or s.startswith("open"):
96
+ continue
97
+ out.append(i)
98
+ return out
99
+
100
+
101
+ # --------------------------------------------------------------------------- proof structure
102
+
103
+
104
+ @dataclass
105
+ class _Lines:
106
+ """Masked, bullet-normalised working copy of the code."""
107
+
108
+ raw: list[str]
109
+ m: list[str] # masked lines (comments blanked); bullets `· ` replaced by two spaces
110
+ indent: list[int]
111
+ blank: list[bool]
112
+ depth_before: list[int] # open-bracket depth at the start of each line
113
+ cont: list[bool] # the line continues the previous statement at the same indent
114
+ ends: list[int] = field(default_factory=list) # extent end line of a statement starting here (-1: not a start)
115
+
116
+
117
+ _BULLET = re.compile(r"^(\s*)[·.](?=\s)")
118
+
119
+
120
+ def _analyse(code: str) -> _Lines:
121
+ raw = code.split("\n")
122
+ mk = mask(code).split("\n")
123
+ m = []
124
+ for ln in mk:
125
+ mm = _BULLET.match(ln)
126
+ if mm:
127
+ ln = mm.group(1) + " " + ln[len(mm.group(1)) + 1:]
128
+ m.append(ln)
129
+ indent = [len(x) - len(x.lstrip(" ")) for x in m]
130
+ blank = [not x.strip() for x in m]
131
+ depth, db = 0, []
132
+ for x in m:
133
+ db.append(depth)
134
+ for ch in x:
135
+ if ch in _OPEN:
136
+ depth += 1
137
+ elif ch in _CLOSE:
138
+ depth = max(0, depth - 1)
139
+ cont = [False] * len(m)
140
+ prev = None
141
+ for i, x in enumerate(m):
142
+ if blank[i]:
143
+ continue
144
+ s = x.strip()
145
+ if prev is not None and (db[i] > 0 or s.startswith("<;>") or m[prev].rstrip().endswith("<;>")):
146
+ cont[i] = True
147
+ prev = i
148
+ L = _Lines(raw, m, indent, blank, db, cont)
149
+ L.ends = [-1] * len(m)
150
+ for i in range(len(m)):
151
+ if blank[i] or cont[i]:
152
+ continue
153
+ e = i
154
+ for j in range(i + 1, len(m)):
155
+ if blank[j]:
156
+ continue
157
+ if indent[j] > indent[i] or (indent[j] == indent[i] and cont[j]):
158
+ e = j
159
+ else:
160
+ break
161
+ L.ends[i] = e
162
+ return L
163
+
164
+
165
+ _THEOREM = re.compile(r"^\s*(?:theorem|lemma)\b")
166
+
167
+
168
+ @dataclass(frozen=True)
169
+ class Have:
170
+ start: int # first line
171
+ end: int # last line of the unit (inclusive)
172
+ name: str
173
+ assign_line: int # line holding the depth-0 `:=`
174
+ assign_col: int # column of that `:=`
175
+
176
+
177
+ def _first_assign(L: _Lines, s: int, e: int) -> Optional[tuple[int, int]]:
178
+ depth = 0
179
+ for i in range(s, e + 1):
180
+ x = L.m[i]
181
+ for c, ch in enumerate(x):
182
+ if ch in _OPEN:
183
+ depth += 1
184
+ elif ch in _CLOSE:
185
+ depth = max(0, depth - 1)
186
+ elif ch == ":" and depth == 0 and x.startswith(":=", c):
187
+ return i, c
188
+ return None
189
+
190
+
191
+ def find_haves(code: str, L: Optional[_Lines] = None) -> list[Have]:
192
+ """Every `have <name> : T := <rhs>` unit (any nesting depth) that has a `:=`. Side-goal haves (no `:=`, closed by
193
+ following bullets) and `have ... from` are not units this module can cut."""
194
+ L = L or _analyse(code)
195
+ out = []
196
+ for i, x in enumerate(L.m):
197
+ if L.ends[i] < 0:
198
+ continue
199
+ s = x.strip()
200
+ if not (s.startswith("have ") or s.startswith("have:") or s.startswith("haveI ")):
201
+ continue
202
+ a = _first_assign(L, i, L.ends[i])
203
+ if a is None:
204
+ continue
205
+ toks = s.split()
206
+ name = toks[1] if len(toks) > 1 and toks[1] not in (":", ":=") and not toks[1].startswith(("⟨", "(", ":")) else "_"
207
+ out.append(Have(i, L.ends[i], name, a[0], a[1]))
208
+ return out
209
+
210
+
211
+ def theorem_by_line(L: _Lines) -> Optional[tuple[int, int]]:
212
+ """(line, column just after `by`) of the main theorem's `:= by`; None when absent."""
213
+ for i, x in enumerate(L.m):
214
+ if _THEOREM.match(x):
215
+ a = _first_assign(L, i, min(len(L.m) - 1, i + 12))
216
+ if a is None:
217
+ return None
218
+ j = L.m[a[0]].find("by", a[1])
219
+ return (a[0], j + 2) if j >= 0 else None
220
+ return None
221
+
222
+
223
+ # --------------------------------------------------------------------------- targets
224
+
225
+
226
+ @dataclass
227
+ class Target:
228
+ kind: str # have | tactic | tail
229
+ start: tuple[int, int] # (line, col) of the text to replace, 0-based, full-code coordinates
230
+ end: tuple[int, int] # exclusive end (line, col); for "tail" start == end (insertion point)
231
+ original: str # the replaced text, restorable verbatim
232
+ indent: int # indentation of the statement the gap lives in
233
+ name: str = ""
234
+ errors: list = field(default_factory=list)
235
+
236
+
237
+ def _cut(raw: list[str], a: tuple[int, int], b: tuple[int, int]) -> str:
238
+ if a[0] == b[0]:
239
+ return raw[a[0]][a[1]:b[1]]
240
+ parts = [raw[a[0]][a[1]:]] + raw[a[0] + 1:b[0]] + [raw[b[0]][:b[1]]]
241
+ return "\n".join(parts)
242
+
243
+
244
+ def tail_target(L: _Lines, tb: tuple[int, int]) -> Target:
245
+ """Insertion point for a `sorry` after the proof's last line: the proof ended with goals left."""
246
+ body_lines = [i for i in range(tb[0] + 1, len(L.m)) if not L.blank[i]]
247
+ ind = min((L.indent[i] for i in body_lines), default=2)
248
+ last = max(body_lines, default=tb[0])
249
+ pos = (last, len(L.raw[last]))
250
+ return Target("tail", pos, pos, "", ind)
251
+
252
+
253
+ def locate_targets(code: str, errors: list[dict], repl_code: Optional[str] = None) -> tuple[list[Target], list[dict]]:
254
+ """Map Lean errors (positions in `repl_code`, default `compile_view(code)`) to repairable targets.
255
+
256
+ Returns (targets sorted by position, unrepairable errors with a `why`). Only severity-error messages are used.
257
+ Rules, in order, for an error at line l, column c:
258
+ * inside the rhs of a `have` -> kind "have" (innermost such have); target = text after its `:=`
259
+ * on the theorem's `:= by` line, or the proof ends with goals left -> kind "tail"
260
+ * on a `have` header (before its `:=`) -> unrepairable "have_header" (the statement itself is wrong)
261
+ * otherwise -> kind "tactic": the statement containing the error, as one unit
262
+ Targets that contain another target are dropped; the finer cut wins."""
263
+ L = _analyse(code)
264
+ vmap = view_line_map(code)
265
+ if repl_code is not None and len(repl_code.split("\n")) != len(vmap):
266
+ raise ValueError("repl_code does not match compile_view(code): the error positions would index other lines")
267
+ haves = find_haves(code, L)
268
+ tb = theorem_by_line(L)
269
+ found: dict[tuple, Target] = {}
270
+ bad: list[dict] = []
271
+ for e in errors:
272
+ if e.get("severity", "error") != "error" or not e.get("pos"):
273
+ continue
274
+ vl, c = int(e["pos"]["line"]) - 1, int(e["pos"]["column"])
275
+ if not 0 <= vl < len(vmap):
276
+ bad.append({**e, "why": "position_outside_code"})
277
+ continue
278
+ l = vmap[vl]
279
+ inside = [h for h in haves if h.start <= l <= h.end and (l, c) > (h.assign_line, h.assign_col + 1)]
280
+ if inside:
281
+ h = max(inside, key=lambda x: x.start)
282
+ rhs_start = (h.assign_line, h.assign_col + 2)
283
+ end = (h.end, len(L.raw[h.end]))
284
+ key = ("have", rhs_start)
285
+ t = found.setdefault(key, Target("have", rhs_start, end, _cut(L.raw, rhs_start, end), L.indent[h.start], h.name))
286
+ t.errors.append(e)
287
+ continue
288
+ if tb is not None and l == tb[0]:
289
+ t = found.setdefault(("tail",), tail_target(L, tb))
290
+ t.errors.append(e)
291
+ continue
292
+ if any(h.start <= l <= h.assign_line and (l, c) <= (h.assign_line, h.assign_col + 1) for h in haves):
293
+ bad.append({**e, "why": "have_header"})
294
+ continue
295
+ starts = [i for i in range(l + 1) if L.ends[i] >= l and not L.blank[i]]
296
+ if not starts or (tb is not None and l < tb[0]):
297
+ bad.append({**e, "why": "no_statement"})
298
+ continue
299
+ s = max(starts)
300
+ end = (L.ends[s], len(L.raw[L.ends[s]]))
301
+ st = (s, L.indent[s])
302
+ t = found.setdefault(("tactic", st), Target("tactic", st, end, _cut(L.raw, st, end), L.indent[s]))
303
+ t.errors.append(e)
304
+ ts = sorted(found.values(), key=lambda t: (t.start, t.end))
305
+ keep = []
306
+ for t in ts:
307
+ if t.kind != "tail" and any(o is not t and o.kind != "tail" and t.start <= o.start and o.end <= t.end and (t.start, t.end) != (o.start, o.end)
308
+ for o in ts):
309
+ continue # contains a finer target
310
+ keep.append(t)
311
+ return keep, bad
312
+
313
+
314
+ # --------------------------------------------------------------------------- skeleton
315
+
316
+
317
+ GAP = "⟪K5GAP{}⟫"
318
+
319
+
320
+ def build_skeleton(code: str, targets: list[Target]) -> tuple[str, list[tuple[int, int]]]:
321
+ """(skeleton, [(line, col) of the i-th target's `sorry` in the skeleton]). Order of positions == order of
322
+ `targets` as given (they are applied bottom-up on a copy)."""
323
+ raw = code.split("\n")
324
+ order = sorted(range(len(targets)), key=lambda i: (targets[i].start, targets[i].end), reverse=True)
325
+ for i in order:
326
+ t = targets[i]
327
+ if t.kind == "have":
328
+ ins = " by " + GAP.format(i)
329
+ elif t.kind == "tactic":
330
+ ins = GAP.format(i)
331
+ else: # tail: a new line after the last proof line
332
+ raw.insert(t.start[0] + 1, " " * t.indent + GAP.format(i))
333
+ continue
334
+ a, b = t.start, t.end
335
+ raw[a[0]:b[0] + 1] = [raw[a[0]][:a[1]] + ins + raw[b[0]][b[1]:]]
336
+ text = "\n".join(raw)
337
+ pos: dict[int, tuple[int, int]] = {}
338
+ out_lines = []
339
+ for ln_no, ln in enumerate(text.split("\n")):
340
+ while True:
341
+ m = re.search(r"⟪K5GAP(\d+)⟫", ln)
342
+ if not m:
343
+ break
344
+ pos[int(m.group(1))] = (ln_no, m.start())
345
+ ln = ln[:m.start()] + "sorry" + ln[m.end():]
346
+ out_lines.append(ln)
347
+ return "\n".join(out_lines), [pos[i] for i in range(len(targets))]
348
+
349
+
350
+ # --------------------------------------------------------------------------- goal state -> lemma
351
+
352
+
353
+ _INACC = re.compile(r"✝[⁰¹²³⁴⁵⁶⁷⁸⁹]*$")
354
+
355
+
356
+ @dataclass
357
+ class GoalState:
358
+ case: Optional[str]
359
+ hyps: list[tuple[list[str], str]] # (names, type)
360
+ goal: str
361
+ has_let: bool = False
362
+
363
+
364
+ def parse_goal_state(text: str) -> GoalState:
365
+ """Parse a Lean goal display (`case h` / `x y : T` / ... / `⊢ goal`). Wrapped lines (indented) are joined."""
366
+ lines = text.split("\n")
367
+ case = None
368
+ blocks: list[str] = []
369
+ goal_lines: list[str] = []
370
+ in_goal = False
371
+ for ln in lines:
372
+ if in_goal:
373
+ goal_lines.append(ln.strip())
374
+ elif ln.startswith("⊢"):
375
+ in_goal = True
376
+ goal_lines.append(ln[1:].strip())
377
+ elif ln.startswith("case ") and not blocks:
378
+ case = ln[5:].strip()
379
+ elif ln.startswith((" ", "\t")) and blocks:
380
+ blocks[-1] += " " + ln.strip()
381
+ elif ln.strip():
382
+ blocks.append(ln.strip())
383
+ hyps, has_let = [], False
384
+ for b in blocks:
385
+ names, sep, typ = b.partition(" : ")
386
+ if not sep:
387
+ continue
388
+ if re.search(r"\s:=\s", typ):
389
+ has_let = True
390
+ hyps.append((names.split(), typ.strip()))
391
+ return GoalState(case, hyps, " ".join(goal_lines), has_let)
392
+
393
+
394
+ @dataclass
395
+ class Lemma:
396
+ name: str
397
+ statement: str # `theorem name (binders) :\n goal := by sorry` (no header)
398
+ rename: list[str] # `rename_i` argument list ("_" for instances); [] when no hypothesis is inaccessible
399
+
400
+
401
+ def lemma_from_goal(gs: GoalState, name: str) -> Optional[Lemma]:
402
+ """Standalone lemma for a goal state, or None when unsupported (let-bound hypotheses).
403
+
404
+ Inaccessible hypotheses (`n✝`, shadowed or auto-named) get fresh names `k5h<i>` and every later mention of the old name
405
+ in a type or the goal is rewritten, so the lemma is closed; `rename` lists them (and `_` for instances) in context order
406
+ for the `rename_i` that gives the inlined proof the same names."""
407
+ if gs.has_let or not gs.goal:
408
+ return None
409
+ mp: dict[str, str] = {}
410
+ k = 0
411
+ for names, _ in gs.hyps:
412
+ inst = all(_INACC.search(n) and n.startswith("inst") for n in names)
413
+ if inst:
414
+ continue
415
+ for n in names:
416
+ if _INACC.search(n):
417
+ k += 1
418
+ mp[n] = f"k5h{k}"
419
+ pats = sorted(mp, key=len, reverse=True)
420
+
421
+ def fix(text: str) -> str:
422
+ for old in pats:
423
+ text = re.sub(r"(?<![\w.])" + re.escape(old) + r"(?![\w⁰¹²³⁴⁵⁶⁷⁸⁹])", mp[old], text)
424
+ return text
425
+
426
+ binders, rename = [], []
427
+ for names, typ in gs.hyps:
428
+ inst = all(_INACC.search(n) and n.startswith("inst") for n in names)
429
+ typ = fix(typ)
430
+ for n in names:
431
+ if _INACC.search(n):
432
+ rename.append("_" if inst else mp[n])
433
+ if inst:
434
+ binders += [f"[{typ}]" for _ in names]
435
+ else:
436
+ binders.append(f"({' '.join(mp.get(n, n) for n in names)} : {typ})")
437
+ head = " ".join(["theorem", name] + binders)
438
+ return Lemma(name, f"{head} :\n {fix(gs.goal)} := by sorry", rename)
439
+
440
+
441
+ # --------------------------------------------------------------------------- the plan
442
+
443
+
444
+ @dataclass
445
+ class Gap:
446
+ target: Target
447
+ sorry_pos: tuple[int, int] # in the skeleton, full-code coordinates
448
+ goal_state: Optional[str] = None
449
+ lemma: Optional[Lemma] = None
450
+ why_not: Optional[str] = None
451
+
452
+
453
+ @dataclass
454
+ class SubgoalPlan:
455
+ code: str
456
+ skeleton: str
457
+ gaps: list[Gap]
458
+ unrepairable: list[dict]
459
+ residual_errors: list[dict] # errors the skeleton still has (outer failures: next round)
460
+ header: str # the header every lemma problem needs (comment-free)
461
+
462
+ @property
463
+ def lemmas(self) -> list[Gap]:
464
+ return [g for g in self.gaps if g.lemma is not None]
465
+
466
+ @property
467
+ def complete(self) -> bool:
468
+ """Every gap has a lemma and the skeleton has no error of its own."""
469
+ return bool(self.gaps) and all(g.lemma for g in self.gaps) and not self.residual_errors and not self.unrepairable
470
+
471
+
472
+ Probe = Callable[[str], dict]
473
+ """`probe(code) -> {"errors": [...], "sorries": [{"pos": {"line","column"}, "goal": str}, ...], "view_lines": int}`: runs
474
+ `compile_view(code)` in the statement's header environment (one REPL command) and returns the error messages and the
475
+ `sorries` the REPL reports. `repl_probe` builds one from a pool worker."""
476
+
477
+ MAX_GAPS = 6
478
+
479
+
480
+ def header_of(statement: str) -> str:
481
+ """Everything before the first `theorem`/`lemma`, comments removed: the header every derived lemma must carry."""
482
+ m = re.search(r"\b(?:theorem|lemma)\b", statement)
483
+ head = statement[:m.start()] if m else statement
484
+ return strip_comments(head).rstrip() + "\n\n"
485
+
486
+
487
+ def _sorry_map(resp: dict, skeleton: str) -> dict:
488
+ vmap = view_line_map(skeleton)
489
+ out = {}
490
+ for s in resp.get("sorries", []):
491
+ p = s.get("pos") or {}
492
+ if "line" in p and 0 <= int(p["line"]) - 1 < len(vmap):
493
+ out[(vmap[int(p["line"]) - 1], int(p["column"]))] = s.get("goal", "")
494
+ return out
495
+
496
+
497
+ def plan_subgoals(statement: str, code: str, errors: list[dict], probe: Probe, parent_id: str = "p",
498
+ repl_code: Optional[str] = None, max_gaps: int = MAX_GAPS) -> SubgoalPlan:
499
+ """Cut a failed proof into gaps (see the module docstring). Two passes: the have / tactic cuts first; the "proof ended
500
+ with goals left" tail cut only when the skeleton still shows that error (after a parse error Lean reports a cascade
501
+ of `unsolved goals` at the theorem's `by` that vanishes once the broken step is cut)."""
502
+ targets, bad = locate_targets(code, errors, repl_code)
503
+ header = header_of(statement)
504
+ if not targets or len(targets) > max_gaps:
505
+ return SubgoalPlan(code, code, [], bad + ([{"why": f"too_many_gaps:{len(targets)}"}] if targets else []), [], header)
506
+ main = [t for t in targets if t.kind != "tail"]
507
+ tails = [t for t in targets if t.kind == "tail"]
508
+ first = main or tails
509
+ skeleton, pos = build_skeleton(code, first)
510
+ resp = probe(skeleton)
511
+ tail_gap = None
512
+ if main and tails:
513
+ L2 = _analyse(skeleton)
514
+ tb2 = theorem_by_line(L2)
515
+ vmap2 = view_line_map(skeleton)
516
+ at_by = [e for e in resp.get("errors", []) if e.get("pos") and tb2 is not None
517
+ and 0 <= int(e["pos"]["line"]) - 1 < len(vmap2) and vmap2[int(e["pos"]["line"]) - 1] == tb2[0]]
518
+ if at_by:
519
+ tt = tail_target(L2, tb2)
520
+ tt.errors = at_by
521
+ skeleton2, pos2 = build_skeleton(skeleton, [tt])
522
+ tail_gap = (tt, pos2[0])
523
+ skeleton = skeleton2
524
+ resp = probe(skeleton)
525
+ sorries = _sorry_map(resp, skeleton)
526
+ gaps = []
527
+ for i, t in enumerate(first):
528
+ gaps.append(_make_gap(t, pos[i], sorries, parent_id))
529
+ if tail_gap is not None:
530
+ gaps.append(_make_gap(tail_gap[0], tail_gap[1], sorries, parent_id))
531
+ resid = [e for e in resp.get("errors", []) if isinstance(e, dict)]
532
+ return SubgoalPlan(code, skeleton, gaps, bad, resid, header)
533
+
534
+
535
+ def _make_gap(t: Target, pos: tuple[int, int], sorries: dict, parent_id: str) -> Gap:
536
+ g = Gap(t, pos)
537
+ goal = sorries.get(pos)
538
+ if goal is None:
539
+ g.why_not = "no_sorry_reported"
540
+ return g
541
+ g.goal_state = goal
542
+ h = hashlib.sha256((parent_id + "|" + goal).encode()).hexdigest()[:10]
543
+ lem = lemma_from_goal(parse_goal_state(goal), f"k5_sg_{h}")
544
+ if lem is None:
545
+ g.why_not = "unsupported_goal_state"
546
+ else:
547
+ g.lemma = lem
548
+ return g
549
+
550
+
551
+ def lemma_problem_statement(plan: SubgoalPlan, gap: Gap) -> str:
552
+ """The reference statement for the prover: header + the lemma ending `:= by sorry` (what `lean_gen` problems carry)."""
553
+ assert gap.lemma is not None
554
+ return plan.header + gap.lemma.statement
555
+
556
+
557
+ # --------------------------------------------------------------------------- reassembly
558
+
559
+
560
+ _LEMMA_HEAD = re.compile(r"theorem\s+(\S+)[\s\S]*?:=\s*by", re.M)
561
+
562
+
563
+ def lemma_body(lemma_code: str, lemma_name: str) -> Optional[str]:
564
+ """The tactic text after the lemma's own `:= by` in a prover-produced code (`lean_code` of a record), or None when
565
+ the code is not a proof of exactly that lemma (another declaration, no body). Comments are masked for the search."""
566
+ mk = mask(lemma_code)
567
+ m = None
568
+ for cand in re.finditer(r"theorem\s+" + re.escape(lemma_name) + r"\b[\s\S]*?:=\s*by", mk):
569
+ m = cand
570
+ break
571
+ if m is None:
572
+ return None
573
+ body = lemma_code[m.end():]
574
+ if re.search(r"^\s*(?:theorem|lemma)\s", mask(body), re.M):
575
+ return None # a second declaration: not an inline-able body
576
+ return body
577
+
578
+
579
+ def _indent_block(body: str, indent: int) -> list[str]:
580
+ ls = body.split("\n")
581
+ while ls and not ls[0].strip():
582
+ ls.pop(0)
583
+ while ls and not ls[-1].strip():
584
+ ls.pop()
585
+ if not ls:
586
+ return []
587
+ first = ls[0]
588
+ # `:= by simp` leaves the first tactic on the lemma's line: its text starts after one space
589
+ if body[:1] not in ("\n", "\r") and body.strip():
590
+ ls[0] = first.strip()
591
+ rest = [x for x in ls[1:]]
592
+ mi = min((len(x) - len(x.lstrip(" ")) for x in rest if x.strip()), default=0)
593
+ return [" " * indent + ls[0]] + [" " * indent + x[mi:] if x.strip() else "" for x in rest]
594
+ mi = min(len(x) - len(x.lstrip(" ")) for x in ls if x.strip())
595
+ return [" " * indent + x[mi:] if x.strip() else "" for x in ls]
596
+
597
+
598
+ def reassemble(plan: SubgoalPlan, bodies: dict[int, str], restore_unproven: bool = True) -> Optional[str]:
599
+ """Full code with each proven gap's tactic body spliced in.
600
+
601
+ `bodies[i]` = tactic text for `plan.gaps[i]` (from `lemma_body`). An unproven gap gets its original text back when
602
+ `restore_unproven` (the result is then still a failing proof, but with every proven cut fixed: a better parent for
603
+ the next round), else the function returns None as soon as one is missing."""
604
+ raw = plan.skeleton.split("\n")
605
+ for i in sorted(range(len(plan.gaps)), key=lambda k: plan.gaps[k].sorry_pos, reverse=True):
606
+ g = plan.gaps[i]
607
+ ln, col = g.sorry_pos
608
+ line = raw[ln]
609
+ assert line[col:col + 5] == "sorry", "skeleton drifted from the plan"
610
+ body = bodies.get(i)
611
+ if body is None or not body.strip():
612
+ if not restore_unproven:
613
+ return None
614
+ if g.target.kind == "have": # the skeleton holds `... := by sorry`: put the original rhs back
615
+ raw[ln] = line[:col - len(" by ")] + g.target.original + line[col + 5:]
616
+ elif g.target.kind == "tactic":
617
+ raw[ln] = line[:col] + g.target.original + line[col + 5:]
618
+ else:
619
+ del raw[ln]
620
+ continue
621
+ pre = ["rename_i " + " ".join(g.lemma.rename)] if g.lemma is not None and g.lemma.rename else []
622
+ if g.target.kind == "have":
623
+ ind = g.target.indent + 2
624
+ raw[ln:ln + 1] = [line[:col].rstrip()] + [" " * ind + p for p in pre] + _indent_block(body, ind)
625
+ elif g.target.kind == "tactic":
626
+ blk = [" " * g.target.indent + p for p in pre] + _indent_block(body, g.target.indent)
627
+ raw[ln:ln + 1] = [line[:col] + blk[0].lstrip()] + blk[1:]
628
+ else: # tail: the inserted line holds only the gap
629
+ raw[ln:ln + 1] = [" " * g.target.indent + p for p in pre] + _indent_block(body, g.target.indent)
630
+ return "\n".join(raw)
631
+
632
+
633
+ # --------------------------------------------------------------------------- Lean (read-only use of align.verify.lean)
634
+
635
+
636
+ def repl_probe(worker: Any, statement: str, timeout_s: float = 120.0) -> Probe:
637
+ """A `Probe` over one pool worker (a `Worker` from `ReplPool.acquire`/`map`): the statement's header environment is
638
+ built (cached by the worker) and `compile_view(code)` is run in it, exactly as `verify_generations.collect_feedback`
639
+ does. Read-only: no change to the verifier, the pool or its limits."""
640
+ from align.verify.lean.strict import parse_statement
641
+
642
+ ref = parse_statement(statement)
643
+ if not ref.ok:
644
+ raise ValueError(f"statement not supported by the strict parser: {ref.problem}")
645
+
646
+ def probe(code: str) -> dict:
647
+ view = "\n".join(code.split("\n")[i] for i in view_line_map(code))
648
+ env = worker.header_env(ref.header_text, "")
649
+ resp = worker.command(view, env, timeout_s)
650
+ errs = [{"severity": "error", "pos": m.get("pos"), "endPos": m.get("endPos"), "data": m.get("data", "")}
651
+ for m in resp.get("messages", []) if isinstance(m, dict) and m.get("severity") == "error"]
652
+ return {"errors": errs, "sorries": resp.get("sorries", []), "view": view}
653
+
654
+ return probe
655
+
656
+
657
+ def check_lemma_elaborates(worker: Any, plan: SubgoalPlan, gap: Gap, timeout_s: float = 120.0) -> bool:
658
+ """True iff `lemma_problem_statement` elaborates (no error; the `sorry` warning is expected). Gaps whose lemma does
659
+ not elaborate (pretty-printer round-trip failures) are dropped by the caller, not sent to the prover."""
660
+ from align.verify.lean.strict import parse_statement
661
+
662
+ stmt = lemma_problem_statement(plan, gap)
663
+ ref = parse_statement(stmt)
664
+ if not ref.ok:
665
+ return False
666
+ env = worker.header_env(ref.header_text, "")
667
+ view = stmt[len(plan.header):]
668
+ resp = worker.command(view, env, timeout_s)
669
+ return not any(isinstance(m, dict) and m.get("severity") == "error" for m in resp.get("messages", []))
670
+
671
+
672
+ # --------------------------------------------------------------------------- (de)serialisation (route -> assemble hand-off)
673
+
674
+
675
+ def plan_to_dict(plan: SubgoalPlan) -> dict:
676
+ return {
677
+ "code": plan.code, "skeleton": plan.skeleton, "header": plan.header,
678
+ "unrepairable": plan.unrepairable, "residual_errors": plan.residual_errors,
679
+ "gaps": [{
680
+ "kind": g.target.kind, "start": list(g.target.start), "end": list(g.target.end), "original": g.target.original,
681
+ "indent": g.target.indent, "name": g.target.name, "sorry_pos": list(g.sorry_pos), "goal_state": g.goal_state,
682
+ "why_not": g.why_not,
683
+ "lemma": None if g.lemma is None else {"name": g.lemma.name, "statement": g.lemma.statement, "rename": g.lemma.rename},
684
+ } for g in plan.gaps],
685
+ }
686
+
687
+
688
+ def plan_from_dict(d: dict) -> SubgoalPlan:
689
+ gaps = []
690
+ for g in d["gaps"]:
691
+ t = Target(g["kind"], tuple(g["start"]), tuple(g["end"]), g["original"], g["indent"], g.get("name", ""))
692
+ lem = None if g["lemma"] is None else Lemma(g["lemma"]["name"], g["lemma"]["statement"], list(g["lemma"]["rename"]))
693
+ gaps.append(Gap(t, tuple(g["sorry_pos"]), g.get("goal_state"), lem, g.get("why_not")))
694
+ return SubgoalPlan(d["code"], d["skeleton"], gaps, d.get("unrepairable", []), d.get("residual_errors", []), d["header"])
code/align/rl/lean_rl.py ADDED
@@ -0,0 +1,659 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Single-turn on-policy RL for Lean provers (lane R): config, rollout->batch, losses, gradient step.
2
+
3
+ The run loop, checkpoints, ledger and PEFT export live in `align.rl.lean_rl_run`; the CLI is
4
+ `scripts/p2/rl_lean.py`. Nothing here imports vLLM or runs Lean: generation and verification are
5
+ file contracts (the P2 Generation / Verification JSONL records), so this module is testable on CPU
6
+ with a fake generator and a fake verifier.
7
+
8
+ THREE LOGPROB BASES, kept apart and stamped (Codex P13-32, audit U04-3):
9
+ behaviour log pi_infer(token) recorded by the sampler at sampling time (`sampling_logprobs`).
10
+ Requires top_p == 1.0 and top_k disabled: under a truncated sampler the sampled
11
+ distribution is NOT softmax(logits/T), so no trainer-side ratio against it is meaningful.
12
+ old trainer logprobs at the rollout-time weights. Single optimizer step per iteration: this is
13
+ `current.detach()` (no extra forward); it is used ONLY for the engine-mismatch telemetry
14
+ |old - behaviour|, never as the importance denominator.
15
+ current the differentiable forward of the step.
16
+ The importance ratio is r = pi_current / pi_BEHAVIOUR, so the CISPO clamp (and the PPO clip of
17
+ `dr_grpo`) is a trust region against the policy that actually produced the tokens, including checkpoint
18
+ lag and the sampler/trainer kernel mismatch (U04-3). No separate rho factor exists to double count.
19
+
20
+ LOSS MODES (all token-level, all normalised by an EXPLICIT global token count, see `norm_count`):
21
+ cispo -A * sg(min(r, 1+eps_high)[, floor 1-eps_low]) * log pi (MiniMax-M1 Eq. 4-5)
22
+ dr_grpo PPO clip max(-A r, -A clip(r, 1-lo, 1+hi)), centered advantages, no std, no length norm
23
+ ei -log pi on ACCEPTED samples only, weight 1: the same pipeline with negatives dropped
24
+ (K1: positive-only SFT vs signed updates on the SAME cached rollouts, Codex P13-12)
25
+ Optional `neg_seq_ratio_floor` (cispo / dr_grpo): a sequence with A < 0 whose geometric-mean ratio is below
26
+ the floor is masked whole (pushing down what the policy already made unlikely is the unstable direction).
27
+
28
+ LOSS NORMALISATION (Codex P13-31). loss = sum_tokens(per-token loss) / N, N = the number of RESPONSE tokens
29
+ of every sample the batch builder kept, over ALL micro-batches of ALL ranks (each rank all-reduces its local
30
+ count and the result is checked against the count of the full list). Loss-time masks (negative-sequence
31
+ mask, ratio clamp) do not shrink N: a masked token still counts, exactly as a clipped token does in DAPO.
32
+ Consequence tested: the gradient is invariant to micro-batch partitioning and to the number of ranks.
33
+
34
+ REWARDS. accepted -> 1.0, rejected -> 0.0. A verdict whose reason is in `no_signal_reasons` (timeout, and
35
+ infra deaths) is MASKED: dropped from the batch AND from its group's baseline, never scored 0.
36
+ Advantages are group-centered over the valid samples only (Dr.GRPO: no /std).
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import dataclasses
42
+ import hashlib
43
+ import json
44
+ import math
45
+ import random
46
+ from dataclasses import dataclass, field
47
+ from typing import Any, Optional, Sequence
48
+
49
+ import numpy as np
50
+ import torch
51
+
52
+ from align.rl.advantages import DEAD_ATOL, GroupFilterStats
53
+ from align.rl.cispo import cispo_policy_loss, fp32_head_forward
54
+ from align.rl.ops import log_softmax_and_gather
55
+
56
+ LOSS_MODES = ("cispo", "dr_grpo", "ei")
57
+ NORMS = ("global_token_mean", "constant")
58
+ #: Verdict reasons that carry NO reward signal. `memory` / `output_too_large` are deliberately absent: a
59
+ #: proof that kills its REPL is the policy's doing and scores 0 (scripts/p2/verify_generations.py).
60
+ DEFAULT_NO_SIGNAL = ("timeout", "crash", "protocol")
61
+
62
+
63
+ class RLConfigError(ValueError):
64
+ """A config that cannot launch."""
65
+
66
+
67
+ # ---------------------------------------------------------------------------------------- config
68
+
69
+
70
+ @dataclass
71
+ class LossCfg:
72
+ mode: str = "cispo"
73
+ eps_high: float = 4.0 # CISPO IS-weight clamp (pre-registered sweep knob; paper does not state it)
74
+ eps_low: Optional[float] = None # None = no lower clamp (the paper's setting)
75
+ clip_lower: float = 0.2 # dr_grpo PPO clip
76
+ clip_higher: float = 0.28
77
+ neg_seq_ratio_floor: Optional[float] = None
78
+ norm: str = "global_token_mean"
79
+ constant: float = 0.0 # denominator when norm == "constant"
80
+ fp32_head: bool = False
81
+ fp32_head_chunk_tokens: int = 2048
82
+ temperature: float = 1.0
83
+ max_grad_norm: float = 1.0
84
+
85
+ def validate(self) -> None:
86
+ if self.mode not in LOSS_MODES:
87
+ raise RLConfigError(f"loss.mode={self.mode!r}: one of {LOSS_MODES}")
88
+ if self.norm not in NORMS:
89
+ raise RLConfigError(f"loss.norm={self.norm!r}: one of {NORMS}")
90
+ if self.norm == "constant" and not self.constant > 0:
91
+ raise RLConfigError("loss.norm=constant needs loss.constant > 0")
92
+ if not (self.eps_high > 0 and math.isfinite(self.eps_high)):
93
+ raise RLConfigError("loss.eps_high must be finite and > 0")
94
+ if self.eps_low is not None and not (self.eps_low > 0 and math.isfinite(self.eps_low)):
95
+ raise RLConfigError("loss.eps_low must be None or finite > 0")
96
+ if self.neg_seq_ratio_floor is not None and not (0 < self.neg_seq_ratio_floor < 1):
97
+ raise RLConfigError("loss.neg_seq_ratio_floor must be in (0, 1)")
98
+ if self.neg_seq_ratio_floor is not None and self.mode == "ei":
99
+ raise RLConfigError("neg_seq_ratio_floor is meaningless in ei mode (no negatives)")
100
+ if not self.temperature > 0:
101
+ raise RLConfigError("loss.temperature must be > 0")
102
+
103
+
104
+ @dataclass
105
+ class BatchCfg:
106
+ group_size: int = 8 # G rollouts per prompt
107
+ min_valid_per_group: int = 2 # after masking timeouts; fewer -> group dropped
108
+ no_signal_reasons: tuple = DEFAULT_NO_SIGNAL
109
+ #: ei only: keep the positives of all-solved groups (rejection-sampling FT keeps them); signed modes always
110
+ #: drop dead groups (zero advantage = zero gradient, wasted tokens).
111
+ ei_keep_all_solved: bool = True
112
+ micro_tokens: int = 16384 # padded tokens per micro-batch (rows x max len)
113
+ max_total_len: int = 0 # >0: drop samples whose prompt+completion exceeds this (counted)
114
+
115
+
116
+ @dataclass
117
+ class RolloutCfg:
118
+ top_p: float = 1.0 # REQUIRED 1.0 (Codex P13-32)
119
+ top_k: int = 0
120
+ temperature: float = 1.0
121
+ cap_tokens: int = 16384
122
+ max_model_len: int = 20480
123
+ tp: int = 4
124
+ adapter_mode: str = "unmerged" # "unmerged" (vLLM LoRARequest) | "merged_export" (fallback)
125
+ rollout_python: str = ".venv-rollout/bin/python"
126
+ gen_extra_args: tuple = ()
127
+
128
+ def validate(self) -> None:
129
+ if self.top_p != 1.0:
130
+ raise RLConfigError(f"rollout.top_p={self.top_p}: RL rollouts need top_p == 1.0 (Codex P13-32)")
131
+ if self.top_k not in (0, -1):
132
+ raise RLConfigError(f"rollout.top_k={self.top_k}: must be 0/-1 (disabled)")
133
+ if self.adapter_mode not in ("unmerged", "merged_export"):
134
+ raise RLConfigError(f"rollout.adapter_mode={self.adapter_mode!r}")
135
+
136
+
137
+ @dataclass
138
+ class OptimCfg:
139
+ lr: float = 1e-5
140
+ betas: tuple = (0.9, 0.95)
141
+ weight_decay: float = 0.0
142
+ eps: float = 1e-8
143
+ warmup_iters: int = 0
144
+ steps_per_iteration: int = 1 # optimizer steps taken on one rollout batch
145
+
146
+
147
+ @dataclass
148
+ class LoraCfg:
149
+ r: int = 64
150
+ alpha: float = 128.0
151
+ rslora: bool = False
152
+ targets: tuple = ("q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj")
153
+ adapter: str = "policy"
154
+ param_dtype: str = "float32"
155
+
156
+
157
+ @dataclass
158
+ class RLConfig:
159
+ run_name: str = "p2-rl-lean"
160
+ run_dir: str = ""
161
+ model_snapshot: str = "" # HF snapshot dir of the base (Goedel-8B / Kimina-Distill-8B)
162
+ problems: str = "" # P2 problem jsonl (training pool)
163
+ iterations: int = 1
164
+ prompts_per_iter: int = 8
165
+ seed: int = 0
166
+ keep_checkpoints: int = 2
167
+ export_merged: bool = False
168
+ replay_from: str = ""
169
+ verify_workers: int = 8
170
+ verify_budget_s: float = 180.0
171
+ verify_python: str = ".venv/bin/python"
172
+ loss: LossCfg = field(default_factory=LossCfg)
173
+ batch: BatchCfg = field(default_factory=BatchCfg)
174
+ rollout: RolloutCfg = field(default_factory=RolloutCfg)
175
+ optim: OptimCfg = field(default_factory=OptimCfg)
176
+ lora: LoraCfg = field(default_factory=LoraCfg)
177
+
178
+ def validate(self) -> None:
179
+ self.loss.validate()
180
+ self.rollout.validate()
181
+ if self.loss.temperature != self.rollout.temperature:
182
+ raise RLConfigError(
183
+ f"loss.temperature={self.loss.temperature} != rollout.temperature={self.rollout.temperature}: the "
184
+ "trainer must score tokens under the distribution that sampled them"
185
+ )
186
+ if self.batch.group_size < 2:
187
+ raise RLConfigError("batch.group_size must be >= 2 (a group of 1 has no baseline)")
188
+ if self.batch.min_valid_per_group < 2:
189
+ raise RLConfigError("batch.min_valid_per_group must be >= 2")
190
+ if self.prompts_per_iter < 1 or self.iterations < 1:
191
+ raise RLConfigError("prompts_per_iter and iterations must be >= 1")
192
+ if self.optim.steps_per_iteration < 1:
193
+ raise RLConfigError("optim.steps_per_iteration must be >= 1")
194
+ if self.loss.mode == "cispo" and self.loss.fp32_head and self.loss.fp32_head_chunk_tokens < 1:
195
+ raise RLConfigError("fp32_head_chunk_tokens must be >= 1")
196
+
197
+ def fingerprint(self) -> str:
198
+ """Hash of everything that changes the experiment (not paths of scratch). A resume under a different
199
+ fingerprint is a different run and is refused."""
200
+ d = dataclasses.asdict(self)
201
+ for k in ("run_dir", "run_name", "iterations", "replay_from", "verify_workers", "keep_checkpoints", "export_merged"):
202
+ d.pop(k, None)
203
+ return hashlib.sha256(json.dumps(d, sort_keys=True, default=str).encode()).hexdigest()
204
+
205
+
206
+ _SECTIONS = {"loss": LossCfg, "batch": BatchCfg, "rollout": RolloutCfg, "optim": OptimCfg, "lora": LoraCfg}
207
+
208
+
209
+ def config_from_dict(d: dict) -> RLConfig:
210
+ """Strict: an unknown key anywhere is an error (a typo'd knob silently keeping its default is how a
211
+ pre-registered setting gets lost)."""
212
+ d = dict(d)
213
+ kw: dict[str, Any] = {}
214
+ for name, cls in _SECTIONS.items():
215
+ sec = dict(d.pop(name, {}))
216
+ names = {f.name for f in dataclasses.fields(cls)}
217
+ bad = sorted(set(sec) - names)
218
+ if bad:
219
+ raise RLConfigError(f"[{name}] unknown keys {bad}")
220
+ for k, v in list(sec.items()):
221
+ if isinstance(v, list):
222
+ sec[k] = tuple(v)
223
+ kw[name] = cls(**sec)
224
+ names = {f.name for f in dataclasses.fields(RLConfig)} - set(_SECTIONS)
225
+ bad = sorted(set(d) - names)
226
+ if bad:
227
+ raise RLConfigError(f"unknown top-level keys {bad}")
228
+ cfg = RLConfig(**d, **kw)
229
+ cfg.validate()
230
+ return cfg
231
+
232
+
233
+ def load_config(path: str, overrides: Sequence[str] = ()) -> RLConfig:
234
+ import tomllib
235
+
236
+ with open(path, "rb") as f:
237
+ d = tomllib.load(f)
238
+ for ov in overrides:
239
+ key, _, raw = ov.partition("=")
240
+ try:
241
+ val = tomllib.loads(f"v = {raw}")["v"]
242
+ except tomllib.TOMLDecodeError:
243
+ val = raw
244
+ cur = d
245
+ parts = key.split(".")
246
+ for p in parts[:-1]:
247
+ cur = cur.setdefault(p, {})
248
+ cur[parts[-1]] = val
249
+ return config_from_dict(d)
250
+
251
+
252
+ # ------------------------------------------------------------------------- prompt stream (U04-2)
253
+
254
+
255
+ def prompt_block(ids: Sequence[str], seed: int, per_iter: int, k: int) -> list[str]:
256
+ """The prompt ids of iteration `k`: a PURE function of (ids, seed, per_iter, k).
257
+
258
+ Replays the stream from the start (cheap: id lists). Each pass over the set is an independent shuffle
259
+ seeded by (seed, pass index) -- epoch-indexed, so passes differ -- and the tail of one pass is carried
260
+ into the next block (nothing is dropped, every prompt is drawn once per pass). A block never repeats a
261
+ prompt unless it is larger than the pool.
262
+ """
263
+ n = len(ids)
264
+ if n == 0:
265
+ raise ValueError("empty prompt pool")
266
+ order: list[int] = []
267
+ cursor = 0
268
+ passes = 0
269
+ out: list[str] = []
270
+ for it in range(k + 1):
271
+ block: list[int] = []
272
+ while len(block) < per_iter:
273
+ if cursor >= len(order):
274
+ order = list(range(n))
275
+ random.Random(seed * 1000003 + passes * 7919 + 11).shuffle(order)
276
+ passes += 1
277
+ cursor = 0
278
+ i = order[cursor]
279
+ if per_iter <= n and i in block:
280
+ for j in range(cursor + 1, len(order)):
281
+ if order[j] not in block:
282
+ order[cursor], order[j] = order[j], order[cursor]
283
+ i = order[cursor]
284
+ break
285
+ cursor += 1
286
+ block.append(i)
287
+ out = [ids[i] for i in block]
288
+ return out
289
+
290
+
291
+ def stream_position(n_pool: int, per_iter: int, k: int) -> dict:
292
+ """Position stored in the checkpoint (draws made, pass index); recomputable, kept as a cross-check."""
293
+ draws = k * per_iter
294
+ return {"draws": draws, "pass": draws // n_pool, "offset": draws % n_pool}
295
+
296
+
297
+ # -------------------------------------------------------------------------------- rollout -> batch
298
+
299
+
300
+ @dataclass
301
+ class Sample:
302
+ key: tuple # (problem_id, sample_idx, round, cap_tokens)
303
+ group: str # problem_id
304
+ prompt_ids: list
305
+ completion_ids: list
306
+ behaviour_lp: list # one per completion token
307
+ reward: float
308
+ adv: float = 0.0
309
+
310
+ @property
311
+ def n_resp(self) -> int:
312
+ return len(self.completion_ids)
313
+
314
+ @property
315
+ def total_len(self) -> int:
316
+ return len(self.prompt_ids) + len(self.completion_ids)
317
+
318
+
319
+ def validate_rl_generation(rec: dict) -> None:
320
+ """The rollout-time contract an RL record must meet (raises with the key named)."""
321
+ key = (rec.get("problem_id"), rec.get("sample_idx"))
322
+ s = rec.get("sampling") or {}
323
+ if s.get("top_p") != 1.0:
324
+ raise RLConfigError(f"{key}: sampling.top_p={s.get('top_p')!r}; RL rollouts require top_p == 1.0")
325
+ if s.get("top_k") not in (0, -1, None):
326
+ raise RLConfigError(f"{key}: sampling.top_k={s.get('top_k')!r}; RL rollouts require top_k disabled")
327
+ lp = rec.get("sampling_logprobs")
328
+ comp = rec.get("completion_ids")
329
+ if not isinstance(lp, list) or not isinstance(comp, list) or len(lp) != len(comp) or not comp:
330
+ raise RLConfigError(f"{key}: sampling_logprobs must be a list aligned with completion_ids")
331
+ arr = np.asarray(lp, dtype=np.float64)
332
+ if not np.all(np.isfinite(arr)) or np.any(arr > 1e-4):
333
+ raise RLConfigError(f"{key}: sampling_logprobs must be finite and <= 0")
334
+
335
+
336
+ def reward_of(ver: dict, no_signal: Sequence[str]) -> tuple[Optional[float], Optional[str]]:
337
+ """(reward, None) or (None, why-masked). Timeouts / infra deaths carry NO signal."""
338
+ reasons = list(ver.get("reasons") or [])
339
+ if ver.get("accepted"):
340
+ return 1.0, None
341
+ hit = [r for r in reasons if r in no_signal]
342
+ if hit:
343
+ return None, hit[0]
344
+ return 0.0, None
345
+
346
+
347
+ def build_samples(gens: Sequence[dict], vers: dict, cfg: RLConfig) -> tuple[list[Sample], dict]:
348
+ """Join generations with verdicts -> trainable samples + telemetry.
349
+
350
+ Dead groups (all-rewards-equal over the VALID samples) are dropped in signed modes; `ei` keeps the
351
+ positives of all-solved groups when `batch.ei_keep_all_solved`. Masked (no-signal) samples are removed
352
+ before the baseline is computed.
353
+ """
354
+ b = cfg.batch
355
+ stats = {"samples": len(gens), "masked": collections_counter(), "groups": 0, "groups_too_few_valid": 0,
356
+ "dropped_overlength": 0, "accepted": 0, "valid": 0}
357
+ by_group: dict[str, list[Sample]] = {}
358
+ for g in gens:
359
+ validate_rl_generation(g)
360
+ key = (g["problem_id"], g["sample_idx"], g["round"], g["cap_tokens"])
361
+ v = vers.get(key)
362
+ if v is None:
363
+ raise RLConfigError(f"{key}: generation has no verification record (verify before training)")
364
+ r, why = reward_of(v, b.no_signal_reasons)
365
+ if r is None:
366
+ stats["masked"][why] += 1
367
+ continue
368
+ s = Sample(key, g["problem_id"], list(g["prompt_ids"]), list(g["completion_ids"]),
369
+ [float(x) for x in g["sampling_logprobs"]], r)
370
+ if b.max_total_len and s.total_len > b.max_total_len:
371
+ stats["dropped_overlength"] += 1
372
+ continue
373
+ stats["valid"] += 1
374
+ stats["accepted"] += int(r == 1.0)
375
+ by_group.setdefault(g["problem_id"], []).append(s)
376
+ fstats = GroupFilterStats(max_possible_score=1.0)
377
+ out: list[Sample] = []
378
+ for pid in sorted(by_group):
379
+ grp = sorted(by_group[pid], key=lambda s: s.key)
380
+ stats["groups"] += 1
381
+ if len(grp) < b.min_valid_per_group:
382
+ stats["groups_too_few_valid"] += 1
383
+ continue
384
+ rewards = np.array([s.reward for s in grp])
385
+ dead = fstats.observe(rewards)
386
+ if cfg.loss.mode == "ei":
387
+ keep = [s for s in grp if s.reward == 1.0]
388
+ all_solved = bool(np.all(rewards == 1.0))
389
+ if not keep or (all_solved and not b.ei_keep_all_solved):
390
+ continue
391
+ for s in keep:
392
+ s.adv = 1.0
393
+ out.extend(keep)
394
+ continue
395
+ if dead:
396
+ continue
397
+ mean = float(rewards.mean())
398
+ for s in grp:
399
+ s.adv = s.reward - mean
400
+ out.extend(grp)
401
+ stats["masked"] = dict(stats["masked"])
402
+ stats["group_filter"] = fstats.as_log_dict()
403
+ stats["kept_samples"] = len(out)
404
+ stats["kept_resp_tokens"] = sum(s.n_resp for s in out)
405
+ return out, stats
406
+
407
+
408
+ def collections_counter():
409
+ import collections
410
+
411
+ return collections.Counter()
412
+
413
+
414
+ # ------------------------------------------------------------------------------- sharding / packing
415
+
416
+
417
+ def shard_samples(samples: Sequence[Sample], world: int) -> list[list[Sample]]:
418
+ """Deterministic length-balanced split (longest-first onto the lightest rank)."""
419
+ order = sorted(samples, key=lambda s: (-s.total_len, s.key))
420
+ loads = [0] * world
421
+ shards: list[list[Sample]] = [[] for _ in range(world)]
422
+ for s in order:
423
+ r = min(range(world), key=lambda i: (loads[i], i))
424
+ shards[r].append(s)
425
+ loads[r] += s.total_len
426
+ return shards
427
+
428
+
429
+ @dataclass
430
+ class Micro:
431
+ input_ids: torch.Tensor # [B, T]
432
+ attention_mask: torch.Tensor # [B, T]
433
+ resp: torch.Tensor # [B, T-1] bool, label space
434
+ behaviour: torch.Tensor # [B, T-1]
435
+ adv: torch.Tensor # [B]
436
+ dummy: bool = False
437
+
438
+ @property
439
+ def n_resp(self) -> int:
440
+ return 0 if self.dummy else int(self.resp.sum())
441
+
442
+
443
+ def pack_micros(samples: Sequence[Sample], micro_tokens: int, pad_id: int = 0, dtype=torch.float32) -> list[Micro]:
444
+ """Token-budget micro-batches (rows x padded max length <= micro_tokens; a longer lone row gets its own)."""
445
+ rows = sorted(samples, key=lambda s: (s.total_len, s.key))
446
+ micros: list[Micro] = []
447
+ cur: list[Sample] = []
448
+ cur_max = 0
449
+ for s in rows:
450
+ m = max(cur_max, s.total_len)
451
+ if cur and m * (len(cur) + 1) > micro_tokens:
452
+ micros.append(_collate(cur, pad_id, dtype))
453
+ cur, m = [], s.total_len
454
+ cur.append(s)
455
+ cur_max = m
456
+ if cur:
457
+ micros.append(_collate(cur, pad_id, dtype))
458
+ return micros
459
+
460
+
461
+ def _collate(rows: Sequence[Sample], pad_id: int, dtype) -> Micro:
462
+ T = max(s.total_len for s in rows)
463
+ B = len(rows)
464
+ ids = torch.full((B, T), pad_id, dtype=torch.int64)
465
+ att = torch.zeros((B, T), dtype=torch.int64)
466
+ resp = torch.zeros((B, T - 1), dtype=torch.bool)
467
+ beh = torch.zeros((B, T - 1), dtype=dtype)
468
+ adv = torch.zeros(B, dtype=dtype)
469
+ for i, s in enumerate(rows):
470
+ n = s.total_len
471
+ ids[i, :n] = torch.tensor(s.prompt_ids + s.completion_ids)
472
+ att[i, :n] = 1
473
+ p = len(s.prompt_ids)
474
+ resp[i, p - 1 : n - 1] = True # label t+1 in the completion
475
+ beh[i, p - 1 : n - 1] = torch.tensor(s.behaviour_lp, dtype=dtype)
476
+ adv[i] = s.adv
477
+ return Micro(ids, att, resp, beh, adv)
478
+
479
+
480
+ def dummy_micro(like: Optional[Micro], pad_id: int = 0, dtype=torch.float32) -> Micro:
481
+ """A micro that trains on nothing but runs the same forward/backward (so every rank issues the same
482
+ collectives): one row, empty response mask, zero advantage."""
483
+ if like is not None:
484
+ n = int(like.attention_mask[0].sum())
485
+ ids = like.input_ids[:1, :n].clone()
486
+ else:
487
+ ids = torch.full((1, 2), pad_id, dtype=torch.int64)
488
+ T = ids.shape[1]
489
+ return Micro(ids, torch.ones_like(ids), torch.zeros((1, T - 1), dtype=torch.bool),
490
+ torch.zeros((1, T - 1), dtype=dtype), torch.zeros(1, dtype=dtype), dummy=True)
491
+
492
+
493
+ # ---------------------------------------------------------------------------------------- the loss
494
+
495
+
496
+ def token_loss(new_lp: torch.Tensor, behaviour: torch.Tensor, adv: torch.Tensor, resp: torch.Tensor,
497
+ cfg: LossCfg) -> tuple[torch.Tensor, dict]:
498
+ """Unreduced per-token loss [B, T-1] (zero off the response mask) + diagnostic tensors.
499
+
500
+ ratio = exp(new - behaviour): importance weight against the BEHAVIOUR policy (U04-3).
501
+ """
502
+ m = resp.to(new_lp.dtype)
503
+ A = adv.to(new_lp.dtype).unsqueeze(-1).expand_as(new_lp)
504
+ diff = (new_lp - behaviour).clamp(-20.0, 20.0)
505
+ ratio = torch.exp(diff)
506
+ clipfrac = torch.zeros_like(new_lp)
507
+ row_w = torch.ones(new_lp.shape[0], dtype=new_lp.dtype, device=new_lp.device)
508
+ if cfg.mode == "ei":
509
+ loss = -new_lp
510
+ else:
511
+ if cfg.mode == "cispo":
512
+ loss, clipfrac = cispo_policy_loss(new_lp, ratio, A, cfg.eps_high, cfg.eps_low)
513
+ else: # dr_grpo
514
+ l1 = -A * ratio
515
+ l2 = -A * torch.clamp(ratio, 1.0 - cfg.clip_lower, 1.0 + cfg.clip_higher)
516
+ loss = torch.max(l1, l2)
517
+ clipfrac = (l2 > l1).to(new_lp.dtype)
518
+ if cfg.neg_seq_ratio_floor is not None:
519
+ n = m.sum(-1).clamp_min(1.0)
520
+ geo = torch.exp((diff.detach() * m).sum(-1) / n)
521
+ drop = (adv < 0) & (geo < cfg.neg_seq_ratio_floor)
522
+ row_w = (~drop).to(new_lp.dtype)
523
+ loss = loss * m * row_w.unsqueeze(-1)
524
+ return loss, {"ratio": ratio.detach(), "clipfrac": clipfrac.detach() * m, "row_w": row_w.detach()}
525
+
526
+
527
+ def policy_logprobs(model, mb: Micro, cfg: LossCfg) -> torch.Tensor:
528
+ """Label-aligned [B, T-1] logprobs of the batch's own tokens under the CURRENT policy."""
529
+ if cfg.fp32_head:
530
+ return fp32_head_forward(model, mb.input_ids, mb.attention_mask, cfg.temperature, cfg.fp32_head_chunk_tokens)
531
+ out = model(input_ids=mb.input_ids, attention_mask=mb.attention_mask)
532
+ logits = getattr(out, "logits", out)
533
+ if cfg.temperature != 1.0:
534
+ logits = logits / cfg.temperature
535
+ return log_softmax_and_gather(logits[:, :-1], mb.input_ids[:, 1:])
536
+
537
+
538
+ # ------------------------------------------------------------------------------- distributed bits
539
+
540
+
541
+ def _dist_on() -> bool:
542
+ import torch.distributed as dist
543
+
544
+ return dist.is_available() and dist.is_initialized()
545
+
546
+
547
+ def world_size() -> int:
548
+ import torch.distributed as dist
549
+
550
+ return dist.get_world_size() if _dist_on() else 1
551
+
552
+
553
+ def all_reduce_ints(vals: list[int], op: str = "sum") -> list[int]:
554
+ if not _dist_on():
555
+ return [int(v) for v in vals]
556
+ import torch.distributed as dist
557
+
558
+ from align.utils.dist import gloo_group
559
+
560
+ g = gloo_group() or dist.group.WORLD
561
+ t = torch.tensor([int(v) for v in vals], dtype=torch.int64)
562
+ dist.all_reduce(t, op=dist.ReduceOp.SUM if op == "sum" else dist.ReduceOp.MAX, group=g)
563
+ return [int(v) for v in t.tolist()]
564
+
565
+
566
+ def all_reduce_floats(vals: list[float]) -> list[float]:
567
+ if not _dist_on():
568
+ return [float(v) for v in vals]
569
+ import torch.distributed as dist
570
+
571
+ from align.utils.dist import gloo_group
572
+
573
+ g = gloo_group() or dist.group.WORLD
574
+ t = torch.tensor([float(v) for v in vals], dtype=torch.float64)
575
+ dist.all_reduce(t, op=dist.ReduceOp.SUM, group=g)
576
+ return [float(v) for v in t.tolist()]
577
+
578
+
579
+ def norm_count(local_resp_tokens: int, cfg: LossCfg, expect_total: Optional[int] = None) -> float:
580
+ """The explicit loss denominator N (module docstring). Global = SUM over ranks of the local response-token
581
+ counts, verified against the full-list count when the caller can supply it."""
582
+ if cfg.norm == "constant":
583
+ return float(cfg.constant)
584
+ total = all_reduce_ints([local_resp_tokens])[0]
585
+ if expect_total is not None and total != expect_total:
586
+ raise RuntimeError(f"global token count {total} != full-batch count {expect_total}: ranks hold different data")
587
+ if total <= 0:
588
+ raise RuntimeError("global response-token count is 0")
589
+ return float(total)
590
+
591
+
592
+ def _is_sharded(params: Sequence[torch.nn.Parameter]) -> bool:
593
+ return any(hasattr(p, "to_local") for p in params)
594
+
595
+
596
+ def accumulate_gradients(model, micros: Sequence[Micro], cfg: LossCfg, denom: float,
597
+ params: Sequence[torch.nn.Parameter], pad_id: int = 0) -> dict:
598
+ """Forward+backward over this rank's micro-batches with the GLOBAL denominator; returns summed stats.
599
+
600
+ Every rank runs the same number of micro-steps (agreed by MAX; short ranks run zero-weight dummies).
601
+ Gradient scale: loss_i / N. FSDP2 reduce-scatter AVERAGES over ranks, so a sharded model multiplies by the
602
+ world size; a replicated model (CPU tests, single GPU) sums gradients explicitly with an all-reduce SUM.
603
+ Either way grad = d/dtheta [ sum over ALL ranks/micros/tokens of per-token loss / N ].
604
+ """
605
+ world = world_size()
606
+ n_agreed = all_reduce_ints([len(micros)], "max")[0]
607
+ sharded = _is_sharded(params)
608
+ scale = float(world) if (sharded and world > 1) else 1.0
609
+ like = micros[0] if micros else None
610
+ dtype = like.behaviour.dtype if like is not None else torch.float32
611
+ acc = {"loss_sum": 0.0, "resp_tokens": 0.0, "clip_tokens": 0.0, "ratio_sum": 0.0, "rows": 0.0,
612
+ "rows_masked_negseq": 0.0, "mismatch_abs_sum": 0.0, "adv_pos_rows": 0.0, "entropy_proxy": 0.0}
613
+ for i in range(n_agreed):
614
+ mb = micros[i] if i < len(micros) else dummy_micro(like, pad_id, dtype)
615
+ new_lp = policy_logprobs(model, mb, cfg)
616
+ tok, d = token_loss(new_lp, mb.behaviour.to(new_lp.dtype), mb.adv, mb.resp, cfg)
617
+ loss = tok.sum() / denom * scale
618
+ loss.backward()
619
+ with torch.no_grad():
620
+ m = mb.resp.to(torch.float64)
621
+ acc["loss_sum"] += float(tok.detach().double().sum())
622
+ acc["resp_tokens"] += float(m.sum())
623
+ acc["clip_tokens"] += float((d["clipfrac"].double() * m).sum())
624
+ acc["ratio_sum"] += float((d["ratio"].double() * m).sum())
625
+ acc["mismatch_abs_sum"] += float(((new_lp.detach() - mb.behaviour.to(new_lp.dtype)).abs().double() * m).sum())
626
+ if not mb.dummy:
627
+ acc["rows"] += float(mb.input_ids.shape[0])
628
+ acc["rows_masked_negseq"] += float((d["row_w"] == 0).sum())
629
+ acc["adv_pos_rows"] += float((mb.adv > 0).sum())
630
+ if world > 1 and not sharded:
631
+ import torch.distributed as dist
632
+
633
+ from align.utils.dist import gloo_group
634
+
635
+ g = gloo_group() or dist.group.WORLD
636
+ for p in params:
637
+ if p.grad is None:
638
+ p.grad = torch.zeros_like(p)
639
+ dist.all_reduce(p.grad, op=dist.ReduceOp.SUM, group=g)
640
+ keys = sorted(acc)
641
+ red = all_reduce_floats([acc[k] for k in keys])
642
+ return dict(zip(keys, red))
643
+
644
+
645
+ def summarize(stats: dict, denom: float, grad_norm: float, cfg: LossCfg) -> dict:
646
+ t = max(stats["resp_tokens"], 1.0)
647
+ return {
648
+ "loss": stats["loss_sum"] / denom,
649
+ "denominator": denom,
650
+ "resp_tokens": stats["resp_tokens"],
651
+ "rows": stats["rows"],
652
+ "clipfrac": stats["clip_tokens"] / t,
653
+ "ratio_mean": stats["ratio_sum"] / t,
654
+ # engine mismatch |log pi_current(=old at step 0) - log pi_behaviour|, mean over response tokens
655
+ "old_vs_behaviour_abs_logratio": stats["mismatch_abs_sum"] / t,
656
+ "negseq_masked_rows": stats["rows_masked_negseq"],
657
+ "grad_norm": grad_norm,
658
+ "loss_mode": cfg.mode,
659
+ }
code/align/rl/lean_rl_run.py ADDED
@@ -0,0 +1,716 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Lean RL run loop: iteration stages, atomic checkpoints, PEFT export, budget ledger, backends.
2
+
3
+ iteration k (policy = ckpt/iter_k's adapter):
4
+ gen generator.generate -> iters/iter_k/gens.jsonl (CURRENT policy; behaviour logprobs recorded)
5
+ verify verifier.verify -> iters/iter_k/ver.jsonl
6
+ train trainer.train(k) -> ckpt/iter_{k+1}/ (adapter, optimizer, RNG, stream, ledger)
7
+
8
+ Every stage is idempotent and commits with a marker (`gen.done`, `ver.done`) or a directory rename
9
+ (checkpoints), so a kill at any point resumes into the same state: a killed `train` leaves only a `.tmp_*`
10
+ directory, which is deleted, and the stage re-runs from the same gens/ver files and the same ckpt k.
11
+
12
+ GPU HANDOFF. The default real flow keeps the loop process CPU-only: generation (vLLM, tp=4) and training
13
+ (torchrun, FSDP2) are separate child processes that run one after the other, so neither holds the GPUs while the
14
+ other runs; the loop itself never imports a model. `InProcessTrainer` (tests, single-device runs) calls
15
+ `train_iteration` directly. This module imports torch/numpy/safetensors/stdlib/align only.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import contextlib
21
+ import hashlib
22
+ import json
23
+ import os
24
+ import shutil
25
+ import subprocess
26
+ import sys
27
+ import time
28
+ from typing import Callable, Optional
29
+
30
+ import torch
31
+
32
+ from align.rl import lean_rl as L
33
+ from align.utils.dist import capture_rng_state, restore_rng_state
34
+
35
+ ADAPTER_NAME_PEFT = "adapter_model.safetensors"
36
+
37
+
38
+ # ------------------------------------------------------------------------------------ fs helpers
39
+
40
+
41
+ def fsync_dir(path: str) -> None:
42
+ fd = os.open(path, os.O_RDONLY)
43
+ try:
44
+ os.fsync(fd)
45
+ finally:
46
+ os.close(fd)
47
+
48
+
49
+ def atomic_write_text(path: str, text: str) -> None:
50
+ tmp = path + ".partial"
51
+ with open(tmp, "w") as f:
52
+ f.write(text)
53
+ f.flush()
54
+ os.fsync(f.fileno())
55
+ os.replace(tmp, path)
56
+
57
+
58
+ def read_jsonl(path: str) -> list[dict]:
59
+ out = []
60
+ with open(path, encoding="utf-8") as f:
61
+ for n, line in enumerate(f, 1):
62
+ if line.strip():
63
+ try:
64
+ out.append(json.loads(line))
65
+ except json.JSONDecodeError as e:
66
+ raise ValueError(f"{path}:{n}: {e}") from e
67
+ return out
68
+
69
+
70
+ def write_jsonl_atomic(path: str, rows) -> None:
71
+ atomic_write_text(path, "".join(json.dumps(r, sort_keys=True) + "\n" for r in rows))
72
+
73
+
74
+ def sha256_file(path: str) -> str:
75
+ h = hashlib.sha256()
76
+ with open(path, "rb") as f:
77
+ for c in iter(lambda: f.read(1 << 20), b""):
78
+ h.update(c)
79
+ return h.hexdigest()
80
+
81
+
82
+ # --------------------------------------------------------------------------------- PEFT export
83
+
84
+
85
+ def peft_key_map(qcfg) -> dict[str, str]:
86
+ """align module path -> HF module path, for every projection LoRA can target."""
87
+ from align.model.qwen3_checkpoint import _table
88
+
89
+ out = {}
90
+ for hf_w, al_w in _table(qcfg).items():
91
+ if hf_w.endswith(".weight") and ".layers." in hf_w:
92
+ out[al_w[: -len(".weight")]] = hf_w[: -len(".weight")]
93
+ return out
94
+
95
+
96
+ def lora_to_peft_tensors(lora_state: dict, qcfg) -> dict[str, torch.Tensor]:
97
+ m = peft_key_map(qcfg)
98
+ out = {}
99
+ for k, v in lora_state.items():
100
+ path, ab = k.rsplit(".", 1)
101
+ if path not in m:
102
+ raise KeyError(f"adapter path {path!r} has no HF counterpart")
103
+ out[f"base_model.model.{m[path]}.lora_{ab}.weight"] = v.detach().to("cpu").contiguous()
104
+ return out
105
+
106
+
107
+ def peft_to_lora_state(tensors: dict, qcfg) -> dict[str, torch.Tensor]:
108
+ inv = {v: k for k, v in peft_key_map(qcfg).items()}
109
+ out = {}
110
+ for k, v in tensors.items():
111
+ assert k.startswith("base_model.model.")
112
+ body = k[len("base_model.model."):]
113
+ hf_path, lora_ab = body.rsplit(".lora_", 1)
114
+ out[f"{inv[hf_path]}.{lora_ab.split('.')[0]}"] = v
115
+ return out
116
+
117
+
118
+ def write_peft_dir(out_dir: str, lora_state: dict, qcfg, lcfg: L.LoraCfg, base_name: str, dtype=torch.float32) -> None:
119
+ """A PEFT adapter directory (adapter_config.json + adapter_model.safetensors), written to a sibling temp dir
120
+ and renamed in, so a reader (vLLM) never sees half of it."""
121
+ import safetensors.torch as st
122
+
123
+ tmp = out_dir.rstrip("/") + ".partial"
124
+ shutil.rmtree(tmp, ignore_errors=True)
125
+ os.makedirs(tmp)
126
+ tens = {k: v.to(dtype) for k, v in lora_to_peft_tensors(lora_state, qcfg).items()}
127
+ st.save_file(tens, os.path.join(tmp, ADAPTER_NAME_PEFT), metadata={"format": "pt"})
128
+ cfg = {
129
+ "peft_type": "LORA", "task_type": "CAUSAL_LM", "r": lcfg.r, "lora_alpha": lcfg.alpha, "lora_dropout": 0.0,
130
+ "bias": "none", "use_rslora": bool(lcfg.rslora), "target_modules": list(lcfg.targets),
131
+ "base_model_name_or_path": base_name, "inference_mode": True, "fan_in_fan_out": False,
132
+ "init_lora_weights": True, "modules_to_save": None,
133
+ }
134
+ with open(os.path.join(tmp, "adapter_config.json"), "w") as f:
135
+ json.dump(cfg, f, indent=1, sort_keys=True)
136
+ f.flush()
137
+ os.fsync(f.fileno())
138
+ if os.path.isdir(out_dir):
139
+ shutil.rmtree(out_dir)
140
+ os.replace(tmp, out_dir)
141
+
142
+
143
+ def read_peft_dir(path: str, qcfg) -> dict[str, torch.Tensor]:
144
+ import safetensors.torch as st
145
+
146
+ return peft_to_lora_state(st.load_file(os.path.join(path, ADAPTER_NAME_PEFT)), qcfg)
147
+
148
+
149
+ def export_merged_checkpoint(model, adapter: str, qcfg, out_dir: str, tokenizer_dir: Optional[str] = None,
150
+ dtype=torch.bfloat16) -> dict:
151
+ """FALLBACK for serving without un-merged LoRA: base+adapter folded into an HF checkpoint. Lossy by
152
+ construction (bf16 cast drops small delta elements; the fidelity report is returned). Collective under FSDP:
153
+ every rank must call; rank 0 writes."""
154
+ from align.model.qwen3_checkpoint import save_hf_qwen3
155
+ from align.ppo.lora import export_merged_state_dict
156
+ from align.utils.dist import is_rank0
157
+
158
+ if L._is_sharded(list(model.parameters())):
159
+ from torch.distributed.checkpoint.state_dict import StateDictOptions, get_model_state_dict
160
+
161
+ sd = get_model_state_dict(model, options=StateDictOptions(full_state_dict=True, cpu_offload=True))
162
+ else:
163
+ sd = {k: v.detach().to("cpu") for k, v in model.state_dict().items()}
164
+ report: dict = {}
165
+ if sd:
166
+ merged = export_merged_state_dict(model, sd, adapter, report=report)
167
+ if is_rank0():
168
+ tmp = out_dir.rstrip("/") + ".partial"
169
+ shutil.rmtree(tmp, ignore_errors=True)
170
+ save_hf_qwen3(merged, tmp, cfg=qcfg, dtype=dtype, tokenizer_dir=tokenizer_dir)
171
+ if os.path.isdir(out_dir):
172
+ shutil.rmtree(out_dir)
173
+ os.replace(tmp, out_dir)
174
+ return report
175
+
176
+
177
+ # --------------------------------------------------------------------------------- checkpoints
178
+
179
+
180
+ def ckpt_path(run_dir: str, k: int) -> str:
181
+ return os.path.join(run_dir, "ckpt", f"iter_{k:06d}")
182
+
183
+
184
+ def latest_complete(run_dir: str) -> Optional[int]:
185
+ root = os.path.join(run_dir, "ckpt")
186
+ if not os.path.isdir(root):
187
+ return None
188
+ best = None
189
+ for name in os.listdir(root):
190
+ if name.startswith("iter_") and os.path.isfile(os.path.join(root, name, "COMPLETE")):
191
+ k = int(name[5:])
192
+ best = k if best is None else max(best, k)
193
+ return best
194
+
195
+
196
+ def clean_partial(run_dir: str) -> list[str]:
197
+ """Delete half-written checkpoints (`.tmp_*`) and incomplete `iter_*` dirs: they look resumable and are not."""
198
+ root = os.path.join(run_dir, "ckpt")
199
+ gone = []
200
+ if not os.path.isdir(root):
201
+ return gone
202
+ for name in sorted(os.listdir(root)):
203
+ p = os.path.join(root, name)
204
+ if name.startswith(".tmp_") or (name.startswith("iter_") and not os.path.isfile(os.path.join(p, "COMPLETE"))):
205
+ shutil.rmtree(p, ignore_errors=True)
206
+ gone.append(name)
207
+ return gone
208
+
209
+
210
+ def _rank() -> int:
211
+ import torch.distributed as dist
212
+
213
+ return dist.get_rank() if L._dist_on() else 0
214
+
215
+
216
+ def _barrier() -> None:
217
+ import torch.distributed as dist
218
+
219
+ if L._dist_on():
220
+ dist.barrier()
221
+
222
+
223
+ def save_checkpoint(run_dir: str, k: int, *, model, optimizer, lora_state: dict, qcfg, cfg: L.RLConfig, ledger: list,
224
+ bases: dict, base_name: str, base_fingerprint: Optional[str]) -> str:
225
+ """Commit checkpoint k atomically: write everything into `.tmp_iter_k`, then rename. `COMPLETE` is the
226
+ last file written inside; the rename is the commit. `lora_state` must already be gathered (collective done
227
+ by the caller on every rank)."""
228
+ final = ckpt_path(run_dir, k)
229
+ tmp = os.path.join(run_dir, "ckpt", f".tmp_iter_{k:06d}")
230
+ r = _rank()
231
+ if r == 0:
232
+ os.makedirs(os.path.join(run_dir, "ckpt"), exist_ok=True)
233
+ shutil.rmtree(tmp, ignore_errors=True)
234
+ os.makedirs(tmp)
235
+ _barrier()
236
+ optim_sd = optimizer_state_for_save(model, optimizer) # collective on every rank
237
+ if r == 0:
238
+ torch.save(_cpu_tree(optim_sd), os.path.join(tmp, "optim.pt"))
239
+ state = {
240
+ "iteration": k, "cfg_fingerprint": cfg.fingerprint(), "world_size": L.world_size(),
241
+ "adapter": {a: t.detach().cpu() for a, t in lora_state.items()}, "rng": capture_rng_state(),
242
+ "stream": L.stream_position(_pool_size(cfg), cfg.prompts_per_iter, k), "ledger": ledger,
243
+ "bases": bases, "base_fingerprint": base_fingerprint,
244
+ }
245
+ torch.save(state, os.path.join(tmp, "state.pt"))
246
+ write_peft_dir(os.path.join(tmp, "adapter"), lora_state, qcfg, cfg.lora, base_name)
247
+ write_jsonl_atomic(os.path.join(tmp, "ledger.jsonl"), ledger)
248
+ atomic_write_text(os.path.join(tmp, "COMPLETE"), json.dumps({"iteration": k, "t": time.time()}))
249
+ _barrier()
250
+ if r == 0:
251
+ if os.path.isdir(final):
252
+ shutil.rmtree(final)
253
+ fsync_dir(tmp)
254
+ os.replace(tmp, final)
255
+ fsync_dir(os.path.dirname(final))
256
+ write_jsonl_atomic(os.path.join(run_dir, "ledger.jsonl"), ledger)
257
+ _prune(run_dir, cfg.keep_checkpoints)
258
+ _barrier()
259
+ return final
260
+
261
+
262
+ def _prune(run_dir: str, keep: int) -> None:
263
+ root = os.path.join(run_dir, "ckpt")
264
+ done = sorted(n for n in os.listdir(root) if n.startswith("iter_") and os.path.isfile(os.path.join(root, n, "COMPLETE")))
265
+ for n in done[:-max(keep, 1)]:
266
+ shutil.rmtree(os.path.join(root, n), ignore_errors=True)
267
+
268
+
269
+ def load_checkpoint_state(run_dir: str, k: int, cfg: L.RLConfig) -> dict:
270
+ d = ckpt_path(run_dir, k)
271
+ if not os.path.isfile(os.path.join(d, "COMPLETE")):
272
+ raise FileNotFoundError(f"no complete checkpoint {d}")
273
+ st = torch.load(os.path.join(d, "state.pt"), weights_only=True)
274
+ if st["cfg_fingerprint"] != cfg.fingerprint():
275
+ raise RuntimeError("checkpoint was written under a different config fingerprint; refusing to resume")
276
+ return st
277
+
278
+
279
+ def optimizer_state_for_save(model, optimizer) -> dict:
280
+ """Optimizer state in a world-size-independent form. Replicated model: the local state (identical on every
281
+ rank). FSDP2-sharded: the FULL state via DCP (a collective; rank 0 receives it, the others {})."""
282
+ if L._is_sharded(list(model.parameters())):
283
+ from torch.distributed.checkpoint.state_dict import StateDictOptions, get_optimizer_state_dict
284
+
285
+ return get_optimizer_state_dict(model, optimizer, options=StateDictOptions(full_state_dict=True, cpu_offload=True))
286
+ return optimizer.state_dict()
287
+
288
+
289
+ def load_optimizer(run_dir: str, k: int, model, optimizer) -> None:
290
+ p = os.path.join(ckpt_path(run_dir, k), "optim.pt")
291
+ sd = torch.load(p, weights_only=True)
292
+ if L._is_sharded(list(model.parameters())):
293
+ from torch.distributed.checkpoint.state_dict import StateDictOptions, set_optimizer_state_dict
294
+
295
+ set_optimizer_state_dict(model, optimizer, optim_state_dict=sd,
296
+ options=StateDictOptions(full_state_dict=True, broadcast_from_rank0=False))
297
+ else:
298
+ optimizer.load_state_dict(sd)
299
+
300
+
301
+ def _cpu_tree(x):
302
+ if isinstance(x, torch.Tensor):
303
+ return x.detach().cpu()
304
+ if isinstance(x, dict):
305
+ return {k: _cpu_tree(v) for k, v in x.items()}
306
+ if isinstance(x, (list, tuple)):
307
+ return type(x)(_cpu_tree(v) for v in x)
308
+ return x
309
+
310
+
311
+ def _pool_size(cfg: L.RLConfig) -> int:
312
+ return len(read_jsonl(cfg.problems))
313
+
314
+
315
+ def load_problems(path: str) -> list[dict]:
316
+ rows = read_jsonl(path)
317
+ ids = [r["problem_id"] for r in rows]
318
+ if len(set(ids)) != len(ids):
319
+ raise ValueError(f"{path}: duplicate problem_id")
320
+ return rows
321
+
322
+
323
+ # ------------------------------------------------------------------------------- iteration files
324
+
325
+
326
+ def iter_dir(run_dir: str, k: int) -> str:
327
+ return os.path.join(run_dir, "iters", f"iter_{k:04d}")
328
+
329
+
330
+ def stage_marker(d: str, name: str) -> str:
331
+ return os.path.join(d, f"{name}.done")
332
+
333
+
334
+ def add_stage_time(d: str, stage: str, wall_s: float) -> None:
335
+ p = os.path.join(d, "stage_times.json")
336
+ cur = json.load(open(p)) if os.path.exists(p) else {}
337
+ cur[f"{stage}_wall_s"] = cur.get(f"{stage}_wall_s", 0.0) + wall_s
338
+ cur[f"{stage}_attempts"] = cur.get(f"{stage}_attempts", 0) + 1
339
+ atomic_write_text(p, json.dumps(cur, sort_keys=True))
340
+
341
+
342
+ def ledger_entry(cfg: L.RLConfig, k: int, d: str, bstats: dict, metrics: dict, train_wall_s: float,
343
+ padded_tokens: float, prev: list) -> dict:
344
+ gens = read_jsonl(os.path.join(d, "gens.jsonl"))
345
+ vers = read_jsonl(os.path.join(d, "ver.jsonl"))
346
+ st = json.load(open(os.path.join(d, "stage_times.json"))) if os.path.exists(os.path.join(d, "stage_times.json")) else {}
347
+ gen_tokens = sum(len(g["completion_ids"]) for g in gens)
348
+ ver_s = sum(float(v.get("verify_s", 0.0)) for v in vers)
349
+ e = {
350
+ "iteration": k, "loss_mode": cfg.loss.mode, "prompts": cfg.prompts_per_iter, "samples": len(gens),
351
+ "generated_tokens": gen_tokens, "prompt_tokens": sum(len(g["prompt_ids"]) for g in gens),
352
+ "verifier_seconds": ver_s, "verifier_wall_s": st.get("verify_wall_s", 0.0),
353
+ "generate_wall_s": st.get("gen_wall_s", 0.0), "train_wall_s": train_wall_s,
354
+ "train_tokens": metrics.get("resp_tokens_total", 0.0), "train_padded_tokens": padded_tokens,
355
+ "stage_attempts": {s: st.get(f"{s}_attempts", 0) for s in ("gen", "verify")},
356
+ "updated": bool(metrics.get("updated", False)), "batch": bstats, "metrics": metrics,
357
+ "bases": metrics.get("bases", {}),
358
+ }
359
+ e["wall_s"] = e["generate_wall_s"] + e["verifier_wall_s"] + train_wall_s
360
+ keys = ("generated_tokens", "verifier_seconds", "train_tokens", "train_padded_tokens", "wall_s")
361
+ e["cumulative"] = {c: (prev[-1]["cumulative"][c] if prev else 0.0) + e[c] for c in keys}
362
+ return e
363
+
364
+
365
+ # ---------------------------------------------------------------------------- the train iteration
366
+
367
+
368
+ def bases_stamp(cfg: L.RLConfig, gens: list) -> dict:
369
+ g0 = gens[0] if gens else {}
370
+ s = g0.get("sampling", {})
371
+ return {
372
+ "behaviour": {"source": "sampler", "field": "sampling_logprobs", "temperature": s.get("temperature"),
373
+ "top_p": s.get("top_p"), "top_k": s.get("top_k"), "model_rev": g0.get("model_rev"),
374
+ "serving": (g0.get("adapter") or {}).get("serving", "merged_or_base")},
375
+ "old": {"source": "trainer", "weights": "rollout-time (= current at the first optimizer step)",
376
+ "role": "mismatch telemetry only; NOT the importance denominator", "fp32_head": cfg.loss.fp32_head},
377
+ "current": {"source": "trainer", "differentiable": True, "fp32_head": cfg.loss.fp32_head,
378
+ "temperature": cfg.loss.temperature},
379
+ "importance_ratio": "pi_current / pi_behaviour",
380
+ }
381
+
382
+
383
+ def lr_at(cfg: L.RLConfig, k: int) -> float:
384
+ w = cfg.optim.warmup_iters
385
+ return cfg.optim.lr * (min(1.0, (k + 1) / w) if w > 0 else 1.0)
386
+
387
+
388
+ def train_iteration(cfg: L.RLConfig, k: int, *, model, params, optimizer, qcfg, pad_id: int, base_name: str,
389
+ base_fingerprint: Optional[str] = None, tokenizer_dir: Optional[str] = None) -> dict:
390
+ """One training iteration on iteration k's cached gens/ver, committing checkpoint k+1.
391
+
392
+ `model` carries the injected LoRA adapter `cfg.lora.adapter`; `params` are its trainable parameters;
393
+ `optimizer` is built over them. State is loaded from checkpoint k (adapter, optimizer, RNG)."""
394
+ from align.ppo.lora import load_lora_state_dict, lora_state_dict
395
+
396
+ run = cfg.run_dir
397
+ d = iter_dir(run, k)
398
+ st = load_checkpoint_state(run, k, cfg)
399
+ load_lora_state_dict(model, cfg.lora.adapter, st["adapter"], strict=True)
400
+ load_optimizer(run, k, model, optimizer)
401
+ restore_rng_state(st["rng"])
402
+ ledger = list(st["ledger"])
403
+ if len(ledger) != k:
404
+ raise RuntimeError(f"checkpoint {k} carries {len(ledger)} ledger rows")
405
+ pos = L.stream_position(_pool_size(cfg), cfg.prompts_per_iter, k)
406
+ if st["stream"] != pos:
407
+ raise RuntimeError(f"prompt stream position {st['stream']} != recomputed {pos}")
408
+
409
+ gens = read_jsonl(os.path.join(d, "gens.jsonl"))
410
+ vers = {(v["problem_id"], v["sample_idx"], v["round"], v["cap_tokens"]): v for v in read_jsonl(os.path.join(d, "ver.jsonl"))}
411
+ samples, bstats = L.build_samples(gens, vers, cfg)
412
+ world = L.world_size()
413
+ r = _rank()
414
+ t0 = time.perf_counter()
415
+ metrics: dict = {"updated": False, "bases": bases_stamp(cfg, gens), "resp_tokens_total": 0.0}
416
+ padded = 0.0
417
+ if samples:
418
+ dtype = next(iter(params)).dtype
419
+ steps = cfg.optim.steps_per_iteration
420
+ groups = sorted({s.group for s in samples})
421
+ chunks = [[s for s in samples if groups.index(s.group) % steps == i] for i in range(steps)]
422
+ step_metrics = []
423
+ for i, chunk in enumerate(chunks):
424
+ if not chunk:
425
+ continue
426
+ mine = L.shard_samples(chunk, world)[r]
427
+ micros = L.pack_micros(mine, cfg.batch.micro_tokens, pad_id, dtype)
428
+ expect = sum(s.n_resp for s in chunk)
429
+ denom = L.norm_count(sum(m.n_resp for m in micros), cfg.loss, expect)
430
+ for p in params:
431
+ p.grad = None
432
+ t_step = time.perf_counter()
433
+ acc = L.accumulate_gradients(model, micros, cfg.loss, denom, params, pad_id)
434
+ total = torch.nn.utils.clip_grad_norm_(list(params), cfg.loss.max_grad_norm)
435
+ if hasattr(total, "full_tensor"):
436
+ total = total.full_tensor()
437
+ for g in optimizer.param_groups:
438
+ g["lr"] = lr_at(cfg, k)
439
+ optimizer.step()
440
+ if torch.cuda.is_available() and torch.cuda.is_initialized():
441
+ torch.cuda.synchronize()
442
+ for p in params:
443
+ p.grad = None
444
+ padded += L.all_reduce_floats([float(sum(m.input_ids.numel() for m in micros))])[0]
445
+ sm = L.summarize(acc, denom, float(total), cfg.loss)
446
+ sm["step_wall_s"] = time.perf_counter() - t_step
447
+ sm["n_micro_this_rank"] = len(micros)
448
+ if torch.cuda.is_available() and torch.cuda.is_initialized():
449
+ sm["cuda_peak_alloc_gb"] = torch.cuda.max_memory_allocated() / 2**30
450
+ sm["cuda_peak_reserved_gb"] = torch.cuda.max_memory_reserved() / 2**30
451
+ step_metrics.append(sm)
452
+ metrics.update(step_metrics[-1])
453
+ metrics["steps"] = step_metrics
454
+ metrics["updated"] = True
455
+ metrics["resp_tokens_total"] = float(sum(m["resp_tokens"] for m in step_metrics))
456
+ train_wall = time.perf_counter() - t0
457
+ lora = lora_state_dict(model, cfg.lora.adapter) # collective under FSDP
458
+ entry = ledger_entry(cfg, k, d, bstats, metrics, train_wall, padded, ledger)
459
+ ledger.append(entry)
460
+ if cfg.export_merged:
461
+ entry["merged_export"] = export_merged_checkpoint(model, cfg.lora.adapter, qcfg,
462
+ os.path.join(ckpt_path(run, k + 1) + ".merged_tmp"), tokenizer_dir)
463
+ save_checkpoint(run, k + 1, model=model, optimizer=optimizer, lora_state=lora, qcfg=qcfg, cfg=cfg, ledger=ledger,
464
+ bases=metrics["bases"], base_name=base_name, base_fingerprint=base_fingerprint)
465
+ if cfg.export_merged and _rank() == 0:
466
+ src = ckpt_path(run, k + 1) + ".merged_tmp"
467
+ if os.path.isdir(src):
468
+ os.replace(src, os.path.join(ckpt_path(run, k + 1), "merged"))
469
+ _barrier()
470
+ return entry
471
+
472
+
473
+ def init_checkpoint(cfg: L.RLConfig, *, model, optimizer, qcfg, base_name: str, base_fingerprint: Optional[str] = None) -> None:
474
+ """Checkpoint 0 = the freshly initialised adapter (B = 0, so it IS the base policy) and a fresh optimizer."""
475
+ from align.ppo.lora import lora_state_dict
476
+
477
+ if latest_complete(cfg.run_dir) is not None:
478
+ return
479
+ save_checkpoint(cfg.run_dir, 0, model=model, optimizer=optimizer, lora_state=lora_state_dict(model, cfg.lora.adapter), qcfg=qcfg,
480
+ cfg=cfg, ledger=[], bases={}, base_fingerprint=base_fingerprint, base_name=base_name)
481
+
482
+
483
+ def make_optimizer(cfg: L.RLConfig, params):
484
+ return torch.optim.AdamW(list(params), lr=cfg.optim.lr, betas=tuple(cfg.optim.betas), eps=cfg.optim.eps,
485
+ weight_decay=cfg.optim.weight_decay)
486
+
487
+
488
+ # ----------------------------------------------------------------------------- real policy build
489
+
490
+
491
+ def build_real_policy(cfg: L.RLConfig) -> tuple:
492
+ """Rank-serial load of the frozen bf16 base -> GPU -> LoRA (identical init on every rank, B = 0) -> adapter-only
493
+ trainable -> activation checkpointing -> FSDP2 (world > 1). Same recipe as `align.train.qwen3_stage`, without
494
+ its RunConfig. Returns the tuple `InProcessTrainer` expects."""
495
+ import gc
496
+
497
+ from align.model import qwen3 as q3
498
+ from align.model.checkpoint import load_hf_safetensors, resolve_snapshot_dir
499
+ from align.model.qwen3_checkpoint import hf_to_align, read_qwen3_config
500
+ from align.ppo.lora import LoRAConfig, inject_lora, mark_only_adapter_trainable
501
+ from align.train import fsdp
502
+ from align.train.qwen3_stage import base_fingerprint
503
+ from align.utils.dist import barrier, local_rank, rank, world_size
504
+
505
+ snapshot = resolve_snapshot_dir(cfg.model_snapshot)
506
+ qcfg = read_qwen3_config(snapshot).replace(attn_implementation="sdpa")
507
+ device = torch.device("cuda", local_rank())
508
+ torch.cuda.set_device(device)
509
+ model = None
510
+ for r in range(world_size()):
511
+ if rank() == r:
512
+ sd = hf_to_align(load_hf_safetensors(snapshot, dtype=torch.bfloat16), qcfg)
513
+ model = q3.Qwen3ForCausalLM.from_state_dict(qcfg, sd, device="cuda").to(device)
514
+ del sd
515
+ gc.collect()
516
+ barrier()
517
+ fp = base_fingerprint(model.state_dict()) # whole tensors, before inject/FSDP
518
+ lc = cfg.lora
519
+ gen = torch.Generator().manual_seed(cfg.seed) # identical A on every rank: FSDP2 shards, never broadcasts
520
+ hit = inject_lora(model, LoRAConfig(r=lc.r, alpha=lc.alpha, target_modules=tuple(lc.targets), rslora=lc.rslora),
521
+ adapter=lc.adapter, generator=gen)
522
+ expected = len(lc.targets) * qcfg.n_layers
523
+ if len(hit) != expected:
524
+ raise RuntimeError(f"LoRA hit {len(hit)} layers, expected {expected}")
525
+ mark_only_adapter_trainable(model, lc.adapter)
526
+ fsdp.apply_activation_checkpointing(model)
527
+ if world_size() > 1:
528
+ fsdp.apply_fsdp(model)
529
+ params = [p for p in model.parameters() if p.requires_grad]
530
+ pad = qcfg.pad_token_id if qcfg.pad_token_id is not None else (qcfg.eos_token_id or 0)
531
+ return model, params, qcfg, pad, snapshot, fp, snapshot
532
+
533
+
534
+ # --------------------------------------------------------------------------------------- backends
535
+
536
+
537
+ class InProcessTrainer:
538
+ """Trainer backend that runs `train_iteration` in this process. `builder()` -> (model, params, qcfg, pad_id,
539
+ base_name, base_fp, tokenizer_dir)."""
540
+
541
+ def __init__(self, cfg: L.RLConfig, builder: Callable[[], tuple]):
542
+ self.cfg, self.builder = cfg, builder
543
+
544
+ def _build(self):
545
+ model, params, qcfg, pad_id, base_name, base_fp, tok = self.builder()
546
+ return model, params, make_optimizer(self.cfg, params), qcfg, pad_id, base_name, base_fp, tok
547
+
548
+ def init(self) -> None:
549
+ model, params, opt, qcfg, pad_id, base_name, base_fp, _ = self._build()
550
+ init_checkpoint(self.cfg, model=model, optimizer=opt, qcfg=qcfg, base_name=base_name, base_fingerprint=base_fp)
551
+
552
+ def train(self, k: int) -> dict:
553
+ model, params, opt, qcfg, pad_id, base_name, base_fp, tok = self._build()
554
+ return train_iteration(self.cfg, k, model=model, params=params, optimizer=opt, qcfg=qcfg, pad_id=pad_id,
555
+ base_name=base_name, base_fingerprint=base_fp, tokenizer_dir=tok)
556
+
557
+
558
+ def repo_root() -> str:
559
+ return os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
560
+
561
+
562
+ class LeanGenCliGenerator:
563
+ """Real generator: `extras/vllm_rollout/lean_gen.py` as a child process (needs the genharness flags
564
+ `--lora-adapter`, `--top-p`, `--temperature` and `--record-logprobs`)."""
565
+
566
+ def __init__(self, cfg: L.RLConfig, config_path: str = ""):
567
+ self.cfg = cfg
568
+
569
+ def command(self, k: int, d: str, problems_path: str, adapter_dir: str, merged_dir: Optional[str], seed: int) -> list[str]:
570
+ c, ro = self.cfg, self.cfg.rollout
571
+ root = repo_root()
572
+ py = ro.rollout_python if os.path.isabs(ro.rollout_python) else os.path.join(root, ro.rollout_python)
573
+ model = c.model_snapshot
574
+ cmd = [py, os.path.join(root, "extras", "vllm_rollout", "lean_gen.py"), "--problems", problems_path,
575
+ "--out", os.path.join(d, "gens.jsonl"), "--n", str(c.batch.group_size), "--cap-tokens", str(ro.cap_tokens),
576
+ "--round", "0", "--tp", str(ro.tp), "--wait-lock", "--seed", str(seed), "--max-model-len", str(ro.max_model_len),
577
+ "--temperature", str(ro.temperature), "--top-p", "1.0", "--record-logprobs",
578
+ "--label", f"rl-lean-gen-{k}"]
579
+ if ro.adapter_mode == "unmerged":
580
+ cmd += ["--model", model, "--lora-adapter", adapter_dir]
581
+ else:
582
+ if not merged_dir:
583
+ raise L.RLConfigError("adapter_mode=merged_export needs export_merged=true")
584
+ cmd += ["--model", merged_dir]
585
+ return cmd + list(ro.gen_extra_args)
586
+
587
+ def generate(self, k: int, d: str, problems_path: str, adapter_dir: str, merged_dir: Optional[str], seed: int) -> None:
588
+ subprocess.run(self.command(k, d, problems_path, adapter_dir, merged_dir, seed), check=True)
589
+
590
+
591
+ class VerifyCliVerifier:
592
+ def __init__(self, cfg: L.RLConfig):
593
+ self.cfg = cfg
594
+
595
+ def command(self, d: str, problems_path: str) -> list[str]:
596
+ c, root = self.cfg, repo_root()
597
+ py = c.verify_python if os.path.isabs(c.verify_python) else os.path.join(root, c.verify_python)
598
+ return [py, os.path.join(root, "scripts", "p2", "verify_generations.py"), "--generations", os.path.join(d, "gens.jsonl"),
599
+ "--problems", problems_path, "--out", os.path.join(d, "ver.jsonl"), "--workers", str(c.verify_workers),
600
+ "--budget-s", str(c.verify_budget_s)]
601
+
602
+ def verify(self, d: str, problems_path: str) -> None:
603
+ subprocess.run(self.command(d, problems_path), check=True)
604
+
605
+
606
+ class ReplayBackend:
607
+ """Replay cached rollouts + verdicts of another run (K1: positive-only vs signed on the SAME rollouts).
608
+ Rollouts beyond iteration 0 were sampled by the SOURCE arm's policy, so they are off-policy for this arm;
609
+ the importance ratio against the recorded behaviour logprobs keeps the update well defined."""
610
+
611
+ def __init__(self, src_run_dir: str):
612
+ self.src = src_run_dir
613
+
614
+ def generate(self, k, d, problems_path, adapter_dir, merged_dir, seed) -> None:
615
+ shutil.copyfile(os.path.join(iter_dir(self.src, k), "gens.jsonl"), os.path.join(d, "gens.jsonl"))
616
+
617
+ def verify(self, d, problems_path) -> None:
618
+ # k is encoded in d's name; copy the sibling verdicts
619
+ k = int(os.path.basename(d).split("_")[1])
620
+ shutil.copyfile(os.path.join(iter_dir(self.src, k), "ver.jsonl"), os.path.join(d, "ver.jsonl"))
621
+
622
+
623
+ def torchrun_command(cfg_path: str, k: int, nproc: int, overrides=()) -> list[str]:
624
+ return ["torchrun", "--standalone", f"--nproc_per_node={nproc}", os.path.join(repo_root(), "scripts", "p2", "rl_lean.py"),
625
+ "train-iter", cfg_path, "--iteration", str(k), *[f"--set={o}" for o in overrides]]
626
+
627
+
628
+ class TorchrunTrainer:
629
+ def __init__(self, cfg: L.RLConfig, cfg_path: str, nproc: int, overrides=()):
630
+ self.cfg, self.cfg_path, self.nproc, self.overrides = cfg, cfg_path, nproc, tuple(overrides)
631
+
632
+ def init(self) -> None:
633
+ subprocess.run(torchrun_command(self.cfg_path, -1, self.nproc, self.overrides), check=True)
634
+
635
+ def train(self, k: int) -> dict:
636
+ subprocess.run(torchrun_command(self.cfg_path, k, self.nproc, self.overrides), check=True)
637
+ return {}
638
+
639
+
640
+ # ------------------------------------------------------------------------------------- the loop
641
+
642
+
643
+ def derive_seed(seed: int, k: int) -> int:
644
+ return int.from_bytes(hashlib.sha256(f"rl|{seed}|{k}".encode()).digest()[:8], "big") % (2**31)
645
+
646
+
647
+ def run_loop(cfg: L.RLConfig, generator, verifier, trainer, hook: Optional[Callable[[str, int], None]] = None,
648
+ until: Optional[int] = None) -> list[dict]:
649
+ """Run (or resume) iterations up to `until` (default cfg.iterations). `hook(stage, k)` fires after each
650
+ committed stage (tests inject crashes there). Returns the committed ledger."""
651
+ run = cfg.run_dir
652
+ os.makedirs(run, exist_ok=True)
653
+ cfg_json = os.path.join(run, "run_config.json")
654
+ import dataclasses
655
+
656
+ fp = cfg.fingerprint()
657
+ if os.path.exists(cfg_json):
658
+ old = json.load(open(cfg_json))["fingerprint"]
659
+ if old != fp:
660
+ raise RuntimeError(f"{run} was started under config {old[:12]}, this is {fp[:12]}: refusing to mix runs")
661
+ else:
662
+ atomic_write_text(cfg_json, json.dumps({"fingerprint": fp, "config": dataclasses.asdict(cfg)}, sort_keys=True, default=str, indent=1))
663
+ clean_partial(run)
664
+ problems = load_problems(cfg.problems)
665
+ by_id = {p["problem_id"]: p for p in problems}
666
+ ids = [p["problem_id"] for p in problems]
667
+ if latest_complete(run) is None:
668
+ trainer.init()
669
+ k0 = latest_complete(run)
670
+ if k0 is None:
671
+ raise RuntimeError("no checkpoint after init")
672
+ hook = hook or (lambda stage, k: None)
673
+ end = cfg.iterations if until is None else min(until, cfg.iterations)
674
+ for k in range(k0, end):
675
+ d = iter_dir(run, k)
676
+ os.makedirs(d, exist_ok=True)
677
+ probs_path = os.path.join(d, "problems.jsonl")
678
+ if not os.path.exists(probs_path):
679
+ block = L.prompt_block(ids, cfg.seed, cfg.prompts_per_iter, k)
680
+ write_jsonl_atomic(probs_path, [by_id[i] for i in block])
681
+ if not os.path.exists(stage_marker(d, "gen")):
682
+ gp = os.path.join(d, "gens.jsonl")
683
+ adapter_dir = os.path.join(ckpt_path(run, k), "adapter")
684
+ merged = os.path.join(ckpt_path(run, k), "merged") if cfg.export_merged else None
685
+ t0 = time.perf_counter()
686
+ gen = ReplayBackend(cfg.replay_from) if cfg.replay_from else generator
687
+ gen.generate(k, d, probs_path, adapter_dir, merged, derive_seed(cfg.seed, k))
688
+ add_stage_time(d, "gen", time.perf_counter() - t0)
689
+ _check_generations(gp, cfg, [by_id_id["problem_id"] for by_id_id in read_jsonl(probs_path)])
690
+ atomic_write_text(stage_marker(d, "gen"), json.dumps({"sha256": sha256_file(gp)}))
691
+ hook("gen", k)
692
+ if not os.path.exists(stage_marker(d, "verify")):
693
+ vp = os.path.join(d, "ver.jsonl")
694
+ t0 = time.perf_counter()
695
+ (ReplayBackend(cfg.replay_from) if cfg.replay_from else verifier).verify(d, probs_path)
696
+ add_stage_time(d, "verify", time.perf_counter() - t0)
697
+ n_g, n_v = len(read_jsonl(os.path.join(d, "gens.jsonl"))), len(read_jsonl(vp))
698
+ if n_g != n_v:
699
+ raise RuntimeError(f"{n_v} verdicts for {n_g} generations")
700
+ atomic_write_text(stage_marker(d, "verify"), json.dumps({"sha256": sha256_file(vp)}))
701
+ hook("verify", k)
702
+ t0 = time.perf_counter()
703
+ trainer.train(k)
704
+ add_stage_time(d, "train_proc", time.perf_counter() - t0) # whole child: model load + step + commit
705
+ hook("train", k)
706
+ last = latest_complete(run)
707
+ return read_jsonl(os.path.join(run, "ledger.jsonl")) if last else []
708
+
709
+
710
+ def _check_generations(path: str, cfg: L.RLConfig, expect_ids: list[str]) -> None:
711
+ gens = read_jsonl(path)
712
+ want = len(expect_ids) * cfg.batch.group_size
713
+ if len(gens) != want:
714
+ raise RuntimeError(f"{path}: {len(gens)} generations, expected {want} ({len(expect_ids)} prompts x G={cfg.batch.group_size})")
715
+ for g in gens:
716
+ L.validate_rl_generation(g)
code/align/rl/lean_rl_testing.py ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """TEST SUPPORT for the Lean RL loop (CPU only): a tiny Qwen3-shaped model, a FAKE generator that really
2
+ samples from it (so the loop is genuinely on-policy), and a FAKE verifier. No vLLM, no Lean.
3
+
4
+ Not used by any real run; it lives in the package so the tests and `scripts/p2/rl_lean.py --selftest` share it.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import os
12
+ import random
13
+
14
+ import torch
15
+
16
+ from align.model.qwen3 import Qwen3Config, Qwen3ForCausalLM
17
+ from align.ppo.lora import LoRAConfig, inject_lora, lora_modules, mark_only_adapter_trainable
18
+ from align.rl import lean_rl as L
19
+ from align.rl import lean_rl_run as R
20
+
21
+ TINY = Qwen3Config(name="tiny", vocab_size=48, hidden_size=32, intermediate_size=64, n_layers=2, n_heads=4,
22
+ n_kv_heads=2, head_dim=8, max_position_embeddings=256, bos_token_id=1, eos_token_id=2,
23
+ pad_token_id=0)
24
+ EOS = 2
25
+
26
+
27
+ def tiny_base(seed: int = 0, dtype=torch.float32) -> Qwen3ForCausalLM:
28
+ torch.manual_seed(seed)
29
+ m = Qwen3ForCausalLM(TINY, attn_implementation="eager", device="cpu")
30
+ return m.to(dtype)
31
+
32
+
33
+ def tiny_policy(lcfg: L.LoraCfg, seed: int = 0, dtype=torch.float32, adapter_seed: int = 1234):
34
+ """(model, params) with LoRA injected (B = 0 unless randomised) and only the adapter trainable."""
35
+ m = tiny_base(seed, torch.float32)
36
+ gen = torch.Generator().manual_seed(adapter_seed)
37
+ inject_lora(m, LoRAConfig(r=lcfg.r, alpha=lcfg.alpha, target_modules=tuple(lcfg.targets), rslora=lcfg.rslora),
38
+ adapter=lcfg.adapter, generator=gen)
39
+ mark_only_adapter_trainable(m, lcfg.adapter)
40
+ m = m.to(dtype)
41
+ params = [p for p in m.parameters() if p.requires_grad]
42
+ return m, params
43
+
44
+
45
+ def randomize_adapter(model, adapter: str, seed: int, std: float = 0.05) -> None:
46
+ g = torch.Generator().manual_seed(seed)
47
+ with torch.no_grad():
48
+ for _, mod in lora_modules(model):
49
+ a = mod.adapters[adapter]
50
+ a.A.copy_(torch.randn(a.A.shape, generator=g) * std)
51
+ a.B.copy_(torch.randn(a.B.shape, generator=g) * std)
52
+
53
+
54
+ def tiny_builder(cfg: L.RLConfig, dtype=torch.float32):
55
+ def build():
56
+ model, params = tiny_policy(cfg.lora, dtype=dtype)
57
+ return model, params, TINY, 0, "tiny-base", None, None
58
+
59
+ return build
60
+
61
+
62
+ def synthetic_problems(n: int) -> list[dict]:
63
+ rows = []
64
+ for i in range(n):
65
+ fs = f"theorem t{i} : {i} = {i} := by"
66
+ rows.append({"problem_id": f"p{i:03d}", "source": "synthetic", "split": "train", "formal_statement": fs,
67
+ "informal_prefix": "", "statement_sha": hashlib.sha256(fs.encode()).hexdigest()})
68
+ return rows
69
+
70
+
71
+ def prompt_ids_for(pid: str) -> list[int]:
72
+ h = int.from_bytes(hashlib.sha256(pid.encode()).digest()[:4], "big")
73
+ rng = random.Random(h)
74
+ return [1] + [rng.randrange(3, TINY.vocab_size) for _ in range(rng.randrange(2, 6))]
75
+
76
+
77
+ def _key_hash(*parts) -> int:
78
+ return int.from_bytes(hashlib.sha256("|".join(map(str, parts)).encode()).digest()[:8], "big")
79
+
80
+
81
+ class FakeGenerator:
82
+ """Samples from the tiny model + the adapter in `adapter_dir` (the CURRENT policy), temperature 1, top_p 1,
83
+ recording the sampled-token logprobs. A real on-policy rollout, CPU-sized. `crash_in_gen` (set by a test)
84
+ kills the process mid-stage after writing nothing, like a vLLM crash before the output is complete."""
85
+
86
+ def __init__(self, cfg: L.RLConfig, max_new: int = 8, base_seed: int = 0, dtype=torch.float32):
87
+ self.cfg, self.max_new, self.base_seed, self.dtype = cfg, max_new, base_seed, dtype
88
+ self.calls = 0
89
+
90
+ def generate(self, k, d, problems_path, adapter_dir, merged_dir, seed) -> None:
91
+ from align.ppo.lora import load_lora_state_dict
92
+
93
+ self.calls += 1
94
+ model, _ = tiny_policy(self.cfg.lora, seed=self.base_seed, dtype=self.dtype)
95
+ load_lora_state_dict(model, self.cfg.lora.adapter, R.read_peft_dir(adapter_dir, TINY), strict=True)
96
+ model.eval()
97
+ recs = []
98
+ for prob in R.read_jsonl(problems_path):
99
+ pid = prob["problem_id"]
100
+ prompt = prompt_ids_for(pid)
101
+ for si in range(self.cfg.batch.group_size):
102
+ g = torch.Generator().manual_seed(_key_hash(seed, pid, si))
103
+ ids = list(prompt)
104
+ lps = []
105
+ finish = "length"
106
+ for _ in range(self.max_new):
107
+ with torch.no_grad():
108
+ logits = model(input_ids=torch.tensor([ids]))[0, -1].double()
109
+ lp = torch.log_softmax(logits, -1)
110
+ tok = int(torch.multinomial(lp.exp(), 1, generator=g))
111
+ ids.append(tok)
112
+ lps.append(float(lp[tok]))
113
+ if tok == EOS:
114
+ finish = "stop"
115
+ break
116
+ comp = ids[len(prompt):]
117
+ recs.append({
118
+ "problem_id": pid, "sample_idx": si, "round": 0, "cap_tokens": self.cfg.rollout.cap_tokens,
119
+ "prompt_ids": prompt, "completion_ids": comp, "prompt_text": "", "completion_text": " ".join(map(str, comp)),
120
+ "finish_reason": finish, "lean_code": "theorem x := by\n trivial", "messages": [],
121
+ "model_path": "tiny", "model_rev": "tiny+adapter",
122
+ "sampling": {"temperature": 1.0, "top_p": 1.0, "top_k": 0, "n": 1, "max_tokens": self.max_new, "seed": seed},
123
+ "context_clipped": False, "parent": None, "sampling_logprobs": lps,
124
+ "adapter": {"serving": "unmerged_vllm_lora"},
125
+ })
126
+ R.write_jsonl_atomic(os.path.join(d, "gens.jsonl"), recs)
127
+
128
+
129
+ class FakeVerifier:
130
+ """accepted iff the completion contains an even token > 10 in its first 3 tokens (learnable, ~50% under a
131
+ random policy); every 9th sample by key hash is a TIMEOUT (no signal)."""
132
+
133
+ def __init__(self, timeout_mod: int = 9):
134
+ self.timeout_mod = timeout_mod
135
+ self.calls = 0
136
+
137
+ def verify(self, d, problems_path) -> None:
138
+ self.calls += 1
139
+ out = []
140
+ for g in R.read_jsonl(os.path.join(d, "gens.jsonl")):
141
+ key = (g["problem_id"], g["sample_idx"], g["round"], g["cap_tokens"])
142
+ timeout = self.timeout_mod and _key_hash(*key) % self.timeout_mod == 0
143
+ ok = any(t % 2 == 0 and t > 10 for t in g["completion_ids"][:3])
144
+ out.append({"problem_id": key[0], "sample_idx": key[1], "round": key[2], "cap_tokens": key[3],
145
+ "accepted": bool(ok and not timeout), "reasons": ["timeout"] if timeout else ([] if ok else ["compile_error"]),
146
+ "verify_s": 0.25, "budget_s": 1.0, "verifier": {"fake": True},
147
+ "code_sha256": hashlib.sha256(g["lean_code"].encode()).hexdigest(), "stage": "fake", "diagnostics": {}})
148
+ R.write_jsonl_atomic(os.path.join(d, "ver.jsonl"), out)
149
+
150
+
151
+ def synthetic_rollouts(n_prompts: int, G: int, seed: int, timeouts: int = 0, dead_prompts: int = 0,
152
+ cap: int = 64) -> tuple[list[dict], dict]:
153
+ """Random gens + verdicts with UNEVEN lengths, behaviour logprobs unrelated to any model (so the importance
154
+ ratio spans well below 1 and above the clamp), `dead_prompts` all-equal groups and `timeouts` masked samples."""
155
+ rng = random.Random(seed)
156
+ gens, vers = [], {}
157
+ for pi in range(n_prompts):
158
+ pid = f"p{pi:03d}"
159
+ prompt = prompt_ids_for(pid)
160
+ dead = pi < dead_prompts
161
+ for si in range(G):
162
+ n = rng.randrange(1, 10)
163
+ comp = [rng.randrange(3, TINY.vocab_size) for _ in range(n)]
164
+ lps = [-rng.uniform(0.05, 4.0) for _ in range(n)]
165
+ gens.append({"problem_id": pid, "sample_idx": si, "round": 0, "cap_tokens": cap, "prompt_ids": prompt,
166
+ "completion_ids": comp, "finish_reason": "stop", "lean_code": "x",
167
+ "sampling": {"temperature": 1.0, "top_p": 1.0, "top_k": 0}, "sampling_logprobs": lps})
168
+ acc = False if dead else (rng.random() < 0.5)
169
+ reasons = [] if acc else ["compile_error"]
170
+ vers[(pid, si, 0, cap)] = {"accepted": acc, "reasons": reasons, "verify_s": 0.1}
171
+ keys = list(vers)
172
+ for key in rng.sample([k for k in keys if int(k[0][1:]) >= dead_prompts], timeouts):
173
+ vers[key] = {"accepted": False, "reasons": ["timeout"], "verify_s": 5.0}
174
+ return gens, vers
code/align/rl/multiturn_source.py ADDED
@@ -0,0 +1,454 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Multi-turn Lean rollouts -> GRPO rows with PER-TOKEN advantages (opt-in).
2
+
3
+ `GRPOTrainModule` used to see one scalar advantage per row, so the credit the
4
+ multi-turn env computes per turn could not reach the loss. This module is the
5
+ producer side of the fix (the consumer side is `expand_advantages` in
6
+ grpo_loss.py): it runs `align/rl/envs/lean_multiturn.py` episodes, assigns
7
+ advantages, and emits rows whose ``advantages`` is [B, T] in INPUT space,
8
+ nonzero only on tokens the policy generated.
9
+
10
+ Every design choice below names the failure it prevents.
11
+
12
+ ROW LAYOUT: one row per TURN (the env's ``layout="per_turn"``, the default).
13
+ * A packed row per episode is valid only when the chat template renders
14
+ turn k's prompt as an exact extension of turn k-1's prompt + completion.
15
+ Qwen3-family templates (Goedel-Prover-V2 is one) strip earlier <think>
16
+ blocks, so the env raises `PrefixBreak` on them; per-turn rows are exact on
17
+ every template because each row carries the exact prompt ids the policy
18
+ saw at that turn.
19
+ * Memory: a per-turn row is bounded by one turn's prompt + completion, so
20
+ micro-batch padding is bounded by the single longest TURN. A packed row is
21
+ the SUM of all turns' context and pads every micro-batch row to that sum.
22
+ Per-turn rows pay for it by re-running the shared prefix (~K/2 x extra
23
+ forward tokens at K turns) -- accepted: it is bounded and the alternative
24
+ cannot always be built.
25
+ * Rows are independent in the loss. The Dr.GRPO denominator is a constant
26
+ and advantages are already per-token, so no statistic crosses rows: a
27
+ micro-slice or rank boundary that splits one episode's rows changes
28
+ nothing (a test pins that the summed loss is invariant to row order and
29
+ split). The packed layout is still accepted (the env's ``layout=
30
+ "concatenated"``) when the template permits it; the same masks and
31
+ advantages come out.
32
+ * DENOMINATOR: the constant D is per OPTIMIZER STEP. A multi-turn episode
33
+ can generate max_turns x max_response_len tokens, so D should be sized
34
+ prompts_per_step x G x max_turns x max_response_len
35
+ (`recommended_loss_denominator`); sizing it for one turn silently scales
36
+ every gradient by up to max_turns. The A3 gate still only requires a
37
+ positive finite float; the value is the caller's pre-registered choice.
38
+
39
+ CREDIT (`MultiTurnCreditConfig`):
40
+ mode="episode" (DEFAULT): one number per episode, A_ep = R_ep - mean_group(R),
41
+ the Dr.GRPO centered advantage over the episode's TOTAL reward, broadcast
42
+ to EVERY generated token of EVERY turn. Prevents: a baseline computed on a
43
+ thinned sample (see below) and the variance of fitting a per-turn value
44
+ function from G <= 16 episodes. It is the exact single-turn algebra, so
45
+ group centering, dead-group semantics and A3 all carry over unchanged.
46
+ Its cost is credit blur: a failed first draft is rewarded for the repair
47
+ that followed. That is wrong only if the draft did not make the repair
48
+ possible; with turn_discount < 1 the solved-late episode already scores
49
+ lower than a solved-first one, which is the ordering we want taught.
50
+ mode="per_turn_loo": turn t gets G_t - b_t, G_t = sum_{s>=t} gamma^(s-t) u_s
51
+ (the env's return; gamma=0 -> each turn judged on its own outcome).
52
+ turn 0 : b_0 = mean over the WHOLE group. Every episode has a turn 0,
53
+ so this is the ordinary group baseline and the turn-0
54
+ advantages are exactly centered.
55
+ turn t>=1: b_t = mean over the OTHER episodes of the group that reached
56
+ turn t (leave-one-out). The old rule averaged over ALL
57
+ episodes that reached turn t INCLUDING self; with one repair
58
+ in the group the baseline was the sample's own return, so the
59
+ lone repair that SOLVED the problem got advantage exactly 0
60
+ (and one that failed also 0). LOO removes self-inclusion, and
61
+ the LOO advantages of a cell sum to exactly 0 (x_i - mean_{-i}
62
+ = n/(n-1) (x_i - mean)).
63
+ lone cell: if no other episode of the group reached turn t there is no
64
+ within-group peer. The baseline is then the mean of the other
65
+ episodes at turn t POOLED across the other groups of the same
66
+ rollout step (>= ``min_pool_peers`` of them; still leave-one-
67
+ out and independent of the sample's own action, so the
68
+ estimator stays unbiased). With fewer, the cell's advantage
69
+ is 0 and counted (`credit/cells_no_baseline`) -- a baseline
70
+ invented from nothing would be a made-up gradient.
71
+ Pooled cells trade within-group centering for a baseline that exists; the
72
+ telemetry counts how many cells took each path.
73
+
74
+ DEAD GROUPS. "episode": dead iff the group's episode rewards have zero
75
+ variance, classified all_zero / all_solved / nonzero_std0 by the shared
76
+ `GroupFilterStats` (max_possible_score=1.0: the best episode reward). "per_turn
77
+ _loo": dead iff EVERY token advantage of the group is exactly 0 -- the group
78
+ contributes no gradient, so keeping it only spends compute; classified from
79
+ the rewards the same way. Dead groups are dropped whole, as in single-turn
80
+ assembly; an all-dead step returns None (the driver resamples).
81
+
82
+ No CUDA, no Lean here: the env, generator and verifier are injected.
83
+ """
84
+
85
+ from __future__ import annotations
86
+
87
+ import hashlib
88
+ from collections import OrderedDict
89
+ from dataclasses import dataclass, field
90
+ from typing import Any, Callable, Optional, Sequence
91
+
92
+ import numpy as np
93
+ import torch
94
+
95
+ from align.rl.advantages import GroupFilterStats, compute_group_advantages
96
+ from align.rl.batch_source import GrpoBatchSource
97
+ from align.rl.envs.lean_multiturn import (
98
+ EpisodeRecord,
99
+ EpisodeSpec,
100
+ LeanMultiTurnEnv,
101
+ Problem,
102
+ )
103
+
104
+ CREDIT_MODES = ("episode", "per_turn_loo")
105
+
106
+
107
+ @dataclass(frozen=True)
108
+ class MultiTurnCreditConfig:
109
+ mode: str = "episode"
110
+ #: per-turn return discount (per_turn_loo only): 0 = a turn is judged on its own outcome,
111
+ #: >0 = an earlier turn shares in the later turns' outcomes
112
+ gamma: float = 0.0
113
+ #: pooled-baseline floor for a turn no group-mate reached (per_turn_loo only)
114
+ min_pool_peers: int = 2
115
+
116
+ def __post_init__(self) -> None:
117
+ if self.mode not in CREDIT_MODES:
118
+ raise ValueError(f"mode must be one of {CREDIT_MODES}, got {self.mode!r}")
119
+ if not 0.0 <= self.gamma <= 1.0:
120
+ raise ValueError(f"gamma must be in [0, 1], got {self.gamma}")
121
+ if self.min_pool_peers < 1:
122
+ raise ValueError(f"min_pool_peers must be >= 1, got {self.min_pool_peers}")
123
+ if self.mode == "episode" and self.gamma != 0.0:
124
+ raise ValueError("gamma is read only by mode='per_turn_loo'; it would be silently ignored")
125
+
126
+
127
+ def recommended_loss_denominator(
128
+ prompts_per_step: int, n_samples: int, max_turns: int, max_response_len: int
129
+ ) -> float:
130
+ """Dr.GRPO constant sized for the worst-case GENERATED tokens of one optimizer step."""
131
+ return float(prompts_per_step * n_samples * max_turns * max_response_len)
132
+
133
+
134
+ # ---------------------------------------------------------------------------- credit
135
+
136
+
137
+ def turn_returns(u: Sequence[float], gamma: float) -> list[float]:
138
+ """G_t = sum_{s>=t} gamma^(s-t) u_s (the env's definition)."""
139
+ out, acc = [0.0] * len(u), 0.0
140
+ for t in range(len(u) - 1, -1, -1):
141
+ acc = u[t] + gamma * acc
142
+ out[t] = acc
143
+ return out
144
+
145
+
146
+ @dataclass
147
+ class CreditResult:
148
+ #: per episode (input order), per turn advantage
149
+ turn_adv: list
150
+ #: path taken per (episode, turn) cell: "episode" | "group_mean" | "group_loo" | "pooled" | "none"
151
+ cell_path: list
152
+ counts: dict = field(default_factory=dict)
153
+
154
+
155
+ def compute_turn_credit(episodes: Sequence[EpisodeRecord], credit: MultiTurnCreditConfig) -> CreditResult:
156
+ """Advantage per (episode, turn). Episodes of one problem_id form a group."""
157
+ n = len(episodes)
158
+ groups: "OrderedDict[str, list[int]]" = OrderedDict()
159
+ for i, e in enumerate(episodes):
160
+ groups.setdefault(e.problem_id, []).append(i)
161
+
162
+ ep_adv = [0.0] * n
163
+ for idx in groups.values():
164
+ a = compute_group_advantages(
165
+ np.array([episodes[i].reward["reward/total"] for i in idx]), len(idx), "centered"
166
+ )
167
+ for i, v in zip(idx, a):
168
+ ep_adv[i] = float(v)
169
+
170
+ n_turns = [len(e.turns) for e in episodes]
171
+ if credit.mode == "episode":
172
+ return CreditResult(
173
+ [[ep_adv[i]] * n_turns[i] for i in range(n)],
174
+ [["episode"] * n_turns[i] for i in range(n)],
175
+ {"credit/cells_episode": sum(n_turns)},
176
+ )
177
+
178
+ G = [turn_returns(e.turn_rewards, credit.gamma) for e in episodes]
179
+ turn_adv = [[0.0] * n_turns[i] for i in range(n)]
180
+ path = [[""] * n_turns[i] for i in range(n)]
181
+ counts = {"credit/cells_group_mean": 0, "credit/cells_group_loo": 0,
182
+ "credit/cells_pooled": 0, "credit/cells_no_baseline": 0}
183
+ for idx in groups.values():
184
+ for i in idx: # turn 0: the ordinary group baseline (every episode has a turn 0)
185
+ base = sum(G[j][0] for j in idx) / len(idx)
186
+ turn_adv[i][0] = G[i][0] - base
187
+ path[i][0] = "group_mean"
188
+ counts["credit/cells_group_mean"] += 1
189
+ for gidx in groups.values():
190
+ for i in gidx:
191
+ for t in range(1, n_turns[i]):
192
+ peers = [G[j][t] for j in gidx if j != i and n_turns[j] > t]
193
+ if peers:
194
+ turn_adv[i][t] = G[i][t] - sum(peers) / len(peers)
195
+ path[i][t] = "group_loo"
196
+ counts["credit/cells_group_loo"] += 1
197
+ continue
198
+ pool = [G[j][t] for j in range(n) if j != i and n_turns[j] > t and j not in gidx]
199
+ if len(pool) >= credit.min_pool_peers:
200
+ turn_adv[i][t] = G[i][t] - sum(pool) / len(pool)
201
+ path[i][t] = "pooled"
202
+ counts["credit/cells_pooled"] += 1
203
+ else:
204
+ path[i][t] = "none"
205
+ counts["credit/cells_no_baseline"] += 1
206
+ return CreditResult(turn_adv, path, counts)
207
+
208
+
209
+ # ---------------------------------------------------------------------------- assembly
210
+
211
+
212
+ @dataclass
213
+ class AssembledMultiTurn:
214
+ """Duck-types `AssembledRollout` (the fields `GrpoBatchSource._produce_step` reads),
215
+ except ``advantages`` is [B, T] in input space."""
216
+
217
+ input_ids: torch.Tensor
218
+ attention_mask: torch.Tensor
219
+ response_mask: torch.Tensor
220
+ advantages: torch.Tensor
221
+ vllm_logprobs: torch.Tensor
222
+ group_ids: torch.Tensor
223
+ rewards: dict
224
+ turn_ids: torch.Tensor
225
+ stats: dict = field(default_factory=dict)
226
+ kept_groups: int = 0
227
+ dropped_groups: int = 0
228
+
229
+
230
+ #: A group is dead below this spread. `np.std(...) == 0` (the single-turn rule) is an EXACT test, and
231
+ #: with discounted multi-turn rewards it misfires: seven episodes that all solved on turn 1 at
232
+ #: discount 0.9 have std 1.1e-16, not 0, because ``mean(x, x, ..., x) != x`` in float64. Such a group
233
+ #: was kept with advantages of +-1e-16 (a zero-gradient group paid for in full, and counted as
234
+ #: ``groups/kept``). Rewards live in [-1, 1] and real differences are >= ~1e-3, so 1e-9 separates
235
+ #: rounding noise from signal by six orders of magnitude on each side.
236
+ DEAD_ATOL = 1e-9
237
+
238
+
239
+ def _observe_group(stats: GroupFilterStats, rewards: np.ndarray, live: bool) -> None:
240
+ """Record one group in the three-way telemetry with an externally decided liveness."""
241
+ if live:
242
+ stats.kept += 1
243
+ return
244
+ if np.all(np.abs(rewards) <= DEAD_ATOL):
245
+ stats.all_zero += 1
246
+ elif np.all(np.abs(rewards - stats.max_possible_score) <= DEAD_ATOL):
247
+ stats.all_solved += 1
248
+ else:
249
+ stats.nonzero_std0 += 1
250
+
251
+
252
+ def assemble_multiturn(
253
+ episodes: Sequence[EpisodeRecord],
254
+ credit: MultiTurnCreditConfig,
255
+ pad_token_id: int,
256
+ filter_stats: Optional[GroupFilterStats] = None,
257
+ ) -> Optional[AssembledMultiTurn]:
258
+ """Episodes -> one training batch with per-token advantages; None if every group is dead."""
259
+ if not episodes:
260
+ raise ValueError("no episodes")
261
+ stats = filter_stats if filter_stats is not None else GroupFilterStats(max_possible_score=1.0)
262
+ res = compute_turn_credit(episodes, credit)
263
+
264
+ groups: "OrderedDict[str, list[int]]" = OrderedDict()
265
+ for i, e in enumerate(episodes):
266
+ groups.setdefault(e.problem_id, []).append(i)
267
+
268
+ keep: list[list[int]] = []
269
+ for idx in groups.values():
270
+ rewards = np.array([episodes[i].reward["reward/total"] for i in idx], dtype=np.float64)
271
+ if credit.mode == "episode":
272
+ # the single-turn rule (zero reward variance) with a rounding tolerance, see DEAD_ATOL
273
+ dead = float(np.ptp(rewards)) <= DEAD_ATOL
274
+ else:
275
+ dead = not any(abs(a) > DEAD_ATOL for i in idx for a in res.turn_adv[i])
276
+ _observe_group(stats, rewards, live=not dead)
277
+ if not dead:
278
+ keep.append(idx)
279
+ if not keep:
280
+ return None
281
+
282
+ rows = [] # (group_index, episode, row, per-token adv)
283
+ for gi, idx in enumerate(keep):
284
+ for i in idx:
285
+ ep = episodes[i]
286
+ for row in ep.rows:
287
+ if row.logprobs is None:
288
+ raise ValueError(
289
+ f"episode {ep.episode_key}: generation carries no logprobs; the rho "
290
+ "correction cannot align without the engine's per-token logprobs"
291
+ )
292
+ adv = [res.turn_adv[i][tid] if tid >= 0 else 0.0 for tid in row.turn_ids]
293
+ rows.append((gi, ep, row, adv))
294
+ B = len(rows)
295
+ T = max(len(r.input_ids) for _, _, r, _ in rows)
296
+ input_ids = torch.full((B, T), pad_token_id, dtype=torch.int64)
297
+ attention_mask = torch.zeros((B, T), dtype=torch.int64)
298
+ response_mask = torch.zeros((B, T), dtype=torch.int64)
299
+ turn_ids = torch.full((B, T), -1, dtype=torch.int64)
300
+ advantages = torch.zeros((B, T), dtype=torch.float32)
301
+ vllm_lp = torch.zeros((B, T - 1), dtype=torch.float32)
302
+ group_ids = torch.zeros((B,), dtype=torch.int64)
303
+ term_names = list(rows[0][1].reward)
304
+ reward_tensors = {k: torch.zeros(B, dtype=torch.float32) for k in term_names}
305
+ for b, (gi, ep, row, adv) in enumerate(rows):
306
+ L = len(row.input_ids)
307
+ if not (len(row.response_mask) == len(row.turn_ids) == len(row.logprobs) == L):
308
+ raise ValueError(f"row {b} of episode {ep.episode_key} is ragged across ids/mask/turn_ids/logprobs")
309
+ # Credit is placed by ``turn_ids`` and trained on by ``response_mask``. If the two disagree
310
+ # by even one position, the loss-side stray check (`expand_advantages`) catches it only
311
+ # when the misplaced credit lands on an excluded position that exists: a row whose last
312
+ # generated token is its last column (every micro-batch's longest per-turn row) has no
313
+ # position past it, so a right-shifted span slips through. Refuse here, where it is exact.
314
+ if [int(t >= 0) for t in row.turn_ids] != [int(m) for m in row.response_mask]:
315
+ raise ValueError(
316
+ f"row {b} of episode {ep.episode_key}: turn_ids >= 0 does not coincide with "
317
+ f"response_mask; per-token credit would be misaligned with the trained tokens"
318
+ )
319
+ input_ids[b, :L] = torch.tensor(row.input_ids, dtype=torch.int64)
320
+ attention_mask[b, :L] = 1
321
+ response_mask[b, :L] = torch.tensor(row.response_mask, dtype=torch.int64)
322
+ turn_ids[b, :L] = torch.tensor(row.turn_ids, dtype=torch.int64)
323
+ advantages[b, :L] = torch.tensor(adv, dtype=torch.float32)
324
+ # label position j <-> input token j+1
325
+ vllm_lp[b, : L - 1] = torch.tensor(row.logprobs[1:], dtype=torch.float32)
326
+ group_ids[b] = gi
327
+ for k in term_names:
328
+ reward_tensors[k][b] = float(ep.reward[k])
329
+
330
+ kept_eps = [episodes[i] for idx in keep for i in idx]
331
+ gsz = [len(idx) for idx in keep]
332
+ first = [e.solved_turn == 0 for e in kept_eps]
333
+ failed_first = [e for e in kept_eps if e.solved_turn != 0]
334
+ stat = {
335
+ "multiturn/episodes": float(len(kept_eps)),
336
+ "multiturn/rows": float(B),
337
+ "multiturn/turns_mean": float(np.mean([len(e.turns) for e in kept_eps])),
338
+ "multiturn/solved_turn0": float(np.mean(first)),
339
+ "multiturn/solved": float(np.mean([e.solved_turn is not None for e in kept_eps])),
340
+ "multiturn/repair_rate": (
341
+ float(np.mean([e.solved_turn is not None for e in failed_first])) if failed_first else float("nan")
342
+ ),
343
+ "multiturn/generated_tokens_mean": float(np.mean([e.n_generated for e in kept_eps])),
344
+ "multiturn/group_size_min": float(min(gsz)),
345
+ "multiturn/group_size_max": float(max(gsz)),
346
+ **{k: float(v) for k, v in res.counts.items()},
347
+ **{k: float(v) for k, v in stats.as_log_dict().items()},
348
+ }
349
+ return AssembledMultiTurn(
350
+ input_ids, attention_mask, response_mask, advantages, vllm_lp, group_ids, reward_tensors,
351
+ turn_ids, stat, kept_groups=len(keep), dropped_groups=len(groups) - len(keep),
352
+ )
353
+
354
+
355
+ # ---------------------------------------------------------------------------- the batch source
356
+
357
+
358
+ class MultiTurnBatchSource(GrpoBatchSource):
359
+ """`GrpoBatchSource` whose rollout is a batch of multi-turn Lean episodes.
360
+
361
+ Everything downstream of rollout production (rank-0 produce + broadcast,
362
+ row slicing, no-grad old_logprobs, the validated batch, the collapse
363
+ tripwire, the draw-position resume state) is inherited unchanged; only
364
+ `_rollout_once` differs. Rows are sliced along dim 0 and the [B, T]
365
+ advantages slice with them.
366
+
367
+ ``prompt_rows``: dicts with ``formal_statement`` (or ``problem``) and a
368
+ UNIQUE ``problem_id`` (the group key; duplicates are refused -- two
369
+ problems sharing an id would be centered against each other).
370
+ ``generate_factory(seed=..., weights_dir=...)`` returns the env's
371
+ ``generate(list[GenRequest]) -> list[Generation]`` for this step.
372
+ """
373
+
374
+ def __init__(
375
+ self,
376
+ prompt_rows: Sequence[dict[str, Any]],
377
+ *,
378
+ env: LeanMultiTurnEnv,
379
+ credit: MultiTurnCreditConfig = MultiTurnCreditConfig(),
380
+ prompts_per_step: int,
381
+ n_samples_per_prompt: int,
382
+ max_response_len: int,
383
+ temperature: float,
384
+ optimizer_steps: int,
385
+ grad_accum: int,
386
+ seed: int,
387
+ dead_group_retries: int = 5,
388
+ ):
389
+ rows, seen = [], set()
390
+ for i, r in enumerate(prompt_rows):
391
+ stmt = r.get("formal_statement", r.get("problem"))
392
+ if not isinstance(stmt, str) or not stmt:
393
+ raise ValueError(f"prompt row {i} lacks formal_statement/problem: {sorted(r)}")
394
+ pid = str(r.get("problem_id") or hashlib.sha256(stmt.encode()).hexdigest()[:12])
395
+ if pid in seen:
396
+ raise ValueError(f"duplicate problem_id {pid!r}: it is the advantage group key")
397
+ seen.add(pid)
398
+ rows.append({"problem": stmt, "answer": "", "problem_id": pid})
399
+ super().__init__(
400
+ rows,
401
+ prompts_per_step=prompts_per_step,
402
+ n_samples_per_prompt=n_samples_per_prompt,
403
+ max_response_len=max_response_len,
404
+ temperature=temperature,
405
+ optimizer_steps=optimizer_steps,
406
+ grad_accum=grad_accum,
407
+ seed=seed,
408
+ prompt_builder=lambda p: p, # unused: the env builds the Goedel prompts
409
+ dead_group_retries=dead_group_retries,
410
+ )
411
+ self.env = env
412
+ self.credit = credit
413
+ # Multi-turn episode reward is <= 1.0 (turn_discount ** 0); the single-turn
414
+ # source's 1.1 (accuracy + format bonus) would misfile all-solved groups.
415
+ self.filter_stats.max_possible_score = 1.0
416
+ self.last_rollout_stats: dict[str, float] = {}
417
+
418
+ def bind( # noqa: D401 - deliberately narrower than the parent's (no client/reward/verifier)
419
+ self,
420
+ *,
421
+ generate_factory: Callable[..., Callable[[list], list]],
422
+ old_logprob_fn,
423
+ weights_dir_fn,
424
+ rank: int,
425
+ world_size: int,
426
+ device: str = "cpu",
427
+ canary=None,
428
+ canary_generate_fn=None,
429
+ collapse_tripwire=None,
430
+ trainer=None,
431
+ canary_log_path=None,
432
+ ) -> None:
433
+ self.generate_factory = generate_factory
434
+ super().bind(
435
+ client=None, reward=None, verifier=None, old_logprob_fn=old_logprob_fn,
436
+ weights_dir_fn=weights_dir_fn, rank=rank, world_size=world_size, device=device,
437
+ canary=canary, canary_generate_fn=canary_generate_fn, collapse_tripwire=collapse_tripwire,
438
+ trainer=trainer, canary_log_path=canary_log_path,
439
+ )
440
+
441
+ def _rollout_once(self, indices: list[int], optimizer_step: int = 0) -> Optional[AssembledMultiTurn]:
442
+ specs = [
443
+ EpisodeSpec(Problem(self.prompt_rows[i]["problem_id"], self.prompt_rows[i]["problem"]), s)
444
+ for i in indices
445
+ for s in range(self.n_samples_per_prompt)
446
+ ]
447
+ generate = self.generate_factory(
448
+ seed=self.rollout_seed(optimizer_step), weights_dir=self.weights_dir_fn()
449
+ )
450
+ episodes = self.env.run_group(specs, generate)
451
+ assembled = assemble_multiturn(episodes, self.credit, self.pad_token_id, self.filter_stats)
452
+ if assembled is not None:
453
+ self.last_rollout_stats = assembled.stats
454
+ return assembled
code/align/rl/ops.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pure tensor ops for the GRPO layer.
2
+
3
+ Vendored from allenai/open-instruct @ 8759c91:
4
+ masked_mean <- open_instruct/rl_utils.py:426-448
5
+ entropy_from_logits <- open_instruct/model_utils.py:326-334
6
+ log_softmax_and_gather <- open_instruct/model_utils.py:588-601
7
+ estimate_kl <- open_instruct/model_utils.py:793-819
8
+
9
+ Bodies are verbatim; only the module docstrings/imports differ. Any edit to
10
+ these functions breaks parity with the upstream line citations in
11
+ docs/research/nmo-research/docs/04_ALLENAI_CODEBASE_MAP.md and must be
12
+ recorded as a deliberate divergence, not slipped in.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Optional, Union
18
+
19
+ import torch
20
+
21
+
22
+ def masked_mean(
23
+ values: torch.Tensor,
24
+ mask: torch.Tensor,
25
+ axis: Optional[int] = None,
26
+ denominator: Optional[float] = None,
27
+ ) -> torch.Tensor:
28
+ """Compute mean of tensor with masked values.
29
+
30
+ Returns 0 if mask is empty (no valid elements) to avoid division by zero.
31
+ This can happen with sequence parallel when a chunk contains only
32
+ query/padding tokens.
33
+
34
+ A3 note: passing a constant float ``denominator`` is what realises the
35
+ Dr.GRPO constant-global-denominator form; leaving it None realises the
36
+ DAPO token-mean. align's conformance gate requires the float.
37
+ """
38
+ extra_dims = values.ndim - mask.ndim
39
+ if axis is None:
40
+ sum_dims: int | tuple[int, ...] = tuple(range(extra_dims, values.ndim))
41
+ elif axis >= 0:
42
+ sum_dims = axis + extra_dims
43
+ else:
44
+ sum_dims = axis
45
+ numerator = (values * mask).sum(dim=sum_dims)
46
+ denom = mask.sum(dim=axis) if denominator is None else denominator
47
+ if isinstance(denom, torch.Tensor):
48
+ result = torch.where(denom > 0, numerator / denom, torch.zeros_like(numerator))
49
+ else:
50
+ result = numerator / denom if denom > 0 else torch.zeros_like(numerator)
51
+ return result.flatten(extra_dims).mean(-1) if result.ndim > extra_dims else result
52
+
53
+
54
+ def entropy_from_logits(logits: torch.Tensor) -> torch.Tensor:
55
+ """Compute the entropy of the logits.
56
+
57
+ Upstream credit chain: open-instruct borrowed this from verl
58
+ (verl/utils/torch_functional.py). logsumexp form avoids materialising
59
+ log-probabilities next to probabilities.
60
+ """
61
+ pd = torch.nn.functional.softmax(logits, dim=-1)
62
+ entropy = torch.logsumexp(logits, dim=-1) - torch.sum(pd * logits, dim=-1)
63
+ return entropy
64
+
65
+
66
+ def log_softmax_and_gather(logits: torch.Tensor, index: torch.Tensor) -> torch.Tensor:
67
+ """`log_softmax -> gather` without allocating a [B, T, V] logprob tensor.
68
+
69
+ Upstream compiles this (open-instruct PR #584); align leaves compilation to
70
+ the caller because torch.compile on this box requires the vendored-header
71
+ toolchain fix (align/kernels/_toolchain.py) and is opted into per-site.
72
+ """
73
+ logprobs = logits.log_softmax(dim=-1)
74
+ return torch.gather(logprobs, dim=-1, index=index.unsqueeze(-1)).squeeze(-1)
75
+
76
+
77
+ def estimate_kl(ref_logprobs_diff: torch.Tensor, ratio: torch.Tensor) -> torch.Tensor:
78
+ """Compute 4 KL divergence estimators between current and reference policies.
79
+
80
+ Args:
81
+ ref_logprobs_diff: (new_logprobs - ref_logprobs), pre-clamped to
82
+ [-40, 40] by the caller for numerical stability.
83
+ ratio: exp(new_logprobs - old_logprobs) vs the step-start policy.
84
+
85
+ Returns:
86
+ Tensor of shape [4, *ref_logprobs_diff.shape]:
87
+ [0] linear approximation
88
+ [1] quadratic approximation (diff^2 / 2)
89
+ [2] numerically stable form (expm1(-diff) + diff) -- default
90
+ [3] importance-weighted (ratio * diff)
91
+ """
92
+ return torch.stack(
93
+ [
94
+ ref_logprobs_diff,
95
+ (ref_logprobs_diff) ** 2 / 2,
96
+ torch.expm1(-ref_logprobs_diff) + ref_logprobs_diff,
97
+ ratio * ref_logprobs_diff,
98
+ ]
99
+ )
code/align/rl/rewards.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Reward composition -- the ONE genuinely net-new A3 piece.
2
+
3
+ The corpus's conformance table (04_ALLENAI_CODEBASE_MAP.md item 9) found soft
4
+ overlong punishment absent from open-instruct entirely (its
5
+ ``mask_truncated_completions`` is DAPO's HARD binary filter, a different
6
+ mechanism). Per the same table's architectural note: this lives in the
7
+ REWARD-COMPOSITION layer, never in the loss -- keeping compute_grpo_loss
8
+ provably unmodified from upstream is an auditability property of the
9
+ pre-registration, not a style preference.
10
+
11
+ soft_overlong_punishment implements DAPO eq. (arXiv:2503.14476, "Soft
12
+ Overlong Punishment"): zero inside the budget, linear ramp to -1 across the
13
+ final ``cache`` tokens, -1 beyond max length.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+ from typing import Optional, Sequence
20
+
21
+
22
+ def soft_overlong_punishment(response_len: int, max_len: int, cache_len: int) -> float:
23
+ """DAPO soft length penalty in [-1, 0].
24
+
25
+ 0 for len <= max_len - cache_len
26
+ ((max_len - cache_len) - len)/cache_len inside the buffer (linear to -1)
27
+ -1 for len > max_len
28
+ """
29
+ if cache_len <= 0 or cache_len >= max_len:
30
+ raise ValueError(f"need 0 < cache_len < max_len, got cache={cache_len} max={max_len}")
31
+ soft_start = max_len - cache_len
32
+ if response_len <= soft_start:
33
+ return 0.0
34
+ if response_len > max_len:
35
+ return -1.0
36
+ return (soft_start - response_len) / cache_len
37
+
38
+
39
+ @dataclass
40
+ class CompositeReward:
41
+ """A3 reward: verifier accuracy + soft format term + soft overlong penalty.
42
+
43
+ ``accuracy`` and ``format_score`` arrive from the verifier layer (rule
44
+ verifier only -- no PRM/neural RM, A3's hardest lock and the field's
45
+ strongest cross-lab consensus per doc 08). This class only composes and
46
+ itemises them so history.jsonl can log each term separately -- the
47
+ format/structural-consistency instrument (doc 09 amendment) reads the
48
+ per-term stream, not the sum.
49
+ """
50
+
51
+ format_weight: float = 0.1
52
+ overlong_max_len: Optional[int] = None
53
+ overlong_cache_len: Optional[int] = None
54
+
55
+ def __post_init__(self) -> None:
56
+ # Fail at CONSTRUCTION, not at the first rollout. The dangerous half is
57
+ # cache_len set with max_len unset: __call__ takes the "overlong off"
58
+ # branch and the soft length penalty silently never applies while the
59
+ # config reads as if it were armed (a disarmed-instrument shape).
60
+ if self.overlong_max_len is None and self.overlong_cache_len is not None:
61
+ raise ValueError(
62
+ f"overlong_cache_len={self.overlong_cache_len} given without overlong_max_len: "
63
+ "the soft overlong penalty would be silently OFF"
64
+ )
65
+ if self.overlong_max_len is not None:
66
+ if self.overlong_cache_len is None:
67
+ raise ValueError("overlong_cache_len required when overlong_max_len is set")
68
+ if not 0 < self.overlong_cache_len < self.overlong_max_len:
69
+ raise ValueError(
70
+ f"need 0 < overlong_cache_len < overlong_max_len, got "
71
+ f"cache={self.overlong_cache_len} max={self.overlong_max_len}"
72
+ )
73
+
74
+ def __call__(self, accuracy: float, format_score: float, response_len: int) -> dict[str, float]:
75
+ terms: dict[str, float] = {
76
+ "reward/accuracy": float(accuracy),
77
+ "reward/format": self.format_weight * float(format_score),
78
+ }
79
+ if self.overlong_max_len is not None:
80
+ if self.overlong_cache_len is None:
81
+ raise ValueError("overlong_cache_len required when overlong_max_len is set")
82
+ terms["reward/overlong"] = soft_overlong_punishment(
83
+ response_len, self.overlong_max_len, self.overlong_cache_len
84
+ )
85
+ else:
86
+ terms["reward/overlong"] = 0.0
87
+ terms["reward/total"] = (
88
+ terms["reward/accuracy"] + terms["reward/format"] + terms["reward/overlong"]
89
+ )
90
+ return terms
91
+
92
+ def batch(
93
+ self, accuracies: Sequence[float], format_scores: Sequence[float], lengths: Sequence[int]
94
+ ) -> list[float]:
95
+ return [
96
+ self(a, f, n)["reward/total"]
97
+ for a, f, n in zip(accuracies, format_scores, lengths, strict=True)
98
+ ]
code/align/rl/stage_config.py ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Typed stage-b config: configs/grpo_stageb.toml -> dataclasses + launch refusals.
2
+
3
+ Deliberately NOT align.config.RunConfig: that is the trainer's surface, this is
4
+ the stage's pre-registration. Collapsing them would let a trainer default
5
+ silently become an experimental parameter (the same separation dpo_stage
6
+ draws). The trainer still gets a RunConfig -- built FROM this, explicitly,
7
+ in scripts/grpo.py.
8
+
9
+ Every check in `validate()` is a launch refusal for a failure phase-1 paid
10
+ for: the denominator arithmetic (obligation a's config half), the
11
+ save-cadence/weight-sync alignment (a drifted engine trains against stale
12
+ rho baselines silently), the ceiling receipt (the 0/100-perfect-policy
13
+ trap), max_grad_norm (R3), a live canary cadence (R4).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ from dataclasses import dataclass, field
21
+
22
+ from align.rl.grpo_loss import GRPOLossConfig, GRPOLossType
23
+
24
+
25
+ @dataclass
26
+ class RolloutShape:
27
+ prompts_per_step: int
28
+ max_response_len: int
29
+ temperature: float = 1.0
30
+ overlong_cache_len: int = 128
31
+ #: Where completions come from. "spool" = the external vLLM worker (own venv,
32
+ #: extras/vllm_rollout; the backend the pre-registration describes) and is
33
+ #: the default so an existing config keeps its generation engine. "native" =
34
+ #: the in-repo torch sampler on a STANDALONE engine copy on a dedicated GPU
35
+ #: (NMO_ROLLOUT_GPU), synced by reloading the latest checkpoint; GPU-unverified
36
+ #: at 7B at the time of the port, so it is opt-in. A silent switch changes
37
+ #: throughput by an order of magnitude and the logprob basis (native logprobs
38
+ #: are of the temperature-scaled distribution), hence never inferred.
39
+ backend: str = "spool"
40
+ #: INFORMATIONAL ONLY: configs/grpo_stageb.toml carries `vllm_env` as a note of the
41
+ #: worker environment the pre-registration ran under. Nothing reads it -- the env is
42
+ #: pinned in code (align/rollout/client.py) -- so it is declared here, rather than
43
+ #: left to the unknown-key refusal, purely so that refusal can be strict for every
44
+ #: OTHER key. A value here does NOT change the worker environment.
45
+ vllm_env: str = ""
46
+
47
+
48
+ @dataclass
49
+ class RewardCfg:
50
+ format_weight: float = 0.1
51
+
52
+
53
+ @dataclass
54
+ class DataCfg:
55
+ band_file: str
56
+ banding_manifest: str
57
+
58
+
59
+ @dataclass
60
+ class TrainCfg:
61
+ lr: float
62
+ optimizer_steps: int
63
+ warmup_frac: float = 0.03
64
+ micro_batch_size: int = 1
65
+ save_every_micro_steps: int = 100
66
+ keep_checkpoints: int = 2
67
+ #: Per-rank optimizer shards (TrainConfig.per_rank_optimizer_state); forwarded
68
+ #: by scripts/grpo.py::_run_config. Default False = gathered layout.
69
+ per_rank_optimizer_state: bool = False
70
+ max_grad_norm: float | None = None
71
+ seed: int = 41
72
+ optimizer_name: str = "adamw8bit"
73
+ grad_accum: int = 8
74
+ attn_implementation: str = "sdpa"
75
+
76
+ # -- run-health bounds (G0), forwarded to TrainConfig by the stage script's
77
+ # _run_config: a field that is not copied trains at the default while the
78
+ # TOML says otherwise. Semantics: align/config.py::TrainConfig.
79
+ max_wall_seconds: float | None = None
80
+ max_consecutive_skipped_steps: int = 3
81
+ max_skipped_fraction: float = 0.05
82
+
83
+
84
+ @dataclass
85
+ class CanaryCfg:
86
+ every: int
87
+ n_problems: int
88
+ source: str = "gsm8k"
89
+
90
+
91
+ @dataclass
92
+ class GrpoStageConfig:
93
+ sft_checkpoint: str
94
+ run_dir: str
95
+ grpo: GRPOLossConfig
96
+ rollout: RolloutShape
97
+ reward: RewardCfg
98
+ data: DataCfg
99
+ train: TrainCfg
100
+ canary: CanaryCfg
101
+ armed_tripwires: tuple[str, ...] = ("grad_norm",)
102
+ ceiling_receipt: str = "outputs/eval/ceiling_test.json"
103
+ rollout_timeout_s: float = 1800.0
104
+ dead_group_retries: int = 5
105
+ extras: dict = field(default_factory=dict)
106
+
107
+ def validate(self) -> None:
108
+ g = self.grpo
109
+ if self.rollout.backend not in ("spool", "native"):
110
+ raise ValueError(
111
+ f"rollout.backend {self.rollout.backend!r}: choose 'spool' (external vLLM "
112
+ "worker) or 'native' (in-repo torch sampler)"
113
+ )
114
+ expected = float(
115
+ self.rollout.prompts_per_step
116
+ * g.num_samples_per_prompt_rollout
117
+ * self.rollout.max_response_len
118
+ )
119
+ if not isinstance(g.loss_denominator, float) or g.loss_denominator != expected:
120
+ raise ValueError(
121
+ f"loss_denominator {g.loss_denominator!r} != prompts_per_step x G x "
122
+ f"max_response_len = {self.rollout.prompts_per_step} x "
123
+ f"{g.num_samples_per_prompt_rollout} x {self.rollout.max_response_len} "
124
+ f"= {expected}. The Dr.GRPO constant is DERIVED, never hand-typed."
125
+ )
126
+ if not os.path.isfile(self.data.band_file):
127
+ raise FileNotFoundError(
128
+ f"band file missing: {self.data.band_file}. Run "
129
+ "scripts/build_stageb_data.py first; the driver never builds data."
130
+ )
131
+ if self.train.save_every_micro_steps % self.train.grad_accum != 0:
132
+ raise ValueError(
133
+ f"save_every_micro_steps={self.train.save_every_micro_steps} is not a "
134
+ f"multiple of grad_accum={self.train.grad_accum}: checkpoint saves ARE "
135
+ "the weight sync (the engine reloads the latest checkpoint), so a save "
136
+ "landing mid-accumulation would sync half-stepped weights."
137
+ )
138
+ if self.train.max_grad_norm is None or self.train.max_grad_norm <= 0:
139
+ raise ValueError(
140
+ "train.max_grad_norm must be a positive float: R3 (2026-08-23 audit) "
141
+ "-- the recorded phase-1 rerun blew up to grad_norm 355,830 with no "
142
+ "clipping anywhere. Unclipped RL is not a configuration this stage "
143
+ "accepts."
144
+ )
145
+ if self.canary.every <= 0 or self.canary.n_problems <= 0:
146
+ raise ValueError(
147
+ "canary cadence/n must be positive: R4 -- 727 optimizer steps of "
148
+ "phase-1 preference training ran with zero task-accuracy signal."
149
+ )
150
+ if "grad_norm" not in self.armed_tripwires:
151
+ raise ValueError("stage-b requires the grad_norm HALT tripwire armed (R3)")
152
+ if not g.record_entropy:
153
+ raise ValueError("record_entropy must be true: the collapse tripwire reads entropy")
154
+
155
+ def validate_ceiling_receipt(self) -> dict:
156
+ """Separate from validate(): tests exercise config logic without a receipt."""
157
+ if not os.path.isfile(self.ceiling_receipt):
158
+ raise FileNotFoundError(
159
+ f"ceiling receipt missing: {self.ceiling_receipt}. No GRPO rollout "
160
+ "launches until the reward path is certified (the 0/100-perfect-policy "
161
+ "incident, mistral-prm-ppo 2026-08-14)."
162
+ )
163
+ with open(self.ceiling_receipt) as f:
164
+ receipt = json.load(f)
165
+ if not receipt.get("overall_pass"):
166
+ raise RuntimeError(f"ceiling receipt {self.ceiling_receipt} is not a PASS")
167
+ return receipt
168
+
169
+ def validate_world(self, world_size: int) -> None:
170
+ """Checks that need the launch topology."""
171
+ rows_per_step = self.rollout.prompts_per_step * self.grpo.num_samples_per_prompt_rollout
172
+ per_micro = rows_per_step // (world_size * self.train.grad_accum)
173
+ if per_micro < 1:
174
+ raise ValueError(
175
+ f"rollout shape too small for the topology: {rows_per_step} completions "
176
+ f"per step cannot fill world_size({world_size}) x "
177
+ f"grad_accum({self.train.grad_accum}) micro-slices"
178
+ )
179
+
180
+
181
+ def load_stage_config(path: str, smoke: bool = False) -> GrpoStageConfig:
182
+ from align.config import load_toml
183
+
184
+ raw = load_toml(path)
185
+ g = dict(raw["grpo"])
186
+ g["loss_fn"] = GRPOLossType(g.get("loss_fn", "dapo"))
187
+ # TOML floats survive; TOML ints for float fields do not -- coerce the one
188
+ # field whose TYPE is load-bearing (the conformance gate refuses non-float).
189
+ if "loss_denominator" in g:
190
+ g["loss_denominator"] = float(g["loss_denominator"])
191
+ t = dict(raw["train"])
192
+ t["optimizer_name"] = raw.get("optimizer", {}).get("name", t.pop("optimizer_name", "adamw8bit"))
193
+ t["grad_accum"] = raw.get("optimizer", {}).get("grad_accum", t.pop("grad_accum", 8))
194
+ # STRUCT MODE for [train]. This used to filter through
195
+ # TrainCfg.__dataclass_fields__, which made a typo'd or not-yet-plumbed key
196
+ # (e.g. per_rank_optimizer_state before TrainCfg had it) ACCEPTED AND
197
+ # SILENTLY IGNORED: the TOML looked armed and the run was not. Raise at
198
+ # launch, naming the key, like DpoStageConfig's struct-mode loader.
199
+ unknown = sorted(k for k in t if k not in TrainCfg.__dataclass_fields__)
200
+ if unknown:
201
+ raise ValueError(
202
+ f"unknown key(s) {unknown} in [train] are not fields of TrainCfg "
203
+ f"(struct mode: a silently ignored key is a disarmed config). "
204
+ f"Known fields: {sorted(TrainCfg.__dataclass_fields__)}"
205
+ )
206
+ # STRUCT MODE for [rollout] too. The same filter-through-__dataclass_fields__
207
+ # idiom that [train] shed above survived here: `[rollout] max_reponse_len = 512`
208
+ # (typo) or a not-yet-plumbed `top_p = 0.9` was DROPPED, the shape kept its
209
+ # default, and `validate()` then checked the loss denominator against the
210
+ # default -- the pre-registered rollout looked armed and was not.
211
+ r = dict(raw["rollout"])
212
+ unknown_rollout = sorted(k for k in r if k not in RolloutShape.__dataclass_fields__)
213
+ if unknown_rollout:
214
+ raise ValueError(
215
+ f"unknown key(s) {unknown_rollout} in [rollout] are not fields of RolloutShape "
216
+ f"(struct mode: a silently ignored key is a disarmed config). "
217
+ f"Known fields: {sorted(RolloutShape.__dataclass_fields__)}"
218
+ )
219
+ cfg = GrpoStageConfig(
220
+ sft_checkpoint=raw["sft_checkpoint"],
221
+ run_dir=raw["run_dir"],
222
+ grpo=GRPOLossConfig(**g),
223
+ rollout=RolloutShape(**r),
224
+ reward=RewardCfg(**raw.get("reward", {})),
225
+ data=DataCfg(**raw["data"]),
226
+ train=TrainCfg(**t),
227
+ canary=CanaryCfg(**raw["canary"]),
228
+ armed_tripwires=tuple(raw.get("tripwires", {}).get("armed", ("grad_norm",))),
229
+ )
230
+ if smoke:
231
+ cfg = _smokeify(cfg)
232
+ cfg.validate()
233
+ return cfg
234
+
235
+
236
+ def _smokeify(cfg: GrpoStageConfig) -> GrpoStageConfig:
237
+ """Tiny everything; the derived denominator is recomputed, not hand-edited."""
238
+ cfg.rollout.prompts_per_step = 2
239
+ cfg.grpo.num_samples_per_prompt_rollout = 8 # A3 floor is G >= 8, smoke included
240
+ # 256 tokens, not 32: the sixth smoke run proved a cap real answers
241
+ # cannot fit under produces zero reward variance -> every group dead ->
242
+ # the (correct) band-mischosen refusal. The smoke needs a policy with
243
+ # SOME task signal (the real SFT substrate) and room to finish answers.
244
+ cfg.rollout.max_response_len = 256
245
+ cfg.rollout.overlong_cache_len = 64
246
+ cfg.grpo.loss_denominator = float(2 * 8 * 256)
247
+ cfg.train.optimizer_steps = 10
248
+ cfg.train.grad_accum = 2
249
+ cfg.train.save_every_micro_steps = 10 # sync twice in a 20-micro smoke
250
+ cfg.canary.every = 5
251
+ cfg.canary.n_problems = 4
252
+ cfg.run_dir = cfg.run_dir + "_smoke"
253
+ return cfg
code/align/rl/tripwire.py ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Entropy/length collapse tripwire -- the second net-new A3 piece.
2
+
3
+ Upstream has the loggable INPUTS (record_entropy=True) but no tripwire logic
4
+ (corpus conformance table item 7: "the knob exists, the tripwire mechanism
5
+ does not"). This is the GRPO sibling of the DPO chosen-logprob tripwire that
6
+ fired -- and autonomously launched its pre-registered response -- on Arm B
7
+ (2026-08-22, runs/dpo_arm_b_response/).
8
+
9
+ Design contract, identical to the DPO one:
10
+ * The tripwire DETECTS and RECORDS. The stage script owns the response,
11
+ and the only admissible response is the pre-registered one
12
+ (KL=0 collapse -> ONE rerun with beta=1e-4, recorded AS the result;
13
+ no third configuration). No improvisation lives here by construction.
14
+ * Fed from the same metrics stream that history.jsonl records, so a fire
15
+ is always reproducible from the run's own log after the fact.
16
+
17
+ Signals (cross-lab consensus per doc 08: entropy collapse is the 2025
18
+ field-wide GRPO failure mode; length collapse is its usual companion):
19
+ E: policy entropy trend -- median over the trailing window falls below
20
+ ``entropy_floor_frac`` x the median over the baseline window.
21
+ L: mean response length trend -- same construction, lengths shrinking
22
+ below ``length_floor_frac`` of baseline (degenerate short answers).
23
+ Either alone fires; both are cheap medians over deques, no model access.
24
+
25
+ Resume (2026-10-12 review). The instrument is STATEFUL -- a baseline measured
26
+ over the first ``baseline_steps`` observations, a trailing window, a step
27
+ counter and a latch -- so a resume that rebuilds it fresh silently changes the
28
+ experiment: the baseline is re-measured on the POST-resume (possibly already
29
+ collapsed) policy, which makes the collapse the new "normal", and
30
+ ``min_steps_before_fire`` restarts at 0, blinding the guard for ~100 steps.
31
+ ``state_dict()`` / ``load_state_dict()`` carry all of it through the Trainer's
32
+ ``extra_state`` slot in resume.pt (``GRPOTrainModule.extra_state``).
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import logging
38
+ import math
39
+ from collections import deque
40
+ from dataclasses import dataclass, field
41
+ from statistics import median
42
+ from typing import Optional
43
+
44
+ logger = logging.getLogger(__name__)
45
+
46
+
47
+ @dataclass
48
+ class CollapseTripwire:
49
+ baseline_steps: int = 50
50
+ window_steps: int = 50
51
+ entropy_floor_frac: float = 0.2
52
+ length_floor_frac: float = 0.4
53
+ min_steps_before_fire: int = 100
54
+
55
+ _baseline_entropy: list[float] = field(default_factory=list)
56
+ _baseline_length: list[float] = field(default_factory=list)
57
+ _entropy: deque = field(default_factory=deque)
58
+ _length: deque = field(default_factory=deque)
59
+ _steps: int = 0
60
+ fired: bool = False
61
+ fire_reason: Optional[str] = None
62
+ fire_step: Optional[int] = None
63
+
64
+ def __post_init__(self) -> None:
65
+ self._entropy = deque(maxlen=self.window_steps)
66
+ self._length = deque(maxlen=self.window_steps)
67
+
68
+ def observe(self, step: int, entropy: float, mean_response_len: float) -> bool:
69
+ """Feed one optimizer step's metrics; returns True the step it fires.
70
+
71
+ Once fired it stays fired (a latched instrument, like the DPO one) --
72
+ the stage script reads ``fired``/``fire_reason`` and executes the
73
+ pre-registered response exactly once.
74
+ """
75
+ if not (math.isfinite(entropy) and math.isfinite(mean_response_len)):
76
+ # NaN compares False against every threshold below (`nan > 0`,
77
+ # `nan < x`), so a NaN entropy stream would leave the entropy arm
78
+ # permanently inert while the object reads as armed -- the
79
+ # disarmed-tripwire shape (audit D1). `_on_step_boundary` feeds NaN
80
+ # when a metric key is missing. Refuse loudly instead.
81
+ raise ValueError(
82
+ f"CollapseTripwire.observe(step={step}): non-finite input "
83
+ f"(entropy={entropy}, mean_response_len={mean_response_len}); a guard "
84
+ "fed NaN can never fire"
85
+ )
86
+ self._steps += 1
87
+ if len(self._baseline_entropy) < self.baseline_steps:
88
+ self._baseline_entropy.append(entropy)
89
+ self._baseline_length.append(mean_response_len)
90
+ return False
91
+ self._entropy.append(entropy)
92
+ self._length.append(mean_response_len)
93
+
94
+ if (
95
+ self.fired
96
+ or self._steps < self.min_steps_before_fire
97
+ or len(self._entropy) < self.window_steps
98
+ ):
99
+ return False
100
+
101
+ base_e = median(self._baseline_entropy)
102
+ base_l = median(self._baseline_length)
103
+ cur_e = median(self._entropy)
104
+ cur_l = median(self._length)
105
+ reasons = []
106
+ if base_e > 0 and cur_e < self.entropy_floor_frac * base_e:
107
+ reasons.append(
108
+ f"entropy collapse: trailing median {cur_e:.4f} < {self.entropy_floor_frac} x baseline {base_e:.4f}"
109
+ )
110
+ if base_l > 0 and cur_l < self.length_floor_frac * base_l:
111
+ reasons.append(
112
+ f"length collapse: trailing median {cur_l:.1f} < {self.length_floor_frac} x baseline {base_l:.1f}"
113
+ )
114
+ if reasons:
115
+ self.fired = True
116
+ self.fire_step = step
117
+ self.fire_reason = "; ".join(reasons)
118
+ logger.error(
119
+ "TRIPWIRE (GRPO collapse) at step %d: %s -- pre-registered response: ONE beta=1e-4 "
120
+ "rerun recorded AS the result; no third configuration.",
121
+ step,
122
+ self.fire_reason,
123
+ )
124
+ return True
125
+ return False
126
+
127
+ #: Bump on any change to the persisted layout; a mismatch refuses to load.
128
+ STATE_VERSION = 1
129
+
130
+ def _config_dict(self) -> dict:
131
+ return {
132
+ "baseline_steps": self.baseline_steps,
133
+ "window_steps": self.window_steps,
134
+ "entropy_floor_frac": self.entropy_floor_frac,
135
+ "length_floor_frac": self.length_floor_frac,
136
+ "min_steps_before_fire": self.min_steps_before_fire,
137
+ }
138
+
139
+ def state_dict(self) -> dict:
140
+ """Everything `observe` reads, as primitives only.
141
+
142
+ Plain dict/list/float/int/bool/None/str so it survives the Trainer's
143
+ ``torch.load(weights_only=True)`` of resume.pt -- unpickling arbitrary
144
+ objects to restore a counter would be a code-execution path for nothing.
145
+ The thresholds are included so `load_state_dict` can refuse a state cut
146
+ for a different configuration.
147
+ """
148
+ return {
149
+ "version": self.STATE_VERSION,
150
+ "config": self._config_dict(),
151
+ "baseline_entropy": [float(x) for x in self._baseline_entropy],
152
+ "baseline_length": [float(x) for x in self._baseline_length],
153
+ "entropy": [float(x) for x in self._entropy],
154
+ "length": [float(x) for x in self._length],
155
+ "steps": int(self._steps),
156
+ "fired": bool(self.fired),
157
+ "fire_reason": self.fire_reason,
158
+ "fire_step": self.fire_step,
159
+ }
160
+
161
+ def load_state_dict(self, sd: dict) -> None:
162
+ """Restore `state_dict()` output; refuse anything that is not exactly it.
163
+
164
+ A silently half-restored tripwire is the disarmed-instrument shape, so
165
+ every inconsistency raises: wrong version, thresholds that differ from
166
+ this instance's (a different experiment -- rebuild it deliberately),
167
+ or windows longer than the configured sizes. Validation happens BEFORE
168
+ any field is assigned, so a refused load leaves the instance untouched.
169
+ """
170
+ if sd.get("version") != self.STATE_VERSION:
171
+ raise ValueError(
172
+ f"CollapseTripwire state version {sd.get('version')!r} != {self.STATE_VERSION}"
173
+ )
174
+ if sd.get("config") != self._config_dict():
175
+ raise ValueError(
176
+ f"CollapseTripwire state was saved under {sd.get('config')!r} but this "
177
+ f"instance is configured {self._config_dict()!r}; resuming under different "
178
+ "thresholds is a different experiment."
179
+ )
180
+ base_e, base_l = list(sd["baseline_entropy"]), list(sd["baseline_length"])
181
+ ent, length = list(sd["entropy"]), list(sd["length"])
182
+ if len(base_e) != len(base_l) or len(ent) != len(length):
183
+ raise ValueError("CollapseTripwire state has mismatched entropy/length series")
184
+ if len(base_e) > self.baseline_steps or len(ent) > self.window_steps:
185
+ raise ValueError("CollapseTripwire state windows exceed the configured sizes")
186
+ if not all(math.isfinite(x) for x in base_e + base_l + ent + length):
187
+ raise ValueError("CollapseTripwire state contains non-finite observations")
188
+ self._baseline_entropy = [float(x) for x in base_e]
189
+ self._baseline_length = [float(x) for x in base_l]
190
+ self._entropy = deque((float(x) for x in ent), maxlen=self.window_steps)
191
+ self._length = deque((float(x) for x in length), maxlen=self.window_steps)
192
+ self._steps = int(sd["steps"])
193
+ self.fired = bool(sd["fired"])
194
+ self.fire_reason = sd["fire_reason"]
195
+ self.fire_step = sd["fire_step"]
196
+
197
+ def as_log_dict(self) -> dict:
198
+ return {
199
+ "tripwire/fired": self.fired,
200
+ "tripwire/fire_step": self.fire_step,
201
+ "tripwire/fire_reason": self.fire_reason,
202
+ }
code/align/rl/types.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rollout data types for stage-b GRPO.
2
+
3
+ The rollout ORCHESTRATOR (M-D, separate build) produces these; the
4
+ GRPOTrainModule consumes them. Alignment convention, stated once: per-token
5
+ logprob tensors are LABEL-ALIGNED -- position t holds the logprob of
6
+ input_ids[:, t+1], so they have length T-1 and pair with
7
+ `shifted_response_mask`. Getting this half-open boundary wrong is the same
8
+ off-by-one family as the decode-position incident (section-09) and the
9
+ completion-mask defect (audit R1); the validating constructor exists so the
10
+ mismatch fails at batch construction, not as a silently shifted objective.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, field
16
+
17
+ import torch
18
+
19
+
20
+ @dataclass
21
+ class RolloutBatch:
22
+ """One training micro-batch of rollout data.
23
+
24
+ Shapes (B = completions in the batch, T = padded sequence length):
25
+ input_ids [B, T] int64 prompt + completion, right-padded
26
+ attention_mask [B, T] int64 1 on real tokens
27
+ response_mask [B, T] int64 1 on COMPLETION tokens only (input space)
28
+ advantages [B] float per-sequence Dr.GRPO centered advantage
29
+ old_logprobs [B, T-1] policy-at-rollout logprobs (label-aligned)
30
+ vllm_logprobs [B, T-1] inference-engine logprobs (label-aligned)
31
+ ref_logprobs [B, T-1]? reference-policy logprobs; REQUIRED iff
32
+ the loss config has beta > 0 (the KL
33
+ ablation), absent otherwise
34
+ rewards {name: [B]} per-completion reward terms (reward/total,
35
+ reward/accuracy, ... -- itemised so the
36
+ format-consistency instrument reads terms,
37
+ not sums)
38
+ group_ids [B] int64 prompt-group id of each completion
39
+ """
40
+
41
+ input_ids: torch.Tensor
42
+ attention_mask: torch.Tensor
43
+ response_mask: torch.Tensor
44
+ advantages: torch.Tensor
45
+ old_logprobs: torch.Tensor
46
+ vllm_logprobs: torch.Tensor
47
+ group_ids: torch.Tensor
48
+ rewards: dict[str, torch.Tensor] = field(default_factory=dict)
49
+ ref_logprobs: torch.Tensor | None = None
50
+
51
+ def __post_init__(self) -> None:
52
+ B, T = self.input_ids.shape
53
+ for name, t, shape in (
54
+ ("attention_mask", self.attention_mask, (B, T)),
55
+ ("response_mask", self.response_mask, (B, T)),
56
+ ("advantages", self.advantages, (B,)),
57
+ ("old_logprobs", self.old_logprobs, (B, T - 1)),
58
+ ("vllm_logprobs", self.vllm_logprobs, (B, T - 1)),
59
+ ("group_ids", self.group_ids, (B,)),
60
+ ):
61
+ if tuple(t.shape) != shape:
62
+ raise ValueError(
63
+ f"RolloutBatch.{name} has shape {tuple(t.shape)}, expected {shape} "
64
+ f"(B={B}, T={T}; logprob tensors are label-aligned, length T-1)"
65
+ )
66
+ if self.ref_logprobs is not None and tuple(self.ref_logprobs.shape) != (B, T - 1):
67
+ raise ValueError(
68
+ f"RolloutBatch.ref_logprobs has shape {tuple(self.ref_logprobs.shape)}, "
69
+ f"expected {(B, T - 1)}"
70
+ )
71
+ for name, t in self.rewards.items():
72
+ if tuple(t.shape) != (B,):
73
+ raise ValueError(
74
+ f"RolloutBatch.rewards[{name!r}] has shape {tuple(t.shape)}, expected {(B,)}"
75
+ )
76
+
77
+ @property
78
+ def shifted_response_mask(self) -> torch.Tensor:
79
+ """Response mask in LABEL space [B, T-1], pairing with the logprobs."""
80
+ return self.response_mask[:, 1:]
81
+
82
+ @property
83
+ def n_response_tokens(self) -> int:
84
+ return int(self.shifted_response_mask.sum())
code/align/rollout/__init__.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rollout: request/result shapes, the native sampler, and the optional spool client.
2
+
3
+ Backends (selected by `align.rollout.client.make_rollout_client`):
4
+
5
+ 'native' (default) `native.NativeRolloutBackend` -- in-process torch KV-cache
6
+ sampler; zero dependencies beyond torch.
7
+ 'spool' `client.RolloutClient` -- file protocol to the optional
8
+ vLLM worker in extras/vllm_rollout/ (its own venv).
9
+
10
+ Nothing under align/ imports vllm or ray, and nothing under align/ imports
11
+ extras/ (tests/test_dependency_firewall.py enforces both). The extras worker
12
+ imports `align.rollout.protocol`, which is stdlib-only BY CONTRACT; this
13
+ package `__init__` therefore imports ONLY that module eagerly. The torch-using
14
+ pieces (`NativeRolloutBackend`, `make_rollout_client`) resolve lazily so the
15
+ worker's venv never pays for, or is constrained by, them.
16
+
17
+ Per the 2026-08-22 sovereignty amendment (RESULTS.md dated entry): vLLM is
18
+ permitted for GENERATION only, in an isolated venv (.venv-rollout), behind a
19
+ process boundary. The v1 topology (DESIGN v3 section B.1b, decided 2026-08-24):
20
+ 3 FSDP train ranks + 1 dedicated engine GPU; weight handoff via checkpoint
21
+ directory + engine reload every `sync_every` optimizer steps;
22
+ use_rho_correction=True absorbs the resulting off-policyness -- that is the
23
+ term's entire purpose. The native backend honours the same contract
24
+ (`load_weights_fn` on a changed weights_dir).
25
+ """
26
+
27
+ from align.rollout.protocol import (
28
+ RolloutBackend,
29
+ RolloutRequest,
30
+ RolloutResult,
31
+ read_request,
32
+ read_result,
33
+ write_request,
34
+ write_result,
35
+ )
36
+
37
+ __all__ = [
38
+ "PagedEngine",
39
+ "PagedEngineConfig",
40
+ "PagedResult",
41
+ "SamplingParams",
42
+ "NativeRolloutBackend",
43
+ "NativeRolloutConfig",
44
+ "RolloutBackend",
45
+ "RolloutRequest",
46
+ "RolloutResult",
47
+ "checkpoint_weight_loader",
48
+ "make_rollout_client",
49
+ "read_request",
50
+ "read_result",
51
+ "write_request",
52
+ "write_result",
53
+ ]
54
+
55
+ _LAZY = {
56
+ # P3 opt-in paged continuous-batching sampler (docs/P3_ENGINE.md). Nothing
57
+ # in the default GRPO/rollout path imports these.
58
+ "PagedEngine": "align.rollout.engine",
59
+ "PagedEngineConfig": "align.rollout.engine",
60
+ "PagedResult": "align.rollout.engine",
61
+ "SamplingParams": "align.rollout.engine",
62
+ "NativeRolloutBackend": "align.rollout.native",
63
+ "NativeRolloutConfig": "align.rollout.native",
64
+ "checkpoint_weight_loader": "align.rollout.native",
65
+ "make_rollout_client": "align.rollout.client",
66
+ }
67
+
68
+
69
+ def __getattr__(name):
70
+ if name in _LAZY:
71
+ import importlib
72
+
73
+ return getattr(importlib.import_module(_LAZY[name]), name)
74
+ raise AttributeError(f"module 'align.rollout' has no attribute {name!r}")
code/align/rollout/client.py ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Trainer-side rollout clients and the backend selector.
2
+
3
+ Two backends serve the same `RolloutRequest -> RolloutResult` contract
4
+ (align/rollout/protocol.py), chosen by `make_rollout_client(backend=...)`:
5
+
6
+ 'native' (DEFAULT) -- `native.NativeRolloutBackend`: in-process torch
7
+ KV-cache sampler on the training/passed model. Zero extra dependencies;
8
+ this is what makes GRPO runnable on torch + triton + numpy + safetensors.
9
+ 'spool' -- `RolloutClient` below: file protocol to the optional
10
+ vLLM worker (extras/vllm_rollout/rollout_worker.py) running in its OWN
11
+ venv. A throughput option, never a requirement.
12
+
13
+ This module lives in the TRAINING venv and imports nothing from vLLM -- the
14
+ process boundary is the sovereignty line. `spawn_worker` launches the extras
15
+ worker under the ISOLATED rollout venv with the full required environment
16
+ baked in, so a caller cannot forget the flashinfer workaround or point a cache
17
+ at the 13GB root filesystem.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ import subprocess
24
+ from typing import Optional
25
+
26
+ from align.rollout.protocol import (
27
+ RolloutBackend,
28
+ RolloutRequest,
29
+ RolloutResult,
30
+ wait_for_result,
31
+ write_request,
32
+ )
33
+
34
+ #: The selectable backends. Data, so error messages and CLIs iterate it.
35
+ BACKENDS: tuple[str, ...] = ("native", "spool")
36
+ DEFAULT_BACKEND = "native"
37
+
38
+ #: Repo root, derived from this file's location -- the client and the worker
39
+ #: script it spawns always come from the same checkout.
40
+ REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
41
+ ROLLOUT_PYTHON = os.path.join(REPO_ROOT, ".venv-rollout", "bin", "python")
42
+ #: The vLLM worker is an EXTRA (own venv, see extras/vllm_rollout/README.md),
43
+ #: not part of the align package: nothing under align/ may import it, but a
44
+ #: path string to launch it as a subprocess is the intended coupling.
45
+ WORKER_SCRIPT = os.path.join(REPO_ROOT, "extras", "vllm_rollout", "rollout_worker.py")
46
+
47
+
48
+ class RolloutClient:
49
+ """The 'spool' backend: submit rollout requests and block on their results.
50
+
51
+ Timeouts are the caller's budget decision (stage-b sets them from the
52
+ measured pilot throughput); the stale-heartbeat threshold distinguishes
53
+ a slow request from a dead worker -- see protocol.wait_for_result.
54
+ """
55
+
56
+ def __init__(self, spool: str, timeout_s: float, stale_heartbeat_s: float = 600.0):
57
+ self.spool = spool
58
+ self.timeout_s = float(timeout_s)
59
+ self.stale_heartbeat_s = float(stale_heartbeat_s)
60
+
61
+ def submit(self, request: RolloutRequest) -> str:
62
+ write_request(self.spool, request)
63
+ return request.request_id
64
+
65
+ def collect(self, request_id: str) -> RolloutResult:
66
+ return wait_for_result(
67
+ self.spool,
68
+ request_id,
69
+ timeout_s=self.timeout_s,
70
+ stale_heartbeat_s=self.stale_heartbeat_s,
71
+ )
72
+
73
+
74
+ #: torchrun/torchelastic variables that MUST NOT reach the worker: vLLM's
75
+ #: engine initialises torch.distributed internally, and a child that inherits
76
+ #: the trainer's rendezvous (MASTER_ADDR/RANK/WORLD_SIZE/...) tries to JOIN
77
+ #: the trainer's process group -- observed on the 2026-08-24 smoke as the
78
+ #: engine core dialing torchrun's TCP store while ranks 1-2 timed out waiting
79
+ #: for rank 0. The worker is a separate world of size 1, always.
80
+ _POISON_ENV_KEYS = (
81
+ "MASTER_ADDR",
82
+ "MASTER_PORT",
83
+ "RANK",
84
+ "WORLD_SIZE",
85
+ "LOCAL_RANK",
86
+ "LOCAL_WORLD_SIZE",
87
+ "GROUP_RANK",
88
+ "GROUP_WORLD_SIZE",
89
+ "ROLE_RANK",
90
+ "ROLE_WORLD_SIZE",
91
+ "ROLE_NAME",
92
+ "NODE_RANK",
93
+ )
94
+ _POISON_ENV_PREFIXES = ("TORCHELASTIC_", "PET_", "TORCH_NCCL_")
95
+
96
+
97
+ def _worker_env() -> dict:
98
+ """A copy of os.environ with every trainer-distributed variable scrubbed."""
99
+ return {
100
+ k: v
101
+ for k, v in os.environ.items()
102
+ if k not in _POISON_ENV_KEYS and not k.startswith(_POISON_ENV_PREFIXES)
103
+ }
104
+
105
+
106
+ def spawn_worker(
107
+ spool: str,
108
+ gpu: int,
109
+ log_path: str,
110
+ gpu_mem_util: float = 0.85,
111
+ tp: int = 1,
112
+ idle_exit_s: float = 0.0,
113
+ rollout_python: Optional[str] = None,
114
+ ) -> subprocess.Popen:
115
+ """Launch the vLLM worker on ONE dedicated GPU with the required env.
116
+
117
+ The env block is the launch checklist, encoded: the flashinfer sampler
118
+ is disabled (its JIT ninja build fails on this box's toolchain, measured
119
+ 2026-08-23), every cache/tmp path lands on /apps, and the HF hub cache is
120
+ the rlhf one the stage-b snapshots live in.
121
+ """
122
+ python = rollout_python or ROLLOUT_PYTHON
123
+ if not os.path.isfile(python):
124
+ raise FileNotFoundError(
125
+ f"rollout venv python not found at {python}; build .venv-rollout first "
126
+ "(uv venv + torch==2.11.0 triton==3.6.0 vllm==0.26.0 ray pyarrow)."
127
+ )
128
+ env = _worker_env()
129
+ env.update(
130
+ {
131
+ "CUDA_VISIBLE_DEVICES": str(gpu),
132
+ "VLLM_USE_FLASHINFER_SAMPLER": "0",
133
+ "TMPDIR": os.path.join(REPO_ROOT, ".tmp"),
134
+ "XDG_CACHE_HOME": os.path.join(REPO_ROOT, ".cache"),
135
+ "HF_HUB_CACHE": "/apps/development/rlhf/hf_cache/hub",
136
+ # worker logs must flush per line or a killed worker leaves an
137
+ # empty log (first smoke run, 2026-08-24)
138
+ "PYTHONUNBUFFERED": "1",
139
+ }
140
+ )
141
+ os.makedirs(os.path.dirname(os.path.abspath(log_path)), exist_ok=True)
142
+ log = open(log_path, "a")
143
+ return subprocess.Popen(
144
+ [
145
+ python,
146
+ WORKER_SCRIPT,
147
+ "--spool",
148
+ spool,
149
+ "--gpu-mem-util",
150
+ str(gpu_mem_util),
151
+ "--tp",
152
+ str(tp),
153
+ "--idle-exit-s",
154
+ str(idle_exit_s),
155
+ ],
156
+ stdout=log,
157
+ stderr=subprocess.STDOUT,
158
+ env=env,
159
+ cwd=REPO_ROOT,
160
+ # own process GROUP so teardown can reap vLLM's EngineCore child --
161
+ # terminating only the parent orphaned a 19.6GB engine core on GPU 3
162
+ # (fifth smoke run, 2026-08-24)
163
+ start_new_session=True,
164
+ )
165
+
166
+
167
+ def terminate_worker(proc: subprocess.Popen, timeout_s: float = 30.0) -> None:
168
+ """Kill the worker's WHOLE process group (worker + vLLM engine core).
169
+
170
+ proc.terminate() reaches only the parent; the engine core reparents to
171
+ init and keeps its VRAM. Group-SIGTERM, grace, then group-SIGKILL.
172
+ """
173
+ import signal
174
+
175
+ try:
176
+ pgid = os.getpgid(proc.pid)
177
+ except ProcessLookupError:
178
+ return
179
+ try:
180
+ os.killpg(pgid, signal.SIGTERM)
181
+ proc.wait(timeout=timeout_s)
182
+ except subprocess.TimeoutExpired:
183
+ os.killpg(pgid, signal.SIGKILL)
184
+ proc.wait(timeout=10)
185
+ except ProcessLookupError:
186
+ pass
187
+
188
+
189
+ #: Explicit name for the spool backend; `RolloutClient` is kept as the
190
+ #: original spelling because existing callers and tests import it.
191
+ SpoolRolloutClient = RolloutClient
192
+
193
+
194
+ def make_rollout_client(
195
+ backend: str = DEFAULT_BACKEND,
196
+ *,
197
+ # -- native -----------------------------------------------------------------
198
+ model=None,
199
+ tokenizer=None,
200
+ native_config=None,
201
+ load_weights_fn=None,
202
+ device=None,
203
+ # -- spool ------------------------------------------------------------------
204
+ spool: Optional[str] = None,
205
+ timeout_s: float = 3600.0,
206
+ stale_heartbeat_s: float = 600.0,
207
+ ) -> RolloutBackend:
208
+ """Build the selected rollout backend.
209
+
210
+ Each backend REFUSES the other's arguments' absence loudly instead of
211
+ falling back: 'native' without a model is not "spool by default", and
212
+ 'spool' without a spool dir is not "native by default". A silent switch of
213
+ generation backend changes throughput by an order of magnitude and
214
+ off-policyness by a sync interval, and would show up first as a mystery in
215
+ the step time.
216
+ """
217
+ if backend == "native":
218
+ if model is None or tokenizer is None:
219
+ raise ValueError(
220
+ "backend='native' needs model= and tokenizer= (the in-process sampler runs "
221
+ "on the model it is given); pass backend='spool' to use the external worker"
222
+ )
223
+ # lazy: keeps `import align.rollout.client` free of torch/engine for
224
+ # spool-only callers
225
+ from align.rollout.native import NativeRolloutBackend
226
+
227
+ return NativeRolloutBackend(
228
+ model, tokenizer, native_config, load_weights_fn=load_weights_fn, device=device
229
+ )
230
+ if backend == "spool":
231
+ if not spool:
232
+ raise ValueError("backend='spool' needs spool= (the request/result spool directory)")
233
+ return RolloutClient(spool, timeout_s=timeout_s, stale_heartbeat_s=stale_heartbeat_s)
234
+ raise ValueError(f"unknown rollout backend {backend!r}; choose one of {BACKENDS}")
code/align/rollout/engine.py ADDED
@@ -0,0 +1,674 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Continuous-batching sampler over a paged KV cache (P3, opt-in).
2
+
3
+ This is the native-engine step ROADMAP Track L calls P3: many sequences of very
4
+ different lengths in flight at once, admitted and retired independently,
5
+ backed by `align.rollout.paged`'s block tables. It is model-agnostic over
6
+ align's causal LMs (Mistral, Qwen3, OLMoE) WITHOUT touching them: the engine
7
+ drives the model's own submodules (`embed_tokens`, per-block `attn_norm` /
8
+ `attn.{q,k,v,o}_proj` / optional `q_norm`,`k_norm` / `mlp_norm` / `mlp`,
9
+ `norm`, `lm_head`, `rope`) layer by layer and replaces only the attention
10
+ kernel, which is the one thing a paged cache changes. Qwen3's per-head QK-norm
11
+ and OLMoE's full-width QK-norm (+ `clip_qkv`) are told apart by the norm weight
12
+ width, the same distinction the model files' docstrings warn about.
13
+
14
+ THE SCHEDULER, in one paragraph. Every sequence is `tokens` (prompt + sampled)
15
+ and `num_computed` (how many have KV in the cache). A step schedules, per
16
+ sequence, the next `n_new = min(len(tokens) - num_computed, chunk_size,
17
+ budget)` tokens. Prefill, chunked prefill, decode and post-preemption
18
+ recompute are therefore ONE code path; a sequence samples exactly when its
19
+ scheduled tokens reach the end of `tokens`. Mixed prefill chunks and decode
20
+ tokens share a step (one flat ragged batch), so a long prompt cannot stall
21
+ the decoders behind it (the head-of-line blocking a prefill-then-decode engine
22
+ has).
23
+
24
+ MEMORY PRESSURE. Blocks are allocated lazily as sequences grow. When a running
25
+ sequence cannot get its next block the YOUNGEST unscheduled running sequence is
26
+ preempted by RECOMPUTE: its blocks are freed, `num_computed` returns to 0, and
27
+ it re-enters the front of the waiting queue with its tokens (and its sampler
28
+ state) intact, so it re-prefills and carries on. Recompute rather than swap
29
+ because there is no host swap space on this box by design. The generator is
30
+ only advanced when a token is actually sampled, so a preemption changes WHEN a
31
+ token is drawn, never WHICH.
32
+
33
+ BATCH INVARIANCE (the property RL rollouts care about). A sequence's tokens
34
+ and logprobs should not depend on who else is in the batch, when it was
35
+ admitted, how its prompt was chunked, or whether it was preempted: otherwise
36
+ `pi_infer_old` (the logprobs that feed the rho correction) is a function of the
37
+ scheduler's mood, and two reruns of the same rollout seed disagree. Floating
38
+ point breaks this through (a) GEMM kernels whose reduction order depends on the
39
+ number of rows, and (b) attention whose reduction length depends on padding or
40
+ chunk size. `numerics='invariant'` removes both by construction: every
41
+ row-wise module call (projections, norms, MLP, lm_head) is issued on one row
42
+ at a time, and attention is one SDPA call per query row over exactly its own
43
+ keys. Every operand the kernel sees then depends only on the sequence itself.
44
+ It is slow and is the reference/RL-safe mode. `numerics='fast'` batches rows
45
+ and pads decode attention: faster, equal to 'invariant' to rounding only
46
+ (fp64: ~1e-15 on logits and tokens identical; bf16: expect occasional
47
+ argmax/sample flips on near-ties -- docs/P3_ENGINE.md has the measured rate).
48
+
49
+ SAMPLING. One `torch.Generator` PER SEQUENCE, seeded from `SamplingParams.seed`
50
+ (or a stable hash of the request id), advanced only by that sequence's own
51
+ draws. A shared engine generator (what `align.inference.Engine` uses) makes a
52
+ sampled token depend on how many other rows drew before it in the step, i.e. on
53
+ batch composition. Temperature, top-k, then top-p (minimal nucleus) are applied
54
+ to the fp32 logits; the RETURNED logprob is `log_softmax(logits/T)[token]`
55
+ UNTRUNCATED, the same basis as `align.rollout.native.LOGPROB_BASIS`, so the
56
+ rho correction contract is unchanged.
57
+
58
+ PREFIX SHARING (opt-in). Requests whose prompts share leading FULL blocks with
59
+ a running sequence adopt that sequence's blocks by reference instead of
60
+ recomputing them (GRPO: G samples of one prompt prefill it once). A follower
61
+ waits until the leader has computed the shared region, never shares a partial
62
+ block, and always recomputes at least its last prompt token (it needs that
63
+ row's logits). The allocator's refcounts keep shared blocks alive until the
64
+ last owner leaves; `PagedKVCache.slots` refuses writes into a shared block.
65
+ """
66
+
67
+ from __future__ import annotations
68
+
69
+ import hashlib
70
+ import math
71
+ import time
72
+ from dataclasses import dataclass, field
73
+ from typing import Callable, Optional, Sequence
74
+
75
+ import torch
76
+ import torch.nn as nn
77
+
78
+ from align.inference.engine import _sampling_distribution, find_stop
79
+ from align.model.mistral import apply_rotary, kv_head_index_map
80
+ from align.rollout.paged import (
81
+ OutOfBlocks,
82
+ PagedAttention,
83
+ PagedKVCache,
84
+ Segment,
85
+ StepBatch,
86
+ TorchPagedAttention,
87
+ )
88
+
89
+ FINISH_REASONS = ("eos", "stop_token", "stop_string", "max_new_tokens", "max_seq_len")
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Public dataclasses
94
+ # ---------------------------------------------------------------------------
95
+
96
+
97
+ @dataclass
98
+ class SamplingParams:
99
+ """Per-sequence sampling. `temperature` 0/None = greedy."""
100
+
101
+ max_tokens: int = 64
102
+ temperature: Optional[float] = 0.0
103
+ top_p: float = 1.0
104
+ #: 0 disables. Applied BEFORE top-p (vLLM's order).
105
+ top_k: int = 0
106
+ #: None -> stable hash of the request id (NOT admission order, which would
107
+ #: make "unseeded" depend on batch composition).
108
+ seed: Optional[int] = None
109
+ stop_token_ids: tuple[int, ...] = ()
110
+ #: text stops need a tokenizer; the completion keeps the token that
111
+ #: completed the stop string (token ids and logprobs stay aligned).
112
+ stop: tuple[str, ...] = ()
113
+
114
+ def validate(self) -> None:
115
+ if self.max_tokens < 1:
116
+ raise ValueError(f"max_tokens={self.max_tokens} must be >= 1")
117
+ t = self.temperature
118
+ if t is not None and (math.isnan(t) or t < 0):
119
+ raise ValueError(f"temperature={t} must be >= 0")
120
+ if math.isnan(self.top_p) or not (0.0 < self.top_p <= 1.0):
121
+ raise ValueError(f"top_p={self.top_p} must be in (0, 1]")
122
+ if self.top_k < 0:
123
+ raise ValueError(f"top_k={self.top_k} must be >= 0")
124
+
125
+ @property
126
+ def greedy(self) -> bool:
127
+ return self.temperature is None or self.temperature == 0.0
128
+
129
+
130
+ @dataclass
131
+ class PagedEngineConfig:
132
+ #: Tokens per KV block. 16 is vLLM's default; smaller wastes less tail,
133
+ #: larger gathers fewer indices.
134
+ block_size: int = 16
135
+ num_blocks: int = 256
136
+ #: Max sequences resident (admitted) at once.
137
+ max_num_seqs: int = 32
138
+ #: Per-step token budget across all segments (decode tokens + prefill chunks).
139
+ max_num_batched_tokens: int = 512
140
+ #: Max prompt tokens one sequence contributes to a step. None -> the budget.
141
+ chunk_size: Optional[int] = None
142
+ #: Hard cap on prompt + completion. None -> the model's max_position_embeddings.
143
+ max_seq_len: Optional[int] = None
144
+ #: 'invariant' (bit-reproducible across batch compositions; slow) or 'fast'.
145
+ numerics: str = "invariant"
146
+ prefix_sharing: bool = False
147
+ eos_token_id: Optional[int] = None
148
+ #: KV dtype. None -> the model's parameter dtype.
149
+ kv_dtype: Optional[torch.dtype] = None
150
+ #: Replace the attention backend (e.g. a future Triton kernel).
151
+ attention: Optional[PagedAttention] = None
152
+
153
+ def validate(self) -> None:
154
+ if self.numerics not in ("invariant", "fast"):
155
+ raise ValueError(f"numerics={self.numerics!r}; expected 'invariant' or 'fast'")
156
+ if min(self.block_size, self.num_blocks, self.max_num_seqs, self.max_num_batched_tokens) < 1:
157
+ raise ValueError(f"bad PagedEngineConfig: {self}")
158
+ if self.chunk_size is not None and self.chunk_size < 1:
159
+ raise ValueError(f"chunk_size={self.chunk_size} must be >= 1")
160
+
161
+
162
+ @dataclass
163
+ class PagedResult:
164
+ rid: str
165
+ prompt_ids: list[int]
166
+ token_ids: list[int]
167
+ logprobs: list[float]
168
+ finish_reason: str
169
+ preemptions: int = 0
170
+ shared_prefix_tokens: int = 0
171
+ text: Optional[str] = None
172
+ step_logits: Optional[list[torch.Tensor]] = None
173
+
174
+
175
+ @dataclass
176
+ class _Seq:
177
+ rid: str
178
+ prompt: list[int]
179
+ params: SamplingParams
180
+ order: int # monotonically increasing arrival index (age for preemption)
181
+ generator: torch.Generator
182
+ tokens: list[int] = field(default_factory=list)
183
+ num_computed: int = 0
184
+ logprobs: list[float] = field(default_factory=list)
185
+ finish: Optional[str] = None
186
+ preemptions: int = 0
187
+ shared_tokens: int = 0
188
+ logits_log: Optional[list[torch.Tensor]] = None
189
+
190
+ @property
191
+ def remaining(self) -> int:
192
+ return len(self.tokens) - self.num_computed
193
+
194
+ @property
195
+ def n_generated(self) -> int:
196
+ return len(self.tokens) - len(self.prompt)
197
+
198
+
199
+ # ---------------------------------------------------------------------------
200
+ # Sampling
201
+ # ---------------------------------------------------------------------------
202
+
203
+
204
+ def top_k_filter(logits: torch.Tensor, k: int) -> torch.Tensor:
205
+ """Keep the k largest logits of a [V] / [B,V] tensor, others -> -inf.
206
+
207
+ Ties at the k-th value are resolved by `torch.topk`'s index order, which is
208
+ deterministic; the cut is by COUNT, never by value, so exactly k survive."""
209
+ if k <= 0 or k >= logits.shape[-1]:
210
+ return logits
211
+ idx = torch.topk(logits, k, dim=-1).indices
212
+ out = torch.full_like(logits, float("-inf"))
213
+ return out.scatter(-1, idx, logits.gather(-1, idx))
214
+
215
+
216
+ def row_distribution(logits: torch.Tensor, p: SamplingParams) -> torch.Tensor:
217
+ """The distribution a non-greedy draw is made from: softmax(logits/T) over the
218
+ top-k survivors, truncated to the minimal top-p nucleus, renormalised. [V] fp32.
219
+ Split out so tests can compare it with an independent fp64 reference instead
220
+ of inferring it from draw frequencies."""
221
+ masked = top_k_filter(logits.float(), p.top_k)
222
+ return _sampling_distribution(masked.unsqueeze(0), float(p.temperature), p.top_p)[0]
223
+
224
+
225
+ def sample_row(
226
+ logits: torch.Tensor, p: SamplingParams, gen: torch.Generator
227
+ ) -> tuple[int, float]:
228
+ """One row [V] -> (token, logprob). Greedy = argmax. The logprob is of the
229
+ UNTRUNCATED tempered distribution (T=1 for greedy): top-k/top-p decide which
230
+ token is drawn, they are not part of the distribution the training objective
231
+ models (see align.rollout.native for why folding them in breaks rho)."""
232
+ x = logits.float()
233
+ if p.greedy:
234
+ tok = int(x.argmax())
235
+ return tok, float(torch.log_softmax(x, dim=-1)[tok])
236
+ lp = torch.log_softmax(x / float(p.temperature), dim=-1)
237
+ probs = row_distribution(logits, p)
238
+ tok = int(torch.multinomial(probs.unsqueeze(0), 1, generator=gen)[0, 0])
239
+ return tok, float(lp[tok])
240
+
241
+
242
+ def _stable_seed(rid: str) -> int:
243
+ return int.from_bytes(hashlib.sha256(rid.encode()).digest()[:8], "little") & ((1 << 62) - 1)
244
+
245
+
246
+ # ---------------------------------------------------------------------------
247
+ # Model adapter
248
+ # ---------------------------------------------------------------------------
249
+
250
+
251
+ class _ModelView:
252
+ """Reads align's causal-LM structure and runs it layer by layer on a flat
253
+ token batch. Raises at construction on anything it does not recognise, so a
254
+ new architecture fails at build, not as wrong logits."""
255
+
256
+ def __init__(self, model: nn.Module):
257
+ try:
258
+ inner = model.model
259
+ self.embed, self.layers, self.final_norm, self.rope = (
260
+ inner.embed_tokens, list(inner.layers), inner.norm, inner.rope,
261
+ )
262
+ self.lm_head = model.lm_head
263
+ except AttributeError as e:
264
+ raise TypeError(
265
+ f"{type(model).__name__} does not look like an align causal LM "
266
+ f"(needs .model.{{embed_tokens,layers,norm,rope}} and .lm_head): {e}"
267
+ ) from None
268
+ a0 = self.layers[0].attn
269
+ for attr in ("n_heads", "n_kv_heads", "head_dim", "scale", "q_proj", "k_proj", "v_proj", "o_proj"):
270
+ if not hasattr(a0, attr):
271
+ raise TypeError(f"attention module lacks `{attr}`; unsupported architecture")
272
+ self.n_layers = len(self.layers)
273
+ self.n_heads, self.n_kv_heads, self.head_dim, self.scale = (
274
+ a0.n_heads, a0.n_kv_heads, a0.head_dim, a0.scale,
275
+ )
276
+ # The paged backends use SDPA's enable_gqa, i.e. the canonical BLOCKED map
277
+ # q_head // group -> kv_head. Anything else would be silently wrong.
278
+ group = self.n_heads // self.n_kv_heads
279
+ idx = kv_head_index_map(self.n_heads, self.n_kv_heads, device=torch.device("cpu"))
280
+ if not torch.equal(idx, torch.arange(self.n_heads) // group):
281
+ raise NotImplementedError("non-canonical kv-head map is not supported by the paged engine")
282
+ self.qk_norm = self._qk_norm_kind(a0)
283
+ self.clip = getattr(a0, "clip_qkv", None)
284
+
285
+ def _qk_norm_kind(self, attn) -> Optional[str]:
286
+ if not hasattr(attn, "q_norm"):
287
+ return None
288
+ w = attn.q_norm.weight.numel()
289
+ if w == self.head_dim:
290
+ return "head" # Qwen3
291
+ if w == self.n_heads * self.head_dim:
292
+ return "full" # OLMoE
293
+ raise NotImplementedError(f"q_norm width {w} matches neither head_dim nor n_heads*head_dim")
294
+
295
+ def qkv(self, attn, h: torch.Tensor):
296
+ """h [1,t,D] -> q [1,t,H,Dh], k/v [1,t,Hkv,Dh] BEFORE RoPE."""
297
+ b, t, _ = h.shape
298
+ q, k, v = attn.q_proj(h), attn.k_proj(h), attn.v_proj(h)
299
+ if self.qk_norm == "full":
300
+ q, k = attn.q_norm(q), attn.k_norm(k)
301
+ if self.clip is not None:
302
+ c = self.clip
303
+ q, k, v = q.clamp(-c, c), k.clamp(-c, c), v.clamp(-c, c)
304
+ q = q.view(b, t, self.n_heads, self.head_dim)
305
+ k = k.view(b, t, self.n_kv_heads, self.head_dim)
306
+ v = v.view(b, t, self.n_kv_heads, self.head_dim)
307
+ if self.qk_norm == "head":
308
+ q, k = attn.q_norm(q), attn.k_norm(k)
309
+ return q, k, v
310
+
311
+
312
+ def _assert_finite_logits(logits: torch.Tensor, rids: list[str]) -> None:
313
+ """Raise FloatingPointError if any sampled row holds NaN or +inf.
314
+
315
+ Sampled rows would fail loudly inside torch.multinomial, but GREEDY rows do not:
316
+ argmax of a row containing NaN returns the NaN's index and `log_softmax` returns
317
+ NaN, so a bf16 overflow silently becomes a plausible-looking token with a NaN
318
+ logprob that then poisons the rho correction. -inf is allowed (a masked vocab).
319
+ Called BEFORE `num_computed` advances, so the step is retryable and no sequence has
320
+ consumed a draw from its generator."""
321
+ ok = (logits == logits) & (logits != float("inf"))
322
+ if logits.numel() and not bool(ok.all()):
323
+ bad = [rids[i] for i in range(len(rids)) if not bool(ok[i].all())]
324
+ raise FloatingPointError(f"non-finite logits (NaN/+inf) for request(s) {bad}")
325
+
326
+
327
+ def _rows(fn: Callable, x: torch.Tensor, rowwise: bool):
328
+ """Apply `fn` to x [1,T,D]; if `rowwise`, once per row [1,1,D] and re-join.
329
+
330
+ This is the whole invariance trick for non-attention ops: a GEMM with M=1
331
+ runs the same kernel on the same operands whatever the step contains."""
332
+ if not rowwise or x.shape[1] == 1:
333
+ return fn(x)
334
+ outs = [fn(x[:, i : i + 1]) for i in range(x.shape[1])]
335
+ if isinstance(outs[0], tuple):
336
+ return tuple(torch.cat([o[j] for o in outs], dim=1) for j in range(len(outs[0])))
337
+ return torch.cat(outs, dim=1)
338
+
339
+
340
+ # ---------------------------------------------------------------------------
341
+ # Engine
342
+ # ---------------------------------------------------------------------------
343
+
344
+
345
+ class PagedEngine:
346
+ """Continuous-batching sampler. Usage:
347
+
348
+ eng = PagedEngine(model, PagedEngineConfig(num_blocks=64))
349
+ eng.add_request("a", prompt_ids, SamplingParams(max_tokens=32, seed=1))
350
+ while eng.has_unfinished():
351
+ eng.step() # may add_request between steps
352
+ res = eng.results["a"]
353
+ """
354
+
355
+ def __init__(self, model: nn.Module, cfg: Optional[PagedEngineConfig] = None, *,
356
+ tokenizer=None, return_logits: bool = False):
357
+ self.cfg = cfg if cfg is not None else PagedEngineConfig()
358
+ self.cfg.validate()
359
+ self.model = model
360
+ self.view = _ModelView(model)
361
+ self.tokenizer = tokenizer
362
+ self.return_logits = return_logits
363
+ p0 = next(model.parameters())
364
+ self.device = p0.device
365
+ kv_dtype = self.cfg.kv_dtype or p0.dtype
366
+ mcfg = getattr(model, "cfg", None)
367
+ self.max_seq_len = self.cfg.max_seq_len or getattr(mcfg, "max_position_embeddings", 2048)
368
+ self.cache = PagedKVCache(
369
+ self.view.n_layers, self.cfg.num_blocks, self.cfg.block_size,
370
+ self.view.n_kv_heads, self.view.head_dim, kv_dtype, self.device,
371
+ )
372
+ self.rowwise = self.cfg.numerics == "invariant"
373
+ self.attention: PagedAttention = self.cfg.attention or TorchPagedAttention(
374
+ "row" if self.rowwise else "segment"
375
+ )
376
+ self.waiting: list[_Seq] = []
377
+ self.running: list[_Seq] = []
378
+ self.results: dict[str, PagedResult] = {}
379
+ self._known: set[str] = set()
380
+ self._arrivals = 0
381
+ self.stats = dict(steps=0, tokens_forward=0, preemptions=0, prefix_tokens_shared=0,
382
+ peak_blocks_used=0, tokens_sampled=0, forward_seconds=0.0)
383
+
384
+ # -- requests ---------------------------------------------------------------
385
+
386
+ def add_request(self, rid: str, prompt_ids: Sequence[int], params: Optional[SamplingParams] = None) -> None:
387
+ params = params if params is not None else SamplingParams()
388
+ params.validate()
389
+ if rid in self._known:
390
+ raise ValueError(f"duplicate request id {rid!r}")
391
+ prompt = [int(t) for t in prompt_ids]
392
+ if not prompt:
393
+ raise ValueError(f"{rid!r}: empty prompt")
394
+ # An out-of-range id fails INSIDE the embedding lookup during `step()`, long after
395
+ # add_request returned: on CPU it is an IndexError that re-fires on every later step
396
+ # (the poisoned request stays in `running`, so one bad prompt wedges every other
397
+ # request), on CUDA a device-side assert that corrupts the whole context. Refuse it here.
398
+ vocab = self.view.embed.weight.shape[0]
399
+ bad = next((t for t in prompt if not 0 <= t < vocab), None)
400
+ if bad is not None:
401
+ raise ValueError(f"{rid!r}: token id {bad} outside the model vocabulary [0, {vocab})")
402
+ if len(prompt) >= self.max_seq_len:
403
+ raise ValueError(f"{rid!r}: prompt of {len(prompt)} tokens reaches max_seq_len={self.max_seq_len}")
404
+ if params.stop and self.tokenizer is None:
405
+ raise ValueError("text stop sequences need a tokenizer")
406
+ # The LAST sampled token is never fed back, so it never gets KV: a request
407
+ # that ends with `worst` tokens holds `worst - 1` in the cache. Sizing the guard
408
+ # on `worst` refuses a request that fits exactly (prompt 3 + 6 new, block 4 ->
409
+ # 8 cached tokens = 2 blocks, yet a 2-block pool was rejected as needing 3).
410
+ worst = min(len(prompt) + params.max_tokens, self.max_seq_len) - 1
411
+ if self.cache.blocks_needed(worst) > self.cache.num_blocks:
412
+ raise ValueError(
413
+ f"{rid!r} can need {self.cache.blocks_needed(worst)} blocks but the pool has "
414
+ f"{self.cache.num_blocks}: it could never finish even running alone"
415
+ )
416
+ seed = params.seed if params.seed is not None else _stable_seed(rid)
417
+ gen = torch.Generator(device=self.device)
418
+ gen.manual_seed(int(seed))
419
+ seq = _Seq(rid=rid, prompt=prompt, params=params, order=self._arrivals, generator=gen,
420
+ tokens=list(prompt), logits_log=[] if self.return_logits else None)
421
+ self._arrivals += 1
422
+ self._known.add(rid)
423
+ self.waiting.append(seq)
424
+
425
+ def has_unfinished(self) -> bool:
426
+ return bool(self.waiting or self.running)
427
+
428
+ # -- scheduling -------------------------------------------------------------
429
+
430
+ def _lcp(self, a: list[int], b: list[int]) -> int:
431
+ n = min(len(a), len(b))
432
+ i = 0
433
+ while i < n and a[i] == b[i]:
434
+ i += 1
435
+ return i
436
+
437
+ def _find_share(self, seq: _Seq) -> Optional[tuple[list[int], bool]]:
438
+ """-> (blocks to adopt, defer?) for the best leader among running seqs."""
439
+ bs = self.cfg.block_size
440
+ cap_blocks = (len(seq.tokens) - 1) // bs # always recompute the last token
441
+ best: list[int] = []
442
+ defer = False
443
+ for src in self.running:
444
+ lcp_blocks = min(self._lcp(seq.tokens, src.tokens), len(src.prompt)) // bs
445
+ lcp_blocks = min(lcp_blocks, cap_blocks)
446
+ if lcp_blocks == 0:
447
+ continue
448
+ ready = min(lcp_blocks, src.num_computed // bs)
449
+ if ready < lcp_blocks and src.num_computed < len(src.prompt):
450
+ defer = True # the leader is still computing blocks we could adopt
451
+ if ready > len(best):
452
+ best = list(self.cache.tables[src.rid][:ready])
453
+ return best, defer
454
+
455
+ @staticmethod
456
+ def _pick_victim(victims: list[_Seq]) -> _Seq:
457
+ """The YOUNGEST (latest arrival): it has the least computed work to throw
458
+ away, and older sequences keep making progress, so the oldest request is
459
+ never starved by newer ones."""
460
+ return max(victims, key=lambda s: s.order)
461
+
462
+ def _preempt(self, seq: _Seq) -> None:
463
+ self.cache.free_seq(seq.rid)
464
+ self.running.remove(seq)
465
+ seq.num_computed = 0
466
+ seq.shared_tokens = 0
467
+ seq.preemptions += 1
468
+ self.stats["preemptions"] += 1
469
+ self.waiting.append(seq)
470
+ self.waiting.sort(key=lambda s: s.order)
471
+
472
+ def _schedule(self) -> list[tuple[_Seq, int]]:
473
+ cfg = self.cfg
474
+ chunk = cfg.chunk_size or cfg.max_num_batched_tokens
475
+ budget = cfg.max_num_batched_tokens
476
+ sched: list[tuple[_Seq, int]] = []
477
+ scheduled: set[str] = set()
478
+
479
+ # 1) resident sequences: decodes first, then prefill chunks, oldest first
480
+ for seq in sorted(self.running, key=lambda s: (s.remaining > 1, s.order)):
481
+ if seq not in self.running or budget <= 0:
482
+ continue
483
+ n = min(seq.remaining, chunk, budget)
484
+ while True:
485
+ try:
486
+ self.cache.ensure_capacity(seq.rid, seq.num_computed + n)
487
+ break
488
+ except OutOfBlocks:
489
+ victims = [s for s in self.running if s is not seq and s.rid not in scheduled]
490
+ if not victims:
491
+ n = 0
492
+ break
493
+ self._preempt(self._pick_victim(victims))
494
+ if n == 0:
495
+ continue
496
+ sched.append((seq, n))
497
+ scheduled.add(seq.rid)
498
+ budget -= n
499
+
500
+ # 2) admit waiting sequences (FIFO, deferred followers skipped)
501
+ for seq in list(self.waiting):
502
+ if budget <= 0 or len(self.running) >= cfg.max_num_seqs:
503
+ break
504
+ shared: list[int] = []
505
+ if cfg.prefix_sharing:
506
+ got = self._find_share(seq)
507
+ shared, defer = got
508
+ if defer:
509
+ continue
510
+ start = len(shared) * cfg.block_size
511
+ n = min(len(seq.tokens) - start, chunk, budget)
512
+ need = self.cache.blocks_needed(len(seq.tokens)) - len(shared)
513
+ if not self.cache.allocator.can_allocate(need):
514
+ break # keep FIFO: do not let a small request leapfrog a starving big one
515
+ self.waiting.remove(seq)
516
+ self.cache.add_seq(seq.rid, shared)
517
+ seq.num_computed = start
518
+ seq.shared_tokens = start
519
+ self.stats["prefix_tokens_shared"] += start
520
+ self.cache.ensure_capacity(seq.rid, start + n)
521
+ self.running.append(seq)
522
+ sched.append((seq, n))
523
+ scheduled.add(seq.rid)
524
+ budget -= n
525
+ return sched
526
+
527
+ # -- one step ---------------------------------------------------------------
528
+
529
+ def step(self) -> list[tuple[str, int, Optional[str]]]:
530
+ """Run one scheduler step. Returns [(rid, token, finish_reason|None)] for
531
+ every token sampled this step."""
532
+ sched = self._schedule()
533
+ if not sched:
534
+ if self.running or self.waiting:
535
+ raise RuntimeError(
536
+ "scheduler made no progress: no sequence can get the blocks it needs "
537
+ f"(free={self.cache.allocator.num_free}, running={len(self.running)}, "
538
+ f"waiting={len(self.waiting)})"
539
+ )
540
+ return []
541
+ batch = StepBatch()
542
+ toks: list[int] = []
543
+ pos: list[int] = []
544
+ off = 0
545
+ for seq, n in sched:
546
+ batch.segments.append(Segment(seq.rid, off, n, seq.num_computed))
547
+ toks.extend(seq.tokens[seq.num_computed : seq.num_computed + n])
548
+ pos.extend(range(seq.num_computed, seq.num_computed + n))
549
+ off += n
550
+ # rows whose logits we need: the last row of every segment that reaches
551
+ # the end of its sequence's tokens
552
+ sample_rows = [
553
+ (seq, seg.offset + seg.n_new - 1)
554
+ for (seq, n), seg in zip(sched, batch.segments)
555
+ if seq.num_computed + n == len(seq.tokens)
556
+ ]
557
+ t0 = time.perf_counter()
558
+ with torch.no_grad():
559
+ logits = self._forward(
560
+ torch.tensor(toks, dtype=torch.long, device=self.device),
561
+ torch.tensor(pos, dtype=torch.long, device=self.device),
562
+ batch,
563
+ [r for _, r in sample_rows],
564
+ )
565
+ # The finiteness check reads a device scalar, i.e. it synchronises: placing it BEFORE
566
+ # the timer stops makes `forward_seconds` include the GPU work (CUDA launches are
567
+ # async, so without a sync here the stat measured kernel-launch time only).
568
+ _assert_finite_logits(logits, [seq.rid for seq, _ in sample_rows])
569
+ self.stats["forward_seconds"] += time.perf_counter() - t0
570
+ self.stats["steps"] += 1
571
+ self.stats["tokens_forward"] += off
572
+ self.stats["peak_blocks_used"] = max(self.stats["peak_blocks_used"], self.cache.allocator.num_used)
573
+
574
+ for seq, n in sched:
575
+ seq.num_computed += n
576
+ self.cache.lengths[seq.rid] = seq.num_computed
577
+
578
+ events = []
579
+ for i, (seq, _) in enumerate(sample_rows):
580
+ row = logits[i]
581
+ tok, lp = sample_row(row, seq.params, seq.generator)
582
+ if seq.logits_log is not None:
583
+ seq.logits_log.append(row.detach().float().cpu())
584
+ seq.tokens.append(tok)
585
+ seq.logprobs.append(lp)
586
+ self.stats["tokens_sampled"] += 1
587
+ reason = self._finish_reason(seq)
588
+ events.append((seq.rid, tok, reason))
589
+ if reason is not None:
590
+ self._retire(seq, reason)
591
+ return events
592
+
593
+ def _finish_reason(self, seq: _Seq) -> Optional[str]:
594
+ p, gen = seq.params, seq.tokens[len(seq.prompt):]
595
+ last = gen[-1]
596
+ if self.cfg.eos_token_id is not None and last == self.cfg.eos_token_id:
597
+ return "eos"
598
+ if last in p.stop_token_ids:
599
+ return "stop_token"
600
+ if p.stop:
601
+ text = self.tokenizer.decode(gen, skip_special_tokens=True)
602
+ if find_stop(text, p.stop) is not None:
603
+ return "stop_string"
604
+ if len(gen) >= p.max_tokens:
605
+ return "max_new_tokens"
606
+ if len(seq.tokens) >= self.max_seq_len:
607
+ return "max_seq_len"
608
+ return None
609
+
610
+ def _retire(self, seq: _Seq, reason: str) -> None:
611
+ seq.finish = reason
612
+ self.cache.free_seq(seq.rid)
613
+ self.running.remove(seq)
614
+ gen = seq.tokens[len(seq.prompt):]
615
+ text = self.tokenizer.decode(gen, skip_special_tokens=True) if self.tokenizer is not None else None
616
+ self.results[seq.rid] = PagedResult(
617
+ rid=seq.rid, prompt_ids=list(seq.prompt), token_ids=list(gen), logprobs=list(seq.logprobs),
618
+ finish_reason=reason, preemptions=seq.preemptions, shared_prefix_tokens=seq.shared_tokens,
619
+ text=text, step_logits=seq.logits_log,
620
+ )
621
+
622
+ # -- the model, layer by layer -------------------------------------------------
623
+
624
+ def _forward(self, tokens: torch.Tensor, positions: torch.Tensor, batch: StepBatch,
625
+ sample_row_ids: list[int]) -> torch.Tensor:
626
+ v, rw = self.view, self.rowwise
627
+ x = v.embed(tokens).unsqueeze(0) # [1, T, D]
628
+ cos, sin = v.rope(positions.unsqueeze(0))
629
+ for li, blk in enumerate(v.layers):
630
+ attn = blk.attn
631
+ h = _rows(blk.attn_norm, x, rw)
632
+ q, k, val = _rows(lambda z: v.qkv(attn, z), h, rw)
633
+ qh, kh = apply_rotary(q.transpose(1, 2), k.transpose(1, 2), cos, sin) # [1,H,T,Dh]
634
+ out = self.attention(
635
+ li, qh[0].transpose(0, 1), kh[0].transpose(0, 1), val[0],
636
+ self.cache, batch, v.scale,
637
+ ) # [T, H, Dh]
638
+ x = x + _rows(attn.o_proj, out.reshape(1, out.shape[0], -1), rw)
639
+
640
+ def mlp(z, blk=blk):
641
+ o = blk.mlp(blk.mlp_norm(z))
642
+ return o[0] if isinstance(o, tuple) else o
643
+
644
+ x = x + _rows(mlp, x, rw)
645
+ if not sample_row_ids:
646
+ return x.new_zeros((0, 1))
647
+ sel = x[:, torch.tensor(sample_row_ids, device=x.device)]
648
+ sel = _rows(v.final_norm, sel, rw)
649
+ return _rows(v.lm_head, sel, rw)[0]
650
+
651
+ # -- convenience ----------------------------------------------------------------
652
+
653
+ def generate(
654
+ self,
655
+ requests: Sequence[tuple[str, Sequence[int], SamplingParams]],
656
+ arrival_step: Optional[dict[str, int]] = None,
657
+ max_steps: int = 1_000_000,
658
+ ) -> dict[str, PagedResult]:
659
+ """Run `requests` to completion. `arrival_step[rid] = s` admits that
660
+ request to the queue just before step s (0 = immediately), which is how
661
+ tests stage 'admitted at different steps'."""
662
+ arrival_step = arrival_step or {}
663
+ pending = sorted(requests, key=lambda r: arrival_step.get(r[0], 0))
664
+ i, s = 0, 0
665
+ while i < len(pending) or self.has_unfinished():
666
+ while i < len(pending) and arrival_step.get(pending[i][0], 0) <= s:
667
+ self.add_request(*pending[i])
668
+ i += 1
669
+ if self.has_unfinished():
670
+ self.step()
671
+ s += 1
672
+ if s > max_steps:
673
+ raise RuntimeError("generate exceeded max_steps")
674
+ return {r[0]: self.results[r[0]] for r in requests}
code/align/rollout/native.py ADDED
@@ -0,0 +1,372 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Native rollout: GRPO generation on torch alone, in-process, zero extra deps.
2
+
3
+ This is the CORE rollout backend. vLLM (extras/vllm_rollout/) is an optional
4
+ throughput upgrade behind a process boundary; this module is what makes the
5
+ GRPO stage runnable with exactly torch + triton + numpy + safetensors. It
6
+ satisfies the same `RolloutBackend` surface (submit/collect) and returns the
7
+ same `RolloutResult` the spool client returns, so `align.rl.batch_source`
8
+ cannot tell which backend served it.
9
+
10
+ What it does, in one paragraph: tokenize each prompt ONCE (those ids are
11
+ returned in `extras["prompt_token_ids"]` and are what assembly concatenates --
12
+ prompts are never re-tokenized), expand to P x G `GenerationRequest`s, run
13
+ them through `align.inference.engine.Engine` (varlen batched prefill,
14
+ contiguous per-slot KV, completion-based refill) with
15
+ `return_logprobs=True`, and regroup into completions[i][j] / logprobs[i][j] /
16
+ texts[i][j]. The per-token logprobs are captured AT SAMPLING TIME from the
17
+ very logits the token was drawn from; they are never recomputed from text.
18
+
19
+ THE RHO-CORRECTION CONTRACT (the reason logprobs travel with the rollout).
20
+ The GRPO module's `old_logprobs` is a teacher-forced pass over
21
+ (prompt + completion): `log_softmax(logits / T)` gathered at the label.
22
+ `vllm_logprobs` (pi^infer_old) must be the same quantity as seen by the
23
+ sampler. With the native engine they differ only by floating-point noise
24
+ (KV-cache decode vs full-sequence forward), so rho = exp(old - infer) == 1 up
25
+ to ~1e-6 in fp32 -- which `tests/test_rollout_native.py` pins by recomputing
26
+ the teacher-forced gather on the same model. The basis is therefore
27
+ log_softmax(logits / T), UNTRUNCATED: top-p shapes which token gets drawn but
28
+ is not a part of the distribution the training objective models, and folding
29
+ it into the logprob would make rho != 1 on-policy for every top_p < 1.
30
+
31
+ TOPOLOGY CAVEAT. The backend drives a model it can call standalone on its own
32
+ device. That is a dedicated engine copy (the v1 topology: one engine GPU
33
+ beside the FSDP train ranks) or a single-process run (CPU tests, 1-GPU
34
+ smoke). It is NOT valid to point it at an FSDP2-sharded module from one rank:
35
+ the decode loop's forwards are collectives and the other ranks would never
36
+ join them. `GrpoBatchSource` only generates on rank 0, which is exactly why the
37
+ sharded case needs a separate engine-side copy plus `load_weights_fn`.
38
+
39
+ WEIGHT SYNC. `request.weights_dir` is provenance, not an input, unless the
40
+ caller supplies `load_weights_fn`: then a CHANGED weights_dir triggers
41
+ `load_weights_fn(weights_dir)` (and `engine_reloaded=True`), mirroring the
42
+ spool worker's "checkpoint saves ARE the weight sync" contract. With no hook
43
+ the model is assumed live (shared parameters), so there is nothing to sync.
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import inspect
49
+ import logging
50
+ import time
51
+ from dataclasses import dataclass
52
+ from typing import Callable, Optional
53
+
54
+ import torch
55
+
56
+ from align.inference.engine import Engine, GenerationRequest, InferenceConfig
57
+ from align.rollout.protocol import RolloutRequest, RolloutResult
58
+
59
+ logger = logging.getLogger(__name__)
60
+
61
+ #: How the returned logprobs are defined; stamped into `extras` so a result
62
+ #: file is self-describing about the one thing the rho correction depends on.
63
+ LOGPROB_BASIS = "log_softmax(logits/T)[token], fp32, no top-p truncation (T=1 for greedy)"
64
+
65
+
66
+ def checkpoint_weight_loader(model, dtype: Optional[torch.dtype] = None) -> Callable[[str], None]:
67
+ """A `load_weights_fn` that refreshes a STANDALONE engine model from a
68
+ trainer checkpoint directory ("checkpoint saves ARE the weight sync").
69
+
70
+ Reads the HF-layout safetensors the Trainer wrote (single file or sharded
71
+ snapshot), converts to the align key layout and copies INTO the model's
72
+ existing parameters (`assign=False`): the Engine and any CUDA graphs hold
73
+ references to those tensors, so rebinding them would silently leave the
74
+ sampler on stale weights. `strict=True`: a checkpoint that does not cover
75
+ every parameter is a wrong sync, not a partial one.
76
+ """
77
+ from align.model import checkpoint as ckpt
78
+
79
+ def _load(weights_dir: str) -> None:
80
+ if dtype is None:
81
+ target = next(model.parameters()).dtype
82
+ else:
83
+ target = dtype
84
+ state = ckpt.hf_to_nmo(ckpt.load_hf_safetensors(weights_dir, dtype=target))
85
+ with torch.no_grad():
86
+ model.load_state_dict(state, strict=True)
87
+
88
+ return _load
89
+
90
+
91
+ @dataclass
92
+ class NativeRolloutConfig:
93
+ """Knobs of the native backend, one docstring per field."""
94
+
95
+ #: Concurrent sequences = KV slots. Each slot owns `max_seq_len` tokens of
96
+ #: KV for the engine's life (128 KiB/token at 7B bf16), so this multiplies
97
+ #: the cache budget directly. The engine is built with
98
+ #: min(n_slots, sequences-in-the-request) so a small request does not pay
99
+ #: for a big cache.
100
+ n_slots: int = 32
101
+ #: HARD cap on per-slot sequence length (prompt + completion). The engine
102
+ #: is sized to the request's actual need (longest prompt + max_tokens)
103
+ #: rounded up to `seq_len_granularity` and never above this; a sequence
104
+ #: that reaches the cap ends with finish_reason 'max_seq_len' (recorded in
105
+ #: extras), while a PROMPT that alone reaches it is refused loudly -- the
106
+ #: truncation of a prompt must be visible upstream, not done here.
107
+ max_seq_len_cap: int = 2048
108
+ #: Engine rebuilds are expensive (the KV is preallocated), so the length is
109
+ #: rounded up to a multiple of this to make growth rare.
110
+ seq_len_granularity: int = 64
111
+ #: Passed to the tokenizer when encoding prompts. True matches what vLLM's
112
+ #: `LLM.generate(prompts)` does (prepends BOS for Mistral), which keeps the
113
+ #: native and spool backends token-identical on the same prompt text.
114
+ add_special_tokens: bool = True
115
+ #: Used when a request carries `seed=None`: the n-th unseeded request gets
116
+ #: default_seed + n, so even "unseeded" rollouts are reproducible per run.
117
+ default_seed: int = 0
118
+ #: Ends a sequence when sampled. None -> take it from the tokenizer.
119
+ eos_token_id: Optional[int] = None
120
+ #: Filler for right-padded prefill rows; never read back.
121
+ pad_token_id: int = 0
122
+ #: 'length' admits shortest-first (measured default, see InferenceConfig).
123
+ admission_order: str = "length"
124
+ #: Run the engine's VRAM preflight when the KV cache is allocated on CUDA.
125
+ preflight: bool = True
126
+
127
+
128
+ def _make_encoder(tokenizer, add_special_tokens: bool) -> Callable[[str], list[int]]:
129
+ """prompt text -> token ids, over the minimal tokenizer contract.
130
+
131
+ The contract is `encode(text) -> list[int]` and
132
+ `decode(ids, skip_special_tokens=True) -> str`. If `encode` takes an
133
+ `add_special_tokens` keyword it is passed; otherwise the tokenizer is
134
+ taken to apply its own convention and the flag is not forwarded. Decided
135
+ once from the signature, not by catching TypeError per call -- a TypeError
136
+ raised INSIDE a tokenizer must surface, not be mistaken for "no such kwarg".
137
+ """
138
+ enc = tokenizer.encode
139
+ try:
140
+ params = inspect.signature(enc).parameters
141
+ except (TypeError, ValueError): # builtins / odd callables
142
+ params = {}
143
+ accepts = "add_special_tokens" in params or any(
144
+ p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values()
145
+ )
146
+
147
+ def encode(text: str) -> list[int]:
148
+ out = enc(text, add_special_tokens=add_special_tokens) if accepts else enc(text)
149
+ ids = getattr(out, "ids", out) # tolerate an Encoding-like return
150
+ return [int(t) for t in ids]
151
+
152
+ return encode
153
+
154
+
155
+ class NativeRolloutBackend:
156
+ """In-process rollout on a torch model via the repo's own KV-cache engine.
157
+
158
+ `submit(request)` runs the whole request synchronously and parks the
159
+ result; `collect(request_id)` hands it over. That keeps the two-call
160
+ surface of the spool client (so `GrpoBatchSource` is backend-agnostic)
161
+ while being honest that there is no worker to wait on.
162
+ """
163
+
164
+ def __init__(
165
+ self,
166
+ model,
167
+ tokenizer,
168
+ cfg: Optional[NativeRolloutConfig] = None,
169
+ *,
170
+ load_weights_fn: Optional[Callable[[str], None]] = None,
171
+ device: Optional[torch.device] = None,
172
+ ):
173
+ if tokenizer is None:
174
+ raise ValueError(
175
+ "native rollout needs a tokenizer: prompts must be encoded here (and their "
176
+ "ids returned) and completions decoded to the exact text the reward path scores"
177
+ )
178
+ self.model = model
179
+ self.tokenizer = tokenizer
180
+ self.cfg = cfg if cfg is not None else NativeRolloutConfig()
181
+ if self.cfg.n_slots < 1 or self.cfg.max_seq_len_cap < 2 or self.cfg.seq_len_granularity < 1:
182
+ raise ValueError(f"bad NativeRolloutConfig: {self.cfg}")
183
+ self.load_weights_fn = load_weights_fn
184
+ self.device = device
185
+ eos = self.cfg.eos_token_id
186
+ self.eos_token_id = eos if eos is not None else getattr(tokenizer, "eos_token_id", None)
187
+ self._encode = _make_encoder(tokenizer, self.cfg.add_special_tokens)
188
+
189
+ self._engine: Optional[Engine] = None
190
+ self._results: dict[str, RolloutResult] = {}
191
+ self._loaded_weights_dir: Optional[str] = None
192
+ self._unseeded = 0
193
+
194
+ # -- the RolloutBackend surface --------------------------------------------
195
+
196
+ def submit(self, request: RolloutRequest) -> str:
197
+ if request.request_id in self._results:
198
+ # same refusal as the spool protocol's write_request
199
+ raise FileExistsError(f"duplicate request_id {request.request_id!r}")
200
+ self._results[request.request_id] = self.generate(request)
201
+ return request.request_id
202
+
203
+ def collect(self, request_id: str) -> RolloutResult:
204
+ try:
205
+ return self._results.pop(request_id)
206
+ except KeyError:
207
+ raise KeyError(
208
+ f"no pending native result for {request_id!r} (never submitted, or already "
209
+ "collected: results are single-use, as the spool's consumed files are)"
210
+ ) from None
211
+
212
+ # -- core -------------------------------------------------------------------
213
+
214
+ def generate(self, request: RolloutRequest) -> RolloutResult:
215
+ """One request -> one RolloutResult. Exceptions propagate directly: there
216
+ is no worker boundary to convert them into an `error` result across."""
217
+ request.validate(check_weights_dir=False)
218
+ reloaded = self._maybe_sync_weights(request.weights_dir)
219
+
220
+ prompt_ids = [self._encode(p) for p in request.prompts]
221
+ n, g = len(request.prompts), request.n_samples_per_prompt
222
+ engine = self._engine_for(
223
+ need_len=max(len(p) for p in prompt_ids) + request.max_tokens, n_sequences=n * g
224
+ )
225
+
226
+ seed = request.seed
227
+ if seed is None:
228
+ seed = self.cfg.default_seed + self._unseeded
229
+ self._unseeded += 1
230
+ # Re-seed per request: the sample stream is a pure function of
231
+ # (weights, prompts, seed), not of how many requests came before.
232
+ engine.generator.manual_seed(int(seed))
233
+
234
+ gen_requests = [
235
+ GenerationRequest(
236
+ rid=f"{request.request_id}:{i}:{j}",
237
+ prompt_ids=prompt_ids[i],
238
+ max_new_tokens=request.max_tokens,
239
+ temperature=request.temperature,
240
+ top_p=request.top_p,
241
+ )
242
+ for i in range(n)
243
+ for j in range(g)
244
+ ]
245
+
246
+ t0 = time.perf_counter()
247
+ with _rollout_mode(self.model):
248
+ results = engine.generate(gen_requests, return_logprobs=True)
249
+ gen_s = time.perf_counter() - t0
250
+
251
+ completions, logprobs, texts, finish = [], [], [], []
252
+ for i in range(n):
253
+ row = results[i * g : (i + 1) * g]
254
+ for r in row:
255
+ if r.logprobs is None or len(r.logprobs) != len(r.token_ids):
256
+ raise RuntimeError(
257
+ f"{r.rid}: engine returned {None if r.logprobs is None else len(r.logprobs)}"
258
+ f" logprobs for {len(r.token_ids)} tokens; the rho correction cannot run"
259
+ )
260
+ if r.text is None: # unreachable with a tokenizer; cheap to keep honest
261
+ raise RuntimeError(f"{r.rid}: engine returned no decoded text")
262
+ completions.append([r.token_ids for r in row])
263
+ logprobs.append([r.logprobs for r in row])
264
+ texts.append([r.text for r in row])
265
+ finish.append([r.finish_reason for r in row])
266
+
267
+ return RolloutResult(
268
+ request_id=request.request_id,
269
+ completions=completions,
270
+ logprobs=logprobs,
271
+ texts=texts,
272
+ weights_dir=request.weights_dir,
273
+ engine_reloaded=reloaded,
274
+ generate_seconds=gen_s,
275
+ extras={
276
+ # THE tokenization of each prompt that the completions continue
277
+ # from; assembly forbids re-deriving it (audit R1 class).
278
+ "prompt_token_ids": [list(p) for p in prompt_ids],
279
+ "finish_reasons": finish,
280
+ "backend": "native",
281
+ "seed": int(seed),
282
+ "logprob_basis": LOGPROB_BASIS,
283
+ "engine_max_seq_len": engine.cfg.max_seq_len,
284
+ "engine_n_slots": engine.cfg.n_slots,
285
+ },
286
+ )
287
+
288
+ # -- internals ----------------------------------------------------------------
289
+
290
+ def _maybe_sync_weights(self, weights_dir: str) -> bool:
291
+ if self.load_weights_fn is None or not weights_dir:
292
+ return False
293
+ if weights_dir == self._loaded_weights_dir:
294
+ return False
295
+ t0 = time.perf_counter()
296
+ self.load_weights_fn(weights_dir)
297
+ self._loaded_weights_dir = weights_dir
298
+ logger.info("native rollout: weights synced from %s in %.1fs", weights_dir,
299
+ time.perf_counter() - t0)
300
+ return True
301
+
302
+ def _engine_for(self, need_len: int, n_sequences: int) -> Engine:
303
+ """The cached engine if it is big enough, else a (re)built one.
304
+
305
+ Grows only: a shorter later request reuses the bigger cache, so a run
306
+ converges to one engine after a few steps instead of thrashing on
307
+ prompt-length variance. The old engine's KV is freed BEFORE the new
308
+ one is allocated -- both alive at once would double the cache.
309
+ """
310
+ gran = self.cfg.seq_len_granularity
311
+ want_len = min(self.cfg.max_seq_len_cap, -(-need_len // gran) * gran)
312
+ want_slots = min(self.cfg.n_slots, n_sequences)
313
+ eng = self._engine
314
+ if eng is not None and eng.cfg.max_seq_len >= want_len and eng.cfg.n_slots >= want_slots:
315
+ return eng
316
+ if eng is not None:
317
+ want_len = max(want_len, eng.cfg.max_seq_len)
318
+ want_slots = max(want_slots, eng.cfg.n_slots)
319
+ self._engine = None
320
+ del eng
321
+ if torch.cuda.is_available():
322
+ torch.cuda.empty_cache()
323
+ icfg = InferenceConfig(
324
+ n_slots=want_slots,
325
+ max_seq_len=want_len,
326
+ max_new_tokens=1, # always overridden per request
327
+ temperature=1.0,
328
+ top_p=1.0,
329
+ seed=self.cfg.default_seed,
330
+ eos_token_id=self.eos_token_id,
331
+ pad_token_id=self.cfg.pad_token_id,
332
+ admission_order=self.cfg.admission_order,
333
+ )
334
+ self._engine = Engine(
335
+ self.model,
336
+ icfg,
337
+ tokenizer=self.tokenizer,
338
+ device=self.device,
339
+ preflight=self.cfg.preflight,
340
+ )
341
+ return self._engine
342
+
343
+
344
+ class _rollout_mode:
345
+ """Borrow a TRAINING model for sampling, then give it back untouched.
346
+
347
+ The engine flips every attention module's backend per phase (prefill vs
348
+ decode) and runs the model as-is; on a model that is also being trained
349
+ that would leak: a decode-phase 'sdpa' left on a `triton_fa2` model would
350
+ silently change the next TRAIN step's kernel, and a model left in eval()
351
+ would stay there. Both are restored in `__exit__`, including on error.
352
+ """
353
+
354
+ def __init__(self, model):
355
+ self.model = model
356
+
357
+ def __enter__(self):
358
+ m = self.model
359
+ self.was_training = m.training
360
+ layers = getattr(getattr(m, "model", None), "layers", None)
361
+ self.backends = (
362
+ [(blk.attn, blk.attn.attn_implementation) for blk in layers] if layers else []
363
+ )
364
+ m.eval()
365
+ return m
366
+
367
+ def __exit__(self, *exc):
368
+ for attn, impl in self.backends:
369
+ attn.attn_implementation = impl
370
+ if self.was_training:
371
+ self.model.train()
372
+ return False
code/align/rollout/paged.py ADDED
@@ -0,0 +1,494 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Paged KV cache for the native continuous-batching sampler (P3).
2
+
3
+ WHY PAGING (the failure mode this replaces). `align.inference.engine.Engine`
4
+ gives every slot a CONTIGUOUS `max_seq_len` strip of KV for the engine's whole
5
+ life. For a prover-RL rollout the length distribution is brutal: most
6
+ completions stop at a few thousand tokens, a few run to the 16k/40k cap. A
7
+ strip sized for the cap wastes ~all of it on the short ones, so concurrency is
8
+ `budget / cap` regardless of what is actually in flight. On the 23 GB A10G
9
+ that is the difference between "a handful of sequences" and "as many as the
10
+ live tokens allow" (numbers: `plan_capacity`, docs/P3_ENGINE.md). Paging cuts
11
+ the cache into fixed `block_size`-token blocks handed out on demand from a
12
+ free list; a sequence owns a *block table* (list of block ids) and the waste
13
+ is bounded by one partially filled block per sequence.
14
+
15
+ What lives here:
16
+
17
+ * `BlockAllocator` free-list allocator with refcounts. The refcount is what
18
+ makes prefix sharing safe: a block returns to the free
19
+ list only when its LAST owner releases it.
20
+ * `PagedKVCache` per-layer `[num_blocks, block_size, Hkv, D]` tensors +
21
+ block tables. Append is COPY-FREE: the new token's K/V is
22
+ written in place at (block, offset); nothing is moved or
23
+ re-concatenated when a sequence grows (the dense-cache
24
+ `torch.cat` path is O(L) per token per layer).
25
+ * `StepBatch` the flat, ragged description of one engine step.
26
+ * `TorchPagedAttention` the pure-torch attention backend (gather from the
27
+ block table, SDPA on the gathered K/V). Correctness
28
+ first. Three granularities, see its docstring.
29
+ * `TritonPagedAttention` INTERFACE ONLY (raises NotImplementedError): the
30
+ signature a fused kernel must satisfy, so the engine
31
+ does not change when the kernel lands.
32
+ * `kv_bytes_per_token`, `plan_capacity` the memory accounting.
33
+
34
+ Nothing here imports anything outside torch + stdlib (+ `align.model` for the
35
+ GQA map check), so the dependency firewall is untouched.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import math
41
+ from dataclasses import dataclass, field
42
+ from typing import Optional, Protocol, Sequence
43
+
44
+ import torch
45
+ import torch.nn.functional as F
46
+
47
+
48
+ class OutOfBlocks(RuntimeError):
49
+ """The free list cannot satisfy an allocation. The engine catches exactly this
50
+ to decide on preemption; any other error is a bug and must propagate."""
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Allocator
55
+ # ---------------------------------------------------------------------------
56
+
57
+
58
+ class BlockAllocator:
59
+ """Free-list block allocator with reference counts.
60
+
61
+ LIFO free list: a just-freed block is the next one handed out, which keeps
62
+ the working set of block ids small and -- more usefully for tests --
63
+ deterministic, so a failing property run replays exactly.
64
+
65
+ Failure modes each check prevents:
66
+ * double free -> the block would sit on the free list TWICE and be handed
67
+ to two sequences at once; their KV would silently overwrite each other.
68
+ `free` raises on refcount 0.
69
+ * free of an out-of-range / never-issued id -> same corruption, one hop
70
+ later.
71
+ * leaks -> `check_consistent` proves `free + referenced == total` and that
72
+ the free list has no duplicates; the property test calls it after every
73
+ event.
74
+ """
75
+
76
+ def __init__(self, num_blocks: int):
77
+ if num_blocks < 1:
78
+ raise ValueError(f"num_blocks={num_blocks} must be >= 1")
79
+ self.num_blocks = int(num_blocks)
80
+ # reversed so pop() yields block 0 first
81
+ self._free: list[int] = list(range(self.num_blocks - 1, -1, -1))
82
+ self._ref: list[int] = [0] * self.num_blocks
83
+
84
+ @property
85
+ def num_free(self) -> int:
86
+ return len(self._free)
87
+
88
+ @property
89
+ def num_used(self) -> int:
90
+ return self.num_blocks - len(self._free)
91
+
92
+ def refcount(self, block: int) -> int:
93
+ return self._ref[block]
94
+
95
+ def can_allocate(self, n: int) -> bool:
96
+ return n <= len(self._free)
97
+
98
+ def allocate(self) -> int:
99
+ if not self._free:
100
+ raise OutOfBlocks("no free KV blocks")
101
+ b = self._free.pop()
102
+ self._ref[b] = 1
103
+ return b
104
+
105
+ def incref(self, block: int) -> None:
106
+ if not (0 <= block < self.num_blocks) or self._ref[block] <= 0:
107
+ raise ValueError(f"incref of block {block} which is not allocated")
108
+ self._ref[block] += 1
109
+
110
+ def free(self, block: int) -> None:
111
+ if not (0 <= block < self.num_blocks):
112
+ raise ValueError(f"free of out-of-range block {block}")
113
+ if self._ref[block] <= 0:
114
+ raise ValueError(f"double free of block {block}")
115
+ self._ref[block] -= 1
116
+ if self._ref[block] == 0:
117
+ self._free.append(block)
118
+
119
+ def check_consistent(self) -> None:
120
+ """Raise AssertionError unless the books balance. O(num_blocks)."""
121
+ free = self._free
122
+ assert len(set(free)) == len(free), "duplicate block on the free list"
123
+ for b in free:
124
+ assert self._ref[b] == 0, f"block {b} is free but has refcount {self._ref[b]}"
125
+ referenced = sum(1 for r in self._ref if r > 0)
126
+ assert referenced + len(free) == self.num_blocks, (
127
+ f"leak: {referenced} referenced + {len(free)} free != {self.num_blocks}"
128
+ )
129
+ assert all(r >= 0 for r in self._ref), "negative refcount"
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # KV cache
134
+ # ---------------------------------------------------------------------------
135
+
136
+
137
+ class PagedKVCache:
138
+ """Block-table KV cache. One K and one V tensor per layer, each
139
+ `[num_blocks, block_size, n_kv_heads, head_dim]`.
140
+
141
+ A sequence is `(block_table, length)`. Token `p` of a sequence lives at
142
+ block `table[p // block_size]`, offset `p % block_size`. Block 0 is NOT
143
+ reserved: nothing pads a table with a sentinel, because every reader slices
144
+ by `length` instead.
145
+ """
146
+
147
+ def __init__(
148
+ self,
149
+ n_layers: int,
150
+ num_blocks: int,
151
+ block_size: int,
152
+ n_kv_heads: int,
153
+ head_dim: int,
154
+ dtype: torch.dtype = torch.float32,
155
+ device: Optional[torch.device | str] = None,
156
+ ):
157
+ if block_size < 1:
158
+ raise ValueError(f"block_size={block_size} must be >= 1")
159
+ self.n_layers, self.block_size = n_layers, int(block_size)
160
+ self.n_kv_heads, self.head_dim = n_kv_heads, head_dim
161
+ self.dtype, self.device = dtype, torch.device(device) if device is not None else torch.device("cpu")
162
+ self.allocator = BlockAllocator(num_blocks)
163
+ shape = (num_blocks, self.block_size, n_kv_heads, head_dim)
164
+ self.k = [torch.zeros(shape, dtype=dtype, device=self.device) for _ in range(n_layers)]
165
+ self.v = [torch.zeros(shape, dtype=dtype, device=self.device) for _ in range(n_layers)]
166
+ self.tables: dict[str, list[int]] = {}
167
+ self.lengths: dict[str, int] = {}
168
+
169
+ # -- accounting -----------------------------------------------------------
170
+
171
+ @property
172
+ def num_blocks(self) -> int:
173
+ return self.allocator.num_blocks
174
+
175
+ def blocks_needed(self, n_tokens: int) -> int:
176
+ return -(-n_tokens // self.block_size)
177
+
178
+ def bytes_total(self) -> int:
179
+ itemsize = torch.empty((), dtype=self.dtype).element_size()
180
+ return self.n_layers * 2 * self.num_blocks * self.block_size * self.n_kv_heads * self.head_dim * itemsize
181
+
182
+ # -- sequence lifecycle ----------------------------------------------------
183
+
184
+ def add_seq(self, seq_id: str, shared_blocks: Sequence[int] = ()) -> None:
185
+ """Register a sequence, optionally adopting FULL blocks of another one.
186
+
187
+ Only whole blocks can be shared: a partially filled tail block is still
188
+ being appended to by its owner, and a second writer (or even a reader
189
+ racing the owner's append) would see/clobber tokens that are not part
190
+ of the shared prefix. The shared blocks are incref'd, and the length
191
+ starts at the end of the last shared block.
192
+ """
193
+ if seq_id in self.tables:
194
+ raise ValueError(f"sequence {seq_id!r} already registered")
195
+ for b in shared_blocks:
196
+ self.allocator.incref(b)
197
+ self.tables[seq_id] = list(shared_blocks)
198
+ self.lengths[seq_id] = len(shared_blocks) * self.block_size
199
+
200
+ def free_seq(self, seq_id: str) -> int:
201
+ """Release every block of a sequence (shared blocks only drop a ref)."""
202
+ table = self.tables.pop(seq_id)
203
+ self.lengths.pop(seq_id)
204
+ for b in table:
205
+ self.allocator.free(b)
206
+ return len(table)
207
+
208
+ def ensure_capacity(self, seq_id: str, total_tokens: int) -> None:
209
+ """Grow the table to hold `total_tokens`. ALL-OR-NOTHING: if the free
210
+ list cannot cover the whole growth nothing is allocated, so a failed
211
+ reservation never strands blocks on a sequence that is about to be
212
+ preempted (which would make the preemption free them again -- fine --
213
+ but a *skipped* sequence would hold them forever)."""
214
+ table = self.tables[seq_id]
215
+ need = self.blocks_needed(total_tokens) - len(table)
216
+ if need <= 0:
217
+ return
218
+ if not self.allocator.can_allocate(need):
219
+ raise OutOfBlocks(f"need {need} blocks for {seq_id!r}, {self.allocator.num_free} free")
220
+ for _ in range(need):
221
+ table.append(self.allocator.allocate())
222
+
223
+ def slots(self, seq_id: str, start: int, n: int) -> torch.Tensor:
224
+ """Flat slot ids (block * block_size + offset) for positions [start, start+n).
225
+
226
+ Refuses to hand out a slot inside a block somebody else also references:
227
+ a write there would corrupt the sharer's KV with no error anywhere.
228
+ """
229
+ table = self.tables[seq_id]
230
+ bs = self.block_size
231
+ if start + n > len(table) * bs:
232
+ raise ValueError(f"{seq_id!r}: positions up to {start + n} exceed reserved {len(table) * bs}")
233
+ pos = torch.arange(start, start + n)
234
+ blocks = torch.tensor(table, dtype=torch.long)[pos // bs]
235
+ for b in set(blocks.tolist()):
236
+ if self.allocator.refcount(b) != 1:
237
+ raise RuntimeError(
238
+ f"{seq_id!r}: write into shared block {b} (refcount {self.allocator.refcount(b)})"
239
+ )
240
+ return (blocks * bs + pos % bs).to(self.device)
241
+
242
+ # -- data movement ----------------------------------------------------------
243
+
244
+ def write(self, layer: int, slots: torch.Tensor, k: torch.Tensor, v: torch.Tensor) -> None:
245
+ """In-place scatter of `k`,`v` [n, Hkv, D] at flat `slots`. No allocation,
246
+ no copy of existing KV (the 'copy-free append')."""
247
+ self.k[layer].view(-1, self.n_kv_heads, self.head_dim)[slots] = k.to(self.dtype)
248
+ self.v[layer].view(-1, self.n_kv_heads, self.head_dim)[slots] = v.to(self.dtype)
249
+
250
+ def gather(self, layer: int, seq_id: str, length: int) -> tuple[torch.Tensor, torch.Tensor]:
251
+ """K, V of the first `length` tokens as contiguous `[1, Hkv, length, D]`.
252
+
253
+ Contiguity is deliberate: SDPA can pick different kernels / reduction
254
+ orders for strided inputs, and the batch-invariance guarantee needs the
255
+ SAME bytes in the SAME layout whatever else is in the batch."""
256
+ table = self.tables[seq_id]
257
+ idx = torch.tensor(table[: self.blocks_needed(length)], dtype=torch.long, device=self.device)
258
+ k = self.k[layer][idx].reshape(-1, self.n_kv_heads, self.head_dim)[:length]
259
+ v = self.v[layer][idx].reshape(-1, self.n_kv_heads, self.head_dim)[:length]
260
+ return (
261
+ k.permute(1, 0, 2).unsqueeze(0).contiguous(),
262
+ v.permute(1, 0, 2).unsqueeze(0).contiguous(),
263
+ )
264
+
265
+ def block_table_tensor(self, seq_ids: Sequence[str], pad: int = 0) -> torch.Tensor:
266
+ """`[B, max_blocks]` int32 table (padded with `pad`; readers slice by
267
+ context length). This is the argument a fused kernel consumes."""
268
+ width = max((len(self.tables[s]) for s in seq_ids), default=0)
269
+ out = torch.full((len(seq_ids), max(width, 1)), pad, dtype=torch.int32)
270
+ for i, s in enumerate(seq_ids):
271
+ t = self.tables[s]
272
+ if t:
273
+ out[i, : len(t)] = torch.tensor(t, dtype=torch.int32)
274
+ return out.to(self.device)
275
+
276
+
277
+ # ---------------------------------------------------------------------------
278
+ # One engine step, flat and ragged
279
+ # ---------------------------------------------------------------------------
280
+
281
+
282
+ @dataclass
283
+ class Segment:
284
+ """`n_new` consecutive tokens of one sequence in the flat batch.
285
+
286
+ `ctx` is the number of tokens of this sequence whose KV is ALREADY in the
287
+ cache before this step; the segment's token j sits at position `ctx + j`
288
+ and attends to positions `[0, ctx + j]`. A decode token is a segment with
289
+ n_new == 1; a prefill chunk is n_new > 1; they coexist in one step.
290
+ """
291
+
292
+ seq_id: str
293
+ offset: int # row of the segment's first token in the flat batch
294
+ n_new: int
295
+ ctx: int
296
+
297
+
298
+ @dataclass
299
+ class StepBatch:
300
+ segments: list[Segment] = field(default_factory=list)
301
+
302
+ @property
303
+ def n_tokens(self) -> int:
304
+ return sum(s.n_new for s in self.segments)
305
+
306
+
307
+ # ---------------------------------------------------------------------------
308
+ # Attention backends
309
+ # ---------------------------------------------------------------------------
310
+
311
+
312
+ class PagedAttention(Protocol):
313
+ """The seam between the engine and an attention implementation.
314
+
315
+ `q` is `[T, H, D]` (flat rows, RoPE already applied), `k_new`/`v_new` are
316
+ `[T, Hkv, D]` for the same rows. The backend must (1) write the new K/V into
317
+ `cache` for `layer` at the segments' positions, then (2) return the
318
+ attention output `[T, H, D]` where row r attends to the cache contents
319
+ `[0, position(r)]` of its own sequence. Splitting write from read inside the
320
+ backend lets a fused kernel do both in one launch.
321
+ """
322
+
323
+ def __call__(
324
+ self, layer: int, q: torch.Tensor, k_new: torch.Tensor, v_new: torch.Tensor,
325
+ cache: PagedKVCache, batch: StepBatch, scale: float,
326
+ ) -> torch.Tensor: ...
327
+
328
+
329
+ class TorchPagedAttention:
330
+ """Pure-torch paged attention: gather K/V from the block table, run SDPA.
331
+
332
+ `mode`:
333
+ 'row' one SDPA call per QUERY ROW, over exactly that row's keys
334
+ `[0, pos]`, no mask. The operands of every call depend only on
335
+ the sequence's own tokens, never on what else is in the step or
336
+ how the prompt was chunked, so the output bits are identical
337
+ across batch compositions and chunk schedules. Slow; it is the
338
+ correctness/invariance reference and the mode RL rollouts that
339
+ need bit-reproducible logprobs should use.
340
+ 'segment' one SDPA per segment (a prefill chunk is one causal call with
341
+ an explicit bool mask; a decode token is mask-free) -- but all
342
+ decode segments of a step are *padded and batched* into one
343
+ call. Faster; the softmax/matmul reduction length now depends on
344
+ the longest sequence in the batch (padding) or the chunk size,
345
+ so results match 'row' to rounding only (see docs/P3_ENGINE.md
346
+ for the measured bf16 expectation).
347
+ """
348
+
349
+ def __init__(self, mode: str = "row"):
350
+ if mode not in ("row", "segment"):
351
+ raise ValueError(f"mode={mode!r}; expected 'row' or 'segment'")
352
+ self.mode = mode
353
+
354
+ def __call__(self, layer, q, k_new, v_new, cache, batch, scale):
355
+ group = q.shape[1] // k_new.shape[1]
356
+ gqa = group > 1
357
+ out = torch.empty_like(q)
358
+ # 1) copy-free append of every segment's new K/V
359
+ for s in batch.segments:
360
+ sl = slice(s.offset, s.offset + s.n_new)
361
+ cache.write(layer, cache.slots(s.seq_id, s.ctx, s.n_new), k_new[sl], v_new[sl])
362
+ # 2) read
363
+ decode = [s for s in batch.segments if s.n_new == 1] if self.mode == "segment" else []
364
+ decode_ids = {id(s) for s in decode}
365
+ for s in batch.segments:
366
+ if id(s) in decode_ids:
367
+ continue
368
+ total = s.ctx + s.n_new
369
+ kk, vv = cache.gather(layer, s.seq_id, total)
370
+ if kk.dtype != q.dtype: # a cache stored narrower than the model (kv_dtype=bf16/fp8): SDPA
371
+ kk, vv = kk.to(q.dtype), vv.to(q.dtype) # insists on one dtype, so dequantise on read
372
+ if self.mode == "row":
373
+ for j in range(s.n_new):
374
+ ln = s.ctx + j + 1
375
+ qj = q[s.offset + j].view(1, q.shape[1], 1, q.shape[2])
376
+ o = F.scaled_dot_product_attention(
377
+ qj, kk[:, :, :ln].contiguous(), vv[:, :, :ln].contiguous(),
378
+ enable_gqa=gqa, scale=scale,
379
+ )
380
+ out[s.offset + j] = o[0, :, 0]
381
+ else:
382
+ qs = q[s.offset : s.offset + s.n_new].permute(1, 0, 2).unsqueeze(0)
383
+ qpos = torch.arange(s.ctx, total, device=q.device)[:, None]
384
+ keep = qpos >= torch.arange(total, device=q.device)[None, :]
385
+ o = F.scaled_dot_product_attention(qs, kk, vv, attn_mask=keep, enable_gqa=gqa, scale=scale)
386
+ out[s.offset : s.offset + s.n_new] = o[0].permute(1, 0, 2)
387
+ if decode:
388
+ lens = [s.ctx + 1 for s in decode]
389
+ lmax = max(lens)
390
+ kp = q.new_zeros(len(decode), k_new.shape[1], lmax, q.shape[2])
391
+ vp = torch.zeros_like(kp)
392
+ keep = torch.zeros(len(decode), 1, 1, lmax, dtype=torch.bool, device=q.device)
393
+ for i, s in enumerate(decode):
394
+ kk, vv = cache.gather(layer, s.seq_id, lens[i])
395
+ kp[i, :, : lens[i]] = kk[0]
396
+ vp[i, :, : lens[i]] = vv[0]
397
+ keep[i, ..., : lens[i]] = True
398
+ rows = torch.tensor([s.offset for s in decode], device=q.device)
399
+ qd = q[rows].unsqueeze(2) # [B, H, 1, D]
400
+ o = F.scaled_dot_product_attention(qd, kp, vp, attn_mask=keep, enable_gqa=gqa, scale=scale)
401
+ out[rows] = o[:, :, 0]
402
+ return out
403
+
404
+
405
+ class TritonPagedAttention:
406
+ """INTERFACE ONLY. A later step replaces `TorchPagedAttention` with a fused
407
+ kernel; this class pins what that kernel receives so the engine is untouched.
408
+
409
+ Kernel contract (vLLM-style "paged attention v1/v2" over a ragged batch):
410
+
411
+ q [T, H, D] flat query rows (RoPE applied)
412
+ k_cache [NB, BS, Hkv, D] this layer's K blocks
413
+ v_cache [NB, BS, Hkv, D]
414
+ block_tables [B, max_blocks] i32 from `PagedKVCache.block_table_tensor`
415
+ cu_seqlens_q [B+1] i32 row offsets of each segment in q
416
+ ctx_lens [B] i32 tokens already cached BEFORE this step
417
+ scale float
418
+ out [T, H, D]
419
+
420
+ Row r of segment b (position ctx_lens[b] + j) attends to cached positions
421
+ [0, ctx_lens[b] + j]; the new K/V for the step must be written to the cache
422
+ BEFORE the launch (or fused in the same kernel via `slot_mapping`
423
+ [T] i64 = block * BS + offset). Bit-invariance requirement for RL use: the
424
+ reduction over keys for a given row must not depend on B, T or on other
425
+ segments (fixed KV-split count per context length, no atomics).
426
+ """
427
+
428
+ def __call__(self, layer, q, k_new, v_new, cache, batch, scale): # pragma: no cover
429
+ raise NotImplementedError(
430
+ "TritonPagedAttention is an interface placeholder; use TorchPagedAttention. "
431
+ "See the class docstring for the kernel contract."
432
+ )
433
+
434
+
435
+ # ---------------------------------------------------------------------------
436
+ # Memory accounting
437
+ # ---------------------------------------------------------------------------
438
+
439
+
440
+ def kv_bytes_per_token(n_layers: int, n_kv_heads: int, head_dim: int, dtype: torch.dtype = torch.bfloat16,
441
+ tp_degree: int = 1) -> int:
442
+ """Bytes of KV one token costs on ONE device: layers x 2 (K,V) x kv-heads/tp x head_dim x itemsize.
443
+
444
+ `tp_degree` shards the KV heads (vLLM's tensor parallelism does exactly this),
445
+ which is why a TP=4 probe's "tokens in cache" is not comparable to a
446
+ single-device engine's without it. Qwen3-8B (36, 8, 128, bf16) -> 147456 B
447
+ = 144 KiB = the '147 KB/token' figure.
448
+ """
449
+ if n_kv_heads % tp_degree:
450
+ raise ValueError(f"n_kv_heads={n_kv_heads} not divisible by tp_degree={tp_degree}")
451
+ itemsize = torch.empty((), dtype=dtype).element_size()
452
+ return n_layers * 2 * (n_kv_heads // tp_degree) * head_dim * itemsize
453
+
454
+
455
+ @dataclass(frozen=True)
456
+ class CapacityPlan:
457
+ kv_bytes_per_token: int
458
+ kv_budget_bytes: int
459
+ num_blocks: int
460
+ block_size: int
461
+ kv_tokens: int
462
+ max_concurrent_at_cap: int # sequences that can each hold `cap` tokens simultaneously
463
+ cap: int
464
+
465
+
466
+ def plan_capacity(
467
+ device_total_bytes: int,
468
+ weights_bytes: int,
469
+ kv_bytes_per_tok: int,
470
+ *,
471
+ utilization: float = 0.9,
472
+ activation_bytes: int = 0,
473
+ other_bytes: int = 0,
474
+ block_size: int = 16,
475
+ cap: int = 16384,
476
+ ) -> CapacityPlan:
477
+ """How much KV fits, and how many `cap`-token sequences that sustains.
478
+
479
+ budget = utilization * device_total - weights - activation peak - other.
480
+ Paging rounds each sequence up to whole blocks, so `max_concurrent_at_cap`
481
+ = num_blocks // ceil(cap / block_size) (a dense per-slot strip would give
482
+ the same number for sequences that all reach the cap; paging's advantage is
483
+ for the ones that do not, which is why the engine also reports live usage).
484
+ """
485
+ budget = int(utilization * device_total_bytes) - weights_bytes - activation_bytes - other_bytes
486
+ budget = max(budget, 0)
487
+ per_block = kv_bytes_per_tok * block_size
488
+ nb = budget // per_block
489
+ per_seq_blocks = math.ceil(cap / block_size)
490
+ return CapacityPlan(
491
+ kv_bytes_per_token=kv_bytes_per_tok, kv_budget_bytes=budget, num_blocks=int(nb),
492
+ block_size=block_size, kv_tokens=int(nb * block_size),
493
+ max_concurrent_at_cap=int(nb // per_seq_blocks), cap=cap,
494
+ )
code/align/rollout/protocol.py ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Request/result shapes for rollout, plus the file protocol the spool worker speaks.
2
+
3
+ DEPENDENCY-FREE ON PURPOSE: this module is imported by the training venv
4
+ (torch 2.11/cu128), by the native in-process backend (`native.py`), AND by the
5
+ optional vLLM worker in extras/vllm_rollout/ (its own venv, vllm/cu130); it
6
+ may import nothing beyond the standard library, or the environments' pin sets
7
+ start constraining each other -- the exact coupling the isolated-venv
8
+ design exists to prevent.
9
+
10
+ `RolloutRequest` / `RolloutResult` are the backend-neutral contract. The
11
+ default backend (`native.NativeRolloutBackend`) fills them in-process with no
12
+ file I/O; the 'spool' backend moves the SAME dataclasses through the files
13
+ described below. The trainer cannot tell which one served it, by design.
14
+
15
+ Mechanics: one JSON file per message, written atomically (.partial ->
16
+ os.replace) into a spool directory. The worker polls `requests/`, writes
17
+ into `results/`, and touches `heartbeat` every poll so the trainer can
18
+ distinguish "generating" from "dead" (mtime-based liveness, per the house
19
+ monitor-mtime-not-PIDs rule). Completions travel as token id lists plus
20
+ the engine's per-token logprobs -- the trainer needs vllm_logprobs for the
21
+ rho correction and must NOT re-derive them from text (re-tokenization is
22
+ not identity).
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import os
29
+ import time
30
+ from dataclasses import asdict, dataclass, field
31
+ from typing import Optional, Protocol, runtime_checkable
32
+
33
+ PROTOCOL_VERSION = 1
34
+
35
+
36
+ @dataclass
37
+ class RolloutRequest:
38
+ request_id: str
39
+ weights_dir: str # HF-loadable checkpoint dir; worker reloads on change
40
+ prompts: list[str]
41
+ n_samples_per_prompt: int
42
+ max_tokens: int
43
+ temperature: float = 1.0
44
+ top_p: float = 1.0
45
+ seed: Optional[int] = None
46
+ protocol_version: int = PROTOCOL_VERSION
47
+
48
+ def validate(self, *, check_weights_dir: bool = True) -> None:
49
+ """Shape checks always; the weights_dir existence check only for
50
+ backends that LOAD from it. The native backend samples from a live
51
+ in-process model, where weights_dir is provenance, not an input -- a
52
+ step-0 request legitimately names a checkpoint that has not been
53
+ written yet."""
54
+ if not self.prompts:
55
+ raise ValueError("empty prompt list")
56
+ if self.n_samples_per_prompt < 1 or self.max_tokens < 1:
57
+ raise ValueError(
58
+ f"bad shape: n={self.n_samples_per_prompt}, max_tokens={self.max_tokens}"
59
+ )
60
+ if check_weights_dir and not os.path.isdir(self.weights_dir):
61
+ raise FileNotFoundError(f"weights_dir does not exist: {self.weights_dir}")
62
+
63
+
64
+ @dataclass
65
+ class RolloutResult:
66
+ """One request's samples, in the form `align.rl.assembly` consumes.
67
+
68
+ Whatever the backend, the contract the trainer relies on is:
69
+ logprobs[i][j][t] is the logprob of completions[i][j][t] under the
70
+ SAMPLING-TEMPERATURE-scaled, untruncated policy distribution, so
71
+ rho = exp(old - infer) is ~1 when the weights agree. And
72
+ `extras["prompt_token_ids"]` (one id list per prompt, request order) is
73
+ REQUIRED: completions continue from exactly those ids, and assembly
74
+ refuses to re-tokenize prompt text.
75
+ """
76
+
77
+ request_id: str
78
+ #: completions[i][j] = j-th sample for prompt i: token ids WITHOUT the prompt.
79
+ completions: list[list[list[int]]]
80
+ #: logprobs[i][j][t] = engine logprob of completions[i][j][t] (the rho
81
+ #: correction's pi^infer_old side; label-aligned with the completion ids).
82
+ logprobs: list[list[list[float]]]
83
+ #: decoded completion texts, same nesting -- the reward path consumes text
84
+ #: and must see EXACTLY what the engine's tokenizer decodes, not a re-decode.
85
+ texts: list[list[str]]
86
+ weights_dir: str
87
+ engine_reloaded: bool
88
+ generate_seconds: float
89
+ error: Optional[str] = None
90
+ protocol_version: int = PROTOCOL_VERSION
91
+ extras: dict = field(default_factory=dict)
92
+
93
+
94
+ @runtime_checkable
95
+ class RolloutBackend(Protocol):
96
+ """What `GrpoBatchSource` consumes: submit a request, collect its result.
97
+
98
+ Implemented by `native.NativeRolloutBackend` (in-process, synchronous: the
99
+ work happens in `submit`) and `client.RolloutClient` (file spool to an
100
+ external worker: the work happens between `submit` and `collect`).
101
+ """
102
+
103
+ def submit(self, request: RolloutRequest) -> str: ...
104
+
105
+ def collect(self, request_id: str) -> RolloutResult: ...
106
+
107
+
108
+ def _atomic_write_json(payload: dict, path: str) -> None:
109
+ tmp = path + ".partial"
110
+ with open(tmp, "w") as f:
111
+ json.dump(payload, f)
112
+ os.replace(tmp, path)
113
+
114
+
115
+ def spool_dirs(spool: str) -> tuple[str, str]:
116
+ req = os.path.join(spool, "requests")
117
+ res = os.path.join(spool, "results")
118
+ os.makedirs(req, exist_ok=True)
119
+ os.makedirs(res, exist_ok=True)
120
+ return req, res
121
+
122
+
123
+ def write_request(spool: str, request: RolloutRequest) -> str:
124
+ request.validate()
125
+ req_dir, _ = spool_dirs(spool)
126
+ path = os.path.join(req_dir, f"{request.request_id}.json")
127
+ if os.path.exists(path):
128
+ raise FileExistsError(f"duplicate request_id {request.request_id!r}")
129
+ _atomic_write_json(asdict(request), path)
130
+ return path
131
+
132
+
133
+ def read_request(path: str) -> RolloutRequest:
134
+ with open(path) as f:
135
+ payload = json.load(f)
136
+ if payload.get("protocol_version") != PROTOCOL_VERSION:
137
+ raise RuntimeError(
138
+ f"protocol version mismatch: request carries "
139
+ f"{payload.get('protocol_version')!r}, this side speaks {PROTOCOL_VERSION}"
140
+ )
141
+ return RolloutRequest(**payload)
142
+
143
+
144
+ def write_result(spool: str, result: RolloutResult) -> str:
145
+ _, res_dir = spool_dirs(spool)
146
+ path = os.path.join(res_dir, f"{result.request_id}.json")
147
+ _atomic_write_json(asdict(result), path)
148
+ return path
149
+
150
+
151
+ def read_result(spool: str, request_id: str) -> Optional[RolloutResult]:
152
+ """Non-blocking: None until the result file exists (atomically complete)."""
153
+ _, res_dir = spool_dirs(spool)
154
+ path = os.path.join(res_dir, f"{request_id}.json")
155
+ if not os.path.exists(path):
156
+ return None
157
+ with open(path) as f:
158
+ payload = json.load(f)
159
+ if payload.get("protocol_version") != PROTOCOL_VERSION:
160
+ raise RuntimeError(
161
+ f"protocol version mismatch on result {request_id!r}: "
162
+ f"{payload.get('protocol_version')!r} vs {PROTOCOL_VERSION}"
163
+ )
164
+ return RolloutResult(**payload)
165
+
166
+
167
+ def touch_heartbeat(spool: str) -> None:
168
+ path = os.path.join(spool, "heartbeat")
169
+ with open(path, "a"):
170
+ os.utime(path, None)
171
+
172
+
173
+ def heartbeat_age_seconds(spool: str) -> float:
174
+ """Inf when no heartbeat exists yet (worker never started != worker slow)."""
175
+ path = os.path.join(spool, "heartbeat")
176
+ if not os.path.exists(path):
177
+ return float("inf")
178
+ return time.time() - os.path.getmtime(path)
179
+
180
+
181
+ def wait_for_result(
182
+ spool: str,
183
+ request_id: str,
184
+ timeout_s: float,
185
+ stale_heartbeat_s: float = 600.0,
186
+ poll_s: float = 2.0,
187
+ startup_grace_s: float = 300.0,
188
+ ) -> RolloutResult:
189
+ """Block until the result lands; raise on timeout, dead worker, or error.
190
+
191
+ THREE distinct failure signatures, kept distinct on purpose: a TIMEOUT
192
+ with a live heartbeat means the request is too big for the budget; a
193
+ STALE heartbeat means the worker died mid-service; and NO heartbeat at
194
+ all means the worker has not finished booting -- which is fatal only
195
+ after `startup_grace_s`, measured from this wait's start. The first
196
+ smoke run (2026-08-24) found the missing third case the hard way: a
197
+ step-0 canary raced the worker's ~30s vLLM import, age=inf compared
198
+ `> stale_heartbeat_s` as instantly dead, and the teardown killed a
199
+ perfectly healthy worker before its first log line flushed.
200
+ """
201
+ start = time.time()
202
+ while True:
203
+ result = read_result(spool, request_id)
204
+ if result is not None:
205
+ if result.error:
206
+ raise RuntimeError(f"rollout worker reported error: {result.error}")
207
+ return result
208
+ age = heartbeat_age_seconds(spool)
209
+ if age == float("inf"):
210
+ if time.time() - start > startup_grace_s:
211
+ raise RuntimeError(
212
+ f"rollout worker produced NO heartbeat within {startup_grace_s:.0f}s "
213
+ f"of waiting on request {request_id!r}: it never started. Check its "
214
+ "log (spawn env, venv, import errors)."
215
+ )
216
+ elif age > stale_heartbeat_s:
217
+ raise RuntimeError(
218
+ f"rollout worker heartbeat is {age:.0f}s stale "
219
+ f"(> {stale_heartbeat_s:.0f}s) while request {request_id!r} is pending: "
220
+ "worker presumed dead. Check its log; do not resubmit blindly."
221
+ )
222
+ if time.time() - start > timeout_s:
223
+ raise TimeoutError(
224
+ f"rollout request {request_id!r} exceeded {timeout_s:.0f}s with a live "
225
+ "worker: the request shape is too big for the time budget."
226
+ )
227
+ time.sleep(poll_s)
code/align/rollout/tp_engine.py ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tensor-parallel driver for `PagedEngine` (P3, opt-in): same scheduler, same sampler, a TP-sharded model.
2
+
3
+ WHAT CHANGES vs the single-device engine, and what deliberately does not.
4
+
5
+ * The model is `align.model.tp`'s shard. `PagedEngine` drives it UNCHANGED through its own submodules, so the
6
+ paged KV cache is sharded by kv head for free: `_ModelView` reads the LOCAL `n_kv_heads` (Qwen3-8B at tp=4:
7
+ 8 -> 2) and sizes `PagedKVCache` with it. Per-rank KV bytes are exactly 1/tp (tested), the block tables,
8
+ refcounts, prefix sharing and preemption logic are the original code, and attention is purely local (a rank's
9
+ q heads read only kv heads it owns, see `align.model.tp`).
10
+
11
+ * REPLICATED SCHEDULER, NOT A BROADCAST ONE. The task offered "scheduler on rank 0 broadcasting decisions".
12
+ Chosen instead: every rank runs the identical deterministic scheduler on identical inputs, and rank 0 is
13
+ authoritative for the INPUTS (the request stream, broadcast once by `generate`) and the VERIFIER of the
14
+ replication. Why:
15
+ - Broadcasting decisions means shipping a ragged object (segments, block tables, the sampled-row list) every
16
+ step: a pickle + host sync on the critical path, per step, for information every rank can recompute for
17
+ free from state it already holds.
18
+ - The scheduler is a pure function of (request stream, sampled tokens). Sampled tokens are identical on
19
+ every rank because the logits are: the lm_head ALL-GATHER hands every rank the same bytes, and sampling
20
+ (per-sequence seeded generators, `_stable_seed` = sha256 not `hash()`) is a deterministic function of the
21
+ logits row. So replicated sampling needs no extra collective; rank-0-only sampling would need a token
22
+ broadcast between every forward and the next (a serial hop) PLUS the full-logit row on rank 0 anyway.
23
+ - The one real risk of replication is SILENT DIVERGENCE (a single differing token or schedule makes ranks
24
+ issue different collectives: an NCCL hang for the timeout, or all-reduces of mismatched shapes). That is
25
+ closed by two tiny consensus all-gathers per step (8 bytes/rank each, vs 2*n_layers+1 all-reduces of
26
+ [rows, hidden]): `_schedule` checks a fingerprint of the step's schedule BEFORE any model collective, and
27
+ `has_unfinished` checks the previous step's events (tokens, finish reasons) and the loop
28
+ predicate. The check is SYMMETRIC (all-gather, every rank sees every fingerprint), so a divergence raises
29
+ `TPDesyncError` on ALL ranks instead of leaving the agreeing ones blocked in the next collective.
30
+
31
+ * Residual rounding. Under bf16 the row-parallel all-reduce sums per-rank partials each rounded to bf16, so TP
32
+ logits differ from the unsharded model's by bf16 rounding (measured in docs/P3_ENGINE.md); in fp64 they agree
33
+ to ~1e-15. `numerics='invariant'` keeps its meaning under TP: row-wise calls keep every all-reduce the same
34
+ shape whatever the batch holds, so a sequence's bits do not depend on its companions (tested bitwise).
35
+
36
+ Every rank must call the same methods with the same arguments in the same order (`add_request`, `step`, `generate`);
37
+ `generate` takes its requests from rank 0 and broadcasts them, so only rank 0 needs the real list.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import hashlib
43
+ import struct
44
+ from typing import Optional, Sequence
45
+
46
+ import torch
47
+
48
+ from align.model.tp import TPGroup
49
+ from align.rollout.engine import PagedEngine, PagedEngineConfig, PagedResult, SamplingParams
50
+
51
+
52
+ class TPDesyncError(RuntimeError):
53
+ """Ranks disagree about the schedule, the sampled tokens or whether work remains. Raised on EVERY rank."""
54
+
55
+
56
+ class _FullVocabEmbed:
57
+ """What `PagedEngine.add_request` sees as the embedding: a `.weight` whose shape[0] is the FULL vocabulary
58
+ (the real weight is this rank's shard, whose row count would reject every valid id >= V/tp), and the sharded
59
+ lookup as the call. The meta tensor allocates nothing."""
60
+
61
+ def __init__(self, emb):
62
+ self._emb = emb
63
+ self.weight = torch.empty((emb.full_vocab, emb.weight.shape[1]), device="meta")
64
+
65
+ def __call__(self, ids):
66
+ return self._emb(ids)
67
+
68
+
69
+ def _digest(parts: Sequence[bytes]) -> int:
70
+ h = hashlib.sha256()
71
+ for p in parts:
72
+ h.update(struct.pack("<Q", len(p)))
73
+ h.update(p)
74
+ return int.from_bytes(h.digest()[:8], "little") & ((1 << 62) - 1)
75
+
76
+
77
+ def schedule_fingerprint(sched) -> int:
78
+ """Hash of everything that decides the step's collectives and shapes: for each scheduled sequence its id, how
79
+ many tokens it already has computed, how many it adds, and the token ids it feeds. Module-level so a test can
80
+ substitute a constant (divergence then goes undetected until a collective hangs)."""
81
+ parts = []
82
+ for seq, n in sched:
83
+ parts.append(f"{seq.rid}|{seq.num_computed}|{n}".encode())
84
+ parts.append(struct.pack(f"<{n}q", *seq.tokens[seq.num_computed : seq.num_computed + n]))
85
+ return _digest(parts)
86
+
87
+
88
+ def events_fingerprint(events) -> int:
89
+ """Hash of a step's sampled events: request id, token and finish reason. (Logprobs need no hash of their own: they are
90
+ a deterministic function of the all-gathered logits row and the token, both already agreed.)"""
91
+ return _digest([f"{rid}|{tok}|{reason}".encode() for rid, tok, reason in events])
92
+
93
+
94
+ class TPPagedEngine(PagedEngine):
95
+ """`PagedEngine` over a `align.model.tp` shard. `verify=False` drops the two consensus all-gathers (a
96
+ measured-cost opt-out for a job that has proven itself; the default keeps the hang-proofing)."""
97
+
98
+ def __init__(self, model: torch.nn.Module, tp: Optional[TPGroup] = None, cfg: Optional[PagedEngineConfig] = None, *,
99
+ tokenizer=None, return_logits: bool = False, verify: bool = True):
100
+ tp = tp if tp is not None else getattr(model, "tp", None)
101
+ if tp is None:
102
+ raise ValueError("TPPagedEngine needs a TPGroup (or a model built by align.model.tp, which carries one)")
103
+ super().__init__(model, cfg, tokenizer=tokenizer, return_logits=return_logits)
104
+ self.tp, self.verify = tp, verify and tp.world_size > 1
105
+ emb = self.view.embed
106
+ if not hasattr(emb, "full_vocab"):
107
+ raise TypeError("model is not a tensor-parallel shard (embed_tokens has no `full_vocab`)")
108
+ emb.check_range = False # add_request already validates ids; skip the per-step device sync
109
+ self.view.embed = _FullVocabEmbed(emb)
110
+ self._last_events = 0
111
+ self.stats.update(consensus_checks=0)
112
+
113
+ # -- replication guards ----------------------------------------------------------
114
+
115
+ def _agree(self, label: str, values: list[int]) -> None:
116
+ got = self.tp.all_gather_i64(values, self.device)
117
+ self.stats["consensus_checks"] += 1
118
+ if any(g != got[0] for g in got):
119
+ raise TPDesyncError(
120
+ f"tensor-parallel ranks diverged at {label}: per-rank fingerprints {got} (this is rank {self.tp.rank}). "
121
+ "Every rank must see the same requests in the same order with the same config."
122
+ )
123
+
124
+ def has_unfinished(self) -> bool:
125
+ local = super().has_unfinished()
126
+ if self.verify:
127
+ self._agree("loop predicate / previous step's events", [int(local), self._last_events])
128
+ return local
129
+
130
+ def _schedule(self):
131
+ sched = super()._schedule()
132
+ if self.verify:
133
+ self._agree("the step schedule", [schedule_fingerprint(sched)])
134
+ return sched
135
+
136
+ def step(self):
137
+ events = super().step()
138
+ self._last_events = events_fingerprint(events)
139
+ return events
140
+
141
+ # -- driver ---------------------------------------------------------------------------
142
+
143
+ def generate(self, requests=None, arrival_step: Optional[dict[str, int]] = None, max_steps: int = 1_000_000
144
+ ) -> dict[str, PagedResult]:
145
+ """As `PagedEngine.generate`, but rank 0's `requests`/`arrival_step` are broadcast to every rank (other
146
+ ranks may pass None): the request stream has exactly one author."""
147
+ payload = None
148
+ if self.tp.rank == 0:
149
+ # Built BEFORE the broadcast, so a malformed stream would raise on rank 0 alone and leave every other
150
+ # rank blocked in the broadcast until the process-group timeout (minutes to hours on NCCL). The error
151
+ # is therefore shipped as data and re-raised everywhere.
152
+ try:
153
+ payload = ("ok", ([(rid, list(ids), p) for rid, ids, p in requests], dict(arrival_step or {})))
154
+ except Exception as e: # noqa: BLE001
155
+ payload = ("err", f"{type(e).__name__}: {e}")
156
+ tag, body = self.tp.broadcast_object(payload)
157
+ if tag == "err":
158
+ raise ValueError(f"rank 0 could not build the request stream: {body}")
159
+ reqs, arrivals = body
160
+ reqs = [(rid, ids, p if isinstance(p, SamplingParams) else SamplingParams(**p)) for rid, ids, p in reqs]
161
+ return super().generate(reqs, arrivals, max_steps)
162
+
163
+ # -- accounting --------------------------------------------------------------------------
164
+
165
+ def kv_bytes_per_rank(self) -> int:
166
+ return self.cache.bytes_total()
167
+
168
+
169
+ __all__ = ["TPPagedEngine", "TPDesyncError", "schedule_fingerprint", "events_fingerprint"]
code/align/tokenizer/__init__.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """align.tokenizer -- pure-Python (stdlib-only) Mistral-7B-v0.3 BPE tokenizer.
2
+
3
+ from align.tokenizer import Tokenizer, load_tokenizer
4
+ tok = load_tokenizer("/path/to/dir/with/tokenizer.json")
5
+ tok("a\\nb")["input_ids"] # [1, 1032, <0x0A id>, 29494] (<s> ▁a <0x0A> b)
6
+ tok.decode(ids, skip_special_tokens=True)
7
+
8
+ It replaces `transformers.AutoTokenizer` for this codebase (dependency policy:
9
+ torch/triton/numpy/safetensors only). Parity is certified against the legacy
10
+ transformers tokenizer by the `oracle`-marked test in tests/test_tokenizer.py.
11
+
12
+ Two places pure reimplementations usually get wrong -- both are load-bearing:
13
+
14
+ 1. Metaspace prepend_scheme="first".
15
+ The pre-tokenizer replaces ' ' with U+2581 and prepends U+2581 to split #0 of
16
+ the whole input -- and only if that split is NOT an added token. Added tokens
17
+ (<s>, [INST], [control_N], ...) are carved out of the raw text before the
18
+ pre-tokenizer runs; they occupy a split index but skip the pre-tokenizer, so
19
+ "first" is neither "first character of the string" nor "first segment after
20
+ each special token" nor "first non-added segment":
21
+ "hello<s>world" -> ▁hello <s> world (prefix on split 0 only)
22
+ "<s>hello" -> <s> <s> hello (bare 'hello': the leading <s> used
23
+ up split 0 -- measured, not guessed)
24
+ " hello" -> ▁hello (already starts with U+2581: no
25
+ second prefix)
26
+ With split=false the whole non-added segment is a single BPE "word", so
27
+ merges can run across spaces and newlines ('\\n' is not touched by Metaspace;
28
+ it has no vocab piece and falls back to the byte token <0x0A>, the id the vocab assigns it).
29
+
30
+ 2. Strip(" ", start=1) at the END of the decoder chain.
31
+ decode = Replace(U+2581 -> ' ') / ByteFallback / Fuse / Strip. Strip removes
32
+ at most ONE leading space from the FUSED string. Consequences: decode of the
33
+ ids for "hello" is "hello" (the prepended ▁ is stripped, round-trip exact),
34
+ but decoding a mid-sequence chunk [▁world] gives "world", not " world" -- so
35
+ decoding token deltas and concatenating drops spaces. Decode the whole
36
+ sequence and diff the TEXT (the inference engine does exactly that). Also:
37
+ ByteFallback decodes each maximal run of <0xNN> pieces as one UTF-8 unit, and
38
+ an invalid run yields one U+FFFD per byte (not Python's errors="replace").
39
+
40
+ The loader refuses (NotImplementedError) any tokenizer.json whose pipeline
41
+ differs from the one above, rather than silently producing wrong ids.
42
+
43
+ Byte-level BPE (Qwen2/Qwen3, e.g. Goedel-Prover-V2-8B) lives in `.bytelevel`
44
+ (`ByteLevelTokenizer`); see that module's docstring. `load_tokenizer` dispatches on
45
+ tokenizer.json (model.type + pre_tokenizer): Metaspace -> the Mistral `Tokenizer`
46
+ above, completely unchanged; Split+ByteLevel -> `ByteLevelTokenizer`; anything else
47
+ -> NotImplementedError (never a guess).
48
+ """
49
+ from __future__ import annotations
50
+
51
+ import json
52
+ import os
53
+ from typing import Union
54
+
55
+ from .bytelevel import ByteLevelTokenizer
56
+ from .core import Tokenizer, _resolve_dir
57
+
58
+ __all__ = ["Tokenizer", "ByteLevelTokenizer", "load_tokenizer"]
59
+
60
+
61
+ def _pre_tokenizer_kinds(pt) -> set:
62
+ if not pt:
63
+ return set()
64
+ if pt.get("type") == "Sequence":
65
+ return {p.get("type") for p in pt.get("pretokenizers", [])}
66
+ return {pt.get("type")}
67
+
68
+
69
+ def load_tokenizer(path: Union[str, os.PathLike]):
70
+ """Local-only loader. Mistral (Metaspace) -> Tokenizer; Qwen (ByteLevel) ->
71
+ ByteLevelTokenizer. The choice is made from tokenizer.json, never from the
72
+ directory name, and every other family fails loudly instead of being fed to a
73
+ pipeline that would encode it wrong."""
74
+ d = _resolve_dir(os.fspath(path))
75
+ with open(os.path.join(d, "tokenizer.json"), "rb") as f:
76
+ tj = json.loads(f.read())
77
+ kinds = _pre_tokenizer_kinds(tj.get("pre_tokenizer"))
78
+ if "ByteLevel" in kinds:
79
+ return ByteLevelTokenizer.from_pretrained(d)
80
+ if kinds == {"Metaspace"}:
81
+ return Tokenizer.from_pretrained(d)
82
+ raise NotImplementedError(
83
+ f"no tokenizer implementation for pre_tokenizer {sorted(k for k in kinds if k)!r} "
84
+ f"in {d!r}; supported: Metaspace (Mistral), Split+ByteLevel (Qwen2/3)"
85
+ )
code/align/tokenizer/_qwen2_unicode.py ADDED
@@ -0,0 +1,875 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GENERATED by tests/data/make_qwen2_unicode_tables.py -- do not edit.
2
+
3
+ Probed from transformers 5.19.0 / tokenizers 0.23.3 against Python unicodedata 15.0.0.
4
+ See that script for why these are frozen.
5
+ """
6
+ PYTHON_UNIDATA_VERSION = '15.0.0' # the unicodedata these deltas were measured against
7
+
8
+ #: \p{L} / \p{N} exactly as the Rust regex engine sees them (inclusive ranges).
9
+ L_RANGES = (
10
+ (0x41, 0x5a),
11
+ (0x61, 0x7a),
12
+ (0xaa, 0xaa),
13
+ (0xb5, 0xb5),
14
+ (0xba, 0xba),
15
+ (0xc0, 0xd6),
16
+ (0xd8, 0xf6),
17
+ (0xf8, 0x2c1),
18
+ (0x2c6, 0x2d1),
19
+ (0x2e0, 0x2e4),
20
+ (0x2ec, 0x2ec),
21
+ (0x2ee, 0x2ee),
22
+ (0x370, 0x374),
23
+ (0x376, 0x377),
24
+ (0x37a, 0x37d),
25
+ (0x37f, 0x37f),
26
+ (0x386, 0x386),
27
+ (0x388, 0x38a),
28
+ (0x38c, 0x38c),
29
+ (0x38e, 0x3a1),
30
+ (0x3a3, 0x3f5),
31
+ (0x3f7, 0x481),
32
+ (0x48a, 0x52f),
33
+ (0x531, 0x556),
34
+ (0x559, 0x559),
35
+ (0x560, 0x588),
36
+ (0x5d0, 0x5ea),
37
+ (0x5ef, 0x5f2),
38
+ (0x620, 0x64a),
39
+ (0x66e, 0x66f),
40
+ (0x671, 0x6d3),
41
+ (0x6d5, 0x6d5),
42
+ (0x6e5, 0x6e6),
43
+ (0x6ee, 0x6ef),
44
+ (0x6fa, 0x6fc),
45
+ (0x6ff, 0x6ff),
46
+ (0x710, 0x710),
47
+ (0x712, 0x72f),
48
+ (0x74d, 0x7a5),
49
+ (0x7b1, 0x7b1),
50
+ (0x7ca, 0x7ea),
51
+ (0x7f4, 0x7f5),
52
+ (0x7fa, 0x7fa),
53
+ (0x800, 0x815),
54
+ (0x81a, 0x81a),
55
+ (0x824, 0x824),
56
+ (0x828, 0x828),
57
+ (0x840, 0x858),
58
+ (0x860, 0x86a),
59
+ (0x870, 0x887),
60
+ (0x889, 0x88e),
61
+ (0x8a0, 0x8c9),
62
+ (0x904, 0x939),
63
+ (0x93d, 0x93d),
64
+ (0x950, 0x950),
65
+ (0x958, 0x961),
66
+ (0x971, 0x980),
67
+ (0x985, 0x98c),
68
+ (0x98f, 0x990),
69
+ (0x993, 0x9a8),
70
+ (0x9aa, 0x9b0),
71
+ (0x9b2, 0x9b2),
72
+ (0x9b6, 0x9b9),
73
+ (0x9bd, 0x9bd),
74
+ (0x9ce, 0x9ce),
75
+ (0x9dc, 0x9dd),
76
+ (0x9df, 0x9e1),
77
+ (0x9f0, 0x9f1),
78
+ (0x9fc, 0x9fc),
79
+ (0xa05, 0xa0a),
80
+ (0xa0f, 0xa10),
81
+ (0xa13, 0xa28),
82
+ (0xa2a, 0xa30),
83
+ (0xa32, 0xa33),
84
+ (0xa35, 0xa36),
85
+ (0xa38, 0xa39),
86
+ (0xa59, 0xa5c),
87
+ (0xa5e, 0xa5e),
88
+ (0xa72, 0xa74),
89
+ (0xa85, 0xa8d),
90
+ (0xa8f, 0xa91),
91
+ (0xa93, 0xaa8),
92
+ (0xaaa, 0xab0),
93
+ (0xab2, 0xab3),
94
+ (0xab5, 0xab9),
95
+ (0xabd, 0xabd),
96
+ (0xad0, 0xad0),
97
+ (0xae0, 0xae1),
98
+ (0xaf9, 0xaf9),
99
+ (0xb05, 0xb0c),
100
+ (0xb0f, 0xb10),
101
+ (0xb13, 0xb28),
102
+ (0xb2a, 0xb30),
103
+ (0xb32, 0xb33),
104
+ (0xb35, 0xb39),
105
+ (0xb3d, 0xb3d),
106
+ (0xb5c, 0xb5d),
107
+ (0xb5f, 0xb61),
108
+ (0xb71, 0xb71),
109
+ (0xb83, 0xb83),
110
+ (0xb85, 0xb8a),
111
+ (0xb8e, 0xb90),
112
+ (0xb92, 0xb95),
113
+ (0xb99, 0xb9a),
114
+ (0xb9c, 0xb9c),
115
+ (0xb9e, 0xb9f),
116
+ (0xba3, 0xba4),
117
+ (0xba8, 0xbaa),
118
+ (0xbae, 0xbb9),
119
+ (0xbd0, 0xbd0),
120
+ (0xc05, 0xc0c),
121
+ (0xc0e, 0xc10),
122
+ (0xc12, 0xc28),
123
+ (0xc2a, 0xc39),
124
+ (0xc3d, 0xc3d),
125
+ (0xc58, 0xc5a),
126
+ (0xc5d, 0xc5d),
127
+ (0xc60, 0xc61),
128
+ (0xc80, 0xc80),
129
+ (0xc85, 0xc8c),
130
+ (0xc8e, 0xc90),
131
+ (0xc92, 0xca8),
132
+ (0xcaa, 0xcb3),
133
+ (0xcb5, 0xcb9),
134
+ (0xcbd, 0xcbd),
135
+ (0xcdd, 0xcde),
136
+ (0xce0, 0xce1),
137
+ (0xcf1, 0xcf2),
138
+ (0xd04, 0xd0c),
139
+ (0xd0e, 0xd10),
140
+ (0xd12, 0xd3a),
141
+ (0xd3d, 0xd3d),
142
+ (0xd4e, 0xd4e),
143
+ (0xd54, 0xd56),
144
+ (0xd5f, 0xd61),
145
+ (0xd7a, 0xd7f),
146
+ (0xd85, 0xd96),
147
+ (0xd9a, 0xdb1),
148
+ (0xdb3, 0xdbb),
149
+ (0xdbd, 0xdbd),
150
+ (0xdc0, 0xdc6),
151
+ (0xe01, 0xe30),
152
+ (0xe32, 0xe33),
153
+ (0xe40, 0xe46),
154
+ (0xe81, 0xe82),
155
+ (0xe84, 0xe84),
156
+ (0xe86, 0xe8a),
157
+ (0xe8c, 0xea3),
158
+ (0xea5, 0xea5),
159
+ (0xea7, 0xeb0),
160
+ (0xeb2, 0xeb3),
161
+ (0xebd, 0xebd),
162
+ (0xec0, 0xec4),
163
+ (0xec6, 0xec6),
164
+ (0xedc, 0xedf),
165
+ (0xf00, 0xf00),
166
+ (0xf40, 0xf47),
167
+ (0xf49, 0xf6c),
168
+ (0xf88, 0xf8c),
169
+ (0x1000, 0x102a),
170
+ (0x103f, 0x103f),
171
+ (0x1050, 0x1055),
172
+ (0x105a, 0x105d),
173
+ (0x1061, 0x1061),
174
+ (0x1065, 0x1066),
175
+ (0x106e, 0x1070),
176
+ (0x1075, 0x1081),
177
+ (0x108e, 0x108e),
178
+ (0x10a0, 0x10c5),
179
+ (0x10c7, 0x10c7),
180
+ (0x10cd, 0x10cd),
181
+ (0x10d0, 0x10fa),
182
+ (0x10fc, 0x1248),
183
+ (0x124a, 0x124d),
184
+ (0x1250, 0x1256),
185
+ (0x1258, 0x1258),
186
+ (0x125a, 0x125d),
187
+ (0x1260, 0x1288),
188
+ (0x128a, 0x128d),
189
+ (0x1290, 0x12b0),
190
+ (0x12b2, 0x12b5),
191
+ (0x12b8, 0x12be),
192
+ (0x12c0, 0x12c0),
193
+ (0x12c2, 0x12c5),
194
+ (0x12c8, 0x12d6),
195
+ (0x12d8, 0x1310),
196
+ (0x1312, 0x1315),
197
+ (0x1318, 0x135a),
198
+ (0x1380, 0x138f),
199
+ (0x13a0, 0x13f5),
200
+ (0x13f8, 0x13fd),
201
+ (0x1401, 0x166c),
202
+ (0x166f, 0x167f),
203
+ (0x1681, 0x169a),
204
+ (0x16a0, 0x16ea),
205
+ (0x16f1, 0x16f8),
206
+ (0x1700, 0x1711),
207
+ (0x171f, 0x1731),
208
+ (0x1740, 0x1751),
209
+ (0x1760, 0x176c),
210
+ (0x176e, 0x1770),
211
+ (0x1780, 0x17b3),
212
+ (0x17d7, 0x17d7),
213
+ (0x17dc, 0x17dc),
214
+ (0x1820, 0x1878),
215
+ (0x1880, 0x1884),
216
+ (0x1887, 0x18a8),
217
+ (0x18aa, 0x18aa),
218
+ (0x18b0, 0x18f5),
219
+ (0x1900, 0x191e),
220
+ (0x1950, 0x196d),
221
+ (0x1970, 0x1974),
222
+ (0x1980, 0x19ab),
223
+ (0x19b0, 0x19c9),
224
+ (0x1a00, 0x1a16),
225
+ (0x1a20, 0x1a54),
226
+ (0x1aa7, 0x1aa7),
227
+ (0x1b05, 0x1b33),
228
+ (0x1b45, 0x1b4c),
229
+ (0x1b83, 0x1ba0),
230
+ (0x1bae, 0x1baf),
231
+ (0x1bba, 0x1be5),
232
+ (0x1c00, 0x1c23),
233
+ (0x1c4d, 0x1c4f),
234
+ (0x1c5a, 0x1c7d),
235
+ (0x1c80, 0x1c8a),
236
+ (0x1c90, 0x1cba),
237
+ (0x1cbd, 0x1cbf),
238
+ (0x1ce9, 0x1cec),
239
+ (0x1cee, 0x1cf3),
240
+ (0x1cf5, 0x1cf6),
241
+ (0x1cfa, 0x1cfa),
242
+ (0x1d00, 0x1dbf),
243
+ (0x1e00, 0x1f15),
244
+ (0x1f18, 0x1f1d),
245
+ (0x1f20, 0x1f45),
246
+ (0x1f48, 0x1f4d),
247
+ (0x1f50, 0x1f57),
248
+ (0x1f59, 0x1f59),
249
+ (0x1f5b, 0x1f5b),
250
+ (0x1f5d, 0x1f5d),
251
+ (0x1f5f, 0x1f7d),
252
+ (0x1f80, 0x1fb4),
253
+ (0x1fb6, 0x1fbc),
254
+ (0x1fbe, 0x1fbe),
255
+ (0x1fc2, 0x1fc4),
256
+ (0x1fc6, 0x1fcc),
257
+ (0x1fd0, 0x1fd3),
258
+ (0x1fd6, 0x1fdb),
259
+ (0x1fe0, 0x1fec),
260
+ (0x1ff2, 0x1ff4),
261
+ (0x1ff6, 0x1ffc),
262
+ (0x2071, 0x2071),
263
+ (0x207f, 0x207f),
264
+ (0x2090, 0x209c),
265
+ (0x2102, 0x2102),
266
+ (0x2107, 0x2107),
267
+ (0x210a, 0x2113),
268
+ (0x2115, 0x2115),
269
+ (0x2119, 0x211d),
270
+ (0x2124, 0x2124),
271
+ (0x2126, 0x2126),
272
+ (0x2128, 0x2128),
273
+ (0x212a, 0x212d),
274
+ (0x212f, 0x2139),
275
+ (0x213c, 0x213f),
276
+ (0x2145, 0x2149),
277
+ (0x214e, 0x214e),
278
+ (0x2183, 0x2184),
279
+ (0x2c00, 0x2ce4),
280
+ (0x2ceb, 0x2cee),
281
+ (0x2cf2, 0x2cf3),
282
+ (0x2d00, 0x2d25),
283
+ (0x2d27, 0x2d27),
284
+ (0x2d2d, 0x2d2d),
285
+ (0x2d30, 0x2d67),
286
+ (0x2d6f, 0x2d6f),
287
+ (0x2d80, 0x2d96),
288
+ (0x2da0, 0x2da6),
289
+ (0x2da8, 0x2dae),
290
+ (0x2db0, 0x2db6),
291
+ (0x2db8, 0x2dbe),
292
+ (0x2dc0, 0x2dc6),
293
+ (0x2dc8, 0x2dce),
294
+ (0x2dd0, 0x2dd6),
295
+ (0x2dd8, 0x2dde),
296
+ (0x2e2f, 0x2e2f),
297
+ (0x3005, 0x3006),
298
+ (0x3031, 0x3035),
299
+ (0x303b, 0x303c),
300
+ (0x3041, 0x3096),
301
+ (0x309d, 0x309f),
302
+ (0x30a1, 0x30fa),
303
+ (0x30fc, 0x30ff),
304
+ (0x3105, 0x312f),
305
+ (0x3131, 0x318e),
306
+ (0x31a0, 0x31bf),
307
+ (0x31f0, 0x31ff),
308
+ (0x3400, 0x4dbf),
309
+ (0x4e00, 0xa48c),
310
+ (0xa4d0, 0xa4fd),
311
+ (0xa500, 0xa60c),
312
+ (0xa610, 0xa61f),
313
+ (0xa62a, 0xa62b),
314
+ (0xa640, 0xa66e),
315
+ (0xa67f, 0xa69d),
316
+ (0xa6a0, 0xa6e5),
317
+ (0xa717, 0xa71f),
318
+ (0xa722, 0xa788),
319
+ (0xa78b, 0xa7cd),
320
+ (0xa7d0, 0xa7d1),
321
+ (0xa7d3, 0xa7d3),
322
+ (0xa7d5, 0xa7dc),
323
+ (0xa7f2, 0xa801),
324
+ (0xa803, 0xa805),
325
+ (0xa807, 0xa80a),
326
+ (0xa80c, 0xa822),
327
+ (0xa840, 0xa873),
328
+ (0xa882, 0xa8b3),
329
+ (0xa8f2, 0xa8f7),
330
+ (0xa8fb, 0xa8fb),
331
+ (0xa8fd, 0xa8fe),
332
+ (0xa90a, 0xa925),
333
+ (0xa930, 0xa946),
334
+ (0xa960, 0xa97c),
335
+ (0xa984, 0xa9b2),
336
+ (0xa9cf, 0xa9cf),
337
+ (0xa9e0, 0xa9e4),
338
+ (0xa9e6, 0xa9ef),
339
+ (0xa9fa, 0xa9fe),
340
+ (0xaa00, 0xaa28),
341
+ (0xaa40, 0xaa42),
342
+ (0xaa44, 0xaa4b),
343
+ (0xaa60, 0xaa76),
344
+ (0xaa7a, 0xaa7a),
345
+ (0xaa7e, 0xaaaf),
346
+ (0xaab1, 0xaab1),
347
+ (0xaab5, 0xaab6),
348
+ (0xaab9, 0xaabd),
349
+ (0xaac0, 0xaac0),
350
+ (0xaac2, 0xaac2),
351
+ (0xaadb, 0xaadd),
352
+ (0xaae0, 0xaaea),
353
+ (0xaaf2, 0xaaf4),
354
+ (0xab01, 0xab06),
355
+ (0xab09, 0xab0e),
356
+ (0xab11, 0xab16),
357
+ (0xab20, 0xab26),
358
+ (0xab28, 0xab2e),
359
+ (0xab30, 0xab5a),
360
+ (0xab5c, 0xab69),
361
+ (0xab70, 0xabe2),
362
+ (0xac00, 0xd7a3),
363
+ (0xd7b0, 0xd7c6),
364
+ (0xd7cb, 0xd7fb),
365
+ (0xf900, 0xfa6d),
366
+ (0xfa70, 0xfad9),
367
+ (0xfb00, 0xfb06),
368
+ (0xfb13, 0xfb17),
369
+ (0xfb1d, 0xfb1d),
370
+ (0xfb1f, 0xfb28),
371
+ (0xfb2a, 0xfb36),
372
+ (0xfb38, 0xfb3c),
373
+ (0xfb3e, 0xfb3e),
374
+ (0xfb40, 0xfb41),
375
+ (0xfb43, 0xfb44),
376
+ (0xfb46, 0xfbb1),
377
+ (0xfbd3, 0xfd3d),
378
+ (0xfd50, 0xfd8f),
379
+ (0xfd92, 0xfdc7),
380
+ (0xfdf0, 0xfdfb),
381
+ (0xfe70, 0xfe74),
382
+ (0xfe76, 0xfefc),
383
+ (0xff21, 0xff3a),
384
+ (0xff41, 0xff5a),
385
+ (0xff66, 0xffbe),
386
+ (0xffc2, 0xffc7),
387
+ (0xffca, 0xffcf),
388
+ (0xffd2, 0xffd7),
389
+ (0xffda, 0xffdc),
390
+ (0x10000, 0x1000b),
391
+ (0x1000d, 0x10026),
392
+ (0x10028, 0x1003a),
393
+ (0x1003c, 0x1003d),
394
+ (0x1003f, 0x1004d),
395
+ (0x10050, 0x1005d),
396
+ (0x10080, 0x100fa),
397
+ (0x10280, 0x1029c),
398
+ (0x102a0, 0x102d0),
399
+ (0x10300, 0x1031f),
400
+ (0x1032d, 0x10340),
401
+ (0x10342, 0x10349),
402
+ (0x10350, 0x10375),
403
+ (0x10380, 0x1039d),
404
+ (0x103a0, 0x103c3),
405
+ (0x103c8, 0x103cf),
406
+ (0x10400, 0x1049d),
407
+ (0x104b0, 0x104d3),
408
+ (0x104d8, 0x104fb),
409
+ (0x10500, 0x10527),
410
+ (0x10530, 0x10563),
411
+ (0x10570, 0x1057a),
412
+ (0x1057c, 0x1058a),
413
+ (0x1058c, 0x10592),
414
+ (0x10594, 0x10595),
415
+ (0x10597, 0x105a1),
416
+ (0x105a3, 0x105b1),
417
+ (0x105b3, 0x105b9),
418
+ (0x105bb, 0x105bc),
419
+ (0x105c0, 0x105f3),
420
+ (0x10600, 0x10736),
421
+ (0x10740, 0x10755),
422
+ (0x10760, 0x10767),
423
+ (0x10780, 0x10785),
424
+ (0x10787, 0x107b0),
425
+ (0x107b2, 0x107ba),
426
+ (0x10800, 0x10805),
427
+ (0x10808, 0x10808),
428
+ (0x1080a, 0x10835),
429
+ (0x10837, 0x10838),
430
+ (0x1083c, 0x1083c),
431
+ (0x1083f, 0x10855),
432
+ (0x10860, 0x10876),
433
+ (0x10880, 0x1089e),
434
+ (0x108e0, 0x108f2),
435
+ (0x108f4, 0x108f5),
436
+ (0x10900, 0x10915),
437
+ (0x10920, 0x10939),
438
+ (0x10980, 0x109b7),
439
+ (0x109be, 0x109bf),
440
+ (0x10a00, 0x10a00),
441
+ (0x10a10, 0x10a13),
442
+ (0x10a15, 0x10a17),
443
+ (0x10a19, 0x10a35),
444
+ (0x10a60, 0x10a7c),
445
+ (0x10a80, 0x10a9c),
446
+ (0x10ac0, 0x10ac7),
447
+ (0x10ac9, 0x10ae4),
448
+ (0x10b00, 0x10b35),
449
+ (0x10b40, 0x10b55),
450
+ (0x10b60, 0x10b72),
451
+ (0x10b80, 0x10b91),
452
+ (0x10c00, 0x10c48),
453
+ (0x10c80, 0x10cb2),
454
+ (0x10cc0, 0x10cf2),
455
+ (0x10d00, 0x10d23),
456
+ (0x10d4a, 0x10d65),
457
+ (0x10d6f, 0x10d85),
458
+ (0x10e80, 0x10ea9),
459
+ (0x10eb0, 0x10eb1),
460
+ (0x10ec2, 0x10ec4),
461
+ (0x10f00, 0x10f1c),
462
+ (0x10f27, 0x10f27),
463
+ (0x10f30, 0x10f45),
464
+ (0x10f70, 0x10f81),
465
+ (0x10fb0, 0x10fc4),
466
+ (0x10fe0, 0x10ff6),
467
+ (0x11003, 0x11037),
468
+ (0x11071, 0x11072),
469
+ (0x11075, 0x11075),
470
+ (0x11083, 0x110af),
471
+ (0x110d0, 0x110e8),
472
+ (0x11103, 0x11126),
473
+ (0x11144, 0x11144),
474
+ (0x11147, 0x11147),
475
+ (0x11150, 0x11172),
476
+ (0x11176, 0x11176),
477
+ (0x11183, 0x111b2),
478
+ (0x111c1, 0x111c4),
479
+ (0x111da, 0x111da),
480
+ (0x111dc, 0x111dc),
481
+ (0x11200, 0x11211),
482
+ (0x11213, 0x1122b),
483
+ (0x1123f, 0x11240),
484
+ (0x11280, 0x11286),
485
+ (0x11288, 0x11288),
486
+ (0x1128a, 0x1128d),
487
+ (0x1128f, 0x1129d),
488
+ (0x1129f, 0x112a8),
489
+ (0x112b0, 0x112de),
490
+ (0x11305, 0x1130c),
491
+ (0x1130f, 0x11310),
492
+ (0x11313, 0x11328),
493
+ (0x1132a, 0x11330),
494
+ (0x11332, 0x11333),
495
+ (0x11335, 0x11339),
496
+ (0x1133d, 0x1133d),
497
+ (0x11350, 0x11350),
498
+ (0x1135d, 0x11361),
499
+ (0x11380, 0x11389),
500
+ (0x1138b, 0x1138b),
501
+ (0x1138e, 0x1138e),
502
+ (0x11390, 0x113b5),
503
+ (0x113b7, 0x113b7),
504
+ (0x113d1, 0x113d1),
505
+ (0x113d3, 0x113d3),
506
+ (0x11400, 0x11434),
507
+ (0x11447, 0x1144a),
508
+ (0x1145f, 0x11461),
509
+ (0x11480, 0x114af),
510
+ (0x114c4, 0x114c5),
511
+ (0x114c7, 0x114c7),
512
+ (0x11580, 0x115ae),
513
+ (0x115d8, 0x115db),
514
+ (0x11600, 0x1162f),
515
+ (0x11644, 0x11644),
516
+ (0x11680, 0x116aa),
517
+ (0x116b8, 0x116b8),
518
+ (0x11700, 0x1171a),
519
+ (0x11740, 0x11746),
520
+ (0x11800, 0x1182b),
521
+ (0x118a0, 0x118df),
522
+ (0x118ff, 0x11906),
523
+ (0x11909, 0x11909),
524
+ (0x1190c, 0x11913),
525
+ (0x11915, 0x11916),
526
+ (0x11918, 0x1192f),
527
+ (0x1193f, 0x1193f),
528
+ (0x11941, 0x11941),
529
+ (0x119a0, 0x119a7),
530
+ (0x119aa, 0x119d0),
531
+ (0x119e1, 0x119e1),
532
+ (0x119e3, 0x119e3),
533
+ (0x11a00, 0x11a00),
534
+ (0x11a0b, 0x11a32),
535
+ (0x11a3a, 0x11a3a),
536
+ (0x11a50, 0x11a50),
537
+ (0x11a5c, 0x11a89),
538
+ (0x11a9d, 0x11a9d),
539
+ (0x11ab0, 0x11af8),
540
+ (0x11bc0, 0x11be0),
541
+ (0x11c00, 0x11c08),
542
+ (0x11c0a, 0x11c2e),
543
+ (0x11c40, 0x11c40),
544
+ (0x11c72, 0x11c8f),
545
+ (0x11d00, 0x11d06),
546
+ (0x11d08, 0x11d09),
547
+ (0x11d0b, 0x11d30),
548
+ (0x11d46, 0x11d46),
549
+ (0x11d60, 0x11d65),
550
+ (0x11d67, 0x11d68),
551
+ (0x11d6a, 0x11d89),
552
+ (0x11d98, 0x11d98),
553
+ (0x11ee0, 0x11ef2),
554
+ (0x11f02, 0x11f02),
555
+ (0x11f04, 0x11f10),
556
+ (0x11f12, 0x11f33),
557
+ (0x11fb0, 0x11fb0),
558
+ (0x12000, 0x12399),
559
+ (0x12480, 0x12543),
560
+ (0x12f90, 0x12ff0),
561
+ (0x13000, 0x1342f),
562
+ (0x13441, 0x13446),
563
+ (0x13460, 0x143fa),
564
+ (0x14400, 0x14646),
565
+ (0x16100, 0x1611d),
566
+ (0x16800, 0x16a38),
567
+ (0x16a40, 0x16a5e),
568
+ (0x16a70, 0x16abe),
569
+ (0x16ad0, 0x16aed),
570
+ (0x16b00, 0x16b2f),
571
+ (0x16b40, 0x16b43),
572
+ (0x16b63, 0x16b77),
573
+ (0x16b7d, 0x16b8f),
574
+ (0x16d40, 0x16d6c),
575
+ (0x16e40, 0x16e7f),
576
+ (0x16f00, 0x16f4a),
577
+ (0x16f50, 0x16f50),
578
+ (0x16f93, 0x16f9f),
579
+ (0x16fe0, 0x16fe1),
580
+ (0x16fe3, 0x16fe3),
581
+ (0x17000, 0x187f7),
582
+ (0x18800, 0x18cd5),
583
+ (0x18cff, 0x18d08),
584
+ (0x1aff0, 0x1aff3),
585
+ (0x1aff5, 0x1affb),
586
+ (0x1affd, 0x1affe),
587
+ (0x1b000, 0x1b122),
588
+ (0x1b132, 0x1b132),
589
+ (0x1b150, 0x1b152),
590
+ (0x1b155, 0x1b155),
591
+ (0x1b164, 0x1b167),
592
+ (0x1b170, 0x1b2fb),
593
+ (0x1bc00, 0x1bc6a),
594
+ (0x1bc70, 0x1bc7c),
595
+ (0x1bc80, 0x1bc88),
596
+ (0x1bc90, 0x1bc99),
597
+ (0x1d400, 0x1d454),
598
+ (0x1d456, 0x1d49c),
599
+ (0x1d49e, 0x1d49f),
600
+ (0x1d4a2, 0x1d4a2),
601
+ (0x1d4a5, 0x1d4a6),
602
+ (0x1d4a9, 0x1d4ac),
603
+ (0x1d4ae, 0x1d4b9),
604
+ (0x1d4bb, 0x1d4bb),
605
+ (0x1d4bd, 0x1d4c3),
606
+ (0x1d4c5, 0x1d505),
607
+ (0x1d507, 0x1d50a),
608
+ (0x1d50d, 0x1d514),
609
+ (0x1d516, 0x1d51c),
610
+ (0x1d51e, 0x1d539),
611
+ (0x1d53b, 0x1d53e),
612
+ (0x1d540, 0x1d544),
613
+ (0x1d546, 0x1d546),
614
+ (0x1d54a, 0x1d550),
615
+ (0x1d552, 0x1d6a5),
616
+ (0x1d6a8, 0x1d6c0),
617
+ (0x1d6c2, 0x1d6da),
618
+ (0x1d6dc, 0x1d6fa),
619
+ (0x1d6fc, 0x1d714),
620
+ (0x1d716, 0x1d734),
621
+ (0x1d736, 0x1d74e),
622
+ (0x1d750, 0x1d76e),
623
+ (0x1d770, 0x1d788),
624
+ (0x1d78a, 0x1d7a8),
625
+ (0x1d7aa, 0x1d7c2),
626
+ (0x1d7c4, 0x1d7cb),
627
+ (0x1df00, 0x1df1e),
628
+ (0x1df25, 0x1df2a),
629
+ (0x1e030, 0x1e06d),
630
+ (0x1e100, 0x1e12c),
631
+ (0x1e137, 0x1e13d),
632
+ (0x1e14e, 0x1e14e),
633
+ (0x1e290, 0x1e2ad),
634
+ (0x1e2c0, 0x1e2eb),
635
+ (0x1e4d0, 0x1e4eb),
636
+ (0x1e5d0, 0x1e5ed),
637
+ (0x1e5f0, 0x1e5f0),
638
+ (0x1e7e0, 0x1e7e6),
639
+ (0x1e7e8, 0x1e7eb),
640
+ (0x1e7ed, 0x1e7ee),
641
+ (0x1e7f0, 0x1e7fe),
642
+ (0x1e800, 0x1e8c4),
643
+ (0x1e900, 0x1e943),
644
+ (0x1e94b, 0x1e94b),
645
+ (0x1ee00, 0x1ee03),
646
+ (0x1ee05, 0x1ee1f),
647
+ (0x1ee21, 0x1ee22),
648
+ (0x1ee24, 0x1ee24),
649
+ (0x1ee27, 0x1ee27),
650
+ (0x1ee29, 0x1ee32),
651
+ (0x1ee34, 0x1ee37),
652
+ (0x1ee39, 0x1ee39),
653
+ (0x1ee3b, 0x1ee3b),
654
+ (0x1ee42, 0x1ee42),
655
+ (0x1ee47, 0x1ee47),
656
+ (0x1ee49, 0x1ee49),
657
+ (0x1ee4b, 0x1ee4b),
658
+ (0x1ee4d, 0x1ee4f),
659
+ (0x1ee51, 0x1ee52),
660
+ (0x1ee54, 0x1ee54),
661
+ (0x1ee57, 0x1ee57),
662
+ (0x1ee59, 0x1ee59),
663
+ (0x1ee5b, 0x1ee5b),
664
+ (0x1ee5d, 0x1ee5d),
665
+ (0x1ee5f, 0x1ee5f),
666
+ (0x1ee61, 0x1ee62),
667
+ (0x1ee64, 0x1ee64),
668
+ (0x1ee67, 0x1ee6a),
669
+ (0x1ee6c, 0x1ee72),
670
+ (0x1ee74, 0x1ee77),
671
+ (0x1ee79, 0x1ee7c),
672
+ (0x1ee7e, 0x1ee7e),
673
+ (0x1ee80, 0x1ee89),
674
+ (0x1ee8b, 0x1ee9b),
675
+ (0x1eea1, 0x1eea3),
676
+ (0x1eea5, 0x1eea9),
677
+ (0x1eeab, 0x1eebb),
678
+ (0x20000, 0x2a6df),
679
+ (0x2a700, 0x2b739),
680
+ (0x2b740, 0x2b81d),
681
+ (0x2b820, 0x2cea1),
682
+ (0x2ceb0, 0x2ebe0),
683
+ (0x2ebf0, 0x2ee5d),
684
+ (0x2f800, 0x2fa1d),
685
+ (0x30000, 0x3134a),
686
+ (0x31350, 0x323af),
687
+ )
688
+ N_RANGES = (
689
+ (0x30, 0x39),
690
+ (0xb2, 0xb3),
691
+ (0xb9, 0xb9),
692
+ (0xbc, 0xbe),
693
+ (0x660, 0x669),
694
+ (0x6f0, 0x6f9),
695
+ (0x7c0, 0x7c9),
696
+ (0x966, 0x96f),
697
+ (0x9e6, 0x9ef),
698
+ (0x9f4, 0x9f9),
699
+ (0xa66, 0xa6f),
700
+ (0xae6, 0xaef),
701
+ (0xb66, 0xb6f),
702
+ (0xb72, 0xb77),
703
+ (0xbe6, 0xbf2),
704
+ (0xc66, 0xc6f),
705
+ (0xc78, 0xc7e),
706
+ (0xce6, 0xcef),
707
+ (0xd58, 0xd5e),
708
+ (0xd66, 0xd78),
709
+ (0xde6, 0xdef),
710
+ (0xe50, 0xe59),
711
+ (0xed0, 0xed9),
712
+ (0xf20, 0xf33),
713
+ (0x1040, 0x1049),
714
+ (0x1090, 0x1099),
715
+ (0x1369, 0x137c),
716
+ (0x16ee, 0x16f0),
717
+ (0x17e0, 0x17e9),
718
+ (0x17f0, 0x17f9),
719
+ (0x1810, 0x1819),
720
+ (0x1946, 0x194f),
721
+ (0x19d0, 0x19da),
722
+ (0x1a80, 0x1a89),
723
+ (0x1a90, 0x1a99),
724
+ (0x1b50, 0x1b59),
725
+ (0x1bb0, 0x1bb9),
726
+ (0x1c40, 0x1c49),
727
+ (0x1c50, 0x1c59),
728
+ (0x2070, 0x2070),
729
+ (0x2074, 0x2079),
730
+ (0x2080, 0x2089),
731
+ (0x2150, 0x2182),
732
+ (0x2185, 0x2189),
733
+ (0x2460, 0x249b),
734
+ (0x24ea, 0x24ff),
735
+ (0x2776, 0x2793),
736
+ (0x2cfd, 0x2cfd),
737
+ (0x3007, 0x3007),
738
+ (0x3021, 0x3029),
739
+ (0x3038, 0x303a),
740
+ (0x3192, 0x3195),
741
+ (0x3220, 0x3229),
742
+ (0x3248, 0x324f),
743
+ (0x3251, 0x325f),
744
+ (0x3280, 0x3289),
745
+ (0x32b1, 0x32bf),
746
+ (0xa620, 0xa629),
747
+ (0xa6e6, 0xa6ef),
748
+ (0xa830, 0xa835),
749
+ (0xa8d0, 0xa8d9),
750
+ (0xa900, 0xa909),
751
+ (0xa9d0, 0xa9d9),
752
+ (0xa9f0, 0xa9f9),
753
+ (0xaa50, 0xaa59),
754
+ (0xabf0, 0xabf9),
755
+ (0xff10, 0xff19),
756
+ (0x10107, 0x10133),
757
+ (0x10140, 0x10178),
758
+ (0x1018a, 0x1018b),
759
+ (0x102e1, 0x102fb),
760
+ (0x10320, 0x10323),
761
+ (0x10341, 0x10341),
762
+ (0x1034a, 0x1034a),
763
+ (0x103d1, 0x103d5),
764
+ (0x104a0, 0x104a9),
765
+ (0x10858, 0x1085f),
766
+ (0x10879, 0x1087f),
767
+ (0x108a7, 0x108af),
768
+ (0x108fb, 0x108ff),
769
+ (0x10916, 0x1091b),
770
+ (0x109bc, 0x109bd),
771
+ (0x109c0, 0x109cf),
772
+ (0x109d2, 0x109ff),
773
+ (0x10a40, 0x10a48),
774
+ (0x10a7d, 0x10a7e),
775
+ (0x10a9d, 0x10a9f),
776
+ (0x10aeb, 0x10aef),
777
+ (0x10b58, 0x10b5f),
778
+ (0x10b78, 0x10b7f),
779
+ (0x10ba9, 0x10baf),
780
+ (0x10cfa, 0x10cff),
781
+ (0x10d30, 0x10d39),
782
+ (0x10d40, 0x10d49),
783
+ (0x10e60, 0x10e7e),
784
+ (0x10f1d, 0x10f26),
785
+ (0x10f51, 0x10f54),
786
+ (0x10fc5, 0x10fcb),
787
+ (0x11052, 0x1106f),
788
+ (0x110f0, 0x110f9),
789
+ (0x11136, 0x1113f),
790
+ (0x111d0, 0x111d9),
791
+ (0x111e1, 0x111f4),
792
+ (0x112f0, 0x112f9),
793
+ (0x11450, 0x11459),
794
+ (0x114d0, 0x114d9),
795
+ (0x11650, 0x11659),
796
+ (0x116c0, 0x116c9),
797
+ (0x116d0, 0x116e3),
798
+ (0x11730, 0x1173b),
799
+ (0x118e0, 0x118f2),
800
+ (0x11950, 0x11959),
801
+ (0x11bf0, 0x11bf9),
802
+ (0x11c50, 0x11c6c),
803
+ (0x11d50, 0x11d59),
804
+ (0x11da0, 0x11da9),
805
+ (0x11f50, 0x11f59),
806
+ (0x11fc0, 0x11fd4),
807
+ (0x12400, 0x1246e),
808
+ (0x16130, 0x16139),
809
+ (0x16a60, 0x16a69),
810
+ (0x16ac0, 0x16ac9),
811
+ (0x16b50, 0x16b59),
812
+ (0x16b5b, 0x16b61),
813
+ (0x16d70, 0x16d79),
814
+ (0x16e80, 0x16e96),
815
+ (0x1ccf0, 0x1ccf9),
816
+ (0x1d2c0, 0x1d2d3),
817
+ (0x1d2e0, 0x1d2f3),
818
+ (0x1d360, 0x1d378),
819
+ (0x1d7ce, 0x1d7ff),
820
+ (0x1e140, 0x1e149),
821
+ (0x1e2f0, 0x1e2f9),
822
+ (0x1e4f0, 0x1e4f9),
823
+ (0x1e5f1, 0x1e5fa),
824
+ (0x1e8c7, 0x1e8cf),
825
+ (0x1e950, 0x1e959),
826
+ (0x1ec71, 0x1ecab),
827
+ (0x1ecad, 0x1ecaf),
828
+ (0x1ecb1, 0x1ecb4),
829
+ (0x1ed01, 0x1ed2d),
830
+ (0x1ed2f, 0x1ed3d),
831
+ (0x1f100, 0x1f10c),
832
+ (0x1fbf0, 0x1fbf9),
833
+ )
834
+
835
+ #: Code points the Rust NFC treats as combining-class 0 although unicodedata says otherwise,
836
+ #: plus second members of compositions the Rust side lacks. NFC is applied around them.
837
+ NFC_BARRIERS = (
838
+ (0x7fd, 0x7fd),
839
+ (0x898, 0x89f),
840
+ (0x8ca, 0x8d3),
841
+ (0x9fe, 0x9fe),
842
+ (0xc3c, 0xc3c),
843
+ (0xd3b, 0xd3c),
844
+ (0xeba, 0xeba),
845
+ (0x1715, 0x1715),
846
+ (0x1abf, 0x1ace),
847
+ (0x1df6, 0x1dfa),
848
+ (0xa82c, 0xa82c),
849
+ (0x10d24, 0x10d27),
850
+ (0x10eab, 0x10eac),
851
+ (0x10efd, 0x10eff),
852
+ (0x10f46, 0x10f50),
853
+ (0x10f82, 0x10f85),
854
+ (0x11070, 0x11070),
855
+ (0x1133b, 0x1133b),
856
+ (0x1145e, 0x1145e),
857
+ (0x11839, 0x1183a),
858
+ (0x11930, 0x11930),
859
+ (0x1193d, 0x1193e),
860
+ (0x11943, 0x11943),
861
+ (0x119e0, 0x119e0),
862
+ (0x11a34, 0x11a34),
863
+ (0x11a47, 0x11a47),
864
+ (0x11a99, 0x11a99),
865
+ (0x11d42, 0x11d42),
866
+ (0x11d44, 0x11d45),
867
+ (0x11d97, 0x11d97),
868
+ (0x11f41, 0x11f42),
869
+ (0x16ff0, 0x16ff1),
870
+ (0x1e08f, 0x1e08f),
871
+ (0x1e130, 0x1e136),
872
+ (0x1e2ae, 0x1e2ae),
873
+ (0x1e2ec, 0x1e2ef),
874
+ (0x1e4ec, 0x1e4ef),
875
+ )
code/align/tokenizer/bytelevel.py ADDED
@@ -0,0 +1,693 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pure-Python (stdlib only) byte-level BPE tokenizer for Qwen2/Qwen3 (Goedel-Prover-V2-8B).
2
+
3
+ Why this exists. P2 (LoRA SFT on Goedel-Prover-V2-8B's own verified samples) needs
4
+ token ids for prompts and completions. Before this module the only way to get
5
+ them for a Qwen3 vocabulary was `transformers.AutoTokenizer`, i.e. the isolated
6
+ vLLM venv: the training process would have had to import (or shell out to) a
7
+ second Python environment just to turn text into ints, and a version skew
8
+ between that venv and the one that trained would silently change the ids
9
+ (measured elsewhere in this repo: transformers 5.x REBUILDS pipelines from the
10
+ config rather than from tokenizer.json). This module reads tokenizer.json
11
+ directly and is certified byte-identical (ids AND decode) against the HF tokenizer
12
+ on a frozen oracle corpus by tests/test_tokenizer_bytelevel.py.
13
+
14
+ Pipeline (encode), exactly what tokenizer.json declares and nothing else:
15
+
16
+ text
17
+ -> split on ADDED tokens (<|im_start|>, <think>, ...), leftmost-longest,
18
+ on the RAW text (all are normalized=false)
19
+ -> per remaining segment: NFC normalizer
20
+ -> Split(Isolated) with the Qwen2 regex
21
+ -> ByteLevel: UTF-8 bytes of each piece mapped through GPT-2 bytes_to_unicode
22
+ -> BPE (rank-ordered merges) per piece
23
+ -> post-processor ByteLevel: adds nothing (no BOS/EOS)
24
+
25
+ Decode: ids -> pieces (optionally drop special=true added tokens) -> join ->
26
+ byte-decode -> UTF-8 with U+FFFD replacement.
27
+
28
+ The three places a reimplementation gets this wrong -- each has a mutant in the
29
+ test file that must make parity FAIL:
30
+
31
+ 1. NFC per SEGMENT, not per string. Added tokens are carved out first, so a
32
+ combining mark right after "<|im_end|>" has nothing to compose with. NFC on the
33
+ whole string would compose across the special token. Dropping NFC entirely
34
+ changes the ids of every decomposed-form string (e.g. "e" + U+0301).
35
+ 2. The regex has `\\p{N}` (ONE digit per piece, so "2024" is four pieces) and
36
+ `\\p{L}+` / `[^\\r\\n\\p{L}\\p{N}]?` (a single non-letter prefix glues onto a word).
37
+ Python `re` has no \\p{..}: the classes are explicit ranges (_class_body)
38
+ built from the frozen Rust-side tables in _qwen2_unicode.py. A "\\d+" or "\\w+" shortcut is
39
+ not equivalent (superscripts are \\p{N} but not \\d; "_" is \\w but not \\p{L}).
40
+ 3. BPE tie-break: lowest merge RANK wins, ties go to the LEFTMOST pair. Using
41
+ vocab id (or longest-match) instead of rank changes tokens inside long
42
+ identifiers and whitespace runs, silently.
43
+
44
+ Unicode tables (measured, pinned in tests): the Rust tokenizer's Unicode tables are not
45
+ those of any one Python. \\p{L}/\\p{N} are NEWER than CPython 3.12's unicodedata (5004
46
+ Unicode-16 code points are letters/digits there and unassigned here) and the NFC
47
+ normalizer is OLDER (108 combining marks of Unicode 10-15 keep combining class 0, and
48
+ U+11935 U+11930 does not compose). A unicodedata-derived translation therefore split
49
+ "X's" differently for those code points and canonically reordered marks HF leaves alone.
50
+ Both are now FROZEN from the real tokenizer (_qwen2_unicode.py, generated by
51
+ tests/data/make_qwen2_unicode_tables.py), so the result does not depend on the Python
52
+ version for the regex classes; NFC still uses unicodedata for everything else and is pinned
53
+ to the certified unicodedata version by a test.
54
+
55
+ The loader VALIDATES the whole pipeline configuration and raises
56
+ NotImplementedError on anything not implemented (a different regex, a missing
57
+ NFC, ignore_merges, normalized added tokens, clean_up_tokenization_spaces,
58
+ an unknown chat template ...). A different tokenizer.json must fail loudly, not
59
+ silently encode wrong ids.
60
+ """
61
+ from __future__ import annotations
62
+
63
+ import hashlib
64
+ import heapq
65
+ import json
66
+ import os
67
+ import re
68
+ import unicodedata
69
+ from typing import Iterable, Optional, Sequence, Union
70
+
71
+ from .core import _TOKENIZER_FILES, _as_list, _content, _expect, _resolve_dir
72
+ from ._qwen2_unicode import L_RANGES, N_RANGES, NFC_BARRIERS
73
+
74
+ __all__ = ["ByteLevelTokenizer", "QWEN2_PRETOKENIZE_REGEX", "bytes_to_unicode", "render_qwen3_chat"]
75
+
76
+ #: The pre_tokenizer Split pattern the Qwen2/Qwen2.5/Qwen3 tokenizer.json files ship.
77
+ #: The loader requires tokenizer.json to carry EXACTLY this string: the Python
78
+ #: translation below (_build_pattern) is only valid for it.
79
+ QWEN2_PRETOKENIZE_REGEX = (
80
+ r"(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?\p{L}+|\p{N}| ?[^\s\p{L}\p{N}]+[\r\n]*"
81
+ r"|\s*[\r\n]+|\s+(?!\S)|\s+"
82
+ )
83
+
84
+ #: sha256 of the Qwen3 chat_template string that render_qwen3_chat reimplements
85
+ #: (the subset without tools). A config carrying any other template makes
86
+ #: apply_chat_template raise instead of rendering with the wrong one.
87
+ QWEN3_CHAT_TEMPLATE_SHA256 = "a55ee1b1660128b7098723e0abcd92caa0788061051c62d51cbe87d9cf1974d8"
88
+
89
+ _CACHE_MAX_ENTRIES = 200_000
90
+
91
+ # Unicode White_Space property (what the Rust regex `\s` means). NOT Python's
92
+ # str.isspace(): that also accepts U+001C..U+001F, which are not White_Space.
93
+ _WHITE_SPACE = (
94
+ (0x09, 0x0D), (0x20, 0x20), (0x85, 0x85), (0xA0, 0xA0), (0x1680, 0x1680),
95
+ (0x2000, 0x200A), (0x2028, 0x2029), (0x202F, 0x202F), (0x205F, 0x205F), (0x3000, 0x3000),
96
+ )
97
+
98
+
99
+ def bytes_to_unicode() -> dict[int, str]:
100
+ """GPT-2 byte -> printable-char map (the ByteLevel alphabet): printable Latin-1
101
+ bytes map to themselves, the other 68 bytes to U+0100 and up, in byte order."""
102
+ keep = list(range(0x21, 0x7F)) + list(range(0xA1, 0xAD)) + list(range(0xAE, 0x100))
103
+ out: dict[int, str] = {}
104
+ n = 0
105
+ for b in range(256):
106
+ if b in keep:
107
+ out[b] = chr(b)
108
+ else:
109
+ out[b] = chr(256 + n)
110
+ n += 1
111
+ return out
112
+
113
+
114
+ def _ranges(pred) -> list[tuple[int, int]]:
115
+ out: list[tuple[int, int]] = []
116
+ start = None
117
+ for cp in range(0x110000):
118
+ ok = pred(cp)
119
+ if ok and start is None:
120
+ start = cp
121
+ elif not ok and start is not None:
122
+ out.append((start, cp - 1))
123
+ start = None
124
+ if start is not None:
125
+ out.append((start, 0x10FFFF))
126
+ return out
127
+
128
+
129
+ def _class_body(ranges: Iterable[tuple[int, int]]) -> str:
130
+ """Body of a regex character class (without the brackets) for explicit ranges."""
131
+ parts = []
132
+ for a, b in ranges:
133
+ parts.append(f"\\U{a:08x}" if a == b else f"\\U{a:08x}-\\U{b:08x}")
134
+ return "".join(parts)
135
+
136
+
137
+ _PATTERN: Optional[re.Pattern] = None
138
+ _BARRIER_RE: Optional[re.Pattern] = None
139
+
140
+
141
+ def _build_pattern() -> re.Pattern:
142
+ """Python-`re` translation of QWEN2_PRETOKENIZE_REGEX. \\p{L} / \\p{N} are the FROZEN
143
+ Rust-side tables (L_RANGES / N_RANGES), \\s = White_Space. The contraction group is
144
+ spelled out instead of (?i:...): Python's IGNORECASE is Unicode-aware and would also
145
+ accept U+017F (long s) for 's -- the oracle test pins what the reference does for it."""
146
+ global _PATTERN
147
+ if _PATTERN is not None:
148
+ return _PATTERN
149
+ L = _class_body(L_RANGES)
150
+ N = _class_body(N_RANGES)
151
+ S = _class_body(_WHITE_SPACE)
152
+ pat = (
153
+ r"'(?:[sS]|[tT]|[rR][eE]|[vV][eE]|[mM]|[lL][lL]|[dD])"
154
+ rf"|[^\r\n{L}{N}]?[{L}]+"
155
+ rf"|[{N}]"
156
+ rf"| ?[^{S}{L}{N}]+[\r\n]*"
157
+ rf"|[{S}]*[\r\n]+"
158
+ rf"|[{S}]+(?![^{S}])"
159
+ rf"|[{S}]+"
160
+ )
161
+ _PATTERN = re.compile(pat)
162
+ return _PATTERN
163
+
164
+
165
+ def _barrier_re() -> re.Pattern:
166
+ global _BARRIER_RE
167
+ if _BARRIER_RE is None:
168
+ _BARRIER_RE = re.compile("([" + _class_body(NFC_BARRIERS) + "])")
169
+ return _BARRIER_RE
170
+
171
+
172
+ class ByteLevelTokenizer:
173
+ """Qwen2/Qwen3 byte-level BPE, API-compatible with align.tokenizer.Tokenizer
174
+ for the calls this repo makes (encode / __call__ / decode / ids / save).
175
+
176
+ Build with `ByteLevelTokenizer.from_pretrained(dir)` or `load_tokenizer(dir)`.
177
+ """
178
+
179
+ def __init__(
180
+ self,
181
+ tokenizer_json: dict,
182
+ tokenizer_config: Optional[dict] = None,
183
+ special_tokens_map: Optional[dict] = None,
184
+ *,
185
+ files: Optional[dict] = None,
186
+ ):
187
+ self._files: dict[str, bytes] = dict(files or {})
188
+ self._config = dict(tokenizer_config or {})
189
+ self._special_map = dict(special_tokens_map or {})
190
+ self._tokenizer_json = tokenizer_json
191
+ self._validate_pipeline(tokenizer_json, self._config)
192
+
193
+ model = tokenizer_json["model"]
194
+ base_vocab: dict[str, int] = dict(model["vocab"])
195
+ self._base_vocab = base_vocab
196
+ vocab = dict(base_vocab)
197
+
198
+ self._added: dict[str, int] = {}
199
+ self._special_contents: set[str] = set()
200
+ for t in tokenizer_json.get("added_tokens", []):
201
+ _expect(
202
+ not (t.get("lstrip") or t.get("rstrip") or t.get("single_word")),
203
+ f"added token {t['content']!r} with lstrip/rstrip/single_word",
204
+ )
205
+ _expect(
206
+ not t.get("normalized"),
207
+ f"added token {t['content']!r} with normalized=true (NFC-before-match not implemented)",
208
+ )
209
+ vocab[t["content"]] = int(t["id"])
210
+ self._added[t["content"]] = int(t["id"])
211
+ if t.get("special"):
212
+ self._special_contents.add(t["content"])
213
+ self._vocab = vocab
214
+ size = max(vocab.values()) + 1
215
+ self._id_to_token: list[Optional[str]] = [None] * size
216
+ for tok, i in vocab.items():
217
+ self._id_to_token[i] = tok
218
+
219
+ self._b2u = bytes_to_unicode()
220
+ self._u2b = {c: b for b, c in self._b2u.items()}
221
+ missing = [b for b, c in self._b2u.items() if c not in base_vocab]
222
+ _expect(not missing, f"vocab lacks ByteLevel alphabet symbols for bytes {missing[:5]}")
223
+
224
+ self._ranks: dict[tuple[str, str], int] = {}
225
+ for rank, m in enumerate(model["merges"]):
226
+ a, b = (m[0], m[1]) if isinstance(m, (list, tuple)) else m.split(" ")
227
+ if (a + b) not in base_vocab or a not in base_vocab or b not in base_vocab:
228
+ raise ValueError(f"merge #{rank} {a!r}+{b!r} references a token outside the vocab")
229
+ self._ranks[(a, b)] = rank # last duplicate wins (Rust HashMap semantics)
230
+
231
+ if self._added:
232
+ alts = sorted(self._added, key=len, reverse=True)
233
+ self._added_re: Optional[re.Pattern] = re.compile("|".join(re.escape(a) for a in alts))
234
+ else:
235
+ self._added_re = None
236
+ self._cache: dict[str, tuple[int, ...]] = {}
237
+ self._id_bytes: list[Optional[bytes]] = [None] * size
238
+
239
+ def pick(name: str, default: Optional[str] = None) -> Optional[str]:
240
+ v = _content(self._config.get(f"{name}_token"))
241
+ if v is None:
242
+ v = _content(self._special_map.get(f"{name}_token"))
243
+ if v is None:
244
+ v = default
245
+ return v if (v is None or v in self._vocab) else None
246
+
247
+ self.bos_token = pick("bos")
248
+ self.eos_token = pick("eos")
249
+ self.unk_token = pick("unk")
250
+ self.pad_token = pick("pad")
251
+
252
+ # ------------------------------------------------------------------ config
253
+
254
+ @staticmethod
255
+ def _validate_pipeline(tj: dict, cfg: dict) -> None:
256
+ model = tj["model"]
257
+ _expect(model.get("type") == "BPE", f"model.type={model.get('type')!r}")
258
+ _expect(not model.get("dropout"), "BPE dropout")
259
+ _expect(not model.get("continuing_subword_prefix"), "continuing_subword_prefix")
260
+ _expect(not model.get("end_of_word_suffix"), "end_of_word_suffix")
261
+ _expect(not model.get("byte_fallback"), "byte_fallback")
262
+ _expect(not model.get("ignore_merges"), "ignore_merges")
263
+ _expect(not model.get("fuse_unk"), "fuse_unk")
264
+ _expect(model.get("unk_token") is None, "an unk_token")
265
+ _expect(tj.get("normalizer") == {"type": "NFC"}, f"normalizer {tj.get('normalizer')!r} (only NFC)")
266
+ pt = tj.get("pre_tokenizer") or {}
267
+ _expect(pt.get("type") == "Sequence", f"pre_tokenizer {pt.get('type')!r}")
268
+ seq = pt.get("pretokenizers") or []
269
+ _expect(len(seq) == 2, "pre_tokenizer Sequence other than [Split, ByteLevel]")
270
+ sp, bl = seq
271
+ _expect(sp.get("type") == "Split", f"pre_tokenizer[0] {sp.get('type')!r}")
272
+ _expect(
273
+ (sp.get("pattern") or {}).get("Regex") == QWEN2_PRETOKENIZE_REGEX,
274
+ "a Split regex other than the Qwen2 one",
275
+ )
276
+ _expect(sp.get("behavior") == "Isolated", f"Split behavior {sp.get('behavior')!r}")
277
+ _expect(sp.get("invert") is False, "Split invert=true")
278
+ _expect(bl.get("type") == "ByteLevel", f"pre_tokenizer[1] {bl.get('type')!r}")
279
+ _expect(not bl.get("add_prefix_space"), "ByteLevel add_prefix_space=true")
280
+ _expect(bl.get("use_regex") is False, "ByteLevel use_regex=true (GPT-2 regex on top)")
281
+ dec = tj.get("decoder") or {}
282
+ _expect(dec.get("type") == "ByteLevel", f"decoder {dec.get('type')!r}")
283
+ pp = tj.get("post_processor")
284
+ if pp is not None:
285
+ _expect(
286
+ pp.get("type") == "ByteLevel" and not pp.get("add_prefix_space") and not pp.get("trim_offsets"),
287
+ f"post_processor {pp.get('type')!r} other than a no-op ByteLevel",
288
+ )
289
+ # tokenizer.json can carry truncation/padding; the Rust encoder applies them
290
+ # (silently clipping every encode). Neither is implemented.
291
+ _expect(tj.get("truncation") is None, "a tokenizer.json truncation setting")
292
+ _expect(tj.get("padding") is None, "a tokenizer.json padding setting")
293
+ ByteLevelTokenizer._check_added_tokens_decoder(tj, cfg)
294
+ # transformers applies these from the CONFIG on top of tokenizer.json.
295
+ _expect(not cfg.get("add_prefix_space"), "tokenizer_config.json add_prefix_space=true")
296
+ _expect(not cfg.get("add_bos_token"), "tokenizer_config.json add_bos_token=true")
297
+ _expect(
298
+ not cfg.get("clean_up_tokenization_spaces"),
299
+ "tokenizer_config.json clean_up_tokenization_spaces=true",
300
+ )
301
+ _expect(not cfg.get("split_special_tokens"), "tokenizer_config.json split_special_tokens=true")
302
+ _expect(cfg.get("errors", "replace") == "replace", f"decode errors={cfg.get('errors')!r}")
303
+
304
+ @staticmethod
305
+ def _check_added_tokens_decoder(tj: dict, cfg: dict) -> None:
306
+ """tokenizer_config.json `added_tokens_decoder` must AGREE with tokenizer.json's
307
+ added_tokens (same ids, contents and lstrip/rstrip/single_word/normalized/special).
308
+
309
+ Incident this prevents: transformers builds the added-token matcher from the
310
+ CONFIG as well as tokenizer.json. Measured (transformers 5.19.0, Goedel snapshot
311
+ with only `<think>.lstrip=true` flipped in tokenizer_config.json): HF encodes
312
+ "a <think> b" as [64, 151667, 293] (the space before the token is eaten) while a
313
+ reader of tokenizer.json alone gives [64, 220, 151667, 293]. A config/json pair
314
+ that disagrees must not load silently."""
315
+ adt = cfg.get("added_tokens_decoder")
316
+ if adt is None:
317
+ return
318
+ declared = {int(k): v for k, v in adt.items()}
319
+ in_json = {int(t["id"]): t for t in tj.get("added_tokens", [])}
320
+ _expect(
321
+ set(declared) == set(in_json),
322
+ "tokenizer_config.json added_tokens_decoder ids differ from tokenizer.json added_tokens",
323
+ )
324
+ for i, t in in_json.items():
325
+ c = declared[i]
326
+ _expect(c.get("content") == t["content"], f"added token {i} content differs between config and json")
327
+ for flag in ("special", "normalized", "lstrip", "rstrip", "single_word"):
328
+ _expect(
329
+ bool(c.get(flag)) == bool(t.get(flag)),
330
+ f"added token {t['content']!r}: {flag} differs between tokenizer_config.json and tokenizer.json",
331
+ )
332
+
333
+ # ------------------------------------------------------------- vocabulary
334
+
335
+ @property
336
+ def bos_token_id(self) -> Optional[int]:
337
+ return None if self.bos_token is None else self._vocab[self.bos_token]
338
+
339
+ @property
340
+ def eos_token_id(self) -> Optional[int]:
341
+ return None if self.eos_token is None else self._vocab[self.eos_token]
342
+
343
+ @property
344
+ def pad_token_id(self) -> Optional[int]:
345
+ return None if self.pad_token is None else self._vocab[self.pad_token]
346
+
347
+ @property
348
+ def unk_token_id(self) -> Optional[int]:
349
+ return None
350
+
351
+ @property
352
+ def vocab_size(self) -> int:
353
+ """BPE base vocabulary only (HF semantics): 151643 for Qwen3; len(tok) adds
354
+ the added tokens. The model's embedding rows (151936) are padding on top."""
355
+ return len(self._base_vocab)
356
+
357
+ @property
358
+ def all_special_tokens(self) -> list[str]:
359
+ return sorted(self._special_contents, key=lambda t: self._vocab[t])
360
+
361
+ @property
362
+ def all_special_ids(self) -> list[int]:
363
+ return [self._vocab[t] for t in self.all_special_tokens]
364
+
365
+ def get_vocab(self) -> dict[str, int]:
366
+ return dict(self._vocab)
367
+
368
+ def get_added_vocab(self) -> dict[str, int]:
369
+ return dict(self._added)
370
+
371
+ def __len__(self) -> int:
372
+ return len(self._vocab)
373
+
374
+ def __repr__(self) -> str:
375
+ return f"ByteLevelTokenizer(n={len(self)}, merges={len(self._ranks)}, added={len(self._added)})"
376
+
377
+ def _piece(self, i: int) -> Optional[str]:
378
+ if i < 0:
379
+ raise ValueError(f"negative token id {i}: a label-mask value leaked into a token-id path")
380
+ return self._id_to_token[i] if i < len(self._id_to_token) else None
381
+
382
+ def convert_ids_to_tokens(self, ids: Union[int, Iterable[int]]):
383
+ if isinstance(ids, int):
384
+ return self._piece(ids)
385
+ return [self._piece(int(i)) for i in _as_list(ids)]
386
+
387
+ def convert_tokens_to_ids(self, tokens: Union[str, Iterable[str]]):
388
+ if isinstance(tokens, str):
389
+ return self._vocab.get(tokens)
390
+ return [self._vocab.get(t) for t in tokens]
391
+
392
+ # ------------------------------------------------------------------ encode
393
+
394
+ def __call__(self, text: Union[str, Sequence[str]], add_special_tokens: bool = True) -> dict:
395
+ if isinstance(text, str):
396
+ return {"input_ids": self.encode(text, add_special_tokens=add_special_tokens)}
397
+ return {"input_ids": [self.encode(t, add_special_tokens=add_special_tokens) for t in text]}
398
+
399
+ def encode(self, text: str, add_special_tokens: bool = True) -> list[int]:
400
+ """`add_special_tokens` is accepted for API parity; Qwen3 adds none."""
401
+ if not isinstance(text, str):
402
+ raise TypeError(f"encode() takes str, got {type(text).__name__}")
403
+ try:
404
+ text.encode("utf-8")
405
+ except UnicodeEncodeError as e:
406
+ raise ValueError(f"text is not valid Unicode (lone surrogate at index {e.start})") from e
407
+ ids: list[int] = []
408
+ for is_added, seg in self._split_added(text):
409
+ if is_added:
410
+ ids.append(self._added[seg])
411
+ else:
412
+ ids.extend(self._encode_segment(self._normalize(seg)))
413
+ return ids
414
+
415
+ @staticmethod
416
+ def _normalize(seg: str) -> str:
417
+ """NFC as the Rust normalizer computes it. ASCII is NFC-invariant. A code point in
418
+ NFC_BARRIERS is a starter there (never reordered, never composed) but not in
419
+ Python's newer table, so the text is normalized AROUND it, never across it.
420
+
421
+ Incident this prevents: "\\u0301\\U00011d45" (acute, then Masaram Gondi virama) is
422
+ kept in that order by HF, but unicodedata.normalize moves the virama (ccc 9) in
423
+ front of the acute (ccc 230): same text, different ids."""
424
+ if seg.isascii():
425
+ return seg
426
+ rx = _barrier_re()
427
+ if rx.search(seg) is None:
428
+ return unicodedata.normalize("NFC", seg)
429
+ parts = rx.split(seg) # [text, barrier, text, barrier, ..., text]
430
+ return "".join(p if k & 1 else unicodedata.normalize("NFC", p) for k, p in enumerate(parts))
431
+
432
+ def _split_added(self, text: str):
433
+ if self._added_re is None or not text:
434
+ if text:
435
+ yield False, text
436
+ return
437
+ pos = 0
438
+ for m in self._added_re.finditer(text):
439
+ if m.start() > pos:
440
+ yield False, text[pos : m.start()]
441
+ yield True, m.group(0)
442
+ pos = m.end()
443
+ if pos < len(text):
444
+ yield False, text[pos:]
445
+
446
+ def _pretokenize(self, seg: str):
447
+ """Split(Isolated): matches AND the gaps between them become pieces (the
448
+ regex's last alternatives cover every character, so gaps cannot occur for
449
+ the Qwen2 pattern; kept so a pattern change cannot silently drop text)."""
450
+ pos = 0
451
+ for m in _build_pattern().finditer(seg):
452
+ if m.start() > pos:
453
+ yield seg[pos : m.start()]
454
+ yield m.group(0)
455
+ pos = m.end()
456
+ if pos < len(seg):
457
+ yield seg[pos:]
458
+
459
+ def _encode_segment(self, seg: str) -> list[int]:
460
+ out: list[int] = []
461
+ cache = self._cache
462
+ for piece in self._pretokenize(seg):
463
+ r = cache.get(piece)
464
+ if r is None:
465
+ r = self._bpe(piece)
466
+ if len(cache) >= _CACHE_MAX_ENTRIES:
467
+ cache.clear()
468
+ cache[piece] = r
469
+ out.extend(r)
470
+ return out
471
+
472
+ def _bpe(self, piece: str) -> tuple[int, ...]:
473
+ b2u = self._b2u
474
+ syms = [b2u[b] for b in piece.encode("utf-8")]
475
+ return self._merge(syms)
476
+
477
+ def _merge(self, syms: list[str]) -> tuple[int, ...]:
478
+ """Rank-ordered pair merging (linked list + lazy-deletion min-heap, O(n log n)
479
+ so a multi-thousand-char whitespace run is not quadratic). Tie-break:
480
+ lowest merge rank, then leftmost position."""
481
+ n = len(syms)
482
+ vocab = self._base_vocab
483
+ if n < 2:
484
+ return tuple(vocab[s] for s in syms)
485
+ ranks = self._ranks
486
+ nxt = list(range(1, n + 1))
487
+ nxt[-1] = -1
488
+ prv = list(range(-1, n - 1))
489
+ heap: list[tuple[int, int, str, str]] = []
490
+ for i in range(n - 1):
491
+ r = ranks.get((syms[i], syms[i + 1]))
492
+ if r is not None:
493
+ heap.append((r, i, syms[i], syms[i + 1]))
494
+ heapq.heapify(heap)
495
+ pop, push = heapq.heappop, heapq.heappush
496
+ while heap:
497
+ _, i, left, right = pop(heap)
498
+ if syms[i] != left:
499
+ continue
500
+ j = nxt[i]
501
+ if j == -1 or syms[j] != right:
502
+ continue
503
+ new = left + right
504
+ syms[i] = new
505
+ syms[j] = None # type: ignore[call-overload]
506
+ nj = nxt[j]
507
+ nxt[i] = nj
508
+ if nj != -1:
509
+ prv[nj] = i
510
+ p = prv[i]
511
+ if p != -1:
512
+ r = ranks.get((syms[p], new))
513
+ if r is not None:
514
+ push(heap, (r, p, syms[p], new))
515
+ if nj != -1:
516
+ r = ranks.get((new, syms[nj]))
517
+ if r is not None:
518
+ push(heap, (r, i, new, syms[nj]))
519
+ return tuple(vocab[s] for s in syms if s is not None)
520
+
521
+ # ------------------------------------------------------------------ decode
522
+
523
+ def _bytes_of(self, i: int) -> Optional[bytes]:
524
+ b = self._id_bytes[i]
525
+ if b is None:
526
+ t = self._id_to_token[i]
527
+ u2b = self._u2b
528
+ # Same rule as the Rust ByteLevel decoder: a token whose every char is in
529
+ # the alphabet is byte-decoded; anything else passes through as literal text.
530
+ if all(c in u2b for c in t): # type: ignore[union-attr]
531
+ b = bytes(u2b[c] for c in t) # type: ignore[union-attr]
532
+ else:
533
+ b = t.encode("utf-8") # type: ignore[union-attr]
534
+ self._id_bytes[i] = b
535
+ return b
536
+
537
+ def decode(self, ids, skip_special_tokens: bool = False) -> str:
538
+ """Join all tokens' bytes THEN decode UTF-8 once: a multi-byte character split
539
+ across tokens decodes correctly, a truncated one yields U+FFFD (never an
540
+ exception). Ids outside the vocab are skipped (HF behaviour). With
541
+ skip_special_tokens only added tokens flagged special=true are dropped
542
+ (<think>/</think>/<tool_call> are special=false and stay)."""
543
+ special = self._special_contents
544
+ buf = bytearray()
545
+ for i in _as_list(ids):
546
+ i = int(i)
547
+ t = self._piece(i)
548
+ if t is None:
549
+ continue
550
+ if skip_special_tokens and t in special:
551
+ continue
552
+ buf += self._bytes_of(i) # type: ignore[arg-type]
553
+ return bytes(buf).decode("utf-8", errors="replace")
554
+
555
+ # ------------------------------------------------------------ chat template
556
+
557
+ def apply_chat_template(
558
+ self,
559
+ messages: Sequence[dict],
560
+ *,
561
+ tokenize: bool = False,
562
+ add_generation_prompt: bool = False,
563
+ enable_thinking: Optional[bool] = None,
564
+ tools=None,
565
+ ):
566
+ """Qwen3 chat template, the system/user/assistant subset. See render_qwen3_chat.
567
+ Raises NotImplementedError if the shipped chat_template is not the one this
568
+ implements (checked by sha256) or if tools are requested.
569
+
570
+ `tokenize=True` returns a plain list[int]. transformers 5.x returns a BatchEncoding
571
+ ({"input_ids", "attention_mask"}) there (measured, 5.19.0), so code written against
572
+ HF's tokenize=True result must index ["input_ids"] on HF but not here; callers in
573
+ this repo use tokenize=False + encode()."""
574
+ tpl = self._config.get("chat_template")
575
+ _expect(
576
+ tpl is not None and hashlib.sha256(tpl.encode("utf-8")).hexdigest() == QWEN3_CHAT_TEMPLATE_SHA256,
577
+ "a chat_template other than the Qwen3 one (sha256 mismatch)",
578
+ )
579
+ text = render_qwen3_chat(
580
+ messages, add_generation_prompt=add_generation_prompt, enable_thinking=enable_thinking, tools=tools
581
+ )
582
+ return self.encode(text, add_special_tokens=False) if tokenize else text
583
+
584
+ # -------------------------------------------------------------------- save
585
+
586
+ def save_pretrained(self, save_directory: str) -> tuple[str, ...]:
587
+ """Copy-through of the source files (atomic tmp + os.replace per file)."""
588
+ os.makedirs(save_directory, exist_ok=True)
589
+ files = dict(self._files)
590
+ if "tokenizer.json" not in files:
591
+ files["tokenizer.json"] = json.dumps(self._tokenizer_json, ensure_ascii=False).encode("utf-8")
592
+ if "tokenizer_config.json" not in files and self._config:
593
+ files["tokenizer_config.json"] = json.dumps(self._config, indent=2).encode("utf-8")
594
+ if "special_tokens_map.json" not in files and self._special_map:
595
+ files["special_tokens_map.json"] = json.dumps(self._special_map, indent=2).encode("utf-8")
596
+ written = []
597
+ for name, blob in files.items():
598
+ dst = os.path.join(save_directory, name)
599
+ tmp = dst + f".tmp.{os.getpid()}"
600
+ with open(tmp, "wb") as f:
601
+ f.write(blob)
602
+ f.flush()
603
+ os.fsync(f.fileno())
604
+ os.replace(tmp, dst)
605
+ written.append(dst)
606
+ return tuple(written)
607
+
608
+ @classmethod
609
+ def from_pretrained(cls, path: Union[str, os.PathLike]) -> "ByteLevelTokenizer":
610
+ """LOCAL ONLY: a dir holding tokenizer.json, a tokenizer.json path, or an HF
611
+ cache model dir. Never touches the network."""
612
+ d = _resolve_dir(os.fspath(path))
613
+ files: dict[str, bytes] = {}
614
+ for name in _TOKENIZER_FILES:
615
+ p = os.path.join(d, name)
616
+ if os.path.isfile(p):
617
+ with open(p, "rb") as f:
618
+ files[name] = f.read()
619
+ tj = json.loads(files["tokenizer.json"])
620
+ cfg = json.loads(files["tokenizer_config.json"]) if "tokenizer_config.json" in files else None
621
+ smap = json.loads(files["special_tokens_map.json"]) if "special_tokens_map.json" in files else None
622
+ return cls(tj, cfg, smap, files=files)
623
+
624
+
625
+ def render_qwen3_chat(
626
+ messages: Sequence[dict],
627
+ *,
628
+ add_generation_prompt: bool = False,
629
+ enable_thinking: Optional[bool] = None,
630
+ tools=None,
631
+ ) -> str:
632
+ """Render the Qwen3 chat template for system/user/assistant messages.
633
+
634
+ Incident this prevents: P2 trains on (prompt, completion) pairs whose prompt
635
+ bytes must equal what Goedel-Prover sampled under; correction rounds replay
636
+ earlier assistant turns, and the template REWRITES them (earlier turns lose
637
+ their <think> block; only turns after the last user query keep
638
+ "<think>\\n..\\n</think>\\n\\n"). Hand-rolled "<|im_start|>" concatenation
639
+ gets the multi-turn case wrong without any error. Verified against the HF
640
+ template output in tests/test_tokenizer_bytelevel.py.
641
+
642
+ Deliberate deviations from the Jinja (each raises instead of silently differing):
643
+ tools / role "tool" / tool_calls -> NotImplementedError; a role outside
644
+ system/user/assistant -> ValueError (Jinja drops the message); non-str content
645
+ -> TypeError (Jinja renders ''); empty messages -> ValueError.
646
+ """
647
+ if tools:
648
+ raise NotImplementedError("Qwen3 chat template with tools is not implemented")
649
+ if not messages:
650
+ raise ValueError("messages is empty")
651
+ for k, m in enumerate(messages):
652
+ role = m.get("role")
653
+ if role == "tool" or m.get("tool_calls"):
654
+ raise NotImplementedError("tool messages / tool_calls are not implemented")
655
+ if role not in ("system", "user", "assistant"):
656
+ raise ValueError(f"message {k}: unsupported role {role!r}")
657
+ if not isinstance(m.get("content"), str):
658
+ raise TypeError(f"message {k}: content must be str, got {type(m.get('content')).__name__}")
659
+ out: list[str] = []
660
+ if messages[0]["role"] == "system":
661
+ out.append("<|im_start|>system\n" + messages[0]["content"] + "<|im_end|>\n")
662
+ last_query_index = len(messages) - 1
663
+ for idx in range(len(messages) - 1, -1, -1):
664
+ c = messages[idx]["content"]
665
+ if messages[idx]["role"] == "user" and not (c.startswith("<tool_response>") and c.endswith("</tool_response>")):
666
+ last_query_index = idx
667
+ break
668
+ n = len(messages)
669
+ for idx, m in enumerate(messages):
670
+ role, content = m["role"], m["content"]
671
+ if role == "user" or (role == "system" and idx != 0):
672
+ out.append("<|im_start|>" + role + "\n" + content + "<|im_end|>\n")
673
+ elif role == "assistant":
674
+ reasoning = ""
675
+ rc = m.get("reasoning_content")
676
+ if isinstance(rc, str):
677
+ reasoning = rc
678
+ elif "</think>" in content:
679
+ reasoning = content.split("</think>")[0].rstrip("\n").split("<think>")[-1].lstrip("\n")
680
+ content = content.split("</think>")[-1].lstrip("\n")
681
+ is_last = idx == n - 1
682
+ if idx > last_query_index and (is_last or reasoning):
683
+ out.append(
684
+ "<|im_start|>assistant\n<think>\n" + reasoning.strip("\n") + "\n</think>\n\n" + content.lstrip("\n")
685
+ )
686
+ else:
687
+ out.append("<|im_start|>assistant\n" + content)
688
+ out.append("<|im_end|>\n")
689
+ if add_generation_prompt:
690
+ out.append("<|im_start|>assistant\n")
691
+ if enable_thinking is False:
692
+ out.append("<think>\n\n</think>\n\n")
693
+ return "".join(out)
code/align/tokenizer/core.py ADDED
@@ -0,0 +1,615 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pure-Python (stdlib only) BPE tokenizer for Mistral-7B-v0.3.
2
+
3
+ This is a from-scratch reimplementation of exactly the pipeline that
4
+ `tokenizers`/`transformers` run for the v0.3 `tokenizer.json`, and nothing
5
+ else. It exists so the training stack has zero dependency on `transformers`
6
+ (see docs/DEPENDENCY_POLICY.md); parity with the legacy stack is certified by
7
+ the `oracle`-marked test in tests/test_tokenizer.py (hundreds of strings,
8
+ ids AND decode byte-identical).
9
+
10
+ Pipeline (encode):
11
+
12
+ text
13
+ -> split on ADDED tokens (771: <unk> <s> </s> [INST] ... [control_N]),
14
+ leftmost-longest, raw text, BEFORE any BPE (_split_added)
15
+ -> per non-added segment: Metaspace pre-tokenizer (_metaspace)
16
+ ' ' -> U+2581, prepend U+2581 per prepend_scheme
17
+ -> BPE over the WHOLE segment (split=false => ONE "word") (_bpe)
18
+ chars not in vocab -> <0xNN> byte-fallback pieces
19
+ -> post-processor: prepend <s> (id 1) iff add_special_tokens
20
+
21
+ Decode: ids -> pieces, optionally drop special pieces, then the decoder chain
22
+ Replace(U+2581 -> ' ') / ByteFallback / Fuse / Strip(' ', start=1).
23
+
24
+ The loader VALIDATES the pipeline configuration and raises
25
+ NotImplementedError on anything this module does not implement. A different
26
+ tokenizer.json must fail loudly, not silently encode wrong ids.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import heapq
31
+ import json
32
+ import os
33
+ import re
34
+ from typing import Iterable, Optional, Sequence, Union
35
+
36
+ __all__ = ["Tokenizer", "load_tokenizer"]
37
+
38
+ #: U+2581 LOWER ONE EIGHTH BLOCK, the SentencePiece "space" glyph.
39
+ _SP = "▁"
40
+ _HEX = frozenset("0123456789abcdefABCDEF")
41
+ #: Cap on the per-segment encode cache. With split=false a "word" is a whole
42
+ #: text segment, so the cache only pays off for short, repeated strings
43
+ #: (stop strings, role tags, "\n"); long segments are never cached.
44
+ _CACHE_MAX_ENTRIES = 50_000
45
+ _CACHE_MAX_SEGMENT_CHARS = 128
46
+
47
+ _TOKENIZER_FILES = ("tokenizer.json", "tokenizer_config.json", "special_tokens_map.json")
48
+ #: Optional passthrough: AutoTokenizer on a LlamaTokenizer-class config wants it.
49
+ _OPTIONAL_FILES = ("tokenizer.model",)
50
+
51
+
52
+ def _content(tok) -> Optional[str]:
53
+ """HF configs store a special token as either a bare string or a dict
54
+ {"content": ...}; None means 'unset'."""
55
+ if tok is None:
56
+ return None
57
+ if isinstance(tok, dict):
58
+ return tok.get("content")
59
+ return str(tok)
60
+
61
+
62
+ def _expect(cond: bool, what: str) -> None:
63
+ if not cond:
64
+ raise NotImplementedError(
65
+ f"tokenizer.json uses a configuration this pure-Python tokenizer does not "
66
+ f"implement: {what}. Refusing to load rather than silently encode wrong ids."
67
+ )
68
+
69
+
70
+ class Tokenizer:
71
+ """Drop-in for the subset of the HF tokenizer API this repo calls.
72
+
73
+ Build with `Tokenizer.from_pretrained(dir)` / `load_tokenizer(dir)`.
74
+ """
75
+
76
+ def __init__(
77
+ self,
78
+ tokenizer_json: dict,
79
+ tokenizer_config: Optional[dict] = None,
80
+ special_tokens_map: Optional[dict] = None,
81
+ *,
82
+ files: Optional[dict] = None,
83
+ ):
84
+ #: raw bytes of the source files, kept so save_pretrained is a true
85
+ #: copy-through that survives the source dir being pruned/rotated.
86
+ self._files: dict[str, bytes] = dict(files or {})
87
+ self._config = dict(tokenizer_config or {})
88
+ self._special_map = dict(special_tokens_map or {})
89
+ self._tokenizer_json = tokenizer_json
90
+ self._validate_pipeline(tokenizer_json)
91
+ # transformers REBUILDS the Metaspace pre-tokenizer from the config's
92
+ # add_prefix_space (measured on 5.15: False changes the ids, while
93
+ # add_bos_token/add_eos_token are ignored by the tokenizer.json path). The
94
+ # pure-Python pipeline follows tokenizer.json only, so refuse loudly rather
95
+ # than silently disagree with the legacy stack. Every v0.3 config has True.
96
+ _expect(
97
+ self._config.get("add_prefix_space") is not False,
98
+ "tokenizer_config.json add_prefix_space=false",
99
+ )
100
+
101
+ model = tokenizer_json["model"]
102
+ vocab: dict[str, int] = dict(model["vocab"])
103
+
104
+ # -- added tokens: override/extend the model vocab, and are matched first.
105
+ added = list(tokenizer_json.get("added_tokens", []))
106
+ self._added: dict[str, int] = {}
107
+ self._special_contents: set[str] = set()
108
+ for t in added:
109
+ _expect(
110
+ not (t.get("lstrip") or t.get("rstrip") or t.get("single_word")),
111
+ f"added token {t['content']!r} with lstrip/rstrip/single_word",
112
+ )
113
+ vocab[t["content"]] = int(t["id"])
114
+ self._added[t["content"]] = int(t["id"])
115
+ if t.get("special"):
116
+ self._special_contents.add(t["content"])
117
+ self._vocab = vocab
118
+
119
+ size = max(vocab.values()) + 1
120
+ self._id_to_token: list[Optional[str]] = [None] * size
121
+ for tok, i in vocab.items():
122
+ self._id_to_token[i] = tok
123
+ self._base_vocab = model["vocab"] # the BPE model's own vocab (ids for symbols)
124
+
125
+ # -- BPE tables. Merges may be [a, b] pairs (older saves) or "a b" strings.
126
+ self._ranks: dict[tuple[str, str], int] = {}
127
+ for rank, m in enumerate(model["merges"]):
128
+ a, b = (m[0], m[1]) if isinstance(m, (list, tuple)) else m.split(" ")
129
+ # Rust's BPE builder errors if the merge result is not in the vocab.
130
+ if (a + b) not in self._base_vocab or a not in self._base_vocab or b not in self._base_vocab:
131
+ raise ValueError(f"merge #{rank} {a!r}+{b!r} references a token outside the vocab")
132
+ # LAST duplicate wins: Rust collects merges into a HashMap, so a repeated
133
+ # pair keeps its final rank (measured against tokenizers 0.22). v0.3 has none.
134
+ self._ranks[(a, b)] = rank
135
+ self._byte_fallback = bool(model.get("byte_fallback"))
136
+ self._byte_pieces: list[Optional[str]] = [
137
+ (f"<0x{b:02X}>" if f"<0x{b:02X}>" in self._base_vocab else None) for b in range(256)
138
+ ]
139
+ self._fuse_unk = bool(model.get("fuse_unk"))
140
+ self._model_unk = model.get("unk_token")
141
+ self._ignore_merges = bool(model.get("ignore_merges"))
142
+ self._cache: dict[str, tuple[int, ...]] = {}
143
+
144
+ # -- added-token splitter. Alternation sorted longest-first == leftmost-longest.
145
+ if self._added:
146
+ alts = sorted(self._added, key=len, reverse=True)
147
+ self._added_re: Optional[re.Pattern] = re.compile("|".join(re.escape(a) for a in alts))
148
+ else:
149
+ self._added_re = None
150
+
151
+ # -- Metaspace
152
+ pt = tokenizer_json["pre_tokenizer"]
153
+ self._prepend_scheme = pt.get("prepend_scheme", "always" if pt.get("add_prefix_space", True) else "never")
154
+
155
+ # -- special tokens (strings), resolved config -> special_tokens_map -> defaults
156
+ def pick(name: str, default: Optional[str] = None) -> Optional[str]:
157
+ v = _content(self._config.get(f"{name}_token"))
158
+ if v is None:
159
+ v = _content(self._special_map.get(f"{name}_token"))
160
+ if v is None:
161
+ v = default
162
+ return v if (v is None or v in self._vocab) else None
163
+
164
+ self.bos_token = pick("bos", "<s>")
165
+ self.eos_token = pick("eos", "</s>")
166
+ self.unk_token = pick("unk", self._model_unk)
167
+ self.pad_token = pick("pad") # v0.3 ships none; callers fall back to eos
168
+ self._post_bos = self._post_processor_bos(tokenizer_json.get("post_processor"))
169
+
170
+ # ------------------------------------------------------------------ config
171
+
172
+ @staticmethod
173
+ def _validate_pipeline(tj: dict) -> None:
174
+ model = tj["model"]
175
+ _expect(model.get("type") == "BPE", f"model.type={model.get('type')!r}")
176
+ _expect(not model.get("dropout"), "BPE dropout")
177
+ _expect(not model.get("continuing_subword_prefix"), "continuing_subword_prefix")
178
+ _expect(not model.get("end_of_word_suffix"), "end_of_word_suffix")
179
+ _expect(tj.get("normalizer") is None, "a non-null normalizer")
180
+ pt = tj.get("pre_tokenizer") or {}
181
+ _expect(pt.get("type") == "Metaspace", f"pre_tokenizer {pt.get('type')!r}")
182
+ _expect(pt.get("replacement") == _SP, "Metaspace replacement other than U+2581")
183
+ _expect(pt.get("split") is False, "Metaspace split=true")
184
+ _expect(
185
+ pt.get("prepend_scheme", "always") in ("first", "always", "never"),
186
+ f"Metaspace prepend_scheme {pt.get('prepend_scheme')!r}",
187
+ )
188
+ dec = tj.get("decoder") or {}
189
+ chain = dec.get("decoders") if dec.get("type") == "Sequence" else None
190
+ _expect(chain is not None and len(chain) == 4, "decoder other than the 4-step Sequence")
191
+ r, bf, fu, st = chain
192
+ _expect(
193
+ r.get("type") == "Replace"
194
+ and r.get("pattern") == {"String": _SP}
195
+ and r.get("content") == " ",
196
+ "decoder[0] other than Replace(U+2581 -> ' ')",
197
+ )
198
+ _expect(bf.get("type") == "ByteFallback", "decoder[1] other than ByteFallback")
199
+ _expect(fu.get("type") == "Fuse", "decoder[2] other than Fuse")
200
+ _expect(
201
+ st.get("type") == "Strip"
202
+ and st.get("content") == " "
203
+ and st.get("start") == 1
204
+ and st.get("stop") == 0,
205
+ "decoder[3] other than Strip(' ', start=1, stop=0)",
206
+ )
207
+
208
+ @staticmethod
209
+ def _post_processor_bos(pp: Optional[dict]) -> Optional[str]:
210
+ """The single-sequence template must be exactly [<special>, A]; returns
211
+ that special token's content (the BOS), or None if there is no post-processor."""
212
+ if pp is None:
213
+ return None
214
+ _expect(pp.get("type") == "TemplateProcessing", f"post_processor {pp.get('type')!r}")
215
+ single = pp.get("single", [])
216
+ _expect(
217
+ len(single) == 2 and "SpecialToken" in single[0] and "Sequence" in single[1],
218
+ "post_processor single template other than [SpecialToken, Sequence]",
219
+ )
220
+ return single[0]["SpecialToken"]["id"]
221
+
222
+ # --------------------------------------------------------------- id access
223
+
224
+ @property
225
+ def bos_token_id(self) -> Optional[int]:
226
+ return self._vocab.get(self.bos_token) if self.bos_token is not None else None
227
+
228
+ @property
229
+ def eos_token_id(self) -> Optional[int]:
230
+ return self._vocab.get(self.eos_token) if self.eos_token is not None else None
231
+
232
+ @property
233
+ def pad_token_id(self) -> Optional[int]:
234
+ """None on v0.3 -- callers (align.data.encoding.pad_token_id) fall back to eos."""
235
+ return self._vocab.get(self.pad_token) if self.pad_token is not None else None
236
+
237
+ @property
238
+ def unk_token_id(self) -> Optional[int]:
239
+ return self._vocab.get(self.unk_token) if self.unk_token is not None else None
240
+
241
+ @property
242
+ def vocab_size(self) -> int:
243
+ return len(self._base_vocab)
244
+
245
+ @property
246
+ def all_special_tokens(self) -> list[str]:
247
+ return sorted(self._special_contents, key=lambda t: self._vocab[t])
248
+
249
+ @property
250
+ def all_special_ids(self) -> list[int]:
251
+ return [self._vocab[t] for t in self.all_special_tokens]
252
+
253
+ def get_vocab(self) -> dict[str, int]:
254
+ return dict(self._vocab)
255
+
256
+ def get_added_vocab(self) -> dict[str, int]:
257
+ return dict(self._added)
258
+
259
+ def __len__(self) -> int:
260
+ return len(self._vocab)
261
+
262
+ def __repr__(self) -> str:
263
+ return f"Tokenizer(n={len(self)}, merges={len(self._ranks)}, added={len(self._added)})"
264
+
265
+ def convert_ids_to_tokens(self, ids: Union[int, Iterable[int]]):
266
+ """Pieces for ids; None for ids outside the vocab (HF returns None too)."""
267
+ if isinstance(ids, int):
268
+ return self._piece(ids)
269
+ return [self._piece(int(i)) for i in _as_list(ids)]
270
+
271
+ def convert_tokens_to_ids(self, tokens: Union[str, Iterable[str]]):
272
+ """Ids for pieces; unknown pieces map to the unk id (HF behaviour)."""
273
+ unk = self.unk_token_id
274
+ if isinstance(tokens, str):
275
+ return self._vocab.get(tokens, unk)
276
+ return [self._vocab.get(t, unk) for t in tokens]
277
+
278
+ def _piece(self, i: int) -> Optional[str]:
279
+ if i < 0:
280
+ raise ValueError(f"negative token id {i}: a label-mask value leaked into a token-id path")
281
+ return self._id_to_token[i] if i < len(self._id_to_token) else None
282
+
283
+ # ------------------------------------------------------------------ encode
284
+
285
+ def __call__(self, text: Union[str, Sequence[str]], add_special_tokens: bool = True) -> dict:
286
+ """{"input_ids": ...}. A list of strings gives a list of id lists.
287
+ Padding/truncation/tensors are deliberately NOT supported (TypeError on
288
+ unknown kwargs) -- callers pad their own batches."""
289
+ if isinstance(text, str):
290
+ return {"input_ids": self.encode(text, add_special_tokens=add_special_tokens)}
291
+ return {"input_ids": [self.encode(t, add_special_tokens=add_special_tokens) for t in text]}
292
+
293
+ def encode(self, text: str, add_special_tokens: bool = True) -> list[int]:
294
+ if not isinstance(text, str):
295
+ raise TypeError(f"encode() takes str, got {type(text).__name__}")
296
+ try:
297
+ text.encode("utf-8")
298
+ except UnicodeEncodeError as e: # lone surrogate: Rust strings cannot hold one
299
+ raise ValueError(f"text is not valid Unicode (lone surrogate at index {e.start})") from e
300
+ ids: list[int] = []
301
+ if add_special_tokens and self._post_bos is not None:
302
+ ids.append(self._vocab[self._post_bos])
303
+ for k, (is_added, seg) in enumerate(self._split_added(text)):
304
+ if is_added:
305
+ ids.append(self._added[seg])
306
+ continue
307
+ piece = self._metaspace(seg, k == 0)
308
+ if piece:
309
+ ids.extend(self._bpe(piece))
310
+ return ids
311
+
312
+ def _split_added(self, text: str):
313
+ """Yield (is_added, substring). Added tokens are matched on the RAW text,
314
+ leftmost-longest, and are never fed to BPE (so nothing merges across them).
315
+ No empty segments are produced (matches HF: "" encodes to no tokens)."""
316
+ if self._added_re is None or not text:
317
+ if text:
318
+ yield False, text
319
+ return
320
+ pos = 0
321
+ for m in self._added_re.finditer(text):
322
+ if m.start() > pos:
323
+ yield False, text[pos : m.start()]
324
+ yield True, m.group(0)
325
+ pos = m.end()
326
+ if pos < len(text):
327
+ yield False, text[pos:]
328
+
329
+ def _metaspace(self, seg: str, is_first_split: bool) -> str:
330
+ """Metaspace(replacement=U+2581, prepend_scheme=..., split=false).
331
+
332
+ ' ' -> U+2581 (ONLY the space char; '\\n' and '\\t' stay literal and later
333
+ byte-fall-back). prepend_scheme="first" prepends U+2581 only when this
334
+ segment is split #0 of the WHOLE input, i.e. the text does not begin with
335
+ an added token. Added-token splits are counted (they occupy an index)
336
+ but skip the pre-tokenizer, so a leading special token CONSUMES the
337
+ 'first' slot and nothing after it is ever prefixed:
338
+ "hello<s>world" -> ▁hello <s> world
339
+ "<s>hello" -> <s> <s> hello (bare 'hello', NO ▁ -- measured
340
+ against the legacy tokenizer)
341
+ A segment that already starts with U+2581 (leading space / literal U+2581)
342
+ gets no second prefix. 'always' prefixes every such segment; 'never' none."""
343
+ s = seg.replace(" ", _SP)
344
+ scheme = self._prepend_scheme
345
+ if scheme == "never" or s.startswith(_SP):
346
+ return s
347
+ if scheme == "always" or is_first_split:
348
+ return _SP + s
349
+ return s
350
+
351
+ def _bpe(self, word: str) -> tuple[int, ...]:
352
+ cached = self._cache.get(word)
353
+ if cached is not None:
354
+ return cached
355
+ vocab = self._base_vocab
356
+ if self._ignore_merges and word in vocab:
357
+ res = (vocab[word],)
358
+ else:
359
+ res = self._merge(self._initial_symbols(word))
360
+ if len(word) <= _CACHE_MAX_SEGMENT_CHARS:
361
+ if len(self._cache) >= _CACHE_MAX_ENTRIES:
362
+ self._cache.clear()
363
+ self._cache[word] = res
364
+ return res
365
+
366
+ def _initial_symbols(self, word: str) -> list[str]:
367
+ """One symbol per char if the char is a vocab piece; else its UTF-8 bytes
368
+ as <0xNN> pieces (byte_fallback); else <unk> (consecutive unks fuse).
369
+
370
+ Mirrors the Rust `BPE::merge_word` loop INCLUDING a quirk: an unknown char
371
+ only records a PENDING unk, and the pending unk is flushed solely by the
372
+ next in-vocab char (or end of word). A byte-fallback expansion in between
373
+ neither flushes nor resets it, so the unk lands AFTER those byte pieces
374
+ (and, with fuse_unk, unks on both sides of a byte-fallback char fuse into
375
+ one). Unreachable for v0.3 (all 256 <0xNN> pieces exist, so the unk path
376
+ never fires), but the loader accepts other byte_fallback tables."""
377
+ vocab = self._base_vocab
378
+ syms: list[str] = []
379
+ unk = self._model_unk
380
+ pending_unk = False
381
+ for ch in word:
382
+ if ch in vocab:
383
+ if pending_unk:
384
+ syms.append(unk) # type: ignore[arg-type]
385
+ pending_unk = False
386
+ syms.append(ch)
387
+ continue
388
+ if self._byte_fallback:
389
+ pieces = [self._byte_pieces[b] for b in ch.encode("utf-8")]
390
+ if None not in pieces:
391
+ syms.extend(pieces) # type: ignore[arg-type]
392
+ continue # pending unk is deliberately left untouched (Rust parity)
393
+ if unk is not None:
394
+ if pending_unk and not self._fuse_unk:
395
+ syms.append(unk)
396
+ pending_unk = True
397
+ if pending_unk:
398
+ syms.append(unk) # type: ignore[arg-type]
399
+ return syms
400
+
401
+ def _merge(self, syms: list[str]) -> tuple[int, ...]:
402
+ """Rank-ordered pair merging over a doubly linked list with a lazy-deletion
403
+ min-heap: O(n log n), so a multi-thousand-char segment (split=false makes
404
+ every text segment ONE word) is not quadratic. Tie-break is lowest rank,
405
+ then leftmost position -- identical to the Rust implementation."""
406
+ n = len(syms)
407
+ vocab = self._base_vocab
408
+ if n < 2:
409
+ return tuple(vocab[s] for s in syms)
410
+ ranks = self._ranks
411
+ nxt = list(range(1, n + 1))
412
+ nxt[-1] = -1
413
+ prv = list(range(-1, n - 1))
414
+ heap: list[tuple[int, int, str, str]] = []
415
+ for i in range(n - 1):
416
+ r = ranks.get((syms[i], syms[i + 1]))
417
+ if r is not None:
418
+ heap.append((r, i, syms[i], syms[i + 1]))
419
+ heapq.heapify(heap)
420
+ pop, push = heapq.heappop, heapq.heappush
421
+ while heap:
422
+ _, i, left, right = pop(heap)
423
+ if syms[i] != left: # stale: left side was merged or absorbed (None)
424
+ continue
425
+ j = nxt[i]
426
+ if j == -1 or syms[j] != right: # stale: right neighbour changed
427
+ continue
428
+ new = left + right
429
+ syms[i] = new
430
+ syms[j] = None # type: ignore[call-overload]
431
+ nj = nxt[j]
432
+ nxt[i] = nj
433
+ if nj != -1:
434
+ prv[nj] = i
435
+ p = prv[i]
436
+ if p != -1:
437
+ r = ranks.get((syms[p], new))
438
+ if r is not None:
439
+ push(heap, (r, p, syms[p], new))
440
+ if nj != -1:
441
+ r = ranks.get((new, syms[nj]))
442
+ if r is not None:
443
+ push(heap, (r, i, new, syms[nj]))
444
+ out = []
445
+ i = 0
446
+ while i != -1:
447
+ out.append(vocab[syms[i]])
448
+ i = nxt[i]
449
+ return tuple(out)
450
+
451
+ # ------------------------------------------------------------------ decode
452
+
453
+ def decode(self, ids, skip_special_tokens: bool = False) -> str:
454
+ """ids -> text. Default skip_special_tokens=False matches HF (callers that
455
+ want specials dropped, e.g. the inference engine, pass True explicitly).
456
+
457
+ Ids outside the vocab are silently dropped (HF/Rust behaviour); negative
458
+ ids raise (they are masks that leaked, never tokens).
459
+
460
+ Decoder chain, applied to the list of pieces:
461
+ Replace(U+2581 -> ' ') per piece;
462
+ ByteFallback: each maximal run of <0xNN> pieces is UTF-8 decoded as ONE
463
+ unit; an INVALID run becomes one U+FFFD **per byte** (Rust semantics,
464
+ not Python errors='replace', which collapses ill-formed subparts);
465
+ Fuse: concatenate;
466
+ Strip(' ', start=1): drop at most ONE leading space of the fused text.
467
+ The Strip is why decode([id_of('▁hello')]) == 'hello', not ' hello': a
468
+ chunk decoded in isolation loses its leading space. Never decode token
469
+ deltas and concatenate -- decode the whole sequence (the engine does).
470
+ """
471
+ pieces: list[str] = []
472
+ special = self._special_contents
473
+ for i in _as_list(ids):
474
+ t = self._piece(int(i))
475
+ if t is None:
476
+ continue
477
+ if skip_special_tokens and t in special:
478
+ continue
479
+ pieces.append(t)
480
+ return self._decode_pieces(pieces)
481
+
482
+ @staticmethod
483
+ def _decode_pieces(pieces: list[str]) -> str:
484
+ out: list[str] = []
485
+ run = bytearray()
486
+
487
+ def flush() -> None:
488
+ if run:
489
+ try:
490
+ out.append(run.decode("utf-8"))
491
+ except UnicodeDecodeError:
492
+ out.append("�" * len(run))
493
+ run.clear()
494
+
495
+ for p in pieces:
496
+ p = p.replace(_SP, " ")
497
+ if len(p) == 6 and p.startswith("<0x") and p.endswith(">") and p[3] in _HEX and p[4] in _HEX:
498
+ run.append(int(p[3:5], 16))
499
+ else:
500
+ flush()
501
+ out.append(p)
502
+ flush()
503
+ text = "".join(out) # Fuse
504
+ return text[1:] if text.startswith(" ") else text # Strip(' ', start=1)
505
+
506
+ # -------------------------------------------------------------------- save
507
+
508
+ def save_pretrained(self, save_directory: str) -> tuple[str, ...]:
509
+ """Write tokenizer.json, tokenizer_config.json, special_tokens_map.json
510
+ (+ tokenizer.model if the source had one). Copy-through of the source
511
+ bytes when loaded from a dir; synthesized (HF-shaped) otherwise. Each file
512
+ is written to a sibling temp name then os.replace()d, so a kill mid-save
513
+ leaves the old file or the new file, never a torn one."""
514
+ os.makedirs(save_directory, exist_ok=True)
515
+ files = dict(self._files)
516
+ if "tokenizer.json" not in files:
517
+ files["tokenizer.json"] = json.dumps(self._tokenizer_json, ensure_ascii=False).encode("utf-8")
518
+ if "tokenizer_config.json" not in files:
519
+ files["tokenizer_config.json"] = json.dumps(self._synth_config(), indent=2).encode("utf-8")
520
+ if "special_tokens_map.json" not in files:
521
+ files["special_tokens_map.json"] = json.dumps(self._synth_special_map(), indent=2).encode("utf-8")
522
+ written = []
523
+ for name, blob in files.items():
524
+ dst = os.path.join(save_directory, name)
525
+ tmp = dst + f".tmp.{os.getpid()}"
526
+ with open(tmp, "wb") as f:
527
+ f.write(blob)
528
+ f.flush()
529
+ os.fsync(f.fileno())
530
+ os.replace(tmp, dst)
531
+ written.append(dst)
532
+ return tuple(written)
533
+
534
+ def _synth_config(self) -> dict:
535
+ return {
536
+ "add_bos_token": self._post_bos is not None,
537
+ "add_eos_token": False,
538
+ "bos_token": self.bos_token,
539
+ "eos_token": self.eos_token,
540
+ "unk_token": self.unk_token,
541
+ "pad_token": self.pad_token,
542
+ "clean_up_tokenization_spaces": False,
543
+ "legacy": False,
544
+ "model_max_length": int(1e30),
545
+ "tokenizer_class": "LlamaTokenizer",
546
+ }
547
+
548
+ def _synth_special_map(self) -> dict:
549
+ d = {}
550
+ for name in ("bos", "eos", "unk", "pad"):
551
+ tok = getattr(self, f"{name}_token")
552
+ if tok is not None:
553
+ d[f"{name}_token"] = {
554
+ "content": tok,
555
+ "lstrip": False,
556
+ "normalized": False,
557
+ "rstrip": False,
558
+ "single_word": False,
559
+ }
560
+ return d
561
+
562
+ # -------------------------------------------------------------------- load
563
+
564
+ @classmethod
565
+ def from_pretrained(cls, path: Union[str, os.PathLike]) -> "Tokenizer":
566
+ """Load from a directory holding tokenizer.json (a checkpoint dir, or an HF
567
+ snapshot dir), a path to a tokenizer.json, or an HF cache model dir
568
+ (`models--org--name`, newest snapshot with a tokenizer.json). LOCAL ONLY:
569
+ never touches the network, never downloads."""
570
+ d = _resolve_dir(os.fspath(path))
571
+ files: dict[str, bytes] = {}
572
+ for name in _TOKENIZER_FILES + _OPTIONAL_FILES:
573
+ p = os.path.join(d, name)
574
+ if os.path.isfile(p):
575
+ with open(p, "rb") as f:
576
+ files[name] = f.read()
577
+ tj = json.loads(files["tokenizer.json"])
578
+ cfg = json.loads(files["tokenizer_config.json"]) if "tokenizer_config.json" in files else None
579
+ smap = json.loads(files["special_tokens_map.json"]) if "special_tokens_map.json" in files else None
580
+ return cls(tj, cfg, smap, files=files)
581
+
582
+
583
+ def _as_list(ids) -> list:
584
+ """Accept list/tuple/iterable, numpy arrays and torch tensors (via .tolist())."""
585
+ if hasattr(ids, "tolist"):
586
+ ids = ids.tolist()
587
+ if isinstance(ids, int):
588
+ return [ids]
589
+ return list(ids)
590
+
591
+
592
+ def _resolve_dir(path: str) -> str:
593
+ if os.path.isfile(path) and os.path.basename(path) == "tokenizer.json":
594
+ return os.path.dirname(os.path.abspath(path))
595
+ if os.path.isfile(os.path.join(path, "tokenizer.json")):
596
+ return path
597
+ snaps = os.path.join(path, "snapshots")
598
+ if os.path.isdir(snaps):
599
+ cands = [
600
+ os.path.join(snaps, s)
601
+ for s in os.listdir(snaps)
602
+ if os.path.isfile(os.path.join(snaps, s, "tokenizer.json"))
603
+ ]
604
+ if cands:
605
+ return max(cands, key=os.path.getmtime)
606
+ raise FileNotFoundError(
607
+ f"no tokenizer.json at {path!r} (also tried {snaps!r}). align.tokenizer is "
608
+ "local-only by design: it never downloads. Pass a checkpoint dir or an HF "
609
+ "snapshot dir containing tokenizer.json."
610
+ )
611
+
612
+
613
+ def load_tokenizer(path: Union[str, os.PathLike]) -> Tokenizer:
614
+ """Load a Tokenizer from a local dir/file. See Tokenizer.from_pretrained."""
615
+ return Tokenizer.from_pretrained(path)
code/align/train/__init__.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The distributed training core (section-07).
2
+
3
+ * `offload_adamw.py` -- optimizer-state-only CPU offload; params and grads
4
+ stay on the training device, moments and the fp32 master live in host RAM.
5
+ * `fsdp.py` -- FSDP2 wrapping + mixed-precision policy, called from
6
+ the launch scripts, never from the Trainer.
7
+ * `trainer.py` -- the one Trainer for SFT and DPO: micro-step loop,
8
+ seeded lossless resume, optimizer-step scheduler, checkpoint cadence.
9
+ * `probes.py` -- trunk-movement probe, run-health tripwires, the
10
+ 20-step throughput probe.
11
+ * `sft_loss.py` -- adapter from a collated batch to section-05's
12
+ chunked fused CE.
13
+
14
+ Every module here imports torch at module scope, so nothing above the resource
15
+ guards may import this package.
16
+ """
code/align/train/adamw8bit.py ADDED
@@ -0,0 +1,630 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Block-wise 8-bit AdamW in pure torch: the optimizer that lets full-FT 7B launch.
2
+
3
+ WHY THIS EXISTS (the arithmetic, from .tmp/handoff/review_trainer.md): every
4
+ shipped full-FT config names `optimizer.name = "adamw8bit"`, and the two
5
+ alternatives are both unlaunchable on this box at 7.248B params / 4 ranks:
6
+
7
+ * fp32 AdamW on the device needs 14.5GB/rank of moments on top of a 9.6GB
8
+ activation peak against a 23GB A10G.
9
+ * `OffloadAdamW` pins the WHOLE rank master (n/world*4 B) while its staging
10
+ cap is two layer-shards (n/layers/world*4*2 B): required/cap = layers /
11
+ (world * shards) = 4, so it refuses to construct. Past that, its host RSS
12
+ projects to ~105GB against ~85GB MemAvailable, and the checkpoint clones
13
+ master + both moments (12 B/param).
14
+
15
+ This optimizer keeps both Adam moments as uint8 codes ON THE DEVICE and has no
16
+ master copy at all.
17
+
18
+ MEMORY BUDGET, per rank-local parameter element (block_size B = 2048):
19
+
20
+ exp_avg 1 byte code + 4/B byte absmax = 1.00195 B
21
+ exp_avg_sq 1 byte code + 4/B byte absmax = 1.00195 B
22
+ ------------------------------------------------------------
23
+ total 2 + 8/B = 2.0039 B/param (fp32 AdamW: 8; OffloadAdamW: 12 host)
24
+
25
+ 7.248e9 params / 4 ranks = 1.812e9 elements/rank -> 3.63 GB (3.38 GiB)/rank
26
+ 14.5 GB across the job, all on the GPU, ZERO host RSS (which is what
27
+ `guard.projected_full_ft_rss_gb` already assumes for non-offload).
28
+
29
+ Tensors below `min_8bit_size` (4096) elements -- norm scales, biases -- keep
30
+ fp32 moments (8 B/param); there are ~65 of them in a 32-layer Mistral and
31
+ they total well under a megabyte. The step additionally holds one CHUNK of
32
+ fp32 transients (default 2**21 elements, < 100MB), never a whole tensor.
33
+ `projected_state_bytes` computes the exact figure from element counts and is
34
+ what the launch banner prints.
35
+
36
+ THE STEP, per chunk of whole blocks: dequantize both moments -> fp32 AdamW
37
+ update (decoupled weight decay, bias correction; the same op order as
38
+ `torch.optim.AdamW`'s single-tensor path, so the fp32 fallback is BIT-EXACT
39
+ against it) -> requantize -> store. The update uses the freshly computed
40
+ fp32 moments, not the requantized ones: the quantization error only enters
41
+ through the NEXT step's decayed moment, which is the same order bitsandbytes'
42
+ fused kernel uses (quantization is a storage format, not part of the rule).
43
+
44
+ KNOWN LIMITATION, measured and bounded (not a bug to chase): SECOND-MOMENT
45
+ UNDERFLOW. v is a square, so its dynamic range is the SQUARE of the
46
+ gradient's. The smallest nonzero unsigned code is 3.25e-7 of the block absmax,
47
+ so an element whose gradient is < ~5e-4 of its block's largest one stores v = 0.
48
+ The update never divides by that stored zero (it uses the fresh fp32 moment,
49
+ see above), but the decayed history of v is lost for those elements: right
50
+ after a cold start a tiny-gradient element sees only (1-beta2)*g^2 instead of
51
+ the running average, so its step is up to lr / sqrt(1 - beta2) (4.5 lr at
52
+ beta2 = 0.95) instead of ~lr. Measured on a Gaussian-gradient linear layer: a
53
+ handful of elements (~0.1%) are off by ~1-2 lr on the second step and the mean
54
+ error over all elements is ~1% of the travel -- and that is a COLD-START /
55
+ CHANGING-GRADIENT picture. It does NOT relax for an element whose gradient
56
+ stays below the threshold: v is re-floored to 0 every step, so the over-step is
57
+ PERMANENT, and it GROWS with beta2 (adversarial review, constant g = 1e-4 of the
58
+ block max, 300 steps, travel vs torch.optim.AdamW: 3.9x at beta2 = 0.95, 9.9x at
59
+ 0.999 and still rising toward 1/sqrt(1-beta2) = 31.6x; g = 1e-3 of the max is
60
+ 1.07x, g = 1e-2 is 1.00x). Clamping every positive v up to the smallest nonzero
61
+ code would turn that into a bounded UNDER-step (0.16x, independent of beta2) at
62
+ the price of freezing those elements; that is a different optimizer, so it is
63
+ the owner's call (`.tmp/review_adamw8bit/probe3.py`), not made here.
64
+ bitsandbytes has the same property
65
+ (same map, same nearest-code rounding); the alternative of storing sqrt(v)
66
+ doubles the relative error of the denominator in exchange for the range, which
67
+ is a different optimizer than the one specified, so it is not done here.
68
+ `tests/test_adamw8bit.py::test_second_moment_underflow_...` pins the bound.
69
+
70
+ NO MASTER COPY, AND WHAT THAT COSTS. The parameters are bf16 and the update is
71
+ tiny: at lr 1e-5 an update is ~1e-5 against a bf16 weight of ~2e-2 whose
72
+ spacing is 1.2e-4, so round-to-nearest drops almost every update -- a run
73
+ whose loss curve is flat and whose weights "never move" (the same
74
+ silent-no-train class as the ZeRO-3 + Adafactor incident). The fp32 master that
75
+ `OffloadAdamW` pays 4 B/param for exists to prevent exactly this. Here the
76
+ write-back to a bf16 parameter is STOCHASTIC ROUNDING by default: the fp32
77
+ result is rounded up or down with probability proportional to proximity, so the
78
+ parameter moves by the right amount IN EXPECTATION. It is deterministic given
79
+ the seed, and its generator state is checkpointed, so resume stays bit-exact.
80
+ Pass `stochastic_rounding=False` for round-to-nearest (what bnb does).
81
+
82
+ FSDP2 / DTENSOR (mirrors `OffloadAdamW`, same defect history): under
83
+ `fully_shard` the parameters are DTensors. AdamW is elementwise, so stepping
84
+ `param.to_local()` against the grad's local shard is EXACT; every access goes
85
+ through `_localize` (a storage-sharing view, so in-place updates reach the
86
+ DTensor). Numels are THIS RANK'S, never the global count.
87
+
88
+ GLOBAL GRAD NORM (mirrors `OffloadAdamW._grad_norm`): the sum of squares is
89
+ all-reduced across ranks before the root, so `last_grad_norm` is the true
90
+ global norm, identical on every rank, and is recorded BEFORE clipping --
91
+ the tripwires that read it must see an explosion, not its clipped image.
92
+ The same all-reduced value is the finiteness gate: a non-finite gradient on
93
+ ANY rank makes the global sum non-finite on EVERY rank, so all ranks skip
94
+ together with one collective instead of two. (In float64 the sum of squares
95
+ of finite fp32 values cannot overflow, so non-finite sum <=> non-finite grad.)
96
+
97
+ RESUME: `state_dict()` is a snapshot of the uint8 codes, the absmax, the step
98
+ counts, the hyper-parameters and the rounding RNG, all cloned to CPU. Reloading
99
+ copies them back byte for byte, so a resumed run is bit-identical to an
100
+ uninterrupted one. The state is SHARD-shaped: it records `world_size` and
101
+ every tensor's element count and REFUSES a mismatched resume rather than
102
+ aligning rank r's moments to a different slice of the parameter.
103
+ """
104
+
105
+ from __future__ import annotations
106
+
107
+ import logging
108
+ import math
109
+ from typing import Iterable, Optional, Sequence
110
+
111
+ import torch
112
+
113
+ from ..utils.dist import rank, world_size
114
+ from .offload_adamw import OffloadAdamW
115
+ from .quant8 import (
116
+ codebook,
117
+ dequantize_rows,
118
+ quantize_rows,
119
+ zero_code,
120
+ )
121
+
122
+ logger = logging.getLogger(__name__)
123
+
124
+ STATE_FORMAT = "adamw8bit-blockwise-v1"
125
+ DEFAULT_BLOCK_SIZE = 2048
126
+ #: bnb's `min_8bit_size`: below this a tensor's moments stay fp32.
127
+ DEFAULT_MIN_8BIT_SIZE = 4096
128
+ #: Elements processed per fused dequant/update/requant pass (rounded down to
129
+ #: whole blocks). Bounds the fp32 transients; see the module docstring.
130
+ DEFAULT_CHUNK_ELEMS = 2**21
131
+ _FP32_MAX = float(torch.finfo(torch.float32).max)
132
+
133
+
134
+ def adamw_update_(
135
+ p: torch.Tensor,
136
+ g: torch.Tensor,
137
+ m: torch.Tensor,
138
+ v: torch.Tensor,
139
+ *,
140
+ lr: float,
141
+ beta1: float,
142
+ beta2: float,
143
+ eps: float,
144
+ weight_decay: float,
145
+ step: int,
146
+ ) -> None:
147
+ """One AdamW update on fp32 tensors, IN PLACE on `p`, `m`, `v`.
148
+
149
+ The op sequence is `torch.optim.AdamW._single_tensor_adamw` verbatim
150
+ (decoupled decay first, `lerp_` for the first moment, `addcmul_` for the
151
+ second, bias-correction folded into step_size and the denominator). That
152
+ is what makes "fp32 fallback == torch AdamW" a property of the
153
+ construction: a reordering that is algebraically identical is NOT
154
+ bit-identical in fp32, and a test that tolerates 1e-6 would hide the day
155
+ someone swaps `lerp_` for a hand-written EMA.
156
+ """
157
+ if weight_decay != 0:
158
+ p.mul_(1 - lr * weight_decay)
159
+ m.lerp_(g, 1 - beta1)
160
+ v.mul_(beta2).addcmul_(g, g, value=1 - beta2)
161
+ bias_correction1 = 1 - beta1**step
162
+ bias_correction2 = 1 - beta2**step
163
+ step_size = lr / bias_correction1
164
+ bias_correction2_sqrt = bias_correction2**0.5
165
+ denom = (v.sqrt() / bias_correction2_sqrt).add_(eps)
166
+ p.addcdiv_(m, denom, value=-step_size)
167
+
168
+
169
+ def stochastic_round_to_bf16(x: torch.Tensor, generator: Optional[torch.Generator]) -> torch.Tensor:
170
+ """fp32 -> bf16 with stochastic rounding (unbiased: E[out] == x).
171
+
172
+ bf16 is exactly the top 16 bits of an fp32. Adding a uniform random
173
+ integer in [0, 2**16) to the raw bit pattern and truncating the low 16 bits
174
+ rounds UP with probability (low16 / 2**16) -- i.e. in proportion to how
175
+ close x is to the next representable value -- and an already-representable
176
+ x (low16 == 0) is returned unchanged. The add acts on magnitude for both
177
+ signs, because the sign is the top bit and the carry never reaches it for
178
+ finite inputs. Truncation back to bf16 is then exact (`.to(bfloat16)` of a
179
+ value whose low 16 bits are zero).
180
+ """
181
+ x = x.contiguous()
182
+ noise = torch.randint(
183
+ 0, 1 << 16, x.shape, dtype=torch.int32, device=x.device, generator=generator
184
+ )
185
+ bits = (x.view(torch.int32) + noise) & -65536
186
+ return bits.view(torch.float32).to(torch.bfloat16)
187
+
188
+
189
+ class BlockwiseAdamW8bit:
190
+ """AdamW with block-wise 8-bit moments, stepping each rank's LOCAL shard.
191
+
192
+ `step()` returns False when the grad-skip gate refused the step (same
193
+ contract as `OffloadAdamW`), True when it applied.
194
+
195
+ Args:
196
+ params: the parameters (plain tensors or FSDP2 DTensors). Floating
197
+ point, contiguous locally, no duplicates.
198
+ lr, betas, eps, weight_decay: AdamW; decay is DECOUPLED (applied to
199
+ the weight, not folded into the gradient).
200
+ max_grad_norm: if set, gradients are scaled by
201
+ `min(1, max_grad_norm / (norm + 1e-6))` -- `clip_grad_norm_`'s rule
202
+ -- using the GLOBAL norm. `last_grad_norm` is the pre-clip norm.
203
+ The scale is applied inside the fused update; `p.grad` itself is
204
+ not rewritten (it is about to be zeroed, and rewriting a bf16 grad
205
+ in place would only add rounding).
206
+ block_size: elements per absmax block (default 2048).
207
+ min_8bit_size: tensors with fewer local elements keep fp32 moments.
208
+ chunk_elems: fp32-transient bound for the fused update.
209
+ stochastic_rounding: stochastic (default) vs nearest rounding when the
210
+ fp32 result is written back to a bf16 parameter. See module doc.
211
+ seed: base seed of the rounding RNG (offset by rank).
212
+ """
213
+
214
+ def __init__(
215
+ self,
216
+ params: Iterable[torch.Tensor],
217
+ lr: float,
218
+ betas: tuple[float, float] = (0.9, 0.95),
219
+ eps: float = 1e-8,
220
+ weight_decay: float = 0.0,
221
+ max_grad_norm: Optional[float] = None,
222
+ block_size: int = DEFAULT_BLOCK_SIZE,
223
+ min_8bit_size: int = DEFAULT_MIN_8BIT_SIZE,
224
+ chunk_elems: int = DEFAULT_CHUNK_ELEMS,
225
+ stochastic_rounding: bool = True,
226
+ seed: int = 0,
227
+ ) -> None:
228
+ self.params: list[torch.Tensor] = list(params)
229
+ if not self.params:
230
+ raise ValueError("BlockwiseAdamW8bit got an empty parameter list")
231
+ if len({id(p) for p in self.params}) != len(self.params):
232
+ raise ValueError(
233
+ "BlockwiseAdamW8bit got the same parameter object twice (tied weights?): "
234
+ "it would be stepped twice per step with one shared state slot"
235
+ )
236
+ if not (isinstance(block_size, int) and block_size >= 1):
237
+ raise ValueError(f"block_size must be a positive int, got {block_size!r}")
238
+ if min_8bit_size < 0 or chunk_elems < 1:
239
+ raise ValueError("min_8bit_size must be >= 0 and chunk_elems >= 1")
240
+ if lr < 0 or eps <= 0 or weight_decay < 0:
241
+ raise ValueError(f"invalid AdamW hyper-parameters lr={lr} eps={eps} wd={weight_decay}")
242
+ if not (0.0 <= betas[0] < 1.0 and 0.0 <= betas[1] < 1.0):
243
+ raise ValueError(f"betas must be in [0, 1), got {betas}")
244
+ if max_grad_norm is not None and not max_grad_norm > 0:
245
+ raise ValueError(f"max_grad_norm must be positive or None, got {max_grad_norm}")
246
+
247
+ self.block_size = block_size
248
+ self.min_8bit_size = min_8bit_size
249
+ self.chunk_blocks = max(1, chunk_elems // block_size)
250
+ self.stochastic_rounding = bool(stochastic_rounding)
251
+ self.seed = int(seed)
252
+ self.banner_tag = "adamw8bit"
253
+ self.skipped_steps = 0
254
+ self.last_grad_norm = float("nan")
255
+ #: One group, like OffloadAdamW: `Trainer._apply_scheduled_lr` writes
256
+ #: the scheduled lr into every group, and `step` reads it from here.
257
+ self.param_groups = [
258
+ {
259
+ "params": self.params,
260
+ "lr": float(lr),
261
+ "betas": (float(betas[0]), float(betas[1])),
262
+ "eps": float(eps),
263
+ "weight_decay": float(weight_decay),
264
+ "max_grad_norm": None if max_grad_norm is None else float(max_grad_norm),
265
+ }
266
+ ]
267
+ self._generators: dict[str, torch.Generator] = {}
268
+
269
+ self._state: list[dict] = []
270
+ for i, p in enumerate(self.params):
271
+ if not p.dtype.is_floating_point:
272
+ raise ValueError(f"param {i} has non-floating dtype {p.dtype}")
273
+ local = self._localize(p)
274
+ if not local.is_contiguous():
275
+ raise ValueError(
276
+ f"param {i} (local shape {tuple(local.shape)}) is not contiguous: the "
277
+ "blockwise state is laid out over the flat local shard. FSDP2 dim-0 "
278
+ "shards are contiguous; a transposed/strided view is not supported."
279
+ )
280
+ self._state.append(self._init_slot(local))
281
+
282
+ logger.info(
283
+ "BlockwiseAdamW8bit: %d tensors, %.2fM local elements, state %.3fGiB "
284
+ "(block %d, min_8bit_size %d, stochastic_rounding=%s)",
285
+ len(self.params),
286
+ sum(s["numel"] for s in self._state) / 1e6,
287
+ self.state_bytes() / 2**30,
288
+ block_size,
289
+ min_8bit_size,
290
+ self.stochastic_rounding,
291
+ )
292
+
293
+ # -- layout --------------------------------------------------------------
294
+
295
+ @staticmethod
296
+ def _localize(t):
297
+ """This rank's local shard of an FSDP2 DTensor (a storage-sharing
298
+ view), or `t` itself. Identical contract to OffloadAdamW._localize."""
299
+ return t.to_local() if hasattr(t, "to_local") else t
300
+
301
+ def _is_quantized(self, numel: int) -> bool:
302
+ return numel >= self.min_8bit_size and numel > 0
303
+
304
+ def _init_slot(self, local: torch.Tensor) -> dict:
305
+ n = local.numel()
306
+ dev = local.device
307
+ slot: dict = {"numel": n, "step": 0, "quantized": self._is_quantized(n)}
308
+ if slot["quantized"]:
309
+ nb = -(-n // self.block_size)
310
+ # Codes are allocated in WHOLE blocks (the last block is padded with
311
+ # the zero code) so every chunk is a clean [blocks, block_size] row
312
+ # matrix; the pad costs < one block per tensor.
313
+ slot["m_codes"] = torch.full(
314
+ (nb, self.block_size), zero_code(True), dtype=torch.uint8, device=dev
315
+ )
316
+ slot["v_codes"] = torch.full(
317
+ (nb, self.block_size), zero_code(False), dtype=torch.uint8, device=dev
318
+ )
319
+ slot["m_absmax"] = torch.zeros(nb, dtype=torch.float32, device=dev)
320
+ slot["v_absmax"] = torch.zeros(nb, dtype=torch.float32, device=dev)
321
+ else:
322
+ slot["m"] = torch.zeros(n, dtype=torch.float32, device=dev)
323
+ slot["v"] = torch.zeros(n, dtype=torch.float32, device=dev)
324
+ return slot
325
+
326
+ @staticmethod
327
+ def projected_state_bytes(
328
+ local_numels: Sequence[int],
329
+ block_size: int = DEFAULT_BLOCK_SIZE,
330
+ min_8bit_size: int = DEFAULT_MIN_8BIT_SIZE,
331
+ ) -> int:
332
+ """Exact optimizer-state bytes for these RANK-LOCAL element counts.
333
+
334
+ Large tensors: `2*n + 8*n/block_size` (two uint8 code arrays plus two
335
+ fp32 absmax arrays), with `n` rounded up to whole blocks because that
336
+ is how the codes are allocated. Small tensors: 8*n (two fp32 moments).
337
+ Computable BEFORE construction, so a launch banner or preflight can
338
+ state the number without allocating it.
339
+ """
340
+ total = 0
341
+ for n in local_numels:
342
+ if n >= min_8bit_size and n > 0:
343
+ nb = -(-n // block_size)
344
+ total += 2 * nb * block_size + 8 * nb
345
+ else:
346
+ total += 8 * n
347
+ return int(total)
348
+
349
+ def state_bytes(self) -> int:
350
+ """Bytes actually held by this optimizer's state tensors on this rank."""
351
+ total = 0
352
+ for s in self._state:
353
+ for k, v in s.items():
354
+ if isinstance(v, torch.Tensor):
355
+ total += v.numel() * v.element_size()
356
+ return total
357
+
358
+ def describe_placement(self) -> dict:
359
+ """Where the state lives (device/dtype/bytes), for the banner and the
360
+ placement test -- a 'the state is 8-bit' claim nobody checks is how it
361
+ quietly stops being true."""
362
+ ents = []
363
+ for s in self._state:
364
+ for k, v in s.items():
365
+ if isinstance(v, torch.Tensor):
366
+ ents.append(
367
+ {"key": k, "device": v.device.type, "dtype": str(v.dtype),
368
+ "bytes": v.numel() * v.element_size()}
369
+ )
370
+ return {"state": ents, "state_bytes": self.state_bytes()}
371
+
372
+ # -- rounding RNG --------------------------------------------------------
373
+
374
+ def _generator(self, device: torch.device) -> torch.Generator:
375
+ key = device.type
376
+ gen = self._generators.get(key)
377
+ if gen is None:
378
+ gen = torch.Generator(device=device)
379
+ gen.manual_seed(self.seed + rank())
380
+ self._generators[key] = gen
381
+ return gen
382
+
383
+ def _write_param(self, dst: torch.Tensor, src32: torch.Tensor) -> None:
384
+ """Write the fp32 result into the (possibly bf16) parameter chunk."""
385
+ if dst.dtype == torch.float32:
386
+ if dst.data_ptr() != src32.data_ptr():
387
+ dst.copy_(src32)
388
+ elif dst.dtype == torch.bfloat16 and self.stochastic_rounding:
389
+ dst.copy_(stochastic_round_to_bf16(src32, self._generator(dst.device)))
390
+ else:
391
+ dst.copy_(src32)
392
+
393
+ # -- the step ------------------------------------------------------------
394
+
395
+ @torch.no_grad()
396
+ def step(self) -> bool:
397
+ """One optimizer step, or a refusal.
398
+
399
+ Order is load-bearing: the (global) norm / finiteness verdict is reached
400
+ BEFORE any moment is touched, so a skipped step leaves the state
401
+ byte-identical. A discovered-too-late NaN would already have poisoned
402
+ the second moment, and every later step is scaled by it.
403
+ """
404
+ grads = [None if p.grad is None else self._localize(p.grad) for p in self.params]
405
+ present = [g for g in grads if g is not None]
406
+ if not present:
407
+ raise RuntimeError(
408
+ "BlockwiseAdamW8bit.step() called with no gradients on any parameter. "
409
+ "A step that silently does nothing is indistinguishable from a run "
410
+ "that trained."
411
+ )
412
+ for i, (g, s) in enumerate(zip(grads, self._state)):
413
+ if g is not None and g.numel() != s["numel"]:
414
+ raise ValueError(
415
+ f"param {i}: local grad has {g.numel()} elements but the state was "
416
+ f"built for {s['numel']} (grad of a different shard layout?)"
417
+ )
418
+
419
+ norm = OffloadAdamW._grad_norm(present)
420
+ if not math.isfinite(norm):
421
+ self.skipped_steps += 1
422
+ # NaN, not 0.0: a 0.0 in the log reads as 'perfectly converged'.
423
+ self.last_grad_norm = float("nan")
424
+ logger.warning(
425
+ "grad-skip gate: non-finite gradient, skipping optimizer step "
426
+ "(%d skipped so far). Moments and step counts left untouched.",
427
+ self.skipped_steps,
428
+ )
429
+ return False
430
+ self.last_grad_norm = norm # PRE-clip, on purpose
431
+
432
+ group = self.param_groups[0]
433
+ coef = 1.0
434
+ max_norm = group["max_grad_norm"]
435
+ if max_norm is not None:
436
+ coef = min(1.0, max_norm / (norm + 1e-6))
437
+
438
+ for p, g, st in zip(self.params, grads, self._state):
439
+ if g is None or st["numel"] == 0:
440
+ continue
441
+ st["step"] += 1
442
+ local = self._localize(p).detach().view(-1)
443
+ gflat = g.detach().reshape(-1)
444
+ if st["quantized"]:
445
+ self._step_quantized(local, gflat, st, group, coef)
446
+ else:
447
+ self._step_fp32(local, gflat, st, group, coef)
448
+ return True
449
+
450
+ def _hyper(self, group: dict, st: dict) -> dict:
451
+ return dict(
452
+ lr=group["lr"],
453
+ beta1=group["betas"][0],
454
+ beta2=group["betas"][1],
455
+ eps=group["eps"],
456
+ weight_decay=group["weight_decay"],
457
+ step=st["step"],
458
+ )
459
+
460
+ def _step_fp32(self, pflat, gflat, st, group, coef) -> None:
461
+ g32 = gflat.to(torch.float32)
462
+ if coef < 1.0:
463
+ g32 = g32 * coef # out of place: g32 may alias the live .grad
464
+ p32 = pflat.to(torch.float32) # aliases pflat when it is already fp32
465
+ adamw_update_(p32, g32, st["m"], st["v"], **self._hyper(group, st))
466
+ self._write_param(pflat, p32)
467
+
468
+ def _step_quantized(self, pflat, gflat, st, group, coef) -> None:
469
+ B = self.block_size
470
+ n = st["numel"]
471
+ n_blocks = st["m_codes"].shape[0]
472
+ cb_m = codebook(True, pflat.device)
473
+ cb_v = codebook(False, pflat.device)
474
+ hyper = self._hyper(group, st)
475
+ for b0 in range(0, n_blocks, self.chunk_blocks):
476
+ b1 = min(n_blocks, b0 + self.chunk_blocks)
477
+ lo, hi = b0 * B, min(n, b1 * B)
478
+ ln = hi - lo
479
+ g32 = gflat[lo:hi].to(torch.float32)
480
+ if coef < 1.0:
481
+ g32 = g32 * coef
482
+ m_full = dequantize_rows(st["m_codes"][b0:b1], st["m_absmax"][b0:b1], cb_m).view(-1)
483
+ v_full = dequantize_rows(st["v_codes"][b0:b1], st["v_absmax"][b0:b1], cb_v).view(-1)
484
+ pchunk = pflat[lo:hi]
485
+ p32 = pchunk.to(torch.float32)
486
+ # [:ln] are views of the block-padded buffers: the pad tail is never
487
+ # touched by the update (it stays exactly 0), then the whole padded
488
+ # rows are requantized.
489
+ adamw_update_(p32, g32, m_full[:ln], v_full[:ln], **hyper)
490
+ self._write_param(pchunk, p32)
491
+ # THE V-OVERFLOW POISON (adversarial review, 2026-10-12). The finiteness
492
+ # gate in `step` tests the NORM, which is accumulated in float64 and so
493
+ # stays finite for a gradient the fp32 SQUARE cannot hold: one element of
494
+ # 1e21 gives v = (1-beta2)*g^2 = inf. torch.optim.AdamW survives that
495
+ # (m / (inf + eps) == 0), but here inf becomes the block's absmax, the
496
+ # requantize divides inf/inf = NaN, the NaN lands in the uint8 codes,
497
+ # and the block's weights go NaN on the NEXT step, with the gate seeing
498
+ # only finite gradients. Clamping v to the largest finite fp32 keeps the
499
+ # update identical (m / 1.8e19 is still 0 for any sane m) and the stored
500
+ # state finite. Only v can overflow: m is a convex mix of finite values.
501
+ v_full.clamp_(max=_FP32_MAX)
502
+ codes, absmax = quantize_rows(m_full.view(-1, B), cb_m)
503
+ st["m_codes"][b0:b1] = codes
504
+ st["m_absmax"][b0:b1] = absmax
505
+ codes, absmax = quantize_rows(v_full.view(-1, B), cb_v)
506
+ st["v_codes"][b0:b1] = codes
507
+ st["v_absmax"][b0:b1] = absmax
508
+
509
+ def zero_grad(self, set_to_none: bool = True) -> None:
510
+ for p in self.params:
511
+ if set_to_none:
512
+ p.grad = None
513
+ elif p.grad is not None:
514
+ p.grad.zero_()
515
+
516
+ # -- resume --------------------------------------------------------------
517
+
518
+ def state_dict(self) -> dict:
519
+ """A DETACHED CPU COPY of everything the next step depends on.
520
+
521
+ `.to("cpu", copy=True)`, not `.to("cpu")`: on a CPU tensor the latter
522
+ returns the SAME object, and a "snapshot" that aliases the live codes
523
+ is mutated by the very next step -- the shared-state aliasing incident
524
+ OffloadAdamW documents, here with uint8 codes that no comparison would
525
+ flag as drifting. Everything is copied so the dict is safe to pickle
526
+ straight into the checkpoint gather.
527
+ """
528
+ slots = []
529
+ for s in self._state:
530
+ slots.append(
531
+ {
532
+ k: (v.detach().to("cpu", copy=True) if isinstance(v, torch.Tensor) else v)
533
+ for k, v in s.items()
534
+ }
535
+ )
536
+ group = self.param_groups[0]
537
+ return {
538
+ "format": STATE_FORMAT,
539
+ "world_size": world_size(),
540
+ "block_size": self.block_size,
541
+ "min_8bit_size": self.min_8bit_size,
542
+ "stochastic_rounding": self.stochastic_rounding,
543
+ "param_group": {k: v for k, v in group.items() if k != "params"},
544
+ "state": slots,
545
+ "skipped_steps": self.skipped_steps,
546
+ "last_grad_norm": self.last_grad_norm,
547
+ "sr_rng": {k: g.get_state().clone() for k, g in self._generators.items()},
548
+ }
549
+
550
+ def load_state_dict(self, sd: dict) -> None:
551
+ """Restore the state byte for byte. Validates EVERYTHING before writing
552
+ anything, so a refused load leaves the optimizer exactly as it was."""
553
+ if sd.get("format") != STATE_FORMAT:
554
+ raise ValueError(
555
+ f"optimizer state format {sd.get('format')!r} is not {STATE_FORMAT!r}; "
556
+ "refusing to interpret it (a 32-bit or bnb state is not loadable here)"
557
+ )
558
+ saved_ws, now_ws = sd.get("world_size"), world_size()
559
+ if saved_ws is not None and saved_ws != now_ws:
560
+ raise ValueError(
561
+ f"this optimizer state was saved at world_size={saved_ws} but the current "
562
+ f"run has world_size={now_ws}. The 8-bit moments are SHARD-shaped: loading "
563
+ "them under a different world size would align each rank's state to a "
564
+ "different slice of the parameter, and training would continue plausibly "
565
+ "on corrupted state. Resume at the original world size, or start fresh."
566
+ )
567
+ if sd["block_size"] != self.block_size or sd["min_8bit_size"] != self.min_8bit_size:
568
+ raise ValueError(
569
+ f"state was built with block_size={sd['block_size']}, min_8bit_size="
570
+ f"{sd['min_8bit_size']} but this optimizer has {self.block_size}, "
571
+ f"{self.min_8bit_size}: the absmax blocks would not line up"
572
+ )
573
+ saved = sd["state"]
574
+ if len(saved) != len(self._state):
575
+ raise ValueError(
576
+ f"checkpoint holds {len(saved)} tensors but this optimizer has {len(self._state)}"
577
+ )
578
+ for i, (cur, new) in enumerate(zip(self._state, saved)):
579
+ if cur["numel"] != new["numel"] or cur["quantized"] != new["quantized"]:
580
+ raise ValueError(
581
+ f"tensor {i}: checkpoint has numel={new['numel']} quantized="
582
+ f"{new['quantized']}, this optimizer has numel={cur['numel']} quantized="
583
+ f"{cur['quantized']} (a different shard layout)"
584
+ )
585
+ for k, v in cur.items():
586
+ if isinstance(v, torch.Tensor):
587
+ nv = new.get(k)
588
+ if nv is None or nv.shape != v.shape or nv.dtype != v.dtype:
589
+ raise ValueError(
590
+ f"tensor {i} field {k!r}: checkpoint "
591
+ f"{None if nv is None else (tuple(nv.shape), nv.dtype)} vs "
592
+ f"expected {(tuple(v.shape), v.dtype)}"
593
+ )
594
+ if bool(sd.get("stochastic_rounding", self.stochastic_rounding)) != self.stochastic_rounding:
595
+ logger.warning(
596
+ "resuming with stochastic_rounding=%s but the checkpoint was written with %s: "
597
+ "the trajectory will differ from an uninterrupted run",
598
+ self.stochastic_rounding,
599
+ sd.get("stochastic_rounding"),
600
+ )
601
+
602
+ # The rounding RNG is part of the state being restored. A generator that
603
+ # already exists here (this optimizer has stepped) and is ABSENT from `sd`
604
+ # (saved before any bf16 step, or from a fp32 run) would otherwise keep its
605
+ # advanced stream, and the rewound optimizer would round differently from an
606
+ # uninterrupted one -- "byte for byte" quietly untrue (adversarial review,
607
+ # 2026-10-12). Dropping them re-seeds lazily from `seed + rank`, which is
608
+ # exactly the state a never-stepped optimizer has.
609
+ self._generators.clear()
610
+
611
+ # copy_ INTO the existing tensors: keeps their device, and means loading
612
+ # one checkpoint dict into two optimizers cannot make them share state.
613
+ for cur, new in zip(self._state, saved):
614
+ for k, v in cur.items():
615
+ if isinstance(v, torch.Tensor):
616
+ v.copy_(new[k])
617
+ cur["step"] = int(new["step"])
618
+ group = self.param_groups[0]
619
+ for k, v in sd["param_group"].items():
620
+ group[k] = tuple(v) if k == "betas" else v
621
+ self.skipped_steps = int(sd.get("skipped_steps", 0))
622
+ self.last_grad_norm = float(sd.get("last_grad_norm", float("nan")))
623
+ for key, st in sd.get("sr_rng", {}).items():
624
+ first = self.params[0].device
625
+ gen = self._generator(first if first.type == key else torch.device(key))
626
+ gen.set_state(st.detach().to("cpu", copy=True))
627
+
628
+
629
+ #: The name the factory and docs use.
630
+ AdamW8bit = BlockwiseAdamW8bit
code/align/train/ckpt_format.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Weight-file formats the Trainer can write and read: the model-family seam.
2
+
3
+ Why this exists. `Trainer.save_checkpoint` / `resume_from` called the dense
4
+ Mistral converters (`save_nmo_as_hf`, `hf_to_nmo(expected_n_layers=
5
+ cfg.model.n_layers)`) directly. Those tables know the 7B dense key names and
6
+ nothing about routers or stacked 3-D expert tensors, so an OLMoE run could be
7
+ built and stepped but never checkpointed -- the first save would raise on a
8
+ key it cannot map (or, worse for a model family added later, silently drop
9
+ parameters if a converter ever became lenient). The seam makes the family an
10
+ explicit, injected choice with ONE contract:
11
+
12
+ save_weights(align_state_dict, out_dir, dtype) -> writes model weights +
13
+ config.json, atomically
14
+ load_weights(path) -> align state dict
15
+
16
+ and nothing else about the checkpoint changes: optimizer state, `resume.pt`,
17
+ fsync/rename/completeness all stay in the Trainer and are family-independent.
18
+
19
+ The default is the dense format built from `cfg.model`, so every existing
20
+ caller is unchanged. A family is selected by construction (`Trainer(...,
21
+ weight_format=OlmoeFormat(olmoe_cfg))`), never sniffed from the state dict:
22
+ guessing the family from key names is how a misconfigured run loads the wrong
23
+ converter and half-works.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from typing import Any, Mapping, Optional, Protocol
29
+
30
+ import torch
31
+
32
+
33
+ class WeightFormat(Protocol):
34
+ def save_weights(
35
+ self, state: Mapping[str, torch.Tensor], out_dir: str, dtype: Optional[torch.dtype]
36
+ ) -> None: ...
37
+
38
+ def load_weights(self, path: str) -> dict[str, torch.Tensor]: ...
39
+
40
+ def copy_tokenizer(self, snapshot: str, dst_dir: str) -> list[str]:
41
+ """Copy the tokenizer files that belong to THIS family into `dst_dir`;
42
+ return the names copied (empty = weights-only checkpoint)."""
43
+ ...
44
+
45
+
46
+ class DenseFormat:
47
+ """The original behaviour: dense Mistral-family HF checkpoints."""
48
+
49
+ def __init__(self, model_cfg: Any):
50
+ self.model_cfg = model_cfg
51
+
52
+ def save_weights(self, state, out_dir, dtype):
53
+ from ..model import checkpoint as ckpt
54
+
55
+ ckpt.save_nmo_as_hf(dict(state), self.model_cfg, out_dir, dtype=dtype)
56
+
57
+ def load_weights(self, path):
58
+ from ..model import checkpoint as ckpt
59
+
60
+ return ckpt.hf_to_nmo(
61
+ ckpt.load_hf_safetensors(path), expected_n_layers=self.model_cfg.n_layers
62
+ )
63
+
64
+ def copy_tokenizer(self, snapshot, dst_dir):
65
+ from ..model import checkpoint as ckpt
66
+
67
+ return ckpt.copy_tokenizer_files(snapshot, dst_dir)
68
+
69
+
70
+ #: Files that make up an HF tokenizer, in either of the layouts OLMoE-class
71
+ #: models ship (BPE `tokenizer.json`, or `vocab.json` + `merges.txt`).
72
+ OLMOE_TOKENIZER_FILES = (
73
+ "tokenizer.json",
74
+ "tokenizer_config.json",
75
+ "special_tokens_map.json",
76
+ "vocab.json",
77
+ "merges.txt",
78
+ "tokenizer.model",
79
+ "generation_config.json",
80
+ )
81
+
82
+
83
+ class OlmoeFormat:
84
+ """OLMoE-architecture checkpoints (routers + stacked experts), HF-compatible.
85
+
86
+ TOKENIZER. The dense path copies the pinned Mistral v0.3 tokenizer into
87
+ every checkpoint (same vocab by construction); doing that for an OLMoE
88
+ checkpoint would stamp a 32768-token tokenizer onto a 50304-token model --
89
+ loads fine, tokenizes wrong. So this format copies tokenizer files only
90
+ from an EXPLICIT `tokenizer_dir`, and without one writes a weights-only
91
+ checkpoint (and says so), never a guess.
92
+ """
93
+
94
+ def __init__(self, olmoe_cfg: Any, tokenizer_dir: Optional[str] = None):
95
+ self.olmoe_cfg = olmoe_cfg
96
+ self.tokenizer_dir = tokenizer_dir
97
+
98
+ def save_weights(self, state, out_dir, dtype):
99
+ from ..model.olmoe_checkpoint import save_olmoe_as_hf
100
+
101
+ save_olmoe_as_hf(dict(state), self.olmoe_cfg, out_dir, dtype=dtype)
102
+
103
+ def load_weights(self, path):
104
+ from ..model.olmoe_checkpoint import hf_to_align
105
+ from ..model.checkpoint import load_hf_safetensors
106
+
107
+ return hf_to_align(load_hf_safetensors(path), self.olmoe_cfg)
108
+
109
+ def copy_tokenizer(self, snapshot, dst_dir): # noqa: ARG002 -- the dense snapshot is not ours
110
+ import os
111
+ import shutil
112
+
113
+ if not self.tokenizer_dir:
114
+ return []
115
+ copied = []
116
+ for name in OLMOE_TOKENIZER_FILES:
117
+ src = os.path.join(self.tokenizer_dir, name)
118
+ if os.path.isfile(src):
119
+ shutil.copyfile(src, os.path.join(dst_dir, name))
120
+ copied.append(name)
121
+ return copied
122
+
123
+
124
+ __all__ = ["WeightFormat", "DenseFormat", "OlmoeFormat"]
code/align/train/dpo_loss.py ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DPO loss adapter: paired batch -> section-05's chunked fused DPO loss.
2
+
3
+ Mirror of `sft_loss.py`: this module owns plumbing only -- unpacking the paired
4
+ batch, the caller-side label shift, and packaging metrics -- while the kernel in
5
+ `align.kernels.fused_dpo` owns the objective.
6
+
7
+ The one thing worth stating here rather than in the kernel: the reference
8
+ log-probabilities arrive as PRECOMPUTED CONSTANTS, produced offline by
9
+ section-12 in a single no-grad pass over the SFT checkpoint. There is no
10
+ reference model resident at DPO time, which is what makes 7B DPO fit on 4x A10G
11
+ at all. The kernel detaches them on entry and a test proves no gradient path
12
+ survives; this adapter's job is simply never to build one, so it refuses a
13
+ batch that does not carry them rather than silently substituting zeros -- a
14
+ zero reference turns DPO into plain likelihood maximisation on the chosen side,
15
+ which trains, converges, and is not DPO.
16
+
17
+ `DpoLossConfig` is the trainer-facing default surface. Note `token_chunk_size`:
18
+ the pre-registered config is micro_batch 1 x seq 1024, i.e. ONE pair per step,
19
+ and at one pair the pair-chunking does nothing at all. Leaving it None there
20
+ would silently restore the [1024, 32768] logit tensor the fused loss exists to
21
+ remove, so the trainer-facing default sets it.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ from dataclasses import dataclass
28
+ from typing import Any, Optional
29
+
30
+ import torch
31
+
32
+ from .trainer import LossOutput
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ #: Section-05's kernel module, named so the both-directions test can block the
37
+ #: import and exercise the refusal branch (same pattern as `sft_loss`).
38
+ FUSED_DPO_MODULE = "align.kernels.fused_dpo"
39
+
40
+ #: Fallback only; the authority is the kernel's constant and section-03's
41
+ #: collator, which both use -100.
42
+ IGNORE_INDEX = -100
43
+
44
+
45
+ @dataclass
46
+ class DpoLossConfig:
47
+ """Trainer-facing DPO loss settings. Section-12 nests this in its stage
48
+ config; every default that encodes a decision says why."""
49
+
50
+ #: Preference sharpness. 0.1 is the pre-registered value for both arms.
51
+ beta: float = 0.1
52
+ #: One of align.kernels.fused_dpo.DPO_VARIANTS. `dpop` is the pre-registered
53
+ #: tripwire RESPONSE for logps/chosen declining while the margin rises; it
54
+ #: is not the primary configuration and switching to it is a documented
55
+ #: deviation, not a tuning knob.
56
+ variant: str = "sigmoid"
57
+ #: Pairs per chunk.
58
+ chunk_size: int = 1
59
+ #: Tokens per inner chunk. NOT None: at micro_batch 1 there is one pair per
60
+ #: step and pair-chunking alone would materialise the full logit tensor.
61
+ #: 128 rather than 256, DERIVED from `fused_ce.chunked_peak_bound` at the
62
+ #: 7B shapes (V 32768, H 4096, 2x1024 rows): 256 predicts a 192 MiB peak
63
+ #: against a 256 MiB budget (0.75, too tight to survive an allocator
64
+ #: fluctuation in the M5 smoke), while 128 predicts 128 MiB (0.50). The
65
+ #: matmul is still large enough to be efficient at 128 x 32768.
66
+ token_chunk_size: Optional[int] = 128
67
+ #: NLL auxiliary on the chosen completion. 0.0 is provably inert (the term
68
+ #: is skipped structurally, not multiplied by zero), so the primary
69
+ #: pre-registered run is unaffected by this field existing.
70
+ nll_coeff: float = 0.0
71
+ #: DPO-Positive penalty weight; read only when variant == "dpop".
72
+ dpop_lambda: float = 50.0
73
+
74
+
75
+ def _load_fused():
76
+ try:
77
+ import importlib
78
+
79
+ return importlib.import_module(FUSED_DPO_MODULE)
80
+ except Exception: # noqa: BLE001
81
+ return None
82
+
83
+
84
+ def fused_dpo_available() -> bool:
85
+ return _load_fused() is not None
86
+
87
+
88
+ def _unpack(batch: Any):
89
+ """Accept a paired batch object or a mapping. Six tensors are mandatory and
90
+ none of them has a defensible default."""
91
+ required = (
92
+ "input_ids_chosen",
93
+ "labels_chosen",
94
+ "input_ids_rejected",
95
+ "labels_rejected",
96
+ "ref_logp_chosen",
97
+ "ref_logp_rejected",
98
+ )
99
+ if isinstance(batch, dict):
100
+ got = {k: batch.get(k) for k in required}
101
+ else:
102
+ got = {k: getattr(batch, k, None) for k in required}
103
+ missing = [k for k, v in got.items() if v is None]
104
+ if missing:
105
+ raise TypeError(
106
+ f"DPO batch is missing {missing}. The reference log-probabilities "
107
+ "in particular have no default: substituting zeros would turn DPO "
108
+ "into likelihood maximisation on the chosen completion, which "
109
+ "trains and converges and is not DPO. Section-12's "
110
+ "precompute step produces them."
111
+ )
112
+ return got
113
+
114
+
115
+ def make_dpo_loss_adapter(
116
+ config: Optional[DpoLossConfig] = None,
117
+ require_fused: bool = False,
118
+ decoupled_backward: bool = False,
119
+ grad_scale: float = 1.0,
120
+ ):
121
+ """Build the DPO loss adapter the Trainer calls once per micro-step.
122
+
123
+ `require_fused=True` refuses the reference path, which is what the 7B stage
124
+ scripts pass: the eager reference materialises BOTH [P, T, 32768] logit
125
+ tensors and is a memory profile no A10G survives at 7B.
126
+
127
+ `decoupled_backward=True` (the 7B stage setting) backwards each side's
128
+ trunk separately so both checkpointed graphs are never resident at once;
129
+ `grad_scale` must then be 1/grad_accum, matching the scaling the Trainer
130
+ applies to the loss it backwards.
131
+ """
132
+ cfg = config or DpoLossConfig()
133
+ module = _load_fused()
134
+ if module is None:
135
+ if require_fused:
136
+ raise RuntimeError(
137
+ f"the fused chunked DPO loss ({FUSED_DPO_MODULE}."
138
+ "fused_dpo_loss) is not importable. Refusing to fall back to "
139
+ "the reference path for a full-scale run: it materialises both "
140
+ "[P, T, 32768] logit tensors and their gradients."
141
+ )
142
+ raise RuntimeError(
143
+ f"{FUSED_DPO_MODULE} is not importable and there is no reference "
144
+ "fallback worth offering for DPO at any useful scale."
145
+ )
146
+ if cfg.variant not in module.DPO_VARIANTS:
147
+ raise ValueError(f"unknown DPO variant {cfg.variant!r}; known: {list(module.DPO_VARIANTS)}")
148
+ ignore_index = module.IGNORE_INDEX
149
+
150
+ def _kernel(h_c, h_r, w, got, target_c, target_r):
151
+ return module.fused_dpo_loss(
152
+ h_c,
153
+ h_r,
154
+ w,
155
+ target_c,
156
+ target_r,
157
+ got["ref_logp_chosen"],
158
+ got["ref_logp_rejected"],
159
+ beta=cfg.beta,
160
+ variant=cfg.variant,
161
+ chunk_size=cfg.chunk_size,
162
+ nll_coeff=cfg.nll_coeff,
163
+ dpop_lambda=cfg.dpop_lambda,
164
+ ignore_index=ignore_index,
165
+ token_chunk_size=cfg.token_chunk_size,
166
+ )
167
+
168
+ def _full_w(model):
169
+ # FSDP2 params are sharded DTensors OUTSIDE a module __call__; passing
170
+ # one into the fused kernel's mm mixes Tensor and DTensor (the
171
+ # hook-bypass class, DPO edition). full_tensor() is the DIFFERENTIABLE
172
+ # all-gather: backward reduce-scatters d_weight onto the shards.
173
+ w = model.lm_head.weight
174
+ return w.full_tensor() if hasattr(w, "full_tensor") else w
175
+
176
+ def adapter(model, batch) -> LossOutput:
177
+ got = _unpack(batch)
178
+ # Caller-shifted, exactly as in sft_loss: position t predicts t+1.
179
+ target_c = got["labels_chosen"][:, 1:]
180
+ target_r = got["labels_rejected"][:, 1:]
181
+
182
+ if not decoupled_backward:
183
+ hidden_c = model.forward_hidden_states(input_ids=got["input_ids_chosen"])[:, :-1]
184
+ hidden_r = model.forward_hidden_states(input_ids=got["input_ids_rejected"])[:, :-1]
185
+ loss, metrics = _kernel(hidden_c, hidden_r, _full_w(model), got, target_c, target_r)
186
+ module.require_metrics(metrics)
187
+ n_tokens = int((target_c != ignore_index).sum() + (target_r != ignore_index).sum())
188
+ return LossOutput(loss=loss, n_tokens=n_tokens, metrics=dict(metrics))
189
+
190
+ # DECOUPLED BACKWARD -- one trunk graph at a time. Holding BOTH
191
+ # sides' checkpointed 7B graphs plus fp32 shard gradients peaked at
192
+ # 21.5GB and OOM'd every rank (measured; batch-size independent).
193
+ # DPO's coupling between the sides is one scalar per pair, so:
194
+ # 1. no-grad trunk forwards; the kernel runs over LEAF copies of
195
+ # the hiddens plus the gathered lm_head, and one backward on
196
+ # that small graph yields d_hidden per side analytically and
197
+ # accumulates the lm_head shard grad;
198
+ # 2. each side then re-forwards WITH grad and backwards against
199
+ # its saved d_hidden -- never two trunk graphs at once.
200
+ # The re-forward recomputes hiddens that differ from the no-grad
201
+ # pass only by kernel-dispatch noise (~1e-7 relative, the same class
202
+ # the batch-invariance receipt records); act-ckpt already relies on
203
+ # this determinism. The trainer's `(loss/accum).backward()` contract
204
+ # is honoured with a detached leaf proxy; the REAL grads are scaled
205
+ # here by grad_scale (1/accum), so nothing is double-counted.
206
+ with torch.no_grad():
207
+ h_c0 = model.forward_hidden_states(input_ids=got["input_ids_chosen"])[:, :-1]
208
+ h_r0 = model.forward_hidden_states(input_ids=got["input_ids_rejected"])[:, :-1]
209
+ h_c = h_c0.detach().requires_grad_(True)
210
+ h_r = h_r0.detach().requires_grad_(True)
211
+ loss, metrics = _kernel(h_c, h_r, _full_w(model), got, target_c, target_r)
212
+ module.require_metrics(metrics)
213
+ (loss * grad_scale).backward()
214
+ g_c, g_r = h_c.grad, h_r.grad
215
+ if g_c is None or g_r is None:
216
+ raise RuntimeError(
217
+ "decoupled DPO backward produced no hidden gradients; the "
218
+ "kernel did not differentiate w.r.t. the hidden leaves"
219
+ )
220
+ del h_c, h_r, h_c0, h_r0
221
+
222
+ hc = model.forward_hidden_states(input_ids=got["input_ids_chosen"])[:, :-1]
223
+ hc.backward(gradient=g_c)
224
+ del hc, g_c
225
+ hr = model.forward_hidden_states(input_ids=got["input_ids_rejected"])[:, :-1]
226
+ hr.backward(gradient=g_r)
227
+ del hr, g_r
228
+
229
+ n_tokens = int((target_c != ignore_index).sum() + (target_r != ignore_index).sum())
230
+ # Leaf proxy: the trainer's (loss/accum).backward() runs on THIS and
231
+ # touches no model gradient; the value is the real loss for the log.
232
+ proxy = loss.detach().clone().requires_grad_(True)
233
+ return LossOutput(loss=proxy, n_tokens=n_tokens, metrics=dict(metrics))
234
+
235
+ adapter.uses_fused = True
236
+ adapter.config = cfg
237
+ return adapter
code/align/train/dpo_stage.py ADDED
@@ -0,0 +1,1362 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DPO stage: config, entry gates, banner, tripwire response, arm manifest.
2
+
3
+ Section-12 is the second and last GPU training stage, run as TWO
4
+ pre-registered arms. Everything numerical belongs to other sections -- the
5
+ objective is section-05's `fused_dpo`, the loop is section-07's `Trainer`, the
6
+ tripwire DETECTORS are section-07's `probes.py`, the reference constants are
7
+ `ref_cache.py`. What lives here is the part that decides whether a run is
8
+ allowed to start and what its result is permitted to be.
9
+
10
+ That sounds like bureaucracy and is not. The predecessor project's expensive
11
+ failures were never wrong arithmetic; they were runs that completed, produced
12
+ publishable-looking numbers, and measured something other than what was
13
+ claimed. So:
14
+
15
+ * **Entry gates run in a fixed order and any failure blocks everything
16
+ after it, including the model load.** Ordered by cost, so a stage that was
17
+ never going to be valid dies in milliseconds instead of after putting
18
+ 14.5 GB onto a shared no-swap box.
19
+ * **The tripwire response is pre-registered.** `logps/chosen` declining
20
+ while the margin rises is the DPO chosen-likelihood-collapse mode, and it
21
+ is *maximal for Arm B by design*: pi_SFT already assigns the chosen
22
+ continuations high likelihood, so the optimizer's cheapest margin comes
23
+ from crushing both logprobs. The response -- rerun with `dpop` or the NLL
24
+ auxiliary, and record THAT as the arm's result -- was written down before
25
+ any run, and no third configuration is reachable without a new
26
+ pre-registration.
27
+ * **The primary metrics are margin and held-out preference accuracy**, not
28
+ benchmark accuracy. The pre-registered behavioural expectations are Arm A
29
+ within +-2 pp of SFT (below floor is the EXPECTED outcome) and Arm B's
30
+ honest range INCLUDING ZERO. Section-13 owns the paired benchmark
31
+ evaluation; a stage that graded itself on benchmarks would be choosing its
32
+ own success criterion after seeing the data.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import collections
38
+ import json
39
+ import logging
40
+ import math
41
+ import os
42
+ from dataclasses import dataclass, field, fields, replace
43
+ from typing import Any, Callable, Mapping, Optional, Sequence
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+ NAN = float("nan")
48
+
49
+ #: Entry gates, in the order they run. Ordered by COST: the cheap file reads
50
+ #: come first so an invalid stage fails before it touches a GPU. Data, so the
51
+ #: test iterates it instead of copying a list.
52
+ GATE_ORDER: tuple[str, ...] = (
53
+ "sft_quality_gate", # section-11 ran and passed its own gate
54
+ "build_manifest", # shard counts match the pinned values; Arm A DESCOPED
55
+ "ref_cache_key", # the reference constants belong to THIS run
56
+ "path_bias", # measured, and its decision honored
57
+ "throughput_probe", # 20 steps -> banner wall-clock (never asserted ahead)
58
+ "rss_preflight", # host RSS headroom for the full-FT job class
59
+ )
60
+
61
+ #: Banner keys this stage adds on top of `registry.MANDATORY_BANNER_KEYS`.
62
+ #:
63
+ #: Deliberately NOT registered into the global registry at import time.
64
+ #: `registry.register_mandatory_key` mutates a module-level list shared by
65
+ #: every script in the process; importing this module would then make an
66
+ #: unrelated banner (trace, generate) incomplete. The contract is identical --
67
+ #: a missing key raises, a partial banner never prints -- it is just enforced
68
+ #: without a global side effect.
69
+ DPO_BANNER_KEYS: tuple[str, ...] = (
70
+ "arm",
71
+ "loss_type",
72
+ "beta",
73
+ "variant",
74
+ "lr",
75
+ "epochs",
76
+ "global_batch",
77
+ "grad_accum",
78
+ "expected_pairs",
79
+ "optimizer_step_formula",
80
+ "scheduler_horizon_optimizer_steps",
81
+ "dataset_hashes",
82
+ "ref_cache_key",
83
+ "path_bias",
84
+ "probe_step_time_s",
85
+ "probe_derived_wall_clock_h",
86
+ "rss_budget",
87
+ "attention_backend",
88
+ "length_drift_baseline",
89
+ "length_drift_baseline_source",
90
+ "sft_checkpoint",
91
+ "pre_registration",
92
+ )
93
+
94
+ #: The only configurations the pre-registration permits when the
95
+ #: chosen-logprob tripwire fires. Anything else requires a new dated entry in
96
+ #: docs/RESULTS.md written BEFORE the rerun, not after seeing it.
97
+ #:
98
+ #: `dpop_nll_aux` is the default because it is what the frozen
99
+ #: pre-registration literally says: "rerun THAT arm once with dpop + NLL
100
+ #: auxiliary". The section-12 spec phrases the same response as "the `dpop`
101
+ #: variant OR the NLL auxiliary", so the two single-knob forms are kept
102
+ #: available -- but docs/RESULTS.md is the frozen document and the conjunction
103
+ #: is what it froze, so the conjunction is the default. (Discrepancy raised
104
+ #: with the lead rather than resolved silently in either direction.)
105
+ PRE_REGISTERED_RESPONSES: tuple[str, ...] = ("dpop_nll_aux", "dpop", "nll_aux")
106
+
107
+ #: NLL auxiliary weight used when the response is `nll_aux`. Small: the term
108
+ #: exists to put a floor under the chosen logprob, not to turn DPO into SFT.
109
+ NLL_AUX_COEFF = 0.1
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # Config
114
+ # ---------------------------------------------------------------------------
115
+
116
+
117
+ @dataclass
118
+ class DpoStageConfig:
119
+ """Stage settings for one arm. Per-field docstrings, per repo convention:
120
+ the config file is where someone will try to change these at 2am."""
121
+
122
+ #: `arm_a` (argilla, response-level, 2,310 pairs) or `arm_b` (curated
123
+ #: PRM800K shared-prefix step-local pairs). Selects everything else.
124
+ arm: str = "arm_a"
125
+ #: Section-11's SFT checkpoint. Both arms fine-tune from the SAME one, so
126
+ #: any arm-vs-arm difference is the preference data, not the start point.
127
+ sft_checkpoint: str = ""
128
+ #: Section-11's quality-gate receipt. The stage refuses to launch unless
129
+ #: it exists AND records a pass.
130
+ sft_quality_gate_path: str = ""
131
+ #: Pair shards for this arm (train split) and the problem-level held-out
132
+ #: split used for preference accuracy.
133
+ shard_dir: str = ""
134
+ heldout_shard_dir: str = ""
135
+ #: Reference-logprob caches written by scripts/precompute_ref_logprobs.py.
136
+ ref_cache_path: str = ""
137
+ heldout_ref_cache_path: str = ""
138
+ #: The mandatory pre-launch path-bias measurement's report.
139
+ path_bias_report_path: str = ""
140
+ #: Section-03's build manifest. Its hash is part of the ref-cache key, so
141
+ #: a shard rebuild cannot leave a stale cache looking valid.
142
+ manifest_path: str = "/apps/development/rlhf/align/data/manifest.json"
143
+ #: docs/RESULTS.md must already carry this arm's pre-registration
144
+ #: (expectations + tripwire response) before the run starts.
145
+ #: The pre-registration is a FROZEN, dated document that lives with the
146
+ #: predecessor experiment record (read-only; the gate only greps it).
147
+ #: Copying it would fork the thing that is supposed to be frozen, so the
148
+ #: default stays on the original until it is migrated deliberately.
149
+ results_doc: str = "/apps/development/rlhf/nano-mistral-o1/docs/RESULTS.md"
150
+ run_dir: str = ""
151
+
152
+ #: Post-filter pair count for this arm, from the build manifest. Drives
153
+ #: the optimizer-step horizon, so it is a measured number, never a guess.
154
+ expected_pairs: int = 2310
155
+
156
+ # -- objective ---------------------------------------------------------
157
+ #: Pre-registered for both arms. `dpop`/`nll_aux` are reachable only as
158
+ #: the tripwire RESPONSE, and switching is a recorded deviation.
159
+ variant: str = "sigmoid"
160
+ #: Preference sharpness. 0.1, both arms, pre-registered.
161
+ beta: float = 0.1
162
+ #: NLL auxiliary weight. 0.0 is provably inert in section-05's kernel (the
163
+ #: term is skipped structurally, not multiplied by zero).
164
+ nll_coeff: float = 0.0
165
+ dpop_lambda: float = 50.0
166
+ #: Which pre-registered response to apply if the chosen-logprob tripwire
167
+ #: fires. Defaults to the conjunction docs/RESULTS.md froze; the two
168
+ #: single-knob forms are permitted, anything else raises.
169
+ tripwire_response: str = "dpop_nll_aux"
170
+
171
+ # -- model -------------------------------------------------------------
172
+ #: Attention backend for the policy model. NEVER handed raw to a
173
+ #: constructor: `NmoMistralForCausalLM.__init__` routes it through
174
+ #: `align.model.attention.select_attn_implementation`, which RAISES if the
175
+ #: request cannot be honoured -- for `triton_fa2` that means section-06's
176
+ #: equivalence-gate receipt must be valid for THIS kernel source on THIS
177
+ #: GPU. The pre-registered value for BOTH arms is `triton_fa2`
178
+ #: (docs/RESULTS.md dated entry, 2026-08-17); the neutral default here is
179
+ #: sdpa so the pin lives in the arm configs, where changing it is a
180
+ #: visible diff against the pre-registration. Membership is validated at
181
+ #: config load: a typo'd backend raises at launch, in milliseconds, not
182
+ #: as a selector error with four GPUs claimed.
183
+ attn_implementation: str = "sdpa"
184
+
185
+ # -- optimization ------------------------------------------------------
186
+ #: 5e-7, pre-registered. Two orders below SFT's: DPO moves a model that is
187
+ #: already where it should be.
188
+ lr: float = 5e-7
189
+ #: 2 pre-registered, 3 maximum.
190
+ epochs: int = 2
191
+ #: ~32 via gradient accumulation (e.g. 4 ranks x micro 2 x accum 4).
192
+ global_batch: int = 32
193
+ #: >= 4 is MANDATORY for full-FT with offloaded AdamW (section-07 refuses
194
+ #: less): at accum 1 the CPU optimizer step dominates wall-clock.
195
+ grad_accum: int = 4
196
+ micro_batch_size: int = 2
197
+ num_workers: int = 2
198
+ save_every_micro_steps: int = 100
199
+ keep_checkpoints: int = 2
200
+ #: Each rank writes its own optimizer_rank{r}.pt instead of gathering the
201
+ #: full optimizer state (see TrainConfig.per_rank_optimizer_state). Without
202
+ #: this field the key was REJECTED by struct mode in DPO TOMLs and, worse,
203
+ #: scripts/dpo.py built its RunConfig without ever setting it -- so the
204
+ #: trainer-level flag was unreachable from a DPO run. Default False keeps
205
+ #: the gathered layout; a resume at a different world_size is refused by
206
+ #: the trainer when True.
207
+ per_rank_optimizer_state: bool = False
208
+
209
+ #: Global-norm gradient clip (OptimizerConfig.max_grad_norm), forwarded by
210
+ #: scripts/dpo.py::_run_config. Declared here because the DPO RunConfig is
211
+ #: built from defaults: before this field the clip could not be stated in a
212
+ #: DPO TOML at all, only inherited. The recorded grad norm stays PRE-clip.
213
+ max_grad_norm: Optional[float] = 1.0
214
+
215
+ # -- run-health bounds (G0), forwarded to TrainConfig by the stage script's
216
+ # _run_config: a field that is not copied trains at the default while the
217
+ # TOML says otherwise. Semantics: align/config.py::TrainConfig.
218
+ max_wall_seconds: Optional[float] = None
219
+ max_consecutive_skipped_steps: int = 3
220
+ max_skipped_fraction: float = 0.05
221
+
222
+ # -- measurement -------------------------------------------------------
223
+ #: Fires the path-bias rerun when max|delta ref-margin| exceeds this
224
+ #: fraction of the early-margin scale. 0.05 = a 5% perturbation of the
225
+ #: quantity DPO reads. Chosen as a threshold to MEASURE against, not a
226
+ #: tolerance to pass: the measured single-GPU-vs-sharded deltas are
227
+ #: expected orders below it, and if they are not, the cache is rebuilt
228
+ #: through the sharded forward rather than argued about.
229
+ path_bias_threshold: float = 0.05
230
+ #: Denominator of that ratio: the typical magnitude of the implicit margin
231
+ #: early in the run, where the loss argument sits near zero and a
232
+ #: reference bias is least attenuated.
233
+ path_bias_early_margin_scale: float = 0.5
234
+ #: Held-out preference accuracy cadence, in optimizer steps.
235
+ heldout_every_optimizer_steps: int = 50
236
+ #: Pairs the chosen-longer fraction is computed over, as a ROLLING
237
+ #: WINDOW. Not per micro-batch, which is what the statistic naively is:
238
+ #: at the pre-registered micro_batch 2 the per-batch fraction can only be
239
+ #: 0, 0.5 or 1, so against a +-0.10 band it false-fires ~78% of the time.
240
+ #:
241
+ #: 512 is DERIVED, not chosen. SE of a fraction near the 0.5489 baseline
242
+ #: is sqrt(p(1-p)/k), so the band sits at 0.10/SE standard errors:
243
+ #: k=2 -> 0.28 SE (78% false-fire) k=128 -> 2.27 SE (2.3e-02)
244
+ #: k=32 -> 1.14 SE (26%) k=256 -> 3.22 SE (1.3e-03)
245
+ #: k=512 -> 4.55 SE (5.4e-06)
246
+ #: At 512 the per-check false-fire rate is 5e-06; over Arm B's ~3,000
247
+ #: logged micro-steps that is ~1.6e-02 expected spurious alarms for the
248
+ #: whole run. 256 would give ~4 -- enough to bury a real one.
249
+ length_window_pairs: int = 512
250
+ #: Which tripwires this arm ARMS, copied into `RunConfig.tripwires.armed`.
251
+ #: Both, for both arms: the collapse tripwire is the one with a
252
+ #: pre-registered response, and the length-drift one guards the 0.5489
253
+ #: baseline. Section-07 halts at the first log step if an armed tripwire's
254
+ #: inputs are missing or NaN, so arming here is a commitment that this
255
+ #: stage's loss adapter emits them -- not a hopeful setting.
256
+ armed_tripwires: tuple[str, ...] = ("logps_chosen", "length_drift", "grad_norm")
257
+ #: Length-drift band around the arm's MEASURED chosen-longer baseline
258
+ #: (Arm A: 0.5489 from the build manifest -- never hardcoded here).
259
+ length_band: float = 0.10
260
+ logps_window: int = 20
261
+
262
+ def __post_init__(self) -> None:
263
+ if self.arm not in ("arm_a", "arm_b"):
264
+ raise ValueError(f"arm={self.arm!r} must be 'arm_a' or 'arm_b'")
265
+ if self.tripwire_response not in PRE_REGISTERED_RESPONSES:
266
+ raise ValueError(
267
+ f"tripwire_response={self.tripwire_response!r} is not "
268
+ f"pre-registered; permitted: {list(PRE_REGISTERED_RESPONSES)}"
269
+ )
270
+ # Imported here, not at module top: align.model.attention pulls torch,
271
+ # and this dataclass must stay constructible before the resource
272
+ # guards have configured the process.
273
+ from ..model.attention import NMO_IMPLEMENTED
274
+
275
+ if self.attn_implementation not in NMO_IMPLEMENTED:
276
+ raise ValueError(
277
+ f"attn_implementation={self.attn_implementation!r} is not an "
278
+ f"align attention code path; permitted: {sorted(NMO_IMPLEMENTED)}. "
279
+ "A typo must raise at config load, never fall through to a "
280
+ "default backend -- a run that reports one backend while "
281
+ "executing another poisons every downstream comparison."
282
+ )
283
+
284
+
285
+ def load_stage_config(path: str, overrides: Optional[Sequence[str]] = None) -> DpoStageConfig:
286
+ """TOML (+ dotted `key=value` overrides) -> DpoStageConfig, struct mode on:
287
+ a typo'd key raises at launch instead of silently training with the
288
+ default it failed to override."""
289
+ from align.config import load_config
290
+
291
+ return load_config(DpoStageConfig, path, list(overrides or []))
292
+
293
+
294
+ def rotate_stale_checkpoints(ckpt_dir: str) -> Optional[str]:
295
+ """Move a previous run's checkpoint dir aside, ONCE, for a fresh arm.
296
+
297
+ COLLECTIVE: every rank calls it. Only rank 0 touches the filesystem and
298
+ every rank then waits at a barrier, so all ranks proceed with the same
299
+ directory state. Run on every rank, the bare `os.rename` let exactly one
300
+ rank win and the other three die with FileNotFoundError (ranks straddling
301
+ a second boundary even computed different destinations); the survivors
302
+ had already entered Trainer construction. Returns the destination on
303
+ rank 0 when a rotation happened, else None.
304
+ """
305
+ import time
306
+
307
+ from ..utils.dist import barrier, is_rank0
308
+
309
+ dst = None
310
+ if is_rank0() and os.path.isdir(ckpt_dir) and os.listdir(ckpt_dir):
311
+ dst = f"{ckpt_dir}_stale_{time.strftime('%Y%m%d-%H%M%S')}"
312
+ os.rename(ckpt_dir, dst)
313
+ logger.warning("rotated stale checkpoints to %s", dst)
314
+ barrier()
315
+ return dst
316
+
317
+
318
+ def probe_micro_cap(micro_step: int, probe_steps: int) -> int:
319
+ """The `max_micro_steps` cap for the launch probe, on the ABSOLUTE counter.
320
+
321
+ The Trainer tests `micro_step >= limit` against the absolute micro-step
322
+ count, which a resume restores. A bare `probe_steps` cap on a run resumed
323
+ at micro N >= probe_steps therefore trained exactly one micro-step and
324
+ left `throughput_probe` one time to aggregate (ValueError), so every
325
+ supervisor retry died here without a checkpoint. The probe is the first
326
+ `probe_steps` micro-steps of THIS process, wherever it starts.
327
+ """
328
+ return int(micro_step) + int(probe_steps)
329
+
330
+
331
+ def reduce_tripwire_alarms(alarms: Sequence[Mapping]) -> list[dict]:
332
+ """Every rank's tripwire alarms, unioned: the same list on all ranks.
333
+
334
+ The pre-registered rerun is a COLLECTIVE decision (a second model load, a
335
+ new FSDP wrap, a new Trainer). The trainer's alarms are built from
336
+ rank-local metrics over different pairs, and only HALT verdicts are
337
+ reduced inside the trainer, so a non-HALT alarm (the chosen-logprob
338
+ decline) can fire on some ranks only. Acting on the local list sends the
339
+ firing ranks into the rerun's collectives while the rest write the
340
+ manifest and exit. COLLECTIVE: every rank must call it. Each alarm gains
341
+ a `rank` key naming the rank that raised it.
342
+ """
343
+ from ..utils.dist import all_gather_objects
344
+
345
+ union: list[dict] = []
346
+ for r, rank_alarms in enumerate(all_gather_objects([dict(a) for a in alarms])):
347
+ for a in rank_alarms:
348
+ union.append({**a, "rank": r})
349
+ return union
350
+
351
+
352
+ def resolve_pair_split_dir(shard_dir: str, split: str) -> str:
353
+ """The directory holding `<split>-NNNN.receipt.json`, found by DISCOVERY.
354
+
355
+ THE HAND-COMPOSED-PATH DEFECT, DPO edition. The attempt-1 configs pointed
356
+ at `data/shards/arm_a-train`, composed from a naming convention; the store
357
+ actually writes `data/shards/arm_a/train/arm_a-train-0000.receipt.json`.
358
+ Section-11 hit the identical defect and fixed it with
359
+ `stage_smoke.resolve_split_dir`, which searches for the receipts the store
360
+ itself wrote; this delegates to that same function (read-only) rather than
361
+ growing a second, drifting copy. Accepts either the shard root or the
362
+ exact split directory -- both contain the split's receipts -- and a
363
+ missing split reports the splits that ARE present.
364
+ """
365
+ from .stage_smoke import resolve_split_dir
366
+
367
+ return str(resolve_split_dir(shard_dir, split))
368
+
369
+
370
+ def is_hf_checkpoint(path: Optional[str]) -> bool:
371
+ """A directory is a loadable checkpoint iff it carries `config.json` --
372
+ the file `AutoConfig.from_pretrained` reads first. A run dir that merely
373
+ CONTAINS checkpoints is not itself one."""
374
+ return bool(path) and os.path.isfile(os.path.join(str(path), "config.json"))
375
+
376
+
377
+ def resolve_sft_checkpoint(configured: str, receipt_checkpoint: Optional[str]) -> tuple[str, str]:
378
+ """The policy's starting checkpoint: `(path, source)`, or a raise.
379
+
380
+ Section-11's Trainer writes `<run_dir>/checkpoints/micro_NNNNNNNN`
381
+ (trainer.save_checkpoint) and there is NO stable `final` alias -- the
382
+ attempt-1 configs pointed at `checkpoints/sft/final`, a path nothing ever
383
+ writes, which would have failed at model load with the gates already
384
+ passed. What IS stable is the quality-gate receipt: section-11 records
385
+ WHICH micro-step checkpoint passed its gate in the receipt's `checkpoint`
386
+ field, so that is the discovery mechanism. A configured path that is
387
+ itself a checkpoint wins, as the explicit override for rerunning against
388
+ a pinned checkpoint.
389
+ """
390
+ if is_hf_checkpoint(configured):
391
+ return configured, "config"
392
+ if is_hf_checkpoint(receipt_checkpoint):
393
+ return str(receipt_checkpoint), "sft_quality_gate_receipt"
394
+ raise FileNotFoundError(
395
+ f"no SFT checkpoint found. Configured sft_checkpoint={configured!r} "
396
+ "is not a checkpoint directory (no config.json), and the quality-gate "
397
+ f"receipt records checkpoint={receipt_checkpoint!r}, which is not one "
398
+ "either. Section-11's trainer writes "
399
+ "<run_dir>/checkpoints/micro_NNNNNNNN and its receipt names the one "
400
+ "that passed the gate; refusing to guess a starting point for a "
401
+ "full-FT arm."
402
+ )
403
+
404
+
405
+ # ---------------------------------------------------------------------------
406
+ # Step arithmetic
407
+ # ---------------------------------------------------------------------------
408
+
409
+
410
+ def optimizer_steps(pairs: int, global_batch: int, epochs: int) -> int:
411
+ """epochs * ceil(pairs / global_batch).
412
+
413
+ Written once, here, because the iterations-vs-steps confusion is a
414
+ documented predecessor incident: an LR horizon quoted in MICRO-steps
415
+ schedules the cosine over 4-8x the real run, so the LR never decays and
416
+ the run ends at nearly its peak rate with no sign anything was wrong.
417
+ Ceiling, not floor -- a trailing partial batch is still an optimizer step.
418
+ """
419
+ if pairs < 1:
420
+ raise ValueError(f"pairs={pairs} must be >= 1")
421
+ if global_batch < 1:
422
+ raise ValueError(f"global_batch={global_batch} must be >= 1")
423
+ if epochs < 1:
424
+ raise ValueError(f"epochs={epochs} must be >= 1")
425
+ return epochs * math.ceil(pairs / global_batch)
426
+
427
+
428
+ # ---------------------------------------------------------------------------
429
+ # Entry gates
430
+ # ---------------------------------------------------------------------------
431
+
432
+
433
+ def run_entry_gates(
434
+ cfg: DpoStageConfig, gates: Mapping[str, Callable[[DpoStageConfig], dict]]
435
+ ) -> list[dict]:
436
+ """Run every gate in `GATE_ORDER`, stopping at the first failure.
437
+
438
+ Returns one record per gate that ran. A gate absent from `gates` RAISES:
439
+ an unimplemented gate treated as passed is how a run launches with an
440
+ unchecked reference cache, and the whole point of the ordered sequence is
441
+ that reaching gate N means gates 1..N-1 held.
442
+ """
443
+ report: list[dict] = []
444
+ for name in GATE_ORDER:
445
+ fn = gates.get(name)
446
+ if fn is None:
447
+ raise RuntimeError(
448
+ f"entry gate {name!r} has no implementation. A missing gate is "
449
+ "not a passed gate; refusing to launch."
450
+ )
451
+ result = fn(cfg)
452
+ record = {"gate": name}
453
+ if isinstance(result, Mapping):
454
+ record.update(result)
455
+ report.append(record)
456
+ logger.info("entry gate ok: %s", name)
457
+ return report
458
+
459
+
460
+ #: The gate whose pass this stage depends on, named in section-11's own
461
+ #: `stage_gates.GATE_ORDER` rather than spelled here as a string this module
462
+ #: invented.
463
+ SFT_GATE_NAME = "quality_gate"
464
+
465
+
466
+ def read_sft_quality_gate(path: str) -> dict:
467
+ """Section-11's quality-gate verdict: exists, and records a PASS.
468
+
469
+ Reads section-11's OWN artifact. `scripts/sft.py` records its gates
470
+ through `stage_gates.GateLedger`, which writes `stage_gates.json` as
471
+ `{"eval_source_hash": ..., "gates": {"quality_gate": {"passed": ...}}}`.
472
+ An earlier draft of this function read a standalone
473
+ `{"passed": true}` receipt that nothing writes -- the gate would have
474
+ raised FileNotFoundError with four GPUs already claimed. `path` may name
475
+ the ledger file or the run directory containing it.
476
+
477
+ The comparison itself is NOT restated here. Section-11's
478
+ `stage_gates.quality_gate` delegates to section-10's `assert_meets_golden`,
479
+ which compares against the WILSON LOWER BOUND -- the golden anchors are
480
+ themselves measurements with sampling noise, so demanding the point
481
+ estimate meet them would fail correct runs about half the time. This stage
482
+ consumes that verdict; a second copy of the CI reasoning here would drift
483
+ from it, and the drift would be invisible.
484
+
485
+ Three states, and the third is the one that gets skipped. Missing: section
486
+ 11 never ran, or ran and never recorded. Present and failed: obvious.
487
+ Present and SILENT on this gate: also a refusal, because an artifact that
488
+ does not answer the question is indistinguishable from one that answers
489
+ 'no', and defaulting to 'yes' would let a partial ledger authorise eight
490
+ GPU-hours.
491
+ """
492
+ from .stage_gates import LEDGER_FILENAME, PENDING
493
+
494
+ resolved = path
495
+ if path and os.path.isdir(path):
496
+ resolved = os.path.join(path, LEDGER_FILENAME)
497
+ if not resolved or not os.path.isfile(resolved):
498
+ raise FileNotFoundError(
499
+ f"SFT quality gate ledger {resolved!r} is missing. DPO fine-tunes "
500
+ "the SFT checkpoint, so an SFT run that never passed its own gate "
501
+ "makes both arms measurements of a broken starting point -- at "
502
+ "full GPU cost, with nothing downstream able to tell. Point "
503
+ "sft_quality_gate_path at section-11's run dir or its "
504
+ f"{LEDGER_FILENAME}."
505
+ )
506
+ with open(resolved) as f:
507
+ blob = json.load(f)
508
+
509
+ # Accept the ledger shape, and a bare {"passed": ...} object too: the
510
+ # latter is what a hand-written override looks like, and refusing it would
511
+ # push someone toward editing the ledger instead.
512
+ gates = blob.get("gates", blob if "passed" in blob else {})
513
+ entry = gates.get(SFT_GATE_NAME, blob if "passed" in blob else PENDING)
514
+ if not isinstance(entry, dict) or "passed" not in entry:
515
+ raise RuntimeError(
516
+ f"{resolved!r} does not record whether the SFT {SFT_GATE_NAME!r} "
517
+ f"passed (it reads {entry!r}). An artifact that cannot answer the "
518
+ "question is not a pass."
519
+ )
520
+ if not entry["passed"]:
521
+ raise RuntimeError(
522
+ f"the SFT quality gate did not pass: "
523
+ f"{entry.get('detail', '(no detail recorded)')}. Fix section-11 "
524
+ "before spending this stage's GPU budget."
525
+ )
526
+ return {
527
+ "passed": True,
528
+ "detail": entry.get("detail", ""),
529
+ "recorded_at": entry.get("recorded_at"),
530
+ "eval_source_hash": blob.get("eval_source_hash"),
531
+ "checkpoint": blob.get("checkpoint", entry.get("checkpoint")),
532
+ "ledger": resolved,
533
+ }
534
+
535
+
536
+ def check_arm_descoped(arm: str, manifest: Mapping) -> dict:
537
+ """Honour section-03's Arm A DESCOPED marker (filter yield < 1,500).
538
+
539
+ The failure this prevents is quiet and expensive: training Arm A on
540
+ whatever survived the filter and reporting it under the pre-registered
541
+ arm's name. A descoped arm is a RESULT -- it gets a manifest saying so --
542
+ not an absence.
543
+ """
544
+ descoped = bool(manifest.get("arm_a_descoped", False)) and arm == "arm_a"
545
+ return {
546
+ "arm": arm,
547
+ "descoped": descoped,
548
+ "train": not descoped,
549
+ "detail": (
550
+ "section-03 marked Arm A descoped (filter yield below the "
551
+ "1,500 floor); refusing to train it and recording the "
552
+ "descope as this arm's result"
553
+ if descoped
554
+ else "arm is in scope"
555
+ ),
556
+ }
557
+
558
+
559
+ def build_manifest_gate(cfg: DpoStageConfig, manifest: Mapping) -> dict:
560
+ """Counts match the pinned values and the build verified its own pins."""
561
+ if not manifest.get("pins_verified", False):
562
+ raise RuntimeError(
563
+ "the build manifest reports pins_verified=false: the corpus was "
564
+ "built in re-measurement mode and its counts were never checked "
565
+ "against the pinned values. Sections 11 and 12 refuse to launch "
566
+ f"against it. Violations: {manifest.get('pin_violations')}"
567
+ )
568
+ counts = manifest.get("counts", {})
569
+ key = "arm_a_pairs" if cfg.arm == "arm_a" else "arm_b_curated"
570
+ measured = counts.get(key)
571
+ if measured is None:
572
+ raise KeyError(
573
+ f"build manifest carries no {key!r} count, so the optimizer-step "
574
+ "horizon cannot be derived from a measured pair count."
575
+ )
576
+ if int(measured) != int(cfg.expected_pairs):
577
+ raise RuntimeError(
578
+ f"{cfg.arm}: manifest reports {measured} pairs but the stage "
579
+ f"config expects {cfg.expected_pairs}. The horizon, the LR "
580
+ "schedule and the ref cache were all built for the config number; "
581
+ "one of the two is stale."
582
+ )
583
+ return {"pairs": int(measured), **check_arm_descoped(cfg.arm, manifest)}
584
+
585
+
586
+ # ---------------------------------------------------------------------------
587
+ # Banner
588
+ # ---------------------------------------------------------------------------
589
+
590
+
591
+ def measure_chosen_longer_fraction(rows: Sequence[Any]) -> float:
592
+ """Fraction of pairs whose CHOSEN completion is longer, from the rows.
593
+
594
+ Needed because section-03's build manifest records Arm A's baseline
595
+ (0.5489) and not Arm B's, while the length-drift tripwire needs one per
596
+ arm: `LengthDriftTripwire` refuses a NaN baseline outright, and borrowing
597
+ Arm A's would arm Arm B against a distribution it was never drawn from --
598
+ a tripwire that then fires forever or never, both of which are worse than
599
+ no tripwire because they look like measurements.
600
+
601
+ Completion length, not sequence length: Arm B's two sides share their
602
+ entire prefix, so total length is dominated by a term identical on both
603
+ sides and would compress the fraction toward 0.5 regardless of the data.
604
+ """
605
+ import numpy as np
606
+
607
+ longer = 0
608
+ counted = 0
609
+ for r in rows:
610
+ lc = len(np.asarray(r["chosen_input_ids"])) - int(r["chosen_prompt_len"])
611
+ lr = len(np.asarray(r["rejected_input_ids"])) - int(r["rejected_prompt_len"])
612
+ if lc <= 0 and lr <= 0:
613
+ continue # no completion on either side
614
+ counted += 1
615
+ longer += int(lc > lr)
616
+ return longer / counted if counted else NAN
617
+
618
+
619
+ def dpo_banner(
620
+ *,
621
+ cfg: DpoStageConfig,
622
+ manifest: Mapping,
623
+ ref_cache_key,
624
+ path_bias: Mapping,
625
+ probe: Mapping,
626
+ rss: Mapping,
627
+ backend: str,
628
+ sft_gate: Mapping,
629
+ length_baseline: Optional[tuple[float, str]] = None,
630
+ pre_registration: str = "docs/RESULTS.md",
631
+ ) -> dict:
632
+ """Everything this run depends on, or a raise.
633
+
634
+ Two things are enforced here rather than merely reported. The length-drift
635
+ baseline is READ FROM THE MANIFEST (Arm A measured 0.5489; Arm B's is
636
+ different, so a constant would mis-arm the tripwire on one arm forever),
637
+ and a path-bias decision of 'rerun through the sharded forward' must
638
+ actually have been honoured -- a measurement taken, recorded, and ignored
639
+ is worse than one never taken, because it leaves evidence that the
640
+ question was considered.
641
+ """
642
+ baseline_key = f"{cfg.arm}_chosen_longer_fraction"
643
+ baselines = manifest.get("length_bias_baseline", {})
644
+ if length_baseline is not None:
645
+ baseline, baseline_source = length_baseline
646
+ if math.isnan(baseline):
647
+ raise ValueError(
648
+ f"{baseline_key}: the supplied baseline is NaN, which means "
649
+ "nobody measured it. The length-drift tripwire refuses a NaN "
650
+ "baseline, so this would disarm it silently."
651
+ )
652
+ elif baseline_key in baselines:
653
+ baseline, baseline_source = baselines[baseline_key], "build_manifest"
654
+ else:
655
+ raise KeyError(
656
+ f"build manifest has no {baseline_key!r} and no measured baseline "
657
+ "was supplied. The length-drift tripwire needs THIS arm's "
658
+ "chosen-longer fraction; defaulting to the other arm's would "
659
+ "mis-arm it permanently. scripts/dpo.py measures it from the "
660
+ "arm's own shards when the manifest lacks it "
661
+ "(measure_chosen_longer_fraction)."
662
+ )
663
+
664
+ if path_bias.get("fired") and getattr(ref_cache_key, "forward_impl", None) != "fsdp2_sharded":
665
+ raise RuntimeError(
666
+ "the path-bias measurement fired (action="
667
+ f"{path_bias.get('action')!r}, ratio={path_bias.get('ratio')}) but "
668
+ f"the reference cache is tagged "
669
+ f"{getattr(ref_cache_key, 'forward_impl', None)!r}, not "
670
+ "'fsdp2_sharded'. Re-run the precompute through the sharded "
671
+ "forward. A decision that was measured, recorded and then ignored "
672
+ "is worse than one never taken."
673
+ )
674
+
675
+ horizon = optimizer_steps(cfg.expected_pairs, cfg.global_batch, cfg.epochs)
676
+ return {
677
+ "arm": cfg.arm,
678
+ "loss_type": f"dpo_{cfg.variant}",
679
+ "variant": cfg.variant,
680
+ "beta": cfg.beta,
681
+ "lr": cfg.lr,
682
+ "epochs": cfg.epochs,
683
+ "global_batch": cfg.global_batch,
684
+ "grad_accum": cfg.grad_accum,
685
+ "expected_pairs": cfg.expected_pairs,
686
+ "optimizer_step_formula": f"{cfg.epochs} x ceil({cfg.expected_pairs}/{cfg.global_batch}) "
687
+ f"= {horizon}",
688
+ "scheduler_horizon_optimizer_steps": horizon,
689
+ "dataset_hashes": dict(manifest.get("hashes", {})),
690
+ "ref_cache_key": json.loads(ref_cache_key.to_json()),
691
+ "path_bias": dict(path_bias),
692
+ "probe_step_time_s": probe.get("step_time_s_median", NAN),
693
+ "probe_derived_wall_clock_h": probe.get("projected_wall_clock_h", NAN),
694
+ "rss_budget": dict(rss),
695
+ "attention_backend": backend,
696
+ "length_drift_baseline": baseline,
697
+ "length_drift_baseline_source": baseline_source,
698
+ "sft_checkpoint": sft_gate.get("checkpoint", cfg.sft_checkpoint),
699
+ "pre_registration": pre_registration,
700
+ }
701
+
702
+
703
+ def render_dpo_banner(resolved: Mapping, title: str = "align dpo") -> str:
704
+ """Render, or raise. Never a partial banner.
705
+
706
+ Checks this stage's keys AND section-01's global mandatory set, without
707
+ mutating the global registry -- see DPO_BANNER_KEYS for why.
708
+ """
709
+ from ..registry import MANDATORY_BANNER_KEYS
710
+
711
+ required = list(DPO_BANNER_KEYS) + [
712
+ k for k in MANDATORY_BANNER_KEYS if k not in DPO_BANNER_KEYS
713
+ ]
714
+ missing = [k for k in DPO_BANNER_KEYS if k not in resolved]
715
+ unresolved = [k for k in DPO_BANNER_KEYS if k in resolved and resolved[k] is None]
716
+ if missing or unresolved:
717
+ raise RuntimeError(
718
+ "DPO launch banner incomplete -- refusing to start. A run that "
719
+ f"cannot answer its question does not begin. missing={missing} "
720
+ f"unresolved(None)={unresolved}. Undefined values must be "
721
+ "EXPLICIT: float('nan') or 'PENDING', never absent."
722
+ )
723
+ width = 78
724
+ lines = ["=" * width, f"| {title} [{resolved['arm']}]", "=" * width]
725
+ for key in required:
726
+ if key in resolved:
727
+ lines.append(f"| {key:<36} {resolved[key]!r}")
728
+ for key in sorted(k for k in resolved if k not in required):
729
+ lines.append(f"| {key:<36} {resolved[key]!r} (extra)")
730
+ lines.append("=" * width)
731
+ return "\n".join(lines)
732
+
733
+
734
+ # ---------------------------------------------------------------------------
735
+ # Tripwire response
736
+ # ---------------------------------------------------------------------------
737
+
738
+
739
+ def apply_tripwire_response(
740
+ cfg: DpoStageConfig, alarms: Sequence[Mapping]
741
+ ) -> tuple[DpoStageConfig, dict]:
742
+ """Map a fired chosen-logprob tripwire to the PRE-REGISTERED response.
743
+
744
+ Returns `(config to rerun the arm with, record)`. The record goes into the
745
+ arm manifest, so the deviation is part of the result rather than a note
746
+ somebody remembers.
747
+
748
+ The alarm being responded to: `logps/chosen` declining while the margin
749
+ improves. DPO's objective is satisfied by separating the two logprobs, and
750
+ crushing the rejected one separates them just as well as raising the
751
+ chosen one -- while degrading the policy. The mode is MAXIMAL FOR ARM B by
752
+ design, because pi_SFT already assigns those chosen continuations high
753
+ likelihood, so there is little headroom above and a great deal below.
754
+
755
+ Every permitted response puts a floor under the chosen logprob: `dpop`
756
+ adds an explicit penalty when it falls below the reference, `nll_aux` adds
757
+ a likelihood term on the chosen completion, and `dpop_nll_aux` -- the
758
+ frozen pre-registration's wording -- does both. Beta is NOT changed, and
759
+ the arm re-runs ONCE: a second rerun would be a third configuration.
760
+ """
761
+ # "logps_chosen" is the WIRE NAME the trainer records (trainer.py appends
762
+ # {"alarm": name} for the armed wire key). This filter matched a name
763
+ # nobody emits ("logps_chosen_decline") until 2026-08-17 -- a fired
764
+ # collapse tripwire could never trigger the pre-registered rerun, and the
765
+ # unit test asserted the filter against its own literal instead of the
766
+ # trainer's. Silent-failure trap #7's shape: armed-looking, disconnected.
767
+ fired = [a for a in alarms if a.get("alarm") == "logps_chosen"]
768
+ if not fired:
769
+ return cfg, {
770
+ "fired": False,
771
+ "rerun_required": False,
772
+ "pre_registered": True,
773
+ "configuration": f"{cfg.variant} beta={cfg.beta}",
774
+ "detail": "no chosen-logprob alarm; primary configuration stands as the arm's result",
775
+ }
776
+
777
+ if cfg.tripwire_response not in PRE_REGISTERED_RESPONSES:
778
+ raise ValueError(
779
+ f"tripwire_response={cfg.tripwire_response!r} is outside the "
780
+ f"pre-registration ({list(PRE_REGISTERED_RESPONSES)}). A third "
781
+ "configuration requires a NEW pre-registration written before the "
782
+ "rerun -- choosing one after seeing the data is how a stage picks "
783
+ "its own success criterion."
784
+ )
785
+ if cfg.tripwire_response == "dpop_nll_aux":
786
+ new = replace(cfg, variant="dpop", nll_coeff=NLL_AUX_COEFF)
787
+ elif cfg.tripwire_response == "dpop":
788
+ new = replace(cfg, variant="dpop")
789
+ else:
790
+ new = replace(cfg, variant="sigmoid", nll_coeff=NLL_AUX_COEFF)
791
+ record = {
792
+ "fired": True,
793
+ "rerun_required": True,
794
+ "pre_registered": True,
795
+ "alarms": [dict(a) for a in fired],
796
+ "response": cfg.tripwire_response,
797
+ "configuration": (
798
+ f"{new.variant} beta={new.beta} nll_coeff={new.nll_coeff} dpop_lambda={new.dpop_lambda}"
799
+ ),
800
+ "detail": (
801
+ "chosen-logprob collapse detected; the arm re-runs with the "
802
+ "pre-registered response and THAT configuration is recorded "
803
+ "as the arm's result"
804
+ ),
805
+ }
806
+ logger.error("tripwire response applied: %s", record["configuration"])
807
+ return new, record
808
+
809
+
810
+ # ---------------------------------------------------------------------------
811
+ # Arm result manifest
812
+ # ---------------------------------------------------------------------------
813
+
814
+
815
+ def arm_result_manifest(
816
+ *,
817
+ cfg: DpoStageConfig,
818
+ descoped: bool,
819
+ summary: Mapping,
820
+ heldout: Mapping,
821
+ banner: Mapping,
822
+ checkpoint: Optional[str],
823
+ tripwire: Optional[Mapping] = None,
824
+ ) -> dict:
825
+ """What section-13 reads to select an arm and run the paired evaluation.
826
+
827
+ A descoped arm still produces a manifest. Its accuracy is NaN, not 0.0:
828
+ zero is a plausible catastrophic result and would read as one, while NaN
829
+ says nobody measured -- the distinction section-13's arm-selection rule
830
+ depends on.
831
+ """
832
+ return {
833
+ "arm": cfg.arm,
834
+ "status": "descoped" if descoped else "trained",
835
+ "checkpoint": None if descoped else checkpoint,
836
+ "loss_variant_actually_used": None if descoped else cfg.variant,
837
+ "beta": cfg.beta,
838
+ "nll_coeff": cfg.nll_coeff,
839
+ "lr": cfg.lr,
840
+ "epochs": cfg.epochs,
841
+ "global_batch": cfg.global_batch,
842
+ "optimizer_steps_planned": optimizer_steps(
843
+ cfg.expected_pairs, cfg.global_batch, cfg.epochs
844
+ ),
845
+ "optimizer_steps_run": summary.get("optimizer_step", 0),
846
+ "heldout_preference_accuracy": heldout.get("accuracy", NAN),
847
+ "heldout_ci": [heldout.get("ci_low", NAN), heldout.get("ci_high", NAN)],
848
+ "heldout_n": heldout.get("n", 0),
849
+ "final_mean_margin": heldout.get("mean_margin", NAN),
850
+ "tripwire": dict(tripwire) if tripwire else {"fired": False},
851
+ "tripwire_alarms": list(summary.get("tripwire_alarms", [])),
852
+ "trunk_report": dict(summary.get("trunk_report", {})),
853
+ "checkpoints_written": list(summary.get("checkpoints_written", [])),
854
+ "banner": dict(banner),
855
+ }
856
+
857
+
858
+ def write_arm_result(path: str, manifest: Mapping) -> str:
859
+ """Write the arm manifest: rank 0 only, atomically.
860
+
861
+ COLLECTIVE (every rank calls it; the closing barrier keeps ranks together).
862
+ Run on all four torchrun ranks with a bare `open(path, "w")`, the ranks
863
+ truncated and wrote one path within a millisecond, with rank-local content
864
+ (`checkpoint` is "" off rank 0): the recorded Arm B manifest is
865
+ unparseable. Section-13 reads this file to select an arm, so it is written
866
+ once, via tmp + os.replace, and rank 0's `checkpoint` is the real one.
867
+ """
868
+ from ..utils.dist import barrier, is_rank0
869
+ from ..utils.guard import assert_paths_off_root
870
+ from .ref_cache import _atomic_write
871
+
872
+ assert_paths_off_root([os.path.dirname(os.path.abspath(path))])
873
+ if is_rank0():
874
+
875
+ def _dump(tmp: str) -> None:
876
+ with open(tmp, "w") as f:
877
+ # default=str: the manifest is assembled by the caller from eval
878
+ # output and this writes an ARM'S RESULT after its full DPO run --
879
+ # the exact shape (harness-measured values serialized at the end)
880
+ # that killed SFT attempt 6 with a Tensor in a gate receipt.
881
+ json.dump(manifest, f, indent=2, default=str)
882
+ f.flush()
883
+ os.fsync(f.fileno())
884
+
885
+ _atomic_write(path, _dump)
886
+ logger.info("arm result manifest -> %s", path)
887
+ barrier()
888
+ return path
889
+
890
+
891
+ # ---------------------------------------------------------------------------
892
+ # Trainer-facing batch + loss adapter
893
+ # ---------------------------------------------------------------------------
894
+
895
+
896
+ class DpoBatch:
897
+ """A `Preference` batch, iterable over its pair ids.
898
+
899
+ Section-07's `Trainer` iterates the object it is handed and logs
900
+ `list(batch)` as that step's identity -- its own tests pass samplers that
901
+ yield row-index lists, for which `list(batch)` is exactly right. A
902
+ `Preference` dataclass is not iterable, so a bare one would raise
903
+ TypeError at the first log step.
904
+
905
+ Wrapping supplies the identity the log wants, and supplies a better one
906
+ than row indices: which PAIRS a step consumed. Row indices are meaningless
907
+ across a reshuffle or a rebuild; pair ids survive both, so a suspicious
908
+ step stays reproducible afterwards.
909
+ """
910
+
911
+ __slots__ = ("preference", "pair_ids")
912
+
913
+ def __init__(self, preference):
914
+ self.preference = preference
915
+ self.pair_ids = [(m or {}).get("pair_id") for m in preference.metadata]
916
+
917
+ def __iter__(self):
918
+ return iter(self.pair_ids)
919
+
920
+ def __len__(self) -> int:
921
+ return len(self.pair_ids)
922
+
923
+ def to(self, device) -> "DpoBatch":
924
+ return DpoBatch(self.preference.to(device))
925
+
926
+
927
+ class PreferenceBatchSource:
928
+ """What section-07's `Trainer` iterates: `set_epoch` / `__iter__` / `__len__`.
929
+
930
+ The Trainer walks a batch source directly rather than a `DataLoader`, and
931
+ that is a deliberate section-07 choice: batch order becomes a pure
932
+ function of (seed, epoch), which is what makes the seeded-resume contract
933
+ checkable. It also means there are no dataloader worker processes in this
934
+ loop at all -- the `<=2 workers per rank` rule that the /dev/shm SIGBUS
935
+ incident produced has nothing to bind to here, because nothing is forked.
936
+ Collation happens inline, in the training process.
937
+
938
+ That is affordable for DPO specifically: pair shards are memory-mapped and
939
+ a micro-batch is two short sequences, so collation is microseconds against
940
+ a 7B forward. It would not be affordable for a tokenization-heavy loop.
941
+ """
942
+
943
+ def __init__(self, rows: Sequence[Any], sampler, pad_token_id: int):
944
+ self.rows = list(rows)
945
+ self.sampler = sampler
946
+ self.pad_token_id = int(pad_token_id)
947
+
948
+ def set_epoch(self, epoch: int) -> None:
949
+ self.sampler.set_epoch(epoch)
950
+
951
+ def __len__(self) -> int:
952
+ return len(self.sampler)
953
+
954
+ def __iter__(self):
955
+ from ..data.loader import collate_preference
956
+
957
+ for indices in self.sampler:
958
+ yield DpoBatch(
959
+ collate_preference([self.rows[i] for i in indices], pad_token_id=self.pad_token_id)
960
+ )
961
+
962
+
963
+ def make_preference_sampler(
964
+ rows, *, max_tokens: int, max_rows: int, seed: int = 0, rank=None, world_size=None
965
+ ):
966
+ """Section-03's length-grouped sampler, RANK-SHARDED.
967
+
968
+ THE UNSHARDED-SAMPLER INCIDENT (~24 GPU-hours on Arm A). This used to
969
+ return the bare `LengthGroupedSampler`, so every rank walked the same
970
+ batch list in the same order and all four trained the SAME pair every
971
+ micro-step. The effective global batch was micro x accum (8), not
972
+ micro x accum x world (32) as pre-registered; one "epoch" therefore
973
+ consumed a quarter of the corpus; the horizon covered half an epoch; and
974
+ past it the cosine schedule clamps to lr=0, so the run kept stepping on
975
+ frozen weights for 287 more steps. Nothing failed -- the loss curve was
976
+ plausible and the manifest reported the intended batch size throughout.
977
+
978
+ SHARDING THE BATCHES, NOT THE ROWS, and the choice matters twice over:
979
+
980
+ * `PreferenceBatchSource` indexes the FULL row list with whatever
981
+ indices the sampler yields, so slicing batches keeps every index
982
+ valid and needs no remapping -- one seam instead of two that can
983
+ disagree.
984
+ * `DistributedBatchSampler` truncates the batch list to a multiple of
985
+ world_size so EVERY RANK RUNS THE SAME NUMBER OF STEPS. A rank that
986
+ finished early would leave its peers blocked inside a collective,
987
+ which surfaces hours later as an NCCL timeout rather than as an error
988
+ where it happened. Row-slicing (`rows[rank::world]` before batching)
989
+ cannot promise this: the sampler is token-budgeted, so different row
990
+ slices yield different batch counts.
991
+
992
+ The cost is a bounded tail: up to (world_size - 1) batches are dropped per
993
+ epoch. Section-03 chose that over padding with repeats, which would
994
+ double-count rows and corrupt the epoch semantics the horizon is derived
995
+ from.
996
+
997
+ `rank`/`world_size` default to the live environment but are explicit
998
+ parameters so four ranks can be simulated in one CPU process -- which is
999
+ how this contract is tested, since the incident is a property of the
1000
+ relationship BETWEEN ranks and no single-process run can see it.
1001
+ """
1002
+ from ..data.loader import (
1003
+ DistributedBatchSampler,
1004
+ LengthGroupedSampler,
1005
+ preference_length_key,
1006
+ )
1007
+ from ..utils.dist import rank as env_rank
1008
+ from ..utils.dist import world_size as env_world_size
1009
+
1010
+ lengths = [preference_length_key(r) for r in rows]
1011
+ base = LengthGroupedSampler(lengths, max_tokens=max_tokens, max_rows=max_rows, seed=seed)
1012
+
1013
+ r = env_rank() if rank is None else int(rank)
1014
+ ws = env_world_size() if world_size is None else int(world_size)
1015
+ if ws <= 1:
1016
+ return base
1017
+ if not 0 <= r < ws:
1018
+ raise ValueError(f"rank {r} is outside world_size {ws}")
1019
+ return DistributedBatchSampler(base, rank=r, world_size=ws)
1020
+
1021
+
1022
+ def _check_global_batch(
1023
+ global_batch: int, world_size: int, micro_batch_size: int, grad_accum: int
1024
+ ) -> int:
1025
+ """The arithmetic behind both launch refusals; returns the live global batch."""
1026
+ live = world_size * micro_batch_size * grad_accum
1027
+ if global_batch != live:
1028
+ raise RuntimeError(
1029
+ f"global_batch={global_batch} (the pre-registered value the banner "
1030
+ f"and horizon are quoted in) != world_size {world_size} x micro "
1031
+ f"{micro_batch_size} x grad_accum {grad_accum} = {live}. The run "
1032
+ "would consume a different number of pairs per optimizer step than "
1033
+ "it reports. Launch with the pre-registered rank count or change "
1034
+ "the config deliberately."
1035
+ )
1036
+ return live
1037
+
1038
+
1039
+ def live_world_size() -> int:
1040
+ """The data-parallel width this process is ACTUALLY part of.
1041
+
1042
+ The initialised process group wins over the environment: `WORLD_SIZE` is
1043
+ what torchrun exported, the group is what the collectives will average
1044
+ over, and a launch decision belongs to the latter. Falls back to the
1045
+ environment before `init_process_group` (and to 1 outside torchrun).
1046
+ """
1047
+ import torch.distributed as dist
1048
+
1049
+ if dist.is_available() and dist.is_initialized():
1050
+ return int(dist.get_world_size())
1051
+ from ..utils.dist import world_size
1052
+
1053
+ return int(world_size())
1054
+
1055
+
1056
+ def assert_launch_topology(cfg: "DpoStageConfig", *, world_size: Optional[int] = None) -> dict:
1057
+ """LAUNCH REFUSAL: world_size x micro x grad_accum must equal the
1058
+ pre-registered `global_batch` (2026-10-12 review).
1059
+
1060
+ Nothing used to check it. `global_batch` is a config number that the
1061
+ banner, `optimizer_steps()` and the arm manifest all quote, while what a
1062
+ run really consumes per optimizer step is `world_size * micro_batch_size *
1063
+ grad_accum`. A 2-rank launch of the 4-rank pre-registration therefore ran
1064
+ 16 pairs per step under a banner that said 32 and a quoted horizon of 146
1065
+ (really 290): the experiment recorded as one thing and executed as
1066
+ another, with every number finite. `assert_dpo_horizon_consistent`
1067
+ carries the same check but sits AFTER the 14.5GB model load and was never
1068
+ passed the pre-registered value by the launcher; this one runs before any
1069
+ gate, in milliseconds, fed by the live topology.
1070
+
1071
+ `world_size=None` reads it from the live process group (see
1072
+ `live_world_size`); tests and tools pass it explicitly.
1073
+ """
1074
+ ws = live_world_size() if world_size is None else int(world_size)
1075
+ if ws < 1:
1076
+ raise ValueError(f"world_size={ws} must be >= 1")
1077
+ live = _check_global_batch(cfg.global_batch, ws, cfg.micro_batch_size, cfg.grad_accum)
1078
+ return {
1079
+ "world_size": ws,
1080
+ "micro_batch_size": cfg.micro_batch_size,
1081
+ "grad_accum": cfg.grad_accum,
1082
+ "global_batch": live,
1083
+ }
1084
+
1085
+
1086
+ def expected_ref_cache_key(cfg: "DpoStageConfig", manifest: Mapping, sft_checkpoint: str):
1087
+ """The key this run requires of its reference caches.
1088
+
1089
+ Five components are EXPECTATIONS built from this run's own facts (content
1090
+ hash of the SFT weights, tokenizer, shard manifest, max_length, truncation)
1091
+ and one is a RECORD: `forward_impl`, how the cache's own pass ran. The
1092
+ record is read from the cache itself, never from the path-bias report.
1093
+
1094
+ WHY NOT THE REPORT (2026-10-12 review). The launcher used to take
1095
+ forward_impl from the report's `cache_forward_impl`, which
1096
+ precompute_ref_logprobs wrote as "fsdp2_sharded" whenever `--path-bias`
1097
+ was passed -- but a `--path-bias` run does NOT recompute the caches, so the
1098
+ field described the measuring run, not the cache. The documented happy
1099
+ path (single-GPU precompute, then `--path-bias`, decision fired=False)
1100
+ was refused at the cache gate: "Mismatched: forward_impl
1101
+ (cache='single_gpu', run='fsdp2_sharded')". The rule that a FIRED decision
1102
+ must be honoured by a sharded cache is enforced where it belongs, by
1103
+ `dpo_banner`; here the held-out cache must agree with the train cache's
1104
+ tag, and everything else is asserted by `load_ref_cache(expect=...)`.
1105
+ """
1106
+ from .ref_cache import build_ref_cache_key, peek_ref_cache_key
1107
+
1108
+ cache_key = peek_ref_cache_key(cfg.ref_cache_path)
1109
+ forward_impl = cache_key.forward_impl if cache_key is not None else "single_gpu"
1110
+ return build_ref_cache_key(sft_checkpoint, dict(manifest), forward_impl=forward_impl)
1111
+
1112
+
1113
+ def load_run_ref_caches(cfg: "DpoStageConfig", manifest: Mapping, sft_checkpoint: str):
1114
+ """Load (train, held-out) reference caches under `expected_ref_cache_key`.
1115
+
1116
+ Both caches are loaded with `expect=`, so a missing file, a stale
1117
+ checkpoint, a rebuilt shard set, a different tokenizer/max_length, or a
1118
+ held-out cache computed through a different forward than the train cache
1119
+ all raise here, at the gate, naming the guilty component.
1120
+ """
1121
+ from .ref_cache import load_ref_cache
1122
+
1123
+ key = expected_ref_cache_key(cfg, manifest, sft_checkpoint)
1124
+ train = load_ref_cache(cfg.ref_cache_path, expect=key)
1125
+ held = load_ref_cache(cfg.heldout_ref_cache_path, expect=key)
1126
+ return train, held
1127
+
1128
+
1129
+ def assert_dpo_horizon_consistent(
1130
+ scheduler_total_steps: int,
1131
+ *,
1132
+ n_pairs: int,
1133
+ world_size: int,
1134
+ micro_batch_size: int,
1135
+ grad_accum: int,
1136
+ epochs: int,
1137
+ slack: Optional[int] = None,
1138
+ global_batch: Optional[int] = None,
1139
+ ) -> dict:
1140
+ """Refuse to launch when the LR horizon disagrees with the corpus.
1141
+
1142
+ `global_batch`, when given, is the PRE-REGISTERED number the banner and the
1143
+ horizon formula (`optimizer_steps`) are quoted in; it must equal what the
1144
+ live topology actually consumes per optimizer step,
1145
+ `world_size * micro_batch_size * grad_accum`. Without this the banner can
1146
+ say global_batch 32 / horizon 146 while a 2-rank launch really runs 16 /
1147
+ 290: the scheduler-vs-corpus check below passes (it uses the live world
1148
+ size) and the banner is simply wrong about what ran.
1149
+
1150
+ THE ZOMBIE-RUN REFUSAL. Arm A ran a 146-step horizon over a corpus needing
1151
+ ~289 per-rank steps, so the cosine finished at half an epoch; past it
1152
+ `WarmupCosineSchedule` clamps to exactly 0 (min_lr_frac=0) and the loop
1153
+ kept stepping on frozen weights for 287 more steps. The horizon is a
1154
+ PRE-REGISTERED quantity: if it and the arithmetic disagree, one of them is
1155
+ wrong and no amount of training will reconcile them.
1156
+
1157
+ Computed from the SHARDED per-rank row count, because that is what a rank
1158
+ actually walks. `slack` absorbs the token-budgeted sampler landing a batch
1159
+ or two either side of the row-count arithmetic; it does not absorb a
1160
+ factor.
1161
+ """
1162
+ import math as _math
1163
+
1164
+ if min(n_pairs, world_size, micro_batch_size, grad_accum, epochs) < 1:
1165
+ raise ValueError("every horizon input must be >= 1")
1166
+
1167
+ if global_batch is not None:
1168
+ _check_global_batch(global_batch, world_size, micro_batch_size, grad_accum)
1169
+ per_rank_rows = _math.ceil(n_pairs / world_size)
1170
+ rows_per_step = micro_batch_size * grad_accum
1171
+ expected = epochs * _math.ceil(per_rank_rows / rows_per_step)
1172
+ if slack is None:
1173
+ # PROPORTIONAL slack: the token-budgeted sampler legitimately cuts
1174
+ # batches short of max_rows, so the sampler-true horizon runs a few
1175
+ # PERCENT over the row arithmetic (Arm B: 728 vs 708, 2.8%, which a
1176
+ # fixed slack of 2 wrongly refused). 5% absorbs batch-shortfall while
1177
+ # still catching the zombie class (Arm A was 2.1x, far outside).
1178
+ slack = max(2, _math.ceil(0.05 * expected))
1179
+ report = {
1180
+ "scheduler_total_steps": int(scheduler_total_steps),
1181
+ "expected_steps": expected,
1182
+ "per_rank_rows": per_rank_rows,
1183
+ "rows_per_optimizer_step_per_rank": rows_per_step,
1184
+ "unique_rows_per_optimizer_step": rows_per_step * world_size,
1185
+ "epochs_covered": (scheduler_total_steps / expected * epochs if expected else float("nan")),
1186
+ }
1187
+ if abs(int(scheduler_total_steps) - expected) > slack:
1188
+ covered = report["epochs_covered"]
1189
+ raise RuntimeError(
1190
+ f"DPO horizon is inconsistent with the corpus: the scheduler will "
1191
+ f"run {scheduler_total_steps} optimizer steps but "
1192
+ f"{n_pairs} pairs / {world_size} ranks = {per_rank_rows} rows per "
1193
+ f"rank at {rows_per_step} rows per step needs {expected} "
1194
+ f"(epochs={epochs}, slack={slack}). That horizon covers "
1195
+ f"{covered:.2f} epoch(s).\n\nA horizon shorter than the data is "
1196
+ "not a shorter run -- past it the cosine schedule clamps the "
1197
+ "learning rate to exactly 0 and the loop keeps stepping on frozen "
1198
+ "weights, which is how Arm A spent ~24 GPU-hours training "
1199
+ "nothing. Fix the horizon or the corpus arithmetic before "
1200
+ "launching."
1201
+ )
1202
+ return report
1203
+
1204
+
1205
+ #: The canonical name for the implicit reward margin -- section-05's kernel
1206
+ #: emits it and section-07's Trainer reads it. Imported from the Trainer
1207
+ #: rather than spelled again here: a second copy of the string, asserting what
1208
+ #: the Trainer reads, is precisely the drift that disarmed the tripwire the
1209
+ #: first time.
1210
+ from .trainer import MARGIN_KEY as KERNEL_MARGIN_KEY # noqa: E402
1211
+
1212
+
1213
+ def _arm_tripwire_metrics(metrics: dict, preference, window=None) -> dict:
1214
+ """Assert this adapter supplies what the armed tripwires consume.
1215
+
1216
+ THE DISARMED-TRIPWIRE INCIDENT, in its final form. Section-05's kernel
1217
+ emits the implicit margin as `rewards/margins`; section-07's Trainer used
1218
+ to seed a DIFFERENT key, `margin`, with NaN and feed the tripwire from
1219
+ that. Nothing produced it, `_slope` returns NaN for any window containing
1220
+ NaN, and the trip condition requires a non-NaN slope -- so the
1221
+ chosen-likelihood-collapse tripwire, the one with a pre-registered
1222
+ response and the one maximal for Arm B by design, could never fire while
1223
+ appearing fully wired.
1224
+
1225
+ The cure is section-07's, not this module's: the Trainer now reads
1226
+ `MARGIN_KEY` directly, tripwires declare `INPUT_KEYS`, and
1227
+ `assert_tripwires_armed` halts at the first log step if an armed guard's
1228
+ inputs are missing or NaN. This function no longer bridges names -- an
1229
+ alias here would write a key nothing reads.
1230
+
1231
+ What remains is a PRESENCE check at the seam, which is still worth its
1232
+ line: it catches a section-05 rename here, one function call after the
1233
+ kernel returns, rather than at the first log step with the metric already
1234
+ gone. `chosen_longer_fraction` is supplied for the same reason -- the
1235
+ length-drift tripwire's only input, and this adapter is the only thing
1236
+ that can compute it.
1237
+ """
1238
+ if KERNEL_MARGIN_KEY not in metrics:
1239
+ raise KeyError(
1240
+ f"the DPO kernel did not emit {KERNEL_MARGIN_KEY!r} (present: "
1241
+ f"{sorted(metrics)}). That is the canonical margin key the "
1242
+ "chosen-logprob tripwire consumes; without it the armed tripwire "
1243
+ "self-test halts the run, and this raise names the cause one call "
1244
+ "after the kernel returned instead of at the first log step."
1245
+ )
1246
+ if window is not None:
1247
+ window.extend(_chosen_longer_flags(preference))
1248
+ metrics["chosen_longer_fraction"] = sum(window) / len(window) if window else NAN
1249
+ metrics["chosen_longer_pairs"] = float(len(window))
1250
+ metrics.setdefault("chosen_longer_fraction", preference.stats()["chosen_longer_fraction"])
1251
+ metrics.setdefault("chosen_longer_fraction_batch", preference.stats()["chosen_longer_fraction"])
1252
+ return metrics
1253
+
1254
+
1255
+ def _chosen_longer_flags(preference) -> list[int]:
1256
+ """Per-pair `chosen completion is longer`, skipping pairs with no
1257
+ completion on either side.
1258
+
1259
+ Per PAIR, because the length-drift tripwire's input has to be a fraction
1260
+ over a sample whose size we control; the batch-level statistic is that
1261
+ same fraction over whatever happened to be in the micro-batch, which at
1262
+ micro_batch 2 is three possible values.
1263
+
1264
+ Completion tokens, not sequence tokens: Arm B's two sides share their
1265
+ entire prefix, so a sequence-length comparison is dominated by a term
1266
+ identical on both sides and would compress the fraction toward 0.5
1267
+ regardless of the data -- hiding exactly the drift being watched for.
1268
+ """
1269
+ c = preference.chosen_completion_mask.sum(dim=1)
1270
+ r = preference.rejected_completion_mask.sum(dim=1)
1271
+ return [int(ci > ri) for ci, ri in zip(c.tolist(), r.tolist()) if (ci + ri) > 0]
1272
+
1273
+
1274
+ def make_dpo_stage_adapter(
1275
+ cache, cfg: Optional[DpoStageConfig] = None, require_fused: bool = False
1276
+ ):
1277
+ """Trainer loss adapter: attach cached refs by pair_id, then delegate.
1278
+
1279
+ Bridges three things section-05 and section-03 do not agree on, and does
1280
+ it in one place so the disagreement cannot be re-resolved differently
1281
+ somewhere else:
1282
+
1283
+ * NAMES. Section-03's `Preference` carries `chosen_input_ids` /
1284
+ `ref_chosen_logps`; section-05's adapter unpacks `input_ids_chosen` /
1285
+ `ref_logp_chosen`. Neither is wrong; they were written to different
1286
+ drafts.
1287
+ * LABELS. `Preference` carries a completion MASK, `fused_dpo` wants
1288
+ labels with IGNORE_INDEX outside the completion. The conversion is one
1289
+ `masked_fill`, and getting it backwards would score the prompt -- which
1290
+ cancels in the DPO difference while inflating both magnitudes.
1291
+ * REFERENCES. They come from the cache, joined by pair_id, never from
1292
+ batch order and never defaulted.
1293
+
1294
+ The reference tensors enter as plain constants; section-05's kernel
1295
+ detaches them and proves no gradient path survives. This adapter's only
1296
+ obligation is never to build one, which it discharges by reading them out
1297
+ of a dict of floats.
1298
+ """
1299
+ from ..kernels.fused_dpo import IGNORE_INDEX
1300
+ from .dpo_loss import DpoLossConfig, make_dpo_loss_adapter
1301
+ from .ref_cache import attach_refs
1302
+
1303
+ stage = cfg or DpoStageConfig()
1304
+ #: Rolling per-PAIR history feeding the length-drift tripwire. Lives on
1305
+ #: the adapter, not on the batch: the statistic the tripwire needs spans
1306
+ #: batches, and computing it per batch is what makes it unusable.
1307
+ length_window = collections.deque(maxlen=stage.length_window_pairs)
1308
+ inner = make_dpo_loss_adapter(
1309
+ DpoLossConfig(
1310
+ beta=stage.beta,
1311
+ variant=stage.variant,
1312
+ nll_coeff=stage.nll_coeff,
1313
+ dpop_lambda=stage.dpop_lambda,
1314
+ ),
1315
+ require_fused=require_fused,
1316
+ # One trunk graph at a time: both sides' checkpointed 7B graphs plus
1317
+ # fp32 shard grads measured 21.5GB and OOM'd (batch-size independent).
1318
+ decoupled_backward=require_fused,
1319
+ grad_scale=1.0 / max(1, stage.grad_accum),
1320
+ )
1321
+
1322
+ def adapter(model, batch):
1323
+ import torch
1324
+
1325
+ pref = batch.preference if isinstance(batch, DpoBatch) else batch
1326
+ pref = attach_refs(pref, cache)
1327
+ # The WHOLE batch to the compute device, refs included. The model
1328
+ # forward moves input_ids internally, which let CPU labels survive
1329
+ # to the loss's gather ("index is on cpu") on the stage's first
1330
+ # real step -- the SFT script moves its collated batch explicitly,
1331
+ # and this adapter is the DPO stage's equivalent place.
1332
+ #
1333
+ # Follow the MODEL's device, not `cuda.is_available()`: on a box with
1334
+ # visible GPUs a CPU-resident model (the unit-test tiny model) would
1335
+ # otherwise get CUDA batches and die inside the embedding. Under FSDP2
1336
+ # the parameters are DTensors on this rank's cuda device, so the real
1337
+ # path is unchanged.
1338
+ param = next(iter(model.parameters()), None)
1339
+ if param is not None and param.device.type == "cuda":
1340
+ pref = pref.to(torch.device("cuda", torch.cuda.current_device()))
1341
+ elif param is None and torch.cuda.is_available():
1342
+ pref = pref.to(torch.device("cuda", torch.cuda.current_device()))
1343
+ bound = {
1344
+ "input_ids_chosen": pref.chosen_input_ids,
1345
+ "labels_chosen": pref.chosen_input_ids.masked_fill(
1346
+ pref.chosen_completion_mask == 0, IGNORE_INDEX
1347
+ ),
1348
+ "input_ids_rejected": pref.rejected_input_ids,
1349
+ "labels_rejected": pref.rejected_input_ids.masked_fill(
1350
+ pref.rejected_completion_mask == 0, IGNORE_INDEX
1351
+ ),
1352
+ "ref_logp_chosen": pref.ref_chosen_logps,
1353
+ "ref_logp_rejected": pref.ref_rejected_logps,
1354
+ }
1355
+ out = inner(model, bound)
1356
+ _arm_tripwire_metrics(out.metrics, pref, window=length_window)
1357
+ return out
1358
+
1359
+ adapter.uses_fused = getattr(inner, "uses_fused", True)
1360
+ adapter.config = inner.config
1361
+ adapter.stage_config = stage
1362
+ return adapter
code/align/train/fsdp.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FSDP2 wrapping and activation checkpointing.
2
+
3
+ Called from the LAUNCH SCRIPTS, never from the Trainer: the Trainer receives an
4
+ already-wrapped model, which is what keeps it identical between the SFT and DPO
5
+ stages and testable on CPU without a process group.
6
+
7
+ Two policies here are not configurable, deliberately:
8
+
9
+ * `use_reentrant=False`, hard-asserted. Reentrant activation checkpointing
10
+ silently ZEROES trunk gradients under sharding. The run trains -- loss
11
+ falls, throughput is normal -- while only the embeddings and the LM head
12
+ move. There is no knob to turn it back on, because the only reason anyone
13
+ would set it is not knowing this. The trunk-movement probe in `probes.py`
14
+ is the runtime backstop; this is the compile-time one.
15
+ * No `CPUOffloadPolicy`. FSDP's own CPU offload pays per-layer transfers on
16
+ every forward AND backward; our offload is optimizer-state-only and
17
+ happens once per optimizer step (see `offload_adamw.py`). Turning both on
18
+ would move the same bytes twice.
19
+
20
+ Sharding is PER TRANSFORMER BLOCK, then the root. Per-block units are what
21
+ make the all-gather granularity match the compute granularity: one unit for
22
+ the whole model would gather all 7.25B parameters before the first matmul.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import functools
28
+ import logging
29
+ from typing import Any, Optional
30
+
31
+ import torch
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ #: bf16 compute, fp32 reduction. bf16 is mandatory (fp16 overflows the
36
+ #: 32768-vocab logits); the fp32 REDUCE matters independently -- gradient
37
+ #: all-reduce in bf16 loses small updates across 4 ranks precisely when they
38
+ #: are averaged, which is every step.
39
+ PARAM_DTYPE = torch.bfloat16
40
+ REDUCE_DTYPE = torch.float32
41
+
42
+
43
+ def transformer_blocks(model) -> list:
44
+ """The per-block FSDP units: every `DecoderBlock` in the stack.
45
+
46
+ Found structurally rather than by isinstance so this module does not
47
+ import the model (and so a wrapped/compiled model still works).
48
+ """
49
+ inner = getattr(model, "model", model)
50
+ layers = getattr(inner, "layers", None)
51
+ if layers is None:
52
+ raise ValueError(
53
+ "no `.layers` found on the model: FSDP2 here shards per "
54
+ "transformer block, and a model with no block list would be "
55
+ "wrapped as ONE unit -- gathering all 7.25B parameters before the "
56
+ "first matmul, which does not fit on a 23GB card."
57
+ )
58
+ return list(layers)
59
+
60
+
61
+ def mixed_precision_policy():
62
+ """`MixedPrecisionPolicy(param_dtype=bf16, reduce_dtype=fp32)`."""
63
+ from torch.distributed.fsdp import MixedPrecisionPolicy
64
+
65
+ return MixedPrecisionPolicy(param_dtype=PARAM_DTYPE, reduce_dtype=REDUCE_DTYPE)
66
+
67
+
68
+ def apply_fsdp(model, mesh: Optional[Any] = None, reshard_after_forward: bool = True):
69
+ """Shard per transformer block, then the root.
70
+
71
+ Order is load-bearing: the root must be sharded LAST, after its children
72
+ are already units, or the root unit would own their parameters too.
73
+ """
74
+ from torch.distributed.fsdp import fully_shard
75
+
76
+ policy = mixed_precision_policy()
77
+ blocks = transformer_blocks(model)
78
+ for block in blocks:
79
+ fully_shard(block, mesh=mesh, mp_policy=policy, reshard_after_forward=reshard_after_forward)
80
+ fully_shard(model, mesh=mesh, mp_policy=policy, reshard_after_forward=reshard_after_forward)
81
+ logger.info(
82
+ "FSDP2: %d per-block units + root, param=%s reduce=%s",
83
+ len(blocks),
84
+ PARAM_DTYPE,
85
+ REDUCE_DTYPE,
86
+ )
87
+ return model
88
+
89
+
90
+ def _checkpointed_forward(block, *args, **kwargs):
91
+ from torch.utils.checkpoint import checkpoint
92
+
93
+ return checkpoint(block._nmo_inner_forward, *args, use_reentrant=False, **kwargs)
94
+
95
+
96
+ def apply_activation_checkpointing(model, use_reentrant: bool = False):
97
+ """Wrap every transformer block in non-reentrant activation checkpointing.
98
+
99
+ Patches each block's `forward` rather than wrapping the block in a new
100
+ module: a wrapper module would insert a level into the module tree and
101
+ change every parameter name, which would silently break the HF checkpoint
102
+ mapping and the interp pillar that loads through it.
103
+ """
104
+ if use_reentrant:
105
+ raise ValueError(
106
+ "use_reentrant=True is refused. Reentrant activation checkpointing "
107
+ "silently ZEROES trunk gradients under sharding: the run trains "
108
+ "the embeddings and the LM head, the loss falls, throughput looks "
109
+ "normal, and the body of the model never moves. This is a "
110
+ "documented predecessor failure and there is no configuration in "
111
+ "which we want it."
112
+ )
113
+ blocks = transformer_blocks(model)
114
+ for block in blocks:
115
+ if hasattr(block, "_nmo_inner_forward"):
116
+ continue # idempotent: never double-wrap
117
+ block._nmo_inner_forward = block.forward
118
+ block.forward = functools.partial(_checkpointed_forward, block)
119
+ logger.info("activation checkpointing on %d blocks (use_reentrant=False)", len(blocks))
120
+ return model
121
+
122
+
123
+ def is_checkpointed(block) -> bool:
124
+ return hasattr(block, "_nmo_inner_forward")
code/align/train/lora_scale.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LoRA scale sweep WITHOUT retraining (SOTA plan K4).
2
+
3
+ A LoRA layer computes `W x + s * B A x` with s = alpha / r. Evaluating the SAME trained adapter at a
4
+ multiple `k` of that delta (k in {0.5, 0.7, 1.0}: "alpha curve") needs no optimisation: set s -> k*s.
5
+ k = 1 is the trained model, k = 0 is the base, and the curve between them tells whether a regression
6
+ is "too much adapter" (a smaller k recovers it) or "the wrong direction" (it does not).
7
+
8
+ Two entry points, matching the two ways an adapter is evaluated:
9
+
10
+ * `scaled_adapter(model, adapter, k)` -- a context manager over a LIVE model with the adapter attached
11
+ (the un-merged serving option); `sweep(...)` runs an eval callable at each k.
12
+ * `merged_state_dict_at_scale(model, full_state, adapter, k)` -- a BASE-keyed merged state dict at
13
+ scale k, for merged exports, with exactly the bf16-cast fidelity report of
14
+ `align.ppo.lora.export_merged_state_dict` (the delta is k times smaller, so MORE of it can fall into
15
+ the bf16 dead zone: read `report["cosine"]` before trusting a small-k merged eval).
16
+
17
+ Refusals: a layer that has the adapter MERGED into its weight cannot be rescaled (the delta is already
18
+ inside W); the context manager raises instead of evaluating a polluted point. The model is never left
19
+ rescaled: the original scaling is restored in `finally`, on error and on KeyboardInterrupt alike.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import contextlib
25
+ import math
26
+ from collections.abc import Callable, Iterable, Iterator
27
+ from typing import Any, Optional
28
+
29
+ import torch
30
+ import torch.nn as nn
31
+
32
+ from ..ppo.lora import export_merged_state_dict, lora_modules
33
+
34
+ #: The plan's K4 grid: fractions of the TRAINED adapter delta.
35
+ DEFAULT_SCALES: tuple[float, ...] = (0.5, 0.7, 1.0)
36
+
37
+
38
+ def _check_scale(k: float) -> float:
39
+ k = float(k)
40
+ if not math.isfinite(k) or k < 0.0:
41
+ raise ValueError(f"LoRA delta scale must be a finite number >= 0, got {k!r}")
42
+ return k
43
+
44
+
45
+ @contextlib.contextmanager
46
+ def scaled_adapter(model: nn.Module, adapter: str, k: float) -> Iterator[int]:
47
+ """Within the block, every layer's `adapter` contributes `k` times its trained delta.
48
+
49
+ Yields the number of layers rescaled. Raises (before changing anything) if no layer carries the
50
+ adapter or if any layer has it merged.
51
+ """
52
+ k = _check_scale(k)
53
+ layers = [(p, m) for p, m in lora_modules(model) if adapter in m.adapters]
54
+ if not layers:
55
+ raise KeyError(f"no layer carries adapter {adapter!r}; nothing to scale")
56
+ merged = [p for p, m in layers if m._merged is not None]
57
+ if merged:
58
+ raise RuntimeError(
59
+ f"adapter {adapter!r} is merged into {len(merged)} layer(s) (first: {merged[0]}); the delta is "
60
+ "already inside the weight and cannot be rescaled. Unmerge first, or use "
61
+ "merged_state_dict_at_scale on the unmerged model."
62
+ )
63
+ original = [(m.adapters[adapter], m.adapters[adapter].scaling) for _, m in layers]
64
+ try:
65
+ for ad, s in original:
66
+ ad.scaling = s * k
67
+ yield len(layers)
68
+ finally:
69
+ for ad, s in original:
70
+ ad.scaling = s
71
+
72
+
73
+ def sweep(
74
+ model: nn.Module,
75
+ adapter: str,
76
+ eval_fn: Callable[[nn.Module], Any],
77
+ scales: Iterable[float] = DEFAULT_SCALES,
78
+ ) -> dict[float, Any]:
79
+ """`{k: eval_fn(model)}` with the adapter delta scaled to k each time. `eval_fn` sees the live model."""
80
+ out: dict[float, Any] = {}
81
+ for k in scales:
82
+ k = _check_scale(k)
83
+ if k in out:
84
+ raise ValueError(f"duplicate scale {k} in the sweep")
85
+ with scaled_adapter(model, adapter, k):
86
+ out[k] = eval_fn(model)
87
+ return out
88
+
89
+
90
+ def merged_state_dict_at_scale(
91
+ model: nn.Module,
92
+ full_state: dict[str, torch.Tensor],
93
+ adapter: str,
94
+ k: float,
95
+ report: Optional[dict] = None,
96
+ min_cosine: Optional[float] = None,
97
+ ) -> dict[str, torch.Tensor]:
98
+ """`export_merged_state_dict` with the adapter delta scaled by `k` (the model is not mutated).
99
+
100
+ k = 1.0 is bit-identical to `export_merged_state_dict`. k = 0 returns the base weights (every nonzero
101
+ delta is zero, so `report` has cosine 1.0 and delta_norm 0). `report["scale"]` records k.
102
+ """
103
+ k = _check_scale(k)
104
+ rep: dict = {}
105
+ with scaled_adapter(model, adapter, k):
106
+ out = export_merged_state_dict(model, full_state, adapter, report=rep, min_cosine=min_cosine)
107
+ rep["scale"] = k
108
+ if report is not None:
109
+ report.update(rep)
110
+ return out
code/align/train/moe_module.py ADDED
@@ -0,0 +1,691 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MoE auxiliary losses on the training seam: `MoETrainModule`.
2
+
3
+ The model (`align.model.olmoe`) adds NO aux loss: it RETURNS per-layer router
4
+ statistics (`RouterStats`) when asked. This module is where those become a
5
+ training objective, because only the TrainModule sees all three things the
6
+ objective depends on: the loss adapter's CE (an opaque scalar), the
7
+ gradient-accumulation window (`grad_accum`), and the data-parallel group.
8
+
9
+ THE MATH, and the one approximation in it
10
+ -----------------------------------------
11
+ Switch/OLMoE load balance, per layer: `E * sum_i f_i * P_i` with `f_i` the hard
12
+ top-k hit fraction (no gradient) and `P_i` the mean router probability
13
+ (gradient). Which batch `f_i` and `P_i` are measured over is the whole question
14
+ (Qiu et al., "Demons in the Detail"): per-micro-batch statistics over-constrain
15
+ the router when micro-batches are domain-homogeneous, because a sequence of
16
+ code must be allowed to use the code experts heavily. The loss should balance
17
+ over the GLOBAL batch -- every rank, every micro-batch of the optimizer step.
18
+
19
+ Exact global `f_i` is not available at micro-batch `m`: the window's later
20
+ micro-batches have not been run, and their backward cannot be deferred without
21
+ retaining every micro-batch's activations. Demons' Algorithm 1 resolves it with
22
+ a BUFFER: keep the window's running (all-reduced) counts, and take `f_i` from
23
+ the buffer as it stands when micro-batch `m` runs. The approximation is
24
+ therefore confined to `f_i` of micro-batches 1..accum-1; at the LAST micro-batch
25
+ `f_i` is exactly the global-batch fraction, and the logged `moe/lb_loss` (computed
26
+ from the full buffer at the optimizer-step boundary) is exact.
27
+
28
+ SCALING (why `world * m`). The Trainer divides each micro loss by `grad_accum`
29
+ and the gradient reduction averages over `world` ranks, so micro-batch `m` on
30
+ rank `r` reaches the weights with weight `1/(world*accum)`. For the window's
31
+ gradient to equal the gradient of ONE global loss
32
+ `E * sum_i f_i * (sum_{r,m} prob_sum_{r,m,i}) / N`, each term must be multiplied
33
+ by `world * accum / N_window`, and `N_window` (all valid tokens of the window) is
34
+ unknown until the end; estimated as `n_buf * accum / m` it gives
35
+ `world * m / n_buf`. At `m = accum` the estimate is exact. The same factor
36
+ scales the z-loss. In "micro" scope (the per-rank formulation, kept for
37
+ ablations) the factor is 1 and everything is local.
38
+
39
+ WHAT IS ALL-REDUCED, and when. One packed float64 tensor per micro-batch
40
+ [counts L*E | tokens L | prob_sum L*E | z_sum L | entropy_sum L]. DETACHED data
41
+ only: the gradient of the loss is each rank differentiating its own
42
+ `prob_sum`/`z_sum`, never a gradient crossing ranks here. Every rank calls the
43
+ collective the same number of times (the Trainer runs micro-steps in lockstep),
44
+ and the buffer is reset at `optim_step()`, which the Trainer calls on every
45
+ boundary whether or not the optimizer applied the step.
46
+
47
+ DEFAULTS. Load balance and z-loss are OFF for post-training stages and
48
+ 0.01 / 0.001 for `pretrain` / `proxy` (see `MoEAuxConfig`); telemetry
49
+ (MaxVio, utilization, dead experts, router entropy) is logged either way.
50
+
51
+ LIMITS (stated, not hidden)
52
+ * Adapters that run their own backward and return a detached proxy (the DPO
53
+ decoupled-backward adapter) cannot carry an aux loss: the router graph is
54
+ consumed by the adapter's backward. With a nonzero weight this raises; with
55
+ zero weights telemetry still works (it reads detached data).
56
+ * Not validated under FSDP2 on GPU (no GPU in this lane): the stats ride out
57
+ of the wrapped forward as plain dataclass fields rather than pytree leaves.
58
+ * The returned `LossOutput.loss` is the CE alone (comparable with dense runs).
59
+ If an aux term is non-finite the returned loss is NaN, so the Trainer's
60
+ non-finite-loss accounting sees it.
61
+
62
+ EXPERT PARALLELISM (opt-in: `ep_groups`, default None = nothing below applies)
63
+ -----------------------------------------------------------------------------
64
+ `align.model.moe_ep` shards the experts across an EP group. The caller applies it
65
+ (`apply_expert_parallel(model, groups)`) BEFORE the optimizer is built -- the
66
+ optimizer must hold the shard parameters, and sharding after construction would
67
+ leave it stepping tensors that are no longer in the model. This module then does
68
+ the three things only the TrainModule can:
69
+ * the aux losses need NO change: the router is replicated, `RouterStats` come from
70
+ local tokens exactly as without EP, and `MoEAux` all-reduces them over the
71
+ DATA-parallel group (`group`), which contains the EP group.
72
+ * `optim_step` puts expert-shard gradients on the replicated-gradient scale
73
+ (`finalize_expert_grads`: they arrive SUMMED over the EP group) and clips by the
74
+ EP-GLOBAL norm (`clip_grads_`). The wrapped optimizer must therefore NOT clip
75
+ itself (`max_grad_norm` must be None): its norm would be shard-local, ranks would
76
+ clip by different coefficients and the replicated parameters would drift apart.
77
+ Pass the clip value as `ep_max_grad_norm`; `last_grad_norm` is the PRE-clip global.
78
+ * `full_model_state_dict` / `load_model_state_dict` gather / slice the expert shards
79
+ (a collective over the EP group), so a checkpoint is the same file a non-EP run writes.
80
+ """
81
+
82
+ from __future__ import annotations
83
+
84
+ import logging
85
+ import math
86
+ from dataclasses import dataclass
87
+ from typing import Any, Optional
88
+
89
+ import torch
90
+
91
+ from ..config import MoEAuxConfig
92
+ from ..model import moe
93
+ from .train_module import LossAdapter, LossOutput, TrainModule
94
+
95
+ logger = logging.getLogger(__name__)
96
+
97
+ NAN = float("nan")
98
+
99
+ #: (lb_weight, z_weight) per stage when the config leaves a weight as None.
100
+ #: Stages that train a router FROM SCRATCH balance it; everything else leaves
101
+ #: the pretrained router alone. See MoEAuxConfig for the argument.
102
+ STAGE_AUX_DEFAULTS: dict[str, tuple[float, float]] = {
103
+ "pretrain": (0.01, 0.001),
104
+ "proxy": (0.01, 0.001),
105
+ }
106
+ POST_TRAINING_AUX_DEFAULT = (0.0, 0.0)
107
+ LB_SCOPES = ("global", "micro")
108
+
109
+
110
+ def resolve_aux_weights(aux: MoEAuxConfig, stage: str) -> tuple[float, float]:
111
+ """(lb_weight, z_weight) after applying the stage default to any `None`."""
112
+ d_lb, d_z = STAGE_AUX_DEFAULTS.get(stage, POST_TRAINING_AUX_DEFAULT)
113
+ lb = d_lb if aux.lb_weight is None else float(aux.lb_weight)
114
+ z = d_z if aux.z_weight is None else float(aux.z_weight)
115
+ for name, v in (("lb_weight", lb), ("z_weight", z)):
116
+ if not (math.isfinite(v) and v >= 0.0):
117
+ raise ValueError(f"moe_aux.{name}={v!r} must be a finite number >= 0")
118
+ if aux.lb_scope not in LB_SCOPES:
119
+ raise ValueError(f"moe_aux.lb_scope={aux.lb_scope!r}; known: {LB_SCOPES}")
120
+ return lb, z
121
+
122
+
123
+ # ---------------------------------------------------------------------------
124
+ # the window buffer (Demons Alg. 1)
125
+ # ---------------------------------------------------------------------------
126
+
127
+
128
+ @dataclass
129
+ class WindowTotals:
130
+ """Detached sums over (ranks x micro-batches so far): everything the aux
131
+ losses and the telemetry need, nothing with a gradient."""
132
+
133
+ counts: torch.Tensor # [L, E] float64: top-k hits
134
+ n_tokens: torch.Tensor # [L] float64: valid tokens
135
+ prob_sum: torch.Tensor # [L, E] float64: sum of softmax probs
136
+ z_sum: torch.Tensor # [L] float64: sum of lse^2
137
+ entropy_sum: torch.Tensor # [L] float64
138
+
139
+ def __add__(self, o: "WindowTotals") -> "WindowTotals":
140
+ return WindowTotals(
141
+ self.counts + o.counts,
142
+ self.n_tokens + o.n_tokens,
143
+ self.prob_sum + o.prob_sum,
144
+ self.z_sum + o.z_sum,
145
+ self.entropy_sum + o.entropy_sum,
146
+ )
147
+
148
+ @classmethod
149
+ def of(cls, stats: list[moe.RouterStats]) -> "WindowTotals":
150
+ d = torch.float64
151
+ return cls(
152
+ counts=torch.stack([s.counts for s in stats]).to(d),
153
+ n_tokens=torch.stack([s.n_tokens for s in stats]).to(d),
154
+ prob_sum=torch.stack([s.prob_sum.detach() for s in stats]).to(d),
155
+ z_sum=torch.stack([s.z_sum.detach() for s in stats]).to(d),
156
+ entropy_sum=torch.stack([s.entropy_sum.detach() for s in stats]).to(d),
157
+ )
158
+
159
+ def all_reduce(self, group=None) -> "WindowTotals":
160
+ """Sum over a process group in ONE collective (packed)."""
161
+ import torch.distributed as dist
162
+
163
+ n_layers, e = self.counts.shape
164
+ packed = torch.cat(
165
+ [
166
+ self.counts.reshape(-1),
167
+ self.n_tokens,
168
+ self.prob_sum.reshape(-1),
169
+ self.z_sum,
170
+ self.entropy_sum,
171
+ ]
172
+ ).contiguous()
173
+ dist.all_reduce(packed, group=group)
174
+ le = n_layers * e
175
+ o = 0
176
+ counts = packed[o : o + le].reshape(n_layers, e)
177
+ o += le
178
+ n = packed[o : o + n_layers]
179
+ o += n_layers
180
+ ps = packed[o : o + le].reshape(n_layers, e)
181
+ o += le
182
+ z = packed[o : o + n_layers]
183
+ o += n_layers
184
+ ent = packed[o : o + n_layers]
185
+ return WindowTotals(counts, n, ps, z, ent)
186
+
187
+
188
+ def merge_call_stats(calls: list[list[moe.RouterStats]]) -> list[moe.RouterStats]:
189
+ """Combine the stats of several grad-enabled forwards of ONE micro-batch.
190
+
191
+ One call (SFT, pretrain) is the identity. Several (a DPO adapter that runs
192
+ chosen and rejected separately) are summed per layer: they are different
193
+ token populations of the same optimizer step, so counts, probability sums,
194
+ z sums and token counts all ADD, and the losses are then over their union.
195
+ """
196
+ if not calls:
197
+ raise ValueError("no router stats captured")
198
+ if len(calls) == 1:
199
+ return calls[0]
200
+ n_layers = len(calls[0])
201
+ if any(len(c) != n_layers for c in calls):
202
+ raise ValueError(f"forwards returned different layer counts: {[len(c) for c in calls]}")
203
+ out = []
204
+ for layer in range(n_layers):
205
+ parts = [c[layer] for c in calls]
206
+ out.append(
207
+ moe.RouterStats(
208
+ counts=sum(p.counts for p in parts),
209
+ prob_sum=sum(p.prob_sum for p in parts),
210
+ n_tokens=sum(p.n_tokens for p in parts),
211
+ z_sum=sum(p.z_sum for p in parts),
212
+ entropy_sum=sum(p.entropy_sum for p in parts),
213
+ )
214
+ )
215
+ return out
216
+
217
+
218
+ class MoEAux:
219
+ """The aux-loss math and the window buffer, independent of any Trainer.
220
+
221
+ `micro(stats)` is called once per micro-batch, in lockstep on every rank,
222
+ and returns `(lb_term, z_term)` ALREADY scaled for the Trainer's
223
+ `/grad_accum` and the gradient reduction's mean over ranks (see the module
224
+ docstring). `window_end()` returns the exact global-window telemetry and
225
+ resets the buffer.
226
+ """
227
+
228
+ def __init__(
229
+ self,
230
+ top_k: int,
231
+ grad_accum: int,
232
+ *,
233
+ scope: str = "global",
234
+ group=None,
235
+ world_size: Optional[int] = None,
236
+ ):
237
+ if scope not in LB_SCOPES:
238
+ raise ValueError(f"scope {scope!r}; known: {LB_SCOPES}")
239
+ if grad_accum < 1:
240
+ raise ValueError(f"grad_accum must be >= 1, got {grad_accum}")
241
+ self.top_k = int(top_k)
242
+ self.grad_accum = int(grad_accum)
243
+ self.scope = scope
244
+ self.group = group
245
+ self._world = world_size
246
+ self.m = 0 # micro-batches seen in the current window
247
+ self.buf: Optional[WindowTotals] = None
248
+
249
+ # -- world ----------------------------------------------------------
250
+ def world(self) -> int:
251
+ if self._world is not None:
252
+ return self._world
253
+ import torch.distributed as dist
254
+
255
+ if dist.is_available() and dist.is_initialized():
256
+ return dist.get_world_size(self.group)
257
+ return 1
258
+
259
+ def _distributed(self) -> bool:
260
+ import torch.distributed as dist
261
+
262
+ return dist.is_available() and dist.is_initialized() and self.world() > 1
263
+
264
+ def reset(self) -> None:
265
+ self.m = 0
266
+ self.buf = None
267
+
268
+ # -- one micro-batch --------------------------------------------------
269
+ def micro(
270
+ self, stats: list[moe.RouterStats], want_lb: bool = True, want_z: bool = True
271
+ ) -> tuple[Optional[torch.Tensor], Optional[torch.Tensor]]:
272
+ if self.m >= self.grad_accum:
273
+ raise RuntimeError(
274
+ f"{self.m + 1} micro-batches in one accumulation window (grad_accum="
275
+ f"{self.grad_accum}): optim_step()/reset() was not called at the boundary"
276
+ )
277
+ self.m += 1
278
+ local = WindowTotals.of(stats)
279
+ glob = local.all_reduce(self.group) if self._distributed() else local
280
+ self.buf = glob if self.buf is None else self.buf + glob
281
+ buf = self.buf
282
+ if not bool((buf.n_tokens > 0).all()):
283
+ raise ValueError(
284
+ "a MoE layer has seen zero valid tokens in this window: every position was masked. "
285
+ "The balance and z-losses are undefined over zero tokens (and returning 0.0 would "
286
+ "report a perfectly balanced router)."
287
+ )
288
+ lb = z = None
289
+ if self.scope == "global":
290
+ scale = float(self.world() * self.m)
291
+ if want_lb:
292
+ lb = scale * moe.load_balancing_loss(
293
+ stats,
294
+ self.top_k,
295
+ global_totals=moe.Totals(counts=buf.counts, n_tokens=buf.n_tokens),
296
+ )
297
+ if want_z:
298
+ # n_tokens is identical across layers (every layer routes the
299
+ # same valid tokens); the scalar is what router_z_loss takes.
300
+ z = scale * moe.router_z_loss(stats, loss_div_factor=buf.n_tokens[0])
301
+ else:
302
+ if want_lb:
303
+ lb = moe.load_balancing_loss(stats, self.top_k)
304
+ if want_z:
305
+ z = moe.router_z_loss(stats)
306
+ return lb, z
307
+
308
+ # -- the boundary -----------------------------------------------------
309
+ def window_values(self) -> dict[str, Any]:
310
+ """Exact global-window quantities (float64 tensors) from the buffer."""
311
+ buf = self.buf
312
+ if buf is None:
313
+ raise RuntimeError("window_values() before any micro-batch")
314
+ n_layers, e = buf.counts.shape
315
+ n = buf.n_tokens.unsqueeze(1)
316
+ f = buf.counts / (self.top_k * n)
317
+ p = buf.prob_sum / n
318
+ lb = (e * (f * p).sum(dim=1)).mean()
319
+ z = (buf.z_sum / buf.n_tokens).mean()
320
+ ent = buf.entropy_sum / buf.n_tokens
321
+ per_layer = [moe.expert_load_metrics(buf.counts[i]) for i in range(n_layers)]
322
+ return {
323
+ "lb": lb,
324
+ "z": z,
325
+ "entropy": ent,
326
+ "f": f,
327
+ "max_vio": torch.stack([d["max_vio"] for d in per_layer]),
328
+ "utilization": torch.stack([d["utilization"] for d in per_layer]),
329
+ "frac_dead": torch.stack([d["frac_dead"] for d in per_layer]),
330
+ "load_entropy": torch.stack([d["load_entropy"] for d in per_layer]),
331
+ }
332
+
333
+
334
+ def window_metrics(values: dict[str, Any], per_layer: bool = False) -> dict[str, float]:
335
+ """The `moe/*` history keys from `MoEAux.window_values()`. ONE host sync."""
336
+ n_layers = int(values["max_vio"].numel())
337
+ flat = torch.cat(
338
+ [
339
+ values["lb"].reshape(1),
340
+ values["z"].reshape(1),
341
+ values["entropy"],
342
+ values["max_vio"],
343
+ values["utilization"],
344
+ values["frac_dead"],
345
+ values["load_entropy"],
346
+ ]
347
+ ).tolist()
348
+ lb, z = flat[0], flat[1]
349
+ o = 2
350
+ ent = flat[o : o + n_layers]
351
+ o += n_layers
352
+ vio = flat[o : o + n_layers]
353
+ o += n_layers
354
+ util = flat[o : o + n_layers]
355
+ o += n_layers
356
+ dead = flat[o : o + n_layers]
357
+ o += n_layers
358
+ lent = flat[o : o + n_layers]
359
+ out = {
360
+ "moe/lb_loss": lb,
361
+ "moe/z_loss": z,
362
+ "moe/max_vio": sum(vio) / n_layers,
363
+ "moe/max_vio_worst": max(vio),
364
+ "moe/utilization": sum(util) / n_layers,
365
+ "moe/utilization_worst": min(util),
366
+ "moe/frac_dead_worst": max(dead),
367
+ "moe/load_entropy": sum(lent) / n_layers,
368
+ "moe/router_entropy": sum(ent) / n_layers,
369
+ }
370
+ if per_layer:
371
+ for i in range(n_layers):
372
+ out[f"moe/l{i}/max_vio"] = vio[i]
373
+ out[f"moe/l{i}/utilization"] = util[i]
374
+ return out
375
+
376
+
377
+ #: The keys a MoE run logs, NaN until the first window completes. Fixed set so
378
+ #: every history.jsonl row has the same columns.
379
+ def undefined_window_metrics(n_layers: Optional[int] = None, per_layer: bool = False) -> dict[str, float]:
380
+ keys = [
381
+ "moe/lb_loss",
382
+ "moe/z_loss",
383
+ "moe/max_vio",
384
+ "moe/max_vio_worst",
385
+ "moe/utilization",
386
+ "moe/utilization_worst",
387
+ "moe/frac_dead_worst",
388
+ "moe/load_entropy",
389
+ "moe/router_entropy",
390
+ ]
391
+ out = {k: NAN for k in keys}
392
+ if per_layer and n_layers:
393
+ for i in range(n_layers):
394
+ out[f"moe/l{i}/max_vio"] = NAN
395
+ out[f"moe/l{i}/utilization"] = NAN
396
+ return out
397
+
398
+
399
+ # ---------------------------------------------------------------------------
400
+ # the model proxy
401
+ # ---------------------------------------------------------------------------
402
+
403
+
404
+ class _StatsModel:
405
+ """What the loss adapter is handed instead of the model.
406
+
407
+ Adapters call `model(input_ids=..., hidden_consumer=...)` and expect the
408
+ loss (or logits) back. The proxy adds `return_router_stats=True` (only when
409
+ gradients are enabled -- a reference-model forward under `no_grad` has
410
+ nothing to balance), unwraps the `(out, stats)` tuple, and stashes the
411
+ stats for the module. Every other attribute is delegated, so adapters that
412
+ read `model.lm_head` or `model.attn_implementation` are unaffected.
413
+ """
414
+
415
+ def __init__(self, owner: "MoETrainModule"):
416
+ object.__setattr__(self, "_owner", owner)
417
+ object.__setattr__(self, "calls", [])
418
+ object.__setattr__(self, "attention_mask", None)
419
+
420
+ def __call__(self, *args, **kwargs):
421
+ owner = self._owner
422
+ model = owner.model
423
+ want = torch.is_grad_enabled()
424
+ if want:
425
+ kwargs["return_router_stats"] = True
426
+ if kwargs.get("attention_mask") is None and self.attention_mask is not None:
427
+ # The batch's real-token mask: padding must not vote in the
428
+ # balance loss. The model's attention applies it too (a no-op
429
+ # for valid tokens under right padding, identical outputs).
430
+ kwargs["attention_mask"] = self.attention_mask
431
+ try:
432
+ out = model(*args, **kwargs)
433
+ except TypeError as e:
434
+ if want and "return_router_stats" in str(e):
435
+ raise RuntimeError(
436
+ "MoETrainModule needs a model whose forward accepts return_router_stats=True "
437
+ f"(got {type(model).__name__}); a dense model has no router to balance. "
438
+ "Set moe_aux weights to 0 and use the plain TrainModule."
439
+ ) from e
440
+ raise
441
+ if not want:
442
+ return out
443
+ out, stats = out
444
+ self.calls.append(stats)
445
+ return out
446
+
447
+ def forward_hidden_states(self, **kwargs):
448
+ """Route `forward_hidden_states` through THIS proxy's `__call__`.
449
+
450
+ The DPO adapter (coupled AND decoupled) never calls `model(...)`: it
451
+ calls `model.forward_hidden_states(...)`. Without this method the
452
+ attribute falls through `__getattr__` to the REAL model's bound method,
453
+ which runs without `return_router_stats`, so the adapter "ran no
454
+ grad-enabled forward" as far as the module can tell and the first
455
+ micro-step raises -- with default settings (telemetry on) on a stage
456
+ whose docstring promises telemetry still works. Mirrors
457
+ `OlmoeForCausalLM.forward_hidden_states`, which is `self(...,
458
+ return_hidden_states=True)`.
459
+ """
460
+ return self(**kwargs, return_hidden_states=True)
461
+
462
+ def __getattr__(self, name):
463
+ return getattr(self._owner.model, name)
464
+
465
+
466
+ # ---------------------------------------------------------------------------
467
+ # the train module
468
+ # ---------------------------------------------------------------------------
469
+
470
+
471
+ class MoETrainModule(TrainModule):
472
+ """`TrainModule` + router aux losses + MoE telemetry.
473
+
474
+ `train_batch` does what the base does -- forward through the loss adapter,
475
+ then `(loss / grad_accum).backward()` -- with `loss = CE + lb_weight * lb +
476
+ z_weight * z`. See the module docstring for the window buffer and scaling.
477
+ """
478
+
479
+ def __init__(
480
+ self,
481
+ model,
482
+ loss_adapter: LossAdapter,
483
+ optimizer,
484
+ grad_accum: int,
485
+ *,
486
+ aux: Optional[MoEAuxConfig] = None,
487
+ stage: str = "sft",
488
+ top_k: Optional[int] = None,
489
+ weight_format=None,
490
+ group=None,
491
+ world_size: Optional[int] = None,
492
+ ep_groups=None,
493
+ ep_max_grad_norm: Optional[float] = None,
494
+ ):
495
+ super().__init__(model, loss_adapter, optimizer, grad_accum)
496
+ self.ep_groups = ep_groups
497
+ self.ep_max_grad_norm = ep_max_grad_norm
498
+ self._ep_norm = float("nan")
499
+ if ep_groups is not None:
500
+ self._check_expert_parallel(model, optimizer, ep_groups)
501
+ self.aux_cfg = aux or MoEAuxConfig()
502
+ self.stage = stage
503
+ self.lb_weight, self.z_weight = resolve_aux_weights(self.aux_cfg, stage)
504
+ if top_k is None:
505
+ top_k = getattr(getattr(model, "cfg", None), "top_k", None)
506
+ if top_k is None:
507
+ raise ValueError("top_k not given and model.cfg.top_k not found")
508
+ self.top_k = int(top_k)
509
+ self.weight_format = weight_format
510
+ self.collect = bool(self.aux_cfg.telemetry or self.lb_weight > 0 or self.z_weight > 0)
511
+ self._aux = MoEAux(
512
+ self.top_k, grad_accum, scope=self.aux_cfg.lb_scope, group=group, world_size=world_size
513
+ )
514
+ self._proxy = _StatsModel(self)
515
+ self._last_metrics: dict[str, float] = {}
516
+ logger.info(
517
+ "MoE aux: stage=%s lb_weight=%g z_weight=%g scope=%s telemetry=%s top_k=%d",
518
+ stage,
519
+ self.lb_weight,
520
+ self.z_weight,
521
+ self.aux_cfg.lb_scope,
522
+ self.aux_cfg.telemetry,
523
+ self.top_k,
524
+ )
525
+
526
+ # -- expert parallelism (opt-in) ---------------------------------------
527
+ @staticmethod
528
+ def _check_expert_parallel(model, optimizer, groups) -> None:
529
+ """Refuse the three silent EP misconfigurations instead of training through them."""
530
+ from ..model import moe_ep
531
+
532
+ blocks = moe_ep._moe_blocks(model)
533
+ if not blocks or any(b.ep is None for b in blocks):
534
+ raise ValueError(
535
+ "ep_groups was given but the model's MoE blocks are not expert-parallel: call "
536
+ "align.model.moe_ep.apply_expert_parallel(model, groups) BEFORE building the optimizer"
537
+ )
538
+ if any(b.ep.groups is not groups for b in blocks):
539
+ raise ValueError("ep_groups is not the group set the model was sharded with")
540
+ if getattr(optimizer, "max_grad_norm", None) is not None:
541
+ raise ValueError(
542
+ "the optimizer clips by its own (shard-local) gradient norm; under expert parallelism "
543
+ "that clips every rank by a different coefficient. Build it with max_grad_norm=None and "
544
+ "pass ep_max_grad_norm to MoETrainModule"
545
+ )
546
+
547
+ def optim_step(self) -> bool:
548
+ if self.ep_groups is not None:
549
+ from ..model import moe_ep
550
+
551
+ moe_ep.finalize_expert_grads(self.model, self.ep_groups)
552
+ if self.ep_max_grad_norm is not None:
553
+ norm = moe_ep.clip_grads_(self.model, self.ep_groups, self.ep_max_grad_norm)
554
+ else:
555
+ norm = moe_ep.global_grad_norm(self.model, self.ep_groups)
556
+ self._ep_norm = float(norm)
557
+ try:
558
+ return super().optim_step()
559
+ finally:
560
+ self._aux.reset()
561
+
562
+ @property
563
+ def last_grad_norm(self) -> float:
564
+ return self._ep_norm if self.ep_groups is not None else super().last_grad_norm
565
+
566
+ def full_model_state_dict(self):
567
+ sd = super().full_model_state_dict()
568
+ if self.ep_groups is not None:
569
+ from ..model import moe_ep
570
+
571
+ # the gather is a collective (every rank calls it); like the base state dict (rank 0 only
572
+ # when nothing is a DTensor) the result is merged where the base returned something
573
+ gathered = moe_ep.gather_full_expert_state(self.model, self.ep_groups, offload_to_cpu=True)
574
+ if sd:
575
+ for k, v in gathered.items():
576
+ sd[k] = v.cpu()
577
+ return sd
578
+
579
+ def load_model_state_dict(self, nmo_state: dict) -> None:
580
+ if self.ep_groups is not None:
581
+ from ..model import moe_ep
582
+
583
+ nmo_state = {**nmo_state, **moe_ep.local_expert_slices(self.model, self.ep_groups, nmo_state)}
584
+ super().load_model_state_dict(nmo_state)
585
+
586
+ # -- the loop-facing call ---------------------------------------------
587
+ def train_batch(self, batch) -> LossOutput:
588
+ if not self.collect:
589
+ return super().train_batch(batch)
590
+
591
+ proxy = self._proxy
592
+ proxy.calls.clear()
593
+ mask = getattr(batch, "attention_mask", None)
594
+ proxy.attention_mask = mask if isinstance(mask, torch.Tensor) else None
595
+ out = self.loss_adapter(proxy, batch)
596
+ calls = list(proxy.calls)
597
+ proxy.calls.clear()
598
+ if not calls:
599
+ raise RuntimeError(
600
+ "the loss adapter ran no grad-enabled forward through the model, so no router "
601
+ "statistics exist; MoE aux losses/telemetry cannot be computed"
602
+ )
603
+ stats = merge_call_stats(calls)
604
+ want_lb, want_z = self.lb_weight > 0, self.z_weight > 0
605
+ lb, z = self._aux.micro(stats, want_lb=want_lb, want_z=want_z)
606
+
607
+ ce = out.loss
608
+ total = ce
609
+ decoupled = ce.grad_fn is None
610
+ if want_lb or want_z:
611
+ if decoupled:
612
+ raise NotImplementedError(
613
+ "the loss adapter returned a detached proxy (decoupled backward, e.g. DPO): "
614
+ "its backward already consumed the model graph, so a router aux loss cannot "
615
+ "be added. Set moe_aux.lb_weight = z_weight = 0 for this stage "
616
+ "(telemetry still works)."
617
+ )
618
+ if want_lb:
619
+ total = total + self.lb_weight * lb
620
+ if want_z:
621
+ total = total + self.z_weight * z
622
+ (total / self.grad_accum).backward()
623
+
624
+ metrics = dict(out.metrics)
625
+ if self._aux.m == self.grad_accum:
626
+ vals = self._aux.window_values()
627
+ self._last_metrics = window_metrics(vals, per_layer=self.aux_cfg.per_layer_metrics)
628
+ elif not self._last_metrics:
629
+ self._last_metrics = undefined_window_metrics(
630
+ len(stats), per_layer=self.aux_cfg.per_layer_metrics
631
+ )
632
+ metrics.update(self._last_metrics)
633
+
634
+ reported = ce.detach()
635
+ if want_lb or want_z:
636
+ # 0 * finite = 0 (loss stays exactly CE); 0 * NaN/inf = NaN, so a
637
+ # poisoned aux term cannot hide behind a healthy CE.
638
+ reported = reported + 0.0 * (total.detach() - ce.detach())
639
+ return LossOutput(loss=reported, n_tokens=out.n_tokens, metrics=metrics)
640
+
641
+ def zero_grads(self) -> None:
642
+ super().zero_grads()
643
+ self._aux.reset()
644
+
645
+
646
+ def build_moe_train_module(
647
+ cfg,
648
+ model,
649
+ loss_adapter: LossAdapter,
650
+ optimizer,
651
+ *,
652
+ stage: str,
653
+ olmoe_cfg,
654
+ group=None,
655
+ ep_groups=None,
656
+ ep_max_grad_norm: Optional[float] = None,
657
+ ) -> MoETrainModule:
658
+ """The MoE module for an OLMoE-architecture model, wired from a RunConfig.
659
+
660
+ Carries the OLMoE weight format (the Trainer picks it up from the module),
661
+ `cfg.moe_aux` and `cfg.optimizer.grad_accum`.
662
+ """
663
+ from .ckpt_format import OlmoeFormat
664
+
665
+ return MoETrainModule(
666
+ model,
667
+ loss_adapter,
668
+ optimizer,
669
+ cfg.optimizer.grad_accum,
670
+ aux=cfg.moe_aux,
671
+ stage=stage,
672
+ top_k=olmoe_cfg.top_k,
673
+ weight_format=OlmoeFormat(olmoe_cfg),
674
+ group=group,
675
+ ep_groups=ep_groups,
676
+ ep_max_grad_norm=ep_max_grad_norm,
677
+ )
678
+
679
+
680
+ __all__ = [
681
+ "STAGE_AUX_DEFAULTS",
682
+ "POST_TRAINING_AUX_DEFAULT",
683
+ "resolve_aux_weights",
684
+ "WindowTotals",
685
+ "merge_call_stats",
686
+ "MoEAux",
687
+ "window_metrics",
688
+ "undefined_window_metrics",
689
+ "MoETrainModule",
690
+ "build_moe_train_module",
691
+ ]
code/align/train/offload_adamw.py ADDED
@@ -0,0 +1,680 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Optimizer-state-only CPU offload for AdamW.
2
+
3
+ The arithmetic that forces this design (measured on this box, not re-derived):
4
+ full fine-tuning 7.25B params with AdamW needs ~116GB of optimizer state --
5
+ ~29GB per GPU even fully sharded across 4 ranks, against a 23GB A10G budget.
6
+ Moving the state to host RAM fits it. Moving anything ELSE to host RAM does
7
+ not pay: FSDP's own `CPUOffloadPolicy` transfers parameters every forward AND
8
+ backward, while this transfers once per optimizer step, which at grad-accum 4
9
+ is a quarter as often and overlappable with the next accumulation window.
10
+
11
+ Measured cost (tmp/offload_bench.json): torch CPU AdamW (foreach) 2.56s per
12
+ 1.81B-param rank shard; PCIe 13.2GB/s solo scaling to 52.7/53.9GB/s aggregate
13
+ across 4 ranks, giving ~0.82s of transfer per rank per optimizer step. At
14
+ grad-accum >= 4 that is an estimated 20-30% step-time overhead; at accum 1 it
15
+ would be 50-100%. This is WHY accum >= 4 is mandatory for full-FT, and the
16
+ Trainer refuses less.
17
+
18
+ Design note on the update rule: the CPU step is torch's own `AdamW` driven
19
+ over the offloaded fp32 master copies, not a reimplementation. Bitwise
20
+ equivalence with `torch.optim.AdamW` is then a property of the construction
21
+ rather than something a test has to keep rediscovering after every torch
22
+ release -- and the test still catches the failures that actually happen here,
23
+ which are plumbing failures: a dropped weight_decay, a mis-forwarded beta, a
24
+ master copy that round-trips through a narrower dtype.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import logging
30
+ from typing import Iterable, Optional, Sequence
31
+
32
+ import torch
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ #: Optimizer identities this repo KNOWS. Adafactor is deliberately absent
37
+ #: and refused BY NAME -- see `make_optimizer`.
38
+ #:
39
+ #: `adamw8bit` is BUILDABLE again: `align/train/adamw8bit.py` is a torch-only
40
+ #: block-wise 8-bit AdamW (the bitsandbytes dependency stays banned; see
41
+ #: docs/DEPENDENCY_POLICY.md). The distinction between "a typo'd optimizer
42
+ #: name" and "a known optimizer" must still survive in the error text: the
43
+ #: former is `ValueError("unknown optimizer")`.
44
+ KNOWN_OPTIMIZERS = ("offload_adamw", "adamw8bit")
45
+
46
+ #: The default optimizer in `RunConfig` and the one the pinned-staging budget
47
+ #: and the RSS projection model. Every SHIPPED full-FT config overrides it to
48
+ #: `adamw8bit`, because at 7.25B params / 4 ranks the host cannot hold this
49
+ #: optimizer's state (see adamw8bit.py for the arithmetic).
50
+ OPTIMIZER_OF_RECORD = "offload_adamw"
51
+
52
+
53
+ def pinned_staging_bytes(cfg) -> float:
54
+ """Total pinned staging budget across all ranks, DERIVED from the config.
55
+
56
+ One fp32 layer-shard is `n_params / n_layers / world_size` elements at 4
57
+ bytes; each rank is allowed `pinned_staging_shards_per_rank` of them.
58
+
59
+ Pinned memory is unswappable and unreclaimable, and this box has no swap,
60
+ so this is a hard budget rather than an estimate. It is the same formula
61
+ `guard.projected_full_ft_rss_gb` uses for the RSS projection -- deliberately
62
+ the same, so the launch banner and the preflight cannot disagree about how
63
+ much host RAM the job intends to lock down.
64
+ """
65
+ if cfg.model.n_params <= 0:
66
+ raise ValueError(
67
+ f"model.n_params={cfg.model.n_params} is not a parameter count, so "
68
+ "there is no staging budget to derive. The tiny CI config sets it "
69
+ "to -1 deliberately (the 7B projection does not apply there); a "
70
+ "negative budget must raise rather than silently become a negative "
71
+ "cap that every allocation trivially satisfies."
72
+ )
73
+ world = cfg.train.world_size
74
+ layer_shard_bytes = cfg.model.n_params / cfg.model.n_layers / world * 4.0
75
+ return layer_shard_bytes * cfg.train.pinned_staging_shards_per_rank * world
76
+
77
+
78
+ class OffloadAdamW:
79
+ """AdamW whose state lives on the host; params and grads never move.
80
+
81
+ `step()` returns False when the grad-skip gate refused the step.
82
+ """
83
+
84
+ def __init__(
85
+ self,
86
+ params: Iterable[torch.Tensor],
87
+ lr: float,
88
+ betas: tuple[float, float] = (0.9, 0.95),
89
+ eps: float = 1e-8,
90
+ weight_decay: float = 0.0,
91
+ pinned_staging_cap_bytes: Optional[float] = None,
92
+ overlap: bool = True,
93
+ state_device: str = "cpu",
94
+ max_grad_norm: Optional[float] = None,
95
+ ) -> None:
96
+ self.params: list[torch.Tensor] = list(params)
97
+ if not self.params:
98
+ raise ValueError("OffloadAdamW got an empty parameter list")
99
+ if max_grad_norm is not None and not (float(max_grad_norm) > 0.0):
100
+ raise ValueError(
101
+ f"max_grad_norm must be a positive float or None, got {max_grad_norm!r}"
102
+ )
103
+ #: GLOBAL-norm clip (`clip_grad_norm_`'s rule, `min(1, c / (norm + 1e-6))`),
104
+ #: None = no clipping. THE SILENT-NO-CLIP DEFECT (H2 review, 2026-10-13):
105
+ #: `OptimizerConfig.max_grad_norm` defaults to 1.0 and every shipped TOML
106
+ #: states it, but this class had no such parameter and `make_optimizer`
107
+ #: never forwarded the field -- the one optimizer of record for full-FT
108
+ #: ran UNCLIPPED while `BlockwiseAdamW8bit` honoured the same config.
109
+ #: `last_grad_norm` stays the PRE-clip norm (the tripwires must see an
110
+ #: explosion, not its clipped image).
111
+ self.max_grad_norm = None if max_grad_norm is None else float(max_grad_norm)
112
+ self.state_device = torch.device(state_device)
113
+ self.overlap = bool(overlap)
114
+ self.banner_tag = "offload_adamw"
115
+
116
+ # THIS RANK'S bytes, so the staging cap is a per-rank quantity. Under
117
+ # FSDP2 every parameter is a DTensor whose `.numel()` is the GLOBAL
118
+ # element count: a world-size-times overcount that would have refused
119
+ # (or, worse, mis-banner) any sharded job.
120
+ self.staging_bytes_required = float(
121
+ sum(self._localize(p).numel() * 4 for p in self.params)
122
+ )
123
+ self.pinned_staging_cap_bytes = pinned_staging_cap_bytes
124
+ if (
125
+ pinned_staging_cap_bytes is not None
126
+ and self.staging_bytes_required > pinned_staging_cap_bytes
127
+ ):
128
+ raise ValueError(
129
+ f"pinned staging would need "
130
+ f"{self.staging_bytes_required / 2**30:.2f}GiB but the cap is "
131
+ f"{pinned_staging_cap_bytes / 2**30:.2f}GiB. Pinned memory is "
132
+ "unswappable and this box has no swap: exceeding the budget "
133
+ "does not degrade, it wedges the host. Lower "
134
+ "train.pinned_staging_shards_per_rank or shard further."
135
+ )
136
+
137
+ # fp32 master copies on the host. These are the AUTHORITATIVE weights:
138
+ # the device params are a bf16 view of them, refreshed every step.
139
+ self._pin = torch.cuda.is_available() and self.state_device.type == "cpu"
140
+ # THE FSDP2 DTENSOR DEFECT (found in the align port review, 2026-10-12;
141
+ # the predecessor never hit it because the shard-local 8-bit optimizer
142
+ # was the one that actually ran on FSDP2 parameters). Under `fully_shard`
143
+ # `model.parameters()` yields DTensors, and OffloadAdamW died on them
144
+ # twice: `is_pinned()` has no DTensor sharding rule at construction,
145
+ # and the grad-norm accumulator mixed a DTensor into a plain tensor at
146
+ # the first step. Masters, grads and the H2D copy-back therefore all go
147
+ # through `_localize` -- this rank's shard, a storage-sharing view -- the
148
+ # same contract `ShardLocalAdamW8bit` documents (AdamW is elementwise,
149
+ # so stepping the local shard is exact).
150
+ self._master = [
151
+ self._host_tensor(self._localize(p).detach().to(torch.float32)) for p in self.params
152
+ ]
153
+ self._inner = torch.optim.AdamW(
154
+ self._master,
155
+ lr=lr,
156
+ betas=betas,
157
+ eps=eps,
158
+ weight_decay=weight_decay,
159
+ )
160
+
161
+ self.skipped_steps = 0
162
+ self.last_grad_norm = float("nan")
163
+ logger.info(
164
+ "OffloadAdamW: %d tensors, %.2fGiB fp32 master on %s (pinned=%s), "
165
+ "staging %.2fGiB, overlap=%s",
166
+ len(self.params),
167
+ sum(m.numel() * 4 for m in self._master) / 2**30,
168
+ self.state_device,
169
+ self._pin,
170
+ self.staging_bytes_required / 2**30,
171
+ self.overlap,
172
+ )
173
+
174
+ # -- host staging -------------------------------------------------------
175
+
176
+ @staticmethod
177
+ def _localize(t):
178
+ """This rank's local shard of an FSDP2 DTensor (a view sharing the
179
+ parameter's storage), or `t` itself for an ordinary tensor."""
180
+ return t.to_local() if hasattr(t, "to_local") else t
181
+
182
+ def _host_tensor(self, t: torch.Tensor) -> torch.Tensor:
183
+ out = t.to(self.state_device, copy=True)
184
+ if self._pin and not out.is_pinned():
185
+ out = out.pin_memory()
186
+ return out
187
+
188
+ # -- the step -----------------------------------------------------------
189
+
190
+ def step(self) -> bool:
191
+ """One optimizer step, or a refusal.
192
+
193
+ Order matters and is load-bearing: finiteness is checked BEFORE any
194
+ state is touched. An implementation that discovers the NaN after
195
+ updating the moments has already poisoned the second-moment estimate,
196
+ and every subsequent step is scaled by it.
197
+ """
198
+ grads = [None if p.grad is None else self._localize(p.grad) for p in self.params]
199
+ if all(g is None for g in grads):
200
+ raise RuntimeError(
201
+ "OffloadAdamW.step() called with no gradients on any parameter. "
202
+ "A step that silently does nothing is indistinguishable from a "
203
+ "run that trained."
204
+ )
205
+
206
+ if not self._all_finite(grads):
207
+ self.skipped_steps += 1
208
+ # NaN, not 0.0: the norm of a non-finite gradient is undefined, and
209
+ # a 0.0 in the log reads as 'perfectly converged'.
210
+ self.last_grad_norm = float("nan")
211
+ logger.warning(
212
+ "grad-skip gate: non-finite gradient, skipping optimizer step "
213
+ "(%d skipped so far). Moments and step counts left untouched.",
214
+ self.skipped_steps,
215
+ )
216
+ return False
217
+
218
+ self.last_grad_norm = self._grad_norm(grads) # PRE-clip, on purpose
219
+
220
+ # `_grad_norm` all-reduces the sum of squares, so `coef` is the same on
221
+ # every rank: each rank scales its own disjoint shard by the GLOBAL factor.
222
+ coef = 1.0
223
+ if self.max_grad_norm is not None:
224
+ coef = min(1.0, self.max_grad_norm / (self.last_grad_norm + 1e-6))
225
+
226
+ # D2H: fp32 grads into the host staging that backs the master copies.
227
+ for master, g in zip(self._master, grads):
228
+ if g is None:
229
+ master.grad = None
230
+ continue
231
+ master.grad = self._host_tensor(g.detach().to(torch.float32))
232
+ if coef != 1.0:
233
+ master.grad.mul_(coef)
234
+
235
+ self._inner.step()
236
+
237
+ # H2D: updated master back into the device params, in their own dtype.
238
+ # copy_ keeps the storage identity FSDP's shard views depend on.
239
+ with torch.no_grad():
240
+ for p, master in zip(self.params, self._master):
241
+ local = self._localize(p).detach()
242
+ local.copy_(master.to(local.dtype))
243
+ return True
244
+
245
+ def zero_grad(self, set_to_none: bool = True) -> None:
246
+ for p in self.params:
247
+ if set_to_none:
248
+ p.grad = None
249
+ elif p.grad is not None:
250
+ p.grad.zero_()
251
+ for m in self._master:
252
+ m.grad = None
253
+
254
+ def _all_finite(self, grads: Sequence[Optional[torch.Tensor]]) -> bool:
255
+ """Global finiteness. Under FSDP a rank whose shard is finite must not
256
+ step while a peer's is not: the peers would diverge silently and only
257
+ surface as an NCCL mismatch much later, if at all."""
258
+ local = True
259
+ for g in grads:
260
+ if g is not None and not bool(torch.isfinite(g).all()):
261
+ local = False
262
+ break
263
+ return bool(self._all_reduce_flag(local))
264
+
265
+ @staticmethod
266
+ def _collective_device() -> torch.device:
267
+ """The device a tensor must live on to join a collective on the default
268
+ process group: this rank's OWN cuda device under NCCL (a bare .cuda()
269
+ would put every rank's tensor on device 0 and reduce the wrong ones),
270
+ CPU under gloo. Keyed on the group's backend, not on
271
+ `torch.cuda.is_available()`: a gloo group on a box that happens to have
272
+ GPUs would otherwise be handed a cuda tensor it cannot reduce."""
273
+ import torch.distributed as dist
274
+
275
+ if "nccl" in str(dist.get_backend()).lower() and torch.cuda.is_available():
276
+ from ..utils.dist import local_rank
277
+
278
+ return torch.device("cuda", local_rank())
279
+ return torch.device("cpu")
280
+
281
+ @staticmethod
282
+ def _all_reduce_flag(local: bool) -> bool:
283
+ import torch.distributed as dist
284
+
285
+ if not dist.is_available() or not dist.is_initialized():
286
+ return local
287
+ flag = torch.tensor([1.0 if local else 0.0], device=OffloadAdamW._collective_device())
288
+ dist.all_reduce(flag, op=dist.ReduceOp.MIN)
289
+ return bool(flag.item() > 0.0)
290
+
291
+ @staticmethod
292
+ def _grad_norm(grads: Sequence[Optional[torch.Tensor]]) -> float:
293
+ """The GLOBAL L2 norm of the gradient, identical on every rank.
294
+
295
+ Accumulate on the GRADS' device, not on CPU: the accumulator used to be
296
+ a bare `torch.zeros((), dtype=float64)`, a CPU tensor, which broke the
297
+ moment the shard-local 8-bit optimizer called it with CUDA grads:
298
+ "Expected all tensors to be on the same device, but found cuda:0 and cpu".
299
+
300
+ THE SHARD-LOCAL NORM DEFECT (align port review, 2026-10-12). Under FSDP2
301
+ each rank holds a DISJOINT 1/world slice of every gradient, so the sum of
302
+ squares over the local slice is a norm of a quarter of the gradient --
303
+ and a different number on every rank. `GradNormTripwire` is a HALT wire
304
+ reading this value: a rank-local norm lets ONE rank cross the ceiling and
305
+ raise while its peers continue into the next collective (a hang until the
306
+ NCCL watchdog), and it under-reads a true explosion by ~sqrt(world). The
307
+ sum of squares is therefore all-reduced (SUM) before the root. Every
308
+ rank reaches this call together or not at all, because the finiteness
309
+ gate that precedes it is itself a collective.
310
+ """
311
+ import torch.distributed as dist
312
+
313
+ present = [g for g in grads if g is not None]
314
+ distributed = dist.is_available() and dist.is_initialized()
315
+ if not present and not distributed:
316
+ return float("nan")
317
+ device = present[0].device if present else torch.device("cpu")
318
+ total = torch.zeros((), dtype=torch.float64, device=device)
319
+ for g in present:
320
+ total += g.detach().to(torch.float64).pow(2).sum()
321
+ if distributed:
322
+ total = total.to(OffloadAdamW._collective_device())
323
+ dist.all_reduce(total, op=dist.ReduceOp.SUM)
324
+ return float(total.sqrt())
325
+
326
+ # -- introspection ------------------------------------------------------
327
+
328
+ def describe_placement(self) -> dict:
329
+ """Where every tensor actually lives. Consumed by the launch banner and
330
+ by the device-partition test; a placement claim nobody checks is how
331
+ 'the state is offloaded' becomes untrue after a refactor."""
332
+
333
+ def entry(t: torch.Tensor) -> dict:
334
+ return {
335
+ "device": t.device.type,
336
+ "dtype": str(t.dtype),
337
+ "pinned": bool(t.is_pinned()) if t.device.type == "cpu" else False,
338
+ "bytes": t.numel() * t.element_size(),
339
+ }
340
+
341
+ state = []
342
+ for group in self._inner.state.values():
343
+ for key, v in group.items():
344
+ if isinstance(v, torch.Tensor):
345
+ state.append({"key": key, **entry(v)})
346
+ return {
347
+ "state": state,
348
+ "master": [entry(m) for m in self._master],
349
+ "params": [entry(self._localize(p)) for p in self.params],
350
+ "staging_bytes_required": self.staging_bytes_required,
351
+ "pinned_staging_cap_bytes": self.pinned_staging_cap_bytes,
352
+ }
353
+
354
+ def state_snapshot_for_tests(self) -> dict:
355
+ """Flat clone of every optimizer-state tensor, including the step
356
+ counts. Tests assert these are byte-identical across a skipped step."""
357
+ out = {}
358
+ for i, p in enumerate(self._master):
359
+ for key, v in self._inner.state.get(p, {}).items():
360
+ out[f"{i}.{key}"] = (
361
+ v.detach().clone() if isinstance(v, torch.Tensor) else torch.tensor(float(v))
362
+ )
363
+ return out
364
+
365
+ # -- resume -------------------------------------------------------------
366
+
367
+ def state_dict(self) -> dict:
368
+ """A DETACHED CLONE of every state tensor, on CPU.
369
+
370
+ THE SHARED-STATE ALIASING INCIDENT. Two traps stack here, and both are
371
+ silent:
372
+
373
+ 1. `torch.optim.Optimizer.state_dict()` returns per-param dicts that
374
+ SHARE the live state tensors, and `.to("cpu")` on a tensor already
375
+ on CPU returns the same object -- so a state dict built without
376
+ clones is a live view of the optimizer, not a snapshot of it.
377
+ 2. Mutating that returned dict in place (to clone the values) rebinds
378
+ the optimizer's OWN state entries, so the "snapshot" aliases the
379
+ live state no matter which order you do it in.
380
+
381
+ The result of getting this wrong is not a crash: `load_state_dict`
382
+ hands the loaded optimizer the same `step`/`exp_avg`/`exp_avg_sq`
383
+ objects the source still holds, both mutate them every step, Adam's
384
+ step counter advances twice per step, and the two runs diverge while
385
+ every state comparison between them says they are identical.
386
+
387
+ So this builds a NEW nested structure and never touches what the inner
388
+ optimizer returned.
389
+ """
390
+ inner = self._inner.state_dict()
391
+ return {
392
+ "param_groups": inner["param_groups"],
393
+ "state": {
394
+ idx: {
395
+ k: (v.detach().to("cpu").clone() if isinstance(v, torch.Tensor) else v)
396
+ for k, v in group.items()
397
+ }
398
+ for idx, group in inner["state"].items()
399
+ },
400
+ "master": [m.detach().to("cpu").clone() for m in self._master],
401
+ "skipped_steps": self.skipped_steps,
402
+ }
403
+
404
+ def load_state_dict(self, sd: dict) -> None:
405
+ """Restore moments AND master weights, then refresh the device params.
406
+
407
+ The master is the authoritative weight copy, so a resume that restored
408
+ only the moments would carry the pre-checkpoint parameters forward --
409
+ a run that looks like it resumed and is training a different model.
410
+ """
411
+ masters = sd.get("master")
412
+ if masters is None:
413
+ raise KeyError(
414
+ "optimizer state_dict has no 'master' entry; refusing to "
415
+ "resume from a checkpoint that carries moments but not the "
416
+ "fp32 master weights."
417
+ )
418
+ if len(masters) != len(self._master):
419
+ raise ValueError(
420
+ f"checkpoint has {len(masters)} master tensors but this "
421
+ f"optimizer holds {len(self._master)}"
422
+ )
423
+ with torch.no_grad():
424
+ for dst, src in zip(self._master, masters):
425
+ dst.copy_(src.to(dst.dtype))
426
+ for p, master in zip(self.params, self._master):
427
+ local = self._localize(p).detach()
428
+ local.copy_(master.to(local.dtype))
429
+ # Clone on the way IN as well: torch stores these objects directly when
430
+ # no dtype/device cast is needed, so loading one checkpoint dict into
431
+ # two optimizers would otherwise make them share moments. Symmetric
432
+ # with the cloning in state_dict(), and for the same reason.
433
+ inner_sd = {
434
+ "param_groups": sd["param_groups"],
435
+ "state": {
436
+ idx: {
437
+ k: (v.detach().clone() if isinstance(v, torch.Tensor) else v)
438
+ for k, v in group.items()
439
+ }
440
+ for idx, group in sd["state"].items()
441
+ },
442
+ }
443
+ self._inner.load_state_dict(inner_sd)
444
+ self.skipped_steps = int(sd.get("skipped_steps", 0))
445
+
446
+ def reseed_masters_from_params(self) -> None:
447
+ """Overwrite the fp32 masters with the CURRENT device params (audit U01-2).
448
+
449
+ The master is authoritative: every step rewrites the params from it. A resume that loads
450
+ weights but no optimizer state (a legacy checkpoint, `reshard(drop_optimizer=True)`) would
451
+ otherwise leave the masters at the LAUNCH weights and the first step would silently revert
452
+ the restored model. Moments stay fresh; only the master copy is re-seeded.
453
+ """
454
+ with torch.no_grad():
455
+ for p, master in zip(self.params, self._master):
456
+ master.copy_(self._localize(p).detach().to(torch.float32))
457
+
458
+ @property
459
+ def param_groups(self):
460
+ """Exposed so a torch LR scheduler can drive this optimizer directly."""
461
+ return self._inner.param_groups
462
+
463
+
464
+ class ShardLocalAdamW8bit:
465
+ """Compatibility shim: the legacy name for the shard-local 8-bit AdamW.
466
+
467
+ The optimizer itself is `align.train.adamw8bit.BlockwiseAdamW8bit` (pure
468
+ torch; `make_optimizer` returns THAT directly). This class keeps the
469
+ legacy wrapper shape -- `_inner`, `_pairs`, and a `state_dict` that wraps
470
+ the inner state with `world_size`/`n_shards` -- because two invariants are
471
+ regression-tested against that shape WITHOUT constructing the class
472
+ (tests build a stub via `__new__` and hand it a fake `_inner`):
473
+
474
+ * the recursive CPU clone: a one-level clone once passed ~3.2GB of live
475
+ CUDA moments straight into the checkpoint pickle, and `gather_object`
476
+ restored them onto the SENDER'S device on rank 0 and OOM'd the DPO
477
+ run at its first checkpoint;
478
+ * the world-size refusal: the saved state is SHARD-shaped, and reloading
479
+ it under a different world size would silently align rank r's moments
480
+ to a different slice of the parameter.
481
+
482
+ WHY SHARD-LOCAL STEPPING IS EXACT, not an approximation: AdamW is
483
+ ELEMENTWISE. Every update depends only on that element's own gradient and
484
+ its own two moment scalars, so there is nothing for a shard boundary to
485
+ cut. (This is exactly what Adafactor cannot claim, and why Adafactor is
486
+ banned here: its factored second moment is a row/column statistic that
487
+ DOES cross shard boundaries.) `to_local()` is a VIEW sharing storage with
488
+ the DTensor, so stepping it updates the parameter -- measured on 4 real
489
+ ranks: storage pointers stable across calls and steps, and the DTensor
490
+ observes the update.
491
+
492
+ HISTORY: the first version wrapped bitsandbytes, whose fused kernel
493
+ cannot consume DTensors ("optimizer_update_8bit_blockwise got mixed
494
+ torch.Tensor and DTensor"), and was then retired with the dependency. It
495
+ is now a thin delegate to the in-repo implementation, which does the
496
+ gating, the GLOBAL grad norm and the state handling itself; the shim only
497
+ mirrors `skipped_steps` / `last_grad_norm` so legacy readers keep working.
498
+ """
499
+
500
+ def __init__(
501
+ self,
502
+ params: Iterable[torch.Tensor],
503
+ lr: float,
504
+ betas: tuple[float, float] = (0.9, 0.95),
505
+ eps: float = 1e-8,
506
+ weight_decay: float = 0.0,
507
+ ):
508
+ # Consume the parameter iterable first so a generator-backed caller
509
+ # (`model.parameters()`) behaves the same however it is used, and
510
+ # validate the empty case before anything is built.
511
+ params = list(params)
512
+ if not params:
513
+ raise ValueError("ShardLocalAdamW8bit got an empty parameter list")
514
+ # Lazy: adamw8bit imports this module at top level (it reuses the
515
+ # collective helpers), so the reverse import cannot be top-level.
516
+ from .adamw8bit import BlockwiseAdamW8bit
517
+
518
+ self._inner = BlockwiseAdamW8bit(
519
+ params, lr=lr, betas=betas, eps=eps, weight_decay=weight_decay
520
+ )
521
+ self._pairs = [(p, self._localize(p).detach()) for p in params]
522
+ self.skipped_steps = 0
523
+ self.last_grad_norm = float("nan")
524
+ self.banner_tag = self._inner.banner_tag
525
+
526
+ @staticmethod
527
+ def _localize(t):
528
+ return t.to_local() if hasattr(t, "to_local") else t
529
+
530
+ def step(self) -> bool:
531
+ """One optimizer step, or a refusal. Same contract as OffloadAdamW.
532
+
533
+ Gating, the global grad norm and the update all live in the inner
534
+ optimizer; doing them here too would pay the collectives twice."""
535
+ stepped = self._inner.step()
536
+ self.skipped_steps = self._inner.skipped_steps
537
+ self.last_grad_norm = self._inner.last_grad_norm
538
+ return stepped
539
+
540
+ def zero_grad(self, set_to_none: bool = True) -> None:
541
+ for p, local in self._pairs:
542
+ p.grad = None if set_to_none else p.grad
543
+ local.grad = None
544
+
545
+ def state_dict(self) -> dict:
546
+ from ..utils.dist import world_size
547
+
548
+ inner = self._inner.state_dict()
549
+
550
+ # DETACHED CPU CLONES, RECURSIVELY -- same two aliasing traps
551
+ # OffloadAdamW.state_dict documents, plus the one that survived the
552
+ # first fix: bnb nests its real tensors under
553
+ # state[idx]["__bnb_optimizer_quant_state__"], a dict, so a one-level
554
+ # clone passed ~3.2GB of live CUDA moments straight into the pickle.
555
+ # gather_object then restored them onto the SENDER'S device on rank 0
556
+ # and OOM'd the DPO run at its first checkpoint (SFT's end-save
557
+ # survived the same bug on VRAM headroom alone).
558
+ def _to_cpu(o):
559
+ if isinstance(o, torch.Tensor):
560
+ return o.detach().to("cpu").clone()
561
+ if isinstance(o, dict):
562
+ return {k: _to_cpu(v) for k, v in o.items()}
563
+ if isinstance(o, (list, tuple)):
564
+ return type(o)(_to_cpu(v) for v in o)
565
+ return o
566
+
567
+ return {
568
+ "inner": _to_cpu(inner),
569
+ "world_size": world_size(),
570
+ "n_shards": len(self._pairs),
571
+ "skipped_steps": self.skipped_steps,
572
+ }
573
+
574
+ def load_state_dict(self, sd: dict) -> None:
575
+ from ..utils.dist import world_size
576
+
577
+ saved_ws, now_ws = sd.get("world_size"), world_size()
578
+ if saved_ws is not None and saved_ws != now_ws:
579
+ raise ValueError(
580
+ f"this optimizer state was saved at world_size={saved_ws} but "
581
+ f"the current run has world_size={now_ws}. The 8-bit moments "
582
+ "are SHARD-shaped: loading them under a different world size "
583
+ "would align each rank's state to a different slice of the "
584
+ "parameter, and training would continue plausibly on "
585
+ "corrupted state. Resume at the original world size, or start "
586
+ "fresh."
587
+ )
588
+ saved_n = sd.get("n_shards")
589
+ if saved_n is not None and saved_n != len(self._pairs):
590
+ raise ValueError(
591
+ f"checkpoint holds {saved_n} shards, this optimizer has {len(self._pairs)}"
592
+ )
593
+ self._inner.load_state_dict(sd["inner"])
594
+ self.skipped_steps = int(sd.get("skipped_steps", 0))
595
+
596
+ @property
597
+ def param_groups(self):
598
+ return self._inner.param_groups
599
+
600
+
601
+ def make_optimizer(cfg, params: Iterable[torch.Tensor]):
602
+ """Build the optimizer named by the config, and tag it for the banner.
603
+
604
+ Which optimizer actually ran is recorded in the banner: a run that used a
605
+ different optimizer than its config names is not reproducible from that
606
+ config.
607
+
608
+ `adamw8bit` is the pre-registered on-device fallback for the case where
609
+ host RAM cannot hold the offloaded state (every shipped full-FT config
610
+ names it). It is the in-repo torch-only `BlockwiseAdamW8bit`; the banner
611
+ states its state size from the RANK-LOCAL element count
612
+ (`2*n + 8*n/block` bytes), so the launch log cannot claim a footprint the
613
+ optimizer does not have. It is never silently swapped for `offload_adamw`:
614
+ a run whose banner says one optimizer while the config names another is
615
+ exactly the irreproducibility this function exists to prevent.
616
+ """
617
+ name = cfg.optimizer.name
618
+ lowered = str(name).lower()
619
+
620
+ if lowered == "adafactor":
621
+ raise ValueError(
622
+ "Adafactor is BANNED in this repo. Its factored second moment "
623
+ "crosses DTensor shard boundaries: under ZeRO-3 on this box it "
624
+ "produced a perfect-looking loss curve while moving ~1% of "
625
+ "tensors -- a run that trained nothing and reported success for "
626
+ "hours. AdamW only (offload_adamw is the optimizer of record)."
627
+ )
628
+ if lowered not in KNOWN_OPTIMIZERS:
629
+ raise ValueError(
630
+ f"unknown optimizer {name!r}; known: {list(KNOWN_OPTIMIZERS)}. "
631
+ "A typo must not fall through to a default -- the whole point of "
632
+ "naming the optimizer in the config is that the banner can state "
633
+ "which one ran."
634
+ )
635
+
636
+ o = cfg.optimizer
637
+ if lowered == "offload_adamw":
638
+ # The staging cap is a full-FT host-RAM budget. The tiny CPU harness
639
+ # has no layer shards to size it from (n_params = -1), so it runs
640
+ # uncapped rather than against a fabricated number.
641
+ cap = pinned_staging_bytes(cfg) if cfg.train.full_ft else None
642
+ opt = OffloadAdamW(
643
+ params,
644
+ lr=o.lr,
645
+ betas=tuple(o.betas),
646
+ eps=o.eps,
647
+ weight_decay=o.weight_decay,
648
+ pinned_staging_cap_bytes=cap,
649
+ max_grad_norm=getattr(o, "max_grad_norm", None),
650
+ )
651
+ opt.banner_tag = (
652
+ f"offload_adamw (fp32 master + moments on host, "
653
+ f"{opt.staging_bytes_required / 2**30:.2f}GiB staging)"
654
+ )
655
+ return opt
656
+
657
+ # Only 'adamw8bit' can reach here (unknown names raised above).
658
+ # Lazy import: adamw8bit imports this module for the collective helpers.
659
+ from .adamw8bit import BlockwiseAdamW8bit
660
+
661
+ opt = BlockwiseAdamW8bit(
662
+ params,
663
+ lr=o.lr,
664
+ betas=tuple(o.betas),
665
+ eps=o.eps,
666
+ weight_decay=o.weight_decay,
667
+ # OptimizerConfig.max_grad_norm (default 1.0), same rule and same pre-clip
668
+ # `last_grad_norm` record as the offload_adamw branch above.
669
+ max_grad_norm=getattr(o, "max_grad_norm", None),
670
+ )
671
+ n_local = sum(s["numel"] for s in opt._state)
672
+ state_bytes = BlockwiseAdamW8bit.projected_state_bytes(
673
+ [s["numel"] for s in opt._state], opt.block_size, opt.min_8bit_size
674
+ )
675
+ opt.banner_tag = (
676
+ f"adamw8bit (block-wise 8-bit moments ON DEVICE, block {opt.block_size}, "
677
+ f"{state_bytes / 2**30:.2f}GiB state for {n_local / 1e6:.1f}M local params "
678
+ f"= 2B + 8B/block per param; no master copy; zero host RSS)"
679
+ )
680
+ return opt
code/align/train/probes.py ADDED
@@ -0,0 +1,627 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Probes and tripwires: the checks a loss curve cannot make.
2
+
3
+ Every mechanism here exists because the predecessor shipped through the
4
+ failure it detects, with healthy-looking metrics the whole way:
5
+
6
+ * TRUNK MOVEMENT. Reentrant gradient checkpointing silently zeroes trunk
7
+ gradients under sharding. The run trains -- loss falls, throughput is
8
+ normal -- while only the embeddings and the LM head move. Nothing in the
9
+ loss, the grad norm, or the throughput distinguishes it from a healthy
10
+ run, so the probe compares actual parameter values and RAISES at the first
11
+ log step rather than warning at the hundredth.
12
+ * logps/chosen DECLINE. DPO's objective only asks for a MARGIN, which is
13
+ equally well served by pushing the rejected logprob down as by pulling the
14
+ chosen one up. When it takes the second route the model gets better at the
15
+ loss and worse at the task. Maximal for Arm B by design, since pi_SFT
16
+ already assigns the chosen sequence high likelihood.
17
+ * LENGTH DRIFT. Preference optimization has a documented length bias. The
18
+ alarm is baseline-RELATIVE (Arm A measured 54.9% chosen-longer), because
19
+ an absolute threshold says nothing about a dataset whose baseline is not
20
+ 50%.
21
+
22
+ Tripwires are pure functions over metric windows, so they are tested without a
23
+ model. NaN in means NaN out and never a trip: a NaN coerced to 0.0 reads as
24
+ 'flat', and flat-chosen-with-rising-margin is itself the alarm condition.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import logging
30
+ import math
31
+ from dataclasses import dataclass, field
32
+ from typing import Any, Optional, Sequence
33
+
34
+ import torch
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ NAN = float("nan")
39
+
40
+ #: Parameter-name fragments that are NOT trunk. The probe must exclude them:
41
+ #: the incident's signature is "head and embeddings moved, trunk did not", so
42
+ #: a probe that tracked them would call that run healthy.
43
+ NON_TRUNK_FRAGMENTS = ("embed_tokens", "lm_head", "embed_out", "wte", "wpe")
44
+
45
+
46
+ class TrunkMovementProbe:
47
+ """Snapshot trunk parameters, then assert they actually moved."""
48
+
49
+ def __init__(self, model, max_tracked: int = 16, seed: int = 0):
50
+ self._model = model
51
+ self.tracked_names: list[str] = []
52
+ self._snapshots: list[torch.Tensor] = []
53
+
54
+ # Only TRAINABLE parameters can move. On a LoRA/partially-frozen model a
55
+ # stride that landed on frozen base weights would report "trunk frozen"
56
+ # on a perfectly healthy run (found in the PPO port: the probe was
57
+ # rebuilt on the real model at resume and tracked frozen tensors).
58
+ candidates = [
59
+ (n, p)
60
+ for n, p in model.named_parameters()
61
+ if p.requires_grad and not any(f in n for f in NON_TRUNK_FRAGMENTS)
62
+ ]
63
+ if not candidates:
64
+ raise ValueError(
65
+ "TrunkMovementProbe found no trunk parameters to track "
66
+ f"(every TRAINABLE parameter was absent or matched {NON_TRUNK_FRAGMENTS}). It cannot report "
67
+ "'no movement' on an empty set -- that is the alarm condition, "
68
+ "and reporting it from nothing would be a false alarm forever."
69
+ )
70
+ # Deterministic even sample across depth, so a probe on a 32-layer
71
+ # model does not track 16 tensors from layer 0.
72
+ stride = max(1, len(candidates) // max_tracked)
73
+ for name, p in candidates[::stride][:max_tracked]:
74
+ self.tracked_names.append(name)
75
+ self._snapshots.append(self.snapshot_tensor(p))
76
+ self.n_tracked = len(self._snapshots)
77
+
78
+ def release(self) -> None:
79
+ """Drop the model reference and the snapshots (U01-11): the probe is dead after the
80
+ post-train handoff and must not keep the training replica alive."""
81
+ self._model = None
82
+ self._snapshots = []
83
+
84
+ @staticmethod
85
+ def snapshot_tensor(t: Any) -> torch.Tensor:
86
+ """Detached CPU copy of a parameter, reaching through DTensor.
87
+
88
+ Under FSDP2 every sharded parameter is a DTensor whose global view is
89
+ an unmaterialised placeholder; `.to_local()` is the rank's actual
90
+ shard. Comparing global views would compare something that never
91
+ changes on this rank.
92
+ """
93
+ if hasattr(t, "to_local"):
94
+ t = t.to_local()
95
+ if hasattr(t, "detach"):
96
+ t = t.detach()
97
+ return t.to("cpu", copy=True).to(torch.float32)
98
+
99
+ def check(self, step: int, model=None, raise_on_frozen: bool = True) -> dict:
100
+ """Compare against the snapshot. Zero movement RAISES."""
101
+ target = model or self._model
102
+ if target is None:
103
+ raise RuntimeError("TrunkMovementProbe.check after release(): the model reference was dropped")
104
+ current = dict(target.named_parameters())
105
+ tensors = [current[n] for n in self.tracked_names]
106
+ deltas = []
107
+ for snap, t in zip(self._snapshots, tensors):
108
+ now = self.snapshot_tensor(t)
109
+ deltas.append((now - snap).abs().max().item())
110
+
111
+ moved = sum(1 for d in deltas if d > 0.0)
112
+ report = {
113
+ "step": step,
114
+ "n_tracked": self.n_tracked,
115
+ "moved_fraction": moved / self.n_tracked if self.n_tracked else NAN,
116
+ "max_abs_delta": max(deltas) if deltas else NAN,
117
+ "tracked_names": list(self.tracked_names),
118
+ }
119
+ if moved == 0 and raise_on_frozen:
120
+ raise RuntimeError(
121
+ f"TRUNK MOVEMENT PROBE FAILED at step {step}: not one of "
122
+ f"{self.n_tracked} tracked trunk parameters changed by any "
123
+ "amount since step 0.\n\nThis is the reentrant-checkpointing "
124
+ "incident: `torch.utils.checkpoint` with use_reentrant=True "
125
+ "silently zeroes trunk gradients under sharding, and the run "
126
+ "keeps training the embeddings and the LM head while the body "
127
+ "stays frozen. Loss still falls. Throughput is normal. Nothing "
128
+ "else detects it.\n\nCheck that activation checkpointing was "
129
+ "wrapped with use_reentrant=False, that the trunk parameters "
130
+ "are in the optimizer's param groups, and that requires_grad "
131
+ f"is set on them. Tracked: {self.tracked_names[:4]}..."
132
+ )
133
+ logger.info(
134
+ "trunk probe ok at step %d: %d/%d moved, max|d|=%.3e",
135
+ step,
136
+ moved,
137
+ self.n_tracked,
138
+ report["max_abs_delta"],
139
+ )
140
+ return report
141
+
142
+
143
+ # ---------------------------------------------------------------------------
144
+ # tripwires
145
+ # ---------------------------------------------------------------------------
146
+
147
+
148
+ @dataclass
149
+ class TripwireResult:
150
+ tripped: bool
151
+ response: str
152
+ value: float = NAN
153
+ logps_slope: float = NAN
154
+ margin_slope: float = NAN
155
+ detail: str = ""
156
+
157
+
158
+ def _slope(values: Sequence[float]) -> float:
159
+ """Least-squares slope over an evenly spaced window. NaN anywhere in the
160
+ window makes the slope NaN -- a window with missing data has no trend, and
161
+ pretending otherwise is how a tripwire fires on absent measurements."""
162
+ n = len(values)
163
+ if n < 2 or any(math.isnan(v) for v in values):
164
+ return NAN
165
+ xs = list(range(n))
166
+ mx = sum(xs) / n
167
+ my = sum(values) / n
168
+ denom = sum((x - mx) ** 2 for x in xs)
169
+ if denom == 0:
170
+ return NAN
171
+ return sum((x - mx) * (y - my) for x, y in zip(xs, values)) / denom
172
+
173
+
174
+ class LogpsChosenTripwire:
175
+ """Fires when PER-TOKEN chosen NLL rises materially while the margin improves.
176
+
177
+ R2 RECALIBRATION (2026-08-23 audit; docs/audits/phase1_postmortem.md).
178
+ The v1 instrument read raw SUMMED logps/chosen -- corr -0.89 with batch
179
+ completion length -- and applied a zero-threshold slope test over a
180
+ 20-raw-row window (~1.25 optimizer steps). On the primary Arm B run it
181
+ fired 3,049 times; on a SHUFFLED copy of the same series, 2,987 times:
182
+ zero discriminating power. Its false alarm at micro-step 43 selected the
183
+ objective of the recorded (destroyed) rerun. By the correct metric --
184
+ per-token NLL, DPOP's own published diagnostic -- that primary run showed
185
+ no collapse at all (0.487 -> 0.548 nats/token across 727 steps).
186
+
187
+ v2 semantics: per-token chosen NLL (= -logps/chosen / lengths/chosen),
188
+ baseline median over the first `window` rows vs trailing median over the
189
+ last `window` rows; fires only when the trailing median exceeds baseline
190
+ by `nll_rise_nats` (default 0.15 -- healthy phase-1 drift measured 0.06,
191
+ Smaug's documented collapse is +1.45) AND the margin median has risen.
192
+ LATCHED: once fired it stays fired and stops logging. The calibration
193
+ contract is pinned by tests: it must NOT fire on a shuffled copy of a
194
+ healthy series, and MUST fire on an injected +0.3 nats/token shift.
195
+ """
196
+
197
+ #: The metric keys this tripwire READS, declared so the wiring can be
198
+ #: checked instead of assumed (THE DISARMED-TRIPWIRE INCIDENT, below).
199
+ INPUT_KEYS: tuple[str, ...] = ("logps/chosen", "lengths/chosen", "rewards/margins")
200
+
201
+ #: The response is PRE-REGISTERED: decided before the run, so a tripped
202
+ #: alarm produces a recorded configuration change rather than a debate
203
+ #: about whether the number is real. Recorded as the arm's configuration.
204
+ PRE_REGISTERED_RESPONSE = (
205
+ "rerun this arm with DPOP (chosen-logprob floor) or an NLL auxiliary "
206
+ "term, and record the switch as the arm's configuration"
207
+ )
208
+
209
+ def __init__(self, window: int = 400, nll_rise_nats: float = 0.15):
210
+ self.window = int(window)
211
+ self.nll_rise_nats = float(nll_rise_nats)
212
+ self._baseline_nll: list[float] = []
213
+ self._baseline_margin: list[float] = []
214
+ self._nll: list[float] = []
215
+ self._margin: list[float] = []
216
+ self.fired = False
217
+ self._fired_at_row: Optional[int] = None
218
+ self._rows = 0
219
+
220
+ def state_dict(self) -> dict:
221
+ """Baselines, window and latch (U01-6): primitives only, so resume.pt stays
222
+ `weights_only`-loadable. Without it every supervised relaunch re-baselined."""
223
+ return {
224
+ "baseline_nll": [float(v) for v in self._baseline_nll],
225
+ "baseline_margin": [float(v) for v in self._baseline_margin],
226
+ "nll": [float(v) for v in self._nll],
227
+ "margin": [float(v) for v in self._margin],
228
+ "fired": bool(self.fired),
229
+ "fired_at_row": self._fired_at_row,
230
+ "rows": int(self._rows),
231
+ }
232
+
233
+ def load_state_dict(self, sd: dict) -> None:
234
+ self._baseline_nll = [float(v) for v in sd["baseline_nll"]]
235
+ self._baseline_margin = [float(v) for v in sd["baseline_margin"]]
236
+ self._nll = [float(v) for v in sd["nll"]]
237
+ self._margin = [float(v) for v in sd["margin"]]
238
+ self.fired = bool(sd["fired"])
239
+ fr = sd.get("fired_at_row")
240
+ self._fired_at_row = None if fr is None else int(fr)
241
+ self._rows = int(sd["rows"])
242
+
243
+ @staticmethod
244
+ def _median(values: Sequence[float]) -> float:
245
+ # NaN-poisoning median: any NaN in the window makes the statistic NaN,
246
+ # so missing data can never fire (blind != healthy != alarm).
247
+ if any(math.isnan(v) for v in values):
248
+ return NAN
249
+ s = sorted(values)
250
+ n = len(s)
251
+ return s[n // 2] if n % 2 else 0.5 * (s[n // 2 - 1] + s[n // 2])
252
+
253
+ def update(self, logps_chosen: float, lengths_chosen: float, margin: float) -> TripwireResult:
254
+ self._rows += 1
255
+ lp, ln = float(logps_chosen), float(lengths_chosen)
256
+ nll = -lp / ln if (not math.isnan(lp) and not math.isnan(ln) and ln > 0) else NAN
257
+ m = float(margin)
258
+
259
+ if self.fired:
260
+ return TripwireResult(
261
+ True,
262
+ self.PRE_REGISTERED_RESPONSE,
263
+ detail=f"latched since row {self._fired_at_row}",
264
+ )
265
+ if len(self._baseline_nll) < self.window:
266
+ self._baseline_nll.append(nll)
267
+ self._baseline_margin.append(m)
268
+ return TripwireResult(
269
+ False, "", detail=f"baselining {len(self._baseline_nll)}/{self.window}"
270
+ )
271
+ self._nll.append(nll)
272
+ self._margin.append(m)
273
+ self._nll = self._nll[-self.window :]
274
+ self._margin = self._margin[-self.window :]
275
+ if len(self._nll) < self.window:
276
+ return TripwireResult(False, "", detail=f"windowing {len(self._nll)}/{self.window}")
277
+
278
+ base_nll = self._median(self._baseline_nll)
279
+ cur_nll = self._median(self._nll)
280
+ base_m = self._median(self._baseline_margin)
281
+ cur_m = self._median(self._margin)
282
+ rise = cur_nll - base_nll
283
+ tripped = (
284
+ not math.isnan(rise)
285
+ and not math.isnan(cur_m)
286
+ and not math.isnan(base_m)
287
+ and rise > self.nll_rise_nats
288
+ and cur_m > base_m
289
+ )
290
+ if tripped:
291
+ self.fired = True
292
+ self._fired_at_row = self._rows
293
+ logger.error(
294
+ "TRIPWIRE: per-token chosen NLL rose %.3f nats/token over baseline "
295
+ "(%.3f -> %.3f) while the DPO margin rises (%.4f -> %.4f). The "
296
+ "objective is degrading chosen likelihood in real terms. "
297
+ "Pre-registered response: %s",
298
+ rise,
299
+ base_nll,
300
+ cur_nll,
301
+ base_m,
302
+ cur_m,
303
+ self.PRE_REGISTERED_RESPONSE,
304
+ )
305
+ return TripwireResult(
306
+ tripped,
307
+ self.PRE_REGISTERED_RESPONSE if tripped else "",
308
+ value=rise,
309
+ logps_slope=rise, # field kept for record-schema compat: nll shift
310
+ margin_slope=(cur_m - base_m) if not math.isnan(cur_m) else NAN,
311
+ )
312
+
313
+
314
+ class GradNormTripwire:
315
+ """HALTS the run on a gradient-norm explosion. R3 (2026-08-23 audit).
316
+
317
+ grad_norm was logged every optimizer step of both destroyed-run attempts
318
+ and read by NOTHING: the recorded rerun's first step showed 2,851 vs the
319
+ primary's 22 (~130x), and the terminal blow-up reached 355,830 with the
320
+ run continuing 221 steps past the break. A ceiling here would have fired
321
+ at step 1. Unlike the diagnostic tripwires this one HALTS (HALT=True):
322
+ training past a gradient explosion spends GPU-hours destroying weights.
323
+ """
324
+
325
+ INPUT_KEYS: tuple[str, ...] = ("grad_norm",)
326
+ PRE_REGISTERED_RESPONSE = (
327
+ "halt the run; a gradient explosion is a launch/config defect, not a "
328
+ "tunable -- diagnose before any rerun"
329
+ )
330
+ HALT = True
331
+ #: `grad_norm` is a TRAINER-produced metric that is legitimately NaN on
332
+ #: every micro-step without an applied optimizer step (the NaN-reporting
333
+ #: contract). That is a periodic honest state, not a disarmed feed --
334
+ #: this wire handles NaN as "blind, cannot fire" itself. The arm-time
335
+ #: self-test therefore still requires the KEY (name drift stays a launch
336
+ #: failure) but tolerates NaN for wires that declare this.
337
+ TOLERATES_NAN_INPUT = True
338
+
339
+ def __init__(
340
+ self,
341
+ absolute_ceiling: float = 500.0,
342
+ relative_factor: float = 10.0,
343
+ window: int = 50,
344
+ warmup: int = 10,
345
+ ):
346
+ self.absolute_ceiling = float(absolute_ceiling)
347
+ self.relative_factor = float(relative_factor)
348
+ self.window = int(window)
349
+ self.warmup = int(warmup)
350
+ self._history: list[float] = []
351
+ self.fired = False
352
+
353
+ def state_dict(self) -> dict:
354
+ """Trailing history and latch (U01-6); primitives only."""
355
+ return {"history": [float(v) for v in self._history], "fired": bool(self.fired)}
356
+
357
+ def load_state_dict(self, sd: dict) -> None:
358
+ self._history = [float(v) for v in sd["history"]]
359
+ self.fired = bool(sd["fired"])
360
+
361
+ def update(self, grad_norm: float) -> TripwireResult:
362
+ g = float(grad_norm)
363
+ if self.fired:
364
+ return TripwireResult(True, self.PRE_REGISTERED_RESPONSE, value=g, detail="latched")
365
+ if math.isnan(g):
366
+ return TripwireResult(False, "", value=NAN, detail="NaN grad_norm: blind, cannot fire")
367
+ history = self._history
368
+ trailing = sorted(history[-self.window :])
369
+ med = trailing[len(trailing) // 2] if trailing else NAN
370
+ over_abs = g > self.absolute_ceiling
371
+ over_rel = (
372
+ len(history) >= self.warmup
373
+ and not math.isnan(med)
374
+ and med > 0
375
+ and g > self.relative_factor * med
376
+ )
377
+ history.append(g)
378
+ if over_abs or over_rel:
379
+ self.fired = True
380
+ logger.error(
381
+ "TRIPWIRE (HALT): grad_norm %.1f exceeds %s (trailing median %.2f). %s",
382
+ g,
383
+ f"absolute ceiling {self.absolute_ceiling}"
384
+ if over_abs
385
+ else f"{self.relative_factor}x trailing median",
386
+ med,
387
+ self.PRE_REGISTERED_RESPONSE,
388
+ )
389
+ return TripwireResult(True, self.PRE_REGISTERED_RESPONSE, value=g)
390
+ return TripwireResult(False, "", value=g)
391
+
392
+
393
+ class LengthDriftTripwire:
394
+ """Fires when the chosen-longer fraction leaves a band around the measured
395
+ baseline for the arm -- but only once it has seen enough pairs to mean it.
396
+
397
+ THREE DECLARED STATES, and keeping them distinct is the whole design:
398
+
399
+ * BLIND -- input absent or NaN. The guard cannot see AND cannot say
400
+ so. Forbidden; `assert_tripwires_armed` halts the run.
401
+ * WARMING UP -- inputs present and finite, cumulative pairs below
402
+ `min_pairs`. The guard is watching and honestly reports
403
+ insufficient sample. Valid; the self-test accepts it.
404
+ * WATCHING -- armed and evaluating.
405
+
406
+ Why `min_pairs` exists (sec09's measurement): at micro_batch 2 the
407
+ per-batch chosen-longer fraction can only take the values {0, 0.5, 1}, two
408
+ of which sit outside a +-0.10 band around 0.549 -- so the alarm fired on
409
+ 78% of batches while measuring nothing but the batch size. Their rolling
410
+ window fixes the estimator; this threshold fixes when it may speak. From
411
+ their standard-error table: 512 pairs gives 5.4e-06 false-fire per check,
412
+ 256 gives roughly 4 spurious alarms per Arm-B run. An alarm that fires
413
+ four times a run is an alarm nobody reads the fifth time.
414
+
415
+ The baseline is a config field, never a constant here: Arm A measured
416
+ 0.549 and Arm B's is different, so a hardcoded threshold would mis-fire on
417
+ one of them permanently.
418
+ """
419
+
420
+ INPUT_KEYS: tuple[str, ...] = ("chosen_longer_fraction", "pairs")
421
+
422
+ RESPONSE = (
423
+ "length bias suspected: report length-controlled win-rate "
424
+ "alongside the raw metric and record the drift band that fired"
425
+ )
426
+
427
+ def __init__(self, baseline: float, band: float = 0.10, min_pairs: int = 512):
428
+ if math.isnan(baseline):
429
+ raise ValueError(
430
+ "LengthDriftTripwire needs a measured baseline; NaN means "
431
+ "nobody measured the arm and the check would be meaningless"
432
+ )
433
+ self.baseline = float(baseline)
434
+ self.band = float(band)
435
+ self.min_pairs = int(min_pairs)
436
+ self._pairs_seen = 0
437
+
438
+ def state_dict(self) -> dict:
439
+ return {"pairs_seen": int(self._pairs_seen)}
440
+
441
+ def load_state_dict(self, sd: dict) -> None:
442
+ self._pairs_seen = int(sd["pairs_seen"])
443
+
444
+ def state(self) -> str:
445
+ """`warming_up` until the cumulative sample reaches min_pairs, then
446
+ `watching`. Reported in the manifest so 'armed' is never ambiguous."""
447
+ return "watching" if self._pairs_seen >= self.min_pairs else "warming_up"
448
+
449
+ def update(self, chosen_longer_fraction: float, n_pairs: int = 1) -> TripwireResult:
450
+ self._pairs_seen += int(n_pairs)
451
+ v = float(chosen_longer_fraction)
452
+ if math.isnan(v):
453
+ # No measurement is not a drift and not an all-clear.
454
+ return TripwireResult(False, "", value=NAN, detail="chosen_longer_fraction undefined")
455
+ if self._pairs_seen < self.min_pairs:
456
+ return TripwireResult(
457
+ False, "", value=v, detail=f"warming up: {self._pairs_seen}/{self.min_pairs} pairs"
458
+ )
459
+
460
+ tripped = abs(v - self.baseline) > self.band
461
+ if tripped:
462
+ logger.error(
463
+ "TRIPWIRE: chosen-longer fraction %.3f is outside %.3f +- %.3f over %d pairs",
464
+ v,
465
+ self.baseline,
466
+ self.band,
467
+ self._pairs_seen,
468
+ )
469
+ return TripwireResult(
470
+ tripped,
471
+ self.RESPONSE if tripped else "",
472
+ value=v,
473
+ detail=f"watching ({self._pairs_seen} pairs)",
474
+ )
475
+
476
+
477
+ def assert_tripwires_armed(metrics: dict, tripwires: dict) -> None:
478
+ """Every ARMED tripwire's inputs must be present and finite, or halt.
479
+
480
+ THE DISARMED-TRIPWIRE INCIDENT. section-05's kernel emits the implicit
481
+ margin as `rewards/margins`; section-07's Trainer seeded `margin` with NaN
482
+ and the tripwire read that. Nothing produced the key, `_slope` returns NaN
483
+ for any window containing one, and the trip condition requires a non-NaN
484
+ slope -- so the chosen-likelihood-collapse tripwire could never fire while
485
+ appearing fully wired: constructed, updated every log step, and listed as
486
+ armed in every manifest. A guard that cannot fire is worse than no guard,
487
+ because it is also a claim that the failure mode is being watched.
488
+
489
+ TWO DIFFERENT CONTRACTS, and conflating them is what caused this:
490
+
491
+ * A REPORTED statistic that is undefined must be NaN. `0.0` would read
492
+ as a real measurement ("no preference learned"), which is a finding
493
+ rather than a missing number.
494
+ * A tripwire INPUT must never be NaN-seeded. An armed guard reading NaN
495
+ is running blind, and blind is indistinguishable from healthy in every
496
+ log it writes.
497
+
498
+ Run at the first log step of any stage with armed tripwires, so metric-name
499
+ drift becomes a launch failure instead of a silent disarming.
500
+
501
+ A tripwire reporting `warming_up` PASSES this check. Its inputs are
502
+ present and finite; it simply has not accumulated enough sample to speak
503
+ yet, and it says so. Treating that as a failure would halt every DPO run
504
+ on its first batch, and the fix for THAT would be to delete the self-test
505
+ -- which is exactly how the original disarmed tripwire came about.
506
+ """
507
+ problems = []
508
+ for name, wire in tripwires.items():
509
+ for key in getattr(wire, "INPUT_KEYS", ()):
510
+ if key not in metrics:
511
+ problems.append(
512
+ f"{name}: input {key!r} is ABSENT from the logged metrics "
513
+ f"(present: {sorted(metrics)[:8]})"
514
+ )
515
+ elif (
516
+ isinstance(metrics[key], float)
517
+ and math.isnan(metrics[key])
518
+ and not getattr(wire, "TOLERATES_NAN_INPUT", False)
519
+ ):
520
+ problems.append(f"{name}: input {key!r} is NaN -- the guard would run blind")
521
+ if problems:
522
+ raise RuntimeError(
523
+ "ARMED TRIPWIRE SELF-TEST FAILED -- halting before training "
524
+ "proceeds:\n - "
525
+ + "\n - ".join(problems)
526
+ + "\n\nAn armed tripwire whose input is missing or NaN cannot "
527
+ "fire, and will report itself armed in every manifest anyway. "
528
+ "Either supply the metric (the loss adapter owns this) or do not "
529
+ "arm the tripwire for this stage. Canonical margin key: "
530
+ "'rewards/margins' -- the name section-05's kernel emits."
531
+ )
532
+
533
+
534
+ # ---------------------------------------------------------------------------
535
+ # throughput probe + RSS projection
536
+ # ---------------------------------------------------------------------------
537
+
538
+
539
+ def _percentile(sorted_vals: Sequence[float], q: float) -> float:
540
+ if not sorted_vals:
541
+ return NAN
542
+ idx = min(len(sorted_vals) - 1, max(0, int(round(q * (len(sorted_vals) - 1)))))
543
+ return sorted_vals[idx]
544
+
545
+
546
+ def aggregate_micro_step_times(micro_times_s: Sequence[float], grad_accum: int) -> list[float]:
547
+ """Sum consecutive micro-step durations into OPTIMIZER-step durations.
548
+
549
+ The unit bug this exists to kill (task #25): both probes measure
550
+ per-MICRO durations, while `throughput_report` takes per-OPTIMIZER token
551
+ counts and horizon. Feeding micro times straight in under-projects
552
+ wall-clock and over-reports tokens/s by ~grad_accum x (8x on the DPO
553
+ config) -- a banner that costs GPU-hours wrong by an order of magnitude.
554
+
555
+ Only COMPLETE groups are kept: a trailing partial group would read as one
556
+ impossibly fast optimizer step. Raises when no complete group exists --
557
+ same contract as throughput_report itself (resolved or refused, never
558
+ quietly wrong); size probe_steps >= grad_accum at the config level.
559
+ """
560
+ if grad_accum <= 0:
561
+ raise ValueError(f"grad_accum={grad_accum} is not a schedule")
562
+ times = [float(t) for t in micro_times_s]
563
+ groups = [
564
+ sum(times[i : i + grad_accum]) for i in range(0, len(times) - grad_accum + 1, grad_accum)
565
+ ]
566
+ if not groups:
567
+ raise ValueError(
568
+ f"{len(times)} micro-step times cannot fill one optimizer step at "
569
+ f"grad_accum={grad_accum}; raise probe_steps to at least grad_accum"
570
+ )
571
+ return groups
572
+
573
+
574
+ def throughput_report(
575
+ step_times_s: Sequence[float],
576
+ tokens_per_step: int,
577
+ optimizer_steps_planned: int,
578
+ offload_transfer_s: Optional[float] = None,
579
+ offload_cpu_step_s: Optional[float] = None,
580
+ ) -> dict:
581
+ """Turn measured step times into the banner's throughput entry.
582
+
583
+ Refuses to produce a partial entry: a stage that starts without knowing its
584
+ step time is a stage committing GPU-hours to a schedule nobody costed. When
585
+ offload is inactive the offload timings are NaN -- explicitly, because a
586
+ 0.0 there would read as 'offload is free'.
587
+ """
588
+ times = [float(t) for t in step_times_s]
589
+ if not times:
590
+ raise ValueError(
591
+ "throughput_report got no step times. A probe that cannot fill "
592
+ "its banner keys must raise: the banner's contract is that every "
593
+ "value is resolved or explicitly NaN, never quietly absent."
594
+ )
595
+ if optimizer_steps_planned <= 0:
596
+ raise ValueError(
597
+ f"optimizer_steps_planned={optimizer_steps_planned} is not a "
598
+ "schedule; the projected wall-clock would be meaningless"
599
+ )
600
+
601
+ mean = sum(times) / len(times)
602
+ ordered = sorted(times)
603
+ return {
604
+ "n_steps": len(times),
605
+ "mean_step_time_s": mean,
606
+ "p50_step_time_s": _percentile(ordered, 0.50),
607
+ "p90_step_time_s": _percentile(ordered, 0.90),
608
+ "tokens_per_s": tokens_per_step / mean if mean > 0 else NAN,
609
+ "projected_epoch_s": optimizer_steps_planned * mean,
610
+ "offload_transfer_s": (NAN if offload_transfer_s is None else float(offload_transfer_s)),
611
+ "offload_cpu_step_s": (NAN if offload_cpu_step_s is None else float(offload_cpu_step_s)),
612
+ }
613
+
614
+
615
+ def projected_job_rss(cfg) -> dict:
616
+ """Projected host RSS for this job, for the guard and the launch banner.
617
+
618
+ Delegates to `guard.projected_full_ft_rss_gb` rather than restating the
619
+ arithmetic. Two formulas for the same budget WILL drift, and then the
620
+ banner advertises a number the preflight is not enforcing -- while the
621
+ preflight is the only thing standing between this job and the 2026-08-12
622
+ host crash (2GB free, load 619, no swap, a healthy run killed by an NCCL
623
+ timeout five hours into a stalled checkpoint).
624
+ """
625
+ from ..utils.guard import projected_full_ft_rss_gb
626
+
627
+ return projected_full_ft_rss_gb(cfg)
code/align/train/quant8.py ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Block-wise 8-bit dynamic quantization, in plain torch.
2
+
3
+ This is the numeric core of `adamw8bit.py` and carries NO optimizer logic, so
4
+ the codebook and the quantize/dequantize round trip can be tested (and
5
+ mis-implemented) on their own. The scheme is Dettmers et al. 2022, "8-bit
6
+ Optimizers via Block-wise Quantization" (arXiv:2110.02861), rebuilt from the
7
+ paper's description with torch only (docs/DEPENDENCY_POLICY.md: bitsandbytes
8
+ is not on the allowed list).
9
+
10
+ WHY BLOCK-WISE: one absmax for a whole 134M-element embedding shard lets a
11
+ single outlier set the scale for every element, and the other 134M-1 values
12
+ land in the bottom few codes. Normalising each block of `block_size` (default
13
+ 2048) elements by its OWN absmax confines an outlier's damage to its block and
14
+ makes the quantization independent of how the tensor happens to be sharded
15
+ across ranks -- which matters, because under FSDP2 the blocks are cut from the
16
+ rank-LOCAL shard.
17
+
18
+ WHY A DYNAMIC (NON-LINEAR) CODEBOOK: Adam's moments span many decades inside
19
+ one block (the second moment is a square of a heavy-tailed gradient). A
20
+ linear 8-bit grid has uniform ABSOLUTE resolution, so everything below
21
+ ~absmax/256 collapses to zero -- and a zero second moment makes the update
22
+ `m / (sqrt(0) + eps)`, i.e. m / 1e-8. The dynamic map spends its 256 codes as
23
+ an exponent (a power of ten, 7 of them) plus a linear fraction inside each
24
+ decade: relative resolution of a few percent from absmax down to ~1e-6*absmax.
25
+
26
+ THE ONE THING THIS MODULE DOES NOT DO is decide when quantization is safe:
27
+ the caller keeps tensors below `min_8bit_size` in fp32 (small tensors such as
28
+ norm scales are both cheap to keep and the most sensitive to rounding).
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import functools
34
+
35
+ import torch
36
+
37
+ #: 8-bit codes. Not configurable: the code type is `uint8` and the lookup
38
+ #: arithmetic assumes exactly 256 entries.
39
+ N_CODES = 256
40
+
41
+
42
+ def create_dynamic_map(signed: bool = True, max_exponent_bits: int = 7, total_bits: int = 8):
43
+ """The sorted 256-entry dynamic codebook, float32, built like bnb's
44
+ `create_dynamic_map` (the construction the paper describes).
45
+
46
+ Layout: for each exponent `i` in `0..max_exponent_bits-1` the decade
47
+ `10**(i - (max_exponent_bits-1)) * [0.1, 1]` is cut into linear fraction
48
+ bins and each bin contributes its MIDPOINT (so no code sits on a decade
49
+ edge and codes are strictly increasing). Decade `i` gets `2**i` fraction
50
+ values when signed and `2**(i+1)` when unsigned -- the unsigned map spends
51
+ the sign bit on one more bit of fraction. Exact `0.0` and `1.0` are then
52
+ appended. Counting, signed: 2 * (1+2+...+64) + 2 = 256; unsigned:
53
+ (2+4+...+128) + 2 = 256. Both assertions below fire if someone changes a
54
+ parameter without redoing the arithmetic.
55
+
56
+ Signed map range: [-0.99297, 1.0] (there is no exact -1.0; the most
57
+ negative code is the midpoint of the top bin, 0.7% from -1). That is the
58
+ same asymmetry bnb has, and it is bounded: the block's largest-magnitude
59
+ element, if negative, is stored within 0.7%.
60
+ Unsigned map range: [0.0, 1.0].
61
+
62
+ Built in float64 and cast once, so the table is bit-identical across
63
+ machines and torch versions (linspace/mean in fp32 is not guaranteed to be).
64
+ """
65
+ if total_bits != 8:
66
+ raise ValueError("create_dynamic_map builds 256-entry (8-bit) maps only")
67
+ non_sign_bits = total_bits - 1
68
+ if not 1 <= max_exponent_bits <= non_sign_bits:
69
+ raise ValueError(f"max_exponent_bits must be in [1, {non_sign_bits}]")
70
+ additional_items = 2 ** (non_sign_bits - max_exponent_bits) - 1
71
+
72
+ data: list[float] = []
73
+ for i in range(max_exponent_bits):
74
+ if signed:
75
+ fraction_items = 2 ** (i + non_sign_bits - max_exponent_bits) + 1
76
+ else:
77
+ fraction_items = 2 ** (i + non_sign_bits - max_exponent_bits + 1) + 1
78
+ boundaries = torch.linspace(0.1, 1.0, fraction_items, dtype=torch.float64)
79
+ means = (boundaries[:-1] + boundaries[1:]) / 2.0
80
+ scale = 10.0 ** (-(max_exponent_bits - 1) + i)
81
+ data += (scale * means).tolist()
82
+ if signed:
83
+ data += (-scale * means).tolist()
84
+ if additional_items > 0:
85
+ boundaries = torch.linspace(0.1, 1.0, additional_items + 1, dtype=torch.float64)
86
+ means = (boundaries[:-1] + boundaries[1:]) / 2.0
87
+ data += means.tolist()
88
+ if signed:
89
+ data += (-means).tolist()
90
+ data.append(0.0)
91
+ data.append(1.0)
92
+ data.sort()
93
+ if len(data) != N_CODES:
94
+ raise AssertionError(f"dynamic map has {len(data)} entries, expected {N_CODES}")
95
+ out = torch.tensor(data, dtype=torch.float64).to(torch.float32)
96
+ if not bool((out[1:] > out[:-1]).all()):
97
+ raise AssertionError("dynamic map is not strictly increasing in float32")
98
+ return out
99
+
100
+
101
+ @functools.lru_cache(maxsize=None)
102
+ def _cached_map(signed: bool) -> torch.Tensor:
103
+ return create_dynamic_map(signed=signed)
104
+
105
+
106
+ _DEVICE_CACHE: dict = {}
107
+
108
+
109
+ def codebook(signed: bool, device) -> torch.Tensor:
110
+ """The shared codebook on `device` (cached; never mutate the result)."""
111
+ device = torch.device(device)
112
+ key = (bool(signed), device.type, device.index)
113
+ cb = _DEVICE_CACHE.get(key)
114
+ if cb is None:
115
+ cb = _cached_map(bool(signed)).to(device)
116
+ _DEVICE_CACHE[key] = cb
117
+ return cb
118
+
119
+
120
+ def zero_code(signed: bool) -> int:
121
+ """Index of the exact-0.0 code (the all-zero state initialises to it)."""
122
+ return int((_cached_map(bool(signed)) == 0.0).nonzero()[0])
123
+
124
+
125
+ def nearest_code(x: torch.Tensor, cb: torch.Tensor) -> torch.Tensor:
126
+ """Nearest-code index of every element of `x` (any shape), as uint8.
127
+
128
+ `searchsorted` gives, for each x, the first index `hi` with cb[hi] >= x; the
129
+ nearest code is then either `hi` or `hi-1`, decided by comparing x against
130
+ their MIDPOINT (ties go to the lower code). Clamping `hi` to [1, 255]
131
+ first makes both candidates always exist, and values outside the table
132
+ (x <= cb[0] or x >= cb[-1]) resolve to the end codes by the same
133
+ comparison -- no special-casing, so no off-by-one at the edges.
134
+
135
+ `out_int32=True`: the index tensor is the largest transient in a chunked
136
+ step, and int32 halves it. (The final uint8 cast is what is stored.)
137
+ """
138
+ flat = x.reshape(-1).contiguous()
139
+ hi = torch.searchsorted(cb, flat, out_int32=True).clamp_(1, N_CODES - 1)
140
+ lo = hi - 1
141
+ mid = (cb[lo] + cb[hi]) * 0.5
142
+ pick_hi = flat > mid
143
+ return torch.where(pick_hi, hi, lo).to(torch.uint8).reshape(x.shape)
144
+
145
+
146
+ def quantize_rows(x: torch.Tensor, cb: torch.Tensor):
147
+ """Quantize a `[n_blocks, block_size]` fp32 matrix, one block per row.
148
+
149
+ Returns `(codes uint8 [n_blocks, block_size], absmax fp32 [n_blocks])`.
150
+ An all-zero block has absmax 0; dividing by it would put NaN through the
151
+ lookup, so the divisor is replaced by 1 there (the block's values are all
152
+ 0, which then quantize to the zero code and dequantize to 0 * absmax = 0).
153
+ """
154
+ absmax = x.abs().amax(dim=1)
155
+ safe = torch.where(absmax > 0, absmax, torch.ones_like(absmax))
156
+ normalized = x / safe.unsqueeze(1)
157
+ return nearest_code(normalized, cb), absmax
158
+
159
+
160
+ def dequantize_rows(codes: torch.Tensor, absmax: torch.Tensor, cb: torch.Tensor) -> torch.Tensor:
161
+ """Inverse of `quantize_rows`: fp32 `[n_blocks, block_size]`.
162
+
163
+ The index MUST be a signed integer tensor: `cb[codes]` with a uint8 `codes`
164
+ is interpreted by torch as a (deprecated) boolean-mask index, not a gather.
165
+ `index_select` with an int32 index has no such reading.
166
+ """
167
+ n_blocks, block_size = codes.shape
168
+ values = cb.index_select(0, codes.reshape(-1).to(torch.int32)).reshape(n_blocks, block_size)
169
+ return values * absmax.unsqueeze(1)
170
+
171
+
172
+ def quantize_blockwise(x: torch.Tensor, block_size: int = 2048, signed: bool = True):
173
+ """Quantize any tensor (flattened) -> `(codes uint8 [n_blocks*block_size], absmax)`.
174
+
175
+ The final partial block is zero-padded; the pad quantizes to the zero code
176
+ and is dropped by `dequantize_blockwise(..., numel=)`.
177
+ """
178
+ flat = x.detach().reshape(-1).to(torch.float32)
179
+ n = flat.numel()
180
+ n_blocks = max(1, -(-n // block_size))
181
+ padded = torch.zeros(n_blocks * block_size, dtype=torch.float32, device=flat.device)
182
+ padded[:n] = flat
183
+ codes, absmax = quantize_rows(padded.view(n_blocks, block_size), codebook(signed, flat.device))
184
+ return codes.reshape(-1), absmax
185
+
186
+
187
+ def dequantize_blockwise(
188
+ codes: torch.Tensor, absmax: torch.Tensor, numel: int, block_size: int = 2048, signed: bool = True
189
+ ) -> torch.Tensor:
190
+ """Inverse of `quantize_blockwise`, trimmed to `numel` elements (fp32, flat)."""
191
+ rows = codes.reshape(-1, block_size)
192
+ out = dequantize_rows(rows, absmax, codebook(signed, codes.device))
193
+ return out.reshape(-1)[:numel]
code/align/train/qwen3_stage.py ADDED
@@ -0,0 +1,466 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Qwen3 (Goedel-Prover-V2-8B) SFT stage pieces: model build, LoRA train module, weight format.
2
+
3
+ `scripts/sft.py` dispatches here when `model.arch == "qwen3"`; the dense-Mistral path never
4
+ imports this module. Everything the Trainer needs that is family-specific lives behind the
5
+ two seams the Trainer already has: `weight_format` (what a checkpoint's weight file is) and
6
+ `train_module` (what touches the model).
7
+
8
+ THE LoRA CHECKPOINT CONTRACT (same as `PPOTrainModule`, for the same reasons):
9
+
10
+ * The weight file of every checkpoint is the MERGED bf16 model (base + scaling * B @ A,
11
+ accumulated in fp32, cast once). Checkpoint saves are how vLLM / transformers / the
12
+ rollout engine receive the policy, and they load HF base weights, not adapters.
13
+ * The adapter itself (fp32, exact) rides in the optimizer payload, so a resume is lossless:
14
+ base is reloaded from the pinned snapshot at launch, the adapter and the 8-bit moments
15
+ come back from `optimizer_rank{r}.pt`.
16
+ * `load_weights` on a LoRA resume reads only the safetensors HEADERS (key set + shapes, on
17
+ the meta device). Materialising the merged 16 GB on each of four ranks would cost 64 GB of
18
+ host RAM to verify a key set and then be thrown away -- the 2026-08-12 shape.
19
+
20
+ FSDP2 + frozen base. Per-block `fully_shard` shards the frozen bf16 base (16.4 GB / world) and
21
+ the fp32 adapters together; only adapter gradients are reduce-scattered. Activation
22
+ checkpointing wraps each block. The root unit also owns embed_tokens / lm_head / final norm.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import gc
28
+ import logging
29
+ import os
30
+ from typing import Any, Optional
31
+
32
+ import torch
33
+
34
+ from .ckpt_format import WeightFormat # noqa: F401 (the Protocol this implements)
35
+ from .train_module import TrainModule
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ SUPPORTED_ARCHS = ("mistral", "qwen3")
40
+
41
+
42
+ class Qwen3StageError(RuntimeError):
43
+ """A qwen3 stage config that cannot be launched as written."""
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Config validation
48
+ # ---------------------------------------------------------------------------
49
+
50
+
51
+ def validate_qwen3_cfg(cfg, qcfg) -> None:
52
+ """Refuse launches whose declared numbers disagree with the snapshot they will load.
53
+
54
+ Derived-vs-hardcoded: `model.n_params` feeds the RSS projection and the banner; the
55
+ architecture truth is the snapshot's config.json. A TOML that still carries the Mistral
56
+ defaults (7.25B / 32 layers) would project the wrong host footprint and print a banner
57
+ describing a different model.
58
+ """
59
+ from ..model.qwen3 import count_params
60
+
61
+ n = count_params(qcfg)
62
+ problems = []
63
+ if cfg.model.n_params != n:
64
+ problems.append(f"model.n_params={cfg.model.n_params} but the snapshot implies {n}")
65
+ if cfg.model.n_layers != qcfg.n_layers:
66
+ problems.append(f"model.n_layers={cfg.model.n_layers} but the snapshot has {qcfg.n_layers}")
67
+ if cfg.model.vocab_size != qcfg.vocab_size:
68
+ problems.append(f"model.vocab_size={cfg.model.vocab_size} but the snapshot has {qcfg.vocab_size}")
69
+ if cfg.model.hidden_size != qcfg.hidden_size:
70
+ problems.append(f"model.hidden_size={cfg.model.hidden_size} but the snapshot has {qcfg.hidden_size}")
71
+ if cfg.model.precision != "bf16":
72
+ problems.append(f"model.precision={cfg.model.precision!r}: bf16 only")
73
+ if problems:
74
+ raise Qwen3StageError("qwen3 stage config disagrees with its snapshot: " + "; ".join(problems))
75
+ lo = cfg.lora
76
+ if lo.enabled:
77
+ if lo.dropout != 0.0:
78
+ raise Qwen3StageError(
79
+ f"lora.dropout={lo.dropout}: align.ppo.lora has no dropout path (train-time and "
80
+ "score-time logprobs would differ); set 0.0"
81
+ )
82
+ if cfg.train.full_ft:
83
+ raise Qwen3StageError("lora.enabled with train.full_ft=true: LoRA freezes the base; set train.full_ft=false")
84
+ elif not cfg.train.full_ft:
85
+ raise Qwen3StageError("train.full_ft=false requires lora.enabled=true (otherwise nothing trains)")
86
+
87
+
88
+ def resolve_qwen3(cfg):
89
+ """(snapshot_dir, Qwen3Config) from `paths.model_snapshot`, with the attention request applied."""
90
+ from ..model import checkpoint as ckpt
91
+ from ..model.qwen3_checkpoint import read_qwen3_config
92
+
93
+ snapshot = ckpt.resolve_snapshot_dir(cfg.paths.model_snapshot)
94
+ qcfg = read_qwen3_config(snapshot).replace(attn_implementation=cfg.model.attn_implementation)
95
+ validate_qwen3_cfg(cfg, qcfg)
96
+ return snapshot, qcfg
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # Weight format
101
+ # ---------------------------------------------------------------------------
102
+
103
+
104
+ class Qwen3Format:
105
+ """`WeightFormat` for Qwen3: sharded HF safetensors + config.json + tokenizer files."""
106
+
107
+ def __init__(self, qcfg, tokenizer_dir: str, lora: bool):
108
+ self.qcfg = qcfg
109
+ self.tokenizer_dir = tokenizer_dir
110
+ self.lora = bool(lora)
111
+
112
+ def save_weights(self, state, out_dir, dtype):
113
+ from ..model.qwen3_checkpoint import save_hf_qwen3
114
+
115
+ save_hf_qwen3(dict(state), out_dir, cfg=self.qcfg, dtype=dtype)
116
+
117
+ def load_weights(self, path):
118
+ from ..model.checkpoint import load_hf_safetensors
119
+ from ..model.qwen3_checkpoint import hf_to_align
120
+
121
+ if self.lora:
122
+ return hf_to_align(_hf_headers_on_meta(path), self.qcfg)
123
+ return hf_to_align(load_hf_safetensors(path), self.qcfg)
124
+
125
+ def copy_tokenizer(self, snapshot, dst_dir): # noqa: ARG002 -- the configured dir is authoritative
126
+ from ..model.qwen3_checkpoint import copy_tokenizer_files
127
+
128
+ return copy_tokenizer_files(self.tokenizer_dir, dst_dir)
129
+
130
+
131
+ def _hf_headers_on_meta(path: str) -> dict:
132
+ """{hf key: empty meta tensor of the stored shape/dtype} for a checkpoint dir. Reads the
133
+ safetensors headers only -- no tensor data is touched."""
134
+ import json
135
+
136
+ from safetensors import safe_open
137
+
138
+ from ..model.checkpoint import INDEX_NAME, WEIGHTS_NAME
139
+
140
+ idx = os.path.join(path, INDEX_NAME)
141
+ if os.path.isfile(idx):
142
+ with open(idx) as f:
143
+ files = sorted(set(json.load(f)["weight_map"].values()))
144
+ elif os.path.isfile(os.path.join(path, WEIGHTS_NAME)):
145
+ files = [WEIGHTS_NAME]
146
+ else:
147
+ raise FileNotFoundError(f"no {WEIGHTS_NAME} or {INDEX_NAME} at {path!r}")
148
+ out: dict[str, torch.Tensor] = {}
149
+ for fname in files:
150
+ with safe_open(os.path.join(path, fname), framework="pt") as f:
151
+ for k in f.keys():
152
+ sl = f.get_slice(k)
153
+ dt = _ST_DTYPES[sl.get_dtype()]
154
+ out[k] = torch.empty(sl.get_shape(), dtype=dt, device="meta")
155
+ return out
156
+
157
+
158
+ _ST_DTYPES = {"BF16": torch.bfloat16, "F32": torch.float32, "F16": torch.float16}
159
+
160
+
161
+ # ---------------------------------------------------------------------------
162
+ # Base identity (a LoRA resume must reattach the adapter to the base it was trained on)
163
+ # ---------------------------------------------------------------------------
164
+
165
+
166
+ def base_fingerprint(state) -> str:
167
+ """sha256 over the frozen base's identity: every non-adapter tensor's name/shape/dtype, every
168
+ 1-D tensor in full (norm weights differ between fine-tunes of one architecture), and the first
169
+ and last 8 rows of every matrix (embeddings, lm_head and each projection). Cheap (~tens of MB
170
+ hashed for 8B) yet distinguishes two bases with the SAME key set and shapes -- the only
171
+ difference a LoRA resume, which never loads the checkpoint's weights, could otherwise miss.
172
+ Needs plain (unsharded, non-meta) tensors: a shard-local hash would differ per rank."""
173
+ import hashlib
174
+
175
+ h = hashlib.sha256()
176
+ for k in sorted(k for k in state if ".adapters." not in k):
177
+ t = state[k]
178
+ if hasattr(t, "to_local") or t.device.type == "meta":
179
+ raise ValueError(f"cannot fingerprint {k!r}: sharded or meta tensor")
180
+ t = t.detach()
181
+ h.update(f"{k}|{tuple(t.shape)}|{t.dtype}".encode())
182
+ parts = [t] if t.dim() <= 1 else [t[:8], t[-8:]]
183
+ for part in parts:
184
+ h.update(part.to("cpu").contiguous().reshape(-1).view(torch.uint8).numpy().tobytes())
185
+ return h.hexdigest()
186
+
187
+
188
+ # ---------------------------------------------------------------------------
189
+ # LoRA train module
190
+ # ---------------------------------------------------------------------------
191
+
192
+
193
+ class LoRASFTModule(TrainModule):
194
+ """`TrainModule` whose checkpoints publish merged weights and persist the adapter."""
195
+
196
+ def __init__(self, model, loss_adapter, optimizer, grad_accum: int, adapter: str, weight_format=None):
197
+ super().__init__(model, loss_adapter, optimizer, grad_accum)
198
+ from ..ppo.lora import has_lora
199
+
200
+ if not has_lora(model):
201
+ raise Qwen3StageError("LoRASFTModule needs a model with injected LoRA layers")
202
+ self.adapter = adapter
203
+ if weight_format is not None:
204
+ self.weight_format = weight_format
205
+ # Identity of the frozen base this adapter trains on (recorded in the optimizer payload,
206
+ # checked on resume). `build_qwen3_stage_model` stamps it BEFORE the FSDP wrap, when the
207
+ # tensors are still whole; a directly built model is fingerprinted here if it can be.
208
+ fp = getattr(model, "_base_fingerprint", None)
209
+ if fp is None:
210
+ try:
211
+ fp = base_fingerprint(model.state_dict())
212
+ except ValueError as e:
213
+ logger.warning("LoRA base fingerprint unavailable (%s); a resume cannot verify its base", e)
214
+ self.base_fingerprint: Optional[str] = fp
215
+
216
+ def full_model_state_dict(self):
217
+ """BASE-keyed weights with the adapter FOLDED IN (see the module docstring).
218
+
219
+ `get_model_state_dict(full_state_dict=True, cpu_offload=True)` returns the full dict on
220
+ RANK 0 ONLY; every other rank gets `{}`. The gather is the collective every rank
221
+ joins; merging an empty dict is a KeyError on ranks 1..N-1 at the FIRST save, which
222
+ strands rank 0 in the next collective until the watchdog kills the run.
223
+ """
224
+ import torch.distributed as dist
225
+
226
+ from ..ppo.lora import export_merged_state_dict
227
+
228
+ sd = super().full_model_state_dict()
229
+ if not sd and dist.is_available() and dist.is_initialized() and dist.get_rank() != 0:
230
+ return sd
231
+ self.last_export_report: dict = {} # retained-delta fidelity of the bf16 cast (warned on when poor)
232
+ return export_merged_state_dict(self.model, sd, self.adapter, report=self.last_export_report)
233
+
234
+ def load_model_state_dict(self, nmo_state: dict) -> None:
235
+ """LoRA resume: the checkpoint's weights are MERGED and are deliberately NOT loaded --
236
+ the frozen base was reloaded from the snapshot at launch, and loading merged weights
237
+ into it would double-count the delta once the adapter is restored from the optimizer
238
+ payload. The key set is still verified so a checkpoint of another architecture cannot
239
+ resume silently."""
240
+ base_keys = {k for k in self.model.state_dict() if ".adapters." not in k}
241
+ missing, unexpected = sorted(base_keys - set(nmo_state)), sorted(set(nmo_state) - base_keys)
242
+ if missing or unexpected:
243
+ raise KeyError(f"resume state mismatch: missing={missing[:5]} unexpected={unexpected[:5]}")
244
+ logger.info("LoRA resume: merged weights verified (%d tensors) and NOT loaded; adapter comes from the optimizer payload", len(base_keys))
245
+
246
+ def optim_state_dict(self) -> dict:
247
+ from ..ppo.lora import lora_state_dict
248
+
249
+ lora = lora_state_dict(self.model, self.adapter)
250
+ # The ZeRO-3 save defect, structurally: a sharded placeholder serialises as an EMPTY
251
+ # tensor and the file loads without error while being untrained.
252
+ empty = [k for k, v in lora.items() if v.numel() == 0]
253
+ if empty:
254
+ raise RuntimeError(
255
+ f"{len(empty)} adapter tensors are empty (e.g. {empty[:2]}): a sharded placeholder was "
256
+ "serialised instead of the gathered parameter. Refusing to write a checkpoint that "
257
+ "loads without error and is untrained."
258
+ )
259
+ return {"optimizer": self.optimizer.state_dict(), "lora": lora, "base_fingerprint": self.base_fingerprint}
260
+
261
+ def load_optim_state_dict(self, sd: dict) -> None:
262
+ from ..ppo.lora import load_lora_state_dict
263
+
264
+ if "lora" not in sd or "optimizer" not in sd:
265
+ raise KeyError(
266
+ "checkpoint optimizer payload lacks 'lora'/'optimizer'; resuming would restart the "
267
+ "policy from the bare base while claiming a resume"
268
+ )
269
+ # THE UNBOUND-ADAPTER DEFECT. The checkpoint's merged weights are not loaded, so nothing
270
+ # else ties this adapter to the base reloaded at launch: a repointed model_snapshot or a
271
+ # second HF-cache snapshot (same keys, same shapes) would resume "fine" on the wrong base.
272
+ saved = sd.get("base_fingerprint")
273
+ if saved is None or self.base_fingerprint is None:
274
+ logger.warning(
275
+ "LoRA resume cannot verify the base: base fingerprint missing (checkpoint=%s, this launch=%s)",
276
+ saved is not None, self.base_fingerprint is not None,
277
+ )
278
+ elif saved != self.base_fingerprint:
279
+ raise RuntimeError(
280
+ "LoRA resume: the base model loaded at launch is not the base this adapter was trained on "
281
+ f"(fingerprint {self.base_fingerprint[:12]} != checkpoint {saved[:12]}). Check "
282
+ "paths.model_snapshot and the HF cache for a second snapshot."
283
+ )
284
+ self.optimizer.load_state_dict(sd["optimizer"])
285
+ load_lora_state_dict(self.model, self.adapter, sd["lora"], strict=True)
286
+
287
+
288
+ # ---------------------------------------------------------------------------
289
+ # Model build
290
+ # ---------------------------------------------------------------------------
291
+
292
+
293
+ def _vm_rss_gb(field: str = "VmRSS") -> float:
294
+ with open("/proc/self/status") as f:
295
+ for line in f:
296
+ if line.startswith(field + ":"):
297
+ return float(line.split()[1]) / 1024**2
298
+ return float("nan")
299
+
300
+
301
+ def build_qwen3_stage_model(cfg, attn_implementation: Optional[str] = None):
302
+ """Rank-serial load -> GPU -> (LoRA inject, freeze base) -> activation checkpointing -> FSDP2.
303
+
304
+ RANK-SERIAL because each rank materialises the 16.4 GB bf16 state dict on the host before
305
+ moving it to its card; four at once is 66 GB on a no-swap box. Ranks load one at a time
306
+ (barrier-ordered) and return freed heap to the OS (`malloc_trim`) before the next starts.
307
+ """
308
+ from ..model import qwen3 as q3
309
+ from ..model.qwen3_checkpoint import hf_to_align
310
+ from ..model.checkpoint import load_hf_safetensors
311
+ from ..ppo.lora import LoRAConfig, inject_lora, mark_only_adapter_trainable
312
+ from ..utils.dist import barrier, local_rank, rank, world_size
313
+ from . import fsdp
314
+
315
+ snapshot, qcfg = resolve_qwen3(cfg)
316
+ if attn_implementation:
317
+ qcfg = qcfg.replace(attn_implementation=attn_implementation)
318
+ device = torch.device("cuda", local_rank())
319
+ torch.cuda.set_device(device)
320
+
321
+ model = None
322
+ peaks: dict[str, Any] = {}
323
+ for r in range(world_size()):
324
+ if rank() == r:
325
+ sd = hf_to_align(load_hf_safetensors(snapshot, dtype=torch.bfloat16), qcfg)
326
+ model = q3.Qwen3ForCausalLM.from_state_dict(qcfg, sd, device="cuda")
327
+ del sd
328
+ model = model.to(device)
329
+ peaks["rss_hwm_gb"] = round(_vm_rss_gb("VmHWM"), 2) # high-water mark of the load, not the residual
330
+ gc.collect()
331
+ try:
332
+ import ctypes
333
+
334
+ ctypes.CDLL("libc.so.6").malloc_trim(0)
335
+ except (OSError, AttributeError):
336
+ pass
337
+ peaks["rss_after_trim_gb"] = round(_vm_rss_gb(), 2)
338
+ barrier()
339
+
340
+ if cfg.lora.enabled:
341
+ model._base_fingerprint = base_fingerprint(model.state_dict()) # whole tensors, before inject/FSDP
342
+ lc = LoRAConfig(r=cfg.lora.r, alpha=cfg.lora.alpha, target_modules=tuple(cfg.lora.target_modules), rslora=cfg.lora.rslora)
343
+ # Identical adapter init on every rank: FSDP2 shards what each rank already holds and
344
+ # does NOT broadcast, so a per-rank random A would be a different adapter per shard.
345
+ gen = torch.Generator().manual_seed(cfg.train.seed)
346
+ hit = inject_lora(model, lc, adapter=cfg.lora.adapter, generator=gen)
347
+ n_trainable = mark_only_adapter_trainable(model, cfg.lora.adapter)
348
+ expected = len(cfg.lora.target_modules) * qcfg.n_layers
349
+ if len(hit) != expected:
350
+ raise Qwen3StageError(f"LoRA hit {len(hit)} layers, expected {expected} ({len(cfg.lora.target_modules)} targets x {qcfg.n_layers} layers)")
351
+ peaks["lora_trainable_params"] = n_trainable
352
+ fsdp.apply_activation_checkpointing(model)
353
+ if world_size() > 1:
354
+ fsdp.apply_fsdp(model)
355
+ model._stage_load_stats = peaks # for the banner / probe records
356
+ return model
357
+
358
+
359
+ def qwen3_trainer_extras(cfg, model, loss_adapter, run_dir: str) -> dict:
360
+ """The Trainer kwargs a qwen3 stage adds: weight_format, per-rank tracker, and for LoRA the
361
+ train_module and the probe view.
362
+
363
+ PER-RANK TRACKER. The Trainer's default constructs a `Tracker` on EVERY rank, all appending
364
+ to the same `<run_dir>/history.jsonl`: four interleaved streams with no rank field, so
365
+ "did each rank see different rows?" cannot be answered from the record (and torn
366
+ interleaving is one fsync away). Rank 0 keeps `<run_dir>`; rank r writes `<run_dir>/rank{r}`.
367
+ """
368
+ from ..utils.dist import rank as _rank
369
+ from ..utils.tracking import Tracker
370
+ from .offload_adamw import make_optimizer
371
+ from .trainer import MANDATORY_METRICS
372
+
373
+ snapshot, qcfg = resolve_qwen3(cfg)
374
+ r = _rank()
375
+ extras: dict = {
376
+ "weight_format": Qwen3Format(qcfg, tokenizer_dir=snapshot, lora=cfg.lora.enabled),
377
+ "tracker": Tracker(run_dir if r == 0 else os.path.join(run_dir, f"rank{r}"), "sft",
378
+ mandatory_keys=MANDATORY_METRICS, run_name=cfg.run_name),
379
+ }
380
+ if not cfg.lora.enabled:
381
+ return extras
382
+ optimizer = make_optimizer(cfg, [p for p in model.parameters() if p.requires_grad])
383
+ extras["train_module"] = LoRASFTModule(
384
+ model, loss_adapter, optimizer, cfg.optimizer.grad_accum, cfg.lora.adapter,
385
+ weight_format=extras["weight_format"],
386
+ )
387
+ from ..ppo.trainer import AdapterProbeView
388
+
389
+ extras["probe_model"] = AdapterProbeView(model, cfg.lora.adapter)
390
+ return extras
391
+
392
+
393
+ def qwen3_entry_banner(cfg, ledger) -> dict:
394
+ """The qwen3 counterpart of `stage_gates.run_entry_gates`: what can be verified WITHOUT the
395
+ Mistral-specific machinery, and an explicit PENDING for what cannot.
396
+
397
+ NOT APPLICABLE here, and why (each is a Mistral/PRM800K artifact, not a skipped check):
398
+ * golden regression + MATH-500/GSM8K quality gate: they certify the Mistral eval stack
399
+ against a Mistral checkpoint's recorded score. The qwen3 experiment's evaluation is the
400
+ Lean verifier loop (P2-B/C).
401
+ * the 10-step 7B smoke / 20-step probe: `build_stage_model` is Mistral-only. The qwen3
402
+ memory and throughput are measured by scripts/qwen3_sft_probe.py and recorded in
403
+ docs; the banner says PENDING rather than quoting a number this launch did not measure.
404
+ KEPT: batch arithmetic, shard receipts (hashes into the banner), the RSS projection, the
405
+ sampler-derived scheduler horizon, the snapshot-vs-config agreement.
406
+ """
407
+ from ..utils.guard import projected_full_ft_rss_gb
408
+ from .stage_gates import PENDING, assemble_sft_banner, assert_batch_arithmetic_consistent
409
+ from .stage_smoke import build_training_sampler, resolve_split_dir, sampler_derived_horizon
410
+
411
+ snapshot, qcfg = resolve_qwen3(cfg)
412
+ assert_batch_arithmetic_consistent(cfg)
413
+ sampler, dataset = build_training_sampler(cfg)
414
+ horizon = sampler_derived_horizon(cfg, sampler)
415
+ # THE OVERLONG-ROW DEFECT. The length-grouped sampler budgets tokens, but a row longer than the
416
+ # budget is not rejected: it becomes a batch of one and trains. seq_len is the length the
417
+ # memory envelope was MEASURED at (scripts/qwen3_sft_probe.py); a longer row would be the
418
+ # first thing to run at an untested length, on the last GPU-hour of an epoch.
419
+ lengths = dataset.lengths()
420
+ over = int((lengths > cfg.train.seq_len).sum())
421
+ if over:
422
+ raise Qwen3StageError(
423
+ f"{over} of {len(lengths)} rows are longer than train.seq_len={cfg.train.seq_len} "
424
+ f"(longest {int(lengths.max())}). Filter them at data-build time or raise seq_len to a "
425
+ "length that has been measured; the sampler would otherwise train each as a lone batch."
426
+ )
427
+ split_dir = resolve_split_dir(cfg.data.shard_dir, cfg.data.sft_split)
428
+ import json
429
+
430
+ hashes = {}
431
+ for rp in sorted(split_dir.glob("*.receipt.json")):
432
+ with open(rp) as f:
433
+ r = json.load(f)
434
+ hashes[r["name"]] = r["content_hash"]
435
+ if not hashes:
436
+ raise Qwen3StageError(f"no shard receipts under {split_dir}")
437
+ banner = assemble_sft_banner(
438
+ cfg,
439
+ attention_backend=qcfg.attn_implementation or "auto(sdpa)",
440
+ optimizer_impl=f"{cfg.optimizer.name} lora={'on r=%d alpha=%g' % (cfg.lora.r, cfg.lora.alpha) if cfg.lora.enabled else 'off'}",
441
+ dataset_hashes=hashes,
442
+ ceiling_results={"lean_verifier": PENDING, "note": "qwen3 stage: evaluated by the Lean loop (P2), not MATH/GSM8K"},
443
+ probe_report=None,
444
+ rss_projection=projected_full_ft_rss_gb(cfg),
445
+ mde={"note": PENDING},
446
+ scheduler_horizon_optimizer_steps=horizon,
447
+ scheduler_horizon_rows_derived=None,
448
+ )
449
+ banner.update({"arch": cfg.model.arch, "snapshot": snapshot, "n_rows": len(dataset), "seq_len": cfg.train.seq_len})
450
+ # The Mistral gate ledger enforces the golden->smoke->probe order, which does not apply here
451
+ # (recording a pass for a gate that did not run would be a lie), so the entry is persisted as
452
+ # its own receipt next to the ledger instead.
453
+ from ..utils.dist import is_rank0
454
+
455
+ if is_rank0():
456
+ tmp = os.path.join(ledger.run_dir, "qwen3_entry.json.partial")
457
+ with open(tmp, "w") as f:
458
+ json.dump(banner, f, indent=1, sort_keys=True, default=str)
459
+ os.replace(tmp, os.path.join(ledger.run_dir, "qwen3_entry.json"))
460
+ return banner
461
+
462
+
463
+ __all__ = [
464
+ "SUPPORTED_ARCHS", "Qwen3StageError", "base_fingerprint", "Qwen3Format", "LoRASFTModule", "validate_qwen3_cfg",
465
+ "resolve_qwen3", "build_qwen3_stage_model", "qwen3_trainer_extras", "qwen3_entry_banner",
466
+ ]
code/align/train/ref_cache.py ADDED
@@ -0,0 +1,834 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Reference log-probabilities: precompute, cache, join, and the path-bias rule.
2
+
3
+ DPO needs, for every pair, what the FROZEN reference policy thought of the
4
+ chosen and rejected completions. The textbook implementation keeps a second
5
+ 7B model resident and forwards it alongside the policy. That does not fit:
6
+ 14.5 GB of policy weights plus optimizer shards plus activations already fills
7
+ a 23 GB A10G, and a second copy fills a second one for no gradient.
8
+
9
+ So the reference is computed once, offline, and consumed as constants. The
10
+ whole arrangement rests on one property -- with dropout disabled, a no-grad
11
+ pass over a fixed dataset yields the same numbers every time -- and on one
12
+ discipline: the cache must be keyed on EVERYTHING that could change those
13
+ numbers, because every failure mode of a stale cache is silent. A cache from
14
+ the wrong checkpoint, the wrong tokenizer, or the pre-rebuild shards still
15
+ produces a finite log-sigmoid of a difference of plausible numbers. The loss
16
+ decreases. The margin it reports is a margin against something that does not
17
+ exist.
18
+
19
+ The key therefore carries six components, and each one is a way somebody has
20
+ or could break this:
21
+
22
+ * `checkpoint_hash` -- the obvious one; a cache from the previous SFT run.
23
+ Must be a CONTENT hash (`checkpoint_content_hash`): the tensor-inventory
24
+ hash cannot tell two SFT checkpoints of one architecture apart.
25
+ (A seventh, DIAGNOSTIC field, `checkpoint_inventory_hash`, carries the
26
+ inventory hash beside it: it is compared like the rest, but its only job
27
+ is to make a `checkpoint_hash` mismatch legible -- inventory equal means
28
+ "same architecture, different weights", the stale-SFT-attempt case.)
29
+ * `tokenizer_hash` -- retokenized pairs are different pairs.
30
+ * `manifest_hash` -- the one the plan did not list and this project needs
31
+ most: section-03's shards are being REBUILT (the duplicated `# Answer`
32
+ marker fix). A rebuild changes every pair's tokens while leaving the
33
+ checkpoint, the tokenizer and max_length identical, so nothing else in the
34
+ key moves.
35
+ * `max_length` and `truncation_mode` -- a completion truncated differently
36
+ has a different summed logprob, and summed logprobs are what DPO compares.
37
+ * `forward_impl` -- a single-GPU forward and an FSDP2-sharded forward are
38
+ not bitwise identical. That is the PATH-BIAS risk, and it is measured
39
+ (`path_bias_decision`) rather than assumed away, because early in DPO the
40
+ policy IS the reference and the loss argument sits at zero, which is
41
+ exactly where a systematic reference bias does the most damage.
42
+
43
+ EXISTING CACHES MUST BE RE-PRECOMPUTED (2026-10-12). Before this date the
44
+ launchers filled `checkpoint_hash` from the tensor-inventory hash
45
+ (`checkpoint_fingerprint()["manifest_sha256"]`). Every cache written that way
46
+ carries a key that can no longer be reproduced from a checkpoint's weights, so
47
+ it is refused by `load_ref_cache(expect=...)` -- correctly, because it could not
48
+ have told its own checkpoint from any sibling's. The refusal names
49
+ `checkpoint_hash`; the fix is to re-run scripts/precompute_ref_logprobs.py, not
50
+ to loosen the comparison. Both launchers now build the key through
51
+ `build_ref_cache_key`, the single place the six facts are assembled.
52
+ """
53
+
54
+ from __future__ import annotations
55
+
56
+ import hashlib
57
+ import json
58
+ import logging
59
+ import math
60
+ import os
61
+ from dataclasses import asdict, dataclass, field, fields
62
+ from typing import Any, Iterable, Optional, Sequence
63
+
64
+ import torch
65
+
66
+ logger = logging.getLogger(__name__)
67
+
68
+ NAN = float("nan")
69
+
70
+ #: One entry per pair. Lengths are carried because `sigmoid_norm` divides by
71
+ #: them; a length of 0 (a prompt-only row) must make the normalised quantity
72
+ #: NaN rather than raise a ZeroDivisionError three weeks later.
73
+ ENTRY_FIELDS = ("ref_logp_chosen", "ref_logp_rejected", "len_chosen", "len_rejected")
74
+
75
+ #: How the pass actually ran. Not a preference -- a record.
76
+ FORWARD_IMPLS = ("single_gpu", "fsdp2_sharded")
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class RefCacheKey:
81
+ """Everything that could silently change a cached reference logprob.
82
+
83
+ Frozen and compared component-by-component so a mismatch names the guilty
84
+ field. `RuntimeError: ref cache mismatch` with no further detail sends
85
+ somebody hunting through three sections; `mismatched: manifest_hash
86
+ (cache=man-abc, run=man-xyz)` sends them to the shard rebuild.
87
+ """
88
+
89
+ checkpoint_hash: str
90
+ tokenizer_hash: str
91
+ manifest_hash: str
92
+ max_length: int
93
+ truncation_mode: str
94
+ forward_impl: str
95
+ #: DIAGNOSTIC companion to `checkpoint_hash` (see the module docstring).
96
+ #: Empty only on caches written before 2026-10-12, which are refused on
97
+ #: `checkpoint_hash` anyway.
98
+ checkpoint_inventory_hash: str = ""
99
+
100
+ def __post_init__(self) -> None:
101
+ if self.forward_impl not in FORWARD_IMPLS:
102
+ raise ValueError(
103
+ f"forward_impl={self.forward_impl!r} must be one of "
104
+ f"{FORWARD_IMPLS}: it records how the pass ACTUALLY ran, and "
105
+ "an unrecognised tag cannot be compared against the run's."
106
+ )
107
+
108
+ def to_json(self) -> str:
109
+ return json.dumps(asdict(self), sort_keys=True)
110
+
111
+ @classmethod
112
+ def from_json(cls, blob: str) -> "RefCacheKey":
113
+ return cls(**json.loads(blob))
114
+
115
+ def mismatches(self, other: "RefCacheKey") -> list[str]:
116
+ return [f.name for f in fields(self) if getattr(self, f.name) != getattr(other, f.name)]
117
+
118
+
119
+ @dataclass
120
+ class RefCache:
121
+ key: RefCacheKey
122
+ #: pair_id -> (ref_logp_chosen, ref_logp_rejected, len_chosen, len_rejected)
123
+ entries: dict[str, tuple[float, float, float, float]] = field(default_factory=dict)
124
+
125
+ def __len__(self) -> int:
126
+ return len(self.entries)
127
+
128
+ def require(self, pair_id: str) -> tuple[float, float, float, float]:
129
+ """Look up a pair, or raise. There is no default: a zero reference
130
+ turns DPO into likelihood maximisation on the chosen side, which
131
+ trains and converges and is not DPO."""
132
+ try:
133
+ return self.entries[pair_id]
134
+ except KeyError:
135
+ raise KeyError(
136
+ f"pair_id {pair_id!r} is not in the reference cache "
137
+ f"({len(self.entries)} entries, key {self.key.to_json()}). "
138
+ "Refusing to substitute a default reference logprob. Either "
139
+ "the precompute did not cover this split, or these shards are "
140
+ "not the ones it ran over."
141
+ ) from None
142
+
143
+
144
+ def build_ref_cache_key(
145
+ sft_checkpoint: str, manifest: dict, *, forward_impl: str
146
+ ) -> RefCacheKey:
147
+ """The ONE place a launcher turns facts into a `RefCacheKey`.
148
+
149
+ Both scripts/precompute_ref_logprobs.py (writer) and scripts/dpo.py
150
+ (reader) call this, so the key they compare cannot drift apart again. The
151
+ drift it replaces: each script assembled the key inline and BOTH filled
152
+ `checkpoint_hash` from the tensor-inventory hash (2026-10-12 review), so
153
+ two different-weight SFT checkpoints produced the same key. Here
154
+ `checkpoint_hash` is the WEIGHT-BYTES hash (`checkpoint_content_hash`; one
155
+ streamed read of the checkpoint, ~14.5GB at 7B) and the inventory hash
156
+ rides along as `checkpoint_inventory_hash`.
157
+
158
+ `manifest_hash` covers the shard rebuild nothing else in the key notices;
159
+ `forward_impl` is a record of how the pass ran and is the caller's to state.
160
+ """
161
+ from ..data.receipts import content_hash
162
+
163
+ return RefCacheKey(
164
+ checkpoint_hash=checkpoint_content_hash(sft_checkpoint),
165
+ tokenizer_hash=manifest["tokenizer_hash"],
166
+ manifest_hash=content_hash(
167
+ [manifest["hashes"][k] for k in sorted(manifest.get("hashes", {}))]
168
+ ),
169
+ max_length=int(manifest.get("pinned", {}).get("max_len", 2048)),
170
+ truncation_mode="right",
171
+ forward_impl=forward_impl,
172
+ checkpoint_inventory_hash=checkpoint_inventory_hash(sft_checkpoint),
173
+ )
174
+
175
+
176
+ def peek_ref_cache_key(path: str) -> Optional[RefCacheKey]:
177
+ """The key stored in a cache file's header, or None when there is no file.
178
+
179
+ INSPECTION ONLY -- it validates nothing about the cache. It exists so a
180
+ launcher can read the one component that is a RECORD rather than an
181
+ expectation (`forward_impl`: how the cache's own pass ran) from the cache
182
+ itself, build the expected key around it, and then `load_ref_cache(path,
183
+ expect=...)`, which asserts the other components. Never a substitute for
184
+ that load.
185
+ """
186
+ from safetensors import safe_open
187
+
188
+ if not os.path.isfile(path):
189
+ return None
190
+ with safe_open(path, framework="pt") as f:
191
+ meta = f.metadata() or {}
192
+ if "key" not in meta:
193
+ return None
194
+ return RefCacheKey.from_json(meta["key"])
195
+
196
+
197
+ #: What `cache_forward_impl` reads when the report is written before any cache
198
+ #: exists. Not a member of FORWARD_IMPLS on purpose: it can never equal a real
199
+ #: cache's tag, so nothing can mistake "no cache yet" for a measured pass.
200
+ NO_CACHE_YET = "no_cache_at_report_time"
201
+
202
+
203
+ def path_bias_report_cache_forward_impl(cache_path: str) -> str:
204
+ """The `forward_impl` a path-bias report should record as `cache_forward_impl`.
205
+
206
+ The report's field is about the CACHE the training run will consume; the
207
+ `--path-bias` run measures it but does not recompute it. So the value is
208
+ the on-disk train cache's own tag, or `NO_CACHE_YET` when the measurement
209
+ was taken first. The launcher does not read this field back for its
210
+ expectation (it reads the cache), so this is a record, not a lever.
211
+ """
212
+ key = peek_ref_cache_key(cache_path)
213
+ return key.forward_impl if key is not None else NO_CACHE_YET
214
+
215
+
216
+ def _checkpoint_files(path: str) -> list[str]:
217
+ """The weight files of a checkpoint, in the order they are hashed.
218
+
219
+ A `.safetensors` file, a directory holding `model.safetensors`, or a
220
+ sharded directory (index + shards, sorted by name).
221
+ """
222
+ from ..model.checkpoint import INDEX_NAME, WEIGHTS_NAME
223
+
224
+ if os.path.isfile(path):
225
+ files = [path]
226
+ else:
227
+ single = os.path.join(path, WEIGHTS_NAME)
228
+ index = os.path.join(path, INDEX_NAME)
229
+ if os.path.isfile(single):
230
+ files = [single]
231
+ elif os.path.isfile(index):
232
+ with open(index) as f:
233
+ shards = sorted(set(json.load(f)["weight_map"].values()))
234
+ files = [os.path.join(path, s) for s in shards]
235
+ missing = [s for s in files if not os.path.isfile(s)]
236
+ if missing:
237
+ raise FileNotFoundError(f"{INDEX_NAME} names shard(s) absent from {path!r}: {missing}")
238
+ else:
239
+ raise FileNotFoundError(f"no {WEIGHTS_NAME} or {INDEX_NAME} at {path!r}")
240
+ return files
241
+
242
+
243
+ def checkpoint_inventory_hash(path: str) -> str:
244
+ """Hash of the tensor INVENTORY (name, shape, dtype) read from the safetensors
245
+ HEADERS only -- no tensor data is materialised.
246
+
247
+ This is the DIAGNOSTIC companion to `checkpoint_content_hash`, not a
248
+ substitute: it is exactly the kind of hash that cannot distinguish two
249
+ checkpoints of one architecture, which is its use -- when `checkpoint_hash`
250
+ mismatches and this one does not, the cache is from the same architecture
251
+ with different weights (the stale-SFT-attempt case). Deliberately NOT
252
+ `checkpoint_fingerprint()["manifest_sha256"]`: that routes through
253
+ `load_hf_safetensors`, which reads the whole 14.5GB into RAM on every rank
254
+ just to print shapes -- four ranks of it at gate 3 on a no-swap box.
255
+ """
256
+ from safetensors import safe_open
257
+
258
+ inventory = {}
259
+ for fp in _checkpoint_files(path):
260
+ with safe_open(fp, framework="pt") as f:
261
+ for k in f.keys():
262
+ sl = f.get_slice(k)
263
+ inventory[k] = [list(sl.get_shape()), str(sl.get_dtype())]
264
+ return hashlib.sha256(json.dumps(inventory, sort_keys=True).encode()).hexdigest()[:16]
265
+
266
+
267
+ def checkpoint_content_hash(path: str) -> str:
268
+ """Hash of the checkpoint's WEIGHT BYTES: the value `RefCacheKey.checkpoint_hash` must carry.
269
+
270
+ WHY THIS EXISTS (review of 2026-10-12). The launchers used to fill
271
+ `checkpoint_hash` from `checkpoint_fingerprint(path)["manifest_sha256"]`,
272
+ which hashes only the tensor inventory (name, shape, dtype). Every full-FT
273
+ SFT checkpoint of one architecture has the SAME inventory, so a cache from
274
+ a previous SFT attempt, or from micro_00000100 when the gate passed
275
+ micro_00000200, produced an identical key and loaded without complaint --
276
+ the exact stale-cache failure this key is documented to defend against
277
+ ("a cache from the previous SFT run"). The receipt module says so itself
278
+ (`weights_sha256` is the content hash; `manifest_sha256` is a structure
279
+ hash), and `test_fingerprint_does_not_call_a_structure_hash_a_content_hash`
280
+ pins it. Reading ~14.5GB once per launch is the price of the key meaning
281
+ what it says; it is streamed in 64MB blocks and never held in memory.
282
+
283
+ Accepts a `.safetensors` file, a directory holding `model.safetensors`, or
284
+ a sharded directory (index + shards). Shard names are hashed together with
285
+ their bytes, in sorted order, so a renamed or reordered shard set changes
286
+ the hash too.
287
+ """
288
+ files = _checkpoint_files(path)
289
+ h = hashlib.sha256()
290
+ for fp in files:
291
+ h.update(os.path.basename(fp).encode() + b"\0")
292
+ with open(fp, "rb") as fh:
293
+ for block in iter(lambda: fh.read(64 << 20), b""):
294
+ h.update(block)
295
+ return h.hexdigest()[:16]
296
+
297
+
298
+ def _atomic_write(path: str, write) -> None:
299
+ """Run `write(tmp_path)` then `os.replace` onto `path`.
300
+
301
+ A cache or report written straight to its final name and killed mid-write
302
+ (the host has seized mid-checkpoint before) leaves a truncated file at the
303
+ name the next launch trusts, and REPLACES a previously good one. The temp
304
+ file sits beside the target (same filesystem, on /apps) so the rename is
305
+ atomic, and is removed if the write raises.
306
+ """
307
+ tmp = f"{path}.tmp.{os.getpid()}"
308
+ try:
309
+ write(tmp)
310
+ os.replace(tmp, path)
311
+ except BaseException:
312
+ if os.path.exists(tmp):
313
+ os.remove(tmp)
314
+ raise
315
+
316
+
317
+ # ---------------------------------------------------------------------------
318
+ # I/O
319
+ # ---------------------------------------------------------------------------
320
+
321
+
322
+ def write_ref_cache(path: str, cache: RefCache) -> str:
323
+ """One safetensors file per (arm, split): tensors for the values, the key
324
+ and the pair-id order in the metadata.
325
+
326
+ One file, not a tensor file plus a JSON sidecar, because a cache split
327
+ across two files can half-exist -- and a half-existing cache is the
328
+ resumable-looking partial checkpoint problem in another costume.
329
+ """
330
+ from safetensors.torch import save_file
331
+
332
+ from ..utils.guard import assert_paths_off_root
333
+
334
+ assert_paths_off_root([os.path.dirname(os.path.abspath(path))])
335
+ pair_ids = list(cache.entries)
336
+ cols = list(zip(*(cache.entries[p] for p in pair_ids))) if pair_ids else [(), (), (), ()]
337
+ # float64 throughout: the values arrive as fp32 (the log-softmax is upcast
338
+ # even in a bf16 forward) and float32 -> float64 is exact, so the stored
339
+ # number IS the computed number. Storing them as fp32 would be equally
340
+ # exact today and would silently truncate if the compute dtype ever rose.
341
+ tensors = {
342
+ name: torch.tensor(col, dtype=torch.float64) for name, col in zip(ENTRY_FIELDS, cols)
343
+ }
344
+ from ..data.loader import PREFERENCE_MASK_VERSION
345
+
346
+ _atomic_write(
347
+ path,
348
+ lambda tmp: save_file(
349
+ tensors,
350
+ tmp,
351
+ metadata={
352
+ "key": cache.key.to_json(),
353
+ "pair_ids": json.dumps(pair_ids),
354
+ "mask_version": PREFERENCE_MASK_VERSION,
355
+ },
356
+ ),
357
+ )
358
+ logger.info("ref cache written: %s (%d pairs)", path, len(pair_ids))
359
+ return path
360
+
361
+
362
+ def load_ref_cache(path: str, expect: Optional[RefCacheKey] = None) -> RefCache:
363
+ """Load a cache and, when `expect` is given, ASSERT the key.
364
+
365
+ Loading without `expect` is for inspection tools only. Every training path
366
+ passes it; that assertion is the entire defence against a stale cache.
367
+ """
368
+ from safetensors import safe_open
369
+
370
+ if not os.path.isfile(path):
371
+ raise FileNotFoundError(
372
+ f"reference cache {path!r} does not exist. Run "
373
+ "scripts/precompute_ref_logprobs.py for this arm first; DPO "
374
+ "cannot proceed without it and will not invent one."
375
+ )
376
+ with safe_open(path, framework="pt") as f:
377
+ meta = f.metadata() or {}
378
+ cols = {name: f.get_tensor(name) for name in ENTRY_FIELDS}
379
+ if "key" not in meta or "pair_ids" not in meta:
380
+ raise RuntimeError(
381
+ f"{path!r} carries no cache key. An unkeyed cache cannot be shown "
382
+ "to belong to this checkpoint, tokenizer and shard build, so it is "
383
+ "refused rather than trusted."
384
+ )
385
+ from ..data.loader import PREFERENCE_MASK_VERSION
386
+
387
+ cache_mask = meta.get("mask_version", "prompt_len_v1")
388
+ if cache_mask != PREFERENCE_MASK_VERSION:
389
+ raise RuntimeError(
390
+ f"{path!r} was precomputed under completion-mask semantics "
391
+ f"{cache_mask!r} but this build uses {PREFERENCE_MASK_VERSION!r} "
392
+ "(audit R1, 2026-08-23: the boundary moved from prompt_len to "
393
+ "shared_prefix_len). Reference logprobs are mask-dependent; "
394
+ "recompute the cache rather than mixing semantics."
395
+ )
396
+ key = RefCacheKey.from_json(meta["key"])
397
+ if expect is not None:
398
+ bad = key.mismatches(expect)
399
+ if bad:
400
+ detail = "; ".join(
401
+ f"{n} (cache={getattr(key, n)!r}, run={getattr(expect, n)!r})" for n in bad
402
+ )
403
+ legacy = ""
404
+ if "checkpoint_hash" in bad and not key.checkpoint_inventory_hash:
405
+ legacy = (
406
+ " This cache carries no checkpoint_inventory_hash: it predates "
407
+ "the 2026-10-12 content-hash key, whose checkpoint_hash was a "
408
+ "tensor-inventory hash that cannot identify a checkpoint. It "
409
+ "must be re-precomputed."
410
+ )
411
+ raise RuntimeError(
412
+ f"reference cache {path!r} does not match this run. "
413
+ f"Mismatched: {detail}.{legacy} A stale reference cache does not fail "
414
+ "loudly at train time -- the loss stays finite, the curve "
415
+ "stays healthy, and the margin is measured against a policy "
416
+ "that never existed. Re-run the precompute."
417
+ )
418
+ pair_ids = json.loads(meta["pair_ids"])
419
+ entries = {p: tuple(float(cols[n][i]) for n in ENTRY_FIELDS) for i, p in enumerate(pair_ids)}
420
+ return RefCache(key=key, entries=entries)
421
+
422
+
423
+ # ---------------------------------------------------------------------------
424
+ # Precompute
425
+ # ---------------------------------------------------------------------------
426
+
427
+
428
+ def _labels(
429
+ input_ids: torch.Tensor, completion_mask: torch.Tensor, ignore_index: int
430
+ ) -> torch.Tensor:
431
+ """Completion-only labels: prompt AND pad positions ignored.
432
+
433
+ Excluding the prompt is not cosmetic. For Arm B the entire shared prefix
434
+ is identical across the two sides, so including it adds the same term to
435
+ both logprobs: it cancels in the DPO difference while inflating both
436
+ magnitudes, and any length-normalised variant computed from it is wrong.
437
+ """
438
+ return input_ids.masked_fill(completion_mask == 0, ignore_index)
439
+
440
+
441
+ @torch.no_grad()
442
+ def _side_logprobs(
443
+ model, input_ids: torch.Tensor, completion_mask: torch.Tensor, ignore_index: int
444
+ ) -> tuple[torch.Tensor, torch.Tensor]:
445
+ """Summed completion logprobs via the SAME routine `fused_dpo` uses.
446
+
447
+ Not a reimplementation: `completion_logprobs_eager` is imported from the
448
+ kernel module, and the caller-side shift here (`logits[:, :-1]` against
449
+ `labels[:, 1:]`) is the shift the DPO adapter applies at train time. If
450
+ precompute and consumption disagreed by one position, DPO would start from
451
+ a nonzero margin against itself and spend the run optimising a constant
452
+ offset it can never remove.
453
+ """
454
+ from ..kernels.fused_dpo import completion_logprobs_eager
455
+
456
+ labels = _labels(input_ids, completion_mask, ignore_index)
457
+ logits = model(input_ids=input_ids)
458
+ return completion_logprobs_eager(logits[:, :-1], labels[:, 1:], ignore_index)
459
+
460
+
461
+ def precompute_ref_logprobs(
462
+ model,
463
+ rows: Sequence[Any],
464
+ *,
465
+ key: RefCacheKey,
466
+ pad_token_id: int,
467
+ batch_size: int = 8,
468
+ device: Optional[torch.device] = None,
469
+ ) -> RefCache:
470
+ """One no-grad pass over `rows`, returning a keyed cache.
471
+
472
+ `rows` are in the shape `collate_preference` consumes (section-03's
473
+ `preference_row_from_store` produces them); every row must carry a
474
+ `pair_id`, because pair_id is how refs find their way back to the right
475
+ training example after any reshuffle.
476
+
477
+ NOTE on batch size -- why it is NOT a key component, MEASURED not assumed.
478
+ Right-padding plus causal masking makes padded columns unreachable, so the
479
+ value is mathematically independent of how rows are grouped; the fp32
480
+ reduction ORDER is not. Measured on the tiny CPU harness (40 pairs, batch
481
+ size 1 vs 2/4/8/20/40): max |delta logp| 3.8e-06 = 9.7e-08 relative, max
482
+ |delta reference MARGIN| 5.7e-06, mean delta ~1e-07. Symmetric rounding
483
+ noise, not a systematic offset -- which is the distinction that decides
484
+ it, since only a systematic offset biases the loss argument. Run through
485
+ this module's own `path_bias_decision` the ratio is ~1e-05 against a 0.05
486
+ threshold, so the same rule that governs single-GPU-vs-sharded says
487
+ negligible here. Bitwise determinism holds at a FIXED batch size, which is
488
+ the property the caching design actually needs, and
489
+ `test_batch_size_variation_is_below_the_path_bias_threshold` keeps this
490
+ paragraph honest.
491
+ """
492
+ from ..data.loader import collate_preference
493
+ from ..kernels.fused_dpo import IGNORE_INDEX
494
+
495
+ if getattr(model, "training", False):
496
+ raise RuntimeError(
497
+ "the reference model is in train mode. Mistral-v0.3 sets "
498
+ "attention_dropout 0.0 so this is PROBABLY harmless -- which is "
499
+ "exactly why nobody would notice, and why 'probably' is not a "
500
+ "basis for numbers that get cached and consumed as constants for "
501
+ "the rest of the stage. Call model.eval()."
502
+ )
503
+ if batch_size < 1:
504
+ raise ValueError(f"batch_size={batch_size} must be >= 1")
505
+
506
+ rows = list(rows)
507
+ entries: dict[str, tuple[float, float, float, float]] = {}
508
+ for start in range(0, len(rows), batch_size):
509
+ chunk = rows[start : start + batch_size]
510
+ batch = collate_preference(chunk, pad_token_id=pad_token_id)
511
+ if device is not None:
512
+ batch = batch.to(device)
513
+ lp_c, len_c = _side_logprobs(
514
+ model, batch.chosen_input_ids, batch.chosen_completion_mask, IGNORE_INDEX
515
+ )
516
+ lp_r, len_r = _side_logprobs(
517
+ model, batch.rejected_input_ids, batch.rejected_completion_mask, IGNORE_INDEX
518
+ )
519
+ for i, meta in enumerate(batch.metadata):
520
+ pid = meta.get("pair_id")
521
+ if pid is None:
522
+ raise ValueError(
523
+ "a preference row carries no pair_id. Refs are joined to "
524
+ "training rows BY pair_id; a row without one could only be "
525
+ "joined by position, which mis-pairs under any reshuffle."
526
+ )
527
+ if pid in entries:
528
+ raise ValueError(
529
+ f"duplicate pair_id {pid!r}: the cache is a mapping, so a "
530
+ "duplicate would silently overwrite one pair's reference "
531
+ "with another's."
532
+ )
533
+ entries[pid] = (float(lp_c[i]), float(lp_r[i]), float(len_c[i]), float(len_r[i]))
534
+ logger.info("precomputed %d reference pairs (%s)", len(entries), key.forward_impl)
535
+ return RefCache(key=key, entries=entries)
536
+
537
+
538
+ # ---------------------------------------------------------------------------
539
+ # Joining refs to batches
540
+ # ---------------------------------------------------------------------------
541
+
542
+
543
+ def attach_refs(batch, cache: RefCache):
544
+ """Fill a `Preference` batch's ref tensors FROM THE CACHE, by pair_id.
545
+
546
+ By pair_id and never by position. The section-03 loader is length-grouped
547
+ and shuffled per epoch, so batch order is not cache order and never will
548
+ be; a positional join would attach a perfectly plausible reference to the
549
+ wrong row, produce a finite loss, and point the gradient somewhere
550
+ arbitrary. Nothing downstream can see it.
551
+ """
552
+ pair_ids = []
553
+ for i, meta in enumerate(batch.metadata):
554
+ pid = (meta or {}).get("pair_id")
555
+ if pid is None:
556
+ raise ValueError(
557
+ f"row {i} of this batch carries no pair_id, so its reference "
558
+ "logprobs cannot be looked up. Falling back to batch order is "
559
+ "the silent-substitution incident this join exists to prevent."
560
+ )
561
+ pair_ids.append(pid)
562
+
563
+ vals = [cache.require(p) for p in pair_ids]
564
+ dtype = torch.float32
565
+ batch.ref_chosen_logps = torch.tensor([v[0] for v in vals], dtype=dtype)
566
+ batch.ref_rejected_logps = torch.tensor([v[1] for v in vals], dtype=dtype)
567
+ return batch
568
+
569
+
570
+ # ---------------------------------------------------------------------------
571
+ # Held-out preference accuracy
572
+ # ---------------------------------------------------------------------------
573
+
574
+
575
+ @torch.no_grad()
576
+ def heldout_preference_accuracy(
577
+ model, batches: Iterable[Any], cache: RefCache, beta: float, z: float = 1.96
578
+ ) -> dict:
579
+ """Fraction of held-out pairs the policy's IMPLICIT reward prefers.
580
+
581
+ The implicit reward difference is beta * [(logp_c - logp_r) - (ref_c -
582
+ ref_r)]; the pair counts as correct when it is strictly positive. This is
583
+ the stage's primary health metric together with the margin, and it is
584
+ deliberately NOT benchmark accuracy: the pre-registered expectations for
585
+ both arms are about preference fit, and section-13 owns the paired
586
+ benchmark evaluation.
587
+
588
+ Empty or degenerate held-out sets report NaN, never 0. Note the
589
+ distinction the test pins: at initialisation the policy IS the reference,
590
+ every implicit margin is exactly 0.0, and nothing is STRICTLY preferred --
591
+ accuracy 0.0 over a real denominator. That is an honest measurement, and
592
+ it is a different fact from NaN over a denominator of zero.
593
+ """
594
+ from ..eval.metrics import wilson_interval
595
+ from ..kernels.fused_dpo import IGNORE_INDEX
596
+
597
+ was_training = getattr(model, "training", False)
598
+ model.eval()
599
+ n = 0
600
+ correct = 0
601
+ margin_sum = 0.0
602
+ try:
603
+ for batch in batches:
604
+ if int(batch.chosen_input_ids.shape[0]) == 0:
605
+ continue
606
+ lp_c, _ = _side_logprobs(
607
+ model, batch.chosen_input_ids, batch.chosen_completion_mask, IGNORE_INDEX
608
+ )
609
+ lp_r, _ = _side_logprobs(
610
+ model, batch.rejected_input_ids, batch.rejected_completion_mask, IGNORE_INDEX
611
+ )
612
+ for i, meta in enumerate(batch.metadata):
613
+ pid = (meta or {}).get("pair_id")
614
+ if pid is None:
615
+ raise ValueError("held-out row carries no pair_id")
616
+ ref_c, ref_r, _, _ = cache.require(pid)
617
+ m = beta * ((float(lp_c[i]) - float(lp_r[i])) - (ref_c - ref_r))
618
+ margin_sum += m
619
+ correct += int(m > 0.0)
620
+ n += 1
621
+ finally:
622
+ if was_training:
623
+ model.train()
624
+
625
+ if n == 0:
626
+ return {
627
+ "accuracy": NAN,
628
+ "ci_low": NAN,
629
+ "ci_high": NAN,
630
+ "mean_margin": NAN,
631
+ "n": 0,
632
+ "correct": 0,
633
+ "beta": beta,
634
+ }
635
+ lo, hi = wilson_interval(correct, n, z)
636
+ return {
637
+ "accuracy": correct / n,
638
+ "ci_low": lo,
639
+ "ci_high": hi,
640
+ "mean_margin": margin_sum / n,
641
+ "n": n,
642
+ "correct": correct,
643
+ "beta": beta,
644
+ }
645
+
646
+
647
+ # ---------------------------------------------------------------------------
648
+ # Path bias
649
+ # ---------------------------------------------------------------------------
650
+
651
+
652
+ def path_bias_decision(
653
+ deltas: Sequence[float], *, beta: float, early_margin_scale: float, threshold: float
654
+ ) -> dict:
655
+ """Decide whether a single-GPU reference cache is usable under FSDP2.
656
+
657
+ `deltas` are per-pair differences of the REFERENCE MARGIN (ref_c - ref_r)
658
+ between the single-GPU forward and the sharded forward. The two are not
659
+ bitwise identical -- different reduction trees, different collective
660
+ orders -- and the question is whether the difference matters.
661
+
662
+ It matters more here than anywhere else in the project. DPO's loss
663
+ argument is beta * (pi_margin - ref_margin). Early in the run the policy
664
+ IS the reference, so that argument sits at zero, which is where the
665
+ log-sigmoid's gradient is largest and where a systematic offset in
666
+ ref_margin is least attenuated. A bias that would be invisible against a
667
+ large margin is fully visible against a zero one.
668
+
669
+ The rule compares max|delta| to the early-margin scale. Beta multiplies
670
+ the perturbation and the reference scale alike, so it CANCELS out of the
671
+ ratio; it stays in the signature because the receipt must record the beta
672
+ the decision was made under, and because a reader who does not see the
673
+ cancellation stated will assume it was forgotten.
674
+
675
+ NaN is not small. A NaN delta means a pair could not be compared, and an
676
+ uncomparable pair forces the firing path rather than comparing False and
677
+ vanishing. So does an empty measurement: not measuring is not passing.
678
+ """
679
+ if not (early_margin_scale > 0) or math.isnan(early_margin_scale):
680
+ raise ValueError(
681
+ f"early_margin_scale={early_margin_scale!r} must be a positive "
682
+ "measured scale; it is the denominator of the decision ratio and "
683
+ "a zero or NaN one makes the decision undefined rather than lenient"
684
+ )
685
+ if not (threshold > 0):
686
+ raise ValueError(f"threshold={threshold!r} must be positive")
687
+
688
+ vals = [float(d) for d in deltas]
689
+ n_nan = sum(1 for d in vals if math.isnan(d))
690
+ if not vals or n_nan:
691
+ detail = (
692
+ "no deltas were measured; not measuring is not passing"
693
+ if not vals
694
+ else f"{n_nan} of {len(vals)} deltas are NaN and a NaN delta is not a small delta"
695
+ )
696
+ return {
697
+ "fired": True,
698
+ "action": "rerun_precompute_sharded",
699
+ "ratio": NAN,
700
+ "max_abs_delta": NAN,
701
+ "mean_delta": NAN,
702
+ "median_abs_delta": NAN,
703
+ "p95_abs_delta": NAN,
704
+ "n": len(vals),
705
+ "n_nan": n_nan,
706
+ "beta": beta,
707
+ "early_margin_scale": early_margin_scale,
708
+ "threshold": threshold,
709
+ "detail": detail,
710
+ }
711
+
712
+ absd = sorted(abs(d) for d in vals)
713
+ max_abs = absd[-1]
714
+ ratio = max_abs / early_margin_scale
715
+ fired = ratio > threshold
716
+ return {
717
+ "fired": fired,
718
+ "action": ("rerun_precompute_sharded" if fired else "keep_single_gpu_cache"),
719
+ "ratio": ratio,
720
+ "max_abs_delta": max_abs,
721
+ "mean_delta": sum(vals) / len(vals),
722
+ "median_abs_delta": _quantile(absd, 0.5),
723
+ "p95_abs_delta": _quantile(absd, 0.95),
724
+ "n": len(vals),
725
+ "n_nan": 0,
726
+ "beta": beta,
727
+ "early_margin_scale": early_margin_scale,
728
+ "threshold": threshold,
729
+ "detail": (
730
+ f"max|delta| {max_abs:.3e} is "
731
+ f"{'above' if fired else 'within'} {threshold:g} x the "
732
+ f"early-margin scale {early_margin_scale:g}"
733
+ ),
734
+ }
735
+
736
+
737
+ def _quantile(sorted_vals: Sequence[float], q: float) -> float:
738
+ if not sorted_vals:
739
+ return NAN
740
+ idx = min(len(sorted_vals) - 1, max(0, int(round(q * (len(sorted_vals) - 1)))))
741
+ return float(sorted_vals[idx])
742
+
743
+
744
+ def batch_invariance_report(
745
+ model,
746
+ rows: Sequence[Any],
747
+ *,
748
+ key: RefCacheKey,
749
+ pad_token_id: int,
750
+ beta: float,
751
+ early_margin_scale: float,
752
+ threshold: float,
753
+ batch_sizes: Sequence[int] = (2, 4, 8, 16),
754
+ ) -> dict:
755
+ """Batched-vs-single-row precompute deltas, as a receipt.
756
+
757
+ A SECOND path-bias question, distinct from the single-GPU-vs-sharded one
758
+ and cheaper to answer: does it matter how many pairs share a forward?
759
+ Right-padding plus causal masking makes the value grouping-independent in
760
+ exact arithmetic; the fp32 reduction order is not, so the honest answer is
761
+ a measurement.
762
+
763
+ It decides one thing: whether `batch_size` belongs in the cache key. It is
764
+ judged by the SAME rule as the sharded comparison (`path_bias_decision`),
765
+ because it is the same kind of error entering the same place -- a
766
+ perturbation of the reference margin, read at the point where the loss
767
+ argument sits near zero.
768
+
769
+ Reported per batch size, plus the worst case across all of them. A
770
+ systematic MEAN delta matters more than a large max: symmetric rounding
771
+ averages out of the gradient, a consistent offset does not.
772
+ """
773
+ single = precompute_ref_logprobs(model, rows, key=key, pad_token_id=pad_token_id, batch_size=1)
774
+ per_size = {}
775
+ worst = None
776
+ for bs in batch_sizes:
777
+ got = precompute_ref_logprobs(
778
+ model, rows, key=key, pad_token_id=pad_token_id, batch_size=bs
779
+ )
780
+ deltas = [
781
+ (got.entries[p][0] - got.entries[p][1]) - (single.entries[p][0] - single.entries[p][1])
782
+ for p in single.entries
783
+ ]
784
+ d = path_bias_decision(
785
+ deltas, beta=beta, early_margin_scale=early_margin_scale, threshold=threshold
786
+ )
787
+ per_size[str(bs)] = d
788
+ if worst is None or (not math.isnan(d["ratio"]) and d["ratio"] > worst["ratio"]):
789
+ worst = d
790
+ return {
791
+ "comparison": "batched_vs_single_row",
792
+ "n_examples": len(list(rows)),
793
+ "batch_sizes": list(batch_sizes),
794
+ "per_batch_size": per_size,
795
+ "worst": worst,
796
+ "belongs_in_cache_key": bool(worst and worst["fired"]),
797
+ "detail": (
798
+ "if belongs_in_cache_key is true, batch_size must join the "
799
+ "six key components; measured false means regrouping is "
800
+ "below the threshold that governs the sharded comparison"
801
+ ),
802
+ }
803
+
804
+
805
+ def write_path_bias_report(path: str, decision: dict, *, n_examples: int, forward_impl: str) -> str:
806
+ """The decision is written, not remembered: precompute and training are
807
+ separate heavy jobs (serialized by the flock, never concurrent) in
808
+ different processes, and the banner reads this file."""
809
+ from ..utils.guard import assert_paths_off_root
810
+
811
+ assert_paths_off_root([os.path.dirname(os.path.abspath(path))])
812
+ payload = dict(decision)
813
+ payload["n_examples"] = int(n_examples)
814
+ payload["cache_forward_impl"] = forward_impl
815
+ def _dump(tmp: str) -> None:
816
+ with open(tmp, "w") as f:
817
+ json.dump(payload, f, indent=2)
818
+
819
+ _atomic_write(path, _dump)
820
+ logger.info("path-bias report -> %s (fired=%s)", path, decision["fired"])
821
+ return path
822
+
823
+
824
+ def read_path_bias_report(path: str) -> dict:
825
+ if not os.path.isfile(path):
826
+ raise FileNotFoundError(
827
+ f"path-bias report {path!r} is missing. The measurement is "
828
+ "MANDATORY before a DPO launch: a sharded forward that disagrees "
829
+ "with the cached single-GPU reference biases the loss argument "
830
+ "exactly where it is most sensitive. Run "
831
+ "scripts/precompute_ref_logprobs.py --path-bias."
832
+ )
833
+ with open(path) as f:
834
+ return json.load(f)
code/align/train/sft_loss.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SFT loss adapter: collated batch -> section-05's chunked fused CE.
2
+
3
+ LOSS NORMALISATION (G3 audit U03-2). DEFAULT UNCHANGED; the global token mean is OPT-IN via
4
+ `train.loss_normalization = "global_token_mean"` (Trainer window look-ahead +
5
+ `TrainModule.train_batch(window_tokens=...)`; this adapter supplies `count_tokens`).
6
+ The paragraph below describes the default, "micro_mean".
7
+ The returned loss is the TOKEN MEAN OF ONE MICRO-BATCH, and `n_tokens` is only
8
+ reported. Under grad-accum A and world W the window gradient is therefore the
9
+ mean of A*W micro-batch means -- not the gradient of the global per-token mean
10
+ (sum CE / sum n_tokens): a target token carries weight 1/(A*W*n_micro), so with
11
+ token-budget batches whose target counts differ (280 vs 6) the short batch's
12
+ tokens weigh ~47x more. The claim "matches one large batch" holds only for
13
+ equal-n micro-batches. Making it a global token mean needs the window's token
14
+ total (a look-ahead over the window's batches plus a cross-rank reduce) and the
15
+ adapter returning CE_sum; that changes the objective every recorded SFT run
16
+ was trained under, so it is a protocol decision, not a bug fix made here.
17
+
18
+ The adapter owns plumbing only -- label shifting, masking, token accounting --
19
+ and section-05 owns the kernel. Keeping the shift on THIS side (caller-shifted
20
+ views) is the arbitrated contract: `fused_linear_cross_entropy` takes hidden
21
+ states and labels that already line up, so there is exactly one place in the
22
+ repo that decides what predicts what.
23
+
24
+ Why the fused path matters, so the reason survives the handoff: at seq 1024 and
25
+ micro-batch 8 the `[B*T, 32768]` logit tensor is 1.0GB in bf16 and its gradient
26
+ is another 1.0GB. Materialising both spends 2GB of a 23GB card on a tensor
27
+ nobody reads. The fused loss consumes HIDDEN STATES and applies the LM head per
28
+ chunk, which is why `NmoMistralForCausalLM.forward_hidden_states` exists as a
29
+ separate entry point.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import logging
35
+ from typing import Any, Optional
36
+
37
+ import torch
38
+ import torch.nn.functional as F
39
+
40
+ from .trainer import LossOutput
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+ #: Section-05's kernel module. Named here so the both-directions test can block
45
+ #: the import and exercise the refusal branch.
46
+ FUSED_CE_MODULE = "align.kernels.fused_ce"
47
+
48
+ #: Fallback only. The authority is section-05's constant (imported below when
49
+ #: available) and section-03's collator, which both use -100; this literal
50
+ #: exists so the module still loads if the kernel is missing.
51
+ IGNORE_INDEX = -100
52
+
53
+
54
+ def _load_fused():
55
+ """Return (fused_fn, ignore_index) or (None, IGNORE_INDEX)."""
56
+ try:
57
+ import importlib
58
+
59
+ mod = importlib.import_module(FUSED_CE_MODULE)
60
+ return mod.fused_linear_cross_entropy, mod.IGNORE_INDEX
61
+ except Exception: # noqa: BLE001
62
+ return None, IGNORE_INDEX
63
+
64
+
65
+ def fused_ce_available() -> bool:
66
+ return _load_fused()[0] is not None
67
+
68
+
69
+ def make_sft_loss_adapter(chunk_size: Optional[int] = None, require_fused: bool = False):
70
+ """Build the SFT loss adapter the Trainer calls once per micro-step.
71
+
72
+ `require_fused=True` refuses to run on the reference path -- which is what
73
+ the 7B stage scripts pass, so a GPU run cannot quietly spend 2GB of card on
74
+ a materialised logit tensor because section-05's import broke.
75
+ """
76
+ fused_fn, ignore_index = _load_fused()
77
+ if require_fused and fused_fn is None:
78
+ raise RuntimeError(
79
+ f"the fused chunked cross-entropy ({FUSED_CE_MODULE}."
80
+ "fused_linear_cross_entropy) is not importable. Refusing to fall "
81
+ "back to the reference path for a full-scale run: it materialises "
82
+ "the [B*T, 32768] logit tensor AND its gradient, ~2GB of a 23GB "
83
+ "card at seq 1024."
84
+ )
85
+ if fused_fn is None:
86
+ logger.warning(
87
+ "%s unavailable; using the reference path. Correct, and "
88
+ "memory-hungry -- small-model use only.",
89
+ FUSED_CE_MODULE,
90
+ )
91
+
92
+ kwargs = {} if chunk_size is None else {"chunk_size": chunk_size}
93
+
94
+ def adapter(model, batch) -> LossOutput:
95
+ input_ids, labels = _unpack(batch)
96
+ # Caller-shifted: position t predicts token t+1. Done once, here.
97
+ target = labels[:, 1:]
98
+ n_tokens = int((target != ignore_index).sum())
99
+ if n_tokens == 0:
100
+ raise ValueError(
101
+ "SFT batch has no unmasked target tokens: every position is "
102
+ f"ignore_index ({ignore_index}). The mean cross-entropy over "
103
+ "zero positions is undefined, and returning 0.0 would report a "
104
+ "perfectly fit batch. Check prompt_len against sequence length "
105
+ "in the collator."
106
+ )
107
+
108
+ if fused_fn is not None:
109
+ # The loss runs INSIDE the model's forward via `hidden_consumer`,
110
+ # so section-05 still owns the lm_head matmul while FSDP2 has both
111
+ # the hidden states and lm_head.weight gathered. Computing it
112
+ # after the forward returned would see `lm_head.weight` re-sharded
113
+ # -- the mixed-DTensor failure that killed the first 4-rank smoke.
114
+ def _consume(hidden, lm_head_weight):
115
+ return fused_fn(
116
+ hidden[:, :-1], lm_head_weight, target, ignore_index=ignore_index, **kwargs
117
+ )
118
+
119
+ loss = model(input_ids=input_ids, hidden_consumer=_consume)
120
+ else:
121
+ logits = model(input_ids=input_ids)
122
+ loss = F.cross_entropy(
123
+ logits[:, :-1].reshape(-1, logits.shape[-1]).float(),
124
+ target.reshape(-1),
125
+ ignore_index=ignore_index,
126
+ )
127
+
128
+ return LossOutput(loss=loss, n_tokens=n_tokens, metrics={"tokens_in_loss": float(n_tokens)})
129
+
130
+ def count_tokens(batch) -> int:
131
+ """Unmasked target tokens of `batch` (what `n_tokens` will be), without a forward pass.
132
+ The Trainer sums this over a look-ahead window for the global token mean."""
133
+ _, labels = _unpack(batch)
134
+ return int((labels[:, 1:] != ignore_index).sum())
135
+
136
+ adapter.uses_fused = fused_fn is not None
137
+ adapter.count_tokens = count_tokens
138
+ return adapter
139
+
140
+
141
+ def _unpack(batch: Any) -> tuple[torch.Tensor, torch.Tensor]:
142
+ """Accept either a `Collated` from section-03 or a plain (ids, labels)."""
143
+ if hasattr(batch, "input_ids") and hasattr(batch, "labels"):
144
+ return batch.input_ids, batch.labels
145
+ if isinstance(batch, (tuple, list)) and len(batch) == 2:
146
+ return batch[0], batch[1]
147
+ raise TypeError(
148
+ f"SFT loss adapter got {type(batch).__name__}; expected a Collated "
149
+ "batch (input_ids, labels) from align.data.loader.collate_sft"
150
+ )
code/align/train/sharded_ckpt.py ADDED
@@ -0,0 +1,1285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sharded streaming checkpoints: every rank writes ONLY the weights it owns.
2
+
3
+ OPT-IN (`train.checkpoint_format = "sharded"`; the default `"gathered"` is
4
+ untouched). Runtime deps: torch + stdlib. The converter half (`export_hf`,
5
+ `reshard`) never touches a GPU and never imports transformers/safetensors.
6
+
7
+ THE PROBLEM, MEASURED (docs/MEASUREMENTS.md, Track M, OLMoE-1B-7B, 4 ranks)
8
+ * save 228-241 s, rank-0 RSS peak +28 GB. `get_model_state_dict(
9
+ full_state_dict=True, cpu_offload=True)` is a full all-gather into rank 0's
10
+ host RAM (1x weights), and the HF converter then builds a second dict of
11
+ cloned expert slices plus `.contiguous()` copies (the other 1x): 2x the
12
+ weights on ONE rank of a no-swap, shared box -- the 2026-08-12 OOM shape.
13
+ Ranks 1..N-1 sit in a barrier for the whole four minutes.
14
+ * resume peaks at 90 GiB SUMMED RSS across ranks: `resume_from` reads the
15
+ whole `model.safetensors` on EVERY rank and then slices out the 1/N it
16
+ owns.
17
+ OLMo-core avoids both with distributed checkpointing (per-rank files, no full
18
+ gather). This is the same idea at the size this repo needs.
19
+
20
+ LAYOUT (a checkpoint directory in sharded mode)
21
+ model_rank00000.safetensors ... one per rank; that rank's LOCAL shards,
22
+ bf16, standard safetensors (any reader
23
+ opens them), written tensor by tensor
24
+ optimizer_rank{r}.pt unchanged: the existing per-rank optimizer
25
+ files (sharded mode implies them)
26
+ shard_manifest.json global shapes, placements, per-rank row
27
+ ranges, world size, per-file bytes and
28
+ sha256, the HF export recipe
29
+ resume.pt unchanged loop state (no weights)
30
+ tokenizer files as in the gathered layout
31
+ The manifest is written last, inside the `.partial` staging directory, and the
32
+ directory is published by the existing fsync-then-rename protocol. A directory
33
+ without a complete manifest and every rank file the manifest names is not a
34
+ checkpoint (`sharded_problem`, called from `trainer.checkpoint_problem`).
35
+
36
+ WHAT EACH PIECE PREVENTS (incident-first, repo style)
37
+
38
+ THE WRONG-SHARD DEFECT. A rank's file holds rows [off, off+n) of each tensor.
39
+ Loaded under a different world size, rank r would receive rows cut for another
40
+ partitioning and training would continue plausibly on scrambled weights --
41
+ nothing crashes, the loss is merely worse. Resume therefore reads the manifest
42
+ FIRST and REFUSES a different world size with a message naming the offline
43
+ reshard (`scripts/ckpt_convert.py reshard`); it never "tries" a mismatch. The
44
+ per-row ranges recorded in the manifest are also compared against the LIVE
45
+ model's own sharding tensor by tensor, so a model rebuilt with a different mesh
46
+ or an uneven-sharding change is caught even at the same world size.
47
+
48
+ THE SILENT-CORRUPTION DEFECT. Per-file sha256 is recorded at save and checked
49
+ by each rank, on its OWN file, before any weight is copied. The verdict is then
50
+ reduced across ranks (`any_rank`): a bit-rotted file on rank 2 alone would
51
+ otherwise make rank 2 raise while ranks 0/1/3 entered the next collective and
52
+ hung until the watchdog -- the lone-rank shape every other decision in the
53
+ trainer avoids. The save side uses the same reduction: a rank whose disk fills
54
+ mid-write must stop its peers at once, not leave them in a gather for the
55
+ watchdog's 30 minutes.
56
+
57
+ THE TWO-COPIES-ON-RANK-0 DEFECT (the measured 28 GB). There is no gather. Each
58
+ rank's peak is ONE tensor (`emit` copies one local shard to host, writes it,
59
+ drops it). The export side is byte-copy `pread` -> `write` in 8 MiB pieces: for
60
+ 1:1 families it never builds a tensor at all.
61
+
62
+ THE PARTIAL-WRITE DEFECT (same as the gathered format). Rank files are written
63
+ to `*.tmp`, fsynced, renamed into the `.partial` staging dir; the staging dir
64
+ is fsync_tree'd and renamed to `micro_N` only after rank 0 has the manifest of
65
+ every rank. A kill anywhere leaves a `.partial` that `_discard_partials`
66
+ removes at the next start, never a `micro_N` that looks resumable.
67
+
68
+ WHAT THIS DOES NOT DO (read before relying on it)
69
+ * It is not loadable by `eval_checkpoint.py` / the rollout weight sync /
70
+ `ref_cache` until exported: those read `model.safetensors`. Export is
71
+ `ckpt_convert.py export` (streaming, bounded memory).
72
+ * Placements: `Shard(0)` and `Replicate()` on a 1-D mesh (what
73
+ `fully_shard` produces). Anything else (HSDP, TP, Shard(d>0)) is refused
74
+ at SAVE time, not discovered at resume.
75
+ * A train module that changes what "the weights" mean (LoRA folds an
76
+ adapter into merged weights; PPO has its own gather) must provide
77
+ `sharded_model_state()` / `load_sharded_model_state()` or the Trainer
78
+ refuses `"sharded"` at construction. Silent fallback to the base-class
79
+ behaviour would checkpoint the UNMERGED base under a merged name.
80
+ """
81
+
82
+ from __future__ import annotations
83
+
84
+ import hashlib
85
+ import json
86
+ import logging
87
+ import os
88
+ import re
89
+ import shutil
90
+ import struct
91
+ from dataclasses import dataclass
92
+ from typing import Any, Callable, Iterable, Mapping, Optional
93
+
94
+ import torch
95
+
96
+ logger = logging.getLogger(__name__)
97
+
98
+ SHARD_MANIFEST_NAME = "shard_manifest.json"
99
+ FORMAT_NAME = "align-sharded-ckpt"
100
+ FORMAT_VERSION = 1
101
+ CHECKPOINT_FORMATS = ("gathered", "sharded")
102
+
103
+ #: Bytes moved per pread/write while exporting or resharding. Peak RSS of the
104
+ #: converter is a small multiple of this plus one optimizer tensor, whatever the
105
+ #: model size.
106
+ COPY_CHUNK = 8 * 1024 * 1024
107
+
108
+ #: Files in a sharded checkpoint directory that are NOT tokenizer/config
109
+ #: payload (the exporter copies everything else next to the weights).
110
+ _OWN_FILE_RE = re.compile(
111
+ r"^(model_rank\d+\.safetensors(\.tmp)?|optimizer_rank\d+\.pt(\.tmp)?|resume\.pt|"
112
+ + r"integrity\.json|"
113
+ + re.escape(SHARD_MANIFEST_NAME)
114
+ + r")$"
115
+ )
116
+
117
+
118
+ class ShardedCheckpointError(RuntimeError):
119
+ """A sharded checkpoint that is malformed, mismatched, or corrupt."""
120
+
121
+
122
+ class CheckpointIntegrityError(ShardedCheckpointError):
123
+ """A payload file's sha256 differs from the one recorded at save time."""
124
+
125
+
126
+ class ShardedWorldSizeError(ShardedCheckpointError):
127
+ """Resume under a world size other than the one that saved the shards."""
128
+
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # dtypes
132
+ # ---------------------------------------------------------------------------
133
+
134
+ _ST_TO_TORCH = {
135
+ "BOOL": torch.bool,
136
+ "U8": torch.uint8,
137
+ "I8": torch.int8,
138
+ "I16": torch.int16,
139
+ "F16": torch.float16,
140
+ "BF16": torch.bfloat16,
141
+ "I32": torch.int32,
142
+ "F32": torch.float32,
143
+ "F64": torch.float64,
144
+ "I64": torch.int64,
145
+ }
146
+ _TORCH_TO_ST = {v: k for k, v in _ST_TO_TORCH.items()}
147
+ #: safetensors serialises tensors sorted by (dtype DESC, name); this is the
148
+ #: dtype order of its enum. Byte-identical output to `save_file` is a tested
149
+ #: property (it is what lets "export equals gathered export" be a file hash).
150
+ _ST_ORDER = ["BOOL", "U8", "I8", "I16", "F16", "BF16", "I32", "F32", "F64", "I64"]
151
+ _ST_ITEMSIZE = {"BOOL": 1, "U8": 1, "I8": 1, "I16": 2, "F16": 2, "BF16": 2, "I32": 4, "F32": 4, "F64": 8, "I64": 8}
152
+
153
+
154
+ def _prod(shape: Iterable[int]) -> int:
155
+ n = 1
156
+ for s in shape:
157
+ n *= int(s)
158
+ return n
159
+
160
+
161
+ def chunk_range(n: int, world_size: int, rank: int) -> tuple[int, int]:
162
+ """(row offset, row count) of `rank`'s `Shard(0)` slice of `n` rows.
163
+
164
+ `torch.chunk` semantics, which is what `DTensor` `Shard(0)` uses: ceil(n/ws)
165
+ rows per rank, the tail ranks shorter or EMPTY (5 rows over 4 ranks is
166
+ 2,2,1,0). Evenly dividing would put row 4 nowhere. One function for save,
167
+ load, reshard and export so they cannot disagree about who owns a row.
168
+ """
169
+ c = -(-int(n) // int(world_size))
170
+ off = min(int(rank) * c, int(n))
171
+ return off, min(c, int(n) - off)
172
+
173
+
174
+ # ---------------------------------------------------------------------------
175
+ # safetensors, streaming (writer) and random-access (reader)
176
+ # ---------------------------------------------------------------------------
177
+
178
+
179
+ @dataclass
180
+ class StEntry:
181
+ """One tensor to be written: `emit(f)` must write exactly `nbytes` bytes."""
182
+
183
+ name: str
184
+ st_dtype: str
185
+ shape: tuple
186
+ emit: Callable[[Any], None]
187
+
188
+ @property
189
+ def nbytes(self) -> int:
190
+ return _prod(self.shape) * _ST_ITEMSIZE[self.st_dtype]
191
+
192
+
193
+ def _st_sort_key(e: StEntry):
194
+ return (-_ST_ORDER.index(e.st_dtype), e.name)
195
+
196
+
197
+ def safetensors_header(entries: list[StEntry], metadata: Optional[Mapping[str, str]] = None) -> tuple[bytes, list[StEntry]]:
198
+ """(8-byte length prefix + JSON header padded to 8 bytes, entries in file order)."""
199
+ ordered = sorted(entries, key=_st_sort_key)
200
+ header: dict[str, Any] = {}
201
+ if metadata:
202
+ header["__metadata__"] = dict(metadata)
203
+ off = 0
204
+ for e in ordered:
205
+ header[e.name] = {"dtype": e.st_dtype, "shape": [int(s) for s in e.shape], "data_offsets": [off, off + e.nbytes]}
206
+ off += e.nbytes
207
+ raw = json.dumps(header, separators=(",", ":")).encode("utf-8")
208
+ raw += b" " * ((-len(raw)) % 8)
209
+ return struct.pack("<Q", len(raw)) + raw, ordered
210
+
211
+
212
+ class _HashingWriter:
213
+ def __init__(self, f):
214
+ self.f = f
215
+ self.sha = hashlib.sha256()
216
+ self.n = 0
217
+
218
+ def write(self, b) -> int:
219
+ self.sha.update(b)
220
+ self.n += len(b)
221
+ return self.f.write(b)
222
+
223
+
224
+ def write_safetensors_streaming(
225
+ path: str, entries: list[StEntry], metadata: Optional[Mapping[str, str]] = None
226
+ ) -> dict:
227
+ """Write `path` tensor by tensor, fsync it, return {"bytes", "sha256"}.
228
+
229
+ Peak memory is whatever ONE `emit` holds. `safetensors.save_file` needs the
230
+ whole dict resident, which is the 2x-weights transient this module exists
231
+ to remove.
232
+ """
233
+ head, ordered = safetensors_header(entries, metadata)
234
+ with open(path, "wb") as raw:
235
+ w = _HashingWriter(raw)
236
+ w.write(head)
237
+ for e in ordered:
238
+ before = w.n
239
+ e.emit(w)
240
+ if w.n - before != e.nbytes:
241
+ raise ShardedCheckpointError(
242
+ f"{e.name}: emitted {w.n - before} bytes, header promised {e.nbytes}"
243
+ )
244
+ raw.flush()
245
+ os.fsync(raw.fileno())
246
+ return {"bytes": w.n, "sha256": w.sha.hexdigest()}
247
+
248
+
249
+ def tensor_bytes_emitter(t: torch.Tensor) -> Callable[[Any], None]:
250
+ def emit(f):
251
+ c = t.detach().contiguous().reshape(-1)
252
+ if c.numel():
253
+ f.write(memoryview(c.view(torch.uint8).numpy()))
254
+
255
+ return emit
256
+
257
+
258
+ class ShardFile:
259
+ """Random-access reader over one safetensors file (header parsed once)."""
260
+
261
+ def __init__(self, path: str):
262
+ self.path = path
263
+ self.fd = os.open(path, os.O_RDONLY)
264
+ try:
265
+ size = os.fstat(self.fd).st_size
266
+ if size < 8:
267
+ raise ShardedCheckpointError(f"{os.path.basename(path)} is {size} bytes (no header)")
268
+ (n,) = struct.unpack("<Q", os.pread(self.fd, 8, 0))
269
+ if n > size - 8 or n > 256 * 2**20:
270
+ raise ShardedCheckpointError(f"{os.path.basename(path)} header claims {n} bytes of {size}")
271
+ header = json.loads(os.pread(self.fd, n, 8))
272
+ self.data_start = 8 + n
273
+ self.tensors: dict[str, tuple[str, tuple, int, int]] = {}
274
+ end = 0
275
+ for k, v in header.items():
276
+ if k == "__metadata__":
277
+ continue
278
+ a, b = v["data_offsets"]
279
+ self.tensors[k] = (v["dtype"], tuple(v["shape"]), int(a), int(b))
280
+ end = max(end, int(b))
281
+ if size < self.data_start + end:
282
+ raise ShardedCheckpointError(
283
+ f"{os.path.basename(path)} truncated: {size} bytes, header needs {self.data_start + end}"
284
+ )
285
+ except BaseException:
286
+ os.close(self.fd)
287
+ raise
288
+
289
+ def close(self) -> None:
290
+ if self.fd >= 0:
291
+ os.close(self.fd)
292
+ self.fd = -1
293
+
294
+ def __enter__(self):
295
+ return self
296
+
297
+ def __exit__(self, *a):
298
+ self.close()
299
+
300
+ def copy_range(self, name: str, lo: int, hi: int, sink) -> None:
301
+ """Stream bytes [lo, hi) of tensor `name` into `sink.write`."""
302
+ _dt, _shape, a, b = self.tensors[name]
303
+ if not (0 <= lo <= hi <= b - a):
304
+ raise ShardedCheckpointError(f"{name}: byte range [{lo},{hi}) outside the {b - a}-byte tensor")
305
+ pos = self.data_start + a + lo
306
+ left = hi - lo
307
+ while left:
308
+ buf = os.pread(self.fd, min(COPY_CHUNK, left), pos)
309
+ if not buf:
310
+ raise ShardedCheckpointError(f"{self.path}: short read at {pos}")
311
+ sink.write(buf)
312
+ pos += len(buf)
313
+ left -= len(buf)
314
+
315
+ def tensor(self, name: str) -> torch.Tensor:
316
+ dt, shape, a, b = self.tensors[name]
317
+ torch_dt = _ST_TO_TORCH[dt]
318
+ if b == a:
319
+ return torch.empty(shape, dtype=torch_dt)
320
+ buf = bytearray(b - a)
321
+ mv = memoryview(buf)
322
+ pos, got = self.data_start + a, 0
323
+ while got < len(buf):
324
+ n = os.preadv(self.fd, [mv[got : got + COPY_CHUNK]], pos + got)
325
+ if n <= 0:
326
+ raise ShardedCheckpointError(f"{self.path}: short read of {name}")
327
+ got += n
328
+ return torch.frombuffer(buf, dtype=torch_dt).reshape(shape)
329
+
330
+
331
+ def file_sha256(path: str) -> str:
332
+ h = hashlib.sha256()
333
+ with open(path, "rb") as f:
334
+ while True:
335
+ b = f.read(COPY_CHUNK)
336
+ if not b:
337
+ break
338
+ h.update(b)
339
+ return h.hexdigest()
340
+
341
+
342
+ # ---------------------------------------------------------------------------
343
+ # payload integrity sidecar (audit U01-3)
344
+ # ---------------------------------------------------------------------------
345
+
346
+ #: `<ckpt>/integrity.json`: {"version": 1, "sha256": {file name: hex}} for the payloads the
347
+ #: weights manifest does not already cover (resume.pt, optimizer_rank*.pt, and the gathered
348
+ #: layout's model.safetensors). A torch.save zip passes the structural check after a same-size
349
+ #: bit flip in a stored tensor, so structure alone cannot catch silent media corruption.
350
+ INTEGRITY_NAME = "integrity.json"
351
+
352
+
353
+ def write_integrity(out_dir: str, sha256: Mapping[str, str]) -> None:
354
+ path = os.path.join(out_dir, INTEGRITY_NAME)
355
+ with open(path, "w", encoding="utf-8") as f:
356
+ f.write(json.dumps({"version": 1, "sha256": dict(sha256)}, indent=1, sort_keys=True) + "\n")
357
+ f.flush()
358
+ os.fsync(f.fileno())
359
+
360
+
361
+ def read_integrity(path: str) -> Optional[dict]:
362
+ """{file name: sha256} or None for a checkpoint that predates the sidecar. A sidecar that
363
+ exists but cannot be parsed is an error, never 'legacy' (fail closed)."""
364
+ fp = os.path.join(path, INTEGRITY_NAME)
365
+ if not os.path.isfile(fp):
366
+ return None
367
+ try:
368
+ with open(fp, encoding="utf-8") as f:
369
+ blob = json.load(f)
370
+ out = {str(k): str(v) for k, v in blob["sha256"].items()}
371
+ except (OSError, ValueError, KeyError, AttributeError, TypeError) as e:
372
+ raise CheckpointIntegrityError(f"{fp} is unreadable ({type(e).__name__}: {e})") from e
373
+ return out
374
+
375
+
376
+ def verify_integrity(path: str, names: Iterable[str], recorded: Optional[dict] = None) -> Optional[str]:
377
+ """None iff every name in `names` that the sidecar records matches its sha256.
378
+
379
+ A name the sidecar does not list is skipped (a legacy or partial sidecar); a listed file that
380
+ is missing or differs is a problem.
381
+ """
382
+ recorded = read_integrity(path) if recorded is None else recorded
383
+ if recorded is None:
384
+ return None
385
+ for n in names:
386
+ want = recorded.get(n)
387
+ if want is None:
388
+ continue
389
+ fp = os.path.join(path, n)
390
+ if not os.path.isfile(fp):
391
+ return f"{n} is missing (integrity.json records it)"
392
+ got = file_sha256(fp)
393
+ if got != want:
394
+ return f"{n} sha256 {got[:12]}.. != recorded {want[:12]}.. (bit rot or a partial copy)"
395
+ return None
396
+
397
+
398
+ # ---------------------------------------------------------------------------
399
+ # describing a live (possibly DTensor) tensor
400
+ # ---------------------------------------------------------------------------
401
+
402
+
403
+ def _local_and_desc(t: torch.Tensor, rank: int, world_size: int) -> tuple[torch.Tensor, dict]:
404
+ """(this rank's local tensor, descriptor) for a plain tensor or a DTensor.
405
+
406
+ Descriptor: global `shape`, `placement` ("shard0" | "replicate"), and for
407
+ shard0 this rank's `row` = [offset, count]. Everything else is refused HERE
408
+ (save time): a layout the loader cannot place must not produce a file.
409
+ """
410
+ if hasattr(t, "to_local") and hasattr(t, "placements"):
411
+ pl = tuple(t.placements)
412
+ mesh = t.device_mesh
413
+ if mesh.ndim != 1 or mesh.size() != world_size:
414
+ raise ShardedCheckpointError(
415
+ f"unsupported mesh {tuple(mesh.shape)} for world_size={world_size}: sharded checkpoints "
416
+ "support a 1-D mesh over all ranks (what fully_shard builds). HSDP/TP meshes are not "
417
+ "implemented; use checkpoint_format='gathered'."
418
+ )
419
+ if mesh.get_local_rank() != rank:
420
+ raise ShardedCheckpointError(f"mesh local rank {mesh.get_local_rank()} != process rank {rank}")
421
+ local = t.to_local()
422
+ shape = tuple(int(s) for s in t.shape)
423
+ from torch.distributed.tensor import Replicate, Shard
424
+
425
+ if len(pl) == 1 and isinstance(pl[0], Shard) and pl[0].dim == 0:
426
+ off, n = chunk_range(shape[0], world_size, rank)
427
+ if tuple(local.shape) != (n,) + shape[1:]:
428
+ raise ShardedCheckpointError(
429
+ f"local shard {tuple(local.shape)} != chunk_range-derived {(n,) + shape[1:]} "
430
+ f"(global {shape}, rank {rank}/{world_size}): uneven/padded layout not understood"
431
+ )
432
+ return local, {"shape": list(shape), "placement": "shard0", "row": [off, n]}
433
+ if len(pl) == 1 and isinstance(pl[0], Replicate):
434
+ return local, {"shape": list(shape), "placement": "replicate", "row": None}
435
+ raise ShardedCheckpointError(f"unsupported placements {pl}: only Shard(0) and Replicate() are implemented")
436
+ return t, {"shape": [int(s) for s in t.shape], "placement": "replicate", "row": None}
437
+
438
+
439
+ def _store_dtype(t: torch.Tensor, store_dtype: torch.dtype) -> str:
440
+ dt = store_dtype if t.is_floating_point() else t.dtype
441
+ try:
442
+ return _TORCH_TO_ST[dt]
443
+ except KeyError as e:
444
+ raise ShardedCheckpointError(f"unsupported tensor dtype {dt}") from e
445
+
446
+
447
+ def rank_file_name(rank: int) -> str:
448
+ return f"model_rank{rank:05d}.safetensors"
449
+
450
+
451
+ # ---------------------------------------------------------------------------
452
+ # save (one rank)
453
+ # ---------------------------------------------------------------------------
454
+
455
+
456
+ def save_rank_shard(
457
+ state: Mapping[str, torch.Tensor],
458
+ out_dir: str,
459
+ *,
460
+ rank: int,
461
+ world_size: int,
462
+ store_dtype: torch.dtype = torch.bfloat16,
463
+ ) -> dict:
464
+ """Write THIS rank's local shards to `out_dir/model_rank{r}.safetensors`.
465
+
466
+ Atomic per file (tmp + fsync + rename). Returns this rank's info for the
467
+ manifest: {"file": {name, bytes, sha256}, "tensors": {name: descriptor}}.
468
+ Peak host memory is one tensor.
469
+ """
470
+ entries: list[StEntry] = []
471
+ tensors: dict[str, dict] = {}
472
+ for name in sorted(state):
473
+ local, desc = _local_and_desc(state[name], rank, world_size)
474
+ st_dt = _store_dtype(local, store_dtype)
475
+ desc["dtype"] = st_dt
476
+ tensors[name] = desc
477
+
478
+ def emit(f, local=local, st_dt=st_dt):
479
+ host = local.detach().to(device="cpu", dtype=_ST_TO_TORCH[st_dt])
480
+ tensor_bytes_emitter(host)(f)
481
+
482
+ entries.append(StEntry(name, st_dt, tuple(local.shape), emit))
483
+ fname = rank_file_name(rank)
484
+ final = os.path.join(out_dir, fname)
485
+ tmp = final + ".tmp"
486
+ try:
487
+ meta = write_safetensors_streaming(tmp, entries, {"format": "pt"})
488
+ os.replace(tmp, final)
489
+ except BaseException:
490
+ for p in (tmp, final):
491
+ try:
492
+ os.remove(p)
493
+ except OSError:
494
+ pass
495
+ raise
496
+ return {"file": {"name": fname, **meta}, "tensors": tensors, "order": list(state)}
497
+
498
+
499
+ def build_manifest(
500
+ infos: list[dict],
501
+ *,
502
+ param_order: Optional[list[str]] = None,
503
+ export: Optional[dict] = None,
504
+ extra: Optional[dict] = None,
505
+ ) -> dict:
506
+ """Merge every rank's info (rank-ordered) into the manifest. Cross-checks
507
+ that all ranks saw the same tensors and that shard0 rows tile [0, n)."""
508
+ ws = len(infos)
509
+ names = sorted(infos[0]["tensors"])
510
+ tensors: dict[str, dict] = {}
511
+ for n in names:
512
+ d0 = infos[0]["tensors"][n]
513
+ rows = []
514
+ for r, info in enumerate(infos):
515
+ if sorted(info["tensors"]) != names:
516
+ raise ShardedCheckpointError(f"rank {r} wrote a different tensor set than rank 0")
517
+ d = info["tensors"][n]
518
+ for k in ("shape", "placement", "dtype"):
519
+ if d[k] != d0[k]:
520
+ raise ShardedCheckpointError(f"{n}: rank {r} disagrees with rank 0 on {k}: {d[k]} vs {d0[k]}")
521
+ rows.append(d["row"])
522
+ entry = {"shape": d0["shape"], "dtype": d0["dtype"], "placement": d0["placement"]}
523
+ if d0["placement"] == "shard0":
524
+ pos = 0
525
+ for off, cnt in rows:
526
+ if off != min(pos, d0["shape"][0]) and cnt:
527
+ raise ShardedCheckpointError(f"{n}: shard rows do not tile ({rows})")
528
+ pos += cnt
529
+ if pos != d0["shape"][0]:
530
+ raise ShardedCheckpointError(f"{n}: shard rows sum to {pos}, global rows {d0['shape'][0]}")
531
+ entry["rows"] = rows
532
+ tensors[n] = entry
533
+ m: dict[str, Any] = {
534
+ "format": FORMAT_NAME,
535
+ "version": FORMAT_VERSION,
536
+ "world_size": ws,
537
+ "files": {str(r): infos[r]["file"] for r in range(ws)},
538
+ "tensors": tensors,
539
+ }
540
+ if infos[0].get("order") is not None:
541
+ # The model's own state-dict order: shard planning (qwen3's 5 GB files)
542
+ # is greedy over insertion order, and the manifest JSON is key-sorted,
543
+ # so without this the exported file split would differ from the
544
+ # gathered writer's.
545
+ m["key_order"] = list(infos[0]["order"])
546
+ if param_order is not None:
547
+ m["param_order"] = list(param_order)
548
+ if export is not None:
549
+ m["export"] = export
550
+ if extra:
551
+ m.update(extra)
552
+ return m
553
+
554
+
555
+ def write_manifest(out_dir: str, manifest: dict) -> None:
556
+ path = os.path.join(out_dir, SHARD_MANIFEST_NAME)
557
+ with open(path, "w", encoding="utf-8") as f:
558
+ f.write(json.dumps(manifest, indent=1, sort_keys=True) + "\n")
559
+ f.flush()
560
+ os.fsync(f.fileno())
561
+
562
+
563
+ # ---------------------------------------------------------------------------
564
+ # read side: manifest, completeness, load
565
+ # ---------------------------------------------------------------------------
566
+
567
+
568
+ def is_sharded(path: str) -> bool:
569
+ return os.path.isfile(os.path.join(path, SHARD_MANIFEST_NAME))
570
+
571
+
572
+ def read_manifest(path: str) -> dict:
573
+ """Parse + structurally validate; raises `ShardedCheckpointError`."""
574
+ mp = os.path.join(path, SHARD_MANIFEST_NAME)
575
+ try:
576
+ with open(mp, encoding="utf-8") as f:
577
+ m = json.load(f)
578
+ except (OSError, ValueError) as e:
579
+ raise ShardedCheckpointError(f"unreadable {SHARD_MANIFEST_NAME} ({e})") from e
580
+ if not isinstance(m, dict) or m.get("format") != FORMAT_NAME:
581
+ raise ShardedCheckpointError(f"{SHARD_MANIFEST_NAME} is not an {FORMAT_NAME} manifest")
582
+ if m.get("version") != FORMAT_VERSION:
583
+ raise ShardedCheckpointError(f"manifest version {m.get('version')} != supported {FORMAT_VERSION}")
584
+ ws = m.get("world_size")
585
+ if not isinstance(ws, int) or ws < 1:
586
+ raise ShardedCheckpointError(f"manifest world_size {ws!r} is not a positive int")
587
+ files, tensors = m.get("files"), m.get("tensors")
588
+ if not isinstance(files, dict) or sorted(files, key=int) != [str(r) for r in range(ws)]:
589
+ raise ShardedCheckpointError("manifest does not name exactly one file per rank")
590
+ for r in range(ws):
591
+ fe = files[str(r)]
592
+ if not (isinstance(fe, dict) and {"name", "bytes", "sha256"} <= fe.keys()):
593
+ raise ShardedCheckpointError(f"manifest file entry for rank {r} is malformed")
594
+ # An EMPTY tensor set is legal: a module whose weights are reconstructible
595
+ # elsewhere (a frozen base + adapter) may declare `sharded_model_state() == {}`
596
+ # and checkpoint only its optimizer/adapter state.
597
+ if not isinstance(tensors, dict):
598
+ raise ShardedCheckpointError("manifest tensors entry is not an object")
599
+ for n, e in tensors.items():
600
+ if e.get("placement") not in ("shard0", "replicate") or e.get("dtype") not in _ST_ITEMSIZE:
601
+ raise ShardedCheckpointError(f"{n}: malformed tensor entry")
602
+ if e["placement"] == "shard0" and len(e.get("rows", [])) != ws:
603
+ raise ShardedCheckpointError(f"{n}: shard0 entry has {len(e.get('rows', []))} row ranges, world {ws}")
604
+ return m
605
+
606
+
607
+ def sharded_problem(path: str, st_header_problem: Callable[[str], Optional[str]]) -> Optional[str]:
608
+ """None iff every rank file the manifest names is present, the size it
609
+ recorded, and a structurally complete safetensors file. CHEAP (no payload
610
+ reads): sha256 is verified on the rank that loads the file."""
611
+ try:
612
+ m = read_manifest(path)
613
+ except ShardedCheckpointError as e:
614
+ return str(e)
615
+ for r in range(m["world_size"]):
616
+ fe = m["files"][str(r)]
617
+ fp = os.path.join(path, fe["name"])
618
+ if not os.path.isfile(fp):
619
+ return f"missing shard file {fe['name']}"
620
+ if os.path.getsize(fp) != fe["bytes"]:
621
+ return f"{fe['name']} is {os.path.getsize(fp)} bytes, manifest recorded {fe['bytes']}"
622
+ problem = st_header_problem(fp)
623
+ if problem:
624
+ return problem
625
+ return None
626
+
627
+
628
+ def verify_rank_file(path: str, rank: int) -> Optional[str]:
629
+ """sha256 of THIS rank's file against the manifest; None if it matches."""
630
+ m = read_manifest(path)
631
+ fe = m["files"][str(rank)]
632
+ got = file_sha256(os.path.join(path, fe["name"]))
633
+ if got != fe["sha256"]:
634
+ return f"{fe['name']} sha256 {got[:12]}.. != manifest {fe['sha256'][:12]}.. (bit rot or a partial copy)"
635
+ return None
636
+
637
+
638
+ def check_world_size(path: str, manifest: dict, world_size: int) -> None:
639
+ if manifest["world_size"] != world_size:
640
+ raise ShardedWorldSizeError(
641
+ f"checkpoint {path} was saved with world_size={manifest['world_size']} but this run has "
642
+ f"world_size={world_size}. Sharded weights are rank-local row slices and cannot be loaded "
643
+ "under another partitioning. Resume with the same number of ranks, or reshard offline: "
644
+ f"python scripts/ckpt_convert.py reshard {path} <dst> --world-size {world_size}"
645
+ )
646
+
647
+
648
+ def load_rank_shard(
649
+ state: Mapping[str, torch.Tensor], path: str, *, rank: int, world_size: int, verify_hash: bool = False
650
+ ) -> int:
651
+ """Copy this rank's file into the LIVE tensors of `state` (a model state
652
+ dict whose DTensors alias the parameters). Reads only this rank's file, one
653
+ tensor at a time. Returns the number of tensors loaded."""
654
+ m = read_manifest(path)
655
+ check_world_size(path, m, world_size)
656
+ mt = m["tensors"]
657
+ missing, unexpected = sorted(set(mt) - set(state)), sorted(set(state) - set(mt))
658
+ if missing or unexpected:
659
+ raise KeyError(f"resume state mismatch: missing={missing[:5]} unexpected={unexpected[:5]}")
660
+ if verify_hash:
661
+ bad = verify_rank_file(path, rank)
662
+ if bad:
663
+ raise ShardedCheckpointError(bad)
664
+ # validate EVERYTHING before the first copy_: a mismatch on tensor 300 must
665
+ # not leave a model half-overwritten.
666
+ plan = []
667
+ for name in sorted(mt):
668
+ local, desc = _local_and_desc(state[name], rank, world_size)
669
+ e = mt[name]
670
+ if desc["shape"] != e["shape"] or desc["placement"] != e["placement"]:
671
+ raise ShardedCheckpointError(
672
+ f"{name}: live {desc['placement']} {desc['shape']} != checkpoint {e['placement']} {e['shape']}"
673
+ )
674
+ if e["placement"] == "shard0" and list(e["rows"][rank]) != list(desc["row"]):
675
+ raise ShardedCheckpointError(
676
+ f"{name}: live rows {desc['row']} != checkpoint rows {e['rows'][rank]} for rank {rank}"
677
+ )
678
+ if local.is_floating_point() != _ST_TO_TORCH[e["dtype"]].is_floating_point:
679
+ raise ShardedCheckpointError(f"{name}: dtype class differs (live {local.dtype}, ckpt {e['dtype']})")
680
+ plan.append((name, local))
681
+ with ShardFile(os.path.join(path, m["files"][str(rank)]["name"])) as sf:
682
+ missing_in_file = [n for n, _ in plan if n not in sf.tensors]
683
+ if missing_in_file:
684
+ raise ShardedCheckpointError(f"{sf.path} lacks tensors {missing_in_file[:5]}")
685
+ with torch.no_grad():
686
+ for name, local in plan:
687
+ t = sf.tensor(name)
688
+ if tuple(t.shape) != tuple(local.shape):
689
+ raise ShardedCheckpointError(f"{name}: file shape {tuple(t.shape)} != live {tuple(local.shape)}")
690
+ local.copy_(t.to(device=local.device))
691
+ return len(plan)
692
+
693
+
694
+ class _ListSink:
695
+ def __init__(self):
696
+ self.parts: list[bytes] = []
697
+
698
+ def write(self, b):
699
+ self.parts.append(bytes(b))
700
+ return len(b)
701
+
702
+
703
+ class GlobalReader:
704
+ """Byte-range reads over the GLOBAL (row-major) tensors of a sharded checkpoint."""
705
+
706
+ def __init__(self, path: str, manifest: Optional[dict] = None):
707
+ self.path = path
708
+ self.m = manifest or read_manifest(path)
709
+ self.files = {
710
+ r: ShardFile(os.path.join(path, self.m["files"][str(r)]["name"])) for r in range(self.m["world_size"])
711
+ }
712
+
713
+ def close(self):
714
+ for f in self.files.values():
715
+ f.close()
716
+
717
+ def __enter__(self):
718
+ return self
719
+
720
+ def __exit__(self, *a):
721
+ self.close()
722
+
723
+ def info(self, name: str) -> tuple[str, tuple]:
724
+ e = self.m["tensors"][name]
725
+ return e["dtype"], tuple(e["shape"])
726
+
727
+ def nbytes(self, name: str) -> int:
728
+ dt, shape = self.info(name)
729
+ return _prod(shape) * _ST_ITEMSIZE[dt]
730
+
731
+ def copy_range(self, name: str, lo: int, hi: int, sink) -> None:
732
+ """Stream global bytes [lo, hi) of tensor `name` into `sink`."""
733
+ e = self.m["tensors"][name]
734
+ if not (0 <= lo <= hi <= self.nbytes(name)):
735
+ raise ShardedCheckpointError(f"{name}: byte range [{lo},{hi}) outside tensor")
736
+ if e["placement"] == "replicate":
737
+ self.files[0].copy_range(name, lo, hi, sink)
738
+ return
739
+ dt, shape = self.info(name)
740
+ rb = _prod(shape[1:]) * _ST_ITEMSIZE[dt]
741
+ for r, (off, cnt) in enumerate(e["rows"]):
742
+ a, b = max(lo, off * rb), min(hi, (off + cnt) * rb)
743
+ if a < b:
744
+ self.files[r].copy_range(name, a - off * rb, b - off * rb, sink)
745
+
746
+ def tensor(self, name: str) -> torch.Tensor:
747
+ dt, shape = self.info(name)
748
+ sink = _ListSink()
749
+ self.copy_range(name, 0, self.nbytes(name), sink)
750
+ buf = bytearray(b"".join(sink.parts))
751
+ if not buf:
752
+ return torch.empty(shape, dtype=_ST_TO_TORCH[dt])
753
+ return torch.frombuffer(buf, dtype=_ST_TO_TORCH[dt]).reshape(shape)
754
+
755
+
756
+ def load_full_state_dict(path: str) -> dict[str, torch.Tensor]:
757
+ """The WHOLE state dict, assembled in host memory. For tests and small
758
+ models only -- the production path is `export_hf`, which never does this."""
759
+ with GlobalReader(path) as g:
760
+ return {n: g.tensor(n) for n in g.m["tensors"]}
761
+
762
+
763
+ # ---------------------------------------------------------------------------
764
+ # describing the weight format (so the checkpoint is self-exporting)
765
+ # ---------------------------------------------------------------------------
766
+
767
+
768
+ def describe_weight_format(weight_format: Any, dtype: torch.dtype = torch.bfloat16) -> Optional[dict]:
769
+ """{"family", "hf_config"} for the formats we can export; None otherwise
770
+ (the checkpoint still saves and resumes, `export` then refuses with a clear
771
+ message instead of guessing a config)."""
772
+ # Subclasses count: scripts wrap OlmoeFormat to serialise its loads
773
+ # (olmoe_sft_smoke.SerialWeightFormat) and must stay exportable.
774
+ names = {c.__name__ for c in type(weight_format).__mro__}
775
+ name = next((n for n in ("DenseFormat", "OlmoeFormat", "Qwen3Format") if n in names), type(weight_format).__name__)
776
+ try:
777
+ if name == "DenseFormat":
778
+ from ..model import hf_config as hfc
779
+
780
+ return {"family": "dense", "hf_config": hfc.to_hf_config_dict(weight_format.model_cfg, dtype=dtype)}
781
+ if name == "OlmoeFormat":
782
+ from ..model.olmoe_checkpoint import config_to_hf_dict
783
+
784
+ return {"family": "olmoe", "hf_config": config_to_hf_dict(weight_format.olmoe_cfg, dtype=dtype)}
785
+ if name == "Qwen3Format":
786
+ return {"family": "qwen3", "hf_config": weight_format.qcfg.to_hf_config(dtype=dtype)}
787
+ except Exception as e: # noqa: BLE001 -- a config we cannot describe must not block saving weights
788
+ logger.warning("cannot describe weight format %s for export (%s: %s)", name, type(e).__name__, e)
789
+ return None
790
+
791
+
792
+ # ---------------------------------------------------------------------------
793
+ # HF export plan + streaming export
794
+ # ---------------------------------------------------------------------------
795
+
796
+
797
+ @dataclass
798
+ class HFEntry:
799
+ hf_name: str
800
+ st_dtype: str
801
+ shape: tuple
802
+ src: str # align key
803
+ lo: int # global byte range in the source tensor
804
+ hi: int
805
+
806
+
807
+ def _meta(manifest: dict) -> dict[str, torch.Tensor]:
808
+ order = manifest.get("key_order") or list(manifest["tensors"])
809
+ return {
810
+ n: torch.empty(tuple(manifest["tensors"][n]["shape"]), dtype=_ST_TO_TORCH[manifest["tensors"][n]["dtype"]], device="meta")
811
+ for n in order
812
+ }
813
+
814
+
815
+ def build_hf_plan(manifest: dict) -> list[HFEntry]:
816
+ """Plan the HF tensor list from the manifest ALONE (no data). 1:1 families
817
+ run the family's own converter on META tensors and map outputs back by
818
+ object identity, so the name table is the converter's, not a copy of it.
819
+ OLMoE's stacked experts become per-expert byte ranges of the fused tensor."""
820
+ if not manifest["tensors"]:
821
+ raise ShardedCheckpointError("this checkpoint holds no weight tensors (adapter/optimizer-only); nothing to export")
822
+ ex = manifest.get("export")
823
+ if not ex:
824
+ raise ShardedCheckpointError(
825
+ "this checkpoint carries no export recipe (its weight format is not Dense/Olmoe/Qwen3); "
826
+ "export cannot guess a config"
827
+ )
828
+ fam, hfc_d = ex["family"], ex["hf_config"]
829
+ meta = _meta(manifest)
830
+ ident = {id(v): k for k, v in meta.items()}
831
+ entries: list[HFEntry] = []
832
+
833
+ def one_to_one(out: Mapping[str, torch.Tensor]) -> None:
834
+ for hf_name, v in out.items():
835
+ src = ident.get(id(v))
836
+ if src is None:
837
+ raise ShardedCheckpointError(f"{hf_name}: not a 1:1 image of an align tensor")
838
+ e = manifest["tensors"][src]
839
+ entries.append(HFEntry(hf_name, e["dtype"], tuple(e["shape"]), src, 0, _prod(e["shape"]) * _ST_ITEMSIZE[e["dtype"]]))
840
+
841
+ if fam == "dense":
842
+ from ..model import checkpoint as ckpt
843
+ from ..model import hf_config as hfc
844
+
845
+ cfg = hfc.from_hf_config_dict(hfc_d)
846
+ one_to_one(ckpt.nmo_to_hf(meta, expected_n_layers=cfg.n_layers))
847
+ elif fam == "qwen3":
848
+ from ..model.qwen3 import Qwen3Config
849
+ from ..model.qwen3_checkpoint import align_to_hf
850
+
851
+ one_to_one(align_to_hf(meta, Qwen3Config.from_hf_config(hfc_d)))
852
+ elif fam == "olmoe":
853
+ from ..model import olmoe_checkpoint as oc
854
+
855
+ cfg = oc.config_from_hf_dict(hfc_d)
856
+ real = oc.align_to_hf(meta, cfg) # the converter's own key set and shapes: the oracle
857
+ inter = cfg.intermediate_size
858
+ fused = set()
859
+ for i in range(cfg.n_layers):
860
+ gu_k, dn_k = (k.format(i=i) for k in oc._AL_FUSED)
861
+ fused.update((gu_k, dn_k))
862
+ gu, dn = manifest["tensors"][gu_k], manifest["tensors"][dn_k]
863
+ es = _ST_ITEMSIZE[gu["dtype"]]
864
+ e_n, two_i, d = gu["shape"]
865
+ for e in range(e_n):
866
+ base = e * two_i * d * es
867
+ half = inter * d * es
868
+ entries.append(HFEntry(oc._HF_EXPERT.format(i=i, e=e, p="gate"), gu["dtype"], (inter, d), gu_k, base, base + half))
869
+ entries.append(HFEntry(oc._HF_EXPERT.format(i=i, e=e, p="up"), gu["dtype"], (inter, d), gu_k, base + half, base + 2 * half))
870
+ dbase = e * dn["shape"][1] * dn["shape"][2] * es
871
+ entries.append(HFEntry(oc._HF_EXPERT.format(i=i, e=e, p="down"), dn["dtype"], tuple(dn["shape"][1:]), dn_k, dbase, dbase + _prod(dn["shape"][1:]) * es))
872
+ rest = {k: v for k, v in real.items() if id(v) in ident and ident[id(v)] not in fused}
873
+ one_to_one(rest)
874
+ got = {e.hf_name: e.shape for e in entries}
875
+ want = {k: tuple(v.shape) for k, v in real.items()}
876
+ if got != want:
877
+ raise ShardedCheckpointError(
878
+ f"olmoe export plan disagrees with align_to_hf: only-plan={sorted(set(got) - set(want))[:3]} "
879
+ f"only-converter={sorted(set(want) - set(got))[:3]} "
880
+ f"shape-diff={[k for k in got if k in want and got[k] != want[k]][:3]}"
881
+ )
882
+ else:
883
+ raise ShardedCheckpointError(f"unknown export family {fam!r}")
884
+ return entries
885
+
886
+
887
+ def export_hf(src: str, dst: str, *, verify: bool = True) -> dict:
888
+ """Sharded checkpoint -> HF-layout directory, tensor by tensor.
889
+
890
+ Byte-identical to what the gathered path writes (`save_nmo_as_hf` /
891
+ `save_olmoe_as_hf` / `save_hf_qwen3`) -- tested by file hash -- with peak RSS
892
+ of one `COPY_CHUNK` instead of 2x the weights. `dst` is staged as
893
+ `dst.partial`, fsynced and renamed: an interrupted export leaves nothing at
894
+ `dst`.
895
+ """
896
+ if os.path.exists(dst):
897
+ raise FileExistsError(f"{dst} exists; export never overwrites")
898
+ m = read_manifest(src)
899
+ if verify:
900
+ for r in range(m["world_size"]):
901
+ bad = verify_rank_file(src, r)
902
+ if bad:
903
+ raise ShardedCheckpointError(bad)
904
+ plan = build_hf_plan(m)
905
+ fam = m["export"]["family"]
906
+ staging = dst.rstrip("/") + ".partial"
907
+ if os.path.isdir(staging):
908
+ shutil.rmtree(staging)
909
+ os.makedirs(staging)
910
+ try:
911
+ with GlobalReader(src, m) as g:
912
+ def make_entry(h: HFEntry) -> StEntry:
913
+ def emit(f, h=h):
914
+ g.copy_range(h.src, h.lo, h.hi, f)
915
+
916
+ return StEntry(h.hf_name, h.st_dtype, h.shape, emit)
917
+
918
+ by_name = {h.hf_name: h for h in plan}
919
+ if fam == "qwen3":
920
+ from ..model.qwen3_checkpoint import MAX_SHARD_BYTES, _plan_shards
921
+
922
+ sizes = {h.hf_name: _prod(h.shape) * _ST_ITEMSIZE[h.st_dtype] for h in plan}
923
+ groups = _plan_shards(sizes, MAX_SHARD_BYTES)
924
+ weight_map: dict[str, str] = {}
925
+ for n, keys in enumerate(groups, start=1):
926
+ fname = f"model-{n:05d}-of-{len(groups):05d}.safetensors"
927
+ write_safetensors_streaming(
928
+ os.path.join(staging, fname), [make_entry(by_name[k]) for k in keys], {"format": "pt"}
929
+ )
930
+ weight_map.update({k: fname for k in keys})
931
+ with open(os.path.join(staging, "model.safetensors.index.json"), "w") as fh:
932
+ json.dump({"metadata": {"total_size": sum(sizes.values())}, "weight_map": weight_map}, fh, indent=2, sort_keys=True)
933
+ fh.flush()
934
+ os.fsync(fh.fileno())
935
+ else:
936
+ write_safetensors_streaming(
937
+ os.path.join(staging, "model.safetensors"), [make_entry(h) for h in plan], {"format": "pt"}
938
+ )
939
+ with open(os.path.join(staging, "config.json"), "w", encoding="utf-8") as fh:
940
+ fh.write(json.dumps(m["export"]["hf_config"], indent=2, sort_keys=True) + "\n")
941
+ fh.flush()
942
+ os.fsync(fh.fileno())
943
+ for fn in sorted(os.listdir(src)):
944
+ if not _OWN_FILE_RE.match(fn) and os.path.isfile(os.path.join(src, fn)) and fn != "config.json":
945
+ shutil.copyfile(os.path.join(src, fn), os.path.join(staging, fn))
946
+ _fsync_tree(staging)
947
+ os.rename(staging, dst)
948
+ _fsync_dir(os.path.dirname(os.path.abspath(dst)))
949
+ except BaseException:
950
+ shutil.rmtree(staging, ignore_errors=True)
951
+ raise
952
+ return {"tensors": len(plan), "family": fam, "dst": dst}
953
+
954
+
955
+ def _fsync_dir(p: str) -> None:
956
+ fd = os.open(p, os.O_RDONLY)
957
+ try:
958
+ os.fsync(fd)
959
+ finally:
960
+ os.close(fd)
961
+
962
+
963
+ def _fsync_tree(root: str) -> None:
964
+ dirs = []
965
+ for dp, _dn, fns in os.walk(root):
966
+ dirs.append(dp)
967
+ for fn in fns:
968
+ fd = os.open(os.path.join(dp, fn), os.O_RDONLY)
969
+ try:
970
+ os.fsync(fd)
971
+ finally:
972
+ os.close(fd)
973
+ for d in reversed(dirs):
974
+ _fsync_dir(d)
975
+
976
+
977
+ # ---------------------------------------------------------------------------
978
+ # offline reshard
979
+ # ---------------------------------------------------------------------------
980
+
981
+
982
+ class ReshardUnsupported(ShardedCheckpointError):
983
+ """Optimizer state whose layout cannot be re-cut without knowing the optimizer."""
984
+
985
+
986
+ def _check_opt_leaf(vals: list, shapes_old: list[tuple], name: str, where: str) -> None:
987
+ """Every old rank's piece must have exactly the LOCAL shard shape of the parameter it is
988
+ filed under (shape metadata only: reads nothing from an mmap'd file)."""
989
+ for r, (v, want) in enumerate(zip(vals, shapes_old)):
990
+ if tuple(v.shape) != tuple(want):
991
+ raise ReshardUnsupported(
992
+ f"{where}: tensor shape {tuple(v.shape)} on old rank {r} != the local shard {tuple(want)} of "
993
+ f"parameter {name!r}. This optimizer state is not elementwise-per-shard (quantised/blocked "
994
+ "moments?) or its parameter order differs from the model's; it cannot be re-cut safely."
995
+ )
996
+
997
+
998
+ def _cut_opt_leaf(vals: list, old_rows: Optional[list], new_ws: int, new_rank: int) -> torch.Tensor:
999
+ """NEW rank `new_rank`'s piece of one per-param optimizer tensor, built from only the
1000
+ old pieces that overlap its rows (no whole-tensor concatenation, no other new rank)."""
1001
+ if old_rows is None: # replicated parameter
1002
+ return vals[0].clone()
1003
+ total = sum(c for _o, c in old_rows)
1004
+ off, n = chunk_range(total, new_ws, new_rank)
1005
+ parts = []
1006
+ for v, (qo, qc) in zip(vals, old_rows):
1007
+ a, b = max(off, qo), min(off + n, qo + qc)
1008
+ if a < b:
1009
+ parts.append(v[a - qo : b - qo])
1010
+ if not parts:
1011
+ return torch.empty((0,) + tuple(vals[0].shape[1:]), dtype=vals[0].dtype)
1012
+ return torch.cat(parts, dim=0) if len(parts) > 1 else parts[0].clone()
1013
+
1014
+
1015
+ def iter_reshard_optimizer(old: list[dict], manifest: dict, new_ws: int):
1016
+ """Yield the re-cut optimizer state dict of NEW rank 0, 1, ... one at a time.
1017
+
1018
+ THE ALL-RANKS-AT-ONCE DEFECT this shape prevents. The first version returned a
1019
+ list holding every new rank's state, so its peak was the WHOLE optimizer
1020
+ (12 B/param for fp32-master AdamW: 83 GB for a 7B model) while the CLI
1021
+ estimated `optimizer_bytes / new_ws` and the 45 GB floor check was sized on
1022
+ that. Measured on 4 old -> 4 new ranks, 50 M params: anonymous-RSS growth 576
1023
+ MiB against an estimate of 144 MiB, i.e. `new_ws` times the estimate. On this
1024
+ no-swap shared box a reshard that passes the preflight at 21 GB and takes 83
1025
+ GB is the 2026-08-12 OOM. Here only the old pieces that overlap the rank being
1026
+ built are read (mmap'd), and the caller writes and drops each yielded dict
1027
+ before asking for the next, so the peak is one new rank's state.
1028
+
1029
+ Every shape/consistency refusal is raised BEFORE the first yield, so a
1030
+ resharder that cannot prove it understands the state writes no file at all.
1031
+ """
1032
+ order = manifest.get("param_order")
1033
+ if not order:
1034
+ raise ReshardUnsupported("manifest has no param_order: cannot map optimizer slots to parameters")
1035
+ ws = manifest["world_size"]
1036
+ if len(old) != ws:
1037
+ raise ReshardUnsupported(f"{len(old)} optimizer file(s) for a {ws}-rank checkpoint")
1038
+ if "world_size" in old[0]:
1039
+ raise ReshardUnsupported("optimizer state is stamped with its world_size (quantised shard-local state)")
1040
+
1041
+ def entry(pname: str) -> dict:
1042
+ e = manifest["tensors"].get(pname)
1043
+ if e is None:
1044
+ raise ReshardUnsupported(f"param_order names {pname!r}, which is not a tensor of this checkpoint")
1045
+ return e
1046
+
1047
+ def local_shape(pname: str, r: int) -> tuple:
1048
+ e = entry(pname)
1049
+ if e["placement"] == "replicate":
1050
+ return tuple(e["shape"])
1051
+ return (e["rows"][r][1],) + tuple(e["shape"][1:])
1052
+
1053
+ def rows_of(pname: str) -> Optional[list]:
1054
+ e = entry(pname)
1055
+ return None if e["placement"] == "replicate" else [tuple(x) for x in e["rows"]]
1056
+
1057
+ def agree(vals: list, where: str):
1058
+ v0 = vals[0]
1059
+ for v in vals[1:]:
1060
+ same = torch.equal(v, v0) if isinstance(v0, torch.Tensor) else v == v0
1061
+ if not same:
1062
+ raise ReshardUnsupported(f"{where}: differs across old ranks ({v0!r} vs {v!r})")
1063
+ return v0
1064
+
1065
+ # -- pass 1: validate everything, build a per-key plan (no tensor payload read) -----------
1066
+ plan: list[tuple] = []
1067
+ for key in old[0]:
1068
+ vals = [o[key] for o in old]
1069
+ if key == "state":
1070
+ leaves = [] # (idx, k, kind, payload)
1071
+ for idx in vals[0]:
1072
+ if not 0 <= int(idx) < len(order):
1073
+ raise ReshardUnsupported(f"state slot {idx} is outside param_order ({len(order)} entries)")
1074
+ pname = order[int(idx)]
1075
+ for k in vals[0][idx]:
1076
+ leaf = [v[idx][k] for v in vals]
1077
+ where = f"state[{idx}][{k}]"
1078
+ if isinstance(leaf[0], torch.Tensor) and leaf[0].ndim >= 1:
1079
+ _check_opt_leaf(leaf, [local_shape(pname, r) for r in range(ws)], pname, where)
1080
+ leaves.append((idx, k, "cut", (leaf, rows_of(pname))))
1081
+ else:
1082
+ leaves.append((idx, k, "same", agree(leaf, where)))
1083
+ plan.append((key, "state", leaves))
1084
+ elif key == "master":
1085
+ if len(vals[0]) != len(order) or any(len(v) != len(order) for v in vals):
1086
+ raise ReshardUnsupported(
1087
+ f"master has {[len(v) for v in vals]} entries per old rank, param_order {len(order)}: the "
1088
+ "optimizer was not built over the model's trainable parameters in order"
1089
+ )
1090
+ masters = []
1091
+ for i, pname in enumerate(order):
1092
+ leaf = [v[i] for v in vals]
1093
+ _check_opt_leaf(leaf, [local_shape(pname, r) for r in range(ws)], pname, f"master[{i}]")
1094
+ masters.append((leaf, rows_of(pname)))
1095
+ plan.append((key, "master", masters))
1096
+ else:
1097
+ if _has_tensor(vals[0], min_ndim=1):
1098
+ raise ReshardUnsupported(f"optimizer key {key!r} holds sharded tensors this resharder does not understand")
1099
+ plan.append((key, "plain", _agree_tree(vals, key)))
1100
+
1101
+ # -- pass 2: build ONE new rank at a time ---------------------------------------------------
1102
+ for r in range(new_ws):
1103
+ out: dict = {}
1104
+ for key, kind, payload in plan:
1105
+ if kind == "state":
1106
+ st: dict = {}
1107
+ for idx, k, how, p in payload:
1108
+ st.setdefault(idx, {})[k] = _cut_opt_leaf(p[0], p[1], new_ws, r) if how == "cut" else p
1109
+ out[key] = st
1110
+ elif kind == "master":
1111
+ out[key] = [_cut_opt_leaf(leaf, rows, new_ws, r) for leaf, rows in payload]
1112
+ else:
1113
+ out[key] = payload
1114
+ yield out
1115
+
1116
+
1117
+ def reshard_optimizer(old: list[dict], manifest: dict, new_ws: int) -> list[dict]:
1118
+ """Re-cut per-rank optimizer state dicts for `new_ws`, all at once (tests, small models).
1119
+
1120
+ Production resharding uses `iter_reshard_optimizer` and writes each rank
1121
+ before building the next; this wrapper holds every new rank in memory.
1122
+
1123
+ Handles the layout `torch.optim.AdamW` / `OffloadAdamW` produce:
1124
+ `state[idx][k]` tensors and a `master` list, each shaped like the LOCAL
1125
+ shard of parameter `param_order[idx]`. Scalars (`step`) and non-tensor
1126
+ leaves must agree across old ranks. ANYTHING else that holds a tensor with
1127
+ ndim >= 1 (adamw8bit's quantised blocks, a `world_size`-stamped state)
1128
+ raises `ReshardUnsupported`: re-cutting what we do not understand is how a
1129
+ resume loads plausible, wrong moments.
1130
+ """
1131
+ return list(iter_reshard_optimizer(old, manifest, new_ws))
1132
+
1133
+
1134
+ def _has_tensor(o: Any, min_ndim: int) -> bool:
1135
+ if isinstance(o, torch.Tensor):
1136
+ return o.ndim >= min_ndim
1137
+ if isinstance(o, dict):
1138
+ return any(_has_tensor(v, min_ndim) for v in o.values())
1139
+ if isinstance(o, (list, tuple)):
1140
+ return any(_has_tensor(v, min_ndim) for v in o)
1141
+ return False
1142
+
1143
+
1144
+ def _agree_tree(vals: list, where: str):
1145
+ v0 = vals[0]
1146
+ for v in vals[1:]:
1147
+ if repr(v) != repr(v0):
1148
+ raise ReshardUnsupported(f"{where}: differs across old ranks")
1149
+ return v0
1150
+
1151
+
1152
+ def reshard(src: str, dst: str, new_ws: int, *, drop_optimizer: bool = False) -> dict:
1153
+ """Offline reshard of a sharded checkpoint to `new_ws` ranks.
1154
+
1155
+ Model shards are re-cut by byte range (bounded memory). Optimizer files are
1156
+ re-cut by `reshard_optimizer` (peak RSS = one NEW rank's optimizer state --
1157
+ at 7B fp32-master AdamW that is 12 B/param/new_ws; run it on an idle box).
1158
+ With `drop_optimizer=True` the optimizer files are omitted and the loop
1159
+ state is marked, so resume starts FRESH moments: a recorded deviation, and
1160
+ NOT lossless when the optimizer holds the fp32 master.
1161
+ `resume.pt` is carried over with its world-size stamp rewritten and the
1162
+ per-rank RNG list dropped (the trainer restarts RNG from the seed with a
1163
+ warning; carrying N_old streams into N_new ranks would be wrong).
1164
+ """
1165
+ if new_ws < 1:
1166
+ raise ValueError("new_ws must be >= 1")
1167
+ m = read_manifest(src)
1168
+ for r in range(m["world_size"]):
1169
+ bad = verify_rank_file(src, r)
1170
+ if bad:
1171
+ raise ShardedCheckpointError(bad)
1172
+ if os.path.exists(dst):
1173
+ raise FileExistsError(f"{dst} exists; reshard never overwrites")
1174
+ staging = dst.rstrip("/") + ".partial"
1175
+ if os.path.isdir(staging):
1176
+ shutil.rmtree(staging)
1177
+ os.makedirs(staging)
1178
+ try:
1179
+ infos = []
1180
+ with GlobalReader(src, m) as g:
1181
+ for r in range(new_ws):
1182
+ entries, tdesc = [], {}
1183
+ for name in sorted(m["tensors"]):
1184
+ e = m["tensors"][name]
1185
+ dt, shape = e["dtype"], tuple(e["shape"])
1186
+ if e["placement"] == "shard0":
1187
+ off, cnt = chunk_range(shape[0], new_ws, r)
1188
+ lshape = (cnt,) + shape[1:]
1189
+ rb = _prod(shape[1:]) * _ST_ITEMSIZE[dt]
1190
+ lo, hi = off * rb, (off + cnt) * rb
1191
+ tdesc[name] = {"shape": list(shape), "placement": "shard0", "row": [off, cnt], "dtype": dt}
1192
+ else:
1193
+ lshape, lo, hi = shape, 0, g.nbytes(name)
1194
+ tdesc[name] = {"shape": list(shape), "placement": "replicate", "row": None, "dtype": dt}
1195
+
1196
+ def emit(f, name=name, lo=lo, hi=hi):
1197
+ g.copy_range(name, lo, hi, f)
1198
+
1199
+ entries.append(StEntry(name, dt, lshape, emit))
1200
+ fname = rank_file_name(r)
1201
+ meta = write_safetensors_streaming(os.path.join(staging, fname), entries, {"format": "pt"})
1202
+ infos.append({"file": {"name": fname, **meta}, "tensors": tdesc, "order": m.get("key_order")})
1203
+ new_manifest = build_manifest(
1204
+ infos,
1205
+ param_order=m.get("param_order"),
1206
+ export=m.get("export"),
1207
+ extra={"resharded_from": {"world_size": m["world_size"], "source": os.path.basename(src.rstrip("/"))}},
1208
+ )
1209
+ write_manifest(staging, new_manifest)
1210
+
1211
+ state = torch.load(os.path.join(src, "resume.pt"), weights_only=True, map_location="cpu")
1212
+ state.pop("rng_per_rank", None)
1213
+ if drop_optimizer:
1214
+ state["optimizer_state"] = {"layout": "none", "world_size": new_ws}
1215
+ else:
1216
+ state["optimizer_state"] = {"layout": "per_rank_files", "world_size": new_ws}
1217
+ olds = [
1218
+ torch.load(os.path.join(src, f"optimizer_rank{r}.pt"), weights_only=True, map_location="cpu", mmap=True)
1219
+ for r in range(m["world_size"])
1220
+ ]
1221
+ for r, sd in enumerate(iter_reshard_optimizer(olds, m, new_ws)):
1222
+ p = os.path.join(staging, f"optimizer_rank{r}.pt")
1223
+ with open(p, "wb") as f:
1224
+ torch.save(sd, f)
1225
+ f.flush()
1226
+ os.fsync(f.fileno())
1227
+ del sd # one new rank's state alive at a time
1228
+ del olds
1229
+ with open(os.path.join(staging, "resume.pt"), "wb") as f:
1230
+ torch.save(state, f)
1231
+ f.flush()
1232
+ os.fsync(f.fileno())
1233
+ write_integrity(
1234
+ staging,
1235
+ {
1236
+ fn: file_sha256(os.path.join(staging, fn))
1237
+ for fn in sorted(os.listdir(staging))
1238
+ if fn == "resume.pt" or re.fullmatch(r"optimizer_rank\d+\.pt", fn)
1239
+ },
1240
+ )
1241
+ for fn in sorted(os.listdir(src)):
1242
+ if not _OWN_FILE_RE.match(fn) and os.path.isfile(os.path.join(src, fn)):
1243
+ shutil.copyfile(os.path.join(src, fn), os.path.join(staging, fn))
1244
+ _fsync_tree(staging)
1245
+ os.rename(staging, dst)
1246
+ _fsync_dir(os.path.dirname(os.path.abspath(dst)))
1247
+ except BaseException:
1248
+ shutil.rmtree(staging, ignore_errors=True)
1249
+ raise
1250
+ return {"dst": dst, "world_size": new_ws, "from": m["world_size"], "optimizer": "dropped" if drop_optimizer else "resharded"}
1251
+
1252
+
1253
+ def peak_rss_kb() -> int:
1254
+ """VmHWM of this process in kB (psutil is not a dependency)."""
1255
+ with open("/proc/self/status") as f:
1256
+ for line in f:
1257
+ if line.startswith("VmHWM:"):
1258
+ return int(line.split()[1])
1259
+ return -1
1260
+
1261
+
1262
+ __all__ = [
1263
+ "CHECKPOINT_FORMATS",
1264
+ "SHARD_MANIFEST_NAME",
1265
+ "ShardedCheckpointError",
1266
+ "ShardedWorldSizeError",
1267
+ "ReshardUnsupported",
1268
+ "chunk_range",
1269
+ "save_rank_shard",
1270
+ "build_manifest",
1271
+ "write_manifest",
1272
+ "read_manifest",
1273
+ "is_sharded",
1274
+ "sharded_problem",
1275
+ "verify_rank_file",
1276
+ "load_rank_shard",
1277
+ "load_full_state_dict",
1278
+ "describe_weight_format",
1279
+ "build_hf_plan",
1280
+ "export_hf",
1281
+ "reshard",
1282
+ "reshard_optimizer",
1283
+ "iter_reshard_optimizer",
1284
+ "peak_rss_kb",
1285
+ ]
code/align/train/stage_gates.py ADDED
@@ -0,0 +1,1002 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stage gate sequencing and decisions (section-11).
2
+
3
+ This is the first stage that spends real GPU-hours, so the job of this module
4
+ is to make wasting them structurally impossible rather than merely unlikely.
5
+ Everything here is a pure decision over recorded facts; the stage script drives
6
+ it and owns the side effects.
7
+
8
+ WHY THE ORDER IS FIXED, and why it is enforced instead of documented:
9
+
10
+ golden_regression -> smoke_7b -> probe_20step -> rss_preflight
11
+ -> train -> eval -> quality_gate
12
+
13
+ * The golden regression runs before ANY new training because it validates
14
+ the eval stack itself against numbers that are already known. A defective
15
+ harness silently corrupts every downstream measurement, and a full-epoch
16
+ run that under-performs a 0.35-epoch run then looks like a modeling
17
+ result instead of the stack bug it is.
18
+ * The 7B smoke runs before the probe because the chunked-loss peak-memory
19
+ assertion is only meaningful at 7B on the real 4-GPU topology. A
20
+ unit-scale memory check cannot catch what a 32768-vocab logit tensor does
21
+ to a 23GB card.
22
+ * The probe runs before the preflight and the launch because a measured step
23
+ time that invalidates the schedule must stop the run BEFORE it starts, not
24
+ three hours in.
25
+
26
+ MODULE-LEVEL IMPORTS ARE DELIBERATELY TORCH-FREE. `scripts/sft.py` imports this
27
+ module above `apply_resource_guards()`, and torch reads
28
+ PYTORCH_CUDA_ALLOC_CONF, the cache paths and the thread caps AT IMPORT -- so
29
+ anything that pulls torch in transitively (probes, trainer, model) is imported
30
+ lazily inside the function that needs it. A test asserts this.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import hashlib
36
+ import json
37
+ import logging
38
+ import math
39
+ import os
40
+ from dataclasses import dataclass
41
+ from datetime import datetime, timezone
42
+ from typing import Any, Optional
43
+
44
+ from ..registry import MANDATORY_BANNER_KEYS, assert_banner_complete
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+ NAN = float("nan")
49
+
50
+ #: Explicit "not measured". Never absent, never a plausible substitute.
51
+ PENDING = "PENDING"
52
+
53
+ #: The fixed stage sequence. Data, not prose: the ledger iterates it.
54
+ GATE_ORDER: tuple[str, ...] = (
55
+ "golden_regression",
56
+ "smoke_7b",
57
+ "probe_20step",
58
+ "rss_preflight",
59
+ "train",
60
+ "eval",
61
+ "quality_gate",
62
+ )
63
+
64
+ #: Gates whose result depends on the eval stack. A change under `align/eval/`
65
+ #: invalidates exactly these -- re-running the 7B smoke or the probe because
66
+ #: someone edited a verifier would burn GPU-hours for nothing.
67
+ EVAL_DEPENDENT_GATES: frozenset[str] = frozenset({"golden_regression", "eval", "quality_gate"})
68
+
69
+ LEDGER_FILENAME = "stage_gates.json"
70
+
71
+
72
+ def _is_rank0() -> bool:
73
+ """Rank from the environment, so this works before (and without) any
74
+ process group -- same convention as the heavy-job flock."""
75
+ return int(os.environ.get("RANK", "0")) == 0
76
+
77
+
78
+ class GateOrderError(RuntimeError):
79
+ """Raised when a gate runs before its predecessors have passed."""
80
+
81
+
82
+ @dataclass
83
+ class GateDecision:
84
+ passed: bool
85
+ halt_message: str = ""
86
+ detail: dict = None # type: ignore[assignment]
87
+
88
+
89
+ # ---------------------------------------------------------------------------
90
+ # ledger
91
+ # ---------------------------------------------------------------------------
92
+
93
+
94
+ def eval_source_hash(repo_root: Optional[str] = None) -> str:
95
+ """Hash the `align/eval/` source tree.
96
+
97
+ The golden regression certifies the harness, so its pass is only valid for
98
+ the harness that produced it. Keying the ledger entry on this hash is what
99
+ turns "we ran the golden regression once" into "we ran it against THIS
100
+ code".
101
+ """
102
+ root = repo_root or os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
103
+ eval_dir = os.path.join(root, "align", "eval")
104
+ h = hashlib.sha256()
105
+ for dirpath, _dirs, files in sorted(os.walk(eval_dir)):
106
+ for fn in sorted(files):
107
+ if not fn.endswith(".py"):
108
+ continue
109
+ path = os.path.join(dirpath, fn)
110
+ h.update(os.path.relpath(path, eval_dir).encode())
111
+ with open(path, "rb") as f:
112
+ h.update(f.read())
113
+ return h.hexdigest()[:16]
114
+
115
+
116
+ class GateLedger:
117
+ """Records gate results, enforces the order, survives a restart."""
118
+
119
+ def __init__(self, run_dir: str, eval_source_hash: Optional[str] = None):
120
+ self.run_dir = run_dir
121
+ os.makedirs(run_dir, exist_ok=True)
122
+ self.path = os.path.join(run_dir, LEDGER_FILENAME)
123
+ self.eval_source_hash = eval_source_hash
124
+ self._entries: dict[str, dict] = {}
125
+ self._load()
126
+
127
+ # -- persistence --------------------------------------------------------
128
+
129
+ def _load(self) -> None:
130
+ if not os.path.isfile(self.path):
131
+ return
132
+ try:
133
+ with open(self.path) as f:
134
+ blob = json.load(f)
135
+ except (OSError, json.JSONDecodeError):
136
+ logger.warning("unreadable gate ledger at %s; starting fresh", self.path)
137
+ return
138
+
139
+ recorded_hash = blob.get("eval_source_hash")
140
+ self._entries = dict(blob.get("gates", {}))
141
+ if (
142
+ self.eval_source_hash is not None
143
+ and recorded_hash is not None
144
+ and recorded_hash != self.eval_source_hash
145
+ ):
146
+ dropped = [g for g in EVAL_DEPENDENT_GATES if g in self._entries]
147
+ for g in dropped:
148
+ del self._entries[g]
149
+ if dropped:
150
+ logger.warning(
151
+ "align/eval/ changed since this ledger was written (%s -> %s); "
152
+ "invalidated %s. The golden regression certifies the eval "
153
+ "stack, so its pass does not carry across a change to it.",
154
+ recorded_hash,
155
+ self.eval_source_hash,
156
+ sorted(dropped),
157
+ )
158
+
159
+ def _save(self) -> None:
160
+ """Persist. RANK 0 ONLY -- see `record`."""
161
+ if not _is_rank0():
162
+ return
163
+ tmp = self.path + ".partial"
164
+ with open(tmp, "w") as f:
165
+ json.dump(
166
+ {"eval_source_hash": self.eval_source_hash, "gates": self._entries},
167
+ f,
168
+ indent=1,
169
+ sort_keys=True,
170
+ # Runs on EVERY record: the site with the most chances to meet
171
+ # a non-serializable detail (attempt-6 class).
172
+ default=str,
173
+ )
174
+ os.replace(tmp, self.path)
175
+
176
+ # -- recording ----------------------------------------------------------
177
+
178
+ def record(self, gate: str, passed: bool, detail: str = "") -> dict:
179
+ if gate not in GATE_ORDER:
180
+ raise GateOrderError(f"unknown gate {gate!r}; the sequence is {list(GATE_ORDER)}")
181
+ idx = GATE_ORDER.index(gate)
182
+ for earlier in GATE_ORDER[:idx]:
183
+ if not self.is_passed(earlier):
184
+ raise GateOrderError(
185
+ f"refusing to record {gate!r}: {earlier!r} has not passed "
186
+ f"(it reads {self.status()[earlier]!r}). The stage sequence "
187
+ f"is fixed at {list(GATE_ORDER)} and each gate exists to "
188
+ "stop the next one from wasting GPU-hours. Run the earlier "
189
+ "gate, or fix why it failed."
190
+ )
191
+ entry = {
192
+ "passed": bool(passed),
193
+ "detail": detail,
194
+ "recorded_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
195
+ }
196
+ # EVERY rank updates its own in-memory view; only RANK 0 persists.
197
+ #
198
+ # THE LEDGER RENAME RACE (killed attempt 3, past the smoke): all four
199
+ # ranks must run the gates -- the 7B smoke needs the full FSDP
200
+ # topology -- so all four called this, all four wrote the SAME
201
+ # `.partial`, the first rename consumed it and the other three died on
202
+ # FileNotFoundError. A gate is a property of the JOB, not of a rank.
203
+ #
204
+ # NO BARRIER, deliberately, and this is the safer half of the choice.
205
+ # Every rank keeps an identical in-memory ledger (the order checks and
206
+ # the passed flag are deterministic from local state), so no rank ever
207
+ # needs to READ the file mid-run -- which means no collective here,
208
+ # which means no way for a rank-0 raise to strand its peers waiting on
209
+ # one. A barrier would have re-introduced the NCCL-timeout shape to
210
+ # fix a filesystem race. Gate failures are themselves deterministic
211
+ # across ranks (same config, same files, host-wide MemAvailable), so
212
+ # all ranks raise together and torchrun propagates it.
213
+ self._entries[gate] = entry
214
+ self._save()
215
+ logger.info("gate %s: %s (%s)", gate, "PASS" if passed else "FAIL", detail or "-")
216
+ return entry
217
+
218
+ # -- reading ------------------------------------------------------------
219
+
220
+ def is_passed(self, gate: str) -> bool:
221
+ return self._entries.get(gate, {}).get("passed") is True
222
+
223
+ def status(self) -> dict:
224
+ """Every gate present; unrun gates read PENDING, never absent."""
225
+ return {g: (self._entries[g] if g in self._entries else PENDING) for g in GATE_ORDER}
226
+
227
+ def next_gate(self) -> Optional[str]:
228
+ for gate in GATE_ORDER:
229
+ if not self.is_passed(gate):
230
+ return gate
231
+ return None
232
+
233
+ def require(self, gate: str) -> None:
234
+ if not self.is_passed(gate):
235
+ raise GateOrderError(
236
+ f"gate {gate!r} has not passed (reads {self.status()[gate]!r}); "
237
+ "refusing to continue"
238
+ )
239
+
240
+
241
+ # ---------------------------------------------------------------------------
242
+ # individual gates
243
+ # ---------------------------------------------------------------------------
244
+
245
+
246
+ def _mem_available_gb() -> float:
247
+ with open("/proc/meminfo") as f:
248
+ for line in f:
249
+ if line.startswith("MemAvailable:"):
250
+ return float(line.split()[1]) / 1024**2
251
+ raise RuntimeError("/proc/meminfo has no MemAvailable line")
252
+
253
+
254
+ def rss_preflight(cfg, mem_available_gb: Optional[float] = None) -> dict:
255
+ """`MemAvailable >= projected_job_RSS + headroom`, or refuse to start.
256
+
257
+ The generic 45GB launch floor is insufficient for this job class and that
258
+ insufficiency IS the 2026-08-12 incident: the offloaded optimizer state
259
+ (~87GB) arrives over the first optimizer steps, AFTER any launch-time free
260
+ check has passed. On a box with no swap there is no overflow -- exhausting
261
+ RAM wedges the host in D-state and takes unrelated runs down with it.
262
+ """
263
+ from .probes import projected_job_rss # imports torch; kept lazy
264
+
265
+ projection = projected_job_rss(cfg)
266
+ projected = projection["total_gb"]
267
+ headroom = cfg.gates.rss_headroom_gb
268
+ required = projected + headroom
269
+ available = _mem_available_gb() if mem_available_gb is None else float(mem_available_gb)
270
+
271
+ result = {
272
+ "passed": available >= required,
273
+ "mem_available_gb": available,
274
+ "projected_job_rss_gb": projected,
275
+ "headroom_gb": headroom,
276
+ "required_gb": required,
277
+ "breakdown": projection,
278
+ }
279
+ if not result["passed"]:
280
+ raise RuntimeError(
281
+ f"RSS PREFLIGHT FAILED: MemAvailable {available:.1f}GB < projected "
282
+ f"job RSS {projected:.1f}GB + {headroom:.0f}GB headroom = "
283
+ f"{required:.1f}GB.\nBreakdown: {projection}\nThis box has NO SWAP, "
284
+ "so there is no overflow: the offloaded optimizer state lands after "
285
+ "launch and would wedge the host mid-run (2026-08-12: 2GB free, "
286
+ "load 619, a healthy run killed by a 5-hour NCCL stall). Wait for "
287
+ "the host to quiesce, or switch optimizer.name to adamw8bit, which "
288
+ "drops the host-state term entirely."
289
+ )
290
+ logger.info("RSS preflight ok: %.1fGB available >= %.1fGB required", available, required)
291
+ return result
292
+
293
+
294
+ def schedule_check(probe_report: dict, cfg, optimizer_steps_planned: int) -> dict:
295
+ """Project epoch wall-clock from the measured step time, or refuse.
296
+
297
+ A measured step time that invalidates the schedule must stop the run before
298
+ it starts. Three hours into an epoch is too late to discover that the
299
+ epoch was never going to fit the window.
300
+ """
301
+ step_time = float(probe_report.get("mean_step_time_s", NAN))
302
+ if math.isnan(step_time):
303
+ raise RuntimeError(
304
+ "schedule check refused: the probe did not measure a step time "
305
+ "(mean_step_time_s is NaN). Every comparison against NaN is False, "
306
+ "so a naive budget check would read 'within budget' and launch. "
307
+ "Re-run the 20-step probe."
308
+ )
309
+ if optimizer_steps_planned <= 0:
310
+ raise ValueError(f"optimizer_steps_planned={optimizer_steps_planned} is not a schedule")
311
+
312
+ projected = step_time * optimizer_steps_planned
313
+ allowed = cfg.gates.epoch_budget_s * cfg.gates.schedule_budget_factor
314
+ result = {
315
+ "passed": projected <= allowed,
316
+ "mean_step_time_s": step_time,
317
+ "optimizer_steps_planned": optimizer_steps_planned,
318
+ "projected_epoch_s": projected,
319
+ "budget_s": cfg.gates.epoch_budget_s,
320
+ "allowed_s": allowed,
321
+ }
322
+ if not result["passed"]:
323
+ raise RuntimeError(
324
+ f"SCHEDULE CHECK FAILED: measured step time {step_time:.2f}s x "
325
+ f"{optimizer_steps_planned} optimizer steps projects an epoch "
326
+ f"wall-clock of {projected:.0f}s, beyond the allowed "
327
+ f"{allowed:.0f}s (budget {cfg.gates.epoch_budget_s:.0f}s x factor "
328
+ f"{cfg.gates.schedule_budget_factor}). Refusing to launch: the "
329
+ "GPU-hours are spent whether or not the epoch finishes. Either "
330
+ "raise gates.epoch_budget_s deliberately, or find the step-time "
331
+ "regression first."
332
+ )
333
+ logger.info("schedule ok: %.0fs projected <= %.0fs allowed", projected, allowed)
334
+ return result
335
+
336
+
337
+ def quality_gate(report: dict, cfg) -> GateDecision:
338
+ """Does the new SFT checkpoint reach the golden anchors?
339
+
340
+ Delegates the accuracy-vs-CI comparison to section-10's
341
+ `assert_meets_golden` rather than restating it: that function compares
342
+ against the WILSON LOWER BOUND (the golden numbers are themselves
343
+ measurements with sampling noise, so demanding the point estimate meet
344
+ them would fail correct runs about half the time), and two copies of that
345
+ reasoning would drift.
346
+
347
+ NaN halts. That is the extraction-ceiling incident in gate form: a PERFECT
348
+ policy scored 0/100 for two full PPO runs because extraction was broken,
349
+ and an undefined accuracy that compared False would read as 'above golden'.
350
+ """
351
+ from ..eval.harness import assert_meets_golden
352
+
353
+ golden = {"math500": cfg.gates.golden_math500, "gsm8k": cfg.gates.golden_gsm8k}
354
+ try:
355
+ assert_meets_golden(report, golden)
356
+ except RuntimeError as exc:
357
+ return GateDecision(
358
+ passed=False,
359
+ halt_message=(
360
+ f"{exc}\n\nSTAGE HALTED. Section-12 (DPO) must not start on a "
361
+ "failed SFT gate. Bisect in this order before touching any "
362
+ "hyperparameter -- DATA (is the corpus the rebuilt one, and "
363
+ "does a decoded sample match the training format?), then LOSS "
364
+ "(does fused_ce still match the eager reference?), then "
365
+ "TRAINER (did the trunk-movement probe pass, and did the "
366
+ "schedule run in optimizer steps?), then EVAL (does the golden "
367
+ "regression still reproduce 0.09/0.258?). A full-epoch run "
368
+ "below a 0.35-epoch anchor is near-certain evidence of a "
369
+ "new-stack defect, not a modeling result."
370
+ ),
371
+ detail={"golden": golden, "report": report.get("per_set", {})},
372
+ )
373
+ return GateDecision(passed=True, detail={"golden": golden})
374
+
375
+
376
+ def write_quality_gate_receipt(cfg, decision: "GateDecision", checkpoint: str = "") -> str:
377
+ """Write the receipt section-12 reads before it will start.
378
+
379
+ The contract is narrow on purpose: a JSON object with a BOOLEAN `passed`.
380
+ Section-12's `read_sft_quality_gate` refuses anything else -- absent,
381
+ PENDING, or a truthy string all read as "not passed", because the whole
382
+ point is that a DPO run cannot begin on an SFT stage that did not clear
383
+ its own gate.
384
+ """
385
+ path = cfg.paths.sft_quality_gate_path
386
+ if not _is_rank0():
387
+ # Same rename race as the ledger: one receipt, written by the job.
388
+ return path
389
+ os.makedirs(os.path.dirname(path), exist_ok=True)
390
+ payload = {
391
+ "passed": bool(decision.passed),
392
+ # `detail` is the key SECTION-12's reader quotes in its refusal
393
+ # message. Emitting only `halt_message` would leave their error
394
+ # reading "(no detail recorded)" -- technically a correct refusal,
395
+ # and useless to whoever has to act on it.
396
+ "detail": (
397
+ decision.halt_message.splitlines()[0]
398
+ if decision.halt_message
399
+ else "quality gate passed"
400
+ ),
401
+ "halt_message": decision.halt_message,
402
+ "checkpoint": checkpoint,
403
+ "golden": (decision.detail or {}).get("golden", {}),
404
+ "recorded_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
405
+ }
406
+ tmp = path + ".partial"
407
+ with open(tmp, "w") as f:
408
+ # default=str: this receipt is written AFTER the full epoch and both
409
+ # evals -- a non-serializable value here would discard the run's
410
+ # verdict at maximum cost (the smoke-receipt Tensor crash, attempt 6).
411
+ json.dump(payload, f, indent=1, sort_keys=True, default=str)
412
+ os.replace(tmp, path)
413
+ logger.info("SFT quality-gate receipt written: %s (passed=%s)", path, payload["passed"])
414
+ return path
415
+
416
+
417
+ def assert_build_is_current(manifest_path: str, cfg) -> dict:
418
+ """Refuse to train on a data build that predates the last semantic fix.
419
+
420
+ SECTION-03 REOPENED: every SFT target rendered `# Answer` TWICE -- the raw
421
+ PRM800K final step carries the marker inline and the renderer prepended it
422
+ again. Extraction survived by taking the last match, so nothing failed
423
+ loudly; the cost is training on a format the extraction ceiling never
424
+ exercised.
425
+
426
+ Read honestly from the manifest, with no hardcoded dates: `pins_verified`
427
+ must be True, `pin_violations` must be empty, and `build_version` must
428
+ reach the configured minimum. A MISSING `build_version` raises -- an
429
+ unstamped build is not evidence of a current one.
430
+ """
431
+ if not os.path.isfile(manifest_path):
432
+ raise RuntimeError(
433
+ f"no data build manifest at {manifest_path}. The stage consumes "
434
+ "shard hashes from the build artifact and never rebuilds data; "
435
+ "run scripts/build_data.py first."
436
+ )
437
+
438
+ # Section-03 drops a DEFECTIVE marker beside a build it has condemned.
439
+ # Checked FIRST and independently of build_version: a human-written "do
440
+ # not train on this" is the most direct signal there is, and honouring it
441
+ # costs one stat() while ignoring it costs a 4-hour epoch.
442
+ defective = os.path.join(os.path.dirname(os.path.abspath(manifest_path)), "DEFECTIVE")
443
+ if os.path.isfile(defective):
444
+ with open(defective) as f:
445
+ reason = f.read().strip()
446
+ raise RuntimeError(
447
+ f"the data build at {os.path.dirname(defective)} is marked "
448
+ f"DEFECTIVE by section-03:\n\n{reason}\n\nRefusing to train on "
449
+ "condemned shards."
450
+ )
451
+
452
+ with open(manifest_path) as f:
453
+ manifest = json.load(f)
454
+
455
+ if manifest.get("pins_verified") is not True:
456
+ raise RuntimeError(
457
+ f"{manifest_path}: pins_verified is "
458
+ f"{manifest.get('pins_verified')!r}, not True. The corpus counts "
459
+ "were not corroborated against their pinned values, so the stage "
460
+ "cannot say what it would be training on."
461
+ )
462
+ violations = manifest.get("pin_violations") or []
463
+ if violations:
464
+ raise RuntimeError(
465
+ f"{manifest_path}: pins_verified is True but pin_violations is "
466
+ f"non-empty: {violations}. Trust the violations, not the flag."
467
+ )
468
+
469
+ version = manifest.get("build_version")
470
+ minimum = cfg.gates.min_build_version
471
+ if version is None:
472
+ raise RuntimeError(
473
+ f"{manifest_path} has no `build_version` field, so the stage "
474
+ "cannot tell whether this corpus predates the doubled-`# Answer`-"
475
+ "marker fix. An unstamped build is NOT assumed current: section-03 "
476
+ "stamps build_version (>= 2 after the fix; 1 is everything before "
477
+ "it) and this stage requires at least "
478
+ f"{minimum}. Rebuild the corpus, or add the field if the rebuild "
479
+ "already happened."
480
+ )
481
+ if int(version) < minimum:
482
+ raise RuntimeError(
483
+ f"{manifest_path}: build_version {version} < required {minimum}. "
484
+ "This corpus predates the doubled-`# Answer`-marker fix -- every "
485
+ "SFT target renders the marker twice, which trains a format the "
486
+ "extraction ceiling never exercised. Rebuild before spending "
487
+ "GPU-hours on it."
488
+ )
489
+ logger.info("data build ok: build_version=%s, pins verified", version)
490
+ return manifest
491
+
492
+
493
+ def assert_distinct_devices() -> dict:
494
+ """Every rank must own a DIFFERENT physical GPU. Microseconds, at startup.
495
+
496
+ THE DUPLICATE-GPU DEFECT. torchrun starts four processes and assigns no
497
+ devices; nothing bound a rank to its LOCAL_RANK, so every `.cuda()` landed
498
+ on device 0 and NCCL died with "Duplicate GPU detected : rank 2 and rank 0
499
+ both on CUDA device 1b0" -> ncclInvalidUsage. The symptom appeared deep in
500
+ a 7B forward+backward and read like a sharding or dtype problem; the cause
501
+ was four processes sharing one card.
502
+
503
+ Two checks, and the second is the one that matters. `current_device() ==
504
+ local_rank()` catches the missing bind. But the REAL invariant is that the
505
+ four devices are physically distinct -- a correct-looking local index can
506
+ still collide under an unexpected CUDA_VISIBLE_DEVICES -- so the ranks
507
+ all-gather their device UUIDs and assert uniqueness. UUID rather than
508
+ pci_bus_id: `get_device_properties(i).pci_bus_id` returns the local index
509
+ (0 here), which is exactly the number that would agree while the hardware
510
+ collides.
511
+
512
+ Raises with the full rank -> device map, so the failure names itself
513
+ instead of surfacing 400 lines later as an NCCL error code.
514
+ """
515
+ import torch
516
+
517
+ from ..utils.dist import local_rank, world_size
518
+
519
+ if not torch.cuda.is_available():
520
+ return {"checked": False, "reason": "no CUDA device visible"}
521
+
522
+ current, lr = torch.cuda.current_device(), local_rank()
523
+ uuid = str(getattr(torch.cuda.get_device_properties(current), "uuid", current))
524
+ ws = world_size()
525
+
526
+ import torch.distributed as dist
527
+
528
+ distributed = ws > 1 and dist.is_available() and dist.is_initialized()
529
+
530
+ # THE COLLECTIVE COMES FIRST, and my own both-directions test is why. My
531
+ # first version raised the local mismatch BEFORE gathering: under a real
532
+ # collision ranks 1-3 raised immediately while rank 0 (whose
533
+ # current_device DID equal its local_rank) entered the all-gather alone
534
+ # and waited forever for peers that had already died. A guard against a
535
+ # hang must not be able to hang. So every rank enters the collective
536
+ # unconditionally, then all of them evaluate the SAME gathered map and
537
+ # raise identically.
538
+ gathered: list = [(0, lr, current, uuid)]
539
+ if distributed:
540
+ # GLOO, not the default NCCL group. NCCL cannot complete a collective
541
+ # on ranks that share a device -- it raises ncclInvalidUsage -- so a
542
+ # guard that gathers over NCCL reports the very failure it exists to
543
+ # name, in NCCL's words instead of ours. gloo is CPU-side and does not
544
+ # care about GPU binding, so the duplicate map survives to the error
545
+ # message. Verified: with all four ranks forced onto device 0 this
546
+ # prints the rank->device map; over NCCL it printed "invalid usage".
547
+ group = getattr(assert_distinct_devices, "_guard_group", None)
548
+ if group is None:
549
+ group = dist.new_group(backend="gloo")
550
+ assert_distinct_devices._guard_group = group
551
+ gathered = [None] * ws
552
+ dist.all_gather_object(gathered, (dist.get_rank(), lr, current, uuid), group=group)
553
+
554
+ device_map = {r: {"local_rank": l, "current_device": c, "uuid": u} for r, l, c, u in gathered}
555
+
556
+ mismatched = {r: v for r, v in device_map.items() if v["current_device"] != v["local_rank"]}
557
+ if mismatched:
558
+ raise RuntimeError(
559
+ f"rank binding is wrong on {sorted(mismatched)}: "
560
+ f"{mismatched}. Call align.utils.dist.init_distributed() BEFORE any "
561
+ "CUDA op -- it does init_process_group + set_device(local_rank) -- "
562
+ "and place tensors with torch.device('cuda', local_rank()), never "
563
+ "a bare .cuda()."
564
+ )
565
+
566
+ uuids = [v["uuid"] for v in device_map.values()]
567
+ if len(set(uuids)) != len(uuids):
568
+ collisions: dict = {}
569
+ for r, v in device_map.items():
570
+ collisions.setdefault(v["uuid"], []).append(r)
571
+ dupes = {u: rs for u, rs in collisions.items() if len(rs) > 1}
572
+ raise RuntimeError(
573
+ f"DUPLICATE GPU: {len(uuids)} ranks are sharing "
574
+ f"{len(set(uuids))} physical device(s). Ranks per device: {dupes}. "
575
+ f"Full map: {device_map}. Every rank must bind its own card before "
576
+ "any CUDA allocation; this is the ncclInvalidUsage that surfaces "
577
+ "later as a mysterious failure inside the model's forward."
578
+ )
579
+ logger.info("device binding ok: %d rank(s) on %d distinct GPU(s)", len(uuids), len(set(uuids)))
580
+ return {"checked": True, "world_size": len(uuids), "device_map": device_map}
581
+
582
+
583
+ def derived_global_batch_rows(cfg) -> int:
584
+ """Rows per OPTIMIZER step, derived from the numbers that actually
585
+ produce them: grad_accum x micro_batch_size x world_size."""
586
+ return cfg.optimizer.grad_accum * cfg.train.micro_batch_size * cfg.train.world_size
587
+
588
+
589
+ def assert_batch_arithmetic_consistent(cfg) -> dict:
590
+ """`global_batch_rows` must equal accum x micro x world, or refuse.
591
+
592
+ THE TWO-HORIZONS DEFECT (found pre-launch). configs/sft.toml carried
593
+ grad_accum 4 x micro_batch 3 x world 4 = 48 rows per optimizer step while
594
+ declaring global_batch_rows = 24. Nothing reconciled them, and the two
595
+ numbers feed DIFFERENT consumers: the banner's reported horizon is
596
+ ceil(rows / global_batch_rows), while the Trainer's LR schedule is
597
+ ceil(len(sampler) x epochs / accum) and never reads global_batch_rows at
598
+ all. An LR schedule that ends at half or a third of its reported horizon
599
+ is the iterations-vs-steps incident wearing a new coat -- the run finishes
600
+ at a learning rate nobody chose, and the banner records the intended one.
601
+
602
+ This makes the config internally consistent. It does NOT by itself make
603
+ the banner agree with the Trainer -- see `assert_horizon_agrees`, because
604
+ the length-grouped sampler batches by TOKEN budget, so its batch count is
605
+ not rows/global_batch under any value.
606
+ """
607
+ derived = derived_global_batch_rows(cfg)
608
+ declared = cfg.train.global_batch_rows
609
+ if declared != derived:
610
+ raise RuntimeError(
611
+ f"batch arithmetic is inconsistent: train.global_batch_rows="
612
+ f"{declared} but grad_accum({cfg.optimizer.grad_accum}) x "
613
+ f"micro_batch_size({cfg.train.micro_batch_size}) x world_size("
614
+ f"{cfg.train.world_size}) = {derived}. One of these is wrong and "
615
+ "the stage cannot tell which: they drive DIFFERENT consumers "
616
+ "(the banner's reported horizon vs the optimizer step the trainer "
617
+ "actually takes), so a mismatch means the LR schedule ends "
618
+ f"{max(declared, derived) / min(declared, derived):.1f}x away from "
619
+ "the horizon the banner reports. Set global_batch_rows to "
620
+ f"{derived}, or change accum/micro/world so the product is "
621
+ f"{declared}."
622
+ )
623
+ return {"global_batch_rows": declared, "derived": derived}
624
+
625
+
626
+ def assert_horizon_agrees(sampler_horizon: int, cfg, tolerance: float = 0.0) -> dict:
627
+ """The banner's horizon and the Trainer's must be the SAME number.
628
+
629
+ The Trainer schedules on `ceil(len(sampler) x epochs / accum)`; the banner
630
+ reports `ceil(rows / global_batch_rows) x epochs`. Because the sampler
631
+ batches by token budget rather than row count, these agree only by
632
+ coincidence. Rather than pick one silently, this refuses on disagreement
633
+ and prints both -- an LR horizon is a pre-registered quantity and choosing
634
+ it is a human decision, not a rounding convention.
635
+ """
636
+ from .stage_smoke import _sft_rows
637
+
638
+ config_horizon = scheduler_horizon_optimizer_steps(
639
+ rows=_sft_rows(cfg), global_batch=cfg.train.global_batch_rows, epochs=cfg.train.epochs
640
+ )
641
+ if sampler_horizon != config_horizon:
642
+ spread = max(sampler_horizon, config_horizon) / max(1, min(sampler_horizon, config_horizon))
643
+ raise RuntimeError(
644
+ f"HORIZON MISMATCH: the trainer will schedule over "
645
+ f"{sampler_horizon} optimizer steps (len(sampler) x epochs / "
646
+ f"accum) but the config projects {config_horizon} "
647
+ f"(rows / global_batch_rows x epochs) -- {spread:.2f}x apart. The "
648
+ "LR schedule is a pre-registered quantity; the stage refuses to "
649
+ "guess which horizon was intended. Reconcile global_batch_rows "
650
+ "with the sampler's token budget, or set the horizon explicitly."
651
+ )
652
+ return {"horizon_optimizer_steps": sampler_horizon}
653
+
654
+
655
+ def stamp_golden_report(path: str, repo_root: Optional[str] = None) -> str:
656
+ """Stamp the golden report with the hash of the eval source that PRODUCED it (audit U01-9).
657
+
658
+ Called by `scripts/eval.py --mode=golden` right after the run (also when the gate raised: the
659
+ failing report is evidence too). `record_golden_from_report` refuses a report whose stamp is
660
+ not the current eval source, so editing align/eval/ and re-recording from the old report
661
+ cannot re-certify the new code. Atomic rewrite; returns the stamped hash.
662
+ """
663
+ h = eval_source_hash(repo_root)
664
+ with open(path, "rb") as f:
665
+ report = json.loads(f.read())
666
+ report["eval_source_hash"] = h
667
+ tmp = path + ".partial"
668
+ with open(tmp, "w") as f:
669
+ json.dump(report, f, indent=1, default=str)
670
+ f.flush()
671
+ os.fsync(f.fileno())
672
+ os.replace(tmp, path)
673
+ return h
674
+
675
+
676
+ def record_golden_from_report(
677
+ ledger: GateLedger, report_path: Optional[str] = None, cfg=None
678
+ ) -> dict:
679
+ """Bridge the golden-regression RUN to the gate LEDGER, by re-deciding.
680
+
681
+ THE MISSING-BRIDGE DEFECT (pre-launch): `scripts/eval.py --mode=golden`
682
+ writes its report and passes or raises IN PROCESS; nothing ever wrote the
683
+ verdict into the ledger. `require_golden_receipt` reads
684
+ `ledger.is_passed("golden_regression")`, so after a PASSING golden run the
685
+ SFT stage would still have refused on PENDING.
686
+
687
+ VERIFY FROM EVIDENCE, NOT FROM A FLAG. This does not take "the run
688
+ exited 0" as the receipt -- it loads the persisted report and re-runs the
689
+ verdict through section-10's own `assert_meets_golden`, so there is ONE
690
+ authority for what meeting the golden numbers means. A report that no
691
+ longer passes cannot leave a stale PASS in the ledger.
692
+
693
+ Three outcomes, and the middle one is the one that gets skipped:
694
+ * report passes -> record PASSED, with the path and sha256 in the
695
+ detail so the receipt names the exact bytes it certified;
696
+ * report FAILS -> record FAILED with the failure text. Failing is not
697
+ not-yet-run: a recorded failure BLOCKS every later gate, whereas
698
+ leaving it unrecorded would let a rerun quietly try again;
699
+ * report absent -> record NOTHING, leaving the gate PENDING. The run
700
+ has not happened; inventing a verdict from a missing file is the
701
+ failure this whole gate exists to prevent.
702
+
703
+ sha256 is computed inline rather than via `receipts.sha256_file` to keep
704
+ this module importable above the resource guards -- see the module header.
705
+ """
706
+ from ..eval.golden import GOLDEN, GOLDEN_N, assert_meets_golden
707
+
708
+ path = report_path or (cfg.paths.golden_report_path if cfg is not None else None)
709
+ if not path:
710
+ raise ValueError("record_golden_from_report needs a report path or a cfg")
711
+
712
+ if not os.path.isfile(path):
713
+ logger.warning(
714
+ "golden report %s is absent; leaving golden_regression PENDING. "
715
+ "Run `scripts/eval.py --mode=golden` before the stage.",
716
+ path,
717
+ )
718
+ return {"recorded": False, "passed": None, "path": path, "reason": "report absent"}
719
+
720
+ with open(path, "rb") as f:
721
+ raw = f.read()
722
+ digest = hashlib.sha256(raw).hexdigest()
723
+ report = json.loads(raw)
724
+
725
+ # U01-9: the ledger drops the golden pass when align/eval/ changes (`GateLedger._load`), but a
726
+ # report produced by the OLD source would otherwise be re-recorded as a fresh PASS here. The
727
+ # report must name the source that produced it, and it must be the source we are running.
728
+ stamped = report.get("eval_source_hash") if isinstance(report, dict) else None
729
+ current = ledger.eval_source_hash or eval_source_hash()
730
+ if stamped is None:
731
+ logger.warning(
732
+ "golden report %s carries no eval_source_hash (it predates producer stamping): "
733
+ "cannot verify it was produced by the current align/eval/ source",
734
+ path,
735
+ )
736
+ elif stamped != current:
737
+ logger.error(
738
+ "golden report %s was produced by eval source %s but align/eval/ is now %s; refusing to "
739
+ "re-certify it. Re-run `scripts/eval.py --mode=golden`.",
740
+ path,
741
+ stamped,
742
+ current,
743
+ )
744
+ return {
745
+ "recorded": False,
746
+ "passed": None,
747
+ "path": path,
748
+ "reason": f"golden report produced by a different eval source ({stamped} != {current})",
749
+ }
750
+
751
+ try:
752
+ assert_meets_golden(report, GOLDEN, expected_n=GOLDEN_N)
753
+ except (RuntimeError, AssertionError) as exc:
754
+ detail = json.dumps({"report": path, "sha256": digest, "failure": str(exc)[:600]})
755
+ ledger.record("golden_regression", passed=False, detail=detail)
756
+ logger.error("golden regression FAILED per %s: %s", path, exc)
757
+ return {
758
+ "recorded": True,
759
+ "passed": False,
760
+ "path": path,
761
+ "sha256": digest,
762
+ "failure": str(exc),
763
+ }
764
+
765
+ detail = json.dumps(
766
+ {
767
+ "report": path,
768
+ "sha256": digest,
769
+ "golden": GOLDEN,
770
+ "expected_n": GOLDEN_N,
771
+ "per_set": report.get("per_set", {}),
772
+ },
773
+ default=str,
774
+ )
775
+ ledger.record("golden_regression", passed=True, detail=detail)
776
+ logger.info("golden regression PASSED per %s (sha256 %s)", path, digest[:12])
777
+ return {"recorded": True, "passed": True, "path": path, "sha256": digest}
778
+
779
+
780
+ def require_golden_receipt(ledger: GateLedger, cfg) -> None:
781
+ """The golden regression must have PASSED, not merely been attempted.
782
+
783
+ PENDING is never passed. This is the precondition that keeps a defective
784
+ eval stack from certifying everything downstream of it.
785
+ """
786
+ if not cfg.gates.require_golden_receipt:
787
+ logger.warning(
788
+ "gates.require_golden_receipt is False -- training will "
789
+ "start without a validated eval stack"
790
+ )
791
+ return
792
+ if not ledger.is_passed("golden_regression"):
793
+ raise GateOrderError(
794
+ "the golden-regression gate has not passed (it reads "
795
+ f"{ledger.status()['golden_regression']!r}). Refusing to train: "
796
+ "that run is what validates the eval stack -- verifier, prompt "
797
+ "template, extraction, inference path -- against numbers that are "
798
+ "already known. Without it, every accuracy this stage produces is "
799
+ "unfalsifiable. Run `scripts/eval.py --mode=golden` first."
800
+ )
801
+
802
+
803
+ # ---------------------------------------------------------------------------
804
+ # banner
805
+ # ---------------------------------------------------------------------------
806
+
807
+
808
+ def assemble_sft_banner(
809
+ cfg,
810
+ attention_backend: str,
811
+ optimizer_impl: str,
812
+ dataset_hashes: Optional[dict],
813
+ ceiling_results: Optional[dict],
814
+ probe_report: Optional[dict],
815
+ rss_projection: Optional[dict],
816
+ mde: Optional[dict],
817
+ scheduler_horizon_optimizer_steps: Optional[int],
818
+ scheduler_horizon_rows_derived: Optional[int] = None,
819
+ ) -> dict:
820
+ """Every mandatory banner value, resolved or explicitly PENDING/NaN.
821
+
822
+ `beta` is NaN for SFT rather than 0.0: 0.0 is a legitimate DPO beta, so a
823
+ zero here would be indistinguishable from a real setting.
824
+
825
+ THE HORIZON IS THE TRAINER'S, NOT THE CONFIG'S. `scheduler_horizon_
826
+ optimizer_steps` must be the sampler-derived number the scheduler will
827
+ actually run; the rows/global_batch projection rides beside it under a
828
+ LABELED secondary key. A banner that records a horizon the scheduler does
829
+ not run is the config-translation value-drift class in its purest form --
830
+ the record looks right and the run is something else. A mismatch is LOGGED
831
+ loudly here rather than raised; the hard refusal
832
+ (`assert_horizon_agrees`) is wired after the first SFT run so it cannot
833
+ become a fresh launch blocker.
834
+ """
835
+ banner = {
836
+ "attention_backend": attention_backend,
837
+ "loss_type": "sft_ce",
838
+ "beta": NAN,
839
+ "lr": cfg.optimizer.lr,
840
+ "scheduler_horizon_optimizer_steps": (
841
+ scheduler_horizon_optimizer_steps
842
+ if scheduler_horizon_optimizer_steps is not None
843
+ else PENDING
844
+ ),
845
+ "dataset_hashes": dataset_hashes,
846
+ "ceiling_results": ceiling_results,
847
+ "rss_budget": rss_projection,
848
+ "pinned_staging_gb": (rss_projection or {}).get("pinned_staging_gb", PENDING),
849
+ "probe_step_time_s": (probe_report or {}).get("mean_step_time_s", PENDING),
850
+ "mde": mde,
851
+ "optimizer_impl": optimizer_impl,
852
+ "grad_accum": cfg.optimizer.grad_accum,
853
+ "probe_p90_step_time_s": (probe_report or {}).get("p90_step_time_s", PENDING),
854
+ "scheduler_horizon_rows_derived": (
855
+ scheduler_horizon_rows_derived
856
+ if scheduler_horizon_rows_derived is not None
857
+ else PENDING
858
+ ),
859
+ }
860
+ if (
861
+ scheduler_horizon_rows_derived is not None
862
+ and scheduler_horizon_optimizer_steps is not None
863
+ and scheduler_horizon_rows_derived != scheduler_horizon_optimizer_steps
864
+ ):
865
+ logger.warning(
866
+ "HORIZON DIVERGENCE: the scheduler will run %s optimizer steps "
867
+ "(sampler-derived, AUTHORITATIVE) but rows/global_batch_rows "
868
+ "projects %s -- %.2fx apart. The banner records the former "
869
+ "because that is what runs. Reconcile global_batch_rows with the "
870
+ "sampler's token budget; the hard refusal lands after this run.",
871
+ scheduler_horizon_optimizer_steps,
872
+ scheduler_horizon_rows_derived,
873
+ max(scheduler_horizon_optimizer_steps, scheduler_horizon_rows_derived)
874
+ / max(1, min(scheduler_horizon_optimizer_steps, scheduler_horizon_rows_derived)),
875
+ )
876
+ # Raises rather than printing a partial banner: a run that cannot answer
877
+ # its own question does not begin.
878
+ assert_banner_complete(banner)
879
+ return banner
880
+
881
+
882
+ def assert_probe_resolved(banner: dict) -> None:
883
+ """A stage that REQUIRES the probe refuses to launch on PENDING.
884
+
885
+ Banner completeness and banner resolution are different properties: every
886
+ key being present is not the same as every value having been measured.
887
+ """
888
+ pending = [
889
+ k
890
+ for k in ("probe_step_time_s", "rss_budget", "scheduler_horizon_optimizer_steps")
891
+ if banner.get(k) == PENDING or banner.get(k) is None
892
+ ]
893
+ if pending:
894
+ raise RuntimeError(
895
+ f"refusing to launch: {pending} still read PENDING. The 20-step "
896
+ "probe and the RSS projection must be MEASURED before the epoch "
897
+ "starts -- their whole purpose is to stop a run that cannot fit "
898
+ "its schedule or its host RAM."
899
+ )
900
+ step_time = banner["probe_step_time_s"]
901
+ if isinstance(step_time, float) and math.isnan(step_time):
902
+ raise RuntimeError(
903
+ "probe_step_time_s is NaN: the probe ran but measured nothing. "
904
+ "Refusing to launch on an unmeasured schedule."
905
+ )
906
+
907
+
908
+ # ---------------------------------------------------------------------------
909
+ # CLI contract + stage drivers (thin; scripts/sft.py owns the side effects)
910
+ # ---------------------------------------------------------------------------
911
+
912
+
913
+ def parse_args(argv: Optional[list[str]] = None):
914
+ """`torchrun --nproc_per_node=4 scripts/sft.py configs/sft.toml
915
+ --optimizer.lr=1e-5` -- first positional is the TOML, the rest are dotted
916
+ overrides handed to section-01's loader (struct mode on, so a typo'd key
917
+ raises at launch instead of training with the default it failed to
918
+ override).
919
+
920
+ Parsed by hand rather than with argparse: `--optimizer.lr=1e-5` is not an
921
+ argparse option, it is a dotted-override entry (`section.field=value`), and argparse rejects
922
+ every one of them as unrecognized.
923
+ """
924
+ import sys as _sys
925
+
926
+ argv = list(_sys.argv[1:] if argv is None else argv)
927
+ if "-h" in argv or "--help" in argv:
928
+ print(__doc__)
929
+ raise SystemExit(0)
930
+
931
+ positional = [a for a in argv if not a.startswith("-")]
932
+ overrides = [a.lstrip("-") for a in argv if a.startswith("-")]
933
+ if not positional:
934
+ raise SystemExit(
935
+ "usage: sft.py <config.toml> [--section.field=value ...]\n"
936
+ "The stage TOML is required: a stage that picks its own defaults "
937
+ "is a stage nobody can reproduce."
938
+ )
939
+ if len(positional) > 1:
940
+ raise SystemExit(
941
+ f"expected exactly one config TOML, got {positional}. Extra "
942
+ "positional arguments are silently ignored by most launchers, "
943
+ "which is how a run ends up using the wrong config."
944
+ )
945
+ bad = [o for o in overrides if "=" not in o]
946
+ if bad:
947
+ raise SystemExit(f"overrides must be dotted key=value pairs; got {bad}")
948
+ return type("Args", (), {"config": positional[0], "overrides": overrides})()
949
+
950
+
951
+ def run_entry_gates(
952
+ cfg,
953
+ ledger: GateLedger,
954
+ smoke_steps: int = 10,
955
+ probe_steps: int = 20,
956
+ mem_available_gb: Optional[float] = None,
957
+ optimizer_steps_planned: Optional[int] = None,
958
+ ) -> dict:
959
+ """The M5 entry sequence, in order: 7B smoke -> 20-step probe -> RSS
960
+ preflight. GPU-only; imported lazily so this module stays torch-free."""
961
+ from .stage_smoke import run_smoke_and_probe # GPU-only helpers
962
+
963
+ require_golden_receipt(ledger, cfg)
964
+ assert_batch_arithmetic_consistent(cfg)
965
+
966
+ smoke = run_smoke_and_probe(cfg, smoke_steps=smoke_steps, probe_steps=probe_steps)
967
+ ledger.record("smoke_7b", passed=True, detail=json.dumps(smoke["smoke"], default=str))
968
+ ledger.record("probe_20step", passed=True, detail=json.dumps(smoke["probe"], default=str))
969
+
970
+ steps = optimizer_steps_planned or smoke["optimizer_steps_planned"]
971
+ schedule_check(smoke["probe"], cfg, optimizer_steps_planned=steps)
972
+ rss = rss_preflight(cfg, mem_available_gb=mem_available_gb)
973
+ ledger.record(
974
+ "rss_preflight",
975
+ passed=True,
976
+ detail=json.dumps({k: v for k, v in rss.items() if k != "breakdown"}),
977
+ )
978
+
979
+ return assemble_sft_banner(
980
+ cfg,
981
+ attention_backend=smoke["attention_backend"],
982
+ optimizer_impl=smoke["optimizer_impl"],
983
+ dataset_hashes=smoke["dataset_hashes"],
984
+ ceiling_results=smoke["ceiling_results"],
985
+ probe_report=smoke["probe"],
986
+ rss_projection=rss["breakdown"],
987
+ mde=smoke["mde"],
988
+ scheduler_horizon_optimizer_steps=steps,
989
+ scheduler_horizon_rows_derived=smoke.get("optimizer_steps_rows_derived"),
990
+ )
991
+
992
+
993
+ def evaluate_and_gate(cfg, ledger: GateLedger, checkpoint: str) -> GateDecision:
994
+ """Run the post-training eval and apply the quality gate, recording both."""
995
+ from .stage_smoke import run_stage_eval # GPU-only helper
996
+
997
+ report = run_stage_eval(cfg, checkpoint)
998
+ ledger.record("eval", passed=True, detail=json.dumps(report.get("per_set", {}), default=str))
999
+ decision = quality_gate(report, cfg)
1000
+ ledger.record("quality_gate", passed=decision.passed, detail=decision.halt_message[:400])
1001
+ write_quality_gate_receipt(cfg, decision, checkpoint=checkpoint)
1002
+ return decision
code/align/train/stage_smoke.py ADDED
@@ -0,0 +1,530 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GPU-only stage helpers: the 7B smoke, the throughput probe, the stage eval.
2
+
3
+ Separated from `stage_gates.py` so that module stays importable above the
4
+ resource guards. Everything here touches torch, the 7B weights, or the eval
5
+ harness, so nothing here runs in the CPU suite -- the gate DECISIONS these
6
+ feed are what the CPU tests cover.
7
+
8
+ The peak-memory assertion lives here rather than in the unit tests because it
9
+ is only meaningful at 7B on the real topology: the tensor it proves we avoid
10
+ is `[B*T, 32768]`, which at seq 1024 x micro-batch 8 is 1.0GB in bf16 plus
11
+ another 1.0GB of gradient. A unit-scale check cannot see that.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import time
18
+ from typing import Optional
19
+
20
+ import torch
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ def _load_snapshot_state(snapshot: str) -> dict:
26
+ """HF-layout state dict from a snapshot dir, single-file OR sharded, cast
27
+ to bf16 shard-by-shard.
28
+
29
+ The pinned Mistral-7B-v0.3 snapshot is THREE shards plus
30
+ `model.safetensors.index.json`; `transformers.from_pretrained` used to
31
+ resolve that. The layout logic (and the index-vs-shards completeness
32
+ check) now lives in `ckpt.load_hf_safetensors`, so every caller pointed at
33
+ a base snapshot gets the same behaviour; this wrapper only pins the dtype.
34
+ """
35
+ from ..model import checkpoint as ckpt
36
+
37
+ return ckpt.load_hf_safetensors(snapshot, dtype=torch.bfloat16)
38
+
39
+
40
+ def build_stage_model(
41
+ cfg, attn_implementation: Optional[str] = None, weights_dir: Optional[str] = None
42
+ ):
43
+ """The real 7B model, FSDP2-sharded per block with activation
44
+ checkpointing. Wrapping happens HERE (the stage), never inside the
45
+ Trainer -- the Trainer receives an already-wrapped model, which is what
46
+ keeps it identical between the SFT and DPO stages.
47
+
48
+ `weights_dir` names an HF-layout checkpoint whose weights replace the
49
+ pinned snapshot's (the architecture still comes from the snapshot's
50
+ config.json, which a full-FT checkpoint shares). Without it the model is
51
+ the PRETRAINED BASE -- right for training, wrong for evaluating a trained
52
+ checkpoint (G3 audit U01-4: the stage-11 gate scored the base)."""
53
+ from ..model import checkpoint as ckpt
54
+ from ..model.hf_config import read_config
55
+ from ..model.mistral import NmoMistralForCausalLM
56
+ from . import fsdp
57
+
58
+ # Local only -- never let a stage reach the hub and start a 15GB download
59
+ # onto a shared box mid-launch. `resolve_snapshot_dir` accepts the
60
+ # configured `snapshots/` parent and resolves the one hash dir under it.
61
+ snapshot = ckpt.resolve_snapshot_dir(cfg.paths.model_snapshot)
62
+ # The pre-port path went through transformers' `from_pretrained` (and its
63
+ # MistralConfig defaults); `read_config` states every architecture field
64
+ # explicitly and refuses a config.json that omits one.
65
+ model_cfg = read_config(snapshot)
66
+ model_cfg.attn_implementation = attn_implementation or cfg.model.attn_implementation
67
+ # Shards are read straight from safetensors (the snapshot is bf16 on disk;
68
+ # the cast is a no-op there and a safety net for an fp32 checkpoint).
69
+ hf_state = _load_snapshot_state(weights_dir if weights_dir is not None else snapshot)
70
+ model = NmoMistralForCausalLM.from_state_dict(
71
+ model_cfg,
72
+ ckpt.hf_to_nmo(hf_state),
73
+ attn_implementation=model_cfg.attn_implementation,
74
+ )
75
+ del hf_state
76
+
77
+ # Onto THIS rank's device before sharding. The weights arrive on CPU from
78
+ # safetensors, and `fully_shard` on CPU parameters shards the wrong
79
+ # thing; the batches are already `.cuda()`. Uses the local rank explicitly
80
+ # rather than a bare `.cuda()`, which is device 0 for every rank.
81
+ from ..utils.dist import local_rank
82
+
83
+ device = torch.device("cuda", local_rank()) if torch.cuda.is_available() else None
84
+ if device is not None:
85
+ torch.cuda.set_device(device)
86
+ model = model.to(device)
87
+
88
+ fsdp.apply_activation_checkpointing(model)
89
+ fsdp.apply_fsdp(model)
90
+ return model
91
+
92
+
93
+ def assert_chunked_loss_memory(cfg, model, batch) -> dict:
94
+ """The chunked-loss peak-memory assertion, at 7B scale.
95
+
96
+ MEASURES THE LOSS, NOT THE STEP -- and the first 7B run is why. I had
97
+ wrapped the whole adapter forward+backward, so the measured peak was
98
+ 6103 MiB against a [1002, 32768] logit tensor of 125 MiB: a 40x "failure"
99
+ that was really the 7B model's own activations and FSDP all-gathers being
100
+ attributed to the loss. The gate asserts that CHUNKING controls the loss's
101
+ memory; feeding it the model's memory makes it unsatisfiable by any
102
+ implementation, which is the same unsatisfiable-gate trap section-05
103
+ already hit once with the lm_head gradient.
104
+
105
+ So the hidden states are produced OUTSIDE the measured region and
106
+ detached, and the lm_head weight is materialised once via `full_tensor()`
107
+ (it is a DTensor under FSDP2 and the fused loss needs the whole thing).
108
+ What remains inside the measurement is exactly the chunked loss and its
109
+ backward.
110
+
111
+ The full step's peak is still REPORTED, as `full_step_peak_bytes` -- it is
112
+ the number that tells us what a rank actually costs -- it just is not what
113
+ this gate asserts on.
114
+ """
115
+ from ..kernels.fused_ce import (
116
+ IGNORE_INDEX,
117
+ assert_chunking_saves,
118
+ assert_peak_memory_below_full_logits,
119
+ fused_linear_cross_entropy,
120
+ lm_head_grad_allowance,
121
+ )
122
+ from .sft_loss import make_sft_loss_adapter
123
+
124
+ input_ids, labels = batch
125
+ target = labels[:, 1:]
126
+
127
+ # 1. The real step, executed and measured for the record (this is also the
128
+ # end-to-end wiring check: FSDP2 hooks, checkpointing, fused path).
129
+ torch.cuda.reset_peak_memory_stats()
130
+ out = make_sft_loss_adapter(require_fused=True)(model, (input_ids, labels))
131
+ out.loss.backward()
132
+ torch.cuda.synchronize()
133
+ full_step_peak = int(torch.cuda.max_memory_allocated())
134
+ model.zero_grad(set_to_none=True)
135
+ loss_value = float(out.loss.detach())
136
+ del out
137
+
138
+ # 2. The gate: the chunked loss in isolation, over detached hidden states.
139
+ with torch.no_grad():
140
+ hidden = model(input_ids=input_ids, return_hidden_states=True)
141
+ h = hidden[:, :-1].detach().clone().requires_grad_(True)
142
+ del hidden
143
+ # n_rows DERIVED FROM THE TENSOR, never from the config. The measured
144
+ # batch is 1002 rows where seq_len x micro_batch would predict 3072 --
145
+ # the sampler is token-budgeted -- and a budget computed from the config
146
+ # would silently be 3x the shape actually being measured.
147
+ n_rows = int(h.reshape(-1, h.shape[-1]).shape[0])
148
+ w = model.lm_head.weight
149
+ w_full = w.full_tensor() if hasattr(w, "full_tensor") else w
150
+ w_full = w_full.detach().clone().requires_grad_(True)
151
+
152
+ def _loss_only():
153
+ loss = fused_linear_cross_entropy(h, w_full, target, ignore_index=IGNORE_INDEX)
154
+ loss.backward()
155
+ return loss
156
+
157
+ report = assert_peak_memory_below_full_logits(
158
+ _loss_only,
159
+ n_rows,
160
+ cfg.model.vocab_size,
161
+ device=h.device,
162
+ # DTYPE-AWARE, and section-05's gate now refuses the alternative. The
163
+ # fp32 default (8 bytes/element) came to 1024 MiB, which EXCEEDED the
164
+ # entire 775.8 MiB measured peak -- so `attributable` clamped to zero
165
+ # and the gate passed no matter what the loss did. Under FSDP2
166
+ # mixed-precision the lm_head is bf16 with fp32 gradient accumulation:
167
+ # 6 bytes/element, 768 MiB. That leaves a real 7.8 MiB attributable
168
+ # against a 125.2 MiB budget -- a measurement instead of a rubber stamp.
169
+ # `param_dtype` is the lm_head's own dtype under FSDP2
170
+ # mixed-precision (bf16); `accum_dtype` is the fp32 the fused kernel
171
+ # accumulates d_weight in. Their sum -- 6 bytes/element, 768 MiB -- is
172
+ # what the module provably holds at the instant d_weight is cast to
173
+ # the parameter dtype, which is the peak.
174
+ unavoidable_bytes=lm_head_grad_allowance(
175
+ cfg.model.vocab_size,
176
+ cfg.model.hidden_size,
177
+ param_dtype=torch.bfloat16,
178
+ accum_dtype=torch.float32,
179
+ ),
180
+ label="section-11 M5 7B smoke",
181
+ )
182
+
183
+ # DIFFERENTIAL gate: chunked vs unchunked at the SAME shape, so every
184
+ # confounder cancels and no allowance is needed. An implementation that
185
+ # materialised the full logit tensor would show ~0 saving. This is the
186
+ # check that cannot be made vacuous by a mis-specified allowance, which is
187
+ # the failure the previous version of this gate actually had.
188
+ def _make_fn(chunk_size: int):
189
+ def _run():
190
+ h.grad = None
191
+ w_full.grad = None
192
+ fused_linear_cross_entropy(
193
+ h, w_full, target, chunk_size=chunk_size, ignore_index=IGNORE_INDEX
194
+ ).backward()
195
+
196
+ return _run
197
+
198
+ report["chunking_saves"] = assert_chunking_saves(
199
+ _make_fn,
200
+ n_rows=n_rows,
201
+ vocab=cfg.model.vocab_size,
202
+ chunk_rows=128,
203
+ device=h.device,
204
+ label="section-11 M5 7B smoke (differential)",
205
+ )
206
+
207
+ report["full_step_peak_bytes"] = full_step_peak
208
+ report["step_loss"] = loss_value
209
+ logger.info(
210
+ "7B smoke: loss=%.4f full-step peak=%.0fMiB loss-only peak=%.0fMiB",
211
+ loss_value,
212
+ full_step_peak / 2**20,
213
+ report.get("peak_bytes", 0) / 2**20,
214
+ )
215
+ return report
216
+
217
+
218
+ def resolve_split_dir(shard_dir, split: str):
219
+ """Find the directory holding `<split>-NNNN.receipt.json`, by DISCOVERY.
220
+
221
+ THE HAND-COMPOSED-PATH DEFECT (caught at launch). I wrote
222
+ `ShardDataset(cfg.data.shard_dir, names=[cfg.data.sft_split])`, expecting a
223
+ flat `data/shards/sft-train.receipt.json`. Section-03's real layout is
224
+ `data/shards/sft/train/sft-train-0000.receipt.json`. Resolved by searching
225
+ for the receipts the store itself wrote rather than rebuilding
226
+ `<root>/<group>/<split>` from a naming convention, so a layout change moves
227
+ this by itself and a missing split reports the splits that ARE present.
228
+ """
229
+ from pathlib import Path
230
+
231
+ root = Path(shard_dir)
232
+ if not root.is_dir():
233
+ raise FileNotFoundError(
234
+ f"shard root {root} does not exist; the stage consumes shards "
235
+ "built by scripts/build_data.py and never builds them itself"
236
+ )
237
+
238
+ found: dict[str, "Path"] = {}
239
+ for receipt in root.rglob("*.receipt.json"):
240
+ prefix = receipt.name.split(".receipt.json")[0].rsplit("-", 1)[0]
241
+ found.setdefault(prefix, receipt.parent)
242
+ if split not in found:
243
+ raise FileNotFoundError(
244
+ f"no shards for split {split!r} under {root}. Splits present: "
245
+ f"{sorted(found)}. (The stage never composes this path from a "
246
+ "naming convention -- it looks for the receipts the store wrote.)"
247
+ )
248
+ return found[split]
249
+
250
+
251
+ def open_split(cfg, split: Optional[str] = None):
252
+ """The ONE way this stage opens shards. Both the smoke batches and the
253
+ training sampler go through here, so they cannot disagree about layout."""
254
+ from ..data.store import ShardDataset
255
+
256
+ name = split or cfg.data.sft_split
257
+ return ShardDataset(resolve_split_dir(cfg.data.shard_dir, name))
258
+
259
+
260
+ def build_training_sampler(cfg, rank: int | None = None, world_size: int | None = None):
261
+ """The sampler the Trainer will actually iterate -- THIS RANK's share.
262
+
263
+ Built HERE and reused by scripts/sft.py and scripts/pretrain.py so the
264
+ stage cannot time, project, and then train on three different batch
265
+ streams. The LR horizon is derived from THIS object's length, which is
266
+ therefore the per-rank micro-step count the Trainer really runs.
267
+
268
+ Under world_size > 1 the token-budgeted batch list is sharded by
269
+ `DistributedBatchSampler` (the same seam dpo_stage uses). Before this,
270
+ every rank iterated the full unsharded list: a 4-rank run averaged four
271
+ IDENTICAL gradients -- the throughput of one GPU at the cost of four --
272
+ and scheduled on a horizon 4x the per-rank work. Nothing failed; the loss
273
+ curve was that of a smaller-batch run.
274
+
275
+ `rank`/`world_size` default to the live environment and are explicit so
276
+ the inter-rank contract can be tested in one CPU process.
277
+ """
278
+ from ..data.loader import DistributedBatchSampler, LengthGroupedSampler
279
+ from ..utils.dist import rank as env_rank
280
+ from ..utils.dist import world_size as env_world_size
281
+
282
+ dataset = open_split(cfg)
283
+ lengths = [len(dataset[i].input_ids) for i in range(len(dataset))]
284
+ base = LengthGroupedSampler(
285
+ lengths, max_tokens=cfg.train.seq_len * cfg.train.micro_batch_size, seed=cfg.train.seed
286
+ )
287
+ r = env_rank() if rank is None else int(rank)
288
+ ws = env_world_size() if world_size is None else int(world_size)
289
+ if ws <= 1:
290
+ return base, dataset
291
+ if not 0 <= r < ws:
292
+ raise ValueError(f"rank {r} is outside world_size {ws}")
293
+ return DistributedBatchSampler(base, rank=r, world_size=ws), dataset
294
+
295
+
296
+ def sampler_derived_horizon(cfg, sampler) -> int:
297
+ """The horizon the TRAINER will schedule on: the same arithmetic
298
+ `Trainer.__init__` uses, `ceil(len(sampler) x epochs / accum)`.
299
+
300
+ This is the authoritative number. The rows/global_batch figure is a
301
+ projection over a ROW count, but the sampler batches by TOKEN budget, so
302
+ the two agree only by coincidence -- and the banner must record the
303
+ schedule that runs, not the one that was estimated.
304
+ """
305
+ import math as _math
306
+
307
+ total_micro = len(sampler) * cfg.train.epochs
308
+ return max(1, _math.ceil(total_micro / cfg.optimizer.grad_accum))
309
+
310
+
311
+ def run_smoke_and_probe(cfg, smoke_steps: int = 10, probe_steps: int = 20) -> dict:
312
+ """10-step 7B smoke (with the peak-memory assertion) then the 20-step
313
+ throughput probe. Returns everything the launch banner needs."""
314
+ from ..data.loader import assert_worker_count
315
+ from .offload_adamw import make_optimizer
316
+ from .probes import aggregate_micro_step_times, throughput_report
317
+ from .sft_loss import make_sft_loss_adapter
318
+ from .trainer import scheduler_horizon_optimizer_steps
319
+
320
+ assert_worker_count(cfg.data.num_workers)
321
+ model = build_stage_model(cfg)
322
+ optimizer = make_optimizer(cfg, [p for p in model.parameters() if p.requires_grad])
323
+ adapter = make_sft_loss_adapter(require_fused=True)
324
+
325
+ batches = _stage_batches(cfg, n=smoke_steps + probe_steps)
326
+ memory_report = assert_chunked_loss_memory(cfg, model, batches[0])
327
+
328
+ step_times: list[float] = []
329
+ for i, batch in enumerate(batches):
330
+ t0 = time.perf_counter()
331
+ out = adapter(model, batch)
332
+ (out.loss / cfg.optimizer.grad_accum).backward()
333
+ if (i + 1) % cfg.optimizer.grad_accum == 0:
334
+ optimizer.step()
335
+ optimizer.zero_grad()
336
+ torch.cuda.synchronize()
337
+ elapsed = time.perf_counter() - t0
338
+ if i >= smoke_steps: # the smoke steps are warmup
339
+ step_times.append(elapsed)
340
+
341
+ # AUTHORITATIVE: what the Trainer will actually schedule on.
342
+ sampler, _dataset = build_training_sampler(cfg)
343
+ planned = sampler_derived_horizon(cfg, sampler)
344
+ # SECONDARY, labeled: the rows/global_batch projection, kept beside it so
345
+ # a divergence is visible in the record rather than silently resolved.
346
+ rows_derived = scheduler_horizon_optimizer_steps(
347
+ rows=_sft_rows(cfg), global_batch=cfg.train.global_batch_rows, epochs=cfg.train.epochs
348
+ )
349
+ probe = throughput_report(
350
+ # micro -> optimizer-step durations (task #25): tokens_per_step and
351
+ # `planned` below are per-optimizer-step units.
352
+ step_times_s=aggregate_micro_step_times(step_times, cfg.optimizer.grad_accum),
353
+ tokens_per_step=cfg.train.seq_len
354
+ * cfg.train.micro_batch_size
355
+ * cfg.optimizer.grad_accum
356
+ * cfg.train.world_size,
357
+ optimizer_steps_planned=planned,
358
+ offload_transfer_s=None,
359
+ offload_cpu_step_s=None,
360
+ )
361
+
362
+ return {
363
+ "smoke": {"steps": smoke_steps, **memory_report},
364
+ "probe": probe,
365
+ "optimizer_steps_planned": planned,
366
+ "optimizer_steps_rows_derived": rows_derived,
367
+ "attention_backend": model.attn_implementation,
368
+ "optimizer_impl": getattr(optimizer, "banner_tag", cfg.optimizer.name),
369
+ "dataset_hashes": _manifest_hashes(cfg),
370
+ "ceiling_results": _ceiling_results(cfg),
371
+ "mde": _mde(cfg),
372
+ }
373
+
374
+
375
+ def run_stage_eval(cfg, checkpoint: str) -> dict:
376
+ """Evaluate a checkpoint on MATH-500 + GSM8K under section-10's harness."""
377
+ from ..eval.harness import EvalConfig, load_eval_set, run_eval
378
+ from ..eval.verifier import OutcomeVerifier
379
+
380
+ verifier = OutcomeVerifier()
381
+ reports = {}
382
+ for name, path, limit in (
383
+ ("math500", cfg.paths.math500_path, cfg.eval.math500_n),
384
+ ("gsm8k", cfg.paths.gsm8k_path, cfg.eval.gsm8k_n),
385
+ ):
386
+ problems = load_eval_set(path, limit=limit)
387
+ report = run_eval(
388
+ problems, _stage_generate_fn(cfg, checkpoint), verifier, EvalConfig(), model_name=name
389
+ )
390
+ # EvalReport has no `.results`; per-set scores live in `per_set`,
391
+ # keyed by each problem's source (one key for a single-set call).
392
+ # The old attribute error was latent -- this path never ran to
393
+ # completion before 2026-08-18.
394
+ reports[name] = report.per_set[name]
395
+ return {
396
+ "per_set": reports,
397
+ "config": {
398
+ "mde": _mde(cfg),
399
+ "n_problems": sum(r.get("n", 0) for r in reports.values()),
400
+ "eval_set_hash": _manifest_hashes(cfg),
401
+ },
402
+ }
403
+
404
+
405
+ # -- small helpers the stage script fills in from the build artifact ---------
406
+
407
+
408
+ def _stage_batches(cfg, n: int):
409
+ from ..data.loader import collate_sft
410
+ from ..utils.dist import local_rank
411
+
412
+ dataset = open_split(cfg)
413
+ mb = cfg.train.micro_batch_size
414
+ out = []
415
+ for i in range(n):
416
+ rows = [dataset[(i * mb + j) % len(dataset)] for j in range(mb)]
417
+ batch = collate_sft(rows, pad_token_id=cfg.data.pad_token_id)
418
+ # Explicit device: a bare .cuda() is device 0 for every rank unless
419
+ # set_device already ran, and relying on that ordering is what the
420
+ # duplicate-GPU guard exists to stop us doing.
421
+ dev = torch.device("cuda", local_rank())
422
+ out.append((batch.input_ids.to(dev), batch.labels.to(dev)))
423
+ return out
424
+
425
+
426
+ def _sft_rows(cfg) -> int:
427
+ """Corpus size from the BUILD ARTIFACT, never a literal.
428
+
429
+ The plan says "~11.8K"; the build measured 12,410 (walk- and max_len-
430
+ dependent). The schedule horizon must follow the corpus that actually
431
+ exists, or the LR decays against a row count nobody trained on.
432
+ """
433
+ import json
434
+
435
+ with open(cfg.data.manifest_path) as f:
436
+ counts = json.load(f).get("counts", {})
437
+ rows = counts.get("sft_rows")
438
+ if not rows:
439
+ raise RuntimeError(
440
+ f"{cfg.data.manifest_path} has no counts.sft_rows; the scheduler "
441
+ "horizon cannot be derived and must not be guessed."
442
+ )
443
+ return int(rows)
444
+
445
+
446
+ def _manifest_hashes(cfg) -> dict:
447
+ import json
448
+
449
+ with open(cfg.data.manifest_path) as f:
450
+ return json.load(f).get("hashes", {})
451
+
452
+
453
+ def _ceiling_results(cfg) -> dict:
454
+ """The extraction ceiling, re-run against THIS stage's eval sets.
455
+
456
+ `preflight_extraction` returns NaN with a loud warning when no source
457
+ exists, and a NaN here is what the banner shows and the gate refuses --
458
+ which is the point: an outcome signal nobody ceiling-tested is how a
459
+ PERFECT policy scored 0/100 for two full PPO runs.
460
+ """
461
+ from ..eval.roundtrip import preflight_extraction
462
+
463
+ return {
464
+ "extraction_roundtrip": preflight_extraction(
465
+ [cfg.paths.math500_path, cfg.paths.gsm8k_path], eos="</s>", label="section-11 SFT stage"
466
+ )
467
+ }
468
+
469
+
470
+ def _mde(cfg) -> dict:
471
+ from ..eval.metrics import min_detectable_effect
472
+
473
+ return {
474
+ "math500": min_detectable_effect(cfg.eval.math500_n),
475
+ "gsm8k": min_detectable_effect(cfg.eval.gsm8k_n),
476
+ }
477
+
478
+
479
+ def _stage_generate_fn(cfg, checkpoint: str):
480
+ """Bind section-09's Engine to the harness's `generate_fn(prompts, cfg,
481
+ seeds)` contract, over the weights IN `checkpoint`.
482
+
483
+ THE BASE-MODEL-GATE DEFECT (G3 audit U01-4). This used to build the model
484
+ from `cfg.paths.model_snapshot` and use `checkpoint` only to find
485
+ tokenizer.json, so the post-epoch quality gate (and the receipt section-12
486
+ reads) scored the untouched base model; and its signature
487
+ `(prompts, seeds=None, **kw)` could not even accept the harness's three
488
+ positional arguments. Generation is the golden gate's greedy path
489
+ (`golden_generate_fn`), so the numbers are comparable to the anchors the
490
+ gate compares against.
491
+
492
+ PAIRED SEEDS. The Engine owns ONE sampling generator per instance, so
493
+ per-problem seeds cannot be threaded through it. Under greedy decoding
494
+ (the eval default) the seeds are irrelevant and dropping them is exact; a
495
+ SAMPLED eval would silently decorrelate the arms and invalidate every
496
+ McNemar comparison, so it is refused instead.
497
+ """
498
+ import os
499
+
500
+ from ..eval.golden import golden_generate_fn
501
+ from ..model.checkpoint import resolve_snapshot_dir
502
+ from ..tokenizer import load_tokenizer
503
+
504
+ # Weights-only checkpoints (pre tokenizer-copy fix) have no tokenizer.json
505
+ # -- that crash cost stage-11's first post-epoch eval. Vocab never changes
506
+ # during full-FT, so the pinned snapshot's tokenizer IS the checkpoint's.
507
+ # (`load_tokenizer` reads tokenizer.json directly; there is no slow
508
+ # sentencepiece-conversion path left to fall into.)
509
+ tok_src = checkpoint
510
+ if not os.path.isfile(os.path.join(checkpoint, "tokenizer.json")):
511
+ tok_src = resolve_snapshot_dir(cfg.paths.model_snapshot)
512
+ logger.warning(
513
+ "checkpoint %s has no tokenizer.json; loading tokenizer from the pinned snapshot %s",
514
+ checkpoint,
515
+ tok_src,
516
+ )
517
+ tokenizer = load_tokenizer(tok_src)
518
+ model = build_stage_model(cfg, weights_dir=checkpoint)
519
+ greedy_fn = golden_generate_fn(model, tokenizer)
520
+
521
+ def generate_fn(prompts, eval_cfg, seeds):
522
+ if eval_cfg.temperature != 0.0:
523
+ raise ValueError(
524
+ f"stage eval is greedy-only: temperature={eval_cfg.temperature} needs the "
525
+ "paired per-problem seeds, which the Engine cannot thread (one generator "
526
+ "per engine); dropping them would invalidate every paired comparison"
527
+ )
528
+ return greedy_fn(prompts, eval_cfg, seeds)
529
+
530
+ return generate_fn
code/align/train/train_module.py ADDED
@@ -0,0 +1,283 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The TrainModule: model + loss adapter + optimizer behind one seam.
2
+
3
+ This is the OLMo-core separation (olmo_core/train/train_module/train_module.py)
4
+ ported as a PATTERN, not as code: the Trainer owns the loop, the schedule, the
5
+ checkpoint orchestration, the instruments and history.jsonl; the TrainModule
6
+ owns everything that touches the model -- forward, backward, the gated
7
+ optimizer step, and the model/optimizer state movement that FSDP2 makes
8
+ non-trivial. The Trainer never calls `.backward()` or `.step()` itself and
9
+ never inspects a DTensor; the TrainModule never opens a file, never logs a
10
+ metric, and never decides WHEN anything happens.
11
+
12
+ Why the seam matters here and not just aesthetically: stage-b GRPO (M-C/M-D)
13
+ needs a second module -- same Trainer loop, same checkpoint orchestration,
14
+ same tripwires-and-history machinery, different train_batch (rollout batch,
15
+ vendored GRPO loss, rho weights). Under the old single-class Trainer that
16
+ would have meant a second trainer or a stage flag forest; under this seam it
17
+ is one new TrainModule subclass.
18
+
19
+ Two contracts are load-bearing and inherited verbatim from the old Trainer:
20
+
21
+ * `train_batch` performs EXACTLY `(out.loss / grad_accum).backward()`. The
22
+ DPO decoupled-backward adapter returns a detached leaf proxy sized so
23
+ that this call applies the trainer's accumulation contract to gradients
24
+ the adapter already planted -- any cleverness here (scaling elsewhere,
25
+ calling backward twice, retaining graphs) silently double-scales DPO
26
+ gradients.
27
+ * The FSDP2 state movement (full gather on save, CPU-slice per-tensor load
28
+ on resume) keeps its exact production-proven form; both carry incident
29
+ history in their docstrings.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import logging
35
+ from dataclasses import dataclass, field
36
+ from typing import Any, Optional, Protocol
37
+
38
+ import torch
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+
43
+ @dataclass
44
+ class LossOutput:
45
+ """What every loss adapter returns.
46
+
47
+ `metrics` carries stage-specific values for the log (DPO's margin,
48
+ logps/chosen, logps/rejected). Undefined entries must be NaN, never 0.0 --
49
+ a 0.0 margin reads as 'no preference learned', which is a finding, not a
50
+ missing measurement.
51
+ """
52
+
53
+ loss: torch.Tensor
54
+ n_tokens: int
55
+ metrics: dict[str, float] = field(default_factory=dict)
56
+
57
+
58
+ class LossAdapter(Protocol):
59
+ """The interface the TrainModule consumes. Section-05 owns the two real
60
+ implementations; neither the Trainer nor the TrainModule imports either.
61
+
62
+ SFT: batch -> chunked fused CE over hidden states, so the
63
+ [B*T, 32768] logit tensor never materialises.
64
+ DPO: batch (with precomputed reference logprobs as constants) ->
65
+ fused DPO loss, reporting margin and both logps in `metrics`.
66
+ """
67
+
68
+ def __call__(self, model: Any, batch: Any) -> LossOutput: ...
69
+
70
+
71
+ LOSS_NORMALIZATIONS = ("micro_mean", "global_token_mean")
72
+
73
+
74
+ def reduce_window_tokens(local_tokens: int) -> int:
75
+ """SUM of `local_tokens` over every rank (gloo group; identity on one process).
76
+
77
+ The denominator of the global token mean: the number of target tokens in the whole accumulation
78
+ window of the WHOLE world. A collective -- every rank must call it at the same program point.
79
+ """
80
+ import torch.distributed as dist
81
+
82
+ if not (dist.is_available() and dist.is_initialized()):
83
+ return int(local_tokens)
84
+ from ..utils.dist import gloo_group
85
+
86
+ g = gloo_group()
87
+ if g is None:
88
+ return int(local_tokens)
89
+ t = torch.tensor([int(local_tokens)], dtype=torch.int64)
90
+ dist.all_reduce(t, op=dist.ReduceOp.SUM, group=g)
91
+ return int(t.item())
92
+
93
+
94
+ def _dp_world_size() -> int:
95
+ import torch.distributed as dist
96
+
97
+ return dist.get_world_size() if (dist.is_available() and dist.is_initialized()) else 1
98
+
99
+
100
+ class TrainModule:
101
+ """Owns the model, the loss adapter, and the optimizer.
102
+
103
+ The default module wraps the existing SFT/DPO path unchanged; stage-b
104
+ GRPO subclasses `train_batch`.
105
+ """
106
+
107
+ def __init__(self, model, loss_adapter: LossAdapter, optimizer, grad_accum: int):
108
+ if grad_accum < 1:
109
+ raise ValueError(f"grad_accum must be >= 1, got {grad_accum}")
110
+ self.model = model
111
+ self.loss_adapter = loss_adapter
112
+ self.optimizer = optimizer
113
+ self.grad_accum = int(grad_accum)
114
+
115
+ # -- the two loop-facing calls -------------------------------------------
116
+
117
+ def train_batch(self, batch, window_tokens: Optional[int] = None) -> LossOutput:
118
+ """Forward + backward for one micro-batch.
119
+
120
+ Scales by accumulation so the gradient matches one large batch rather
121
+ than the sum of `grad_accum` batches. This exact call is the contract
122
+ the DPO decoupled-backward proxy honors -- see the module docstring.
123
+
124
+ `window_tokens` (None = `loss_normalization="micro_mean"`, the original
125
+ behaviour, byte-for-byte the line above) switches to the GLOBAL TOKEN
126
+ MEAN (audit U03-2): the adapter's micro-batch mean is turned back into
127
+ the micro-batch's summed CE (`loss * n_tokens`) and divided by the
128
+ target-token count of the whole window across all ranks. FSDP2/DDP then
129
+ AVERAGE the per-rank gradients over the world, so the local term is
130
+ multiplied by the world size to make that average a SUM:
131
+ grad = (1/N) * sum over every rank and micro-batch of grad(sum CE).
132
+ """
133
+ out = self.loss_adapter(self.model, batch)
134
+ if window_tokens is None:
135
+ (out.loss / self.grad_accum).backward()
136
+ else:
137
+ if window_tokens < 1:
138
+ raise ValueError(f"window_tokens must be >= 1, got {window_tokens}")
139
+ scale = _dp_world_size() * float(out.n_tokens) / float(window_tokens)
140
+ (out.loss * scale).backward()
141
+ return out
142
+
143
+ def optim_step(self) -> bool:
144
+ """Gated optimizer step + zero_grad; True iff the step was applied.
145
+
146
+ The gate (non-finite grads skip the step) lives in the optimizer
147
+ wrapper; this module only reports what happened so the Trainer can
148
+ keep its rule that the schedule counts steps that ACTUALLY happened.
149
+ """
150
+ stepped = self.optimizer.step()
151
+ self.optimizer.zero_grad()
152
+ return stepped
153
+
154
+ def zero_grads(self) -> None:
155
+ self.optimizer.zero_grad()
156
+
157
+ def set_train(self) -> None:
158
+ self.model.train()
159
+
160
+ # -- optimizer telemetry the Trainer logs --------------------------------
161
+
162
+ @property
163
+ def last_grad_norm(self) -> float:
164
+ return self.optimizer.last_grad_norm
165
+
166
+ @property
167
+ def skipped_steps(self) -> int:
168
+ return getattr(self.optimizer, "skipped_steps", 0)
169
+
170
+ # -- state movement -------------------------------------------------------
171
+
172
+ def full_model_state_dict(self):
173
+ """An UNSHARDED, CPU state dict that safetensors can actually write.
174
+
175
+ THE DTENSOR CHECKPOINT DEFECT (found before it could fire, one gate
176
+ after the ledger race). Under FSDP2 `model.state_dict()` returns
177
+ DTensors -- global shape, local shard -- and safetensors dies on them
178
+ with "Attempted to access the data pointer on an invalid python
179
+ storage". Measured on the tiny model at 4 ranks: a [64,64] weight
180
+ comes back as a DTensor holding a [16,64] shard. Nothing in the CPU
181
+ suite can see this; a single process has no sharding.
182
+
183
+ `get_model_state_dict(full_state_dict=True, cpu_offload=True)` is the
184
+ canonical gather. It costs a transient full copy in host RAM per rank,
185
+ which is why the write is rank-0-only rather than the gather being
186
+ skipped.
187
+ """
188
+ import torch.distributed as dist
189
+
190
+ if not (dist.is_available() and dist.is_initialized()):
191
+ return self.model.state_dict()
192
+ from torch.distributed.checkpoint.state_dict import StateDictOptions, get_model_state_dict
193
+
194
+ return get_model_state_dict(
195
+ self.model, options=StateDictOptions(full_state_dict=True, cpu_offload=True)
196
+ )
197
+
198
+ def load_model_state_dict(self, nmo_state: dict) -> None:
199
+ """Load full CPU tensors into a possibly-FSDP2-sharded model.
200
+
201
+ FSDP2 params are DTensors: a plain load_state_dict copies full
202
+ tensors into shards and throws "mixed torch.Tensor and DTensor"
203
+ -- first exercised IN PRODUCTION at the stage-11 tail resume,
204
+ since every earlier launch resumed from nothing. And
205
+ set_model_state_dict(full_state_dict=True) is the wrong mirror
206
+ here, and so is per-tensor distribute_tensor over full tensors:
207
+ both put each WHOLE tensor on GPU before sharding, and the
208
+ varying-shape transients churn reserved memory toward
209
+ full-model size per rank (measured: rank 0 at 14GB against a
210
+ 3.4GB shard; the 4-rank resume OOM'd on a card also carrying
211
+ peers' NCCL IPC mappings). Every rank already read the FULL
212
+ state from disk, so: slice on CPU, ship ONLY the local shard.
213
+ """
214
+ if any(hasattr(p, "to_local") for p in self.model.parameters()):
215
+ from torch.distributed.tensor import Shard, distribute_tensor
216
+
217
+ params = dict(self.model.named_parameters())
218
+ missing = sorted(set(params) - set(nmo_state))
219
+ unexpected = sorted(set(nmo_state) - set(params))
220
+ if missing or unexpected:
221
+ raise KeyError(
222
+ f"resume state mismatch: missing={missing[:5]} unexpected={unexpected[:5]}"
223
+ )
224
+ with torch.no_grad():
225
+ for pname, p in params.items():
226
+ full = nmo_state[pname].to(p.dtype)
227
+ local = p.to_local()
228
+ if tuple(p.placements) == (Shard(0),):
229
+ ws = p.device_mesh.size()
230
+ rk = p.device_mesh.get_local_rank()
231
+ chunk = full.chunk(ws, dim=0)[rk]
232
+ if chunk.shape != local.shape:
233
+ raise RuntimeError(
234
+ f"{pname}: local shard {tuple(local.shape)} != "
235
+ f"chunk {tuple(chunk.shape)} (uneven sharding?)"
236
+ )
237
+ local.copy_(chunk.to(local.device))
238
+ else:
239
+ p.copy_(
240
+ distribute_tensor(full, p.device_mesh, p.placements, src_data_rank=None)
241
+ )
242
+ else:
243
+ self.model.load_state_dict(nmo_state, strict=True)
244
+
245
+ def optim_state_dict(self) -> dict:
246
+ return self.optimizer.state_dict()
247
+
248
+ def load_optim_state_dict(self, sd: dict) -> None:
249
+ self.optimizer.load_state_dict(sd)
250
+
251
+ def reseed_optimizer_from_params(self) -> bool:
252
+ """After a resume that restored weights but NO optimizer state: let an optimizer that owns an
253
+ fp32 master copy (OffloadAdamW) re-seed it from the restored params. True iff it did."""
254
+ fn = getattr(self.optimizer, "reseed_masters_from_params", None)
255
+ if fn is None:
256
+ return False
257
+ fn()
258
+ return True
259
+
260
+ # -- module-owned state that must survive a kill --------------------------
261
+
262
+ def extra_state(self) -> dict:
263
+ """Module-private resume state, saved under `resume.pt["extra_state"]`.
264
+
265
+ The Trainer owns weights, optimizer, schedule and stream position; it
266
+ cannot know about state a SUBCLASS accumulates across steps (the GRPO
267
+ collapse tripwire's baseline window and latch). That state used to die
268
+ with the process: a resume re-measured the baseline on the post-resume
269
+ policy, so a collapse that began before the kill was invisible for
270
+ ~min_steps_before_fire steps. Subclasses override this pair; the default
271
+ is an empty dict so the key always exists and `load_extra_state({})`
272
+ is a no-op, never a KeyError on an older checkpoint's neighbour.
273
+
274
+ Contract: only tensors, dicts, lists and primitives. `resume.pt` is
275
+ read back with `weights_only=True`, so anything else would make the
276
+ checkpoint unloadable -- fail at SAVE time, not at the resume you
277
+ needed it for.
278
+ """
279
+ return {}
280
+
281
+ def load_extra_state(self, sd: dict) -> None:
282
+ """Inverse of `extra_state`. Called BEFORE the scheduler/stream restore."""
283
+ return None
code/align/train/trainer.py ADDED
The diff for this file is too large to render. See raw diff
 
code/align/utils/__init__.py ADDED
File without changes
code/align/utils/dist.py ADDED
@@ -0,0 +1,391 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Thin distributed helpers -- env-var based so they work before
2
+ init_process_group, plus the seeded-determinism policy and the
3
+ wait-condition helper.
4
+
5
+ No framework: torchrun sets RANK/WORLD_SIZE/LOCAL_RANK; we read them.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import random
12
+
13
+
14
+ def rank() -> int:
15
+ return int(os.environ.get("RANK", "0"))
16
+
17
+
18
+ def local_rank() -> int:
19
+ return int(os.environ.get("LOCAL_RANK", "0"))
20
+
21
+
22
+ def world_size() -> int:
23
+ return int(os.environ.get("WORLD_SIZE", "1"))
24
+
25
+
26
+ def is_rank0() -> bool:
27
+ return rank() == 0
28
+
29
+
30
+ #: Default collective timeout. torch's own default is 10 min for NCCL (30 min
31
+ #: for gloo), which is shorter than a 14.5GB checkpoint write on this box
32
+ #: while peers wait in the post-save barrier; 30 min is long enough for that
33
+ #: and short enough that a genuinely wedged rank is reported in the same
34
+ #: sitting rather than after the 5-hour stall of the 2026-08-12 incident.
35
+ DEFAULT_PG_TIMEOUT_S = 1800.0
36
+
37
+ PG_TIMEOUT_ENV = "ALIGN_PG_TIMEOUT_S"
38
+
39
+
40
+ def pg_timeout_s(environ=None) -> float:
41
+ """The process-group timeout in seconds: `ALIGN_PG_TIMEOUT_S` or 30 min.
42
+
43
+ Parsed strictly. A typo ("30m") that silently fell back to the default
44
+ would leave an operator believing a 60-second watchdog was armed; a
45
+ non-positive value would make every collective time out instantly (or,
46
+ in torch, mean "no timeout"). Both raise instead.
47
+ """
48
+ environ = os.environ if environ is None else environ
49
+ raw = environ.get(PG_TIMEOUT_ENV)
50
+ if raw is None or raw.strip() == "":
51
+ return DEFAULT_PG_TIMEOUT_S
52
+ try:
53
+ val = float(raw)
54
+ except ValueError:
55
+ raise ValueError(f"{PG_TIMEOUT_ENV}={raw!r} is not a number of seconds") from None
56
+ if not (val > 0.0) or val == float("inf"):
57
+ raise ValueError(f"{PG_TIMEOUT_ENV}={raw!r} must be a finite number of seconds > 0")
58
+ return val
59
+
60
+
61
+ #: The timeout `init_distributed` armed the default process group with. The
62
+ #: gloo CONTROL group (`gloo_group`) must use the same number -- see there.
63
+ _armed_timeout_s: float | None = None
64
+
65
+
66
+ #: The flight-recorder buffer-size variable was renamed TORCH_NCCL_TRACE_BUFFER_SIZE ->
67
+ #: TORCH_FR_BUFFER_SIZE when the recorder moved out of ProcessGroupNCCL (torch 2.11:
68
+ #: FlightRecorder.hpp:111 looks up {"TORCH_FR_BUFFER_SIZE", "TORCH_NCCL_TRACE_BUFFER_SIZE"}
69
+ #: in that order). Both are read by 2.11; only the new one is the supported name.
70
+ _FR_BUFFER_NEW = "TORCH_FR_BUFFER_SIZE"
71
+ _FR_BUFFER_LEGACY = "TORCH_NCCL_TRACE_BUFFER_SIZE"
72
+ _FR_RENAME_VERSION = (2, 11)
73
+
74
+
75
+ def _torch_version_tuple(version: str | None = None) -> tuple[int, int]:
76
+ """(major, minor) of the installed torch, or of an explicit version string."""
77
+ import re
78
+
79
+ if version is None:
80
+ import torch
81
+
82
+ version = torch.__version__
83
+ m = re.match(r"^(\d+)\.(\d+)", str(version))
84
+ if not m:
85
+ raise ValueError(f"cannot parse a torch version from {version!r}")
86
+ return int(m.group(1)), int(m.group(2))
87
+
88
+
89
+ def configure_nccl_diagnostics(
90
+ run_dir: str | None = None, environ=None, torch_version: str | None = None
91
+ ) -> dict:
92
+ """Turn the PyTorch NCCL flight recorder on, with its dump under the run dir.
93
+
94
+ Must run BEFORE `init_process_group`: the recorder and the watchdog read
95
+ these variables when the NCCL process group is constructed. Defaults only
96
+ (`setdefault`): an operator's explicit export always wins. Returns what it
97
+ set, for the launch banner.
98
+
99
+ Variable names verified against the installed torch 2.11.0 (headers under
100
+ torch/include/torch/csrc/distributed/c10d/ and the strings in
101
+ libtorch_cpu.so / libtorch_cuda.so):
102
+
103
+ * TORCH_FR_BUFFER_SIZE (torch >= 2.11, FlightRecorder.hpp:111) or, below 2.11,
104
+ TORCH_NCCL_TRACE_BUFFER_SIZE (ProcessGroupNCCL.hpp:114; still read as the
105
+ fallback alias on 2.11). Ring-buffer entries;
106
+ the 2.11 default is 2000, which one FSDP2 step with 32 layers can wrap, so we
107
+ ask for 20000. >0 enables the recorder.
108
+ * TORCH_NCCL_DUMP_ON_TIMEOUT (ProcessGroupNCCL.hpp:73). Dump the buffer when
109
+ the watchdog times out. Per the header it must be set together with
110
+ TORCH_NCCL_ENABLE_MONITORING=1 and a buffer size > 0; we do NOT set the
111
+ monitoring variable (its abort-on-stuck-heartbeat side effect is a separate
112
+ decision) and have not re-verified its 2.11 default, so on a build where it
113
+ is off the dump is simply not triggered.
114
+ * TORCH_FR_DUMP_TEMP_FILE (new name) / TORCH_NCCL_DEBUG_INFO_TEMP_FILE (legacy
115
+ name; both strings present in libtorch_cpu.so, neither in a header because the
116
+ dump writer is compiled). The value is a FILE PREFIX; torch appends the
117
+ global rank. Without it the dump goes to /tmp/nccl_trace_rank_<r>, which this
118
+ box forbids (/tmp is noexec and not durable). We set both names so either
119
+ generation of the reader finds it.
120
+
121
+ `run_dir=None` falls back to `<cwd>/nccl_trace` rather than the /tmp default.
122
+
123
+ THE DEPRECATED-NAME DEFECT (H2 review, 2026-10-13): on torch >= 2.11 the buffer
124
+ size is `TORCH_FR_BUFFER_SIZE`; the old `TORCH_NCCL_TRACE_BUFFER_SIZE` is only a
125
+ fallback alias, so exporting it alone arms the recorder through a name the
126
+ next release is free to drop -- and the recorder going dark is invisible until
127
+ the hang it was meant to explain. The legacy name is set ONLY below 2.11, where
128
+ it is the only one the build knows. An operator's explicit export of EITHER name
129
+ wins: the new name outranks the old in torch's lookup, so setting it over an
130
+ operator's legacy export would silently override their value.
131
+ """
132
+ environ = os.environ if environ is None else environ
133
+ new_torch = _torch_version_tuple(torch_version) >= _FR_RENAME_VERSION
134
+ buffer_name = _FR_BUFFER_NEW if new_torch else _FR_BUFFER_LEGACY
135
+ operator_set_buffer = _FR_BUFFER_NEW in environ or _FR_BUFFER_LEGACY in environ
136
+ trace_dir = os.path.join(run_dir or os.getcwd(), "nccl_trace")
137
+ os.makedirs(trace_dir, exist_ok=True)
138
+ prefix = os.path.join(trace_dir, "rank_")
139
+ wanted = {
140
+ buffer_name: "20000",
141
+ "TORCH_NCCL_DUMP_ON_TIMEOUT": "1",
142
+ "TORCH_FR_DUMP_TEMP_FILE": prefix,
143
+ "TORCH_NCCL_DEBUG_INFO_TEMP_FILE": prefix,
144
+ }
145
+ applied = {}
146
+ for k, v in wanted.items():
147
+ if k == buffer_name and operator_set_buffer:
148
+ continue
149
+ if k not in environ:
150
+ environ[k] = v
151
+ applied[k] = v
152
+ return applied
153
+
154
+
155
+ def init_distributed(
156
+ backend: str = "nccl", run_dir: str | None = None, timeout_s: float | None = None
157
+ ) -> None:
158
+ """Idempotent process-group init for torchrun launches; no-op on a
159
+ single process.
160
+
161
+ Always passes an EXPLICIT timeout (`ALIGN_PG_TIMEOUT_S`, default 30 min)
162
+ instead of inheriting torch's backend-dependent default, and, for NCCL,
163
+ arms the flight recorder first (see `configure_nccl_diagnostics`) so a hang
164
+ leaves a per-rank trace of the last collectives instead of only a
165
+ watchdog SIGABRT.
166
+ """
167
+ if world_size() == 1:
168
+ return
169
+ import datetime
170
+
171
+ import torch.distributed as dist
172
+
173
+ if not dist.is_initialized():
174
+ if backend == "nccl":
175
+ configure_nccl_diagnostics(run_dir)
176
+ global _armed_timeout_s
177
+ t = pg_timeout_s() if timeout_s is None else float(timeout_s)
178
+ _armed_timeout_s = t
179
+ dist.init_process_group(backend=backend, timeout=datetime.timedelta(seconds=t))
180
+ import torch
181
+
182
+ # Only NCCL needs a device bound per rank; a gloo group on a box whose
183
+ # GPUs are hidden (the CPU test children) must not touch CUDA.
184
+ if backend == "nccl" and torch.cuda.is_available():
185
+ torch.cuda.set_device(local_rank())
186
+
187
+
188
+ def barrier() -> None:
189
+ """No-op-safe barrier: single process or uninitialized group passes."""
190
+ if world_size() == 1:
191
+ return
192
+ import torch.distributed as dist
193
+
194
+ if dist.is_initialized():
195
+ dist.barrier()
196
+
197
+
198
+ def seed_everything(seed: int, rank_offset: bool = True) -> int:
199
+ """The seeded-determinism policy.
200
+
201
+ `rank_offset=True` gives each rank a distinct-but-derived stream
202
+ (seed + rank) -- required for dataloader shuffling; checkpoint-resume
203
+ tests (section-07) and crosscoder streaming (section-08) rely on this
204
+ exact derivation to reproduce batch order after a restart.
205
+ """
206
+ s = seed + (rank() if rank_offset else 0)
207
+ random.seed(s)
208
+ try:
209
+ import numpy as np
210
+
211
+ np.random.seed(s % 2**32)
212
+ except ImportError:
213
+ pass
214
+ import sys
215
+
216
+ if "torch" in sys.modules: # never import torch just to seed it
217
+ import torch
218
+
219
+ torch.manual_seed(s)
220
+ return s
221
+
222
+
223
+ def find_pids(interpreter_substr: str, script_path: str, exclude_self: bool = True) -> list[int]:
224
+ """Find processes by INTERPRETER + FULL SCRIPT PATH, never bare name.
225
+
226
+ Incident: a stage-4 launcher waited on `pgrep -f <bare name>`, matched
227
+ its own command line, and idled 4 GPUs for 3 hours. Matching both the
228
+ interpreter and the absolute script path (and excluding ourselves and
229
+ our parent shell) cannot self-match.
230
+ """
231
+ me = os.getpid()
232
+ parent = os.getppid()
233
+ hits = []
234
+ for pid_dir in os.listdir("/proc"):
235
+ if not pid_dir.isdigit():
236
+ continue
237
+ pid = int(pid_dir)
238
+ if exclude_self and pid in (me, parent):
239
+ continue
240
+ try:
241
+ with open(f"/proc/{pid}/cmdline", "rb") as f:
242
+ argv = f.read().decode(errors="replace").split("\0")
243
+ except OSError:
244
+ continue
245
+ if len(argv) < 2:
246
+ continue
247
+ if interpreter_substr in os.path.basename(argv[0]) and any(a == script_path for a in argv):
248
+ hits.append(pid)
249
+ return hits
250
+
251
+
252
+ def gloo_group():
253
+ """Cached CPU-side subgroup for object collectives and control-plane
254
+ gathers. None on a single process. gloo, never NCCL: object collectives
255
+ stage through pickled CPU tensors, and NCCL binds them to GPU state that
256
+ the control plane must not depend on.
257
+
258
+ THE UNARMED-WATCHDOG DEFECT. `dist.new_group(backend="gloo")` with no
259
+ `timeout` does NOT inherit the default group's timeout: torch fills in its
260
+ own per-backend default (`_get_default_timeout`, 30 min for gloo) whatever
261
+ `init_process_group` was given. Every stop / HALT / skip-cap decision and
262
+ every checkpoint gather runs on THIS group, so an operator who exported
263
+ `ALIGN_PG_TIMEOUT_S=120` to be told about a wedged rank within two minutes
264
+ still waited 30 on the optimizer-step boundary all-reduce -- the exact
265
+ shape of the 2026-08-12 stall, behind a knob that read as armed. The group
266
+ is therefore built with the timeout `init_distributed` armed the default
267
+ group with (or, when a caller initialised the group itself, the env value).
268
+ """
269
+ import datetime
270
+
271
+ import torch.distributed as dist
272
+
273
+ if world_size() == 1 or not dist.is_initialized():
274
+ return None
275
+ g = getattr(gloo_group, "_group", None)
276
+ if g is None:
277
+ t = _armed_timeout_s if _armed_timeout_s is not None else pg_timeout_s()
278
+ g = dist.new_group(backend="gloo", timeout=datetime.timedelta(seconds=t))
279
+ gloo_group._group = g
280
+ return g
281
+
282
+
283
+ def all_reduce_max(values: list[int]) -> list[int]:
284
+ """Element-wise MAX of a short int vector across ranks, on the gloo group.
285
+
286
+ The control-plane primitive: "did ANY rank ...?" decisions (stop signal,
287
+ wall-clock, HALT, bad step) are made by reducing a 0/1 flag with MAX so that
288
+ every rank acts on the same answer at the same program point. gloo, not
289
+ NCCL, for the same reason as `gloo_group` -- and so a decision never
290
+ queues behind (or deadlocks with) in-flight CUDA collectives. A single
291
+ process returns the input unchanged.
292
+ """
293
+ import torch
294
+ import torch.distributed as dist
295
+
296
+ g = gloo_group()
297
+ if g is None:
298
+ return [int(v) for v in values]
299
+ t = torch.tensor([int(v) for v in values], dtype=torch.int64)
300
+ dist.all_reduce(t, op=dist.ReduceOp.MAX, group=g)
301
+ return [int(v) for v in t.tolist()]
302
+
303
+
304
+ def any_rank(flag: bool) -> bool:
305
+ """True iff `flag` is True on at least one rank (identical on all ranks)."""
306
+ return bool(all_reduce_max([1 if flag else 0])[0])
307
+
308
+
309
+ def all_gather_objects(obj) -> list:
310
+ """`obj` from every rank, rank-ordered, on the gloo group ([obj] on one process)."""
311
+ import torch.distributed as dist
312
+
313
+ g = gloo_group()
314
+ if g is None:
315
+ return [obj]
316
+ out: list = [None] * dist.get_world_size()
317
+ dist.all_gather_object(out, obj, group=g)
318
+ return out
319
+
320
+
321
+ def capture_rng_state() -> dict:
322
+ """This process's RNG state: torch CPU, torch CUDA (current device only),
323
+ python `random`, numpy. Every leaf is a tensor, list, int, float, str or
324
+ None, because `resume.pt` is read back with `weights_only=True`.
325
+
326
+ WHY. `seed_everything` seeds the streams at launch, but a resumed process
327
+ re-seeds from scratch and so replays the first draws of the original run:
328
+ dropout masks, sampling noise and any shuffle that is not a pure function
329
+ of (seed, epoch) restart instead of continuing. The run is then not the run
330
+ its uninterrupted twin would have been. State is per rank (the streams are
331
+ seeded seed + rank).
332
+
333
+ CUDA: only the CURRENT device, and only if CUDA is already initialised.
334
+ `torch.cuda.get_rng_state_all()` would create a context on every visible
335
+ GPU from every rank -- under torchrun that is world_size x n_gpus contexts
336
+ on a box with 23GB cards.
337
+ """
338
+ import torch
339
+
340
+ py = random.getstate()
341
+ out: dict = {
342
+ "torch": torch.get_rng_state(),
343
+ "python": [py[0], list(py[1]), py[2]],
344
+ }
345
+ try:
346
+ import numpy as np
347
+
348
+ ns = np.random.get_state()
349
+ out["numpy"] = {
350
+ "name": str(ns[0]),
351
+ "keys": [int(k) for k in ns[1]],
352
+ "pos": int(ns[2]),
353
+ "has_gauss": int(ns[3]),
354
+ "cached_gaussian": float(ns[4]),
355
+ }
356
+ except ImportError: # pragma: no cover -- numpy is a hard dependency
357
+ pass
358
+ if torch.cuda.is_available() and torch.cuda.is_initialized():
359
+ dev = torch.cuda.current_device()
360
+ out["cuda"] = {str(dev): torch.cuda.get_rng_state(dev)}
361
+ return out
362
+
363
+
364
+ def restore_rng_state(sd: dict) -> None:
365
+ """Inverse of `capture_rng_state`. Missing sections are skipped (an older
366
+ checkpoint or a CPU-only save resumed on GPU)."""
367
+ import torch
368
+
369
+ if "python" in sd:
370
+ ver, keys, gauss = sd["python"]
371
+ random.setstate((int(ver), tuple(int(k) for k in keys), gauss))
372
+ if "numpy" in sd:
373
+ import numpy as np
374
+
375
+ n = sd["numpy"]
376
+ np.random.set_state(
377
+ (
378
+ n["name"],
379
+ np.array(n["keys"], dtype=np.uint32),
380
+ int(n["pos"]),
381
+ int(n["has_gauss"]),
382
+ float(n["cached_gaussian"]),
383
+ )
384
+ )
385
+ if "torch" in sd:
386
+ torch.set_rng_state(sd["torch"].cpu())
387
+ if "cuda" in sd and torch.cuda.is_available():
388
+ for idx, state in sd["cuda"].items():
389
+ if int(idx) < torch.cuda.device_count():
390
+ # lazily applied if CUDA is not initialised yet
391
+ torch.cuda.set_rng_state(state.cpu(), device=int(idx))