Skip to content

Commit 8f8156e

Browse files
committed
Add PNW assignment gap scoring
1 parent 33424ab commit 8f8156e

5 files changed

Lines changed: 464 additions & 5 deletions

File tree

src/lib/recipes/constraints.test.ts

Lines changed: 134 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
avoidSimilarFirstNames,
3+
maximizeAssignmentGaps,
34
mustNotHaveRoles,
45
onlyMultipleGroupRounds,
56
preferLaterGroups,
@@ -14,8 +15,22 @@ const buildGroup = (id: number, groupNumber: number): Activity =>
1415
id,
1516
name: `Group ${groupNumber}`,
1617
activityCode: `333-r1-g${groupNumber}`,
17-
startTime: `2024-01-01T10:0${groupNumber}:00Z`,
18-
endTime: `2024-01-01T10:1${groupNumber}:00Z`,
18+
startTime: new Date(Date.UTC(2024, 0, 1, 10, (groupNumber - 1) * 10)).toISOString(),
19+
endTime: new Date(Date.UTC(2024, 0, 1, 10, groupNumber * 10)).toISOString(),
20+
});
21+
22+
const buildTimedActivity = (
23+
id: number,
24+
activityCode: string,
25+
startTime: string,
26+
endTime: string
27+
): Activity =>
28+
buildActivity({
29+
id,
30+
name: activityCode,
31+
activityCode,
32+
startTime,
33+
endTime,
1934
});
2035

2136
const scoreProps = (activity: Activity, activities: Activity[]) => ({
@@ -113,4 +128,121 @@ describe('recipe constraints', () => {
113128
expect(avoidSimilarFirstNames.score(props)).toBeNull();
114129
expect(avoidSimilarFirstNames.score({ ...props, activity: activities[1] })).toBe(0);
115130
});
131+
132+
it('penalizes but does not reject competitor assignments immediately after helping', () => {
133+
const staffActivity = buildTimedActivity(
134+
21,
135+
'333-r1-g1',
136+
'2024-01-01T10:00:00.000Z',
137+
'2024-01-01T10:10:00.000Z'
138+
);
139+
const immediateCompetitorActivity = buildTimedActivity(
140+
22,
141+
'333-r1-g2',
142+
'2024-01-01T10:10:00.000Z',
143+
'2024-01-01T10:20:00.000Z'
144+
);
145+
const props = {
146+
...scoreProps(immediateCompetitorActivity, [immediateCompetitorActivity]),
147+
wcif: buildWcif([staffActivity, immediateCompetitorActivity]),
148+
person: buildPerson({
149+
assignments: [{ activityId: 21, assignmentCode: 'staff-judge', stationNumber: null }],
150+
}),
151+
options: { noGapPenalty: 100, gapCapMinutes: 120 },
152+
};
153+
154+
expect(maximizeAssignmentGaps.score(props)).toBe(-100);
155+
});
156+
157+
it('scores larger positive staff-to-competitor gaps higher', () => {
158+
const staffActivity = buildTimedActivity(
159+
21,
160+
'333-r1-g1',
161+
'2024-01-01T10:00:00.000Z',
162+
'2024-01-01T10:10:00.000Z'
163+
);
164+
const shortGapActivity = buildTimedActivity(
165+
22,
166+
'333-r1-g2',
167+
'2024-01-01T10:15:00.000Z',
168+
'2024-01-01T10:25:00.000Z'
169+
);
170+
const longGapActivity = buildTimedActivity(
171+
23,
172+
'333-r1-g3',
173+
'2024-01-01T10:50:00.000Z',
174+
'2024-01-01T11:00:00.000Z'
175+
);
176+
const props = {
177+
...scoreProps(shortGapActivity, [shortGapActivity, longGapActivity]),
178+
wcif: buildWcif([staffActivity, shortGapActivity, longGapActivity]),
179+
person: buildPerson({
180+
assignments: [{ activityId: 21, assignmentCode: 'staff-runner', stationNumber: null }],
181+
}),
182+
options: { noGapPenalty: 100, gapCapMinutes: 120 },
183+
};
184+
185+
expect(maximizeAssignmentGaps.score({ ...props, activity: longGapActivity })).toBeGreaterThan(
186+
maximizeAssignmentGaps.score(props) ?? 0
187+
);
188+
});
189+
190+
it('scores competitor assignments farther from other rounds higher', () => {
191+
const otherRoundActivity = buildTimedActivity(
192+
31,
193+
'222-r1-g1',
194+
'2024-01-01T10:00:00.000Z',
195+
'2024-01-01T10:10:00.000Z'
196+
);
197+
const nearActivity = buildTimedActivity(
198+
41,
199+
'333-r1-g1',
200+
'2024-01-01T10:15:00.000Z',
201+
'2024-01-01T10:25:00.000Z'
202+
);
203+
const farActivity = buildTimedActivity(
204+
42,
205+
'333-r1-g2',
206+
'2024-01-01T11:00:00.000Z',
207+
'2024-01-01T11:10:00.000Z'
208+
);
209+
const props = {
210+
...scoreProps(nearActivity, [nearActivity, farActivity]),
211+
wcif: buildWcif([otherRoundActivity, nearActivity, farActivity]),
212+
person: buildPerson({
213+
assignments: [{ activityId: 31, assignmentCode: 'competitor', stationNumber: null }],
214+
}),
215+
options: { noGapPenalty: 100, gapCapMinutes: 120 },
216+
};
217+
218+
expect(maximizeAssignmentGaps.score({ ...props, activity: farActivity })).toBeGreaterThan(
219+
maximizeAssignmentGaps.score(props) ?? 0
220+
);
221+
});
222+
223+
it('penalizes staff assignments immediately before future competitor assignments', () => {
224+
const immediateStaffActivity = buildTimedActivity(
225+
51,
226+
'333-r1-g1',
227+
'2024-01-01T10:00:00.000Z',
228+
'2024-01-01T10:10:00.000Z'
229+
);
230+
const futureCompetitorActivity = buildTimedActivity(
231+
61,
232+
'222-r1-g1',
233+
'2024-01-01T10:10:00.000Z',
234+
'2024-01-01T10:20:00.000Z'
235+
);
236+
const props = {
237+
...scoreProps(immediateStaffActivity, [immediateStaffActivity]),
238+
assignmentCode: 'staff-judge',
239+
wcif: buildWcif([immediateStaffActivity, futureCompetitorActivity]),
240+
person: buildPerson({
241+
assignments: [{ activityId: 61, assignmentCode: 'competitor', stationNumber: null }],
242+
}),
243+
options: { noGapPenalty: 100, gapCapMinutes: 120 },
244+
};
245+
246+
expect(maximizeAssignmentGaps.score(props)).toBe(-100);
247+
});
116248
});

src/lib/recipes/constraints.ts

Lines changed: 143 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1-
import { parseActivityCode, type Activity } from '@wca/helpers';
1+
import { parseActivityCode } from '@wca/helpers';
22
import { roomByActivity, type Constraint } from 'wca-group-generators';
33

4+
type Activity = Parameters<Constraint['score']>[0]['activity'];
5+
46
const DEFAULT_KEY_STAFF_ROLES = ['delegate', 'trainee-delegate', 'organizer'];
7+
const DEFAULT_GAP_CAP_MINUTES = 120;
8+
const DEFAULT_NO_GAP_PENALTY = 100;
59

610
const isStaffAssignmentCode = (assignmentCode: string) => assignmentCode.startsWith('staff-');
11+
const isCompetitorAssignmentCode = (assignmentCode: string) => assignmentCode === 'competitor';
712

813
const firstNameFor = (name: string) => name.trim().split(/\s+/)[0]?.toLowerCase() ?? '';
914

@@ -53,6 +58,103 @@ const earliestStaffActivityForPerson = (
5358
return (staffActivities as Activity[]).sort((a, b) => a.startTime.localeCompare(b.startTime))[0];
5459
};
5560

61+
const minutesBetween = (before: Activity, after: Activity) =>
62+
(new Date(after.startTime).getTime() - new Date(before.endTime).getTime()) / 60000;
63+
64+
const gapScore = (minutes: number, gapCapMinutes: number, noGapPenalty: number) =>
65+
minutes > 0 ? Math.min(minutes, gapCapMinutes) / gapCapMinutes : -noGapPenalty;
66+
67+
const activitiesOverlap = (first: Activity, second: Activity) =>
68+
new Date(first.startTime) < new Date(second.endTime) &&
69+
new Date(second.startTime) < new Date(first.endTime);
70+
71+
const assignmentActivitiesForPerson = (
72+
wcif: Parameters<Constraint['score']>[0]['wcif'],
73+
person: Parameters<Constraint['score']>[0]['person'],
74+
assignmentTest: (assignmentCode: string) => boolean
75+
) => {
76+
const allActivities = wcif.schedule.venues.flatMap((venue) =>
77+
venue.rooms.flatMap((room) =>
78+
room.activities.flatMap((activity) => [activity, ...(activity.childActivities ?? [])])
79+
)
80+
);
81+
82+
return (
83+
person.assignments
84+
?.filter((assignment) => assignmentTest(assignment.assignmentCode))
85+
.map((assignment) => allActivities.find((activity) => activity.id === assignment.activityId))
86+
.filter(Boolean) as Activity[]
87+
).sort((a, b) => a.startTime.localeCompare(b.startTime));
88+
};
89+
90+
const nearestAssignmentGapScore = (
91+
activity: Activity,
92+
activities: Activity[],
93+
gapCapMinutes: number,
94+
noGapPenalty: number
95+
) => {
96+
if (!activities.length) {
97+
return 0;
98+
}
99+
100+
return Math.min(
101+
...activities.map((otherActivity) => {
102+
if (new Date(otherActivity.endTime) <= new Date(activity.startTime)) {
103+
return gapScore(minutesBetween(otherActivity, activity), gapCapMinutes, noGapPenalty);
104+
}
105+
106+
if (new Date(activity.endTime) <= new Date(otherActivity.startTime)) {
107+
return gapScore(minutesBetween(activity, otherActivity), gapCapMinutes, noGapPenalty);
108+
}
109+
110+
return -noGapPenalty;
111+
})
112+
);
113+
};
114+
115+
const staffBeforeCompetitorGapScore = (
116+
competitorActivity: Activity,
117+
staffActivities: Activity[],
118+
gapCapMinutes: number,
119+
noGapPenalty: number
120+
) => {
121+
const priorStaffActivities = staffActivities.filter(
122+
(staffActivity) =>
123+
new Date(staffActivity.startTime) < new Date(competitorActivity.startTime) ||
124+
activitiesOverlap(staffActivity, competitorActivity)
125+
);
126+
127+
return nearestAssignmentGapScore(
128+
competitorActivity,
129+
priorStaffActivities,
130+
gapCapMinutes,
131+
noGapPenalty
132+
);
133+
};
134+
135+
const staffBeforeFutureCompetitorGapScore = (
136+
staffActivity: Activity,
137+
competitorActivities: Activity[],
138+
gapCapMinutes: number,
139+
noGapPenalty: number
140+
) => {
141+
const futureCompetitorActivities = competitorActivities.filter(
142+
(competitorActivity) =>
143+
new Date(staffActivity.startTime) < new Date(competitorActivity.startTime) ||
144+
activitiesOverlap(staffActivity, competitorActivity)
145+
);
146+
147+
if (!futureCompetitorActivities.length) {
148+
return 0;
149+
}
150+
151+
return Math.min(
152+
...futureCompetitorActivities.map((competitorActivity) =>
153+
gapScore(minutesBetween(staffActivity, competitorActivity), gapCapMinutes, noGapPenalty)
154+
)
155+
);
156+
};
157+
56158
export const shouldHelpAfterCompeting: Constraint = {
57159
name: 'Should Help After Competing',
58160
score: ({ wcif, activities, activity, person }) => {
@@ -151,10 +253,50 @@ export const avoidSimilarFirstNames: Constraint = {
151253
},
152254
};
153255

256+
export const maximizeAssignmentGaps: Constraint = {
257+
name: 'Maximize Assignment Gaps',
258+
score: ({ wcif, activity, assignmentCode, person, options }) => {
259+
const scoredPerson = wcif.persons.find(
260+
(candidate) => candidate.registrantId === person.registrantId
261+
) ?? person;
262+
const gapCapMinutes = (options?.gapCapMinutes as number | undefined) ?? DEFAULT_GAP_CAP_MINUTES;
263+
const noGapPenalty = (options?.noGapPenalty as number | undefined) ?? DEFAULT_NO_GAP_PENALTY;
264+
const staffActivities = assignmentActivitiesForPerson(
265+
wcif,
266+
scoredPerson,
267+
isStaffAssignmentCode
268+
);
269+
const competitorActivities = assignmentActivitiesForPerson(
270+
wcif,
271+
scoredPerson,
272+
isCompetitorAssignmentCode
273+
);
274+
275+
if (isCompetitorAssignmentCode(assignmentCode)) {
276+
return (
277+
staffBeforeCompetitorGapScore(activity, staffActivities, gapCapMinutes, noGapPenalty) +
278+
nearestAssignmentGapScore(activity, competitorActivities, gapCapMinutes, noGapPenalty)
279+
);
280+
}
281+
282+
if (isStaffAssignmentCode(assignmentCode)) {
283+
return staffBeforeFutureCompetitorGapScore(
284+
activity,
285+
competitorActivities,
286+
gapCapMinutes,
287+
noGapPenalty
288+
);
289+
}
290+
291+
return 0;
292+
},
293+
};
294+
154295
export const RecipeConstraints: Record<string, Constraint> = {
155296
shouldHelpAfterCompeting,
156297
preferLaterGroups,
157298
mustNotHaveRoles,
158299
onlyMultipleGroupRounds,
159300
avoidSimilarFirstNames,
301+
maximizeAssignmentGaps,
160302
};

src/lib/recipes/steps/pnw.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { StepDefinition } from '../types';
22

33
const PNW_BALANCED_GROUP_SIZE_WEIGHT = 20;
4+
const PNW_ASSIGNMENT_GAP_WEIGHT = 10;
5+
const PNW_ASSIGNMENT_GAP_OPTIONS = {
6+
gapCapMinutes: 120,
7+
noGapPenalty: 100,
8+
};
49
const PNW_GLOBAL_SCORE = {
510
maxPasses: 1,
611
maxEvaluations: 250,
@@ -56,6 +61,11 @@ export const GenerateCompetitorAssignmentsForStaff: StepDefinition = {
5661
constraint: 'maximizeBreaks',
5762
weight: 10,
5863
},
64+
{
65+
constraint: 'maximizeAssignmentGaps',
66+
weight: PNW_ASSIGNMENT_GAP_WEIGHT,
67+
options: PNW_ASSIGNMENT_GAP_OPTIONS,
68+
},
5969
{
6070
constraint: 'assignmentsNextToEachother',
6171
weight: 2,
@@ -122,6 +132,11 @@ export const GenerateCompetitorAssignmentsForDelegatesAndOrganizers: StepDefinit
122132
constraint: 'preferLaterGroups',
123133
weight: 20,
124134
},
135+
{
136+
constraint: 'maximizeAssignmentGaps',
137+
weight: PNW_ASSIGNMENT_GAP_WEIGHT,
138+
options: PNW_ASSIGNMENT_GAP_OPTIONS,
139+
},
125140
{
126141
constraint: 'balancedGroupNumberSize',
127142
weight: 15,
@@ -182,6 +197,11 @@ export const GenerateCompetitorAssignmentsForFirstTimers: StepDefinition = {
182197
constraint: 'mustNotHaveOtherAssignments',
183198
weight: 1,
184199
},
200+
{
201+
constraint: 'maximizeAssignmentGaps',
202+
weight: PNW_ASSIGNMENT_GAP_WEIGHT,
203+
options: PNW_ASSIGNMENT_GAP_OPTIONS,
204+
},
185205
{
186206
constraint: 'avoidConflictingNames',
187207
weight: 1,
@@ -235,6 +255,11 @@ export const GenerateCompetitorAssignments: StepDefinition = {
235255
constraint: 'mustNotHaveOtherAssignments',
236256
weight: 1,
237257
},
258+
{
259+
constraint: 'maximizeAssignmentGaps',
260+
weight: PNW_ASSIGNMENT_GAP_WEIGHT,
261+
options: PNW_ASSIGNMENT_GAP_OPTIONS,
262+
},
238263
{
239264
constraint: 'avoidConflictingNames',
240265
weight: 10,
@@ -311,6 +336,11 @@ export const GenerateJudgeAssignmentsForCompetitors: StepDefinition = {
311336
constraint: 'sameStageAsOtherAssignments',
312337
weight: 5,
313338
},
339+
{
340+
constraint: 'maximizeAssignmentGaps',
341+
weight: PNW_ASSIGNMENT_GAP_WEIGHT,
342+
options: PNW_ASSIGNMENT_GAP_OPTIONS,
343+
},
314344
{
315345
constraint: 'shouldFollowCompetitorAssignment',
316346
weight: 10,

0 commit comments

Comments
 (0)