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:
co-authored by
Claude Fable 5
parent
8db69685c4
commit
1dd72d2688
+36
-4
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user