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.
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/andydixon/noema/internal/embeddings"
|
||||
"github.com/andydixon/noema/pkg/concepts"
|
||||
"github.com/andydixon/noema/pkg/events"
|
||||
"github.com/andydixon/noema/pkg/ids"
|
||||
)
|
||||
|
||||
// Retrieval is one recalled memory with the arithmetic behind its score.
|
||||
type Retrieval struct {
|
||||
Memory Memory `json:"memory"`
|
||||
Score float64 `json:"score"`
|
||||
Components map[string]float64 `json:"components"` // text, vector, recency, activation, importance, credibility
|
||||
}
|
||||
|
||||
// Query describes what to recall.
|
||||
type Query struct {
|
||||
Text string
|
||||
Kinds []concepts.MemoryKind
|
||||
Limit int
|
||||
Now time.Time
|
||||
Semantic bool // use embeddings when available
|
||||
Strict bool // websearch syntax with AND semantics; default matches any term
|
||||
}
|
||||
|
||||
// Weights for fusion. When vectors are unavailable the vector weight is
|
||||
// redistributed to text so totals stay comparable.
|
||||
var weights = map[string]float64{"text": 0.40, "vector": 0.25, "recency": 0.10, "activation": 0.10, "importance": 0.10, "credibility": 0.05}
|
||||
|
||||
// Retrieve performs fused retrieval: full-text rank, optional vector
|
||||
// similarity, recency, activation, importance and credibility, each recorded.
|
||||
func (s *Store) Retrieve(ctx context.Context, mind ids.ID, q Query) ([]Retrieval, error) {
|
||||
if q.Limit <= 0 || q.Limit > 50 {
|
||||
q.Limit = 10
|
||||
}
|
||||
if q.Now.IsZero() {
|
||||
q.Now = time.Now()
|
||||
}
|
||||
text := strings.TrimSpace(q.Text)
|
||||
cands := map[ids.ID]*Retrieval{}
|
||||
add := func(m Memory) *Retrieval {
|
||||
r, ok := cands[m.ID]
|
||||
if !ok {
|
||||
r = &Retrieval{Memory: m, Components: map[string]float64{}}
|
||||
cands[m.ID] = r
|
||||
}
|
||||
return r
|
||||
}
|
||||
kindFilter := "TRUE"
|
||||
args := []any{mind}
|
||||
if len(q.Kinds) > 0 {
|
||||
kindFilter = "m.kind = ANY($2)"
|
||||
args = append(args, strs(q.Kinds))
|
||||
}
|
||||
// Full-text candidates.
|
||||
if text != "" {
|
||||
tsq := "websearch_to_tsquery('english', $%d)"
|
||||
queryText := text
|
||||
if !q.Strict {
|
||||
tsq = "to_tsquery('english', $%d)"
|
||||
queryText = anyTermsQuery(text)
|
||||
}
|
||||
targs := append(append([]any{}, args...), queryText)
|
||||
tsq = strings.ReplaceAll(tsq, "%d", itoa(len(targs)))
|
||||
rows, err := s.db.Query(ctx, `SELECT `+cols+`, ts_rank_cd(m.search, `+tsq+`) AS rank
|
||||
FROM memories m WHERE m.mind_id=$1 AND m.status='active' AND `+kindFilter+` AND m.search @@ `+tsq+`
|
||||
ORDER BY rank DESC LIMIT 50`, targs...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var maxRank float64
|
||||
type ranked struct {
|
||||
m Memory
|
||||
rank float64
|
||||
}
|
||||
var list []ranked
|
||||
for rows.Next() {
|
||||
var m Memory
|
||||
var rank float64
|
||||
var kind, epi, status, trust string
|
||||
var detail []byte
|
||||
var origin, supersedes *string
|
||||
if err := rows.Scan(&m.ID, &m.MindID, &kind, &epi, &status, &m.Content, &detail, &m.Source, &trust, &m.Credibility, &m.Confidence, &m.Importance, &m.Salience, &m.Activation,
|
||||
&m.Valence, &m.Arousal, &m.OccurredAt, &m.CreatedAt, &m.UpdatedAt, &m.RetrievalCount, &m.LastRetrievedAt, &origin, &supersedes, &rank); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
m.Kind, m.Epistemic, m.Status, m.Trust = concepts.MemoryKind(kind), concepts.Epistemic(epi), Status(status), events.Trust(trust)
|
||||
if origin != nil {
|
||||
m.OriginEvent = ids.ID(*origin)
|
||||
}
|
||||
list = append(list, ranked{m, rank})
|
||||
if rank > maxRank {
|
||||
maxRank = rank
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
for _, r := range list {
|
||||
c := add(r.m)
|
||||
if maxRank > 0 {
|
||||
c.Components["text"] = round4(r.rank / maxRank)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Vector candidates.
|
||||
vectorUsed := false
|
||||
if text != "" && q.Semantic && s.SemanticEnabled() {
|
||||
if vecs, err := s.embedder.Embed(ctx, []string{text}); err == nil && len(vecs) == 1 {
|
||||
vargs := append(append([]any{}, args...), s.embedder.Model(), embeddings.Format(vecs[0]))
|
||||
n := len(vargs)
|
||||
rows, err := s.db.Query(ctx, `SELECT `+cols+`, 1 - (e.embedding <=> $`+itoa(n)+`::vector) AS sim
|
||||
FROM memories m JOIN memory_embeddings e ON e.memory_id = m.id AND e.model = $`+itoa(n-1)+`
|
||||
WHERE m.mind_id=$1 AND m.status='active' AND `+kindFilter+` ORDER BY e.embedding <=> $`+itoa(n)+`::vector LIMIT 50`, vargs...)
|
||||
if err == nil {
|
||||
vectorUsed = true
|
||||
for rows.Next() {
|
||||
var m Memory
|
||||
var sim float64
|
||||
var kind, epi, status, trust string
|
||||
var detail []byte
|
||||
var origin, supersedes *string
|
||||
if err := rows.Scan(&m.ID, &m.MindID, &kind, &epi, &status, &m.Content, &detail, &m.Source, &trust, &m.Credibility, &m.Confidence, &m.Importance, &m.Salience, &m.Activation,
|
||||
&m.Valence, &m.Arousal, &m.OccurredAt, &m.CreatedAt, &m.UpdatedAt, &m.RetrievalCount, &m.LastRetrievedAt, &origin, &supersedes, &sim); err != nil {
|
||||
break
|
||||
}
|
||||
m.Kind, m.Epistemic, m.Status, m.Trust = concepts.MemoryKind(kind), concepts.Epistemic(epi), Status(status), events.Trust(trust)
|
||||
if origin != nil {
|
||||
m.OriginEvent = ids.ID(*origin)
|
||||
}
|
||||
c := add(m)
|
||||
c.Components["vector"] = round4(math.Max(0, sim))
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
// No text: recall by importance/activation only (e.g. idle association).
|
||||
if text == "" {
|
||||
list, err := s.List(ctx, mind, Filter{Kinds: q.Kinds, Limit: 50})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, m := range list {
|
||||
add(m)
|
||||
}
|
||||
}
|
||||
// Fuse.
|
||||
w := map[string]float64{}
|
||||
for k, v := range weights {
|
||||
w[k] = v
|
||||
}
|
||||
if !vectorUsed {
|
||||
w["text"] += w["vector"]
|
||||
w["vector"] = 0
|
||||
}
|
||||
if text == "" {
|
||||
w["text"], w["vector"] = 0, 0
|
||||
}
|
||||
var sum float64
|
||||
for _, v := range w {
|
||||
sum += v
|
||||
}
|
||||
out := make([]Retrieval, 0, len(cands))
|
||||
for _, r := range cands {
|
||||
m := r.Memory
|
||||
age := q.Now.Sub(m.OccurredAt).Hours() / 24
|
||||
r.Components["recency"] = round4(math.Exp(-age / 30))
|
||||
r.Components["activation"] = round4(m.Activation)
|
||||
r.Components["importance"] = round4(m.Importance)
|
||||
r.Components["credibility"] = round4(m.Credibility)
|
||||
var total float64
|
||||
for k, wk := range w {
|
||||
total += r.Components[k] * wk
|
||||
}
|
||||
r.Score = round4(total / sum)
|
||||
out = append(out, *r)
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].Score != out[j].Score {
|
||||
return out[i].Score > out[j].Score
|
||||
}
|
||||
return out[i].Memory.ID < out[j].Memory.ID
|
||||
})
|
||||
if len(out) > q.Limit {
|
||||
out = out[:q.Limit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// anyTermsQuery builds an OR tsquery from the alphanumeric words of text so
|
||||
// recall favours breadth; ranking then orders by how many terms matched.
|
||||
func anyTermsQuery(text string) string {
|
||||
var terms []string
|
||||
seen := map[string]bool{}
|
||||
var cur strings.Builder
|
||||
flush := func() {
|
||||
w := cur.String()
|
||||
cur.Reset()
|
||||
if len(w) >= 2 && !seen[w] {
|
||||
seen[w] = true
|
||||
terms = append(terms, w)
|
||||
}
|
||||
}
|
||||
for _, r := range strings.ToLower(text) {
|
||||
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
|
||||
cur.WriteRune(r)
|
||||
} else {
|
||||
flush()
|
||||
}
|
||||
}
|
||||
flush()
|
||||
if len(terms) > 32 {
|
||||
terms = terms[:32]
|
||||
}
|
||||
if len(terms) == 0 {
|
||||
return "zzzznomatch"
|
||||
}
|
||||
return strings.Join(terms, " | ")
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var b [20]byte
|
||||
i := len(b)
|
||||
for n > 0 {
|
||||
i--
|
||||
b[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
return string(b[i:])
|
||||
}
|
||||
|
||||
func round4(v float64) float64 { return math.Round(v*10000) / 10000 }
|
||||
Reference in New Issue
Block a user