mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
A run stops at whichever comes first, the step budget or --duration, so a clean run that reached the wall clock exited with fewer steps than the budget and was still credited with the whole of it. The model arm pays a network call and a screenshot per step, so it reaches the wall sooner and was handed exposure it never had. Nothing checked that two arms shared a budget either. Thirty identical clean runs under budgets of 400 and 100 read a12 0.000 and p 1.685e-14 from the rank-sum while the log-rank in the same report read p 1.0000. groupArms already refused this within one arm. The claims the old convention left in comments and report lines are corrected rather than left standing beside the new behaviour.
219 lines
8.2 KiB
Go
219 lines
8.2 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func stepPointer(value int) *int { return &value }
|
|
|
|
func TestClassify_FailedAndTimedOutRunsAreMissingDataNotCensored(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
record runRecord
|
|
reason string
|
|
}{
|
|
{"launch error", runRecord{LaunchError: "fork/exec: no such file"}, reasonLaunchError},
|
|
{"timed out", runRecord{TimedOut: true, ExitCode: -1}, reasonTimedOut},
|
|
{"nonzero exit", runRecord{ExitCode: 3}, reasonNonzeroExit},
|
|
{"unreadable trace", runRecord{TraceError: "no run directory with meta.json"}, reasonTraceError},
|
|
{"violation at step zero", runRecord{FirstViolationOriginStep: stepPointer(0)}, reasonMalformedStep},
|
|
{"violation without a step", runRecord{ViolatedProperties: []string{"cartTotal"}}, reasonMalformedStep},
|
|
}
|
|
for _, test := range cases {
|
|
item := classify(test.record, 50)
|
|
if item.ExcludedBecause != test.reason {
|
|
t.Errorf("%s: excluded because %q, want %q", test.name, item.ExcludedBecause, test.reason)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A run also ends when the campaign's wall clock does, so a clean run can stop
|
|
// well short of the budget. Censoring it at the budget would credit it with
|
|
// steps it never ran, and a slower arm loses fewer steps in the same wall clock
|
|
// than a fast one, so the credit does not cancel between arms.
|
|
func TestClassify_CleanRunIsCensoredAtTheStepsItRan(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
steps int
|
|
budget int
|
|
censored float64
|
|
}{
|
|
{"stopped by the wall clock short of the budget", 12, 400, 12},
|
|
{"ran the whole budget", 400, 400, 400},
|
|
{"recorded more steps than the manifest budget", 420, 400, 400},
|
|
}
|
|
for _, test := range cases {
|
|
item := classify(runRecord{Seed: 4, Steps: test.steps, DurationMillis: 1000}, test.budget)
|
|
if item.ExcludedBecause != "" || item.Violated {
|
|
t.Fatalf("%s: run %+v, want a usable clean run", test.name, item)
|
|
}
|
|
current := arm{Budget: test.budget, Runs: []classifiedRun{item}}
|
|
observations := current.observations()
|
|
if len(observations) != 1 || observations[0].Event || observations[0].Steps != test.censored {
|
|
t.Errorf("%s: observations %+v, want one censored observation at %v",
|
|
test.name, observations, test.censored)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestClassify_ViolationIsAnEventAtTheOriginStep(t *testing.T) {
|
|
item := classify(runRecord{Seed: 5, Steps: 12, FirstViolationOriginStep: stepPointer(7)}, 50)
|
|
if !item.Violated || item.OriginStep != 7 || item.ClampedToBudget {
|
|
t.Fatalf("run %+v, want an unclamped event at step 7", item)
|
|
}
|
|
current := arm{Budget: 50, Runs: []classifiedRun{item}}
|
|
observations := current.observations()
|
|
if len(observations) != 1 || !observations[0].Event || observations[0].Steps != 7 {
|
|
t.Errorf("observations %+v, want one event at 7", observations)
|
|
}
|
|
}
|
|
|
|
// The run-end finalize line reports at an index one past the last executed step,
|
|
// so an origin past the budget is held at the budget and counted rather than
|
|
// silently turned into a censored run.
|
|
func TestClassify_ViolationPastTheBudgetIsHeldAtTheBudget(t *testing.T) {
|
|
item := classify(runRecord{FirstViolationOriginStep: stepPointer(51)}, 50)
|
|
if !item.Violated || item.EventStep != 50 || !item.ClampedToBudget {
|
|
t.Errorf("run %+v, want a clamped event at 50", item)
|
|
}
|
|
}
|
|
|
|
func writeCampaign(t *testing.T, directory string, declared map[string]any, records []map[string]any) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(directory, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body, err := json.MarshalIndent(declared, "", " ")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(directory, manifestFileName), append(body, '\n'), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var lines strings.Builder
|
|
for _, record := range records {
|
|
line, err := json.Marshal(record)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
lines.Write(line)
|
|
lines.WriteByte('\n')
|
|
}
|
|
if err := os.WriteFile(filepath.Join(directory, recordsFileName), []byte(lines.String()), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestClassify_CarriesTheDispatchedActionCount(t *testing.T) {
|
|
actions := 7
|
|
item := classify(runRecord{Seed: 4, Steps: 30, Actions: &actions}, 50)
|
|
if item.Steps != 30 || item.Actions != 7 {
|
|
t.Errorf("run %+v, want 30 steps and 7 actions", item)
|
|
}
|
|
}
|
|
|
|
func TestGroupArms_PoolsDirectoriesSharingAnArmAndReportsMissingSeeds(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeCampaign(t, filepath.Join(root, "north"), map[string]any{
|
|
"arm": "seeded", "max_steps": 40, "seeds": []int{1, 2, 3},
|
|
}, []map[string]any{
|
|
{"seed": 1, "exit_code": 0, "steps": 40, "actions": 33},
|
|
{"seed": 2, "exit_code": 0, "steps": 9, "actions": 8, "first_violation_origin_step": 9},
|
|
})
|
|
writeCampaign(t, filepath.Join(root, "south"), map[string]any{
|
|
"arm": "seeded", "max_steps": 40, "seeds": []int{4},
|
|
}, []map[string]any{
|
|
{"seed": 4, "exit_code": 0, "steps": 40, "actions": 40},
|
|
})
|
|
|
|
arms, err := groupArms([]string{filepath.Join(root, "north"), filepath.Join(root, "south")})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(arms) != 1 {
|
|
t.Fatalf("%d arms, want 1", len(arms))
|
|
}
|
|
if len(arms[0].Runs) != 3 {
|
|
t.Errorf("%d runs, want 3", len(arms[0].Runs))
|
|
}
|
|
if len(arms[0].MissingSeeds) != 1 || arms[0].MissingSeeds[0] != 3 {
|
|
t.Errorf("missing seeds %v, want [3]", arms[0].MissingSeeds)
|
|
}
|
|
if len(arms[0].Directories) != 2 {
|
|
t.Errorf("directories %v, want both", arms[0].Directories)
|
|
}
|
|
}
|
|
|
|
func TestGroupArms_RejectsDisagreeingStepBudgets(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeCampaign(t, filepath.Join(root, "a"), map[string]any{"arm": "seeded", "max_steps": 40, "seeds": []int{1}},
|
|
[]map[string]any{{"seed": 1, "exit_code": 0, "steps": 40, "actions": 40}})
|
|
writeCampaign(t, filepath.Join(root, "b"), map[string]any{"arm": "seeded", "max_steps": 80, "seeds": []int{2}},
|
|
[]map[string]any{{"seed": 2, "exit_code": 0, "steps": 80, "actions": 80}})
|
|
|
|
_, err := groupArms([]string{filepath.Join(root, "a"), filepath.Join(root, "b")})
|
|
if err == nil || !strings.Contains(err.Error(), "different budgets") {
|
|
t.Fatalf("error %v, want a refusal to pool different budgets", err)
|
|
}
|
|
}
|
|
|
|
func TestGroupArms_RejectsAMissingStepBudget(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeCampaign(t, filepath.Join(root, "a"), map[string]any{"arm": "seeded", "seeds": []int{1}},
|
|
[]map[string]any{{"seed": 1, "exit_code": 0}})
|
|
_, err := groupArms([]string{filepath.Join(root, "a")})
|
|
if err == nil || !strings.Contains(err.Error(), "censored at") {
|
|
t.Fatalf("error %v, want a complaint about max_steps", err)
|
|
}
|
|
}
|
|
|
|
func TestGroupArms_ReportsBadRecordLines(t *testing.T) {
|
|
root := t.TempDir()
|
|
directory := filepath.Join(root, "a")
|
|
writeCampaign(t, directory, map[string]any{"arm": "seeded", "max_steps": 40, "seeds": []int{1}}, nil)
|
|
if err := os.WriteFile(filepath.Join(directory, recordsFileName),
|
|
[]byte("{\"seed\":1,\"actions\":0}\nnot json\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err := groupArms([]string{directory})
|
|
if err == nil || !strings.Contains(err.Error(), "line 2") {
|
|
t.Fatalf("error %v, want the offending line number", err)
|
|
}
|
|
}
|
|
|
|
// A runs.jsonl written before the campaign counted dispatched actions has no
|
|
// such field. Reading the absence as zero would divide by zero, so the whole
|
|
// campaign is refused instead.
|
|
func TestGroupArms_RefusesRecordsWithoutADispatchedActionCount(t *testing.T) {
|
|
root := t.TempDir()
|
|
directory := filepath.Join(root, "old-format")
|
|
writeCampaign(t, directory, map[string]any{"arm": "seeded", "max_steps": 40, "seeds": []int{1, 2}},
|
|
[]map[string]any{
|
|
{"seed": 1, "exit_code": 0, "steps": 40, "actions": 40},
|
|
{"seed": 2, "exit_code": 0, "steps": 40},
|
|
})
|
|
_, err := groupArms([]string{directory})
|
|
if err == nil {
|
|
t.Fatal("a runs.jsonl without an action count was accepted")
|
|
}
|
|
for _, fragment := range []string{"line 2", "actions", directory} {
|
|
if !strings.Contains(err.Error(), fragment) {
|
|
t.Errorf("error %q is missing %q", err, fragment)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGroupArms_RefusesAnExcludedRecordWithoutAnActionCount(t *testing.T) {
|
|
root := t.TempDir()
|
|
directory := filepath.Join(root, "old-format")
|
|
writeCampaign(t, directory, map[string]any{"arm": "seeded", "max_steps": 40, "seeds": []int{1}},
|
|
[]map[string]any{{"seed": 1, "exit_code": 3}})
|
|
if _, err := groupArms([]string{directory}); err == nil {
|
|
t.Fatal("an old-format record was accepted because the run was excluded anyway")
|
|
}
|
|
}
|