예제 소스
SGF 기보로 학습하는 바둑 LLM 인공지능 · 파일 169개
test_pass_guard.py
from __future__ import annotations
import sys
import threading
import types
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
try:
import PIL # noqa: F401
except ImportError:
pil = types.ModuleType("PIL")
pil.Image = types.SimpleNamespace(Image=object)
sys.modules["PIL"] = pil
from vlm_server.server import ( # noqa: E402
ServerConfig,
_GoTcpHandler,
_Logger,
_MiniGo,
_position_scoped_reject_bans,
)
N = 19
BLACK, WHITE = 1, 2
def make_board(black: list[tuple[int, int]], white: list[tuple[int, int]]) -> bytes:
cells = bytearray(N * N)
for r, c in black:
cells[r * N + c] = BLACK
for r, c in white:
cells[r * N + c] = WHITE
return bytes(cells)
class _PassModel:
def predict_move(self, *args, **kwargs):
return None, None, True
class _QuietMoveModel:
def __init__(self, move: tuple[int, int]) -> None:
self.move = move
def predict_move(self, *args, **kwargs):
return self.move[0], self.move[1], False
class _Socket:
def __init__(self) -> None:
self.sent = bytearray()
def sendall(self, data: bytes) -> None:
self.sent.extend(data)
class PassGuardTest(unittest.TestCase):
def _move(
self, board: bytes, side: int, model=None
) -> tuple[bool, tuple[int, int] | None]:
cfg = ServerConfig(
host="127.0.0.1",
port=0,
model_id="test",
max_new_tokens=1,
temperature=0.0,
load_model=True,
log_jsonl_path=None,
)
server = types.SimpleNamespace(
cfg=cfg,
model=model or _PassModel(),
policy=None,
policy_lock=threading.Lock(),
logger=_Logger(None),
)
handler = _GoTcpHandler.__new__(_GoTcpHandler)
handler.server = server
sock = _Socket()
return handler._handle_one(
sock,
None,
board,
N,
side,
[],
None,
)
def test_model_pass_is_replaced_when_meaningful_move_exists(self) -> None:
board = make_board(
[(3, 3), (3, 15), (9, 3), (15, 3)],
[(15, 15), (9, 15), (3, 9), (15, 9)],
)
is_pass, move = self._move(board, WHITE)
self.assertFalse(is_pass)
self.assertIsNotNone(move)
def test_sparse_position_is_not_mistaken_for_endgame(self) -> None:
board = make_board(
[],
[(3, 3), (3, 9), (3, 15), (9, 3), (9, 15), (15, 3), (15, 9), (15, 15)],
)
is_pass, move = self._move(board, WHITE)
self.assertFalse(is_pass)
self.assertIsNotNone(move)
def test_model_pass_is_replaced_by_urgent_capture(self) -> None:
board = make_board(
[(5, 5), (3, 3), (15, 3), (3, 15)],
[(4, 5), (5, 4), (6, 5), (15, 15)],
)
is_pass, move = self._move(board, WHITE)
self.assertFalse(is_pass)
self.assertEqual(move, (5, 6))
def test_quiet_model_move_is_not_turned_into_midgame_pass(self) -> None:
# 모델이 합법 좌표를 냈지만 모든 후보 Δ가 임계값(0.75) 미만인 중반.
# 이전에는 PASS 제안일 때만 임계 완화를 해서, 이 경우 서버가 PASS를 보냈다.
board = make_board(
[(3, 3), (3, 15), (9, 3), (15, 3), (5, 9), (13, 13)],
[(15, 15), (9, 15), (3, 9), (15, 9), (13, 5), (6, 13)],
)
original = _MiniGo.pick_by_score
def below_threshold(self, *args, minimum_delta=0.75, **kwargs):
if minimum_delta is not None:
return None
return original(self, *args, minimum_delta=None, **kwargs)
_MiniGo.pick_by_score = below_threshold
try:
is_pass, move = self._move(board, WHITE, _QuietMoveModel((9, 9)))
finally:
_MiniGo.pick_by_score = original
self.assertFalse(is_pass)
self.assertIsNotNone(move)
def test_lone_stone_inside_shallow_enemy_area_is_hopeless(self) -> None:
# 흑이 오른쪽 아래 귀 (16..18, 16..18) 빈 구역을 완전히 둘러쌌다.
# 면적 계가는 백 돌 하나로 이 구역을 공배로 바꿔 큰 이득처럼 보지만 살 수 없다.
wall = [(15, c) for c in range(15, 19)] + [(r, 15) for r in range(16, 19)]
board = make_board(wall + [(3, 3), (3, 15)], [(9, 9), (9, 3), (3, 9), (15, 9)])
mini = _MiniGo(board, N)
self.assertTrue(mini.is_hopeless_invasion(17, 17, WHITE))
self.assertEqual(mini.self_damage_reason(17, 17, WHITE, min_line=1), "hopeless_invasion")
picked = mini.pick_by_score(
WHITE, [(17, 17), (9, 12)], min_line=1, minimum_delta=None
)
self.assertIsNotNone(picked)
self.assertEqual(picked[:2], (9, 12))
def test_open_area_and_weak_wall_are_not_hopeless(self) -> None:
wall = [(15, c) for c in range(15, 19)] + [(r, 15) for r in range(16, 19)]
board = make_board(wall, [(9, 9)])
mini = _MiniGo(board, N)
self.assertFalse(mini.is_hopeless_invasion(9, 12, WHITE))
# 백 돌이 맞닿은 구역은 침입이 아니라 접전이다.
contact = make_board([(17, 17), (18, 16)], [(16, 17), (17, 16), (9, 9)])
self.assertFalse(_MiniGo(contact, N).is_hopeless_invasion(18, 17, WHITE))
# 흑 벽이 단수에 걸리는 수는 공격이므로 남긴다.
thin = make_board(
[(17, 17), (17, 18), (18, 16)], [(16, 17), (16, 18), (17, 16), (9, 9)]
)
thin_mini = _MiniGo(thin, N)
self.assertTrue(thin_mini.is_legal(18, 18, WHITE))
self.assertFalse(thin_mini.is_hopeless_invasion(18, 18, WHITE))
def test_dead_group_area_fill_is_hopeless(self) -> None:
# 백 (17,17) 한 돌이 흑 벽에 둘러싸여 활로 2개뿐이다.
# 이 영역의 빈점들을 메우는 것은 절망적인 수다.
wall = [(16, c) for c in range(16, 19)] + [(r, 16) for r in range(17, 19)]
board = make_board(wall + [(3, 3)], [(17, 17), (9, 9)])
mini = _MiniGo(board, N)
stones, libs = mini._group(17, 17)
self.assertEqual(len(stones), 1)
self.assertLessEqual(libs, 2)
self.assertTrue(mini.is_hopeless_invasion(18, 18, WHITE))
self.assertEqual(mini.self_damage_reason(18, 18, WHITE, min_line=1), "hopeless_invasion")
def test_shallow_one_eye_with_many_liberties_is_hopeless(self) -> None:
# 백 (17,17)의 활로는 4개지만 전부 흑이 가둔 얕은 귀 안이다.
# 활로 2 이하만 죽은 돌로 보던 조건은 이 숨구멍을 메우게 둔다.
# 메운 돌은 집과 포로로 두 번 세어진다.
wall = [(15, c) for c in range(15, 19)] + [(r, 15) for r in range(16, 19)]
board = make_board(wall + [(3, 3)], [(17, 17), (9, 9)])
mini = _MiniGo(board, N)
_stones, libs = mini._group(17, 17)
self.assertGreater(libs, 2)
self.assertTrue(mini.is_hopeless_invasion(18, 18, WHITE))
self.assertTrue(mini.is_hopeless_invasion(16, 16, WHITE))
self.assertEqual(
mini.self_damage_reason(18, 18, WHITE, min_line=1), "hopeless_invasion"
)
picked = mini.pick_by_score(
WHITE, [(18, 18), (9, 12)], min_line=1, minimum_delta=None
)
self.assertIsNotNone(picked)
self.assertEqual(picked[:2], (9, 12))
def test_pass_remains_when_board_has_no_playable_point(self) -> None:
cells = bytes(BLACK if (r + c) % 2 == 0 else WHITE for r in range(N) for c in range(N))
is_pass, move = self._move(cells, WHITE)
self.assertTrue(is_pass)
self.assertIsNone(move)
def test_diagonal_advance_that_allows_double_atari_is_penalized(self) -> None:
# 백 (5,5), (7,7) 사이 (6,6) 진출 뒤 흑 (5,6)이 두 백 그룹을
# 동시에 단수로 만든다. 각 그룹의 남은 활로가 달라 둘 다 구할 수 없다.
board = make_board(
[(4, 5), (6, 5), (7, 6)],
[(5, 5), (7, 7)],
)
mini = _MiniGo(board, N)
threat = mini.double_atari_threat_after(6, 6, WHITE)
picked = mini.pick_by_score(
WHITE,
[(6, 6), (5, 4)],
min_line=1,
minimum_delta=None,
)
self.assertIsNotNone(threat)
self.assertEqual(threat[0], 2)
self.assertEqual(threat[2], (5, 6))
self.assertIsNotNone(picked)
self.assertEqual(picked[:2], (5, 4))
def test_rejected_coordinate_is_released_after_position_changes(self) -> None:
target = (9, 9)
before = bytes(N * N)
state = {
"reject_bans": [[target[0], target[1]]],
"reject_bans_board": before,
"reject_bans_side": WHITE,
"last_offers": {
str(WHITE): [target[0], target[1], before],
},
}
same_bans, same_inferred = _position_scoped_reject_bans(
state, before, N, WHITE, []
)
self.assertIn(target, same_bans)
self.assertEqual(same_inferred, target)
# 그 수가 실제로 진행된 뒤 따내져 target이 다시 빈 점이 된 국면.
# 다른 교차점의 상태가 바뀌었으므로 과거 좌표 금지는 더 이상 유효하지 않다.
after_capture = bytearray(before)
after_capture[8 * N + 9] = BLACK
changed_bans, changed_inferred = _position_scoped_reject_bans(
state, bytes(after_capture), N, WHITE, [target]
)
self.assertNotIn(target, changed_bans)
self.assertIsNone(changed_inferred)
repeated_bans, _ = _position_scoped_reject_bans(
state, bytes(after_capture), N, WHITE, [target]
)
self.assertNotIn(target, repeated_bans)
# 반대로 같은 국면의 재요청에서 새로 추가된 좌표는 정상적으로 금지한다.
fresh_state: dict = {}
_position_scoped_reject_bans(fresh_state, before, N, WHITE, [])
fresh_bans, _ = _position_scoped_reject_bans(
fresh_state, before, N, WHITE, [target]
)
self.assertIn(target, fresh_bans)
if __name__ == "__main__":
unittest.main()