Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
2585ffb
feat(timing): book each turn's head and tail as their own buckets
uipreliga Sep 11, 2026
7637f36
test(lint): 2/4 — widen CE058 to the turn head/tail buckets
uipreliga Sep 11, 2026
1f6f2a8
feat(evalboard): 3/4 — name the harness head and tail in the timeline…
uipreliga Sep 11, 2026
8d370ce
feat(timing): 4/4 — assert the buckets in replay, and record what the…
uipreliga Sep 11, 2026
b9eadf5
fix: code review fixes for turn head/tail timing
uipreliga Sep 11, 2026
bb05482
docs(harness): record three guards the head/tail review could not close
uipreliga Sep 11, 2026
ef53e8e
docs(harness): widen the measured head/tail figures to six turns per …
uipreliga Sep 11, 2026
d154d87
test(timing): unify the fixture clocks and assert the four-bucket ide…
uipreliga Sep 11, 2026
6ebfccf
test(harness): pin why claude-code's zero head is left as a clamp
uipreliga Sep 11, 2026
67f9f7a
docs(harness): claude-code's generation windows are not tool-subtracted
uipreliga Sep 11, 2026
966fac7
fix(claude-code): subtract tool execution from the generation windows
uipreliga Sep 11, 2026
d28686c
fix(antigravity): 1/3 — give every generation a message_id
uipreliga Sep 11, 2026
0f5d033
test(lint): 2/3 — CE060, an AssistantMessage must declare its message_id
uipreliga Sep 11, 2026
8765248
docs(harness): 3/3 — message_id is what splits the timeline
uipreliga Sep 11, 2026
57257cc
fix: code review fixes for antigravity-message-id
uipreliga Sep 11, 2026
cb7d6f0
docs(harness): register the message_id gaps the final review surfaced
uipreliga Sep 11, 2026
517e376
fix(timing): bracket the head and tail on the main thread only
uipreliga Sep 11, 2026
462a204
docs(timing): every harness subtracts tool time now, not two
uipreliga Sep 12, 2026
712526f
feat(timing): 1/6 — a two-sided residual gate for the four-bucket ide…
uipreliga Sep 12, 2026
fd451fe
refactor(timing): 2/6 — one close_window() for the tiling reducers
uipreliga Sep 12, 2026
f078282
fix(timing): 3/6 — a tool that closes between two windows is not mode…
uipreliga Sep 12, 2026
125b388
test(lint): 4/6 — CE061, a window must come from the shared helper
uipreliga Sep 12, 2026
1347a8c
fix(timing): 5/6 — one clock basis per turn on antigravity and pi
uipreliga Sep 12, 2026
d0d30f2
docs(harness): 6/6 — the timing architecture as it now stands
uipreliga Sep 12, 2026
b2a8c30
fix: code review fixes for timing-architecture-standardization
uipreliga Sep 12, 2026
e1c4af9
test(harness): stamp the two codex fixtures that timed themselves wit…
uipreliga Sep 12, 2026
702935e
docs(harness): register that the golden corpus cannot see a timing va…
uipreliga Sep 12, 2026
3baaa53
feat(timing): 1/7 — a committed, ms-exact magnitude sensor
uipreliga Sep 12, 2026
8cc587c
test(harness): 2/7 — OpenCode and Pi get a corpus worth replaying
uipreliga Sep 12, 2026
95b52e2
fix(timing): 3/7 — a naive/aware mix names the pair that disagreed
uipreliga Sep 12, 2026
306015d
feat(timing): 4/7 — one meaning for harness_startup_ms, on all five
uipreliga Sep 12, 2026
520c1ad
refactor(timing): 5/7 — one tool-subtraction, at the collector seam
uipreliga Sep 12, 2026
8ab9d24
feat(reports): 6-7/7 — the offline report carries the buckets; a TS N…
uipreliga Sep 12, 2026
73c3b11
fix: code review fixes for turn-timing-p0-p3
uipreliga Sep 12, 2026
3b9338c
docs(harness): register what the turn-timing run could not guard
uipreliga Sep 12, 2026
588dea6
fix(timing): a TurnClock for claude-code, and pi's two captured defects
uipreliga Sep 12, 2026
74b157a
fix(timing): stamp the turn bracket off the turn clock (CE064)
uipreliga Sep 12, 2026
a5bfeea
feat(timing): name the setup and grading phases; union a row's tool time
uipreliga Sep 12, 2026
6d0122d
fix(timing): setup_ms marks from the task's start, not from _setup()
uipreliga Sep 12, 2026
c34e2cb
test(timing): 1/8 — the bracket's SOURCE, not just its presence
uipreliga Sep 12, 2026
cf20c07
fix(timing): 2/8 — a published window must match its own bounds
uipreliga Sep 12, 2026
3243ea4
fix(timing): the bracket fixture's anchor cannot expire, and the tail…
uipreliga Sep 12, 2026
9c72c39
fix(evalboard): 3/8 — an unbounded tool call contributes to no bucket
uipreliga Sep 12, 2026
e0633a1
docs(harness): 4/8 — say what each harness's clock basis actually is
uipreliga Sep 12, 2026
74701da
refactor(timing): 5/8 — one home for the rules, and a stored tool union
uipreliga Sep 12, 2026
8fa3e53
refactor(timing): 6/8 — the two sensors share one selection rule
uipreliga Sep 12, 2026
37211b2
feat(reports): 7/8 — the buckets reach every surface through one func…
uipreliga Sep 12, 2026
8b4d4a8
refactor(opencode): 8/8 — one duplicate parse
uipreliga Sep 12, 2026
0cf7101
fix: code review fixes for turn-timing-consolidation
uipreliga Sep 12, 2026
2c3cc10
test(harness): no test may read the pinned timing corpus
uipreliga Sep 12, 2026
159047a
fix: review pass B — the tool bucket's dash survives the language bou…
uipreliga Sep 12, 2026
94ac71b
fix(timing): claude-code's windows tile across a tool result
uipreliga Sep 13, 2026
b841537
chore(timing): decompose_run's dead datetime import
uipreliga Sep 13, 2026
bfec0dd
test(lint): CE058 form 6 — the zero its guard does not vouch for
uipreliga Sep 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
322 changes: 322 additions & 0 deletions .claude/harness-candidates.md

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions .github/workflows/pr-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -593,6 +593,21 @@ jobs:
test "$FAILED" = "0" || { echo "smoke-pass had unexpected failures"; exit 1; }
test "$ERRORED" = "0" || { echo "smoke-pass had errors"; exit 1; }

# The four wall-clock buckets (head + generation + UNION(tool) + tail)
# must account for each turn's own duration. This is the TWO-SIDED gate:
# the committed golden sensor only catches an OVERSHOOT, so a bucket that
# claims LESS time than it should — the defect class this area keeps
# producing — passes every test in the suite. It needs live task.json
# files, which the smoke-pass run above already leaves on disk.
#
# COVERS CLAUDE-CODE ONLY: experiments/default.yaml sets type: claude-code,
# so every turn here is that harness. The other four are covered by
# tests/test_timing_identity_contract.py, which is ms-exact but synthetic.
- name: Verify timing residual (claude-code only)
run: |
.venv/bin/python scripts/timing/decompose_run.py \
$(find runs/ci-smoke-pass -name task.json) --max-residual-pct 5

- name: Verify smoke-fail bucket
run: |
F=runs/ci-smoke-fail/experiment.json
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md

Large diffs are not rendered by default.

487 changes: 468 additions & 19 deletions docs/agents/HARNESS_PARITY.md

Large diffs are not rendered by default.

343 changes: 333 additions & 10 deletions evalboard/app/runs/[id]/[...task]/__tests__/message-timeline.test.tsx

Large diffs are not rendered by default.

152 changes: 135 additions & 17 deletions evalboard/app/runs/[id]/[...task]/_sections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import type {
TokenTotals,
ToolCall,
} from "@/lib/runs";
import { toolExecutionMs } from "@/lib/timing";
import { measuredToolExecutionMs, toolExecutionMs } from "@/lib/timing";
import {
type PerMessageImpact,
buildThinkingModel,
Expand Down Expand Up @@ -318,6 +318,11 @@ export function MessageTimelineSection({
subAgentUsageByToolId = {},
impactByIndex,
taskDurationSeconds,
harnessStartupMs,
harnessTeardownMs,
storedToolMs,
setupMs,
gradingMs,
}: {
messages: MessageEvent[];
// Per-Agent-call sub-agent token breakdown (input/output/cache-create/
Expand All @@ -332,6 +337,31 @@ export function MessageTimelineSection({
// tool execution do NOT account for. Null/absent on a run predating
// duration capture — the cell then renders "—" rather than a fake residual.
taskDurationSeconds?: number | null;
// The turn-level head and tail, summed over the task's turns: wall clock
// before the first generation window opened and after the last one closed.
// Turn-scoped, so they cannot be derived from the per-message stream the
// other stats come from. Null/absent on a run predating the capture, and
// the cells then read "—" while Unaccounted keeps exactly its old meaning.
harnessStartupMs?: number | null;
harnessTeardownMs?: number | null;
// The tool bucket as the HARNESS recorded it, summed over the task's turns
// (`TurnRecord.tool_union_ms`). Preferred over recomputing it from the
// message stream, because the collector wrote it from the same span set it
// measured the head and the tail against — reading it is how this cell and
// the harness are guaranteed to agree rather than merely observed to.
// Null/absent on a run predating the field, and the cell then computes the
// union itself; the two agree by construction, since `toolExecutionMs`
// applies the same bounded-spans-only policy as the Python selector.
storedToolMs?: number | null;
// TASK-scoped phases either side of the turns: provisioning before the
// first turn, criteria checking after the last. Named so Unaccounted is a
// residual instead of a label for the setup phase — it was ~1.9s of known,
// constant orchestrator cost on every row, which reads as 10% of a 19s
// task and would read 60% of a 3s one. Null/absent on a run predating the
// capture, and the cells then read "—" while Unaccounted keeps exactly its
// old meaning.
setupMs?: number | null;
gradingMs?: number | null;
}) {
// Token columns can be shown as counts or as their estimated USD value.
const [unit, setUnit] = useState<Unit>("tokens");
Expand Down Expand Up @@ -381,7 +411,17 @@ export function MessageTimelineSection({
// occupy the wall clock once. Summing them made Unaccounted negative on
// any task that ran tools in parallel, reporting overlap as if the
// harness had lost time.
const toolExecMs = toolExecutionMs(mainThread);
//
// STORED first, computed as the fallback. `?? null` and not `?? computed`
// in one expression because `storedToolMs` of 0 is a measurement and must
// win: the harness recorded spans and they occupied no measurable time.
// Only its ABSENCE (a run predating the field) routes here.
//
// The Generation cell below has no stored twin and is deliberately still
// computed from the messages — the reconciliation entry exists so a
// consumer sums that stream rather than reading a separate aggregate. The
// mixed sourcing is intentional; see `sumTurnBuckets` in lib/runs.ts.
const toolExecMs = storedToolMs ?? measuredToolExecutionMs(mainThread);
const slowGen = mainThread.filter(
(m) => (m.generationMs ?? 0) >= SLOW_GEN_MS,
).length;
Expand All @@ -398,11 +438,25 @@ export function MessageTimelineSection({
const attributableGenMs = totalGenMs - mixedMs;
const thinkingShare = attributableGenMs > 0 ? thinkingMs / attributableGenMs : 0;

// Wall clock the agent stream does not explain. Negative means generation
// and tool execution overlapped, which is a real signal — never clamped.
// Wall clock the agent stream does not explain, AFTER every named bucket.
// Startup and teardown are subtracted because they are measured intervals,
// not residual — leaving them in reported a harness's CLI boot as
// unexplained time. `?? 0` subtracts only what was actually measured, so an
// older run with neither field keeps exactly its previous number.
// Negative means generation and tool execution overlapped, which is a real
// signal — never clamped.
const taskMs =
taskDurationSeconds != null ? taskDurationSeconds * 1000 : null;
const unaccountedMs = taskMs != null ? taskMs - totalGenMs - toolExecMs : null;
const unaccountedMs =
taskMs != null
? taskMs -
totalGenMs -
(toolExecMs ?? 0) -
(harnessStartupMs ?? 0) -
(harnessTeardownMs ?? 0) -
(setupMs ?? 0) -
(gradingMs ?? 0)
: null;
const unaccountedShare =
taskMs != null && taskMs > 0 && unaccountedMs != null
? unaccountedMs / taskMs
Expand All @@ -419,19 +473,36 @@ export function MessageTimelineSection({
<p className="text-[10px] text-gray-500">
MIXED = multiple block types · red = slow (gen ≥10s, tool ≥5s)
</p>
{/* TWO LEVELS, two rows. The top row's Generation, Tool exec and
Unaccounted sum to the task's wall clock; the bottom row splits
Generation alone and sums to IT. Rendering the split as a
sub-cell of one top-row cell put both sums on one line, where
nothing said which total each part belonged to. */}
{/* TWO LEVELS, two rows. The top row's five time cells — Startup,
Generation, Tool exec, Teardown, Unaccounted — sum to the task's
wall clock; the bottom row splits Generation alone and sums to
IT. Rendering the split as a sub-cell of one top-row cell put
both sums on one line, where nothing said which total each part
belonged to. The time cells are ordered as the turn runs. */}
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3 tabular-nums space-y-3">
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 text-xs">
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-9 gap-3 text-xs">
<div>
<div className="text-gray-500 uppercase tracking-wide text-[10px]">
Messages
</div>
<div className="text-gray-900 font-medium">{messageCount}</div>
</div>
<div title="sandbox provisioning, agent start() and pre_run — everything before the first turn begins. TASK-scoped, so it is NOT one of the turn's four buckets: those tile a single turn and their identity is asserted to the millisecond, while this happens once for a task that may run many turns. It is the orchestrator's own cost, not the harness's — measured at ~1.9s for claude-code and pi alike. Blank on runs recorded before the field existed.">
<div className="text-gray-500 uppercase tracking-wide text-[10px]">
Setup
</div>
<div className="text-gray-900 font-medium">
{fmtMs(setupMs ?? null)}
</div>
</div>
<div title="wall clock from the turn starting until the harness first observed model output — a latency that INCLUDES time-to-first-token, and the same instant its first generation window opens. Named for the interval it measures, not for what it contains. Deliberately NOT decomposed further: a harness with a CLI to boot fuses CLI boot, provider resolution, dispatch and TTFT here, and no stream carries a marker between them. See docs/agents/HARNESS_PARITY.md.">
<div className="text-gray-500 uppercase tracking-wide text-[10px]">
Startup
</div>
<div className="text-gray-900 font-medium">
{fmtMs(harnessStartupMs ?? null)}
</div>
</div>
<div title="model-generation time, split per block kind on the row below">
<div className="text-gray-500 uppercase tracking-wide text-[10px]">
Generation
Expand All @@ -445,10 +516,26 @@ export function MessageTimelineSection({
Tool exec
</div>
<div className="text-gray-900 font-medium">
{fmtMs(toolExecMs)}
{fmtMs(toolExecMs ?? null)}
</div>
</div>
<div title="wall clock after the last generation window closed: SDK/CLI finalization, result assembly and process teardown">
<div className="text-gray-500 uppercase tracking-wide text-[10px]">
Teardown
</div>
<div className="text-gray-900 font-medium">
{fmtMs(harnessTeardownMs ?? null)}
</div>
</div>
<div title="every success-criteria check this row made, summed — the single-shot check, each dialog turn's check, and the post-failure diagnostic pass. Blank when nothing was graded (coder-eval execute) or on runs recorded before the field existed.">
<div className="text-gray-500 uppercase tracking-wide text-[10px]">
Grading
</div>
<div className="text-gray-900 font-medium">
{fmtMs(gradingMs ?? null)}
</div>
</div>
<div title="task wall clock minus generation and tool execution — includes sandbox setup, grading, simulator calls, and any time the harness did not report">
<div title="task wall clock minus every named bucket above. A TRUE residual now that setup and grading are measured: it used to hold the ~1.9s setup phase, a known constant reading as unexplained time. What is left is post_run, sandbox cleanup, simulator calls and any interval the harness did not report.">
<div className="text-gray-500 uppercase tracking-wide text-[10px]">
Unaccounted
</div>
Expand Down Expand Up @@ -771,13 +858,26 @@ export function CostExplorerSection({
tokens,
recordedCostUsd,
taskDurationSeconds,
harnessStartupMs,
harnessTeardownMs,
storedToolMs,
setupMs,
gradingMs,
}: {
messages: MessageEvent[];
subAgentUsageByToolId?: Record<string, SubAgentTotals>;
tokens: TokenTotals;
recordedCostUsd: number | null;
// Forwarded verbatim to the timeline's Unaccounted cell.
taskDurationSeconds?: number | null;
// Forwarded verbatim to the timeline's Startup/Teardown cells.
harnessStartupMs?: number | null;
harnessTeardownMs?: number | null;
storedToolMs?: number | null;
// Forwarded straight through to MessageTimelineSection — this component
// renders it and owns no timing of its own.
setupMs?: number | null;
gradingMs?: number | null;
}) {
const [scale, setScale] = useState(1);
const [toolScale, setToolScale] = useState(1);
Expand Down Expand Up @@ -816,6 +916,11 @@ export function CostExplorerSection({
subAgentUsageByToolId={subAgentUsageByToolId}
impactByIndex={impactByIndex}
taskDurationSeconds={taskDurationSeconds}
harnessStartupMs={harnessStartupMs}
harnessTeardownMs={harnessTeardownMs}
storedToolMs={storedToolMs}
setupMs={setupMs}
gradingMs={gradingMs}
/>
{model && tokens.total > 0 && (
<section className="space-y-2">
Expand Down Expand Up @@ -1445,9 +1550,22 @@ function MessageRow({
const slowTool = m.toolUses.some((t) => (t.durationMs ?? 0) >= SLOW_TOOL_MS);
const hasErrorTool = m.toolUses.some((t) => t.isError);
const preview = summaryPreview(m);
// Sum tool exec time for this message — matches the rollup strip.
const execMs = m.toolUses.reduce((a, t) => a + (t.durationMs ?? 0), 0);
const hasExec = m.toolUses.some((t) => t.durationMs != null);
// UNION, not sum — and through the same helper the rollup strip uses, so
// the row and the header cannot answer one question two ways. Summing
// double-books concurrent calls: one measured antigravity turn issued two
// `sleep 2` Bash calls overlapping almost entirely, and this cell read
// 4.1s for 2.1s of wall clock — more tool time in one message than the
// whole task's Tool exec cell, which is impossible on its face. The old
// comment here claimed parity with the strip; that stopped being true when
// `toolExecutionMs` was changed to union and this line was not. Expand the
// row to see each call's own wall clock: sequential calls still add up to
// this number, concurrent ones deliberately do not.
// `measured…`, so a row whose calls were TIMED BUT UNBOUNDED reads "—"
// rather than "0ms". Under the union policy such a call contributes to no
// bucket, and claiming it took no time is the one thing that is certainly
// false. This replaces a `durationMs != null` guard, which asked whether
// the harness timed anything rather than whether it bounded anything.
const execMs = measuredToolExecutionMs([m]);
// Render full body only when something more than the summary exists.
const hasBody =
m.toolUses.length > 0 ||
Expand Down Expand Up @@ -1492,7 +1610,7 @@ function MessageRow({
: "text-gray-600")
}
>
{hasExec ? fmtMs(execMs) : "—"}
{fmtMs(execMs)}
</span>
<span className="flex items-center gap-2 min-w-0">
<span
Expand Down
5 changes: 5 additions & 0 deletions evalboard/app/runs/[id]/[...task]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,11 @@ export default async function TaskPage({
tokens={task.tokens}
recordedCostUsd={task.totalCostUsd}
taskDurationSeconds={task.durationSeconds}
harnessStartupMs={task.harnessStartupMs}
setupMs={task.setupMs}
gradingMs={task.gradingMs}
harnessTeardownMs={task.harnessTeardownMs}
storedToolMs={task.storedToolMs}
/>
)}
<ProviderCallTableSection providerCalls={task.providerCalls} />
Expand Down
80 changes: 80 additions & 0 deletions evalboard/lib/__tests__/harnessOverhead.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";

// End-to-end: the two turn-level timing buckets survive the trip from
// task.json's `iterations` onto TaskDetail. `sumHarnessOverhead` is unit-tested
// in runs.test.ts; what only a read off disk can catch is a misspelled raw key,
// since every TurnEntry field is optional and a typo would just parse as
// absent. Mirrors providerCalls.test.ts's env-stub + fresh-import pattern.
const RUN = "2026-01-01_00-00-00";
const TASK = "demo-task";
let tmp: string;

async function write(rel: string, body: string): Promise<void> {
const abs = path.join(tmp, rel);
await fs.mkdir(path.dirname(abs), { recursive: true });
await fs.writeFile(abs, body);
}

async function loadRuns() {
vi.resetModules();
vi.stubEnv("EVALBOARD_LOCAL_RUNS_DIR", tmp);
return import("../runs");
}

async function writeTask(iterations: unknown[]): Promise<void> {
await write(
`${RUN}/run.json`,
JSON.stringify({
run_id: RUN,
task_results: [{ task_id: TASK, status: "success" }],
}),
);
await write(
`${RUN}/default/${TASK}/00/task.json`,
JSON.stringify({ final_status: "success", iterations }),
);
}

beforeEach(async () => {
tmp = await fs.mkdtemp(path.join(os.tmpdir(), "evalboard-overhead-"));
});

afterEach(async () => {
vi.unstubAllEnvs();
await fs.rm(tmp, { recursive: true, force: true });
});

describe("readTaskDetail: harness startup/teardown", () => {
test("sums both buckets across the task's turns", async () => {
await writeTask([
{ harness_startup_ms: 3047.9, harness_teardown_ms: 33.1 },
{ harness_startup_ms: 120.5, harness_teardown_ms: 4.2 },
]);
const { readTaskDetail } = await loadRuns();
const detail = await readTaskDetail(RUN, TASK);
expect(detail?.harnessStartupMs).toBeCloseTo(3168.4, 3);
expect(detail?.harnessTeardownMs).toBeCloseTo(37.3, 3);
});

test("an older run without the fields reports null, not zero", async () => {
await writeTask([{ model_used: "claude-haiku-4-5" }]);
const { readTaskDetail } = await loadRuns();
const detail = await readTaskDetail(RUN, TASK);
expect(detail?.harnessStartupMs).toBeNull();
expect(detail?.harnessTeardownMs).toBeNull();
});

test("a measured zero head is preserved as 0", async () => {
// A head of 0.0 stays representable: a turn can reach its first model
// output with nothing measurable in front of it. It must not read as
// "never measured", which is what null means.
await writeTask([{ harness_startup_ms: 0.0, harness_teardown_ms: 834.7 }]);
const { readTaskDetail } = await loadRuns();
const detail = await readTaskDetail(RUN, TASK);
expect(detail?.harnessStartupMs).toBe(0);
expect(detail?.harnessTeardownMs).toBeCloseTo(834.7, 3);
});
});
Loading
Loading