Files
sanderling/cmd/internal-tools/analyze/load.go
T
pj eacf3fd11f fix(analyze): divide per-hour rates by time actually worked
A host asleep mid-run tested nothing, and charging that sleep to an arm reports
it slower for a reason unrelated to the arm. The legend also claimed wall clock
while the number was monotonic. Campaigns written before the split are still
read through the old field name so their run hours do not silently zero.

Claude-Session: https://claude.ai/code/session_01A5KmftdEJ49A9z5mF5ESrX
2026-08-14 17:43:12 +05:30

273 lines
8.7 KiB
Go

package main
import (
"bufio"
"encoding/json"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
)
const (
manifestFileName = "campaign.json"
recordsFileName = "runs.jsonl"
maxRecordBytes = 4 * 1024 * 1024
)
// manifest mirrors the fields analyze reads from campaign.json. The step budget
// lives here rather than in any run, because it is what clean runs are censored
// at and every run in an arm has to share it.
type manifest struct {
Arm string `json:"arm"`
Generator string `json:"generator"`
Platform string `json:"platform"`
MaxSteps int `json:"max_steps"`
Seeds []int64 `json:"seeds"`
Host string `json:"host"`
}
// runRecord mirrors the fields analyze reads from one line of runs.jsonl.
type runRecord struct {
Seed int64 `json:"seed"`
ExitCode int `json:"exit_code"`
LaunchError string `json:"launch_error"`
TimedOut bool `json:"timed_out"`
// MonotonicMillis is how long the run worked, and it is what every
// per-hour rate here divides by: a host asleep mid-run tested nothing, so
// charging that time to the arm would report it as slower for a reason
// that has nothing to do with the arm. The wall clock the campaign also
// records answers the other question, how much time passed.
MonotonicMillis int64 `json:"monotonic_millis"`
// DurationMillis is the name campaigns written before the two clocks were
// split gave the same monotonic reading, so those files still read.
DurationMillis int64 `json:"duration_millis"`
TraceError string `json:"trace_error"`
Steps int `json:"steps"`
FirstViolationOriginStep *int `json:"first_violation_origin_step"`
ViolatedProperties []string `json:"violated_properties"`
// Actions is the count of steps that dispatched an action, and it is a
// pointer so that a runs.jsonl written before the campaign tool counted
// them is refused rather than read as an arm that acted zero times. The
// campaign tool always emits the field, so its absence dates the file.
Actions *int `json:"actions"`
}
// Exclusion reasons. A run that failed or timed out is missing data, not a
// censored observation: it never ran its budget, so treating it as a clean run
// that survived to the budget would bias the survival estimate downward.
const (
reasonLaunchError = "launch error"
reasonTimedOut = "timed out"
reasonNonzeroExit = "nonzero exit"
reasonTraceError = "unreadable trace"
reasonMalformedStep = "violation step outside the budget"
)
type classifiedRun struct {
Seed int64
Steps int
Actions int
MonotonicMillis int64
OriginStep int
Violated bool
ClampedToBudget bool
ViolatedProperties []string
ExcludedBecause string
}
type arm struct {
Name string
Budget int
Generator string
Platform string
Directories []string
Runs []classifiedRun
MissingSeeds []int64
}
func loadCampaign(directory string) (manifest, []runRecord, error) {
body, err := os.ReadFile(filepath.Join(directory, manifestFileName))
if err != nil {
return manifest{}, nil, fmt.Errorf("read %s: %w", manifestFileName, err)
}
var declared manifest
if err := json.Unmarshal(body, &declared); err != nil {
return manifest{}, nil, fmt.Errorf("parse %s in %s: %w", manifestFileName, directory, err)
}
if declared.Arm == "" {
return manifest{}, nil, fmt.Errorf("%s in %s has no arm", manifestFileName, directory)
}
if declared.MaxSteps <= 0 {
return manifest{}, nil, fmt.Errorf("%s in %s has max_steps %d: clean runs have nothing to be censored at",
manifestFileName, directory, declared.MaxSteps)
}
file, err := os.Open(filepath.Join(directory, recordsFileName))
if err != nil {
return manifest{}, nil, fmt.Errorf("read %s: %w", recordsFileName, err)
}
defer file.Close()
var records []runRecord
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 0, 64*1024), maxRecordBytes)
lineNumber := 0
for scanner.Scan() {
lineNumber++
raw := strings.TrimSpace(scanner.Text())
if raw == "" {
continue
}
var record runRecord
if err := json.Unmarshal([]byte(raw), &record); err != nil {
return manifest{}, nil, fmt.Errorf("%s line %d in %s: %w", recordsFileName, lineNumber, directory, err)
}
if record.Actions == nil {
return manifest{}, nil, fmt.Errorf("%s line %d in %s has no actions count: it was written before "+
"dispatched actions were counted, and reading the missing count as zero would report every "+
"per-action rate wrongly; re-run the campaign to produce it",
recordsFileName, lineNumber, directory)
}
records = append(records, record)
}
if err := scanner.Err(); err != nil {
return manifest{}, nil, fmt.Errorf("read %s in %s: %w", recordsFileName, directory, err)
}
return declared, records, nil
}
func (r runRecord) workingMillis() int64 {
if r.MonotonicMillis != 0 {
return r.MonotonicMillis
}
return r.DurationMillis
}
// classify turns one record into the run the analysis works with, deciding
// whether it is usable and, if it is, whether it is an event or censored.
func classify(record runRecord, budget int) classifiedRun {
item := classifiedRun{
Seed: record.Seed,
Steps: record.Steps,
MonotonicMillis: record.workingMillis(),
ViolatedProperties: slices.Clone(record.ViolatedProperties),
}
if record.Actions != nil {
item.Actions = *record.Actions
}
switch {
case record.LaunchError != "":
item.ExcludedBecause = reasonLaunchError
return item
case record.TimedOut:
item.ExcludedBecause = reasonTimedOut
return item
case record.ExitCode != 0:
item.ExcludedBecause = reasonNonzeroExit
return item
case record.TraceError != "":
item.ExcludedBecause = reasonTraceError
return item
}
if record.FirstViolationOriginStep == nil {
if len(record.ViolatedProperties) > 0 {
item.ExcludedBecause = reasonMalformedStep
}
return item
}
origin := *record.FirstViolationOriginStep
if origin < 1 {
item.ExcludedBecause = reasonMalformedStep
return item
}
item.Violated = true
if origin > budget {
// The run-end finalize line reports obligations that never discharged
// at an index one past the last executed step. That is a real detection
// but not a real step, so it is held at the budget and counted.
origin = budget
item.ClampedToBudget = true
}
item.OriginStep = origin
return item
}
// groupArms folds every campaign directory into its arm. Two directories with
// the same arm label are pooled, which is how a campaign split across hosts is
// analysed, but they must agree on the step budget.
func groupArms(directories []string) ([]arm, error) {
byName := map[string]*arm{}
var order []string
for _, directory := range directories {
declared, records, err := loadCampaign(directory)
if err != nil {
return nil, err
}
current, seen := byName[declared.Arm]
if !seen {
current = &arm{
Name: declared.Arm,
Budget: declared.MaxSteps,
Generator: declared.Generator,
Platform: declared.Platform,
}
byName[declared.Arm] = current
order = append(order, declared.Arm)
}
if current.Budget != declared.MaxSteps {
return nil, fmt.Errorf("arm %q has step budget %d in an earlier campaign and %d in %s: "+
"runs censored at different budgets cannot be pooled",
declared.Arm, current.Budget, declared.MaxSteps, directory)
}
current.Directories = append(current.Directories, directory)
present := map[int64]bool{}
for _, record := range records {
present[record.Seed] = true
current.Runs = append(current.Runs, classify(record, declared.MaxSteps))
}
for _, seed := range declared.Seeds {
if !present[seed] {
current.MissingSeeds = append(current.MissingSeeds, seed)
}
}
}
slices.Sort(order)
arms := make([]arm, 0, len(order))
for _, name := range order {
arms = append(arms, *byName[name])
}
return arms, nil
}
// observations returns the usable runs as survival data: an event at the step
// that armed the first violation, or a censored observation at the step budget.
func (a arm) observations() []observation {
var result []observation
for _, item := range a.Runs {
if item.ExcludedBecause != "" {
continue
}
if item.Violated {
result = append(result, observation{Steps: float64(item.OriginStep), Event: true})
continue
}
result = append(result, observation{Steps: float64(a.Budget), Event: false})
}
return result
}
// stepTimes is the observations flattened to plain numbers, censored runs held
// at the budget. Holding them there rather than dropping them is conservative:
// it can only understate how much sooner a violating arm finds its first
// defect, never overstate it.
func (a arm) stepTimes() []float64 {
var result []float64
for _, item := range a.observations() {
result = append(result, item.Steps)
}
return result
}