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:
2026-06-22 05:45:39 +00:00
parent 43697f8340
commit c2cee3be4d
7 changed files with 571 additions and 25 deletions
+83
View File
@@ -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