Skip to content

Commit ef73231

Browse files
jlhe97claude
andcommitted
Add account-recency signal and stale-account confidence cap
Lichess and chess.com both expose a last-activity timestamp on the same profile call already made (seenAt / last_online respectively), so this is free to add: a candidate with hundreds of games is still weak evidence of being a tournament entrant's *current* account if none of those games happened in the last several years. - lookup/lichess.py, lookup/chesscom.py: expose last_active (ISO date) on _slim_profile. - pipeline/resolver.py: _recency_score weights it into the composite score (full credit within 90 days, tapering to zero by ~2 years of inactivity), and _composite_score returns a third hard-cap flag (recency_ok, alongside the existing games-count and rating ones) that forces confidence to "low" once an account's been inactive for 5+ years, regardless of how well every other signal matches. Unknown activity doesn't penalize, matching the existing caps' pattern. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 56df0bd commit ef73231

6 files changed

Lines changed: 225 additions & 59 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ pipeline/ → end-to-end orchestrator: tournament → dossier folder
7474
- **chess.com username guessing must drop middle names**`lookup.chesscom.guess_usernames` splits `"Last, First Middle"` on the comma, but without trimming to just the first given name, every guess embeds a literal space (e.g. `"john derekheinichen"`) and 404s, silently breaking chess.com matching for anyone with a middle name on the entry list.
7575
- **Resolver confidence factors in games played, not just name/rating/country**`pipeline.resolver._composite_score` adds an optional games-count signal (`lookup.lichess`/`lookup.chesscom` `_slim_profile` expose `games_count`); more games raises confidence, scaling up to `_GAMES_FOR_FULL_SCORE` (50). Separately, `_confidence_for` hard-caps confidence at `"low"` when games_count is known and below `_MIN_GAMES_FOR_HIGH` (5) — a same-name account with almost no games is too thin a sample to call "high confidence" even if name/rating/country line up perfectly, since that combination is just as consistent with a different person. chess.com's `_slim_profile` also now exposes `real_name` (the account's real-name field, distinct from `display_name` which falls back to the username) — a genuine real-name match is direct evidence and can upgrade a late, generic-looking username guess to high confidence, the same way Lichess's `real_name` already could.
7676
- **A catastrophic rating mismatch is a second hard confidence cap, not just a scoring penalty** — a real production case exposed this: a generic guess ("john") landed on an unrelated stranger's real chess.com account, and because that account happened to have thousands of games and a "preferred" country, the composite score nearly reached "high" despite an ~1000-point rating gap. `_composite_score` now also returns `rating_ok` (`False` only when a rating comparison was actually made *and* came back fully clamped to the 0.0 floor — i.e. the gap is at or past `_RATING_TOLERANCE`), and `_confidence_for` caps confidence at `"low"` whenever `rating_ok` is `False`, the same way it does for a too-small `games_count`. No rating data at all leaves `rating_ok` `True` — absence of evidence isn't evidence of a mismatch.
77+
- **Account recency is a third signal and hard cap, distinct from games_count** — a lot of games proves the account is a real, active identity, but says nothing about whether it's *still* this player's active account: hundreds of games from 5+ years ago is weak evidence for a tournament entrant playing today. `lookup.lichess`/`lookup.chesscom` `_slim_profile` now expose `last_active` (an ISO date, from Lichess's `seenAt` or chess.com's `last_online` — both already present on the same profile call, no extra request) — `_recency_score` scales it from 1.0 (active within `_RECENCY_FULL_SCORE_DAYS`, 90 days) down to 0.0 (inactive `_RECENCY_ZERO_SCORE_DAYS`, ~2 years, or more), and `_composite_score` returns a third hard-cap flag `recency_ok` (`False` only when `last_active` is known *and* older than `_STALE_ACCOUNT_DAYS`, 5 years) that `_confidence_for` treats the same way as `rating_ok`/too-few-games. No `last_active` data leaves `recency_ok` `True`.
7778
- **chess.com guessing scores every guess and keeps the best, not just the first hit** — previously `_resolve_chesscom_by_guessing` stopped at the first guess that resolved to a real profile, so a weak, coincidental match early in the guess list (e.g. a bare first name colliding with an unrelated stranger) could block a later, better-evidenced guess from ever being considered. Now it behaves like the Lichess candidate-ranking path: try every guess, score each hit, keep the best.
7879
- **The country signal cross-checks actual FIDE nationality instead of assuming every entrant is US-based** — `lookup.uscf.get_fide_country(uscf_id)` scrapes the public USCF MSA member page (`https://www.uschess.org/msa/MbrDtlMain.php?<uscf_id>`, keyed by the `uscf_id` already scraped from the entry list — no name-based lookup or disambiguation needed) for the entrant's "FIDE Country" field, and converts the FIDE/IOC-style 3-letter federation code to ISO alpha-2 via `_FIDE_TO_ISO2` for comparison against a candidate's Lichess/chess.com country. `pipeline.runner.run_pipeline` looks this up once per player and passes it to both resolvers as `fide_country`; `_country_score` uses it when available and only falls back to the old blanket "US-preferred" heuristic when it isn't (most club-level players have no FIDE record at all). This was a real, demonstrated bug, not a hypothetical: the blanket US bias caused a correct non-US candidate with a 0.90 real-name match to lose to a wrong US candidate with only a 0.65 match, purely because of nationality — confirmed fixed against live data (score flipped from 0.75-wrong/0.64-correct to 0.53-wrong/0.93-correct once the actual FIDE nationality was known).
7980

@@ -90,7 +91,7 @@ Full URLs are auto-detected; `--site` is only needed for ID shorthands.
9091

9192
`pipeline/resolver.py`:
9293
- `_strip_title(name)` — strips a leading FIDE/USCF title
93-
- `resolve_lichess(name, rating=None, searxng_url=None, fide_country=None)` / `resolve_chesscom(name, rating=None, searxng_url=None, fide_country=None)``(username, "high"|"low"|None, score, reasons)` — each candidate is scored on name/handle similarity (weight 0.5), rating closeness to `rating` when available (weight 0.3, tighter tolerance if it's a FIDE rating rather than an online blitz/rapid one), account country (weight 0.2 — see below), and games played (weight 0.2, scaling up to `_GAMES_FOR_FULL_SCORE`); missing signals are dropped from the weighted average rather than penalising the candidate. `score >= 0.55` → high, `>= 0.30` → low, else rejected (`None`) — except `_confidence_for` overrides a would-be "high" down to "low" when the account has fewer than `_MIN_GAMES_FOR_HIGH` games on record, or when the rating comparison came back catastrophically bad (`rating_ok is False`), regardless of score (see the games-count and rating-mismatch design notes above).
94+
- `resolve_lichess(name, rating=None, searxng_url=None, fide_country=None)` / `resolve_chesscom(name, rating=None, searxng_url=None, fide_country=None)` → `(username, "high"|"low"|None, score, reasons)` — each candidate is scored on name/handle similarity (weight 0.5), rating closeness to `rating` when available (weight 0.3, tighter tolerance if it's a FIDE rating rather than an online blitz/rapid one), account country (weight 0.2 — see below), games played (weight 0.2, scaling up to `_GAMES_FOR_FULL_SCORE`), and account recency (weight 0.15, scaling from `_RECENCY_FULL_SCORE_DAYS` down to `_RECENCY_ZERO_SCORE_DAYS` — see below); missing signals are dropped from the weighted average rather than penalising the candidate. `score >= 0.55` → high, `>= 0.30` → low, else rejected (`None`) — except `_confidence_for` overrides a would-be "high" down to "low" when the account has fewer than `_MIN_GAMES_FOR_HIGH` games on record, when the rating comparison came back catastrophically bad (`rating_ok is False`), or when the account's been inactive for `_STALE_ACCOUNT_DAYS`+ (`recency_ok is False`), regardless of score (see the games-count, rating-mismatch, and recency design notes above).
9495
- Lichess: scores all `search()` candidates (cheap name-only pass first, then fetches full profiles for just the top 2)
9596
- chess.com: scores every guess that resolves to a real profile and keeps the best, on guess specificity ("firstlast" vs. a bare "first" stands in for name similarity, since every guess is mechanically derived from the name) *unless* the account has a genuine `real_name` on file, in which case an actual name-similarity score is used instead when it's higher
9697
- Both: if `searxng_url` is given and the above didn't already reach high confidence, also try `find_usernames_via_search()` (SearXNG search for the player's name plus `lichess.org` or `chess.com`) and keep whichever candidate scores best — catches a personalized handle with no relation to the player's name (e.g. Magnus Carlsen's real Lichess account is the pseudonymous `DrNykterstein`), findable only via the account's linked real name, which neither Lichess's username-only autocomplete nor any mechanical chess.com guess would ever surface. **This path needs a running SearXNG instance** (`$SEARXNG_URL` / `--searxng-url`) — without one, personalized handles like this are simply not discoverable; see the SearXNG setup note below.

lookup/chesscom.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
import json
2121
import argparse
2222
import calendar
23-
from datetime import date, timedelta
23+
from datetime import date, datetime, timedelta, timezone
2424

2525
import requests
2626

@@ -198,6 +198,14 @@ def _slim_profile(username: str, profile: dict, stats: dict) -> dict:
198198
"url": profile.get("url", f"https://www.chess.com/member/{username}"),
199199
"country": profile.get("country", "").split("/")[-1],
200200
"games_count": games_count,
201+
# When this account was last online, used as a confidence signal
202+
# distinct from games_count: an account with hundreds of games but
203+
# no activity in years is weak evidence of being *this* player's
204+
# current account, regardless of how many games it has on record.
205+
"last_active": (
206+
datetime.fromtimestamp(profile["last_online"], tz=timezone.utc).date().isoformat()
207+
if profile.get("last_online") else None
208+
),
201209
}
202210

203211

lookup/lichess.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import json
1919
import argparse
2020
import time
21+
from datetime import datetime, timezone
2122

2223
import requests
2324

@@ -181,6 +182,15 @@ def _slim_profile(data: dict) -> dict:
181182
# account with almost no games is weak evidence either way, no
182183
# matter how well the name matches.
183184
"games_count": data.get("count", {}).get("all"),
185+
# Also full-profile-only — when this account was last seen online,
186+
# used as a confidence signal distinct from games_count: an account
187+
# with hundreds of games but no activity in years is weak evidence
188+
# of being *this* player's current account, regardless of how many
189+
# games it has on record.
190+
"last_active": (
191+
datetime.fromtimestamp(data["seenAt"] / 1000, tz=timezone.utc).date().isoformat()
192+
if data.get("seenAt") else None
193+
),
184194
}
185195

186196

0 commit comments

Comments
 (0)