diff --git a/MPCAutofill/cardpicker/image_evidence.py b/MPCAutofill/cardpicker/image_evidence.py index 60b3ec3f7..d2d26fa21 100644 --- a/MPCAutofill/cardpicker/image_evidence.py +++ b/MPCAutofill/cardpicker/image_evidence.py @@ -370,6 +370,20 @@ def _contains_digit(text: str) -> bool: return any(character.isdigit() for character in text) +def _parse_is_lexicon_valid(parsed: Any, known_set_codes: Optional[frozenset[str]]) -> bool: + """SET-CODE LEXICON GATE (2026-07-23, issue #370's recorded follow-up - see + `compute_card_evidence`'s own `known_set_codes` docstring paragraph for the full autopsy): + gates the escalation loop's ACCEPTANCE criterion, not whether escalation starts. True iff a + `collector_number`-bearing `parsed` result (an `OcrParseResult`) is eligible to terminate the + loop below - either it never found a set-code-shaped token at all (`set_code is None`, the + pre-M15 collector-number-only case - deliberately UNAFFECTED by this gate, the same carve-out + `calculate_join_key_verdict`'s own SET-CODE LEXICON GATE uses, per that function's own + docstring: "the gate only ever applies when a set-code-shaped token was actually found"), or + the gate is disabled (`known_set_codes is None` - no lexicon threaded, e.g. an older direct + caller/test), or the parsed `set_code` is a real `CanonicalExpansion.code` member.""" + return parsed.set_code is None or known_set_codes is None or parsed.set_code in known_set_codes + + def _confidently_digit_free(tier1_raw_texts: list[str]) -> bool: """Gate for the pre-classification short-circuit (2026-07-22, pipeline-fidelity parity replay #154 "unexplained" divergence autopsy - 155 of 373 conservative-abstention @@ -393,6 +407,7 @@ def extract_card_evidence( dpi: Optional[int] = DEFAULT_FETCH_DPI, profile: Optional[dict[str, float]] = None, short_circuit: Optional[bool] = None, + known_set_codes: Optional[frozenset[str]] = None, ) -> ExtractionResult: """ The per-card callable work unit - fetch, then compute. `card.content_phash` (not recomputed @@ -423,6 +438,9 @@ def extract_card_evidence( `short_circuit`, if given, is forwarded straight through to `compute_card_evidence` below - see that function's own docstring for the pre-classification short-circuit this controls. + + `known_set_codes`, if given, is forwarded straight through to `compute_card_evidence` below - + see that function's own docstring for the escalation-loop lexicon gate this controls. """ fetch_started_at = time.monotonic() @@ -436,7 +454,13 @@ def extract_card_evidence( fetch_latency_ms = (time.monotonic() - fetch_started_at) * 1000 return compute_card_evidence( - card.pk, card.content_phash, image, fetch_latency_ms, profile=profile, short_circuit=short_circuit + card.pk, + card.content_phash, + image, + fetch_latency_ms, + profile=profile, + short_circuit=short_circuit, + known_set_codes=known_set_codes, ) @@ -447,6 +471,7 @@ def compute_card_evidence( fetch_latency_ms: float, profile: Optional[dict[str, float]] = None, short_circuit: Optional[bool] = None, + known_set_codes: Optional[frozenset[str]] = None, ) -> ExtractionResult: """ Compute-only continuation of `extract_card_evidence` above - everything that function does @@ -498,6 +523,37 @@ def compute_card_evidence( (a strict subset of what used to qualify still does), so its own worst-case-cost bound above is unaffected; the perf win is simply now scoped to genuinely-content-bearing, genuinely digit-free reads rather than also swallowing outright read failures. + + `known_set_codes` (2026-07-23, issue #370's own recorded follow-up - see this module's + `_collector_line_ocr_attempts` docstring and the escalation loop below for the mechanism): + the SET-CODE LEXICON GATE `local_calculate_verdicts.known_set_codes()` produces, threaded + straight through from the caller rather than queried here - this function never issues its + own DB query, matching that helper's own "built once per batch, passed through explicitly" + convention (`local_calculate_verdicts.run_join_key_calculator`'s identical pattern). Changes + the escalation loop's OWN acceptance criterion, not whether escalation starts (the + `short_circuit` gate above is unaffected and unchanged): a tier's parse used to terminate + escalation the instant ANY `collector_number` was found, regardless of whether the paired + `set_code` was real - a live structural finding (docs/reports/2026-07-23-ocr-preprocessing- + probe-2.md) traced 94% of a lexicon-invalid-no-match sample to exactly this, tier 1's very + first attempt accepting OCR noise that happened to regex-parse into a collector-number-shaped + token, before tiers 2-3 (built for exactly this recovery) ever got a chance to run. Now: a + parse only terminates escalation when its `set_code` is `None` (the pre-M15 collector-number- + only case, unaffected by this gate - same "only applies when a set-code-shaped token was + actually found" carve-out `calculate_join_key_verdict`'s own gate uses) OR is a real + `known_set_codes` member; a `collector_number`-bearing parse whose `set_code` is lexicon- + invalid no longer stops the loop - it's remembered as the running "best invalid candidate" + (the first such parse, by tier order) and escalation continues. If no attempt across all 8 + ever yields a lexicon-valid parse, the best invalid candidate (if any) becomes the stored + outcome - IDENTICAL to what today's pre-gate code already stored for that card (the first + `collector_number`-bearing parse it found, since old code never distinguished valid from + invalid), so `collector_line_set_code`/`collector_line_collector_number`/ + `collector_line_raw_text`/`collector_line_word_boxes` and the `"no-text"` skip-reason + threshold are BYTE-IDENTICAL to before for every card that ends up in this bucket - only the + PATH there changed (now via genuine escalation through every tier, not an early accept), never + the stored result. `None` (the default, e.g. a caller/test that doesn't pass this) disables + the gate entirely - every parse is accepted exactly as before, the pre-2026-07-23 behavior - + so this is purely additive: a card whose first parse is already lexicon-valid (the overwhelming + majority) sees zero behavior or compute change either way. """ if short_circuit is None: short_circuit = _short_circuit_enabled_by_env() @@ -609,22 +665,43 @@ def compute_card_evidence( # docstring) - and so does a BLANK tier-1 read (2026-07-22: blank is a read FAILURE, not a # confident "nothing here" signal, so it no longer qualifies either). This can only ever # short-circuit a STRICT SUBSET of cards that would have ended in "no-text" anyway, never - # a card that could have parsed at tier 1. + # a card that could have parsed at tier 1. This gate governs whether escalation STARTS + # (i.e. whether tiers 2-3 run at all) - entirely independent of the lexicon-validity + # acceptance criterion immediately below, which governs whether a tier's parse is allowed + # to STOP escalation once it's already running. + # + # SET-CODE LEXICON GATE (2026-07-23, issue #370's own recorded follow-up - see + # `compute_card_evidence`'s own `known_set_codes` docstring paragraph for the full + # autopsy/structural finding this responds to): a tier's parse used to terminate + # escalation the instant ANY `collector_number` was found, regardless of whether the + # paired `set_code` was a real one - `_parse_is_lexicon_valid` now gates that acceptance + # instead. A `collector_number`-bearing parse that fails the gate is remembered as the + # running "best invalid candidate" (`best_invalid_index`/`best_invalid_parse` - the FIRST + # such parse, by tier order) rather than accepted, and the loop keeps escalating; if no + # attempt across every tier ever produces a lexicon-valid parse, the best invalid + # candidate becomes the stored outcome below - identical to what pre-gate code already + # stored for this bucket (see that paragraph for why the two are provably the same). collector_texts_and_words: list[tuple[str, list[dict[str, Any]]]] = [] selected_index = 0 parsed = parse_collector_line("") matched = False short_circuited = False tier1_raw_texts: list[str] = [] + best_invalid_index: Optional[int] = None + best_invalid_parse: Any = None for i, (variant, config, tier) in enumerate(_collector_line_ocr_attempts(collector_crop)): raw_text, word_boxes = run_tesseract_text_and_words(variant, config=config) collector_texts_and_words.append((raw_text, word_boxes)) candidate_parse = parse_collector_line(raw_text) if candidate_parse.collector_number is not None: - parsed = candidate_parse - selected_index = i - matched = True - break + if _parse_is_lexicon_valid(candidate_parse, known_set_codes): + parsed = candidate_parse + selected_index = i + matched = True + break + if best_invalid_index is None: + best_invalid_index = i + best_invalid_parse = candidate_parse if tier == 1: tier1_raw_texts.append(raw_text) if ( @@ -634,15 +711,24 @@ def compute_card_evidence( ): short_circuited = True break - if not matched and collector_texts_and_words: - # every attempt actually tried (every tier, OR a short-circuit exit after tier 1) - # found no parse - keep the first attempt's (empty-ish) parse as the deterministic - # stored artifact, matching the pre-existing fallback precedence. Safe to reuse - # unconditionally on a short-circuit exit too: `_contains_digit` false for both tier-1 - # texts means `_COLLECTOR_NUMBER_RE` (a strict subset check - see `_contains_digit`'s - # own docstring) cannot have matched either, so re-parsing text[0] here can only ever - # reproduce the same collector_number=None outcome already implied. - parsed = parse_collector_line(collector_texts_and_words[0][0]) + if not matched: + if best_invalid_index is not None: + # every attempt either found nothing or a lexicon-invalid parse - keep the BEST + # invalid candidate (the first collector_number-bearing parse by tier order, + # matching exactly what pre-2026-07-23 code already stored for this bucket, since + # old code never distinguished valid from invalid - see this loop's own comment + # above and compute_card_evidence's own known_set_codes docstring paragraph). + parsed = best_invalid_parse + selected_index = best_invalid_index + elif collector_texts_and_words: + # no attempt ever produced ANY collector_number at all (the true "no-text" case) - + # keep the first attempt's (empty-ish) parse as the deterministic stored artifact, + # matching the pre-existing fallback precedence. Safe to reuse unconditionally on + # a short-circuit exit too: `_contains_digit` false for both tier-1 texts means + # `_COLLECTOR_NUMBER_RE` (a strict subset check - see `_contains_digit`'s own + # docstring) cannot have matched either, so re-parsing text[0] here can only ever + # reproduce the same collector_number=None outcome already implied. + parsed = parse_collector_line(collector_texts_and_words[0][0]) card_short_circuited = short_circuited collector_raw_texts = [text for text, _words in collector_texts_and_words] diff --git a/MPCAutofill/cardpicker/local_calculate_verdicts.py b/MPCAutofill/cardpicker/local_calculate_verdicts.py index bddea442e..0c1fd6ab5 100644 --- a/MPCAutofill/cardpicker/local_calculate_verdicts.py +++ b/MPCAutofill/cardpicker/local_calculate_verdicts.py @@ -198,15 +198,20 @@ low-confidence machine vote used to assert (weight `PRINTING_TAG_MACHINE_WEIGHT`, never enough to resolve consensus alone regardless). -NOT APPLIED to `cardpicker.local_identify_printing_tags`'s own live-pilot OCR engine -(`OCR_ANONYMOUS_ID="local-ocr-v1"`, `run_ocr_for_card`/`_compute_card`), which casts an -`is_no_match=True` vote from the identical `"parsed-but-no-match"` outcome at its own call site - -same underlying defect, deliberately left unfixed here: that engine fetches and processes a live -image per card (a materially larger, higher-risk surface than this module's own zero-fetch, -already-persisted-`ImageEvidence` design), no live-proven case was found against it specifically, -and it sits outside this task's own explicit "join-key calculator" scope. `known_set_codes()` -below is written so that engine's own selection loop could reuse it directly in a focused -follow-up - flagged, not silently left inconsistent. +APPLIED (2026-07-23, issue #370's own recorded follow-up - the deferred item this paragraph used +to flag) to `cardpicker.local_identify_printing_tags`'s own live-pilot OCR engine too +(`OCR_ANONYMOUS_ID="local-ocr-v1"`, `run_ocr_for_card`/`_compute_card`), which used to cast an +`is_no_match=True` vote from the identical `"parsed-but-no-match"` outcome at its own call site +with no lexicon check - same underlying defect this module's own gate above fixes. +`run_ocr_for_card` now takes an explicit `known_set_codes` argument (this exact `known_set_codes()` +helper's output, built once per `run_pilot` invocation and threaded through via +`functools.partial`, a deferred import since this module already imports FROM +`local_identify_printing_tags` - see that function's own docstring for the full mechanism) and +demotes a `parsed-but-no-match` outcome whose `set_code` is out-of-lexicon to a distinct +`"unknown-set-code"` skip (abstain, no vote) instead - the exact same semantics this module's own +gate applies, reproduced at that engine's own call site rather than shared code, since the two +engines' surrounding control flow (this module's stored-evidence read vs. that engine's live +per-variant loop) differ enough that sharing the branch itself would obscure more than it saves. TWO FURTHER CHEAP ADDITIONS (this PR, built 2026-07-20, owner decision on issue #220): diff --git a/MPCAutofill/cardpicker/local_identify_printing_tags.py b/MPCAutofill/cardpicker/local_identify_printing_tags.py index 2efd5fe0f..983314fc2 100644 --- a/MPCAutofill/cardpicker/local_identify_printing_tags.py +++ b/MPCAutofill/cardpicker/local_identify_printing_tags.py @@ -570,10 +570,36 @@ def run_ocr_for_card( image: Optional["Image.Image"], crop_box: tuple[float, float, float, float] = local_ocr.DEFAULT_CROP_BOX, bleed_class: Optional[str] = None, + known_set_codes: Optional[frozenset[str]] = None, ) -> OcrCardResult: """`bleed_class` (from local_fallback.classify_bleed_edge, run once per card ahead of everything else - see run_pilot) remaps `crop_box` via local_fallback.normalize_crop_box for - a trimmed image; a no-op otherwise.""" + a trimmed image; a no-op otherwise. + + `known_set_codes` (2026-07-23, issue #370's own recorded follow-up - the deferred item + `local_calculate_verdicts.py`'s own module docstring flagged: "known_set_codes() below is + written so that engine's own selection loop could reuse it directly in a focused follow-up"): + the SET-CODE LEXICON GATE `local_calculate_verdicts.known_set_codes()` produces, threaded + straight through from `run_pilot` (built once per invocation there - one DB query, not one + per card - via a deferred/local import, since a module-level import here would be circular: + `local_calculate_verdicts.py` already imports FROM this module). Gates which "no candidate + matched" outcome this loop reports, not whether the loop tries every variant - it already + does (no early exit on a non-match, only on a real match). A `parsed-but-no-match` variant + (`validate_against_candidates` found 0 candidates - `local_ocr.py`'s own outcome) only keeps + that label, which `run_pilot`'s own write loop casts a real, confident `is_no_match=True` vote + from, when at least one tried variant's `set_code` is either `None` (the pre-M15 collector- + number-only case, deliberately UNAFFECTED by this gate - the same carve-out + `calculate_join_key_verdict`'s own SET-CODE LEXICON GATE uses) or a real `known_set_codes` + member. If EVERY variant's `parsed-but-no-match` outcome carried an out-of-lexicon `set_code` + (un-parsed noise shaped like a set code - `local_calculate_verdicts.py`'s own module docstring + documents a live audit finding this is 85.5% of that outcome's real-world population), the + outcome demotes to `unknown-set-code` instead: a named, non-rescannable ABSTENTION (no vote + cast, the same treatment `no-text` already gets), never a confident negative the parse can't + actually back up. `None` (the default - an older/direct caller that doesn't thread this + through, e.g. a pre-2026-07-23 test) disables the gate entirely, reproducing the exact + pre-2026-07-23 behavior (every `parsed-but-no-match` outcome casts a vote, regardless of + `set_code` validity) - so this is purely additive: a card whose winning variant is a genuine + candidate match, or whose collector line is genuinely illegible, sees zero behavior change.""" if image is None: return OcrCardResult(skip_reason="unfetchable-image") @@ -591,6 +617,13 @@ def run_ocr_for_card( # something (more than one something), which is real evidence the parse was plausible, not # the same as every variant failing to match anything at all. saw_ambiguous = False + # SET-CODE LEXICON GATE (2026-07-23, issue #370's own recorded follow-up - see this + # function's own `known_set_codes` docstring paragraph above for the full mechanism): True + # once ANY tried variant's "parsed-but-no-match" outcome carries trustworthy signal (a real/ + # lexicon set code, or a genuine pre-M15 collector-number-only parse) rather than un-parsed + # noise shaped like a set code - decides below whether the final outcome keeps the real + # "parsed-but-no-match" label (casts a vote) or demotes to "unknown-set-code" (abstains). + saw_lexicon_valid_no_match = False for variant in variants: raw_text = local_ocr.run_tesseract(variant) result.raw_texts.append(raw_text) @@ -606,10 +639,19 @@ def run_ocr_for_card( return result if reason == "ambiguous": saw_ambiguous = True + elif reason == "parsed-but-no-match" and ( + parsed.set_code is None or known_set_codes is None or parsed.set_code in known_set_codes + ): + saw_lexicon_valid_no_match = True if saw_ambiguous: result.skip_reason = "ambiguous" - elif result.parsed_a_collector_number: + elif saw_lexicon_valid_no_match: result.skip_reason = "parsed-but-no-match" + elif result.parsed_a_collector_number: + # every "parsed-but-no-match" outcome this loop saw carried an out-of-lexicon set_code - + # a distinct, non-rescannable ABSTENTION (see this function's own known_set_codes + # docstring paragraph), not the confident "parsed-but-no-match" negative. + result.skip_reason = "unknown-set-code" else: result.skip_reason = "no-text" return result @@ -755,6 +797,7 @@ def _compute_card( phash_margin: int, phash_max_candidates: int, fetch_dpi: Optional[int], + 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 @@ -769,6 +812,10 @@ def _compute_card( 2026-07-15) - it's the one reading every other fixed-fraction crop box in this function needs (via local_fallback.normalize_crop_box) to know whether to correct itself for a trimmed image, so it has to be available before OCR/phash/illus-anchor/border/symbol crop. + + `known_set_codes` (2026-07-23, issue #370's own recorded follow-up): built once by + `run_pilot` and forwarded straight through to `run_ocr_for_card` - see that function's own + docstring for the SET-CODE LEXICON GATE this controls. """ card_id = selected.card.pk outcome = CardOutcome(card_id=card_id) @@ -787,7 +834,7 @@ def _compute_card( outcome.bleed_class = bleed_class if card_id in ocr_selected_ids: - ocr_result = run_ocr_for_card(selected, image, ocr_crop_box, bleed_class) + ocr_result = run_ocr_for_card(selected, image, ocr_crop_box, bleed_class, known_set_codes) outcome.ocr_vote, outcome.ocr_skip_reason = ocr_result.vote, ocr_result.skip_reason ocr_raw_texts = ocr_result.raw_texts if card_id in phash_selected_ids: @@ -802,8 +849,16 @@ def _compute_card( if image is not None: outcome.border_color = local_fallback.classify_border_color(image, bleed_class) illus_anchor_fired, _artist_name = local_fallback.detect_illus_anchor(image, ocr_raw_texts, bleed_class) + # "unknown-set-code" (2026-07-23, the SET-CODE LEXICON GATE - see run_ocr_for_card's own + # known_set_codes docstring paragraph) is included alongside "parsed-but-no-match" here + # for the same reason OcrCardResult.parsed_a_collector_number's own docstring already + # gives: a legible collector-line FORMAT is evidence of a post-2003 frame independent of + # whether the specific number matched a real candidate OR whether its set_code happened + # to be a real lexicon member - this signal is orthogonal to lexicon validity by design. parsed_a_collector_number = card_id in ocr_selected_ids and bool( - outcome.ocr_vote is not None or outcome.ocr_skip_reason == "parsed-but-no-match" + outcome.ocr_vote is not None + or outcome.ocr_skip_reason == "parsed-but-no-match" + or outcome.ocr_skip_reason == "unknown-set-code" ) outcome.frame_reading_attempted = True outcome.frame_class = local_fallback.classify_frame_style(parsed_a_collector_number, illus_anchor_fired) @@ -929,6 +984,17 @@ def run_pilot( run_start_time = time.time() index = CandidateNameIndex() + # Set-code lexicon (2026-07-23, issue #370's own recorded follow-up - see run_ocr_for_card's + # own known_set_codes docstring paragraph for the full mechanism this feeds): a DEFERRED + # import, not a module-level one - local_calculate_verdicts.py already imports FROM this + # module (CandidateNameIndex/generate_run_id/etc above), so a module-level import back here + # would be circular. Built ONCE per invocation (one DB query, the same "call-once-reuse- + # across-the-batch" convention CandidateNameIndex() above already follows), then threaded + # through _compute_card/run_ocr_for_card via the functools.partial below rather than queried + # per card. + from cardpicker.local_calculate_verdicts import known_set_codes as _known_set_codes + + 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) @@ -1165,6 +1231,7 @@ def propagate_cluster_vote( phash_margin=phash_margin, phash_max_candidates=phash_max_candidates, fetch_dpi=fetch_dpi, + known_set_codes=ocr_known_set_codes, ) chunk_start = 0 diff --git a/MPCAutofill/cardpicker/management/commands/run_image_evidence_cohort.py b/MPCAutofill/cardpicker/management/commands/run_image_evidence_cohort.py index 299750753..9c4bf317c 100644 --- a/MPCAutofill/cardpicker/management/commands/run_image_evidence_cohort.py +++ b/MPCAutofill/cardpicker/management/commands/run_image_evidence_cohort.py @@ -188,6 +188,7 @@ from django.utils import timezone from cardpicker.harvest_fetch_limiter import GoogleFetchLockoutError +from cardpicker.local_calculate_verdicts import known_set_codes from cardpicker.models import CanonicalCard, Card, ImageEvidence, PilotRunLedger from cardpicker.pilot_run_lifecycle import ( add_dry_run_guard_arguments, @@ -338,6 +339,7 @@ def _compute_one_card( run_id: str, profile: bool = False, short_circuit: Optional[bool] = None, + known_set_codes: Optional[frozenset[str]] = None, ) -> tuple[int, str, Optional[dict[str, float]], bool]: """Module-level (picklable) compute-only work unit for the process pool - takes plain, already-fetched data (never a `Card`/`Image` instance re-fetched or re-decoded elsewhere), and @@ -362,7 +364,14 @@ def _compute_one_card( command's own `--no-shortcircuit` escape hatch. The fourth return value, `short_circuited`, is `result.short_circuited` verbatim - a per-card diagnostic count `_CohortStats` aggregates into the run's own final summary line (never persisted onto `ImageEvidence`), which is how the - 197k-card remainder run itself produces the plan's own "open verification gap" measurement.""" + 197k-card remainder run itself produces the plan's own "open verification gap" measurement. + + `known_set_codes` (2026-07-23, issue #370's own recorded follow-up): the SET-CODE LEXICON GATE + `local_calculate_verdicts.known_set_codes()` produces, built ONCE by `handle()` below (one DB + query in the parent process, not per-card) and forwarded straight through - see + `compute_card_evidence`'s own docstring for the escalation-loop acceptance criterion this + controls. Picklable (a plain `frozenset[str]`), so passing it into each `compute_pool.submit` + call below costs one IPC serialization per card, not a query.""" from cardpicker.image_evidence import compute_card_evidence, persist_evidence wall_started_at = time.monotonic() if profile else None @@ -377,7 +386,13 @@ def _compute_one_card( profile_dict: Optional[dict[str, float]] = {} if profile else None result = compute_card_evidence( - card_id, content_hash, image, fetch_latency_ms, profile=profile_dict, short_circuit=short_circuit + card_id, + content_hash, + image, + fetch_latency_ms, + profile=profile_dict, + short_circuit=short_circuit, + known_set_codes=known_set_codes, ) if not dry_run: persist_evidence(result, run_id=run_id) @@ -480,6 +495,7 @@ def _run_cohort( profile_file: Any = None, short_circuit: Optional[bool] = None, max_rss_mb: Optional[float] = None, + known_set_codes: Optional[frozenset[str]] = None, ) -> tuple[int, int, bool, int, bool]: """ The decoupled fetch/compute driver itself. Two concurrent executors: @@ -520,6 +536,10 @@ def _run_cohort( `max_rss_mb` (2026-07-22): forwarded to `_CohortStats` - see its own docstring for the checkpoint-and-stop mechanism this drives. + `known_set_codes` (2026-07-23, issue #370's own recorded follow-up): built ONCE by `handle()` + below and forwarded to every `_compute_one_card` submission - see that function's own + docstring and `compute_card_evidence`'s own docstring for the mechanism this controls. + Returns `(completed, fetch_failures, lockout_hit, short_circuited, rss_limit_hit)` - the same three figures the old single-loop design printed in its final summary line, plus the short-circuit counter (item 1's own "count it during the real run" ask) and the new RSS-limit @@ -592,6 +612,7 @@ def _drain_one_pending() -> None: run_id, profile, short_circuit, + known_set_codes, ) pending[compute_future] = fetch_result.card_id _submit_more_fetch() @@ -868,6 +889,14 @@ def priority_key(pair: tuple[int, str]) -> tuple[float, int]: self.stdout.write("Nothing to do.") return + # Set-code lexicon (2026-07-23, issue #370's own recorded follow-up): built ONCE here + # (one DB query in the parent process, before the pool forks) and threaded through to + # every compute worker via `_run_cohort`/`_compute_one_card` - see + # `compute_card_evidence`'s own docstring for the escalation-loop acceptance criterion + # this feeds. Same "query once, pass through explicitly" convention step 1/2 above + # already use for name_rank/already_done_ids. + lexicon = known_set_codes() + # Close the parent's own DB connection(s) before forking the compute pool - belt-and- # braces alongside each compute worker's own _init_worker close_all() call, so the # connection this command's own step 1/2/3 queries above used is never inherited by any @@ -891,6 +920,7 @@ def priority_key(pair: tuple[int, str]) -> tuple[float, int]: profile_file=profile_file, short_circuit=short_circuit, max_rss_mb=max_rss_mb, + known_set_codes=lexicon, ) finally: if profile_file is not None: diff --git a/MPCAutofill/cardpicker/tests/test_image_evidence.py b/MPCAutofill/cardpicker/tests/test_image_evidence.py index 94c159751..12f57c8c6 100644 --- a/MPCAutofill/cardpicker/tests/test_image_evidence.py +++ b/MPCAutofill/cardpicker/tests/test_image_evidence.py @@ -1006,6 +1006,155 @@ def test_persist_writes_collector_line_fields(self, db): assert evidence.collector_line_collector_number == "158" +class TestExtractCardEvidenceCollectorLineOcrSetCodeLexiconGate: + """2026-07-23, issue #370's own recorded follow-up: the escalation loop's acceptance + criterion changes from "any parse" to "lexicon-valid parse, else keep escalating, else keep + the best invalid candidate" (see `compute_card_evidence`'s own `known_set_codes` docstring + paragraph for the full mechanism/autopsy). Uses a monkeypatched `run_tesseract_text_and_words` + (not real tesseract) throughout for exact, controlled per-attempt text, mirroring + `TestExtractCardEvidenceCollectorLineOcr`'s own style for controlled-text escalation tests + above (`test_digit_bearing_tier_one_failure_still_escalates_by_default` etc.).""" + + def test_lexicon_invalid_parse_keeps_escalating_until_a_valid_one_is_found(self, db, monkeypatch): + card = CardFactory(content_phash=1) + image = _build_card_image([(DEFAULT_CROP_BOX, "")]) + monkeypatch.setattr(module, "fetch_card_image", lambda card, dpi=None: image) + + # attempts 1-3 (tier 1's two + tier 2's first): a genuine collector-number-shaped read + # whose set code ("fak") isn't real. Attempt 4 (still tier 2): a lexicon-valid parse - + # escalation must stop there, never reaching attempts 5-8. + texts = iter(["158/287 R FAK EN", "158/287 R FAK EN", "158/287 R FAK EN", "158/287 R MOM EN"]) + calls: list[str] = [] + + def _stub(image_arg, config): + calls.append(config) + return next(texts), [] + + monkeypatch.setattr(module, "run_tesseract_text_and_words", _stub) + + result = extract_card_evidence(card, known_set_codes=frozenset({"mom"})) + + assert result.fields["collector_line_set_code"] == "mom" + assert result.fields["collector_line_collector_number"] == "158" + assert "collector_line_ocr" not in result.skip_reasons + assert len(calls) == 4 # stopped the instant a lexicon-valid parse was found + + def test_all_invalid_parses_keep_the_best_invalid_candidate(self, db, monkeypatch): + """No attempt across every tier ever produces a lexicon-valid parse - the stored outcome + keeps the FIRST collector_number-bearing parse (by tier order), matching exactly what + pre-2026-07-23 code already stored for this bucket (old code never distinguished valid + from invalid) - byte-identical stored fields, only the path there (genuine escalation + through every tier) changed.""" + card = CardFactory(content_phash=1) + image = _build_card_image([(DEFAULT_CROP_BOX, "")]) + monkeypatch.setattr(module, "fetch_card_image", lambda card, dpi=None: image) + + calls: list[str] = [] + + def _stub(image_arg, config): + calls.append(config) + return "158/287 R FAK EN", [] # always the same out-of-lexicon parse + + monkeypatch.setattr(module, "run_tesseract_text_and_words", _stub) + + result = extract_card_evidence(card, known_set_codes=frozenset({"mom"}), short_circuit=False) + + assert result.fields["collector_line_set_code"] == "fak" + assert result.fields["collector_line_collector_number"] == "158" + assert "collector_line_ocr" not in result.skip_reasons # a collector_number WAS found + assert len(calls) == 8 # every tier tried - no attempt ever validated + + def test_pre_gate_stored_outcome_is_reproduced_exactly_when_gate_disabled(self, db, monkeypatch): + """Same all-invalid scenario as above, but with known_set_codes=None (the gate disabled, + e.g. an older/direct caller) - must accept the FIRST parse immediately (no escalation at + all), reproducing the exact pre-2026-07-23 "any parse" behavior and stored fields.""" + card = CardFactory(content_phash=1) + image = _build_card_image([(DEFAULT_CROP_BOX, "")]) + monkeypatch.setattr(module, "fetch_card_image", lambda card, dpi=None: image) + + calls: list[str] = [] + + def _stub(image_arg, config): + calls.append(config) + return "158/287 R FAK EN", [] + + monkeypatch.setattr(module, "run_tesseract_text_and_words", _stub) + + result = extract_card_evidence(card) # known_set_codes not passed - defaults to None + + assert result.fields["collector_line_set_code"] == "fak" + assert result.fields["collector_line_collector_number"] == "158" + assert "collector_line_ocr" not in result.skip_reasons + assert len(calls) == 1 # accepted immediately - gate disabled, no escalation triggered + + def test_lexicon_valid_first_parse_short_circuits_exactly_as_before(self, db, monkeypatch): + """A card whose first parse is already lexicon-valid (the overwhelming majority) sees + IDENTICAL behavior and compute whether or not known_set_codes is threaded through - + companion to test_happy_path_never_computes_fallback_preprocessing above, asserting the + exact attempt count with the gate actively enabled this time.""" + card = CardFactory(content_phash=1) + image = _build_card_image([(DEFAULT_CROP_BOX, "158/287 R MOM EN")]) + monkeypatch.setattr(module, "fetch_card_image", lambda card, dpi=None: image) + + def _boom(cropped): + raise AssertionError("preprocess_fallback_variants must never be called on the happy path") + + monkeypatch.setattr(module, "preprocess_fallback_variants", _boom) + + result = extract_card_evidence(card, known_set_codes=frozenset({"mom"})) + + assert result.fields["collector_line_set_code"] == "mom" + assert result.fields["collector_line_collector_number"] == "158" + assert "collector_line_ocr" not in result.skip_reasons + + def test_collector_number_only_parse_unaffected_by_gate(self, db, monkeypatch): + """The pre-M15 collector-number-only case (no set-code-shaped token found at all) is + deliberately UNAFFECTED by the lexicon gate, same as `calculate_join_key_verdict`'s own + carve-out - accepted immediately even though known_set_codes doesn't contain anything.""" + card = CardFactory(content_phash=1) + image = _build_card_image([(DEFAULT_CROP_BOX, "")]) + monkeypatch.setattr(module, "fetch_card_image", lambda card, dpi=None: image) + + calls: list[str] = [] + + def _stub(image_arg, config): + calls.append(config) + return "158", [] # a bare collector number, no set-code-shaped token at all + + monkeypatch.setattr(module, "run_tesseract_text_and_words", _stub) + + result = extract_card_evidence(card, known_set_codes=frozenset()) # empty lexicon + + assert result.fields["collector_line_set_code"] == "" + assert result.fields["collector_line_collector_number"] == "158" + assert "collector_line_ocr" not in result.skip_reasons + assert len(calls) == 1 # accepted immediately - set_code is None, gate doesn't apply + + def test_short_circuit_interplay_unaffected_by_lexicon_gate(self, db, monkeypatch): + """The digit-free short-circuit (#340) governs whether escalation STARTS; the lexicon + gate governs acceptance once it's already running - the two are independent. A confidently + digit-free tier-1 read still short-circuits exactly as before, regardless of + known_set_codes being threaded through (there's no collector_number at all here for the + lexicon gate to ever evaluate).""" + card = CardFactory(content_phash=1) + image = _build_card_image([(DEFAULT_CROP_BOX, "")]) + monkeypatch.setattr(module, "fetch_card_image", lambda card, dpi=None: image) + + calls: list[str] = [] + + def _stub(image_arg, config): + calls.append(config) + return "no digits anywhere in this line", [] + + monkeypatch.setattr(module, "run_tesseract_text_and_words", _stub) + + result = extract_card_evidence(card, known_set_codes=frozenset({"mom"})) + + assert result.skip_reasons["collector_line_ocr"] == "no-text" + assert len(calls) == 2 # tier 1 only - short-circuit fires exactly as without the gate + assert result.short_circuited is True + + class TestCollectorLineOcrAttempts: """Direct tests of `_collector_line_ocr_attempts` (issue #259) - the lazy, ordered (image, tesseract_config, tier) generator `collector_line_ocr`'s own loop consumes. The `tier` diff --git a/MPCAutofill/cardpicker/tests/test_local_identify_printing_tags.py b/MPCAutofill/cardpicker/tests/test_local_identify_printing_tags.py index bf66061c7..ccc543fc5 100644 --- a/MPCAutofill/cardpicker/tests/test_local_identify_printing_tags.py +++ b/MPCAutofill/cardpicker/tests/test_local_identify_printing_tags.py @@ -1048,6 +1048,129 @@ def test_ambiguous_on_one_preprocessing_variant_wins_over_no_match_on_another(se assert result.skip_reason == "ambiguous" +class TestOcrSetCodeLexiconGate: + """2026-07-23, issue #370's own recorded follow-up - the deferred item local_calculate_ + verdicts.py's own module docstring used to flag ("known_set_codes() below is written so that + engine's own selection loop could reuse it directly in a focused follow-up"). run_ocr_for_card's + own "parsed-but-no-match" outcome (TestOcrAmbiguousSplit above) now only keeps that label - + which run_pilot's write loop casts a real, confident is_no_match=True vote from + (TestOcrNoMatchVoteCasting) - when at least one tried variant's parsed set_code is a REAL + CanonicalExpansion code (or None, the pre-M15 collector-number-only case). An out-of-lexicon + set_code demotes the outcome to "unknown-set-code" (abstain, same non-rescannable treatment + as "no-text") instead - the exact same criterion local_calculate_verdicts.calculate_join_key_ + verdict's own SET-CODE LEXICON GATE already applies to the stored-evidence join-key + calculator.""" + + def test_lexicon_invalid_set_code_abstains_as_unknown_set_code(self, db, monkeypatch): + import cardpicker.local_identify_printing_tags as module + + card = CardFactory(name="Forest") + candidates = [CandidatePrinting(pk=1, expansion_code="mom", collector_number="158")] + selected = module.SelectedCard(card=card, candidates=candidates) + + # a genuine collector-number-shaped read, but the set code ("fak") isn't a real one. + monkeypatch.setattr(module.local_ocr, "run_tesseract", lambda image: "999/287 R FAK EN") + + result = module.run_ocr_for_card(selected, Image.new("RGB", (750, 1050)), known_set_codes=frozenset({"mom"})) + assert result.vote is None + assert result.skip_reason == "unknown-set-code" + + def test_lexicon_valid_set_code_still_casts_parsed_but_no_match(self, db, monkeypatch): + import cardpicker.local_identify_printing_tags as module + + card = CardFactory(name="Forest") + candidates = [CandidatePrinting(pk=1, expansion_code="mom", collector_number="158")] + selected = module.SelectedCard(card=card, candidates=candidates) + + # "mom" is a real set code (this card's own candidate's own code) but the NUMBER + # doesn't match anything - a genuine parsed-but-no-match, not lexicon noise. + monkeypatch.setattr(module.local_ocr, "run_tesseract", lambda image: "999/287 R MOM EN") + + result = module.run_ocr_for_card(selected, Image.new("RGB", (750, 1050)), known_set_codes=frozenset({"mom"})) + assert result.vote is None + assert result.skip_reason == "parsed-but-no-match" + + def test_collector_number_only_parse_unaffected_by_gate(self, db, monkeypatch): + """The pre-M15 collector-number-only case (no set-code-shaped token found at all) is + deliberately UNAFFECTED by the lexicon gate - the same carve-out + calculate_join_key_verdict's own gate uses (its own module docstring: "the gate only + ever applies when a set-code-shaped token was actually found").""" + import cardpicker.local_identify_printing_tags as module + + card = CardFactory(name="Forest") + candidates = [CandidatePrinting(pk=1, expansion_code="mom", collector_number="158")] + selected = module.SelectedCard(card=card, candidates=candidates) + + # a bare collector number, no set code at all - "999" never matches "158". + monkeypatch.setattr(module.local_ocr, "run_tesseract", lambda image: "999") + + result = module.run_ocr_for_card( + selected, Image.new("RGB", (750, 1050)), known_set_codes=frozenset() # empty lexicon + ) + assert result.vote is None + assert result.skip_reason == "parsed-but-no-match" + + def test_known_set_codes_none_disables_the_gate(self, db, monkeypatch): + """No known_set_codes threaded through (the default - an older/direct caller) reproduces + the exact pre-2026-07-23 behavior: every parsed-but-no-match outcome keeps its label + regardless of set_code validity.""" + import cardpicker.local_identify_printing_tags as module + + card = CardFactory(name="Forest") + candidates = [CandidatePrinting(pk=1, expansion_code="mom", collector_number="158")] + selected = module.SelectedCard(card=card, candidates=candidates) + + monkeypatch.setattr(module.local_ocr, "run_tesseract", lambda image: "999/287 R FAK EN") + + result = module.run_ocr_for_card(selected, Image.new("RGB", (750, 1050))) + assert result.vote is None + assert result.skip_reason == "parsed-but-no-match" + + def test_one_lexicon_valid_variant_among_several_keeps_parsed_but_no_match(self, db, monkeypatch): + """Multiple preprocessing variants can read differently - ONE lexicon-valid no-match + parse is enough to keep the real "parsed-but-no-match" label, even if another variant's + read was lexicon-invalid noise (mirrors test_ambiguous_on_one_preprocessing_variant_wins_ + over_no_match_on_another's own "any real signal wins" precedent above).""" + import cardpicker.local_identify_printing_tags as module + + card = CardFactory(name="Forest") + candidates = [CandidatePrinting(pk=1, expansion_code="mom", collector_number="158")] + selected = module.SelectedCard(card=card, candidates=candidates) + + texts = iter(["999/287 R FAK EN", "999/287 R MOM EN"]) + monkeypatch.setattr(module.local_ocr, "run_tesseract", lambda image: next(texts)) + + result = module.run_ocr_for_card(selected, Image.new("RGB", (750, 1050)), known_set_codes=frozenset({"mom"})) + assert result.vote is None + assert result.skip_reason == "parsed-but-no-match" + + def test_run_pilot_threads_known_set_codes_end_to_end(self, db, monkeypatch): + """Integration: run_pilot itself builds known_set_codes() once (a real DB query against + CanonicalExpansion) and threads it through _compute_card/run_ocr_for_card - without + stubbing run_ocr_for_card itself, an out-of-lexicon set_code must abstain (a CardScanLog + row, skip_reason="unknown-set-code") rather than casting a confident is_no_match vote.""" + import cardpicker.local_identify_printing_tags as module + import cardpicker.local_ocr as local_ocr_module + + # "mom" is the ONLY real lexicon entry for this test - the card's own candidate is tied + # to it, but the stub OCR read below deliberately parses a DIFFERENT, out-of-lexicon code. + CanonicalCardFactory(name="Forest", expansion=CanonicalExpansionFactory(code="mom")) + card = CardFactory(name="Forest") + + # a genuine collector-number-shaped read whose set code ("fak") is real-looking noise, + # not a real CanonicalExpansion.code. + monkeypatch.setattr(local_ocr_module, "run_tesseract", lambda image: "999/287 R FAK EN") + monkeypatch.setattr(module, "fetch_card_image", lambda card, dpi=None: Image.new("RGB", (750, 1050))) + + results, _attributes = run_pilot(engine="ocr", limit=10, dry_run=False, nice=False) + + assert not CardPrintingTag.objects.filter(card=card).exists() + row = CardScanLog.objects.get(card=card, anonymous_id=OCR_ANONYMOUS_ID) + assert row.skip_reason == "unknown-set-code" + assert results["ocr"].no_match_votes_written == 0 + assert results["ocr"].skip_counts["unknown-set-code"] == 1 + + class TestPhashThresholdAndMargin: def test_clear_winner_within_threshold_and_margin(self): candidate_a = CandidatePrinting(pk=1, expansion_code="mom", collector_number="1") @@ -1220,7 +1343,7 @@ def test_both_engines_agree_keeps_both_votes(self, db, monkeypatch): import cardpicker.local_identify_printing_tags as module - def fake_ocr(selected, image, crop_box, bleed_class=None): + def fake_ocr(selected, image, crop_box, bleed_class=None, known_set_codes=None): return module.OcrCardResult( vote=module.EngineVote(engine="ocr", printing_pk=printing.pk, confidence=0.85, detail="raw") ) @@ -1249,7 +1372,7 @@ def test_both_engines_disagree_writes_neither(self, db, monkeypatch): import cardpicker.local_identify_printing_tags as module - def fake_ocr(selected, image, crop_box, bleed_class=None): + def fake_ocr(selected, image, crop_box, bleed_class=None, known_set_codes=None): return module.OcrCardResult( vote=module.EngineVote(engine="ocr", printing_pk=printing_a.pk, confidence=0.85, detail="raw") ) @@ -1277,7 +1400,7 @@ def test_dry_run_writes_nothing(self, db, monkeypatch): import cardpicker.local_identify_printing_tags as module - def fake_ocr(selected, image, crop_box, bleed_class=None): + def fake_ocr(selected, image, crop_box, bleed_class=None, known_set_codes=None): return module.OcrCardResult( vote=module.EngineVote(engine="ocr", printing_pk=printing.pk, confidence=0.85, detail="raw") ) @@ -1312,7 +1435,7 @@ def test_stays_zero_on_a_real_write_run_since_a_pilot_vote_alone_cannot_resolve_ import cardpicker.local_identify_printing_tags as module - def fake_ocr(selected, image, crop_box, bleed_class=None): + def fake_ocr(selected, image, crop_box, bleed_class=None, known_set_codes=None): return module.OcrCardResult( vote=module.EngineVote(engine="ocr", printing_pk=printing.pk, confidence=0.85, detail="raw") ) @@ -1329,7 +1452,7 @@ def test_stays_zero_in_dry_run(self, db, monkeypatch): import cardpicker.local_identify_printing_tags as module - def fake_ocr(selected, image, crop_box, bleed_class=None): + def fake_ocr(selected, image, crop_box, bleed_class=None, known_set_codes=None): return module.OcrCardResult( vote=module.EngineVote(engine="ocr", printing_pk=printing.pk, confidence=0.85, detail="raw") ) @@ -1538,7 +1661,7 @@ def test_excluded_sources_cards_never_reach_the_engine(self, db, monkeypatch): import cardpicker.local_identify_printing_tags as module - def fake_ocr(selected, image, crop_box, bleed_class=None): + def fake_ocr(selected, image, crop_box, bleed_class=None, known_set_codes=None): return module.OcrCardResult( vote=module.EngineVote(engine="ocr", printing_pk=printing.pk, confidence=0.85, detail="raw") ) @@ -1570,7 +1693,7 @@ class TestCheckpointing: def _wire_fake_ocr(monkeypatch, printing_pk): import cardpicker.local_identify_printing_tags as module - def fake_ocr(selected, image, crop_box, bleed_class=None): + def fake_ocr(selected, image, crop_box, bleed_class=None, known_set_codes=None): return module.OcrCardResult( vote=module.EngineVote(engine="ocr", printing_pk=printing_pk, confidence=0.85, detail="raw") ) @@ -1814,7 +1937,7 @@ def test_a_card_voted_on_is_excluded_from_the_next_selection(self, db, monkeypat monkeypatch.setattr( module, "run_ocr_for_card", - lambda selected, image, crop_box, bleed_class=None: module.OcrCardResult( + 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") ), ) @@ -1840,7 +1963,9 @@ def test_a_skipped_card_gets_a_scan_log_row_and_is_excluded_next_time(self, db, monkeypatch.setattr( module, "run_ocr_for_card", - lambda selected, image, crop_box, bleed_class=None: module.OcrCardResult(skip_reason="no-text"), + lambda selected, image, crop_box, bleed_class=None, known_set_codes=None: module.OcrCardResult( + skip_reason="no-text" + ), ) monkeypatch.setattr(module, "fetch_card_image", lambda card, dpi=None: None) @@ -1863,7 +1988,7 @@ def test_a_voted_card_gets_no_scan_log_row(self, db, monkeypatch): monkeypatch.setattr( module, "run_ocr_for_card", - lambda selected, image, crop_box, bleed_class=None: module.OcrCardResult( + 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") ), ) @@ -1907,7 +2032,9 @@ def test_a_later_non_rescannable_reason_overrides_an_earlier_rescannable_one(sel monkeypatch.setattr( module, "run_ocr_for_card", - lambda selected, image, crop_box, bleed_class=None: module.OcrCardResult(skip_reason="no-text"), + lambda selected, image, crop_box, bleed_class=None, known_set_codes=None: module.OcrCardResult( + skip_reason="no-text" + ), ) # image now "fetches" (a real tiny image - classify_bleed_edge needs .size, not a bare # object) - this also makes fallback newly eligible to run (it requires image is not @@ -1943,7 +2070,13 @@ def counting_fetch(card: object, dpi: object = None) -> object: return None # unfetchable-image every time - genuinely transient return Image.new("RGB", (10, 10)) # real image - run_ocr_for_card below is also monkeypatched - def per_card_ocr_result(selected: object, image: object, crop_box: object, bleed_class: object = None): + def per_card_ocr_result( + selected: object, + image: object, + crop_box: object, + bleed_class: object = None, + known_set_codes: object = None, + ): card_id = selected.card.pk # type: ignore[attr-defined] if card_id == voted_card.pk: return module.OcrCardResult( @@ -1986,7 +2119,9 @@ def test_parsed_but_no_match_casts_is_no_match_vote_not_a_scan_log_row(self, db, monkeypatch.setattr( module, "run_ocr_for_card", - lambda selected, image, crop_box, bleed_class=None: module.OcrCardResult(skip_reason="parsed-but-no-match"), + lambda selected, image, crop_box, bleed_class=None, known_set_codes=None: module.OcrCardResult( + skip_reason="parsed-but-no-match" + ), ) monkeypatch.setattr(module, "fetch_card_image", lambda card, dpi=None: None) @@ -2003,7 +2138,7 @@ def test_parsed_but_no_match_casts_is_no_match_vote_not_a_scan_log_row(self, db, # bookkeeping needed (same idempotence mechanism a positive vote already relies on) assert select_candidates("ocr") == [] - @pytest.mark.parametrize("skip_reason", ["no-text", "ambiguous", "unfetchable-image"]) + @pytest.mark.parametrize("skip_reason", ["no-text", "ambiguous", "unfetchable-image", "unknown-set-code"]) def test_pure_abstention_reasons_never_cast_is_no_match(self, db, monkeypatch, skip_reason): CanonicalCardFactory(name="Forest") card = CardFactory(name="Forest") @@ -2012,7 +2147,9 @@ def test_pure_abstention_reasons_never_cast_is_no_match(self, db, monkeypatch, s monkeypatch.setattr( module, "run_ocr_for_card", - lambda selected, image, crop_box, bleed_class=None: module.OcrCardResult(skip_reason=skip_reason), + lambda selected, image, crop_box, bleed_class=None, known_set_codes=None: module.OcrCardResult( + skip_reason=skip_reason + ), ) monkeypatch.setattr(module, "fetch_card_image", lambda card, dpi=None: None) @@ -2038,7 +2175,7 @@ def test_frame_mismatch_never_casts_is_no_match(self, db, monkeypatch): monkeypatch.setattr(local_ocr_module, "run_tesseract", lambda image: "") - def fake_ocr(selected, image, crop_box, bleed_class=None): + def fake_ocr(selected, image, crop_box, bleed_class=None, known_set_codes=None): return module.OcrCardResult( vote=module.EngineVote( engine="ocr", printing_pk=printing.pk, confidence=0.85, detail="158/281 R MOM EN" @@ -2066,7 +2203,7 @@ def test_engine_disagreement_never_casts_is_no_match(self, db, monkeypatch): monkeypatch.setattr( module, "run_ocr_for_card", - lambda selected, image, crop_box, bleed_class=None: module.OcrCardResult( + lambda selected, image, crop_box, bleed_class=None, known_set_codes=None: module.OcrCardResult( vote=module.EngineVote(engine="ocr", printing_pk=printing_a.pk, confidence=0.85, detail="a") ), ) @@ -2096,7 +2233,11 @@ class TestFallbackNoMatchVoteCasting: def _wire_pass_1_miss_and_fallback(module, fallback_outcome, image=None): image = image or Image.new("RGB", (750, 1050), (5, 5, 5)) monkeypatch_targets = [ - (module, "run_ocr_for_card", lambda selected, image, crop_box, bleed_class=None: module.OcrCardResult()), + ( + module, + "run_ocr_for_card", + lambda selected, image, crop_box, bleed_class=None, known_set_codes=None: module.OcrCardResult(), + ), ( module, "run_phash_for_card", @@ -2199,7 +2340,9 @@ def test_frame_mismatch_never_casts_is_no_match(self, db, monkeypatch): 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: module.OcrCardResult() + module, + "run_ocr_for_card", + lambda selected, image, crop_box, bleed_class=None, known_set_codes=None: module.OcrCardResult(), ) monkeypatch.setattr( module, @@ -2388,7 +2531,9 @@ def test_fallback_fires_and_votes_when_pass_1_misses_entirely(self, db, monkeypa # 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, "run_ocr_for_card", lambda selected, image, crop_box, bleed_class=None: module.OcrCardResult() + module, + "run_ocr_for_card", + lambda selected, image, crop_box, bleed_class=None, known_set_codes=None: module.OcrCardResult(), ) monkeypatch.setattr( module, @@ -2431,7 +2576,7 @@ def test_frame_mismatch_withholds_the_printing_vote(self, db, monkeypatch): # detect_illus_anchor() call must not depend on the real binary being present to do so. monkeypatch.setattr(local_ocr_module, "run_tesseract", lambda image: "") - def fake_ocr(selected, image, crop_box, bleed_class=None): + def fake_ocr(selected, image, crop_box, bleed_class=None, known_set_codes=None): return module.OcrCardResult( vote=module.EngineVote( engine="ocr", printing_pk=printing.pk, confidence=0.85, detail="158/281 R MOM EN" @@ -2466,7 +2611,9 @@ def fail_if_called(selected, image, ocr_raw_texts): 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: module.OcrCardResult() + module, + "run_ocr_for_card", + lambda selected, image, crop_box, bleed_class=None, known_set_codes=None: module.OcrCardResult(), ) monkeypatch.setattr( module, @@ -2500,7 +2647,7 @@ def test_ground_truth_overrides_heuristic_when_printing_confirmed(self, db, monk # real read would find anyway; see the identical note on TestPass2Wiring above. monkeypatch.setattr(local_ocr_module, "run_tesseract", lambda image: "") - def fake_ocr(selected, image, crop_box, bleed_class=None): + def fake_ocr(selected, image, crop_box, bleed_class=None, known_set_codes=None): return module.OcrCardResult( vote=module.EngineVote( engine="ocr", printing_pk=printing.pk, confidence=0.85, detail="158/281 R MOM EN" @@ -2541,7 +2688,9 @@ def test_heuristic_used_when_no_printing_confirmed_this_run(self, db, monkeypatc monkeypatch.setattr(local_ocr_module, "run_tesseract", lambda image: "") monkeypatch.setattr( - module, "run_ocr_for_card", lambda selected, image, crop_box, bleed_class=None: module.OcrCardResult() + module, + "run_ocr_for_card", + lambda selected, image, crop_box, bleed_class=None, known_set_codes=None: module.OcrCardResult(), ) monkeypatch.setattr( module, @@ -2570,7 +2719,7 @@ def test_heuristic_used_when_confirmed_printing_has_no_usable_metadata(self, db, # 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 fake_ocr(selected, image, crop_box, bleed_class=None): + def fake_ocr(selected, image, crop_box, bleed_class=None, known_set_codes=None): return module.OcrCardResult( vote=module.EngineVote( engine="ocr", printing_pk=printing.pk, confidence=0.85, detail="158/281 R MOM EN" @@ -2606,7 +2755,9 @@ def test_bleed_shaped_image_is_censused_but_casts_no_vote(self, db, monkeypatch) monkeypatch.setattr(local_ocr_module, "run_tesseract", lambda image: "") monkeypatch.setattr( - module, "run_ocr_for_card", lambda selected, image, crop_box, bleed_class=None: module.OcrCardResult() + module, + "run_ocr_for_card", + lambda selected, image, crop_box, bleed_class=None, known_set_codes=None: module.OcrCardResult(), ) monkeypatch.setattr( module, @@ -2633,7 +2784,9 @@ def test_trimmed_shaped_image_casts_a_negative_vote(self, db, monkeypatch): monkeypatch.setattr(local_ocr_module, "run_tesseract", lambda image: "") monkeypatch.setattr( - module, "run_ocr_for_card", lambda selected, image, crop_box, bleed_class=None: module.OcrCardResult() + module, + "run_ocr_for_card", + lambda selected, image, crop_box, bleed_class=None, known_set_codes=None: module.OcrCardResult(), ) monkeypatch.setattr( module, @@ -2659,7 +2812,9 @@ def test_ambiguous_ratio_abstains_without_writing_anything(self, db, monkeypatch monkeypatch.setattr(local_ocr_module, "run_tesseract", lambda image: "") monkeypatch.setattr( - module, "run_ocr_for_card", lambda selected, image, crop_box, bleed_class=None: module.OcrCardResult() + module, + "run_ocr_for_card", + lambda selected, image, crop_box, bleed_class=None, known_set_codes=None: module.OcrCardResult(), ) monkeypatch.setattr( module, @@ -2887,7 +3042,7 @@ def test_accepted_vote_on_representative_propagates_to_absorbed_member(self, db, monkeypatch.setattr( module, "run_ocr_for_card", - lambda selected, image, crop_box, bleed_class=None: module.OcrCardResult( + 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") ), ) @@ -2932,7 +3087,7 @@ def test_member_with_an_existing_vote_from_a_prior_run_is_not_double_voted_or_ov monkeypatch.setattr( module, "run_ocr_for_card", - lambda selected, image, crop_box, bleed_class=None: module.OcrCardResult( + 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") ), ) @@ -2973,7 +3128,7 @@ def test_absorbed_member_never_reaches_ocr_or_phash_processing(self, db, monkeyp ocr_called_for_card_ids: list[int] = [] - def recording_run_ocr_for_card(selected, image, crop_box, bleed_class=None): + def recording_run_ocr_for_card(selected, image, crop_box, bleed_class=None, known_set_codes=None): ocr_called_for_card_ids.append(selected.card.pk) return module.OcrCardResult() @@ -3011,7 +3166,7 @@ def fake_select_candidates(engine, index=None, exclude_source_pks=None, covered_ monkeypatch.setattr( module, "run_ocr_for_card", - lambda selected, image, crop_box, bleed_class=None: module.OcrCardResult( + 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") ), ) diff --git a/MPCAutofill/cardpicker/tests/test_run_image_evidence_cohort.py b/MPCAutofill/cardpicker/tests/test_run_image_evidence_cohort.py index 5d1792bdb..7d49017a4 100644 --- a/MPCAutofill/cardpicker/tests/test_run_image_evidence_cohort.py +++ b/MPCAutofill/cardpicker/tests/test_run_image_evidence_cohort.py @@ -116,6 +116,7 @@ def _stub_compute_ok( run_id: str, profile: bool = False, short_circuit: Optional[bool] = None, + known_set_codes: Optional[frozenset[str]] = None, ) -> tuple[int, str, Optional[dict[str, float]], bool]: """Replaces the real compute-stage step - no PIL decode, no extractors, no persist_evidence call, just the (card_id, outcome, profile, short_circuited) tuple `_run_cohort` consumes. @@ -333,6 +334,7 @@ def _stub_compute_short_circuited( run_id: str, profile: bool = False, short_circuit: Optional[bool] = None, + known_set_codes: Optional[frozenset[str]] = None, ) -> tuple[int, str, Optional[dict[str, float]], bool]: return card_id, "ok", None, True @@ -541,6 +543,7 @@ def _stub_compute_card_evidence( fetch_latency_ms: float, profile: Optional[dict[str, float]] = None, short_circuit: Optional[bool] = None, + known_set_codes: Optional[frozenset[str]] = None, ) -> Any: captured["card_id"] = card_id captured["content_hash"] = content_hash @@ -582,6 +585,7 @@ def _stub_compute_card_evidence( fetch_latency_ms: float, profile: Optional[dict[str, float]] = None, short_circuit: Optional[bool] = None, + known_set_codes: Optional[frozenset[str]] = None, ) -> Any: captured["image"] = image @@ -643,6 +647,7 @@ def _stub_compute_card_evidence( fetch_latency_ms: float, profile: Optional[dict[str, float]] = None, short_circuit: Optional[bool] = None, + known_set_codes: Optional[frozenset[str]] = None, ) -> Any: if profile is not None: profile["fetch_ms"] = fetch_latency_ms @@ -749,6 +754,7 @@ def _stub_compute_with_profile( run_id: str, profile: bool = False, short_circuit: Optional[bool] = None, + known_set_codes: Optional[frozenset[str]] = None, ) -> tuple[int, str, Optional[dict[str, float]], bool]: profile_dict = {"fetch_ms": fetch_latency_ms, "wall_ms": 1.0} if profile else None return card_id, "ok", profile_dict, False diff --git a/docs/identification-pipeline.md b/docs/identification-pipeline.md index 301ab04b3..c07cadd07 100644 --- a/docs/identification-pipeline.md +++ b/docs/identification-pipeline.md @@ -35,7 +35,24 @@ pre-197k review. the first pass reads text with **no digit-bearing structure**, escalation is skipped entirely — measured 99.7% of such cards never yield a collector line at any tier (customs). A `short_circuited` counter logs every skip so the - 197k run itself validates this. Escape hatch: `--no-shortcircuit`. + 197k run itself validates this. Escape hatch: `--no-shortcircuit`. **Set-code + lexicon gate on acceptance (2026-07-23, issue #370):** a tier's parse only + terminates escalation if its set code is a REAL `CanonicalExpansion` code (or + the pre-M15 collector-number-only case, no set code parsed at all) — a live + structural finding (issue #370) traced 94% of a lexicon-invalid-no-match + sample to the OLD "any parse" criterion accepting tier 1's own OCR noise + before tiers 2/3 ever got a chance to run. A `collector_number`-bearing parse + whose set code ISN'T a real one no longer stops the loop; it's kept as the + running best invalid candidate (first such parse, by tier order) while + escalation continues, and only becomes the stored outcome if no later tier + ever produces a lexicon-valid parse — the exact value pre-gate code already + stored for that case, so this only changes the PATH there, never the result. + Governs acceptance during escalation, not whether escalation starts (the + digit-free short-circuit above is unaffected). The live-pilot OCR engine + (`local_identify_printing_tags.run_ocr_for_card`) applies the same lexicon + check at its own "parsed-but-no-match" outcome: a lexicon-invalid parse + there abstains (`unknown-set-code`, non-rescannable) instead of casting the + confident `is_no_match` vote it used to. 4. **Parse**: set code + collector number from the collector line (slash-format-aware since #260); the legal band is scanned for proxy marking — `not for sale`, `proxy/proxies/proxied`, `playtest` variants