File size: 14,727 Bytes
7103d1c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
"""Live progress of the teacher-labelling runs (plan 4.1/4.2), served on localhost for the app's browser pane.

    .venv/bin/python -m analysis.progress_server --port 8765     # then open http://localhost:8765

Also any other run log written in the last 24 hours (runs/<name>.out: state and last lines, e.g. a Message Batch's
progress), and the corpus-profiles run (plan 4.7): finished Parquet parts per genre against the parts the indexable corpus needs,
the rate from the run log, and an ETA from the parts' timestamps.

Counts are exact, read from the teacher's response cache (.cache/negspace/teacher/*.json: route, prompt, output):
each answer is matched to its section by the sha1 of the section text inside the prompt and to the fixed label sets
(packs/<genre>/label_sets.json), and to its pass by the prompt (first pass: label_presence; second look:
label_presence_check). Every new cache file is read once. Run state comes from the process list and the run logs
(runs/<name>.out). Read-only: it never writes to the cache, the runs or the data. Standard library only.
"""

from __future__ import annotations

import argparse
import hashlib
import json
import os
import re
import subprocess
import threading
import time
from collections import defaultdict, deque
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any

REPO = Path(__file__).resolve().parent.parent
CACHE = REPO / ".cache" / "negspace" / "teacher"

# The runs to watch: which model route they use, which label sets they label, their log and process name.
RUNS = [
    {"key": "teacher", "title": "Teacher: Qwen2.5-72B", "route": "qwen/qwen-2.5-72b-instruct",
     "sets": ["teacher", "human"], "log": "runs/label_72b.out", "process": "name label_72b --set"},
    {"key": "judge", "title": "Second judge: Claude Haiku 5.5", "route": "anthropic/claude-haiku-5.5",
     "sets": ["human"], "log": "runs/judge_haiku.out", "process": "name judge_haiku --set"},
    {"key": "judge_docs", "title": "Judge on whole documents: Claude Haiku 5.5 (Anthropic API, batches)",
     "route": "anthropic:claude-haiku-5-5", "sets": ["documents"], "log": "runs/judge_docs_haiku.out",
     "process": "name judge_docs_haiku --set"},
]
SET_NAMES = {"teacher": "teacher set", "human": "reference set", "documents": "whole documents"}
_TEXT = re.compile(r'Section text:\n"""\n(.*?)\n"""\n\n', re.S)


def classify(entry: dict[str, Any]) -> tuple[str, str, str] | None:
    """(route, pass, section sha1) of one cached answer, or None when it is not a labelling call."""
    prompt = (entry.get("messages") or [{}])[-1].get("content", "")
    if '{"statements"' in prompt:
        kind = "first"
    elif '{"present"' in prompt:
        kind = "check"
    else:
        return None
    m = _TEXT.search(prompt)
    if not m:
        return None
    return entry.get("route", ""), kind, hashlib.sha1(m.group(1).encode("utf-8")).hexdigest()


def load_sets(repo: Path = REPO) -> dict[str, tuple[str, str]]:
    """section text sha1 -> (genre, set name), for every fixed label set."""
    out = {}
    for path in sorted((repo / "packs").glob("*/label_sets.json")):
        d = json.loads(path.read_text(encoding="utf-8"))
        for name in SET_NAMES:
            for r in d.get(name, []):
                out[r["text_sha1"]] = (d["genre"], name)
    return out


class Tracker:
    """Incremental index of the cache: each new file is read once; done[(route, kind)] = set of section sha1."""

    def __init__(self, cache: Path, sets: dict[str, tuple[str, str]]):
        self.cache, self.sets = cache, sets
        self.seen: set[str] = set()
        self.done: dict[tuple[str, str], set[str]] = defaultdict(set)
        self.events: dict[str, deque] = defaultdict(lambda: deque(maxlen=20000))  # route -> answer times
        self.last: dict[str, float] = {}
        self.lock = threading.Lock()

    def scan(self) -> None:
        if not self.cache.is_dir():
            return
        new = []
        with os.scandir(self.cache) as it:
            for e in it:
                if e.name not in self.seen and e.name.endswith(".json"):
                    new.append(e)
        for e in new:
            try:
                entry = json.loads(Path(e.path).read_text(encoding="utf-8"))
                mtime = e.stat().st_mtime
            except (OSError, ValueError):
                continue  # being written: next scan
            with self.lock:
                self.seen.add(e.name)
                c = classify(entry)
                if c is None:
                    continue
                route, kind, sha = c
                self.done[(route, kind)].add(sha)
                self.events[route].append(mtime)
                self.last[route] = max(self.last.get(route, 0.0), mtime)

    def counts(self, route_part: str, genre: str, name: str, kind: str) -> int:
        with self.lock:
            return sum(1 for (route, k), shas in self.done.items() if route_part in route and k == kind
                       for s in shas if self.sets.get(s) == (genre, name))


def _running(pattern: str) -> bool:
    try:
        return subprocess.run(["pgrep", "-f", pattern], capture_output=True).returncode == 0
    except OSError:
        return False


def _log_tail(path: Path) -> tuple[str, list[str]]:
    if not path.is_file():
        return "not started", []
    text = path.read_text(encoding="utf-8", errors="replace").replace("\r", "\n")
    lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
    keep = [ln for ln in lines if re.search(r"teacher_labels|\[teacher\] (batch|submitted|resuming)|\[ok\]|\[failed\]|Error|Traceback|WARNING", ln)]
    state = "finished" if any(ln.startswith("[ok]") for ln in lines) else (
        "failed" if any(ln.startswith("[failed]") or "Traceback" in ln for ln in lines) else "")
    return state, keep[-6:]


def recent_runs(repo: Path, now: float, *, hours: float = 24, skip: set[str]) -> list[dict[str, Any]]:
    """Every run log runs/<name>.out written in the last `hours` that no dedicated card shows: state (running from the
    process list, else finished / failed from the log), the last lines, the age of the last write. Any long run
    started with `> runs/<name>.out` shows up here without code changes."""
    out = []
    for path in sorted((repo / "runs").glob("*.out"), key=lambda p: -p.stat().st_mtime):
        age = now - path.stat().st_mtime
        if age > hours * 3600 or path.stem in skip:
            continue
        text = path.read_text(encoding="utf-8", errors="replace").replace("\r", "\n")
        lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
        alive = _running(f"name {path.stem}")
        state = "running" if alive else ("finished" if any(ln.startswith("[ok]") for ln in lines) else (
            "failed" if any(ln.startswith("[failed]") or "Traceback" in ln for ln in lines) else "stopped"))
        out.append({"name": path.stem, "state": state, "alive": alive, "last_write_s": round(age), "log": lines[-8:]})
    return out


def status(tracker: Tracker, totals: dict[tuple[str, str], int]) -> dict[str, Any]:
    tracker.scan()
    now = time.time()
    runs = []
    for run in RUNS:
        alive = _running(run["process"])
        log_state, log_lines = _log_tail(REPO / run["log"])
        with tracker.lock:
            times = [t for r, q in tracker.events.items() if run["route"] in r for t in q]
            last = max([v for r, v in tracker.last.items() if run["route"] in r], default=None)
        recent = [t for t in times if t >= now - 600]
        per_min = len([t for t in recent if t >= now - 120]) / 2
        buckets = [0] * 60  # answers per 10 s over the last 10 minutes
        for t in recent:
            buckets[min(59, int((now - t) // 10))] += 1
        groups, remaining = [], 0
        for genre in ("privacy", "papers"):
            for name in run["sets"]:
                n = totals.get((genre, name), 0)
                first = tracker.counts(run["route"], genre, name, "first")
                check = tracker.counts(run["route"], genre, name, "check")
                remaining += (n - first) + max(0, first - check) + (n - first)  # checks follow first passes
                groups.append({"genre": genre, "set": SET_NAMES[name], "total": n, "first": first, "check": check})
        state = "running" if alive else (log_state or "stopped")
        runs.append({**{k: run[k] for k in ("key", "title", "route")}, "state": state, "alive": alive,
                     "per_min": per_min, "eta_min": round(remaining / per_min) if per_min and alive else None,
                     "last_answer_s": round(now - last) if last else None, "spark": buckets[::-1],
                     "groups": groups, "log": log_lines})
    skip = {Path(r["log"]).stem for r in RUNS} | {Path(PROFILES["log"]).stem}
    skip |= {p.stem for p in (REPO / "runs").glob("hub_push_*.out")}
    return {"now": now, "runs": runs, "profiles": profiles_status(now), "upload": upload_status(now),
            "recent": recent_runs(REPO, now, skip=skip)}


PROFILES = {"title": "Corpus profiles (4.7)", "log": "runs/profiles_v1.out", "process": "name profiles_v1",
            "chunk": 500, "splits": ("train",)}


def profiles_status(now: float) -> dict[str, Any]:
    """Parts written per genre vs parts needed, rate and ETA, for the profiles run."""
    alive = _running(PROFILES["process"])
    log_path = REPO / PROFILES["log"]
    text = log_path.read_text(encoding="utf-8", errors="replace") if log_path.is_file() else ""
    rates = {g: float(m.group(2)) for g, m in
             ((m.group(1), m) for m in re.finditer(r"\[profiles (\w+)\] part \d+/\d+: .*?\(([\d.]+) sections/s\)", text))}
    state = "finished" if "\n[ok]" in text or text.startswith("[ok]") else (
        "failed" if "[failed]" in text or "Traceback" in text else "")
    genres, last_part, times = [], None, []
    for genre in ("privacy", "papers"):
        manifest = REPO / "data" / "processed" / "splits" / f"{genre}.json"
        docs = 0
        if manifest.is_file():
            ids = json.loads(manifest.read_text(encoding="utf-8")).get("doc_ids", {})
            docs = sum(len(ids.get(s, [])) for s in PROFILES["splits"])
        total = -(-docs // PROFILES["chunk"])
        parts = sorted((REPO / "packs" / genre / "profiles").glob("part-*.parquet"))
        mt = sorted(p.stat().st_mtime for p in parts)
        times += mt
        if mt:
            last_part = max(last_part or 0, mt[-1])
        recent = [t for t in mt if t >= now - 900]
        per_part = (recent[-1] - recent[0]) / (len(recent) - 1) if len(recent) >= 2 else None
        genres.append({"genre": genre, "parts": len(parts), "total": total, "documents": docs,
                       "rate": rates.get(genre), "per_part_s": per_part})
    eta = None
    if alive:
        pending = [g for g in genres if g["parts"] < g["total"]]
        if pending:
            cur = pending[0]
            known = cur["per_part_s"] or next((g["per_part_s"] for g in genres if g["per_part_s"]), None)
            if known:  # later genres: scale by the ratio of their rates when only one rate is known
                eta = round(sum((g["total"] - g["parts"]) * (cur["per_part_s"] or known) for g in pending) / 60)
    lines = [ln.strip() for ln in text.replace("\r", "\n").splitlines()
             if re.search(r"\[profiles|\[ok\]|\[failed\]|Error|Traceback", ln)]
    return {"title": PROFILES["title"], "state": "running" if alive else (state or "stopped"), "alive": alive,
            "eta_min": eta, "last_part_s": round(now - last_part) if last_part else None, "genres": genres,
            "log": lines[-5:]}


def upload_status(now: float) -> dict[str, Any] | None:
    """The latest hub_sync push (runs/hub_push_*.out): files committed, the current chunk's bytes and rate, retries."""
    logs = sorted((REPO / "runs").glob("hub_push_*.out"), key=lambda p: p.stat().st_mtime)
    if not logs:
        return None
    log = logs[-1]
    text = log.read_text(encoding="utf-8", errors="replace").replace("\r", "\n")
    committed = re.findall(r"\[hub_sync\] committed (\d+)/(\d+) files", text)
    plan = re.findall(r"\[hub_sync\] plan: (\d+) files, ([\d.]+) GB", text)
    chunk = re.findall(r"Processing Files \((\d+) / (\d+)\)\s*:\s*(\d+)%\|[^|]*\|\s*([\d.]+\s*\w?B) /\s*([\d.]+\s*\w?B),\s*([\d.]+\s*\w?B/s)", text)
    retries = re.findall(r"\[hub_sync\] commit of .*", text)
    done = re.search(r"\{'uploaded': (\d+)", text)
    failed = "Traceback" in text
    alive = _running("experiments.hub_sync push")
    c = committed[-1] if committed else None
    k = chunk[-1] if chunk else None
    return {"title": "Upload to the private dataset repo", "log": log.name,
            "state": "running" if alive else ("finished" if done else ("failed" if failed else "stopped")),
            "files_done": int(c[0]) if c else 0, "files_total": int(c[1]) if c else (int(plan[-1][0]) if plan else None),
            "planned_gb": float(plan[-1][1]) if plan else None,
            "chunk": {"files": f"{k[0]}/{k[1]}", "pct": int(k[2]), "done": k[3], "total": k[4], "rate": k[5]} if k else None,
            "retries": retries[-3:], "last_write_s": round(now - log.stat().st_mtime)}


PAGE = (Path(__file__).resolve().parent / "progress_page.html")


class Handler(BaseHTTPRequestHandler):
    tracker: Tracker
    totals: dict[tuple[str, str], int]

    def do_GET(self) -> None:
        if self.path.startswith("/status.json"):
            body = json.dumps(status(self.tracker, self.totals)).encode("utf-8")
            ctype = "application/json"
        elif self.path in ("/", "/index.html"):
            body, ctype = PAGE.read_bytes(), "text/html; charset=utf-8"
        else:
            self.send_error(404)
            return
        self.send_response(200)
        self.send_header("Content-Type", ctype)
        self.send_header("Cache-Control", "no-store")
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, *args: Any) -> None:
        pass


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--port", type=int, default=8765)
    args = ap.parse_args()
    sets = load_sets()
    totals: dict[tuple[str, str], int] = defaultdict(int)
    for g_n in sets.values():
        totals[g_n] += 1
    Handler.tracker, Handler.totals = Tracker(CACHE, sets), dict(totals)
    t0 = time.time()
    Handler.tracker.scan()
    print(f"indexed {len(Handler.tracker.seen)} cache files in {time.time() - t0:.1f}s; http://localhost:{args.port}",
          flush=True)
    ThreadingHTTPServer(("127.0.0.1", args.port), Handler).serve_forever()


if __name__ == "__main__":
    main()