Completed
Push — master ( 739021...01de59 )
by
unknown
10s
created

Schedule.attendance_score_metrics()   A

Complexity

Conditions 1

Size

Total Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
c 1
b 0
f 0
dl 0
loc 10
rs 9.4285
1
require 'pp'
2
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 = Hash.new { |h,k| h[k] = [] }  # map from timeslot to array of sessions
14
15
      fill_schedule!(
16
        event.sessions
17
          .where(manually_scheduled: false)
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 = Hash.new { |h,k| h[k] = [] }
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
30
    end
31
32
  private
33
34
    attr_reader :ctx
35
36
    class Unassigned  # placeholder for empty room
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 Unassigned 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
      unassigned = sessions.reject(&:timeslot)
110
      ctx.timeslots.each do |slot|
111
        opening_count = ctx.room_count - @sessions_by_slot[slot].size
112
        slot_sessions = (unassigned.slice!(0, opening_count) || []).map(&:id)
113
        slot_sessions << Unassigned.new while slot_sessions.size < opening_count
114
        slot_sessions.each { |session| schedule(session, slot)  }
115
      end
116
      unless unassigned.empty?
117
        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."
118
      end
119
    end
120
121
    # Return a similar but slightly different schedule. Used by the annealing algorithm to explore
122
    # the scheduling space.
123
    #
124
    def random_neighbor
125
      dup.random_neighbor!
126
    end
127
128
    def random_neighbor!
129
      # Choose 2 or more random sessions in distinct time slots
130
      k = 1.0 / (ctx.timeslots.size - 1)
131
      cycle_size = 1 + ((1 + k) / (rand + k)).floor
132
      slot_cycle = ctx.timeslots.shuffle.slice(0, cycle_size)
133
134
      # Rotate their assignments
135
      slot_cycle.each_with_index do |old_slot, i|
136
        new_slot = slot_cycle[(i+1) % slot_cycle.size]
137
        schedule @sessions_by_slot[old_slot].sample, new_slot
138
      end
139
140
      self
141
    end
142
143
  private
144
145
    def schedule(session, new_slot)
146
      old_slot = @slots_by_session[session]
147
      @sessions_by_slot[old_slot].delete(session) if old_slot
148
149
      @slots_by_session[session] = new_slot
150
      @sessions_by_slot[new_slot] << session if new_slot
151
    end
152
153
  public
154
155
    # ------------ Managing results ------------
156
157
    def save!
158
      Session.transaction do
159
        ctx.timeslots.sort_by(&:starts_at).each do |slot|
160
          puts slot
161
          sessions = Session.find(
162
            @sessions_by_slot[slot].reject { |s| Unassigned === s })
0 ignored issues
show
Coding Style introduced by
Your coding style requires you to avoid the use of the case equality operator === outside of case statements. It is not an instanceof check like it is in other languages.
Loading history...
163
          sessions.sort_by { |s| -s.attendance_count }.each do |session|
164
            puts "    #{session.id} #{session.title}" +
165
                 " (#{session.attendances.count} vote(s) / #{'%1.1f' % session.estimated_interest} interest)"
166
            session.timeslot = slot
167
            session.room = nil  # Rooms have to be reassigned after rearranging timeslots
168
            session.save!
169
          end
170
        end
171
      end
172
    end
173
174
    def inspect
175
      worst_score, random_score, best_score = self.attendance_score_metrics
176
      attendance_score_scaled = (attendance_score - random_score) / (best_score - random_score)
177
178
      s = "Schedule\n"
179
      s << "| quality vs. random = #{format_percent attendance_score_scaled} (0% is no better than random; 100% is unachievable; > 50% is good)\n"
180
      s << "| absolute satisfaction = #{format_percent attendance_score} of impossibly perfect schedule\n"
181
      s << "| presenter score = #{format_percent presenter_score} (if < 100 then speakers are double-booked)\n"
182
      ctx.timeslots.each do |slot|
183
        s << "  #{slot}: #{@sessions_by_slot[slot].join(' ')}\n"
184
      end
185
      s
186
    end
187
188
    def inspect_bounds
189
      worst_score, random_score, best_score = self.attendance_score_metrics
190
      s =  "If we could give everyone a different schedule optimized just for them,\n"
191
      s << "the schedule quality could be...\n"
192
      s << "\n"
193
      s << "    at worst #{'%03.3f' % ((worst_score) * 100)}%\n"
194
      s << "     at best #{'%03.3f' % ((best_score ) * 100)}%\n"
195
      s << "\n"
196
      s << "Note that these are outer limits on what is possible.\n"
197
      s << "Neither the best nor the worst score is likely to be achievable.\n"
198
      s << "\n"
199
      s << "If we pick a schedule at random, its score will be about\n"
200
      s << "\n"
201
      s << "             #{'%03.3f' % ((random_score) * 100)}%\n"
202
      s << "\n"
203
      s << "...and that's what we're trying to improve on.\n"
204
    end
205
206
  private
207
208
    def format_percent(x)
209
      "#{'%1.3f' % (x * 100)}%"
210
    end
211
212
  end
213
end
214
215