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
draftomen/benchmark.py ADDED
@@ -0,0 +1,834 @@
1
+ """Offline pick-ranking benchmarks for public 17Lands draft data.
2
+ Compare raw 17Lands WR recommendations against Draftomen scores.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from collections import Counter
8
+ from collections.abc import Iterable, Mapping
9
+ from dataclasses import dataclass, replace
10
+ from os import PathLike
11
+ from typing import TypeAlias
12
+
13
+ from draftomen.carddb import CardDatabase, CardInfo
14
+ from draftomen.events import EXPECTED_PICKS_PER_PACK
15
+ from draftomen.pickengine import PickEngine, ScoredCard
16
+ from draftomen.ranking import rank_scored_cards, ranking_label
17
+ from draftomen.seventeen import (
18
+ SeventeenLandsData,
19
+ iter_17lands_draft_data_rows,
20
+ )
21
+
22
+ PathInput: TypeAlias = str | PathLike[str]
23
+
24
+ BENCHMARK_RANKING_MODES = ("win_rate", "score")
25
+ PHASE_ORDER = ("open", "building", "locked")
26
+ PUBLIC_DRAFT_SOURCE = "17Lands public draft data"
27
+
28
+
29
+ class PickBenchmarkError(RuntimeError):
30
+ """Raised when public draft data cannot be benchmarked.
31
+ CLI callers surface this as a concise diagnostic.
32
+ """
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class PickBenchmarkRankResult:
37
+ """Actual-pick rank for one ranking mode.
38
+ Lower rank means the mode placed the trophy pick closer to the top.
39
+ """
40
+
41
+ ranking_mode: str
42
+ actual_rank: int
43
+ top_card: ScoredCard
44
+ actual_card: ScoredCard
45
+
46
+
47
+ @dataclass(frozen=True, slots=True)
48
+ class PickBenchmarkPickResult:
49
+ """One reconstructed trophy-draft pick decision.
50
+ It stores ranks for each compared recommendation mode.
51
+ """
52
+
53
+ draft_id: str
54
+ pack_number: int
55
+ pick_number: int
56
+ pick_index: int
57
+ phase: str
58
+ actual: CardInfo
59
+ offered_count: int
60
+ rankings: tuple[PickBenchmarkRankResult, ...]
61
+
62
+ def rank_for(self, *, ranking_mode: str) -> PickBenchmarkRankResult | None:
63
+ """Return the rank result for a mode when available.
64
+ Benchmark rows normally carry both configured modes.
65
+ """
66
+
67
+ for result in self.rankings:
68
+ if result.ranking_mode == ranking_mode:
69
+ return result
70
+
71
+ return None
72
+
73
+
74
+ @dataclass(frozen=True, slots=True)
75
+ class PickBenchmarkSummary:
76
+ """Aggregate match metrics for one ranking mode.
77
+ Top-N counts and average rank are enough for stable CLI reports.
78
+ """
79
+
80
+ ranking_mode: str
81
+ pick_count: int
82
+ top_1_count: int
83
+ top_3_count: int
84
+ top_5_count: int
85
+ average_actual_pick_rank: float | None
86
+
87
+
88
+ @dataclass(frozen=True, slots=True)
89
+ class PickBenchmarkPhaseSummary:
90
+ """Aggregate benchmark metrics for one commitment phase.
91
+ Phases mirror the pick engine open/building/locked ramp.
92
+ """
93
+
94
+ phase: str
95
+ summary: PickBenchmarkSummary
96
+
97
+
98
+ @dataclass(frozen=True, slots=True)
99
+ class PickBenchmarkComparison:
100
+ """Direct DO-vs-17L rank comparison.
101
+ Counts describe which mode ranked the actual pick better.
102
+ """
103
+
104
+ better_count: int
105
+ same_count: int
106
+ worse_count: int
107
+
108
+ @property
109
+ def total_count(self) -> int:
110
+ """Return comparable rows for direct mode comparison.
111
+ It is the denominator for better/same/worse percentages.
112
+ """
113
+
114
+ return self.better_count + self.same_count + self.worse_count
115
+
116
+
117
+ @dataclass(frozen=True, slots=True)
118
+ class PickBenchmarkReport:
119
+ """Full offline benchmark report.
120
+ Formatting stays separate so tests can inspect structured metrics.
121
+ """
122
+
123
+ set_code: str
124
+ event_format: str
125
+ source: str
126
+ trophy_only: bool
127
+ draft_count: int
128
+ picks: tuple[PickBenchmarkPickResult, ...]
129
+ skipped_reasons: tuple[tuple[str, int], ...]
130
+ ranking_summaries: tuple[PickBenchmarkSummary, ...]
131
+ phase_summaries: tuple[PickBenchmarkPhaseSummary, ...]
132
+ comparison: PickBenchmarkComparison
133
+
134
+ @property
135
+ def skipped_count(self) -> int:
136
+ """Return rows skipped after draft/result filtering.
137
+ Reason counts are preserved for user-facing diagnostics.
138
+ """
139
+
140
+ return sum(count for _, count in self.skipped_reasons)
141
+
142
+
143
+ def generate_pick_benchmark_report(
144
+ *,
145
+ set_code: str,
146
+ event_format: str,
147
+ draft_data_file: PathInput,
148
+ card_database: CardDatabase,
149
+ ratings_data: SeventeenLandsData,
150
+ max_drafts: int | None = None,
151
+ trophy_only: bool = True,
152
+ ) -> PickBenchmarkReport:
153
+ """Benchmark recommendations from a local public draft-data dump.
154
+ The file may be plain CSV, gzip CSV, or a tar archive containing CSV.
155
+ """
156
+
157
+ rows = iter_17lands_draft_data_rows(path=draft_data_file)
158
+ return build_pick_benchmark_report_from_rows(
159
+ set_code=set_code,
160
+ event_format=event_format,
161
+ rows=rows,
162
+ card_database=card_database,
163
+ ratings_data=ratings_data,
164
+ source=str(draft_data_file),
165
+ max_drafts=max_drafts,
166
+ trophy_only=trophy_only,
167
+ )
168
+
169
+
170
+ def build_pick_benchmark_report_from_rows(
171
+ *,
172
+ set_code: str,
173
+ event_format: str,
174
+ rows: Iterable[Mapping[str, str]],
175
+ card_database: CardDatabase,
176
+ ratings_data: SeventeenLandsData,
177
+ source: str = PUBLIC_DRAFT_SOURCE,
178
+ max_drafts: int | None = None,
179
+ trophy_only: bool = True,
180
+ ) -> PickBenchmarkReport:
181
+ """Build a pick benchmark from already-loaded public draft rows.
182
+ Tests use this path to avoid large fixture files and network access.
183
+ """
184
+
185
+ if max_drafts is not None and max_drafts <= 0:
186
+ raise PickBenchmarkError("max_drafts must be greater than zero when provided.")
187
+
188
+ effective_database = _database_with_rating_metadata(
189
+ card_database=card_database,
190
+ ratings_data=ratings_data,
191
+ )
192
+ name_index = _card_name_index(
193
+ card_database=effective_database,
194
+ ratings_data=ratings_data,
195
+ )
196
+ rows_by_draft = _rows_by_draft(
197
+ rows=rows,
198
+ set_code=set_code,
199
+ event_format=event_format,
200
+ max_drafts=max_drafts,
201
+ trophy_only=trophy_only,
202
+ )
203
+ engine = PickEngine(ratings_data=ratings_data)
204
+ picks: list[PickBenchmarkPickResult] = []
205
+ skipped: Counter[str] = Counter()
206
+ for draft_id, draft_rows in rows_by_draft.items():
207
+ pool_grp_ids: list[int] = []
208
+ for row in sorted(draft_rows, key=_draft_row_sort_key):
209
+ result, skipped_reason = _score_benchmark_row(
210
+ draft_id=draft_id,
211
+ row=row,
212
+ pool_grp_ids=tuple(pool_grp_ids),
213
+ name_index=name_index,
214
+ card_database=effective_database,
215
+ pick_engine=engine,
216
+ )
217
+ if result is None:
218
+ skipped[skipped_reason or "unscored row"] += 1
219
+ else:
220
+ picks.append(result)
221
+
222
+ pool_grp_ids.extend(
223
+ _resolved_actual_pick_grp_ids(row=row, name_index=name_index)
224
+ )
225
+
226
+ pick_results = tuple(picks)
227
+ return PickBenchmarkReport(
228
+ set_code=set_code.upper(),
229
+ event_format=event_format,
230
+ source=source,
231
+ trophy_only=trophy_only,
232
+ draft_count=len(rows_by_draft),
233
+ picks=pick_results,
234
+ skipped_reasons=tuple(sorted(skipped.items())),
235
+ ranking_summaries=tuple(
236
+ _summary_for_mode(picks=pick_results, ranking_mode=mode)
237
+ for mode in BENCHMARK_RANKING_MODES
238
+ ),
239
+ phase_summaries=_phase_summaries(picks=pick_results),
240
+ comparison=_compare_modes(picks=pick_results),
241
+ )
242
+
243
+
244
+ def format_pick_benchmark_report(report: PickBenchmarkReport) -> str:
245
+ """Format a pick benchmark report as stable plain text.
246
+ The output is intentionally grep-friendly for comparing runs.
247
+ """
248
+
249
+ lines = [
250
+ "Draft Omen trophy pick benchmark",
251
+ f"Set: {report.set_code}",
252
+ f"Format: {report.event_format}",
253
+ f"Source: {report.source}",
254
+ f"Draft filter: {_format_draft_filter(report=report)}",
255
+ (
256
+ "Rows: "
257
+ f"{len(report.picks)} compared, "
258
+ f"{report.skipped_count} skipped"
259
+ ),
260
+ (
261
+ "Default ranking decision: DO Score is the default because "
262
+ "public trophy benchmarks improved top-1/top-3/top-5 match "
263
+ "rates and average actual-pick rank."
264
+ ),
265
+ "",
266
+ ]
267
+ lines.extend(_format_ranking_summary_table(summaries=report.ranking_summaries))
268
+ lines.append("")
269
+ lines.extend(_format_phase_summary_table(summaries=report.phase_summaries))
270
+ lines.append("")
271
+ lines.append(_format_comparison(comparison=report.comparison))
272
+ lines.append(_format_heuristic_note(report=report))
273
+ if report.skipped_reasons:
274
+ lines.append(_format_skipped_reasons(report=report))
275
+
276
+ return "\n".join(lines).rstrip() + "\n"
277
+
278
+
279
+ def _rows_by_draft(
280
+ *,
281
+ rows: Iterable[Mapping[str, str]],
282
+ set_code: str,
283
+ event_format: str,
284
+ max_drafts: int | None,
285
+ trophy_only: bool,
286
+ ) -> dict[str, list[Mapping[str, str]]]:
287
+ rows_by_draft: dict[str, list[Mapping[str, str]]] = {}
288
+ for row in rows:
289
+ if not _draft_row_matches(
290
+ row=row,
291
+ set_code=set_code,
292
+ event_format=event_format,
293
+ ):
294
+ continue
295
+
296
+ if trophy_only and _event_match_wins(row=row) != _trophy_wins(
297
+ event_format=event_format,
298
+ ):
299
+ continue
300
+
301
+ draft_id = _clean_text(row.get("draft_id"))
302
+ if draft_id is None:
303
+ continue
304
+
305
+ if draft_id not in rows_by_draft:
306
+ if max_drafts is not None and len(rows_by_draft) >= max_drafts:
307
+ continue
308
+
309
+ rows_by_draft[draft_id] = []
310
+
311
+ rows_by_draft[draft_id].append(row)
312
+
313
+ return rows_by_draft
314
+
315
+
316
+ def _draft_row_matches(
317
+ *,
318
+ row: Mapping[str, str],
319
+ set_code: str,
320
+ event_format: str,
321
+ ) -> bool:
322
+ row_set = _clean_text(row.get("expansion"))
323
+ if row_set is not None and row_set != set_code.upper():
324
+ return False
325
+
326
+ row_format = _clean_text(row.get("event_type"))
327
+ return row_format is None or row_format == event_format
328
+
329
+
330
+ def _score_benchmark_row(
331
+ *,
332
+ draft_id: str,
333
+ row: Mapping[str, str],
334
+ pool_grp_ids: tuple[int, ...],
335
+ name_index: Mapping[str, int],
336
+ card_database: CardDatabase,
337
+ pick_engine: PickEngine,
338
+ ) -> tuple[PickBenchmarkPickResult | None, str | None]:
339
+ actual_name = _clean_text(row.get("pick"))
340
+ if actual_name is None:
341
+ return None, "missing actual pick"
342
+
343
+ actual_grp_id = _resolve_card_name(name=actual_name, name_index=name_index)
344
+ if actual_grp_id is None:
345
+ return None, "unresolved actual card"
346
+
347
+ offered_grp_ids = _resolved_offered_grp_ids(row=row, name_index=name_index)
348
+ if not offered_grp_ids:
349
+ return None, "missing pack cards"
350
+
351
+ if actual_grp_id not in offered_grp_ids:
352
+ return None, "actual pick not in resolved pack"
353
+
354
+ pack_number = _public_pack_number(row=row)
355
+ pick_number = _public_pick_number(row=row)
356
+ scored_pack = pick_engine.score_pack(
357
+ offered_grp_ids=offered_grp_ids,
358
+ card_database=card_database,
359
+ pool_grp_ids=pool_grp_ids,
360
+ pick_index=_public_pick_index(
361
+ pack_number=pack_number,
362
+ pick_number=pick_number,
363
+ ),
364
+ )
365
+ ranking_results = tuple(
366
+ _rank_result_for_mode(
367
+ cards=scored_pack.cards,
368
+ actual_grp_id=actual_grp_id,
369
+ ranking_mode=mode,
370
+ )
371
+ for mode in BENCHMARK_RANKING_MODES
372
+ )
373
+ if any(result is None for result in ranking_results):
374
+ return None, "actual pick not ranked"
375
+
376
+ return (
377
+ PickBenchmarkPickResult(
378
+ draft_id=draft_id,
379
+ pack_number=pack_number,
380
+ pick_number=pick_number,
381
+ pick_index=_public_pick_index(
382
+ pack_number=pack_number,
383
+ pick_number=pick_number,
384
+ ),
385
+ phase=scored_pack.commitment.phase,
386
+ actual=card_database.lookup(grp_id=actual_grp_id),
387
+ offered_count=len(offered_grp_ids),
388
+ rankings=tuple(
389
+ result for result in ranking_results if result is not None
390
+ ),
391
+ ),
392
+ None,
393
+ )
394
+
395
+
396
+ def _rank_result_for_mode(
397
+ *,
398
+ cards: tuple[ScoredCard, ...],
399
+ actual_grp_id: int,
400
+ ranking_mode: str,
401
+ ) -> PickBenchmarkRankResult | None:
402
+ ranked = rank_scored_cards(cards=cards, ranking_mode=ranking_mode)
403
+ if not ranked:
404
+ return None
405
+
406
+ for index, card in enumerate(ranked, start=1):
407
+ if card.card.grp_id == actual_grp_id:
408
+ return PickBenchmarkRankResult(
409
+ ranking_mode=ranking_mode,
410
+ actual_rank=index,
411
+ top_card=ranked[0],
412
+ actual_card=card,
413
+ )
414
+
415
+ return None
416
+
417
+
418
+ def _resolved_actual_pick_grp_ids(
419
+ *,
420
+ row: Mapping[str, str],
421
+ name_index: Mapping[str, int],
422
+ ) -> tuple[int, ...]:
423
+ grp_ids: list[int] = []
424
+ for key in ("pick", "pick_2"):
425
+ name = _clean_text(row.get(key))
426
+ if name is None:
427
+ continue
428
+
429
+ grp_id = _resolve_card_name(name=name, name_index=name_index)
430
+ if grp_id is not None:
431
+ grp_ids.append(grp_id)
432
+
433
+ return tuple(grp_ids)
434
+
435
+
436
+ def _resolved_offered_grp_ids(
437
+ *,
438
+ row: Mapping[str, str],
439
+ name_index: Mapping[str, int],
440
+ ) -> tuple[int, ...]:
441
+ grp_ids: list[int] = []
442
+ seen: set[int] = set()
443
+ for name in _offered_card_names(row=row):
444
+ grp_id = _resolve_card_name(name=name, name_index=name_index)
445
+ if grp_id is None or grp_id in seen:
446
+ continue
447
+
448
+ seen.add(grp_id)
449
+ grp_ids.append(grp_id)
450
+
451
+ return tuple(grp_ids)
452
+
453
+
454
+ def _offered_card_names(*, row: Mapping[str, str]) -> tuple[str, ...]:
455
+ pack_card_names = tuple(
456
+ key.removeprefix("pack_card_")
457
+ for key, value in row.items()
458
+ if key.startswith("pack_card_") and _pack_count_is_positive(value=value)
459
+ )
460
+ if pack_card_names:
461
+ return pack_card_names
462
+
463
+ return tuple(
464
+ name
465
+ for _, name in sorted(
466
+ (
467
+ (_available_card_column_index(key=key), value)
468
+ for key, value in row.items()
469
+ if key.startswith("available_card_") and _clean_text(value) is not None
470
+ ),
471
+ key=lambda item: item[0],
472
+ )
473
+ )
474
+
475
+
476
+ def _available_card_column_index(*, key: str) -> int:
477
+ suffix = key.removeprefix("available_card_")
478
+ try:
479
+ return int(suffix)
480
+ except ValueError:
481
+ return 0
482
+
483
+
484
+ def _pack_count_is_positive(*, value: str) -> bool:
485
+ text = _clean_text(value)
486
+ if text is None:
487
+ return False
488
+
489
+ try:
490
+ return float(text) > 0
491
+ except ValueError:
492
+ return text.casefold() in {"true", "yes"}
493
+
494
+
495
+ def _summary_for_mode(
496
+ *,
497
+ picks: tuple[PickBenchmarkPickResult, ...],
498
+ ranking_mode: str,
499
+ ) -> PickBenchmarkSummary:
500
+ ranks = tuple(
501
+ result.actual_rank
502
+ for pick in picks
503
+ if (result := pick.rank_for(ranking_mode=ranking_mode)) is not None
504
+ )
505
+ pick_count = len(ranks)
506
+ average = (sum(ranks) / pick_count) if ranks else None
507
+ return PickBenchmarkSummary(
508
+ ranking_mode=ranking_mode,
509
+ pick_count=pick_count,
510
+ top_1_count=sum(1 for rank in ranks if rank <= 1),
511
+ top_3_count=sum(1 for rank in ranks if rank <= 3),
512
+ top_5_count=sum(1 for rank in ranks if rank <= 5),
513
+ average_actual_pick_rank=average,
514
+ )
515
+
516
+
517
+ def _phase_summaries(
518
+ *,
519
+ picks: tuple[PickBenchmarkPickResult, ...],
520
+ ) -> tuple[PickBenchmarkPhaseSummary, ...]:
521
+ summaries: list[PickBenchmarkPhaseSummary] = []
522
+ for phase in PHASE_ORDER:
523
+ phase_picks = tuple(pick for pick in picks if pick.phase == phase)
524
+ if not phase_picks:
525
+ continue
526
+
527
+ for mode in BENCHMARK_RANKING_MODES:
528
+ summaries.append(
529
+ PickBenchmarkPhaseSummary(
530
+ phase=phase,
531
+ summary=_summary_for_mode(
532
+ picks=phase_picks,
533
+ ranking_mode=mode,
534
+ ),
535
+ )
536
+ )
537
+
538
+ return tuple(summaries)
539
+
540
+
541
+ def _compare_modes(
542
+ *,
543
+ picks: tuple[PickBenchmarkPickResult, ...],
544
+ ) -> PickBenchmarkComparison:
545
+ better = 0
546
+ same = 0
547
+ worse = 0
548
+ for pick in picks:
549
+ win_rate = pick.rank_for(ranking_mode="win_rate")
550
+ score = pick.rank_for(ranking_mode="score")
551
+ if win_rate is None or score is None:
552
+ continue
553
+
554
+ if score.actual_rank < win_rate.actual_rank:
555
+ better += 1
556
+ elif score.actual_rank > win_rate.actual_rank:
557
+ worse += 1
558
+ else:
559
+ same += 1
560
+
561
+ return PickBenchmarkComparison(
562
+ better_count=better,
563
+ same_count=same,
564
+ worse_count=worse,
565
+ )
566
+
567
+
568
+ def _database_with_rating_metadata(
569
+ *,
570
+ card_database: CardDatabase,
571
+ ratings_data: SeventeenLandsData,
572
+ ) -> CardDatabase:
573
+ cards = dict(card_database.cards)
574
+ for rating in ratings_data.ratings.values():
575
+ existing = cards.get(rating.grp_id)
576
+ if existing is not None and not existing.unknown:
577
+ continue
578
+
579
+ if rating.name.startswith("Unknown card "):
580
+ continue
581
+
582
+ cards[rating.grp_id] = CardInfo(
583
+ grp_id=rating.grp_id,
584
+ name=rating.name,
585
+ colors=_rating_colors(color=rating.color),
586
+ mana_value=None,
587
+ rarity=rating.rarity or "unknown",
588
+ types=("Unknown",),
589
+ )
590
+
591
+ return replace(
592
+ card_database,
593
+ cards=cards,
594
+ image_uris_by_name=dict(card_database.image_uris_by_name),
595
+ )
596
+
597
+
598
+ def _card_name_index(
599
+ *,
600
+ card_database: CardDatabase,
601
+ ratings_data: SeventeenLandsData,
602
+ ) -> dict[str, int]:
603
+ index: dict[str, int] = {}
604
+ for card in card_database.cards.values():
605
+ for name in _card_lookup_names(card=card):
606
+ index.setdefault(_normalize_card_name(name), card.grp_id)
607
+
608
+ for rating in ratings_data.ratings.values():
609
+ if rating.name.startswith("Unknown card "):
610
+ continue
611
+
612
+ index.setdefault(_normalize_card_name(rating.name), rating.grp_id)
613
+
614
+ return index
615
+
616
+
617
+ def _card_lookup_names(*, card: CardInfo) -> tuple[str, ...]:
618
+ names = [card.name]
619
+ names.extend(part.strip() for part in card.name.split("//") if part.strip())
620
+ return tuple(dict.fromkeys(names))
621
+
622
+
623
+ def _resolve_card_name(
624
+ *,
625
+ name: str,
626
+ name_index: Mapping[str, int],
627
+ ) -> int | None:
628
+ return name_index.get(_normalize_card_name(name))
629
+
630
+
631
+ def _normalize_card_name(name: str) -> str:
632
+ return " ".join(name.casefold().split())
633
+
634
+
635
+ def _rating_colors(*, color: str | None) -> tuple[str, ...]:
636
+ if color is None:
637
+ return ()
638
+
639
+ return tuple(symbol for symbol in "WUBRG" if symbol in color)
640
+
641
+
642
+ def _draft_row_sort_key(row: Mapping[str, str]) -> tuple[int, int]:
643
+ return (_public_pack_number(row=row), _public_pick_number(row=row))
644
+
645
+
646
+ def _public_pack_number(*, row: Mapping[str, str]) -> int:
647
+ return max(0, _optional_int(row.get("pack_number")) or 0)
648
+
649
+
650
+ def _public_pick_number(*, row: Mapping[str, str]) -> int:
651
+ pick_number = _optional_int(row.get("pick_number"))
652
+ if pick_number is None:
653
+ return 1
654
+
655
+ if pick_number <= 0:
656
+ return pick_number + 1
657
+
658
+ return pick_number
659
+
660
+
661
+ def _public_pick_index(*, pack_number: int, pick_number: int) -> int:
662
+ return (max(0, pack_number) * EXPECTED_PICKS_PER_PACK) + max(1, pick_number)
663
+
664
+
665
+ def _event_match_wins(*, row: Mapping[str, str]) -> int | None:
666
+ return _optional_int(row.get("event_match_wins"))
667
+
668
+
669
+ def _trophy_wins(*, event_format: str) -> int:
670
+ if event_format.startswith("Trad"):
671
+ return 3
672
+
673
+ return 7
674
+
675
+
676
+ def _optional_int(value: str | None) -> int | None:
677
+ text = _clean_text(value)
678
+ if text is None:
679
+ return None
680
+
681
+ try:
682
+ return int(float(text))
683
+ except ValueError:
684
+ return None
685
+
686
+
687
+ def _clean_text(value: str | None) -> str | None:
688
+ if value is None:
689
+ return None
690
+
691
+ text = value.strip()
692
+ if text == "":
693
+ return None
694
+
695
+ return text
696
+
697
+
698
+ def _format_draft_filter(*, report: PickBenchmarkReport) -> str:
699
+ result_filter = "trophy drafts only" if report.trophy_only else "all matching drafts"
700
+ return f"{report.draft_count} {result_filter}"
701
+
702
+
703
+ def _format_ranking_summary_table(
704
+ *,
705
+ summaries: tuple[PickBenchmarkSummary, ...],
706
+ ) -> list[str]:
707
+ if not summaries or all(summary.pick_count == 0 for summary in summaries):
708
+ return ["No comparable picks were found."]
709
+
710
+ lines = [
711
+ "Ranking comparison:",
712
+ "Ranking Picks Top-1 Top-3 Top-5 Avg actual-pick rank",
713
+ ]
714
+ for summary in summaries:
715
+ lines.append(_format_summary_row(summary=summary))
716
+
717
+ return lines
718
+
719
+
720
+ def _format_phase_summary_table(
721
+ *,
722
+ summaries: tuple[PickBenchmarkPhaseSummary, ...],
723
+ ) -> list[str]:
724
+ if not summaries:
725
+ return ["Phase breakdown: no comparable picks."]
726
+
727
+ lines = [
728
+ "Phase breakdown:",
729
+ "Phase Ranking Picks Top-1 Top-3 Top-5 Avg rank",
730
+ ]
731
+ for phase_summary in summaries:
732
+ summary = phase_summary.summary
733
+ lines.append(
734
+ f"{phase_summary.phase:<9} "
735
+ f"{ranking_label(ranking_mode=summary.ranking_mode):<9} "
736
+ f"{summary.pick_count:>5} "
737
+ f"{_format_top_rate(count=summary.top_1_count, total=summary.pick_count):>6} "
738
+ f"{_format_top_rate(count=summary.top_3_count, total=summary.pick_count):>6} "
739
+ f"{_format_top_rate(count=summary.top_5_count, total=summary.pick_count):>6} "
740
+ f"{_format_average(value=summary.average_actual_pick_rank):>8}"
741
+ )
742
+
743
+ return lines
744
+
745
+
746
+ def _format_summary_row(*, summary: PickBenchmarkSummary) -> str:
747
+ return (
748
+ f"{ranking_label(ranking_mode=summary.ranking_mode):<9} "
749
+ f"{summary.pick_count:>5} "
750
+ f"{_format_top_rate(count=summary.top_1_count, total=summary.pick_count):>6} "
751
+ f"{_format_top_rate(count=summary.top_3_count, total=summary.pick_count):>6} "
752
+ f"{_format_top_rate(count=summary.top_5_count, total=summary.pick_count):>6} "
753
+ f"{_format_average(value=summary.average_actual_pick_rank):>20}"
754
+ )
755
+
756
+
757
+ def _format_comparison(*, comparison: PickBenchmarkComparison) -> str:
758
+ total = comparison.total_count
759
+ if total == 0:
760
+ return "DO vs 17L actual-pick rank: no comparable picks."
761
+
762
+ return (
763
+ "DO vs 17L actual-pick rank: "
764
+ f"better {_format_count_rate(count=comparison.better_count, total=total)}, "
765
+ f"same {_format_count_rate(count=comparison.same_count, total=total)}, "
766
+ f"worse {_format_count_rate(count=comparison.worse_count, total=total)}."
767
+ )
768
+
769
+
770
+ def _format_heuristic_note(*, report: PickBenchmarkReport) -> str:
771
+ late_off_color_misses = 0
772
+ neutral_prior_misses = 0
773
+ for pick in report.picks:
774
+ score = pick.rank_for(ranking_mode="score")
775
+ if score is None or score.actual_rank <= 1:
776
+ continue
777
+
778
+ if (
779
+ pick.phase in {"building", "locked"}
780
+ and score.actual_card.color_fit == "off-color"
781
+ ):
782
+ late_off_color_misses += 1
783
+
784
+ if score.actual_card.no_data:
785
+ neutral_prior_misses += 1
786
+
787
+ if late_off_color_misses > 0:
788
+ return (
789
+ "Non-ML heuristic candidate from misses: "
790
+ f"{late_off_color_misses} DO Score misses were building/locked "
791
+ "off-color trophy picks; tune the color commitment ramp or "
792
+ "off-color penalty before ML work (#37)."
793
+ )
794
+
795
+ if neutral_prior_misses > 0:
796
+ return (
797
+ "Non-ML heuristic candidate from misses: "
798
+ f"{neutral_prior_misses} DO Score misses used neutral-prior data; "
799
+ "test ALSA and maindeck-rate weighting before ML work (#37)."
800
+ )
801
+
802
+ return (
803
+ "Non-ML heuristic candidate from misses: review DO Score misses by "
804
+ "phase for pair-specific pick priorities and maindeck-rate weighting "
805
+ "before ML work (#37)."
806
+ )
807
+
808
+
809
+ def _format_skipped_reasons(*, report: PickBenchmarkReport) -> str:
810
+ reasons = "; ".join(
811
+ f"{reason}: {count}"
812
+ for reason, count in report.skipped_reasons
813
+ )
814
+ return f"Skipped rows: {report.skipped_count} ({reasons})."
815
+
816
+
817
+ def _format_top_rate(*, count: int, total: int) -> str:
818
+ if total == 0:
819
+ return "—"
820
+
821
+ return f"{count / total:.1%}"
822
+
823
+
824
+ def _format_count_rate(*, count: int, total: int) -> str:
825
+ return f"{count} ({count / total:.1%})"
826
+
827
+
828
+ def _format_average(*, value: float | None) -> str:
829
+ if value is None:
830
+ return "—"
831
+
832
+ return f"{value:.2f}"
833
+
834
+