Spaces:
Sleeping
Sleeping
| """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()) | |