File size: 9,948 Bytes
5936e76
e317359
5936e76
 
 
 
e317359
5936e76
e317359
5936e76
e317359
 
 
5936e76
 
e317359
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5936e76
 
 
 
 
 
e317359
5936e76
 
 
 
e317359
 
 
 
 
 
 
 
5936e76
 
 
e317359
 
 
5936e76
 
e317359
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a9c5d6e
 
 
 
 
 
e317359
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5936e76
e317359
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5936e76
 
 
 
e317359
 
 
 
 
5936e76
 
 
 
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
#!/usr/bin/env python3
"""Publish public metrics and bounded visualization snapshots atomically to HF."""

from __future__ import annotations

import argparse
import json
import os
import shutil
import sys
import tempfile
import time
from datetime import datetime, timezone
from pathlib import Path

from dotenv import load_dotenv

REPO_ROOT = Path(__file__).resolve().parents[1]
load_dotenv(REPO_ROOT / ".env")
DEFAULT_RESULTS = REPO_ROOT / "space" / "results"
DEFAULT_REPO = "ThinkcatLab/LiveHouse-TS"
API_RETRIES = 5
UPLOAD_IGNORE_PATTERNS = [
    # Raw inference/task archives stay local. public_snapshots/<model>/<dataset>.json
    # contains the bounded, explicit visualization export and MUST be published.
    "prequential/**",
    "saved_evals/**",
    "*/forecasts/**",
    "**/forecasts/**",
    "evaluation_metrics.jsonl",
    "evaluation_metrics_canonical.jsonl",
    "aggregates/**/evaluation_metrics_canonical.jsonl",
    "aggregates/canonical_all_history/**",
    "aggregates/*.zip",
    ".DS_Store",
]
REMOTE_PRUNE_PATHS = [
    "results/evaluation_metrics.jsonl",
    "results/evaluation_metrics_canonical.jsonl",
    "results/aggregates/results.zip",
    "results/aggregates/aggregates.zip",
]


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--repo", default=os.getenv("HF_SPACE_REPO", DEFAULT_REPO))
    parser.add_argument("--results", type=Path, default=DEFAULT_RESULTS)
    parser.add_argument("--results-repo", default=os.getenv("HF_RESULTS_REPO"))
    parser.add_argument(
        "--commit-message",
        default="Update leaderboard results from local eval",
    )
    parser.add_argument(
        "--restart-space",
        action="store_true",
        help=(
            "Explicitly restart the Space after pushing results. Disabled by default "
            "to avoid periodic 504s during live leaderboard refreshes."
        ),
    )
    return parser.parse_args()


def load_token() -> str:
    load_dotenv(REPO_ROOT / ".env")
    token = (os.getenv("HF_TOKEN") or "").strip()
    if not token:
        print("HF_TOKEN is required (add to .env)", file=sys.stderr)
        raise SystemExit(1)
    return token


def get_hf_api(token: str):
    from huggingface_hub import HfApi

    endpoint = (os.getenv("HF_ENDPOINT") or "").strip() or None
    return HfApi(token=token, endpoint=endpoint)


def ensure_space_repo(token: str, repo_id: str) -> None:
    try:
        from huggingface_hub.utils import RepositoryNotFoundError
    except ImportError:
        from huggingface_hub.errors import RepositoryNotFoundError

    api = get_hf_api(token)
    try:
        api.repo_info(repo_id, repo_type="space")
    except RepositoryNotFoundError:
        print(f"Creating Space {repo_id}…", file=sys.stderr)
        api.create_repo(
            repo_id=repo_id,
            repo_type="space",
            space_sdk="gradio",
            exist_ok=True,
        )
        print(f"Created Space {repo_id}", file=sys.stderr)


def prune_remote_non_leaderboard_artifacts(token: str, repo_id: str) -> bool:
    """Delete remote-only artifacts that are not read by the Space UI.

    The evaluation metric JSONL files are local aggregation caches. Once they grow beyond
    the Hub git limit, keeping it in the Space repository blocks every refresh.
    """

    api = get_hf_api(token)
    try:
        remote_files = set(api.list_repo_files(repo_id=repo_id, repo_type="space"))
    except Exception as exc:
        print(f"Could not verify remote files before upload: {exc}", file=sys.stderr)
        return False

    stale_paths = sorted(path for path in remote_files if (
        path in REMOTE_PRUNE_PATHS or path.startswith("results/prequential/")
        or path.startswith("results/saved_evals/") or "/forecasts/" in path
    ))
    if not stale_paths:
        return True

    try:
        from huggingface_hub import CommitOperationDelete

        api.create_commit(
            repo_id=repo_id,
            repo_type="space",
            operations=[CommitOperationDelete(path_in_repo=path) for path in stale_paths],
            commit_message="Prune non-leaderboard result artifacts",
        )
        print(f"Pruned {len(stale_paths)} non-leaderboard artifacts from current remote revision")
        return True
    except Exception as exc:
        print(f"Could not prune remote non-leaderboard artifacts: {exc}", file=sys.stderr)
        return False


def update_push_status(results: Path, *, ok: bool, detail: str = "") -> None:
    status_path = results / "online_status.json"
    if not status_path.exists():
        return
    try:
        payload = json.loads(status_path.read_text())
    except (json.JSONDecodeError, OSError):
        return
    payload["push_status"] = "ok" if ok else "failed"
    payload["pushed_at"] = datetime.now(timezone.utc).isoformat()
    if detail:
        payload["push_detail"] = detail
    status_path.write_text(json.dumps(payload, indent=4) + "\n")
    # Evaluation rewrites online_status while rapid cycles wait for publication.
    # Keep the last actual publish attempt independently for the watchdog.
    receipt = results / "publication_status.json"
    temporary = receipt.with_suffix(".tmp")
    temporary.write_text(json.dumps({key: payload[key] for key in ("push_status", "pushed_at")}))
    temporary.replace(receipt)


def restart_space(token: str, repo_id: str) -> None:
    api = get_hf_api(token)
    try:
        api.restart_space(repo_id)
        print(f"Restarted Space {repo_id} to load new results", file=sys.stderr)
    except Exception as exc:
        print(f"Space restart failed (non-fatal): {exc}", file=sys.stderr)


def maybe_restart_space(args: argparse.Namespace, token: str) -> None:
    """Restart only when explicitly requested by a full deployment workflow."""

    if args.restart_space:
        restart_space(token, args.repo)


def stage_public_results(results: Path, destination: Path) -> None:
    from huggingface_hub.utils import filter_repo_objects
    candidates = [p.relative_to(results).as_posix() for p in results.rglob("*") if p.is_file()]
    for rel in filter_repo_objects(candidates, ignore_patterns=UPLOAD_IGNORE_PATTERNS):
        if Path(rel).suffix not in {".csv", ".json", ".tex"}:
            continue
        output = destination / rel
        output.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(results / rel, output)


def push_via_api(args: argparse.Namespace, token: str) -> int:
    try:
        from huggingface_hub import HfApi
    except ImportError:
        print("huggingface_hub not installed; run setup_portable.sh", file=sys.stderr)
        return 1

    api = get_hf_api(token)
    results_repo = getattr(args, "results_repo", None) or args.repo + "-results"
    api.create_repo(results_repo, repo_type="dataset", exist_ok=True)
    last_error = ""

    for attempt in range(1, API_RETRIES + 1):
        try:
            # Refuse an old clone replacing a newer operator's cumulative history.
            from huggingface_hub import hf_hub_download
            from huggingface_hub.utils import EntryNotFoundError
            import pandas as pd
            revision = api.dataset_info(results_repo).sha
            metrics = pd.read_csv(args.results / "aggregates/live_release_metrics.csv")
            history_rows = len(metrics)
            try:
                previous_path = hf_hub_download(results_repo, "results/online_status.json",
                                                repo_type="dataset", revision=revision, token=token)
                previous = json.loads(Path(previous_path).read_text())
            except EntryNotFoundError:
                previous = {}
            if int(previous.get("canonical_history_rows", 0)) > history_rows:
                print("Remote history is newer/larger. Restore the latest operator backup before publishing.", file=sys.stderr)
                return 1
            with tempfile.TemporaryDirectory(prefix="livehouse-results-") as temp:
                stage = Path(temp)
                stage_public_results(args.results, stage)
                # The public status and tables belong to the same Hub commit.
                update_push_status(stage, ok=True, detail="HF Hub API")
                status_path = stage / "online_status.json"
                status = json.loads(status_path.read_text())
                status["canonical_history_rows"] = history_rows
                status["latest_scored_release_time"] = str(metrics["release_time"].max()) if history_rows else None
                status_path.write_text(json.dumps(status, indent=2) + "\n")
                commit = api.upload_folder(
                    folder_path=str(stage), path_in_repo="results", repo_id=results_repo,
                    repo_type="dataset", revision="main", parent_commit=revision, commit_message=args.commit_message,
                )
            print(f"Pushed results → https://huggingface.co/datasets/{results_repo}/commit/{commit.oid}")
            update_push_status(args.results, ok=True, detail="HF Hub API")
            maybe_restart_space(args, token)
            return 0
        except Exception as exc:
            last_error = str(exc)
            print(
                f"HF Hub API upload retry {attempt}/{API_RETRIES}: {exc}",
                file=sys.stderr,
            )
            time.sleep(3 * attempt)

    print(f"HF Hub API upload failed after {API_RETRIES} retries: {last_error}", file=sys.stderr)
    return 1


def main() -> int:
    args = parse_args()
    if not args.results.is_dir():
        print(f"Results directory not found: {args.results}", file=sys.stderr)
        return 1

    token = load_token()
    code = push_via_api(args, token)
    if code != 0:
        update_push_status(args.results, ok=False, detail="HF Hub API upload failed")
    return code


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