cb99a8bcee
Lyra was hallucinating poker facts — phantom flushes, missed straights, wrong equity, only correcting when spoon-fed. Board reading + equity are combinatorial facts an LLM can't do reliably; this is exactly the "math via deterministic tools, never the LLM" principle. - lyra/equity.py: treys-backed analyze(hero, villain, board) -> made hands, who's ahead, EXACT equity (enumerated), and outs (one to come). Handles 'Jx' unknown suits (assigned rainbow to avoid phantom flushes); rejects 'x'/dupes. - analyze_spot tool wired into chat; persona MANDATES it for any equity/board/ who's-ahead/outs question — never eyeballed. - tests on the real JJ-vs-65 hand: flop 78.7%, turn villain straight + hero 6.8% with outs "9s 9h 9c" (correctly excludes 9d, which makes villain a flush). Verified live: she now calls the tool and reports exact numbers, no hallucinated flush. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
132 lines
5.0 KiB
Python
132 lines
5.0 KiB
Python
"""Deterministic poker evaluation + equity — the math Lyra must NEVER eyeball.
|
|
|
|
Wraps `treys` so board reading (what each hand makes), who's ahead, exact equity,
|
|
and outs are *computed*, not guessed by the LLM (which is unreliable at it). Cards
|
|
are 'Rs' (rank + suit letter, e.g. 'Jh','Td'); a card with unknown suit ('Jx') is
|
|
assigned an arbitrary free suit; a fully-unknown 'x' can't be used for equity.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from itertools import combinations
|
|
|
|
from treys import Card, Evaluator
|
|
|
|
_EV = Evaluator()
|
|
_RANKS = "23456789TJQKA"
|
|
_SUITS = "shdc"
|
|
_DECK = [r + s for r in _RANKS for s in _SUITS]
|
|
_SYM = {"♥": "h", "♦": "d", "♣": "c", "♠": "s"}
|
|
|
|
|
|
class EquityError(ValueError):
|
|
pass
|
|
|
|
|
|
def _norm(tok: str) -> str:
|
|
t = (tok or "").strip().replace("10", "T")
|
|
for sym, ltr in _SYM.items():
|
|
t = t.replace(sym, ltr)
|
|
return t
|
|
|
|
|
|
def _resolve(groups: list[list[str]]) -> list[list[str]]:
|
|
"""Resolve card tokens across groups to concrete 'Rs' cards (assign suits to
|
|
'Rx', reject fully-unknown 'x'); raise on real duplicates/garbage."""
|
|
# concrete cards already named, so 'Rx' suit-assignment can avoid them
|
|
concrete: set[str] = set()
|
|
for g in groups:
|
|
for tok in g:
|
|
t = _norm(tok)
|
|
if len(t) == 2 and t[0].upper() in _RANKS and t[1].lower() in _SUITS:
|
|
concrete.add(t[0].upper() + t[1].lower())
|
|
placed: set[str] = set()
|
|
out: list[list[str]] = []
|
|
cycle = 0 # rotate suit assignment for unknown suits so we don't fabricate flushes
|
|
for g in groups:
|
|
rg: list[str] = []
|
|
for tok in g:
|
|
t = _norm(tok)
|
|
if not t or t.lower() == "x":
|
|
raise EquityError(f"card '{tok}' is fully unknown — need at least a rank")
|
|
r = t[0].upper()
|
|
if r not in _RANKS:
|
|
raise EquityError(f"can't read card '{tok}'")
|
|
if len(t) > 1 and t[1].lower() in _SUITS:
|
|
card = r + t[1].lower()
|
|
else: # unknown suit -> spread suits (rainbow) to avoid phantom flushes
|
|
order = _SUITS[cycle % 4:] + _SUITS[:cycle % 4]
|
|
cycle += 1
|
|
card = next((r + s for s in order
|
|
if r + s not in concrete and r + s not in placed), None)
|
|
if card is None:
|
|
raise EquityError(f"no free suit left for {r}")
|
|
if card in placed:
|
|
raise EquityError(f"duplicate card {card}")
|
|
placed.add(card)
|
|
rg.append(card)
|
|
out.append(rg)
|
|
return out
|
|
|
|
|
|
def _made(cards: list[str], board: list[str]) -> str:
|
|
score = _EV.evaluate([Card.new(c) for c in board], [Card.new(c) for c in cards])
|
|
return _EV.class_to_string(_EV.get_rank_class(score))
|
|
|
|
|
|
def _equity(hero: list[str], vil: list[str], board: list[str]) -> tuple[float, float, float]:
|
|
known = set(hero + vil + board)
|
|
rem = [c for c in _DECK if c not in known]
|
|
need = 5 - len(board)
|
|
hw = vw = tie = 0
|
|
bh = [Card.new(c) for c in board]
|
|
hh = [Card.new(c) for c in hero]
|
|
vh = [Card.new(c) for c in vil]
|
|
for extra in combinations(rem, need) if need else [()]:
|
|
full = bh + [Card.new(c) for c in extra]
|
|
h, v = _EV.evaluate(full, hh), _EV.evaluate(full, vh)
|
|
if h < v:
|
|
hw += 1
|
|
elif v < h:
|
|
vw += 1
|
|
else:
|
|
tie += 1
|
|
n = hw + vw + tie or 1
|
|
return round(100 * hw / n, 1), round(100 * vw / n, 1), round(100 * tie / n, 1)
|
|
|
|
|
|
def _outs(hero: list[str], vil: list[str], board: list[str]) -> dict:
|
|
"""River cards (when one to come) that give hero the win. Lists them so a
|
|
'tricky' card (e.g. one that makes villain a flush) is visible by omission."""
|
|
if len(board) != 4:
|
|
return {}
|
|
known = set(hero + vil + board)
|
|
bh = [Card.new(c) for c in board]
|
|
hh = [Card.new(c) for c in hero]
|
|
vh = [Card.new(c) for c in vil]
|
|
winners = []
|
|
for c in (x for x in _DECK if x not in known):
|
|
full = bh + [Card.new(c)]
|
|
if _EV.evaluate(full, hh) < _EV.evaluate(full, vh):
|
|
winners.append(c)
|
|
return {"count": len(winners), "cards": winners}
|
|
|
|
|
|
def analyze(hero: list[str], villain: list[str], board: list[str]) -> dict:
|
|
"""Made hands + exact equity + outs for a hero-vs-villain spot at a given board."""
|
|
h, v, b = _resolve([hero, villain, board])
|
|
allc = h + v + b
|
|
if len(set(allc)) != len(allc):
|
|
raise EquityError("duplicate cards across hands/board")
|
|
res: dict = {"hero": h, "villain": v, "board": b}
|
|
if len(b) >= 3:
|
|
res["hero_hand"] = _made(h, b)
|
|
res["villain_hand"] = _made(v, b)
|
|
hs = _EV.evaluate([Card.new(c) for c in b], [Card.new(c) for c in h])
|
|
vs = _EV.evaluate([Card.new(c) for c in b], [Card.new(c) for c in v])
|
|
res["ahead"] = "hero" if hs < vs else "villain" if vs < hs else "tie"
|
|
heq, veq, tie = _equity(h, v, b)
|
|
res.update(hero_equity=heq, villain_equity=veq, tie_equity=tie)
|
|
if len(b) == 4:
|
|
res["hero_outs"] = _outs(h, v, b)
|
|
return res
|