Skip to content

Commit 6b1eb7a

Browse files
authored
feat: UTC-563: Annotations per task coverage percentage distributed randomly (#9374)
Co-authored-by: mcanu <mcanu@users.noreply.github.com>
1 parent 047c4cf commit 6b1eb7a

File tree

2 files changed

+118
-8
lines changed

2 files changed

+118
-8
lines changed

label_studio/projects/models.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from annoying.fields import AutoOneToOneField
88
from core.current_request import CurrentContext
9+
from core.feature_flags import flag_set
910
from core.label_config import (
1011
check_control_in_config_by_regex,
1112
check_toname_in_config_by_regex,
@@ -576,17 +577,24 @@ def _rearrange_overlap_cohort(self):
576577
all_project_tasks.filter(id__in=ids), overlap=max_annotations, is_labeled=True
577578
)
578579
# order other tasks by count(annotations)
579-
tasks_with_min_annotations = (
580-
tasks_with_min_annotations.annotate(anno=Count('annotations')).order_by('-anno').distinct()
581-
)
580+
tasks_with_min_annotations = tasks_with_min_annotations.annotate(annotation_count=Count('annotations'))
581+
if flag_set('fflag_feat_utc_563_randomize_overlap_cohort', user='auto'):
582+
# Randomize within tie groups so cohort selection isn't deterministic.
583+
# If there are many tasks with the same annotation count, their order is random.
584+
tasks_with_min_annotations = tasks_with_min_annotations.order_by('-annotation_count', '?')
585+
else:
586+
tasks_with_min_annotations = tasks_with_min_annotations.order_by('-annotation_count').distinct()
587+
588+
# Materialize the full ID list once to ensure consistent ordering across slices, instead of slicing twice with random ordering.
589+
all_min_ids = list(tasks_with_min_annotations.values_list('id', flat=True))
590+
cohort_ids = all_min_ids[:left_must_tasks]
591+
remaining_ids = all_min_ids[left_must_tasks:]
592+
582593
# assign overlap depending on annotation count
583594
# assign max_annotations and update is_labeled
584-
ids = list(tasks_with_min_annotations[:left_must_tasks].values_list('id', flat=True))
585-
self._batch_update_with_retry(all_project_tasks.filter(id__in=ids), overlap=max_annotations)
595+
self._batch_update_with_retry(all_project_tasks.filter(id__in=cohort_ids), overlap=max_annotations)
586596
# assign 1 to left
587-
ids = list(tasks_with_min_annotations[left_must_tasks:].values_list('id', flat=True))
588-
min_tasks_to_update = all_project_tasks.filter(id__in=ids)
589-
self._batch_update_with_retry(min_tasks_to_update, overlap=1)
597+
self._batch_update_with_retry(all_project_tasks.filter(id__in=remaining_ids), overlap=1)
590598
else:
591599
ids = list(tasks_with_max_annotations.values_list('id', flat=True))
592600
self._batch_update_with_retry(all_project_tasks.filter(id__in=ids), overlap=max_annotations)
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
"""Tests for projects.models (Project model and related logic)."""
2+
from django.test import TestCase
3+
from projects.tests.factories import ProjectFactory
4+
from tasks.models import Task
5+
from tasks.tests.factories import AnnotationFactory, TaskFactory
6+
from tests.utils import mock_feature_flag
7+
8+
9+
class TestRearrangeOverlapCohort(TestCase):
10+
"""
11+
Tests for Project._rearrange_overlap_cohort().
12+
13+
Covers overlap cohort assignment when overlap_cohort_percentage < 100:
14+
correct cohort size, deterministic vs random tie-breaking (feature flag),
15+
and prioritization of tasks with more annotations (progress preservation).
16+
"""
17+
18+
@mock_feature_flag('fflag_feat_utc_563_randomize_overlap_cohort', True, parent_module='projects.models')
19+
def test_randomize_when_flag_on(self):
20+
"""
21+
With fflag_feat_utc_563_randomize_overlap_cohort on, cohort selection
22+
varies across runs because tie-breaking within same annotation count
23+
is random. Expected: at least 2 distinct cohort ID sets over 10 runs,
24+
and cohort size always equals must_tasks.
25+
"""
26+
num_tasks = 20
27+
overlap_cohort_pct = 25
28+
expected_cohort_size = int(num_tasks * overlap_cohort_pct / 100 + 0.5) # 5
29+
project = ProjectFactory(
30+
maximum_annotations=2,
31+
overlap_cohort_percentage=overlap_cohort_pct,
32+
)
33+
TaskFactory.create_batch(num_tasks, project=project)
34+
35+
cohorts_seen = set()
36+
for _ in range(10):
37+
project._rearrange_overlap_cohort()
38+
cohort_ids = frozenset(Task.objects.filter(project=project, overlap__gt=1).values_list('id', flat=True))
39+
assert len(cohort_ids) == expected_cohort_size
40+
cohorts_seen.add(cohort_ids)
41+
assert len(cohorts_seen) >= 2, 'Random tie-breaking should produce at least 2 different cohorts over 10 runs'
42+
43+
@mock_feature_flag('fflag_feat_utc_563_randomize_overlap_cohort', False, parent_module='projects.models')
44+
def test_deterministic_when_flag_off(self):
45+
"""
46+
With fflag_feat_utc_563_randomize_overlap_cohort off, cohort selection
47+
is deterministic. Expected: two consecutive runs yield the same cohort
48+
ID set and correct cohort size.
49+
"""
50+
num_tasks = 20
51+
overlap_cohort_pct = 25
52+
expected_cohort_size = int(num_tasks * overlap_cohort_pct / 100 + 0.5)
53+
project = ProjectFactory(
54+
maximum_annotations=2,
55+
overlap_cohort_percentage=overlap_cohort_pct,
56+
)
57+
TaskFactory.create_batch(num_tasks, project=project)
58+
59+
project._rearrange_overlap_cohort()
60+
cohort_first = frozenset(Task.objects.filter(project=project, overlap__gt=1).values_list('id', flat=True))
61+
project._rearrange_overlap_cohort()
62+
cohort_second = frozenset(Task.objects.filter(project=project, overlap__gt=1).values_list('id', flat=True))
63+
assert len(cohort_first) == expected_cohort_size
64+
assert cohort_first == cohort_second
65+
66+
@mock_feature_flag('fflag_feat_utc_563_randomize_overlap_cohort', True, parent_module='projects.models')
67+
def test_preserves_progress_when_flag_on(self):
68+
"""
69+
Tasks with more finished annotations are prioritized into the cohort
70+
(progress preserved). With flag on, only tie-breaking is random.
71+
Expected: tasks that already have one annotation are in the cohort.
72+
"""
73+
num_tasks = 10
74+
overlap_cohort_pct = 30
75+
expected_cohort_size = int(num_tasks * overlap_cohort_pct / 100 + 0.5) # 3
76+
project = ProjectFactory(
77+
maximum_annotations=2,
78+
overlap_cohort_percentage=overlap_cohort_pct,
79+
)
80+
tasks = TaskFactory.create_batch(num_tasks, project=project)
81+
for t in tasks[:2]:
82+
AnnotationFactory(
83+
task=t,
84+
project=project,
85+
result=[
86+
{
87+
'value': {'choices': ['A']},
88+
'from_name': 'text_class',
89+
'to_name': 'text',
90+
'type': 'choices',
91+
}
92+
],
93+
was_cancelled=False,
94+
ground_truth=False,
95+
)
96+
97+
project._rearrange_overlap_cohort()
98+
99+
cohort_ids = set(Task.objects.filter(project=project, overlap__gt=1).values_list('id', flat=True))
100+
assert len(cohort_ids) == expected_cohort_size
101+
assert tasks[0].id in cohort_ids
102+
assert tasks[1].id in cohort_ids

0 commit comments

Comments
 (0)