testsAndMisc/python_pkg/session_autopsy/records.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

391 lines
12 KiB
Python

"""Dataclass schema for per-session autopsy records (schema v2)."""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
SCHEMA_VERSION = 3
@dataclass
class TokenTotals:
"""Token usage summed over every assistant message in a session."""
output: int = 0
input: int = 0
cache_read: int = 0
cache_write: int = 0
def add_usage(self, usage: dict[str, object]) -> None:
"""Accumulate one assistant message's ``usage`` block.
Args:
usage: The ``message.usage`` object from a transcript line.
"""
self.output += _as_int(usage.get("output_tokens"))
self.input += _as_int(usage.get("input_tokens"))
self.cache_read += _as_int(usage.get("cache_read_input_tokens"))
self.cache_write += _as_int(usage.get("cache_creation_input_tokens"))
@dataclass
class TurnEfficiency:
"""API round-trips taken, and how many of them a script could have taken.
Cache-read tokens dominate a long session (one measured session: 235M read
against 800k output), and cache read is charged per round-trip on the whole
conversation so far. So the cost lever is the NUMBER of turns, not the length
of any message — and turns where the model decided nothing are pure waste.
"""
api_turns: int = 0
tool_calls: int = 0
batched_turns: int = 0
poll_turns: int = 0
lint_test_turns: int = 0
vcs_check_turns: int = 0
@property
def mechanical_turns(self) -> int:
"""Turns whose every action was observation a script could have done."""
return self.poll_turns + self.lint_test_turns + self.vcs_check_turns
@property
def batching_rate(self) -> float:
"""Share of tool-carrying turns that issued more than one call (0..1)."""
return self.batched_turns / self.api_turns if self.api_turns else 0.0
@property
def mechanical_rate(self) -> float:
"""Share of tool-carrying turns that were purely mechanical (0..1)."""
return self.mechanical_turns / self.api_turns if self.api_turns else 0.0
@dataclass
class SessionMeta:
"""Where and when the session happened."""
started_at: str | None = None
ended_at: str | None = None
cwd: str | None = None
git_branch: str | None = None
slug: str | None = None
title: str | None = None
@dataclass
class ActivityCounts:
"""How much happened, by category."""
assistant_msgs: int = 0
external_prompts: int = 0
subagent_count: int = 0
compaction_count: int = 0
retry_prompt_count: int = 0
malformed_lines: int = 0
@dataclass
class SkillInvocation:
"""One skill span (Skill tool_use or slash command) and its turns."""
name: str
tool_only_turns: int = 0
text_turns: int = 0
tokens_output: int = 0
tokens_cache_write: int = 0
bash_sig: list[str] = field(default_factory=list)
@dataclass
class Observations:
"""Signals mined from the transcript for the detectors."""
tool_histogram: dict[str, int] = field(default_factory=dict)
bash_first_lines: dict[str, int] = field(default_factory=dict)
bash_sequences: list[list[str]] = field(default_factory=list)
skill_invocations: list[SkillInvocation] = field(default_factory=list)
error_signatures: dict[str, int] = field(default_factory=dict)
typed_prompt_signatures: dict[str, int] = field(default_factory=dict)
@dataclass
class SessionRecord:
"""Compact, deterministic summary of one Claude Code session transcript."""
session_id: str
project_slug: str
transcript_path: str
file_size: int
file_mtime: float
analyzed_at: str
schema_version: int = SCHEMA_VERSION
meta: SessionMeta = field(default_factory=SessionMeta)
counts: ActivityCounts = field(default_factory=ActivityCounts)
tokens: TokenTotals = field(default_factory=TokenTotals)
turns: TurnEfficiency = field(default_factory=TurnEfficiency)
obs: Observations = field(default_factory=Observations)
def to_dict(self) -> dict[str, object]:
"""Serialize to a JSON-compatible dict.
Returns:
A plain dict with nested dataclasses expanded.
"""
data = asdict(self)
# asdict() only walks fields, so the derived rates are added by hand —
# readers of sessions.jsonl should not have to recompute them.
data["turns"] |= {
"mechanical_turns": self.turns.mechanical_turns,
"batching_rate": round(self.turns.batching_rate, 4),
"mechanical_rate": round(self.turns.mechanical_rate, 4),
}
return data
def _meta_from_dict(data: object) -> SessionMeta:
"""Rebuild the meta block from its serialized form.
Args:
data: The ``meta`` value of a stored record.
Returns:
The reconstructed meta (empty on junk).
"""
return SessionMeta(
started_at=_opt_str(_dict_get(data, "started_at")),
ended_at=_opt_str(_dict_get(data, "ended_at")),
cwd=_opt_str(_dict_get(data, "cwd")),
git_branch=_opt_str(_dict_get(data, "git_branch")),
slug=_opt_str(_dict_get(data, "slug")),
title=_opt_str(_dict_get(data, "title")),
)
def _counts_from_dict(data: object) -> ActivityCounts:
"""Rebuild the counts block from its serialized form.
Args:
data: The ``counts`` value of a stored record.
Returns:
The reconstructed counts (zeros on junk).
"""
return ActivityCounts(
assistant_msgs=_as_int(_dict_get(data, "assistant_msgs")),
external_prompts=_as_int(_dict_get(data, "external_prompts")),
subagent_count=_as_int(_dict_get(data, "subagent_count")),
compaction_count=_as_int(_dict_get(data, "compaction_count")),
retry_prompt_count=_as_int(_dict_get(data, "retry_prompt_count")),
malformed_lines=_as_int(_dict_get(data, "malformed_lines")),
)
def _tokens_from_dict(data: object) -> TokenTotals:
"""Rebuild token totals from their serialized form.
Args:
data: The ``tokens`` value of a stored record.
Returns:
The reconstructed totals (zeros on junk).
"""
return TokenTotals(
output=_as_int(_dict_get(data, "output")),
input=_as_int(_dict_get(data, "input")),
cache_read=_as_int(_dict_get(data, "cache_read")),
cache_write=_as_int(_dict_get(data, "cache_write")),
)
def _turns_from_dict(data: object) -> TurnEfficiency:
"""Rebuild turn-efficiency counts from their serialized form.
Args:
data: The ``turns`` value of a stored record.
Returns:
The reconstructed counts (zeros on junk or on pre-v3 records).
"""
return TurnEfficiency(
api_turns=_as_int(_dict_get(data, "api_turns")),
tool_calls=_as_int(_dict_get(data, "tool_calls")),
batched_turns=_as_int(_dict_get(data, "batched_turns")),
poll_turns=_as_int(_dict_get(data, "poll_turns")),
lint_test_turns=_as_int(_dict_get(data, "lint_test_turns")),
vcs_check_turns=_as_int(_dict_get(data, "vcs_check_turns")),
)
def _obs_from_dict(data: object) -> Observations:
"""Rebuild the observations block from its serialized form.
Args:
data: The ``obs`` value of a stored record.
Returns:
The reconstructed observations (empty on junk).
"""
invocations = [
SkillInvocation(
name=str(_dict_get(raw, "name") or "?"),
tool_only_turns=_as_int(_dict_get(raw, "tool_only_turns")),
text_turns=_as_int(_dict_get(raw, "text_turns")),
tokens_output=_as_int(_dict_get(raw, "tokens_output")),
tokens_cache_write=_as_int(_dict_get(raw, "tokens_cache_write")),
bash_sig=_str_list(_dict_get(raw, "bash_sig")),
)
for raw in _list_of_dicts(_dict_get(data, "skill_invocations"))
]
return Observations(
tool_histogram=_str_int_map(_dict_get(data, "tool_histogram")),
bash_first_lines=_str_int_map(_dict_get(data, "bash_first_lines")),
bash_sequences=[
_str_list(seq) for seq in _list_value(_dict_get(data, "bash_sequences"))
],
skill_invocations=invocations,
error_signatures=_str_int_map(_dict_get(data, "error_signatures")),
typed_prompt_signatures=_str_int_map(
_dict_get(data, "typed_prompt_signatures")
),
)
def record_from_dict(data: dict[str, object]) -> SessionRecord:
"""Rebuild a :class:`SessionRecord` from its serialized form.
Args:
data: A dict previously produced by :meth:`SessionRecord.to_dict`.
Returns:
The reconstructed record; unknown keys are ignored so old readers
survive future additive schema changes.
"""
return SessionRecord(
session_id=str(data.get("session_id") or ""),
project_slug=str(data.get("project_slug") or ""),
transcript_path=str(data.get("transcript_path") or ""),
file_size=_as_int(data.get("file_size")),
file_mtime=_as_float(data.get("file_mtime")),
analyzed_at=str(data.get("analyzed_at") or ""),
schema_version=_as_int(data.get("schema_version")),
meta=_meta_from_dict(data.get("meta")),
counts=_counts_from_dict(data.get("counts")),
tokens=_tokens_from_dict(data.get("tokens")),
turns=_turns_from_dict(data.get("turns")),
obs=_obs_from_dict(data.get("obs")),
)
def _as_int(value: object) -> int:
"""Coerce a JSON value to int, treating bools and junk as 0.
Args:
value: Any value read from a transcript or store line.
Returns:
The int value, or 0 when absent/bool/non-numeric.
"""
if isinstance(value, bool):
return 0
if isinstance(value, (int, float)):
return int(value)
return 0
def _as_float(value: object) -> float:
"""Coerce a JSON value to float, treating bools and junk as 0.0.
Args:
value: Any value read from a transcript or store line.
Returns:
The float value, or 0.0 when absent/bool/non-numeric.
"""
if isinstance(value, bool):
return 0.0
if isinstance(value, (int, float)):
return float(value)
return 0.0
def _opt_str(value: object) -> str | None:
"""Return the value if it is a non-empty string, else None.
Args:
value: Any JSON value.
Returns:
The string, or None.
"""
if isinstance(value, str) and value:
return value
return None
def _dict_get(value: object, key: str) -> object:
"""Safely index into a value that may not be a dict.
Args:
value: Any JSON value.
key: Key to read when the value is a dict.
Returns:
The mapped value, or None.
"""
if isinstance(value, dict):
return value.get(key)
return None
def _list_value(value: object) -> list[object]:
"""Return the value if it is a list, else an empty list.
Args:
value: Any JSON value.
Returns:
A list (possibly empty).
"""
if isinstance(value, list):
return value
return []
def _list_of_dicts(value: object) -> list[dict[str, object]]:
"""Filter a JSON value down to its dict elements.
Args:
value: Any JSON value.
Returns:
The dict elements of the list, or an empty list.
"""
return [item for item in _list_value(value) if isinstance(item, dict)]
def _str_list(value: object) -> list[str]:
"""Filter a JSON value down to its string elements.
Args:
value: Any JSON value.
Returns:
The string elements of the list, or an empty list.
"""
return [item for item in _list_value(value) if isinstance(item, str)]
def _str_int_map(value: object) -> dict[str, int]:
"""Coerce a JSON value to a ``str -> int`` counter mapping.
Args:
value: Any JSON value.
Returns:
A dict of the string keys to int counts, or an empty dict.
"""
if not isinstance(value, dict):
return {}
return {str(key): _as_int(val) for key, val in value.items()}