From 7306aa2ec4da3ea1c93fdd0ac1779916fa9edf24 Mon Sep 17 00:00:00 2001 From: wilfordgrimley <2397930+WilfordGrimley@users.noreply.github.com> Date: Tue, 14 Jul 2026 07:24:13 +0000 Subject: [PATCH] Add deductive printing-tag backfill: AI-weight votes for entailed printings Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_016i9S7LQsCL3FGaih3ZTRBJ --- MPCAutofill/cardpicker/deductive_backfill.py | 258 +++++++++++++++++ .../deductive_backfill_printing_tags.py | 75 +++++ .../tests/test_deductive_backfill.py | 266 ++++++++++++++++++ docs/features/printing-tags.md | 79 +++++- 4 files changed, 677 insertions(+), 1 deletion(-) create mode 100644 MPCAutofill/cardpicker/deductive_backfill.py create mode 100644 MPCAutofill/cardpicker/management/commands/deductive_backfill_printing_tags.py create mode 100644 MPCAutofill/cardpicker/tests/test_deductive_backfill.py diff --git a/MPCAutofill/cardpicker/deductive_backfill.py b/MPCAutofill/cardpicker/deductive_backfill.py new file mode 100644 index 000000000..804d0eee0 --- /dev/null +++ b/MPCAutofill/cardpicker/deductive_backfill.py @@ -0,0 +1,258 @@ +""" +Deductive printing-tag backfill: cast AI-weight `CardPrintingTag` votes for cards whose +printing is logically entailed by existing catalog data, in two confidence tiers. + +PRINCIPLE: a deduction is only valid conditional on the image actually being an authentic +depiction of the named card - this catalog contains custom art, so a deduction can never be +more than a vote. `VoteSource.AI` (weight `PRINTING_TAG_AI_WEIGHT`, default 0.5) plus the +hard "at least one human-backed vote" gate in `cardpicker.vote_consensus.resolve_weighted_consensus` +means these votes can NEVER resolve consensus by themselves, regardless of volume - a human +still has to confirm. See `docs/features/printing-tags.md`'s Stage 4 section for the full +design writeup (census methodology, Scryfall `printings_count` cross-verification). +""" + +import collections +import itertools +from dataclasses import dataclass, field +from typing import Iterable, Literal, Optional + +from django.db.models import QuerySet + +from cardpicker.models import ( + CanonicalCard, + Card, + CardPrintingTag, + PrintingTagStatus, + VoteSource, +) +from cardpicker.search.sanitisation import to_searchable + +DEDUCTIVE_BACKFILL_ANONYMOUS_ID = "deductive-backfill-v1" + +Tier = Literal["d1", "d2"] + +# D1 = name matches exactly one CanonicalCard, cross-verified against Scryfall's own +# `printings_count` (not just "our table happens to have one row" - see module docstring). +# D2 = name matches multiple CanonicalCard rows, but the card's own `expansion_hint` +# (parsed at upload time from a lone set-code bracket token in the source filename - +# `cardpicker/tags.py::Tags.extract()`) narrows it to exactly one. +CONFIDENCE_BY_TIER: dict[Tier, float] = {"d1": 0.95, "d2": 0.90} + + +@dataclass(frozen=True) +class DeductiveVote: + card_id: int + printing_id: int + tier: Tier + + @property + def confidence(self) -> float: + return CONFIDENCE_BY_TIER[self.tier] + + +class CanonicalNameIndex: + """ + In-memory index over every `CanonicalCard`, built once and reused across the whole scan - + `to_searchable` isn't a SQL function, so per-card exact-name and (name, expansion) lookups + have to happen in Python against a prebuilt structure rather than as a query per card + (which would be 113k+ queries per backfill run). + """ + + def __init__(self) -> None: + by_name: dict[str, list[tuple[int, int]]] = collections.defaultdict(list) + by_name_expansion: dict[tuple[str, str], list[tuple[int, int]]] = collections.defaultdict(list) + rows = CanonicalCard.objects.select_related("expansion", "printing_metadata").values_list( + "pk", "name", "expansion__code", "printing_metadata__printings_count" + ) + for pk, name, expansion_code, printings_count in rows: + normalised = to_searchable(name) + # printings_count can be null if a CanonicalCard predates the metadata import + # (`printing_metadata` is a nullable reverse OneToOne) - treat as "unverifiable", + # never as 1, so it can't slip through the D1 Scryfall cross-check by accident. + count = printings_count if printings_count is not None else -1 + by_name[normalised].append((pk, count)) + by_name_expansion[(normalised, expansion_code.lower())].append((pk, count)) + self._by_name = dict(by_name) + self._by_name_expansion = dict(by_name_expansion) + + def exact_matches(self, name: str) -> list[tuple[int, int]]: + return self._by_name.get(to_searchable(name), []) + + def exact_matches_in_expansion(self, name: str, expansion_code_lower: str) -> list[tuple[int, int]]: + return self._by_name_expansion.get((to_searchable(name), expansion_code_lower), []) + + +def _eligible_base_queryset() -> "QuerySet[Card]": + """ + Shared base pool for both tiers: unresolved, no confirmed indexing match, no vote of any + kind yet (not just no *deductive* vote - see docs/features/printing-tags.md's Stage 4 + section for why the exclusion is "any existing vote", not merely this cohort's own + anonymous_id: a card with a pre-existing human vote is exactly the case where adding an + AI-weight vote for the same outcome could increase an already-human-backed group's weight + across the resolution threshold - the hard "AI-only can never resolve" gate protects + AI-only cards, not cards where AI top-tops an existing human vote. Excluding them outright + removes the scenario rather than relying on the live post-write check to catch it). + + Also excludes anything that already tells us the PRINCIPLE's precondition (an authentic + depiction of the named card) doesn't hold: a card with the "Custom" tag already resolved + (`card.tags`, confirmed by the tag-vote consensus - this catalog deliberately allows + custom/fan art, and a deduction from the *name* alone is meaningless once we already know + the art isn't depicting a real printing) or a non-English card (`Card.language` - the whole + name-matching pipeline compares against `CanonicalCard.name`, which is Scryfall's English + oracle name; a coincidental text match against a foreign-language card's name isn't a + trustworthy signal about which specific printing it depicts). + """ + return ( + Card.objects.filter( + printing_tag_status=PrintingTagStatus.UNRESOLVED, + canonical_card__isnull=True, + printing_tags__isnull=True, + language__iexact="en", + ) + .exclude(tags__contains=["Custom"]) + .select_related("source") + ) + + +def select_d1_candidates(index: "CanonicalNameIndex | None" = None) -> Iterable[DeductiveVote]: + index = index or CanonicalNameIndex() + for card in _eligible_base_queryset().only("pk", "name", "source_id").iterator(chunk_size=5000): + matches = index.exact_matches(card.name) + if len(matches) == 1: + printing_pk, printings_count = matches[0] + if printings_count == 1: + yield DeductiveVote(card_id=card.pk, printing_id=printing_pk, tier="d1") + + +def select_d2_candidates(index: "CanonicalNameIndex | None" = None) -> Iterable[DeductiveVote]: + index = index or CanonicalNameIndex() + for card in _eligible_base_queryset().only("pk", "name", "expansion_hint", "source_id").iterator(chunk_size=5000): + if not card.expansion_hint: + continue + matches = index.exact_matches(card.name) + if len(matches) <= 1: + continue # D1's territory, or no match at all - not D2 + narrowed = index.exact_matches_in_expansion(card.name, card.expansion_hint) + if len(narrowed) == 1: + printing_pk, _printings_count = narrowed[0] + yield DeductiveVote(card_id=card.pk, printing_id=printing_pk, tier="d2") + + +def select_candidates(tier: Literal["d1", "d2", "all"]) -> Iterable[DeductiveVote]: + index = CanonicalNameIndex() + if tier in ("d1", "all"): + yield from select_d1_candidates(index) + if tier in ("d2", "all"): + yield from select_d2_candidates(index) + + +@dataclass +class BackfillResult: + d1_written: int = 0 + d2_written: int = 0 + dry_run: bool = False + gate_violations: list[int] = field(default_factory=list) + + @property + def total_written(self) -> int: + return self.d1_written + self.d2_written + + +def verify_zero_resolutions(card_ids: list[int], batch_size: int = 5000) -> list[int]: + """ + The live gate check: re-fetches each just-voted card fresh from the DB (picking up the + vote(s) just written) and runs the *pure* `resolve_printing` (never `resolve_and_persist_printing` + - this must never itself cause a write, including under the failure case this exists to + catch) to confirm the new AI-only vote didn't tip any card into a resolved outcome. Returns + the card pks that violated the gate - empty on success. Structurally this should always be + empty (see module docstring: AI-only groups can never satisfy `resolve_weighted_consensus`'s + human-backed gate, and `_eligible_base_queryset` excludes every card with a pre-existing + vote of any kind), but "should structurally never happen" is exactly what an operational + gate exists to verify against the real data rather than trust. + """ + from cardpicker.printing_consensus import resolve_printing + + violations: list[int] = [] + for i in range(0, len(card_ids), batch_size): + chunk = card_ids[i : i + batch_size] + for card in Card.objects.filter(pk__in=chunk).iterator(chunk_size=batch_size): + if resolve_printing(card) is not None: + violations.append(card.pk) + return violations + + +def run_backfill( + tier: Literal["d1", "d2", "all"], + limit: Optional[int] = None, + dry_run: bool = False, + batch_size: int = 2000, + progress_every: int = 20000, +) -> BackfillResult: + """ + Selects candidates for `tier`, writes them in `batch_size` chunks (so an interrupted run + keeps whatever it already committed rather than losing all progress - `_eligible_base_queryset` + excludes any card with an existing vote, so simply re-running the command later picks up + exactly where it left off with no separate checkpoint file needed), then - unless `dry_run` + - runs the live gate check over every card just written to. + """ + votes: Iterable[DeductiveVote] = select_candidates(tier) + if limit is not None: + votes = itertools.islice(votes, limit) + + result = BackfillResult(dry_run=dry_run) + written_card_ids: list[int] = [] + batch: list[DeductiveVote] = [] + seen = 0 + + def flush(pending: list[DeductiveVote]) -> None: + if not pending: + return + if not dry_run: + CardPrintingTag.objects.bulk_create( + [ + CardPrintingTag( + card_id=vote.card_id, + printing_id=vote.printing_id, + is_no_match=False, + anonymous_id=DEDUCTIVE_BACKFILL_ANONYMOUS_ID, + source=VoteSource.AI, + confidence=vote.confidence, + ) + for vote in pending + ] + ) + for vote in pending: + if vote.tier == "d1": + result.d1_written += 1 + else: + result.d2_written += 1 + written_card_ids.append(vote.card_id) + + for vote in votes: + batch.append(vote) + seen += 1 + if len(batch) >= batch_size: + flush(batch) + batch = [] + if seen % progress_every == 0: + print(f" ... {seen} candidates processed") + flush(batch) + + if not dry_run and written_card_ids: + result.gate_violations = verify_zero_resolutions(written_card_ids) + + return result + + +__all__ = [ + "DEDUCTIVE_BACKFILL_ANONYMOUS_ID", + "CONFIDENCE_BY_TIER", + "DeductiveVote", + "CanonicalNameIndex", + "BackfillResult", + "select_d1_candidates", + "select_d2_candidates", + "select_candidates", + "verify_zero_resolutions", + "run_backfill", +] diff --git a/MPCAutofill/cardpicker/management/commands/deductive_backfill_printing_tags.py b/MPCAutofill/cardpicker/management/commands/deductive_backfill_printing_tags.py new file mode 100644 index 000000000..c9ea47c71 --- /dev/null +++ b/MPCAutofill/cardpicker/management/commands/deductive_backfill_printing_tags.py @@ -0,0 +1,75 @@ +from typing import Any + +from django.core.management.base import BaseCommand, CommandError + +from cardpicker.deductive_backfill import run_backfill + + +class Command(BaseCommand): + help = ( + "Casts AI-weight (source=ai) CardPrintingTag votes for cards whose printing is " + "logically entailed by existing catalog data (see cardpicker/deductive_backfill.py " + "and docs/features/printing-tags.md). These are suggestions, never resolutions - the " + "human-backed gate in vote_consensus.resolve_weighted_consensus means AI-only votes " + "can never resolve a card by themselves. Idempotent: a card that already has any " + "printing_tags vote (from this command or otherwise) is never revisited, so an " + "interrupted run can simply be re-invoked to resume." + ) + + def add_arguments(self, parser: Any) -> None: + parser.add_argument( + "--tier", + choices=["d1", "d2", "all"], + default="all", + help="Which confidence tier to backfill. d1=0.95 (unique name match), " + "d2=0.90 (name + expansion_hint narrows to one printing). Default: all " + "(d1 fully, then d2).", + ) + parser.add_argument( + "--limit", + type=int, + default=None, + help="Cap the number of votes written in this invocation. Useful for staged " + "rollout or a quick --dry-run sample.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + default=False, + help="Select and count candidates without writing anything or running the gate check.", + ) + parser.add_argument( + "--batch-size", + type=int, + default=2000, + help="CardPrintingTag rows per bulk_create batch. Default: 2000.", + ) + + def handle(self, *args: Any, **kwargs: Any) -> None: + tier = kwargs["tier"] + limit = kwargs["limit"] + dry_run = kwargs["dry_run"] + batch_size = kwargs["batch_size"] + + mode = "DRY RUN" if dry_run else "WRITE" + print(f"[{mode}] deductive_backfill_printing_tags --tier={tier} --limit={limit} --batch-size={batch_size}") + + result = run_backfill(tier=tier, limit=limit, dry_run=dry_run, batch_size=batch_size) + + print(f"D1 votes: {result.d1_written}") + print(f"D2 votes: {result.d2_written}") + print(f"Total: {result.total_written}") + + if dry_run: + print("Dry run - nothing written, gate check not run.") + return + + if result.gate_violations: + raise CommandError( + f"GATE VIOLATION: {len(result.gate_violations)} card(s) resolved after an AI-only " + f"vote, which should be structurally impossible - STOP and investigate before " + f"continuing this backfill. Affected card pks: {result.gate_violations[:50]}" + + (" (truncated)" if len(result.gate_violations) > 50 else "") + ) + + print(f"Gate check passed: 0/{result.total_written} affected cards resolved.") diff --git a/MPCAutofill/cardpicker/tests/test_deductive_backfill.py b/MPCAutofill/cardpicker/tests/test_deductive_backfill.py new file mode 100644 index 000000000..240d326e0 --- /dev/null +++ b/MPCAutofill/cardpicker/tests/test_deductive_backfill.py @@ -0,0 +1,266 @@ +import pytest + +from cardpicker.deductive_backfill import ( + DEDUCTIVE_BACKFILL_ANONYMOUS_ID, + run_backfill, + select_d1_candidates, + select_d2_candidates, + verify_zero_resolutions, +) +from cardpicker.models import PrintingTagStatus, VoteSource +from cardpicker.printing_consensus import resolve_printing +from cardpicker.tests.factories import ( + CanonicalArtistFactory, + CanonicalCardFactory, + CanonicalExpansionFactory, + CanonicalPrintingMetadataFactory, + CardFactory, + CardPrintingTagFactory, + SourceFactory, +) + +# `factory.Sequence` counters are process-global - see test_printing_consensus.py's identical +# fixture for the full rationale. Mirrored here since this module uses the same shared factories +# - including SourceFactory/CanonicalArtistFactory, consumed indirectly via CardFactory.source +# and CanonicalCardFactory.artist SubFactories, not just the ones referenced by name above. +_SHARED_FACTORIES = [ + CardFactory, + SourceFactory, + CanonicalArtistFactory, + CanonicalExpansionFactory, + CanonicalCardFactory, +] + + +@pytest.fixture(autouse=True) +def _preserve_shared_factory_sequences(): + before = {f: f._meta.next_sequence() for f in _SHARED_FACTORIES} + for f, n in before.items(): + f.reset_sequence(n, force=True) + yield + for f, n in before.items(): + f.reset_sequence(n, force=True) + + +def _unique_printing(name: str, printings_count: int = 1, **kwargs) -> "CanonicalCardFactory": + printing = CanonicalCardFactory(name=name, **kwargs) + CanonicalPrintingMetadataFactory(canonical_card=printing, printings_count=printings_count) + return printing + + +class TestD1Selection: + def test_unique_name_match_is_d1(self, db): + printing = _unique_printing("Plumecreed Mentor") + card = CardFactory(name="Plumecreed Mentor") + votes = list(select_d1_candidates()) + assert len(votes) == 1 + assert votes[0].card_id == card.pk + assert votes[0].printing_id == printing.pk + assert votes[0].tier == "d1" + + def test_parenthetical_suffix_is_stripped_by_normalization(self, db): + # mirrors real corpus data: many cards carry an "(Style Artist Name)" suffix that + # to_searchable strips (bracketed content removal) but CanonicalCard.name never has. + printing = _unique_printing("Kusari-Gama") + card = CardFactory(name="Kusari-Gama (Modern Tomas Giorello)") + votes = list(select_d1_candidates()) + assert len(votes) == 1 + assert votes[0].card_id == card.pk + assert votes[0].printing_id == printing.pk + + def test_mid_string_the_is_preserved_post_460(self, db): + # if to_searchable still stripped mid-string "the" (the pre-#460 bug), both of these + # would normalize to the same string and the match would be ambiguous (2 matches, not + # D1) instead of each resolving independently. + printing_with_the = _unique_printing("Adanto, the First Fort") + _unique_printing("Adanto First Fort") # deliberately similar but distinct name + card = CardFactory(name="Adanto, the First Fort") + votes = list(select_d1_candidates()) + assert len(votes) == 1 + assert votes[0].card_id == card.pk + assert votes[0].printing_id == printing_with_the.pk + + def test_ambiguous_name_is_not_d1(self, db): + _unique_printing("Forest", expansion=CanonicalExpansionFactory(code="ust")) + _unique_printing("Forest", expansion=CanonicalExpansionFactory(code="csp")) + CardFactory(name="Forest") + assert list(select_d1_candidates()) == [] + + def test_printings_count_greater_than_one_excludes_from_d1(self, db): + # our table has exactly one CanonicalCard row for this name, but Scryfall's own + # printings_count says there are more real printings we haven't imported yet - the + # whole point of the cross-check is to not treat this as verified-unique. + _unique_printing("Gilded Drake", printings_count=2) + CardFactory(name="Gilded Drake") + assert list(select_d1_candidates()) == [] + + def test_missing_printing_metadata_is_treated_as_unverifiable(self, db): + # a CanonicalCard with no CanonicalPrintingMetadata sidecar at all (predates that + # import) must never be silently treated as printings_count == 1. + CanonicalCardFactory(name="No Metadata Card") + CardFactory(name="No Metadata Card") + assert list(select_d1_candidates()) == [] + + def test_resolved_card_is_excluded(self, db): + _unique_printing("Already Resolved") + card = CardFactory(name="Already Resolved") + card.printing_tag_status = PrintingTagStatus.RESOLVED + card.inferred_canonical_card = CanonicalCardFactory() + card.save() + assert list(select_d1_candidates()) == [] + + def test_card_with_confirmed_canonical_card_is_excluded(self, db): + printing = _unique_printing("Already Tagged") + CardFactory(name="Already Tagged", canonical_card=printing) + assert list(select_d1_candidates()) == [] + + def test_card_with_any_existing_vote_is_excluded(self, db): + # not just an existing deductive-backfill vote - ANY existing vote, since that's + # exactly the scenario where an added AI vote could tip an already-human-backed + # group over the resolution threshold (see deductive_backfill.py's docstring). + _unique_printing("Has A Vote Already") + card = CardFactory(name="Has A Vote Already") + CardPrintingTagFactory(card=card, printing=CanonicalCardFactory(), source=VoteSource.USER) + assert list(select_d1_candidates()) == [] + + def test_card_with_existing_deductive_vote_is_excluded(self, db): + _unique_printing("Already Backfilled") + card = CardFactory(name="Already Backfilled") + CardPrintingTagFactory( + card=card, + printing=CanonicalCardFactory(), + source=VoteSource.AI, + anonymous_id=DEDUCTIVE_BACKFILL_ANONYMOUS_ID, + ) + assert list(select_d1_candidates()) == [] + + def test_card_with_resolved_custom_tag_is_excluded(self, db): + # the catalog deliberately allows custom/fan art - once tag-vote consensus has + # already confirmed "Custom", a name-based printing deduction is meaningless. + _unique_printing("Custom Art Card") + CardFactory(name="Custom Art Card", tags=["Custom"]) + assert list(select_d1_candidates()) == [] + + def test_non_english_card_is_excluded(self, db): + # name-matching compares against CanonicalCard.name (Scryfall's English oracle name); + # a foreign-language card's name isn't a trustworthy signal for it. + _unique_printing("Foreign Language Card") + CardFactory(name="Foreign Language Card", language="FR") + assert list(select_d1_candidates()) == [] + + +class TestD2Selection: + def test_expansion_hint_narrows_ambiguous_name_to_one(self, db): + _unique_printing("Snow-Covered Forest", expansion=CanonicalExpansionFactory(code="csp")) + matching = _unique_printing("Snow-Covered Forest", expansion=CanonicalExpansionFactory(code="wwk")) + card = CardFactory(name="Snow-Covered Forest", expansion_hint="wwk") + votes = list(select_d2_candidates()) + assert len(votes) == 1 + assert votes[0].card_id == card.pk + assert votes[0].printing_id == matching.pk + assert votes[0].tier == "d2" + + def test_no_expansion_hint_is_not_d2(self, db): + _unique_printing("No Hint Card", expansion=CanonicalExpansionFactory(code="csp")) + _unique_printing("No Hint Card", expansion=CanonicalExpansionFactory(code="wwk")) + CardFactory(name="No Hint Card", expansion_hint="") + assert list(select_d2_candidates()) == [] + + def test_hint_that_still_does_not_narrow_to_one_is_excluded(self, db): + # hint present, but that (name, expansion) pair matches zero printings (stale/wrong + # hint) - must not guess. + _unique_printing("Wrong Hint Card", expansion=CanonicalExpansionFactory(code="csp")) + CardFactory(name="Wrong Hint Card", expansion_hint="wwk") + assert list(select_d2_candidates()) == [] + + def test_unambiguous_name_is_not_d2(self, db): + # D1's territory - a name matching exactly one printing is never D2, hint or not. + _unique_printing("Solo Printing", expansion=CanonicalExpansionFactory(code="csp")) + CardFactory(name="Solo Printing", expansion_hint="csp") + assert list(select_d2_candidates()) == [] + + +class TestRunBackfillWriteShape: + def test_d1_vote_row_shape(self, db): + printing = _unique_printing("Shape Test D1") + card = CardFactory(name="Shape Test D1") + result = run_backfill(tier="d1") + assert result.d1_written == 1 + assert result.d2_written == 0 + assert result.gate_violations == [] + + vote = card.printing_tags.get() + assert vote.printing_id == printing.pk + assert vote.is_no_match is False + assert vote.anonymous_id == DEDUCTIVE_BACKFILL_ANONYMOUS_ID + assert vote.source == VoteSource.AI + assert vote.confidence == 0.95 + + def test_d2_vote_row_shape(self, db): + matching = _unique_printing("Shape Test D2", expansion=CanonicalExpansionFactory(code="csp")) + _unique_printing("Shape Test D2", expansion=CanonicalExpansionFactory(code="wwk")) + card = CardFactory(name="Shape Test D2", expansion_hint="csp") + result = run_backfill(tier="d2") + assert result.d2_written == 1 + + vote = card.printing_tags.get() + assert vote.printing_id == matching.pk + assert vote.source == VoteSource.AI + assert vote.confidence == 0.90 + + def test_dry_run_writes_nothing(self, db): + _unique_printing("Dry Run Card") + card = CardFactory(name="Dry Run Card") + result = run_backfill(tier="d1", dry_run=True) + assert result.d1_written == 1 # counted, but not persisted + assert result.gate_violations == [] + assert card.printing_tags.count() == 0 + + def test_limit_caps_total_written(self, db): + # distinct alphabetic suffixes, not digits - to_searchable strips all digits, so + # "Limit Card 0"/"Limit Card 1" would collide into the same normalized name and + # make every one of them ambiguous (not D1) rather than exercising the --limit path. + for suffix in ["Alpha", "Bravo", "Charlie", "Delta", "Echo"]: + _unique_printing(f"Limit Card {suffix}") + CardFactory(name=f"Limit Card {suffix}") + result = run_backfill(tier="d1", limit=2) + assert result.total_written == 2 + + def test_idempotent_on_rerun(self, db): + _unique_printing("Idempotence Card") + card = CardFactory(name="Idempotence Card") + + first = run_backfill(tier="d1") + assert first.d1_written == 1 + assert card.printing_tags.count() == 1 + + second = run_backfill(tier="d1") + assert second.d1_written == 0 + assert card.printing_tags.count() == 1 # no duplicate vote + + +class TestZeroResolutionsGate: + def test_backfill_never_resolves_an_ai_only_card(self, db): + _unique_printing("Gate Test Card") + card = CardFactory(name="Gate Test Card") + result = run_backfill(tier="d1") + assert result.gate_violations == [] + card.refresh_from_db() + assert card.printing_tag_status == PrintingTagStatus.UNRESOLVED + assert resolve_printing(card) is None + + def test_verify_zero_resolutions_detects_a_real_violation(self, db): + # constructs the scenario _eligible_base_queryset is designed to prevent from ever + # reaching run_backfill - a card with a pre-existing human vote, plus (bypassing + # selection entirely) a same-outcome AI vote added directly - to prove the detector + # itself actually catches a resolved card rather than trivially always passing. + printing = CanonicalCardFactory() + card = CardFactory() + CardPrintingTagFactory(card=card, printing=printing, source=VoteSource.USER) + CardPrintingTagFactory(card=card, printing=printing, source=VoteSource.USER) + # two USER votes alone already clear consensus here - assert the fixture itself + # actually resolves before layering the AI vote on top, so the test is meaningful. + assert resolve_printing(card) == printing + + violations = verify_zero_resolutions([card.pk]) + assert violations == [card.pk] diff --git a/docs/features/printing-tags.md b/docs/features/printing-tags.md index 82c9d17c7..75a4c9d9c 100644 --- a/docs/features/printing-tags.md +++ b/docs/features/printing-tags.md @@ -248,6 +248,81 @@ field (`inferred_canonical_artist`/`artist_vote_status` only, serialise-time-only) — confirmed against `documents.py`'s field list, no hook needed. +## Stage 4: deductive backfill (AI-weight votes for logically-entailed printings) + +Casts `source=ai` `CardPrintingTag` votes (`cardpicker/deductive_backfill.py`, +management command `deductive_backfill_printing_tags`) for cards whose +printing is entailed by data already in the catalog, rather than waiting +for a human to vote from scratch on every one of the ~207k untagged +cards. **PRINCIPLE**: a deduction is only valid conditional on the image +actually being an authentic depiction of the named card - this catalog +allows custom art - so a deduction is always a _vote_ +(`PRINTING_TAG_AI_WEIGHT`, default 0.5), never a direct +`printing_tag_status`/`inferred_canonical_card` write. The hard +"at least one human-backed vote" gate in +`vote_consensus.resolve_weighted_consensus` means an AI-only vote can +never resolve a card by itself, at any volume - a human still confirms. + +**Two confidence tiers**, both keyed on `to_searchable`-normalized name +(the same normalizer `printing_candidates.py`'s queue lookup uses, +post-#460 - no mid-string "the" stripping): + +- **D1** (confidence 0.95): the name matches exactly one `CanonicalCard` + row. Cross-verified against Scryfall's own `printings_count` + (`CanonicalPrintingMetadata`, not derived from our import) so "exactly + one row in our table" can't be mistaken for "Scryfall says this card + only has one printing" when the two disagree - a card is only D1 if + both agree. A `CanonicalCard` with no `CanonicalPrintingMetadata` + sidecar at all is treated as unverifiable, never as count-1. +- **D2** (confidence 0.90): the name matches more than one `CanonicalCard` + row, but `Card.expansion_hint` (already parsed at upload time from a + lone set-code bracket token in the source filename - + `cardpicker/tags.py::Tags.extract()`, no new parsing built for this) + narrows `(name, expansion)` to exactly one row. + +**Eligibility, beyond the two tiers above**: `printing_tag_status == UNRESOLVED`, no `canonical_card` (a confirmed ingestion-time match already +settles it), **no existing vote of any kind** - not just no prior +deductive vote. A card with a pre-existing human vote is exactly the +scenario where adding a same-outcome AI vote could push an _already_ +human-backed group's weight over the resolution threshold; excluding +these outright removes the scenario rather than relying on the live gate +check below to catch it. Also excludes a card with the `"Custom"` tag +already resolved (`Card.tags` - the PRINCIPLE's precondition is already +known false) and a non-English card (`Card.language` - name-matching +compares against Scryfall's English oracle name, so a coincidental match +against a foreign-language name isn't trustworthy). + +**Idempotent / resumable**: the "no existing vote" exclusion above is +also the checkpoint mechanism - an interrupted run leaves whatever it +already committed, and simply re-invoking the command later picks up +exactly where it left off with no separate checkpoint file. `--limit` +caps a single invocation; `--dry-run` selects and counts without writing. + +**Live gate check**: after writing (unless `--dry-run`), every affected +card is re-fetched fresh and run through the _pure_ `resolve_printing` +(never `resolve_and_persist_printing` - the check itself must never be +able to cause a write) to confirm none of them actually resolved. Should +be structurally impossible per the paragraph above; verified live against +the real data anyway rather than only trusted in theory. Any violation +raises `CommandError` and stops rather than continuing past it. + +**Census** (2026-07-14, `printing_tag_status=UNRESOLVED`, +`canonical_card` null pool of 207,123 / 218,128 total cards): D1 = +26,962, D2 = 1,202 after the Custom-tag/non-English exclusions (27,424 / +1,204 before them). Every D1 candidate's Scryfall `printings_count` +cross-check passed (0 false positives out of 27,424). D2's `(name, expansion)` narrowing occasionally collides across distinct oracle +objects sharing a display name (generic tokens - Treasure, Zombie, Beast, +etc. - and one real card, Llanowar Elves, colliding with an unrelated +same-named token in a token-only set); doesn't affect any individual +vote's correctness since each vote's own `(name, expansion_hint)` pair is +independently verified to narrow to one row. + +**Out of scope for this stage**: vision/AI image classification calls +(this is pure logical deduction from existing structured data, zero new +dependencies), `is_no_match` votes, fuzzy/lower-confidence signals beyond +D1/D2, a "suggested" badge in the queue UI, and artist/tag deduction +(printing only). + ## Key files - Backend: `cardpicker/printing_consensus.py`, @@ -256,7 +331,9 @@ hook needed. `0050_canonicalprintingmetadata_cardprintingtag_and_more.py`), `cardpicker/search/search_functions.py` (Stage 3 re-rank/filter), `cardpicker/documents.py` (Stage 3 widened indexing; Stage 3.5 - `reindex_card_safely`), `cardpicker/tag_consensus.py` (Stage 3.5) + `reindex_card_safely`), `cardpicker/tag_consensus.py` (Stage 3.5), + `cardpicker/deductive_backfill.py` + management command + `deductive_backfill_printing_tags` (Stage 4) - Frontend: `frontend/src/features/printingTags/` (`PrintingTagQueue.tsx`, `PrintingTagPicker.tsx`, `starburstShape.ts`, `useStickyTop`), `frontend/src/features/filters/ResolvedAttributeFilter.tsx` (Stage 3),