LittlePlyResearch notebook

convert.py

python · 316 lines · Exact bytes

Line 1 ↗
Version & provenance · data-202609

Recorded SHA-256 3568afe293b4d5ca2ff3eb7a3e6be5673163198eb76f0188468e74e60d218583Rendered SHA-256 3568afe293b4d5ca2ff3eb7a3e6be5673163198eb76f0188468e74e60d218583

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"""Disk-backed ingestion journal and atomic, replayable dataset publication."""
2import argparse
3import hashlib
4import json
5import os
6from pathlib import Path
7import re
8import sqlite3
9import struct
10import sys
11from itertools import islice
12from concurrent.futures import ProcessPoolExecutor
13import chess
14from .pgn import open_pgn, records, parse, Rejected, filter_reason
15from .tokenizer import VERSION
16from .limits import MAX_SHARDS, MAX_MANIFEST_BYTES, MAX_SOURCES, MAX_CONFIG_BYTES
17
18
19def canonical(value):
20    return json.dumps(value, sort_keys=True, separators=(',', ':'), ensure_ascii=False).encode('utf-8')
21
22
23def digest(value):
24    return hashlib.sha256(value).hexdigest()
25
26
27def file_hash(path):
28    h = hashlib.sha256()
29    with open(path, 'rb') as stream:
30        while chunk := stream.read(1024 * 1024):
31            h.update(chunk)
32    return h.hexdigest()
33
34
35def fault(point):
36    # Explicit test-only failpoint. Abrupt process death exercises actual recovery.
37    if os.environ.get('CHESSDATA_TEST_CRASH') == point:
38        os._exit(86)
39
40
41def sync_dir(path):
42    fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
43    try:
44        os.fsync(fd)
45    finally:
46        os.close(fd)
47
48
49def atomic(path, data):
50    temporary = path.with_name(path.name + '.tmp')
51    with open(temporary, 'wb') as stream:
52        stream.write(data)
53        stream.flush()
54        os.fsync(stream.fileno())
55    os.replace(temporary, path)
56    sync_dir(path.parent)
57
58
59def game_id(headers):
60    # Only explicit IDs or canonical Lichess game URLs, never generic Site strings.
61    explicit = headers.get('GameId')
62    if explicit:
63        return 'explicit:' + explicit
64    match = re.fullmatch(r'https?://lichess.org/([A-Za-z0-9]{8})(?:/(?:white|black))?/?', headers.get('Site', ''))
65    return 'lichess:' + match[1] if match else None
66
67
68def split_for(sequence_hash, config):
69    value = int(digest((config['seed'] + ':' + sequence_hash).encode())[:16], 16) % 10000
70    return 'test' if value < config['test_bps'] else ('validation' if value < config['test_bps'] + config['validation_bps'] else 'train')
71
72
73def _parse_result(record):
74    try:
75        return None, parse(record)
76    except Rejected as error:
77        return str(error), None
78
79
80def _worker_parent_guard(parent_pid):
81    # Linux-only writer already requires fcntl/fsync. Prevent orphan parser workers
82    # after an abrupt parent crash; this affects only this owned child process.
83    import ctypes
84    import signal
85    if ctypes.CDLL(None).prctl(1,signal.SIGTERM,0,0,0)!=0:
86        raise OSError('could not set parser-worker parent-death cleanup')
87    if os.getppid()!=parent_pid:os._exit(1)
88
89
90def _parsed_records(stream, config, done):
91    pending=((i,r) for i,r in enumerate(records(stream,config['max_record_bytes'])) if i>done)
92    workers=config.get('workers',1)
93    if workers==1:
94        for i,record in pending:yield i,_parse_result(record)
95    else:
96        # Python 3.12 Executor.map is eager: bound each submission explicitly.
97        with ProcessPoolExecutor(max_workers=workers,initializer=_worker_parent_guard,initargs=(os.getpid(),)) as executor:
98            while batch:=list(islice(pending,64)):
99                for (i,_),parsed in zip(batch,executor.map(_parse_result,(r for i,r in batch))):
100                    yield i,parsed
101
102
103class _Transactions:
104    """Dedup, tokens, counts and checkpoint commit together in bounded groups."""
105    def __init__(self,db,size):self.db,self.size,self.pending,self.committed=db,size,0,False
106    def __enter__(self):
107        self.committed=False
108        if not self.db.in_transaction:self.db.execute('BEGIN')
109        return self
110    def __exit__(self,kind,value,tb):
111        if kind:
112            self.db.rollback();self.pending=0
113        else:
114            self.pending+=1
115            if self.pending>=self.size:self.flush()
116    def flush(self):
117        if self.pending:
118            self.db.commit();self.pending=0;self.committed=True
119
120
121def convert(inputs, output, config):
122    import fcntl  # Linux publication durability/locking contract.
123    config = dict(config)
124    config.setdefault('max_shards', MAX_SHARDS)
125    if type(config.get('workers',1)) is not int or not 1<=config.get('workers',1)<=8:
126        raise ValueError('workers must be 1..8')
127    if type(config.get('commit_records',1)) is not int or not 1<=config.get('commit_records',1)<=4096:
128        raise ValueError('commit_records must be 1..4096')
129    if config.get('max_game_tokens') is not None and (type(config['max_game_tokens']) is not int or config['max_game_tokens']<2):
130        raise ValueError('max_game_tokens must be an integer >=2')
131    if type(config['max_shards']) is not int or not 1 <= config['max_shards'] <= MAX_SHARDS:
132        raise ValueError(f'max_shards must be between 1 and {MAX_SHARDS}')
133    if len(canonical(config)) > MAX_CONFIG_BYTES:
134        raise ValueError('configuration exceeds v1 size limit')
135    inputs = list(islice(iter(inputs), MAX_SOURCES + 1))
136    if not 1 <= len(inputs) <= MAX_SOURCES:
137        raise ValueError(f'v1 requires 1..{MAX_SOURCES} source files')
138    output = Path(output)
139    output.mkdir(parents=True, exist_ok=True)
140    with open(output / '.writer.lock', 'a') as lock:
141        try:
142            fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
143        except BlockingIOError as error:
144            raise ValueError('another writer holds the output lock') from error
145        return _convert(inputs, output, config)
146
147
148def _convert(inputs, output, config):
149    inputs = [Path(p).resolve() for p in inputs]
150    source = [{'path': str(p), 'bytes': p.stat().st_size, 'sha256': file_hash(p)} for p in inputs]
151    code = {p.name: file_hash(p) for p in sorted(Path(__file__).parent.glob('*.py'))}
152    provenance = {'contract': VERSION, 'config': config, 'sources': source,
153                  'code_sha256': code, 'chess_version': chess.__version__, 'python': sys.version.split()[0]}
154    fingerprint = digest(canonical(provenance))
155    database = output / 'journal.sqlite3'
156    if not database.exists() and any(p.name != '.writer.lock' for p in output.iterdir()):
157        raise ValueError('output is not an empty or resumable dataset directory')
158    db = sqlite3.connect(database)
159    try:
160        db.execute('PRAGMA journal_mode=WAL')
161        db.execute('PRAGMA synchronous=FULL')
162        db.execute('PRAGMA cache_size=-4096')
163        db.execute('PRAGMA temp_store=FILE')
164        db.executescript('''
165            CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value TEXT NOT NULL);
166            CREATE TABLE IF NOT EXISTS progress(source INTEGER PRIMARY KEY, ordinal INTEGER NOT NULL);
167            CREATE TABLE IF NOT EXISTS counts(reason TEXT PRIMARY KEY, n INTEGER NOT NULL);
168            CREATE TABLE IF NOT EXISTS games(seq TEXT PRIMARY KEY, split TEXT NOT NULL,
169                tokens BLOB, ntokens INTEGER NOT NULL, metadata TEXT NOT NULL, source INTEGER, ordinal INTEGER);
170            CREATE TABLE IF NOT EXISTS ids(id TEXT PRIMARY KEY, seq TEXT NOT NULL);
171            CREATE TABLE IF NOT EXISTS excluded(seq TEXT PRIMARY KEY, ntokens INTEGER NOT NULL, metadata TEXT NOT NULL);
172            CREATE INDEX IF NOT EXISTS game_order ON games(split,source,ordinal);
173        ''')
174        row = db.execute("SELECT value FROM meta WHERE key='fingerprint'").fetchone()
175        if db.execute("SELECT 1 FROM meta WHERE key='invalid_source'").fetchone():
176            raise ValueError('source mutated during an earlier run; use a fresh output directory')
177        if row and row[0] != fingerprint:
178            raise ValueError('resume refused: source/config/implementation/runtime fingerprint changed')
179        with db:
180            db.execute("INSERT OR IGNORE INTO meta VALUES('fingerprint', ?)", (fingerprint,))
181        for source_index, path in enumerate(inputs):
182            row = db.execute('SELECT ordinal FROM progress WHERE source=?', (source_index,)).fetchone()
183            done = row[0] if row else -1
184            transactions=_Transactions(db,config.get('commit_records',1))
185            with open_pgn(path) as stream:
186                for ordinal, (parse_error, parsed) in _parsed_records(stream,config,done):
187                    with transactions:
188                        reason = 'accepted'
189                        if parse_error is not None:
190                            reason = 'rejected:' + parse_error
191                        else:
192                            headers, tokens, uci, final_fen = parsed
193                            filtered = filter_reason(headers, config['filter'])
194                            if filtered:
195                                reason = 'filtered:' + filtered
196                            else:
197                                seq = digest(uci.encode('ascii'))
198                                gid = game_id(headers)
199                                previous = db.execute('SELECT seq FROM ids WHERE id=?', (gid,)).fetchone() if gid else None
200                                exists = db.execute('SELECT 1 FROM games WHERE seq=? UNION ALL SELECT 1 FROM excluded WHERE seq=?', (seq,seq)).fetchone()
201                                if previous and previous[0] != seq:
202                                    reason = 'rejected:game_id_conflict'
203                                elif exists or previous:
204                                    reason = 'duplicate'
205                                else:
206                                    metadata = {'headers': headers, 'sequence_sha256': seq, 'game_id': gid,
207                                                'plies': (len(tokens)-2)//3, 'final_fen': final_fen,
208                                                'source_index': source_index, 'record_ordinal': ordinal}
209                                    if config.get('max_game_tokens') is not None and len(tokens)>config['max_game_tokens']:
210                                        db.execute('INSERT INTO excluded VALUES(?,?,?)',(seq,len(tokens),canonical(metadata).decode()))
211                                        reason='filtered:overlength_unique'
212                                    else:
213                                        db.execute('INSERT INTO games VALUES(?,?,?,?,?,?,?)',
214                                                   (seq, split_for(seq, config), None if config['count_only'] else bytes(tokens),
215                                                    len(tokens), canonical(metadata).decode(), source_index, ordinal))
216                                if gid and reason in ('accepted', 'duplicate','filtered:overlength_unique'):
217                                    db.execute('INSERT OR IGNORE INTO ids VALUES(?,?)', (gid, seq))
218                        db.execute('INSERT INTO counts VALUES(?,1) ON CONFLICT(reason) DO UPDATE SET n=n+1', (reason,))
219                        db.execute('INSERT INTO progress VALUES(?,?) ON CONFLICT(source) DO UPDATE SET ordinal=excluded.ordinal', (source_index, ordinal))
220                        fault('before_record_commit')
221                    if transactions.committed:fault('after_record_commit')
222                    if transactions.committed and ordinal%5000<config.get('commit_records',1):
223                        atomic(output/'ingest-progress.json',canonical({'source':source_index,'committed_ordinal':ordinal,
224                               'counts':dict(db.execute('SELECT reason,n FROM counts')),'published':False}))
225                transactions.flush()
226                if transactions.committed:fault('after_record_commit')
227            # A concurrent source edit is fatal, even when all old records replayed.
228            if file_hash(path) != source[source_index]['sha256']:
229                with db:
230                    db.execute("INSERT OR REPLACE INTO meta VALUES('invalid_source','true')")
231                raise ValueError('source changed during conversion; use a fresh output directory')
232        fault('after_ingest')
233        shards = []
234        if not config['count_only']:
235            for split in ('train', 'validation', 'test'):
236                number, data, offsets, metadata, metadata_bytes = 0, bytearray(), [0], [], 0
237                def flush():
238                    nonlocal number, data, offsets, metadata, metadata_bytes
239                    if not metadata:
240                        return
241                    if len(shards) >= config['max_shards']:
242                        raise ValueError('shard limit exceeded; no new manifest published. Use a fresh output with larger shard_tokens or a higher max_shards within the v1 hard limit')
243                    stem = f'{split}-{number:06d}'
244                    files = {}
245                    for suffix, content in [('tokens.u8', bytes(data)), ('offsets.u64le', struct.pack('<' + 'Q'*len(offsets), *offsets)),
246                                            ('metadata.jsonl', b''.join(canonical(m)+b'\n' for m in metadata))]:
247                        name = stem + '.' + suffix
248                        atomic(output / name, content)
249                        files[name] = {'sha256': digest(content), 'bytes': len(content)}
250                        fault('after_shard_file')
251                    shards.append({'split': split, 'games': len(metadata), 'tokens': len(data), 'files': files})
252                    number += 1
253                    data, offsets, metadata, metadata_bytes = bytearray(), [0], [], 0
254                cursor = db.execute('SELECT tokens,metadata FROM games WHERE split=? ORDER BY source,ordinal', (split,))
255                for blob, meta in cursor:
256                    if data and (len(data) + len(blob) > config['shard_tokens'] or metadata_bytes + len(meta) > config['shard_tokens']):
257                        flush()
258                    data.extend(blob)
259                    offsets.append(len(data))
260                    metadata.append(json.loads(meta))
261                    metadata_bytes += len(meta)
262                flush()
263        stats = dict(db.execute('SELECT reason,n FROM counts ORDER BY reason'))
264        split_counts = {s: {'games': n, 'tokens': t} for s,n,t in db.execute('SELECT split,count(*),sum(ntokens) FROM games GROUP BY split')}
265        manifest = {'format': 'chess-dataset-v1', 'fingerprint': fingerprint, 'provenance': provenance,
266                    'counts': stats, 'splits': split_counts, 'shards': shards,
267                    'accepted_tokens': sum(s['tokens'] for s in split_counts.values()),
268                    'overlength_unique':dict(zip(('games','tokens'),db.execute('SELECT count(*),coalesce(sum(ntokens),0) FROM excluded').fetchone())),
269                    'attention_contract': 'Each offset interval is one game. Never attend or train EOS->BOS across intervals. Use single-game causal windows; pad loss=False. EOS only at actual recorded endings.'}
270        fault('before_manifest')
271        serialized = canonical(manifest) + b'\n'
272        if len(serialized) > MAX_MANIFEST_BYTES:
273            raise ValueError('manifest exceeds v1 byte limit; no new manifest published')
274        atomic(output / 'manifest.json', serialized)
275        fault('after_manifest')
276        return manifest
277    finally:
278        db.close()
279
280
281def main(argv=None):
282    parser = argparse.ArgumentParser(description=__doc__)
283    parser.add_argument('inputs', nargs='+')
284    parser.add_argument('--output', required=True)
285    parser.add_argument('--count-only', action='store_true')
286    parser.add_argument('--strong', action='store_true', help='Provisional: both Elo fields >=2000, base+40*inc >=480, completed, no BOT titles')
287    parser.add_argument('--min-rating', type=int)
288    parser.add_argument('--min-time', type=int)
289    parser.add_argument('--completed', action='store_true')
290    parser.add_argument('--exclude-bots', action='store_true')
291    parser.add_argument('--shard-tokens', type=int, default=4*1024*1024)
292    parser.add_argument('--max-shards', type=int, default=MAX_SHARDS, help=f'manifest index bound, 1..{MAX_SHARDS}')
293    parser.add_argument('--max-record-bytes', type=int, default=1024*1024)
294    parser.add_argument('--workers',type=int,default=1)
295    parser.add_argument('--commit-records',type=int,default=1)
296    parser.add_argument('--max-game-tokens',type=int)
297    parser.add_argument('--seed', default='chess-v1')
298    parser.add_argument('--validation-bps', type=int, default=100)
299    parser.add_argument('--test-bps', type=int, default=100)
300    args = parser.parse_args(argv)
301    if args.shard_tokens < 1 or args.max_record_bytes < 64 or min(args.validation_bps,args.test_bps) < 0 or args.validation_bps + args.test_bps > 10000:
302        parser.error('invalid size or split configuration')
303    config = {k: getattr(args, k) for k in ('count_only','shard_tokens','max_shards','max_record_bytes','seed','validation_bps','test_bps','workers','commit_records','max_game_tokens')}
304    config['filter'] = {'min_rating': args.min_rating if args.min_rating is not None else (2000 if args.strong else None),
305                        'min_time': args.min_time if args.min_time is not None else (480 if args.strong else None),
306                        'completed': args.completed or args.strong, 'exclude_bots': args.exclude_bots or args.strong}
307    try:
308        result = convert(args.inputs, args.output, config)
309    except (ValueError, OSError, UnicodeError, sqlite3.Error) as error:
310        parser.exit(2, f'conversion failed: {error}\n')
311    print(json.dumps({k: result[k] for k in ('fingerprint','counts','splits','accepted_tokens')}, indent=2))
312
313
314if __name__ == '__main__':
315    main()
316