c2cee3be4d
Replaces the thought loop's grist (recent-convo + her own saved narrative, the
feedback-loop attractor) with a model of how a thought actually arises:
seed (salience-weighted: a recent moment / resurfaced memory / feed item)
-> spreading activation: embed the seed, let it light up associatively-near
material across ALL her stores (conversations, gists, her own journal/
thoughts), blended by relevance + recency + noise; optional 2nd hop for leaps
-> her self-narrative stays the LENS (supplied as interiority), not the input
-> the thought is generated from what lit up, routed through a faculty
(notice / connect / abstract / project / feel)
-> journaled + embedded, so it can light up in future cycles
This breaks the feedback loop structurally: the narrative is no longer reread and
paraphrased each cycle; grist is genuinely associative and varied; and her past
thoughts re-activate (continuity without calcification).
- lyra/cognition.py (new): spontaneous_seed, activate (spreading activation),
constellation_block, faculties.
- memory.py: journal entries now embedded; recall_journal(); backfill_journal_embeddings()
(ran once: 341 past entries embedded so her history is associatively retrievable).
- thoughts.think(): new-thread mode now uses the associative engine; dropped _grist().
- tests: test_cognition.py (recall_journal ranking, activation, seeding) + fixture
reloads cognition. Suite 72 green, ruff clean.
Honest scope: this fixes the mechanism (how thoughts arise). The residual
"be useful for Brian" voice drift is the separate model/fine-tune problem.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
84 lines
3.4 KiB
Python
84 lines
3.4 KiB
Python
"""Associative cognition: embedding-based recall over her journal + spreading
|
|
activation (what 'lights up' from a seed) + spontaneous seeding."""
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
|
|
import pytest
|
|
|
|
|
|
def _fake_embed(texts):
|
|
"""Content-sensitive embeddings: same words -> same vector, overlap -> closer.
|
|
(The shared test stub returns a constant, which would make all cosines equal.)"""
|
|
out = []
|
|
for t in texts:
|
|
v = [0.0] * 64
|
|
for w in t.lower().split():
|
|
v[hash(w) % 64] += 1.0
|
|
out.append(v if any(v) else [1e-6] * 64)
|
|
return out
|
|
|
|
|
|
@pytest.fixture
|
|
def lyra(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.self_state as self_state
|
|
importlib.reload(self_state)
|
|
import lyra.cognition as cognition
|
|
importlib.reload(cognition)
|
|
return memory, cognition
|
|
|
|
|
|
def test_recall_journal_ranks_by_meaning(lyra):
|
|
memory, _ = lyra
|
|
memory.add_journal_entry("thought", "poker tilt control discipline at the table")
|
|
memory.add_journal_entry("thought", "the quiet stillness between our conversations")
|
|
memory.add_journal_entry("thought", "usb drive hardware windows formatting")
|
|
hits = memory.recall_journal("poker tilt discipline", k=3)
|
|
assert hits and "poker" in hits[0]["content"] # the on-topic entry ranks first
|
|
assert "score" in hits[0] and "embedding" not in hits[0]
|
|
|
|
|
|
def test_recall_journal_skips_unembedded_rows(lyra):
|
|
memory, _ = lyra
|
|
# simulate a pre-embedding-era entry (NULL embedding) — must be skipped, not crash
|
|
conn = memory._connection()
|
|
with conn:
|
|
conn.execute("INSERT INTO journal (created_at, kind, content) VALUES ('2020-01-01','thought','old')")
|
|
memory.add_journal_entry("thought", "fresh embedded poker thought")
|
|
hits = memory.recall_journal("poker", k=5)
|
|
assert all(h["content"] != "old" for h in hits)
|
|
|
|
|
|
def test_activate_lights_up_related_not_unrelated(lyra):
|
|
memory, cognition = lyra
|
|
memory.ensure_session("s1")
|
|
memory.remember("s1", "user", "I keep tilting when I'm card dead at poker")
|
|
memory.add_journal_entry("thought", "tilt is really about ego and discipline")
|
|
memory.add_journal_entry("thought", "spring gardening soil and seedlings")
|
|
items = cognition.activate("poker tilt discipline", k=4, hops=1)
|
|
assert items and all("text" in i and "source" in i for i in items)
|
|
joined = " ".join(i["text"] for i in items)
|
|
assert "tilt" in joined # related material surfaced
|
|
|
|
|
|
def test_spontaneous_seed_fallback_then_real(lyra):
|
|
memory, cognition = lyra
|
|
s = cognition.spontaneous_seed() # empty DB -> wander fallback
|
|
assert s["text"] and s["source"]
|
|
memory.ensure_session("s1")
|
|
memory.remember("s1", "user", "been thinking about impermanence lately")
|
|
s2 = cognition.spontaneous_seed() # now has material to draw on
|
|
assert isinstance(s2["text"], str) and s2["text"] and s2["source"]
|
|
|
|
|
|
def test_constellation_block_handles_empty(lyra):
|
|
_, cognition = lyra
|
|
assert "quiet" in cognition.constellation_block([]).lower()
|
|
block = cognition.constellation_block([{"source": "conversation", "text": "hi there"}])
|
|
assert "hi there" in block
|