"""ZonePlanner V2 — gate G3 (lọc) + G4 (xếp hạng) phần LOGIC (31/07/2026). Chạy dưới stub ``pooltool`` RỖNG của ``conftest.py``: không sim cú nào. Cái được khoá ở đây là MÁY LỌC và KHOÁ XẾP HẠNG trên facts dựng tay; phần "sim thật có cho ra đúng như thế không" (G3/G4 trên thế dựng tay THẬT) nằm ở ``scripts/check_zone_v2_gates.py``. Ba chỗ đắt nhất: * **(iii) đọc từ events, không phải từ vị trí bi.** ``touched_others`` V1 so vị trí trước/sau nên MÙ với double-kiss (bi mục tiêu bị đụng thì nó đang bay vào lỗ, vị trí "sau" là None). V2 phải bắt được đúng ca đó (G3.1). * **(v) so bằng GIÁ TRỊ GRID** — ``v0_grid[1]`` = 0.8888... ≠ 0.889; một lần làm tròn là lọc lực im lặng loại nhầm cả nấc (G3.3). * **Bất biến ``sorted(pool, key)[0] is shot``** — cùng bất biến V1: caller xếp top-4 phải cho đúng top-1 mà ``plan_shot_v2`` chọn bằng ``min``. """ from __future__ import annotations import numpy as np import pytest from poolcoach_rl.recommend import core from poolcoach_rl.recommend import zone_v2 as zv from poolcoach_rl.recommend.simulate import path_len_after from poolcoach_rl.recommend.zone_v2 import (CandidateV2, cue_hits_after_contact, force_ok, rank_key_v2, target_path_dirty, v2_specs) BALLS_MID = {"cue": np.array([0.45, 0.30]), "1": np.array([0.32, 1.09]), "2": np.array([0.74, 0.95]), "9": np.array([0.50, 1.29])} BALLS_LAST = {"cue": np.array([0.40, 0.55]), "9": np.array([0.62, 1.35])} # ------------------------------------------------------------ tập ứng viên def test_v2_specs_99_ung_vien_khong_co_nac_1(): specs = v2_specs() assert len(specs) == 99 # 11 kỹ thuật × 9 nấc v0s = zv.default_v0_grid() assert all(v0 > float(v0s[0]) for _s, _v, v0 in specs) # giá trị lực là PHẦN TỬ GRID nguyên xi, không phải literal làm tròn assert {v0 for _s, _v, v0 in specs} == {float(x) for x in v0s[1:]} def test_v2_specs_spin_da_snap_ve_luoi(): """0.2 literal phải thành 0.20000000000000007 của linspace — sim ở literal là sim ở một ô lưới chưa từng quét (bài học docstring zone_planner).""" grid = zv.default_spin_grid() specs = v2_specs() vals = {s for s, _v, _v0 in specs} | {v for _s, v, _v0 in specs} assert vals <= {float(x) for x in grid} # ---------------------------------------------------------------- G3.3 (v) def test_g3_3_luc_so_bang_gia_tri_grid(): v0s = zv.default_v0_grid() assert force_ok(float(v0s[1])) # 0.888... — nấc 2, QUA assert not force_ok(float(v0s[0])) # 0.5 — nấc 1, LOẠI # literal 0.889 KHÔNG phải phần tử grid → không được nhận assert not force_ok(0.889) # ----------------------------------------------------- (i)/(iii) từ events BB_CLEAN = ((0.22, ("cue", "1")),) # một chạm duy nhất BB_DOUBLE_KISS = ((0.22, ("cue", "1")), (0.35, ("cue", "1"))) BB_CUE_HITS_9 = ((0.22, ("cue", "1")), (0.90, ("cue", "9"))) BB_TARGET_HITS_2 = ((0.22, ("cue", "1")), (0.60, ("1", "2"))) def test_g3_1_iii_bat_double_kiss_va_cham_bi_sau(): assert not cue_hits_after_contact(BB_CLEAN) assert cue_hits_after_contact(BB_DOUBLE_KISS) # kiss lại CHÍNH bi mục tiêu assert cue_hits_after_contact(BB_CUE_HITS_9) assert not cue_hits_after_contact(()) # không chạm gì assert not cue_hits_after_contact(None) # facts cũ không có key def test_i_duong_bi_muc_tieu_khong_duoc_cham_bi_khac(): assert not target_path_dirty(BB_CLEAN, "1") assert target_path_dirty(BB_TARGET_HITS_2, "1") # combo/đẩy bi 2 # double-kiss dính CẢ (i) lẫn (iii) — event thứ hai có target assert target_path_dirty(BB_DOUBLE_KISS, "1") # bi khác chạm nhau mà không dính target thì (i) không quan tâm assert not target_path_dirty(((0.2, ("cue", "1")), (0.5, ("2", "9"))), "1") # ------------------------------------------------------------ plan_shot_v2 def fake_m(bb=BB_CLEAN, roll=0.5, cue_xy=(0.5, 1.0), potted=("1",), scratch=False, first="1"): bf = {"cue": None if scratch else np.array(cue_xy), "1": None, "2": np.array([0.74, 0.95]), "9": np.array([0.50, 1.29])} for b in potted: bf[b] = None return {"system": None, "potted": list(potted), "scratch": scratch, "first_contact": first, "balls_final": bf, "bb_events": bb, "cue_roll_after": roll} def run_plan(monkeypatch, env_stub, sim_results, balls=None, target="1", pockets=None, **plan_kw): """plan_shot_v2 với sim trả theo kịch bản: hàm nhận (side, vert, v0) → facts dict | None. Mặc định MỘT lỗ để kịch bản tất định; ``plan_kw`` truyền thẳng xuống (04/08: cho ``debug_candidates=True``).""" from poolcoach_rl.recommend import rules, simulate def fake_sim(env_h, b, phi, v0, side, vert, render=False): return sim_results(side, vert, v0) def fake_judge(env_h, m, b, tgt): potted = m["potted"] foul = m["scratch"] or m["first_contact"] != tgt remaining = [bid for bid in b if bid != "cue" and bid not in potted] nxt = str(min(int(x) for x in remaining)) if remaining else None return {"foul": foul, "win": "9" in potted and not foul, "next": nxt, "q": 0.6, "ev": None if foul else 1.3} monkeypatch.setattr(simulate, "simulate_shot_multi", fake_sim) monkeypatch.setattr(rules, "judge_shot", fake_judge) return zv.plan_shot_v2(env_stub, balls or BALLS_MID, target, pockets=pockets or [(2, 30.0, 0.95, False)], **plan_kw) def test_g3_1_double_kiss_bi_loai_qua_ca_may_loc(monkeypatch, env_stub): plan = run_plan(monkeypatch, env_stub, lambda s, v, v0: fake_m(bb=BB_DOUBLE_KISS)) assert plan.shot is None and plan.pool == [] rep = plan.pockets[0] assert rep.n_pass == 0 assert rep.fail_counts["iii"] == 99 # mọi ứng viên chết vì (iii) def test_g3_2_dai_d_dap_015_150(monkeypatch, env_stub): """d ≈ 0.20 qua; ≈ 0.10 loại; ≈ 1.6 loại. Bi kế là bi 2 ở (0.74, 0.95).""" def sim(side, vert, v0): return fake_m(cue_xy=(0.74, 0.75)) # d = 0.20 tới bi 2 plan = run_plan(monkeypatch, env_stub, sim) assert plan.shot is not None assert plan.shot.d_land == pytest.approx(0.20) for cue_xy, expect_fail in (((0.74, 0.85), "iv"), # d = 0.10 < 0.15 ((0.74, 2.55), "iv")): # d = 1.60 > 1.50 plan = run_plan(monkeypatch, env_stub, lambda s, v, v0, xy=cue_xy: fake_m(cue_xy=xy)) assert plan.shot is None assert plan.pockets[0].fail_counts[expect_fail] == 99 def test_ii_nem_45_do_tren_diem_dap(monkeypatch, env_stub): """Điểm đáp cho góc cắt vào bi kế > 45° với MỌI lỗ → (ii) loại. Hình học quyết định thế dựng: bi kế GIỮA BÀN thì 6 hướng lỗ phủ gần kín vòng tròn — khe hở lớn nhất ~84° nên góc tốt nhất không vượt nổi ~42°, tức (ii) gần như KHÔNG ràng buộc ở đó (đúng tiên đoán §7 design cho θ_max=45°). Muốn có vùng chết phải đưa bi kế VỀ GÓC — hướng lỗ co cụm, khe hở nở ra. Test dò điểm chết bằng chính ``best_cut_deg`` thay vì tin trực giác một toạ độ, và ràng ``d`` trong [0.15, 1.5] để ứng viên chỉ có thể chết vì (ii).""" from poolcoach_rl.recommend.zone_v2 import best_cut_deg balls = dict(BALLS_MID, **{"2": np.array([0.10, 0.10])}) # bi kế sát góc # điểm đáp thẳng hướng bi2 → lỗ góc dưới-trái: cut 0°, d = 0.5 — PHẢI QUA ok_plan = run_plan(monkeypatch, env_stub, lambda s, v, v0: fake_m(cue_xy=(0.454, 0.454)), balls=balls) assert ok_plan.shot is not None assert ok_plan.shot.cut_next_deg <= 45.0 bad_xy = None for cand in ((0.04, 0.44), (0.06, 0.52), (0.10, 0.60), (0.03, 0.38)): cut = float(best_cut_deg(env_stub._pockets, balls["2"], [np.array(cand)])[0]) d = float(np.linalg.norm(np.array(cand) - balls["2"])) if cut > 45.0 and 0.15 <= d <= 1.50: bad_xy = cand break assert bad_xy is not None, "không dựng nổi điểm đáp > 45° — sửa test" plan = run_plan(monkeypatch, env_stub, lambda s, v, v0: fake_m(cue_xy=bad_xy), balls=balls) assert plan.shot is None assert plan.pockets[0].fail_counts["ii"] == 99 def test_i_scratch_va_sai_bi_dau_va_combo_deu_chet_o_i(monkeypatch, env_stub): for m in (fake_m(scratch=True), fake_m(first="2"), fake_m(potted=("1", "9"))): # bi 9 rơi kèm = không trực tiếp plan = run_plan(monkeypatch, env_stub, lambda s, v, v0, mm=m: mm) assert plan.shot is None assert plan.pockets[0].fail_counts["i"] == 99 # --------------------------- G2 04/08: debug_candidates (mở rộng thuần) def snap_plan(plan): """Ảnh chụp MỌI trường CŨ của PlanV2Result, so được bằng ``==`` — thiếu trường nào ở đây là test khoá G2 mù trường đó, đừng rút gọn.""" def snap_c(c): return (c.idx, c.side, c.vert, c.v0, c.pocket, c.phi, c.ok, c.fails, c.roll_len, c.d_land, c.cut_next_deg) return { "shot": None if plan.shot is None else snap_c(plan.shot), "pool": [snap_c(c) for c in plan.pool], "pocket_rank": plan.pocket_rank, "n_tie_top1": plan.n_tie_top1, "picks": [(p.idx, p.phi, p.theta_deg, p.dist) for p in plan.picks], "n_cand": plan.n_cand, "pockets": [(r.rank, r.n_cand, r.n_sim_none, r.n_pass, dict(r.fail_counts)) for r in plan.pockets], } # ≥2 thế cố định (G2): một thế CÓ cú, một thế hết đường, một thế cú cuối ván G2_SCENARIOS = { "co-cu": (lambda s, v, v0: fake_m(cue_xy=(0.74, 0.55)), BALLS_MID, "1"), "het-duong-iii": (lambda s, v, v0: fake_m(bb=BB_DOUBLE_KISS), BALLS_MID, "1"), "cu-cuoi-van": (lambda s, v, v0: fake_m(bb=((0.22, ("cue", "9")),), potted=("9",), first="9"), BALLS_LAST, "9"), } @pytest.mark.parametrize("name", list(G2_SCENARIOS)) def test_g2_flag_tat_va_bat_truong_cu_y_het(monkeypatch, env_stub, name): """Flag tắt (mặc định) → không có debug + mọi trường cũ y hệt; flag bật → top-1 và mọi trường cũ VẪN y hệt (debug chỉ THÊM thông tin).""" sim, balls, target = G2_SCENARIOS[name] off = run_plan(monkeypatch, env_stub, sim, balls=balls, target=target) on = run_plan(monkeypatch, env_stub, sim, balls=balls, target=target, debug_candidates=True) assert off.debug_candidates == [] assert snap_plan(off) == snap_plan(on) def test_g2_mask_du_5_phan_tu_moi_ung_vien_pass_all_trung_ok(monkeypatch, env_stub): plan = run_plan(monkeypatch, env_stub, lambda s, v, v0: fake_m(cue_xy=(0.74, 0.55)), debug_candidates=True) assert len(plan.debug_candidates) == 99 # đủ MỌI ô đã enumerate for e in plan.debug_candidates: assert set(e.mask) == set(zv.MASK_KEYS) # đủ 5 phần tử/ứng viên # pass_all tính từ mask phải trùng máy lọc thật (ok) — cùng một code path assert (sum(e.pass_all for e in plan.debug_candidates) == plan.pockets[0].n_pass) assert [e.idx for e in plan.debug_candidates] == list(range(99)) def test_mask_khong_short_circuit_ii_iv_khi_i_chet(monkeypatch, env_stub): """Ứng viên chết (i) (sai bi đầu) VÀ đáp d = 0.10: engine cũ chỉ đếm "i" (short-circuit — giữ nguyên), mask phải thấy CẢ "iv" trượt.""" plan = run_plan(monkeypatch, env_stub, lambda s, v, v0: fake_m(first="2", cue_xy=(0.74, 0.85)), debug_candidates=True) assert plan.shot is None rep = plan.pockets[0] assert rep.fail_counts["i"] == 99 assert rep.fail_counts["iv"] == 0 # đường cũ vẫn short-circuit for e in plan.debug_candidates: assert e.mask["i"] is False assert e.mask["iv"] is False # mask thì chấm đủ assert e.mask["iii"] is True assert not e.pass_all def test_mask_cu_cuoi_van_ii_iv_tu_qua(monkeypatch, env_stub): plan = run_plan(monkeypatch, env_stub, lambda s, v, v0: fake_m(bb=((0.22, ("cue", "9")),), potted=("9",), first="9"), balls=BALLS_LAST, target="9", debug_candidates=True) assert plan.shot is not None for e in plan.debug_candidates: assert e.mask["ii"] is True and e.mask["iv"] is True assert e.pass_all def test_mask_scratch_khong_diem_dap_ii_iv_none(monkeypatch, env_stub): """Cue vào lỗ → không có điểm đáp: (ii)/(iv) là "không đo được" (None), không phải False — script đo phải phân biệt được hai chuyện đó.""" plan = run_plan(monkeypatch, env_stub, lambda s, v, v0: fake_m(scratch=True), debug_candidates=True) assert plan.shot is None for e in plan.debug_candidates: assert e.mask["i"] is False # scratch = foul assert e.mask["ii"] is None and e.mask["iv"] is None assert not e.pass_all def test_mask_sim_none_van_co_entry(monkeypatch, env_stub): v0s = zv.default_v0_grid() dead = float(v0s[5]) def sim(s, v, v0): return None if v0 == dead else fake_m(cue_xy=(0.74, 0.55)) plan = run_plan(monkeypatch, env_stub, sim, debug_candidates=True) assert len(plan.debug_candidates) == 99 # sim chết vẫn có entry nones = [e for e in plan.debug_candidates if e.sim_none] assert len(nones) == 11 # 11 kỹ thuật × 1 nấc chết for e in nones: assert e.mask["i"] is None and e.mask["iv"] is None assert e.mask["v"] is True # (v) thuần tham số, vẫn chấm assert not e.pass_all assert plan.pockets[0].n_sim_none == 11 assert (sum(e.pass_all for e in plan.debug_candidates) == plan.pockets[0].n_pass == 88) def test_debug_khong_sim_them_o_nao_lo_2_van_theo_nep_cu(monkeypatch, env_stub): """Flag bật không được đổi tập ô đã sim: lỗ 2 CHỈ thử khi lỗ 1 trắng tay (nếp cũ), nên thế có cú → 99 entry, thế hết đường 2 lỗ → 198 entry.""" two_pockets = [(2, 30.0, 0.95, False), (4, 210.0, 0.90, False)] ok = run_plan(monkeypatch, env_stub, lambda s, v, v0: fake_m(cue_xy=(0.74, 0.55)), pockets=two_pockets, debug_candidates=True) assert ok.shot is not None assert len(ok.debug_candidates) == 99 assert {e.pocket_rank for e in ok.debug_candidates} == {1} dry = run_plan(monkeypatch, env_stub, lambda s, v, v0: fake_m(bb=BB_DOUBLE_KISS), pockets=two_pockets, debug_candidates=True) assert dry.shot is None assert len(dry.debug_candidates) == 198 assert {e.pocket_rank for e in dry.debug_candidates} == {1, 2} # ------------------------------------------------------------ G4: xếp hạng def cand(roll, d_land=None, idx=0): return CandidateV2(side=0.0, vert=0.0, v0=2.0, pocket=2, phi=45.0, idx=idx, facts={}, ok=True, roll_len=roll, d_land=d_land) def test_g4_1_duong_lan_ngan_hon_thang(): key = rank_key_v2() a, b = cand(0.40, 1.0, idx=0), cand(0.90, 1.0, idx=1) assert min([b, a], key=key) is a def test_g4_2_hoa_duong_lan_thi_gan_1m_thang(): key = rank_key_v2() a, b = cand(0.50, 1.72, idx=0), cand(0.50, 0.95, idx=1) assert min([a, b], key=key) is b # |0.95−1| = 0.05 < 0.72 def test_g4_3_van_hoa_thi_thu_tu_enumerate(monkeypatch, env_stub): key = rank_key_v2() a, b = cand(0.50, 0.95, idx=3), cand(0.50, 0.95, idx=7) assert min([b, a], key=key) is a # idx nhỏ hơn def test_cu_cuoi_van_khong_co_d_dap_xep_theo_duong_lan(): key = rank_key_v2() a, b = cand(0.80, None, idx=0), cand(0.30, None, idx=1) assert min([a, b], key=key) is b def test_path_len_after_duong_gay_khuc_dai_hon_euclid(): """Vế logic của G4.3: polyline có đỉnh dội băng phải DÀI HƠN đoạn thẳng đầu→cuối. Vế sim thật nằm ở scripts/check_zone_v2_gates.py.""" xy = np.array([[0.0, 0.0], [0.5, 0.0], [1.0, 0.0], [1.0, 0.5], [0.5, 0.5]]) # gấp khúc 2 lần ts = np.array([0.0, 0.2, 0.5, 0.8, 1.0]) ln = path_len_after(xy, ts, 0.2) # từ điểm chạm (0.5, 0) euclid = float(np.linalg.norm(xy[-1] - xy[1])) assert ln == pytest.approx(1.5) assert ln > euclid def test_path_len_after_khong_rot_snapshot_va_cham_vi_float(): xy = np.array([[0.0, 0.0], [1.0, 0.0], [2.0, 0.0]]) ts = np.array([0.0, 0.3000000000000001, 0.6]) assert path_len_after(xy, ts, 0.30000000000000016) == pytest.approx(1.0) # ----------------------------------------------- bất biến pool → top-4 def test_sorted_pool_dau_bang_min_ke_ca_khi_hoa(monkeypatch, env_stub): rolls = {2.0: 0.50, 2.5: 0.30, 3.0: 0.30} # hoà 0.30 giữa hai nấc lực def sim(side, vert, v0): r = rolls.get(round(v0, 1), 0.9) return fake_m(roll=r, cue_xy=(0.74, 0.55)) # d = 0.4 — qua (iv) plan = run_plan(monkeypatch, env_stub, sim) key = rank_key_v2() assert sorted(plan.pool, key=key)[0] is plan.shot assert plan.shot is min(plan.pool, key=key) # ------------------------------------------------------------ recommend_v2 def make_v2_plan(pool, shot=None): rep = zv.PocketV2Report(pick=None, rank=1, n_cand=99, n_sim_none=0, n_pass=len(pool), fail_counts={}) return zv.PlanV2Result(shot=shot or (min(pool, key=rank_key_v2()) if pool else None), pool=list(pool), pocket_rank=1 if pool else None, n_cand=99, pockets=[rep]) def v2c(roll, d_land, idx, v0=2.0, target="1", nxt="2", win=False): facts = {"pocket": 2, "phi": 45.0, "v0": v0, "side": 0.0, "vert": 0.0, "potted": [target], "scratch": False, "first_contact": target, "foul": False, "win": win, "next": nxt, "q": 0.6, "ev": 1.3, "balls_final": {"cue": np.array([0.5, 1.0]), target: None, "9": np.array([0.50, 1.29])}} return CandidateV2(side=0.0, vert=0.0, v0=v0, pocket=2, phi=45.0, idx=idx, facts=facts, ok=True, roll_len=roll, d_land=d_land) def test_recommend_v2_top4_theo_khoa_v2_va_khai_zone(monkeypatch, env_stub): pool = [v2c(0.50, 0.9, 0), v2c(0.30, 1.7, 1), v2c(0.30, 1.1, 2), v2c(0.90, 1.0, 3), v2c(0.70, 1.0, 4)] monkeypatch.setattr(zv, "plan_shot_v2", lambda *a, **k: make_v2_plan(pool)) res = core.recommend_v2(BALLS_MID, env_h=env_stub, n_render=0) assert res.engine == "zone" and res.target == "1" assert len(res.shots) == 4 # thứ tự: roll 0.30 (|d−1| 0.1) → 0.30 (0.7) → 0.50 → 0.70 assert [s.roll_len for s in res.shots] == [0.30, 0.30, 0.50, 0.70] assert [s.d_land for s in res.shots] == [1.1, 1.7, 0.9, 1.0] assert all(s.rank_by == "roll" for s in res.shots) # `dt` của V1 đã gỡ khỏi ShotFull (việc D) — không được mọc lại assert all(not hasattr(s, "dt") for s in res.shots) assert [s.rank for s in res.shots] == [1, 2, 3, 4] def test_recommend_v2_het_duong_shots_rong_khong_fallback(monkeypatch, env_stub): monkeypatch.setattr(zv, "plan_shot_v2", lambda *a, **k: make_v2_plan([])) res = core.recommend_v2(BALLS_MID, env_h=env_stub, n_render=0) assert res.shots == [] and res.fallback is None assert res.engine == "zone" def test_recommend_v2_cu_cuoi_van_d_land_none(monkeypatch, env_stub): pool = [v2c(0.4, None, 0, target="9", nxt=None, win=True)] monkeypatch.setattr(zv, "plan_shot_v2", lambda *a, **k: make_v2_plan(pool)) res = core.recommend_v2(BALLS_LAST, env_h=env_stub, n_render=0) s = res.shots[0] assert s.d_land is None and s.roll_len == 0.4 assert s.rank_by == "roll" and s.win is True def test_recommend_v2_khong_con_duong_cu_de_lac_sang(monkeypatch, env_stub): """Sau việc D core không còn pick_engine/load_qfield_net — khoá luôn điều đó: đường cũ mà mọc lại trong core là test này đỏ, và recommend_v2 vẫn phải tự chạy trọn không cần chúng.""" assert not hasattr(core, "pick_engine") assert not hasattr(core, "load_qfield_net") assert not hasattr(core, "recommend_full") monkeypatch.setattr(zv, "plan_shot_v2", lambda *a, **k: make_v2_plan([v2c(0.5, 1.0, 0)])) res = core.recommend_v2(BALLS_MID, env_h=env_stub, n_render=0) assert res.engine == "zone" def test_recommend_v2_validate_van_chay(monkeypatch, env_stub): with pytest.raises(ValueError, match="chồng lên nhau"): core.recommend_v2({"cue": np.array([0.5, 0.5]), "1": np.array([0.5, 0.5])}, env_h=env_stub)