Spaces:
Sleeping
Sleeping
| """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()) | |