Spaces:
Running
Running
Mithula Artigala commited on
Use JSONL-only experience storage with checkpoints every 300 games
Browse files- README.md +1 -1
- backend/storage.py +23 -14
- scripts/migrate_experiences.py +0 -46
- tests/test_game.py +4 -3
- tests/test_storage.py +60 -0
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.
|
| 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 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
temporary = directory / (TABLE + '.tmp')
|
| 42 |
-
temporary.write_bytes(
|
| 43 |
temporary.replace(table)
|
| 44 |
-
|
| 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 |
-
|
| 66 |
-
|
| 67 |
-
operations = [CommitOperationAdd(path_in_repo=TABLE, path_or_fileobj=
|
| 68 |
-
|
| 69 |
-
|
|
|
|
| 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
|
| 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 /
|
| 118 |
assert saved['experience_rating'] == 90
|
| 119 |
-
assert
|
|
|
|
| 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 /
|
| 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
|