skill-eval: report dead runs as ungraded, not failed
Assisted-by: Claude Opus 5 via pi
change ptnowxtnmxlomsurqtwolumykyupsvwv
commit a060aecbfc835d9cd35b7d696411989afe967784
author Alpha Chen <alpha@kejadlen.dev>
date
parent mozmxytt
diff --git a/ai/pi/extensions/skill-eval/README.md b/ai/pi/extensions/skill-eval/README.md
index 975773ab..49d34d8f 100644
--- a/ai/pi/extensions/skill-eval/README.md
+++ b/ai/pi/extensions/skill-eval/README.md
@@ -110,9 +110,18 @@ cheapest trim signal the run produces, and it cuts both ways: either the file
 isn't earning its place, or no case reaches the behavior it covers.
 
 A report that opens with a provider-retry line is not a verdict on the skill.
-Overload and rate limiting inside a nested run can empty a reply, which lands as
-`unclear` rather than `fail`; re-run those cases with `--case` before believing
-them.
+Overload, rate limiting, or the five-minute per-run timeout can kill a nested
+run before anything reaches the judge. Those cases report once as `run error`,
+list their expectations as `ungraded` rather than `fail`, and print the tail of
+the transcript under "Where it stopped" so you can tell a thrashing agent from
+one that just ran out of clock. When every failure in a run is a dead run, the
+follow-up asks for a re-run instead of edits — unproven is not disproven, and a
+timeout says nothing about the text. Re-run with `--case` before believing any
+of it.
+
+`ungraded` and `unclear` are different: `ungraded` means no verdict exists,
+while `unclear` means the judge read the transcript and it didn't settle the
+expectation. Only the second is a signal about the skill.
 
 Trigger misses usually mean the `description` field needs the vocabulary of the
 prompt that missed. False positives on negative cases mean it claims too much
diff --git a/ai/pi/extensions/skill-eval/run.test.ts b/ai/pi/extensions/skill-eval/run.test.ts
index f45dfdfc..923cf171 100644
--- a/ai/pi/extensions/skill-eval/run.test.ts
+++ b/ai/pi/extensions/skill-eval/run.test.ts
@@ -9,6 +9,7 @@ import {
   buildTrimFollowUp,
   bundledReads,
   formatEvalReport,
+  hasGradedFailure,
   listBundledFiles,
   loadedSkill,
   mapWithConcurrency,
@@ -18,6 +19,7 @@ import {
   runEvals,
   skillLoadPath,
   summarizeFailures,
+  transcriptTail,
   unusedBundledFiles,
   type EvalReport,
 } from "./run.ts";
@@ -232,6 +234,105 @@ test("formatEvalReport skips trim signals for a skill with no bundled files", ()
   assert.doesNotMatch(formatEvalReport({ ...report, bundledFiles: [] }), /Trim signals/);
 });
 
+// A run killed by the timeout: every expectation carries the same reason and
+// nothing reached the judge.
+const deadReason = "run failed: run exceeded 300000ms and was killed";
+const dead: EvalReport = {
+  ...report,
+  trigger: [],
+  adherence: [
+    {
+      kind: "adherence",
+      prompt: "calibrate it and report the torque",
+      attempts: [
+        {
+          transcript: "[assistant] reading torque.md\n[tool] read torque.md\n[assistant] checking the bench",
+          reads: ["torque.md"],
+          retries: [],
+          error: "run exceeded 300000ms and was killed",
+          expectations: [
+            { expectation: "names the torque value 42", verdict: "unclear", reason: deadReason },
+            { expectation: "reports newton-metres", verdict: "unclear", reason: deadReason },
+          ],
+        },
+      ],
+      passes: 0,
+      costUsd: 0.05,
+    },
+  ],
+};
+
+test("transcriptTail keeps the end and says how much it dropped", () => {
+  const long = Array.from({ length: 25 }, (_, i) => `line ${i + 1}`).join("\n");
+  const tail = transcriptTail(long, 5);
+  assert.equal(tail[0], "…20 earlier line(s) elided");
+  assert.equal(tail.at(-1), "line 25");
+  assert.deepEqual(transcriptTail("only\n\n", 5), ["only"]);
+  assert.deepEqual(transcriptTail("", 5), []);
+});
+
+test("summarizeFailures collapses a dead run into one note instead of one per expectation", () => {
+  const notes = summarizeFailures(dead);
+  assert.equal(notes.length, 1);
+  assert.match(notes[0], /No verdict on "calibrate it and report the torque"/);
+  assert.match(notes[0], /2 expectation\(s\) ungraded/);
+  assert.match(notes[0], /says nothing about the skill/);
+});
+
+test("summarizeFailures does not call a dead trigger run a description miss", () => {
+  const deadTrigger: EvalReport = {
+    ...report,
+    adherence: [],
+    trigger: [
+      {
+        kind: "trigger",
+        expected: "load",
+        prompt: "check widget torque",
+        attempts: [{ loaded: false, reads: [], retries: [], error: "run exceeded 300000ms and was killed" }],
+        passes: 0,
+        costUsd: 0.01,
+      },
+    ],
+  };
+  const notes = summarizeFailures(deadTrigger);
+  assert.equal(notes.length, 1);
+  assert.match(notes[0], /No verdict on "check widget torque"/);
+  assert.doesNotMatch(notes[0], /did not fire/);
+});
+
+test("hasGradedFailure separates real verdicts from dead runs", () => {
+  assert.equal(hasGradedFailure(report), true);
+  assert.equal(hasGradedFailure(dead), false);
+  assert.equal(hasGradedFailure(clean), false);
+});
+
+test("formatEvalReport prints a dead run once and shows where it stopped", () => {
+  const markdown = formatEvalReport(dead);
+  assert.match(markdown, /- run error: run exceeded 300000ms and was killed/);
+  assert.match(markdown, /2 expectation\(s\) ungraded, so this case says nothing about the skill/);
+  assert.match(markdown, /  - ungraded: names the torque value 42/);
+  assert.match(markdown, /Where it stopped:/);
+  assert.match(markdown, /\[assistant\] checking the bench/);
+  // The identical reason should not be repeated once per expectation.
+  assert.equal(markdown.match(/run exceeded 300000ms/g)?.length, 1);
+});
+
+test("formatEvalReport says so when a dead run produced no output at all", () => {
+  const silent: EvalReport = {
+    ...dead,
+    adherence: [{ ...dead.adherence[0], attempts: [{ ...dead.adherence[0].attempts[0], transcript: "" }] }],
+  };
+  assert.match(formatEvalReport(silent), /The run produced no output before it died\./);
+});
+
+test("buildFailureFollowUp asks for a re-run when nothing was graded", () => {
+  const message = buildFailureFollowUp(dead, summarizeFailures(dead), "/reports/eval.md");
+  assert.match(message, /unproven, not\ndisproven/);
+  assert.match(message, /--case <filter>` before/);
+  assert.match(message, /a timeout is not a\nverdict on the text/);
+  assert.doesNotMatch(message, /propose specific edits/);
+});
+
 test("buildFailureFollowUp asks for cuts before additions", () => {
   const message = buildFailureFollowUp(report, summarizeFailures(report), "/reports/eval.md");
   assert.match(message, /# Eval failures: widget-wrangler/);
diff --git a/ai/pi/extensions/skill-eval/run.ts b/ai/pi/extensions/skill-eval/run.ts
index caa09782..a7df7ec3 100644
--- a/ai/pi/extensions/skill-eval/run.ts
+++ b/ai/pi/extensions/skill-eval/run.ts
@@ -529,6 +529,21 @@ function firstLine(text: string, max = 80): string {
   return line.length > max ? `${line.slice(0, max - 1)}…` : line;
 }
 
+/**
+ * The end of a transcript from a run that died. A killed run stalls at its last
+ * action, so the tail is the diagnostic — it separates an agent thrashing on
+ * tool calls from one that simply ran out of clock.
+ */
+export function transcriptTail(transcript: string, maxLines = 20): string[] {
+  const all = transcript.split("\n");
+  while (all.length > 0 && all.at(-1)!.trim() === "") all.pop();
+  const tail = all.slice(-maxLines);
+  while (tail.length > 0 && tail[0].trim() === "") tail.shift();
+  if (tail.length === 0) return [];
+  const elided = all.length - tail.length;
+  return elided > 0 ? [`…${elided} earlier line(s) elided`, ...tail] : tail;
+}
+
 /** Bundled references no attempt opened — the cheapest trim candidates in the skill. */
 export function unusedBundledFiles(report: EvalReport): string[] {
   const opened = new Set<string>();
@@ -602,7 +617,33 @@ export function formatEvalReport(report: EvalReport): string {
       lines.push("");
       row.attempts.forEach((attempt, i) => {
         if (row.attempts.length > 1) lines.push(`Attempt ${i + 1}:`);
-        if (attempt.error) lines.push(`- run error: ${attempt.error}`);
+        // A failed run grades nothing, so every expectation carries the same
+        // reason. Print it once: repeating it per expectation reads as several
+        // independent findings instead of one dead run.
+        if (attempt.error) {
+          const count = attempt.expectations.length;
+          lines.push(`- run error: ${attempt.error}`);
+          lines.push(
+            `- ${count} expectation(s) ungraded, so this case says nothing about the skill. Re-run it before ` +
+              `changing the text.`,
+          );
+          for (const expectation of attempt.expectations) {
+            lines.push(`  - ungraded: ${expectation.expectation}`);
+          }
+          const tail = transcriptTail(attempt.transcript);
+          lines.push("");
+          if (tail.length > 0) {
+            lines.push("Where it stopped:");
+            lines.push("");
+            lines.push("```");
+            lines.push(...tail);
+            lines.push("```");
+          } else {
+            lines.push("The run produced no output before it died.");
+          }
+          lines.push("");
+          return;
+        }
         for (const expectation of attempt.expectations) {
           lines.push(`- ${expectation.verdict}: ${expectation.expectation} — ${expectation.reason}`);
         }
@@ -629,11 +670,40 @@ export function formatEvalReport(report: EvalReport): string {
   return lines.join("\n");
 }
 
+/**
+ * Whether any failure in the report is a verdict on the skill rather than a dead
+ * run. A killed or errored run fails every expectation under it for the same
+ * reason, which says nothing about the text.
+ */
+export function hasGradedFailure(report: EvalReport): boolean {
+  for (const row of report.trigger) {
+    if (row.passes === row.attempts.length) continue;
+    if (row.attempts.some((a) => !a.error)) return true;
+  }
+  for (const row of report.adherence) {
+    for (const attempt of row.attempts) {
+      if (attempt.error) continue;
+      if (attempt.expectations.some((e) => e.verdict !== "pass")) return true;
+    }
+  }
+  return false;
+}
+
 /** Failure detail worth pulling into the conversation so the skill text can be fixed. */
 export function summarizeFailures(report: EvalReport): string[] {
   const notes: string[] = [];
   for (const row of report.trigger) {
     if (row.passes === row.attempts.length) continue;
+    const errored = row.attempts.filter((a) => a.error);
+    // A run that died never chose whether to load the skill, so reporting it as
+    // a trigger miss invents a verdict the run didn't reach.
+    if (errored.length === row.attempts.length) {
+      notes.push(
+        `No verdict on "${firstLine(row.prompt, 120)}": the run failed (${errored[0].error}). ` +
+          `Re-run this case; it says nothing about the description.`,
+      );
+      continue;
+    }
     const loads = row.attempts.filter((a) => a.loaded).length;
     notes.push(
       row.expected === "load"
@@ -643,6 +713,16 @@ export function summarizeFailures(report: EvalReport): string[] {
   }
   for (const row of report.adherence) {
     for (const attempt of row.attempts) {
+      // One note per dead run, not one per expectation: the reason is identical
+      // across them, and repeating it reads as several independent findings.
+      if (attempt.error) {
+        const count = attempt.expectations.length;
+        notes.push(
+          `No verdict on "${firstLine(row.prompt, 80)}": the run failed (${attempt.error}), leaving ` +
+            `${count} expectation(s) ungraded. Re-run this case; it says nothing about the skill.`,
+        );
+        continue;
+      }
       for (const expectation of attempt.expectations) {
         if (expectation.verdict === "pass") continue;
         notes.push(
@@ -660,6 +740,22 @@ export function summarizeFailures(report: EvalReport): string[] {
  * ones.
  */
 export function buildFailureFollowUp(report: EvalReport, failures: string[], reportPath: string): string {
+  // With nothing graded, asking for edits invites rewriting the skill against a
+  // blank transcript. Ask for a re-run instead.
+  const ask = hasGradedFailure(report)
+    ? [
+        "Read the skill and propose specific edits to its text that would fix these, or say why a case is",
+        "wrong about the skill rather than the skill being wrong.",
+        "",
+        "Reach the fix by sharpening or cutting what's already there before adding anything. If a failure",
+        "genuinely needs new instruction, name something else in the file to delete alongside it.",
+      ]
+    : [
+        "Every failure above is a run that died, so nothing was graded and the skill text is unproven, not",
+        `disproven. Re-run the affected cases with \`/skill-eval run ${report.skillName} --case <filter>\` before`,
+        "changing a word of the skill. If they keep dying, fix the case or the harness — a timeout is not a",
+        "verdict on the text.",
+      ];
   return [
     `# Eval failures: ${report.skillName}`,
     "",
@@ -667,11 +763,7 @@ export function buildFailureFollowUp(report: EvalReport, failures: string[], rep
     "",
     ...failures.map((note) => `- ${note}`),
     "",
-    "Read the skill and propose specific edits to its text that would fix these, or say why a case is",
-    "wrong about the skill rather than the skill being wrong.",
-    "",
-    "Reach the fix by sharpening or cutting what's already there before adding anything. If a failure",
-    "genuinely needs new instruction, name something else in the file to delete alongside it.",
+    ...ask,
   ].join("\n");
 }