feat(snake): modularize engine and add tournament tools

- Split active strategies, reusable engine code, core classes, and legacy snakes.
- Replace implicit snake imports with explicit module registrations.
- Extract Prism duel, spatial, and survival behavior into focused mixins.
- Improve duel scoring with food races, pressure, caches, and depth metrics.
- Add deterministic arena scenarios and paired seeded engine tournaments.
- Expand benchmark telemetry and bump Prism to version 1.3.0.
- Update documentation and tests for the new package layout and tooling.
This commit is contained in:
2026-08-01 20:25:07 +02:00
parent cb6c8d4dc8
commit 3a9af3f54d
39 changed files with 1447 additions and 768 deletions
+53 -28
View File
@@ -17,34 +17,20 @@ from time import perf_counter
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from scripts.benchmark_snakes_from_db import load_states, percentile
from scripts.snake_arena_scenarios import SCENARIOS, synthetic_states
from server.GameBoard import GameBoard
from snakes import SnakeBuilder
from tests.bench_best_battle_snake import build_game_state
def synthetic_states(count: int) -> list[tuple[dict, dict]]:
states: list[tuple[dict, dict]] = []
for index in range(count):
payload = build_game_state()
payload["game"]["id"] = f"arena-{index}"
payload["turn"] = 20 + index
# Rotate food deterministically to exercise routing without creating
# invalid bodies or relying on private/real network values.
payload["board"]["food"] = [
{"x": 1 + index % 3, "y": 9},
{"x": 9, "y": 1 + (index // 3) % 3},
]
states.append((payload["board"], {
"game_id": payload["game"]["id"],
"source": "custom", "map": "standard",
"ruleset": payload["game"]["ruleset"], "turn": payload["turn"],
"you": payload["you"],
}))
return states
def evaluate(name: str, states: list[tuple[dict, dict]]) -> tuple[list[str], dict]:
moves: list[str] = []
durations: list[float] = []
depths: list[int] = []
duel_depths: list[int] = []
rollout_depths: list[int] = []
duel_nodes = 0
rollout_nodes = 0
cache_hits = 0
deadline_exits = 0
scenario_durations: dict[str, list[float]] = {}
for index, (board_data, metadata) in enumerate(states):
snake = SnakeBuilder.build(name)
game_id = f"arena-{name}-{index}-{metadata['game_id']}"
@@ -62,15 +48,32 @@ def evaluate(name: str, states: list[tuple[dict, dict]]) -> tuple[list[str], dic
})
started = perf_counter()
moves.append(snake.choose_move(board))
durations.append((perf_counter() - started) * 1000.0)
duration = (perf_counter() - started) * 1000.0
durations.append(duration)
scenario = metadata.get("scenario", "recorded")
scenario_durations.setdefault(scenario, []).append(duration)
history = snake.get_history() if hasattr(snake, "get_history") else []
if history:
depths.append(int(history[-1].get("minimax_depth_reached", 0)))
thinking = history[-1]
duel_depths.append(int(thinking.get("prism_duel_depth", thinking.get("minimax_depth_reached", 0))))
rollout_depths.append(int(thinking.get("prism_rollout_depth", 0)))
duel_nodes += int(thinking.get("prism_duel_nodes", 0))
rollout_nodes += int(thinking.get("prism_rollout_nodes", 0))
cache_hits += int(thinking.get("prism_duel_cache_hits", 0))
cache_hits += int(thinking.get("prism_rollout_cache_hits", 0))
deadline_exits += int(thinking.get("prism_duel_deadline_exits", 0))
deadline_exits += int(thinking.get("prism_rollout_deadline_exits", 0))
return moves, {
"snake": name, "positions": len(states),
"mean_ms": mean(durations), "median_ms": median(durations),
"p95_ms": percentile(durations, 0.95), "max_ms": max(durations),
"mean_minimax_depth": mean(depths) if depths else 0.0,
"mean_duel_depth": mean(duel_depths) if duel_depths else 0.0,
"mean_rollout_depth": mean(rollout_depths) if rollout_depths else 0.0,
"duel_nodes": duel_nodes, "rollout_nodes": rollout_nodes,
"cache_hits": cache_hits, "deadline_exits": deadline_exits,
"scenario_mean_ms": {
scenario: mean(values) for scenario, values in scenario_durations.items()
},
}
def main() -> None:
@@ -79,12 +82,13 @@ def main() -> None:
parser.add_argument("--database")
parser.add_argument("--positions", type=int, default=100)
parser.add_argument("--stride", type=int, default=997)
parser.add_argument("--scenario", action="append", choices=sorted(SCENARIOS))
parser.add_argument("--json-output")
args = parser.parse_args()
states = (
load_states(args.database, max(1, args.positions), max(1, args.stride))
if args.database else synthetic_states(max(1, args.positions))
if args.database else synthetic_states(max(1, args.positions), args.scenario)
)
if not states:
raise SystemExit("No benchmark positions found")
@@ -99,7 +103,10 @@ def main() -> None:
print(
f"{name}: mean={report['mean_ms']:.3f} ms, "
f"p95={report['p95_ms']:.3f} ms, max={report['max_ms']:.3f} ms, "
f"depth={report['mean_minimax_depth']:.2f}"
f"duel-depth={report['mean_duel_depth']:.2f}, "
f"rollout-depth={report['mean_rollout_depth']:.2f}, "
f"nodes={report['duel_nodes'] + report['rollout_nodes']}, "
f"cache-hits={report['cache_hits']}, deadline-exits={report['deadline_exits']}"
)
baseline = names[0]
disagreements = {
@@ -109,7 +116,25 @@ def main() -> None:
if disagreements:
print(f"Move disagreements versus {baseline}: {disagreements}")
payload = {"reports": reports, "baseline": baseline, "disagreements": disagreements}
scenario_disagreements = {}
for name in names[1:]:
counts: dict[str, int] = {}
for index, (baseline_move, candidate_move) in enumerate(
zip(move_sets[baseline], move_sets[name])
):
if baseline_move != candidate_move:
scenario = states[index][1].get("scenario", "recorded")
counts[scenario] = counts.get(scenario, 0) + 1
scenario_disagreements[name] = counts
if any(scenario_disagreements.values()):
print(f"Disagreements by scenario: {scenario_disagreements}")
payload = {
"reports": reports,
"baseline": baseline,
"disagreements": disagreements,
"scenario_disagreements": scenario_disagreements,
}
if args.json_output:
Path(args.json_output).write_text(json.dumps(payload, indent=2) + "\n")