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