OSDN Git Service

[mk_rate] [mk_game_results] Supports Ruby 1.9.3.
[shogi-server/shogi-server.git] / mk_rate
1 #!/usr/bin/ruby1.9.1
2 # $Id$
3 #
4 # Author:: Daigo Moriwaki
5 # Homepage:: http://sourceforge.jp/projects/shogi-server/
6 #
7 #--
8 # Copyright (C) 2006-2009 Daigo Moriwaki <daigo at debian dot org>
9 #
10 # This program is free software; you can redistribute it and/or modify
11 # it under the terms of the GNU General Public License as published by
12 # the Free Software Foundation; either version 2 of the License, or
13 # (at your option) any later version.
14 #
15 # This program is distributed in the hope that it will be useful,
16 # but WITHOUT ANY WARRANTY; without even the implied warranty of
17 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
18 # GNU General Public License for more details.
19 #
20 # You should have received a copy of the GNU General Public License
21 # along with this program; if not, write to the Free Software
22 # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
23 #++
24 #
25 # == Synopsis
26 #
27 # mk_rate reads game results files generated by the mk_game_results command,
28 # calculates rating scores of each player, and then outputs a yaml file 
29 # (players.yaml) that Shogi-server can recognize.
30 #
31 # == Usage
32 #
33 # ./mk_rate [options] GAME_RESULTS_FILE [...]
34 #
35 # ./mk_rate [options]
36
37 # GAME_RESULTS_FILE::
38 #   a path to a file listing results of games, which is genrated by the
39 #   mk_game_results command.
40 #   In the second style above, the file content can be read from the stdin.
41 #
42 # --base-date::
43 #   a base time point for this calicuration (default now). Ex. '2009-10-31'
44 #
45 # --half-life::
46 #   n [days] (default 60)
47 #   
48 # --half-life-ignore::
49 #   m [days] (default  7)
50 #   after m days, the half-life effect works
51 #
52 # --fixed-rate-player::
53 #   player whose rate is fixed at the rate
54 #
55 # --fixed-rate::
56 #   rate 
57 #
58 # --skip-draw-games::
59 #   skip draw games. [default: draw games are counted in as 0.5 win and 0.5
60 #   lost.]
61 #
62 # --help::
63 #   show this message
64 #
65 # == PREREQUIRE
66 #
67 # Sample Command lines that isntall prerequires will work on Debian.
68 #
69 # * Ruby 1.9.3 (including Rubygems)
70 #
71 #   $ sudo aptitude install ruby1.9.1
72 #
73 # * Ruby bindings for the GNU Scientific Library (GSL[http://rb-gsl.rubyforge.org/])
74 #
75 #   $ sudo aptitude install ruby-gsl
76 #
77 # * RGL: {Ruby Graph Library}[http://rubyforge.org/projects/rgl/]
78 #
79 #   $ sudo gem1.9.1 install rgl
80 #
81 # == Examples
82 #
83 #   $ ./mk_rate game_results.txt > players.yaml
84 #
85 #   $ ./mk_game_results . | ./mk_rate > players.yaml
86 #
87 # If you do not want the file to be update in case of errors, 
88 #
89 #   $ ./mk_rate game_results.txt && ./mk_rate game_results.txt > players.yaml
90 #
91 # == How players are rated
92 #
93 # The conditions that games and players are rated as following:
94 #
95 # * Rated games, which were played by both rated players.
96 # * Rated players, who logged in the server with a name followed by a trip: "name,trip".
97 # * (Rated) players, who played more than $GAMES_LIMIT [15] (rated) games. 
98 #
99
100 require 'yaml'
101 require 'time'
102 require 'getoptlong'
103 require 'gsl'
104 require 'rubygems'
105 require 'rgl/adjacency'
106 require 'rgl/connected_components'
107
108 #################################################
109 # Constants
110 #
111
112 # Count out players who play less games than $GAMES_LIMIT
113 $GAMES_LIMIT = $DEBUG ? 0 : 15
114 WIN_MARK  = "win"
115 LOSS_MARK = "lose"
116 DRAW_MARK = "draw"
117
118 # Holds players
119 $players = Hash.new
120 # Holds the last time when a player gamed
121 $players_time = Hash.new { Time.at(0) }
122
123
124 #################################################
125 # Keeps the value of the lowest key
126 #
127 class Record
128   def initialize
129     @lowest = []
130   end
131
132   def set(key, value)
133     if @lowest.empty? || key < @lowest[0]
134       @lowest = [key, value]
135     end
136   end
137
138   def get
139     if @lowest.empty?
140       nil
141     else
142       @lowest[1]
143     end
144   end
145 end
146
147 #################################################
148 # Calculates rates of every player from a Win Loss GSL::Matrix
149 #
150 class Rating
151   include Math
152
153   # The model of the win possibility is 1/(1 + 10^(-d/400)).
154   # The equation in this class is 1/(1 + e^(-Kd)).
155   # So, K should be calculated like this.
156   K = Math.log(10.0) / 400.0
157   
158   # Convergence limit to stop Newton method.
159   ERROR_LIMIT = 1.0e-3
160   # Stop Newton method after this iterations.
161   COUNT_MAX = 500
162
163   # Average rate among the players
164   AVERAGE_RATE = 1000
165
166   
167   ###############
168   # Class methods
169   #  
170   
171   ##
172   # Calcurates the average of the vector.
173   #
174   def Rating.average(vector, mean=0.0)
175     sum = Array(vector).inject(0.0) {|sum, n| sum + n}
176     vector -= GSL::Vector[*Array.new(vector.size, sum/vector.size - mean)]
177     vector
178   end
179
180   ##################
181   # Instance methods
182   #
183   def initialize(win_loss_matrix)
184     @record = Record.new
185     @n = win_loss_matrix
186     case @n
187     when GSL::Matrix, GSL::Matrix::Int
188       @size = @n.size1
189     when ::Matrix
190       @size = @n.row_size
191     else
192       raise ArgumentError
193     end
194     initial_rate
195   end
196   attr_reader :rate, :n
197
198   def player_vector
199     GSL::Vector[*
200       (0...@size).collect {|k| yield k}
201     ]
202   end
203
204   def each_player
205     (0...@size).each {|k| yield k}
206   end
207
208   ##
209   # The possibility that the player k will beet the player i.
210   #
211   def win_rate(k,i)
212     1.0/(1.0 + exp(@rate[i]-@rate[k]))
213   end
214
215   ##
216   # Most possible equation
217   #
218   def func_vector
219     player_vector do|k| 
220       sum = 0.0
221       each_player do |i|
222         next if i == k
223         sum += @n[k,i] * win_rate(i,k) - @n[i,k] * win_rate(k,i) 
224       end
225       sum * 2.0
226     end
227   end
228
229   ##
230   #           / f0/R0 f0/R1 f0/R2 ... \
231   # dfk/dRj = | f1/R0 f1/R1 f1/R2 ... |
232   #           \ f2/R0 f2/R1 f2/R2 ... /
233   def d_func(k,j)
234     sum = 0.0
235     if k == j
236       each_player do |i|
237         next if i == k
238         sum += win_rate(i,k) * win_rate(k,i) * (@n[k,i] + @n[i,k])
239       end
240       sum *= -2.0
241     else # k != j
242       sum = 2.0 * win_rate(j,k) * win_rate(k,j) * (@n[k,j] + @n[j,k])
243     end
244     sum
245   end
246
247   ##
248   # Jacobi matrix of the func().
249   #   m00 m01
250   #   m10 m11
251   #
252   def j_matrix
253     GSL::Matrix[*
254       (0...@size).collect do |k|
255         (0...@size).collect do |j|
256           d_func(k,j)
257         end
258       end
259     ]
260   end
261
262   ##
263   # The initial value of the rate, which is of very importance for Newton
264   # method.  This is based on my huristics; the higher the win probablity of
265   # a player is, the greater points he takes.
266   #
267   def initial_rate
268     possibility = 
269       player_vector do |k|
270         v = GSL::Vector[0, 0]
271         each_player do |i|
272           next if k == i
273           v += GSL::Vector[@n[k,i], @n[i,k]]
274         end
275         v.nrm2 < 1 ? 0 : v[0] / (v[0] + v[1])
276       end
277     rank = possibility.sort_index
278     @rate = player_vector do |k|
279       K*500 * (rank[k]+1) / @size
280     end
281     average!
282   end
283
284   ##
285   # Resets @rate as the higher the current win probablity of a player is, 
286   # the greater points he takes. 
287   #
288   def initial_rate2
289     @rate = @record.get || @rate
290     rank = @rate.sort_index
291     @rate = player_vector do |k|
292       K*@count*1.5 * (rank[k]+1) / @size
293     end
294     average!
295   end
296
297   # mu is the deaccelrating parameter in Deaccelerated Newton method
298   def deaccelrate(mu, old_rate, a, old_f_nrm2)
299     @rate = old_rate - a * mu
300     if func_vector.nrm2 < (1 - mu / 4.0 ) * old_f_nrm2 then
301       return
302     end
303     if mu < 1e-4
304       @record.set(func_vector.nrm2, @rate)
305       initial_rate2
306       return
307     end
308     $stderr.puts "mu: %f " % [mu] if $DEBUG
309     deaccelrate(mu*0.5, old_rate, a, old_f_nrm2)
310   end
311
312   ##
313   # Main process to calculate ratings.
314   #
315   def rating
316     # Counter to stop the process. 
317     # Calulation in Newton method may fall in an infinite loop
318     @count = 0
319
320     # Main loop
321     begin
322       # Solve the equation: 
323       #   J*a=f
324       #   @rate_(n+1) = @rate_(n) - a
325       #
326       # f.nrm2 should approach to zero.
327       f = func_vector
328       j = j_matrix
329
330       # $stderr.puts "j: %s" % [j.inspect] if $DEBUG
331       $stderr.puts "f: %s -> %f" % [f.to_a.inspect, f.nrm2] if $DEBUG
332
333       # GSL::Linalg::LU.solve or GSL::Linalg::HH.solve would be available instead.
334       #a = GSL::Linalg::HH.solve(j, f)
335       a, = GSL::MultiFit::linear(j, f)
336       a = self.class.average(a)
337       # $stderr.puts "a: %s -> %f" % [a.to_a.inspect, a.nrm2] if $DEBUG
338       
339       # Deaccelerated Newton method
340       # GSL::Vector object should be immutable.
341       old_rate   = @rate
342       old_f      = f
343       old_f_nrm2 = old_f.nrm2
344       deaccelrate(1.0, old_rate, a, old_f_nrm2)
345       #@rate -= a # Instead, do not deaccelerate
346       @record.set(func_vector.nrm2, @rate)
347
348       $stderr.printf "|error| : %5.2e\n", a.nrm2 if $DEBUG
349
350       @count += 1
351       if @count > COUNT_MAX
352         $stderr.puts "Values seem to oscillate. Stopped the process."
353         $stderr.puts "f: %s -> %f" % [func_vector.to_a.inspect, func_vector.nrm2]
354         break
355       end
356
357     end while (a.nrm2 > ERROR_LIMIT * @rate.nrm2)
358     
359     @rate = @record.get
360     $stderr.puts "resolved f: %s -> %f" %
361       [func_vector.to_a.inspect, func_vector.nrm2] if $DEBUG
362     $stderr.puts "Count: %d" % [@count] if $DEBUG
363
364     @rate *= 1.0/K
365     finite!
366     self
367   end
368
369   ##
370   # Make the values of @rate finite.
371   #
372   def finite!
373     @rate = @rate.collect do |a|
374       if a.infinite?
375         a.infinite? * AVERAGE_RATE * 100
376       else
377         a
378       end
379     end
380   end
381
382   ##
383   # Flatten the values of @rate.
384   #
385   def average!(mean=0.0)
386     @rate = self.class.average(@rate, mean)
387   end
388
389   ##
390   # Translate by value
391   #
392   def translate!(value)
393     @rate += value
394   end
395
396   ##
397   # Make the values of @rate integer.
398   #
399   def integer!
400     @rate = @rate.collect do |a|
401       if a.finite?
402         a.to_i
403       elsif a.nan?
404         0
405       elsif a.infinite?
406         a.infinite? * AVERAGE_RATE * 100
407       end
408     end
409   end
410 end
411
412 #################################################
413 # Encapsulate a pair of keys and win loss matrix.
414 #   - keys is an array of player IDs; [gps+123, foo+234, ...]
415 #   - matrix holds games # where player i (row index) beats player j (column index).
416 #     The row and column indexes match with the keys.
417 #
418 # This object should be immutable. If an internal state is being modified, a
419 # new object is always returned.
420 #
421 class WinLossMatrix
422
423   ###############
424   # Class methods
425   #  
426
427   def self.mk_matrix(players)
428     keys = players.keys.sort
429     size = keys.size
430     matrix =
431       GSL::Matrix[*
432       ((0...size).collect do |k|
433         p1 = keys[k]
434         p1_hash = players[p1]
435         ((0...size).collect do |j|
436           if k == j
437             0
438           else
439             p2 = keys[j]
440             v = p1_hash[p2] || GSL::Vector[0,0]
441             v[0]
442           end
443         end)
444       end)]
445     return WinLossMatrix.new(keys, matrix)
446   end
447
448   def self.mk_win_loss_matrix(players)
449     obj = mk_matrix(players)
450     return obj.filter
451   end
452
453   ##################
454   # Instance methods
455   #
456
457   # an array of player IDs; [gps+123, foo+234, ...]
458   attr_reader :keys
459
460   # matrix holds games # where player i (row index) beats player j (column index).
461   # The row and column indexes match with the keys.
462   attr_reader :matrix
463
464   def initialize(keys, matrix)
465     @keys   = keys
466     @matrix = matrix
467   end
468
469   ##
470   # Returns the size of the keys/matrix
471   #
472   def size
473     if @keys
474       @keys.size
475     else
476       nil
477     end
478   end
479
480   ##
481   # Removes players in a rows such as [1,3,5], and then returns a new
482   # object.
483   #
484   def delete_rows(rows)
485     rows = rows.sort.reverse
486
487     copied_cols = []
488     (0...size).each do |i|
489       next if rows.include?(i)
490       row = @matrix.row(i).clone
491       rows.each do |j|
492         row.delete_at(j)
493       end
494       copied_cols << row
495     end
496     if copied_cols.size == 0
497       new_matrix = GSL::Matrix.new
498     else
499       new_matrix = GSL::Matrix[*copied_cols]
500     end
501
502     new_keys = @keys.clone
503     rows.each do |j|
504       new_keys.delete_at(j)
505     end
506
507     return WinLossMatrix.new(new_keys, new_matrix)
508   end
509
510   ##
511   # Removes players who do not pass a criteria to be rated, and returns a
512   # new object.
513   # 
514   def filter
515     $stderr.puts @keys.inspect if $DEBUG
516     $stderr.puts @matrix.inspect if $DEBUG
517     delete = []  
518     (0...size).each do |i|
519       row = @matrix.row(i)
520       col = @matrix.col(i)
521       win  = row.sum
522       loss = col.sum
523       if win < 1 || loss < 1 || win + loss < $GAMES_LIMIT
524         delete << i
525       end
526     end
527
528     # The recursion ends if there is nothing to delete
529     return self if delete.empty?
530
531     new_obj = delete_rows(delete)
532     new_obj.filter
533   end
534
535   ##
536   # Cuts self into connecting groups such as each player in a group has at least
537   # one game with other players in the group. Returns them as an array.
538   #
539   def connected_subsets
540     g = RGL::AdjacencyGraph.new
541     (0...size).each do |k|
542       (0...size).each do |i|
543         next if k == i
544         if @matrix[k,i] > 0
545           g.add_edge(k,i)
546         end
547       end
548     end
549
550     subsets = []
551     g.each_connected_component do |c|
552       new_keys = []      
553       c.each do |v|
554         new_keys << keys[v.to_s.to_i]
555       end
556       subsets << new_keys
557     end
558
559     subsets = subsets.sort {|a,b| b.size <=> a.size}
560
561     result = subsets.collect do |keys|
562       matrix =
563         GSL::Matrix[*
564         ((0...keys.size).collect do |k|
565           p1 = @keys.index(keys[k])
566           ((0...keys.size).collect do |j|
567             if k == j
568               0
569             else
570               p2 = @keys.index(keys[j])
571               @matrix[p1,p2]
572             end
573           end)
574         end)]
575       WinLossMatrix.new(keys, matrix)
576     end
577
578     return result
579   end
580
581   def to_s
582     "size : #{@keys.size}" + "\n" +
583     @keys.inspect + "\n" + 
584     @matrix.inspect
585   end
586
587 end
588
589
590 #################################################
591 # Main methods
592 #
593
594 # Half-life effect
595 # After NHAFE_LIFE days value will get half.
596 # 0.693 is constant, where exp(0.693) ~ 0.5
597 def half_life(days)
598   if days < $options["half-life-ignore"]
599     return 1.0
600   else
601     Math::exp(-0.693/$options["half-life"]*(days-$options["half-life-ignore"]))
602   end
603 end
604
605 def _add_win_loss(winner, loser, time)
606   how_long_days = ($options["base-date"] - time)/(3600*24)
607   $players[winner] ||= Hash.new { GSL::Vector[0,0] }
608   $players[loser]  ||= Hash.new { GSL::Vector[0,0] }
609   $players[winner][loser] += GSL::Vector[1.0*half_life(how_long_days),0]
610   $players[loser][winner] += GSL::Vector[0,1.0*half_life(how_long_days)]
611 end
612
613 def _add_draw(player1, player2, time)
614   how_long_days = ($options["base-date"] - time)/(3600*24)
615   $players[player1] ||= Hash.new { GSL::Vector[0,0] }
616   $players[player2] ||= Hash.new { GSL::Vector[0,0] }
617   $players[player1][player2] += GSL::Vector[0.5*half_life(how_long_days),0.5*half_life(how_long_days)]
618   $players[player2][player1] += GSL::Vector[0.5*half_life(how_long_days),0.5*half_life(how_long_days)]
619 end
620
621 def _add_time(player, time)
622   $players_time[player] = time if $players_time[player] < time
623 end
624
625 def add(black_mark, black_name, white_name, white_mark, time)
626   if black_mark == WIN_MARK && white_mark == LOSS_MARK
627     _add_win_loss(black_name, white_name, time)
628   elsif black_mark == LOSS_MARK && white_mark == WIN_MARK
629     _add_win_loss(white_name, black_name, time)
630   elsif black_mark == DRAW_MARK && white_mark == DRAW_MARK
631     if $options["skip-draw-games"]
632       return
633     else
634       _add_draw(black_name, white_name, time)
635     end
636   else
637     raise "Never reached!"
638   end
639   _add_time(black_name, time)
640   _add_time(white_name, time)
641 end
642
643 def identify_id(id)
644   if /@NORATE\+/ =~ id # the player having @NORATE in the name should not be rated
645     return nil
646   end
647   id.gsub(/@.*?\+/,"+")
648 end
649
650 # Parse a game result line
651 #
652 def parse(line)
653   time, state, black_mark, black_id, white_id, white_mark, file = line.split("\t")
654   unless time && state && black_mark && black_id &&
655          white_id && white_mark && file
656     $stderr.puts "Failed to parse the line : #{line}"
657     return
658   end
659
660   return if state == "abnormal"
661   time = Time.parse(time)
662   return if $options["base-date"] < time
663   black_id = identify_id(black_id)
664   white_id = identify_id(white_id)
665
666   if black_id && white_id && (black_id != white_id) &&
667      black_mark && white_mark
668     add(black_mark, black_id, white_id, white_mark, time)
669   end
670 end
671
672 def validate(yaml)
673   yaml["players"].each do |group_key, group|
674     group.each do |player_key, player|
675       rate = player['rate']
676       next unless rate
677       if rate > 10000 || rate < -10000
678         return false
679       end
680     end
681   end
682   return true
683 end
684
685 def usage(io)
686     io.puts <<EOF
687 USAGE: #{$0} [options] GAME_RESULTS_FILE [...]
688        #{$0} [options]
689        
690 GAME_RESULTS_FILE:
691   a path to a file listing results of games, which is genrated by the
692   mk_game_results command.
693   In the second style above, the file content can be read from the stdin.
694
695 OPTOINS:
696   --base-date         a base time point for this calicuration (default now). Ex. '2009-10-31'
697   --half-life         n [days] (default 60)
698   --half-life-ignore  m [days] (default  7)
699                       after m days, half-life effect works
700   --fixed-rate-player player whose rate is fixed at the rate
701   --fixed-rate        rate 
702   --skip-draw-games   skip draw games. [default: draw games are counted in
703                       as 0.5 win and 0.5 lost]
704   --help              show this message
705 EOF
706 end
707
708 def main
709   $options = Hash::new
710   parser = GetoptLong.new(
711     ["--base-date",         GetoptLong::REQUIRED_ARGUMENT],
712     ["--half-life",         GetoptLong::REQUIRED_ARGUMENT],
713     ["--half-life-ignore",  GetoptLong::REQUIRED_ARGUMENT],
714     ["--help", "-h",        GetoptLong::NO_ARGUMENT],
715     ["--fixed-rate-player", GetoptLong::REQUIRED_ARGUMENT],
716     ["--fixed-rate",        GetoptLong::REQUIRED_ARGUMENT],
717     ["--skip-draw-games",   GetoptLong::NO_ARGUMENT])
718   parser.quiet = true
719   begin
720     parser.each_option do |name, arg|
721       name.sub!(/^--/, '')
722       $options[name] = arg.dup
723     end
724     if ( $options["fixed-rate-player"] && !$options["fixed-rate"]) ||
725        (!$options["fixed-rate-player"] &&  $options["fixed-rate"]) ||
726        ( $options["fixed-rate-player"] &&  $options["fixed-rate"].to_i <= 0) 
727       usage($stderr)
728       exit 1
729     end
730   rescue
731     usage($stderr)
732     raise parser.error_message
733   end
734   if $options["help"]
735     usage($stdout) 
736     exit 0
737   end
738   if $options["base-date"]
739     $options["base-date"] = Time::parse $options["base-date"]
740   else
741     $options["base-date"] = Time.now
742   end
743   $options["half-life"] ||= 60
744   $options["half-life"] = $options["half-life"].to_i
745   $options["half-life-ignore"] ||= 7
746   $options["half-life-ignore"] = $options["half-life-ignore"].to_i
747   $options["fixed-rate"] = $options["fixed-rate"].to_i if $options["fixed-rate"]
748
749   if ARGV.empty?
750     while line = $stdin.gets do
751       parse line.strip
752     end
753   else
754     while file = ARGV.shift do
755       File.open(file) do |f|
756         f.each_line do |line|
757           parse line.strip
758         end
759       end 
760     end
761   end
762
763   yaml = {} 
764   yaml["players"] = {}
765   rating_group = 0
766   if $players.size > 0
767     obj = WinLossMatrix::mk_win_loss_matrix($players)
768     obj.connected_subsets.each do |win_loss_matrix|
769       yaml["players"][rating_group] = {}
770
771       rating = Rating.new(win_loss_matrix.matrix)
772       rating.rating
773       rating.average!(Rating::AVERAGE_RATE)
774       rating.integer!
775
776       if $options["fixed-rate-player"]
777         # first, try exact match
778         index = win_loss_matrix.keys.index($options["fixed-rate-player"])
779         # second, try regular match
780         unless index
781           win_loss_matrix.keys.each_with_index do |p, i|
782             if %r!#{$options["fixed-rate-player"]}! =~ p
783               index = i
784             end
785           end
786         end
787         if index
788           the_rate = rating.rate[index]
789           rating.translate!($options["fixed-rate"] - the_rate)
790         end
791       end
792
793       win_loss_matrix.keys.each_with_index do |p, i| # player_id, index#
794         win  = win_loss_matrix.matrix.row(i).sum
795         loss = win_loss_matrix.matrix.col(i).sum
796
797         yaml["players"][rating_group][p] = 
798           { 'name' => p.split("+")[0],
799             'rating_group' => rating_group,
800             'rate' => rating.rate[i],
801             'last_modified' => $players_time[p].dup,
802             'win'  => win,
803             'loss' => loss}
804       end
805       rating_group += 1
806     end
807   end
808   rating_group -= 1
809   non_rated_group = 999 # large enough
810   yaml["players"][non_rated_group] = {}
811   $players.each_key do |id|
812     # skip players who have already been rated
813     found = false
814     (0..rating_group).each do |i|
815        found = true if yaml["players"][i][id]
816        break if found
817     end
818     next if found
819
820     v = GSL::Vector[0, 0]
821     $players[id].each_value {|value| v += value}
822     next if v[0] < 1 && v[1] < 1
823
824     yaml["players"][non_rated_group][id] =
825       { 'name' => id.split("+")[0],
826         'rating_group' => non_rated_group,
827         'rate' => 0,
828         'last_modified' => $players_time[id].dup,
829         'win'  => v[0],
830         'loss' => v[1]}
831   end
832   unless validate(yaml)
833     $stderr.puts "Aborted. It did not result in valid ratings."
834     $stderr.puts yaml.to_yaml if $DEBUG
835     exit 10
836   end
837   puts yaml.to_yaml
838 end
839
840 if __FILE__ == $0
841   main
842 end
843
844 # vim: ts=2 sw=2 sts=0