feat: associative cognition — thoughts arise from spreading activation, not a re-read bio
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>
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
"""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
|
||||
@@ -23,6 +23,8 @@ def lyra(tmp_path, monkeypatch):
|
||||
importlib.reload(self_state)
|
||||
import lyra.feeds as feeds
|
||||
importlib.reload(feeds)
|
||||
import lyra.cognition as cognition
|
||||
importlib.reload(cognition)
|
||||
import lyra.thoughts as thoughts
|
||||
importlib.reload(thoughts)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user