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>
This commit is contained in:
2026-07-04 03:16:50 +00:00
parent 4ce1b05fad
commit 392c46d8bf
3 changed files with 78 additions and 9 deletions
+7 -2
View File
@@ -102,8 +102,13 @@ stays off the table. At the table you're logging the session, not processing you
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 \
whenever he describes the guy again. The `name` field is ONLY a real handle (what he'd call \
him — "Jonathan", "Sleepy John"); a physical description NEVER goes in `name` — that spawns a \
new duplicate player every time the wording drifts. Put the look in `descriptor`, and keep it \
to a few DISTINCTIVE tags ("Filipino, Fox Racing hat, DKNY shirt"), not a paragraph and not \
generic filler — "mid-aged white guy in glasses" identifies no one. If he tells you the same \
guy's name after you'd been describing him, use name_villain to fuse them — don't create a \
second record. 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 \
+53 -7
View File
@@ -1189,14 +1189,27 @@ _SIM_AMBIGUOUS = 0.58 # plausible — don't guess live, route to review
_DISTINCT_MIN = 0.30 # below this the description is too generic to match at all
_GENERIC_SET = frozenset(_GENERIC)
def distinctiveness(text: str) -> float:
"""How usable a description is as an identity key: ~1.0 for a neck tattoo,
~0.1 for 'mid-aged white guy with glasses'. Generic-only stays near zero."""
t = (text or "").lower()
dist = sum(1 for w in _DISTINCTIVE if w in t)
if dist == 0:
return 0.10 if any(w in t for w in _GENERIC) else 0.30
return min(1.0, 0.45 + 0.28 * dist)
"""How usable a description is as an identity key: ~1.0 for 'neck tattoo, Fox
Racing hat', ~0.1 for 'mid-aged white guy with glasses'. Generic-ONLY stays
near zero; specific content (named features, brands, a list) reads as high —
even if a generic word like 'shirt' is mixed in."""
t = (text or "").strip()
if not t:
return 0.0
low = t.lower()
tokens = re.findall(r"[a-z0-9']+", low)
dist = sum(1 for w in _DISTINCTIVE if w in low)
proper = len(re.findall(r"\b[A-Z][a-z]{2,}", text)) # brands/proper nouns: Fox, DKNY-ish
non_generic = sum(1 for w in tokens if w not in _GENERIC_SET)
# Only bland filler (age/race/build/gender) and nothing concrete → not usable.
specific = dist + proper + (1 if "," in t else 0)
if specific == 0 and non_generic <= 1:
return 0.10
return min(1.0, 0.40 + 0.14 * specific + 0.05 * non_generic)
def _embed_vec(text: str):
@@ -1473,6 +1486,27 @@ def scan_merge_candidates(sim_threshold: float = _SIM_HIGH) -> int:
return filed
# Words that mark a "name" as really a physical description (misused name field).
_DESC_MARKERS = (
"shirt", "hat", "cap", "hair", "beard", "glasses", "sunglasses", "tattoo",
"bracelet", "watch", "descent", "jersey", "hoodie", "jacket", "build",
"bald", "goatee", "chain", "necklace", "piercing", "mustache", "ponytail",
"sleeve", "skin", "wearing", "heavyset", "tall guy", "older", "younger",
)
def _looks_like_description(text: str | None) -> bool:
"""A physical description mistakenly passed as a name — should be a descriptor.
Real handles are short (1-3 words, no commas); descriptions are longer / listy."""
t = (text or "").strip()
if not t:
return False
low = t.lower()
if "," in t or len(t.split()) > 4:
return True
return any(m in low for m in _DESC_MARKERS)
def add_read(note: str, seat: str | None = None, name: str | None = None,
descriptor: str | None = None, session_id: int | None = None,
**player_fields) -> int:
@@ -1481,6 +1515,11 @@ def add_read(note: str, seat: str | None = None, name: str | None = None,
confident, else opens a new one — so reads on unnamed players still accumulate."""
sid = _resolve(session_id)
venue = player_fields.get("venue")
# A description passed as a name (e.g. "Filipino, Fox Racing hat, DKNY shirt")
# is really a descriptor — route it so it dedupes instead of spawning a new
# named player each time the wording drifts.
if name and not descriptor and _looks_like_description(name):
descriptor, name = name, None
pid = None
if name:
pid = upsert_player(name, **{k: v for k, v in player_fields.items()
@@ -1494,6 +1533,13 @@ def add_read(note: str, seat: str | None = None, name: str | None = None,
else:
pid = create_descriptor_villain(descriptor, venue=venue,
category=player_fields.get("category"))
# Plausibly the same guy as an existing villain, but not confident —
# surface it for a one-click merge instead of leaving a silent dup.
if res["band"] == "ambiguous" and res["match_id"]:
queue_identity_task("merge_candidate", [pid, res["match_id"]],
descriptor=descriptor,
context="similar description logged live",
confidence=res["confidence"])
conn = _c()
with conn:
cur = conn.execute(
+18
View File
@@ -46,6 +46,24 @@ def test_descriptor_read_creates_then_reuses_nameless_villain(mods):
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)