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
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,58 @@ spin options; boolean switches are best evaluated separately. It works with
IronPhoenix and SpellWarden by selecting the corresponding `variant` and
using parameters actually advertised by that engine.

## <img src="https://cdn.jsdelivr.net/npm/lucide-static@latest/icons/target.svg" alt="" width="20" height="20"> Texel tuning (HCE)

Texel tuning fits **handcrafted evaluation parameters to positions with known
game outcomes**, rather than spending games on every candidate weight vector.
TuneX uses a logistic outcome prediction from the engine's **static** evaluation
and minimizes mean squared prediction error with a bounded integer coordinate
descent. This is a slow but dependency-free black-box Texel-loss optimizer;
it is **not** the analytical-gradient variant.

**Requires:** an IronPhoenix build with the companion `eval` UCI command
returning `info string eval cp N` (a *side-to-move team* score). TuneX turns
this into **Red+Yellow team perspective** before fitting. Use handcrafted
evaluation (`UseNNUE=false`); weight tuning must not accidentally fit NNUE
output. SpellWarden currently has no runtime `Eval*` UCI options, so Texel
HCE tuning is intentionally unsupported there until the evaluator exposes them.

Create a JSONL dataset, one object per line:

~~~json
{"fen":"<native IronPhoenix FEN4>","result":1.0,"game_id":"match-001"}
{"fen":"<native IronPhoenix FEN4>","result":0.0,"game_id":"match-002"}
{"fen":"<native IronPhoenix FEN4>","result":0.5,"game_id":"match-003"}
~~~

`result` is **1 for Red+Yellow winning, 0 for Blue+Green winning,
0.5 for draw**, regardless of the FEN's side to move. `game_id` is required
to partition entire games into disjoint training and validation sets, preventing
later positions from the same game appearing in both splits. Only use completed,
reliable game outcomes. Do not treat the engine's current scores as labels.
Prefer many varied, nonterminal positions and a representative held-out dataset.
`fen` must be a native FEN4 snapshot (not just `4PC`).

Copy `configs/texel.example.json`, set `engine.path`, provide a real
`configs/texel_positions.example.jsonl` file with your labeled positions,
then run:

~~~powershell
tunex -c configs/texel.example.json texel
tunex -c configs/texel.example.json texel --epochs 12
~~~

The `texel` section configures `dataset`, `epochs`, logistic scaling `k`,
`validation_fraction`, `seed`, `state_file`, and integer `parameters`
with `name`, `value`, `min`, `max`, `step`. Each epoch considers
neighbouring integer values per weight, accepting changes that reduce training
loss. TuneX saves current weights and the weights with **lowest validation
loss**, and resumes from a compatible state file. It rejects incompatible
configurations, datasets, and malformed labels.

Texel loss is not Elo: compare the selected HCE weights against the original
settings in fresh **paired SPRT matches** before changing production defaults.

## <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
73 changes: 73 additions & 0 deletions configs/texel.example.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
{
"variant": "four_player_teams",
"engine": {
"path": "C:/path/to/IronPhoenix/out/build/x64-release/ironphoenix.exe",
"startup_timeout": 15,
"move_timeout": 60,
"strict_options": true,
"common_options": {
"Hash": 64,
"MultiPV": 1,
"UseNNUE": false
}
},
"search": {
"mode": "movetime",
"value": 250
},
"match": {
"games": 100,
"workers": 4,
"max_plies": 320,
"paired": true,
"seed": 20261113,
"openings_file": null,
"results_file": "results/games.jsonl"
},
"baseline": {
"name": "Fixed HCE context",
"options": {}
},
"candidate": {
"name": "Texel candidate",
"options": {}
},
"texel": {
"dataset": "texel_positions.example.jsonl",
"epochs": 8,
"k": 1,
"validation_fraction": 0.2,
"seed": 20261113,
"state_file": "results/texel_state.json",
"parameters": [
{
"name": "EvalKnightMobility",
"value": 9,
"min": 0,
"max": 24,
"step": 1
},
{
"name": "EvalBishopPairBonus",
"value": 62,
"min": 20,
"max": 100,
"step": 2
},
{
"name": "EvalKingZoneAttackPenalty",
"value": 77,
"min": 30,
"max": 140,
"step": 3
},
{
"name": "EvalKnightOutpostBonus",
"value": 14,
"min": 0,
"max": 40,
"step": 2
}
]
}
}
15 changes: 15 additions & 0 deletions src/tunex/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from .match import GameRecord, GameSpec, MatchRunner
from .sprt import SprtState
from .spsa import SpsaRunner
from .texel import TexelRunner
from .stats import WDL, format_summary


Expand Down Expand Up @@ -178,6 +179,15 @@ def command_bayesian(config: Config, iterations: int | None) -> int:
return 0


def command_texel(config: Config, epochs: int | None) -> int:
runner = TexelRunner(config)
runner.run(epochs)
print("best validation HCE weights (verify in independent matches):")
for name, value in runner.best_weights.items():
print(f" {name} = {value}")
return 0


def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="tunex",
Expand All @@ -204,6 +214,9 @@ def build_parser() -> argparse.ArgumentParser:
bayesian = sub.add_parser("bayesian", help="run resumable Bayesian TPE tuning")
bayesian.add_argument("--iterations", type=int, default=None)

texel = sub.add_parser("texel", help="fit HCE weights to labeled positions")
texel.add_argument("--epochs", type=int, default=None)

return parser


Expand All @@ -223,6 +236,8 @@ def main(argv: list[str] | None = None) -> int:
return command_spsa(config, args.iterations)
if args.command == "bayesian":
return command_bayesian(config, args.iterations)
if args.command == "texel":
return command_texel(config, args.epochs)
except KeyboardInterrupt:
print("\nTunex interrupted; active engine processes stopped.", file=sys.stderr)
return 130
Expand Down
47 changes: 47 additions & 0 deletions src/tunex/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,47 @@ def from_dict(cls, data: dict[str, Any]) -> "BayesianConfig":
return cfg


@dataclass(slots=True)
class TexelConfig:
"""Labeled-position Texel fitting via bounded integer coordinate descent."""

dataset: str
parameters: list[SpsaParameter]
epochs: int = 5
k: float = 1.0
validation_fraction: float = 0.2
seed: int = 20261113
state_file: str = "results/texel_state.json"

@classmethod
def from_dict(cls, data: dict[str, Any]) -> "TexelConfig":
params = [SpsaParameter.from_dict(p) for p in data.get("parameters", [])]
cfg = cls(
dataset=str(data.get("dataset", "")),
parameters=params,
epochs=int(data.get("epochs", 5)),
k=float(data.get("k", 1.0)),
validation_fraction=float(data.get("validation_fraction", 0.2)),
seed=int(data.get("seed", 20261113)),
state_file=str(data.get("state_file", "results/texel_state.json")),
)
if not cfg.dataset.strip() or not params:
raise ValueError("texel dataset and parameters are required")
if cfg.epochs < 1 or not 0 < cfg.k <= 10:
raise ValueError("texel epochs must be positive and k in (0, 10]")
if not 0 < cfg.validation_fraction < 0.5:
raise ValueError("texel validation_fraction must be in (0, 0.5)")
if len({p.name for p in params}) != len(params):
raise ValueError("texel parameter names must be unique")
for p in params:
if any(abs(v - round(v)) > 1e-9 for v in
(p.minimum, p.maximum, p.value, p.step)):
raise ValueError(f"{p.name}: texel requires integer-valued parameters")
if p.minimum == p.maximum or p.step < 1:
raise ValueError(f"{p.name}: texel bounds must vary with step >= 1")
return cfg


@dataclass(slots=True)
class Config:
engine: EngineConfig
Expand All @@ -261,6 +302,7 @@ class Config:
sprt: SprtConfig
spsa: SpsaConfig | None = None
bayesian: BayesianConfig | None = None
texel: TexelConfig | None = None
variant: str = "four_player_teams"

@classmethod
Expand All @@ -270,6 +312,10 @@ def load(cls, path: str | Path) -> "Config":
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")
if "texel" in data and data["texel"].get("dataset"):
dataset = Path(data["texel"]["dataset"])
if not dataset.is_absolute():
data["texel"]["dataset"] = str((config_path.parent / dataset).resolve())
engine = EngineConfig.from_dict(data.get("engine", {}))
match = MatchConfig.from_dict(data.get("match"))
openings = match.openings_file
Expand All @@ -292,5 +338,6 @@ def load(cls, path: str | Path) -> "Config":
spsa=SpsaConfig.from_dict(data["spsa"]) if "spsa" in data else None,
bayesian=BayesianConfig.from_dict(data["bayesian"])
if "bayesian" in data else None,
texel=TexelConfig.from_dict(data["texel"]) if "texel" in data else None,
variant=variant,
)
27 changes: 27 additions & 0 deletions src/tunex/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,33 @@ def search(self, position_command: str, go_command: str) -> SearchResult:
info_line=last_info,
)

def static_eval_cp(self, fen: str) -> int:
"""Read the engine's *static*, side-to-move evaluation (not a search score).

IronPhoenix and SpellWarden use 'info string eval cp N' in response
to 'eval'. Reject missing or malformed responses, including bad FENs.
"""
self.send(f"position fen {fen}")
self.send("eval")
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(term in line.lower() for term in (
"invalid", "illegal", "error", "failed", "requires", "refused",
)):
raise EngineError(f"{self.name}: {line}")
scores = [
re.fullmatch(r"info string eval cp (-?\d+)", line)
for line in lines
if line.startswith("info string eval cp ")
]
if len(scores) != 1 or scores[0] is None:
raise EngineError(
f"{self.name} must support 'eval' -> 'info string eval cp N' "
"for Texel tuning"
)
return int(scores[0].group(1))

def opening_position(self, position_command: str) -> tuple[str, tuple[str, ...]]:
"""Get native FEN4 + legal UCI moves for SAN opening conversion.

Expand Down
Loading
Loading