Fix local_lands_identify write-path vote collision and dry-run yield display - #411
Merged
WilfordGrimley merged 1 commit intoJul 24, 2026
Merged
Conversation
This was referenced Jul 24, 2026
WilfordGrimley
added a commit
that referenced
this pull request
Jul 24, 2026
* Fix Stage E concurrent-dispatch vote-collision IntegrityError Two concurrent dispatch_micro_batch invocations (django-q2's 8 workers, or the backstop sweep racing an event trigger) could both pass Stage D's per-identity eligibility check before either committed, then race to bulk_create the same (card, anonymous_id) CardPrintingTag - the loser hit IntegrityError and aborted its whole micro-batch (trip envtrip-20260724T214616-be6e5db9, failed run_ids stage-e-stream-20260724T2144*). Adds a pre-write skip-if-exists guard (_split_new_printing_tag_votes, mirroring PR #411's precedent) to run_join_key_calculator and run_fallback_calculator - skip-and-count, not retract-and-recast, since a concurrent race yields the same verdict from the same evidence, not a genuine conclusion change. run_slow_path_calculator needs no equivalent guard (CardScanLog carries no DB uniqueness constraint). Corrects stage-e-operations.md's overstated "eligibility exclude alone is idempotent" claim for the concurrent (not just sequential) case. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Correct incident facts, add ignore_conflicts belt-and-suspenders (Tron gate) Tron gate on PR #448: seven failed run_ids + one winner (not four) = Q_CLUSTER workers=8; separates the vote-collision failure (this guard) from the SEPARATE envtrip-20260724T214616 host-load trip (11.85 vs 7.0, 8 concurrent OCR dispatches on 7 cores, not fixed by this change); qualifies the "same verdict" premise as contingent on unchanged code/ evidence/lexicon, naming reparse_collector_evidence as the remedy otherwise; adds bulk_create(..., ignore_conflicts=True) as the actual crash-proofing against the guard's own residual check-then-insert race window (precedent: local_layout_class_cast.py:300, local_detect_ai_art.py:459, local_identify_printing_tags.py:1246), with a regression test defeating the pre-write check to prove it; adds an ops-doc runbook line against running BULK-mode writes while PASSIVE streaming is enabled. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
4 tasks
WilfordGrimley
added a commit
that referenced
this pull request
Jul 28, 2026
…tomicity, and 1:1 printing resolution (#525) (#526) * fix: remove dead import of renamed measure_bleed_diff_mm in image_evidence 53c1484 renamed measure_bleed_diff_mm -> compute_bleed_diff_mm in local_fallback.py without leaving an alias. image_evidence.py still imported the old name, raising ImportError at module import time for every one of its ten importers (Stage C/D/E). This removes the dead import and the now-unreachable fallback call at the old bleed_diff_mm assignment, letting compute_bleed_diff_mm's own assignment stand as the sole source of that field. * test: remove orphaned test_artist_external_links.py 53c1484 added this test file for an MTGAC proxy view (get_artist_external_links) and imports from cardpicker.views that were never actually committed - the feature has an unstarted design. The test aborted collection for the entire backend suite (Interrupted: 1 error during collection) and was pushed unformatted (isort + black both flagged it). Deleting it is safe and reversible: the file remains in git history at 53c1484 and will be recovered when the MTGAC integration is actually built. Not implementing the missing view, adding skip/xfail markers, or merely reformatting - those would leave a test asserting against code that does not exist. * fix: Stage D illustration hot-path regressions + vote-write ordering/atomicity `dispatch_micro_batch` is a streaming conveyor processing cards in 25-card micro-batches; anything it calls must cost O(batch), never O(catalog) (issues #458/#460). `_run_stage_d` calls `run_illustration_calculator` once per micro-batch. The illustration calculator (added 2026-07-28) never carried over the issue-#469 fixes that `local_calculate_verdicts.py` already has. 1. CandidateNameIndex cache bypass (local_illustration.py:468) `candidate_name_index = CandidateNameIndex()` — a direct constructor call whose own comment claimed "same cached pattern as `_get_cached_candidate_name_index()`". It was not: it bypassed the cache entirely. Cost before: a 113,224-row CanonicalCard scan, measured 1.48s, per micro-batch with at least one artist-OCR card. Now calls `local_calculate_verdicts._get_cached_candidate_name_index()`. 2. Unscoped materialization (local_illustration.py:385-394) Both join-key no-hit populations were wrapped in `list(...)`. Cost before: every join-key `is_no_match` CardPrintingTag row and every join-key no-hit CardScanLog row (CardScanLog is 2,093,147 rows live, append-only) pulled into process memory on every micro-batch, before any card_ids scoping. Now lazy querysets compiled into SQL subqueries, matching `_fallback_eligible_cards_queryset`. 3. card_ids applied late (local_illustration.py:211-253, 396-401) `_eligible_illustration_cards_queryset` took no `card_ids`; the caller filtered afterwards, so the CardScanLog exclusion subquery inside it compiled unscoped. Cost before: a full pass over 2,093,147 rows per micro-batch. Now mirrors `_eligible_cards_queryset:966-996` exactly, including `.filter(card_id__in=card_ids)` on the subquery, with issue #469's reasoning preserved. 4. IllustrationIndex rebuild (local_illustration.py:128-165, 411) Two catalog-wide CanonicalCard queries (one filtered on illustration_id, one UNFILTERED over every card with an artist) plus full-catalog dicts, constructed per invocation i.e. per micro-batch. Now memoized per worker process behind a version stamp, same shape as `_get_cached_candidate_name_index()`. The stamp adds a fifth term — the count of non-null `illustration_id` — because that column is BACKFILLED IN PLACE by `import_scryfall_printing_metadata`, an UPDATE that moves neither max pk nor row count. The no-op `.select_related(...)` chained before a `.values_list()` with traversal args was removed. 5. `already_voted` was structurally 0 (local_calculate_verdicts.py:1286-1293, 1614-1620; local_illustration.py:532) `purge_stale_machine_votes` deletes by calculator FAMILY, which includes the caller's own current anonymous_id — so running it before `_split_new_printing_tag_votes` deleted exactly the rows the split then looked for. The counter read 0 in every deployment forever: the literal "zero forever would suggest the guard itself is dead code" failure `stage_e_dispatch.py:250-253` warns about. Split now runs first, and the purge is scoped to `new_votes` so a skipped card keeps the winner's row. Calculator-version self-overwrite (#519/#520) is preserved: the split checks the exact current anonymous_id, so a stale `-v1` row is never a collision and its card is still purged and overwritten. 6. Cancel-safety (same three sites) The purge is a DELETE and the insert a separate statement. A process killed between them — which this operator does deliberately, mid-flight — lost votes with nothing written back. Both are now inside one `transaction.atomic()`, via the new shared `_purge_and_write_printing_tag_votes` primitive. Tests: +17 net. New coverage for item 3 (the CardScanLog subquery is genuinely narrowed in the compiled SQL, and BULK mode is untouched), item 4 (process cache reuse, invalidation on insert and on in-place illustration_id backfill, zero index builds for an empty micro-batch), item 6 (a failed insert rolls the purge back), and items 1/5. Three existing tests changed behaviour, all of them assertions added by #519/#520 that codified the regression above ("Update two concurrent-dispatch collision tests: purge changes 'skip' semantics to 'overwrite'"): `test_local_calculate_verdicts.py` join-key and fallback collision tests and `test_stage_e_dispatch.py`'s ledger assertion now expect `already_voted == 1` and the winner's row surviving, which is the semantic `local_lands_identify`'s equivalent test has asserted since PR #411. Refs #458, #460, #469, #507, #519 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN * test: derive RSS-ceiling tests from RSS_MB_PER_WORKER_CEILING, not a hardcoded 512 70225df raised the RSS ceiling 512->768MB in operating_envelope.py but updated zero tests. test_operating_envelope.py::test_trips_above_512mb_per_worker fed a literal 512.1, which is now under the 768 ceiling, so check_envelope() returned None and the trip-not-None assertion failed. test_resolve_envelope_trip.py::test_acknowledging_one_trip_does_not_touch_another cascaded from the same cause: it built its second trip with a literal 600.0, also now under the ceiling, so no trip was ever created for it to acknowledge. Both now derive the RSS value from RSS_MB_PER_WORKER_CEILING (already imported and already used this way in test_operating_envelope.py's own test_exactly_at_the_ceiling_does_not_trip) instead of a hardcoded number, so a future ceiling change can't silently rot these tests the same way again. Renamed test_trips_above_512mb_per_worker -> test_trips_above_rss_ceiling_per_worker since its name no longer hardcodes the ceiling either. No production code touched - operating_envelope.py and the 768 ceiling are unchanged. * fix: illustration calculator resolves a printing only at 1:1 (#525) BEHAVIOUR CHANGE — reduces illustration vote coverage. Deliberately its own commit so it can be evaluated or reverted independently of the performance work in the previous commit. THE DEFECT. `calculate_illustration_verdict` returned MULTIPLE `printing_pks` from both terminal branches, and the runner emits one `CardPrintingTag` per printing pk. One machine identity therefore voted simultaneously for several MUTUALLY EXCLUSIVE printings of the same card: - N>1 illustrations (local_illustration.py:305-320): "union of printings across all illustrations, each at BASE_CONFIDENCE/N". - N==1 illustration (local_illustration.py:322-329): "vote every printing at full BASE_CONFIDENCE". Since illustration_id → printing is 1:N, this fired for ANY reprinted artwork — the common case, not an edge case. The confidence spread does not mitigate it. `resolve_vote_weight(source, anonymous_id)` takes no confidence argument and `VoteTuple` has no confidence field, so confidence never reaches the tally: every row landed at full `PRINTING_TAG_MACHINE_WEIGHT`. `cardprintingtag_unique_printing_vote` is on (card, printing, anonymous_id), so all N rows persist. Where md5 identity-group pooling is active they read as self-contradiction and get withheld; where it is not, they all count for outcomes that cannot all be true. THE FIX. A printing vote is cast ONLY when the surviving set resolves to exactly one printing pk — exactly one illustration AND that illustration mapping to exactly one printing. Every other case abstains with a CardScanLog row. TWO DISTINCT ABSTAIN REASONS, because only one of them is recoverable: - `multiple-illustrations` (N>1 illustrations): no single illustration identity exists. Counts only; no representative is invented. - `multiple-printings-one-illustration` (1 illustration, N printings): the illustration identity IS known with full confidence — only the printing choice is undetermined. `IllustrationVerdict` retains `illustration_id` and `candidate_printing_pks` so issue #524 (`CardIllustrationVote`) can persist it without re-deriving anything. The retained values live in new verdict fields, NOT in `printing_pks`, so no future reordering of the runner's loop can turn retained evidence into cast votes. Nothing is persisted beyond the skip_reason string: no new model, no new field, no migration. Persistence is #524 and separately owned — this abstain is the seam it plugs into, not the intended end state. MEASURABILITY. `IllustrationCalculatorResult` gains `cards_abstained_ambiguous` and `printing_votes_withheld` (the exact number of CardPrintingTag rows the pre-#525 code would have written). Both are counted in dry-run mode, so the coverage figure is obtainable without a live write. The audit sample carries the retained narrowing per abstained card. Not changed, deliberately: `stage-d-illustration-v1`'s anonymous_id and version string (#525 records that a bump is unsafe pending separate investigation); `vote_consensus.py`/`printing_consensus.py` (confidence is NOT made load-bearing — that is a separate owner decision and the consensus code carries its own warning against it); the artist input, which stays OCR-evidence-derived (#523). Tests: one existing test asserted the defect and was rewritten, not quietly changed — `test_multiple_illustrations_spreads_confidence` asserted `skip_reason == ""`, two printing_pks and `confidence == BASE_CONFIDENCE / 2`; it is now `test_multiple_illustrations_abstains_and_retains_no_identity`. `test_single_illustration_votes_all_printings` was renamed (its fixture only ever built one printing, so it never exercised the multi-printing path its name described). New: 1-illustration-N-printings abstains and retains the illustration; N>1 abstains and retains nothing; 1:1 casts exactly one vote at BASE_CONFIDENCE; the two reasons are distinguishable; a printing reached twice via two candidates sharing an artist is still 1:1; and five end-to-end runner tests covering what actually reaches the DB. Refs #525, #524, #523, #507 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
WilfordGrimley
added a commit
that referenced
this pull request
Jul 29, 2026
…e writer, and the #523 invariant lock (#524) (#531) * fix: remove dead import of renamed measure_bleed_diff_mm in image_evidence 53c1484 renamed measure_bleed_diff_mm -> compute_bleed_diff_mm in local_fallback.py without leaving an alias. image_evidence.py still imported the old name, raising ImportError at module import time for every one of its ten importers (Stage C/D/E). This removes the dead import and the now-unreachable fallback call at the old bleed_diff_mm assignment, letting compute_bleed_diff_mm's own assignment stand as the sole source of that field. * test: remove orphaned test_artist_external_links.py 53c1484 added this test file for an MTGAC proxy view (get_artist_external_links) and imports from cardpicker.views that were never actually committed - the feature has an unstarted design. The test aborted collection for the entire backend suite (Interrupted: 1 error during collection) and was pushed unformatted (isort + black both flagged it). Deleting it is safe and reversible: the file remains in git history at 53c1484 and will be recovered when the MTGAC integration is actually built. Not implementing the missing view, adding skip/xfail markers, or merely reformatting - those would leave a test asserting against code that does not exist. * fix: Stage D illustration hot-path regressions + vote-write ordering/atomicity `dispatch_micro_batch` is a streaming conveyor processing cards in 25-card micro-batches; anything it calls must cost O(batch), never O(catalog) (issues #458/#460). `_run_stage_d` calls `run_illustration_calculator` once per micro-batch. The illustration calculator (added 2026-07-28) never carried over the issue-#469 fixes that `local_calculate_verdicts.py` already has. 1. CandidateNameIndex cache bypass (local_illustration.py:468) `candidate_name_index = CandidateNameIndex()` — a direct constructor call whose own comment claimed "same cached pattern as `_get_cached_candidate_name_index()`". It was not: it bypassed the cache entirely. Cost before: a 113,224-row CanonicalCard scan, measured 1.48s, per micro-batch with at least one artist-OCR card. Now calls `local_calculate_verdicts._get_cached_candidate_name_index()`. 2. Unscoped materialization (local_illustration.py:385-394) Both join-key no-hit populations were wrapped in `list(...)`. Cost before: every join-key `is_no_match` CardPrintingTag row and every join-key no-hit CardScanLog row (CardScanLog is 2,093,147 rows live, append-only) pulled into process memory on every micro-batch, before any card_ids scoping. Now lazy querysets compiled into SQL subqueries, matching `_fallback_eligible_cards_queryset`. 3. card_ids applied late (local_illustration.py:211-253, 396-401) `_eligible_illustration_cards_queryset` took no `card_ids`; the caller filtered afterwards, so the CardScanLog exclusion subquery inside it compiled unscoped. Cost before: a full pass over 2,093,147 rows per micro-batch. Now mirrors `_eligible_cards_queryset:966-996` exactly, including `.filter(card_id__in=card_ids)` on the subquery, with issue #469's reasoning preserved. 4. IllustrationIndex rebuild (local_illustration.py:128-165, 411) Two catalog-wide CanonicalCard queries (one filtered on illustration_id, one UNFILTERED over every card with an artist) plus full-catalog dicts, constructed per invocation i.e. per micro-batch. Now memoized per worker process behind a version stamp, same shape as `_get_cached_candidate_name_index()`. The stamp adds a fifth term — the count of non-null `illustration_id` — because that column is BACKFILLED IN PLACE by `import_scryfall_printing_metadata`, an UPDATE that moves neither max pk nor row count. The no-op `.select_related(...)` chained before a `.values_list()` with traversal args was removed. 5. `already_voted` was structurally 0 (local_calculate_verdicts.py:1286-1293, 1614-1620; local_illustration.py:532) `purge_stale_machine_votes` deletes by calculator FAMILY, which includes the caller's own current anonymous_id — so running it before `_split_new_printing_tag_votes` deleted exactly the rows the split then looked for. The counter read 0 in every deployment forever: the literal "zero forever would suggest the guard itself is dead code" failure `stage_e_dispatch.py:250-253` warns about. Split now runs first, and the purge is scoped to `new_votes` so a skipped card keeps the winner's row. Calculator-version self-overwrite (#519/#520) is preserved: the split checks the exact current anonymous_id, so a stale `-v1` row is never a collision and its card is still purged and overwritten. 6. Cancel-safety (same three sites) The purge is a DELETE and the insert a separate statement. A process killed between them — which this operator does deliberately, mid-flight — lost votes with nothing written back. Both are now inside one `transaction.atomic()`, via the new shared `_purge_and_write_printing_tag_votes` primitive. Tests: +17 net. New coverage for item 3 (the CardScanLog subquery is genuinely narrowed in the compiled SQL, and BULK mode is untouched), item 4 (process cache reuse, invalidation on insert and on in-place illustration_id backfill, zero index builds for an empty micro-batch), item 6 (a failed insert rolls the purge back), and items 1/5. Three existing tests changed behaviour, all of them assertions added by #519/#520 that codified the regression above ("Update two concurrent-dispatch collision tests: purge changes 'skip' semantics to 'overwrite'"): `test_local_calculate_verdicts.py` join-key and fallback collision tests and `test_stage_e_dispatch.py`'s ledger assertion now expect `already_voted == 1` and the winner's row surviving, which is the semantic `local_lands_identify`'s equivalent test has asserted since PR #411. Refs #458, #460, #469, #507, #519 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN * test: derive RSS-ceiling tests from RSS_MB_PER_WORKER_CEILING, not a hardcoded 512 70225df raised the RSS ceiling 512->768MB in operating_envelope.py but updated zero tests. test_operating_envelope.py::test_trips_above_512mb_per_worker fed a literal 512.1, which is now under the 768 ceiling, so check_envelope() returned None and the trip-not-None assertion failed. test_resolve_envelope_trip.py::test_acknowledging_one_trip_does_not_touch_another cascaded from the same cause: it built its second trip with a literal 600.0, also now under the ceiling, so no trip was ever created for it to acknowledge. Both now derive the RSS value from RSS_MB_PER_WORKER_CEILING (already imported and already used this way in test_operating_envelope.py's own test_exactly_at_the_ceiling_does_not_trip) instead of a hardcoded number, so a future ceiling change can't silently rot these tests the same way again. Renamed test_trips_above_512mb_per_worker -> test_trips_above_rss_ceiling_per_worker since its name no longer hardcodes the ceiling either. No production code touched - operating_envelope.py and the 768 ceiling are unchanged. * fix: illustration calculator resolves a printing only at 1:1 (#525) BEHAVIOUR CHANGE — reduces illustration vote coverage. Deliberately its own commit so it can be evaluated or reverted independently of the performance work in the previous commit. THE DEFECT. `calculate_illustration_verdict` returned MULTIPLE `printing_pks` from both terminal branches, and the runner emits one `CardPrintingTag` per printing pk. One machine identity therefore voted simultaneously for several MUTUALLY EXCLUSIVE printings of the same card: - N>1 illustrations (local_illustration.py:305-320): "union of printings across all illustrations, each at BASE_CONFIDENCE/N". - N==1 illustration (local_illustration.py:322-329): "vote every printing at full BASE_CONFIDENCE". Since illustration_id → printing is 1:N, this fired for ANY reprinted artwork — the common case, not an edge case. The confidence spread does not mitigate it. `resolve_vote_weight(source, anonymous_id)` takes no confidence argument and `VoteTuple` has no confidence field, so confidence never reaches the tally: every row landed at full `PRINTING_TAG_MACHINE_WEIGHT`. `cardprintingtag_unique_printing_vote` is on (card, printing, anonymous_id), so all N rows persist. Where md5 identity-group pooling is active they read as self-contradiction and get withheld; where it is not, they all count for outcomes that cannot all be true. THE FIX. A printing vote is cast ONLY when the surviving set resolves to exactly one printing pk — exactly one illustration AND that illustration mapping to exactly one printing. Every other case abstains with a CardScanLog row. TWO DISTINCT ABSTAIN REASONS, because only one of them is recoverable: - `multiple-illustrations` (N>1 illustrations): no single illustration identity exists. Counts only; no representative is invented. - `multiple-printings-one-illustration` (1 illustration, N printings): the illustration identity IS known with full confidence — only the printing choice is undetermined. `IllustrationVerdict` retains `illustration_id` and `candidate_printing_pks` so issue #524 (`CardIllustrationVote`) can persist it without re-deriving anything. The retained values live in new verdict fields, NOT in `printing_pks`, so no future reordering of the runner's loop can turn retained evidence into cast votes. Nothing is persisted beyond the skip_reason string: no new model, no new field, no migration. Persistence is #524 and separately owned — this abstain is the seam it plugs into, not the intended end state. MEASURABILITY. `IllustrationCalculatorResult` gains `cards_abstained_ambiguous` and `printing_votes_withheld` (the exact number of CardPrintingTag rows the pre-#525 code would have written). Both are counted in dry-run mode, so the coverage figure is obtainable without a live write. The audit sample carries the retained narrowing per abstained card. Not changed, deliberately: `stage-d-illustration-v1`'s anonymous_id and version string (#525 records that a bump is unsafe pending separate investigation); `vote_consensus.py`/`printing_consensus.py` (confidence is NOT made load-bearing — that is a separate owner decision and the consensus code carries its own warning against it); the artist input, which stays OCR-evidence-derived (#523). Tests: one existing test asserted the defect and was rewritten, not quietly changed — `test_multiple_illustrations_spreads_confidence` asserted `skip_reason == ""`, two printing_pks and `confidence == BASE_CONFIDENCE / 2`; it is now `test_multiple_illustrations_abstains_and_retains_no_identity`. `test_single_illustration_votes_all_printings` was renamed (its fixture only ever built one printing, so it never exercised the multi-printing path its name described). New: 1-illustration-N-printings abstains and retains the illustration; N>1 abstains and retains nothing; 1:1 casts exactly one vote at BASE_CONFIDENCE; the two reasons are distinguishable; a printing reached twice via two candidates sharing an artist is still 1:1; and five end-to-end runner tests covering what actually reaches the DB. Refs #525, #524, #523, #507 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN * feat: CardIllustrationVote + machine writer, read-side narrowing, #523 lock Closes #524, closes #523. Stacked on #526 (fix/illustration-hotpath-regressions) — must merge after it. An `illustration_id` identifies an ARTWORK, and artwork-to-printing is 1:N (~2.2 printings per artwork across the catalogue), so identifying the artwork narrows the printing but usually does not determine it. None of the four existing vote tables can express "this card depicts illustration X", so when the illustration calculator confidently identifies an artwork mapping to several printings, #526 makes it abstain and log a skip reason — establishing the identity with full confidence and then discarding it. This closes that gap. THE MODEL. `CardIllustrationVote(AbstractWeightedVote)` with card FK (related_name="illustration_votes"), a plain indexed UUIDField `illustration_id` (NOT an FK — there is no CanonicalIllustration table and this must not create one), and `is_unknown`. Two constraints: - CheckConstraint illustration_id XOR is_unknown, mirroring `cardartistvote_artist_xor_unknown`. - UniqueConstraint on (card, anonymous_id), UNCONDITIONAL — no `condition=`. That second one deliberately diverges from both sibling identity-vote models, and the divergence is the point. CardPrintingTag keys on (card, printing, anonymous_id) and CardArtistVote's artist branch on (card, artist, anonymous_id); both rely on the submit VIEW deleting prior rows to enforce one-vote-per-card, and CardArtistVote's own comment concedes its constraint is "a safety net against a double-submit race, not the primary mechanism". Machine writers using bulk_create never call that view — which is exactly how the illustration calculator came to cast several mutually exclusive printing votes under one identity (#525). An unconditional key makes the contradiction unrepresentable rather than merely discouraged. The reasoning is in the model docstring, citing #525, so nobody later "fixes" it to match its siblings. THE MACHINE WRITER. `run_illustration_calculator` now writes at two independent grains: a CardPrintingTag only at 1:1 (#526's rule, UNCHANGED and not weakened), and a CardIllustrationVote whenever exactly ONE illustration was resolved however many printings it maps to — i.e. the 1:1 cards plus the entire `multiple-printings-one-illustration` population, so most of the coverage the 1:1 rule withholds at the printing grain is retained at the artwork grain instead of discarded. At N>1 illustrations nothing is written at either grain and #526's skip reason is untouched. The trigger is a truthiness check on `verdict.illustration_id`, which #526 retained for exactly this consumer — nothing is re-derived. Confidence is BASE_CONFIDENCE, not `verdict.confidence` (which is confidence in the PRINTING, 0.0 on the abstain path, while this vote's claim is about the artwork the calculator resolved with full confidence in both cases). No anonymous_id or version string is bumped. IDEMPOTENCE, CANCEL-SAFETY, AND THE CHANGED-ANSWER CASE. `_split_new_illustration_votes`/`_purge_and_write_illustration_votes` follow `_purge_and_write_printing_tag_votes`' established semantics: split/count BEFORE any purge, purge scoped to the rows actually being written (never the whole batch), purge + write inside one `transaction.atomic()`. They live here rather than in local_calculate_verdicts.py because another worker is concurrently generalizing that primitive; a comment marks them for unification in a follow-up. The one difference is load-bearing. The existing split matches on the KEY only. Under an unconditional UNIQUE(card, anonymous_id) that makes a CORRECTED answer permanently unlandable: a metadata refresh that changes the resolved illustration would be seen as an existing row for the same key, counted as already-voted, dropped from new_votes — so it never reaches the purge that would have made room — and ignore_conflicts would swallow any attempt anyway. So this split compares the illustration_id VALUE as well: an unchanged answer is a no-op counted as already-voted, a changed answer is kept and overwrites. Comparison normalises through uuid.UUID so the str-vs-UUID representation gap never reads as a spurious change. Calculator-version self-overwrite (#519/#520) is preserved and human votes are never purged. THE READ HELPER. `printings_for_illustration(illustration_id, candidate_printing_pks=None)` returns the CanonicalCard printings carrying an illustration, joined through CanonicalPrintingMetadata. This is the narrowing and it stays a READ — materialising it as implied printing votes is #525's defect restated at a new grain. The candidate list intersects (never adds), so per-batch callers pay batch-scale cost. THE INVARIANT LOCK (#523). The calculator's artist input must come from OCR evidence, never from artist votes: `illustration_id -> artist` is functional, so an illustration answer can derive a CardArtistVote, and wiring that back in as the calculator's artist input would close a self-confirmation loop. Before this change there were no illustration votes at all, so the loop was unreachable — this change is what makes it reachable, which is why the lock lands with it. Adds the call-site comment #523 asks for plus four tests asserting on the SEAM (the exact argument passed to `match_artist`), including two that assert no SQL statement issued by the verdict path or the whole runner touches the CardArtistVote table. Also registers the model in admin.py, matching every sibling vote model. Verification: `pytest .` from MPCAutofill/ — 2414 passed / 7 skipped / 0 failed before, 2453 passed / 7 skipped / 0 failed after. black, isort, ruff and mypy clean on every file touched; `makemigrations --check` reports no pending changes; one migration added and it is the only one. Both subtle behaviours were mutation-tested: reverting the split to a key-only comparison fails exactly the two changed-answer tests and nothing else, and rewiring the artist input to read CardArtistVote fails three of the four #523 tests. Refs #507, #519, #520, #522, #523, #524, #525, #526 * migrations: renumber 0090_cardillustrationvote -> 0091, chained behind #532's 0090 PR #532 (feat/stream-full-catalog) independently authored 0090_stage_e_full_catalog_cursor against the same 0089 parent. Two migrations sharing a parent with neither depending on the other leaves the cardpicker graph with two leaf nodes, which Django rejects at migrate time ('Conflicting migrations detected; multiple leaf nodes in the migration graph'). #532 was green and mergeable first, so this one renumbers and chains behind it, giving a single linear chain 0089 -> 0090_stage_e_full_catalog_cursor -> 0091_cardillustrationvote. #532's migration is not touched. Purely a graph-ordering dependency: no schema or data relationship between the two migrations. Operations are byte-identical to the 0090 version of this file. ORDERING HAZARD, stated plainly: 0090_stage_e_full_catalog_cursor does not exist on master yet (#532 is still open at time of writing), so until #532 merges this branch's migration graph has an unresolved dependency and Django raises NodeNotFoundError. #532 MUST merge before this PR. Verified against a scratch worktree holding master + #532 + this branch - see the PR body. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
WilfordGrimley
added a commit
that referenced
this pull request
Jul 29, 2026
#534) * fix: remove dead import of renamed measure_bleed_diff_mm in image_evidence 53c1484 renamed measure_bleed_diff_mm -> compute_bleed_diff_mm in local_fallback.py without leaving an alias. image_evidence.py still imported the old name, raising ImportError at module import time for every one of its ten importers (Stage C/D/E). This removes the dead import and the now-unreachable fallback call at the old bleed_diff_mm assignment, letting compute_bleed_diff_mm's own assignment stand as the sole source of that field. * test: remove orphaned test_artist_external_links.py 53c1484 added this test file for an MTGAC proxy view (get_artist_external_links) and imports from cardpicker.views that were never actually committed - the feature has an unstarted design. The test aborted collection for the entire backend suite (Interrupted: 1 error during collection) and was pushed unformatted (isort + black both flagged it). Deleting it is safe and reversible: the file remains in git history at 53c1484 and will be recovered when the MTGAC integration is actually built. Not implementing the missing view, adding skip/xfail markers, or merely reformatting - those would leave a test asserting against code that does not exist. * fix: Stage D illustration hot-path regressions + vote-write ordering/atomicity `dispatch_micro_batch` is a streaming conveyor processing cards in 25-card micro-batches; anything it calls must cost O(batch), never O(catalog) (issues #458/#460). `_run_stage_d` calls `run_illustration_calculator` once per micro-batch. The illustration calculator (added 2026-07-28) never carried over the issue-#469 fixes that `local_calculate_verdicts.py` already has. 1. CandidateNameIndex cache bypass (local_illustration.py:468) `candidate_name_index = CandidateNameIndex()` — a direct constructor call whose own comment claimed "same cached pattern as `_get_cached_candidate_name_index()`". It was not: it bypassed the cache entirely. Cost before: a 113,224-row CanonicalCard scan, measured 1.48s, per micro-batch with at least one artist-OCR card. Now calls `local_calculate_verdicts._get_cached_candidate_name_index()`. 2. Unscoped materialization (local_illustration.py:385-394) Both join-key no-hit populations were wrapped in `list(...)`. Cost before: every join-key `is_no_match` CardPrintingTag row and every join-key no-hit CardScanLog row (CardScanLog is 2,093,147 rows live, append-only) pulled into process memory on every micro-batch, before any card_ids scoping. Now lazy querysets compiled into SQL subqueries, matching `_fallback_eligible_cards_queryset`. 3. card_ids applied late (local_illustration.py:211-253, 396-401) `_eligible_illustration_cards_queryset` took no `card_ids`; the caller filtered afterwards, so the CardScanLog exclusion subquery inside it compiled unscoped. Cost before: a full pass over 2,093,147 rows per micro-batch. Now mirrors `_eligible_cards_queryset:966-996` exactly, including `.filter(card_id__in=card_ids)` on the subquery, with issue #469's reasoning preserved. 4. IllustrationIndex rebuild (local_illustration.py:128-165, 411) Two catalog-wide CanonicalCard queries (one filtered on illustration_id, one UNFILTERED over every card with an artist) plus full-catalog dicts, constructed per invocation i.e. per micro-batch. Now memoized per worker process behind a version stamp, same shape as `_get_cached_candidate_name_index()`. The stamp adds a fifth term — the count of non-null `illustration_id` — because that column is BACKFILLED IN PLACE by `import_scryfall_printing_metadata`, an UPDATE that moves neither max pk nor row count. The no-op `.select_related(...)` chained before a `.values_list()` with traversal args was removed. 5. `already_voted` was structurally 0 (local_calculate_verdicts.py:1286-1293, 1614-1620; local_illustration.py:532) `purge_stale_machine_votes` deletes by calculator FAMILY, which includes the caller's own current anonymous_id — so running it before `_split_new_printing_tag_votes` deleted exactly the rows the split then looked for. The counter read 0 in every deployment forever: the literal "zero forever would suggest the guard itself is dead code" failure `stage_e_dispatch.py:250-253` warns about. Split now runs first, and the purge is scoped to `new_votes` so a skipped card keeps the winner's row. Calculator-version self-overwrite (#519/#520) is preserved: the split checks the exact current anonymous_id, so a stale `-v1` row is never a collision and its card is still purged and overwritten. 6. Cancel-safety (same three sites) The purge is a DELETE and the insert a separate statement. A process killed between them — which this operator does deliberately, mid-flight — lost votes with nothing written back. Both are now inside one `transaction.atomic()`, via the new shared `_purge_and_write_printing_tag_votes` primitive. Tests: +17 net. New coverage for item 3 (the CardScanLog subquery is genuinely narrowed in the compiled SQL, and BULK mode is untouched), item 4 (process cache reuse, invalidation on insert and on in-place illustration_id backfill, zero index builds for an empty micro-batch), item 6 (a failed insert rolls the purge back), and items 1/5. Three existing tests changed behaviour, all of them assertions added by #519/#520 that codified the regression above ("Update two concurrent-dispatch collision tests: purge changes 'skip' semantics to 'overwrite'"): `test_local_calculate_verdicts.py` join-key and fallback collision tests and `test_stage_e_dispatch.py`'s ledger assertion now expect `already_voted == 1` and the winner's row surviving, which is the semantic `local_lands_identify`'s equivalent test has asserted since PR #411. Refs #458, #460, #469, #507, #519 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN * test: derive RSS-ceiling tests from RSS_MB_PER_WORKER_CEILING, not a hardcoded 512 70225df raised the RSS ceiling 512->768MB in operating_envelope.py but updated zero tests. test_operating_envelope.py::test_trips_above_512mb_per_worker fed a literal 512.1, which is now under the 768 ceiling, so check_envelope() returned None and the trip-not-None assertion failed. test_resolve_envelope_trip.py::test_acknowledging_one_trip_does_not_touch_another cascaded from the same cause: it built its second trip with a literal 600.0, also now under the ceiling, so no trip was ever created for it to acknowledge. Both now derive the RSS value from RSS_MB_PER_WORKER_CEILING (already imported and already used this way in test_operating_envelope.py's own test_exactly_at_the_ceiling_does_not_trip) instead of a hardcoded number, so a future ceiling change can't silently rot these tests the same way again. Renamed test_trips_above_512mb_per_worker -> test_trips_above_rss_ceiling_per_worker since its name no longer hardcodes the ceiling either. No production code touched - operating_envelope.py and the 768 ceiling are unchanged. * fix: illustration calculator resolves a printing only at 1:1 (#525) BEHAVIOUR CHANGE — reduces illustration vote coverage. Deliberately its own commit so it can be evaluated or reverted independently of the performance work in the previous commit. THE DEFECT. `calculate_illustration_verdict` returned MULTIPLE `printing_pks` from both terminal branches, and the runner emits one `CardPrintingTag` per printing pk. One machine identity therefore voted simultaneously for several MUTUALLY EXCLUSIVE printings of the same card: - N>1 illustrations (local_illustration.py:305-320): "union of printings across all illustrations, each at BASE_CONFIDENCE/N". - N==1 illustration (local_illustration.py:322-329): "vote every printing at full BASE_CONFIDENCE". Since illustration_id → printing is 1:N, this fired for ANY reprinted artwork — the common case, not an edge case. The confidence spread does not mitigate it. `resolve_vote_weight(source, anonymous_id)` takes no confidence argument and `VoteTuple` has no confidence field, so confidence never reaches the tally: every row landed at full `PRINTING_TAG_MACHINE_WEIGHT`. `cardprintingtag_unique_printing_vote` is on (card, printing, anonymous_id), so all N rows persist. Where md5 identity-group pooling is active they read as self-contradiction and get withheld; where it is not, they all count for outcomes that cannot all be true. THE FIX. A printing vote is cast ONLY when the surviving set resolves to exactly one printing pk — exactly one illustration AND that illustration mapping to exactly one printing. Every other case abstains with a CardScanLog row. TWO DISTINCT ABSTAIN REASONS, because only one of them is recoverable: - `multiple-illustrations` (N>1 illustrations): no single illustration identity exists. Counts only; no representative is invented. - `multiple-printings-one-illustration` (1 illustration, N printings): the illustration identity IS known with full confidence — only the printing choice is undetermined. `IllustrationVerdict` retains `illustration_id` and `candidate_printing_pks` so issue #524 (`CardIllustrationVote`) can persist it without re-deriving anything. The retained values live in new verdict fields, NOT in `printing_pks`, so no future reordering of the runner's loop can turn retained evidence into cast votes. Nothing is persisted beyond the skip_reason string: no new model, no new field, no migration. Persistence is #524 and separately owned — this abstain is the seam it plugs into, not the intended end state. MEASURABILITY. `IllustrationCalculatorResult` gains `cards_abstained_ambiguous` and `printing_votes_withheld` (the exact number of CardPrintingTag rows the pre-#525 code would have written). Both are counted in dry-run mode, so the coverage figure is obtainable without a live write. The audit sample carries the retained narrowing per abstained card. Not changed, deliberately: `stage-d-illustration-v1`'s anonymous_id and version string (#525 records that a bump is unsafe pending separate investigation); `vote_consensus.py`/`printing_consensus.py` (confidence is NOT made load-bearing — that is a separate owner decision and the consensus code carries its own warning against it); the artist input, which stays OCR-evidence-derived (#523). Tests: one existing test asserted the defect and was rewritten, not quietly changed — `test_multiple_illustrations_spreads_confidence` asserted `skip_reason == ""`, two printing_pks and `confidence == BASE_CONFIDENCE / 2`; it is now `test_multiple_illustrations_abstains_and_retains_no_identity`. `test_single_illustration_votes_all_printings` was renamed (its fixture only ever built one printing, so it never exercised the multi-printing path its name described). New: 1-illustration-N-printings abstains and retains the illustration; N>1 abstains and retains nothing; 1:1 casts exactly one vote at BASE_CONFIDENCE; the two reasons are distinguishable; a printing reached twice via two candidates sharing an artist is still 1:1; and five end-to-end runner tests covering what actually reaches the DB. Refs #525, #524, #523, #507 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN * fix: transactional purge+write at every remaining calculator vote site PR #519/#520 wired `purge_stale_machine_votes` into eleven `bulk_create` call sites across nine calculator modules. At each one the sequence is DELETE prior votes -> compute -> INSERT new ones, with NO surrounding transaction: a process killed between the DELETE and the INSERT destroys committed votes with nothing written back. That is a routine event here, not a disaster scenario - this project's operator kills long runs mid-flight deliberately and a full-catalog pass takes hours. PR #526 fixed three of those sites and introduced `_purge_and_write_printing_tag_votes` for it. This change generalises that primitive and applies it at the remaining twelve. 1. `cardpicker/vote_write.py` - the generalised primitive `purge_and_write_votes(model_class, rows, *, anonymous_id=None, target_field="card_id", ignore_conflicts=False)` parameterises #526's primitive over the four axes the remaining sites need: vote model (`CardPrintingTag`, `CardTagVote`, `CardArtistVote`, `PrintingTagVote`), purge identity, target field (`card_id` / `printing_id`), and `ignore_conflicts`. #526's three semantics are preserved exactly and are now implemented once: - split/count BEFORE purge (the purge deletes by calculator FAMILY, which includes the caller's own current anonymous_id, so purging first empties the table the already-voted guard then reads - `already_voted` structurally 0 in every deployment forever); - the purge is scoped to the rows ACTUALLY WRITTEN, never the pre-split batch. `rows` is simultaneously the purge scope and the insert payload, which makes the trap unexpressible at a call site: purging the full batch after a split deletes a skipped target's surviving winner row and never re-inserts it, destroying a committed vote with no error and no counter movement; - purge + `bulk_create` inside ONE `transaction.atomic()`. `anonymous_id=None` purges each row under its OWN family, grouped one purge call per distinct identity - reproducing by contract the hand-rolled grouping `local_identify_printing_tags`' pilot flush already did for its multi-engine batches. It is a separate leaf module rather than a function in `local_calculate_verdicts` because of an import cycle: `local_calculate_verdicts` imports from `local_identify_printing_tags`, which is itself one of the call sites, so it cannot import back - and `local_lands_identify`/`local_residual_classify` sit on that same chain, while a management command has no business importing a Stage D calculator just to write a vote batch. It is deliberately NOT in `models.py`: that file is the schema, this is write-path policy. `local_calculate_verdicts._purge_and_write_printing_tag_votes` is kept as the `CardPrintingTag`/`card_id` binding and now delegates, which leaves `local_illustration.py` completely untouched. 2. Twelve sites migrated local_layout_class_cast.run_layout_class_cast CardTagVote local_detect_ai_art.run_ai_art_detector CardTagVote local_residual_classify.run_frame_mismatch_recovery CardArtistVote local_residual_classify.run_frame_mismatch_recovery CardTagVote local_residual_classify.run_d0_sibling_artist_propagation CardArtistVote local_identify_printing_tags.run_pilot flush CardTagVote local_identify_printing_tags.run_pilot flush CardPrintingTag local_identify_printing_tags.run_name_frequency_elimination flush CardPrintingTag deductive_backfill.run_backfill flush CardPrintingTag import_external_ip_tags positive pass PrintingTagVote import_external_ip_tags negative pass PrintingTagVote local_lands_identify.run_lands_identify CardPrintingTag `ignore_conflicts` is preserved exactly per site - load-bearing crash-proofing where it was set, deliberately absent where it was not. `deductive_backfill` and `local_identify_printing_tags` already purged inside a chunked flush - better placed than the rest, but still untransacted, so a kill inside a chunk deleted that chunk's cards' previous votes and wrote no replacement: strictly worse than losing the chunk, because the pre-existing votes went with it. 3. `local_lands_identify` had the same ordering bug #526 fixed elsewhere It purged the RAW `votes_batch`, then split, then inserted - the purge deleted exactly the rows `_split_new_votes` then went looking for. That was MASKED rather than harmless: the batch also carries OCR_ANONYMOUS_ID votes (`_process_land_card`'s OCR-resolved branch) and the lands-family purge never touched those, so the one collision this module's tests exercise still counted - while a genuine LANDS_ANONYMOUS_ID collision went un-counted AND had its winner's row destroyed with nothing re-inserted, since the split drops the loser. Split now runs first and the purge is scoped to `new_votes`. The purge identity stays LANDS_ANONYMOUS_ID explicitly rather than per-row: widening it to every identity present would start purging the OCR family too, a behaviour change well outside an atomicity fix. Tests: new `test_vote_write.py` covers the primitive across all four vote models, both target fields, both `ignore_conflicts` settings, rollback, empty-rows-purges-nothing, partial-collision scoping, human-vote isolation, and both identity-grouping modes. Every migrated site gets a rollback test through its real entrypoint (`bulk_create` patched to raise; the pre-existing same-family row must survive), following #526's shape. `local_lands_identify` additionally gets an ordering test: a racing lands-family vote committed between selection and write is now counted in `already_voted` and its row survives. ONE EXISTING TEST CHANGED BEHAVIOUR, DELIBERATELY: `test_local_identify_printing_tags.py::TestCheckpointing:: test_resume_after_a_simulated_kill_completes_the_remaining_cards` simulated the kill by wrapping `bulk_create` and raising AFTER calling through. Inside the new `transaction.atomic()` that raise happens INSIDE the transaction and correctly rolls the whole pair back - so it would have tested the new atomicity instead of the test's actual subject (chunked checkpointing: a kill loses at most one chunk, and a plain re-invocation resumes). The kill now fires from `verify_zero_resolutions`, the last statement in the same `flush()`, i.e. genuinely "immediately after the first flush committed". Same intent, correct write boundary. No migration, no anonymous_id or version-string change. `models.py`, `image_evidence.py`, `local_fallback.py`, `local_illustration.py`, `printing_consensus.py`, `vote_consensus.py`, `views.py`, `urls.py`, `question_feed.py`, `schema_types.py`, `reason_tags.py`, `frontend/` and `schemas/` are untouched. Refs #507, #519, #520, #526 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN * Drop the now-unused django.db.transaction import in local_calculate_verdicts The merge of master reinstated #526's import; the atomic block it served now lives in vote_write.purge_and_write_votes, so ruff F401 flags it. * Correct an inaccurate inline comment in test_vote_write The first purge_and_write_votes call in test_card_tag_vote_with_ignore_conflicts purges a DIFFERENT family from the pre-existing row, so it deletes nothing - the comment claimed the opposite. Assertions unchanged. * Fix test fixture violating cardprintingtag_printing_xor_no_match test_an_explicit_anonymous_id_purges_only_that_family built its no-match victim row with a printing attached, which the model's own check constraint forbids. printing=None; assertions unchanged. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Fixes two bugs in
local_lands_identify(docs/features/catalog-completion-plan.md's Part 4):OCR_ANONYMOUS_ID(local-ocr-v1), an identity_land_pool_selected_cards's own eligibility query never checks (it's scoped toLANDS_ANONYMOUS_IDalone). A card can legitimately still be selected into this pool while an earlier pass/pilot run already cast an identical(card, printing, OCR_ANONYMOUS_ID)vote —--writerun20260724T021229-15c88ebahit exactly this and crashed withIntegrityErroroncardprintingtag_unique_printing_vote, rolling back with zero rows landed. Fixed with an explicit pre-write skip-if-exists (_split_new_votes) rather than a blanketignore_conflicts=True: a single batched existence query partitions the batch into (safe to write, already-voted), the latter counted in a newLandsIdentifyResult.already_votedfield (surfaced in the ledger counters and terminal output) rather than silently dropped, so the run survives the collision and every other vote in the batch still commits.total_votes=would_cast=0unconditionally (reusingvotes_written, the write-gated counter, which is always 0 in dry-run) even on runs whose ledger counters recorded a real prediction (e.g.ocr_resolved=102+singleton=7+tiebreak=4=113/300on runs20260724T013302/20260724T014701), misleading a review into treating a real-yield run as zero-yield. Now prints the realocr_resolved+singleton_votes+tiebreak_votesbreakdown in dry-run mode, matchinglocal_calculate_verdicts's ownwould_cast=convention.Closes #408
Closes #407
Checklist
pre-commitand installed the hooks withpre-commit installbefore creating any commits.TestSplitNewVotes(4 tests): direct unit coverage of the new pre-write guard, including the "different identity, same card+printing" non-collision case.TestRunLandsIdentify::test_ocr_resolved_vote_colliding_with_an_existing_identical_vote_is_skipped_not_crashed: regression for local_lands_identify --write crashes on duplicate local-ocr-v1 vote #408 — seeds an identical pre-existingOCR_ANONYMOUS_IDvote, runs--write, asserts no crash, no duplicate row,already_voted == 1, and the other card's vote still commits.test_dry_run_prints_the_real_would_cast_breakdown_not_the_write_gated_zero: regression for local_lands_identify dry-run prints would_cast=0 despite real would-cast counters #407 — mocks a dry-run result with real ocr_resolved/singleton/tiebreak counters and asserts the printed summary containstotal_votes=would_cast=113, notwould_cast=0.1906 passed, 4 skipped(up from1899 passed, 4 skippedon master — the 7 new tests), viapytest cardpickerin/home/ubuntu/.venvs/mpcautofill-pilot.ruff/isort/black/mypyall clean (pre-commit hook ran clean on the commit).docs/features/catalog-completion-plan.md's Part 4 section: added a dated status entry documenting the crash, root cause, and fix, matching that section's own established history-of-runs convention.Notes for reviewer
--writerun this unblocks has not been re-run.already_votedis a new counter, not just a bugfix side-effect: it's surfaced inPilotRunLedger.countersand the terminal summary so a future run's ledger stays honest about computed-vs-written when this identity-scoping mismatch recurs (it's structural, not a one-off — any card resolved via plain OCR in this module can hit it again on a future re-run).