Skip to content

Commit f05fe2b

Browse files
committed
WIP: a new rototron engine
This is not perfect, or probably even 10 years perfect, but I think it's a big improvement, and should be pretty easy to finish delivering. The old rototron worked like this: every week has a Weeks Since Epoch number, WSE every rotor has a list of workers for each rotor: candidate-list = all workers for rotor, deterministically sorted for each day in the week being scheduled: CANDIDATE: until scheduled or worker list exhausted: candidate = delete candidate-list[ WSE % candidate-list.size ] if candidate is unavailable or on leave next CANDIDATE GOOD: Because the WSE number is stable within a week, the first choice for any day in the week is the same, as is the second. That means a week will favor being one person. If that person is not available for 2 days, the same person is likely to be fallback. BAD: Not too much prevents week 1's first choice from being week 2's second choice, meaning the odds of getting two weeks in a row, when somebody is out for a week, are poorly defined. (Insert link to number theory about coprimality here.) BAD: Rotors are entirely unaware of each other, so nothing much prevents someone being assigned three rotations in a week. The new rototron (not fully implemented yet) works like this: every week has a Weeks Since Epoch number, WSE every rotor has a map of workers to fixed preference¹ every rotor-worker has a level of rotor-fatigue² ROTOR: for each rotor: for every worker in worker map: if worker has any other rotor assignment in this date range: mark worker as one level less preferable for every level of preferability: select set of workers available on the most days³ for each worker in this set: select set of workers with the lowest rotor-fatigue candidate = set[ WSE % set.size ] schedule candidate for all available dates in range redo ROTOR with unscheduled days 1: Preference is a number. The lower the preference number, the more likely someone is to be scheduled. Thing of preference:1 as being "first string". For most rotors, the usual plumbers will all have preference 1, and I (rjbs) might have preference 3. For escalation, a full-time escalation handler would have preference 1, and most everyone else preference 2. 2: I bet we'll want to adjust how rotor fatigue works, but right now your level of rotor fatigue is the number of days you were on rotation in the last 90 days. This builds up every time you're on duty, and fades off over time. 3: If everybody is available all week, the set is everybody. If two people are out one day, it's everybody but those two. If everybody is out exactly one day, it's everybody again. Rotor fatigue probably has two problems. First, if a rotation is done by one person for 90d and then a second person is added to rotation with the same preference, they'd be on rotation 45d before getting a break. be on rotation for 45d straight. Second, it's per-rotor, which may or may not be noticeable over time. Still, it should be easy to adjust its behavior over time, and this was easy to implement. The next likely steps are: 1. Replace the availability checker callback with something acting kind of or exactly like the AvailabilityChecker class in Rototron1. 2. Treat the rototron's own schedule (persisted in SQLite or something) as canonical, with the JMAP duty roster calendar treated as a published copy.
1 parent 9cd474e commit f05fe2b

File tree

2 files changed

+377
-0
lines changed

2 files changed

+377
-0
lines changed

lib/Synergy/Rototron2.pm

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
use v5.28.0;
2+
use warnings;
3+
package Synergy::Rototron2;
4+
5+
use Moose;
6+
use experimental qw(lexical_subs signatures);
7+
8+
use JSON::MaybeXS;
9+
use Path::Tiny;
10+
11+
use DateTime ();
12+
13+
# This tiny bit of code and comment brought in from Rototron1. It's still
14+
# useful to know the week-since-epoch of a date, for a rotating random
15+
# tie-breaker. The big goal in Rototron2 is just to get to ties a lot less
16+
# often.
17+
#
18+
# Let's talk about the epoch here. It's the first Monday before this program
19+
# existed. To compute who handles what in a given week, we compute the week
20+
# number, with this week as week zero. Everything else is a rotation through
21+
# that.
22+
#
23+
# We may very well change this later. -- rjbs, 2019-01-30
24+
my $EPOCH = 1548633600;
25+
my sub week_of_date ($dt) { int( ($dt->epoch - $EPOCH) / (86400 * 7) ) }
26+
27+
package Synergy::Rototron2::Rotor {
28+
use Moose;
29+
use experimental qw(lexical_subs signatures);
30+
31+
use List::Util qw(any max min);
32+
33+
use Synergy::Logger '$Logger';
34+
35+
has ident => (is => 'ro', isa => 'Str', required => 1);
36+
has name => (is => 'ro', isa => 'Str', required => 1);
37+
38+
# a map from { date => username }
39+
has schedule => (
40+
reader => '_schedule',
41+
default => sub { {} },
42+
traits => [ 'Hash' ],
43+
);
44+
45+
sub person_scheduled_on ($self, $dt) {
46+
return $self->_schedule->{$dt->ymd};
47+
}
48+
49+
sub fatigue_for ($self, $person) {
50+
my $schedule = $self->_schedule;
51+
52+
# This variable is here for testing. -- rjbs, 2022-09-17
53+
my $min = $Synergy::Rototron2::FATIGUE_BACKSTOP
54+
// DateTime->now(time_zone => 'UTC')->subtract(days => 90)->ymd;
55+
my @keys = grep {; $_ gt $min } keys %$schedule;
56+
57+
return scalar grep {; $schedule->{$_} eq $person } @keys;
58+
}
59+
60+
sub include_weekends { 0 }
61+
62+
# a user has "availability_on($date)" method
63+
# {
64+
# username => $preference,
65+
# ...
66+
# }
67+
#
68+
# a person's fatigue is the count of keys in the schedule where the key is a
69+
# date in the last 90d and the value is the username
70+
has people_preferences => (
71+
required => 1,
72+
traits => [ 'Hash' ],
73+
handles => { people_preferences => 'elements' },
74+
);
75+
76+
sub _schedule_dates ($self, $input_dates, $arg) {
77+
local $Logger = $Logger->proxy({
78+
proxy_prefix => "rotor " . $self->ident . ": ",
79+
});
80+
81+
$Logger->log([
82+
"scheduling from %s to %s",
83+
$input_dates->[0]->ymd,
84+
$input_dates->[-1]->ymd,
85+
]);
86+
87+
my @dates = @$input_dates;
88+
unless ($self->include_weekends) {
89+
@dates = grep {; $_->day_of_week < 6 } @dates;
90+
}
91+
92+
my $other_rotors = $arg->{other_rotors};
93+
my $availability_checker = $arg->{availability_checker};
94+
95+
my %person_preferences = $self->people_preferences;
96+
97+
my %has_other_duty;
98+
for my $date (@dates) {
99+
for my $rotor ($arg->{other_rotors}->@*) {
100+
if (my $username = $rotor->person_scheduled_on($date)) {
101+
$has_other_duty{$username} = 1;
102+
}
103+
}
104+
}
105+
106+
my %preference_group;
107+
for my $username (keys %person_preferences) {
108+
my $level = $person_preferences{$username};
109+
110+
if ($has_other_duty{$username}) {
111+
# if any user in the group has duty on any other rotor, locally
112+
# increase their preference number
113+
$level++;
114+
115+
$Logger->log([
116+
"bumping %s to preference %s, already on a rotation this period",
117+
$username,
118+
$level,
119+
]);
120+
}
121+
122+
$preference_group{$level} //= [];
123+
push $preference_group{$level}->@*, $username;
124+
}
125+
126+
LEVEL: for my $level (sort {; $a <=> $b } keys %preference_group) {
127+
my @people = $preference_group{$level}->@*;
128+
129+
$Logger->log([
130+
"%s, level %s, people: %s",
131+
$self->ident,
132+
$level,
133+
\@people
134+
]);
135+
136+
my %days_available;
137+
my %daycount_available;
138+
for my $person (@people) {
139+
my @days = grep {; $availability_checker->($person, $_) } @dates;
140+
141+
$days_available{$person} = \@days;
142+
$daycount_available{$person} = @days;
143+
}
144+
145+
$Logger->log([ 'availability: %s', \%daycount_available ]);
146+
147+
my ($most_days) = max values %daycount_available;
148+
149+
unless ($most_days) {
150+
$Logger->log([
151+
'nobody at level %s available, will try next level',
152+
$level,
153+
]);
154+
155+
next LEVEL;
156+
}
157+
158+
my @candidates = sort grep {; $daycount_available{$_} == $most_days }
159+
keys %daycount_available;
160+
161+
$Logger->log([ 'most available candidates: %s', \@candidates ]);
162+
163+
# if set size > 1
164+
# pick users with minimum fatigue
165+
166+
my %fatigue_for;
167+
for my $person (@candidates) {
168+
$fatigue_for{$person} = $self->fatigue_for($person);
169+
}
170+
171+
$Logger->log([ 'fatigue levels: %s', \%fatigue_for ]);
172+
173+
my ($least_fatigue) = min values %fatigue_for;
174+
175+
@candidates = sort grep {; $fatigue_for{$_} == $least_fatigue }
176+
keys %daycount_available;
177+
178+
$Logger->log([ 'least fatigued candidates: %s', \@candidates ]);
179+
180+
# Here, we assume that all dates in range have the same week.
181+
# -- rjbs, 2022-09-17
182+
my $winner = $candidates[ week_of_date($dates[0]) % @candidates ];
183+
184+
if ($winner) {
185+
my @can_work = $days_available{ $winner}->@*;
186+
$Logger->log([
187+
'and the winner is: %s who will work %s',
188+
$winner,
189+
[ map {; $_->ymd } @can_work ],
190+
]);
191+
192+
$self->_commit_user($winner, \@can_work);
193+
194+
if (@dates != @can_work) {
195+
my %scheduled = map {; $_ => 1 } @can_work;
196+
my @unscheduled = grep {; ! $scheduled{$_} } @dates;
197+
198+
$Logger->log([
199+
"couldn't schedule all days, so will try again on: %s",
200+
[ map {; $_->ymd } @unscheduled ],
201+
]);
202+
203+
return $self->_schedule_dates([ grep {; ! $scheduled{$_} } @dates ], $arg);
204+
}
205+
206+
# We did it! All dates scheduled.
207+
return;
208+
}
209+
210+
$Logger->log([ "no success at level %s", $level ]);
211+
}
212+
213+
$Logger->log("failed to schedule!");
214+
215+
return;
216+
}
217+
218+
sub _commit_user ($self, $person, $dates) {
219+
my $schedule = $self->_schedule;
220+
221+
for my $ymd (map {; $_->ymd } @$dates) {
222+
if (my $already = $schedule->{ $ymd }) {
223+
my $ident = $self->ident;
224+
confess "rotor $ident already scheduled on $ymd for $already";
225+
}
226+
227+
$self->_schedule->{ $ymd } = $person;
228+
}
229+
230+
return;
231+
}
232+
233+
sub _uncommit_dates ($self, $dates) {
234+
my $schedule = $self->_schedule;
235+
236+
for my $ymd (map {; $_->ymd } @$dates) {
237+
delete $schedule->{ $ymd };
238+
}
239+
240+
return;
241+
}
242+
243+
__PACKAGE__->meta->make_immutable;
244+
no Moose;
245+
}
246+
247+
has rotors => (
248+
isa => 'ArrayRef',
249+
required => 1,
250+
traits => [ 'Array' ],
251+
handles => { rotors => 'elements' },
252+
);
253+
254+
# TODO Replace this with something like (or exactly) the Rototron1 availability
255+
# checker! -- rjbs, 2022-09-17
256+
has availability_checker => (
257+
is => 'ro',
258+
required => 1,
259+
);
260+
261+
sub schedule_range ($self, $start_date, $and_next) {
262+
my %rotors = map {; $_->ident, $_ } $self->rotors;
263+
264+
my @dates = (
265+
$start_date,
266+
map {; $start_date->clone->add(days => $_) } (1 .. $and_next),
267+
);
268+
269+
# This "sort" is bogus, nothing should actually matter based on naming of
270+
# things, but for now, trying to keep it semi-simple and definitely
271+
# deterministic. -- rjbs, 2022-09-17
272+
for my $ident (sort keys %rotors) {
273+
# This "delete local" is less bogus, but deserves a raised eyebrow. Each
274+
# rotor wants to be able to say "Aiden is already doing the dishes, let
275+
# them skip taking out the trash this week" when possible, so knowing the
276+
# other rotors is useful. Probably this should be dumped into some kind of
277+
# availability helper, but I dunno yet. -- rjbs, 2022-09-17
278+
my $rotor = delete local $rotors{$ident};
279+
$rotor->_schedule_dates(\@dates, {
280+
other_rotors => [ values %rotors ],
281+
availability_checker => $self->availability_checker,
282+
});
283+
}
284+
285+
return;
286+
}
287+
288+
__PACKAGE__->meta->make_immutable;
289+
no Moose;
290+
1;

t/rototron2.t

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
#!perl
2+
use v5.28.0;
3+
use warnings;
4+
use experimental 'signatures';
5+
6+
use lib 'lib', 't/lib';
7+
8+
use Synergy::Rototron2;
9+
use Test::More;
10+
11+
use Synergy::Logger::Test '$Logger';
12+
13+
my $ezra_rotor = Synergy::Rototron2::Rotor->new({
14+
name => 'be ezra',
15+
ident => 'ezra',
16+
people_preferences => {
17+
ezra => 1,
18+
whitney => 99,
19+
}
20+
});
21+
22+
my $lunch_rotor = Synergy::Rototron2::Rotor->new({
23+
name => 'order lunch',
24+
ident => 'lunch',
25+
people_preferences => {
26+
hayden => 1, # almost always this person!
27+
bailey => 2,
28+
dana => 2,
29+
finley => 2,
30+
kim => 2,
31+
}
32+
});
33+
34+
my $sweep_rotor = Synergy::Rototron2::Rotor->new({
35+
name => 'sweep the floor',
36+
ident => 'sweep',
37+
people_preferences => {
38+
aiden => 1,
39+
bailey => 1,
40+
chaz => 1,
41+
dana => 1,
42+
ezra => 2, # fallback sweepist
43+
}
44+
});
45+
46+
my $trash_rotor = Synergy::Rototron2::Rotor->new({
47+
name => 'take out the trash',
48+
ident => 'trash',
49+
people_preferences => {
50+
aiden => 2, # fallback trash taker-outer
51+
chaz => 1,
52+
ezra => 1,
53+
finley => 1,
54+
grey => 1,
55+
}
56+
});
57+
58+
my $rototron = Synergy::Rototron2->new({
59+
rotors => [ $ezra_rotor, $sweep_rotor, $trash_rotor, $lunch_rotor ],
60+
availability_checker => sub ($person, $dt) {
61+
# Happy Birthday, Ezra!
62+
return if $person eq 'ezra' && $dt->ymd eq '2022-01-11';
63+
64+
return 1;
65+
},
66+
});
67+
68+
my sub ymd ($year, $month, $day) {
69+
return DateTime->new(
70+
time_zone => 'UTC',
71+
year => $year,
72+
month => $month,
73+
day => $day,
74+
);
75+
}
76+
77+
my $first_monday = ymd(2022, 1, 3);
78+
79+
$Synergy::Rototron2::FATIGUE_BACKSTOP = '2022-01-01';
80+
81+
# Basic setup, everybody is bright eyed and bushy tailed.
82+
$rototron->schedule_range($first_monday, 6);
83+
84+
# Now we should see fatigue.
85+
$rototron->schedule_range($first_monday->clone->add(days => 7), 6);
86+
87+
done_testing;

0 commit comments

Comments
 (0)