headroom/tests/test_rtk_session_savings.py
Tejas Chopra 93627471b7
fix(perf): surface RTK/CLI context-tool savings in perf and the session card (#1433)
## Description

`headroom perf` read only `proxy.log` compression records, so RTK's
savings — which live in RTK's own lifetime counter and never land in
`proxy.log` — were **invisible**: perf reported "token savings" while
silently dropping the entire CLI-filtering layer. The dashboard
**Session** card likewise showed only the session-delta (≈0 right after
a proxy restart), with no scope label and no lifetime figure.

This surfaces RTK lifetime savings in `headroom perf` (text + JSON) and
clarifies the dashboard Session card. It complements #1324 (which added
RTK to the Historical tab) by covering the two surfaces #1324 didn't:
`perf` and the live Session card.

Closes # N/A — complements #1324; no standalone issue.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- `headroom/perf/analyzer.py`: `format_report` and `build_perf_summary`
now attach RTK/CLI context-tool **lifetime** savings, sourced
best-effort from `_get_context_tool_stats().lifetime` (the same source
`/stats` and #1324 use). Lifetime — not session — is the right scope for
a one-shot CLI, since the proxy-session baseline `/stats` subtracts is
meaningless out of process. Omitted entirely when no tool is installed
or its stats can't be read, so the report degrades to proxy-only rather
than erroring.
- `headroom/dashboard/templates/dashboard.html`: the Session card now
labels the RTK number **"this session"**, uses the real
`session_savings_pct` (via a new `cliFilteringSessionPctDisplay` getter)
instead of an ad-hoc share, and shows **lifetime** alongside it (new
`cliFilteringLifetime` getter + row, hidden when 0).
- `tests/test_perf_cli_filtering.py` (new): perf surfaces RTK in text +
JSON; omits cleanly when the tool is absent.
- `tests/test_rtk_session_savings.py` (new): exercises the real
`_get_context_tool_stats()` plumbing to pin that session RTK savings are
the **delta from the startup baseline**, and session `savings_pct` is
derived from that delta — not RTK's lifetime-diluted average.
- `tests/test_proxy_dashboard_stats_cache.py`: updated the Session-card
label assertion and added one for the new lifetime row.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ ruff check headroom/perf/analyzer.py tests/test_perf_cli_filtering.py tests/test_rtk_session_savings.py tests/test_proxy_dashboard_stats_cache.py
All checks passed!

$ mypy headroom/perf/analyzer.py
mypy: No issues found

$ python -m pytest tests/test_perf_cli_filtering.py tests/test_rtk_session_savings.py tests/test_proxy_dashboard_stats_cache.py tests/test_owned_asset_encoding.py -q
17 passed, 1 skipped in 15.66s
```

## Real Behavior Proof

- Environment: macOS (darwin), Python 3.12, branch
`fix/rtk-savings-perf-dashboard`, RTK v0.28.2.
- Exact command / steps: `headroom perf` and `headroom perf --format
json`.
- Observed result: the text report now includes a section
`RTK CLI Filtering (lifetime, all-time) — Tokens saved: 26,867,610
(68.8%), Commands: 8,023`,
and the JSON output carries `"cli_filtering":
{"tool":"rtk","label":"RTK","tokens_saved":26867610,"commands":8023,"savings_pct":68.8}`.
Before this change, both omitted RTK entirely (perf's "Total saved" was
proxy-compression only). The dashboard template renders the new "this
session" / "lifetime" RTK rows (verified via `get_dashboard_html()` +
substring test).
- Not tested: live dashboard browser click-through (template loads and
the new strings are asserted by the substring test); CSV output of
`perf` (per-model table only, by design).

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable

## Additional Notes

- CHANGELOG: left to Release Please (the conventional `fix(perf):`
commit generates the entry on merge), matching how the existing "Bug
Fixes" entries are produced.
- Follow-up: #1403 (`fix/rtk-savings-scope-regression`) bundles
unrelated kompress must-keep work (overlaps #1400/#1419) and only
documents the scope `%` invariant in the abstract. The real,
code-exercising session-delta regression now lives here
(`test_rtk_session_savings.py`), so #1403 can be split — route the
kompress bits to #1400/#1419 and drop the rest.
2026-06-25 21:13:36 -07:00

70 lines
2.6 KiB
Python

"""Session RTK savings must be the delta from the proxy-startup baseline.
Regression for the scope-mixing bug: the dashboard's *session* RTK number must
be computed from token deltas since the baseline pinned at proxy startup — NOT
from RTK's lifetime average (which dilutes a 62%-this-session rate down to an
18.5% all-time number). This exercises the real ``_get_context_tool_stats()``
plumbing rather than asserting the arithmetic in the abstract.
"""
from __future__ import annotations
import headroom.proxy.helpers as helpers
def _reset(monkeypatch):
monkeypatch.delenv(helpers._RTK_GAIN_SCOPE_ENV, raising=False)
monkeypatch.setenv("HEADROOM_CONTEXT_TOOL", "rtk")
helpers._context_tool_stats_cache.update(
{"expires_at": 0.0, "has_value": False, "tool": None, "value": None}
)
helpers._context_tool_session_baseline.update(
{
"initialized": False,
"tool": None,
"total_commands": 0,
"input_tokens": 0,
"output_tokens": 0,
"tokens_saved": 0,
"total_time_ms": 0,
"captured_at": 0.0,
}
)
def _bust_cache():
helpers._context_tool_stats_cache.update(
{"expires_at": 0.0, "has_value": False, "tool": None, "value": None}
)
def test_session_savings_is_delta_not_lifetime_average(monkeypatch):
_reset(monkeypatch)
state: dict = {"summary": None}
def fake_lifetime(tool):
return helpers._context_tool_summary_payload(
tool="rtk", installed=True, scope="global", summary=state["summary"]
)
monkeypatch.setattr(helpers, "_read_context_tool_lifetime_stats", fake_lifetime)
# First poll pins the baseline to the current lifetime → session delta is 0,
# but the lifetime number is preserved untouched.
state["summary"] = {"total_input": 1000, "total_output": 400, "total_saved": 600}
first = helpers._get_context_tool_stats()
assert first is not None
assert first["session"]["tokens_saved"] == 0
assert first["lifetime"]["tokens_saved"] == 600
# Lifetime advances (more RTK commands run this session); the session number
# is the DELTA, not the 800 lifetime total.
_bust_cache()
state["summary"] = {"total_input": 1300, "total_output": 500, "total_saved": 800}
second = helpers._get_context_tool_stats()
assert second["session"]["tokens_saved"] == 200 # 800 - 600
assert second["lifetime"]["tokens_saved"] == 800
# Session % is derived from the delta (200 saved / 300 input delta), not the
# lifetime-diluted average.
assert second["session"]["savings_pct"] == round(200 / 300 * 100, 4)