Skip to content

Commit 8de066d

Browse files
committed
feat: Phase 6 — world-class retrieval (MAGMA + GAM inspired)
Based on two April 2026 research papers: - MAGMA (arxiv:2601.03236): 0.700 on LoCoMo benchmark - GAM (arxiv:2604.12285): 40.0 F1 on LoCoMo benchmark Changes: - Intent classifier: Why/When/Who/How/What detection (no LLM needed) - Intent-aware graph traversal: edge weights boosted by query intent - Why queries → boost caused_by, led_to edges (5x) - When queries → boost learned_in, temporal edges (4x) - Who queries → boost touches, entity edges (5x) - How queries → boost part_of, depends_on edges (4x) - Dual-stream ingestion: fast path (sync) + slow path (async goroutine) - Fast: store node + temporal backbone edge, return immediately - Slow: infer causal edges, link entities, consolidate - Semantic boundary detection: cosine distance-based topic shift detection - Consolidate only at semantic boundaries (not arbitrary session ends) - Prevents transient noise contaminating long-term memory - New CLI: yaad intent [query] - New MCP tool: yaad_intent - 17 tests passing (3 new Phase 6 tests)
1 parent 231ceda commit 8de066d

10 files changed

Lines changed: 770 additions & 8 deletions

File tree

PLAN.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -994,6 +994,63 @@ yaad/
994994
- [ ] Session replay
995995
- [ ] WebSocket/SSE streaming for real-time memory updates
996996

997+
### Phase 6: World-Class Retrieval (MAGMA + GAM inspired)
998+
999+
Based on two April 2026 papers:
1000+
- **MAGMA** (arxiv:2601.03236) — Multi-Graph Agentic Memory Architecture, 0.700 on LoCoMo
1001+
- **GAM** (arxiv:2604.12285) — Hierarchical Graph-based Agentic Memory, 40.0 F1 on LoCoMo
1002+
1003+
#### Key Innovations
1004+
1005+
**Intent-Aware Retrieval (MAGMA)**
1006+
```
1007+
Query: "Why did we choose NATS?"
1008+
→ Intent: Why → boost causal edges (caused_by, led_to)
1009+
→ Traverse: decision → convention → bug (causal chain)
1010+
1011+
Query: "When did we fix the auth bug?"
1012+
→ Intent: When → boost temporal edges (learned_in, session order)
1013+
→ Traverse: temporal backbone
1014+
1015+
Query: "What is the auth subsystem?"
1016+
→ Intent: What → boost entity/spec edges
1017+
→ Traverse: entity → spec → convention
1018+
```
1019+
1020+
**Dual-Stream Ingestion (MAGMA + GAM)**
1021+
```
1022+
Remember("Use jose for JWT")
1023+
1024+
├── FAST PATH (sync, <1ms)
1025+
│ ├── Privacy filter
1026+
│ ├── Create node
1027+
│ ├── Add temporal edge (temporal backbone)
1028+
│ └── Return immediately ← agent not blocked
1029+
1030+
└── SLOW PATH (async goroutine)
1031+
├── Extract entities (LLM or regex)
1032+
├── Infer causal edges (LLM: "what caused this?")
1033+
├── Link entity graph
1034+
└── Update semantic edges
1035+
```
1036+
1037+
**Semantic Boundary Detection (GAM)**
1038+
```
1039+
Session buffer fills with events
1040+
→ LLM (or heuristic) detects topic shift
1041+
→ Consolidate buffer → topic node
1042+
→ Reset buffer
1043+
→ Long-term memory only updated at semantic boundaries
1044+
→ Prevents transient noise contaminating long-term memory
1045+
```
1046+
1047+
- [ ] Intent classifier (Why/When/Who/How/What) — regex + keyword, no LLM needed
1048+
- [ ] Intent-aware edge weight boosting in graph traversal
1049+
- [ ] Dual-stream ingestion (fast sync + slow async goroutine)
1050+
- [ ] Semantic boundary detection (heuristic: cosine distance between consecutive summaries)
1051+
- [ ] Topic consolidation at boundaries (not just at session end)
1052+
- [ ] Multi-factor re-ranking: semantic × temporal × confidence × role
1053+
9971054
### Phase 5: Team & Scale
9981055
- [ ] Team memory sharing (namespaced)
9991056
- [ ] Skill/procedural memory (replayable workflows)

cmd/yaad/main.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"github.com/yaadmemory/yaad/internal/storage"
2222
yaadsync "github.com/yaadmemory/yaad/internal/sync"
2323
"github.com/yaadmemory/yaad/internal/tui"
24+
intentpkg "github.com/yaadmemory/yaad/internal/intent"
2425
)
2526

2627
var version = "0.1.0"
@@ -295,7 +296,7 @@ func init() {
295296
hookCmd, setupCmd, replayCmd,
296297
exportJSONCmd, exportMarkdownCmd, exportObsidianCmd, importJSONCmd,
297298
skillStoreCmd, skillListCmd, skillReplayCmd, benchCmd,
298-
syncCmd, tuiCmd)
299+
syncCmd, tuiCmd, intentCmd)
299300
}
300301

301302
func truncate(s string, n int) string {
@@ -675,6 +676,23 @@ var tuiCmd = &cobra.Command{
675676
},
676677
}
677678

679+
var intentCmd = &cobra.Command{
680+
Use: "intent [query]",
681+
Short: "Classify query intent (Why/When/Who/How/What) for intent-aware retrieval",
682+
Args: cobra.MinimumNArgs(1),
683+
Run: func(cmd *cobra.Command, args []string) {
684+
query := strings.Join(args, " ")
685+
i := intentpkg.Classify(query)
686+
w := intentpkg.Weights(i)
687+
fmt.Printf("Query: %s\n", query)
688+
fmt.Printf("Intent: %s\n", i.String())
689+
fmt.Printf("Edge weights:\n")
690+
fmt.Printf(" caused_by: %.1f led_to: %.1f\n", w.CausedBy, w.LedTo)
691+
fmt.Printf(" learned_in: %.1f touches: %.1f\n", w.LearnedIn, w.Touches)
692+
fmt.Printf(" part_of: %.1f relates_to: %.1f\n", w.PartOf, w.RelatesTo)
693+
},
694+
}
695+
678696
var syncCmd = &cobra.Command{
679697
Use: "sync",
680698
Short: "Sync memories via git chunks (.yaad/chunks/*.jsonl.gz)",

integration_test.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,17 @@ import (
1010
"path/filepath"
1111
"strings"
1212
"testing"
13+
"time"
1314

1415
"github.com/yaadmemory/yaad/internal/agentconfig"
1516
"github.com/yaadmemory/yaad/internal/bench"
17+
"github.com/yaadmemory/yaad/internal/boundary"
1618
"github.com/yaadmemory/yaad/internal/embeddings"
1719
"github.com/yaadmemory/yaad/internal/engine"
1820
"github.com/yaadmemory/yaad/internal/exportimport"
1921
"github.com/yaadmemory/yaad/internal/hooks"
22+
"github.com/yaadmemory/yaad/internal/ingest"
23+
intentpkg "github.com/yaadmemory/yaad/internal/intent"
2024
"github.com/yaadmemory/yaad/internal/server"
2125
"github.com/yaadmemory/yaad/internal/skill"
2226
"github.com/yaadmemory/yaad/internal/storage"
@@ -548,6 +552,129 @@ func TestGitSync(t *testing.T) {
548552
}
549553
}
550554

555+
func TestPhase6IntentClassifier(t *testing.T) {
556+
cases := []struct {
557+
query string
558+
expected intentpkg.Intent
559+
}{
560+
{"why did we choose NATS over Redis?", intentpkg.IntentWhy},
561+
{"when did we fix the auth bug?", intentpkg.IntentWhen},
562+
{"how to deploy the application?", intentpkg.IntentHow},
563+
{"what is the auth subsystem?", intentpkg.IntentWhat},
564+
{"which library should I use for JWT?", intentpkg.IntentWho},
565+
{"recall auth middleware", intentpkg.IntentGeneral},
566+
}
567+
for _, c := range cases {
568+
got := intentpkg.Classify(c.query)
569+
if got != c.expected {
570+
t.Errorf("Classify(%q) = %s, want %s", c.query, got, c.expected)
571+
}
572+
}
573+
}
574+
575+
func TestPhase6IntentAwareRetrieval(t *testing.T) {
576+
eng, cleanup := setup(t)
577+
defer cleanup()
578+
579+
// Seed memories
580+
decision, _ := eng.Remember(engine.RememberInput{Type: "decision", Content: "Chose NATS over Redis Streams for event bus", Scope: "project"})
581+
convention, _ := eng.Remember(engine.RememberInput{Type: "convention", Content: "Use NATS client v2 for all event publishing", Scope: "project"})
582+
583+
// Link: decision led_to convention
584+
eng.Graph().AddEdge(&storage.Edge{
585+
ID: "e-test", FromID: decision.ID, ToID: convention.ID, Type: "led_to", Weight: 1.0,
586+
})
587+
588+
// Why query should find the decision via causal traversal
589+
result, err := eng.Recall(engine.RecallOpts{Query: "why NATS", Depth: 2, Limit: 10})
590+
if err != nil {
591+
t.Fatal(err)
592+
}
593+
if len(result.Nodes) == 0 {
594+
t.Error("intent-aware recall returned no nodes")
595+
}
596+
// Should find both decision and convention via causal chain
597+
found := map[string]bool{}
598+
for _, n := range result.Nodes {
599+
found[n.Type] = true
600+
}
601+
t.Logf("Why query found types: %v", found)
602+
}
603+
604+
func TestPhase6DualStream(t *testing.T) {
605+
eng, cleanup := setup(t)
606+
defer cleanup()
607+
608+
ds := ingest.New(eng)
609+
defer ds.Stop()
610+
611+
// Fast path should return immediately
612+
node, err := ds.Remember(engine.RememberInput{
613+
Type: "convention", Content: "Use jose not jsonwebtoken", Scope: "project",
614+
})
615+
if err != nil {
616+
t.Fatal(err)
617+
}
618+
if node.ID == "" {
619+
t.Error("dual stream: empty node ID")
620+
}
621+
622+
// Second remember should create temporal backbone edge
623+
node2, err := ds.Remember(engine.RememberInput{
624+
Type: "decision", Content: "Chose RS256 for JWT", Scope: "project",
625+
})
626+
if err != nil {
627+
t.Fatal(err)
628+
}
629+
if node2.ID == "" {
630+
t.Error("dual stream: second node empty ID")
631+
}
632+
633+
// Give slow path time to run and release DB lock
634+
time.Sleep(200 * time.Millisecond)
635+
636+
// Verify temporal backbone edge exists
637+
edges, _ := eng.Store().GetEdgesFrom(node.ID)
638+
hasTemporalEdge := false
639+
for _, e := range edges {
640+
if e.ToID == node2.ID && e.Type == "learned_in" {
641+
hasTemporalEdge = true
642+
}
643+
}
644+
if !hasTemporalEdge {
645+
t.Error("dual stream: temporal backbone edge not created")
646+
}
647+
}
648+
649+
func TestPhase6BoundaryDetector(t *testing.T) {
650+
// Test buffer overflow boundary (deterministic)
651+
det := boundary.New(3, 0.99) // very high threshold, only overflow triggers
652+
det.Add("item 1 about auth")
653+
det.Add("item 2 about auth")
654+
if !det.Add("item 3 about auth") {
655+
t.Error("buffer overflow should trigger boundary")
656+
}
657+
658+
// Test flush
659+
det2 := boundary.New(10, 0.3)
660+
det2.Add("content about authentication")
661+
det2.Add("more about JWT tokens")
662+
buf := det2.Flush()
663+
if len(buf) != 2 {
664+
t.Errorf("flush: expected 2 items, got %d", len(buf))
665+
}
666+
if det2.Size() != 0 {
667+
t.Error("flush: buffer should be empty after flush")
668+
}
669+
670+
// Test semantic distance detection (non-deterministic, just verify no panic)
671+
det3 := boundary.New(20, 0.3)
672+
det3.Add("Use jose for JWT authentication in Node.js")
673+
det3.Add("PostgreSQL database connection pooling configuration")
674+
// May or may not trigger — just verify it runs without error
675+
t.Logf("Boundary detector size after 2 items: %d", det3.Size())
676+
}
677+
551678
func TestRESTAPI(t *testing.T) {
552679
eng, cleanup := setup(t)
553680
defer cleanup()

internal/boundary/detector.go

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// Package boundary implements semantic boundary detection for memory consolidation.
2+
// Based on GAM (arxiv:2604.12285): consolidate only at semantic boundaries,
3+
// not arbitrary session ends, to prevent transient noise contaminating long-term memory.
4+
package boundary
5+
6+
import (
7+
"math"
8+
"strings"
9+
"unicode"
10+
)
11+
12+
// Detector detects semantic topic shifts in a stream of memory content.
13+
type Detector struct {
14+
buffer []string // recent content items
15+
maxBuffer int // max items before forced consolidation
16+
threshold float64 // cosine distance threshold for boundary detection
17+
}
18+
19+
// New creates a boundary detector.
20+
// threshold: 0.0-1.0, higher = more sensitive to topic shifts (default 0.3)
21+
func New(maxBuffer int, threshold float64) *Detector {
22+
if maxBuffer <= 0 {
23+
maxBuffer = 20
24+
}
25+
if threshold <= 0 {
26+
threshold = 0.3
27+
}
28+
return &Detector{maxBuffer: maxBuffer, threshold: threshold}
29+
}
30+
31+
// Add adds content to the buffer and returns true if a semantic boundary is detected.
32+
// A boundary means: consolidate the current buffer into a topic node.
33+
func (d *Detector) Add(content string) bool {
34+
if len(d.buffer) == 0 {
35+
d.buffer = append(d.buffer, content)
36+
return false
37+
}
38+
39+
// Check semantic distance between new content and buffer summary
40+
bufferSummary := strings.Join(d.buffer, " ")
41+
dist := semanticDistance(bufferSummary, content)
42+
43+
d.buffer = append(d.buffer, content)
44+
45+
// Boundary detected if:
46+
// 1. Semantic distance exceeds threshold (topic shift), OR
47+
// 2. Buffer is full (forced consolidation)
48+
if dist > d.threshold || len(d.buffer) >= d.maxBuffer {
49+
return true
50+
}
51+
return false
52+
}
53+
54+
// Flush returns and clears the current buffer.
55+
func (d *Detector) Flush() []string {
56+
buf := d.buffer
57+
d.buffer = nil
58+
return buf
59+
}
60+
61+
// Size returns current buffer size.
62+
func (d *Detector) Size() int { return len(d.buffer) }
63+
64+
// semanticDistance computes a lightweight semantic distance between two texts.
65+
// Uses TF-IDF-inspired bag-of-words cosine distance — no embeddings needed.
66+
// Returns 0.0 (identical) to 1.0 (completely different).
67+
func semanticDistance(a, b string) float64 {
68+
vecA := termFreq(a)
69+
vecB := termFreq(b)
70+
return 1.0 - cosineSim(vecA, vecB)
71+
}
72+
73+
func termFreq(text string) map[string]float64 {
74+
words := tokenize(text)
75+
freq := map[string]float64{}
76+
for _, w := range words {
77+
if len(w) > 2 && !isStopWord(w) {
78+
freq[w]++
79+
}
80+
}
81+
// Normalize
82+
total := 0.0
83+
for _, v := range freq {
84+
total += v * v
85+
}
86+
if total > 0 {
87+
norm := math.Sqrt(total)
88+
for k := range freq {
89+
freq[k] /= norm
90+
}
91+
}
92+
return freq
93+
}
94+
95+
func cosineSim(a, b map[string]float64) float64 {
96+
dot := 0.0
97+
for k, va := range a {
98+
if vb, ok := b[k]; ok {
99+
dot += va * vb
100+
}
101+
}
102+
return dot
103+
}
104+
105+
func tokenize(text string) []string {
106+
text = strings.ToLower(text)
107+
var words []string
108+
var word strings.Builder
109+
for _, r := range text {
110+
if unicode.IsLetter(r) || unicode.IsDigit(r) {
111+
word.WriteRune(r)
112+
} else if word.Len() > 0 {
113+
words = append(words, word.String())
114+
word.Reset()
115+
}
116+
}
117+
if word.Len() > 0 {
118+
words = append(words, word.String())
119+
}
120+
return words
121+
}
122+
123+
var stopWords = map[string]bool{
124+
"the": true, "and": true, "for": true, "are": true, "but": true,
125+
"not": true, "you": true, "all": true, "can": true, "had": true,
126+
"her": true, "was": true, "one": true, "our": true, "out": true,
127+
"use": true, "with": true, "this": true, "that": true, "from": true,
128+
"they": true, "will": true, "have": true, "been": true, "when": true,
129+
}
130+
131+
func isStopWord(w string) bool { return stopWords[w] }

0 commit comments

Comments
 (0)