Files
project-lyra/tests/test_villain_tools.py
T
serversdown 392c46d8bf fix: stop spawning duplicate villains from descriptions in the name field
Root cause of "4 entries for the same person": physical descriptions were being
passed as `name`, creating a new *named* player each time the wording drifted
(exact-name match can't dedupe near-identical sentences, and the merge scan only
looks at descriptor embeddings).

- add_read: a `name` that looks like a description (comma-listed / long / has
  appearance words) is rerouted to the descriptor path so it dedupes.
- descriptor reads that are ambiguously close to an existing villain now file a
  merge_candidate to the review queue instead of leaving a silent duplicate.
- distinctiveness() reworked: recognizes specific content (proper nouns/brands,
  feature lists) as distinctive even when a generic word like "shirt" is present —
  the old list-only heuristic scored "Filipino, Fox Racing hat, DKNY shirt" as
  generic and gated it out.
- Cash card: name = real handle ONLY; the look goes in descriptor as a few
  distinctive tags, and use name_villain to fuse a name onto a described player.

Full suite 157 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 03:16:50 +00:00

104 lines
4.4 KiB
Python

"""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_description_as_name_routes_to_descriptor_and_dedupes(mods):
poker, tools = mods
poker.start_session(venue="Meadows", buy_in=300)
# She (wrongly) puts a physical description in the name field, twice, worded
# slightly differently — must resolve to ONE nameless villain, not two named.
tools.dispatch("add_read", {"note": "limp 3bet A3o",
"name": "Filipino, Fox Racing hat, DKNY shirt, two bracelets"}, {})
tools.dispatch("add_read", {"note": "called a 4bet light",
"name": "Filipino, Fox Racing hat, DKNY shirt, watch on left"}, {})
named = [p for p in poker.get_villain_file() if p["named"]]
assert named == [] # no sentence-named players spawned (the bug)
# Either they merged, or the near-dup is surfaced for a one-click merge — never
# a silent duplicate the way sentence-names were.
q = poker.list_identity_queue()
nameless = [p for p in poker.get_villain_file() if not p["named"]]
assert len(nameless) == 1 or any(t["kind"] == "merge_candidate" for t in q)
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()