LittlePlyResearch notebook

harness.py

python · 257 lines · Exact bytes

Line 1 ↗
Version & provenance · match-20261028

Recorded SHA-256 8e3605a31e807caef54da830b4a812fa7f56f577f978071a38eb16b45e423863Rendered SHA-256 8e3605a31e807caef54da830b4a812fa7f56f577f978071a38eb16b45e423863

Apache-2.0 for original LittlePly code, declared by the project owner on 2026-10-28. Historical source bytes and hashes are unchanged. Third-party components retain their own licenses; Lichess data remain CC0-1.0. License notes. Line links are stable within this snapshot. SHA-256 compared to persisted run identity/manifest. Unavailable files are never replaced with newer versions. Rendered derivatives preserve line numbers and redact private paths; original and rendered hashes are distinct.

1"""Standard-start, full-history matches. No trained matches run by default."""
2import argparse, hashlib, importlib, json, math, os, platform, queue, random
3import signal, subprocess, sys, threading, time, multiprocessing
4from pathlib import Path
5import chess, chess.pgn
6from chessdata import tokenizer
7from mate_override import forced_mate
8
9PIN = 'e396948acbd80cdcbad3dccfb07e878fba9df8f4'
10ROOT = Path(__file__).resolve().parent
11
12def sha(path):
13    h = hashlib.sha256()
14    with open(path, 'rb') as f:
15        for block in iter(lambda: f.read(1048576), b''): h.update(block)
16    return h.hexdigest()
17
18def history(board):
19    if board.root().fen() != chess.STARTING_FEN or board.chess960:
20        raise ValueError('only full standard-start history supported')
21    return [tokenizer.BOS] + [t for m in board.move_stack for t in tokenizer.encode_move(m)]
22
23def logprobs(model, prefix):
24    if len(prefix) > model.context_length: raise ValueError('context overflow; never truncate')
25    values = list(map(float, model.next_logits(list(prefix))))
26    if len(values) != 128 or not all(math.isfinite(v) for v in values):
27        raise ValueError('expected 128 finite raw logits')
28    top = max(values)
29    shifted = [v-top for v in values]
30    norm = math.log(sum(math.exp(v) for v in shifted))
31    return [v-norm for v in shifted]
32
33def choose(model, board):
34    prefix = history(board)
35    legal = sorted(board.legal_moves, key=lambda m: m.uci())
36    if not legal: raise ValueError('terminal board')
37    cache = {}
38    def lp(tail):
39        key = tuple(tail)
40        if key not in cache: cache[key] = logprobs(model, prefix + list(tail))
41        return cache[key]
42    scores = []
43    for move in legal:
44        a,b,c = tokenizer.encode_move(move)
45        scores.append(lp([])[a] + lp([a])[b] + lp([a,b])[c])
46    best = max(range(len(legal)), key=lambda i: scores[i])
47    mass = sum(math.exp(v) for v in scores)
48    raw = []
49    for _ in range(3):
50        values = lp(raw)
51        raw.append(max(range(128), key=lambda i: values[i]))
52    triples = [tokenizer.encode_move(m) for m in legal]
53    return legal[best], {'legal_joint_probability': mass,
54        'illegal_or_nonmove_three_token_probability': max(0., 1.-mass),
55        'raw_greedy_tokens': raw, 'raw_greedy_legal': raw in triples,
56        'selected_joint_log_probability': scores[best], 'forward_calls': len(cache)}
57
58class UCI:
59    def __init__(self, command, timeout=10):
60        self.timeout, self.lines, self.transcript = timeout, queue.Queue(), []
61        self.p = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
62            stderr=subprocess.STDOUT, text=True, bufsize=1, start_new_session=True)
63        def reader():
64            for line in self.p.stdout: self.lines.put(line.strip())
65            self.lines.put(None)
66        self.reader = threading.Thread(target=reader, daemon=True)
67        self.reader.start()
68        try:
69            self.send('uci'); self.until('uciok')
70            self.send('isready'); self.until('readyok')
71        except BaseException:
72            self.close(); raise
73    def send(self, line):
74        self.transcript.append('> '+line)
75        self.p.stdin.write(line+'\n'); self.p.stdin.flush()
76    def until(self, word):
77        deadline = time.monotonic()+self.timeout
78        while True:
79            remaining = deadline-time.monotonic()
80            if remaining <= 0: raise TimeoutError('UCI '+word)
81            try: line = self.lines.get(timeout=remaining)
82            except queue.Empty: raise TimeoutError('UCI '+word)
83            if time.monotonic() >= deadline: raise TimeoutError('UCI '+word)
84            if line is None: raise RuntimeError('engine crashed / EOF')
85            self.transcript.append('< '+line)
86            if line.split()[:1] == [word]: return line
87    def move(self, board, nodes):
88        self.send('position startpos moves '+' '.join(m.uci() for m in board.move_stack))
89        self.send('go nodes '+str(nodes))
90        line = self.until('bestmove')
91        if any('aborted' in s for s in self.transcript[-3:]): raise RuntimeError('aborted search')
92        return chess.Move.from_uci(line.split()[1])
93    def close(self):
94        if self.p.poll() is None:
95            if os.name == 'posix': os.killpg(self.p.pid, signal.SIGKILL)
96            else: self.p.kill()
97        self.p.wait(timeout=2)
98        self.reader.join(timeout=2)
99        self.p.stdin.close(); self.p.stdout.close()
100
101def model_worker(conn, adapter, checkpoint, seed):
102    try:
103        mod,fn=adapter.split(':')
104        model=getattr(importlib.import_module(mod),fn)(Path(checkpoint),seed=seed)
105        conn.send(('ok',model.context_length))
106        while True:
107            board=conn.recv()
108            conn.send(('ok',choose(model,board)))
109    except Exception as exc:
110        conn.send(('error',repr(exc)))
111    finally: conn.close()
112
113class ModelWorker:
114    """Spawned process makes model load/decoding enforceably bounded."""
115    def __init__(self,adapter,checkpoint,seed,timeout):
116        self.timeout=timeout
117        ctx=multiprocessing.get_context('spawn')
118        self.conn,child=ctx.Pipe()
119        self.p=ctx.Process(target=model_worker,args=(child,adapter,str(checkpoint),seed),daemon=True)
120        self.p.start(); child.close()
121        try: self.context_length=self.receive()
122        except BaseException: self.close(); raise
123    def receive(self):
124        if not self.conn.poll(self.timeout):
125            self.close(); raise TimeoutError('model load/decoding timeout')
126        status,value=self.conn.recv()
127        if status!='ok': raise RuntimeError(value)
128        return value
129    def choose(self,board):
130        self.conn.send(board)
131        return self.receive()
132    def close(self):
133        if self.p.is_alive(): self.p.kill()
134        self.p.join(timeout=2); self.conn.close()
135
136def outcome(board):
137    # Symmetric immediate claims, including a claim available by declaring a move.
138    result = board.outcome(claim_draw=True)
139    return (result.result(), result.termination.name.lower()) if result else None
140
141def game(model, engine_factory, color, out, cap=16, nodes=1000, opening=(), walltime=None, label='Harness smoke; no Elo claim',
142         mate_override=True, mate_nodes=50000, mate_seconds=1.0):
143    out.mkdir(parents=True, exist_ok=False)
144    board, record, rows, engine = chess.Board(), chess.pgn.Game(), [], None
145    node=record
146    result, reason, error = '*', 'ply_cap_unfinished', None
147    started=time.monotonic()
148    try:
149        if cap < len(opening) or cap*3+1 > model.context_length:
150            raise ValueError('cap/opening exceeds full-history capacity')
151        for uci in opening:
152            if outcome(board): raise ValueError('opening continues after adjudicated end')
153            move=chess.Move.from_uci(uci)
154            if move not in board.legal_moves: raise ValueError('illegal opening')
155            board.push(move); node=node.add_variation(move)
156        while True:
157            remaining = None if walltime is None else walltime-(time.monotonic()-started)
158            if remaining is not None and remaining <= 0: raise TimeoutError('game walltime limit')
159            end=outcome(board)
160            if end: result,reason=end; break
161            if board.ply()>=cap: break
162            before=time.monotonic(); metrics={}
163            if board.turn==color:
164                tactical=forced_mate(board,adjudicate=outcome,max_nodes=mate_nodes,
165                    seconds=min(mate_seconds,remaining) if remaining is not None else mate_seconds) if mate_override else {'status':'disabled'}
166                if tactical['status']=='proven':
167                    move=chess.Move.from_uci(tactical['move'])
168                    metrics={'decision_source':'forced_mate_override','model_query':'not_queried'}
169                else:
170                    remaining=None if walltime is None else walltime-(time.monotonic()-started)
171                    if remaining is not None and remaining <= 0: raise TimeoutError('game walltime limit')
172                    if isinstance(model,ModelWorker) and remaining is not None:
173                        model.timeout=min(model.timeout,remaining)
174                    move,metrics=model.choose(board) if isinstance(model,ModelWorker) else choose(model,board)
175                    metrics.update(decision_source='model_policy',model_query='queried')
176                metrics['mate_search']=tactical
177            else:
178                if engine is None: engine=engine_factory()
179                if remaining is not None: engine.timeout=min(engine.timeout,max(.001,walltime-(time.monotonic()-started)))
180                move=engine.move(board,nodes)
181            if move not in board.legal_moves: raise ValueError('illegal player output: '+move.uci())
182            board.push(move); node=node.add_variation(move)
183            if metrics.get('decision_source')=='forced_mate_override':
184                node.comment=(f"Forced-mate override: mate within {tactical['mate_plies']} plies; "
185                    f"limit 3 plies; {tactical['nodes']} nodes; {tactical['seconds']:.6f}s; "
186                    f"model not queried; full all-defenses proof in game.json at ply {board.ply()}.")
187            rows.append({'ply':board.ply(),'uci':move.uci(),'seconds':time.monotonic()-before,**metrics})
188    except Exception as exc:
189        reason = 'timeout_unfinished' if isinstance(exc,TimeoutError) else 'error_unfinished'
190        error=repr(exc)
191    finally:
192        cleanup_error = None
193        if engine is not None:
194            try: engine.close()
195            except Exception as exc: cleanup_error = repr(exc)
196        mode='model+mate-in-3-ply-override' if mate_override else 'pure-model-policy'
197        record.headers.update(Event=label+'; '+mode, White='Model' if color else 'Sunfish',
198            Black='Sunfish' if color else 'Model',Result=result,Termination=reason)
199        record.headers['ModelPolicy']=mode
200        (out/'game.pgn').write_text(str(record)+'\n')
201        report={'result':result,'termination':reason,'error':error,'cleanup_error':cleanup_error,'plies':board.ply(),
202            'final_fen':board.fen(),'seconds':time.monotonic()-started,'moves':rows,
203            'model_policy':mode,'mate_override':mate_override,'mate_max_plies':3,
204            'mate_node_budget':mate_nodes,'mate_time_budget_seconds':mate_seconds}
205        (out/'game.json').write_text(json.dumps(report,indent=2))
206        if engine is not None:
207            (out/'uci.log').write_text('\n'.join(engine.transcript))
208    return report
209
210def engine_command():
211    return [sys.executable,'-u',str(ROOT/'sunfish'/'sunfish.py')]
212
213def main():
214    p=argparse.ArgumentParser()
215    p.add_argument('--adapter',required=True,help='module:load; see README')
216    p.add_argument('--checkpoint',type=Path,required=True)
217    p.add_argument('--dataset-manifest',type=Path,required=True)
218    p.add_argument('--output',type=Path,required=True)
219    p.add_argument('--verified-checkpoint-sha256',required=True)
220    p.add_argument('--authorize-matches',action='store_true')
221    p.add_argument('--cap',type=int,default=16); p.add_argument('--nodes',type=int,default=1000)
222    p.add_argument('--timeout',type=float,default=10); p.add_argument('--seed',type=int,default=0)
223    p.add_argument('--model-timeout',type=float,default=60)
224    p.add_argument('--opening',nargs='*',default=[])
225    p.add_argument('--mate-override',action=argparse.BooleanOptionalAction,default=True)
226    p.add_argument('--mate-nodes',type=int,default=50000)
227    p.add_argument('--mate-seconds',type=float,default=1.0)
228    args=p.parse_args()
229    if not args.authorize_matches: p.error('matches require explicit authorization flag')
230    if args.cap<1 or args.nodes<1 or args.timeout<=0 or args.model_timeout<=0: p.error('positive budgets required')
231    if args.mate_nodes<1 or not math.isfinite(args.mate_seconds) or args.mate_seconds<=0: p.error('positive finite tactical budgets required')
232    if sha(args.checkpoint)!=args.verified_checkpoint_sha256: p.error('checkpoint hash mismatch')
233    commit=subprocess.check_output(['git','-c','core.autocrlf=true','-c','core.filemode=false','-C',str(ROOT/'sunfish'),'rev-parse','HEAD'],text=True).strip()
234    dirty=subprocess.check_output(['git','-c','core.autocrlf=true','-c','core.filemode=false','-C',str(ROOT/'sunfish'),'status','--porcelain'],text=True)
235    if commit!=PIN or dirty: p.error('Sunfish pin mismatch or modified checkout')
236    random.seed(args.seed)
237    model=ModelWorker(args.adapter,args.checkpoint,args.seed,args.model_timeout)
238    try:
239        args.output.mkdir(parents=True,exist_ok=False)
240        metadata={**{k:str(v) if isinstance(v,Path) else v for k,v in vars(args).items()},
241            'checkpoint_sha256':sha(args.checkpoint),'dataset_manifest_sha256':sha(args.dataset_manifest),
242            'tokenizer_sha256':sha(tokenizer.__file__),'tokenizer_contract':tokenizer.VERSION,
243            'harness_sha256':sha(__file__),'sunfish_commit':commit,'sunfish_license':'GPL-3.0',
244            'mate_search_sha256':sha(ROOT/'mate_override.py'),'mate_max_plies':3,
245            'runtime':sys.version,'executable':sys.executable,'platform':platform.platform(),
246            'python_chess':chess.__version__,'context_length':model.context_length,
247            'draw_policy':'python-chess outcome(claim_draw=True), immediate symmetric claims',
248            'decoding':'global legal full-move joint argmax; unmasked 128-way conditionals',
249            'model_policy':'model+mate-in-3-ply-override' if args.mate_override else 'pure-model-policy'}
250        (args.output/'manifest.json').write_text(json.dumps(metadata,indent=2))
251        for color,name in [(chess.WHITE,'model-white'),(chess.BLACK,'model-black')]:
252            game(model,lambda:UCI(engine_command(),args.timeout),color,args.output/name,args.cap,args.nodes,args.opening,
253                mate_override=args.mate_override,mate_nodes=args.mate_nodes,mate_seconds=args.mate_seconds)
254    finally: model.close()
255
256if __name__=='__main__': main()
257