]> git.giacomolli.uk - onechat/blob - tree.go
Add the summary tree nodes
[onechat] / tree.go
1 package main
2
3 import (
4         "context"
5         "encoding/json"
6         "fmt"
7         "math/bits"
8         "os"
9         "path/filepath"
10         "time"
11         "unicode/utf8"
12 )
13
14 // nodeBytes is the size limit of a node text in UTF-8 bytes. A model can go a
15 // few bytes over it, because the view measures the real sizes.
16 const nodeBytes = 512
17
18 // Node is one line of the summary tree. Node (l, i) covers the 2^l messages
19 // from i*2^l on. A node of level 0 holds one message. A node of a higher level
20 // merges its two children, (l-1, 2i) and (l-1, 2i+1). Size is the UTF-8 byte
21 // length of Text.
22 type Node struct {
23         Level int    `json:"l"`
24         Index int    `json:"i"`
25         Text  string `json:"text"`
26         Size  int    `json:"size"`
27 }
28
29 // nodeKey names the node (l, i).
30 type nodeKey struct {
31         level int
32         index int
33 }
34
35 // Tree holds the built nodes of one chat. It writes one JSON line for each node
36 // to tree/YYYY-MM-DD.jsonl, named for the local day of the build, and loads all
37 // lines again at start. Each node is built once and never again. A node of a
38 // higher level is built only after both of its children. After a failed write,
39 // the tree refuses all writes until it is opened again. The caller holds the
40 // lock of the chat and serializes all calls.
41 type Tree struct {
42         clock   func() time.Time
43         journal journal
44         nodes   map[nodeKey]Node
45         failure error
46 }
47
48 // openTree loads the nodes of the chat in root. A chat of total messages cannot
49 // have a node that covers a later message.
50 func openTree(ctx context.Context, root string, total int, clock func() time.Time) (*Tree, error) {
51         err := os.MkdirAll(filepath.Join(root, "tree"), 0o700)
52         if err != nil {
53                 return nil, fmt.Errorf("create node directory: %w", err)
54         }
55
56         t := &Tree{
57                 clock: clock,
58                 journal: journal{
59                         dir: filepath.Join(root, "tree"),
60                 },
61                 nodes: make(map[nodeKey]Node),
62         }
63
64         err = readLines(ctx, t.journal.dir, func(node Node) error {
65                 err := t.check(node)
66                 if err != nil {
67                         return err
68                 }
69
70                 if nodeEnd(node.Level, node.Index) > total {
71                         return fmt.Errorf("node (%d, %d) covers messages after the log", node.Level, node.Index)
72                 }
73
74                 t.nodes[nodeKey{node.Level, node.Index}] = node
75
76                 return nil
77         })
78         if err != nil {
79                 return nil, fmt.Errorf("load tree: %w", err)
80         }
81
82         return t, nil
83 }
84
85 // check makes sure that node is valid and can join the tree now.
86 func (t *Tree) check(node Node) error {
87         if node.Level < 0 || node.Level >= bits.UintSize-2 || node.Index < 0 {
88                 return fmt.Errorf("node (%d, %d) is out of range", node.Level, node.Index)
89         }
90
91         if node.Text == "" || !utf8.ValidString(node.Text) || node.Size != len(node.Text) {
92                 return fmt.Errorf("node (%d, %d) has no valid text of its size", node.Level, node.Index)
93         }
94
95         if t.Built(node.Level, node.Index) {
96                 return fmt.Errorf("node (%d, %d) is already built", node.Level, node.Index)
97         }
98
99         if node.Level > 0 && (!t.Built(node.Level-1, 2*node.Index) || !t.Built(node.Level-1, 2*node.Index+1)) {
100                 return fmt.Errorf("node (%d, %d) has a child that is not built", node.Level, node.Index)
101         }
102
103         return nil
104 }
105
106 // Close closes the open day file.
107 func (t *Tree) Close() error {
108         return t.journal.Close()
109 }
110
111 // Add writes a new node to the tree.
112 func (t *Tree) Add(node Node) error {
113         if t.failure != nil {
114                 return fmt.Errorf("tree needs a reopen after a failed write: %w", t.failure)
115         }
116
117         err := t.check(node)
118         if err != nil {
119                 return err
120         }
121
122         line, err := json.Marshal(node)
123         if err != nil {
124                 return fmt.Errorf("encode node: %w", err)
125         }
126
127         err = t.journal.Write(t.clock().Format(time.DateOnly), append(line, '\n'))
128         if err != nil {
129                 t.failure = err
130                 return fmt.Errorf("write node (%d, %d): %w", node.Level, node.Index, err)
131         }
132
133         t.nodes[nodeKey{node.Level, node.Index}] = node
134
135         return nil
136 }
137
138 // Built tells if the node (l, i) is built.
139 func (t *Tree) Built(level, index int) bool {
140         _, built := t.nodes[nodeKey{level, index}]
141         return built
142 }
143
144 // Node returns the node (l, i) if it is built.
145 func (t *Tree) Node(level, index int) (Node, bool) {
146         node, built := t.nodes[nodeKey{level, index}]
147         return node, built
148 }
149
150 // leafSource returns the text that the leaf of message holds or summarizes.
151 func leafSource(message Message) string {
152         return message.Kind + ": " + message.Text
153 }
154
155 // freeLeaf returns the leaf of message when its source fits in a node. Such a
156 // leaf holds the source word for word and needs no model call.
157 func freeLeaf(message Message) (Node, bool) {
158         text := leafSource(message)
159         if len(text) > nodeBytes {
160                 return Node{}, false
161         }
162
163         return Node{
164                 Level: 0,
165                 Index: message.ID,
166                 Text:  text,
167                 Size:  len(text),
168         }, true
169 }
170
171 // freeMerge returns the parent of two sibling nodes when both texts, joined by
172 // a newline, fit in a node. Such a parent needs no model call.
173 func freeMerge(left, right Node) (Node, bool) {
174         if left.Size+1+right.Size > nodeBytes {
175                 return Node{}, false
176         }
177
178         text := left.Text + "\n" + right.Text
179
180         return Node{
181                 Level: left.Level + 1,
182                 Index: left.Index / 2,
183                 Text:  text,
184                 Size:  len(text),
185         }, true
186 }
187
188 // nodeEnd returns the ID after the last message of node (l, i).
189 func nodeEnd(level, index int) int {
190         return (index + 1) << level
191 }