feat(compaction): calibrate token-estimate skew per content class - #541
Merged
Merged
Conversation
The skew calibration was a single global ratio, but the rough estimator's
error is a RATE error that is not uniform across content. Measured against
provider ground truth on 176 chunks (15.4 MB) replayed from 30 real
production sessions:
class n median mean sd
text 89 1.0048 1.0128 0.054
tool 87 1.1629 1.1757 0.087
global 176 1.0842
Welch t = 14.89, Mann-Whitney z = -10.48, Cohen's d = 2.26; text p90
(1.0877) and tool p10 (1.0842) barely touch. Applying the single global
median over-corrects text-dominated turns by 7.9% and under-corrects
tool-dominated ones by 6.8% -- the "wrong for both" blend, quantified.
Full method and reproduction: docs/per-class-skew-measurement.md.
Structured tool output tokenizes denser than prose, so the character-rate
estimator reads LOW on it (compaction fires LATE, toward overflow) and
roughly correct on prose. That is exactly what a per-class ratio corrects
and a blended one cannot.
What this adds:
* agent/content_class.py -- a cheap, total, never-raising classifier over
three classes (text / tool / media). Weight is attributed at the PART
level, so an assistant message carrying both prose and a tool_calls
payload contributes to both classes proportionally instead of being
mislabelled wholesale. Media is weighed at its flat provider-pricing
cost (media_part_token_cost), never at base64 length, so one screenshot
cannot out-vote a conversation. "Dominant" is a strict MAJORITY:
plurality-without-majority is genuinely ambiguous and the honest
correction for a mixed turn is the blended global one.
* Per-class skew histories alongside the existing global one. The class
arm is strictly ADDITIVE -- every pair still lands in the global
history, which remains the fallback.
* compression.skew_class_min_samples (default 3) with an explicit reader
(_per_class_min_samples). Justification: the class ratio is a median of
at most _SKEW_HISTORY (5) readings, and a median only rejects an outlier
from 3 samples up (at 1 it IS the outlier; at 2 an outlier drags it
half-way). 3 is the smallest size at which the smoothing the
calibration already relies on functions. Not set higher because the
calibration resets per conversation, so a floor of ~10 would leave the
feature inert in most sessions. Setting it to 0 disables the class arm
and restores the single-global-ratio behavior exactly.
Scale-up work is preserved, not regressed: the per-class median rides the
SAME clamp band as the global one, so an UNDER-count is still correctable
upward bounded by _SKEW_SCALE_UP_MAX (PR #506's lift in
record_skew_from_real and _current_skew, PR #529's fix in
seed_skew_calibration). Tests assert that band explicitly.
Wiring: the preflight paths in agent/turn_context.py and
agent/conversation_loop.py now thread the outgoing `messages` into the
calibration via call_with_messages(), which degrades to the old
single-argument call for plugin engines predating the parameter (they keep
global-only behavior). Tests include AST/source assertions that the
classifier is actually consulted on the production estimate path, so the
feature cannot rot into dead code.
Deliberately NOT done: the measured body divisor is not hardcoded. Each
class accumulates its own last-k readings and takes its own median, so the
adaptive loop converges per class on its own rather than freezing one
workload's number into a fleet-wide constant.
Tests: tests/agent/test_per_class_skew_calibration.py -- 36 tests
covering the classifier, per-class application, sample-floor fallback, the
scale-up regression band, recording hygiene, and production-path wiring.
RED proof against the un-implemented baseline: 10 failed / 26 passed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
The token-estimate skew was a single global ratio, but the rough estimator's error is a rate error and that rate is not uniform across content. One blended ratio is wrong for text-heavy turns and wrong for tool-output-heavy turns.
Measurement first (this is not speculative)
I replayed real data before building anything. 30 real production sessions from the local session store, bucketed by content class, chunked to ~80 KB, and compared the production estimator (
estimate_messages_tokens_rough) against provider ground truth (a realPOST /v1/messageswithmax_tokens=1, readingusage.input_tokens, net of a measured empty-request baseline).15.4 MB of real transcript, 176 measured chunks:
Separation is decisive:
Cost of the single global ratio, applied to each class:
That is the "wrong for both" failure, quantified. Structured tool output (JSON, escaped strings, long identifiers, paths) tokenizes denser than prose, so the character-rate estimator reads LOW on it — which makes threshold compaction fire LATE, toward provider overflow.
Full method + reproduction steps:
docs/per-class-skew-measurement.md.What this adds
agent/content_class.py— a cheap, total, never-raising classifier over three classes (text/tool/media):tool_callspayload contributes to both classes proportionally, instead of mislabelling the most common message shape in an agent transcript.media_part_token_cost), never at base64 length — so one screenshot cannot out-vote an entire conversation. (This is the bugagent/media_tokens.pyexists to prevent; there's a test asserting the classifier doesn't reintroduce it.)Per-class skew histories alongside the existing global one. The class arm is strictly additive: every pair still lands in the global history, which remains the fallback.
Required behaviors
✅ Clean fallback below the sample floor. New knob
compression.skew_class_min_samples, default 3, with an explicit reader (_per_class_min_samples) — aconfig.yamlknob, declared inconfig_defaults.py(sohermes config setvalidates it), not a hardcoded constant and not an env var. There's a test asserting the reader is actually consulted and a test asserting the key is declared (an undeclared knob is an inert knob).Why 3: the class ratio is a median of at most
_SKEW_HISTORY(5) readings. A median only rejects an outlier from 3 samples up — at 1 it is the outlier, at 2 it's a mean of two that an outlier still drags half the distance. 3 is the smallest size at which the smoothing the calibration already relies on actually functions. Deliberately not higher: the calibration resets per conversation, so a floor of ~10 would leave the feature inert in exactly the sessions that matter. Set it to 0 to disable the class arm entirely and restore the single-global-ratio behavior exactly.✅ Scale-up work preserved, not regressed. The per-class median rides the same clamp band as the global one, so an UNDER-count is still correctable upward, bounded by
_SKEW_SCALE_UP_MAX. There's a dedicated test class (TestUndercountStillCorrectableUpward) asserting: class ratio may exceed 1.0, is bounded by_SKEW_SCALE_UP_MAX, respects the skew floor, the global ratio still exceeds 1.0, andseed_skew_calibration's band is unchanged — i.e. the exact bands PR #506 lifted fromrecord_skew_from_real+_current_skewand PR #529 fixed inseed_skew_calibration.✅ Measured constant NOT hardcoded. The ~2.91 body divisor is nowhere in this diff. Each class accumulates its own last-k readings and takes its own median, so the adaptive loop converges per class on its own rather than freezing one workload's number into a fleet-wide constant. Convergence is also now measurable:
COMPACTION_SKEWtelemetry gained aclass=label, so the skew distribution in~/.hermes/state/skew-samples.logis decomposable by class.Wiring (the anti-dead-code assertions)
The preflight paths in
agent/turn_context.pyandagent/conversation_loop.py— the sites that decide compaction for every CLI/gateway turn — now thread the outgoingmessagesinto the calibration viacall_with_messages(), which degrades to the old single-argument call for plugin engines predating the parameter (they keep global-only behavior, the documented fallback).TestProductionPathWiringincludes an AST assertion over the realagent/turn_context.py: every calibration entry point (note_rough_sent,calibrated_tokens,should_compress_calibrated,_trigger_calibrated_tokens) must be called with the message list. If someone drops the argument, no request is ever classified and the whole arm is dead — that test fails loudly instead.Tests
tests/agent/test_per_class_skew_calibration.py— 36 tests, asserting the behavior contract, never the measured constants:_SKEW_SCALE_UP_MAXRED proof: against the un-implemented baseline (class arm neutered, wiring reverted, knob undeclared — module kept importable so the suite collects): 10 failed / 26 passed. With the implementation: 36/36 pass.
Regression: 589 passed / 2 failed across
test_context_compressor,test_skew_persistence,test_compaction_preemption_gate,tests/context_engine/,test_api_content_sidecar. Both failures are pre-existing on the base branch (aDaemonThreadPoolExecutor._initializerenvironment issue), verified by stashing the change and re-running.ruff checkclean on all touched files;scripts/check_subprocess_stdin.pypasses.