headroom/deploy/beacon
Tejas Chopra e9a24f3ec1
fix(beacon): report all-layers savings, not context-compression only (#2796)
## Description

`rates.saved_pct` and `rates.yield_pct` in the beacon payload divide
`tokens_saved` by `original` / `attempted`. Tool-schema deferral never
lands in either denominator — `outcome.py` says so explicitly, and
`tokens.tool_saved` exists precisely because of it — so every beacon
rate silently reports context compression only.

On a tool-heavy fleet that is not a rounding difference. Across the
first 516 sessions in the corpus the beacon reads **2.80%** where the
dashboard headline for the same traffic reads **12.82%**: 157.6M context
tokens vs 803.9M all-layers, with 646.3M of tool-schema deferral missing
from the ratio.

`headroom/proxy/server.py` already resolved this for the dashboard in
#2737 — `savings_percent` is `all_layers_saved / (input +
all_layers_saved)` and `active_savings_percent` puts tool savings on
both sides of the ratio. The beacon was never brought along. This does
that.

Closes #

## 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/telemetry/session.py`: add `rates.all_layers_saved_pct` and
`rates.all_layers_yield_pct`, computed the way `server.py` builds
`savings_percent` / `active_savings_percent` — tool savings added to
**both** sides, since deferred schemas were attempted work that
succeeded whole.
- `headroom/telemetry/session.py`: extend the `demo()` self-test to
assert both new rates against the existing tool-heavy fixture.
- `deploy/beacon/query.sh`: add an `all_layers_pct` column to the fleet
summary, so the reader stops showing the understated number too.

**Kept alongside `saved_pct` rather than folded into it.** Every row
already in the corpus means context-only under that name; redefining it
would make old and new rows non-comparable with no field to tell them
apart.

**`SCHEMA_VERSION` deliberately stays at 1.** The change is purely
additive, nothing reads the field, and the query uses `union_by_name =
true`, so old and new rows mix cleanly. Happy to bump it if maintainers
want the marker.

## 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
$ python -m headroom.telemetry.session
ok

$ python -m pytest tests/test_savings_tool_search_aggregation.py tests/test_outcome_dual_ruler_funnel.py -q
FAILED tests/test_outcome_dual_ruler_funnel.py::test_ledger_delta_stays_on_the_local_ruler
FAILED tests/test_outcome_dual_ruler_funnel.py::test_ledger_falls_back_to_billed_when_local_omitted
2 failed, 5 passed, 3 warnings in 4.42s

$ ruff check headroom/
All checks passed!

$ mypy --python-version 3.12 headroom/telemetry/session.py
Success: no issues found in 1 source file
```

The two `test_outcome_dual_ruler_funnel.py` failures are **pre-existing
on `main`, not caused by this PR** — verified by `git stash`-ing the
change and re-running:

```text
$ git stash && python -m pytest tests/test_outcome_dual_ruler_funnel.py -q
FAILED tests/test_outcome_dual_ruler_funnel.py::test_ledger_delta_stays_on_the_local_ruler
FAILED tests/test_outcome_dual_ruler_funnel.py::test_ledger_falls_back_to_billed_when_local_omitted
2 failed, 3 passed, 3 warnings in 3.71s
```

## Real Behavior Proof

- **Environment:** macOS 25.4.0 arm64, Python 3.12.6, repo `.venv`,
branch rebased on `upstream/main` @ `d0a86d40`.

- **Exact command / steps:** drive a real `SessionAggregator` with a
tool-heavy outcome (`original=1000`, `attempted=400`,
`tokens_saved=300`, plus `tool_search_deferred_tokens=800` and
`turn_hook_tools_saved_tokens=200`) and print the emitted payload's
`rates` block:

```text
tokens: {"original": 1000, "attempted": 400, "saved": 300, "tool_saved": 1000}
rates:  {
  "saved_pct": 30.0,
  "eligible_pct": 40.0,
  "yield_pct": 75.0,
  "all_layers_saved_pct": 65.0,
  "all_layers_yield_pct": 92.86,
  "cache_read_pct": 50.0,
  "overhead_pct": 5.0
}
```

- **Observed result:** the pre-existing rates are byte-identical (30.0 /
40.0 / 75.0 / 50.0 / 5.0 — no regression), and the two new fields report
the all-layers view: 1300 saved of 2000 sent = **65.0%**, 1300 of 1400
attempted = **92.86%**. Both denominators grow with the numerator,
matching `server.py`.

Cross-checked against the live corpus with DuckDB over the R2 bucket —
restating all 516 sessions both ways reproduces the gap this PR closes:

```text
┌───────────┬────────────┬──────────────────┬──────────────────┬─────────────────────┐
│ ctx_saved │ tool_saved │ all_layers_saved │ beacon_saved_pct │ dashboard_saved_pct │
├───────────┼────────────┼──────────────────┼──────────────────┼─────────────────────┤
│ 157561051 │ 646293457  │ 803854508        │ 2.8              │ 12.82               │
└───────────┴────────────┴──────────────────┴──────────────────┴─────────────────────┘
```

- **Not tested:** no live proxy run was made against a real provider —
the payload above comes from `SessionAggregator` driven directly, which
is the same code path the proxy feeds. The 516 sessions already in R2
are **not backfilled**: they carry `tool_saved`, so the corrected rate
is computable from them today, but their own `rates` block stays
context-only. Only sessions emitted from the next release carry the new
fields.

## 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
- [x] I did **not** edit `CHANGELOG.md`

## Additional Notes

- **Documentation:** N/A — no doc references the beacon `rates` field
names (`grep -rn "saved_pct\|yield_pct" docs/` returns nothing). The
field semantics are documented inline in `session.py`, which this PR
extends.
- **Worth a maintainer opinion:** the dashboard formula adds
`tool_saved` to the *denominator* as well. That is defensible — deferred
schemas were attempted work that succeeded 100% — but it does mean a
tool-heavy session's ratio is partly measuring a layer that is
near-always at full yield. This PR matches the dashboard rather than
inventing a third convention; if the convention should change, it should
change in both places at once.
- **Follow-up:** nothing here changes what the fleet actually saved,
only what the beacon admits to. The 4.6x gap was reporting, not
performance.
2026-08-05 08:33:04 -07:00
..
.gitignore fix(telemetry): anonymous compression stats — no prompts, no data (#2728) 2026-08-03 05:43:25 -07:00
query.sh fix(beacon): report all-layers savings, not context-compression only (#2796) 2026-08-05 08:33:04 -07:00
sample-event.json fix(telemetry): anonymous compression stats — no prompts, no data (#2728) 2026-08-03 05:43:25 -07:00
worker.js fix(telemetry): anonymous compression stats — no prompts, no data (#2728) 2026-08-03 05:43:25 -07:00
wrangler.toml fix(telemetry): anonymous compression stats — no prompts, no data (#2728) 2026-08-03 05:43:25 -07:00