From 7fcf79b74018bd0671132e42e66fea0195b64335 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Fri, 23 Oct 2026 04:09:59 +0300 Subject: [PATCH] fix: guarantee distinct balanced allocation alternatives --- project_rates/allocation_alternatives.py | 61 ++++++++++++ .../tests/test_allocation_alternatives.py | 58 +++++++++++ project_rates/tests/test_case_distribution.py | 97 ++++++++++++++++++- project_rates/workspace.py | 71 +++++++++++++- project_rates/workspace_views.py | 1 + 5 files changed, 284 insertions(+), 4 deletions(-) create mode 100644 project_rates/allocation_alternatives.py create mode 100644 project_rates/tests/test_allocation_alternatives.py diff --git a/project_rates/allocation_alternatives.py b/project_rates/allocation_alternatives.py new file mode 100644 index 00000000..bb80892d --- /dev/null +++ b/project_rates/allocation_alternatives.py @@ -0,0 +1,61 @@ +"""Find a different proposal without increasing unfinished-workload imbalance.""" + +from collections import defaultdict +from random import Random + + +def alternative_pairs(pairs, candidates, load, seed=0): + """Exchange one equal/better-load member or follow an alternating cycle. + + Every edge moves a proposed assignment to another eligible expert of the + same project. Cycles keep all expert loads unchanged; a direct exchange + is accepted only when it does not increase the sum of squared workloads. + Existing assignments never appear in pairs or candidate sets. + """ + chosen = defaultdict(set) + for pid, eid in pairs: + chosen[pid].add(eid) + graph = defaultdict(dict) + rng = Random(seed) + projects = sorted(chosen) + rng.shuffle(projects) + for pid in projects: + sources = sorted(chosen[pid]) + targets = sorted(set(candidates[pid]) - chosen[pid]) + rng.shuffle(sources) + rng.shuffle(targets) + for source in sources: + for target in targets: + if load[source] > load[target]: + return (pairs - {(pid, source)}) | {(pid, target)} + graph[source].setdefault(target, pid) + + state, parent = {}, {} + for root in sorted(graph): + if root in state: + continue + state[root] = 1 + stack = [(root, iter(graph[root].items()))] + while stack: + source, edges = stack[-1] + edge = next(edges, None) + if edge is None: + state[source] = 2 + stack.pop() + continue + target, pid = edge + if target not in state: + state[target] = 1 + parent[target] = (source, pid) + stack.append((target, iter(graph.get(target, {}).items()))) + elif state[target] == 1: + moves = [(pid, source, target)] + current = source + while current != target: + previous, project = parent[current] + moves.append((project, previous, current)) + current = previous + return (pairs - {(p, a) for p, a, _ in moves}) | { + (p, b) for p, _, b in moves + } + return None diff --git a/project_rates/tests/test_allocation_alternatives.py b/project_rates/tests/test_allocation_alternatives.py new file mode 100644 index 00000000..52e4b8b4 --- /dev/null +++ b/project_rates/tests/test_allocation_alternatives.py @@ -0,0 +1,58 @@ +from collections import Counter +from itertools import product + +from django.test import SimpleTestCase + +from project_rates.allocation_alternatives import alternative_pairs + + +class AllocationAlternativeTests(SimpleTestCase): + def test_matches_exhaustive_small_case_feasibility_and_balance(self): + # Enumerate all three-project case restrictions and their valid plans. + options = [{e for e in range(3) if mask & (1 << e)} for mask in range(1, 8)] + for allowed in product(options, repeat=3): + candidates = dict(enumerate(allowed)) + plans = [set(enumerate(ids)) for ids in product(*allowed)] + for fixed in [Counter(), Counter({0: 2, 1: 1})]: + for pairs in plans: + load = fixed + Counter(e for _, e in pairs) + cost = sum(load[e] ** 2 for e in range(3)) + expected = any( + other != pairs + and sum( + (fixed + Counter(e for _, e in other))[e] ** 2 + for e in range(3) + ) + <= cost + for other in plans + ) + different = alternative_pairs(pairs, candidates, load) + self.assertEqual(different is not None, expected) + if different is not None: + self.assertNotEqual(different, pairs) + self.assertEqual( + Counter(p for p, _ in different), Counter(p for p, _ in pairs) + ) + self.assertTrue(all(e in candidates[p] for p, e in different)) + self.assertLessEqual( + sum( + (fixed + Counter(e for _, e in different))[e] ** 2 + for e in range(3) + ), + cost, + ) + + def test_cycle_preserves_load_when_no_single_exchange_is_balanced(self): + pairs = {(10, 1), (20, 2), (30, 3)} + result = alternative_pairs( + pairs, {10: {1, 2}, 20: {2, 3}, 30: {3, 1}}, Counter({1: 1, 2: 1, 3: 1}) + ) + self.assertEqual(result, {(10, 2), (20, 3), (30, 1)}) + + def test_multiple_slots_stay_unique_and_fixed_members_are_never_candidates(self): + pairs = {(10, 1), (10, 2), (20, 3), (20, 4)} + candidates = {10: {1, 2, 3, 4}, 20: {1, 2, 3, 4}} + result = alternative_pairs(pairs, candidates, Counter({1: 1, 2: 1, 3: 1, 4: 1})) + self.assertIsNotNone(result) + self.assertEqual(len(result), 4) + self.assertEqual(Counter(p for p, _ in result), Counter({10: 2, 20: 2})) diff --git a/project_rates/tests/test_case_distribution.py b/project_rates/tests/test_case_distribution.py index b8570b46..f69d6428 100644 --- a/project_rates/tests/test_case_distribution.py +++ b/project_rates/tests/test_case_distribution.py @@ -1,10 +1,18 @@ """Case eligibility, universal workload and atomic project-centric assignment.""" from django.test import TestCase +from django.core import signing +from unittest.mock import patch +import time from rest_framework.test import APIClient from partner_programs.models import PartnerProgramField, PartnerProgramFieldValue -from project_rates.models import ExpertCaseScope, ProjectExpertAssignment, ProjectScore +from project_rates.models import ( + Criteria, + ExpertCaseScope, + ProjectExpertAssignment, + ProjectScore, +) from project_rates.tests.helpers import ( create_rate_program, create_rate_user, @@ -16,6 +24,93 @@ class CaseDistributionTests(TestCase): + def test_load_separates_assigned_projects_from_completed_work(self): + self.write(self.projects[:1], [self.universal, self.sber]) + create_rate_criteria(self.program) + for criterion in Criteria.objects.filter(partner_program=self.program): + ProjectScore.objects.create( + criteria=criterion, + project=self.projects[0], + user=self.universal, + value="1", + ) + result = self.plan(project_ids=[self.projects[0].pk]) + self.assertEqual(result.status_code, 200, result.data) + row = next( + r for r in result.data["load"] if r["expert_id"] == self.universal.expert.pk + ) + self.assertEqual((row["assigned_before"], row["assigned_after"]), (1, 1)) + self.assertEqual((row["before"], row["after"]), (0, 0)) + + def test_repeated_alternative_never_repeats_previous_even_with_same_seed(self): + users = [self.universal] + [ + create_rate_expert(program=self.program) for _ in range(3) + ] + ids = [u.expert.pk for u in users] + query = { + "expert_ids": ids, + "case_pools": [ + {"case_name": c, "expert_ids": ids} for c in ["Сбер", "Т-Банк"] + ], + "seed": 0, + } + response = self.plan(**query) + self.assertEqual(response.status_code, 200) + for _ in range(6): + previous = response.data + self.assertTrue(previous["can_reroll"]) + response = self.plan(**query, previous_plan_token=previous["plan_token"]) + self.assertEqual(response.status_code, 200, response.data) + previous_pairs = { + (r["project_id"], r["expert_id"]) for r in previous["assignments"] + } + current_pairs = { + (r["project_id"], r["expert_id"]) for r in response.data["assignments"] + } + self.assertNotEqual(previous_pairs, current_pairs) + self.assertEqual(len(current_pairs), 8) + self.assertLessEqual( + sum(r["after"] ** 2 for r in response.data["load"]), + sum(r["after"] ** 2 for r in previous["load"]), + ) + self.assertFalse(ProjectExpertAssignment.objects.exists()) + + def test_unique_plan_does_not_offer_alternative(self): + first = self.plan().data + self.assertFalse(first["can_reroll"]) + response = self.plan(previous_plan_token=first["plan_token"]) + self.assertEqual(response.status_code, 409) + self.assertFalse(ProjectExpertAssignment.objects.exists()) + + def test_previous_plan_rejects_tampering_foreign_program_expiry_and_changed_pools( + self, + ): + first = self.plan().data + payload = signing.loads(first["plan_token"], salt="expert-allocation") + foreign = signing.dumps( + {**payload, "program_id": self.program.pk + 1000}, salt="expert-allocation" + ) + self.assertEqual(self.plan(previous_plan_token=foreign).status_code, 400) + self.assertEqual( + self.plan(previous_plan_token=first["plan_token"] + "broken").status_code, 409 + ) + later = time.time() + 901 + with patch("django.core.signing.time.time", return_value=later): + self.assertEqual( + self.plan(previous_plan_token=first["plan_token"]).status_code, 409 + ) + self.assertEqual( + self.plan( + previous_plan_token=first["plan_token"], + case_pools=[{"case_name": "Сбер", "expert_ids": []}], + ).status_code, + 400, + ) + self.write(self.projects[:1], [self.sber]) + self.assertEqual( + self.plan(previous_plan_token=first["plan_token"]).status_code, 409 + ) + def test_seed_varies_equal_load_choices_without_changing_existing_assignments(self): self.write(self.projects[:1], [self.universal]) extra = [create_rate_expert(program=self.program) for _ in range(3)] diff --git a/project_rates/workspace.py b/project_rates/workspace.py index abf87c1f..faf35ff4 100644 --- a/project_rates/workspace.py +++ b/project_rates/workspace.py @@ -24,6 +24,7 @@ ProjectExpertAssignment, ProjectScore, ) +from project_rates.allocation_alternatives import alternative_pairs from projects.models import Collaborator from users.models import CustomUser, Expert @@ -762,7 +763,8 @@ def save_assignments(program_id, user, expert_id, data): def allocation(program, data): - experts = expert_rows(program) + all_experts = expert_rows(program) + experts = all_experts selected = set(data["expert_ids"]) if (not selected and "case_pools" not in data) or selected - {e.pk for e in experts}: raise ValidationError("Выберите принявших приглашение экспертов этой программы.") @@ -800,6 +802,7 @@ def allocation(program, data): counts = Counter(a.project_id for a in assignments) remaining = Counter(a.expert_id for a in assignments if not a.is_completed) before = remaining.copy() + assigned_before = Counter(a.expert_id for a in assignments) teams = defaultdict(set) for link in links: teams[link.project.leader_id].add(link.project_id) @@ -817,7 +820,7 @@ def allocation(program, data): raise ValidationError("Проверьте проекты и экспертов в предпросмотре.") overrides[pid] = set(ids) seed = data.get("seed") - additions, shortages = [], [] + additions, shortages, candidate_ids = [], [], {} for link in links: needed = max(0, target(program) - counts[link.project_id]) candidates = [ @@ -836,6 +839,7 @@ def allocation(program, data): else hashlib.sha256(f"{seed}:{link.project_id}:{e.pk}".encode()).digest(), ) ) + candidate_ids[link.project_id] = {e.pk for e in candidates} if link.project_id in overrides: chosen = overrides[link.project_id] if chosen - {e.pk for e in candidates} or len(chosen) > needed: @@ -866,6 +870,64 @@ def allocation(program, data): } ) rev = revision(program) + if "previous_plan_token" in data: + if overrides: + raise ValidationError("Обновите предпросмотр после изменения состава.") + try: + previous = signing.loads( + data["previous_plan_token"], salt="expert-allocation", max_age=900 + ) + except signing.BadSignature: + raise WorkspaceConflict("stale_plan", "Предпросмотр устарел. Обновите его.") + if previous.get("program_id") != program.pk: + raise ValidationError("План принадлежит другой программе.") + check_revision(program, previous["revision"]) + previous_pairs = { + (a["project_id"], a["expert_id"]) for a in previous["additions"] + } + grouped = defaultdict(set) + for pid, eid in previous_pairs: + grouped[pid].add(eid) + if ( + set(grouped) - set(candidate_ids) + or previous.get("has_shortages") + or any( + grouped[link.project_id] - candidate_ids[link.project_id] + or len(grouped[link.project_id]) + != max(0, target(program) - counts[link.project_id]) + for link in links + ) + ): + raise ValidationError( + "Параметры распределения изменились. Обновите предпросмотр." + ) + previous_load = before + Counter(eid for _, eid in previous_pairs) + different = alternative_pairs( + previous_pairs, candidate_ids, previous_load, seed or 0 + ) + if different is None: + raise WorkspaceConflict( + "no_alternative", "Другого сбалансированного варианта нет." + ) + link_by_id = {link.project_id: link for link in links} + expert_by_id = {e.pk: e for e in experts} + additions = [ + { + "project_id": pid, + "project_name": link_by_id[pid].project.name, + "expert_id": eid, + "expert_name": person_name(expert_by_id[eid].user), + "case_name": link_by_id[pid].case_name, + } + for pid, eid in sorted(different) + ] + shortages = [] + pairs = {(a["project_id"], a["expert_id"]) for a in additions} + added = Counter(eid for _, eid in pairs) + remaining = before + added + can_reroll = ( + not shortages and alternative_pairs(pairs, candidate_ids, remaining) is not None + ) token = signing.dumps( { "program_id": program.pk, @@ -885,14 +947,17 @@ def allocation(program, data): "assignments": additions, "shortages": shortages, "unavailable_projects": excluded, + "can_reroll": can_reroll, "load": [ { "expert_id": e.pk, "name": person_name(e.user), "before": before[e.pk], "after": remaining[e.pk], + "assigned_before": assigned_before[e.pk], + "assigned_after": assigned_before[e.pk] + added[e.pk], } - for e in experts + for e in all_experts ], } diff --git a/project_rates/workspace_views.py b/project_rates/workspace_views.py index e938d7fd..aa7a77b2 100644 --- a/project_rates/workspace_views.py +++ b/project_rates/workspace_views.py @@ -110,6 +110,7 @@ class AllocationWrite(serializers.Serializer): case_pools = CasePoolWrite(many=True, required=False, max_length=1000) revision = serializers.CharField(max_length=64, required=False) seed = serializers.IntegerField(min_value=0, max_value=2147483647, required=False) + previous_plan_token = serializers.CharField(max_length=500000, required=False) assignment_overrides = AllocationOverrideWrite( many=True, required=False, max_length=10000 )