AdithyaSK HF Staff commited on
Commit
3d20eb8
·
verified ·
1 Parent(s): 6dd2ce8

Harbor run viewer: Phase 0 eval, 15 harnesses x 50 tasks, pass@4

Browse files
Files changed (10) hide show
  1. .gitignore +2 -0
  2. CONTRACT.md +135 -0
  3. Dockerfile +19 -0
  4. README.md +63 -9
  5. app.py +278 -0
  6. requirements.txt +5 -0
  7. site/viewer.html +607 -0
  8. tools/classify.py +147 -0
  9. tools/ingest.py +274 -0
  10. tools/watch_ingest.sh +39 -0
.gitignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # Run data lives in the bucket, not in git: that is the whole point of the design.
2
+ data/
CONTRACT.md ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Information contract
2
+
3
+ Everything the viewer shows lives in **one directory**, identical whether it is a local folder or a
4
+ Hugging Face bucket mounted into a Space. A mounted bucket appears as an ordinary filesystem path inside
5
+ the container, so local and hosted use the same code path and there is nothing to keep in sync.
6
+
7
+ ## Hierarchy
8
+
9
+ ```
10
+ project "data-agent" — a body of work
11
+ └── dataset "eval-v1", "eval-easy50", "dabstep" — variations within it
12
+ └── task one ROW in the table
13
+ └── cell one (model, harness) pair
14
+ └── attempts k tries → pass@k, each with a trace
15
+ ```
16
+
17
+ Two axes cross at a cell, and **which one is the column is a view choice, not a storage choice** — so
18
+ cells are keyed `"<model>|<harness>"` and the viewer pivots either way. Storing per-axis would force a
19
+ rewrite to flip the table.
20
+
21
+ ```
22
+ <DATA_DIR>/
23
+ └── projects/
24
+ └── <project_id>/
25
+ ├── project.json # REQUIRED — label, description, source, support tiers
26
+ ├── datasets/
27
+ │ └── <dataset_id>/
28
+ │ ├── dataset.json # optional label/notes
29
+ │ ├── summary.json # tasks[] with cells, by_model, by_harness
30
+ │ └── traces/<trace_id>.json # one attempt each; referenced from a cell
31
+ └── runs/
32
+ └── <run_id>/ # training runs belonging to this project
33
+ ├── run.json
34
+ └── train/metrics.jsonl
35
+ ```
36
+
37
+ Discovery is by file presence: `project.json` makes a project, `summary.json` a dataset, `run.json` a
38
+ training run. Nothing else is required and the viewer never assumes a file exists.
39
+
40
+ ## `project.json`
41
+
42
+ ```json
43
+ {
44
+ "project_id": "data-agent",
45
+ "label": "Data-Agent Bench",
46
+ "description": "Verified data-analysis tasks over Kaggle datasets, graded exact / numeric / LLM-judge.",
47
+ "source": {"hf_dataset": "AdithyaSK/data_agent_rl_environment_eval"},
48
+ "support": {
49
+ "mini-swe-agent": {"tier": "stable"},
50
+ "opencode": {"tier": "experimental",
51
+ "caveats": ["no step limit; ~90 turns and ~17% of rollouts retry after exit 137"]}
52
+ }
53
+ }
54
+ ```
55
+
56
+ `support` is per-harness and drives the stable / experimental badge and its warning. A tier with no
57
+ caveat is just a colour, so an experimental harness has to say what does not work.
58
+
59
+ ## `datasets/<id>/summary.json`
60
+
61
+ ```json
62
+ {
63
+ "k_max": 4,
64
+ "models": ["Qwen/Qwen3.5-2B"],
65
+ "harnesses": ["mini-swe-agent", "opencode"],
66
+ "summary": {"tasks_total": 50, "tasks_any_pass": 31, "attempts_total": 400,
67
+ "attempts_passed": 74, "n_all_infra": 0},
68
+ "by_model": [{"model": "Qwen/Qwen3.5-2B", "cells": 100, "pass@1": 0.17, "pass@4": 0.31,
69
+ "n_measured": 100, "mean_turns": 11.7}],
70
+ "by_harness": [{"harness": "mini-swe-agent", "cells": 100, "pass@1": 0.17, "pass@4": 0.31,
71
+ "n_measured": 100, "mean_turns": 11.7}],
72
+ "tasks": [
73
+ {"id": "0000_416_416942_qa_3", "index": 7, "difficulty_level": 0, "difficulty": "easy",
74
+ "question": "What is the median of ...?", "answer": "0.4056", "reward_mode": "numeric",
75
+ "cells": {
76
+ "Qwen/Qwen3.5-2B|mini-swe-agent": {
77
+ "passed_at": 3,
78
+ "attempts": [{"attempt": 1, "reward": 0.0, "n_turns": 12, "elapsed_sec": 41.2,
79
+ "trace": "traces/0000_416_416942_qa_3-2b-mini-1.json"}]
80
+ }
81
+ }}
82
+ ]
83
+ }
84
+ ```
85
+
86
+ Conventions that carry meaning rather than being formatting:
87
+
88
+ * **`reward: null` means the verifier never ran** — dead sandbox, no answer. Not a zero. Aggregates
89
+ exclude those attempts and count a task under `n_all_infra` when every attempt is null. Averaging them
90
+ in as zeros makes infrastructure failure look like a weak model: one run reported `pass@4 0.333` from
91
+ 3 measured tasks out of 8 before that rule existed.
92
+ * **`passed_at`** is the first passing attempt, so `pass@1` and `pass@k` both derive from one structure
93
+ and cannot disagree.
94
+ * **`trace`** is a path relative to the dataset directory, so opening a cell needs no naming convention
95
+ the viewer has to guess.
96
+
97
+ ## `traces/<trace_id>.json`
98
+
99
+ Free-form per attempt; rendered as whatever is present.
100
+
101
+ ```json
102
+ {"task_id": "...", "model": "...", "harness": "...", "attempt": 1,
103
+ "reward": 0.0, "reward_method": "llm", "n_turns": 12, "elapsed_sec": 41.2,
104
+ "gold": "0.4056", "predicted": "0.41",
105
+ "messages": [{"role": "user", "content": "..."},
106
+ {"role": "assistant", "content": "...", "tool_calls": [...]},
107
+ {"role": "tool", "content": "..."}]}
108
+ ```
109
+
110
+ `reward_method` is worth carrying: the grader is three-tier (exact → numeric → gpt-4o-mini judge), so
111
+ knowing a 0 came from the judge rather than exact match separates a wrong answer from a formatting
112
+ mismatch.
113
+
114
+ ## `runs/<run_id>/train/metrics.jsonl`
115
+
116
+ One object per step, append-only so a live run is readable without rewriting.
117
+
118
+ ```json
119
+ {"step": 3, "reward": 0.829, "reward_std": 0.146, "loss": 0.279, "ratio": 0.9999,
120
+ "kl": 0.032, "entropy": 0.51, "n_rollouts": 8, "n_unscorable": 1, "wall_s": 47.6}
121
+ ```
122
+
123
+ `reward_std` sits next to `reward` because a step whose generations all score the same has zero
124
+ advantage and teaches nothing — a reward curve alone cannot tell learning from a flat run.
125
+
126
+ ## Publishing
127
+
128
+ ```sh
129
+ hf buckets create <ns>/harbor-runs --exist-ok
130
+ HF_HUB_DISABLE_XET=1 hf sync ./data hf://buckets/<ns>/harbor-runs
131
+ hf spaces volumes set <ns>/harbor-viewer -v hf://buckets/<ns>/harbor-runs:/data
132
+ ```
133
+
134
+ `HF_HUB_DISABLE_XET=1` is not decoration: the Xet upload path hangs indefinitely on this cluster while
135
+ the plain HTTP path finishes in seconds.
Dockerfile ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hugging Face Docker Space. The data is NOT baked in: a bucket is mounted at /data, so the image stays
2
+ # tiny and a new run needs no rebuild — only a bucket sync and a click of Refresh.
3
+ FROM python:3.12-slim
4
+
5
+ # Spaces run as uid 1000.
6
+ RUN useradd -m -u 1000 user
7
+ USER user
8
+ ENV PATH="/home/user/.local/bin:$PATH"
9
+ WORKDIR /app
10
+
11
+ COPY --chown=user requirements.txt .
12
+ RUN pip install --no-cache-dir -r requirements.txt
13
+ COPY --chown=user . .
14
+
15
+ # Mount the bucket here: hf spaces volumes set <space> -v hf://buckets/<ns>/<bucket>:/data
16
+ ENV DATA_DIR=/data
17
+
18
+ # HF routes public traffic to app_port from README.md frontmatter.
19
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,13 +1,67 @@
1
  ---
2
- title: Dataagent Phase0 Evals
3
- emoji: 👀
4
- colorFrom: purple
5
- colorTo: purple
6
- sdk: gradio
7
- sdk_version: 6.26.0
8
- python_version: '3.13'
9
- app_file: app.py
10
  pinned: false
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Harbor Run Viewer
3
+ emoji: 🔭
4
+ colorFrom: indigo
5
+ colorTo: blue
6
+ sdk: docker
7
+ app_port: 7860
 
 
8
  pinned: false
9
  ---
10
 
11
+ # Harbor Run Viewer
12
+
13
+ Train and eval runs from the OpenEnv × Harbor stack. **The data lives in a Hugging Face bucket; this app
14
+ only renders it.**
15
+
16
+ ## The one design decision
17
+
18
+ A bucket mounted into a Space appears as an ordinary filesystem path inside the container, so the Space
19
+ and a laptop run *the same* code against *the same* layout — `DATA_DIR` is `/data` in one case and
20
+ `./data` in the other. There is no local-vs-remote branch to keep in step, and no data in the image.
21
+
22
+ ```
23
+ laptop: DATA_DIR=./data uvicorn app:app
24
+ Space: DATA_DIR=/data ← bucket mounted read-only or read-write
25
+ fallback: DATA_BUCKET=ns/bucket ← read a bucket over HfFileSystem without mounting it
26
+ ```
27
+
28
+ ## Run it locally
29
+
30
+ ```sh
31
+ pip install -r requirements.txt
32
+ DATA_DIR=./data uvicorn app:app --port 8000 # http://localhost:8000
33
+ ```
34
+
35
+ ## Publish runs to the bucket
36
+
37
+ ```sh
38
+ hf buckets create <ns>/harbor-runs --exist-ok
39
+ hf sync ./data hf://buckets/<ns>/harbor-runs # add --delete to mirror exactly
40
+ ```
41
+
42
+ ## Point the Space at the bucket
43
+
44
+ ```sh
45
+ hf spaces volumes set <ns>/harbor-run-viewer -v hf://buckets/<ns>/harbor-runs:/data
46
+ ```
47
+
48
+ Only buckets support read-write mounts; models, datasets and Spaces are always read-only. A private
49
+ bucket needs `HF_TOKEN` as a Space secret.
50
+
51
+ Then press **⟳ Refresh** in the UI: it drops every cache and rescans. A mounted bucket already reflects
52
+ new writes, so nothing needs rebuilding or restarting to see a run that finished a minute ago.
53
+
54
+ ## Layout
55
+
56
+ See [CONTRACT.md](CONTRACT.md). Briefly: one directory per run under `runs/<run_id>/`, identified by a
57
+ `run.json`, with optional `eval/summary.json` and `train/metrics.jsonl`. A run with only training data
58
+ renders as training; one with both shows both; nothing is required beyond `run.json`.
59
+
60
+ Two conventions in that contract carry real meaning rather than being formatting:
61
+
62
+ - **`reward: null` is not zero.** It means the verifier never ran — a dead sandbox, no answer. Those
63
+ attempts are excluded, and a task whose every attempt is null is counted as *unmeasured*. Averaging
64
+ them in as zeros makes infrastructure failure look like a weak model.
65
+ - **`reward_std` sits next to `reward`.** A step whose generations all score the same has zero advantage
66
+ and teaches nothing, so the viewer reports *steps with a gradient* — a reward curve alone cannot
67
+ distinguish learning from a flat run.
app.py ADDED
@@ -0,0 +1,278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI viewer for Harbor train + eval runs. The data lives in a bucket; this only renders it.
2
+
3
+ ONE READ PATH, TWO DEPLOYMENTS. A Hugging Face bucket mounted into a Space appears as an ordinary
4
+ filesystem path inside the container (`Volume(type="bucket", source=..., mount_path="/data")`), so the
5
+ Space and a laptop run the *same* code against the same layout — `DATA_DIR` is just `/data` in one case
6
+ and `./data` in the other. The alternative, branching between a local reader and an `HfFileSystem`
7
+ reader, means two code paths where only one is ever exercised by whoever is debugging.
8
+
9
+ `DATA_BUCKET` exists for the case the mount cannot cover: reading a remote bucket from a laptop without
10
+ mounting it. It goes through `HfFileSystem`, which speaks `hf://buckets/<ns>/<name>/<path>`. When both
11
+ are set the local directory wins, because a mount is always fresher and cheaper than the network.
12
+
13
+ DATA_DIR = ./data # a local dir, or a mounted bucket at /data
14
+ DATA_BUCKET = (unset) # e.g. AdithyaSK/harbor-runs — remote fallback
15
+ REFRESH_TTL = 30 # seconds a listing is trusted before rescanning
16
+
17
+ WHY A REFRESH BUTTON AND NOT A WATCHER. A run in progress appends to `metrics.jsonl` and drops new files
18
+ into `traces/`; a mounted bucket reflects that without the app doing anything. But listings are cached
19
+ so that a page load does not restat thousands of files, so the UI needs a way to say "look again now" —
20
+ `POST /api/refresh` drops every cache. Nothing is precomputed, so refresh is the only invalidation
21
+ needed.
22
+
23
+ HIERARCHY: project -> dataset -> task (row) -> cell (model x harness) -> attempts (pass@k) -> trace.
24
+ Cells are keyed "<model>|<harness>" so the viewer can pivot which axis is the column without refetching;
25
+ that is a view choice, and storing it per-axis would force a rewrite to flip the table.
26
+
27
+ Endpoints:
28
+ GET / → the viewer
29
+ GET /api/projects → tree: projects, their datasets and runs
30
+ GET /api/projects/{pid}/datasets/{did} → the table (tasks + cells + aggregates)
31
+ GET /api/projects/{pid}/datasets/{did}/trace?path= → one attempt
32
+ GET /api/projects/{pid}/runs/{rid} → a training run's run.json
33
+ GET /api/projects/{pid}/runs/{rid}/train → its metrics.jsonl, parsed
34
+ POST /api/refresh → drop caches; returns what it now sees
35
+ GET /healthz → {ok, source, n_projects, ...}
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import json
41
+ import os
42
+ import time
43
+ from pathlib import PurePosixPath
44
+ from typing import Any
45
+
46
+ from fastapi import FastAPI, HTTPException, Query
47
+ from fastapi.responses import HTMLResponse, JSONResponse
48
+
49
+ HERE = os.path.dirname(os.path.abspath(__file__))
50
+ SITE = os.path.join(HERE, "site")
51
+ DATA_DIR = os.getenv("DATA_DIR", os.path.join(HERE, "data"))
52
+ DATA_BUCKET = os.getenv("DATA_BUCKET", "")
53
+ REFRESH_TTL = float(os.getenv("REFRESH_TTL", "30"))
54
+
55
+ app = FastAPI(title="Harbor run viewer", docs_url="/docs")
56
+
57
+ _cache: dict[str, Any] = {}
58
+ _cache_at: dict[str, float] = {}
59
+
60
+
61
+ # --- storage: a local tree, or a bucket over fsspec -------------------------------------------------
62
+ class _Local:
63
+ kind = "local"
64
+
65
+ def __init__(self, root: str) -> None:
66
+ self.root = root
67
+
68
+ def describe(self) -> str:
69
+ return f"local:{self.root}"
70
+
71
+ def exists(self, rel: str) -> bool:
72
+ return os.path.exists(os.path.join(self.root, rel))
73
+
74
+ def listdir(self, rel: str) -> list[str]:
75
+ p = os.path.join(self.root, rel)
76
+ return sorted(os.listdir(p)) if os.path.isdir(p) else []
77
+
78
+ def read_text(self, rel: str) -> str:
79
+ with open(os.path.join(self.root, rel), encoding="utf-8") as f:
80
+ return f.read()
81
+
82
+ def mtime(self, rel: str) -> float:
83
+ try:
84
+ return os.path.getmtime(os.path.join(self.root, rel))
85
+ except OSError:
86
+ return 0.0
87
+
88
+
89
+ class _Bucket:
90
+ """`hf://buckets/<ns>/<name>/<path>` over HfFileSystem. Used only when nothing is mounted."""
91
+
92
+ kind = "bucket"
93
+
94
+ def __init__(self, bucket: str) -> None:
95
+ from huggingface_hub import HfFileSystem
96
+
97
+ self.bucket = bucket
98
+ self.fs = HfFileSystem()
99
+
100
+ def _p(self, rel: str) -> str:
101
+ return f"buckets/{self.bucket}/{rel}".rstrip("/")
102
+
103
+ def describe(self) -> str:
104
+ return f"hf://buckets/{self.bucket}"
105
+
106
+ def exists(self, rel: str) -> bool:
107
+ return bool(self.fs.exists(self._p(rel)))
108
+
109
+ def listdir(self, rel: str) -> list[str]:
110
+ try:
111
+ return sorted(PurePosixPath(p).name for p in self.fs.ls(self._p(rel), detail=False))
112
+ except FileNotFoundError:
113
+ return []
114
+
115
+ def read_text(self, rel: str) -> str:
116
+ with self.fs.open(self._p(rel), "r") as f:
117
+ return f.read()
118
+
119
+ def mtime(self, rel: str) -> float:
120
+ return 0.0 # not exposed uniformly; the refresh button is the invalidation story
121
+
122
+
123
+ def store():
124
+ """Prefer a real directory: a mounted bucket is fresher and cheaper than the network."""
125
+ if os.path.isdir(DATA_DIR):
126
+ return _Local(DATA_DIR)
127
+ if DATA_BUCKET:
128
+ return _Bucket(DATA_BUCKET)
129
+ return _Local(DATA_DIR) # missing; every read 404s with a path in the message
130
+
131
+
132
+ def _cached(key: str, produce):
133
+ now = time.time()
134
+ if key in _cache and now - _cache_at.get(key, 0.0) < REFRESH_TTL:
135
+ return _cache[key]
136
+ value = produce()
137
+ _cache[key] = value
138
+ _cache_at[key] = now
139
+ return value
140
+
141
+
142
+ def _safe_rel(*parts: str) -> str:
143
+ """Join a relative path and refuse to escape the data directory.
144
+
145
+ Trace paths come straight from summary.json, which this app did not write, so a `../` in one of them
146
+ must not read outside DATA_DIR.
147
+ """
148
+ for part in parts:
149
+ if not part or part.startswith("/") or ".." in PurePosixPath(part).parts:
150
+ raise HTTPException(400, f"unsafe path component: {part!r}")
151
+ return str(PurePosixPath(*parts))
152
+
153
+
154
+ def _read_json(st, rel: str) -> Any:
155
+ if not st.exists(rel):
156
+ raise HTTPException(404, f"missing: {st.describe()}/{rel}")
157
+ try:
158
+ return json.loads(st.read_text(rel))
159
+ except json.JSONDecodeError as exc:
160
+ raise HTTPException(500, f"{rel} is not valid JSON: {exc}") from exc
161
+
162
+
163
+ def _list_projects() -> list[dict]:
164
+ """A directory with project.json is a project. Datasets and runs are listed alongside it so the
165
+ sidebar can render the whole tree from one request instead of N+1."""
166
+ st = store()
167
+ out = []
168
+ for pid in st.listdir("projects"):
169
+ rel = f"projects/{pid}/project.json"
170
+ if not st.exists(rel):
171
+ continue
172
+ try:
173
+ meta = json.loads(st.read_text(rel))
174
+ except Exception: # noqa: BLE001 - one malformed project must not hide the others
175
+ meta = {"project_id": pid, "error": "project.json is unreadable"}
176
+ meta.setdefault("project_id", pid)
177
+ meta["datasets"] = [
178
+ d for d in st.listdir(f"projects/{pid}/datasets")
179
+ if st.exists(f"projects/{pid}/datasets/{d}/summary.json")
180
+ ]
181
+ meta["runs"] = [
182
+ r for r in st.listdir(f"projects/{pid}/runs")
183
+ if st.exists(f"projects/{pid}/runs/{r}/run.json")
184
+ ]
185
+ out.append(meta)
186
+ out.sort(key=lambda p: p.get("label") or p["project_id"])
187
+ return out
188
+
189
+
190
+ @app.get("/", response_class=HTMLResponse)
191
+ def index() -> HTMLResponse:
192
+ p = os.path.join(SITE, "viewer.html")
193
+ if not os.path.exists(p):
194
+ raise HTTPException(503, "site/viewer.html is missing")
195
+ with open(p, encoding="utf-8") as f:
196
+ return HTMLResponse(f.read())
197
+
198
+
199
+ @app.get("/api/projects")
200
+ def projects() -> JSONResponse:
201
+ return JSONResponse(_cached("projects", _list_projects))
202
+
203
+
204
+ @app.get("/api/projects/{pid}/datasets/{did}")
205
+ def dataset(pid: str, did: str) -> JSONResponse:
206
+ """The table: tasks as rows, cells keyed '<model>|<harness>'. The viewer pivots which axis is the
207
+ column, so this is returned once and re-rendered client-side rather than fetched per view."""
208
+ rel = _safe_rel("projects", pid, "datasets", did, "summary.json")
209
+ return JSONResponse(_cached(f"ds:{pid}/{did}", lambda: _read_json(store(), rel)))
210
+
211
+
212
+ @app.get("/api/projects/{pid}/datasets/{did}/trace")
213
+ def trace(pid: str, did: str, path: str = Query(..., description="path relative to the dataset dir")) -> JSONResponse:
214
+ """One attempt. `path` comes from summary.json, which this app did not write, so it is validated
215
+ against escaping the data directory before being opened."""
216
+ rel = _safe_rel("projects", pid, "datasets", did, *PurePosixPath(path).parts)
217
+ return JSONResponse(_read_json(store(), rel))
218
+
219
+
220
+ @app.get("/api/projects/{pid}/runs/{rid}")
221
+ def run(pid: str, rid: str) -> JSONResponse:
222
+ return JSONResponse(
223
+ _cached(f"run:{pid}/{rid}", lambda: _read_json(store(), _safe_rel("projects", pid, "runs", rid, "run.json")))
224
+ )
225
+
226
+
227
+ @app.get("/api/projects/{pid}/runs/{rid}/train")
228
+ def run_train(pid: str, rid: str) -> JSONResponse:
229
+ """metrics.jsonl -> list. A malformed final line is skipped rather than fatal: a run being appended
230
+ to right now can have a half-written line, and refusing the file would make live runs unviewable."""
231
+
232
+ def produce():
233
+ st = store()
234
+ rel = _safe_rel("projects", pid, "runs", rid, "train", "metrics.jsonl")
235
+ if not st.exists(rel):
236
+ raise HTTPException(404, f"missing: {st.describe()}/{rel}")
237
+ rows, skipped = [], 0
238
+ for line in st.read_text(rel).splitlines():
239
+ line = line.strip()
240
+ if not line:
241
+ continue
242
+ try:
243
+ rows.append(json.loads(line))
244
+ except json.JSONDecodeError:
245
+ skipped += 1
246
+ return {"steps": rows, "skipped_lines": skipped}
247
+
248
+ return JSONResponse(_cached(f"train:{pid}/{rid}", produce))
249
+
250
+
251
+ @app.post("/api/refresh")
252
+ def refresh() -> JSONResponse:
253
+ """Drop every cache and rescan. A mounted bucket already reflects new writes; this is what makes
254
+ the UI notice them without a restart."""
255
+ _cache.clear()
256
+ _cache_at.clear()
257
+ listing = _list_projects()
258
+ return JSONResponse({
259
+ "refreshed": True,
260
+ "source": store().describe(),
261
+ "projects": len(listing),
262
+ "datasets": sum(len(p.get("datasets", [])) for p in listing),
263
+ "runs": sum(len(p.get("runs", [])) for p in listing),
264
+ })
265
+
266
+
267
+ @app.get("/healthz")
268
+ def healthz() -> dict:
269
+ st = store()
270
+ return {
271
+ "ok": os.path.exists(os.path.join(SITE, "viewer.html")),
272
+ "source": st.describe(),
273
+ "source_kind": st.kind,
274
+ "data_dir_exists": os.path.isdir(DATA_DIR),
275
+ "bucket_fallback": DATA_BUCKET or None,
276
+ "n_projects": len(_list_projects()),
277
+ "refresh_ttl_s": REFRESH_TTL,
278
+ }
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ fastapi>=0.115
2
+ uvicorn[standard]>=0.30
3
+ # Only needed for the DATA_BUCKET fallback (reading a bucket without mounting it). A mounted bucket is
4
+ # an ordinary directory, so the Space itself does not import this.
5
+ huggingface_hub>=1.0
site/viewer.html ADDED
@@ -0,0 +1,607 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <meta charset="utf-8">
3
+ <title>Harbor runs</title>
4
+ <style>
5
+ :root{
6
+ --bg:#0d1117;--panel:#161b22;--panel2:#0e1116;--border:#30363d;--text:#e6edf3;
7
+ --muted:#8b949e;--gray:#6e7681;--accent:#58a6ff;--pass:#3ec97a;--fail:#e25c5c;--warn:#e6b455;
8
+ }
9
+ *{box-sizing:border-box}
10
+ body{margin:0;background:var(--bg);color:var(--text);font:13px/1.5 ui-sans-serif,system-ui,sans-serif}
11
+ /* ── top bar ─────────────────────────────────────────────────────────── */
12
+ header{display:flex;align-items:center;gap:8px;padding:8px 14px;border-bottom:1px solid var(--border);
13
+ background:var(--panel);position:sticky;top:0;z-index:30}
14
+ header h1{font-size:13px;margin:0;font-weight:600;letter-spacing:.2px}
15
+ select,button{background:var(--panel2);color:var(--text);border:1px solid var(--border);border-radius:5px;
16
+ padding:4px 8px;font:inherit;font-size:12px;cursor:pointer}
17
+ select:hover,button:hover{border-color:var(--accent)}
18
+ .view-btn.on{background:rgba(89,166,255,.14);border-color:var(--accent);color:var(--accent)}
19
+ .src{font:10.5px ui-monospace,monospace;color:var(--gray)}
20
+ .pill{font-size:10px;padding:1px 6px;border-radius:3px;border:1px solid var(--border);white-space:nowrap}
21
+ .pill.ok{background:rgba(62,201,122,.15);color:var(--pass);border-color:transparent}
22
+ .pill.exp{background:rgba(230,180,85,.15);color:var(--warn);border-color:transparent}
23
+ /* ── layout ──────────────────────────────────────────────────────────── */
24
+ main{display:grid;grid-template-columns:184px minmax(0,1fr);height:calc(100vh - 41px)}
25
+ .statwrap{border-bottom:1px solid var(--border)}
26
+ .statwrap>summary{cursor:pointer;padding:7px 14px;font-size:11.5px;color:var(--muted);list-style:none}
27
+ .statwrap>summary::-webkit-details-marker{display:none}
28
+ .statwrap>summary::before{content:"▸ ";color:var(--gray)}
29
+ .statwrap[open]>summary::before{content:"▾ "}
30
+ #burger{display:none}
31
+ /* Laptop-first: below 1100px the sidebar narrows, below 820px it becomes an overlay so the grid keeps
32
+ the full width — a data-dense table is the thing that must not be squeezed. */
33
+ @media(max-width:1100px){
34
+ main{grid-template-columns:150px minmax(0,1fr)}
35
+ td.cell{width:52px}
36
+ table.heatmap th.col-task,table.heatmap td.col-task{min-width:210px;max-width:210px}
37
+ }
38
+ @media(max-width:820px){
39
+ #burger{display:inline-block}
40
+ main{grid-template-columns:minmax(0,1fr)}
41
+ aside{position:fixed;left:0;top:41px;bottom:0;width:210px;z-index:25;transform:translateX(-100%);
42
+ transition:transform .15s;box-shadow:2px 0 12px rgba(0,0,0,.4)}
43
+ body.nav-open aside{transform:none}
44
+ .matrices{flex-direction:column}
45
+ table.heatmap th.col-task,table.heatmap td.col-task{min-width:150px;max-width:150px}
46
+ }
47
+ aside{border-right:1px solid var(--border);overflow:auto;background:var(--panel2)}
48
+ .pgroup{padding:7px 10px 3px;font-size:10px;text-transform:uppercase;letter-spacing:.6px;color:var(--gray)}
49
+ .kid{padding:5px 10px 5px 16px;font-size:12px;color:var(--muted);cursor:pointer;border-left:2px solid transparent}
50
+ .kid:hover{background:var(--panel);color:var(--text)}
51
+ .kid.sel{background:rgba(89,166,255,.10);border-left-color:var(--accent);color:var(--text);font-weight:600}
52
+ section{overflow:auto;padding:0}
53
+ /* ── stats ───────────────────────────────────────────────────────────── */
54
+ .stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:8px;padding:12px 14px 4px}
55
+ .stat{background:var(--panel);border:1px solid var(--border);border-radius:6px;padding:8px 12px}
56
+ .stat .num{font-size:17px;font-weight:600}
57
+ .stat .lbl{font-size:11px;color:var(--muted);margin-top:2px}
58
+ /* ── aggregate matrices ──────────────────────────────────────────────── */
59
+ .matrices{display:flex;flex-wrap:wrap;gap:10px;padding:10px 14px}
60
+ .matrix-card{background:var(--panel2);border:1px solid var(--border);border-radius:6px;padding:11px 13px}
61
+ .matrix-card h4{margin:0 0 9px;font-size:12px;color:var(--muted);font-weight:500;display:flex;gap:8px}
62
+ .matrix-card h4 .sub{color:var(--gray);font-size:10.5px}
63
+ .matrix-card table{border-collapse:collapse;font-size:12px}
64
+ .matrix-card th,.matrix-card td{padding:5px 9px;border:1px solid var(--border);text-align:center;white-space:nowrap}
65
+ .matrix-card th{background:var(--panel);color:var(--muted);font-weight:500}
66
+ .matrix-card td.lbl{background:var(--panel);text-align:left;font-size:11.5px}
67
+ .matrix-card td.cell b{font-size:13px}
68
+ .matrix-card td.cell .det{color:rgba(255,255,255,.55);font-size:10px}
69
+ .matrix-card td.empty{color:var(--gray)}
70
+ /* ── the heatmap ───────────────────────────���─────────────────────────── */
71
+ .hwrap{overflow:auto;padding:4px 14px 24px}
72
+ table.heatmap{border-collapse:separate;border-spacing:0;font-size:12px}
73
+ table.heatmap th{background:var(--panel);color:var(--muted);font-weight:500;font-size:11px;
74
+ padding:5px 8px;border-bottom:1px solid var(--border);position:sticky;top:0;z-index:6;white-space:nowrap}
75
+ table.heatmap td{border-bottom:1px solid var(--border);padding:0}
76
+ table.heatmap th.col-task,table.heatmap td.col-task{position:sticky;left:0;z-index:5;background:var(--panel2);
77
+ min-width:280px;max-width:280px;padding:5px 9px;text-align:left;border-right:2px solid var(--border)}
78
+ table.heatmap thead th.col-task{z-index:8;background:var(--panel)}
79
+ td.col-task .tid{font:11px ui-monospace,monospace;color:var(--muted)}
80
+ td.col-task .q{color:var(--text);font-size:11.5px;display:-webkit-box;-webkit-line-clamp:2;
81
+ -webkit-box-orient:vertical;overflow:hidden}
82
+ .diff{font-size:9.5px;text-transform:uppercase;font-weight:600;padding:1px 5px;border-radius:3px;margin-right:5px}
83
+ .diff.easy{background:rgba(62,201,122,.15);color:var(--pass)}
84
+ .diff.medium{background:rgba(230,180,85,.15);color:var(--warn)}
85
+ .diff.hard{background:rgba(226,92,92,.15);color:var(--fail)}
86
+ td.cell{width:62px;height:26px;cursor:pointer;text-align:center;font:11px ui-monospace,monospace;
87
+ transition:filter .1s;border-left:1px solid var(--border)}
88
+ td.cell:hover{filter:brightness(1.45);outline:1px solid var(--accent);outline-offset:-1px}
89
+ /* pass@k gradient — darker green = passed on an earlier attempt */
90
+ td.cell.pass-1{background:rgba(62,201,122,.55);color:#fff;font-weight:600}
91
+ td.cell.pass-2{background:rgba(62,201,122,.40);color:#fff;font-weight:600}
92
+ td.cell.pass-3{background:rgba(62,201,122,.28);color:#fff;font-weight:600}
93
+ td.cell.pass-4{background:rgba(62,201,122,.18);color:#fff;font-weight:600}
94
+ td.cell.fail{background:rgba(226,92,92,.25);color:rgba(255,255,255,.85)}
95
+ td.cell.na{background:rgba(230,180,85,.16);color:var(--warn)}
96
+ td.cell.empty{background:transparent;color:var(--gray);cursor:default}
97
+ td.cell.empty:hover{filter:none;outline:0}
98
+ .filters{display:flex;flex-wrap:wrap;gap:6px;align-items:center;padding:8px 14px 2px;border-bottom:1px solid var(--border)}
99
+ .fchip{font-size:11px;padding:2px 7px;border-radius:11px;border:1px solid var(--border);background:var(--panel2);
100
+ color:var(--muted);cursor:pointer;white-space:nowrap}
101
+ .fchip:hover{border-color:var(--accent);color:var(--text)}
102
+ .fchip.on{background:rgba(89,166,255,.16);border-color:var(--accent);color:var(--accent)}
103
+ .fkey{font-size:10px;text-transform:uppercase;letter-spacing:.5px;color:var(--gray);margin-left:6px}
104
+ .kid input{margin-right:6px;vertical-align:-1px}
105
+ .ctrls{display:flex;flex-wrap:wrap;gap:10px;align-items:center;padding:8px 14px;border-bottom:1px solid var(--border)}
106
+ .ctrls label{font-size:11px;color:var(--muted);display:flex;align-items:center;gap:5px}
107
+ input[type=range]{width:120px;accent-color:var(--accent)}
108
+ .chartgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:8px;padding:8px 14px 24px}
109
+ .chartbox{background:var(--panel2);border:1px solid var(--border);border-radius:6px;padding:6px 8px 2px}
110
+ .chartbox h5{margin:0 0 2px;font-size:11px;color:var(--muted);font-weight:500;display:flex;justify-content:space-between}
111
+ .chartbox h5 span{color:var(--gray);font-size:10px}
112
+ .rlegend{display:flex;flex-wrap:wrap;gap:10px;padding:4px 14px;font-size:11px}
113
+ .rlegend i{display:inline-block;width:16px;height:3px;border-radius:2px;margin-right:4px;vertical-align:2px}
114
+ .taskcard{background:var(--panel2);border:1px solid var(--border);border-radius:6px;padding:10px 12px;margin-bottom:10px}
115
+ .metagrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:6px;margin-top:8px}
116
+ .metagrid div{background:var(--panel);border:1px solid var(--border);border-radius:4px;padding:4px 8px;font-size:11px}
117
+ .metagrid em{display:block;font-style:normal;color:var(--gray);font-size:10px}
118
+ .legend{display:flex;gap:12px;align-items:center;padding:6px 14px;font-size:10.5px;color:var(--muted)}
119
+ .legend i{display:inline-block;width:14px;height:11px;border-radius:2px;margin-right:3px;vertical-align:middle}
120
+ /* ── trace dialog ────────────────────────────────────────────────────── */
121
+ dialog{background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:9px;
122
+ width:min(1020px,95vw);max-height:90vh;padding:0}
123
+ dialog::backdrop{background:rgba(0,0,0,.65)}
124
+ .dhead{display:flex;align-items:center;gap:9px;padding:10px 14px;border-bottom:1px solid var(--border);
125
+ background:var(--panel);position:sticky;top:0}
126
+ .dbody{padding:12px 14px;overflow:auto;max-height:calc(90vh - 48px)}
127
+ .trial-table{width:100%;border-collapse:collapse;font-size:12px;margin:8px 0}
128
+ .trial-table th,.trial-table td{padding:5px 8px;border-bottom:1px solid var(--border);text-align:left}
129
+ .trial-table .reward.pass{color:var(--pass);font-weight:600}
130
+ .trial-table .reward.fail{color:var(--fail)}
131
+ .trial-table .reward.na{color:var(--warn)}
132
+ .msg{border:1px solid var(--border);border-radius:6px;margin-bottom:6px;overflow:hidden}
133
+ .msg-head{background:var(--panel);padding:3px 9px;font:10.5px ui-monospace,monospace;color:var(--muted)}
134
+ .msg-body{margin:0;padding:7px 10px;white-space:pre-wrap;word-break:break-word;
135
+ font:11px/1.5 ui-monospace,monospace;max-height:280px;overflow:auto}
136
+ .gold{color:var(--pass)}
137
+ .empty-msg{color:var(--gray);padding:34px;text-align:center}
138
+ svg{background:var(--panel2);border:1px solid var(--border);border-radius:6px}
139
+ </style>
140
+ <header>
141
+ <button id="burger" title="Show/hide runs">☰</button>
142
+ <select id="proj"></select>
143
+ <button id="refresh" title="Re-read the bucket / data directory">⟳</button>
144
+ <span style="flex:1"></span>
145
+ <span class="src">cols</span>
146
+ <button id="axH" class="view-btn on">harness</button>
147
+ <button id="axM" class="view-btn">model</button>
148
+ <span class="src" id="src"></span>
149
+ </header>
150
+ <main>
151
+ <aside id="tree"></aside>
152
+ <section id="body"><div class="empty-msg">select a dataset</div></section>
153
+ </main>
154
+ <dialog id="dlg">
155
+ <div class="dhead"><b id="dtitle"></b><span id="dpills"></span><span style="flex:1"></span>
156
+ <button onclick="document.getElementById('dlg').close()">esc</button></div>
157
+ <div class="dbody" id="dbody"></div>
158
+ </dialog>
159
+ <script>
160
+ const $=s=>document.querySelector(s);
161
+ let TREE=[],AXIS="harness",DS=null,SEL=null,KMAX=4;
162
+ // Active metadata filters: {key: Set(values)}. Metadata is whatever the suite recorded, so nothing is
163
+ // hardcoded — difficulty, package_tier and reward_mode are exactly the fields that differ between
164
+ // suites, and promoting any of them to a column breaks the viewer when a suite renames one.
165
+ let FILT={};
166
+ // Several training runs can be on screen at once, so selection is a set and the charts overlay.
167
+ // Comparing curves across runs is the whole reason to look at them side by side.
168
+ let RUNSEL=new Set(), RUNCACHE={}, SMOOTH=0.6, LOGX=false;
169
+ const RUNCOLORS=["#58a6ff","#3ec97a","#e6b455","#e25c5c","#bc8cff","#39c5cf","#f778ba","#a5d6ff"];
170
+ const api=async(p,o)=>{const r=await fetch(p,o);if(!r.ok)throw new Error(`${p}: ${r.status}`);return r.json()};
171
+ const n=(v,d=3)=>v==null?"–":(typeof v==="number"?(Number.isInteger(v)?v:v.toFixed(d)):v);
172
+ const pct=v=>v==null?"–":(v*100).toFixed(0)+"%";
173
+ const short=m=>String(m).split("/").pop();
174
+ const esc=s=>String(s??"").replace(/[<>&"]/g,c=>({"<":"&lt;",">":"&gt;","&":"&amp;",'"':"&quot;"}[c]));
175
+
176
+ // Heat for the aggregate matrices: one hue, alpha carries the value. Two hues would imply a midpoint
177
+ // that pass-rate does not have.
178
+ const heatBg=p=>p==null?"transparent":`rgba(62,201,122,${(0.08+0.5*p).toFixed(3)})`;
179
+ // Only fields that can actually partition the rows are offered: a key with one value filters nothing,
180
+ // and one with a value per task (an id, a free-text question) is a list, not a filter.
181
+ function metaFacets(tasks){
182
+ const counts={};
183
+ tasks.forEach(t=>Object.entries(t.meta||{}).forEach(([k,v])=>{
184
+ if(v==null||typeof v==="object")return;
185
+ counts[k]=counts[k]||{};
186
+ counts[k][String(v)]=(counts[k][String(v)]||0)+1;
187
+ }));
188
+ const out={};
189
+ for(const [k,vals] of Object.entries(counts)){
190
+ const nv=Object.keys(vals).length;
191
+ if(nv>1 && nv<=12 && nv<tasks.length) out[k]=vals;
192
+ }
193
+ return out;
194
+ }
195
+ function passesFilters(t){
196
+ return Object.entries(FILT).every(([k,set])=>
197
+ !set.size || set.has(String((t.meta||{})[k])));
198
+ }
199
+ const heatOf=(pass,measured)=>measured?pass/measured:null;
200
+
201
+ async function loadTree(){
202
+ TREE=await api("/api/projects");
203
+ const h=await api("/healthz");
204
+ $("#src").textContent=h.source;
205
+ $("#proj").innerHTML=TREE.map(p=>`<option value="${p.project_id}">${esc(p.label||p.project_id)}</option>`).join("");
206
+ renderRuns();
207
+ const f=document.querySelector(".kid"); if(f&&!SEL) f.click();
208
+ }
209
+
210
+ // One list, in the sidebar, scoped to the selected project. Two dropdowns made the project choice and
211
+ // the run choice look like peers when the second is nested inside the first.
212
+ function syncBoxes(){
213
+ document.querySelectorAll(".kid input[data-run]").forEach(cb=>cb.checked=RUNSEL.has(cb.dataset.run));
214
+ }
215
+
216
+ function renderRuns(){
217
+ const p=TREE.find(x=>x.project_id===$("#proj").value)||TREE[0]||{};
218
+ const ds=(p.datasets||[]), rs=(p.runs||[]);
219
+ $("#tree").innerHTML=`<div class="pgroup">Runs</div>
220
+ ${ds.length?ds.map(d=>`<div class="kid" data-p="${p.project_id}" data-d="${d}"
221
+ title="${esc(d)}">▦ ${esc(d)}</div>`).join(""):""}
222
+ ${rs.length?`<div class="pgroup">Training</div>`+rs.map(r=>`<div class="kid" data-p="${p.project_id}" data-r="${r}"
223
+ title="${esc(r)}"><input type="checkbox" data-run="${esc(r)}"${RUNSEL.has(r)?" checked":""}>${esc(r)}</div>`).join(""):""}
224
+ ${!ds.length&&!rs.length?`<div class="empty-msg" style="padding:18px;font-size:11px">nothing here yet</div>`:""}`;
225
+ document.querySelectorAll(".kid").forEach(el=>el.onclick=(ev)=>{
226
+ if(ev.target.tagName==="INPUT")return; // the checkbox drives comparison, not navigation
227
+ document.querySelectorAll(".kid").forEach(k=>k.classList.remove("sel"));el.classList.add("sel");
228
+ if(window.matchMedia("(max-width:820px)").matches) document.body.classList.remove("nav-open");
229
+ if(el.dataset.d){RUNSEL.clear();openDataset(el.dataset.p,el.dataset.d)}
230
+ else{RUNSEL.clear();RUNSEL.add(el.dataset.r);syncBoxes();openRuns(el.dataset.p)}
231
+ });
232
+ document.querySelectorAll(".kid input[data-run]").forEach(cb=>cb.onchange=()=>{
233
+ cb.checked?RUNSEL.add(cb.dataset.run):RUNSEL.delete(cb.dataset.run);
234
+ if(RUNSEL.size) openRuns($("#proj").value);
235
+ });
236
+ }
237
+ $("#proj").onchange=()=>{renderRuns();const f=document.querySelector(".kid");if(f)f.click()};
238
+ $("#burger").onclick=()=>document.body.classList.toggle("nav-open");
239
+
240
+ async function openDataset(pid,did){
241
+ SEL={pid,did};$("#body").innerHTML=`<div class="empty-msg">loading ${did}…</div>`;
242
+ try{DS=await api(`/api/projects/${pid}/datasets/${did}`)}
243
+ catch(e){$("#body").innerHTML=`<div class="empty-msg">${e.message}</div>`;return}
244
+ KMAX=DS.k_max||4; $("#proj").value=pid;
245
+ renderGrid();
246
+ }
247
+
248
+ function renderGrid(){
249
+ const d=DS, models=d.models||[], harnesses=d.harnesses||[];
250
+ const cols = AXIS==="harness"?harnesses:models;
251
+ const subs = AXIS==="harness"?models:harnesses;
252
+ const keyFor=(c,s)=>AXIS==="harness"?`${s}|${c}`:`${c}|${s}`;
253
+ const s=d.summary||{};
254
+
255
+ const facets=metaFacets(d.tasks||[]);
256
+ const shown=(d.tasks||[]).filter(passesFilters);
257
+ const filterBar=Object.keys(facets).length?`<div class="filters">
258
+ ${Object.entries(facets).map(([k,vals])=>`<span class="fkey">${esc(k)}</span>` +
259
+ Object.entries(vals).sort((a,b)=>a[0].localeCompare(b[0],undefined,{numeric:true})).map(([v,c])=>
260
+ `<span class="fchip ${(FILT[k]&&FILT[k].has(v))?"on":""}" data-fk="${esc(k)}" data-fv="${esc(v)}"
261
+ >${esc(v)} <span class="src">${c}</span></span>`).join("")).join("")}
262
+ ${Object.values(FILT).some(x=>x.size)?`<span class="fchip" id="fclear">clear</span>`:""}
263
+ <span class="src">${shown.length}/${(d.tasks||[]).length} tasks</span></div>`:"";
264
+
265
+ const stats=[["tasks",n(s.tasks_total)],["solved ≤k",n(s.tasks_any_pass)],
266
+ ["attempts",n(s.attempts_total)],["passed",n(s.attempts_passed)],
267
+ // Beside the pass rates on purpose: an all-infra task is unmeasured, not failed, and folding it in
268
+ // as a zero would read as a real (bad) measurement.
269
+ ["unmeasured",n(s.n_all_infra)],["k",KMAX]]
270
+ .map(([l,v])=>`<div class="stat"><div class="num">${v}</div><div class="lbl">${l}</div></div>`).join("");
271
+
272
+ // aggregate matrix: model × harness, cells = pass@k
273
+ const agg={};
274
+ shown.forEach(t=>Object.entries(t.cells||{}).forEach(([k,c])=>{
275
+ const [m,h]=k.split("|"); agg[m]=agg[m]||{}; agg[m][h]=agg[m][h]||{cells:0,pass:0,measured:0};
276
+ const a=agg[m][h]; a.cells++;
277
+ const graded=(c.attempts||[]).filter(x=>x.reward!=null);
278
+ if(graded.length){a.measured++; if(c.passed_at)a.pass++}
279
+ }));
280
+ const mxh=`<table><thead><tr><th></th>${harnesses.map(h=>`<th>${esc(h)}</th>`).join("")}</tr></thead><tbody>
281
+ ${models.map(m=>`<tr><td class="lbl">${esc(short(m))}</td>${harnesses.map(h=>{
282
+ const a=(agg[m]||{})[h];
283
+ if(!a||!a.measured) return `<td class="empty">—</td>`;
284
+ const p=a.pass/a.measured;
285
+ return `<td class="cell" style="background:${heatBg(p)}"><b>${pct(p)}</b><div class="det">${a.pass}/${a.measured}</div></td>`;
286
+ }).join("")}</tr>`).join("")}</tbody></table>`;
287
+
288
+ // Second matrix pivots on whichever metadata field has the most values — chosen from the data
289
+ // rather than hardcoded to difficulty, because that field is not guaranteed to exist.
290
+ const facetKey=Object.entries(facets).sort((a,b)=>Object.keys(b[1]).length-Object.keys(a[1]).length)[0]?.[0];
291
+ let mxd="";
292
+ if(facetKey){
293
+ const vals=[...new Set(shown.map(t=>String((t.meta||{})[facetKey])).filter(v=>v!=="undefined"))]
294
+ .sort((a,b)=>a.localeCompare(b,undefined,{numeric:true}));
295
+ const acc={};
296
+ shown.forEach(t=>Object.entries(t.cells||{}).forEach(([k,c])=>{
297
+ const h=k.split("|")[1], v=String((t.meta||{})[facetKey]); if(v==="undefined")return;
298
+ acc[h]=acc[h]||{}; acc[h][v]=acc[h][v]||{measured:0,pass:0};
299
+ if((c.attempts||[]).some(x=>x.reward!=null)){acc[h][v].measured++; if(c.passed_at)acc[h][v].pass++}
300
+ }));
301
+ mxd=`<table><thead><tr><th></th>${vals.map(v=>`<th>${esc(v)}</th>`).join("")}</tr></thead><tbody>
302
+ ${harnesses.map(h=>`<tr><td class="lbl">${esc(h)}</td>${vals.map(v=>{
303
+ const a=(acc[h]||{})[v];
304
+ if(!a||!a.measured)return `<td class="empty">—</td>`;
305
+ const pr=heatOf(a.pass,a.measured);
306
+ return `<td class="cell" style="background:${heatBg(pr)}"><b>${pct(pr)}</b><div class="det">${a.pass}/${a.measured}</div></td>`;
307
+ }).join("")}</tr>`).join("")}</tbody></table>`;
308
+ }
309
+
310
+ // the per-task heatmap
311
+ const head=`<tr><th class="col-task">task</th>${cols.flatMap(c=>
312
+ (subs.length?subs:[null]).map(sb=>`<th title="${esc(c)}${sb?" · "+esc(sb):""}">${esc(subs.length>1?short(sb):c)}</th>`)).join("")}</tr>`
313
+ + (subs.length>1?`<tr><th class="col-task"></th>${cols.flatMap(c=>subs.map(()=>`<th>${esc(c)}</th>`)).join("")}</tr>`:"");
314
+ const rows=shown.map(t=>{
315
+ const tds=cols.flatMap(c=>(subs.length?subs:[null]).map(sb=>{
316
+ const cell=(t.cells||{})[keyFor(c,sb)];
317
+ if(!cell) return `<td class="cell empty">—</td>`;
318
+ const graded=(cell.attempts||[]).filter(x=>x.reward!=null);
319
+ // pass-N shades by WHICH attempt passed: darker = solved sooner, so the grid shows reliability
320
+ // rather than a flat pass/fail.
321
+ const cls=cell.passed_at?`pass-${Math.min(cell.passed_at,4)}`:graded.length?"fail":"na";
322
+ const mark=cell.passed_at?`✓${cell.passed_at}`:graded.length?"✗":"·";
323
+ return `<td class="cell ${cls}" data-t="${esc(t.id)}" data-k="${esc(keyFor(c,sb))}"
324
+ title="${graded.length}/${(cell.attempts||[]).length} graded">${mark}</td>`;
325
+ })).join("");
326
+ // Whichever facet is active labels the row, so the pill reflects the data instead of assuming a
327
+ // difficulty scale exists.
328
+ const lbl=facetKey?String((t.meta||{})[facetKey]??""):"";
329
+ return `<tr><td class="col-task" data-task="${esc(t.id)}">
330
+ <div>${lbl?`<span class="diff ${diffLike(facetKey,lbl)}">${esc(lbl)}</span>`:""}<span class="tid">${esc(t.id)}</span></div>
331
+ <div class="q">${esc((t.question||"").slice(0,150))}</div></td>${tds}</tr>`;
332
+ }).join("");
333
+
334
+ // Collapsed by default: the grid is what you came for, and six counters above it push it below the
335
+ // fold on a laptop.
336
+ const statSummary=`${n(s.tasks_total)} tasks · ${n(s.tasks_any_pass)} solved ≤k · ${n(s.attempts_total)} attempts`
337
+ + (s.n_all_infra?` · ${n(s.n_all_infra)} unmeasured`:"");
338
+ $("#body").innerHTML=`${filterBar}
339
+ <details class="statwrap"><summary>${statSummary}</summary><div class="stats">${stats}</div></details>
340
+ <div class="matrices">
341
+ <div class="matrix-card"><h4>model × harness <span class="sub">pass@${KMAX}</span></h4>${mxh}</div>
342
+ ${mxd?`<div class="matrix-card"><h4>harness × ${esc(facetKey)} <span class="sub">pass@${KMAX}</span></h4>${mxd}</div>`:""}
343
+ </div>
344
+ <div class="legend">
345
+ <span><i style="background:rgba(62,201,122,.55)"></i>passed 1st</span>
346
+ <span><i style="background:rgba(62,201,122,.18)"></i>passed 4th</span>
347
+ <span><i style="background:rgba(226,92,92,.25)"></i>graded, failed</span>
348
+ <span><i style="background:rgba(230,180,85,.16)"></i>unmeasured (infra)</span>
349
+ <span class="src">click a cell for attempts + trace</span>
350
+ </div>
351
+ <div class="hwrap"><table class="heatmap"><thead>${head}</thead><tbody>${rows}</tbody></table></div>`;
352
+ document.querySelectorAll("td.cell[data-t]").forEach(td=>td.onclick=()=>openCell(td.dataset.t,td.dataset.k));
353
+ document.querySelectorAll("td.col-task[data-task]").forEach(td=>td.onclick=()=>openTask(td.dataset.task));
354
+ document.querySelectorAll(".fchip[data-fk]").forEach(el=>el.onclick=()=>{
355
+ const k=el.dataset.fk,v=el.dataset.fv;
356
+ FILT[k]=FILT[k]||new Set();
357
+ FILT[k].has(v)?FILT[k].delete(v):FILT[k].add(v);
358
+ renderGrid();
359
+ });
360
+ const fc=$("#fclear"); if(fc) fc.onclick=()=>{FILT={};renderGrid()};
361
+ }
362
+
363
+ function diffLike(key,val){
364
+ // Colour a facet value only when it plausibly orders low->high; otherwise leave it neutral rather
365
+ // than implying a ranking the field may not have.
366
+ const num=Number(val);
367
+ if(!/level|difficulty|tier|rank/i.test(key||"")||Number.isNaN(num))return "";
368
+ return num<=0?"easy":num<=2?"medium":"hard";
369
+ }
370
+
371
+ function openTask(taskId){
372
+ const t=(DS.tasks||[]).find(x=>String(x.id)===String(taskId))||{};
373
+ const cells=Object.entries(t.cells||{});
374
+ $("#dtitle").textContent=t.id||taskId;
375
+ $("#dpills").innerHTML=`<span class="pill">${cells.length} cell(s)</span>`;
376
+ const meta=Object.entries(t.meta||{}).map(([k,v])=>
377
+ `<div><em>${esc(k)}</em>${esc(typeof v==="object"?JSON.stringify(v):v)}</div>`).join("");
378
+ const perCell=cells.map(([k,c])=>{
379
+ const [m,h]=k.split("|");
380
+ const graded=(c.attempts||[]).filter(a=>a.reward!=null);
381
+ return `<tr><td>${esc(h)}</td><td>${esc(short(m))}</td>
382
+ <td class="reward ${c.passed_at?"pass":graded.length?"fail":"na"}">
383
+ ${c.passed_at?`✓ attempt ${c.passed_at}`:graded.length?"✗ failed":"unmeasured"}</td>
384
+ <td>${graded.length}/${(c.attempts||[]).length}</td>
385
+ <td>${c.attempts?.[0]?.n_turns??"–"}</td>
386
+ <td><button data-cell="${esc(k)}">open</button></td></tr>`;
387
+ }).join("");
388
+ $("#dbody").innerHTML=`
389
+ <div class="taskcard"><div class="msg-head" style="background:none;padding:0 0 4px">question</div>
390
+ <pre class="msg-body" style="padding:0">${esc(t.question||"(none recorded)")}</pre></div>
391
+ ${t.answer!=null?`<div class="taskcard"><div class="msg-head" style="background:none;padding:0 0 4px">gold answer</div>
392
+ <pre class="msg-body gold" style="padding:0">${esc(t.answer)}</pre></div>`:""}
393
+ ${meta?`<div class="taskcard"><div class="msg-head" style="background:none;padding:0">metadata</div>
394
+ <div class="metagrid">${meta}</div></div>`:""}
395
+ <table class="trial-table"><thead><tr><th>harness</th><th>model</th><th>result</th><th>graded</th><th>turns</th><th></th></tr></thead>
396
+ <tbody>${perCell}</tbody></table>`;
397
+ $("#dlg").showModal();
398
+ document.querySelectorAll("#dbody button[data-cell]").forEach(b=>
399
+ b.onclick=()=>openCell(taskId,b.dataset.cell));
400
+ }
401
+
402
+ async function openCell(taskId,cellKey){
403
+ const t=(DS.tasks||[]).find(x=>String(x.id)===String(taskId))||{};
404
+ const cell=(t.cells||{})[cellKey]||{};
405
+ const [model,harness]=cellKey.split("|");
406
+ const graded=(cell.attempts||[]).filter(a=>a.reward!=null);
407
+ const pass1=graded.length?graded.filter(a=>a.reward>0).length/graded.length:null;
408
+ $("#dtitle").textContent=`${taskId}`;
409
+ $("#dpills").innerHTML=`<span class="pill">${esc(harness)}</span> <span class="pill">${esc(short(model))}</span>
410
+ <span class="pill ${cell.passed_at?"ok":"exp"}">${cell.passed_at?`pass@${KMAX} ✓ (attempt ${cell.passed_at})`:graded.length?"failed":"unmeasured"}</span>`;
411
+ $("#dbody").innerHTML=`
412
+ <div class="stats" style="padding:0 0 8px">
413
+ <div class="stat"><div class="num">${pass1==null?"–":pct(pass1)}</div><div class="lbl">pass@1</div></div>
414
+ <div class="stat"><div class="num">${graded.length}/${(cell.attempts||[]).length}</div><div class="lbl">graded</div></div>
415
+ <div class="stat"><div class="num">${cell.passed_at??"–"}</div><div class="lbl">first pass</div></div>
416
+ </div>
417
+ ${t.question?`<div class="msg"><div class="msg-head">question</div><pre class="msg-body">${esc(t.question)}</pre></div>`:""}
418
+ ${t.answer!=null?`<div class="msg"><div class="msg-head">gold</div><pre class="msg-body gold">${esc(t.answer)}</pre></div>`:""}
419
+ <table class="trial-table"><thead><tr><th>#</th><th>reward</th><th>turns</th><th>elapsed</th><th></th></tr></thead>
420
+ <tbody>${(cell.attempts||[]).map(a=>`<tr>
421
+ <td>${a.attempt}</td>
422
+ <td class="reward ${a.reward==null?"na":a.reward>0?"pass":"fail"}">${a.reward==null?"unmeasured":n(a.reward,2)}</td>
423
+ <td>${n(a.n_turns)}</td><td>${a.elapsed_sec?n(a.elapsed_sec,1)+"s":"–"}</td>
424
+ <td>${a.trace?`<button data-tr="${esc(a.trace)}">trace</button>`:`<span class="src">no trace recorded</span>`}</td>
425
+ </tr>`).join("")}</tbody></table>
426
+ <div id="tr"></div>`;
427
+ $("#dlg").showModal();
428
+ document.querySelectorAll("#dbody button[data-tr]").forEach(b=>b.onclick=()=>showTrace(b.dataset.tr));
429
+ }
430
+
431
+ async function showTrace(path){
432
+ $("#tr").innerHTML=`<div class="empty-msg">loading…</div>`;
433
+ let tr;
434
+ try{tr=await api(`/api/projects/${SEL.pid}/datasets/${SEL.did}/trace?path=${encodeURIComponent(path)}`)}
435
+ catch(e){$("#tr").innerHTML=`<div class="empty-msg">${e.message}</div>`;return}
436
+ // reward_method is surfaced because the grader is exact -> numeric -> LLM judge: a 0 from the judge is
437
+ // a wrong answer, a 0 from exact match may only be a formatting mismatch.
438
+ const meta=["reward","reward_method","n_turns","elapsed_sec","gold","predicted"].filter(k=>tr[k]!==undefined)
439
+ .map(k=>`<div class="stat"><div class="num">${esc(n(tr[k],2))}</div><div class="lbl">${k}</div></div>`).join("");
440
+ const msgs=(tr.messages||[]).map(m=>{
441
+ const calls=(m.tool_calls||[]).map(c=>JSON.stringify(c)).join("\n");
442
+ const body=(typeof m.content==="string"?m.content:JSON.stringify(m.content,null,2))||"";
443
+ return `<div class="msg"><div class="msg-head">${esc(m.role)}${calls?" · tool_calls":""}</div>
444
+ <pre class="msg-body">${esc(body)}${calls?"\n\n"+esc(calls):""}</pre></div>`;
445
+ }).join("");
446
+ $("#tr").innerHTML=`<div class="stats" style="padding:10px 0 6px">${meta}</div>
447
+ ${msgs||`<pre class="msg-body">${esc(JSON.stringify(tr,null,2).slice(0,4000))}</pre>`}`;
448
+ }
449
+
450
+ async function openRuns(pid){
451
+ const ids=[...RUNSEL];
452
+ if(!ids.length){$("#body").innerHTML=`<div class="empty-msg">select a training run</div>`;return}
453
+ SEL={pid,rid:ids[0],runs:ids};
454
+ $("#body").innerHTML=`<div class="empty-msg">loading ${ids.length} run(s)…</div>`;
455
+
456
+ const loaded=[];
457
+ for(const rid of ids){
458
+ const key=`${pid}/${rid}`;
459
+ if(!RUNCACHE[key]){
460
+ let meta={},d={steps:[]};
461
+ try{meta=await api(`/api/projects/${pid}/runs/${rid}`)}catch(e){}
462
+ try{d=await api(`/api/projects/${pid}/runs/${rid}/train`)}
463
+ catch(e){RUNCACHE[key]={rid,meta,steps:[],error:e.message};loaded.push(RUNCACHE[key]);continue}
464
+ RUNCACHE[key]={rid,meta,steps:d.steps||[],skipped:d.skipped_lines,rollouts:d.rollout_steps||[]};
465
+ }
466
+ loaded.push(RUNCACHE[key]);
467
+ }
468
+ renderTrain(loaded);
469
+ }
470
+
471
+ function renderTrain(runs){
472
+ // Chart every numeric series the runs actually logged, rather than a fixed list: a trainer that adds
473
+ // a metric should show it without a code change here, the same reason task metadata is discovered.
474
+ const keys=[...new Set(runs.flatMap(r=>r.steps.flatMap(s=>
475
+ Object.entries(s).filter(([k,v])=>typeof v==="number"&&k!=="step").map(([k])=>k))))];
476
+ const preferred=["reward","reward_std","loss","ratio","kl","entropy","learning_rate"];
477
+ keys.sort((a,b)=>{
478
+ const ia=preferred.indexOf(a),ib=preferred.indexOf(b);
479
+ return (ia<0?99:ia)-(ib<0?99:ib) || a.localeCompare(b);
480
+ });
481
+
482
+ const summary=runs.map((r,i)=>{
483
+ const g=r.steps.filter(s=>(s.reward_std||0)>0).length;
484
+ return `<div class="stat" style="border-left:3px solid ${RUNCOLORS[i%RUNCOLORS.length]}">
485
+ <div class="num">${r.steps.length}<span class="src"> steps</span></div>
486
+ <div class="lbl">${esc(r.rid)}</div>
487
+ <div class="lbl">${g}/${r.steps.length} with a gradient${r.error?" · "+esc(r.error):""}</div></div>`;
488
+ }).join("");
489
+
490
+ const legend=runs.map((r,i)=>
491
+ `<span><i style="background:${RUNCOLORS[i%RUNCOLORS.length]}"></i>${esc(r.rid)}</span>`).join("");
492
+
493
+ const charts=keys.map(k=>{
494
+ const series=runs.map((r,i)=>({
495
+ pts:r.steps.map((s,j)=>[s.step??j,s[k]]).filter(pt=>typeof pt[1]==="number"),
496
+ color:RUNCOLORS[i%RUNCOLORS.length],
497
+ })).filter(x=>x.pts.length);
498
+ if(!series.length)return "";
499
+ return `<div class="chartbox"><h5>${esc(k)}<span>${series.map(x=>x.pts.length).join(" / ")} pts</span></h5>
500
+ ${multiChart(series)}</div>`;
501
+ }).join("");
502
+
503
+ const anyRollouts=runs.some(r=>(r.rollouts||[]).length);
504
+ $("#body").innerHTML=`
505
+ <div class="ctrls">
506
+ <label>smoothing <input type="range" id="sm" min="0" max="0.95" step="0.05" value="${SMOOTH}">
507
+ <span class="src" id="smv">${SMOOTH.toFixed(2)}</span></label>
508
+ <label><input type="checkbox" id="logx"${LOGX?" checked":""}> log x</label>
509
+ <span class="src">${runs.length} run(s) overlaid — tick more in the sidebar to compare</span>
510
+ ${anyRollouts?`<button id="rollbtn">rollout traces</button>`:`<span class="src">no rollout traces recorded for these runs</span>`}
511
+ </div>
512
+ <details class="statwrap" open><summary>${runs.length} run(s)</summary><div class="stats">${summary}</div></details>
513
+ <div class="rlegend">${legend}</div>
514
+ <div class="chartgrid">${charts||`<div class="empty-msg">no numeric series logged</div>`}</div>`;
515
+
516
+ $("#sm").oninput=(e)=>{SMOOTH=+e.target.value;$("#smv").textContent=SMOOTH.toFixed(2);renderTrain(runs)};
517
+ $("#logx").onchange=(e)=>{LOGX=e.target.checked;renderTrain(runs)};
518
+ const rb=$("#rollbtn"); if(rb) rb.onclick=()=>openRollouts(runs);
519
+ }
520
+
521
+ // EMA, applied left to right. Shown WITH the raw series rather than instead of it: a smoothed curve
522
+ // hides the variance, and on a GRPO run the variance is the signal — a flat reward with high spread
523
+ // means different things from a flat reward with none.
524
+ function ema(pts,alpha){
525
+ if(alpha<=0)return pts;
526
+ const out=[];let acc=null;
527
+ for(const [x,y] of pts){acc = acc===null ? y : alpha*acc+(1-alpha)*y; out.push([x,acc])}
528
+ return out;
529
+ }
530
+
531
+ function multiChart(series){
532
+ const W=330,H=140,P=34,B=20;
533
+ const all=series.flatMap(s=>s.pts);
534
+ const xs=all.map(p=>p[0]),ys=all.map(p=>p[1]);
535
+ let x0=Math.min(...xs),x1=Math.max(...xs);if(x0===x1)x1=x0+1;
536
+ let y0=Math.min(...ys),y1=Math.max(...ys);if(y0===y1){y0-=.5;y1+=.5}
537
+ const pad=(y1-y0)*0.08;y0-=pad;y1+=pad;
538
+ const fx=v=>LOGX?Math.log10(Math.max(v,1)):v;
539
+ const fx0=fx(x0),fx1=Math.max(fx(x1),fx0+1e-9);
540
+ const sx=v=>P+(fx(v)-fx0)/(fx1-fx0)*(W-P-8);
541
+ const sy=v=>H-B-(v-y0)/(y1-y0)*(H-B-14);
542
+ const grid=[0,.5,1].map(f=>{const y=y0+(y1-y0)*f;
543
+ return `<line x1="${P}" x2="${W-8}" y1="${sy(y).toFixed(1)}" y2="${sy(y).toFixed(1)}" stroke="#30363d" stroke-width=".5"/>
544
+ <text x="2" y="${(sy(y)+3).toFixed(1)}" fill="#6e7681" font-size="8.5">${y.toFixed(2)}</text>`}).join("");
545
+ const paths=series.map(s=>{
546
+ const raw=s.pts.map((p,i)=>`${i?"L":"M"}${sx(p[0]).toFixed(1)},${sy(p[1]).toFixed(1)}`).join("");
547
+ const sm=ema(s.pts,SMOOTH).map((p,i)=>`${i?"L":"M"}${sx(p[0]).toFixed(1)},${sy(p[1]).toFixed(1)}`).join("");
548
+ return `<path d="${raw}" fill="none" stroke="${s.color}" stroke-width="1" opacity="${SMOOTH>0?0.22:1}"/>
549
+ ${SMOOTH>0?`<path d="${sm}" fill="none" stroke="${s.color}" stroke-width="1.8"/>`:""}`;
550
+ }).join("");
551
+ return `<svg viewBox="0 0 ${W} ${H}" style="width:100%;height:auto">${grid}${paths}
552
+ <text x="${P}" y="${H-4}" fill="#6e7681" font-size="8.5">${x0}</text>
553
+ <text x="${W-30}" y="${H-4}" fill="#6e7681" font-size="8.5">${x1}</text></svg>`;
554
+ }
555
+
556
+ async function openRollouts(runs){
557
+ const withR=runs.filter(r=>(r.rollouts||[]).length);
558
+ $("#dtitle").textContent="rollout traces";
559
+ $("#dpills").innerHTML=`<span class="pill">${withR.length} run(s)</span>`;
560
+ $("#dbody").innerHTML=withR.map(r=>`<div class="taskcard"><b>${esc(r.rid)}</b>
561
+ <table class="trial-table"><thead><tr><th>step</th><th>rollouts</th><th>rewards</th><th></th></tr></thead>
562
+ <tbody>${r.rollouts.map(st=>`<tr><td>${st.step}</td><td>${st.n}</td>
563
+ <td>${(st.rewards||[]).map(x=>x==null?"·":Number(x).toFixed(2)).join(" ")}</td>
564
+ <td><button data-run="${esc(r.rid)}" data-step="${st.step}">open</button></td></tr>`).join("")}
565
+ </tbody></table></div>`).join("") || `<div class="empty-msg">no rollout traces recorded</div>`;
566
+ $("#dlg").showModal();
567
+ document.querySelectorAll("#dbody button[data-step]").forEach(b=>b.onclick=()=>showRolloutStep(b.dataset.run,b.dataset.step));
568
+ }
569
+
570
+ async function showRolloutStep(rid,step){
571
+ const box=$("#dbody");
572
+ box.insertAdjacentHTML("afterbegin",`<div id="rs" class="empty-msg">loading step ${step}…</div>`);
573
+ let rows;
574
+ try{rows=await api(`/api/projects/${SEL.pid}/runs/${rid}/rollouts/${step}`)}
575
+ catch(e){$("#rs").outerHTML=`<div class="warnbox">${e.message}</div>`;return}
576
+ $("#rs").outerHTML=`<div class="taskcard"><b>step ${esc(step)} · ${rows.rollouts.length} rollout(s)</b>
577
+ ${rows.rollouts.map((ro,i)=>`
578
+ <details><summary class="src">#${i+1} · reward ${ro.reward==null?"unmeasured":n(ro.reward,2)}
579
+ · ${n(ro.n_turns)} turns · ${esc(ro.task_id||"")}</summary>
580
+ ${(ro.messages||[]).map(m=>`<div class="msg"><div class="msg-head">${esc(m.role)}</div>
581
+ <pre class="msg-body">${esc(typeof m.content==="string"?m.content:JSON.stringify(m.content,null,2))}</pre></div>`).join("")
582
+ || `<div class="src" style="padding:6px">no conversation recorded for this rollout</div>`}
583
+ </details>`).join("")}</div>`;
584
+ }
585
+
586
+ function chart(steps,key){
587
+ const pts=steps.map((s,i)=>[s.step??i,s[key]]).filter(p=>typeof p[1]==="number");
588
+ if(!pts.length)return"";
589
+ const W=380,H=112,P=30,xs=pts.map(p=>p[0]),ys=pts.map(p=>p[1]);
590
+ const x0=Math.min(...xs),x1=Math.max(...xs,x0+1);let y0=Math.min(...ys),y1=Math.max(...ys);
591
+ if(y0===y1){y0-=.5;y1+=.5}
592
+ const sx=v=>P+(v-x0)/(x1-x0)*(W-P-8),sy=v=>H-P+4-(v-y0)/(y1-y0)*(H-P-12);
593
+ return `<svg width="${W}" height="${H}" style="margin:0 8px 8px 0">
594
+ <text x="6" y="13" fill="#8b949e" font-size="10.5">${key}</text>
595
+ <text x="6" y="${H-6}" fill="#6e7681" font-size="9">${y0.toFixed(2)}</text>
596
+ <text x="${W-44}" y="${H-6}" fill="#6e7681" font-size="9">step ${x1}</text>
597
+ <path d="${pts.map((p,i)=>`${i?"L":"M"}${sx(p[0]).toFixed(1)},${sy(p[1]).toFixed(1)}`).join("")}"
598
+ fill="none" stroke="#58a6ff" stroke-width="1.5"/></svg>`;
599
+ }
600
+ $("#axH").onclick=()=>{AXIS="harness";$("#axH").classList.add("on");$("#axM").classList.remove("on");if(DS&&SEL?.did)renderGrid()};
601
+ $("#axM").onclick=()=>{AXIS="model";$("#axM").classList.add("on");$("#axH").classList.remove("on");if(DS&&SEL?.did)renderGrid()};
602
+ $("#refresh").onclick=async()=>{$("#refresh").textContent="…";
603
+ try{await api("/api/refresh",{method:"POST"});RUNCACHE={};const k=SEL;await loadTree();
604
+ if(k?.did)await openDataset(k.pid,k.did);else if(RUNSEL.size)await openRuns(k.pid)}
605
+ finally{$("#refresh").textContent="⟳"}};
606
+ loadTree();
607
+ </script>
tools/classify.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Derive the stable / experimental tiers from a sweep, instead of asserting them.
2
+
3
+ The tier is a claim about evidence, so it should be computed from the evidence and carry it. A badge
4
+ that someone typed by hand goes stale the moment a harness improves or regresses, and a tier with no
5
+ stated reason is just a colour.
6
+
7
+ RULES, in the order they are applied:
8
+
9
+ stable the harness produced graded rollouts for essentially every task AND has a verified
10
+ training run. Both halves matter: capture working proves the tokens are right, and a
11
+ completed training step proves the trainer can consume them — they are separate failure
12
+ modes, and this stack has hit each independently.
13
+ experimental anything else, with the specific gap named. Never a bare tier.
14
+
15
+ WHAT IS NOT A REASON TO DOWNGRADE. A low pass rate. A harness scoring 0.0 on hard tasks is working
16
+ correctly and reporting a real result; treating that as a defect would rank harnesses by how easy their
17
+ tasks were. Only unmeasured rollouts, pauses, and known skew count against a harness here.
18
+
19
+ Prompt re-render skew IS recorded as a caveat rather than a downgrade on its own: it is harmless for
20
+ eval (nothing is trained) and disqualifying for training, so the caveat says which.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import json
27
+ from pathlib import Path
28
+
29
+ HERE = Path(__file__).resolve().parents[1]
30
+
31
+ # Measured over 670 turns against the engine's own prompt_token_ids. Eval-safe, training-unsafe.
32
+ KNOWN_SKEW = {
33
+ "claude-code": "+2 tokens per prompt re-render — harmless for eval, forks every turn when training",
34
+ "gemini-cli": "+2 tokens per prompt re-render — harmless for eval, forks every turn when training",
35
+ "kimi-cli": "-10 tokens per tool call — the largest measured skew; unsafe to train on",
36
+ }
37
+ # Reads os.environ inside run(), so concurrency depends on the context-local overlay.
38
+ NEEDS_OVERLAY = {"goose", "claude-code", "gemini-cli"}
39
+ NO_STEP_LIMIT_NOTE = ("no step-limit expression in its seam, so rollouts run to the timeout; "
40
+ "the kill surfaces as exit 137 and the rollout is retried")
41
+
42
+
43
+ def classify(sweep: dict, trained: set[str], measured_floor: float) -> dict:
44
+ summary = sweep.get("summary", {})
45
+ per = summary.get("harnesses", {})
46
+ paused = summary.get("paused_harnesses", {})
47
+ k = summary.get("k", 4)
48
+ out = {}
49
+
50
+ for harness, m in sorted(per.items()):
51
+ caveats, tier = [], "experimental"
52
+ n_tasks = m.get("n_tasks") or 0
53
+ measured = m.get("n_measured") or 0
54
+ coverage = (measured / n_tasks) if n_tasks else 0.0
55
+
56
+ if harness in paused:
57
+ caveats.append(f"PAUSED mid-sweep: {paused[harness]}")
58
+ elif coverage < measured_floor:
59
+ caveats.append(
60
+ f"only {measured}/{n_tasks} tasks produced a graded rollout "
61
+ f"({coverage:.0%} < {measured_floor:.0%} required)"
62
+ )
63
+ elif harness not in trained:
64
+ caveats.append(
65
+ "eval measured but no verified training run — capture working does not prove the "
66
+ "trainer can consume it, which is a separate failure mode"
67
+ )
68
+ else:
69
+ tier = "stable"
70
+
71
+ if harness in KNOWN_SKEW:
72
+ caveats.append(KNOWN_SKEW[harness])
73
+ if harness in NEEDS_OVERLAY:
74
+ caveats.append("reads os.environ inside run(); concurrent only via the context-local overlay")
75
+
76
+ entry = {
77
+ "tier": tier,
78
+ "evidence": (
79
+ f"pass@{k} {m.get(f'pass@{k}')}, pass@1 {m.get('pass@1')}, "
80
+ f"{measured}/{n_tasks} tasks measured, mean {m.get('mean_turns')} turns"
81
+ ),
82
+ }
83
+ if caveats:
84
+ entry["caveats"] = caveats
85
+ out[harness] = entry
86
+
87
+ # A harness that never appeared in the sweep at all is not 'experimental', it is untested — saying
88
+ # otherwise would imply it was tried.
89
+ for harness in paused:
90
+ out.setdefault(harness, {"tier": "experimental", "caveats": [f"PAUSED: {paused[harness]}"]})
91
+ return out
92
+
93
+
94
+ def main() -> int:
95
+ ap = argparse.ArgumentParser()
96
+ # Several files, because a sweep can be split across jobs — and it was: one 15-harness job projected
97
+ # past its time limit, so it became three. Merging here rather than requiring one file means the
98
+ # split is an operational detail instead of something the classification has to know about.
99
+ ap.add_argument("--sweep", required=True, nargs="+", help="one or more eval sweep JSONs")
100
+ ap.add_argument("--project", default="data-agent")
101
+ ap.add_argument("--trained", default="mini-swe-agent,opencode",
102
+ help="harnesses with a verified training run")
103
+ ap.add_argument("--measured-floor", type=float, default=0.9,
104
+ help="fraction of tasks that must produce a graded rollout to be stable")
105
+ ap.add_argument("--dry-run", action="store_true")
106
+ args = ap.parse_args()
107
+
108
+ merged = {"summary": {"harnesses": {}, "paused_harnesses": {}, "k": None}}
109
+ for f in args.sweep:
110
+ one = json.loads(Path(f).read_text())
111
+ sm = one.get("summary", {})
112
+ merged["summary"]["k"] = merged["summary"]["k"] or sm.get("k")
113
+ merged["summary"]["paused_harnesses"].update(sm.get("paused_harnesses") or {})
114
+ for h, m in (sm.get("harnesses") or {}).items():
115
+ prev = merged["summary"]["harnesses"].get(h)
116
+ # A harness can appear in more than one file — opencode ran in the cancelled job AND in the
117
+ # relaunch. Keep whichever measured more tasks: that is the more complete evidence, and
118
+ # averaging two partial runs of different sizes would invent a number neither produced.
119
+ if prev is None or (m.get("n_measured") or 0) > (prev.get("n_measured") or 0):
120
+ merged["summary"]["harnesses"][h] = m
121
+ sweep = merged
122
+ trained = {h.strip() for h in args.trained.split(",") if h.strip()}
123
+ support = classify(sweep, trained, args.measured_floor)
124
+
125
+ for h, e in sorted(support.items(), key=lambda kv: (kv[1]["tier"] != "stable", kv[0])):
126
+ print(f" {h:18s} {e['tier']:13s} {e.get('evidence','')}")
127
+ for c in e.get("caveats", []):
128
+ print(f" · {c}")
129
+
130
+ if args.dry_run:
131
+ return 0
132
+ p = HERE / "data" / "projects" / args.project / "project.json"
133
+ d = json.loads(p.read_text()) if p.exists() else {"project_id": args.project}
134
+ d["support"] = support
135
+ d["tier_rule"] = (
136
+ "stable = graded rollouts on >=90% of tasks AND a verified training run. experimental = "
137
+ "anything else, with the gap named. A low pass rate is never a downgrade: a harness scoring 0.0 "
138
+ "is reporting a real result, and penalising that would rank harnesses by task difficulty."
139
+ )
140
+ d["support_source"] = [Path(f).name for f in args.sweep]
141
+ p.write_text(json.dumps(d, indent=2))
142
+ print(f"\nwrote {p.relative_to(HERE)}")
143
+ return 0
144
+
145
+
146
+ if __name__ == "__main__":
147
+ raise SystemExit(main())
tools/ingest.py ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Turn what our runs actually produce into the viewer's contract.
2
+
3
+ The stack already writes two things we do not control the shape of: `eval_pass_at_k.py` dumps a JSON of
4
+ per-harness summaries plus raw rows, and AsyncGRPO logs training metrics to a trackio sqlite. Rather
5
+ than change either — a viewer should not dictate how a trainer logs — this converts them into
6
+ `runs/<run_id>/` as CONTRACT.md describes.
7
+
8
+ # an eval sweep
9
+ python tools/ingest.py eval --json logs/eval_6harness.json --run-id 2b-6harness --model Qwen/Qwen3.5-2B
10
+
11
+ # a training run, straight from the trackio db
12
+ python tools/ingest.py train --trackio ~/runs/agrpo_harbor/trackio/<project>.db \
13
+ --run-name Qwen3.5-2B-mini-swe-agent-20steps-46552 --run-id 2b-mini-20
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import sqlite3
21
+ from collections import defaultdict
22
+ from datetime import datetime, timezone
23
+ from pathlib import Path
24
+
25
+ HERE = Path(__file__).resolve().parents[1]
26
+
27
+
28
+ def _now() -> str:
29
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
30
+
31
+
32
+ def _write(run_dir: Path, rel: str, payload) -> None:
33
+ p = run_dir / rel
34
+ p.parent.mkdir(parents=True, exist_ok=True)
35
+ p.write_text(json.dumps(payload, indent=2, default=str) if not isinstance(payload, str) else payload)
36
+ print(f" wrote {p.relative_to(HERE)}")
37
+
38
+
39
+ def _task_metadata(split: str) -> dict[int, dict]:
40
+ """index -> {id, question, answer, difficulty_level} from the Harbor suite itself."""
41
+ import sys as _sys
42
+
43
+ _sys.path.insert(0, str(HERE.parents[0] / "async_grpo_harbor_data_agent" / "src"))
44
+ from harbor_tasks import _read_meta, download_suite # type: ignore
45
+
46
+ root = download_suite(split)
47
+ out = {}
48
+ for i, toml in enumerate(sorted(root.glob("tasks/*/task.toml"))):
49
+ m = dict(_read_meta(toml.parent).get("metadata") or {})
50
+ instr = toml.parent / "instruction.md"
51
+ # `meta` is whatever the suite records, carried verbatim. Promoting a fixed set of keys to
52
+ # columns means the viewer breaks the moment a suite adds or renames one — and difficulty,
53
+ # package_tier and reward_mode are exactly the kind of field that changes between suites. The
54
+ # viewer discovers the keys instead and offers them as filters.
55
+ out[i] = {
56
+ "id": toml.parent.name,
57
+ "answer": m.pop("gold_answer", None),
58
+ "question": (instr.read_text()[:4000] if instr.exists() else None),
59
+ "meta": m,
60
+ }
61
+ return out
62
+
63
+
64
+ def ds_dir_for(args):
65
+ return HERE / "data" / "projects" / args.project / "datasets" / args.dataset_id
66
+
67
+
68
+ def ingest_eval(args) -> None:
69
+ raw = json.loads(Path(args.json).read_text())
70
+ summary_in = raw.get("summary", {})
71
+ per_harness_in = summary_in.get("harnesses") or {}
72
+ rows_in = raw.get("rows") or {}
73
+
74
+ # Two shapes exist in the wild and both are real output from this stack: the multi-harness sweep
75
+ # writes {"rows": {harness: [...]}} while the earlier single-harness runs wrote
76
+ # {"per_task": {index: [...]}} with no harness on the rows. Normalising here rather than rejecting
77
+ # the older one keeps already-collected results usable — they are the only baseline we have.
78
+ if not rows_in and raw.get("per_task"):
79
+ harness = summary_in.get("harness") or args.harness_fallback
80
+ rows_in = {harness: [r for rows in raw["per_task"].values() for r in rows]}
81
+ if not per_harness_in:
82
+ per_harness_in = {harness: {k: v for k, v in summary_in.items() if k.startswith("pass@") or k == "mean_turns"}}
83
+
84
+ # pass@k over TASKS, pass@1 over SAMPLES, and `reward is None` excluded rather than scored 0 —
85
+ # the same rule the eval tool applies, restated here so the published numbers cannot drift from it.
86
+ by_harness, tasks_acc = [], defaultdict(dict)
87
+ task_meta: dict[int, dict] = {}
88
+ if args.tasks_from:
89
+ # Question, gold answer and difficulty come from the suite, not from the eval output. Without
90
+ # them a row is an opaque index and a cell cannot be judged by eye.
91
+ task_meta = _task_metadata(args.tasks_from)
92
+ totals = {"tasks_total": 0, "tasks_any_pass": 0, "attempts_total": 0, "attempts_passed": 0, "n_all_infra": 0}
93
+ k = summary_in.get("k", args.k)
94
+
95
+ for harness, rows in rows_in.items():
96
+ by_task = defaultdict(list)
97
+ for r in rows:
98
+ by_task[r["index"]].append(r)
99
+ measured = solved = infra = 0
100
+ samples, turns = [], []
101
+ for index, rs in by_task.items():
102
+ graded = [r for r in rs if r.get("reward") is not None]
103
+ key = f"{args.model}|{harness}"
104
+ attempts = []
105
+ for i, r in enumerate(sorted(rs, key=lambda r: r.get("rep", 0))):
106
+ att = {"attempt": i + 1, "reward": r.get("reward"), "n_turns": r.get("n_turns")}
107
+ # A trace is written only when the row carries one. Referencing a file that does not
108
+ # exist would give the viewer an "open" button that always 404s.
109
+ att["elapsed_sec"] = r.get("wall_s")
110
+ if r.get("messages"):
111
+ tid = f"{index}-{harness}-{i + 1}.json"
112
+ _write(ds_dir_for(args), f"traces/{tid}", {
113
+ "task_index": index, "task_id": r.get("task_id"),
114
+ "model": args.model, "harness": harness, "attempt": i + 1,
115
+ "reward": r.get("reward"), "rewards": r.get("rewards") or {},
116
+ "n_turns": r.get("n_turns"), "elapsed_sec": r.get("wall_s"),
117
+ "rollout_type": r.get("rollout_type"),
118
+ "n_trainable_tokens": r.get("n_trainable_tokens"),
119
+ "trial_name": r.get("trial_name"),
120
+ "messages": r["messages"],
121
+ })
122
+ att["trace"] = f"traces/{tid}"
123
+ attempts.append(att)
124
+ first_pass = next((a["attempt"] for a in attempts if (a["reward"] or 0) > 0), None)
125
+ tasks_acc[index][key] = {"passed_at": first_pass, "attempts": attempts}
126
+ if not graded:
127
+ infra += 1
128
+ continue
129
+ measured += 1
130
+ if any((r["reward"] or 0) > 0 for r in graded):
131
+ solved += 1
132
+ samples.extend(r["reward"] for r in graded)
133
+ turns.extend(r.get("n_turns") or 0 for r in graded)
134
+ m = per_harness_in.get(harness, {})
135
+ by_harness.append({
136
+ "harness": harness,
137
+ f"pass@{k}": m.get(f"pass@{k}", round(solved / measured, 4) if measured else None),
138
+ "pass@1": m.get("pass@1", round(sum(samples) / len(samples), 4) if samples else None),
139
+ "mean_turns": m.get("mean_turns", round(sum(turns) / len(turns), 2) if turns else None),
140
+ "n_measured": measured, "cells": len(by_task), "n_all_infra": infra,
141
+ })
142
+ totals["attempts_total"] += sum(len(v) for v in by_task.values())
143
+ totals["attempts_passed"] += sum(1 for s in samples if s > 0)
144
+ totals["n_all_infra"] += infra
145
+
146
+ totals["tasks_total"] = len(tasks_acc)
147
+ totals["tasks_any_pass"] = sum(
148
+ 1 for cells in tasks_acc.values() if any(c["passed_at"] for c in cells.values())
149
+ )
150
+
151
+ proj = HERE / "data" / "projects" / args.project
152
+ ds = proj / "datasets" / args.dataset_id
153
+
154
+ # by_model mirrors by_harness so the viewer's pivot has aggregates on both axes. With one model in a
155
+ # sweep it is a single row, which is honest rather than empty.
156
+ model_samples = [a["reward"] for cells in tasks_acc.values() for c in cells.values()
157
+ for a in c["attempts"] if a["reward"] is not None]
158
+ by_model = [{
159
+ "model": args.model,
160
+ "cells": totals["attempts_total"],
161
+ f"pass@{k}": round(totals["tasks_any_pass"] / totals["tasks_total"], 4) if totals["tasks_total"] else None,
162
+ "pass@1": round(sum(1 for r in model_samples if r > 0) / len(model_samples), 4) if model_samples else None,
163
+ "n_measured": totals["tasks_total"] - totals["n_all_infra"],
164
+ "mean_turns": None,
165
+ }]
166
+
167
+ _write(ds, "summary.json", {
168
+ "k_max": k,
169
+ "models": [args.model],
170
+ "harnesses": sorted(rows_in),
171
+ "summary": totals,
172
+ "by_harness": sorted(by_harness, key=lambda h: -(h.get(f"pass@{k}") or 0)),
173
+ "by_model": by_model,
174
+ "tasks": [
175
+ {"id": task_meta.get(i, {}).get("id", str(i)), "index": i,
176
+ "question": task_meta.get(i, {}).get("question"),
177
+ "answer": task_meta.get(i, {}).get("answer"),
178
+ "meta": task_meta.get(i, {}).get("meta", {}),
179
+ "cells": cells}
180
+ for i, cells in sorted(tasks_acc.items())
181
+ ],
182
+ })
183
+ _write(ds, "dataset.json", {
184
+ "dataset_id": args.dataset_id, "label": args.dataset_label or args.dataset_id,
185
+ "split": "eval", "source": args.dataset, "k": k,
186
+ "created_at": _now(), "notes": args.notes,
187
+ })
188
+ if not (proj / "project.json").exists():
189
+ _write(proj, "project.json", {
190
+ "project_id": args.project, "label": args.project_label or args.project,
191
+ "description": args.project_description,
192
+ "source": {"hf_dataset": args.dataset},
193
+ "support": {},
194
+ })
195
+
196
+
197
+ def ingest_train(args) -> None:
198
+ """Read trackio's sqlite directly: it is the source of truth for a finished run, and re-deriving
199
+ metrics from stdout would invent numbers the trainer never logged."""
200
+ con = sqlite3.connect(args.trackio)
201
+ rows = con.execute(
202
+ "select step, metrics from metrics where run_name like ? and length(metrics) > 4 order by step",
203
+ (f"%{args.run_name}%",),
204
+ ).fetchall()
205
+ if not rows:
206
+ raise SystemExit(f"no metric rows matching {args.run_name!r} in {args.trackio}")
207
+
208
+ lines = []
209
+ for step, blob in rows:
210
+ d = json.loads(blob if isinstance(blob, (str, bytes)) else str(blob))
211
+ lines.append(json.dumps({
212
+ "step": step,
213
+ "reward": d.get("train/reward"), "reward_std": d.get("train/reward_std"),
214
+ "loss": d.get("train/loss"), "ratio": d.get("train/ratio"),
215
+ "kl": d.get("train/kl"), "entropy": d.get("train/entropy"),
216
+ "learning_rate": d.get("train/learning_rate"),
217
+ }))
218
+
219
+ run_dir = HERE / "data" / "projects" / args.project / "runs" / args.run_id
220
+ _write(run_dir, "train/metrics.jsonl", "\n".join(lines) + "\n")
221
+ with_grad = sum(1 for line in lines if (json.loads(line).get("reward_std") or 0) > 0)
222
+ _write(run_dir, "run.json", {
223
+ "run_id": args.run_id, "kind": "train", "created_at": _now(), "updated_at": _now(),
224
+ "model": args.model, "harnesses": [args.harness] if args.harness else [],
225
+ "sandbox": args.sandbox, "dataset": args.dataset, "split": "train",
226
+ "notes": args.notes or f"{len(lines)} steps, {with_grad} with a non-zero gradient",
227
+ "config": {"trackio_run": args.run_name},
228
+ })
229
+ print(f" {len(lines)} steps, {with_grad} with reward_std > 0")
230
+
231
+
232
+ def main() -> int:
233
+ ap = argparse.ArgumentParser()
234
+ sub = ap.add_subparsers(dest="cmd", required=True)
235
+
236
+ e = sub.add_parser("eval")
237
+ e.add_argument("--json", required=True, help="output of tools/eval_pass_at_k.py")
238
+ e.add_argument("--model", default="Qwen/Qwen3.5-2B")
239
+ e.add_argument("--sandbox", default="e2b")
240
+ e.add_argument("--dataset", default="AdithyaSK/data_agent_rl_environment_eval")
241
+ e.add_argument("--k", type=int, default=4)
242
+ e.add_argument("--notes", default="")
243
+ e.add_argument("--project", default="data-agent")
244
+ e.add_argument("--project-label", default="Data-Agent Bench")
245
+ e.add_argument("--project-description", default="")
246
+ e.add_argument("--dataset-id", required=True, help="a variation within the project, e.g. eval-easy50")
247
+ e.add_argument("--dataset-label", default="")
248
+ e.add_argument("--tasks-from", default="", help="Harbor suite to pull question/answer/difficulty from")
249
+ e.add_argument("--harness-fallback", default="mini-swe-agent",
250
+ help="harness name for older single-harness JSON that does not record one")
251
+ e.set_defaults(fn=ingest_eval)
252
+
253
+ t = sub.add_parser("train")
254
+ t.add_argument("--trackio", required=True, help="path to the trackio sqlite db")
255
+ t.add_argument("--run-name", required=True, help="substring of the trackio run name")
256
+ t.add_argument("--run-id", required=True)
257
+ t.add_argument("--model", default="Qwen/Qwen3.5-2B")
258
+ t.add_argument("--harness", default="mini-swe-agent")
259
+ t.add_argument("--sandbox", default="e2b")
260
+ t.add_argument("--dataset", default="AdithyaSK/data_agent_rl_environment_train")
261
+ t.add_argument("--notes", default="")
262
+ t.add_argument("--project", default="data-agent")
263
+ t.set_defaults(fn=ingest_train)
264
+
265
+ args = ap.parse_args()
266
+ where = (f"projects/{args.project}/datasets/{args.dataset_id}" if args.cmd == "eval"
267
+ else f"projects/{args.project}/runs/{args.run_id}")
268
+ print(f"ingesting {args.cmd} -> data/{where}/")
269
+ args.fn(args)
270
+ return 0
271
+
272
+
273
+ if __name__ == "__main__":
274
+ raise SystemExit(main())
tools/watch_ingest.sh ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Re-ingest a live sweep into the viewer every INTERVAL seconds, so progress is visible while it runs
3
+ # rather than only at the end. The eval tool writes partial summaries atomically, so reading one mid-run
4
+ # is safe; a half-written file would have looked like a corrupt result instead of an in-progress one.
5
+ set -uo pipefail
6
+ VIZ="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
7
+ PY="${PY:-/fsx/adithyaskolavi/projects/trl_prod/.venv312/bin/python}"
8
+ SRC="${SRC:?set SRC=/path/to/sweep.json}"
9
+ DSID="${DSID:-phase0}"
10
+ INTERVAL="${INTERVAL:-120}"
11
+ JOB="${JOB:-}"
12
+ LOG="$VIZ/logs/watch_ingest.log"; mkdir -p "$VIZ/logs"
13
+
14
+ log(){ echo "$(date -Is) $*" >> "$LOG"; }
15
+ log "watching $SRC -> dataset $DSID every ${INTERVAL}s (job ${JOB:-none})"
16
+
17
+ while true; do
18
+ if [ -f "$SRC" ]; then
19
+ if "$PY" "$VIZ/tools/ingest.py" eval --json "$SRC" --dataset-id "$DSID" \
20
+ --dataset-label "phase 0 · 15 harnesses · 50 tasks · k=4" \
21
+ --project data-agent --tasks-from AdithyaSK/data_agent_rl_environment_eval \
22
+ --notes "live sweep" >>"$LOG" 2>&1; then
23
+ # The viewer caches listings, so tell it to drop them; otherwise a refresh in the browser shows
24
+ # the same numbers for up to REFRESH_TTL seconds and looks stuck.
25
+ curl -fs -m 10 -X POST http://127.0.0.1:8090/api/refresh >/dev/null 2>&1 || true
26
+ log "ingested $(stat -c%s "$SRC") bytes"
27
+ else
28
+ log "ingest failed (likely a partial write mid-move); retrying next tick"
29
+ fi
30
+ else
31
+ log "waiting for $SRC to appear"
32
+ fi
33
+ # Stop once the job is gone AND one final ingest has run, so the last state is always captured.
34
+ if [ -n "$JOB" ] && ! squeue -h -j "$JOB" >/dev/null 2>&1; then
35
+ log "job $JOB finished; final ingest done, exiting"
36
+ exit 0
37
+ fi
38
+ sleep "$INTERVAL"
39
+ done