ehejin commited on
Commit
46bfd91
Β·
1 Parent(s): 261fec3

item tracking more carefully

Browse files
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, round-robin assignment, and session-state init.
3
-
4
- Pool selection
5
- --------------
6
- 50 items per (study_type, category) are selected from the HF test split using the
7
- configured seed, written to data/ on first startup, and reloaded from disk after that.
8
-
9
- Assignment
10
- ----------
11
- Items are assigned round-robin via a file-locked counter per category so that
12
- concurrent users get different items. For two-category studies the per-user split
13
- (e.g. 3 movies / 2 groceries) alternates on successive calls via a separate
14
- alternation counter that increments by exactly 1 per user β€” fixing the original
15
- bug where the movies product counter was reused.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 = 50 # items selected per (study_type, category)
 
 
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 _counter_path(category: str, cfg: dict) -> Path:
44
- return _data_dir(cfg) / f"counter_{cfg['study_type']}_{category}.txt"
45
 
46
 
47
- def _counter_lock_path(category: str, cfg: dict) -> Path:
48
- return _data_dir(cfg) / f"counter_{cfg['study_type']}_{category}.lock"
49
 
50
 
51
- def _alternation_path(cfg: dict) -> Path:
52
- return _data_dir(cfg) / "alternation_counter.txt"
53
-
54
-
55
- def _alternation_lock_path(cfg: dict) -> Path:
56
- return _data_dir(cfg) / "alternation_counter.lock"
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
- # ── Round-robin assignment ────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
 
167
- def _assign_from_category(category: str, n: int, cfg: dict) -> list:
168
- """Atomically assign n items from the pool, wrapping around when exhausted."""
169
- pool = _load_pool(str(_pool_path(category, cfg)))
170
- total = len(pool)
171
- lock = FileLock(str(_counter_lock_path(category, cfg)))
 
172
 
173
  with lock:
174
- ctr = _read_counter(_counter_path(category, cfg))
175
- assigned = [pool[(ctr + i) % total] for i in range(n)]
176
- _write_counter(_counter_path(category, cfg), ctr + n)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
 
178
- return assigned
 
 
 
 
 
179
 
180
  def _assign_variants(cfg: dict, n: int) -> list:
181
  variants = cfg.get("model_variants")
182
  if not variants:
183
- # Old-style config with top-level model_name/prompt_variant
184
- return [{"model_name": cfg["model_name"], "prompt_variant": cfg["prompt_variant"]}] * n
 
185
 
186
  if len(variants) == 1:
187
- # Single variant β€” assign it to all items, no alternation needed
188
  return [variants[0]] * n
189
 
190
- # Two-variant alternation
191
- lock = FileLock(str(_data_dir(cfg) / "variant_counter.lock"))
192
  with lock:
193
- ctr = _read_counter(_data_dir(cfg) / "variant_counter.txt")
194
- _write_counter(_data_dir(cfg) / "variant_counter.txt", ctr + 1)
 
195
 
196
  v0, v1 = variants[0], variants[1]
197
  if ctr % 2 == 1:
198
  v0, v1 = v1, v0
199
 
200
- assigned = [v0] * v0["count"] + [v1] * v1["count"]
201
- random.shuffle(assigned)
202
- print(f"[VARIANTS] user {ctr}: {[v['name'] for v in assigned]}")
203
- return assigned
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
- # Alternation counter increments by exactly 1 per user (separate from item counters)
224
- lock = FileLock(str(_alternation_lock_path(cfg)))
225
  with lock:
226
- call_count = _read_counter(_alternation_path(cfg))
227
- _write_counter(_alternation_path(cfg), call_count + 1)
 
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
- # On odd calls swap the two counts
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) # interleave so participant doesn't see all of one category first
253
  return items
254
 
255
 
256
- # ── Session-state construction ────────────────────────────────────────────────
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
- # conversation stores ALL turns including the two synthetic opening turns.
262
- # num_turns counts only real human exchanges (not the synthetic ones).
 
263
  "conversation": {
264
  "system_prompt": "",
265
- "closing_message": "", # vote_final_message equivalent β€” stored but never shown
266
  "turns": [],
267
  "num_turns": 0,
268
  },
269
  "reflection": {},
270
- "pre_rating": None, # 1–7 int set on the intro screen
271
- "post_rating": None, # 1–7 int set on the post_rating screen
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": str(uuid.uuid4()),
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"