Update main. #6

Merged
serversdown merged 70 commits from dev into main 2026-07-10 15:17:09 -04:00
2 changed files with 467 additions and 1 deletions
Showing only changes of commit 8a6b11c56a - Show all commits
+351 -1
View File
@@ -16,6 +16,8 @@ import json
import re import re
from datetime import datetime, timezone from datetime import datetime, timezone
import numpy as np
from lyra import clock, llm, memory from lyra import clock, llm, memory
_SCHEMA = """ _SCHEMA = """
@@ -122,6 +124,32 @@ CREATE TABLE IF NOT EXISTS poker_rituals (
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
CREATE INDEX IF NOT EXISTS idx_rituals_session ON poker_rituals(session_id); 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). # 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). # 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", for ddl in ("ALTER TABLE poker_hands ADD COLUMN structured TEXT",
"ALTER TABLE poker_sessions ADD COLUMN chat_session_id 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: try:
conn.execute(ddl) conn.execute(ddl)
except Exception: except Exception:
@@ -1042,6 +1076,322 @@ def update_player(player_id: int, **fields) -> dict | None:
return dict(row) if row else 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, def add_read(note: str, seat: str | None = None, name: str | None = None,
session_id: int | None = None, **player_fields) -> int: session_id: int | None = None, **player_fields) -> int:
"""Log a live read. If `name` is given, upsert the player and link the read.""" """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