Skip to content

Fix simulated 5h/7d rate-limit buckets: account-wide usage, real countdown, cheaper log, and buckets that reach the renderer - #127

Merged
tmck-code merged 9 commits into
mainfrom
fix/rate-limit-window-aggregation
Sep 3, 2026
Merged

Fix simulated 5h/7d rate-limit buckets: account-wide usage, real countdown, cheaper log, and buckets that reach the renderer#127
tmck-code merged 9 commits into
mainfrom
fix/rate-limit-window-aggregation

Conversation

@tmck-code

@tmck-code tmck-code commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Context

The [rate_limits] simulator reported 0.0% for the 5H bucket on any freshly-started session, while the day token counters on row 3 showed six figures, and the countdown sat near the full window forever. Three independent defects, all in how the simulated buckets were derived.

Bumped to 0.9.2. The weighting defaults are a documented assumption rather than a confirmed rule (see Known limitations), and the table is configurable so users can tune it against their own usage.

Changes

Account-wide usage aggregation

  • RateLimitLog.usage_since filtered the log to a single session_id, but 5h/7d limits are account-wide. With two concurrent sessions it reported 3,973 tokens against a true cross-session total of ~125,000.
  • It also computed in_window[-1] - in_window[0], discarding the first in-window sample's cumulative value — for a session that started inside the window, that sample is the bulk of its usage.
  • Now groups the log by session, baselines each against its last sample before the window (0 when it has none), and sums the per-session deltas.

A countdown that actually counts down

  • _rolling_bucket set resets_at = now + window_seconds, recomputed every render, so the countdown never moved and the window never ended.
  • anchor = "rolling" now means a window anchored at first account-wide activity, derived from the existing log rather than new state. It runs its full length regardless of idle time and advances only once actually elapsed — a session joining after a 3h break sees the same anchor and the remaining 2h, not a fresh window.
  • Semantic change to the documented anchor = "rolling" value. The previous relative-to-now behaviour was the bug, so there is no prior meaning worth preserving.

Log parsed once, written only when it changes

  • The account-wide fix made every render scan the whole log, and _rolling_bucket read it twice — a 1.18x regression (72.1ms vs 61.1ms) at refreshInterval: 1.
  • Single parse per render threaded through both bucket helpers; the lapsed-window loop walks sorted timestamps by index instead of rebuilding a generator per iteration.
  • record() no longer appends when a session's cumulative value is unchanged. On a real log 229 of 5,927 lines (3.9%) changed a value; the other 96% were idle heartbeats, each triggering a full read-parse-rewrite of a 300KB file.
  • Side benefit: timestamps now mark real token spend, so an idle session can no longer open a fresh window having consumed nothing.

Correct usage signal

  • _apply_rate_limit_sim fed the buckets context_window.total_input_tokens + total_output_tokens. That field is the composition of the most recent request (verified: input + cache_creation + cache_read == total_input_tokens, exactly) — a gauge of current context size, not a lifetime total. Diffing it undercounted real consumption by roughly the number of turns in the window (~50x on a measured session), and a /compact made it drop, which max(0, ...) silently swallowed.
  • The signal is now the transcript's lifetime totals, summed per message id across the whole transcript — genuinely monotonic, so the compaction failure mode is structurally impossible.
  • The transcript is parsed exactly once per render and threaded into both the session view and the simulator; the synthesised values still land in the persisted payload that mon reads.
  • The log line now carries the four components (ts session_id input cache_creation cache_read output) rather than one pre-summed number, so weighting can change later without invalidating history. Legacy 3-field lines are skipped on read — their single value is a context-size gauge and cannot be reinterpreted; retention ages them out.
  • Weights are applied at bucket time as named constants: input 1.0, cache_creation 1.25, cache_read 0.1, output 1.0.

Configurable weightings

  • The weights are no longer hardcoded. A [rate_limits.weights] table (input, cache_creation, cache_read, output) is resolved once in Config.load() and threaded through to the aggregation — no Config.load() in the hot path.
  • Every key is optional and the table is independent of the bucket rules, so [rate_limits.weights] alone is valid but inert, and a config without it behaves identically to the built-in defaults.
  • Defaults are derived from the published API pricing ratios (model pricing): input = 1.0, cache_creation = 1.25 (5-minute cache write), cache_read = 0.1, output = 5.0 — output is uniformly 5x base input across every current model.
  • No YAS_* env vars: four bespoke lookups for one opt-in table, and the sibling bucket tables have no env form either.

Synthesised buckets reach the renderer, not just the payload

  • _apply_rate_limit_sim mutated only the raw info dict (the persisted payload mon indexes), but render reads view.session.rate_limits — a SessionInfo snapshotted before the mutation. With all-zero real buckets the statusline drew the "unlimited" glyph on both rows instead of the simulated percentage, even with [rate_limits] configured.
  • It now returns the synthesised RateLimits and reattaches it to the session, keeping exactly one synthesis call per render.

Append-only log: history survives writes

  • record() did a read-all → rewrite-all of the whole log on every writing tick, unlocked. Legacy 3-field lines are skipped on read but were physically erased by the first 6-field write — so the window lost all pre-switch history and the bucket jumped 0% → 4% instead of tracking. The docstring's claim that they "age out on their own" was untrue.
  • Concurrent writers also dropped each other's samples: 3 writers x 200 records left 10 of 600 surviving. Six sessions sharing one config dir hit this routinely.
  • The write path is now a single append. Compaction moved to _maybe_compact, which rewrites only when the oldest row actually predates keep_seconds, under an O_EXCL lock with stale-lock recovery, via tmp + os.replace. It filters raw lines by leading timestamp regardless of field count, so legacy lines now genuinely age out via retention. Dedupe and the parse-once-per-render contract are unchanged.
  • Known limit: an append landing between compaction's re-read and its os.replace is still lost, since it goes to the old inode. Milliseconds wide, only on a compacting tick — not worth blocking a statusline's hot path to close.

In-window usage is growth, not lifetime (baseline fix)

  • usage_since baselined a session at (0,0,0,0) when it had no sample at-or-before the window start, charging that session's entire lifetime cumulative to the window. Observed as a 0 → 1,998,171 jump, of which 8.99M cache-read had accrued an hour before the anchor.
  • The baseline is now the session's first in-window sample, so only growth observed inside the window counts.
  • This also removes the idle-session cliff: a session whose samples all predate the window has cumulative-at-window-start == cumulative-now, so its true delta is 0 and used_percentage decays smoothly across a window roll instead of stepping down by that session's whole share at once.
  • Trade-off, pinned by a test: a brand-new session's very first sample is excluded from its own contribution — a bounded undercount of one sample, replacing an unbounded overcount of a whole lifetime.

Subagent tokens are counted

  • from_transcript reads only the session's own transcript, so the simulator counted main-thread tokens only. Subagent usage is persisted at <session>/subagents/agent-*.jsonl — one flat directory per session covering the whole spawn tree, including depth-2 and depth-3 agents.
  • Measured on a real session: main thread 71,869 billed input vs subagents 244,638. The simulator saw 22.7% of true burn. For a delegation-heavy workflow this undercount is structural, not incidental.
  • New TranscriptUsage.from_session sums the main transcript plus every subagent file. Layout is discovered at runtime — a missing subagents/ falls back to main-only rather than crashing — and no sidechain filter is applied, since those records are all isSidechain:true and filtering would zero them (the trap toolcounts.py:55 already warns about).
  • Subagent files are read through TranscriptCache on the same (mtime, size) contract count_transcript uses, so repeat renders don't re-parse them.
  • Only the rate-limit call site moves, via a new SessionView.rate_limit_usage. The cost row and other transcript_usage consumers keep main-only semantics.

Known limitations

The buckets are a synthetic cap, not a model of a real limit. The motivating case is Bedrock, which exposes no 5h/7d token windows at all: the simulator invents one so developers still get a burn signal against a reasonable self-imposed budget. Absolute percentages are therefore indicative by design — what matters is that the weighting is internally consistent, so relative burn between sessions and over time is meaningful.

Where a real limit does exist, the weighting is a proxy. The defaults come from pricing ratios. Anthropic does not publish how a Max subscription's 5h/7d windows weight cache or output tokens — a quota metering raw token counts rather than cost would weight output at 1.0, not 5.0. That single choice moves the output contribution by 5x, which is exactly why the table is configurable.

1-hour cache writes are under-weighted. A 1h cache write is documented at 2x base input, but the transcript layer does not split cache-creation tokens by TTL, so one weight cannot distinguish them. Users relying on 1h caching may want to raise cache_creation toward 2.0.

Budgets need recalibrating — twice over. Any budget tuned against the old signal was tuned against numbers ~50x too low. Counting subagent tokens raises reported cumulative by a further several-fold (3.4x on the measured session, more for delegation-heavy work), so an existing budget will now read as near-instant exhaustion. Re-tune before rolling this out.

Also unfixed: aggregate_rate_limits (claude/mon/layout.py:149) reads the raw host payload, so mon ignores simulated values.

Checklist

  • Tests added or updated for new/changed behaviour
  • (bug fixes) a regression test now covers the situation so it can't come back
  • N/A — no behaviour change, because:

Screenshots / recording

The final fix changes what the statusline draws when [rate_limits] is configured: the 5H/7D rows showed the "unlimited" glyph () and now show the simulated percentage and countdown. No renderer/layout/glyph code was touched — only the values fed into existing fields.

  • N/A — no visible change

Benchmark

Command Mean [ms] Min [ms] Max [ms] Relative
main 47.1 ± 1.2 45.0 50.5 1.00
PR 47.6 ± 1.8 44.4 54.1 1.01 ± 0.05

Re-run after the subagent-counting change. The 0.5 ms difference is inside one standard deviation. Caveat: the benchmark fixture's session has no subagents/ directory, so this does not exercise the extra file reads — on a real delegation-heavy session the cold-cache render reads up to ~1.4 MB more, absorbed by TranscriptCache on warm renders.

  • N/A — no performance-relevant change

System info

Key Value
OS Linux debian-work 6.12.105+deb13-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.12.105-1 (2026-08-24) x86_64 GNU/Linux
Claude Code 2.1.259 (Claude Code)
Terminal TERM=tmux-256color TERM_PROGRAM=tmux SHELL=/bin/bash COLORTERM=truecolor
Locale LANG=en_AU.UTF-8 LC_ALL=
Python Python 3.12.7
uv uv 0.9.17

🤖 Generated with Claude Code

tmck-code and others added 2 commits September 3, 2026 09:55
The [rate_limits] simulator produced 0% for any freshly-started session and
a countdown pinned near the full window, because of three separate defects.

RateLimitLog.usage_since was scoped to a single session_id, but 5h/7d
limits are account-wide. With two concurrent sessions the reported figure
was 3,973 tokens against a true cross-session total of ~125,000. It also
computed in_window[-1] - in_window[0], discarding the first in-window
sample's cumulative value; for a session that started inside the window
that sample is the bulk of its usage. It now groups the log by session,
baselines each one against its last sample *before* the window (0 when it
has none), and sums the per-session deltas.

_rolling_bucket set resets_at = now + window_seconds, recomputed every
render, so the countdown never counted down and the window never ended.
anchor = "rolling" now means a window anchored at first account-wide
activity, shared by every concurrent session and derived from the existing
rate-limit log rather than new state: the window runs its full length
regardless of idle time, and only advances once it has actually elapsed.
A session joining after a 3h break sees the same anchor and the remaining
2h, not a fresh window.

This redefines the documented meaning of anchor = "rolling"; the previous
relative-to-now behaviour was the bug, so there is no prior semantics
worth preserving under that name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…hanges

The account-wide fix in 0a9d33d made every render scan the whole log
instead of one session's slice, and _rolling_bucket read it twice (once
for the anchor, once for the usage). With refreshInterval: 1 that showed
up as a 1.18x regression: 72.1ms against main's 61.1ms.

Two changes. RateLimitLog now parses the file once per render and threads
that structure through simulate_rate_limits into both bucket helpers, so
the anchor and the usage come from the same pass; the lapsed-window loop
walks the sorted timestamps with an index instead of rebuilding a
generator per iteration.

record() then stops appending when a session's cumulative value is
unchanged from its last line. On a real log only 229 of 5,927 lines
(3.9%) changed a value -- the rest were idle heartbeats repeating the
previous total, and each one triggered a full read-parse-rewrite of a
300KB file. A session's first line is always written; pruning still
happens on the ticks that do write.

Dedupe also fixes a latent bug in the anchor: timestamps now mark real
token spend rather than idle ticks, so an idle session can no longer open
a fresh window having consumed nothing.

make bench: 58.9ms against main's 63.7ms, 1.08x faster.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tmck-code
tmck-code marked this pull request as ready for review September 3, 2026 00:21
tmck-code and others added 4 commits September 3, 2026 10:31
…ntext-window gauge

_apply_rate_limit_sim built its cumulative-token signal from
context_window.total_input_tokens + total_output_tokens, but that field is
the composition of only the single most recent request, not a lifetime
total. Diffing consecutive samples of it undercounted real consumption by
roughly the turn count in the window (every turn re-reads the whole cached
prefix), and the value drops on /compact or /clear -- which the existing
max(0, ...) clamp silently swallowed, reading as zero usage until context
climbed back past its old peak.

TranscriptUsage.gather already sums usage across the whole transcript,
deduped by message id, giving a monotonic lifetime total per session. Route
that through instead: RateLimitLog now logs the four raw components
(input/cache_creation/cache_read/output) rather than one pre-summed number,
so the per-component weighting in usage_since can be tuned later without
invalidating stored history, and legacy 3-field lines (an incompatible
gauge signal) are skipped rather than migrated. usage_since applies a
documented-as-an-assumption weighting mirroring the public API's
cache-pricing ratios, since Anthropic doesn't publish how Max's 5h/7d
windows actually weight cache tokens.

To avoid a second transcript parse per render, main() now builds the
SessionView (when rate_limit_rules are configured) before calling the
simulator, and threads that same view into render() via a new optional
parameter, so the one transcript parse feeds both the simulator and the
normal session view.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Anthropic doesn't document how Max's 5h/7d windows weight cache/output
tokens, so the previous hardcoded weights were a bare assumption baked
into code. Expose them as a global, all-optional yas.toml table (default
unchanged except output, corrected to 5.0 to match the public API's
pricing ratios: output is uniformly 5x base input, not 1x) so users can
tune the guess without patching source.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`_apply_rate_limit_sim` mutated only the raw `info` dict, but `render`
reads `view.session.rate_limits`, snapshotted before the mutation. With
all-zero real buckets the renderer drew the "unlimited" glyph instead of
the simulated percentage. Return the synthesised `RateLimits` and reattach
it to the session, keeping one synthesis call per render.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tmck-code tmck-code changed the title Fix simulated 5h/7d rate-limit buckets: account-wide usage, real countdown, cheaper log Fix simulated 5h/7d rate-limit buckets: account-wide usage, real countdown, cheaper log, and buckets that reach the renderer Sep 3, 2026
tmck-code and others added 3 commits September 3, 2026 12:07
`record()` did a read-all -> rewrite-all of the whole log on every writing
tick, unlocked. Two consequences, both observed on a real log:

- Legacy 3-field lines are skipped on read but were physically erased by
  the first 6-field write, so the window lost all pre-switch history and
  the simulated bucket jumped 0% -> 4% instead of tracking. The class
  docstring's claim that they "age out on their own" was untrue.
- Concurrent writers silently dropped each other's samples: 3 writers x
  200 records left 10 of 600 surviving.

The write path is now a single append of one line. Compaction moves to
`_maybe_compact`, which rewrites only when the oldest row actually
predates `keep_seconds`, under an O_EXCL lock with stale-lock recovery,
via tmp + os.replace. It filters raw lines by leading timestamp
regardless of field count, so legacy lines now genuinely age out via
retention.

The dedupe and the "log parsed once per render" contract are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`usage_since` baselined a session at (0,0,0,0) when it had no sample
at-or-before the window start, charging that session's entire lifetime
cumulative to the window -- observed as a 0 -> 1,998,171 jump of which
8.99M cache-read had accrued an hour before the anchor.

The baseline is now the session's first in-window sample, so only growth
observed inside the window is counted. That also removes the idle-session
cliff: a session whose samples all predate the window has
cumulative-at-window-start == cumulative-now, so its true delta is 0 and
`used_percentage` decays smoothly across a window roll instead of
stepping down by that session's whole share.

Trade-off, pinned by test: a brand-new session's very first sample is
excluded from its own contribution -- a bounded undercount of one sample,
replacing an unbounded overcount of a whole lifetime.

The sim tests that asserted the old behaviour are updated; three of them
now record an explicit zero-baseline sample first, leaving their expected
percentages unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`from_transcript` reads only the session's own transcript, so the
simulator counted main-thread tokens only. Subagent usage is persisted at
`<session>/subagents/agent-*.jsonl`, one flat directory per session
covering the whole spawn tree. Measured on a real session: main thread
71,869 billed input vs subagents 244,638 -- the simulator saw 22.7% of
true burn. On Bedrock, where these buckets exist purely to give devs
burn awareness, that undercount defeats the feature.

Adds `TranscriptUsage.__add__` and `from_session`, which sums the main
transcript plus every subagent file, discovering the layout at runtime
(missing `subagents/` falls back to main-only rather than crashing) and
applying no sidechain filter -- those records are all `isSidechain:true`
and filtering would zero them.

Subagent files are read through `TranscriptCache` on the same
(mtime, size) contract `count_transcript` uses, so repeat renders don't
re-parse them. Only the rate-limit call site moves to `from_session`, via
a new `SessionView.rate_limit_usage`; the cost row and other
`transcript_usage` consumers keep main-only semantics.

Every session's reported cumulative rises several-fold, so a previously
tuned `budget` needs recalibrating.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tmck-code
tmck-code merged commit 365819a into main Sep 3, 2026
6 checks passed
@tmck-code
tmck-code deleted the fix/rate-limit-window-aggregation branch September 3, 2026 03:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant