File size: 40,133 Bytes
e0eb79a
 
 
5c30113
 
 
e0eb79a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5c30113
e0eb79a
 
 
 
 
 
 
5c30113
 
 
 
 
 
 
 
 
 
e0eb79a
 
 
 
5c30113
e0eb79a
 
5c30113
 
e0eb79a
 
 
 
 
 
 
 
 
 
 
 
 
5c30113
 
 
 
e0eb79a
 
 
 
 
 
 
 
5c30113
 
 
 
 
 
 
 
e0eb79a
 
 
 
 
 
5c30113
 
 
 
 
e0eb79a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5c30113
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e0eb79a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5c30113
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e0eb79a
 
 
 
 
 
5c30113
 
 
 
e0eb79a
5c30113
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e0eb79a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5c30113
e0eb79a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5c30113
 
 
e0eb79a
5c30113
e0eb79a
 
 
 
 
 
 
5c30113
 
 
 
 
 
 
 
 
 
 
 
 
e0eb79a
 
 
 
 
5c30113
e0eb79a
5c30113
 
 
 
 
 
 
 
 
 
e0eb79a
 
 
5c30113
 
 
 
 
 
 
 
 
e0eb79a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5c30113
 
 
 
 
e0eb79a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5c30113
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e0eb79a
 
 
 
 
 
 
 
 
 
 
 
 
 
5c30113
e0eb79a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5c30113
e0eb79a
 
5c30113
 
 
 
 
 
e0eb79a
 
 
5c30113
 
 
 
 
 
 
 
 
e0eb79a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5c30113
e0eb79a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5c30113
e0eb79a
 
 
5c30113
 
e0eb79a
 
 
5c30113
 
e0eb79a
 
 
 
 
 
 
5c30113
e0eb79a
 
 
 
 
 
 
 
5c30113
 
e0eb79a
 
 
 
 
 
 
5c30113
 
 
 
 
 
e0eb79a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
"""Upload the trained MiniHack ReMDM checkpoints and results to the Hugging Face Hub.

Discovers every checkpoint under ``checkpoints/``, every ablation run under
``experiments/rl_finetuning/outputs/``, every ``--mode inference`` result
under ``results/inference/`` and the manuscript figure PDFs under
``results/paper_figures/``, stages them with the repo-relative layout
preserved, drops W&B and hub metadata (which carries the author's account,
hostname and local paths), exports safetensors inference weights alongside each
full training state, generates a model card from the checkpoints' own config
snapshots, and uploads.

    HF_TOKEN=hf_xxx uv run python scripts/hf_upload.py \\
        --repo-id <ANON_HF_REPO_ID> \\
        [--inference-results PATH ...] [--dry-run] [--private]
"""

from __future__ import annotations

import argparse
import json
import os
import re
import shutil
import sys
import tempfile
from pathlib import Path

import yaml

# scripts/ is not a package, so the helper is imported by bare name.
# Python already puts this file's directory on sys.path when the script is
# run directly; the explicit insert is for the file-location loaders the
# tests use (tests/test_config.py), which do not.
_SCRIPTS = Path(__file__).resolve().parent
if str(_SCRIPTS) not in sys.path:
    sys.path.insert(0, str(_SCRIPTS))

from _git_provenance import copy_tracked_file  # noqa: E402

ROOT = Path(__file__).resolve().parent.parent
CKPTS = ROOT / "checkpoints"
RUNS = ROOT / "experiments" / "rl_finetuning" / "outputs"
INFERENCE = ROOT / "results" / "inference"
PAPER_FIGURES = ROOT / "results" / "paper_figures"

PAPER = (
    "Return-Weighted ELBO Fine-Tuning Degrades "
    "Masked Diffusion Planners"
)
CODE_URL = "https://github.com/ANONYMOUS/remdm-planners"
ENV_NAME = "MiniHack"

ROLES = {
    "offline": "Diffusion planner (offline BC)",
    "online": "Diffusion planner (online DAgger)",
    "imported": "Imported baseline",
}

# An ablation run is published as its own summary plus tables and figures; the
# raw per-iteration logs stay in the code repository.
RUN_FILES = ("results.json", "diagnosis.md")
# gdelta/ holds the per-seed gradient measurements behind the paper's
# decomposition appendix; they land inside the run directory, so
# discover_runs() finds them with no further change.
RUN_DIRS = ("tables", "figures", "gdelta")

# Environment provenance, never needed to restore a checkpoint, and dropped
# from every published config in both repos: the nested `_wandb` blob (email,
# host, git remote, absolute paths), every `wandb_*` and `hub_*` key, and
# `use_wandb` itself — which matches neither prefix, so both repos shipped it
# in the released config while claiming to scrub W&B settings. Keys are
# compared lower-cased, because craftax records them UPPERCASE and minihack
# lower-case.
DROP_PREFIXES = ("hub_",)
# `wandb_` is matched anywhere in the key, not only at the start. A prefix
# rule caught `wandb_project` but not `baselines_wandb_project`, which was
# published in both released config.yaml files, nor the sibling's
# `RESUME_WANDB_RUN_ID` -- the same way `use_wandb` slipped past originally.
# The underscore is what keeps it narrow: `wandbish` contains `wandb` but not
# `wandb_`, so the negative assertions still hold in both repos.
DROP_SUBSTRINGS = ("wandb_",)
DROP_KEYS = ("_wandb", "use_wandb")


def is_environment_key(key: str) -> bool:
    """True for a config key that is provenance rather than recipe."""
    lowered = key.lower()
    return (
        lowered in DROP_KEYS
        or lowered.startswith(DROP_PREFIXES)
        or any(mark in lowered for mark in DROP_SUBSTRINGS)
    )


COPY_IGNORE = shutil.ignore_patterns(
    ".DS_Store", "__pycache__", "*.pyc", "wandb-metadata.json",
)
HUB_IGNORE = ["**/.DS_Store", "**/__pycache__/**", "**/wandb-metadata.json"]


# =============================================================================
# Discovery
# =============================================================================

# `checkpoints/hf/` is where a Hub *download* lands. Publishing from it would
# re-upload already-published artefacts into a nested `checkpoints/hf/...` tree
# on the Hub, so it is never a publish source in either repo.
HF_DOWNLOAD_DIR = "hf"


def _is_download_copy(path: Path) -> bool:
    """True for anything under ``checkpoints/hf/``, wherever it sits."""
    return HF_DOWNLOAD_DIR in path.relative_to(CKPTS).parts


def discover_checkpoints() -> dict[Path, list[Path]]:
    """Map each checkpoint directory to its ``.pth`` files, oldest first.

    Discovery is at the **released layout**, ``checkpoints/<role>/<name>/`` —
    the layout the Hub repo mirrors, matching the sibling repo. A recursive
    ``rglob("*.pth")`` found everything instead, which is wrong in both
    directions: measured on this repo's live tree it discovered 10 directories,
    **none of them at the released layout** — seven raw ``dagger_<timestamp>/``
    run directories, the repository root itself with two loose files, and **two
    already-published artefacts under** ``checkpoints/hf/``, which a publish
    would have pushed back up into a nested ``checkpoints/hf/checkpoints/...``
    tree.

    A training run writes to its own directory, so its checkpoints have to be
    copied into ``checkpoints/{offline,online}/<name>/`` before publishing. The
    README documents that; it is the same requirement the sibling repo has
    always had, and it is now stated on both sides rather than one.

    Anything under ``checkpoints/hf/`` is skipped as a download copy, explicitly
    rather than by depth arithmetic.

    Returns:
        ``{checkpoint directory: [.pth paths, oldest first]}``.
    """
    models: dict[Path, list[Path]] = {}
    for pth in sorted(CKPTS.glob("*/*/*.pth")):
        if _is_download_copy(pth):
            continue
        models.setdefault(pth.parent, []).append(pth)
    return models


def discover_runs() -> list[Path]:
    """Every ablation output directory holding a ``results.json``."""
    if not RUNS.is_dir():
        return []
    return sorted(d for d in RUNS.iterdir() if (d / "results.json").is_file())


def discover_paper_figures() -> list[Path]:
    """The manuscript figures, as vector PDFs.

    Built by ``scripts/paper_figures.py``, which lives in the sibling repo
    because each figure reads *both* repositories' ablation ``results.json``
    and puts Craftax Classic and MiniHack side by side in one figure. The
    resulting PDFs therefore describe this repo's results as much as the
    sibling's, and both Hub repos publish the same set from
    ``results/paper_figures/``.

    They were previously published by neither: discovery covered only
    ``checkpoints/``, the ablation run directories and ``results/inference/``,
    so ``results/paper_figures/`` matched nothing and the upload said nothing
    about it.
    """
    if not PAPER_FIGURES.is_dir():
        return []
    return sorted(PAPER_FIGURES.glob("*.pdf"))


def discover_inference(extra: list[str]) -> list[Path]:
    """Inference result JSONs: the default directory plus any given paths."""
    found: list[Path] = []
    for source in [INFERENCE, *(Path(p) for p in extra)]:
        if source.is_dir():
            found.extend(sorted(source.glob("*.json")))
        elif source.is_file():
            found.append(source)
        elif source != INFERENCE:
            print(f"No inference results at {source}.", file=sys.stderr)
    return list(dict.fromkeys(found))


# =============================================================================
# Helpers
# =============================================================================

def dir_size_mb(path: Path) -> float:
    if path.is_file():
        return path.stat().st_size / 1_048_576
    return sum(f.stat().st_size for f in path.rglob("*") if f.is_file()) / 1_048_576


def human_size(path: Path) -> str:
    mb = dir_size_mb(path)
    return f"{mb:.0f} MB" if mb >= 1 else f"{max(mb * 1024, 1):.0f} KB"


def plural(n: int, word: str) -> str:
    return f"{n} {word}" if n == 1 else f"{n} {word}s"


def shorten_paths(value):
    """Shorten absolute cluster paths anywhere in a staged JSON document."""
    if isinstance(value, dict):
        return {k: shorten_paths(v) for k, v in value.items()}
    if isinstance(value, list):
        return [shorten_paths(v) for v in value]
    if isinstance(value, str) and value.startswith("/"):
        return "/".join(Path(value).parts[-2:])
    return value


def environment_key_paths(value, path: str = "") -> list[str]:
    """Every dotted path at which an environment key appears, at any depth.

    The reporting counterpart to :func:`drop_environment_keys`, so the
    uploader can name what it removed rather than claiming a scrub happened.
    Recursion stops at a key that is itself dropped: its whole subtree goes.
    """
    found: list[str] = []
    if isinstance(value, dict):
        for key, sub in value.items():
            name = str(key)
            here = f"{path}.{name}" if path else name
            if is_environment_key(name):
                found.append(here)
            else:
                found.extend(environment_key_paths(sub, here))
    elif isinstance(value, (list, tuple)):
        for i, sub in enumerate(value):
            found.extend(environment_key_paths(sub, f"{path}[{i}]"))
    return found


def drop_environment_keys(value):
    """Strip environment keys at **every** depth, not just the top level.

    Filtering only the top level was a real defect in the sibling repo: its
    path-shortener recursed while its key-filter did not, so W&B project and
    entity survived one level down inside a nested config snapshot and were
    published, while the uploader reported a successful scrub. This repo's
    checkpoints happen to carry no nested config, so the same one-level filter
    leaked nothing here -- clean for a reason that was not the code being
    right, and one schema change away from leaking silently.

    Mapping types are preserved, so an ``OrderedDict`` state dict stays an
    ``OrderedDict`` and the published checkpoint keeps its original structure.
    """
    if isinstance(value, dict):
        kept = {
            key: drop_environment_keys(sub)
            for key, sub in value.items()
            if not is_environment_key(str(key))
        }
        if type(value) is dict:
            return kept
        try:
            return type(value)(kept)
        except TypeError:
            # A mapping needing constructor arguments (e.g. a defaultdict
            # factory). Structure matters less than not shipping the key.
            return kept
    if isinstance(value, list):
        return [drop_environment_keys(sub) for sub in value]
    return value


def scrub(cfg: dict) -> dict:
    """Drop the environment keys and shorten absolute cluster paths.

    The prefixes alone missed `use_wandb`, which starts with neither, so it
    shipped in every released config; the sibling repo dropped a different set
    again, and left `USE_WANDB` with it. Both now drop the same one.

    Both passes recurse. They used to disagree -- ``shorten_paths`` descended
    and the key filter did not -- which is the asymmetry that leaked W&B
    settings from a nested config snapshot in the sibling repo.
    """
    return drop_environment_keys(shorten_paths(cfg))


#: An absolute POSIX path with at least three components, as it appears
#: inside a JSON string, a Markdown table or a LaTeX table cell.
# The lookbehind anchors the match to an absolute path. Without it the regex
# also matched from the middle of a relative one: `experiments/rl_finetuning/
# analysis/tables.py` in a generated LaTeX comment became
# `experimentsanalysis/tables.py`, because matching could start at the slash
# after `experiments`.
#
# `@` and `+` are in the class because the lookbehind makes a narrow class
# leak rather than merely under-match: with them omitted,
# `/home/user@dom/proj/run/file.txt` fails at the start (`user@dom` is not one
# component) and the lookbehind then refuses to restart at `/proj`, so there
# is no fallback match and the whole absolute path ships unshortened. `@` is
# exactly where an email-like component appears in a home-directory path,
# which is the leak class this scrub exists for.
_ABS_PATH = re.compile(r"(?<![\w.@+\-])/(?:[\w.@+\-]+/){2,}[\w.@+\-]+")

#: What a staged tree holds that is worth reading as text.
_TEXT_SUFFIXES = (".md", ".txt", ".csv", ".tex")
_DOC_SUFFIXES = (".json", ".yaml", ".yml")


def shorten_text_paths(text: str) -> str:
    """Apply the ``shorten_paths`` rule to paths embedded in plain text.

    ``shorten_paths`` only reaches strings it can find by walking a parsed
    document. The same cluster paths appear inside ``diagnosis.md`` prose and
    LaTeX table cells, where nothing parses them.
    """
    return _ABS_PATH.sub(lambda m: "/".join(Path(m.group(0)).parts[-2:]), text)


def scrub_staged_tree(staging: Path) -> list[str]:
    """Scrub every document and text file in the staged tree, in place.

    The stagers copy whole directories -- ``tables/``, ``figures/``,
    ``gdelta/`` -- so naming the files to scrub means maintaining a list by
    hand, and a list of known filenames is the same mistake as a known
    nesting. Walking what was actually staged makes the set of files scrubbed
    identical to the set published, by construction.

    This closes a reach bug, not a depth one: ``stage_runs`` copied
    ``results.json`` with ``shutil.copy2`` and scrubbed nothing at all, so the
    released file carried `wandb_project`, `wandb_entity` and the absolute
    path of the machine it ran on. ``scrub`` was correct and was simply never
    called on it.

    A file is rewritten only when scrubbing changes it, so anything already
    clean keeps its bytes.

    Returns:
        Staged-relative paths of the files that changed.
    """
    changed: list[str] = []
    for path in sorted(staging.rglob("*")):
        if not path.is_file():
            continue
        suffix = path.suffix.lower()
        if suffix in _DOC_SUFFIXES:
            raw = path.read_text()
            try:
                doc = (json.loads(raw) if suffix == ".json"
                       else yaml.safe_load(raw))
            except (json.JSONDecodeError, yaml.YAMLError):
                continue
            if not isinstance(doc, (dict, list)):
                continue
            clean = drop_environment_keys(shorten_paths(doc))
            if clean == doc:
                continue
            path.write_text(
                json.dumps(clean, indent=2) + "\n" if suffix == ".json"
                else yaml.safe_dump(clean, sort_keys=False)
            )
            changed.append(str(path.relative_to(staging)))
        elif suffix in _TEXT_SUFFIXES:
            raw = path.read_text()
            clean = shorten_text_paths(raw)
            if clean != raw:
                path.write_text(clean)
                changed.append(str(path.relative_to(staging)))
    return changed


def scrub_checkpoint(src: Path, dst: Path) -> None:
    """Copy a ``.pth`` with its provenance keys dropped.

    ``scrub`` only reaches the YAML/JSON sidecars, so `wandb_run_id` shipped
    inside the pickled checkpoints themselves (iter563.pth -> runid002,
    offline_step50000.pth -> runid003) while the uploader reported that W&B
    settings had been scrubbed. Environment keys are dropped with the same
    predicate the configs use; every training-state key is preserved.
    """
    import torch

    ckpt = torch.load(src, map_location="cpu", weights_only=False)
    if not isinstance(ckpt, dict):
        shutil.copy2(src, dst)
        return
    dropped = environment_key_paths(ckpt)
    if not dropped:
        # Nothing to remove, so copy the bytes rather than re-saving them:
        # torch.save is not byte-reproducible, and a clean checkpoint should
        # not change on the Hub just for having been inspected.
        shutil.copy2(src, dst)
        return
    torch.save(drop_environment_keys(ckpt), dst)
    print(f"  scrubbed {', '.join(sorted(dropped))} from {src.name}")


def export_weights(pth: Path, out: Path) -> dict:
    """Write EMA inference weights as safetensors; return checkpoint stats."""
    import torch
    from safetensors.torch import save_file

    ckpt = torch.load(pth, map_location="cpu", weights_only=False)
    model_sd = ckpt["model_state_dict"]
    ema_sd = ckpt.get("ema_state_dict") or {}
    source = "ema_state_dict" if set(ema_sd) >= set(model_sd) else "model_state_dict"
    tensors = {
        k: v.contiguous()
        for k, v in (ema_sd if source == "ema_state_dict" else model_sd).items()
    }
    save_file(tensors, str(out), metadata={"format": "pt", "source": source})

    # The offline trainer stores step x batch under `env_steps`; those are
    # sample-equivalents, not env.step() calls. Only DAgger's are real.
    if "iteration" in ckpt:
        counter, value = "dagger_iteration", ckpt["iteration"]
        step, unit = f"iteration {value:,}", "env steps"
    elif "step" in ckpt:
        counter, value = "gradient_step", ckpt["step"]
        step, unit = f"gradient step {value:,}", "sample-equivalents"
    else:
        counter, value = None, None
        step, unit = "final", "sample-equivalents"

    return {
        "params": sum(v.numel() for v in tensors.values()),
        "source": source,
        "counter": counter,
        "value": value,
        "step": step,
        "detail": f"{ckpt['env_steps']:,} {unit}" if ckpt.get("env_steps") else step,
        "keys": sorted(
            k for k in ckpt
            if k.endswith("state_dict") or k in {"rng_states", "curriculum_state"}
        ),
    }


def required(cfg: dict, key: str) -> object:
    """Read a key the published record depends on, or fail the upload.

    ``.get()`` here published a null instead: every DAgger `selection.json`
    released before this guard recorded `"every": null` because the two keys
    read had been renamed out of the config, while the model card claims each
    file records the budget it came from. A declared-but-null value is still
    accepted -- the four `offline_*` keys are nullable by design.
    """
    if key not in cfg:
        raise KeyError(
            f"{key} is absent from the checkpoint's config snapshot, so "
            f"selection.json cannot record the candidate set it came from"
        )
    return cfg[key]


def selection(cfg: dict, stats: dict, metric: str | None) -> dict:
    """Record the best-of-N selection a published checkpoint came from."""
    if stats["counter"] == "gradient_step":
        candidates = {
            "unit": "gradient_steps",
            "every": required(cfg, "offline_checkpoint_every_grad_steps"),
            "configured_budget": required(cfg, "offline_total_grad_steps"),
        }
    else:
        # DAgger checkpoints on an env-step cadence (`online.py` compares
        # against `env_steps_total`) but names its files by iteration, so the
        # candidate set is denominated in env steps and `selected` is not.
        candidates = {
            "unit": "env_steps",
            "every": required(cfg, "checkpoint_every_timesteps"),
            "configured_max": required(cfg, "total_timesteps"),
        }
    return {
        "policy": "best-of-N over periodic checkpoints",
        "selected": {stats["counter"]: stats["value"]},
        "selection_metric": metric,
        "candidates": candidates,
        "eval_protocol": {
            "episodes_per_env": required(cfg, "checkpoint_eval_episodes"),
            "weights": "ema",
            "id_envs": required(cfg, "id_envs"),
            "ood_envs": required(cfg, "ood_envs"),
        },
    }


# =============================================================================
# Description
# =============================================================================

def describe(
    model_dir: Path, staged: Path, pth: Path, cfg_name: str, stats: dict, cfg: dict,
) -> dict[str, str]:
    """Pull env name and training detail out of a checkpoint's own metadata."""
    return {
        "path": str(model_dir.relative_to(ROOT)),
        "role": ROLES.get(model_dir.parent.name, model_dir.parent.name),
        "env": cfg.get("env_name") or ENV_NAME,
        "arch": (
            f"{cfg['n_layer']}L, d_model {cfg['n_embd']}, {cfg['n_head']} heads, "
            f"horizon {cfg['seq_len']}, {stats['params'] / 1e6:.0f}M params"
        ),
        "step": stats["step"],
        "detail": stats["detail"],
        "size": human_size(staged),
        "restores": ", ".join(f"`{k}`" for k in stats["keys"]),
        "file": pth.name,
        "config": cfg_name,
    }


def describe_run(run: Path, staged: Path) -> dict[str, str]:
    """Summarise what an ablation run contributes to the release."""
    counts = [
        f"{len(list((staged / d).glob('*')))} {d}"
        for d in RUN_DIRS if (staged / d).is_dir()
    ]
    files = [f for f in RUN_FILES if (staged / f).is_file()]
    return {
        "run": run.name,
        "path": str(run.relative_to(ROOT)),
        "contents": ", ".join([*(f"`{f}`" for f in files), *counts]),
        "size": human_size(staged),
    }


def describe_inference(name: str, payload: dict) -> dict[str, str]:
    """Summarise one ``--mode inference`` result JSON."""
    results = payload.get("results", payload)
    per_env = {
        env: stats for env, stats in results.items()
        if isinstance(stats, dict) and "win_rate" in stats
    }
    rates = [stats["win_rate"] for stats in per_env.values()]
    episodes = {stats.get("n_episodes") for stats in per_env.values()}
    return {
        "file": name,
        "env": (
            next(iter(per_env)) if len(per_env) == 1
            else f"{len(per_env)} envs" if per_env else "-"
        ),
        "episodes": (
            f"{max(e for e in episodes if e)} episodes per env"
            if episodes - {None} else "-"
        ),
        "metric": (
            f"mean win rate {sum(rates) / len(rates):.2f}" if rates else "-"
        ),
    }


# =============================================================================
# Staging
# =============================================================================

def stage_checkpoints(
    staging: Path, models: dict[Path, list[Path]], metric: str | None,
) -> list[dict[str, str]]:
    """Copy each checkpoint directory, scrubbing its provenance metadata."""
    rows = []
    for model_dir, pths in models.items():
        target = staging / model_dir.relative_to(ROOT)
        target.mkdir(parents=True, exist_ok=True)

        cfg: dict = {}
        cfg_name = ""
        for src in sorted(model_dir.iterdir()):
            if src.suffix in {".yaml", ".yml"}:
                cfg, cfg_name = scrub(yaml.safe_load(src.read_text())), src.name
                (target / src.name).write_text(yaml.safe_dump(cfg, sort_keys=True))
            elif src.suffix == ".pth":
                scrub_checkpoint(src, target / src.name)

        stats = export_weights(pths[-1], target / "model.safetensors")
        (target / "selection.json").write_text(
            json.dumps(selection(cfg, stats, metric), indent=2) + "\n",
        )
        rows.append(describe(model_dir, target, pths[-1], cfg_name, stats, cfg))
    return rows


def stage_runs(staging: Path, runs: list[Path]) -> list[dict[str, str]]:
    """Copy each ablation run's summary, tables and figures."""
    rows = []
    for run in runs:
        target = staging / run.relative_to(ROOT)
        target.mkdir(parents=True, exist_ok=True)
        for name in RUN_FILES:
            if (run / name).is_file():
                shutil.copy2(run / name, target / name)
        for name in RUN_DIRS:
            if (run / name).is_dir():
                shutil.copytree(run / name, target / name, ignore=COPY_IGNORE)
        rows.append(describe_run(run, target))
    return rows


def stage_inference(staging: Path, files: list[Path]) -> list[dict[str, str]]:
    """Copy each inference result JSON into ``results/inference/``."""
    target_dir = staging / INFERENCE.relative_to(ROOT)
    rows: list[dict[str, str]] = []
    for src in files:
        try:
            payload = json.loads(src.read_text())
        except json.JSONDecodeError:
            print(f"Skipping unreadable inference result {src}.", file=sys.stderr)
            continue
        name = src.name
        if any(r["file"] == name for r in rows):
            name = f"{src.parent.name}-{src.name}"
        target_dir.mkdir(parents=True, exist_ok=True)
        # scrub, not shorten_paths: this shortened cluster paths while doing
        # nothing about environment keys, the same asymmetry as stage_runs one
        # step further along. These payloads carry no config today.
        (target_dir / name).write_text(
            json.dumps(scrub(payload), indent=2) + "\n",
        )
        row = describe_inference(name, payload)
        row["size"] = human_size(target_dir / name)
        rows.append(row)
    return rows


def stage_paper_figures(staging: Path, files: list[Path]) -> list[dict[str, str]]:
    """Copy the manuscript figures into ``results/paper_figures/``."""
    if not files:
        return []
    target_dir = staging / PAPER_FIGURES.relative_to(ROOT)
    target_dir.mkdir(parents=True, exist_ok=True)
    rows = []
    for src in files:
        shutil.copy2(src, target_dir / src.name)
        rows.append({"file": src.name, "size": human_size(target_dir / src.name)})
    return rows


def stage(
    staging: Path,
    models: dict[Path, list[Path]],
    runs: list[Path],
    inference: list[Path],
    figures: list[Path],
    metric: str | None,
) -> tuple[
    list[dict[str, str]], list[dict[str, str]],
    list[dict[str, str]], list[dict[str, str]],
]:
    """Stage checkpoints, results and LICENSE; the card is written by the caller.

    LICENSE comes from git rather than the working tree: a
    ``hf download --local-dir .`` overwrites it with the Hub's own copy, and
    publishing from the tree would push that straight back up as current.
    """
    rows = stage_checkpoints(staging, models, metric)
    run_rows = stage_runs(staging, runs)
    inf_rows = stage_inference(staging, inference)
    fig_rows = stage_paper_figures(staging, figures)
    # ROOT is passed explicitly rather than read inside the helper, so a
    # test can rebind it and have that take effect.
    copy_tracked_file("LICENSE", staging / "LICENSE", ROOT)
    # Last, so it covers everything every stager copied -- including the whole
    # directories stage_runs copies wholesale, which no stager scrubbed.
    for name in scrub_staged_tree(staging):
        print(f"  scrubbed provenance from {name}")
    return rows, run_rows, inf_rows, fig_rows


# =============================================================================
# Model card
# =============================================================================

def table(headers: list[str], lines: list[str]) -> str:
    sep = "|".join(["---"] * len(headers))
    return f"| {' | '.join(headers)} |\n|{sep}|\n" + "".join(f"| {ln} |\n" for ln in lines)


def checkpoint_table(rows: list[dict[str, str]]) -> str:
    return table(
        ["Path", "Role", "Environment", "Architecture", "Selected at", "Training", "Size"],
        [
            f"`{r['path']}` | {r['role']} | `{r['env']}` | {r['arch']} | "
            f"{r['step']} | {r['detail']} | {r['size']}"
            for r in sorted(rows, key=lambda r: r["path"])
        ],
    )


def results_section(
    run_rows: list[dict[str, str]],
    inf_rows: list[dict[str, str]],
    fig_rows: list[dict[str, str]],
) -> str:
    """Ablation and inference tables; empty when the release carries neither."""
    parts = []
    if run_rows:
        parts.append(
            "RL fine-tuning ablation runs, as produced by "
            "`experiments/rl_finetuning/run_ablations.py`. Each run ships its "
            "`results.json` summary, the `diagnosis.md` write-up, and the "
            "tables (`.csv` and `.tex`) and figures generated from it.\n\n"
            + table(
                ["Run", "Contents", "Size"],
                [
                    f"`{r['path']}` | {r['contents']} | {r['size']}"
                    for r in sorted(run_rows, key=lambda r: r["run"])
                ],
            ),
        )
    if inf_rows:
        parts.append(
            "Evaluation results produced by `main.py --mode inference` on the "
            "checkpoints above, under `results/inference/`.\n\n"
            + table(
                ["File", "Environment", "Evaluation", "Headline metric", "Size"],
                [
                    f"`{r['file']}` | `{r['env']}` | {r['episodes']} | "
                    f"{r['metric']} | {r['size']}"
                    for r in sorted(inf_rows, key=lambda r: r["file"])
                ],
            ),
        )
    if fig_rows:
        parts.append(
            "Manuscript figures as vector PDF, under `results/paper_figures/`. "
            "Each puts Craftax Classic and MiniHack side by side, so they are "
            "built from both repositories' ablation `results.json` by the "
            "sibling repo's `scripts/paper_figures.py` and published "
            "identically in both Hub repos.\n\n"
            + table(
                ["Figure", "Size"],
                [
                    f"`{r['file']}` | {r['size']}"
                    for r in sorted(fig_rows, key=lambda r: r["file"])
                ],
            ),
        )
    return "## Results\n\n" + "\n".join(parts) if parts else ""


def featured(rows: list[dict[str, str]]) -> dict[str, str]:
    """The checkpoint the download and usage examples are written against."""
    planners = [r for r in rows if "planner" in r["role"].lower()]
    return sorted(planners or rows, key=lambda r: r["path"])[0]


def model_card(
    repo_id: str,
    rows: list[dict[str, str]],
    run_rows: list[dict[str, str]],
    inf_rows: list[dict[str, str]],
    fig_rows: list[dict[str, str]],
    total_mb: float,
    metric: str | None,
) -> str:
    example = featured(rows)
    selected_on = (
        f"selected on {metric}" if metric
        else "the metric behind that selection is not recorded in this release"
    )
    return f"""---
license: mit
library_name: pytorch
pipeline_tag: reinforcement-learning
tags:
- reinforcement-learning
- planning
- discrete-diffusion
- remdm
- minihack
- nethack
- pytorch
---

# ReMDM Planner: {ENV_NAME} checkpoints

Trained weights accompanying *{PAPER}*: a remasking discrete diffusion model
(ReMDM) used as an action-sequence planner in
[MiniHack](https://github.com/facebookresearch/minihack), together with the BFS
oracle rollouts that supervise it, and the results reported in the paper.

Code, configs and evaluation harness: {CODE_URL}

## Contents

{checkpoint_table(rows)}
Each checkpoint ships the `.pth` training state it was published from (weights,
EMA shadow, optimiser, scheduler, and for the DAgger run the curriculum and RNG
state, so training resumes exactly), a `model.safetensors` export of the EMA
weights for inference, the YAML config snapshot it was trained under, and a
`selection.json` recording how it was chosen.

Weights are PyTorch training states with a `safetensors` export of the EMA
weights alongside, and the paths above mirror the source repository so a
snapshot can be dropped straight into a working copy.

{results_section(run_rows, inf_rows, fig_rows)}
## Download

This repo mirrors the code repository's layout, so a snapshot drops straight
into a working copy -- but it also carries its own `README.md` (this card),
`LICENSE` and `.gitattributes`, and `local_dir="."` would overwrite the code
repository's copies of all three. Exclude them, or download into a directory
of its own.

```python
from huggingface_hub import snapshot_download

# everything (~{total_mb:.0f} MB), into a clone of the code repository
snapshot_download(
    repo_id="{repo_id}",
    local_dir=".",
    ignore_patterns=["README.md", "LICENSE", ".gitattributes"],
)

# or somewhere of its own, leaving any working copy untouched
snapshot_download(repo_id="{repo_id}", local_dir="remdm-planner-minihack")

# a single model
snapshot_download(
    repo_id="{repo_id}",
    local_dir=".",
    allow_patterns="{example['path']}/**",
)
```

## Use

From a clone of the code repository, after downloading into it:

```bash
DIR={example['path']}
uv run python main.py --mode inference \\
    --config $DIR/{example['config']} --checkpoint $DIR/{example['file']} \\
    --output results/inference/eval.json
```

Programmatic loading uses `src.models.denoiser.make_model` with the checkpoint's
own config, then the safetensors export:

```python
from safetensors.torch import load_file
from src.config import load_config
from src.models.denoiser import make_model

cfg = load_config("{example['path']}/{example['config']}")
model = make_model(cfg)
model.load_state_dict(load_file("{example['path']}/model.safetensors"))
model.eval()
```

Architecture arguments should be read from the checkpoint's own config snapshot
rather than from `configs/defaults.yaml`, which tracks the current code.

## Training

The planners are bidirectional transformers that denoise a masked action plan
conditioned on a cropped MiniHack glyph observation, trained either by offline
behaviour cloning on oracle rollouts or by online DAgger against the BFS oracle
under a dynamic environment curriculum. Model size and horizon differ per run
(see the table). Exact hyperparameters for every run, including the
in-distribution and out-of-distribution environment sets, the remasking
strategy, schedule and sampling settings, are in the per-checkpoint config
snapshots listed above, which are the authoritative record.

Both models are best-checkpoint selections rather than final-step dumps: each
trainer evaluates every periodic checkpoint on its configured number of
episodes per environment using EMA weights, and the highest-scoring one is
published ({selected_on}). Directory names encode the sample-equivalents the
published model consumed (gradient steps x batch size, rounded); file names
carry each trainer's own counter, DAgger iterations online and gradient steps
offline. Each checkpoint's `selection.json` records the configured budget it
was drawn from and the step it was selected at.

## Limitations

These are research artefacts tied to specific MiniHack environment versions and
to the cropped-glyph observation encoding; they are not general-purpose agents
and will not transfer to other environments or to pixel observations.
Evaluation results and their variance are reported in the paper.

## Citation

```bibtex
@inproceedings{{remdm-planner-minihack-planner,
  title  = {{{PAPER}}},
  author = {{Anonymous}},
  year   = {{2026}},
  note   = {{NeurIPS 2026 Workshop: Beyond Next-Token Prediction}}
}}
```

## License

MIT, see `LICENSE`.
"""


# =============================================================================
# Entry point
# =============================================================================

def parse_args() -> argparse.Namespace:
    p = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    p.add_argument("--repo-id", required=True, help="e.g. <ANON_HF_REPO_ID>")
    p.add_argument(
        "--inference-results", nargs="*", default=[], metavar="PATH",
        help=f"extra --mode inference JSONs or directories, on top of "
             f"{INFERENCE.relative_to(ROOT)}/",
    )
    p.add_argument(
        "--selection-metric",
        help="metric the best checkpoint was chosen on, e.g. 'mean ID+OOD win rate'",
    )
    p.add_argument("--private", action="store_true", help="create the repo private")
    p.add_argument("--dry-run", action="store_true", help="stage and print, do not upload")
    p.add_argument("--yes", action="store_true", help="skip the confirmation prompt")
    return p.parse_args()


def main() -> int:
    args = parse_args()

    token = os.environ.get("HF_TOKEN")
    if not args.dry_run and not token:
        print("HF_TOKEN is not set.", file=sys.stderr)
        return 1

    models = discover_checkpoints()
    if not models:
        print(
            f"No .pth checkpoints found under {CKPTS}.\n"
            "Discovery expects the released layout, "
            "checkpoints/<role>/<name>/*.pth, and skips checkpoints/hf/ "
            "because that is where Hub downloads land. Copy a run's "
            "checkpoints into checkpoints/{offline,online}/<name> first.",
            file=sys.stderr,
        )
        return 1
    runs = discover_runs()
    inference = discover_inference(args.inference_results)
    figures = discover_paper_figures()

    with tempfile.TemporaryDirectory(prefix="remdm-planner-minihack-") as tmp:
        staging = Path(tmp)
        rows, run_rows, inf_rows, fig_rows = stage(
            staging, models, runs, inference, figures, args.selection_metric,
        )
        total_mb = dir_size_mb(staging)
        card = model_card(
            args.repo_id, rows, run_rows, inf_rows, fig_rows, total_mb,
            args.selection_metric,
        )
        (staging / "README.md").write_text(card)

        files = [f for f in staging.rglob("*") if f.is_file()]
        print(f"Staged {plural(len(rows), 'checkpoint')}, "
              f"{plural(len(run_rows), 'ablation run')}, "
              f"{plural(len(inf_rows), 'inference result')}, "
              f"{plural(len(fig_rows), 'paper figure')}, "
              f"{plural(len(files), 'file')}, {total_mb:.0f} MB")
        for r in sorted(rows, key=lambda r: r["path"]):
            print(f"  {r['path']:<70} {r['size']:>8}  {r['detail']}")
            print(f"  {'':<70} restores {r['restores']}")
        for r in sorted(run_rows, key=lambda r: r["run"]):
            print(f"  {r['path']:<70} {r['size']:>8}  {r['contents']}")
        for r in sorted(inf_rows, key=lambda r: r["file"]):
            print(f"  results/inference/{r['file']:<52} {r['size']:>8}  {r['metric']}")
        for r in sorted(fig_rows, key=lambda r: r["file"]):
            print(f"  results/paper_figures/{r['file']:<48} {r['size']:>8}")
        if not run_rows:
            print(f"Warning: no ablation runs with a results.json under {RUNS}.",
                  file=sys.stderr)
        if not inf_rows:
            print("Warning: no inference results; produce them with "
                  "`main.py --mode inference --output "
                  f"{INFERENCE.relative_to(ROOT)}/<name>.json`.", file=sys.stderr)
        if not fig_rows:
            print(
                "Warning: no manuscript figures; build them with the sibling "
                "repo's `scripts/paper_figures.py` and copy the PDFs into "
                f"{PAPER_FIGURES.relative_to(ROOT)}/.", file=sys.stderr,
            )
        if not args.selection_metric:
            print(
                "Warning: --selection-metric not given; these are best-of-N "
                "checkpoints and the card cannot say what they were chosen on.",
                file=sys.stderr,
            )

        if args.dry_run:
            print(f"Dry run; staged tree left nowhere. Card:\n\n{card}")
            return 0

        if not args.yes:
            visibility = "private" if args.private else "public"
            if input(f"Upload to {args.repo_id} ({visibility})? [y/N] ").strip().lower() not in {"y", "yes"}:
                print("Aborted.")
                return 0

        from huggingface_hub import HfApi

        api = HfApi(token=token)
        api.create_repo(args.repo_id, repo_type="model", private=args.private, exist_ok=True)
        api.upload_folder(
            repo_id=args.repo_id,
            folder_path=str(staging),
            repo_type="model",
            ignore_patterns=HUB_IGNORE,
            commit_message="Upload MiniHack ReMDM planner checkpoints and results",
        )
        print(f"Done: https://huggingface.co/{args.repo_id}")
    return 0


if __name__ == "__main__":
    sys.exit(main())