Skip to content

Commit 2c4d8e9

Browse files
jlhe97claude
andcommitted
Fix chess.com generic-guess false positives and stale broadcast search query
A bare single-word chess.com guess (e.g. "john") could coincidentally match an unrelated stranger's account and score "high" purely from country/games/ recency when no rating was available to catch it — locking out the SearXNG search path that would have found the real, differently-named account. resolve_chesscom now tracks a fourth hard-cap signal, name_ok, and prefers a well-evidenced search hit over an untrustworthy guess winner even at a lower raw score. Separately, lookup.broadcasts.find_broadcast_round_ids was still building the pre-fix search query (exact-phrase-quoted, raw "Last, First" order, a /broadcast path fragment) that lichess.py/chesscom.py's search already moved away from — silently missing real, findable rounds. Also raised max_results 5 -> 10, since target rounds only reliably surfaced past the old default. Also documents five new roadmap ideas: opening-theory/blunder insights, feeding the user's own games into opponent dossiers, personal-repertoire matching, cloud-hosted reports, and pipeline caching/perf. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent ef73231 commit 2c4d8e9

6 files changed

Lines changed: 160 additions & 21 deletions

File tree

CLAUDE.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,15 @@ Full URLs are auto-detected; `--site` is only needed for ID shorthands.
118118
- Also done: game-count-aware and rating-mismatch-aware match confidence, chess.com guessing scores every candidate instead of stopping at the first hit, web search migrated from Brave to self-hosted SearXNG, a client-side engine (Stockfish by default, off until toggled, top-3 multi-PV lines) with flip/eval/best-move in an enlarged/centered games viewer, a recent-games (last 12 months) viewer for high-confidence matches, and an opt-in cross-scan dossier database.
119119
- Verified against real data across two real tournament scans: with the games-count and rating-mismatch hard caps in place, a wrong-but-plausible candidate (large game count, matching country, but a rating far outside tolerance) correctly stays at *low* confidence instead of being fooled by the incidental signals; separately, fixing the search query construction (natural name order, unquoted, bare domain — see above) took a personalized-handle case that the guess-only path could never reach from "not discoverable at all" to "found, high confidence" once SearXNG was live, though a single self-hosted instance with only 1–2 reliably-responding engines means recall on any one query isn't 100% consistent call to call.
120120
- 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.
121+
- 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`.
122+
123+
### Future roadmap ideas (not yet started)
124+
125+
- **Mistake/theory-deviation insights per opponent** — flag where an opponent's games diverge from known theory or contain outright blunders, not just which openings they play. Two different signals, both new: (1) "deviates from theory" doesn't need an external opening book — the megabase itself already has 11.7M games, so "theory" at any position can be derived from what the megabase's strongest/most-common continuations actually are, compared ply-by-ply against the opponent's games (`analysis/openings.py` already builds a move-tree per opponent; this would need the same tree built from megabase as a baseline to diff against). (2) "mistakes" needs actual eval, which today only exists client-side (the games-browser's Stockfish Web Worker, browser-only, nothing persisted) — a report-time version would need a server-side UCI engine pass (e.g. the `stockfish` PyPI binary wrapped via `python-chess`) over each opponent's games, which is a real new dependency and meaningfully slower per-dossier build, so probably opt-in (`--analyse-blunders` or similar) rather than default.
126+
- **Feed your own games against an opponent into their dossier** — right now `build_dossier()` only ever sees the opponent's own games from the 5 sourcing paths; there's no way to inject *your* games against them specifically. Would need: a way to supply your own PGN pool (a file, or your own Lichess/chess.com username reused through the existing `lookup.lichess`/`lookup.chesscom` fetchers), a head-to-head filter matching the opponent's name on the *other* side of the board from you, and a distinct "vs. you" section in the report (and, per the "combined reports and databases" ask, a way to keep accumulating this across scans rather than starting over each run — `dossier/db.py`'s existing cross-scan history is the natural place this could plug in, since it's already keyed by opponent name-slug).
127+
- **Personal repertoire vs. opponent tendencies** — let the user define their own opening repertoire (as White and Black) and have the report highlight which of the opponent's games actually entered one of those lines, i.e. "here's what they did the last N times they faced what you actually play," rather than their opening stats in the abstract. Representationally this is close to what `analysis/openings.py` already builds (a move-sequence tree per player) — the new part is a second tree (the user's own repertoire, probably authored as a small PGN of main lines) and a matching pass that walks each opponent game against it to find where the game and the repertoire diverge, then surfaces just those games instead of (or in addition to) the full opening breakdown.
128+
- **Cloud hosting for dossiers/history, accessible from multiple devices** — today everything is local-only: HTML/MD/JSON dossier files on disk, `dossier/db.py`'s SQLite history, and the 16GB `megabase.db`. The megabase itself is impractical to host remotely at that size for a personal tool (and read-only/build-once anyway, so it doesn't need to move), but the actual per-scan outputs (dossier files + the history db) are small and are the part worth syncing — e.g. pushing rendered HTML to a static host (S3/R2/GitHub Pages on a private repo) or standing up a minimal read-only web view backed by `dossier/db.py`. Needs a real answer on access control before anything ships, since dossiers currently contain other real players' names/data with no auth model at all today.
129+
- **Caching / faster pipeline runs** — a full `WQ260708`-sized scan (~33 players) takes on the order of 10+ minutes end to end, and re-running it (e.g. after a resolver change, or the tournament's entry list gaining a few late entrants) currently redoes *everything* from scratch for every player, including the ones that haven't changed. The known cost centers, concretely: `lookup.lichess` sleeps 1s before every game-fetch call (`_RATE_DELAY`) and `pipeline.runner` adds its own 1s sleeps around megabase/profile fetches (`time.sleep(1.0)`, `pipeline/runner.py`) — deliberate rate-limit courtesy, not accidental, so not just removable; chess.com guessing can issue up to 9 sequential profile HTTP requests per player before or in addition to a SearXNG search call; and nothing from a previous run is reused, so an unresolved or low-confidence player gets the identical (slow, and possibly still-failing) resolution attempt every single re-run. The most promising angle is probably a resolver-result cache keyed by player-name-slug with a TTL (skip re-resolving a player whose username/confidence was already established recently, similar to how `dossier/db.py` already dedupes a same-day rerun) — separately, megabase queries and broadcast-round PGN fetches (immutable once played) are also cacheable with no staleness concern at all, unlike a live Lichess/chess.com profile lookup.
121130

122131
### SearXNG setup
123132

lookup/broadcasts.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,32 @@ def _extract_round_id(url: str) -> str | None:
3535
return m.group(1) if m else None
3636

3737

38-
def find_broadcast_round_ids(player_name: str, searxng_url: str, max_results: int = 5) -> list[str]:
39-
"""Web-search for the player's name alongside Lichess broadcasts, return round IDs found."""
40-
results = web_search(f'"{player_name}" lichess.org/broadcast', searxng_url, count=max_results)
38+
def _natural_name_order(name: str) -> str:
39+
"""'Last, First Middle' -> 'First Middle Last' — real web pages never
40+
write a name in tournament-entry-list comma order."""
41+
if "," not in name:
42+
return name
43+
last, _, first = name.partition(",")
44+
return f"{first.strip()} {last.strip()}"
45+
46+
47+
def find_broadcast_round_ids(player_name: str, searxng_url: str, max_results: int = 10) -> list[str]:
48+
"""
49+
Web-search for the player's name alongside Lichess broadcasts, return
50+
round IDs found.
51+
52+
The query is natural-order, unquoted, and uses a bare domain — the
53+
same fix already applied to lookup.lichess/chesscom's
54+
find_usernames_via_search (see their docstrings/CLAUDE.md): exact-
55+
phrase-quoting the raw "Last, First" order finds nothing (real pages
56+
essentially never contain that literal comma-ordered substring), and
57+
appending a path fragment like "lichess.org/broadcast" gets tokenized
58+
as unrelated keyword noise rather than treated as a URL hint. This
59+
module used to do both, which silently missed real, findable
60+
broadcast rounds.
61+
"""
62+
query_name = _natural_name_order(player_name)
63+
results = web_search(f'{query_name} lichess.org', searxng_url, count=max_results)
4164
round_ids: list[str] = []
4265
for r in results:
4366
rid = _extract_round_id(r.get("url", ""))
@@ -53,7 +76,7 @@ def get_round_pgn(round_id: str) -> str:
5376
return resp.text
5477

5578

56-
def find_games(player_name: str, searxng_url: str, max_results: int = 5) -> list[str]:
79+
def find_games(player_name: str, searxng_url: str, max_results: int = 10) -> list[str]:
5780
"""
5881
Search for broadcasts mentioning `player_name`, fetch matching rounds,
5982
and return every game in them as a PGN string. Games not actually

pipeline/resolver.py

Lines changed: 60 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,21 @@
5757
# strict cutoff — it only nudges the score, never hard-rejects.
5858
_RATING_TOLERANCE = 800
5959

60+
# A real production case exposed this: chess.com's bare single-word guesses
61+
# ("john") get a low placeholder name score (0.4, see
62+
# _resolve_chesscom_by_guessing) precisely because they're generic enough to
63+
# collide with an unrelated stranger — but when that stranger's account also
64+
# happens to have a "preferred" country, plenty of games, and recent
65+
# activity, those three signals alone (none of which is actually
66+
# person-specific — country is broad, games/recency just prove "a real
67+
# active account", not "the right one") can still drag the composite score
68+
# past _HIGH_THRESHOLD, with no rating comparison available at all to catch
69+
# the mismatch the way _RATING_TOLERANCE normally would. 0.65 mirrors
70+
# _name_score's own surname-substring floor, so any guess/search hit that's
71+
# genuinely tied to the name (compound guess, real-name match, or a search
72+
# result whose surname actually appears) already clears it.
73+
_MIN_NAME_SCORE_FOR_HIGH = 0.65
74+
6075
# Tournaments on the two supported sites (kingregistration.com,
6176
# chessaction.com) are US-based, so a US-located profile is weak
6277
# positive evidence and a clearly non-US one is weak negative evidence.
@@ -335,15 +350,17 @@ def _resolve_lichess_by_search(
335350

336351

337352
def _confidence_for(score: float, games_count: int | None, rating_ok: bool = True,
338-
recency_ok: bool = True) -> str | None:
353+
recency_ok: bool = True, name_ok: bool = True) -> str | None:
339354
"""
340-
Map a composite score to a confidence label, with three hard overrides
355+
Map a composite score to a confidence label, with four hard overrides
341356
that can only ever pull "high" down to "low", never the reverse:
342357
- a match backed by very few games (see _MIN_GAMES_FOR_HIGH)
343358
- a match whose rating comparison was catastrophically bad (see
344359
_composite_score's rating_ok)
345360
- a match whose account has had no activity in ~5 years (see
346361
_composite_score's recency_ok)
362+
- a match whose name evidence is a generic, uncorroborated guess
363+
(see _MIN_NAME_SCORE_FOR_HIGH)
347364
Any one alone means "score says high, but the strongest piece of
348365
corroborating evidence actively argues against it" — not a case to
349366
present as a confident identity match.
@@ -355,6 +372,8 @@ def _confidence_for(score: float, games_count: int | None, rating_ok: bool = Tru
355372
return "low"
356373
if not recency_ok:
357374
return "low"
375+
if not name_ok:
376+
return "low"
358377
return "high"
359378
if score >= _LOW_THRESHOLD:
360379
return "low"
@@ -408,7 +427,7 @@ def resolve_lichess(name: str, rating: int | None = None,
408427

409428
def _resolve_chesscom_by_guessing(
410429
name: str, rating: int | None, fide_country: str | None = None
411-
) -> tuple[str | None, float, list[str], int | None, bool, bool]:
430+
) -> tuple[str | None, float, list[str], int | None, bool, bool, bool]:
412431
"""
413432
Try every guessed chess.com username, scoring each one that resolves
414433
to a real profile and keeping the best-scoring candidate — rather than
@@ -428,11 +447,11 @@ def _resolve_chesscom_by_guessing(
428447
try:
429448
from lookup.chesscom import guess_usernames, get_profile
430449
except Exception:
431-
return None, -1.0, [], None, True, True
450+
return None, -1.0, [], None, True, True, True
432451

433452
guesses = guess_usernames(name)
434453
best_username, best_score, best_reasons = None, -1.0, []
435-
best_games, best_rating_ok, best_recency_ok = None, True, True
454+
best_games, best_rating_ok, best_recency_ok, best_name_ok = None, True, True, True
436455
for i, username in enumerate(guesses):
437456
try:
438457
profile = get_profile(username)
@@ -455,6 +474,13 @@ def _resolve_chesscom_by_guessing(
455474
if real_s > name_s:
456475
name_s, name_reason = real_s, f"real name match {real_s:.2f} ({real_name})"
457476

477+
# A weak, generic guess (bare "john") is only trustworthy if
478+
# something else actually ties it to *this* person — a rating
479+
# comparison genuinely happened (entry rating known and the
480+
# account has one on file), not just "no data to contradict it".
481+
# See _MIN_NAME_SCORE_FOR_HIGH.
482+
rating_corroborated = bool(rating) and bool(profile.get("ratings"))
483+
458484
score, reasons, rating_ok, recency_ok = _composite_score(
459485
name_s, name_reason,
460486
profile.get("ratings", {}), profile.get("country"), rating,
@@ -465,13 +491,14 @@ def _resolve_chesscom_by_guessing(
465491
if score > best_score:
466492
best_username, best_score, best_reasons = username, score, reasons
467493
best_games, best_rating_ok, best_recency_ok = profile.get("games_count"), rating_ok, recency_ok
494+
best_name_ok = name_s >= _MIN_NAME_SCORE_FOR_HIGH or rating_corroborated
468495

469-
return best_username, best_score, best_reasons, best_games, best_rating_ok, best_recency_ok
496+
return best_username, best_score, best_reasons, best_games, best_rating_ok, best_recency_ok, best_name_ok
470497

471498

472499
def _resolve_chesscom_by_search(
473500
name: str, rating: int | None, searxng_url: str, fide_country: str | None = None
474-
) -> tuple[str | None, float, list[str], int | None, bool, bool]:
501+
) -> tuple[str | None, float, list[str], int | None, bool, bool, bool]:
475502
"""
476503
Web-search for the player's chess.com profile and score every
477504
candidate found on real name/rating/country — catches personalized
@@ -481,10 +508,10 @@ def _resolve_chesscom_by_search(
481508
try:
482509
from lookup.chesscom import find_usernames_via_search, get_profile
483510
except Exception:
484-
return None, -1.0, [], None, True, True
511+
return None, -1.0, [], None, True, True, True
485512

486513
best_username, best_score, best_reasons = None, -1.0, []
487-
best_games, best_rating_ok, best_recency_ok = None, True, True
514+
best_games, best_rating_ok, best_recency_ok, best_name_ok = None, True, True, True
488515
for username in find_usernames_via_search(name, searxng_url):
489516
try:
490517
profile = get_profile(username)
@@ -505,8 +532,9 @@ def _resolve_chesscom_by_search(
505532
if score > best_score:
506533
best_username, best_score, best_reasons = username, score, reasons
507534
best_games, best_rating_ok, best_recency_ok = profile.get("games_count"), rating_ok, recency_ok
535+
best_name_ok = name_s >= _MIN_NAME_SCORE_FOR_HIGH
508536

509-
return best_username, best_score, best_reasons, best_games, best_rating_ok, best_recency_ok
537+
return best_username, best_score, best_reasons, best_games, best_rating_ok, best_recency_ok, best_name_ok
510538

511539

512540
def resolve_chesscom(name: str, rating: int | None = None,
@@ -527,27 +555,43 @@ def resolve_chesscom(name: str, rating: int | None = None,
527555
"""
528556
name = _strip_title(name)
529557

530-
best_username, best_score, best_reasons, best_games, best_rating_ok, best_recency_ok = (
558+
best_username, best_score, best_reasons, best_games, best_rating_ok, best_recency_ok, best_name_ok = (
531559
_resolve_chesscom_by_guessing(name, rating, fide_country))
532560

533-
if searxng_url and best_score < _HIGH_THRESHOLD:
534-
s_username, s_score, s_reasons, s_games, s_rating_ok, s_recency_ok = _resolve_chesscom_by_search(
561+
# Even if guessing already scored "high", a generic guess with no
562+
# rating corroboration (best_name_ok False) isn't trustworthy enough to
563+
# skip search on — the win could be a same-first-name stranger, and
564+
# search is the only path that can find the real, differently-named
565+
# account instead. See _MIN_NAME_SCORE_FOR_HIGH.
566+
if searxng_url and (best_score < _HIGH_THRESHOLD or not best_name_ok):
567+
s_username, s_score, s_reasons, s_games, s_rating_ok, s_recency_ok, s_name_ok = _resolve_chesscom_by_search(
535568
name, rating, searxng_url, fide_country)
536-
if s_score > best_score:
569+
# Prefer the search hit outright, even at a lower raw score, when
570+
# the guess winner is a generic, uncorroborated guess (untrusted —
571+
# see above) and search found a candidate whose name evidence is
572+
# actually solid: a same-first-name stranger's inflated score from
573+
# non-discriminating signals (country/games/recency) shouldn't
574+
# outrank the real, differently-named account search just found.
575+
if s_username is not None and (
576+
s_score > best_score
577+
or (not best_name_ok and s_name_ok and s_score >= _LOW_THRESHOLD)
578+
):
537579
best_username, best_score, best_reasons = s_username, s_score, s_reasons
538-
best_games, best_rating_ok, best_recency_ok = s_games, s_rating_ok, s_recency_ok
580+
best_games, best_rating_ok, best_recency_ok, best_name_ok = s_games, s_rating_ok, s_recency_ok, s_name_ok
539581

540582
if best_username is None:
541583
return None, None, 0.0, []
542584

543-
confidence = _confidence_for(best_score, best_games, best_rating_ok, best_recency_ok)
585+
confidence = _confidence_for(best_score, best_games, best_rating_ok, best_recency_ok, best_name_ok)
544586
if confidence == "low" and best_score >= _HIGH_THRESHOLD:
545587
if best_games is not None and best_games < _MIN_GAMES_FOR_HIGH:
546588
best_reasons = best_reasons + [f"capped from high: only {best_games} games on record"]
547589
elif not best_rating_ok:
548590
best_reasons = best_reasons + ["capped from high: rating far outside tolerance"]
549591
elif not best_recency_ok:
550592
best_reasons = best_reasons + ["capped from high: no recent activity"]
593+
elif not best_name_ok:
594+
best_reasons = best_reasons + ["capped from high: generic username guess with no rating/real-name corroboration"]
551595
if confidence is None:
552596
return None, None, best_score, best_reasons
553597
return best_username, confidence, best_score, best_reasons

pipeline/runner.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ def _fetch_megabase_games(name: str, db_path: str, limit: int | None = None,
115115
return []
116116

117117

118-
def _fetch_broadcast_games(name: str, searxng_url: str, max_results: int = 5) -> list[str]:
118+
def _fetch_broadcast_games(name: str, searxng_url: str, max_results: int = 10) -> list[str]:
119119
try:
120120
from lookup.broadcasts import find_games
121121
return find_games(name, searxng_url, max_results=max_results)

0 commit comments

Comments
 (0)