Files
6krrt/plans/pinch-instrumentation-and-token-accounting.md
adlee-was-taken 3523dcf93e docs(plans): give every plan a Status line so the queue is greppable
plans/ held 58 documents and exactly one said whether it was open. The rest
mixed finished work, reviews of shipped work, parked specs and genuinely
pending ones, with nothing distinguishing them, so "how many plans are in
the queue" had no answer short of reading all 58.

Now `grep -H '^Status:' plans/*.md` is the answer:

    50 done   3 in progress   2 planned   2 reference   1 parked

Statuses were derived rather than guessed: CLAUDE.md's own built list and
"What's NOT built yet" section, plus checking the subject exists in the
code. A review of work that shipped counts as done -- it records what was
found, it is not a request for anything. `reference` separates the two docs
that are conventions rather than work items (admin-design-standards,
admin-work-framework), which otherwise read as permanently-open plans.

The vocabulary is deliberately five words. A larger one invites "mostly
done" and "blocked-ish", which is how the directory became unreadable.

test_plans_declare_status.py keeps it from rotting: a new plan without a
marker fails, as does an unknown status, one buried below the eighth line,
or an open status with no reason -- "planned" alone is the state that rots,
since nobody can tell later whether it waits on a decision, a dependency,
or just nobody's turn.

Also updates the sweep plan with what landed and what did not, including
that #9 was not a defect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRQXz5SYZYVWscxS1QqF6U
2026-09-08 18:55:16 -04:00

354 lines
16 KiB
Markdown

# Pinch: instrument the 93% lever, then fix what it counts
Status: done -- pinch_summary in metrics.py
**Date:** 2026-09-01
**Status:** **FINAL — ready to implement.** Two commits, small.
**Follows:** `plans/conceptual-review-premise-and-execution.md` finding #3.
---
## 1. Why this is the highest-leverage item in the repo
Decomposing the estimated cost of all 9,007 real chat decisions at the shipped
`assumed_cache_rate: 0.917`:
```
prompt share of est cost: $105.48 (92.6%)
completion share: $8.40 (7.4%)
real billed tokens: 1,190,607,380 prompt / 5,880,453 completion = 202:1
```
Pinch is the **only** thing in this codebase that touches the 92.6%. Every
routing decision, every proficiency score, every tier rule adjudicates the other
7.4% plus whatever model-price multiple applies. A 20% reduction in prompt
tokens is worth more than every routing decision in the measured window
combined.
And it is currently the least observable component in the system.
---
## 2. Nothing can see it
| surface | reports pinch? |
|---|---|
| journal | **no** — `dispatcher.py:2414` and `:2646` call `logs.debug`, `logging.level` is `info`. **Zero** pinch lines in 7 days of journal. |
| `/metrics` | no |
| `route_decisions` | no columns |
| TUI / admin dashboard | no |
`prune_context` returns a perfectly good stats dict — `pruned`,
`original_tokens`, `final_tokens`, `tokens_saved` — and every one of those
values is computed, logged at a level nobody runs, and discarded.
Prompts are deliberately not stored, so **this cannot be reconstructed
retroactively.** There is no analysis that recovers it; the only path is to
start recording. That is the whole argument for doing this before anything else
on the cost axis.
What is visible from `route_decisions` is suggestive but not conclusive.
`required_context_tokens` (recorded at `dispatcher.py:2450` as `measured`,
computed on the **post-pinch** `send_messages`) distributes as:
```
p10 49,817 p25 66,097 p50 88,069 p75 127,517 p90 195,683 p99 279,995
```
against `pinch.budget_tokens: 50000`. The median request ships at 1.8x the
budget and p90 at 3.9x. That is consistent with pinch working correctly on an
untrimmable conversation, *and* with pinch barely firing at all. Without
`tokens_saved` there is no way to tell which — which is the point.
---
## 3. The budget is denominated in the wrong units
Found while writing this, and it is the substantive bug rather than a
reporting gap.
**`prune_context` decides whether to prune from message text alone**
(`context_prune.py:263`):
```python
orig_tokens = sum(estimate_tokens(extract_text(m)) for m in messages)
if orig_tokens <= budget_tokens:
return messages, {"pruned": False, ...}
```
**`estimate_prompt_tokens` — what actually governs routing and tracks the
bill — counts messages *plus tool definitions*** (`dispatcher.py:1839`), and
its own docstring says why:
> Tool definitions ride along in `upstream_body` on every call, so they count
> against the same context window and belong in this estimate too — omitting
> them undercounts every tool-carrying request, which is nearly all agent
> traffic.
Pinch never got that fix. So the two disagree by exactly the tool-definitions
payload — and `CLAUDE.md` records that opencode sends **~32K prompt tokens** of
system prompt and tool definitions on a trivial request. **8,910 of 9,007
requests in the window carried tools.**
Concretely: a conversation whose messages total 45,000 tokens sits under the
50,000 budget, so `prune_context` returns `pruned: False` and touches nothing —
while the request that actually ships and bills is ~77,000 tokens. The knob
labelled "trim once the conversation exceeds 50k" is comparing against a number
that excludes the single largest fixed component of every agent request.
This is the same error the project has already corrected twice elsewhere
(list price vs. billed cost; `cheap_completion_max` gating on the 7.4% axis):
**measure the quantity you are billed for.** The dispatcher learned it and wrote
it into a docstring; pinch is still on the old side of it.
---
## 4. The fix, in order
Instrument **before** changing behaviour, so the change has a baseline to be
judged against. Fixing §3 first would move the numbers with nothing recorded to
compare them to.
### Commit 1 — make it visible (no behaviour change)
1. **`logs.debug` → `logs.info`** at both pinch call sites
(`dispatcher.py:2414`, `:2646`). One line per request is consistent with what
`logging.level: info` already promises ("one line per request: what it was
classified as, which model won, what it cost, how long each stage took").
Emit it only when `pruned` is true, so the no-op case stays quiet.
2. **Persist it.** Add to `route_decisions`:
```sql
ALTER TABLE route_decisions ADD COLUMN pinch_original_tokens INTEGER;
ALTER TABLE route_decisions ADD COLUMN pinch_final_tokens INTEGER;
```
`tokens_saved` is their difference and `pruned` is `original > final`, so two
columns carry all four stats without redundancy. Same `ensure_columns` /
`_ensure_route_decisions_table` migration pattern as everything else. NULL
means pinch was disabled or the request predates this.
*If the exposure-bias plan lands first, fold these into the same migration —
it already adds `request_id` and `exploration` to this table.*
3. **Surface it in `/metrics`.** A `pinch` block alongside the existing
aggregates: share of requests pruned, median and total `tokens_saved`, and
estimated dollars saved. **Price the saved tokens at the same BLENDED rate
`routing.estimated_cost` uses**, not at the cached rate:
```
saved_usd = saved_tokens
* ( (1 - cache_rate) * cost_per_1m_prompt
+ cache_rate * COALESCE(cost_per_1m_prompt_cached, cost_per_1m_prompt) )
/ 1_000_000
```
*(Corrected 2026-09-01. An earlier draft said "price the delta at the
cached prompt rate", which is wrong twice over: `cost_per_1m_prompt_cached`
is already the discounted per-1M price, so multiplying by `cache_rate`
applies the discount a second time, and it silently drops the
`(1 - cache_rate)` fraction billed at full price. Match
`routing.estimated_cost` — `src/routing.py:363-374` — or the dashboard will
report a savings figure that disagrees with the cost model the router
actually ranks on. Note the COALESCE: `cost_per_1m_prompt_cached` can be
NULL, and routing falls back to the full prompt price in that case.)*
This is the number that says whether pinch earns its place, and there is
currently no way to ask for it.
### Commit 2 — count what gets billed
4. **Give `prune_context` the tool-definitions size.** Add a
`extra_fixed_tokens: int = 0` parameter, added to `orig_tokens` before the
budget comparison, and have `dispatcher` pass
`len(json.dumps(tools)) // CHARS_PER_TOKEN` when the request carries tools.
A parameter rather than passing `tools` itself, deliberately: the tool array
is not trimmable — it ships verbatim on every call — so pinch has no reason
to see its contents. It only needs to know how much of the budget is already
spent before it starts. This keeps `context_prune` pure and its signature
honest about what it can act on.
Reuse `dispatcher.estimate_prompt_tokens`'s arithmetic rather than
re-deriving it, so the two cannot drift again — that drift is this bug.
5. **Expect pinch to start firing much more often**, and read commit 1's numbers
before touching `budget_tokens`. Do not tune the budget in the same commit.
### Then — and only then — tune
`budget_tokens: 50000` was set when the budget excluded ~32k of fixed overhead.
Under correct accounting it is a materially tighter setting than it was written
to be. Whether to raise it, or to widen what pinch may trim, is a question for
the data from commits 1-2, not a guess now. `config.yaml`'s own note applies:
*"start conservative (large budget, small reduction) and watch route_decisions /
pinch stats on real traffic before widening it."* Commit 1 is what makes that
sentence executable.
---
## 4b. Bundled cleanup: the test suite is 39% one test
**Unrelated to pinch, folded in deliberately** (requested 2026-09-01) because it
is small, self-contained, and touches nothing the rest of this plan touches. Do
it as its own commit so it can be reverted independently.
Measured on `feat/catalog-staleness-and-ceiling-warnings`, 848 tests, 38.55s
total. Collection is only 1.54s, so startup is not the problem. The `--durations`
profile is dominated by a single test:
```
15.08s tests/test_eval_scoring.py::test_infinite_loop_is_bounded_not_hung
3.40s tests/test_tui.py::test_auto_refresh_rerenders_updated_payload
1.38s tests/test_admin_frontend.py::test_admin_frontend_path_does_not_depend_on_base_dir
1.09s tests/test_admin_health.py::test_admin_does_not_import_dispatcher
…everything below 0.6s
```
### 4b.1 Bound the timeout the test is waiting on — free, no dependency
`test_infinite_loop_is_bounded_not_hung` calls `score_code` on `while True: pass`
and genuinely waits out `eval_proficiency.CODE_TIMEOUT_SECONDS = 15`
(`src/eval_proficiency.py:54`, consumed at `:171`). It is a module-level
constant, so the test can bound it. Verified live:
```
CODE_TIMEOUT_SECONDS = 15 -> 15.08s
CODE_TIMEOUT_SECONDS = 1 -> 1.01s, returns (0.0, 'timeout') — identical
```
```python
def test_infinite_loop_is_bounded_not_hung(monkeypatch):
monkeypatch.setattr(eval_proficiency, "CODE_TIMEOUT_SECONDS", 1)
score, detail = score_code("def f(x):\n while True: pass", ["f(1)==1"])
assert (score, detail) == (0.0, "timeout")
```
**38.5s → ~24.5s from one line.** It also makes the test *better*: it pins the
bounding mechanism rather than the specific 15-second duration, so it stays fast
if the real timeout is ever tuned. Do not change `CODE_TIMEOUT_SECONDS` itself —
15s is the right production value for a model's code; only the test needs it
short.
While in the file, check whether `test_auto_refresh_rerenders_updated_payload`
(3.40s) is waiting on a real Textual timer that a monkeypatched interval would
also collapse. Fix it only if it is the same shape; do not restructure the TUI
tests to chase it.
### 4b.2 Then parallelize — `pytest-xdist`, `-n auto`
`nproc` is 20 and `pytest-xdist` is not installed. Tests look independent
(temp-file SQLite, no shared server fixture), so `-n auto` should distribute
cleanly. Estimated floor after 4b.1 lands: **~5-8s**, bounded by the slowest
remaining test.
**Order matters.** With the 15s test still in place, xdist cannot beat 15s on any
number of cores — a suite is bounded below by its slowest single test. Land 4b.1
first, measure, then add xdist.
Add to `requirements.txt` pinned, matching the file's existing convention. Note
that file's standing warning — dependencies are pinned deliberately because a
service that restarts on boot should not change its dependency tree underneath
itself. `pytest-xdist` is dev-only and never imported by the dispatcher, so it
does not reach the service, but it is still an addition and belongs in its own
commit with that reasoning recorded.
If parallel runs turn out to be flaky, suspect shared state before suspecting
xdist — a test writing to a fixed path (`router.db` in the repo root) rather than
a tmp path would fail only under concurrency. Fix the test; do not fall back to
serial.
### 4b.3 Reframe `src/gate_step2.py` — DEFERRED, wrong branch
**Deferred 2026-09-01.** `src/gate_step2.py` does not exist on `main` or on the
pinch branch — it was created by `feat/proficiency-exposure-bias-and-exploration`,
which is still unmerged. Implementing this here would mean porting
`recompute_category` and the outcome schema across, dragging the whole
exposure-bias surface into a cleanup task. Do it on that branch, or as a
follow-up once it merges. The analysis below stands; only the location is wrong.
Added 2026-09-01, after the exposure-bias work landed and its backlog was spent.
`gate_step2.py` was written as a one-shot pre-step-3 gate: it asserts that stored
`proficiency.blended_score` still equals a benchmark-only `blend()` on the same
components, proving the empirical-Bayes code was inert before any outcome data
was folded in. That gate has served its purpose. The backlog has since been
applied — 886 of 982 `succeeded` and 199 of 226 `failed` client outcomes are now
`applied_at`, and 33 proficiency rows carry 1,059 outcome samples — so stored
scores now legitimately read `outcome_blended` / `outcome_prior` while a
benchmark-only recompute reads `self_eval`.
Run today it prints `FAIL: stored blended_score does not equal recomputed
blend(); regression?` and reports 108 mismatches. **All of them are correct.**
Leaving a checked-in script that loudly cries regression when the system is
healthy is a trap for whoever runs it next — including a future agent, which
will try to "fix" it.
Two acceptable resolutions; prefer the second:
1. Delete it. Its job is done and git history preserves it.
2. **Reframe it as an idempotence check**, which stays useful forever: run
`recompute_category` over a COPY of the live DB and assert the stored
`(source, blended_score)` snapshot is byte-identical afterwards. That pins
the property the single-writer invariant actually depends on — recomputing
changes nothing when inputs have not changed — and it is true both before
and after a backlog spend. Keep the DB-copy discipline (`VACUUM INTO` / `cp`,
never the live file) and the triage output, which are the good parts.
Whichever is chosen, remove the "step-2" framing from the name and docstring so
it no longer implies a one-time migration gate.
---
## 5. File-by-file
**Commit 1**
- `src/dispatcher.py` — `logs.debug` → `logs.info` at both call sites, gated on
`pinch_stats["pruned"]`; add the two columns to `ensure_route_decisions` and
`_ensure_route_decisions_table`; write them in `persist_route_decision` (both
the chat path and the passthrough path, so a pinned-model request still
records what pinch did).
- `config/schema.sql` — the two columns in the `route_decisions` DDL.
- `src/metrics.py` — the `pinch` block in the returned dict.
- `admin/frontend/index.html` and `src/tui_model.py` — surface the block. Follow
the existing "Local compute" card as the pattern; it solves the same problem
(a cost the operator otherwise cannot see).
**Commit 2**
- `src/context_prune.py` — `extra_fixed_tokens` parameter on `prune_context`,
added to `orig_tokens` before the budget check. Docstring: say that the caller
owns fixed, untrimmable overhead and why pinch is not given the tool array.
- `src/dispatcher.py` — pass it at both call sites.
**Commit 3 — bundled test-speed cleanup (§4b), independent of the above**
- `tests/test_eval_scoring.py` — `monkeypatch` `CODE_TIMEOUT_SECONDS` in
`test_infinite_loop_is_bounded_not_hung`. Own commit. Measure before/after.
- `requirements.txt` — add `pytest-xdist` pinned. Separate commit from the
above, with the dev-only-never-imported-by-the-service reasoning in the
message.
**Tests**
- `tests/test_context_prune.py` — a conversation under budget on messages alone
but **over** once `extra_fixed_tokens` is supplied now prunes (this is the §3
bug, pinned); `extra_fixed_tokens=0` reproduces current behaviour
byte-for-byte; the parameter never makes the tool array itself trimmable.
- `tests/test_route_decisions.py` — the two columns are written when pinch
prunes and left NULL when it is disabled.
- `tests/test_metrics.py` — the `pinch` block, including the zero-traffic case.
- The existing rule that the write path stores no conversation text still holds:
these are two integers.
---
## 6. Summary
| | |
|---|---|
| Why | 92.6% of estimated cost is prompt tokens; pinch is the only lever on it. |
| Problem A | Its stats are computed and thrown away — `logs.debug` under `level: info`, nothing in `/metrics` or `route_decisions`. Prompts aren't stored, so this **cannot** be reconstructed later. |
| Problem B | Its budget is compared against message text only, while ~32k of tool definitions ship on 8,910 of 9,007 requests and count against both the window and the bill. |
| Fix | Commit 1: promote the log, persist two integers, surface a `/metrics` block. Commit 2: give `prune_context` the fixed overhead it is currently blind to. |
| Then | Re-tune `budget_tokens` from data, not from a guess. Not in either commit. |
| Bundled (§4b) | Unrelated test-speed cleanup: one monkeypatch takes the suite 38.5s → ~24.5s, then `pytest-xdist -n auto` on 20 cores targets ~5-8s. Separate commits; revertable independently. |