From d5c80f61532069045297f98d9b3fc630dcbcd911 Mon Sep 17 00:00:00 2001 From: serversdown Date: Fri, 3 Jul 2026 23:01:53 +0000 Subject: [PATCH] feat: dream-cycle merge scan + pattern desk (leak recall) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- lyra/dream.py | 12 ++++++- lyra/poker.py | 44 ++++++++++++++++++++--- lyra/scouting.py | 38 +++++++++++++++++--- tests/test_pattern_recall.py | 70 ++++++++++++++++++++++++++++++++++++ 4 files changed, 154 insertions(+), 10 deletions(-) create mode 100644 tests/test_pattern_recall.py diff --git a/lyra/dream.py b/lyra/dream.py index bc578b4..ce6ca21 100644 --- a/lyra/dream.py +++ b/lyra/dream.py @@ -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: diff --git a/lyra/poker.py b/lyra/poker.py index 29d578a..dfe02b3 100644 --- a/lyra/poker.py +++ b/lyra/poker.py @@ -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.""" diff --git a/lyra/scouting.py b/lyra/scouting.py index 9eb06bd..c223ec5 100644 --- a/lyra/scouting.py +++ b/lyra/scouting.py @@ -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 diff --git a/tests/test_pattern_recall.py b/tests/test_pattern_recall.py new file mode 100644 index 0000000..7a100cf --- /dev/null +++ b/tests/test_pattern_recall.py @@ -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