]> git.giacomolli.uk - onechat/blob - build_run.go
Check spacing between Go statement blocks
[onechat] / build_run.go
1 package main
2
3 import (
4         "bufio"
5         "cmp"
6         "context"
7         "encoding/json"
8         "errors"
9         "fmt"
10         "io"
11         "maps"
12         "os"
13         "slices"
14         "strconv"
15         "strings"
16         "time"
17 )
18
19 // buildLineBytes is the most bytes of one line in a build input or usage log.
20 const buildLineBytes = 1 << 20
21
22 // buildQuotaLimit is the largest increase in used quota points of a run.
23 const buildQuotaLimit = 15.0
24
25 // buildCallLimit is the most model calls of a run.
26 const buildCallLimit = 660
27
28 // BuildRun appends messages, builds their tree, and stops the run by the rules
29 // of buildStop.
30 type BuildRun struct {
31         memory    *Memory
32         client    *ModelClient
33         usagePath string
34 }
35
36 // Run appends the entries. It waits for the builds only before a user message,
37 // as a turn does, so up to buildMax builds run at once in between, and it waits
38 // for all builds at the end. It returns the number of messages appended and why
39 // the run stopped early, or "" when it did not. After a stop it waits for the
40 // calls in flight, so no sent call is cancelled.
41 func (r *BuildRun) Run(ctx context.Context, entries []DatedMessage) (int, string) {
42         appended, reason := r.append(ctx, entries)
43         if reason == "" {
44                 return appended, ""
45         }
46
47         err := r.memory.Wait(ctx)
48         if err != nil {
49                 reason += "; " + err.Error()
50         }
51
52         return appended, reason
53 }
54
55 // append appends the entries and stops at the first stop rule.
56 func (r *BuildRun) append(ctx context.Context, entries []DatedMessage) (int, string) {
57         for n, entry := range entries {
58                 if entry.Kind == "user" {
59                         reason := r.wait(ctx)
60                         if reason != "" {
61                                 return n, reason
62                         }
63                 }
64
65                 if ctx.Err() != nil {
66                         return n, ctx.Err().Error()
67                 }
68
69                 _, err := r.memory.AppendDated(entry.Kind, entry.Text, entry.Date)
70                 if err != nil {
71                         return n, err.Error()
72                 }
73
74                 reason := r.check()
75                 if reason != "" {
76                         return n + 1, reason
77                 }
78         }
79
80         return len(entries), r.wait(ctx)
81 }
82
83 // wait returns when no build runs, or when a stop rule ends the run. It checks
84 // the rules each second, so a run stops soon also during a long wait.
85 func (r *BuildRun) wait(ctx context.Context) string {
86         for {
87                 reason := r.check()
88                 if reason != "" {
89                         return reason
90                 }
91
92                 err := r.waitBuilds(ctx)
93                 if err == nil {
94                         return r.check()
95                 }
96
97                 if ctx.Err() != nil {
98                         return ctx.Err().Error()
99                 }
100         }
101 }
102
103 // check returns why the run must stop, or "" when it can go on.
104 func (r *BuildRun) check() string {
105         if r.client.LimitReached() {
106                 return fmt.Sprintf("call limit of %d reached", r.client.CallLimit())
107         }
108
109         records, err := buildReadUsage(r.usagePath)
110         if err != nil {
111                 return err.Error()
112         }
113
114         return buildStop(records)
115 }
116
117 // waitBuilds waits up to one second without leaving a child context open.
118 func (r *BuildRun) waitBuilds(ctx context.Context) error {
119         ctx, cancel := context.WithTimeout(ctx, time.Second)
120         defer cancel()
121
122         return r.memory.Wait(ctx)
123 }
124
125 // DatedMessage is one message with an optional RFC 3339 date.
126 type DatedMessage struct {
127         Kind string `json:"kind"`
128         Text string `json:"text"`
129         Date string `json:"date,omitempty"`
130 }
131
132 // buildReadUsage reads the records of usage.jsonl at path, in the order of the
133 // start of their calls. The file has them in the order of the end of their
134 // calls, which differs when calls run at once.
135 func buildReadUsage(path string) (records []ModelUsageRecord, err error) {
136         file, err := os.Open(path)
137         if errors.Is(err, os.ErrNotExist) {
138                 return nil, nil
139         }
140
141         if err != nil {
142                 return nil, fmt.Errorf("open usage file: %w", err)
143         }
144
145         defer func() {
146                 closeErr := file.Close()
147                 if closeErr != nil {
148                         err = errors.Join(err, fmt.Errorf("close build usage: %w", closeErr))
149                 }
150         }()
151
152         scanner := bufio.NewScanner(file)
153         scanner.Buffer(make([]byte, 64*1024), buildLineBytes)
154
155         for scanner.Scan() {
156                 var record ModelUsageRecord
157
158                 err = json.Unmarshal(scanner.Bytes(), &record)
159                 if err != nil {
160                         return nil, fmt.Errorf("decode usage: %w", err)
161                 }
162
163                 records = append(records, record)
164         }
165
166         err = scanner.Err()
167         if err != nil {
168                 return nil, fmt.Errorf("read usage file: %w", err)
169         }
170
171         slices.SortStableFunc(records, func(a, b ModelUsageRecord) int {
172                 return buildCallStart(a).Compare(buildCallStart(b))
173         })
174
175         return records, nil
176 }
177
178 // buildWriteReport writes the result of a build run.
179 func buildWriteReport(out io.Writer, appended int, stop string, records []ModelUsageRecord, sizes []int) error {
180         var input, cached, output, failed int
181
182         for _, record := range records {
183                 input += record.InputTokens
184                 cached += record.CachedTokens
185                 output += record.OutputTokens
186                 if record.Error != "" {
187                         failed++
188                 }
189         }
190
191         var lines []string
192
193         if stop != "" {
194                 lines = append(lines, "stopped early: "+stop)
195         }
196
197         lines = append(lines,
198                 fmt.Sprintf("messages: %d", appended),
199                 fmt.Sprintf("calls: %d (%d failed), %.2f per message", len(records), failed, buildRatio(len(records), appended)),
200                 fmt.Sprintf("model lines: %d, retries: %d, %.2f calls per model line", len(sizes), len(records)-failed-len(sizes), buildRatio(len(records), len(sizes))),
201                 fmt.Sprintf("tokens: %d input, %d cached (%.1f%%), %d output", input, cached, 100*buildRatio(cached, input), output),
202         )
203
204         if len(sizes) > 0 {
205                 lines = append(lines, buildSizeText(sizes))
206         }
207
208         if len(records) > 0 {
209                 lines = append(lines, buildTimeText(records))
210         }
211
212         if len(records) > 0 {
213                 lines = append(lines,
214                         "quota at the first call: "+buildQuotaText(records[0]),
215                         "quota at the last call: "+buildQuotaText(records[len(records)-1]),
216                 )
217         }
218
219         _, err := io.WriteString(out, strings.Join(lines, "\n")+"\n")
220         if err != nil {
221                 return fmt.Errorf("write build report: %w", err)
222         }
223
224         return nil
225 }
226
227 // buildStop returns why a build run must stop: a failed call, or a quota window
228 // whose used percent grew by more than buildQuotaLimit points since the first
229 // call. It returns "" when the run can go on.
230 func buildStop(records []ModelUsageRecord) string {
231         for _, record := range records {
232                 if record.Error != "" {
233                         return "call failed: " + record.Error
234                 }
235         }
236
237         if len(records) == 0 {
238                 return ""
239         }
240
241         first := buildUsedPercents(records[0])
242         for name, used := range buildUsedPercents(records[len(records)-1]) {
243                 start, found := first[name]
244                 if found && used-start > buildQuotaLimit {
245                         return fmt.Sprintf("%s grew from %g to %g", name, start, used)
246                 }
247         }
248
249         return ""
250 }
251
252 // buildCallStart returns the start of the call of record, or the zero time when
253 // its date is not valid.
254 func buildCallStart(record ModelUsageRecord) time.Time {
255         start, err := time.Parse(time.RFC3339Nano, record.Date)
256         if err != nil {
257                 return time.Time{}
258         }
259
260         return start
261 }
262
263 // buildRatio returns a / b, or 0 when b is 0.
264 func buildRatio(a, b int) float64 {
265         if b == 0 {
266                 return 0
267         }
268
269         return float64(a) / float64(b)
270 }
271
272 // buildSizeText describes the sizes of the model lines.
273 func buildSizeText(sizes []int) string {
274         sorted := slices.Sorted(slices.Values(sizes))
275         total, over := 0, 0
276         for _, size := range sorted {
277                 total += size
278                 if size > nodeBytes {
279                         over++
280                 }
281         }
282
283         return fmt.Sprintf("line bytes: min %d, median %d, mean %.0f, max %d, %d over %d", sorted[0], sorted[len(sorted)/2], buildRatio(total, len(sorted)), sorted[len(sorted)-1], over, nodeBytes)
284 }
285
286 // buildTimeText describes the time per call and how many calls ran at once. A
287 // call runs from its date for its seconds.
288 func buildTimeText(records []ModelUsageRecord) string {
289         type event struct {
290                 at    time.Time
291                 delta int
292         }
293
294         var events []event
295
296         seconds := make([]float64, 0, len(records))
297         total := 0.0
298         for _, record := range records {
299                 start, err := time.Parse(time.RFC3339Nano, record.Date)
300                 if err != nil {
301                         continue
302                 }
303
304                 length := time.Duration(record.Seconds * float64(time.Second))
305                 events = append(events, event{
306                         at:    start,
307                         delta: 1,
308                 }, event{
309                         at:    start.Add(length),
310                         delta: -1,
311                 })
312
313                 seconds = append(seconds, record.Seconds)
314                 total += record.Seconds
315         }
316
317         if len(events) == 0 {
318                 return "time: no dated calls"
319         }
320
321         slices.SortFunc(events, func(a, b event) int {
322                 return cmp.Or(a.at.Compare(b.at), cmp.Compare(a.delta, b.delta))
323         })
324
325         running, most := 0, 0
326         for _, e := range events {
327                 running += e.delta
328                 most = max(most, running)
329         }
330
331         wall := events[len(events)-1].at.Sub(events[0].at).Seconds()
332
333         slices.Sort(seconds)
334
335         return fmt.Sprintf("time: %.0f s in all, %.1f calls per minute, %.1f s per call (median %.1f, max %.1f), calls at once: at most %d, %.1f on average", wall, 60*float64(len(seconds))/max(wall, 1e-9), total/float64(len(seconds)), seconds[len(seconds)/2], seconds[len(seconds)-1], most, total/max(wall, 1e-9))
336 }
337
338 // buildQuotaText returns the quota headers of record, sorted by name.
339 func buildQuotaText(record ModelUsageRecord) string {
340         names := slices.Sorted(maps.Keys(record.Quota))
341         parts := make([]string, len(names))
342         for n, name := range names {
343                 parts[n] = strings.TrimPrefix(name, "x-codex-") + "=" + record.Quota[name]
344         }
345
346         return strings.Join(parts, " ")
347 }
348
349 // buildUsedPercents returns the used percent of each quota window of record, by
350 // header name.
351 func buildUsedPercents(record ModelUsageRecord) map[string]float64 {
352         used := make(map[string]float64)
353         for name, value := range record.Quota {
354                 if !strings.HasSuffix(name, "-used-percent") {
355                         continue
356                 }
357
358                 percent, err := strconv.ParseFloat(value, 64)
359                 if err == nil {
360                         used[name] = percent
361                 }
362         }
363
364         return used
365 }