Harbor run viewer: Phase 0 eval, 15 harnesses x 50 tasks, pass@4
Browse files- .gitignore +2 -0
- CONTRACT.md +135 -0
- Dockerfile +19 -0
- README.md +63 -9
- app.py +278 -0
- requirements.txt +5 -0
- site/viewer.html +607 -0
- tools/classify.py +147 -0
- tools/ingest.py +274 -0
- 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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk:
|
| 7 |
-
|
| 8 |
-
python_version: '3.13'
|
| 9 |
-
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=>({"<":"<",">":">","&":"&",'"':"""}[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
|