"""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())