Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions project_rates/allocation_alternatives.py
Original file line number Diff line number Diff line change
@@ -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
58 changes: 58 additions & 0 deletions project_rates/tests/test_allocation_alternatives.py
Original file line number Diff line number Diff line change
@@ -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}))
97 changes: 96 additions & 1 deletion project_rates/tests/test_case_distribution.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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)]
Expand Down
71 changes: 68 additions & 3 deletions project_rates/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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("Выберите принявших приглашение экспертов этой программы.")
Expand Down Expand Up @@ -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)
Expand All @@ -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 = [
Expand All @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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
],
}

Expand Down
1 change: 1 addition & 0 deletions project_rates/workspace_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
Loading