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 +1 -0
- playcal/__main__.py +5 -0
- playcal/affected.py +115 -0
- playcal/baseline.py +172 -0
- playcal/calibrate.py +171 -0
- playcal/checkpoint.py +172 -0
- playcal/checks.py +154 -0
- playcal/cli.py +61 -0
- playcal/doctor.py +77 -0
- playcal/ingest.py +120 -0
- playcal/lock.py +165 -0
- playcal/logs.py +143 -0
- playcal/loop.py +368 -0
- playcal/observe.py +213 -0
- playcal/query.py +90 -0
- playcal/registry.py +31 -0
- playcal/replay.py +315 -0
- playcal/report.py +136 -0
- playcal/schema/event-v0.json +32 -0
- playcal/snap.py +145 -0
- playcal/surfaces.py +156 -0
- playcal/testhist.py +166 -0
- playcal/visual.py +181 -0
- playcal-0.1.0.dist-info/METADATA +386 -0
- playcal-0.1.0.dist-info/RECORD +28 -0
- playcal-0.1.0.dist-info/WHEEL +4 -0
- playcal-0.1.0.dist-info/entry_points.txt +19 -0
- playcal-0.1.0.dist-info/licenses/LICENSE +202 -0
playcal/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
playcal/__main__.py
ADDED
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)
|