feat: dream-cycle merge scan + pattern desk (leak recall)
Phases 5 & 6, completing the scouting desk. Phase 5 — the nightly consolidation (dream cycle, coherence block) now runs poker.scan_merge_candidates(), filing likely same-person merges to the review queue off the hot path. Fail-safe: a scan error never sinks the cycle. Phase 6 — "you've hit this leak before". Scar/confidence notes are embedded on write; recall_similar_rituals() finds past ones close to the current spot, excluding tonight. The scouting desk adds a pattern pass that surfaces them — but ONLY on genuine strategy/tilt talk (a length + cue gate), so routine logging like "stack 350" never pays for an embed. Respects the per-turn latency concern. 7 tests (deterministic embed stub to keep threshold assertions stable). Full suite 153 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+11
-1
@@ -25,7 +25,9 @@ import argparse
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from lyra import config, era, feeds, logbus, memory, narrative, profile, self_state, summary, thoughts
|
||||
from lyra import (
|
||||
config, era, feeds, logbus, memory, narrative, poker, profile, self_state, summary, thoughts,
|
||||
)
|
||||
from lyra.llm import Backend
|
||||
from lyra.summary import SUMMARIZE_AFTER
|
||||
|
||||
@@ -112,6 +114,14 @@ def dream_cycle(backend: Backend | None = None, force: bool = False) -> dict:
|
||||
narrative.rebuild_narrative(backend=backend)
|
||||
actions.append("integrated knowledge (profile/eras/narrative)")
|
||||
drives["coherence"] = 0.0
|
||||
# Off-hot-path villain identity housekeeping: propose likely same-person
|
||||
# merges for Brian to confirm on the Players page. Never sinks the cycle.
|
||||
try:
|
||||
filed = poker.scan_merge_candidates()
|
||||
if filed:
|
||||
actions.append(f"flagged {filed} possible villain merge(s)")
|
||||
except Exception as exc:
|
||||
logbus.log("error", "villain merge scan failed", error=str(exc)[:200])
|
||||
|
||||
# --- curiosity: reflect and evolve the self, then advance the thought loop ---
|
||||
if force or drives["curiosity"] >= THRESHOLD:
|
||||
|
||||
+40
-4
@@ -173,7 +173,9 @@ def _c():
|
||||
"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"):
|
||||
"ALTER TABLE poker_players ADD COLUMN named INTEGER DEFAULT 1",
|
||||
# Embedded scar/confidence notes → "you've hit this leak before".
|
||||
"ALTER TABLE poker_rituals ADD COLUMN embedding BLOB"):
|
||||
try:
|
||||
conn.execute(ddl)
|
||||
except Exception:
|
||||
@@ -502,16 +504,50 @@ def log_ritual(kind: str, content: str | None = None, classification: str | None
|
||||
sid = _resolve(session_id)
|
||||
if sid is None:
|
||||
raise ValueError("no live session")
|
||||
# Embed scar/confidence text so a similar spot later can recall it (Gap 3:
|
||||
# "you've hit this leak before"). Other ritual kinds don't need it.
|
||||
blob = None
|
||||
if content and kind in ("scar", "confidence"):
|
||||
vec = _embed_vec(content)
|
||||
blob = memory._to_blob(vec.tolist()) if vec is not None else None
|
||||
conn = _c()
|
||||
with conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO poker_rituals (session_id, kind, content, classification, hand_id, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(sid, kind, content, classification, hand_id, _now()),
|
||||
"INSERT INTO poker_rituals (session_id, kind, content, classification, hand_id, "
|
||||
"embedding, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(sid, kind, content, classification, hand_id, blob, _now()),
|
||||
)
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def recall_similar_rituals(text: str, kinds: tuple[str, ...] = ("scar", "confidence"),
|
||||
k: int = 2, min_sim: float = 0.60,
|
||||
exclude_session: int | None = None) -> list[dict]:
|
||||
"""Past scar/confidence notes most similar to `text` — the leak/discipline
|
||||
pattern recall. Excludes the current session so tonight doesn't echo itself."""
|
||||
vec = _embed_vec(text)
|
||||
if vec is None:
|
||||
return []
|
||||
ph = ",".join("?" * len(kinds))
|
||||
rows = _c().execute(
|
||||
f"SELECT r.id, r.kind, r.content, r.classification, r.session_id, r.embedding, "
|
||||
f"s.started_at AS s_at, s.venue AS venue FROM poker_rituals r "
|
||||
f"LEFT JOIN poker_sessions s ON s.id = r.session_id "
|
||||
f"WHERE r.embedding IS NOT NULL AND r.kind IN ({ph})", tuple(kinds)
|
||||
).fetchall()
|
||||
scored = []
|
||||
for row in rows:
|
||||
if exclude_session and row["session_id"] == exclude_session:
|
||||
continue
|
||||
sim = _cos(vec, memory._from_blob(row["embedding"]))
|
||||
if sim >= min_sim:
|
||||
scored.append((sim, row))
|
||||
scored.sort(key=lambda x: x[0], reverse=True)
|
||||
return [{"kind": r["kind"], "content": r["content"], "classification": r["classification"],
|
||||
"when": r["s_at"], "venue": r["venue"], "sim": round(s, 3)}
|
||||
for s, r in scored[:k]]
|
||||
|
||||
|
||||
def list_rituals(session_id: int | None = None,
|
||||
kinds: tuple[str, ...] | None = None) -> list[dict]:
|
||||
"""Ritual events for a session, oldest first; optionally filtered by kind."""
|
||||
|
||||
+33
-5
@@ -23,6 +23,20 @@ _DESC_PATTERNS = (
|
||||
)
|
||||
_MIN_NAME = 3
|
||||
|
||||
# Cues that a message is a strategy/spot/tilt discussion — the only turns worth
|
||||
# paying an embed to recall past leaks. Keeps the pattern pass off routine logging.
|
||||
_STRAT_CUES = (
|
||||
"fold", "call", "raise", "bluff", "river", "turn", "flop", "tilt", "punt",
|
||||
"leak", "should i", "hero", "value", "overbet", "spew", "stack off", "3bet",
|
||||
"4bet", "check-raise", "checkraise", "range", "board", "steaming", "felted",
|
||||
"all in", "all-in", "shoved", "jammed", "snap", "sizing",
|
||||
)
|
||||
|
||||
|
||||
def _looks_strategic(msg: str) -> bool:
|
||||
low = msg.lower()
|
||||
return len(msg) >= 40 and any(c in low for c in _STRAT_CUES)
|
||||
|
||||
|
||||
def _named_hits(msg: str) -> list[int]:
|
||||
"""Ids of known *named* villains whose name appears as a word in the message."""
|
||||
@@ -113,12 +127,26 @@ def scout(user_msg: str, venue: str | None = None, session_id: int | None = None
|
||||
context=f'Brian referred to "{span}"', session_id=session_id,
|
||||
confidence=res["confidence"])
|
||||
|
||||
if not lines:
|
||||
# Pattern desk: on genuine strategy talk, recall his own past leaks/wins in
|
||||
# similar spots. Gated so routine logging never pays for an embed.
|
||||
pattern: list[str] = []
|
||||
if _looks_strategic(msg):
|
||||
for r in poker.recall_similar_rituals(msg, exclude_session=session_id):
|
||||
tag = "leak" if r["kind"] == "scar" else "banked"
|
||||
cls = f"/{r['classification']}" if r.get("classification") else ""
|
||||
when = f" ({str(r['when'])[:10]})" if r.get("when") else ""
|
||||
pattern.append(f"[{tag}{cls}]{when} {r['content']}")
|
||||
|
||||
if not lines and not pattern:
|
||||
return None
|
||||
logbus.log("info", "scouting desk", hits=len(lines))
|
||||
return ("SCOUTING DESK — structured recall for players in his message (cite it, "
|
||||
"don't invent; if unsure it's the same person, ask him):\n• "
|
||||
+ "\n• ".join(lines))
|
||||
logbus.log("info", "scouting desk", hits=len(lines), patterns=len(pattern))
|
||||
out = "SCOUTING DESK — structured recall for his message (cite it, don't invent; " \
|
||||
"if unsure it's the same person, ask him):"
|
||||
if lines:
|
||||
out += "\n• " + "\n• ".join(lines)
|
||||
if pattern:
|
||||
out += "\nYou've been in a spot like this before —\n• " + "\n• ".join(pattern)
|
||||
return out
|
||||
except Exception as exc: # desk must never break a turn
|
||||
logbus.log("error", "scouting desk failed", error=str(exc)[:160])
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Pattern desk: embedded scar recall + strategy gating in the scouting desk."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import importlib
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
|
||||
def _idx(w: str) -> int:
|
||||
# Stable across processes (unlike hash()), so threshold tests aren't flaky.
|
||||
return int.from_bytes(hashlib.md5(w.encode()).digest()[:4], "little") % 256
|
||||
|
||||
|
||||
def _fake_embed(texts):
|
||||
out = []
|
||||
for t in texts:
|
||||
v = np.zeros(256, dtype=np.float32)
|
||||
for w in t.lower().split():
|
||||
v[_idx(w)] += 1.0
|
||||
out.append((v if v.any() else np.full(256, 1e-6, dtype=np.float32)).tolist())
|
||||
return out
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mods(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)
|
||||
import lyra.scouting as scouting
|
||||
importlib.reload(scouting)
|
||||
return poker, scouting
|
||||
|
||||
|
||||
def test_scar_recall_finds_similar_past_leak(mods):
|
||||
poker, _ = mods
|
||||
old = poker.start_session(stakes="1/3", buy_in=300)
|
||||
poker.log_ritual("scar", "overvalued top pair and stacked off on a wet board",
|
||||
classification="punt", session_id=old)
|
||||
poker.end_session(200, session_id=old)
|
||||
hits = poker.recall_similar_rituals("stacked off top pair wet board again")
|
||||
assert hits and hits[0]["classification"] == "punt"
|
||||
|
||||
|
||||
def test_recall_excludes_current_session(mods):
|
||||
poker, _ = mods
|
||||
sid = poker.start_session(stakes="1/3", buy_in=300)
|
||||
poker.log_ritual("scar", "punted river bluff into the nut flush", session_id=sid)
|
||||
assert poker.recall_similar_rituals("river bluff nut flush punt", exclude_session=sid) == []
|
||||
|
||||
|
||||
def test_pattern_pass_only_fires_on_strategic_talk(mods):
|
||||
poker, scouting = mods
|
||||
old = poker.start_session(stakes="1/3", buy_in=300)
|
||||
poker.log_ritual("scar", "punting river bluffs into missed draws again",
|
||||
classification="punt", session_id=old)
|
||||
poker.end_session(200, session_id=old)
|
||||
poker.start_session(stakes="1/3", buy_in=300, venue="Meadows")
|
||||
# A routine, non-strategic line pays no embed and surfaces nothing.
|
||||
assert scouting.scout("stack is 350 now", venue="Meadows") is None
|
||||
# A real strategy question in the same shape recalls the leak. (The test stub
|
||||
# embeds by shared tokens; real embeddings match on meaning/paraphrase.)
|
||||
note = scouting.scout(
|
||||
"why do i keep punting river bluffs into missed draws", venue="Meadows")
|
||||
assert note and "punting river bluffs" in note
|
||||
Reference in New Issue
Block a user