Commit b3a559b
authored
fix(savings): cap ledger retention at 30 days (#1985)
## Description
The durable savings ledger (`headroom savings`) retained up to 365 days
of history with an unbounded-sounding "All time" window. Long-lived
installs accumulate an ever-growing `~/.headroom/savings_events.jsonl`,
and `--days` had no upper bound so a caller could request an arbitrarily
large lookback. This caps retention at 30 days everywhere it's read,
shrinks the compaction threshold to match, and renames the "All time"
window to reflect what it actually is now: `Last 30 days`.
## Type of Change
- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which 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/savings_ledger.py`: `DEFAULT_RETENTION_DAYS` 365 → 30; add
`MAX_RETENTION_DAYS = 30` and hard-clamp the lookback inside
`aggregate_savings` so no caller (CLI or programmatic) can read back
further than 30 days, regardless of the `retention_days` argument passed
in.
- `headroom/savings_ledger.py`: report window `all_time` →
`last_30_days` (the bucket is exactly 30-day-bounded now, so it doubles
as the lifetime view too). `_COMPACT_SIZE_BYTES` 8 MiB → 1 MiB, since a
30-day-bounded ledger should never need to grow large.
- `headroom/cli/savings.py`: `--days` is now `click.IntRange(min=1,
max=30)` (was unbounded); help text states the max. Window label `"All
time"` → `"Last 30 days"`, and the label column width bumped 11 → 12 so
the longer label stays aligned with the other rows' progress bars.
- `tests/test_savings_ledger.py`: updated window-label assertions; added
a hard-cap regression test (`retention_days=365` passed explicitly still
excludes a 60-day-old event) and a `--days` range-rejection test
(31/60/365 all rejected).
## 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/savings_ledger.py headroom/cli/savings.py tests/test_savings_ledger.py
All checks passed!
$ ruff format --check headroom/savings_ledger.py headroom/cli/savings.py tests/test_savings_ledger.py
3 files already formatted
$ mypy headroom --ignore-missing-imports
Success: no issues found in 409 source files
$ pytest tests/test_savings_ledger.py -q
............ss.... [100%]
16 passed, 2 skipped in 6.11s
```
(ruff `0.15.17`, mypy `1.20.2` — pinned to match
`.github/workflows/ci.yml`'s `lint` job. Full multi-shard suite left to
CI; ran the full touched-module suite locally.)
## Real Behavior Proof
- Environment: macOS (Darwin 25.5.0), Python 3.13.14, local `uv` venv;
branch built and installed via `uv tool install --force`.
- Exact command / steps: ran `headroom savings` against a ledger holding
multiple models' events (claude-opus-4-8, claude-sonnet-5,
claude-haiku-4-5) recorded across the retention window, then ran
`headroom savings --days 60` to exercise the new upper bound.
- Observed result: all three windows (Today / Last 7 days / Last 30
days) populate and are each bounded to at most 30 days; cost-avoided
breaks down per model; `--days 60` is rejected by the new `1..30` range
instead of silently accepted.
- Not tested: Windows/macOS native-wrapper e2e jobs — left to CI.
```text
$ headroom savings
Today █████░░░░░░░░░░░ 33.8% saved 8,702,348 / 25,781,326 tokens $25.5830
Last 7 days ██████░░░░░░░░░░ 36.3% saved 11,289,737 / 31,072,254 tokens $34.8287
Last 30 days ██████░░░░░░░░░░ 38.2% saved 14,449,516 / 37,821,634 tokens $48.5385
Cost avoided per model:
claude-opus-4-8 $33.0494
claude-sonnet-5 $15.2989
claude-haiku-4-5-20251001 $0.1902
$ headroom savings --days 60
Usage: headroom savings [OPTIONS]
Try 'headroom savings --help' for help.
Error: Invalid value for '--days': 60 is not in the range 1<=x<=30.
```
- Not tested: Windows/macOS native-wrapper e2e jobs — left to CI.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] 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 have updated the CHANGELOG.md if applicable
## Screenshots (if applicable)
N/A — CLI text output only, see Real Behavior Proof above.1 parent 82af5cd commit b3a559b
3 files changed
Lines changed: 49 additions & 18 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
40 | 40 | | |
41 | 41 | | |
42 | 42 | | |
43 | | - | |
| 43 | + | |
44 | 44 | | |
45 | 45 | | |
46 | 46 | | |
| |||
49 | 49 | | |
50 | 50 | | |
51 | 51 | | |
52 | | - | |
| 52 | + | |
53 | 53 | | |
54 | 54 | | |
55 | | - | |
| 55 | + | |
56 | 56 | | |
57 | 57 | | |
58 | 58 | | |
| |||
87 | 87 | | |
88 | 88 | | |
89 | 89 | | |
90 | | - | |
| 90 | + | |
91 | 91 | | |
92 | 92 | | |
93 | 93 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
48 | 48 | | |
49 | 49 | | |
50 | 50 | | |
51 | | - | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
52 | 55 | | |
53 | 56 | | |
54 | 57 | | |
55 | 58 | | |
56 | 59 | | |
57 | 60 | | |
58 | | - | |
| 61 | + | |
| 62 | + | |
59 | 63 | | |
60 | 64 | | |
61 | 65 | | |
| |||
286 | 290 | | |
287 | 291 | | |
288 | 292 | | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
289 | 296 | | |
290 | 297 | | |
291 | 298 | | |
| |||
294 | 301 | | |
295 | 302 | | |
296 | 303 | | |
297 | | - | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
298 | 307 | | |
299 | 308 | | |
300 | 309 | | |
| |||
309 | 318 | | |
310 | 319 | | |
311 | 320 | | |
312 | | - | |
| 321 | + | |
313 | 322 | | |
314 | 323 | | |
315 | 324 | | |
| |||
328 | 337 | | |
329 | 338 | | |
330 | 339 | | |
331 | | - | |
| 340 | + | |
332 | 341 | | |
333 | 342 | | |
334 | 343 | | |
335 | | - | |
| 344 | + | |
336 | 345 | | |
337 | 346 | | |
338 | 347 | | |
| |||
383 | 392 | | |
384 | 393 | | |
385 | 394 | | |
| 395 | + | |
386 | 396 | | |
387 | 397 | | |
388 | 398 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
66 | 66 | | |
67 | 67 | | |
68 | 68 | | |
69 | | - | |
| 69 | + | |
70 | 70 | | |
71 | 71 | | |
72 | 72 | | |
| |||
84 | 84 | | |
85 | 85 | | |
86 | 86 | | |
87 | | - | |
| 87 | + | |
88 | 88 | | |
89 | 89 | | |
90 | 90 | | |
91 | 91 | | |
92 | | - | |
93 | | - | |
| 92 | + | |
| 93 | + | |
94 | 94 | | |
95 | 95 | | |
96 | 96 | | |
97 | 97 | | |
98 | | - | |
| 98 | + | |
99 | 99 | | |
100 | 100 | | |
101 | 101 | | |
102 | 102 | | |
103 | 103 | | |
| 104 | + | |
104 | 105 | | |
105 | 106 | | |
106 | 107 | | |
107 | 108 | | |
108 | 109 | | |
109 | | - | |
| 110 | + | |
110 | 111 | | |
| 112 | + | |
111 | 113 | | |
112 | 114 | | |
| 115 | + | |
113 | 116 | | |
114 | 117 | | |
115 | 118 | | |
| |||
184 | 187 | | |
185 | 188 | | |
186 | 189 | | |
187 | | - | |
| 190 | + | |
188 | 191 | | |
189 | 192 | | |
190 | 193 | | |
191 | 194 | | |
192 | 195 | | |
193 | 196 | | |
194 | 197 | | |
195 | | - | |
| 198 | + | |
196 | 199 | | |
197 | 200 | | |
198 | 201 | | |
| |||
279 | 282 | | |
280 | 283 | | |
281 | 284 | | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
0 commit comments