poolcoach / tests /test_zone_v2.py
masterdanh's picture
deploy: snapshot for HF Space
78738de
Raw
History Blame Contribute Delete
22.1 kB
"""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)