From dafef103b14f03a5dc80ec3d4b707646323b265d Mon Sep 17 00:00:00 2001 From: wilfordgrimley <2397930+WilfordGrimley@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:13:32 +0000 Subject: [PATCH] Retire local-fallback-v1 printing votes as redundant with stage-d-fallback-v1 Owner ruling 2026-07-29, redundancy doctrine: anything made redundant is retired, judged on the EVIDENCE SOURCE rather than the vote cast. Two calculators reading one underlying signal are one witness counted twice, inflating apparent corroboration in vote_consensus.resolve_weighted_consensus' weighted quorum without adding information. The same ruling already retired local-ocr-v1 against stage-d-join-key-v1. PREMISE VERIFIED IN CODE, not taken on the measurement alone. local_calculate_verdicts.calculate_fallback_verdict ("stage-d-fallback-v1") is a faithful port of local_fallback.run_fallback_for_card's decision model: the same three sub-checks composed by the same intersection loop in the same order, abstaining on the same "no sub-check produced a reading"/"zero survivors"/"more than one survivor" trichotomy, at the same FALLBACK_CONFIDENCE_SINGLE_EVIDENCE/ MULTI_EVIDENCE constants imported from local_fallback rather than redeclared. It calls filter_by_border_color and match_artist directly, and duplicates find_symbol_matches' arithmetic against the same PROTECTED CORE SYMBOL_DISTANCE_THRESHOLD/SYMBOL_MARGIN. Its inputs are Stage C's stored ImageEvidence readings of the SAME image signal, produced by the SAME extractors: layout_class is local_fallback.classify_border_color's own output, artist_ocr_name is local_fallback.extract_artist_name's (the same extraction detect_illus_anchor performs), symbol_phash is a hash of local_fallback.SYMBOL_STRIP_BOX (the same strip find_symbol_matches scans). The 100.0% agreement over 11,825 overlapping cards is therefore NOT an artefact: neither calls the other, and they run from two different management commands (local_identify_printing_tags vs local_calculate_verdicts). Same evidence source, one witness counted twice. ENTANGLEMENT CHECKED FIRST (the local-ocr-v1 trap, where "stop running the command" would have silently dropped local-phash-v1). Here it is worse in three ways, so this is a scalpel on ONE call site, not a switch-off: 1. The pilot command has no --engine value for the fallback (choices are ocr/phash/both) - it rode along on whatever pass 1 selected. Not running the command would drop local-ocr-v1 AND local-phash-v1. 2. local-fallback-v1 is ALSO the identity of three CardTagVote attribute-chip channels - cast_border_attribute_vote, cast_frame_style_vote and cast_bleed_edge_vote. Stage D has no analogue for any of them and none was part of the printing-vote measurement. Retiring the IDENTITY would drop them; only its CardPrintingTag channel is retired. 3. run_fallback_for_card is still called by local_residual_classify's recover_frame_mismatch_printing_via_fallback_refetch, and image_evidence.py still calls cast_border_attribute_vote. local_fallback.py stays live and unmodified apart from its docstring. What changed: run_pilot no longer calls run_fallback_for_card, and its pass-2 write branch (positive vote, cluster propagation, the "eliminated" is_no_match vote, and the CardScanLog abstention rows) is removed rather than gated off, along with the now-dead already_fallback_covered idempotence set and the permanently-zero results["fallback"] entry. A family-keyed RETIREMENT LOCK in flush() refuses any CardPrintingTag row from a retired calculator family, so a future "local-fallback-v2" cannot silently un-retire the ruling; it deliberately does not screen tag_votes_batch, where that identity still legitimately votes. The family set is DERIVED via models.calculator_family, never a second literal. Existing local-fallback-v1 vote rows are LEFT IN PLACE - history is kept, only future casting stops. Removing them is a separate, owner-authorized purge_machine_votes step and is NOT part of this change. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN --- MPCAutofill/cardpicker/local_fallback.py | 15 + .../local_identify_printing_tags.py | 276 ++++++++---------- .../commands/local_identify_printing_tags.py | 11 +- .../test_local_identify_printing_tags.py | 265 ++++++++--------- 4 files changed, 260 insertions(+), 307 deletions(-) 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)