…ion-tag grain (#599)
* docs: printing-tag vs illustration-tag grain — decision document
Investigation only; no pipeline code touched. Answers whether
PrintingTagVote should continue to exist.
Measured (production, read-only):
- PrintingTagVote: 0 rows, 0 of them human. No consensus resolver exists
anywhere; printing_tag_consensus.py, forward-referenced by two
docstrings, has never existed on any branch. Nothing reads the table.
- resolve_weighted_consensus applies a hard has_human_backed gate
independent of the weight math. Executed: machine-only vote sets return
None at n=1,2,4,10,1000. The original "tag art:external-ip cards as UB
automatically" design was unreachable at every grain, not just this one.
- CanonicalPrintingMetadata.promo_types already carries the Scryfall token
"universesbeyond" on 10,407 of 113,224 printings, 100% per-set recall on
every dedicated UB set, correctly partial on sld/clu, correctly zero on
unf. set_type and security_stamp are not stored at all.
- Counterexample search for illustration grain: 0 of 50,828 illustrations
appear on both a UB and a non-UB printing, against 25,381 reprinted and
20,542 crossing set boundaries. UB-ness travels with the artwork.
- art:external-ip is broader than promo_types: ~13,166 printings vs
10,407. The ~2,759 delta is counted but not characterised.
- Only 4 of 230,770 catalogue images have a vote-resolved printing;
19,475 have the ingestion-time canonical_card link, of which 3,450
already resolve to a UB printing today.
- The external-ip Tag does not exist in production, so neither the
printing-tag endpoint nor the human no-match reason strip can record it.
- import_external_ip_tags was forgotten, not abandoned: an owner-ratified
step 4d on 2026-07-28 that fell out of the handoff chain, invisible to
the roster tether's non-recursive glob. Every prerequisite passes today.
Also disambiguates the three adjacent vote models and traces
PRINTING_TAG_MIN_VOTES, PRINTING_TAG_IMPLICIT_CAP and
_split_new_printing_tag_votes to their call sites — none govern
PrintingTagVote.
Ten numbered open questions for the owner. Indexed in docs/reports/
README.md; docs_lint.py --strict clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN
* docs: fold in the three owner rulings of 2026-07-29
1. The machine-only-cannot-resolve gate is the design, not a defect
("that is the entire theory in one sentence, we designed things like
this on purpose"). Reframed as the governing constraint and moved to
lead the document: any tag category expected to resolve from machine
evidence alone is mis-specified by construction; the correct design
either routes it through human confirmation or does not model it as a
vote at all. Votes are for disputable claims; an imported Scryfall
fact is not one.
2. Why import_external_ip_tags never ran: never rebuilt after Scryfall
changed their API process, and it belongs in the new unified Scryfall
importer. Recorded as the authoritative account. The measured record
(PR #555 repaired the bulk-data path afterwards; every gate passes
today; the scheduled run then fell out of the handoff chain) is kept
alongside rather than dropped, since it does not perfectly match — the
two accounts compose. New section 8.7 specifies what the unified
importer must carry and what it must not (no negative pass, no
vote-system plumbing, no security_stamp revival, illustration grain
rather than printing).
3. Owner is willing to drop PrintingTagVote, and regards the existing
implementation as half-cooked. Section 7.1 updated; the recommendation
and its evidence are unchanged.
New section 0.1 answers the reframed question directly — what is the
smallest thing that delivers the UB filter:
(a) official printings need no vote at all (promo_types is deterministic
and already ingested; 3,450 catalogue images already resolve to a UB
printing via the existing canonical_card link);
(b) user-tagging of alternate-art cards needs card grain, not
illustration grain, because a custom image has no illustration id to
key on — and CardTagVote already does it end to end, sharing one
Tag.name with the machine path so tag:external-ip is one predicate;
(c) the migration is empty: 0 rows, 0 human votes.
Open questions renumbered to 11; the three settled ones are recorded as
closed rather than re-asked. docs_lint.py --strict clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN
* docs: mark the PrintingTagVote retirement as ruled and executed (#615)
The central open question of this decision document — should
PrintingTagVote exist — was answered no and implemented while the PR sat
unmerged. Adds a dated update note, closes open question 1, and corrects
the reports index entry so the document reads as a record rather than a
live proposal. The analysis itself is left as written.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Owner ruling, 2026-07-29: "i am willing to not need the printing tag. i am happy to reduce things to the minimum that gives us our expected results. we don't need to reinvent the wheel everyday." And on its origin: "printingtag was half cooked."
Implements PR #599's §7.1 / §8 recommendation. Every claim below was re-verified against production and the working tree by this PR's author, not taken on trust from the investigation.
Why this is safe
PrintingTagVoterows in productionselect count(*) from cardpicker_printingtagvote, read-only, 2026-07-29)printing_tag_consensus.py, forward-referenced by two docstrings, is absent from disk, fromgit log --all, and from every open PRsubmitPrintingTagVotefrontend/src/(re-grepped)docs/upstreaming/license-provenance.md§2 excludesmodels.pyfrom file-level protection; its manual-review carve-out names exactlyVoteSource,AbstractWeightedVote,CanonicalPrintingMetadata,CardPrintingTag. The three protected consensus modules do not referencePrintingTagVote. No license-provenance review is triggered.Human votes are the one thing in this system that cannot be re-derived. There are none here, so there is nothing to migrate, export or rescue.
The deeper reason it was unsalvageable rather than merely unused: an imported Scryfall fact is not a disputable claim.
resolve_weighted_consensus'shas_human_backedgate is absolute and independent of the weight sum, so a machine-only channel returnsNoneat any volume — the model was built to carry an indisputable fact through a mechanism designed for disputable ones.What is NOT touched
The names are confusingly adjacent and an earlier orchestrator conflated two of them while briefing this change. Stated explicitly:
CardPrintingTag— 167,206 rows live. "This catalogue IMAGE depicts this Scryfall PRINTING". The entire Stage D printing channel, read byprinting_consensusintoCard.printing_tag_status/inferred_canonical_card/ Elasticsearch. Untouched.CardTagVote— 223,909 rows live. Read bytag_consensusintoCard.tags. Untouched.PRINTING_TAG_MIN_VOTES/PRINTING_TAG_IMPLICIT_CAP/PRINTING_TAG_MACHINE_WEIGHT— app-wide consensus weights, never on aPrintingTagVotepath. Untouched._split_new_printing_tag_votes/_purge_and_write_printing_tag_votes—CardPrintingTagcollision guards (list[CardPrintingTag]signatures). Untouched.verify_no_machine_only_resolutionsalready excludedPrintingTagVoteatpurge_machine_votes.py:139— confirmed; only its now-stale explanatory note changed, no gate logic.What shipped
PrintingTagVotemodel deleted; migration0101_delete_printingtagvote(DeleteModel).views.post_submit_printing_tag_vote,_SubmitPrintingTagVoteRequest,_get_canonical_card_or_400, and the2/submitPrintingTagVote/route deleted. No frontend caller existed, so this is not a breaking API change in practice. (The rate-limit helpers named_printing_tag_rate_limit_*stay — five other endpoints share them.)AdminPrintingTagVotedeleted.purge_machine_votes: the queryset, both.delete()calls, andPurgeResult.printing_tag_votes_deletedremoved. The counter was dropped, not pinned at 0 — a purge report naming a table that no longer exists is a claim about work it did not do, and this module's whole premise is that its output must be sufficient on its own to reconstruct what was destroyed. The rationale is written into_report_counts' docstring beside the pre-existing "keep the wording stable for log scrapers" note it deviates from.test_purge_machine_votes.py'stest_covers_all_four_vote_tablesbecomestest_covers_every_vote_tableover three.manage.py import_external_ip_tags(579 lines) andtest_import_external_ip_tags.pydeleted — see the ruling below.test_submit_printing_tag_vote.pydeleted.printing_tag_consensus.pyforward-references are gone, along with the file that carried them.vote_write.pyandintegrations/game/scryfall_bulk_data.py(whose "three importers" origin story now names two live ones and one retired).The dormant importer — ruling: REMOVE, with the knowledge relocated, not the PR description
It could not have been kept. The command's entire purpose is writing
PrintingTagVoterows; with the model gone it would not import, let alone run. "Keep it and make it fail loudly" would mean gutting its write path anyway, leaving a stub that preserves nothing.So the knowledge was moved somewhere greppable from a checkout, not left in a PR description.
docs/features/printing-tags.md's External-IP entry is rewritten from "here is how the import works" into a retirement record carrying:art_tagsbulk entry, the BFS-to-fixpoint overchild_idsfrom theexternal-ipslug (deliberately not the one level the original plan specified) with its fail-loud-on-missing-slug rule, the two-pass tag/tagging split that keeps the taggings payload out of memory during indexing, and theillustration_id -> default_cards (incl. per-facecard_faces) -> CanonicalCard.identifierjoin;promo_typesreaches 10,407 of 113,224 printings at 100% per-set recall;art:external-ipcloses over 2,799 tags / 8,332 illustrations reaching ~13,166 — an uncharacterised ~2,759 delta that is the whole case for the artwork-origin question being distinct;NOT_APPLICABLErows into a table nothing read), thesource=DEDUCTION/scryfall-tagger-v1/run_id/purge plumbing, andsecurity_stampas a UB signal;git show e6c6429a:MPCAutofill/cardpicker/management/commands/import_external_ip_tags.pyto read the deleted code verbatim.Two contracts were rescued rather than deleted with it:
EXTERNAL_IP_TAG_NAMEmoved toreason_tags.py, beside the surviving human channel. It is a convergence contract — both channels must write oneTag.namesotag:external-ipis a single predicate — and it now outlives the code that used to honour it.test_reason_tagsstill pins it.vote_write'starget_fieldparameter lost its only production caller. Its two tests are re-pointed atCardPrintingTag.printing_id(which also has that column) rather than deleted, so the parameter stays exercised instead of quietly becoming acard_idconstant. The re-pointed test is sharper than the original: the stale row belongs to a different card, same printing, so only aprinting_id-keyed purge can reach it.scryfall_bulk_data.ART_TAGSis kept with its live-endpoint test although nothing now consumes it — one constant plus one test is cheaper to carry than re-deriving which bulk entry type the Tagger feed lives under, and the test is what would tell us if it disappeared upstream in the meantime.Verification
DeleteModel, alongsideCardPrintingTag167,206 andCardTagVote223,909 as a positive check that the right table was identified.cardpicker/tests/suite: 3237 passed, 8 skipped, 0 failed (4m16s, run twice).makemigrations --check --dry-run: "No changes detected", exit 0.cardpicker: exactly 1 ([('cardpicker', '0101_delete_printingtagvote')]), asserted viaMigrationLoader.docs_lint.py --strict: clean.pre-commiton every changed file (ruff, isort, black, mypy, prettier, README parity): all pass.PrintingTagVote,printing_tag_consensus,import_external_ip_tags,submitPrintingTagVote,scryfall-tagger: every surviving hit is a deliberate historical aside, plus the twoTestSplitNewPrintingTagVotes/TestPurgeAndWritePrintingTagVotesclass names that are aboutCardPrintingTagand must stay.The file is numbered 0101 per the orchestrator's allocation (#601 → 0099, #604 → 0100), but its
dependenciespoint at0098_card_illustration_consensus_fields,master's real and only leaf, so this branch is migratable and single-leaf today and its CI can actually run.This follows the convention
0098_superseded_card_printing_tag_archive(#604) states in its own docstring: "a dependency on a migration that does not exist onmastermakes THIS branch unmigratable today, with certainty, in exchange for avoiding a collision that may never happen. A collision, by contrast, is loud and caught at merge time bymakemigrations --check."=> Whichever of #601 / #604 merges before this, repoint this migration's dependency at the new leaf on rebase. Leaving it on 0098 after 0099/0100 land gives
cardpickertwo leaves, and becausepytest-djangobuilds its test DB by runningmigrate, that takes every branch's CI down — the failure #576 exists to repair. The migration's own docstring says all of this at the top.Current state confirmed 2026-07-29: #601 has already renumbered to
0099_rename_printings_count_catalogued; #604 is still at 0098 as0098_superseded_card_printing_tag_archiveand expected to become 0100.#588 (open) adds a
docs/pipeline-fidelity-gate.mdroster entry forscryfall-tagger-v1that links tomanagement/commands/import_external_ip_tags.py— a file this PR deletes. If #588 merges first, that entry must be removed (or rewritten as retired) here on rebase, ordocs_lint's path check will fail on the dead link. If this merges first, #588's recursive-glob fix will simply not find the identity and its roster row should not be added.Per PR #599 §10.10 the recommended order is #588 first: the tether fix is correct independent of this ruling, and a row that is later deleted is cheaper than a tether that silently misses the next dormant identity. Either order works; they must not be merged blind to each other.
Deviations from the brief
printing_tag_votes_deletedremoved rather than pinned at 0. PR Decision doc: should PrintingTagVote exist? Printing-tag vs illustration-tag grain #599 §8.2 offered both. Reasoning is in_report_counts' docstring and above.vote_writetests re-pointed rather than deleted, to keeptarget_fielda tested parameter.docs/reports/2026-07-29-fidelity-gate-recheck.mdgot a dated "PARTLY SUPERSEDED" banner rather than edits. It is a same-day audit whosePrintingTagVote/scryfall-tagger-v1findings were accurate when measured and are part of what motivated this removal; rewriting a dated report to match a later decision is the opposite of what this repo's correction log exists for.test_vote_consensus.py's "some other calculator" example swapped fromscryfall-tagger-v1tostage-d-illustration-v1. The assertion is about the three-conjunct zero-weight rule and is unchanged, but an example identity nobody can look up is a worse example.Open items for the owner
external-ipTagin production —manage.py seed_no_match_reason_tags. It does not exist live (Tag.objects.count()is 30), so the human card-grain channel — the one that works end to end — still cannot recordexternal-ip. Independent of everything here, and one command.art:external-ipandpromo_types. A ~50-row sample decides whether the Tagger dependency survives into the unified importer at all. The algorithm to do it is preserved in the retirement record.promo_types— 3,450 catalogue images already resolve to a UB printing today through the existingCard.canonical_cardlink, with no vote of any kind. Denormalising ontoCard.tagsgets Elasticsearch filtering free and shares one predicate with the human channel.🤖 Generated with Claude Code
https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN