Skip to content

Make AI-generated art and external-IP human-votable attributes #719

Description

@WilfordGrimley

Owner requirement

"AI art needs to be a votable thing for users the same way external-ip does."

Related, earlier: "being able to vote for UB/UW for custom cards is important."

Verified against origin/master at 17345a48050cd6734f312d033b6ef1ed4f1af014 (2026-08-05).

Current state (code-grounded)

V1 — The attribute-chip taxonomy

Authoritatively defined at MPCAutofill/cardpicker/attribute_tags.py:32-44, ATTRIBUTE_CHIP_TAG_NAMES — exactly 11 tags:
Full Art, Borderless, Showcase, Extended, Etched, Black Border, White Border, Silver Border, Old Border, Modern Border, Future Frame.

Mirrored frontend-side in frontend/src/features/attributeChips/attributeChips.ts:96-127 (STANDALONE_CHIPS [5] + BORDER_COLOR_GROUP [3] + FRAME_STYLE_GROUP [3] = 11, via ALL_ATTRIBUTE_CHIPS). Backend _tag_confidence() (question_feed.py:148-153) filters Tag.objects.filter(name__in=ATTRIBUTE_CHIP_TAG_NAMES) — this is the curated, proactively-surfaced roster.

V2 — The human vote surface (three surfaces, one generic mechanism underneath)

  1. Direct-access chip ring/railattributeChipRender.tsx (Yes/No buttons, redesigned by fix(question-feed): direct-access Yes/No chips, narrowing after no-match, art-crop gap documented #687 2026-08-04, see its header comment lines 12-21), rendered by AttributesSection.tsx (display rail) and QuestionFeed.tsx's ring. Restricted to the 11-tag roster above.
  2. Generic TagVotePicker.tsx grid — rendered via AttributeVotingPanel.tsx on the card-detail modal / printing-tag queue when a printing hasn't resolved. Backed by post_tag_consensus (views.py:1560-1574), which queries Tag.objects.order_by("name")every seeded Tag, no taxonomy filter — and post_submit_tag_vote (views.py:1577-1619), which accepts Tag.objects.get(name=req.tagName) for any existing tag name.
  3. Question-feed "tag" tierQueueTagQuestion.tsx, served by _tier_2_contested/_tier_4_fresh (question_feed.py:470-519, 536+) via get_tag_review_queue_pairs() (tag_consensus.py:358-389). Candidate set is Card.tag_vote_statuses for any tag with CONTESTED/UNRESOLVED status — populated by resolve_and_persist_tag_votes() (tag_consensus.py:81-134) for "every tag that has at least one vote cast against card" — also not filtered to the 11-tag roster.

Conclusion: the 11-tag chip ring is a curated subset; the underlying CardTagVote/consensus/question-feed machinery is generic to any seeded Tag row.

V3 — Is "AI-Generated" already human-votable?

Mechanically, yes — it already works end-to-end, at low coverage. "AI-Generated" is a real, pre-existing Tag (cardpicker.default_tags.DEFAULT_TAGS), TagModerationClass.STANDARD — briefly marked SENSITIVE in PR #263, reverted by owner decision 2026-07-21 (local_detect_ai_art.py:22-42: "ordinary human votes is fine for AI I think... they will go contested if there is not an immediate human consensus that is the system working as intended"). It is not in ATTRIBUTE_CHIP_TAG_NAMES (no ring/rail chip), but is reachable via surfaces 2 and 3 above.

Production row counts (read-only query, 2026-08-05):

  • CardTagVote rows with anonymous_id='ai-art-detector-v1': 1,183, all source=OCR, from a single run_id (20260721T114816-0fa65084 — confirmed via .distinct() on both CardScanLog and CardTagVote; the detector has never run a second time).
  • "AI-Generated" tag total CardTagVote rows: 1,185 (1,183 machine + 2 human USER-sourced — essentially untouched by humans despite being live-votable today).
  • tag_vote_statuses breakdown for "AI-Generated" across 1,185 cards: 100% unresolved, 0 contested, 0 resolved — a lone OCR vote can never clear the human-backed gate (vote_consensus.is_human_backed_source, line 55-56: only sources outside {DEDUCTION, OCR, FEDERATED, IMPLICIT} count), so all 1,185 of these cards are, right now, live-eligible in get_tag_review_queue_pairs() — a human confirmation prompt already exists for them; almost nobody has answered it.

Detector coverage (verified 2026-08-05, contradicts nothing in issue #367 — if anything it's worse today): CardScanLog skip breakdown for ai-art-detector-v1: no-evidence=196,856, no-marker-hit=19,401, plus 1,183 votes = 217,440 cards ever touched, out of 230,501 total Card rows (13,061 never touched at all). Of those, only no-marker-hit + votes = 20,584 cards (8.9% of the catalog) have ever actually had their evidence checked against the marker list. The other ~91% either had no ImageEvidence yet at the single 2026-07-21 run (no-evidence, technically rescan-eligible but never rescanned) or postdate that run entirely. #367's "ran once, ~90% uncovered" finding is current, not stale.

V4 — Is external-IP/UB-UW defined and human-votable?

A "external-ip" Tag already exists (Tag.objects.filter(name='external-ip').exists()True, 7 production CardTagVote rows — necessarily all human, since the only machine writer, import_external_ip_tags, was retired 2026-07-29 having never run in production, 0 rowsmodels.py:1597-1617, scryfall_bulk_data.py:74). But its wiring is narrow: it is one of seven fixed reason-chips in MPCAutofill/cardpicker/reason_tags.py:56-98 (EXTERNAL_IP_TAG_NAME = "external-ip"), surfaced only by NoMatchReasonStrip.tsx — a single-tap, one-directional "why no match?" reason shown only immediately after a human has already voted "No match" on printing identification. reason_tags.py:90-93 states explicitly: "There is no 'external-ip'-negation counterpart tag here either" — no persistent Yes/No toggle exists for it the way the 11-chip roster or TagVotePicker work for other tags.

#505's claim ("no UB/UW-related data is stored or indexed anywhere... set_type/security_stamp [are not] declared" on PrintingMetadataRow/CanonicalPrintingMetadata) is verified current: grep -c 'set_type\|security_stamp' MPCAutofill/cardpicker/models.py → 0 matches.

#635 (open, owner-ratified 2026-07-29) defines "external-ip" as an artwork-origin test ("does this art come from outside Magic's own IP"), explicitly not the Wizards "Universes Beyond" product line — and rules the tag applies at illustration grain, not card/printing grain (1,467 oracle cards split card-grain vs. 0-of-50,757 illustration counterexamples). Still open: whether 21 homage basic lands (Godzilla/Mothra/Dracula art) count. #437 (open) is the companion machine deductive-tagging pass researching set_type/security_stamp as the canonical-side UB/UW primitive — separate from human votability, cross-referenced not duplicated here.

V5 — Taxonomy addition or mechanism build?

Taxonomy addition, not a mechanism build. The generic CardTagVoteresolve_and_persist_tag_votesget_tag_review_queue_pairs → question-feed-tag-tier pipeline already handles both tags mechanically today, proven by live production data: 1,185 UNRESOLVED "AI-Generated" rows and 7 "external-ip" CardTagVote rows already exist and already flow through the same consensus gate as any other tag. No new model, endpoint, or consensus mechanism is required. What's actually missing:

  1. Neither tag is in the curated, proactively-surfaced roster — ATTRIBUTE_CHIP_TAG_NAMES (backend) / ALL_ATTRIBUTE_CHIPS (frontend) — so neither gets a ring/rail chip or a prioritized question-feed slot; a voter only reaches "AI-Generated" via the passive, unprompted TagVotePicker grid, and only reaches "external-ip" via the one-shot no-match-reason strip.
  2. For "external-ip" specifically: today's wiring has no persistent toggle / no NOT_APPLICABLE counter-vote path (a deliberate prior decision, reason_tags.py:90-93) — making it "votable... for custom cards" the way AI-art already partially is would mean either adding it to the direct-access roster (reopening that no-negation decision) or building the What's That: contextual report button — flag an image for moderators against relevant tags, with optional per-user hide #714 context-aware report affordance on top of the existing reason-chip.
  3. For "AI-Generated" specifically: detector coverage (AI-art detector has run only once; 90% of ImageEvidence (197k rows, including the whole 18,418-card blank-OCR pool) was never scanned #367) and stale-negative rescanning (AI-art detector: rescan-on-evidence-change tool (no-marker-hit negatives are permanently gated) #278) are the actual scaling bottlenecks on the machine side — tracked separately, not part of this issue's scope.

Why AI-art voting is load-bearing, not merely parity

#278 (open) confirms: AI_ART_RESCANNABLE_SKIP_REASONS = {no-evidence, incomplete-evidence} (local_detect_ai_art.py:139) — no-marker-hit is deliberately excluded. Once the detector scans a card's evidence and finds no marker, that negative conclusion is permanent; no automated path revisits it (#278's own body: "no-marker-hit negatives are permanently gated", one concrete flip already found: card 214416). Combined with #367's ~91%-never-checked figure verified above, for the overwhelming majority of the catalog there is either no machine verdict at all, or a stale one the machine cannot itself correct. A human vote is the only correction path for a detector false negative — this makes AI-art human voting a repair mechanism for a channel that structurally cannot repair itself, not a nice-to-have UI parity item.

The two-mode distinction (differs between the two attributes)

  • AI-art: a machine channel exists but has thin, currently-frozen coverage (8.9% actually checked, single historical run). Where the detector has run and hit (1,185 cards) — a human vote is a confirmation with a prior, and the mechanism to serve that confirmation already exists live in the question feed today, just unprompted/low-visibility. Where it hasn't run (the other ~91%) — the human answers cold, and today has no entry point at all short of unprompted use of the generic TagVotePicker grid.
  • External-IP on custom art: no machine ground truth is possible — custom art has no Scryfall record, so there is no set_type/security_stamp to read (confirmed: those fields don't exist anywhere in models.py). The human vote is the primary and sole signal, always cold — but the current wiring (the no-match-reason strip) only reaches a card after an unrelated prior vote, is single-tap, and has no revisit path.

This maps onto the difficulty-lane model recorded in #716 (easy = confirm an uncontested machine vote; medium = break a tie between competing votes; hard = answer cold), derived purely from data state, no telemetry needed. #619 is the sibling case for frame-style/bleed-edge chips (145,419 evidence-derivable votes sitting uncast) — same "channel exists, coverage is the gap" shape as AI-art, cross-referenced not duplicated.

Two open decisions (for the owner — not answered here)

  1. Vote or report? Should AI-art determination be a consensus vote, a moderator report (the affordance proposed but not yet built in What's That: contextual report button — flag an image for moderators against relevant tags, with optional per-user hide #714, part of epic [Epic] What's That Card: full-page repass (layout, question content, feed intelligence) #704), or both? The closest existing precedent is "Marked as proxy" tag + marker-absence moderation flags (catalog compliance scan) #291 ("Marked as proxy" tag + marker-absence moderation flags): a machine detector casts a positive tag, human-confirmable via ordinary consensus (same generic gate, no special moderator requirement on the vote itself) — but the negative/absence case routes to a separate, batched moderator-review flag surface, not the vote system. That precedent already suggests an answer in the direction of "both, split by direction" — vote for confirmation, a separate flag surface for escalation — but the owner should rule on it explicitly. What's That: contextual report button — flag an image for moderators against relevant tags, with optional per-user hide #714 itself is scoped only as a record (owner's own words, quoted there): "a card that is custom art could be universes within or beyond (art:external-ip)... there should be a report button... that lets a user flag for mods among relevant tags" — i.e. the owner has already asked for exactly this kind of context-aware report affordance, evaluated against the existing ReportCardPanel.tsx (fixed NSFW/Low-quality/Wrong-info/Broken-image/Other reasons, anonymous-id-scoped, 10/day rate limit) and cardpicker/moderation.py moderator queue, both of which already exist and were assessed as the natural extension point (not greenfield).

  2. Threshold. The standard consensus gate is PRINTING_TAG_MIN_SHARE = 0.6 (60%), PRINTING_TAG_MIN_VOTES = 2 (MPCAutofill/settings.py:65-66), plus the hard human-backed gate (vote_consensus.is_human_backed_source, line 55-56 — no volume of DEDUCTION/OCR/FEDERATED/IMPLICIT votes can resolve a tag alone). This applies uniformly today to "AI-Generated" (moderation_class=standard, confirmed live — no AI-art-specific higher bar or mandatory moderator co-sign exists in code, per the owner's own 2026-07-21 reversal of its earlier SENSITIVE marking). A false "this is AI-generated" resolution is a public accusation about a named human artist's work — the reputational cost of a wrong resolution is asymmetric, unlike e.g. border-colour, where a wrong call is merely a wrong call. Should this tag carry a higher threshold, a mandatory moderator review before the resolution becomes visible/actionable, or the standard gate as-is? Stated neutrally; the owner decides.

Cross-references (not modified by this issue)

#367, #278, #291, #437, #505, #635, #714, #716, #619

Owner decision (2026-08-05): context-dependent surfacing

Owner, verbatim: "I think I want them to surface context dependant. external-ip, ai art can show when we want to ask about custom art."

This settles part of decision 1 above (vote or report?) in one direction: neither tag joins the fixed 11-tag ATTRIBUTE_CHIP_TAG_NAMES roster surfaced unconditionally on every card. Instead both surface conditionally, when the feed/rail is asking about custom art specifically — a third surfacing mode alongside the two already documented in V2 above (the curated chip ring, and the generic always-available TagVotePicker/question-feed-tag-tier). Not yet specified by the owner: the exact trigger condition ("when we want to ask about custom art" — plausibly a custom-art tag already present on the card, or a card with no resolved printing at all) or which of the two existing generic mechanisms this context-dependent mode attaches to. Both remain open.

Owner, also verbatim: "They can be sensitive, but no consumer built for sensitive yet."

V1 finding (verified against current code) — refutes this claim as stated. A SENSITIVE-class consumer already exists and is live, not absent. tag_consensus.resolve_tag passes require_privileged=tag.moderation_class == TagModerationClass.SENSITIVE into vote_consensus.resolve_weighted_consensus (tag_consensus.py:73). When true, a crowd consensus that would otherwise resolve is instead parked as PENDING_PRIVILEGED (vote_consensus.py:533-534) unless a moderator/admin vote backs the winning side — persisted as TagVoteStatus.PENDING_APPROVAL (tag_consensus.py:140-141), with card.tags deliberately left untouched so a pending tag has zero search consequences until a moderator co-signs. A moderator-facing approval queue is already wired too: get_pending_approval_queue_pairs (views.py:1992-2003), and SENSITIVE tags additionally block implicit votes while pending (views.py:1711). Tested end-to-end (tests/test_moderation_gate.py, tests/test_sensitive_tags.py).

What's actually true and load-bearing for this issue's open threshold decision (item 2 above): neither "AI-Generated" nor "external-ip" is currently marked SENSITIVE. "AI-Generated" is explicitly STANDARD (sensitive_tags.py:46-60, FORMERLY_SENSITIVE_TAG_NAMES, reverted from #263's brief SENSITIVE marking by the 2026-07-21 owner decision already cited in V3 above), and "external-ip" was never listed in SENSITIVE_TAGS at all. So the gap is narrower than the owner's framing suggests: the SENSITIVE consensus gate itself is built and working; what does NOT exist is any UI/report-affordance CONSUMER of a SENSITIVE tag (the surface #714 describes), and no decision has marked either of these two tags SENSITIVE in the first place. If this issue's threshold decision lands on "raise the bar for AI-art", the consensus-side change is one line (add the tag name to sensitive_tags.SENSITIVE_TAGS); the report-button UI consumer described in #714 is the piece that would need to be built from scratch.

Threshold decision: resolved - standard consensus gate, no raised bar (2026-08-06)

Answers this issue's open decision 2 (threshold). Both AI-Generated and external-ip stay STANDARD class (see #714 for the full moderation-class ruling and code verification - not duplicated here); the ruling's reasoning: ordinary human votes are sufficient for these two attributes, and absent immediate human consensus the tag going CONTESTED is the system working as intended, not a failure to be pre-empted with a privileged co-sign.

Use the standard consensus gate for both - verified: PRINTING_TAG_MIN_SHARE = 0.6 (MPCAutofill/MPCAutofill/settings.py:66), PRINTING_TAG_MIN_VOTES = 2 (:65), plus the unconditional has_human_backed requirement (vote_consensus.py:532, winner["has_human_backed"]) - the same gate every other STANDARD tag resolves through, applied via tag_consensus.resolve_tag (tag_consensus.py:35-78) with require_privileged=False for a STANDARD tag's moderation_class (:73). No raised bar, no mandatory moderator co-sign, for either tag.

Consequence - context-dependent surfacing is now principled, not arbitrary. Per the 2026-08-05 owner decision above, both tags surface conditionally ("when we want to ask about custom art") as ordinary votable chips through the existing generic TagVotePicker/question-feed-tag-tier mechanism (V2 above) - the same STANDARD consensus path as any other tag. This pairs with #714's SENSITIVE-set ruling: SENSITIVE narrows to exactly NSFW and appropriate-bleed, which surface instead through #714's always-visible report affordance with privileged co-sign. STANDARD attributes (this issue) surface context-dependently as ordinary votable chips; SENSITIVE attributes (#714) surface through the report affordance and carry privileged co-sign - two surfaces for two moderation classes, not an arbitrary split.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions