From 8a6b11c56a421733532c6585dd1f3ca25169448a Mon Sep 17 00:00:00 2001 From: serversdown Date: Fri, 3 Jul 2026 22:46:24 +0000 Subject: [PATCH] feat(poker): nameless-villain identity resolution engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- lyra/poker.py | 352 ++++++++++++++++++++++++++++++++++++++++- tests/test_villains.py | 116 ++++++++++++++ 2 files changed, 467 insertions(+), 1 deletion(-) create mode 100644 tests/test_villains.py diff --git a/lyra/poker.py b/lyra/poker.py index 23b1957..b536cb3 100644 --- a/lyra/poker.py +++ b/lyra/poker.py @@ -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.""" diff --git a/tests/test_villains.py b/tests/test_villains.py new file mode 100644 index 0000000..659373f --- /dev/null +++ b/tests/test_villains.py @@ -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