diff --git a/headroom/transforms/content_router.py b/headroom/transforms/content_router.py index d90dc9cfe..c25cb7b3a 100644 --- a/headroom/transforms/content_router.py +++ b/headroom/transforms/content_router.py @@ -2226,13 +2226,34 @@ class ContentRouter(Transform): _PendingTask = tuple[int, str, str, float, int] pending_tasks: list[_PendingTask] = [] + # #856 P2b (flag-gated, default off): net-cost frozen-floor unlock. + # Without the flag, every message in the provider's prefix cache + # (index < frozen_message_count) is unconditionally skipped — mutating + # one trades a 90% read discount for a 25% write penalty (Anthropic). + # That binary floor leaves money on the table: a 50K-token stale tool + # dump with only a 10K cached suffix after it pays for itself many + # times over. With HEADROOM_NET_COST_POLICY=1 a *string-content* + # frozen message instead falls through to the normal candidate + # pipeline, where the P2 break-even gate (_net_cost_allows) decides + # per candidate: its S is the full invalidated suffix after the slot, + # so the deep edit proceeds only when ΔT·(w+r(R-1)) still beats the + # cache-bust penalty. Block-list and non-string frozen content stay + # frozen — the gate is wired into the string and parallel-merge paths + # only, and the per-block cache_control contract in + # _process_content_blocks is not net-cost aware, so opening them here + # would mutate cached blocks ungated. + frozen_unlock_slots: set[int] = set() for i, message in enumerate(messages): - # Skip frozen messages (in provider's prefix cache). - # Modifying these would invalidate the cache, replacing a 90% - # read discount with a 25% write penalty (Anthropic). if i < frozen_message_count: - result_slots[i] = message - continue + if netcost_enabled and isinstance(message.get("content", ""), str): + # Defer to the break-even gate below instead of skipping. + frozen_unlock_slots.add(i) + route_counts.setdefault("netcost_frozen_considered", 0) + route_counts["netcost_frozen_considered"] += 1 + else: + # Frozen — byte-identical to preserve the prefix cache. + result_slots[i] = message + continue role = message.get("role", "") content = message.get("content", "") @@ -2398,6 +2419,10 @@ class ContentRouter(Transform): result_slots[i] = {**message, "content": cached_compressed} transforms_applied.append(f"router:{cached_strategy}:{cached_ratio:.2f}") compressed_details.append(f"{cached_strategy}:{cached_ratio:.2f}") + if i in frozen_unlock_slots: + transforms_applied.append("router:netcost_frozen_unlock") + route_counts.setdefault("netcost_frozen_unlocked", 0) + route_counts["netcost_frozen_unlocked"] += 1 else: # Threshold tightened — no longer qualifies. Move to skip. self._cache.move_to_skip(content_key) @@ -2477,6 +2502,10 @@ class ContentRouter(Transform): compressed_details.append( f"{result.strategy_used.value}:{result.compression_ratio:.2f}" ) + if slot_idx in frozen_unlock_slots: + transforms_applied.append("router:netcost_frozen_unlock") + route_counts.setdefault("netcost_frozen_unlocked", 0) + route_counts["netcost_frozen_unlocked"] += 1 else: # Didn't compress — add to skip set self._cache.mark_skip(content_key) diff --git a/tests/test_netcost_gate.py b/tests/test_netcost_gate.py index f5bc681d9..70564bf6a 100644 --- a/tests/test_netcost_gate.py +++ b/tests/test_netcost_gate.py @@ -190,3 +190,75 @@ class TestNetCostHelpers: assert _netcost_message_tokens({"role": "user", "content": s}, tokenizer) == ( tokenizer.count_text(s) ) + + +def _frozen_messages(tool_content: str, suffix_filler_words: int) -> list[dict]: + """A short conversation whose compressible tool dump sits *inside* the + frozen prefix (index 1, with frozen_message_count=2).""" + suffix = "analysis context word " * suffix_filler_words + return [ + {"role": "user", "content": "fetch the records"}, + {"role": "tool", "content": tool_content}, + {"role": "user", "content": suffix}, + {"role": "user", "content": "summarize"}, + ] + + +class TestNetCostFrozenUnlock: + """#856 P2b: let formula-positive deep edits through the frozen floor.""" + + def test_flag_off_frozen_stays_frozen(self, router, tokenizer, monkeypatch): + # Default (flag off): a message in the prefix cache is never mutated, + # however compressible it is — the binary floor wins. + monkeypatch.delenv("HEADROOM_NET_COST_POLICY", raising=False) + messages = _frozen_messages(_tool_json(2000), suffix_filler_words=5) + result = router.apply([dict(m) for m in messages], tokenizer, frozen_message_count=2) + assert not _tool_slot_compressed(result, messages) + assert "router:netcost_frozen_unlock" not in result.transforms_applied + + def test_flag_on_unlocks_when_shave_dominates(self, router, tokenizer, monkeypatch): + # Huge shave deep in the frozen zone, tiny suffix after -> the + # break-even gate clears the deep edit and it proceeds (the "50K + # stale dump, 10K suffix" user story). + monkeypatch.setenv("HEADROOM_NET_COST_POLICY", "1") + messages = _frozen_messages(_tool_json(2000), suffix_filler_words=5) + result = router.apply([dict(m) for m in messages], tokenizer, frozen_message_count=2) + assert _tool_slot_compressed(result, messages) + assert "router:netcost_frozen_unlock" in result.transforms_applied + + def test_flag_on_keeps_frozen_when_suffix_dominates(self, router, tokenizer, monkeypatch): + # Modest shave, big cached suffix -> gate runs on the unlocked slot + # but rejects it. The frozen message is left byte-identical and no + # unlock marker is emitted, proving the floor opened yet the formula + # still protected the cache. + monkeypatch.setenv("HEADROOM_NET_COST_POLICY", "1") + messages = _frozen_messages(_tool_json(300), suffix_filler_words=40000) + result = router.apply([dict(m) for m in messages], tokenizer, frozen_message_count=2) + assert not _tool_slot_compressed(result, messages) + assert "router:netcost_frozen_unlock" not in result.transforms_applied + assert any(t.startswith("netcost:skip:") for t in result.transforms_applied) + + def test_flag_on_block_content_frozen_stays_frozen(self, router, tokenizer, monkeypatch): + # The gate is wired into the string and parallel-merge paths only; + # block-list frozen content (whose per-block cache_control contract + # is not net-cost aware) stays frozen even with a tiny suffix. + monkeypatch.setenv("HEADROOM_NET_COST_POLICY", "1") + big = "log line of output " * 400 + messages = [ + {"role": "user", "content": "fetch"}, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "t1", + "content": [{"type": "text", "text": big}], + } + ], + }, + {"role": "user", "content": "summarize"}, + ] + original = [dict(m) for m in messages] + result = router.apply([dict(m) for m in messages], tokenizer, frozen_message_count=2) + assert result.messages[1]["content"] == original[1]["content"] + assert "router:netcost_frozen_unlock" not in result.transforms_applied