File size: 35,370 Bytes
8538f10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8e82666
 
 
 
 
 
 
8538f10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2fd2eab
 
 
 
 
 
 
 
8538f10
 
 
 
08b6c63
 
 
8538f10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
08b6c63
 
 
 
8538f10
08b6c63
 
 
 
 
 
8538f10
 
 
08b6c63
 
 
 
 
 
8538f10
 
 
 
08b6c63
 
8538f10
 
 
 
 
 
08b6c63
 
 
 
 
 
 
 
 
 
8538f10
 
15c3f7a
 
8538f10
 
 
 
15c3f7a
 
 
 
 
 
8538f10
08b6c63
8538f10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2fd2eab
08b6c63
8538f10
 
 
 
 
 
 
08b6c63
8538f10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
08b6c63
8538f10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
08b6c63
 
 
 
8538f10
08b6c63
 
 
 
 
 
 
 
8538f10
 
08b6c63
8538f10
 
 
 
 
 
 
 
08b6c63
 
8538f10
 
 
 
 
 
 
 
 
08b6c63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8538f10
 
 
 
 
 
 
 
 
 
 
 
 
 
08b6c63
8538f10
 
08b6c63
8538f10
08b6c63
8538f10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
08b6c63
 
 
8538f10
 
 
 
 
 
 
 
 
 
8e82666
8538f10
 
 
 
 
 
08b6c63
8538f10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
08b6c63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8538f10
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
"""
deckbuilder.py

Turns "commander(s) + partial decklist" into a complete 100-card Commander
deck using the production ensemble (training/ensemble.py). The model only
ever proposes nonbasic cards -- basic lands are excluded from the tokenizer
vocab -- so this module owns everything around it:

1. Resolve pasted names against the vocab (exact, then fuzzy with a
   warning), keeping user-supplied basics aside since the vocab can't
   represent them.
2. Ask the ensemble for every open slot, with Commander-banned cards
   excluded from its candidate pool.
3. Hit a land target: trim the ensemble's lowest-scoring nonland,
   non-staple picks and replace them with basics split by the deck's
   colored-pip distribution.

Land target: DEFAULT_TARGET_LANDS (38) unless the caller sets one, and never
below MIN_TARGET_LANDS (36) -- the project owner's deckbuilding standard.
(The training data suggests players run fewer: estimated median totals of
33-35 by color count. That dataset drops basic lands from ~92% of its decks,
10,300 of 122,571 contain any basic, which is also why the ensemble fills
open slots almost entirely with nonbasics and needs this step.) The target
is a minimum: lands the model picks beyond it are kept.
"""

from __future__ import annotations

import secrets
import threading
from collections import Counter
from dataclasses import dataclass, field
from difflib import get_close_matches

import torch

from tokenizer.mtg_tokenizer import BASIC_LAND_NAMES, COLORS, CardRecord, CardTokenizer, _normalize_name, _pip_counts
from server.card_prices import PriceBook
from server.decklist import parse_decklist

DECK_SIZE = 100
DEFAULT_TARGET_LANDS = 38
# Cards picked per model pass in the synergy phase and prune refills: an int
# for a fixed size, or "dynamic" to vary it each pass with how alike the
# model's top candidates are (see training/complete_deck.py's
# _choose_dynamic_chunk_size). Set here rather than inherited from
# ensemble_complete_deck's default, so the live behavior only changes when
# this line does.
GENERATION_CHUNK_SIZE: int | str = "dynamic"
MIN_TARGET_LANDS = 36
BASIC_FOR_COLOR = {"W": "Plains", "U": "Island", "B": "Swamp", "R": "Mountain", "G": "Forest"}
BASIC_TYPE_LINES = {name: f"Basic Land — {name}" for name in BASIC_LAND_NAMES - {"Wastes"}} | {"Wastes": "Basic Land"}
BASIC_COLOR = {v: k for k, v in BASIC_FOR_COLOR.items()}
BASIC_TEXT = {name: f"({{T}}: Add {{{BASIC_COLOR.get(name, 'C')}}}.)" for name in BASIC_LAND_NAMES}

# Grouping buckets; a card goes in the first one its FRONT face's type line
# matches. Land comes first so artifact lands and Dryad Arbor are grouped
# (and counted in the summary) the same way the land-target math counts them.
CATEGORIES = ["Land", "Creature", "Planeswalker", "Battle", "Instant", "Sorcery", "Artifact", "Enchantment"]

class DeckInputError(ValueError):
    """Bad user input that the API should report back (HTTP 422), not crash
    on. `unresolved` lists names that matched nothing, each with
    suggestions, so the UI can offer one-click fixes."""

    def __init__(self, message: str, unresolved: list[dict] | None = None):
        super().__init__(message)
        self.message = message
        self.unresolved = unresolved or []


@dataclass
class DeckCard:
    name: str
    quantity: int
    source: str  # "input" | "synergy" | "staple" | "basic"
    score: float | None
    type_line: str
    mana_cost: str
    cmc: float
    category: str
    oracle_text: str
    locked: bool = False  # kept from a previous completion at the user's request
    # Real-deck-frequency classification ("staple" | "mid" | "long_tail"),
    # None for basics (outside the tokenizer vocab, so untiered). Distinct
    # from `source`: the synergy phase runs unrestricted and routinely
    # picks real staples (Sol Ring, Command Tower) on their own merit, so
    # a card can be source="synergy" and tier="staple" at once -- without
    # this field the frontend had no way to show that, and a staple could
    # read as "just a synergy pick" with nothing marking it as universal.
    tier: str | None = None
    # Per-face power/toughness, loyalty, or defense ("4/3", "Loyalty 3", None), see server/card_stats.py.
    stats: list[str | None] = field(default_factory=list)
    # Cheapest paper printing: {"usd": 0.25, "set": "M21", "finish": "foil"}, see server/card_prices.py.
    price: dict | None = None
    color_identity: list[str] = field(default_factory=list)
    # Whether a deck may hold more than one copy (basics, Relentless Rats, ...).
    multiples_allowed: bool = False

    def to_dict(self) -> dict:
        return self.__dict__.copy()


@dataclass
class Completion:
    commanders: list[DeckCard]
    cards: list[DeckCard]
    warnings: list[str] = field(default_factory=list)
    target_lands: int = 0
    prices_updated: str | None = None

    def to_dict(self) -> dict:
        land_count = sum(c.quantity for c in self.cards if c.category == "Land")
        everything = self.commanders + self.cards
        return {
            "commanders": [c.to_dict() for c in self.commanders],
            "cards": [c.to_dict() for c in self.cards],
            "warnings": self.warnings,
            "prices_updated": self.prices_updated,
            "summary": {
                "price_usd": round(sum(c.price["usd"] * c.quantity for c in everything if c.price), 2),
                "unpriced": sum(c.quantity for c in everything if not c.price),
                "total": sum(c.quantity for c in self.commanders + self.cards),
                "lands": land_count,
                "target_lands": self.target_lands,
                "added": sum(c.quantity for c in self.cards if c.source != "input"),
                "basics_added": sum(c.quantity for c in self.cards if c.source == "basic"),
            },
        }


def front_face_type(card: CardRecord) -> str:
    return card.type_line.split("//")[0]


def category_of(type_line: str) -> str:
    front = type_line.split("//")[0]
    for cat in CATEGORIES:
        if cat in front:
            return cat
    return "Other"


def is_front_land(card: CardRecord) -> bool:
    return "Land" in front_face_type(card)


def is_commander_eligible(card: CardRecord) -> bool:
    front = front_face_type(card)
    return (
        ("Legendary" in front and ("Creature" in front or "Background" in front))
        or "can be your commander" in card.oracle_text
    )


def _allows_multiple(card: CardRecord) -> bool:
    text = card.oracle_text
    return "Basic" in card.type_line or "A deck can have any number" in text or "A deck can have up to" in text


def _partner_pair_ok(a: CardRecord, b: CardRecord) -> bool:
    def has(card: CardRecord, phrase: str) -> bool:
        return phrase in card.oracle_text or phrase in card.keywords

    if has(a, "Partner with") or has(b, "Partner with"):
        return f"Partner with {b.name}" in a.oracle_text and f"Partner with {a.name}" in b.oracle_text
    for phrase in ("Partner", "Friends forever"):
        if has(a, phrase) and has(b, phrase):
            return True
    for x, y in ((a, b), (b, a)):
        if has(x, "Choose a Background") and "Background" in front_face_type(y):
            return True
        if has(x, "Doctor's companion") and "Doctor" in front_face_type(y):
            return True
    return False


def allocate_basics(n: int, colors: set[str], pip_totals: dict[str, float]) -> Counter:
    """Split n basic lands across the commander's colors in proportion to
    the deck's colored pips (largest-remainder rounding). Colorless decks
    get Wastes. Colors with no pips at all still get a share when every
    color has zero pips (e.g. an all-colorless-spells deck)."""
    if n <= 0:
        return Counter()
    if not colors:
        return Counter({"Wastes": n})
    ordered = [c for c in COLORS if c in colors]
    weights = [pip_totals.get(c, 0.0) for c in ordered]
    if sum(weights) == 0:
        weights = [1.0] * len(ordered)
    total = sum(weights)
    exact = [n * w / total for w in weights]
    counts = [int(x) for x in exact]
    by_remainder = sorted(range(len(ordered)), key=lambda i: exact[i] - counts[i], reverse=True)
    for i in by_remainder[: n - sum(counts)]:
        counts[i] += 1
    return Counter({BASIC_FOR_COLOR[c]: k for c, k in zip(ordered, counts) if k > 0})


class DeckBuilder:
    """Loads the three checkpoints once and serves completions. Inference is
    serialized with a lock: requests arrive on FastAPI's thread pool, and a
    completion is only ~1-3s, so a queue of one is simpler than per-thread
    model copies.

    `version` is the Hub release record from server/model_files.py (None
    when the local files were never synced), reported by /api/health.
    `card_stats` is the preview lookup from server/card_stats.py; the app
    fills it in after loading, since building it needs the tokenizer.
    `prices` serves cheapest-printing prices (server/card_prices.py); each
    completion nudges it to refresh in the background when stale."""

    def __init__(
        self,
        version: dict | None = None,
        card_stats: dict[str, list[str | None]] | None = None,
        prices: PriceBook | None = None,
    ):
        from training.ensemble import PRUNE_CHECKPOINT, STAPLE_CHECKPOINT, SYNERGY_CHECKPOINT, load_tiers
        from training.evaluate import load_checkpoint
        from training.train import pick_device
        from training.train_prune import load_prune_checkpoint

        self.device = pick_device()
        self.staple_model, self.tok, _ = load_checkpoint(STAPLE_CHECKPOINT, self.device)
        self.synergy_model, _, _ = load_checkpoint(SYNERGY_CHECKPOINT, self.device)
        self.prune_model, _, _ = load_prune_checkpoint(PRUNE_CHECKPOINT, self.device)
        self.tiers = load_tiers(self.tok)
        # A release's tokenizer can list cards newer than its checkpoints
        # (new cards are appended to the end of the vocab), and ids past a
        # checkpoint's vocab must never reach it. The model only ever
        # suggests cards below this; newer ones can still be in a deck.
        models = [self.staple_model, self.synergy_model, self.prune_model]
        self.model_vocab_size = min(m.card_embedding.vocab_size for m in models)
        self.version = version
        self.card_stats = card_stats or {}
        self.prices = prices or PriceBook(allow_download=False, data={})
        self._lock = threading.Lock()
        self._search_index = self._build_search_index(self.tok)

    # ------------------------------------------------------------------
    # Card lookup
    # ------------------------------------------------------------------

    @staticmethod
    def _build_search_index(tok: CardTokenizer) -> list[tuple[str, CardRecord]]:
        index = [(_normalize_name(card.name), card) for card in tok.card_by_token.values()]
        index.sort(key=lambda pair: pair[0])
        return index

    def search(
        self, query: str, commander_only: bool = False, limit: int = 10,
        colors: str | None = None, basics: bool = False,
    ) -> list[dict]:
        """Autocomplete: whole-name prefix matches first, then word-prefix,
        then substring matches, each alphabetical.

        `colors` (e.g. "BG"; "" for colorless) keeps only cards whose color
        identity fits within it, for adding cards to a deck. `basics` also
        offers matching basic lands, which aren't in the tokenizer's vocab
        (first, since they're exact prefix matches)."""
        q = _normalize_name(query)
        if not q:
            return []
        allowed = set(colors) if colors is not None else None
        basic_matches = [
            self._basic_summary(name) for name in sorted(BASIC_LAND_NAMES)
            if basics and not commander_only and _normalize_name(name).startswith(q)
            and (allowed is None or set(self._basic_card(name, 1, "input").color_identity) <= allowed)
        ]
        buckets: tuple[list, list, list] = ([], [], [])
        for norm, card in self._search_index:
            if commander_only and not is_commander_eligible(card):
                continue
            if allowed is not None and not set(card.color_identity) <= allowed:
                continue
            if norm.startswith(q):
                buckets[0].append(card)
            elif f" {q}" in norm:
                buckets[1].append(card)
            elif q in norm:
                buckets[2].append(card)
        results = (buckets[0] + buckets[1] + buckets[2])[: max(0, limit - len(basic_matches))]
        return basic_matches + [self._card_summary(card) for card in results]

    def _basic_summary(self, name: str) -> dict:
        card = self._basic_card(name, 1, "input")
        return {
            "name": name, "type_line": card.type_line, "mana_cost": "", "cmc": 0.0, "category": "Land",
            "oracle_text": card.oracle_text, "stats": [], "price": card.price, "tier": None,
            "color_identity": card.color_identity, "multiples_allowed": True, "banned": False,
        }

    def _card_summary(self, card: CardRecord) -> dict:
        """A search result: enough to show the card (cost, rules text, stats,
        price) anywhere a deck card can be shown, e.g. in the ban list."""
        return {
            "name": card.name,
            "type_line": card.type_line,
            "mana_cost": card.mana_cost,
            "cmc": card.cmc,
            "category": category_of(card.type_line),
            "oracle_text": card.oracle_text,
            "stats": self.card_stats.get(card.oracle_id, []),
            "price": self.prices.get(card.oracle_id),
            "tier": self.tiers.get(self.tok.token_to_id[self.tok.oracle_id_to_token[card.oracle_id]]),
            "color_identity": card.color_identity,
            "multiples_allowed": _allows_multiple(card),
            "banned": card.commander_legality == "banned",
        }

    def _card(self, name: str) -> CardRecord:
        return self.tok.card_by_token[self.tok.resolve_name(name)]

    def _suggestions(self, name: str, commander_only: bool = False) -> list[str]:
        norm = _normalize_name(name)
        candidates = get_close_matches(norm, self.tok._name_index.keys(), n=8, cutoff=0.6)
        names: list[str] = []
        for c in candidates:
            card = self.tok.card_by_token[self.tok._name_index[c]]
            if commander_only and not is_commander_eligible(card):
                continue
            if card.name not in names:
                names.append(card.name)
        if not names:
            names = [r["name"] for r in self.search(name, commander_only=commander_only, limit=3)]
        return names[:3]

    def _resolve(self, name: str, warnings: list[str]) -> CardRecord | None:
        result = self.tok.resolve_name_with_confidence(name)
        if result is None:
            return None
        token, exact = result
        card = self.tok.card_by_token[token]
        if not exact:
            warnings.append(f'Read "{name}" as "{card.name}".')
        return card

    def _deck_card(self, card: CardRecord, quantity: int, source: str, score: float | None) -> DeckCard:
        return DeckCard(
            name=card.name, quantity=quantity, source=source, score=score,
            type_line=card.type_line, mana_cost=card.mana_cost, cmc=card.cmc,
            category=category_of(card.type_line), oracle_text=card.oracle_text,
            stats=self.card_stats.get(card.oracle_id, []), price=self.prices.get(card.oracle_id),
            tier=self.tiers.get(self.tok.token_to_id[self.tok.oracle_id_to_token[card.oracle_id]]),
            color_identity=card.color_identity, multiples_allowed=_allows_multiple(card),
        )

    def _basic_card(self, name: str, quantity: int, source: str) -> DeckCard:
        return DeckCard(
            name=name, quantity=quantity, source=source, score=None,
            type_line=BASIC_TYPE_LINES[name], mana_cost="", cmc=0.0, category="Land",
            oracle_text=BASIC_TEXT[name], price=self.prices.basic(name),
            color_identity=[BASIC_COLOR[name]] if name in BASIC_COLOR else [], multiples_allowed=True,
        )

    # ------------------------------------------------------------------
    # Completion
    # ------------------------------------------------------------------

    def model_knows(self, card: CardRecord) -> bool:
        return self.tok.token_to_id[self.tok.oracle_id_to_token[card.oracle_id]] < self.model_vocab_size

    def complete(
        self,
        commander_names: list[str],
        decklist: str,
        target_lands: int | None = None,
        prune: bool = True,
        locked: list[tuple[str, int, str, bool]] | None = None,
        exclude: list[str] | None = None,
    ) -> Completion:
        """`locked`: (name, quantity, source) cards kept from a previous
        completion. They're treated like the user's own cards -- model
        context, never trimmed -- but returned with their original source and
        locked=True. Rerolling = same request with the cards to keep locked.

        `exclude`: names the model must never suggest (the user's ban list).
        Matched by exact name (case and
        punctuation ignored, no fuzzy matching, so a typo can't ban the wrong
        card); unknown names are skipped with a warning. A banned card that's
        also in the user's own list or locked is kept, with a warning."""
        from training.ensemble import ensemble_complete_deck

        warnings: list[str] = []
        commander_names = [n.strip() for n in commander_names if n.strip()]
        if not 1 <= len(commander_names) <= 2:
            raise DeckInputError("Choose one commander, or two for a partner pair.")

        # -- commanders --
        commanders: list[CardRecord] = []
        unresolved: list[dict] = []
        for name in commander_names:
            card = self._resolve(name, warnings)
            if card is None:
                unresolved.append({"name": name, "line": None, "suggestions": self._suggestions(name, commander_only=True)})
            else:
                commanders.append(card)
        if unresolved:
            raise DeckInputError("Couldn't find that commander.", unresolved)
        too_new = [c.name for c in commanders if not self.model_knows(c)]
        if too_new:
            raise DeckInputError(f"{' and '.join(too_new)} is newer than the model, so it can't build around it yet.")
        if len({c.oracle_id for c in commanders}) != len(commanders):
            raise DeckInputError("The two commanders are the same card.")
        for card in commanders:
            if not is_commander_eligible(card):
                warnings.append(f"{card.name} can't normally be a commander.")
            if card.commander_legality == "banned":
                warnings.append(f"{card.name} is banned in Commander.")
        if len(commanders) == 2 and not _partner_pair_ok(*commanders):
            warnings.append(f"{commanders[0].name} and {commanders[1].name} don't look like a legal partner pair.")
        colors: set[str] = set()
        for card in commanders:
            colors |= set(card.color_identity)

        # -- decklist --
        known: dict[str, tuple[CardRecord, int]] = {}  # oracle_id -> (card, qty), input order
        basics: Counter = Counter()
        commander_ids = {c.oracle_id for c in commanders}
        for entry in parse_decklist(decklist):
            basic = next((b for b in BASIC_LAND_NAMES if b.lower() == entry.name.lower()), None)
            if basic:
                basics[basic] += entry.quantity
                continue
            card = self._resolve(entry.name, warnings)
            if card is None:
                unresolved.append({"name": entry.name, "line": entry.line_number, "suggestions": self._suggestions(entry.name)})
                continue
            if card.oracle_id in commander_ids:
                continue  # exports often list the commander inside the deck too
            _, qty = known.get(card.oracle_id, (card, 0))
            known[card.oracle_id] = (card, qty + entry.quantity)
        if unresolved:
            noun = "card" if len(unresolved) == 1 else "cards"
            raise DeckInputError(f"Couldn't find {len(unresolved)} {noun} in the card database.", unresolved)

        # -- locked cards from a previous completion --
        # Each entry is (name, quantity, source, is_locked); is_locked=False
        # marks a card the reroll keeps without the user locking it. Only
        # locked cards are safe from being cut, and at most 100 minus the
        # commanders can be locked, so a deck can always be cut to 100.
        locked_sources: dict[str, str] = {}  # oracle_id -> original source
        protected: set[str] = set()  # oracle_ids the user locked
        locked_basics: list[tuple[str, int, str, bool]] = []
        lock_cap = DECK_SIZE - len(commanders)
        locked_count = sum(entry[1] for entry in locked or [] if len(entry) < 4 or entry[3])
        if locked_count > lock_cap:
            raise DeckInputError(f"You can lock at most {lock_cap} cards (you locked {locked_count}).")
        for name, qty, source, *flag in locked or []:
            is_locked = flag[0] if flag else True
            basic = next((b for b in BASIC_LAND_NAMES if b.lower() == name.strip().lower()), None)
            if basic:
                locked_basics.append((basic, qty, source, is_locked))
                continue
            card = self._resolve(name, [])
            if card is None:
                raise DeckInputError(f"Couldn't find locked card {name!r}.")
            if card.oracle_id in commander_ids or card.oracle_id in known:
                continue  # already in the deck as a commander or your own card
            known[card.oracle_id] = (card, qty)
            locked_sources[card.oracle_id] = source
            if is_locked:
                protected.add(card.oracle_id)

        for oid, (card, qty) in list(known.items()):
            if qty > 1 and not _allows_multiple(card):
                warnings.append(f"Commander is singleton: kept 1 copy of {card.name} (you listed {qty}).")
                known[oid] = (card, 1)
            if not set(card.color_identity) <= colors:
                warnings.append(f"{card.name} is outside your commander's color identity.")
            if card.commander_legality == "banned":
                warnings.append(f"{card.name} is banned in Commander.")
        if target_lands is None:
            target_lands = DEFAULT_TARGET_LANDS
        if target_lands < MIN_TARGET_LANDS:
            raise DeckInputError(f"Decks need at least {MIN_TARGET_LANDS} lands.")

        # -- over 100: cut down to size (locked and user cards included:
        # that's all an over-100 deck can be made of) --
        size = len(commanders) + sum(q for _, q in known.values()) + sum(basics.values())
        size += sum(q for _, q, _, _ in locked_basics)
        trimmed = False
        if size > DECK_SIZE:
            with self._lock, torch.no_grad():
                cut, left_over = self._trim_to_size(commanders, colors, known, protected, basics, locked_basics,
                                                    size - DECK_SIZE, target_lands)
            trimmed = True
            listing = ", ".join(f"{q} {name}" if q > 1 else name for name, q in cut.items())
            warnings.append(f"Your deck had {size} cards, so {size - DECK_SIZE - left_over} were cut "
                            f"to bring it to {DECK_SIZE}: {listing}.")
            if left_over:  # only reachable through the API with nothing left to cut
                warnings.append(f"It's still {left_over} over {DECK_SIZE}: every card left is locked.")

        unknown_to_model = [card.name for card, _ in known.values() if not self.model_knows(card)]
        if unknown_to_model:
            warnings.append(
                f"The model hasn't seen {', '.join(unknown_to_model)} yet (newer than its training data). "
                f"{'It stays' if len(unknown_to_model) == 1 else 'They stay'} in your deck, "
                f"but the picks don't take {'it' if len(unknown_to_model) == 1 else 'them'} into account."
            )
        for basic in basics:
            if basic != "Wastes" and BASIC_COLOR[basic] not in colors:
                warnings.append(f"{basic} is outside your commander's color identity.")

        input_cards = []
        for oid, (card, qty) in known.items():
            deck_card = self._deck_card(card, qty, locked_sources.get(oid, "input"), None)
            deck_card.locked = oid in protected
            input_cards.append(deck_card)
        input_cards += [self._basic_card(name, qty, "input") for name, qty in basics.items()]
        for name, qty, source, is_locked in locked_basics:
            deck_card = self._basic_card(name, qty, source)
            deck_card.locked = is_locked
            input_cards.append(deck_card)
        commander_cards = [self._deck_card(card, 1, "input", None) for card in commanders]

        # -- ban list --
        exclude_ids: list[int] = []
        unknown_excluded: list[str] = []
        seen_tokens: set[str] = set()
        for name in exclude or []:
            token = self.tok._name_index.get(_normalize_name(name))
            if token is None:
                if name not in unknown_excluded:
                    unknown_excluded.append(name)
                continue
            if token in seen_tokens:
                continue
            seen_tokens.add(token)
            card = self.tok.card_by_token[token]
            if card.oracle_id in known or card.oracle_id in commander_ids:
                warnings.append(f"{card.name} is on your ban list, but it's in your deck, so it was kept.")
                continue
            exclude_ids.append(self.tok.token_to_id[token])
        if unknown_excluded:
            warnings.append(f"Ignored unknown cards on your ban list: {', '.join(unknown_excluded)}.")

        self.prices.maybe_refresh()
        result = Completion(commanders=commander_cards, cards=input_cards, warnings=warnings,
                            target_lands=target_lands, prices_updated=self.prices.built_at)

        open_slots = DECK_SIZE - len(commanders) - sum(c.quantity for c in input_cards)
        if open_slots <= 0:  # (below 0 only when trimming had to stop at the land target)
            if not trimmed:
                warnings.append(f"Your list is already {DECK_SIZE} cards, so nothing was added.")
            return self._warn_if_short(result)

        # -- model picks, one per open slot. A fresh seed every request, so
        # submitting the same inputs again gives a different deck. --
        seed = secrets.randbelow(2**31)
        with self._lock, torch.no_grad():
            picks = ensemble_complete_deck(
                [c.name for c in commanders], [c.name for c, _ in known.values() if self.model_knows(c)], open_slots,
                self.tok, self.device, self.staple_model, self.synergy_model, self.tiers,
                prune_model=self.prune_model if prune else None,
                chunk_size=GENERATION_CHUNK_SIZE,
                seed=seed, allow_banned=False, exclude_ids=exclude_ids,
            )
        pick_cards = [(self._card(name), score, source) for name, score, source in picks]

        # -- land target: swap the weakest nonland picks for basics --
        land_count = sum(qty for card, qty in known.values() if is_front_land(card)) + sum(basics.values())
        land_count += sum(qty for _, qty, _, _ in locked_basics)
        land_count += sum(is_front_land(card) for card, _, _ in pick_cards)
        basics_needed = max(0, target_lands - land_count)
        if basics_needed:
            removable = [
                i for i, (card, _, source) in enumerate(pick_cards)
                if not is_front_land(card) and source == "synergy"
                and self.tiers.get(self.tok.token_to_id[self.tok.oracle_id_to_token[card.oracle_id]]) != "staple"
            ]
            removable.sort(key=lambda i: pick_cards[i][1])
            drop = set(removable[:basics_needed])
            basics_needed = len(drop)
            pick_cards = [p for i, p in enumerate(pick_cards) if i not in drop]

        result.cards += [self._deck_card(card, 1, source, score) for card, score, source in pick_cards]

        if basics_needed:
            pips: Counter = Counter()
            for card in commanders + [c for c, _ in known.values()] + [c for c, _, _ in pick_cards]:
                if not is_front_land(card):
                    for color, n in zip(COLORS, _pip_counts(card.mana_cost)):
                        pips[color] += float(n)
            for name, qty in allocate_basics(basics_needed, colors, pips).items():
                result.cards.append(self._basic_card(name, qty, "basic"))

        return self._warn_if_short(result)

    def _trim_to_size(
        self,
        commanders: list[CardRecord],
        colors: set[str],
        known: dict[str, tuple[CardRecord, int]],
        protected: set[str],
        basics: Counter,
        locked_basics: list[tuple[str, int, str, bool]],
        excess: int,
        target_lands: int,
    ) -> tuple[Counter, int]:
        """Cuts `excess` cards from an over-100 deck, editing `known`,
        `basics` and `locked_basics` in place. Returns (what was cut, by
        name; how many it couldn't cut). Locked cards (`protected`, and
        locked basics) are never cut. Among the rest, in order:

        1. Cards that make the deck illegal: outside the commander's color
           identity, or banned in Commander.
        2. Basic lands above the land target, from the biggest pile.
        3. Nonland, non-staple cards, by the prune model's fit score (lowest
           first, re-scored after every cut, as the ensemble's own pruning
           does). Staples are held back until after these: the prune model
           is known to doubt near-universal cards wrongly.
        4. Staples, by the same score.
        5. Nonbasic lands above the target, by the same score.
        6. Cards the model doesn't know yet (no score), last listed first.
        7. Only if still over (the locked cards leave nothing else): lands
           below the target, basics first. Locks win over the land target.
        """
        cut: Counter = Counter()

        def land_count() -> int:
            return (sum(q for c, q in known.values() if is_front_land(c)) + sum(basics.values())
                    + sum(q for _, q, _, _ in locked_basics))

        def token_id(card: CardRecord) -> int:
            return self.tok.token_to_id[self.tok.oracle_id_to_token[card.oracle_id]]

        def cuttable() -> list[str]:
            return [oid for oid in known if oid not in protected]

        def drop_one(oid: str) -> None:
            card, qty = known[oid]
            if qty > 1:
                known[oid] = (card, qty - 1)
            else:
                del known[oid]
            cut[card.name] += 1

        def lands_spare(card: CardRecord) -> bool:
            return not is_front_land(card) or land_count() > target_lands

        def cut_basic(above_target: bool) -> bool:
            """Cuts one unlocked basic from the biggest pile; False if none can go."""
            if above_target and land_count() <= target_lands:
                return False
            piles = Counter(basics)
            for name, qty, _, is_locked in locked_basics:
                if not is_locked:
                    piles[name] += qty
            if not piles:
                return False
            name = max(piles, key=piles.get)
            if basics[name]:
                basics[name] -= 1
                if not basics[name]:
                    del basics[name]
            else:
                i = next(i for i, (n, _, _, lk) in enumerate(locked_basics) if n == name and not lk)
                n, qty, source, is_locked = locked_basics[i]
                if qty > 1:
                    locked_basics[i] = (n, qty - 1, source, is_locked)
                else:
                    del locked_basics[i]
            cut[name] += 1
            return True

        # 1. illegal cards
        for oid in cuttable():
            card = known[oid][0]
            illegal = not set(card.color_identity) <= colors or card.commander_legality == "banned"
            while excess and illegal and oid in known and lands_spare(card):
                drop_one(oid)
                excess -= 1

        # 2. basics above the land target, biggest pile first
        while excess and cut_basic(above_target=True):
            excess -= 1

        # 3-5. by the prune model's score, re-scored after every cut
        cmdr_ids = [token_id(c) for c in commanders]
        cmdr_tensor = torch.tensor([cmdr_ids], dtype=torch.long, device=self.device)
        cmdr_mask = torch.ones_like(cmdr_tensor, dtype=torch.bool)

        def is_staple(card: CardRecord) -> bool:
            return self.tiers.get(token_id(card)) == "staple"

        stages = [
            lambda c: not is_front_land(c) and not is_staple(c),
            lambda c: not is_front_land(c) and is_staple(c),
            lambda c: is_front_land(c) and land_count() > target_lands,
            lambda c: is_front_land(c),  # 7, if the locks leave nothing else
        ]
        for n, eligible in enumerate(stages):
            if n == 3:
                # 6. cards the model can't score, last listed first
                for oid in reversed(cuttable()):
                    card = known[oid][0]
                    while excess and oid in known and not self.model_knows(card) and lands_spare(card):
                        drop_one(oid)
                        excess -= 1
                # 7. basics, then other lands, even below the target
                while excess and cut_basic(above_target=False):
                    excess -= 1
            while excess:
                scored = [oid for oid, (c, _) in known.items() if self.model_knows(c)]
                candidates = [oid for oid in scored if oid not in protected and eligible(known[oid][0])]
                if not candidates:
                    break
                deck_ids = [token_id(known[oid][0]) for oid in scored]
                deck_tensor = torch.tensor([deck_ids], dtype=torch.long, device=self.device)
                logits = self.prune_model(deck_tensor, torch.ones_like(deck_tensor, dtype=torch.bool),
                                          cmdr_tensor, cmdr_mask)[0].cpu()
                fit = dict(zip(scored, torch.sigmoid(logits).tolist()))
                drop_one(min(candidates, key=fit.__getitem__))
                excess -= 1
        return cut, excess

    @staticmethod
    def _warn_if_short(result: Completion) -> Completion:
        """Basics only replace the model's own new picks, never the user's or
        locked cards, so a deck made mostly of those can end up short."""
        lands = sum(c.quantity for c in result.cards if c.category == "Land")
        if lands < result.target_lands:
            result.warnings.append(
                f"This deck has {lands} lands, below the {result.target_lands}-land target, because your own and "
                "locked cards fill the slots basics would need. Add lands to your list or unlock some cards."
            )
        return result