1 | require 'set' |
||
2 | require 'pp' |
||
3 | |||
4 | module Scheduling |
||
5 | # Represents a particular schedule (i.e. assignment of sessions to timeslots) for the purpose of annealing. |
||
6 | # Scores the schedule and returns nearby variations. |
||
7 | # |
||
8 | class Schedule |
||
9 | def initialize(event) |
||
10 | @ctx = Scheduling::Context.new(event) |
||
11 | |||
12 | @slots_by_session = {} # map from session to timeslot |
||
13 | @sessions_by_slot = empty_array_hash # map from timeslot to array of sessions |
||
14 | @schedulable_sessions = Set.new # sessions we’re allowed to move around |
||
15 | |||
16 | fill_schedule!( |
||
17 | event.sessions |
||
18 | .includes(:timeslot) |
||
19 | .to_a) |
||
20 | end |
||
21 | |||
22 | def initialize_copy(source) # deep copy; called by dup |
||
23 | @slots_by_session = @slots_by_session.dup |
||
24 | @sessions_by_slot = empty_array_hash |
||
25 | @slots_by_session.each { |session, slot| @sessions_by_slot[slot] << session } |
||
26 | end |
||
27 | |||
28 | def slot_id_for(session_id) |
||
29 | @slots_by_session[session_id]&.id |
||
0 ignored issues
–
show
Bug
introduced
by
![]() |
|||
30 | end |
||
31 | |||
32 | private |
||
33 | |||
34 | attr_reader :ctx |
||
35 | |||
36 | class EmptyRoom # placeholder for empty room, which can be swapped with real sessions |
||
37 | def to_s |
||
38 | "<< open >>" |
||
39 | end |
||
40 | end |
||
41 | |||
42 | public |
||
43 | |||
44 | # ------------ Scoring ------------ |
||
45 | |||
46 | # This is the metric we're trying to minimize. It's called "energy" in simulated annealing by analogy to the |
||
47 | # real-life physical process of annealing, in which a cooling material minimizes the energy of its chemical bonds. |
||
48 | # |
||
49 | def energy |
||
50 | (1 - attendance_score) + |
||
51 | (1 - presenter_score) * ctx.people.size # Multiplier b/c presenter double-bookings trump attendance prefs |
||
52 | end |
||
53 | |||
54 | # Average attendee satisfaction with the schedule, measured as the estimated fraction of the total value |
||
55 | # of all their desired sessions that this schedule will give them. (1 = perfect schedule for everyone; |
||
56 | # 0 = no attendees can attend any desired sessions at all.) |
||
57 | # |
||
58 | def attendance_score |
||
59 | score(:attending) |
||
60 | end |
||
61 | |||
62 | # Average ability of presenter to present all the sessions they signed up for. For an optimized schedule, |
||
63 | # this should always be 1 unless a presenter created an inherent conflict (e.g. signing up to present more |
||
64 | # sessions than there are timeslots.) |
||
65 | # |
||
66 | def presenter_score |
||
67 | score(:presenting) |
||
68 | end |
||
69 | |||
70 | # Gives bounds on the possible range of attendance_score. Returns [best, random, worst] scores. |
||
71 | def attendance_score_metrics |
||
72 | count = ctx.people.size.to_f |
||
73 | %i( |
||
74 | worst_possible_score |
||
75 | random_schedule_score |
||
76 | best_possible_score |
||
77 | ).map do |metric| |
||
78 | ctx.people.sum { |p| p.attending.send(metric) } / count |
||
79 | end |
||
80 | end |
||
81 | |||
82 | private |
||
83 | |||
84 | def score(role) |
||
85 | count = ctx.people.size |
||
86 | score = ctx.people.sum do |person| |
||
87 | person.send(role).score(self) |
||
88 | end |
||
89 | |||
90 | if count == 0 |
||
91 | 1 |
||
92 | else |
||
93 | score / count |
||
94 | end |
||
95 | end |
||
96 | |||
97 | public |
||
98 | |||
99 | # ------------ State space traversal ------------ |
||
100 | |||
101 | # Fill the schedule, using existing timeslots if any are present, assigning the remaining sessions |
||
102 | # randomly, then adding empty placeholders to openings in the schedule. |
||
103 | # |
||
104 | def fill_schedule!(sessions) |
||
105 | sessions.each do |session| |
||
106 | schedule(session.id, session.timeslot) if session.timeslot |
||
107 | end |
||
108 | |||
109 | @schedulable_sessions += sessions.reject(&:manually_scheduled).map(&:id) |
||
110 | |||
111 | unassigned = sessions.reject(&:timeslot).reject(&:manually_scheduled) |
||
112 | ctx.timeslots.each do |slot| |
||
113 | opening_count = ctx.room_count - @sessions_by_slot[slot].size |
||
114 | slot_sessions = (unassigned.slice!(0, opening_count) || []).map(&:id) |
||
115 | while slot_sessions.size < opening_count |
||
116 | placeholder = EmptyRoom.new |
||
117 | slot_sessions << placeholder |
||
118 | @schedulable_sessions << placeholder |
||
119 | end |
||
120 | slot_sessions.each { |session| schedule(session, slot) } |
||
121 | end |
||
122 | unless unassigned.empty? |
||
123 | raise "Not enough room / slot combinations! There are #{sessions.size} sessions, but only #{ctx.timeslots.size} times slots * #{ctx.room_count} rooms = #{ctx.timeslots.size * ctx.room_count} combinations." |
||
124 | end |
||
125 | |||
126 | @schedulable_timeslots = ctx.timeslots.select do |slot| |
||
127 | schedulable_sessions_in_slot(slot).any? |
||
128 | end |
||
129 | puts "#{ctx.timeslots.size - @schedulable_timeslots.size} timeslot(s) have no movable sessions" |
||
130 | end |
||
131 | |||
132 | # Return a similar but slightly different schedule. Used by the annealing algorithm to explore |
||
133 | # the scheduling space. |
||
134 | # |
||
135 | def random_neighbor |
||
136 | dup.random_neighbor! |
||
137 | end |
||
138 | |||
139 | def random_neighbor! |
||
140 | # Choose 2 or more random sessions in distinct time slots |
||
141 | k = 1.0 / (@schedulable_timeslots.size - 1) |
||
142 | cycle_size = 1 + ((1 + k) / (rand + k)).floor |
||
143 | slot_cycle = @schedulable_timeslots.shuffle.slice(0, cycle_size) |
||
144 | |||
145 | # Rotate their assignments |
||
146 | slot_cycle.each_with_index do |old_slot, i| |
||
147 | new_slot = slot_cycle[(i+1) % slot_cycle.size] |
||
148 | schedule random_schedulable_session(old_slot), new_slot |
||
149 | end |
||
150 | |||
151 | self |
||
152 | end |
||
153 | |||
154 | private |
||
155 | |||
156 | def schedulable_sessions_in_slot(slot) |
||
157 | @schedulable_sessions & @sessions_by_slot[slot] |
||
158 | end |
||
159 | |||
160 | def random_schedulable_session(slot) |
||
161 | schedulable_sessions_in_slot(slot).to_a.sample |
||
162 | end |
||
163 | |||
164 | def schedule(session, new_slot) |
||
165 | old_slot = @slots_by_session[session] |
||
166 | @sessions_by_slot[old_slot].delete(session) if old_slot |
||
167 | |||
168 | @slots_by_session[session] = new_slot |
||
169 | @sessions_by_slot[new_slot] << session if new_slot |
||
170 | end |
||
171 | |||
172 | public |
||
173 | |||
174 | # ------------ Managing results ------------ |
||
175 | |||
176 | def save! |
||
177 | Session.transaction do |
||
178 | ctx.timeslots.sort_by(&:starts_at).each do |slot| |
||
179 | puts slot |
||
180 | sessions = Session.find( |
||
181 | schedulable_sessions_in_slot(slot).reject { |s| EmptyRoom === s }) |
||
182 | sessions.sort_by { |s| -s.attendance_count }.each do |session| |
||
183 | puts " #{session.id} #{session.title}" + |
||
184 | " (#{session.attendances.count} vote(s) / #{'%1.1f' % session.estimated_interest} interest)" |
||
185 | session.timeslot = slot |
||
186 | session.room = nil # Rooms have to be reassigned after rearranging timeslots |
||
187 | session.save! |
||
188 | end |
||
189 | end |
||
190 | end |
||
191 | end |
||
192 | |||
193 | def dump_presenter_conflicts |
||
194 | unhappy_presenters = ctx.people.select do |person| |
||
195 | person.presenting.score(self) < 1 |
||
196 | end |
||
197 | |||
198 | if unhappy_presenters.any? |
||
199 | puts |
||
200 | puts "WARNING! The following presenters have problems with this schedule:" |
||
201 | unhappy_presenters.each do |person| |
||
202 | puts " #{person.id} #{Participant.find(person.id).name}" |
||
203 | end |
||
204 | puts |
||
205 | end |
||
206 | end |
||
207 | |||
208 | def inspect |
||
209 | worst_score, random_score, best_score = self.attendance_score_metrics |
||
210 | attendance_score_scaled = (attendance_score - random_score) / (best_score - random_score) |
||
211 | |||
212 | s = "Schedule\n" |
||
213 | s << "| quality vs. random = #{format_percent attendance_score_scaled} (0% is no better than random; 100% is unachievable; > 50% is good)\n" |
||
214 | s << "| absolute satisfaction = #{format_percent attendance_score} of impossibly perfect schedule\n" |
||
215 | s << "| presenter score = #{format_percent presenter_score} (if < 100 then presenters have conflicts)\n" |
||
216 | ctx.timeslots.each do |slot| |
||
217 | s << " #{slot}: #{@sessions_by_slot[slot].join(' ')}\n" |
||
218 | end |
||
219 | s |
||
220 | end |
||
221 | |||
222 | def inspect_bounds |
||
223 | worst_score, random_score, best_score = self.attendance_score_metrics |
||
224 | s = "If we could give everyone a different schedule optimized just for them,\n" |
||
225 | s << "the schedule quality could be...\n" |
||
226 | s << "\n" |
||
227 | s << " at worst #{'%03.3f' % ((worst_score) * 100)}%\n" |
||
228 | s << " at best #{'%03.3f' % ((best_score ) * 100)}%\n" |
||
229 | s << "\n" |
||
230 | s << "Note that these are outer limits on what is possible.\n" |
||
231 | s << "Neither the best nor the worst score is likely to be achievable.\n" |
||
232 | s << "\n" |
||
233 | s << "If we pick a schedule at random, its score will be about\n" |
||
234 | s << "\n" |
||
235 | s << " #{'%03.3f' % ((random_score) * 100)}%\n" |
||
236 | s << "\n" |
||
237 | s << "...and that's what we're trying to improve on.\n" |
||
238 | end |
||
239 | |||
240 | private |
||
241 | |||
242 | def format_percent(x) |
||
243 | "#{'%1.3f' % (x * 100)}%" |
||
244 | end |
||
245 | |||
246 | def empty_array_hash |
||
247 | Hash.new { |h,k| h[k] = [] } |
||
248 | end |
||
249 | |||
250 | end |
||
251 | end |
||
252 | |||
253 |