diff --git a/MPCAutofill/cardpicker/local_fallback.py b/MPCAutofill/cardpicker/local_fallback.py index 77e57a6b7..e572da754 100644 --- a/MPCAutofill/cardpicker/local_fallback.py +++ b/MPCAutofill/cardpicker/local_fallback.py @@ -13,6 +13,21 @@ produce a reading narrows to EXACTLY ONE candidate, with the usual clear-margin rule inside the sub-checks that need one (symbol matching). Distinct anonymous_id 'local-fallback-v1'. +THIS MODULE NO LONGER CASTS A PRINTING VOTE (owner ruling 2026-07-29, redundancy doctrine). +`local_identify_printing_tags` stopped calling `run_fallback_for_card` for a pass-2 +`CardPrintingTag` vote under 'local-fallback-v1': `local_calculate_verdicts. +calculate_fallback_verdict` ('stage-d-fallback-v1') applies the SAME decision model to the SAME +three readings out of stored `ImageEvidence`, and measurement over all 179,176 printing-vote +rows found the two agreeing on 11,825/11,825 overlapping cards with zero conflicts - one witness +counted twice. See `local_identify_printing_tags`' own module docstring for the full ruling. +What that retirement did NOT touch, and what keeps this module live: every pure decision helper +below is called directly by the Stage D calculator that survived; `cast_border_attribute_vote`/ +`cast_frame_style_vote`/`cast_bleed_edge_vote` still cast their `CardTagVote` attribute chips +under 'local-fallback-v1' (Stage D has no analogue, and they were never part of the printing- +vote measurement); and `run_fallback_for_card` itself is still called by +`local_residual_classify.recover_frame_mismatch_printing_via_fallback_refetch` to recompute a +printing for HISTORICAL frame-mismatch scan-log rows, which casts under its own identity. + Border-color sampling (2c) does double duty: besides filtering candidates here, it separately casts a standalone CardTagVote on the matching border attribute chip tag (Black Border/White Border/Silver Border/Borderless - see attribute_tags.py) for EVERY card either pass processes, diff --git a/MPCAutofill/cardpicker/local_identify_printing_tags.py b/MPCAutofill/cardpicker/local_identify_printing_tags.py index 04eba5823..67acf5150 100644 --- a/MPCAutofill/cardpicker/local_identify_printing_tags.py +++ b/MPCAutofill/cardpicker/local_identify_printing_tags.py @@ -14,6 +14,37 @@ also revisits single-candidate names deductive backfill's own Scryfall printings_count cross-check rejected, since those are still unresolved despite one local match. +PASS-2 FALLBACK PRINTING VOTES ARE RETIRED (owner ruling 2026-07-29, redundancy doctrine - +"anything made redundant is retired", and the test is the EVIDENCE SOURCE, not the vote cast). +This module used to cast a THIRD printing-vote identity, `local_fallback.FALLBACK_ANONYMOUS_ID` +("local-fallback-v1"), from a pass-2 border/artist/symbol evidence combination run over the +cards pass 1 missed. `local_calculate_verdicts.calculate_fallback_verdict` +("stage-d-fallback-v1") is a faithful port of that SAME decision model reading the SAME three +readings out of stored `ImageEvidence` instead of a freshly-fetched image - it calls +`local_fallback.filter_by_border_color`/`match_artist` directly, and duplicates +`find_symbol_matches`' arithmetic against the same PROTECTED CORE thresholds. Two calculators +over one evidence source are one witness counted twice, which inflates apparent corroboration in +`vote_consensus.resolve_weighted_consensus`' weighted quorum without adding information. +Measured over all 179,176 `CardPrintingTag` rows (2026-07-29): the two overlap on 11,825 cards +and agree on 11,825 of them - 100.0%, zero conflicts, zero one-sided abstentions. Stage D's +identity is the one kept (it reads persisted evidence, so it re-runs with no CDN fetch at all); +this module's pass-2 printing channel is gone. Rows already cast under "local-fallback-v1" are +LEFT IN PLACE - history is kept, only future casting stops; removing them is a separate, +owner-authorized `purge_machine_votes` step. + +WHAT IS *NOT* RETIRED WITH IT, and must not be swept up by a future "just stop running the +fallback": `local_fallback.py` is also the home of three ATTRIBUTE-chip vote channels that vote +under the very same `local-fallback-v1` identity but on `CardTagVote`, never `CardPrintingTag` - +`cast_border_attribute_vote` (Black/White/Silver/Borderless), `cast_frame_style_vote` (Old/ +Modern Border) and `cast_bleed_edge_vote` (the `appropriate-bleed` SENSITIVE tag). Stage D has +no analogue for any of the three, none of them was part of the redundancy measurement (which +counted printing votes only), and all three are still cast below - as is `image_evidence.py`'s +own separate `cast_border_attribute_vote` call under that same identity. Likewise +`local-ocr-v1`/`local-phash-v1` share this module's single management command with the retired +pass (there is no `--engine` value for the fallback: it rode along on whatever pass 1 selected), +so the ruling had to be a code change here, not "stop running the command" - that would have +silently dropped two calculators that are kept. + FILENAME-STYLE DUPLICATE-UPLOAD SUFFIX NORMALIZATION (2026-07-23, `CandidateNameIndex. candidates_for` - live-proven defect, card_id 7173 "Plaguecrafter (1)"): a source folder's own auto-dedup naming (two files sharing a name - Google Drive/local-filesystem convention) or the @@ -50,11 +81,7 @@ local_ocr, local_phash, ) -from cardpicker.local_fallback import ( - FALLBACK_ANONYMOUS_ID, - FALLBACK_CONFIDENCE_MULTI_EVIDENCE, - FALLBACK_CONFIDENCE_SINGLE_EVIDENCE, -) +from cardpicker.local_fallback import FALLBACK_ANONYMOUS_ID from cardpicker.models import ( CanonicalCard, Card, @@ -64,6 +91,7 @@ CardTypes, PrintingTagStatus, VoteSource, + calculator_family, ) from cardpicker.search.sanitisation import strip_bracketed_groups, to_searchable from cardpicker.vote_write import purge_and_write_votes @@ -89,12 +117,25 @@ # candidate that does exist but wasn't captured correctly, whereas a real match's exact-string # agreement is stronger positive confirmation. OCR_NO_MATCH_CONFIDENCE = 0.6 -# issue #207: same rationale as OCR_NO_MATCH_CONFIDENCE, for fallback's "eliminated" outcome -# (every sub-check that produced a reading agrees no candidate fits) - below -# FALLBACK_CONFIDENCE_SINGLE_EVIDENCE/MULTI_EVIDENCE (0.7/0.8, see local_fallback.py) for the -# same reason: a negative conclusion from noisy sub-checks is weaker evidence than a positive -# one landing on a single specific candidate. -FALLBACK_NO_MATCH_CONFIDENCE = 0.6 +# RETIRED 2026-07-29 (module docstring's pass-2 section): the calculator FAMILIES that must +# never appear on a `CardPrintingTag` row this module writes again. Keyed on the versionless +# FAMILY, never on the literal id, for exactly the reason `vote_consensus. +# DEDUCTIVE_BACKFILL_FAMILY`'s own comment gives: a machine calculator's version lives INSIDE +# its `anonymous_id`, so an exact-string check would let an ordinary redeploy to +# "local-fallback-v2" silently un-retire a ratified ruling with no error and no failing test. +# DERIVED via `models.calculator_family`, not written out as a second literal, so the two can +# never drift; the assert makes a rename that took the id outside the machine naming convention +# fail loudly at import time rather than quietly disable the guard in `flush` below. +# +# This is scoped to PRINTING votes only. The same identity still legitimately writes +# `CardTagVote` rows (border/frame/bleed attribute chips - see the module docstring), and +# `purge_stale_machine_votes` still purges the family's HISTORICAL printing rows if a future +# calculator in this family is ever reinstated - retiring the caster does not rewrite history. +RETIRED_PRINTING_VOTE_FAMILIES = frozenset({calculator_family(FALLBACK_ANONYMOUS_ID)}) +assert None not in RETIRED_PRINTING_VOTE_FAMILIES, ( + "every retired printing-vote identity must follow the machine calculator naming convention " + "(-v): the 2026-07-29 redundancy retirement is enforced by family, not by literal." +) # Basic lands and staple commons can carry hundreds of printings (Forest alone: 944 in the # live pilot's own eligible pool, confirmed live 2026-07-15) - and "multi-candidate names # first" ordering puts exactly those names first, meaning an uncapped pilot run would try to @@ -655,9 +696,9 @@ class CardOutcome: phash_vote: Optional[EngineVote] = None phash_skip_reason: str = "" disagreement: bool = False - fallback_vote: Optional[EngineVote] = None - fallback_skip_reason: str = "" - fallback_evidence_types: list[str] = field(default_factory=list) + # `fallback_vote`/`fallback_skip_reason`/`fallback_evidence_types` REMOVED 2026-07-29 with the + # pass-2 printing channel itself (module docstring) - nothing computes a fallback printing + # verdict here any more, so there is no per-card fallback state left to carry. border_color: Optional[str] = None frame_reading_attempted: bool = False frame_class: Optional[str] = None @@ -907,7 +948,7 @@ def _narrow_candidates_by_expansion_hint(selected: SelectedCard) -> SelectedCard Scoped to engine-matching ONLY - never call this from select_candidates/ compute_covered_printing_pks/anything computing coverage or ordering, which need the true, unnarrowed candidate set to stay correct. The returned SelectedCard is a LOCAL substitute - used only for this card's own OCR/phash/fallback calls within _compute_card; nothing + used only for this card's own OCR/phash calls within _compute_card; nothing downstream (run_pilot's own all_selected_by_card_id) ever sees the narrowed version. Real yield (measured live, 2026-07-15): of 2,466 pilot-eligible cards with a real @@ -929,7 +970,6 @@ def _compute_card( selected: SelectedCard, ocr_selected_ids: set[int], phash_selected_ids: set[int], - already_fallback_covered: set[int], ocr_crop_box: tuple[float, float, float, float], phash_distance_threshold: int, phash_margin: int, @@ -938,8 +978,8 @@ def _compute_card( known_set_codes: Optional[frozenset[str]] = None, ) -> CardComputeResult: """The parallelizable half of a card's work (pre-scale program item 3d): fetch + every - read-only heuristic reading (OCR, phash, border/frame/bleed classification, pass-2 - fallback) - no DB writes, no shared/nonlocal state, safe to run concurrently across cards + read-only heuristic reading (OCR, phash, border/frame/bleed classification) - no DB + writes, no shared/nonlocal state, safe to run concurrently across cards via ThreadPoolExecutor.map() (see run_pilot's chunked loop). Deliberately does NOT include the ground-truth-preferred attribute override or the frame-mismatch consistency check - both of those are tightly coupled to the write/consensus decision (which candidate_vote @@ -1001,23 +1041,15 @@ def _compute_card( outcome.frame_reading_attempted = True outcome.frame_class = local_fallback.classify_frame_style(parsed_a_collector_number, illus_anchor_fired) - pass_1_accepted = (outcome.ocr_vote is not None or outcome.phash_vote is not None) and not outcome.disagreement - if not pass_1_accepted and card_id not in already_fallback_covered and image is not None: - fallback_outcome = local_fallback.run_fallback_for_card(selected, image, ocr_raw_texts, bleed_class) - outcome.fallback_skip_reason = fallback_outcome.skip_reason - outcome.fallback_evidence_types = fallback_outcome.evidence_types_used - if fallback_outcome.printing_pk is not None: - confidence = ( - FALLBACK_CONFIDENCE_MULTI_EVIDENCE - if len(fallback_outcome.evidence_types_used) >= 2 - else FALLBACK_CONFIDENCE_SINGLE_EVIDENCE - ) - outcome.fallback_vote = EngineVote( - engine="phash", # placeholder Engine literal - fallback isn't a selectable --engine - printing_pk=fallback_outcome.printing_pk, - confidence=confidence, - detail=",".join(fallback_outcome.evidence_types_used), - ) + # PASS 2 (`local_fallback.run_fallback_for_card` -> a "local-fallback-v1" printing vote for + # every card pass 1 missed) USED TO RUN HERE, and is RETIRED as of 2026-07-29 - see the + # module docstring for the ruling, the measurement behind it, and what deliberately survives + # it. Nothing calls `run_fallback_for_card` from this module any more; the border/artist/ + # symbol evidence combination it performed is now cast ONCE, by + # `local_calculate_verdicts.calculate_fallback_verdict` off stored `ImageEvidence`. Note that + # `detect_illus_anchor` above is NOT part of that retirement: it feeds the frame-style + # classifier for EVERY card, independent of whether any printing vote is cast, and it was + # already a separate call before pass 2 ran. return CardComputeResult(card_id=card_id, fetch_attempted=fetch_attempted, outcome=outcome) @@ -1033,7 +1065,8 @@ class PilotResult: run_id: str = "" votes_written: int = 0 # issue #207: real is_no_match votes cast from a genuine whole-candidate-set no-match - # conclusion (OCR's "parsed-but-no-match", fallback's "eliminated") - counted separately + # conclusion (OCR's "parsed-but-no-match"; fallback's "eliminated" was the other, retired + # 2026-07-29 with the pass-2 printing channel itself) - counted separately # from votes_written (which names a specific printing) rather than folded into it, so # existing callers/tests reading votes_written as "a printing was identified" don't silently # change meaning. @@ -1135,7 +1168,9 @@ def run_pilot( ocr_known_set_codes = _known_set_codes() engines_to_run: list[Engine] = ["ocr", "phash"] if engine == "both" else [engine] results: dict[str, PilotResult] = {e: PilotResult(engine=e, dry_run=dry_run, run_id=run_id) for e in engines_to_run} - results["fallback"] = PilotResult(engine="fallback", dry_run=dry_run, run_id=run_id) + # No `results["fallback"]` entry any more: the pass-2 printing channel that produced it is + # retired (module docstring), so a permanently all-zero PilotResult would only misreport a + # calculator that no longer exists as one that ran and found nothing. attributes = AttributeReport() exclude_source_pks_by_engine = exclude_source_pks_by_engine or {} # addendum item 1 (2026-07-15): computed ONCE per invocation (not once per engine) and @@ -1217,26 +1252,17 @@ def run_pilot( if _cluster_member_ids: for _card_id, _anonymous_id in CardPrintingTag.objects.filter( card_id__in=_cluster_member_ids, - anonymous_id__in=[OCR_ANONYMOUS_ID, PHASH_ANONYMOUS_ID, FALLBACK_ANONYMOUS_ID], + # FALLBACK_ANONYMOUS_ID dropped from this lookup 2026-07-29 with the pass-2 printing + # channel (module docstring): nothing propagates a cluster vote under that identity + # any more, so there is no uniqueness collision left for it to guard against. + anonymous_id__in=[OCR_ANONYMOUS_ID, PHASH_ANONYMOUS_ID], ).values_list("card_id", "anonymous_id"): members_already_voted_by_anonymous_id[_anonymous_id].add(_card_id) - # fallback's own idempotence check - it has no selection query/anonymous_id exclusion of - # its own (it rides on whichever cards ocr/phash already selected), so a card already - # covered by a prior fallback run is excluded here instead. Part 3 addendum item 3: a - # non-re-scannable fallback scan-log row counts as "covered" too, same as a vote - fallback - # abstaining for a real reason (not a transient fetch failure or a frame-mismatch withhold) - # shouldn't be silently re-attempted forever just because fallback has no selection query - # of its own to apply RESCANNABLE_SKIP_REASONS through. - already_fallback_covered = set( - CardPrintingTag.objects.filter( - card_id__in=all_selected_by_card_id.keys(), anonymous_id=FALLBACK_ANONYMOUS_ID - ).values_list("card_id", flat=True) - ) | set( - CardScanLog.objects.filter(card_id__in=all_selected_by_card_id.keys(), anonymous_id=FALLBACK_ANONYMOUS_ID) - .exclude(skip_reason__in=RESCANNABLE_SKIP_REASONS) - .values_list("card_id", flat=True) - ) + # The pass-2 fallback's own `already_fallback_covered` idempotence set (a prior run's + # "local-fallback-v1" vote or non-rescannable scan-log row) was removed here 2026-07-29 with + # the channel it guarded - it existed only because the fallback had no selection query of its + # own to apply RESCANNABLE_SKIP_REASONS through. def _absorb_engine_selection(engine_selected_ids: set[int]) -> set[int]: # a cluster's representative must run an engine if EITHER it or any absorbed member was @@ -1260,7 +1286,7 @@ def _absorb_engine_selection(engine_selected_ids: set[int]) -> set[int]: # DIVERGES from that precedent on ONE point: the gate check runs after every flush here, not # once at the very end. deductive_backfill's votes are provably exact by construction (a gate # violation there is structurally impossible), so a single end-of-run check is just belt-and- - # suspenders; this pilot's OCR/phash/fallback votes are explicitly weaker, lower-confidence + # suspenders; this pilot's OCR/phash votes are explicitly weaker, lower-confidence # signal (module docstring) where a real violation is more plausible, and a kill is an # EXPECTED event for a multi-day run (the whole reason this checkpointing exists) - a # violation in an already-flushed batch must not sit undetected in the DB indefinitely just @@ -1288,8 +1314,29 @@ def flush() -> None: # `vote_write.purge_and_write_votes` makes each pair atomic and scopes the purge to # exactly the rows it inserts. Passing `anonymous_id=None` reproduces the per-identity # grouping this flush did by hand: a pilot batch legitimately mixes engines' identities - # (OCR/phash/fallback, plus propagated cluster votes), and each row must be purged under - # its OWN family, never one representative's. + # (OCR/phash, plus propagated cluster votes), and each row must be purged under its OWN + # family, never one representative's. + # + # RETIREMENT LOCK (2026-07-29, module docstring): the one place every printing vote this + # module casts passes through, so the one place a retired identity can be caught before + # it lands. Family-keyed, so a "-v2" redeploy of a retired calculator is caught too. This + # deliberately does NOT screen `tag_votes_batch`: the SAME "local-fallback-v1" identity + # still legitimately casts border/frame/bleed attribute chips there, and only its + # PRINTING channel was retired. + retired = sorted( + { + row.anonymous_id + for row in votes_batch + if calculator_family(row.anonymous_id) in RETIRED_PRINTING_VOTE_FAMILIES + } + ) + if retired: + raise AssertionError( + f"RETIRED CALCULATOR: refusing to write CardPrintingTag rows under {retired} - " + "that calculator family's printing votes were retired as redundant (owner ruling " + "2026-07-29, see this module's docstring). Existing rows are kept as history; " + "casting new ones is not." + ) purge_and_write_votes(CardTagVote, tag_votes_batch, target_field="card_id", ignore_conflicts=True) purge_and_write_votes(CardPrintingTag, votes_batch, target_field="card_id") if scan_log_batch: @@ -1303,7 +1350,7 @@ def propagate_cluster_vote( ) -> int: """Addendum item 2a: an accepted vote on a cluster representative propagates as an identical vote (same anonymous_id, printing, confidence) to every OTHER cluster member - - absorbed members never ran their own OCR/phash/fallback, so this is the only vote they + absorbed members never ran their own OCR/phash, so this is the only vote they ever get. Skips any member that already has a vote from this SAME anonymous_id (e.g. one engine's vote from a prior invocation, on a member only newly eligible for a DIFFERENT engine this run) - propagating anyway would violate CardPrintingTag's own @@ -1347,7 +1394,7 @@ def propagate_cluster_vote( cards_attempted = 0 # Pipeline concurrency (pre-scale program item 3d, 2026-07-15): the per-card COMPUTE work - # (fetch, OCR, phash, border/frame/bleed classification, pass-2 fallback - everything + # (fetch, OCR, phash, border/frame/bleed classification - everything # _compute_card does) is independent per card and safe to run concurrently; the per-card # WRITE work below (votes_batch/tag_votes_batch staging, disagreement bookkeeping, # ground-truth-preferred attribute overrides, the frame-mismatch consistency check) stays @@ -1371,7 +1418,6 @@ def propagate_cluster_vote( _compute_card, ocr_selected_ids=ocr_selected_ids, phash_selected_ids=phash_selected_ids, - already_fallback_covered=already_fallback_covered, ocr_crop_box=ocr_crop_box, phash_distance_threshold=phash_distance_threshold, phash_margin=phash_margin, @@ -1425,19 +1471,18 @@ def propagate_cluster_vote( if compute_result.fetch_attempted: fetches_made += 1 - # Finalize + queue for write - a card's full cost (image fetch, OCR, phash, - # fallback) was already paid once in _compute_card above; nothing here depends on + # Finalize + queue for write - a card's full cost (image fetch, OCR, phash) was + # already paid once in _compute_card above; nothing here depends on # any OTHER card's outcome, only this card's own DB state (the frame-mismatch # consistency check below re-queries the matched printing's own metadata, # independent of processing order). result_ocr = results.get("ocr") result_phash = results.get("phash") - result_fallback = results["fallback"] printing_vote_withheld_for_frame_mismatch = False - # consistency check: only meaningful once a printing vote (from either pass) exists - # to compare against the observed frame reading. - candidate_vote = outcome.ocr_vote or outcome.phash_vote or outcome.fallback_vote + # consistency check: only meaningful once a printing vote exists to compare + # against the observed frame reading. + candidate_vote = outcome.ocr_vote or outcome.phash_vote if outcome.frame_class is not None and candidate_vote is not None and not outcome.disagreement: canonical = ( CanonicalCard.objects.filter(pk=candidate_vote.printing_pk) @@ -1602,96 +1647,15 @@ def propagate_cluster_vote( ) ) - if outcome.fallback_vote is not None: - if printing_vote_withheld_for_frame_mismatch: - result_fallback.skip_counts["frame-mismatch"] += 1 - scan_log_batch.append( - CardScanLog( - card_id=card_id, - anonymous_id=FALLBACK_ANONYMOUS_ID, - run_id=run_id, - skip_reason="frame-mismatch", - ) - ) - else: - votes_batch.append( - CardPrintingTag( - card_id=card_id, - printing_id=outcome.fallback_vote.printing_pk, - is_no_match=False, - anonymous_id=FALLBACK_ANONYMOUS_ID, - source=VoteSource.OCR, - confidence=outcome.fallback_vote.confidence, - run_id=run_id, - ) - ) - result_fallback.votes_written += 1 - result_fallback.audit.append({"card_id": card_id, "evidence": outcome.fallback_vote.detail}) - if card_id not in written_card_ids: - written_card_ids.append(card_id) - batch_written_card_ids.append(card_id) - result_fallback.votes_written += propagate_cluster_vote( - card_id, - outcome.fallback_vote.printing_pk, - FALLBACK_ANONYMOUS_ID, - outcome.fallback_vote.confidence, - ) - elif outcome.fallback_skip_reason: - if outcome.fallback_skip_reason == "eliminated": - # issue #207: the evidence-combination intersection narrowed to ZERO - # surviving candidates - every sub-check that produced a reading - # agrees none of this card's own candidates fit, which is genuine - # whole-set no-match evidence (unlike "ambiguous", more than one - # candidate still fits, or "no-evidence", no sub-check produced a - # reading at all - both stay abstentions, below). Same "the vote IS - # the record" convention as the OCR branch above. - votes_batch.append( - CardPrintingTag( - card_id=card_id, - printing_id=None, - is_no_match=True, - anonymous_id=FALLBACK_ANONYMOUS_ID, - source=VoteSource.OCR, - confidence=FALLBACK_NO_MATCH_CONFIDENCE, - run_id=run_id, - ) - ) - result_fallback.no_match_votes_written += 1 - result_fallback.audit.append( - { - "card_id": card_id, - "no_match_reason": "eliminated", - "evidence": outcome.fallback_evidence_types, - } - ) - if card_id not in written_card_ids: - written_card_ids.append(card_id) - batch_written_card_ids.append(card_id) - else: - result_fallback.skip_counts[outcome.fallback_skip_reason] += 1 - # issue #207 instrumentation (code-only, no ranked-vote schema built - # here): survivor_pks is the trivial, zero-recomputation case for - # "no-evidence" (nothing filtered anything, so every candidate this - # card's engines considered "survived" by definition) - left `null` - # for "ambiguous", where the actual narrowed survivor set isn't - # recoverable without either reimplementing local_fallback's - # border/artist/symbol sub-checks a second time here, or having - # FallbackOutcome expose it directly (a protected-core change, open - # item - see this PR's body, not built in this change). - scan_log_batch.append( - CardScanLog( - card_id=card_id, - anonymous_id=FALLBACK_ANONYMOUS_ID, - run_id=run_id, - skip_reason=outcome.fallback_skip_reason, - evidence_types_used=outcome.fallback_evidence_types, - survivor_pks=( - outcome.candidate_pks_considered - if outcome.fallback_skip_reason == "no-evidence" - else None - ), - ) - ) + # THE PASS-2 FALLBACK'S OWN WRITE BRANCH STOOD HERE and is RETIRED as of + # 2026-07-29 (module docstring): a positive "local-fallback-v1" + # CardPrintingTag vote (plus its cluster propagation), an is_no_match vote + # for the "eliminated" outcome, and the CardScanLog abstention rows for + # every other outcome. All three are gone because nothing computes a + # fallback printing verdict any more - not gated off, removed - and the + # `flush` guard above makes re-adding one fail loudly rather than silently + # restore a retired witness. The card's border/frame/bleed attribute votes + # are cast below, unchanged. # border/frame attribute votes are independent of printing-vote success or the # consistency-check outcome above - they fire for any card a border/frame reading diff --git a/MPCAutofill/cardpicker/management/commands/local_identify_printing_tags.py b/MPCAutofill/cardpicker/management/commands/local_identify_printing_tags.py index 0b8b6e44b..7b8596d94 100644 --- a/MPCAutofill/cardpicker/management/commands/local_identify_printing_tags.py +++ b/MPCAutofill/cardpicker/management/commands/local_identify_printing_tags.py @@ -15,8 +15,10 @@ class Command(BaseCommand): "PILOT (see docs/features/printing-tags.md Stage 8): casts OCR-weight (source=ocr) " "CardPrintingTag votes from two local, zero-API-cost engines that actually look at a " "card's image - L1 Tesseract OCR on the collector-line crop, L2 perceptual-hash art " - "matching - plus a pass-2 fallback (border/artist/symbol evidence combination) for cards " - "pass 1 misses. Never resolves a card by itself (the human-backed gate in " + "matching. (The pass-2 border/artist/symbol fallback that used to run alongside them was " + "RETIRED 2026-07-29 as redundant with stage-d-fallback-v1 - see cardpicker/" + "local_identify_printing_tags.py's module docstring.) Never resolves a card by itself " + "(the human-backed gate in " "vote_consensus.resolve_weighted_consensus still applies). PILOT ONLY: --limit defaults " "to 300 - do not scale this up to a full-catalog run without reviewing the pilot's " "yield/accuracy report first." @@ -250,8 +252,9 @@ def _parse_source_pks(raw: str) -> list[int]: print(f" votes written: {result.votes_written}") if result.no_match_votes_written: # issue #207: is_no_match votes cast from a genuine whole-candidate-set no-match - # conclusion (OCR's "parsed-but-no-match", fallback's "eliminated") - reported - # separately from votes_written (which names a specific printing). + # conclusion (OCR's "parsed-but-no-match"; fallback's "eliminated" was the other, + # retired 2026-07-29 with the pass-2 printing channel) - reported separately from + # votes_written (which names a specific printing). print(f" no-match votes written: {result.no_match_votes_written}") for reason, count in sorted(result.skip_counts.items()): print(f" skipped ({reason}): {count}") diff --git a/MPCAutofill/cardpicker/tests/test_local_identify_printing_tags.py b/MPCAutofill/cardpicker/tests/test_local_identify_printing_tags.py index 7066805d9..10ac3cc28 100644 --- a/MPCAutofill/cardpicker/tests/test_local_identify_printing_tags.py +++ b/MPCAutofill/cardpicker/tests/test_local_identify_printing_tags.py @@ -1469,9 +1469,11 @@ def fake_ocr(selected, image, crop_box, bleed_class=None, known_set_codes=None): def test_only_requested_engine_appears_in_results(self, db, monkeypatch): results, _attributes = run_pilot(engine="ocr", limit=10, dry_run=True, nice=False) - # "fallback" (pass 2) always gets a result entry - it isn't a selectable --engine, it - # fires automatically whenever pass 1 (whichever engines were requested) misses - assert set(results.keys()) == {"ocr", "fallback"} + # "fallback" (pass 2) used to get an unconditional result entry alongside the requested + # engine(s) - it isn't a selectable --engine, it fired automatically whenever pass 1 + # missed. That channel is RETIRED (2026-07-29, module docstring), so there is no + # permanently-zero entry left to report. + assert set(results.keys()) == {"ocr"} class TestUncoveredPrintingsClosed: @@ -2285,17 +2287,24 @@ def test_engine_disagreement_never_casts_is_no_match(self, db, monkeypatch): assert not CardPrintingTag.objects.filter(card=card).exists() -class TestFallbackNoMatchVoteCasting: - """issue #207 part 2: fallback's "eliminated" outcome (the evidence-combination - intersection narrowed to ZERO surviving candidates) is genuine whole-candidate-set no-match - evidence and casts a real `CardPrintingTag(is_no_match=True)` vote instead of a mere - CardScanLog row - "no-evidence" and "ambiguous" (a real, not-yet-eliminated candidate set) - must NOT.""" +class TestFallbackPrintingVoteRetired: + """RETIREMENT LOCK for the pass-2 fallback printing channel (owner ruling 2026-07-29, + redundancy doctrine - see local_identify_printing_tags' module docstring). `local-fallback-v1` + and `stage-d-fallback-v1` read the SAME border/artist/symbol evidence and agreed on + 11,825/11,825 overlapping cards with zero conflicts, so the pilot's copy is one witness + counted twice; Stage D's is the one kept. + + This class replaces `TestFallbackNoMatchVoteCasting` (issue #207 part 2), whose whole subject + - fallback's "eliminated" outcome casting a real `is_no_match` vote, and "no-evidence"/ + "ambiguous" recording a `CardScanLog` abstention instead - no longer has a code path. The + tests below assert the retirement rather than the removed behaviour, and (critically) that + the retirement did NOT take the attribute-chip channels or the other two engines with it. + """ @staticmethod - def _wire_pass_1_miss_and_fallback(module, fallback_outcome, image=None): + def _wire_pass_1_miss(module, image=None): image = image or Image.new("RGB", (750, 1050), (5, 5, 5)) - monkeypatch_targets = [ + return [ ( module, "run_ocr_for_card", @@ -2310,118 +2319,106 @@ def _wire_pass_1_miss_and_fallback(module, fallback_outcome, image=None): ), ), (module, "fetch_card_image", lambda card, dpi=None: image), - ( - module.local_fallback, - "run_fallback_for_card", - lambda selected, image, ocr_raw_texts, bleed_class=None: fallback_outcome, - ), ] - return monkeypatch_targets - def test_eliminated_casts_is_no_match_vote_not_a_scan_log_row(self, db, monkeypatch): + def test_a_pass_1_miss_never_calls_the_fallback_engine_at_all(self, db, monkeypatch): + """Not merely "casts no vote": the verdict is never computed, so the run doesn't pay for + an extra artist-crop OCR pass and a symbol phash scan per missed card either.""" import cardpicker.local_identify_printing_tags as module import cardpicker.local_ocr as local_ocr_module - from cardpicker.local_fallback import FallbackOutcome CanonicalCardFactory(name="Forest") card = CardFactory(name="Forest") monkeypatch.setattr(local_ocr_module, "run_tesseract", lambda image: "") - for target, name, fn in self._wire_pass_1_miss_and_fallback( - module, FallbackOutcome(skip_reason="eliminated", evidence_types_used=["border"]) - ): + def fail_if_called(*args, **kwargs): + raise AssertionError("run_fallback_for_card is retired and must never be called from run_pilot") + + monkeypatch.setattr(module.local_fallback, "run_fallback_for_card", fail_if_called) + for target, name, fn in self._wire_pass_1_miss(module): monkeypatch.setattr(target, name, fn) - results, _attributes = run_pilot(engine="both", limit=10, dry_run=False, nice=False, workers=1) + run_pilot(engine="both", limit=10, dry_run=False, nice=False, workers=1) - vote = CardPrintingTag.objects.get(card=card, anonymous_id=FALLBACK_ANONYMOUS_ID) - assert vote.is_no_match is True - assert vote.printing is None + assert not CardPrintingTag.objects.filter(card=card, anonymous_id=FALLBACK_ANONYMOUS_ID).exists() assert not CardScanLog.objects.filter(card=card, anonymous_id=FALLBACK_ANONYMOUS_ID).exists() - assert results["fallback"].no_match_votes_written == 1 - assert results["fallback"].votes_written == 0 - - def test_no_evidence_does_not_cast_is_no_match_and_records_full_candidate_set_as_survivors(self, db, monkeypatch): - import cardpicker.local_identify_printing_tags as module - import cardpicker.local_ocr as local_ocr_module - from cardpicker.local_fallback import FallbackOutcome + def test_the_other_two_engines_in_the_same_command_are_untouched(self, db, monkeypatch): + """THE TRAP this retirement had to avoid (the `local-ocr-v1` precedent): the retired pass + shares its ONE management command with `local-phash-v1`/`local-ocr-v1`, which are kept - + "just stop running the command" would have silently dropped both.""" printing = CanonicalCardFactory(name="Forest") card = CardFactory(name="Forest") - monkeypatch.setattr(local_ocr_module, "run_tesseract", lambda image: "") - - for target, name, fn in self._wire_pass_1_miss_and_fallback(module, FallbackOutcome(skip_reason="no-evidence")): - monkeypatch.setattr(target, name, fn) - - run_pilot(engine="both", limit=10, dry_run=False, nice=False, workers=1) - - assert not CardPrintingTag.objects.filter(card=card, anonymous_id=FALLBACK_ANONYMOUS_ID).exists() - row = CardScanLog.objects.get(card=card, anonymous_id=FALLBACK_ANONYMOUS_ID) - assert row.skip_reason == "no-evidence" - assert row.evidence_types_used == [] - assert row.survivor_pks == [printing.pk] - def test_ambiguous_does_not_cast_is_no_match_and_leaves_survivor_pks_unknown(self, db, monkeypatch): import cardpicker.local_identify_printing_tags as module import cardpicker.local_ocr as local_ocr_module - from cardpicker.local_fallback import FallbackOutcome - CanonicalCardFactory(name="Forest") - CanonicalCardFactory(name="Forest", expansion=CanonicalExpansionFactory(code="bbb")) - card = CardFactory(name="Forest") monkeypatch.setattr(local_ocr_module, "run_tesseract", lambda image: "") + monkeypatch.setattr( + module, + "run_phash_for_card", + lambda selected, image, threshold, margin, max_candidates, bleed_class=None: ( + module.EngineVote(engine="phash", printing_pk=printing.pk, confidence=0.8, detail="d=2"), + "", + ), + ) + monkeypatch.setattr( + module, + "run_ocr_for_card", + lambda selected, image, crop_box, bleed_class=None, known_set_codes=None: module.OcrCardResult(), + ) + monkeypatch.setattr(module, "fetch_card_image", lambda card, dpi=None: Image.new("RGB", (750, 1050), (5, 5, 5))) - for target, name, fn in self._wire_pass_1_miss_and_fallback( - module, FallbackOutcome(skip_reason="ambiguous", evidence_types_used=["border", "artist"]) - ): - monkeypatch.setattr(target, name, fn) + results, _attributes = run_pilot(engine="both", limit=10, dry_run=False, nice=False, workers=1) - run_pilot(engine="both", limit=10, dry_run=False, nice=False, workers=1) + assert results["phash"].votes_written == 1 + assert CardPrintingTag.objects.filter( + card=card, anonymous_id=module.PHASH_ANONYMOUS_ID, printing=printing + ).exists() - assert not CardPrintingTag.objects.filter(card=card, anonymous_id=FALLBACK_ANONYMOUS_ID).exists() - row = CardScanLog.objects.get(card=card, anonymous_id=FALLBACK_ANONYMOUS_ID) - assert row.skip_reason == "ambiguous" - assert row.evidence_types_used == ["border", "artist"] - # the OPEN ITEM this PR's body flags: the actual narrowed survivor set for "ambiguous" - # isn't recoverable without touching local_fallback.py (PROTECTED CORE) - left null - # rather than guessed at. - assert row.survivor_pks is None + def test_the_flush_refuses_a_retired_family_row_by_family_not_by_literal(self, db, monkeypatch): + """The guard is keyed on `calculator_family`, so a "-v2" redeploy of a retired calculator + is caught too - an exact-string check would let an ordinary version bump silently + un-retire a ratified ruling (`vote_consensus.DEDUCTIVE_BACKFILL_FAMILY`'s own reasoning).""" + import cardpicker.local_identify_printing_tags as module - def test_frame_mismatch_never_casts_is_no_match(self, db, monkeypatch): - # companion to TestPass2Wiring::test_frame_mismatch_withholds_the_printing_vote, - # asserting the is_no_match angle specifically. - printing = CanonicalCardFactory( - name="Forest", - expansion=CanonicalExpansionFactory(code="aaa"), - artist=CanonicalArtistFactory(name="Marie Magny"), - ) - CanonicalPrintingMetadataFactory(canonical_card=printing, border_color="black", frame="2015") + printing = CanonicalCardFactory(name="Forest") card = CardFactory(name="Forest") - TagFactory(name="Black Border") - import cardpicker.local_identify_printing_tags as module - import cardpicker.local_ocr as local_ocr_module - monkeypatch.setattr(local_ocr_module, "run_tesseract", lambda image: "Illus. Marie Magny") monkeypatch.setattr( module, "run_ocr_for_card", - lambda selected, image, crop_box, bleed_class=None, known_set_codes=None: module.OcrCardResult(), - ) - monkeypatch.setattr( - module, - "run_phash_for_card", - lambda selected, image, threshold, margin, max_candidates, bleed_class=None: ( - None, - "no-clear-winner-distance", + lambda selected, image, crop_box, bleed_class=None, known_set_codes=None: module.OcrCardResult( + vote=module.EngineVote(engine="ocr", printing_pk=printing.pk, confidence=0.85, detail="raw") ), ) - monkeypatch.setattr( - module, "fetch_card_image", lambda card, dpi=None: _black_bordered_image_with_artist_text("Marie Magny") - ) + monkeypatch.setattr(module, "fetch_card_image", lambda card, dpi=None: None) - run_pilot(engine="both", limit=10, dry_run=False, nice=False, workers=1) + # The family set is DERIVED from FALLBACK_ANONYMOUS_ID, never written out as a second + # literal - so a "-v2" of the retired calculator is already a member without anyone + # having to remember to add it, and the kept engines are not. + assert module.calculator_family("local-fallback-v2") in module.RETIRED_PRINTING_VOTE_FAMILIES + assert module.calculator_family(module.OCR_ANONYMOUS_ID) not in module.RETIRED_PRINTING_VOTE_FAMILIES - assert not CardPrintingTag.objects.filter(card=card).exists() + # Sanity-check the fixture actually reaches `flush` with a printing row BEFORE re-pointing + # the identity - otherwise a vacuous run would "pass" the guard assertion below. + results, _attributes = run_pilot(engine="ocr", limit=10, dry_run=False, nice=False) + assert results["ocr"].votes_written == 1 + CardPrintingTag.objects.filter(card=card).delete() + + # Now simulate the future edit this lock exists to stop: a printing vote reaching `flush` + # under a BUMPED version of the retired family. Re-pointing the one engine identity that + # does still stage printing rows is the cheapest faithful way to get such a row into + # `votes_batch` without re-adding the removed pass-2 code. + monkeypatch.setattr(module, "OCR_ANONYMOUS_ID", "local-fallback-v2") + + with pytest.raises(AssertionError, match="RETIRED CALCULATOR"): + run_pilot(engine="ocr", limit=10, dry_run=False, nice=False) + + # Refused BEFORE the write, not cleaned up after it: nothing landed under the retired + # family, and the flush's other write (the attribute CardTagVotes) didn't land either. + assert not CardPrintingTag.objects.filter(anonymous_id="local-fallback-v2").exists() + assert not CardTagVote.objects.filter(anonymous_id="local-fallback-v2").exists() class TestAbstentionAwareOrdering: @@ -2567,31 +2564,36 @@ def _black_bordered_image_with_artist_text(artist_name: str) -> Image: class TestPass2Wiring: - """Integration coverage for the pass-2 fallback wiring inside run_pilot itself (not just - local_fallback's own unit tests) - pass 1 mocked to always miss, local_fallback's real - logic runs against a real (synthetic) image.""" - - def test_fallback_fires_and_votes_when_pass_1_misses_entirely(self, db, monkeypatch): + """What survives the pass-2 printing channel's retirement (2026-07-29 - see + `TestFallbackPrintingVoteRetired`): the ATTRIBUTE-chip votes `local_fallback` casts under the + very same `local-fallback-v1` identity but on `CardTagVote`, and the frame-mismatch + consistency check, which was never fallback-specific.""" + + def test_the_attribute_chip_channel_survives_the_printing_vote_retirement(self, db, monkeypatch): + """THE SECOND TRAP: `local-fallback-v1` is ALSO the identity of the border/frame/bleed + attribute-chip `CardTagVote`s, which Stage D has no analogue for and which were never part + of the printing-vote redundancy measurement. Retiring the identity wholesale would have + silently dropped them. Same fixture as the retired + `test_fallback_fires_and_votes_when_pass_1_misses_entirely`: pass 1 misses entirely on a + real (synthetic) black-bordered image.""" printing = CanonicalCardFactory( name="Forest", expansion=CanonicalExpansionFactory(code="aaa"), artist=CanonicalArtistFactory(name="Marie Magny"), ) - # frame="1993" (old-border class) - the fake image below carries an "Illus. " - # credit, which the frame classifier reads as old-border; the printing's own frame must - # agree, or the CONSISTENCY CHECK correctly withholds the vote as a frame-mismatch (see - # TestPass2Wiring::test_frame_mismatch_withholds_the_printing_vote below for that path). CanonicalPrintingMetadataFactory(canonical_card=printing, border_color="black", frame="1993") CanonicalCardFactory(name="Forest", expansion=CanonicalExpansionFactory(code="bbb")) card = CardFactory(name="Forest") TagFactory(name="Black Border") + TagFactory(name="Old Border") import cardpicker.local_identify_printing_tags as module import cardpicker.local_ocr as local_ocr_module # no real tesseract binary in CI - the fake image below has "Illus. Marie Magny" drawn - # on it, but the crop/OCR fallback inside detect_illus_anchor() must not depend on the - # real binary reading it accurately; this mirrors what it would extract. + # on it, but the crop/OCR pass inside detect_illus_anchor() (which feeds the frame-style + # classifier, NOT the retired printing channel) must not depend on the real binary + # reading it accurately; this mirrors what it would extract. monkeypatch.setattr(local_ocr_module, "run_tesseract", lambda image: "Illus. Marie Magny") monkeypatch.setattr( module, @@ -2607,21 +2609,18 @@ def test_fallback_fires_and_votes_when_pass_1_misses_entirely(self, db, monkeypa module, "fetch_card_image", lambda card, dpi=None: _black_bordered_image_with_artist_text("Marie Magny") ) - # workers=1: this test exercises REAL (unmocked) run_fallback_for_card, which queries - # CanonicalCard/CanonicalPrintingMetadata/CanonicalArtist - under workers>1 those queries - # run on a worker thread's own DB connection, which can't see this test's fixture data - # under pytest-django's default (non-transactional) `db` fixture (only the original - # connection sees an uncommitted test transaction). Concurrency correctness itself is - # covered separately (TestConcurrency, using transactional_db) - this test is about - # fallback wiring, not concurrency, so it stays on the simple single-threaded path. - results, attributes = run_pilot(engine="both", limit=10, dry_run=False, nice=False, workers=1) - - assert results["fallback"].votes_written == 1 - assert CardPrintingTag.objects.filter(card=card, anonymous_id=FALLBACK_ANONYMOUS_ID, printing=printing).exists() + _results, attributes = run_pilot(engine="both", limit=10, dry_run=False, nice=False, workers=1) + + # the printing vote is gone... + assert not CardPrintingTag.objects.filter(card=card, anonymous_id=FALLBACK_ANONYMOUS_ID).exists() + # ...but the border/frame attribute chips it used to ride alongside are not assert attributes.border_votes_by_class["black"] == 1 assert CardTagVote.objects.filter( card=card, anonymous_id=FALLBACK_ANONYMOUS_ID, tag__name="Black Border" ).exists() + assert CardTagVote.objects.filter( + card=card, anonymous_id=FALLBACK_ANONYMOUS_ID, tag__name="Old Border" + ).exists() def test_frame_mismatch_withholds_the_printing_vote(self, db, monkeypatch): # OCR "succeeds" (a modern-frame signal: a collector number was parsed), but the @@ -2658,36 +2657,6 @@ def fake_ocr(selected, image, crop_box, bleed_class=None, known_set_codes=None): assert len(attributes.frame_mismatches) == 1 assert attributes.frame_mismatches[0]["card_id"] == card.pk - def test_already_fallback_covered_card_is_not_reattempted(self, db, monkeypatch): - printing = CanonicalCardFactory(name="Forest") - card = CardFactory(name="Forest") - CardPrintingTagFactory(card=card, printing=printing, anonymous_id=FALLBACK_ANONYMOUS_ID) - - import cardpicker.local_identify_printing_tags as module - import cardpicker.local_ocr as local_ocr_module - - # no real tesseract binary in CI - see the identical note on the sibling test above - monkeypatch.setattr(local_ocr_module, "run_tesseract", lambda image: "") - - def fail_if_called(selected, image, ocr_raw_texts): - raise AssertionError("run_fallback_for_card must not run again for an already-covered card") - - monkeypatch.setattr(module.local_fallback, "run_fallback_for_card", fail_if_called) - monkeypatch.setattr( - module, - "run_ocr_for_card", - lambda selected, image, crop_box, bleed_class=None, known_set_codes=None: module.OcrCardResult(), - ) - monkeypatch.setattr( - module, - "run_phash_for_card", - lambda selected, image, threshold, margin, max_candidates, bleed_class=None: (None, "no-clear-winner"), - ) - monkeypatch.setattr(module, "fetch_card_image", lambda card, dpi=None: Image.new("RGB", (750, 1050), (5, 5, 5))) - - # if the assertion inside fail_if_called had fired, this call itself would raise - run_pilot(engine="both", limit=10, dry_run=False, nice=False) - class TestGroundTruthAttributeVotes: """When a printing is confirmed for a card this run, border/frame attribute votes prefer @@ -2896,7 +2865,7 @@ def test_ambiguous_ratio_abstains_without_writing_anything(self, db, monkeypatch class TestConcurrency: - """Pre-scale program item 3d (2026-07-15): the per-card fetch+OCR+phash+fallback compute + """Pre-scale program item 3d (2026-07-15): the per-card fetch+OCR+phash compute work now runs across `workers` concurrent threads, feeding the same single-threaded DB-write loop as before. `transactional_db` (real commits, TRUNCATE-based cleanup), not the default rollback-wrapped `db` fixture - a real regression was caught writing these tests: @@ -3604,9 +3573,10 @@ def test_pilot_printing_tag_insert_failure_rolls_its_purge_back(self, db, monkey assert CardPrintingTag.objects.filter(pk=stale.pk).exists() def test_pilot_tag_vote_insert_failure_rolls_its_purge_back(self, db, monkeypatch): - """The same flush's OTHER write - the attribute `CardTagVote`s pass 2 casts. Fixture is - `TestPass2Wiring::test_fallback_fires_and_votes_when_pass_1_misses_entirely`'s, which is - the only path that puts rows in `tag_votes_batch`.""" + """The same flush's OTHER write - the attribute `CardTagVote`s the border/frame + classifiers cast (still live after the 2026-07-29 printing-channel retirement). Fixture is + `TestPass2Wiring::test_the_attribute_chip_channel_survives_the_printing_vote_retirement`'s, + which is the only path that puts rows in `tag_votes_batch`.""" printing = CanonicalCardFactory( name="Forest", expansion=CanonicalExpansionFactory(code="aaa"), @@ -3645,8 +3615,9 @@ def test_pilot_tag_vote_insert_failure_rolls_its_purge_back(self, db, monkeypatc monkeypatch.setattr(CardTagVote.objects, "bulk_create", self._boom) - # workers=1 for the same reason TestPass2Wiring gives: real (unmocked) - # run_fallback_for_card must run on this test's own DB connection. + # workers=1 for the same reason TestPass2Wiring gives: the real (unmocked) border/frame + # classifiers query CanonicalCard/CanonicalPrintingMetadata, which under workers>1 would + # run on a worker thread's own DB connection and miss this test's uncommitted fixtures. with pytest.raises(RuntimeError): run_pilot(engine="both", limit=10, dry_run=False, nice=False, workers=1)