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
+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()