Spaces:
Sleeping
Sleeping
File size: 9,099 Bytes
23f9faf 9765f33 23f9faf | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 |
"""Balanced random subset assignment: pure logic, no FastAPI/HF imports.
Everything here takes plain data in and returns plain data out, so it can be
unit-tested (see test_assignment.py) without touching the network. The I/O
layer (reading/writing annotation and assignment records from the HF dataset
repo, locking) lives in main.py and calls into these functions.
Config is env-overridable, matching the existing HF_* var convention in
main.py, so these three numbers can change without touching the algorithm.
"""
import os
import re
import secrets
import string
from datetime import datetime
TOTAL_VIDEOS = int(os.getenv("TOTAL_VIDEOS", "200"))
VIDEOS_PER_ANNOTATOR = int(os.getenv("VIDEOS_PER_ANNOTATOR", "20"))
TARGET_ANNOTATIONS_PER_VIDEO = int(os.getenv("TARGET_ANNOTATIONS_PER_VIDEO", "5"))
# How long an in-progress (not-yet-completed) round still counts as an open
# "reservation" against other annotators' balancing, before being treated as
# abandoned. Distinct from COMPLETION_GRACE_PERIOD_SECONDS below.
RESERVATION_TTL_SECONDS = int(os.getenv("ASSIGNMENT_RESERVATION_TTL_SECONDS", str(24 * 3600)))
# How long a just-completed round keeps showing its completion code before a
# later visit automatically gets the next round instead. Distinct from
# RESERVATION_TTL_SECONDS above - this one is "how long do you get to read
# your own code," not "when do other annotators stop waiting on your slot."
COMPLETION_GRACE_PERIOD_SECONDS = int(os.getenv("COMPLETION_GRACE_PERIOD_SECONDS", str(10 * 60)))
COMPLETION_CODE_ALPHABET = string.ascii_uppercase + string.digits
COMPLETION_CODE_LENGTH = 12
def generate_completion_code():
"""Cryptographically random completion code, format T2AV-XXXXXXXXXXXX.
Uses `secrets`, not `random` - this is a credential the annotator submits
elsewhere (e.g. an MTurk HIT) as proof of completion, not a UI cosmetic."""
suffix = "".join(secrets.choice(COMPLETION_CODE_ALPHABET) for _ in range(COMPLETION_CODE_LENGTH))
return f"T2AV-{suffix}"
def slugify(value):
"""Canonical annotator_id from a raw display name. Lowercased (unlike
main.py's filename slugify) so "Adi" and "adi" resolve to one identity -
this is the identity key used for completion/assignment lookups, not a
filename, so case-insensitivity is the more correct default here."""
slug = re.sub(r"[^A-Za-z0-9_.-]+", "-", value or "").strip("-").lower()
return slug or "unknown"
def parse_iso(value):
return datetime.fromisoformat(value.replace("Z", "+00:00"))
def dedupe_latest(annotation_records):
"""A "completed annotation" is one row per unique (annotator_id, video_id)
pair - repeated saves/autosaves/updates from the same annotator for the
same video collapse into a single record, keeping the most recent
created_at. Records missing a required field are skipped (never crash on
older/malformed data) and reported separately.
Returns (deduped: dict[(annotator_id, video_id)] -> record, skipped: int).
"""
latest = {}
skipped = 0
for record in annotation_records:
try:
video_id = record["video_id"]
user_raw = record["user"]
created_at = record["created_at"]
if not video_id or not user_raw or not created_at:
raise ValueError("empty required field")
parse_iso(created_at) # validate shape before trusting string comparisons below
except (KeyError, TypeError, ValueError, AttributeError):
skipped += 1
continue
key = (slugify(user_raw), video_id)
existing = latest.get(key)
if existing is None or created_at > existing["created_at"]:
latest[key] = record
return latest, skipped
def completed_by_video(deduped):
result = {}
for annotator_id, video_id in deduped:
result.setdefault(video_id, set()).add(annotator_id)
return result
def completed_by_annotator(deduped):
result = {}
for annotator_id, video_id in deduped:
result.setdefault(annotator_id, set()).add(video_id)
return result
def saved_responses_for_round(deduped, annotator_id, video_ids):
"""{video_id: responses} for every video in `video_ids` that `annotator_id`
already has a saved annotation for - lets a returning annotator (same
name, new session or a reload) resume with prior answers pre-filled
instead of starting blank. `responses` is exactly the {events, video}
shape the frontend's annotation state already uses, since it's what was
saved from that same shape originally."""
video_id_set = set(video_ids)
return {
video_id: record["annotations"]["responses"]
for (a_id, video_id), record in deduped.items()
if a_id == annotator_id and video_id in video_id_set
}
def reserved_by_video(assignment_records, completed_by_annotator_map, now, ttl_seconds, exclude_annotator=None):
"""Open (not-yet-completed, not-yet-expired) reservations per video,
across every OTHER annotator's assignment. A reservation stops counting
once its assignment is older than ttl_seconds ("abandoned") - this never
deletes or mutates the stored assignment record, it only stops
contributing to the live coverage snapshot used for balancing."""
result = {}
for record in assignment_records:
annotator_id = record.get("annotator_id")
if not annotator_id or annotator_id == exclude_annotator:
continue
try:
created_at = parse_iso(record["created_at"])
except (KeyError, ValueError, AttributeError, TypeError):
continue
if (now - created_at).total_seconds() > ttl_seconds:
continue
completed = completed_by_annotator_map.get(annotator_id, set())
for video_id in record.get("video_ids", []):
if video_id in completed:
continue
result[video_id] = result.get(video_id, 0) + 1
return result
def pick_balanced(candidates, coverage_fn, count, rng, target=TARGET_ANNOTATIONS_PER_VIDEO):
"""Picks `count` videos, prioritizing lowest coverage (completed +
reserved) first, random tie-break among equal coverage. Prefers
strictly-under-target videos; only reaches into at-or-over-target ones if
there aren't enough under-target candidates to satisfy `count`."""
if count <= 0:
return []
under_target = [v for v in candidates if coverage_fn(v) < target]
pool = under_target if len(under_target) >= count else list(candidates)
ordered = sorted(pool, key=lambda video_id: (coverage_fn(video_id), rng.random()))
return ordered[:count]
def build_round(
annotator_id,
annotator_raw,
catalog_video_ids,
already_completed,
completed_by_video_map,
reserved_by_video_map,
rng,
round_number,
videos_per_annotator=VIDEOS_PER_ANNOTATOR,
target=TARGET_ANNOTATIONS_PER_VIDEO,
):
"""Builds a new round: exactly `videos_per_annotator` videos this
annotator has never completed before - across every prior round AND any
completions that predate this feature (`already_completed` is the full,
round-agnostic history). Videos already completed are unconditionally
excluded, never used to seed the round. Returns fewer than
`videos_per_annotator` only when the eligible pool itself is smaller
(see `actual_size` vs `requested_size` in the result) - never fills the
gap with already-completed videos."""
candidates = [v for v in catalog_video_ids if v not in already_completed]
def coverage(video_id):
return len(completed_by_video_map.get(video_id, set())) + reserved_by_video_map.get(video_id, 0)
video_ids = pick_balanced(candidates, coverage, videos_per_annotator, rng, target)
return {
"annotator_id": annotator_id,
"annotator_name_raw": annotator_raw,
"round_number": round_number,
"video_ids": video_ids,
"requested_size": videos_per_annotator,
"actual_size": len(video_ids),
}
def summarize(
catalog_video_ids,
completed_by_video_map,
reserved_by_video_map,
total_completed_preserved,
skipped_records,
duplicate_records_deduped,
assignment_count,
target=TARGET_ANNOTATIONS_PER_VIDEO,
):
below = sum(1 for v in catalog_video_ids if len(completed_by_video_map.get(v, set())) < target)
return {
"total_videos": len(catalog_video_ids),
"target_per_video": target,
"videos_per_annotator": VIDEOS_PER_ANNOTATOR,
"completed_counts": {v: len(completed_by_video_map.get(v, set())) for v in catalog_video_ids},
"reserved_counts": {v: reserved_by_video_map.get(v, 0) for v in catalog_video_ids},
"videos_below_target": below,
"videos_at_or_above_target": len(catalog_video_ids) - below,
"total_existing_annotations_preserved": total_completed_preserved,
"skipped_malformed_records": skipped_records,
"duplicate_records_deduplicated": duplicate_records_deduped,
"total_assignments": assignment_count,
}
|