File size: 6,402 Bytes
0dd6c2f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
Upload a tau-bench experiment directory (or just its archived best checkpoints) to the Hugging Face Hub.

Default scope is the entire experiment directory:
  `.art/<project>/models/<experiment>/`

The legacy "best_models" scope uploads only archived checkpoints under:
  `.art/<project>/models/<experiment>/best_models/<split>/<step>/`

Usage (dry-run first):
  uv run python linalg_zero/grpo/tau-bench/push_best_models_to_hf.py \
    --project linalgzero-grpo \
    --experiment linalgzero-grpo-001 \
    --hub-namespace atomwalk12 \
    --dry-run

Auth:
  - Run `huggingface-cli login` beforehand, or set `HF_TOKEN` in your environment.
"""

from __future__ import annotations

import argparse
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Literal


@dataclass(frozen=True)
class BestModelCheckpoint:
    model: str
    split: str
    step: int
    path: Path


_HF_REPO_NAME_ALLOWED = re.compile(r"[^A-Za-z0-9_.-]+")
_DEFAULT_IGNORE_PATTERNS: tuple[str, ...] = (
    "**/__pycache__/**",
    "**/.DS_Store",
)


def _sanitize_hf_repo_name(name: str) -> str:
    # Hugging Face repo names allow: letters, numbers, "-", "_", "."
    # Convert runs of other chars to a single "-".
    name = _HF_REPO_NAME_ALLOWED.sub("-", name).strip("-.")
    name = re.sub(r"-{2,}", "-", name)
    if not name:
        raise ValueError("Sanitized repo name is empty; choose a different template.")
    return name


def _discover_best_model_checkpoints(best_models_dir: Path) -> list[BestModelCheckpoint]:
    checkpoints: list[BestModelCheckpoint] = []
    if not best_models_dir.is_dir():
        return checkpoints

    for split_dir in sorted(p for p in best_models_dir.iterdir() if p.is_dir()):
        split = split_dir.name
        for step_dir in sorted(p for p in split_dir.iterdir() if p.is_dir()):
            if not step_dir.name.isdigit():
                continue
            checkpoints.append(
                BestModelCheckpoint(
                    model=best_models_dir.parent.name,
                    split=split,
                    step=int(step_dir.name),
                    path=step_dir,
                )
            )
    return checkpoints


def _format_repo_id(
    *, namespace: str, project: str, experiment: str, scope: Literal["experiment", "best_models"]
) -> str:
    suffix = "experiment" if scope == "experiment" else "best-models"
    repo_name = _sanitize_hf_repo_name(f"{project}--{experiment}--{suffix}")
    return f"{namespace}/{repo_name}"


def main() -> int:
    parser = argparse.ArgumentParser(description="Push one experiment directory to HF Hub.")
    parser.add_argument("--project", required=True, help="Project directory under `.art/` (e.g. linalgzero-grpo).")
    parser.add_argument(
        "--experiment",
        required=True,
        help="Experiment/model directory name under `.art/<project>/models/` (e.g. linalgzero-grpo-001).",
    )
    parser.add_argument("--hub-namespace", required=True, help="HF namespace/user/org (e.g. atomwalk12).")
    parser.add_argument(
        "--scope",
        choices=["experiment", "best_models"],
        default="experiment",
        help="What to upload: the whole experiment directory (default) or only `best_models/` checkpoints.",
    )
    parser.add_argument("--dry-run", action="store_true", help="List planned uploads without pushing.")
    args = parser.parse_args()

    experiment_dir = Path(".art") / args.project / "models" / args.experiment
    if not experiment_dir.is_dir():
        print(f"Not found: {experiment_dir}")
        print("Expected layout: .art/<project>/models/<experiment>/")
        return 1

    scope: Literal["experiment", "best_models"] = args.scope
    repo_id = _format_repo_id(
        namespace=args.hub_namespace,
        project=args.project,
        experiment=args.experiment,
        scope=scope,
    )

    print(f"Repo: https://huggingface.co/{repo_id}")
    if scope == "experiment":
        print("Planned upload:")
        print(f"- {experiment_dir} -> (repo root)")
    else:
        best_models_dir = experiment_dir / "best_models"
        if not best_models_dir.is_dir():
            print(f"Not found: {best_models_dir}")
            print("Expected layout: .art/<project>/models/<experiment>/best_models/<split>/<step>/")
            return 1

        checkpoints = _discover_best_model_checkpoints(best_models_dir)
        if not checkpoints:
            print(f"No checkpoints found under {best_models_dir}/*/*")
            return 1

        planned: list[tuple[BestModelCheckpoint, str]] = [
            (c, f"best_models/{c.split}/{c.step:04d}") for c in checkpoints
        ]
        print("Planned uploads:")
        for ckpt, path_in_repo in planned:
            print(f"- {ckpt.path} -> {path_in_repo}/")

    if args.dry_run:
        return 0

    try:
        from huggingface_hub import HfApi
    except Exception as e:  # pragma: no cover
        print("Missing dependency: huggingface_hub. Install it in your environment to use this script.")
        print(f"Import error: {e}")
        return 1

    api = HfApi()

    api.create_repo(repo_id=repo_id, private=False, exist_ok=True)
    if scope == "experiment":
        print(f"\nUploading {experiment_dir} -> {repo_id}:(repo root)")
        api.upload_folder(
            repo_id=repo_id,
            repo_type="model",
            folder_path=str(experiment_dir),
            path_in_repo="",
            commit_message=f"Upload {args.project}/{args.experiment} experiment directory",
            ignore_patterns=list(_DEFAULT_IGNORE_PATTERNS),
        )
    else:
        best_models_dir = experiment_dir / "best_models"
        checkpoints = _discover_best_model_checkpoints(best_models_dir)
        planned = [(c, f"best_models/{c.split}/{c.step:04d}") for c in checkpoints]

        for ckpt, path_in_repo in planned:
            print(f"\nUploading {ckpt.path} -> {repo_id}:{path_in_repo}/")
            api.upload_folder(
                repo_id=repo_id,
                repo_type="model",
                folder_path=str(ckpt.path),
                path_in_repo=path_in_repo,
                commit_message=f"Upload {args.project}/{args.experiment}:{path_in_repo}/",
                ignore_patterns=list(_DEFAULT_IGNORE_PATTERNS),
            )

    print("\nDone.")
    return 0


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