"""Nghiệm thu engine app trên bàn random — bản V2 (31/07/2026). Gate giữ NGUYÊN nghĩa từ phase B: (1) KHÔNG cú gợi ý nào phạm luật, (2) search median < 5s/cú. Engine đo là ZonePlanner V2 (`recommend_v2`) — đường duy nhất còn trên `main` từ 31/07; bản đo oracle/hybrid sống ở nhánh `v1-full` (mọi đối chứng sau này chạy ở đó, không phải ở đây). Bàn: nửa đầu đủ 10 bi (cue + 1..9), nửa sau mid-game (cue + subset ngẫu nhiên 2-6 bi, luôn giữ bi 9). Mỗi bàn gọi `recommend_v2` → check TỪNG cú trả về, độc lập với máy lọc của chính V2: foul=False, scratch=False, pot đúng target, KHÔNG bi nào khác rơi kèm (tiêu chí (i) — ăn trực tiếp). Số "bàn hết đường" ở khối cuối KHÔNG phải lỗi: V2 cố ý không thang nới (design §8.1), tỉ lệ này là số phải báo cáo cùng mọi kết quả khác. Chạy: python scripts/eval_fullrack.py --tables 20 """ from __future__ import annotations import argparse import statistics import sys import time from pathlib import Path import numpy as np sys.stdout.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8") ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "src")) def _place_random(rng, w, l, n, ball_r, min_gap=1.1): pts = [] while len(pts) < n: p = np.array([rng.uniform(2 * ball_r, w - 2 * ball_r), rng.uniform(2 * ball_r, l - 2 * ball_r)]) if all(np.linalg.norm(p - q) >= 2 * ball_r * min_gap for q in pts): pts.append(p) return pts def _gen_table(rng, w, l, ball_r, full): if full: ids = [str(i) for i in range(1, 10)] else: n = int(rng.integers(2, 7)) # 2-6 bi mục tiêu lows = sorted(rng.choice(np.arange(1, 9), size=n - 1, replace=False)) ids = [str(int(i)) for i in lows] + ["9"] # luôn giữ bi 9 pts = _place_random(rng, w, l, len(ids) + 1, ball_r) return dict(zip(["cue"] + ids, pts)) def main(): ap = argparse.ArgumentParser() ap.add_argument("--tables", type=int, default=20) ap.add_argument("--seed", type=int, default=42) ap.add_argument("--alternatives", type=int, default=3) args = ap.parse_args() from poolcoach_rl.envs import PositionPlayEnv from poolcoach_rl.envs.position_env import BALL_R from poolcoach_rl.recommend import recommend_v2, warmup env_h = PositionPlayEnv() w, l = env_h.w, env_h.l rng = np.random.default_rng(args.seed) print("JIT warmup process chính...", flush=True) print(f" xong sau {warmup(env_h):.1f}s") times, violations = [], [] n_with_shot, n_win_top, rolls, v0s = 0, 0, [], [] for k in range(args.tables): full = k < args.tables // 2 balls = _gen_table(rng, w, l, BALL_R, full) t0 = time.time() res = recommend_v2(balls, env_h=env_h, alternatives=args.alternatives) dt = time.time() - t0 times.append(dt) tag = "full-9" if full else f"mid-{len(balls) - 1}bi" for s in res.shots: # re-check độc lập từng điều kiện (không tin máy lọc của V2) errs = [] if s.foul: errs.append("foul=True") if s.scratch: errs.append("scratch") if s.target not in s.potted: errs.append("không pot được bi target") if sorted(s.potted) != [s.target]: errs.append(f"rơi kèm bi khác {s.potted} — vi phạm (i)") for e in errs: violations.append(f"bàn {k} ({tag}) rank {s.rank}: {e}") if res.shots: n_with_shot += 1 top = res.shots[0] n_win_top += int(top.win) rolls.append(float(top.roll_len)) v0s.append(float(top.v0)) d_txt = ("cuối ván" if top.d_land is None else f"d {top.d_land:.2f} m") print(f" bàn {k:2d} {tag:8s}: {dt:5.2f}s, target {res.target}, " f"{res.n_legal_pot:3d} cú đạt tiêu chí, top lăn " f"{top.roll_len:.2f} m, {d_txt}" f"{' WIN' if top.win else ''}", flush=True) else: print(f" bàn {k:2d} {tag:8s}: {dt:5.2f}s, target {res.target}, " f"HẾT ĐƯỜNG ({res.n_results} sim) — nằm trong dự tính V2", flush=True) med, mx = statistics.median(times), max(times) n_dry = args.tables - n_with_shot print("\n=== NGHIỆM THU ENGINE V2 ===") print(f" {args.tables} bàn | search median {med:.2f}s | max {mx:.2f}s") print(f" bàn có cú gợi ý : {n_with_shot}/{args.tables}" f" (hết đường: {n_dry} — SỐ PHẢI BÁO, không phải lỗi)") print(f" top-1 WIN : {n_win_top}") if rolls: print(f" đường lăn top-1 : mean {statistics.mean(rolls):.2f} m" f" max {max(rolls):.2f} m") print(f" V0 top-1 : mean {statistics.mean(v0s):.2f}" f" max {max(v0s):.2f} (soi bão hoà biên §8.4(d))") print(f" cú phạm luật trong gợi ý: {len(violations)}") for v in violations: print(f" VI PHẠM: {v}") ok_legal, ok_speed = not violations, med < 5.0 print(f" [{'ĐẠT' if ok_legal else 'FAIL'}] 0 cú phạm luật" f" [{'ĐẠT' if ok_speed else 'FAIL'}] search median < 5s (serial)") sys.exit(0 if (ok_legal and ok_speed) else 1) if __name__ == "__main__": main()