Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 62 additions & 4 deletions MPCAutofill/cardpicker/local_fallback.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,42 @@
# a ground-truth reading is than an estimate from the same OCR-weight source.
GROUND_TRUTH_ATTRIBUTE_VOTE_CONFIDENCE = 0.95

# THIS MODULE'S SKIP VOCABULARY (2026-07-29, see docs/reference/skip-reasons.md).
# `run_fallback_for_card` below returns these three strings on `FallbackOutcome.skip_reason`.
# They are LATENT roster members, not live ones: the module's own `CardScanLog`/printing-vote
# write branch was retired on 2026-07-29 (see this module's docstring), and its one non-test
# caller - `local_residual_classify.recover_frame_mismatch_printing_via_fallback_refetch` -
# reads `outcome.printing_pk` and discards `skip_reason`. Nothing persists them TODAY. They go
# live the moment anything does, which is exactly why they must be enumerable BEFORE that
# happens: the docs_lint roster tether derives the roster from module-level
# `*_SKIP_REASON = "<literal>"` declarations and CANNOT see a bare inline literal, so a new
# literal added inside `run_fallback_for_card` plus one new write would reach
# `CardScanLog.skip_reason` (~2.7M rows, no `choices` list, no FK) with no lint failure anywhere.
#
# THIS FILE IS PROTECTED CORE (docs/upstreaming/license-provenance.md section 2). These three
# declarations exist under a NARROW, EXPLICIT owner exception granted 2026-07-29 and recorded in
# section 2.1 there - it authorises declaring skip-reason constants HERE and nothing else. The
# file remains protected; any other change to it still needs its own ruling.
#
# Naming: `LOCAL_FALLBACK_` and not `FALLBACK_`, because `local_calculate_verdicts` already
# declares a `FALLBACK_*_SKIP_REASON` family for the Stage D `stage-d-fallback-v1` calculator -
# a DIFFERENT calculator with its own rows and its own vocabulary, which deliberately shares
# `eliminated`/`ambiguous` verbatim with this module and deliberately renames this module's
# `no-evidence` to `no-sub-check-evidence`. Those are not mirrors of these and must not be
# collapsed into them; the prefix keeps the two families legible at a glance. `LOCAL_FALLBACK_`
# matches this module's own `FALLBACK_ANONYMOUS_ID = "local-fallback-v1"` identity.
#
# Each declaration must stay on ONE line starting at column 0 - that is the shape the tether's
# regex matches. Comments go ABOVE, never trailing: a trailing comment long enough to push the
# line past black's limit makes black wrap the value in parentheses, silently dropping the
# constant out of the derived roster.
# Not one sub-check (border, artist, symbol) produced a reading at all.
LOCAL_FALLBACK_NO_EVIDENCE_SKIP_REASON = "no-evidence"
# Sub-checks ran and ruled out every candidate - zero survivors.
LOCAL_FALLBACK_ELIMINATED_SKIP_REASON = "eliminated"
# More than one candidate survived the sub-check intersection.
LOCAL_FALLBACK_AMBIGUOUS_SKIP_REASON = "ambiguous"

# ---------------------------------------------------------------------------------------------
# 2a: artist OCR - full-width bottom band (not pass 1's narrower left-side collector-line crop):
# old border prints "Illus. <name>" centred at the very bottom; modern frames put the artist
Expand Down Expand Up @@ -635,6 +671,9 @@ def cast_bleed_edge_vote(card: Card, bleed_class: Optional[str], run_id: Optiona
class FallbackOutcome:
printing_pk: Optional[int] = None
evidence_types_used: list[str] = field(default_factory=list)
# One of this module's three `LOCAL_FALLBACK_*_SKIP_REASON` constants, or `""` for "matched,
# not a skip". Anything set here must be a named module-level constant, never a bare literal:
# the roster tether cannot enumerate what it cannot see. See the declarations at the top.
skip_reason: str = ""
# whether the "Illus." anchor was found at all (independent of whether the extracted name
# went on to fuzzy-match a candidate) - the frame-style classifier's "old border" signal,
Expand All @@ -654,7 +693,17 @@ def run_fallback_for_card(
redundant tesseract call on cards where the artist line already happened to be visible in
the narrower pass-1 crop. `bleed_class` (from classify_bleed_edge, run once per card ahead
of everything else - see run_pilot) is threaded through to every sub-check's own
fixed-fraction crop box via normalize_crop_box."""
fixed-fraction crop box via normalize_crop_box.

`FallbackOutcome.skip_reason` is one of LOCAL_FALLBACK_NO_EVIDENCE_SKIP_REASON
("no-evidence" - not one sub-check produced a reading), LOCAL_FALLBACK_ELIMINATED_SKIP_REASON
("eliminated" - zero survivors), LOCAL_FALLBACK_AMBIGUOUS_SKIP_REASON ("ambiguous" - more
than one survivor), or "" (matched).

Any NEW skip reason added here must be declared as a module-level `*_SKIP_REASON` constant
above and documented in docs/reference/skip-reasons.md - a bare literal returned from here
is invisible to the roster tether, and would reach `CardScanLog` unnoticed the moment
anything persists this outcome."""
candidate_pks = {c.pk for c in selected.candidates}
canonicals = {
c.pk: c
Expand Down Expand Up @@ -684,14 +733,20 @@ def run_fallback_for_card(
evidence_types_used.append(name)

if not evidence_types_used:
return FallbackOutcome(skip_reason="no-evidence", illus_anchor_fired=illus_anchor_fired)
return FallbackOutcome(
skip_reason=LOCAL_FALLBACK_NO_EVIDENCE_SKIP_REASON, illus_anchor_fired=illus_anchor_fired
)
if len(survivors) == 0:
return FallbackOutcome(
skip_reason="eliminated", evidence_types_used=evidence_types_used, illus_anchor_fired=illus_anchor_fired
skip_reason=LOCAL_FALLBACK_ELIMINATED_SKIP_REASON,
evidence_types_used=evidence_types_used,
illus_anchor_fired=illus_anchor_fired,
)
if len(survivors) > 1:
return FallbackOutcome(
skip_reason="ambiguous", evidence_types_used=evidence_types_used, illus_anchor_fired=illus_anchor_fired
skip_reason=LOCAL_FALLBACK_AMBIGUOUS_SKIP_REASON,
evidence_types_used=evidence_types_used,
illus_anchor_fired=illus_anchor_fired,
)

return FallbackOutcome(
Expand All @@ -707,6 +762,9 @@ def run_fallback_for_card(
"FALLBACK_CONFIDENCE_SINGLE_EVIDENCE",
"BORDER_ATTRIBUTE_VOTE_CONFIDENCE",
"GROUND_TRUTH_ATTRIBUTE_VOTE_CONFIDENCE",
"LOCAL_FALLBACK_NO_EVIDENCE_SKIP_REASON",
"LOCAL_FALLBACK_ELIMINATED_SKIP_REASON",
"LOCAL_FALLBACK_AMBIGUOUS_SKIP_REASON",
"BORDER_COLOR_TO_TAG",
"FRAME_VOTE_CONFIDENCE",
"FRAME_STYLE_TO_TAG",
Expand Down
28 changes: 17 additions & 11 deletions MPCAutofill/cardpicker/local_identify_printing_tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
local_phash,
)
from cardpicker.local_fallback import FALLBACK_ANONYMOUS_ID
from cardpicker.local_phash import PHASH_NO_CLEAR_WINNER_SKIP_REASON
from cardpicker.models import (
CanonicalCard,
Card,
Expand Down Expand Up @@ -167,15 +168,18 @@

# The phash engine's own outcomes (`run_phash_for_card`).
PHASH_TOO_MANY_CANDIDATES_SKIP_REASON = "too-many-candidates"
# `local_phash.find_best_match` is PROTECTED CORE (docs/upstreaming/license-provenance.md §2) and
# returns these two strings as its own inline literals; they cannot be declared at their true
# source without editing a protected file. They are MIRRORED here - the one roster entry whose
# declaration is not co-located with its origin - and used for the equality test below so the
# coupling is at least named rather than anonymous. NO_CLEAR_WINNER is never written to
# `CardScanLog` by this module today (`_classify_no_clear_winner` always refines it into one of
# the two variants below); HISTORICAL rows predating that refinement still carry it.
PHASH_NO_HASHABLE_CANDIDATES_SKIP_REASON = "no-hashable-candidates"
PHASH_NO_CLEAR_WINNER_SKIP_REASON = "no-clear-winner"
# `no-hashable-candidates` and `no-clear-winner` are NOT declared here. They originate inside
# `local_phash.find_best_match` and, as of the 2026-07-29 protected-core exception
# (docs/upstreaming/license-provenance.md §2), are declared THERE as
# `PHASH_NO_HASHABLE_CANDIDATES_SKIP_REASON` / `PHASH_NO_CLEAR_WINNER_SKIP_REASON`. They were
# briefly mirrored here because that file is protected core and could not be edited; the mirror is
# gone, so there is ONE declaration per value and nothing that can drift. This module IMPORTS the
# one it needs (see the import block above) rather than re-declaring it.
#
# The two refinements below ARE this module's own: `_classify_no_clear_winner` splits phash's
# undifferentiated `no-clear-winner` into a threshold miss and a margin miss, so plain
# `no-clear-winner` is never written to `CardScanLog` by this module today - HISTORICAL rows
# predating that refinement still carry it.
PHASH_NO_CLEAR_WINNER_DISTANCE_SKIP_REASON = "no-clear-winner-distance"
PHASH_NO_CLEAR_WINNER_MARGIN_SKIP_REASON = "no-clear-winner-margin"

Expand Down Expand Up @@ -1994,8 +1998,10 @@ def verify_zero_resolutions(card_ids: list[int], batch_size: int = 2000) -> list
"OCR_UNKNOWN_SET_CODE_SKIP_REASON",
"PARSED_BUT_NO_MATCH_SKIP_REASON",
"PHASH_TOO_MANY_CANDIDATES_SKIP_REASON",
"PHASH_NO_HASHABLE_CANDIDATES_SKIP_REASON",
"PHASH_NO_CLEAR_WINNER_SKIP_REASON",
# `PHASH_NO_HASHABLE_CANDIDATES_SKIP_REASON` / `PHASH_NO_CLEAR_WINNER_SKIP_REASON` are
# deliberately absent: they are declared and exported by `cardpicker.local_phash`, their
# origin. Re-exporting them from here would recreate the two-names-one-value ambiguity that
# removing the mirror was meant to end.
"PHASH_NO_CLEAR_WINNER_DISTANCE_SKIP_REASON",
"PHASH_NO_CLEAR_WINNER_MARGIN_SKIP_REASON",
"RESCANNABLE_SKIP_REASONS",
Expand Down
46 changes: 37 additions & 9 deletions MPCAutofill/cardpicker/local_phash.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,27 @@
# win either one already gets over full resolution.
INGEST_HASH_FETCH_DPI = 40

# THIS MODULE'S SKIP VOCABULARY (2026-07-29, see docs/reference/skip-reasons.md). `find_best_match`
# below returns these two strings; via `local_identify_printing_tags.run_phash_for_card` they reach
# `CardScanLog.skip_reason`, so they are roster members and must be statically enumerable - the
# docs_lint roster tether derives the roster from module-level `*_SKIP_REASON = "<literal>"`
# declarations and CANNOT see a bare inline literal, which is exactly the hole a new literal added
# inside `find_best_match` would have fallen through.
#
# THIS FILE IS PROTECTED CORE (docs/upstreaming/license-provenance.md section 2). These two
# declarations exist under a NARROW, EXPLICIT owner exception granted 2026-07-29, recorded in that
# section - it authorises declaring skip-reason constants HERE and nothing else. The file remains
# protected; any other change to it still needs its own ruling.
#
# Naming: the `PHASH_` prefix is kept even though this module is already the phash engine, because
# these names moved here VERBATIM from `local_identify_printing_tags`, where they were mirrored,
# and because the consuming module reads them alongside its own
# `PHASH_NO_CLEAR_WINNER_{DISTANCE,MARGIN}_SKIP_REASON` refinements as one family. Keeping the
# names byte-identical (not only the values) is what makes this change nothing but a move: the
# roster's pinning test and the doc's Constant column needed no edit at all.
PHASH_NO_HASHABLE_CANDIDATES_SKIP_REASON = "no-hashable-candidates"
PHASH_NO_CLEAR_WINNER_SKIP_REASON = "no-clear-winner"


def _hash_to_int(image_hash: "imagehash.ImageHash") -> int:
return twos_complement(str(image_hash), _HASH_BITS)
Expand Down Expand Up @@ -376,15 +397,20 @@ def find_best_match(
margin: int = DEFAULT_MARGIN,
) -> tuple[Optional[PhashMatch], str]:
"""
Returns (match, skip_reason). skip_reason is "no-hashable-candidates" (every candidate
failed to fetch/hash), "no-clear-winner" (best distance is over threshold, or the runner-up
is too close behind it), or "" (matched). Requires at least 2 hashed candidates to compute a
margin at all when there's more than one name-candidate in the first place; a genuinely
single-candidate name (already excluded by the orchestrator's selection - phash only runs on
multi-candidate names in practice) would just need the threshold.
Returns (match, skip_reason). skip_reason is PHASH_NO_HASHABLE_CANDIDATES_SKIP_REASON
("no-hashable-candidates" - every candidate failed to fetch/hash),
PHASH_NO_CLEAR_WINNER_SKIP_REASON ("no-clear-winner" - best distance is over threshold, or the
runner-up is too close behind it), or "" (matched). Requires at least 2 hashed candidates to
compute a margin at all when there's more than one name-candidate in the first place; a
genuinely single-candidate name (already excluded by the orchestrator's selection - phash only
runs on multi-candidate names in practice) would just need the threshold.

Any NEW skip reason added here must be declared as a module-level `*_SKIP_REASON` constant
above and documented in docs/reference/skip-reasons.md - a bare literal returned from here
reaches `CardScanLog` without the roster tether ever seeing it.
"""
if not candidates_with_hashes:
return None, "no-hashable-candidates"
return None, PHASH_NO_HASHABLE_CANDIDATES_SKIP_REASON

# card_hash and each candidate hash are both plain ints (the DB storage representation) -
# ImageHash's `-` operator (Hamming distance) needs two ImageHash objects, not raw ints.
Expand All @@ -400,9 +426,9 @@ def find_best_match(
runner_up_distance = scored[1][1] if len(scored) > 1 else None

if best_distance > distance_threshold:
return None, "no-clear-winner"
return None, PHASH_NO_CLEAR_WINNER_SKIP_REASON
if runner_up_distance is not None and (runner_up_distance - best_distance) <= margin:
return None, "no-clear-winner"
return None, PHASH_NO_CLEAR_WINNER_SKIP_REASON

return PhashMatch(candidate=best_candidate, distance=best_distance, runner_up_distance=runner_up_distance), ""

Expand All @@ -412,6 +438,8 @@ def find_best_match(
"DEFAULT_MARGIN",
"ART_CROP_BOX",
"INGEST_HASH_FETCH_DPI",
"PHASH_NO_HASHABLE_CANDIDATES_SKIP_REASON",
"PHASH_NO_CLEAR_WINNER_SKIP_REASON",
"DEFAULT_BACKFILL_BATCH_SIZE",
"DEFAULT_BACKFILL_WORKERS",
"DEFAULT_PIPELINE_QUEUE_DEPTH_BATCHES",
Expand Down
Loading
Loading