draftomen 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. draftomen/__init__.py +17 -0
  2. draftomen/assets/draftomen.icns +0 -0
  3. draftomen/assets/draftomen.ico +0 -0
  4. draftomen/assets/draftomen_logo.png +0 -0
  5. draftomen/audit.py +606 -0
  6. draftomen/backtest.py +449 -0
  7. draftomen/benchmark.py +834 -0
  8. draftomen/carddb.py +1869 -0
  9. draftomen/cardimages.py +313 -0
  10. draftomen/cli.py +891 -0
  11. draftomen/config.py +133 -0
  12. draftomen/deckbuilder.py +2723 -0
  13. draftomen/events.py +772 -0
  14. draftomen/logfollow.py +451 -0
  15. draftomen/mock_session.py +745 -0
  16. draftomen/paths.py +120 -0
  17. draftomen/pickengine.py +1117 -0
  18. draftomen/pool.py +1259 -0
  19. draftomen/preferences.py +289 -0
  20. draftomen/qml/AboutDialog.qml +156 -0
  21. draftomen/qml/AppBar.qml +120 -0
  22. draftomen/qml/BacktestView.qml +316 -0
  23. draftomen/qml/BuildView.qml +1151 -0
  24. draftomen/qml/CardPreview.qml +434 -0
  25. draftomen/qml/DimensionalButton.qml +42 -0
  26. draftomen/qml/DimensionalComboBox.qml +182 -0
  27. draftomen/qml/DimensionalSurface.qml +118 -0
  28. draftomen/qml/DimensionalTabButton.qml +41 -0
  29. draftomen/qml/LiveDraftView.qml +468 -0
  30. draftomen/qml/Main.qml +175 -0
  31. draftomen/qml/NavigationRail.qml +226 -0
  32. draftomen/qml/PoolSummaryPanel.qml +322 -0
  33. draftomen/qml/PrivacyDialog.qml +96 -0
  34. draftomen/qml/RecentPickThumbnail.qml +83 -0
  35. draftomen/qml/RecentPicksGallery.qml +333 -0
  36. draftomen/qml/RecommendationRow.qml +404 -0
  37. draftomen/qml/SettingsSwitch.qml +141 -0
  38. draftomen/qml/SettingsView.qml +531 -0
  39. draftomen/qml/StateBanner.qml +189 -0
  40. draftomen/qml/StatusStrip.qml +84 -0
  41. draftomen/qml/Theme.qml +71 -0
  42. draftomen/qml/qmldir +23 -0
  43. draftomen/qt_adapter.py +884 -0
  44. draftomen/qt_gui.py +338 -0
  45. draftomen/qt_mock.py +63 -0
  46. draftomen/ranking.py +126 -0
  47. draftomen/replay.py +480 -0
  48. draftomen/session.py +3668 -0
  49. draftomen/setinfo.py +26 -0
  50. draftomen/seventeen.py +2766 -0
  51. draftomen/splash.py +617 -0
  52. draftomen/tui.py +4007 -0
  53. draftomen/watch.py +336 -0
  54. draftomen-0.3.0.dist-info/METADATA +156 -0
  55. draftomen-0.3.0.dist-info/RECORD +59 -0
  56. draftomen-0.3.0.dist-info/WHEEL +5 -0
  57. draftomen-0.3.0.dist-info/entry_points.txt +6 -0
  58. draftomen-0.3.0.dist-info/licenses/LICENSE +21 -0
  59. draftomen-0.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1117 @@
1
+ """Card scoring and ranked pack recommendations.
2
+ Keep pick-quality math isolated from CLI and TUI rendering code.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import math
8
+ from dataclasses import dataclass, replace
9
+ from functools import cmp_to_key
10
+
11
+ from draftomen.carddb import CardDatabase, CardInfo
12
+ from draftomen.config import COLOR_PAIRS, PICK_ENGINE, SPLASH, PickEngineConfig
13
+ from draftomen.seventeen import (
14
+ FORMAT_RATING_SOURCE,
15
+ NEUTRAL_PRIOR_SOURCE,
16
+ PREMIER_DRAFT_FORMAT,
17
+ QUICK_DRAFT_FORMAT,
18
+ RatingSampleCounts,
19
+ RatingSourceMetadata,
20
+ ResolvedCardRating,
21
+ SeventeenLandsData,
22
+ )
23
+ from draftomen.splash import (
24
+ SplashAssessment,
25
+ SplashState,
26
+ assess_splash_card,
27
+ card_is_castable_in_pair,
28
+ infer_splash_state,
29
+ )
30
+ CLOSE_DO_SCORE_THRESHOLD = 3.0
31
+ CLOSE_WIN_RATE_THRESHOLD = 0.01
32
+ STANDARD_BASIC_LAND_NAMES = frozenset(
33
+ {"Plains", "Island", "Swamp", "Mountain", "Forest"}
34
+ )
35
+ STANDARD_BASIC_LAND_TYPE_LINES = frozenset(
36
+ f"Basic Land — {name}" for name in STANDARD_BASIC_LAND_NAMES
37
+ )
38
+
39
+
40
+ def recommendation_confidence_summary(
41
+ *,
42
+ cards: tuple[ScoredCard, ...],
43
+ ranking_mode: str,
44
+ phase: str,
45
+ ) -> str | None:
46
+ """Summarize how decisive the current recommendation evidence is.
47
+ The input cards must already be ordered by ``ranking_mode``.
48
+ """
49
+
50
+ close_label = _close_pick_label(cards=cards, ranking_mode=ranking_mode)
51
+ if phase == "open":
52
+ if close_label is not None:
53
+ return f"early/open {close_label}; stay flexible"
54
+
55
+ return "early/open pick — stay flexible"
56
+
57
+ return close_label
58
+
59
+
60
+ def recommendation_explanation(
61
+ *,
62
+ scored_card: ScoredCard,
63
+ inferred_pair: str | None,
64
+ ) -> str:
65
+ """Describe one recommendation using only scoring and pool evidence.
66
+ Wording describes supporting evidence rather than promising an outcome.
67
+ """
68
+
69
+ if scored_card.freely_available_basic:
70
+ return (
71
+ f"{scored_card.card.name} is freely available during deck building, "
72
+ "so it receives 0 DO points and ranks after draftable cards."
73
+ )
74
+
75
+ fit = scored_card.color_fit.replace("-", " ")
76
+ pool_context = (
77
+ f"the inferred {inferred_pair} pool"
78
+ if inferred_pair is not None
79
+ else "an open-color pool"
80
+ )
81
+ gih_win_rate = scored_card.rating.gih_win_rate
82
+ alsa = scored_card.rating.average_last_seen_at
83
+ if gih_win_rate is not None:
84
+ rating_evidence = (
85
+ f"{scored_card.source_label} GIH win rate "
86
+ f"{gih_win_rate:.1%}"
87
+ )
88
+ elif scored_card.no_data and alsa is not None:
89
+ rating_evidence = (
90
+ f"neutral-prior estimate adjusted by ALSA "
91
+ f"{alsa:.2f}"
92
+ )
93
+ elif scored_card.no_data:
94
+ rating_evidence = "neutral-prior estimate with no GIH data"
95
+ else:
96
+ rating_evidence = f"{scored_card.source_label} rating data"
97
+
98
+ return (
99
+ f"{fit.capitalize()} fit supports a {scored_card.score} DO-point "
100
+ f"candidate for {pool_context}; {rating_evidence} informs the score."
101
+ )
102
+
103
+
104
+ def _close_pick_label(
105
+ *,
106
+ cards: tuple[ScoredCard, ...],
107
+ ranking_mode: str,
108
+ ) -> str | None:
109
+ draftable_cards = tuple(
110
+ card for card in cards if not card.freely_available_basic
111
+ )
112
+ if len(draftable_cards) < 2:
113
+ return None
114
+
115
+ top_card, second_card = draftable_cards[:2]
116
+ if ranking_mode == "score":
117
+ score_delta = max(0.0, top_card.raw_score - second_card.raw_score)
118
+ if score_delta <= CLOSE_DO_SCORE_THRESHOLD:
119
+ return f"close pick — top two within {score_delta:.1f} DO points"
120
+
121
+ return None
122
+
123
+ if ranking_mode == "win_rate":
124
+ top_win_rate = top_card.rating.gih_win_rate
125
+ second_win_rate = second_card.rating.gih_win_rate
126
+ if top_win_rate is None or second_win_rate is None:
127
+ return None
128
+
129
+ win_rate_delta = max(0.0, top_win_rate - second_win_rate)
130
+ if win_rate_delta <= CLOSE_WIN_RATE_THRESHOLD:
131
+ return f"close pick — top two within {win_rate_delta * 100:.1f}pp WR"
132
+
133
+ return None
134
+
135
+
136
+ @dataclass(frozen=True, slots=True)
137
+ class ScoreNormalization:
138
+ """Rating bounds used to normalize raw card ratings.
139
+ The neutral prior is centered at score 50 by construction.
140
+ """
141
+
142
+ lower_rating: float
143
+ upper_rating: float
144
+ neutral_rating: float
145
+
146
+
147
+ @dataclass(frozen=True, slots=True)
148
+ class ColorCommitment:
149
+ """Color inference for the current pick.
150
+ The level is a 0.0-1.0 ramp from open to locked.
151
+ """
152
+
153
+ pick_index: int
154
+ pool_size: int
155
+ color_weights: tuple[tuple[str, float], ...]
156
+ inferred_pair: str | None
157
+ level: float
158
+
159
+ @property
160
+ def locked(self) -> bool:
161
+ """Return whether commitment is fully locked.
162
+ Locked picks may use pair-filtered ratings when available.
163
+ """
164
+
165
+ return self.level >= 1.0
166
+
167
+ @property
168
+ def phase(self) -> str:
169
+ """Return a compact label for status-line rendering.
170
+ This keeps CLI and TUI language consistent.
171
+ """
172
+
173
+ if self.level <= 0.0:
174
+ return "open"
175
+
176
+ if self.locked:
177
+ return "locked"
178
+
179
+ return "building"
180
+
181
+
182
+ @dataclass(frozen=True, slots=True)
183
+ class ScoredCard:
184
+ """One offered card with its computed pick score.
185
+ Rows keep source metadata so renderers can mark fallbacks clearly.
186
+ """
187
+
188
+ card: CardInfo
189
+ rating: ResolvedCardRating
190
+ original_index: int
191
+ base_rating: float
192
+ base_score: float
193
+ color_factor: float
194
+ adjusted_rating: float
195
+ raw_score: float
196
+ score: int
197
+ source_label: str
198
+ color_fit: str
199
+ pair_tiebreaker_pair: str | None
200
+ pair_tiebreaker_win_rate: float | None
201
+ pair_tiebreaker_weight: float | None
202
+ score_sort_index: int
203
+ splash: SplashAssessment
204
+ freely_available_basic: bool
205
+
206
+ @property
207
+ def no_data(self) -> bool:
208
+ """Return whether this row used the neutral-prior fallback.
209
+ Renderers mark these cards because no strong GIH sample was available.
210
+ """
211
+
212
+ return self.rating.neutral_prior and not self.freely_available_basic
213
+
214
+ @property
215
+ def prior_adjusted_by_alsa(self) -> bool:
216
+ """Return whether ALSA moved the neutral prior up or down.
217
+ Missing ALSA leaves the neutral prior exactly centered.
218
+ """
219
+
220
+ return self.no_data and self.rating.average_last_seen_at is not None
221
+
222
+
223
+ @dataclass(frozen=True, slots=True)
224
+ class ScoredPack:
225
+ """A recommendation-ordered view of one offered pack.
226
+ Source summary describes the actual data used in this pack.
227
+ """
228
+
229
+ cards: tuple[ScoredCard, ...]
230
+ normalization: ScoreNormalization
231
+ source_summary: str
232
+ commitment: ColorCommitment
233
+ splash_state: SplashState
234
+
235
+
236
+ class PickEngine:
237
+ """Score offered cards using 17Lands data and configured priors.
238
+ Pool color weights progressively bias scores toward an inferred pair.
239
+ """
240
+
241
+ def __init__(
242
+ self,
243
+ *,
244
+ ratings_data: SeventeenLandsData | None = None,
245
+ config: PickEngineConfig = PICK_ENGINE,
246
+ splash_enabled: bool = SPLASH.enabled_by_default,
247
+ ) -> None:
248
+ self.ratings_data = ratings_data
249
+ self.config = config
250
+ self.splash_enabled = splash_enabled
251
+ self.normalization = _normalization_from_data(
252
+ ratings_data=ratings_data,
253
+ config=config,
254
+ )
255
+
256
+ def score_pack(
257
+ self,
258
+ *,
259
+ offered_grp_ids: tuple[int, ...],
260
+ card_database: CardDatabase,
261
+ pool_grp_ids: tuple[int, ...] = (),
262
+ pick_index: int | None = None,
263
+ ) -> ScoredPack:
264
+ """Return offered cards in recommendation order.
265
+ Open close-pick groups can use pair win rates as a final tiebreaker.
266
+ """
267
+
268
+ commitment = _color_commitment(
269
+ pool_grp_ids=pool_grp_ids,
270
+ pick_index=_pick_index(pool_grp_ids=pool_grp_ids, pick_index=pick_index),
271
+ card_database=card_database,
272
+ ratings_data=self.ratings_data,
273
+ config=self.config,
274
+ )
275
+ splash_state = infer_splash_state(
276
+ pool_grp_ids=pool_grp_ids,
277
+ card_database=card_database,
278
+ ratings_data=self.ratings_data,
279
+ base_pair=(
280
+ commitment.inferred_pair
281
+ if commitment.level > 0.0
282
+ else None
283
+ ),
284
+ enabled=self.splash_enabled,
285
+ )
286
+ best_on_color_score = self._best_on_color_score(
287
+ offered_grp_ids=offered_grp_ids,
288
+ card_database=card_database,
289
+ commitment=commitment,
290
+ )
291
+ scored_cards = tuple(
292
+ self._score_card(
293
+ grp_id=grp_id,
294
+ original_index=index,
295
+ card_database=card_database,
296
+ ratings_data=self.ratings_data,
297
+ commitment=commitment,
298
+ splash_state=splash_state,
299
+ best_on_color_score=best_on_color_score,
300
+ )
301
+ for index, grp_id in enumerate(offered_grp_ids)
302
+ )
303
+ sorted_cards = _score_sorted_cards(
304
+ cards=scored_cards,
305
+ commitment=commitment,
306
+ ratings_data=self.ratings_data,
307
+ offered_count=len(offered_grp_ids),
308
+ config=self.config,
309
+ )
310
+ return ScoredPack(
311
+ cards=sorted_cards,
312
+ normalization=self.normalization,
313
+ source_summary=_source_summary(cards=sorted_cards),
314
+ commitment=commitment,
315
+ splash_state=splash_state,
316
+ )
317
+
318
+ def _best_on_color_score(
319
+ self,
320
+ *,
321
+ offered_grp_ids: tuple[int, ...],
322
+ card_database: CardDatabase,
323
+ commitment: ColorCommitment,
324
+ ) -> float | None:
325
+ pair = commitment.inferred_pair
326
+ if pair is None:
327
+ return None
328
+
329
+ scores: list[float] = []
330
+ for grp_id in offered_grp_ids:
331
+ card = card_database.lookup(grp_id=grp_id)
332
+ if card.unknown or _is_freely_available_basic_land(card=card):
333
+ continue
334
+ if not card_is_castable_in_pair(card=card, base_pair=pair):
335
+ continue
336
+
337
+ rating = _rating_for(
338
+ ratings_data=self.ratings_data,
339
+ grp_id=grp_id,
340
+ config=self.config,
341
+ commitment=commitment,
342
+ )
343
+ scores.append(
344
+ _normalized_score(
345
+ adjusted_rating=_base_rating(rating=rating, config=self.config),
346
+ normalization=self.normalization,
347
+ )
348
+ )
349
+
350
+ return max(scores, default=None)
351
+
352
+ def _score_card(
353
+ self,
354
+ *,
355
+ grp_id: int,
356
+ original_index: int,
357
+ card_database: CardDatabase,
358
+ ratings_data: SeventeenLandsData | None,
359
+ commitment: ColorCommitment,
360
+ splash_state: SplashState,
361
+ best_on_color_score: float | None,
362
+ ) -> ScoredCard:
363
+ card = card_database.lookup(grp_id=grp_id)
364
+ freely_available_basic = _is_freely_available_basic_land(card=card)
365
+ rating = _rating_for(
366
+ ratings_data=self.ratings_data,
367
+ grp_id=grp_id,
368
+ config=self.config,
369
+ commitment=commitment,
370
+ )
371
+ base_rating = (
372
+ self.normalization.lower_rating
373
+ if freely_available_basic
374
+ else _base_rating(rating=rating, config=self.config)
375
+ )
376
+ base_score = _normalized_score(
377
+ adjusted_rating=base_rating,
378
+ normalization=self.normalization,
379
+ )
380
+ global_rating = _rating_for(
381
+ ratings_data=self.ratings_data,
382
+ grp_id=grp_id,
383
+ config=self.config,
384
+ )
385
+ splash = assess_splash_card(
386
+ card=card,
387
+ grade=global_rating.letter_grade,
388
+ base_score=base_score,
389
+ best_on_color_score=best_on_color_score,
390
+ locked=commitment.locked,
391
+ state=splash_state,
392
+ )
393
+ color_fit = splash.classification
394
+ color_factor = _color_factor(
395
+ color_fit=color_fit,
396
+ commitment=commitment,
397
+ config=self.config,
398
+ )
399
+ raw_score = _clamp(
400
+ value=base_score * color_factor,
401
+ lower=0.0,
402
+ upper=100.0,
403
+ )
404
+ if freely_available_basic:
405
+ pair_tiebreaker_pair = None
406
+ pair_tiebreaker_win_rate = None
407
+ pair_tiebreaker_weight = None
408
+ else:
409
+ (
410
+ pair_tiebreaker_pair,
411
+ pair_tiebreaker_win_rate,
412
+ pair_tiebreaker_weight,
413
+ ) = _pair_tiebreaker_for_card(
414
+ card=card,
415
+ base_rating=base_rating,
416
+ commitment=commitment,
417
+ ratings_data=ratings_data,
418
+ config=self.config,
419
+ )
420
+ return ScoredCard(
421
+ card=card,
422
+ rating=rating,
423
+ original_index=original_index,
424
+ base_rating=base_rating,
425
+ base_score=base_score,
426
+ color_factor=color_factor,
427
+ adjusted_rating=base_rating * color_factor,
428
+ raw_score=raw_score,
429
+ score=_integer_score(raw_score=raw_score),
430
+ source_label=(
431
+ "Basic"
432
+ if freely_available_basic
433
+ else _source_label(rating=rating)
434
+ ),
435
+ color_fit=color_fit,
436
+ pair_tiebreaker_pair=pair_tiebreaker_pair,
437
+ pair_tiebreaker_win_rate=pair_tiebreaker_win_rate,
438
+ pair_tiebreaker_weight=pair_tiebreaker_weight,
439
+ score_sort_index=original_index,
440
+ splash=splash,
441
+ freely_available_basic=freely_available_basic,
442
+ )
443
+
444
+
445
+ def score_pack(
446
+ *,
447
+ offered_grp_ids: tuple[int, ...],
448
+ card_database: CardDatabase,
449
+ ratings_data: SeventeenLandsData | None = None,
450
+ config: PickEngineConfig = PICK_ENGINE,
451
+ pool_grp_ids: tuple[int, ...] = (),
452
+ pick_index: int | None = None,
453
+ splash_enabled: bool = SPLASH.enabled_by_default,
454
+ ) -> ScoredPack:
455
+ """Convenience wrapper for callers that do not keep an engine instance.
456
+ The reusable PickEngine class avoids rebuilding normalization per pack.
457
+ """
458
+
459
+ return PickEngine(
460
+ ratings_data=ratings_data,
461
+ config=config,
462
+ splash_enabled=splash_enabled,
463
+ ).score_pack(
464
+ offered_grp_ids=offered_grp_ids,
465
+ card_database=card_database,
466
+ pool_grp_ids=pool_grp_ids,
467
+ pick_index=pick_index,
468
+ )
469
+
470
+
471
+ def _pick_index(
472
+ *,
473
+ pool_grp_ids: tuple[int, ...],
474
+ pick_index: int | None,
475
+ ) -> int:
476
+ if pick_index is not None:
477
+ return max(1, pick_index)
478
+
479
+ return len(pool_grp_ids) + 1
480
+
481
+
482
+ def _color_commitment(
483
+ *,
484
+ pool_grp_ids: tuple[int, ...],
485
+ pick_index: int,
486
+ card_database: CardDatabase,
487
+ ratings_data: SeventeenLandsData | None,
488
+ config: PickEngineConfig,
489
+ ) -> ColorCommitment:
490
+ weights = _pool_color_weights(
491
+ pool_grp_ids=pool_grp_ids,
492
+ card_database=card_database,
493
+ ratings_data=ratings_data,
494
+ config=config,
495
+ )
496
+ inferred_pair = _inferred_pair(weights=weights, config=config)
497
+ level = _commitment_level(pick_index=pick_index, config=config)
498
+ if inferred_pair is None:
499
+ level = 0.0
500
+
501
+ return ColorCommitment(
502
+ pick_index=pick_index,
503
+ pool_size=len(pool_grp_ids),
504
+ color_weights=tuple((color, weights[color]) for color in weights),
505
+ inferred_pair=inferred_pair,
506
+ level=level,
507
+ )
508
+
509
+
510
+ def _pool_color_weights(
511
+ *,
512
+ pool_grp_ids: tuple[int, ...],
513
+ card_database: CardDatabase,
514
+ ratings_data: SeventeenLandsData | None,
515
+ config: PickEngineConfig,
516
+ ) -> dict[str, float]:
517
+ weights = _empty_color_weights()
518
+ for grp_id in pool_grp_ids:
519
+ card = card_database.lookup(grp_id=grp_id)
520
+ if card.unknown or not card.colors:
521
+ continue
522
+
523
+ rating = _rating_for(
524
+ ratings_data=ratings_data,
525
+ grp_id=grp_id,
526
+ config=config,
527
+ )
528
+ base_rating = _base_rating(rating=rating, config=config)
529
+ weight = _pool_card_weight(base_rating=base_rating, config=config)
530
+ for color in card.colors:
531
+ if color in weights:
532
+ weights[color] += weight
533
+
534
+ return weights
535
+
536
+
537
+ def _empty_color_weights() -> dict[str, float]:
538
+ colors: dict[str, float] = {}
539
+ for pair in COLOR_PAIRS:
540
+ for color in pair:
541
+ colors.setdefault(color, 0.0)
542
+
543
+ return colors
544
+
545
+
546
+ def _pool_card_weight(*, base_rating: float, config: PickEngineConfig) -> float:
547
+ lower = min(config.pool_weight_minimum, config.pool_weight_maximum)
548
+ upper = max(config.pool_weight_minimum, config.pool_weight_maximum)
549
+ rating_delta = base_rating - config.neutral_prior_win_rate
550
+ weight = config.pool_weight_baseline + (rating_delta * config.pool_weight_rating_scale)
551
+ return _clamp(value=weight, lower=lower, upper=upper)
552
+
553
+
554
+ def _inferred_pair(
555
+ *,
556
+ weights: dict[str, float],
557
+ config: PickEngineConfig,
558
+ ) -> str | None:
559
+ positive_colors = tuple(
560
+ color for color, weight in weights.items() if weight > config.pool_weight_epsilon
561
+ )
562
+ if len(positive_colors) < config.minimum_pair_colors:
563
+ return None
564
+
565
+ return max(COLOR_PAIRS, key=lambda pair: _pair_weight(pair=pair, weights=weights))
566
+
567
+
568
+ def _pair_weight(*, pair: str, weights: dict[str, float]) -> float:
569
+ return sum(weights.get(color, 0.0) for color in pair)
570
+
571
+
572
+ def _commitment_level(*, pick_index: int, config: PickEngineConfig) -> float:
573
+ if pick_index <= config.open_pick_count:
574
+ return 0.0
575
+
576
+ ramp_start = max(config.commitment_start_pick, config.open_pick_count + 1)
577
+ if pick_index < ramp_start:
578
+ return 0.0
579
+
580
+ if pick_index >= config.locked_pick_index:
581
+ return 1.0
582
+
583
+ ramp_span = config.locked_pick_index - ramp_start + 1
584
+ if ramp_span <= 0:
585
+ return 1.0
586
+
587
+ return _clamp(
588
+ value=(pick_index - ramp_start + 1) / ramp_span,
589
+ lower=0.0,
590
+ upper=1.0,
591
+ )
592
+
593
+
594
+ def _color_factor(
595
+ *,
596
+ color_fit: str,
597
+ commitment: ColorCommitment,
598
+ config: PickEngineConfig,
599
+ ) -> float:
600
+ if commitment.level <= 0.0 or color_fit in {"open", "colorless", "unknown"}:
601
+ return 1.0
602
+
603
+ if color_fit == "on-color":
604
+ factor = 1.0 + (commitment.level * (config.on_color_bonus_multiplier - 1.0))
605
+ return max(0.0, factor)
606
+
607
+ if color_fit == "off-color":
608
+ penalty = 1.0 - config.off_color_penalty_multiplier
609
+ factor = 1.0 - (commitment.level * penalty)
610
+ return max(0.0, factor)
611
+
612
+ if color_fit == "splash-ready":
613
+ penalty = 1.0 - SPLASH.ready_score_multiplier
614
+ factor = 1.0 - (commitment.level * penalty)
615
+ return max(0.0, factor)
616
+
617
+ if color_fit == "splash-speculative":
618
+ penalty = 1.0 - SPLASH.speculative_score_multiplier
619
+ factor = 1.0 - (commitment.level * penalty)
620
+ return max(0.0, factor)
621
+
622
+ if color_fit == "splash-fixer":
623
+ bonus = SPLASH.fixer_score_multiplier - 1.0
624
+ factor = 1.0 + (commitment.level * bonus)
625
+ return max(0.0, factor)
626
+
627
+ return 1.0
628
+
629
+
630
+ def _score_sorted_cards(
631
+ *,
632
+ cards: tuple[ScoredCard, ...],
633
+ commitment: ColorCommitment,
634
+ ratings_data: SeventeenLandsData | None,
635
+ offered_count: int,
636
+ config: PickEngineConfig,
637
+ ) -> tuple[ScoredCard, ...]:
638
+ base_sorted = tuple(sorted(cards, key=_scored_card_base_sort_key))
639
+ if not _early_pair_tiebreaker_enabled(
640
+ commitment=commitment,
641
+ ratings_data=ratings_data,
642
+ offered_count=offered_count,
643
+ config=config,
644
+ ):
645
+ return _with_score_sort_indexes(cards=base_sorted)
646
+
647
+ sorted_cards = _apply_early_pair_tiebreaker(cards=base_sorted, config=config)
648
+ return _with_score_sort_indexes(cards=sorted_cards)
649
+
650
+
651
+ def _early_pair_tiebreaker_enabled(
652
+ *,
653
+ commitment: ColorCommitment,
654
+ ratings_data: SeventeenLandsData | None,
655
+ offered_count: int,
656
+ config: PickEngineConfig,
657
+ ) -> bool:
658
+ if ratings_data is None or not ratings_data.pair_win_rates:
659
+ return False
660
+
661
+ if commitment.level > 0.0 or commitment.pick_index > config.open_pick_count:
662
+ return False
663
+
664
+ if config.early_pair_tiebreaker_score_threshold <= 0.0:
665
+ return False
666
+
667
+ if config.early_pair_tiebreaker_max_offered_cards <= 0:
668
+ return False
669
+
670
+ return offered_count <= config.early_pair_tiebreaker_max_offered_cards
671
+
672
+
673
+ def _apply_early_pair_tiebreaker(
674
+ *,
675
+ cards: tuple[ScoredCard, ...],
676
+ config: PickEngineConfig,
677
+ ) -> tuple[ScoredCard, ...]:
678
+ sorted_cards: list[ScoredCard] = []
679
+ group: list[ScoredCard] = []
680
+ group_top_score = 0.0
681
+ for card in cards:
682
+ if not group:
683
+ group = [card]
684
+ group_top_score = card.raw_score
685
+ continue
686
+
687
+ score_delta = group_top_score - card.raw_score
688
+ if score_delta <= config.early_pair_tiebreaker_score_threshold:
689
+ group.append(card)
690
+ continue
691
+
692
+ sorted_cards.extend(
693
+ _sort_early_pair_tiebreaker_group(group=group, config=config)
694
+ )
695
+ group = [card]
696
+ group_top_score = card.raw_score
697
+
698
+ if group:
699
+ sorted_cards.extend(
700
+ _sort_early_pair_tiebreaker_group(group=group, config=config)
701
+ )
702
+
703
+ return tuple(sorted_cards)
704
+
705
+
706
+ def _sort_early_pair_tiebreaker_group(
707
+ *,
708
+ group: list[ScoredCard],
709
+ config: PickEngineConfig,
710
+ ) -> tuple[ScoredCard, ...]:
711
+ return tuple(
712
+ sorted(
713
+ group,
714
+ key=cmp_to_key(
715
+ lambda left, right: _compare_early_pair_tiebreaker(
716
+ left=left,
717
+ right=right,
718
+ config=config,
719
+ ),
720
+ ),
721
+ )
722
+ )
723
+
724
+
725
+ def _compare_early_pair_tiebreaker(
726
+ *,
727
+ left: ScoredCard,
728
+ right: ScoredCard,
729
+ config: PickEngineConfig,
730
+ ) -> int:
731
+ left_win_rate = left.pair_tiebreaker_win_rate
732
+ right_win_rate = right.pair_tiebreaker_win_rate
733
+ left_weight = left.pair_tiebreaker_weight
734
+ right_weight = right.pair_tiebreaker_weight
735
+ if (
736
+ left_win_rate is not None
737
+ and right_win_rate is not None
738
+ and left_weight is not None
739
+ and right_weight is not None
740
+ and abs(left.raw_score - right.raw_score)
741
+ <= config.early_pair_tiebreaker_score_threshold
742
+ and abs(left_weight - right_weight)
743
+ <= config.early_pair_tiebreaker_pair_weight_threshold
744
+ and left_win_rate != right_win_rate
745
+ ):
746
+ return -1 if left_win_rate > right_win_rate else 1
747
+
748
+ return _compare_base_scored_cards(left=left, right=right)
749
+
750
+
751
+ def _compare_base_scored_cards(*, left: ScoredCard, right: ScoredCard) -> int:
752
+ left_key = _scored_card_base_sort_key(left)
753
+ right_key = _scored_card_base_sort_key(right)
754
+ if left_key < right_key:
755
+ return -1
756
+
757
+ if left_key > right_key:
758
+ return 1
759
+
760
+ return 0
761
+
762
+
763
+ def _with_score_sort_indexes(*, cards: tuple[ScoredCard, ...]) -> tuple[ScoredCard, ...]:
764
+ return tuple(
765
+ replace(card, score_sort_index=index)
766
+ for index, card in enumerate(cards)
767
+ )
768
+
769
+
770
+ def _pair_tiebreaker_for_card(
771
+ *,
772
+ card: CardInfo,
773
+ base_rating: float,
774
+ commitment: ColorCommitment,
775
+ ratings_data: SeventeenLandsData | None,
776
+ config: PickEngineConfig,
777
+ ) -> tuple[str | None, float | None, float | None]:
778
+ if ratings_data is None or not ratings_data.pair_win_rates:
779
+ return (None, None, None)
780
+
781
+ colors = _recognized_card_colors(card=card)
782
+ if not colors:
783
+ return (None, None, None)
784
+
785
+ compatible_pairs = _compatible_pairs_for_colors(colors=colors)
786
+ if not compatible_pairs:
787
+ return (None, None, None)
788
+
789
+ weights = _candidate_pair_weights(
790
+ colors=colors,
791
+ base_rating=base_rating,
792
+ commitment=commitment,
793
+ config=config,
794
+ )
795
+ pair = _best_tiebreaker_pair(
796
+ pairs=compatible_pairs,
797
+ weights=weights,
798
+ ratings_data=ratings_data,
799
+ config=config,
800
+ )
801
+ if pair is None:
802
+ return (None, None, None)
803
+
804
+ return (
805
+ pair,
806
+ _pair_win_rate(pair=pair, ratings_data=ratings_data),
807
+ _pair_weight(pair=pair, weights=weights),
808
+ )
809
+
810
+
811
+ def _recognized_card_colors(*, card: CardInfo) -> tuple[str, ...]:
812
+ color_order = tuple(_empty_color_weights())
813
+ colors: list[str] = []
814
+ for color in card.colors:
815
+ if color in color_order and color not in colors:
816
+ colors.append(color)
817
+
818
+ return tuple(colors)
819
+
820
+
821
+ def _compatible_pairs_for_colors(*, colors: tuple[str, ...]) -> tuple[str, ...]:
822
+ color_set = set(colors)
823
+ return tuple(
824
+ pair for pair in COLOR_PAIRS if color_set.issubset(set(pair))
825
+ )
826
+
827
+
828
+ def _candidate_pair_weights(
829
+ *,
830
+ colors: tuple[str, ...],
831
+ base_rating: float,
832
+ commitment: ColorCommitment,
833
+ config: PickEngineConfig,
834
+ ) -> dict[str, float]:
835
+ weights = dict(commitment.color_weights)
836
+ weight = _pool_card_weight(base_rating=base_rating, config=config)
837
+ for color in colors:
838
+ weights[color] = weights.get(color, 0.0) + weight
839
+
840
+ return weights
841
+
842
+
843
+ def _best_tiebreaker_pair(
844
+ *,
845
+ pairs: tuple[str, ...],
846
+ weights: dict[str, float],
847
+ ratings_data: SeventeenLandsData,
848
+ config: PickEngineConfig,
849
+ ) -> str | None:
850
+ if not pairs:
851
+ return None
852
+
853
+ max_pair_weight = max(_pair_weight(pair=pair, weights=weights) for pair in pairs)
854
+ weight_threshold = max(0.0, config.early_pair_tiebreaker_pair_weight_threshold)
855
+ close_pairs = tuple(
856
+ pair
857
+ for pair in pairs
858
+ if max_pair_weight - _pair_weight(pair=pair, weights=weights) <= weight_threshold
859
+ )
860
+ return max(
861
+ close_pairs,
862
+ key=lambda pair: _tiebreaker_pair_sort_key(
863
+ pair=pair,
864
+ weights=weights,
865
+ ratings_data=ratings_data,
866
+ ),
867
+ )
868
+
869
+
870
+ def _tiebreaker_pair_sort_key(
871
+ *,
872
+ pair: str,
873
+ weights: dict[str, float],
874
+ ratings_data: SeventeenLandsData,
875
+ ) -> tuple[bool, float, float, int]:
876
+ win_rate = _pair_win_rate(pair=pair, ratings_data=ratings_data)
877
+ return (
878
+ win_rate is not None,
879
+ 0.0 if win_rate is None else win_rate,
880
+ _pair_weight(pair=pair, weights=weights),
881
+ -COLOR_PAIRS.index(pair),
882
+ )
883
+
884
+
885
+ def _pair_win_rate(
886
+ *,
887
+ pair: str,
888
+ ratings_data: SeventeenLandsData | None,
889
+ ) -> float | None:
890
+ if ratings_data is None:
891
+ return None
892
+
893
+ pair_record = ratings_data.pair_win_rates.get(pair)
894
+ if pair_record is None:
895
+ return None
896
+
897
+ return pair_record.win_rate
898
+
899
+
900
+ def _normalization_from_data(
901
+ *,
902
+ ratings_data: SeventeenLandsData | None,
903
+ config: PickEngineConfig,
904
+ ) -> ScoreNormalization:
905
+ distribution = _strong_rating_distribution(ratings_data=ratings_data)
906
+ lower_percentile = _percentile(
907
+ values=distribution,
908
+ percentile=config.normalization_lower_percentile,
909
+ )
910
+ upper_percentile = _percentile(
911
+ values=distribution,
912
+ percentile=config.normalization_upper_percentile,
913
+ )
914
+ neutral = config.neutral_prior_win_rate
915
+ half_span = config.normalization_min_half_span
916
+ if lower_percentile is not None:
917
+ half_span = max(half_span, neutral - lower_percentile)
918
+
919
+ if upper_percentile is not None:
920
+ half_span = max(half_span, upper_percentile - neutral)
921
+
922
+ return ScoreNormalization(
923
+ lower_rating=neutral - half_span,
924
+ upper_rating=neutral + half_span,
925
+ neutral_rating=neutral,
926
+ )
927
+
928
+
929
+ def _strong_rating_distribution(
930
+ *,
931
+ ratings_data: SeventeenLandsData | None,
932
+ ) -> tuple[float, ...]:
933
+ if ratings_data is None:
934
+ return ()
935
+
936
+ return tuple(
937
+ rating.gih_win_rate
938
+ for rating in ratings_data.ratings.values()
939
+ if rating.gih_win_rate is not None
940
+ )
941
+
942
+
943
+ def _rating_for(
944
+ *,
945
+ ratings_data: SeventeenLandsData | None,
946
+ grp_id: int,
947
+ config: PickEngineConfig,
948
+ commitment: ColorCommitment | None = None,
949
+ ) -> ResolvedCardRating:
950
+ if ratings_data is None:
951
+ return _neutral_rating(grp_id=grp_id, config=config)
952
+
953
+ if commitment is not None and commitment.locked and commitment.inferred_pair is not None:
954
+ return ratings_data.pair_rating_for(
955
+ grp_id=grp_id,
956
+ pair=commitment.inferred_pair,
957
+ )
958
+
959
+ return ratings_data.rating_for(grp_id=grp_id)
960
+
961
+
962
+ def _neutral_rating(*, grp_id: int, config: PickEngineConfig) -> ResolvedCardRating:
963
+ return ResolvedCardRating(
964
+ grp_id=grp_id,
965
+ name=f"Unknown card {grp_id}",
966
+ color=None,
967
+ rarity=None,
968
+ average_last_seen_at=None,
969
+ gih_win_rate=None,
970
+ opening_hand_win_rate=None,
971
+ drawn_improvement_win_rate=None,
972
+ sample_counts=RatingSampleCounts(
973
+ seen=0,
974
+ picked=0,
975
+ games_played=0,
976
+ opening_hand=0,
977
+ games_in_hand=0,
978
+ ),
979
+ letter_grade=None,
980
+ neutral_prior_score=config.neutral_prior_score,
981
+ metadata=RatingSourceMetadata(
982
+ requested_format=QUICK_DRAFT_FORMAT,
983
+ source=NEUTRAL_PRIOR_SOURCE,
984
+ source_format=None,
985
+ fallback_reason="ratings-unavailable",
986
+ ),
987
+ )
988
+
989
+
990
+ def _base_rating(*, rating: ResolvedCardRating, config: PickEngineConfig) -> float:
991
+ if rating.gih_win_rate is not None:
992
+ return rating.gih_win_rate
993
+
994
+ return config.neutral_prior_win_rate + _alsa_adjustment(
995
+ average_last_seen_at=rating.average_last_seen_at,
996
+ config=config,
997
+ )
998
+
999
+
1000
+ def _alsa_adjustment(
1001
+ *,
1002
+ average_last_seen_at: float | None,
1003
+ config: PickEngineConfig,
1004
+ ) -> float:
1005
+ if average_last_seen_at is None:
1006
+ return 0.0
1007
+
1008
+ early = config.alsa_early_pick
1009
+ late = config.alsa_late_pick
1010
+ if late <= early:
1011
+ return 0.0
1012
+
1013
+ clamped = _clamp(value=average_last_seen_at, lower=early, upper=late)
1014
+ midpoint = (early + late) / 2.0
1015
+ half_span = (late - early) / 2.0
1016
+ return ((midpoint - clamped) / half_span) * config.alsa_adjustment_max
1017
+
1018
+
1019
+ def _normalized_score(
1020
+ *,
1021
+ adjusted_rating: float,
1022
+ normalization: ScoreNormalization,
1023
+ ) -> float:
1024
+ rating_span = normalization.upper_rating - normalization.lower_rating
1025
+ if rating_span <= 0:
1026
+ return 50.0
1027
+
1028
+ score = ((adjusted_rating - normalization.lower_rating) / rating_span) * 100.0
1029
+ return _clamp(value=score, lower=0.0, upper=100.0)
1030
+
1031
+
1032
+ def _integer_score(*, raw_score: float) -> int:
1033
+ return int(math.floor(_clamp(value=raw_score, lower=0.0, upper=100.0) + 0.5))
1034
+
1035
+
1036
+ def _source_label(*, rating: ResolvedCardRating) -> str:
1037
+ if rating.metadata.source == NEUTRAL_PRIOR_SOURCE:
1038
+ return "Prior*"
1039
+
1040
+ if rating.metadata.source != FORMAT_RATING_SOURCE:
1041
+ return "Unknown"
1042
+
1043
+ if rating.metadata.source_format == QUICK_DRAFT_FORMAT:
1044
+ return "Quick"
1045
+
1046
+ if rating.metadata.source_format == PREMIER_DRAFT_FORMAT:
1047
+ return "Premier"
1048
+
1049
+ return rating.metadata.source_format or "Unknown"
1050
+
1051
+
1052
+ def _is_freely_available_basic_land(*, card: CardInfo) -> bool:
1053
+ return card.name in STANDARD_BASIC_LAND_NAMES or any(
1054
+ type_line in STANDARD_BASIC_LAND_TYPE_LINES
1055
+ for type_line in card.types
1056
+ )
1057
+
1058
+
1059
+ def _source_summary(*, cards: tuple[ScoredCard, ...]) -> str:
1060
+ if not cards:
1061
+ return "none"
1062
+
1063
+ uses_quick = any(card.source_label == "Quick" for card in cards)
1064
+ uses_premier = any(card.source_label == "Premier" for card in cards)
1065
+ uses_prior = any(card.no_data for card in cards)
1066
+ uses_basic_policy = any(card.freely_available_basic for card in cards)
1067
+ parts: list[str] = []
1068
+ if uses_quick:
1069
+ parts.append("QuickDraft")
1070
+
1071
+ if uses_premier:
1072
+ parts.append("Premier fallback")
1073
+
1074
+ if uses_prior:
1075
+ parts.append("neutral prior")
1076
+
1077
+ if uses_basic_policy:
1078
+ parts.append("basic land policy")
1079
+
1080
+ return " + ".join(parts) if parts else "unknown"
1081
+
1082
+
1083
+ def _scored_card_base_sort_key(
1084
+ card: ScoredCard,
1085
+ ) -> tuple[bool, int, float, float, int]:
1086
+ return (
1087
+ card.freely_available_basic,
1088
+ -card.score,
1089
+ -card.raw_score,
1090
+ -card.base_rating,
1091
+ card.original_index,
1092
+ )
1093
+
1094
+
1095
+ def _percentile(*, values: tuple[float, ...], percentile: float) -> float | None:
1096
+ if not values:
1097
+ return None
1098
+
1099
+ ordered = sorted(values)
1100
+ if len(ordered) == 1:
1101
+ return ordered[0]
1102
+
1103
+ clamped_percentile = _clamp(value=percentile, lower=0.0, upper=100.0)
1104
+ rank = (len(ordered) - 1) * (clamped_percentile / 100.0)
1105
+ lower_index = math.floor(rank)
1106
+ upper_index = math.ceil(rank)
1107
+ if lower_index == upper_index:
1108
+ return ordered[lower_index]
1109
+
1110
+ lower_value = ordered[lower_index]
1111
+ upper_value = ordered[upper_index]
1112
+ fraction = rank - lower_index
1113
+ return lower_value + ((upper_value - lower_value) * fraction)
1114
+
1115
+
1116
+ def _clamp(*, value: float, lower: float, upper: float) -> float:
1117
+ return min(max(value, lower), upper)