feat: deterministic equity/board-reading tool (math via tools, not LLM)

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>
This commit is contained in:
2026-06-18 18:45:40 +00:00
parent 3bf18605db
commit cb99a8bcee
6 changed files with 231 additions and 6 deletions
+131
View File
@@ -0,0 +1,131 @@
"""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