#!/usr/bin/env python3 """ Gradio control/monitoring UI for the TinyChess research Space. This is NOT the training implementation. Everything runs from the CLI; this UI only launches, monitors and inspects background jobs, so a browser disconnect never kills an experiment. """ from __future__ import annotations import json import os import signal import subprocess import sys import time from pathlib import Path # --- CPU-ONLY SPACE --------------------------------------------------------- # This Space previously imported `spaces` and exposed an @spaces.GPU function, # which made HF classify it as a ZeroGPU Space (requested_hardware=zero-a10g). # That hardware now requires a PRO subscription, so the Space wedged in # RUNTIME_ERROR ("403: Subscribe to PRO to use ZeroGPU") and no jobs could run. # All real research is CPU-only by design (the GPU had a ~5 min cap and was # never used for training), so the dependency is removed entirely. class _NoGPU: @staticmethod def GPU(*a, **k): def deco(fn): return fn return deco(a[0]) if a and callable(a[0]) else deco spaces = _NoGPU() _HAS_SPACES = False import gradio as gr ROOT = Path(__file__).parent.resolve() RUNS = ROOT / "runs" RUNS.mkdir(exist_ok=True) JOBS = RUNS / "jobs.json" PY = sys.executable # ----------------------------------------------------------------- job state def load_jobs() -> dict: if JOBS.exists(): try: return json.loads(JOBS.read_text()) except Exception: return {} return {} def save_jobs(j): JOBS.write_text(json.dumps(j, indent=2)) def job_alive(pid) -> bool: try: os.kill(int(pid), 0) return True except Exception: return False def _pid_is_ours(pid: int, cmd: list[str]) -> bool: """True only if `pid` is alive AND its cmdline matches the recorded job. After a Space rebuild the PID space restarts, so a recorded pid can be re-used by an unrelated process. Checking liveness alone made launch() refuse valid jobs and made job_table() report dead jobs as RUNNING. """ if not job_alive(pid): return False try: with open(f"/proc/{pid}/cmdline", "rb") as fh: actual = fh.read().decode(errors="ignore").split("\x00") except Exception: return False want = os.path.basename(cmd[1]) if len(cmd) > 1 else "" return any(want and want in a for a in actual) def launch(name: str, cmd: list[str]) -> str: jobs = load_jobs() prev = jobs.get(name) if prev and _pid_is_ours(prev.get("pid", -1), prev.get("cmd", [])): return f"Job '{name}' is already running (pid {prev['pid']})." logp = RUNS / f"{name}.log" with open(logp, "a") as lf: lf.write(f"\n=== launch {time.strftime('%F %T')} ===\n{' '.join(cmd)}\n") p = subprocess.Popen(cmd, stdout=lf, stderr=subprocess.STDOUT, cwd=ROOT, start_new_session=True) jobs[name] = {"pid": p.pid, "cmd": cmd, "started": time.time(), "log": str(logp)} save_jobs(jobs) return f"Launched '{name}' (pid {p.pid})\n$ {' '.join(cmd)}" def stop_job(name: str) -> str: jobs = load_jobs() if name not in jobs: return f"No job '{name}'." pid = jobs[name]["pid"] try: os.killpg(os.getpgid(pid), signal.SIGTERM) except Exception as e: return f"Could not stop {name}: {e}" return f"Sent SIGTERM to '{name}' (pid {pid})." def job_table() -> str: jobs = load_jobs() if not jobs: return "No jobs yet." rows = ["| job | pid | state | runtime |", "|---|---|---|---|"] for n, j in sorted(jobs.items()): alive = _pid_is_ours(j.get("pid", -1), j.get("cmd", [])) rt = time.time() - j["started"] rows.append(f"| {n} | {j['pid']} | {'RUNNING' if alive else 'stopped'} " f"| {rt/60:.1f} min |") return "\n".join(rows) def tail_log(name: str, n: int = 60) -> str: p = RUNS / f"{name}.log" if not p.exists(): return f"(no log for '{name}')" lines = p.read_text(errors="replace").splitlines() return "\n".join(lines[-n:]) # ----------------------------------------------------------------- metrics def list_runs(): return sorted([d.name for d in RUNS.iterdir() if d.is_dir()]) def read_metrics(run: str): p = RUNS / run / "metrics.jsonl" if not p.exists(): return [], "(no metrics.jsonl)" rows = [] for line in p.read_text(errors="replace").splitlines(): try: rows.append(json.loads(line)) except Exception: pass iters = [r for r in rows if r.get("event") == "iter"] return iters, f"{len(iters)} iterations logged" def metrics_plot(run: str): import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt iters, msg = read_metrics(run) if not iters: return None, msg fig, ax = plt.subplots(2, 2, figsize=(10, 6)) it = [r["iter"] for r in iters] ax[0, 0].plot(it, [r.get("sp_decisive_rate", 0) for r in iters]); ax[0, 0].set_title("decisive rate") ax[0, 1].plot(it, [r.get("sp_mean_entropy", 0) for r in iters]); ax[0, 1].set_title("policy entropy") ax[1, 0].plot(it, [r.get("tr_value_mae", 0) for r in iters]); ax[1, 0].set_title("value MAE") ax[1, 1].plot(it, [r.get("sp_mean_plies", 0) for r in iters]); ax[1, 1].set_title("mean plies") for a in ax.ravel(): a.grid(alpha=.3); a.set_xlabel("iteration") fig.tight_layout() return fig, msg def summary_table(run: str) -> str: iters, msg = read_metrics(run) if not iters: return msg keys = ["iter", "opponent", "sp_decisive_rate", "sp_mean_plies", "sp_mean_entropy", "tr_loss", "tr_value_mae", "t_selfplay", "t_learn"] rows = ["| " + " | ".join(k.replace("sp_", "").replace("tr_", "") for k in keys) + " |", "|" + "---|" * len(keys)] for r in iters[-25:]: rows.append("| " + " | ".join( (f"{r.get(k):.3f}" if isinstance(r.get(k), float) else str(r.get(k, ""))) for k in keys) + " |") return "\n".join(rows) # ----------------------------------------------------------------- traces def list_checkpoints(): out = [] for d in RUNS.iterdir(): if d.is_dir(): out += [str(p.relative_to(ROOT)) for p in sorted(d.glob("*.pt"))] return out def run_trace(ckpt: str, fen: str, steps: int, adaptive: bool): if not ckpt: return "Select a checkpoint.", "{}" cmd = [PY, "inspect_trace.py", "--checkpoint", ckpt, "--fen", fen or "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", "--steps", str(steps), "--format", "both"] if adaptive: cmd.append("--adaptive") r = subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True, timeout=300) return (r.stdout or "") + (("\n[stderr]\n" + r.stderr) if r.stderr else ""), "" # ----------------------------------------------------------------- GPU smoke test def gpu_smoke_test(): """Tiny GPU sanity check. NOT used for training (5-min cap).""" import torch from tinychess.config import baseline_config from tinychess.model import TinyChess lines = [f"torch {torch.__version__}", f"cuda available: {torch.cuda.is_available()}"] if torch.cuda.is_available(): dev = torch.device("cuda") lines.append(f"device: {torch.cuda.get_device_name(0)}") m = TinyChess(baseline_config()).to(dev) rep = m.param_report() lines.append(f"model params: {rep['total']:,}") sq = torch.randint(0, 13, (8, 64), device=dev) ex = torch.zeros(8, 64, 4, device=dev) gl = torch.zeros(8, 20, device=dev) cf = torch.zeros(8, 20, 7, dtype=torch.long, device=dev) cm = torch.ones(8, 20, dtype=torch.bool, device=dev) import time as _t t = _t.time() with torch.no_grad(): out = m(sq, ex, gl, cf, cm, steps=8) lines.append(f"forward(8 steps, B=8): {(_t.time()-t)*1000:.1f} ms") lines.append(f"logits {tuple(out['logits'].shape)} value {tuple(out['value'].shape)}") lines.append("GPU OK - but training stays on CPU by design.") else: lines.append("no CUDA visible") return "\n".join(lines) ALLOWED_CMDS = {"train_selfplay.py", "inspect_trace.py", "tests/test_phase1.py", "scripts/bench_threads.py", "experiments/exp_ablation.py", "experiments/exp_learning.py", "experiments/exp_compute_scaling.py", "experiments/exp_faithfulness.py", "experiments/exp_precision.py", "experiments/analyse_grid.py", "experiments/exp_step_embedding.py", "experiments/exp_halting_routing.py", "experiments/exp_mate_capability.py", "experiments/exp_check_feature.py", "experiments/exp_mate_readout.py", "experiments/exp_check_intervention.py", "experiments/exp_endgame_conversion.py", "scripts/bench_parallel.py", "scripts/bench_learn.py", "experiments/exp_greedy_health.py"} def run_job(name: str, script: str, argstr: str): """Launch a whitelisted research script as a detached background job.""" script = script.strip() if script not in ALLOWED_CMDS: return f"script '{script}' not in the allowed list: {sorted(ALLOWED_CMDS)}" import shlex args = shlex.split(argstr or "") # The Space container is EPHEMERAL: a rebuild deletes runs/. Force periodic # pushes to the HF model repo so no experiment is ever lost. if script == "train_selfplay.py" and "--push-every" not in args: args += ["--push-every", "5"] cmd = [PY, script] + args return launch(name.strip() or "job", cmd) def read_file_tail(relpath: str, n: int = 200): p = (ROOT / relpath.strip()).resolve() if not str(p).startswith(str(ROOT)) or not p.exists(): return f"(no such file: {relpath})" if p.suffix in (".pt", ".pkl"): return f"{relpath}: binary, {p.stat().st_size/1e6:.2f} MB" lines = p.read_text(errors="replace").splitlines() return "\n".join(lines[-int(n):]) def list_files(pattern: str = "runs/**/*"): import glob out = [] for f in sorted(glob.glob(str(ROOT / pattern), recursive=True))[:400]: p = Path(f) if p.is_file(): out.append(f"{p.relative_to(ROOT)} {p.stat().st_size/1024:.1f} KB") return "\n".join(out) or "(none)" def env_audit(): """Report the ACTUAL hardware of this Space (the research machine).""" import multiprocessing, platform, subprocess, torch L = [] L.append(f"python {platform.python_version()} torch {torch.__version__}") L.append(f"os.cpu_count(): {os.cpu_count()}") try: L.append(f"sched_getaffinity: {len(os.sched_getaffinity(0))}") except Exception: pass L.append(f"torch threads: {torch.get_num_threads()}") try: out = subprocess.run(["lscpu"], capture_output=True, text=True, timeout=20).stdout for k in ("Model name", "CPU(s):", "Thread(s) per core", "Core(s) per socket", "Socket(s)", "L3 cache"): for line in out.splitlines(): if line.strip().startswith(k): L.append(line.strip()); break except Exception as e: L.append(f"lscpu failed: {e}") try: mt = open("/proc/meminfo").readline().split() L.append(f"MemTotal: {int(mt[1])/1024/1024:.1f} GB") except Exception: pass try: import shutil du = shutil.disk_usage("/data" if os.path.isdir("/data") else ".") L.append(f"disk free: {du.free/1e9:.1f} GB (persistent /data: {os.path.isdir('/data')})") except Exception: pass L.append(f"cuda available: {torch.cuda.is_available()}") L.append(f"BLAS: {torch.__config__.show().split('BLAS_INFO=')[-1].split(',')[0] if 'BLAS_INFO' in torch.__config__.show() else '?'}") L.append(f"cpu capability: {torch.backends.cpu.get_cpu_capability()}") return "\n".join(L) # ----------------------------------------------------------------- UI with gr.Blocks(title="TinyChess Research Console") as demo: gr.Markdown("# TinyChess — ~100K-parameter self-play research console\n" "Training runs as detached CLI jobs; this UI only controls and inspects them.") with gr.Tab("Train"): with gr.Row(): cfg_name = gr.Dropdown(["baseline", "memory", "refine", "halting", "router", "full", "mlp", "transformer"], value="baseline", label="config") job_name = gr.Textbox("sp_baseline", label="job / run name") with gr.Row(): iters = gr.Number(200, label="iterations", precision=0) games = gr.Number(64, label="games / iter", precision=0) steps = gr.Number(4, label="recurrent steps", precision=0) threads = gr.Number(4, label="torch threads", precision=0) with gr.Row(): updates = gr.Number(24, label="grad steps / iter", precision=0) batch = gr.Number(256, label="batch", precision=0) lr = gr.Number(3e-4, label="lr") seed = gr.Number(0, label="seed", precision=0) with gr.Row(): b_start = gr.Button("Start", variant="primary") b_resume = gr.Button("Resume") b_stop = gr.Button("Stop", variant="stop") train_out = gr.Textbox(label="result", lines=4) def _start(cfg, name, it, g, st, th, up, ba, lr_, sd, resume): out = f"runs/{name}" cmd = [PY, "train_selfplay.py", "--config", cfg, "--out", out, "--iters", str(int(it)), "--games", str(int(g)), "--steps", str(int(st)), "--threads", str(int(th)), "--updates", str(int(up)), "--batch", str(int(ba)), "--lr", str(lr_), "--seed", str(int(sd))] if resume: cmd += ["--resume", f"{out}/latest.pt"] return launch(name, cmd) b_start.click(lambda *a: _start(*a, False), [cfg_name, job_name, iters, games, steps, threads, updates, batch, lr, seed], train_out) b_resume.click(lambda *a: _start(*a, True), [cfg_name, job_name, iters, games, steps, threads, updates, batch, lr, seed], train_out) b_stop.click(stop_job, job_name, train_out) with gr.Tab("Jobs"): jt = gr.Markdown() logname = gr.Textbox("sp_baseline", label="job") logbox = gr.Textbox(label="log tail", lines=25, max_lines=25) with gr.Row(): gr.Button("Refresh jobs").click(job_table, None, jt) gr.Button("Refresh log").click(lambda n: tail_log(n, 80), logname, logbox) demo.load(job_table, None, jt) with gr.Tab("Metrics"): run_dd = gr.Dropdown(list_runs(), label="run") with gr.Row(): gr.Button("Reload runs").click(lambda: gr.update(choices=list_runs()), None, run_dd) gr.Button("Plot").click(metrics_plot, run_dd, [gr.Plot(), gr.Markdown()]) tbl = gr.Markdown() gr.Button("Table").click(summary_table, run_dd, tbl) with gr.Tab("Inspect trace"): ck = gr.Dropdown(list_checkpoints(), label="checkpoint") fen_in = gr.Textbox("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", label="FEN") with gr.Row(): tsteps = gr.Number(8, label="steps", precision=0) tad = gr.Checkbox(False, label="adaptive halting") gr.Button("Reload checkpoints").click( lambda: gr.update(choices=list_checkpoints()), None, ck) trace_out = gr.Textbox(label="trace", lines=30) gr.Button("Run trace", variant="primary").click( run_trace, [ck, fen_in, tsteps, tad], [trace_out, gr.Textbox(visible=False)]) with gr.Tab("Run job"): gr.Markdown("Launch a research script as a detached background job on " "the Space CPUs. Jobs survive browser disconnects.") rj_name = gr.Textbox("job1", label="job name") rj_script = gr.Textbox("train_selfplay.py", label="script") rj_args = gr.Textbox("--config baseline --iters 10 --games 32 --threads 8 --out runs/test", label="arguments") rj_out = gr.Textbox(label="result", lines=4) gr.Button("Launch", variant="primary").click(run_job, [rj_name, rj_script, rj_args], rj_out) gr.Markdown("### Inspect files") f_pat = gr.Textbox("runs/**/*", label="glob") f_list = gr.Textbox(label="files", lines=12) gr.Button("List files").click(list_files, f_pat, f_list) f_path = gr.Textbox("runs/test/metrics.jsonl", label="file") f_n = gr.Number(100, label="tail lines", precision=0) f_body = gr.Textbox(label="contents", lines=20) gr.Button("Read file").click(read_file_tail, [f_path, f_n], f_body) with gr.Tab("Environment"): gr.Markdown("Actual hardware of this Space - the real research machine.") env_out = gr.Textbox(label="environment", lines=18) gr.Button("Audit environment", variant="primary").click(env_audit, None, env_out) with gr.Tab("GPU check"): gr.Markdown("ZeroGPU smoke test only. Training runs on CPU as detached " "jobs because the GPU allocation is capped at ~5 minutes.") gpu_out = gr.Textbox(label="gpu", lines=10) gr.Button("Run GPU smoke test").click(gpu_smoke_test, None, gpu_out) with gr.Tab("Evaluate"): ck2 = gr.Dropdown(list_checkpoints(), label="checkpoint") init2 = gr.Textbox("", label="random-init checkpoint (optional)") egames = gr.Number(60, label="games per match", precision=0) ejob = gr.Textbox("eval1", label="job name") eout = gr.Textbox(label="result", lines=4) gr.Button("Reload").click(lambda: gr.update(choices=list_checkpoints()), None, ck2) gr.Button("Run evaluation", variant="primary").click( lambda c, i, g, n: launch(n, [PY, "experiments/exp_learning.py", "--checkpoint", c, "--games", str(int(g))] + (["--init", i] if i else [])), [ck2, init2, egames, ejob], eout) if __name__ == "__main__": demo.queue().launch(server_name="0.0.0.0", server_port=int(os.environ.get("PORT", 7860)), ssr_mode=False, show_api=False)