headroom/tests/test_issue_2671_block_growth_cache.py
Tejas Chopra 1a04c957f5
fix(cache): stabilize Anthropic block-growing lineages (#2917)
## Description

Fixes the remaining Anthropic prompt-cache failure in #2671 and the
newly reported parallel-tool-profile variant.

The production failure has three connected parts:

1. `SessionTrackerStore.resolve_tracker` only recognized whole-message
prefixes. A caller that grows or regenerates blocks inside one message
therefore received a fresh tracker every turn, so previous forwarded
state was always empty and breakpoint relocation could never run.
2. `normalize_message_cache_control` always moved the message breakpoint
to the newest block. That is correct for a pure block append, but a
message that rewrites its tail can never match the prior newest-block
write and repeatedly rewrites the full message prefix.
3. Parallel Anthropic sub-calls can carry identical messages but
different tools. Because tools precede messages in the provider cache
key, sharing one frozen-prefix tracker across those calls
cross-contaminates cache state even when message lineage is identical.

This PR deliberately combines the valid parts of #2699 and #2702, fixes
the discriminator between their two shapes, and adds cache-key affinity
for the second pattern reported on #2671. In particular, a pure append
is identified by `stable_prefix_blocks == previous_block_count`;
rewritten-tail relocation is only possible when `stable_prefix_blocks <
previous_block_count`. This prevents a pure append from being pinned to
an old boundary.

Closes #2671.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [x] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- Added one canonical history classifier with distinct exact,
whole-message append, pure block append, rewritten-tail, and diverged
outcomes.
- Kept pure block appends on newest-block breakpoint placement so each
request reads the old prefix and writes only its appended blocks.
- Added block-level replay of the prior forwarded bytes for pure
appends; the whole-message delta path explicitly refuses this shape so
it cannot silently discard appended blocks.
- Kept a rewritten-tail request on its existing tracker and anchored its
breakpoint to the end of the byte-stable leading run.
- Made rewritten-tail matching conservative: one changed message,
unchanged message count, no shrink, at least 8 stable leading blocks
covering at least half of old and new content, and a fixed suffix of at
least 2 blocks.
- Required a unique best rewritten-tail lineage match. Ambiguity creates
a fresh lineage instead of making sibling sub-calls ping-pong one
tracker.
- Added a stable affinity fingerprint over model, deterministically
forwarded tools, tool choice, thinking, and output configuration.
Different provider cache-key profiles cannot share frozen-prefix state.
- Snapshotted previous original/forwarded messages once in the Anthropic
handler and reused that exact state for delta extraction, replay, and
breakpoint placement.
- Added `HEADROOM_STABLE_BOUNDARY_BREAKPOINT=0` as a rollback switch for
rewritten-tail relocation.

The canonical projection is used only for comparison. Replayed content
always comes from the exact previously forwarded bytes or the current
raw/optimized tail; canonicalized data is never reconstructed into an
upstream request.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check` on changed files)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ python -m pytest tests/test_issue_2671_block_growth_cache.py -q
12 passed in 0.10s

$ python -m pytest tests/test_cache -q
253 passed, 3 skipped in 1.94s

$ python -m pytest <Anthropic handler/proxy regression set> -q
134 passed, 1 warning in 9.42s

$ ruff format --check <changed files>
4 files already formatted

$ ruff check <changed files>
All checks passed!

$ git diff --check
# clean
```

The Anthropic regression set covers beta stickiness, CCR injection,
compaction transforms, pre-upstream backpressure, streaming
reconstruction, upstream headers, model sanitization, diagnostics, and
cache stability.

## Real Behavior Proof

- Environment: macOS, Python 3.12.13, real FastAPI Anthropic handler
with a local upstream stub plus a deterministic provider-cache oracle.
- Exact steps: send a cold 35-block aggregate message, then three
requests that preserve a 30-block prefix and fixed two-block suffix
while regenerating a growing middle tail. Resolve the real session
tracker, normalize the real handler body, record the response, and
repeat.
- Observed result: handler breakpoint indices are `34 -> 29 -> 29`; the
cache oracle transitions from a cold 35-block write to establishing the
30-block stable boundary, then produces `(read=30, write=0)` on
subsequent rewritten-tail turns. A separate pure-append sequence
produces `(0,30) -> (30,4) -> (34,4) -> (38,5)`, proving its breakpoint
continues to advance.
- Also observed: identical message histories with different tool schemas
resolve to distinct trackers in the real handler path.
- Not tested: a live Anthropic billing soak, the complete repository
test suite, or mypy. #2702 contains earlier live production measurements
for the rewritten-tail mechanism; this PR adds the pure-append
correction, affinity isolation, and broader regression model.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [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 documentation updates where applicable
(internal behavior is documented in code; no user-facing surface
changed)
- [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 relevant unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md` — it is generated by
release-please from the Conventional Commit PR title

## Additional Notes

- Consolidates the complementary approaches in #2699 and #2702. Credit
to @axisrow and @nangsontay for the traces, root-cause work, and live
validation that made the two production shapes distinguishable.
- The 20-block minimum for relocation mirrors the provider lookup-window
risk boundary and keeps short ordinary messages on the established
newest-block behavior.
- Disabling stable-boundary relocation does not disable improved lineage
resolution or tool-profile isolation; it restores only the previous
breakpoint placement.

Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local>
2026-08-10 22:38:51 -07:00

288 lines
11 KiB
Python

"""Comprehensive regression for #2671's block-growing Anthropic histories.
The provider writes cache entries only at explicit breakpoints and searches at
most 20 block boundaries backwards on the next request. Consequently:
* a pure append must advance the breakpoint to the newest block;
* a rewritten tail must anchor at the last byte-stable leading block;
* both shapes must retain one conversation lineage across turns;
* different tools/thinking profiles must never share that lineage, because
Anthropic renders those segments before messages in its cache key.
The small cache oracle below models those write/lookback rules. It catches a
green-but-inert implementation: merely moving a marker in a unit-built message
is insufficient unless the real resolve -> normalize -> record sequence carries
the previous turn's state forward.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Any
from headroom.cache.prefix_tracker import (
RELATION_BLOCK_APPEND,
RELATION_BLOCK_REWRITE_TAIL,
RELATION_DIVERGED,
PrefixFreezeConfig,
SessionTrackerStore,
_strip_cache_control,
classify_history_relation,
extract_cache_stable_delta,
normalize_message_cache_control,
overlay_cached_prefix,
segment_fingerprint,
)
def _text(text: str, *, cache: bool = False) -> dict[str, Any]:
block: dict[str, Any] = {"type": "text", "text": text}
if cache:
block["cache_control"] = {"type": "ephemeral"}
return block
def _message(blocks: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [{"role": "user", "content": blocks}]
def _pure_append(total: int) -> list[dict[str, Any]]:
return _message([_text(f"block-{index}") for index in range(total)])
def _rewritten_tail(
turn: int,
churn_blocks: int,
*,
stable_blocks: int = 30,
instruction: str = "instruction: summarize",
) -> list[dict[str, Any]]:
blocks = [_text(f"stable-{index}") for index in range(stable_blocks)]
blocks += [_text(f"turn-{turn}-changing-{index}") for index in range(churn_blocks)]
# The captured production shape keeps a two-block identity suffix pinned at
# the end while the blocks immediately before it are rewritten.
blocks += [_text(instruction), _text("fixed end-of-transcript reminder")]
return _message(blocks)
def _breakpoint(messages: list[dict[str, Any]]) -> tuple[int, int]:
found = [
(message_index, block_index)
for message_index, message in enumerate(messages)
if isinstance(message.get("content"), list)
for block_index, block in enumerate(message["content"])
if isinstance(block, dict) and "cache_control" in block
]
assert len(found) == 1
return found[0]
@dataclass
class _AnthropicBreakpointCache:
"""Deterministic model of Anthropic's explicit-breakpoint cache lookup."""
entries: dict[str, int] = field(default_factory=dict)
lookback_blocks: int = 20
@staticmethod
def _blocks(messages: list[dict[str, Any]]) -> list[Any]:
blocks: list[Any] = []
for message in messages:
content = message.get("content")
if isinstance(content, list):
blocks.extend(_strip_cache_control(content))
return blocks
@staticmethod
def _key(blocks: list[Any], end: int) -> str:
return json.dumps(blocks[: end + 1], sort_keys=True, separators=(",", ":"))
def request(self, messages: list[dict[str, Any]]) -> tuple[int, int]:
"""Return simulated ``(cache_read_blocks, cache_write_blocks)``."""
_, breakpoint = _breakpoint(messages)
blocks = self._blocks(messages)
read = 0
first = max(0, breakpoint - self.lookback_blocks + 1)
for candidate in range(breakpoint, first - 1, -1):
key = self._key(blocks, candidate)
if key in self.entries:
read = self.entries[key]
break
written_prefix = breakpoint + 1
write = max(0, written_prefix - read)
self.entries[self._key(blocks, breakpoint)] = written_prefix
return read, write
def _record(tracker, original, forwarded, *, read=0, write=10_000): # noqa: ANN001
tracker.update_from_response(
cache_read_tokens=read,
cache_write_tokens=write,
messages=forwarded,
original_messages=original,
)
def test_classifier_separates_pure_append_from_rewritten_tail() -> None:
append = classify_history_relation(_pure_append(35), _pure_append(30))
rewrite = classify_history_relation(_rewritten_tail(2, 5), _rewritten_tail(1, 3))
assert append.kind == RELATION_BLOCK_APPEND
assert append.stable_prefix_blocks == 30
assert rewrite.kind == RELATION_BLOCK_REWRITE_TAIL
assert rewrite.stable_prefix_blocks == 30
assert rewrite.stable_suffix_blocks == 2
def test_rewritten_tail_requires_a_real_previous_divergence() -> None:
"""The #2702 bug classified a pure append as a rewritten tail."""
previous = _pure_append(30)
current = _pure_append(31)
relation = classify_history_relation(current, previous)
assert relation.kind == RELATION_BLOCK_APPEND
assert relation.stable_prefix_blocks == relation.previous_block_count
def test_rewritten_tail_requires_a_two_block_identity_suffix() -> None:
"""Sibling sub-calls sharing a transcript and generic reminder must split."""
previous = _rewritten_tail(1, 3, instruction="instruction: summarize")
sibling = _rewritten_tail(2, 5, instruction="instruction: title")
assert classify_history_relation(sibling, previous).kind == RELATION_DIVERGED
def test_lineage_survives_rewritten_tail_growth_and_delivers_previous_state() -> None:
store = SessionTrackerStore(PrefixFreezeConfig(min_cached_tokens=0))
first_tracker = None
for turn, churn in enumerate((3, 5, 8, 11), start=1):
original = _rewritten_tail(turn, churn)
tracker = store.resolve_tracker("shared", "anthropic", messages=original)
first_tracker = first_tracker or tracker
assert tracker is first_tracker
previous = tracker.get_last_forwarded_messages()
if turn > 1:
assert previous, "lineage match must deliver the previous forwarded request"
forwarded = normalize_message_cache_control(original, previous)
_record(tracker, original, forwarded)
assert store.active_sessions == 1
assert first_tracker._turn_number == 4
def test_sibling_rewritten_tail_streams_do_not_ping_pong() -> None:
store = SessionTrackerStore()
seen = {}
for turn, churn in enumerate((3, 5, 8), start=1):
for instruction in ("instruction: summarize", "instruction: title"):
original = _rewritten_tail(turn, churn, instruction=instruction)
tracker = store.resolve_tracker("shared", "anthropic", messages=original)
seen.setdefault(instruction, tracker)
assert tracker is seen[instruction]
forwarded = normalize_message_cache_control(
original, tracker.get_last_forwarded_messages()
)
_record(tracker, original, forwarded)
assert seen["instruction: summarize"] is not seen["instruction: title"]
def test_cache_affinity_splits_identical_histories_with_different_tools() -> None:
store = SessionTrackerStore()
history = _pure_append(30)
shell = segment_fingerprint({"model": "claude", "tools": [{"name": "shell"}]})
search = segment_fingerprint({"model": "claude", "tools": [{"name": "search"}]})
shell_tracker = store.resolve_tracker(
"shared", "anthropic", messages=history, cache_affinity=shell
)
search_tracker = store.resolve_tracker(
"shared", "anthropic", messages=history, cache_affinity=search
)
assert search_tracker is not shell_tracker
assert (
store.resolve_tracker("shared", "anthropic", messages=history, cache_affinity=shell)
is shell_tracker
)
def test_cache_affinity_ignores_only_cache_directive_movement() -> None:
base = {
"model": "claude",
"tools": [{"name": "shell", "cache_control": {"type": "ephemeral"}}],
}
moved = {"model": "claude", "tools": [{"name": "shell"}]}
changed = {"model": "claude", "tools": [{"name": "search"}]}
assert segment_fingerprint(base) == segment_fingerprint(moved)
assert segment_fingerprint(base) != segment_fingerprint(changed)
def test_pure_append_replays_forwarded_blocks_and_advances_breakpoint() -> None:
previous_original = _pure_append(30)
previous_forwarded = _message([_text(f"COMPRESSED-{index}") for index in range(30)])
current = _pure_append(34)
overlaid = overlay_cached_prefix(current, current, previous_original, previous_forwarded)
normalized = normalize_message_cache_control(overlaid, previous_forwarded)
assert [block["text"] for block in normalized[0]["content"][:30]] == [
f"COMPRESSED-{index}" for index in range(30)
]
assert [block["text"] for block in normalized[0]["content"][30:]] == [
f"block-{index}" for index in range(30, 34)
]
assert _breakpoint(normalized) == (0, 33)
def test_whole_message_delta_path_cannot_discard_appended_blocks() -> None:
"""Block appends require a splice, never an empty whole-message delta."""
previous = _pure_append(30)
assert extract_cache_stable_delta(_pure_append(34), previous, previous) is None
def test_cache_oracle_proves_pure_append_chains_without_rewrites() -> None:
oracle = _AnthropicBreakpointCache()
previous = None
outcomes = []
for total in (30, 34, 38, 43):
current = _pure_append(total)
forwarded = normalize_message_cache_control(current, previous)
outcomes.append(oracle.request(forwarded))
previous = forwarded
assert outcomes == [(0, 30), (30, 4), (34, 4), (38, 5)]
def test_cache_oracle_proves_rewritten_tail_stops_perpetual_full_writes() -> None:
oracle = _AnthropicBreakpointCache()
previous = None
outcomes = []
breakpoints = []
for turn, churn in enumerate((3, 5, 8, 11), start=1):
current = _rewritten_tail(turn, churn)
forwarded = normalize_message_cache_control(current, previous)
breakpoints.append(_breakpoint(forwarded)[1])
outcomes.append(oracle.request(forwarded))
previous = forwarded
# Cold turn writes its varying tail. Turn two establishes the new stable
# boundary; subsequent turns read it and perform no repeated full write.
assert breakpoints == [34, 29, 29, 29]
assert outcomes[0] == (0, 35)
assert outcomes[1] == (0, 30)
assert outcomes[2:] == [(30, 0), (30, 0)]
def test_relocation_kill_switch_restores_newest_block(monkeypatch) -> None: # noqa: ANN001
previous = normalize_message_cache_control(_rewritten_tail(1, 3))
monkeypatch.setenv("HEADROOM_STABLE_BOUNDARY_BREAKPOINT", "0")
current = _rewritten_tail(2, 5)
forwarded = normalize_message_cache_control(current, previous)
assert _breakpoint(forwarded) == (0, len(current[0]["content"]) - 1)