GitHub - szge/rank_anything: rank anything

GitHub

19 min read Original article ↗

What League of Legends rank is your income?

rank_anything converts a value from one statistical distribution into the equivalent value in another: your income in CAD, your SAT score, your Valorant rank. It works by matching percentiles.

$ rank-anything convert 120k --from canada-income --to lol-rank
120,000 CAD in Canadian individual income (CAD, before tax)
  = better than 91.9% (top 8.1%)
  ≈ Emerald II  (86% of the way through) in League of Legends solo queue rank

It's written in TypeScript and ships with several real datasets. Use it as a command-line tool, or as a library in Node or in the browser: the whole thing runs client-side, with the datasets embedded. To add your own, write a small JSON file; there's a built-in prompt for getting one out of Perplexity, ChatGPT or Claude.

Missing a game, exam or statistic? Please add it! Dataset pull requests are the most useful contribution to this project, and each new dataset works with every existing one. See CONTRIBUTING.md.


Install

Needs Node.js 18 or newer.

npm install -g rank-anything   # installs the `rank-anything` command
# or run it once without installing:
npx rank-anything --help

To use it as a library, npm install rank-anything; see Library use.

From source:

git clone https://github.com/szge/rank_anything.git && cd rank_anything
npm install               # also builds dist/
npm link                  # installs the `rank-anything` command
# or, without installing:
node dist/bin.js --help
node src/bin.ts --help    # straight from source, on Node 22.18+

Quick start

rank-anything list                                           # what's available
rank-anything convert 85k --from canada-income --to lol-rank # one target
rank-anything convert E5 --from meta-level                   # compare against everything
rank-anything convert 1520 --from sat-score --to lol-rank --raw  # just the answer: Diamond II
rank-anything percentile 1520 --from sat-score               # just the percentile: 97.6
rank-anything table --from valorant-rank --to lol-rank       # full side-by-side mapping
rank-anything show lol-rank                                  # inspect a distribution

Examples

Your Meta level in every other distribution. Leave out --to to compare against all of them:

$ rank-anything convert E5 --from meta-level
E5 in Meta software engineering level
  = better than 71.5% (top 28.5%)

distribution    equivalent
--------------  -------------------------------------
canada-income   67,400 CAD
iq              108.52 IQ
lol-rank        Platinum III  (7% of the way through)
sat-score       1,173 points
us-male-height  179.72 cm
valorant-rank   Platinum 2  (12% of the way through)

Game rank to game rank. Label matching ignores case and treats roman numerals and digits the same, and you can abbreviate. plat 2 means Platinum II, and a bare tier such as gold or imm covers the whole tier:

$ rank-anything convert imm --from valorant-rank --to lol-rank
Immortal 1 – Immortal 3 in Valorant competitive rank
  = better than 99.27% (top 0.73%)
  ≈ Master  (21% of the way through) in League of Legends solo queue rank

Game ranks, chess and running:

$ rank-anything convert gold --from lol-rank --to lichess-rapid --to dota2-rank --to cs2-premier
Gold IV – Gold I in League of Legends solo queue rank
  = better than 52% (top 48%)

distribution   equivalent
-------------  ----------------------------------
lichess-rapid  1,422 rating
dota2-rank     Archon 1  (34% of the way through)
cs2-premier    12,240 rating

$ rank-anything convert 25:20 --from 5k-time --to marathon-time
25:20 in 5K race finish time
  = better than 90% (top 10%)
  ≈ 3:31:46 in Marathon finish time

Common abbreviations work too. Acronyms such as gc 2 (Grand Champion 2) are matched automatically, and nicknames such as SSL, GM and pred are defined as aliases in the dataset files.

Where you sit inside a tier. A labeled value defaults to the middle of its band. Use --position to choose a point from 0 (just promoted) to 1 (about to promote):

rank-anything convert "Platinum II" --from lol-rank --to valorant-rank --position 0.9

Percentiles directly. The pseudo-distributions percentile ("better than X%") and top ("top X%") work anywhere a distribution name is accepted:

rank-anything convert 5 --from top --to iq          # top 5% IQ → 124.67
rank-anything convert 130 --from iq --to top        # IQ 130 → top 2.28%
rank-anything convert 99 --from percentile --to canada-income

Full mapping tables:

$ rank-anything table --from meta-level --to lol-rank
meta-level  better than  lol-rank
----------  -----------  -------------------------------------
E3          14%          Bronze II  (60% of the way through)
E4          43%          Gold IV  (33% of the way through)
E5          71.5%        Platinum III  (7% of the way through)
E6          90.5%        Emerald II  (18% of the way through)
E7          97.5%        Diamond II  (24% of the way through)
E8          99.45%       Master  (43% of the way through)
E9          99.9%        Master  (97% of the way through)

Any file, no install step. Pass a .json distribution, or a .csv / .txt file containing a list of numbers:

rank-anything convert 97k --from examples/team-salaries.json --to canada-income
rank-anything convert 97k --from examples/team-salaries.csv --to lol-rank

Scripting. For output you can pipe into other programs, add --raw to print only the equivalent value, or use percentile to print only the percentile, as a bare number:

$ rank-anything convert 1520 --from sat-score --to lol-rank --raw
Diamond II

$ rank-anything percentile 1520 --from sat-score
97.6

$ rank-anything percentile 1520 --from sat-score --top
2.4

With several targets (or none, which compares against all), --raw prints one name<TAB>value line per target. Numbers come out without units or thousands separators, times as clock times, and percentiles without %:

$ rank-anything convert 1520 -f sat-score -t lol-rank -t canada-income -t percentile -t marathon-time --raw
lol-rank	Diamond II
canada-income	239970
percentile	97.6
marathon-time	2:57:10

For everything at once (unrounded percentile, position within a tier, and whether a value was clamped or extrapolated), add --json instead:

rank-anything convert Challenger -f lol-rank -t valorant-rank --json

Numbers can be written the way people usually write them: 85000, 85,000, 85k, $85k, C$85k, 1.2M.

Commands

Command What it does
convert VALUE -f SRC [-t DST ...] [-p POS] [--json | --raw] Convert a value. Repeat -t for several targets, or omit it to compare against all distributions. --raw prints only the equivalent value(s).
percentile VALUE -f SRC [-p POS] [--top] Print just the percentile of a value ("better than X%"), or with --top the top X%, as a bare number.
table -f SRC -t DST Map every label of SRC onto DST. For a numeric SRC, the rows are standard percentiles.
show NAME Metadata plus the data table (bands, points, or standard percentiles).
list Every built-in, user-installed and pseudo distribution.
import FILE [-c COL] [--unit U] [--lower-is-better] [-o OUT | --add] Turn a .csv/.tsv/.txt of numbers into a JSON distribution, or install it directly with --add.
add FILE [--name N] [--force] Validate a distribution (.json, or a .csv/.txt of numbers) and install it, so it can be used by name.
validate FILE Check a distribution file without installing it.
remove NAME Uninstall a user distribution.
prompt "TOPIC" [--type T] Print an AI prompt that returns a distribution as JSON.

convert can be shortened to c and list to ls. Run rank-anything COMMAND --help for details on any command.

Built-in distributions

Run rank-anything list to see them all, and rank-anything show NAME for the data and the source of any one.

Competitive games

Name Values Source
lol-rank Iron IV … Challenger Esports Tales, Aug 2026, all regions
valorant-rank Iron 1 … Radiant Esports Tales, V26 Act 5
dota2-rank Herald 1 … Immortal Esports Tales (Stratz / Valve API), Aug 2026
cs2-premier Premier rating Esports Tales (Leetify), Jul 2026
overwatch-rank Bronze … Champion (tiers only) Esports Tales, Season 17, Jul 2025 (latest published)
rocket-league-rank Bronze 1 … Supersonic Legend Esports Tales, Ranked Doubles, Season 22
apex-rank Rookie IV … Apex Predator Esports Tales, Season 30
r6-rank Copper 5 … Champion Esports Tales (official Ubisoft data), Y10S3
lichess-blitz, lichess-rapid rating Lichess live stats: every player active that week
chesscom-rapid rating approximate: community-reported ranges; Chess.com doesn't publish its distribution
github-stars stars GitHub search counts of repos with ≥ N stars; population = the 32M public repos with at least 1 star
monkeytype-wpm words per minute Monkeytype API: 60-second English personal bests. People who use a typing-test site type faster than average.

Fitness and body

Name Values Source
5k-time e.g. 25:20 RunRepeat: 35M race results, all runners
marathon-time e.g. 3:45 or 3:45:30 same as above
us-male-height normal(175.4, 7.6) cm CDC NHANES (approximate)
us-female-height normal(161.3, 7.1) cm CDC NHANES 2015–2018 (approximate)

Money, work and school

Name Values Source
us-income USD US individual income (adjusted gross income on individual tax returns), 2023. Median and up: IRS Table 4.1 percentile floors, to the top 0.001%. Below median: IRS Table 1.1. Method: scripts/build_us_income.ts
us-net-worth USD Household net worth, 2022 Survey of Consumer Finances (Federal Reserve): weighted percentiles computed from the public microdata. Method: scripts/build_net_worth.ts
canada-net-worth CAD Family net worth, 2023 Survey of Financial Security (Statistics Canada) microdata; top 1% from the Parliamentary Budget Officer
credit-score 300–850 FICO Score 8 by range, Experian, Sep 2025 (only 5 ranges published)
canada-income CAD Statistics Canada 2023 top-1%/0.1%/0.01% cutoffs; lower percentiles are approximate
meta-level E3 … E9 rough community estimate (not official)
sat-score 400–1600 College Board SAT User Percentiles (via Larry Learns)
act-score 1–36 ACT National Ranks, Composite, 2026–27
gre-verbal, gre-quant 130–170 ETS GRE interpretive data, Jul 2022–Jun 2025
lsat-score 120–180 LSAC percentile table, 2023–2026
mcat-score 472–528 AAMC percentile ranks, in effect May 2026–Apr 2027
iq normal(100, 15) Standard test norming

Pseudo-distributions: percentile ("better than X%") and top ("top X%").

Refreshing data. The scripts in scripts/ rebuild datasets from their sources (run them with node scripts/NAME.ts on Node 22.18+, or add --out DIR to write somewhere other than data/). build_live_stats.ts fetches Lichess and Monkeytype, build_github_stars.ts queries GitHub search, and build_net_worth.ts recomputes net worth from the Fed and Statistics Canada microdata. build_test_scores.ts and build_us_income.ts hold the published tables. Every other dataset is a fixed snapshot whose source field says where to look for newer figures. Pull requests with newer numbers are welcome.

Want another one? Adding a dataset takes one JSON file with a source cited; CONTRIBUTING.md walks through it. Good candidates include more games (Fortnite, Marvel Rivals, osu!), more countries' income and wealth, other exams (IELTS, GMAT, AP) and more sports.

Test scores use "% of test takers scoring below", so a percentile reads as "better than X%". ACT and MCAT publish "% at or below", which is converted exactly. Scores outside a test's scale are clamped.

Leaderboards (speedruns etc.). There's no general speedrun distribution, because every game and category has its own leaderboard. Export a leaderboard's times to a CSV and use it directly with rank-anything import times.csv --lower-is-better.

The examples/ folder has one file for each input format: team-salaries.json (samples), marathon-times.json (lower is better), chess-ratings.json (numeric histogram), team-salaries.csv (a spreadsheet export) and 5k-times.txt (a plain list of numbers).

How it works

Every distribution is reduced to a monotone mapping between its values and a rank percentile, meaning the share of the population you're better than (0–100). A conversion is two lookups:

value ──source──▶ percentile ──target──▶ equivalent value

When the value you ask about isn't one of the known points, the tool interpolates:

  • Numeric distributions (income, SAT, samples) are piecewise-linear between known points. For example, if C$45,000 is the 50th percentile and C$54,000 is the 60th, then C$49,500 is the 55th.
  • Labeled distributions (ranks, levels) give each label a band of percentiles, and positions inside a band are interpolated linearly. Gold II covers the 54.6th–60th percentiles, so the 57.3rd percentile is Gold II, 50% of the way through.
  • Parametric distributions (normal, lognormal) use the exact CDF.

Values beyond the known data

Numeric data only covers a limited range. For example, the highest income point is the top 0.01% cutoff, C$3.49M. Past the outermost known points, the tool extends the distribution's tail instead of stopping. It fits the tail to the last two known points, so extreme values still get different percentiles:

$ rank-anything convert 12M --from canada-income --to lol-rank
12,000,000 CAD  [beyond known data, extrapolated] in Canadian individual income (CAD, before tax)
  = better than 99.9988% (top 0.0012%)
  ≈ Challenger  (95% of the way through) in League of Legends solo queue rank

$ rank-anything convert 1.2B --from canada-income --to lol-rank
1,200,000,000 CAD  [beyond known data, extrapolated] in Canadian individual income (CAD, before tax)
  = better than 99.99999962% (top 0.00000038%)
  ≈ Challenger  (100% of the way through) in League of Legends solo queue rank

Which tail is used depends on the data (the "tail" field in the JSON):

tail Behaviour past the last point Used when ("auto", the default)
pareto the share beyond x falls off as a power of x (a power law). This suits incomes, wealth, follower counts. the outermost values are positive
exponential the share beyond x falls off exponentially with distance values can be zero or negative
clamp pinned to the endpoint an endpoint is at 0% or 100%, which marks a hard limit (e.g. SAT 1600)

Sparse top-end data: "interpolation": "loglog"

Income-like data often has only a few widely spaced points near the top, for example $675,602 (top 1%) and $3,100,950 (top 0.1%). A straight line between two such points overstates the values in between: it puts the top-0.5% cutoff at ~$2.02M instead of ~$1.07M. Setting "interpolation": "loglog" makes every segment above the median follow the power law through its two endpoints (the same Pareto shape used for the tail). Segments below the median stay linear. us-income uses this setting.

Clamped results are marked [outside known range, clamped] and extrapolated ones [beyond known data, extrapolated]. Datasets with a world record stop there instead (see World records). Extrapolated results are estimates: the further they are from the data, the less reliable they get.

Adding your own distribution

A distribution is a JSON file. type must be one of the five formats below, and data holds the numbers.

1. frequency: label: frequency

Give the share of the population in each category, ordered from worst to best. The values can be percentages or raw counts, and they don't need to add up to 100.

{ "name": "lol-rank", "type": "frequency",
  "data": { "Iron IV": 0.38, "Iron III": 0.42, "Gold IV": 8.3, "Challenger": 0.023 } }

If every key is a number (for example rating buckets such as "1200": 13), the data is treated as a numeric histogram and values between buckets are interpolated. See examples/chess-ratings.json.

2. percentile: label: percentile

Give known values and the percentile at each. Keys can be numbers, which are interpolated between, or labels, which become bands. Set percentile_kind to match how your source reports the numbers:

  • "below" (the default) means X% of people are below this value.
  • "top" means this value and above make up the top X%.
{ "name": "canada-income", "type": "percentile", "percentile_kind": "below", "unit": "CAD",
  "data": { "0": 0, "45000": 50, "108000": 90, "293800": 99, "930100": 99.9 } }
{ "name": "meta-level", "type": "percentile", "percentile_kind": "top",
  "data": { "E3": 100, "E4": 72, "E5": 42, "E6": 15, "E7": 4, "E8": 0.9, "E9": 0.2 } }

With labels, each label's band starts at its percentile and ends where the next label begins.

3. samples: a list of numbers

Give the raw observations. Order doesn't matter: the numbers are sorted for you, so you can paste them in any order. Duplicates are fine too.

{ "name": "team-salaries", "type": "samples", "unit": "USD",
  "data": [104000, 68000, 150000, 85000, 72000, 91000] }

With n samples, each one is placed at the middle of its 1/n share of the population, so the smallest of 10 samples is the 5th percentile and the largest is the 95th. Values in between are interpolated, and values past the smallest or largest sample follow the fitted tail.

If your numbers are in a spreadsheet or text file, you don't need to write this JSON by hand. See From a CSV or text file.

4. normal / lognormal

{ "name": "iq", "type": "normal", "data": { "mean": 100, "std": 15 } }
{ "name": "incomes", "type": "lognormal", "data": { "median": 60000, "sigma": 0.8 } }

Times (race results, speedruns)

Add "duration": "mm:ss" or "duration": "h:mm" and write the values as clock times. They're stored in seconds and shown as clock times. Input accepts 25:20, 1:02:03, 3h31m, 25m20s, or a bare number of minutes. The style decides how a two-part time is read: 3:31 is 3 min 31 s with mm:ss, and 3 h 31 min with h:mm. Add "higher_is_better": false when faster is better.

{ "name": "5k-time", "type": "percentile", "duration": "mm:ss", "higher_is_better": false,
  "data": { "18:40": 1, "25:20": 10, "34:37": 50, "50:04": 90 } }

World records: "record"

A fitted tail doesn't know where human limits are. On its own, the marathon data would put the 99.999th percentile at 1:29. Set "record" to the best value anyone has achieved, and anything better is pinned to it:

"record": { "value": "2:00:35", "holder": "Kelvin Kiptum", "date": "2023-10-08", "source": "https://..." }

("record": "2:00:35" works too.) The value uses the distribution's own units and time format. Capped results are marked [capped at the record], and the placement has atRecord and clamped set (at_record in --json). The record has to be in the top half; otherwise higher_is_better is probably the wrong way round. marathon-time, 5k-time and both heights have records.

data can also be written as a list of pairs, [["Iron IV", 0.38], ...], if you want the order to be explicit.

Then:

rank-anything validate my-dist.json   # check it
rank-anything add my-dist.json        # install → usable as `-f my-dist`

Installed files go to ~/.rank_anything/distributions/. Set RANK_ANYTHING_HOME to use a different location. A user distribution with the same name as a built-in one takes precedence.

From a CSV or text file

If you have a pile of raw numbers (a spreadsheet export, a column of survey answers, times copied from a results page), use the file as it is. As with samples, the order of the numbers doesn't matter.

Use it directly. A .csv, .tsv or .txt path works anywhere a distribution name does:

rank-anything convert 97k --from examples/team-salaries.csv --to lol-rank

Or import it to save it as a named distribution, with a unit and a direction:

$ rank-anything import examples/5k-times.txt --name parkrun --unit min --lower-is-better --add
Imported 15 values and installed 'parkrun' -> ~/.rank_anything/distributions/parkrun.json

$ rank-anything convert 20 --from parkrun --to lol-rank
20 min in parkrun
  = better than 89.3% (top 10.7%)
  ≈ Emerald III  (70% of the way through) in League of Legends solo queue rank

Leave out --add to write NAME.json for you to review or edit first. Use -o out.json to choose the path, or -o - to print to stdout.

What the importer accepts:

  • .txt: numbers one per line, or separated by spaces, tabs, commas or semicolons. Lines starting with # are comments. Thousands separators such as 85,000 are read as one number.
  • .csv / .tsv: one column of numbers is used. A header row is detected and skipped automatically, as are blank cells. If the file has several numeric columns, choose one with --column salary (header name) or --column 3 (1-based position).
  • Number formats: 85000, 85,000, $85,000, 85k, 1.2M, 1.5e3.
# examples/5k-times.txt
24.5 31.2 19.8 27.0 22.1
35.4, 28.3, 21.7, 26.4, 30.0
18.9
# examples/team-salaries.csv: the only numeric column (salary) is picked automatically
name,team,salary
Ana,Platform,"$104,000"
Ben,Platform,"$85,000"

Getting distributions from AI tools

rank-anything prompt prints a prompt that asks an AI assistant (Perplexity works well because it cites sources) to return the data as valid JSON:

rank-anything prompt "Chess.com rapid ratings" --type frequency | pbcopy
rank-anything prompt "US household income, 2025"                # let the AI pick the format

Paste the prompt into the assistant, save the JSON it returns to a file, then run rank-anything validate file.json and rank-anything add file.json. Check the numbers against the cited sources, because AI tools do sometimes make up statistics.

Library use (Node and browser)

import { convert, percentile, load, fromDict } from "rank-anything";

const c = convert("85k", "canada-income", "lol-rank");
c.percentile;                     // 81.48...
c.targetPlacement.value;          // 'Platinum I'
c.targetPlacement.position;       // 0.74  (how far through the tier)

percentile("E6", "meta-level");   // 90.5
fromDict({ type: "normal", data: { mean: 0, std: 1 } });

There are three entry points:

Import Contents Runs in
rank-anything Everything below, plus the built-in datasets (about 26 KB of JSON). browser, Node, workers
rank-anything/core The same, without the built-in datasets: bring your own data. browser, Node, workers
rank-anything/node Also loads file paths (load("examples/marathon-times.json")) and the user directory that rank-anything add installs into. This is what the CLI uses. Node

A pre-bundled ES module for a plain <script type="module">, with no build step, is at dist/browser/rank-anything.js (also exported as rank-anything/browser). examples/web/index.html is a small demo page that uses it, including adding a dataset from an uploaded file.

Your own data in the browser. User distributions live in memory in a Registry. The default one behind convert, load and friends is exported as registry:

import { registry, specFromText, convertMany, describe } from "rank-anything";

// From an object, from JSON text, or from an uploaded .json/.csv/.txt file
registry.add({ name: "team", type: "samples", data: [68000, 85000, 97000, 150000] });
registry.add(specFromText(await file.text(), file.name));

const { placement, results } = convertMany("90k", "team");   // against every distribution
for (const c of results) console.log(c.target.name, describe(c.target, c.targetPlacement));

Use new Registry({ builtins: BUILTIN_SPECS }) for a separate set, or new Registry() from rank-anything/core for one with no built-ins. describe, rankLine, dataTable and mappingTable produce the same text and tables as the CLI, for showing results in a UI.

One JavaScript quirk: objects list integer-like keys ("10", "2") first, whatever order they were written in. That matters for labeled frequency data, where the order is the ranking. JSON text passed to registry.add, specFromText or parseJson keeps its original order. If you build a spec as a JS object with integer-like labels, write data as a list of pairs instead.

Development

npm install
npm test            # vitest
npm run typecheck   # also checks that the browser entry points don't use Node APIs
npm run build       # dist/: compiled modules, type declarations, browser bundle
npm run gen         # after editing data/*.json: re-embed the datasets in src/builtins.ts

tests/golden.test.ts checks CLI output byte for byte, and the math, parsing and label matching, against recorded reference outputs (tests/fixtures/golden.json).

See CONTRIBUTING.md for how to add a dataset or send a pull request.

Caveats

The results are only as good as the input data. Game-rank shares change every season.