| """Upload the trained Craftax ReMDM checkpoints and results to the Hugging Face Hub. |
| |
| Discovers every Orbax checkpoint under ``checkpoints/``, every ablation run |
| under ``experiments/rl_finetuning/outputs/`` and every ``--mode inference`` |
| result under ``results/inference/``, the manuscript figure PDFs under |
| ``results/paper_figures/``, stages them with the repo-relative layout |
| preserved, drops wandb environment metadata (which carries the author's email, |
| hostname and local paths), 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 = Path(__file__).resolve().parent |
| if str(_SCRIPTS) not in sys.path: |
| sys.path.insert(0, str(_SCRIPTS)) |
|
|
| from _git_provenance import copy_tracked_file |
|
|
| 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 = "Craftax" |
|
|
| ROLES = { |
| "offline": "Diffusion planner (offline BC)", |
| "online": "Diffusion planner (online DAgger)", |
| "ppo_agents": "PPO-RNN expert", |
| } |
|
|
| |
| |
| RUN_FILES = ("results.json", "diagnosis.md") |
| |
| |
| RUN_DIRS = ("tables", "figures", "gdelta") |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| DROP_PREFIXES = ("hub_",) |
| 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"] |
|
|
|
|
| |
| |
| |
|
|
| |
| |
| |
| 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[int]]: |
| """Map each checkpoint directory to its saved step numbers. |
| |
| Discovery is at the **released layout**, ``checkpoints/<role>/<name>/<step>/`` |
| — the layout the Hub repo mirrors, which is why `--dry-run` shows the tree a |
| publish would create. A training run writes elsewhere, so its `policies` |
| directory has to be copied into place first; the README documents that and |
| it is a real requirement, not an accident of this glob. |
| |
| Anything under ``checkpoints/hf/`` is skipped as a download copy. The fixed |
| depth already excluded it here, one level deeper than a real checkpoint, but |
| only by arithmetic — the exclusion is now stated, so it survives a layout |
| with a different depth. |
| |
| Measured on this repo's live tree: 4 checkpoints discovered, 4 download |
| copies under ``checkpoints/hf/`` skipped. |
| |
| Returns: |
| ``{checkpoint directory: [step numbers]}``. |
| """ |
| models: dict[Path, list[int]] = {} |
| for marker in sorted(CKPTS.glob("*/*/*/_CHECKPOINT_METADATA")): |
| if _is_download_copy(marker): |
| continue |
| models.setdefault(marker.parent.parent, []).append(int(marker.parent.name)) |
| 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 figure PDFs built by ``scripts/paper_figures.py``. |
| |
| Unlike everything else published here these are *cross-environment*: the |
| script reads both this repository's and the MiniHack sibling's |
| ``results.json`` and draws the two side by side, so the same PDFs belong in |
| both releases and neither repository can build them alone. |
| """ |
| 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)) |
|
|
|
|
| |
| |
| |
|
|
| 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 a scrub 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 live defect here: `scrub_abs_paths` |
| recursed with `shorten_paths` but filtered keys at the top of the document |
| only, so `USE_WANDB`, `WANDB_PROJECT`, `WANDB_ENTITY` and |
| `WANDB_DOWNLOAD_DIR` survived one level down inside `config_snapshot` and |
| were published in both released checkpoints' `resume_metadata.json`, while |
| the uploader printed a successful scrub. No credential was exposed -- those |
| live in the `_wandb` blob and `wandb-metadata.json`, both already removed -- |
| but the published surface advertised a W&B account and project that are |
| nothing to do with the recipe, which the scrub exists to prevent. |
| |
| Mapping types are preserved, so an ordered mapping stays ordered and a |
| published structure is unchanged beyond the removed keys. |
| """ |
| 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: |
| |
| |
| return kept |
| if isinstance(value, list): |
| return [drop_environment_keys(sub) for sub in value] |
| return value |
|
|
|
|
| def scrub(cfg): |
| """Drop the environment keys and shorten absolute cluster paths. |
| |
| Both passes recurse. They used to disagree -- ``shorten_paths`` descended |
| and the key filter did not -- which is the asymmetry that published W&B |
| settings out of a nested `config_snapshot`. Composing them is what stops |
| them disagreeing about depth again; special-casing the one nesting we know |
| about is what left the general case broken in the first place. |
| |
| A document with no environment key anywhere and no absolute path comes back |
| equal to what went in, so a caller may skip rewriting it. |
| """ |
| return drop_environment_keys(shorten_paths(cfg)) |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| ABS_PATH_IN_TEXT = re.compile(r"(?<![\w.@+\-])/(?:[\w.@+\-]+/){2,}[\w.@+\-]+") |
|
|
|
|
| def shorten_text_paths(text: str) -> str: |
| """Shorten absolute cluster paths inside a plain-text file. |
| |
| :func:`shorten_paths` only reaches paths that sit in a JSON or YAML *value*. |
| Orbax writes its own marker files, and `commit_success.txt` records the |
| absolute directory it committed to -- which on this cluster is inside the |
| W&B run directory, so each published marker carried the account name, the |
| full home path and the run id. That is the same `wandb_run_id` the sidecar |
| scrub takes care to remove, published verbatim two directories away. |
| |
| Same rule as the structured shortener: keep the last two components. |
| """ |
| return ABS_PATH_IN_TEXT.sub( |
| lambda m: "/".join(Path(m.group(0)).parts[-2:]), text |
| ) |
|
|
|
|
| def scrub_staged_json(path: Path) -> list[str]: |
| """Scrub a staged JSON file in place; return the paths it dropped.""" |
| try: |
| payload = json.loads(path.read_text()) |
| except (json.JSONDecodeError, UnicodeDecodeError): |
| return [] |
| dropped = environment_key_paths(payload) |
| cleaned = scrub(payload) |
| if cleaned != payload: |
| path.write_text(json.dumps(cleaned, indent=2)) |
| return dropped |
|
|
|
|
| def scrub_staged_tree(target: Path) -> None: |
| """Scrub every JSON and Orbax marker anywhere under a staged directory. |
| |
| Staging copied whole trees and scrubbed only the files it knew by name, so |
| provenance rode out in the ones it did not: `wandb-summary.json` beside a |
| PPO checkpoint, an ablation run's `results.json` (which carried the W&B |
| entity into a published release), and Orbax's own `commit_success.txt`. |
| Walking the staged tree is what stops the list of known filenames from |
| being the thing correctness depends on. |
| """ |
| for f in sorted(target.rglob("*")): |
| if not f.is_file(): |
| continue |
| if f.suffix == ".json": |
| for key in scrub_staged_json(f): |
| print(f" scrubbed {key} from {f.name}") |
| elif f.name.endswith(".txt"): |
| text = f.read_text(errors="ignore") |
| shortened = shorten_text_paths(text) |
| if shortened != text: |
| f.write_text(shortened) |
| print(f" shortened cluster paths in {f.name}") |
|
|
|
|
| def strip_wandb_block(config_yaml: Path) -> None: |
| """Drop the environment keys from a staged config, at every depth. |
| |
| Was `_wandb` alone, which left `USE_WANDB`, `WANDB_ENTITY` and |
| `WANDB_PROJECT` in the released `config.yaml`. No credential was ever |
| exposed — those live in the `_wandb` blob and `wandb-metadata.json`, both |
| already removed — but the published surface advertised a W&B account and |
| project that are nothing to do with the recipe, and the sibling repo |
| dropped a different set again. Both now drop the same one. |
| |
| A PPO `config.yaml` happens to hold its environment keys at the top level, |
| so a top-level filter sufficed here today. That was luck rather than |
| correctness — the same luck ran out one function down, where a nested |
| `config_snapshot` published what a top-level filter could not see — so this |
| recurses too, and stops depending on the shape of the file it is handed. |
| """ |
| raw = yaml.safe_load(config_yaml.read_text()) |
| kept = drop_environment_keys(raw) |
| config_yaml.write_text(yaml.safe_dump(kept, sort_keys=True)) |
|
|
|
|
| def scrub_abs_paths(resume_json: Path) -> None: |
| """Shorten absolute cluster paths and drop provenance from the sidecar. |
| |
| Shortening the snapshot alone left `wandb_run_id`, which |
| save_checkpoint_metadata writes at the top level, in every released |
| checkpoint's metadata. The sibling repo shipped the same id inside its |
| pickled `.pth` files; both now drop it. |
| |
| Dropping it at the top level then left everything below it. `shorten_paths` |
| recursed into `config_snapshot` while the key filter read only the top of |
| the document, so `USE_WANDB`, `WANDB_PROJECT`, `WANDB_ENTITY` and |
| `WANDB_DOWNLOAD_DIR` went out in both released checkpoints while this |
| function reported a clean scrub. Both passes now recurse, by composition |
| rather than by a special case for the one nesting we happened to know |
| about, and the removed paths are named rather than assumed. |
| """ |
| meta = json.loads(resume_json.read_text()) |
| dropped = environment_key_paths(meta) |
| resume_json.write_text(json.dumps(scrub(meta), indent=2)) |
| if dropped: |
| print(f" scrubbed {', '.join(sorted(dropped))} from {resume_json.name}") |
|
|
|
|
| |
| |
| |
|
|
| def describe(model_dir: Path, steps: list[int]) -> dict[str, str]: |
| """Pull env name and training detail out of a checkpoint's own metadata.""" |
| resume = model_dir / "resume_metadata.json" |
| if resume.exists(): |
| meta = json.loads(resume.read_text()) |
| cfg = meta["config_snapshot"] |
| detail = f"{meta['total_gradient_steps_completed']:,} grad steps" |
| arch = ( |
| f"{cfg['N_LAYERS']}L, d_model {cfg['D_MODEL']}, " |
| f"{cfg['N_HEADS']} heads, horizon {cfg['PLAN_HORIZON']}" |
| ) |
| else: |
| raw = yaml.safe_load((model_dir / "config.yaml").read_text()) |
| cfg = { |
| k: v["value"] for k, v in raw.items() if not is_environment_key(k) |
| } |
| detail = f"{float(cfg['TOTAL_TIMESTEPS']):.0e} frames" |
| arch = f"RNN, layer size {cfg['LAYER_SIZE']}" |
| return { |
| "path": str(model_dir.relative_to(ROOT)), |
| "role": ROLES.get(model_dir.parent.name, model_dir.parent.name), |
| "env": cfg["ENV_NAME"], |
| "arch": arch, |
| "step": f"{max(steps):,}", |
| "detail": detail, |
| "size": human_size(model_dir), |
| } |
|
|
|
|
| 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.""" |
| metrics = payload.get("metrics", payload) |
| score = metrics.get("mean_score") |
| envs, steps = metrics.get("eval_num_envs"), metrics.get("eval_steps") |
| return { |
| "file": name, |
| "env": payload.get("env_name", "-"), |
| "episodes": f"{envs} envs x {steps} steps" if envs and steps else "-", |
| "metric": ( |
| f"mean score {score:.2f}" if isinstance(score, int | float) else "-" |
| ), |
| } |
|
|
|
|
| |
| |
| |
|
|
| def stage_checkpoints(staging: Path, models: dict[Path, list[int]]) -> list[dict[str, str]]: |
| """Copy each checkpoint directory, scrubbing its provenance metadata.""" |
| rows = [] |
| for model_dir, steps in models.items(): |
| target = staging / model_dir.relative_to(ROOT) |
| target.parent.mkdir(parents=True, exist_ok=True) |
| shutil.copytree(model_dir, target, ignore=COPY_IGNORE) |
| if (target / "config.yaml").exists(): |
| strip_wandb_block(target / "config.yaml") |
| if (target / "resume_metadata.json").exists(): |
| scrub_abs_paths(target / "resume_metadata.json") |
| scrub_staged_tree(target) |
| rows.append(describe(model_dir, steps)) |
| return rows |
|
|
|
|
| def stage_runs(staging: Path, runs: list[Path]) -> list[dict[str, str]]: |
| """Copy each ablation run's summary, tables and figures, scrubbed. |
| |
| These were copied verbatim, and every run's `results.json` embeds the |
| config it ran under -- so `WANDB_ENTITY` went out in the published ablation |
| suite. The checkpoint sidecar was scrubbed by name while this whole tree |
| was not, which is the same defect one directory across. |
| """ |
| 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) |
| scrub_staged_tree(target) |
| 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/``. |
| |
| Scrubbed, not merely shortened: this staged paths without ever filtering |
| environment keys, which is the same asymmetry that leaked from the |
| checkpoint sidecar, one step further along. No inference payload carries an |
| environment key today; the point is that nothing here depends on that. |
| """ |
| 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) |
| (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, figures: list[Path]) -> list[dict[str, str]]: |
| """Copy the manuscript figures into ``results/paper_figures/``.""" |
| if not figures: |
| return [] |
| target_dir = staging / PAPER_FIGURES.relative_to(ROOT) |
| target_dir.mkdir(parents=True, exist_ok=True) |
| rows = [] |
| for src in figures: |
| 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[int]], |
| runs: list[Path], |
| inference: list[Path], |
| paper_figures: list[Path], |
| ) -> 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) |
| run_rows = stage_runs(staging, runs) |
| inf_rows = stage_inference(staging, inference) |
| fig_rows = stage_paper_figures(staging, paper_figures) |
| |
| |
| copy_tracked_file("LICENSE", staging / "LICENSE", ROOT) |
| return rows, run_rows, inf_rows, fig_rows |
|
|
|
|
| |
| |
| |
|
|
| 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, inference and manuscript-figure tables; empty when none.""" |
| 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 at NeurIPS column width, under " |
| "`results/paper_figures/`. These are built by " |
| "`scripts/paper_figures.py`, which reads the ablation " |
| "`results.json` of *both* environments and draws Craftax Classic " |
| "and MiniHack side by side, so the identical set is published in " |
| "this release and in the MiniHack one.\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, |
| ) -> str: |
| example = featured(rows) |
| return f"""--- |
| license: mit |
| library_name: jax |
| pipeline_tag: reinforcement-learning |
| tags: |
| - reinforcement-learning |
| - planning |
| - discrete-diffusion |
| - remdm |
| - craftax |
| - jax |
| - flax |
| - orbax |
| --- |
| |
| # ReMDM Planner: {ENV_NAME} checkpoints |
| |
| Trained weights accompanying *{PAPER}*: a remasking discrete diffusion model |
| (ReMDM) used as an action-sequence planner in |
| [Craftax](https://github.com/MichaelTMatthews/Craftax), together with the |
| PPO-RNN experts that supervise it, and the results reported in the paper. |
| |
| Code, configs and evaluation harness: {CODE_URL} |
| |
| ## Contents |
| |
| {checkpoint_table(rows)} |
| Each diffusion checkpoint ships a `resume_metadata.json` holding the full |
| config snapshot it was trained under; each PPO expert ships `config.yaml` and |
| `wandb-summary.json` (final training metrics). |
| |
| Weights are [Orbax](https://orbax.readthedocs.io) checkpoint directories |
| (OCDBT format), not `safetensors` — the models are Flax modules restored via |
| `orbax.checkpoint`, 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-craftax") |
| |
| # 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 |
| uv run python main.py --mode inference \\ |
| --checkpoint {example['path']} \\ |
| --output results/inference/eval.json |
| ``` |
| |
| Programmatic loading uses `src.planners.model.load_checkpoint` for the |
| diffusion planners and `src.planners.ppo.load_ppo_agent` for the experts; both |
| take the checkpoint directory path and restore the latest step. Architecture |
| arguments should be read from the checkpoint's own `resume_metadata.json` |
| rather than hardcoded. |
| |
| ## Training |
| |
| The diffusion planners are bidirectional transformers that denoise a masked |
| action plan conditioned on the symbolic observation, trained either by offline |
| behaviour cloning on PPO rollouts or by online DAgger against the PPO expert. |
| Model size and horizon differ per run (see the table); the PPO-RNN experts are |
| the Craftax baselines. Exact hyperparameters for every run, including the |
| remasking strategy, schedule and sampling settings, are in the per-checkpoint |
| metadata files listed above, which are the authoritative record. |
| |
| Directory names encode the environment and the total environment timesteps the |
| run was trained for. `Selected at` is whatever each run used as its Orbax step |
| counter, which is environment frames for the runs published here. |
| |
| ## Limitations |
| |
| These are research artefacts tied to specific Craftax versions and symbolic |
| observation encodings; 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-craftax-planner, |
| title = {{{PAPER}}}, |
| author = {{Anonymous}}, |
| year = {{2026}}, |
| note = {{NeurIPS 2026 Workshop: Beyond Next-Token Prediction}} |
| }} |
| ``` |
| |
| ## License |
| |
| MIT, see `LICENSE`. |
| """ |
|
|
|
|
| |
| |
| |
|
|
| 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("--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 Orbax checkpoints found under {CKPTS}.\n" |
| "Discovery expects the released layout, " |
| "checkpoints/<role>/<name>/<step>/, and skips checkpoints/hf/ " |
| "because that is where Hub downloads land. Copy a run's " |
| "`wandb.run.dir/policies` directory to " |
| "checkpoints/{offline,online}/<name> first.", |
| file=sys.stderr, |
| ) |
| return 1 |
| runs = discover_runs() |
| inference = discover_inference(args.inference_results) |
| paper_figures = discover_paper_figures() |
|
|
| with tempfile.TemporaryDirectory(prefix="remdm-planner-craftax-") as tmp: |
| staging = Path(tmp) |
| rows, run_rows, inf_rows, fig_rows = stage( |
| staging, models, runs, inference, paper_figures |
| ) |
| total_mb = dir_size_mb(staging) |
| card = model_card( |
| args.repo_id, rows, run_rows, inf_rows, fig_rows, total_mb |
| ) |
| (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}") |
| 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 " |
| "`uv run python scripts/paper_figures.py --outdir " |
| f"{PAPER_FIGURES.relative_to(ROOT)}` once the MiniHack " |
| "sibling's results.json is present.", 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 Craftax ReMDM planner checkpoints and results", |
| ) |
| print(f"Done: https://huggingface.co/{args.repo_id}") |
| return 0 |
|
|
|
|
| if __name__ == "__main__": |
| sys.exit(main()) |
|
|