poolcoach / scripts /check_api_drift.py
masterdanh's picture
deploy: snapshot for HF Space
78738de
Raw
History Blame Contribute Delete
7.99 kB
"""Chống drift: cú top-1 qua API phải TRÙNG cú gọi thẳng recommend_v2.
(Tiền lệ `cfce062` — gate G2 phiên 29/07, G4 phiên 30/07 so API với
`recommend_full` cho oracle/hybrid/zone-V1. **31/07**: app chỉ còn MỘT đường
— zone V2 — nên phép kiểm thu về "API ≡ `recommend_v2`", MỞ RỘNG thêm hai vế
theo gate G6: cú cuối ván, và request cũ còn gửi `engine`/`topk` phải cho
ĐÚNG cùng một cú như request mới.)
Chạy (venv, từ gốc repo):
python scripts/check_api_drift.py
python scripts/check_api_drift.py --boards 2
Vì sao tồn tại: test trong `tests/` chỉ khoá được SHAPE (conftest nhét stub
pooltool rỗng, cố ý không có vật lý), nên "hai đường cho ra cùng một cú"
phải được kiểm ở đây, với sim thật.
So `phi`/`v0`/`side`/`vert` float THÔ (API không làm tròn chúng từ 30/07 —
chúng là đầu vào của `/api/trajectory`), `ev` + `roll_len`/`d_land` tròn 4
chữ số đúng như `_shot_full_out`. `elapsed_s` cố ý KHÔNG so: nó là đồng hồ,
không phải kết quả.
Lệch → DỪNG, ghi HANDOFF. Lệch nghĩa là hai đường vốn chưa bao giờ tương
đương, và đó là phát hiện chứ không phải lỗi vặt.
"""
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))
# Thế bàn cố định — KHÔNG random: script này là bằng chứng tái lập được, và
# một thế bàn ngẫu nhiên khiến "chạy lại thấy xanh" không nói lên điều gì.
# Ba thế đầu như bản 29-30/07 để chuỗi bằng chứng nối được; thế cuối ván kiểm
# đúng vế G6 "bàn chỉ còn bi 9 → vẫn trả cú hợp lệ hoặc hết-đường đúng chuẩn".
BOARDS = [
("4 bi", {"cue": [0.45, 0.30], "1": [0.32, 1.09], "2": [0.74, 0.95],
"9": [0.50, 1.29]}),
("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]}),
("cuoi van (chi bi 9)", {"cue": [0.40, 0.55], "9": [0.62, 1.35]}),
]
FIELDS = ("phi", "v0", "side", "vert")
V2_FIELDS = ("rank_by", "roll_len", "d_land")
def api_shot(client, balls, extra=None):
"""Cú rank 1 qua HTTP. ``extra`` trộn thêm trường request cũ
(engine/topk) — chúng phải bị bỏ qua, không đổi kết quả."""
body = {"balls": {b: {"x": xy[0], "y": xy[1]} for b, xy in balls.items()},
"alternatives": 3}
body.update(extra or {})
t0 = time.time()
r = client.post("/api/recommend", json=body)
el = time.time() - t0
if r.status_code != 200:
raise SystemExit(f"API {r.status_code}: {r.json().get('detail')}")
d = r.json()
top = d["shots"][0] if d["shots"] else None
return top, d["search"], d.get("message"), el
def lib_shot(env_h, balls):
"""Cú rank 1 gọi THẲNG thư viện — không qua FastAPI.
Chuẩn hoá Y HỆT `_shot_full_out` để so được với JSON của API: 4 tham số
cú để nguyên float, các số đo tròn 4 chữ số (`_opt_float`).
"""
import numpy as np
from poolcoach_rl.recommend import recommend_v2
res = recommend_v2({b: np.array(xy) for b, xy in balls.items()},
env_h=env_h, alternatives=3, n_render=1)
if not res.shots:
return None, res.engine
s = res.shots[0]
def _r4(v):
return None if v is None else round(float(v), 4)
return {"phi": float(s.phi), "v0": float(s.v0), "side": float(s.side),
"vert": float(s.vert), "ev": round(float(s.ev), 4),
"rank_by": s.rank_by, "roll_len": _r4(s.roll_len),
"d_land": _r4(s.d_land)}, res.engine
def compare(tag, api, search, message, lib, lib_engine) -> bool:
if (api is None) != (lib is None):
print(f" [DRIFT] {tag}: một đường có cú, đường kia không "
f"(api={api is not None}, lib={lib is not None})")
return False
if search["engine"] != lib_engine:
print(f" [DRIFT] {tag}: engine khai khác nhau — "
f"api={search['engine']} lib={lib_engine}")
return False
if api is None:
# G6/G5.2: hết đường phải là 200 + message trung thực, không 500
if not message or "5 tiêu chí" not in message:
print(f" [DRIFT] {tag}: hết đường nhưng message sai chuẩn: "
f"{message!r}")
return False
print(f" [OK] {tag}: cả hai đường cùng hết-đường, message chuẩn")
return True
diffs = [f"{k}: api={api[k]} lib={lib[k]}"
for k in FIELDS + V2_FIELDS if api[k] != lib[k]]
if api["outcome"]["ev"] != lib["ev"]:
diffs.append(f"ev: api={api['outcome']['ev']} lib={lib['ev']}")
if diffs:
print(f" [DRIFT] {tag}: " + " | ".join(diffs))
return False
d_txt = "cuối ván" if api["d_land"] is None else f"d={api['d_land']}"
print(f" [OK] {tag}: phi={api['phi']:.2f} v0={api['v0']:.3f} "
f"side={api['side']:+.1f} vert={api['vert']:+.1f} "
f"lăn={api['roll_len']} {d_txt}")
return True
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--boards", type=int, default=len(BOARDS),
help="số thế bàn đầu trong BOARDS đem chạy")
args = ap.parse_args()
from fastapi.testclient import TestClient
from app import main as app_main
print("[drift] 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"[drift] app lỗi khởi động: {h['error']}")
return 2
if h["status"] == "ok":
break
if time.time() - t0 > 300:
print("[drift] quá 300s vẫn chưa 'ok' — dừng")
return 2
time.sleep(2)
print(f"[drift] app sẵn sàng sau {time.time() - t0:.1f}s", flush=True)
from poolcoach_rl.envs import PositionPlayEnv
# env RIÊNG, không dùng lại state.env_h: bằng chứng phải là "một script
# bất kỳ dựng env của nó rồi gọi thư viện", chứ 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")
lib, lib_eng = lib_shot(env_h, balls)
# request MỚI (không engine) + ba biến thể request CŨ — tất cả
# phải cho ĐÚNG một cú (G5.5: engine/topk bị bỏ qua có chủ đích)
for tag, extra in (("request moi ", None),
("engine=oracle cu", {"engine": "oracle"}),
("engine=hybrid cu", {"engine": "hybrid",
"topk": 80}),
("engine=zone cu ", {"engine": "zone"})):
a, search, msg, el = api_shot(client, balls, extra)
ok &= compare(f"{tag} ({el:.2f}s API)", a, search, msg,
lib, lib_eng)
print("\n[drift] G6 " + ("XANH — API ≡ recommend_v2 trên mọi biến thể "
"request" if ok else
"ĐỎ — CÓ DRIFT, dừng và ghi HANDOFF"))
return 0 if ok else 1
if __name__ == "__main__":
raise SystemExit(main())