Files
Andy Dixon 03631e5685 feat: add long-term memory systems with fused retrieval and memory explorer
Phase 5 (ADR-0003). pkg/concepts fixes the epistemic vocabulary
(observation, fact, memory, claim, hypothesis, belief, prediction,
assumption, inference, opinion), memory kinds and relation names.

internal/memory stores episodic, semantic, procedural and
autobiographical memories in one mind-scoped table with trust-derived
credibility, importance, salience and decaying activation. Retrieval
fuses full-text rank (any-term by default), optional pgvector similarity,
recency, activation, importance and credibility, and returns the
component scores so every recall can be explained. Decay lowers
activation and never deletes; retraction and supersession keep rows for
provenance. Relationships are constrained to one mind at insert time.
Embeddings live in a conditional pgvector table keyed by model so vector
spaces are never mixed; without an embedder the system degrades to text
and graph retrieval.

Attention gates encoding: the attend handler turns admitted observations
into episodic memories (operator changes become autobiographical ones)
with memory_created events; the retrieve process proposes recalled
memories as candidates and admitted recalls are reinforced with
memory_retrieved events carrying their scoring components.

Memory explorer UI (search with component breakdown, timeline, filters,
operator knowledge entry), memory detail with provenance, relationships,
citing events and an inaccurate-memory correction; API; noemactl memory
commands; decay and embed-missing background jobs.
2026-06-20 23:47:50 +01:00

193 lines
6.7 KiB
Go

package brain
import (
"context"
"errors"
"log/slog"
"os"
"sync/atomic"
"testing"
"time"
"github.com/andydixon/noema/internal/attention"
evstore "github.com/andydixon/noema/internal/events"
"github.com/andydixon/noema/internal/minds"
"github.com/andydixon/noema/internal/scheduler"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/internal/workspace"
"github.com/andydixon/noema/migrations"
"github.com/andydixon/noema/pkg/events"
"github.com/andydixon/noema/pkg/ids"
)
// echo proposes one candidate per new external event; repeat proposes the
// same subject every cycle to exercise the loop detector; boom panics.
type echo struct{ calls atomic.Int32 }
func (e *echo) Name() string { return "echo" }
func (e *echo) Observe(_ context.Context, s *State) ([]Candidate, error) {
e.calls.Add(1)
var out []Candidate
for _, ev := range s.NewEvents {
if ev.Type == events.Observation {
out = append(out, Candidate{Kind: "attend", Subject: string(ev.ID), Summary: "noticed: " + ev.Summary, Features: attention.Features{Salience: ev.Salience, Urgency: 0.5}, Confidence: 1, SourceEvents: []ids.ID{ev.ID}})
}
}
return out, nil
}
type repeat struct{}
func (repeat) Name() string { return "repeat" }
func (repeat) Observe(_ context.Context, s *State) ([]Candidate, error) {
return []Candidate{{Kind: "note", Subject: "same-thing", Summary: "the same thought again", Features: attention.Features{Salience: 0.9}, Confidence: 0.5}}, nil
}
type boom struct{}
func (boom) Name() string { return "boom" }
func (boom) Observe(context.Context, *State) ([]Candidate, error) { panic("malformed input") }
type failing struct{}
func (failing) Name() string { return "failing" }
func (failing) Observe(context.Context, *State) ([]Candidate, error) {
return nil, errors.New("upstream down")
}
func setup(t *testing.T, reg *Registry) (Config, minds.Mind, *evstore.Store) {
t.Helper()
pool := storage.TestPool(t, migrations.FS)
ctx := context.Background()
ms := minds.NewStore(pool)
m, err := ms.Create(ctx, minds.CreateParams{Slug: "brain"})
if err != nil {
t.Fatal(err)
}
es := evstore.NewStore(pool, evstore.NewBus(16), nil)
cfg := Config{DB: pool, Events: es, Minds: ms, Registry: reg, Pool: scheduler.NewPool(2), Log: slog.New(slog.NewTextHandler(os.Stderr, nil)), Services: Services{Events: es}}
return cfg, m, es
}
func TestCycleRecordsProvenanceAndPersists(t *testing.T) {
e := &echo{}
cfg, m, es := setup(t, NewRegistry().AddProcess(e).AddProcess(boom{}).AddProcess(failing{}))
ctx := context.Background()
rt, err := NewRuntime(ctx, cfg, m)
if err != nil {
t.Fatal(err)
}
rt.now = func() time.Time { return time.Now().Add(time.Second) } // events are "settled"
obs, _ := es.Append(ctx, events.New{MindID: m.ID, Type: events.Observation, Source: "test", Summary: "load spike on atlas", Salience: 0.9, Confidence: 1})
rt.runCycle(ctx, "operator")
if e.calls.Load() != 1 {
t.Fatalf("echo called %d times", e.calls.Load())
}
st := rt.copyState()
if st.Cycle != 1 || len(st.Workspace) != 1 || st.Workspace[0].Subject != string(obs[0].ID) || len(st.WorkingMemory) != 1 || st.Workspace[0].EventID.IsZero() {
t.Fatalf("workspace wrong: %+v", st)
}
all, _ := es.Query(ctx, evstore.Filter{MindID: m.ID, Ascending: true})
types := map[events.Type]int{}
var selected events.Event
for _, ev := range all {
types[ev.Type]++
if ev.Type == events.ThoughtSelected {
selected = ev
}
}
for _, want := range []events.Type{events.ThoughtCandidate, events.ThoughtSelected, events.WorkspaceBroadcast, events.AttentionShift, events.CycleCompleted} {
if types[want] == 0 {
t.Fatalf("missing %s in %v", want, types)
}
}
// Provenance: selected thought → candidate event → observation.
if selected.CausationID.IsZero() {
t.Fatal("thought_selected has no causation")
}
cand, _ := es.Get(ctx, selected.CausationID)
if cand.Type != events.ThoughtCandidate || cand.CausationID != obs[0].ID {
t.Fatalf("provenance chain broken: %+v", cand)
}
if cur, _ := es.Cursor(ctx, cfg.DB, m.ID, cursorConsumer); cur != obs[0].Seq {
t.Fatalf("cursor %d, want %d", cur, obs[0].Seq)
}
// Restart continuity: a fresh runtime loads the same state.
rt2, err := NewRuntime(ctx, cfg, m)
if err != nil || rt2.state.Cycle != 1 || len(rt2.state.Workspace) != 1 {
t.Fatalf("state not persisted: %v %+v", err, rt2.state)
}
// A second cycle with nothing new triggered by stimulus does nothing.
rt2.now = rt.now
rt2.runCycle(ctx, "stimulus")
if rt2.state.Cycle != 1 {
t.Fatal("cycle ran without input")
}
}
func TestLoopDetectorSuppressesRepeats(t *testing.T) {
cfg, m, es := setup(t, NewRegistry().AddProcess(repeat{}))
ctx := context.Background()
m.Config.LoopDetectThreshold, m.Config.LoopDetectWindow = 3, 10
rt, err := NewRuntime(ctx, cfg, m)
if err != nil {
t.Fatal(err)
}
rt.now = func() time.Time { return time.Now().Add(time.Second) }
for i := 0; i < 6; i++ {
es.Append(ctx, events.New{MindID: m.ID, Type: events.Observation, Source: "test", Summary: "tick", Salience: 0.1, Confidence: 1}) //nolint:errcheck
rt.runCycle(ctx, "operator")
}
meta, _ := es.Query(ctx, evstore.Filter{MindID: m.ID, Types: []events.Type{events.Metacognition}})
if len(meta) != 1 {
t.Fatalf("expected exactly one loop warning, got %d", len(meta))
}
cands, _ := es.Query(ctx, evstore.Filter{MindID: m.ID, Types: []events.Type{events.ThoughtCandidate}})
var suppressed int
for _, c := range cands {
if c.Metadata["fate"] == "suppressed" {
suppressed++
}
}
if suppressed != 3 {
t.Fatalf("expected 3 suppressed candidates, got %d", suppressed)
}
}
func TestPauseResetAndCancellation(t *testing.T) {
cfg, m, es := setup(t, NewRegistry().AddProcess(&echo{}))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
rt, _ := NewRuntime(ctx, cfg, m)
done := make(chan struct{})
go func() { rt.Run(ctx); close(done) }() //nolint:errcheck
if err := rt.Command(ctx, "pause", nil); err != nil {
t.Fatal(err)
}
if err := rt.Command(ctx, "cycle", nil); err == nil {
t.Fatal("cycle should be refused while paused")
}
if err := rt.Command(ctx, "resume", nil); err != nil {
t.Fatal(err)
}
if err := rt.Command(ctx, "reset", map[string]bool{"workspace": true, "working_memory": true}); err != nil {
t.Fatal(err)
}
modes, _ := es.Query(ctx, evstore.Filter{MindID: m.ID, Types: []events.Type{events.ModeChanged}})
if len(modes) != 2 {
t.Fatalf("expected 2 mode changes, got %d", len(modes))
}
st, err := rt.Snapshot(ctx)
if err != nil || st.Mode != string(ModeAwake) {
t.Fatalf("snapshot: %v %+v", err, st)
}
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("runtime did not stop on cancellation")
}
_ = workspace.State{}
}