feat(evalboard): replace the turn-budget signal with time per passed task - #125
feat(evalboard): replace the turn-budget signal with time per passed task#125bai-uipath wants to merge 5 commits into
Conversation
…task The dashboard's only efficiency signal was "Within Expected Turns", a rate computed against a hand-written `run_limits.expected_turns` in each task YAML. That signal did not work: 147 commits touched the field and exactly six revised a value, 249 of 1002 tasks carried no budget at all (holding 34.7% of task-seconds), a fifth of the suite shared a copy-pasted 300 against a p90 actual of 52, and a turn is not a unit of time — seconds per visible turn ran p10 5.2s to p90 15.5s, max 156s, so a Read and a 20-minute deploy both counted 1. Efficiency is now measured in seconds against `expected_seconds`, derived per task per harness from that task's own passing history and stamped into run.json by the eval runner (coder_eval_uipath#86). The dashboard reads the stamp rather than deriving anything, so a number here still matches the Slack ping that announced the run months later, and a task with no line reads as *unscored* everywhere rather than as "on target". - Home page: "Within Expected Turns (%)" becomes "Time per Passed Task", showing the latest run's seconds per passed task with its delta against the previous run, the within-expected share, and the same per-harness trend chart. - Task grid, task detail and trends: the tint moves from the Turns cell to the Duration cell, with the ratio and the line it was measured against in the hover. Turn counts stay, untinted — data, not a score. - Watchlist: the third attention segment becomes time-overage, and the "turn-overage offenders" panel becomes slow-task offenders. `withinExpectedTimeRateForTasks` scores passing tasks only, which is a deliberate departure from the turn rate it replaces (that counted a budgeted failure as over budget). A task that crashed in 10 seconds did not blow a time budget, and folding failures in would make a pass-to-timeout regression read as an efficiency gain once the slow pass stopped counting. Failure is the pass rate's job; the seconds burned failing are in the headline's numerator. `RunLimits.expected_turns` stays accepted and ignored, marked deprecated, and exempted in CE031 with the reason. Deleting it now would fail every one of the ~930 task YAMLs that still declare it (RunLimits forbids extras) and break external suites that set it; the removal belongs in its own release once those YAMLs are cleaned. Every consumer is gone: the orchestrator's one-shot warning, `expected_turns_overage`, the HTML badge, the markdown Run-time Note, and the per-row report fields. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Same content, fewer paragraphs: the comments now say what the code does and keep only the non-obvious constraints. Also drops the stale description of the min / p10 split, which the runner no longer has. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…gregates Found by running the board against real stamped nightlies. A codex run whose runner block said 3m12s per passed task rendered as 1m17s, because the front page counted mature-skipped rows in the denominator while the runner excludes them from both sides: real seconds divided by tasks that never ran. The within-expected rate had the same leak, surviving only because those rows carry no stamped line. The headline's "vs prev" also compared across harnesses, so a codex run was measured against the previous Claude Code run and reported the schedule instead of a change in speed. It now compares against the previous run of the same harness and says which. Tracks the runner: a 2x gate, and a task is unscored only when its harness has never passed it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @bai-uipath's task in 1m 17s —— View job Review in progress
|
The card was titled Time per Passed Task and plotted the within-expected rate, so the heading and the line described different numbers. It now plots what it says: seconds per passed task, per harness, on a zero-based axis. The headline figure above the chart is gone. Both numbers it carried, the per-task time and the within-expected rate, belong to one run, and the chart spans a dozen, so neither reads correctly as a title over the series. The rate moves into the point hover instead, which HarnessTooltip now supports through an optional second line and a caller-supplied value formatter. The "vs prev" delta goes with it. Consecutive nightlies alternate harness, so it was comparing a codex run against the previous claude-code run and reporting the schedule rather than a change in speed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ratio lived in a duration cell's tooltip, so finding the tasks running at 2x their line meant hovering every row. It gets its own column now, vs Expected, sortable and tinted, with the duration left plain: a long task is not a slow one, and the two sorts rank differently (on the 2026-08-18 codex nightly the top ten by duration and the top ten by ratio share no tasks). Ratios render to one decimal. The baseline is a min over a handful of runs, p10 over ten, and a task's own night-to-night spread is wider than the second decimal, so the extra digit was precision the number does not have. The same format carries to the task detail page and to the per-task run history on trends. Also drops the column-help popovers. Each cost a click to read one sentence and crowded the header it sat in; the text is a native title tooltip now, which leaves col-help as the strings themselves. The watchlist badge reads the shared tolerance constant rather than repeating 1.5. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Maybe let's not replace the current turn-budget signal, but add this PR (time budget) to it. |
|
For the expected, maybe let's do bottom quartile (p25)? what is it now? |
uipreliga
left a comment
There was a problem hiding this comment.
Review: coder_eval — pr:125 (56 files) axis:1,2,3,4,5,6,7,8
Scope: pr:125 (56 files) axis:1,2,3,4,5,6,7,8 · branch bai/wall-clock-evalboard · 40b1c92 · 2026-08-21T17:40Z · workflow variant
Change class: complex — 56 files, +1281/-1592, spanning three coupled surfaces: it deprecates a public RunLimits field and rips out every consumer across the orchestrator and four report modules, rewrites the evalboard's core efficiency metric (new lib/timing.ts, four page surfaces, one chart replaced), and takes a new dependency on an expected_seconds field stamped by a separate repo (coder_eval_uipath#86), i.e. a cross-repo contract change with a two-sided rollout.
Architecture, security and test health are excellent (10, 10, 9.7) and the turn-to-wall-clock swap is a sound design that lands cleanly on most surfaces, but the real risks sit at the new metric's edges — a carried-forward row rendering a fabricated green 0.0× verdict on the most-visited page, a run.json field removed out from under the deployed dashboard with only a manual redeploy to fix it, and time aggregates that count timeouts under a "passing" label — so this is fix-then-merge, not rework.
Summary
| Axis | Score | 🔴 | 🟠 | 🟡 | 🔵 | Top Issue |
|---|---|---|---|---|---|---|
| 1. Code Quality & Style | 7.4 / 10 | 0 | 1 | 3 | 1 | The run.json timing block is parsed into RunOverview but unread: timePerPassedTask and visibleTurns are write-only, tolerance is reimplemented as a constant, and the surrounding comments promise fallbacks/roles the code does not implement |
| 2. Type Safety | 9.2 / 10 | 0 | 0 | 1 | 3 | The per-run unstable_cache key was not versioned even though the cached PerRun shape changed (expectedTurns → expectedSeconds, +timePerPassedTask), so stale entries blank the new time signals on the overview/trends/watchlist for up to 5 minutes |
| 3. Test Health | 9.7 / 10 | 0 | 0 | 0 | 3 | pivotByHarness is never exercised with timePerPassedTask — the only metric the new wall-clock chart plots |
| 4. Security | 10 / 10 | 0 | 0 | 0 | 0 | — |
| 5. Architecture & Design | 10 / 10 | 0 | 0 | 0 | 0 | — |
| 6. Error Handling & Resilience | 9 / 10 | 0 | 1 | 0 | 0 | The mature-skipped (carried-forward) zero-duration sentinel is guarded in the run aggregates and on Trends but not in the task-grid render paths or the watchlist time metrics — producing a fabricated "0.0×" verdict and silently suppressing the slow-task signal |
| 7. API Surface & Maintainability | 8 / 10 | 0 | 1 | 2 | 0 | The new metric's only input (expected_seconds) has no producer in this repo, yet the task guide, tutorial, run.json schema doc and OSS-visible UI all present it as automatic |
| 8. Evaluation Harness Quality | 8.5 / 10 | 0 | 1 | 1 | 0 | Dropping expected_turns from the run.json task_results[] contract blanks three surfaces of the currently-deployed dashboard, with no release-note signal and a manual redeploy as the only fix |
Overall Score: 9 / 10 · Weakest Axis: Code Quality & Style at 7.4 / 10
Totals: 🔴 0 · 🟠 4 · 🟡 7 · 🔵 7 across 8 axes.
Blockers
- [Axis 1] The run.json
timingblock is parsed intoRunOverviewbut unread:timePerPassedTaskandvisibleTurnsare write-only,toleranceis reimplemented as a constant, and the surrounding comments promise fallbacks/roles the code does not implement (evalboard/lib/runs.ts:945) — TwoRunOverview*fields are populated for every task of every run inreadRunOverviewand consumed by nothing.grep -rn "visibleTurns\|timePerPassedTask" --include="*.ts" --include="*.tsx"overevalboard/returns only the definitions, the two write sites, and test fixtures that set them tonull— no read.
(a) visibleTurns — leftover from the removal. runs.ts:945 visibleTurns: number | null;, written at runs.ts:1072 visibleTurns: visibleTurnsFromRaw(t), by the 12-line exported helper runs.ts:987 export function visibleTurnsFromRaw(t: {. Its own docstring names the metric this PR deleted as its justification — runs.ts:984-985: // provably identical to the persisted field — so it backfills the "within / // expected turns" metric for runs written before \visible_turns` existed.Turn cells now render fromdisplayedTurns()inlib/turns.ts, so the field is fully redundant. It still carries 6 dedicated tests (lib/tests/runs.test.ts:295-326`).
(b) timePerPassedTask — unused on arrival. runs.ts:978 timePerPassedTask?: number | null;, written at runs.ts:1098 timePerPassedTask: data.timing?.time_per_passed_task ?? null,, fed by the new 9-field runs.ts:468 interface RawTiming {. The field's comment at runs.ts:977 promises // run predates stamping (the front page then falls back to the task rows). — there is no fallback: overview.ts:674 unconditionally computes timePerPassedTask: timePerPassedTaskForTasks(scoped.tasks) from the rows and never consults overview.timePerPassedTask. The RawTiming header comment at runs.ts:465-467 (\tolerance` is recorded by the runner so the dashboard can never disagree with the run it is describing) is contradicted the same way — toleranceis never read;timing.ts:76hardcodesexport const TIME_BUDGET_TOLERANCE = 1;`.
Fix: delete visibleTurns + visibleTurnsFromRaw (and their tests) since the metric they served is gone; and either wire overview.timePerPassedTask/RawTiming.tolerance into overview.ts as the actual runner-stamped source (which is what the comments promise, and what would let the board agree with the Slack ping) or drop both and the RawTiming interface. Either way the three comments must stop asserting behavior that does not exist.
2. [Axis 6] The mature-skipped (carried-forward) zero-duration sentinel is guarded in the run aggregates and on Trends but not in the task-grid render paths or the watchlist time metrics — producing a fabricated "0.0×" verdict and silently suppressing the slow-task signal (evalboard/app/runs/[id]/task-grid.tsx:654) — The new vs Expected column computes its ratio with no matureSkipped guard, on both render paths:
// task-grid.tsx:654 (desktop table)
const timeRatioValue = timeRatio(
t.durationSeconds,
t.expectedSeconds,
);
const timeTint = tintForTimeRatio(timeRatioValue);and identically at task-grid.tsx:794 (mobile cards). run-view.tsx:598 passes filtered — which includes mature rows (they render a <MaturePill /> at task-grid.tsx:691) — so these rows reach the column.
A carried-forward row is documented in this repo as carrying a zero, not a null: lib/runs.ts:949 — "but it wasn't executed — so its 0-cost/0-duration row must be excluded from per-task averages"; lib/trends.ts:165 — "its row carries 0 cost / 0 duration and no turns". The PR's own fixture encodes it: lib/__tests__/overview.test.ts:216-220 builds task({ expectedSeconds: 100, durationSeconds: 0, matureSkipped: true }).
So for a real mature row, timeRatio(0, 100) returns 0 — not null — and the cell renders fmtTimeRatioCell(0) = "0.0×" tinted text-emerald-700 (timeCellClasses("green")), with the tooltip expectedTimeTitle(100) = "expected time: 1m40s". A task that never executed is presented as having beaten its expected time by 100×, in green, on the most-visited page.
This PR establishes the correct treatment in the sibling surface — trends-view.tsx renders {e.matureSkipped ? "—" : fmtTimeRatioCell(timeRatio(e.durationSeconds, e.expectedSeconds))} with MATURE_TOOLTIP — and in the aggregates (lib/overview.ts:59 and :80), but not here.
Fix: gate both call sites the way trends-view.tsx does, e.g. const timeRatioValue = t.matureSkipped ? null : timeRatio(t.durationSeconds, t.expectedSeconds); and set the cell title to MATURE_TOOLTIP when t.matureSkipped. Add a task-grid.test.tsx case asserting a { matureSkipped: true, durationSeconds: 0, expectedSeconds: 100 } row renders — in vsExpCellFor(...) — the existing mature tests (task-grid.test.tsx:65, :107) never assert on that column, and the vs Expected suite (:124-157) never uses a mature row.
3. [Axis 7] The new metric's only input (expected_seconds) has no producer in this repo, yet the task guide, tutorial, run.json schema doc and OSS-visible UI all present it as automatic (docs/TASK_DEFINITION_GUIDE.md:314) — grep -rn 'expected_seconds' src/ tests/ returns nothing — coder-eval never writes expected_seconds or a run-level timing block into run.json; only the external eval runner does (evalboard/lib/runs.ts:405 points at eval_runner/skills/timing.py, a file in another repo). Yet the published guide states flatly at lines 316-321: "There is nothing to declare. A task's expected wall clock is derived... The eval runner computes it from past runs and stamps expected_seconds onto each row of run.json", and docs/tutorials/04-writing-a-task.md:65-67 tells a first-time task author "Nothing to declare for efficiency: expected wall clock is derived per task from run history and stamped into run.json after the run." Neither says that "the eval runner" is not part of coder-eval and that coder-eval run alone never produces the stamp. The UI compounds it: expectedTimeTitle (evalboard/lib/timing.ts:119) renders "no expected time yet (needs a passing run on this harness)" — a promise an OSS build can never fulfil no matter how many passing runs accumulate — and unlike the front-page chart (gated behind isInternal at evalboard/app/page.tsx:228) the new vs Expected column (task-grid.tsx COLUMNS, { key: "vsExp", header: "vs Expected" }) and the task-detail/trends ratios carry no edition gate, so they ship to the OSS edition as a permanently em-dashed column. Fix: state in both docs pages that expected_seconds is stamped by an external pipeline and that a standalone coder-eval run leaves the column unscored; and change the empty-state string to name the actual cause (e.g. "no expected time stamped for this task") instead of implying more passing runs will populate it.
4. [Axis 8] Dropping expected_turns from the run.json task_results[] contract blanks three surfaces of the currently-deployed dashboard, with no release-note signal and a manual redeploy as the only fix (src/coder_eval/reports_experiment.py:192) — eval_result_to_task_dict stops stamping two run.json task-row fields. The diff drops "expected_turns": expected_turns_value, (immediately after the surviving line 192 "visible_turns": visible_turn_count(result),) and "expected_turns_overage": list(overage) if overage is not None else None,. The PR verifies the forward direction (new dashboard vs old run.json = unscored, correct) but not the reverse, which is the one that actually happens on merge: the currently-deployed evalboard still reads expectedTurns: t.expected_turns ?? null and gates on const hasBudget = t.expectedTurns != null && t.expectedTurns >= 1; if (!hasBudget) continue; in the pre-PR turnBudgetRateForTasks. With the field gone from every new run.json, eligible is 0 for every run, the function returns null for all of them, and the front page's "Within Expected Turns (%)" chart plots nothing — plus every run-page "Turns" cell loses its tint. No error, no gap indicator, just a permanently empty headline chart. The author's own "Left to do" says the deploy workflow does not fire on merge and needs a manual workflow_dispatch; there is no evalboard deploy workflow in .github/workflows/ here to fire automatically. So that blank state starts at the first post-merge nightly and lasts until someone remembers to dispatch. Fix: either dispatch the evalboard deploy as a required step of merging this PR (say so in the PR body, not just in "Left to do"), or keep writing "expected_turns": expected_turns_value for one release so the two halves can be deployed in any order — which is what the "safe to merge in either order" claim would then actually mean for both directions rather than only for the producer PR.
Non-blocking, but please consider before merge
- [Axis 1] The two new pass-scoped aggregates in overview.ts test the raw
"SUCCESS"literal instead of the already-importedisPassStatuspredicate (evalboard/lib/overview.ts:59) — Both new pass-scoped aggregates test the raw status string instead of the shared predicate:
overview.ts:59if (t.status !== "SUCCESS" || t.matureSkipped) continue;overview.ts:81const passed = executed.filter((t) => t.status === "SUCCESS").length;
isPassStatus is already imported at overview.ts:20 (import { isPassStatus } from "./status";), and the same file carries an explicit rule against this at overview.ts:833-836:
} else if (isPassStatus(t.status)) {
// lib/status.ts, not a raw "SUCCESS" literal: `status` is an
// untyped string, and this page's pass rate must move with
// every other surface if the passing set ever widens.
The two new lines are exactly what that comment tells the next author not to write, and they sit in the numerator/denominator of the PR's two headline metrics — so if the passing set ever widens (lib/status.ts::statusCategory), the front-page time metrics would silently diverge from the pass rate beside them. Replace both with isPassStatus(t.status).
2. [Axis 1] The "2× expected" boundary has multiple independent encodings and the EVALBOARD_TIME_RED_RATIO knob moves the cell tint without moving the TIME_BUDGET_TOLERANCE rollup boundary, breaking the invariant the module's comment asserts (evalboard/lib/timing.ts:75) — timing.ts:75 asserts an invariant the code cannot hold: // run's \timing` block. Red tints at the same 2×, so the cell and the rollup agree.` But the two sides of that claim are separate values, and one is externally overridable:
timing.ts:76export const TIME_BUDGET_TOLERANCE = 1;→ the rollup line is1 + 1 = 2(hard constant,withinExpectedTime,timing.ts:88).timing.ts:25red: parse(process.env.EVALBOARD_TIME_RED_RATIO, 2),→ the tint line is2by default only.
Set EVALBOARD_TIME_RED_RATIO=3 on a deployment and the cell tints red at 3× while withinExpectedTimeRateForTasks still counts anything past 2× as over — the stated "cell and the rollup agree" is gone, with nothing failing. Two more copies hardcode the same number as prose that the knob cannot move:
app/_overview/wall-clock-chart.tsx:104: \${r.toFixed(0)}% within 2× expected`;`app/runs/[id]/task-grid.tsx:48vsExp: "… Past 2× counts as slow; …"
Fix: derive one value — e.g. export const RED_RATIO = 1 + TIME_BUDGET_TOLERANCE and have getTimeRatioThresholds() use it as the red default (or drop the red env override entirely), then interpolate that constant into both UI strings instead of typing 2×. Worth noting while you are there: EVALBOARD_TIME_YELLOW_RATIO / EVALBOARD_TIME_RED_RATIO are referenced nowhere outside timing.ts and lib/__tests__/timing.test.ts:65-85 — no deployment, doc, or .env sets them — so this is a knob that exists only to be a second source of truth.
3. [Axis 1] The turn-budget removal sweep left stale comments and retired vocabulary behind: an orphaned experiments/default.yaml comment now mis-annotates task_timeout, a TaskGrid comment describes removed openHelp state, and turn-budget naming survives in touched files including a new test (experiments/default.yaml:24) — The PR deleted the commented-out # expected_turns: 15 line but left the three-line comment that documented it, so it now reads as the preamble to task_timeout:
23: max_turns: 20
24: # Soft target: when cumulative SDK turns across all iterations of a task
25: # exceed this, the orchestrator logs a one-shot warning and the report
26: # surfaces a badge. Does NOT abort the run (max_turns is the hard cap).
27: # Maximum total seconds for the entire task evaluation (all iterations combined).
28: task_timeout: 600
This is the baseline defaults file every task inherits from (layer 1 of the 5-layer merge), and it now tells a reader that task_timeout "does NOT abort the run" — the opposite of what RunLimits.task_timeout does. Delete lines 24-26.
4. [Axis 2] The per-run unstable_cache key was not versioned even though the cached PerRun shape changed (expectedTurns → expectedSeconds, +timePerPassedTask), so stale entries blank the new time signals on the overview/trends/watchlist for up to 5 minutes (evalboard/lib/overview.ts:292) — This PR changes the shape of the object stored under this cache entry — RunOverviewTask.expectedTurns was removed and expectedSeconds added (runs.ts:944), and RunOverview gained timePerPassedTask (runs.ts:978) — but the key parts are unchanged:
const loader = unstable_cache(
(id: string) => loadPerRunForId(id, source),
["evalboard-per-run", source.id],
{ revalidate: 300 },
);Next's key is hashString([MAIN_KEY_PREFIX, fetchCacheKeyPrefix, ${cb.toString()}-${keyParts.join(',')}]) (node_modules/next/dist/server/web/spec-extension/unstable-cache.js:55,82-83 + incremental-cache/index.js:200-206). No build id participates, and cb.toString() here is the unchanged arrow (id: string) => loadPerRunForId(id, source) — so the key is byte-identical before and after this PR. That is precisely why the two sibling loaders in this codebase carry explicit version tokens and were bumped for smaller shape changes: ["aggregate-task-trends-v3", source.id] with the comment "the cached shape gained tagCounts … the key bump keeps a stale pre-deploy payload from being served into new code" (trends.ts:258-259) and ["history-for-task-v2", source.id] (trends.ts:339). A stale entry served into the new code yields a PerRun whose tasks[i].expectedSeconds is undefined while the type says number | null — it degrades to — rather than crashing, but the type is asserted over an unvalidated deserialized payload, and the within-expected tooltip and every vs Expected cell go blank for up to revalidate: 300. Bump to ["evalboard-per-run-v2", source.id].
5. [Axis 7] getTimeRatioThresholds() reads non-NEXT_PUBLIC_ env vars, so the new tint knobs are inert at the three client-component call sites (timing.ts:24-25) (evalboard/lib/timing.ts:24) — timing.ts:23-26 reads parse(process.env.EVALBOARD_TIME_YELLOW_RATIO, 1.5) / parse(process.env.EVALBOARD_TIME_RED_RATIO, 2), and tintForTimeRatio calls it as a default argument (line 51: t: TimeRatioThresholds = getTimeRatioThresholds()), evaluated at each call site. Two of the three call sites are client components: evalboard/app/runs/[id]/task-grid.tsx line 1 is "use client" and calls tintForTimeRatio(timeRatioValue) at lines 658 and 798; evalboard/app/trends/trends-view.tsx line 1 is "use client" and calls it at line 299. Next.js only inlines NEXT_PUBLIC_-prefixed vars into the client bundle (evalboard/next.config.mjs adds no env passthrough), so in the browser both reads are undefined and the knobs silently fall back to 1.5/2 — while the same helper on the un-use client task-stats.tsx honors them, giving two surfaces different thresholds from one setting, plus a hydration mismatch on the tint class if SSR resolves the var and the client does not. The codebase already documents this exact rule at evalboard/app/runs/[id]/run-view.tsx:174: "Edition gate (passed from the server page — process.env isn't readable in this client component)". Fix: resolve the thresholds once in the server page and pass them down as a prop (mirroring isInternal), or rename them NEXT_PUBLIC_EVALBOARD_TIME_*. Also document them in evalboard/README.md — neither name appears there or anywhere outside timing.ts and its test.
6. [Axis 7] Metric and chart help on the front page was demoted from visible text to native title tooltips, making the headline definition and the "scoped to the active filter" notice unreachable on touch and by keyboard (evalboard/app/page.tsx:286) — Three surfaces lose reachable help in one PR. (1) evalboard/app/page.tsx:285-291 deletes the visible <p className="text-xs text-gray-500"> caption that explained the chart and moves the whole definition into a hover-only attribute on a non-interactive heading: <h2 className="text-sm font-semibold text-gray-900" title="Total wall clock ÷ tasks passed. Every task's seconds count, failures included; only passes count in the denominator...">. The sibling "Daily Success Rate (%)" heading a few lines above keeps its visible caption, so the two charts are now inconsistent, and the counterintuitive half of this metric (failures in the numerator, passes in the denominator) is exactly the part now invisible. (2) evalboard/app/_components/col-help.tsx drops ColHelp / HelpPopover / ColHelpIcon and the causes / fix guidance entirely, leaving four plain strings. (3) evalboard/app/runs/[id]/task-grid.tsx replaces the per-column ⓘ button (which had aria-label={What is ${col.header}?} and aria-expanded) with title={COLUMN_HELP[col.key]} on the <th>. A title on a non-focusable element is not exposed to keyboard users, is not reliably announced by screen readers, and does not open on touch — and the PR states it was never viewed on a real mobile device. Fix: keep the one-line visible caption under the front-page heading (matching the neighbouring chart), and if the popovers are staying deleted, move the header help onto the existing sort <button> so it is at least focusable.
7. [Axis 8] Watchlist time aggregates include failed/timed-out tasks with no outcome filter, so the "Slow-task offenders" panel can list a skill whose every outcome timed out under the label "Passing, but well past their expected time" (evalboard/lib/watchlist.ts:194) — attention() collects the ratio with no outcome filter — const r = timeRatio(t.durationSeconds, t.expectedSeconds); if (r != null) ratios.push(r); at line 194 — and timeOverage() repeats it verbatim at line 340. Meanwhile the same PR establishes the opposite semantics for the headline in overview.ts:56: if (t.status !== "SUCCESS" || t.matureSkipped) continue;, justified in the comment as "a task that crashed in 10 seconds did not blow a time budget". Swapping turns for wall clock reverses the direction of that leakage: a crash used to deflate the turn ratio (2 turns against a budget of 8), but a task_timeout breach inflates the time ratio without bound (600s against a 60s line = 10×), so clamp01(mean(ratios) - 1) saturates at 1.0 and hands the row the full TIME_WEIGHT * 1.0 = 20 points that FAIL_WEIGHT * failRate already charged for the same failure. The user-visible strings are then untrue: line 165 returns "Passing, but well over expected time" and watchlist-view.tsx:520-521 titles the panel "🐌 Slow-task offenders" / "Passing, but well past their expected time" for rows that may be purely timeouts. Add the same isPass(t.status) guard both places (matching the headline), or change the three labels to stop claiming these are passing tasks.
Nits
-
[Axis 1] Two formatters render the same ratio two different ways on the same element —
2.63x expected(ASCII x) vs2.6×(multiplication sign) (evalboard/lib/timing.ts:104) —timing.ts:105return ratio == null ? "—" : \${ratio.toFixed(2)}x expected`;andtiming.ts:112return ratio == null ? "—" : `${ratio.toFixed(1)}×`;differ in both precision and glyph.task-stats.tsxshows both on one:fmtTimeRatio(ratio)in the hover title at:32andfmtTimeRatioCell(ratio)in the visible chip at:41— so a task hovers as2.63x expectedabove a chip reading2.6×.timing.ts:109-110justifies the one-decimal choice (One decimal, not two: the baseline is a min over a handful of runs), which argues the two-decimal variant should not exist at all. Collapse to one formatter (one decimal,×) and let the caller append" expected"`. -
[Axis 2] timing.ts carries two disagreeing definitions of "unscored": the ratio helpers reject
expectedSeconds <= 0while the title/stat helpers only check!= null, soexpected_seconds: 0reads as a real 0-second target (evalboard/lib/timing.ts:117) —timeRatio(timing.ts:39-45) and thereforewithinExpectedTimetreat a non-positive stamp as unscored, butexpectedTimeTitleuses a different predicate:
export function expectedTimeTitle(expectedSeconds: number | null): string {
return expectedSeconds != null
? `expected time: ${fmtTaskSeconds(expectedSeconds)}`
: "no expected time yet (needs a passing run on this harness)";
}and ExpectedTimeStat repeats it at evalboard/app/runs/[id]/[...task]/task-stats.tsx:66 ({expectedSeconds != null ? fmtTaskSeconds(expectedSeconds) : "—"}). With a stamped expected_seconds: 0 (or a negative one), the vs Expected cell renders — while the very tooltip on that same cell (task-grid.tsx:706) asserts expected time: 0m00s, and the task page's "Expected time" stat prints 0m00s as if a line existed. Extract the guard into one exported predicate (e.g. hasExpectedTime(s): s is number => s != null && Number.isFinite(s) && s > 0) and use it in all three places, so the module's own header claim — "A task with no expected_seconds is unscored … Every helper returns null there" (timing.ts:9-10) — is actually true of every helper.
3. [Axis 2] Non-null assertions on durationSeconds / expectedSeconds rely on an invariant that lives inside another function and that TypeScript cannot verify (evalboard/lib/watchlist.ts:343) — timeOverage re-points these assertions at the new, externally-stamped field:
const r = timeRatio(t.durationSeconds, t.expectedSeconds);
if (r == null) continue;
push(ratios, t.skill, r);
push(seconds, t.skill, t.durationSeconds!);
push(expected, t.skill, t.expectedSeconds!);The assertions are sound only because timeRatio returns null when either input is null — an invariant in a different module that the compiler never re-checks, so a future relaxation of timeRatio (e.g. defaulting a missing duration) silently converts these into undefined values pushed into number[] arrays that mean() then turns into NaN. Narrow locally instead: const { durationSeconds: d, expectedSeconds: e } = t; if (d == null || e == null) continue; then compute the ratio from d/e — same control flow, zero !, and the compiler enforces it. Same applies to r.avgSeconds / r.avgExpectedSeconds consumers.
4. [Axis 2] The new vsExp sort comparator returns NaN when both rows are unscored, silently dropping the taskId tie-break (evalboard/app/runs/[id]/task-grid.tsx:303) — ```ts
case "vsExp":
return (
(timeRatio(a.durationSeconds, a.expectedSeconds) ?? -Infinity) -
(timeRatio(b.durationSeconds, b.expectedSeconds) ?? -Infinity)
);
When neither row is scored — which is *every* row on every run that predates the `expected_seconds` stamp, i.e. all history and every run until the producer PR lands — this evaluates `-Infinity - -Infinity` = `NaN`. The caller at task-grid.tsx:549-551 then does `const c = compare(a, b, sort.key); if (c !== 0) return sort.dir === "asc" ? c : -c; return a.taskId.localeCompare(b.taskId);` — `NaN !== 0` is `true`, so it returns `NaN` and the alphabetical tie-break is never reached; ECMA-262 SortCompare coerces NaN to +0, so clicking "vs Expected" leaves the grid in raw run.json order rather than the intended alphabetical order. The other columns share the `?? -Infinity` idiom (pre-existing), but they are populated in practice while this one is empty by default. Guard the sentinel case, e.g. compare via a helper that maps null to a sentinel and returns `0` when both are null, or use `Number.isNaN(c) ? a.taskId.localeCompare(b.taskId) : ...` in the caller.
5. **[Axis 3] `pivotByHarness` is never exercised with `timePerPassedTask` — the only metric the new wall-clock chart plots** (`evalboard/app/_overview/__tests__/harness-series.test.ts:23`) — The PR widens `HarnessMetric` to three members (`harness-series.ts:15-18`), but the test factory hardcodes the new one to null —
```ts
successRate,
withinExpectedTimeRate,
timePerPassedTask: null,
— and both metric-specific assertions (harness-series.test.ts:103 and :115) use "withinExpectedTimeRate". TimePerPassedTaskChart calls pivotByHarness(data, harnesses, "timePerPassedTask") (wall-clock-chart.tsx:42-46), so the one metric that actually reaches the new chart has no pivot coverage. Give the factory a timePerPassedTask parameter and add one assertion mirroring line 103 for that key.
6. [Axis 3] Watchlist slow-task badge threshold moved from 1.5× to 2× with no view-level test (evalboard/app/watchlist/watchlist-view.tsx:546) — The red/amber gate changed value in this PR — r.avgTurnRatio > 1.5 became:
className={`ml-auto font-semibold rounded-full px-2.5 py-0.5 text-[11px] border ${r.avgTimeRatio > 1 + TIME_BUDGET_TOLERANCE ? "bg-red-50 text-red-700 border-red-200" : "bg-amber-50 text-amber-700 border-amber-200"}`}TIME_BUDGET_TOLERANCE is 1 (timing.ts:76), so the red cutoff is now 2×. app/watchlist/__tests__/watchlist-view.test.tsx was touched only to rename a fixture field (expectedTurns → expectedSeconds, line 38); its four tests (lines 56, 78, 102, 120) never render the renamed "🐌 Slow-task offenders" panel's badge. Add one test building a timeOverage row at 1.8× and one at 2.5× and assert bg-amber-50 vs bg-red-50, so the threshold is pinned rather than implicit.
7. [Axis 3] CE031's EXEMPT map gains its first entry, but nothing validates that an exemption names a real model or field (tests/lint/dead_config_fields.py:55) — The comment block promises a contract nothing checks:
# deliberate. Every entry must name a real field.
EXEMPT: dict[str, dict[str, str]] = {
"RunLimits": {
"expected_turns": (find_dead_config_fields only reads EXEMPT.get(model.__name__, {}) (line ~87) and never asserts the exempted names exist in model.model_fields. tests/test_custom_lint.py::TestCE031DeadConfigFields (line 2038) has four tests — test_exempt_field_not_flagged (line 2083) uses a synthetic model, so the real map is unvalidated. When expected_turns is finally dropped from RunLimits ("dropped in a later minor"), the exemption silently lingers. Add one test: for every Model in EXEMPT, assert the model is in CONSUMED_MODELS and every exempted key is in model.model_fields.
What's Missing
Parallel paths:
- 🟠
src/coder_eval/orchestration/run_limits.py::validate_run_limits— the existing non-blocking run-limits warning seam, surfaced bycoder-eval plan(plan_command.py:139) and by the orchestrator's_warn_on_ineffective_task_timeout— was not given anexpected_turns is deprecated and ignoredmessage, even though this PR deletedOrchestrator._check_expected_turns, the only code that ever produced output for the field. Pydantic'sdeprecated=fires on attribute access, and the CE031 exemption exists precisely because nothing reads the attribute any more, so loading a YAML that declaresexpected_turns(per the exemption text, ~930 of them) now emits nothing at all, on any surface, before the field is dropped in a later minor. (trigger: src/coder_eval/orchestrator.py) - 🟠
evalboard/app/runs/[id]/task-grid.tsxrenders every row twice — desktop<tbody>(654-657, cell at 705-708) and mobile card (794-797, Durationsubat 843-853) — each computingtimeRatio+tintForTimeRatio+expectedTimeTitleinline rather than through one shared row helper. Every fix in this column therefore has to land twice (the missingmatureSkippedguard, thetitle/MATURE_TOOLTIPswap, any threshold change), and only the desktop copy is reachable by the tests, whosecellFordeliberately scopes toscreen.getByRole("table")(task-grid.test.tsx:41-44). (trigger: evalboard/app/runs/[id]/task-grid.tsx) (restates: Axis 6: mature-skipped zero-duration sentinel unguarded in the task-grid render paths) - 🟡 The Python report surfaces lost the efficiency signal and gained no seconds-based replacement:
reports.py::_runtime_notes_linesdropped the overage blockquote,reports_html.py::_render_headerdropped the badge,reports_stats.pydroppedexpected_turns_overage, and nothing writes a duration-vs-expected note in their place. Wall-clock efficiency now exists only in the TypeScript dashboard, fed by an out-of-repo stamp — so a CLI-only or OSS user ofcoder-eval runhas no efficiency signal on any surface, which the PR does not state. (trigger: src/coder_eval/reports.py) (restates: Axis 7:expected_secondshas no producer in this repo) - 🟡
evalboard/lib/timing.tsdeclares itself a mirror of the runner'stiming.pytwice (header: "The dashboard reads that stamp rather than deriving its own, so a number here still matches the ping"; line 74: "Mirrorstiming.TOLERANCEon the runner side") but ships no parity mechanism. The repo's own convention for a hand-copied cross-language mirror is a parity test — CLAUDE.md mandates it forpricing.py⟷pricing.ts, enforced bylib/__tests__/pricing-parity.test.ts. Here the runner's owntime_per_passed_taskandtolerancearrive in every run.jsontimingblock, so the mirror is checkable at runtime, and nothing checks it. (trigger: evalboard/lib/timing.ts) (restates: Axis 1: the run.jsontimingblock is parsed into RunOverview but unread)
Tests:
- 🟠
evalboard/app/trends/trends-view.tsxchanged 100 lines to add the expected-time column (the newtimeCellClasses/tintForTimeRatio/fmtTimeRatioCellcell at 294-320) and is the only surface that guardsmatureSkippedcorrectly, yetapp/trends/__tests__/trends-view.test.tsxwas not touched at all — its six tests cover the status strip and maturity badges only. The one implementation the run grid should be copied from has nothing pinning it. (trigger: evalboard/app/trends/trends-view.tsx) - 🟠
evalboard/app/_overview/wall-clock-chart.tsx(TimePerPassedTaskChart, renamed from turn-budget-chart) has no test file —app/_overview/__tests__/holds onlyharness-legendandharness-series. Its non-trivial new logic is thewithinByPointmap keyed on the string join`${p.harness}|${p.timestamp}`(47-56) and read back with the same join in the tooltipsecondarycallback (95-105): a key-shape mismatch or a harness-normalization difference returnsnulland the within-expected line silently disappears from every hover, with nothing failing. _(trigger: evalboard/app/overview/wall-clock-chart.tsx) - 🟡 Two exported helpers of the new
lib/timing.tshave no direct unit test inlib/__tests__/timing.test.ts:timeCellClasses(tint → Tailwind class, including thenull→ untinted branch) andfmtTimeRatioCell(the one-decimal×chip added by the final commit, which drives task-grid, task-stats and trends). Both are only covered incidentally through component tests that assert rendered strings/classes, so a change to either is caught in three component suites or not at all. (trigger: evalboard/lib/tests/timing.test.ts) - 🟡 The mobile card branch of the task grid — a full second rendering of Duration + the new ratio + tint (task-grid.tsx:794-797, 843-853) — has zero assertions;
cellForscopes queries to the desktop<table>by design, and the newvs Expectedsuite (task-grid.test.tsx:124-157) never queries the cards. Add card-scoped cases for at least the tinted, unscored and mature rows, or extract one shared row-model helper so the desktop tests cover both. (trigger: evalboard/app/runs/[id]/tests/task-grid.test.tsx)
Downstream consumers:
- 🟡 The same PR renamed
TaskHistoryEntry.expectedTurns→expectedSeconds(lib/trends.ts:62, populated at :309), which changes the payload cached under["aggregate-task-trends-v3"](trends.ts:258) and["history-for-task-v2"](trends.ts:339) — neither key was bumped, despite both carrying a version token added by earlier, smaller shape changes with the explicit comment "the key bump keeps a stale pre-deploy payload from being served into new code". Applying the codebase's own convention to this PR means three key bumps, not one. (trigger: evalboard/lib/trends.ts) (restates: Axis 2: the per-rununstable_cachekey was not versioned even though the cachedPerRunshape changed) - 🟡 The watchlist attention score swapped its input metric but kept the normalizer and weight unchanged:
timeOverage = clamp01(mean(ratios) - 1)withTIME_WEIGHT = 20(watchlist.ts:209-213) is the verbatim turn-ratio formula. A turn ratio is bounded bymax_turns; a wall-clock ratio is not — onetask_timeoutbreach at 600s against a 60s line is 10×, which alone drags a skill's mean past 2 and saturates the full 20-point segment. The scoring change is a formula change and neither the normalizer, the weight, nor a per-row cap was revisited for the new distribution. (trigger: evalboard/lib/watchlist.ts) (restates: Axis 8: watchlist time aggregates include failed/timed-out tasks with no outcome filter) - 🟡 While
expected_secondsis unstamped, the degradation is not uniformly "a blank column": inwatchlist.ts::attentioneverytimeRatioreturns null, soratiosis empty,timeOverageis 0 for every skill, and 20 of the attention score's 100 points drop out — the ranking changes shape and the "🐌 Slow-task offenders" panel empties, rather than a cell reading—. The PR's forward-compatibility argument covers the em-dashed columns but not the reordered attention list a reviewer would read as a real improvement. (trigger: evalboard/lib/watchlist.ts) (restates: Axis 7:expected_secondshas no producer in this repo)
Display & mapping dicts:
- 🔵
COLUMN_HELP(task-grid.tsx:46-50) isPartial<Record<SortKey, string>>and gains onlyvsExp;durationandturnsstill have no entry, so those<th>s rendertitle={undefined}. Both were redefined by this PR —lib/turns.tsnow opens with "Turns are shown as a plain count on task views; they are no longer an efficiency signal", and Duration acquired a sibling ratio column — so the two columns whose meaning changed are the two with no help string at all. (trigger: evalboard/app/runs/[id]/task-grid.tsx) - 🔵 Retired turn-budget vocabulary survives in surfaces this PR touched, including new test copy:
app/_overview/__tests__/harness-legend.test.tsxstill assertssuffix="within turn budget"/emptyText="no tasks with a turn budget";app/page.tsx:222-226still comments the analytics block as "daily success / turn-budget charts"; andlib/__tests__/trends.test.ts:174is still named "propagates totalTurns and expectedTurns" for a field that no longer exists. _(trigger: evalboard/app/overview/tests/harness-legend.test.tsx) (restates: Axis 1: the turn-budget removal sweep left stale comments and retired vocabulary behind) - 🔵
docs/tutorials/04-writing-a-task.mdhad its body rewritten (expected_turns→max_turns, plus the new efficiency paragraph) but not its framing: the frontmatterdescription(lines 2-5) still promises "prompt, isolated sandbox, turn budget, and three success criteria", and line 11 still says the reader will author "a soft turn budget". The frontmatter description is the published page/site blurb, so the tutorial advertises a concept its own body says was retired. (trigger: docs/tutorials/04-writing-a-task.md) (restates: Axis 1: the turn-budget removal sweep left stale comments and retired vocabulary behind)
Daily/nightly:
- 🟠 The PR states no nightly-pipeline plan for the reverse deploy skew it creates.
eval_result_to_task_dictis the sole writer of run.json task rows and stops emittingexpected_turns/expected_turns_overagethe moment the first post-merge nightly runs, while the currently deployed board still reads them (runs.ts:738/:1045on main) and gatesturnBudgetRateForTaskson them — blanking the front-page chart, both Turns tint sites, and the watchlist turn-overage segment. This repo has no evalboard deploy workflow (.github/workflows/has none), and the PR body files the fix under "Left to do" as a manualworkflow_dispatchin another repo rather than as a merge prerequisite. (trigger: src/coder_eval/reports_experiment.py) (restates: Axis 8: droppingexpected_turnsfrom the run.json task_results[] contract blanks the deployed dashboard) - 🟡
docs/REPORT_SCHEMA.mdis the published cross-repo run.json contract and this PR leaves it stale in both directions: the diff removesexpected_turnsfrom the turn-accounting list (line 79) and documents neither the per-rowexpected_secondsnor the run-leveltimingblock that the external eval_runner now stamps and that four evalboard surfaces read. The producer of the PR's only input metric therefore has no schema entry anywhere in this repo. (trigger: docs/REPORT_SCHEMA.md) (restates: Axis 7:expected_secondshas no producer in this repo)
Harness & Lint Improvements
Static checks (lint / type):
- [ce-lint] CE043 — evalboard dead-projection gate. Port CE031's dead-config idea to the TS read path: a new
tests/lint/evalboard_dead_fields.py(whole-tree text/regex rule, wired astests/test_custom_lint.py::TestCE043EvalboardDeadProjections— not aBaseRule, since the runner is Python-AST-only) that, for each field declared on the run.json projection types inevalboard/lib/runs.ts(RunOverview,RunOverviewTask,RawTiming,RawTaskResult), counts identifier occurrences acrossevalboard/**/*.{ts,tsx}excludingnode_modules, the declaration itself, and__tests__/fixtures. Zero non-test, non-write occurrences = a field computed per task/per run on the front page's hot read path that nothing reads; requires anEXEMPTentry with a reason. Pair it withknip(orts-prune) added tomake evalboard-verifyfor the exported-symbol half, which catches the orphan helper directly. Forbidden pattern in one line: a projection field or exported helper inevalboard/lib/that only ever gets written. Prevents: Finding 1 (high, cross-axis 1/2/3/5/7/8):RunOverviewTask.visibleTurns(runs.ts:945) + the orphaned exportedvisibleTurnsFromRaw(runs.ts:987) +RunOverview.timePerPassedTask(runs.ts:978, written at :1098) + 7 of the 8 fields of the brand-newRawTiminginterface (runs.ts:468-477), all with no reader anywhere. - [ce-lint] CE044 — retired-vocabulary gate. A
RETIRED: dict[str, str]map (dead symbol → what replaced it / the PR that removed it) intests/lint/retired_vocabulary.py, scanned oversrc/,tasks/,experiments/,docs/,plugins/,.claude/andevalboard/**(excludingnode_modules,.next, and an explicit allowlist for CHANGELOG/migration notes). Any surviving mention — comment, docstring, YAML comment, test name, prose — fails with the map's replacement text. Rule of use: a symbol goes into the map only when it is fully removed; a deliberate deprecation shim (RunLimits.expected_turns) is carried as an allowlist entry instead, exactly like CE031'sEXEMPT. Seed withexpected_turns_overage,turnBudgetRate,openHelp, and the phrase "within expected turns". Prevents: Finding 4 (medium, axes 1/5/7): the orphaned 3-line comment inexperiments/default.yaml:24-26that now mis-annotatestask_timeoutas "does NOT abort the run" in the layer-1 defaults file every task inherits; thevisibleTurnsFromRawdocstring atruns.ts:984-985still citing the deleted "within expected turns" metric as its justification; and thetask-grid.tsxcomment describing the removedopenHelpstate. - [ce-lint] CE045 — single pass predicate in evalboard. Forbid a raw
"SUCCESS"string comparison (=== "SUCCESS",!== "SUCCESS",status === "SUCCESS"inside a filter) anywhere underevalboard/exceptevalboard/lib/status.ts, which ownsstatusCategory/isPassStatus. Implement as an ESLintno-restricted-syntaxselector once ESLint exists in evalboard (see CE047's note — the package has no linter today, onlytsc,vitest,next build), or as a text rule alongside CE043 if the team prefers no new toolchain. The file already carries the convention as a prose comment atoverview.ts:833-836; this makes it mechanical. Note the fix must be a lib-wide sweep, not a two-line edit — converting only the new call sites would introduce the asymmetry the finding warns about. Prevents: Finding 2 (medium, axes 1/2):overview.ts:59and:81— the numerator/denominator of both new headline time metrics — plus the pre-existingoverview.ts:975,:1087,watchlist.ts:87,trends.ts:160that the same sweep must convert. - [ce-lint] CE046 — no hardcoded time-budget ratio in prose or defaults. Forbid the literals
2×,1.5×,x expected,× expectedand the bare numeric defaults1.5/2insidegetTimeRatioThresholds()from appearing outsideevalboard/lib/timing.ts; the single source isTIME_BUDGET_TOLERANCE, and UI strings must interpolate1 + TIME_BUDGET_TOLERANCE. Also require the red tint default to derive from that constant (const RED_RATIO = 1 + TIME_BUDGET_TOLERANCE) rather than repeat2, so the module comment's claim that "the cell and the rollup agree" becomes structurally true instead of true-by-coincidence-at-defaults.watchlist-view.tsx:546already does the right thing and is the in-tree precedent. Prevents: Finding 3 (medium, axes 1/5/7): six non-derived copies of the boundary —timing.ts:25(env-overridable red default),wall-clock-chart.tsx:104,task-grid.tsx:48,overview.ts:32and:43comments,harness-legend.test.tsx:66,74. Also finding 5 (low): thex expectedclause forces the ASCII/×-glyph and 2-decimal/1-decimal formatter split attiming.ts:105vs:112to collapse to one formatter. - [ce-lint] CE047 — no server-only env read reachable from a client component. Walk the
evalboardimport graph from every file whose first line is"use client"; flag anyprocess.env.Xread in that transitive closure whereXdoes not start withNEXT_PUBLIC_.next.config.mjscarries noenvpassthrough, so such a read isundefinedin the browser, silently falls back to its default, and produces a hydration mismatch because Next SSRs the same component on the server where the var resolves. The repo already states the rule in prose atrun-view.tsx:174("process.env isn't readable in this client component") andlib/edition.tsis the worked precedent for threading the value down as a prop. This rule (and CE045/CE046/CE048's AST clauses) are the concrete argument for adding an ESLint config toevalboard/and gating it inmake evalboard-verify. Prevents: Finding (medium, axis 7):timing.ts:24-25readingEVALBOARD_TIME_YELLOW_RATIO/EVALBOARD_TIME_RED_RATIOviatintForTimeRatio's default argument at three client call sites (task-grid.tsx:658,:798,trends-view.tsx:299) while the server-renderedtask-stats.tsx:39honors them — one setting, two different thresholds across the board. Vitest'svi.stubEnvtests pass regardless, so CI is green while the knob is inert. - [ce-lint] CE048 — versioned
unstable_cachekeys. Require the first element of thekeyPartsarray in everyunstable_cache(...)call underevalboard/to match-v\d+$. Next's key ishash([MAIN_KEY_PREFIX, fetchCacheKeyPrefix, cb.toString() + keyParts])with no build id participating, so an unversioned key is byte-identical before and after a payload-shape change. The codebase already follows the convention attrends.ts:258(aggregate-task-trends-v3) and:339(history-for-task-v2) — this makes it enforced rather than remembered. This rule catches the missing token; it cannot know a shape changed, which is what the paired harness snapshot (below) is for. Prevents: Finding (medium, axis 2):overview.ts:292["evalboard-per-run", source.id]never versioned despiteexpectedTurns→expectedSecondsand+timePerPassedTask. The same rule flags thataggregate-task-trends-v3andhistory-for-task-v2also needed bumps this PR (trends.ts:62changedTaskHistoryEntry), i.e. three bumps, not one. - [ce-lint] CE049 — run.json task-row contract parity. Extend
tests/lint/doc_schema_parity.py(CE030's engine) with a second direction: AST-read the key set of the dict literal returned byreports_experiment.eval_result_to_task_dict(the sole writer of run.json task rows, perorchestration/batch.py:673) and require every key to appear as inline code indocs/REPORT_SCHEMA.md, and vice versa. A key that disappears from the producer without disappearing from the doc — or a doc that quietly drops a concept — fails the build, which is the signal a deployed consumer needs. Prevents: Finding (high, axis 8):reports_experiment.py:192silently dropping"expected_turns"and"expected_turns_overage"from every new run.json, blanking four surfaces of the currently-deployed dashboard with no release note. Also finding (high, axis 7):docs/REPORT_SCHEMA.mdlosingexpected_turnsfrom the turn-accounting list while gaining noexpected_secondsentry. - [ce-lint] CE050 — externally-stamped fields must be declared as such. A companion map
EXTERNALLY_STAMPED: dict[str, str](run.json field → producing repo/module) in the same doc-parity module. Any run.json field documented indocs/**as "derived"/"stamped"/"computed" that has no writer undersrc/must be listed there, and each doc page mentioning it must carry the producer's name (grep the surrounding paragraph for the map's value). This turns "the eval runner does it" into a checked statement about a component that is not part of coder-eval. Prevents: Finding (high, axis 7):grep -rn 'expected_seconds' src/ tests/returns nothing, yetdocs/TASK_DEFINITION_GUIDE.md:316-321anddocs/tutorials/04-writing-a-task.md:66-67present the stamp as automatic, andtiming.ts:120tells OSS users "no expected time yet (needs a passing run on this harness)" — a promise a standalonecoder-eval runcan never fulfil, on an ungated column (task-grid.tsx:345) that ships permanently em-dashed. - [ce-lint] Extend CE031 to validate its own
EXEMPTmap. Add one test toTestCE031DeadConfigFields: for every key intests/lint/dead_config_fields.py::EXEMPT, assert the model is inCONSUMED_MODELSand every exempted field name is inmodel.model_fields. The module comment already promises "Every entry must name a real field", butfind_dead_config_fieldsonly doesEXEMPT.get(model.__name__, {})and never checks the names exist; the four existing tests use synthetic models, so the real map is unvalidated. Same shape as the runner's duplicate-rule-id assertion — a self-check on the lint config, not onsrc/. Prevents: Finding (low, axis 3): the map's first-ever entry (RunLimits.expected_turns, exempted as "dropped in a later minor") would silently linger as a stale exemption once the field is actually removed, re-opening the dead-config hole CE031 exists to close. - [ce-lint] ESLint baseline for
evalboard/(the package has no linter at all today). Beyond the rules above, three off-the-shelf/near-off-the-shelf settings, wired intopnpm verifysomake evalboard-verifygates them: (a)@typescript-eslint/no-non-null-assertion— the!operators leaning on an invariant that lives in another module; (b) ano-restricted-syntaxselector banning?? -Infinity(or any non-finite sentinel) as an operand of-inside a comparator, since-Infinity - -InfinityisNaNandNaN !== 0swallows the tie-break; (c) a selector banningexpectedSeconds != nulloutsidelib/timing.ts, forcing one exportedhasExpectedTime(s): s is numberpredicate so the module header's claim ("Every helper returns null there") holds. Pluseslint-plugin-jsx-a11ywith a project rule banningtitle=as the only help affordance on non-focusable JSX (th,h1–h6, barespan). Prevents: Finding (low, axis 2)watchlist.ts:343-344t.durationSeconds!/t.expectedSeconds!; finding (low, axis 2)task-grid.tsx:303'svsExpcomparator returningNaNfor every unscored pair — i.e. every historical run — leaving the grid in raw run.json order; finding (low, axis 2) the two disagreeing "unscored" predicates attiming.ts:117andtask-stats.tsx:66that render0m00sforexpected_seconds: 0; and finding (medium, axis 7) the help demoted totitleon a non-interactive<th>(task-grid.tsx:626) and<h2>(page.tsx:288) afterHelpPopover/ColHelpIconwere deleted. - [pyright] Type-level exhaustiveness for the harness-metric test factory (the TypeScript half of the same "let the type checker enumerate the cases" gate —
tsc --noEmitinmake evalboard-verify; this schema has notsckind). Change theharness-series.test.tsrow factory to build its metrics from aRecord<HarnessMetricKey, number | null>derived fromkeyof HarnessMetric, so widening the metric union fails compilation until the new member is supplied and asserted, instead of being quietly defaulted. Same pattern applies to any future pivot-style union. Prevents: Finding (low, axis 3):HarnessMetricwidened to three members (harness-series.ts:15-18), but the factory hardcodestimePerPassedTask: nulland both metric assertions (:103,:115) usewithinExpectedTimeRate— so the only metric the new wall-clock chart plots (wall-clock-chart.tsx:42-46) has zero pivot coverage.
Harness improvements (not statically reachable):
- Producer-generated run.json fixture as the evalboard test corpus. Add a
make evalboard-fixturetarget that runs the Python producer (eval_result_to_task_dictover a cannedEvaluationResult) and writesevalboard/lib/__tests__/fixtures/run.json, committed and re-generated + diffed in CI. Every evalboard reader test then parses the real producer output instead of a hand-written literal, so a key removed on the Python side breaks the JS suite in the same PR. Why not static: Producer and consumer are in different languages and (for the deploy) different repos; the coupling is data-shaped — knowing the emitted key set for a realistic result requires actually executing the serializer (defaults,None-elision, nested dicts), which no single-tree lint can compute. CE049 checks the producer against the docs; only an executed fixture checks it against the consumer. Prevents: Finding (high, axis 8) —expected_turns/expected_turns_overageremoved atreports_experiment.py:192with every evalboard test still green because each fixture is hand-authored. - Backward-skew CI job. Build evalboard from
origin/main(i.e. what is deployed) and feed it a run.json produced by the PR HEAD; assert the headline metric functions (turnBudgetRateForTasks/withinExpectedTimeRateForTasks/timePerPassedTaskForTasks) return non-null for at least one run and that no chart series is empty. Today only the forward direction (new board, old run.json) was verified — the reverse is the one that actually happens on merge. Why not static: Requires two builds of the dashboard plus executing the metric code against real rows; "old consumer + new producer" is a cross-commit property, invisible to any check that sees one tree. Prevents: Finding (high, axis 8) — the front-page "Within Expected Turns (%)" chart plotting nothing, run-page and mobile Turns cells losing their tint, and the watchlist turn-overage segment going empty, from the first post-merge nightly until someone remembers the manualworkflow_dispatchdeploy. - Cached-payload shape snapshot bound to the cache key. One vitest snapshot per
unstable_cachecall recording the literal key string plus the sorted field set of the payload type (via asatisfies-driven key list or a smallts-morphextraction). Any field added/removed/renamed fails the snapshot, and the fix message says: bump-vNand re-snapshot. Why not static: The invariant is "shape changed ⇒ key string must change", which needs a stored baseline of the previously-shipped shape. A single-tree lint (CE048) can only require that a version token exists, never that it was incremented for this change. Prevents: Finding (medium, axis 2) —evalboard-per-runserving a pre-deployPerRuninto new code for up to 300 s, blanking the wall-clock chart,/trendsexpected-time cells and the watchlist time rail; plus the two unbumped sibling keys (aggregate-task-trends-v3,history-for-task-v2) whoseTaskHistoryEntryalso changed attrends.ts:62. - Shared pathological-row fixture set + a surface checklist test. One exported fixture module with the four rows every time surface must handle — carried-forward (
matureSkipped: true, durationSeconds: 0, expectedSeconds: 100), unscored (expectedSeconds: null), degenerate (expectedSeconds: 0), and failed/timed-out (status: "TASK_TIMEOUT", durationSeconds: 600) — plus a checklist test that enumerates every module importingtimeRatioand fails when one has no case for each row. The repo already documents the 0-duration contract twice (runs.ts:948-950,trends.ts:162-164) and the PR's own fixture encodes it (overview.test.ts:211-222), so the fixture is codifying an existing written rule. Why not static: The correct handling differs per surface ——in a cell, excluded from an aggregate, or legitimately included in a numerator (timePerPassedTaskForTaskskeeps failure seconds on purpose) — so it is semantic per call site, only observable by rendering/aggregating. A lint can flag an unguardedtimeRatio(t.durationSeconds, …)shape, but not which of the three treatments is right. Prevents: Finding (high, axis 6) — a carried-forward task rendering a green0.0×"beat its expected time by 100×" attask-grid.tsx:654and:794whiletrends-view.tsxand both aggregates guard correctly; finding (medium, axis 6) —watchlist.ts:194and:340folding unbounded timeout ratios (600 s/60 s = 10×) into a panel titled "Passing, but well past their expected time", double-charging the same failure the fail-rate segment already scored; finding (low, axis 2) —expected_seconds: 0rendering—in the cell and0m00sin the tooltip on the same element. - Client-bundle env audit after
next build. A CI step that greps the emitted.nextclient chunks for eachEVALBOARD_*name the code reads, asserting that non-NEXT_PUBLIC_names appear in server chunks only and that any knob intended for the browser isNEXT_PUBLIC_-prefixed and actually inlined. Pair with documenting both threshold vars inevalboard/README.md, and consider extending CE027's parity to the reverse direction for theEVALBOARD_prefix it currently excludes (a var read inevalboard/must be documented there). Why not static: Whether a value survives into the client bundle is a property of the bundler's output for the currentnext.config.mjs, not of the source tree; CE047 catches the source-level shape, this proves the deployed artifact. Prevents: Finding (medium, axis 7) —EVALBOARD_TIME_YELLOW_RATIO/EVALBOARD_TIME_RED_RATIOinert in the browser, undocumented anywhere, and green in CI becausevi.stubEnvexercises Node'sprocess.envrather than the bundle. - Make the dashboard deploy part of merging a contract change. Either add a
repository_dispatch/workflow_calldeploy that fires on merges touching the run.json contract, or apr-checksjob that fails when a PR changes the keys emitted byeval_result_to_task_dictandevalboard/lib/runs.tsunless the PR body carries an explicitDeploy: <workflow>line. Today.github/workflows/here has no evalboard deploy at all and the required dispatch lives only in the PR's "Left to do" notes. Why not static: Deployment ordering is a process/infrastructure property outside the repository tree; no tree-local check can know whether the consumer has been redeployed. Prevents: Finding (high, axis 8) — the exposure window between merge and a remembered manual dispatch, during which every new run.json degrades three-to-four dashboard surfaces with no error and no gap indicator. - Threshold- and a11y-pinning tests for shared-constant UI gates. (a) Render a
timeOveragerow at 1.8× and one at 2.5× and assertbg-amber-50vsbg-red-50, so the badge cutoff is pinned to1 + TIME_BUDGET_TOLERANCErather than implicit. (b) A keyboard/AT reachability test (jest-axeor a focus-order assertion) for column and chart help, asserting the definition is reachable without a pointer hover. Why not static: A tint/threshold boundary is a rendered-output property of a value that lint sees only as a number, and assistive-technology reachability is a runtime focus/announcement property — ESLint can ban thetitle-only anti-pattern shape but cannot prove the replacement help is actually reachable. Prevents: Finding (low, axis 3) — the watchlist slow-task badge moving from 1.5× to 2× with all four existing tests never rendering that panel; finding (medium, axis 7) — the front-page metric definition and the per-column help becoming hover-only afterHelpPopover/ColHelpIconwere deleted, unreachable on touch and by keyboard.
Top 5 Priority Actions
- Guard the carried-forward sentinel in both
vs Expectedrender paths —evalboard/app/runs/[id]/task-grid.tsx:654(desktop) and:794(mobile) calltimeRatio(t.durationSeconds, t.expectedSeconds)with nomatureSkippedcheck, so a task that never executed renders a green0.0×beside a real expected time; mirrortrends-view.tsx(t.matureSkipped ? null : timeRatio(...)plusMATURE_TOOLTIP) and pin it with avsExpCellFortest. - Decide the deploy order for the dropped run.json contract at
src/coder_eval/reports_experiment.py:192— removingexpected_turns/expected_turns_overageblanks the currently-deployed board's headline chart, both Turns tint sites and the watchlist turn-overage rail with no error, and the evalboard deploy is a manualworkflow_dispatch, so either keep emittingexpected_turnsfor one release or make the dispatch a stated merge step. - Add the pass-only filter the headline already uses to the two watchlist time aggregates —
evalboard/lib/watchlist.ts:194and:340push every ratio regardless of outcome, and because atask_timeoutinflates the time ratio without bound (unlike the old turn ratio, which a crash deflated), the "🐌 Slow-task offenders" panel can list an all-timeout skill under "Passing, but well past their expected time" while double-charging it the full 20-point time segment. - Stop presenting
expected_secondsas automatic — nothing insrc/writes it (only the out-of-repoeval_runner), yetdocs/TASK_DEFINITION_GUIDE.md:314,docs/tutorials/04-writing-a-task.md:66and the tooltip atevalboard/lib/timing.ts:120("needs a passing run on this harness") promise a stamp an OSScoder-eval runcan never produce, and the ungatedvs Expectedcolumn ships to OSS permanently em-dashed; name the external producer in both docs pages plusdocs/REPORT_SCHEMA.mdand reword the empty state. - Finish the data-layer plumbing for the new metric: delete
visibleTurns/visibleTurnsFromRawand the inertRawTiming(7 of 8 fields have no reader) or wiretimePerPassedTask/toleranceintooverview.tsas the comments atevalboard/lib/runs.ts:465,:977and:984claim, and bump the three unversionedunstable_cachekeys whose payload shape changed (overview.ts:292,trends.ts:258,trends.ts:339).
Stats: 0 🔴 · 4 🟠 · 7 🟡 · 7 🔵 across 8 axes reviewed.

The consumer half of Proposal: optimizing for wall clock time instead of expected turns. The producer is coder_eval_uipath#86, which derives each task's expected wall clock from run history and stamps
expected_secondsintorun.json.Safe to merge in either order: runs without the stamp render as unscored rather than erroring.
Screenshots
Why the turn budget had to go
skillstouchedexpected_turns; six revised a valuemin_turns_success, the luckiest run ever seen; 44.7% of passing budgeted tasks exceed budget on a healthy night300against a p90 actual of 52, so they are permanently greenReadand a 20-minute deploy both count 1What changes on the dashboard
uipath-troubleshootcluster the duration sort cannot see.7m29s 3.1×) and gets its own column in the per-task run history, so the number is readable without hovering.2m18s / 1m22s expectedinstead of turn counts.Ratios render to one decimal. The second decimal is fake precision: the baseline is a
minover a handful of runs (p10 over ten), and a task's own night-to-night spread is wider than the digit it would add.The dashboard derives no baseline of its own. It reads the
expected_secondsthe runner stamped, so a number rendered here still matches the Slack ping that announced that run, and an unscored task (no passing run yet on that harness, or a run predating the stamp) reads as unscored everywhere rather than as "on target".Two bugs that only running it against real data exposed
Both were invisible to the tests and are fixed here with regression coverage.
The board disagreed with the runner about its own headline. A codex nightly that stamps
3m12srendered as1m17s.timePerPassedTaskForTaskscounted mature-skipped rows in the denominator while the runner excludes them from both sides, so it divided real seconds by 681 carried-forward passes that never ran.withinExpectedTimeRateForTaskshad the same leak and survived only by accident, because those rows carry no stamped line.The delta compared across harnesses. Consecutive nightlies alternate harness, so a codex run was being measured against the previous claude-code run and reported
▼ 75% vs prev, which described the schedule rather than a change in speed. That delta is now gone from the card entirely, for the reason above.One semantic change worth reviewing
withinExpectedTimeRateForTasksscores passing tasks only. The turn rate it replaces counted a budgeted failure as over budget. Two reasons that does not carry over: a task that crashed in 10 seconds did not blow a time budget, and folding failures in would make a pass-to-timeout regression read as an efficiency gain once the slow pass stopped counting. Failure is already the pass rate's job, and the seconds burned failing are already in the headline's numerator (total seconds of every task that ran, over the number that passed).expected_turnsis deprecated, not deletedThe field stays on
RunLimits, accepted and ignored, markeddeprecated, with a CE031 exemption that states why. Deleting it now would fail every one of the ~930 skills task YAMLs that still declare it, sinceRunLimitssetsextra="forbid", and would break any external suite that sets it (this package publishes to public PyPI and has anADOPTERS.md). Removal belongs in its own release once those YAMLs are cleaned, and keeping the field here means no release-and-pin chain is on this PR's critical path.Every consumer is gone: the orchestrator's one-shot warning and its two call sites,
reports_stats.expected_turns_overage, the HTML header badge, the markdown Run-time Note, and the per-rowexpected_turns/expected_turns_overagereport fields. Docs updated to describe the derived line instead.Testing done
pnpm vitest run: 523 passed, 43 files.tsc --noEmit: clean.next build: clean.uv run pytest -m "not live and not lint": 4175 passed. Four failures are pre-existing onmainin this environment and untouched by this branch:test_litellm_route(us.vseu.Bedrock profile, no litellm file in the diff) and threetest_reports_stats_nonfinitecases (cohens_d→statistics.stdevon NaN; verified against 3.12.5 as well, so it is environment-local rather than a version issue, andcohens_dis byte-identical tomain).ruff format --check+ruff check: clean. Custom CE lint: 344 passed.Left to do
mainby a few non-conflicting commits and needs an update before merge.workflow_dispatchoncoder_eval_uipath/.github/workflows/deploy-evalboard.ymlwithref: main. That workflow does not fire on merge.🤖 Generated with Claude Code