playcal 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
playcal/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
playcal/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
playcal/affected.py ADDED
@@ -0,0 +1,115 @@
1
+ """Which Unity test assemblies a change can break, from the project's .asmdef graph.
2
+
3
+ A changed .cs file belongs to the assembly of the nearest .asmdef (or .asmref) above it, or to
4
+ Assembly-CSharp / Assembly-CSharp-Editor when there is none. Every assembly that references a changed
5
+ one, directly or not, is affected; the test assemblies among them are the ones worth running.
6
+ Changes to scenes, prefabs, settings or packages can't be traced this way, so they ask for a full run.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+ import subprocess
13
+ from pathlib import Path
14
+
15
+ CODE = (".cs", ".asmdef", ".asmref")
16
+ TRACKED = ("Assets/", "Packages/", "ProjectSettings/")
17
+ PLAYER, EDITOR = "Assembly-CSharp", "Assembly-CSharp-Editor"
18
+
19
+
20
+ def _guid(f: Path) -> str | None:
21
+ meta = f.with_name(f.name + ".meta")
22
+ m = re.search(r"^guid:\s*(\w+)", meta.read_text(encoding="utf-8"), re.M) if meta.exists() else None
23
+ return m[1] if m else None
24
+
25
+
26
+ def _json(f: Path) -> dict:
27
+ try:
28
+ return json.loads(f.read_text(encoding="utf-8-sig"))
29
+ except (OSError, json.JSONDecodeError):
30
+ return {}
31
+
32
+
33
+ def _is_test(doc: dict) -> bool:
34
+ return ("UNITY_INCLUDE_TESTS" in doc.get("defineConstraints", [])
35
+ or "TestAssemblies" in doc.get("optionalUnityReferences", [])
36
+ or "nunit.framework.dll" in doc.get("precompiledReferences", [])
37
+ or "UnityEngine.TestRunner" in doc.get("references", []))
38
+
39
+
40
+ def scan(root: Path) -> tuple[dict[str, dict], dict[str, str]]:
41
+ """({assembly: {refs, test, auto}}, {folder: assembly}) for the project's own assemblies."""
42
+ asms: dict[str, dict] = {}
43
+ folders: dict[str, str] = {}
44
+ by_guid: dict[str, str] = {}
45
+ files = [f for top in ("Assets", "Packages") for f in (root / top).rglob("*.asmdef")]
46
+ for f in files:
47
+ doc = _json(f)
48
+ name = doc.get("name") or f.stem
49
+ asms[name] = {"refs": doc.get("references", []), "test": _is_test(doc), "auto": doc.get("autoReferenced", True)}
50
+ folders[f.parent.relative_to(root).as_posix()] = name
51
+ if g := _guid(f):
52
+ by_guid[g] = name
53
+ for f in (r for top in ("Assets", "Packages") for r in (root / top).rglob("*.asmref")):
54
+ ref = _json(f).get("reference", "")
55
+ target = by_guid.get(ref[5:], "") if ref.startswith("GUID:") else ref
56
+ if target:
57
+ folders[f.parent.relative_to(root).as_posix()] = target
58
+ for a in asms.values():
59
+ a["refs"] = [by_guid.get(r[5:], r) if r.startswith("GUID:") else r for r in a["refs"]]
60
+ # the predefined assemblies see every auto-referenced asmdef; the Editor one also sees the player one
61
+ autos = [n for n, a in asms.items() if a["auto"] and not a["test"]]
62
+ asms[PLAYER] = {"refs": autos, "test": False, "auto": False}
63
+ asms[EDITOR] = {"refs": [*autos, PLAYER], "test": _has_tests(root, editor=True), "auto": False}
64
+ return asms, folders
65
+
66
+
67
+ def _has_tests(root: Path, editor: bool) -> bool:
68
+ """Tests can live in Editor folders without an asmdef; look for NUnit there."""
69
+ for f in (root / "Assets").rglob("*.cs"):
70
+ if editor == ("Editor" in f.relative_to(root).parts) and "NUnit.Framework" in f.read_text(encoding="utf-8", errors="ignore"):
71
+ return True
72
+ return False
73
+
74
+
75
+ def owner(path: str, folders: dict[str, str]) -> str:
76
+ parts = path.split("/")
77
+ for i in range(len(parts) - 1, 0, -1):
78
+ if (d := "/".join(parts[:i])) in folders:
79
+ return folders[d]
80
+ return EDITOR if "Editor" in parts[:-1] else PLAYER
81
+
82
+
83
+ def changed_files(root: Path, base: str) -> list[str]:
84
+ run = lambda *a: subprocess.run(["git", *a], cwd=root, capture_output=True, text=True, timeout=30)
85
+ diff = run("diff", "--name-only", "--relative", base)
86
+ if diff.returncode:
87
+ raise ValueError(f"git diff against {base!r} failed: {diff.stderr.strip()}")
88
+ new = run("ls-files", "--others", "--exclude-standard")
89
+ return [ln for ln in (diff.stdout + new.stdout).splitlines() if ln]
90
+
91
+
92
+ def affected(root: Path, changed: list[str]) -> dict:
93
+ asms, folders = scan(root)
94
+ hit, unmapped = set(), []
95
+ for p in changed:
96
+ p = p.replace("\\", "/").removesuffix(".meta")
97
+ if p.endswith(CODE) and p.startswith(("Assets/", "Packages/")):
98
+ if p.endswith((".asmdef", ".asmref")):
99
+ folders.setdefault(p.rsplit("/", 1)[0], Path(p).stem) # a new or deleted asmdef
100
+ hit.add(owner(p, folders))
101
+ elif p.startswith(TRACKED):
102
+ unmapped.append(p)
103
+ users: dict[str, set] = {}
104
+ for name, a in asms.items():
105
+ for r in a["refs"]:
106
+ users.setdefault(r, set()).add(name)
107
+ seen, todo = set(hit), list(hit)
108
+ while todo:
109
+ for u in users.get(todo.pop(), ()):
110
+ if u not in seen:
111
+ seen.add(u)
112
+ todo.append(u)
113
+ tests = sorted(n for n in seen if asms.get(n, {}).get("test"))
114
+ return {"run_all": bool(unmapped), "assemblies": tests, "changed_assemblies": sorted(hit),
115
+ "unmapped": unmapped, "all_tests": sorted(n for n, a in asms.items() if a["test"])}
playcal/baseline.py ADDED
@@ -0,0 +1,172 @@
1
+ """playcal baseline: keep a perf baseline (frame time, GC allocation, any per-frame number) and fail
2
+ only when a new run is slower by more than noise.
3
+
4
+ Input is per-frame samples: {"frame_ms": [...], "gc_alloc_bytes": [...]}, or a bare list (taken as
5
+ frame_ms). check compares a percentile (p95 by default) and bootstraps the difference: it fails only
6
+ when even the low end of the 95% interval is past the tolerance. One hitchy run on a busy machine
7
+ then shows up as "inconclusive", not as a red build.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import random
14
+ import re
15
+ import sys
16
+ import time
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from .registry import Module
21
+
22
+ ROUNDS = 400
23
+
24
+
25
+ class BaselineError(Exception):
26
+ pass
27
+
28
+
29
+ def samples(doc) -> dict[str, list[float]]:
30
+ if isinstance(doc, list):
31
+ doc = {"frame_ms": doc}
32
+ if not isinstance(doc, dict):
33
+ raise BaselineError("samples must be a list of numbers or {metric: [numbers]}")
34
+ out = {}
35
+ for k, v in doc.items():
36
+ if isinstance(v, list) and v and all(isinstance(x, (int, float)) and not isinstance(x, bool) for x in v):
37
+ out[k] = [float(x) for x in v]
38
+ if not out:
39
+ raise BaselineError("no metric with a list of numbers found")
40
+ return out
41
+
42
+
43
+ def pct(xs: list[float], q: float) -> float:
44
+ s = sorted(xs)
45
+ i = (len(s) - 1) * q / 100
46
+ lo = int(i)
47
+ return s[lo] + (s[min(lo + 1, len(s) - 1)] - s[lo]) * (i - lo)
48
+
49
+
50
+ def stats(xs: list[float]) -> dict:
51
+ return {"n": len(xs), "mean": round(sum(xs) / len(xs), 4), "p50": round(pct(xs, 50), 4),
52
+ "p95": round(pct(xs, 95), 4), "p99": round(pct(xs, 99), 4), "max": max(xs)}
53
+
54
+
55
+ def compare(base: list[float], new: list[float], q: float = 95, tolerance: float = 0.10,
56
+ floor: float = 0.0, rounds: int = ROUNDS, seed: int = 0) -> dict:
57
+ """Relative change of the q-th percentile with a bootstrap 95% interval.
58
+ verdict: "slower" (interval's low end > tolerance and the absolute change > floor),
59
+ "faster", "same" (interval inside ±tolerance), or "inconclusive"."""
60
+ b0, n0 = pct(base, q), pct(new, q)
61
+ rng = random.Random(seed)
62
+ rel = sorted((pct(rng.choices(new, k=len(new)), q) - pct(rng.choices(base, k=len(base)), q)) / (b0 or 1)
63
+ for _ in range(rounds))
64
+ lo, hi = rel[int(rounds * 0.025)], rel[int(rounds * 0.975) - 1]
65
+ change = (n0 - b0) / (b0 or 1)
66
+ if lo > tolerance and n0 - b0 > floor:
67
+ verdict = "slower"
68
+ elif hi < -tolerance:
69
+ verdict = "faster"
70
+ elif -tolerance <= lo and hi <= tolerance:
71
+ verdict = "same"
72
+ else:
73
+ verdict = "inconclusive"
74
+ return {"verdict": verdict, f"p{q:g}_base": round(b0, 4), f"p{q:g}_new": round(n0, 4),
75
+ "change": round(change, 4), "ci95": [round(lo, 4), round(hi, 4)]}
76
+
77
+
78
+ def _file(folder: Path, name: str) -> Path:
79
+ if not re.fullmatch(r"[\w.-]+", name):
80
+ raise BaselineError(f"baseline names are letters, digits, . _ - only: {name!r}")
81
+ return folder / f"{name}.json"
82
+
83
+
84
+ def _read(src: str):
85
+ text = sys.stdin.read() if src == "-" else Path(src).read_text(encoding="utf-8")
86
+ return samples(json.loads(text))
87
+
88
+
89
+ def save(folder: Path, name: str, data: dict[str, list[float]], source: str) -> dict:
90
+ folder.mkdir(parents=True, exist_ok=True)
91
+ doc = {"source": source, "saved_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
92
+ "stats": {k: stats(v) for k, v in data.items()}, "samples": data}
93
+ _file(folder, name).write_text(json.dumps(doc), encoding="utf-8")
94
+ return {"name": name, "stats": doc["stats"]}
95
+
96
+
97
+ def check(folder: Path, name: str, data: dict[str, list[float]], q: float, tolerance: float,
98
+ floors: dict[str, float], metrics: list[str] | None = None) -> dict:
99
+ path = _file(folder, name)
100
+ if not path.exists():
101
+ raise BaselineError(f"no baseline {name!r} in {folder}; make one with: playcal baseline save {name} FILE")
102
+ base = json.loads(path.read_text(encoding="utf-8"))["samples"]
103
+ results, missing = {}, []
104
+ for m in metrics or sorted(set(base) & set(data)):
105
+ if m not in base or m not in data:
106
+ missing.append(m)
107
+ continue
108
+ results[m] = compare(base[m], data[m], q, tolerance, floors.get(m, 0.0))
109
+ results[m]["n"] = [len(base[m]), len(data[m])]
110
+ if not results:
111
+ raise BaselineError(f"no metric in both the baseline and the new run (asked for {metrics or 'all'})")
112
+ verdicts = {r["verdict"] for r in results.values()}
113
+ return {"ok": "slower" not in verdicts, "verdict": "slower" if "slower" in verdicts else
114
+ "inconclusive" if "inconclusive" in verdicts else "same_or_faster",
115
+ "metrics": results, **({"missing": missing} if missing else {})}
116
+
117
+
118
+ def _floor(spec: str) -> tuple[str, float]:
119
+ k, sep, v = spec.partition("=")
120
+ if not sep:
121
+ raise argparse.ArgumentTypeError("--floor is METRIC=VALUE, e.g. frame_ms=0.5")
122
+ return k, float(v)
123
+
124
+
125
+ def _add_args(p: argparse.ArgumentParser) -> None:
126
+ p.add_argument("action", choices=["save", "check", "show"],
127
+ help="save NAME FILE: keep a run as the baseline. check NAME FILE: compare a new run. show NAME")
128
+ p.add_argument("name", help="baseline name, e.g. level4-android")
129
+ p.add_argument("source", nargs="?", default="-", help="samples JSON file, or - for stdin (default)")
130
+ p.add_argument("--dir", default=".playcal/baselines", help="where baselines live (default .playcal/baselines)")
131
+ p.add_argument("--percentile", type=float, default=95, help="percentile to compare (default 95)")
132
+ p.add_argument("--tolerance", type=float, default=10, help="allowed slowdown in percent (default 10)")
133
+ p.add_argument("--floor", type=_floor, action="append", default=[], metavar="METRIC=VALUE",
134
+ help="ignore slowdowns smaller than this in absolute units, e.g. frame_ms=0.5. Repeatable")
135
+ p.add_argument("--metric", action="append", default=None, help="only these metrics (default: all in both)")
136
+ p.add_argument("--json", action="store_true", help="print the result as JSON")
137
+
138
+
139
+ def _run(args: argparse.Namespace) -> int:
140
+ folder = Path(args.dir)
141
+ out: Any
142
+ try:
143
+ if args.action == "show":
144
+ path = _file(folder, args.name)
145
+ if not path.exists():
146
+ raise BaselineError(f"no baseline {args.name!r} in {folder}")
147
+ doc = json.loads(path.read_text(encoding="utf-8"))
148
+ out = {"name": args.name, "source": doc["source"], "saved_at": doc["saved_at"], "stats": doc["stats"]}
149
+ elif args.action == "save":
150
+ out = save(folder, args.name, _read(args.source), args.source)
151
+ else:
152
+ out = check(folder, args.name, _read(args.source), args.percentile, args.tolerance / 100,
153
+ dict(args.floor), args.metric)
154
+ except (BaselineError, OSError, json.JSONDecodeError) as e:
155
+ print(f"playcal baseline: {e}", file=sys.stderr)
156
+ return 2
157
+ if args.json:
158
+ print(json.dumps(out, ensure_ascii=False))
159
+ elif args.action == "check":
160
+ for m, r in out["metrics"].items():
161
+ q = f"p{args.percentile:g}"
162
+ print(f"{m}: {r['verdict']} {q} {r[q + '_base']} -> {r[q + '_new']} ({r['change']:+.1%}, "
163
+ f"95% CI {r['ci95'][0]:+.1%} .. {r['ci95'][1]:+.1%})")
164
+ print("ok" if out["ok"] else "slower than the baseline")
165
+ else:
166
+ for m, s in out["stats"].items():
167
+ print(f"{m}: n={s['n']} p50={s['p50']} p95={s['p95']} p99={s['p99']} max={s['max']}")
168
+ return 0 if args.action != "check" or out["ok"] else 1
169
+
170
+
171
+ MODULE = Module(name="baseline", summary="keep a perf baseline (p95 frame time, GC, memory) and fail only when a run is worse by more than noise",
172
+ add_args=_add_args, run=_run)
playcal/calibrate.py ADDED
@@ -0,0 +1,171 @@
1
+ """playcal calibrate: predict the human pass rate of each level from bot runs, calibrated on the
2
+ levels a small human panel played.
3
+
4
+ Both sides are reduced to a per-level attempt pass rate on the logit scale. Human logits are fit to
5
+ bot logits with a random-effects meta-regression (weighted least squares, DerSimonian-Laird tau^2),
6
+ so the between-level scatter the bots don't explain widens every prediction. Leave-one-out over the
7
+ calibration levels says how far to trust it.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import math
14
+ import statistics
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ from .ingest import check_lines
19
+ from .registry import Module
20
+ from .report import _level_sort_key, summarize
21
+
22
+ # two-sided 95% t quantiles; above 30 df the normal value is close enough
23
+ T975 = [12.706, 4.303, 3.182, 2.776, 2.571, 2.447, 2.365, 2.306, 2.262, 2.228, 2.201, 2.179, 2.160, 2.145, 2.131,
24
+ 2.120, 2.110, 2.101, 2.093, 2.086, 2.080, 2.074, 2.069, 2.064, 2.060, 2.056, 2.052, 2.048, 2.045, 2.042]
25
+
26
+
27
+ def t975(df: int) -> float:
28
+ return T975[df - 1] if 1 <= df <= len(T975) else 1.96
29
+
30
+
31
+ def logit(k: int, n: int) -> tuple[float, float]:
32
+ """Empirical logit of k/n with the 0.5 correction, and its sampling variance."""
33
+ return math.log((k + 0.5) / (n - k + 0.5)), 1 / (k + 0.5) + 1 / (n - k + 0.5)
34
+
35
+
36
+ def expit(x: float) -> float:
37
+ return 1 / (1 + math.exp(-x))
38
+
39
+
40
+ def _wls(xs, ys, ws):
41
+ """Weighted least squares y = a + b x. Returns (a, b, cov) with cov the 2x2 inverse of X'WX."""
42
+ sw = sum(ws)
43
+ sx = sum(w * x for w, x in zip(ws, xs))
44
+ sxx = sum(w * x * x for w, x in zip(ws, xs))
45
+ sy = sum(w * y for w, y in zip(ws, ys))
46
+ sxy = sum(w * x * y for w, x, y in zip(ws, xs, ys))
47
+ det = sw * sxx - sx * sx
48
+ if det <= 1e-12:
49
+ raise ValueError("the bot pass rates of the calibration levels are all the same; nothing to fit")
50
+ b = (sw * sxy - sx * sy) / det
51
+ a = (sy - b * sx) / sw
52
+ return a, b, ((sxx / det, -sx / det), (-sx / det, sw / det))
53
+
54
+
55
+ def fit(points: list[tuple[float, float, float]]) -> dict:
56
+ """points: (bot logit x, human logit y, human sampling variance v), one per calibration level."""
57
+ if len(points) < 3:
58
+ raise ValueError(f"need humans on at least 3 levels to calibrate, got {len(points)}")
59
+ xs, ys, vs = zip(*points)
60
+ w = [1 / v for v in vs]
61
+ a, b, cov = _wls(xs, ys, w)
62
+ q = sum(wi * (y - a - b * x) ** 2 for wi, x, y in zip(w, xs, ys))
63
+ df = len(points) - 2
64
+ # C = sum(w) - trace((X'WX)^-1 X'W^2X)
65
+ s2 = (sum(wi ** 2 for wi in w), sum(wi ** 2 * x for wi, x in zip(w, xs)), sum(wi ** 2 * x * x for wi, x in zip(w, xs)))
66
+ c = sum(w) - (cov[0][0] * s2[0] + 2 * cov[0][1] * s2[1] + cov[1][1] * s2[2])
67
+ tau2 = max(0.0, (q - df) / c) if c > 0 else 0.0
68
+ a, b, cov = _wls(xs, ys, [1 / (v + tau2) for v in vs])
69
+ return {"a": a, "b": b, "cov": cov, "tau2": tau2, "df": df}
70
+
71
+
72
+ def predict(model: dict, x: float, extra_var: float = 0.0) -> tuple[float, float, float]:
73
+ """Predicted human pass rate for a level with bot logit x, and its 95% interval. extra_var adds
74
+ sampling noise when the interval should cover an observed rate rather than the true one."""
75
+ (c00, c01), (_, c11) = model["cov"]
76
+ eta = model["a"] + model["b"] * x
77
+ se = math.sqrt(c00 + 2 * c01 * x + c11 * x * x + model["tau2"] + extra_var)
78
+ t = t975(model["df"])
79
+ return expit(eta), expit(eta - t * se), expit(eta + t * se)
80
+
81
+
82
+ def leave_one_out(points) -> dict | None:
83
+ if len(points) < 4:
84
+ return None
85
+ hits, errs = 0, []
86
+ for i, (x, y, v) in enumerate(points):
87
+ try:
88
+ m = fit(points[:i] + points[i + 1:])
89
+ except ValueError:
90
+ return None
91
+ p, lo, hi = predict(m, x, extra_var=v)
92
+ hits += lo <= expit(y) <= hi
93
+ errs.append(abs(p - expit(y)))
94
+ return {"levels": len(points), "inside_interval": hits, "median_abs_error": round(statistics.median(errs), 4)}
95
+
96
+
97
+ def _stats(files: list[str]) -> dict:
98
+ lines = [(f, line) for f in files for line in Path(f).read_text(encoding="utf-8").splitlines()]
99
+ res = check_lines(lines)
100
+ if not res.accepted:
101
+ why = f" ({len(res.rejected)} rejected, first: {res.rejected[0]['reason']})" if res.rejected else ""
102
+ raise ValueError(f"no valid events in {', '.join(files)}{why}")
103
+ return {(s.game_version, s.level): s for s in summarize(res.accepted)}
104
+
105
+
106
+ def calibrate(human: dict, bot: dict, min_attempts: int = 5) -> dict:
107
+ points, keys = [], []
108
+ for key in sorted(bot, key=_level_sort_key):
109
+ h, b = human.get(key), bot[key]
110
+ if h and h.attempts >= min_attempts and b.attempts:
111
+ x, _ = logit(b.attempts_passed, b.attempts)
112
+ y, v = logit(h.attempts_passed, h.attempts)
113
+ points.append((x, y, v))
114
+ keys.append(key)
115
+ model = fit(points)
116
+ levels = []
117
+ for key in sorted(bot, key=_level_sort_key):
118
+ b = bot[key]
119
+ if not b.attempts:
120
+ continue
121
+ p, lo, hi = predict(model, logit(b.attempts_passed, b.attempts)[0])
122
+ row = {"game_version": key[0], "level": key[1], "bot_pass_rate": round(b.attempts_passed / b.attempts, 4),
123
+ "bot_attempts": b.attempts, "predicted": round(p, 4), "ci": [round(lo, 4), round(hi, 4)],
124
+ "calibration": key in keys}
125
+ h = human.get(key)
126
+ if h and h.attempts:
127
+ row["human_pass_rate"], row["human_attempts"] = round(h.attempts_passed / h.attempts, 4), h.attempts
128
+ levels.append(row)
129
+ return {"model": {"a": round(model["a"], 4), "b": round(model["b"], 4), "tau": round(math.sqrt(model["tau2"]), 4),
130
+ "calibration_levels": len(points)},
131
+ "leave_one_out": leave_one_out(points), "levels": levels}
132
+
133
+
134
+ def _add_args(p: argparse.ArgumentParser) -> None:
135
+ p.add_argument("--human", nargs="+", required=True, help="event JSONL from the human panel (accepted.jsonl from ingest)")
136
+ p.add_argument("--bot", nargs="+", required=True, help="event JSONL from bot runs, same schema")
137
+ p.add_argument("--min-attempts", type=int, default=5, help="human attempts a level needs to count for calibration (default 5)")
138
+ p.add_argument("--json", action="store_true", help="print JSON instead of a table")
139
+
140
+
141
+ def _pct(x: float) -> str:
142
+ return f"{100 * x:.0f}%"
143
+
144
+
145
+ def _run(args: argparse.Namespace) -> int:
146
+ try:
147
+ out = calibrate(_stats(args.human), _stats(args.bot), args.min_attempts)
148
+ except (OSError, ValueError) as e:
149
+ print(json.dumps({"error": str(e)}) if args.json else f"playcal calibrate: {e}", file=None if args.json else sys.stderr)
150
+ return 2
151
+ if args.json:
152
+ print(json.dumps(out, ensure_ascii=False))
153
+ return 0
154
+ m, loo = out["model"], out["leave_one_out"]
155
+ print(f"{'version':<10} {'level':<8} {'bot':>5} {'predicted human':>20} {'human seen':>12}")
156
+ for r in out["levels"]:
157
+ pred = f"{_pct(r['predicted'])} [{_pct(r['ci'][0])}-{_pct(r['ci'][1])}]"
158
+ seen = f"{_pct(r['human_pass_rate'])} n={r['human_attempts']}" if "human_pass_rate" in r else "-"
159
+ print(f"{r['game_version']:<10} {r['level']:<8} {_pct(r['bot_pass_rate']):>5} {pred:>20} {seen:>12}"
160
+ + (" *" if r["calibration"] else ""))
161
+ print(f"* calibration level ({m['calibration_levels']}). Attempt pass rates; brackets = 95% interval for the level's true human rate.")
162
+ if loo:
163
+ print(f"leave-one-out: {loo['inside_interval']}/{loo['levels']} human rates inside their interval, "
164
+ f"median error {100 * loo['median_abs_error']:.0f} points.")
165
+ else:
166
+ print("leave-one-out needs at least 4 calibration levels; treat these numbers as a rough guess.")
167
+ return 0
168
+
169
+
170
+ MODULE = Module(name="calibrate", summary="predict each level's human pass rate from bot runs, calibrated on a small human panel, with 95% intervals",
171
+ add_args=_add_args, run=_run)
playcal/checkpoint.py ADDED
@@ -0,0 +1,172 @@
1
+ """playcal checkpoint: save the project's files before each agent step, and go back to any save.
2
+
3
+ Checkpoints are ordinary git commits on a private ref (refs/playcal/checkpoints), written through
4
+ a separate index file. Your branch, HEAD, staging area and stash are never touched, and the ref is
5
+ not pushed unless you push it on purpose. .gitignore is respected, so Unity's Library/ and Temp/
6
+ stay out if your .gitignore says so.
7
+
8
+ restore only rewrites the files that differ (so Unity doesn't reimport the whole project) and
9
+ always saves a checkpoint of the current state first, so a restore can itself be undone.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import os
16
+ import subprocess
17
+ import sys
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from .registry import Module
22
+
23
+ REF = "refs/playcal/checkpoints"
24
+ IDENTITY = {"GIT_AUTHOR_NAME": "playcal", "GIT_AUTHOR_EMAIL": "playcal@localhost",
25
+ "GIT_COMMITTER_NAME": "playcal", "GIT_COMMITTER_EMAIL": "playcal@localhost"}
26
+
27
+
28
+ class GitError(RuntimeError):
29
+ pass
30
+
31
+
32
+ class Repo:
33
+ def __init__(self, path: str | os.PathLike = "."):
34
+ self.root = Path(self._git(["rev-parse", "--show-toplevel"], cwd=path).strip())
35
+ self.index = Path(self._git(["rev-parse", "--absolute-git-dir"]).strip()) / "playcal-index"
36
+
37
+ def _git(self, args, cwd=None, index=False, check=True) -> str:
38
+ env = {**os.environ, **IDENTITY}
39
+ if index:
40
+ env["GIT_INDEX_FILE"] = str(self.index)
41
+ try:
42
+ p = subprocess.run(["git", *args], cwd=cwd or self.root, env=env, capture_output=True, text=True,
43
+ encoding="utf-8", errors="replace")
44
+ except OSError as e: # no git on PATH, or the folder doesn't exist
45
+ raise GitError(str(e)) from e
46
+ if check and p.returncode != 0:
47
+ raise GitError(p.stderr.strip() or f"git {args[0]} failed")
48
+ return p.stdout
49
+
50
+ def rev(self, name: str) -> str | None:
51
+ out = self._git(["rev-parse", "--verify", "--quiet", f"{name}^{{commit}}"], check=False).strip()
52
+ return out or None
53
+
54
+ def snapshot_tree(self) -> str:
55
+ """The working tree as a git tree object, built in playcal's own index."""
56
+ self.index.unlink(missing_ok=True)
57
+ if self.rev("HEAD"):
58
+ self._git(["read-tree", "HEAD"], index=True) # start from HEAD so add -A only hashes what changed
59
+ self._git(["add", "-A", "--", "."], index=True)
60
+ return self._git(["write-tree"], index=True).strip()
61
+
62
+ def resolve(self, ident: str) -> str:
63
+ if ident in ("latest", "last"):
64
+ ident = REF
65
+ elif ident.startswith("~") and ident[1:].isdigit():
66
+ ident = f"{REF}~{ident[1:]}"
67
+ sha = self.rev(ident)
68
+ if not sha or not self._is_checkpoint(sha):
69
+ raise GitError(f"no checkpoint {ident!r} (see: playcal checkpoint list)")
70
+ return sha
71
+
72
+ def _is_checkpoint(self, sha: str) -> bool:
73
+ tip = self.rev(REF)
74
+ return bool(tip) and subprocess.run(["git", "merge-base", "--is-ancestor", sha, str(tip)], cwd=self.root,
75
+ capture_output=True).returncode == 0
76
+
77
+
78
+ def save(repo: Repo, message: str = "") -> dict:
79
+ tree = repo.snapshot_tree()
80
+ tip = repo.rev(REF)
81
+ if tip and repo._git(["rev-parse", f"{tip}^{{tree}}"]).strip() == tree:
82
+ return {"id": tip[:10], "saved": False, "reason": "unchanged"}
83
+ head = repo.rev("HEAD")
84
+ msg = (message or "checkpoint") + (f"\n\nbase: {head}" if head else "")
85
+ sha = repo._git(["commit-tree", tree, *(["-p", tip] if tip else []), "-m", msg]).strip()
86
+ repo._git(["update-ref", REF, sha, tip or ""]) # fails if someone else moved the ref meanwhile
87
+ return {"id": sha[:10], "saved": True, "message": message or "checkpoint"}
88
+
89
+
90
+ def list_(repo: Repo, limit: int = 20) -> list[dict]:
91
+ if not repo.rev(REF):
92
+ return []
93
+ out = repo._git(["log", f"-{limit}", "--format=%H%x1f%cI%x1f%s", REF])
94
+ rows = []
95
+ for i, line in enumerate(out.splitlines()):
96
+ sha, when, subject = line.split("\x1f")
97
+ rows.append({"id": sha[:10], "back": i, "time": when, "message": subject})
98
+ return rows
99
+
100
+
101
+ def changes(repo: Repo, sha: str, tree: str | None = None) -> list[dict]:
102
+ """What changed from checkpoint `sha` to the working tree (or to `tree`)."""
103
+ tree = tree or repo.snapshot_tree()
104
+ out = repo._git(["diff-tree", "-r", "--no-renames", "--name-status", "-z", f"{sha}^{{tree}}", tree])
105
+ parts = [p for p in out.split("\0") if p]
106
+ names = {"A": "added", "D": "deleted", "M": "modified", "T": "modified"}
107
+ return [{"status": names.get(parts[i], parts[i]), "path": parts[i + 1]} for i in range(0, len(parts) - 1, 2)]
108
+
109
+
110
+ def restore(repo: Repo, ident: str) -> dict:
111
+ sha = repo.resolve(ident)
112
+ before = save(repo, f"before restore to {sha[:10]}")
113
+ diff = changes(repo, sha, repo._git(["rev-parse", f"{REF}^{{tree}}"]).strip())
114
+ gone = [d["path"] for d in diff if d["status"] == "added"]
115
+ back = [d["path"] for d in diff if d["status"] != "added"]
116
+ for path in gone:
117
+ (repo.root / path).unlink(missing_ok=True)
118
+ repo.index.unlink(missing_ok=True)
119
+ repo._git(["read-tree", sha], index=True)
120
+ for i in range(0, len(back), 200): # keep the command line short on Windows
121
+ repo._git(["checkout-index", "-f", "--", *back[i:i + 200]], index=True)
122
+ return {"restored": sha[:10], "undo": before["id"], "rewrote": len(back), "removed": len(gone),
123
+ "files": diff[:50]}
124
+
125
+
126
+ def _add_args(p: argparse.ArgumentParser) -> None:
127
+ p.add_argument("action", choices=["save", "list", "diff", "restore"],
128
+ help="save the files now, list saves, diff a save against now, restore a save")
129
+ p.add_argument("target", nargs="?", help="save: a message. diff/restore: checkpoint id, latest, or ~N (N saves back)")
130
+ p.add_argument("--repo", default=".", help="folder inside the git repo (default: current folder)")
131
+ p.add_argument("--limit", type=int, default=20, help="how many saves to list (default 20)")
132
+ p.add_argument("--json", action="store_true", help="print the result as JSON")
133
+
134
+
135
+ def _run(args: argparse.Namespace) -> int:
136
+ out: Any
137
+ try:
138
+ repo = Repo(args.repo)
139
+ if args.action == "save":
140
+ out = save(repo, args.target or "")
141
+ elif args.action == "list":
142
+ out = list_(repo, args.limit)
143
+ elif args.action == "diff":
144
+ sha = repo.resolve(args.target or "latest")
145
+ out = {"id": sha[:10], "changes": changes(repo, sha)}
146
+ else:
147
+ if not args.target:
148
+ print("playcal checkpoint restore: give a checkpoint id, latest or ~N", file=sys.stderr)
149
+ return 2
150
+ out = restore(repo, args.target)
151
+ except GitError as e:
152
+ print(json.dumps({"error": str(e)}) if args.json else f"playcal checkpoint: {e}", file=sys.stderr)
153
+ return 2
154
+ if args.json:
155
+ print(json.dumps(out, ensure_ascii=False))
156
+ elif args.action == "list":
157
+ for r in out:
158
+ print(f"{r['id']} ~{r['back']:<3} {r['time']} {r['message']}")
159
+ elif args.action == "diff":
160
+ for c in out["changes"]:
161
+ print(f"{c['status']:<9} {c['path']}")
162
+ print(f"{len(out['changes'])} files differ from {out['id']}")
163
+ elif args.action == "save":
164
+ print(f"{'saved' if out['saved'] else 'nothing changed since'} {out['id']}")
165
+ else:
166
+ print(f"restored {out['restored']}: {out['rewrote']} rewritten, {out['removed']} removed. "
167
+ f"Undo with: playcal checkpoint restore {out['undo']}")
168
+ return 0
169
+
170
+
171
+ MODULE = Module(name="checkpoint", summary="save project files to a private git ref before each step, and restore any save",
172
+ add_args=_add_args, run=_run)