Catalog pairs same-title rows by photos, not csv position

The Wiz-War lines were displaying each other's rows: an edit re-queue
recreates its row at the END of matches.csv, and the catalog's
positional per-title pairing then crossed the wires — the 4504 line
wore 4528's open ballot while 4528's line offered 4504's pick-edition
button (whose click re-targeted by photos and safely hit the other
row, deepening the confusion). Pairing now matches run_resolve's rule:
exact photo set, then overlap, then positional fallback, with
unclaimed rows appended as their own lines. Plus a regression test
with deliberately reversed csv order, and the open-ballot marker
restyled as a quiet dotted link instead of a mis-wrapped button.

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:32:46 -04:00
co-authored by Claude Fable 5
parent c94e8bc695
commit 3e61a03686
5 changed files with 80 additions and 17 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ Cards Against Humanity,50381,Cards Against Humanity,2009,boardgame,auto,,,versio
Joking Hazard,193621,Joking Hazard,2016,boardgame,auto,,,version_unknown,"[{""bgg_id"": 193621, ""name"": ""Joking Hazard"", ""year"": 2016, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]",[],IMG_4501.jpeg;IMG_4502.jpeg,,
SKIP-BO,1269,Skip-Bo,1967,boardgame,auto,,,version_unknown,"[{""bgg_id"": 1269, ""name"": ""Skip-Bo"", ""year"": 1967, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]",[],IMG_4501.jpeg;IMG_4542.jpeg,,
EXPLODING KITTENS,172225,Exploding Kittens,2015,boardgame,auto,,,version_unknown,"[{""bgg_id"": 172225, ""name"": ""Exploding Kittens"", ""year"": 2015, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]",[],IMG_4501.jpeg;IMG_4505.jpeg,,
Wiz-War,589,Wiz-War,1983,boardgame,auto,,,version_ambiguous,"[{""bgg_id"": 589, ""name"": ""Wiz-War"", ""year"": 1983, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]","[{""version_id"": 188937, ""name"": ""First edition"", ""year"": 1985, ""publishers"": [""Jolly Games""], ""languages"": [""English""], ""score"": 1}, {""version_id"": 360421, ""name"": ""Second edition"", ""year"": 1985, ""publishers"": [""Jolly Games""], ""languages"": [""English""], ""score"": 1}, {""version_id"": 360420, ""name"": ""Third edition"", ""year"": 1986, ""publishers"": [""Jolly Games""], ""languages"": [""English""], ""score"": 1}, {""version_id"": 29214, ""name"": ""Fourth edition"", ""year"": 1987, ""publishers"": [""Jolly Games""], ""languages"": [""English""], ""score"": 1}, {""version_id"": 26114, ""name"": ""Fifth edition"", ""year"": 1991, ""publishers"": [""Chessex""], ""languages"": [""English""], ""score"": 1}, {""version_id"": 27352, ""name"": ""Chessex sixth edition"", ""year"": 1993, ""publishers"": [""Chessex""], ""languages"": [""English""], ""score"": 1}, {""version_id"": 28612, ""name"": ""Chessex classic edition 1997"", ""year"": 1997, ""publishers"": [""Chessex""], ""languages"": [""English""], ""score"": 1}]",IMG_4502.jpeg,,
Wiz-War,589,Wiz-War,1983,boardgame,auto,27352,Chessex sixth edition,version_approved,"[{""bgg_id"": 589, ""name"": ""Wiz-War"", ""year"": 1983, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]","[{""version_id"": 188937, ""name"": ""First edition"", ""year"": 1985, ""publishers"": [""Jolly Games""], ""languages"": [""English""], ""score"": 1}, {""version_id"": 360421, ""name"": ""Second edition"", ""year"": 1985, ""publishers"": [""Jolly Games""], ""languages"": [""English""], ""score"": 1}, {""version_id"": 360420, ""name"": ""Third edition"", ""year"": 1986, ""publishers"": [""Jolly Games""], ""languages"": [""English""], ""score"": 1}, {""version_id"": 29214, ""name"": ""Fourth edition"", ""year"": 1987, ""publishers"": [""Jolly Games""], ""languages"": [""English""], ""score"": 1}, {""version_id"": 26114, ""name"": ""Fifth edition"", ""year"": 1991, ""publishers"": [""Chessex""], ""languages"": [""English""], ""score"": 1}, {""version_id"": 27352, ""name"": ""Chessex sixth edition"", ""year"": 1993, ""publishers"": [""Chessex""], ""languages"": [""English""], ""score"": 1}, {""version_id"": 28612, ""name"": ""Chessex classic edition 1997"", ""year"": 1997, ""publishers"": [""Chessex""], ""languages"": [""English""], ""score"": 1}]",IMG_4502.jpeg,,
SCRAWL,202982,Scrawl,2016,boardgame,auto,,,version_unknown,"[{""bgg_id"": 202982, ""name"": ""Scrawl"", ""year"": 2016, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]",[],IMG_4502.jpeg,,
FLAT TOP,2529,Flat Top,1977,boardgame,auto,,,version_unknown,"[{""bgg_id"": 2529, ""name"": ""Flat Top"", ""year"": 1977, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]",[],IMG_4502.jpeg,,
Diplomacy,483,Diplomacy,1959,boardgame,auto,,,version_unknown,"[{""bgg_id"": 483, ""name"": ""Diplomacy"", ""year"": 1959, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]",[],IMG_4502.jpeg,,
1 title_raw bgg_id bgg_name year type match_status version_id version_name version_status candidates_json version_candidates_json source_photos merged_into dedupe_veto
17 Joking Hazard 193621 Joking Hazard 2016 boardgame auto version_unknown [{"bgg_id": 193621, "name": "Joking Hazard", "year": 2016, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": null, "rank": null}] [] IMG_4501.jpeg;IMG_4502.jpeg
18 SKIP-BO 1269 Skip-Bo 1967 boardgame auto version_unknown [{"bgg_id": 1269, "name": "Skip-Bo", "year": 1967, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": null, "rank": null}] [] IMG_4501.jpeg;IMG_4542.jpeg
19 EXPLODING KITTENS 172225 Exploding Kittens 2015 boardgame auto version_unknown [{"bgg_id": 172225, "name": "Exploding Kittens", "year": 2015, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": null, "rank": null}] [] IMG_4501.jpeg;IMG_4505.jpeg
20 Wiz-War 589 Wiz-War 1983 boardgame auto 27352 Chessex sixth edition version_ambiguous version_approved [{"bgg_id": 589, "name": "Wiz-War", "year": 1983, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": null, "rank": null}] [{"version_id": 188937, "name": "First edition", "year": 1985, "publishers": ["Jolly Games"], "languages": ["English"], "score": 1}, {"version_id": 360421, "name": "Second edition", "year": 1985, "publishers": ["Jolly Games"], "languages": ["English"], "score": 1}, {"version_id": 360420, "name": "Third edition", "year": 1986, "publishers": ["Jolly Games"], "languages": ["English"], "score": 1}, {"version_id": 29214, "name": "Fourth edition", "year": 1987, "publishers": ["Jolly Games"], "languages": ["English"], "score": 1}, {"version_id": 26114, "name": "Fifth edition", "year": 1991, "publishers": ["Chessex"], "languages": ["English"], "score": 1}, {"version_id": 27352, "name": "Chessex sixth edition", "year": 1993, "publishers": ["Chessex"], "languages": ["English"], "score": 1}, {"version_id": 28612, "name": "Chessex classic edition 1997", "year": 1997, "publishers": ["Chessex"], "languages": ["English"], "score": 1}] IMG_4502.jpeg
21 SCRAWL 202982 Scrawl 2016 boardgame auto version_unknown [{"bgg_id": 202982, "name": "Scrawl", "year": 2016, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": null, "rank": null}] [] IMG_4502.jpeg
22 FLAT TOP 2529 Flat Top 1977 boardgame auto version_unknown [{"bgg_id": 2529, "name": "Flat Top", "year": 1977, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": null, "rank": null}] [] IMG_4502.jpeg
23 Diplomacy 483 Diplomacy 1959 boardgame auto version_unknown [{"bgg_id": 483, "name": "Diplomacy", "year": 1959, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": null, "rank": null}] [] IMG_4502.jpeg
+5
View File
@@ -324,6 +324,11 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: #
.catalog a:hover { color: var(--accent-ink); }
.catalog td.actions { text-align: right; }
.catalog td.actions button { margin: .1rem 0 .1rem .3rem; white-space: nowrap; }
.catalog td.actions .ballotlink {
display: inline-block; margin: .1rem 0 .1rem .3rem; white-space: nowrap;
font-size: .78rem; color: var(--accent-ink);
text-decoration: underline dotted; text-underline-offset: 3px;
}
.catalog td.actions button { font-size: .78rem; padding: .2rem .6rem; box-shadow: none; }
.catalog td.actions button:hover { background: var(--board); }
.catalog tr.editrow td { background: #fff; border-top: none; padding: .2rem .5rem .7rem; }
+2 -2
View File
@@ -90,8 +90,8 @@ function render() {
split into copies</button>`
: ""}
${c.version_status === "version_ambiguous"
? `<a class="linkbtn ballot" href="/review#editions"
title="this copy's edition ballot is open">ballot in Review</a>`
? `<a class="ballotlink" href="/review#editions"
title="this copy's edition ballot is open">ballot in Review</a>`
: ""}
${c.bgg_id && ["auto", "approved"].includes(c.status)
&& ["version_unknown", "version_error", ""].includes(c.version_status || "")
+30 -13
View File
@@ -534,7 +534,6 @@ def create_app(
rows_by_title: dict[str, list[dict]] = {}
for r in session.rows:
rows_by_title.setdefault(r["title_raw"], []).append(r)
title_seen: Counter[str] = Counter()
catalog = []
def catalog_line(entry, row) -> dict:
@@ -579,24 +578,42 @@ def create_app(
"shaky": bool(entry and not row and entry.confidence != "high"),
}
for entry in session.titles:
# photo-overlap pairing first, positional fallback — the same rule
# as run_resolve. Positional-only broke the moment an edited copy's
# row was recreated at the END of matches.csv: same-title lines
# displayed each other's rows (buttons and ballots swapped owners)
claimed: set[int] = set()
def row_for(entry) -> dict | None:
same_title = rows_by_title.get(entry.title_raw, [])
ix = title_seen[entry.title_raw]
title_seen[entry.title_raw] += 1
# positional pairing: the ix-th entry of a title reports the
# ix-th row of that title (run_resolve pairs photo-overlap
# first, but split entries and split rows both keep sorted
# photo order, so the ordinals line up)
row = same_title[ix] if ix < len(same_title) else None
entry_photos = set(entry.source_photos)
def photos_of(r: dict) -> set[str]:
return {p for p in r["source_photos"].split(";") if p}
for match in (
lambda r: photos_of(r) == entry_photos,
lambda r: bool(photos_of(r) & entry_photos),
lambda r: True,
):
for r in same_title:
if id(r) not in claimed and match(r):
claimed.add(id(r))
return r
return None
for entry in session.titles:
row = row_for(entry)
# a split row's photo set is narrower than its entry's — show
# the row's own photos for split copies
catalog.append(
catalog_line(None if row and row.get("dedupe_veto") else entry, row)
)
# surplus rows beyond the entry count — split copies — are real
# physical games and get their own catalog lines
for title, rows_for_title in rows_by_title.items():
for row in rows_for_title[title_seen[title] :]:
# rows no entry claimed — split copies — are real physical games
# and get their own catalog lines
for rows_for_title in rows_by_title.values():
for row in rows_for_title:
if id(row) not in claimed:
catalog.append(catalog_line(None, row))
unresolved_count = sum(
1 for e in session.titles if e.title_raw not in resolved_titles
+41
View File
@@ -1202,3 +1202,44 @@ def test_version_cards_carry_their_photos(tmp_path):
(card,) = web.get("/api/state").json()["versions"]
assert card["title_raw"] == "Dungeon!"
assert card["photos"] == ["shelf.jpg"] # same-title copies stay tellable
def test_same_title_lines_pair_rows_by_photos_not_csv_order(tmp_path):
cfg = make_cfg(tmp_path)
titles = json.loads(cfg.titles_path.read_text())
titles += [
{"title_raw": "WIZ-WAR", "confidence": "high", "source_photos": ["a.jpg"]},
{"title_raw": "WIZ-WAR", "confidence": "high", "source_photos": ["b.jpg"]},
]
cfg.titles_path.write_text(json.dumps(titles))
rows = read_matches(cfg.matches_path)
# csv order REVERSED vs entry order: b's row first (an edit re-queue
# recreates a row at the file's end)
rows.append(
_row(
title_raw="WIZ-WAR",
match_status="auto",
bgg_id="589",
source_photos="b.jpg",
version_status="version_ambiguous",
version_candidates_json=VERSION_CANDIDATES,
)
)
rows.append(
_row(
title_raw="WIZ-WAR",
match_status="auto",
bgg_id="589",
source_photos="a.jpg",
version_status="version_unknown",
)
)
write_matches(cfg.matches_path, rows)
web = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
lines = {
tuple(c["photos"]): c
for c in web.get("/api/state").json()["catalog"]
if c["title_raw"] == "WIZ-WAR"
}
assert lines[("a.jpg",)]["version_status"] == "version_unknown"
assert lines[("b.jpg",)]["version_status"] == "version_ambiguous"