-
Notifications
You must be signed in to change notification settings - Fork 0
Printing Tags
Current-state reference, as of 2026-07-17. Full stage-by-stage build
history (Stages 1–7 below): git log e4eb6cb3 -- docs/features/printing-tags.md
and earlier commits — that SHA is the last commit before this file was
rewritten from a linear changelog into this reference.
Stage 8 onward (local/zero-API-cost printing-ID backfill, a.k.a. the "catalog-completion" package) is still in active development and not yet migrated into this file — for its current status, architecture, and known gaps, see Catalog-Completion-Plan (that file is the live source of truth for it) and this file's own tail section below the "Stage 8" heading, kept as-is until that work reaches a hold.
Additive, upstream-pitchable feature letting users/admins tag which
Scryfall printing a Card (catalog image) depicts, with a
weighted-consensus mechanism to auto-resolve uncontested cases. A single
unified question feed (GET 2/questionFeed/) now drives voting for
printings, artists, tags, and moderation from one screen.
-
Data model:
CanonicalCard(cardpicker/models.py) is already a per-printing model (identifier= Scryfall printing UUID, unique on(expansion, collector_number)) — no separateCanonicalPrintingmodel was added.CanonicalPrintingMetadata(OneToOne) holds the Scryfall fieldsCanonicalCarddoesn't (fullart, border_color, frame, frame_effects, promo_types, edhrec_rank, released_at, lang), populated bycardpicker/printing_metadata_import.py+import_scryfall_printing_metadata. It also holds one field that is not Scryfall data:catalogued_printings_countcounts how manyCanonicalCardrows _we hold per oracle id, computed by that same importer as aCounterover our own table. It says nothing about how many printings Scryfall publishes, and cannot detect that our catalogue is missing printings, because it is derived from the catalogue itself. It was calledprintings_countuntil 2026-07-29 (migration 0099), and this document described it as Scryfall printing data — that was false.CardPrintingTag.printingFKs directly toCanonicalCard. -
Artwork identity, per face: the same model carries
art_crop_urland two illustration columns at different grains, and consumers must pick deliberately.illustration_idis a scalar, front-face-only value — for a multi-faced row it iscard_faces[0].illustration_id, so the back face's own artwork is not addressable through it.face_illustrations(JSON list, migration 0095) retains every face's ownillustration_idpaired with that face's own name, incard_facesorder, so index 0 is the front and index 1 is the back. The scalar is unchanged and still front-face — the list is purely additive. Two properties of the list are load-bearing, and are pinned byTestFaceIllustrationsincardpicker/tests/test_printing_metadata_import.py:- It is
[]for anything that is not a genuine double-faced card, gated on theDOUBLE_FACED_LAYOUTSallowlist (transform,modal_dfc,double_faced_token,battle,reversible_card) — the same allowlistget_back_face_namesuses.split/adventure/flip/aftermath/mutate/prototypealso nest several named modes undercard_faces, but those modes are printed on one physical face; giving "Stomp" its own entry would assert a second scannable side of "Bonecrusher Giant" that does not exist, and would let a scan of the creature be attributed to the adventure's artwork.meldis out of scope by construction — meld pieces carry nocard_facesof their own in this bulk data. - A face that Scryfall publishes no
illustration_idfor recordsNonerather than being dropped, so the list's index keeps corresponding to the face's position — a consumer walkingcard_faces[1]must not have the list silently shift under it.
- It is
-
How this column refreshes (it is not a one-off backfill):
import_scryfall_printing_metadatais a full-set, value-diffing upsert, not insert-only and not a last-modified diff. Every run re-derives the desired row for every printing in thedefault_cardsbulk file and compares it field-by-field against the stored row, joined oncanonical_card_id: no match → CREATE, any_METADATA_SYNC_FIELDSmember differs → UPDATE, all equal → SKIP, stored key absent from the desired set → DELETE.face_illustrationsis in_METADATA_SYNC_FIELDS, and that membership is what makes an ordinary run populate rows that already exist — so this needs no backfill command and no flag; running the importer is the backfill. That membership is the whole load-bearing bit:bulk_createwrites every column regardless, so had the field been omitted from_METADATA_SYNC_FIELDS, newly-seen printings would still have populated while every already-stored row stayed[]forever. The column therefore reads[]on every row imported before the field existed, until the next import run. -
Expected coverage — a mostly-
[]column is correct, not a bug. Measured against the on-diskdefault_cardsbulk file and production on 2026-07-30: of 116,254 bulk rows, 1,594 carry a genuine double-faced layout with two or more faces, and all 1,594 join to aCanonicalCardthat already has aCanonicalPrintingMetadatarow. So a first import populates 1,594 of 113,224 rows (1.4%) — 1,534 of them with a realillustration_idon every face, exposing 1,534 back-face illustrations the scalar column cannot address, and 60 with name-only entries whoseillustration_idisNonebecause Scryfall publishes no artwork id for those faces. The remaining ~111,630 rows are single-faced and correctly stay[]. -
Card payload — machine-suggested printing + tag vote status (Proposal H §4.4′, issue #184, PR #195; consumed by the Select Version section, issue #167 — see Grid-Selector's own "Select Version section" entry):
Card.serialise()(cardpicker/models.py) takes a keyword-onlyinclude_suggested_printing: bool = Falseargument. WhenTrue,SerialisedCard.suggestedCanonicalCardis populated with the printing named by a machine-cast (VoteSource.DEDUCTION/OCR)CardPrintingTagvote — mirroringquestion_feed.py's_confirm_suggestion_itemai_votelookup exactly (same filter, same "first" ordering) so the two surfaces can't drift on what counts as "machine-suggested" — but only whileprinting_tag_status != RESOLVED(never redundant with the already-resolvedcanonicalCard). Deliberately does not reuseget_ranked_printing_candidates()(Levenshtein-ranked candidate search) here — that's a distinct, much more expensive mechanism, fine per-slot-on-demand but not for a bulk result set. The flag defaultsFalse(a zero-cost no-op) everywhere except the two bulk endpoints that actually need it —post_cards/post_explore_searchinviews.py, both paired withmodels.suggested_printing_votes_prefetch()on their queryset (aPrefetch("printing_tags", ...)filtered to machine votes, ordered bypk) so populating this field across a page of results costs one extra query total, not one per card._suggested_canonical_card()reads that prefetch cache and, if the instance wasn't prefetched (e.g. a one-off shell/test lookup), falls back to a single bounded per-instance query rather than silently returning stale data — but any new bulk call site MUST attach the prefetch itself; the method will never do it for you, by design, so a forgotten prefetch fails safe (quietlyNone-heavy) rather than silently reintroducing an N+1 across a whole page.Separately,
SerialisedCard.tagVoteStatuses(always populated, no flag, zero extra query cost — it's justCard.tag_vote_statuses, an already-loaded JSONField) collapses that field's 5-way DB status (resolved_apply/resolved_reject/contested/unresolved/pending_approval) down to the 2-way"resolved"/"suggested"distinction the frontend needs for the "Looks retro-frame? ✓" inline confirm-chip moment:resolved_apply/resolved_reject→"resolved",contested/unresolved→"suggested".pending_approvaltags are excluded from the object entirely (never bucketed either way) — same reason they're excluded fromCard.tagstoday: they're gated behind the sensitive-tag co-sign queue (Moderation) and must not leak to ordinary users ahead of that review. A tag absent from the object has zero votes cast against it at all, same convention as the DB field it derives from. Schema source:schemas/schemas/Card.json'ssuggestedCanonicalCard(referencesCanonicalCard.json, same shape ascanonicalCard) andtagVoteStatuses(references the newTagVoteDisplayStatus.jsonenum) — both purely additive, no existing field renamed or removed. -
Run-scoped eligibility + the vote archive (2026-07-29, owner directive: "prior runs must not suppress work in a new run; the CURRENT run's own output must, so a killed run resumes rather than redoing completed batches"). Every Stage D printing-channel calculator used to ask "have I EVER voted on / abstained on this card?", so a calculator's own history permanently narrowed every future run of it — a repaired engine could never re-examine anything the broken one had answered, which is why
stage-d-illustrationhad to be version-bumped v1→v2 purely to escape its own scan-log rows. Both self-suppressing excludes (.exclude(printing_tags__anonymous_id=…)and the non-rescannableCardScanLogexclusion) now additionally match on the CURRENTrun_idinlocal_calculate_verdicts._eligible_cards_queryset/_slow_path_eligible_cards_queryset,local_illustration._eligible_illustration_cards_querysetandlocal_identify_printing_tags._eligible_base_queryset(opt-in there; only lands passes a run_id today).run_id=Nonekeeps the old behaviour for callers that genuinely mean "anything this identity has never touched" —stream_backstop_sweep.verify_chunkis the live one.Two things make this safe rather than a churn machine:
-
The pre-write split compares the VALUE, not just the key.
local_calculate_verdicts._split_new_printing_tag_votesnow compares the whole set of(printing_id, is_no_match)a batch proposes for a(card, anonymous_id)group against what is stored — the shapelocal_illustration._split_new_illustration_voteshas always had. An unchanged verdict is still skipped (so a re-run over a converged catalogue writes nothing), and a CHANGED verdict now reaches the delete-then-insert instead of being dropped before it. Without this, un-suppressing eligibility buys nothing at all: the calculator recomputes the verdict and throws it away, and because the purge is scoped to the rows being written, a dropped row purges nothing and the stale vote survives verbatim. -
A superseded vote is ARCHIVED, not destroyed.
models.purge_stale_machine_votescopies every row it is about to delete intoArchivedCardPrintingTagfirst, stamped with the run that overwrote it. A separate table rather than retained generations in the live one because nine of the thirteen modules that readCardPrintingTagbypassresolve_vote_weightentirely — a zero-weight-by-run_id rule would not stop a retained generation being displayed byviews.py, counted bycatalog_stats.py, or treated as "already voted" by the eligibility query this work exists to un-suppress. Nothing in the pipeline reads the archive; the only consumer is the opt-inmanage.py local_calculate_verdicts --generation-diff <path>debug report (generation-diffing is a debug flag, never a default write path — owner ruling). Retention is issue #575's janitor's ("keep the N most recent runs, sweep the oldest, never delete wholesale"), which is why bothrun_idandsuperseded_by_run_idare indexed.
Order within a batch is a CORRECTNESS constraint, not a performance one:
fallback,illustrationandslow-pathall select POSITIVELY from join-key's output, so "purge everything, then run them in parallel" gives three of the four an EMPTY eligible set — a silent near-no-op that looks like it worked. Required order stays join-key → fallback → illustration → slow-path. The upstream populations those three select from, and slow-path's fallback-voted exclusion, are deliberately NOT run-scoped: a converged upstream calculator writes no rows under a fresh run_id, so a run-scoped version of them is empty on every re-run.cardpicker/tests/test_run_scoped_eligibility.pypins all of the above, including the out-of-order empty-set failure. -
The pre-write split compares the VALUE, not just the key.
-
Consensus:
cardpicker/printing_consensus.py::resolve_printing(card)— weighted-vote formula, weight by source (user 1, adminPRINTING_TAG_ADMIN_WEIGHTdefault 5, machine (deduction/OCR)PRINTING_TAG_MACHINE_WEIGHTdefault 0.5; settings inMPCAutofill/MPCAutofill/settings.py), resolved per-vote viavote_consensus.resolve_vote_weight(source, anonymous_id, run_id)rather than a bare source-keyed lookup — the one override it applies: the 28,112 votes cast by the 2026-07-14 deductive-name-backfill run (source=deduction,anonymous_id="deductive-backfill-v1", andrun_id=vote_consensus.DEDUCTIVE_BACKFILL_ZERO_WEIGHT_RUN_ID— the stamp migration0097_freeze_deductive_backfill_zero_weight_cohortput on exactly those rows) carry weight 0, permanently, per the 2026-07-23 owner ruling (seetheory.md's soundness section andpipeline-fidelity-gate.md's §3 item 3). Scoped to that cohort, not to the method (owner clarification, 2026-07-29): the cohort is zeroed so it can serve as a measurement control; name-matching deductive inference is not disqualified, so a vote cast by that same calculator in future carries the ordinaryPRINTING_TAG_MACHINE_WEIGHT. Every otherdeduction/ocrvote is likewise unaffected.PRINTING_TAG_MIN_VOTEScompares against summed weight, not row count. A winning group also needsPRINTING_TAG_MIN_SHARE(default 0.6) of total weight and at least one non-machine vote —vote_consensus.is_human_backed_source()is the one place that knows whichVoteSourcevalues are machine-derived, so no volume of machine-only votes can resolve a card alone. The shared core (vote_consensus.resolve_weighted_consensus, used by printing/artist/tag alike) additionally implements two owner-ratified mechanisms from the 2026-07-22 vote-weight scenario matrix (owner-ratified 2026-07-22, PR #325; raw ratification record:docs/reference/vote-weight-matrix.md— see that function's own docstring for the exact arithmetic):- No machine tipping of a human-vs-human contest (formerly labeled D1 in this document): if two or more outcome groups each carry SOME human-backed weight (a genuine human-vs-human disagreement), every group's non-human-backed weight (machine/implicit alike) is dropped entirely for both winner-selection and the gate checks — machine/implicit weight can pool an already-agreeing human side's total, but can never be the deciding weight in an actual human-vs-human contest it isn't part of.
-
Machine dissent never
de-resolves an already-quorum-valid human winner (formerly labeled
D4 in this document): if the no-machine-tipping
mechanism above didn't trigger and
the winner's own human-backed weight
alone already clears
PRINTING_TAG_MIN_VOTES, the share computation is recomputed from human-backed weight only across every group — so a pile of machine/implicit dissent elsewhere can't drag an already-quorum-valid human winner's share belowPRINTING_TAG_MIN_SHAREand silently de-resolve it (the single highest-impact fact the matrix's Stage D arithmetic table flagged: at 28k+ deduction-vote scale, 3+ machine dissent votes against a 2-user RESOLVED printing used to flip it back to UNRESOLVED with no CONTESTED status to signal it — this is fixed, not merely documented). Promotion (a lone human vote plus agreeing machine weight resolving a previously-UNRESOLVED pair) is unaffected by either mechanism — that's Stage D's own purpose and neither mechanism's trigger condition is met in that shape. -
Tag-path-only: dissent whose only weight is machine/implicit-
derived no longer classifies a
CardTagVotepair's persisted status ascontested(tag_consensus.py::resolve_and_persist_tag_votes) —contestednow requires more than one polarity backed by an actual human-backed vote. Such a pair still stays inget_tag_review_queue_pairs's candidate set (it still needs a human tiebreak), just filedunresolvedrather thancontested— de-escalated, not removed, so a real 23k+-scale deduction-dissent pile doesn't flood the review queue with false "genuinely contested" signals. Printing/artist paths are unchanged here (no persistedCONTESTEDstatus exists for printing; artist's own CONTESTED-vs-UNRESOLVED split is a separate, untouched raw-outcome-count heuristic).
-
identity-group pooling (md5 ∪ phash-d0) (issue #473 PR-3, owner-ratified 2026-07-25, md5-only; widened to union in artbox-phash distance-0 by issue #661/PR #695, 2026-08-05; soundness statement in
theory.md's §4 item 3): cards sharing a non-nullCard.md5_checksumindex the same image file and are ONE identification target, so printing consensus tallies them together — and, since PR #695, so do cards sharing a CURRENTImageEvidence.artbox_phashat exact (distance-0) equality, the same sound-entailment tierdocs/theory.md's two-threshold split already reserves for phash; a card with neither is a group of one.printing_consensus.identity_group_card_ids()expands a card to its combined group (md5_group_card_ids()/phash_group_card_ids()are its two direct components, still separately named);build_group_printing_vote_tuples()builds the group's tally andvote_consensus.pool_group_votes()collapses it by castinganonymous_id, for every vote — human-backed included: one agent's agreeing votes across members become ONE vote (a person answering the same image under two of its identifiers is one answer; a machine agent's verdict about identical bytes is one verdict), and an agent whose votes across members disagree with each other is withheld from the tally entirely (it has contradicted itself about identical bytes, so it is evidence for neither side — the same withhold-never-manufacture rule theg₄cross-checks follow). What sums is distinct agents: two different people voting on two different members are two votes, and that is the intended multiplier. Both rules were tightened at the 2026-07-25 gate on PR #482 — human votes were originally left unkeyed (which let ONE person reach quorum by answering two siblings) and self-contradiction originally kept the max-weight side (which, at equal weights, letcard_idorder decide which outcome an agent appeared to support). The pooled tally then runs through the UNCHANGEDresolve_weighted_consensus— same weights, same thresholds, same two mechanisms above, same human-backed gate, applied once per group instead of once per member.resolve_and_persist_printing()writes the outcome (inferred_canonical_card+printing_tag_status) to every member, in pk order, reindexing only the members whose indexed printing actually changed — so members cannot diverge while that shared path is the only writer, though a change in group MEMBERSHIP (checksum backfill, re-upload) needs aconsensus_recomputepass for the affected group;consensus_recomputewalks each group once;question_feedclassifies likely-resolve on the group tally and never serves a second member of a group a voter has already answered. A card with neither a checksum nor a current phash is a group of one, for which all of the above is provably the pre-#473 behavior — which is also every card until #473's PR-1 populates the column (LOCAL_FILEand other checksum-less sources stay null permanently).The phash-d0 union is narrower in scope than it looks. It replaces md5-alone in exactly the callers named above (
group_printing_votes/resolve_printing/resolve_and_persist_printing,question_feed.py's four answered-set widening helpers,consensus_recompute.py's printing loop) and nowhere else. It does not touch the human-backed gate itself, and it does not extend to illustration or artist consensus —illustration_consensus.pyandstage_e_dispatch.py's own separately-inlined md5 grouping, and Stage C+'s own phash-d0 vote-propagation tier (identification-pipeline.md's "Stage C+" section), are deliberately untouched — different consumers, different mechanisms. A single union of a card's md5 group and its own phash-d0 group, not an iterative transitive closure, is already the full connected component, becauseartbox_phashis a deterministic function of the image bytes (seeidentity_group_card_ids's own docstring for the argument). Measured against production 2026-08-05: 19,065 phash-d0 groups of size >1 covering 40,493 cards; 22,454 cards reachable by a phash-d0 sibling their md5 group alone would not reach; 1,492 cards with no printing vote of their own that gain access to one through the union. -
Frontend consumer (funnel round, docs/features/grid-selector.md's "art-picker FUNNEL" section): the two endpoints below are called from the
/displayrail's Select Version FUNNEL (SelectVersionResults.tsx'slayout="stacked"branch), not/editor— the funnel's per-axis chips are the filter-chip surface this section describes generically. The frontend request types (CastImplicitVoteRequest/RetractImplicitVoteRequest,frontend/src/common/schema_types.ts) andAPICastImplicitVote/APIRetractImplicitVote(frontend/src/store/api.ts) were added as part of that round — PR #325 (below) shipped the backend contract with the frontend half explicitly deferred.SerialisedCard.suggestedFilterTagNamesIS now the funnel's SUGGESTED-chip data source (fixed during PR #329 review, owner-ratified condition 6): an earlier draft of the funnel readcard.tagVoteStatusesinstead, which is a source-agnostic collapse (CONTESTED and UNRESOLVED both read"suggested", no implicit-vote exclusion, no weight floor) — since a SUGGESTED chip also drives F4b's implicit-vote cast-on-pick, that let an already-implicit-only signal seed MORE implicit votes for itself, the self-seeding loop condition 6 forbids.suggestedFilterTagNames's own implicit-exclusion and non-implicit-weight floor (below) close that loop — see grid-selector.md's own "Compliance fix" note for the full before/after. The endpoint payload for the field beingnull/absent (the backend serializer wiring that actually POPULATES this field for the/displayrail's card-fetch endpoint is a parallel, not-yet-landed PR) degrades to "no suggested chips" on the frontend, never a crash. -
Implicit votes (
VoteSource.IMPLICIT, owner-ratified 2026-07-22 vote-weight scenario matrix): a passive, low-weight signal cast when a person picks a candidate card on the/displayfunnel (see above) while one or more filter chips are active — the pick implicitly endorses those tags for that card, atPRINTING_TAG_IMPLICIT_WEIGHT(default 0.25) per vote, well below a realUSERvote. Never human-backed, never privileged, and the SUM of implicit weight per (card, tag, polarity) outcome group is hard- capped atPRINTING_TAG_IMPLICIT_CAP(default 1.0, strictly belowPRINTING_TAG_MIN_VOTES) — a pile of implicit votes can never form quorum alone, decide a live human-vs-human contest (the no-machine-tipping mechanism above), or veto an already-quorum-valid human win (the machine-dissent-never-de-resolves mechanism above). Lifecycle: one implicit vote per (anonymous identity, card, tag) —views.py::_cast_implicit_vote_and_resolvesupersedes (viaupdate_or_create) a stale implicit vote from the same identity, and refuses (silent no-op) to touch a row that's already a REAL vote from that identity — the(card, tag, anonymous_id)uniqueness constraint (models.py'scardtagvote_unique_vote) is shared across every source, so an implicit cast may only ever create a fresh row or update a row that's already implicit, never silently downgrade a deliberate vote.views.py::_retract_implicit_vote_and_resolveis the deselect path (only ever deletes a row whosesource == IMPLICIT). Write-side guards: refused entirely forSENSITIVE-class tags and for any (card, tag) pair alreadyRESOLVED_APPLY/RESOLVED_REJECT/PENDING_APPROVAL; every implicit vote is stampedvote_surface= "display-editor-filter"(views.IMPLICIT_VOTE_SURFACE). Rate-limited separately from real tag votes viaPRINTING_TAG_IMPLICIT_SUBMISSION_RATE(default 60/h — tighter thanPRINTING_TAG_SUBMISSION_RATE's 300/h, since browsing candidates under active filters generates implicit votes at a much higher natural cadence than deliberate tapping). Endpoints:POST 2/castImplicitVote/({identifier, tagNames, anonymousId}— one call per candidate pick, casting/superseding an implicit vote for every named tag; unknown/guarded tag names are silently skipped, never an error) andPOST 2/retractImplicitVote/({identifier, tagName, anonymousId}— the deselect path).cardpicker.vote_consensus.VoteTuple.is_implicitis the flag the shared resolver core keys its cap and its two human-contest-protection mechanisms off of — set bytag_consensus.py's wrappers, never inferred fromsourceinside the resolver itself (same "caller decides" conventionis_human_backedalready used). -
Suggested filter tags / suggestedness excludes implicit (owner-ratified 2026-07-22, PR #325; formerly labeled decision D6 in this document; raw ratification record:
docs/reference/vote-weight-matrix.md):tag_consensus.py::get_tag_net_polarity(the questionFeed confidence-fill scalar) EXCLUDESVoteSource.IMPLICITweight entirely — an implicit vote is a passive selection by-product, not independent evidence, so it must never color a chip's confidence fill or let a person's own earlier pick "explain itself" back to them. Separately,tag_consensus.py::get_suggested_filter_tags_overlay(card_ids)(batched, mirrorsget_resolved_tag_overlay's shape) computes which tags qualify as a suggested/editorfilter chip for a card: the APPLY side's non-implicit weight is >= 1.0 and not exceeded by the NOT_APPLICABLE side's own non-implicit weight, the pair's persisted status isn't alreadyRESOLVED_APPLY/RESOLVED_REJECT/CONTESTED/PENDING_APPROVAL, and the tag isn'tSENSITIVE. Exposed asCard.serialise'sinclude_suggested_filter_tagskwarg →SerialisedCard.suggestedFilterTagNames(schemas/schemas/Card.json), same opt-in-per-endpoint, zero-cost-when-unused pattern assuggestedCanonicalCard/include_suggested_printingabove — defaultsFalseeverywhere exceptpost_cardsinviews.py(the endpoint feeding the /display grid-selector candidate list,SelectVersionResults.tsxviacardDocumentsSlice), which now enables it and pairs it withmodels.attach_suggested_filter_tags_overlay(cards)called once on the fully-realizedcardslist BEFORE any.serialise(...)call — oneget_suggested_filter_tags_overlay()call (two queries total, see that function's own docstring) for the whole response, not one per card. The precomputed result is stamped onto each instance viaSUGGESTED_FILTER_TAG_NAMES_ATTR(mirroringSUGGESTED_PRINTING_VOTES_ATTR's shape above, though this one isn'tPrefetch-shaped so it's a plain call-then-stamp helper rather than aPrefetchobject);_suggested_filter_tag_names()reads that attribute first and only falls back to a single bounded per-instance query when it's absent (a one-off shell/test lookup, never the code path a bulk endpoint should exercise).post_explore_search(catalog browse, a different surface) does not enable this field yet — a future bulk endpoint doing so should follow the sameattach_suggested_filter_tags_overlay()call, never invokeserialise(include_suggested_filter_tags=True)per card in a loop. -
Search consumption:
printing_consensus.py::get_resolved_printings(identifiers)is the single shared gate (printing_tag_status == RESOLVEDonly) that both the search re-rank (search_functions.py::retrieve_card_identifiers, a stable-sort boost after the existing ES hard filter, never a new query path) and the opt-in Full Art/Borderless attribute filters (ResolvedAttributeFilter.tsx) consult, so they can't drift on what counts as "resolved."Card.get_expansion_code/get_collector_number(models.py) fall back toinferred_canonical_cardwhen RESOLVED, so community-tagged cards (which mostly lackcanonical_card) are actually reachable by the boost/filter, not just ingestion-time-matched ones.Card.printingTagStatus+getPrintingMatchLabeldrive the frontend's match-indicator icon. Known gap: client-side (local-folder/Drive, Orama-indexed) search has no ES/DB access and gets no re-rank/filter/ indicator parity. -
Reindex on vote transition:
documents.py::reindex_card_safely(card)is the shared, failure-isolated ES push (never raises — Postgres is truth, ES is a projection) thatresolve_and_persist_printingandtag_consensus.py::resolve_and_persist_tag_votescall when a card's effective indexed printing/tags actually change, so a vote that just resolved consensus is searchable immediately rather than waiting for the next scheduledupdate_databasescan. -
No-match reason tags: seven
Tagrows (custom-art,altered-frame,upscaled,ai-art,no-collector-line,non-english,external-ip) seeded bymanage.py seed_no_match_reason_tags(a management command, not a migration — see Lessons's data-migration-vs-command-seeding entry). These exact strings are a federation interchange contract (other instances consuming our vote export expect them) — renaming any of them is a breaking change. Deliberately a separate taxonomy fromDEFAULT_TAGSeven where concepts overlap (upscaledvsUpscaledetc.), since one is cast at upload-time from filename parsing and the other is a human's queue-time judgment — kept exact-string-distinct so the two vote populations don't silently merge.external-ip(added 2026-07-28, WTC artist question re-frame) is the stringreason_tags.EXTERNAL_IP_TAG_NAME— a convergence contract, not just a row in the list: the human no-match reason and any machine channel that ever derives external-IP-ness must write oneTag.name, sotag:external-ipis a single predicate over the catalog rather than two names that permanently fragment it. The constant lived inmanagement/commands/import_external_ip_tags.pyuntil that command was retired on 2026-07-29 (see the retirement record below) and moved toreason_tags.pyso the contract outlives the code that used to honour it. Not named after the official "Universes Beyond" Wizards product line: that name covers OFFICIAL Magic printings, so a custom proxy bearing e.g. Warhammer or Lord of the Rings art isn't one of those — it's non-official art drawn from an external IP. Two-axis split (WTC phase B, 2026-07-28): the seven tags above answer two different questions, not one, andNoMatchReasonStrip.tsx's exportedNO_MATCH_REASON_TAG_GROUPS(mirrored inreason_tags.py's docstring so both sides agree without either re-deriving it from the other) is the single source of truth for the split, presented in the UI as two labelled chip groups instead of one flat wall:-
not-official-printing (
altered-frame,upscaled,no-collector-line,non-english) — the artwork is genuine, the physical card is not; the artwork question stays answerable. -
not-official-art (
custom-art,ai-art,external-ip) — the artwork itself isn't from any official card; the artwork question is unanswerable.
Exhaustive over the taxonomy (asserted by a frontend test) so a future tag added here without a matching entry on either side of
NO_MATCH_REASON_TAG_GROUPSfails loudly instead of silently missing from the UI. This split is presentational plus a shared routing constant only — it makes no funnel/selection decision itself; the illustration funnel (WTC phase C) is the intended consumer of it as a routing signal (not-official-printing cards stay in the funnel, not-official-art cards drop out). -
not-official-printing (
-
Tag identity vs. presentation:
Tag.nameis the immutable machine key (votes,Card.tags, filename-bracket matching, federation);Tag.display_name(nullable, additive) is freely-editable presentation text, admin-editable, looked up frontend-wide viafrontend/src/common/tagDisplayNames.ts::useTagDisplayName(). The filename tag-extraction pipeline (cardpicker/tags.py) only ever readsname, neverdisplay_name— presentation changes can never affect ingestion-time matching. -
Deductive backfill:
cardpicker/deductive_backfill.py+manage.py deductive_backfill_printing_tagscastssource=deductionvotes (weightPRINTING_TAG_MACHINE_WEIGHT) for cards whose printing is logically entailed by data already in the catalog — D1 (name matches exactly oneCanonicalCardrow in our catalogue) and D2 (name +Card.expansion_hintnarrows to exactly one row) tiers. Idempotent/resumable (the "no existing vote" eligibility check doubles as the checkpoint).VoteSource.DEDUCTION(pure logical inference) andVoteSource.OCR(Stage 8, image-inspecting) are a label split of what was originally oneVoteSource.AIvalue — same weight/gate treatment for both, seemodels.py'sVoteSourcedocstring. Production run: 28,112 votes written, 0/28,112 later resolved a card on their own (human-backed gate verified at scale).The first tier is not cross-verified against Scryfall, and never was (corrected 2026-07-29). This entry used to say D1 was "cross-verified against Scryfall's own
printings_count". No such verification exists. The column that claim referred to counts our ownCanonicalCardrows (see the Data model entry above), so the condition it feeds restates the name-uniqueness test that immediately precedes it and excludes nothing — measured against the live catalogue on 2026-07-29, 137 D1 candidates before the condition and 137 after, and all 14,893 uniquely-namedCanonicalCardrows in the catalogue carry a count of exactly 1. The condition is left in the code, labelled as entailed rather than deleted, so the gap stays visible; issue #600 tracks what a real external check would be. D1's actual claim is the one it can support: the name matches exactly one row in our catalogue. -
External-IP tag import (Scryfall Tagger) — RETIRED 2026-07-29, together with
PrintingTagVote. Owner ruling: "i am willing to not need the printing tag. i am happy to reduce things to the minimum that gives us our expected results." This entry is the durable record of what was removed, why, and what any rebuild must carry — the code is gone, so this is the only place the knowledge survives. Full evidence: PR #599, whose report lands underdocs/reports/as2026-07-29-printing-vs-illustration-tag-grain.mdwhen it merges (it was still open when this was written, which is why the path is spelled out rather than linked).What was removed.
PrintingTagVote(model + table, migration0101_delete_printingtagvote),POST 2/submitPrintingTagVote/and its URL route, the Django admin registration,manage.py import_external_ip_tags(579 lines) and its tests, and thePrintingTagVotearm ofmanage.py purge_machine_votes(includingPurgeResult.printing_tag_votes_deleted— the purge report enumerates three vote tables now, not four). The last commit carrying the deleted files ise6c6429a; read them withgit show e6c6429a:MPCAutofill/cardpicker/management/commands/import_external_ip_tags.py. Not touched, despite the adjacent names:CardPrintingTag(167,229 rows, the entire Stage D printing channel) andCardTagVote(223,999 rows, resolves intoCard.tags). NorPRINTING_TAG_MIN_VOTES/PRINTING_TAG_IMPLICIT_CAP/PRINTING_TAG_MACHINE_WEIGHT, which are the app-wide consensus weights, nor_split_new_printing_tag_votes, which is aCardPrintingTagcollision guard.Why. Measured against production on 2026-07-29: 0 rows, 0 of them human, 0 ever resolved. There was no consensus resolver — the
printing_tag_consensus.pythat two docstrings forward-referenced never existed on any branch in the repo's history — no reader outside the Django admin, and no frontend caller of the submit endpoint. The importer, its only machine writer, never ran once (PilotRunLedgerhas zero rows for it, and it writes its ledger row before branching on dry-run, so even a dry run would have left a trace). Underneath all of that sits the design principle that makes it unsalvageable rather than merely unused: an imported Scryfall fact is not a disputable claim, so it must not be modelled as a vote.resolve_weighted_consensus'shas_human_backedgate is absolute and independent of the weight sum, so a machine-only channel returnsNoneat any volume (verified by execution at n=1…1000). Automatic Scryfall-derived tagging could never have displayed anything through the vote system, at any grain, at any threshold. That gate is the invariant this project is built on, working correctly; the mistake was routing an indisputable fact through it.What serves the use case instead, today, with no new machinery.
CanonicalPrintingMetadata.promo_typesalready carries Scryfall'suniversesbeyondtoken on 10,407 of 113,224 printings, at 100% per-set recall on every dedicated UB set and correct partial behaviour on mixed sets (sld,clu) — it has been ingested all along and nothing read it for this. 3,450 catalogue images already resolve to a UB printing through the existing ingestion-timeCard.canonical_cardlink. For custom/proxy images — which have no Scryfall printing and noillustration_idat all, so no Scryfall-derived tag can reach them by construction — the channel isCardTagVoteon theexternal-ipTag, which already resolves throughtag_consensusintoCard.tagsand is already Elasticsearch-indexed. Both converge on oneTag.nameby design.The algorithm, for whoever rebuilds it. The reusable core was ~150 lines and is worth restating rather than re-derived. Scryfall's Tagger publishes an
art_tagsbulk entry (scryfall_bulk_data.ART_TAGS, still defined and still tested against the live endpoint although nothing consumes it today); itsjsonl_download_uriis ~12 MB gzipped. Pass 1 indexes every tag row'sid -> (slug, child_ids), finds the tag whose slug isexternal-ip, and BFS-closes itschild_idsto a fixpoint — not the one level the original plan called for, because the hierarchy can deepen; only leaf tags carry taggings, per Scryfall's own documentation, and slugs are explicitly not permanent identifiers, so a missing slug must fail LOUD rather than import zero rows. Pass 2 re-reads the file collectingtaggings[].illustration_idfor tags in the subtree (kept separate from pass 1 so the much larger taggings payload is never held alongside the tag index). Pass 3 buildsillustration_id -> {scryfall card id}from the already-on-diskdefault_cardsbulk data — top-levelillustration_idon single-faced rows, one entry per face undercard_facesfor double-faced rows — and joins straight ontoCanonicalCard.identifier. Measured 2026-07-29 against the live feed: the subtree closes over 2,799 tags / 8,332 distinct illustrations, reaching ~13,166 of our printings — about 2,759 more thanpromo_types, becauseart:external-ipmarks the artwork's IP origin whilepromo_typesmarks Wizards' product line. What that ~2,759 consists of is not established, and characterising a sample of it is the cheapest thing that would decide whether the Tagger dependency is worth carrying at all.What a rebuild must NOT carry. (1) Votes — store it as an imported attribute, readable directly, with no threshold and no resolver. (2) The negative pass: the removed command voted
NOT_APPLICABLEon every confirmed printing outside the positive set, so a--writerun would have inserted ~13kAPPLYplus ~100k negative rows into a table nothing read. An attribute expresses absence by being absent. (3) The vote-system plumbing that went with it —source=DEDUCTION,anonymous_id="scryfall-tagger-v1",run_idstamping,purge_machine_votesintegration — which also correctly drops that identity off the calculator roster, since it was never a calculator. (4)security_stampas a UB signal: issue #437's own Phase-1 research established it is not deterministic (stamp:triangle2,413 vsis:universesbeyond4,415; LotR, Avatar, Final Fantasy and Assassin's Creed carry oval or null stamps). Grain note: UB-ness travels with the artwork — of 50,828 distinct storedillustration_idvalues, 0 appear on both a UB and a non-UB printing, despite 25,381 being reprinted and 20,542 crossing set boundaries — so an illustration-keyed column is the right shape, not a printing-keyed one, and not a vote either way. Owner direction: fold this into the unified Scryfall importer rather than resurrect a standalone command. -
Moderation layer: builds on the same consensus system — see Moderation for the sensitive-tag taxonomy, privileged-approval gate, and its own Reports/Drives review surface.
-
Unified question feed:
GET 2/questionFeed/replaces the old printing/artist/tag/moderation tab switcher with one typed, prioritized stream (confirm_suggestion→ contested pairs →moderation→ fresh unresolved; "dumb ranked union," no cross-tier scoring, with one deliberate exception — the mix-composition policy immediately below). Full rationale injournal/2026-07-14-queue-question-feed-design.md(gitignored, local-only). Known v1 property, not a bug: at current volume a voter only sees tier-1 (confirm_suggestion) questions until all ~28k are exhausted — an interleaved/weighted union is the likely v2 fix, out of scope for v1. Every tier excludes(card, tag)pairs the requestinganonymous_idalready voted on. -
Mix-composition policy (2026-07-24,
cardpicker/question_feed.py, owner-ratified per the WTC vote-queue data brief's OWNER ADDENDUM — that brief was a read-only diagnostic session with no committed doc of its own, so its raw finding isn't independently citable; the durable soundness citation istheory.md§10 "Streaming and continuous operation," which names this exact served-mix/human-vote- quality surface and explicitly invites folding in a mix-logging mechanism once one lands — this policy, and itsQuestionFeedServedLogbelow, is that mechanism, noted in place in §10's own text): the feed serves ≥settings.QUESTION_FEED_LIKELY_RESOLVE_MIX_RATIO(default0.51) of a session's questions from the LIKELY-RESOLVE pool — printing questions one more agreeing human vote would actually resolve under the real resolver — whenever that pool has supply for the requesting voter, falling back to the three-tier ranked union otherwise. Classification (question_feed.is_likely_resolve_printing): finds a card's current highest-weighted printing outcome group, adds one hypotheticalVoteSource.USER-weight vote to that group, and re-runs the realvote_consensus.resolve_weighted_consensus(never a reimplementation of its weight/threshold arithmetic) to check whether that group now wins — the same exact-code simulation approach the data brief used to derive its 46,310-card LIKELY-RESOLVE SUPPLY figure (45,154 single-candidate + 1,156 multi-candidate near-threshold cards). Compute-per-serve, no caching layer:_likely_resolve_printing_cardpre-filters to cards carrying ≥1CardPrintingTagrow (~97k of 218k at the brief's snapshot, not the full unresolved population) then scans indate_createdorder via.iterator()until the first match — the same bounded "scan in priority order, stop at first hit" shape tier 1 already uses, accepted as a v1 cost like tier 1's own starvation-risk property above, not solved with a materialized index. Remainder ordering: within tier 4, cards whose latest Stage D join-key/fallbackCardScanLog.skip_reasonis a "quick-negative" reason (question_feed.QUICK_NEGATIVE_SKIP_REASONS—unknown-set-code/eliminated/border-mismatch/frame-mismatch) are now prioritized (as a secondary tiebreak, after the pre-existing-vote_count"closest to resolving" ordering, never ahead of it) over the harder/open-ended remainder —"ambiguous"is deliberately excluded from that set despite being nominally answerable, since the brief ranks it as blocked on theCardScanLog.survivor_pksgap (see that field's own docstring), not free supply today. Soundness: this is a selection-layer policy only — it makes zero change tovote_consensus.resolve_weighted_consensus's weights,PRINTING_TAG_MIN_VOTES/MIN_SHAREthresholds, or the D1/D4 human-backed-priority mechanisms (see that function's own docstring);is_likely_resolve_printingonly ever calls it, never reimplements it. The brief's own SOUNDNESS NOTE flags a presentation-bias risk this policy doesn't eliminate on its own: serving a mix skewed hard toward machine-agreeing "easy" questions could habituate reflexive confirmation, eroding the vote-weight model's independence assumption even though no vote's weight ever changes — the same failure category (a UI's own suggestion signal contaminating what looks like independent confirmation) the implicit-vote weight's exclusion from suggestedness and IMPLICIT's human-backed exclusion already guard against elsewhere. Mix logging: every served item (either pool) is recorded inQuestionFeedServedLog(anonymous_id,pool,question_type,origin_reason,served_at) — the bias-conditioning record the SOUNDNESS NOTE calls for, so a future audit can correlate click latency/agreement-rate against a session's easy-question exposure;question_feed._served_mix_ratioreads it back as two cheap indexedCOUNTs, never a per-row scan. Append-only, same convention asCardScanLog— never read by any consensus computation. -
"Not sure" abstention (issue #712): Level 1's "Yes"/"No, different printing" both cast a real vote (see
selectCandidate/rejectSuggestioninQuestionFeed.tsx); "Not sure" and "Skip" used to be indistinguishable no-ops — neither wrote anything. They are now split: "Not sure" means the voter engaged and found the image genuinely ambiguous, real information about the CARD, so it POSTs2/submitQuestionAbstention/and is recorded inCardQuestionAbstention(card,anonymous_id,question_type, unique together,get_or_create-idempotent per repeat tap) before the samesetStage("level2")transition it always did. "Skip" carries no signal about the card at all and still writes nothing anywhere — that stays a deliberate no-op, not a bug.CardQuestionAbstentionis the HUMAN counterpart toCardScanLog's MACHINE abstention (see that model's own docstring for why they're separate tables) and is, like it, NOT anAbstractWeightedVotesubclass — an abstention never entersvote_consensus. A future exclusion query (issue #713, not built by this addition) reads it back as a single indexed equality lookup:CardQuestionAbstention.objects.filter(card_id=..., anonymous_id=..., question_type=...).exists(). -
Remaining-work count:
get_remaining_estimate()returnsQuestionFeedCounts(schemas/schemas/QuestionFeedCounts.json), not a single number.totalis a.distinct().count()union across printing/artist/tag categories, bounded by catalogue size - the non-overlapping "cards needing review in any category" figure.confirmable/contested/freshmirror the feed's own three tiers and are independent metrics, not a partition oftotal- a card can count toward more than one bucket (e.g. machine-suggested-but-unconfirmed printing plus a still-fresh artist question). A fresh, untouched card defaults toUNRESOLVEDon bothprinting_tag_statusandartist_vote_statussimultaneously, which is why a flat sum of the three category counts (the pre-fix implementation) over-counted every such card 2-3x.QuestionFeed.tsxsurfaces all three via a single small, muted stats line ("N ready · N in catalog · N contested",question-feed-stats) tucked at the bottom of the question column - a fix round on the quiz-reveal hero (PR #305/#308's owner review) retired the old standalone headline/subcounts text that used to sit above the question and eat into the vertical space needed to keep the answer buttons above the fold.
SUPERSEDED (WTC rebuild, 2026-07-24,
docs/proposals/mockups/wtc-rebuild/SPEC-wtc-rebuild.md,
owner rulings on that spec's three open questions — see PR #446) — /whatsthat's
visual/layout tree was rebuilt from scratch onto the
Tokyo-11 theme's ruled token layer (page-private --wtc-* tokens derived from --accent/
--body/--conf, defined in whatsthat.tsx's WtcTokenScope), replacing the bespoke gold/
navy/starburst-blue/deep-blue-field identity the many "quiz-reveal hero" (issue #305) bullets
below describe. The old dual-layout mechanism (HeroGrid's 768px grid-template-areas swap,
MobileButtonRow/MobileCandidateScroller/MobileChipRow's horizontal scrollers,
Level2NarrowGrid's narrow-only 2x2 action grid, WideWordmark/NarrowWordmark's CSS-display
fork) is retired in favour of ONE @container-driven hero (WtcHero/Subject/QPanel in
QuestionFeed.tsx) that folds continuously via flex-wrap + clamp() + auto-fill/auto-fit
grids — no viewport breakpoint drives sizing (container-first policy, WTC = first consumer).
Also retired: BurstSvg/HoverBurst/useStarburstFrame (the starburst animation, owner
ruling — the token-derived --wtc-reveal-glow field glow replaces it; reveal reads through the
mystery-card flip only), CardPulseWrapper/the sliced WHAT'S/THAT/CARD? pop sequence
(WhatsThatWords.tsx is now a plain, static, single-tree <h1>), the PageColumn
100dvh-bounded hero + "portrait static top block" hack (the page is an ordinary scrolling
document now). Added: the quiet "N tagged this session" affordance (the only reward surface —
no streak/score/confetti) and the seven question-shapes-as-visually-distinct-modes framing
(confirm/shortlist/quick-negative/open-ended/artist/tag/follow-up) SPEC-wtc-rebuild.md
section 2 defines. The interaction contract (Level 1/2/3 flows, getAutoTagChips, no-re-
presentation, the singleton-NO terminal vote, per-item state reset, the rate-limit banner) is
unchanged — every bullet below describing THAT contract (not the retired visual mechanism) is
still accurate. The detailed "quiz-reveal hero"/starburst/gold-button narrative below is kept
for history (this doc's own established convention — see the cardPanel.tsx bullet's own prior
"SUPERSEDED" marker two bullets down) but no longer describes the current rendering; read it as
"how we got here," not "what's live."
-
frontend/src/pages/whatsthat.tsx(renamed fromprintingQueue.tsx) +QuestionFeed.tsxrender the single unified feed; the old standalonePrintingTagQueue.tsx/GenericVoteQueue.tsx/ModerationQueue.tsxtab switcher was deleted, its mechanics extracted intocardPanel.tsxand reused directly. -
Per-question local state (
chipStates/revealed/filterExpanded/etc) resets inside the same.then()assetItem(...), not via a separateuseEffectkeyed on[item?.card.identifier, item?.type]— that dependency array silently skips the reset whenever two consecutive feed items share both values (the same card can carry more than one pending question, or the same question can be re-served), leaving a chip left "positive" from the previous card filtering the new card's candidate grid against an unrelated attribute — observed as the grid rendering empty until the user happened to touch a chip themselves (the only other thing that ever updatedchipStates). Resetting unconditionally alongsidesetItemremoves the dependency array (and the class of bug it enables) entirely, rather than trying to compute a "definitely always changes" key. -
starburstShape.ts— seeded PRNG (mulberry32) generates the animated starburst background, 5 precomputed frames per layer; skipped underprefers-reduced-motion(checked once viamatchMedia). -
cardPanel.tsx— SUPERSEDED (quiz-reveal hero redesign, issue #305, see the dedicated bullet below):CardPanel/StaticCardPanelare now both a plainposition: relative; z-index: 0;at every width — theposition: sticky-at-md-and-up-only mechanism this bullet used to describe (via auseStickyTophook, since removed as dead code) is gone. The redesign'sHeroGridbounds the whole hero to one viewport- height row atmd+ instead, so the card's own grid cell never scrolls in the first place and sticky has nothing left to do; belowmd,HeroCardArea(QuestionFeed.tsx) applies its own compactposition: stickybar directly, notCardPanel. Thez-index: 0(not left atauto) still matters unchanged from before — it's what givesCardPanel/StaticCardPaneltheir own local stacking context, soBurstSvg'sz-index: -1stays contained to just this panel instead of escaping to the nearest ancestor stacking context and painting behind unrelated page content. -
Chip-ring era layout reconciliation:
AttributeChipPanel.tsx'sChipRing(the PR #21-era 3-column ring — chips flanking the card left/ right, reachable via Level 2's opt-in "Filter by attribute" disclosure) had no responsive behavior at all — its flanking columns were alwaysauto-sized to their own chip content while the card's own column was the only flexible one, so at narrow widths the card was squeezed narrower to make room for the chip columns rather than the chips reflowing. Belowsm(576px) the ring now collapses to a single vertical stack (top chips, then the card at its own full natural width, then the two exclusion groups below it as ordinary flowing rows) instead of forming a ring at all — the ring visual survives only at widths wide enough to contain it without squeezing the card. -
frontend/src/features/attributeChips/— tri-state chips (untouched → positive → negative), fill color renders weighted net polarity (tag_consensus.get_tag_net_polarity). Two exclusion groups (Border Color, Frame Style) are frontend-only styling/filtering concerns — the vote write path treats every chip independently. Picking a printing candidate auto-casts one positive vote per attribute the candidate itself derives — every standalone boolean, plus whichever exclusion-group chip actually matches (getAutoTagChips; PrintingCandidate carriesborderColor/framedirectly, so a group value is exactly as derivable as the standalone ones).getOpenExclusionGroupsflags the rarer case where a candidate's own value falls outside the taxonomy entirely (e.g.borderColor: "borderless"isn't Black/White/Silver) — that's what gates Level 3 below. -
frontend/src/common/tagDisplayNames.ts— sharedname -> displayNamelookup, built off the already-cached tags query (no new fetch per consumer). -
Candidate buttons carry the card DOM API's data attributes — see Card-DOM-API.
-
Confirmation-flow UX pass (frontend-polish package, PR-A, 2026-07-17), presentation/interaction only — none of it touches
question_feed.pyor tier ordering:- A genuine
GET 2/questionFeed/fetch failure now renders a distinctquestion-feed-errorstate (a "something went wrong" message plus a retry button) instead of being folded into the samecaughtUpstate as a real empty queue — the old code made a backend outage look exactly like "you've finished," so a user could walk away thinking there was nothing left to do. - The candidate-type layout (
confirm_suggestion/identify_printing) put the card being asked about first in DOM order via Bootstrap'sorder/order-md-*utilities — atmdand up this was a no-op (same candidates-left/card-right arrangement as before), but at mobile widths, where the two columns stacked, the mystery card used to render after every answer option, so a voter had to scroll past all the candidates before seeing what they were even being asked about. SUPERSEDED by the quiz-reveal hero redesign (issue #305, below): theorder/order-md-*utilities and the two-Collayout they applied to are gone, replaced byHeroGrid's grid-area map — the card-before-questions guarantee on mobile is now a property of the grid areas themselves ("words" / "card" / "questions" top-to-bottom), not a Bootstrap order utility. - Tapping a candidate now shows a small spinner on that specific candidate's own art box while the vote submits, instead of uniformly dimming every button — previously there was no way to tell, under any real latency, whether the button you tapped actually registered.
- Candidate-type items now show a small badge ("Suggested match" for
confirm_suggestion, "Needs identification" foridentify_printing) above the candidate grid.
- A genuine
-
Mobile funnel redesign (frontend-polish package, PR-E follow-on), again presentation/interaction only —
question_feed.py/tier ordering untouched. Real-device evidence (3 screenshots,proxyprints.ca/whatsthat) found the old always-on chip ring wedging the ~90px card thumbnail between two flanking chip columns and burying it below a full screen of chips before the question was even visible; see the held funnel-proposal artifact (linked from PR #47's body) for the full findings/mocks this implements.QuestionFeed.tsx's candidate branch now runs three stages instead of one grid screen:-
Level 1 —
confirm_suggestionitems with asuggestedPrintingland here: the suggested printing alone, YES / NOT SURE / NO / SKIP, no grid. YES casts the same vote Level 2's tap does; NOT SURE and NO both drop to Level 2 with no vote cast (per the state diagram, they're intentionally identical transitions — "an honest skip beats a coerced guess"), but NO additionally records the rejected candidate's identifier client-side (rejectedCandidateIds— never NOT SURE, which is genuine uncertainty, not a rejection) so Level 2 excludes it — see the no-re-presentation rule below.identify_printingitems (andconfirm_suggestionitems without asuggestedPrinting) skip Level 1 entirely. -
Level 2 — the candidate grid. The attribute-chip ring is now an opt-in, collapsed-by-default "Filter by attribute" disclosure instead of always-on chrome — selecting a candidate ignores filter state entirely (filters are navigation, never votes). Two classified exits sit below the grid: "None of these" (unchanged, still followed by
NoMatchReasonStrip) and "Art matches, not an official printing" (one tap: anisNoMatchprinting vote plus a positivecustom-arttag vote, reusingreason_tags.py's existing seeded tag — no reason strip, since the tap already said why). The old gate that disabled "No match" until a chip was explicitly set is gone — it existed only to force a description before a now-superseded flow, and directly conflicted with the filter panel defaulting to collapsed. -
Illustration grouping (issue #503, WTC phase C1): the Level 2 grid now clusters candidates that share a Scryfall illustration — printings that are visually near-identical, so asking a voter to pick between them in a flat grid mostly produced a guess.
PrintingCandidategained an optional, nullableillustrationId(backend:CanonicalCard.serialise_as_printing_candidate, sourced fromCanonicalPrintingMetadata.illustration_id);QuestionFeed.tsxgroupsvisibleCandidatesby that field into>= 2-member clusters, each rendered as its own labelledCandidateGridinside anIllustrationGroupwrapper (data-testid="question-feed-illustration-group").illustration_idis nullable and frequently absent (seelocal_illustration.py'sillustration_id__isnull=Falsefilter) — a candidate with no illustration, or a candidate whose illustration has no other member, is never dropped: it renders in a flat "ungrouped"CandidateGridbelow the clusters, visually identical to Level 2's pre-grouping grid. Ungrouped candidates still callselectCandidatewith that exactPrintingCandidatethrough the unchanged/2/submitPrintingTag/path. Clustered tiles render the candidate'sartCropUrl(PrintingCandidate.artCropUrl, added for this purpose —schema_types.py/schema_types.ts, sourced fromCanonicalPrintingMetadata.art_crop_url) in place ofmediumThumbnailUrl, falling back to the printing scan when a candidate's metadata sidecar has no crop — since the vote this cluster casts is illustration-level, the border/frame/language a full scan shows is information the vote itself doesn't record. Ungrouped tiles keep the full scan unconditionally: identifying one specific printing (the voteselectCandidatecasts) does need that detail. -
Illustration voting (issue #503, WTC phase C2;
CardIllustrationVoteitself is #524/#531): tapping a tile inside an illustration cluster now callsselectIllustrationGroupinstead ofselectCandidate— it sends ONEillustrationId(never a printing list) to the newPOST /2/submitIllustrationVote/endpoint (views.post_submit_illustration_vote,cardpicker/illustration_vote.py'scast_illustration_vote). That one call performs up to three writes in a singletransaction.atomic()block:CardIllustrationVoteis always written (update_or_create(card=, anonymous_id=), since that model's(card, anonymous_id)uniqueness is unconditional — see the model's own docstring, issue #525);CardPrintingTagis written only when the illustration resolves, against LIVE data at write time, to exactly one candidate printing for this card (printings_for_card_and_illustration— N>1 casts nothing on the printing channel, matching #526's machine-side rule;question_feed._voter_answered_printing_card_idsreadsCardIllustrationVoteas well asCardPrintingTagso an N>1 answer still excludes the card from the printing tiers, issue #713); and aCardArtistVoteis derived (source=USER,vote_surface="illustration_vote_derived_artist") whenever the resolved artist's name doesn't indicate a combined credit (tests for' & 'only — see the module's own census comment) and noCardArtistVotealready exists for(card, anonymous_id)— an existing vote, explicit or previously derived, is left untouched (the "never override" precedence rule; seeillustration_vote.py's docstring for the full TOCTOU/stale-snapshot rationale and its relation to issue #483). Because the endpoint never reveals which single printing (if any) was resolved,selectIllustrationGroupskips the auto-tag-chip/Level 3 attribute-exclusion flow and advances straight to the next item. Request/response types (SubmitIllustrationVoteRequest/Response) are hand-integrated into bothschema_types.py/schema_types.tsfrom real JSON schema sources inschemas/schemas/endpoints/— the generator itself was NOT run (see issue #332: it destroys the hand-addedCastImplicitVoteRequest/RetractImplicitVoteRequesttypes). -
No-re-presentation rule (owner-directed fix, was a real live bug: Level 1 "Is it M21 203?" → NO → Level 2 grid containing only M21 203 again): within a single question item's flow, a candidate the user has just rejected is never re-presented as a selectable answer at a later level — each level's display set is candidates minus already-rejected-this-item. Level 2's grid is computed from
nonRejectedCandidates(all candidates minusrejectedCandidateIds, filtered before the attribute-chip filter, so "N hidden by your tags" doesn't conflate a rejection with a filter), and the singleton case — rejecting the one and only candidate, or a rejection that happens to empty the remaining set — skips the grid entirely: the prompt swaps to a contextual "Got it — not that one. Is it any official printing at all?" with the rejected candidate shown only as grayed, non-interactive context (never a button), falling straight through to the same classified-exit choice (None of these / custom art / skip) that always rendered below the grid.rejectedCandidateIdsis per-item state, reset alongside every other per-question field in the same fetch effect (see the module's own comment on why that reset can't be a separate dependency-keyed effect).Singleton "No" now casts the terminal vote immediately (owner- reported "dedup doesn't work" bug, fixed after this bullet originally shipped): the vote-semantics claim this bullet used to make — "Level 1's NO never casts a vote, only
rejectSuggestionchanges what's displayed" — was itself the bug for the singleton case. When rejecting the suggestion leaves zero remaining candidates, there is nothing else this "No" could mean, sorejectSuggestionnow calls the sameselectCandidate(undefined, true)"None of these" itself calls, casting theisNoMatchvote at the moment "No" is tapped rather than waiting for a further explicit tap on the classified-exit screen. Before this fix, a voter who read "No" as final (or tapped the generic "Skip" on that follow-up screen instead of "None of these") never produced aCardPrintingTagrow at all —question_feed.py's tier-1 exclusion (.exclude(printing_tags__anonymous_id=...)) is and always was correct (seetest_tier_1_excludes_cards_this_voter_already_voted_on/test_tier_1_excludes_a_confirm_suggestion_card_after_a_no_match_voteintest_question_feed.py), it simply had no vote to exclude on, so the identicalconfirm_suggestionquestion resurfaced on the voter's next feed fetch. The multi-candidate case is unaffected: rejecting one of several candidates still only changes what's displayed, same as before — the one real negative vote for those items still only happens at the eventual "None of these"/custom-art/skip tap. Audited as clean for the same re-presentation pattern: Level 0 below (NO opens the deckbuilder's general grid-selector browse UI, a different paradigm from a guided funnel step — showing the current image among many search results there is normal browse behavior, not a re-ask) and Level 3 (only ever asks about attribute groupsgetOpenExclusionGroupsfinds genuinely open on the selected candidate — inherently already excludes anything already answered). -
Level 3 — conditional, not a standard stage. Selecting a candidate auto-tags everything derivable from it (see above); Level 3 only renders when
getOpenExclusionGroupsfinds a genuinely open group, presenting just that group as a real single-select lock (picking one deselects its alternates) — distinct from Level 2's filter panel, which keeps the usual independent tri-state cycling. With the current taxonomy this is a real but infrequent case (an out-of-taxonomyborderColor/framevalue), not a hypothetical one — seeprintingCandidate2's test fixture (borderColor: "borderless").
-
-
Known schema gap surfaced by the badge above, not fixed here: tier 2 (contested) and tier 4 (fresh) —
_tier_2_contested/_tier_4_freshinquestion_feed.py— both call_identify_printing_item, producing the exact sametype: "identify_printing"payload shape with no field distinguishing one from the other. The frontend badge above can only tell confirmable apart from everything else; a true three-way confirmable/contested/fresh split would need a new field onQuestionFeedItem(a schema +question_feed.pychange), which is outside this pass's presentation-only scope. -
Level 0 — in-context deckbuilder confirmation (frontend-polish package, second funnel PR), a different surface entirely from the
/whatsthatfunnel above:frontend/src/features/card/ DeckbuilderConfirmAffordance.tsx, mounted fromCardSlot.tsxright after the card image. Shows a small, inert badge under a slot whose search query names a specific printing (expansionCode/collectorNumber) that isn't yet the human-resolved consensus for the currently selected image — the gate reusesgetPrintingMatchLabel's own logic, inverted, rather than a new condition. Hover (desktop) or tap (touch, since click fires on both) pins the reference printing's thumbnail near the badge and enables Y/N, which stay disabled until that compare has fired once (misclick protection for what's otherwise a one-tap vote). YES casts a realsubmitPrintingTagvote for the imported printing; NO opens the slot's existingshowGridSelector/setShowGridSelectorstate (no new plumbing) with no vote cast —CardPrintingTag(models.py) has no schema concept of "this specific candidate is wrong," only a positive vote for one printing or a globalis_no_match=True("no known printing matches this card at all"), and the latter would misrepresent what NO actually means here. Density (v1): no cap on how many slots show the badge at once, but each is genuinely inert until touched, and once explicitly resolved (YES or NO) never reappears for that specific image this session — tracked in a module-level, non-persistedSet<identifier>, not Redux, since it only needs to survive this browser session, not a reload. No banners, no counters, no review mode. -
Item (c) — requested-printing badge on editor slots (frontend- polish package), a sibling of Level 0 above but a distinct concept: where Level 0 shows the resolved canonical printing (confirm/deny what indexing found), this badge shows the requested printing — the expansion code + collector number the slot's own search query actually asked for (e.g.
MID 245), which otherwise gets visually "sanitized away" the moment an image is selected, with no at-a-glance way to tell which specific printing was requested short of opening the change-query modal. Not the resolvedcanonicalCard, and not printing-tag consensus status. Originally built inline for Proposal H Step 2 PR 2b's/displayrail header; extracted into its ownfrontend/src/features/card/RequestedPrintingBadge.tsxcomponent so both surfaces — the/displayrail header andCardSlot.tsx's own editor slots — mount the exact same badge, one place the degraded- style logic lives so the two can't drift apart. Gate: the slot's query names a specific printing (searchQuery.expansionCode != null) — always-visible when that's true (no hover gating; the point is at-a-glance), nothing when it isn't. Style: normal (bg-secondary) when resolved cleanly, degraded (bg-warning, warning-triangle icon, hover tooltip) when the backend'sEditorSearchResponse.degradedQueriesflagged this exact query — i.e. the printing-specific search found nothing and the backend retried unfiltered, so what's shown is the closest available match, not a guaranteed exact hit (seeselectIsSearchQueryDegradedinsearchResultsSlice.ts). Unlike Level 0 (gated on a selected image existing to compare against), this badge is independent of whether an image has been selected yet — it's about what was asked for, not what's currently shown. -
Post-export contribution prompt (issue #166, Proposal H milestone) — another entry point INTO this same
/whatsthatfunnel, not a parallel one: a small, dismissibleAlertshown once per session immediately after a genuinely successful PDF export (either "Generate PDF" or "Save PDF to Google Drive"), linking straight to/whatsthatvia the exact same routeNavbar.tsx/HomepagePanel.tsxalready use. Mounted fromPDFGenerator.tsxitself (so the classic "Print!" tab / standalonepages/print.tsx(issue #275) /PDFGeneratorModal.tsx/ProjectEditor.tsxmounts all get it, since they render the same component) — oneusePostExportContributionPrompt/PostExportContributionPrompt.tsxpair (frontend/src/features/export/), not two copies. This used to ALSO be mounted fromDisplayPage.tsx's own inline export (Proposal H, item 2) — issue #275 retired that pipeline entirely, so PDF generation (and this prompt) now lives solely on the Print page, reached from/display's Finish footer via a pre-print save gate. "Never repeats within a session" (this is the "separate post-export contribution toast, task #31" the unified-display-page proposal's own §4.4′ footnote references) — asessionStorageflag set the moment the prompt is shown, mirroringchunkErrorRecovery.ts's existing session-scoped-flag precedent; deliberately notlocalStorage, since this must not survive a "clear site data"/incognito test. Does not fire when an export is cancelled (e.g. the image-fetch-failure confirm modal's Cancel path) — seepostExportContributionPrompt.ts'swasMostRecentCardsPdfDownloadSuccessfulfor the success-detection logic (the download path reads thefileDownloadsredux slice the download manager already populates, sinceuseDownloadPDF's own return value is swallowed tovoidelsewhere; the Save-to-Drive path readsuseSaveToDrivePDF's resolved promise value directly). No backend change, no new API data. -
Vote provenance (
voteSurface):AbstractWeightedVote.vote_surface(backend PR #48, nullable additive field, already onSubmitPrintingTagRequest/SubmitArtistVoteRequest/SubmitTagVoteRequestinschema_types.ts) is now sent by every vote call in the/whatsthatfunnel ("question-feed") and by Level 0's own vote ("deckbuilder").ArtistVotePicker.tsx— shared between the funnel and every mount ofAttributeVotingPanel(the card-detail-modal's,CardDetailedViewBody.tsx, and — since the rail-delegacy round, PR #413, 2026-07-24 — the/editorrail's own D14-anchoredIdentifyPanel, which wrapsPrintingTagPicker+ a conditionalAttributeVotingPanelverbatim, opened on demand rather than living in its own grey rail accordion; seegrid-selector.md's RD1/RD4 note for the sibling change to the funnel chips this same round shipped) — takes an optionalvoteSurfaceprop rather than hardcoding it, same pattern as its existingonRateLimitedprop: the funnel passes"question-feed", everyAttributeVotingPanelmount passes nothing (unchanged). Every other voting surface (PrintingTagPicker.tsx,TagVotePicker.tsx,ReportsPanel.tsx) is untouched —voteSurfacestaysundefinedthere, not a guessed value. A fourth value,"select-version", was added (issue #167, the unified display page's Select Version section — seedocs/features/grid-selector.md) for the confirm chip a user can tap while browsing/filtering that section's candidate grid — see that doc's own entry for the exact gating logic. -
Branding integration (frontend-polish package). PARTIALLY SUPERSEDED by the quiz-reveal hero redesign (issue #305, below): the visible page title is no longer the
whatsthat-composite.svgmark+ wordmark lockup image — it'sWhatsThatWords, three cropped<svg>slices ofwhatsthat-wordmark.svgalone (see the redesign bullet for why).whatsthat-composite.svgitself is unused now but still checked in (not deleted — no cleanup pass has touched it); a real, visually- hidden<h1>What's That Card?</h1>(whatsthat.tsx'sVisuallyHiddenHeading) replaces it for the page's semantic heading/ accessible name, the same guarantee the old wrapped-<h1>lockup gave. Source assets (question-mark.svg/mark,wordmark.svg/word,composite.svg/lockup — gradient idswtc-grad-mark/wtc-grad-word/wtc-grad-comppre-namespaced so more than one could coexist on a page without id collision) came from theassets/whatsthat-brandingbranch, copied intofrontend/public/aswhatsthat-{mark,wordmark,composite}.svg(renamed from the generic source names to avoid colliding with this repo's existing flatpublic/*.svgnamespace — seeflags.tsx's identical<img src="/...">pattern).whatsthat-mark.svgis used by the PWA icons bullet below;whatsthat-wordmark.svgis now inlined (not<img src>'d) byWhatsThatWords.tsxspecifically so each of its three instances can set its ownviewBoxcrop — an externally-loaded SVG document's viewBox can't be retargeted from the referencing<img>. -
Mobile funnel pass: thumb-native tap targets (frontend-polish package). Audited via real Playwright screenshots +
boundingBox()measurements at 390px, not assumption: Bootstrap's own default.btnheight (~38px, measured) and the attribute chips' original padding (~30px, measured) both fell short of the 44px minimum both Apple's HIG and WCAG 2.5.5 (Target Size, AA) call for — the "Filter by attribute" toggle was worse still (variant="link" className="p-0", ~26px, sincep-0zeroes Bootstrap's own padding entirely). Fixed via two newQuestionFeed.tsxstyled wrappers around react-bootstrap'sButton—ThumbButton(min-height: 44px, flex-centered, for every stacked full-width action across Level 1/2/3: YES/NOT SURE/NO/SKIP, None of these/Art matches/Skip, Confirm & continue/Skip, plus the artist question's Skip and the fetch-error retry) andFilterToggleButton(same floor,padding: 0.5rem 0restoring a real hit area without looking like a filled button — its ownp-0had to be removed, not just supplemented, since Bootstrap's utility class carries!importantand would otherwise silently win over any styled-component override) — and one change to the sharedChipstyled-button (min-height/min-width: 44px, flex-centered — originally defined inAttributeChipPanel.tsx, later extracted intoattributeChipRender.tsxalongside the chip-render/vote-submission logic so the display page's rail Attributes section could reuse the exact same chip, per the left-panel-unification pass — seedocs/proposals/proposal-h-unified-display-page.md(historical doc — see its own banner)). Level 3's Confirm/Skip pair also gainedflex-column flex-sm-row(previously always side-by-side, squeezed to half-width each on a phone) to match the stacking-to-full-width pattern every other level already used. Regression coverage:tests/QuestionFeedResponsive.spec.ts(formerlyQuestionFeedTapTargets.spec.ts) asserts real measured heights (not just that a CSS rule exists) at 390px. Chip-ring reflow itself (the single-column stack belowsm- seeAttributeChipPanel.tsx's own "MOBILE OVERRIDE" comment) was audited and found already correct; not touched. -
/whatsthatPWA installability (frontend-polish package). A manifest + icon set scoped to/whatsthatalone —start_url/scopeboth"/whatsthat"infrontend/public/whatsthat-manifest.json— not a site-wide manifest on_document.tsx: the game is the installable "app" here, not the whole catalog/editor. Linked fromwhatsthat.tsx's ownnext/head<Head>(<link rel="manifest">, atheme-colormeta, and anapple-touch-icon) — underoutput: "export"'s per-page static HTML, that<Head>content lands only inwhatsthat.html's own generated markup, confirmed viatests/WhatsThatPWA.spec.ts's negative assertion on a different page. Icons (whatsthat-icon-192.png/-512.png, Chrome's own minimum installability sizes) rasterized fromwhatsthat-mark.svg(the branding integration's source asset) centered on the page's own background color via a one-off Playwright screenshot script - not a new build-time asset pipeline, the PNGs are checked in directly like the SVGs themselves. No service worker/offline caching added - explicitly out of scope per the owner's brief ("no offline scope beyond the default"); Chrome's own install-prompt heuristics may want one for the full native "Add to Home Screen" banner in the strictest case, a gap noted here rather than silently built around. Retinted (issue #305/W6):theme-color/whatsthat-manifest.json'sbackground_color/theme_colorand both icon PNGs moved from the retired#ff4719orange to#123a6b(HERO_FIELD_BLUE_DEEP,whatsthat.tsx) — the mark itself (the "?" glyph) is unchanged, only its background swapped, rasterized the same way as the original pass (a one-off, not-committed Playwright screenshot script). -
Quiz-reveal hero redesign (issue #305, 2026-07-21, designer round
wtc-redesign-spec.md+wtc-mockup.html, owner-approved before implementation). Flips the page's axis: the subject card + a large blue/white starburst are now the left hero column; every question surface (L1 confirm, L2 grid, L3 attributes, artist, tags, rate-limit banner, moderator switch) renders to the right — reversing the pre-#305 candidates-left/card-right arrangement (see the two "SUPERSEDED" notes above). Every question-render unit itself is reused unforked, just repositioned.-
Layout:
QuestionFeed.tsx'sHeroGridis a single CSSgrid-template-areasmap (not nestedRow/Col) with three named areas —card(spans two rows on the left atmd+),words, andquestions— collapsing to awords/card/questionstop-to- bottom stack belowmd. One markup tree drives both breakpoints, so the card is reachable near the top of a phone screen without a separate re-nested layout (the first design pass's own caught-and- fixed bug: nesting words+questions in one column with the card as the other buried the card below every question on phone). -
Sliced words:
WhatsThatWords.tsxinlineswhatsthat-wordmark.svg's path data (not<img src>, so each instance can set its ownviewBox) and renders three crops — WHAT'S (-40 150 1030 340), THAT (990 150 695 340), CARD? (1685 150 905 340) — measured off the wordmark's own letter-start x-coordinates. Each word pops (scale 1 → 1.34 → 1, 480mscubic-bezier(0.34,1.45,0.64,1),bothfill) staggered 0/240/480ms for a continuous left-to-right ripple, re-armed by keying the whole words container on the current item's card identifier (a React remount, not a class toggle) so every new card replays the sequence from WHAT'S.prefers-reduced-motion: reducedisables the pop entirely (words render statically at rest size/tilt) via the same CSS custom-property-driven rest transform the animation itself uses, so there's no flash-of-unanimated-content either way. Real pitfall hit and fixed: a root-level<svg>(unlike one nested inside another<svg>) defaults tooverflow: visibleper spec, and aflex-direction: columnparent's defaultalign-items: stretchoverrides a replaced element's own intrinsic (aspect-ratio-derived) width — either alone silently broke theviewBoxcrop, each word rendering the entire wordmark instead of its own band. Fixed with an explicitoverflow: hiddenon theWordstyled-component plusalign-items: flex-starton its flex parent (verified via isolated Playwright reproductions before landing, not just visual inspection). -
Hero card pulse (owner addendum, not in the original spec): the
reference card itself pulses in lockstep with the THAT word — same
easing/duration/240ms delay as
wtcWordPop, but a separate, much smaller-amplitude keyframe (wtcCardPulse, scale 1 → 1.1 → 1 vs. the word's 1.34 peak — a full-size card "breathing" at the word's peak would read as violent, not playful) onCardPulseWrapper(cardPanel.tsx), wrapping whichever card node is active for every item type uniformly (not stage-special-cased) and re-armed the same way as the words (keyed on the card identifier). Also disabled underprefers-reduced-motion. -
Palette (W6/W7): the page's old
#ff4719orange full-bleed field — kept as "the page's deliberate identity" by a 2026-07-18 decision — is superseded:StarburstBackground(whatsthat.tsx) is now a deep-blue field, reconciling with issue #302's sitewide orange retheme instead of clashing with it (two similar oranges were less distinguishable than the old blue-on-orange pairing this page shipped with originally). The page-scopedACCENT_NAVYoverride (buttons/ links/pills recolored so they'd clear AA against the orange field) is removed entirely — off that field and onto the standard dark body, the sitewide accent#df6919already clears AA (4.61:1) with no override needed. The starburst's own two identity colors (outer blue#4d8ddf, inner white#ffffff,starburstShape.ts) are unchanged — only the field behind it moved. A fix round on the owner's live review (PR #305/#308) flattened the original three-stop radial (#1a4f8a→#123a6b→ body#0f2537, a pronounced vignette that "felt unnatural") to a two-stop#1d4d82→#123a6bgradient with no third, darker stop at all — a small highlight around the starburst that settles into a flat deep blue well before the edges, instead of fading further toward near-black. -
Enlarged hero starburst:
BurstSvg(cardPanel.tsx) gained an additive, default-off$heroprop (width: 230%of the card box vs. the existing55%, atmd+) so it dominates the hero's left column per the reference aesthetic — deliberately sized past the card's own box (bleeding into the hero field or partway behind the words/ questions columns is on-aesthetic;CardPanel's ownz-index: 0stacking context, see above, keeps it from ever painting over the neighbouring columns' actual text).-
Fix round (owner live-review, "no starburst visible on mobile
portrait"): the mobile value below
mdused to be90%— a literal CSS width SMALLER than the card's own box (100% of the sameCardPanel), whichaspect-ratio: 1makes a perfect square besides. Centered on a full-width, taller-than-wide portrait card, a burst narrower AND shallower than that box is fully eclipsed by the (opaque) card art in every direction, by construction — no value<= 100%can ever be visible here, confirmed live via a real Pixel 7 portrait screenshot +getBoundingClientRect()diff (the burst rendered atopacity: 1/width~108px, sitting entirely inside the card's own ~120px box).90%read as a scale-DOWN-from-desktop INTENT ("back off to a modest size on phone") rather than a literal container-width instruction that was ever checked against actually being bigger than the card — raised to200%(230% * ~0.9, rounded — the same "back off a bit from desktop" ratio, just applied to a value that's actually large enough to bleed past the card's own edges like every other breakpoint here already does). Belowmdthe card now sits in its own compact grid column beside the questions (see the pinning bullet below), not overlaid on top of them, so a bigger bleed lands in the row's own gap/blue field rather than on scrolled- under text — the exact risk the old90%was guarding against no longer applies the way it used to.
-
Fix round (owner live-review, "no starburst visible on mobile
portrait"): the mobile value below
-
Reference-card pinning (owner addendum): the card must stay fully
visible while the user works through the questions. At
md+, the wholeHeroGridis bounded to one viewport-height row and onlyHeroQuestionsAreascrolls internally (overflow-y: auto, a subtle themed scrollbar viascrollbar-color/::-webkit-scrollbar-thumb, not default browser chrome) — the card's own grid cell never scrolls, so the old sticky-plus-negative-z-index mechanism (see the supersededcardPanel.tsxbullet above) has nothing left to do and was removed. Belowmd,HeroCardArearenders ABOVEHeroQuestionsAreain a single-column stack (HeroGrid'sgrid-template-areas: "words" "card" "questions") — see the two SUPERSEDED bullets and the "portrait static top block" bullet immediately below them for the two prior mobile designs this one replaced, and why. Verified viaQuestionFeedResponsive.spec.ts's scroll-then-reread-boundingBox()assertion (full equality, not just visibility) on desktop.-
SUPERSEDED mobile design #1 (owner live-review, "the card covers
the questions on scroll"): below
mdoriginally collapsed to a SINGLE column ("words" "card" "questions"stacked top-to-bottom), withHeroCardAreabecoming aposition: sticky; z-index: 5compact bar (shrunk viamax-width: 7.5rem) riding on top ofHeroQuestionsAreaas the page scrolled — the phone-shaped interpretation of "keep the reference comparable" the original redesign shipped with. This shared the same horizontal space as the questions by construction, so the sticky card was always going to end up geometrically nested inside the questions box's own bounds once scrolled — confirmed live via a real Pixel 7 portrait screenshot +getBoundingClientRect()diff (post-scroll, the card's box[146, 50, w120, h236]sat fully inside the questions box's own[24, -366, w364, h1376]), not a rare edge case. Replaced with SUPERSEDED mobile design #2's disjoint-grid-column layout below (owner's own live-review layout proposal) — the card and questions structurally cannot overlap regardless of scroll position or either one's own height, mirroring the invariantmd+ already had via its bounded one-row hero. -
SUPERSEDED mobile design #2 (owner live-review, "wastes space and
clips question text behind the card"): replaced design #1 above
with
HeroCardAreasitting BESIDEHeroQuestionsAreain its own compact grid COLUMN (minmax(0, 7.5rem) minmax(0, 1fr)) instead of stacking, with Level 1's full-width buttons/Level 2's candidate grid/Level 3's exclusion chips each rendering in a single horizontally-scrollable row beside the card (MobileButtonRow/MobileCandidateScroller/MobileChipRow,QuestionFeed.tsx) — genuinely fixed the overlap bug design #1 had (card/questions structurally can't share the same space in a column split), but a FOLLOW-UP owner review of that fix found the ~7.5rem card column left most of a phone screen's width empty below the tiny card, and squeezing the question prompt/badge into the narrow remaining column let it clip behind the card at some viewport widths. See "portrait static top block" immediately below for the design that replaced this one.MobileButtonRow/MobileCandidateScroller/MobileChipRowthemselves survive into the current design (unchanged mechanism, just no longer sitting beside the card) — onlyHeroCardArea's own beside-vs-above relationship to the questions column changed. -
Portrait static top block (owner live-review, current design):
below
md, the reference card is height-capped (CardPanel/StaticCardPanelincardPanel.tsx,max-width: min(100%, calc(32vh * 63 / 88))— a max-WIDTH expressed in terms of a target HEIGHT, so the existing width-driven<img>sizing —width: 100%; aspect-ratio: 63 / 88— doesn't need to change at all) and renders as a STATIC (non-scrolling) block together with its name, the "Suggested match"/"Needs identification" badge, the question prompt, and a compact 2×2 grid of the Level 2 action buttons (Filter by attribute / None of these / Art matches / Skip) — all reachable with zero scrolling, one tap. Only the candidate grid below that block scrolls (horizontally,MobileCandidateScroller, unchanged mechanism from design #2). Implementation (QuestionFeed.tsx):-
Level2NarrowGrid— Level 2's OWN narrow-width restructuring,display: gridwith named areas (text/filter+none-of-these/art-matches+skip/reason/options) entirely inside@media (max-width: 767.98px)with no unguarded base rule, so at>= mdit's a plain unstyled<div>and desktop's existing block-flow layout (DOM order still exactly as before) is byte-for-byte unaffected.grid-area(not flexorder) is the mechanism specifically because the filter-toggle button and the three exit buttons are non-adjacent DOM siblings kept in their EXISTING desktop position/order (orderonly reorders within a shared flex axis, not across independently-wrapped siblings a grid-area can place anywhere regardless of DOM position). A real 2×2 grid for the four action buttons — NOT a first-pass "filter alone in a left column, all three exits stacked in a right column" split, which a live Playwright measurement (Pixel 7, this task's own report) found forced the row's height to whichever column was TALLEST (~166px for three stacked buttons), leaving the candidate row only 69–105px of its own ~176px natural height even after every other budget trim below — a proper 2×2 grid caps each row at the taller of just ITS OWN two buttons instead (~44px/~62px), roughly half the height for the same four buttons. Level1/Level3/artist/tag question types are NOT restructured this way (deliberately, per the same owner review) — none of them has a genuinely scrollable grid the way Level 2 does, so they simply render below the (now non-sticky, stacked) card via the existingHeroQuestionsArea, unchanged beyond inheriting the sharedHeroGrid/HeroCardAreafixes (no more sticky, card stacks above). - Narrow-only Bootstrap-margin cancellation: the four action-button
wrappers' own
mb-2/mt-2/mt-3classNames (needed at>= md, where they carry no CSS of their own and margin is the only thing spacing one button from the next) DOUBLELevel2NarrowGrid's owngap: 0.5remat narrow —margin: 0 !importanton each wrapper (Bootstrap's own spacing utilities are!important, so an unmarked override can't win) cancels that redundant spacing specifically belowmd. -
NarrowOptionsArea/MobileCandidateScroller— a hard vertical clip boundary (overflow-y: hidden, defaultalign-items: stretchrather than the first pass'scenter) matching whatoverflow-x: autoalready is horizontally, so a tile row that comes out even a few px taller than its own allotted grid row is clipped rather than bleeding out top-and-bottom (align-items: center's own behavior) and reintroducing scroll viaHeroQuestionsArea's defensiveoverflow-y: autofallback (own bullet below). - Candidate tile width trimmed
6.5rem→5.5rembelowmdspecifically (proportionally shorter too, viaArtPlaceholder's own aspect-ratio) — still comfortably above the 44px WCAG 2.5.5/ Apple HIG floor for the tap target itself (the whole button, not just the art). - The "N ready / N in catalog / N contested" stats line
(
question-feed-stats) is hidden belowmd(StatsLine) — a nice-to-have info line, not part of the answer funnel, and real measurement found it was the last few px keeping theoverflow-y: autofallback engaged even after every other trim above. Unchanged at>= md. -
Wordmark: below
md, the sliced/stacked WHAT'S/THAT/CARD? teaser (WhatsThatWords.tsx) alone burned ~15% of the viewport as a three-line column — replaced withwhatsthat-composite.svg(the mark+wordmark lockup — the ORIGINAL one-line horizontal wordmark this page shipped with before issue #305's sliced/ stacked treatment, sourced from PR #114's "branding integration" and still on disk, just unused since #305).NarrowWordmark/WideWordmark(QuestionFeed.tsx) toggle via plain CSSdisplay(both always mounted — no conditional render, no hydration-mismatch risk, andWhatsThatWords' own pop/pulse timing is gated onimageLoaded/$playingregardless of whether its container is visible). Unchanged at>= md(still the sliced, per-card-animated version). Regression coverage:WhatsThatWordsAnimation.spec.ts's "narrow-width wordmark swap" describe block asserts visibility (not just DOM presence) of each version at its own breakpoint. -
Starburst readability: the hero's own
$hero-enlargedBurstSvg(200% belowmd) now bleeds into exactly the space the card's name/badge/question text occupy (they moved from beside the card to directly under it). Rather than shrinking the burst back down (the owner's own earlier, separate ask keeps it visible at 200% bleed on mobile — see that fix round's own bullet above), a bottom-fademask-image/-webkit-mask-imagelinear gradient (opaque 0–55%, fading to transparent by 88%,$hero-only) keeps the top/sides fully visible (nothing competes with those) while fading the lower spikes so they don't fight the text below for contrast. -
No
position: stickyanywhere (this codebase's own sticky/ overflow lesson, Lessons, plus the task's own explicit ask) —HeroCardAreais plain, unstyled beyond flex-centering at every width now; nothing needs pinning since PageColumn/ StarburstBackground (whatsthat.tsx, next bullet) bound the WHOLE hero to the viewport at every width, the same invariant>= mdhas always had. -
PageColumn/StarburstBackground now bound height below
mdtoo (whatsthat.tsx) — previously>= md-only (belowmdthe whole page scrolled normally, matching design #2's sticky-adjacent mobile intent); the static-top-block/scrollable-candidate-row split needs the same bounded-viewport chain FeedRoot/HeroGrid already had>= mdat every width.StarburstBackgroundalso gained a below-md-onlymin-height: 100%—Footer.tsx's own responsive layout stacks its columns vertically belowmd, running much taller there than at>= md(confirmed empirically: without this, a real Playwright run crushed the WHOLE hero to ~4px tall,Footerclaiming its full natural height instead, sinceFooterhas nomin-height: 0of its own to let it shrink the wayStarburstBackground's ownmin-height: 0lets IT shrink — flexbox drew the entire deficit from the box that could actually give).min-height: 100%restores the correct priority: the hero claims PageColumn's full height first, andFooter, if it doesn't fit, is pushed below PageColumn's own bottom edge — still fully reachable, just via the page's own ordinary scroll (Layout.tsx'sContentContainer), the same way any long page's footer normally is. Unchanged at>= md(Footer is compact there, genuinely shares the fixed budget with the hero, fully visible with no scroll needed, as before). - Regression coverage:
QuestionFeedResponsive.spec.ts's "portrait static top block" describe block (real, genuinely-loaded CDN image via route interception — the empty-mediumThumbnailUrlfixture convention every other test in that file uses renders at a small intrinsic fallback size regardless of CSS, which would never exercise the height-cap math at all) asserts, at Pixel 7's own 412×839 CSS viewport: zero page-level scroll AND zeroHeroQuestionsArea-internal scroll; the static action row renders fully above the candidate row's own top edge; the question badge/ prompt render entirely below the card's own bottom edge (never occluded by it or its starburst).
-
-
Owner review round 2 (live device follow-up on the block above):
three asks — a "?" motif on every blue "unrevealed" card, fading
with the blue as it reveals; dropping the narrow-width standalone
"?" if one exists distinct from the wordmark's own glyph; and a
golden treatment for every quiz action button, since Bootstrap's
per-variant colours (grey/red/green/blue) were designed against the
site's neutral background, not this page's own deep-blue field.
-
"?" motif: already implemented going into this round —
RevealOverlay(cardPanel.tsx) has always rendered?as its own child text, so the blue field and the "?" fade together via the SAME parentopacityanimation (revealAnimation); the candidate grid'sArtPlaceholder"mystery card" tiles carry the same glyph via a CSS::before { content: "?" }, at every viewport. Confirmed via real (non-empty-src) Playwright measurements at both Pixel 7 (412×839) and desktop (1280×900) — an earlier apparent desktop "collapse" (the reveal overlay rendering as a thin, mostly-clipped sliver) turned out to be an artifact of a manually-started dev server missing theNEXT_PUBLIC_IMAGE_WORKER_URLenv var used by Playwright's ownwebServerconfig, not a real product bug — reproducing it against a correctly-configured server showed a full-height, fully-legible "?" at both viewports. New regression coverage (QuestionFeedResponsive.spec.ts's "question-mark motif + golden action buttons" describe block): the overlay's own text content is asserted at both viewports, andArtPlaceholder's pseudo-elementcontentis asserted directly (Playwright's own mechanism for a CSS-generated-content assertion, since it isn't real DOM text);QuestionFeed.test.tsx's sharedrevealCard()jest helper also asserts the overlay's text content before dispatching the animation-end event every existing jest test already relies on. -
Standalone "?" (mobile): NOT removed. The narrow-width
wordmark (
whatsthat-composite.svg, see the "portrait static top block" bullet above) bakes its own "?" mascot glyph directly into the same flattened image as the "WHAT'S THAT CARD?" text (its own path data is a scaled/repositioned copy ofwhatsthat-mark.svg's "?"-only mark, confirmed by inspecting both SVGs directly) — there is no separate, distinct "?" DOM element at narrow widths to drop without altering the wordmark image itself. This is the exact ambiguity the task's own fallback anticipated, so it's left untouched here and reported instead (see this PR's own body). -
Golden action buttons:
ThumbButton(Level 1's Yes/Not sure/ No/Skip, Level 2's None of these/Art matches/Skip, Level 3's Confirm & continue/Skip),FilterToggleButton("Filter by attribute"), andThumbChip(Level 3's attribute picker) — all three styled components local toQuestionFeed.tsx— now share one gold treatment (#f8d42b,whatsthat-mark.svg's own top gradient stop;#124063, that same SVG's dark-navy stroke) instead of each Bootstrap variant's own semantic colour (success/outline-danger/outline-secondary/link/primary). Measured contrast (WCAG's standard relative-luminance formula): the pre-existing default greyoutline-secondarytext/border against the field's deep-blue stop (#123a6b) was ~2.4:1, under AA's 4.5:1 text floor (and its 3:1 UI-component floor) — the "hard to read" the owner reported live; gold text against that same stop is 7.84:1, against the field's lighter highlight stop (#1d4d82) is 5.93:1, and dark-navy text on filled gold is 7.45:1 — all past AA, most past AAA. Bordered/filled variants (ThumbButton's non-.btn-linkcases) get gold outline/text at rest, filling solid gold with dark-navy text on hover/focus/press;.btn-link(the Skip buttons,FilterToggleButton) gets a colour-only swap (no border/fill added, preserving the existing plain-text-link look);ThumbChipreuses its own existing selected/unselected variant split (primary/outline-secondary) to make a selected chip PERSISTENTLY filled gold rather than only on hover.&&(Emotion's specificity-doubling trick) on every override, so it reliably wins over Bootstrap's own single-class variant rules regardless of CSS injection order. Page-scoped: only these three styled components are touched;ChipCard.tsx(the no-match-reason chips, shared withReportCardPanel.tsxon a different page) is deliberately left alone, and the sitewide orange theme is untouched. New regression coverage (same describe block as above): computedcoloron the filter toggle and "None of these"; computedcolor/background-coloron a selected vs. unselected Level 3 chip.
-
"?" motif: already implemented going into this round —
-
Owner review round 3 (ruling on round 2's open items): three
further asks — remove the narrow-width standalone "?" mascot;
slow the wordmark's 3-stage pop animation down "a bit"; and build
ONE shared blue-card-with-"?" composition used in every blue-card
slot on the page, replacing round 2's two independent
implementations.
-
Narrow-width "?" removal:
whatsthat-composite.svg(the narrow wordmark asset, own bullet above) bakes its "?" mascot into the same flattened image as the "WHAT'S THAT CARD?" text — confirmed by inspecting both that file andwhatsthat-mark.svg(the standalone mascot) directly, their path data for the "?" is an identical shape, just scaled/repositioned.whatsthat- wordmark.svg— the SAME text with no separate mascot at all, already on disk (WhatsThatWords.tsxalready slices this exact file into its three animated words) — is exactly the pre- cropped, text-only asset the owner asked to check for, soNarrowWordmark's<img src>was simply swapped to it; no new art or manual SVG surgery was needed.WideWordmark(WhatsThatWords) was never affected either way — it has always rendered from this samewhatsthat-wordmark.svgsource, never the composite. Regression coverage:WhatsThatWordsAnimation.spec.ts's existing narrow/wide visibility test now also asserts the narrow<img>'s ownsrc. -
Pop animation slowdown:
WhatsThatWords.tsx'sWordcomponent (duration 480ms → 640ms) and itsWORDSarray's own delay stagger (0/240/480ms → 0/320/640ms) both scaled by the same 4/3 (33.3%) factor — within the owner's own "25-40% longer per stage" target, and a clean fraction that preserves the EXISTING duration:stagger ratio exactly (240ms was already precisely half of 480ms; 320ms is precisely half of 640ms) — "keep the stage ordering/feel identical" means the ripple's own overlap shape has to scale in lockstep with the duration, not just the duration alone.CardPulseWrapper(cardPanel.tsx) moves in lockstep by the same factor (640ms/320ms delay) since it must stay frame-for- frame synced to THAT's own timing (its own long-standing comment). The hero reveal fade itself (0.8s) is untouched — out of scope, the owner's ask was specifically the wordmark's pop-in. Regression coverage:WhatsThatWordsAnimation.spec.ts's computed- style assertions updated to the new duration/delay values (see Lessons's cyclic-animation-sampling entry — these assert declarative CSS animation properties directly, not a single sampled frame, so they're not subject to that lesson's own failure mode, but the same "don't trust one point-in-time read" discipline applies to any human eyeballing the new timing live). -
One shared blue mystery card:
RevealOverlay(the hero reveal cover, plain?text) andArtPlaceholder's own CSS::before { content: "?" }(the candidate-grid/no-match placeholders) — round 2's own two independent implementations of the same idea, sharing only theirSTARBURST_OUTER_COLORconstant — are now ONE component,MysteryCard/MysteryCardFace(cardPanel.tsx). The owner read the large hero card and the small candidate cards as visibly different shades of blue; measured via computedbackground-coloron both, they were already byte-identical (rgb(77, 141, 223)) — the perceived difference was a context/contrast effect from the hero's own starburst bleed behind the large card, not a real colour mismatch. Consolidating onto one shared component makes any future drift between the two structurally impossible regardless of whether today's colours already matched, which is the actual "so future changes are one place" ask. The "?" itself is nowwhatsthat-mark.svg's own gold-gradient mascot (not plain text), sized viaheight: 66.6667%on the glyph<img>(2/3 ofMysteryCardFace's own height — aposition: absolute; inset: 0box over a definite-height containing block IS itself a definite height, so a percentage-height child resolves normally) rather than a fixed rem value, so the same component reads correctly at both the large hero card and the much smaller candidate tiles.$playing/onAnimationEndare optional onMysteryCard— only the hero reveal slot passes them (fading away in lockstep with the blue, exactly asRevealOverlaydid); every candidate-grid/ no-match slot omits them and gets the SAME component, just permanently static (falls back toanimation-play-state: pausedforever) — the exact behaviourArtPlaceholder's old::beforehad, just as a real element instead of generated content. Existing regression tests rewritten against the new structure: the reveal-overlay "?" assertions (jest'srevealCard()helper, Playwright's "question-mark motif" describe block) now check the glyph<img>'s ownsrcinstead of text content/pseudo-elementcontent; two pre-existing Playwright tests that queried a candidate tile's own<img>generically (Level 1's reference- image test, the hover-zoom edge-clipping test) had to be narrowed (getByRole("img")/img:not([alt=""])) sinceMysteryCard's ownalt=""glyph is now a SECOND<img>in the same container, ahead of the real thumbnail in DOM order — a bare.locator("img" ).first()silently resolved to the non-interactive glyph instead after this change, which would have quietly broken those tests' own intent (measuring the wrong element) without failing outright. New regression coverage: the glyph's own height-to-card-height ratio (both the hero card and a candidate tile, asserting it's relative sizing rather than a fixed value that happens to work for both); every blue card on the page rendering the identicalwhatsthat-mark.svgglyph asset.
-
Narrow-width "?" removal:
-
Fix round (PR #305/#308 owner review): the original
HeroGrid { max-height: calc(100dvh - NavbarHeight - 2rem) }passed CI but let the whole page scroll live — the flat2remguess never accounted forStarburstBackground's real padding/margin (4.5rem, not 2rem) orFooter's entire height below it, so total page content routinely exceededLayout.tsx'sContentContainerand forced its own outer scrollbar to activate, moving the "pinned" card along with everything else. Replaced with pure flex sizing instead of a hand-maintained calc:whatsthat.tsx'sPageColumn(flex column, height locked tocalc(100dvh - navbarHeight)atmd+ via the newuseNavbarHeight()hook — see below) wrapsStarburstBackground+Footer,StarburstBackgroundtakesflex: 1 1 auto; min-height: 0(whatever's left afterFooter's natural size), and that flex chain propagates down throughStarburstContent→QuestionFeed.tsx's ownFeedRoot→HeroGrid(nowflex: 1; min-height: 0instead of its oldmax-heightcalc) — it structurally can't drift out of sync withFooter's real height again. Deliberately NOT extended through the moderatorTab.Container/Tab.Content/Tab.Paneswitcher (a small, privileged audience) — that branch keeps its previous natural/auto height, unchanged, rather than wiring three more react-bootstrap wrappers into the flex chain.useNavbarHeight()(frontend/src/common/useNavbarHeight.ts) replaces the hardcodedNavbarHeightconstant (issue #250 — confirmed 64-88px real vs the constant's 50px in some auth/nav-link states, seedocs/troubleshooting.md) with aResizeObserver-measured value, forLayout.tsx'sContentContainer(sitewide — this is what was hiding the first several px of top-of-page content, including this page's hero title and/display's own toolbar, behind the real navbar in a taller-navbar state) and this page'sPageColumn. Scoped, not a blanket swap — every otherNavbarHeightconsumer (Explore.tsx,ProjectEditor.tsx,FinishedMyProject.tsx) is unchanged, and #250 stays open for that broader decision (the hook also doesn't yet handle the navbar's own crowded-state wrapping to a second, taller row — only the single-row height mismatch). StrengthenedQuestionFeedResponsive.spec.ts's own pinning assertion with a realpage.mouse.wheel()scroll (not justel.scrollTopon the inner questions box) plus acontent-containertestid check — that gap (never exercising the outer container) is exactly what let the original bug pass CI.
-
SUPERSEDED mobile design #1 (owner live-review, "the card covers
the questions on scroll"): below
-
Hover-zoom/hover-burst edge clipping (fix round, PR #305/#308
owner review):
ZoomableThumbnail's hover-zoom andHoverBurst's glow (cardPanel.tsx) were both deliberately built with nooverflow: hiddenof their own so the enlarged art/glow could pop out uncropped — the pinning fix above'soverflow-y: autoonHeroQuestionsAreaforcesoverflow-x: autotoo (CSS's own "visible computes to auto once the other axis isn't visible" rule), silently re-clipping both right at that box's left/right edges, worst on the left where the first column in every row sits flush against it with no buffer. Two-part fix:HeroQuestionsAreaitself getsmargin: 0 -2.5rem+ matchingpadding(bleeds its own clip boundary 2.5rem into the real empty space already there — the grid's own column gap on the left, the page's outer margin on the right — with zero visible resting-layout shift, verified viaboundingBox()diff);HoverBurstgained an additive$edgeprop that shrinks its 331.2% bloom to 150% for the first/last column specifically (index % 4 === 0 || index % 4 === 3, the two columns still short on room even with the added bleed) — interior columns keep the full-size, unmodified glow. Regression coverage:QuestionFeedResponsive.spec.ts's new horizontal-only containment check (vertical clipping at the top/ bottom of this box is its intended scroll behaviour; only left/right clipping is the bug). -
One hero card slot per item type, not per stage: Level 3 dropped
its old inline 48px thumbnail+name row (the shared hero card already
shows the same art, one persistent slot across every stage per the
mockup's own stage-switcher demo); artist/tag items' plain reference
<img>(no burst/reveal — "reposition, don't redesign") just moved into the same sharedcardgrid area instead of its ownCol. -
Fix round (owner live blocker, post-#310): word-stack sizing +
animation choreography sync. Two independent owner-reported bugs
found on the live site after #310 landed:
-
Question box too small for its content: the word stack
(
WordinWhatsThatWords.tsx) rendered at a fixed3.75rem/4.5remper word (~220px total for all three, measured) — about 1.4xwtc-mockup.html's own approved proportion (164px, measured directly off that file with its demo-only scale transform removed) — and since #310 bounded the whole hero to one viewport- height row, every extra pixel the words claimed came straight out ofHeroQuestionsArea's own budget (HeroGrid'sautorow sizes to the words' content height, subtracting directly from thequestionsrow's1frshare). At 1400×900 this left even Level 1 (suggested-match card + all four answer controls, no candidate grid to scroll) short by ~140px, forcing an internal scroll that clipped the card mid-view. Fixed by shrinkingWord's height to aclamp()of the viewport height (not a flat rem guess, so it can't silently regress on a shorter viewport than was checked) — deliberately smaller than even the mockup's own absolute number, since the mockup was never height-constrained the way the pinned hero now is.HeroGrid's row-gap andStarburstBackground's own padding/margin (whatsthat.tsx) were also trimmed atmd+ (never approved content, pure chrome spacing that was also coming straight out of the same budget), and the Level 1 reference thumbnail'smaxWidthwas cut as a smaller, separately-named lever once the above alone still left only a single-digit-px margin. All of this was re-measured and re-tuned again on rebase onto #313's three-tierFooterredesign, which is substantially taller than the single-tier footer this fix's own first pass was built against and ate further into the same budget — seeWhatsThatWords.tsx'sWordcomponent andQuestionFeed.tsx'sHeroGrid/reference-thumbnail comments for the exact before/after numbers at each pass. New hard regression guard:QuestionFeedResponsive.spec.tsasserts at 1400×900 that Level 1'sHeroQuestionsAreanever overflows (scrollHeight <= clientHeight) and all four answer controls are fully within the viewport, not merelytoBeVisible()(which only requires a non-zero intersection, not full containment) — confirmed to fail on the pre-fix code with the exact expected numbers before the fix landed. -
Pulse/pop desynced from a still-loading card: the reveal
fade (
RevealOverlay), the word-pop sequence (WhatsThatWords), and the hero card pulse (CardPulseWrapper) all used to start counting the moment their own elements mounted, independent of whether the subject card's<img>had actually finished loading — on a slow connection this could reveal, pop, or pulse against a still-loading or half- painted image. Owner's redesign of the choreography: the card slot shows the blue cover state and holds it until the image'sloadevent fires, then the entire sequence (cover fade off + word pops + card pulse, all still frame-for-frame in sync with each other) runs as one queue anchored to that single moment. Implemented via a sharedimageLoadedboolean (QuestionFeed.tsx) threaded into each of the three animated components' own$playing/playingprop, which gates each one's CSSanimation-play-state(pauseduntil told otherwise,runningonceimageLoadedflips) — the timeline, delay included, doesn't advance at all while paused, so flipping all three at once genuinely starts them in lockstep rather than merely un-pausing three independently-drifted clocks. A failed load (onError, non-empty configured URL) keeps the cover up permanently with no animation at all (no legitimate "reveal" moment to sync to), whilerevealedstill flips true so the rest of the question UI isn't stranded behind it; reduced motion skips the whole animated queue and jumps straight torevealedon load, matching the owner's "swap to the image without pops, immediately on load" instruction. A genuinely empty configured URL (this test suite's own fixture convention — real cards always carry a real CDN URL) is treated as trivially settled rather than a failure, resolved synchronously in the fetch handler rather than waiting on any browser event — an<img src="">resolves its emptysrcagainst the current page's own URL (confirmed empirically, the URL spec's own "empty string" case) and predictably fails to decode that as an image, so relying on the resulting realonErrorevent as the only settle signal for this specific case proved flaky under dev-server load. Real bug found and fixed during this pass, not just the intended one: the settle logic's own catch-up effect was initially keyed on the card's identifier, which can legitimately repeat between two consecutive feed items (documented already, for different state, in the fetch handler's own comment) — a repeat resolution resetsimageLoaded/revealedunconditionally but doesn't change the identifier, so an identifier-keyed effect silently skips re-running and permanently strands the UI on "Loading...". Fixed by keying that effect on a generation counter bumped unconditionally in the same reset block instead — seedocs/troubleshooting.md's dedicated entry for the full symptom/cause/fix writeup. New regression coverage:WhatsThatWordsAnimation.spec.tsgained a test that holds a real (route-intercepted) image response and asserts every one of the three animations is genuinelyanimation-play-state: pausedbefore the response resolves andrunningonce released — confirmed to fail on the pre-fix code.
-
Question box too small for its content: the word stack
(
-
Fix round (owner live-review, mobile /whatsthat pass): two more
owner-reported issues, found once the
imageLoadedgate above was confirmed genuinely deployed and working.-
Fade felt delayed even though the gate was working:
revealAnimation(cardPanel.tsx) held at full opacity for the first 55% of its 1.8s run before this fix round — a leftover from BEFORE theimageLoadedgate existed, when the hold bought the network a moment to catch up before the cover started fading at mount time regardless of load state. Once gated on the real load event, that hold became pure redundant lag layered on top of the real wait: by the time$playingis ever allowed to flip torunning, the image is already there, so holding at opacity 1 for another ~1s just reads as "nothing happened yet". Collapsed to a plain two-stop fade (from/to, no hold checkpoint) and shortened 1.8s ease-in → 0.8s ease-out, so the drop starts the instant playback resumes and is visibly moving immediately rather than creeping through ease-in's own "slow start" for its first chunk. Timings:WhatsThatWords' pop sequence (0/0.24s/0.48s delay, 0.48s duration each — ends 0.48s/0.72s/0.96s) andCardPulseWrapper's pulse (0.24s delay, 0.48s duration — ends 0.72s) now land across the fade's own 0-0.8s run instead of only starting after it had long finished, so the pops visibly overlap the fade's tail as intended. -
Subject card bypassed the image CDN entirely: the hero
<img>rendereditem.card.mediumThumbnailUrldirectly — a raw backend field that (cardpicker/sources/source_types.py'sGoogleDrive.get_medium_thumbnail_url) resolves straight todrive.google.com/thumbnail?..., bypassingcdn.proxyprints.ca(the R2-cached image-CDN Worker every other card surface —Card.tsx,SharedDeckViewer.tsx,downloadImages.ts— already prefers) entirely. Confirmed live via a real Network-tab capture: the hero request landed ondrive.google.com, notcdn.proxyprints.ca. Now resolves throughgetWorkerImageURL(common/image.ts) first, at the"small"(400px) tier — this hero caps at 320px CSS width even on desktop, and far less in the mobile compact column, so"small"comfortably covers a >2x- retina render at that size — falling back to the rawsmallThumbnailUrlfield only when the worker genuinely isn't configured for this source type (the same fallback chainCard.tsx's ownuseImageSrcuses). Guarded on the rawmediumThumbnailUrlfield being non-empty (not on whethergetWorkerImageURLreturned something) so the test suite's "emptymediumThumbnailUrlmeans nothing to load, skip to the settled fast-path" fixture convention keeps working —getWorkerImageURLwould otherwise happily build a real-looking (but bogus) CDN URL for anyGoogleDrive-sourceType fixture regardless of whether either thumbnail field is genuinely configured.WhatsThatWordsAnimation.spec.ts's route-intercepted- image test was updated to intercept the actual CDN worker URL (computed via the samegetWorkerImageURLhelper, not hand- constructed) instead of an arbitrary same-origin path — it silently kept "passing" against the CDN swap by racing a real, unmocked outbound network call tocdn.proxyprints.cabefore this fix, which is exactly the kind of drift this task's own report flagged rather than left in place.
-
Fade felt delayed even though the gate was working:
- Regression coverage:
QuestionFeedResponsive.spec.ts's desktop-axis test now asserts card-left-of-questions (was candidates-left-of-card pre-#305);WhatsThatWordsAnimation.spec.ts(new) asserts computed CSS animation properties (animation-name/-duration/-delay/-timing-function), not mid-frame screenshots (the sheet-position-pill flake lesson,docs/troubleshooting.md) — Playwright's project-widereducedMotion: "reduce"default means every other existing spec never has to account for this animation at all; this file explicitly overrides it per-test where it needs the animation actually running.
-
Layout:
-
Artist write-in with autocomplete (backend only this pass — frontend ships separately). Lets a voter on
/whatsthat's artist question and/display's Artist sidebar suggest an artist not in the candidate list, for whenArtistVotePicker.tsx's existing ranked-candidates/typeahead modes (post_artist_candidates, exact-nameSubmitArtistVoteRequest) come up empty. Two new endpoints, both additive — no change tocardpicker.artist_consensus(PROTECTED CORE) or topost_artist_candidates/post_submit_artist_vote, which are untouched:-
POST 2/artistAutocomplete/(ArtistAutocompleteRequest{query}->ArtistAutocompleteResponse{results: [{id, name}]}) — unscoped (no cardidentifier), catalogue-wide, case-insensitive, prefix-then- substring name search, capped atARTIST_AUTOCOMPLETE_PAGE_SIZE(10) results. Unauthenticated-OK (public, read-only data, noreject_untrusted_origin) but rate-limited per-IP (ARTIST_AUTOCOMPLETE_RATE, default120/m) since it's expected to fire on every keystroke. Returns anid(unlikepost_artist_candidates's bare-name results) so the cast endpoint below can be called unambiguously. -
POST 2/submitArtistWriteInVote/(SubmitArtistWriteInVoteRequest{identifier, anonymousId, artistId?, freeText?, voteSurface?}->SubmitArtistWriteInVoteResponse— the same consensus shape asArtistConsensusResponsepluscastArtistandcreatedNewArtist). Exactly one ofartistId(the autocomplete- pick path, the PRIMARY normalization route) orfreeTextis required.freeTextis control-character-stripped, whitespace- collapsed, length-capped (ARTIST_WRITEIN_NAME_MAX_LENGTH, 100), then matched case-insensitively against existingCanonicalArtist.namevalues — a match REUSES that row (no twin of an existing artist under a different casing); only a genuinely new normalized name creates a new row. Either way the vote that gets cast is a completely ordinary USER-sourceCardArtistVote, through the exact sameresolve_and_persist_artistpath aspost_submit_artist_vote— same weight, same gates, same rate budget (sharesPRINTING_TAG_SUBMISSION_RATEvia the existing_printing_tag_rate_limit_key/_rate, not a separate budget) — write-ins get no special treatment in consensus. Guarded withreject_untrusted_origin, same as every other vote-casting endpoint. No guard against an already-RESOLVED card (mirrorspost_submit_artist_vote/post_submit_printing_tag, neither of which gate on that either). - No new
CanonicalArtistfield for write-in provenance: a write-in- created row is indistinguishable from any catalog-imported one by design (per spec, it gets no special consensus treatment), and an unreferenced write-in row (name created, vote later retracted/ superseded) is judged harmless to persist — aCanonicalArtistrow is a few bytes of text, not image data, and the admin panel already supports a manual rename/merge if one ever needs cleaning up. - KNOWN LIMITATION, flagged rather than silently fixed: the write-in
reuse match is case-insensitive, but the Scryfall/MTG catalog sync
path (
integrations/game/mtg.py'sartists_by_namedict) dedupes existingCanonicalArtistrows by EXACT, case-sensitive name. A write-in stored with unconventional casing that's later confirmed by an official Scryfall entry under different casing will still produce a duplicate row at the next sync — deferred, not eliminated. Fixing that touches the catalog sync integration, not the vote/consensus system this task targets, so it's out of scope here.
-
Key files (Stages 1–7; Stage 8+ files are in Catalog-Completion-Plan)
- Backend:
cardpicker/printing_consensus.py,cardpicker/printing_metadata_import.py,cardpicker/integrations/game/mtg.py,cardpicker/models.py,cardpicker/search/search_functions.py,cardpicker/documents.py,cardpicker/tag_consensus.py,cardpicker/reason_tags.py,cardpicker/default_tags.py,cardpicker/management/commands/seed_no_match_reason_tags.py,cardpicker/deductive_backfill.py+deductive_backfill_printing_tagsmanagement command,cardpicker/question_feed.py,cardpicker/attribute_tags.py+seed_attribute_tagsmanagement command. - Frontend:
frontend/src/features/printingTags/(PrintingTagPicker.tsx,starburstShape.ts— kept only for itsSTARBURST_OUTER_COLORconstant,ChipCard.tsx's default/pre-rebuild frame;cardPanel.tsx— WTC-rebuild-retinted,BurstSvg/HoverBurst/useStarburstFrame/CardPulseWrapperdeleted),frontend/src/features/questionFeed/WhatsThatWords.tsx(WTC rebuild: now a plain, static, token-coloured<h1>wordmark — no more sliced- word pop sequence),frontend/src/features/filters/ResolvedAttributeFilter.tsx,frontend/src/common/processing.ts::getPrintingMatchLabel,frontend/src/features/attributeVoting/(ChipCard.tsx,NoMatchReasonStrip.tsx,QueueTagQuestion.tsx,ArtistVotePicker.tsx),frontend/src/common/tagDisplayNames.ts,frontend/src/features/attributeChips/,frontend/src/features/questionFeed/QuestionFeed.tsx,frontend/src/pages/whatsthat.tsx. - Docs:
docs/upstreaming/vote-system.md(upstream cherry-pick classification — flags that the starburst theming is interleaved with real vote-queue logic across many commits and shouldn't be cherry-picked commit-by-commit),docs/federation-v1.md(namevsdisplay_nameinterchange-key note).
- Client-side (local-folder/Google Drive) search gets no re-rank/filter/ match-indicator parity — no ES/DB access on that path.
- The starburst/card/chip-ring layout was hand-tuned via iterative
screenshot review, not built against a formal design system — flagged
for a
/dataviz-skill pass. - Ranked-union v1 has a known starvation property (see above); interleaved/weighted scheduling is a future v2, not built.
- Stage 8+ (local printing-ID backfill / catalog-completion) gaps: see Catalog-Completion-Plan.
- Moderation — sensitive-tag moderation layer
- Card-DOM-API — printing-candidate DOM attribute wiring
- Catalog-Completion-Plan — Stage 8+ (active development)
-
../upstreaming/vote-system.md— upstream cherry-pick manifest -
../federation-v1.md— federation verdict exchange format - Lessons — sticky/overflow CSS, testid collisions, cyclic- animation sampling, and data-migration-vs-command-seeding gotchas surfaced while building this
Status: built and pilot-run against live production (2026-07-15,
worktree-local-printing-id-pilot, PR #22). Code + tests merged; a real
--limit 300 --engine both --nice invocation ran against the live DB and
its full results are summarized below. Full-catalog run explicitly NOT
executed - see "Real pilot run results" below for why (a ~13-day
single-process projection). See
journal/2026-07-15-local-printing-id-pilot.md (machine-local, not
committed) for the complete data dump this summary is drawn from.
Sibling to Stage 6's deductive backfill, same non-negotiable principle
(a vote is always just a vote, never a direct resolve - the human-backed
gate in vote_consensus.resolve_weighted_consensus still applies), but
sourced from actually looking at the card image instead of pure logical
deduction from existing structured data - two independent local (no paid
API calls) pass-1 engines, plus a pass-2 fallback for cards pass 1 can't
reach at all:
-
L1, OCR: Tesseract on a cropped, preprocessed collector-line region (bottom-left corner, grayscale/upscaled/thresholded). Parses candidate (set code, collector number) pairs from the raw text and only casts a positive vote when the parse matches exactly one of the card's own name-candidates - weak OCR is made safe by this validation rail, not by trusting the OCR output itself. Also casts a real
is_no_match=Truevote for its own distinct "parsed-but-no-match" outcome (a syntactically valid parse that matches none of the card's candidates) - see "Negative-vote wiring" below for why this one skip_reason (of several) is treated as genuine whole-candidate-set evidence rather than a mere abstention. OCR engine seam (issue #423, 2026-07-25 spike):cardpicker/local_ocr.pydispatches between two Tesseract bindings behindsettings.OCR_ENGINE-"pytesseract"(the OLD default, process-per-call behavior) or"tesserocr"(a persistent in-processPyTessBaseAPI, ~5.7x faster per the spike's real-OCR-call measurement, since it eliminates pytesseract's own ~97ms/call tesseract-process-spawn floor).cardpicker/management/commands/ocr_engine_ab.pyis the read-only real-image A/B validation tool any flip decision is gated on (per-image byte-identity, parse-level agreement, stored-vs-fresh drift detection, confidence deltas, and latency for both engines - writes nothing, per this doc's own "index, don't store" discipline). Its--disagreements-detailflag classifies every parse-level disagreement further - each engine's parse checked against the realknown_set_codes()lexicon andvalidate_against_candidatescandidate-matcher (same checks the real join-key calculator uses, not reimplemented) - intotesserocr_only_valid/pytesseract_only_valid/both_valid_different/neither_validbuckets, also recorded on the run's own ledger row.THE FLIP (issue #480's combined whole-catalog pass, held in a PR that merges only on the owner's own A/B GO - see that PR's own description, not a standalone decision):
settings.OCR_ENGINEnow defaults to"tesserocr", bundled in the SAME change as the OCR-derived extractor version bump (COLLECTOR_LINE_OCR/ARTIST_OCR/COLLECTOR_LINE_TSV/LEGAL_LINE_EXTRACTOR_VERSION, v1 -> v2 inimage_evidence.py) - tesserocr's wheel vendors a different compiled tesseract/leptonica build than the apt package pytesseract shells out to, so the swap is real output drift, not a transparent speed-up, and issue #480's own correction comment forbids an engine swap without a version bump (untracked drift under one provenance label).OCR_ENGINE=pytesseractin the environment is the instant rollback. That same PR also landed issue #487's three pre-flip fixes found by the Tron gate on PR #486:_tesserocr_availablewidened to catchException(not justImportError), the TSV read+parse moved inside the crash-guarded region (a malformed row degrades to the pytesseract fallback instead of raising), and athreading.Lockserializing the process-globalPyTessBaseAPI's SetImage/Recognize/read sequence. -
L2, perceptual hash: art-region phash comparison against each name-candidate's Scryfall art crop, voting only when there's a clear single best match (distance threshold + margin over the second-best, recalibrated from real production data - see the journal for the calibration history).
CanonicalCard.image_hash(models.py) already exists as aBigIntegerFieldfor exactly this - added whenimport_canonical_card_datafirst shipped ("CanonicalCard population fix" above) but never computed in production (--skip-image-hashwas used for the real import) - so this pilot is the first thing to actually populate it, lazily, only for candidates it needs. Capped at 12 candidates per name (basic lands/staples can have hundreds - see "Real pilot run results" for how often this cap fires). Never writesis_no_match- a "no-clear-winner" outcome is evidence against ONE candidate falling short of the bar, not against the whole set (see "Negative-vote wiring" below). -
Pass 2, fallback (
local_fallback.py,local-fallback-v1): fires only when pass 1 (either engine) produced no accepted vote for a card - the old-border-frame case (no collector line printed on the card face at all, just an "Illus.<artist>" credit). Evidence-combination model across border-color sample, artist-name OCR fuzzy match, and set-symbol phash (found unreliable in practice, kept but effectively disabled via a strict threshold - seelocal_fallback.py's module docstring for the full negative finding): a positive vote is cast only when the intersection of every sub-check that produced a reading narrows to exactly one candidate. Also casts a realis_no_match=Truevote for its own distinct "eliminated" outcome (the intersection narrowed to ZERO candidates) - see "Negative-vote wiring" below. -
Border-color sampling and frame-style classification (OCR-collector- line-present vs. Illus.-anchor-present) run for every processed card regardless of printing-vote success, casting standalone attribute-chip votes (Black/White/Silver Border, Borderless, Old/Modern Border) - and, when a printing vote is confirmed for that card this run, preferring ground truth from that printing's own
CanonicalPrintingMetadata(Scryfallborder_color/frame) over the heuristic estimate. The same heuristic reading also feeds a consistency check: if a card's observed frame class contradicts its matched printing's real frame value, the printing vote itself is withheld (kept as a frame-vote-only outcome) rather than trusting an art/OCR match that likely landed on the wrong printing. -
All engines vote under
VoteSource.OCR(the 2026-07-15 split of the old singleVoteSource.AIvalue intoDEDUCTION/OCR- seemodels.py'sVoteSourcedocstring; same weight/gate treatment as before, individual technique still distinguishable viaanonymous_id)- when OCR and phash both vote on the same card and agree, both votes stand as independent evidence; on disagreement, neither is written (logged instead - see the journal's disagreement examples).
Environment: resolved via a host-side venv pointed at the
already-127.0.0.1-exposed Postgres/Elasticsearch ports (zero
Docker/container change) - tesseract-ocr installed via host apt,
pytesseract/ImageHash/Pillow via the venv's requirements.txt
install. No Dockerfile change made. (A future full-catalog run, if one
ever happens, should revisit baking tesseract-ocr into
docker/django/Dockerfile instead, per the original tradeoff writeup.)
Owner-approved, diagnostic-backed follow-up closing the implementation
gap docs/theory.md §4 always assumed existed: machine engines can cast
a real is_no_match=True CardPrintingTag vote, not only a positive
one, for the two skip_reasons that carry genuine evidence against the
whole candidate set (not just one candidate) - OCR's
"parsed-but-no-match" and fallback's "eliminated". Every other
skip_reason (a pure abstention, or evidence against a single
candidate/pair rather than the set) is unaffected and still casts no
vote at all. Same non-negotiable principle as every other engine in this
doc: still just a vote (VoteSource.OCR, machine weight 0.5 by
default) - the human-backed gate
(vote_consensus.resolve_weighted_consensus) means a lone machine
is_no_match vote can never resolve a card to NO_MATCH by itself
(PRINTING_TAG_MIN_VOTES=2 can't clear on weight 0.5 alone). Effect at
current weights: advisory only - it surfaces the card as CONTESTED if
a printing vote already exists or is cast later
(printing_consensus.get_contested_card_ids already treats "a printing
vote + a no-match vote on the same card" as contested, unchanged by this
work), giving a reviewer a genuine negative signal instead of the prior
silence.
PREREQUISITE - the ambiguous/no-match split: local_ocr. validate_against_candidates already returned a distinct "ambiguous"
outcome (a collector-number-only match against more than one candidate -
possible when a name spans multiple sets sharing a number) separately
from "parsed-but-no-match" (matches none), but run_ocr_for_card used
to discard that distinction - it only ever inspected the matched
candidate, never the skip reason, on a miss, silently folding both into
"parsed-but-no-match". Fixed first, since casting is_no_match on the
now-corrected "parsed-but-no-match" would otherwise have miscast an
ambiguous (evidence-FOR-more-than-one-candidate) read as
whole-set-no-match evidence. Tracked across every preprocessing variant
tried (not just the last), with "ambiguous" taking priority if any
variant produced it.
What does NOT cast is_no_match, deliberately: frame-mismatch (a
vote WAS produced but withheld by the consistency check - evidence
against the ONE candidate it landed on, not the set),
engine-disagreement (OCR and phash each finding a DIFFERENT candidate -
evidence FOR two candidates), and every pure-abstention reason (OCR's
"no-text"/"ambiguous"; phash's "too-many-candidates"/
"no-hashable-candidates"/"no-clear-winner-distance"/
"no-clear-winner-margin"; fallback's "no-evidence"/"ambiguous").
Casting a whole-set no-match vote from any of these would misrepresent
what the engine actually found.
Instrumentation for a future ranked-vote decision (code-only, no new
fetches, no ranked-vote schema built here - docs/theory.md's
Dawid-Skene addendum is the eventual consumer): CardScanLog gained two
additive fields, evidence_types_used and survivor_pks, populated for
fallback's own "no-evidence"/"ambiguous" rows (never for an OCR/phash
row, which have no sub-check concept of their own).
evidence_types_used is always available (already threaded through as
CardOutcome.fallback_evidence_types, just never persisted before this
change). survivor_pks is populated only where knowable WITHOUT
re-deriving local_fallback.py's own border/artist/symbol sub-checks a
second time in the caller: trivially the card's full (post
expansion_hint-narrowing) candidate set for "no-evidence" (nothing
filtered anything) - left null for "ambiguous", see the open item
below. phash's own "no-clear-winner" skip_reason is similarly split
into two distinct sub-cases, "no-clear-winner-distance" (best distance
over threshold) and "no-clear-winner-margin" (runner-up too close
behind it) - re-derived in the caller via a pure Hamming-distance
recompute over the exact same inputs local_phash.find_best_match
itself used (verified empirically to match imagehash.ImageHash's own
subtraction before use), never by editing that function's own decision
logic.
PROTECTED CORE boundary honored, not routed around
(docs/upstreaming/license-provenance.md §2): local_phash.py and
local_fallback.py were not edited. The phash no-clear-winner split is
pure arithmetic re-derivation from inputs the caller already held (safe
to duplicate - no tunable decision logic of its own). Fallback's
survivor set for "ambiguous" is a different case: recovering it would
mean either reimplementing local_fallback.py's border/artist/symbol
sub-checks (three algorithms with real tunable thresholds/margins/fuzzy
ratios) a second time in the caller, or having FallbackOutcome expose
the already-computed survivors set directly - the latter touches
protected core. Open item, not built: exposing survivors on
FallbackOutcome is the clean fix, needs owner sign-off per the
absorption-adjacent protected-core review convention; survivor_pks
stays null for "ambiguous" rows until that lands.
Known gap, not built: is_no_match votes do not propagate to
cluster (identical-image) members the way positive votes do via
propagate_cluster_vote - unscoped by issue #207, and extending that
function to accept an Optional[printing_pk] is real surface area
nobody asked for. A representative card's no-match conclusion is
currently NOT mirrored onto its absorbed duplicates.
Side effect on abstention-aware ordering, worth being explicit about:
_compute_hard_names demotes a name to the back of the queue once it
has >= HARD_NAME_MIN_ATTEMPTS non-rescannable scan-log rows and zero
votes, "one real vote disqualifies the name permanently" (see that
function's own docstring). Since "parsed-but-no-match"/"eliminated"
now cast a real vote instead of a scan-log row, cards that used to count
toward hard-name demotion no longer do, the moment they get a genuine
is_no_match conclusion. Judged correct, not a regression: a no-match
vote is a real conclusion (the engine successfully determined "not this
set"), not a failure-to-conclude - the demotion mechanism exists to
deprioritize names an engine keeps failing to say anything about, and
this is the opposite of that.
32m4.6s wall-clock, 19m36s user + 4m37s sys CPU (≈76% avg utilization of
one core on this 2-CPU box), exit 0. --nice confirmed actually
throttling (process niceness observed alternating 5↔19 during the run).
| 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 - the human-backed gate held perfectly at this scale, same result as Stage 6's 0/28,112.
Largest skip bucket by far: OCR's "parsed-but-no-match" at 176/300 (58.7%) - a syntactically valid collector line that didn't match any of the card's own candidates. Not investigated further in this pilot (out of scope), but the single most promising lead for improving yield before any larger run - see the journal for the plausible-causes breakdown.
Attribute votes: border {black: 280, borderless: 17, white: 3} (91 from
ground truth, 209 from the pixel heuristic); frame {modern: 258, old: 14}, 28 abstains (91 from ground truth, 181 from the OCR/Illus.-anchor
heuristic); 6 frame-mismatches (printing vote withheld by the
consistency check) - see the journal for all 10 sampled examples and the
per-case reasoning.
Full-catalog projection: ~171,800 eligible cards remain (of 179,002 raw eligible pool) → naive linear projection ≈ 306 hours ≈ 12.8 days of continuous single-process runtime. This is the key number for any future decision to scale up - not attempted in this pilot, and not practical as a single uninterrupted process. Before attempting it: parallelizing across multiple processes/pk-range partitions (raised, not yet implemented - see the pre-scale program's scaling proposal below).
run_pilot no longer does one giant bulk_create at the very end -
matches deductive_backfill.py's periodic-flush precedent (--batch-size,
default 25 cards - much smaller than deductive_backfill's 2000, since
each card here costs a real image fetch plus OCR/phash CPU work, not just
a DB write). A killed/interrupted run keeps whatever it already flushed;
a plain re-invocation resumes cleanly with no separate checkpoint file,
via the same select_candidates idempotence mechanism --resume already
relied on. Verified live in tests (TestCheckpointing, not just
plausible): flushes happen every --batch-size cards, a simulated kill
mid-run leaves the already-flushed cards durably committed and a
follow-up invocation completes exactly the remainder with no duplicates.
One deliberate deviation from deductive_backfill's pattern: the gate
check (verify_zero_resolutions) now runs after every flush, not once at
the end. deductive_backfill's votes are provably exact by construction
(a violation there is structurally impossible, so one end-of-run check is
belt-and-suspenders); this pilot's OCR/phash/fallback votes are explicitly
weaker, lower-confidence signal where a real violation is more plausible,
and a kill is now an expected event for a multi-day run - a violation in
an already-flushed batch must not sit undetected in the DB indefinitely
just because the process died before reaching a final check that may
never come.
5-vote spot check, 20-vote random admin-link sample, 3 disagreement
examples, and the filename tag-gap census (1,097 unresolved cards with an
unmatchable expansion_hint) are all in the journal, not duplicated here.
Pilot discipline honored: --limit 300, no full-catalog run attempted
per the original hold.
Measured against real production data (read-only, no writes) via two
instrumented 30-card samples run through the actual pipeline functions,
not simulated. First sample (OCR + phash only) undercounted real cost -
run_pilot also runs border/frame classification and pass-2 fallback for
every card with an image, regardless of pass-1 outcome. Second sample
matched run_pilot's real per-card call sequence exactly:
| phase | mean/card | share of measured total |
|---|---|---|
detect_illus_anchor |
1.466s | 33.0% |
| pass-2 fallback (fires ~70%) | 1.474s* | 23.2% |
fetch_card_image |
1.187s | 26.6% |
| OCR (crop+preprocess+tesseract) | 0.602s | 13.5% |
classify_border_color |
0.159s | 3.6% |
| phash (hash+compare) | 0.011s | 0.2% |
*mean over the 21/30 cards it actually fired on; contributes 0 for the other 9.
Measured total: 4.46s/card (sum of the above), against the real
300-card pilot run's observed 6.42s/card (32m4.6s / 300) - a ~2s/card
gap not fully attributed by this instrumented sample, plausibly per-card
DB queries this sample didn't isolate (the frame-mismatch consistency
check and ground-truth-metadata lookup each re-query CanonicalCard once
per confirmed vote) and/or run-to-run network/cache variance (different
selection window, different Scryfall/CDN load). Treat 6.42s/card as the
trustworthy full-pipeline number and the phase breakdown above as
directional (which phases dominate), not a component-by-component
reconciliation.
detect_illus_anchor is the single largest cost, and it's partly
redundant with the main OCR pass: when pass 1's OCR text doesn't
already contain the "Illus." artist credit, it runs its OWN
crop+preprocess+tesseract pass (a second full OCR call per card, on a
different crop) purely to extract the artist name for pass-2 evidence
and the frame-style classifier's illus-anchor signal. This is a real
optimization target flagged for a future pass, not fixed here - the
addendum ledger closed on ideas beyond items 1-8, and this wasn't one of
them.
The premise "CDN-first fetching" was built on turned out to be wrong,
checked against the actual Worker source (image-cdn/src/handler/image.ts,
R2Service.ts) before implementing anything. The pilot's
get_worker_image_url requests the full tier (matching the PDF export
path, for print-quality output) - and the full tier is a pure
passthrough: fetch(url) straight to Google Drive, every single
request, with zero R2 involvement. Only the small/large tiers go
through R2Service.getThumbnail's cache-check-then-populate-on-miss
logic. There is no bucket to be "first" about in the pilot's current flow
- it was never touching one.
Checked whether switching tiers would help anyway - real measurement,
not assumption. If the pilot switched to the large tier (800px,
R2-cacheable), would it benefit from a warm cache? Fetched 20 real
pilot-candidate images through both full and large Worker endpoints:
0/20 large-tier requests showed a cache hit (cf-cache-status: DYNAMIC on every one; large mean 0.881s vs. full mean 0.983s -
within noise, both dominated by Google Drive origin latency, not R2 read
time). This isn't surprising in hindsight: the pilot's candidate pool is
specifically the tail of the catalog needing backfill - by definition
these are exactly the cards NOT recently popular enough to have been
browsed (and thus cached) by real users. Verdict: switching tiers would
not reduce fetch latency or add caching benefit for this workload - stay
on full tier, already in use, gives the best-quality image for OCR.
This also makes addendum item 6's original framing ("OCR resolution floor
re-measured at the CDN's delivered pixel size") moot - the delivered
pixel size doesn't change, since no tier switch is happening.
Checked a real cache-key gap while in the Worker source, cleared it -
not applicable today. R2Service.getImageKey doesn't include
jpgQuality in the cache key (${imageIdentifier}-${imageSize}-${imageType})
- whichever quality first populated an entry is what every later request
gets, silently. Checked every call site across
frontend/src/that requestssmall/large: all either omitjpgQuality(defaulting to
- or pass 100 explicitly - no call site requests a different quality today, so this can't currently produce a mismatched-quality cache hit. Worth remembering if a quality-tunable thumbnail path is ever added, but not an active risk to the pilot (or anything else) as the code stands.
What the real constraint actually is, and what got built for it: every
image fetch is one request against the Worker's daily request quota,
which is shared with live site traffic regardless of which tier is
requested (a cache hit still counts as a Worker request, just a cheaper
one to serve) - this part of the original concern was correct, just not
for the "bucket-first" reason originally assumed. Implemented
--fetch-budget (run_pilot(fetch_budget=...)): caps the number of
image fetches a single invocation will make; on exhaustion the run stops
cleanly mid-selection, whatever was already flushed stays committed, and
every card not yet reached is left completely untouched (no vote, no
skip-reason recorded) so the next invocation's ordinary idempotent
selection just picks them up - no special resume handling needed, same
mechanism --resume/checkpointing already relies on. Verified in tests
(TestFetchBudget): stops exactly at the budget, and a follow-up
invocation with no budget completes the untouched remainder with no
duplicates.
Quota math: ~171,800 eligible cards remain, each fetched at most once
(idempotent selection - no repeat fetches across invocations). Spread
across the ~13-day naive full-catalog projection (see wall-clock section
below), that's roughly 13,000/day if evenly sliced - well under the
Worker's 100,000/day shared limit on its own. The real risk isn't the
pilot in isolation, it's concentration: heavy parallelization (item 3d)
compressing the same total fetch count into fewer, busier days, stacked
on top of live traffic's own share of the same quota on those days.
--fetch-budget is the safety valve for that scenario - a conservative
per-invocation cap (a specific number is a scaling-proposal decision, not
fixed here) leaves headroom for live traffic regardless of how
aggressively a given slice is scheduled.
Amendment (same day, owner review): the "shared Worker request quota"
framing above was the wrong quota. The real question is whether
lh4.googleusercontent.com itself (the domain the full tier's
passthrough actually fetches from - the Worker's own 100k/day request
quota was never the binding constraint) can take sustained pilot-scale
load without degrading for live traffic. That domain is genuinely shared
-
frontend/src/features/pdf/pdfImage.ts(PDF export) andfrontend/src/features/download/downloadImages.ts(bulk image download) both request thefulltier already - but had no rate limiting of any kind, unlike the real Drive API (GoogleDriveService.executeCall, guarded by the existingGOOGLE_DRIVE_RATE_LIMITERbinding - a DIFFERENT Google domain thefull-tier image fetch never touches). Fixed with a real enforced limiter (image-cdn, separate PR ahead of this one): a newIMAGE_FULL_TIER_RATE_LIMITERCloudflare rate-limiting binding (3 req/s sustained,wrangler.toml), wired intoimage.ts'sfull-tier handler via a newfetchWithRateLimithelper (src/utils.ts) that mirrorsGoogleDriveService.executeCall's check-then-backoff-then-retry pattern - checked-in-limit, delay-and-retry on denial, plus a defensive retry on an upstream 429. This is now the primary protection, shared by all three callers (pilot, PDF export, bulk download);--fetch-budgetis defense-in-depth on the pilot's own pacing, not the main safeguard the earlier paragraphs implied. Lands and deploys independently of this pilot's own branch, ahead of any full-catalog run.
lh4.googleusercontent.com's size-suffix parameter genuinely
re-encodes a smaller image - verified directly, not assumed: fetched
one real card at =h200/=h400/=h800/native and confirmed real,
progressively smaller dimensions and byte counts each time (native
1146x1600 @ 3.29MB → =h800 573x800 @ 892KB → =h400 287x400 @ 218KB).
The image CDN Worker already exposes this via the full tier's existing
dpi query param (height = dpi * 1110 / 300, image-cdn/src/url.ts)
- the pilot just never passed one, so every fetch requested the uncapped native original.
Empirical resolution floor, a real 6-way sweep (dpi 100/150/200/250/ 300/native) against the same 30-card sample used to validate the tightened crop box, applying that same tightened box and the production OCR pipeline at each size:
| dpi | matched/30 | mean payload |
|---|---|---|
| 100 | 3 | 144KB |
| 150 | 7 | 298KB |
| 200 | 12 | 495KB |
| 250 | 10 | 728KB |
| 300 | 9 | 997KB |
| native (none) | 8 | 1.84MB |
dpi≤150 clearly degrades yield below the native baseline; dpi≥200
matches or exceeds it despite a 2-4x smaller payload (plausibly a
smaller re-encoded JPEG rendering small text more cleanly than a full-res
original in some cases - 30 cards is too small a sample to fully explain
the exact ranking, but the floor itself - "150 is unsafe, 200+ is safe" -
is a clear, robust signal). Adopted DEFAULT_FETCH_DPI = 250 in
local_identify_printing_tags.py (a --fetch-dpi CLI flag, 0 for
uncapped) - a margin above the empirically-best 200, hedging against
small-sample noise while keeping most of the win (728KB vs. 1.84MB
native, 2.5x smaller). Pilot-only: pdfImage.ts/downloadImages.ts
are untouched and still request full print-quality resolution by design.
Tesseract's TSV bbox output, sampled across the same 30-card sample
(both preprocessing polarities), showed every observed collector-number-
shaped text line landing within the top 41.2% / right-hand 74.4% of the
existing crop's own area - meaning the bottom ~59% and left ~26% were
dead space. Tightened local_ocr.DEFAULT_CROP_BOX from
(0.0, 0.90, 0.35, 1.0) to (0.06, 0.90, 0.35, 0.965), applying a
safety margin over the observed range (not cutting exactly to it, per
the addendum's explicit bleed-variance caution) and leaving the right
edge untouched (text was observed touching that boundary already -
trimming it would risk clipping, not save anything). Validated, not
just derived: re-ran OCR with both the old and new box against the
same 30 cards - identical match count (8/30 both) AND identical card-
level match set (same 8 card pks matched both ways) - zero yield
regression on this sample. See local_ocr.py's DEFAULT_CROP_BOX
comment for the full derivation.
Checked whether a bleed tag already existed before proposing anything
new, per the addendum's explicit gate: appropriate-bleed already
exists (sensitive_tags.py, 0 cards tagged) - but as a
TagModerationClass.SENSITIVE tag, the same category as low-res/NSFW,
with its own code comment: "Sensitive because that verification is
exactly a moderator's co-sign." It was designed for human-only
verification. Surfaced this to the owner before building anything -
decision: proceed, cast machine votes on the existing tag anyway (a
SENSITIVE tag still requires a moderator co-sign to resolve either way,
so a vote alone can't misuse it - it's one more signal moderators see,
not an override).
Detection design, owner-directed: measure the image's own aspect
ratio against chilli_axe's two known reference card sizes
(frontend/src/common/constants.ts's CardWidthMM/CardHeightMM =
63x88mm trim; +1/8" bleed per edge = 69.35x94.35mm) rather than any
pixel-color heuristic - purely geometric, so it's inherently
DPI/resolution-independent (verified: 0/15 mismatches between native and
--fetch-dpi=250-scaled classification of the same real cards - Google's
resize preserves aspect ratio) and unaffected by whether the card's own
border is visually a normal frame or a borderless full-art printing
(both follow the same file-dimension convention regardless of what's
visible).
TRIM_ASPECT_RATIO = 63/88 ≈ 0.7159BLEED_ASPECT_RATIO = 69.35/94.35 ≈ 0.7350
Validated against a real, source-diverse sample (one card per
distinct source, 40 sources, not the earlier 30-card OCR-selection
sample - this needed source diversity, not OCR-selection-order
diversity): a clean, well-separated bimodal signal. Every source but one
clustered tightly at ratio 0.7325-0.7393 (bleed present); the one
exception measured 0.7163, matching the theoretical trim ratio almost
exactly. Nothing observed in the gap between clusters. Classification:
nearest-reference-ratio, abstaining (no vote) when the ratio is more
than 0.03 from BOTH references (classify_bleed_edge,
local_fallback.py) - comfortably covers the observed real spread on
either side while still abstaining on a genuinely non-standard image
(a DFC composite scan, a token, a corrupted fetch).
Wired into run_pilot: fires for every card with a fetched image,
independent of printing-vote success (same "double duty" convention as
border/frame attribute votes) - classification is censused
(AttributeReport.bleed_votes_by_class) for every card regardless, but
see the negative-only voting change below for what actually gets
written. VoteSource.OCR, confidence 0.7 (BLEED_EDGE_VOTE_CONFIDENCE).
No ground-truth counterpart to prefer - unlike border/frame, Scryfall
doesn't encode this at all.
Negative-only voting (2026-07-16, consolidated respec item 4b -
supersedes the original both-directions design above): a vote is now
cast ONLY for a trimmed reading (NOT_APPLICABLE) - a bleed reading
(the ~97.5% common case per the 40-source validation) still counts
toward the census but writes NO CardTagVote at all. Absence of any
vote is the documented convention for "presumed normal bleed" -
updated in sensitive_tags.py's SENSITIVE_TAGS comment alongside this
doc, since the tag's original design comment said the opposite
("absence just means not yet verified") from before this pilot existed.
Rationale: appropriate-bleed is SENSITIVE and needs a moderator
co-sign regardless of machine votes - voting APPLY on the routine 97.5%
case would flood moderation with confirmations of normalcy instead of
surfacing the rare real exception, which is what a SENSITIVE tag is for.
Confidence unchanged (0.7). No new tag seeded - the existing-tag check
(Tag.objects.filter(name=...).first(), degrades to no vote if absent)
was already in place before this change.
This replaces the pilot as the answer. All three chip families —
border colour, frame style, bleed edge — are cast by evidence-reading
casters that fetch no images, and both modules are wired into the
streaming conveyor (stage_e_dispatch._run_stage_d):
| chip family | module | identity |
|---|---|---|
| Black/White/Silver Border, Borderless | local_layout_class_cast |
layout-class-cast-v1 |
| Old Border, Modern Border | local_attribute_chip_cast |
frame-style-cast-v1 |
| appropriate-bleed | local_attribute_chip_cast |
bleed-edge-cast-v1 |
Each also has a standalone --write-gated management command of the same
name. Frame style and bleed edge get separate identities because the
bleed chip is negative-only: under one shared identity a card's frame vote
would read as "handled" and permanently strand its bleed chip.
Why this exists. local_fallback's three cast_*_vote functions were
reachable only from local_identify_printing_tags.run_pilot (which
FETCHES every image, and has one completed run in its history) and from
image_evidence.extract_card_evidence, which had zero production
callers — both engines call compute_card_evidence/persist_evidence
directly. So neither engine could cast any chip. After the 2026-07-29
purge, Old Border, Modern Border and appropriate-bleed sat at zero
machine rows with nothing able to re-derive them; border colour survived
only because local_layout_class_cast independently computes the same
thing. extract_card_evidence is now
fetch_and_compute_card_evidence_for_tests, named for what it is, and
casts nothing. The pilot's own casters are unchanged and still fire on a
pilot run; they are simply no longer the only path.
Zero image fetches, and that is the point. Every input is already
stored on ImageEvidence: classify_frame_style reads
collector_line_collector_number and illus_anchor_fired; the bleed chip
reads bleed_class. Re-deriving these through the pilot would have meant
re-fetching ~220,000 images to recompute facts already in the database.
Derivable populations, measured read-only 2026-07-29: Modern Border
133,627, Old Border 9,006, appropriate-bleed 2,786.
The frame chip gates on artist_ocr, not only on collector_line_ocr.
illus_anchor_fired is nullable and bool(None) is False, which is
indistinguishable from "the extractor ran and found no anchor" — so
without that gate every card missing artist_ocr would read modern.
That is a manufactured vote from evidence that does not exist, and it is
the same failure mode that lets a genuine old-frame card be vetoed
frame-mismatch in Stage D.
Live, read-only cross-reference of Card.dpi (computed once at import
time, update_database.py) against both places tag state lives -
Card.tags (resolved/baked) and CardTagVote (raw votes, including
anything pending a moderator co-sign) - for the low-res SENSITIVE tag
specifically. No votes cast, no code changed; report only, per the
addendum's own scope, and low-res itself stays untouched by this item
(distinct from appropriate-bleed above - see the "Future work" note
below for a follow-up idea that WOULD vote on it, deliberately deferred).
Findings (218,152 cards, live 2026-07-15):
| dpi bucket | count | resolved low-res
|
pending vote | neither |
|---|---|---|---|---|
| 0 (unset) | 4 | 0 | 0 | 4 |
| 1-99 | 7 | 0 | 0 | 7 |
| 100-149 | 9 | 0 | 0 | 9 |
| 150-199 | 3 | 0 | 0 | 3 |
| 200-299 | 40 | 0 | 0 | 40 |
| 300+ | 218089 | 0 | 0 | 218089 |
Query mechanics sanity-checked before trusting an all-zero result:
the same resolved/pending query pattern run against tags known to
have real production data - NSFW (339 resolved, via filename-bracket
import tagging, not the vote flow), and custom-art/AI-Generated/
Borderless (1, 1, 13 genuinely pending via the exact same
tag_votes__tag__name=... pattern used above) - all returned correct
nonzero counts. The low-res all-zero result is a real finding, not a
broken query.
Two things worth flagging, neither actionable within this item's scope:
-
99.97% of cards already report full 300dpi -
Card.dpiisn't a useful prioritization signal on its own; the sub-300 tail is 63 cards total across the whole catalog. -
The
low-restag has never been used, anywhere, by anyone - 0 resolved, 0 pending, independent of dpi bucket. The report-flow (CardReportReason.LOW_QUALITY->low-resCardTagVote,sensitive_tags.REPORT_REASON_TO_TAG_NAME) exists in code and has never actually been exercised in production. Not a bug - just means there's no existing signal to reconcile against yet, and any future automated low-res detection (see below) would be establishing this tag's first real usage, not correcting drift from manual reports.
Future work: art-crop-specific DPI check + Scryfall comparison
(2026-07-15, flagged by owner during this item, deliberately deferred -
not built). Card.dpi measures the FULL card image's resolution, which
this audit shows is essentially always fine (99.97% at 300dpi) - but a
proxy can have a perfectly fine full-image dpi while still having a
genuinely blurry/undersized ART specifically (upscaled source, a bad
crop-and-stretch, etc.), which Card.dpi can't see. Sketched design,
explicitly NOT built this pass:
- Reuse
local_phash.ART_CROP_BOX(already the art-region fraction used for phash matching - one definition, not a second one) to crop the art region out of the pilot's own fetched image. - Reuse the just-built
classify_bleed_edgeresult to pick the correct physical reference height per card (trim 88mm vs. bleed-inclusive 94.35mm - see the bleed-edge section above) before converting the crop's pixel height to a real DPI number, rather than assuming one. - Cross-check against Scryfall's own official
art_cropimage for the same printing (local_phash._fetch_scryfall_art_crop_urlalready fetches this, reused not reinvented) as a second, comparative signal - independent of the absolute-DPI estimate, catches "much smaller than the official art for this exact card" even if the physical-mm math has slack in it. -
This is additive, not a replacement for
Card.dpi-Card.dpistays as the whole-image import-time measurement it already is; this would be a second, art-specific signal alongside it, for a different failure modeCard.dpistructurally can't catch. -
Goes straight to the moderation pipeline when built -
low-resisTagModerationClass.SENSITIVE(same property established forappropriate-bleedabove: a moderator co-sign is required to resolve either way, so a machine vote alone can't misuse it), so this would cast realCardTagVotes, not just report - unlike this item's DB-audit scope, which deliberately doesn't. - Deferred rather than built now because it needs its own validation pass (a real sample, Scryfall-vs-source comparison, a derived threshold with a safety margin - the same discipline every other detector in this pilot got) before casting anything real, and this item's own scope was report-only.
Owner's question mid-item-8: since bleed classification is cheap and
purely geometric, should it run FIRST, ahead of everything else, so its
result can normalize every OTHER fixed-fraction crop box in the pipeline?
Investigated rather than assumed: local_ocr.DEFAULT_CROP_BOX,
local_phash.ART_CROP_BOX, local_fallback.ARTIST_CROP_BOX,
local_fallback.SYMBOL_STRIP_BOX, and local_fallback._BORDER_SAMPLE_BANDS
are all fixed-fraction boxes empirically tuned against real fetched images -
which are ~97.5% bleed-inclusive (the 40-source bleed sample above). That
means they're already correctly calibrated for the bleed-inclusive
majority; the ~2.5% TRIMMED minority is the one case where a box tuned
against a bleed-inclusive image lands in the wrong place, since removing
the bleed margin shifts where the same physical card position falls as a
fraction of the (now smaller) full image.
local_fallback.normalize_crop_box(box, bleed_class): a no-op for
'bleed' or None (abstain); for 'trimmed', rescales each fraction by
the same margin-fraction math derived from the bleed-edge section's own
reference geometry (_WIDTH_MARGIN_FRACTION = 3.175 / 69.35 ≈ 4.58%,
_HEIGHT_MARGIN_FRACTION = 3.175 / 94.35 ≈ 3.37% per edge). Threaded
through all five crop sites via a new bleed_class parameter on
classify_border_color, detect_illus_anchor, find_symbol_matches,
run_ocr_for_card, and local_phash.compute_card_art_hash.
The border-sample bands got a real empirical check, not just the
derivation, before being included - their sample position sits close to
where the bleed margin lives (0.03-0.05 fraction from each edge, inside
the ~3.4-4.6% margin), which could in principle mean the EXISTING
(unmodified) bands were already misreading bleed-inclusive images, not
just needing a trimmed-image fix. Sampled 15 real bleed-classified cards
with and without the remap applied: solid-color borders (the common case,
most sources) read IDENTICAL RGB regardless of exact sample position -
border color extends uniformly through the bleed margin in real print
prep. Confirms the existing bands are correct for the majority as-is, and
normalizing is safe to apply unconditionally (a no-op there anyway, since
it only activates for 'trimmed').
run_pilot's per-card processing now classifies bleed FIRST (before
OCR/phash/border/frame/symbol/artist), immediately after image fetch -
see _compute_card's docstring.
Measured the real constraint before designing anything: this box has
2 CPU cores total, shared with 5 live production containers (Django,
worker, nginx, Postgres, Elasticsearch) - not an abstract "how many
threads" question, a genuine resource-contention one. Also found (while
setting up the measurement) that mpcautofill_django doesn't have
tesseract installed at all - confirms the real pilot run's host-venv
execution path is the ONLY one that currently works, not just how it
happened to be run (relevant to item 4's install-path decision).
Live-contention test, not a synthetic benchmark: 10 real candidate
cards, dry, fetch+OCR+phash only, run against the live production DB
while a separate probe hit the live API's 2/languages/ endpoint
locally (bypassing Cloudflare) every ~0.3s, comparing latency across
three conditions:
| condition | mean latency | p95 latency | wall clock (10 cards) |
|---|---|---|---|
| idle (no pilot load) | 79.8ms | 94.7ms | - |
| sequential (today's behavior) | 88.7ms | 126.1ms | 13.42s |
| 2-worker concurrent | 93.9ms | 135.7ms | 6.34s |
Only ~5ms extra mean latency for 2 workers over the ALREADY-EXISTING
single-threaded impact, for a near-ideal ~2.1x wall-clock speedup
matching the core count exactly - tesseract's subprocess-based OCR
genuinely parallelizes here (the GIL releases during the subprocess
wait). DEFAULT_WORKERS = 2 adopted as the new default.
Design: split compute from writes, not a full concurrent rewrite.
_compute_card (new) does the parallelizable half - fetch, bleed
classification (first), OCR, phash, border/frame classification, pass-2
fallback - as a pure function with no DB writes and no shared/nonlocal
state, safe to run via ThreadPoolExecutor.map() (which preserves
submission order in its results regardless of completion order).
run_pilot's own loop - votes_batch/tag_votes_batch staging,
disagreement bookkeeping, the ground-truth-preferred attribute override,
the frame-mismatch consistency check, flush/gate-check - stays
single-threaded and in selection order, completely UNCHANGED from
before this split; only where its input comes from changed. Chunked at
batch_size granularity (reusing checkpointing's existing boundary,
item 2) rather than a second batching concept - each chunk's compute
pool completes before that chunk's writes are staged and flushed.
OMP_THREAD_LIMIT=1 set (via os.environ.setdefault, respects an
operator's own override) whenever workers > 1 - without it, N
concurrent tesseract subprocesses could each ALSO try to multi-thread
themselves internally, oversubscribing this box's 2 real cores well
beyond workers.
Fetch budget is now checked between chunks, not per-card - a chunk
already in flight always completes once started, so the real bound on
an overshoot is one chunk's worth of fetches (<= batch_size), not
zero. Consistent with the belt-and-suspenders framing already
established for --fetch-budget (item 3b) - the real enforcement is the
Worker's own IMAGE_FULL_TIER_RATE_LIMITER, not this counter.
A real threading bug found and fixed while writing the tests, not
just a design risk avoided in the abstract: run_fallback_for_card's
own CanonicalCard.objects.filter(...) query, executed from a worker
thread, silently returned empty under pytest-django's default
(non-transactional) db fixture - a worker thread opens its own DB
connection, which can't see an uncommitted test transaction only the
original connection is inside. Exact same root cause and fix
(transactional_db, real commits, TRUNCATE-based cleanup) as
test_sources.py's pre-existing test_all_sources_scanned_concurrently_local_file
for update_database()'s own worker threads - this is a known, already-
established pattern in this codebase, not a new problem. Not a
production concern (committed data is visible across connections/threads
fine); a test-fixture-only issue, but a real one - the failing assertion
caught it, not a code review guess. New TestConcurrency test class
(transactional_db-based) validates workers>1 finds a real cross-thread
DB match, workers=1 and workers=4 agree on the same input, and
OMP_THREAD_LIMIT is set/unset correctly.
--workers CLI flag added (default DEFAULT_WORKERS = 2, --workers=1
disables concurrency entirely).
Explicit correction from the owner, applied here: the original
projection couldn't reuse item 3a's phase-timing numbers unmodified -
those were measured against NATIVE-resolution fetches, and --fetch-dpi
didn't exist yet. Re-measured directly rather than assumed:
-
Fetch latency at the real default (
dpi=250): 20 real direct fetches against the live CDN Worker, mean 0.509s (vs. item 3a's native-resolution mean of 1.187s - a real, not assumed, 57% reduction). -
Full per-card compute cost (fetch + bleed-first + OCR + phash +
border/frame + pass-2 fallback, everything
_compute_carddoes) on 15 real candidate cards: 2.520s/card sequential, 1.568s/card at 2 workers (1.61x speedup) - notably LOWER than the 2.1x seen in item 3d's own narrower fetch+OCR+phash-only benchmark, becausedetect_illus_anchorand pass-2 fallback (item 3a's two LARGEST cost components, 33% and 23.2% respectively) also make their own DB queries and tesseract calls, which don't parallelize quite as cleanly as pure fetch+OCR+phash did. 1.61x, not 2.1x, is the correct real figure for a full-catalog projection - flagging this discrepancy explicitly rather than letting the earlier item-3d number stand uncorrected. -
A real 300-card (
--limit 300, 392 candidates processed - both engines' selections union) dry-run at the CURRENT code (dpi=250, bleed-first, crop-tightened, 2 workers), timed end-to-end via the actual management command: 12m10s / 392 = 1.863s/card for compute + the frame-mismatch consistency check + ground-truth-metadata lookup (dry-run skipsbulk_create/the gate check entirely - can't measure that component this way). A genuine write-enabled run was attempted first and correctly blocked by the auto-mode classifier - HOLD #2 gates scaled DB-writing runs, and a fresh 300-card write wasn't pre-cleared for this specific measurement; pivoted to--dry-runinstead, which still exercises real fetch/compute/consistency-check cost.
Reconciling the three measurements: 1.863 - 1.568 = 0.295s/card is
the consistency-check + ground-truth-lookup overhead alone, at 2 workers
- and since that portion runs single-threaded in the main loop
regardless of
workers(only the compute half is parallelized), it's aworkers-invariant constant. The one component with NO fresh measurement isbulk_create/verify_zero_resolutions's gate-check cost (write-path code, untouched by items 3b/3c/3d) - reusing item 3a's own residual (old real total 6.42s/card minus old compute-only 4.46s/card minus this same 0.295s/card consistency-check estimate = ~1.665s/card inferred write-path cost). Two independently-derived estimates cross- validate within 0.1%:
| projection | compute | consistency-check | write-path (inferred) | total |
|---|---|---|---|---|
| single-threaded (now) | 2.520s | 0.295s | 1.665s | 4.480s |
| 2 workers (now) | 1.568s | 0.295s | 1.665s | 3.528s |
| single-threaded (OLD) | -- | -- | -- | 6.42s |
Full-catalog projection (171,853 cards - the live union of both engines' eligible pools, fresh count 2026-07-15, up from the ~171,878 figure quoted earlier in this doc - natural drift as votes accumulate):
| scenario | s/card | wall clock |
|---|---|---|
| OLD (native fetch, single-threaded) | 6.42s | ~12.8 days |
| NEW (dpi=250+bleed+crop, single-threaded) | 4.48s | ~8.9 days |
| NEW (dpi=250+bleed+crop, 2 workers) | 3.53s | ~7.0 days |
~45% wall-clock reduction from items 3b/3c/3d combined (12.8 → 7.0
days) - real, substantial, and cross-validated by two independent
derivations. Still a full week of continuous host-process runtime -
this is the single most consequential number for item 4's scheduling
decision (chunked scheduler slices vs. one continuous screen'd process):
a naive one-shot week-long run on a box that also serves live production
traffic is a real operational risk regardless of --nice throttling
(no natural checkpoint against an OS update, a reboot, a multi-hour
network blip - though item 2's checkpointing does bound how much work
any single interruption loses). The one inferred (not freshly
re-measured) component - write-path cost - should be validated with a
real, HOLD #2-cleared write run before this projection is treated as
final; the write-path code itself is unchanged by any of this session's
work, so reusing the old measurement is a reasonable but not yet
re-confirmed assumption.
A wrong hypothesis, tested and rejected before it reached this doc.
The item 3e dry-run showed phash yield collapse to 0/300 (0%), down from
the baseline run's 13/300 (4.3%) - and the skip-count redistribution
looked suspiciously exact (13 fewer votes, +8 no-clear-winner,
+5 too-many-candidates, exactly 13). The obvious suspect: --fetch-dpi =250 was only ever validated against OCR yield (item 6/3c's sweep
explicitly used "the production OCR pipeline" at each dpi) - never
against phash, which hashes the SAME fetched image. Tested directly
rather than trusted the correlation: computed phash match outcomes at
native vs. dpi=250 resolution for 12 real current-pool multi-candidate
cards - zero difference in outcome on any of them. The dpi
hypothesis is rejected.
More likely explanation: the candidate POOL itself shifted between the two runs - the baseline run wrote 94 real votes (including 13 phash matches), which are now excluded from selection (idempotence), so the "next 300" cards by the selection ordering are a genuinely different set than the original 300. Phash's yield is small enough (4.3% at best) that which specific 300 cards happen to be sampled plausibly explains more of the swing than any code change does. Not fully resolved - flagged honestly as unexplained sample volatility, not asserted as a solved mystery.
Verdict: keep phash as-is, no further tuning. Real but small contribution (13/300 in the one sample with a nonzero count), zero false-positive risk (the distance+margin gate is strict - a wrong-but- confident vote has never been observed), and negligible cost (item 3a: 0.011s/card, 0.2% of total time) - there's no strong case for either investing more tuning effort or dropping it. Its abstention rate is simply the honest floor for this signal at pilot scale.
The real constraint driving this decision: item 3e's ~7.0-day (2 workers) / ~8.9-day (single-threaded) full-catalog projection is a multi-day-to-week continuous workload on a box that also serves live production traffic. Two scheduling shapes were compared, not assumed - checked against what actually exists in this codebase before proposing anything new.
A real, load-bearing tension found while checking, not assumed away:
django-q2 infrastructure already exists here (Q_CLUSTER in
settings.py, an already-running mpcautofill_worker container whose
entire job is python3 manage.py qcluster, an existing daily
update_database schedule seeded via migrations 0043/0048). Its
Q_CLUSTER config sets cpu_affinity: 1 - a deliberate reservation
(alongside timeout: 12 hours, "extreme upper limit") that reads as
intentionally protecting this box's OTHER core for live traffic, not an
arbitrary default. That directly conflicts with item 3d's validated
--workers=2 default - scheduling the pilot through the EXISTING
cluster would effectively cap it at 1 core, meaning the real achievable
rate under that path is the ~8.9-day single-threaded projection, not the
~7.0-day one, unless a second, dedicated cluster/queue with different
affinity is stood up specifically for this workload (more infrastructure
complexity, not evaluated further here - the addendum's own scope is
"a decision," not a second scheduler).
Option A - screen'd host process, --workers=2 (recommended):
- Works TODAY with zero infrastructure changes - tesseract is already
installed on the host (
/usr/bin/tesseract, confirmed while measuring item 3d), and the host venv used for every real run in this session already proves the path works against the live DB. - Gets the full validated ~7.0-day projection - the only option that
does, since it isn't constrained by the existing cluster's
cpu_affinity=1. - No new Dockerfile/image rebuild, no new
Schedule/queue infrastructure to build and validate. -
Real gap, not glossed over: the live-latency-contention
measurement (item 3d) that justified
--workers=2as safe was a 10-card, ~20-second burst test - it validates "briefly safe," not "safe sustained for a full week." A longer soak measurement (a few hours, not 20 seconds) against live traffic latency is a reasonable ask before actually launching a multi-day run, and is flagged here as a residual open item for the HOLD #2 package, not resolved by this proposal. - Lifecycle is manual (
screen, not django-q's built-in retry/crash handling) - partially mitigated by item 2's checkpointing (a kill loses at most one--batch-sizeworth of unflushed work and resumes cleanly on restart), but still needs a human or acronre-invocation after a real crash, not automatic retry.
Option B - chunked django-q nightly slices, existing cluster:
- Reuses established, already-working infrastructure - the exact
Schedule.objects.create(func="django.core.management.call_command", args="'local_identify_printing_tags', '--limit', 'N', ...", schedule_type="D")pattern already seedsupdate_database/import_canonical_card_datatoday (migrations0043/0048) - a new migration doing the same for this command is a small, low-risk, precedented change. - Gets django-q's existing retry/crash-recovery machinery for free.
-
Requires a Dockerfile change:
mpcautofill_workerbuilds from the samedocker/django/Dockerfilebuilderstage asmpcautofill_django(confirmed by reading it) - neither has tesseract; addingtesseract-ocrto that stage'sapt-get installline and rebuilding both images is a real, concrete requirement, not a formality. - Bound by
cpu_affinity=1unless a second cluster is built (see above) - realistically the ~8.9-day single-threaded rate, spread across many nights at whatever--limitfits comfortably inside a night's window (well under the cluster's 12-hour task timeout, to leave real margin - a nightly--limitsized for ~2-4 hours, not 12, is the safer target).
Recommendation: Option A for the eventual full-catalog run - it's faster, needs no new infrastructure, and the pilot's own checkpointing already covers most of what django-q's retry machinery would otherwise buy. Revisit Option B only if the longer soak-test flagged above turns up a real sustained-load problem Option A can't tolerate.
Host-venv disposition - a real, currently-open gap: every real run
in this session used
/home/ubuntu/.claude/jobs/4495614d/tmp/venv - a job-scoped path that
is cleaned up when this Claude Code job ends. Whichever option is
eventually chosen, a permanent venv needs to live somewhere stable and
documented (docs/infrastructure.md) before an unattended host-process
run is launched for real - this doesn't block anything in this pre-scale
program itself (every measurement in this doc was already real, run
against the live DB), but it's a genuine loose end for whoever actually
launches the full-catalog run.
Closes the "host-venv disposition" gap flagged in the scaling proposal
above: docker/django/Dockerfile's shared builder stage now installs
tesseract-ocr tesseract-ocr-eng alongside the existing
dos2unix gcc netcat-traditional curl libpq-dev line - both the
webserver and worker targets inherit it since they both FROM builder. Verified end-to-end, not just "image builds": rebuilt the
worker image and ran
python3 manage.py local_identify_printing_tags --dry-run --limit 3 --skip-checks
inside a one-off docker compose run --rm worker ... container against
the real live DB - tesseract resolved (/usr/bin/tesseract, v5.5.0),
OCR/phash/fallback/attribute voting all executed and reported real
(dry-run) output. The job-scoped host venv
(~/.claude/jobs/4495614d/tmp/venv) used for every prior measurement in
this doc is now retired - no job dependency for this recurring task
lives outside the image anymore.
Build-context note: docker/django/check_client_secrets.sh and
check_drives.sh require MPCAutofill/client_secrets.json and
MPCAutofill/drives.csv to exist in the build context (both gitignored,
real content only on-disk per CLAUDE.md) - a fresh worktree doesn't
have them by default, since worktrees share git history but not
untracked files. Verifying this Dockerfile change from a worktree
required temporarily copying those two files plus docker/.env (needed
at container-run time for SECRET_KEY/DB config) in from the main
checkout, with explicit user go-ahead for each, and deleting all three
immediately after the verification container run completed. This is a
one-time verification cost, not a recurring one - normal builds/deploys
happen from the main checkout, where these files already live natively.
This does not change the Option A vs. B scheduling recommendation above
(still Option A, screen'd host process) - it only changes how the
command executes (containerized instead of a host venv) once a
scheduling shape is chosen, and removes one of Option B's stated
requirements ("Dockerfile change...adding tesseract-ocr") since that
part is now already done regardless of which scheduling path is picked.
Full respecification from the owner, superseding the earlier AskUserQuestion-confirmed interpretations - implemented verbatim, not re-derived.
Item 1 - coverage-gap prioritization: select_candidates's ordering is now a full 5-key
tuple, REPLACING the old "multi-candidate names first" primary split entirely (that split is now
only tiebreak #4, "fewer candidates"): (1) names with zero covered printings first, (2)
descending count of uncovered printings, (3) demand rank (item 3), (4) fewer candidates, (5) pk.
"Covered" (compute_covered_printing_pks): a printing has >=1 Card with canonical_card
pointing at it (a confirmed indexing match, no RESOLVED gate needed - already a direct,
non-vote-based signal) OR inferred_canonical_card pointing at it with
printing_tag_status=RESOLVED - gated on RESOLVED specifically so a machine vote pending human
confirmation does NOT count as coverage, per the owner's explicit clarification. Computed fresh
on every run_pilot call (never cached across invocations), so a nightly slice's ordering
reflects human confirmations made in the queue since the previous slice. Fully-covered names
still process, just LAST - redundant identifications add real value (image choice per printing,
coverage-independent border/frame attribute votes). New report metric,
AttributeReport.uncovered_printings_closed: of the printings in scope this run that were
uncovered at the start, how many are covered by the end - the run's real progress metric per the
owner ("that number, not raw votes"). Almost always 0 for a machine-only run BY DESIGN, not a
bug: a pilot vote is never a direct resolve (the gate check asserts this structurally), and
"covered" explicitly excludes unresolved machine votes - a printing only counts as closed once a
human confirms it in the queue, which is what item 5 (follow-up) is for.
Item 3 - demand order via edhrec_rank: already existed as a schema field
(CanonicalPrintingMetadata.edhrec_rank, populated by the existing printing_metadata_import
Scryfall bulk-data import) - checked live before assuming it needed adding: 101,133/113,224 rows
(89.3%) genuinely populated. CandidatePrinting now carries edhrec_rank (fetched via
CandidateNameIndex's existing single query, select_related("printing_metadata") - zero extra
queries). A name's demand rank is the MINIMUM edhrec_rank across its candidates (its most
popular printing, not an average) - missing ranks (~10.7% of rows) sort LAST via a large
sentinel, not first, so "no demand signal" never masquerades as highest-priority. Public Scryfall
data, zero user tracking - explicitly the zero-telemetry-policy-clean substitute for a previously
-parked export-popularity-ordering idea.
Item 4 - skip-before-fetch: RESOLUTION_FLOOR_DPI = 200 (the actual empirical floor from the
6-way dpi sweep above - NOT DEFAULT_FETCH_DPI = 250, which is a safety margin above it) applied
against Card.dpi (computed once at catalog-import time from the source image's own pixel
height) directly in select_candidates' selection query (.exclude(dpi__lt=...)) - a source
image already below the floor is never fetched at all, not just never OCR'd. Card.size (raw
file bytes) is deliberately NOT used as a second condition despite the addendum's "dpi/size"
phrasing: it's a compression-dependent proxy with no empirical calibration behind it, unlike
dpi's direct, validated sweep - an unvalidated byte threshold would violate this pilot's own
"measure, don't assume" discipline. New PilotResult.skipped_below_resolution_floor counter
(count_below_resolution_floor, a separate COUNT query, cheap at full-catalog scale) - its own
report line, not folded into the existing skip_counts dict (which is populated downstream of a
fetch attempt; a selection-time skip never reaches that loop).
Sequencing note (owner-directed): items 3/4/1 ship together with item 2a (cluster dedup, no
schema change) as one PR. Item 2b (persisting content_hash for federation) and item 5
(questionFeed ordering mirror) are deferred, logged as follow-ups, not built here.
Verified: 82/82 pilot tests pass (15 new: TestCoveragePriority, TestDemandRank,
TestResolutionFloor, TestUncoveredPrintingsClosed), including a coverage-tier test that
specifically distinguishes "zero-covered" from "most uncovered" (a partially-covered name with
MORE absolute uncovered printings than a zero-covered name must still sort after it) and an
inferred_canonical_card-without-RESOLVED test (confirms an unconfirmed machine vote doesn't
count as coverage). mypy clean (MPCAutofill/, whole-package invocation). black/prettier
clean.
Before slicing, compute_own_image_clusters phashes OUR OWN eligible images (local only - no
candidate/Scryfall downloads) via the same local_phash.compute_card_art_hash the phash engine
already uses, and collapses distance-0 (EXACT 64-bit hash match) groups to one representative
(lowest pk, for determinism) - only representatives reach _compute_card; absorbed members never
run their own OCR/phash/border/frame/fallback at all. "One read answers N cards": an accepted
vote on the representative propagates as an identical CardPrintingTag (same anonymousid,
printing, confidence, source) to every absorbed member. Sound by construction: a distance-0 match
among OUR OWN uploaded images most plausibly means a duplicate/shared-source image, not
independent depictions that coincidentally look alike (that's the _candidate art-crop clustering
problem the phash engine already handles separately via DEFAULT_DISTANCE_THRESHOLD=20, a much
looser bar than 0) - identical image genuinely entails identical printing. Costs one extra fetch
per selected card (the clustering pass itself) to save the far more expensive per-card compute
pipeline on every absorbed duplicate.
Scoped to the printing-identification vote only, not border/frame/bleed attribute votes -
absorbed members never get their own image classified, so there's nothing of theirs to
propagate for those; a documented limitation, not a silent gap. Run-scoped only, no schema
change: no content_hash persisted anywhere (that's item 2b, deferred as a standalone future
task for federation-v1's content_hash groundwork). New AttributeReport.cluster_count/
cards_absorbed_into_clusters report fields.
A real idempotence gap found and fixed before landing, not just anticipated: a cluster
member can reach clustering via one engine's independent eligibility (e.g. phash) while already
carrying a vote from a DIFFERENT engine's anonymous_id from a prior invocation (the exact
reason it was excluded from that OTHER engine's selection this run). Propagating a same-
anonymous_id vote to it anyway would violate CardPrintingTag's own
(card, printing, anonymous_id) uniqueness constraint - checked once per run (a single query
across all cluster members, not re-queried per propagation call) and skipped, not attempted.
ocr_selected_ids/phash_selected_ids also get absorbed into the representative's own
eligibility (a representative must run an engine if EITHER it or any absorbed member was
independently selected for that engine), so clustering can't silently drop an engine's
eligibility just because the specific card that became the representative wasn't itself
originally selected for it.
Verified: 96/96 pilot tests pass (7 new: TestClusterDedup), including a test that caught a
genuine test-fixture bug during development (two different solid-color images accidentally
hashed identically - imagehash's DCT-based phash has zero frequency content on any uniform
fill regardless of color, so distinguishable synthetic fixtures need actual drawn shapes, not
just a different fill color; the production clustering logic was working correctly the whole
time), a test proving the double-vote/overwrite gap above is actually fixed (not just that some
vote exists), and a test proving the efficiency win itself (an absorbed member's card id is
never passed to run_ocr_for_card, not just that it ends up with a vote). mypy clean.
black/prettier clean.
Re-measured phase timing against the CURRENT code (post items 1/2a/3/4/4b) rather than trusting item 3a's original breakdown, which predates dpi=250, crop tightening, bleed-first classification, and clustering entirely. Real instrumented run, 50 selected candidates (representative-only, post-clustering), against the live DB/API - not simulated:
| phase | mean/card | share (uncorrected) |
|---|---|---|
fetch_card_image |
0.450s | 13.4% |
classify_bleed_edge |
~0.000s | ~0.0% |
| OCR (crop+preprocess+tesseract) | 0.478s | 14.3% |
| phash (hash+compare) | ~0.000s | ~0.0% |
border/frame (detect_illus_anchor+) |
1.206s | 36.0% |
| pass-2 fallback | 1.218s | 36.3% |
Measurement caveat, stated plainly: this run called fallback unconditionally for every
representative (not gated on pass-1's real accept/reject outcome), so its 36.3% share is
inflated relative to real run_pilot behavior (item 3a's original sample: fallback fires
~70% of the time). Corrected estimate using that same 70% rate:
0.450 + 0.478 + 1.206 + (1.218 × 0.7) ≈ 2.99s/card sequential, for cards that reach full
compute (clustering representatives only).
Bonus real data point from the same sample: 13/50 selected cards (26%) were absorbed into 10 clusters by item 2a's dedup - a materially higher rate than assumed, though from one 50-card sample, not a claim about the full-catalog rate.
The clear finding: this is CPU-bound, not I/O-bound. fetch_card_image is ~13% of
per-card cost; detect_illus_anchor-plus-border-classification and pass-2 fallback together
are ~72% (uncorrected) / ~65% (corrected). This directly answers throughput track item 2a's own
question: the "6-8 fetch threads, I/O-bound, no core needed" idea does not currently exist as
a mechanism - _compute_card's single ThreadPoolExecutor(max_workers=workers) runs fetch
AND OCR AND phash AND fallback all in the SAME worker, sized for CPU-bound work
(DEFAULT_WORKERS=2, matching this box's core count). Decoupling fetch into its own larger pool
would only ever attack the ~13% fetch share - a real potential improvement, but not the
dominant cost, and not built in this pass. This bottleneck split is the evidence that makes
manifest mode (item 2c) and a core-count resize (item 2b) the higher-leverage levers, not a
larger fetch pool.
Current instance shape (checked via the cloud provider's own instance-metadata endpoint,
no auth needed - exact shape/region kept out of this public doc, see CLAUDE.local.md/journal
for the specific values): core count matches DEFAULT_WORKERS=2's own derivation (item 3d)
exactly - this box has never had spare cores for a bigger pool without a resize.
This measurement is at the box's PRE-RESIZE core count (--workers 2) - it is the pre-resize
baseline, NOT the workers=3 post-resize number the resize decision is waiting on. A separate
post-resize soak test (same 250-card window, same selection/dedup) at a higher --workers count
on the resized shape is required before comparing - see the entry below once that lands. Do not
conflate the two numbers. (Exact shape/OCPU/RAM values kept out of this public doc - see
CLAUDE.local.md/journal.)
Real 250-card --dry-run --workers 2 run (not a burst - the prior --workers=2 safety
validation was only ~20 seconds/10 cards) against the live DB/API with live services running
normally. Clustering (item 2a) absorbed 70/250 selected candidates (28%) into 59 clusters before
the main loop even started, leaving 180 representatives actually processed - closely matching
the bottleneck-split sample's independently-observed 26% (13/50) absorption rate, two samples
now agreeing rather than one small anecdote. Total wall-clock ~400s (00:16:10 start to 00:22:50
log-file mtime), including container startup/migrate/collectstatic overhead (~45-60s fixed cost,
not pilot processing) - effective throughput ≈1.94s/card across the 180 processed
representatives, consistent with the previously-established top-down 1.863s/card figure from
the original 392-candidate real run. Caveat, stated plainly: this run's progress markers
(50/100/150-candidate checkpoints) weren't individually timestamped, so this confirms
AGGREGATE throughput held up over a real multi-hundred-card window (not just a burst) but
doesn't give intra-run stability granularity (e.g. whether the first 50 cards processed at a
different rate than the last 50) - a finer-grained timing pass would be needed for that
specific claim, not done here.
A real correctness gap found and fixed before trusting any throughput number from this
window: the first several 250-card soak-test runs at both pre- and post-resize core counts
showed 0/250 OCR votes - a stark regression from the original 94/300 baseline. Diagnosed live
by sampling real OCR output against real candidates for the first 8 selected cards: all 8 were
generic "Beast" tokens (source-uploaded images with card_type=TOKEN) with ~90 candidate
printings each across token-only sets. A token's printed collector line reads its PARENT set's
code (e.g. "MM3"), while its CanonicalCard candidates use token-specific set codes (e.g.
"tm3c") that never match - structural, not a parsing bug. Item 1's "descending uncovered count"
ordering was front-loading this near-0%-matchable cohort (huge candidate counts, near-zero
coverage) to the very front of every real selection. Fixed: _eligible_base_queryset now
filters to card_type=CARD only (excludes tokens and cardbacks) - confirmed via a fresh
eligible-pool count, 172,494 cards (the pilot's own real filtered count, not a naive
unfiltered query). Future work (not built): a token-aware matching path using Scryfall's own
token detection to search collector info or the set icon instead of the parent-set text tokens
don't reliably print.
Corrected before/after comparison, same 250-card window, re-run after the fix - OCR yield now healthy and consistent at both core counts (56/198 votes, 28.3%, matching the original 94/300 baseline):
| config (pre-resize vs. post-resize core count) | wall-clock | processing-only rate |
|---|---|---|
lower core count (--workers 2) |
456s | 2.051s/card |
higher core count (--workers 7) |
230s | 0.914s/card |
Speedup: 2.24x (up from an earlier token-contaminated measurement's 2.03x - the sequential clustering pre-pass, still unparallelized, see below, is a smaller share of a longer, more representative run). Full-catalog re-projection (172,494 eligible pool):
| raw (naive) | cluster-dedup-adjusted (incl. ~21.6h sequential clustering pre-pass) | |
|---|---|---|
| lower core count | 4.09 days | 4.14 days |
| higher core count | 1.82 days | 2.34 days |
The sequential clustering pre-pass (compute_own_image_clusters, confirmed via code inspection
AND a live docker stats capture showing a single-core-only ~100% CPU plateau during that
phase) is ~21.6h fixed regardless of core count - ~38% of total time at the higher core count.
Parallelizing that one loop (flagged as a follow-up, not built) remains the highest-leverage
lever to push below ~2.3 days. Verified via direct docker stats sampling (not just aggregate
system top) that the compute phase itself DOES achieve real multi-core parallelism (a peak of
~625% CPU observed, consistent with most of a 7-worker pool active simultaneously) once past the
pre-pass - an earlier read of aggregate top data alone had incorrectly suggested a GIL-bound
compute phase; the direct per-process measurement corrected that.
Classified all 176 OCR "parsed-but-no-match" cases from the pilot run
(reconstructed via selection-order stability, since the CLI doesn't
persist per-card raw text - a real gap, see the journal). Two real,
contained parser bugs found, both now fixed in local_ocr.py:
-
Set-code token position:
parse_collector_line's set-code search took the FIRST plausible 3-5 char token in the line, which is virtually always leading noise (a watermark, a rarity-letter glyph merging with a stray digit into something code-shaped) rather than the real set code that always follows the collector number in a genuine card layout. Fixed to search the text AFTER the number first, falling back to before only if nothing plausible follows. -
Collector-number leading zeros: OCR frequently reads a spurious
leading zero ("0093" for a real "93") that literal string comparison
silently rejected. Fixed via
_normalize_collector_number(strip leading zeros, keep any trailing variant letter) applied symmetrically to both the parsed reading and every candidate's stored value.
Yield delta, precisely measured (re-parsing the exact same 176 raw
texts with both the old and new logic, isolating exactly this cohort from
the 3 cards that already matched under the old parser): 47/176 (26.7%)
now match. Projected full-engine impact: OCR yield 77/300 (25.7%) →
~124/300 (41.3%), a ~60% relative improvement, from a small parser fix.
Confirmed live via a real (non-simulated) --dry-run afterward: 62/250
votes on a fresh selection window, consistent with the isolated measurement.
Yield reconciliation, old logic vs. new logic on that same 250-card window (no new OCR work run for this - reusing already-known numbers): selection-order stability means the 250-card window is the 223-card reconstructed cohort above (still eligible - no vote was ever cast on a skip) plus 27 cards never seen in the original 300-card pilot run at all. Old-logic yield on the 223 known cards is measured, not estimated: 3 (the "already-matching" cards, unaffected by the fix) out of 223 - the other 220 are old-logic non-matches by definition of how they were classified as skips. Old-logic yield on the 27 unseen cards is not measured - no new work was done to classify them - and is instead estimated by applying the original pilot's overall old-logic OCR base rate (77/300, 25.7%) to that count: 27 × 0.257 ≈ 7. Combined old-logic estimate: (3 + ~7)/250 ≈ 10/250 ≈ 4.0%, against the confirmed new-logic 24.8% (62/250) on the identical window - roughly a 6x relative lift here, well above the pilot-set's ~1.6x (60% relative) projection, because this window is disproportionately drawn from cards that were old-logic failures by construction (the 223-card skip cohort), not a representative sample of the full catalog. Treat the 24.8%-vs-4.0% comparison as the honest floor-to-floor number on hard cases, and the 41.3%-vs-25.7% pilot-set figures as the representative full-run projection - they are not the same statistic and should not be quoted interchangeably.
Of the 129 cases still unfixed: only 2/176 (1.1%) are genuinely-missing
printings (the parsed set code is real, but no CanonicalCard row exists
for that (set, number) at all); the remaining 127/176 (72.2%) are true
OCR garbage with no salvageable signal - a meaningful fraction of which
traces to one specific custom-frame Drive source
(Source pk=1, "WilfordGrimley", "Custom Cardbacks and alternate frames
with Upscaled images") whose non-standard branding text sits inside the
collector-line crop region and defeats OCR outright; not something a
parsing fix can address.
Cross-check against the filename tag-gap census (1,097 unresolved cards
with an unmatchable expansion_hint, from the pre-pilot addendum): NOT
the same root cause. All 1,097 have a fully recognized
CanonicalExpansion code (0 unknown) - the gap is a name-matching problem
(many are (Front)/(Back) filename-parsing artifacts on basic lands),
unrelated to the OCR token-position bug above. Two separate fixes, not
one parser fix arriving twice - the D2.5 deterministic tier is not
implied by this OCR fix and was not built.
- Backend:
cardpicker/printing_consensus.py,cardpicker/printing_metadata_import.py,cardpicker/integrations/game/mtg.py,cardpicker/models.py(migration0050_canonicalprintingmetadata_cardprintingtag_and_more.py;display_name— migration0056_tag_display_name.py, Stage 5),cardpicker/search/search_functions.py(Stage 3 re-rank/filter),cardpicker/documents.py(Stage 3 widened indexing; Stage 3.5reindex_card_safely),cardpicker/tag_consensus.py(Stage 3.5),cardpicker/reason_tags.py,cardpicker/default_tags.py,cardpicker/management/commands/seed_no_match_reason_tags.py(Stage 4, display_name seeding Stage 5),cardpicker/deductive_backfill.py+ management commanddeductive_backfill_printing_tags(Stage 6),cardpicker/question_feed.py,cardpicker/attribute_tags.py+ management commandseed_attribute_tags(Stage 7) - Frontend:
frontend/src/features/printingTags/(PrintingTagPicker.tsx,starburstShape.ts,cardPanel.tsx— the extracted sticky/starburst/ reveal/candidate-grid mechanics, Stage 7),frontend/src/features/filters/ResolvedAttributeFilter.tsx(Stage 3),frontend/src/common/processing.ts::getPrintingMatchLabel(Stage 3),frontend/src/features/attributeVoting/(ChipCard.tsx,NoMatchReasonStrip.tsx— Stage 4;QueueTagQuestion.tsx,ArtistVotePicker.tsx— reused directly by Stage 7),frontend/src/common/tagDisplayNames.ts(Stage 5),frontend/src/features/attributeChips/,frontend/src/features/ questionFeed/QuestionFeed.tsx,frontend/src/pages/whatsthat.tsx(renamed fromprintingQueue.tsx— Stage 7) -
docs/upstreaming/vote-system.md,docs/federation-v1.md(namevs.display_nameinterchange-key note, Stage 5) -
docs/features/catalog-completion-plan.md(iteration safety - Part 1 detail),cardpicker/utils.py(find_stale_applied_migrations,get_baked_git_sha),cardpicker/management/commands/purge_machine_votes.py, migration0061_pilotrunledger_cardartistvote_run_id_and_more.py -
cardpicker/local_layout_class_cast.py+ management commandlocal_layout_class_cast(public issue #369, "the Hidden Courtyard should register as borderless") - closes the gap between Stage C'sImageEvidence.layout_class(issue #148's geometry-group extractor) and an actualCardTagVote: any card whose Stage C evidence carries a confident border-color reading but never got a border-attribute vote cast by the live pilot/fallback engine (e.g. extracted before that engine ran, or whose printing resolved before reaching the fallback stage) is picked up by this standalone, zero-image-fetch caster instead. See that module's own docstring for the full mapping/anonymous-id/confidence-tier rationale - not duplicated here.
- The Stage 7 layout (starburst/card/chip-ring composition) was hand-tuned
via iterative screenshot review, not built against a real design system -
owner has flagged that this needs a proper pass with the
/datavizskill in the future rather than further ad hoc CSS tuning. -
CanonicalCard.image_hashwas bootstrapped to0for every row (--skip-image-hash) at import time; Stage 8's phash engine is the first thing to actually populate it, lazily and only for rows it needs - most of the table (any candidate no pilot run has hashed yet) is still at the placeholder0. - Client-side (Orama) search has no Stage 3 parity — see above.
- Upstreaming this feature is deprioritized — see Infrastructure's Upstreaming section.
- Tier-1
confirm_suggestionvolume (28,112) is confirmed via a direct live query, not the live-usage starvation impact - whether it actually swamps tiers 2-4 in practice (vs. just in raw candidate-set size) is a server follow-up. -
netPolarity's optimistic client-side update (set to the tapped direction's extreme immediately, reconciled with the server's real value once the response lands) isn't linear in vote count once machine/admin weights are involved - can't fully verify the two never visibly diverge against MSW mocks alone. - Border Color's v1 chip set omits gold/yellow
border_colorvalues, and the frame_effects chip set omitslegendary/inverteddespite higher raw counts than the chips that made the cut - both flagged as judgment calls in Stage 7 above, worth revisiting with real moderator/voter feedback. - Stage numbering: Stage 4 (no-match reason tags, merged as PR #12),
Stage 5 (tag identity/presentation decoupling via
Tag.display_name, merged as PR #14), and Stage 6 (deductive printing-tag backfill) reflect three concurrently-developed branches sharing this one doc file, numbered in landing order to avoid collisions. -
Future work: anonymous_id trust scoring via honeypot questions
(2026-07-15, raised during Stage 8's pilot run). Idea: periodically
serve a voter a card whose printing is already known with very high
confidence — ideally an already-
RESOLVEDcard (real human-backed consensus), Stage 6's D1 tier as a fallback pool (0 false positives across 27,424 live cards, but still machine-derived, not independently human-verified, so using it as "trusted" ground truth to police other submissions has a circularity worth being honest about) — without telling the voter it's a check, and score theiranonymous_idbased on whether they answer correctly. Deprioritize/downweight low-scoring anonymous_ids to make data poisoning more costly. Same crowdsourcing pattern as reCAPTCHA/Mechanical-Turk gold-standard questions. Known limitation before this is worth building:anonymous_idis a client-generated, trivially rotatable value (frontend/src/common/cookies.ts) with no persisted identity — a trust score raises the cost of poisoning (a fresh ID needed per abuse attempt) but doesn't stop a determined actor, so it's a speed bump, not a hard Sybil defense. Also a genuinely new subsystem, not a small addition: a honeypot-injection point inquestion_feed.py(nothing currently interrupts the three-tier ranked union with a planted question), somewhere to persist per-anonymous_idtrust state (no such model exists today), and a way to feed that score back intovote_consensus's per-source weighting — worth its own design pass rather than bolting onto an existing stage. -
Future work:
(Front)/(Back)name-matching fix for theexpansion_hintcensus gap (2026-07-15, deferred out of the pre-scale program by owner decision — deterministic parser fix, not part of Stage 8's OCR/phash engines). The 1,097-card filename tag-gap census (cards with an unmatchableexpansion_hint, all with a fully recognizedCanonicalExpansioncode) was cross-checked against the Stage 8 no-match autopsy above and confirmed to be a different root cause — a name-matching problem, not the OCR set-code-position/ leading-zero bugs the autopsy fixed. Many of the 1,097 are(Front)/(Back)filename-parsing artifacts on basic lands (a double-faced-card naming convention this catalog's name-matching doesn't strip before comparing againstCanonicalCard.name). Belongs withcardpicker.deductive_backfill's deterministic tiers (D1/D2), not Stage 8's visual-disambiguation engines — explicitly not the "D2.5 arriving for free" the autopsy's cross-check ruled out. Now tracked with real sizing, not just this note: issue #386 (2026-07-23, found live while investigating issue #372's own "Malakir Rebirth" sub-question) confirms the specific mechanism —DFCPairrows are correct and complete (this is NOT a missing-data gap), but neitherdeductive_backfill.CanonicalNameIndexnorlocal_identify_printing_tags.CandidateNameIndexconsultDFCPairat all, so a card uploaded under just one MDFC face's name (with or without an explicit(Front)/(Back)literal tag) never matches the combined"Front // Back"CanonicalCard.nameeither index keys on. 34 confirmed live in #372's own 135-card cohort alone (not the full catalog count). Deliberately left untouched by #372's own fix (a same-index, narrowly-scoped de-concatenation fallback for space-stripped filenames) — MDFC face names are correctly space- delimited already, so that fallback's own gate never fires for them. -
deductive_backfill.py's own votes don't carryrun_idyet (2026-07-16, iteration-safety Part 1's explicit scoping decision, not an oversight) — therun_idthreading in this section only coverslocal_identify_printing_tags.py/local_fallback.py's engines.deductive_backfill.run_backfill()would benefit from the same revocability property but wasn't in scope for this pass.
Synthesizing deliverable gating full-catalog run authorization. Everything below is either already-linked from earlier in this doc or newly summarized here; nothing in this section is a new claim not otherwise sourced above.
Infrastructure prerequisites - all landed:
- Rate limiter (PR #25): merged to master. Deploy confirmed live via direct requests against
cdn.proxyprints.ca's full tier (4 real requests, all HTTP 200, 0.46-1.67s latency, no 429s)- the underlying fetch path PDF export and bulk download both depend on is healthy
post-merge. (CI's "Publish image CDN" job shows red on every run, before and after this
merge - a separate, pre-existing, unrelated failure in a
thumbnail-refreshCloudflare Workflow trigger, not the image-serving route itself; logged as its own follow-up, task #111, not a gate.)
- the underlying fetch path PDF export and bulk download both depend on is healthy
post-merge. (CI's "Publish image CDN" job shows red on every run, before and after this
merge - a separate, pre-existing, unrelated failure in a
- Tesseract dockerized (
docker/django/Dockerfile's sharedbuilderstage) - verified end-to-end via a real--dry-run --limit 3inside the rebuilt container. Host venv retired. - Container boot-recovery hardened:
restart: unless-stoppedon all 5 services plus ampcautofill-docker-compose.servicesystemd unit as belt-and-suspenders - verified with a realsudo reboot, not simulated (all containers back up unattended within minutes, site returned 200 on both domains). - Batch-flush checkpointing (item 2): a kill loses at most one
--batch-size(default 25) worth of unflushed work; a plain re-invocation resumes cleanly via the existing idempotent selection query. Verified with a simulated-kill test.
Throughput, real and corrected:
A real correctness gap was found and fixed before trusting any number from this window: the
first several soak-test runs showed 0/250 OCR votes (vs. an original 94/300 baseline) - traced
to generic multi-set token names (e.g. "Beast", ~90 candidates each, essentially 0% coverage)
being front-loaded by item 1's coverage-gap ordering into a cohort that's structurally
unmatchable by OCR (a token's printed collector line reads its parent set's code; its DB
candidates use token-specific codes that never match). Fixed by excluding card_type=TOKEN/
CARDBACK from selection. Post-fix, OCR yield is healthy and consistent (56/198 votes, 28.3%,
matching the original baseline) at every core count tested.
Corrected same-window (250-card) before/after comparison, real docker stats-verified multi-core
parallelism (not just inferred from noisy aggregate top):
| core count | wall-clock | processing-only rate |
|---|---|---|
| lower | 456s | 2.051s/card |
| higher | 230s | 0.914s/card |
Speedup: 2.24x. Full-catalog re-projection (172,494 eligible pool, freshly counted with the token/cardback fix applied):
| raw (naive) | cluster-dedup-adjusted | |
|---|---|---|
| lower core count | 4.09 days | 4.14 days |
| higher core count | 1.82 days | 2.34 days |
The instance is now running at its higher core count as the standing configuration (not a temporary state for this measurement alone) - not reverting to the lower count, though not treated as permanently fixed either; revisit whenever convenient, no urgency either way.
Cluster + coverage census: clustering (item 2a) absorbed ~21% of selected candidates into
representatives in the corrected (token-excluded) sample - down from an earlier ~26-28% observed
in the token-contaminated sample, consistent with tokens/generic images being more prone to
visual duplication. The sequential clustering pre-pass (compute_own_image_clusters, confirmed
via code inspection and a live docker stats capture showing a single-core ~100% CPU plateau
during that phase specifically) is ~21.6h fixed regardless of core count - ~38% of total time at
the higher core count. Logged as task #108, held as an available future optimization, not built
now - the current projection is already a good number for a background job.
Track 4 (pilot-quality items):
- Bleed tag: negative-only voting shipped (item 4b) - votes only on a detected
trimmedreading, absence of any vote is the documented convention for "presumed normal bleed" (updated in both this doc andsensitive_tags.py's own comment, which previously documented the opposite pre-pilot convention). The existing-tag check (Tag.objects.filter(...).first(), degrades to no vote if the tag isn't seeded) was already in place before this change - no new tag seeded, matching the "wait for owner ok" instruction by construction. The underlying aspect-ratio classification itself was validated against a real 40-source diverse sample (Bleed-edge tagging section above) - the negative-only voting change is a polarity/gating change on top of that already-validated classification, not a new detection algorithm needing its own separate validation pass. - DPI-tag audit (item 8, report only): 99.97% of the catalog already at 300+dpi - not a useful
prioritization signal on its own.
low-resSENSITIVE tag has never been used in production (0 resolved, 0 pending) - stays untouched, human-judgment/moderation-gated as designed. Both tag stores checked (Card.tagsresolved/baked array andCardTagVoteraw votes).
Git/branch audit: clean. This session's branch (worktree-pilot-prescale, PR #24) is
in sync with origin, mergeable. PR #25 merged (rate limiter). PR #20 (unrelated frontend fix)
merged at the owner's request, reviewed and confirmed by the owner before merging. PR #19
(unrelated docs-only Playwright-flake note) remains open with a trivial, keep-both docs/lessons.md
conflict against master - not a dependency of anything in this program, disposition left to the
owner's convenience. Several other worktree branches exist but are either already merged or have
zero unique diff against master (content already landed via a different commit path) - no lost
work found anywhere in the audit.
Scaling recommendation, updated for the shorter true runtime: the original Option A
(screen'd process) vs. Option B (django-q nightly slices) decision assumed a ~7-day run,
where crash-recovery and unattended multi-night scheduling mattered enough to weigh a full
scheduler infrastructure investment. At the now-real ~1.8-2.3 day full-catalog runtime, a
single continuous run is the right shape - chunked nightly slicing is not needed. Item 2's
own batch-flush checkpointing already provides crash-resilience within that single run (a kill
loses at most one batch, a plain re-invocation resumes cleanly), which is the main protection
django-q's scheduler infrastructure would otherwise buy - not worth the added complexity for a
run this short. Execution is via the now-dockerized image (docker compose run, matching every
verification run this session), not a host venv - a screen/tmux-wrapped single invocation is
sufficient; no new infrastructure to build.
Open, non-blocking items (logged, not gates): item 2b (persist content_hash for
federation, deferred), item 5 (questionFeed ordering mirror, separate follow-up PR), task #108
(parallelize the clustering pre-pass), task #109's future-work note (token-aware matching via
Scryfall's own token detection), task #111 (unrelated CI noise in the thumbnail-refresh
trigger), PR #19's disposition (owner's convenience).
Full-catalog run: since fired and completed (see catalog-completion-plan.md's Status
section for final numbers). This report was the synthesizing deliverable requested before
that authorization.
Both researched and sized before building (see the HOLD #2 section above and this doc's earlier feasibility notes) - neither required schema changes, both reuse already-existing, already- populated data.
_narrow_candidates_by_expansion_hint (local_identify_printing_tags.py) narrows the
candidate list every engine considers, using Card.expansion_hint - a field that already
existed and is already populated at import time by cardpicker.tags.Tags.extract (a lone
set-code bracket token in the filename that didn't resolve a direct match, e.g. [UNF] with no
collector number). Not a new signal - just newly wired into the pilot; deductive_backfill's
own D2 tier already trusts this same field for direct resolution when it narrows to exactly
one candidate.
A confidence PRIOR, not an entailment: narrows the list passed to run_ocr_for_card/
run_phash_for_card/run_fallback_for_card inside _compute_card only - never touches
select_candidates's ordering, compute_covered_printing_pks, or the
uncovered_printings_closed metric, all of which need the true, unnarrowed candidate set to
stay correct. Never narrows to empty: if the hint matches zero of the name's real candidates (a
real, measured ~9% data-quality case - the hint may be stale or mismatched), the full list is
used instead.
Real yield, measured live: of 2,466 pilot-eligible cards with a real expansion_hint, 645
currently get skipped by phash outright (too-many-candidates) - narrowing brings 407 of those
back under PHASH_MAX_CANDIDATES, giving phash a real shot where it currently never runs.
OCR's own exact-match logic doesn't benefit (a smaller candidate list doesn't change whether a
parsed code+number is in it) - this is a phash-only unlock in practice.
run_name_frequency_elimination (new function, new management command
local_name_frequency_elimination) - for a NAME where exactly one printing remains uncovered
AND exactly one pilot-eligible card is unresolved for that name, the match is deducible by
elimination alone: no image fetch, no OCR/phash, no visual disambiguation at all.
The safety gate is the whole point, not a refinement. A name can have exactly one uncovered printing while SEVERAL unresolved cards share that name - in that case elimination does NOT tell you WHICH card is the missing one (any of the others could just as easily be a redundant depiction of an already-covered printing uploaded by a different source). The naive version (gate on "one uncovered printing" alone) was the original researched number; adding "and exactly one unresolved card too" is what makes the deduction airtight. Measured live against the full catalog (not a sample), 2026-07-16: 2,076 names have exactly one uncovered printing; only 1,678 of those also have exactly one unresolved eligible card - the naive version would have voted incorrectly, on average, for the other ~400 names' multiple candidate cards.
Confidence deliberately modest (0.6, vs. OCR/phash's 0.85/0.75/0.8) - a purely structural
deduction is weaker evidence than an engine that actually looked at the image, even with the
1:1 gate making it sound. Still just a vote (NAME_FREQUENCY_ANONYMOUS_ID), never a direct
resolve - same consensus/gate-check discipline as every other engine in this module, same
batch-flush checkpointing pattern as run_pilot.
Owner ruling: "just because a card was printed exactly once doesn't mean that the image in our catalogue is an accurate depiction of that card, it may have a different border or another issue."
Everything the 1:1 gate checks is a count. Counting establishes that if this card is a
depiction of one of the name's printings, then it must be the uncovered one. It establishes
nothing about the antecedent, and the only filters that spoke to it at all were the declared
custom-art and non-English tags — so an altered border, a custom frame, or a misnamed upload
that nobody had tagged yet passed the gate and received a full-confidence structural vote.
"It is only a vote" is a weaker defence than it sounds. Issue #593 established that a machine vote is what the question feed renders as the suggestion to confirm, and a human's click returns as a full-weight USER vote — so a visually-unverified deduction becomes a one-click rubber stamp, and the human-backed consensus gate is the mechanism that launders it.
The missing conjunct is now required: the card's already-stored evidence must be consistent
with the candidate printing — border class and frame style, via
local_identify_printing_tags.printing_attribute_disagreement, which is the same check the
Stage D join-key channel has always applied, now shared between the two callers rather than
re-derived. No image fetch and no new extraction: every input is a field already sitting on the
card's current ImageEvidence row. Sharing it also inherits PR #656's gate for free — the frame
half is skipped entirely unless artist_ocr actually ran, because illus_anchor_fired is
nullable and bool(None) is indistinguishable from "ran and found no anchor".
No stored evidence means abstain, not proceed. If the card has never been extracted, we have
never looked at the image, so the antecedent is exactly as unestablished as before. This is the
one place where this module's usual "missing data is not evidence" rule points the other way, and
deliberately so: that rule protects a match from being vetoed by silence, whereas here silence
is being asked to establish something. The two abstention populations are reported separately
(abstained_no_evidence, abstained_attribute_mismatch) so the cost of the gate is legible on
the first run rather than inferred from a smaller total.
The tier was not dropped, although it could have been cleanly: it has never run in production
(zero PilotRunLedger rows for local_name_frequency_elimination, ever), so nothing is
contaminated and no retraction was needed. It is kept because the deduction is genuinely sound
once the antecedent is established, and elimination reaches a population the image-based channels
structurally cannot — a name whose single uncovered printing has no distinguishing collector line
to read. Deleting a sound tier for want of a guard is a worse trade than adding the guard.
Separately from soundness, the 1:1 gate was counting over a population it was itself permanently
shrinking. _eligible_base_queryset was called with no run_id, so its exclusion of cards
already carrying this calculator's vote was lifetime rather than per-run:
- run 1 — a name has one unresolved eligible card and one uncovered printing. Sound; it votes.
- later — a second upload of that name arrives from another source, the ordinary way this catalogue grows.
- run 2 — the run-1 card is excluded forever by its own vote, so the name presents as having exactly one unresolved card again and votes for the new one. Correctly, the pool is two cards and the gate should abstain.
That is a fresh wrong positive, not a stale or missed vote: nothing about the second card
changed, only the size of the population the gate counts. The fix is to pass this run's run_id,
which narrows the self-suppressing excludes to rows this run wrote. Within-run resume is
unaffected — re-invoking with the same run_id still skips cards this run already voted on.
compute_covered_printing_pks() stays catalogue-wide and un-scoped, deliberately: "covered" is a
fact about the world (a confirmed canonical_card, or a RESOLVED inferred_canonical_card), not
about this calculator's progress, and run-scoping it would make every run treat every printing as
uncovered. The two halves of the gate are scoped differently on purpose.
run_pilot's own select_candidates and count_below_resolution_floor are left unscoped —
neither gates on a count over the returned population (the pilot's predicate is per-card and its
selection is a fetch-budget ordering; the floor count is a report metric), so neither has this
defect.
The second full-catalog relaunch (post cluster-dedup removal) died ~3 minutes in with
psycopg2.OperationalError: FATAL: sorry, too many clients already. Root cause: pipeline
concurrency's ThreadPoolExecutor (item 3d above) was constructed inside the chunk while
loop, once per chunk, instead of once for the whole run. Django DB connections are thread-local
and nothing closes a connection when its owning thread is torn down, so every chunk's disposable
ThreadPoolExecutor leaked up to workers Postgres connections that were never coming back.
At DEFAULT_BATCH_SIZE=25 and workers=7, against max_connections=100 with ~10 already in
use by live traffic, the math works out to roughly a dozen chunks (~300 cards) before
exhaustion - consistent with the observed crash timing at workers=7's measured throughput.
Production site itself was never affected (confirmed 200s on both domains, and Postgres recovered to its normal ~8 connections once the crashed process released its leaked slots) - this was a background management-command process, not user-facing traffic.
Fix: hoist the with ThreadPoolExecutor(...) (falling back to contextlib.nullcontext()
for workers==1) to wrap the entire chunk loop, so the same pool - and therefore the same
workers threads, and therefore each thread's single DB connection - is reused across every
chunk instead of recreated. Zero behavior change to write ordering or chunking semantics (see
the code comment at the fix site); regression test
TestConcurrency::test_thread_pool_is_created_once_for_the_whole_run_not_per_chunk asserts
pool construction count stays at 1 across multiple real chunks of work, not just that votes
still get written.
Timeboxed (~1hr) research task, ahead of designing the two-threshold clustering (item 3) and
art-region hash variant (item 4) follow-ups. Examined
tmikonen/magic_card_detector and
freeall/mtg-card-detector, both MIT-licensed
(copyright Timo Ikonen). These are not two independent implementations - freeall's repo is
an explicit fork of tmikonen's; the core hashing/matching code (magic_card_detector.py) is
essentially unmodified between them, freeall's changes being CLI ergonomics and a filename
convention for carrying Scryfall IDs through. Credit: threshold/matching approach below is
tmikonen's original work, referenced here as prior art per project attribution policy - no code
adopted verbatim, MIT terms would apply if that changes.
Their "threshold" is not directly reusable as a Hamming-distance number. They use
imagehash.phash(hash_size=32) (a 32x32/1024-bit hash, far larger than imagehash's 8x8 default),
but the match decision isn't a flat distance cutoff - it's a per-query statistical outlier test:
the best (smallest) Hamming distance among all candidates is compared to the mean and standard
deviation of the distances to every other candidate, and accepted only if it's more than 4
standard deviations below that mean. Reusing "4" as if it were a raw phash bit-distance (the way
this pilot's own d=0/d<=2 tiers are expressed) would be a category error - the two numbers aren't
on the same scale. The transferable idea, if any, is the method: validating a distance
threshold against the population's own distance distribution rather than picking a fixed cutoff
in isolation - a possible cross-check for calibrating d<=2, not a value to copy.
No working art-region hash code exists in either project. tmikonen's own blog post (tmikonen.github.io) names hashing a separate art-only reference image as future work, never implemented in either repo. Nothing to borrow beyond "someone else independently considered this useful," which is a weak signal, not a design.
Other notes: both preprocess with CLAHE histogram equalization and hash at all 4 rotations (a "photo of a physical card" concern from unknown-orientation scans - doesn't apply to this pilot's Scryfall-sourced digital images, which are already upright). Neither repo touches the Scryfall API directly; both assume a pre-populated local image folder, matched by brute-force linear scan against every reference hash (no indexing/bucketing) - not a scale precedent worth following at 172k+ cards regardless of threshold source.
Investigated whether the disabled cluster-dedup pre-pass (compute_own_image_clusters, see the
disablement entry above) could be cheaply re-added by hashing small CDN-resized images instead
of full resolution. There's only one fetch path in the whole module
(fetch_card_image/get_worker_image_url) - OCR, the main phash engine, and clustering all go
through it identically, so a smaller size needs no new plumbing, just a smaller fetch_dpi.
Gotcha: the CDN's dpi-to-pixel-height conversion isn't rounded - a dpi not a multiple of
10 produces a non-integer height param that Google's lh4 endpoint flat-out rejects with a 400.
Usable small sizes confirmed: dpi=40 (148px), dpi=50 (185px).
Measured on 150 real cards (11,175 pairs), hashed at full res (250dpi/~925px) and both small sizes with the exact production hash function:
- Zero false merges for the clustering pre-pass's actual exact-match (distance-0) criterion, across ~11k confirmed-different pairs - minimum observed distance at small size was 16-18, nowhere near 0.
- False splits: only 2 true-duplicate pairs existed in the sample; one survived at small size, one drifted to distance 2 at both small sizes and would no longer cluster. 1/2 is a real signal but too thin (n=2) to call this proven safe - would need a larger duplicate-focused sample before trusting it for a real re-add.
- Separately (not the clustering path, but relevant): checked against the other phash engine's
own match threshold (
DEFAULT_DISTANCE_THRESHOLD=20) - 1.0% of confirmed-different pairs fell ≤20 at 148px vs 0.56% at 185px, a real erosion of that engine's already-tight margin. Not itself a reason to change that engine (it doesn't use small images), but a caution against assuming small-size hashing is free of cost everywhere it might get reused. - Fetch time: real ~2-2.5x speedup (not the ~6x pixel-count reduction would suggest - cost is dominated by network/proxy round-trip overhead, not payload size). At full-catalog scale this still leaves roughly 9-11h of fixed sequential pre-pass cost, down from ~21.6h - a real improvement, but likely not enough alone to justify re-adding a separate pre-pass fetch.
Conclusion: small-size hashing looks safe for the clustering pre-pass's specific use case, with the false-split evidence still too thin to call proven. Even if proven, the bigger lever is avoiding a separate pre-pass fetch entirely - reusing the image OCR/phash already fetches per card, rather than shrinking a redundant one. That reframes task #108/#118 more than resolving task #117 on its own does.
Built on worktree-hash-at-ingest as the coherent follow-on the research above pointed at:
hash ONCE at ingest, store, never recompute corpus-wide - absorbing deferred item 2b and making
cluster dedup a per-run DB query at zero fetch cost, so the standalone pre-pass (disabled above)
never needs to exist again in any form. Built while the fast-follow-enabled full-catalog run
(PR #26) continued unattended - this work does not touch that run, it's the next-run
architecture.
Card.image_hash (migration 0046) turned out to already exist as a dead field - added
alongside expansion_hint's era, always written as a literal 0 placeholder by
update_database, never read anywhere (confirmed: zero references outside models.py and that
one write site). Rather than add a second, confusingly-similar hash field next to a dead one on
the same model, migration 0061 renames it to content_phash, makes it nullable (existing 0
rows migrated to NULL - none of them were ever real hashes), and indexes it. Dual consumer:
this pilot's own clustering, and federation-v1's reserved content_hash field (see
docs/federation-v1.md) - one field, two consumers. Algorithm/params documented as a
cross-instance interchange contract in the field's own docstring: imagehash.phash,
hash_size=8 (64-bit) - the library default, inherited from CanonicalCard.image_hash's
pre-existing convention rather than deliberately chosen; changing it later is a re-hash
migration, not a config flip, since federation peers would need to agree on the same params.
get_worker_image_url/fetch_card_image moved from local_identify_printing_tags.py to a new
cardpicker/image_cdn_fetch.py - a second, non-pilot caller (update_database's ingest hook)
needed the identical fetch, and the core ingest pipeline shouldn't depend on the pilot
orchestration module for something this foundational.
The task brief's premise here needed a correction, found before building anything wrong:
update_database's per-card path (transform_image_into_object) builds a Card row purely
from Google Drive folder-listing metadata (id/name/size/height/timestamps) - it never touches
image bytes. There was no existing fetch to piggyback on. hash_newly_created_cards
(cardpicker/sources/update_database.py) is therefore genuine new cost: one small-CDN-size
fetch per newly-created card, threaded (MAX_WORKERS=5, matching this module's own Drive-scan
concurrency), called right before bulk_create. Best-effort - a fetch/hash failure just leaves
content_phash NULL for the backfill command to retry, never blocks a sync.
Scoped to CREATED cards only, not UPDATED ones - a deliberate narrowing of the brief's literal
"new/changed cards" wording, flagged explicitly rather than silently assumed: content_phash
was never in bulk_sync_objects's bulk_update field whitelist (confirmed - the whitelist's own
comment claims "every field except identifier," which was already inaccurate before this
change), so there is nothing to persist for an updated card even if it were re-hashed - the
write would be silently discarded. A genuinely changed image at the same Drive file id (rare -
Drive normally assigns a new id on real content replacement) isn't detected or corrected here;
the standalone backfill command's NULL-only filter is the correction path if that's ever
suspected for a specific card. Building real change-detection for that rare case was judged out
of proportion to the risk - logged here rather than built.
local_backfill_content_phash (new management command, local_phash.run_content_phash_backfill)
hashes every existing content_phash IS NULL row. Idempotent and resumable by construction (the
NULL filter IS the checkpoint - no separate --resume flag or state file), batched
(fetch+hash batch_size cards concurrently, one bulk_update per batch - a kill loses at most
one in-flight batch), --nice by default matching run_pilot's convention.
Replaces the disabled fetch-based pre-pass entirely - run_pilot's cluster_result call site now
reads Card.content_phash (already loaded via select_candidates's .only()) instead of
fetching. Restores the representative-only filtering the original (pre-disablement)
implementation had (all_selected_by_card_id narrowed to non-absorbed cards before the compute
loop) - the disabled no-op version had dropped this line since it was a no-op with an
always-empty members_by_representative; re-enabling clustering without restoring it would have
silently run full OCR/phash/fallback compute on absorbed members AND propagated a redundant
vote to them.
Two tiers, two trust levels: d=0 (exact hash match) propagates votes exactly as the old
pre-pass did - sound entailment, unchanged semantics. 0 < d <= 2 is a narrowing PRIOR only
(never auto-votes) - required, not optional, given small-size hashing is in use (the earlier
"Phash accuracy at small CDN sizes" section found a real true-duplicate pair landing at exactly
d=2). The d<=2 narrowing HINT is computed (near_duplicate_ids_by_card_id) but not yet wired
into _compute_card's actual candidate-narrowing chain - flagged explicitly as a scoped-out
fast-follow, not silently half-built: wiring it into the hot per-card compute path under a
ThreadPoolExecutor needed more careful threading verification than this pass's effort budget
allowed, and the d=0 propagation win stands on its own without it.
Performance, benchmarked before trusting the design, not assumed: the two tiers are computed
as independent steps (advisor review caught this before it shipped as one coupled pass) - d=0 is
a plain dict grouping, measured 0.13s at N=166,422 real-scale synthetic hashes. The d<=2 tier
(chunked numpy XOR + numpy.bitwise_count popcount, never a Python pairwise loop or an
all-at-once O(N^2) allocation) measured ~2-3 minutes at the same N (contended with this box's
own concurrently-running full-catalog job at benchmark time), and it's pure in-memory compute,
so it doesn't compete for the shared CDN request budget the old pre-pass did.
Correcting an overstated comparison (caught when the owner asked "are we sure this is
legitimate" rather than accepting the headline number): the naive "~500-650x win over the
disabled pre-pass's ~21.6h" comparison is misleading on two counts, not a fair apples-to-apples
claim. First, it's comparing two different operations - 21.6h was a recurring per-run network
fetch, 2-3 min is a one-time-amortized in-memory read that only works AFTER the separate ~2.8h
backfill has populated content_phash (see below) - not "the same work, done faster." Second,
the 21.6h baseline was never threaded (--workers had no effect on the fetch loop, which is
WHY it was disabled) - a fair comparison should be against what a THREADED fetch pre-pass could
have achieved (~21.6h / 7 workers ≈ ~3h), not the unoptimized sequential baseline. Against that
fairer baseline, the real improvement is closer to ~60-90x (~3h / ~2.5min), not 500-650x.
The underlying design is still a genuine, large win - eliminating a recurring network-bound cost
in favor of a one-time investment plus a fast in-memory read - but the specific multiplier
quoted needs to be the honest one, not the most dramatic one available. Wrapped in a try/except:
a failure in the d<=2 scan falls back to "no near-duplicate hints this run," never taking down
d=0's already-proven propagation.
The original "Phash accuracy at small CDN sizes" research flagged its own weakness: only 2 true duplicates existed in that 150-card sample, too thin to trust. The live full-catalog run (running throughout this work) provided a much better source: harvested 300 real pairs of different Card rows that received a vote for the SAME printing from the run's own OCR/phash engines (1,771 such pairs existed at harvest time), plus 300 pairs voted for different printings (false-merge check), via a read-only query against the live production DB.
A ground-truth correction made before trusting the numbers: "voted for the same printing" is NOT the same claim as "the same uploaded image" - two community members can scan/photograph the same real card differently, and the clustering feature's own definition of "true duplicate" (from its original docstring) is full-resolution hash distance-0, not "same printing." Computed full-resolution hashes for the same-printing sample too, and partitioned by that ground truth before drawing any conclusion:
- 79 pairs were true duplicates (full-res distance=0, i.e. really the same uploaded image): 100% landed at small-size distance<=2 (73 at exactly 0, 6 at 2) - zero false splits, directly confirming the d<=2 threshold is the correct one for small-size hashing, at 40x the sample size of the original n=2 test.
- 162 pairs were different photos of the same real printing (full-res distance>0) - correctly did NOT cluster in the vast majority (mean small-size distance 17.8, ranging 0-38); 19/162 (11.7%) coincidentally landed at d<=2 anyway. Noted as a real but benign effect: since the underlying printing genuinely is the same, an incorrect "same upload" assumption still propagates a factually correct vote - not a correctness risk, just a documented imprecision in the "distance-0 means duplicate upload" model.
- 269 different-printing pairs (false-merge check): zero landed at distance<=2 - minimum observed distance was 6, comfortably clear of the threshold.
This run's own live numbers (no clustering active - PR #26's code, not this branch, is what's
actually running): ~2.65 candidates/sec observed, projecting ~0.73 days for the full
166,422-candidate catalog. Bottleneck-split measurement (earlier in this doc) found 26-28%
cluster absorption in real samples; applying that reduction to the compute-bound majority of the
pipeline projects ~0.52-0.54 days (~12.6-12.9h) for the next run, once content_phash is
backfilled - clustering as a zero-fetch DB read rather than a competing sequential pre-pass.
The one-time backfill itself (166,422 cards, small-size fetch, 5 concurrent workers) projects to
roughly ~2.8 hours - a one-time investment, not a recurring cost; the ingest hook keeps
future new cards hashed automatically at near-zero marginal cost going forward.
Art-region second hash (needs the frame-mismatch census's own value estimate first - separate
task, #119), multi-hash ensembles, deep-embedding dedup (violates the cheap-deterministic
discipline this whole engine is built on), hash_size re-tuning (parked until the art-hash
question is taken up), and wiring the d<=2 narrowing hint into _compute_card's live candidate
matching (computed and tested, not yet consumed - see above).
Full design/build detail lives in
docs/features/catalog-completion-plan.md's Part 1 - this section
is the pointer from the pilot's own doc, stating the complete safety-property set this module
now guarantees, since Part 1 completes it rather than replacing anything below.
Four properties, three pre-existing and one new:
-
Machine votes can never resolve a card by themselves (pre-existing, unchanged) -
vote_consensus.resolve_weighted_consensus's human-backed gate: no matter how manyVoteSource.OCR/VoteSource.DEDUCTIONvotes pile up, a card only reachesRESOLVEDwith at least one human-backed vote behind the winning outcome. This is the foundational invariant every stage of this project is built on - see the module's own opening paragraph. -
A killed/interrupted run is restart-safe (pre-existing, unchanged) - the NULL-filter/
anonymous_id-exclusion idempotence in_eligible_base_queryset, plus batch-flush checkpointing (see "Checkpointing" above): a plain re-invocation resumes cleanly, never double-votes, loses at most one in-flight batch on a kill. -
Revocability (new, Part 1) - every machine vote now carries a
run_id(a separate, nullable, indexed field onAbstractWeightedVote-anonymous_iditself is untouched, its exact-match reuse across invocations is what property 2 above depends on, so it could never be safely repurposed as a per-run stamp).manage.py purge_machine_votes --run-id <id>deletes exactly one invocation's votes and re-resolves every affected card, so a bad iteration can be cleaned up surgically without touching any other run's votes. -
Staleness guard (new, Part 1) - every pilot command refuses to start if the DB has
migrations applied that the running image's own code doesn't know about
(
cardpicker.utils.find_stale_applied_migrations) - automates the PR #24/#26 lesson (adocker compose buildcan report success while a BuildKit caching bug ships old code underneath) instead of relying on someone remembering to check image timestamps.
Updated rebuild command, now required (adds the git-SHA build-info bake - best-effort visibility, logged at each command's startup, never itself the gate):
GIT_SHA=$(git rev-parse --short HEAD) docker compose -f docker/docker-compose.prod.yml buildThe post-purge invariant, stated precisely (this is the safety property revocability rests
on - "corrected" without the exact statement isn't reviewable, so here it is verbatim against
the actual implementation, cardpicker.management.commands.purge_machine_votes. verify_no_machine_only_resolutions): after a real (non-dry-run) purge, every affected card is
re-resolved from scratch via the persisting consensus resolvers
(resolve_and_persist_printing/resolve_and_persist_artist/resolve_and_persist_tag_votes),
using whatever votes actually remain after the purge - not a diff against pre-purge state. The
command then asserts: for every affected card whose printing_tag_status is RESOLVED, at
least one of its surviving CardPrintingTag votes for that resolved printing has a human-backed
source (not VoteSource.DEDUCTION/VoteSource.OCR); identically for artist_vote_status
against the resolved artist, and for each individual tag whose tag_vote_statuses entry is
RESOLVED_APPLY/RESOLVED_REJECT against that specific tag's own surviving votes. A card is
NOT required to return to its pre-purge status - un-resolving as a consequence of losing
machine-only weight is the expected, correct outcome, reported separately
(cards_unresolved_by_purge), never a violation. Only a RESOLVED outcome with zero surviving
human-backed votes behind it is a violation - resolve_weighted_consensus's own human-backed
gate should make that structurally impossible, so if it ever happens it means something upstream
broke, not that the purge did anything wrong.
Cohort convention: run_id IS NULL identifies the pre-crash cohort - every vote from
every invocation of this pilot before 2026-07-16 15:39 UTC. Any run_id value identifies the
post-crash cohort - stamped, individually purgeable, PilotRunLedger-tracked, from that
timestamp onward. The dividing line is that specific crash, not a "run completes naturally, then
stamping begins" boundary - see Troubleshooting's "Entrypoint + migrate composition
traps" entry for what happened and why. Properties 1/2 above were never conditional on run_id
existing, so the crash changed nothing about correctness, only when stamping started - a
strictly better state than waiting for a natural completion that was never guaranteed to arrive
first.
What the staleness guard does and doesn't catch: it blocks a restart from an image older
than the DB's applied migrations (property 4) - the guard doing its job. It does NOT catch a
non-additive migration landing via docker compose up while a different, already-running
container is still on the old code, since that container never restarts and so never re-checks.
See the troubleshooting.md entry above for the operational rule this established.
Understanding the system
- Overview
- Documentation-Process
- Theory
- Identification-Pipeline
- Pipeline-Fidelity-Gate
- Federation-v1
- Vote-System
- Readiness-Audit
- License-Provenance
- Upstreaming-Conventions
- Drift-Log
- Upstream-Wiki-Drift
- Printing-Tags
- Catalog-Completion-Plan
- Moderation
- Card-DOM-API
- PDF-Generator
- Print-Export-Page
- Google-Drive-Connect
- Grid-Selector
- Image-CDN
- Local-File-Source
Using it
Operating it
Folded into other pages