Type check lib with Steep, as pro-tacts does
Types are inline RBS comments, checked by `rake steep`, which the
default task now runs ahead of the tests. sig/ carries hand-written
stubs for the Sequel and Roda surface the app calls, the Data classes
and models the inline parser cannot read, and the Phlex views, which
are ignored.
OpenSkill::Model now keeps its parameters as Floats, so the math
downstream has one numeric type to check against.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014U2t8JuR681EA8GQD9z5mD
diff --git a/AGENTS.md b/AGENTS.md
index 2f2bae8..985c114 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -10,6 +10,7 @@ Rank King is a webapp for ranking items along multiple axes through pairwise com
- Phlex for views (pure Ruby HTML)
- Puma web server, Rack
- Minitest for tests
+- Steep for type checking, against inline RBS comments
## Repository layout
@@ -26,6 +27,8 @@ lib/rank_king/ — app code
views/ — Phlex view components
lib/open_skill/ — OpenSkill rating library (pure Ruby)
test/ — Minitest tests
+sig/ — RBS for what inline comments cannot express
+Steepfile — type checking config
public/ — static assets
.github/workflows/ — CI (GitHub Actions)
```
@@ -41,7 +44,9 @@ public/ — static assets
| Task | Command |
|------|---------|
+| Type check and test | `bundle exec rake` |
| Run tests | `bundle exec rake test` |
+| Type check | `bundle exec rake steep` |
| Run dev server | `bundle exec rake dev` (uses `fd \| entr -r rackup`) |
| Start server | `bundle exec rackup` |
@@ -60,3 +65,10 @@ Tests set `DATABASE_URL=:memory:` in `test/test_helper.rb`.
- Views are Phlex components under `lib/rank_king/views/`
- CSRF protection is on for all POST routes
- Config is a frozen `Data.define` value object
+- Types are inline RBS comments (`#:` before a method, `# @rbs` for the
+ rest), checked by `rake steep`. `sig/` holds only what that syntax
+ cannot say, each file saying why: gems with no signatures (Sequel,
+ Roda), Data classes, the models (no constant superclass), and the
+ Phlex views, which the Steepfile ignores.
+- `rake steep` needs a UTF-8 locale: under a C locale the em dashes in
+ comments are invalid bytes and the check hangs.
diff --git a/Gemfile b/Gemfile
index 860a5d8..64ac153 100644
--- a/Gemfile
+++ b/Gemfile
@@ -13,4 +13,8 @@ group :development do
gem "minitest"
gem "rack-test"
gem "rake"
+ # Pinned to a prerelease: released Steep parses Ruby as 3.3, where `it`
+ # is a method call rather than the block parameter. 2.1.0.dev.1 is the
+ # first release parsing 3.4.
+ gem "steep", "2.1.0.dev.1"
end
diff --git a/Gemfile.lock b/Gemfile.lock
index f4f5711..bf2fa9b 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,29 +1,63 @@
GEM
remote: https://rubygems.org/
specs:
+ ast (2.4.3)
bigdecimal (4.0.1)
+ concurrent-ruby (1.3.8)
+ csv (3.3.6)
drb (2.2.3)
+ ffi (1.17.4-aarch64-linux-gnu)
+ ffi (1.17.4-aarch64-linux-musl)
+ ffi (1.17.4-arm-linux-gnu)
+ ffi (1.17.4-arm-linux-musl)
+ ffi (1.17.4-arm64-darwin)
+ ffi (1.17.4-x86-linux-gnu)
+ ffi (1.17.4-x86-linux-musl)
+ ffi (1.17.4-x86_64-darwin)
+ ffi (1.17.4-x86_64-linux-gnu)
+ ffi (1.17.4-x86_64-linux-musl)
+ fileutils (1.8.1)
+ json (3.1.0)
+ language_server-protocol (3.17.0.6)
+ listen (3.10.1)
+ logger
+ rb-fsevent (~> 0.10, >= 0.10.3)
+ rb-inotify (~> 0.9, >= 0.9.10)
+ logger (1.7.0)
minitest (6.0.2)
drb (~> 2.0)
prism (~> 1.5)
nio4r (2.7.5)
+ parser (3.3.13.0)
+ ast (~> 2.4.1)
+ racc
phlex (2.4.1)
refract (~> 1.0)
zeitwerk (~> 2.7)
prism (1.9.0)
puma (7.2.0)
nio4r (~> 2.0)
+ racc (1.8.1)
rack (3.2.5)
rack-test (2.2.0)
rack (>= 1.3)
rackup (2.3.1)
rack (>= 3)
+ rainbow (3.1.1)
rake (13.3.1)
+ rb-fsevent (0.11.2)
+ rb-inotify (0.11.1)
+ ffi (~> 1.0)
+ rbs (4.2.0)
+ logger
+ prism (>= 1.6.0)
+ tsort
refract (1.1.0)
prism
zeitwerk
roda (3.101.0)
rack
+ securerandom (0.4.1)
sequel (5.102.0)
bigdecimal
sqlite3 (2.9.1-aarch64-linux-gnu)
@@ -36,6 +70,30 @@ GEM
sqlite3 (2.9.1-x86_64-darwin)
sqlite3 (2.9.1-x86_64-linux-gnu)
sqlite3 (2.9.1-x86_64-linux-musl)
+ steep (2.1.0.dev.1)
+ concurrent-ruby (>= 1.1.10)
+ csv (>= 3.0.9)
+ fileutils (>= 1.1.0)
+ json (>= 2.1.0)
+ language_server-protocol (>= 3.17.0.4, < 4.0)
+ listen (~> 3.0)
+ logger (>= 1.3.0)
+ parser (>= 3.2)
+ prism (>= 0.25.0)
+ rainbow (>= 2.2.2, < 4.0)
+ rbs (~> 4.0)
+ securerandom (>= 0.1)
+ strscan (>= 1.0.0)
+ terminal-table (>= 2, < 5)
+ uri (>= 0.12.0)
+ strscan (3.1.8)
+ terminal-table (4.0.0)
+ unicode-display_width (>= 1.1.1, < 4)
+ tsort (0.2.0)
+ unicode-display_width (3.3.0)
+ unicode-emoji (~> 4.3)
+ unicode-emoji (4.3.0)
+ uri (1.1.2)
zeitwerk (2.7.5)
PLATFORMS
@@ -60,21 +118,48 @@ DEPENDENCIES
roda
sequel
sqlite3
+ steep (= 2.1.0.dev.1)
CHECKSUMS
+ ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7
+ bundler (4.0.18) sha256=02d9a17429de1847b4e0c9f27a9ee4b20c0a74c0a641b4e77195d6019e3618ac
+ concurrent-ruby (1.3.8) sha256=b2f1be836e968ccc78ccfce277ea79c72a88633f22306782c16ff23fb415d1e1
+ csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456
drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
+ ffi (1.17.4-aarch64-linux-gnu) sha256=b208f06f91ffd8f5e1193da3cae3d2ccfc27fc36fba577baf698d26d91c080df
+ ffi (1.17.4-aarch64-linux-musl) sha256=9286b7a615f2676245283aef0a0a3b475ae3aae2bb5448baace630bb77b91f39
+ ffi (1.17.4-arm-linux-gnu) sha256=d6dbddf7cb77bf955411af5f187a65b8cd378cb003c15c05697f5feee1cb1564
+ ffi (1.17.4-arm-linux-musl) sha256=9d4838ded0465bef6e2426935f6bcc93134b6616785a84ffd2a3d82bc3cf6f95
+ ffi (1.17.4-arm64-darwin) sha256=19071aaf1419251b0a46852abf960e77330a3b334d13a4ab51d58b31a937001b
+ ffi (1.17.4-x86-linux-gnu) sha256=38e150df5f4ca555e25beca4090823ae09657bceded154e3c52f8631c1ed72cf
+ ffi (1.17.4-x86-linux-musl) sha256=fbeec0fc7c795bcf86f623bb18d31ea1820f7bd580e1703a3d3740d527437809
+ ffi (1.17.4-x86_64-darwin) sha256=aa70390523cf3235096cf64962b709b4cfbd5c082a2cb2ae714eb0fe2ccda496
+ ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d
+ ffi (1.17.4-x86_64-linux-musl) sha256=3fdf9888483de005f8ef8d1cf2d3b20d86626af206cbf780f6a6a12439a9c49e
+ fileutils (1.8.1) sha256=2d541a1ee34a9ed5c4f054be8fb819a26b5d654488a31dc8806917cf448b9cc3
+ json (3.1.0) sha256=3ce7ccf8fde58960177a3a756c31f292a4c1e1dedee884547096a5b0604e3bd8
+ language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0
+ listen (3.10.1) sha256=4c3f32855657f348747da4dd2372ff3790d90a6b1d3e4f21a3706adb2d04783c
+ logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
minitest (6.0.2) sha256=db6e57956f6ecc6134683b4c87467d6dd792323c7f0eea7b93f66bd284adbc3d
nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1
+ parser (3.3.13.0) sha256=e02f9b63c2fe05b0c68f692ba4df89b3cb113b663964f75d988fbebfd6c3deca
phlex (2.4.1) sha256=e596717fbfe38b5271840266758779ebe75092e02629f0c170287e6290a70b12
prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
puma (7.2.0) sha256=bf8ef4ab514a4e6d4554cb4326b2004eba5036ae05cf765cfe51aba9706a72a8
+ racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
rack (3.2.5) sha256=4cbd0974c0b79f7a139b4812004a62e4c60b145cba76422e288ee670601ed6d3
rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463
rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868
+ rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a
rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c
+ rb-fsevent (0.11.2) sha256=43900b972e7301d6570f64b850a5aa67833ee7d87b458ee92805d56b7318aefe
+ rb-inotify (0.11.1) sha256=a0a700441239b0ff18eb65e3866236cd78613d6b9f78fea1f9ac47a85e47be6e
+ rbs (4.2.0) sha256=51f7b886dcc05bc09e10b901daa6a81829f6adc03101d6ca9ea4aac6103e0674
refract (1.1.0) sha256=ee3b9627e39f7692831101e2fedd73e0d09a592ff5d5c05f171d14211fc7a9c7
roda (3.101.0) sha256=07b13bb5fbb8f606c17f8c21ae4897f629e59cb6f398665da6666920efd39843
+ securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
sequel (5.102.0) sha256=c25bb85a44ae59b3ed41ca7c32c33316cf81a6fcaeabedb4036ba141dde70919
sqlite3 (2.9.1-aarch64-linux-gnu) sha256=85535ddf1c37f116ebebe0330bbbffc2ccb55d09f69717a565f8cfb35142f136
sqlite3 (2.9.1-aarch64-linux-musl) sha256=646a28a655fc0298ff4266de0af89b66477a2d9ad65cebb5abad190bb64ed092
@@ -86,6 +171,13 @@ CHECKSUMS
sqlite3 (2.9.1-x86_64-darwin) sha256=5ce2c05eed8dc7c6debd560e2c5960e36521652b9a43bc3e42bc431db600c36f
sqlite3 (2.9.1-x86_64-linux-gnu) sha256=1cbb644204ed143e5c96f6d59b5c571ba6f18b18a9dc5aa11c101187ff227afd
sqlite3 (2.9.1-x86_64-linux-musl) sha256=bbd50dd1caca78b6c069701d9009ef714461495985d4c374ea1a1def061ba67c
+ steep (2.1.0.dev.1) sha256=af0b17c8d45a5e76d66e5218512db21e13e6a173e0763a109c2af97511cd5215
+ strscan (3.1.8) sha256=aae2db611a225559f21ffbb71765c9a4e60fd262534a9ea84f4f11c7f32f679e
+ terminal-table (4.0.0) sha256=f504793203f8251b2ea7c7068333053f0beeea26093ec9962e62ea79f94301d2
+ tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
+ unicode-display_width (3.3.0) sha256=4b7aa66a4b11db50f6f7e98411215cd0dd10eaecb665d36eec5bcacb3fa0b613
+ unicode-emoji (4.3.0) sha256=11c02fa73290378c066bb0562cd4c87d8e1b706fbbe0059ca12746a4244de8ce
+ uri (1.1.2) sha256=89292e98eabbef6248b0a0ed5e7e398e152f83f17560023a6d94a7f492b169fb
zeitwerk (2.7.5) sha256=d8da92128c09ea6ec62c949011b00ed4a20242b255293dd66bf41545398f73dd
BUNDLED WITH
diff --git a/Rakefile b/Rakefile
index cdd1c50..30154cc 100644
--- a/Rakefile
+++ b/Rakefile
@@ -9,4 +9,9 @@ task :dev do
sh "fd | entr -r rackup"
end
-task default: :test
+desc "Type check lib against the RBS comments in it"
+task :steep do
+ sh "steep", "check"
+end
+
+task default: %i[ steep test ]
diff --git a/Steepfile b/Steepfile
new file mode 100644
index 0000000..ae41aec
--- /dev/null
+++ b/Steepfile
@@ -0,0 +1,14 @@
+# Type checking for lib/. Signatures live in the code as RBS comments
+# (`#:` before a method, `# @rbs` for everything else), so the type and
+# the implementation are read and changed together. sig/ holds only what
+# inline comments cannot express, each file saying why.
+target :lib do
+ check "lib", inline: true
+ # Phlex ships no RBS of its own, so the views are ignored rather than
+ # stubbed. inline: true is load-bearing: `ignore` keeps separate lists
+ # for plain and inline sources, and a bare `ignore` leaves these checked.
+ ignore "lib/rank_king/views", inline: true
+ signature "sig"
+
+ library "securerandom"
+end
diff --git a/lib/open_skill/game.rb b/lib/open_skill/game.rb
index 062dc07..4304eba 100644
--- a/lib/open_skill/game.rb
+++ b/lib/open_skill/game.rb
@@ -12,13 +12,23 @@ module OpenSkill
# Intermediate structure for team-level aggregation during rating.
# Produced by Game#team_ratings, consumed by PlackettLuce and Util helpers.
- TeamRating = Data.define(:mu, :sigma_sq, :team, :rank) do
+ # Signed in sig/open_skill/rating.rbs: a Data class.
+ # @rbs skip
+ TeamRating = Data.define(:mu, :sigma_sq, :team, :rank)
+
+ # Reopened rather than defined in the block above, so the inline
+ # parser reads its type.
+ class TeamRating
+ #: () -> [Float, Float, Array[Rating], Integer]
def to_a = [mu, sigma_sq, team, rank]
end
class Game
- attr_reader :rank_groups, :teams, :rank
+ attr_reader :rank_groups #: Array[Array[Array[Rating]]]
+ attr_reader :teams #: Array[Array[Rating]]
+ attr_reader :rank #: Array[Integer]
+ #: (*Array[Array[Rating]] rank_groups) -> void
def initialize(*rank_groups)
@rank_groups = rank_groups.freeze
@teams = @rank_groups.flatten(1).freeze
@@ -26,17 +36,19 @@ module OpenSkill
freeze
end
+ #: (Array[Rating] winner, Array[Rating] loser) -> Game
def self.win_lose(winner, loser)
new([winner], [loser])
end
+ #: () -> Array[TeamRating]
def team_ratings
teams.zip(rankings).map { |team, rank|
TeamRating.new(
- mu: team.sum(&:mu),
- sigma_sq: team.sum { |p| p.sigma**2 },
+ mu: team.sum(0.0) { |p| p.mu },
+ sigma_sq: team.sum(0.0) { |p| p.sigma**2 },
team: team,
- rank: rank,
+ rank: rank, #: Integer
)
}
end
@@ -49,6 +61,7 @@ module OpenSkill
# The prior implementation produced sparse/positional ranks where gaps
# reflect the number of tied teams ([0, 0, 2]). Restore that if a
# future model needs it.
+ #: () -> Array[Integer]
def rankings
rank.chunk_while { |a, b| a == b }.flat_map.with_index { |group, i|
[i] * group.size
diff --git a/lib/open_skill/model.rb b/lib/open_skill/model.rb
index 2678808..ab1bfe5 100644
--- a/lib/open_skill/model.rb
+++ b/lib/open_skill/model.rb
@@ -2,27 +2,42 @@
module OpenSkill
class Model
- attr_reader :mu, :sigma, :beta, :beta_sq, :two_beta_sq,
- :epsilon, :z, :alpha, :target, :tau, :limit_sigma
+ attr_reader :mu, :sigma, :beta, :beta_sq, :two_beta_sq, :epsilon, :z, :alpha, :target #: Float
+ attr_reader :tau #: Float?
+ attr_reader :limit_sigma #: bool
- def initialize(mu: 25, z: 3, sigma: mu.to_f / z, beta: sigma / 2.0,
+ # Parameters are taken as written, 25 or 8.33, and kept as Floats.
+ #
+ # @rbs mu: number
+ # @rbs z: number
+ # @rbs sigma: number
+ # @rbs beta: number
+ # @rbs epsilon: number
+ # @rbs alpha: number
+ # @rbs target: number
+ # @rbs tau: number?
+ # @rbs prevent_sigma_increase: bool
+ # @rbs limit_sigma: bool
+ # @rbs return: void
+ def initialize(mu: 25, z: 3, sigma: mu.to_f / z, beta: sigma.to_f / 2,
epsilon: 0.0001, alpha: 1, target: 0, tau: nil,
prevent_sigma_increase: false, limit_sigma: prevent_sigma_increase)
- @mu = mu
- @sigma = sigma
- @beta = beta
- @beta_sq = beta**2
+ @mu = mu.to_f
+ @sigma = sigma.to_f
+ @beta = beta.to_f
+ @beta_sq = @beta**2
@two_beta_sq = 2 * @beta_sq
- @epsilon = epsilon
- @z = z
- @alpha = alpha
- @target = target
- @tau = tau
+ @epsilon = epsilon.to_f
+ @z = z.to_f
+ @alpha = alpha.to_f
+ @target = target.to_f
+ @tau = tau&.to_f
@limit_sigma = limit_sigma
@rating_model = PlackettLuce.new(self)
end
+ #: () -> Rating
def rating
Rating.new(mu, sigma)
end
@@ -34,8 +49,10 @@ module OpenSkill
# model.rate(Game.new([[first]], [[tied_a], [tied_b]], [[last]]))
#
# Returns a flat array of updated teams in the same (flattened) order.
+ #: (Game game) -> Array[Array[Rating]]
def rate(game)
processed_game = game
+ tau = self.tau
# If tau is provided, use additive dynamics factor to keep ratings pliable
if tau
@@ -62,6 +79,7 @@ module OpenSkill
new_ratings
end
+ #: (Rating rating) -> Float
def ordinal(rating)
target + alpha * (rating.mu - z * rating.sigma)
end
diff --git a/lib/open_skill/plackett_luce.rb b/lib/open_skill/plackett_luce.rb
index 0f257bc..f0cefee 100644
--- a/lib/open_skill/plackett_luce.rb
+++ b/lib/open_skill/plackett_luce.rb
@@ -4,11 +4,13 @@
module OpenSkill
class PlackettLuce
+ #: (Model model) -> void
def initialize(model)
@beta_sq = model.beta_sq
@epsilon = model.epsilon
end
+ #: (Game game) -> Array[Array[Rating]]
def rate(game)
return game.teams if game.teams.length <= 1
diff --git a/lib/open_skill/rating.rb b/lib/open_skill/rating.rb
index cfdb203..9ab0f2b 100644
--- a/lib/open_skill/rating.rb
+++ b/lib/open_skill/rating.rb
@@ -3,5 +3,7 @@
# Port of https://github.com/philihp/openskill.js/blob/main/src/rating.ts
module OpenSkill
+ # Signed in sig/open_skill/rating.rbs: a Data class.
+ # @rbs skip
Rating = Data.define(:mu, :sigma)
end
diff --git a/lib/open_skill/statistics.rb b/lib/open_skill/statistics.rb
index 8de0b2d..1cd3b82 100644
--- a/lib/open_skill/statistics.rb
+++ b/lib/open_skill/statistics.rb
@@ -5,15 +5,18 @@
module OpenSkill
module Statistics
# Standard normal PDF
+ #: (Float x) -> Float
def self.phi_minor(x)
Math.exp(-0.5 * x * x) / Math.sqrt(2 * Math::PI)
end
# Standard normal CDF via Math.erf
+ #: (Float x) -> Float
def self.phi_major(x)
0.5 * (1.0 + Math.erf(x / Math.sqrt(2)))
end
+ #: (Float x, Float t) -> Float
def self.v(x, t)
xt = x - t
denom = phi_major(xt)
@@ -22,17 +25,19 @@ module OpenSkill
phi_minor(xt) / denom
end
+ #: (Float x, Float t) -> Float
def self.w(x, t)
xt = x - t
denom = phi_major(xt)
if denom < Float::EPSILON
- return x.negative? ? 1 : 0
+ return x.negative? ? 1.0 : 0.0
end
vxt = v(x, t)
vxt * (vxt + xt)
end
+ #: (Float x, Float t) -> Float
def self.vt(x, t)
xx = x.abs
b = phi_major(t - xx) - phi_major(-t - xx)
@@ -44,6 +49,7 @@ module OpenSkill
(x.negative? ? -a : a) / b
end
+ #: (Float x, Float t) -> Float
def self.wt(x, t)
xx = x.abs
b = phi_major(t - xx) - phi_major(-t - xx)
diff --git a/lib/open_skill/util.rb b/lib/open_skill/util.rb
index 3882c84..96daadd 100644
--- a/lib/open_skill/util.rb
+++ b/lib/open_skill/util.rb
@@ -4,24 +4,28 @@
module OpenSkill
module Util
+ #: (Integer q, Integer i) -> Float
def self.score(q, i)
case q <=> i
when -1 then 0.0
when 0 then 0.5
- when 1 then 1.0
+ else 1.0
end
end
+ #: [T] (Array[T] ranks) -> Array[Array[T]]
def self.ladder_pairs(ranks)
return [[]] if ranks.length <= 1
[nil, *ranks, nil].each_cons(3).map { |l, _, r| [l, r].compact }
end
+ #: (Array[TeamRating] team_ratings, Float beta_sq) -> Float
def self.util_c(team_ratings, beta_sq)
Math.sqrt(team_ratings.sum { |tr| tr.sigma_sq + beta_sq })
end
+ #: (Array[TeamRating] team_ratings, Float c) -> Array[Float]
def self.util_sum_q(team_ratings, c)
team_ratings.map { |qr|
team_ratings
@@ -30,10 +34,12 @@ module OpenSkill
}
end
+ #: (Array[TeamRating] team_ratings) -> Array[Integer]
def self.util_a(team_ratings)
team_ratings.map { |ir| team_ratings.count { |qr| ir.rank == qr.rank } }
end
+ #: (number c, Integer _k, TeamRating tr) -> Float
def self.gamma(c, _k, tr)
Math.sqrt(tr.sigma_sq) / c
end
diff --git a/lib/rank_king/config.rb b/lib/rank_king/config.rb
index 712f3b7..efbcda8 100644
--- a/lib/rank_king/config.rb
+++ b/lib/rank_king/config.rb
@@ -3,16 +3,19 @@
require "securerandom"
module RankKing
+ # @rbs skip
Config = Data.define(
:database_url,
:session_secret,
)
class Config
+ #: () -> String
def to_s
"Config(database=#{database_url})"
end
+ #: (?Hash[String, String] env) -> Config
def self.from_env(env = ENV)
new(
database_url: env.fetch("DATABASE_URL", ":memory:"),
diff --git a/lib/rank_king/db.rb b/lib/rank_king/db.rb
index 8b9fc1c..d9ffb7a 100644
--- a/lib/rank_king/db.rb
+++ b/lib/rank_king/db.rb
@@ -7,5 +7,11 @@ require_relative "config"
module RankKing
DB = Sequel.sqlite(CONFIG.database_url)
Sequel.extension :migration
- Sequel::Migrator.run(DB, File.expand_path("../../db/migrate", __dir__))
+ # `__dir__` is nil only for code with no file behind it, which a
+ # required library is not, and the assertion says so to Steep.
+ MIGRATIONS = File.expand_path(
+ "../../db/migrate",
+ __dir__, #: String
+ )
+ Sequel::Migrator.run(DB, MIGRATIONS)
end
diff --git a/lib/rank_king/models.rb b/lib/rank_king/models.rb
index 6bc49b6..8c0f6ac 100644
--- a/lib/rank_king/models.rb
+++ b/lib/rank_king/models.rb
@@ -5,19 +5,24 @@ require_relative "db"
Sequel::Model.plugin :timestamps, update_on_create: true
module RankKing
+ # Signed in sig/rank_king/models.rbs: the superclass is not a constant.
+ # @rbs skip
class List < Sequel::Model(DB[:lists])
one_to_many :items, class: "RankKing::Item"
one_to_many :axes, class: "RankKing::Axis"
end
+ # @rbs skip
class Item < Sequel::Model(DB[:items])
many_to_one :list, class: "RankKing::List"
end
+ # @rbs skip
class Axis < Sequel::Model(DB[:axes])
one_to_many :results, class: "RankKing::Result"
end
+ # @rbs skip
class Result < Sequel::Model(DB[:results])
many_to_one :axis, class: "RankKing::Axis"
many_to_one :winner, class: "RankKing::Item"
diff --git a/sig/gems/roda.rbs b/sig/gems/roda.rbs
new file mode 100644
index 0000000..f329db5
--- /dev/null
+++ b/sig/gems/roda.rbs
@@ -0,0 +1,36 @@
+# Roda ships no signatures. This is the routing surface web.rb uses and
+# no more of it.
+#
+# Matchers stay untyped on purpose. Roda's matcher vocabulary is open,
+# and the block arguments a match yields depend on which matcher matched.
+class Roda
+ def self.plugin: (Symbol plugin, *untyped args, **untyped options) -> void
+
+ # The block is the router, run against a fresh instance per request.
+ def self.route: () { (RodaRequest) [self: instance] -> untyped } -> void
+
+ def self.freeze: () -> singleton(Roda)
+
+ def self.app: () -> untyped
+
+ # The route_csrf plugin's: halts a request whose token does not check
+ # out, and hands out the token a form has to post back.
+ def check_csrf!: () -> void
+ def csrf_token: (?String? path, ?String? method) -> String
+
+ class RodaRequest
+ def is: (*untyped matchers) { (*untyped) -> untyped } -> void
+ def on: (*untyped matchers) { (*untyped) -> untyped } -> void
+ def root: () { () -> untyped } -> void
+ def get: (*untyped matchers) ?{ (*untyped) -> untyped } -> void
+ def post: (*untyped matchers) ?{ (*untyped) -> untyped } -> void
+
+ # Values stay untyped: repeated keys arrive as arrays.
+ def params: () -> Hash[String, untyped]
+
+ def redirect: (String path, ?Integer status) -> bot
+
+ # The halt plugin's: ends the request with this status.
+ def halt: (?Integer status) -> bot
+ end
+end
diff --git a/sig/gems/sequel.rbs b/sig/gems/sequel.rbs
new file mode 100644
index 0000000..54ae822
--- /dev/null
+++ b/sig/gems/sequel.rbs
@@ -0,0 +1,53 @@
+# Sequel ships no signatures, so this describes the parts rank-king
+# calls: one SQLite database, the datasets over it, and the model
+# plumbing the four models are built from. Anything called that is
+# missing here is a type error rather than a silent `untyped`.
+module Sequel
+ type row = Hash[Symbol, untyped]
+
+ def self.sqlite: (String path, **untyped options) -> Database
+
+ # Loads an extension into Sequel itself; `:migration` is the one this
+ # app asks for, and it is what defines Migrator.
+ def self.extension: (*Symbol names) -> void
+
+ # The superclass of a model over a table, as `Sequel::Model(DB[:t])`.
+ def self.Model: (Dataset[row] source) -> singleton(Model)
+
+ class Database
+ def []: (Symbol table) -> Dataset[row]
+
+ def transaction: [T] () { () -> T } -> T
+ end
+
+ # Generic over what a row comes back as: a hash from a plain dataset,
+ # a model instance from a model's association dataset.
+ class Dataset[out T]
+ def where: (*untyped conditions) -> Dataset[T]
+
+ def first: () -> T?
+ end
+
+ class Model
+ def self.plugin: (Symbol name, **untyped options) -> void
+
+ def self.one_to_many: (Symbol name, **untyped options) -> void
+
+ def self.many_to_one: (Symbol name, **untyped options) -> void
+
+ def self.create: (**untyped values) -> instance
+
+ def self.all: () -> Array[instance]
+
+ # The row with this primary key, or nil.
+ def self.[]: (untyped id) -> instance?
+
+ def id: () -> Integer
+ end
+
+ # Applies the migrations in a directory that the database has not run
+ # yet, and does nothing to one that is already current.
+ class Migrator
+ def self.run: (Database database, String directory, **untyped options) -> untyped
+ end
+end
diff --git a/sig/open_skill.rbs b/sig/open_skill.rbs
new file mode 100644
index 0000000..e0f554e
--- /dev/null
+++ b/sig/open_skill.rbs
@@ -0,0 +1,6 @@
+module OpenSkill
+ # Model parameters arrive as whatever the caller wrote, 25 or 8.33.
+ # The model keeps them as Floats, and everything computed from them is
+ # one.
+ type number = Integer | Float
+end
diff --git a/sig/open_skill/rating.rbs b/sig/open_skill/rating.rbs
new file mode 100644
index 0000000..a410d33
--- /dev/null
+++ b/sig/open_skill/rating.rbs
@@ -0,0 +1,21 @@
+# Rating and TeamRating are Data classes, whose members the inline parser
+# cannot read. They are marked `@rbs skip` and signed here instead.
+module OpenSkill
+ class Rating
+ attr_reader mu: Float
+ attr_reader sigma: Float
+
+ def self.new: (Float mu, Float sigma) -> instance
+ | (mu: Float, sigma: Float) -> instance
+ end
+
+ class TeamRating
+ attr_reader mu: Float
+ attr_reader sigma_sq: Float
+ attr_reader team: Array[Rating]
+ attr_reader rank: Integer
+
+ def self.new: (mu: Float, sigma_sq: Float, team: Array[Rating], rank: Integer) -> instance
+
+ end
+end
diff --git a/sig/rank_king/config.rbs b/sig/rank_king/config.rbs
new file mode 100644
index 0000000..f359a38
--- /dev/null
+++ b/sig/rank_king/config.rbs
@@ -0,0 +1,11 @@
+# Config is a Data class, whose members the inline parser cannot read.
+# It is marked `@rbs skip` in config.rb and its members are signed here;
+# the methods reopened onto it carry their own inline types.
+module RankKing
+ class Config
+ attr_reader database_url: String
+ attr_reader session_secret: String
+
+ def self.new: (database_url: String, session_secret: String) -> instance
+ end
+end
diff --git a/sig/rank_king/models.rbs b/sig/rank_king/models.rbs
new file mode 100644
index 0000000..4c373f5
--- /dev/null
+++ b/sig/rank_king/models.rbs
@@ -0,0 +1,33 @@
+# `class List < Sequel::Model(DB[:lists])` has no constant superclass for
+# the inline parser to read, so the models are marked `@rbs skip` in
+# models.rb and signed here: their columns and associations.
+module RankKing
+ class List < Sequel::Model
+ def name: () -> String
+ def items: () -> Array[Item]
+ def axes: () -> Array[Axis]
+ def items_dataset: () -> Sequel::Dataset[Item]
+ def axes_dataset: () -> Sequel::Dataset[Axis]
+ end
+
+ class Item < Sequel::Model
+ def name: () -> String
+ def list_id: () -> Integer
+ def list: () -> List
+ end
+
+ class Axis < Sequel::Model
+ def name: () -> String
+ def list_id: () -> Integer
+ def results: () -> Array[Result]
+ end
+
+ class Result < Sequel::Model
+ def axis_id: () -> Integer
+ def winner_id: () -> Integer
+ def loser_id: () -> Integer
+ def axis: () -> Axis
+ def winner: () -> Item
+ def loser: () -> Item
+ end
+end
diff --git a/sig/rank_king/views.rbs b/sig/rank_king/views.rbs
new file mode 100644
index 0000000..0d5cb78
--- /dev/null
+++ b/sig/rank_king/views.rbs
@@ -0,0 +1,18 @@
+# The views are Phlex components, and Phlex ships no RBS, so the
+# Steepfile ignores lib/rank_king/views. This is the surface web.rb
+# calls on them.
+module RankKing
+ module Views
+ class Home
+ def initialize: (lists: Array[List], csrf: Method) -> void
+ def call: () -> String
+ end
+
+ module ListViews
+ class Show
+ def initialize: (list: List, csrf: Method) -> void
+ def call: () -> String
+ end
+ end
+ end
+end