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