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