Skip to content

VoteSource AI->DEDUCTION/OCR split + local OCR/phash printing-ID pilot (Stage 8) - #22

Merged
WilfordGrimley merged 5 commits into
masterfrom
worktree-local-printing-id-pilot
Jul 15, 2026
Merged

VoteSource AI->DEDUCTION/OCR split + local OCR/phash printing-ID pilot (Stage 8)#22
WilfordGrimley merged 5 commits into
masterfrom
worktree-local-printing-id-pilot

Conversation

@WilfordGrimley

Copy link
Copy Markdown

Summary

Two commits, in dependency order:

  1. VoteSource.AI split into DEDUCTION/OCR (repo-wide rename + live migration).
    Requested mid-session: the old single AI value conflated two genuinely different
    mechanisms - deductive_backfill.py (pure logical inference, zero image inspection) and
    this pilot's engines (OCR text extraction, perceptual-hash art matching - i.e. things that
    actually look at the card image). Split into VoteSource.DEDUCTION and VoteSource.OCR,
    same weight/gate treatment as the old AI value (label split, not a policy change).
    Migration 0060_votesource_deduction_ocr_split.py has already been applied to the live
    production database
    (28,112 source='ai' rows -> source='deduction', verified 0
    remaining 'ai' rows across all three vote tables) - this push makes that live state match
    what'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.

  2. Local (zero-API-cost) printing-identification backfill pilot (Stage 8 -
    docs/features/printing-tags.md). Two independent engines that vote on a card's printing
    by 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 the
    pixel/OCR heuristic when available. Code + tests only in this PR - the actual
    --limit 300 pilot 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 - both DEDUCTION and OCR
are in _MACHINE_DERIVED_SOURCES, so both read as non-human-backed, same as the old AI
value did. Proof, three layers:

  • cardpicker/tests/test_vote_consensus.py::TestIsHumanBackedSource - new, direct unit test:
    is_human_backed_source(VoteSource.DEDUCTION) is False and
    is_human_backed_source(VoteSource.OCR) is False, plus USER/ADMIN/FEDERATED all True.
  • 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, still
    passing.
  • cardpicker/tests/test_printing_consensus.py::TestResolvePrinting::test_machine_only_insufficient
    • now parametrized over both VoteSource.DEDUCTION and VoteSource.OCR: 4 unanimous
      machine-sourced votes of either kind still resolve to None.
      Also re-verified live: deductive_backfill.verify_zero_resolutions semantics unchanged, and
      the 28,112-row migration didn't touch printing_tag_status on any card (backfill is a pure
      label update, CardPrintingTag.objects.filter(source='ai').update(source='deduction') - no
      resolution logic runs during a data migration).

(b) Settings: PRINTING_TAG_AI_WEIGHT was NOT renamed. Both VoteSource.DEDUCTION and
VoteSource.OCR map to settings.PRINTING_TAG_AI_WEIGHT in _SOURCE_WEIGHTS
(vote_consensus.py) - same setting name as before, so no rebinding risk. Checked
docker/.env on the production host directly: PRINTING_TAG_AI_WEIGHT is not set there,
meaning prod runs on the Django default (env.float("PRINTING_TAG_AI_WEIGHT", default=0.5) in
settings.py) - prod is using 0.5, unchanged before and after this split (the setting was
never explicitly configured, so there was nothing to silently unbind).

(c) Docs updated, interchange contract flagged explicitly:

  • docs/federation-v1.md - the human_votes field'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=deduction in the
    present-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.AI comparisons.
    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: added Pillow (was only pulled in transitively via ImageHash, but
    every pilot module imports PIL directly - explicit pin now).
  • cardpicker/management/commands/local_identify_printing_tags.py: real bug - the command
    still unpacked run_pilot()'s return value as a bare dict (results.items()) after an
    earlier session change made it return a (results, attributes) tuple. Would have crashed
    with AttributeError on the very first real invocation. Fixed, and the command now also
    prints 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: a best_distance: Optional[int] = None type annotation mypy
    needed (was inferring the variable as permanently None-typed, silently making a real
    branch 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).
  • A ground-truth-vs-heuristic asymmetry the advisor caught before commit: the border attribute
    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_color like "gold" (outside this v1
    taxonomy) would have discarded a valid heuristic reading for a vote that silently resolves to
    nothing. Fixed to mirror frame's guard.

Test plan

  • Full backend suite: 668 passed, only 4 pre-existing/environmental failures (missing
    client_secrets.json for a Drive-integration test, a network-dependent moxfield URL
    test) - confirmed unrelated by diffing against the pre-session base.
  • ruff / black / isort / mypy clean on all touched files (2 pre-existing
    CheckConstraint stub-version mypy errors in models.py confirmed present before this
    session's changes, unrelated).
  • Migration applied and verified against live production data (counts above).
  • Pilot management command smoke-tested end-to-end against the live DB post-fix.
  • Real --limit 300 pilot run + deliverables report - next PR/commit, not in scope here.

Co-Authored-By: Claude Sonnet 5 noreply@anthropic.com

@WilfordGrimley

Copy link
Copy Markdown
Author

Real pilot run results (--limit 300 --engine both --nice, live production DB)

32m4.6s wall-clock, 19m36s user + 4m37s sys CPU (~76% avg utilization of one core, 2-CPU box), exit 0.

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.

@WilfordGrimley
WilfordGrimley marked this pull request as ready for review July 15, 2026 16:26
@WilfordGrimley
WilfordGrimley merged commit bfa807f into master Jul 15, 2026
1 of 2 checks passed
@WilfordGrimley
WilfordGrimley deleted the worktree-local-printing-id-pilot branch July 19, 2026 21:53
WilfordGrimley added a commit that referenced this pull request Jul 29, 2026
…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>
WilfordGrimley added a commit that referenced this pull request Jul 29, 2026
…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
WilfordGrimley added a commit that referenced this pull request Jul 29, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant