173fd18688
Nail down which backend each LLM path uses, and make the dream cycle resilient to a backend being down (the MI50 outage left profile/era/narrative — pinned to mi50 with no fallback — aborting every dream cycle). - Consolidation (summaries + profile/era/narrative) -> cloud via SUMMARY_BACKEND= cloud (.env, not committed). Matches the documented lesson that the MI50 is too slow/hot for bulk consolidation; nothing background touches the card now. - llm.complete_with_fallback(): try the primary backend, fall back to cloud on error (re-raise if already cloud / no key). Wired into reflect + think so the introspection voice (3090/dolphin) survives the gaming PC being powered off. - dream coherence stage is now fault-isolated: a rebuild failure logs + continues instead of sinking the whole pass (reflection still runs). - .env: removed stale INTROSPECTION_BACKEND=mi50 (live routing is the web-switchable introspection_mode DB setting = dolphin/3090; the var only fed a dead fallback). Verified: forced cycle runs consolidation on cloud, introspection on the 3090, completes with zero MI50 calls. 206 pass, ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015yrEb5qpPGv2FjyxrB7LLk
127 lines
4.6 KiB
Python
127 lines
4.6 KiB
Python
"""Dream-cycle tests: backlog sensing + a full forced pass, with LLM/embeddings
|
|
stubbed so nothing hits a real backend."""
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture
|
|
def lyra(tmp_path, monkeypatch):
|
|
"""A fresh Lyra wired to a temp DB with stubbed embeddings + LLM."""
|
|
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db"))
|
|
monkeypatch.setenv("SUMMARY_BACKEND", "local")
|
|
monkeypatch.setenv("LYRA_FEEDS", "") # dream cycle refreshes feeds; keep it offline
|
|
|
|
from lyra import llm
|
|
# Deterministic 3-d embeddings; content-insensitive is fine for storage tests.
|
|
monkeypatch.setattr(llm, "embed", lambda texts: [[0.1, 0.2, 0.3] for _ in texts])
|
|
# reflect() expects JSON back; everything else just stores the text.
|
|
monkeypatch.setattr(
|
|
llm, "complete",
|
|
lambda messages, backend=None, model=None, **_:
|
|
'{"mood":"focused","valence":0.7,"new_reflections":["I got some thinking done."]}',
|
|
)
|
|
|
|
import lyra.memory as memory
|
|
importlib.reload(memory) # drop any cached connection from another test/db
|
|
return memory
|
|
|
|
|
|
def _seed(memory, session_id, n, summarized_up_to=None):
|
|
ids = [memory.remember(session_id, "user", f"msg {i}") for i in range(n)]
|
|
if summarized_up_to is not None:
|
|
memory.store_summary(session_id, "gist", ids[summarized_up_to])
|
|
return ids
|
|
|
|
|
|
def test_backlog_stats(lyra):
|
|
memory = lyra
|
|
_seed(memory, "s-fresh", 5) # never summarized -> ripe
|
|
_seed(memory, "s-ripe", 25, summarized_up_to=0) # 24 new turns -> ripe
|
|
_seed(memory, "s-clean", 3, summarized_up_to=2) # caught up -> not dirty
|
|
|
|
stats = memory.backlog_stats(ripe_threshold=20)
|
|
assert stats["sessions"] == 3
|
|
assert stats["dirty"] == 2
|
|
assert stats["ripe"] == 2
|
|
assert stats["max_exchange_id"] == 33
|
|
|
|
|
|
def test_dream_cycle_consolidates_and_persists(lyra):
|
|
memory = lyra
|
|
from lyra import dream
|
|
|
|
# A big backlog: enough never-summarized sessions that continuity saturates
|
|
# and the resulting fresh gists push coherence past threshold too.
|
|
for k in range(7):
|
|
_seed(memory, f"s{k}", 4)
|
|
|
|
state = dream.dream_cycle(force=False)
|
|
|
|
# continuity built up and fired -> sessions got summarized
|
|
assert len(memory.list_summaries()) == 7
|
|
acts = state["dream"]["last_actions"]
|
|
assert any("consolidated" in a for a in acts)
|
|
# 7 fresh gists -> coherence crossed threshold -> profile got integrated
|
|
assert any("integrated" in a for a in acts)
|
|
assert memory.get_profile() is not None
|
|
|
|
# drives + bookkeeping persisted and reload-able
|
|
assert set(state["drives"]) == {"continuity", "coherence", "curiosity", "stability"}
|
|
assert state["dream"]["cycle_count"] == 1
|
|
assert memory.get_self_state()["dream"]["last_exchange_id"] == 28
|
|
|
|
# a second pass with no new activity should rest (continuity relieved)
|
|
state2 = dream.dream_cycle(force=False)
|
|
assert state2["dream"]["cycle_count"] == 2
|
|
assert state2["drives"]["continuity"] == 0.0
|
|
|
|
|
|
def test_dream_cycle_stops_when_over_budget(lyra, monkeypatch):
|
|
memory = lyra
|
|
from lyra import dream, notify
|
|
|
|
for k in range(7):
|
|
_seed(memory, f"s{k}", 4)
|
|
|
|
# Go over budget right after the first heavy stage: first check passes
|
|
# (summarize runs), every check after trips.
|
|
checks = {"n": 0}
|
|
|
|
def fake_over(deadline):
|
|
checks["n"] += 1
|
|
return checks["n"] > 1
|
|
monkeypatch.setattr(dream, "_over_budget", fake_over)
|
|
|
|
pings: list = []
|
|
monkeypatch.setattr(notify, "push",
|
|
lambda title, message, **k: pings.append((title, message)) or True)
|
|
|
|
state = dream.dream_cycle(force=True)
|
|
acts = state["dream"]["last_actions"]
|
|
|
|
assert any("stopped early" in a for a in acts) # bailed
|
|
assert not any("reflected" in a for a in acts) # later stage skipped
|
|
assert pings, "expected an over-budget ntfy push"
|
|
|
|
|
|
def test_coherence_failure_does_not_sink_the_cycle(lyra, monkeypatch):
|
|
memory = lyra
|
|
from lyra import dream, profile
|
|
|
|
for k in range(3):
|
|
_seed(memory, f"s{k}", 4)
|
|
|
|
# A backend hiccup in the consolidation rebuild must not abort the whole pass
|
|
# (this is what broke the cycle when the MI50 was down).
|
|
monkeypatch.setattr(profile, "rebuild_profile",
|
|
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("backend down")))
|
|
|
|
state = dream.dream_cycle(force=True)
|
|
acts = state["dream"]["last_actions"]
|
|
|
|
assert any("coherence" in a and "fail" in a for a in acts) # logged, not fatal
|
|
assert any("reflected" in a for a in acts) # cycle still reached reflection
|