]> git.giacomolli.uk - onechat/blob - memory_test.go
fe050c4f001dce923fefecbd3244387d53d31ce3
[onechat] / memory_test.go
1 package main
2
3 import (
4 "context"
5 "fmt"
6 "log"
7 "maps"
8 "os"
9 "path/filepath"
10 "slices"
11 "strings"
12 "testing"
13 "time"
14
15 "github.com/stretchr/testify/require"
16 )
17
18 func TestMemoryRestartLoadsTheSameView(t *testing.T) {
19 root := t.TempDir()
20 clock := newTestClock()
21 memory, err := openMemory(t.Context(), root, clock.Now, newFakeSummarizer(), discardLog())
22 require.NoError(t, err)
23
24 memory.chat.high = 200
25 memory.chat.low = 100
26
27 memory.cview.high = 80
28 memory.cview.low = 40
29 for n := range 100 {
30 _, err = memory.Append("user", fmt.Sprint(n))
31 require.NoError(t, err)
32 }
33
34 view := memory.View()
35 context := memory.Context(0, 100)
36 saved, err := os.ReadFile(filepath.Join(root, "view.json"))
37 require.NoError(t, err)
38 require.Less(t, len(memory.chat.lines), 100)
39 require.NoError(t, memory.Close())
40
41 memory, err = openMemory(t.Context(), root, clock.Now, newFakeSummarizer(), discardLog())
42 require.NoError(t, err)
43
44 defer func() {
45 require.NoError(t, memory.Close())
46 }()
47
48 require.Equal(t, view, memory.View())
49 require.Equal(t, context, memory.Context(0, 100))
50
51 loaded, err := os.ReadFile(filepath.Join(root, "view.json"))
52 require.NoError(t, err)
53 require.Equal(t, saved, loaded)
54 }
55
56 func TestMemoryOpensAfterACrashBeforeTheViewSave(t *testing.T) {
57 root := t.TempDir()
58 clock := newTestClock()
59 memory, err := openMemory(t.Context(), root, clock.Now, newFakeSummarizer(), discardLog())
60 require.NoError(t, err)
61
62 _, err = memory.Append("user", "first")
63 require.NoError(t, err)
64
65 saved, err := os.ReadFile(filepath.Join(root, "view.json"))
66 require.NoError(t, err)
67
68 _, err = memory.Append("user", "second")
69 require.NoError(t, err)
70
71 view := memory.View()
72 require.NoError(t, memory.Close())
73
74 // The log and the tree hold message 1, but the view does not.
75 require.NoError(t, os.WriteFile(filepath.Join(root, "view.json"), saved, 0o600))
76
77 memory, err = openMemory(t.Context(), root, clock.Now, newFakeSummarizer(), discardLog())
78 require.NoError(t, err)
79 require.Equal(t, view, memory.View())
80 require.NoError(t, memory.Close())
81 }
82
83 func TestMemoryKeepsTheCompactionViewCoarser(t *testing.T) {
84 memory, err := openMemory(t.Context(), t.TempDir(), newTestClock().Now, newFakeSummarizer(), discardLog())
85 require.NoError(t, err)
86
87 defer func() {
88 require.NoError(t, memory.Close())
89 }()
90
91 memory.chat.high = 200
92 memory.chat.low = 100
93
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, discardLog())
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", memoryLongText(n))
125 require.NoError(t, err)
126 }
127
128 memory.mutex.Lock()
129
130 running := slices.Collect(maps.Keys(memory.queue.running))
131 memory.mutex.Unlock()
132 slices.SortFunc(running, func(a, b NodeKey) int {
133 return a.index - b.index
134 })
135 require.Equal(t, []NodeKey{{0, 0}, {0, 1}, {0, 2}, {0, 3}, {0, 4}, {0, 5}, {0, 6}, {0, 7}}, running)
136
137 require.Eventually(t, func() bool {
138 return summarizer.Active() == buildMax
139 }, 10*time.Second, time.Millisecond)
140
141 close(summarizer.gate)
142 require.NoError(t, memory.Wait(t.Context()))
143 require.Equal(t, buildMax, summarizer.peak)
144
145 for level, count := range []int{12, 6, 3, 1} {
146 for index := range count {
147 require.True(t, treeIsBuilt(t, memory.tree, level, index), "node (%d, %d)", level, index)
148 }
149 }
150 }
151
152 func TestMemorySettleWaitsForThePendingLines(t *testing.T) {
153 summarizer := newFakeSummarizer()
154 summarizer.gate = make(chan struct{})
155 memory, err := openMemory(t.Context(), t.TempDir(), newTestClock().Now, summarizer, discardLog())
156 require.NoError(t, err)
157
158 defer func() {
159 require.NoError(t, memory.Close())
160 }()
161
162 _, err = memory.Append("echo", memoryLongText(0))
163 require.NoError(t, err)
164 require.Contains(t, memory.View(), viewPending)
165
166 settled := make(chan error, 1)
167 go func() {
168 settled <- memory.Settle(t.Context())
169 }()
170
171 select {
172 case err = <-settled:
173 require.Fail(t, "Settle returned before the build", "error: %v", err)
174 case <-time.After(50 * time.Millisecond):
175 }
176
177 close(summarizer.gate)
178 require.NoError(t, <-settled)
179 require.NotContains(t, memory.View(), viewPending)
180 }
181
182 func TestMemorySettleFailsWhileABuildFailed(t *testing.T) {
183 summarizer := newFakeSummarizer()
184 summarizer.SetFailing(true)
185
186 logs := &SyncBuffer{}
187 memory, err := openMemory(t.Context(), t.TempDir(), newTestClock().Now, summarizer, log.New(logs, "", 0))
188 require.NoError(t, err)
189
190 defer func() {
191 require.NoError(t, memory.Close())
192 }()
193
194 _, err = memory.Append("echo", memoryLongText(0))
195 require.NoError(t, err)
196 require.ErrorIs(t, memory.Settle(t.Context()), errMemoryUnsettled)
197 require.Equal(t, "summary failed, retry at next message: node (0, 0): fake failure\n", logs.String())
198
199 summarizer.SetFailing(false)
200
201 _, err = memory.Append("user", "retry")
202 require.NoError(t, err)
203 require.NoError(t, memory.Settle(t.Context()))
204 }
205
206 func TestMemorySettleEndsWithItsContext(t *testing.T) {
207 summarizer := newFakeSummarizer()
208 summarizer.gate = make(chan struct{})
209 memory, err := openMemory(t.Context(), t.TempDir(), newTestClock().Now, summarizer, discardLog())
210 require.NoError(t, err)
211
212 defer func() {
213 require.NoError(t, memory.Close())
214 }()
215
216 _, err = memory.Append("echo", memoryLongText(0))
217 require.NoError(t, err)
218
219 ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond)
220 defer cancel()
221
222 require.ErrorIs(t, memory.Settle(ctx), context.DeadlineExceeded)
223 }
224
225 func TestMemoryStartsALeafAfterFewerThanEightUnbuilt(t *testing.T) {
226 summarizer := newFakeSummarizer()
227 summarizer.SetFailing(true)
228
229 memory, err := openMemory(t.Context(), t.TempDir(), newTestClock().Now, summarizer, discardLog())
230 require.NoError(t, err)
231
232 defer func() {
233 require.NoError(t, memory.Close())
234 }()
235
236 for n := range 9 {
237 _, err = memory.Append("echo", memoryLongText(n))
238 require.NoError(t, err)
239 }
240
241 require.NoError(t, memory.Wait(t.Context()))
242
243 for _, task := range summarizer.Tasks() {
244 require.Less(t, task.Index, 8)
245 }
246
247 require.False(t, treeIsBuilt(t, memory.tree, 0, 8))
248 summarizer.SetFailing(false)
249
250 _, err = memory.Append("user", "retry")
251 require.NoError(t, err)
252 require.NoError(t, memory.Wait(t.Context()))
253 require.True(t, treeIsBuilt(t, memory.tree, 0, 8))
254 require.True(t, treeIsBuilt(t, memory.tree, 3, 0))
255 }
256
257 func TestMemoryMergesAfterBothHalves(t *testing.T) {
258 summarizer := newFakeSummarizer()
259 memory, err := openMemory(t.Context(), t.TempDir(), newTestClock().Now, summarizer, discardLog())
260 require.NoError(t, err)
261
262 defer func() {
263 require.NoError(t, memory.Close())
264 }()
265
266 _, err = memory.Append("echo", memoryLongText(0))
267 require.NoError(t, err)
268 require.NoError(t, memory.Wait(t.Context()))
269
270 _, err = memory.Append("echo", memoryLongText(1))
271 require.NoError(t, err)
272 require.NoError(t, memory.Wait(t.Context()))
273 require.Len(t, summarizer.Tasks(), 3)
274
275 left := treeReadNode(t, memory.tree, 0, 0)
276 right := treeReadNode(t, memory.tree, 0, 1)
277 merge := summarizer.Tasks()[2]
278 require.Equal(t, []string{left.Text, right.Text}, merge.Input)
279 require.Equal(t, "<chat>\n0+1|"+left.Text+"\n1+1|"+right.Text+"\n</chat>", merge.Context)
280 require.True(t, treeIsBuilt(t, memory.tree, 1, 0))
281 }
282
283 func TestMemoryRestartRestoresTheQueue(t *testing.T) {
284 root := t.TempDir()
285 clock := newTestClock()
286 summarizer := newFakeSummarizer()
287 summarizer.SetFailing(true)
288
289 memory, err := openMemory(t.Context(), root, clock.Now, summarizer, discardLog())
290 require.NoError(t, err)
291
292 for n := range 4 {
293 _, err = memory.Append("echo", memoryLongText(n))
294 require.NoError(t, err)
295 }
296
297 require.NoError(t, memory.Wait(t.Context()))
298 require.NoError(t, memory.Close())
299
300 memory, err = openMemory(t.Context(), root, clock.Now, newFakeSummarizer(), discardLog())
301 require.NoError(t, err)
302
303 defer func() {
304 require.NoError(t, memory.Close())
305 }()
306
307 require.NoError(t, memory.Wait(t.Context()))
308 require.True(t, treeIsBuilt(t, memory.tree, 2, 0))
309 }
310
311 func TestMemoryBuildsFreeParents(t *testing.T) {
312 memory, err := openMemory(t.Context(), t.TempDir(), newTestClock().Now, newFakeSummarizer(), discardLog())
313 require.NoError(t, err)
314
315 defer func() {
316 require.NoError(t, memory.Close())
317 }()
318
319 for _, text := range []string{"a", "b", "c", "d"} {
320 _, err = memory.Append("user", text)
321 require.NoError(t, err)
322 }
323
324 node := treeReadNode(t, memory.tree, 2, 0)
325 require.Equal(t, "user: a\nuser: b\nuser: c\nuser: d", node.Text)
326 }
327
328 func TestMemoryKeepsALongMessageOutOfTheView(t *testing.T) {
329 summarizer := newFakeSummarizer()
330 summarizer.SetFailing(true)
331
332 memory, err := openMemory(t.Context(), t.TempDir(), newTestClock().Now, summarizer, discardLog())
333 require.NoError(t, err)
334
335 defer func() {
336 require.NoError(t, memory.Close())
337 }()
338
339 _, err = memory.Append("echo", memoryLongText(0))
340 require.NoError(t, err)
341 require.NoError(t, memory.Wait(t.Context()))
342 require.Equal(t, "<chat>\n0+1|"+viewPending+"\n</chat>", memory.View())
343
344 summarizer.SetFailing(false)
345
346 _, err = memory.Append("user", "again")
347 require.NoError(t, err)
348 require.NoError(t, memory.Wait(t.Context()))
349
350 task, err := memory.task(NodeKey{0, 0})
351 require.NoError(t, err)
352 require.Equal(t, "<chat>\n0+1|"+fakeLine(task)+"\n1+1|user: again\n</chat>", memory.View())
353 }
354
355 func TestMemoryCoversMessagesAfterTheSavedView(t *testing.T) {
356 root := t.TempDir()
357 clock := newTestClock()
358 memory, err := openMemory(t.Context(), root, clock.Now, newFakeSummarizer(), discardLog())
359 require.NoError(t, err)
360
361 _, err = memory.Append("user", "a")
362 require.NoError(t, err)
363
364 _, err = memory.log.Append("reply", "b")
365 require.NoError(t, err)
366 require.NoError(t, memory.Close())
367
368 memory, err = openMemory(t.Context(), root, clock.Now, newFakeSummarizer(), discardLog())
369 require.NoError(t, err)
370
371 defer func() {
372 require.NoError(t, memory.Close())
373 }()
374
375 require.Equal(t, "<chat>\n0+1|user: a\n1+1|reply: b\n</chat>", memory.View())
376 }
377
378 func TestMemoryRefusesToRebuildAMissingView(t *testing.T) {
379 root := t.TempDir()
380 clock := newTestClock()
381 memory, err := openMemory(t.Context(), root, clock.Now, newFakeSummarizer(), discardLog())
382 require.NoError(t, err)
383
384 _, err = memory.Append("user", "a")
385 require.NoError(t, err)
386 require.NoError(t, memory.Close())
387 require.NoError(t, os.Remove(filepath.Join(root, "view.json")))
388
389 _, err = openMemory(t.Context(), root, clock.Now, newFakeSummarizer(), discardLog())
390 require.ErrorIs(t, err, errMemoryNoView)
391 }
392
393 // memoryLongText returns a text that is too long for its own node.
394 func memoryLongText(n int) string {
395 return fmt.Sprintf("long %d ", n) + strings.Repeat("x", nodeBytes)
396 }