CI / ledger-and-tools (push) Successful in 17s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
284 lines
11 KiB
Python
Executable File
284 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Run ledger: one directory per training run under ``runs/``, tracked in git.
|
|
|
|
Every run is ``runs/<id>/run.json`` with a status that moves through fixed states;
|
|
each change appends to ``history`` and is meant to be committed, so
|
|
``git log runs/<id>/run.json`` is the timeline of that run. ``metrics.json`` next
|
|
to it holds the gold measure and the gate verdict, ``train.log`` the trainer's
|
|
output.
|
|
|
|
python tools/ledger.py new --dataset dataset/2026-10-02_alc --tag alc --base yolo26n-seg.pt
|
|
python tools/ledger.py set 2026-10-02_alc training --kv host=mx-spark-01 job=12345
|
|
python tools/ledger.py metrics 2026-10-02_alc path/to/metrics.json
|
|
python tools/ledger.py show
|
|
python tools/ledger.py render # runs/README.md
|
|
python tools/ledger.py check # CI: every run.json valid
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import getpass
|
|
import json
|
|
import shutil
|
|
import socket
|
|
import sys
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
STATES = [
|
|
"exported", # dataset tag exists, run directory created
|
|
"queued", # job submitted (Spark or cluster)
|
|
"training", # epochs running, train.log grows
|
|
"measured", # gold measure done, metrics.json written
|
|
"gate_passed", # measure beats the deployed model's baseline
|
|
"gate_failed",
|
|
"promoted", # models/active/production.json points at this run's weights
|
|
"deployed", # AareLC_infer tagged, cps-deployments bumped
|
|
"aborted",
|
|
]
|
|
REQUIRED = ("id", "dataset", "base_weights", "status", "created", "history")
|
|
|
|
|
|
def _now() -> str:
|
|
return datetime.now().isoformat(timespec="seconds")
|
|
|
|
|
|
def _who() -> str:
|
|
try:
|
|
return f"{getpass.getuser()}@{socket.gethostname()}"
|
|
except Exception:
|
|
return "unknown"
|
|
|
|
|
|
def runs_dir(root: Path = ROOT) -> Path:
|
|
return root / "runs"
|
|
|
|
|
|
def run_path(run_id: str, root: Path = ROOT) -> Path:
|
|
return runs_dir(root) / run_id / "run.json"
|
|
|
|
|
|
def load(run_id: str, root: Path = ROOT) -> Dict[str, Any]:
|
|
p = run_path(run_id, root)
|
|
if not p.exists():
|
|
raise SystemExit(f"no such run: {run_id} ({p})")
|
|
return json.loads(p.read_text(encoding="utf-8"))
|
|
|
|
|
|
def save(run: Dict[str, Any], root: Path = ROOT) -> Path:
|
|
p = run_path(run["id"], root)
|
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
p.write_text(json.dumps(run, indent=2, sort_keys=False) + "\n", encoding="utf-8")
|
|
return p
|
|
|
|
|
|
def _parse_kv(items: Optional[List[str]]) -> Dict[str, Any]:
|
|
out: Dict[str, Any] = {}
|
|
for it in items or []:
|
|
if "=" not in it:
|
|
raise SystemExit(f"expected key=value, got {it!r}")
|
|
k, v = it.split("=", 1)
|
|
try:
|
|
out[k] = json.loads(v)
|
|
except json.JSONDecodeError:
|
|
out[k] = v
|
|
return out
|
|
|
|
|
|
def new_run(dataset: str, base_weights: str, tag: str, *, note: str = "", root: Path = ROOT,
|
|
extra: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
stamp = datetime.now().strftime("%Y-%m-%d")
|
|
run_id = f"{stamp}_{tag}"
|
|
n = 2
|
|
while run_path(run_id, root).exists():
|
|
run_id = f"{stamp}_{tag}-{n}"
|
|
n += 1
|
|
run = {
|
|
"id": run_id,
|
|
"dataset": dataset,
|
|
"base_weights": base_weights,
|
|
"status": "exported",
|
|
"created": _now(),
|
|
"created_by": _who(),
|
|
"hyperparams": {},
|
|
"version": None, # models/registry/<version> once trained
|
|
"metrics": {}, # summary of metrics.json
|
|
"history": [{"status": "exported", "at": _now(), "by": _who(), "note": note}],
|
|
}
|
|
run.update(extra or {})
|
|
save(run, root)
|
|
return run
|
|
|
|
|
|
def set_status(run_id: str, status: str, *, note: str = "", kv: Optional[Dict[str, Any]] = None,
|
|
root: Path = ROOT) -> Dict[str, Any]:
|
|
if status not in STATES:
|
|
raise SystemExit(f"unknown state {status!r}; one of {', '.join(STATES)}")
|
|
run = load(run_id, root)
|
|
run["status"] = status
|
|
for k, v in (kv or {}).items():
|
|
run[k] = v
|
|
run["history"].append({"status": status, "at": _now(), "by": _who(), "note": note})
|
|
save(run, root)
|
|
return run
|
|
|
|
|
|
def record_metrics(run_id: str, metrics_file: Path, root: Path = ROOT) -> Dict[str, Any]:
|
|
run = load(run_id, root)
|
|
data = json.loads(Path(metrics_file).read_text(encoding="utf-8"))
|
|
dest = run_path(run_id, root).parent / "metrics.json"
|
|
if Path(metrics_file).resolve() != dest.resolve():
|
|
shutil.copy2(metrics_file, dest)
|
|
overall = data.get("overall") or {}
|
|
gate = data.get("gate") or {}
|
|
run["metrics"] = {
|
|
"f1": overall.get("f1"),
|
|
"mean_mask_iou": overall.get("mean_mask_iou"),
|
|
"gate_pass": gate.get("pass"),
|
|
"baseline": gate.get("baseline"),
|
|
"measured_at": _now(),
|
|
}
|
|
save(run, root)
|
|
return run
|
|
|
|
|
|
def all_runs(root: Path = ROOT) -> List[Dict[str, Any]]:
|
|
out = []
|
|
for p in sorted(runs_dir(root).glob("*/run.json")):
|
|
try:
|
|
out.append(json.loads(p.read_text(encoding="utf-8")))
|
|
except json.JSONDecodeError as e:
|
|
out.append({"id": p.parent.name, "status": f"INVALID JSON: {e}", "history": []})
|
|
return out
|
|
|
|
|
|
def check(root: Path = ROOT) -> List[str]:
|
|
problems: List[str] = []
|
|
for p in sorted(runs_dir(root).glob("*/run.json")):
|
|
try:
|
|
run = json.loads(p.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError as e:
|
|
problems.append(f"{p}: invalid JSON ({e})")
|
|
continue
|
|
for k in REQUIRED:
|
|
if k not in run:
|
|
problems.append(f"{p}: missing {k}")
|
|
if run.get("id") != p.parent.name:
|
|
problems.append(f"{p}: id {run.get('id')!r} does not match directory")
|
|
if run.get("status") not in STATES:
|
|
problems.append(f"{p}: unknown status {run.get('status')!r}")
|
|
for h in run.get("history", []):
|
|
if h.get("status") not in STATES:
|
|
problems.append(f"{p}: history entry with unknown status {h.get('status')!r}")
|
|
if run.get("status") in ("measured", "gate_passed", "gate_failed", "promoted", "deployed"):
|
|
if not (p.parent / "metrics.json").exists():
|
|
problems.append(f"{p}: status {run['status']} but no metrics.json")
|
|
return problems
|
|
|
|
|
|
def _fmt(v: Any) -> str:
|
|
if v is None:
|
|
return "-"
|
|
if isinstance(v, float):
|
|
return f"{v:.3f}"
|
|
if isinstance(v, bool):
|
|
return "yes" if v else "no"
|
|
return str(v)
|
|
|
|
|
|
def table(root: Path = ROOT) -> str:
|
|
rows = [("run", "status", "dataset", "base", "version", "f1", "mask IoU", "gate", "last change")]
|
|
for r in all_runs(root):
|
|
m = r.get("metrics") or {}
|
|
last = (r.get("history") or [{}])[-1].get("at", "")
|
|
rows.append((r.get("id", "?"), r.get("status", "?"), r.get("dataset", "-"), r.get("base_weights", "-"),
|
|
_fmt(r.get("version")), _fmt(m.get("f1")), _fmt(m.get("mean_mask_iou")),
|
|
_fmt(m.get("gate_pass")), last[:16]))
|
|
widths = [max(len(str(row[i])) for row in rows) for i in range(len(rows[0]))]
|
|
lines = []
|
|
for n, row in enumerate(rows):
|
|
lines.append(" ".join(str(c).ljust(widths[i]) for i, c in enumerate(row)))
|
|
if n == 0:
|
|
lines.append(" ".join("-" * w for w in widths))
|
|
return "\n".join(lines)
|
|
|
|
|
|
def render(root: Path = ROOT) -> Path:
|
|
runs = all_runs(root)
|
|
md = ["# Training runs", "", "Generated by `python tools/ledger.py render`; do not edit by hand.", "",
|
|
"| Run | Status | Dataset | Base | Version | F1 | Mask IoU | Gate | Last change |",
|
|
"| --- | --- | --- | --- | --- | --- | --- | --- | --- |"]
|
|
for r in runs:
|
|
m = r.get("metrics") or {}
|
|
last = (r.get("history") or [{}])[-1].get("at", "")
|
|
md.append(f"| {r.get('id')} | {r.get('status')} | {r.get('dataset', '-')} | {r.get('base_weights', '-')} | "
|
|
f"{_fmt(r.get('version'))} | {_fmt(m.get('f1'))} | {_fmt(m.get('mean_mask_iou'))} | "
|
|
f"{_fmt(m.get('gate_pass'))} | {last[:16]} |")
|
|
md.append("")
|
|
md.append("States: " + " → ".join(STATES[:-1]) + f" (or {STATES[-1]}).")
|
|
md.append("")
|
|
for r in runs:
|
|
md.append(f"## {r.get('id')}")
|
|
md.append("")
|
|
for h in r.get("history", []):
|
|
note = f" — {h['note']}" if h.get("note") else ""
|
|
md.append(f"- {h.get('at', '')[:19]} · {h.get('status')} · {h.get('by', '')}{note}")
|
|
md.append("")
|
|
out = runs_dir(root) / "README.md"
|
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
out.write_text("\n".join(md), encoding="utf-8")
|
|
return out
|
|
|
|
|
|
def main(argv=None) -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("--root", default=str(ROOT), help="repository root (default: this checkout)")
|
|
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
n = sub.add_parser("new", help="create a run in state 'exported'")
|
|
n.add_argument("--dataset", required=True, help="dataset tag, e.g. dataset/2026-10-02_alc")
|
|
n.add_argument("--tag", required=True, help="short name; run id = <date>_<tag>")
|
|
n.add_argument("--base", default="yolo26n-seg.pt", help="base weights the run starts from")
|
|
n.add_argument("--note", default="")
|
|
n.add_argument("--kv", nargs="*", help="extra fields key=value (JSON values accepted)")
|
|
s = sub.add_parser("set", help="move a run to a state")
|
|
s.add_argument("run_id")
|
|
s.add_argument("status", choices=STATES)
|
|
s.add_argument("--note", default="")
|
|
s.add_argument("--kv", nargs="*", help="extra fields key=value")
|
|
m = sub.add_parser("metrics", help="record a metrics.json on a run")
|
|
m.add_argument("run_id")
|
|
m.add_argument("metrics_file")
|
|
sub.add_parser("show", help="print the table")
|
|
sub.add_parser("render", help="write runs/README.md")
|
|
sub.add_parser("check", help="validate every run.json (exit 1 on problems)")
|
|
args = ap.parse_args(argv)
|
|
root = Path(args.root)
|
|
|
|
if args.cmd == "new":
|
|
run = new_run(args.dataset, args.base, args.tag, note=args.note, root=root, extra=_parse_kv(args.kv))
|
|
print(run["id"])
|
|
elif args.cmd == "set":
|
|
run = set_status(args.run_id, args.status, note=args.note, kv=_parse_kv(args.kv), root=root)
|
|
print(f"{run['id']}: {run['status']}")
|
|
elif args.cmd == "metrics":
|
|
run = record_metrics(args.run_id, Path(args.metrics_file), root=root)
|
|
print(json.dumps(run["metrics"]))
|
|
elif args.cmd == "show":
|
|
print(table(root))
|
|
elif args.cmd == "render":
|
|
print(render(root))
|
|
elif args.cmd == "check":
|
|
problems = check(root)
|
|
for p in problems:
|
|
print(p, file=sys.stderr)
|
|
print(f"{len(list(runs_dir(root).glob('*/run.json')))} runs, {len(problems)} problems")
|
|
return 1 if problems else 0
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|