Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions api/card_processing.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@
# for any card with no type in this set. Title-cased to match parse_type_line().
PERMANENT_CARD_TYPES = {"Artifact", "Battle", "Creature", "Enchantment", "Land", "Planeswalker"}

# Scryfall's set_type for the products that are collectible objects rather than tournament-legal
# printings: World Championship decks, Collectors' Edition, 30th Anniversary, the oversized promo
# sets -- 30a, ced, cei, ovnt, ptc, o90p, olep, wc97..wc04, 99 sets in all.
MEMORABILIA_SET_TYPE = "memorabilia"


def parse_type_line(type_line: str) -> tuple[list[str], list[str]]:
"""Parse the type line of a card."""
Expand Down Expand Up @@ -127,6 +132,34 @@ def preprocess_card(card: dict[str, Any]) -> list[dict[str, Any]]: # noqa: PLR0
return []
if card.get("set_type") == "funny":
return []
# Memorabilia, for the same reason one line up: a product that is a collectible object rather
# than a tournament-legal printing. Scryfall hides these from any search that does not name
# their set -- measured 2026-08-11, `!"Ancestral Recall"` returns 9 of its 18 printings and
# `!"Birds of Paradise"` 42 of 43 -- so importing them makes ordinary queries disagree with it.
# Concretely they supplied the CHEAPEST printing for 184 cards, which is exactly the printing a
# price ordering is defined to return.
#
# Dropped at import rather than filtered at query time, and that is the load-bearing decision.
# To be correct a query-time predicate has to sit in the filter TREE -- the representative walk
# picks among printings that pass the residual, so a flag outside it would let a hidden
# printing stand for its card -- and a conjunct present on every query breaks four of the six
# physical plans, which gate on the filter being literally `True` or on a range being bare:
# PlanePopcountOrder, CardRangePopcount, PrintingRangeScan, and `all_match_known`'s
# constant-count arms. Measured at +59..115us per query on a 112,932-printing store.
#
# What this gives up instead: `set:cei` and its 98 siblings return nothing, where Scryfall
# returns them. No CARD is lost -- measured, 0 of 31,724 cards are printed only in memorabilia
# sets -- so it changes which printing represents a card, never whether the card is findable.
if card.get("set_type") == MEMORABILIA_SET_TYPE:
return []
# Oversized printings, same reasoning. Every card that exists ONLY oversized -- all 207
# planes, all 102 schemes, all 32 paper Vanguard avatars, Garruk the Slayer -- is not_legal
# in every format, so the legality gate above already refuses it before this line runs.
# Measured 2026-08-27: after the gates above, exactly 240 oversized printings survive, 230 of
# them memorabilia; this line removes the last 10, the p09/p10/p11 oversized box-topper
# promos, each of which has normal-sized printings. So no card is lost here either.
if card.get("oversized"):
return []

# Filter out unplayable cards: Cards and Tokens
type_line = card.get("type_line")
Expand Down
37 changes: 36 additions & 1 deletion api/tests/test_card_processing.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import uuid
from typing import Any

from api.card_processing import extract_frame_data_from_raw_card, preprocess_card
from api.card_processing import MEMORABILIA_SET_TYPE, extract_frame_data_from_raw_card, preprocess_card

# Project root directory for accessing sample data
_PROJECT_ROOT = pathlib.Path(__file__).parent.parent.parent
Expand Down Expand Up @@ -209,6 +209,41 @@ def test_preprocess_card_filters_funny_sets(self) -> None:
result = preprocess_card(invalid_card)
assert result == []

def test_preprocess_card_filters_memorabilia_sets(self) -> None:
"""Memorabilia printings are not imported.

Scryfall hides them from any search that does not name their set, so importing them makes
ordinary queries disagree with it. Measured 2026-08-11: `!"Ancestral Recall"` returns 9 of its 18 printings on Scryfall, and
the 9 it omits are exactly the memorabilia ones (30a, ced, cei, ovnt) plus a digital set.
"""
# The LITERAL, not MEMORABILIA_SET_TYPE: driving both sides off the same constant makes the
# test self-referential, and it then passes with the constant set to anything at all.
assert preprocess_card(create_test_card(set_type="memorabilia")) == []
# Pinned separately, so the constant is still what names the Scryfall set_type.
assert MEMORABILIA_SET_TYPE == "memorabilia"

def test_preprocess_card_keeps_ordinary_sets(self) -> None:
"""The exclusion is on set_type alone — a normal expansion is untouched.

Paired with the test above so a predicate that accidentally dropped everything (an `in`
against the wrong operand, say) fails here rather than looking like a working filter.
"""
assert len(preprocess_card(create_test_card(set_type="expansion"))) == 1

def test_preprocess_card_filters_oversized_printings(self) -> None:
"""Oversized printings are not imported.

Every card that exists ONLY oversized (planes, schemes, Vanguard avatars) is not_legal in
every format and already refused by the legality gate, so this test card carries legal
legalities to reach the oversized check itself. Measured 2026-08-27: past the earlier gates
this drops exactly 10 printings, the p09/p10/p11 oversized box-topper promos, each of which
has normal-sized printings.

`test_preprocess_card_keeps_ordinary_sets` above is the paired keep-side: its card carries
no `oversized` field at all, matching the bulk objects where the flag is false.
"""
assert preprocess_card(create_test_card(set_type="expansion", oversized=True)) == []

def test_preprocess_card_filters_card_type(self) -> None:
"""Test preprocess_card filters out cards with Card type."""
invalid_card = create_test_card(
Expand Down
Loading