lerobot-grader / grading.py
kalanosdev's picture
Share grading core so Space grades match canonical batch grades
cafaeed
Raw
History Blame Contribute Delete
10.8 kB
"""
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,
],
}