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
+10 -5
View File
@@ -97,11 +97,16 @@ inventing a mechanism — same rule as not inventing numbers.
## What you do NOT do
- **You do not invent numbers.** You do not compute exact ICM, equities, or
pot-odds in your head and present them as fact. The deterministic solver tools
aren't wired up yet, so when precise math is needed, be honest: give the
qualitative read and flag that the exact number needs the calc. Approximate
reasoning is fine if you label it as approximate.
- **You never eyeball poker math or board reading.** For equity, who's ahead,
what a hand makes, what a card completes, draws, or outs — call the
`analyze_spot` tool and report ITS numbers. You are genuinely unreliable at
reading boards and counting equity in your head (you'll hallucinate flushes,
miss straights, misjudge who's ahead) — the tool is exact. Never state an
equity %, a made hand, "you're ahead/drawing dead", or an out count without it.
- **You do not invent other numbers either.** Exact ICM and solver outputs aren't
wired up yet (RTO/cfr-core), so for those be honest: give the qualitative read
and flag that the precise number needs the calc. Approximate reasoning is fine
if you label it approximate.
- You don't pretend to remember things you don't. If you're not sure, say so.
- **You don't invent reads on players.** Before you say *anything* about a
specific opponent, you MUST call the `player_profile` tool and answer ONLY from
+36 -1
View File
@@ -10,8 +10,9 @@ the same way once we build that side.
from __future__ import annotations
import json
import re
from lyra import logbus, memory, poker
from lyra import equity, logbus, memory, poker
def _journal_write(args: dict, ctx: dict) -> str:
@@ -173,6 +174,31 @@ def _generate_recap(args: dict, ctx: dict) -> str:
f"at /recap/{out['id']}")
def _analyze_spot(args: dict, ctx: dict) -> str:
def cards(s):
return [c for c in re.split(r"[\s,]+", (s or "").strip()) if c]
try:
r = equity.analyze(cards(args.get("hero")), cards(args.get("villain")),
cards(args.get("board")))
except equity.EquityError as e:
return f"(can't compute equity: {e})"
except Exception as e: # never let a bad spot kill the turn
return f"(equity error: {e})"
street = {0: "preflop", 3: "flop", 4: "turn", 5: "river"}.get(len(r["board"]), "")
L = [f"Board: {' '.join(r['board']) or '(preflop)'}" + (f"{street}" if street else "")]
if "hero_hand" in r:
L.append(f"You ({' '.join(r['hero'])}): {r['hero_hand']}")
L.append(f"Villain ({' '.join(r['villain'])}): {r['villain_hand']}")
L.append(f"Currently ahead: {r['ahead']}")
tie = f" / tie {r['tie_equity']}%" if r.get("tie_equity") else ""
L.append(f"EQUITY (exact): you {r['hero_equity']}% / villain {r['villain_equity']}%{tie}")
o = r.get("hero_outs")
if o:
L.append(f"Your outs (one card to come): {o['count']}"
+ (f"{' '.join(o['cards'])}" if o["count"] else " — drawing dead"))
return "\n".join(L)
def _player_profile(args: dict, ctx: dict) -> str:
prof = poker.player_profile(args.get("name") or "")
if not prof:
@@ -302,6 +328,15 @@ TOOLS.update({
"data + this conversation. Use when he asks for the recap/writeup, usually "
"after ending a session.",
{}, [])},
"analyze_spot": {"handler": _analyze_spot, "spec": _f(
"analyze_spot",
"Compute EXACT poker equity, what each hand makes, who's ahead, and outs "
"for a hero-vs-villain spot. ALWAYS use this for any equity / board-reading "
"/ 'am I ahead' / outs question — never compute it yourself.",
{"hero": {**_S, "description": "Hero's hole cards, rank+suit letters, e.g. 'Jh Js' (use 'Jx' if a suit is unknown)"},
"villain": {**_S, "description": "Villain's hole cards, e.g. '6d 5d'"},
"board": {**_S, "description": "Board cards so far, e.g. '8c 7d Ts' (flop) or '8c 7d Ts 4d' (turn); omit for preflop"}},
["hero", "villain"])},
"player_profile": {"handler": _player_profile, "spec": _f(
"player_profile",
"Look up everything known about one opponent — dossier, reads, hands "
+1
View File
@@ -10,6 +10,7 @@ dependencies = [
"numpy>=2.4.5",
"openai>=2.37.0",
"python-dotenv>=1.2.2",
"treys>=0.1.8",
"uvicorn[standard]>=0.34",
]
+42
View File
@@ -0,0 +1,42 @@
"""Deterministic equity/board-eval — the JJ-vs-65 hand Lyra kept botching."""
from __future__ import annotations
import pytest
from lyra import equity
def test_flop_equity_and_made_hands():
r = equity.analyze(["Jh", "Js"], ["6d", "5d"], ["8c", "7d", "Ts"])
assert r["ahead"] == "hero"
assert r["hero_hand"] == "Pair" and r["villain_hand"] == "High Card"
assert 75 < r["hero_equity"] < 82 # ~78.7%
def test_turn_villain_straight_and_outs_exclude_flush_card():
r = equity.analyze(["Jh", "Js"], ["6d", "5d"], ["8c", "7d", "Ts", "4d"])
assert r["ahead"] == "villain"
assert r["villain_hand"] == "Straight"
# hero's only outs are the three non-diamond nines — 9d makes villain a flush
assert r["hero_outs"]["count"] == 3
assert "9d" not in r["hero_outs"]["cards"]
assert r["hero_equity"] < 10
def test_rejects_unknown_and_duplicate_cards():
with pytest.raises(equity.EquityError):
equity.analyze(["x", "x"], ["6d", "5d"], ["8c", "7d", "Ts"])
with pytest.raises(equity.EquityError):
equity.analyze(["8c", "8c"], ["6d", "5d"], ["8c", "7d", "Ts"])
def test_unknown_suits_spread_rainbow_no_phantom_flush():
# all-unknown-suit board must not become monotone (which would inflate equity)
r = equity.analyze(["Jx", "Jx"], ["6d", "5d"], ["8x", "7x", "Tx"])
assert 75 < r["hero_equity"] < 82
def test_tool_dispatch():
from lyra import tools
out = tools.dispatch("analyze_spot", {"hero": "Jh Js", "villain": "6d 5d", "board": "8c 7d Ts 4d"})
assert "EQUITY" in out and "Straight" in out
Generated
+11
View File
@@ -286,6 +286,7 @@ dependencies = [
{ name = "numpy" },
{ name = "openai" },
{ name = "python-dotenv" },
{ name = "treys" },
{ name = "uvicorn", extra = ["standard"] },
]
@@ -302,6 +303,7 @@ requires-dist = [
{ name = "numpy", specifier = ">=2.4.5" },
{ name = "openai", specifier = ">=2.37.0" },
{ name = "python-dotenv", specifier = ">=1.2.2" },
{ name = "treys", specifier = ">=0.1.8" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34" },
]
@@ -692,6 +694,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" },
]
[[package]]
name = "treys"
version = "0.1.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3b/a6/1712340dc1ac96d40afe162d43ce146c7781ba59cde5efc988aaee35ada4/treys-0.1.8.tar.gz", hash = "sha256:a486a42b899e91985b4da4fdac9a30e638275648977104487acb90a2dd7cd73b", size = 12073, upload-time = "2022-06-21T16:02:44.976Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/46/df/e6b3b1cc98c3e00c5b146113f998dfe0de47358277648df235a6ae571143/treys-0.1.8-py3-none-any.whl", hash = "sha256:9ba3460ff2ed597510fb535af6280f115254b0b70699ea362f8f1ee067378063", size = 11897, upload-time = "2022-06-21T16:02:42.896Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"