feat: add gameplay stats and restore Zenith rollout depth
Build and Push Docker Container / build-and-push (push) Successful in 3m6s
Build and Push Docker Container / build-and-push (push) Successful in 3m6s
- Add read-only gameplay statistics with filters and opponent comparisons. - Report move-trace latency percentiles and external timeout counts. - Pair engine latency with the preceding turn to separate timing stages. - Adjust Zenith rollout deepening for its larger timeout reserve. - Bump Zenith to 1.2.0 and document the fix and snake-stats command. - Cover statistics, trace aggregation, rollout depth, and verdict budgets.
This commit is contained in:
@@ -117,11 +117,32 @@ from the strategy budget and returns a legal-direction fallback instead of null
|
|||||||
when its request budget expires. This is best-effort deadline handling, not a
|
when its request budget expires. This is best-effort deadline handling, not a
|
||||||
hard real-time guarantee under CPU saturation.
|
hard real-time guarantee under CPU saturation.
|
||||||
|
|
||||||
|
Version 1.2.0 fixes a search-depth regression from that larger reserve. Prism
|
||||||
|
looks one turn deeper in 3-snake rollouts when more than 250 ms remain; with
|
||||||
|
Zenith's 50 ms shorter budget that gate was never reached in production, so
|
||||||
|
Zenith always stopped at depth 2. Zenith now lowers the gate by its extra
|
||||||
|
reserve and deepens after the same elapsed time as Prism.
|
||||||
|
|
||||||
Run it with:
|
Run it with:
|
||||||
```sh
|
```sh
|
||||||
SNAKE=ZenithBattleSnake_ClaudeOpus5_5 python main.py
|
SNAKE=ZenithBattleSnake_ClaudeOpus5_5 python main.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Show read-only win statistics per own snake version and per opponent from the
|
||||||
|
PostgreSQL gameplay database (`GAMEPLAY_DB_PG_DSN`):
|
||||||
|
```sh
|
||||||
|
just snake-stats --since-hours 24
|
||||||
|
just snake-stats --snake-type ZenithBattleSnake --game-type duel --min-games 5
|
||||||
|
just snake-stats --since-hours 48 --compare # opponents side by side per own version
|
||||||
|
just snake-stats --traces --since-hours 24 # move-trace latency per version and mode
|
||||||
|
```
|
||||||
|
|
||||||
|
`--traces` unpacks the newest `--trace-games` (default 300) finished replays and
|
||||||
|
reports p50/p90/p99 for strategy time, server overhead (handler minus
|
||||||
|
strategy), handler time, network/outside-handler time (engine latency minus
|
||||||
|
handler) and engine latency. `ext.timeouts` counts engine timeouts where our
|
||||||
|
handler had answered at least 100 ms before the deadline.
|
||||||
|
|
||||||
Benchmark Apex and Prism against sampled positions from a gameplay database:
|
Benchmark Apex and Prism against sampled positions from a gameplay database:
|
||||||
```sh
|
```sh
|
||||||
python scripts/benchmark_snakes_from_db.py \
|
python scripts/benchmark_snakes_from_db.py \
|
||||||
|
|||||||
@@ -90,6 +90,15 @@ db *args:
|
|||||||
command -v psql >/dev/null || { echo "Install the PostgreSQL client (psql) first." >&2; exit 1; }
|
command -v psql >/dev/null || { echo "Install the PostgreSQL client (psql) first." >&2; exit 1; }
|
||||||
exec psql --dbname="$GAMEPLAY_DB_PG_DSN" "$@"
|
exec psql --dbname="$GAMEPLAY_DB_PG_DSN" "$@"
|
||||||
|
|
||||||
|
# Read-only win stats per own snake version and per opponent (extra args are passed through)
|
||||||
|
[positional-arguments]
|
||||||
|
snake-stats *args:
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
: "${GAMEPLAY_DB_PG_DSN:?Set GAMEPLAY_DB_PG_DSN in .env or the environment}"
|
||||||
|
PYTHONPATH="{{justfile_directory()}}" uv run python "{{justfile_directory()}}/scripts/gameplay_snake_stats.py" "$@"
|
||||||
|
|
||||||
cleanup-gameplay-postgres older_than_days="30" execute="false" vacuum="false":
|
cleanup-gameplay-postgres older_than_days="30" execute="false" vacuum="false":
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Read-only win statistics per own snake version and per opponent snake.
|
||||||
|
|
||||||
|
Reads the PostgreSQL gameplay database (``GAMEPLAY_DB_PG_DSN``). The session is
|
||||||
|
switched to read-only before any query runs, so it is safe against production.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class StatsFilter:
|
||||||
|
since_hours: float | None = None
|
||||||
|
snake_type: str | None = None
|
||||||
|
snake_version: str | None = None
|
||||||
|
game_type: str | None = None
|
||||||
|
source: str | None = None
|
||||||
|
|
||||||
|
def build_where(filters: StatsFilter, alias: str = "g") -> tuple[str, list]:
|
||||||
|
"""Return a WHERE clause for finished games plus its positional arguments."""
|
||||||
|
clauses = [f"{alias}.status = 'finished'"]
|
||||||
|
args: list = []
|
||||||
|
|
||||||
|
def add(sql: str, value) -> None:
|
||||||
|
args.append(value)
|
||||||
|
clauses.append(sql.format(alias=alias, n=len(args)))
|
||||||
|
|
||||||
|
if filters.since_hours is not None:
|
||||||
|
add("{alias}.started_at > now() - make_interval(secs => ${n})", float(filters.since_hours) * 3600)
|
||||||
|
if filters.snake_type:
|
||||||
|
add("{alias}.your_snake_type = ${n}", filters.snake_type)
|
||||||
|
if filters.snake_version:
|
||||||
|
add("{alias}.your_snake_version = ${n}", filters.snake_version)
|
||||||
|
if filters.game_type:
|
||||||
|
add("{alias}.game_type = ${n}", filters.game_type)
|
||||||
|
if filters.source:
|
||||||
|
add("{alias}.source = ${n}", filters.source)
|
||||||
|
return "WHERE " + " AND ".join(clauses), args
|
||||||
|
|
||||||
|
def own_snake_query(filters: StatsFilter) -> tuple[str, list]:
|
||||||
|
where, args = build_where(filters)
|
||||||
|
return f"""
|
||||||
|
SELECT g.your_snake_type AS snake, g.your_snake_version AS version,
|
||||||
|
g.game_type, count(*) AS games, sum(g.winner_you::int) AS wins,
|
||||||
|
round(avg(g.final_turn)) AS avg_turns, max(g.started_at) AS last_game
|
||||||
|
FROM games AS g
|
||||||
|
{where}
|
||||||
|
GROUP BY 1, 2, 3
|
||||||
|
ORDER BY 1, 2, 3
|
||||||
|
""", args
|
||||||
|
|
||||||
|
def opponent_query(filters: StatsFilter, min_games: int) -> tuple[str, list]:
|
||||||
|
where, args = build_where(filters)
|
||||||
|
args.append(max(1, min_games))
|
||||||
|
return f"""
|
||||||
|
SELECT s.snake_name AS opponent, g.your_snake_type AS snake,
|
||||||
|
g.your_snake_version AS version,
|
||||||
|
count(*) AS games, sum(g.winner_you::int) AS wins,
|
||||||
|
sum((g.winner_name = s.snake_name)::int) AS their_wins,
|
||||||
|
round(avg(g.final_turn)) AS avg_turns
|
||||||
|
FROM games AS g
|
||||||
|
JOIN game_snakes AS s ON s.game_id = g.game_id AND NOT s.is_you
|
||||||
|
{where}
|
||||||
|
GROUP BY 1, 2, 3
|
||||||
|
HAVING count(*) >= ${len(args)}
|
||||||
|
ORDER BY count(*) DESC, 1
|
||||||
|
""", args
|
||||||
|
|
||||||
|
def win_rate(wins: int, games: int) -> str:
|
||||||
|
return f"{100.0 * wins / games:5.1f}%" if games else " -"
|
||||||
|
|
||||||
|
def format_table(headers: list[str], rows: list[list]) -> str:
|
||||||
|
cells = [[str(value) for value in row] for row in rows]
|
||||||
|
widths = [max(len(h), *(len(row[i]) for row in cells)) if cells else len(h) for i, h in enumerate(headers)]
|
||||||
|
line = lambda values: " ".join(v.ljust(w) for v, w in zip(values, widths)).rstrip()
|
||||||
|
return "\n".join([line(headers), line(["-" * w for w in widths]), *(line(row) for row in cells)])
|
||||||
|
|
||||||
|
def own_rows(records: list[dict]) -> list[list]:
|
||||||
|
return [
|
||||||
|
[r["snake"], r["version"], r["game_type"], r["games"], r["wins"],
|
||||||
|
win_rate(r["wins"], r["games"]), r["avg_turns"],
|
||||||
|
r["last_game"].strftime("%Y-%m-%d %H:%M") if r["last_game"] else "-"]
|
||||||
|
for r in records
|
||||||
|
]
|
||||||
|
|
||||||
|
def opponent_rows(records: list[dict]) -> list[list]:
|
||||||
|
return [
|
||||||
|
[r["opponent"], f"{r['snake']} {r['version']}", r["games"], r["wins"],
|
||||||
|
win_rate(r["wins"], r["games"]), r["their_wins"],
|
||||||
|
win_rate(r["their_wins"], r["games"]), r["avg_turns"]]
|
||||||
|
for r in records
|
||||||
|
]
|
||||||
|
|
||||||
|
def pivot_rows(records: list[dict], min_games: int) -> tuple[list[str], list[list]]:
|
||||||
|
"""One row per opponent, one ``wins/games win%`` column per own snake version."""
|
||||||
|
versions = sorted({f"{r['snake']} {r['version']}" for r in records})
|
||||||
|
table: dict[str, dict[str, tuple[int, int]]] = {}
|
||||||
|
for r in records:
|
||||||
|
table.setdefault(r["opponent"], {})[f"{r['snake']} {r['version']}"] = (r["wins"], r["games"])
|
||||||
|
rows = []
|
||||||
|
for opponent, cells in sorted(table.items(), key=lambda item: -sum(g for _, g in item[1].values())):
|
||||||
|
if len(cells) < 2 or any(games < min_games for _, games in cells.values()):
|
||||||
|
continue
|
||||||
|
rows.append([opponent, *(
|
||||||
|
f"{cells[v][0]}/{cells[v][1]} {win_rate(*cells[v]).strip()}" if v in cells else "-"
|
||||||
|
for v in versions
|
||||||
|
)])
|
||||||
|
return ["opponent", *versions], rows
|
||||||
|
|
||||||
|
async def _read_only_connection(dsn: str):
|
||||||
|
import asyncpg # noqa: PLC0415
|
||||||
|
|
||||||
|
connection = await asyncpg.connect(dsn=dsn)
|
||||||
|
await connection.execute("SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY")
|
||||||
|
return connection
|
||||||
|
|
||||||
|
async def collect_traces(dsn: str, filters: StatsFilter, max_games: int):
|
||||||
|
from scripts.move_trace_stats import load_groups # noqa: PLC0415
|
||||||
|
|
||||||
|
connection = await _read_only_connection(dsn)
|
||||||
|
try:
|
||||||
|
where, args = build_where(filters)
|
||||||
|
return await load_groups(connection, where, args, max_games)
|
||||||
|
finally:
|
||||||
|
await connection.close()
|
||||||
|
|
||||||
|
async def collect(dsn: str, filters: StatsFilter, min_games: int, limit: int) -> tuple[list[dict], list[dict]]:
|
||||||
|
connection = await _read_only_connection(dsn)
|
||||||
|
try:
|
||||||
|
own_sql, own_args = own_snake_query(filters)
|
||||||
|
opp_sql, opp_args = opponent_query(filters, min_games)
|
||||||
|
own = [dict(row) for row in await connection.fetch(own_sql, *own_args)]
|
||||||
|
opponents = [dict(row) for row in await connection.fetch(opp_sql, *opp_args)]
|
||||||
|
return own, opponents if limit <= 0 else opponents[:limit]
|
||||||
|
finally:
|
||||||
|
await connection.close()
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--dsn", default=os.environ.get("GAMEPLAY_DB_PG_DSN"))
|
||||||
|
parser.add_argument("--since-hours", type=float, default=None, help="Only games started within N hours")
|
||||||
|
parser.add_argument("--snake-type", help="Own snake type, e.g. ZenithBattleSnake")
|
||||||
|
parser.add_argument("--snake-version", help="Own snake version, e.g. 1.2.0")
|
||||||
|
parser.add_argument("--game-type", help="duel, standard, constrictor, ...")
|
||||||
|
parser.add_argument("--source", help="ladder, arena, custom, ...")
|
||||||
|
parser.add_argument("--min-games", type=int, default=3, help="Hide opponents with fewer games")
|
||||||
|
parser.add_argument("--limit", type=int, default=40, help="Maximum opponent rows (0 = all)")
|
||||||
|
parser.add_argument("--compare", action="store_true", help="Side-by-side win rates of own snake versions per opponent")
|
||||||
|
parser.add_argument("--traces", action="store_true", help="Move-trace latency stats per snake version and mode")
|
||||||
|
parser.add_argument("--trace-games", type=int, default=300, help="Newest finished games to unpack for --traces")
|
||||||
|
args = parser.parse_args()
|
||||||
|
if not args.dsn:
|
||||||
|
raise SystemExit("Set GAMEPLAY_DB_PG_DSN or pass --dsn")
|
||||||
|
|
||||||
|
filters = StatsFilter(args.since_hours, args.snake_type, args.snake_version, args.game_type, args.source)
|
||||||
|
if args.traces:
|
||||||
|
from scripts.move_trace_stats import summary_rows # noqa: PLC0415
|
||||||
|
|
||||||
|
groups, games = asyncio.run(collect_traces(args.dsn, filters, args.trace_games))
|
||||||
|
print(f"Move traces from {games} newest finished games (ms; ext.timeouts = engine timeout while our handler answered >=100 ms early)")
|
||||||
|
print(format_table(*summary_rows(groups)))
|
||||||
|
return
|
||||||
|
limit = 0 if args.compare else args.limit
|
||||||
|
own, opponents = asyncio.run(collect(args.dsn, filters, args.min_games, limit))
|
||||||
|
print("Own snakes")
|
||||||
|
print(format_table(["snake", "version", "mode", "games", "wins", "win%", "turns", "last game (UTC)"], own_rows(own)))
|
||||||
|
if args.compare:
|
||||||
|
headers, rows = pivot_rows(opponents, args.min_games)
|
||||||
|
print("\nOpponents faced by several own snakes (our wins/games win%)")
|
||||||
|
print(format_table(headers, rows[:args.limit] if args.limit > 0 else rows))
|
||||||
|
return
|
||||||
|
print("\nOpponents (win% = our win rate against them)")
|
||||||
|
print(format_table(["opponent", "our snake", "games", "wins", "win%", "their wins", "their%", "turns"], opponent_rows(opponents)))
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""Aggregate stored move traces into per-snake latency statistics.
|
||||||
|
|
||||||
|
Each move stores ``my_thinking.move_trace`` with server-side stage timings. The
|
||||||
|
engine-measured latency for turn ``T`` arrives with the board of turn ``T + 1``,
|
||||||
|
so both are paired here to split total latency into strategy, server overhead
|
||||||
|
and network/outside-handler time.
|
||||||
|
"""
|
||||||
|
from collections import defaultdict
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from server.database.compact.replay_blob import unpack_replay
|
||||||
|
from server.database.compact.thinking import MASK_KEY
|
||||||
|
|
||||||
|
STAGES = ("strategy_ms", "overhead_ms", "handler_ms", "network_ms", "engine_ms")
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TraceGroup:
|
||||||
|
moves: int = 0
|
||||||
|
timeouts: int = 0
|
||||||
|
timeouts_fast_handler: int = 0
|
||||||
|
values: dict[str, list[float]] = field(default_factory=lambda: defaultdict(list))
|
||||||
|
|
||||||
|
def _thinking_dict(thinking: object) -> dict:
|
||||||
|
if not isinstance(thinking, dict):
|
||||||
|
return {}
|
||||||
|
if thinking.get(MASK_KEY) == 0 and isinstance(thinking.get("_raw"), dict):
|
||||||
|
return thinking["_raw"]
|
||||||
|
return thinking
|
||||||
|
|
||||||
|
def _number(value: object) -> float | None:
|
||||||
|
return float(value) if isinstance(value, (int, float)) and not isinstance(value, bool) else None
|
||||||
|
|
||||||
|
def _latency(value: object) -> float | None:
|
||||||
|
# Compact storage keeps latency as a small int; "" (no response) is negative.
|
||||||
|
number = _number(value)
|
||||||
|
return number if number is not None and number >= 0 else None
|
||||||
|
|
||||||
|
def add_game(groups: dict[tuple, TraceGroup], game: dict, you_id: str, turns: list, states: list) -> None:
|
||||||
|
"""Add every traced move of one game to ``groups`` keyed by snake/version/mode."""
|
||||||
|
latency_by_turn = {s.turn: _latency(s.latency) for s in states if s.snake_id == you_id}
|
||||||
|
for record in turns:
|
||||||
|
thinking = _thinking_dict(record.thinking)
|
||||||
|
trace = thinking.get("move_trace")
|
||||||
|
if not isinstance(trace, dict):
|
||||||
|
continue
|
||||||
|
handler = _number(trace.get("handler_ms"))
|
||||||
|
strategy = _number(trace.get("strategy_ms"))
|
||||||
|
if handler is None:
|
||||||
|
continue
|
||||||
|
key = (game["your_snake_type"], game["your_snake_version"], thinking.get("mode") or "fallback")
|
||||||
|
group = groups.setdefault(key, TraceGroup())
|
||||||
|
group.moves += 1
|
||||||
|
group.values["handler_ms"].append(handler)
|
||||||
|
if strategy is not None:
|
||||||
|
group.values["strategy_ms"].append(strategy)
|
||||||
|
group.values["overhead_ms"].append(max(0.0, handler - strategy))
|
||||||
|
engine = latency_by_turn.get(record.turn + 1)
|
||||||
|
if engine is None:
|
||||||
|
continue
|
||||||
|
group.values["engine_ms"].append(engine)
|
||||||
|
group.values["network_ms"].append(max(0.0, engine - handler))
|
||||||
|
timeout_ms = (_number(trace.get("budget_ms")) or 450.0) + 50.0
|
||||||
|
if engine >= timeout_ms:
|
||||||
|
group.timeouts += 1
|
||||||
|
if handler < timeout_ms - 100:
|
||||||
|
group.timeouts_fast_handler += 1
|
||||||
|
|
||||||
|
def percentile(values: list[float], quantile: float) -> float | None:
|
||||||
|
if not values:
|
||||||
|
return None
|
||||||
|
ordered = sorted(values)
|
||||||
|
return ordered[min(len(ordered) - 1, int(len(ordered) * quantile))]
|
||||||
|
|
||||||
|
def _fmt(value: float | None) -> str:
|
||||||
|
return "-" if value is None else f"{value:.0f}"
|
||||||
|
|
||||||
|
def summary_rows(groups: dict[tuple, TraceGroup]) -> tuple[list[str], list[list]]:
|
||||||
|
headers = ["snake", "version", "mode", "moves", "timeouts", "ext.timeouts"]
|
||||||
|
for stage in STAGES:
|
||||||
|
headers.append(f"{stage.removesuffix('_ms')} p50/p90/p99")
|
||||||
|
rows = []
|
||||||
|
for (snake, version, mode), group in sorted(groups.items(), key=lambda item: tuple(str(v) for v in item[0])):
|
||||||
|
row = [
|
||||||
|
snake, version, mode, group.moves,
|
||||||
|
f"{group.timeouts} ({100.0 * group.timeouts / group.moves:.1f}%)" if group.moves else "0",
|
||||||
|
group.timeouts_fast_handler,
|
||||||
|
]
|
||||||
|
for stage in STAGES:
|
||||||
|
values = group.values.get(stage, [])
|
||||||
|
row.append("/".join(_fmt(percentile(values, q)) for q in (0.5, 0.9, 0.99)))
|
||||||
|
rows.append(row)
|
||||||
|
return headers, rows
|
||||||
|
|
||||||
|
async def load_groups(connection, where: str, args: list, max_games: int) -> tuple[dict[tuple, TraceGroup], int]:
|
||||||
|
"""Read the newest finished replays matching ``where``; never writes."""
|
||||||
|
args = [*args, max(1, max_games)]
|
||||||
|
rows = await connection.fetch(f"""
|
||||||
|
SELECT g.game_id, g.your_snake_id, g.your_snake_type, g.your_snake_version,
|
||||||
|
r.format, r.data
|
||||||
|
FROM games AS g
|
||||||
|
JOIN game_replays AS r ON r.gid = g.gid
|
||||||
|
{where}
|
||||||
|
ORDER BY g.started_at DESC
|
||||||
|
LIMIT ${len(args)}
|
||||||
|
""", *args)
|
||||||
|
groups: dict[tuple, TraceGroup] = {}
|
||||||
|
for row in rows:
|
||||||
|
turns, states = unpack_replay(row["data"], row["format"])
|
||||||
|
add_game(groups, dict(row), row["your_snake_id"], turns, states)
|
||||||
|
return groups, len(rows)
|
||||||
+1
-1
@@ -13,7 +13,7 @@ SNAKE_REGISTRATIONS = {
|
|||||||
"snakes.strategies.prism", "1.5.0"
|
"snakes.strategies.prism", "1.5.0"
|
||||||
),
|
),
|
||||||
"ZenithBattleSnake_ClaudeOpus5_5": SnakeRegistration(
|
"ZenithBattleSnake_ClaudeOpus5_5": SnakeRegistration(
|
||||||
"snakes.strategies.zenith", "1.1.0"
|
"snakes.strategies.zenith", "1.2.0"
|
||||||
),
|
),
|
||||||
"DummSnake": SnakeRegistration("snakes.legacy.DummSnake", "1.0.0"),
|
"DummSnake": SnakeRegistration("snakes.legacy.DummSnake", "1.0.0"),
|
||||||
"LogicSnake": SnakeRegistration("snakes.legacy.LogicSnake", "1.1.0"),
|
"LogicSnake": SnakeRegistration("snakes.legacy.LogicSnake", "1.1.0"),
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ from time import perf_counter
|
|||||||
from snakes.engine.survival_search import CompactSurvivalSearch
|
from snakes.engine.survival_search import CompactSurvivalSearch
|
||||||
|
|
||||||
class BitboardSurvivalMixin:
|
class BitboardSurvivalMixin:
|
||||||
|
# Remaining budget above which rollouts against at most two enemies may look
|
||||||
|
# one turn deeper. Snakes with a larger timeout reserve lower this threshold.
|
||||||
|
_rollout_deepen_ms = 250.0
|
||||||
|
|
||||||
def _future_rollout_bonus(
|
def _future_rollout_bonus(
|
||||||
self, move: str, safe_moves: dict, my_body: list, other_snakes: list,
|
self, move: str, safe_moves: dict, my_body: list, other_snakes: list,
|
||||||
food_set: set, is_constrictor: bool, width: int, height: int,
|
food_set: set, is_constrictor: bool, width: int, height: int,
|
||||||
@@ -36,7 +40,7 @@ class BitboardSurvivalMixin:
|
|||||||
depth = min(self._planning_depth, 2 if len(other_snakes) > 1 else 3)
|
depth = min(self._planning_depth, 2 if len(other_snakes) > 1 else 3)
|
||||||
if remaining < 90:
|
if remaining < 90:
|
||||||
depth = min(depth, 2)
|
depth = min(depth, 2)
|
||||||
elif remaining > 250 and len(other_snakes) <= 2:
|
elif remaining > self._rollout_deepen_ms and len(other_snakes) <= 2:
|
||||||
depth = min(4, depth + 1)
|
depth = min(4, depth + 1)
|
||||||
raw = self._survival_search_context.search_selected(
|
raw = self._survival_search_context.search_selected(
|
||||||
my_body=my_body,
|
my_body=my_body,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""ZenithBattleSnake_ClaudeOpus5_5 v1.1.0
|
"""ZenithBattleSnake_ClaudeOpus5_5 v1.2.0
|
||||||
|
|
||||||
Built on PrismBattleSnake_GPT_5_6_Sol v1.5.0. Prism's bitboard engine, duel
|
Built on PrismBattleSnake_GPT_5_6_Sol v1.5.0. Prism's bitboard engine, duel
|
||||||
search, and adversarial rollouts are inherited unchanged.
|
search, and adversarial rollouts are inherited unchanged.
|
||||||
@@ -15,6 +15,11 @@ Z2: Proven-loss veto — a candidate whose completed search proves a loss is
|
|||||||
replaced by the best non-losing candidate.
|
replaced by the best non-losing candidate.
|
||||||
Z3: Proven-win override — a candidate whose completed search proves a win is
|
Z3: Proven-win override — a candidate whose completed search proves a win is
|
||||||
played even if the heuristic preferred another move.
|
played even if the heuristic preferred another move.
|
||||||
|
Z4: Elapsed-time rollout gate — Zenith keeps a larger timeout reserve than
|
||||||
|
Prism. The inherited multiplayer "deepen when > 250 ms remain" gate is
|
||||||
|
lowered by that extra reserve, so 3-snake rollouts deepen after the same
|
||||||
|
elapsed time as Prism instead of never reaching depth 3. The duel gate is
|
||||||
|
left alone: the verdict pass already spends the duel budget.
|
||||||
"""
|
"""
|
||||||
from time import perf_counter
|
from time import perf_counter
|
||||||
|
|
||||||
@@ -27,18 +32,22 @@ _PROVEN_WIN = BitboardDuelSearch.WIN / 2
|
|||||||
_PROVEN_LOSS = BitboardDuelSearch.LOSS / 2
|
_PROVEN_LOSS = BitboardDuelSearch.LOSS / 2
|
||||||
# Reserve kept for Apex's history bookkeeping after the verdict pass.
|
# Reserve kept for Apex's history bookkeeping after the verdict pass.
|
||||||
_VERDICT_RESERVE_MS = 25.0
|
_VERDICT_RESERVE_MS = 25.0
|
||||||
|
_ZENITH_TIMEOUT_BUFFER_MS = 180
|
||||||
|
|
||||||
class ZenithBattleSnake_ClaudeOpus5_5(ZenithSurvivalMixin, PrismBattleSnake_GPT_5_6_Sol):
|
class ZenithBattleSnake_ClaudeOpus5_5(ZenithSurvivalMixin, PrismBattleSnake_GPT_5_6_Sol):
|
||||||
VERSION = "1.1.0"
|
VERSION = "1.2.0"
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.name = "ZenithBattleSnake"
|
self.name = "ZenithBattleSnake"
|
||||||
self.version = self.VERSION
|
self.version = self.VERSION
|
||||||
|
# Z4: deepen after the same elapsed time as the inherited Prism gates.
|
||||||
|
extra_reserve = self._get_timeout_buffer_ms() - super()._get_timeout_buffer_ms()
|
||||||
|
self._rollout_deepen_ms = max(120.0, self._rollout_deepen_ms - extra_reserve)
|
||||||
|
|
||||||
def _get_timeout_buffer_ms(self):
|
def _get_timeout_buffer_ms(self):
|
||||||
# Leave room for executor scheduling, history, JSON and network delivery.
|
# Leave room for executor scheduling, history, JSON and network delivery.
|
||||||
return max(180, super()._get_timeout_buffer_ms())
|
return max(_ZENITH_TIMEOUT_BUFFER_MS, super()._get_timeout_buffer_ms())
|
||||||
|
|
||||||
def _choose_duel_move(
|
def _choose_duel_move(
|
||||||
self, safe_moves, my_body, my_len, my_health, other_snakes, food_set,
|
self, safe_moves, my_body, my_len, my_health, other_snakes, food_set,
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from scripts.gameplay_snake_stats import (
|
||||||
|
StatsFilter, build_where, format_table, opponent_query, pivot_rows, win_rate,
|
||||||
|
)
|
||||||
|
|
||||||
|
class TestGameplaySnakeStats(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_filters_use_positional_arguments(self):
|
||||||
|
where, args = build_where(StatsFilter(since_hours=2, snake_type="ZenithBattleSnake", source="ladder"))
|
||||||
|
|
||||||
|
self.assertIn("g.status = 'finished'", where)
|
||||||
|
self.assertIn("make_interval(secs => $1)", where)
|
||||||
|
self.assertIn("g.your_snake_type = $2", where)
|
||||||
|
self.assertIn("g.source = $3", where)
|
||||||
|
self.assertEqual(args, [7200.0, "ZenithBattleSnake", "ladder"])
|
||||||
|
|
||||||
|
def test_opponent_query_appends_min_games_last(self):
|
||||||
|
sql, args = opponent_query(StatsFilter(game_type="duel"), min_games=5)
|
||||||
|
|
||||||
|
self.assertIn("HAVING count(*) >= $2", sql)
|
||||||
|
self.assertEqual(args, ["duel", 5])
|
||||||
|
|
||||||
|
def test_win_rate_handles_zero_games(self):
|
||||||
|
self.assertEqual(win_rate(1, 4), " 25.0%")
|
||||||
|
self.assertEqual(win_rate(0, 0), " -")
|
||||||
|
|
||||||
|
def test_pivot_keeps_opponents_faced_by_every_listed_version(self):
|
||||||
|
records = [
|
||||||
|
{"opponent": "A", "snake": "Prism", "version": "1.5.0", "wins": 2, "games": 10},
|
||||||
|
{"opponent": "A", "snake": "Zenith", "version": "1.2.0", "wins": 3, "games": 6},
|
||||||
|
{"opponent": "B", "snake": "Prism", "version": "1.5.0", "wins": 1, "games": 9},
|
||||||
|
]
|
||||||
|
|
||||||
|
headers, rows = pivot_rows(records, min_games=3)
|
||||||
|
|
||||||
|
self.assertEqual(headers, ["opponent", "Prism 1.5.0", "Zenith 1.2.0"])
|
||||||
|
self.assertEqual(rows, [["A", "2/10 20.0%", "3/6 50.0%"]])
|
||||||
|
|
||||||
|
def test_format_table_aligns_columns(self):
|
||||||
|
table = format_table(["name", "n"], [["long-name", 1]])
|
||||||
|
|
||||||
|
self.assertEqual(table.splitlines(), ["name n", "--------- -", "long-name 1"])
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from scripts.move_trace_stats import add_game, percentile, summary_rows
|
||||||
|
from server.database.compact.records import SnakeState, TurnRecord
|
||||||
|
|
||||||
|
GAME = {"your_snake_type": "ZenithBattleSnake", "your_snake_version": "1.2.0"}
|
||||||
|
|
||||||
|
def _turn(turn: int, thinking: object) -> TurnRecord:
|
||||||
|
return TurnRecord(turn=turn, observed_at=None, move="up", thinking=thinking, food=None, hazards=None)
|
||||||
|
|
||||||
|
def _state(snake_id: str, turn: int, latency: int | None) -> SnakeState:
|
||||||
|
return SnakeState(snake_id=snake_id, turn=turn, health=100, latency=latency, body=[])
|
||||||
|
|
||||||
|
def _trace(handler: float, strategy: float) -> dict:
|
||||||
|
return {"handler_ms": handler, "strategy_ms": strategy, "budget_ms": 450.0}
|
||||||
|
|
||||||
|
class TestMoveTraceStats(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_pairs_engine_latency_of_next_turn(self):
|
||||||
|
groups = {}
|
||||||
|
turns = [
|
||||||
|
_turn(1, {"mode": "duel", "move_trace": _trace(200.0, 195.0)}),
|
||||||
|
_turn(2, {"mode": "duel", "move_trace": _trace(100.0, 90.0)}),
|
||||||
|
]
|
||||||
|
states = [_state("you", 2, 260), _state("enemy", 2, 499), _state("you", 3, 500)]
|
||||||
|
|
||||||
|
add_game(groups, GAME, "you", turns, states)
|
||||||
|
|
||||||
|
group = groups[("ZenithBattleSnake", "1.2.0", "duel")]
|
||||||
|
self.assertEqual(group.moves, 2)
|
||||||
|
self.assertEqual(group.values["overhead_ms"], [5.0, 10.0])
|
||||||
|
self.assertEqual(group.values["engine_ms"], [260.0, 500.0])
|
||||||
|
self.assertEqual(group.values["network_ms"], [60.0, 400.0])
|
||||||
|
self.assertEqual((group.timeouts, group.timeouts_fast_handler), (1, 1))
|
||||||
|
|
||||||
|
def test_unwraps_raw_thinking_and_labels_missing_mode(self):
|
||||||
|
groups = {}
|
||||||
|
turns = [_turn(1, {"_derived": 0, "_raw": {"move_trace": _trace(5.0, 1.0)}}), _turn(2, "text")]
|
||||||
|
|
||||||
|
add_game(groups, GAME, "you", turns, [_state("you", 2, -32768)])
|
||||||
|
|
||||||
|
group = groups[("ZenithBattleSnake", "1.2.0", "fallback")]
|
||||||
|
self.assertEqual(group.moves, 1)
|
||||||
|
self.assertEqual(group.values["engine_ms"], [])
|
||||||
|
|
||||||
|
def test_summary_formats_percentiles(self):
|
||||||
|
groups = {}
|
||||||
|
add_game(groups, GAME, "you", [_turn(1, {"mode": "multi", "move_trace": _trace(20.0, 18.0)})], [])
|
||||||
|
|
||||||
|
headers, rows = summary_rows(groups)
|
||||||
|
|
||||||
|
self.assertEqual(headers[:4], ["snake", "version", "mode", "moves"])
|
||||||
|
self.assertEqual(rows[0][:6], ["ZenithBattleSnake", "1.2.0", "multi", 1, "0 (0.0%)", 0])
|
||||||
|
self.assertEqual(rows[0][6], "18/18/18")
|
||||||
|
self.assertEqual(rows[0][-1], "-/-/-")
|
||||||
|
|
||||||
|
def test_percentile_of_empty_list(self):
|
||||||
|
self.assertIsNone(percentile([], 0.5))
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import unittest
|
import unittest
|
||||||
from time import perf_counter
|
from time import perf_counter
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from snakes.engine.survival_search import CompactSurvivalSearch
|
||||||
|
|
||||||
from snakes import SnakeBuilder, get_snake_version
|
from snakes import SnakeBuilder, get_snake_version
|
||||||
from snakes.strategies.prism import PrismBattleSnake_GPT_5_6_Sol
|
from snakes.strategies.prism import PrismBattleSnake_GPT_5_6_Sol
|
||||||
@@ -14,8 +17,8 @@ class TestZenithRegistration(unittest.TestCase):
|
|||||||
snake = ZenithBattleSnake_ClaudeOpus5_5()
|
snake = ZenithBattleSnake_ClaudeOpus5_5()
|
||||||
|
|
||||||
self.assertEqual(snake.name, "ZenithBattleSnake")
|
self.assertEqual(snake.name, "ZenithBattleSnake")
|
||||||
self.assertEqual(snake.version, "1.1.0")
|
self.assertEqual(snake.version, "1.2.0")
|
||||||
self.assertEqual(get_snake_version("ZenithBattleSnake_ClaudeOpus5_5"), "1.1.0")
|
self.assertEqual(get_snake_version("ZenithBattleSnake_ClaudeOpus5_5"), "1.2.0")
|
||||||
self.assertIsInstance(snake, PrismBattleSnake_GPT_5_6_Sol)
|
self.assertIsInstance(snake, PrismBattleSnake_GPT_5_6_Sol)
|
||||||
self.assertIsInstance(
|
self.assertIsInstance(
|
||||||
SnakeBuilder.build("ZenithBattleSnake_ClaudeOpus5_5"),
|
SnakeBuilder.build("ZenithBattleSnake_ClaudeOpus5_5"),
|
||||||
@@ -80,5 +83,56 @@ class TestZenithVerdicts(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(verdicts, {})
|
self.assertEqual(verdicts, {})
|
||||||
|
|
||||||
|
def _rollout_depth(snake, remaining_ms: float) -> int:
|
||||||
|
"""Depth requested by a 3-snake rollout with ``remaining_ms`` left."""
|
||||||
|
snake._get_bb(11, 11)
|
||||||
|
snake._survival_search_context = None
|
||||||
|
enemies = [
|
||||||
|
{"body": [{"x": 8, "y": 8}, {"x": 8, "y": 9}, {"x": 8, "y": 10}]},
|
||||||
|
{"body": [{"x": 2, "y": 8}, {"x": 2, "y": 9}, {"x": 2, "y": 10}]},
|
||||||
|
]
|
||||||
|
with mock.patch.object(CompactSurvivalSearch, "search_selected", return_value=0.0) as search:
|
||||||
|
snake._future_rollout_bonus(
|
||||||
|
move="up", safe_moves={"up": {"x": 5, "y": 3}},
|
||||||
|
my_body=[{"x": 5, "y": 2}, {"x": 5, "y": 1}, {"x": 5, "y": 0}],
|
||||||
|
other_snakes=enemies, food_set=set(), is_constrictor=False,
|
||||||
|
width=11, height=11, enemy_can_grow={},
|
||||||
|
deadline=perf_counter() + remaining_ms / 1000,
|
||||||
|
)
|
||||||
|
return search.call_args.kwargs["depth"]
|
||||||
|
|
||||||
|
class TestZenithRolloutDepth(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_gate_is_lowered_by_the_extra_timeout_reserve(self):
|
||||||
|
zenith = ZenithBattleSnake_ClaudeOpus5_5()
|
||||||
|
prism = PrismBattleSnake_GPT_5_6_Sol()
|
||||||
|
extra = zenith._get_timeout_buffer_ms() - prism._get_timeout_buffer_ms()
|
||||||
|
|
||||||
|
self.assertEqual(zenith._rollout_deepen_ms, prism._rollout_deepen_ms - extra)
|
||||||
|
|
||||||
|
def test_three_snake_rollout_deepens_after_same_elapsed_time_as_prism(self):
|
||||||
|
# Zenith's budget is 50 ms shorter, so 220 ms left equals 270 ms for Prism.
|
||||||
|
self.assertEqual(_rollout_depth(ZenithBattleSnake_ClaudeOpus5_5(), 220), 3)
|
||||||
|
self.assertEqual(_rollout_depth(PrismBattleSnake_GPT_5_6_Sol(), 270), 3)
|
||||||
|
self.assertEqual(_rollout_depth(PrismBattleSnake_GPT_5_6_Sol(), 220), 2)
|
||||||
|
|
||||||
|
def test_low_budget_still_caps_rollout_depth(self):
|
||||||
|
self.assertEqual(_rollout_depth(ZenithBattleSnake_ClaudeOpus5_5(), 150), 2)
|
||||||
|
|
||||||
|
class TestZenithVerdictBudget(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_verdict_pass_skips_when_only_reserve_remains(self):
|
||||||
|
snake = ZenithBattleSnake_ClaudeOpus5_5()
|
||||||
|
|
||||||
|
verdicts = snake._duel_verdicts(
|
||||||
|
safe_moves={"up": {"x": 0, "y": 2}, "right": {"x": 1, "y": 1}},
|
||||||
|
my_body=[{"x": 0, "y": 1}], enemy={"body": [{"x": 4, "y": 4}]},
|
||||||
|
my_health=100, food_set=set(), hazard_set=set(), hazard_damage=15,
|
||||||
|
hazard_count={}, previous_hazard_set=set(), width=5, height=5,
|
||||||
|
deadline=perf_counter() + 0.020,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(verdicts, {})
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user