| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | "time" |
| 10 | ) |
| 11 | |
| 12 | func writeTaskVerify(t *testing.T, taskDir, script string) { |
| 13 | t.Helper() |
| 14 | if err := os.MkdirAll(taskDir, 0o755); err != nil { |
| 15 | t.Fatal(err) |
| 16 | } |
| 17 | if err := os.WriteFile(filepath.Join(taskDir, "verify.sh"), []byte(script), 0o644); err != nil { |
| 18 | t.Fatal(err) |
| 19 | } |
| 20 | } |
| 21 | |
| 22 | func TestGradeCheckpointsFindsEarliestCorrectState(t *testing.T) { |
| 23 | taskDir := filepath.Join(t.TempDir(), "task") |
| 24 | writeTaskVerify(t, taskDir, "#!/usr/bin/env bash\ngrep -q done answer.txt\n") |
| 25 | |
| 26 | snaps := t.TempDir() |
| 27 | mk := func(seq int, elapsed int64, content string) checkpoint { |
| 28 | dir := filepath.Join(snaps, filepath.Base(strings.ReplaceAll(content, " ", "-"))+"-cp") |
| 29 | dir = filepath.Join(snaps, filepath.Base(dir)+"-"+strings.ReplaceAll(content, " ", "_")) |
| 30 | if err := os.MkdirAll(dir, 0o755); err != nil { |
| 31 | t.Fatal(err) |
| 32 | } |
| 33 | if err := os.WriteFile(filepath.Join(dir, "answer.txt"), []byte(content), 0o644); err != nil { |
| 34 | t.Fatal(err) |
| 35 | } |
| 36 | return checkpoint{Seq: seq, ElapsedMs: elapsed, dir: dir} |
| 37 | } |
| 38 | checkpoints := gradeCheckpoints([]checkpoint{ |
| 39 | mk(1, 18_000, "not yet"), |
| 40 | mk(2, 63_000, "done"), |
| 41 | mk(3, 128_000, "done and polished"), |
| 42 | }, taskDir) |
| 43 | if checkpoints[0].Pass || !checkpoints[1].Pass || !checkpoints[2].Pass { |
| 44 | t.Fatalf("pass flags = %+v", checkpoints) |
| 45 | } |
| 46 | |
| 47 | firstMs, broke := firstCorrect(checkpoints, true) |
| 48 | if firstMs != 63_000 || broke { |
| 49 | t.Fatalf("firstCorrect = %d, %v; want 63000, false", firstMs, broke) |
| 50 | } |
| 51 | |
| 52 | // A run whose final state failed after a passing snapshot is the |
| 53 | // solved-then-broke alarm. |
| 54 | firstMs, broke = firstCorrect(checkpoints, false) |
| 55 | if firstMs != 63_000 || !broke { |
| 56 | t.Fatalf("solved-then-broke = %d, %v; want 63000, true", firstMs, broke) |
| 57 | } |
| 58 | |
| 59 | if ms, broke := firstCorrect([]checkpoint{{Seq: 1, ElapsedMs: 5, dir: snaps}}, true); ms != 0 || broke { |
| 60 | t.Fatalf("no passing snapshot must yield 0/false, got %d/%v", ms, broke) |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | func TestSnapshotterCapturesWorkspaceChanges(t *testing.T) { |
| 65 | work := t.TempDir() |
| 66 | dst := t.TempDir() |
| 67 | if err := os.WriteFile(filepath.Join(work, "code.py"), []byte("v1"), 0o644); err != nil { |
| 68 | t.Fatal(err) |
| 69 | } |
| 70 | polls := make(chan time.Time) |
| 71 | acks := make(chan struct{}) |
| 72 | snap := startSnapshotterWithPoll(work, dst, time.Now(), polls, acks) |
| 73 | poll := func() { |
| 74 | t.Helper() |
| 75 | polls <- time.Time{} |
| 76 | <-acks |
| 77 | } |
| 78 | poll() |
| 79 | // The metrics sidecar updating must not trigger a snapshot on its own. |
| 80 | if err := os.WriteFile(filepath.Join(work, ".run-metrics.json"), []byte("{}"), 0o644); err != nil { |
| 81 | t.Fatal(err) |
| 82 | } |
| 83 | poll() |
| 84 | if err := os.WriteFile(filepath.Join(work, "code.py"), []byte("v2 changed"), 0o644); err != nil { |
| 85 | t.Fatal(err) |
| 86 | } |
| 87 | poll() |
| 88 | taken := snap.halt() |
| 89 | |
| 90 | if len(taken) != 1 { |
| 91 | t.Fatalf("snapshots = %d (%+v), want exactly 1 (the code change; metrics writes excluded)", len(taken), taken) |
| 92 | } |
| 93 | data, err := os.ReadFile(filepath.Join(taken[0].dir, "code.py")) |
| 94 | if err != nil || string(data) != "v2 changed" { |
| 95 | t.Fatalf("snapshot content = %q, %v", data, err) |
| 96 | } |
| 97 | if _, err := os.Stat(filepath.Join(taken[0].dir, ".run-metrics.json")); !os.IsNotExist(err) { |
| 98 | t.Fatal("metrics sidecar must be stripped from snapshots") |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | func TestKPILineIncludesTTFCS(t *testing.T) { |
| 103 | r := result{task: task{ID: "a"}, Passed: true, WallMs: 142_000, Attempt: 1, TTCSMs: 142_000} |
| 104 | r.FirstCorrectMs = 63_000 |
| 105 | r.PostSolveWasteMs = 79_000 |
| 106 | got := renderBody([]result{r}) |
| 107 | for _, want := range []string{ |
| 108 | "**TTFCS median** 1m03s", |
| 109 | "**post-solve waste median** 1m19s", |
| 110 | } { |
| 111 | if !strings.Contains(got, want) { |
| 112 | t.Fatalf("KPI line missing %q:\n%s", want, got) |
| 113 | } |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | func TestSolveProfileTriage(t *testing.T) { |
| 118 | cp := []checkpoint{{Seq: 1, ElapsedMs: 1000}} |
| 119 | early := result{Passed: true, WallMs: 140_000, FirstCorrectMs: 55_000, PostSolveWasteMs: 85_000, Checkpoints: cp} |
| 120 | late := result{Passed: true, WallMs: 140_000, FirstCorrectMs: 132_000, PostSolveWasteMs: 8_000, Checkpoints: cp} |
| 121 | never := result{Passed: false, Checkpoints: cp} |
| 122 | broke := result{Passed: false, SolvedThenBroken: true, Checkpoints: cp} |
| 123 | finalOnly := result{Passed: true, WallMs: 30_000, Checkpoints: cp} |
| 124 | off := result{Passed: true} |
| 125 | |
| 126 | for want, r := range map[string]result{ |
| 127 | "early_correct": early, "late_correct": late, "never_correct": never, |
| 128 | "solved_then_broke": broke, "": off, |
| 129 | } { |
| 130 | if got := solveProfile(r); got != want { |
| 131 | t.Fatalf("solveProfile = %q, want %q", got, want) |
| 132 | } |
| 133 | } |
| 134 | if got := solveProfile(finalOnly); got != "late_correct" { |
| 135 | t.Fatalf("final-only pass = %q, want late_correct", got) |
| 136 | } |
| 137 | |
| 138 | line := renderSolveProfiles([]result{early, late, never, broke}) |
| 139 | for _, want := range []string{ |
| 140 | "**early_correct** 1 (median waste 1m25s)", |
| 141 | "**late_correct** 1", |
| 142 | "**never_correct** 1", |
| 143 | "**solved_then_broke** 1", |
| 144 | } { |
| 145 | if !strings.Contains(line, want) { |
| 146 | t.Fatalf("solve profile line missing %q:\n%s", want, line) |
| 147 | } |
| 148 | } |
| 149 | if renderSolveProfiles([]result{off}) != "" { |
| 150 | t.Fatal("uncheckpointed suites must not render the line") |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | func TestCorrectBoundaryMetrics(t *testing.T) { |
| 155 | cps := []checkpoint{ |
| 156 | {Seq: 1, ElapsedMs: 10, Pass: false}, |
| 157 | {Seq: 2, ElapsedMs: 20, Pass: false}, |
| 158 | {Seq: 3, ElapsedMs: 30, Pass: true}, |
| 159 | {Seq: 4, ElapsedMs: 40, Pass: false}, |
| 160 | {Seq: 5, ElapsedMs: 50, Pass: true}, |
| 161 | } |
| 162 | if got := mutationsBeforeCorrect(cps); got != 2 { |
| 163 | t.Fatalf("mutations before correct = %d, want 2", got) |
| 164 | } |
| 165 | if !regressedAfterCorrect(cps) { |
| 166 | t.Fatal("PASS→FAIL→PASS must count as a regression even though it was repaired") |
| 167 | } |
| 168 | if regressedAfterCorrect(cps[:3]) { |
| 169 | t.Fatal("no regression before the first failure-after-pass") |
| 170 | } |
| 171 | if got := mutationsBeforeCorrect(cps[:2]); got != 2 { |
| 172 | t.Fatalf("all-failing run: mutations = %d, want len", got) |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | func TestRoundsSplitAt(t *testing.T) { |
| 177 | path := filepath.Join(t.TempDir(), "split.trajectory.jsonl") |
| 178 | lines := []string{ |
| 179 | `{"seq":1,"ts":1000,"event":{"kind":"turn_started"}}`, |
| 180 | `{"seq":2,"ts":2000,"event":{"kind":"tool_dispatch","tool":{"id":"a","name":"write_file"}}}`, |
| 181 | `{"seq":3,"ts":2100,"event":{"kind":"tool_result","tool":{"id":"a","name":"write_file","durationMs":100}}}`, |
| 182 | `{"seq":4,"ts":3000,"event":{"kind":"tool_dispatch","tool":{"id":"b","name":"bash"}}}`, |
| 183 | `{"seq":5,"ts":3200,"event":{"kind":"tool_result","tool":{"id":"b","name":"bash","readOnly":true,"durationMs":200,"execution":{"verification":"passed"}}}}`, |
| 184 | `{"seq":6,"ts":5000,"event":{"kind":"tool_dispatch","tool":{"id":"c","name":"bash"}}}`, |
| 185 | `{"seq":7,"ts":5200,"event":{"kind":"tool_result","tool":{"id":"c","name":"bash","readOnly":true,"durationMs":200,"execution":{"verification":"passed"}}}}`, |
| 186 | `{"seq":8,"ts":6000,"event":{"kind":"tool_dispatch","tool":{"id":"d","name":"read_file","readOnly":true}}}`, |
| 187 | `{"seq":9,"ts":6100,"event":{"kind":"tool_result","tool":{"id":"d","name":"read_file","readOnly":true,"durationMs":100}}}`, |
| 188 | `{"seq":10,"ts":7000,"event":{"kind":"turn_done"}}`, |
| 189 | } |
| 190 | if err := writeLines(path, lines); err != nil { |
| 191 | t.Fatal(err) |
| 192 | } |
| 193 | split := splitAtCorrect(path, 4000) |
| 194 | if split.RoundsBefore != 2 || split.RoundsAfter != 2 || split.VerifyAfter != 1 { |
| 195 | t.Fatalf("split = %+v, want 2 rounds before, 2 after, 1 verification after", split) |
| 196 | } |
| 197 | if split.CallsBefore != 2 || split.CallsAfter != 2 { |
| 198 | t.Fatalf("calls = %d/%d, want 2/2", split.CallsBefore, split.CallsAfter) |
| 199 | } |
| 200 | if split.MutationsAfter != 0 { |
| 201 | t.Fatalf("read-only tail must count no mutations, got %d", split.MutationsAfter) |
| 202 | } |
| 203 | } |
| 204 | |
| 205 | func TestComputeStopEvalCurveAndHarmfulContinuations(t *testing.T) { |
| 206 | cps := []checkpoint{ |
| 207 | {Seq: 1, ElapsedMs: 8_000, Pass: false}, |
| 208 | {Seq: 2, ElapsedMs: 18_000, Pass: true}, |
| 209 | {Seq: 3, ElapsedMs: 28_000, Pass: true}, |
| 210 | {Seq: 4, ElapsedMs: 38_000, Pass: false}, // the "improvement" that broke it |
| 211 | {Seq: 5, ElapsedMs: 48_000, Pass: true}, |
| 212 | } |
| 213 | rounds := []int64{10_000, 20_000, 30_000, 40_000, 50_000} |
| 214 | eval := computeStopEval(cps, rounds) |
| 215 | want := []bool{false, true, true, false, true} |
| 216 | for i, pass := range want { |
| 217 | if eval.Curve[i] != pass { |
| 218 | t.Fatalf("curve = %v, want %v", eval.Curve, want) |
| 219 | } |
| 220 | } |
| 221 | if eval.FirstStoppableRound != 2 { |
| 222 | t.Fatalf("first stoppable = %d, want 2", eval.FirstStoppableRound) |
| 223 | } |
| 224 | if eval.HarmfulContinuation != 1 { |
| 225 | t.Fatalf("harmful continuations = %d, want 1 (round 4 destroyed a passing state)", eval.HarmfulContinuation) |
| 226 | } |
| 227 | if eval.ContinuationsPast != 3 { |
| 228 | t.Fatalf("continuations past stoppable = %d, want 3", eval.ContinuationsPast) |
| 229 | } |
| 230 | |
| 231 | if computeStopEval(nil, rounds) != nil || computeStopEval(cps, nil) != nil { |
| 232 | t.Fatal("missing inputs must yield no eval") |
| 233 | } |
| 234 | // A boundary before any snapshot grades as the seed: fail. |
| 235 | early := computeStopEval(cps, []int64{1_000}) |
| 236 | if early.Curve[0] || early.FirstStoppableRound != 0 { |
| 237 | t.Fatalf("pre-snapshot boundary must fail: %+v", early) |
| 238 | } |
| 239 | } |
| 240 | |
| 241 | func TestOverthinkingDamageRateInKPIAndCompare(t *testing.T) { |
| 242 | damaged := result{task: task{ID: "a"}, Passed: true, WallMs: 60_000, Attempt: 1, TTCSMs: 60_000} |
| 243 | damaged.FirstCorrectMs = 20_000 |
| 244 | damaged.PostSolveWasteMs = 40_000 |
| 245 | damaged.RegressedAfterCorrect = true |
| 246 | clean := result{task: task{ID: "b"}, Passed: true, WallMs: 30_000, Attempt: 1, TTCSMs: 30_000} |
| 247 | clean.FirstCorrectMs = 25_000 |
| 248 | clean.PostSolveWasteMs = 5_000 |
| 249 | |
| 250 | got := renderBody([]result{damaged, clean}) |
| 251 | if !strings.Contains(got, "**overthinking damage** 50%") { |
| 252 | t.Fatalf("KPI line missing damage rate:\n%s", got) |
| 253 | } |
| 254 | } |
| 255 | |
| 256 | func TestFirstUsefulMutationApproximatesTTFUM(t *testing.T) { |
| 257 | seed := t.TempDir() |
| 258 | final := t.TempDir() |
| 259 | snaps := t.TempDir() |
| 260 | write := func(dir, name, content string) { |
| 261 | t.Helper() |
| 262 | if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { |
| 263 | t.Fatal(err) |
| 264 | } |
| 265 | } |
| 266 | write(seed, "util.py", "v0") |
| 267 | write(seed, "keep.py", "same") |
| 268 | write(final, "util.py", "final fix") |
| 269 | write(final, "keep.py", "same") |
| 270 | write(final, "helper.py", "created") |
| 271 | write(final, "verify.sh", "grader") |
| 272 | |
| 273 | mk := func(seq int, elapsed int64, utilContent string) checkpoint { |
| 274 | dir := filepath.Join(snaps, fmt.Sprintf("%03d", seq)) |
| 275 | if err := os.MkdirAll(dir, 0o755); err != nil { |
| 276 | t.Fatal(err) |
| 277 | } |
| 278 | write(dir, "util.py", utilContent) |
| 279 | return checkpoint{Seq: seq, ElapsedMs: elapsed, dir: dir} |
| 280 | } |
| 281 | cps := []checkpoint{ |
| 282 | mk(1, 10_000, "wrong attempt"), |
| 283 | mk(2, 20_000, "final fix"), // part of the final solution appears |
| 284 | mk(3, 30_000, "final fix"), |
| 285 | } |
| 286 | if got := firstUsefulMutation(cps, seed, final); got != 20_000 { |
| 287 | t.Fatalf("TTFUM = %d, want 20000", got) |
| 288 | } |
| 289 | |
| 290 | // A created file reaching its final content also counts. |
| 291 | write(filepath.Join(snaps, "001"), "helper.py", "created") |
| 292 | if got := firstUsefulMutation(cps, seed, final); got != 10_000 { |
| 293 | t.Fatalf("TTFUM with created file = %d, want 10000", got) |
| 294 | } |
| 295 | |
| 296 | // Unchanged and harness files are never solution files. |
| 297 | files := solutionFiles(seed, final) |
| 298 | if _, ok := files["keep.py"]; ok { |
| 299 | t.Fatal("unchanged file counted as solution") |
| 300 | } |
| 301 | if _, ok := files["verify.sh"]; ok { |
| 302 | t.Fatal("grader counted as solution") |
| 303 | } |
| 304 | if len(files) != 2 { |
| 305 | t.Fatalf("solution files = %v", files) |
| 306 | } |
| 307 | } |
| 308 | |
| 309 | func TestDiagnosisAppliesThePriorityTree(t *testing.T) { |
| 310 | cps := []checkpoint{{Seq: 1, ElapsedMs: 1000}} |
| 311 | wasteful := result{task: task{ID: "a"}, Passed: true, WallMs: 100_000, Checkpoints: cps, |
| 312 | FirstCorrectMs: 30_000, PostSolveWasteMs: 70_000, FirstUsefulMs: 20_000} |
| 313 | explorer := result{task: task{ID: "b"}, Passed: true, WallMs: 100_000, Checkpoints: cps, |
| 314 | FirstCorrectMs: 90_000, PostSolveWasteMs: 10_000, FirstUsefulMs: 85_000} |
| 315 | never := result{task: task{ID: "c"}, Checkpoints: cps} |
| 316 | broken := result{task: task{ID: "d"}, Passed: true, WallMs: 50_000, Checkpoints: cps, |
| 317 | FirstCorrectMs: 10_000, PostSolveWasteMs: 5_000, RegressedAfterCorrect: true} |
| 318 | |
| 319 | if got := renderDiagnosis([]result{wasteful}); !strings.Contains(got, "post-solve waste dominates") { |
| 320 | t.Fatalf("waste verdict missing: %s", got) |
| 321 | } |
| 322 | if got := renderDiagnosis([]result{explorer}); !strings.Contains(got, "exploration dominates") { |
| 323 | t.Fatalf("exploration verdict missing: %s", got) |
| 324 | } |
| 325 | if got := renderDiagnosis([]result{never, never, wasteful}); !strings.Contains(got, "never-correct dominates") { |
| 326 | t.Fatalf("capability verdict must outrank latency: %s", got) |
| 327 | } |
| 328 | if got := renderDiagnosis([]result{broken, wasteful, wasteful}); !strings.Contains(got, "correct→incorrect regressions") { |
| 329 | t.Fatalf("damage verdict must outrank waste-vs-exploration: %s", got) |
| 330 | } |
| 331 | if renderDiagnosis([]result{{task: task{ID: "x"}}}) != "" { |
| 332 | t.Fatal("uncheckpointed suites carry no diagnosis") |
| 333 | } |
| 334 | } |
| 335 |