feat: add Zenith strategy and move latency tracing
Build and Push Docker Container / build-and-push (push) Successful in 2m18s
Build and Push Docker Container / build-and-push (push) Successful in 2m18s
- Register Zenith 1.1 with balanced duel verdicts and forced-outcome overrides. - Prioritize permanent-trail space in Constrictor and health-aware hazard routes. - Deduct executor queue delays and return fallback directions on move timeouts. - Correlate response headers, replay timings, and sampled structured trace logs. - Move shared metrics publication off the response path without losing local counts. - Allow configurable tournament opponents and add a psql helper recipe. - Preserve empty latency values through compact replay encoding and migration. - Update Arena game links and use default assets for missing customizations. - Cover strategy, HTTP lifecycle, tracing, replay codecs, and dashboard behavior. - Validate with 260 passing Python tests, 4 skips, and 2 passing JavaScript tests.
This commit is contained in:
@@ -2,5 +2,10 @@
|
||||
|
||||
from snakes.strategies.apex import ApexBattleSnake
|
||||
from snakes.strategies.prism import PrismBattleSnake_GPT_5_6_Sol
|
||||
from snakes.strategies.zenith import ZenithBattleSnake_ClaudeOpus5_5
|
||||
|
||||
__all__ = ("ApexBattleSnake", "PrismBattleSnake_GPT_5_6_Sol")
|
||||
__all__ = (
|
||||
"ApexBattleSnake",
|
||||
"PrismBattleSnake_GPT_5_6_Sol",
|
||||
"ZenithBattleSnake_ClaudeOpus5_5",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"""ZenithBattleSnake_ClaudeOpus5_5 v1.1.0
|
||||
|
||||
Built on PrismBattleSnake_GPT_5_6_Sol v1.5.0. Prism's bitboard engine, duel
|
||||
search, and adversarial rollouts are inherited unchanged.
|
||||
|
||||
Prism only runs the exact duel search on near-tied heuristic candidates
|
||||
(score gap <= 8). A move outside that window is never checked against every
|
||||
enemy reply, so a forced loss with a strong heuristic score can still be
|
||||
played, and a forced win with a weaker score can be skipped.
|
||||
|
||||
Z1: Root verdict pass — after Prism decides, every safe duel candidate is
|
||||
resolved against all simultaneous enemy replies with the shared
|
||||
transposition table (cheap: most subtrees are already cached).
|
||||
Z2: Proven-loss veto — a candidate whose completed search proves a loss is
|
||||
replaced by the best non-losing candidate.
|
||||
Z3: Proven-win override — a candidate whose completed search proves a win is
|
||||
played even if the heuristic preferred another move.
|
||||
"""
|
||||
from time import perf_counter
|
||||
|
||||
from snakes.engine.duel_search import BitboardDuelSearch
|
||||
from snakes.strategies.zenith_survival import ZenithSurvivalMixin
|
||||
from snakes.strategies.prism import PrismBattleSnake_GPT_5_6_Sol
|
||||
|
||||
# A completed search value beyond half of WIN/LOSS can only come from a terminal.
|
||||
_PROVEN_WIN = BitboardDuelSearch.WIN / 2
|
||||
_PROVEN_LOSS = BitboardDuelSearch.LOSS / 2
|
||||
# Reserve kept for Apex's history bookkeeping after the verdict pass.
|
||||
_VERDICT_RESERVE_MS = 25.0
|
||||
|
||||
class ZenithBattleSnake_ClaudeOpus5_5(ZenithSurvivalMixin, PrismBattleSnake_GPT_5_6_Sol):
|
||||
VERSION = "1.1.0"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.name = "ZenithBattleSnake"
|
||||
self.version = self.VERSION
|
||||
|
||||
def _get_timeout_buffer_ms(self):
|
||||
# Leave room for executor scheduling, history, JSON and network delivery.
|
||||
return max(180, super()._get_timeout_buffer_ms())
|
||||
|
||||
def _choose_duel_move(
|
||||
self, safe_moves, my_body, my_len, my_health, other_snakes, food_set,
|
||||
hazard_set, hazard_damage, hazard_count, previous_hazard_set,
|
||||
enemy_attack_map, enemy_can_grow, total_occupancy, width, height, deadline,
|
||||
):
|
||||
best_move, scores, depth = super()._choose_duel_move(
|
||||
safe_moves=safe_moves, my_body=my_body, my_len=my_len,
|
||||
my_health=my_health, other_snakes=other_snakes, food_set=food_set,
|
||||
hazard_set=hazard_set, hazard_damage=hazard_damage,
|
||||
hazard_count=hazard_count, previous_hazard_set=previous_hazard_set,
|
||||
enemy_attack_map=enemy_attack_map, enemy_can_grow=enemy_can_grow,
|
||||
total_occupancy=total_occupancy, width=width, height=height,
|
||||
deadline=deadline,
|
||||
)
|
||||
if len(safe_moves) < 2:
|
||||
return best_move, scores, depth
|
||||
|
||||
verdicts = self._duel_verdicts(
|
||||
safe_moves=safe_moves, my_body=my_body, enemy=other_snakes[0],
|
||||
my_health=my_health, food_set=food_set, hazard_set=hazard_set,
|
||||
hazard_damage=hazard_damage, hazard_count=hazard_count,
|
||||
previous_hazard_set=previous_hazard_set, width=width, height=height,
|
||||
deadline=deadline,
|
||||
)
|
||||
chosen = self._apply_verdicts(best_move, scores, verdicts)
|
||||
if chosen != best_move:
|
||||
self.add_to_history({
|
||||
"turn": self.game_board.get_turn(), "mode": "duel",
|
||||
"zenith_verdict_changed_move": True,
|
||||
"from": best_move, "to": chosen,
|
||||
"verdicts": {m: round(v, 1) for m, (v, _) in verdicts.items()},
|
||||
})
|
||||
depth = max([depth, *(d for _, d in verdicts.values())])
|
||||
return chosen, scores, depth
|
||||
|
||||
def _duel_verdicts(
|
||||
self, safe_moves, my_body, enemy, my_health, food_set, hazard_set,
|
||||
hazard_damage, hazard_count, previous_hazard_set, width, height, deadline,
|
||||
) -> dict[str, tuple[float, int]]:
|
||||
"""Return completed exact-search values per candidate (Z1)."""
|
||||
verdicts: dict[str, tuple[float, int]] = {}
|
||||
# Search every root at equal depth. A full iterative search for the first
|
||||
# move could consume the deadline before the other roots were visited.
|
||||
search_deadline = None if deadline is None else deadline - _VERDICT_RESERVE_MS / 1000
|
||||
if search_deadline is not None and perf_counter() >= search_deadline:
|
||||
return verdicts
|
||||
search = self._new_duel_search(
|
||||
food_set, hazard_set, hazard_count, hazard_damage, width, height, search_deadline,
|
||||
)
|
||||
search.deadline = search_deadline
|
||||
for depth in range(1, self._planning_depth + 1):
|
||||
layer = {}
|
||||
for move, pos in safe_moves.items():
|
||||
value, completed = search.search_candidate(
|
||||
my_body, enemy["body"], (pos["x"], pos["y"]),
|
||||
my_health, enemy.get("health", 100), depth, previous_hazard_set,
|
||||
)
|
||||
if completed != depth:
|
||||
return verdicts
|
||||
layer[move] = (value, completed)
|
||||
verdicts = layer
|
||||
return verdicts
|
||||
|
||||
@staticmethod
|
||||
def _apply_verdicts(
|
||||
best_move: str, scores: dict[str, float],
|
||||
verdicts: dict[str, tuple[float, int]],
|
||||
) -> str:
|
||||
"""Apply proven-win override (Z3) and proven-loss veto (Z2)."""
|
||||
def rank(move: str) -> tuple[float, float]:
|
||||
return verdicts[move][0], scores.get(move, float("-inf"))
|
||||
|
||||
wins = [m for m, (v, _) in verdicts.items() if v >= _PROVEN_WIN]
|
||||
if wins and best_move not in wins:
|
||||
return max(wins, key=rank)
|
||||
|
||||
best_verdict = verdicts.get(best_move)
|
||||
if best_verdict is None or best_verdict[0] > _PROVEN_LOSS:
|
||||
return best_move
|
||||
alternatives = [m for m, (v, _) in verdicts.items() if v > _PROVEN_LOSS]
|
||||
if not alternatives:
|
||||
return best_move
|
||||
# Prefer Prism's heuristic among surviving moves; fall back to search value.
|
||||
return max(alternatives, key=lambda m: (scores.get(m, float("-inf")), verdicts[m][0]))
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Zenith-specific permanent-trail and health-aware scoring."""
|
||||
from snakes.engine.health_routes import health_route_cost
|
||||
from snakes.engine.survival_search import CompactSurvivalSearch
|
||||
|
||||
class ZenithSurvivalMixin:
|
||||
def _choose_constrictor_move(
|
||||
self, safe_moves, my_body, my_len, my_health, other_snakes, food_set,
|
||||
hazard_set, hazard_damage, hazard_count, previous_hazard_set,
|
||||
enemy_attack_map, enemy_can_grow, total_occupancy, width, height, deadline,
|
||||
):
|
||||
# In constrictor bodies never vacate. Remaining free cells are a survival
|
||||
# horizon, not storage for the existing body. Requiring free space >= body
|
||||
# length incorrectly marks every late-game candidate dead.
|
||||
bb = self._get_bb(width, height)
|
||||
occupied = bb.set_to_bits({
|
||||
(p['x'], p['y']) for body in [my_body, *(s['body'] for s in other_snakes)]
|
||||
for p in body
|
||||
})
|
||||
scores = {}
|
||||
for move, pos in safe_moves.items():
|
||||
target = bb.idx(pos['x'], pos['y'])
|
||||
space = bb.flood_count(target, occupied)
|
||||
liberties = bb.open_neighbor_count(target, occupied)
|
||||
danger = enemy_attack_map.get((pos['x'], pos['y']), 0) >= my_len
|
||||
# Preserve free capacity first. Enemy confinement must not outweigh our
|
||||
# own ability to survive, and tail access has no meaning in this mode.
|
||||
scores[move] = space * 10.0 + liberties * 8.0 - (10000 if danger else 0)
|
||||
if liberties == 0:
|
||||
scores[move] -= 5000
|
||||
|
||||
# Give each candidate the same shallow horizon before considering deeper
|
||||
# layers. Never let the first candidate consume everyone else's budget.
|
||||
search = CompactSurvivalSearch(
|
||||
bb, set(), True, deadline, branch=3, enemy_branch=2, response_cap=8,
|
||||
)
|
||||
ranked = scores
|
||||
for depth in range(1, 5):
|
||||
layer = {}
|
||||
for move, pos in safe_moves.items():
|
||||
if self._remaining_ms(deadline) < 15:
|
||||
break
|
||||
before = search.deadline_exits
|
||||
value = search.search_selected(my_body, other_snakes, (pos['x'], pos['y']), depth)
|
||||
if search.deadline_exits != before:
|
||||
break
|
||||
layer[move] = value
|
||||
if len(layer) != len(safe_moves):
|
||||
break
|
||||
# Rollout can veto immediate/near-term death, but cannot turn temporary
|
||||
# enemy confinement into an overwhelming speculative attack reward.
|
||||
ranked = {m: scores[m] + v * 0.15 for m, v in layer.items()}
|
||||
best = max(ranked, key=ranked.get)
|
||||
if depth == 4:
|
||||
return best, ranked
|
||||
return max(ranked, key=ranked.get), ranked
|
||||
|
||||
def _score_move(self, *args, **kwargs):
|
||||
score, info = super()._score_move(*args, **kwargs)
|
||||
# Apex calls this hook with keywords. Constrictor uses its dedicated path.
|
||||
if not kwargs or kwargs['is_constrictor']:
|
||||
return score, info
|
||||
point = (kwargs['pos']['x'], kwargs['pos']['y'])
|
||||
food = kwargs['food_set']
|
||||
hazards = kwargs['hazard_set']
|
||||
damage = kwargs['hazard_damage']
|
||||
stacks = kwargs['hazard_count']
|
||||
health = 100 if point in food else kwargs['my_health'] - 1 - (
|
||||
damage * stacks.get(point, 1) if point in hazards else 0
|
||||
)
|
||||
if health <= 0:
|
||||
score -= 20000
|
||||
info['is_survivable'] = False
|
||||
return score, info
|
||||
if not hazards:
|
||||
return score, info
|
||||
blocked = info['blocked'] - {point}
|
||||
cost = health_route_cost(
|
||||
point, food, hazards, stacks, damage, blocked,
|
||||
kwargs['width'], kwargs['height'], refuge=True,
|
||||
)
|
||||
# Existing hazards deal damage on entry even when newly seen. Prefer
|
||||
# leaving lethal hazard corridors before low-health urgency kicks in.
|
||||
if cost is None or cost >= health:
|
||||
score -= 12000
|
||||
info['is_survivable'] = False
|
||||
else:
|
||||
score -= cost * 8.0
|
||||
if health < 55:
|
||||
food_cost = health_route_cost(
|
||||
point, food, hazards, stacks, damage, blocked,
|
||||
kwargs['width'], kwargs['height'],
|
||||
)
|
||||
if food_cost is None or food_cost >= health:
|
||||
score -= 2500
|
||||
else:
|
||||
score += (55 - health) * 12.0 / (food_cost + 1)
|
||||
return score, info
|
||||
Reference in New Issue
Block a user