poolcoach / scripts /check_lazy_render.py
masterdanh's picture
deploy: snapshot for HF Space
78738de
Raw
History Blame Contribute Delete
8.8 kB
"""G6.1 + G6.2 — chứng minh RENDER LƯỜI đúng là lười, và không đổi quỹ đạo.
31/07/2026: app chỉ còn MỘT đường (zone V2) — bản tham chiếu gọi thẳng
``recommend_v2`` với ``n_render`` đủ mọi cú; không còn vòng lặp engine.
Chạy (venv, từ gốc repo):
python scripts/check_lazy_render.py
python scripts/check_lazy_render.py --boards 1
Vì sao ở `scripts/` chứ không ở `tests/`: `conftest.py` cố ý nhét stub pooltool
RỖNG, nên test app không chạm được tầng vật lý — mà cả hai câu hỏi dưới đây đều
là câu hỏi về sim thật. Cùng lý do `check_api_drift.py` tồn tại ngoài `tests/`.
HAI PHÉP KIỂM:
G6.1 `/api/recommend` trả ĐÚNG MỘT cú kèm `trajectories`, và đó là cú rank 1.
Ba cú thay thế có đủ tham số nhưng `trajectories` rỗng.
G6.2 `/api/trajectory` trả quỹ đạo KHỚP quỹ đạo mà `n_render = 1 +
alternatives` sẽ trả cho CÙNG cú đó. So từng điểm, không dung sai: sim
tất định thì hoặc khớp tuyệt đối, hoặc hai đường đã khác nhau ở đâu đó.
Tham số cú gửi đi là tham số **lấy từ JSON của `/api/recommend`** — tức
đúng thứ FE có trong tay, không phải float gốc trong bộ nhớ server. Đó
là chỗ phép kiểm này có răng: bản trước 30/07 làm tròn `phi` 2 chữ số
và `v0` 3 chữ số, nên `v0` lưới 0.8888888888888888 xuống JSON thành
0.889 và lần sim lại chạy ở một ô KHÁC. Không có phép kiểm này thì sai
lệch đó chỉ hiện ra dưới dạng "cú thay thế vẽ hơi lệch", không ai bắt.
G6.2b `balls_final` của `/api/trajectory` khớp `outcome.balls_final` mà
`/api/recommend` đã trả cho cú đó. Vế này KHÁC G6.2 và cũng cần: FE
animate theo quỹ đạo lười rồi `applyFinal()` áp `outcome.balls_final`
(từ sim của search, không phải sim render). Hai số lệch nhau thì bi
NHẢY một đoạn ở khung hình cuối.
Thế bàn CỐ ĐỊNH, không random — cùng lý do `check_api_drift.BOARDS`: một thế
bàn ngẫu nhiên làm "chạy lại thấy xanh" không nói lên điều gì.
Lệch → DỪNG, ghi HANDOFF (BRIEF 30/07, mục "Nếu bí").
"""
from __future__ import annotations
import argparse
import io
import os
import sys
import time
from pathlib import Path
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8",
errors="replace", line_buffering=True)
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
sys.path.insert(0, str(ROOT))
BOARDS = [
("6 bi", {"cue": [0.50, 0.42], "1": [0.28, 0.95], "2": [0.66, 1.22],
"3": [0.80, 0.60], "5": [0.36, 1.55], "9": [0.55, 1.70]}),
("4 bi", {"cue": [0.45, 0.30], "1": [0.32, 1.09], "2": [0.74, 0.95],
"9": [0.50, 1.29]}),
]
ALTERNATIVES = 3
def _max_diff(a, b):
"""Sai khác lớn nhất giữa hai polyline cùng độ dài (mét)."""
return max(max(abs(p[0] - q[0]), abs(p[1] - q[1])) for p, q in zip(a, b))
def cmp_traj(tag, got, ref) -> bool:
"""So hai dict {ball_id: [[x, y], ...]}. In nguyên chỗ lệch nếu có."""
if set(got) != set(ref):
print(f" [LỆCH] {tag}: tập bi khác nhau — "
f"api={sorted(got)} ref={sorted(ref)}")
return False
bad = []
for bid in sorted(ref):
g, r = got[bid], ref[bid]
if len(g) != len(r):
bad.append(f"bi {bid}: {len(g)} điểm vs {len(r)}")
elif g != r:
bad.append(f"bi {bid}: lệch tối đa {_max_diff(g, r):.3e} m")
if bad:
print(f" [LỆCH] {tag}: " + " | ".join(bad))
return False
npt = sum(len(v) for v in ref.values())
print(f" [OK] {tag}: {len(ref)} bi, {npt} điểm — khớp TỪNG ĐIỂM")
return True
def cmp_final(tag, got, ref) -> bool:
"""So hai dict balls_final dạng JSON ({'x':..,'y':..} | None)."""
if set(got) != set(ref):
print(f" [LỆCH] {tag}: tập bi khác nhau")
return False
bad = [bid for bid in ref if got[bid] != ref[bid]]
if bad:
for bid in bad:
print(f" [LỆCH] {tag}: bi {bid} traj={got[bid]} recommend={ref[bid]}")
return False
print(f" [OK] {tag}: {len(ref)} bi khớp")
return True
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--boards", type=int, default=len(BOARDS))
args = ap.parse_args()
import numpy as np
from fastapi.testclient import TestClient
from app import main as app_main
print("[lazy] khởi động app (JIT Numba ~40s lần đầu)...", flush=True)
with TestClient(app_main.app) as client:
t0 = time.time()
while True:
h = client.get("/api/health").json()
if h["status"] == "error":
print(f"[lazy] app lỗi khởi động: {h['error']}")
return 2
if h["status"] == "ok":
break
if time.time() - t0 > 300:
print("[lazy] quá 300s vẫn chưa 'ok' — dừng")
return 2
time.sleep(2)
print(f"[lazy] app sẵn sàng sau {time.time() - t0:.1f}s", flush=True)
from poolcoach_rl.envs import PositionPlayEnv
from poolcoach_rl.recommend import recommend_v2
# env RIÊNG, không dùng lại `state.env_h` — cùng lý do check_api_drift:
# bằng chứng phải là "một script bất kỳ dựng env của nó", không phải
# "cùng một object đi qua hai lối vào".
env_h = PositionPlayEnv()
ok = True
for name, balls in BOARDS[:args.boards]:
print(f"\n[{name}] {len(balls) - 1} bi mục tiêu")
body = {"balls": {b: {"x": xy[0], "y": xy[1]}
for b, xy in balls.items()},
"alternatives": ALTERNATIVES}
r = client.post("/api/recommend", json=body)
if r.status_code != 200:
print(f" API {r.status_code}: {r.json().get('detail')}")
ok = False
continue
data = r.json()
shots = data["shots"]
if not shots:
print(" hết đường trên thế bàn này — bỏ qua "
f"(message: {data.get('message')!r})")
continue
# ---- G6.1
have = [i for i, s in enumerate(shots) if s["trajectories"]]
g61 = have == [0]
print(f" G6.1 render lười: {len(shots)} cú, có quỹ đạo ở "
f"index {have}"
+ (" — ĐẠT" if g61 else " — ĐỎ, phải là ĐÚNG [0]"))
ok &= g61
# ---- tham chiếu: bản n_render = MỌI cú, gọi thẳng thư viện
ref = recommend_v2(
{b: np.array(xy) for b, xy in balls.items()},
env_h=env_h, alternatives=ALTERNATIVES, n_render=len(shots))
if len(ref.shots) != len(shots):
print(f" [LỆCH] số cú khác nhau: api {len(shots)} vs "
f"lib {len(ref.shots)} — DRIFT, dừng thế bàn này")
ok = False
continue
# ---- G6.2 + G6.2b, cho MỌI cú (kể cả rank 1: nó phải khớp
# chính nó, và đó là đối chứng cho ba cú kia)
for i, s in enumerate(shots):
tr = client.post("/api/trajectory", json={
"balls": body["balls"], "phi": s["phi"], "v0": s["v0"],
"side": s["side"], "vert": s["vert"]})
if tr.status_code != 200:
print(f" /api/trajectory cú {i}: {tr.status_code} "
f"{tr.json().get('detail')}")
ok = False
continue
td = tr.json()
ok &= cmp_traj(f"G6.2 cú {i + 1} (phi={s['phi']:.6f} "
f"v0={s['v0']:.6f})",
td["trajectories"],
ref.shots[i].trajectories or {})
ok &= cmp_final(f"G6.2b cú {i + 1} balls_final",
td["balls_final"],
s["outcome"]["balls_final"])
print("\n[lazy] G6 " + ("XANH — render lười không đổi một điểm quỹ đạo nào"
if ok else "ĐỎ — DỪNG và ghi HANDOFF"))
return 0 if ok else 1
if __name__ == "__main__":
raise SystemExit(main())