item tracking more carefully
Browse files- data/local_completions_preference_movies.json +1 -0
- data/pool_preference_movies.json +0 -0
- data/reservations.json +1 -0
- data/reservations.lock +0 -0
- reject_submissions.py +57 -0
- scripts/test_coverage.py +120 -0
- src/__pycache__/config.cpython-310.pyc +0 -0
- src/__pycache__/data.cpython-310.pyc +0 -0
- src/data.py +312 -106
- src/upload.py +14 -6
data/local_completions_preference_movies.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{"0": 1, "1": 1, "2": 1, "3": 1, "4": 1, "5": 1, "6": 1, "7": 1, "8": 1, "9": 1, "10": 1, "11": 1, "12": 1, "13": 1, "14": 1, "15": 1, "16": 1, "17": 1, "18": 1, "19": 1, "20": 1, "21": 1, "22": 1, "23": 1, "24": 1, "25": 1, "26": 1, "27": 1, "28": 1, "29": 1, "30": 1, "31": 1, "32": 1, "33": 1, "34": 1, "35": 1, "36": 1, "37": 1, "38": 1, "39": 1, "40": 1, "41": 1, "42": 1, "43": 1, "44": 1, "45": 1, "46": 1, "47": 1, "48": 1, "49": 1}
|
data/pool_preference_movies.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
data/reservations.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{}
|
data/reservations.lock
ADDED
|
File without changes
|
reject_submissions.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Helper script to reject a submission.
|
| 3 |
+
|
| 4 |
+
Usage:
|
| 5 |
+
python scripts/reject_submission.py \
|
| 6 |
+
--repo lms-shape-preferences/results-preference-base \
|
| 7 |
+
--token hf_... \
|
| 8 |
+
--path json/worker123/submission456.json
|
| 9 |
+
|
| 10 |
+
This moves the file from json/ to rejected/ so the item gets re-assigned
|
| 11 |
+
to the next available participant.
|
| 12 |
+
"""
|
| 13 |
+
import argparse
|
| 14 |
+
import os
|
| 15 |
+
import tempfile
|
| 16 |
+
from huggingface_hub import HfApi
|
| 17 |
+
|
| 18 |
+
def reject_submission(repo_id: str, file_path: str, token: str) -> None:
|
| 19 |
+
assert file_path.startswith("json/"), \
|
| 20 |
+
f"Expected path starting with json/, got: {file_path}"
|
| 21 |
+
|
| 22 |
+
api = HfApi(token=token)
|
| 23 |
+
rejected_path = file_path.replace("json/", "rejected/", 1)
|
| 24 |
+
|
| 25 |
+
print(f"Downloading {file_path} ...")
|
| 26 |
+
local = api.hf_hub_download(
|
| 27 |
+
repo_id=repo_id,
|
| 28 |
+
filename=file_path,
|
| 29 |
+
repo_type="dataset",
|
| 30 |
+
token=token,
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
print(f"Re-uploading to {rejected_path} ...")
|
| 34 |
+
api.upload_file(
|
| 35 |
+
path_or_fileobj=local,
|
| 36 |
+
path_in_repo=rejected_path,
|
| 37 |
+
repo_id=repo_id,
|
| 38 |
+
repo_type="dataset",
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
print(f"Deleting {file_path} ...")
|
| 42 |
+
api.delete_file(
|
| 43 |
+
path_in_repo=file_path,
|
| 44 |
+
repo_id=repo_id,
|
| 45 |
+
repo_type="dataset",
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
print(f"Done. {file_path} β {rejected_path}")
|
| 49 |
+
print("The item will be re-assigned within 5 minutes (cache TTL).")
|
| 50 |
+
|
| 51 |
+
if __name__ == "__main__":
|
| 52 |
+
parser = argparse.ArgumentParser()
|
| 53 |
+
parser.add_argument("--repo", required=True)
|
| 54 |
+
parser.add_argument("--token", default=os.getenv("HF_TOKEN"))
|
| 55 |
+
parser.add_argument("--path", required=True, help="e.g. json/worker123/submission456.json")
|
| 56 |
+
args = parser.parse_args()
|
| 57 |
+
reject_submission(args.repo, args.path, args.token)
|
scripts/test_coverage.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Simulate N users going through the study and verify all 50 items get covered.
|
| 3 |
+
|
| 4 |
+
Usage:
|
| 5 |
+
cd /dfs/scratch1/echoi1/prolific_preferences
|
| 6 |
+
HF_TOKEN=hf_... python scripts/test_coverage.py
|
| 7 |
+
"""
|
| 8 |
+
import sys
|
| 9 |
+
import uuid
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 13 |
+
|
| 14 |
+
from src.config import load_config
|
| 15 |
+
from src.data import (
|
| 16 |
+
ensure_datasets,
|
| 17 |
+
assign_items,
|
| 18 |
+
release_reservation,
|
| 19 |
+
record_completion,
|
| 20 |
+
_load_pool,
|
| 21 |
+
_pool_path,
|
| 22 |
+
_data_dir,
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def simulate_user(cfg: dict, complete: bool = True) -> dict:
|
| 27 |
+
user_id = str(uuid.uuid4())
|
| 28 |
+
items = assign_items(cfg, user_id)
|
| 29 |
+
if complete:
|
| 30 |
+
release_reservation(user_id, cfg)
|
| 31 |
+
record_completion(user_id, items, cfg)
|
| 32 |
+
item_ids = [(item.get("pair_id") or item.get("item_id", ""), item.get("category", ""))
|
| 33 |
+
for item in items]
|
| 34 |
+
return {"user_id": user_id, "items": item_ids, "raw_items": items, "completed": complete}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def clear_local_state(cfg: dict):
|
| 38 |
+
data_dir = _data_dir(cfg)
|
| 39 |
+
for pattern in ["reservations*", "completion_cache*", "local_completions*",
|
| 40 |
+
"variant_counter*", "alternation_counter*"]:
|
| 41 |
+
for f in data_dir.glob(pattern):
|
| 42 |
+
f.unlink()
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def analyse_coverage(results: list, cfg: dict) -> bool:
|
| 46 |
+
cats = [c["name"] for c in cfg["categories"]]
|
| 47 |
+
all_passed = True
|
| 48 |
+
|
| 49 |
+
print()
|
| 50 |
+
print("=" * 60)
|
| 51 |
+
print("COVERAGE ANALYSIS")
|
| 52 |
+
print("=" * 60)
|
| 53 |
+
|
| 54 |
+
for cat in cats:
|
| 55 |
+
pool = _load_pool(str(_pool_path(cat, cfg)))
|
| 56 |
+
pool_ids = [p.get("pair_id") or p.get("item_id", "") for p in pool]
|
| 57 |
+
covered = {pid: 0 for pid in pool_ids}
|
| 58 |
+
|
| 59 |
+
for result in results:
|
| 60 |
+
if not result["completed"]:
|
| 61 |
+
continue
|
| 62 |
+
for item_id, item_cat in result["items"]:
|
| 63 |
+
if item_cat == cat and item_id in covered:
|
| 64 |
+
covered[item_id] += 1
|
| 65 |
+
|
| 66 |
+
covered_once = sum(1 for c in covered.values() if c >= 1)
|
| 67 |
+
never_covered = [pid[:8] for pid, c in covered.items() if c == 0]
|
| 68 |
+
over_covered = [pid[:8] for pid, c in covered.items() if c > 1]
|
| 69 |
+
|
| 70 |
+
print(f"\nCategory: {cat}")
|
| 71 |
+
print(f" Pool size: {len(pool)}")
|
| 72 |
+
print(f" Covered >= 1x: {covered_once} / {len(pool)}")
|
| 73 |
+
print(f" Never covered: {len(never_covered)} {never_covered[:5]}")
|
| 74 |
+
print(f" Over-covered: {len(over_covered)} {over_covered[:5]}")
|
| 75 |
+
|
| 76 |
+
if covered_once == len(pool):
|
| 77 |
+
print(f" β
PASS β all {len(pool)} items covered")
|
| 78 |
+
else:
|
| 79 |
+
print(f" β FAIL β {len(pool) - covered_once} items not covered")
|
| 80 |
+
all_passed = False
|
| 81 |
+
|
| 82 |
+
print()
|
| 83 |
+
print("=" * 60)
|
| 84 |
+
print("OVERALL:", "β
PASS" if all_passed else "β FAIL")
|
| 85 |
+
print("=" * 60)
|
| 86 |
+
return all_passed
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def run_simulation(label: str, n_users: int, dropout_indices: list = None):
|
| 90 |
+
dropout_indices = dropout_indices or []
|
| 91 |
+
cfg = load_config()
|
| 92 |
+
ensure_datasets(cfg)
|
| 93 |
+
clear_local_state(cfg)
|
| 94 |
+
|
| 95 |
+
print(f"\nββ {label} ββ")
|
| 96 |
+
print(f"[TEST] {n_users} users, dropouts at: {dropout_indices}")
|
| 97 |
+
|
| 98 |
+
results = []
|
| 99 |
+
for i in range(n_users):
|
| 100 |
+
complete = i not in dropout_indices
|
| 101 |
+
result = simulate_user(cfg, complete=complete)
|
| 102 |
+
results.append(result)
|
| 103 |
+
status = "β
completed" if complete else "β abandoned"
|
| 104 |
+
print(f" User {i+1:2d} ({status}): "
|
| 105 |
+
f"indices = {[r[0][:8] for r in result['items']]}")
|
| 106 |
+
|
| 107 |
+
return analyse_coverage(results, cfg)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
if __name__ == "__main__":
|
| 111 |
+
# Test 1: perfect run β all 10 users complete, all 50 items covered exactly once
|
| 112 |
+
run_simulation("Test 1: Perfect run", n_users=10)
|
| 113 |
+
|
| 114 |
+
# Test 2: 2 dropouts β abandoned items should be picked up by extra users
|
| 115 |
+
# The new sort_key means uncovered+reserved items are preferred over covered+unreserved
|
| 116 |
+
# so items 35-39 (abandoned) get picked up by users 11-12 instead of re-covering 0-9
|
| 117 |
+
run_simulation("Test 2: 2 dropouts, 12 users", n_users=12, dropout_indices=[7, 3])
|
| 118 |
+
|
| 119 |
+
# Test 3: first user drops out β 11 users needed to cover all 50
|
| 120 |
+
run_simulation("Test 3: First user drops out", n_users=11, dropout_indices=[0])
|
src/__pycache__/config.cpython-310.pyc
ADDED
|
Binary file (3.2 kB). View file
|
|
|
src/__pycache__/data.cpython-310.pyc
ADDED
|
Binary file (17.2 kB). View file
|
|
|
src/data.py
CHANGED
|
@@ -1,18 +1,32 @@
|
|
| 1 |
"""
|
| 2 |
-
Dataset download, item-pool caching,
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
--------------
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
"""
|
| 17 |
import json
|
| 18 |
import random
|
|
@@ -25,7 +39,9 @@ from filelock import FileLock
|
|
| 25 |
|
| 26 |
from src.config import CATEGORY_TO_REPO
|
| 27 |
|
| 28 |
-
POOL_SIZE
|
|
|
|
|
|
|
| 29 |
|
| 30 |
|
| 31 |
# ββ Path helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -40,32 +56,22 @@ def _pool_path(category: str, cfg: dict) -> Path:
|
|
| 40 |
return _data_dir(cfg) / f"pool_{cfg['study_type']}_{category}.json"
|
| 41 |
|
| 42 |
|
| 43 |
-
def
|
| 44 |
-
return _data_dir(cfg) /
|
| 45 |
|
| 46 |
|
| 47 |
-
def
|
| 48 |
-
return _data_dir(cfg) /
|
| 49 |
|
| 50 |
|
| 51 |
-
def
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
# ββ Counter helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 60 |
-
|
| 61 |
-
def _read_counter(path: Path) -> int:
|
| 62 |
-
if not path.exists():
|
| 63 |
-
return 0
|
| 64 |
-
return int(path.read_text().strip() or "0")
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
def _write_counter(path: Path, value: int) -> None:
|
| 68 |
-
path.write_text(str(value))
|
| 69 |
|
| 70 |
|
| 71 |
# ββ Dataset download + normalisation βββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -78,10 +84,6 @@ def _download_and_cache(
|
|
| 78 |
hf_token: str,
|
| 79 |
data_dir: str,
|
| 80 |
) -> None:
|
| 81 |
-
"""
|
| 82 |
-
Download from HuggingFace, select POOL_SIZE items reproducibly, cache to disk.
|
| 83 |
-
No-op if the pool file already exists.
|
| 84 |
-
"""
|
| 85 |
pool_path = Path(data_dir) / f"pool_{study_type}_{category}.json"
|
| 86 |
if pool_path.exists():
|
| 87 |
print(f"[DATA] Pool already cached: {pool_path}")
|
|
@@ -96,14 +98,11 @@ def _download_and_cache(
|
|
| 96 |
ds = load_dataset(repo_id, token=token_arg, trust_remote_code=True)
|
| 97 |
|
| 98 |
if study_type == "preference":
|
| 99 |
-
# Preference repos: rows have pair_id, category, product_a, product_b, split.
|
| 100 |
-
# Use the "test" split key when available; otherwise filter by split=="test".
|
| 101 |
if "test" in ds:
|
| 102 |
rows = [dict(r) for r in ds["test"]]
|
| 103 |
else:
|
| 104 |
rows = [dict(r) for r in ds["train"] if r.get("split") == "test"]
|
| 105 |
else:
|
| 106 |
-
# Likelihood repos: use test split if present, otherwise first available split.
|
| 107 |
split_key = "test" if "test" in ds else list(ds.keys())[0]
|
| 108 |
rows = [dict(r) for r in ds[split_key]]
|
| 109 |
|
|
@@ -112,7 +111,6 @@ def _download_and_cache(
|
|
| 112 |
selected = rows[:POOL_SIZE]
|
| 113 |
|
| 114 |
if study_type == "likelihood":
|
| 115 |
-
# Normalise: extract the metadata dict, add a stable item_id and category.
|
| 116 |
normalised = []
|
| 117 |
for i, row in enumerate(selected):
|
| 118 |
meta = row["metadata"]
|
|
@@ -125,8 +123,6 @@ def _download_and_cache(
|
|
| 125 |
normalised.append(meta)
|
| 126 |
selected = normalised
|
| 127 |
else:
|
| 128 |
-
# Preference: deep-copy so nested product dicts are plain Python dicts,
|
| 129 |
-
# and stamp each product with its category.
|
| 130 |
cleaned = []
|
| 131 |
for row in selected:
|
| 132 |
r = dict(row)
|
|
@@ -145,7 +141,6 @@ def _download_and_cache(
|
|
| 145 |
|
| 146 |
|
| 147 |
def ensure_datasets(cfg: dict) -> None:
|
| 148 |
-
"""Download and cache all category pools needed for this study config."""
|
| 149 |
for cat_cfg in cfg["categories"]:
|
| 150 |
_download_and_cache(
|
| 151 |
study_type=cfg["study_type"],
|
|
@@ -162,113 +157,320 @@ def _load_pool(pool_path_str: str) -> list:
|
|
| 162 |
return json.load(f)
|
| 163 |
|
| 164 |
|
| 165 |
-
# ββ
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
|
|
|
| 172 |
|
| 173 |
with lock:
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
|
| 178 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
|
| 180 |
def _assign_variants(cfg: dict, n: int) -> list:
|
| 181 |
variants = cfg.get("model_variants")
|
| 182 |
if not variants:
|
| 183 |
-
|
| 184 |
-
|
|
|
|
| 185 |
|
| 186 |
if len(variants) == 1:
|
| 187 |
-
# Single variant β assign it to all items, no alternation needed
|
| 188 |
return [variants[0]] * n
|
| 189 |
|
| 190 |
-
|
| 191 |
-
lock = FileLock(str(_data_dir(cfg) / "variant_counter.lock"))
|
| 192 |
with lock:
|
| 193 |
-
|
| 194 |
-
|
|
|
|
| 195 |
|
| 196 |
v0, v1 = variants[0], variants[1]
|
| 197 |
if ctr % 2 == 1:
|
| 198 |
v0, v1 = v1, v0
|
| 199 |
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
|
| 206 |
def _compute_counts(cfg: dict) -> dict:
|
| 207 |
-
"""
|
| 208 |
-
Determine how many items to assign from each category for one user.
|
| 209 |
-
|
| 210 |
-
Single category β all pairs_per_user go to that category.
|
| 211 |
-
Two categories β use the configured counts but swap them on every other
|
| 212 |
-
call so the cumulative totals stay balanced:
|
| 213 |
-
user 1: movies=3, groceries=2
|
| 214 |
-
user 2: movies=2, groceries=3
|
| 215 |
-
user 3: movies=3, groceries=2 β¦
|
| 216 |
-
"""
|
| 217 |
cats = cfg["categories"]
|
| 218 |
n = cfg["pairs_per_user"]
|
| 219 |
|
| 220 |
if len(cats) == 1:
|
| 221 |
return {cats[0]["name"]: n}
|
| 222 |
|
| 223 |
-
|
| 224 |
-
lock = FileLock(str(_alternation_lock_path(cfg)))
|
| 225 |
with lock:
|
| 226 |
-
|
| 227 |
-
|
|
|
|
| 228 |
|
| 229 |
base = {c["name"]: c["count"] for c in cats}
|
| 230 |
-
|
| 231 |
-
# Sanity-check: if configured counts don't sum to pairs_per_user, split evenly
|
| 232 |
if sum(base.values()) != n:
|
| 233 |
base = {}
|
| 234 |
for i, c in enumerate(cats):
|
| 235 |
base[c["name"]] = n // len(cats) + (1 if i < n % len(cats) else 0)
|
| 236 |
return base
|
| 237 |
|
| 238 |
-
|
| 239 |
-
if call_count % 2 == 1:
|
| 240 |
names = [c["name"] for c in cats]
|
| 241 |
base[names[0]], base[names[1]] = base[names[1]], base[names[0]]
|
| 242 |
|
| 243 |
return base
|
| 244 |
|
| 245 |
|
| 246 |
-
def assign_items(cfg: dict) -> list:
|
| 247 |
-
"""Assign a full set of items for one participant, interleaved across categories."""
|
| 248 |
counts = _compute_counts(cfg)
|
| 249 |
items = []
|
| 250 |
for cat_name, n in counts.items():
|
| 251 |
-
items.extend(_assign_from_category(cat_name, n, cfg))
|
| 252 |
-
random.shuffle(items)
|
| 253 |
return items
|
| 254 |
|
| 255 |
|
| 256 |
-
# ββ
|
| 257 |
|
| 258 |
def _make_item_slot(item: dict, study_type: str) -> dict:
|
| 259 |
-
"""Create a blank result slot for one item/pair, ready to be filled during the study."""
|
| 260 |
base = {
|
| 261 |
-
#
|
| 262 |
-
|
|
|
|
| 263 |
"conversation": {
|
| 264 |
"system_prompt": "",
|
| 265 |
-
"closing_message": "",
|
| 266 |
"turns": [],
|
| 267 |
"num_turns": 0,
|
| 268 |
},
|
| 269 |
"reflection": {},
|
| 270 |
-
"pre_rating": None,
|
| 271 |
-
"post_rating": None,
|
| 272 |
"rating_delta": None,
|
| 273 |
}
|
| 274 |
if study_type == "preference":
|
|
@@ -290,30 +492,34 @@ def _make_item_slot(item: dict, study_type: str) -> dict:
|
|
| 290 |
return base
|
| 291 |
|
| 292 |
|
|
|
|
|
|
|
| 293 |
def init_state(cfg: dict) -> dict:
|
| 294 |
"""Build the initial session-state dict for a new participant."""
|
| 295 |
n = cfg["pairs_per_user"]
|
|
|
|
| 296 |
variants = _assign_variants(cfg, n)
|
| 297 |
-
items = assign_items(cfg)[:n]
|
| 298 |
-
|
| 299 |
slots = [_make_item_slot(it, cfg["study_type"]) for it in items]
|
| 300 |
for slot, variant in zip(slots, variants):
|
| 301 |
slot["model_name"] = variant["model_name"]
|
| 302 |
slot["prompt_variant"] = variant["prompt_variant"]
|
| 303 |
-
|
| 304 |
for i, slot in enumerate(slots):
|
| 305 |
print(f"[ITEM {i}] category={slot.get('category')} "
|
|
|
|
| 306 |
f"model={slot.get('model_name')} "
|
| 307 |
-
f"personalization={slot.get('prompt_variant',{}).get('personalization')}")
|
| 308 |
-
|
| 309 |
try:
|
| 310 |
params = st.query_params
|
| 311 |
except Exception:
|
| 312 |
params = {}
|
| 313 |
-
|
| 314 |
return {
|
| 315 |
"submission_id": str(uuid.uuid4()),
|
| 316 |
-
"user_id":
|
| 317 |
"prolific_pid": params.get("PROLIFIC_PID", ""),
|
| 318 |
"study_id": params.get("STUDY_ID", ""),
|
| 319 |
"session_id": params.get("SESSION_ID", ""),
|
|
|
|
| 1 |
"""
|
| 2 |
+
Dataset download, item-pool caching, completion-aware assignment, and session-state init.
|
| 3 |
+
|
| 4 |
+
Assignment strategy
|
| 5 |
+
-------------------
|
| 6 |
+
Items are assigned based on how many *accepted* completions they already have,
|
| 7 |
+
ensuring the least-covered items are always prioritised.
|
| 8 |
+
|
| 9 |
+
Each assigned item is stamped with _pool_index and _pool_category at assignment
|
| 10 |
+
time so record_completion never needs to do a fuzzy pair_id match β it reads
|
| 11 |
+
the index directly.
|
| 12 |
+
|
| 13 |
+
Accepted completions = JSON files under json/ in the output repo.
|
| 14 |
+
Rejected completions = JSON files moved to rejected/ by the admin.
|
| 15 |
+
β moving a file to rejected/ automatically makes that item available again.
|
| 16 |
+
|
| 17 |
+
Reservations
|
| 18 |
+
------------
|
| 19 |
+
When a user starts, their items are "reserved" in a local file for 80 min.
|
| 20 |
+
Concurrent users (up to 5) each get a FileLock on the reservation file so they
|
| 21 |
+
never receive the same items. Reservations expire automatically so abandoned
|
| 22 |
+
sessions don't permanently block items.
|
| 23 |
+
|
| 24 |
+
Dropout / rejection recovery
|
| 25 |
+
-----------------------------
|
| 26 |
+
- Dropout: reservation expires after 80 min β item re-enters the pool.
|
| 27 |
+
- Rejection: admin moves json/{worker}/{id}.json β rejected/{worker}/{id}.json
|
| 28 |
+
in the HF dataset repo. On next Space restart (or cache expiry) the item's
|
| 29 |
+
accepted count drops to 0 and it gets re-assigned.
|
| 30 |
"""
|
| 31 |
import json
|
| 32 |
import random
|
|
|
|
| 39 |
|
| 40 |
from src.config import CATEGORY_TO_REPO
|
| 41 |
|
| 42 |
+
POOL_SIZE = 50 # items selected per (study_type, category)
|
| 43 |
+
RESERVATION_TTL = 60 * 80 # 80 min: 30 min expected + ~2.5x buffer
|
| 44 |
+
COMPLETION_CACHE_TTL = 300 # re-scan HF repo every 5 minutes
|
| 45 |
|
| 46 |
|
| 47 |
# ββ Path helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 56 |
return _data_dir(cfg) / f"pool_{cfg['study_type']}_{category}.json"
|
| 57 |
|
| 58 |
|
| 59 |
+
def _reservation_path(cfg: dict) -> Path:
|
| 60 |
+
return _data_dir(cfg) / "reservations.json"
|
| 61 |
|
| 62 |
|
| 63 |
+
def _reservation_lock_path(cfg: dict) -> Path:
|
| 64 |
+
return _data_dir(cfg) / "reservations.lock"
|
| 65 |
|
| 66 |
|
| 67 |
+
def _local_completions_path(category: str, cfg: dict) -> Path:
|
| 68 |
+
"""
|
| 69 |
+
Local file tracking completed item counts this container session.
|
| 70 |
+
Updated immediately on each completion so subsequent assignments
|
| 71 |
+
see accurate counts without waiting for an HF re-scan.
|
| 72 |
+
Reset on container restart β HF is the durable source of truth.
|
| 73 |
+
"""
|
| 74 |
+
return _data_dir(cfg) / f"local_completions_{cfg['study_type']}_{category}.json"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
|
| 77 |
# ββ Dataset download + normalisation βββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 84 |
hf_token: str,
|
| 85 |
data_dir: str,
|
| 86 |
) -> None:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
pool_path = Path(data_dir) / f"pool_{study_type}_{category}.json"
|
| 88 |
if pool_path.exists():
|
| 89 |
print(f"[DATA] Pool already cached: {pool_path}")
|
|
|
|
| 98 |
ds = load_dataset(repo_id, token=token_arg, trust_remote_code=True)
|
| 99 |
|
| 100 |
if study_type == "preference":
|
|
|
|
|
|
|
| 101 |
if "test" in ds:
|
| 102 |
rows = [dict(r) for r in ds["test"]]
|
| 103 |
else:
|
| 104 |
rows = [dict(r) for r in ds["train"] if r.get("split") == "test"]
|
| 105 |
else:
|
|
|
|
| 106 |
split_key = "test" if "test" in ds else list(ds.keys())[0]
|
| 107 |
rows = [dict(r) for r in ds[split_key]]
|
| 108 |
|
|
|
|
| 111 |
selected = rows[:POOL_SIZE]
|
| 112 |
|
| 113 |
if study_type == "likelihood":
|
|
|
|
| 114 |
normalised = []
|
| 115 |
for i, row in enumerate(selected):
|
| 116 |
meta = row["metadata"]
|
|
|
|
| 123 |
normalised.append(meta)
|
| 124 |
selected = normalised
|
| 125 |
else:
|
|
|
|
|
|
|
| 126 |
cleaned = []
|
| 127 |
for row in selected:
|
| 128 |
r = dict(row)
|
|
|
|
| 141 |
|
| 142 |
|
| 143 |
def ensure_datasets(cfg: dict) -> None:
|
|
|
|
| 144 |
for cat_cfg in cfg["categories"]:
|
| 145 |
_download_and_cache(
|
| 146 |
study_type=cfg["study_type"],
|
|
|
|
| 157 |
return json.load(f)
|
| 158 |
|
| 159 |
|
| 160 |
+
# ββ Accepted completion counts ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 161 |
+
|
| 162 |
+
def _get_accepted_counts(category: str, cfg: dict) -> dict:
|
| 163 |
+
"""
|
| 164 |
+
Return how many times each pool item has been accepted.
|
| 165 |
+
|
| 166 |
+
Sources (merged, highest count wins):
|
| 167 |
+
1. Local completions file β written immediately on each completion this session.
|
| 168 |
+
2. HF output repo scan β authoritative after a container restart.
|
| 169 |
+
Results cached for COMPLETION_CACHE_TTL seconds.
|
| 170 |
+
|
| 171 |
+
Rejected submissions live under rejected/ and are NOT counted.
|
| 172 |
+
"""
|
| 173 |
+
pool = _load_pool(str(_pool_path(category, cfg)))
|
| 174 |
+
counts = {str(i): 0 for i in range(len(pool))}
|
| 175 |
+
|
| 176 |
+
# ββ Source 1: local completions (most up-to-date within this session) ββββ
|
| 177 |
+
local_path = _local_completions_path(category, cfg)
|
| 178 |
+
if local_path.exists():
|
| 179 |
+
try:
|
| 180 |
+
with open(local_path) as f:
|
| 181 |
+
local = json.load(f)
|
| 182 |
+
for k, v in local.items():
|
| 183 |
+
counts[k] = max(counts.get(k, 0), v)
|
| 184 |
+
print(f"[ASSIGN] Local completions for {category}: "
|
| 185 |
+
f"{sum(1 for v in local.values() if v > 0)} items completed")
|
| 186 |
+
except Exception as e:
|
| 187 |
+
print(f"[ASSIGN] Could not read local completions: {e}")
|
| 188 |
+
|
| 189 |
+
# ββ Source 2: HF scan (authoritative after restart, with 5-min cache) βββ
|
| 190 |
+
cache_path = _data_dir(cfg) / f"completion_cache_{cfg['study_type']}_{category}.json"
|
| 191 |
+
now = time.time()
|
| 192 |
+
hf_counts = None
|
| 193 |
+
|
| 194 |
+
if cache_path.exists():
|
| 195 |
+
try:
|
| 196 |
+
with open(cache_path) as f:
|
| 197 |
+
cache = json.load(f)
|
| 198 |
+
if now - cache.get("timestamp", 0) < COMPLETION_CACHE_TTL:
|
| 199 |
+
hf_counts = cache["counts"]
|
| 200 |
+
except Exception:
|
| 201 |
+
pass
|
| 202 |
+
|
| 203 |
+
if hf_counts is None:
|
| 204 |
+
hf_counts = {str(i): 0 for i in range(len(pool))}
|
| 205 |
+
hf_token = cfg.get("hf_token", "")
|
| 206 |
+
output_repo = cfg.get("output_dataset_repo", "")
|
| 207 |
+
if hf_token and output_repo:
|
| 208 |
+
try:
|
| 209 |
+
from huggingface_hub import HfApi
|
| 210 |
+
api = HfApi(token=hf_token)
|
| 211 |
+
files = list(api.list_repo_files(repo_id=output_repo, repo_type="dataset"))
|
| 212 |
+
json_files = [f for f in files if f.startswith("json/") and f.endswith(".json")]
|
| 213 |
+
for filepath in json_files:
|
| 214 |
+
try:
|
| 215 |
+
content = api.hf_hub_download(
|
| 216 |
+
repo_id=output_repo,
|
| 217 |
+
filename=filepath,
|
| 218 |
+
repo_type="dataset",
|
| 219 |
+
token=hf_token,
|
| 220 |
+
)
|
| 221 |
+
with open(content) as f:
|
| 222 |
+
submission = json.load(f)
|
| 223 |
+
for item in submission.get("items", []):
|
| 224 |
+
if item.get("category") != category:
|
| 225 |
+
continue
|
| 226 |
+
idx = item.get("_pool_index")
|
| 227 |
+
if idx is not None:
|
| 228 |
+
hf_counts[str(idx)] = hf_counts.get(str(idx), 0) + 1
|
| 229 |
+
except Exception as e:
|
| 230 |
+
print(f"[ASSIGN] Could not parse {filepath}: {e}")
|
| 231 |
+
except Exception as e:
|
| 232 |
+
print(f"[ASSIGN] Could not scan HF repo: {e}")
|
| 233 |
+
try:
|
| 234 |
+
with open(cache_path, "w") as f:
|
| 235 |
+
json.dump({"timestamp": now, "counts": hf_counts}, f)
|
| 236 |
+
except Exception:
|
| 237 |
+
pass
|
| 238 |
+
|
| 239 |
+
for k, v in hf_counts.items():
|
| 240 |
+
counts[k] = max(counts.get(k, 0), v)
|
| 241 |
+
|
| 242 |
+
return counts
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
# ββ Reservation management ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 246 |
+
|
| 247 |
+
def _load_reservations(cfg: dict) -> dict:
|
| 248 |
+
path = _reservation_path(cfg)
|
| 249 |
+
if not path.exists():
|
| 250 |
+
return {}
|
| 251 |
+
try:
|
| 252 |
+
with open(path) as f:
|
| 253 |
+
return json.load(f)
|
| 254 |
+
except Exception:
|
| 255 |
+
return {}
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def _save_reservations(reservations: dict, cfg: dict) -> None:
|
| 259 |
+
with open(_reservation_path(cfg), "w") as f:
|
| 260 |
+
json.dump(reservations, f)
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def _expire_reservations(reservations: dict) -> dict:
|
| 264 |
+
now = time.time()
|
| 265 |
+
expired = [k for k, v in reservations.items() if v["expiry"] < now]
|
| 266 |
+
for k in expired:
|
| 267 |
+
print(f"[ASSIGN] Reservation expired for item index {k}")
|
| 268 |
+
del reservations[k]
|
| 269 |
+
return reservations
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def release_reservation(user_id: str, cfg: dict) -> None:
|
| 273 |
+
"""Release all reservations held by this user immediately after completion."""
|
| 274 |
+
lock = FileLock(str(_reservation_lock_path(cfg)), timeout=10)
|
| 275 |
+
with lock:
|
| 276 |
+
reservations = _load_reservations(cfg)
|
| 277 |
+
_expire_reservations(reservations)
|
| 278 |
+
released = [k for k, v in reservations.items() if v["user_id"] == user_id]
|
| 279 |
+
for k in released:
|
| 280 |
+
del reservations[k]
|
| 281 |
+
_save_reservations(reservations, cfg)
|
| 282 |
+
print(f"[ASSIGN] Released {len(released)} reservations for user {user_id}")
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
def record_completion(user_id: str, items: list, cfg: dict) -> None:
|
| 286 |
+
"""
|
| 287 |
+
Record completed item indices to the local completions file immediately.
|
| 288 |
+
Uses _pool_index stamped on each item at assignment time β no fuzzy matching.
|
| 289 |
+
Called after successful HF upload AND by the simulation script.
|
| 290 |
+
"""
|
| 291 |
+
# Group by category using the stamped _pool_category and _pool_index
|
| 292 |
+
by_category: dict = {}
|
| 293 |
+
for item in items:
|
| 294 |
+
cat = item.get("_pool_category") or item.get("category", "")
|
| 295 |
+
idx = item.get("_pool_index")
|
| 296 |
+
if idx is None:
|
| 297 |
+
print(f"[ASSIGN] WARNING: item missing _pool_index, skipping: "
|
| 298 |
+
f"{item.get('pair_id') or item.get('item_id', '?')}")
|
| 299 |
+
continue
|
| 300 |
+
by_category.setdefault(cat, []).append(idx)
|
| 301 |
+
|
| 302 |
+
for cat, indices in by_category.items():
|
| 303 |
+
pool = _load_pool(str(_pool_path(cat, cfg)))
|
| 304 |
+
completions_path = _local_completions_path(cat, cfg)
|
| 305 |
+
|
| 306 |
+
if completions_path.exists():
|
| 307 |
+
try:
|
| 308 |
+
with open(completions_path) as f:
|
| 309 |
+
completions = json.load(f)
|
| 310 |
+
except Exception:
|
| 311 |
+
completions = {str(i): 0 for i in range(len(pool))}
|
| 312 |
+
else:
|
| 313 |
+
completions = {str(i): 0 for i in range(len(pool))}
|
| 314 |
+
|
| 315 |
+
for idx in indices:
|
| 316 |
+
completions[str(idx)] = completions.get(str(idx), 0) + 1
|
| 317 |
+
|
| 318 |
+
with open(completions_path, "w") as f:
|
| 319 |
+
json.dump(completions, f)
|
| 320 |
+
|
| 321 |
+
# Invalidate HF cache so next scan re-reads fresh
|
| 322 |
+
cache_path = _data_dir(cfg) / f"completion_cache_{cfg['study_type']}_{cat}.json"
|
| 323 |
+
if cache_path.exists():
|
| 324 |
+
try:
|
| 325 |
+
cache_path.unlink()
|
| 326 |
+
except Exception:
|
| 327 |
+
pass
|
| 328 |
+
|
| 329 |
+
print(f"[ASSIGN] Recorded completions for {cat}: indices {indices} "
|
| 330 |
+
f"(user {user_id[:8]})")
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
# ββ Core assignment βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 334 |
+
|
| 335 |
+
def _assign_from_category(category: str, n: int, user_id: str, cfg: dict) -> list:
|
| 336 |
+
"""
|
| 337 |
+
Assign n items using least-coverage-first strategy.
|
| 338 |
+
|
| 339 |
+
Priority order:
|
| 340 |
+
1. Uncovered + unreserved (count=0, not reserved)
|
| 341 |
+
2. Uncovered + reserved by other (count=0, reserved β likely abandoned user)
|
| 342 |
+
3. Covered + unreserved (count>0, not reserved)
|
| 343 |
+
4. Covered + reserved by other (count>0, reserved)
|
| 344 |
|
| 345 |
+
This ensures abandoned users' items get picked up by subsequent users
|
| 346 |
+
rather than already-covered items being re-assigned.
|
| 347 |
+
"""
|
| 348 |
+
pool = _load_pool(str(_pool_path(category, cfg)))
|
| 349 |
+
accepted_counts = _get_accepted_counts(category, cfg)
|
| 350 |
+
lock = FileLock(str(_reservation_lock_path(cfg)), timeout=10)
|
| 351 |
|
| 352 |
with lock:
|
| 353 |
+
reservations = _load_reservations(cfg)
|
| 354 |
+
_expire_reservations(reservations)
|
| 355 |
+
|
| 356 |
+
def is_reserved_by_other(i):
|
| 357 |
+
r = reservations.get(str(i))
|
| 358 |
+
return r is not None and r["user_id"] != user_id
|
| 359 |
+
|
| 360 |
+
def sort_key(i):
|
| 361 |
+
count = accepted_counts.get(str(i), 0)
|
| 362 |
+
reserved = int(is_reserved_by_other(i))
|
| 363 |
+
return (count, reserved)
|
| 364 |
+
|
| 365 |
+
# All indices sorted by (count, is_reserved_by_other)
|
| 366 |
+
all_indices = sorted(range(len(pool)), key=sort_key)
|
| 367 |
+
selected_indices = all_indices[:n]
|
| 368 |
+
|
| 369 |
+
# Reserve selected items (overrides stale reservations from abandoned users)
|
| 370 |
+
expiry = time.time() + RESERVATION_TTL
|
| 371 |
+
for i in selected_indices:
|
| 372 |
+
reservations[str(i)] = {"user_id": user_id, "expiry": expiry}
|
| 373 |
+
|
| 374 |
+
_save_reservations(reservations, cfg)
|
| 375 |
+
|
| 376 |
+
selected = []
|
| 377 |
+
for i in selected_indices:
|
| 378 |
+
item = dict(pool[i])
|
| 379 |
+
item["_pool_index"] = i
|
| 380 |
+
item["_pool_category"] = category
|
| 381 |
+
selected.append(item)
|
| 382 |
|
| 383 |
+
print(f"[ASSIGN] {category}: assigned indices {selected_indices} "
|
| 384 |
+
f"(counts: {[accepted_counts.get(str(i), 0) for i in selected_indices]})")
|
| 385 |
+
return selected
|
| 386 |
+
|
| 387 |
+
|
| 388 |
+
# ββ Variant assignment ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 389 |
|
| 390 |
def _assign_variants(cfg: dict, n: int) -> list:
|
| 391 |
variants = cfg.get("model_variants")
|
| 392 |
if not variants:
|
| 393 |
+
return [{"name": "default",
|
| 394 |
+
"model_name": cfg["model_name"],
|
| 395 |
+
"prompt_variant": cfg["prompt_variant"]}] * n
|
| 396 |
|
| 397 |
if len(variants) == 1:
|
|
|
|
| 398 |
return [variants[0]] * n
|
| 399 |
|
| 400 |
+
lock = FileLock(str(_data_dir(cfg) / "variant_counter.lock"), timeout=10)
|
|
|
|
| 401 |
with lock:
|
| 402 |
+
counter_path = _data_dir(cfg) / "variant_counter.txt"
|
| 403 |
+
ctr = int(counter_path.read_text().strip()) if counter_path.exists() else 0
|
| 404 |
+
counter_path.write_text(str(ctr + 1))
|
| 405 |
|
| 406 |
v0, v1 = variants[0], variants[1]
|
| 407 |
if ctr % 2 == 1:
|
| 408 |
v0, v1 = v1, v0
|
| 409 |
|
| 410 |
+
from itertools import zip_longest
|
| 411 |
+
interleaved = []
|
| 412 |
+
for a, b in zip_longest([v0] * v0["count"], [v1] * v1["count"]):
|
| 413 |
+
if a: interleaved.append(a)
|
| 414 |
+
if b: interleaved.append(b)
|
| 415 |
+
|
| 416 |
+
print(f"[VARIANTS] user {ctr}: {[v['name'] for v in interleaved]}")
|
| 417 |
+
return interleaved
|
| 418 |
+
|
| 419 |
+
|
| 420 |
+
# ββ Category count computation ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 421 |
|
| 422 |
def _compute_counts(cfg: dict) -> dict:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 423 |
cats = cfg["categories"]
|
| 424 |
n = cfg["pairs_per_user"]
|
| 425 |
|
| 426 |
if len(cats) == 1:
|
| 427 |
return {cats[0]["name"]: n}
|
| 428 |
|
| 429 |
+
lock = FileLock(str(_data_dir(cfg) / "alternation_counter.lock"), timeout=10)
|
|
|
|
| 430 |
with lock:
|
| 431 |
+
path = _data_dir(cfg) / "alternation_counter.txt"
|
| 432 |
+
ctr = int(path.read_text().strip()) if path.exists() else 0
|
| 433 |
+
path.write_text(str(ctr + 1))
|
| 434 |
|
| 435 |
base = {c["name"]: c["count"] for c in cats}
|
|
|
|
|
|
|
| 436 |
if sum(base.values()) != n:
|
| 437 |
base = {}
|
| 438 |
for i, c in enumerate(cats):
|
| 439 |
base[c["name"]] = n // len(cats) + (1 if i < n % len(cats) else 0)
|
| 440 |
return base
|
| 441 |
|
| 442 |
+
if ctr % 2 == 1:
|
|
|
|
| 443 |
names = [c["name"] for c in cats]
|
| 444 |
base[names[0]], base[names[1]] = base[names[1]], base[names[0]]
|
| 445 |
|
| 446 |
return base
|
| 447 |
|
| 448 |
|
| 449 |
+
def assign_items(cfg: dict, user_id: str) -> list:
|
|
|
|
| 450 |
counts = _compute_counts(cfg)
|
| 451 |
items = []
|
| 452 |
for cat_name, n in counts.items():
|
| 453 |
+
items.extend(_assign_from_category(cat_name, n, user_id, cfg))
|
| 454 |
+
random.shuffle(items)
|
| 455 |
return items
|
| 456 |
|
| 457 |
|
| 458 |
+
# ββ Item slot construction ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 459 |
|
| 460 |
def _make_item_slot(item: dict, study_type: str) -> dict:
|
|
|
|
| 461 |
base = {
|
| 462 |
+
# Preserve pool index and category for record_completion in upload.py
|
| 463 |
+
"_pool_index": item.get("_pool_index"),
|
| 464 |
+
"_pool_category": item.get("_pool_category", item.get("category", "")),
|
| 465 |
"conversation": {
|
| 466 |
"system_prompt": "",
|
| 467 |
+
"closing_message": "",
|
| 468 |
"turns": [],
|
| 469 |
"num_turns": 0,
|
| 470 |
},
|
| 471 |
"reflection": {},
|
| 472 |
+
"pre_rating": None,
|
| 473 |
+
"post_rating": None,
|
| 474 |
"rating_delta": None,
|
| 475 |
}
|
| 476 |
if study_type == "preference":
|
|
|
|
| 492 |
return base
|
| 493 |
|
| 494 |
|
| 495 |
+
# ββ Session-state construction ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 496 |
+
|
| 497 |
def init_state(cfg: dict) -> dict:
|
| 498 |
"""Build the initial session-state dict for a new participant."""
|
| 499 |
n = cfg["pairs_per_user"]
|
| 500 |
+
user_id = str(uuid.uuid4())
|
| 501 |
variants = _assign_variants(cfg, n)
|
| 502 |
+
items = assign_items(cfg, user_id)[:n]
|
| 503 |
+
|
| 504 |
slots = [_make_item_slot(it, cfg["study_type"]) for it in items]
|
| 505 |
for slot, variant in zip(slots, variants):
|
| 506 |
slot["model_name"] = variant["model_name"]
|
| 507 |
slot["prompt_variant"] = variant["prompt_variant"]
|
| 508 |
+
|
| 509 |
for i, slot in enumerate(slots):
|
| 510 |
print(f"[ITEM {i}] category={slot.get('category')} "
|
| 511 |
+
f"pool_index={slot.get('_pool_index')} "
|
| 512 |
f"model={slot.get('model_name')} "
|
| 513 |
+
f"personalization={slot.get('prompt_variant', {}).get('personalization')}")
|
| 514 |
+
|
| 515 |
try:
|
| 516 |
params = st.query_params
|
| 517 |
except Exception:
|
| 518 |
params = {}
|
| 519 |
+
|
| 520 |
return {
|
| 521 |
"submission_id": str(uuid.uuid4()),
|
| 522 |
+
"user_id": user_id,
|
| 523 |
"prolific_pid": params.get("PROLIFIC_PID", ""),
|
| 524 |
"study_id": params.get("STUDY_ID", ""),
|
| 525 |
"session_id": params.get("SESSION_ID", ""),
|
src/upload.py
CHANGED
|
@@ -9,6 +9,7 @@ from pathlib import Path
|
|
| 9 |
|
| 10 |
import streamlit as st
|
| 11 |
from huggingface_hub import HfApi
|
|
|
|
| 12 |
|
| 13 |
|
| 14 |
@st.cache_resource
|
|
@@ -37,6 +38,10 @@ def save_and_upload(state: dict, cfg: dict) -> None:
|
|
| 37 |
submission_id = state.get("submission_id", str(uuid.uuid4()))
|
| 38 |
safe_worker = "".join(c if c.isalnum() else "_" for c in str(worker_id))
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
# ββ Write JSON ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 41 |
ann_dir = Path(cfg["annotations_dir"]) / safe_worker
|
| 42 |
ann_dir.mkdir(parents=True, exist_ok=True)
|
|
@@ -46,6 +51,7 @@ def save_and_upload(state: dict, cfg: dict) -> None:
|
|
| 46 |
json.dump(state, f, indent=2)
|
| 47 |
print(f"[SAVE] JSON written: {json_path}")
|
| 48 |
|
|
|
|
| 49 |
if hf_token:
|
| 50 |
try:
|
| 51 |
hf_api.upload_file(
|
|
@@ -55,9 +61,17 @@ def save_and_upload(state: dict, cfg: dict) -> None:
|
|
| 55 |
repo_type="dataset",
|
| 56 |
)
|
| 57 |
print("[HF] JSON uploaded.")
|
|
|
|
| 58 |
except Exception as e:
|
| 59 |
print(f"[HF] JSON upload error: {e}")
|
| 60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
# ββ Write + upload CSV ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 62 |
_save_and_upload_csv(state, cfg, hf_api, safe_worker, submission_id)
|
| 63 |
|
|
@@ -135,7 +149,6 @@ def _save_and_upload_csv(
|
|
| 135 |
pv.get("detailed_instruction", True),
|
| 136 |
cfg.get("pair_selection_seed", 42),
|
| 137 |
item.get("category", ""),
|
| 138 |
-
# Demographics (14 fields)
|
| 139 |
demographics.get("age", ""),
|
| 140 |
demographics.get("gender", ""),
|
| 141 |
demographics.get("geographic_region", ""),
|
|
@@ -150,19 +163,15 @@ def _save_and_upload_csv(
|
|
| 150 |
demographics.get("political_views", ""),
|
| 151 |
demographics.get("household_size", ""),
|
| 152 |
demographics.get("employment_status", ""),
|
| 153 |
-
# Background (6 fixed keys; empty string when category not in study)
|
| 154 |
background.get("movies_criteria", ""),
|
| 155 |
background.get("movies_enjoy", ""),
|
| 156 |
background.get("movies_avoid", ""),
|
| 157 |
background.get("groceries_criteria", ""),
|
| 158 |
background.get("groceries_enjoy", ""),
|
| 159 |
background.get("groceries_avoid", ""),
|
| 160 |
-
# Ratings
|
| 161 |
pre, post, delta,
|
| 162 |
-
# Conversation β full turn list as JSON string
|
| 163 |
conv.get("num_turns", 0),
|
| 164 |
json.dumps(conv.get("turns", [])),
|
| 165 |
-
# Reflection
|
| 166 |
refl.get("standout_moment", ""),
|
| 167 |
refl.get("thinking_change", ""),
|
| 168 |
]
|
|
@@ -188,7 +197,6 @@ def _save_and_upload_csv(
|
|
| 188 |
|
| 189 |
rows.append(common + extra)
|
| 190 |
|
| 191 |
-
# Write temp CSV and upload
|
| 192 |
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 193 |
unique_tag = uuid.uuid4().hex[:8]
|
| 194 |
repo_path = f"csv/{timestamp}_{safe_worker}_{unique_tag}.csv"
|
|
|
|
| 9 |
|
| 10 |
import streamlit as st
|
| 11 |
from huggingface_hub import HfApi
|
| 12 |
+
from src.data import release_reservation, record_completion
|
| 13 |
|
| 14 |
|
| 15 |
@st.cache_resource
|
|
|
|
| 38 |
submission_id = state.get("submission_id", str(uuid.uuid4()))
|
| 39 |
safe_worker = "".join(c if c.isalnum() else "_" for c in str(worker_id))
|
| 40 |
|
| 41 |
+
print(f"[SAVE] starting save_and_upload")
|
| 42 |
+
print(f"[SAVE] output_repo={output_repo}")
|
| 43 |
+
print(f"[SAVE] hf_token set={bool(hf_token)}")
|
| 44 |
+
|
| 45 |
# ββ Write JSON ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 46 |
ann_dir = Path(cfg["annotations_dir"]) / safe_worker
|
| 47 |
ann_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
| 51 |
json.dump(state, f, indent=2)
|
| 52 |
print(f"[SAVE] JSON written: {json_path}")
|
| 53 |
|
| 54 |
+
uploaded = False
|
| 55 |
if hf_token:
|
| 56 |
try:
|
| 57 |
hf_api.upload_file(
|
|
|
|
| 61 |
repo_type="dataset",
|
| 62 |
)
|
| 63 |
print("[HF] JSON uploaded.")
|
| 64 |
+
uploaded = True
|
| 65 |
except Exception as e:
|
| 66 |
print(f"[HF] JSON upload error: {e}")
|
| 67 |
|
| 68 |
+
if uploaded:
|
| 69 |
+
# Release reservations so items are immediately available for re-assignment
|
| 70 |
+
release_reservation(state.get("user_id", ""), cfg)
|
| 71 |
+
# Record completion locally β updates counts immediately without waiting
|
| 72 |
+
# for an HF re-scan. Also invalidates the HF cache.
|
| 73 |
+
record_completion(state.get("user_id", ""), state.get("items", []), cfg)
|
| 74 |
+
|
| 75 |
# ββ Write + upload CSV ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 76 |
_save_and_upload_csv(state, cfg, hf_api, safe_worker, submission_id)
|
| 77 |
|
|
|
|
| 149 |
pv.get("detailed_instruction", True),
|
| 150 |
cfg.get("pair_selection_seed", 42),
|
| 151 |
item.get("category", ""),
|
|
|
|
| 152 |
demographics.get("age", ""),
|
| 153 |
demographics.get("gender", ""),
|
| 154 |
demographics.get("geographic_region", ""),
|
|
|
|
| 163 |
demographics.get("political_views", ""),
|
| 164 |
demographics.get("household_size", ""),
|
| 165 |
demographics.get("employment_status", ""),
|
|
|
|
| 166 |
background.get("movies_criteria", ""),
|
| 167 |
background.get("movies_enjoy", ""),
|
| 168 |
background.get("movies_avoid", ""),
|
| 169 |
background.get("groceries_criteria", ""),
|
| 170 |
background.get("groceries_enjoy", ""),
|
| 171 |
background.get("groceries_avoid", ""),
|
|
|
|
| 172 |
pre, post, delta,
|
|
|
|
| 173 |
conv.get("num_turns", 0),
|
| 174 |
json.dumps(conv.get("turns", [])),
|
|
|
|
| 175 |
refl.get("standout_moment", ""),
|
| 176 |
refl.get("thinking_change", ""),
|
| 177 |
]
|
|
|
|
| 197 |
|
| 198 |
rows.append(common + extra)
|
| 199 |
|
|
|
|
| 200 |
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 201 |
unique_tag = uuid.uuid4().hex[:8]
|
| 202 |
repo_path = f"csv/{timestamp}_{safe_worker}_{unique_tag}.csv"
|