Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.30 on 2026-08-05 01:09

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("cardpicker", "0102_artbox_phash_exemplar"),
]

operations = [
migrations.AddField(
model_name="canonicalprintingmetadata",
name="layout",
field=models.CharField(blank=True, max_length=30),
),
]
10 changes: 10 additions & 0 deletions MPCAutofill/cardpicker/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,16 @@ class CanonicalPrintingMetadata(models.Model):
border_color = models.CharField(max_length=20, blank=True)
frame = models.CharField(max_length=10, blank=True)
frame_effects = models.JSONField(default=list, blank=True)
# Scryfall's own layout tag verbatim (e.g. "normal", "transform", "planar", "scheme") -
# the same value `printing_metadata_import.PrintingMetadataRow.layout` already parses and
# was, until issue #693, discarded after being checked against `DOUBLE_FACED_LAYOUTS`.
# Persisted so downstream consumers can ask "is this printing physically sideways"
# (`planar`/`scheme`, plus some `split`/`battle` cards) without re-deriving it from image
# geometry - measured 2026-08-05: only 46 of 230,378 evidence rows are landscape
# (width > height), because sideways cards are overwhelmingly rendered into a portrait
# frame with rotated content, so aspect ratio cannot find them. Canonical metadata is the
# only reliable source.
layout = models.CharField(max_length=30, blank=True)
promo_types = models.JSONField(default=list, blank=True)
edhrec_rank = models.IntegerField(null=True, blank=True)
# HOW MANY PRINTINGS OF THIS ORACLE CARD *WE* HAVE CATALOGUED - a COUNT over our own
Expand Down
2 changes: 2 additions & 0 deletions MPCAutofill/cardpicker/printing_metadata_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,7 @@ def is_back_face(name: str, default_cards_path: Path | None = None) -> bool:
"border_color",
"frame",
"frame_effects",
"layout",
"promo_types",
"edhrec_rank",
"catalogued_printings_count",
Expand Down Expand Up @@ -501,6 +502,7 @@ def import_scryfall_printing_metadata(default_cards_path: Path | None = None) ->
border_color=row.border_color,
frame=row.frame,
frame_effects=row.frame_effects,
layout=row.layout,
promo_types=row.promo_types,
edhrec_rank=row.edhrec_rank,
catalogued_printings_count=catalogued_printings_count,
Expand Down
74 changes: 74 additions & 0 deletions docs/features/catalog-completion-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -1705,6 +1705,80 @@ fit" conditional wording; full suite 1099 passed / 4 skipped (the same
CI-documented named skips — nothing newly broken); `makemigrations --check`
clean (no model change).

**`layout` persisted, sideways-cohort measured (issue #693, 2026-08-05)**:
`CanonicalPrintingMetadata.layout` (migration
`0102_canonicalprintingmetadata_layout`, single-leaf on `0101`) persists
Scryfall's own `layout` tag verbatim from
`printing_metadata_import.PrintingMetadataRow.layout` — the same value the
back-face lookup above already reads to test membership in
`DOUBLE_FACED_LAYOUTS`, but which was discarded once that check ran rather
than kept. `layout` now survives past that check as a plain
`CharField(blank=True)`, following `border_color`/`frame`'s own field
convention exactly: added to `_METADATA_SYNC_FIELDS` and wired into the
`CanonicalPrintingMetadata(...)` constructor call; `DOUBLE_FACED_LAYOUTS`'s
existing use of `row.layout` is unchanged.

Persisting it was motivated by a live measurement, not spec-first — but
the first pass at that measurement (41 of 230,488 cards, 0.018%,
2.4% parse failure) was **retracted and redone** (2026-08-05, same day):
it counted only cards that already carry a `canonical_card` link, and
that link is acquired _by having been successfully identified_ — a
sideways card whose crop lands on rotated content and therefore fails
identification can never appear in a cohort defined by identification
success. That cohort was drawn from a population selected for not having
failed, so its low failure rate proved nothing about sideways cards
generally, and the 91.5% of the catalogue with no canonical linkage
(layout unknown, not normal) was invisible to it entirely.

The redo defines the cohort by **expected** layout instead: every
`Card.name` (source metadata, independent of identification) that
name-matches a `CanonicalCard` whose Scryfall `layout` is `planar`/
`scheme`, via the same `to_searchable` join
`deductive_backfill.CanonicalNameIndex` already uses for this exact
Card-to-CanonicalCard join. That cohort is **1,093 cards (0.474% of the
catalogue)** — 305 canonical printing names map unambiguously to
planar/scheme, plus 4 names that are ambiguous (also match a
non-sideways printing); the old 41-card cohort is fully contained inside
it. Only 43 of the 1,093 (3.9%) carry a `canonical_card` link, confirming
the bias directly. Its collector-line parse failure rate is **53.3%
(583/1,093)**, compared against the correct baseline — the _unlinked_
population generally (40.4%, 85,217/210,832) — not the whole-catalogue
figure (38.4%), which mixes the unlinked population with the
already-successfully-identified linked population (17.1%) and so
understates how bad the unlinked baseline really is. The sideways cohort
fails **13 points above** its correct comparison group, a real elevated
signal the old measurement's biased denominator hid entirely.

That elevated failure rate is what a `local_fallback.normalize_crop_box`
rotation path was built to test — and the rotation trial's result is
negative. A 30-card real-image sample (fetched live via
`image_cdn_fetch.fetch_card_image`, drawn from the 1,046-card pool of
this cohort that has never carried a `canonical_card` link) was run
through the existing collector-line and Illus.-anchor crops unmodified,
then again with the whole image rotated 90° clockwise and 90°
counter-clockwise (both directions, since no stored signal indicates
which way a given render is oriented). Collector-line OCR produced a
_plausible_-looking parse on 17/30 baseline images, 9/30 rotated
clockwise, 7/30 rotated counter-clockwise — but validated against the
card's own real candidates (`local_ocr.validate_against_candidates`,
the same check Stage D's calculator applies), **zero of those parses
were genuine matches, in any of the three orientations**. The
Illus.-anchor artist-line crop found zero matches in all three
orientations too. Rotation neither recovers a match baseline missed nor
loses one baseline found (the one case where a rotation "recovered" a
plausible parse baseline missed was itself non-genuine noise). The
failure is not a crop-box placement problem rotation can fix; it matches
issue #683's own finding for the (heavily overlapping — 24/30 of this
sample's `bleed_class` is `trimmed`) trimmed-cohort investigation: these
are disproportionately fan-made custom renders that plausibly never had
a machine-readable collector line to begin with, a content problem no
crop geometry recovers. Crop-box rotation was evaluated and deliberately
**not built** — not because the cohort is negligible (it isn't) or the
failure rate is unremarkable (it isn't), but because the rotation trial
itself found nothing to recover. `golden_set.py`'s 30 pinned cards
contain zero sideways-layout cards, so the golden set cannot catch a
regression in this path specifically.

**color_profile / quality_signals / fetch-health completion — Stage C manifest
extractor group, built** (public issue #150's re-spec, 2026-07-20 — the phash half of the
original issue is DROPPED per the owner's same-day re-spec comment on #150, superseded by
Expand Down
25 changes: 25 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -2058,3 +2058,28 @@ image samples). `cardpicker/golden_set.py`'s `GOLDEN_CARD_IDS` DOES cover
black-border proxy template above, so the golden set has zero coverage
of a genuine bleed-trimmed real-scan case and cannot catch a regression
in that path specifically.

## `manage.py makemigrations` fails with `ValueError: Missing staticfiles manifest entry for 'cardpicker/favicon.ico'` in a fresh worktree

**Symptom**: `python manage.py makemigrations cardpicker` (or any other
management command that touches `MPCAutofill.urls`, which resolves a
static URL at import time) crashes before it gets anywhere near
migrations, with a `ValueError` from
`django.contrib.staticfiles.storage.ManifestStaticFilesStorage` about a
missing manifest entry for `cardpicker/favicon.ico`. Seen 2026-08-05 in
a brand-new `git worktree` checkout that had never had `collectstatic`
run against it.

**Cause**: `STATICFILES_STORAGE` is `ManifestStaticFilesStorage`
(settings.py), which requires `staticfiles.json` — built by
`collectstatic` — to resolve any `{% static %}`/`static()` reference.
`MPCAutofill/MPCAutofill/urls.py` calls `static("cardpicker/favicon.ico")` at
import time for the favicon redirect, so importing the URLconf at all
(which `makemigrations`' system checks do) fails in any checkout that
hasn't run `collectstatic` yet — this has nothing to do with
migrations themselves.

**Fix**: run `python manage.py collectstatic --noinput` once per
worktree before the first `makemigrations`/`migrate`/any other
management command in it. Output lands in the gitignored `/static/`
directory at the repo root, harmless to leave in place.
Loading