Mithula Artigala commited on
Commit
b385ef1
·
verified ·
1 Parent(s): be19eb5

Use JSONL-only experience storage with checkpoints every 300 games

Browse files
README.md CHANGED
@@ -25,7 +25,7 @@ After solving a word or using all six guesses, players submit a 0–100 rating b
25
 
26
  ## Dataset
27
 
28
- Rated records go to the private `QyrouNnet-AI/wordle-experiences` dataset, one new line per game in `experiences.jsonl`, only after the Hub commit succeeds. Individual JSON files are retained as archives. Concurrent submissions use a parent revision check and retries so earlier rows are preserved. Retries retain the first rating. No name, email, account identifier, or IP is included. Access logging is disabled; Hugging Face may independently maintain infrastructure logs.
29
 
30
  Schema version 2 stores ground-truth word, accepted guess sequence, both selected letter hints (null for unavailable categories), revealed letters and their vowel/consonant type, reveal order, rating, outcome, ordered actions, and timings. `hint_mode` is `random_letters_v1`. Hint actions include `hint_type`, `variation`, `letter`, and `after_guess`.
31
 
 
25
 
26
  ## Dataset
27
 
28
+ Rated records go to the private `QyrouNnet-AI/wordle-experiences` dataset, one new line per game in `experiences.jsonl`, only after the Hub commit succeeds. No per-game JSON files are created. At 300, 600, 900, and each further multiple of 300 experiences, an immutable cumulative JSONL snapshot is saved under `checkpoints/experiences-000300.jsonl`, `experiences-000600.jsonl`, and so on. The new row and checkpoint are saved together in one atomic Hub commit. The Dataset Viewer reads only the main table, so snapshots do not duplicate rows. Concurrent submissions use a parent revision check and retries so earlier rows are preserved. Retries retain the first rating. No name, email, account identifier, or IP is included. Access logging is disabled; Hugging Face may independently maintain infrastructure logs.
29
 
30
  Schema version 2 stores ground-truth word, accepted guess sequence, both selected letter hints (null for unavailable categories), revealed letters and their vowel/consonant type, reveal order, rating, outcome, ordered actions, and timings. `hint_mode` is `random_letters_v1`. Hint actions include `hint_type`, `variation`, `letter`, and `after_guess`.
31
 
backend/storage.py CHANGED
@@ -8,6 +8,11 @@ from huggingface_hub.errors import HfHubHTTPError
8
 
9
  _lock = threading.Lock()
10
  TABLE = 'experiences.jsonl'
 
 
 
 
 
11
 
12
 
13
  def rows_from(data):
@@ -35,21 +40,24 @@ def save_record(record):
35
  rating = existing_rating(rows, record['game_id'])
36
  if rating is not None:
37
  return rating
38
- path = directory / (record['game_id'] + '.json')
39
- saved = json.loads(path.read_text()) if path.exists() else record
40
- rows.append(saved)
 
 
 
 
 
 
41
  temporary = directory / (TABLE + '.tmp')
42
- temporary.write_bytes(encoded_rows(rows))
43
  temporary.replace(table)
44
- if not path.exists():
45
- path.write_text(json.dumps(saved, indent=2))
46
- return saved['experience_rating']
47
  repo = os.environ.get('DATASET_REPO')
48
  token = os.environ.get('DATASET_TOKEN')
49
  if not repo or not token:
50
  raise RuntimeError('Feedback storage is not configured.')
51
  api = HfApi(token=token)
52
- path = f"experiences/{record['game_id']}.json"
53
  for _ in range(8):
54
  info = api.dataset_info(repo)
55
  if not info.private:
@@ -62,16 +70,17 @@ def save_record(record):
62
  rating = existing_rating(rows, record['game_id'])
63
  if rating is not None:
64
  return rating
65
- saved = json.loads(read(path)) if path in files else record
66
- rows.append(saved)
67
- operations = [CommitOperationAdd(path_in_repo=TABLE, path_or_fileobj=encoded_rows(rows))]
68
- if path not in files:
69
- operations.append(CommitOperationAdd(path_in_repo=path, path_or_fileobj=json.dumps(saved, ensure_ascii=False).encode()))
 
70
  try:
71
  # Compare-and-swap protects rows written by other workers/replicas.
72
  api.create_commit(repo, repo_type='dataset', operations=operations,
73
  parent_commit=revision, commit_message='Append a rated Wordle experience')
74
- return saved['experience_rating']
75
  except HfHubHTTPError as exc:
76
  if exc.response is None or exc.response.status_code not in (409, 412):
77
  raise
 
8
 
9
  _lock = threading.Lock()
10
  TABLE = 'experiences.jsonl'
11
+ CHECKPOINT_INTERVAL = 300
12
+
13
+
14
+ def checkpoint_path(count):
15
+ return f'checkpoints/experiences-{count:06d}.jsonl' if count and count % CHECKPOINT_INTERVAL == 0 else None
16
 
17
 
18
  def rows_from(data):
 
40
  rating = existing_rating(rows, record['game_id'])
41
  if rating is not None:
42
  return rating
43
+ rows.append(record)
44
+ data = encoded_rows(rows)
45
+ checkpoint = checkpoint_path(len(rows))
46
+ if checkpoint and not (directory / checkpoint).exists():
47
+ target = directory / checkpoint
48
+ target.parent.mkdir(parents=True, exist_ok=True)
49
+ temp_checkpoint = target.with_suffix('.tmp')
50
+ temp_checkpoint.write_bytes(data)
51
+ temp_checkpoint.replace(target)
52
  temporary = directory / (TABLE + '.tmp')
53
+ temporary.write_bytes(data)
54
  temporary.replace(table)
55
+ return record['experience_rating']
 
 
56
  repo = os.environ.get('DATASET_REPO')
57
  token = os.environ.get('DATASET_TOKEN')
58
  if not repo or not token:
59
  raise RuntimeError('Feedback storage is not configured.')
60
  api = HfApi(token=token)
 
61
  for _ in range(8):
62
  info = api.dataset_info(repo)
63
  if not info.private:
 
70
  rating = existing_rating(rows, record['game_id'])
71
  if rating is not None:
72
  return rating
73
+ rows.append(record)
74
+ data = encoded_rows(rows)
75
+ operations = [CommitOperationAdd(path_in_repo=TABLE, path_or_fileobj=data)]
76
+ checkpoint = checkpoint_path(len(rows))
77
+ if checkpoint and checkpoint not in files:
78
+ operations.append(CommitOperationAdd(path_in_repo=checkpoint, path_or_fileobj=data))
79
  try:
80
  # Compare-and-swap protects rows written by other workers/replicas.
81
  api.create_commit(repo, repo_type='dataset', operations=operations,
82
  parent_commit=revision, commit_message='Append a rated Wordle experience')
83
+ return record['experience_rating']
84
  except HfHubHTTPError as exc:
85
  if exc.response is None or exc.response.status_code not in (409, 412):
86
  raise
scripts/migrate_experiences.py DELETED
@@ -1,46 +0,0 @@
1
- """Build the JSONL table from current records, preserving existing rows and files."""
2
- import json
3
- from pathlib import Path
4
- from huggingface_hub import HfApi, CommitOperationAdd, hf_hub_download
5
- from huggingface_hub.errors import HfHubHTTPError
6
-
7
-
8
- def migrate(repo='QyrouNnet-AI/wordle-experiences'):
9
- api = HfApi()
10
- for _ in range(8):
11
- info = api.dataset_info(repo)
12
- if not info.private:
13
- raise RuntimeError('Dataset must stay private.')
14
- files = {item.rfilename for item in info.siblings}
15
- def read(name):
16
- return Path(hf_hub_download(repo, name, repo_type='dataset', revision=info.sha)).read_text()
17
- rows = [json.loads(line) for line in read('experiences.jsonl').splitlines() if line.strip()] if 'experiences.jsonl' in files else []
18
- ids = {row['game_id'] for row in rows}
19
- for name in sorted(files):
20
- if name.startswith('experiences/') and name.endswith('.json'):
21
- row = json.loads(read(name))
22
- if row['game_id'] not in ids:
23
- rows.append(row)
24
- ids.add(row['game_id'])
25
- card = read('README.md')
26
- if 'configs:' not in card:
27
- card = card.replace('pretty_name: Wordle Experiences\n', 'pretty_name: Wordle Experiences\nconfigs:\n - config_name: default\n data_files:\n - split: train\n path: experiences.jsonl\n')
28
- card = card.replace('as one JSON file in `experiences/`.', 'as one new line per game in `experiences.jsonl`. Individual JSON files in `experiences/` are retained as archives.')
29
- if 'Concurrent submissions' not in card:
30
- card += '\nConcurrent submissions use an atomic Hub commit with a parent revision check and retries. This preserves earlier rows. Retrying the same game ID does not add a duplicate or replace its original rating.\n'
31
- table = ''.join(json.dumps(row, ensure_ascii=False, separators=(',', ':')) + '\n' for row in rows).encode()
32
- try:
33
- api.create_commit(repo, repo_type='dataset', parent_commit=info.sha,
34
- operations=[CommitOperationAdd(path_in_repo='experiences.jsonl', path_or_fileobj=table),
35
- CommitOperationAdd(path_in_repo='README.md', path_or_fileobj=card.encode())],
36
- commit_message='Preserve experience records in an append-only JSONL table')
37
- print(f'JSONL table contains {len(rows)} games; archive files preserved.')
38
- return
39
- except HfHubHTTPError as exc:
40
- if exc.response is None or exc.response.status_code not in (409, 412):
41
- raise
42
- raise RuntimeError('Concurrent changes prevented migration; retry later.')
43
-
44
-
45
- if __name__ == '__main__':
46
- migrate()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_game.py CHANGED
@@ -114,9 +114,10 @@ def test_save_is_idempotent_and_preserves_first_rating(current, tmp_path, monkey
114
  record = game.experience_record(ended, 90)
115
  assert save_record(record) == 90
116
  assert save_record({**record, 'experience_rating': 1}) == 90
117
- saved = json.loads((tmp_path / (record['game_id'] + '.json')).read_text())
118
  assert saved['experience_rating'] == 90
119
- assert len(list(tmp_path.glob('*.json'))) == 1
 
120
 
121
  def test_api_rating_failure_keeps_game(current, monkeypatch, tmp_path):
122
  import app as web
@@ -157,7 +158,7 @@ def test_cpu_new_game_and_hint_api_persist_letter_schema(monkeypatch, tmp_path):
157
  assert current['revealed'][0]['letter'] in 'ae'
158
  current = client.post('/api/guess', json={'token': current['token'], 'guess': 'crane'}).json()
159
  current = client.post('/api/rate', json={'token': current['token'], 'rating': 75}).json()
160
- saved = json.loads((tmp_path / (current['id'] + '.json')).read_text())
161
  assert saved['schema_version'] == 2
162
  assert saved['hint_reveal_sequence'] == ['vowel']
163
  assert saved['actions'][0]['letter'] == saved['hints']['vowel']
 
114
  record = game.experience_record(ended, 90)
115
  assert save_record(record) == 90
116
  assert save_record({**record, 'experience_rating': 1}) == 90
117
+ saved = json.loads((tmp_path / 'experiences.jsonl').read_text().splitlines()[0])
118
  assert saved['experience_rating'] == 90
119
+ assert not list(tmp_path.glob('*.json'))
120
+ assert len((tmp_path / 'experiences.jsonl').read_text().splitlines()) == 1
121
 
122
  def test_api_rating_failure_keeps_game(current, monkeypatch, tmp_path):
123
  import app as web
 
158
  assert current['revealed'][0]['letter'] in 'ae'
159
  current = client.post('/api/guess', json={'token': current['token'], 'guess': 'crane'}).json()
160
  current = client.post('/api/rate', json={'token': current['token'], 'rating': 75}).json()
161
+ saved = json.loads((tmp_path / 'experiences.jsonl').read_text().splitlines()[0])
162
  assert saved['schema_version'] == 2
163
  assert saved['hint_reveal_sequence'] == ['vowel']
164
  assert saved['actions'][0]['letter'] == saved['hints']['vowel']
tests/test_storage.py CHANGED
@@ -54,3 +54,63 @@ def test_remote_conflict_reloads_other_players_row_before_appending(monkeypatch,
54
  assert [call['parent_commit'] for call in hub.calls] == ['v1', 'v2']
55
  assert storage.save_record(submitted) == 60
56
  assert len(hub.calls) == 2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  assert [call['parent_commit'] for call in hub.calls] == ['v1', 'v2']
55
  assert storage.save_record(submitted) == 60
56
  assert len(hub.calls) == 2
57
+
58
+
59
+ def test_local_checkpoints_are_cumulative_jsonl_and_immutable(monkeypatch, tmp_path):
60
+ monkeypatch.setenv('LOCAL_DATA_DIR', str(tmp_path))
61
+ rows = [{'game_id': str(i), 'experience_rating': 80} for i in range(1, 300)]
62
+ table = tmp_path / storage.TABLE
63
+ table.write_bytes(storage.encoded_rows(rows))
64
+ storage.save_record({'game_id': '300', 'experience_rating': 80})
65
+ checkpoint300 = tmp_path / 'checkpoints/experiences-000300.jsonl'
66
+ snapshot = checkpoint300.read_bytes()
67
+ assert snapshot == table.read_bytes()
68
+ assert len(storage.rows_from(snapshot)) == 300
69
+ storage.save_record({'game_id': '301', 'experience_rating': 80})
70
+ assert checkpoint300.read_bytes() == snapshot
71
+ assert len(list((tmp_path / 'checkpoints').iterdir())) == 1
72
+ storage.save_record({'game_id': '300', 'experience_rating': 1})
73
+ assert checkpoint300.read_bytes() == snapshot
74
+ rows = storage.rows_from(table.read_bytes())
75
+ rows.extend({'game_id': str(i), 'experience_rating': 80} for i in range(302, 600))
76
+ table.write_bytes(storage.encoded_rows(rows))
77
+ storage.save_record({'game_id': '600', 'experience_rating': 80})
78
+ checkpoint600 = tmp_path / 'checkpoints/experiences-000600.jsonl'
79
+ assert checkpoint600.read_bytes() == table.read_bytes()
80
+ assert len(storage.rows_from(checkpoint600.read_bytes())) == 600
81
+ assert checkpoint300.read_bytes() == snapshot
82
+ assert not list(tmp_path.rglob('*.json'))
83
+
84
+
85
+ @pytest.mark.parametrize('initial_count', [0, 299, 300, 599])
86
+ def test_hub_checkpoints_commit_atomically_with_new_line(monkeypatch, tmp_path, initial_count):
87
+ monkeypatch.delenv('LOCAL_DATA_DIR', raising=False)
88
+ monkeypatch.setenv('DATASET_REPO', 'test/private')
89
+ monkeypatch.setenv('DATASET_TOKEN', 'test-token')
90
+ rows = [{'game_id': str(i), 'experience_rating': 80} for i in range(initial_count)]
91
+ class FakeHub:
92
+ calls = []
93
+ def dataset_info(self, repo):
94
+ return SimpleNamespace(private=True, sha='snapshot',
95
+ siblings=[SimpleNamespace(rfilename=storage.TABLE)])
96
+ def create_commit(self, repo, **kwargs):
97
+ self.calls.append(kwargs)
98
+ hub = FakeHub()
99
+ monkeypatch.setattr(storage, 'HfApi', lambda **kwargs: hub)
100
+ table = tmp_path / 'remote.jsonl'
101
+ table.write_bytes(storage.encoded_rows(rows))
102
+ monkeypatch.setattr(storage, 'hf_hub_download', lambda *args, **kwargs: str(table))
103
+ storage.save_record({'game_id': 'new', 'experience_rating': 75})
104
+ assert len(hub.calls) == 1
105
+ commit = hub.calls[0]
106
+ assert commit['parent_commit'] == 'snapshot'
107
+ operations = commit['operations']
108
+ expected_paths = [storage.TABLE]
109
+ checkpoint = storage.checkpoint_path(initial_count + 1)
110
+ if checkpoint:
111
+ expected_paths.append(checkpoint)
112
+ assert [op.path_in_repo for op in operations] == expected_paths
113
+ assert all(op.path_in_repo.endswith('.jsonl') for op in operations)
114
+ assert len(storage.rows_from(operations[0].path_or_fileobj)) == initial_count + 1
115
+ if checkpoint:
116
+ assert operations[0].path_or_fileobj == operations[1].path_or_fileobj