Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,42 @@ Tunex currently targets IronPhoenix's 4PC UCI behavior, including its immediate

<a id="-requirements"></a>

## 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.

## <img src="https://cdn.jsdelivr.net/npm/lucide-static@latest/icons/clipboard-list.svg" alt="" width="20" height="20"> Requirements

- Python **3.11+**
Expand Down
78 changes: 78 additions & 0 deletions configs/spellwarden.example.json
Original file line number Diff line number Diff line change
@@ -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
}
]
}
}
2 changes: 1 addition & 1 deletion src/tunex/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 8 additions & 3 deletions src/tunex/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -220,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")),
Expand All @@ -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,
)
57 changes: 57 additions & 0 deletions src/tunex/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading