testsAndMisc/python_pkg/token_audit/tests/test_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

240 lines
7.6 KiB
Python

from __future__ import annotations
import pytest
from python_pkg.token_audit import attribute
from python_pkg.token_audit.imagecost import image_cost
from python_pkg.token_audit.model import Session, ToolCall, Turn
def _turn(
context: int = 0,
out: int = 0,
read: int = 0,
create: int = 0,
*,
sidechain: bool = False,
model: str = "claude-opus-5",
) -> Turn:
return Turn(
usage={
"output_tokens": out,
"cache_read_input_tokens": read,
"cache_creation_input_tokens": create,
},
context=context,
model=model,
is_sidechain=sidechain,
)
def _tool_turn(message_id: str = "", tool_calls: int = 0) -> Turn:
"""A turn that carried tool calls, for the batching axis.
Separate from :func:`_turn` because batching cares only about the message
id and the call count -- none of the token fields are relevant to it.
"""
return Turn(
usage={},
context=0,
model="claude-opus-5",
is_sidechain=False,
message_id=message_id,
tool_calls=tool_calls,
)
def _session(
cwd: str | None = "/home/kuhy/p",
turns: list[Turn] | None = None,
tools: list[ToolCall] | None = None,
image_cost_value: float = 0.0,
) -> Session:
session = Session(session_id="s", path="p", cwd=cwd)
session.turns = turns or []
session.tools = tools or []
session.image_cost = image_cost_value
return session
def test_totals_sum_each_token_kind() -> None:
session = _session(turns=[_turn(out=2, read=100), _turn(out=1, read=50)])
totals, _ = attribute.build([session])
assert totals.by_kind["output_tokens"] == 3
assert totals.by_kind["cache_read_input_tokens"] == 150
assert totals.turns == 2
def test_unknown_cwd_is_labelled() -> None:
_, axes = attribute.build([_session(cwd=None, turns=[_turn(out=1)])])
assert "unknown" in axes.project
def test_sidechain_cost_isolated() -> None:
session = _session(turns=[_turn(out=1, sidechain=True), _turn(out=1)])
_, axes = attribute.build([session])
assert axes.sidechain.count == 1
assert axes.sidechain.total == pytest.approx(5.0)
def test_tool_mcp_and_skill_axes() -> None:
tools = [
ToolCall(name="Read", result_tokens=10, path="/a.png"),
ToolCall(name="Read", result_tokens=5, path="/a.py"),
ToolCall(name="mcp__aseprite__draw_line", result_tokens=1),
ToolCall(name="Skill", result_tokens=1, skill="finish"),
]
_, axes = attribute.build([_session(tools=tools)])
assert axes.tool_tokens["Read"] == 15
assert axes.tool_calls["Read"] == 2
assert axes.mcp_calls["aseprite"] == 1
assert axes.skills["finish"] == 1
assert axes.images.count == 1
assert axes.images.total == 10
def test_long_session_threshold() -> None:
short = _session(turns=[_turn(out=1)])
long_turns = [_turn(out=1) for _ in range(attribute.LONG_SESSION_TURNS + 1)]
long = _session(turns=long_turns)
_, axes = attribute.build([short, long])
assert axes.long_sessions.count == 1
assert axes.long_sessions.total == pytest.approx(len(long_turns) * 5.0)
def test_cold_start_only_counted_when_nonzero() -> None:
cold = _session(turns=[_turn(create=1000)])
warm = _session(turns=[_turn(read=10, create=5)])
_, axes = attribute.build([cold, warm])
assert axes.cold_start.count == 1
assert axes.cold_start.total == 1000
def test_image_cost_is_accumulated_from_sessions() -> None:
_, axes = attribute.build([_session(image_cost_value=7.5)])
assert axes.image_cost == pytest.approx(7.5)
def test_models_axis_tracks_output() -> None:
session = _session(turns=[_turn(out=4, model="claude-sonnet-5")])
_, axes = attribute.build([session])
assert axes.models["claude-sonnet-5"] == 4
def test_reconcile_zero_when_consistent() -> None:
session = _session(turns=[_turn(out=1)])
totals, _ = attribute.build([session])
assert attribute.reconcile(totals, [session]) == pytest.approx(0.0)
def test_reconcile_detects_drift() -> None:
session = _session(turns=[_turn(out=1)])
totals, _ = attribute.build([session])
totals.weighted *= 2
assert attribute.reconcile(totals, [session]) == pytest.approx(0.5)
def test_reconcile_guards_zero_total() -> None:
totals = attribute.Totals()
assert attribute.reconcile(totals, []) == 0.0
# --- imagecost ---------------------------------------------------------------
def _ev_turn(context: int) -> tuple[str, Turn]:
return ("turn", _turn(context=context))
def _ev_image(tokens: int) -> tuple[str, ToolCall]:
return ("tool", ToolCall(name="Read", result_tokens=tokens, path="/s.png"))
def test_image_charged_once_per_following_turn() -> None:
events = [_ev_image(1000), _ev_turn(10), _ev_turn(20), _ev_turn(30)]
assert image_cost(events) == pytest.approx(300.0)
def test_image_not_charged_for_earlier_turns() -> None:
events = [_ev_turn(10), _ev_image(1000), _ev_turn(20)]
assert image_cost(events) == pytest.approx(100.0)
def test_compaction_clears_live_images() -> None:
# context collapses from 100 to 20 (below the 0.7 ratio) => history dropped
events = [_ev_image(1000), _ev_turn(100), _ev_turn(20), _ev_turn(30)]
assert image_cost(events) == pytest.approx(100.0)
def test_growth_below_ratio_does_not_trigger_compaction() -> None:
events = [_ev_image(1000), _ev_turn(100), _ev_turn(90)]
assert image_cost(events) == pytest.approx(200.0)
def test_non_image_tools_are_free() -> None:
events = [
("tool", ToolCall(name="Bash", result_tokens=9999, path=None)),
("tool", ToolCall(name="Read", result_tokens=9999, path="/a.py")),
_ev_turn(10),
]
assert image_cost(events) == 0.0
def test_unknown_event_payloads_are_ignored() -> None:
assert image_cost([("tool", "not a toolcall"), ("turn", "not a turn")]) == 0.0
def test_annotate_sets_session_image_cost() -> None:
from python_pkg.token_audit import imagecost
session = _session()
imagecost.annotate(session, [_ev_image(100), _ev_turn(5)])
assert session.image_cost == pytest.approx(10.0)
def test_batching_groups_records_sharing_a_message_id() -> None:
"""One API message split across records counts once, as a batch.
This is the whole point of the axis: the transcript writes one record per
``tool_use`` block, so a per-record count reports 1.00 calls/message and
hides every batch that ever happened.
"""
session = _session(
turns=[
_tool_turn(message_id="m1", tool_calls=1),
_tool_turn(message_id="m1", tool_calls=1),
_tool_turn(message_id="m1", tool_calls=1),
],
)
_, axes = attribute.build([session])
assert axes.batching.messages == 1
assert axes.batching.batched == 1
assert axes.batching.calls == 3
def test_batching_counts_single_call_messages_as_unbatched() -> None:
session = _session(
turns=[
_tool_turn(message_id="m1", tool_calls=1),
_tool_turn(message_id="m2", tool_calls=1),
],
)
_, axes = attribute.build([session])
assert axes.batching.messages == 2
assert axes.batching.batched == 0
assert axes.batching.calls == 2
def test_batching_ignores_turns_with_no_tool_calls_or_no_id() -> None:
"""Plain text turns and id-less records must not inflate the denominator."""
session = _session(
turns=[
_turn(),
_tool_turn(message_id="m1", tool_calls=0),
_tool_turn(message_id="", tool_calls=2),
],
)
_, axes = attribute.build([session])
assert axes.batching.messages == 0
assert axes.batching.calls == 0