Spaces:
Sleeping
Sleeping
File size: 11,732 Bytes
78738de | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 | """Bất biến sim → validate: mọi trạng thái `simulate_shot_multi` sinh ra phải
được `validate_full` chấp nhận (bug 27/07/2026, tái phát 28/07/2026).
Biên cũ `[BALL_R, w−BALL_R]` giả định tâm bi không bao giờ lấn vào trong đường
băng. Sai: bi dừng trong HÀM LỖ có tâm lọt vào trong biên đó, và đấy là vị trí
vật lý hợp lệ. Hệ quả không nằm ở script eval — `autoPlay()` của FE làm đúng
vòng lặp `applyFinal → recommend → POST`, nên bản chạy trên HF Space trả 422
giữa chừng khi auto-play trọn ván.
Bản vá 27/07 nới đúng `BALL_R` mỗi phía (biên thành `[0, w] × [0, l]`) với lập
luận "dư ~5× so với mức đo được". 28/07 gặp trạng thái vượt **29.7 mm** →
headroom chọn theo mẫu đã thấy không bao giờ chứng minh được là đã chạm đáy.
Biên hiện tại là ENVELOPE HÌNH HỌC: hộp bao của (mặt bàn ∪ mọi đường tròn lỗ),
dẫn từ `table.pockets` của chính bàn đang mô phỏng — xem
`envs.position_env.table_bounds`. Bi chưa bị ăn không ra ngoài hộp đó được, nên
không có lần nới thứ ba.
Ba toạ độ trong case 1-2-6 là số THẬT bắt được tối 27/07 và tối 28/07, chép
nguyên không làm tròn — làm tròn là mất chính cái sai số mà test này canh.
"""
from __future__ import annotations
import numpy as np
import pytest
from poolcoach_rl.envs.position_env import BALL_R, table_bounds
from poolcoach_rl.recommend.core import validate_full
from conftest import EnvStub
# Bi hợp lệ ở giữa bàn, để ghép cùng bi đang thử mà không đụng luật chồng bi.
CUE_MID = np.array([0.45, 0.60])
# Specs lỗ của `pt.Table.default()` — ĐỌC RA từ pooltool thật, không chọn tay:
# for name, p in pt.Table.default().pockets.items():
# print(name, p.center[:2], p.radius)
# Chép vào đây vì conftest cố ý nhét stub pooltool rỗng (test kiểm contract,
# không kiểm vật lý) nên test không dựng nổi bàn thật.
TABLE_W, TABLE_L = 0.9906, 1.9812
TABLE_POCKET_SPECS = [
((-0.0294857, -0.0294857), 0.062), # lb góc dưới-trái
((-0.0685000, 0.9906000), 0.0645), # lc giữa-trái
((-0.0294857, 2.0106857), 0.062), # lt góc trên-trái
((1.0200857, -0.0294857), 0.062), # rb góc dưới-phải
((1.0591000, 0.9906000), 0.0645), # rc giữa-phải
((1.0200857, 2.0106857), 0.062), # rt góc trên-phải
]
class FakePocket:
"""Shape tối thiểu mà `table_bounds` cần: `.center` + `.radius`."""
def __init__(self, center, radius):
self.center = center
self.radius = radius
def _pockets(specs):
return [FakePocket(c, r) for c, r in specs]
# --------------------------------------------- trạng thái sim sinh ra: NHẬN
def test_case1_ban_chua_bi_dung_trong_ham_lo_goc_tren_trai(env_stub):
"""bi 7 (0.02617662835464931, 1.9593260782344746) — hụt 2.4 mm so với
biên gốc ở góc trên-trái. Bắt được khi oracle chạy eval_runout ván 3."""
balls = {"cue": CUE_MID,
"7": np.array([0.02617662835464931, 1.9593260782344746])}
validate_full(env_stub, balls) # không raise
def test_case2_ban_chua_bi_dung_trong_ham_lo_giua_phai(env_stub):
"""bi 8 (0.9678554302996148, 0.9514915281161108) — vượt 5.9 mm so với
biên gốc ở lỗ giữa-phải. Bắt được khi hybrid γ=0.6 chạy ván 34."""
balls = {"cue": CUE_MID,
"8": np.array([0.9678554302996148, 0.9514915281161108])}
validate_full(env_stub, balls) # không raise
def test_case6_bi_cai_dung_trong_ham_lo_x_am(env_stub):
"""cue (-0.0010683236740548342, 1.9491727149488998) — x ÂM 1.07 mm, tức
vượt 29.7 mm so với biên gốc `[BALL_R, w−BALL_R]`. Đây là toạ độ đã giết
`eval_runout --rand-seed 2` ở ván 12 tối 28/07, TRÊN bản vá 27/07: bằng
chứng rằng nới hằng số theo mẫu quan sát được là cách làm hụt được lần nữa.
Bi cái nằm ở hàm lỗ góc trên-trái; tâm lỗ đó là (−0.0295, 2.0107) nên toạ
độ này thừa sức nằm trong đường tròn lỗ — không phải input rác.
"""
balls = {"cue": np.array([-0.0010683236740548342, 1.9491727149488998]),
"1": CUE_MID}
validate_full(env_stub, balls) # không raise
# ------------------------------------------------------- input rác: TỪ CHỐI
@pytest.mark.parametrize("xy, mo_ta", [
((-0.5, 0.60), "x âm nửa mét — ngoài cả hộp bao lỗ"),
((5.0, 0.60), "x gấp 5 lần chiều rộng bàn"),
((0.45, -0.3), "y âm 30 cm"),
((0.45, 3.0), "y vượt xa mép trên"),
])
def test_case3_van_tu_choi_input_rac_that(env_stub, xy, mo_ta):
"""Envelope hình học KHÔNG được rộng tới mức nuốt luôn toạ độ vô nghĩa:
đây vẫn là lớp chắn duy nhất giữa FE và tầng sim (BE trả 422 từ chính
message này).
Bộ toạ độ ĐÃ ĐỔI so với bản trước, và đây là dự kiến chứ không phải hồi
quy: ba toạ độ cũ `(-0.05, 0.60)` · `(0.9906+0.05, 0.60)` · `(0.45, -0.01)`
được chọn để vượt biên CŨ `[0, w] × [0, l]`, mà cả ba đều NẰM TRONG
envelope mới (x ∈ [−0.133, 1.124], y ∈ [−0.091, 2.073]). Cách sửa đúng là
đổi toạ độ, KHÔNG phải thu hẹp envelope cho test cũ xanh lại — làm thế là
quay lại đúng vòng lặp nới-rồi-hụt mà bản vá này đang thoát ra.
"""
balls = {"cue": CUE_MID, "3": np.array(xy)}
with pytest.raises(ValueError, match="ngoài bàn"):
validate_full(env_stub, balls)
@pytest.mark.parametrize("xy, mo_ta", [
((np.nan, 0.60), "NaN ở x"),
((0.45, np.nan), "NaN ở y"),
((np.inf, 0.60), "+inf ở x"),
((0.45, -np.inf), "−inf ở y"),
])
def test_case7_tu_choi_gia_tri_khong_huu_han(env_stub, xy, mo_ta):
"""NaN/inf phải bị từ chối bằng message RIÊNG.
Mọi so sánh với NaN đều trả False, nên `not (lo <= x <= hi)` là True và NaN
rơi thẳng vào nhánh "ngoài bàn" — message đó dẫn người debug đi tìm lỗi toạ
độ trong khi lỗi thật là dữ liệu hỏng. Tệ hơn: `np.linalg.norm` với NaN trả
NaN, `NaN < 2R` là False, nên NaN cũng lọt luôn cả kiểm chồng bi.
"""
balls = {"cue": CUE_MID, "3": np.array(xy)}
with pytest.raises(ValueError, match="không phải số hữu hạn"):
validate_full(env_stub, balls)
with pytest.raises(ValueError, match="không phải số hữu hạn"):
validate_full(env_stub, {"cue": np.array(xy), "3": CUE_MID})
# ------------------------------------------------------------ luật chồng bi
def test_case4_hai_bi_tua_nhau_dung_2R_khong_raise(env_stub):
"""Sau sim, hai bi tựa vào nhau cách nhau ĐÚNG 2R. So sánh `< 2R` trần
trụi thì sai số float một chiều là raise — OVERLAP_EPS chính là để đây."""
a = np.array([0.45, 0.60])
balls = {"cue": a, "3": a + np.array([2 * BALL_R, 0.0])}
validate_full(env_stub, balls) # không raise
def test_case5_chong_bi_that_van_raise(env_stub):
"""0.9 × 2R = lệch 5.7 mm — chồng bi thật, không phải sai số float."""
a = np.array([0.45, 0.60])
balls = {"cue": a, "3": a + np.array([0.9 * 2 * BALL_R, 0.0])}
with pytest.raises(ValueError, match="chồng lên nhau"):
validate_full(env_stub, balls)
# ------------------------------- envelope ĐƯỢC DẪN RA, không phải gõ tay
def test_case8_envelope_bao_tron_moi_duong_tron_lo():
"""Tính chất ĐỊNH NGHĨA của envelope, kiểm trên một bàn GIẢ có hình học
khác hẳn bàn thật: mọi đường tròn lỗ phải nằm TRỌN trong hộp, và hộp phải
chứa cả mặt bàn.
Bàn giả chứ không phải bàn thật có chủ đích: một cài đặt trả hằng số của
bàn mặc định sẽ qua được test trên bàn thật, nhưng trượt ở đây.
"""
w, l = 2.0, 3.0
specs = [((-0.10, -0.20), 0.15), ((-0.30, 1.50), 0.25),
((2.40, -0.05), 0.10), ((2.10, 3.30), 0.20)]
pks = _pockets(specs)
lo_x, hi_x, lo_y, hi_y = table_bounds(w, l, pks)
for (cx, cy), r in specs:
assert lo_x <= cx - r and cx + r <= hi_x, f"lỗ ({cx},{cy}) lòi theo x"
assert lo_y <= cy - r and cy + r <= hi_y, f"lỗ ({cx},{cy}) lòi theo y"
assert lo_x <= 0.0 and hi_x >= w
assert lo_y <= 0.0 and hi_y >= l
def test_case9_envelope_la_hop_bao_CHAT_khong_phai_hop_nao_do_rong_hon():
"""Mỗi cạnh phải CHẠM đúng một ràng buộc — hoặc mép bàn, hoặc mép một
đường tròn lỗ. "Chứa hết" + "chạm" mới đặc trưng được hộp bao nhỏ nhất;
chỉ "chứa hết" thì một hộp nới bừa cũng qua, và nới bừa chính là thứ bản
vá này đang bỏ."""
w, l = 2.0, 3.0
specs = [((-0.10, -0.20), 0.15), ((-0.30, 1.50), 0.25),
((2.40, -0.05), 0.10), ((2.10, 3.30), 0.20)]
lo_x, hi_x, lo_y, hi_y = table_bounds(w, l, _pockets(specs))
def cham(v, moc):
return any(abs(v - m) < 1e-12 for m in moc)
assert cham(lo_x, [0.0] + [cx - r for (cx, _cy), r in specs])
assert cham(hi_x, [w] + [cx + r for (cx, _cy), r in specs])
assert cham(lo_y, [0.0] + [cy - r for (_cx, cy), r in specs])
assert cham(hi_y, [l] + [cy + r for (_cx, cy), r in specs])
def test_case10_envelope_phan_ung_voi_ban_kinh_lo():
"""Nới bán kính một lỗ thì hộp phải rộng ra đúng phần nới.
Đây là test bắt được kiểu hồi quy nguy hiểm nhất: ai đó thay `table_bounds`
bằng một bộ hằng số (hoặc bằng `[0, w] × [0, l]` cộng slack) thì hai test
trên còn có thể lách, test này thì không.
"""
w, l = 2.0, 3.0
specs = [((-0.10, 1.0), 0.15), ((2.10, 1.0), 0.15)]
lo0, hi0, _, _ = table_bounds(w, l, _pockets(specs))
grown = [((-0.10, 1.0), 0.25), ((2.10, 1.0), 0.15)]
lo1, hi1, _, _ = table_bounds(w, l, _pockets(grown))
assert lo1 == pytest.approx(lo0 - 0.10)
assert hi1 == pytest.approx(hi0)
def test_case11_bounds_cua_EnvStub_khop_ban_that():
"""Hằng số `EnvStub._bounds` phải ĐÚNG BẰNG `table_bounds` trên specs lỗ
của `pt.Table.default()`.
Stub phải chép cứng 4 số (conftest không có pooltool thật), nên test này là
thứ duy nhất giữ nó khỏi trôi khỏi bàn thật khi specs bàn đổi.
"""
want = table_bounds(TABLE_W, TABLE_L, _pockets(TABLE_POCKET_SPECS))
assert EnvStub._bounds == pytest.approx(want, abs=1e-6)
# và khớp bộ số Cowork tính tay trong BRIEF 29/07 (4 chữ số)
assert EnvStub._bounds == pytest.approx((-0.1330, 1.1236,
-0.0915, 2.0727), abs=5e-5)
def test_case12_envelope_moi_khong_bao_gio_hep_hon_bien_cu():
"""Bản vá chỉ được NỚI, không được siết: mọi trạng thái biên cũ
`[0, w] × [0, l]` chấp nhận thì biên mới cũng phải chấp nhận.
Đây là lập luận đứng sau gate chống drift B3 — nếu envelope siết ở một
hướng nào đó thì `eval_runout` có thể đổi kết quả một ván mà không ai ngờ.
"""
lo_x, hi_x, lo_y, hi_y = EnvStub._bounds
assert lo_x <= 0.0 and hi_x >= EnvStub.w
assert lo_y <= 0.0 and hi_y >= EnvStub.l
|