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
change
commit 55801b3d0298139b496bc932d7091f81b08ac5ec
author Claude <noreply@anthropic.com>
date
parent rstwlmuo
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