From: Anderson A. Giacomolli Date: Fri, 6 Nov 2026 14:39:32 +0000 (-0300) Subject: Add the compaction view X-Git-Url: https://git.giacomolli.uk/onechat/commitdiff_plain/e5343bd4c3ec69192e92f4be339b855eef36b6ec?hp=9f17bfb1173524553fff5301b0664267b430e6e3 Add the compaction view A compaction reads the chat view merged further, at the limits of 16 to 32 KB, so that it has context to resolve references without the whole chat. The compaction view takes each new line of the chat. It merges in one batch from above 32 KB down to 16 KB, and again when the chat view merges: then it first takes each merge of the chat view, so it stays the chat view merged further. Compactions share its prefix and so read each other's view from the prompt cache. view.json saves it with the chat view, and a restart loads both as they are. The context of a build ends at its node: before the message of a leaf, or after the last message of a merge. It stops at the first line that is not built, so a compaction never sees a pending mark or a part of a message. Issue: beb9a70 --- diff --git a/memory.go b/memory.go index 0be2fde..eb28341 100644 --- a/memory.go +++ b/memory.go @@ -14,21 +14,27 @@ import ( // errNoView tells that a chat has messages but no saved view. var errNoView = errors.New("the chat has messages but no saved view") -// Memory is the memory of one chat: the message log, the summary tree, and the -// view. It saves the view to view.json after each change and loads it at start. -// It never builds the view again from the log, because a new view differs from -// the live one and so loses the prompt cache. +// Memory is the memory of one chat: the message log, the summary tree, the chat +// view, and the compaction view. The compaction view is the chat view merged +// further, at the limits of 16 to 32 KB. It takes the same new lines, and it +// merges again in one batch when it is too large or when the chat view merges. +// So the compactions share its prefix in the prompt cache. It saves the views +// to view.json after each change and loads them at start. It never builds the +// views again from the log, because a new view differs from the live one and so +// loses the prompt cache. type Memory struct { mutex sync.Mutex root string log *Log tree *Tree chat *View + cview *View } // viewFile is the content of view.json. type viewFile struct { - Chat viewState `json:"chat"` + Chat viewState `json:"chat"` + Compaction viewState `json:"compaction"` } // openMemory opens the chat in root. The clock gives the dates of new messages @@ -64,6 +70,8 @@ func (m *Memory) load() error { data, err := os.ReadFile(m.viewPath()) if errors.Is(err, os.ErrNotExist) && m.log.Len() == 0 { m.chat = newView(chatHigh, chatLow) + m.cview = newView(compactionHigh, compactionLow) + return nil } @@ -83,7 +91,16 @@ func (m *Memory) load() error { m.chat, err = restoreView(file.Chat, chatHigh, chatLow, m.tree) if err != nil { - return err + return fmt.Errorf("chat view: %w", err) + } + + m.cview, err = restoreView(file.Compaction, compactionHigh, compactionLow, m.tree) + if err != nil { + return fmt.Errorf("compaction view: %w", err) + } + + if m.cview.end != m.chat.end { + return fmt.Errorf("compaction view covers %d messages, but the chat view %d", m.cview.end, m.chat.end) } if m.chat.end > m.log.Len() { @@ -152,7 +169,11 @@ func (m *Memory) addMessage(message Message) error { } } - m.chat.Append(m.tree) + merged := m.chat.Append(m.tree) + m.cview.Append(m.tree) + if merged { + m.cview.Coarsen(m.chat, m.tree) + } return nil } @@ -188,7 +209,8 @@ func (m *Memory) build(node Node) error { // save writes the views to view.json. func (m *Memory) save() error { data, err := json.Marshal(viewFile{ - Chat: m.chat.State(), + Chat: m.chat.State(), + Compaction: m.cview.State(), }) if err != nil { return fmt.Errorf("encode view: %w", err) @@ -214,6 +236,25 @@ func (m *Memory) View() string { return m.chat.Render(m.tree) } +// Context returns the compaction view for the build of node (l, i). It ends +// before the message of a leaf, or after the last message of a merge, and it +// stops at the first line that is not built. +func (m *Memory) Context(level, index int) string { + m.mutex.Lock() + defer m.mutex.Unlock() + + return m.context(level, index) +} + +func (m *Memory) context(level, index int) string { + end := index + if level > 0 { + end = nodeEnd(level, index) + } + + return m.cview.Context(m.tree, end) +} + // writeFileAtomic replaces the file at path with data. A reader or a crash sees // the old file or the new file, never a part of one. func writeFileAtomic(path string, data []byte) error { diff --git a/memory_test.go b/memory_test.go index b5bddb0..426654a 100644 --- a/memory_test.go +++ b/memory_test.go @@ -17,12 +17,15 @@ func TestMemoryRestartLoadsTheSameView(t *testing.T) { memory.chat.high = 200 memory.chat.low = 100 + memory.cview.high = 80 + memory.cview.low = 40 for n := range 100 { _, err = memory.Append("user", fmt.Sprint(n)) require.NoError(t, err) } view := memory.View() + context := memory.Context(0, 100) saved, err := os.ReadFile(filepath.Join(root, "view.json")) require.NoError(t, err) require.Less(t, len(memory.chat.lines), 100) @@ -36,12 +39,42 @@ func TestMemoryRestartLoadsTheSameView(t *testing.T) { }() require.Equal(t, view, memory.View()) + require.Equal(t, context, memory.Context(0, 100)) loaded, err := os.ReadFile(filepath.Join(root, "view.json")) require.NoError(t, err) require.Equal(t, saved, loaded) } +func TestMemoryKeepsTheCompactionViewCoarser(t *testing.T) { + memory, err := openMemory(t.Context(), t.TempDir(), newTestClock().Now) + require.NoError(t, err) + + defer func() { + require.NoError(t, memory.Close()) + }() + + memory.chat.high = 200 + memory.chat.low = 100 + memory.cview.high = 80 + memory.cview.low = 40 + for n := range 64 { + _, err = memory.Append("user", fmt.Sprint(n%10)) + require.NoError(t, err) + + coarse := 0 + for _, line := range memory.chat.lines { + outer := memory.cview.lines[coarse] + for nodeEnd(outer.level, outer.index) <= line.index< v.high { v.merging = true } + merged := false for v.merging && size > v.low { best := v.mostDue(tree) if best < 0 { - return + return merged } left, right := v.lines[best], v.lines[best+1] @@ -120,9 +129,42 @@ func (v *View) fit(tree nodeReader) { size += lineSize(tree, parent) - lineSize(tree, left) - lineSize(tree, right) v.lines[best] = parent v.lines = append(v.lines[:best+1], v.lines[best+2:]...) + merged = true } v.merging = false + + return merged +} + +// Coarsen takes each merge of other, a view of the same messages, and then +// merges in one batch until the size is at most low. Each line of the result is +// the coarser of the two lines that start at its first message. Dyadic ranges +// either nest or do not overlap, so the result covers the messages without a +// gap. +func (v *View) Coarsen(other *View, tree nodeReader) { + lines := make([]nodeKey, 0, len(v.lines)) + mine, theirs := 0, 0 + for start := 0; start < v.end; { + line := v.lines[mine] + if other.lines[theirs].level > line.level { + line = other.lines[theirs] + } + + lines = append(lines, line) + start = nodeEnd(line.level, line.index) + for mine < len(v.lines) && v.lines[mine].index<\n") + for _, line := range v.lines { + _, built := tree.Node(line.level, line.index) + if !built || nodeEnd(line.level, line.index) > end { + break + } + + renderLine(&text, tree, line) + } + + text.WriteString("") + + return text.String() +} + // lineSize returns the size of a view line. func lineSize(tree nodeReader, line nodeKey) int { node, built := tree.Node(line.level, line.index) diff --git a/view_test.go b/view_test.go index be38873..0162f28 100644 --- a/view_test.go +++ b/view_test.go @@ -90,6 +90,45 @@ func TestViewRendersPendingLinesWithoutTheirMessage(t *testing.T) { require.Equal(t, 7+len(pending), view.Size(tree)) } +func TestViewCoarsenTakesTheCoarserLines(t *testing.T) { + view, err := restoreView(viewState{ + Lines: [][2]int{{1, 0}, {0, 2}, {0, 3}, {0, 4}, {0, 5}}, + }, 100, 100, builtTree{}) + require.NoError(t, err) + + other, err := restoreView(viewState{ + Lines: [][2]int{{0, 0}, {0, 1}, {1, 1}, {0, 4}, {0, 5}}, + }, 100, 100, builtTree{}) + require.NoError(t, err) + + view.Coarsen(other, builtTree{}) + require.Equal(t, []nodeKey{{1, 0}, {1, 1}, {0, 4}, {0, 5}}, view.lines) + + view.low = 2 + view.Coarsen(other, builtTree{}) + require.Equal(t, []nodeKey{{2, 0}, {1, 2}}, view.lines) + require.False(t, view.merging) +} + +func TestViewContextStopsAtTheFirstPendingLine(t *testing.T) { + tree := partTree{ + sizes: map[nodeKey]int{ + {0, 0}: 1, + {0, 1}: 1, + {1, 0}: 1, + {0, 3}: 1, + }, + } + + view, err := restoreView(viewState{ + Lines: [][2]int{{1, 0}, {0, 2}, {0, 3}}, + }, 100, 100, tree) + require.NoError(t, err) + require.Equal(t, "\n", view.Context(tree, 0)) + require.Equal(t, "\n0+2|a\n", view.Context(tree, 2)) + require.Equal(t, "\n0+2|a\n", view.Context(tree, 3)) +} + func TestRestoreViewRejectsAGap(t *testing.T) { _, err := restoreView(viewState{ Lines: [][2]int{{0, 0}, {0, 2}},