feat(snake): add adaptive adversarial search
- Share duel search contexts and transpositions across candidate moves. - Add aspiration windows, principal variation ordering, and body caches. - Model simultaneous multiplayer responses with a compact beam rollout. - Adapt search depth and response breadth to the remaining deadline. - Add a deterministic arena benchmark with optional JSON reporting. - Expose search metrics, document benchmarking, and bump Prism to 1.2.0.
This commit is contained in:
@@ -2,9 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from time import perf_counter
|
||||
from typing import Iterable
|
||||
|
||||
from snakes.bitboard import BitBoard
|
||||
|
||||
@@ -47,9 +47,10 @@ class BitboardDuelSearch:
|
||||
self.hazard_stacks = {
|
||||
board.idx(x, y): count for (x, y), count in hazard_count.items()
|
||||
}
|
||||
self.transposition: dict[tuple[DuelState, int], tuple[float, str]] = {}
|
||||
self.transposition: dict[tuple[DuelState, int], tuple[float, str, int | None]] = {}
|
||||
self.killer_moves: dict[int, int] = {}
|
||||
self.history: dict[int, int] = {}
|
||||
self._body_bits_cache: dict[Body, int] = {}
|
||||
self.nodes = 0
|
||||
self.cache_hits = 0
|
||||
|
||||
@@ -79,7 +80,11 @@ class BitboardDuelSearch:
|
||||
for depth in range(1, max_depth + 1):
|
||||
if self._out_of_time(5.0):
|
||||
break
|
||||
value, completed = self._search(state, depth, -float("inf"), float("inf"))
|
||||
window = 80.0 if completed_depth else float("inf")
|
||||
alpha, beta = result - window, result + window
|
||||
value, completed = self._search(state, depth, alpha, beta)
|
||||
if completed and window != float("inf") and (value <= alpha or value >= beta):
|
||||
value, completed = self._search(state, depth, -float("inf"), float("inf"))
|
||||
if not completed:
|
||||
break
|
||||
result = value
|
||||
@@ -119,9 +124,13 @@ class BitboardDuelSearch:
|
||||
for depth in range(1, max_depth + 1):
|
||||
if self._out_of_time(5.0):
|
||||
break
|
||||
value, completed = self._search_selected_move(
|
||||
state, target_idx, depth, -float("inf"), float("inf")
|
||||
)
|
||||
window = 80.0 if completed_depth else float("inf")
|
||||
alpha, beta = result - window, result + window
|
||||
value, completed = self._search_selected_move(state, target_idx, depth, alpha, beta)
|
||||
if completed and window != float("inf") and (value <= alpha or value >= beta):
|
||||
value, completed = self._search_selected_move(
|
||||
state, target_idx, depth, -float("inf"), float("inf")
|
||||
)
|
||||
if not completed:
|
||||
break
|
||||
result = value
|
||||
@@ -200,7 +209,7 @@ class BitboardDuelSearch:
|
||||
cached = self.transposition.get(cache_key)
|
||||
if cached is not None:
|
||||
self.cache_hits += 1
|
||||
cached_value, bound = cached
|
||||
cached_value, bound, preferred_move = cached
|
||||
if bound == "exact":
|
||||
return cached_value, True
|
||||
if bound == "lower":
|
||||
@@ -217,9 +226,10 @@ class BitboardDuelSearch:
|
||||
if not enemy_moves:
|
||||
return self.WIN + depth, True
|
||||
|
||||
my_moves = self._ordered_moves(my_moves, state, depth, True)
|
||||
my_moves = self._ordered_moves(my_moves, state, depth, True, preferred_move if cached is not None else None)
|
||||
enemy_moves = self._ordered_moves(enemy_moves, state, depth, False)
|
||||
best = -float("inf")
|
||||
best_move: int | None = None
|
||||
|
||||
for my_target in my_moves:
|
||||
worst = float("inf")
|
||||
@@ -240,7 +250,9 @@ class BitboardDuelSearch:
|
||||
self.history[my_target] = self.history.get(my_target, 0) + depth * depth
|
||||
break
|
||||
|
||||
best = max(best, worst)
|
||||
if worst > best:
|
||||
best = worst
|
||||
best_move = my_target
|
||||
alpha = max(alpha, best)
|
||||
if alpha >= beta:
|
||||
break
|
||||
@@ -251,7 +263,7 @@ class BitboardDuelSearch:
|
||||
bound = "lower"
|
||||
else:
|
||||
bound = "exact"
|
||||
self.transposition[cache_key] = (best, bound)
|
||||
self.transposition[cache_key] = (best, bound, best_move)
|
||||
return best, True
|
||||
|
||||
def _advance(self, state: DuelState, my_target: int, enemy_target: int) -> tuple[DuelState, float | None]:
|
||||
@@ -308,7 +320,10 @@ class BitboardDuelSearch:
|
||||
"""
|
||||
return list(self._iter_bits(self.board.neighbors_of(body[0])))
|
||||
|
||||
def _ordered_moves(self, moves: list[int], state: DuelState, depth: int, mine: bool) -> list[int]:
|
||||
def _ordered_moves(
|
||||
self, moves: list[int], state: DuelState, depth: int, mine: bool,
|
||||
preferred: int | None = None,
|
||||
) -> list[int]:
|
||||
body = state.my_body if mine else state.enemy_body
|
||||
other = state.enemy_body if mine else state.my_body
|
||||
killer = self.killer_moves.get(depth)
|
||||
@@ -320,8 +335,9 @@ class BitboardDuelSearch:
|
||||
food_bonus = 200.0 if (1 << target) & state.food_bits else 0.0
|
||||
space = self.board.flood_count(target, (self._body_bits(body[1:]) | self._body_bits(other[1:])) & ~(1 << target))
|
||||
center = -(abs(x - center_x) + abs(y - center_y))
|
||||
preferred_bonus = 20_000.0 if target == preferred else 0.0
|
||||
killer_bonus = 10_000.0 if target == killer else 0.0
|
||||
return killer_bonus + self.history.get(target, 0) + food_bonus + space * 2.0 + center, -target
|
||||
return preferred_bonus + killer_bonus + self.history.get(target, 0) + food_bonus + space * 2.0 + center, -target
|
||||
|
||||
# Our strongest-looking moves first; enemy ordering uses the same quality
|
||||
# estimate because dangerous enemy replies tend to gain space and food.
|
||||
@@ -329,13 +345,25 @@ class BitboardDuelSearch:
|
||||
|
||||
def _evaluate(self, state: DuelState) -> float:
|
||||
my_blocked = self._body_bits(state.my_body[1:]) | self._body_bits(state.enemy_body[1:])
|
||||
my_space = self.board.flood_count(state.my_body[0], my_blocked)
|
||||
enemy_space = self.board.flood_count(state.enemy_body[0], my_blocked)
|
||||
my_liberties = self.board.open_neighbor_count(state.my_body[0], my_blocked)
|
||||
enemy_liberties = self.board.open_neighbor_count(state.enemy_body[0], my_blocked)
|
||||
length_score = (len(state.my_body) - len(state.enemy_body)) * 18.0
|
||||
health_score = (state.my_health - state.enemy_health) * 0.15
|
||||
return (my_space - enemy_space) * 2.0 + (my_liberties - enemy_liberties) * 12.0 + length_score + health_score
|
||||
my_head, enemy_head = state.my_body[0], state.enemy_body[0]
|
||||
my_space = self.board.flood_count(my_head, my_blocked)
|
||||
enemy_space = self.board.flood_count(enemy_head, my_blocked)
|
||||
my_liberties = self.board.open_neighbor_count(my_head, my_blocked)
|
||||
enemy_liberties = self.board.open_neighbor_count(enemy_head, my_blocked)
|
||||
territory = self.board.territory(my_head, [enemy_head], my_blocked)
|
||||
my_tail_path = self.board.path_distance(my_head, state.my_body[-1], my_blocked)
|
||||
enemy_tail_path = self.board.path_distance(enemy_head, state.enemy_body[-1], my_blocked)
|
||||
tail_score = (12.0 if my_tail_path is not None else -24.0) - (12.0 if enemy_tail_path is not None else -24.0)
|
||||
my_hazard = self._hazard_cost(my_head, state.previous_hazard_bits)
|
||||
enemy_hazard = self._hazard_cost(enemy_head, state.previous_hazard_bits)
|
||||
length_score = (len(state.my_body) - len(state.enemy_body)) * 20.0
|
||||
health_score = (state.my_health - state.enemy_health) * 0.18
|
||||
forced_score = (my_liberties > 1) * 10.0 - (enemy_liberties > 1) * 10.0
|
||||
return (
|
||||
(my_space - enemy_space) * 1.5 + territory * 1.2
|
||||
+ (my_liberties - enemy_liberties) * 14.0 + length_score + health_score
|
||||
+ tail_score + forced_score + (enemy_hazard - my_hazard) * 0.8
|
||||
)
|
||||
|
||||
def _hazard_cost(self, target: int, previous_hazard_bits: int) -> int:
|
||||
bit = 1 << target
|
||||
@@ -351,11 +379,15 @@ class BitboardDuelSearch:
|
||||
def _tail_stacked(body: Body) -> bool:
|
||||
return len(body) >= 2 and body[-1] == body[-2]
|
||||
|
||||
@staticmethod
|
||||
def _body_bits(body: Body) -> int:
|
||||
def _body_bits(self, body: Body) -> int:
|
||||
cached = self._body_bits_cache.get(body)
|
||||
if cached is not None:
|
||||
return cached
|
||||
bits = 0
|
||||
for cell in body:
|
||||
bits |= 1 << cell
|
||||
if len(self._body_bits_cache) < 16_384:
|
||||
self._body_bits_cache[body] = bits
|
||||
return bits
|
||||
|
||||
@staticmethod
|
||||
|
||||
Reference in New Issue
Block a user