mirror of
https://github.com/StewBC/cc65-Chess.git
synced 2026-10-24 10:02:24 +00:00
Everything published against Sargon II was level 1, with one six-game look at level 6. Levels 2-5 had never been played, so the competitive band for cc65's upper levels was unmeasured and no match could be sized. sargon/levelcost.py times a few opening replies per level: six minutes for all seven. Level 3 is 1.5 seconds a Sargon move and level 6 is 91, so a 64-game screen is 1.6 hours at level 3, 5 to 11 at level 4 and 93 at level 6. The wall between an afternoon and a fortnight is between levels 4 and 6. Two numbers here were wrong. "45 seconds per Sargon move" at level 6 was seconds per *ply* - half those plies are cc65's, which answers in 8 ms - so it is 91 a move, out by two in the direction that matters when sizing a match. And "an early move is the expensive case" holds at the shallow levels and inverts at level 6, where a whole game costs 1.9x its opening per move. A one-factor model fitted the two known anchors within 4% across a range of 150 and then over-predicted level 3 by 87%. Three measured levels are in the table and levels 4 and 5 are ranges, because two anchors and a line through them is not a model. The first screen off it - cc65 Harder vs level 3, 64 games, 1.6 hours - scores 73.4%, above the band, so level 3 is not the pairing. The numbers worth reading are 27 distinct games in 64 against 16-17 before, worst repeat down from fourteen to six, and one fifty-move draw where the baseline had fifteen. The levels also refuse to sit on one scale: interpolation predicted 58%, and reading back down from 73.4% puts level 6 somewhere cc65 Very Hard should have scored 90% rather than the 41.7% it did. And the queen table, recorded as the most promising untested lead in the project, is closed. EVAL_QUEENHOME sets sc_pstQueen's d1 from -5 to +5, which zeroes the ten centipawns the evaluation pays the queen to leave home; the shipping binaries hash identically with it present. Over 6144 games a side it measures -1.09 sigma, the wrong way. Tripling the incentive instead, which had to be worse if the square mattered, measures +0.40 sigma. Both directions run backwards: the square is inert and the mechanism is not where the queen's opening cost comes from. Replicating the measurement that motivated it on 64 fresh games then found the statistic itself was fragile. Mate scores clamp at +-10000, and a plain mean over evaluation deltas is not robust to that: a middlegame king move reads as -289.3 against a true -23.5, and a pawn move reads as a *gain*. With the clamps dropped the queen ties with the bishop at about twice a pawn, not four times - and a developing bishop move is correct chess, which says this statistic was never measuring what it was read as. sargon/piececost.py is that computation with the clamps dropped and a median printed beside every mean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
200 lines
6.7 KiB
Python
200 lines
6.7 KiB
Python
#!/usr/bin/env python3
|
|
"""How slow is each Sargon II level, in seconds per Sargon move?
|
|
|
|
sargon/README.md has one operational number for the deep levels - about 45
|
|
seconds a move at level 6, from six games - and nothing at all for levels 2 to
|
|
5. That gap is what decides whether a 64-game screen at some level is an
|
|
afternoon or a fortnight, so it is worth a few moves per level rather than a
|
|
match.
|
|
|
|
This plays the opening of one throwaway game per level and times Sargon's
|
|
replies. Two things it deliberately does not claim:
|
|
|
|
* **An opening move is the expensive case.** Sargon's cost tracks the piece
|
|
count, so these numbers are a ceiling on the per-move cost of a whole game,
|
|
not its mean. README.md records extrapolating a level-6 move-four time
|
|
across a game and predicting sixty hours for what took eight. Level 6 is
|
|
probed here anyway, precisely because its whole-game mean is known: it is
|
|
the calibration that turns the other levels' opening times into game
|
|
estimates.
|
|
|
|
* **Sargon's first reply may be book**, not a search, and a book move is
|
|
instant. Reply 1 is timed and reported separately for that reason.
|
|
|
|
cc65 plays White with its opening table off, so its own moves are the same
|
|
every level and cost well under a tenth of a second at 400 nodes; the timing
|
|
starts once its move has been typed and ends when Sargon's move is on the
|
|
screen with the input cursor back.
|
|
|
|
One harness on the control port at a time, and never --headless: this shares
|
|
both rules, and the emulator, with match.py.
|
|
|
|
python3 sargon/levelcost.py --output scratch/sargon-levelcost-YYYYMMDD
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import chess
|
|
|
|
import match as m
|
|
|
|
|
|
# Per level: how many Sargon replies to time, how long one of them may take,
|
|
# and how long the whole level may take. The budget is the thing that keeps
|
|
# this bounded - a level that runs out of it reports the moves it got.
|
|
PLAN = {
|
|
0: {"moves": 4, "move_timeout": 180.0, "budget": 150.0},
|
|
1: {"moves": 4, "move_timeout": 240.0, "budget": 200.0},
|
|
2: {"moves": 4, "move_timeout": 300.0, "budget": 260.0},
|
|
3: {"moves": 4, "move_timeout": 400.0, "budget": 330.0},
|
|
4: {"moves": 3, "move_timeout": 500.0, "budget": 450.0},
|
|
5: {"moves": 3, "move_timeout": 700.0, "budget": 620.0},
|
|
6: {"moves": 3, "move_timeout": 900.0, "budget": 900.0},
|
|
}
|
|
|
|
|
|
def probe_level(emulator, engine, level, plan, seed):
|
|
board = chess.Board()
|
|
moves = []
|
|
engine.set_book_seed(seed)
|
|
engine.new_game()
|
|
emulator.begin_game(chess.WHITE, level, seed)
|
|
|
|
samples = []
|
|
started = time.monotonic()
|
|
deadline = started + plan["budget"]
|
|
while len(samples) < plan["moves"]:
|
|
if board.is_game_over(claim_draw=True):
|
|
m.log(f"level {level}: game ended after {len(moves)} plies")
|
|
break
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0 and samples:
|
|
m.log(f"level {level}: out of budget after {len(samples)} timed moves")
|
|
break
|
|
# Snapshot Sargon's move cells before typing, never after: at max turbo
|
|
# it can answer before the next Python statement runs.
|
|
before = m.SargonEmulator._move_entries(emulator.screen(), chess.BLACK)
|
|
uci = engine.best_move(moves)
|
|
move = chess.Move.from_uci(uci)
|
|
if move not in board.legal_moves:
|
|
raise m.BridgeError(f"cc65 produced illegal move {uci} in {board.fen()}")
|
|
emulator.player_move(move)
|
|
board.push(move)
|
|
moves.append(uci)
|
|
|
|
# Give one move whatever is left of the level's budget, so a slow level
|
|
# cannot run past it, and record a censored sample if it does.
|
|
m.MOVE_TIMEOUT = max(60.0, min(plan["move_timeout"], deadline - time.monotonic()))
|
|
pieces = len(board.piece_map())
|
|
t0 = time.monotonic()
|
|
try:
|
|
reply = emulator.sargon_move(board, before)
|
|
except m.BridgeError as exc:
|
|
elapsed = time.monotonic() - t0
|
|
samples.append({
|
|
"reply": len(samples) + 1,
|
|
"seconds": round(elapsed, 2),
|
|
"censored": True,
|
|
"pieces": pieces,
|
|
"cc65": uci,
|
|
"sargon": None,
|
|
})
|
|
m.log(f"level {level} reply {len(samples)}: >{elapsed:.1f}s (timed out: {exc})")
|
|
break
|
|
elapsed = time.monotonic() - t0
|
|
board.push(reply)
|
|
moves.append(reply.uci())
|
|
samples.append({
|
|
"reply": len(samples) + 1,
|
|
"seconds": round(elapsed, 2),
|
|
"censored": False,
|
|
"pieces": pieces,
|
|
"cc65": uci,
|
|
"sargon": reply.uci(),
|
|
})
|
|
m.log(f"level {level} reply {len(samples)}: {elapsed:.1f}s ({uci} {reply.uci()})")
|
|
|
|
return {
|
|
"level": level,
|
|
"seed": seed,
|
|
"wall_seconds": round(time.monotonic() - started, 2),
|
|
"moves": moves,
|
|
"samples": samples,
|
|
}
|
|
|
|
|
|
def summarize(record):
|
|
searched = [s["seconds"] for s in record["samples"] if s["reply"] > 1]
|
|
book = [s["seconds"] for s in record["samples"] if s["reply"] == 1]
|
|
return {
|
|
"level": record["level"],
|
|
"timed": len(record["samples"]),
|
|
"reply1_seconds": round(book[0], 1) if book else None,
|
|
"mean_after_reply1": round(sum(searched) / len(searched), 1) if searched else None,
|
|
"max_seconds": round(max(s["seconds"] for s in record["samples"]), 1)
|
|
if record["samples"] else None,
|
|
"censored": any(s["censored"] for s in record["samples"]),
|
|
}
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--levels", default="0,1,2,3,4,5,6")
|
|
parser.add_argument("--port", type=int, default=m.DEFAULT_PORT)
|
|
parser.add_argument("--cc-skill", type=int, default=1, choices=sorted(m.CC65_SKILLS))
|
|
parser.add_argument("--seed", type=int, default=97)
|
|
parser.add_argument("--output", required=True)
|
|
args = parser.parse_args()
|
|
|
|
levels = [int(part) for part in args.levels.split(",") if part.strip() != ""]
|
|
output = Path(args.output)
|
|
output.mkdir(parents=True, exist_ok=True)
|
|
results_path = output / "levelcost.json"
|
|
|
|
m.prepare_disk()
|
|
emulator = m.SargonEmulator(args.port)
|
|
engine = m.UCIEngine(skill=args.cc_skill, own_book=False)
|
|
records = []
|
|
try:
|
|
emulator.start()
|
|
for level in levels:
|
|
plan = PLAN[level]
|
|
m.log(f"--- level {level}: up to {plan['moves']} replies, {plan['budget']:.0f}s budget")
|
|
record = probe_level(emulator, engine, level, plan, args.seed)
|
|
records.append(record)
|
|
results_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"cc_skill": args.cc_skill,
|
|
"levels": records,
|
|
"summary": [summarize(r) for r in records],
|
|
},
|
|
indent=2,
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
finally:
|
|
engine.close()
|
|
emulator.close()
|
|
|
|
print()
|
|
print("level timed reply1(book?) mean after reply1 max")
|
|
for record in records:
|
|
row = summarize(record)
|
|
print(
|
|
f"{row['level']:>5} {row['timed']:>5} "
|
|
f"{str(row['reply1_seconds']) + 's':>13} "
|
|
f"{str(row['mean_after_reply1']) + 's':>17} "
|
|
f"{str(row['max_seconds']) + 's':>6}"
|
|
+ (" (censored)" if row["censored"] else "")
|
|
)
|
|
print(f"\nwrote {results_path}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|