Files
project-lyra/tests/test_villains.py
T
serversdown 8a6b11c56a 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>
2026-07-03 22:46:24 +00:00

117 lines
4.6 KiB
Python

"""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