feat(poker): nameless-villain identity resolution engine

Phase 1 of the scouting desk (docs/SCOUTING_DESK.md). Villains keyed by physical
descriptor when there's no name — a fuzzy key matched by embedding, venue-scoped,
gated on distinctiveness so a generic description never resolves to a wrong guess.

- schema: poker_players gains descriptors/descriptor_embedding/distinctiveness/
  named (name stays populated with a descriptor label to avoid a NOT-NULL rebuild
  on the live DB); new tables player_distinct_pairs + identity_queue.
- resolve_villain(ref, venue) → band name|high|ambiguous|generic|none; exact name
  is deterministic, descriptors match by cosine, generic-only refuses to guess.
- create_descriptor_villain / add_descriptor / name_villain; merge_players
  (repoints obs/reads, prefers a real name, re-embeds the union); mark_distinct +
  are_distinct (rejected merges stay rejected); identity_queue file/list/resolve
  with pending-dedup; scan_merge_candidates for the dream cycle (skips distinct
  pairs and cross-venue).

11 tests. Full suite 141 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-03 22:46:24 +00:00
parent 22526d7938
commit 8a6b11c56a
2 changed files with 467 additions and 1 deletions
+351 -1
View File
@@ -16,6 +16,8 @@ import json
import re
from datetime import datetime, timezone
import numpy as np
from lyra import clock, llm, memory
_SCHEMA = """
@@ -122,6 +124,32 @@ CREATE TABLE IF NOT EXISTS poker_rituals (
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_rituals_session ON poker_rituals(session_id);
-- Two profiles a human has confirmed are DIFFERENT people, so the merge-candidate
-- scan never re-proposes them. `note` records the distinguishing tell.
CREATE TABLE IF NOT EXISTS player_distinct_pairs (
a_id INTEGER NOT NULL,
b_id INTEGER NOT NULL,
note TEXT,
created_at TEXT NOT NULL,
PRIMARY KEY (a_id, b_id)
);
-- The async identity-resolution inbox. When the live resolver is uncertain and
-- won't interrupt, it files a task here for Brian to clear via the /identity UI.
CREATE TABLE IF NOT EXISTS identity_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL, -- merge_candidate | needs_clarification
player_ids TEXT, -- JSON list of candidate player ids
descriptor TEXT, -- the raw reference that triggered it, if any
context TEXT, -- what was said / why it's ambiguous
session_id INTEGER,
confidence REAL,
status TEXT NOT NULL DEFAULT 'pending', -- pending | resolved | dismissed
resolution TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_idq_status ON identity_queue(status);
"""
# Below this many observed hands, don't surface % stats (too small a sample).
@@ -139,7 +167,13 @@ def _c():
# Add columns introduced after a DB already had the tables (no-op if present).
for ddl in ("ALTER TABLE poker_hands ADD COLUMN structured TEXT",
"ALTER TABLE poker_sessions ADD COLUMN chat_session_id TEXT",
"ALTER TABLE poker_stack_log ADD COLUMN note TEXT"):
"ALTER TABLE poker_stack_log ADD COLUMN note TEXT",
# Nameless-villain identity (see docs/SCOUTING_DESK.md): a player
# keyed by physical descriptors when no name is known.
"ALTER TABLE poker_players ADD COLUMN descriptors TEXT",
"ALTER TABLE poker_players ADD COLUMN descriptor_embedding BLOB",
"ALTER TABLE poker_players ADD COLUMN distinctiveness REAL",
"ALTER TABLE poker_players ADD COLUMN named INTEGER DEFAULT 1"):
try:
conn.execute(ddl)
except Exception:
@@ -1042,6 +1076,322 @@ def update_player(player_id: int, **fields) -> dict | None:
return dict(row) if row else None
# --- villain identity resolution (nameless villains; see docs/SCOUTING_DESK.md) ---
#
# Most live villains have no name — Brian knows them by a physical descriptor
# ("neck tattoo guy"), a seat (within a session), or something too generic to be
# an identifier. A descriptor is a *fuzzy* key: the same person gets phrased a
# dozen ways, so we match by embedding, scoped by venue, and gated on how
# distinctive the description is. Citing the WRONG villain is worse than silence,
# so a generic-only description never resolves to a guess.
# Features distinctive enough to anchor an identity (a near-unique key).
_DISTINCTIVE = (
"tattoo", "tatted", "ink", "sleeve", "scar", "piercing", "mohawk", "dreads",
"dreadlocks", "braids", "ponytail", "cornrows", "durag", "bald", "goatee",
"cane", "wheelchair", "crutch", "jersey", "grill", "gold teeth", "eyepatch",
"birthmark", "mole", "cowboy hat", "fedora", "turban", "hijab", "accent",
"hearing aid", "prosthetic", "limp", "neck", "face", "hand tattoo", "beard",
)
# Words that describe half the room — near-zero discriminating power.
_GENERIC = (
"guy", "dude", "man", "woman", "lady", "gentleman", "kid", "white", "black",
"asian", "hispanic", "latino", "indian", "old", "older", "young", "younger",
"middle", "mid", "aged", "40s", "50s", "30s", "60s", "20s", "glasses",
"average", "normal", "regular", "tall", "short", "heavy", "thin", "skinny",
"fat", "bigger", "plain", "shirt", "hoodie",
)
# Resolver thresholds (cosine sim on descriptor embeddings). Tunable.
_SIM_HIGH = 0.80 # confident it's the same person
_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
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)
def _embed_vec(text: str):
try:
[v] = llm.embed([text])
return np.asarray(v, dtype=np.float32)
except Exception:
return None
def _cos(a, b) -> float:
na, nb = float(np.linalg.norm(a)), float(np.linalg.norm(b))
if na == 0.0 or nb == 0.0:
return 0.0
return float(np.dot(a, b) / (na * nb))
def _descriptor_candidates(vec, venue: str | None, exclude_id: int | None = None):
"""Players with a descriptor embedding, scored by cosine to `vec`, best first.
Same-venue players are preferred (a small bonus) but not required."""
rows = _c().execute(
"SELECT id, name, venue, descriptors, descriptor_embedding FROM poker_players "
"WHERE descriptor_embedding IS NOT NULL"
).fetchall()
out = []
for r in rows:
if exclude_id is not None and r["id"] == exclude_id:
continue
other = memory._from_blob(r["descriptor_embedding"])
sim = _cos(vec, other)
if venue and r["venue"] and venue.lower() == r["venue"].lower():
sim = min(1.0, sim + 0.05) # same-room nudge
out.append({"id": r["id"], "name": r["name"], "venue": r["venue"],
"descriptors": r["descriptors"], "sim": round(sim, 3)})
out.sort(key=lambda c: c["sim"], reverse=True)
return out
def resolve_villain(ref: str, venue: str | None = None,
session_id: int | None = None) -> dict:
"""Resolve a reference to a villain. Returns {band, match_id, confidence, candidates}.
band: 'name' (exact name hit) | 'high' (confident descriptor match) |
'ambiguous' (plausible — don't guess live) | 'generic' (too vague to
match) | 'none' (new villain). The scouting desk / confirm loop act on
the band; they never silently trust an ambiguous or generic match."""
ref = (ref or "").strip()
empty = {"band": "none", "match_id": None, "confidence": 0.0, "candidates": []}
if not ref:
return empty
# 1) Exact name match against *named* players — deterministic, no guessing.
row = _c().execute(
"SELECT id FROM poker_players WHERE named = 1 AND name = ? COLLATE NOCASE", (ref,)
).fetchone()
if row:
return {"band": "name", "match_id": row["id"], "confidence": 1.0, "candidates": []}
# 2) Descriptor match — but refuse if the description is too generic to key on.
dscore = distinctiveness(ref)
if dscore < _DISTINCT_MIN:
return {"band": "generic", "match_id": None, "confidence": 0.0, "candidates": []}
vec = _embed_vec(ref)
if vec is None:
return empty
cands = _descriptor_candidates(vec, venue)[:5]
best = cands[0]["sim"] if cands else 0.0
if best >= _SIM_HIGH:
band = "high"
elif best >= _SIM_AMBIGUOUS:
band = "ambiguous"
else:
band = "none"
return {"band": band, "match_id": cands[0]["id"] if cands and band in ("high", "ambiguous") else None,
"confidence": best, "candidates": cands}
def create_descriptor_villain(descriptor: str, venue: str | None = None,
category: str | None = None) -> int:
"""Open a new nameless villain keyed on a physical descriptor. name holds the
descriptor label (so displays work); named=0 marks it as not-a-real-name."""
vec = _embed_vec(descriptor)
blob = memory._to_blob(vec.tolist()) if vec is not None else None
conn = _c()
with conn:
cur = conn.execute(
"INSERT INTO poker_players (name, venue, category, descriptors, "
"descriptor_embedding, distinctiveness, named, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, 0, ?)",
(descriptor.strip(), venue, category, descriptor.strip(), blob,
distinctiveness(descriptor), _now()),
)
return int(cur.lastrowid)
def add_descriptor(player_id: int, descriptor: str) -> None:
"""Fold another observed descriptor into a villain and re-embed the union, so
matching sharpens as more phrasings accumulate."""
row = _c().execute(
"SELECT descriptors FROM poker_players WHERE id = ?", (player_id,)
).fetchone()
if not row:
return
merged = "; ".join(dict.fromkeys(
p.strip() for p in ((row["descriptors"] or "") + "; " + descriptor).split(";") if p.strip()
))
vec = _embed_vec(merged)
blob = memory._to_blob(vec.tolist()) if vec is not None else None
conn = _c()
with conn:
conn.execute(
"UPDATE poker_players SET descriptors = ?, descriptor_embedding = ?, "
"distinctiveness = ?, updated_at = ? WHERE id = ?",
(merged, blob, distinctiveness(merged), _now(), player_id),
)
def name_villain(player_id: int, name: str) -> None:
"""Attach a real name to a descriptor villain (caught it off Bravo). Flips named=1."""
conn = _c()
with conn:
conn.execute(
"UPDATE poker_players SET name = ?, named = 1, updated_at = ? WHERE id = ?",
(name.strip(), _now(), player_id),
)
def merge_players(keep_id: int, dup_id: int, note: str | None = None) -> bool:
"""Confirmed same person: repoint dup's observations/reads onto keep, fold in its
descriptors, keep a real name over a descriptor label, then delete the dup."""
if keep_id == dup_id:
return False
conn = _c()
keep = conn.execute("SELECT * FROM poker_players WHERE id = ?", (keep_id,)).fetchone()
dup = conn.execute("SELECT * FROM poker_players WHERE id = ?", (dup_id,)).fetchone()
if not keep or not dup:
return False
keep, dup = dict(keep), dict(dup)
with conn:
conn.execute("UPDATE player_observations SET player_id = ? WHERE player_id = ?",
(keep_id, dup_id))
conn.execute("UPDATE player_reads SET player_id = ? WHERE player_id = ?",
(keep_id, dup_id))
# Prefer a real name; union the descriptor text.
new_name = keep["name"] if keep.get("named") else (dup["name"] if dup.get("named") else keep["name"])
named = 1 if (keep.get("named") or dup.get("named")) else 0
descs = "; ".join(dict.fromkeys(
p.strip() for p in ((keep.get("descriptors") or "") + "; " + (dup.get("descriptors") or "")).split(";")
if p.strip()
)) or None
conn.execute("UPDATE poker_players SET name = ?, named = ?, descriptors = ? WHERE id = ?",
(new_name, named, descs, keep_id))
conn.execute("DELETE FROM poker_players WHERE id = ?", (dup_id,))
conn.execute("DELETE FROM identity_queue WHERE player_ids LIKE ? OR player_ids LIKE ?",
(f"%{dup_id}%", f"%{keep_id}%"))
if descs:
add_descriptor(keep_id, "") # re-embed the merged descriptor set
return True
def mark_distinct(a_id: int, b_id: int, note: str | None = None) -> None:
"""Record that two profiles are confirmed DIFFERENT people so the scan never
re-proposes the merge. Stored order-independent (min, max)."""
lo, hi = sorted((int(a_id), int(b_id)))
conn = _c()
with conn:
conn.execute(
"INSERT OR REPLACE INTO player_distinct_pairs (a_id, b_id, note, created_at) "
"VALUES (?, ?, ?, ?)", (lo, hi, note, _now()))
conn.execute("DELETE FROM identity_queue WHERE kind = 'merge_candidate' AND "
"(player_ids = ? OR player_ids = ?)",
(json.dumps([lo, hi]), json.dumps([hi, lo])))
def are_distinct(a_id: int, b_id: int) -> bool:
lo, hi = sorted((int(a_id), int(b_id)))
return _c().execute(
"SELECT 1 FROM player_distinct_pairs WHERE a_id = ? AND b_id = ?", (lo, hi)
).fetchone() is not None
def queue_identity_task(kind: str, player_ids: list[int], descriptor: str | None = None,
context: str | None = None, session_id: int | None = None,
confidence: float | None = None) -> int | None:
"""File an identity task for async review. De-dupes an identical pending task."""
ids_json = json.dumps(sorted(int(i) for i in player_ids)) if player_ids else None
conn = _c()
dup = conn.execute(
"SELECT id FROM identity_queue WHERE status = 'pending' AND kind = ? AND "
"IFNULL(player_ids,'') = IFNULL(?,'') AND IFNULL(descriptor,'') = IFNULL(?,'')",
(kind, ids_json, descriptor),
).fetchone()
if dup:
return int(dup["id"])
with conn:
cur = conn.execute(
"INSERT INTO identity_queue (kind, player_ids, descriptor, context, session_id, "
"confidence, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
(kind, ids_json, descriptor, context, session_id, confidence, _now()),
)
return int(cur.lastrowid)
def list_identity_queue(status: str = "pending") -> list[dict]:
"""Pending identity tasks, each with its candidate players hydrated for the UI."""
rows = _c().execute(
"SELECT * FROM identity_queue WHERE status = ? ORDER BY id DESC", (status,)
).fetchall()
out = []
for r in rows:
d = dict(r)
ids = json.loads(d["player_ids"]) if d.get("player_ids") else []
d["players"] = [p for p in (_player_brief(i) for i in ids) if p]
out.append(d)
return out
def _player_brief(player_id: int) -> dict | None:
r = _c().execute(
"SELECT id, name, venue, category, named, descriptors FROM poker_players WHERE id = ?",
(player_id,),
).fetchone()
if not r:
return None
d = dict(r)
d["obs"] = _c().execute(
"SELECT COUNT(*) n FROM player_observations WHERE player_id = ?", (player_id,)
).fetchone()["n"]
return d
def resolve_identity_task(task_id: int, action: str, **kw) -> bool:
"""Clear a queue task. action: 'merge' (kw keep_id,dup_id) | 'distinct'
(kw a_id,b_id,note) | 'name' (kw player_id,name) | 'dismiss'."""
if action == "merge":
merge_players(kw["keep_id"], kw["dup_id"], kw.get("note"))
elif action == "distinct":
mark_distinct(kw["a_id"], kw["b_id"], kw.get("note"))
elif action == "name":
name_villain(kw["player_id"], kw["name"])
conn = _c()
with conn:
conn.execute("UPDATE identity_queue SET status = 'resolved', resolution = ? WHERE id = ?",
(action, task_id))
return True
def scan_merge_candidates(sim_threshold: float = _SIM_HIGH) -> int:
"""Off-hot-path (dream cycle): find pairs of profiles likely to be one person
and file merge_candidate tasks. Skips pairs already confirmed distinct. Returns
how many new candidates were filed."""
rows = _c().execute(
"SELECT id, venue, descriptor_embedding FROM poker_players "
"WHERE descriptor_embedding IS NOT NULL"
).fetchall()
vecs = [(r["id"], (r["venue"] or "").lower(), memory._from_blob(r["descriptor_embedding"]))
for r in rows]
filed = 0
for i in range(len(vecs)):
for j in range(i + 1, len(vecs)):
aid, aven, av = vecs[i]
bid, bven, bv = vecs[j]
if aven and bven and aven != bven:
continue # different rooms — leave cross-venue merges to a human
if are_distinct(aid, bid):
continue
sim = _cos(av, bv)
if sim >= sim_threshold:
if queue_identity_task("merge_candidate", [aid, bid],
context=f"descriptor similarity {sim:.2f}",
confidence=round(sim, 3)):
filed += 1
return filed
def add_read(note: str, seat: str | None = None, name: str | None = None,
session_id: int | None = None, **player_fields) -> int:
"""Log a live read. If `name` is given, upsert the player and link the read."""
+116
View File
@@ -0,0 +1,116 @@
"""Nameless-villain identity resolution: descriptor matching, merge, distinct, queue."""
from __future__ import annotations
import importlib
import numpy as np
import pytest
def _fake_embed(texts):
"""Overlap-sensitive bag-of-words vectors so cosine reflects shared tokens."""
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 poker(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)
return poker
def test_distinctiveness_distinctive_vs_generic(poker):
assert poker.distinctiveness("guy with a neck tattoo") > 0.6
assert poker.distinctiveness("mid-aged white dude with glasses") < 0.3
def test_generic_descriptor_never_resolves_to_a_guess(poker):
poker.create_descriptor_villain("neck tattoo sleeve arm", venue="Meadows")
r = poker.resolve_villain("mid aged white guy with glasses", venue="Meadows")
assert r["band"] == "generic"
assert r["match_id"] is None
def test_exact_name_match_is_deterministic(poker):
pid = poker.upsert_player("Sleepy John", venue="Meadows")
r = poker.resolve_villain("sleepy john")
assert r["band"] == "name" and r["match_id"] == pid
def test_rephrased_descriptor_resolves_high(poker):
pid = poker.create_descriptor_villain("neck tattoo sleeve arm", venue="Meadows")
r = poker.resolve_villain("neck tattoo sleeve", venue="Meadows")
assert r["band"] == "high" and r["match_id"] == pid
assert r["confidence"] >= 0.80
def test_partial_descriptor_is_ambiguous_not_high(poker):
poker.create_descriptor_villain("neck tattoo sleeve arm", venue="Meadows")
r = poker.resolve_villain("neck tattoo", venue="Meadows")
assert r["band"] == "ambiguous" # plausible, but don't guess live
def test_merge_repoints_observations_and_deletes_dup(poker):
keep = poker.create_descriptor_villain("neck tattoo", venue="Meadows")
dup = poker.create_descriptor_villain("neck ink tatted", venue="Meadows")
poker._c().execute(
"INSERT INTO player_observations (player_id, session_id, created_at) VALUES (?,1,?)",
(dup, poker._now()))
poker._c().commit()
assert poker.merge_players(keep, dup) is True
assert poker.get_villain_file() and all(p["id"] != dup for p in poker.get_villain_file())
obs = poker._c().execute(
"SELECT COUNT(*) n FROM player_observations WHERE player_id = ?", (keep,)).fetchone()["n"]
assert obs == 1
def test_merge_prefers_a_real_name(poker):
named = poker.upsert_player("Danny", venue="Meadows")
desc = poker.create_descriptor_villain("neck tattoo", venue="Meadows")
poker.merge_players(desc, named) # keep the descriptor id, but name should win
row = dict(poker._c().execute("SELECT name, named FROM poker_players WHERE id = ?", (desc,)).fetchone())
assert row["name"] == "Danny" and row["named"] == 1
def test_mark_distinct_blocks_merge_scan(poker):
a = poker.create_descriptor_villain("neck tattoo sleeve", venue="Meadows")
b = poker.create_descriptor_villain("neck tattoo sleeve", venue="Meadows")
poker.mark_distinct(a, b, note="one's taller")
assert poker.are_distinct(a, b)
assert poker.scan_merge_candidates() == 0 # confirmed-distinct pair is skipped
def test_scan_files_merge_candidate_for_near_duplicates(poker):
poker.create_descriptor_villain("neck tattoo sleeve", venue="Meadows")
poker.create_descriptor_villain("neck tattoo sleeve", venue="Meadows")
filed = poker.scan_merge_candidates()
assert filed == 1
q = poker.list_identity_queue()
assert q and q[0]["kind"] == "merge_candidate" and len(q[0]["players"]) == 2
def test_queue_dedupes_identical_pending_task(poker):
a = poker.create_descriptor_villain("neck tattoo", venue="Meadows")
b = poker.create_descriptor_villain("neck ink", venue="Meadows")
t1 = poker.queue_identity_task("merge_candidate", [a, b])
t2 = poker.queue_identity_task("merge_candidate", [b, a]) # same pair, reversed
assert t1 == t2
assert len(poker.list_identity_queue()) == 1
def test_name_villain_flips_named_flag(poker):
pid = poker.create_descriptor_villain("neck tattoo", venue="Meadows")
poker.name_villain(pid, "Danny")
r = poker.resolve_villain("Danny")
assert r["band"] == "name" and r["match_id"] == pid