Issues (7)

Branch: master

src/lib/scheduling/schedule.rb (1 issue)

Labels
Severity
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
The Ruby parser could not interpret the code. It reported: unexpected token error (Using Ruby 2.0 pa...meter, under `AllCops`).
Loading history...
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