VoteSource AI->DEDUCTION/OCR split + local OCR/phash printing-ID pilot (Stage 8) - #22
Conversation
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016i9S7LQsCL3FGaih3ZTRBJ
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016i9S7LQsCL3FGaih3ZTRBJ
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016i9S7LQsCL3FGaih3ZTRBJ
Real pilot run results (
|
| Engine | Attempted | Votes written | Yield |
|---|---|---|---|
| OCR | 300 | 77 | 25.7% |
| Phash | 300 | 13 | 4.3% |
| Fallback | 210 | 4 | 1.9% |
| Total | — | 94 | — |
Gate check: 0/94 affected cards resolved - human-backed gate held perfectly, same as Stage 6's 0/28,112.
Attribute votes: border {black: 280, borderless: 17, white: 3} (91 ground-truth-sourced, 209 heuristic); frame {modern: 258, old: 14}, 28 abstains (91 ground-truth, 181 heuristic); 6 frame-mismatches (printing vote correctly withheld by the consistency check).
Full-catalog projection: ~171,800 eligible cards remain -> ~12.8 days of continuous single-process runtime at this rate. Not attempted - flagging as the key scaling decision for a future conversation, along with two follow-ups raised mid-pilot: (1) switching to periodic batch-flush writes (matching deductive_backfill.py's existing pattern) before any long unattended run, since the current code loses all in-memory progress on a crash/restart; (2) OCR's "parsed-but-no-match" bucket (58.7% of attempts) is worth investigating before scaling, since it's the single largest yield opportunity.
Also found and fixed along the way: the CLI management command was silently unpacking run_pilot()'s return value incorrectly (would have crashed on first real invocation - see the earlier commit message for detail), and a filename tag-gap census turned up 1,097 unresolved cards with an unmatchable expansion_hint (flagged as a separate future parser fix, not touched here).
Full report with per-card examples (crops, raw OCR text, disagreement list, frame-mismatch cases, 20-vote admin-link spot-check sample): journal/2026-07-15-local-printing-id-pilot.md (machine-local, not committed - available on request). Concise version landed in docs/features/printing-tags.md's Stage 8 section (latest commit on this branch).
Ready for review.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016i9S7LQsCL3FGaih3ZTRBJ
…able Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016i9S7LQsCL3FGaih3ZTRBJ
…in-session green dot (#572) * Homepage participation graph: card-denominated ratio, headline flip, in-session green dot Moves ParticipationGraph's gate/bar off the votes-over-cards approximation (humanVotes.total / total) onto the exact card-denominated ratio distinctCardsRoutedToReviewWithHumanVotes / distinctCardsRoutedToReview (PR #566's new fields), deletes the old path and its units caveat entirely, adds a null-safe live-skew guard for the window before the production API redeploys past #566, flips the revealed headline to a routed-to-review framing with an always-rising reviewed-count beside the bar (issue #22: the flip is not latched), and turns the dot-matrix's dashed "you could be next" mark into a filled green + thank-you once this client has voted in-session (sessionContributionSlice.ts, no localStorage/new endpoint). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nz54BtscqFNzeKuDzEcpT3 * QuestionFeed test: assert casting a vote dispatches recordSessionContribution Closes the untested seam the component tests couldn't reach: they proved the homepage dot responds to recordSessionContribution(), not that a real vote fires it. Two new tests exercise bumpSessionCount's real dispatch through a successful (and, separately, a failed) /2/submitPrintingTag/ call, using the file's existing mock-a-vote harness - no new test machinery. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nz54BtscqFNzeKuDzEcpT3 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e bulk file we already parse Closes the open question in #600. Stacked on #601. WHAT WAS WRONG -------------- `deductive_backfill.select_d1_candidates` cast votes at confidence=0.95 on a documented guarantee - "cross-verified against Scryfall's own printings_count (not just 'our table happens to have one row')" - that no code implemented. The condition it ran, `catalogued_printings_count == 1`, is entailed by the `len(matches) == 1` test one line above it: that column counts OUR rows per oracle id, rows sharing an oracle id share a name, so a count above 1 means the card was already rejected. 137 D1 candidates before the condition, 137 after, measured against the live catalogue. WHAT THIS DOES -------------- 1. `CanonicalPrintingMetadata.scryfall_default_cards_printings_count` (migration 0099, nullable): how many rows Scryfall's `default_cards` export lists for this row's oracle card. Populated by `import_scryfall_printing_metadata` as a second `Counter`, over the `oracle_id` of rows it ALREADY parses - zero extra Scryfall requests. The per-card API alternative would be ~113k of them; the standing instruction is not to hammer their servers, and the existing diff/freshness check (`_is_fresh`) is untouched. 2. D1 gates on `catalogued_printings_count == scryfall_default_cards_...`. The entailed condition is deleted, not softened. 3. NULL means "Scryfall publishes no count for this row" and FAILS the gate. That covers the 81 rows with a NULL `canonical_id` - which are exactly the 81 bulk rows Scryfall itself ships with no `oracle_id`, and which the old code handed a fabricated `1`, i.e. an assertion of the very completeness being checked - and any oracle id absent from the bulk file (0 today). Ours > Scryfall's (the stale-file direction) also fails: equality, not >=. WHY THE NAME SAYS `default_cards` --------------------------------- `default_cards` is ~one row per printing, English-preferred. The count INCLUDES every distinct printing/variant Scryfall knows of - reprints, promos, showcase/borderless variants, tokens, and digital-only Arena/MTGO printings (9,354 digital rows of 116,254) - and EXCLUDES the per-language duplicates of a printing that also exists in English, which live only in `all_cards` (not downloaded here). It is not a paper-only count and not an all-languages count, and the field name, docstring and docs all say so. The defect being fixed was a second field acquiring a claim it could not support. MEASURED EFFECT (live catalogue, read-only, 2026-07-29) ------------------------------------------------------- Running the SHIPPED predicate against production, with the Scryfall side supplied from the on-disk bulk file: D1 candidates reaching the gate (len(matches) == 1): 137 passing the OLD condition (catalogued == 1): 137 passing the NEW gate (both known and equal): 137 excluded by the new gate: 0 THE REAL CHECK EXCLUDES 0 TODAY, and that is reported as the result rather than hunted past: the guarantee currently holds for every D1 candidate. It is not inert like its predecessor and it is not a stable property. Of the 14,893 normalised names with exactly one CanonicalCard row, 2 already disagree with Scryfall and are excluded the moment an eligible Card names either - counterfactually verified against the shipped predicate: 'Chandra, Chill of Compliance': catalogued=1 scryfall=2 -> EXCLUDED 'Tragic Trajectory': catalogued=1 scryfall=2 -> EXCLUDED 'Kusari-Gama': catalogued=1 scryfall=1 -> VOTE Both hand-checked against the bulk rows: Chandra has two `fra` printings (#212 and #297), we hold #212; Tragic Trajectory has `eoe` #122 and promo `pw26` #22, we hold the former. 82 of our 35,990 oracle ids hold fewer printings than Scryfall lists; 0 hold more; 0 are absent from the file. TESTS THAT CAN GO RED --------------------- `TestD1ScryfallPrintingCountGate`, including one end-to-end case that drives the REAL importer over a bulk file listing two printings of an oracle card we hold one of - the exact live production shape, which the deleted fixture-only test could not produce. Reverting the gate to `catalogued_printings_count == 1` turns 3 of them red (demonstrated). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN
…e bulk file we already parse (#614) Closes the open question in #600. Stacked on #601. WHAT WAS WRONG -------------- `deductive_backfill.select_d1_candidates` cast votes at confidence=0.95 on a documented guarantee - "cross-verified against Scryfall's own printings_count (not just 'our table happens to have one row')" - that no code implemented. The condition it ran, `catalogued_printings_count == 1`, is entailed by the `len(matches) == 1` test one line above it: that column counts OUR rows per oracle id, rows sharing an oracle id share a name, so a count above 1 means the card was already rejected. 137 D1 candidates before the condition, 137 after, measured against the live catalogue. WHAT THIS DOES -------------- 1. `CanonicalPrintingMetadata.scryfall_default_cards_printings_count` (migration 0099, nullable): how many rows Scryfall's `default_cards` export lists for this row's oracle card. Populated by `import_scryfall_printing_metadata` as a second `Counter`, over the `oracle_id` of rows it ALREADY parses - zero extra Scryfall requests. The per-card API alternative would be ~113k of them; the standing instruction is not to hammer their servers, and the existing diff/freshness check (`_is_fresh`) is untouched. 2. D1 gates on `catalogued_printings_count == scryfall_default_cards_...`. The entailed condition is deleted, not softened. 3. NULL means "Scryfall publishes no count for this row" and FAILS the gate. That covers the 81 rows with a NULL `canonical_id` - which are exactly the 81 bulk rows Scryfall itself ships with no `oracle_id`, and which the old code handed a fabricated `1`, i.e. an assertion of the very completeness being checked - and any oracle id absent from the bulk file (0 today). Ours > Scryfall's (the stale-file direction) also fails: equality, not >=. WHY THE NAME SAYS `default_cards` --------------------------------- `default_cards` is ~one row per printing, English-preferred. The count INCLUDES every distinct printing/variant Scryfall knows of - reprints, promos, showcase/borderless variants, tokens, and digital-only Arena/MTGO printings (9,354 digital rows of 116,254) - and EXCLUDES the per-language duplicates of a printing that also exists in English, which live only in `all_cards` (not downloaded here). It is not a paper-only count and not an all-languages count, and the field name, docstring and docs all say so. The defect being fixed was a second field acquiring a claim it could not support. MEASURED EFFECT (live catalogue, read-only, 2026-07-29) ------------------------------------------------------- Running the SHIPPED predicate against production, with the Scryfall side supplied from the on-disk bulk file: D1 candidates reaching the gate (len(matches) == 1): 137 passing the OLD condition (catalogued == 1): 137 passing the NEW gate (both known and equal): 137 excluded by the new gate: 0 THE REAL CHECK EXCLUDES 0 TODAY, and that is reported as the result rather than hunted past: the guarantee currently holds for every D1 candidate. It is not inert like its predecessor and it is not a stable property. Of the 14,893 normalised names with exactly one CanonicalCard row, 2 already disagree with Scryfall and are excluded the moment an eligible Card names either - counterfactually verified against the shipped predicate: 'Chandra, Chill of Compliance': catalogued=1 scryfall=2 -> EXCLUDED 'Tragic Trajectory': catalogued=1 scryfall=2 -> EXCLUDED 'Kusari-Gama': catalogued=1 scryfall=1 -> VOTE Both hand-checked against the bulk rows: Chandra has two `fra` printings (#212 and #297), we hold #212; Tragic Trajectory has `eoe` #122 and promo `pw26` #22, we hold the former. 82 of our 35,990 oracle ids hold fewer printings than Scryfall lists; 0 hold more; 0 are absent from the file. TESTS THAT CAN GO RED --------------------- `TestD1ScryfallPrintingCountGate`, including one end-to-end case that drives the REAL importer over a bulk file listing two printings of an oracle card we hold one of - the exact live production shape, which the deleted fixture-only test could not produce. Reverting the gate to `catalogued_printings_count == 1` turns 3 of them red (demonstrated). Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
Two commits, in dependency order:
VoteSource.AIsplit intoDEDUCTION/OCR(repo-wide rename + live migration).Requested mid-session: the old single
AIvalue conflated two genuinely differentmechanisms -
deductive_backfill.py(pure logical inference, zero image inspection) andthis pilot's engines (OCR text extraction, perceptual-hash art matching - i.e. things that
actually look at the card image). Split into
VoteSource.DEDUCTIONandVoteSource.OCR,same weight/gate treatment as the old
AIvalue (label split, not a policy change).Migration
0060_votesource_deduction_ocr_split.pyhas already been applied to the liveproduction database (28,112
source='ai'rows ->source='deduction', verified 0remaining
'ai'rows across all three vote tables) - this push makes that live state matchwhat's in version control; it was not safe to leave the migration applied without the
corresponding code committed, hence pushing this immediately rather than waiting for the
pilot run.
Local (zero-API-cost) printing-identification backfill pilot (Stage 8 -
docs/features/printing-tags.md). Two independent engines that vote on a card's printingby actually looking at its image: L1 Tesseract OCR on the collector-line crop, L2
perceptual-hash art-crop matching, plus a pass-2 fallback (border-color sample + "Illus."
artist-credit OCR + set-symbol phash, evidence-combination via set intersection) for
old-border cards pass 1 can't reach at all (no collector line printed on the card face).
Also casts standalone border-color/frame-style attribute-chip votes, preferring ground
truth from a confirmed printing's own
CanonicalPrintingMetadata(Scryfall data) over thepixel/OCR heuristic when available. Code + tests only in this PR - the actual
--limit 300pilot run and deliverables report have not been executed yet (coming next,per the locked spec).
VoteSource-split accounting (for review)
(a) Human-backed gate still holds for both new values.
is_human_backed_source()(
cardpicker/vote_consensus.py) is the single source of truth - bothDEDUCTIONandOCRare in
_MACHINE_DERIVED_SOURCES, so both read as non-human-backed, same as the oldAIvalue did. Proof, three layers:
cardpicker/tests/test_vote_consensus.py::TestIsHumanBackedSource- new, direct unit test:is_human_backed_source(VoteSource.DEDUCTION) is Falseandis_human_backed_source(VoteSource.OCR) is False, plus USER/ADMIN/FEDERATED allTrue.cardpicker/tests/test_vote_consensus.py::TestResolveWeightedConsensus:: test_ai_only_votes_never_resolve_even_with_large_weight- the core gate test, source-agnostic(constructs
VoteTuple(is_human_backed=False)directly), unaffected by the rename, stillpassing.
cardpicker/tests/test_printing_consensus.py::TestResolvePrinting::test_machine_only_insufficientVoteSource.DEDUCTIONandVoteSource.OCR: 4 unanimousmachine-sourced votes of either kind still resolve to
None.Also re-verified live:
deductive_backfill.verify_zero_resolutionssemantics unchanged, andthe 28,112-row migration didn't touch
printing_tag_statuson any card (backfill is a purelabel update,
CardPrintingTag.objects.filter(source='ai').update(source='deduction')- noresolution logic runs during a data migration).
(b) Settings:
PRINTING_TAG_AI_WEIGHTwas NOT renamed. BothVoteSource.DEDUCTIONandVoteSource.OCRmap tosettings.PRINTING_TAG_AI_WEIGHTin_SOURCE_WEIGHTS(
vote_consensus.py) - same setting name as before, so no rebinding risk. Checkeddocker/.envon the production host directly:PRINTING_TAG_AI_WEIGHTis not set there,meaning prod runs on the Django default (
env.float("PRINTING_TAG_AI_WEIGHT", default=0.5)insettings.py) - prod is using 0.5, unchanged before and after this split (the setting wasnever explicitly configured, so there was nothing to silently unbind).
(c) Docs updated, interchange contract flagged explicitly:
docs/federation-v1.md- thehuman_votesfield's doc comment (an interchange contract:importing instances key gate behavior off this int) now explicitly names the split and
points to
VoteSource's docstring, rather than the stale "non-AI vote count" phrasing.docs/features/printing-tags.md- Stage 6 section:source=ai->source=deductionin thepresent-tense description of what the stage casts; the historical 2026-07-14 production-run
spot-check narrative left as
source=ai(accurate to what was actually observed that day)with an added note that those rows were since migrated; a new blockquote right after the
PRINCIPLE paragraph explains the 2026-07-15 split, the migration, and points at
is_human_backed_source()replacing the old scattered!= VoteSource.AIcomparisons.Stage 8's own status line is not updated in this PR (still says "environment proposal
delivered, not yet built" from the planning-phase commit) - that update is deliberately
deferred to the follow-up PR/commit once the real pilot run + deliverables report exist, per
the requested ordering.
Other fixes found while finishing this out
requirements.txt: addedPillow(was only pulled in transitively viaImageHash, butevery pilot module imports
PILdirectly - explicit pin now).cardpicker/management/commands/local_identify_printing_tags.py: real bug - the commandstill unpacked
run_pilot()'s return value as a bare dict (results.items()) after anearlier session change made it return a
(results, attributes)tuple. Would have crashedwith
AttributeErroron the very first real invocation. Fixed, and the command now alsoprints the attribute-vote summary (border/frame tallies, ground-truth counts, mismatches).
Smoke-tested end-to-end against the live DB (
--dry-run --limit 3) after the fix.cardpicker/local_fallback.py: abest_distance: Optional[int] = Nonetype annotation mypyneeded (was inferring the variable as permanently
None-typed, silently making a realbranch of the symbol-matching window scan unreachable per mypy's own analysis - not a
behavioral bug since Python doesn't care, but worth the one-line annotation for correctness).
vote's ground-truth override wasn't gated on the value actually mapping to a known tag
(frame's override was) - an unmapped Scryfall
border_colorlike"gold"(outside this v1taxonomy) would have discarded a valid heuristic reading for a vote that silently resolves to
nothing. Fixed to mirror frame's guard.
Test plan
client_secrets.jsonfor a Drive-integration test, a network-dependent moxfield URLtest) - confirmed unrelated by diffing against the pre-session base.
ruff/black/isort/mypyclean on all touched files (2 pre-existingCheckConstraintstub-version mypy errors inmodels.pyconfirmed present before thissession's changes, unrelated).
--limit 300pilot run + deliverables report - next PR/commit, not in scope here.Co-Authored-By: Claude Sonnet 5 noreply@anthropic.com