Skip to content

questionFeed: exclude N>1 illustration-vote answers; dedupe contested_card_ids across the view - #738

Merged
WilfordGrimley merged 1 commit into
masterfrom
fix-feed-exclusion-and-dedupe
Aug 6, 2026
Merged

questionFeed: exclude N>1 illustration-vote answers; dedupe contested_card_ids across the view#738
WilfordGrimley merged 1 commit into
masterfrom
fix-feed-exclusion-and-dedupe

Conversation

@WilfordGrimley

Copy link
Copy Markdown

Summary

Two related backend fixes in the What's That Card question feed, sharing files.

1. Printing exclusion now sees N>1 illustration-cluster answers. identify_printing has two
answer paths: a single/unclustered candidate posts to /2/submitPrintingTag/ and writes
CardPrintingTag; a shared-illustration cluster of two or more candidates posts to
/2/submitIllustrationVote/ instead, and cast_illustration_vote writes CardPrintingTag only
when the illustration resolves to exactly one live printing. At N>1 - the exact premise of the
cluster UI that fired - nothing lands on the printing channel, only on CardIllustrationVote.
_voter_answered_printing_card_ids read exclusively from CardPrintingTag, so a voter who
answered via the cluster path cast a real, persisted vote the exclusion couldn't see: the card
stayed UNRESOLVED and was re-served immediately. Production evidence: both of the only two
human illustration votes on record were each followed, within seconds, by an is_no_match
printing vote on the same card - a voter escaping the loop, manufacturing false no-match data.

Fix: _voter_answered_printing_card_ids now unions card ids from CardPrintingTag with card ids
from CardIllustrationVote before the existing md5/phash identity-group widening, so an N>1
answer excludes the card from every printing tier exactly like a direct CardPrintingTag vote
does. No new model or migration - CardIllustrationVote already exists and already carries the
right (card, anonymous_id) grain.

Rejected alternatives (both considered, both wrong for this case):

  • Reusing CardQuestionAbstention (PR Differentiate 'Not sure' from 'Skip': record a human abstention (issue #712) #731's "Not sure" record): that model represents a
    human non-answer ("I looked and couldn't tell"); an N>1 illustration vote is a real, weighted
    answer. Conflating the two would corrupt the exact distinction Differentiate 'Not sure' from 'Skip': record a human abstention (issue #712) #731 was built to preserve.
  • Writing something on the printing channel from the N>1 branch (e.g. a placeholder
    CardPrintingTag): cast_illustration_vote's own docstring explains why this is deliberately
    never done - the endpoint has no way to express "one of these N printings" without falsely
    asserting an unsupported outcome, corrupting printing consensus. Extending the exclusion query
    instead of the write path leaves that constraint untouched.

2. get_contested_card_ids() deduped across the view boundary. get_next_question_feed_item
and get_remaining_estimate each independently called it - measured at 513-627ms per call
against live production data (single-gunicorn-worker deployment, so this is site-wide latency on
every affected request, not one user's). Following the "compute once, thread as an optional
parameter" convention PR #729 established inside this file, both functions now accept an
optional contested_card_ids parameter (None default - every existing direct caller, including
catalog_stats.py, is unaffected). views.get_question_feed resolves it once and passes it to
both. No cross-request cache introduced.

Test plan

  • cd MPCAutofill && pytest cardpicker/tests/test_question_feed.py cardpicker/tests/test_illustration_vote.py cardpicker/tests/test_md5_group_pooling.py -
    150 passed (includes two new regression tests under TestIllustrationVoteAnsweredExclusion
    that fail against pre-fix code: one asserts _voter_answered_printing_card_ids includes the
    card after an N>1 illustration vote, the other asserts a voter is not re-served the card via
    get_next_question_feed_item after casting one)
  • cd MPCAutofill && pytest cardpicker/tests/ (excluding the pre-existing, unrelated
    TestSharedCacheNotConfigured/TestGetCatalogStatsView failures in test_catalog_stats.py,
    confirmed identical on unmodified origin/master, and the container-only
    test_federation_hash_tool_parity.py) - 3676 passed, 8 skipped, 0 failed
  • pre-commit run (ruff, isort, black, mypy, prettier) on all changed files - clean

…_card_ids across the view

- _voter_answered_printing_card_ids now also reads CardIllustrationVote, not just
  CardPrintingTag. The illustration-cluster answer path (cast_illustration_vote,
  N>1 candidates sharing an illustration) writes only CardIllustrationVote, which
  the printing-tier exclusion could not see - the card stayed eligible and was
  immediately re-served to the same voter.
- get_next_question_feed_item and get_remaining_estimate both accept an optional
  contested_card_ids parameter (extending PR #729's convention across the view
  boundary); views.get_question_feed resolves get_contested_card_ids() once per
  request and threads it into both, instead of each independently paying the
  ~520-627ms query cost.
@WilfordGrimley
WilfordGrimley merged commit d3dc43d into master Aug 6, 2026
15 checks passed
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