whole_history_rating 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2012 Pete Schwamb
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,19 @@
1
+
2
+ # Whole History Rating
3
+
4
+ A system for ranking game players by skill, based on Rémi Coulom's Whole History Rating algorithm.
5
+
6
+ Developed for use on [GoShrine](http://goshrine.com), but the code is not go specific. It can support any two player game, as long as the outcome is a simple win/loss. An addition to the algorithm is support for handicaps.
7
+
8
+ Installation
9
+ ------------
10
+
11
+ * gem install whole-history-rating
12
+
13
+ Enjoy!
14
+
15
+ -Pete
16
+
17
+
18
+
19
+
@@ -0,0 +1,19 @@
1
+ require 'rubygems'
2
+
3
+ module WholeHistoryRating
4
+
5
+ VERSION = "0.1.0"
6
+
7
+ STDOUT.sync = true
8
+
9
+ ROOT = File.expand_path(File.dirname(__FILE__))
10
+
11
+ %w[ base
12
+ player
13
+ game
14
+ player_day
15
+ ].each do |lib|
16
+ require File.join(ROOT, 'whole_history_rating', lib)
17
+ end
18
+
19
+ end
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env ruby
2
+ require 'date'
3
+
4
+ module WholeHistoryRating
5
+
6
+ class Base
7
+
8
+ class UnstableRatingException < RuntimeError; end
9
+
10
+ attr_accessor :players, :games
11
+
12
+ def initialize(config = {})
13
+ @config = config
14
+ @config[:w2] ||= 300.0 # elo^2
15
+ @games = []
16
+ @players = {}
17
+ @start_date = nil
18
+ end
19
+
20
+ def print_ordered_ratings
21
+ players = @players.values.select {|p| p.days.count > 0}
22
+ players.sort_by { |p| p.days.last.gamma }.each_with_index do |p,idx|
23
+ if p.days.count > 0
24
+ puts "#{p.name} => #{p.days.map(&:elo)}"
25
+ end
26
+ end
27
+ end
28
+
29
+ def log_likelihood
30
+ score = 0.0
31
+ @players.values.each do |p|
32
+ unless p.days.empty?
33
+ score += p.log_likelihood
34
+ end
35
+ end
36
+ score
37
+ end
38
+
39
+ def player_by_name(name)
40
+ players[name] || players[name] = Player.new(name, @config)
41
+ end
42
+
43
+ def create_game(options)
44
+ if options['finished_at'].nil?
45
+ puts "Skipping (not finished) #{options.inspect}"
46
+ return nil
47
+ end
48
+
49
+ game_date = Date.parse(options['finished_at'])
50
+
51
+ @start_date ||= game_date
52
+
53
+ # Avoid self-played games (no info)
54
+ if options['w_name'] == options['b_name']
55
+ puts "Skipping (black name == white name ?) #{options.inspect}"
56
+ return nil
57
+ end
58
+
59
+ day_num = (game_date - @start_date).to_i
60
+
61
+ #puts "day num = #{day_num}"
62
+
63
+ white_player = player_by_name(options['w_name'])
64
+ white_player.id = options['w_id']
65
+ black_player = player_by_name(options['b_name'])
66
+ black_player.id = options['b_id']
67
+ game = Game.new(day_num, white_player, black_player, options['winner'], options['handicap'], options['extras'])
68
+ game.date = game_date
69
+ game
70
+ end
71
+
72
+ def add_game(game)
73
+ game.white_player.add_game(game)
74
+ game.black_player.add_game(game)
75
+ if game.bpd.nil?
76
+ puts "Bad game: #{options.inspect} -> #{game.inspect}"
77
+ end
78
+ @games << game
79
+ game
80
+ end
81
+
82
+ def run_one_iteration
83
+ players.each do |name,player|
84
+ player.run_one_newton_iteration
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,67 @@
1
+ module WholeHistoryRating
2
+ class Game
3
+ attr_accessor :day, :date, :white_player, :black_player, :handicap, :winner, :wpd, :bpd, :extras
4
+
5
+ def initialize(day, white_player, black_player, winner, handicap, extras)
6
+ @day = day
7
+ @white_player = white_player
8
+ @black_player = black_player
9
+ @winner = winner
10
+ @extras = extras
11
+ @handicap = handicap || 0
12
+ @handicap_proc = handicap if handicap.is_a?(Proc)
13
+ end
14
+
15
+ def opponents_adjusted_gamma(player)
16
+ black_advantage = @handicap_proc ? @handicap_proc.call(self) : @handicap
17
+ #puts "black_advantage = #{black_advantage}"
18
+
19
+ if player == white_player
20
+ opponent_elo = bpd.elo + black_advantage
21
+ elsif player == black_player
22
+ opponent_elo = wpd.elo - black_advantage
23
+ else
24
+ raise "No opponent for #{player.inspect}, since they're not in this game: #{self.inspect}."
25
+ end
26
+ rval = 10**(opponent_elo/400.0)
27
+ if rval == 0 || rval.infinite? || rval.nan?
28
+ raise WHR::UnstableRatingException, "bad adjusted gamma: #{inspect}"
29
+ end
30
+ rval
31
+ end
32
+
33
+ def opponent(player)
34
+ if player == white_player
35
+ black_player
36
+ elsif player == black_player
37
+ white_player
38
+ end
39
+ end
40
+
41
+ def prediction_score
42
+ if white_win_probability == 0.5
43
+ 0.5
44
+ else
45
+ ((winner == "W" && white_win_probability > 0.5) || (winner == "B" && white_win_probability < 0.5)) ? 1.0 : 0.0
46
+ end
47
+ end
48
+
49
+ def inspect
50
+ "#{self}: W:#{white_player.name}(r=#{wpd ? wpd.r : '?'}) B:#{black_player.name}(r=#{bpd ? bpd.r : '?'}) winner = #{winner}, komi = #{@komi}, handicap = #{@handicap}"
51
+ end
52
+
53
+ #def likelihood
54
+ # winner == "W" ? white_win_probability : 1-white_win_probability
55
+ #end
56
+
57
+ # This is the Bradley-Terry Model
58
+ def white_win_probability
59
+ wpd.gamma/(wpd.gamma + opponents_adjusted_gamma(white_player))
60
+ end
61
+
62
+ def black_win_probability
63
+ bpd.gamma/(bpd.gamma + opponents_adjusted_gamma(black_player))
64
+ end
65
+
66
+ end
67
+ end
@@ -0,0 +1,254 @@
1
+ require 'matrix'
2
+
3
+ module WholeHistoryRating
4
+ class Player
5
+ attr_accessor :name, :anchor_gamma, :days, :w2, :debug, :id
6
+
7
+ def initialize(name, config)
8
+ @name = name
9
+ @debug = config[:debug]
10
+ @w2 = (Math.sqrt(config[:w2])*Math.log(10)/400)**2 # Convert from elo^2 to r^2
11
+ @days = []
12
+ end
13
+
14
+ def inspect
15
+ "#{self}:(#{name})"
16
+ end
17
+
18
+ def log_likelihood
19
+ sum = 0.0
20
+ sigma2 = compute_sigma2
21
+ n = days.count
22
+ 0.upto(n-1) do |i|
23
+ prior = 0
24
+ if i < (n-1)
25
+ rd = days[i].r - days[i+1].r
26
+ prior += (1/(Math.sqrt(2*Math::PI*sigma2[i]))) * Math.exp(-(rd**2)/2*sigma2[i])
27
+ end
28
+ if i > 0
29
+ rd = days[i].r - days[i-1].r
30
+ prior += (1/(Math.sqrt(2*Math::PI*sigma2[i-1]))) * Math.exp(-(rd**2)/2*sigma2[i-1])
31
+ end
32
+ if prior == 0
33
+ sum += days[i].log_likelihood
34
+ else
35
+ if (days[i].log_likelihood.infinite? || Math.log(prior).infinite?)
36
+ puts "Infinity at #{inspect}: #{days[i].log_likelihood} + #{Math.log(prior)}: prior = #{prior}, days = #{days.inspect}"
37
+ exit
38
+ end
39
+ sum += days[i].log_likelihood + Math.log(prior)
40
+ end
41
+ end
42
+ sum
43
+ end
44
+
45
+ def hessian(days, sigma2)
46
+ n = days.count
47
+ Matrix.build(n) do |row,col|
48
+ if row == col
49
+ prior = 0
50
+ prior += -1.0/sigma2[row] if row < (n-1)
51
+ prior += -1.0/sigma2[row-1] if row > 0
52
+ days[row].log_likelihood_second_derivative + prior - 0.001
53
+ elsif row == col-1
54
+ 1.0/sigma2[row]
55
+ elsif row == col+1
56
+ 1.0/sigma2[col]
57
+ else
58
+ 0
59
+ end
60
+ end
61
+ end
62
+
63
+ def gradient(r, days, sigma2)
64
+ g = []
65
+ n = days.count
66
+ days.each_with_index do |day,idx|
67
+ prior = 0
68
+ prior += -(r[idx]-r[idx+1])/sigma2[idx] if idx < (n-1)
69
+ prior += -(r[idx]-r[idx-1])/sigma2[idx-1] if idx > 0
70
+ if @debug
71
+ puts "g[#{idx}] = #{day.log_likelihood_derivative} + #{prior}"
72
+ end
73
+ g << day.log_likelihood_derivative + prior
74
+ end
75
+ g
76
+ end
77
+
78
+ def run_one_newton_iteration
79
+ days.each do |day|
80
+ day.clear_game_terms_cache
81
+ end
82
+
83
+ if days.count == 1
84
+ days[0].update_by_1d_newtons_method
85
+ elsif days.count > 1
86
+ update_by_ndim_newton
87
+ end
88
+ end
89
+
90
+ def compute_sigma2
91
+ sigma2 = []
92
+ days.each_cons(2) do |d1,d2|
93
+ sigma2 << (d2.day - d1.day).abs * @w2
94
+ end
95
+ sigma2
96
+ end
97
+
98
+ def update_by_ndim_newton
99
+ # r
100
+ r = days.map(&:r)
101
+
102
+ if @debug
103
+ puts "Updating #{inspect}"
104
+ days.each do |day|
105
+ puts "day[#{day.day}] r = #{day.r}"
106
+ puts "day[#{day.day}] win terms = #{day.won_game_terms}"
107
+ puts "day[#{day.day}] win games = #{day.won_games}"
108
+ puts "day[#{day.day}] lose terms = #{day.lost_game_terms}"
109
+ puts "day[#{day.day}] lost games = #{day.lost_games}"
110
+ puts "day[#{day.day}] log(p) = #{day.log_likelihood}"
111
+ puts "day[#{day.day}] dlp = #{day.log_likelihood_derivative}"
112
+ puts "day[#{day.day}] dlp2 = #{day.log_likelihood_second_derivative}"
113
+ end
114
+ end
115
+
116
+ # sigma squared (used in the prior)
117
+ sigma2 = compute_sigma2
118
+
119
+ h = hessian(days, sigma2)
120
+ g = gradient(r, days, sigma2)
121
+
122
+ a = []
123
+ d = [h[0,0]]
124
+ b = [h[0,1]]
125
+
126
+ n = r.size
127
+ (1..(n-1)).each do |i|
128
+ a[i] = h[i,i-1] / d[i-1]
129
+ d[i] = h[i,i] - a[i] * b[i-1]
130
+ b[i] = h[i,i+1]
131
+ end
132
+
133
+
134
+ y = [g[0]]
135
+ (1..(n-1)).each do |i|
136
+ y[i] = g[i] - a[i] * y[i-1]
137
+ end
138
+
139
+ x = []
140
+ x[n-1] = y[n-1] / d[n-1]
141
+ (n-2).downto(0) do |i|
142
+ x[i] = (y[i] - b[i] * x[i+1]) / d[i]
143
+ end
144
+
145
+ new_r = r.zip(x).map {|ri,xi| ri-xi}
146
+
147
+ new_r.each do |r|
148
+ if r > 650
149
+ raise WHR::UnstableRatingException, "Unstable r (#{new_r}) on player #{inspect}"
150
+ end
151
+ end
152
+
153
+ if @debug
154
+ puts "Hessian = #{h}"
155
+ puts "gradient = #{g}"
156
+ puts "a = #{a}"
157
+ puts "d = #{d}"
158
+ puts "b = #{b}"
159
+ puts "y = #{y}"
160
+ puts "x = #{x}"
161
+ puts "#{inspect} (#{r}) => (#{new_r})"
162
+ end
163
+
164
+ days.each_with_index do |day,idx|
165
+ day.r = day.r - x[idx]
166
+ end
167
+ end
168
+
169
+ def covariance
170
+ r = days.map(&:r)
171
+
172
+ sigma2 = compute_sigma2
173
+ h = hessian(days, sigma2)
174
+ g = gradient(r, days, sigma2)
175
+
176
+ n = days.count
177
+
178
+ a = []
179
+ d = [h[0,0]]
180
+ b = [h[0,1]]
181
+
182
+ n = r.size
183
+ (1..(n-1)).each do |i|
184
+ a[i] = h[i,i-1] / d[i-1]
185
+ d[i] = h[i,i] - a[i] * b[i-1]
186
+ b[i] = h[i,i+1]
187
+ end
188
+
189
+ dp = []
190
+ dp[n-1] = h[n-1,n-1]
191
+ bp = []
192
+ bp[n-1] = h[n-1,n-2]
193
+ ap = []
194
+ (n-2).downto(0) do |i|
195
+ ap[i] = h[i,i+1] / dp[i+1]
196
+ dp[i] = h[i,i] - ap[i]*bp[i+1]
197
+ bp[i] = h[i,i-1]
198
+ end
199
+
200
+ v = []
201
+ 0.upto(n-2) do |i|
202
+ v[i] = dp[i+1]/(b[i]*bp[i+1] - d[i]*dp[i+1])
203
+ end
204
+ v[n-1] = -1/d[n-1]
205
+
206
+ #puts "a = #{a}"
207
+ #puts "b = #{b}"
208
+ #puts "bp = #{bp}"
209
+ #puts "d = #{d}"
210
+ #puts "dp = #{dp}"
211
+ #puts "v = #{v}"
212
+
213
+ Matrix.build(n) do |row,col|
214
+ if row == col
215
+ v[row]
216
+ elsif row == col-1
217
+ -1*a[col]*v[col]
218
+ else
219
+ 0
220
+ end
221
+ end
222
+ end
223
+
224
+ def update_uncertainty
225
+ if days.count > 0
226
+ c = covariance
227
+ u = (0..(days.count-1)).collect{|i| c[i,i]}
228
+ days.zip(u) {|d,u| d.uncertainty = u}
229
+ else
230
+ 5
231
+ end
232
+ end
233
+
234
+ def add_game(game)
235
+ if days.last.nil? || days.last.day != game.day
236
+ new_pday = PlayerDay.new(self, game.day)
237
+ new_pday.date = game.date
238
+ if days.empty?
239
+ new_pday.is_first_day = true
240
+ new_pday.gamma = 1
241
+ else
242
+ new_pday.gamma = days.last.gamma
243
+ end
244
+ days << new_pday
245
+ end
246
+ if (game.white_player == self)
247
+ game.wpd = days.last
248
+ else
249
+ game.bpd = days.last
250
+ end
251
+ days.last.add_game(game)
252
+ end
253
+ end
254
+ end
@@ -0,0 +1,119 @@
1
+
2
+ module WholeHistoryRating
3
+ class PlayerDay
4
+ attr_accessor :won_games, :lost_games, :name, :kyudan, :day, :date, :player, :r, :is_first_day, :uncertainty
5
+ def initialize(player, day)
6
+ @day = day
7
+ @player = player
8
+ @is_first_day = false
9
+ @won_games = []
10
+ @lost_games = []
11
+ end
12
+
13
+ def gamma=(gamma)
14
+ @r = Math.log(gamma)
15
+ end
16
+
17
+ def gamma
18
+ Math.exp(@r)
19
+ end
20
+
21
+ def elo=(elo)
22
+ @r = elo * (Math.log(10)/400.0)
23
+ end
24
+
25
+ def elo
26
+ (@r * 400.0)/(Math.log(10))
27
+ end
28
+
29
+ def clear_game_terms_cache
30
+ @won_game_terms = nil
31
+ @lost_game_terms = nil
32
+ end
33
+
34
+ def won_game_terms
35
+ if @won_game_terms.nil?
36
+ @won_game_terms = @won_games.map do |g|
37
+ other_gamma = g.opponents_adjusted_gamma(player)
38
+ if other_gamma == 0 || other_gamma.nan? || other_gamma.infinite?
39
+ puts "other_gamma (#{g.opponent(player).inspect}) = #{other_gamma}"
40
+ end
41
+ [1.0,0.0,1.0,other_gamma]
42
+ end
43
+ if is_first_day
44
+ @won_game_terms << [1.0,0.0,1.0,1.0] # win against virtual player ranked with gamma = 1.0
45
+ end
46
+ end
47
+ @won_game_terms
48
+ end
49
+
50
+ def lost_game_terms
51
+ if @lost_game_terms.nil?
52
+ @lost_game_terms = @lost_games.map do |g|
53
+ other_gamma = g.opponents_adjusted_gamma(player)
54
+ if other_gamma == 0 || other_gamma.nan? || other_gamma.infinite?
55
+ puts "other_gamma (#{g.opponent(player).inspect}) = #{other_gamma}"
56
+ end
57
+ [0.0,other_gamma,1.0,other_gamma]
58
+ end
59
+ if is_first_day
60
+ @lost_game_terms << [0.0,1.0,1.0,1.0] # loss against virtual player ranked with gamma = 1.0
61
+ end
62
+ end
63
+ @lost_game_terms
64
+ end
65
+
66
+ def log_likelihood_second_derivative
67
+ sum = 0.0
68
+ (won_game_terms + lost_game_terms).each do |a,b,c,d|
69
+ sum += (c*d) / ((c*gamma + d)**2.0)
70
+ end
71
+ if gamma.nan? || sum.nan?
72
+ puts "won_game_terms = #{won_game_terms}"
73
+ puts "lost_game_terms = #{lost_game_terms}"
74
+ end
75
+ -1 * gamma * sum
76
+ end
77
+
78
+ def log_likelihood_derivative
79
+ tally = 0.0
80
+ (won_game_terms + lost_game_terms).each do |a,b,c,d|
81
+ tally += c/(c*gamma + d)
82
+ end
83
+ won_game_terms.count - gamma * tally
84
+ end
85
+
86
+ def log_likelihood
87
+ tally = 0.0
88
+ won_game_terms.each do |a,b,c,d|
89
+ tally += Math.log(a*gamma)
90
+ tally -= Math.log(c*gamma + d)
91
+ end
92
+ lost_game_terms.each do |a,b,c,d|
93
+ tally += Math.log(b)
94
+ tally -= Math.log(c*gamma + d)
95
+ end
96
+ tally
97
+ end
98
+
99
+ def add_game(game)
100
+ if (game.winner == "W" && game.white_player == @player) ||
101
+ (game.winner == "B" && game.black_player == @player)
102
+ @won_games << game
103
+ else
104
+ @lost_games << game
105
+ end
106
+ end
107
+
108
+ def update_by_1d_newtons_method
109
+ dlogp = log_likelihood_derivative
110
+ d2logp = log_likelihood_second_derivative
111
+ dr = (log_likelihood_derivative / log_likelihood_second_derivative)
112
+ new_r = @r - dr
113
+ #new_r = [0, @r - dr].max
114
+ #puts "(#{player.name}) #{new_r} = #{@r} - (#{log_likelihood_derivative}/#{log_likelihood_second_derivative})"
115
+ @r = new_r
116
+ end
117
+
118
+ end
119
+ end
@@ -0,0 +1,31 @@
1
+ DIR = File.dirname(__FILE__)
2
+ LIB = File.join(DIR, *%w[lib whole_history_rating.rb])
3
+ VERSION = open(LIB) { |lib|
4
+ lib.each { |line|
5
+ if v = line[/^\s*VERSION\s*=\s*(['"])(\d+\.\d+\.\d+)\1/, 2]
6
+ break v
7
+ end
8
+ }
9
+ }
10
+
11
+ SPEC = Gem::Specification.new do |s|
12
+ s.name = "whole_history_rating"
13
+ s.version = VERSION
14
+ s.platform = Gem::Platform::RUBY
15
+ s.authors = ["Pete Schwamb"]
16
+ s.email = ["pete@schwamb.net"]
17
+ s.homepage = "http://github.com/goshrine/whole_history_rating"
18
+ s.summary = "A pure ruby implementation of Remi Coulom's Whole-History Rating algorithm."
19
+ s.description = <<-END_DESCRIPTION.gsub(/\s+/, " ").strip
20
+ This gem provides a library and executables that take as input as set of games and output a set of skill rankings for the players of those games. The algorithm is a version of Remi Coulom's Whole-History Rating modified to support handicaps, and implemented in Ruby.
21
+ END_DESCRIPTION
22
+
23
+ #s.add_dependency('json', '>= 1.5.0')
24
+
25
+ #s.add_development_dependency "rspec"
26
+
27
+ #s.executables = ['whr_rate']
28
+
29
+ s.files = `git ls-files`.split("\n")
30
+ s.require_paths = %w[lib]
31
+ end
metadata ADDED
@@ -0,0 +1,56 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: whole_history_rating
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Pete Schwamb
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2012-05-17 00:00:00.000000000Z
13
+ dependencies: []
14
+ description: This gem provides a library and executables that take as input as set
15
+ of games and output a set of skill rankings for the players of those games. The
16
+ algorithm is a version of Remi Coulom's Whole-History Rating modified to support
17
+ handicaps, and implemented in Ruby.
18
+ email:
19
+ - pete@schwamb.net
20
+ executables: []
21
+ extensions: []
22
+ extra_rdoc_files: []
23
+ files:
24
+ - LICENSE
25
+ - README.md
26
+ - lib/whole_history_rating.rb
27
+ - lib/whole_history_rating/base.rb
28
+ - lib/whole_history_rating/game.rb
29
+ - lib/whole_history_rating/player.rb
30
+ - lib/whole_history_rating/player_day.rb
31
+ - whole_history_rating.gemspec
32
+ homepage: http://github.com/goshrine/whole_history_rating
33
+ licenses: []
34
+ post_install_message:
35
+ rdoc_options: []
36
+ require_paths:
37
+ - lib
38
+ required_ruby_version: !ruby/object:Gem::Requirement
39
+ none: false
40
+ requirements:
41
+ - - ! '>='
42
+ - !ruby/object:Gem::Version
43
+ version: '0'
44
+ required_rubygems_version: !ruby/object:Gem::Requirement
45
+ none: false
46
+ requirements:
47
+ - - ! '>='
48
+ - !ruby/object:Gem::Version
49
+ version: '0'
50
+ requirements: []
51
+ rubyforge_project:
52
+ rubygems_version: 1.8.6
53
+ signing_key:
54
+ specification_version: 3
55
+ summary: A pure ruby implementation of Remi Coulom's Whole-History Rating algorithm.
56
+ test_files: []