#!/usr/bin/env python3 """Oracle đo TRẦN controllability của position play (stage 2a). Bối cảnh (14-16/07/2026): Q|pot của agent dính chặt mốc position-blind 0.53 qua MỌI config. Hai giả thuyết đầu đã bị bác: (1) tín hiệu yếu — POS_COEF x2 không đổi gì (14/07) (2) aim khoá lỗ — aim_mode="any" fine-tune, eval 1000 cú: Q|pot 0.522 [0.489, 0.556], vẫn = 0.53 (16/07) Còn lại giả thuyết (3): TRẦN controllability — với skill-set 1 cú hiện tại (aim ghost-ball + V0 + spin), Q tốt nhất CÓ THỂ đạt là bao nhiêu? Cách đo: sample N bàn (cùng phân phối với PositionPlayEnv.reset). Mỗi bàn: aim CỐ ĐỊNH theo ghost-ball của từng lỗ khả thi (logic _ghost_dirs_any), grid search V0 x side x vert, simulate tất cả, lấy max Q trên các cú (pot && !scratch). Phân phối best-Q per bàn = trần controllability. Đọc kết quả: trần ~0.55-0.6 → agent (0.53) đã gần tối ưu — vấn đề là TASK, không phải reward; cân nhắc nới task (bàn nhỏ, bi gần lỗ) hoặc chấp nhận trần và ghi vào luận văn trần >= 0.75 → gap là THẬT, agent chưa học điều bi — quay lại nghĩ cách dạy (curriculum, oracle-guided, reward khác) Kèm trần NO-SPIN (a=b=0) để tách riêng: spin mua được bao nhiêu Q? Chạy từ gốc repo (Numba JIT ~40s/worker lúc khởi động): python scripts/oracle_controllability.py --tables 100 --workers 4 python scripts/oracle_controllability.py --tables 20 --workers 2 # smoke Output: logs/oracle_/{summary.txt, tables.csv, pot_combos.csv, histogram.png} """ from __future__ import annotations import argparse import csv import multiprocessing as mp import sys import time from pathlib import Path 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")) # Mốc tham chiếu cho phần so sánh trong summary BLIND_Q = 0.531 # model stage 1 position-blind, eval 1000 cú (14/07) AGENT_Q = 0.522 # best_model aim-any fine-tune, eval 1000 cú (16/07) # --- state per worker (khởi tạo 1 lần, tránh pickle env qua Pool) --- _ENV = None _GRIDS = None # (v0_grid, side_grid, vert_grid, phi_jitter_deg) def _init_worker(v0_grid, side_grid, vert_grid, phi_jitter_deg): """Tạo env helper + trả JIT Numba NGAY để ETA về sau chính xác.""" global _ENV, _GRIDS from poolcoach_rl.envs import PositionPlayEnv _ENV = PositionPlayEnv() _GRIDS = (v0_grid, side_grid, vert_grid, phi_jitter_deg) _simulate_shot((0.3, 0.5), (0.6, 1.0), (0.6, 1.5), 90.0, 2.0, 0.0, 0.0) def _simulate_shot(cue_xy, b1_xy, b2_xy, phi, v0, a, b): """Simulate 1 cú; trả (potted, scratch, b2_potted, q). Dựng System mới mỗi cú vì pt.simulate là inplace/destructive. q chỉ có nghĩa khi pot && !scratch (theo đúng gate của env). """ import numpy as np import pooltool as pt import pooltool.constants as ptc balls = { "cue": pt.Ball.create("cue", xy=tuple(cue_xy)), "1": pt.Ball.create("1", xy=tuple(b1_xy)), "2": pt.Ball.create("2", xy=tuple(b2_xy)), } system = pt.System(table=_ENV.table, balls=balls, cue=pt.Cue(cue_ball_id="cue")) system.cue.set_state(V0=v0, phi=phi, a=a, b=b) try: pt.simulate(system, inplace=True) except Exception: return False, False, False, 0.0 def pocketed(bid): return system.balls[bid].state.s == ptc.pocketed potted, scratch, b2_potted = pocketed("1"), pocketed("cue"), pocketed("2") q = 0.0 if potted and not scratch: if b2_potted: q = 1.0 # combo may mắn — cùng quy ước với env else: cue_f = np.asarray(system.balls["cue"].state.rvw[0][:2]) b2_f = np.asarray(system.balls["2"].state.rvw[0][:2]) q = _ENV._position_q(cue_f, b2_f) return potted, scratch, b2_potted, q def _eval_table(args): """Grid search 1 bàn. Trả (idx, stats dict, list pot-combo rows).""" import numpy as np idx, cue_xy, b1_xy, b2_xy = args v0_grid, side_grid, vert_grid, jitter = _GRIDS cue_xy, b1_xy, b2_xy = map(np.asarray, (cue_xy, b1_xy, b2_xy)) # phi ứng viên: ghost-ball của mọi lỗ khả thi (+ jitter tuỳ chọn) phis = [] for d in _ENV._ghost_dirs_any(cue_xy, b1_xy): phi0 = float(np.degrees(np.arctan2(d[1], d[0])) % 360.0) offsets = [0.0] if jitter <= 0 else [-jitter, 0.0, +jitter] phis.extend((phi0 + o) % 360.0 for o in offsets) n_sims = n_pot = 0 best = {"q": -1.0, "phi": np.nan, "v0": np.nan, "a": np.nan, "b": np.nan} best_nospin = -1.0 best_xb2 = -1.0 # trần LOẠI combo b2 rớt lỗ (Q=1 may mắn thổi phồng trần) pot_rows = [] for phi in phis: for v0 in v0_grid: for a in side_grid: for b in vert_grid: n_sims += 1 potted, scratch, b2p, q = _simulate_shot( cue_xy, b1_xy, b2_xy, phi, float(v0), float(a), float(b)) if not (potted and not scratch): continue n_pot += 1 pot_rows.append([idx, round(phi, 2), float(v0), float(a), float(b), round(q, 4), int(b2p)]) if q > best["q"]: best = {"q": q, "phi": phi, "v0": float(v0), "a": float(a), "b": float(b)} if not b2p and q > best_xb2: best_xb2 = q if a == 0.0 and b == 0.0 and q > best_nospin: best_nospin = q stats = { "idx": idx, "cue_x": cue_xy[0], "cue_y": cue_xy[1], "b1_x": b1_xy[0], "b1_y": b1_xy[1], "b2_x": b2_xy[0], "b2_y": b2_xy[1], "n_phis": len(phis), "n_sims": n_sims, "n_pot": n_pot, "best_q": best["q"] if n_pot else float("nan"), "best_q_excl_b2": best_xb2 if best_xb2 >= 0 else float("nan"), "best_q_nospin": best_nospin if best_nospin >= 0 else float("nan"), "best_phi": best["phi"], "best_v0": best["v0"], "best_side": best["a"], "best_vert": best["b"], } return idx, stats, pot_rows def sample_tables(n: int, seed: int): """Sample vị trí 3 bi — cùng phân phối với PositionPlayEnv.reset.""" import numpy as np from poolcoach_rl.envs import PositionPlayEnv from poolcoach_rl.envs.position_env import BALL_R env = PositionPlayEnv() rng = np.random.default_rng(seed) margin = 4 * BALL_R tables = [] for i in range(n): placed = [] while len(placed) < 3: xy = np.array([rng.uniform(margin, env.w - margin), rng.uniform(margin, env.l - margin)]) if all(np.linalg.norm(xy - q) > 4 * BALL_R for q in placed): placed.append(xy) tables.append((i, tuple(placed[0]), tuple(placed[1]), tuple(placed[2]))) return tables def main(): p = argparse.ArgumentParser() p.add_argument("--tables", type=int, default=100) p.add_argument("--v0-steps", type=int, default=10, help="số mức V0 trong [0.5, 4.0] (khớp action map của env)") p.add_argument("--spin-steps", type=int, default=5, help="số mức side/vert trong [-0.4, 0.4]; nên LẺ để có 0") p.add_argument("--phi-jitter", type=float, default=0.0, help="thêm ±X độ quanh ghost aim (x3 chi phí; mặc định tắt)") p.add_argument("--workers", type=int, default=4) p.add_argument("--seed", type=int, default=42) p.add_argument("--run-name", default=None) args = p.parse_args() import numpy as np run = args.run_name or f"oracle_{time.strftime('%Y%m%d_%H%M%S')}" out_dir = ROOT / "logs" / run out_dir.mkdir(parents=True, exist_ok=True) v0_grid = np.linspace(0.5, 4.0, args.v0_steps) side_grid = np.linspace(-0.4, 0.4, args.spin_steps) vert_grid = np.linspace(-0.4, 0.4, args.spin_steps) tables = sample_tables(args.tables, args.seed) per_pocket = args.v0_steps * args.spin_steps ** 2 per_pocket *= 3 if args.phi_jitter > 0 else 1 print(f"== Oracle controllability: {args.tables} bàn, " f"~{per_pocket} sim/lỗ khả thi (TB ~2.2 lỗ/bàn) ==") print(f" grid: V0 {args.v0_steps} mức x side/vert {args.spin_steps} mức" f"{f' x phi ±{args.phi_jitter}°' if args.phi_jitter > 0 else ''}") print(f" {args.workers} worker — JIT Numba ~40s lúc khởi động...\n") t0 = time.time() all_stats, all_combos = [], [] with mp.Pool(args.workers, initializer=_init_worker, initargs=(v0_grid, side_grid, vert_grid, args.phi_jitter)) as pool: for k, (idx, stats, rows) in enumerate( pool.imap_unordered(_eval_table, tables), 1): all_stats.append(stats) all_combos.extend(rows) el = time.time() - t0 eta = el / k * (len(tables) - k) bq = stats["best_q"] print(f" bàn {idx:3d} ({k}/{len(tables)}): " f"pot {stats['n_pot']}/{stats['n_sims']}, " f"best Q = {'—' if np.isnan(bq) else f'{bq:.3f}'}" f" [{el/60:.1f} phút, còn ~{eta/60:.1f}]") all_stats.sort(key=lambda s: s["idx"]) total_sims = sum(s["n_sims"] for s in all_stats) el = time.time() - t0 print(f"\nXong {total_sims} sim trong {el/60:.1f} phút " f"({total_sims/el:.0f} sim/s)\n") # ---------------------------------------------------------------- CSV with open(out_dir / "tables.csv", "w", newline="") as f: wr = csv.DictWriter(f, fieldnames=list(all_stats[0].keys())) wr.writeheader() wr.writerows(all_stats) with open(out_dir / "pot_combos.csv", "w", newline="") as f: wr = csv.writer(f) wr.writerow(["table_idx", "phi", "v0", "side", "vert", "q", "b2_potted"]) wr.writerows(all_combos) # ------------------------------------------------------------- summary best_q = np.array([s["best_q"] for s in all_stats]) best_x = np.array([s["best_q_excl_b2"] for s in all_stats]) best_ns = np.array([s["best_q_nospin"] for s in all_stats]) potable = ~np.isnan(best_q) bq, bns = best_q[potable], best_ns[~np.isnan(best_ns)] bx = best_x[~np.isnan(best_x)] lines = [ f"== Oracle controllability — {args.tables} bàn, {total_sims} sim ==", f"grid: V0 {args.v0_steps} mức [0.5,4.0] x side/vert " f"{args.spin_steps} mức [-0.4,0.4]" + (f" x phi ±{args.phi_jitter}°" if args.phi_jitter > 0 else ""), "", f"Bàn pot được (>=1 combo pot && !scratch): " f"{potable.sum()}/{args.tables} ({100*potable.mean():.0f}%)", "", "TRẦN Q (best-Q per bàn, chỉ trên bàn pot được):", f" mean : {bq.mean():.3f} (gồm cả combo b2 rớt lỗ, Q=1 may mắn)", f" median : {np.median(bq):.3f}", f" p25/p75: {np.percentile(bq, 25):.3f} / {np.percentile(bq, 75):.3f}", f" p10/p90: {np.percentile(bq, 10):.3f} / {np.percentile(bq, 90):.3f}", "", f"TRẦN LOẠI b2-potted: mean {bx.mean():.3f}" f" <-- TRẦN controllability THẬT (điều bi, không tính golf-in)" if len(bx) else "TRẦN LOẠI b2-potted: (không có)", "", f"TRẦN NO-SPIN (a=b=0): mean {bns.mean():.3f}" f" -> spin mua thêm ~{bq.mean()-bns.mean():+.3f} Q" if len(bns) else "TRẦN NO-SPIN: không có combo no-spin nào pot được", "", "So sánh:", f" position-blind baseline (14/07): Q|pot = {BLIND_Q:.3f}", f" agent aim-any best (16/07): Q|pot = {AGENT_Q:.3f}", f" -> gap agent vs trần thật: {bx.mean()-AGENT_Q:+.3f}" if len(bx) else " -> gap: n/a", "", f"% bàn có trần thật > 0.53 (mốc blind): {100*(bx > BLIND_Q).mean():.0f}%", f"% bàn có trần thật > 0.70 : {100*(bx > 0.70).mean():.0f}%", f"% bàn có trần thật > 0.80 : {100*(bx > 0.80).mean():.0f}%", "", "Đọc kết quả:", " trần ~0.55-0.6 -> agent đã gần tối ưu, vấn đề là TASK", " trần >= 0.75 -> gap THẬT, agent chưa học điều bi", ] summary = "\n".join(lines) print(summary) (out_dir / "summary.txt").write_text(summary, encoding="utf-8") # ---------------------------------------------------------------- plot import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt fig, ax = plt.subplots(figsize=(9, 5.5)) bins = np.linspace(0, 1, 21) ax.hist(bx if len(bx) else bq, bins=bins, alpha=0.65, label="best Q (loại b2-potted)") if len(bns): ax.hist(bns, bins=bins, alpha=0.5, label="best Q (no-spin)") ax.axvline(AGENT_Q, color="tab:red", ls="--", label=f"agent Q|pot ({AGENT_Q:.2f})") ref = bx.mean() if len(bx) else bq.mean() ax.axvline(ref, color="tab:green", ls="-", label=f"trần thật mean ({ref:.2f})") ax.set_xlabel("best-Q per bàn (grid oracle)") ax.set_ylabel("số bàn") ax.set_title(f"Trần controllability — {potable.sum()} bàn pot được / " f"{args.tables}") ax.legend(loc="upper right") fig.tight_layout() fig.savefig(out_dir / "histogram.png", dpi=120) print(f"\nOutput -> {out_dir}") if __name__ == "__main__": main()