testsAndMisc/python_pkg/token_audit/attribute.py
Krzysztof kuhy Rudnicki c5cd63fb80
Some checks are pending
md-naming / markdown naming convention (push) Waiting to run
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
token_audit: measure tool-call batching per API message
Counting transcript records reports 1.00 tool calls per message no matter
what the model did: the harness writes one JSONL record per tool_use block,
all sharing message.id. A hand-rolled per-record count read the real 1.9%
batching rate as 0.0%, which nearly shipped as a headline finding.

Group by message.id instead, and report it, so the number comes from a
tested code path with the window handled by find_transcripts rather than an
ad-hoc timestamp. Three snapshot fields give next week's run a baseline.

Axes was at the 12-attribute cap, so the counters live in their own Batching
dataclass with the two ratios as properties.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NiSSDu4gKbp9GGxTTtXvYW
2026-08-28 12:22:21 +02:00

191 lines
6.8 KiB
Python

"""Roll sessions up along the axes the report ranks.
Each axis answers a different "should I change something?" question, so they
are kept separate rather than merged into one table:
* **project** (cwd) — which work was expensive, not which tool was.
* **tool** — where result payloads come from; ranked by estimated result size.
* **mcp** — invocation counts per server, the only way to tell a registered
server apart from a *used* one.
* **skill** — how often each skill was actually invoked.
* **sidechain** — what subagents cost, which is easy to assume is large and in
practice may be zero.
* **cold start** — the fixed prefix every new session pays before doing work.
Only :func:`totals` feeds the reconciliation gate; everything else is ranking.
"""
from __future__ import annotations
from collections import Counter
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from python_pkg.token_audit.model import TOKEN_KINDS, Session
if TYPE_CHECKING:
from collections.abc import Iterable
# A session past this many assistant turns is "long". Chosen from measurement:
# in the observed window, sessions above it were ~90% of all weighted spend,
# because per-turn cost scales with accumulated context.
LONG_SESSION_TURNS = 400
@dataclass
class Totals:
"""Raw token counts summed straight from the API's usage blocks."""
by_kind: Counter[str] = field(default_factory=Counter)
weighted: float = 0.0
turns: int = 0
def add(self, session: Session) -> None:
"""Accumulate one session's usage."""
for turn in session.turns:
for kind in TOKEN_KINDS:
self.by_kind[kind] += turn.usage.get(kind, 0)
self.weighted += turn.cost
self.turns += 1
@dataclass
class Tally:
"""A running "how much, over how many" pair.
Every headline driver is reported the same way — a cost or token total plus
the number of things that produced it — so they share one type instead of
two loose fields each.
"""
total: float = 0.0
count: int = 0
def add(self, amount: float) -> None:
"""Record one more contribution."""
self.total += amount
self.count += 1
@dataclass
class Batching:
"""How often independent tool calls shared one API message.
An unbatched call re-sends the whole conversation to earn a single result,
so this is a direct measure of avoidable turns rather than a style metric.
"""
messages: int = 0
batched: int = 0
calls: int = 0
@property
def share(self) -> float:
"""Fraction of tool messages that carried more than one call."""
return self.batched / self.messages if self.messages else 0.0
@property
def per_message(self) -> float:
"""Mean tool calls per API message; 1.0 means nothing was ever batched."""
return self.calls / self.messages if self.messages else 0.0
@dataclass
class Axes:
"""Every ranking the report needs, in one pass over the sessions."""
project: Counter[str] = field(default_factory=Counter)
tool_tokens: Counter[str] = field(default_factory=Counter)
tool_calls: Counter[str] = field(default_factory=Counter)
mcp_calls: Counter[str] = field(default_factory=Counter)
skills: Counter[str] = field(default_factory=Counter)
models: Counter[str] = field(default_factory=Counter)
sidechain: Tally = field(default_factory=Tally)
cold_start: Tally = field(default_factory=Tally)
images: Tally = field(default_factory=Tally)
long_sessions: Tally = field(default_factory=Tally)
# Cost-weighted, compaction-aware image re-send estimate. Tracked apart from
# ``images`` (which counts tokens read once) because the two answer
# different questions and only this one is comparable to session cost.
image_cost: float = 0.0
batching: Batching = field(default_factory=Batching)
def build(sessions: Iterable[Session]) -> tuple[Totals, Axes]:
"""Compute reconciled totals and every ranking axis together."""
totals = Totals()
axes = Axes()
for session in sessions:
totals.add(session)
_add_turns(axes, session)
_add_tools(axes, session)
_add_session_shape(axes, session)
return totals, axes
def _add_turns(axes: Axes, session: Session) -> None:
"""Attribute per-turn cost to project, model and sidechain."""
axes.project[session.cwd or "unknown"] += int(session.cost)
for turn in session.turns:
axes.models[turn.model] += turn.usage.get("output_tokens", 0)
if turn.is_sidechain:
axes.sidechain.add(turn.cost)
_add_batching(axes, session)
def _add_batching(axes: Axes, session: Session) -> None:
"""Count how many API messages carried more than one tool call.
Grouped by ``message_id`` because the transcript splits one API message
across several records -- one per ``tool_use`` block. Counting records
instead reports every message as carrying exactly one call, which is how a
real 2.8% batching rate reads as 0.0%.
"""
per_message: Counter[str] = Counter()
for turn in session.turns:
if turn.tool_calls and turn.message_id:
per_message[turn.message_id] += turn.tool_calls
for calls in per_message.values():
axes.batching.messages += 1
axes.batching.calls += calls
if calls > 1:
axes.batching.batched += 1
def _add_tools(axes: Axes, session: Session) -> None:
"""Attribute tool-result payloads to tools, MCP servers and skills."""
for call in session.tools:
axes.tool_tokens[call.name] += call.result_tokens
axes.tool_calls[call.name] += 1
server = call.mcp_server
if server is not None:
axes.mcp_calls[server] += 1
if call.skill is not None:
axes.skills[call.skill] += 1
if call.is_image:
axes.images.add(call.result_tokens)
def _add_session_shape(axes: Axes, session: Session) -> None:
"""Attribute whole-session properties: cold start, length, image cost."""
cold = session.cold_start_tokens
if cold:
axes.cold_start.add(cold)
axes.image_cost += session.image_cost
if session.turn_count > LONG_SESSION_TURNS:
axes.long_sessions.add(session.cost)
def reconcile(totals: Totals, sessions: Iterable[Session]) -> float:
"""Return the relative drift between axis sums and the raw usage totals.
The report is only worth acting on if its parts add up to its whole, so the
CLI turns this into a hard gate rather than a warning. Comparing the
per-project rollup against the independently summed usage blocks catches
any session that was counted twice or dropped.
"""
summed = sum(session.cost for session in sessions)
if totals.weighted == 0:
return 0.0
return abs(summed - totals.weighted) / totals.weighted