pokerkit-plus 0.0.1__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+ .venv/
9
+ venv/
10
+
11
+ # Tooling caches
12
+ .pytest_cache/
13
+ .mypy_cache/
14
+ .ruff_cache/
15
+
16
+ # OS
17
+ .DS_Store
18
+ .gstack/
19
+
20
+ # Internal planning docs (reference private projects; kept local, not published)
21
+ docs/SEMANTIC_LAYER_PLAN.md
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 billcheung10
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,102 @@
1
+ Metadata-Version: 2.4
2
+ Name: pokerkit-plus
3
+ Version: 0.0.1
4
+ Summary: PokerKit Plus — fork-free capability extensions on top of PokerKit; a drop-in superset of pokerkit.
5
+ Project-URL: Homepage, https://github.com/billcheung10/pokerkit-plus
6
+ Project-URL: Repository, https://github.com/billcheung10/pokerkit-plus
7
+ Project-URL: Issues, https://github.com/billcheung10/pokerkit-plus/issues
8
+ Author: PokerKit Plus contributors
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: equity,holdem,omaha,poker,pokerkit,solver
12
+ Requires-Python: >=3.11
13
+ Requires-Dist: pokerkit==0.7.3
14
+ Provides-Extra: dev
15
+ Requires-Dist: mypy>=1.11; extra == 'dev'
16
+ Requires-Dist: pytest>=8; extra == 'dev'
17
+ Requires-Dist: ruff>=0.7; extra == 'dev'
18
+ Description-Content-Type: text/markdown
19
+
20
+ # PokerKit Plus
21
+
22
+ Fork-free capability extensions on top of [PokerKit](https://github.com/uoftcprg/pokerkit).
23
+
24
+ `pokerkit_plus` is a **drop-in superset** of `pokerkit`: it re-exports the entire
25
+ upstream surface unchanged and adds new capabilities on top via subclasses, free
26
+ functions, and thin wrappers. The upstream package is never modified, so PokerKit
27
+ Plus keeps riding upstream's maintenance (correctness fixes, new parsers, etc.).
28
+
29
+ ```python
30
+ # Anywhere you used pokerkit, you can use pokerkit_plus instead:
31
+ from pokerkit_plus import NoLimitTexasHoldem, Automation, State # all of pokerkit
32
+ # ...plus the PokerKit Plus additions as they land.
33
+ ```
34
+
35
+ ## Design
36
+
37
+ - **Form:** standalone extension package, `pip`-depends on `pokerkit==0.7.3` (pinned).
38
+ - **No fork, no monkeypatch.** PokerKit is built for fork-free extension
39
+ (`_begin_/_update_/_end_` phase hooks, mixin-composed variants, injectable
40
+ `rake`/`divmod`, `ClassVar` registries). Most additions need zero core edits.
41
+ - **`compat.py`** pins the pokerkit version and is the single home for stable
42
+ aliases of version-fragile upstream names.
43
+
44
+ ## Status
45
+
46
+ Semantic layer P0/P1 implemented (drop-in superset of pokerkit + first
47
+ capabilities):
48
+
49
+ - `pokerkit_plus.texture` — `BoardTexture.from_board` plus `Wetness`,
50
+ `Connectivity`, `RankBand`, `StraightDraw`, `FlushDraw`, and
51
+ `are_two_tone`/`are_monotone`/`are_rainbow`.
52
+ - `pokerkit_plus.combos` — `Nuts.from_board`, `CategoryCombos.from_board`,
53
+ `HoleCombo`, `made_label`, `meets`, `CATEGORY_ORDER` (made-hand category is
54
+ pokerkit's own `Label`).
55
+ - `pokerkit_plus.draws` — `Draws.from_hand` (hero flush/straight draw + nut
56
+ rank) and the shared `NutRank`.
57
+ - `pokerkit_plus.tags` — `HandTier.from_hand` (made-hand tier + `labels()`)
58
+ with `PairTier`/`KickerTier`/`TwoPairTier`/`ThreeOfAKindTier`.
59
+ - `pokerkit_plus.outs` — `Outs.from_hand` (category-upgrade outs, grouped).
60
+ - `pokerkit_plus.blockers` — `BlockerReport.from_hand` (count-based: how many
61
+ of the board's nut combos a holding removes).
62
+ - `pokerkit_plus.ranges` — `ComboClass`/`expand_range` (notation),
63
+ `build_value_range`, `calculate_range_advantage` (Monte-Carlo equity), and
64
+ `nut_advantage` (exact nut-category share).
65
+ - `pokerkit_plus.facade` — `HandReport.from_hand` and `BoardReport.from_board`,
66
+ the one-call composed entry points for callers wanting a complete reading.
67
+
68
+ Built on pokerkit's lookup-table total order (no hand-rolled scoring); every
69
+ per-board / per-hand enumeration is memoized, and equity is delegated to
70
+ pokerkit's Monte-Carlo sampler. Verified: mypy `--strict` clean, ruff clean,
71
+ unit tests + doctests green, `Nuts`/`Outs`/blockers match brute-force oracles,
72
+ and a 2000-hand sweep shows no `is_nut`/`nut_rank` contradictions.
73
+
74
+ ### Candidate capabilities (menu, not yet scheduled)
75
+
76
+ | Capability | Category | Effort |
77
+ |---|---|---|
78
+ | FastState: snapshot/restore + undo for tree search | performance | L |
79
+ | Exact + deterministic equity engine with sampled-hand callbacks | equity | M |
80
+ | Range engine v2: weights, %-ranges, removal-aware combos | equity | M |
81
+ | Structured action-history & rich State view layer | dev-experience | M |
82
+ | Positions + multi-hand Table/session object | dev-experience | M |
83
+ | Bot/agent framework + baseline agents | AI/bots | M |
84
+ | Missing variants: 5/6-card PLO, Big O, Courchevel, 5-Card Draw, A-5 | variants | M |
85
+ | Mixed-game rotation (HORSE / 8-Game) | variants | M |
86
+ | Variant registry + parse-by-name + uniform create | integration | S |
87
+ | Fast lookup-table hand evaluator (5/6/7-card) | performance | L |
88
+ | Robust async multi-variant hand-history I/O | integration | L |
89
+ | State visualization & serialization (ASCII / SVG / JSON) | visualization | S |
90
+ | compat shim + Decimal/currency value type | dev-experience | S |
91
+
92
+ ## Development
93
+
94
+ ```bash
95
+ python3 -m venv .venv && . .venv/bin/activate
96
+ pip install -e ".[dev]"
97
+ pytest
98
+ ```
99
+
100
+ ## License
101
+
102
+ MIT (same as PokerKit).
@@ -0,0 +1,83 @@
1
+ # PokerKit Plus
2
+
3
+ Fork-free capability extensions on top of [PokerKit](https://github.com/uoftcprg/pokerkit).
4
+
5
+ `pokerkit_plus` is a **drop-in superset** of `pokerkit`: it re-exports the entire
6
+ upstream surface unchanged and adds new capabilities on top via subclasses, free
7
+ functions, and thin wrappers. The upstream package is never modified, so PokerKit
8
+ Plus keeps riding upstream's maintenance (correctness fixes, new parsers, etc.).
9
+
10
+ ```python
11
+ # Anywhere you used pokerkit, you can use pokerkit_plus instead:
12
+ from pokerkit_plus import NoLimitTexasHoldem, Automation, State # all of pokerkit
13
+ # ...plus the PokerKit Plus additions as they land.
14
+ ```
15
+
16
+ ## Design
17
+
18
+ - **Form:** standalone extension package, `pip`-depends on `pokerkit==0.7.3` (pinned).
19
+ - **No fork, no monkeypatch.** PokerKit is built for fork-free extension
20
+ (`_begin_/_update_/_end_` phase hooks, mixin-composed variants, injectable
21
+ `rake`/`divmod`, `ClassVar` registries). Most additions need zero core edits.
22
+ - **`compat.py`** pins the pokerkit version and is the single home for stable
23
+ aliases of version-fragile upstream names.
24
+
25
+ ## Status
26
+
27
+ Semantic layer P0/P1 implemented (drop-in superset of pokerkit + first
28
+ capabilities):
29
+
30
+ - `pokerkit_plus.texture` — `BoardTexture.from_board` plus `Wetness`,
31
+ `Connectivity`, `RankBand`, `StraightDraw`, `FlushDraw`, and
32
+ `are_two_tone`/`are_monotone`/`are_rainbow`.
33
+ - `pokerkit_plus.combos` — `Nuts.from_board`, `CategoryCombos.from_board`,
34
+ `HoleCombo`, `made_label`, `meets`, `CATEGORY_ORDER` (made-hand category is
35
+ pokerkit's own `Label`).
36
+ - `pokerkit_plus.draws` — `Draws.from_hand` (hero flush/straight draw + nut
37
+ rank) and the shared `NutRank`.
38
+ - `pokerkit_plus.tags` — `HandTier.from_hand` (made-hand tier + `labels()`)
39
+ with `PairTier`/`KickerTier`/`TwoPairTier`/`ThreeOfAKindTier`.
40
+ - `pokerkit_plus.outs` — `Outs.from_hand` (category-upgrade outs, grouped).
41
+ - `pokerkit_plus.blockers` — `BlockerReport.from_hand` (count-based: how many
42
+ of the board's nut combos a holding removes).
43
+ - `pokerkit_plus.ranges` — `ComboClass`/`expand_range` (notation),
44
+ `build_value_range`, `calculate_range_advantage` (Monte-Carlo equity), and
45
+ `nut_advantage` (exact nut-category share).
46
+ - `pokerkit_plus.facade` — `HandReport.from_hand` and `BoardReport.from_board`,
47
+ the one-call composed entry points for callers wanting a complete reading.
48
+
49
+ Built on pokerkit's lookup-table total order (no hand-rolled scoring); every
50
+ per-board / per-hand enumeration is memoized, and equity is delegated to
51
+ pokerkit's Monte-Carlo sampler. Verified: mypy `--strict` clean, ruff clean,
52
+ unit tests + doctests green, `Nuts`/`Outs`/blockers match brute-force oracles,
53
+ and a 2000-hand sweep shows no `is_nut`/`nut_rank` contradictions.
54
+
55
+ ### Candidate capabilities (menu, not yet scheduled)
56
+
57
+ | Capability | Category | Effort |
58
+ |---|---|---|
59
+ | FastState: snapshot/restore + undo for tree search | performance | L |
60
+ | Exact + deterministic equity engine with sampled-hand callbacks | equity | M |
61
+ | Range engine v2: weights, %-ranges, removal-aware combos | equity | M |
62
+ | Structured action-history & rich State view layer | dev-experience | M |
63
+ | Positions + multi-hand Table/session object | dev-experience | M |
64
+ | Bot/agent framework + baseline agents | AI/bots | M |
65
+ | Missing variants: 5/6-card PLO, Big O, Courchevel, 5-Card Draw, A-5 | variants | M |
66
+ | Mixed-game rotation (HORSE / 8-Game) | variants | M |
67
+ | Variant registry + parse-by-name + uniform create | integration | S |
68
+ | Fast lookup-table hand evaluator (5/6/7-card) | performance | L |
69
+ | Robust async multi-variant hand-history I/O | integration | L |
70
+ | State visualization & serialization (ASCII / SVG / JSON) | visualization | S |
71
+ | compat shim + Decimal/currency value type | dev-experience | S |
72
+
73
+ ## Development
74
+
75
+ ```bash
76
+ python3 -m venv .venv && . .venv/bin/activate
77
+ pip install -e ".[dev]"
78
+ pytest
79
+ ```
80
+
81
+ ## License
82
+
83
+ MIT (same as PokerKit).
@@ -0,0 +1,131 @@
1
+ """PokerKit Plus — a fork-free, drop-in superset of :mod:`pokerkit`.
2
+
3
+ Everything importable from :mod:`pokerkit` is re-exported here unchanged, so
4
+ any ``from pokerkit import X`` can become ``from pokerkit_plus import X`` with
5
+ no other change. PokerKit Plus capabilities are layered on top via subclasses,
6
+ free functions, and thin wrappers — the upstream package is never modified.
7
+
8
+ As capabilities land, their public names are appended to ``_PLUS_ALL`` (and the
9
+ relevant submodule is imported below) so they show up in ``pokerkit_plus``'s
10
+ star-import surface alongside the upstream names.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ # Re-export the entire upstream surface. pokerkit ships a complete ``__all__``,
16
+ # so the star import is exact and stable.
17
+ from pokerkit import * # noqa: F401, F403
18
+ from pokerkit import __all__ as _POKERKIT_ALL
19
+
20
+ # PokerKit Plus additions. Submodules own their public names (no per-module
21
+ # ``__all__``, matching pokerkit's leaf modules); this aggregator imports and
22
+ # registers them, exactly as pokerkit's own ``__init__`` does.
23
+ from pokerkit_plus.combos import ( # noqa: F401
24
+ CATEGORY_ORDER,
25
+ CategoryCombos,
26
+ HoleCombo,
27
+ Nuts,
28
+ made_label,
29
+ meets,
30
+ )
31
+ from pokerkit_plus.texture import ( # noqa: F401
32
+ BoardTexture,
33
+ Connectivity,
34
+ CONNECTIVITY_ORDER,
35
+ FlushDraw,
36
+ FLUSH_DRAW_ORDER,
37
+ RankBand,
38
+ RANK_BAND_ORDER,
39
+ StraightDraw,
40
+ STRAIGHT_DRAW_ORDER,
41
+ Wetness,
42
+ WETNESS_ORDER,
43
+ are_monotone,
44
+ are_rainbow,
45
+ are_two_tone,
46
+ )
47
+ from pokerkit_plus.draws import ( # noqa: F401
48
+ Draws,
49
+ NutRank,
50
+ NUT_RANK_ORDER,
51
+ )
52
+ from pokerkit_plus.tags import ( # noqa: F401
53
+ HandTier,
54
+ KickerTier,
55
+ PairTier,
56
+ ThreeOfAKindTier,
57
+ TwoPairTier,
58
+ )
59
+ from pokerkit_plus.outs import Outs # noqa: F401
60
+ from pokerkit_plus.blockers import BlockerReport # noqa: F401
61
+ from pokerkit_plus.ranges import ( # noqa: F401
62
+ Advantage,
63
+ AdvantageBasis,
64
+ Aggression,
65
+ AGGRESSION_ORDER,
66
+ ComboClass,
67
+ build_value_range,
68
+ calculate_range_advantage,
69
+ expand_range,
70
+ nut_advantage,
71
+ )
72
+ from pokerkit_plus.facade import ( # noqa: F401
73
+ BoardReport,
74
+ HandReport,
75
+ )
76
+
77
+ __version__ = "0.0.1"
78
+
79
+ _PLUS_ALL: tuple[str, ...] = (
80
+ # combos
81
+ 'CATEGORY_ORDER',
82
+ 'CategoryCombos',
83
+ 'HoleCombo',
84
+ 'Nuts',
85
+ 'made_label',
86
+ 'meets',
87
+ # texture
88
+ 'BoardTexture',
89
+ 'Connectivity',
90
+ 'CONNECTIVITY_ORDER',
91
+ 'FlushDraw',
92
+ 'FLUSH_DRAW_ORDER',
93
+ 'RankBand',
94
+ 'RANK_BAND_ORDER',
95
+ 'StraightDraw',
96
+ 'STRAIGHT_DRAW_ORDER',
97
+ 'Wetness',
98
+ 'WETNESS_ORDER',
99
+ 'are_monotone',
100
+ 'are_rainbow',
101
+ 'are_two_tone',
102
+ # draws
103
+ 'Draws',
104
+ 'NutRank',
105
+ 'NUT_RANK_ORDER',
106
+ # tags
107
+ 'HandTier',
108
+ 'KickerTier',
109
+ 'PairTier',
110
+ 'ThreeOfAKindTier',
111
+ 'TwoPairTier',
112
+ # outs
113
+ 'Outs',
114
+ # blockers
115
+ 'BlockerReport',
116
+ # ranges
117
+ 'Advantage',
118
+ 'AdvantageBasis',
119
+ 'Aggression',
120
+ 'AGGRESSION_ORDER',
121
+ 'ComboClass',
122
+ 'build_value_range',
123
+ 'calculate_range_advantage',
124
+ 'expand_range',
125
+ 'nut_advantage',
126
+ # facade
127
+ 'BoardReport',
128
+ 'HandReport',
129
+ )
130
+
131
+ __all__ = (*_POKERKIT_ALL, *_PLUS_ALL)
@@ -0,0 +1,249 @@
1
+ """:mod:`pokerkit_plus._semantic` implements private foundations shared
2
+ by the semantic layer.
3
+
4
+ Nothing here is part of the public surface; these helpers centralize live
5
+ card enumeration, royal-flush refinement, and the single memoized nut
6
+ enumeration that :mod:`pokerkit_plus.combos` (and later blocker/range
7
+ modules) build on. Everything is expressed in terms of :mod:`pokerkit`
8
+ primitives so that strength and category come from PokerKit's lookup-table
9
+ total order rather than any hand-rolled scoring.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Iterator
15
+ from dataclasses import dataclass
16
+ from functools import lru_cache
17
+ from itertools import combinations
18
+ from typing import TYPE_CHECKING
19
+
20
+ from pokerkit.hands import Hand
21
+ from pokerkit.lookups import Label
22
+ from pokerkit.utilities import Card, CardsLike, Deck, Rank, RankOrder
23
+
24
+ if TYPE_CHECKING:
25
+ from collections.abc import Mapping
26
+
27
+ __BROADWAY: frozenset[Rank] = frozenset(RankOrder.STANDARD[-5:])
28
+
29
+
30
+ def _used(*groups: CardsLike) -> frozenset[Card]:
31
+ """Return the set of cards used (and hence dead) across the groups.
32
+
33
+ Each group is normalized through :meth:`pokerkit.utilities.Card.clean`,
34
+ so hole cards, board cards, and dead cards can be supplied in any
35
+ cards-like form (a string, a single card, or an iterable of cards).
36
+
37
+ >>> sorted(map(repr, _used('AsKs', 'Qd')))
38
+ ['As', 'Ks', 'Qd']
39
+ >>> _used() == frozenset()
40
+ True
41
+
42
+ :param groups: The card groups to union together.
43
+ :return: The frozen set of all used cards.
44
+ """
45
+ used: set[Card] = set()
46
+
47
+ for group in groups:
48
+ used.update(Card.clean(group))
49
+
50
+ return frozenset(used)
51
+
52
+
53
+ def _live_cards(
54
+ *groups: CardsLike,
55
+ deck: Deck = Deck.STANDARD,
56
+ ) -> Iterator[Card]:
57
+ """Yield the live cards: deck cards not used by any group.
58
+
59
+ The deck is iterated in its native (deterministic) order, so the live
60
+ cards are yielded deterministically. This is a generator; callers
61
+ materialize it with ``tuple(...)`` only when they need a collection.
62
+
63
+ >>> live = tuple(_live_cards('AsKsQs'))
64
+ >>> len(live)
65
+ 49
66
+
67
+ :param groups: The card groups whose cards are dead.
68
+ :param deck: The deck of candidate cards, defaults to
69
+ :attr:`pokerkit.utilities.Deck.STANDARD`.
70
+ :return: The iterator of live cards in deck order.
71
+ """
72
+ used = _used(*groups)
73
+
74
+ for card in deck:
75
+ if card not in used:
76
+ yield card
77
+
78
+
79
+ def _is_royal(hand: Hand) -> bool:
80
+ """Return whether the hand is a royal flush.
81
+
82
+ PokerKit reports a royal flush as :attr:`pokerkit.lookups.Label`
83
+ ``.STRAIGHT_FLUSH``; this refines that by checking the five card ranks
84
+ are exactly the broadway set (ten through ace). The check is on the
85
+ rank *set*, not on the top card by index, because the steel wheel
86
+ (``As2s3s4s5s``) also has the ace as its highest card by
87
+ :attr:`pokerkit.utilities.RankOrder` ``.STANDARD`` index yet is not a
88
+ royal flush.
89
+
90
+ >>> from pokerkit.hands import StandardHighHand
91
+ >>> _is_royal(StandardHighHand.from_game('AsKs', 'QsJsTs'))
92
+ True
93
+ >>> _is_royal(StandardHighHand.from_game('As2s', '3s4s5s'))
94
+ False
95
+ >>> _is_royal(StandardHighHand.from_game('AcAd', 'AhAsKc'))
96
+ False
97
+
98
+ :param hand: The hand to test.
99
+ :return: ``True`` if the hand is a royal flush, otherwise ``False``.
100
+ """
101
+ if hand.entry.label is not Label.STRAIGHT_FLUSH:
102
+ return False
103
+
104
+ return frozenset(card.rank for card in hand.cards) == __BROADWAY
105
+
106
+
107
+ @dataclass(frozen=True)
108
+ class HoleCombo:
109
+ """A two-card hole combination together with its made hand.
110
+
111
+ Instances are produced by the nut enumeration core and never
112
+ constructed by users directly; the embedded :attr:`hand` is the
113
+ already-evaluated best hand for these hole cards on the enumerated
114
+ board, so consumers never re-evaluate.
115
+
116
+ >>> from pokerkit.hands import StandardHighHand
117
+ >>> hand = StandardHighHand.from_game('TsJs', 'AsKsQs')
118
+ >>> combo = HoleCombo(tuple(Card.parse('TsJs')), hand)
119
+ >>> sorted(map(repr, combo.cards))
120
+ ['Js', 'Ts']
121
+ >>> combo.as_frozenset == frozenset(Card.parse('TsJs'))
122
+ True
123
+
124
+ :param cards: The two hole cards, in deck order.
125
+ :param hand: The evaluated best hand these cards make on the board.
126
+ """
127
+
128
+ cards: tuple[Card, ...]
129
+ """The two hole cards, in deck order."""
130
+ hand: Hand
131
+ """The evaluated best hand these cards make on the board."""
132
+
133
+ @property
134
+ def as_frozenset(self) -> frozenset[Card]:
135
+ """Return the hole cards as a frozen set.
136
+
137
+ This is the exact shape consumed by
138
+ :func:`pokerkit.analysis.parse_range` output and
139
+ :func:`pokerkit.analysis.calculate_equities` input, so a combo can
140
+ round-trip into an equity call.
141
+
142
+ >>> from pokerkit.hands import StandardHighHand
143
+ >>> hand = StandardHighHand.from_game('TsJs', 'AsKsQs')
144
+ >>> combo = HoleCombo(tuple(Card.parse('TsJs')), hand)
145
+ >>> combo.as_frozenset == frozenset(Card.parse('JsTs'))
146
+ True
147
+
148
+ :return: The hole cards as a frozen set.
149
+ """
150
+ return frozenset(self.cards)
151
+
152
+
153
+ @dataclass(frozen=True)
154
+ class _NutsCore:
155
+ """The shared, immutable result of one nut enumeration over a board.
156
+
157
+ This is the value cached by :func:`_nuts_core`. A single enumeration
158
+ pass produces both the nut hand (and the combos tying it) and the
159
+ per-category grouping of every live combo, so consumers never
160
+ re-evaluate.
161
+
162
+ :param hand: The strongest hand makeable on the board, or ``None`` if
163
+ the board is too short to enumerate.
164
+ :param combos: Every two-card combo whose best hand ties the strongest
165
+ hand, each carrying its already-evaluated hand.
166
+ :param candidate_count: The number of live two-card combos enumerated.
167
+ :param by_category: Every live combo grouped by its made-hand label, in
168
+ enumeration order.
169
+ """
170
+
171
+ hand: Hand | None
172
+ """The strongest hand makeable on the board, or ``None``."""
173
+ combos: tuple[HoleCombo, ...]
174
+ """The combos tying the strongest hand, with evaluated hands."""
175
+ candidate_count: int
176
+ """The number of live two-card combos enumerated."""
177
+ by_category: Mapping[Label, tuple[HoleCombo, ...]]
178
+ """Every live combo grouped by its made-hand label."""
179
+
180
+
181
+ @lru_cache(maxsize=None)
182
+ def _nuts_core(
183
+ board: frozenset[Card],
184
+ hand_type: type[Hand],
185
+ dead: frozenset[Card],
186
+ ) -> _NutsCore:
187
+ """Enumerate a board once, memoized by board, type, and dead cards.
188
+
189
+ This is the single source of nut enumeration shared by
190
+ :class:`pokerkit_plus.combos.Nuts` and
191
+ :class:`pokerkit_plus.combos.CategoryCombos`. It enumerates every live
192
+ two-card combo ONCE, calls ``hand_type.from_game_or_none`` exactly once
193
+ per combo, and in that same pass tracks the running maximum hand (with
194
+ every tying combo) and groups every combo by its made-hand label. No
195
+ combo is ever re-classified, and nothing uses ``list.index`` or rebuilds
196
+ a set per element.
197
+
198
+ The cache is keyed on :class:`frozenset` of
199
+ :class:`pokerkit.utilities.Card`, which is a correct key because
200
+ ``Card`` is a frozen, hashable dataclass: two boards with the same card
201
+ set hash and compare equal regardless of order.
202
+ ``functools.lru_cache`` is chosen over a hand-rolled module dict because
203
+ all three arguments are already hashable and immutable, the function is
204
+ pure, and ``lru_cache`` gives thread-safe memoization plus
205
+ ``cache_info``/``cache_clear`` introspection for free.
206
+
207
+ A board with fewer than three cards returns an honest empty core
208
+ (``hand is None``, no combos) rather than raising or returning ``None``.
209
+ A board that is itself the nuts (e.g. a quad board) yields *every* live
210
+ combo as a tying combo, exposed via :attr:`_NutsCore.combos` and
211
+ :attr:`_NutsCore.candidate_count` so blockers can detect the
212
+ "no blocker possible" case.
213
+
214
+ :param board: The board cards as a frozen set.
215
+ :param hand_type: The hand type to evaluate with.
216
+ :param dead: Additional dead (used) cards as a frozen set.
217
+ :return: The cached enumeration result.
218
+ """
219
+ board_cards = tuple(board)
220
+
221
+ if len(board_cards) < 3:
222
+ return _NutsCore(None, (), 0, {})
223
+
224
+ max_hand: Hand | None = None
225
+ tying: list[HoleCombo] = []
226
+ grouped: dict[Label, list[HoleCombo]] = {}
227
+ candidate_count = 0
228
+
229
+ for hole in combinations(_live_cards(board, dead), 2):
230
+ candidate_count += 1
231
+ hand = hand_type.from_game_or_none(hole, board_cards)
232
+
233
+ if hand is None:
234
+ continue
235
+
236
+ combo = HoleCombo(hole, hand)
237
+ grouped.setdefault(hand.entry.label, []).append(combo)
238
+
239
+ if max_hand is None or hand > max_hand:
240
+ max_hand = hand
241
+ tying = [combo]
242
+ elif hand == max_hand:
243
+ tying.append(combo)
244
+
245
+ by_category = {
246
+ label: tuple(combos) for label, combos in grouped.items()
247
+ }
248
+
249
+ return _NutsCore(max_hand, tuple(tying), candidate_count, by_category)