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