diff --git a/lyra/equity.py b/lyra/equity.py new file mode 100644 index 0000000..125a8f1 --- /dev/null +++ b/lyra/equity.py @@ -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 diff --git a/lyra/personas/lyra.md b/lyra/personas/lyra.md index 2e6d991..c0e3a6a 100644 --- a/lyra/personas/lyra.md +++ b/lyra/personas/lyra.md @@ -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 diff --git a/lyra/tools.py b/lyra/tools.py index 8b20ee8..3e21973 100644 --- a/lyra/tools.py +++ b/lyra/tools.py @@ -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 " diff --git a/pyproject.toml b/pyproject.toml index 0174d2a..451c31d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] diff --git a/tests/test_equity.py b/tests/test_equity.py new file mode 100644 index 0000000..7004d7a --- /dev/null +++ b/tests/test_equity.py @@ -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 diff --git a/uv.lock b/uv.lock index b83f791..eb0d154 100644 --- a/uv.lock +++ b/uv.lock @@ -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"