The matcher stops trusting what the user can't see

Eric's question cut to the bone: "How would a user know? It matched
wiz-war and that IS the game." The auto looked unanimous because the
matcher discarded the evidence of doubt before anyone saw it — and
worse, BGG's search hides evidence of its own: results truncate
unordered in the several-hundreds (the game named "Dungeon!" appears
in NEITHER the "Dungeon!" nor the "Dungeon" search), and punctuation
can bury matches.

Three matcher changes: every title is searched raw AND depuncted,
merged by id; a name that becomes exact once its trailing
parenthetical is stripped ("Wiz-War (Eighth Edition)") is a sibling
edition — BGG files new editions as separate games — and enters the
candidate set at exact grade, so same-named lineages land in review as
a visible choice; and a LONE candidate must now earn trust (stats
fetched, sibling-grade never autos alone, true exacts must clear the
dominance ownership floor) — closing the fast path both impostors
(.dungeon at 31 owners, then Dungeon (ICP)) walked through.

Recorded outcomes: WIZ-WAR → ambiguous with all three lineages on the
ballot; Dungeon! → ambiguous (its true match is beyond BGG's search
horizon — that's what manual id is for); every legitimate auto in the
fixture set held. And the answer to Eric's second question is now
structural: re-match never re-decides — it demotes to unmatched and
the HUMAN picks from re-search or manual id; the machine only chooses
on first resolve, and it now chooses more humbly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
This commit is contained in:
Eric Wagoner
2026-08-05 21:47:49 -04:00
co-authored by Claude Fable 5
parent 8db69685c4
commit 1dd72d2688
27 changed files with 12753 additions and 13 deletions
+61 -3
View File
@@ -89,6 +89,7 @@ class Candidate:
type: str
exact: bool
fuzzy: float
sibling: bool = False # exactness earned via edition-suffix stripping
owned: int | None = None
rank: int | None = None
publishers: list[str] = field(default_factory=list)
@@ -226,6 +227,18 @@ def _plausible_candidates(
for result in results:
norm = normalize_title(result.name)
exact = norm == entry.title_normalized
# BGG files new editions as SEPARATE games named "X (Nth Edition)":
# a name that equals the title once its trailing parenthetical is
# stripped is a sibling edition — exact-grade, or the match looks
# unanimously confident while hiding the real choice (Wiz-War has
# three same-named lineages; the spec's top failure mode)
sibling = False
if not exact and result.type != "boardgameexpansion":
sibling = (
normalize_title(_EDITION_SUFFIX.sub("", result.name))
== entry.title_normalized
)
exact = sibling
fuzzy = fuzz.token_sort_ratio(norm, entry.title_normalized)
if not exact and fuzzy < FUZZY_THRESHOLD:
if not (head_normalized and norm == head_normalized):
@@ -237,6 +250,7 @@ def _plausible_candidates(
year=result.year,
type=result.type,
exact=exact,
sibling=sibling,
fuzzy=fuzzy,
)
prev = by_id.get(result.bgg_id)
@@ -255,7 +269,23 @@ def _classify(client: BGGClient, entry: TitleEntry, cands: list[Candidate]) -> M
return row
if len(cands) == 1:
chosen = cands[0]
# a lone candidate must still earn trust: BGG's search visibly
# truncates generic queries (the game named "Dungeon!" appears in
# NEITHER of its own searches), so the sole survivor may be an
# impostor standing where the famous game should be. Sibling-grade
# exactness never autos alone, and a true exact must clear the
# same ownership floor the dominance rule enforces.
only = cands[0]
stats = {t.bgg_id: t for t in client.things([only.bgg_id], stats=True)}
if only.bgg_id in stats:
only.owned = stats[only.bgg_id].owned
only.rank = stats[only.bgg_id].rank
only.publishers = list(stats[only.bgg_id].publishers)
row.candidates = [only]
if only.sibling or not only.exact or (only.owned or 0) < DOMINANCE_MIN_OWNED:
row.match_status = "ambiguous"
return row
chosen = only
else:
top = cands[:5]
stats = {
@@ -391,8 +421,36 @@ def resolve_version(client: BGGClient, entry: TitleEntry, row: MatchRow) -> None
row.version_status = "version_ambiguous"
_EDITION_SUFFIX = re.compile(r"\s*\([^)]*\)\s*$")
_PUNCT = re.compile(r"[^\w\s]", re.UNICODE)
def _depunct(title: str) -> str:
"""BGG's search engine can choke on punctuation — the game literally
named "Dungeon!" is missing from its own 824-result search. A plain
query recovers it."""
return " ".join(_PUNCT.sub(" ", title).split())
def _merged_candidates(
client: BGGClient, entry: TitleEntry, query: str, types: str | None = None
) -> list[Candidate]:
"""Search the raw title AND its punctuation-free form, merged by id
(strongest evidence wins)."""
cands = _plausible_candidates(client, entry, query, types=types)
plain = _depunct(query)
if plain.casefold() != query.casefold():
by_id = {c.bgg_id: c for c in cands}
for c in _plausible_candidates(client, entry, plain, types=types):
prev = by_id.get(c.bgg_id)
if prev is None or (c.exact, c.fuzzy) > (prev.exact, prev.fuzzy):
by_id[c.bgg_id] = c
cands = sorted(by_id.values(), key=lambda c: (not c.exact, -c.fuzzy))
return cands
def resolve_entry(client: BGGClient, entry: TitleEntry) -> MatchRow:
cands = _plausible_candidates(client, entry, entry.title_raw)
cands = _merged_candidates(client, entry, entry.title_raw)
if not cands:
# Long transcribed box titles ("CIVILIZATION Game of the Heroic
# Age - ...") defeat search: retry with progressively shorter heads
@@ -406,7 +464,7 @@ def resolve_entry(client: BGGClient, entry: TitleEntry) -> MatchRow:
# type=rpgitem (same API, same token). A hit becomes a LOCAL
# library citizen: identified and enriched, never uploaded (diff
# routes rpgitem rows to local_only).
cands = _plausible_candidates(client, entry, entry.title_raw, types="rpgitem")
cands = _merged_candidates(client, entry, entry.title_raw, types="rpgitem")
if not cands:
for head in _truncation_heads(entry.title_raw):
cands = _plausible_candidates(