runtime.py
python · 168 lines · Exact bytes
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