Skip to content

feat(timing): book each turn's head and tail as their own buckets - #165

Open
uipreliga wants to merge 27 commits into
mainfrom
feat/turn-head-tail-timing
Open

feat(timing): book each turn's head and tail as their own buckets#165
uipreliga wants to merge 27 commits into
mainfrom
feat/turn-head-tail-timing

Conversation

@uipreliga

@uipreliga uipreliga commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

#164 has merged; base is now main and this PR carries 27 commits in three waves: the head/tail buckets it opened with, then message_id (CE060), then a timing-architecture pass (CE061, close_window, TurnClock).

What and why

A turn's wall clock was only partly explained. Generation windows and tool execution were measured; the turn's head (turn start → first generation window opens) and tail (last window closes → turn end) were not, so they surfaced as Unaccounted in the evalboard. On OpenCode that was ~2.5 s per turn of CLI boot reported as unexplained time.

Both are now booked as optional TurnRecord fields, computed once at the EventCollector seam.

The head's composition genuinely differs per harness and is deliberately not decomposed. On an in-process SDK the first window already covers dispatch and TTFT, so it reads 0.0; on a subprocess harness it fuses CLI boot, provider resolution, dispatch and TTFT with no marker between them (measured on OpenCode: the process spawns in ~3 ms, its first event lands at ~3.9 s). The fields are named for the interval they measure, never for what they contain — docs/agents/HARNESS_PARITY.md records the per-harness composition.

The invariant

None means never measured; 0.0 means measured and instant. These stay distinguishable end to end, Python model → task.json → TypeScript → rendered cell ( vs 0ms). CE058 is widened to cover both new names and the TurnRecord constructor.

The identity, and the three defects that closed it

Σ generation + ∪ tool + head + tail ≈ duration_seconds. Each of these was found by measurement, not by reading:

  1. The tool term must be the union, not the sum. One Pi turn overlapped a Write and a Bash by 18.4 ms and produced exactly an 18.3 ms residual.
  2. The four buckets were not disjoint. Generation windows are tool-subtracted; the head and tail were not. A tool escaping every window — Antigravity force-closes an orphan at finalization, inside the tail — was counted twice. On the committed antigravity_d_orphaned_tool fixture that is −86% of wall clock, with all 72 golden tests passing.
  3. claude-code did not subtract tool time from its generation windows at all. It was exempt on the premise that a tool's execution falls between two windows — but a tool's timer starts at the emission carrying its tool_use block, and one assistant turn spans several emissions, so a later emission's window runs concurrently with a tool already timing. Measured at 482 ms and 340 ms of double-count on two ~18–25 s turns. It cannot subtract while flushing (a tool from an earlier emission is still running when the next window closes), so _subtract_tool_time_from_windows runs once at finalization.

Also fixed: placeholder now() stamps (rollout rebuild, sub-agent recovery, synthesized terminal — all of which declare generation_duration_ms=None) were read as window bounds, so a Codex turn rebuilt from its rollout booked the entire turn as startup; bounds depended on list append order; a collector outliving a turn could pair this attempt's start with the last attempt's end; and the head/tail bracket is taken on main-thread messages only, since a sub-agent's generations bubble into the same stream and the spawning Agent call's own interval already spans them.

Wave 2 — message_id, and CE060

Antigravity omitted the message_id kwarg, so the field defaulted to None on every message it ever recorded. The evalboard groups assistant emissions by message_id and falls back to a wall-clock gap when either side lacks one — and that fallback cannot split a harness whose windows are contiguous, so a whole turn's generations collapsed into one timeline row. Nothing failed: the consumer sums a group, so the totals stayed right, and the golden snapshots had ratified the null on the day they were written.

The damage was not only granularity. A grouped emission is one API call to the evalboard's thinking-cost simulator, whose prompt-cache cascade is quadratic in that count, so a single-shot Antigravity run had every cascade coefficient pinned at zero.

CE060 now requires the kwarg, and derives its constructor set from each module's own coder_eval.models imports rather than a hardcoded name list — which is what catches AssistantMessage as AssistantMessageTelemetry in claude_code_agent.py, a spelling CE058 guards only by coincidence.

Wave 3 — one window helper, one clock basis

Four reducers had copy-pasted the same window arithmetic, and Pi had shipped a variant of it that measured from its own turn_start while its siblings tiled from a mark — so every inter-turn gap fell into no bucket. Nothing caught it, because the identity above is asserted on one side only.

  • scripts/timing/decompose_run.py gains a two-sided gate (--max-residual-pct, --min-turn-ms, --include-crashed). It filters on the turn's own crashed flag and head/tail pair, never the record's final_status: the orchestrator preserves a crashed partial across a retry, and an execute corpus finalizes every row as NOT_GRADED, which says nothing about timing. An empty gateable set exits non-zero when a threshold was requested — a gate that passes because it measured nothing is the failure it exists to remove. Report-only on landing; nothing runs it on a schedule.
  • timing.py::close_window() is now the single window implementation; codex, opencode, pi and antigravity all call it. mark is keyword-only with no default, so no reducer can open a window without stating what it tiles from. claude-code is the documented exception (it subtracts once at finalization) and carries the only # noqa: CE061.
  • A reproduced 100% overstatement. Both pi and opencode cleared their tool-span list at turn/step startafter the window it feeds had already opened at the mark — so a call closing in the gap lost its span and the window published that call's execution as model time while the call's own duration_ms counted it again. Driving the real state objects: window 2 published 1000.0 ms where 500.0 is correct. It needs the non-terminal tool path, which is why the CLI's usual one-shot completed event hides it. Pi was protected from it only by not tiling, so its gen_mark and the reset move had to land in one commit, reset first.
  • TurnClock gives antigravity and pi one (wall, monotonic) pair per turn. Antigravity's span was monotonic while its tool intervals were wall — the only reason its window could go negative, behind a clamp indistinguishable from a real instant generation. That branch, its debug line and _gen_mark_monotonic are deleted, not left unreachable. Pi's stamps were naive-local, so a DST transition or NTP step inside a turn landed directly in a generation window. Codex and OpenCode are deliberately not converted: their tool spans are the CLI's own epoch stamps, so converting only the bounds would put two bases inside one busy_ms subtraction. The hazard is narrowed from five harnesses to two, and the parity doc says so rather than implying it is solved.
  • The clock is injected, not read from a module global. That is the phase's real blast radius: a derived stamp does not read datetime.now(), so the four existing monkeypatches would have stopped reaching the reducer and those tests would have quietly measured the real clock and passed. Verified by hand on both harnesses that deleting the injected fake now fails.
  • CE061 requires any module in agents/ publishing a measured generation_duration_ms to import close_window. Its own docstring states its blind spot: it proves the helper is imported, never that a given call used it. The alias resolution CE060 already owned moved into a shared tests/lint/rules/_model_ctor.py that both rules consume.

Found in review of that wave and fixed here: a duplicate turn_end / step_finish with no intervening start republished the previous window in full — the spent start stamp sat before the mark, so close_window's backwards-clock min() reopened the next window at the previous turn's start. Reproduced at 3000 ms of generation for a 2000 ms turn. The stamp is now cleared at the flush alongside the mark and the span list.

Live verification

The first pass used tasks/hello_date, which has no concurrent tools and no sub-agents — so it never exercised the code the fixes touch, and defect 3 survived it. A second pass added a task issuing five parallel writes, five reads and two concurrent Bash calls, plus a sub-agent delegation. Five harnesses, 13 turns of which 9 carried overlapping tool calls:

harness worst |residual| % of wall
antigravity 0.047 ms 0.000%
claude-code 1.351 ms 0.007%
codex 0.376 ms 0.002%
opencode 0.326 ms 0.002%
pi 1.718 ms 0.012%

claude-code went from 481 ms / 2.691% → 1.4 ms / 0.007% on the same task.

Re-measured after wave 3, same task, one turn per harness, through the new gate:

harness wall residual % of wall
antigravity 11.2 s −0.025 ms 0.000%
pi 18.6 s −0.115 ms 0.001%
opencode 22.3 s −0.145 ms 0.001%
codex 20.0 s −0.127 ms 0.001%
claude-code 23.6 s +0.766 ms 0.003%

The gate exits 0 at --max-residual-pct 5 and 0.01, and 1 at 0.0001, naming each offending file and turn index — so it is armed rather than vacuously green. A separate sub-agent run reconciles to 1.833 ms on 12.6 s (0.015%) with the sub-agent's 4425.7 ms of nested generation correctly excluded; including it would drive the residual to about −35%.

Guard added

The golden corpus could catch an absence but not a double-count. The fixture clocks are now unified — codex stamped its items at a fixed 2027 epoch and opencode a month in the past, while both agents stamp now(), so a codex replay recorded a harness_startup_ms of ~126 days — and assert_timing_captured asserts the identity. The threshold is relative with an absolute floor, which is what makes it work: defect 2 read +55% of wall but only +0.175 ms.

Mutation-verified: reintroducing defect 2 fails test_antigravity_golden[d_orphaned_tool]; restoring either span reset to turn/step start turns five wave-3 tests red. 20 of 27 scenarios are identity-checked; 7 inject SDK stamps in integer milliseconds (17–900 ms of declared item time against a sub-millisecond replay), so no rebasing makes them commensurable — exempt via FICTIONAL_DURATIONS, each named with its reason.

Two of those exemptions were added here, and the trade is stated where the set is defined: codex_c_reasoning_placeholder and codex_h_no_turn_completed_crash injected no item stamps at all, so _flush_message took _ms_to_dt(None) for both window bounds — two adjacent datetime.now() reads that collide at microsecond resolution often enough to fail completed_at > started_at roughly one run in twenty under parallel load, naming a different scenario each time. Their identity check was near-vacuous anyway (a zero-width window reconciles trivially), so real bounds buy a stable bounds-span assertion.

Known and documented, not fixed

  • The golden corpus pins that a timing value exists, never what it is. _scrub.py's SCRUB_KEYS masks generation_duration_ms and both bounds to a placeholder, and the one assertion that reads magnitudes is an upper bound. So the committed suite cannot see a per-harness generation number move in either direction — a whole phase of wave 3 was planned expecting the golden master to go red, and it never did. The two-sided check exists but runs by hand against live task.json. Interim cover is an ms-exact generation + ∪ tool == span test on pi and opencode. Deferred to .claude/harness-candidates.md with what closing it would take.
  • claude-code's and antigravity's 0.0 head is a clamped negative, not a measured interval — their first window opens before the AgentStartEvent stamp. Measured at 0.03 ms (0.10 ms with four plugin roots), so it is the sub-millisecond skew the clamp exists for. TestClaudeHeadIsStructurallyZero pins the build cost so the reasoning can't rot silently.
  • "A tool outside every generation window" has no live coverage — defect 2's fix rests on unit tests and the antigravity_d_orphaned_tool fixture.
  • A genuinely backwards clock on codex or opencode can still overlap two windows. TurnClock removes it for antigravity and pi; those two keep the CLI's epoch stamps, which cannot be re-derived host-side.
  • Time to first token is not measured and cannot be derived from what is recorded. The parity doc now carries the design — including the naming rule (first_delta_latency_ms, never ttft_ms, because four harnesses' windows tile so the interval fuses queueing and tool time) and that it is never a fifth bucket. No field, reducer or model change ships for it here.
  • Other guards deferred to .claude/harness-candidates.md: no TypeScript counterpart to CE058, the naive/aware datetime assumption, and widening CE058/CE059 to resolve aliases the way CE060 and CE061 do.

Test plan

  • make verify5340 passed, 2 skipped, 92.72% coverage
  • make lint593, including the new CE060 and CE061
  • make evalboard-verify — 742 tests, tsc, build (wave 1; waves 2–3 touch no evalboard/ file)
  • Live: 30 hello_date runs for head/tail magnitudes, 26 runs on a concurrent-tool + sub-agent task across all five harnesses, plus a post-wave-3 re-measurement of all five through the new gate

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown

Claude finished @uipreliga's task in 1m 37s —— View job


Code Review in Progress

Todo List:

  • Read .github/code_review.md for review standards
  • Read CLAUDE.md for project conventions
  • Review the diff (git diff origin/fix/timing-capture-and-reporting...HEAD)
  • Examine changed files for cross-file consistency
  • Check timing identity invariant implementation
  • Verify test coverage for the new timing fields
  • Complete review and provide feedback

@uipreliga
uipreliga force-pushed the fix/timing-capture-and-reporting branch from d809c93 to 7483b67 Compare September 12, 2026 00:17
Base automatically changed from fix/timing-capture-and-reporting to main September 12, 2026 00:27
uipreliga and others added 18 commits September 11, 2026 17:40
Measured live on all five harnesses, generation + tool left 0.1%-42% of the
turn unexplained, and the whole remainder sat in two places: before the first
generation window opened, and after the last one closed. EventCollector now
measures both between the agent's own AgentStart/AgentEnd stamps and the
first/last AssistantMessage, and publishes them on TurnRecord.

One live turn per harness, residual after all four buckets:

  antigravity  wall 14348 ms  startup    0.0  teardown   3.5  -0.010 ms
  claude-code  wall 13295 ms  startup    0.0  teardown 834.7  +0.086 ms
  codex        wall 11842 ms  startup 5075.2  teardown  13.9  -0.019 ms
  opencode     wall  8157 ms  startup 3047.9  teardown  33.1  +0.022 ms
  pi           wall  6906 ms  startup  345.4  teardown  26.6  +0.621 ms

The turn now reconciles to under a millisecond everywhere. The residual sign
flips, so the invariant is |residual| < 1 ms rather than <= wall: head and
tail are measured between event stamps while duration_seconds is the agent's
own monotonic span, and the field descriptions say so.

The head is NOT decomposed further, deliberately. Its composition differs per
harness and the stream carries no marker to split it: OpenCode's process
spawns in 3 ms and its first event lands at 3921 ms, so CLI boot, provider
resolution, dispatch and TTFT are fused. claude-code and Antigravity read a
measured 0.0 because their first window already covers dispatch — which is
also why nothing folds that time OUT of their generation: for an in-process
SDK it IS the generation. Hence names for the interval measured, not for what
it contains.

`agents/_timing.py` moves to `coder_eval/timing.py`. It is stdlib-only, but
importing anything under `agents/` executes that package's __init__, which
imports every agent, which imports streaming — so the collector could not
reach it. A cycle-free leaf beside the other shared arithmetic, mirroring
models/cli_match.py's rationale.

Both fields join the golden-stream scrub list. They are measured wall values
like duration_seconds and generation_duration_ms beside them; left unscrubbed
they drifted 24 of 68 golden tests on an unchanged re-run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`harness_startup_ms` / `harness_teardown_ms` were cited as CE058-guarded but
matched neither `_TIMING_NAME` nor `_TIMING_CONSTRUCTORS`, so the guard the
head/tail work leans on did not exist for the two fields it was named for.

Add one alternation arm (`[a-z_]*_(?:startup|teardown)_ms`, leading segment
required like the `_duration_ms` arm) and `TurnRecord` to the constructor set,
which is what arms form 1. Mutating the real collector call site from
`harness_startup_ms=startup_ms` to `0.0` now fires the rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… strip

The Unaccounted cell was reporting a harness's CLI boot as unexplained time:
opencode's ~3.4s head and claude-code's ~1.0s tail are measured intervals, not
residual. Parse `harness_startup_ms` / `harness_teardown_ms` off each turn, sum
them across the task's iterations, render them as their own Startup and
Teardown cells, and subtract both so Unaccounted is a true residual.

Aggregation is `null` — never 0 — when no turn measured that end, mirroring the
TurnRecord fields' own contract; a measured 0 (an in-process SDK whose first
generation window already covers dispatch) is preserved and renders as `0ms`.
An older run without either field renders exactly as before, including the
25% red threshold, which now reads the corrected number in both directions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…y contain

Extend `assert_timing_captured` with the one thing the golden replays can
support: a turn that produced an assistant message reports both buckets, and a
turn that produced none reports neither. Keyed on that message rather than on
`expect_generation_window` — `codex_e_orphan_tool` and
`claude_i_in_loop_deadline_break` clear the flag while still having a head and
a tail, so the flag would have left them unchecked. No golden regeneration: all
27 dumps already carried both fields and still match.

`HARNESS_PARITY.md` gains the rows this change exists to publish — what the
FIRST generation window covers per harness, and the measured head and tail —
plus the reason the head is deliberately not split into CLI boot vs TTFT, and
a Known-divergences note for `TurnStartEvent`'s inconsistent emission point.

Live verification (15 runs, 3 turns × 5 harnesses) corrected the identity
itself: `Σ tool` books overlapping tool calls twice, and one Pi turn overlapped
a Write and a Bash by 18.4 ms, producing exactly an 18.3 ms residual. The tool
term is the UNION (`timing.py::busy_ms`), as it already is where a harness
subtracts tool time out of a generation window. With all four buckets and the
union, every harness reconciles to under 0.012% of wall clock.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three defects the final review found, each breaking the invariant the change
exists to establish.

**A placeholder stamp was read as a window bound.** Codex's rollout rebuild,
both its sub-agent recovery builders and Claude's synthesized terminal message
all stamp `started_at == completed_at == now()` at APPEND time and declare
`generation_duration_ms=None` to say no window was measurable. `_overhead_ms`
read those stamps anyway, so a Codex turn rebuilt from its rollout — stamped
at turn end — booked the ENTIRE TURN as harness startup. Skip them, the same
exemption CE059 already makes for the same reason.

**The bounds depended on append order.** Codex appends recovered sub-agent
messages after the parent's last flush, so `generations[-1]` is not the last
generation. Use min/max instead of the first and last list entries.

**The four buckets were not disjoint.** Generation windows are tool-subtracted;
the head and tail were not. A tool that escapes every window — Antigravity
force-closes an orphan at finalization, inside the tail, and backgrounds
anything over ten seconds — was counted both as tool and as head or tail. On
the committed `antigravity_d_orphaned_tool` fixture that is a residual of -86%
of wall clock. `decompose_turn` now subtracts tool time from both ends via the
same `busy_ms` the windows use.

Also: reset the terminal event when a new turn starts, so the one collector
that outlives a turn (EarlyStopWatcher, across retries) cannot pair this
attempt's start with the last attempt's end and publish the clamped inversion
as a measured 0.0; stop `decompose_run.py` double-counting a sub-agent's
generation against its parent Agent call's interval; and say plainly in
HARNESS_PARITY.md that claude-code's and antigravity's `0.0` head is a clamped
value rather than a measured interval.

One golden dump changes, by two lines: `codex_g_items_rebuild` now honestly
reports `null` for both buckets instead of a number derived from a placeholder.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first is the valuable one: a golden-corpus assertion of the four-bucket
identity would have caught this work's worst defect, and it is blocked only
because 5 of 27 fixtures stamp generations on a clock that is not
commensurable with their agent events.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…harness

The post-fix re-verification doubled the sample. Figures move by 5-30% with
CLI cache warmth, which is why the table already says to read their order of
magnitude.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ntity

The golden corpus could not catch a DOUBLE-COUNT, only an absence. That is how
the head/tail work shipped a defect where an orphaned tool was booked both in
the tool union and in the tail: `antigravity_d_orphaned_tool` reconciled at
-86% of its own wall clock while all 72 golden tests passed.

Unify the clocks first, because the assertion is meaningless without it. Codex
stamped its SDK items at a fixed 2027 epoch and OpenCode a month in the past,
while both agents stamp their own lifecycle events with `now()` — so a codex
replay recorded a `harness_startup_ms` of ~126 days and no presence-only check
could see it. Both catalogues stay declarative with an absolute base; the
runners now shift that base onto the replay's own clock, which keeps every
derived duration exact (a 250 ms command stays 250 ms) and fixes only the era.
No golden dump changes — these stamps are scrubbed.

Then assert it: generation + UNION(tool) + head + tail cannot exceed
`duration_seconds`, because the four are disjoint. The threshold is relative
with an absolute floor, which is what makes it work at fixture scale — the
defect reads +55% of wall but only +0.175 ms, so an absolute-only bound
generous enough to survive scheduler jitter would have missed it.
Mutation-verified: reintroducing the defect fails the antigravity fixture.

22 of 27 scenarios are checked. The other 5 inject SDK stamps in integer
MILLISECONDS — 17 to 900 ms of declared item time against a replay that runs
in well under one — so no rebasing makes them commensurable and they are
exempt via `FICTIONAL_DURATIONS`, named individually with the reason. Closing
that last gap needs the agent's own clock faked, not the fixtures' rebased.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The question was whether to emit `AgentStartEvent` before
`_build_claude_query`, so the head became a measurement rather than a clamped
negative. Measured first: the build is 0.03 ms, and 0.10 ms with four plugin
roots — not the hundreds of milliseconds the review hypothesised, because the
transport is constructed lazily and plugin resolution is path work.

So: no. Moving the emit would not change the number anyway — `last_event_wall`,
which becomes the first window's start, is stamped before the build too, so
the build sits inside msg0's generation window either way. It would only
convert a -0.03 ms clamp into a +0.03 ms measurement, and it would cost the
event its `model=effective_model`, which the build resolves and the live
renderers display. Surfacing the build cost would need the window re-seeded
after it, which is the generation-window seeding change HARNESS_PARITY.md
already rules out for an in-process SDK.

Both rejections rest on the build being cheap, so guard that rather than
leaving it as a claim in a commit message: `TestClaudeHeadIsStructurallyZero`
holds it under 50 ms (~300x headroom, best-of-5 so a loaded runner cannot trip
it) and its docstring carries the reasoning. The parity doc now states the
measured figures instead of implying an unquantified gap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Live verification on a task with concurrent tool calls — the earlier runs all
used `hello_date`, which has none — found the four-bucket identity failing on
claude-code alone, by 482 ms and 340 ms on two ~18-25 s turns. The residual
equals the generation/tool overlap to within 1.4 ms on every claude-code turn
measured, including the two whose overlap was under a millisecond and which
reconciled to within 0.1 ms.

Cause is a documented exemption whose premise does not hold: claude-code is
the one harness that does not subtract tool time from its generation windows,
on the reasoning that a tool's execution falls between two windows. A tool's
timer starts at the EMISSION carrying its tool_use block, and one assistant
turn spans several emissions, so a later emission's window runs concurrently
with a tool already timing. The other four harnesses overlapped by ~2.0-2.3 s
on the same task and reconciled to within 1.2 ms, because they subtract it.

This predates the head/tail work — generation-vs-tool timing is older — but
that work's identity is what made it visible, and the parity table was
claiming "yes" for all five. Correct the table and the paragraph, state the
measurement, and track the fix as a candidate: applying `busy_ms` here changes
a published `generation_duration_ms` on the most-used harness, so it needs its
own golden regeneration and live pass rather than a quiet amendment here.

Also warn in the new golden identity assertion's failure text, so a future
claude-code fixture that trips it is not misdiagnosed as a fresh double-count.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
claude-code was the one harness that did not, and the reason it was exempt is
measurably wrong. The premise was that because it marks the end of the previous
SDK event and reads again when the next message arrives, a tool's execution
falls BETWEEN two windows. But a tool's timer starts at the EMISSION carrying
its `tool_use` block, and one assistant turn spans several emissions, so a
later emission's window runs concurrently with a tool already timing.

Measured on a task with five parallel writes, five reads and two concurrent
`Bash` calls: 482 ms and 340 ms of overlap on two ~18-25 s turns, and the
four-bucket residual came out at exactly -481 ms and -339 ms. The other four
harnesses overlapped by ~2.0-2.3 s on the same task and still reconciled to
within 1.2 ms, because they subtract it. Two claude-code turns in the same
batch whose overlap happened to be under a millisecond reconciled to 0.1 ms,
which is what isolated the cause to the missing subtraction rather than to
anything about the head and tail.

The subtraction cannot happen while flushing: a tool issued by an earlier
emission is still running when the next window closes, so its interval does
not exist yet. `_subtract_tool_time_from_windows` therefore runs once at
finalization, when every span is known, and uses the same `busy_ms` union the
other four use — the union and not the sum, because these tools overlap each
other too. Sub-agent emissions are skipped: their own tools are not in this
command list, and the Agent call that spawned them already spans their run.

Re-verified live, same task: claude-code 481 ms / 2.691% -> 1.4 ms / 0.006%
over four turns that all carried overlapping tool calls, and all five harnesses
reconcile (worst 1.7 ms, 0.012%). `generation_duration_ms` now means the same
thing on every harness, so the parity table's identity row is "yes" for all
five without a caveat.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Step stream carries no message id, so every Antigravity
`AssistantMessage` was recorded with `message_id: None`. The evalboard
groups assistant emissions by that field and falls back to a wall-clock
gap threshold when either side lacks one — and PR #164 made this
harness's generation windows contiguous, so the gap is now exactly 0 ms
and the fallback folds a whole turn's generations into one timeline row.

Synthesize the id the way Codex does (`{turn_id}-msg-{gen_index}`),
reusing the `_assistant_turns` counter that already counts appended
generations, read before its increment so the first id is `-msg-0`.

Totals are unaffected: the evalboard sums token buckets across a group,
and the turn/generation counts come from `_assistant_turns` Python-side.
Only display granularity was lost.

The five regenerated goldens are the regression sensor (`message_id` is
not scrubbed); the new unit assertion pins the exact id strings, so
moving the increment above the append fails loudly instead of silently
making the ids 1-based.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Antigravity omitted the kwarg and nothing failed: the field defaulted to
None on every message, the evalboard summed the collapsed group so the
totals stayed right, and the golden snapshots had ratified the null the
day they were written. A snapshot is regenerated from whatever the code
currently does, so it catches a later change and never an initial
omission — which is why the author-time rule is worth its cost and is
the only one of the three sensors that would have failed on the day this
shipped.

Unlike CE058/CE059 it derives its constructor set from each module's own
`coder_eval.models` imports rather than hardcoding the spelling. That
closes the blind spot CE058's own docstring concedes: claude_code_agent
binds only `AssistantMessage as AssistantMessageTelemetry`, so a name
list guards that file's two construction sites purely by coincidence,
and an arbitrary `as Msg` is missed outright. Widening the other two the
same way is recorded in .claude/harness-candidates.md — it changes two
shipped rules and needs its own per-rule mutation check.

Verified non-vacuous: stripping the Phase 1 kwarg yields exactly one
violation, at the site it came from; the clean tree yields zero, with no
suppression anywhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Record the per-harness `message_id` source in the Timing-capture table
and give the rationale one home: the evalboard groups assistant
emissions by the field and falls back to a wall-clock gap when either
side lacks one, which cannot split windows that are contiguous by
construction. The source comment and the CE060 docstring point here
rather than restating it, and this is the only place the 100 ms numeral
is written outside runs.ts.

The table row names both synthetic sub-agent forms, since a row titled
"message_id source" that omits them reads as wrong the first time
somebody greps it. Nothing goes in Known divergences — this is a fix.

On the consumer side, tighten the existing message_id-splitting case
from a 10 ms to a 0 ms gap so the fixture matches the shape this harness
really emits. No second case: runs.ts short-circuits on the two ids
before the gap is computed, so 10 ms and 0 ms take the identical branch
and a parallel case would test nothing new.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two findings, each raised independently by both final reviewers.

CE060's rename-safety was half delivered. Deriving the constructor set
from the module's imports removes the local-BINDING spelling, but the
class's own name was still a string literal here, so renaming the model
— the likelier rename, since the alias exists only because two
AssistantMessage types collide — would have disarmed the rule exactly as
it disarms the name lists CE060 argues against. It now reads
`AssistantMessage.__name__`, the way CE056 imports IN_CONTAINER_ENV.

The import walk also traded the alias gap for an import-FORM gap that
the docstring's "one remaining blind spot" did not mention: only an
absolute `from coder_eval.models import ...` bound anything, so a
relative import went silently blind for a whole file (and agents/ does
use relative imports), as did every module-alias spelling. Both now
fire, verified case by case; the attribute spelling is matched on the
attribute alone, deliberately, because the module binding it arrives
through is the part a class-binding walk cannot see. What remains — a
re-export through an intermediate module — is now stated as such. The
attribute test was retargeted at the module-alias form, since with a
direct import beside it it had been passing for the wrong reason.

The prose in all three surfaces claimed "only granularity was lost",
which is measurably false: a grouped emission is one API call to the
evalboard's thinking-cost simulator, whose cache cascade is quadratic in
that count, so a single-shot Antigravity run had every coefficient
pinned at zero; the Messages count and the 10 s slow-generation bar were
per-turn too. All three move toward the figure they were always meant to
report, so this fix corrects them — but a trend compared across it is
not comparing like with like, and the docs now say so. Also: the table
gave OpenCode's `None` case where the CE060 docstring asserted it, so
the two surfaces in one diff disagreed, and the remaining nulls are not
legacy-only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three candidates, all deferred with the reason stated rather than the
work done: the within-turn-only nature of a synthetic message_id (a
negative property over two languages, and the obvious assertion would
pass today while catching nothing), the absence of any evalboard test
fed by a Python golden (needs a loader and a scrub-aware timestamp
story), and the model field's claude-only description (the plan scoped
out model changes; no mechanical guard is obvious).

A fourth was attempted and dropped: a vitest case asserting that two
null-id messages at a 0 ms gap collapse. Its mutation check showed it
takes the identical `gap <= SAME_EMISSION_GAP_MS` branch as the existing
50 ms legacy case, so it could not fail for the reason it claimed —
which is what the plan's own argument against a parallel case said.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review feedback on #165.

`EventCollector._overhead_ms` bracketed the turn's generation span with
every `AssistantMessage`, sub-agent emissions included — unlike its two
sibling call sites (`codex_agent._token_usage_from_messages` and
`scripts/timing/decompose_run.py`), which both filter on
`parent_tool_use_id` for the same reason.

A sub-agent's generations sit inside the spawning Agent call's own
interval, and the identity the head and tail complete sums generation over
the main thread ONLY. Letting a sub-agent message bracket the span shrinks
the head or the tail by time no bucket then claims; Codex's recovered child
messages carry the CHILD's clock, so it can move either end.

Mutation-verified: dropping the filter fails both new cases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The module docstring named Antigravity and Codex as the only harnesses that
interleave tool execution into a generation window. That stopped being true
in the same release: #164 gave OpenCode and Pi tiled windows (so a call open
at a boundary runs inside two of them), and this branch gives claude-code
tool subtraction. All five now subtract, and all five subtract the union.

Also names the TypeScript twin and the corpus that holds the two in step,
which the docstring did not mention at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@uipreliga
uipreliga force-pushed the feat/turn-head-tail-timing branch from ac4e88f to 0c9a067 Compare September 12, 2026 00:48
uipreliga and others added 2 commits September 11, 2026 18:33
…ntity

The only sensor for `Σ generation + ∪ tool + head + tail ≈ duration` is
one-sided: `_scrub.py` asserts `overshoot <= ...`, which catches a bucket
claiming MORE time than the turn contains and says nothing at all about one
claiming less. An unmeasured bucket — the defect the next four phases move
numbers to fix — passes every test in the suite today.

`--max-residual-pct` gates on `abs(share)` per turn, so both signs count. It
skips a turn on the turn's OWN `crashed` flag and head/tail pair, never on the
record's `final_status`: the orchestrator preserves a crashed partial across a
retry, so a SUCCESS record can hold a crashed turn, and an `execute` corpus
finalizes every row as NOT_GRADED, which is not a statement about timing. Both
skips are counted independently — short-circuiting left the no-window tally
reading 0 on the one corpus that contains it.

An empty gateable set exits non-zero when a threshold was asked for. A gate
that passes because it measured nothing is the failure this file exists to
remove.

Report-only on landing: nothing passes the flag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codex, opencode and pi each carried their own copy of the same window
arithmetic — tile from the mark, defend the start with min(), bound the
still-open calls at the boundary, subtract the UNION, clamp at zero — plus
three near-identical paragraphs explaining why subtracting an open call here
does not double-subtract it later. One helper, one docstring.

A pure refactor: the golden master passes with NO regeneration, and the three
call sites were checked argument by argument against the formulas they
replace. Codex's min() moves from the epoch-millisecond domain into the
datetime domain, which is safe because `_ms_to_dt` is strictly monotone over
ms-spaced inputs, and its `item_start` stays guarded so `_ms_to_dt(None)`
cannot fire a third `datetime.now()`.

`mark` is keyword-only with no default: a reducer cannot open a window without
stating what it tiles from. That constrains the call shape, not the value —
pi still passes its own turn start, and the docstring says so rather than
claiming the defect is already gone.

Antigravity is NOT migrated here. Its span is monotonic while its tool spans
are wall, so this signature cannot express it without either dead code or a
moved number; it migrates in 5/6, with the deletion of that split.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
uipreliga and others added 7 commits September 11, 2026 19:06
…l time

Both reducers cleared their tool-span list at turn/step START, which is after
the window that list feeds has already opened at the mark. A call closing in
the gap therefore had its span wiped before the next flush could subtract it,
and the window published that call's execution as model time while the call's
own duration_ms counted the same milliseconds again.

Reproduced against the real state objects, not argued: a call opening at 100,
still running when the step finishes at 1000, closing at 1500, with the next
window tiling 1000 -> 2000. OpenCode published 1000.0 for a window whose model
time was 500.0 — a 100% overstatement, and it needs the non-terminal tool path,
which is why the CLI's usual one-shot `completed` event hides it and the
measured corpus reads 0.00%.

Pi gets the same reset move AND a `gen_mark`, in one commit and in that order.
It was the last harness measuring from its own turn start, so every inter-turn
gap fell in no bucket — but it was protected from the span-reset defect BY not
tiling, so tiling it without moving the reset first would take a correct
harness and introduce the 500 ms double-count. The reset is the value here;
Pi's tiling gap measures 0.25 ms median over 25 real window pairs.

The golden corpus cannot see any of this: `_scrub.py` masks every timing value
to a placeholder, and its identity assertion is an upper bound, so
under-accounting passes it silently. So both harnesses gain an ms-exact
`generation + UNION(tool) == span` test across the boundary, and the reset move
is mutation-pinned on each.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pi shipped measuring its generation window from its own turn_start while four
sibling reducers tiled from a mark, so every inter-turn gap fell in no bucket.
Nothing caught it: the parity doc asserted the four-bucket identity, the only
sensor for that identity checks one side, and Pi's own tests were written
against Pi's own arithmetic. A sixth harness rolling its own window would
arrive the same way — with a green suite by construction.

So the rule is about PROVENANCE, not values: a module in agents/ that publishes
a measured `generation_duration_ms` must import `close_window`. Separate id
from CE058/CE059/CE060, which are about the values a message carries — one
invariant per id is what makes a noqa mean one thing.

Its weakness is stated in its own docstring rather than left to be discovered:
it proves the helper is imported, never that a given call used it. The value is
always a local, so no AST rule can trace it. The sensors for the arithmetic are
tests/test_timing_close_window.py and the per-reducer window tests.

Two suppressions, not the one the plan predicted. claude-code's is permanent —
it subtracts tool time once at finalization across every emission, a shape
`close_window` cannot take without a mode flag. Antigravity's is marked
TEMPORARY and comes out in 5/6 with its clock conversion. A test pins that
exactly these two files need suppressing, so a noqa cannot outlive its reason.

CE060 already owned the alias resolution both rules need, so it moves to a
shared `_model_ctor.py` rather than being copied: a new import spelling now
needs one fix, not two. Every behavioural CE060 test is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Antigravity read its window span off time.monotonic() while unioning
wall-clock tool intervals and subtracting one from the other. That is the only
reason the window could go negative at all, and the clamp underneath it
published a 0.0 indistinguishable from a real instant generation, with a debug
line as the only trace. One basis makes the disagreement unrepresentable, so
the branch and the clamp are deleted rather than left unreachable — a test
greps the source to say so. It moves onto close_window in the same commit,
which is the only point the two could be exchanged without either dead code or
a moved number, and its temporary CE061 suppression comes out with it.

Pi's stamps were naive-LOCAL datetime.now(). A DST transition or an NTP step
inside a turn lands directly in a generation window — an hour in a field
measured in milliseconds, on nightly runs that start at 04:18 and run for
hours. A monotonic-derived stamp cannot express it.

Codex and OpenCode keep theirs: their tool spans are the CLI's own epoch
stamps, unreachable from the host, so converting only the window bounds would
put two bases inside one busy_ms subtraction — relocating the defect instead of
removing it. This narrows the hazard from five harnesses to two; the parity doc
says so rather than implying it is solved.

The clock is INJECTED into the turn-state constructors, not read from a module
global, and that is the phase's largest blast radius rather than a style
choice: a derived stamp does not read datetime.now(), so the four existing
monkeypatches would have stopped reaching the reducer and those tests would
have quietly measured the real clock and passed. Verified by hand on both
harnesses that deleting the injected fake now FAILS.

Deadlines stay on raw time.monotonic(), commented at one site per harness: a
deadline must not move when the wall clock steps.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Corrects the Pi row, which still claimed a window opening at its own
`turn_start`, and adds two rows the table never had: which clock basis each
harness's recorded stamps come from, and which of them build their window
through the shared helper.

The identity row gets a footnote rather than a bare "yes". Its committed sensor
is one-sided — it catches a bucket claiming more time than the turn contains
and nothing about one claiming less — and it cannot see the magnitudes at all,
because the golden scrubber masks every timing value to a placeholder. A doc
that asserts an invariant should say what actually checks it.

Folds in the time-to-first-token design, which was living in an uncommitted
scratch note that had gone stale in four separate ways — including naming a
file that never existed. The design is recorded as rules with reasons (name it
`first_delta_latency_ms`, never a fifth bucket, first delta of ANY kind, never
0.0) and deliberately without a table of private attribute names, since
transcribing those is how the note died: one of them was deleted in 5/6.

Nothing is implemented here. No field, no reducer change, no model change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A duplicate `turn_end` / `step_finish` with no intervening start republished
the previous window in full. `close_window`'s `min(mark, item_start)` exists to
stop a backwards clock from inverting a span, but a start stamp left in place
after its turn was PUBLISHED is not a backwards clock — it is a stale value
sitting before the mark, so the guard reopened the next window back at the
previous turn's start. Reproduced by driving the real state object: 3000 ms of
generation published for a 2000 ms turn, which `decompose_run.py` would read as
a large negative residual and the evalboard would simply sum. The stamp is now
cleared at the flush alongside the mark and the span list, for the same reason
they are: it has been spent. Regression test on both harnesses.

`close_window`'s own docstring had gone stale in the way it was written to
prevent. Phase 2 wrote it, then 3/6 gave pi the mark it said pi lacked and 5/6
migrated the antigravity window it said the signature could not express — so
the shared helper disagreed with the parity doc about which harnesses use it.

The gate script now counts turns it cannot time at all. They were the one
exclusion with no tally, in a file built around not discarding evidence
silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…h now()

`c_reasoning_placeholder` and `h_no_turn_completed_crash` injected no item
stamps, so `_flush_message` took `_ms_to_dt(None)` for BOTH window bounds —
two adjacent `datetime.now()` reads. They collide at microsecond resolution
often enough that `assert_timing_captured`'s `completed_at > started_at` failed
roughly one run in twenty under parallel load, naming a different scenario each
time and giving no hint of the cause. Two separate reviewers of this branch hit
it on two different scenarios.

Real bounds fix it, at the cost of joining `FICTIONAL_DURATIONS`: integer-ms
SDK stamps cannot reconcile against a replay that runs in under a millisecond.
That trade is stated where the set is defined. It costs little — a window of
width zero reconciled trivially, so the identity check it gives up was
near-vacuous, and what replaces it is a stable bounds-span assertion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lue move

A whole phase of the timing plan was written expecting the golden master to go
red when generation numbers changed. It never did: the scrubber masks every
timing value, and the one assertion that reads magnitudes is one-sided. Record
what closing it would actually take, since it is more than a tolerance
constant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# A reducer cannot open a window without STATING what it tiles from.
# The value is still the caller's to get right — see the docstring.
with pytest.raises(TypeError):
close_window(MARK, _at(1000), closed_spans=[], open_started_ats=[]) # type: ignore[misc]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants