feat: confirm-loop tools + descriptor reads for nameless villains

Phase 3. She can now log and resolve identity at the table:
- add_read gains a `descriptor` param — a read on an unnamed player resolves to an
  existing descriptor villain (confident match) or opens a new one, so reads on
  "neck tattoo guy" accumulate and reuse across the night.
- name_villain(descriptor, name): attach a real name once caught (history carries).
- link_villains(a, b, same): merge on confirmed same-person, or mark distinct so
  she stops asking. Refuses to act on a vague reference — never merges on a guess.
- Cash card PLAYERS guidance: log nameless villains by distinctive descriptor,
  cite the SCOUTING DESK note, ask before assuming a callback, confirm before merge.

4 tests. Full suite 150 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-03 22:52:20 +00:00
parent 9cd962625d
commit f2944ed402
4 changed files with 184 additions and 7 deletions
+16 -3
View File
@@ -47,9 +47,9 @@ _BASE = ("journal_write", "note", "think_about", "thought_response", "set_mode")
# The full live cash-game toolset (incl. Brian's mental-game rituals). # The full live cash-game toolset (incl. Brian's mental-game rituals).
_CASH_TOOLS = _BASE + _LOOKUPS + ( _CASH_TOOLS = _BASE + _LOOKUPS + (
"start_session", "add_buyin", "log_stack", "log_hand", "record_hand", "start_session", "add_buyin", "log_stack", "log_hand", "record_hand",
"add_read", "analyze_spot", "session_stats", "session_state", "end_session", "add_read", "name_villain", "link_villains", "analyze_spot", "session_stats",
"generate_recap", "scar_note", "confidence_bank", "alligator_blood", "reset_ritual", "session_state", "end_session", "generate_recap", "scar_note", "confidence_bank",
"undo_last", "update_session", "alligator_blood", "reset_ritual", "undo_last", "update_session",
) )
# Talk mode also gets start_session as the *entry point*: opening a session from a # Talk mode also gets start_session as the *entry point*: opening a session from a
@@ -99,6 +99,19 @@ thing that shows in the session's "notes" panel. This is NOT the place for how y
existential musing, or reflection on yourself — that's your journal (journal_write), and it \ existential musing, or reflection on yourself — that's your journal (journal_write), and it \
stays off the table. At the table you're logging the session, not processing your night. stays off the table. At the table you're logging the session, not processing your night.
PLAYERS — names AND nameless. Most villains don't come with a name; Brian knows them by a \
look ("neck tattoo guy", "the bald reg two to my left"). Log reads on them anyway: give \
`add_read` a `descriptor` instead of a name and it attaches to that unnamed player, reused \
whenever he describes the guy again. Prefer DISTINCTIVE features (tattoos, build, a hat) over \
generic ones — "mid-aged white guy in glasses" identifies no one. When you already have \
history on someone he names or describes, a SCOUTING DESK note will appear with it — cite it, \
don't invent. If you're not sure the guy he's describing is one you know, ASK ("same neck-\
tattoo reg from last week?") rather than assume — a wrong callback is worse than none. On his \
YES that two are the same person, call link_villains(same=true) to merge them; on "nah, \
different guy," link_villains(same=false) so you stop asking. When he finally catches a name \
for a described player, name_villain carries the whole history over. Never merge on a guess — \
only when he's confirmed it.
Everything you log appears on Brian's live HUD (the Session view) — stack, live net, \ Everything you log appears on Brian's live HUD (the Session view) — stack, live net, \
hands, villains, the confidence bank, the scar notes, and whether Alligator Blood is on. \ hands, villains, the confidence bank, the scar notes, and whether Alligator Blood is on. \
That HUD and you read the SAME data. So when he asks where he's at — his stack, his live \ That HUD and you read the SAME data. So when he asks where he's at — his stack, his live \
+14 -2
View File
@@ -1393,14 +1393,26 @@ def scan_merge_candidates(sim_threshold: float = _SIM_HIGH) -> int:
def add_read(note: str, seat: str | None = None, name: str | None = None, def add_read(note: str, seat: str | None = None, name: str | None = None,
session_id: int | None = None, **player_fields) -> int: descriptor: str | None = None, session_id: int | None = None,
"""Log a live read. If `name` is given, upsert the player and link the read.""" **player_fields) -> int:
"""Log a live read. `name` upserts a named player; `descriptor` (a nameless
villain's physical description) resolves to an existing descriptor villain when
confident, else opens a new one — so reads on unnamed players still accumulate."""
sid = _resolve(session_id) sid = _resolve(session_id)
venue = player_fields.get("venue")
pid = None pid = None
if name: if name:
pid = upsert_player(name, **{k: v for k, v in player_fields.items() pid = upsert_player(name, **{k: v for k, v in player_fields.items()
if k in ("venue", "description", "tendencies", if k in ("venue", "description", "tendencies",
"adjustment", "category")}) "adjustment", "category")})
elif descriptor:
res = resolve_villain(descriptor, venue=venue, session_id=sid)
if res["band"] in ("name", "high") and res["match_id"]:
pid = res["match_id"]
add_descriptor(pid, descriptor) # sharpen the key with this phrasing
else:
pid = create_descriptor_villain(descriptor, venue=venue,
category=player_fields.get("category"))
conn = _c() conn = _c()
with conn: with conn:
cur = conn.execute( cur = conn.execute(
+69 -2
View File
@@ -290,14 +290,60 @@ def _log_hand(args: dict, ctx: dict) -> str:
def _add_read(args: dict, ctx: dict) -> str: def _add_read(args: dict, ctx: dict) -> str:
poker.add_read( poker.add_read(
note=args.get("note") or "", seat=args.get("seat"), name=args.get("name"), note=args.get("note") or "", seat=args.get("seat"), name=args.get("name"),
descriptor=args.get("descriptor"),
tendencies=args.get("tendencies"), adjustment=args.get("adjustment"), tendencies=args.get("tendencies"), adjustment=args.get("adjustment"),
description=args.get("description"), category=args.get("category"), description=args.get("description"), category=args.get("category"),
venue=args.get("venue"), venue=args.get("venue"),
) )
who = f" on {args['name']}" if args.get("name") else "" who = f" on {args['name']}" if args.get("name") else (
f" on “{args['descriptor']}" if args.get("descriptor") else "")
return f"Read logged{who}." return f"Read logged{who}."
def _resolve_villain_ref(ref: str) -> tuple[int | None, str]:
"""Resolve a name-or-descriptor to a single player id for a confirm-loop action.
Returns (id, band); acts only on a deterministic name or a confident descriptor."""
live = poker.live_session()
res = poker.resolve_villain(ref, venue=(live or {}).get("venue"),
session_id=(live or {}).get("id"))
if res["band"] in ("name", "high") and res["match_id"]:
return res["match_id"], res["band"]
return None, res["band"]
def _name_villain(args: dict, ctx: dict) -> str:
ref = (args.get("descriptor") or "").strip()
name = (args.get("name") or "").strip()
if not ref or not name:
return "Need both the description of the player and the name to attach."
pid, band = _resolve_villain_ref(ref)
if pid is None:
return (f"Couldn't confidently find “{ref}” to name — too vague or no match. "
"Add a read with the descriptor first, or be more specific.")
poker.name_villain(pid, name)
return f"Got it — “{ref}” is {name} now; their history carries over."
def _link_villains(args: dict, ctx: dict) -> str:
a = (args.get("player_a") or "").strip()
b = (args.get("player_b") or "").strip()
same = bool(args.get("same"))
if not a or not b:
return "Need two players to link (by name or description)."
ida, _ = _resolve_villain_ref(a)
idb, _ = _resolve_villain_ref(b)
if ida is None or idb is None:
return ("Couldn't confidently pin down both players, so I didn't merge anything — "
"safer to leave it. You can sort it on the Players page.")
if ida == idb:
return "Those resolve to the same profile already — nothing to do."
if same:
poker.merge_players(ida, idb)
return "Merged — same guy. Their histories are one file now."
poker.mark_distinct(ida, idb, note=args.get("note"))
return "Noted they're different people — I won't suggest merging them again."
def _end_session(args: dict, ctx: dict) -> str: def _end_session(args: dict, ctx: dict) -> str:
s = poker.end_session(cash_out=float(args.get("cash_out") or 0), mood=args.get("mood")) s = poker.end_session(cash_out=float(args.get("cash_out") or 0), mood=args.get("mood"))
hourly = f", {s['net'] / s['hours']:+.0f}/hr" if s.get("hours") else "" hourly = f", {s['net'] / s['hours']:+.0f}/hr" if s.get("hours") else ""
@@ -585,9 +631,13 @@ TOOLS.update({
[])}, [])},
"add_read": {"handler": _add_read, "spec": _f( "add_read": {"handler": _add_read, "spec": _f(
"add_read", "add_read",
"Log a read on an opponent. If you give a name, it's saved to the persistent villain file.", "Log a read on an opponent. Give a `name` if known; if not, give a `descriptor` "
"(a distinctive physical description like 'neck tattoo, backwards cap') and the read "
"attaches to that nameless player — reused automatically next time you describe him.",
{"note": {**_S, "description": "The observation / what they showed down"}, {"note": {**_S, "description": "The observation / what they showed down"},
"name": {**_S, "description": "Player name/handle if known (creates/updates their dossier)"}, "name": {**_S, "description": "Player name/handle if known (creates/updates their dossier)"},
"descriptor": {**_S, "description": "Physical description when there's no name, e.g. "
"'neck tattoo, heavyset'. Prefer distinctive features over generic ones."},
"seat": {**_S, "description": "Seat or relative position"}, "seat": {**_S, "description": "Seat or relative position"},
"tendencies": {**_S, "description": "Standing read on how they play"}, "tendencies": {**_S, "description": "Standing read on how they play"},
"adjustment": {**_S, "description": "How Brian should exploit them"}, "adjustment": {**_S, "description": "How Brian should exploit them"},
@@ -595,6 +645,23 @@ TOOLS.update({
"category": {**_S, "description": "feeder | risky | reg | unknown"}, "category": {**_S, "description": "feeder | risky | reg | unknown"},
"venue": {**_S, "description": "Where they play"}}, "venue": {**_S, "description": "Where they play"}},
["note"])}, ["note"])},
"name_villain": {"handler": _name_villain, "spec": _f(
"name_villain",
"Attach a real name to a player you'd only known by description (e.g. you caught it "
"off the Bravo screen). Their whole history carries over to the name.",
{"descriptor": {**_S, "description": "How you'd been referring to him, e.g. 'neck tattoo guy'"},
"name": {**_S, "description": "His real name/handle"}},
["descriptor", "name"])},
"link_villains": {"handler": _link_villains, "spec": _f(
"link_villains",
"Resolve a same-person question when Brian confirms it. same=true MERGES two profiles "
"into one (their histories join); same=false records they're DIFFERENT people so you "
"stop asking. Only call after he's confirmed — never merge on a guess.",
{"player_a": {**_S, "description": "First player, by name or description"},
"player_b": {**_S, "description": "Second player, by name or description"},
"same": {"type": "boolean", "description": "true = same person (merge); false = different"},
"note": {**_S, "description": "For different people: the tell that distinguishes them"}},
["player_a", "player_b", "same"])},
"end_session": {"handler": _end_session, "spec": _f( "end_session": {"handler": _end_session, "spec": _f(
"end_session", "Close the live session: record cashout, compute net + hours.", "end_session", "Close the live session: record cashout, compute net + hours.",
{"cash_out": {**_N, "description": "Final cashout amount"}, {"cash_out": {**_N, "description": "Final cashout amount"},
+85
View File
@@ -0,0 +1,85 @@
"""Confirm-loop tools: descriptor reads, name attach, merge/mark-distinct."""
from __future__ import annotations
import importlib
import numpy as np
import pytest
def _fake_embed(texts):
out = []
for t in texts:
v = np.zeros(64, dtype=np.float32)
for w in t.lower().split():
v[hash(w) % 64] += 1.0
out.append((v if v.any() else np.full(64, 1e-6, dtype=np.float32)).tolist())
return out
@pytest.fixture
def mods(tmp_path, monkeypatch):
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db"))
from lyra import llm
monkeypatch.setattr(llm, "embed", _fake_embed)
import lyra.memory as memory
importlib.reload(memory)
import lyra.poker as poker
importlib.reload(poker)
import lyra.tools as tools
importlib.reload(tools)
return poker, tools
def test_descriptor_read_creates_then_reuses_nameless_villain(mods):
poker, tools = mods
poker.start_session(venue="Meadows", stakes="1/3", buy_in=300)
tools.dispatch("add_read", {"note": "opened UTG light",
"descriptor": "neck tattoo sleeve arm"}, {})
tools.dispatch("add_read", {"note": "showed a bluff",
"descriptor": "neck tattoo sleeve"}, {}) # rephrase → same guy
players = [p for p in poker.get_villain_file() if not p["named"]]
assert len(players) == 1 # one nameless villain, not two
reads = poker._c().execute(
"SELECT COUNT(*) n FROM player_reads WHERE player_id = ?", (players[0]["id"],)
).fetchone()["n"]
assert reads == 2
def test_name_villain_tool_attaches_name(mods):
poker, tools = mods
poker.start_session(venue="Meadows", buy_in=300)
poker.create_descriptor_villain("neck tattoo sleeve arm", venue="Meadows")
out = tools.dispatch("name_villain", {"descriptor": "neck tattoo sleeve arm",
"name": "Danny"}, {})
assert "Danny" in out
assert poker.resolve_villain("Danny")["band"] == "name"
def test_link_villains_merge_and_distinct(mods):
poker, tools = mods
poker.start_session(venue="Meadows", buy_in=300)
poker.upsert_player("Danny", venue="Meadows")
poker.upsert_player("Donny", venue="Meadows")
# same=false → recorded distinct
tools.dispatch("link_villains", {"player_a": "Danny", "player_b": "Donny",
"same": False, "note": "different builds"}, {})
a = poker.resolve_villain("Danny")["match_id"]
b = poker.resolve_villain("Donny")["match_id"]
assert poker.are_distinct(a, b)
# same=true on a fresh pair → merged
poker.upsert_player("Mike", venue="Meadows")
poker.upsert_player("Michael", venue="Meadows")
tools.dispatch("link_villains", {"player_a": "Mike", "player_b": "Michael",
"same": True}, {})
names = [p["name"] for p in poker.get_villain_file()]
assert ("Mike" in names) ^ ("Michael" in names) # one absorbed the other
def test_link_villains_refuses_when_reference_is_vague(mods):
poker, tools = mods
poker.start_session(venue="Meadows", buy_in=300)
poker.upsert_player("Danny", venue="Meadows")
out = tools.dispatch("link_villains", {"player_a": "Danny",
"player_b": "some guy", "same": True}, {})
assert "didn't merge" in out.lower() or "couldn't" in out.lower()