update to 0.2.0 stable #2
+131
@@ -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
@@ -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
@@ -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 "
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user