File size: 8,797 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
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
"""G6.1 + G6.2 — chứng minh RENDER LƯỜI đúng là lười, và không đổi quỹ đạo.

31/07/2026: app chỉ còn MỘT đường (zone V2) — bản tham chiếu gọi thẳng
``recommend_v2`` với ``n_render`` đủ mọi cú; không còn vòng lặp engine.

Chạy (venv, từ gốc repo):
    python scripts/check_lazy_render.py
    python scripts/check_lazy_render.py --boards 1

Vì sao ở `scripts/` chứ không ở `tests/`: `conftest.py` cố ý nhét stub pooltool
RỖNG, nên test app không chạm được tầng vật lý — mà cả hai câu hỏi dưới đây đều
là câu hỏi về sim thật. Cùng lý do `check_api_drift.py` tồn tại ngoài `tests/`.

HAI PHÉP KIỂM:

  G6.1  `/api/recommend` trả ĐÚNG MỘT cú kèm `trajectories`, và đó là cú rank 1.
        Ba cú thay thế có đủ tham số nhưng `trajectories` rỗng.

  G6.2  `/api/trajectory` trả quỹ đạo KHỚP quỹ đạo mà `n_render = 1 +
        alternatives` sẽ trả cho CÙNG cú đó. So từng điểm, không dung sai: sim
        tất định thì hoặc khớp tuyệt đối, hoặc hai đường đã khác nhau ở đâu đó.

        Tham số cú gửi đi là tham số **lấy từ JSON của `/api/recommend`** — tức
        đúng thứ FE có trong tay, không phải float gốc trong bộ nhớ server. Đó
        là chỗ phép kiểm này có răng: bản trước 30/07 làm tròn `phi` 2 chữ số
        và `v0` 3 chữ số, nên `v0` lưới 0.8888888888888888 xuống JSON thành
        0.889 và lần sim lại chạy ở một ô KHÁC. Không có phép kiểm này thì sai
        lệch đó chỉ hiện ra dưới dạng "cú thay thế vẽ hơi lệch", không ai bắt.

  G6.2b `balls_final` của `/api/trajectory` khớp `outcome.balls_final` mà
        `/api/recommend` đã trả cho cú đó. Vế này KHÁC G6.2 và cũng cần: FE
        animate theo quỹ đạo lười rồi `applyFinal()` áp `outcome.balls_final`
        (từ sim của search, không phải sim render). Hai số lệch nhau thì bi
        NHẢY một đoạn ở khung hình cuối.

Thế bàn CỐ ĐỊNH, không random — cùng lý do `check_api_drift.BOARDS`: một thế
bàn ngẫu nhiên làm "chạy lại thấy xanh" không nói lên điều gì.

Lệch → DỪNG, ghi HANDOFF (BRIEF 30/07, mục "Nếu bí").
"""

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

BOARDS = [
    ("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]}),
    ("4 bi", {"cue": [0.45, 0.30], "1": [0.32, 1.09], "2": [0.74, 0.95],
              "9": [0.50, 1.29]}),
]

ALTERNATIVES = 3


def _max_diff(a, b):
    """Sai khác lớn nhất giữa hai polyline cùng độ dài (mét)."""
    return max(max(abs(p[0] - q[0]), abs(p[1] - q[1])) for p, q in zip(a, b))


def cmp_traj(tag, got, ref) -> bool:
    """So hai dict {ball_id: [[x, y], ...]}. In nguyên chỗ lệch nếu có."""
    if set(got) != set(ref):
        print(f"  [LỆCH] {tag}: tập bi khác nhau — "
              f"api={sorted(got)} ref={sorted(ref)}")
        return False
    bad = []
    for bid in sorted(ref):
        g, r = got[bid], ref[bid]
        if len(g) != len(r):
            bad.append(f"bi {bid}: {len(g)} điểm vs {len(r)}")
        elif g != r:
            bad.append(f"bi {bid}: lệch tối đa {_max_diff(g, r):.3e} m")
    if bad:
        print(f"  [LỆCH] {tag}: " + " | ".join(bad))
        return False
    npt = sum(len(v) for v in ref.values())
    print(f"  [OK]    {tag}: {len(ref)} bi, {npt} điểm — khớp TỪNG ĐIỂM")
    return True


def cmp_final(tag, got, ref) -> bool:
    """So hai dict balls_final dạng JSON ({'x':..,'y':..} | None)."""
    if set(got) != set(ref):
        print(f"  [LỆCH] {tag}: tập bi khác nhau")
        return False
    bad = [bid for bid in ref if got[bid] != ref[bid]]
    if bad:
        for bid in bad:
            print(f"  [LỆCH] {tag}: bi {bid} traj={got[bid]} recommend={ref[bid]}")
        return False
    print(f"  [OK]    {tag}: {len(ref)} bi khớp")
    return True


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--boards", type=int, default=len(BOARDS))
    args = ap.parse_args()

    import numpy as np
    from fastapi.testclient import TestClient

    from app import main as app_main

    print("[lazy] 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"[lazy] app lỗi khởi động: {h['error']}")
                return 2
            if h["status"] == "ok":
                break
            if time.time() - t0 > 300:
                print("[lazy] quá 300s vẫn chưa 'ok' — dừng")
                return 2
            time.sleep(2)
        print(f"[lazy] app sẵn sàng sau {time.time() - t0:.1f}s", flush=True)

        from poolcoach_rl.envs import PositionPlayEnv
        from poolcoach_rl.recommend import recommend_v2

        # env RIÊNG, không dùng lại `state.env_h` — cùng lý do check_api_drift:
        # bằng chứng phải là "một script bất kỳ dựng env của nó", 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")
            body = {"balls": {b: {"x": xy[0], "y": xy[1]}
                              for b, xy in balls.items()},
                    "alternatives": ALTERNATIVES}
            r = client.post("/api/recommend", json=body)
            if r.status_code != 200:
                print(f"  API {r.status_code}: {r.json().get('detail')}")
                ok = False
                continue
            data = r.json()
            shots = data["shots"]
            if not shots:
                print("  hết đường trên thế bàn này — bỏ qua "
                      f"(message: {data.get('message')!r})")
                continue

            # ---- G6.1
            have = [i for i, s in enumerate(shots) if s["trajectories"]]
            g61 = have == [0]
            print(f"  G6.1 render lười: {len(shots)} cú, có quỹ đạo ở "
                  f"index {have}"
                  + ("  — ĐẠT" if g61 else "  — ĐỎ, phải là ĐÚNG [0]"))
            ok &= g61

            # ---- tham chiếu: bản n_render = MỌI cú, gọi thẳng thư viện
            ref = recommend_v2(
                {b: np.array(xy) for b, xy in balls.items()},
                env_h=env_h, alternatives=ALTERNATIVES, n_render=len(shots))
            if len(ref.shots) != len(shots):
                print(f"  [LỆCH] số cú khác nhau: api {len(shots)} vs "
                      f"lib {len(ref.shots)} — DRIFT, dừng thế bàn này")
                ok = False
                continue

            # ---- G6.2 + G6.2b, cho MỌI cú (kể cả rank 1: nó phải khớp
            #      chính nó, và đó là đối chứng cho ba cú kia)
            for i, s in enumerate(shots):
                tr = client.post("/api/trajectory", json={
                    "balls": body["balls"], "phi": s["phi"], "v0": s["v0"],
                    "side": s["side"], "vert": s["vert"]})
                if tr.status_code != 200:
                    print(f"  /api/trajectory cú {i}: {tr.status_code} "
                          f"{tr.json().get('detail')}")
                    ok = False
                    continue
                td = tr.json()
                ok &= cmp_traj(f"G6.2  cú {i + 1} (phi={s['phi']:.6f} "
                               f"v0={s['v0']:.6f})",
                               td["trajectories"],
                               ref.shots[i].trajectories or {})
                ok &= cmp_final(f"G6.2b cú {i + 1} balls_final",
                                td["balls_final"],
                                s["outcome"]["balls_final"])

    print("\n[lazy] G6 " + ("XANH — render lười không đổi một điểm quỹ đạo nào"
                            if ok else "ĐỎ — DỪNG và ghi HANDOFF"))
    return 0 if ok else 1


if __name__ == "__main__":
    raise SystemExit(main())