From 881f1f4b964597b0d7c8028d233835c3e8b823fc Mon Sep 17 00:00:00 2001 From: WilfordGrimley <2397930+WilfordGrimley@users.noreply.github.com> Date: Tue, 4 Aug 2026 23:05:26 -0400 Subject: [PATCH] perf(image-evidence): collapse Stage C OCR attempt ladder from 3 tiers to 2 (issue #677) (#698) * perf(image-evidence): collapse Stage C OCR attempt ladder from 3 tiers to 2 (issue #677) _collector_line_ocr_attempts drops tier 3 (a PSM-11 re-try of tier 1's own variants) - measured, not guessed. Two fresh real-production probes (450 forced-escalation cards combined) walked the old 3-tier ladder to full completion and recorded, per card, the first tier at which a candidate-validated genuine match appeared: tier 2 produced 2 genuine matches across the sample, tier 3 produced zero - only more lexicon-valid -but-uncorroborated noise, consistent with the 2026-07-23 preprocessing probes' own ~99% hopeless-art-noise finding for this population. Cross-checked against all 30 golden_set.GOLDEN_CARD_IDS cards: none ever resolved uniquely at tier 3, so this collapse changes zero golden-set expectations. Worst case drops from 8 tesseract calls to 6 (down from #259-#677's own 4x pre-#259 cost to 3x). collector_line_ocr/collector_line_tsv/artist_ocr extractor versions bumped v2->v3 (all three read through the same escalation loop, per EXTRACTOR_OWNERSHIP's own 'bump every listed key together' convention); legal_line unchanged. Investigated and explicitly did NOT ship the companion 'unify the collector-line/legal-line OCR reads into one full-width pass' idea named in this issue's own brief: a real-image accuracy probe found it regresses 46/150 (30.7%) of currently-correct collector-number reads (mostly proxy/ watermark text getting picked up by the set-code regex in place of the real code, or the wider crop causing tesseract to drop the collector- number line outright), with zero improvements. Reported as a legitimate negative finding rather than shipped anyway. Lifts the EXTRACTOR_OWNERSHIP EXCLUDED_HELPERS entry for the OCR ladder (check_extractor_ownership_totality.py) that PR #690 left in place while this branch was in flight - _collector_line_ocr_attempts and preprocess_fallback_variants now carry real ownership entries. Verification: full backend suite (3598 passed, 8 skipped, 0 failed), ownership-totality + manifest-sync scripts clean with their own unit tests green (40/40), docs_lint --strict clean, pre-commit clean on all tracked files. * fix(image-evidence): bump artist_ocr to v4 to avoid colliding with #685's v3 ARTIST_OCR_EXTRACTOR_VERSION was set to "artist-ocr-v3" on this branch for issue #677's ladder-collapse dedup, but PR #685 (merged to master separately) already claims "artist-ocr-v3" for an unrelated change (wiring modern_artist_credit's crop fallback into live extraction). Two different behaviours under one version string would defeat the staleness filter MANIFEST_EXTRACTOR_CURRENT_VERSIONS exists to enforce. Bumps artist_ocr straight to v4, updates the matching manifest entry in run_image_evidence_cohort.py, and documents the collision in both the version constant's own history comment and catalog-completion-plan.md's still has both at v2, no collision). Also fixes golden_set.py's #677 re-verification comment, which cited MPCAutofill/scripts/experiments/ocr_ladder_tier_attribution.py - a worktree-only analysis script never committed to this repo, per the established convention (see docs/reports/2026-07-23-ocr-preprocessing- probe.md's own LIVE STATE) of not committing one-off probes. Points at catalog-completion-plan.md's already-committed #677 section instead, which documents the same 450-card probe's methodology and results. * fix(docs): cite OCR ladder probe scripts as prose, not backticked paths Both scripts are deliberately uncommitted (worktree-only analysis tooling, same convention as the 2026-07-23 probe reports). Backticked citations to their paths tripped docs_lint.py's path-reference rule since MPCAutofill/scripts/ doesn't exist in the repo. Rephrase as prose so the provenance stays legible without a machine-checkable path reference to a file that was never meant to be committed. (cherry picked from commit c5eee1c88bb7d39f290ba4bc6ad3c761ed58a1a2) --- .../check_extractor_ownership_totality.py | 48 ++++--- ...test_check_extractor_ownership_totality.py | 71 +++++++--- MPCAutofill/cardpicker/golden_set.py | 11 ++ MPCAutofill/cardpicker/image_evidence.py | 134 ++++++++++-------- .../commands/run_image_evidence_cohort.py | 6 +- .../cardpicker/tests/test_image_evidence.py | 55 ++++--- docs/features/catalog-completion-plan.md | 47 +++++- 7 files changed, 238 insertions(+), 134 deletions(-) diff --git a/.github/scripts/check_extractor_ownership_totality.py b/.github/scripts/check_extractor_ownership_totality.py index 2e4ef80b6..d93371a95 100644 --- a/.github/scripts/check_extractor_ownership_totality.py +++ b/.github/scripts/check_extractor_ownership_totality.py @@ -50,16 +50,13 @@ DELIBERATE EXCLUSIONS, and why they are not silent ---------------------------------------------------- -`EXCLUDED_HELPERS = {"_collector_line_ocr_attempts"}` — the OCR -attempt-tier ladder (issue #259). Named out of scope by this PR's own brief -("being worked in parallel by other branches"); touching its ownership here -would either fight that branch's own edits or require this script to freeze -a design that branch is actively changing. Its own body is excluded from -the call-scan too (not just the function name itself), so a name ONLY -called from inside it (`preprocess_fallback_variants`, at this writing) is -correctly not required to have an entry either — it is reachable exclusively -through the excluded ladder, and re-included in full the day that ladder's -own PR lands and this exclusion is lifted. +The OCR attempt-tier ladder (`_collector_line_ocr_attempts`, issue #259) was +excluded here (`EXCLUDED_HELPERS = {"_collector_line_ocr_attempts"}`) while a +parallel branch was actively restructuring it — that branch was issue #677 +("collapse the Stage C OCR attempt ladder"), which has now landed, so the +exclusion is LIFTED (`EXCLUDED_HELPERS = frozenset()`): the ladder function +and everything reachable only through it (`preprocess_fallback_variants`) +now carry real `EXTRACTOR_OWNERSHIP` entries like every other contributor. `local_fallback.py`'s own exported helpers (`classify_bleed_edge`, `classify_border_color`, `classify_frame_style`, `compute_bleed_diff_mm`, @@ -91,9 +88,9 @@ SOURCE_REL = "MPCAutofill/cardpicker/image_evidence.py" -# The OCR attempt-tier ladder (issue #259) — out of scope for this PR, being -# worked on in parallel. See module docstring's "DELIBERATE EXCLUSIONS". -EXCLUDED_HELPERS = frozenset({"_collector_line_ocr_attempts"}) +# No excluded helpers - the OCR attempt-tier ladder exclusion (issue #259) was lifted by issue +# #677. See module docstring's "DELIBERATE EXCLUSIONS". +EXCLUDED_HELPERS = frozenset() # Modules whose imported-and-called names are in this script's scope. # `cardpicker.local_fallback` and `cardpicker.local_phash` are deliberately @@ -150,6 +147,12 @@ # Pre-classification short-circuit's own acceptance predicate — same # reach as `_short_circuit_enabled_by_env`/`_contains_digit` above. "_confidently_digit_free": frozenset({"collector_line_ocr", "collector_line_tsv", "artist_ocr"}), + # The OCR attempt-tier ladder itself (issue #259, collapsed to 2 tiers by issue #677) — the + # ordered (variant, config, tier) sequence collector_line_ocr's own loop consumes, which + # therefore governs collector_line_tsv's word-box source and artist_ocr's raw-text-reuse + # population too. Was `EXCLUDED_HELPERS` while #677 was in flight; that exclusion is now + # lifted (see module docstring). + "_collector_line_ocr_attempts": frozenset({"collector_line_ocr", "collector_line_tsv", "artist_ocr"}), # --- cardpicker.collector_line_artist --- # Called from both `_parse_artist_is_contradicted` (gates # collector_line_ocr/collector_line_tsv/artist_ocr's raw-text-reuse @@ -171,21 +174,20 @@ "parse_collector_line": frozenset({"collector_line_ocr", "collector_line_tsv"}), # legal_line's own tolerant parse - called only from `_extract_legal_line`. "parse_legal_line": frozenset({"legal_line"}), - # Called directly (outside the excluded tier ladder) from - # `_extract_legal_line` and the artist_ocr crop+OCR fallback loop - - # NOT from collector_line_ocr's own tier-1 attempts, which live inside - # the excluded `_collector_line_ocr_attempts` generator (see module - # docstring's "DELIBERATE EXCLUSIONS" - that reach is this contributor's - # too, but is out of scope for this PR and left to the parallel branch). - "preprocess_variants": frozenset({"legal_line", "artist_ocr"}), + # Called from `_extract_legal_line`, the artist_ocr crop+OCR fallback loop, AND (since #677 + # lifted the ladder exclusion) `_collector_line_ocr_attempts`' own tier-1 yield - all three + # reaches declared together. + "preprocess_variants": frozenset({"legal_line", "artist_ocr", "collector_line_ocr", "collector_line_tsv"}), + # Tier 2's own heavier-preprocessed variants (issue #259) - reachable exclusively through + # `_collector_line_ocr_attempts`, same reach as the ladder function itself. + "preprocess_fallback_variants": frozenset({"collector_line_ocr", "collector_line_tsv", "artist_ocr"}), # Same two in-scope call sites as `preprocess_variants` above (legal_line's - # OCR pass, artist_ocr's crop+OCR fallback) - not the excluded ladder. + # OCR pass, artist_ocr's crop+OCR fallback). "run_tesseract": frozenset({"legal_line", "artist_ocr"}), # Called directly inside compute_card_evidence's own OCR loop (the # `_collector_line_ocr_attempts` generator only yields preprocessed # variants + config + tier; the actual tesseract call, and therefore - # this function's own reach, is in the loop body, outside the excluded - # generator's subtree). + # this function's own reach, is in the loop body). "run_tesseract_text_and_words": frozenset({"collector_line_ocr", "collector_line_tsv"}), } diff --git a/.github/scripts/tests/test_check_extractor_ownership_totality.py b/.github/scripts/tests/test_check_extractor_ownership_totality.py index c74bb0f4e..e454d92df 100644 --- a/.github/scripts/tests/test_check_extractor_ownership_totality.py +++ b/.github/scripts/tests/test_check_extractor_ownership_totality.py @@ -30,10 +30,14 @@ # A minimal fixture `image_evidence.py`: one module-private helper # (`_helper_one`) called from `compute_card_evidence`, one scoped-external # import (`external_thing`, standing in for e.g. `recover_artist_from_card_text`) -# also called from there, one excluded-ladder helper (`_collector_line_ocr_attempts`) -# whose own internal call (`preprocess_fallback_variants`-analogue, -# `ladder_only_thing`) must NOT be required, and the manifest wiring -# `check_extractor_manifest_sync`'s own derivation needs to find real keys. +# also called from there, one helper (`_would_be_excluded`) whose own internal +# call (`ladder_only_thing`) exercises the EXCLUDED_HELPERS mechanism itself - +# that mechanism is generic and still live in the script even though issue +# #677 emptied the REAL `EXCLUDED_HELPERS` set (it previously held the OCR +# ladder, `_collector_line_ocr_attempts`, while a parallel branch worked on +# it - see the script's own module docstring); tests that need the mechanism +# ON pass `excluded_helpers=frozenset({"_would_be_excluded"})` to `fixture_repo` +# explicitly rather than relying on a name that is no longer excluded for real. SOURCE_OK = ''' """fixture image_evidence""" from cardpicker.collector_line_artist import external_thing @@ -47,7 +51,7 @@ def _helper_one(x): return x -def _collector_line_ocr_attempts(cropped): +def _would_be_excluded(cropped): yield ladder_only_thing(cropped) @@ -57,7 +61,7 @@ def compute_card_evidence(card): extractor_versions["legal_line"] = LEGAL_LINE_EXTRACTOR_VERSION _helper_one(card) external_thing(card) - for _ in _collector_line_ocr_attempts(card): + for _ in _would_be_excluded(card): pass return extractor_versions ''' @@ -70,14 +74,23 @@ def compute_card_evidence(card): } """ +# Matches SOURCE_OK's default (no exclusion in effect - EXCLUDED_HELPERS is empty for real too), +# so `_would_be_excluded`/`ladder_only_thing` need entries like any other contributor. OWNERSHIP_OK = { "_helper_one": frozenset({"fetch_health"}), "external_thing": frozenset({"legal_line"}), + "_would_be_excluded": frozenset({"legal_line"}), + "ladder_only_thing": frozenset({"legal_line"}), } @contextlib.contextmanager -def fixture_repo(source: str = SOURCE_OK, cohort: str = COHORT_OK, ownership: dict = None): +def fixture_repo( + source: str = SOURCE_OK, + cohort: str = COHORT_OK, + ownership: dict = None, + excluded_helpers: frozenset = frozenset(), +): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) for rel, text in ((lint.SOURCE_REL, source), (manifest_sync.COHORT_REL, cohort)): @@ -87,15 +100,18 @@ def fixture_repo(source: str = SOURCE_OK, cohort: str = COHORT_OK, ownership: di saved_lint_root = lint.REPO_ROOT saved_manifest_root = manifest_sync.REPO_ROOT saved_ownership = lint.EXTRACTOR_OWNERSHIP + saved_excluded = lint.EXCLUDED_HELPERS lint.REPO_ROOT = root manifest_sync.REPO_ROOT = root lint.EXTRACTOR_OWNERSHIP = OWNERSHIP_OK if ownership is None else ownership + lint.EXCLUDED_HELPERS = excluded_helpers try: yield root finally: lint.REPO_ROOT = saved_lint_root manifest_sync.REPO_ROOT = saved_manifest_root lint.EXTRACTOR_OWNERSHIP = saved_ownership + lint.EXCLUDED_HELPERS = saved_excluded def joined(findings) -> str: @@ -107,24 +123,32 @@ def test_derives_private_helper_and_called_external_import(self): with fixture_repo(): contributors, findings = lint.derive_reachable_contributors() self.assertEqual(findings, []) - self.assertEqual(contributors, {"_helper_one", "external_thing"}) + self.assertEqual(contributors, {"_helper_one", "external_thing", "_would_be_excluded", "ladder_only_thing"}) - def test_excluded_ladder_function_itself_is_not_a_contributor(self): - # _collector_line_ocr_attempts is in EXCLUDED_HELPERS by name - it - # must never itself require an entry. - with fixture_repo(): + def test_excluded_helper_itself_is_not_a_contributor(self): + # a name in EXCLUDED_HELPERS must never itself require an entry - + # the mechanism issue #677 emptied for the real ladder but which + # stays generic/reusable in the script itself. + with fixture_repo(excluded_helpers=frozenset({"_would_be_excluded"})): contributors, _ = lint.derive_reachable_contributors() - self.assertNotIn("_collector_line_ocr_attempts", contributors) + self.assertNotIn("_would_be_excluded", contributors) - def test_name_called_only_inside_the_excluded_ladder_is_not_a_contributor(self): + def test_name_called_only_inside_an_excluded_helper_is_not_a_contributor(self): # ladder_only_thing is a scoped-external import, but its one call - # site is inside _collector_line_ocr_attempts's own body - excluded - # per this PR's own brief (that ladder is being worked on in - # parallel by another branch). - with fixture_repo(): + # site is inside the excluded helper's own body. + with fixture_repo(excluded_helpers=frozenset({"_would_be_excluded"})): contributors, _ = lint.derive_reachable_contributors() self.assertNotIn("ladder_only_thing", contributors) + def test_no_exclusion_by_default_requires_entries_for_both(self): + # SOURCE_OK's own default fixture_repo() call has EXCLUDED_HELPERS + # empty (matching the real script since issue #677) - both names + # ARE contributors now, and OWNERSHIP_OK declares both. + with fixture_repo(): + contributors, _ = lint.derive_reachable_contributors() + self.assertIn("_would_be_excluded", contributors) + self.assertIn("ladder_only_thing", contributors) + def test_import_used_only_as_a_type_hint_is_not_a_contributor(self): source = SOURCE_OK.replace( "from cardpicker.collector_line_artist import external_thing", @@ -239,13 +263,16 @@ def test_derivation_sees_the_real_contributors(self): "parse_collector_line", "parse_legal_line", "run_tesseract_text_and_words", + # the OCR ladder itself and its own tier-2 helper (issue #677 lifted the + # EXCLUDED_HELPERS entry that used to hide both from this derivation) + "_collector_line_ocr_attempts", + "preprocess_fallback_variants", ): self.assertIn(name, contributors) - def test_excluded_ladder_is_really_excluded_in_the_real_module(self): - contributors, _ = lint.derive_reachable_contributors() - self.assertNotIn("_collector_line_ocr_attempts", contributors) - self.assertNotIn("preprocess_fallback_variants", contributors) + def test_no_helpers_are_excluded_in_the_real_module(self): + # issue #677 emptied EXCLUDED_HELPERS - the real script no longer excludes anything. + self.assertEqual(lint.EXCLUDED_HELPERS, frozenset()) def test_every_declared_owning_key_set_is_non_empty(self): for name, keys in lint.EXTRACTOR_OWNERSHIP.items(): diff --git a/MPCAutofill/cardpicker/golden_set.py b/MPCAutofill/cardpicker/golden_set.py index 0af99db14..53f411791 100644 --- a/MPCAutofill/cardpicker/golden_set.py +++ b/MPCAutofill/cardpicker/golden_set.py @@ -392,6 +392,17 @@ class GoldenExpectation: # (real tesseract found nothing plausible on this sample), which is an honest outcome, not a # placeholder: only 10/30 produced a parseable collector number on this real run, several of # those a 4-digit "year" collector number (mtg/proxy sets) rather than a classic 3-digit one. + # + # Re-verified 2026-08-05 (issue #677, "collapse the Stage C OCR attempt ladder" - dropped the + # ladder's tier 3): none of these 30 values changed. All 30 golden cards were walked through + # the FULL pre-#677 8-attempt ladder with per-tier attribution - same method and the same live + # 450-card (300 blank + 150 success) probe documented in docs/features/catalog-completion-plan.md's + # own "#677" section (the probe script itself is a worktree-only analysis tool, not committed + # here, matching this repo's established convention for one-off probes - see e.g. + # docs/reports/2026-07-23-ocr-preprocessing-probe.md's own LIVE STATE) - not one ever resolved + # (genuine match, lexicon-valid parse, or "best invalid" fallback) uniquely at the removed + # tier, so `COLLECTOR_LINE_OCR_EXTRACTOR_VERSION`'s v2->v3 bump changes zero values here even + # though it changes some values outside this set (see that version constant's own comment). "collector_line_ocr": [ GoldenExpectation(card_id=cid, value=value) for cid, value in { diff --git a/MPCAutofill/cardpicker/image_evidence.py b/MPCAutofill/cardpicker/image_evidence.py index 68e0d78f7..8412c591a 100644 --- a/MPCAutofill/cardpicker/image_evidence.py +++ b/MPCAutofill/cardpicker/image_evidence.py @@ -275,7 +275,6 @@ is_image_truncated, ) from cardpicker.local_ocr import ( - ALTERNATE_TESSERACT_CONFIG, DEFAULT_CROP_BOX, LEGAL_LINE_CROP_BOX, TESSERACT_CONFIG, @@ -300,23 +299,31 @@ LAYOUT_CLASS_EXTRACTOR_VERSION = "layout-class-v1" CROP_COORDINATES_EXTRACTOR_VERSION = "crop-coordinates-v1" # v1 -> v2 (issue #480's combined pass, THE FLIP: settings.OCR_ENGINE default -> "tesserocr" - -# see settings.py's own comment). These extractors' STORED VALUE actually depends on which OCR -# engine produced it (they read through `local_ocr.run_tesseract_text_and_words`, which now runs -# on tesserocr's differently-compiled tesseract/leptonica build by default - issue #423's spike -# already found byte-identical output is structurally unreachable given that vendored-build -# mismatch). Bumping the version is what makes every existing row under the OLD tag stale, so the -# next pass re-extracts it under the new engine rather than silently mixing two engines' output -# under one provenance label (issue #480's correction comment: "Engine swap WITHOUT a version -# bump is forbidden"). -COLLECTOR_LINE_OCR_EXTRACTOR_VERSION = "collector-line-ocr-v2" -# v2 -> v3 (PR #685): wires modern_artist_credit.recognize_artist_credit in as a third artist -# fallback inside compute_card_evidence, recovering a name for ~19,478 cards that previously -# stored none. The extractor's own OCR pass is unchanged from v2 (still issue #480's tesserocr -# engine) - this bump exists solely so Stage C's version-aware resume filter -# (MANIFEST_EXTRACTOR_CURRENT_VERSIONS) selects the whole catalogue for one re-extraction pass -# under the new fallback, per issue #509's stale-value comparison. -ARTIST_OCR_EXTRACTOR_VERSION = "artist-ocr-v3" -COLLECTOR_LINE_TSV_EXTRACTOR_VERSION = "collector-line-tsv-v2" +# see settings.py's own comment). These three extractors are the ones whose STORED VALUE actually +# depends on which OCR engine produced it (they all read through `local_ocr. +# run_tesseract_text_and_words`, which now runs on tesserocr's differently-compiled tesseract/ +# leptonica build by default - issue #423's spike already found byte-identical output is +# structurally unreachable given that vendored-build mismatch). Bumping the version is what makes +# every existing row under the OLD tag stale, so the next pass re-extracts it under the new +# engine rather than silently mixing two engines' output under one provenance label (issue #480's +# correction comment: "Engine swap WITHOUT a version bump is forbidden"). +# v2 -> v3 (issue #677, "collapse the Stage C OCR attempt ladder"): `_collector_line_ocr_attempts` +# drops its own tier 3 (the PSM-11 re-try of tier 1's variants) - see that generator's own +# docstring for the measured evidence. A row whose winning attempt, best-invalid fallback, or +# no-text outcome was previously determined by a tier-3 attempt is stale under the new 2-tier +# ladder; collector_line_tsv's word boxes come from the exact same escalation loop and are +# therefore just as stale - both bump together, matching EXTRACTOR_OWNERSHIP's own "bump every +# listed key together" convention. artist_ocr's raw-text reuse pass scans `collector_raw_texts`, +# which now has up to 2 fewer entries for any card that used to reach tier 3, and would belong in +# this same bump - but "v3" for artist_ocr was already claimed by PR #685 (merged to master, +# unrelated: wires modern_artist_credit.recognize_artist_credit in as a third artist fallback, no +# ladder involvement) before this issue's own bump landed. Stamping this branch's ladder-collapse +# change as "v3" too would put two different behaviours under one version string, defeating +# MANIFEST_EXTRACTOR_CURRENT_VERSIONS's own staleness filter - so artist_ocr alone jumps straight +# to v4 (v3 stays #685's). +COLLECTOR_LINE_OCR_EXTRACTOR_VERSION = "collector-line-ocr-v3" +ARTIST_OCR_EXTRACTOR_VERSION = "artist-ocr-v4" +COLLECTOR_LINE_TSV_EXTRACTOR_VERSION = "collector-line-tsv-v3" # NOT bumped: symbol_region is a raw phash of a crop region (imagehash, no tesseract call at all) # - engine-independent by construction. SYMBOL_REGION_EXTRACTOR_VERSION = "symbol-region-v1" @@ -396,7 +403,7 @@ class ExtractionResult: `short_circuited` (2026-07-21, docs/features/catalog-completion-plan.md's "Recovery-arc lessons" item 1): True iff this card's `collector_line_ocr` pass hit the pre-classification - short-circuit (tier-1 digit-free, tiers 2-3 skipped). Diagnostic-only, same "never persisted + short-circuit (tier-1 digit-free, tier 2 skipped). Diagnostic-only, same "never persisted onto ImageEvidence" convention as `compute_card_evidence`'s own `profile` parameter - the plan's own "open verification gap" note calls for counting this population during the real 197k-card run, not storing a fact per card; `persist_evidence` never reads this attribute. @@ -433,53 +440,66 @@ def _collector_line_ocr_attempts(cropped: Any) -> Iterator[tuple[Any, str, int]] """ Ordered, LAZY (image, tesseract_config, tier) attempts for the `collector_line_ocr` extractor (issue #259, "Stage D no-text bucket: OCR preprocessing/crop recovery") - cheapest/fastest - first, each later tier strictly more expensive than the one before it. A generator (not a - plain list) specifically so the caller's own "stop at the first attempt that parses a - collector number" loop never actually pays for a later tier's preprocessing/OCR cost unless - every earlier tier has already failed - `preprocess_fallback_variants(cropped)` below is not - even CALLED, let alone OCR'd, for the common case where an early attempt already succeeds. - The `tier` element (added for the "Recovery-arc lessons" item 1 pre-classification - short-circuit, docs/features/catalog-completion-plan.md, 2026-07-21) lets the caller detect - "both tier-1 attempts are now exhausted" without hardcoding or re-deriving tier boundaries - from attempt position/count. + first, tier 2 strictly more expensive than tier 1. A generator (not a plain list) + specifically so the caller's own "stop at the first attempt that parses a collector number" + loop never actually pays for tier 2's preprocessing/OCR cost unless tier 1 has already + failed - `preprocess_fallback_variants(cropped)` below is not even CALLED, let alone OCR'd, + for the common case where an early attempt already succeeds. The `tier` element (added for + the "Recovery-arc lessons" item 1 pre-classification short-circuit, + docs/features/catalog-completion-plan.md, 2026-07-21) lets the caller detect "both tier-1 + attempts are now exhausted" without hardcoding or re-deriving tier boundaries from attempt + position/count. - Tier 1 (attempts 1-2, PSM 6): `preprocess_variants`' original two polarity variants - - UNCHANGED from before this issue, still the fast/happy path for the large majority of + UNCHANGED since before issue #259, still the fast/happy path for the large majority of cards that already parse cleanly. - Tier 2 (attempts 3-6, PSM 6): `preprocess_fallback_variants`' four heavier-preprocessed variants (sharpen+heavier-upscale, percentile threshold) - targets the #259 diagnostic's two concrete B-bucket failure modes (blurry uploads, uneven-brightness "garbled but present" text) with better PIXELS, same page-segmentation assumption. - - Tier 3 (attempts 7-8, PSM 11): tier 1's original variants again, but under - `ALTERNATE_TESSERACT_CONFIG` - targets a genuinely different failure mode (tesseract's own - block/line SEGMENTATION going wrong on a noisy crop), not a pixel-quality problem tier 2's - preprocessing can fix. Retried against the original (not fallback-preprocessed) variants - since PSM 11 already drops the block-structure assumption tier 2's heavier processing was - never targeting in the first place. - - Worst case (a card that never parses anything, e.g. a genuine coverage-ceiling case) pays for - all 8 attempts - up to 4x the pre-#259 cost (2 attempts), UNLESS the pre-classification - short-circuit below fires first for a card whose tier-1 attempts are both confidently - digit-free (2026-07-22: non-blank AND digit-free, see `_confidently_digit_free`'s own - docstring - a blank/failed tier-1 read no longer short-circuits). This only hits cards whose - collector line genuinely never resolves to a collector number under ANY of these attempts and - whose tier-1 text is either blank or digit-bearing-but-unparseable; the happy path (an early - tier-1 parse) is unaffected in cost or behavior either way. + + ISSUE #677 REMOVED A THIRD TIER that lived here from issue #259 through #677: a re-try of + tier 1's own variants under `ALTERNATE_TESSERACT_CONFIG` (`--psm 11`, targeting tesseract's + own block/line segmentation rather than a pixel-quality problem). Measured, not guessed: two + independently-sampled real-production probes (450 forced-escalation cards combined - + `MPCAutofill/scripts/experiments/ocr_ladder_tier_attribution.py`'s own 300-card + "currently-blank" pool + 150-card "currently-resolved" pool, 2026-08-05) walked the old + 3-tier ladder to full completion (never stopping early) and recorded, per card, the first + tier at which a CANDIDATE-VALIDATED genuine match appeared + (`local_ocr.validate_against_candidates` against that card's own real name-scoped + candidates - the actual ground-truth check, not merely a lexicon-shaped parse). Result: tier + 2 produced 2 genuine matches across the combined 450-card sample (1 blank-pool rescue, 1 + at-risk success-pool card); the removed tier 3 produced ZERO genuine matches in either + sample - only more lexicon-valid-but-uncorroborated parses (6/300 in the blank pool), the + same "structurally plausible but not this card's own printing" noise both 2026-07-23 + preprocessing probes (`docs/reports/2026-07-23-ocr-preprocessing-probe*.md`) already + characterized as ~99% hopeless art-noise for this population. Cross-checked against all 30 + `golden_set.GOLDEN_CARD_IDS` cards the same way: none of the 30 ever resolved (genuine, + lexicon-valid, or "best invalid" fallback) uniquely at the removed tier, so this collapse + changed zero golden-set expectations. + + Worst case (a card that never parses anything, e.g. a genuine coverage-ceiling case) now pays + for all 6 attempts - up to 3x the pre-#259 cost (2 attempts), down from #259-#677's own 4x + (8 attempts) - UNLESS the pre-classification short-circuit below fires first for a card whose + tier-1 attempts are both confidently digit-free (2026-07-22: non-blank AND digit-free, see + `_confidently_digit_free`'s own docstring - a blank/failed tier-1 read no longer + short-circuits). This only hits cards whose collector line genuinely never resolves to a + collector number under ANY of these attempts and whose tier-1 text is either blank or + digit-bearing-but-unparseable; the happy path (an early tier-1 parse) is unaffected in cost or + behavior either way. """ variants = preprocess_variants(cropped) for variant in variants: yield variant, TESSERACT_CONFIG, 1 for variant in preprocess_fallback_variants(cropped): yield variant, TESSERACT_CONFIG, 2 - for variant in variants: - yield variant, ALTERNATE_TESSERACT_CONFIG, 3 # Tier 1 is exactly `preprocess_variants`' own two polarity variants (see that function's own # docstring - "both polarities of an adaptive-ish threshold") - hardcoded here rather than # re-derived via a redundant extra preprocessing call just to learn the count. If that function's # own variant count ever changes, this constant and `_collector_line_ocr_attempts`' own tier=1 -# yield count must be updated together (already an implicit coupling the "8 attempts total" +# yield count must be updated together (already an implicit coupling the "6 attempts total" # bookkeeping in that function's own docstring already assumes). _COLLECTOR_LINE_TIER1_ATTEMPT_COUNT = 2 @@ -648,8 +668,8 @@ def _confidently_digit_free(tier1_raw_texts: list[str]) -> bool: (`not any(_contains_digit(text) for text in tier1_raw_texts)`) fired identically whether tier-1 read real, digit-free TEXT or read literally NOTHING - an empty/whitespace-only tesseract result is not evidence there's no collector number, it's evidence tier-1 failed to - read anything at all, which is exactly the case a heavier-preprocessed tier 2/an alternate-PSM - tier 3 exists to recover from. A CONFIDENT digit-free read (tier-1 produced real, non-blank + read anything at all, which is exactly the case the heavier-preprocessed tier 2 + exists to recover from. A CONFIDENT digit-free read (tier-1 produced real, non-blank text - a genuine word, watermark, or garbled-but-present line - that simply has no digit character in it) is a much stronger "there's nothing here to find" signal than silence is, and is the only case this now allows to short-circuit. Requires BOTH tier-1 texts to be non-blank @@ -817,8 +837,8 @@ def compute_card_evidence( `short_circuit` (2026-07-21, docs/features/catalog-completion-plan.md's "Recovery-arc lessons" item 1): controls the `collector_line_ocr` pre-classification short-circuit - once BOTH tier-1 attempts fail to parse a collector number, if BOTH attempts' raw text is non-blank and neither - contains a single digit character (`_confidently_digit_free`), tiers 2-3 (6 more tesseract - calls) are skipped and the extractor goes straight to its existing "no-text" outcome, matching + contains a single digit character (`_confidently_digit_free`), tier 2 (4 more tesseract + calls) is skipped and the extractor goes straight to its existing "no-text" outcome, matching the measured finding that a large majority of a real no-text cohort's tier-1 reads were already digit-free and never gained a collector number from the heavier tiers either. `None` (the default) resolves to `_short_circuit_enabled_by_env` (the `STAGE_C_NO_SHORTCIRCUIT` env @@ -835,7 +855,7 @@ def compute_card_evidence( empty (a tesseract read FAILURE, not a confident "no collector number here" finding) yet were short-circuited anyway, silently skipping the deeper tiers that would have recovered the real collector line. `_confidently_digit_free` now additionally requires both tier-1 texts to be - non-blank - an empty/failed tier-1 read always escalates to tiers 2-3, exactly like a + non-blank - an empty/failed tier-1 read always escalates to tier 2, exactly like a digit-bearing unparseable read already did. This is strictly a NARROWING of the short-circuit (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 @@ -853,13 +873,13 @@ def compute_card_evidence( `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 + token, before tier 2 (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 + (the first such parse, by tier order) and escalation continues. If no attempt across all 6 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 @@ -895,7 +915,7 @@ def compute_card_evidence( distinction is the whole design: suppressing only the downstream vote would leave the pipeline having flagged a read as suspect with nothing better to offer. Here a contradicted parse does NOT terminate the loop - later tiers get their chance to produce a read that is internally - consistent, and frequently do. Cost is bounded by the existing 8-attempt ceiling (no new tiers, + consistent, and frequently do. Cost is bounded by the existing 6-attempt ceiling (no new tiers, no unbounded work) and lands inside Stage C's measured ~22.6% idle compute (~170 ms/card idle against a 688 ms/card extraction, fetch-rate-limiter-bound) - the artist recovery itself measures at ~4.6 ms per evaluated attempt. @@ -1111,7 +1131,7 @@ def compute_card_evidence( # "Recovery-arc lessons" item 1; tightened 2026-07-22, parity replay #154 - see # `_confidently_digit_free`'s own docstring for the full autopsy): once both tier-1 # attempts are exhausted with no parse, a card whose tier-1 texts are BOTH non-blank and - # digit-free skips tiers 2-3 entirely rather than paying for 6 more tesseract calls to + # digit-free skips tier 2 entirely rather than paying for 4 more tesseract calls to # re-read the same non-collector-number text more clearly. A digit-bearing tier-1 read # that still fails to parse always escalates exactly as before - `_contains_digit` is a # coarser, cheaper check than `_COLLECTOR_NUMBER_RE` itself (see that helper's own @@ -1119,7 +1139,7 @@ def compute_card_evidence( # 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. This gate governs whether escalation STARTS - # (i.e. whether tiers 2-3 run at all) - entirely independent of the lexicon-validity + # (i.e. whether tier 2 runs 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. # diff --git a/MPCAutofill/cardpicker/management/commands/run_image_evidence_cohort.py b/MPCAutofill/cardpicker/management/commands/run_image_evidence_cohort.py index 903ffbb69..446286b46 100644 --- a/MPCAutofill/cardpicker/management/commands/run_image_evidence_cohort.py +++ b/MPCAutofill/cardpicker/management/commands/run_image_evidence_cohort.py @@ -329,9 +329,9 @@ "geometry_bleed": "geometry-bleed-v1", "layout_class": "layout-class-v1", "crop_coordinates": "crop-coordinates-v1", - "collector_line_ocr": "collector-line-ocr-v2", - "artist_ocr": "artist-ocr-v3", - "collector_line_tsv": "collector-line-tsv-v2", + "collector_line_ocr": "collector-line-ocr-v3", + "artist_ocr": "artist-ocr-v4", + "collector_line_tsv": "collector-line-tsv-v3", "artbox_phash": "artbox-phash-v1", "symbol_region": "symbol-region-v1", "legal_line": "legal-line-v2", diff --git a/MPCAutofill/cardpicker/tests/test_image_evidence.py b/MPCAutofill/cardpicker/tests/test_image_evidence.py index 19acf02f4..792735ab7 100644 --- a/MPCAutofill/cardpicker/tests/test_image_evidence.py +++ b/MPCAutofill/cardpicker/tests/test_image_evidence.py @@ -1050,9 +1050,8 @@ def counting(image_arg, config): result = fetch_and_compute_card_evidence_for_tests(card) assert result.skip_reasons["collector_line_ocr"] == "no-text" - assert len(configs_used) == 8 # blank tier-1 no longer qualifies - every tier tried + assert len(configs_used) == 6 # blank tier-1 no longer qualifies - every tier tried assert configs_used.count(module.TESSERACT_CONFIG) == 6 - assert configs_used.count(module.ALTERNATE_TESSERACT_CONFIG) == 2 assert result.short_circuited is False def test_confidently_digit_free_tier_one_still_short_circuits_by_default(self, db, monkeypatch): @@ -1084,9 +1083,10 @@ def _stub(image_arg, config): def test_no_legible_text_exhausts_every_fallback_tier_when_short_circuit_is_disabled(self, db, monkeypatch): """issue #259's original behavior, preserved behind the escape hatch (2026-07-21, item 1): - a blank crop must genuinely try every tier (2 base + 4 fallback + 2 alternate-PSM = 8 - attempts) before recording "no-text" when `short_circuit=False` - the measurement-run - path a real re-profile would use to gather the plan's own "open verification gap" data.""" + a blank crop must genuinely try every tier (2 base + 4 fallback = 6 attempts, issue #677's + collapsed ladder) before recording "no-text" when `short_circuit=False` - the + measurement-run path a real re-profile would use to gather the plan's own "open + verification gap" data.""" card = CardFactory(content_phash=1) image = _build_card_image([(DEFAULT_CROP_BOX, "")]) monkeypatch.setattr(module, "fetch_card_image", lambda card, dpi=None: image) @@ -1103,9 +1103,8 @@ def counting(image_arg, config): result = fetch_and_compute_card_evidence_for_tests(card, short_circuit=False) assert result.skip_reasons["collector_line_ocr"] == "no-text" - assert len(configs_used) == 8 + assert len(configs_used) == 6 assert configs_used.count(module.TESSERACT_CONFIG) == 6 # 2 base + 4 fallback, PSM 6 - assert configs_used.count(module.ALTERNATE_TESSERACT_CONFIG) == 2 # base variants, PSM 11 assert result.short_circuited is False def test_digit_bearing_tier_one_failure_still_escalates_by_default(self, db, monkeypatch): @@ -1136,7 +1135,7 @@ def _stub(image_arg, config): result = fetch_and_compute_card_evidence_for_tests(card) assert result.skip_reasons["collector_line_ocr"] == "no-text" - assert len(calls) == 8 # every tier tried - digit-bearing tier-1 text never short-circuits + assert len(calls) == 6 # every tier tried - digit-bearing tier-1 text never short-circuits assert result.short_circuited is False def test_short_circuit_only_fires_once_both_tier_one_attempts_are_digit_free(self, db, monkeypatch): @@ -1150,10 +1149,10 @@ def test_short_circuit_only_fires_once_both_tier_one_attempts_are_digit_free(sel monkeypatch.setattr(module, "fetch_card_image", lambda card, dpi=None: image) # attempt 1: digit-free. attempt 2: digit-bearing but unparseable (same shape as - # test_digit_bearing_tier_one_failure_still_escalates_by_default above). Attempts 3-8 - # (tiers 2-3): blank, never parses either - the point is that escalation happens AT ALL, - # not what tiers 2-3 themselves find. - texts = iter(["no digits here", "abc123456789xyz", "", "", "", "", "", ""]) + # test_digit_bearing_tier_one_failure_still_escalates_by_default above). Attempts 3-6 + # (tier 2): blank, never parses either - the point is that escalation happens AT ALL, + # not what tier 2 itself finds. + texts = iter(["no digits here", "abc123456789xyz", "", "", "", ""]) calls: list[str] = [] def _stub(image_arg, config): @@ -1165,7 +1164,7 @@ def _stub(image_arg, config): result = fetch_and_compute_card_evidence_for_tests(card) assert result.skip_reasons["collector_line_ocr"] == "no-text" - assert len(calls) == 8 # escalated past tier 1 despite attempt 1 alone being digit-free + assert len(calls) == 6 # escalated past tier 1 despite attempt 1 alone being digit-free assert result.short_circuited is False def test_happy_path_never_computes_fallback_preprocessing(self, db, monkeypatch): @@ -1278,7 +1277,7 @@ def _stub(image_arg, config): 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 + assert len(calls) == 6 # 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, @@ -1494,7 +1493,7 @@ def _stub(image_arg, config): assert result.fields["collector_line_collector_number"] == "158" assert result.fields["collector_line_set_code"] == "mom" assert "collector_line_ocr" not in result.skip_reasons # a collector_number WAS found - assert len(calls) == 8 # every tier tried before falling back + assert len(calls) == 6 # every tier tried before falling back def test_contradicted_parse_is_preferred_over_a_lexicon_invalid_one(self, db, monkeypatch): """Fallback precedence: a lexicon-valid-but-contradicted parse beats a lexicon-INVALID @@ -1900,25 +1899,25 @@ def _run_tesseract(variant, **kwargs): 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` - element (2026-07-21, "Recovery-arc lessons" item 1) lets the caller detect "both tier-1 - attempts exhausted" for the pre-classification short-circuit. No tesseract call happens in - these tests (only `preprocess_variants`' PIL-only grayscale/threshold work, plus - when the - fallback tier is actually reached - `preprocess_fallback_variants`' own PIL-only work) - these - tests are about the ORDERING/laziness contract, not OCR output, so they're fast and don't need - a real card image.""" - - def test_yields_eight_attempts_in_the_documented_tier_order(self): + """Direct tests of `_collector_line_ocr_attempts` (issue #259, collapsed from 3 tiers to 2 by + issue #677) - the lazy, ordered (image, tesseract_config, tier) generator `collector_line_ocr`'s + own loop consumes. The `tier` element (2026-07-21, "Recovery-arc lessons" item 1) lets the + caller detect "both tier-1 attempts exhausted" for the pre-classification short-circuit. No + tesseract call happens in these tests (only `preprocess_variants`' PIL-only grayscale/threshold + work, plus - when the fallback tier is actually reached - `preprocess_fallback_variants`' own + PIL-only work) - these tests are about the ORDERING/laziness contract, not OCR output, so + they're fast and don't need a real card image.""" + + def test_yields_six_attempts_in_the_documented_tier_order(self): crop = Image.new("RGB", (60, 24), "black") attempts = list(module._collector_line_ocr_attempts(crop)) - assert len(attempts) == 8 + assert len(attempts) == 6 # issue #677 collapsed the old 3-tier/8-attempt ladder to 2/6 configs = [config for _variant, config, _tier in attempts] - assert configs == [module.TESSERACT_CONFIG] * 6 + [module.ALTERNATE_TESSERACT_CONFIG] * 2 + assert configs == [module.TESSERACT_CONFIG] * 6 tiers = [tier for _variant, _config, tier in attempts] - assert tiers == [1, 1, 2, 2, 2, 2, 3, 3] + assert tiers == [1, 1, 2, 2, 2, 2] def test_never_calls_fallback_variants_if_consumer_stops_after_tier_one(self, monkeypatch): crop = Image.new("RGB", (60, 24), "black") diff --git a/docs/features/catalog-completion-plan.md b/docs/features/catalog-completion-plan.md index 2726060c0..776dcc540 100644 --- a/docs/features/catalog-completion-plan.md +++ b/docs/features/catalog-completion-plan.md @@ -1894,7 +1894,10 @@ ladder (`_collector_line_ocr_attempts`, issue #259) — both being worked on in branches at the time this sweep landed. Each is already the direct, named mechanism of an existing versioned extractor (`geometry_bleed`/`layout_class`/`artbox_phash`/`collector_line_ocr`), so their omission is a sequencing decision, not a coverage gap this sweep itself leaves open — lifting each -exclusion is one entry once its own parallel branch lands. +exclusion is one entry once its own parallel branch lands. **The OCR-ladder half of this exclusion +was lifted by issue #677** (see this file's own "#677" section below) — `_collector_line_ocr_attempts` +and `preprocess_fallback_variants` now carry real `EXTRACTOR_OWNERSHIP` entries; the +`local_fallback.py` half remains excluded, unrelated to that branch. CORRECTION TO THE ORIGINATING BRIEF: `local_art_edge.classify_art_edge_continuity` (issue #617's own "Extended" art-edge continuity classifier) was named as a candidate for this sweep but is NOT @@ -1909,6 +1912,48 @@ No stored `ImageEvidence` value changes as a result of this entry: all seventeen contributors were already feeding their currently-versioned extractor(s) before this landed; the work is declaration and CI enforcement only, not a version bump or a re-extraction trigger. +**Collapse the Stage C OCR attempt ladder (issue #677, 2026-08-05)** — `_collector_line_ocr_attempts` +(issue #259) escalated through 3 tiers/8 tesseract calls for any card whose tier 1 (2 attempts) +failed to parse a collector number: tier 2 (4 heavier-preprocessed variants, PSM 6) then tier 3 (a +re-try of tier 1's own variants under PSM 11). Verified against real production images before +touching the code, not from the 2026-07-23 probes alone (those tested ADDING new preprocessing +methods, never removing an existing tier): two fresh probes (worktree-only analysis scripts, NOT +committed to master/any branch — MPCAutofill/scripts/experiments/ocr_ladder_tier_attribution.py, +450 forced-escalation cards — 300 currently-blank + 150 currently-resolved, both freshly +re-derived live 2026-08-05) walked the +old 3-tier ladder to full completion and recorded, per card, the first tier at which a +CANDIDATE-VALIDATED genuine match appeared. Tier 2 produced 2 genuine matches across the 450-card +sample; tier 3 produced ZERO — only more lexicon-valid-but-uncorroborated noise (6/300 in the +blank pool), consistent with the 2026-07-23 probes' own ~99% hopeless-art-noise characterization +of this population. **Tier 3 removed.** Cross-checked against all 30 `golden_set.GOLDEN_CARD_IDS` +cards the same way: none of the 30 ever resolved uniquely at tier 3, so this collapse changes zero +golden-set expectations (see that file's own `collector_line_ocr` comment for the re-verification +note). `COLLECTOR_LINE_OCR_EXTRACTOR_VERSION`/`COLLECTOR_LINE_TSV_EXTRACTOR_VERSION` bumped v2->v3 +(both read through the same escalation loop, per `EXTRACTOR_OWNERSHIP`'s own "bump every listed +key together" convention). `ARTIST_OCR_EXTRACTOR_VERSION` belongs in that same bump for the +identical reason (its raw-text reuse pass scans `collector_raw_texts`, now up to 2 entries +shorter per card) but jumps straight to v4 instead of v3: PR #685 (merged to master separately, +unrelated modern_artist_credit fallback wiring) already claimed "v3" for that extractor before +this issue's bump landed, and stamping two different behaviours under one version string would +defeat the whole point of `MANIFEST_EXTRACTOR_CURRENT_VERSIONS`'s staleness filter. `LEGAL_LINE_EXTRACTOR_VERSION` +unchanged. `EXTRACTOR_OWNERSHIP`'s `EXCLUDED_HELPERS` entry for the ladder (added by the extractor- +ownership-visibility entry just above) is LIFTED as part of this same change — `_collector_line_ocr_attempts` +and `preprocess_fallback_variants` now carry real ownership entries. + +A second restructuring named in this issue's own brief - unifying `collector_line_ocr`'s narrow +crop and `legal_line`'s full-width crop (same y-band, `local_ocr.DEFAULT_CROP_BOX` vs +`LEGAL_LINE_CROP_BOX`) into ONE OCR pass - was investigated and NOT shipped: a real-image accuracy +probe (worktree-only analysis script, NOT committed to master/any branch — +MPCAutofill/scripts/experiments/ocr_crop_widen_accuracy.py, 150 currently-successful production +cards) found that re-reading tier 1 from the wider crop with the SAME preprocessing +regressed 46/150 (30.7%) of currently-correct `collector_line_set_code`/`collector_line_collector_number` +reads - mostly proxy/watermark text (`"PsilosX Proxy"`, `"Playtest Card"`, `"NOT FOR SALE"`, +artist full names) getting picked up by `_SET_CODE_RE`'s "search after the number" fallback in +place of the real set code, and in some cases the wider image causing tesseract to drop the +collector-number line entirely. Zero improvements found (0/150). This is exactly the "collapse +costs measurable accuracy, report it rather than shipping" outcome the issue's own brief +anticipated - the duplicated bottom-band read stays duplicated; only the ladder collapsed. + **Stage C bulk driver: compute profile + concurrency/OCR-cost fix (2026-07-20)** — `docs/reports/2026-07-20-pipeline-compute-profile.md` measured the bulk cohort driver (`run_image_evidence_cohort.py`, previously landed on an unmerged worktree branch only, ported to