]> git.giacomolli.uk - onechat/blob - memory_test.go
Record open questions and reasons in issues
[onechat] / memory_test.go
1 package main
2
3 import (
4         "context"
5         "errors"
6         "fmt"
7         "maps"
8         "os"
9         "path/filepath"
10         "slices"
11         "strings"
12         "sync"
13         "testing"
14         "time"
15         "unicode/utf8"
16
17         "github.com/stretchr/testify/require"
18 )
19
20 func TestMemoryRestartLoadsTheSameView(t *testing.T) {
21         root := t.TempDir()
22         clock := newTestClock()
23         memory, err := openMemory(t.Context(), root, clock.Now, newFakeSummarizer())
24         require.NoError(t, err)
25
26         memory.chat.high = 200
27         memory.chat.low = 100
28         memory.cview.high = 80
29         memory.cview.low = 40
30         for n := range 100 {
31                 _, err = memory.Append("user", fmt.Sprint(n))
32                 require.NoError(t, err)
33         }
34
35         view := memory.View()
36         context := memory.Context(0, 100)
37         saved, err := os.ReadFile(filepath.Join(root, "view.json"))
38         require.NoError(t, err)
39         require.Less(t, len(memory.chat.lines), 100)
40         require.NoError(t, memory.Close())
41
42         memory, err = openMemory(t.Context(), root, clock.Now, newFakeSummarizer())
43         require.NoError(t, err)
44
45         defer func() {
46                 require.NoError(t, memory.Close())
47         }()
48
49         require.Equal(t, view, memory.View())
50         require.Equal(t, context, memory.Context(0, 100))
51
52         loaded, err := os.ReadFile(filepath.Join(root, "view.json"))
53         require.NoError(t, err)
54         require.Equal(t, saved, loaded)
55 }
56
57 func TestMemoryKeepsTheCompactionViewCoarser(t *testing.T) {
58         memory, err := openMemory(t.Context(), t.TempDir(), newTestClock().Now, newFakeSummarizer())
59         require.NoError(t, err)
60
61         defer func() {
62                 require.NoError(t, memory.Close())
63         }()
64
65         memory.chat.high = 200
66         memory.chat.low = 100
67         memory.cview.high = 80
68         memory.cview.low = 40
69         for n := range 64 {
70                 _, err = memory.Append("user", fmt.Sprint(n%10))
71                 require.NoError(t, err)
72
73                 coarse := 0
74                 for _, line := range memory.chat.lines {
75                         outer := memory.cview.lines[coarse]
76                         for nodeEnd(outer.level, outer.index) <= line.index<<line.level {
77                                 coarse++
78                                 outer = memory.cview.lines[coarse]
79                         }
80
81                         require.GreaterOrEqual(t, outer.level, line.level, "message %d", n)
82                 }
83         }
84 }
85
86 func TestMemoryRunsAtMostEightBuilds(t *testing.T) {
87         summarizer := newFakeSummarizer()
88         summarizer.gate = make(chan struct{})
89         memory, err := openMemory(t.Context(), t.TempDir(), newTestClock().Now, summarizer)
90         require.NoError(t, err)
91
92         defer func() {
93                 require.NoError(t, memory.Close())
94         }()
95
96         for n := range 12 {
97                 _, err = memory.Append("echo", longText(n))
98                 require.NoError(t, err)
99         }
100
101         memory.mutex.Lock()
102         running := slices.Collect(maps.Keys(memory.queue.running))
103         memory.mutex.Unlock()
104         slices.SortFunc(running, func(a, b nodeKey) int {
105                 return a.index - b.index
106         })
107         require.Equal(t, []nodeKey{{0, 0}, {0, 1}, {0, 2}, {0, 3}, {0, 4}, {0, 5}, {0, 6}, {0, 7}}, running)
108
109         require.Eventually(t, func() bool {
110                 return summarizer.Active() == maxBuilds
111         }, 10*time.Second, time.Millisecond)
112
113         close(summarizer.gate)
114         require.NoError(t, memory.Wait(t.Context()))
115         require.Equal(t, maxBuilds, summarizer.peak)
116
117         for level, count := range []int{12, 6, 3, 1} {
118                 for index := range count {
119                         require.True(t, isBuilt(t, memory.tree, level, index), "node (%d, %d)", level, index)
120                 }
121         }
122 }
123
124 func TestMemoryStartsALeafAfterFewerThanEightUnbuilt(t *testing.T) {
125         summarizer := newFakeSummarizer()
126         summarizer.SetFailing(true)
127         memory, err := openMemory(t.Context(), t.TempDir(), newTestClock().Now, summarizer)
128         require.NoError(t, err)
129
130         defer func() {
131                 require.NoError(t, memory.Close())
132         }()
133
134         for n := range 9 {
135                 _, err = memory.Append("echo", longText(n))
136                 require.NoError(t, err)
137         }
138
139         require.NoError(t, memory.Wait(t.Context()))
140         for _, task := range summarizer.Tasks() {
141                 require.Less(t, task.Index, 8)
142         }
143
144         require.False(t, isBuilt(t, memory.tree, 0, 8))
145         summarizer.SetFailing(false)
146
147         _, err = memory.Append("user", "retry")
148         require.NoError(t, err)
149         require.NoError(t, memory.Wait(t.Context()))
150         require.True(t, isBuilt(t, memory.tree, 0, 8))
151         require.True(t, isBuilt(t, memory.tree, 3, 0))
152 }
153
154 func TestMemoryMergesAfterBothHalves(t *testing.T) {
155         summarizer := newFakeSummarizer()
156         memory, err := openMemory(t.Context(), t.TempDir(), newTestClock().Now, summarizer)
157         require.NoError(t, err)
158
159         defer func() {
160                 require.NoError(t, memory.Close())
161         }()
162
163         _, err = memory.Append("echo", longText(0))
164         require.NoError(t, err)
165         require.NoError(t, memory.Wait(t.Context()))
166
167         _, err = memory.Append("echo", longText(1))
168         require.NoError(t, err)
169         require.NoError(t, memory.Wait(t.Context()))
170         require.Len(t, summarizer.Tasks(), 3)
171
172         left := readNode(t, memory.tree, 0, 0)
173         right := readNode(t, memory.tree, 0, 1)
174         merge := summarizer.Tasks()[2]
175         require.Equal(t, []string{left.Text, right.Text}, merge.Input)
176         require.Equal(t, "<chat>\n0+1|"+left.Text+"\n1+1|"+right.Text+"\n</chat>", merge.Context)
177         require.True(t, isBuilt(t, memory.tree, 1, 0))
178 }
179
180 func TestMemoryRestartRestoresTheQueue(t *testing.T) {
181         root := t.TempDir()
182         clock := newTestClock()
183         summarizer := newFakeSummarizer()
184         summarizer.SetFailing(true)
185         memory, err := openMemory(t.Context(), root, clock.Now, summarizer)
186         require.NoError(t, err)
187
188         for n := range 4 {
189                 _, err = memory.Append("echo", longText(n))
190                 require.NoError(t, err)
191         }
192
193         require.NoError(t, memory.Wait(t.Context()))
194         require.NoError(t, memory.Close())
195
196         memory, err = openMemory(t.Context(), root, clock.Now, newFakeSummarizer())
197         require.NoError(t, err)
198
199         defer func() {
200                 require.NoError(t, memory.Close())
201         }()
202
203         require.NoError(t, memory.Wait(t.Context()))
204         require.True(t, isBuilt(t, memory.tree, 2, 0))
205 }
206
207 func TestMemoryBuildsFreeParents(t *testing.T) {
208         memory, err := openMemory(t.Context(), t.TempDir(), newTestClock().Now, newFakeSummarizer())
209         require.NoError(t, err)
210
211         defer func() {
212                 require.NoError(t, memory.Close())
213         }()
214
215         for _, text := range []string{"a", "b", "c", "d"} {
216                 _, err = memory.Append("user", text)
217                 require.NoError(t, err)
218         }
219
220         node := readNode(t, memory.tree, 2, 0)
221         require.Equal(t, "user: a\nuser: b\nuser: c\nuser: d", node.Text)
222 }
223
224 func TestMemoryKeepsALongMessageOutOfTheView(t *testing.T) {
225         summarizer := newFakeSummarizer()
226         summarizer.SetFailing(true)
227         memory, err := openMemory(t.Context(), t.TempDir(), newTestClock().Now, summarizer)
228         require.NoError(t, err)
229
230         defer func() {
231                 require.NoError(t, memory.Close())
232         }()
233
234         _, err = memory.Append("echo", longText(0))
235         require.NoError(t, err)
236         require.NoError(t, memory.Wait(t.Context()))
237         require.Equal(t, "<chat>\n0+1|"+pending+"\n</chat>", memory.View())
238
239         summarizer.SetFailing(false)
240
241         _, err = memory.Append("user", "again")
242         require.NoError(t, err)
243         require.NoError(t, memory.Wait(t.Context()))
244
245         task, err := memory.task(nodeKey{0, 0})
246         require.NoError(t, err)
247         require.Equal(t, "<chat>\n0+1|"+fakeLine(task)+"\n1+1|user: again\n</chat>", memory.View())
248 }
249
250 func TestMemoryCoversMessagesAfterTheSavedView(t *testing.T) {
251         root := t.TempDir()
252         clock := newTestClock()
253         memory, err := openMemory(t.Context(), root, clock.Now, newFakeSummarizer())
254         require.NoError(t, err)
255
256         _, err = memory.Append("user", "a")
257         require.NoError(t, err)
258
259         _, err = memory.log.Append("reply", "b")
260         require.NoError(t, err)
261         require.NoError(t, memory.Close())
262
263         memory, err = openMemory(t.Context(), root, clock.Now, newFakeSummarizer())
264         require.NoError(t, err)
265
266         defer func() {
267                 require.NoError(t, memory.Close())
268         }()
269
270         require.Equal(t, "<chat>\n0+1|user: a\n1+1|reply: b\n</chat>", memory.View())
271 }
272
273 func TestMemoryRefusesToRebuildAMissingView(t *testing.T) {
274         root := t.TempDir()
275         clock := newTestClock()
276         memory, err := openMemory(t.Context(), root, clock.Now, newFakeSummarizer())
277         require.NoError(t, err)
278
279         _, err = memory.Append("user", "a")
280         require.NoError(t, err)
281         require.NoError(t, memory.Close())
282         require.NoError(t, os.Remove(filepath.Join(root, "view.json")))
283
284         _, err = openMemory(t.Context(), root, clock.Now, newFakeSummarizer())
285         require.ErrorIs(t, err, errNoView)
286 }
287
288 // fakeSummarizer builds each node with a line of fakeLine. It fails each task
289 // while failing is true, and each task waits for gate when gate is not nil.
290 type fakeSummarizer struct {
291         mutex   sync.Mutex
292         gate    chan struct{}
293         failing bool
294         active  int
295         peak    int
296         tasks   []Task
297 }
298
299 func newFakeSummarizer() *fakeSummarizer {
300         return &fakeSummarizer{}
301 }
302
303 func (f *fakeSummarizer) Summarize(ctx context.Context, task Task) (string, error) {
304         f.mutex.Lock()
305         f.tasks = append(f.tasks, task)
306         f.active++
307         f.peak = max(f.peak, f.active)
308         fail := f.failing
309         f.mutex.Unlock()
310
311         defer func() {
312                 f.mutex.Lock()
313                 f.active--
314                 f.mutex.Unlock()
315         }()
316
317         if f.gate != nil {
318                 select {
319                 case <-f.gate:
320                 case <-ctx.Done():
321                         return "", ctx.Err()
322                 }
323         }
324
325         if fail {
326                 return "", errors.New("fake failure")
327         }
328
329         return fakeLine(task), nil
330 }
331
332 // fakeLine returns a line of nodeBytes bytes or less that names the node and
333 // starts with the input. A real line of this size makes each merge a model
334 // call.
335 func fakeLine(task Task) string {
336         count := 1 << task.Level
337         line := fmt.Sprintf("summary %d+%d: %s", task.Index*count, count, strings.Join(task.Input, " "))
338         line = strings.ReplaceAll(line, "\n", " ")
339         for len(line) > nodeBytes-12 {
340                 _, size := utf8.DecodeLastRuneInString(line)
341                 line = line[:len(line)-size]
342         }
343
344         return line
345 }
346
347 // longText returns a text that is too long for its own node.
348 func longText(n int) string {
349         return fmt.Sprintf("long %d ", n) + strings.Repeat("x", nodeBytes)
350 }
351
352 // SetFailing makes the next tasks fail or not.
353 func (f *fakeSummarizer) SetFailing(failing bool) {
354         f.mutex.Lock()
355         defer f.mutex.Unlock()
356
357         f.failing = failing
358 }
359
360 // Active returns the number of tasks that run now.
361 func (f *fakeSummarizer) Active() int {
362         f.mutex.Lock()
363         defer f.mutex.Unlock()
364
365         return f.active
366 }
367
368 // Tasks returns the tasks so far.
369 func (f *fakeSummarizer) Tasks() []Task {
370         f.mutex.Lock()
371         defer f.mutex.Unlock()
372
373         return slices.Clone(f.tasks)
374 }