From 44c46146582d68377dc7d87c41643ab73033669c Mon Sep 17 00:00:00 2001 From: Nick Date: Fri, 13 Nov 2026 11:09:52 -0600 Subject: [PATCH 1/3] Add SpellWarden Spell Chess match, SPRT, and SPSA support Introduce a backward-compatible spell_chess variant with White/Black pairing, full freeze/jump turns, 8x8 and extended FEN openings, engine-authoritative move legality and check status, king-capture adjudication, config example, and regression tests. --- README.md | 36 ++++++++ configs/spellwarden.example.json | 78 ++++++++++++++++ src/tunex/cli.py | 2 +- src/tunex/config.py | 5 + src/tunex/engine.py | 57 ++++++++++++ src/tunex/match.py | 151 ++++++++++++++++++++++++++++++- tests/test_spell_chess.py | 149 ++++++++++++++++++++++++++++++ 7 files changed, 474 insertions(+), 4 deletions(-) create mode 100644 configs/spellwarden.example.json create mode 100644 tests/test_spell_chess.py diff --git a/README.md b/README.md index 86d621d..2e7a8ca 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,42 @@ Tunex currently targets IronPhoenix's 4PC UCI behavior, including its immediate +## SpellWarden (8×8 Spell Chess) support + +TuneX supports two match variants; existing IronPhoenix configs default to +`"variant": "four_player_teams"`. SpellWarden configs use +`"variant": "spell_chess"` and the engine's native 8×8 UCI extension. + +Start from `configs/spellwarden.example.json` and update `engine.path`: + +~~~powershell +tunex -c configs/spellwarden.example.json probe +tunex -c configs/spellwarden.example.json match --games 20 +tunex -c configs/spellwarden.example.json sprt +tunex -c configs/spellwarden.example.json spsa +~~~ + +The match runner alternates **White and Black**, swapping candidate and +baseline colors in paired games. Complete spell turns are single tokens: +`freeze@d5:e2e4`, `jump@d4:d1d8`, or a normal `e2e4`. +Openings may contain standard six-field chess FEN with optional SpellWarden +`sw=...` metadata, or move lists beginning with `moves`. +Opening turns are validated through the engine's exhaustive `legalmoves` +command. SAN is not yet supported for Spell Chess. + +For accurate checkmate/stalemate adjudication, use SpellWarden with the +integration update to its `d` diagnostic, which exposes +`info string check: yes/no` alongside its existing FEN. SpellWarden +reports its own spell-aware check status; TuneX must not approximate it with +orthodox chess attack rules. King captures are recognized from the returned +8×8 board FEN; max-plies are draws. The engine's existing +`SpellTargets`, `RootSpellTargets`, and `UseMoveOrdering` options can be +used in matches and SPSA. **There is no automatic Spell Chess rating oracle**: +test results reflect SpellWarden's current rules implementation. + +IronPhoenix four-player behavior, FEN4/SAN4 parsing, team pairing, and all +existing configs remain unchanged when `variant` is omitted. + ## Requirements - Python **3.11+** diff --git a/configs/spellwarden.example.json b/configs/spellwarden.example.json new file mode 100644 index 0000000..cc9770d --- /dev/null +++ b/configs/spellwarden.example.json @@ -0,0 +1,78 @@ +{ + "variant": "spell_chess", + "engine": { + "path": "C:/path/to/SpellWarden/out/build/x64-release/spellwarden.exe", + "startup_timeout": 15, + "move_timeout": 90, + "strict_options": true, + "common_options": { + "Hash": 64, + "Threads": 1, + "UCI_Variant": "spell" + } + }, + "search": { + "mode": "movetime", + "value": 250 + }, + "match": { + "games": 100, + "workers": 4, + "max_plies": 200, + "paired": true, + "seed": 20261113, + "openings_file": null, + "results_file": "results/spellwarden_games.jsonl" + }, + "baseline": { + "name": "SpellWarden baseline", + "options": { + "SpellTargets": 6, + "RootSpellTargets": 24, + "UseMoveOrdering": true + } + }, + "candidate": { + "name": "SpellWarden candidate", + "options": { + "SpellTargets": 8, + "RootSpellTargets": 24, + "UseMoveOrdering": true + } + }, + "sprt": { + "elo0": 0, + "elo1": 5, + "alpha": 0.05, + "beta": 0.05, + "max_games": 10000, + "batch_games": 20 + }, + "spsa": { + "iterations": 200, + "games_per_iteration": 40, + "a": 50.0, + "c": 4.0, + "alpha": 0.602, + "gamma": 0.101, + "A": 20, + "seed": 20261113, + "state_file": "results/spellwarden_spsa_state.json", + "parameters": [ + { + "name": "SpellTargets", + "value": 6, + "min": 2, + "max": 16, + "step": 1 + }, + { + "name": "RootSpellTargets", + "value": 24, + "min": 8, + "max": 48, + "step": 1 + } + ] + } +} diff --git a/src/tunex/cli.py b/src/tunex/cli.py index 40d1ac0..6a3c05e 100644 --- a/src/tunex/cli.py +++ b/src/tunex/cli.py @@ -171,7 +171,7 @@ def command_spsa(config: Config, iterations: int | None) -> int: def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="tunex", - description="UCI match testing, SPRT, and SPSA tuning for IronPhoenix.", + description="UCI match testing, SPRT, and SPSA tuning for 4PC Teams and Spell Chess.", ) parser.add_argument( "-c", diff --git a/src/tunex/config.py b/src/tunex/config.py index 8a278fd..c20601d 100644 --- a/src/tunex/config.py +++ b/src/tunex/config.py @@ -206,11 +206,15 @@ class Config: candidate: VariantConfig sprt: SprtConfig spsa: SpsaConfig | None = None + variant: str = "four_player_teams" @classmethod def load(cls, path: str | Path) -> "Config": config_path = Path(path) data = json.loads(config_path.read_text(encoding="utf-8")) + variant = str(data.get("variant", "four_player_teams")).lower() + if variant not in {"four_player_teams", "spell_chess"}: + raise ValueError("variant must be four_player_teams or spell_chess") engine = EngineConfig.from_dict(data.get("engine", {})) match = MatchConfig.from_dict(data.get("match")) openings = match.openings_file @@ -231,4 +235,5 @@ def load(cls, path: str | Path) -> "Config": candidate=candidate, sprt=SprtConfig.from_dict(data.get("sprt")), spsa=SpsaConfig.from_dict(data["spsa"]) if "spsa" in data else None, + variant=variant, ) diff --git a/src/tunex/engine.py b/src/tunex/engine.py index fee639d..4ef1ebe 100644 --- a/src/tunex/engine.py +++ b/src/tunex/engine.py @@ -261,6 +261,63 @@ def opening_position(self, position_command: str) -> tuple[str, tuple[str, ...]] raise EngineError(f"{self.name}: incomplete legal move listing") return fen, moves + def spell_status(self, position_command: str) -> tuple[str, bool]: + """Read SpellWarden's board and exact side-to-move check status. + + The spell-aware attack rules belong to SpellWarden, not Tunex. + Requires SpellWarden's 'd' command to emit 'info string check: yes/no'. + """ + self.send(position_command) + self.send("d") + self.send("isready") + lines = self._read_until(lambda line: line == "readyok", self.startup_timeout) + for line in lines: + if line.startswith("info string ") and any( + token in line.lower() for token in ( + "illegal move", "invalid move", "fen requires", "unknown command", + ) + ): + raise EngineError(f"{self.name}: {line}") + fen = next( + (line[len("info string FEN "):] for line in lines + if line.startswith("info string FEN ")), None + ) + check = next( + (_CHECK_RE.search(line).group(1).lower() == "yes" + for line in lines if _CHECK_RE.search(line)), None + ) + if fen is None or check is None: + raise EngineError( + f"{self.name} needs SpellWarden's FEN and check status from 'd'; " + "use the SpellWarden integration branch" + ) + return fen, check + + def spell_legal_turns(self, position_command: str) -> set[str]: + """Get full spell turns via SpellWarden's exhaustive legalmoves command.""" + self.send(position_command) + self.send("legalmoves") + self.send("isready") + lines = self._read_until(lambda line: line == "readyok", self.startup_timeout) + header = next( + (line for line in lines if line.startswith("info string legal turns ")), + None, + ) + if header is None: + raise EngineError(f"{self.name} does not support SpellWarden legalmoves") + match = re.fullmatch(r"info string legal turns (\d+)", header) + if match is None: + raise EngineError(f"{self.name}: invalid legal turns header") + start = lines.index(header) + 1 + count = int(match.group(1)) + turns = [ + line[len("info string "):] for line in lines[start:start + count] + if line.startswith("info string ") + ] + if len(turns) != count: + raise EngineError(f"{self.name}: incomplete legal turns listing") + return set(turns) + def side_in_check(self, position_command: str) -> bool | None: self.send(position_command) self.send("d") diff --git a/src/tunex/match.py b/src/tunex/match.py index 329da0e..f26d001 100644 --- a/src/tunex/match.py +++ b/src/tunex/match.py @@ -5,6 +5,7 @@ import json from pathlib import Path import random +import re import threading from typing import Any, Callable @@ -60,7 +61,7 @@ def _is_bare_fen4(line: str) -> bool: or (len(line) >= 2 and line[0].upper() in COLORS and line[1] == "-")) -def _load_openings(path: str | None) -> list[tuple[str | None, tuple[str, ...]]]: +def _load_openings(path: str | None, variant: str = "four_player_teams") -> list[tuple[str | None, tuple[str, ...]]]: if not path: return [(None, ())] openings: list[tuple[str | None, tuple[str, ...]]] = [] @@ -75,7 +76,9 @@ def _load_openings(path: str | None) -> list[tuple[str | None, tuple[str, ...]]] fen_text = None if line[:4].lower() == "fen ": fen_text = line[4:].strip() - elif _is_bare_fen4(line): + elif variant == "four_player_teams" and _is_bare_fen4(line): + fen_text = line + elif variant == "spell_chess" and "/" in line.split()[0]: fen_text = line if fen_text is not None: @@ -138,6 +141,145 @@ def _convert_opening(engine: UciEngine, start_fen: str | None, return result +_SPELL_TURN_RE = re.compile( + r"(?:(?:freeze|jump)@[a-h][1-8]:)?[a-h][1-8][a-h][1-8][qrbn]?$" +) + + +def _spell_winner(fen: str) -> int | None: + # The board is the first field; SpellWarden's extended spell metadata + # is separate and must not be interpreted as a chess position. + board = fen.split()[0] + if "k" not in board: + return 0 + if "K" not in board: + return 1 + return None + + +def _spell_start_side(start_fen: str | None) -> int: + if not start_fen: + return 0 + parts = start_fen.split() + if len(parts) < 6 or parts[1] not in ("w", "b"): + raise ValueError("Spell Chess openings require six-field FEN (optional sw= field)") + return int(parts[1] == "b") + + +def _spell_opening(engine: UciEngine, start_fen: str | None, + opening_moves: tuple[str, ...]) -> list[str]: + moves: list[str] = [] + for ply, move in enumerate(opening_moves, 1): + if not _SPELL_TURN_RE.fullmatch(move): + raise EngineError( + f"Spell Chess opening ply {ply}: expected coordinate or " + f"freeze@/jump@ spell turn, got {move!r}; SAN is not supported" + ) + position = _position_command(start_fen, moves) + legal = engine.spell_legal_turns(position) + if move not in legal: + raise EngineError(f"Spell Chess opening ply {ply}: illegal turn {move!r}") + moves.append(move) + return moves + + +def _play_spell_game( + config: Config, spec: GameSpec, baseline: VariantConfig, candidate: VariantConfig, + cancel_event: threading.Event | None = None, + on_progress: Callable[[GameSpec, str, int], None] | None = None, +) -> GameRecord: + if on_progress is not None: + on_progress(spec, "starting", 0) + candidate_options = _merged_options(config.engine.common_options, candidate) + baseline_options = _merged_options(config.engine.common_options, baseline) + engines = { + spec.candidate_team: UciEngine( + candidate.path or config.engine.path, name=candidate.name, + startup_timeout=config.engine.startup_timeout, + move_timeout=config.engine.move_timeout, + strict_options=config.engine.strict_options, cancel_event=cancel_event, + ), + 1 - spec.candidate_team: UciEngine( + baseline.path or config.engine.path, name=baseline.name, + startup_timeout=config.engine.startup_timeout, + move_timeout=config.engine.move_timeout, + strict_options=config.engine.strict_options, cancel_event=cancel_event, + ), + } + moves: list[str] = [] + last_result: SearchResult | None = None + try: + for engine in engines.values(): + engine.start() + engines[spec.candidate_team].configure(candidate_options) + engines[1 - spec.candidate_team].configure(baseline_options) + for engine in engines.values(): + engine.new_game() + moves = _spell_opening( + engines[spec.candidate_team], spec.start_fen, spec.opening_moves + ) + opening_uci = list(moves) + initial_side = _spell_start_side(spec.start_fen) + if on_progress is not None: + on_progress(spec, "playing", len(moves)) + + def result_record(winner: int | None, reason: str) -> GameRecord: + return GameRecord( + game_id=spec.game_id, candidate_team=spec.candidate_team, + result=_candidate_result(winner, spec.candidate_team), + winner_team=winner, reason=reason, plies=len(moves), + moves=list(moves), opening_moves=list(opening_uci), + start_fen=spec.start_fen, + last_score_cp=last_result.score_cp if last_result else None, + last_score_mate=last_result.score_mate if last_result else None, + ) + + for _ in range(config.match.max_plies): + if cancel_event is not None and cancel_event.is_set(): + raise EngineError("match interrupted") + side = (initial_side + len(moves)) % 2 + engine = engines[side] + position = _position_command(spec.start_fen, moves) + + # A custom opening may itself be terminal. + if last_result is None and moves: + fen, _ = engine.spell_status(position) + winner = _spell_winner(fen) + if winner is not None: + return result_record(winner, "terminal-king-capture") + + last_result = engine.search(position, config.search.go_command()) + if last_result.bestmove == "0000": + fen, in_check = engine.spell_status(position) + winner = _spell_winner(fen) + if winner is not None: + return result_record(winner, "terminal-king-capture") + return result_record(1 - side if in_check else None, + "checkmate/no-legal-move" if in_check + else "stalemate/no-legal-move") + + if not _SPELL_TURN_RE.fullmatch(last_result.bestmove): + raise EngineError( + f"{engine.name} returned malformed Spell Chess turn " + f"{last_result.bestmove!r}" + ) + moves.append(last_result.bestmove) + # Terminal king captures are not guaranteed to have a mate 1 + # score, and cannot be adjudicated with 4PC team heuristics. + after = _position_command(spec.start_fen, moves) + fen, _ = engine.spell_status(after) + winner = _spell_winner(fen) + if winner is not None: + return result_record(winner, "terminal-king-capture") + if on_progress is not None and len(moves) % 16 == 0: + on_progress(spec, "playing", len(moves)) + + return result_record(None, "max-plies") + finally: + for engine in engines.values(): + engine.close() + + def _candidate_result(winner_team: int | None, candidate_team: int) -> float: if winner_team is None: return 0.5 @@ -155,6 +297,9 @@ def _play_game( cancel_event: threading.Event | None = None, on_progress: Callable[[GameSpec, str, int], None] | None = None, ) -> GameRecord: + if config.variant == "spell_chess": + return _play_spell_game(config, spec, baseline, candidate, + cancel_event, on_progress) if on_progress is not None: on_progress(spec, "starting", 0) if cancel_event is not None and cancel_event.is_set(): @@ -278,7 +423,7 @@ def _play_game( class MatchRunner: def __init__(self, config: Config) -> None: self.config = config - self.openings = _load_openings(config.match.openings_file) + self.openings = _load_openings(config.match.openings_file, config.variant) self.cancel_event = threading.Event() def _specs(self, games: int, offset: int = 0) -> list[GameSpec]: diff --git a/tests/test_spell_chess.py b/tests/test_spell_chess.py new file mode 100644 index 0000000..806a790 --- /dev/null +++ b/tests/test_spell_chess.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from tunex.config import ( + Config, EngineConfig, MatchConfig, SearchConfig, SprtConfig, VariantConfig, +) +from tunex.engine import EngineError, SearchResult +from tunex.match import ( + GameSpec, _is_bare_fen4, _load_openings, _play_game, _spell_start_side, + _spell_winner, _spell_opening, +) + + +START_FEN = "4k3/8/8/8/8/8/8/4K3 w - - 0 1" +NO_BLACK_KING = "8/8/8/8/8/8/8/4K3 b - - 0 1" + + +class FakeSpellEngine: + mode = "capture" + instances = [] + + def __init__(self, path, *, name, **kwargs): + self.name = name + self.path = path + self.commands = [] + self.closed = False + self.__class__.instances.append(self) + + def start(self): + pass + + def configure(self, options): + self.options = options + + def new_game(self): + self.commands.append("ucinewgame") + + def close(self): + self.closed = True + + def spell_legal_turns(self, position): + return {"freeze@d5:e2e4", "e2e4", "jump@d4:d1d8"} + + def spell_status(self, position): + self.commands.append(position) + if "freeze@d5:e2e4" in position and self.mode == "capture": + return NO_BLACK_KING, False + return START_FEN, self.mode == "checkmate" + + def search(self, position, go): + self.commands.append(go) + if self.mode == "capture": + return SearchResult(bestmove="freeze@d5:e2e4", score_cp=42) + return SearchResult(bestmove="0000") + + +def config(): + return Config( + engine=EngineConfig(path="spellwarden"), + search=SearchConfig(mode="movetime", value=250), + match=MatchConfig(games=2, workers=1, paired=True, max_plies=6), + baseline=VariantConfig(name="baseline", options={}), + candidate=VariantConfig(name="candidate", options={}), + sprt=SprtConfig(), + variant="spell_chess", + ) + + +class SpellChessTests(unittest.TestCase): + def setUp(self): + FakeSpellEngine.instances = [] + FakeSpellEngine.mode = "capture" + + def test_turn_order_and_terminal_king_capture(self): + cfg = config() + with patch("tunex.match.UciEngine", FakeSpellEngine): + white = _play_game(cfg, GameSpec(0, 0), cfg.baseline, cfg.candidate) + black = _play_game(cfg, GameSpec(1, 1), cfg.baseline, cfg.candidate) + self.assertEqual(white.result, 1.0) + self.assertEqual(black.result, 0.0) + self.assertEqual(white.winner_team, 0) + self.assertEqual(white.reason, "terminal-king-capture") + self.assertEqual(white.moves, ["freeze@d5:e2e4"]) + self.assertEqual(white.plies, 1) + self.assertTrue(all(engine.closed for engine in FakeSpellEngine.instances)) + self.assertTrue(all("ucinewgame" in engine.commands + for engine in FakeSpellEngine.instances)) + + def test_no_moves_checkmate_and_stalemate(self): + cfg = config() + with patch("tunex.match.UciEngine", FakeSpellEngine): + FakeSpellEngine.mode = "checkmate" + mate = _play_game(cfg, GameSpec(0, 0), cfg.baseline, cfg.candidate) + FakeSpellEngine.mode = "stalemate" + draw = _play_game(cfg, GameSpec(1, 0), cfg.baseline, cfg.candidate) + self.assertEqual(mate.result, 0.0) + self.assertEqual(mate.winner_team, 1) + self.assertEqual(mate.reason, "checkmate/no-legal-move") + self.assertEqual(draw.result, 0.5) + self.assertEqual(draw.reason, "stalemate/no-legal-move") + + def test_spell_opening_validation(self): + engine = FakeSpellEngine("fake", name="spell") + opening = _spell_opening(engine, None, + ("freeze@d5:e2e4", "jump@d4:d1d8")) + self.assertEqual(opening, ["freeze@d5:e2e4", "jump@d4:d1d8"]) + with self.assertRaisesRegex(EngineError, "SAN is not supported"): + _spell_opening(engine, None, ("Nf3",)) + with self.assertRaisesRegex(EngineError, "illegal turn"): + _spell_opening(engine, None, ("a1a7",)) + + def test_spell_fen_and_opening_file(self): + self.assertEqual(_spell_start_side(START_FEN.replace(" w ", " b ")), 1) + self.assertEqual(_spell_start_side(None), 0) + self.assertEqual(_spell_winner(NO_BLACK_KING), 0) + self.assertIsNone(_spell_winner(START_FEN)) + self.assertTrue(_is_bare_fen4("4PC")) + with tempfile.TemporaryDirectory() as tmp: + file = Path(tmp) / "openings.txt" + file.write_text( + START_FEN + " | moves freeze@d5:e2e4\n" + + "moves jump@d4:d1d8\n", encoding="utf-8" + ) + parsed = _load_openings(str(file), "spell_chess") + self.assertEqual(len(parsed), 2) + self.assertEqual(parsed[0], (START_FEN, ("freeze@d5:e2e4",))) + self.assertEqual(parsed[1], (None, ("jump@d4:d1d8",))) + + def test_variant_config_loading(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "config.json" + path.write_text('{"variant": "spell_chess", ' + '"engine": {"path": "spellwarden"}}', encoding="utf-8") + self.assertEqual(Config.load(path).variant, "spell_chess") + path.write_text('{"engine": {"path": "ironphoenix"}}', + encoding="utf-8") + self.assertEqual(Config.load(path).variant, "four_player_teams") + path.write_text('{"variant": "unknown", "engine": {"path": "engine"}}', + encoding="utf-8") + with self.assertRaisesRegex(ValueError, "variant must"): + Config.load(path) + + +if __name__ == "__main__": + unittest.main() From 0ed1ae0b485c3c0410c96f1d57810a6bf490c4c0 Mon Sep 17 00:00:00 2001 From: Nick Date: Fri, 13 Nov 2026 11:11:13 -0600 Subject: [PATCH 2/3] Avoid shadowing the configured variant when resolving engine paths --- src/tunex/config.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/tunex/config.py b/src/tunex/config.py index c20601d..55f0faa 100644 --- a/src/tunex/config.py +++ b/src/tunex/config.py @@ -224,9 +224,9 @@ def load(cls, path: str | Path) -> "Config": engine.path = str((config_path.parent / engine.path).resolve()) baseline = VariantConfig.from_dict(data.get("baseline", {}), "baseline") candidate = VariantConfig.from_dict(data.get("candidate", {}), "candidate") - for variant in (baseline, candidate): - if variant.path is not None and not Path(variant.path).is_absolute(): - variant.path = str((config_path.parent / variant.path).resolve()) + for engine_variant in (baseline, candidate): + if engine_variant.path is not None and not Path(engine_variant.path).is_absolute(): + engine_variant.path = str((config_path.parent / engine_variant.path).resolve()) return cls( engine=engine, search=SearchConfig.from_dict(data.get("search")), From 55ea6fe3fd254c2e93c8a7e2b4ae231ad09d5568 Mon Sep 17 00:00:00 2001 From: Nick Date: Fri, 13 Nov 2026 11:11:15 -0600 Subject: [PATCH 3/3] Preserve legacy match config compatibility without an explicit variant --- src/tunex/match.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tunex/match.py b/src/tunex/match.py index f26d001..c219c69 100644 --- a/src/tunex/match.py +++ b/src/tunex/match.py @@ -297,7 +297,7 @@ def _play_game( cancel_event: threading.Event | None = None, on_progress: Callable[[GameSpec, str, int], None] | None = None, ) -> GameRecord: - if config.variant == "spell_chess": + if getattr(config, "variant", "four_player_teams") == "spell_chess": return _play_spell_game(config, spec, baseline, candidate, cancel_event, on_progress) if on_progress is not None: @@ -423,7 +423,7 @@ def _play_game( class MatchRunner: def __init__(self, config: Config) -> None: self.config = config - self.openings = _load_openings(config.match.openings_file, config.variant) + self.openings = _load_openings(config.match.openings_file, getattr(config, "variant", "four_player_teams")) self.cancel_event = threading.Event() def _specs(self, games: int, offset: int = 0) -> list[GameSpec]: