Skip to content

Commit bd91f98

Browse files
authored
Merge pull request #87 from CurtisTechSolutions/feat/python-model-ratings
feat(training): rate local model snapshots with Glicko-2
2 parents be77957 + 5662af6 commit bd91f98

7 files changed

Lines changed: 192 additions & 0 deletions

File tree

‎Cargo.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎crates/apps/gfa-py/Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ extension-module = ["pyo3/extension-module"]
1616
[dependencies]
1717
gfa-core.workspace = true
1818
gfa-opponents.workspace = true
19+
gfa-service.workspace = true
1920
gfa-engine-uci.workspace = true
2021
gfa-games.workspace = true
2122
pyo3.workspace = true

‎crates/apps/gfa-py/src/lib.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
//! Native Python training bridge. Typed engine states stay in Rust between steps.
2+
mod ratings;
23
mod uci;
34
mod vector;
45

@@ -247,6 +248,8 @@ fn position_set_data(name: &str) -> PyResult<(String, String)> {
247248
/// GamesForAI's native engine bridge.
248249
#[pymodule]
249250
mod _native {
251+
#[pymodule_export]
252+
use super::ratings::{rating_pair_json, rating_period_json, rating_validate_json};
250253
#[pymodule_export]
251254
use super::{games, position_set_data, uci::NativeUciPool, vector::NativeVectorEnv, NativeEnv};
252255
}

‎crates/apps/gfa-py/src/ratings.rs‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
use crate::error;
2+
use gfa_service::ratings::{rate_pair, RatedResult, Rating};
3+
use pyo3::prelude::*;
4+
5+
fn parse(text: &str) -> PyResult<Rating> {
6+
if text.len() > 4096 {
7+
return Err(error("rating payload exceeds 4096 bytes"));
8+
}
9+
let rating: Rating = serde_json::from_str(text).map_err(error)?;
10+
rating.validate().map_err(error)
11+
}
12+
13+
/// Validate a serialized rating without updating its uncertainty.
14+
#[pyfunction]
15+
pub(crate) fn rating_validate_json(rating: &str) -> PyResult<()> {
16+
parse(rating).map(|_| ())
17+
}
18+
19+
/// Apply the same pure rating period used by the service domain.
20+
#[pyfunction]
21+
#[pyo3(signature = (rating, results, tau=0.5))]
22+
pub(crate) fn rating_period_json(
23+
py: Python<'_>,
24+
rating: &str,
25+
results: &str,
26+
tau: f64,
27+
) -> PyResult<String> {
28+
if results.len() > 1024 * 1024 {
29+
return Err(error("rating results exceed 1 MiB"));
30+
}
31+
let rating = parse(rating)?;
32+
let inputs: Vec<(Rating, f64)> = serde_json::from_str(results).map_err(error)?;
33+
py.detach(|| {
34+
let results: Vec<_> = inputs
35+
.into_iter()
36+
.map(|(opponent, score)| RatedResult { opponent, score })
37+
.collect();
38+
let updated = rating.update_period(&results, tau).map_err(error)?;
39+
serde_json::to_string(&updated).map_err(error)
40+
})
41+
}
42+
43+
/// Rate both seats from the original values, optionally preserving calibrated anchors.
44+
#[pyfunction]
45+
#[pyo3(signature = (left, right, score, fixed_left=false, fixed_right=false, tau=0.5))]
46+
pub(crate) fn rating_pair_json(
47+
left: &str,
48+
right: &str,
49+
score: f64,
50+
fixed_left: bool,
51+
fixed_right: bool,
52+
tau: f64,
53+
) -> PyResult<String> {
54+
let pair = rate_pair(
55+
parse(left)?,
56+
parse(right)?,
57+
score,
58+
fixed_left,
59+
fixed_right,
60+
tau,
61+
)
62+
.map_err(error)?;
63+
serde_json::to_string(&pair).map_err(error)
64+
}

‎examples/model-ratings.md‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Rating model snapshots
2+
3+
```python
4+
from gamesforai.ratings import Rating, rate_pair
5+
6+
model_v1, model_v2 = Rating(), Rating()
7+
model_v1, model_v2 = rate_pair(model_v1, model_v2, 1.0) # v1 won
8+
print(model_v1.rating, model_v1.interval95)
9+
checkpoint = model_v1.to_dict()
10+
restored = Rating(**checkpoint)
11+
```
12+
13+
These Python functions call the same Rust Glicko-2 implementation as the service
14+
domain. Use stable model snapshot identifiers and separate rating pools for
15+
each game, rules configuration and assistance policy. Start uncalibrated
16+
players with the default prior. Fixed anchors require measured calibration.
17+
18+
Use score 1/0.5/0 for win/draw/loss. Exclude truncated and failed games instead
19+
of treating them as draws. For a whole rating period, pass each player's
20+
results with opponents' pre-period ratings to `Rating.update`; do not substitute
21+
ratings that were already updated during that period. Calling `update()` with
22+
no games represents one explicit inactive period and increases uncertainty.
23+
24+
Rating dictionaries are portable checkpoint data. This local API does not
25+
write a server leaderboard or manufacture calibrated engine ratings.

‎python/gamesforai/ratings.py‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""Shared native Glicko-2 calculations for local model leagues."""
2+
from __future__ import annotations
3+
4+
import json
5+
from dataclasses import asdict, dataclass
6+
7+
from ._native import rating_pair_json, rating_period_json, rating_validate_json
8+
9+
10+
def _dump(value):
11+
return json.dumps(value, allow_nan=False)
12+
13+
14+
@dataclass(frozen=True)
15+
class Rating:
16+
rating: float = 1500.0
17+
rd: float = 350.0
18+
volatility: float = 0.06
19+
games_played: int = 0
20+
21+
def __post_init__(self):
22+
rating_validate_json(_dump(asdict(self)))
23+
24+
@property
25+
def interval95(self):
26+
"""Approximate rating interval; this is not a win-rate confidence bound."""
27+
return (self.rating - 2 * self.rd, self.rating + 2 * self.rd)
28+
29+
def update(self, results=(), *, tau=0.5):
30+
"""Apply one period from (pre-period opponent Rating, score) pairs."""
31+
payload = [(asdict(opponent), score) for opponent, score in results]
32+
return Rating(**json.loads(rating_period_json(_dump(asdict(self)), _dump(payload), tau)))
33+
34+
def to_dict(self):
35+
return asdict(self)
36+
37+
38+
def rate_pair(left, right, score, *, fixed_left=False, fixed_right=False, tau=0.5):
39+
"""Return both new ratings without mutating either pre-match rating."""
40+
if isinstance(score, bool):
41+
raise ValueError("score must be numeric 0, 0.5 or 1")
42+
if type(fixed_left) is not bool or type(fixed_right) is not bool:
43+
raise ValueError("fixed anchor flags must be boolean")
44+
result = json.loads(rating_pair_json(
45+
_dump(asdict(left)), _dump(asdict(right)), score, fixed_left, fixed_right, tau
46+
))
47+
return tuple(Rating(**rating) for rating in result)

‎python/tests/test_ratings.py‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import json
2+
3+
import pytest
4+
5+
from gamesforai.ratings import Rating, rate_pair
6+
7+
8+
def test_native_rating_reference_and_json_checkpoint():
9+
current = Rating(rd=200)
10+
updated = current.update([(Rating(rating=1400, rd=30), 1),
11+
(Rating(rating=1550, rd=100), 0),
12+
(Rating(rating=1700, rd=300), 0)])
13+
assert updated.rating == pytest.approx(1464.06, abs=0.01)
14+
assert updated.rd == pytest.approx(151.52, abs=0.01)
15+
assert updated.games_played == 3
16+
assert Rating(**json.loads(json.dumps(updated.to_dict()))) == updated
17+
assert current.rd == 200
18+
19+
20+
def test_pair_symmetry_anchor_and_inactive_period():
21+
original = Rating()
22+
winner, loser = rate_pair(original, original, 1)
23+
assert winner.rating + loser.rating == pytest.approx(3000)
24+
assert winner.games_played == loser.games_played == 1
25+
idle = winner.update()
26+
assert idle.rating == winner.rating and idle.rd > winner.rd
27+
anchor = Rating(rating=1800, rd=0)
28+
learner, fixed = rate_pair(original, anchor, 1, fixed_right=True)
29+
assert learner.rating > winner.rating
30+
assert fixed.rating == anchor.rating and fixed.rd == 0
31+
assert original.interval95 == (800, 2200)
32+
33+
34+
@pytest.mark.parametrize("kwargs", [
35+
{"rd": -1}, {"volatility": 0}, {"rating": float("nan")},
36+
{"games_played": -1}, {"games_played": True},
37+
])
38+
def test_invalid_rating_inputs(kwargs):
39+
with pytest.raises(ValueError):
40+
Rating(**kwargs)
41+
42+
43+
def test_invalid_results_and_anchor_flags():
44+
original = Rating()
45+
for score in (-1, 0.25, 2, float("inf")):
46+
with pytest.raises(ValueError):
47+
rate_pair(original, original, score)
48+
with pytest.raises(ValueError):
49+
rate_pair(original, original, 1, fixed_left=1)
50+
with pytest.raises(ValueError):
51+
original.update([(original, 1)], tau=0)

0 commit comments

Comments
 (0)