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:
@@ -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
|
||||
Reference in New Issue
Block a user