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,
    }