unirating 0.1.0__py3-none-any.whl

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.
unirating/__init__.py ADDED
@@ -0,0 +1,35 @@
1
+ """unirating — Bayesian inverse-variance fusion of FIDE, Chess.com and Lichess ratings.
2
+
3
+ Public API:
4
+ Rating, Prior, Calibration, TimeControl, FusionResult, fuse
5
+ InvalidRatingError, MissingSigmaWarning, ProvisionalRatingWarning
6
+
7
+ See README.md and docs/derivation.md for the mathematical background.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from ._version import __version__
13
+ from .calibration import Calibration, TimeControl
14
+ from .exceptions import (
15
+ InvalidRatingError,
16
+ MissingSigmaWarning,
17
+ ProvisionalRatingWarning,
18
+ UniratingError,
19
+ )
20
+ from .fusion import fuse
21
+ from .models import FusionResult, Prior, Rating
22
+
23
+ __all__ = [
24
+ "Calibration",
25
+ "FusionResult",
26
+ "InvalidRatingError",
27
+ "MissingSigmaWarning",
28
+ "Prior",
29
+ "ProvisionalRatingWarning",
30
+ "Rating",
31
+ "TimeControl",
32
+ "UniratingError",
33
+ "__version__",
34
+ "fuse",
35
+ ]
unirating/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,103 @@
1
+ """Source <-> FIDE affine calibration constants per time control.
2
+
3
+ A measurement on a non-FIDE source is modelled as
4
+
5
+ R_source = alpha + beta * theta + noise
6
+
7
+ so the FIDE-equivalent estimate is
8
+
9
+ theta_hat = (R_source - alpha) / beta
10
+
11
+ with variance (sigma_source / beta)^2.
12
+
13
+ The default constants below come from community regressions; they are not
14
+ authoritative. Pass your own `Calibration` instance to `fuse()` when you have
15
+ better-fitted constants (e.g. a dataset of players holding ratings on multiple
16
+ sites for the time control you care about).
17
+
18
+ Default sigma values are coarse fallbacks used only when the caller does not
19
+ supply an explicit sigma for a Rating. They will trigger MissingSigmaWarning.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass
25
+ from enum import Enum
26
+
27
+
28
+ class TimeControl(str, Enum):
29
+ """Standard chess time-control buckets."""
30
+
31
+ BULLET = "bullet"
32
+ BLITZ = "blitz"
33
+ RAPID = "rapid"
34
+ CLASSICAL = "classical"
35
+
36
+
37
+ # (chesscom_alpha, chesscom_beta, lichess_alpha, lichess_beta)
38
+ # Source: see docs/citations.md (Lichess FAQ, Chess.com community regressions,
39
+ # Reddit r/chess long-running comparisons).
40
+ _DEFAULT_CALIBRATION_BY_TC: dict[TimeControl, tuple[float, float, float, float]] = {
41
+ TimeControl.BULLET: (150.0, 1.0, 400.0, 1.0),
42
+ TimeControl.BLITZ: (120.0, 1.0, 350.0, 1.0),
43
+ TimeControl.RAPID: (100.0, 1.0, 250.0, 1.0),
44
+ TimeControl.CLASSICAL: ( 50.0, 1.0, 200.0, 1.0),
45
+ }
46
+
47
+
48
+ # Default fallback sigmas (per native scale) when a Rating supplies no sigma.
49
+ # Deliberately conservative so a missing-sigma input contributes little.
50
+ _DEFAULT_SIGMA_BY_TC: dict[TimeControl, dict[str, float]] = {
51
+ TimeControl.BULLET: {"fide": 100.0, "chesscom": 100.0, "lichess": 100.0},
52
+ TimeControl.BLITZ: {"fide": 90.0, "chesscom": 90.0, "lichess": 90.0},
53
+ TimeControl.RAPID: {"fide": 80.0, "chesscom": 80.0, "lichess": 80.0},
54
+ TimeControl.CLASSICAL: {"fide": 70.0, "chesscom": 70.0, "lichess": 70.0},
55
+ }
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class Calibration:
60
+ """Affine calibration source -> FIDE.
61
+
62
+ For FIDE the identity is implicit: alpha=0, beta=1.
63
+
64
+ Override any subset of fields; the rest are filled from the time-control
65
+ defaults at fuse() time.
66
+ """
67
+
68
+ chesscom_alpha: float = float("nan")
69
+ chesscom_beta: float = float("nan")
70
+ lichess_alpha: float = float("nan")
71
+ lichess_beta: float = float("nan")
72
+
73
+ @classmethod
74
+ def for_time_control(cls, tc: TimeControl) -> Calibration:
75
+ a_c, b_c, a_l, b_l = _DEFAULT_CALIBRATION_BY_TC[tc]
76
+ return cls(
77
+ chesscom_alpha=a_c,
78
+ chesscom_beta=b_c,
79
+ lichess_alpha=a_l,
80
+ lichess_beta=b_l,
81
+ )
82
+
83
+ def filled(self, tc: TimeControl) -> Calibration:
84
+ """Return a Calibration with NaN slots filled from the TC defaults."""
85
+ import math
86
+
87
+ a_c, b_c, a_l, b_l = _DEFAULT_CALIBRATION_BY_TC[tc]
88
+ return Calibration(
89
+ chesscom_alpha=self.chesscom_alpha if math.isfinite(self.chesscom_alpha) else a_c,
90
+ chesscom_beta=self.chesscom_beta if math.isfinite(self.chesscom_beta) else b_c,
91
+ lichess_alpha=self.lichess_alpha if math.isfinite(self.lichess_alpha) else a_l,
92
+ lichess_beta=self.lichess_beta if math.isfinite(self.lichess_beta) else b_l,
93
+ )
94
+
95
+
96
+ def default_sigma(tc: TimeControl, source: str) -> float:
97
+ """Fallback sigma on the source's native scale when the caller omits one."""
98
+ try:
99
+ return _DEFAULT_SIGMA_BY_TC[tc][source]
100
+ except KeyError as exc:
101
+ raise KeyError(
102
+ f"no default sigma registered for time_control={tc!r} source={source!r}"
103
+ ) from exc
unirating/cli.py ADDED
@@ -0,0 +1,113 @@
1
+ """Command-line interface for unirating."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from collections.abc import Sequence
8
+
9
+ from . import __version__
10
+ from .calibration import TimeControl
11
+ from .fusion import fuse
12
+ from .models import Prior, Rating
13
+
14
+
15
+ def _maybe_rating(value: float | None, sigma: float | None) -> Rating | None:
16
+ if value is None:
17
+ return None
18
+ return Rating(value=value, sigma=sigma)
19
+
20
+
21
+ def build_parser() -> argparse.ArgumentParser:
22
+ p = argparse.ArgumentParser(
23
+ prog="unirating",
24
+ description=(
25
+ "Fuse FIDE, Chess.com and Lichess ratings into a single FIDE-scale "
26
+ "rating using Bayesian inverse-variance weighting."
27
+ ),
28
+ )
29
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
30
+ p.add_argument(
31
+ "--time-control", "-t",
32
+ choices=[tc.value for tc in TimeControl],
33
+ default=TimeControl.RAPID.value,
34
+ help="Time control bucket all ratings belong to (default: rapid).",
35
+ )
36
+ p.add_argument("--fide", type=float, default=None, help="FIDE rating value.")
37
+ p.add_argument("--fide-sigma", type=float, default=None,
38
+ help="FIDE rating 1-sigma uncertainty (default: time-control fallback).")
39
+ p.add_argument("--chesscom", type=float, default=None, help="Chess.com rating value.")
40
+ p.add_argument("--chesscom-rd", type=float, default=None,
41
+ help="Chess.com Glicko RD (preferred name for --chesscom-sigma).")
42
+ p.add_argument("--chesscom-sigma", type=float, default=None,
43
+ help=argparse.SUPPRESS)
44
+ p.add_argument("--lichess", type=float, default=None, help="Lichess rating value.")
45
+ p.add_argument("--lichess-rd", type=float, default=None,
46
+ help="Lichess Glicko-2 RD (preferred name for --lichess-sigma).")
47
+ p.add_argument("--lichess-sigma", type=float, default=None,
48
+ help=argparse.SUPPRESS)
49
+ p.add_argument("--prior-mu", type=float, default=None,
50
+ help="Prior mean on the FIDE scale (default: 1500).")
51
+ p.add_argument("--prior-sigma", type=float, default=None,
52
+ help="Prior std-dev on the FIDE scale (default: 350).")
53
+ p.add_argument("--json", action="store_true",
54
+ help="Emit a JSON object instead of human-readable output.")
55
+ return p
56
+
57
+
58
+ def _pct(x: float) -> str:
59
+ return f"{100.0 * x:.0f}%"
60
+
61
+
62
+ def main(argv: Sequence[str] | None = None) -> int:
63
+ args = build_parser().parse_args(argv)
64
+
65
+ tc = TimeControl(args.time_control)
66
+
67
+ cc_sigma = args.chesscom_rd if args.chesscom_rd is not None else args.chesscom_sigma
68
+ li_sigma = args.lichess_rd if args.lichess_rd is not None else args.lichess_sigma
69
+
70
+ prior_kwargs = {}
71
+ if args.prior_mu is not None:
72
+ prior_kwargs["mu"] = args.prior_mu
73
+ if args.prior_sigma is not None:
74
+ prior_kwargs["sigma"] = args.prior_sigma
75
+ prior = Prior(**prior_kwargs) if prior_kwargs else None
76
+
77
+ result = fuse(
78
+ fide=_maybe_rating(args.fide, args.fide_sigma),
79
+ chesscom=_maybe_rating(args.chesscom, cc_sigma),
80
+ lichess=_maybe_rating(args.lichess, li_sigma),
81
+ time_control=tc,
82
+ prior=prior,
83
+ )
84
+
85
+ if args.json:
86
+ import json
87
+
88
+ payload = {
89
+ "rating": result.rating,
90
+ "sigma": result.sigma,
91
+ "ci95_low": result.ci95[0],
92
+ "ci95_high": result.ci95[1],
93
+ "used_sources": list(result.used_sources),
94
+ "is_prior_only": result.is_prior_only,
95
+ "contributions": dict(result.contributions),
96
+ "prior": {"mu": result.prior.mu, "sigma": result.prior.sigma},
97
+ "time_control": tc.value,
98
+ }
99
+ print(json.dumps(payload, indent=2))
100
+ else:
101
+ low, high = result.ci95
102
+ sources = ", ".join(result.used_sources) if result.used_sources else "(none, prior only)"
103
+ contrib_parts = [f"{k}={_pct(v)}" for k, v in result.contributions.items()]
104
+ print(f"Unified rating (FIDE scale): {result.rating:.0f} +/- {result.sigma:.0f}")
105
+ print(f"95% CI: [{low:.0f}, {high:.0f}]")
106
+ print(f"Sources used: {sources}")
107
+ print(f"Contributions: {' '.join(contrib_parts)}")
108
+
109
+ return 0
110
+
111
+
112
+ if __name__ == "__main__":
113
+ sys.exit(main())
@@ -0,0 +1,35 @@
1
+ """Exception and warning types raised by unirating."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class UniratingError(Exception):
7
+ """Base class for all errors raised by this package."""
8
+
9
+
10
+ class InvalidRatingError(UniratingError, ValueError):
11
+ """Raised when a Rating or Prior contains an invalid value.
12
+
13
+ Cases:
14
+ - non-finite value or sigma (NaN, +/-inf)
15
+ - non-positive sigma
16
+ - rating outside the configured plausible bounds
17
+ """
18
+
19
+
20
+ class MissingSigmaWarning(UserWarning):
21
+ """Emitted when a Rating has no sigma and a default had to be substituted.
22
+
23
+ A default sigma is a coarse approximation. Pass an explicit sigma whenever
24
+ you can — Lichess exposes RD via the public API, and Chess.com exposes it
25
+ via /pub/player/{user}/stats.
26
+ """
27
+
28
+
29
+ class ProvisionalRatingWarning(UserWarning):
30
+ """Emitted when a rating's sigma indicates a provisional account.
31
+
32
+ Glicko-2 considers RD >= 110 provisional (Lichess shows '?' next to it).
33
+ The fusion still works — such ratings simply receive near-zero weight —
34
+ but the caller is warned in case they want to treat it specially.
35
+ """
unirating/fusion.py ADDED
@@ -0,0 +1,161 @@
1
+ """The fusion entry point.
2
+
3
+ `fuse()` computes the Bayesian posterior mean and standard deviation of the
4
+ latent FIDE-scale skill theta given any subset of {FIDE, Chess.com, Lichess}
5
+ ratings, with a Gaussian prior. See docs/derivation.md for the proof.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ import warnings
12
+
13
+ from .calibration import Calibration, TimeControl, default_sigma
14
+ from .exceptions import (
15
+ InvalidRatingError,
16
+ MissingSigmaWarning,
17
+ ProvisionalRatingWarning,
18
+ )
19
+ from .models import FusionResult, Prior, Rating
20
+
21
+ # Glicko-2 threshold beyond which Lichess displays '?' (provisional).
22
+ _PROVISIONAL_RD_THRESHOLD = 110.0
23
+
24
+
25
+ def _coerce_sigma(
26
+ source: str,
27
+ rating: Rating,
28
+ tc: TimeControl,
29
+ ) -> float:
30
+ """Return the sigma to use for `rating`, emitting warnings where needed."""
31
+ if rating.sigma is None:
32
+ fallback = default_sigma(tc, source)
33
+ warnings.warn(
34
+ f"{source}: no sigma supplied; using default {fallback} for "
35
+ f"time_control={tc.value!r}. Pass an explicit sigma (e.g. the "
36
+ "Glicko RD from the API) for a tighter estimate.",
37
+ MissingSigmaWarning,
38
+ stacklevel=3,
39
+ )
40
+ return fallback
41
+
42
+ sigma = float(rating.sigma)
43
+ if source in {"chesscom", "lichess"} and sigma >= _PROVISIONAL_RD_THRESHOLD:
44
+ warnings.warn(
45
+ f"{source}: sigma={sigma} indicates a provisional rating "
46
+ f"(>= {_PROVISIONAL_RD_THRESHOLD}); it will receive very little "
47
+ "weight in the fusion.",
48
+ ProvisionalRatingWarning,
49
+ stacklevel=3,
50
+ )
51
+ return sigma
52
+
53
+
54
+ def fuse(
55
+ *,
56
+ fide: Rating | None = None,
57
+ chesscom: Rating | None = None,
58
+ lichess: Rating | None = None,
59
+ time_control: TimeControl = TimeControl.RAPID,
60
+ prior: Prior | None = None,
61
+ calibration: Calibration | None = None,
62
+ ) -> FusionResult:
63
+ """Fuse up to three ratings into a single FIDE-scale rating.
64
+
65
+ Args:
66
+ fide: FIDE rating measurement, or None if unavailable.
67
+ chesscom: Chess.com rating measurement, or None if unavailable.
68
+ lichess: Lichess rating measurement, or None if unavailable.
69
+ time_control: Which time-control bucket the ratings belong to.
70
+ All three Rating arguments MUST be from the same bucket --
71
+ mixing time controls is a user error this function cannot
72
+ detect, so the caller is responsible.
73
+ prior: Gaussian prior over the latent skill theta on the FIDE scale.
74
+ Defaults to N(1500, 350^2).
75
+ calibration: Affine source->FIDE calibration. Any field left as NaN
76
+ is filled from the per-time-control defaults. Defaults to the
77
+ time-control defaults entirely.
78
+
79
+ Returns:
80
+ A FusionResult on the FIDE scale.
81
+
82
+ Raises:
83
+ TypeError: if `time_control` is not a TimeControl.
84
+ InvalidRatingError: if any Rating or Prior is malformed
85
+ (already raised at construction time, but re-raised here for
86
+ programmatic checks like negative beta).
87
+ """
88
+ if not isinstance(time_control, TimeControl):
89
+ raise TypeError(
90
+ f"time_control must be a TimeControl, got {type(time_control).__name__}"
91
+ )
92
+
93
+ prior = prior if prior is not None else Prior()
94
+ calibration = (calibration or Calibration()).filled(time_control)
95
+
96
+ # Sanity-check calibration slopes -- a zero or non-finite beta would
97
+ # produce a degenerate (infinite-variance) calibrated estimate.
98
+ for name, beta in (
99
+ ("chesscom_beta", calibration.chesscom_beta),
100
+ ("lichess_beta", calibration.lichess_beta),
101
+ ):
102
+ if not math.isfinite(beta) or beta <= 0.0:
103
+ raise InvalidRatingError(
104
+ f"Calibration.{name} must be a positive finite number, got {beta!r}"
105
+ )
106
+
107
+ # Accumulators on the FIDE scale.
108
+ # We work in PRECISION space (1/variance) to avoid catastrophic
109
+ # cancellation when one source dominates the others.
110
+ w_total = prior.precision
111
+ wx_total = prior.mu * prior.precision
112
+
113
+ per_source_weight: dict[str, float] = {"prior": prior.precision}
114
+ used_sources: list[str] = []
115
+
116
+ if fide is not None:
117
+ sigma = _coerce_sigma("fide", fide, time_control)
118
+ theta_hat = float(fide.value) # alpha=0, beta=1
119
+ tau2 = sigma * sigma # variance on FIDE scale
120
+ w = 1.0 / tau2
121
+ w_total += w
122
+ wx_total += w * theta_hat
123
+ per_source_weight["fide"] = w
124
+ used_sources.append("fide")
125
+
126
+ if chesscom is not None:
127
+ sigma = _coerce_sigma("chesscom", chesscom, time_control)
128
+ a, b = calibration.chesscom_alpha, calibration.chesscom_beta
129
+ theta_hat = (float(chesscom.value) - a) / b
130
+ tau2 = (sigma * sigma) / (b * b)
131
+ w = 1.0 / tau2
132
+ w_total += w
133
+ wx_total += w * theta_hat
134
+ per_source_weight["chesscom"] = w
135
+ used_sources.append("chesscom")
136
+
137
+ if lichess is not None:
138
+ sigma = _coerce_sigma("lichess", lichess, time_control)
139
+ a, b = calibration.lichess_alpha, calibration.lichess_beta
140
+ theta_hat = (float(lichess.value) - a) / b
141
+ tau2 = (sigma * sigma) / (b * b)
142
+ w = 1.0 / tau2
143
+ w_total += w
144
+ wx_total += w * theta_hat
145
+ per_source_weight["lichess"] = w
146
+ used_sources.append("lichess")
147
+
148
+ # w_total can never be 0: the prior always contributes prior.precision > 0.
149
+ posterior_mu = wx_total / w_total
150
+ posterior_sigma = math.sqrt(1.0 / w_total)
151
+
152
+ contributions = {k: v / w_total for k, v in per_source_weight.items()}
153
+
154
+ return FusionResult(
155
+ rating=posterior_mu,
156
+ sigma=posterior_sigma,
157
+ contributions=contributions,
158
+ used_sources=tuple(used_sources),
159
+ is_prior_only=len(used_sources) == 0,
160
+ prior=prior,
161
+ )
unirating/models.py ADDED
@@ -0,0 +1,143 @@
1
+ """Data models: Rating, Prior, FusionResult.
2
+
3
+ All models are immutable (frozen dataclasses) and validate on construction.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import math
9
+ from collections.abc import Mapping
10
+ from dataclasses import dataclass, field
11
+
12
+ from .exceptions import InvalidRatingError
13
+
14
+ # Plausible rating bounds on the native scale of any of the three systems.
15
+ # Wide enough to admit any human rating ever recorded; tight enough to catch
16
+ # obvious typos (e.g. someone passing a percentage instead of an Elo).
17
+ _DEFAULT_MIN_RATING = 100.0
18
+ _DEFAULT_MAX_RATING = 3500.0
19
+
20
+ # Default prior on the FIDE scale.
21
+ # mu0=1500 is the historical Elo starting point and roughly the world median
22
+ # of rated adult club players. sigma0=350 covers ~800-2200 within +/-2sigma.
23
+ _DEFAULT_PRIOR_MU = 1500.0
24
+ _DEFAULT_PRIOR_SIGMA = 350.0
25
+
26
+
27
+ def _validate_finite(name: str, value: float) -> None:
28
+ if not math.isfinite(value):
29
+ raise InvalidRatingError(f"{name} must be a finite number, got {value!r}")
30
+
31
+
32
+ def _validate_rating_bounds(
33
+ value: float,
34
+ min_value: float = _DEFAULT_MIN_RATING,
35
+ max_value: float = _DEFAULT_MAX_RATING,
36
+ ) -> None:
37
+ if not (min_value <= value <= max_value):
38
+ raise InvalidRatingError(
39
+ f"rating value {value!r} is outside the plausible range "
40
+ f"[{min_value}, {max_value}]. If your data legitimately lies "
41
+ f"outside, construct Rating with min_value/max_value overrides."
42
+ )
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class Rating:
47
+ """A noisy measurement of a player's strength from a single source.
48
+
49
+ Attributes:
50
+ value: The rating on the source's native scale (FIDE Elo, Chess.com
51
+ Glicko, Lichess Glicko-2, etc.).
52
+ sigma: The 1-sigma measurement uncertainty on the same native scale.
53
+ For Lichess and Chess.com this is the Glicko RD exposed by the API.
54
+ For FIDE, use a rule of thumb (~60 for settled players) or
55
+ leave as None to fall back to a coarse default.
56
+ min_value, max_value: Plausibility bounds for the rating value.
57
+ Defaults reject anything outside [100, 3500].
58
+ """
59
+
60
+ value: float
61
+ sigma: float | None = None
62
+ min_value: float = _DEFAULT_MIN_RATING
63
+ max_value: float = _DEFAULT_MAX_RATING
64
+
65
+ def __post_init__(self) -> None:
66
+ _validate_finite("Rating.value", float(self.value))
67
+ _validate_rating_bounds(float(self.value), self.min_value, self.max_value)
68
+ if self.sigma is not None:
69
+ _validate_finite("Rating.sigma", float(self.sigma))
70
+ if self.sigma <= 0.0:
71
+ raise InvalidRatingError(
72
+ f"Rating.sigma must be strictly positive, got {self.sigma!r}. "
73
+ "A non-positive sigma implies infinite precision, which is "
74
+ "never true of a real rating measurement."
75
+ )
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class Prior:
80
+ """Gaussian prior on the latent FIDE-scale skill theta.
81
+
82
+ Defaults to N(1500, 350^2), appropriate for an adult club-player
83
+ population. Lower mu for juniors, raise it for titled-player populations.
84
+
85
+ Attributes:
86
+ mu: Prior mean of latent skill (FIDE scale).
87
+ sigma: Prior standard deviation. MUST be strictly positive and finite;
88
+ use a large value (e.g. 1e6) to obtain an effectively flat prior.
89
+ """
90
+
91
+ mu: float = _DEFAULT_PRIOR_MU
92
+ sigma: float = _DEFAULT_PRIOR_SIGMA
93
+
94
+ def __post_init__(self) -> None:
95
+ _validate_finite("Prior.mu", float(self.mu))
96
+ _validate_finite("Prior.sigma", float(self.sigma))
97
+ if self.sigma <= 0.0:
98
+ raise InvalidRatingError(
99
+ f"Prior.sigma must be strictly positive, got {self.sigma!r}."
100
+ )
101
+
102
+ @property
103
+ def precision(self) -> float:
104
+ """1 / sigma^2."""
105
+ return 1.0 / (self.sigma * self.sigma)
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class FusionResult:
110
+ """The output of `fuse()`.
111
+
112
+ Attributes:
113
+ rating: The unified rating on the FIDE scale (posterior mean of theta).
114
+ sigma: 1-sigma posterior uncertainty on the FIDE scale.
115
+ contributions: For each source key ('prior', 'fide', 'chesscom',
116
+ 'lichess'), its share of the total precision -- i.e. how much
117
+ it pulled the answer. Always sums to 1.0.
118
+ used_sources: The subset of {'fide', 'chesscom', 'lichess'} that
119
+ actually contributed observations (sigma>0 and value present).
120
+ is_prior_only: True iff no rating sources were available and the
121
+ returned (rating, sigma) is exactly the prior.
122
+ prior: The Prior used.
123
+ """
124
+
125
+ rating: float
126
+ sigma: float
127
+ contributions: Mapping[str, float] = field(default_factory=dict)
128
+ used_sources: tuple[str, ...] = ()
129
+ is_prior_only: bool = False
130
+ prior: Prior = field(default_factory=Prior)
131
+
132
+ @property
133
+ def ci95(self) -> tuple[float, float]:
134
+ """The 95% credible interval, computed as mean +/- 1.96 sigma."""
135
+ half = 1.96 * self.sigma
136
+ return (self.rating - half, self.rating + half)
137
+
138
+ def __repr__(self) -> str:
139
+ sources = ",".join(self.used_sources) if self.used_sources else "prior-only"
140
+ return (
141
+ f"FusionResult(rating={self.rating:.1f}, sigma={self.sigma:.1f}, "
142
+ f"sources=[{sources}])"
143
+ )
unirating/py.typed ADDED
File without changes
@@ -0,0 +1,236 @@
1
+ Metadata-Version: 2.4
2
+ Name: unirating
3
+ Version: 0.1.0
4
+ Summary: Bayesian inverse-variance fusion of FIDE, Chess.com and Lichess ratings into a single FIDE-scale rating.
5
+ Project-URL: Homepage, https://github.com/souravsahums/unirating
6
+ Project-URL: Documentation, https://github.com/souravsahums/unirating#readme
7
+ Project-URL: Issues, https://github.com/souravsahums/unirating/issues
8
+ Project-URL: Source, https://github.com/souravsahums/unirating
9
+ Author-email: Sourav Sahu <souravsahu@duck.com>
10
+ License: MIT License
11
+
12
+ Copyright (c) 2026 Sourav Sahu
13
+
14
+ Permission is hereby granted, free of charge, to any person obtaining a copy
15
+ of this software and associated documentation files (the "Software"), to deal
16
+ in the Software without restriction, including without limitation the rights
17
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
18
+ copies of the Software, and to permit persons to whom the Software is
19
+ furnished to do so, subject to the following conditions:
20
+
21
+ The above copyright notice and this permission notice shall be included in all
22
+ copies or substantial portions of the Software.
23
+
24
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
25
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
26
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
27
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
28
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
29
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
30
+ SOFTWARE.
31
+ License-File: LICENSE
32
+ Keywords: bayesian,chess,chess.com,elo,fide,glicko,lichess,meta-analysis,rating,sensor-fusion
33
+ Classifier: Development Status :: 4 - Beta
34
+ Classifier: Intended Audience :: Developers
35
+ Classifier: Intended Audience :: Science/Research
36
+ Classifier: License :: OSI Approved :: MIT License
37
+ Classifier: Operating System :: OS Independent
38
+ Classifier: Programming Language :: Python :: 3
39
+ Classifier: Programming Language :: Python :: 3 :: Only
40
+ Classifier: Programming Language :: Python :: 3.9
41
+ Classifier: Programming Language :: Python :: 3.10
42
+ Classifier: Programming Language :: Python :: 3.11
43
+ Classifier: Programming Language :: Python :: 3.12
44
+ Classifier: Programming Language :: Python :: 3.13
45
+ Classifier: Topic :: Games/Entertainment :: Board Games
46
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
47
+ Classifier: Typing :: Typed
48
+ Requires-Python: >=3.9
49
+ Provides-Extra: dev
50
+ Requires-Dist: build>=1.0; extra == 'dev'
51
+ Requires-Dist: hypothesis>=6; extra == 'dev'
52
+ Requires-Dist: mypy>=1.8; extra == 'dev'
53
+ Requires-Dist: pytest-cov>=4; extra == 'dev'
54
+ Requires-Dist: pytest>=7; extra == 'dev'
55
+ Requires-Dist: ruff>=0.5; extra == 'dev'
56
+ Requires-Dist: twine>=5.0; extra == 'dev'
57
+ Provides-Extra: test
58
+ Requires-Dist: hypothesis>=6; extra == 'test'
59
+ Requires-Dist: pytest-cov>=4; extra == 'test'
60
+ Requires-Dist: pytest>=7; extra == 'test'
61
+ Description-Content-Type: text/markdown
62
+
63
+ # unirating
64
+
65
+ [![Python](https://img.shields.io/badge/python-3.9%2B-blue.svg)](https://www.python.org/downloads/)
66
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
67
+
68
+ > Author: **Sourav Sahu** ([@souravsahums](https://github.com/souravsahums))
69
+
70
+ Bayesian inverse-variance **fusion** of a player's **FIDE**, **Chess.com** and **Lichess** ratings into a single number on the **FIDE scale**, with proper handling of missing ratings, calibrated per time control.
71
+
72
+ The estimator is the **maximum-likelihood / Best Linear Unbiased Estimator** (Gauss–Markov) when all sources are present, and the **posterior mean of a Gaussian–Gaussian Bayesian model** when one or more sources are missing — so a player with no ratings at all gracefully degrades to the population prior (the "base rating") rather than crashing.
73
+
74
+ Full mathematical derivation with proof: [docs/derivation.md](docs/derivation.md).
75
+ Citations: [docs/citations.md](docs/citations.md).
76
+
77
+ ---
78
+
79
+ ## Install
80
+
81
+ ```bash
82
+ pip install unirating
83
+ ```
84
+
85
+ Zero runtime dependencies. Python 3.9+.
86
+
87
+ ---
88
+
89
+ ## Quick start
90
+
91
+ ```python
92
+ from unirating import Rating, TimeControl, fuse
93
+
94
+ result = fuse(
95
+ fide = Rating(value=1820, sigma=60), # FIDE Rapid, settled
96
+ chesscom = Rating(value=1950, sigma=55), # Chess.com Rapid, RD=55
97
+ lichess = Rating(value=2100, sigma=50), # Lichess Rapid, RD=50
98
+ time_control = TimeControl.RAPID,
99
+ )
100
+
101
+ print(result.rating) # 1838.95 (FIDE scale)
102
+ print(result.sigma) # 31.36 (1-sigma uncertainty)
103
+ print(result.ci95) # (1777.5, 1900.4)
104
+ print(result.contributions) # per-source weight share, sums to 1.0
105
+ ```
106
+
107
+ A player with no ratings at all:
108
+
109
+ ```python
110
+ from unirating import fuse, TimeControl
111
+
112
+ result = fuse(time_control=TimeControl.RAPID)
113
+ print(result.rating) # 1500.0 (prior mean — the "base rating")
114
+ print(result.sigma) # 350.0 (prior std-dev — full uncertainty)
115
+ print(result.is_prior_only) # True
116
+ ```
117
+
118
+ Lichess gives you the RD directly via API — pass it in. If you only have the rating number, the package will use a sensible default $\sigma$ but you should treat the result as approximate:
119
+
120
+ ```python
121
+ from unirating import Rating, TimeControl, fuse
122
+
123
+ result = fuse(
124
+ lichess = Rating(value=1850), # sigma=None → default used + warning
125
+ time_control = TimeControl.RAPID,
126
+ )
127
+ ```
128
+
129
+ ---
130
+
131
+ ## What's in the box
132
+
133
+ | Module | Purpose |
134
+ |---|---|
135
+ | [`unirating.fuse`](src/unirating/fusion.py) | The main entry point — call this. |
136
+ | [`unirating.Rating`](src/unirating/models.py) | Immutable `(value, sigma)` measurement. |
137
+ | [`unirating.Prior`](src/unirating/models.py) | Gaussian prior over latent skill. Defaults to $\mathcal N(1500, 350^2)$. |
138
+ | [`unirating.Calibration`](src/unirating/calibration.py) | Affine map source ↔ FIDE. Defaults per time control. |
139
+ | [`unirating.TimeControl`](src/unirating/calibration.py) | `BULLET`, `BLITZ`, `RAPID`, `CLASSICAL`. |
140
+ | [`unirating.FusionResult`](src/unirating/models.py) | `(rating, sigma, ci95, contributions, used_sources, …)`. |
141
+ | `unirating` (CLI) | One-shot fusion from the shell. |
142
+
143
+ ---
144
+
145
+ ## CLI
146
+
147
+ ```bash
148
+ unirating \
149
+ --fide 1820 --fide-sigma 60 \
150
+ --chesscom 1950 --chesscom-rd 55 \
151
+ --lichess 2100 --lichess-rd 50 \
152
+ --time-control rapid
153
+ ```
154
+
155
+ Output:
156
+
157
+ ```
158
+ Unified rating (FIDE scale): 1839 ± 31
159
+ 95% CI: [1778, 1900]
160
+ Sources used: fide, chesscom, lichess
161
+ Contributions: prior=1% fide=27% chesscom=33% lichess=39%
162
+ ```
163
+
164
+ ---
165
+
166
+ ## How the math works (one paragraph)
167
+
168
+ Each rating is treated as a noisy linear measurement $R_i = \alpha_i + \beta_i \theta + \varepsilon_i$, $\varepsilon_i \sim \mathcal N(0, \sigma_i^2)$, of the latent FIDE-equivalent skill $\theta$. After inverting each measurement to a FIDE-scale estimate $\hat\theta_i$ with variance $\tau_i^2 = \sigma_i^2/\beta_i^2$, the **posterior mean of $\theta$** given a Gaussian prior $\mathcal N(\mu_0, \sigma_0^2)$ is the precision-weighted average
169
+
170
+ $$
171
+ \hat\theta = \frac{\mu_0/\sigma_0^2 + \sum_{i \in S} \hat\theta_i/\tau_i^2}{1/\sigma_0^2 + \sum_{i \in S} 1/\tau_i^2}
172
+ $$
173
+
174
+ over whatever subset $S$ of sources is present. With no sources, the formula collapses to $\mu_0$. The full proof (MLE + Gauss–Markov + Bayes), the choice of constants, and a worked example are in [docs/derivation.md](docs/derivation.md).
175
+
176
+ ---
177
+
178
+ ## Calibration constants (defaults)
179
+
180
+ | Time control | Chess.com $\alpha,\beta$ | Lichess $\alpha,\beta$ |
181
+ |---|---|---|
182
+ | `BULLET` | 150, 1.0 | 400, 1.0 |
183
+ | `BLITZ` | 120, 1.0 | 350, 1.0 |
184
+ | `RAPID` | 100, 1.0 | 250, 1.0 |
185
+ | `CLASSICAL` | 50, 1.0 | 200, 1.0 |
186
+
187
+ Source: published community regressions (see [docs/citations.md](docs/citations.md)). You can override per call:
188
+
189
+ ```python
190
+ from unirating import Calibration, fuse
191
+
192
+ custom = Calibration(chesscom_alpha=80, lichess_alpha=300)
193
+ result = fuse(..., calibration=custom)
194
+ ```
195
+
196
+ ---
197
+
198
+ ## Edge cases handled
199
+
200
+ - **No ratings** → returns prior, `is_prior_only=True`.
201
+ - **One rating** → posterior shrinks toward prior; weight reported.
202
+ - **Missing $\sigma_i$** → fills with conservative default, emits `MissingSigmaWarning`.
203
+ - **Provisional rating** (large RD, Lichess `?`) → naturally down-weighted; warns if RD > 110.
204
+ - **Zero / negative $\sigma_i$** → raises `InvalidRatingError`.
205
+ - **Non-finite values** (`nan`, `inf`) → raises `InvalidRatingError`.
206
+ - **Out-of-range ratings** → raises `InvalidRatingError` (configurable bounds).
207
+ - **Mixed time controls** → only one `TimeControl` per call; the calibration enforces consistency.
208
+ - **Custom prior** → pass any `Prior(mu, sigma)` (e.g. for juniors, titled players).
209
+ - **Numerical stability** → all sums computed in precision-space then inverted at the end.
210
+
211
+ See [tests/](tests/) for the formal coverage.
212
+
213
+ ---
214
+
215
+ ## Citation
216
+
217
+ If you use this package in academic work, please cite:
218
+
219
+ ```bibtex
220
+ @software{sahu_unirating_2026,
221
+ author = {Sahu, Sourav},
222
+ title = {unirating: Bayesian inverse-variance fusion of FIDE,
223
+ Chess.com and Lichess ratings},
224
+ year = {2026},
225
+ url = {https://github.com/souravsahums/unirating},
226
+ version = {0.1.0},
227
+ }
228
+ ```
229
+
230
+ Plain text: Sahu, S. (2026). *unirating: Bayesian inverse-variance fusion of FIDE, Chess.com and Lichess ratings* (v0.1.0) [Computer software]. https://github.com/souravsahums/unirating
231
+
232
+ ---
233
+
234
+ ## License
235
+
236
+ MIT — see [LICENSE](LICENSE). Copyright © 2026 Sourav Sahu.
@@ -0,0 +1,13 @@
1
+ unirating/__init__.py,sha256=rZYr2a12EsiGLIi55zwiGauELPo7CLxiOc1dFMBultU,906
2
+ unirating/_version.py,sha256=QTYqXqSTHFRkM9TEgpDFcHvwLbvqHDqvqfQ9EiXkcAM,23
3
+ unirating/calibration.py,sha256=EUJnHamy6WOX8gikwNSvxnpIydfcC1F0fZC7L2EvQVM,3801
4
+ unirating/cli.py,sha256=SW74NputKnLCcqxlWfvS2vTPVKAITs3zhN1U8MaDzjY,4419
5
+ unirating/exceptions.py,sha256=4tDCFqcacPy2KicbEnHWG64fjb6hJaJWKe9LmncuyJg,1173
6
+ unirating/fusion.py,sha256=NhtsJ-b2Idt_KIpXKsv03vDIIhBt10Fz8AqYd_NHV3E,6026
7
+ unirating/models.py,sha256=KWOwVUrcE6GZPPzLqzQlrPgFjeMZl2McVGE6k33dBoQ,5464
8
+ unirating/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ unirating-0.1.0.dist-info/METADATA,sha256=uoC_CksqW07d9bhRT7eo-UArfZqPDNLuj_OecoYUWPk,9481
10
+ unirating-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
11
+ unirating-0.1.0.dist-info/entry_points.txt,sha256=gORoXeeDenHl7p6C_P13HNlbA1WfzU6gnrDjA8KQ2F8,49
12
+ unirating-0.1.0.dist-info/licenses/LICENSE,sha256=pRsC9XFLTKAVp345FiHOt82M6gv5nhadRatN2d5HnPA,1089
13
+ unirating-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ unirating = unirating.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sourav Sahu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.