Skip to content

Commit 218ef1f

Browse files
jlhe97claude
andcommitted
Fix name-score false positive from stripping word boundaries
_name_score's surname-substring floor stripped all whitespace before comparing, so a real name like "Simran Kolagad" collapsed to "simrankolagad" and matched entrant "Imran, Sheikh Waali" as a raw substring despite being an unrelated person. Real names have genuine word boundaries; only username-style candidates need the old substring-anywhere check. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 67c6f61 commit 218ef1f

3 files changed

Lines changed: 55 additions & 6 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ pipeline/ → end-to-end orchestrator: tournament → dossier folder
7878
- **Name-search queries must be natural-order and unquoted, with a bare domain** — `lookup.lichess.find_usernames_via_search` / `lookup.chesscom.find_usernames_via_search` reformat the entry-list "Last, First" name to natural "First Last" order (`_natural_name_order`) before searching, and build a query like `Marek Antoni Kowalski chess.com` — no exact-phrase quotes, no path fragment (`chess.com/member`, `lichess.org/@`). Confirmed empirically against a real unresolved case: exact-phrase-quoting the raw "Last, First" order finds nothing (real pages essentially never contain that literal comma-ordered substring), and separately, appending a path fragment as query text also kills recall on real search backends (it gets tokenized as unrelated keyword noise, e.g. "member", rather than treated as a URL/site hint) — either mistake alone is enough to silently miss an otherwise easily-findable profile page. A single self-hosted SearXNG instance backed by only 1–2 actually-responding engines (others get rate-limited/CAPTCHA'd quickly on a non-residential IP) is also inherently a bit flaky call-to-call — a miss is worth a retry before concluding the account isn't findable.
7979
- **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.
8080
- **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.
81+
- **`_name_score`'s surname-substring floor must respect word boundaries when the candidate is a real name, not a username** — a real production false positive: entrant "Imran, Sheikh Waali" scored a 0.65 name-match floor against total stranger "Simran Kolagad"'s chess.com `real_name`, because the substring check stripped all whitespace before comparing (`"simran kolagad"` → `"simrankolagad"`, which contains `"imran"` as a raw substring even though the names are unrelated). Usernames genuinely need the old substring-anywhere check (`"jsmith"` for "Smith, John" has no delimiter to preserve), but a `real_name`/display name with a space in it has genuine word boundaries — `_name_score` now only applies the floor there when the surname matches a *whole word* of the candidate, the same whole-word-vs-substring principle already applied to megabase queries above. Only saved from surfacing as a false "high confidence" match that time because the account also happened to be 18+ years stale (see the recency cap below) — a differently-timed collision wouldn't have been. Covered by `TestNameScore` in `tests/test_pipeline.py`.
8182
- **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.
8283
- **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`.
8384
- **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.
@@ -126,6 +127,7 @@ Full URLs are auto-detected; `--site` is only needed for ID shorthands.
126127
- Remaining: combined PDF output. A SearXNG instance still needs a card-free signup-free way to run reliably 24/7 outside a sandbox with no Docker (the one used for development here was run from source in a venv — see the setup note below) — and even with search enabled, it depends on which upstream engines happen to be responding, so this stays a best-effort discovery path, not a guarantee.
127128
- Fixed 2026-07-10, both confirmed against real players in the recurring `WQ260708` tournament: `lookup.broadcasts.find_broadcast_round_ids` had never picked up the natural-order/unquoted/bare-domain query fix already applied to `lichess.py`/`chesscom.py`'s search (it was still exact-phrase-quoting the raw "Last, First" order and appending a `/broadcast` path fragment), silently missing real, findable rounds — and a chess.com generic single-word guess (e.g. "john") could collide with an unrelated stranger's real account and score "high" purely from country/games/recency when no rating was on file to catch it, locking out the search path entirely before it ever ran. See `_MIN_NAME_SCORE_FOR_HIGH`/`name_ok` in `pipeline/resolver.py`.
128129
- Also done 2026-07-10: broadcast games no longer link out to the live Lichess broadcast page (folded into the local games-browser instead) and are persisted per-player so a later run doesn't lose one SearXNG happens to miss; and the resolver/broadcast-search cache described in the caching roadmap item below is implemented (`pipeline/cache.py`) — the first, highest-impact slice of that item. Still open from that same item: megabase-query caching and any change to the deliberate 1s Lichess rate-limit sleeps themselves.
130+
- Investigated 2026-07-14 — user-reported "drop in quality" in generated reports, specifically wrong/low-quality profile matches: audited the live `dossiers/WQ260708/` output (33 players, a 2026-07-11 scan) rather than guessing, and found `_name_score`'s surname-substring floor (see the design-decision note above) producing false-positive name matches against unrelated strangers' real names once word boundaries were erased by space-stripping — fixed. The four confidence hard caps (games/rating/recency/name_ok) added 2026-07-10 are working as designed and are *not* the regression — most of what they're capping down (accounts 8–19 years inactive) is correctly-suspect evidence, not a bug; `_name_score` was the one genuinely broken input feeding them.
129131

130132
### Future roadmap ideas (not yet started)
131133

pipeline/resolver.py

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -100,19 +100,39 @@ def norm(s: str) -> str:
100100

101101
def _name_score(name: str, candidate: str) -> float:
102102
"""
103-
Similarity between an entry-list name and a Lichess display name
104-
(which is just their username, since Lichess has no real-name field).
103+
Similarity between an entry-list name and a Lichess/chess.com display
104+
name or real name.
105105
106106
Blends raw character similarity with a surname-substring check, so a
107107
handle like "hikaru99" still scores well against "Nakamura, Hikaru"
108108
even though the two strings look quite different overall.
109+
110+
A `candidate` with a space is a natural "First Last"-style real name,
111+
not a glued-together username, so it has genuine word boundaries —
112+
the surname must match a *whole word*, not just appear as a substring
113+
once boundaries are erased. Stripping spaces before comparing (as a
114+
username-style candidate needs, since "jsmith" only matches "Smith"
115+
once glued together) turns "Simran Kolagad" into "simrankolagad",
116+
which contains "Imran" as a raw substring even though the two names
117+
are unrelated — a real false-positive match seen in production
118+
(entrant "Imran, Sheikh Waali" scored 0.65 against total stranger
119+
"Simran Kolagad"). Username-style candidates keep the old
120+
substring-anywhere check, since that's exactly the pattern a real
121+
surname+initial/number username needs.
109122
"""
110123
sim = _similarity(name, candidate)
111124
last = name.split(",")[0].strip() if "," in name else (name.split()[-1] if name.split() else "")
112125
last_norm = re.sub(r"[^a-z0-9]", "", last.lower())
113-
cand_norm = re.sub(r"[^a-z0-9]", "", candidate.lower())
114-
if len(last_norm) >= 3 and last_norm in cand_norm:
115-
sim = max(sim, 0.65)
126+
if len(last_norm) < 3:
127+
return sim
128+
if " " in candidate.strip():
129+
words = [re.sub(r"[^a-z0-9]", "", w.lower()) for w in candidate.split()]
130+
if last_norm in words:
131+
sim = max(sim, 0.65)
132+
else:
133+
cand_norm = re.sub(r"[^a-z0-9]", "", candidate.lower())
134+
if last_norm in cand_norm:
135+
sim = max(sim, 0.65)
116136
return sim
117137

118138

tests/test_pipeline.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import pytest
1212
import requests
1313

14-
from pipeline.resolver import resolve_lichess, resolve_chesscom, _similarity, _strip_title
14+
from pipeline.resolver import resolve_lichess, resolve_chesscom, _similarity, _strip_title, _name_score
1515
from pipeline.runner import _slug, run_pipeline, _ensure_game_links, _delink_broadcast_games
1616
from dossier.report import build_dossier, render_markdown, render_html, render_html_combined
1717

@@ -34,6 +34,33 @@ def test_no_match(self):
3434
assert _similarity("aaaa", "zzzz") == pytest.approx(0.0)
3535

3636

37+
# ---------------------------------------------------------------------------
38+
# _name_score
39+
# ---------------------------------------------------------------------------
40+
41+
class TestNameScore:
42+
def test_username_surname_plus_initial_gets_floor(self):
43+
# "jsmith" glues an initial onto the surname with no delimiter —
44+
# the substring-anywhere check is exactly right here.
45+
assert _name_score("Smith, John", "jsmith") >= 0.65
46+
47+
def test_real_name_whole_word_surname_gets_floor(self):
48+
assert _name_score("Hunter, Jacob Carl", "Hunter Jacob") >= 0.65
49+
50+
def test_real_name_unrelated_stranger_does_not_get_floor(self):
51+
# Real production false positive: "Imran" is a raw substring of
52+
# "Simran Kolagad" once spaces are stripped, but Simran Kolagad is
53+
# a totally unrelated person, not a name-order variant of "Sheikh
54+
# Waali Imran". Real names have genuine word boundaries (spaces),
55+
# so the surname must match a whole word, not just survive being
56+
# glued to its neighbours.
57+
score = _name_score("Imran, Sheikh Waali", "simran kolagad")
58+
assert score < 0.65
59+
60+
def test_short_surname_still_requires_whole_word_in_real_name(self):
61+
assert _name_score("Bao, Kangdi", "Rebaostein Anders") < 0.65
62+
63+
3764
# ---------------------------------------------------------------------------
3865
# _strip_title
3966
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)