README.md CHANGED
@@ -34,15 +34,3 @@ Each vote row stores:
34
  - `dataset`
35
  - `group`
36
  - `session_id`
37
- - `pair_reason` (which sampler branch served the pair)
38
- - `rater_name` (optional self-reported handle, empty when anonymous)
39
-
40
- ## Maintenance
41
-
42
- Scripts under `maintenance/` import from the repo root, so run them as modules from there:
43
-
44
- ```
45
- python -m maintenance.compact_logs
46
- ```
47
-
48
- Compaction rewrites every shard through `VOTE_COLUMNS`, so columns added to the schema are backfilled as null on older rows.
 
34
  - `dataset`
35
  - `group`
36
  - `session_id`
 
 
 
 
 
 
 
 
 
 
 
 
app.py CHANGED
@@ -5,36 +5,22 @@ import time
5
  import uuid
6
  import os
7
  import html
8
- import sys
9
-
10
- from typing import Callable
11
 
12
  import pandas as pd
13
  from huggingface_hub import hf_hub_download
14
- from huggingface_hub.utils import EntryNotFoundError
15
 
16
  from storage import VoteStorage
17
- from collections import Counter, defaultdict
18
-
19
- from stats_from_logs import load_vote_logs, stats_by_md5
20
- from explorer import ALLOWED_CLASSIFIER_FILTERS, DISAGREEMENT_SORT_MODE, SORT_MODES, add_results_tab, build_results_data, load_more_results, on_gallery_select
21
 
22
  DEBUG_MODE = os.getenv("DEBUG", "0").lower() in ("1", "true", "yes", "on")
23
  RATINGS_APP_TOKEN = os.getenv("RATINGS_APP_TOKEN")
24
  SUBMIT_KEY = os.getenv("RATINGS_SUBMIT_KEY")
25
  assert SUBMIT_KEY, "Missing required env var: RATINGS_SUBMIT_KEY"
26
  POOL_REPO_ID = "taigasan/e6-visual-ratings"
27
- RATER_NAME_MAX_LEN = 64
28
  VOTE_STORAGE = VoteStorage(mode="void" if DEBUG_MODE else "hf", token=RATINGS_APP_TOKEN)
29
  STATS_RELOAD_S = 30 * 60
30
 
31
- def _drop_cdn_unavailable(df: pd.DataFrame, label: str) -> pd.DataFrame:
32
- keep = df["cdn_available"].astype("boolean").fillna(True).astype(bool)
33
- if dropped := int((~keep).sum()):
34
- print(f"{label}: dropping {dropped} items unavailable on the e621 CDN")
35
-
36
- return df[keep].reset_index(drop=True)
37
-
38
  # -- Pool dataset -----------------------------------------------------------
39
  _pool_path = hf_hub_download(
40
  repo_id=POOL_REPO_ID,
@@ -42,221 +28,111 @@ _pool_path = hf_hub_download(
42
  repo_type="dataset",
43
  token=RATINGS_APP_TOKEN
44
  )
45
- _pool_df = _drop_cdn_unavailable(pd.read_parquet(_pool_path), "pool")
46
- _pool_df[["wins", "losses", "ties", "votes", "winrate"]] = (0, 0, 0, 0, 0.0)
47
-
48
- WINS_LOC = _pool_df.columns.get_loc("wins")
49
- LOSSES_LOC = _pool_df.columns.get_loc("losses")
50
- TIES_LOC = _pool_df.columns.get_loc("ties")
51
- VOTES_LOC = _pool_df.columns.get_loc("votes")
52
- WINRATE_LOC = _pool_df.columns.get_loc("winrate")
53
-
54
- _md5_to_idx = { md5: idx for idx, md5 in enumerate(_pool_df["md5"]) }
55
-
56
- _pool_lock = threading.Lock()
57
-
58
  _stats_last_loaded_at = 0.0
 
59
  _explorer_df = pd.DataFrame(columns=["group", "id", "md5", "rating", "sample_url", "image_url", "classifier", "classifier_score", "percentile"])
60
 
61
- _disputed_queues: dict[str, list[tuple[int, int, float]]] = {}
62
-
63
- def _ordered_pair(pair: tuple[str, str]) -> tuple[str, str]:
64
- if pair[0] <= pair[1]:
65
- return pair
66
-
67
- return (pair[1], pair[0])
68
-
69
- def _load_stats() -> None:
70
- global _disputed_queues
71
-
72
- VOTE_STORAGE.sync()
73
- load_vote_logs(repo_id=POOL_REPO_ID, token=RATINGS_APP_TOKEN)
74
 
75
- n_missing = 0
76
- with _pool_lock:
77
- VOTE_STORAGE.sync()
78
-
79
- votes_df = load_vote_logs(repo_id=POOL_REPO_ID, token=RATINGS_APP_TOKEN)
80
- for md5, stats in stats_by_md5(votes_df).items():
81
- if (idx := _md5_to_idx.get(md5)) is not None:
82
- _pool_df.iloc[idx, [WINS_LOC, LOSSES_LOC, TIES_LOC, VOTES_LOC, WINRATE_LOC]] = (
83
- stats.wins, stats.losses, stats.ties, stats.votes, stats.winrate
84
- )
85
- else:
86
- n_missing += 1
87
-
88
- pair_votes = Counter(
89
- _ordered_pair(pair)
90
- for pair in zip(votes_df["md5a"], votes_df["md5b"])
91
  )
92
-
93
- if n_missing:
94
- print(f"{n_missing} md5s have stats but are not in the pool!", file=sys.stderr)
95
-
96
- try:
97
- disputed_df = pd.read_parquet(hf_hub_download(
98
  repo_id=POOL_REPO_ID,
99
- filename="disputed_pairs.parquet",
100
  repo_type="dataset",
101
  token=RATINGS_APP_TOKEN,
102
- ))
103
- except EntryNotFoundError:
104
- _disputed_queues.clear()
105
- else:
106
- new_disputed: dict[str, list[tuple[int, int, float]]] = defaultdict(list)
107
-
108
- for md5a, md5b, group, margin in disputed_df.itertuples(index=False):
109
- if md5b < md5a:
110
- md5a, md5b = md5b, md5a
111
-
112
- if (
113
- (idx_a := _md5_to_idx.get(md5a)) is not None
114
- and (idx_b := _md5_to_idx.get(md5b)) is not None
115
- and pair_votes[(md5a, md5b)] == 1
116
- ):
117
- new_disputed[group].append((idx_a, idx_b, margin))
118
-
119
- n_disputed = 0
120
- for disputed in new_disputed.values():
121
- n_disputed += len(disputed)
122
- disputed.sort(key=lambda item: item[2])
123
-
124
- print(f"disputed queue: {n_disputed} pairs across {len(new_disputed)} groups")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
 
126
- _disputed_queues = new_disputed
 
 
 
127
 
128
- classifier_scores_path = hf_hub_download(
129
- repo_id=POOL_REPO_ID,
130
- filename="classifier_scores.parquet",
131
- repo_type="dataset",
132
- token=RATINGS_APP_TOKEN,
133
- )
134
- test_set_path = hf_hub_download(
135
- repo_id=POOL_REPO_ID,
136
- filename="test_set.parquet",
137
- repo_type="dataset",
138
- token=RATINGS_APP_TOKEN,
139
- )
140
- test_df = _drop_cdn_unavailable(
141
- pd.read_parquet(
142
- test_set_path,
143
- columns=["group", "id", "md5", "rating", "sample_url", "image_url", "cdn_available"],
144
- ),
145
- "test set",
146
- ).drop(columns="cdn_available")
147
-
148
- classifier_scores_df = pd.read_parquet(classifier_scores_path)
149
- assert {"classifier", "md5", "classifier_score", "percentile"}.issubset(classifier_scores_df.columns), "classifier_scores.parquet missing expected columns"
150
- classifier_scores_df = classifier_scores_df[["classifier", "md5", "classifier_score", "percentile"]]
151
- classifier_scores_df["classifier"] = classifier_scores_df["classifier"].astype(str)
152
- classifier_scores_df["md5"] = classifier_scores_df["md5"].astype(str)
153
- test_df["md5"] = test_df["md5"].astype(str)
154
-
155
- global _explorer_df
156
- _explorer_df = test_df.merge(classifier_scores_df, on="md5", how="left", validate="one_to_many")
157
-
158
- def _stats_reloader() -> None:
159
- while True:
160
- time.sleep(STATS_RELOAD_S)
161
- _load_stats()
162
-
163
- _load_stats()
164
- threading.Thread(target=_stats_reloader, daemon=True).start()
165
-
166
- def _pick_from(df: pd.DataFrame, *, weights: pd.Series | None = None) -> tuple[pd.Series, pd.Series, int] | None:
167
- if len(df) < 2:
168
- return None
169
-
170
- sample = df.sample(2, weights=weights, replace=False)
171
- return sample.iloc[0], sample.iloc[1], len(df)
172
-
173
- def _pick_similar(
174
- df: pd.DataFrame,
175
- distance: Callable[[pd.DataFrame, pd.Series], pd.Series],
176
- *,
177
- weights: Callable[[pd.DataFrame], pd.Series] | None = None,
178
- other_df: pd.DataFrame | None = None,
179
- ) -> tuple[pd.Series, pd.Series, int] | None:
180
- if len(df) < 2:
181
- return None
182
-
183
- if other_df is None:
184
- other_df = df
185
- elif len(other_df) < 2:
186
- return None
187
-
188
- weight_vals: pd.Series | None = None
189
- if weights is not None:
190
- weight_vals = weights(df)
191
-
192
- first = df.sample(weights=weight_vals).iloc[0]
193
- weight_vals = 1.0 / (1.0 + distance(other_df, first))
194
-
195
- while True:
196
- other = other_df.sample(weights=weight_vals).iloc[0]
197
- if other["md5"] != first["md5"]:
198
- return first, other, len(df)
199
-
200
- def _pool_fetch_pair(group: str) -> tuple[pd.Series, pd.Series, int, str]:
201
- queue = _disputed_queues.get(group)
202
- if queue and random.random() < 0.33:
203
- try:
204
- idx_a, idx_b, _ = queue.pop()
205
- except IndexError:
206
- pass
207
  else:
208
- return _pool_df.iloc[idx_a], _pool_df.iloc[idx_b], len(queue), "disputed"
 
209
 
210
- gdf = _pool_df[_pool_df["group"] == group]
211
- voted = gdf[gdf["votes"] > 0]
212
- votes = voted["votes"]
 
213
 
214
- # Pair first-time winners.
215
- picked = _pick_from(voted[(votes == 1) & (voted["wins"] == 1)])
216
- if picked is not None:
217
- return *picked, "new-winners"
218
-
219
- # Pair first-time losers.
220
- picked = _pick_from(voted[(votes == 1) & (voted["losses"] == 1)])
221
- if picked is not None:
222
- return *picked, "new-losers"
223
-
224
- def record_distance(df: pd.DataFrame, pivot: pd.Series) -> pd.Series:
225
- return (
226
- (df["wins"] - pivot["wins"])**2 +
227
- (df["losses"] - pivot["losses"])**2
228
- )**0.75 # L2 is a bit too loose
229
-
230
- # Link cliques to main network and break ties.
231
- nonties = votes - voted["ties"]
232
- picked = _pick_similar(
233
- voted[(nonties == 0) | (votes == 2)],
234
- record_distance,
235
- other_df=voted[nonties > 3],
236
- )
237
- if picked is not None:
238
- return *picked, "sparse"
239
-
240
- # Introduce new images.
241
- if len(voted) < 8 or random.random() < 0.2:
242
- unvoted = gdf[gdf["votes"] == 0]
243
- match len(unvoted):
244
- case 0:
245
- pass
246
- case 1:
247
- return unvoted.iloc[0], voted.iloc[0], 1, "new"
248
- case _:
249
- picked = _pick_from(unvoted)
250
- assert picked is not None
251
- return *picked, "new"
252
-
253
- # Vote-weighted random sampling between similar winrates, slighlty biased against picking losers.
254
- picked = _pick_similar(
255
- voted, record_distance,
256
- weights=lambda df: 1.0 / (df["votes"]**1.25 + 0.1 * df["losses"]),
257
- )
258
- assert picked is not None
259
- return *picked, "fair-probe"
260
 
261
  def _row_image_url(row) -> str:
262
  sample_url = row.get("sample_url")
@@ -272,56 +148,89 @@ DATASETS: dict[str, dict] = {
272
  "fetch_pair": _pool_fetch_pair,
273
  "get_id": lambda row: row["md5"],
274
  "get_image": _row_image_url,
275
- "groups": sorted(_pool_df["group"].unique()),
276
  },
277
  }
278
  DEFAULT_DATASET = list(DATASETS.keys())[0]
279
 
280
- def _format_rating_post_title(post_id: int, votes: int, label: str) -> str:
281
- return f"<strong>{label}</strong>: <a href=\"https://e621.net/posts/{post_id}\" target=\"_blank\" rel=\"noreferrer\">Post #{post_id}</a> | {votes} {'Vote' if votes == 1 else 'Votes'}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
 
283
  def _render_current(state: dict, submit_status: str = "") -> tuple:
284
- votes_a = _pool_df.iloc[_md5_to_idx[state["key_a"]], VOTES_LOC]
285
- votes_b = _pool_df.iloc[_md5_to_idx[state["key_b"]], VOTES_LOC]
286
- title_a = _format_rating_post_title(state["id_a"], votes_a, "Image A")
287
- title_b = _format_rating_post_title(state["id_b"], votes_b, "Image B")
288
- img_a_html = f"<div class=\"rating-card\"><div class=\"rating-card-title\">{title_a}</div><div class=\"rating-image-frame\"><img src=\"{html.escape(state['url_a'])}\" class=\"rating-image\" loading=\"eager\"></div></div>"
289
- img_b_html = f"<div class=\"rating-card\"><div class=\"rating-card-title\">{title_b}</div><div class=\"rating-image-frame\"><img src=\"{html.escape(state['url_b'])}\" class=\"rating-image\" loading=\"eager\"></div></div>"
 
 
 
 
 
 
 
 
 
 
290
 
291
- can_go_back = bool(state.get("pending", ()))
292
- pair_details = f"/ {state['group']} / {state.get('pair_reason', 'unknown')} ({state.get('n_candidates', '?')})"
293
 
294
- return img_a_html, img_b_html, gr.Button(interactive=can_go_back), html.escape(pair_details), _submit_status(submit_status), state
295
 
296
  def _normalize_rating_pref(pref: str | None) -> str:
297
  return pref if pref in ("safe", "all") else "safe"
298
 
299
- def _initial_load(state: dict, rating_pref: str | None, submit_key: str | None, rater_name: str | None, image_height: str, groups: list[str]):
300
- rating_pref = _normalize_rating_pref(rating_pref)
 
301
  submit_key = _normalize_submit_key(submit_key)
302
- rater_name = _normalize_rater_name(rater_name)
303
- return rating_pref, submit_key, rater_name, image_height, image_height, groups, *new_round(DEFAULT_DATASET, groups, state)
304
 
305
- def _on_groups_change(groups: list[str], state: dict):
306
- return *new_round(DEFAULT_DATASET, groups, state), groups
307
 
308
- def _on_image_height_change(image_height: str) -> tuple[str, str]:
309
- return image_height, image_height
 
310
 
311
- def _normalize_submit_key(submit_key: str | None) -> str:
312
- return (submit_key or "").strip()
313
 
314
- def _normalize_rater_name(rater_name: str | None) -> str:
315
- return (rater_name or "").strip()[:RATER_NAME_MAX_LEN]
316
 
317
- def _submit_status(message: str = "") -> gr.HTML:
318
- # "hidden" takes up no space but keeps the block mounted, so the first message
319
- # to arrive renders immediately instead of on the next update.
320
- return gr.HTML(value=message, visible=True if message else "hidden")
321
 
322
  def _filtered_explorer_df(rating_pref: str) -> pd.DataFrame:
323
  return _filtered_explorer_df_by_classifier(rating_pref, ALLOWED_CLASSIFIER_FILTERS[0])
324
 
 
325
  def _filtered_explorer_df_by_classifier(rating_pref: str, classifier_name: str) -> pd.DataFrame:
326
  if rating_pref == "all":
327
  rating_filtered = _explorer_df
@@ -331,10 +240,12 @@ def _filtered_explorer_df_by_classifier(rating_pref: str, classifier_name: str)
331
  assert classifier_name in ALLOWED_CLASSIFIER_FILTERS, f"Unsupported classifier filter: {classifier_name}"
332
  return rating_filtered[rating_filtered["classifier"] == classifier_name]
333
 
334
- def _load_results(rating_pref: str, sort_mode: str, classifier_filter: str):
335
- rating_pref = _normalize_rating_pref(rating_pref)
336
- sort_mode = _normalize_sort_mode(sort_mode)
337
- classifier_name = _normalize_classifier_filter(classifier_filter)
 
 
338
  filtered_explorer_df = _filtered_explorer_df_by_classifier(rating_pref, classifier_name)
339
  summary, score_distribution_plot, distribution_data, gallery_items, page_meta, next_offset, btn_update = build_results_data(
340
  filtered_explorer_df,
@@ -345,10 +256,12 @@ def _load_results(rating_pref: str, sort_mode: str, classifier_filter: str):
345
  )
346
  return summary, score_distribution_plot, distribution_data, gallery_items, btn_update, "Click an image to reveal its ID and link.", page_meta, next_offset
347
 
 
348
  def _normalize_sort_mode(sort_mode: str | None) -> str:
349
- if sort_mode in SORT_MODES:
350
  return sort_mode
351
- return DISAGREEMENT_SORT_MODE
 
352
 
353
  def _normalize_classifier_filter(classifier_name: str | None) -> str:
354
  if classifier_name in ALLOWED_CLASSIFIER_FILTERS:
@@ -357,36 +270,33 @@ def _normalize_classifier_filter(classifier_name: str | None) -> str:
357
 
358
  # -- Gradio callbacks -------------------------------------------------------
359
 
360
- def new_round(dataset_name: str, groups: list[str], state: dict) -> tuple:
361
- if not groups:
362
- return "", "", gr.skip(), "", _submit_status("Please select at least one group."), state
363
-
364
  cfg = DATASETS[dataset_name]
 
 
365
  group = random.choice(groups)
366
- # n_candidates: how many candidates the sampler branch had to draw from
367
- # (images for the cascade branches, pairs left for the disputed queue).
368
- # Display-only — shown next to pair_reason in the UI, never logged.
369
- row_a, row_b, n_candidates, pair_reason = cfg["fetch_pair"](group)
370
-
 
371
  state.setdefault("session_id", uuid.uuid4().hex)
372
- state["n_candidates"] = n_candidates
373
  key_a = cfg["get_id"](row_a)
374
  key_b = cfg["get_id"](row_b)
375
  id_a = int(row_a["id"])
376
  id_b = int(row_b["id"])
377
- state.update(dataset=dataset_name, key_a=key_a, key_b=key_b, id_a=id_a, id_b=id_b, group=group, pair_reason=pair_reason)
378
  url_a = cfg["get_image"](row_a)
379
  url_b = cfg["get_image"](row_b)
380
  state["url_a"] = url_a
381
  state["url_b"] = url_b
382
  return _render_current(state)
383
 
384
- def _queue_decision(winner: str | None, state: dict, rater_name: str):
385
  assert state.get("session_id"), "Missing session_id: refusing to record vote"
386
-
387
- pending = state.setdefault("pending", [])
388
- pending.append({
389
- "rater_name": rater_name,
390
  "winner": winner,
391
  "key_a": state["key_a"],
392
  "key_b": state["key_b"],
@@ -395,92 +305,53 @@ def _queue_decision(winner: str | None, state: dict, rater_name: str):
395
  "url_a": state["url_a"],
396
  "url_b": state["url_b"],
397
  "dataset": state["dataset"],
 
398
  "group": state["group"],
399
  "pair_reason": state.get("pair_reason", ""),
400
  "session_id": state["session_id"],
401
- })
402
-
403
- if len(pending) > 1:
404
- VOTE_STORAGE.queue_row(pending.pop(0))
405
-
406
- def _add_vote(idx: int, col_loc: int, delta: int = 1) -> None:
407
- _pool_df.iloc[idx, [col_loc, VOTES_LOC]] += delta
408
-
409
- wins, ties, votes = _pool_df.iloc[idx, [WINS_LOC, TIES_LOC, VOTES_LOC]]
410
- _pool_df.iloc[idx, WINRATE_LOC] = (wins + 0.5 * ties) / max(votes, 1)
411
-
412
- def vote(winner: str | None, state: dict, groups: list[str], submit_key: str | None, rater_name: str | None) -> tuple:
413
- submit_key = _normalize_submit_key(submit_key)
414
- if submit_key != SUBMIT_KEY:
415
- headline = "That submit key isn't valid." if submit_key else "A submit key is required to record votes."
416
- return _render_current(state, (
417
- f"<strong>{headline}</strong> Nothing you vote on is being saved. "
418
- "Enter a working key under <em>Settings → Submit Key</em> — ask for one on "
419
- "<a href='https://discord.com/channels/1019133813105905664/1531769333196259499/1531769333196259499' target='_blank' rel='noopener noreferrer'>Discord</a>."
420
- ))
421
-
422
- if not groups:
423
- return "", "", gr.skip(), "", _submit_status("Please select at least one group."), state
424
-
425
- _queue_decision(winner, state, _normalize_rater_name(rater_name))
426
-
427
- a_idx = _md5_to_idx[state["key_a"]]
428
- b_idx = _md5_to_idx[state["key_b"]]
429
- with _pool_lock:
430
- match winner:
431
- case "A":
432
- _add_vote(a_idx, WINS_LOC)
433
- _add_vote(b_idx, LOSSES_LOC)
434
- case "B":
435
- _add_vote(a_idx, LOSSES_LOC)
436
- _add_vote(b_idx, WINS_LOC)
437
- case None:
438
- _add_vote(a_idx, TIES_LOC)
439
- _add_vote(b_idx, TIES_LOC)
440
- case _:
441
- raise AssertionError
442
-
443
- return new_round(state["dataset"], groups, state)
444
 
445
  def go_back(state: dict) -> tuple:
446
  pending = state.setdefault("pending", [])
447
-
448
- if pending:
449
- last = pending.pop()
450
- state.update(
451
- dataset=last["dataset"],
452
- key_a=last["key_a"],
453
- key_b=last["key_b"],
454
- id_a=last["id_a"],
455
- id_b=last["id_b"],
456
- url_a=last["url_a"],
457
- url_b=last["url_b"],
458
- group=last["group"],
459
- pair_reason=last.get("pair_reason", ""),
460
- )
461
-
462
- a_idx = _md5_to_idx[state["key_a"]]
463
- b_idx = _md5_to_idx[state["key_b"]]
464
- with _pool_lock:
465
- match last["winner"]:
466
- case "A":
467
- _add_vote(a_idx, WINS_LOC, -1)
468
- _add_vote(b_idx, LOSSES_LOC, -1)
469
- case "B":
470
- _add_vote(a_idx, LOSSES_LOC, -1)
471
- _add_vote(b_idx, WINS_LOC, -1)
472
- case None:
473
- _add_vote(a_idx, TIES_LOC, -1)
474
- _add_vote(b_idx, TIES_LOC, -1)
475
- case _:
476
- raise AssertionError
477
-
478
  return _render_current(state)
479
 
480
  # -- UI ---------------------------------------------------------------------
481
 
482
  with gr.Blocks(
483
- title="e621 Visual Ratings",
484
  head="""
485
  <script>
486
  const VOTE_COOLDOWN_MS = 1500;
@@ -563,15 +434,9 @@ with gr.Blocks(
563
  } else if ((e.key === 'z' || e.key === 'Z') && (e.ctrlKey || e.metaKey) && ratingTabActive) {
564
  e.preventDefault();
565
  backButton?.click();
566
- } else if (e.key === 'ArrowDown') {
567
- if (ratingTabActive) {
568
- e.preventDefault();
569
- backButton?.click();
570
- }
571
- if (resultsTabActive) {
572
- e.preventDefault();
573
- resultsLoadMoreButton?.click();
574
- }
575
  }
576
  });
577
  document.addEventListener('click', function (e) {
@@ -631,6 +496,7 @@ with gr.Blocks(
631
  }
632
  .rating-image-frame {
633
  width: 100%;
 
634
  border: 1px solid #e6e6e6;
635
  border-radius: 8px;
636
  background: #333;
@@ -663,18 +529,21 @@ with gr.Blocks(
663
  .hidden-action-btn {
664
  display: none !important;
665
  }
 
 
 
 
 
 
 
 
 
666
  .submit-status-msg {
667
- border: 1px solid #f0b429;
668
- border-left-width: 4px;
669
- background: rgba(240, 180, 41, 0.12);
670
  border-radius: 8px;
671
- padding: 10px 14px;
672
- font-size: 0.95rem;
673
- line-height: 1.45;
674
- }
675
- .submit-status-msg a {
676
- text-decoration: underline;
677
- font-weight: 600;
678
  }
679
  #results-gallery {
680
  --explorer-thumb-ratio: 1 / 1;
@@ -689,77 +558,54 @@ with gr.Blocks(
689
  object-fit: contain !important;
690
  background: #1f2937;
691
  }
692
- a {
693
- padding: 0 !important;
694
- }
695
  """,
696
- fill_width=True,
697
  ) as demo:
698
  state = gr.State({})
699
  rating_pref_store = gr.BrowserState(default_value="safe", storage_key="rating_pref")
700
  submit_key_store = gr.BrowserState(default_value="", storage_key="submit_key")
701
- rater_name_store = gr.BrowserState(default_value="", storage_key="rater_name")
702
- results_sort_store = gr.BrowserState(default_value=DISAGREEMENT_SORT_MODE, storage_key="results_sort_mode")
703
  results_classifier_store = gr.BrowserState(default_value=ALLOWED_CLASSIFIER_FILTERS[0], storage_key="results_classifier")
704
- image_height_store = gr.BrowserState(default_value=768, storage_key="image_height")
705
- groups_store = gr.BrowserState(default_value=[
706
- group
707
- for group in DATASETS[DEFAULT_DATASET]["groups"]
708
- if group.endswith("_safe")
709
- ], storage_key="groups")
710
 
711
  with gr.Tabs():
712
  with gr.Tab("Image Quality Rater"):
713
  gr.Markdown("Rate relative image quality. Choose the image with better quality, or select same quality if they are comparable. Both images are drawn from the same group to avoid cross-group bias.")
714
- submit_status = gr.HTML(html_template="<div class='submit-status-msg'>${value}</div>", elem_id="submit-status", visible="hidden")
715
 
716
  with gr.Row():
717
  img_a = gr.HTML(elem_id="img-a")
718
  img_b = gr.HTML(elem_id="img-b")
719
 
720
- with gr.Row(equal_height=True):
721
- btn_a = gr.Button("⬅️ Prefer A", variant="primary", elem_id="btn-vote-a")
722
-
723
- with gr.Column(scale=0), gr.Group():
724
- btn_skip = gr.Button("⬆️ Same Quality", elem_id="btn-skip")
725
- btn_back_action = gr.Button("⬇️ Undo", elem_id="btn-back-action")
726
-
727
- btn_b = gr.Button("➡️ Prefer B", variant="primary", elem_id="btn-vote-b")
728
 
729
  with gr.Accordion("Settings", open=False):
730
- groups_select = gr.CheckboxGroup(
731
- choices=DATASETS[DEFAULT_DATASET]["groups"],
732
- label="Categories",
733
- show_label=True,
734
- show_select_all=True
735
- )
736
- image_height_slider = gr.Slider(
737
- minimum=512, maximum=2048, step=16, precision=0,
738
- label="Image Size",
739
  )
740
  submit_key_tb = gr.Textbox(
741
  value="",
742
  type="password",
743
- label="Submit Key",
744
  elem_id="submit-key",
745
  )
746
- rater_name_tb = gr.Textbox(
747
- value="",
748
- label="Rater Name",
749
- elem_id="rater-name",
750
- max_length=RATER_NAME_MAX_LEN,
751
- info=(
752
- "Entirely optional. Leave this blank to rate anonymously"
753
- ),
754
- )
755
 
756
- pair_details = gr.HTML(html_template="Dataset: <a href='https://huggingface.co/datasets/taigasan/e6-visual-ratings' target='_blank' rel='noopener noreferrer'>taigasan/e6-visual-ratings</a> ${value}")
757
- gr.HTML("<span class='subtle-note'>Keyboard Shortcuts: ⬅️ Vote A, ⬆️ Same Quality, ➡️ Vote B, ⬇️ or Ctrl+Z Undo</span>")
758
- image_height = gr.HTML(html_template="<style>.rating-image-frame { height:${value}px; }</style>", apply_default_css=False)
 
 
 
 
 
 
759
 
760
  (
761
  results_summary_md,
762
- results_rating_dd,
763
  results_sort_dd,
764
  results_classifier_dd,
765
  results_score_distribution_plot,
@@ -771,7 +617,7 @@ with gr.Blocks(
771
  results_page_offset_state,
772
  ) = add_results_tab(_pool_df)
773
 
774
- outputs = [img_a, img_b, btn_back_action, pair_details, submit_status, state]
775
  results_outputs = [
776
  results_summary_md,
777
  results_score_distribution_plot,
@@ -783,29 +629,22 @@ with gr.Blocks(
783
  results_page_offset_state,
784
  ]
785
 
786
- vote_inputs = [state, groups_store, submit_key_store, rater_name_store]
787
- btn_a.click(fn=lambda s, g, k, n: vote("A", s, g, k, n), inputs=vote_inputs, outputs=outputs, queue=False, show_progress="hidden")
788
- btn_b.click(fn=lambda s, g, k, n: vote("B", s, g, k, n), inputs=vote_inputs, outputs=outputs, queue=False, show_progress="hidden")
789
- btn_skip.click(fn=lambda s, g, k, n: vote(None, s, g, k, n), inputs=vote_inputs, outputs=outputs, queue=False, show_progress="hidden")
790
  btn_back_action.click(fn=go_back, inputs=[state], outputs=outputs, queue=False, show_progress="hidden")
791
- # Clear the warning once the key is right; leave it alone mid-typing.
792
- submit_key_tb.change(fn=lambda k: (_normalize_submit_key(k), _submit_status() if _normalize_submit_key(k) == SUBMIT_KEY else gr.skip()), inputs=[submit_key_tb], outputs=[submit_key_store, submit_status], queue=False, show_progress="hidden")
793
- rater_name_tb.change(fn=_normalize_rater_name, inputs=[rater_name_tb], outputs=[rater_name_store], queue=False, show_progress="hidden")
794
- groups_select.change(fn=_on_groups_change, inputs=[groups_select, state], outputs=[*outputs, groups_store], queue=False, show_progress="hidden")
795
- image_height_slider.change(fn=_on_image_height_change, inputs=[image_height_slider], outputs=[image_height_store, image_height], queue=False, show_progress="hidden")
796
-
797
- results_rating_dd.change(fn=_normalize_rating_pref, inputs=[results_rating_dd], outputs=[rating_pref_store], queue=False, show_progress="hidden")
798
- results_rating_dd.change(fn=_load_results, inputs=[results_rating_dd, results_sort_store, results_classifier_store], outputs=results_outputs, queue=False, show_progress="hidden")
799
  results_sort_dd.change(fn=_normalize_sort_mode, inputs=[results_sort_dd], outputs=[results_sort_store], queue=False, show_progress="hidden")
800
  results_sort_dd.change(fn=_load_results, inputs=[rating_pref_store, results_sort_dd, results_classifier_store], outputs=results_outputs, queue=False, show_progress="hidden")
801
  results_classifier_dd.change(fn=_normalize_classifier_filter, inputs=[results_classifier_dd], outputs=[results_classifier_store], queue=False, show_progress="hidden")
802
  results_classifier_dd.change(fn=_load_results, inputs=[rating_pref_store, results_sort_store, results_classifier_dd], outputs=results_outputs, queue=False, show_progress="hidden")
803
-
804
- demo.load(fn=_initial_load, inputs=[state, rating_pref_store, submit_key_store, rater_name_store, image_height_store, groups_store], outputs=[results_rating_dd, submit_key_tb, rater_name_tb, image_height_slider, image_height, groups_select, *outputs], queue=False, show_progress="hidden")
805
  demo.load(fn=_load_results, inputs=[rating_pref_store, results_sort_store, results_classifier_store], outputs=results_outputs, queue=False, show_progress="hidden")
806
  demo.load(fn=_normalize_sort_mode, inputs=[results_sort_store], outputs=[results_sort_dd], queue=False, show_progress="hidden")
807
  demo.load(fn=_normalize_classifier_filter, inputs=[results_classifier_store], outputs=[results_classifier_dd], queue=False, show_progress="hidden")
808
-
809
  results_load_more_btn.click(
810
  fn=lambda r, s, c, o: load_more_results(_filtered_explorer_df_by_classifier(_normalize_rating_pref(r), _normalize_classifier_filter(c)), _explorer_df, s, o),
811
  inputs=[rating_pref_store, results_sort_store, results_classifier_store, results_page_offset_state],
 
5
  import uuid
6
  import os
7
  import html
 
 
 
8
 
9
  import pandas as pd
10
  from huggingface_hub import hf_hub_download
 
11
 
12
  from storage import VoteStorage
13
+ from stats_from_logs import load_stats_by_md5
14
+ from explorer import ALLOWED_CLASSIFIER_FILTERS, add_results_tab, build_results_data, load_more_results, on_gallery_select
 
 
15
 
16
  DEBUG_MODE = os.getenv("DEBUG", "0").lower() in ("1", "true", "yes", "on")
17
  RATINGS_APP_TOKEN = os.getenv("RATINGS_APP_TOKEN")
18
  SUBMIT_KEY = os.getenv("RATINGS_SUBMIT_KEY")
19
  assert SUBMIT_KEY, "Missing required env var: RATINGS_SUBMIT_KEY"
20
  POOL_REPO_ID = "taigasan/e6-visual-ratings"
 
21
  VOTE_STORAGE = VoteStorage(mode="void" if DEBUG_MODE else "hf", token=RATINGS_APP_TOKEN)
22
  STATS_RELOAD_S = 30 * 60
23
 
 
 
 
 
 
 
 
24
  # -- Pool dataset -----------------------------------------------------------
25
  _pool_path = hf_hub_download(
26
  repo_id=POOL_REPO_ID,
 
28
  repo_type="dataset",
29
  token=RATINGS_APP_TOKEN
30
  )
31
+ _pool_df = pd.read_parquet(_pool_path)
32
+ _pool_group_dfs = {g: gdf for g, gdf in _pool_df.groupby("group")}
33
+ _stats_lock = threading.Lock()
 
 
 
 
 
 
 
 
 
 
34
  _stats_last_loaded_at = 0.0
35
+ _stats_by_key: dict[str, tuple[int, int]] = {}
36
  _explorer_df = pd.DataFrame(columns=["group", "id", "md5", "rating", "sample_url", "image_url", "classifier", "classifier_score", "percentile"])
37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
 
39
+ def _reload_stats_if_due(force: bool = False):
40
+ global _stats_last_loaded_at, _stats_by_key, _explorer_df
41
+ now = time.time()
42
+ if not force and (now - _stats_last_loaded_at) < STATS_RELOAD_S:
43
+ return
44
+ with _stats_lock:
45
+ now = time.time()
46
+ if not force and (now - _stats_last_loaded_at) < STATS_RELOAD_S:
47
+ return
48
+ _stats_by_key = load_stats_by_md5(
49
+ repo_id=POOL_REPO_ID,
50
+ token=RATINGS_APP_TOKEN,
 
 
 
 
51
  )
52
+ classifier_scores_path = hf_hub_download(
 
 
 
 
 
53
  repo_id=POOL_REPO_ID,
54
+ filename="classifier_scores.parquet",
55
  repo_type="dataset",
56
  token=RATINGS_APP_TOKEN,
57
+ )
58
+ validation_set_path = hf_hub_download(
59
+ repo_id=POOL_REPO_ID,
60
+ filename="validation_set.parquet",
61
+ repo_type="dataset",
62
+ token=RATINGS_APP_TOKEN,
63
+ )
64
+ validation_df = pd.read_parquet(
65
+ validation_set_path,
66
+ columns=["group", "id", "md5", "rating", "sample_url", "image_url"],
67
+ )
68
+ classifier_scores_df = pd.read_parquet(classifier_scores_path)
69
+ assert {"classifier", "md5", "classifier_score", "percentile"}.issubset(classifier_scores_df.columns), "classifier_scores.parquet missing expected columns"
70
+ classifier_scores_df = classifier_scores_df[["classifier", "md5", "classifier_score", "percentile"]]
71
+ classifier_scores_df["classifier"] = classifier_scores_df["classifier"].astype(str)
72
+ classifier_scores_df["md5"] = classifier_scores_df["md5"].astype(str)
73
+ validation_df["md5"] = validation_df["md5"].astype(str)
74
+ _explorer_df = validation_df.merge(classifier_scores_df, on="md5", how="left", validate="one_to_many")
75
+ _stats_last_loaded_at = now
76
+
77
+
78
+ _reload_stats_if_due(force=True)
79
+
80
+ def _pool_fetch_pair(group_name: str) -> tuple:
81
+ gdf = _pool_group_dfs[group_name]
82
+ assert len(gdf) >= 2, f"Not enough rows for group: {group_name}"
83
+ md5_keys = gdf["md5"].astype(str)
84
+ wins = md5_keys.map(lambda k: _stats_by_key.get(k, (0, 0))[0])
85
+ losses = md5_keys.map(lambda k: _stats_by_key.get(k, (0, 0))[1])
86
+
87
+ def _pick_from_mask(mask: pd.Series):
88
+ candidate_df = gdf[mask]
89
+ if len(candidate_df) < 2:
90
+ return None
91
+ sample = candidate_df.sample(2, replace=False)
92
+ return sample.iloc[0], sample.iloc[1]
93
+
94
+
95
+ # 1) Repeat the lowest-margin edge participating in a cycle. (To prevent deadlock, stop if all margins are 4+.)
96
+ # a) If deadlocked on a cycle with 4+ images and no inner cycles, sample a random missing edge inside the cycle.
97
+ # 2) Pair images that both have wins only . (One of them will lose/tie. Stop when there is only one left.)
98
+ # 3) Pair images that both have losses only. (One of them will win/tie. Stop when there is only one left.)
99
+ # 4) Pair images with only 2 edges.
100
+ # 5) X% chance, re-sample an existing edge, inversely proportional to existing number of samples.
101
+ # 6) Y% chance, sample a random missing edge between images already sampled.
102
+ # 7) Pair an unsampled image with a random sampled image.
103
+
104
+ # 2) Pair images that currently have wins-only records.
105
+ picked = _pick_from_mask((wins > 0) & (losses == 0))
106
+ if picked is not None:
107
+ return picked[0], picked[1], "wins-only"
108
 
109
+ # 3) Pair images that currently have losses-only records.
110
+ picked = _pick_from_mask((wins == 0) & (losses > 0))
111
+ if picked is not None:
112
+ return picked[0], picked[1], "losses-only"
113
 
114
+ # 4) Pair images that currently have exactly 2 total edges.
115
+ vote_totals = wins + losses
116
+ picked = _pick_from_mask(vote_totals == 2)
117
+ if picked is not None:
118
+ return picked[0], picked[1], "total_votes=2"
119
+
120
+ # 7) Prefer pairing an unsampled image with a random previously sampled image.
121
+ unsampled_mask = vote_totals == 0
122
+ if unsampled_mask.any():
123
+ unsampled_row = gdf[unsampled_mask].sample(1).iloc[0]
124
+ sampled_df = gdf[~unsampled_mask]
125
+ if len(sampled_df) >= 1:
126
+ sampled_row = sampled_df.sample(1).iloc[0]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  else:
128
+ sampled_row = gdf.drop(index=unsampled_row.name).sample(1).iloc[0]
129
+ return unsampled_row, sampled_row, "unsampled+sampled"
130
 
131
+ # 8) Safety fall back to low-vote weighted sampling.
132
+ sample_weights = 1.0 / (vote_totals + 1.0)
133
+ sample = gdf.sample(2, weights=sample_weights, replace=False)
134
+ return sample.iloc[0], sample.iloc[1], "low-vote"
135
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
 
137
  def _row_image_url(row) -> str:
138
  sample_url = row.get("sample_url")
 
148
  "fetch_pair": _pool_fetch_pair,
149
  "get_id": lambda row: row["md5"],
150
  "get_image": _row_image_url,
151
+ "groups": {g: g for g in sorted(_pool_df["group"].unique())},
152
  },
153
  }
154
  DEFAULT_DATASET = list(DATASETS.keys())[0]
155
 
156
+ def _select_groups(cfg: dict, rating_pref: str) -> list[str]:
157
+ groups = list(cfg["groups"].keys())
158
+ if rating_pref == "all":
159
+ return groups
160
+ return [g for g in groups if g.endswith(f"_{rating_pref}")]
161
+
162
+ def _commit_oldest_pending(state: dict):
163
+ pending = state.setdefault("pending", [])
164
+ if len(pending) <= 1:
165
+ return
166
+ oldest = pending.pop(0)
167
+ if oldest.get("winner") in ("A", "B"):
168
+ _apply_local_stats_update(oldest["winner"], oldest["key_a"], oldest["key_b"])
169
+ threading.Thread(target=VOTE_STORAGE.append_vote_row, args=(oldest.copy(), oldest.get("winner")), daemon=True).start()
170
+
171
+
172
+ def _apply_local_stats_update(winner: str, key_a: str, key_b: str):
173
+ assert winner in ("A", "B")
174
+ with _stats_lock:
175
+ wins_a, losses_a = _stats_by_key.get(str(key_a), (0, 0))
176
+ wins_b, losses_b = _stats_by_key.get(str(key_b), (0, 0))
177
+ if winner == "A":
178
+ _stats_by_key[str(key_a)] = (wins_a + 1, losses_a)
179
+ _stats_by_key[str(key_b)] = (wins_b, losses_b + 1)
180
+ else:
181
+ _stats_by_key[str(key_a)] = (wins_a, losses_a + 1)
182
+ _stats_by_key[str(key_b)] = (wins_b + 1, losses_b)
183
+
184
+
185
+ def _format_rating_post_row(post_id: int, wins: int, losses: int, label: str | None = None) -> str:
186
+ total_votes = wins + losses
187
+ url = f"https://e621.net/posts/{post_id}"
188
+ row = f"{url} | Times rated: {total_votes}"
189
+ return f"{label}: {row}" if label else row
190
 
191
  def _render_current(state: dict, submit_status: str = "") -> tuple:
192
+ _reload_stats_if_due()
193
+ wins_a, losses_a = _stats_by_key.get(str(state["key_a"]), (0, 0))
194
+ wins_b, losses_b = _stats_by_key.get(str(state["key_b"]), (0, 0))
195
+ title_a = "Image A"
196
+ title_b = "Image B"
197
+ img_a_html = f"<div class=\"rating-card\"><div class=\"rating-card-title\"><strong>{html.escape(title_a)}</strong></div><div class=\"rating-image-frame\"><img src=\"{html.escape(state['url_a'])}\" class=\"rating-image\" loading=\"eager\" referrerpolicy=\"no-referrer\"></div></div>"
198
+ img_b_html = f"<div class=\"rating-card\"><div class=\"rating-card-title\"><strong>{html.escape(title_b)}</strong></div><div class=\"rating-image-frame\"><img src=\"{html.escape(state['url_b'])}\" class=\"rating-image\" loading=\"eager\" referrerpolicy=\"no-referrer\"></div></div>"
199
+ link_a = _format_rating_post_row(state["id_a"], wins_a, losses_a, label="Image A")
200
+ link_b = _format_rating_post_row(state["id_b"], wins_b, losses_b, label="Image B")
201
+ can_go_back = bool(state.get("can_go_back"))
202
+ back_md = "[Undo Rating (Ctrl+z)](#back)" if can_go_back else "<span class='subtle-back-link-disabled'>Undo Rating (Ctrl+z)</span>"
203
+ group_md = f"<span class='subtle-note'>Group: {state['group']}</span>"
204
+ pair_reason = state.get("pair_reason", "")
205
+ pair_reason_md = f"<span class='subtle-note'>Pair: {html.escape(pair_reason)}</span>" if pair_reason else ""
206
+ status_md = f"<span class='submit-status-msg'>{html.escape(submit_status)}</span>" if submit_status else ""
207
+ return img_a_html, img_b_html, link_a, link_b, back_md, group_md, pair_reason_md, status_md, state
208
 
 
 
209
 
 
210
 
211
  def _normalize_rating_pref(pref: str | None) -> str:
212
  return pref if pref in ("safe", "all") else "safe"
213
 
214
+
215
+ def _initial_load(state: dict, pref: str | None, submit_key: str | None):
216
+ rating_pref = _normalize_rating_pref(pref)
217
  submit_key = _normalize_submit_key(submit_key)
218
+ return rating_pref, submit_key, *new_round(DEFAULT_DATASET, rating_pref, state)
 
219
 
 
 
220
 
221
+ def _on_rating_change(rating_pref: str, state: dict):
222
+ rating_pref = _normalize_rating_pref(rating_pref)
223
+ return *new_round(DEFAULT_DATASET, rating_pref, state), rating_pref
224
 
 
 
225
 
226
+ def _normalize_submit_key(submit_key: str | None) -> str:
227
+ return submit_key or ""
228
 
 
 
 
 
229
 
230
  def _filtered_explorer_df(rating_pref: str) -> pd.DataFrame:
231
  return _filtered_explorer_df_by_classifier(rating_pref, ALLOWED_CLASSIFIER_FILTERS[0])
232
 
233
+
234
  def _filtered_explorer_df_by_classifier(rating_pref: str, classifier_name: str) -> pd.DataFrame:
235
  if rating_pref == "all":
236
  rating_filtered = _explorer_df
 
240
  assert classifier_name in ALLOWED_CLASSIFIER_FILTERS, f"Unsupported classifier filter: {classifier_name}"
241
  return rating_filtered[rating_filtered["classifier"] == classifier_name]
242
 
243
+
244
+ def _load_results(rating_pref_value: str, sort_mode_value: str, classifier_filter_value: str):
245
+ rating_pref = _normalize_rating_pref(rating_pref_value)
246
+ sort_mode = _normalize_sort_mode(sort_mode_value)
247
+ classifier_name = _normalize_classifier_filter(classifier_filter_value)
248
+ _reload_stats_if_due()
249
  filtered_explorer_df = _filtered_explorer_df_by_classifier(rating_pref, classifier_name)
250
  summary, score_distribution_plot, distribution_data, gallery_items, page_meta, next_offset, btn_update = build_results_data(
251
  filtered_explorer_df,
 
256
  )
257
  return summary, score_distribution_plot, distribution_data, gallery_items, btn_update, "Click an image to reveal its ID and link.", page_meta, next_offset
258
 
259
+
260
  def _normalize_sort_mode(sort_mode: str | None) -> str:
261
+ if sort_mode in ("Default", "Rating: Low to High", "Rating: High to Low"):
262
  return sort_mode
263
+ return "Default"
264
+
265
 
266
  def _normalize_classifier_filter(classifier_name: str | None) -> str:
267
  if classifier_name in ALLOWED_CLASSIFIER_FILTERS:
 
270
 
271
  # -- Gradio callbacks -------------------------------------------------------
272
 
273
+ def new_round(dataset_name: str, rating_pref: str, state: dict) -> tuple:
 
 
 
274
  cfg = DATASETS[dataset_name]
275
+ groups = _select_groups(cfg, rating_pref)
276
+ assert groups, f"No groups for rating preference: {rating_pref}"
277
  group = random.choice(groups)
278
+ pair_data = cfg["fetch_pair"](cfg["groups"][group])
279
+ if len(pair_data) == 3:
280
+ row_a, row_b, pair_reason = pair_data
281
+ else:
282
+ row_a, row_b = pair_data
283
+ pair_reason = ""
284
  state.setdefault("session_id", uuid.uuid4().hex)
 
285
  key_a = cfg["get_id"](row_a)
286
  key_b = cfg["get_id"](row_b)
287
  id_a = int(row_a["id"])
288
  id_b = int(row_b["id"])
289
+ state.update(dataset=dataset_name, rating_pref=rating_pref, key_a=key_a, key_b=key_b, id_a=id_a, id_b=id_b, group=group, pair_reason=pair_reason)
290
  url_a = cfg["get_image"](row_a)
291
  url_b = cfg["get_image"](row_b)
292
  state["url_a"] = url_a
293
  state["url_b"] = url_b
294
  return _render_current(state)
295
 
296
+ def _queue_decision(winner: str | None, state: dict):
297
  assert state.get("session_id"), "Missing session_id: refusing to record vote"
298
+ state.setdefault("pending", [])
299
+ decision = {
 
 
300
  "winner": winner,
301
  "key_a": state["key_a"],
302
  "key_b": state["key_b"],
 
305
  "url_a": state["url_a"],
306
  "url_b": state["url_b"],
307
  "dataset": state["dataset"],
308
+ "rating_pref": state["rating_pref"],
309
  "group": state["group"],
310
  "pair_reason": state.get("pair_reason", ""),
311
  "session_id": state["session_id"],
312
+ }
313
+ state["pending"].append(decision)
314
+ state["last_decision"] = decision
315
+ state["can_go_back"] = True
316
+ _commit_oldest_pending(state)
317
+
318
+ def vote(winner: str | None, state: dict, submit_key: str | None) -> tuple:
319
+ assert winner in ("A", "B", None)
320
+ if _normalize_submit_key(submit_key) != SUBMIT_KEY:
321
+ return _render_current(state, "Wrong submission key.")
322
+ _queue_decision(winner, state)
323
+ return new_round(state["dataset"], state["rating_pref"], state)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
324
 
325
  def go_back(state: dict) -> tuple:
326
  pending = state.setdefault("pending", [])
327
+ if not state.get("can_go_back"):
328
+ return _render_current(state)
329
+ last = state.get("last_decision")
330
+ if not last:
331
+ state["can_go_back"] = False
332
+ return _render_current(state)
333
+ if pending and pending[-1] == last:
334
+ pending.pop()
335
+ state["can_go_back"] = False
336
+ state["last_decision"] = None
337
+ state.update(
338
+ dataset=last["dataset"],
339
+ rating_pref=last["rating_pref"],
340
+ key_a=last["key_a"],
341
+ key_b=last["key_b"],
342
+ id_a=last["id_a"],
343
+ id_b=last["id_b"],
344
+ url_a=last["url_a"],
345
+ url_b=last["url_b"],
346
+ group=last["group"],
347
+ pair_reason=last.get("pair_reason", ""),
348
+ )
 
 
 
 
 
 
 
 
 
349
  return _render_current(state)
350
 
351
  # -- UI ---------------------------------------------------------------------
352
 
353
  with gr.Blocks(
354
+ title="Image Rater",
355
  head="""
356
  <script>
357
  const VOTE_COOLDOWN_MS = 1500;
 
434
  } else if ((e.key === 'z' || e.key === 'Z') && (e.ctrlKey || e.metaKey) && ratingTabActive) {
435
  e.preventDefault();
436
  backButton?.click();
437
+ } else if (e.key === 'ArrowDown' && resultsTabActive) {
438
+ e.preventDefault();
439
+ resultsLoadMoreButton?.click();
 
 
 
 
 
 
440
  }
441
  });
442
  document.addEventListener('click', function (e) {
 
496
  }
497
  .rating-image-frame {
498
  width: 100%;
499
+ height: 512px;
500
  border: 1px solid #e6e6e6;
501
  border-radius: 8px;
502
  background: #333;
 
529
  .hidden-action-btn {
530
  display: none !important;
531
  }
532
+ #submit-status {
533
+ position: fixed;
534
+ left: 50%;
535
+ bottom: 20px;
536
+ transform: translateX(-50%);
537
+ z-index: 9998;
538
+ pointer-events: none;
539
+ min-height: 1.2em;
540
+ }
541
  .submit-status-msg {
542
+ background: rgba(20, 20, 20, 0.92);
543
+ color: #fff;
544
+ padding: 8px 12px;
545
  border-radius: 8px;
546
+ font-size: 0.92rem;
 
 
 
 
 
 
547
  }
548
  #results-gallery {
549
  --explorer-thumb-ratio: 1 / 1;
 
558
  object-fit: contain !important;
559
  background: #1f2937;
560
  }
 
 
 
561
  """,
 
562
  ) as demo:
563
  state = gr.State({})
564
  rating_pref_store = gr.BrowserState(default_value="safe", storage_key="rating_pref")
565
  submit_key_store = gr.BrowserState(default_value="", storage_key="submit_key")
566
+ results_sort_store = gr.BrowserState(default_value="Default", storage_key="results_sort_mode")
 
567
  results_classifier_store = gr.BrowserState(default_value=ALLOWED_CLASSIFIER_FILTERS[0], storage_key="results_classifier")
 
 
 
 
 
 
568
 
569
  with gr.Tabs():
570
  with gr.Tab("Image Quality Rater"):
571
  gr.Markdown("Rate relative image quality. Choose the image with better quality, or select same quality if they are comparable. Both images are drawn from the same group to avoid cross-group bias.")
 
572
 
573
  with gr.Row():
574
  img_a = gr.HTML(elem_id="img-a")
575
  img_b = gr.HTML(elem_id="img-b")
576
 
577
+ with gr.Row():
578
+ btn_a = gr.Button("👍 Prefer A", variant="primary", elem_id="btn-vote-a")
579
+ btn_skip = gr.Button("Same quality", elem_id="btn-skip")
580
+ btn_b = gr.Button("👍 Prefer B", variant="primary", elem_id="btn-vote-b")
 
 
 
 
581
 
582
  with gr.Accordion("Settings", open=False):
583
+ gr.Markdown("<span style='color:#888;font-size:0.9em;'>Advanced options</span>")
584
+ rating_dd = gr.Dropdown(
585
+ choices=["safe", "all"],
586
+ value="safe",
587
+ label="Rating",
588
+ elem_id="rating-pref",
 
 
 
589
  )
590
  submit_key_tb = gr.Textbox(
591
  value="",
592
  type="password",
593
+ label="Submit key",
594
  elem_id="submit-key",
595
  )
 
 
 
 
 
 
 
 
 
596
 
597
+ link_a = gr.Markdown(label="Image A link")
598
+ link_b = gr.Markdown(label="Image B link")
599
+ back_link = gr.Markdown(elem_classes=["subtle-back-link-wrap"])
600
+ btn_back_action = gr.Button("Undo Rating (Ctrl+z)", elem_id="btn-back-action", elem_classes=["hidden-action-btn"])
601
+ details_md = gr.Markdown()
602
+ pair_reason_md = gr.Markdown()
603
+ submit_status_md = gr.Markdown(elem_id="submit-status")
604
+ gr.Markdown("<span class='subtle-note'>Dataset: <a href='https://huggingface.co/datasets/taigasan/e6-visual-ratings' target='_blank' rel='noopener noreferrer'>taigasan/e6-visual-ratings</a></span>")
605
+ gr.Markdown("<span class='subtle-note'>Keyboard Shortcuts: ⬅️ vote A, ⬆️ same quality, ➡️ vote B, Ctrl+z undo rating</span>")
606
 
607
  (
608
  results_summary_md,
 
609
  results_sort_dd,
610
  results_classifier_dd,
611
  results_score_distribution_plot,
 
617
  results_page_offset_state,
618
  ) = add_results_tab(_pool_df)
619
 
620
+ outputs = [img_a, img_b, link_a, link_b, back_link, details_md, pair_reason_md, submit_status_md, state]
621
  results_outputs = [
622
  results_summary_md,
623
  results_score_distribution_plot,
 
629
  results_page_offset_state,
630
  ]
631
 
632
+ btn_a.click(fn=lambda s, k: vote("A", s, k), inputs=[state, submit_key_store], outputs=outputs, queue=False, show_progress="hidden")
633
+ btn_b.click(fn=lambda s, k: vote("B", s, k), inputs=[state, submit_key_store], outputs=outputs, queue=False, show_progress="hidden")
634
+ btn_skip.click(fn=lambda s, k: vote(None, s, k), inputs=[state, submit_key_store], outputs=outputs, queue=False, show_progress="hidden")
 
635
  btn_back_action.click(fn=go_back, inputs=[state], outputs=outputs, queue=False, show_progress="hidden")
636
+ rating_dd.change(fn=_on_rating_change, inputs=[rating_dd, state], outputs=[*outputs, rating_pref_store], queue=False, show_progress="hidden")
637
+ submit_key_tb.input(fn=_normalize_submit_key, inputs=[submit_key_tb], outputs=[submit_key_store], queue=False, show_progress="hidden")
638
+ submit_key_tb.change(fn=_normalize_submit_key, inputs=[submit_key_tb], outputs=[submit_key_store], queue=False, show_progress="hidden")
639
+ rating_dd.change(fn=_load_results, inputs=[rating_dd, results_sort_store, results_classifier_store], outputs=results_outputs, queue=False, show_progress="hidden")
 
 
 
 
640
  results_sort_dd.change(fn=_normalize_sort_mode, inputs=[results_sort_dd], outputs=[results_sort_store], queue=False, show_progress="hidden")
641
  results_sort_dd.change(fn=_load_results, inputs=[rating_pref_store, results_sort_dd, results_classifier_store], outputs=results_outputs, queue=False, show_progress="hidden")
642
  results_classifier_dd.change(fn=_normalize_classifier_filter, inputs=[results_classifier_dd], outputs=[results_classifier_store], queue=False, show_progress="hidden")
643
  results_classifier_dd.change(fn=_load_results, inputs=[rating_pref_store, results_sort_store, results_classifier_dd], outputs=results_outputs, queue=False, show_progress="hidden")
644
+ demo.load(fn=_initial_load, inputs=[state, rating_pref_store, submit_key_store], outputs=[rating_dd, submit_key_tb, *outputs], queue=False, show_progress="hidden")
 
645
  demo.load(fn=_load_results, inputs=[rating_pref_store, results_sort_store, results_classifier_store], outputs=results_outputs, queue=False, show_progress="hidden")
646
  demo.load(fn=_normalize_sort_mode, inputs=[results_sort_store], outputs=[results_sort_dd], queue=False, show_progress="hidden")
647
  demo.load(fn=_normalize_classifier_filter, inputs=[results_classifier_store], outputs=[results_classifier_dd], queue=False, show_progress="hidden")
 
648
  results_load_more_btn.click(
649
  fn=lambda r, s, c, o: load_more_results(_filtered_explorer_df_by_classifier(_normalize_rating_pref(r), _normalize_classifier_filter(c)), _explorer_df, s, o),
650
  inputs=[rating_pref_store, results_sort_store, results_classifier_store, results_page_offset_state],
maintenance/compact_logs.py → compact_logs.py RENAMED
@@ -1,20 +1,21 @@
1
- """Merge the append-only vote shards into one. Run: python -m maintenance.compact_logs"""
2
  from __future__ import annotations
3
 
4
  import os
5
  import time
6
  import uuid
 
 
7
 
8
  import pandas as pd
9
  from huggingface_hub import CommitOperationAdd, CommitOperationDelete, HfApi, hf_hub_download
10
 
11
- from storage import VOTE_COLUMNS
12
-
13
- VOTES_REPO_ID = "RedRocket/e6-visual-ratings"
14
  VOTES_REPO_TYPE = "dataset"
15
  VOTES_LOG_SUBDIR = "ratings_log"
16
  RATINGS_APP_TOKEN_ENV = "RATINGS_APP_TOKEN"
17
 
 
18
  def _list_vote_shards(api: HfApi) -> list[str]:
19
  files = api.list_repo_files(repo_id=VOTES_REPO_ID, repo_type=VOTES_REPO_TYPE)
20
  shard_prefix = f"{VOTES_LOG_SUBDIR}/votes_"
@@ -25,19 +26,21 @@ def _list_vote_shards(api: HfApi) -> list[str]:
25
  and f.endswith(".parquet")
26
  )
27
 
 
28
  def _new_compacted_shard_path() -> str:
29
  ts = int(time.time())
30
  return f"{VOTES_LOG_SUBDIR}/votes_{ts}_{uuid.uuid4().hex}.parquet"
31
 
32
- def compact_votes() -> tuple[int, int, str] | None:
 
33
  token = os.getenv(RATINGS_APP_TOKEN_ENV)
34
  api = HfApi(token=token)
35
 
36
  shards = _list_vote_shards(api)
37
- if len(shards) < 2:
38
- return None
39
 
40
- frames: list[pd.DataFrame] = []
41
  for shard in shards:
42
  shard_local = hf_hub_download(
43
  repo_id=VOTES_REPO_ID,
@@ -45,47 +48,47 @@ def compact_votes() -> tuple[int, int, str] | None:
45
  repo_type=VOTES_REPO_TYPE,
46
  token=token,
47
  )
48
- frames.append(pd.read_parquet(shard_local))
 
49
 
 
50
  combined = pd.concat(frames, ignore_index=True, sort=False)
 
 
 
 
 
 
51
 
52
- # Older shards may predate newly added vote columns; normalize the same
53
- # way VoteStorage does on upload.
54
- for col in VOTE_COLUMNS:
55
- if col not in combined.columns:
56
- combined[col] = None
57
-
58
- combined = combined[VOTE_COLUMNS]
59
-
60
- output_row_count = len(combined)
61
- assert len(combined) == sum(len(frame) for frame in frames)
62
-
63
- compacted_data = combined.to_parquet(index=False)
64
- compacted_path = _new_compacted_shard_path()
65
- api.create_commit(
66
- repo_id=VOTES_REPO_ID,
67
- repo_type=VOTES_REPO_TYPE,
68
- commit_message=f"compact {len(shards)} vote shards",
69
- operations=[
70
- CommitOperationAdd(path_or_fileobj=compacted_data, path_in_repo=compacted_path),
71
- *(CommitOperationDelete(path_in_repo=shard) for shard in shards),
72
- ],
73
- )
74
 
75
- return len(shards), len(combined), compacted_path
76
 
77
- def _main() -> None:
78
- result = compact_votes()
79
- if result is None:
80
- print(f"Nothing to compact.")
81
- return
82
 
83
- shard_count, row_count, compacted_path = result
 
84
  print(
85
- f"Compacted {shard_count} shards into "
86
  f"{VOTES_REPO_ID}/{compacted_path} "
87
  f"with {row_count} rows."
88
  )
89
 
 
90
  if __name__ == "__main__":
91
- _main()
 
1
+ #!/usr/bin/env python3
2
  from __future__ import annotations
3
 
4
  import os
5
  import time
6
  import uuid
7
+ from pathlib import Path
8
+ from tempfile import NamedTemporaryFile
9
 
10
  import pandas as pd
11
  from huggingface_hub import CommitOperationAdd, CommitOperationDelete, HfApi, hf_hub_download
12
 
13
+ VOTES_REPO_ID = "taigasan/e6-visual-ratings"
 
 
14
  VOTES_REPO_TYPE = "dataset"
15
  VOTES_LOG_SUBDIR = "ratings_log"
16
  RATINGS_APP_TOKEN_ENV = "RATINGS_APP_TOKEN"
17
 
18
+
19
  def _list_vote_shards(api: HfApi) -> list[str]:
20
  files = api.list_repo_files(repo_id=VOTES_REPO_ID, repo_type=VOTES_REPO_TYPE)
21
  shard_prefix = f"{VOTES_LOG_SUBDIR}/votes_"
 
26
  and f.endswith(".parquet")
27
  )
28
 
29
+
30
  def _new_compacted_shard_path() -> str:
31
  ts = int(time.time())
32
  return f"{VOTES_LOG_SUBDIR}/votes_{ts}_{uuid.uuid4().hex}.parquet"
33
 
34
+
35
+ def compact_votes() -> tuple[int, int, str]:
36
  token = os.getenv(RATINGS_APP_TOKEN_ENV)
37
  api = HfApi(token=token)
38
 
39
  shards = _list_vote_shards(api)
40
+ if not shards:
41
+ raise FileNotFoundError(f"No vote shards found in {VOTES_REPO_ID}/{VOTES_LOG_SUBDIR}")
42
 
43
+ frames = []
44
  for shard in shards:
45
  shard_local = hf_hub_download(
46
  repo_id=VOTES_REPO_ID,
 
48
  repo_type=VOTES_REPO_TYPE,
49
  token=token,
50
  )
51
+ frame = pd.read_parquet(shard_local)
52
+ frames.append(frame)
53
 
54
+ input_row_count = sum(len(frame) for frame in frames)
55
  combined = pd.concat(frames, ignore_index=True, sort=False)
56
+ output_row_count = int(len(combined))
57
+ if output_row_count != input_row_count:
58
+ raise RuntimeError(
59
+ f"Refusing to commit: row mismatch during compaction "
60
+ f"({input_row_count} -> {output_row_count})."
61
+ )
62
 
63
+ with NamedTemporaryFile(suffix=".parquet", delete=False) as tmp:
64
+ tmp_path = Path(tmp.name)
65
+ try:
66
+ combined.to_parquet(tmp_path, index=False)
67
+ compacted_path = _new_compacted_shard_path()
68
+ operations = [
69
+ CommitOperationAdd(path_or_fileobj=str(tmp_path), path_in_repo=compacted_path),
70
+ *[CommitOperationDelete(path_in_repo=shard) for shard in shards],
71
+ ]
72
+ api.create_commit(
73
+ repo_id=VOTES_REPO_ID,
74
+ repo_type=VOTES_REPO_TYPE,
75
+ commit_message=f"compact {len(shards)} vote shard(s)",
76
+ operations=operations,
77
+ )
78
+ finally:
79
+ tmp_path.unlink(missing_ok=True)
 
 
 
 
 
80
 
81
+ return len(shards), output_row_count, compacted_path
82
 
 
 
 
 
 
83
 
84
+ def main() -> None:
85
+ shard_count, row_count, compacted_path = compact_votes()
86
  print(
87
+ f"Compacted {shard_count} shard(s) into "
88
  f"{VOTES_REPO_ID}/{compacted_path} "
89
  f"with {row_count} rows."
90
  )
91
 
92
+
93
  if __name__ == "__main__":
94
+ main()
explorer.py CHANGED
@@ -3,13 +3,11 @@ import matplotlib.pyplot as plt
3
  import numpy as np
4
  import pandas as pd
5
  import html
6
- from matplotlib.colors import LinearSegmentedColormap
7
  from matplotlib.figure import Figure
8
 
9
  PAGE_SIZE = 30
10
- DISAGREEMENT_SORT_MODE = "Classifier Disagreement: High to Low"
11
- SORT_MODES = [DISAGREEMENT_SORT_MODE, "Rating: Low to High", "Rating: High to Low"]
12
- ALLOWED_CLASSIFIER_FILTERS = ("Siglip + Hydra head", "TaggerExperiment", "PairsExperiment backbone + Hydra head")
13
 
14
 
15
  def _row_image_url(row) -> str | None:
@@ -34,14 +32,10 @@ def _gallery_items(meta: list[dict]) -> list[tuple[str, str]]:
34
 
35
  def _distribution_data(
36
  explorer_df: pd.DataFrame,
37
- classifier_name: str | None = None,
38
  ) -> dict[str, list[float] | int]:
39
- scores_df = explorer_df
40
- if classifier_name is not None:
41
- scores_df = scores_df[scores_df["classifier"].astype(str) == classifier_name]
42
  valid_scores = [
43
  float(score)
44
- for score in scores_df["classifier_score"].tolist()
45
  if pd.notna(score)
46
  ]
47
  if not valid_scores:
@@ -54,65 +48,30 @@ def _distribution_data(
54
  }
55
 
56
 
57
- PLOT_BG = "#0b0e14"
58
- PLOT_TEXT = "#e6edf7"
59
- PLOT_MUTED = "#8b98ad"
60
- PLOT_GRID = "#1c2230"
61
- PLOT_ACCENT = "#fbbf24"
62
- _BAR_CMAP = LinearSegmentedColormap.from_list("explorer_bars", ["#22d3ee", "#4f8cf7", "#8b5cf6"])
63
-
64
-
65
  def _classifier_score_distribution_plot(
66
  distribution_data: dict[str, list[float] | int],
67
  selected_score: float | None = None,
68
  ) -> Figure:
69
- fig, ax = plt.subplots(figsize=(6.4, 2.4))
70
- fig.patch.set_facecolor(PLOT_BG)
71
- ax.set_facecolor(PLOT_BG)
72
  bin_edges = np.asarray(distribution_data.get("bin_edges", []), dtype=float)
73
  counts = np.asarray(distribution_data.get("counts", []), dtype=float)
74
  if counts.size > 0 and bin_edges.size == counts.size + 1:
75
  widths = np.diff(bin_edges)
76
- centers = bin_edges[:-1] + widths / 2.0
77
- span = float(centers[-1] - centers[0]) or 1.0
78
- colors = _BAR_CMAP((centers - centers[0]) / span)
79
- ax.bar(bin_edges[:-1], counts, width=widths * 0.92, align="edge", color=colors, linewidth=0.0, zorder=3)
80
- ax.fill_between(centers, counts, step="mid", color="#4f8cf7", alpha=0.10, linewidth=0.0, zorder=2)
81
- ax.set_ylabel("Count", color=PLOT_MUTED, fontsize=8.5, labelpad=6)
82
- ax.set_ylim(0, float(counts.max()) * 1.18)
83
  if selected_score is not None:
84
- score = float(selected_score)
85
- for width, alpha in ((7.0, 0.07), (4.0, 0.12), (1.6, 0.95)):
86
- ax.axvline(score, color=PLOT_ACCENT, linewidth=width, alpha=alpha, zorder=4)
87
- at_right_edge = score > (bin_edges[0] + bin_edges[-1]) / 2.0
88
- ax.annotate(
89
- f"{score:.4f}",
90
- xy=(score, ax.get_ylim()[1]),
91
- xytext=(-6 if at_right_edge else 6, -4),
92
- textcoords="offset points",
93
- ha="right" if at_right_edge else "left",
94
- va="top",
95
- fontsize=8.0,
96
- color=PLOT_ACCENT,
97
- zorder=5,
98
- bbox=dict(boxstyle="round,pad=0.28", facecolor=PLOT_BG, edgecolor=PLOT_ACCENT, alpha=0.85, linewidth=0.6),
99
- )
100
  else:
101
- ax.text(0.5, 0.5, "No classifier scores available.", ha="center", va="center", transform=ax.transAxes, color=PLOT_MUTED, fontsize=9)
102
  ax.set_yticks([])
103
- total = int(distribution_data.get("total", 0) or 0)
104
- title = "Classifier score distribution"
105
- if total:
106
- title = f"{title} · {total:,} images"
107
- ax.set_title(title, color=PLOT_TEXT, fontsize=10.5, fontweight="semibold", loc="left", pad=10)
108
- ax.set_xlabel("Classifier score", color=PLOT_MUTED, fontsize=8.5, labelpad=6)
109
- ax.tick_params(colors=PLOT_MUTED, labelsize=8, length=0, pad=4)
110
- for name, spine in ax.spines.items():
111
- spine.set_visible(name == "bottom")
112
- spine.set_color(PLOT_GRID)
113
- spine.set_linewidth(0.8)
114
- ax.grid(axis="y", color=PLOT_GRID, linewidth=0.7, alpha=0.9, zorder=0)
115
- ax.set_axisbelow(True)
116
  fig.tight_layout()
117
  return fig
118
 
@@ -123,20 +82,6 @@ def _build_page_meta(
123
  sort_mode: str,
124
  offset: int,
125
  ) -> tuple[list[dict[str, str | int]], int, bool, int]:
126
- all_scores = all_explorer_df[["md5", "classifier", "classifier_score", "percentile"]].copy()
127
- all_scores = all_scores[
128
- all_scores["classifier"].notna()
129
- & all_scores["classifier_score"].notna()
130
- & all_scores["percentile"].notna()
131
- ]
132
- all_scores["md5"] = all_scores["md5"].astype(str)
133
- all_scores["classifier"] = all_scores["classifier"].astype(str)
134
- all_scores = all_scores[all_scores["classifier"].isin(ALLOWED_CLASSIFIER_FILTERS)]
135
- all_scores = all_scores.drop_duplicates(subset=["md5", "classifier"], keep="last")
136
- classifier_order = {name: i for i, name in enumerate(ALLOWED_CLASSIFIER_FILTERS)}
137
- all_scores["classifier_order"] = all_scores["classifier"].map(classifier_order)
138
- all_scores = all_scores.sort_values(["md5", "classifier_order"], kind="mergesort")
139
-
140
  filtered = explorer_df[["id", "md5", "sample_url", "image_url", "classifier_score", "percentile"]].copy()
141
  has_sample = filtered["sample_url"].notna() & (filtered["sample_url"] != "")
142
  has_image = filtered["image_url"].notna() & (filtered["image_url"] != "")
@@ -146,12 +91,17 @@ def _build_page_meta(
146
  filtered = filtered.sort_values("classifier_score", ascending=True, kind="mergesort")
147
  elif sort_mode == "Rating: High to Low":
148
  filtered = filtered.sort_values("classifier_score", ascending=False, kind="mergesort")
149
- else:
150
- disagreement_by_md5 = all_scores.groupby("md5")["percentile"].agg(lambda s: s.max() - s.min())
151
- filtered = filtered.assign(_disagreement=filtered["md5"].astype(str).map(disagreement_by_md5).fillna(0.0))
152
- filtered = filtered.sort_values("_disagreement", ascending=False, kind="mergesort")
153
- filtered = filtered.drop(columns="_disagreement")
154
  page_df = filtered.iloc[offset:offset + PAGE_SIZE]
 
 
 
 
 
 
 
 
 
 
155
  all_scores_by_md5 = {
156
  str(md5): [
157
  {
@@ -187,8 +137,8 @@ def build_results_data(
187
  classifier_name: str,
188
  ) -> tuple[str, Figure, dict[str, list[float] | int], list[tuple[str, str]], list[dict[str, str | int]], int, dict]:
189
  page_meta, next_offset, has_more, total = _build_page_meta(explorer_df, all_explorer_df, sort_mode, offset=0)
190
- summary = f"Showing {total} images from test_set.parquet joined with classifier_scores.parquet (rating: {rating_pref}, classifier: {classifier_name}, sort: {sort_mode})."
191
- distribution_data = _distribution_data(all_explorer_df, classifier_name)
192
  score_distribution_plot = _classifier_score_distribution_plot(distribution_data)
193
  return summary, score_distribution_plot, distribution_data, _gallery_items(page_meta), page_meta, next_offset, gr.update(visible=has_more)
194
 
@@ -258,16 +208,10 @@ def add_results_tab(pool_df: pd.DataFrame):
258
  results_load_more_btn = gr.Button("Load more (ArrowDown)", elem_id="btn-results-load-more")
259
  selected_image_md = gr.Markdown("Click an image to reveal its ID and link.")
260
  results_score_distribution_plot = gr.Plot(label="Classifier score distribution")
261
- results_rating_dd = gr.Dropdown(
262
- choices=["safe", "all"],
263
- value="safe",
264
- label="Rating",
265
- )
266
  results_sort_dd = gr.Dropdown(
267
  choices=SORT_MODES,
268
- value=DISAGREEMENT_SORT_MODE,
269
  label="Sort",
270
- info="Disagreement surfaces images the classifiers rate most differently — useful for spotting which classifier is off.",
271
  elem_id="results-sort-mode",
272
  )
273
  results_classifier_dd = gr.Dropdown(
@@ -281,7 +225,6 @@ def add_results_tab(pool_df: pd.DataFrame):
281
  results_page_offset_state = gr.State(0)
282
  return (
283
  results_summary_md,
284
- results_rating_dd,
285
  results_sort_dd,
286
  results_classifier_dd,
287
  results_score_distribution_plot,
 
3
  import numpy as np
4
  import pandas as pd
5
  import html
 
6
  from matplotlib.figure import Figure
7
 
8
  PAGE_SIZE = 30
9
+ SORT_MODES = ["Default", "Rating: Low to High", "Rating: High to Low"]
10
+ ALLOWED_CLASSIFIER_FILTERS = ("SiglipFinetune", "ScoringHead-v5", "TaggerExperiment")
 
11
 
12
 
13
  def _row_image_url(row) -> str | None:
 
32
 
33
  def _distribution_data(
34
  explorer_df: pd.DataFrame,
 
35
  ) -> dict[str, list[float] | int]:
 
 
 
36
  valid_scores = [
37
  float(score)
38
+ for score in explorer_df["classifier_score"].tolist()
39
  if pd.notna(score)
40
  ]
41
  if not valid_scores:
 
48
  }
49
 
50
 
 
 
 
 
 
 
 
 
51
  def _classifier_score_distribution_plot(
52
  distribution_data: dict[str, list[float] | int],
53
  selected_score: float | None = None,
54
  ) -> Figure:
55
+ fig, ax = plt.subplots(figsize=(6, 2.2))
56
+ fig.patch.set_facecolor("#0f1117")
57
+ ax.set_facecolor("#151922")
58
  bin_edges = np.asarray(distribution_data.get("bin_edges", []), dtype=float)
59
  counts = np.asarray(distribution_data.get("counts", []), dtype=float)
60
  if counts.size > 0 and bin_edges.size == counts.size + 1:
61
  widths = np.diff(bin_edges)
62
+ ax.bar(bin_edges[:-1], counts, width=widths, align="edge", color="#3b82f6", alpha=0.9, edgecolor="#93c5fd", linewidth=0.35)
63
+ ax.set_ylabel("Count", color="#e5e7eb")
 
 
 
 
 
64
  if selected_score is not None:
65
+ ax.axvline(float(selected_score), color="#f97316", linewidth=2.0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  else:
67
+ ax.text(0.5, 0.5, "No classifier scores available.", ha="center", va="center", transform=ax.transAxes, color="#e5e7eb")
68
  ax.set_yticks([])
69
+ ax.set_title("Classifier Score Distribution", color="#f3f4f6")
70
+ ax.set_xlabel("Classifier score", color="#e5e7eb")
71
+ ax.tick_params(colors="#d1d5db")
72
+ for spine in ax.spines.values():
73
+ spine.set_color("#4b5563")
74
+ ax.grid(axis="y", color="#374151", alpha=0.4)
 
 
 
 
 
 
 
75
  fig.tight_layout()
76
  return fig
77
 
 
82
  sort_mode: str,
83
  offset: int,
84
  ) -> tuple[list[dict[str, str | int]], int, bool, int]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  filtered = explorer_df[["id", "md5", "sample_url", "image_url", "classifier_score", "percentile"]].copy()
86
  has_sample = filtered["sample_url"].notna() & (filtered["sample_url"] != "")
87
  has_image = filtered["image_url"].notna() & (filtered["image_url"] != "")
 
91
  filtered = filtered.sort_values("classifier_score", ascending=True, kind="mergesort")
92
  elif sort_mode == "Rating: High to Low":
93
  filtered = filtered.sort_values("classifier_score", ascending=False, kind="mergesort")
 
 
 
 
 
94
  page_df = filtered.iloc[offset:offset + PAGE_SIZE]
95
+ all_scores = all_explorer_df[["md5", "classifier", "classifier_score", "percentile"]].copy()
96
+ all_scores = all_scores[
97
+ all_scores["classifier"].notna()
98
+ & all_scores["classifier_score"].notna()
99
+ & all_scores["percentile"].notna()
100
+ ]
101
+ all_scores["md5"] = all_scores["md5"].astype(str)
102
+ all_scores["classifier"] = all_scores["classifier"].astype(str)
103
+ all_scores = all_scores.drop_duplicates(subset=["md5", "classifier"], keep="last")
104
+ all_scores = all_scores.sort_values(["md5", "classifier"], kind="mergesort")
105
  all_scores_by_md5 = {
106
  str(md5): [
107
  {
 
137
  classifier_name: str,
138
  ) -> tuple[str, Figure, dict[str, list[float] | int], list[tuple[str, str]], list[dict[str, str | int]], int, dict]:
139
  page_meta, next_offset, has_more, total = _build_page_meta(explorer_df, all_explorer_df, sort_mode, offset=0)
140
+ summary = f"Showing {total} images from validation_set.parquet joined with classifier_scores.parquet (rating: {rating_pref}, classifier: {classifier_name}, sort: {sort_mode})."
141
+ distribution_data = _distribution_data(explorer_df)
142
  score_distribution_plot = _classifier_score_distribution_plot(distribution_data)
143
  return summary, score_distribution_plot, distribution_data, _gallery_items(page_meta), page_meta, next_offset, gr.update(visible=has_more)
144
 
 
208
  results_load_more_btn = gr.Button("Load more (ArrowDown)", elem_id="btn-results-load-more")
209
  selected_image_md = gr.Markdown("Click an image to reveal its ID and link.")
210
  results_score_distribution_plot = gr.Plot(label="Classifier score distribution")
 
 
 
 
 
211
  results_sort_dd = gr.Dropdown(
212
  choices=SORT_MODES,
213
+ value="Default",
214
  label="Sort",
 
215
  elem_id="results-sort-mode",
216
  )
217
  results_classifier_dd = gr.Dropdown(
 
225
  results_page_offset_state = gr.State(0)
226
  return (
227
  results_summary_md,
 
228
  results_sort_dd,
229
  results_classifier_dd,
230
  results_score_distribution_plot,
maintenance/refresh_cdn_availability.py DELETED
@@ -1,159 +0,0 @@
1
- #!/usr/bin/env python3
2
- from __future__ import annotations
3
-
4
- import csv
5
- import gzip
6
- import io
7
- import os
8
- import sys
9
- from collections.abc import Iterator
10
-
11
- import httpx
12
- import pandas as pd
13
- from huggingface_hub import CommitOperationAdd, HfApi, hf_hub_download
14
- from tqdm import tqdm
15
-
16
- POOL_REPO_ID = "RedRocket/e6-visual-ratings"
17
- POOL_REPO_TYPE = "dataset"
18
- POOL_FILES = ("pool.parquet", "test_set.parquet")
19
- RATINGS_APP_TOKEN_ENV = "RATINGS_APP_TOKEN"
20
-
21
- # Nullable boolean: True = the file is still served, False = it is gone.
22
- CDN_AVAILABLE_COLUMN = "cdn_available"
23
-
24
- # https://e621.net/help/api asks that bulk lookups go through the daily export
25
- # rather than the site ("If you are looking for bulk data, or are expecting to
26
- # do tens of thousands of lookups, please use the daily data exports"), so this
27
- # whole refresh is one request. Sampled posts confirm the export is enough on
28
- # its own: every is_deleted post 404s on the CDN, as does every md5 the export
29
- # does not list at all (its post was replaced, so nothing serves that file).
30
- POSTS_EXPORT_URL = "https://static1.e621.net/data/db_export/posts.csv.gz"
31
- USER_AGENT = f"e6-visual-ratings-maintenance/1.0 (https://huggingface.co/datasets/{POOL_REPO_ID})"
32
- REQUEST_TIMEOUT_S = 60.0
33
-
34
- class _ChunkReader(io.RawIOBase):
35
- """File-like view over a chunk iterator, so gzip can stream the download."""
36
-
37
- def __init__(self, chunks: Iterator[bytes], progress: tqdm) -> None:
38
- self._chunks = chunks
39
- self._progress = progress
40
- self._buffer = b""
41
-
42
- def readable(self) -> bool:
43
- return True
44
-
45
- def readinto(self, view) -> int:
46
- while not self._buffer:
47
- chunk = next(self._chunks, b"")
48
- if not chunk:
49
- return 0
50
-
51
- self._buffer = chunk
52
- self._progress.update(len(chunk))
53
-
54
- taken = min(len(view), len(self._buffer))
55
- view[:taken] = self._buffer[:taken]
56
- self._buffer = self._buffer[taken:]
57
- return taken
58
-
59
- def _exported_availability(wanted: set[str]) -> dict[str, bool]:
60
- """Maps each wanted md5 the daily export knows about to `not is_deleted`."""
61
- # Post descriptions blow past the default csv field limit.
62
- csv.field_size_limit(sys.maxsize)
63
-
64
- availability: dict[str, bool] = {}
65
- with httpx.stream(
66
- "GET",
67
- POSTS_EXPORT_URL,
68
- headers={"User-Agent": USER_AGENT},
69
- timeout=REQUEST_TIMEOUT_S,
70
- follow_redirects=True,
71
- ) as response:
72
- response.raise_for_status()
73
- total = int(response.headers.get("content-length", 0)) or None
74
-
75
- with tqdm(total=total, unit="B", unit_scale=True, desc="posts.csv.gz") as progress:
76
- chunks = io.BufferedReader(_ChunkReader(response.iter_bytes(), progress))
77
- rows = csv.reader(io.TextIOWrapper(gzip.GzipFile(fileobj=chunks), encoding="utf-8", newline=""))
78
-
79
- header = next(rows)
80
- md5_at = header.index("md5")
81
- is_deleted_at = header.index("is_deleted")
82
-
83
- for row in rows:
84
- md5 = row[md5_at]
85
- if md5 in wanted:
86
- availability[md5] = row[is_deleted_at] == "f"
87
-
88
- return availability
89
-
90
- def _labels(availability: pd.Series) -> pd.Series:
91
- return availability.map({True: "available", False: "missing"}).fillna("unknown")
92
-
93
- def refresh_cdn_availability() -> None:
94
- token = os.getenv(RATINGS_APP_TOKEN_ENV)
95
- api = HfApi(token=token)
96
-
97
- frames: dict[str, pd.DataFrame] = {}
98
- for filename in POOL_FILES:
99
- local_path = hf_hub_download(
100
- repo_id=POOL_REPO_ID,
101
- filename=filename,
102
- repo_type=POOL_REPO_TYPE,
103
- token=token,
104
- )
105
- frames[filename] = pd.read_parquet(local_path)
106
-
107
- md5s = {md5 for df in frames.values() for md5 in df["md5"]}
108
- print(f"looking up {len(md5s)} unique md5s from {', '.join(frames)}")
109
-
110
- exported = _exported_availability(md5s)
111
- # An md5 the export does not list belongs to a post that was replaced, and
112
- # its file is gone from the CDN just like a deleted one's.
113
- availability = {md5: exported.get(md5, False) for md5 in md5s}
114
- available = sum(availability.values())
115
- print(f"export: {available} available, {len(md5s) - available} missing")
116
-
117
- operations: list[CommitOperationAdd] = []
118
- for filename, df in frames.items():
119
- refreshed = pd.Series([availability.get(md5) for md5 in df["md5"]], index=df.index, dtype="boolean")
120
- counts = _labels(refreshed).value_counts()
121
-
122
- if CDN_AVAILABLE_COLUMN in df.columns:
123
- previous = _labels(df[CDN_AVAILABLE_COLUMN].astype("boolean"))
124
- changed = int((previous != _labels(refreshed)).sum())
125
- else:
126
- changed = len(df)
127
-
128
- print(
129
- f"{filename}: {len(df)} rows, "
130
- f"{counts.get('available', 0)} available, "
131
- f"{counts.get('missing', 0)} missing, "
132
- f"{counts.get('unknown', 0)} unknown "
133
- f"({changed} changed)"
134
- )
135
-
136
- if not changed:
137
- continue
138
-
139
- df[CDN_AVAILABLE_COLUMN] = refreshed
140
- operations.append(CommitOperationAdd(
141
- path_or_fileobj=df.to_parquet(index=False),
142
- path_in_repo=filename,
143
- ))
144
-
145
- if not operations:
146
- print("Nothing changed.")
147
- return
148
-
149
- updated = [operation.path_in_repo for operation in operations]
150
- api.create_commit(
151
- repo_id=POOL_REPO_ID,
152
- repo_type=POOL_REPO_TYPE,
153
- commit_message=f"refresh {CDN_AVAILABLE_COLUMN} in {', '.join(updated)}",
154
- operations=operations,
155
- )
156
- print(f"Updated {', '.join(updated)} in {POOL_REPO_ID}.")
157
-
158
- if __name__ == "__main__":
159
- refresh_cdn_availability()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
stats_from_logs.py CHANGED
@@ -1,24 +1,11 @@
1
- from collections import defaultdict
2
- from dataclasses import dataclass
3
 
4
  import pandas as pd
5
  from huggingface_hub import HfApi, hf_hub_download
6
 
7
- VOTES_LOG_SUBDIR = "ratings_log"
8
-
9
- @dataclass(slots=True)
10
- class Stats:
11
- wins: int = 0
12
- losses: int = 0
13
- ties: int = 0
14
 
15
- @property
16
- def votes(self) -> int:
17
- return self.wins + self.losses + self.ties
18
 
19
- @property
20
- def winrate(self) -> float:
21
- return (self.wins + self.ties * 0.5) / max(self.votes, 1)
22
 
23
  def _list_remote_log_files(repo_id: str, token: str | None) -> list[str]:
24
  api = HfApi(token=token)
@@ -29,36 +16,42 @@ def _list_remote_log_files(repo_id: str, token: str | None) -> list[str]:
29
  if f.startswith(f"{VOTES_LOG_SUBDIR}/") and f.endswith(".parquet")
30
  )
31
 
32
- def load_vote_logs(*, repo_id: str, token: str | None) -> pd.DataFrame:
33
- """All logged votes as one DataFrame with columns md5a, md5b, winner_md5."""
34
- frames = [
35
- pd.read_parquet(
36
- hf_hub_download(
37
- repo_id=repo_id,
38
- filename=path_in_repo,
39
- repo_type="dataset",
40
- token=token,
41
- ),
42
- columns=["md5a", "md5b", "winner_md5"],
43
- )
44
- for path_in_repo in _list_remote_log_files(repo_id, token)
45
- ]
46
- if not frames:
47
- return pd.DataFrame(columns=["md5a", "md5b", "winner_md5"])
48
- return pd.concat(frames, ignore_index=True)
49
 
50
- def stats_by_md5(votes_df: pd.DataFrame) -> dict[str, Stats]:
51
- stats: dict[str, Stats] = defaultdict(Stats)
 
 
 
 
 
 
 
 
 
 
52
 
53
- for md5a, md5b, winner_md5 in votes_df.itertuples(index=False):
54
- if winner_md5 == md5a:
55
- stats[md5a].wins += 1
56
- stats[md5b].losses += 1
57
- elif winner_md5 == md5b:
58
- stats[md5b].wins += 1
59
- stats[md5a].losses += 1
60
- else:
61
- stats[md5a].ties += 1
62
- stats[md5b].ties += 1
 
 
 
 
 
 
 
 
 
 
 
 
63
 
64
- return stats
 
 
1
+ from collections import Counter
 
2
 
3
  import pandas as pd
4
  from huggingface_hub import HfApi, hf_hub_download
5
 
 
 
 
 
 
 
 
6
 
7
+ VOTES_LOG_SUBDIR = "ratings_log"
 
 
8
 
 
 
 
9
 
10
  def _list_remote_log_files(repo_id: str, token: str | None) -> list[str]:
11
  api = HfApi(token=token)
 
16
  if f.startswith(f"{VOTES_LOG_SUBDIR}/") and f.endswith(".parquet")
17
  )
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
+ def _accumulate_stats_from_df(df: pd.DataFrame, wins_counter: Counter, losses_counter: Counter):
21
+ if df.empty:
22
+ return
23
+ valid = df[df["winner_md5"].notna()].copy()
24
+ if valid.empty:
25
+ return
26
+ valid["md5a"] = valid["md5a"].astype(str)
27
+ valid["md5b"] = valid["md5b"].astype(str)
28
+ valid["winner_md5"] = valid["winner_md5"].astype(str)
29
+
30
+ a_won_mask = valid["winner_md5"] == valid["md5a"]
31
+ b_won_mask = valid["winner_md5"] == valid["md5b"]
32
 
33
+ winner_keys = pd.concat([valid.loc[a_won_mask, "md5a"], valid.loc[b_won_mask, "md5b"]], ignore_index=True)
34
+ loser_keys = pd.concat([valid.loc[a_won_mask, "md5b"], valid.loc[b_won_mask, "md5a"]], ignore_index=True)
35
+
36
+ for key, count in winner_keys.value_counts().items():
37
+ wins_counter[str(key)] += int(count)
38
+ for key, count in loser_keys.value_counts().items():
39
+ losses_counter[str(key)] += int(count)
40
+
41
+
42
+ def load_stats_by_md5(*, repo_id: str, token: str | None) -> dict[str, tuple[int, int]]:
43
+ wins_counter: Counter[str] = Counter()
44
+ losses_counter: Counter[str] = Counter()
45
+
46
+ for path_in_repo in _list_remote_log_files(repo_id, token):
47
+ local_path = hf_hub_download(
48
+ repo_id=repo_id,
49
+ filename=path_in_repo,
50
+ repo_type="dataset",
51
+ token=token,
52
+ )
53
+ df = pd.read_parquet(local_path, columns=["md5a", "md5b", "winner_md5"])
54
+ _accumulate_stats_from_df(df, wins_counter, losses_counter)
55
 
56
+ all_keys = set(wins_counter) | set(losses_counter)
57
+ return {k: (int(wins_counter.get(k, 0)), int(losses_counter.get(k, 0))) for k in all_keys}
storage.py CHANGED
@@ -23,126 +23,86 @@ VOTE_COLUMNS = [
23
  "dataset",
24
  "group",
25
  "session_id",
26
- "pair_reason", # sampler channel; lets dataset builds treat model-selected pairs specially
27
- "rater_name", # optional self-reported handle; empty when the rater stays anonymous
28
  ]
29
 
30
 
31
  class VoteStorage:
32
- def __init__(self, mode: str, token: str | None = None) -> None:
33
  assert mode in ("hf", "void"), f"Unsupported storage mode: {mode}"
34
  self.mode = mode
 
35
  is_debug_mode = self.mode == "void"
36
-
37
  self._flush_every = 3 if is_debug_mode else 50
38
  self._flush_interval_sec = 15.0 if is_debug_mode else 300.0
39
- self._hf_api = HfApi(token=token)
40
-
41
- self._flush_condition = threading.Condition(threading.Lock())
42
- self._sync_event = threading.Event()
43
- self._sync_lock = threading.Lock()
44
  self._votes_buffer: list[dict] = []
45
- self._shutdown = False
46
-
47
  self._flush_thread = threading.Thread(target=self._flush_loop, daemon=True)
48
  self._flush_thread.start()
49
-
50
  atexit.register(self.close)
51
 
52
- def _upload_votes_batch(self, batch: list[dict]) -> None:
53
- assert batch
54
- if self.mode == "void":
55
- return
56
 
57
- df = pd.DataFrame(batch)
58
-
59
- for col in VOTE_COLUMNS:
60
- if col not in df.columns:
61
- df[col] = None
62
-
63
- df = df[VOTE_COLUMNS]
64
 
 
 
 
 
 
65
  ts = int(time.time())
66
  shard = f"votes_{ts}_{uuid.uuid4().hex}.parquet"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
 
68
- self._hf_api.upload_file(
69
- path_or_fileobj=df.to_parquet(index=False),
70
- path_in_repo=f"{VOTES_LOG_SUBDIR}/{shard}",
71
- repo_id=VOTES_REPO_ID,
72
- repo_type=VOTES_REPO_TYPE,
73
- commit_message=f"upload {len(df)} vote rows",
74
- )
75
-
76
- def _flush_loop(self) -> None:
77
- while True:
78
- with self._flush_condition:
79
- while True:
80
- # Forced sync.
81
- if not self._sync_event.is_set():
82
- if self._votes_buffer:
83
- break
84
-
85
- self._sync_event.set()
86
-
87
- # Shutdown wanted.
88
- if self._shutdown:
89
- if self._votes_buffer:
90
- break
91
-
92
- return
93
-
94
- # Have enough votes to flush now.
95
- if len(self._votes_buffer) >= self._flush_every:
96
- break
97
-
98
- # Wait for a notify to flush early or shutdown.
99
- if not self._flush_condition.wait(self._flush_interval_sec):
100
- # Interval elapsed. Flush if there is at least one vote.
101
- if self._votes_buffer:
102
- break
103
-
104
- # Atomically take the batch of votes.
105
- batch = self._votes_buffer
106
- self._votes_buffer = []
107
-
108
- self._upload_votes_batch(batch)
109
-
110
- def sync(self) -> None:
111
- with self._sync_lock:
112
- with self._flush_condition:
113
- is_shutdown = self._shutdown
114
- if not is_shutdown:
115
- self._sync_event.clear()
116
- self._flush_condition.notify()
117
-
118
- if not is_shutdown:
119
- self._sync_event.wait()
120
-
121
- if is_shutdown:
122
- self._flush_thread.join()
123
-
124
- def close(self) -> None:
125
- with self._flush_condition:
126
- self._shutdown = True
127
- self._flush_condition.notify()
128
 
129
- self._flush_thread.join()
 
 
 
 
 
130
 
131
- def queue_row(self, state: dict) -> None:
132
  id_a = int(state["id_a"])
133
  id_b = int(state["id_b"])
134
-
135
- winner_md5: str | None
136
- match state["winner"]:
137
- case "A":
138
- winner_md5 = state["key_a"]
139
- case "B":
140
- winner_md5 = state["key_b"]
141
- case None:
142
- winner_md5 = None
143
- case _:
144
- raise AssertionError
145
-
146
  vote_row = {
147
  "vote_id": uuid.uuid4().hex,
148
  "timestamp": datetime.now(timezone.utc).isoformat(timespec="seconds"),
@@ -154,12 +114,7 @@ class VoteStorage:
154
  "dataset": state["dataset"],
155
  "group": state["group"],
156
  "session_id": state["session_id"],
157
- "pair_reason": state.get("pair_reason", ""),
158
- "rater_name": state.get("rater_name", ""),
159
  }
160
-
161
- with self._flush_condition:
162
  self._votes_buffer.append(vote_row)
163
-
164
- if len(self._votes_buffer) == self._flush_every:
165
- self._flush_condition.notify()
 
23
  "dataset",
24
  "group",
25
  "session_id",
 
 
26
  ]
27
 
28
 
29
  class VoteStorage:
30
+ def __init__(self, mode: str, token: str | None = None):
31
  assert mode in ("hf", "void"), f"Unsupported storage mode: {mode}"
32
  self.mode = mode
33
+ self._token = token
34
  is_debug_mode = self.mode == "void"
 
35
  self._flush_every = 3 if is_debug_mode else 50
36
  self._flush_interval_sec = 15.0 if is_debug_mode else 300.0
37
+ self._votes_lock = threading.Lock()
 
 
 
 
38
  self._votes_buffer: list[dict] = []
39
+ self._stop_event = threading.Event()
 
40
  self._flush_thread = threading.Thread(target=self._flush_loop, daemon=True)
41
  self._flush_thread.start()
 
42
  atexit.register(self.close)
43
 
44
+ def _hf_token(self) -> str | None:
45
+ return self._token
 
 
46
 
47
+ def _empty_votes_df(self) -> pd.DataFrame:
48
+ return pd.DataFrame(columns=VOTE_COLUMNS)
 
 
 
 
 
49
 
50
+ def _upload_votes_batch(self, df: pd.DataFrame, commit_message: str):
51
+ assert set(VOTE_COLUMNS).issubset(df.columns), "Missing vote columns in upload batch"
52
+ if self.mode == "void":
53
+ _ = commit_message
54
+ return
55
  ts = int(time.time())
56
  shard = f"votes_{ts}_{uuid.uuid4().hex}.parquet"
57
+ api = HfApi(token=self._hf_token())
58
+ with NamedTemporaryFile(suffix=".parquet", delete=False) as tmp:
59
+ tmp_path = tmp.name
60
+ try:
61
+ df[VOTE_COLUMNS].to_parquet(tmp_path, index=False)
62
+ api.upload_file(
63
+ path_or_fileobj=tmp_path,
64
+ path_in_repo=f"{VOTES_LOG_SUBDIR}/{shard}",
65
+ repo_id=VOTES_REPO_ID,
66
+ repo_type=VOTES_REPO_TYPE,
67
+ commit_message=commit_message,
68
+ )
69
+ finally:
70
+ if os.path.exists(tmp_path):
71
+ os.remove(tmp_path)
72
+
73
+ def _flush_votes(self, force: bool = False):
74
+ with self._votes_lock:
75
+ if not self._votes_buffer:
76
+ return
77
+ if not force and len(self._votes_buffer) < self._flush_every:
78
+ return
79
+ batch = list(self._votes_buffer)
80
+ self._votes_buffer.clear()
81
+ incoming = pd.DataFrame(batch)
82
+ for col in VOTE_COLUMNS:
83
+ if col not in incoming.columns:
84
+ incoming[col] = None
85
+ self._upload_votes_batch(incoming[VOTE_COLUMNS], commit_message=f"append {len(batch)} vote rows")
86
 
87
+ def _flush_loop(self):
88
+ while not self._stop_event.wait(self._flush_interval_sec):
89
+ self._flush_votes(force=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
 
91
+ def close(self):
92
+ if self._stop_event.is_set():
93
+ return
94
+ self._stop_event.set()
95
+ self._flush_thread.join(timeout=1.0)
96
+ self._flush_votes(force=True)
97
 
98
+ def append_vote_row(self, state: dict, winner: str | None):
99
  id_a = int(state["id_a"])
100
  id_b = int(state["id_b"])
101
+ winner_md5 = None
102
+ if winner == "A":
103
+ winner_md5 = state["key_a"]
104
+ elif winner == "B":
105
+ winner_md5 = state["key_b"]
 
 
 
 
 
 
 
106
  vote_row = {
107
  "vote_id": uuid.uuid4().hex,
108
  "timestamp": datetime.now(timezone.utc).isoformat(timespec="seconds"),
 
114
  "dataset": state["dataset"],
115
  "group": state["group"],
116
  "session_id": state["session_id"],
 
 
117
  }
118
+ with self._votes_lock:
 
119
  self._votes_buffer.append(vote_row)
120
+ self._flush_votes()