Spaces:
Sleeping
Sleeping
File size: 7,985 Bytes
78738de | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 | """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())
|