File size: 10,821 Bytes
cafaeed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
grading.py — canonical Kalanos RDQ metadata-only grading core.

SINGLE SOURCE OF TRUTH for metadata-only ("0.1-meta") scoring. Imported by:
  - batch_grade.py   (bulk grading of the launch list)
  - app.py           (the Hugging Face Space on-demand grader)

so that a dataset graded on the Space and the same dataset graded in the batch
produce byte-identical *scores* — and, when HF returns the same metadata,
byte-identical *reports*. All scoring logic lives here and nowhere else; do not
re-implement any part of it in app.py or batch_grade.py. Changing a weight or a
check here changes both surfaces at once.

Metadata-only grade: `format` and `coverage` are MEASURED from repository
metadata; `sync` and `outliers` are NOT measured and are scored null. The
disclosure in `notes` must travel with the report — these grades are not a
substitute for a measured (full) grade and must not be published publicly
without that disclosure intact.
"""
import io
import json
import os
import time
from datetime import datetime, timezone

import requests

HF = "https://huggingface.co"
GRADER_VERSION = "0.1-meta"
REPORT_KIND = "metadata"

# Default hard cap for the repo-size tree walk. batch_grade.py keeps the full
# 45s (throughput over latency); the Space passes a smaller value so an
# on-demand grade of a huge repo can't hang the UI (size never affects scores).
SIZE_WALK_DEADLINE_S = 45


def UTC():
    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def _auth_headers():
    """Attach an HF token only if one is present in the environment.
    Public datasets resolve fine without it; this keeps the free public Space
    working with no secret set, while private/gated repos work when HF_TOKEN is."""
    tok = os.environ.get("HF_TOKEN")
    return {"Authorization": f"Bearer {tok}"} if tok else {}


def hf_api(path):
    r = requests.get(f"{HF}/api/{path}", timeout=30, headers=_auth_headers())
    if r.status_code == 404:
        return None
    r.raise_for_status()
    return r.json()


def hf_file(slug, path):
    r = requests.get(f"{HF}/datasets/{slug}/resolve/main/{path}", timeout=60, headers=_auth_headers())
    if r.status_code in (401, 403, 404):
        return None
    r.raise_for_status()
    return r


def fetch_repo_size_bytes(slug, deadline_s=SIZE_WALK_DEADLINE_S):
    """Repo size in bytes. Returns (size_bytes, complete):
      - complete=True  -> size_bytes is the real total (from usedStorage, or a tree
        walk that finished before the cap).
      - complete=False, size_bytes not None -> LOWER BOUND: the tree walk hit the
        page/time cap before finishing (huge repos, e.g. droid_lerobot's ~1.7TB /
        tens of thousands of files).
      - complete=False, size_bytes is None -> couldn't determine size at all.

    Tries the dataset info endpoint's usedStorage first: one request regardless of
    repo size. Falls back to summing the paginated tree API only if that's absent,
    with a hard page/time cap so one huge repo can't stall the whole batch (or the
    interactive Space, which passes a shorter deadline_s)."""
    headers = _auth_headers()

    try:
        r = requests.get(f"{HF}/api/datasets/{slug}?expand[]=usedStorage", headers=headers, timeout=30)
        if r.status_code == 200:
            used = r.json().get("usedStorage")
            if isinstance(used, int) and used > 0:
                return used, True
    except Exception:
        pass

    url = f"{HF}/api/datasets/{slug}/tree/main?recursive=true"
    total = 0
    pages = 0
    deadline = time.time() + deadline_s  # hard cap per dataset — large repos become a lower bound, not a hang
    try:
        while url and pages < 200 and time.time() < deadline:
            r = requests.get(url, headers=headers, timeout=20)
            if r.status_code == 404:
                return None, False
            r.raise_for_status()
            for item in r.json():
                if item.get("type") == "file" and isinstance(item.get("size"), int):
                    total += item["size"]
            url = r.links.get("next", {}).get("url")
            pages += 1
        return total, (url is None)  # url is None => walked the whole tree; else capped out
    except Exception:
        return (total or None), False


def fetch_metadata(slug, size_deadline_s=SIZE_WALK_DEADLINE_S):
    """Real facts from HF: license, gated, and LeRobot meta if present.
    Returns None if the dataset does not exist (or the API 404s)."""
    info = hf_api(f"datasets/{slug}")
    if info is None:
        return None
    card = info.get("cardData") or {}
    meta = {
        "license": (card.get("license") if isinstance(card.get("license"), str) else (card.get("license") or [""])[0]) or "unknown",
        "gated": bool(info.get("gated")),
        "downloads": info.get("downloads", 0),
        "last_modified": info.get("lastModified", ""),
        "sha": (info.get("sha") or "")[:7],
        "lerobot": None, "episodes_meta": None, "size_bytes": None, "size_complete": False,
    }
    meta["size_bytes"], meta["size_complete"] = fetch_repo_size_bytes(slug, deadline_s=size_deadline_s)
    for path in ("meta/info.json",):
        r = hf_file(slug, path)
        if r is not None:
            try:
                meta["lerobot"] = r.json()
            except Exception:
                pass
    # episode stats (v2 jsonl or v3 parquet listing is heavy; jsonl is cheap when present)
    r = hf_file(slug, "meta/episodes.jsonl")
    if r is not None:
        lengths = []
        for line in io.StringIO(r.text):
            try:
                lengths.append(json.loads(line).get("length", 0))
            except Exception:
                pass
        if lengths:
            meta["episodes_meta"] = {"count": len(lengths), "lengths": lengths}
    return meta


def hist(values, bins):
    counts = [0] * (len(bins) - 1)
    for v in values:
        for i in range(len(bins) - 1):
            if bins[i] <= v < bins[i + 1]:
                counts[i] += 1
                break
        else:
            counts[-1] += 1
    return {"bins": bins, "counts": counts}


def gini(xs):
    xs = sorted(x for x in xs if x >= 0)
    n = len(xs)
    s = sum(xs)
    if n == 0 or s == 0:
        return 0.0
    g = 0.0
    for i, x in enumerate(xs, 1):
        g += (2 * i - n - 1) * x
    return round(g / (n * s), 3)


def meta_grade(slug, m):
    """Metadata-only v0 grade. Format & coverage are measured; sync & outliers are
    provisional placeholders (null) disclosed in the notes. Do NOT publish these
    publicly without the disclosure intact.

    `m` is the dict returned by fetch_metadata(). This is the ONE function whose
    output defines a metadata grade; both the batch and the Space call it."""
    lr = m.get("lerobot") or {}
    fps = lr.get("fps", 0)
    total_eps = lr.get("total_episodes") or (m.get("episodes_meta") or {}).get("count") or 0
    total_frames = lr.get("total_frames", 0)
    tasks = lr.get("total_tasks", 0)
    features = lr.get("features", {}) or {}
    cams = [k for k in features if "image" in k or features[k].get("dtype") in ("video", "image")]
    fmt_checks = {
        "has_lerobot_meta": lr != {},
        "fps_declared": bool(fps),
        "features_declared": bool(features),
        "chunked_layout": bool(lr.get("data_path")),
    }
    fmt_score = 100.0 * sum(fmt_checks.values()) / len(fmt_checks) if lr else 40.0
    lens = (m.get("episodes_meta") or {}).get("lengths") or []
    secs = [l / fps for l in lens] if fps and lens else []
    ep_hist = hist(secs, [0, 10, 20, 30, 45, 60, 90, 120]) if secs else {"bins": [0, 1], "counts": [total_eps]}
    task_balance = 0.0  # per-task counts not in cheap metadata; neutral
    cov_score = min(100.0, 30 + 8 * tasks + 6 * len(cams)) if lr else 50.0
    hours = round(total_frames / fps / 3600, 1) if fps and total_frames else 0
    # meta/info.json's codebase_version already includes a leading "v" (e.g. "v2.0");
    # strip it before prepending our own so we never emit "LeRobot vv2.0".
    raw_version = str(lr.get("codebase_version", "")).strip()
    version = raw_version.lstrip("vV")
    fmt_label = f"LeRobot v{version}" if version else ("LeRobot" if lr else "unknown")

    size_bytes = m.get("size_bytes")
    size_complete = m.get("size_complete")
    size_gb = round(size_bytes / 1e9, 2) if isinstance(size_bytes, int) else 0
    if not isinstance(size_bytes, int):
        size_note = ["Repo size could not be determined; size_gb=0 is a placeholder, not a measurement."]
    elif not size_complete:
        size_note = [f"Repo size is a LOWER BOUND ({size_gb} GB): the file listing was capped before finishing "
                     f"(large repo). Re-run with a longer cap or rely on usedStorage for an exact total."]
    else:
        size_note = []

    return {
        "report_id": "",
        "report_kind": REPORT_KIND,
        "grader_version": GRADER_VERSION,
        "graded_at": UTC(),
        "source": {"kind": "hf", "ref": slug, "url": f"{HF}/datasets/{slug}",
                   "commit": m["sha"], "license": m["license"].lower(), "gated": m["gated"]},
        "dataset": {"format": fmt_label,
                    "episodes": total_eps, "hours": hours,
                    "embodiment": lr.get("robot_type", "unknown"),
                    "sensors": cams or ["unknown"], "size_gb": size_gb},
        "scores": {"total": round((fmt_score + cov_score) / 2, 1),
                   "sync": None, "coverage": round(cov_score, 1),
                   "outliers": None, "format": round(fmt_score, 1)},
        "metrics": {
            "sync": {"median_drift_ms": 0, "p95_drift_ms": 0, "max_drift_ms": 0, "episodes_over_10ms": 0, "dropped_frames_pct": 0},
            "coverage": {"task_families": tasks, "environments": 0, "episodes_per_task_min": 0,
                         "episodes_per_task_max": 0, "gini_task_balance": task_balance, "lighting_variation_score": 0},
            "outliers": {"flagged_episodes": 0, "flagged_pct": 0, "categories": {}},
            "format": {"schema_valid_pct": 100 if lr else 0, "calibration_present_pct": 0,
                       "units_consistent": bool(lr), "timestamps_monotonic_pct": 0},
        },
        "histograms": {"drift_ms": {"bins": [0, 1], "counts": [0]}, "episode_length_s": ep_hist},
        "flagged_sample": [],
        "notes": [
            f"Metadata-level report: file structure, coverage and episode statistics are measured from repository metadata. "
            f"Timestamp synchronization and trajectory outliers are not yet measured and are not scored.",
            f"{total_eps:,} episodes, {tasks} task(s), {len(cams)} camera stream(s), fps={fps}.",
            f"HF downloads: {m['downloads']:,}; last modified {m['last_modified'][:10]}.",
            *size_note,
        ],
    }