"""Gate G3 + G4 của ZonePlanner V2 trên SIM THẬT — thế dựng tay (31/07/2026). Vai trò: vế "sim thật" của hai gate mà ``tests/test_zone_v2.py`` chỉ khoá được phần logic (conftest cố ý nhét stub pooltool rỗng). Chạy: python scripts/check_zone_v2_gates.py Kiểm những gì (BRIEF 31/07): G3.1 thế có double-kiss / bi cái đụng bi sau va chạm → ứng viên bị (iii) loại, và bb_events thật sự ghi cú chạm thứ hai của cue. G3.2 (iv) khớp số đo: mọi ứng viên QUA lọc có d ∈ [0.15, 1.50]; mọi ứng viên chết (iv) có d ngoài dải; phải thấy CẢ hai phía trên bộ thế. G3.3 (v) so bằng giá trị grid — 0.5 loại, 0.8888... qua. G4.1-2 cú được chọn ĐÚNG BẰNG min lexicographic của pool. G4.3 ứng viên có chạm băng sau va chạm: roll_len > Euclid(điểm chạm → điểm đáp) — bắt lỗi cài nhầm đường thẳng. Điểm chạm đọc lại từ history của chính sim đó. Thoát code 0 = mọi gate xanh; khác 0 = có gate đỏ (in rõ gate nào). """ from __future__ import annotations import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "src")) import numpy as np # noqa: E402 FAIL = [] def check(name, cond, detail=""): tag = "XANH" if cond else "DO" print(f" [{tag}] {name}" + (f" — {detail}" if detail else "")) if not cond: FAIL.append(name) # Bốn thế dựng tay. Toạ độ mét, bàn 0.9906 × 1.9812. BOARDS = { # Bi kế "2" chắn ngay đường lăn của cú follow thẳng: các ứng viên follow # đụng nó sau va chạm → (iii); các ứng viên khác thoát → còn cú để chọn. "blocker_tren_duong_lan": { "cue": (0.45, 0.30), "1": (0.42, 0.95), "2": (0.38, 1.45), "9": (0.80, 0.40)}, # Bi mục tiêu sát băng trên, đánh thẳng lên: bi 1 dội băng quay lại gặp # bi cái đang lăn tới — double-kiss cue↔1 thật (không pot được, nhưng # (iii) phải GHI NHẬN được cú chạm thứ hai). "doi_bang_double_kiss": { "cue": (0.45, 1.20), "1": (0.45, 1.80), "2": (0.15, 0.40), "9": (0.80, 0.40)}, # Bi kế "2" đặt SÁT một điểm đáp đã đo (cú kéo vert=-0.2 v0=3.222 đáp # (0.326, 1.259) trên thế nền — dò bằng probe 31/07), lệch hẳn đường pot: # cú đó phải chết (iv) phía DƯỚI (d ≈ 0.09 < 0.15). "bi_ke_sat_diem_dap": { "cue": (0.45, 0.30), "1": (0.42, 0.95), "2": (0.26, 1.20), "9": (0.80, 0.40)}, # Bi kế tít góc trên trong khi bi cái đánh nhẹ từ dưới: cú lực yếu đáp xa # bi kế hơn 1.5 m → (iv) phía trên phải xuất hiện. "bi_ke_rat_xa": { "cue": (0.45, 0.25), "1": (0.42, 0.60), "2": (0.85, 1.92), "9": (0.15, 1.60)}, } def main(): import pooltool as pt from poolcoach_rl.envs import PositionPlayEnv from poolcoach_rl.recommend import warmup from poolcoach_rl.recommend.zone_v2 import (D_MAX, D_MIN, force_ok, plan_shot_v2, rank_key_v2, v2_specs) from poolcoach_rl.recommend.zone_v2 import default_v0_grid env_h = PositionPlayEnv() print("[gates] warmup JIT (~40s lan dau)...", flush=True) warmup(env_h) # ---- G3.3: (v) so bằng giá trị grid — thuần, kiểm luôn cho đủ hồ sơ v0s = default_v0_grid() print("\n[G3.3] nấc lực so bằng giá trị grid") check("v0_grid[0]=0.5 bị loại", not force_ok(float(v0s[0]))) check(f"v0_grid[1]={float(v0s[1]):.10f} qua", force_ok(float(v0s[1]))) check("literal 0.889 KHÔNG qua", not force_ok(0.889)) check("specs V2 = 99 ô, không nấc 1", len(v2_specs()) == 99 and all(v0 > 0.5 for _a, _b, v0 in v2_specs())) ball_r = 0.028575 seen = {"iii": 0, "iii_double_kiss": 0, "iv_gan": 0, "iv_xa": 0, "pass": 0, "cushion_ok": 0} for name, raw in BOARDS.items(): balls = {bid: np.array(xy, dtype=np.float64) for bid, xy in raw.items()} target = "1" plan = plan_shot_v2(env_h, balls, target) key = rank_key_v2() print(f"\n[{name}] n_cand={plan.n_cand} " f"pass={len(plan.pool)} " f"fails={[ (p.rank, p.fail_counts) for p in plan.pockets ]}") # --- thu thập bằng chứng qua các ứng viên của mọi lỗ đã thử # (plan chỉ giữ pool của lỗ chọn; muốn soi từng ứng viên thì đi lại # từng lỗ — dùng chính specs + sim để không chép lại máy lọc) from poolcoach_rl.recommend.rules import judge_shot from poolcoach_rl.recommend.simulate import simulate_shot_multi from poolcoach_rl.recommend.zone_v2 import (cue_hits_after_contact, target_path_dirty) for pk in plan.picks[:1 if plan.pocket_rank in (None, 1) else 2]: for side, vert, v0 in v2_specs(): m = simulate_shot_multi(env_h, balls, pk.phi, v0, side, vert) if m is None: continue j = judge_shot(env_h, m, balls, target) bb = m["bb_events"] if cue_hits_after_contact(bb): seen["iii"] += 1 cue_evs = [ids for _t, ids in bb if "cue" in ids] if len(cue_evs) > 1 and target in cue_evs[1]: seen["iii_double_kiss"] += 1 clean = (not j["foul"] and list(m["potted"]) == [target] and not target_path_dirty(bb, target) and not cue_hits_after_contact(bb)) if clean and j["next"] is not None: cf = m["balls_final"]["cue"] d = float(np.linalg.norm(cf - balls[j["next"]])) if d < D_MIN: seen["iv_gan"] += 1 elif d > D_MAX: seen["iv_xa"] += 1 # --- bất biến của kết quả if plan.pool: seen["pass"] += len(plan.pool) check(f"{name}: mọi cú qua lọc có d trong [{D_MIN}, {D_MAX}]", all(c.d_land is None or D_MIN <= c.d_land <= D_MAX for c in plan.pool)) check(f"{name}: shot = min lexicographic của pool (G4.1-2)", plan.shot is min(plan.pool, key=key) and sorted(plan.pool, key=key)[0] is plan.shot) check(f"{name}: mọi cú qua lọc KHÔNG có double-kiss/đụng bi", all(not cue_hits_after_contact(c.facts["bb_events"]) and not target_path_dirty(c.facts["bb_events"], target) for c in plan.pool)) # --- G4.3: cú có chạm băng sau va chạm → roll > Euclid chạm→đáp for c in plan.pool: sysm = simulate_shot_multi(env_h, balls, c.phi, c.v0, c.side, c.vert, render=True) if sysm is None: continue system = sysm["system"] t1 = next((ev.time for ev in system.events if ev.event_type == pt.EventType.BALL_BALL and "cue" in ev.ids), None) has_cush = any( ev.event_type in (pt.EventType.BALL_LINEAR_CUSHION, pt.EventType.BALL_CIRCULAR_CUSHION) and "cue" in ev.ids and ev.time > t1 for ev in system.events) if not has_cush: continue rvw, _ss, ts = system.balls["cue"].history.vectorize() i1 = int(np.argmax(np.asarray(ts) >= t1 - 1e-12)) contact = rvw[i1, 0, :2] final = sysm["balls_final"]["cue"] if final is None: continue euclid = float(np.linalg.norm(final - contact)) check(f"{name}: G4.3 roll {c.roll_len:.3f} > euclid " f"{euclid:.3f} (cú dội băng v0={c.v0:.2f})", c.roll_len > euclid + 1e-4) seen["cushion_ok"] += 1 break print("\n[tổng hợp bằng chứng trên cả bộ thế]") check("G3.1: có ứng viên chết (iii)", seen["iii"] > 0, f"{seen['iii']} ứng viên") check("G3.1: trong đó có double-kiss cue↔target thật", seen["iii_double_kiss"] > 0, f"{seen['iii_double_kiss']} ứng viên") check("G3.2: thấy cú pot sạch đáp GẦN hơn 0.15 m (bị iv loại)", seen["iv_gan"] > 0, f"{seen['iv_gan']} ứng viên") check("G3.2: thấy cú pot sạch đáp XA hơn 1.50 m (bị iv loại)", seen["iv_xa"] > 0, f"{seen['iv_xa']} ứng viên") check("có cú QUA đủ lọc để app còn thứ mà gợi ý", seen["pass"] > 0, f"{seen['pass']} cú") check("G4.3 đã kiểm được trên ≥1 cú dội băng", seen["cushion_ok"] > 0, f"{seen['cushion_ok']} cú") print(f"\n[gates] {'XANH — tất cả' if not FAIL else 'ĐỎ: ' + str(FAIL)}") return 1 if FAIL else 0 if __name__ == "__main__": sys.exit(main())