testsAndMisc/python_pkg/session_autopsy/turns.py
kuhyx 5ffd035c21 session-autopsy: measure turn efficiency, the dominant token cost
Cache read is charged per API round-trip on the whole conversation so far, so a
long session's cost is driven by TURN COUNT, not message length. One measured
session spent 267M cache-read against 856k output — a ratio of 290:1. The report
priced tokens but never counted the turns spending them.

Across the 263 stored transcripts this now shows 33,508 tool turns at an 8%
batching rate, of which 4,437 (13%) decided nothing: polling a counter, running
a linter, checking whether a push landed. At 606k average context re-read per
turn that is ~2.7B cache-read tokens on turns a script could have taken.

- turns.py classifies a command as poll / lint_test / vcs_check / substantive,
  deliberately conservative: state-changing commands (git commit, push) count as
  substantive even though they are scriptable, so the reported waste is a floor.
  A turn is only mechanical when EVERY command in it is.
- Batching is grouped by requestId, not by transcript line: one API response can
  span several lines, and counting per line reports every turn as single-tool
  (0% batching where the real rate is 12%).
- records.py gains TurnEfficiency (schema v3, additive — old records read as
  zeros) with derived rates written into to_dict so readers need not recompute.
- report.py gains a "Turn efficiency" section pricing each mechanical kind.
- _TurnTracker owns both the totals and the open turn, after pylint flagged
  _Accumulator over its attribute budget; _Accumulator.turns is now a property,
  so every existing caller is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 19:29:25 +02:00

110 lines
3.7 KiB
Python

"""Classify assistant turns by whether a model was needed to take them.
Cache-read tokens dominate the cost of a long session — one measured session spent
235M cache-read against 800k output, a ratio of roughly 290:1. Cache read is paid per
API round-trip on the whole conversation so far, so the lever is the NUMBER OF TURNS,
not the length of any message. A short turn taken late is as expensive as a long one.
That makes it worth counting the turns where the model decided nothing: polling a
counter, running a linter, checking whether a push landed. Those are pass/fail
mechanisms that code can adjudicate, which is exactly what belongs in a script rather
than in a round-trip (see ``rules/token-spend.instructions.md``).
Classification is deliberately conservative: a command is only called mechanical when
its whole purpose is observation. Anything that edits, fetches, builds or decides
counts as substantive, so the reported waste is a floor rather than a guess.
"""
from __future__ import annotations
import re
POLL = "poll"
LINT_TEST = "lint_test"
VCS_CHECK = "vcs_check"
SUBSTANTIVE = "substantive"
MECHANICAL = frozenset({POLL, LINT_TEST, VCS_CHECK})
# Ordered: the first pattern to match wins, so put the narrow ones first.
_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
# Waiting on something: process liveness, log tails, progress counters, clocks.
(
POLL,
re.compile(
r"""(?x)
\bkill\s+-0\b # is that pid still alive
| \bpgrep\b | \bpidof\b
| \bps\s+-[eo] # ps -eo / ps -o : process listing
| \bnvidia-smi\b
| \bwc\s+-l\s*< # counting lines in a log
| \btail\s+(-[nf]?\d*\s+)?\S*\.log
| ^\s*sleep\s
| \buntil\s+.*\bdo\b.*\bdone\b
| \bdate\s+['\"]?\+
"""
),
),
# Adjudicated by an exit code, never by a model.
(
LINT_TEST,
re.compile(
r"\b(pytest|ruff|mypy|shellcheck|pre-commit\s+run|npm\s+test|cargo\s+test)\b"
),
),
# Read-only VCS inspection. `git commit`/`push`/`add` are excluded on purpose:
# they change state, so they are substantive even though they are scriptable.
(
VCS_CHECK,
re.compile(
r"""(?x)
\bgit\s+(status|log|diff|ls-remote|rev-parse|remote\s+-v|branch|show)\b
| \bgh\s+(api|repo\s+view|pr\s+view)\b
"""
),
),
)
def classify_command(command: str) -> str:
"""Classify one Bash command.
Args:
command: The raw Bash tool input (may be multi-line).
Returns:
One of :data:`POLL`, :data:`LINT_TEST`, :data:`VCS_CHECK` or
:data:`SUBSTANTIVE`.
"""
text = command.strip()
if not text:
return SUBSTANTIVE
for label, pattern in _PATTERNS:
if pattern.search(text):
return label
return SUBSTANTIVE
def classify_turn(commands: list[str]) -> str:
"""Classify a whole turn from the Bash commands it issued.
A turn only counts as mechanical when *every* command in it is — one real piece
of work makes the round-trip necessary regardless of what else it carried, and
over-counting waste would make the report untrustworthy.
Args:
commands: Every Bash command issued in this turn, in order.
Returns:
The shared mechanical label, or :data:`SUBSTANTIVE` if the turn mixed kinds,
carried real work, or ran no Bash at all.
"""
if not commands:
return SUBSTANTIVE
labels = {classify_command(c) for c in commands}
if len(labels) == 1:
only = labels.pop()
if only in MECHANICAL:
return only
return SUBSTANTIVE