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
+36 -4
View File
@@ -110,11 +110,11 @@ def test_run_resolve_writes_csv_and_is_idempotent(client, tmp_path):
cfg = Config(data_dir=data_dir)
first = run_resolve(cfg, client=client)
assert len(first) == 13
assert len(first) == 15
with cfg.matches_path.open(newline="") as f:
rows = list(csv.DictReader(f))
assert len(rows) == 13
assert len(rows) == 15
by_title = {r["title_raw"]: r for r in rows}
assert by_title["Catan"]["match_status"] == "auto"
assert by_title["Citadels"]["match_status"] == "ambiguous"
@@ -295,6 +295,29 @@ def test_starforce_two_word_head_matches_full_title(client):
assert row.version_status == "version_ambiguous"
def test_sibling_editions_surface_as_ambiguous(client):
"""BGG files new editions as SEPARATE games ("Wiz-War (Eighth
Edition)"): a lone exact match must not hide its siblings behind a
confident auto — the user can't know what they never see."""
entry = _entry("WIZ-WAR", confidence="high")
row = resolve_entry(client, entry)
assert row.match_status == "ambiguous"
names = {c.name for c in row.candidates}
assert "Wiz-War" in names
assert any("Eighth Edition" in n for n in names)
assert any("9th Edition" in n for n in names)
def test_lone_obscure_candidate_never_autos(client):
"""BGG's search visibly truncates generic queries — the game named
"Dungeon!" appears in NEITHER of its own searches — so the sole
surviving candidate may be an impostor. Ambiguous, never auto."""
entry = _entry("Dungeon!", confidence="high")
row = resolve_entry(client, entry)
assert row.match_status == "ambiguous"
assert row.bgg_id is None
def test_wrong_year_hint_never_drives_a_version(client):
"""Flat Top's box says 1942 (the theme, not the print year): version
scoring must not pick any version off the back of it."""
@@ -474,11 +497,20 @@ def test_run_resolve_dedupes_and_keeps_all_rows(tmp_path):
'<name type="primary" value="Wingspan"/><yearpublished value="2019"/>'
"</item></items>"
)
for query in ("Wingspan", "WINGSPAN!"):
# "WINGSPAN" is the depunct retry of "WINGSPAN!"; the stats file is
# the lone-candidate trust check (owned must clear the floor)
for query in ("Wingspan", "WINGSPAN!", "WINGSPAN"):
key = cache_key(
"search", {"query": query, "type": "boardgame,boardgameexpansion"}
)
(cache / key).write_text(wingspan_xml)
(cache / cache_key("thing", {"id": "266192", "stats": "1"})).write_text(
'<items><item type="boardgame" id="266192">'
'<name type="primary" value="Wingspan"/><yearpublished value="2019"/>'
'<statistics><ratings><owned value="120000"/>'
'<ranks><rank type="subtype" id="1" name="boardgame" value="30"/></ranks>'
"</ratings></statistics></item></items>"
)
data_dir = tmp_path / "data"
data_dir.mkdir()
@@ -517,7 +549,7 @@ def test_run_resolve_force_rebuilds_from_scratch(client, tmp_path):
write_matches(cfg.matches_path, rows)
forced = run_resolve(cfg, force=True, client=client)
assert len(forced) == 13 # every title re-resolved, none skipped
assert len(forced) == 15 # every title re-resolved, none skipped
fresh = {r["title_raw"]: r for r in read_matches(cfg.matches_path)}
assert fresh["Catan"]["match_status"] == "auto"