diff --git a/headroom/transforms/content_router.py b/headroom/transforms/content_router.py index df5aabdff..4a4bbc8d0 100644 --- a/headroom/transforms/content_router.py +++ b/headroom/transforms/content_router.py @@ -3410,7 +3410,26 @@ class ContentRouter(Transform): compressor_timing.get(strategy_key, 0.0) + compress_ms ) - if result.compression_ratio < min_ratio: + # Lossless folds (search/log/diff via compact_lossless) shrink by + # collapsing repeated path prefixes, but the gate's default ratio + # is word count — which barely moves (a heading line can push it + # >1.0), discarding a free, recoverable win. Measure lossless + # results by REAL TOKEN count (what actually costs money/context), + # not words and not bytes: accept iff tokens genuinely drop. The + # excluded/bash paths already bypass this gate; this fixes the + # main strategy dispatch. + is_lossless = any( + s.startswith("lossless_") + for s in (getattr(result, "strategy_chain", None) or []) + ) + if is_lossless and getattr(result, "original", None): + orig_tok = tokenizer.count_text(result.original) + accept_ratio = ( + tokenizer.count_text(result.compressed) / orig_tok if orig_tok else 1.0 + ) + else: + accept_ratio = result.compression_ratio + if accept_ratio < min_ratio: # tool ground truth must stay reversible — a lossy summarizer # (kompress/text/code) that emitted no CCR retrieve marker is # unrecoverable, so the agent would act on a fabricated summary @@ -3433,7 +3452,7 @@ class ContentRouter(Transform): self._cache.put( content_key, result.compressed, - result.compression_ratio, + accept_ratio, result.strategy_used.value, ) if netcost_enabled and not self._net_cost_allows( @@ -3450,11 +3469,9 @@ class ContentRouter(Transform): continue result_slots[slot_idx] = {**message, "content": result.compressed} transforms_applied.append( - f"router:{result.strategy_used.value}:{result.compression_ratio:.2f}" - ) - compressed_details.append( - f"{result.strategy_used.value}:{result.compression_ratio:.2f}" + f"router:{result.strategy_used.value}:{accept_ratio:.2f}" ) + compressed_details.append(f"{result.strategy_used.value}:{accept_ratio:.2f}") if slot_idx in frozen_unlock_slots: transforms_applied.append("router:netcost_frozen_unlock") route_counts.setdefault("netcost_frozen_unlocked", 0) diff --git a/tests/test_lossless_mode.py b/tests/test_lossless_mode.py index 65bddd325..9e7a71294 100644 --- a/tests/test_lossless_mode.py +++ b/tests/test_lossless_mode.py @@ -279,6 +279,42 @@ def test_router_lossless_never_emits_marker_various_inputs() -> None: _assert_no_marker(router.compress(s, context="").compressed) +def test_router_apply_accepts_lossless_search_token_measured() -> None: + """Regression: the acceptance gate in router.apply() measured WORD count, so + a lossless search fold — which cuts TOKENS by collapsing a repeated path + prefix while word count stays flat or *rises* (the heading adds a word) — was + wrongly discarded as ratio_too_high. The gate now measures lossless results + by real token count, so the free, recoverable win is applied. (compress()/ + _apply_strategy_to_content bypass this gate, which is why unit tests above + never caught it — the bug only appears through the full apply() path.) + """ + from headroom.providers import OpenAIProvider + from headroom.tokenizer import Tokenizer + from headroom.transforms.lossless_compaction import search_heading + + tok = Tokenizer(OpenAIProvider().get_token_counter("gpt-4o"), "gpt-4o") + router = ContentRouter(ContentRouterConfig(lossless=True)) + grep = "".join( + f"headroom/transforms/content_router.py:{i}: identifier_{i} = compute(value)\n" + for i in range(1, 60) + ) + # The fold does NOT reduce word count (the heading even adds one) — this is + # exactly what made the old word-count gate reject it. + assert len(search_heading(grep).split()) >= len(grep.split()) + + messages = [ + { + "role": "assistant", + "tool_calls": [{"id": "c1", "function": {"name": "find_refs", "arguments": "{}"}}], + }, + {"role": "tool", "tool_call_id": "c1", "content": grep}, + ] + out = router.apply(messages, tok).messages[1]["content"] + assert tok.count_text(out) < tok.count_text(grep) # accepted: fewer TOKENS + assert search_unheading(out) == grep # byte-exact recovery + _assert_no_marker(out) + + # -------------------------------------------------------------------------- # Token-delta measurement (informational) # --------------------------------------------------------------------------