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

505 lines
15 KiB
Python

"""Render REPORT.md, maintain state.json, and measure compiled skills."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from python_pkg.session_autopsy.stats import parse_timestamp
if TYPE_CHECKING:
from datetime import datetime
from pathlib import Path
from python_pkg.session_autopsy.detectors import AnalysisResult, Candidate
from python_pkg.session_autopsy.records import SessionRecord
REPORT_FILE = "REPORT.md"
STATE_FILE = "state.json"
COMPILED_FILE = "compiled.json"
TRACES_HINT = "PYTHONPATH=~/testsAndMisc python3 -m python_pkg.session_autopsy traces"
THOUSAND = 1_000
MILLION = 1_000_000
TABLE_TOP = 40
DETAIL_TOP = 25
# The ⚙ badge / SessionStart nudge count only candidates at least this big —
# nobody reviews 180 weak n-grams, so the count must mean "worth your time".
SIGNIFICANT_WEEKLY_TOKENS = 100_000
def fmt_tokens(count: int) -> str:
"""Format a token count for humans.
Args:
count: Token count.
Returns:
``"842"``, ``"74k"``, or ``"1.2M"``.
"""
if count >= MILLION:
return f"{count / MILLION:.1f}M"
if count >= THOUSAND:
return f"{count // THOUSAND}k"
return str(count)
def handled_ids(compiled: list[dict[str, object]]) -> set[str]:
"""Candidate ids that already have a verdict (compiled, keep-llm, dropped).
A handled candidate never counts toward the unreviewed nudge and its
table row stops suggesting a review — its story lives in the Reviewed
section and the scoreboard instead.
Args:
compiled: Entries from compiled.json.
Returns:
The candidate ids with any recorded verdict.
"""
return {
str(entry.get("candidate_id"))
for entry in compiled
if entry.get("candidate_id")
}
def significant_ids(candidates: list[Candidate]) -> list[str]:
"""Ids of the candidates big enough to surface in the badge.
Args:
candidates: Ranked candidates.
Returns:
Ids with at least :data:`SIGNIFICANT_WEEKLY_TOKENS` estimated weekly
tokens at stake, in rank order.
"""
return [
cand.id
for cand in candidates
if cand.est_weekly_savings >= SIGNIFICANT_WEEKLY_TOKENS
]
def load_state(home: Path) -> dict[str, object]:
"""Read state.json, tolerating absence and corruption.
Args:
home: The autopsy home directory.
Returns:
The stored state dict, or an empty dict.
"""
path = home / STATE_FILE
if not path.is_file():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return {}
if isinstance(data, dict):
return data
return {}
def load_compiled(home: Path) -> list[dict[str, object]]:
"""Read compiled.json entries, tolerating absence and corruption.
Args:
home: The autopsy home directory.
Returns:
The list of ``{candidate_id, skill, script, compiled_at}`` entries.
"""
path = home / COMPILED_FILE
if not path.is_file():
return []
try:
data = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return []
if isinstance(data, list):
return [entry for entry in data if isinstance(entry, dict)]
return []
def write_state(
home: Path, candidate_ids: list[str], now: datetime, *, mark_reviewed: bool = False
) -> int:
"""Update state.json and return the unreviewed-candidate count.
Args:
home: The autopsy home directory.
candidate_ids: Every current candidate id, ranked.
now: Current UTC time.
mark_reviewed: When True, every current candidate becomes reviewed.
Returns:
The number of candidates not yet marked reviewed.
"""
state = load_state(home)
stored = state.get("reviewed_ids")
reviewed = (
{item for item in stored if isinstance(item, str)}
if isinstance(stored, list)
else set()
)
if mark_reviewed:
reviewed = set(candidate_ids)
reviewed &= set(candidate_ids)
unreviewed = len([cid for cid in candidate_ids if cid not in reviewed])
payload = {
"unreviewed_count": unreviewed,
"candidate_ids": candidate_ids,
"reviewed_ids": sorted(reviewed),
"last_generated": now.isoformat(timespec="seconds"),
}
home.mkdir(parents=True, exist_ok=True)
(home / STATE_FILE).write_text(
json.dumps(payload, indent=2) + "\n", encoding="utf-8"
)
return unreviewed
def measure_lines(
records: list[SessionRecord],
compiled: list[dict[str, object]],
only_skill: str | None,
) -> list[str]:
"""Compare tokens/invocation before vs after each compilation.
Args:
records: All session records.
compiled: Entries from compiled.json.
only_skill: Restrict output to one skill name, when given.
Returns:
One human-readable line per compiled skill.
"""
lines = []
for entry in compiled:
skill = str(entry.get("skill") or "")
if (
not skill
or not entry.get("script")
or (only_skill is not None and skill != only_skill)
):
continue
compiled_at = parse_timestamp(str(entry.get("compiled_at") or "") or None)
before: list[int] = []
after: list[int] = []
for record in records:
stamp = parse_timestamp(record.meta.started_at)
for invocation in record.obs.skill_invocations:
if invocation.name != skill:
continue
spent = invocation.tokens_output + invocation.tokens_cache_write
if (
compiled_at is not None
and stamp is not None
and stamp >= compiled_at
):
after.append(spent)
else:
before.append(spent)
lines.append(_measure_line(skill, before, after))
if not lines:
lines.append(
"nothing compiled yet — run /compile-candidate on a ranked candidate"
)
return lines
def _measure_line(skill: str, before: list[int], after: list[int]) -> str:
"""Render one scoreboard line.
Args:
skill: Skill name.
before: Tokens/invocation samples before compilation.
after: Samples after compilation.
Returns:
The formatted line, with a delta when both sides have data.
"""
avg_before = sum(before) // len(before) if before else 0
if not after:
return (
f"- {skill}: before {fmt_tokens(avg_before)}/inv "
f"(n={len(before)}) — no post-compile invocations yet"
)
avg_after = sum(after) // len(after)
delta = ""
if avg_before > 0:
delta = f" — {100 * (avg_after - avg_before) // avg_before:+d}%"
return (
f"- {skill}: before {fmt_tokens(avg_before)}/inv (n={len(before)}) "
f"→ after {fmt_tokens(avg_after)}/inv (n={len(after)}){delta}"
)
def render_report(
records: list[SessionRecord],
result: AnalysisResult,
compiled: list[dict[str, object]],
now: datetime,
) -> str:
"""Render the full REPORT.md content.
Args:
records: All session records.
result: Detector output.
compiled: Entries from compiled.json.
now: Current UTC time.
Returns:
The Markdown document.
"""
handled = handled_ids(compiled)
parts = [
_header(records, now),
_turn_efficiency(records),
_candidate_table(result.candidates, handled),
]
parts.extend(
_candidate_detail(candidate) for candidate in result.candidates[:DETAIL_TOP]
)
parts.append(_reviewed_keep_llm(compiled))
parts.append(_trend_section(result))
parts.append(
"## Compiled scoreboard\n\n"
+ "\n".join(measure_lines(records, compiled, None))
+ "\n"
)
return "\n".join(parts)
def _header(records: list[SessionRecord], now: datetime) -> str:
"""Render the totals header.
Args:
records: All session records.
now: Current UTC time.
Returns:
The header block.
"""
turns = sum(record.counts.assistant_msgs for record in records)
output = sum(record.tokens.output for record in records)
cache_write = sum(record.tokens.cache_write for record in records)
cache_read = sum(record.tokens.cache_read for record in records)
per_turn = cache_read // turns if turns else 0
return (
"# Session Autopsy Report\n\n"
f"Generated: {now.isoformat(timespec='seconds')} | Sessions: {len(records)} | "
f"Assistant msgs: {turns}\n"
f"Tokens: {fmt_tokens(output)} output | "
f"{fmt_tokens(cache_write)} cache-write | "
f"{fmt_tokens(cache_read)} cache-read (avg {fmt_tokens(per_turn)}/turn)\n"
)
def _turn_efficiency(records: list[SessionRecord]) -> str:
"""Render the turn-efficiency block.
Cache read is charged per API round-trip on the whole conversation so far, so
turns the model did not need to take are the single largest recoverable cost —
far larger than message length. This block prices them.
Args:
records: All session records.
Returns:
The turn-efficiency section.
"""
api = sum(r.turns.api_turns for r in records)
if not api:
return "## Turn efficiency\n\nno tool turns recorded yet\n"
batched = sum(r.turns.batched_turns for r in records)
poll = sum(r.turns.poll_turns for r in records)
lint = sum(r.turns.lint_test_turns for r in records)
vcs = sum(r.turns.vcs_check_turns for r in records)
calls = sum(r.turns.tool_calls for r in records)
cache_read = sum(r.tokens.cache_read for r in records)
mechanical = poll + lint + vcs
per_turn = cache_read // api if api else 0
return (
"## Turn efficiency\n\n"
f"{api} tool turns carrying {calls} calls | "
f"batched: {batched} ({batched / api:.0%}) | "
f"avg context re-read: {fmt_tokens(per_turn)}/turn\n\n"
"| mechanical turn kind | turns | est. cache-read |\n"
"| --- | ---: | ---: |\n"
f"| polling (progress, liveness, clocks) | {poll} "
f"| {fmt_tokens(poll * per_turn)} |\n"
f"| lint / test (exit-code adjudicated) | {lint} "
f"| {fmt_tokens(lint * per_turn)} |\n"
f"| read-only VCS checks | {vcs} | {fmt_tokens(vcs * per_turn)} |\n"
f"| **total** | **{mechanical}** ({mechanical / api:.0%}) | "
f"**{fmt_tokens(mechanical * per_turn)}** |\n\n"
"These turns decided nothing: a script could have taken them and reported "
"once. Batching independent calls cuts the rest.\n"
)
def _action_cell(cand: Candidate, handled: set[str]) -> str:
"""The table's action column for one candidate.
Args:
cand: The candidate.
handled: Ids with a recorded verdict.
Returns:
The suggested action, or a pointer to where its verdict lives.
"""
if cand.id in handled:
return "already handled — see Reviewed/scoreboard"
return cand.action
def _verdict_label(entry: dict[str, object]) -> str:
"""Human label for a reviewed entry's verdict.
Args:
entry: A compiled.json entry.
Returns:
``"dropped (workflow removed)"`` or ``"keep LLM"``.
"""
if entry.get("verdict") == "dropped":
return "dropped (workflow removed)"
return "keep LLM"
def _candidate_table(candidates: list[Candidate], handled: set[str]) -> str:
"""Render the ranked candidate table.
Args:
candidates: Ranked candidates.
handled: Ids with a recorded verdict — their action column stops
suggesting a review.
Returns:
The Markdown table (or a placeholder when empty).
"""
if not candidates:
return "## Ranked automation candidates\n\nnone yet — need more sessions\n"
rows = [
f"| {rank} | {cand.id} | {cand.kind} | "
f"{cand.occurrences}x / {cand.sessions} sessions "
f"| {fmt_tokens(cand.est_weekly_savings)}/wk | "
f"{_action_cell(cand, handled)} |"
for rank, cand in enumerate(candidates[:TABLE_TOP], start=1)
]
overflow = ""
if len(candidates) > TABLE_TOP:
overflow = (
f"\n… plus {len(candidates) - TABLE_TOP} below the fold — "
"see `candidates --json` for all.\n"
)
return (
"## Ranked automation candidates\n\n"
"| # | id | kind | evidence | est. weekly tokens | action |\n"
"|---|----|------|----------|--------------------|--------|\n"
+ "\n".join(rows)
+ "\n"
+ overflow
)
def _candidate_detail(candidate: Candidate) -> str:
"""Render one candidate's detail block.
Args:
candidate: The candidate.
Returns:
The detail block with the exact traces command.
"""
return (
f"### {candidate.id} — {candidate.title}\n\n"
f"- score {candidate.score:.2f}, avg "
f"{fmt_tokens(candidate.avg_tokens)} tokens/occurrence, "
f"{candidate.per_week:.1f}/week\n"
f"- signature: `{candidate.sig}`\n"
f"- traces: `{TRACES_HINT} {candidate.id}`\n"
)
def _reviewed_keep_llm(compiled: list[dict[str, object]]) -> str:
"""Render candidates already reviewed and ruled judgment-heavy.
These come from /compile-candidate verdicts in compiled.json, not from a
heuristic: real-trace review decided the workflow needs the LLM.
Args:
compiled: Entries from compiled.json.
Returns:
The section text.
"""
kept = [
entry for entry in compiled if entry.get("verdict") in ("keep-llm", "dropped")
]
if not kept:
return "## Reviewed — keeping LLM\n\nnone reviewed yet\n"
lines = [
f"- {entry.get('candidate_id')} — "
f"reviewed {entry.get('compiled_at')} — {_verdict_label(entry)}"
for entry in kept
]
return "## Reviewed — keeping LLM\n\n" + "\n".join(lines) + "\n"
def _trend_section(result: AnalysisResult) -> str:
"""Render the context-bloat trend section.
Args:
result: Detector output holding the trend.
Returns:
The section text.
"""
trend = result.trend
if not trend.weekly:
return "## Context-bloat trend\n\nno dated sessions yet\n"
weeks = "\n".join(
f"- {week}: {fmt_tokens(avg)}/turn" for week, avg in trend.weekly[-8:]
)
offenders = ", ".join(
f"{name} ({fmt_tokens(avg)}/turn)" for name, avg in trend.top_sessions
)
return (
"## Context-bloat trend\n\n"
f"slope: {fmt_tokens(int(trend.slope_per_week))}/turn per week\n"
f"heaviest sessions: {offenders}\n\n{weeks}\n"
)
def write_report(
home: Path,
records: list[SessionRecord],
result: AnalysisResult,
now: datetime,
) -> int:
"""Write REPORT.md and refresh state.json.
Args:
home: The autopsy home directory.
records: All session records.
result: Detector output.
now: Current UTC time.
Returns:
The unreviewed-candidate count (for the caller's summary line).
"""
home.mkdir(parents=True, exist_ok=True)
compiled = load_compiled(home)
(home / REPORT_FILE).write_text(
render_report(records, result, compiled, now), encoding="utf-8"
)
pending = [
cid
for cid in significant_ids(result.candidates)
if cid not in handled_ids(compiled)
]
return write_state(home, pending, now)