diff --git a/api/card_processing.py b/api/card_processing.py index 3de650c86..843612582 100644 --- a/api/card_processing.py +++ b/api/card_processing.py @@ -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.""" @@ -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") diff --git a/api/tests/test_card_processing.py b/api/tests/test_card_processing.py index e28ee6af4..b2205a781 100644 --- a/api/tests/test_card_processing.py +++ b/api/tests/test_card_processing.py @@ -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 @@ -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(