LittlePlyResearch notebook

runtime.py

python · 168 lines · Exact bytes

Line 1 ↗
Version & provenance · bridge-20261028

Recorded SHA-256 7dcef1b44967962122611e49dfc7ecd05f4487d740acda642de6e82eca5cb555Rendered SHA-256 7dcef1b44967962122611e49dfc7ecd05f4487d740acda642de6e82eca5cb555

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. Completed bridge source captured on 2026-10-28 after the implementation owner reported 12 passing offline tests and online/model-warmup verification. These SHA-256 receipts identify this publication snapshot, not the earlier training or Sunfish runs. Human live-play verification remains pending. No runtime directory, logs, credentials or personal configuration were read or included.

1"""Official Lichess Bot API bridge, explicitly restricted human testing.
2Reference: https://lichess.org/api#tag/Bot . No matchmaking or outgoing challenge API.
3"""
4import argparse,os,sys,time,json,re,threading,queue,subprocess
5from pathlib import Path
6import requests
7from core import Game,challenge_reason,identity,atomic_json
8
9class APIError(Exception):
10    def __init__(self,status):self.status=status;super().__init__('HTTP '+str(status))
11
12class API:
13    def __init__(self,token_file):
14        token=Path(token_file).read_text(encoding='utf-8-sig').strip()
15        if not re.fullmatch(r'[A-Za-z0-9_]+',token):raise ValueError('invalid credential file format')
16        self.headers={'Authorization':'Bearer '+token,'User-Agent':'LittlePly-test-bridge/1.0','Accept':'application/json'}
17        self.lock=threading.Lock()
18    def request(self,method,path,**kw):
19        if not path.startswith('/api/') or any(c in path for c in ['\n','\r','://']):raise ValueError('invalid API path')
20        with self.lock:
21            response=requests.request(method,'https://lichess.org'+path,headers=self.headers,
22                timeout=(5,5),allow_redirects=False,**kw)
23            with response:
24                if response.status_code!=200:raise APIError(response.status_code)
25                return response.json()
26    def stream(self,path,events,stop,tag):
27        delay=2;generation=0
28        while not stop.is_set():
29            generation+=1
30            try:
31                with requests.get('https://lichess.org'+path,headers=self.headers,stream=True,
32                    timeout=(10,20),allow_redirects=False) as response:
33                    if response.status_code!=200:raise APIError(response.status_code)
34                    events.put((tag,'connected',{},generation,time.monotonic()),timeout=1);delay=2
35                    for line in response.iter_lines(chunk_size=1):
36                        if stop.is_set():break
37                        data=json.loads(line) if line else {}
38                        events.put((tag,'data' if line else 'heartbeat',data,generation,time.monotonic()),timeout=1)
39            except Exception as exc:
40                code=exc.status if isinstance(exc,APIError) else type(exc).__name__
41                try:events.put((tag,'disconnected',{'reason':str(code)},generation,time.monotonic()),timeout=1)
42                except queue.Full:pass
43                if code in [401,403]:return
44                if code==429:delay=max(delay,60)
45            stop.wait(delay);delay=min(60,delay*2)
46
47class Policy:
48    def __init__(self,worker,h):self.worker=worker;self.h=h
49    def __call__(self,board,seconds):
50        started=time.monotonic()
51        tactical=self.h.forced_mate(board,adjudicate=self.h.outcome,max_nodes=50000,seconds=min(1.,max(.01,seconds*.2)))
52        if tactical['status']=='proven':
53            return self.h.chess.Move.from_uci(tactical['move']),{'decision_source':'forced_mate_override','model_query':'not_queried','mate_search':tactical}
54        reason=None;remaining=seconds-(time.monotonic()-started)
55        if len(self.h.history(board))+2>self.worker.context_length:reason='full history exceeds model context; no truncation'
56        elif remaining<.2:reason='clock reserve'
57        elif not self.worker.p.is_alive():reason='model worker unavailable'
58        else:
59            try:
60                self.worker.timeout=remaining
61                move,metrics=self.worker.choose(board)
62                metrics.update(decision_source='model_policy',model_query='queried',mate_search=tactical)
63                return move,metrics
64            except Exception as exc:reason='model failure: '+type(exc).__name__
65        return sorted(board.legal_moves,key=lambda m:m.uci())[0],{'decision_source':'legal_fallback',
66            'model_query':'unavailable','reason':reason,'mate_search':tactical}
67
68def main():
69    p=argparse.ArgumentParser();p.add_argument('--token-file',required=True);p.add_argument('--checkpoint',required=True)
70    p.add_argument('--checkpoint-sha256',required=True);p.add_argument('--bot-name',required=True);p.add_argument('--opponent',required=True)
71    p.add_argument('--output',type=Path,required=True);p.add_argument('--session-minutes',type=int,default=90)
72    args=p.parse_args();args.output.mkdir(parents=True,exist_ok=True)
73    import fcntl
74    singleton=(args.output/'runtime.lock').open('a')
75    fcntl.flock(singleton,fcntl.LOCK_EX|fcntl.LOCK_NB)
76    import harness as h
77    if h.sha(args.checkpoint)!=args.checkpoint_sha256:raise ValueError('checkpoint hash mismatch')
78    busy=subprocess.check_output(['nvidia-smi','--query-compute-apps=pid,process_name','--format=csv,noheader'],text=True)
79    if busy.strip():raise RuntimeError('GPU compute process present; will not interfere')
80    os.environ['CHESS_INFERENCE_DEVICE']='cuda:1'
81    worker=h.ModelWorker('training_adapter:load',args.checkpoint,20261028,60)
82    allstop=threading.Event();game_stop=None;threads=[];q=queue.Queue(maxsize=1000)
83    logpath=args.output/'events.jsonl';status={'state':'warming_up'};game=None;reservation=None;accepted=set();closed=set();game_generation=None;starting=None
84    deadline=time.monotonic()+args.session_minutes*60
85    def log(kind,**data):
86        with logpath.open('a') as f:f.write(json.dumps({'time_utc':time.strftime('%Y-%m-%dT%H:%M:%SZ',time.gmtime()),'event':kind,**data})+'\n')
87    def status_write(**data):
88        status.update(data);atomic_json(args.output/'status.json',status)
89    def stream(path,tag,stop):
90        thread=threading.Thread(target=api.stream,args=(path,q,stop,tag),daemon=True);thread.start();threads.append(thread)
91    try:
92        warm_started=time.monotonic();warm=worker.choose(h.chess.Board());assert warm[0] in h.chess.Board().legal_moves
93        status_write(model_warmed=True,warmup_seconds=time.monotonic()-warm_started,checkpoint_sha256=args.checkpoint_sha256,
94            opponent=args.opponent,bot=args.bot_name,minimum_clock='10+5',expires_after_minutes=args.session_minutes)
95        api=API(args.token_file);account=api.request('GET','/api/account')
96        if account.get('id','').lower()!=args.bot_name.lower() or account.get('title')!='BOT':raise RuntimeError('BOT identity mismatch')
97        status_write(account_verified=True,account_id=account['id'],state='connecting')
98        stream('/api/stream/event','account',allstop)
99        while not allstop.is_set():
100            if time.monotonic()>deadline and game is None:break
101            try:tag,kind,data,generation,received=q.get(timeout=1)
102            except queue.Empty:continue
103            if kind=='disconnected':
104                log('stream_disconnected',stream=tag,reason=data['reason'])
105                if tag=='account':status_write(state='reconnecting')
106                if data['reason'] in ['401','403']:raise RuntimeError('API authorization lost')
107                continue
108            if tag=='account' and kind=='connected':
109                online=api.request('GET','/api/users/status',params={'ids':args.bot_name})
110                status_write(state='ready' if not game else 'playing',event_stream_connected=True,
111                    online_verified=bool(online and online[0].get('online')),online_api=online)
112                log('ready',online=status['online_verified']);print(json.dumps(status),flush=True)
113                continue
114            if kind!='data':continue
115            if tag=='account':
116                typ=data.get('type')
117                if typ=='challenge':
118                    c=data['challenge'];cid=c['id']
119                    if cid in accepted:continue
120                    reason=challenge_reason(c,args.opponent,args.bot_name,bool(game or reservation or time.monotonic()>deadline))
121                    if reason:
122                        api.request('POST',f'/api/challenge/{cid}/decline',data={'reason':reason});log('challenge_declined',id=cid,reason=reason)
123                    else:
124                        reservation=cid;accepted.add(cid)
125                        try:api.request('POST',f'/api/challenge/{cid}/accept');log('challenge_accepted',id=cid)
126                        except Exception:
127                            # An uncertain accept stays reserved until gameStart/cancellation.
128                            log('challenge_accept_uncertain',id=cid)
129                elif typ in ['challengeCanceled','challengeDeclined']:
130                    if data.get('challenge',{}).get('id')==reservation:reservation=None
131                elif typ=='gameStart':
132                    info=data['game'];gid=info.get('gameId') or info['id']
133                    if gid in closed or gid==starting or (game is not None and game.id==gid):continue
134                    if game is not None or starting:log('unexpected_second_game',id=gid);continue
135                    if identity(info.get('opponent',{}))!=args.opponent.lower() or info.get('rated') is not False:
136                        log('out_of_scope_game',id=gid);continue
137                    reservation=gid;starting=gid;game_stop=threading.Event();game_generation=None
138                    stream('/api/bot/game/stream/'+gid,gid,game_stop);log('game_stream_started',id=gid)
139                continue
140            if data.get('type')=='gameFull':
141                if tag!=reservation and (game is None or tag!=game.id):continue
142                if game is None:game=Game(data,args.bot_name,args.opponent,args.output/'games')
143                else:game.ingest(data['state'])
144                starting=None;game_generation=generation;fresh=True;status_write(state='playing',game_id=game.id)
145            elif data.get('type')=='gameState' and game and tag==game.id and generation==game_generation:
146                game.ingest(data);fresh=False
147            else:continue
148            if game.state['status']!='started':
149                log('game_finished',id=game.id,status=game.state['status'],winner=game.state.get('winner'))
150                closed.add(game.id);game_stop.set();game=None;reservation=None;status_write(state='ready',game_id=None);continue
151            decision=game.decide(Policy(worker,h),fresh=fresh,age=time.monotonic()-received)
152            if decision:
153                game.mark(decision,'sending')
154                try:
155                    api.request('POST',f'/api/bot/game/{game.id}/move/{decision["uci"]}')
156                    game.mark(decision,'accepted');log('move_submitted',game=game.id,ply=decision['ply'],uci=decision['uci'],source=decision['metrics']['decision_source'])
157                except Exception as exc:
158                    game.mark(decision,'uncertain');log('move_uncertain',game=game.id,error=type(exc).__name__)
159                    game_stop.set();game_stop=threading.Event();time.sleep(2)
160                    stream('/api/bot/game/stream/'+game.id,game.id,game_stop)
161    finally:
162        allstop.set()
163        if game_stop:game_stop.set()
164        worker.close();status_write(state='stopped');log('runtime_stopped')
165        singleton.close()
166
167if __name__=='__main__':main()
168