]> git.giacomolli.uk - onechat/blob - build_run.go
Name the test group in English
[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         for scanner.Scan() {
155                 var record ModelUsageRecord
156                 err = json.Unmarshal(scanner.Bytes(), &record)
157                 if err != nil {
158                         return nil, fmt.Errorf("decode usage: %w", err)
159                 }
160
161                 records = append(records, record)
162         }
163
164         err = scanner.Err()
165         if err != nil {
166                 return nil, fmt.Errorf("read usage file: %w", err)
167         }
168
169         slices.SortStableFunc(records, func(a, b ModelUsageRecord) int {
170                 return buildCallStart(a).Compare(buildCallStart(b))
171         })
172
173         return records, nil
174 }
175
176 // buildWriteReport writes the result of a build run.
177 func buildWriteReport(out io.Writer, appended int, stop string, records []ModelUsageRecord, sizes []int) error {
178         var input, cached, output, failed int
179         for _, record := range records {
180                 input += record.InputTokens
181                 cached += record.CachedTokens
182                 output += record.OutputTokens
183                 if record.Error != "" {
184                         failed++
185                 }
186         }
187
188         var lines []string
189         if stop != "" {
190                 lines = append(lines, "stopped early: "+stop)
191         }
192
193         lines = append(lines,
194                 fmt.Sprintf("messages: %d", appended),
195                 fmt.Sprintf("calls: %d (%d failed), %.2f per message", len(records), failed, buildRatio(len(records), appended)),
196                 fmt.Sprintf("model lines: %d, retries: %d, %.2f calls per model line", len(sizes), len(records)-failed-len(sizes), buildRatio(len(records), len(sizes))),
197                 fmt.Sprintf("tokens: %d input, %d cached (%.1f%%), %d output", input, cached, 100*buildRatio(cached, input), output),
198         )
199
200         if len(sizes) > 0 {
201                 lines = append(lines, buildSizeText(sizes))
202         }
203
204         if len(records) > 0 {
205                 lines = append(lines, buildTimeText(records))
206         }
207
208         if len(records) > 0 {
209                 lines = append(lines,
210                         "quota at the first call: "+buildQuotaText(records[0]),
211                         "quota at the last call: "+buildQuotaText(records[len(records)-1]),
212                 )
213         }
214
215         _, err := io.WriteString(out, strings.Join(lines, "\n")+"\n")
216         if err != nil {
217                 return fmt.Errorf("write build report: %w", err)
218         }
219
220         return nil
221 }
222
223 // buildStop returns why a build run must stop: a failed call, or a quota window
224 // whose used percent grew by more than buildQuotaLimit points since the first
225 // call. It returns "" when the run can go on.
226 func buildStop(records []ModelUsageRecord) string {
227         for _, record := range records {
228                 if record.Error != "" {
229                         return "call failed: " + record.Error
230                 }
231         }
232
233         if len(records) == 0 {
234                 return ""
235         }
236
237         first := buildUsedPercents(records[0])
238         for name, used := range buildUsedPercents(records[len(records)-1]) {
239                 start, found := first[name]
240                 if found && used-start > buildQuotaLimit {
241                         return fmt.Sprintf("%s grew from %g to %g", name, start, used)
242                 }
243         }
244
245         return ""
246 }
247
248 // buildCallStart returns the start of the call of record, or the zero time when
249 // its date is not valid.
250 func buildCallStart(record ModelUsageRecord) time.Time {
251         start, err := time.Parse(time.RFC3339Nano, record.Date)
252         if err != nil {
253                 return time.Time{}
254         }
255
256         return start
257 }
258
259 // buildRatio returns a / b, or 0 when b is 0.
260 func buildRatio(a, b int) float64 {
261         if b == 0 {
262                 return 0
263         }
264
265         return float64(a) / float64(b)
266 }
267
268 // buildSizeText describes the sizes of the model lines.
269 func buildSizeText(sizes []int) string {
270         sorted := slices.Sorted(slices.Values(sizes))
271         total, over := 0, 0
272         for _, size := range sorted {
273                 total += size
274                 if size > nodeBytes {
275                         over++
276                 }
277         }
278
279         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)
280 }
281
282 // buildTimeText describes the time per call and how many calls ran at once. A
283 // call runs from its date for its seconds.
284 func buildTimeText(records []ModelUsageRecord) string {
285         type event struct {
286                 at    time.Time
287                 delta int
288         }
289
290         var events []event
291         seconds := make([]float64, 0, len(records))
292         total := 0.0
293         for _, record := range records {
294                 start, err := time.Parse(time.RFC3339Nano, record.Date)
295                 if err != nil {
296                         continue
297                 }
298
299                 length := time.Duration(record.Seconds * float64(time.Second))
300                 events = append(events, event{
301                         at:    start,
302                         delta: 1,
303                 }, event{
304                         at:    start.Add(length),
305                         delta: -1,
306                 })
307
308                 seconds = append(seconds, record.Seconds)
309                 total += record.Seconds
310         }
311
312         if len(events) == 0 {
313                 return "time: no dated calls"
314         }
315
316         slices.SortFunc(events, func(a, b event) int {
317                 return cmp.Or(a.at.Compare(b.at), cmp.Compare(a.delta, b.delta))
318         })
319
320         running, most := 0, 0
321         for _, e := range events {
322                 running += e.delta
323                 most = max(most, running)
324         }
325
326         wall := events[len(events)-1].at.Sub(events[0].at).Seconds()
327         slices.Sort(seconds)
328
329         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))
330 }
331
332 // buildQuotaText returns the quota headers of record, sorted by name.
333 func buildQuotaText(record ModelUsageRecord) string {
334         names := slices.Sorted(maps.Keys(record.Quota))
335         parts := make([]string, len(names))
336         for n, name := range names {
337                 parts[n] = strings.TrimPrefix(name, "x-codex-") + "=" + record.Quota[name]
338         }
339
340         return strings.Join(parts, " ")
341 }
342
343 // buildUsedPercents returns the used percent of each quota window of record, by
344 // header name.
345 func buildUsedPercents(record ModelUsageRecord) map[string]float64 {
346         used := make(map[string]float64)
347         for name, value := range record.Quota {
348                 if !strings.HasSuffix(name, "-used-percent") {
349                         continue
350                 }
351
352                 percent, err := strconv.ParseFloat(value, 64)
353                 if err == nil {
354                         used[name] = percent
355                 }
356         }
357
358         return used
359 }