File size: 25,431 Bytes
bcc8d35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
#!/usr/bin/env python
"""Merge a LoRA SFT checkpoint into the base, export HF Qwen3, and prove vLLM reads what align wrote.

    CUDA_VISIBLE_DEVICES=0,1 .venv/bin/python scripts/qwen3_export_check.py run \
        --ckpt RUN/checkpoints/micro_N --out-dir .tmp/p2/p2a/export [--keep]

The claim under test: *the file align writes is the model align trained.* Five independent checks,
each of which fails if the export is wrong (the process exit status gates ALL of them, `export_ok`):

  1. MERGE PATH vs TRAINER FILE. align merges the adapter (fp32 B@A * alpha/r, one bf16 cast) into
     the pristine snapshot weights from the adapter stored in the checkpoint's optimizer payload,
     and writes it with `save_hf_qwen3`. The trainer's own checkpoint weight file was built by a
     different route (FSDP gather -> `export_merged_state_dict`). The two must be bit-identical.
  2. vLLM LOADS IT. A separate venv (`.venv-rollout`, vLLM 0.26) loads the exported directory
     unmodified -- sharded safetensors, config.json (rope_theta under both spellings), tokenizer --
     and greedy-generates 64 tokens on 2 prompts, returning the top-20 logprobs at every position.
  3. FIRST-TOKEN LOGPROBS. align's own forward on the merged weights (bf16, GPU 1) scores the same
     prompt; the first-token top-1 id must agree and the logprob difference is reported.
  4. TEACHER-FORCED AGREEMENT over all 64 generated tokens: align scores prompt+generation in one
     pass; per-position argmax agreement with what vLLM sampled, and max |dlogprob| of the sampled
     token. bf16 TOLERANCE (documented, not fitted): two correct bf16 engines with different
     attention/GEMM kernels and accumulation order differ by a few ulps of a logit of magnitude
     10-30 (ulp 0.0625-0.125) amplified through 36 layers; the gate is |dlogprob| <= 0.25 on the
     first token and argmax agreement >= 95% over the 64 positions (a near-tie legitimately flips
     argmax; a wrong merge, a missing QK-norm or a wrong rope_theta disagrees on most positions and
     by whole nats). The thresholds are constants below, asserted by `evaluate`, and exercised on
     injected mutants in tests/test_qwen3_scripts.py.
  5. ADAPTER vs MERGED. The single bf16 cast of W+delta rounds away any delta below half an ulp of
     W (a dead zone, not noise), so at small LoRA movement the merged file can be a different
     policy from base+adapter -- and checks 1-4 all score the MERGED file against itself. Here the
     SAME teacher-forced tokens are scored by align with the adapter applied live (base bf16 +
     fp32 delta) and by the merged file; the gates are the first-token / argmax-agreement
     constants of check 4 (reused, not refitted). The retained-delta cosine (merged-W vs the
     intended delta) is recorded alongside. Also gated: the real snapshot's hf<->align mapping
     round trip, and that the adapter MOVED every adapted matrix (a zero adapter exports the base).

The export is DELETED afterwards unless --keep (16.4 GB on a 94%-full disk).
"""

from __future__ import annotations

import argparse
import json
import os
import shutil
import subprocess
import sys
import time

REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, REPO)
ROLLOUT_PY = os.path.join(REPO, ".venv-rollout", "bin", "python")
SNAPSHOT = os.environ.get("P2_EXPORT_BASE_SNAPSHOT") or ("/apps/development/rlhf/hf_cache/hub/models--Goedel-LM--Goedel-Prover-V2-8B/snapshots/"
            "dfd02e6271a58375dfbf3ece0175277cf6b6a89a")
WORK = os.path.join(REPO, ".tmp", "p2", "p2a", "export_check")

N_NEW = 64
TOPK = 20
FIRST_TOKEN_MAX_ABS_DLOGPROB = 0.25
MIN_ARGMAX_AGREEMENT = 0.95

PROMPTS = (
    "<|im_start|>user\nComplete the following Lean 4 code:\n\n```lean4\nimport Mathlib\nimport Aesop\n\n"
    "set_option maxHeartbeats 400000\n\nopen BigOperators Real Nat Topology Rat\n\n"
    "theorem mathd_algebra_10 : abs ((120 : ℝ) / 100 * 30 - 130 / 100 * 20) = 10 := by\n  sorry\n```\n"
    "<|im_end|>\n<|im_start|>assistant\n",
    "<|im_start|>user\nWhat is 17 * 23? Answer briefly.<|im_end|>\n<|im_start|>assistant\n",
)


# ---------------------------------------------------------------------------
# merge (pure tensor math; unit-tested)
# ---------------------------------------------------------------------------


def merge_lora_state(base: dict, lora: dict, scaling: float) -> dict:
    """`base` (align-keyed, `<path>.weight`) with `scaling * B @ A` folded in for every adapter path
    in `lora` (`<path>.A` / `<path>.B`, as `align.ppo.lora.lora_state_dict` writes them).

    The delta is accumulated in fp32 and cast ONCE to the weight's dtype (the same arithmetic as
    `export_merged_state_dict`, so the two routes can be compared bit for bit). Every adapter path
    must resolve to a base weight and must have both halves: a half-present adapter or a path that
    matches nothing would otherwise "export" an unmodified base.
    """
    import torch

    a_keys = {k[:-2] for k in lora if k.endswith(".A")}
    b_keys = {k[:-2] for k in lora if k.endswith(".B")}
    stray = sorted(k for k in lora if not (k.endswith(".A") or k.endswith(".B")))
    if stray:
        raise KeyError(f"adapter state has keys that are neither .A nor .B: {stray[:4]}")
    if a_keys != b_keys:
        raise KeyError(f"adapter halves do not pair: A-only={sorted(a_keys - b_keys)[:3]} B-only={sorted(b_keys - a_keys)[:3]}")
    if not a_keys:
        raise KeyError("adapter state is empty; refusing to export an unmodified base as a merge")
    out = dict(base)
    for p in sorted(a_keys):
        wk = f"{p}.weight"
        if wk not in base:
            raise KeyError(f"adapter path {p!r} has no base weight {wk!r}")
        w, A, B = base[wk], lora[f"{p}.A"], lora[f"{p}.B"]
        if A.shape[0] != B.shape[1] or (B.shape[0], A.shape[1]) != tuple(w.shape):
            raise ValueError(f"{p}: A{tuple(A.shape)} B{tuple(B.shape)} do not compose to {tuple(w.shape)}")
        out[wk] = (w.float() + (B.float() @ A.float()) * scaling).to(w.dtype)
    return out


# ---------------------------------------------------------------------------
# verdict (pure; unit-tested)
# ---------------------------------------------------------------------------


def compare_generation(vllm: dict, align: dict) -> dict:
    """Per-prompt metrics between vLLM's generation record and align's teacher-forced scoring.

    `vllm[i]`: {"token_ids": [64 ints], "chosen_logprobs": [64 floats], "first_top": {id: logprob}}.
    `align[i]`: {"argmax": [64 ints], "chosen_logprobs": [64 floats], "first_logprobs": {id: logprob}}
    (align's log-softmax at the same 64 positions, and at position 0 for vLLM's top-20 ids).
    """
    per = []
    for v, a in zip(vllm, align):
        n = len(v["token_ids"])
        if not (len(a["argmax"]) == len(a["chosen_logprobs"]) == n):
            raise ValueError("generation records have different lengths")
        agree = sum(int(x == y) for x, y in zip(v["token_ids"], a["argmax"]))
        dchosen = [abs(x - y) for x, y in zip(v["chosen_logprobs"], a["chosen_logprobs"])]
        v_top1 = max(v["first_top"], key=v["first_top"].get)
        a_first = a["first_logprobs"]
        per.append({
            "tokens": n,
            "argmax_agreement": agree / n,
            "max_abs_dlogprob_chosen": max(dchosen),
            "mean_abs_dlogprob_chosen": sum(dchosen) / n,
            "first_token_top1_same": int(v_top1) == int(max(a_first, key=a_first.get)),
            "first_token_abs_dlogprob": abs(v["first_top"][v_top1] - a_first[v_top1]),
            "first_token_max_abs_dlogprob_top20": max(abs(v["first_top"][k] - a_first[k]) for k in v["first_top"]),
        })
    return {"per_prompt": per}


def evaluate(metrics: dict) -> dict:
    checks = {}
    for i, p in enumerate(metrics["per_prompt"]):
        checks[f"prompt{i}.first_token_top1_same"] = bool(p["first_token_top1_same"])
        checks[f"prompt{i}.first_token_dlogprob"] = p["first_token_abs_dlogprob"] <= FIRST_TOKEN_MAX_ABS_DLOGPROB
        checks[f"prompt{i}.argmax_agreement"] = p["argmax_agreement"] >= MIN_ARGMAX_AGREEMENT
    return {"checks": checks, "passed": bool(checks) and all(checks.values()),
            "thresholds": {"first_token_max_abs_dlogprob": FIRST_TOKEN_MAX_ABS_DLOGPROB,
                           "min_argmax_agreement": MIN_ARGMAX_AGREEMENT}}


def merge_fidelity_stats(base: dict, merged: dict, lora: dict, scaling: float) -> dict:
    """How much of the intended delta `scaling * B @ A` survived the cast: aggregate cosine and
    relative error of (merged - base) against the delta, and the fraction of nonzero delta
    elements that rounded to exactly zero (the bf16 dead zone). Same arithmetic as
    `align.ppo.lora.export_merged_state_dict`'s report."""
    import math

    import torch

    dot = ne = nd = 0.0
    nz_total = dropped = layers = 0
    for p in sorted({k[:-2] for k in lora if k.endswith(".A")}):
        wk = f"{p}.weight"
        d = ((lora[f"{p}.B"].float() @ lora[f"{p}.A"].float()) * scaling).flatten()
        e = (merged[wk].float() - base[wk].float()).flatten()
        dot, ne, nd = dot + float(torch.dot(e, d)), ne + float(torch.dot(e, e)), nd + float(torch.dot(d, d))
        nz = d != 0
        nz_total += int(nz.sum())
        dropped += int((nz & (e == 0)).sum())
        layers += 1
    if nd == 0.0:
        return {"layers": layers, "cosine": 1.0, "rel_error": 0.0, "dropped_fraction": 0.0, "delta_norm": 0.0}
    return {"layers": layers, "cosine": dot / math.sqrt(max(ne * nd, 1e-300)),
            "rel_error": math.sqrt(max(ne - 2.0 * dot + nd, 0.0) / nd),
            "dropped_fraction": dropped / nz_total if nz_total else 0.0, "delta_norm": math.sqrt(nd)}


def compare_adapter_vs_merged(adapter_lps: list, merged_lps: list) -> dict:
    """Per-prompt agreement between align's log-softmax at the generated positions with the adapter
    applied live (`adapter_lps[i]`, [T, V]) and on the merged bf16 file (`merged_lps[i]`, [T, V])."""
    import torch

    per = []
    for a, m in zip(adapter_lps, merged_lps):
        if a.shape != m.shape or a.dim() != 2:
            raise ValueError(f"log-prob records differ in shape: {tuple(a.shape)} vs {tuple(m.shape)}")
        a, m = a.float(), m.float()
        a_top, m_top = a.argmax(-1), m.argmax(-1)
        t0 = int(a_top[0])
        per.append({
            "tokens": a.shape[0],
            "argmax_agreement": float((a_top == m_top).float().mean()),
            "first_token_abs_dlogprob": abs(float(a[0, t0] - m[0, t0])),
            "max_abs_dlogprob_top1": float((a.gather(1, a_top[:, None]) - m.gather(1, a_top[:, None])).abs().max()),
            "kl_adapter_to_merged_mean": float((a.exp() * (a - m)).sum(-1).mean()),
        })
    return {"per_prompt": per}


def evaluate_adapter_vs_merged(metrics: dict) -> dict:
    checks = {}
    for i, p in enumerate(metrics["per_prompt"]):
        checks[f"prompt{i}.first_token_dlogprob"] = p["first_token_abs_dlogprob"] <= FIRST_TOKEN_MAX_ABS_DLOGPROB
        checks[f"prompt{i}.argmax_agreement"] = p["argmax_agreement"] >= MIN_ARGMAX_AGREEMENT
    return {"checks": checks, "passed": bool(checks) and all(checks.values()),
            "thresholds": {"first_token_max_abs_dlogprob": FIRST_TOKEN_MAX_ABS_DLOGPROB,
                           "min_argmax_agreement": MIN_ARGMAX_AGREEMENT}}


def export_ok(report: dict) -> bool:
    """The process verdict. EVERY advertised check must have run and passed: a check that did not
    run (key absent) is a failure, not a pass -- a zero adapter, a lossy hf<->align mapping or a
    merge that dropped the delta must not exit 0."""
    rel_min = (report.get("merge_relative_weight_change") or {}).get("min")
    return bool(
        (report.get("verdict") or {}).get("passed") is True
        and (report.get("merge_vs_trainer_file") or {}).get("bitwise_mismatches") == 0
        and (report.get("snapshot_mapping_roundtrip") or {}).get("bitwise_equal") is True
        and (report.get("merge_changed_tensors_vs_snapshot") or 0) > 0
        and isinstance(rel_min, (int, float)) and rel_min > 0
        and ((report.get("adapter_vs_merged") or {}).get("verdict") or {}).get("passed") is True
    )


# ---------------------------------------------------------------------------
# vLLM side: runs in .venv-rollout (source string: not an import of this file)
# ---------------------------------------------------------------------------

_VLLM_SCRIPT = r'''
import json, os, sys, time
model_dir, prompts_json, out_path, n_new, topk = sys.argv[1:6]
n_new, topk = int(n_new), int(topk)
os.environ.setdefault("VLLM_USE_FLASHINFER_SAMPLER", "0")
os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
from transformers import AutoTokenizer
from vllm import LLM, SamplingParams
tok = AutoTokenizer.from_pretrained(model_dir)
prompts = json.loads(prompts_json)
ids = [tok(p, add_special_tokens=False)["input_ids"] for p in prompts]
t0 = time.time()
llm = LLM(model=model_dir, tokenizer=model_dir, dtype="bfloat16", max_model_len=2048,
          gpu_memory_utilization=0.85, enforce_eager=True, seed=0)
t_load = time.time() - t0
sp = SamplingParams(temperature=0.0, max_tokens=n_new, logprobs=topk, ignore_eos=True)
outs = llm.generate([{"prompt_token_ids": x} for x in ids], sp)
res = []
for x, o in zip(ids, outs):
    c = o.outputs[0]
    lps = c.logprobs
    res.append({
        "prompt_ids": x,
        "token_ids": list(c.token_ids),
        "text": c.text,
        "chosen_logprobs": [float(lps[t][c.token_ids[t]].logprob) for t in range(len(c.token_ids))],
        "first_top": {str(k): float(v.logprob) for k, v in lps[0].items()},
    })
json.dump({"results": res, "load_s": t_load, "model": model_dir}, open(out_path, "w"))
print(json.dumps({"ok": True, "n": len(res), "load_s": round(t_load, 1)}), flush=True)
# vLLM 0.26's EngineCore subprocess can hang at interpreter shutdown (measured: the result file was
# written at 00:44 and the process was still alive 20 minutes later holding 19 GiB of GPU 0). The
# result is on disk and flushed; leave without running the atexit/engine teardown.
sys.stdout.flush(); sys.stderr.flush()
os._exit(0)
'''


def _first_visible_gpu(env=None) -> str:
    """The child's CUDA_VISIBLE_DEVICES is relative to PHYSICAL indices, so a hard-coded "0" put
    vLLM (0.85 of the card) on physical GPU 0 even when the operator pinned this check to 2,3.
    Use the parent's FIRST visible entry ("0" when the parent is unpinned)."""
    v = (os.environ if env is None else env).get("CUDA_VISIBLE_DEVICES", "")
    first = v.split(",")[0].strip()
    return first or "0"


def _rollout_env(gpu: str) -> dict:
    scrub = ("RANK", "LOCAL_RANK", "WORLD_SIZE", "LOCAL_WORLD_SIZE", "MASTER_ADDR", "MASTER_PORT", "TORCHELASTIC")
    env = {k: v for k, v in os.environ.items() if not k.startswith(scrub)}
    env.update({
        "CUDA_VISIBLE_DEVICES": gpu, "VLLM_USE_FLASHINFER_SAMPLER": "0",
        "TMPDIR": os.path.join(REPO, ".tmp", "p2", "p2a"), "XDG_CACHE_HOME": os.path.join(REPO, ".cache"),
        "HF_HOME": "/apps/development/rlhf/hf_cache", "HF_HUB_CACHE": "/apps/development/rlhf/hf_cache/hub",
        "HF_HUB_DISABLE_XET": "1", "HF_HUB_OFFLINE": "1", "TRANSFORMERS_OFFLINE": "1",
        "TRITON_CACHE_DIR": os.path.join(REPO, ".cache", "triton"),
        "TORCHINDUCTOR_CACHE_DIR": os.path.join(REPO, ".cache", "torchinductor"),
        "PYTHONUNBUFFERED": "1", "TOKENIZERS_PARALLELISM": "false",
    })
    return env


# ---------------------------------------------------------------------------
# the run
# ---------------------------------------------------------------------------


def run_vllm_child(cmd, result_path, *, env, cwd, log_path, grace_s: float = 30.0, timeout_s: float = 3000.0):
    """Run the vLLM child WITHOUT inheriting pipes, and stop its process group once it has written
    its result.

    DEFECT THIS PREVENTS (measured in the P2 smoke loop): vLLM 0.26's EngineCore can outlive its
    parent at interpreter exit (re-parented to init, holding the parent's stdout/stderr pipes). With
    `subprocess.run(capture_output=True)` the parent then blocks in communicate() until the ORPHAN
    closes the pipe -- 12+ minutes, the result file already written, GPU 0 held at 19 GB, the heavy
    lock held. Here the child logs to a file (nothing for an orphan to hold open), runs in its own
    session so the whole group can be signalled by its exact pgid, and is terminated `grace_s` after
    the result file appears. Returns (returncode or None if it had to be terminated, log tail).
    """
    import signal

    with open(log_path, "w") as log:
        proc = subprocess.Popen(cmd, stdout=log, stderr=subprocess.STDOUT, env=env, cwd=cwd, start_new_session=True)
        t0 = time.time()
        done_at = None
        rc = None
        while True:
            rc = proc.poll()
            if rc is not None:
                break
            if os.path.isfile(result_path) and done_at is None:
                done_at = time.time()
            if (done_at is not None and time.time() - done_at > grace_s) or time.time() - t0 > timeout_s:
                break
            time.sleep(1.0)
        # signal the group even when the leader exited: orphaned workers keep the pgid
        for sig in (signal.SIGTERM, signal.SIGKILL):
            try:
                os.killpg(proc.pid, sig)
            except ProcessLookupError:
                break
            time.sleep(5.0)
        if rc is None:
            try:
                rc = proc.wait(timeout=10)
            except subprocess.TimeoutExpired:
                rc = None
    with open(log_path) as f:
        return rc, f.read()[-3000:]


def _lora_scaling(ckpt: str, r: int | None, alpha: float | None) -> float:
    if r is None or alpha is None:
        raise SystemExit("--r and --alpha are required: the checkpoint stores A/B but not alpha/r, and guessing the scaling would export a wrong merge silently")
    return alpha / r


def run(a) -> dict:
    from align.utils.guard import apply_resource_guards

    apply_resource_guards(cpu_threads=8, heavy=True, label="p2a-qwen3-export-check")
    import torch

    from align.model import qwen3 as q3
    from align.model.checkpoint import load_hf_safetensors
    from align.model.qwen3_checkpoint import align_to_hf, hf_to_align, read_qwen3_config, save_hf_qwen3

    work = os.path.abspath(a.work)
    out_dir = os.path.abspath(a.out_dir)
    os.makedirs(work, exist_ok=True)
    report: dict = {"ckpt": a.ckpt, "out_dir": out_dir}
    qcfg = read_qwen3_config(SNAPSHOT)
    scaling = _lora_scaling(a.ckpt, a.r, a.alpha)

    # -- 1. merge path vs the trainer's file ---------------------------------------------------
    t0 = time.time()
    raw = load_hf_safetensors(SNAPSHOT, dtype=torch.bfloat16)
    base = hf_to_align(raw, qcfg)
    # real-checkpoint mapping round trip: hf -> align -> hf must return every tensor bit for bit
    # (any tensor dropped, renamed or transposed by the table fails here, on the real shapes).
    back = align_to_hf(base, qcfg)
    report["snapshot_mapping_roundtrip"] = {"tensors": len(raw), "bitwise_equal": all(torch.equal(back[k], raw[k]) for k in raw)
                                            and set(back) == set(raw)}
    del raw, back
    payload = torch.load(os.path.join(a.ckpt, "optimizer_rank0.pt"), weights_only=True, map_location="cpu")
    merged = merge_lora_state(base, payload["lora"], scaling)
    report["merge_delta_retention"] = merge_fidelity_stats(base, merged, payload["lora"], scaling)
    del base
    save_hf_qwen3(merged, out_dir, cfg=qcfg, dtype=torch.bfloat16, tokenizer_dir=SNAPSHOT)
    report["merge_s"] = round(time.time() - t0, 1)
    ckpt_state = hf_to_align(load_hf_safetensors(a.ckpt, dtype=None), qcfg)
    mism = [k for k, v in merged.items() if not torch.equal(v, ckpt_state[k])]
    n_changed = sum(1 for p in {k[:-2] for k in payload["lora"] if k.endswith(".A")})
    report["merge_vs_trainer_file"] = {"tensors": len(merged), "bitwise_mismatches": len(mism), "first_mismatches": mism[:3],
                                       "adapted_layers": n_changed}
    del ckpt_state
    # the adapter must have MOVED the weights: a zero adapter exports the base and "passes" check 1
    base2 = hf_to_align(load_hf_safetensors(SNAPSHOT, dtype=torch.bfloat16), qcfg)
    moved = sum(1 for k, v in merged.items() if not torch.equal(v, base2[k]))
    report["merge_changed_tensors_vs_snapshot"] = moved
    # How far the adapter moved the weights: ||W' - W||_F / ||W||_F per adapted matrix (the number
    # an LR choice is judged by; bf16 rounding of W' contributes ~2^-9 ~ 0.2% and is included).
    # Over the ADAPTED matrices only (every one must have moved: `export_ok` gates the minimum).
    adapted = {f"{k[:-2]}.weight" for k in payload["lora"] if k.endswith(".A")}
    rel = [((merged[k].float() - base2[k].float()).norm() / base2[k].float().norm()).item() for k in sorted(adapted)]
    report["merge_relative_weight_change"] = {"n": len(rel), "mean": sum(rel) / len(rel), "max": max(rel), "min": min(rel)}
    del base2, merged

    # -- 2. vLLM generates from the export -----------------------------------------------------
    vout = os.path.join(work, "vllm_out.json")
    env = _rollout_env(_first_visible_gpu())
    if os.path.isfile(vout):
        os.remove(vout)  # a stale result from an earlier run must never be read as this run's
    rc, tail = run_vllm_child([ROLLOUT_PY, "-c", _VLLM_SCRIPT, out_dir, json.dumps(list(PROMPTS)), vout, str(N_NEW), str(TOPK)],
                              vout, env=env, cwd=work, log_path=os.path.join(work, "vllm.log"))
    report["vllm_returncode"] = rc
    if not os.path.isfile(vout):
        report["vllm_stderr_tail"] = tail
        return report
    vres = json.load(open(vout))
    report["vllm_load_s"] = vres["load_s"]
    report["vllm_text"] = [r["text"][:200] for r in vres["results"]]

    # -- 3/4. align scores the same tokens on the merged weights (GPU 1) ------------------------
    dev = torch.device("cuda", 1 if torch.cuda.device_count() > 1 else 0)
    cfg2, sd = (qcfg, hf_to_align(load_hf_safetensors(out_dir, dtype=torch.bfloat16), qcfg))
    model = q3.Qwen3ForCausalLM.from_state_dict(cfg2, sd, attn_implementation="sdpa", device="cuda").to(dev).eval()
    del sd
    arec, mlps = [], []
    with torch.no_grad():
        for r in vres["results"]:
            ids = r["prompt_ids"] + r["token_ids"]
            logits = model(input_ids=torch.tensor([ids], device=dev))[0].float()
            lp = torch.log_softmax(logits, dim=-1)
            n0 = len(r["prompt_ids"])
            pos = lp[n0 - 1: n0 - 1 + len(r["token_ids"])]  # position t predicts generated token t
            chosen = pos.gather(1, torch.tensor(r["token_ids"], device=dev)[:, None])[:, 0]
            first = {k: float(pos[0, int(k)]) for k in r["first_top"]}
            arec.append({"argmax": pos.argmax(-1).tolist(), "chosen_logprobs": chosen.tolist(), "first_logprobs": first})
            mlps.append(pos.cpu())
    metrics = compare_generation(vres["results"], arec)
    report["metrics"] = metrics
    report["verdict"] = evaluate(metrics)

    # -- 5. the same tokens through base + live adapter (fp32 delta) vs the merged file ---------
    from align.ppo.lora import LoRAConfig, inject_lora, load_lora_state_dict

    del model
    torch.cuda.empty_cache()
    base_sd = hf_to_align(load_hf_safetensors(SNAPSHOT, dtype=torch.bfloat16), qcfg)
    amodel = q3.Qwen3ForCausalLM.from_state_dict(qcfg, base_sd, attn_implementation="sdpa", device="cuda").to(dev).eval()
    del base_sd
    inject_lora(amodel, LoRAConfig(r=a.r, alpha=a.alpha), adapter="sft", generator=torch.Generator().manual_seed(0))
    load_lora_state_dict(amodel, "sft", payload["lora"], strict=True)
    alps = []
    with torch.no_grad():
        for r in vres["results"]:
            ids = r["prompt_ids"] + r["token_ids"]
            lp = torch.log_softmax(amodel(input_ids=torch.tensor([ids], device=dev))[0].float(), dim=-1)
            n0 = len(r["prompt_ids"])
            alps.append(lp[n0 - 1: n0 - 1 + len(r["token_ids"])].cpu())
    m5 = compare_adapter_vs_merged(alps, mlps)
    report["adapter_vs_merged"] = {"metrics": m5, "verdict": evaluate_adapter_vs_merged(m5)}
    return report


def main(argv=None) -> int:
    ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    sub = ap.add_subparsers(dest="cmd", required=True)
    r = sub.add_parser("run")
    r.add_argument("--ckpt", required=True, help="a trainer checkpoint dir (micro_N) with optimizer_rank0.pt")
    r.add_argument("--out-dir", default=os.path.join(WORK, "export"))
    r.add_argument("--work", default=WORK)
    r.add_argument("--r", type=int, default=None)
    r.add_argument("--alpha", type=float, default=None)
    r.add_argument("--keep", action="store_true")
    a = ap.parse_args(argv)
    rep = {}
    try:
        rep = run(a)
    finally:
        if not a.keep and os.path.isdir(a.out_dir):
            shutil.rmtree(a.out_dir, ignore_errors=True)
            rep["export_deleted"] = True
        os.makedirs(a.work, exist_ok=True)
        with open(os.path.join(a.work, "report.json"), "w") as f:
            json.dump(rep, f, indent=2, default=str)
    print(json.dumps(rep, indent=2, default=str))
    return 0 if export_ok(rep) else 1


if __name__ == "__main__":
    raise SystemExit(main())