From 8ffbbaf25bc3e5527caa8a1cac7cbcc6867c31a5 Mon Sep 17 00:00:00 2001 From: wilfordgrimley <2397930+WilfordGrimley@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:34:42 +0000 Subject: [PATCH] fix(local_calculate_verdicts): retire the border-mismatch join-key veto in favour of a demoted vote A border disagreement in _apply_agreement_checks no longer withholds an otherwise-exact join-key match. Owner ruling: this catalogue indexes proxy renders of a printing, not the printing itself, so a differently-bordered render still depicts the same printing - the border is a real, worth- recording distinction, not counter-evidence against the match. - Border disagreement now demotes confidence to the new JOIN_KEY_CONFIDENCE_BORDER_DISAGREEMENT tier (0.70) instead of returning a skip. When a card also carries an artist-OCR disagreement, the resulting confidence is the lower of the two tiers, not whichever check runs last. - The frame half of printing_attribute_disagreement is unchanged and still a hard veto - only the border half changes. - JOIN_KEY_BORDER_MISMATCH_SKIP_REASON is retired-not-removed, matching the JOIN_KEY_PROXY_MARKER_VETO_SKIP_REASON precedent: no code path writes it as a new value, but it stays declared and in JOIN_KEY_NO_HIT_SKIP_REASONS so historical rows keep routing to review. - The observed-border attribute-chip tag (local_layout_class_cast. run_layout_class_cast, wired into Stage E's _run_attribute_chip_casters) already casts independently of this calculator's own eligibility, so no new caster is needed to satisfy the "record the observed border as a tag" half of the ruling. - docs/reference/skip-reasons.md updated in place for the retirement. Investigated illustration tagging for the border-mismatch cohort (12,442/15,626 cards carry artist_ocr_name): once the veto is lifted these cards exit the illustration calculator's own eligibility population entirely, since they now resolve via a direct join-key printing vote instead of the join-key "no confident hit" outcome that population is scoped to - a negative finding, not a gap to fix. --- .../cardpicker/local_calculate_verdicts.py | 125 +++++++++++++----- .../tests/test_local_calculate_verdicts.py | 85 ++++++++---- docs/reference/skip-reasons.md | 24 ++-- 3 files changed, 162 insertions(+), 72 deletions(-) diff --git a/MPCAutofill/cardpicker/local_calculate_verdicts.py b/MPCAutofill/cardpicker/local_calculate_verdicts.py index bb28884e1..78335d965 100644 --- a/MPCAutofill/cardpicker/local_calculate_verdicts.py +++ b/MPCAutofill/cardpicker/local_calculate_verdicts.py @@ -76,17 +76,25 @@ match at all, since `CanonicalCard.name` for these rows is the combined Scryfall name, never the bare back-face name a split-image upload is named after. - Geometry/border agreement + frame agreement (issue #148/#149's `layout_class`/OCR-derived - frame reading vs. the matched printing's own `CanonicalPrintingMetadata.border_color`/`frame`): - a genuine disagreement WITHHOLDS the match entirely (`border-mismatch`/`frame-mismatch` named - skips), mirroring `local_identify_printing_tags`'s existing frame-mismatch-withholding logic - exactly (same `local_fallback.classify_frame_style`/`frame_style_is_consistent`, PROTECTED - CORE, called not modified) - a join-key match landing on a printing whose real border/frame - contradicts what's actually visible on the card face means the image most likely doesn't - faithfully depict that specific printing, the same reasoning that precedent already - established. `bleed_class` is NOT cross-checked here - there is no Scryfall field it could - ever agree or disagree with (bleed is a proxy-sheet-formatting property, not a printing - property), so despite this PR's own earlier deferred-item wording naming it, it's correctly - out of scope for an AGREEMENT check specifically (nothing to agree or disagree WITH). + frame reading vs. the matched printing's own `CanonicalPrintingMetadata.border_color`/`frame`). + FRAME: a genuine disagreement WITHHOLDS the match entirely (`frame-mismatch` named skip), + mirroring `local_identify_printing_tags`'s existing frame-mismatch-withholding logic exactly + (same `local_fallback.classify_frame_style`/`frame_style_is_consistent`, PROTECTED CORE, + called not modified) - a join-key match landing on a printing whose real frame contradicts + what's actually visible on the card face means the image most likely doesn't faithfully + depict that specific printing, the same reasoning that precedent already established. BORDER + (2026-08-04, owner ruling - superseded its own former WITHHOLDS treatment, see + `JOIN_KEY_CONFIDENCE_BORDER_DISAGREEMENT`'s own comment for the full history): this catalogue + indexes RENDERS of a printing, not the printing itself, so a differently-bordered render still + depicts the same printing - a disagreement here DEMOTES confidence + (`JOIN_KEY_CONFIDENCE_BORDER_DISAGREEMENT`) rather than withholding, the observed border is + cast as its own attribute-chip tag by `local_layout_class_cast.run_layout_class_cast` + (independent of this calculator entirely), and `JOIN_KEY_BORDER_MISMATCH_SKIP_REASON` is + retired-not-removed for exactly this reason. `bleed_class` is NOT cross-checked here - there + is no Scryfall field it could ever agree or disagree with (bleed is a proxy-sheet-formatting + property, not a printing property), so despite this PR's own earlier deferred-item wording + naming it, it's correctly out of scope for an AGREEMENT check specifically (nothing to agree + or disagree WITH). - Copyright-year era check: the legal line's parsed copyright year (`ImageEvidence.legal_line_copyright_year`, issue #151/#159) cross-checked against the matched printing's own Scryfall release date (`CanonicalPrintingMetadata.released_at`) - reusing the @@ -248,10 +256,15 @@ user-submitted phash itself (issue #203, a distinct, separately-designed, not-yet-built mechanism, deliberately not built here). This is a ROUTING step, not a matching engine: any card the join-key calculator concluded has no confident hit (a real `is_no_match` vote, or a - non-rescannable skip - `"ambiguous"`, `"no-text"`, `"border-mismatch"`, `"frame-mismatch"`, - `"truncated-image"`, `"copyright-year-mismatch"` - NOT `"proxy-marker-veto"`, retired 2026-07-21 - per the moderator-flag signal correction above; a stale pre-2026-07-21 row carrying that value - may still exist until `reparse_collector_evidence --selector proxy-marker-veto` retracts it) + non-rescannable skip - `"ambiguous"`, `"no-text"`, `"frame-mismatch"`, `"truncated-image"`, + `"copyright-year-mismatch"` - NOT `"proxy-marker-veto"`, retired 2026-07-21 per the + moderator-flag signal correction above (a stale pre-2026-07-21 row carrying that value may + still exist until `reparse_collector_evidence --selector proxy-marker-veto` retracts it), and + NOT `"border-mismatch"`, retired 2026-08-04 per the border-demotion correction above - unlike + the proxy-marker case, a stale pre-2026-08-04 `"border-mismatch"` row needs no dedicated + retraction path: it is not a wrong conclusion to undo, only a withheld one, and a plain re-run + of `run_join_key_calculator` under a fresh `run_id` (2026-07-29's own run-scoping) revisits and + casts the demoted vote through the ordinary calculator path) gets a `SlowPathVerdict` carrying its already-persisted `ImageEvidence` signals verbatim, and a `CardScanLog(anonymous_id="stage-d-slow-path-v1", skip_reason="to-review")` durable routing @@ -503,6 +516,28 @@ # two already-established tiers immediately above and below it. JOIN_KEY_CONFIDENCE_ARTIST_DISAGREEMENT = 0.65 +# BORDER-DISAGREEMENT DEMOTION (2026-08-04, owner ruling retiring the border half of the former +# border/frame agreement VETO - see JOIN_KEY_BORDER_MISMATCH_SKIP_REASON's own retirement comment +# below for the full history; the frame half is untouched and still withholds). This catalogue +# indexes proxy RENDERS of a printing, not the printing itself: a borderless full-art proxy of a +# black-bordered card still depicts that card, so an observed border disagreement is not +# counter-evidence against the match the way an artist-OCR/copyright-year/collector-line-artist +# disagreement is - the join-key match "matches exactly" (the owner's own words) and the border is +# a real, worth-recording distinction rather than a contradiction of it. Demoted rather than left +# at base_confidence anyway, since it is still something the calculator did not expect and a +# reviewer should be able to see reflected in the confidence reported. Placed ABOVE +# JOIN_KEY_CONFIDENCE_ARTIST_DISAGREEMENT (0.65) - this disagreement carries none of that check's +# "the parse's own two signals contradict each other" weight - and BELOW +# JOIN_KEY_CONFIDENCE_COLLECTOR_ONLY/JOIN_KEY_CONFIDENCE_SYMBOL_TIEBREAK (0.75, both a full +# base-confidence tier in their own right) since it is still a real demotion, not a full tier. Not +# a calibrated number, a reasoned ordering between the two already-established tiers immediately +# around it - same convention JOIN_KEY_CONFIDENCE_ARTIST_DISAGREEMENT's own comment above already +# uses. When a card carries BOTH a border disagreement and an artist-OCR disagreement, +# `_apply_agreement_checks` takes the LOWER of the two tiers rather than whichever check happens to +# run last - two independent disagreements must never leave a card more confident than either +# alone would. +JOIN_KEY_CONFIDENCE_BORDER_DISAGREEMENT = 0.70 + # RETIRED: INTERIM STAGE D GUARD (issue #473 PR-2, TEMPORARY BY DESIGN, removed by PR-3 - # 2026-07-25 owner-ratified group-level vote pooling, `vote_consensus.pool_group_votes`). A card # whose CURRENT evidence row was created by `evidence_transfer.transfer_evidence` rather than a @@ -538,6 +573,17 @@ JOIN_KEY_NO_TEXT_SKIP_REASON = "no-text" JOIN_KEY_AMBIGUOUS_SKIP_REASON = "ambiguous" JOIN_KEY_TRUNCATED_IMAGE_SKIP_REASON = "truncated-image" +# RETIRED as a write value on 2026-08-04 (owner ruling: "let's have it tag the illustration based +# on artist as well as the border we identify rather than skipping if it doesn't already... a +# printing matches exactly, a border mismatch is an accurate distinction" - see +# JOIN_KEY_CONFIDENCE_BORDER_DISAGREEMENT's own comment above for the full reasoning): +# `_apply_agreement_checks` no longer withholds a join-key match on a border disagreement alone - +# it casts the vote at a demoted confidence instead - so no code path writes a NEW row with this +# reason. Declared, and still a member of JOIN_KEY_NO_HIT_SKIP_REASONS below, purely so HISTORICAL +# rows (question_feed.QUICK_NEGATIVE_SKIP_REASONS' moderator-queue routing, views.py's withheld- +# count funnel reporting, test_skip_reason_roster.py's pinned roster) still read sensibly and +# still route their card to human review - the same retired-not-removed treatment +# JOIN_KEY_PROXY_MARKER_VETO_SKIP_REASON gets a few lines below. JOIN_KEY_BORDER_MISMATCH_SKIP_REASON = "border-mismatch" JOIN_KEY_FRAME_MISMATCH_SKIP_REASON = "frame-mismatch" JOIN_KEY_COPYRIGHT_YEAR_MISMATCH_SKIP_REASON = "copyright-year-mismatch" @@ -727,8 +773,8 @@ def _apply_agreement_checks( before 2a/2b" cost-ordering precedent: 1. moderator-flag signal (no query, no-op as of 2026-07-21 - see its own inline comment) 2. truncated-image veto (no query) - 3. border agreement (one `CanonicalCard` query, shared with 4/5/6) - 4. frame agreement (same query) + 3. border agreement (demotes, does not withhold - one `CanonicalCard` query, shared with 4/5/6) + 4. frame agreement (withholds - same query) 5. copyright-year era check (same query's `released_at` field) 6. collector-line artist veto (same query's `artist` field - see `artist_lexicon` below) 7. artist-OCR corroboration (same query's `artist` field) @@ -804,26 +850,34 @@ def _apply_agreement_checks( # already applies by checking this BEFORE computing blur/entropy/color stats. return JoinKeyVerdict(card_id=card_id, skip_reason=JOIN_KEY_TRUNCATED_IMAGE_SKIP_REASON, detail=detail) + # Initialised here, before the border/copyright/artist checks below, rather than at its old + # location right before the artist-OCR corroboration check - the border check now needs to + # DEMOTE this value too (see immediately below), so it must exist before that check runs. + confidence = base_confidence + canonical = CanonicalCard.objects.filter(pk=matched.pk).select_related("printing_metadata", "artist").first() metadata = getattr(canonical, "printing_metadata", None) if canonical is not None else None if metadata is not None: - # THE BORDER AND FRAME AGREEMENT VETOES (module docstring), both of them, in one shared - # call. The implementation moved to `local_identify_printing_tags. - # printing_attribute_disagreement` on 2026-07-30 - unchanged in behaviour, and moved for a - # specific reason rather than for tidiness: `run_name_frequency_elimination` deduces a - # printing purely by COUNTING, with no look at the image at all, and the owner-ruled fix - # for that unsoundness is to require this exact cross-check. Two implementations of a - # check whose frame half has a STRICT missing-data degradation (PR #656, and that - # function's own docstring) is precisely the shape that has drifted three times in this - # project, so there is now ONE implementation and two callers. + # THE BORDER DEMOTION / FRAME VETO (module docstring), both derived from one shared call. + # The implementation moved to `local_identify_printing_tags.printing_attribute_disagreement` + # on 2026-07-30 - moved for a specific reason rather than for tidiness: + # `run_name_frequency_elimination` deduces a printing purely by COUNTING, with no look at + # the image at all, and the owner-ruled fix for that unsoundness is to require this exact + # cross-check. Two implementations of a check whose frame half has a STRICT missing-data + # degradation (PR #656, and that function's own docstring) is precisely the shape that has + # drifted three times in this project, so there is now ONE implementation and two callers. # # The mapping back to THIS calculator's own skip vocabulary stays here: the shared helper - # names the FINDING, each calculator names its own SKIP. The two constants below are - # unchanged and still what `question_feed` reads. + # names the FINDING, each calculator decides what to DO about it. FRAME is unchanged and + # still a hard veto - `question_feed` still reads JOIN_KEY_FRAME_MISMATCH_SKIP_REASON as a + # live write value. BORDER no longer withholds (2026-08-04, owner ruling - see + # JOIN_KEY_CONFIDENCE_BORDER_DISAGREEMENT's own comment for the full reasoning): the match + # still proceeds, just at a demoted confidence, exactly like the artist-OCR disagreement + # check below rather than like the frame veto beside it. disagreement = printing_attribute_disagreement(evidence, metadata) if disagreement == ATTRIBUTE_BORDER_MISMATCH: - return JoinKeyVerdict(card_id=card_id, skip_reason=JOIN_KEY_BORDER_MISMATCH_SKIP_REASON, detail=detail) + confidence = min(confidence, JOIN_KEY_CONFIDENCE_BORDER_DISAGREEMENT) if disagreement == ATTRIBUTE_FRAME_MISMATCH: return JoinKeyVerdict(card_id=card_id, skip_reason=JOIN_KEY_FRAME_MISMATCH_SKIP_REASON, detail=detail) @@ -876,16 +930,19 @@ def _apply_agreement_checks( if recovered_artist is not None and not recovered_artist.is_compatible_with(canonical.artist.name): return JoinKeyVerdict(card_id=card_id, skip_reason=JOIN_KEY_ARTIST_MISMATCH_SKIP_REASON, detail=detail) - confidence = base_confidence if evidence.artist_ocr_name and canonical is not None: # ARTIST-OCR CORROBORATION (module docstring) - match_artist returns None (no surviving # candidate cleared its own fuzzy-ratio threshold) on a genuine disagreement; a set - # containing matched.pk (the only candidate passed in) means agreement, left at base - # confidence rather than boosted (the directive only asks for a disagreement to weaken - # a hit, not for agreement to strengthen one beyond its own join-key-derived tier). + # containing matched.pk (the only candidate passed in) means agreement, left unchanged + # rather than boosted (the directive only asks for a disagreement to weaken a hit, not + # for agreement to strengthen one beyond its own join-key-derived tier). `min(...)`, not a + # plain assignment, so a card that ALSO carries a border disagreement (above) keeps the + # LOWER of the two demotions rather than this check silently overwriting that one - see + # JOIN_KEY_CONFIDENCE_BORDER_DISAGREEMENT's own comment for why neither check should win + # by virtue of running last. surviving = match_artist(evidence.artist_ocr_name, [matched], {matched.pk: canonical.artist.name}) if surviving is None: - confidence = JOIN_KEY_CONFIDENCE_ARTIST_DISAGREEMENT + confidence = min(confidence, JOIN_KEY_CONFIDENCE_ARTIST_DISAGREEMENT) return JoinKeyVerdict(card_id=card_id, printing_pk=matched.pk, confidence=confidence, detail=detail) diff --git a/MPCAutofill/cardpicker/tests/test_local_calculate_verdicts.py b/MPCAutofill/cardpicker/tests/test_local_calculate_verdicts.py index 0d2031343..a171fc711 100644 --- a/MPCAutofill/cardpicker/tests/test_local_calculate_verdicts.py +++ b/MPCAutofill/cardpicker/tests/test_local_calculate_verdicts.py @@ -40,6 +40,7 @@ JOIN_KEY_ARTIST_MISMATCH_SKIP_REASON, JOIN_KEY_BORDER_MISMATCH_SKIP_REASON, JOIN_KEY_CONFIDENCE_ARTIST_DISAGREEMENT, + JOIN_KEY_CONFIDENCE_BORDER_DISAGREEMENT, JOIN_KEY_CONFIDENCE_BOTH, JOIN_KEY_CONFIDENCE_COLLECTOR_ONLY, JOIN_KEY_CONFIDENCE_SYMBOL_TIEBREAK, @@ -1253,7 +1254,11 @@ class TestAgreementChecks: uses, with a REAL backing `CanonicalCard`/`CanonicalPrintingMetadata` row where a check needs one to compare against.""" - def test_border_mismatch_withholds_the_match(self, db): + def test_border_mismatch_casts_the_vote_at_a_demoted_confidence(self, db): + """2026-08-04 owner ruling: a border disagreement no longer withholds the match (retired + `JOIN_KEY_BORDER_MISMATCH_SKIP_REASON` as a write value) - the join-key match still + "matches exactly", so the vote is cast, just at `JOIN_KEY_CONFIDENCE_BORDER_DISAGREEMENT` + rather than the full base tier.""" printing = CanonicalCardFactory(name="Test Card", expansion__code="mom", collector_number="158") CanonicalPrintingMetadataFactory(canonical_card=printing, border_color="white") card = CardFactory(name="Test Card") @@ -1267,8 +1272,35 @@ def test_border_mismatch_withholds_the_match(self, db): verdict = calculate_join_key_verdict(card.pk, evidence, candidates) - assert verdict.skip_reason == "border-mismatch" - assert verdict.printing_pk is None + assert verdict.printing_pk == printing.pk + assert verdict.skip_reason == "" + assert verdict.confidence == JOIN_KEY_CONFIDENCE_BORDER_DISAGREEMENT + + def test_border_and_artist_disagreement_together_take_the_lower_confidence(self, db): + """When a card carries BOTH a border disagreement and an artist-OCR disagreement, + `_apply_agreement_checks` takes the LOWER of the two tiers - here + JOIN_KEY_CONFIDENCE_ARTIST_DISAGREEMENT (0.65), which sits below + JOIN_KEY_CONFIDENCE_BORDER_DISAGREEMENT (0.70) - rather than whichever check happens to + run last silently overwriting the other's demotion.""" + printing = CanonicalCardFactory( + name="Test Card", expansion__code="mom", collector_number="158", artist__name="Rebecca Guay" + ) + CanonicalPrintingMetadataFactory(canonical_card=printing, border_color="white") + card = CardFactory(name="Test Card") + candidates = [CandidatePrinting(pk=printing.pk, expansion_code="mom", collector_number="158")] + evidence = _evidence( + card, + collector_line_set_code="mom", + collector_line_collector_number="158", + layout_class="black", # disagrees with the printing's real "white" border_color + artist_ocr_name="Someone Totally Different", + ) + + verdict = calculate_join_key_verdict(card.pk, evidence, candidates) + + assert verdict.printing_pk == printing.pk + assert verdict.confidence == JOIN_KEY_CONFIDENCE_ARTIST_DISAGREEMENT + assert JOIN_KEY_CONFIDENCE_ARTIST_DISAGREEMENT < JOIN_KEY_CONFIDENCE_BORDER_DISAGREEMENT def test_border_agreement_does_not_veto_the_match(self, db): printing = CanonicalCardFactory(name="Test Card", expansion__code="mom", collector_number="158") @@ -1722,10 +1754,12 @@ def test_truncated_image_does_not_affect_a_genuine_no_match(self, db): assert verdict.is_no_match is True assert verdict.skip_reason == "" - def test_border_mismatch_writes_a_scan_log_row_via_the_full_runner(self, db): - """Integration check (module docstring's rescannability deviation): a border/frame - mismatch is a permanent skip, not added to JOIN_KEY_RESCANNABLE_SKIP_REASONS - confirmed - here via the real batch runner rather than only the pure-function unit tests above.""" + def test_border_mismatch_writes_a_demoted_vote_via_the_full_runner(self, db): + """Integration check, updated for the 2026-08-04 border-demotion correction: the real + batch runner now casts a `CardPrintingTag` at `JOIN_KEY_CONFIDENCE_BORDER_DISAGREEMENT` + for a border-mismatched card instead of writing a `border-mismatch` `CardScanLog` row - + confirmed here via the real batch runner rather than only the pure-function unit tests + above.""" card = CardFactory(name="Test Card", content_phash=42) printing = CanonicalCardFactory(name="Test Card", expansion__code="mom", collector_number="158") CanonicalPrintingMetadataFactory(canonical_card=printing, border_color="white") @@ -1733,23 +1767,20 @@ def test_border_mismatch_writes_a_scan_log_row_via_the_full_runner(self, db): result = run_join_key_calculator(dry_run=False) - assert result.votes_written == 0 - log = CardScanLog.objects.get(card=card) - assert log.skip_reason == "border-mismatch" + assert result.votes_written == 1 + assert not CardScanLog.objects.filter( + card=card, anonymous_id=JOIN_KEY_ANONYMOUS_ID, skip_reason=JOIN_KEY_BORDER_MISMATCH_SKIP_REASON + ).exists() + vote = CardPrintingTag.objects.get(card=card, anonymous_id=JOIN_KEY_ANONYMOUS_ID) + assert vote.printing_id == printing.pk + assert vote.confidence == JOIN_KEY_CONFIDENCE_BORDER_DISAGREEMENT - # Non-rescannable WITHIN A RUN (2026-07-29 run-scoping): re-running under the SAME run_id - # does not re-select the card, which is what makes a killed run resume. A NEW run DOES - # re-select it - a prior run's abstention is history, not a permanent verdict - and - # reaches the same conclusion again, which is the point: a repaired engine can now - # revisit what a broken one skipped, without the version bump `stage-d-illustration-v2` - # needed for exactly this reason. + # A card this identity already voted on (any confidence) is excluded from the SAME run's + # own eligibility - the ordinary "already voted" idempotence every join-key vote gets, + # nothing border-mismatch-specific about it now that it is a real vote and not a skip. same_run = run_join_key_calculator(run_id=result.run_id, dry_run=False) assert same_run.cards_considered == 0 - second = run_join_key_calculator(run_id="a-later-run", dry_run=False) - assert second.cards_considered == 1 - assert second.skip_counts.get("border-mismatch") == 1 - class TestCopyrightYearEraCheck: """Task 2 (Stage D cheap deductions, issue #152): the legal-line copyright year cross-checked @@ -3541,13 +3572,14 @@ def test_an_agreeing_frame_still_matches_when_artist_ocr_is_absent(self, db): assert verdict.printing_pk == printing.pk assert verdict.confidence == JOIN_KEY_CONFIDENCE_BOTH - def test_the_border_veto_is_unaffected_by_the_frame_gate(self, db): + def test_the_border_demotion_is_unaffected_by_the_frame_gate(self, db): """The gate is scoped to the frame check alone. A border mismatch on a card with no - `artist_ocr` must still withhold - `layout_class` comes from a different extractor and its - own degradation is permissive, which is a separate question this PR does not touch.""" - printing = CanonicalCardFactory(name="Border Still Vetoes", expansion__code="mom", collector_number="158") + `artist_ocr` must still demote confidence (2026-08-04 border-demotion correction) - + `layout_class` comes from a different extractor and its own degradation is permissive, + which is a separate question this PR does not touch.""" + printing = CanonicalCardFactory(name="Border Still Demotes", expansion__code="mom", collector_number="158") CanonicalPrintingMetadataFactory(canonical_card=printing, border_color="white", frame="2015") - card = CardFactory(name="Border Still Vetoes") + card = CardFactory(name="Border Still Demotes") candidates = [CandidatePrinting(pk=printing.pk, expansion_code="mom", collector_number="158")] evidence = _evidence( card, @@ -3559,4 +3591,5 @@ def test_the_border_veto_is_unaffected_by_the_frame_gate(self, db): verdict = calculate_join_key_verdict(card.pk, evidence, candidates) - assert verdict.skip_reason == JOIN_KEY_BORDER_MISMATCH_SKIP_REASON + assert verdict.printing_pk == printing.pk + assert verdict.confidence == JOIN_KEY_CONFIDENCE_BORDER_DISAGREEMENT diff --git a/docs/reference/skip-reasons.md b/docs/reference/skip-reasons.md index 3de186a23..9c67d20f4 100644 --- a/docs/reference/skip-reasons.md +++ b/docs/reference/skip-reasons.md @@ -215,18 +215,18 @@ of these strings are also emitted by other calculators with a different meaning, which is why each carries a `JOIN_KEY_` prefixed constant rather than sharing one. -| Reason | Constant | Means | Status | -| ------------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `no-evidence` | `JOIN_KEY_NO_EVIDENCE_SKIP_REASON` | No current `ImageEvidence` row exists for this card yet. **Rescannable** — a transient state, not a conclusion. | Live (~503k rows, the largest single cohort) | -| `no-text` | `JOIN_KEY_NO_TEXT_SKIP_REASON` | The stored collector-line evidence parsed no collector number. | Live (~81k rows) | -| `ambiguous` | `JOIN_KEY_AMBIGUOUS_SKIP_REASON` | The parsed join key matches more than one candidate printing. | Live (~154 rows) | -| `unknown-set-code` | `JOIN_KEY_UNKNOWN_SET_CODE_SKIP_REASON` | The set-code lexicon gate: a parsed `set_code` matching no `CanonicalExpansion.code`. A permanent conclusion, deliberately NOT rescannable. | Live (~46k rows) | -| `artist-mismatch` | `JOIN_KEY_ARTIST_MISMATCH_SKIP_REASON` | The collector-line artist gate: the printing the join key resolves to has an artist incompatible with the one printed on the card, so the parse contradicts its own source string. | Live (shipped 2026-07-29, no rows yet at measurement time) | -| `border-mismatch` | `JOIN_KEY_BORDER_MISMATCH_SKIP_REASON` | The corroboration layer: observed border colour contradicts the matched printing's own border value; the vote is withheld. | Live (~15k rows) | -| `frame-mismatch` | `JOIN_KEY_FRAME_MISMATCH_SKIP_REASON` | As above, for frame style. | Live (~1.3k rows) | -| `truncated-image` | `JOIN_KEY_TRUNCATED_IMAGE_SKIP_REASON` | The source image is truncated, so the evidence underneath the join key cannot be trusted. | Live | -| `copyright-year-mismatch` | `JOIN_KEY_COPYRIGHT_YEAR_MISMATCH_SKIP_REASON` | The legal line's copyright year contradicts the matched printing's release. | Live (~40 rows) | -| `proxy-marker-veto` | `JOIN_KEY_PROXY_MARKER_VETO_SKIP_REASON` | A moderator proxy-marker flag vetoed an otherwise clean join-key match. | **Retired 2026-07-21.** Nothing writes it; kept declared and kept in `JOIN_KEY_NO_HIT_SKIP_REASONS` so historical rows still route to review. Retract with `reparse_collector_evidence --selector proxy-marker-veto`. | +| Reason | Constant | Means | Status | +| ------------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `no-evidence` | `JOIN_KEY_NO_EVIDENCE_SKIP_REASON` | No current `ImageEvidence` row exists for this card yet. **Rescannable** — a transient state, not a conclusion. | Live (~503k rows, the largest single cohort) | +| `no-text` | `JOIN_KEY_NO_TEXT_SKIP_REASON` | The stored collector-line evidence parsed no collector number. | Live (~81k rows) | +| `ambiguous` | `JOIN_KEY_AMBIGUOUS_SKIP_REASON` | The parsed join key matches more than one candidate printing. | Live (~154 rows) | +| `unknown-set-code` | `JOIN_KEY_UNKNOWN_SET_CODE_SKIP_REASON` | The set-code lexicon gate: a parsed `set_code` matching no `CanonicalExpansion.code`. A permanent conclusion, deliberately NOT rescannable. | Live (~46k rows) | +| `artist-mismatch` | `JOIN_KEY_ARTIST_MISMATCH_SKIP_REASON` | The collector-line artist gate: the printing the join key resolves to has an artist incompatible with the one printed on the card, so the parse contradicts its own source string. | Live (shipped 2026-07-29, no rows yet at measurement time) | +| `border-mismatch` | `JOIN_KEY_BORDER_MISMATCH_SKIP_REASON` | The corroboration layer: observed border colour contradicts the matched printing's own border value. | **Retired 2026-08-04.** Nothing writes it; kept declared and kept in `JOIN_KEY_NO_HIT_SKIP_REASONS` so historical rows still route to review. A border disagreement now casts the match at a demoted confidence (`JOIN_KEY_CONFIDENCE_BORDER_DISAGREEMENT`) instead of withholding it - no retraction command needed for stale rows; a plain re-run under a fresh `run_id` revisits and votes on them normally. | +| `frame-mismatch` | `JOIN_KEY_FRAME_MISMATCH_SKIP_REASON` | As above, for frame style. | Live (~1.3k rows) | +| `truncated-image` | `JOIN_KEY_TRUNCATED_IMAGE_SKIP_REASON` | The source image is truncated, so the evidence underneath the join key cannot be trusted. | Live | +| `copyright-year-mismatch` | `JOIN_KEY_COPYRIGHT_YEAR_MISMATCH_SKIP_REASON` | The legal line's copyright year contradicts the matched printing's release. | Live (~40 rows) | +| `proxy-marker-veto` | `JOIN_KEY_PROXY_MARKER_VETO_SKIP_REASON` | A moderator proxy-marker flag vetoed an otherwise clean join-key match. | **Retired 2026-07-21.** Nothing writes it; kept declared and kept in `JOIN_KEY_NO_HIT_SKIP_REASONS` so historical rows still route to review. Retract with `reparse_collector_evidence --selector proxy-marker-veto`. | `transferred-interim-guard` (`TRANSFERRED_INTERIM_GUARD_SKIP_REASON`) is declared in the same module and belongs to both this calculator and the