Commit graph

19 commits

Author SHA1 Message Date
Abhay Singh
455f4f263c
fix(cache/semantic): don't semantic-match an empty query across contexts (#3226)
## Description

`SemanticCache.get()` matches on the **embedding of the last user
message** whenever an `embedding_fn` is wired. That query is empty
(`""`) for the overwhelming majority of agent/tool turns — a
`tool_result` continuation carries no text block, so
`SemanticCacheLayer._extract_query` returns `""`. A real sentence
embedder maps `""` to a fixed **non-zero** vector, so every empty-query
turn is ~identical to every other in embedding space. The exact
`messages_hash` guard (correctly chosen so `"continue"`/`"yes"` turns in
different contexts don't collide) is then bypassed by the semantic path:
an empty-query request misses on its unique hash, falls through to
embedding matching, and hits a **different conversation's** stored
response.

Reproduction (realistic embedder, non-zero for `""`):

```python
c = SemanticCache(embedding_fn=embed)
c.put(query="", response={"answer": "A"}, messages_hash="ctxA")   # conversation A
c.get(query="", messages_hash="ctxB")   # conversation B, different context
# -> returned A's response (cross-context false hit)
```

Measured on 330 real Claude Code transcripts (28,441 requests): **95.7%
have an empty extracted query**, so this is the dominant case, not a
corner case. The exact-hash path is unaffected; only the
embedding-similarity path is.

## 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
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- `headroom/cache/semantic.py`:
- `get()`: gate the semantic-similarity branch on `query.strip()` — an
empty/blank query can only ever hit via its exact `messages_hash`
(context-complete), never via embedding similarity.
- `put()`: store no embedding for an empty/blank query, so such an entry
is skipped by `_find_similar` (which ignores entries with no embedding)
and can never be a match target.
- `tests/test_cache/test_semantic.py`: added
`test_empty_query_never_semantic_matches` (cross-context empty-query
miss, exact-hash still hits, whitespace treated as empty).

## Testing

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

### Test Output

```text
tests/test_cache/test_semantic.py  ->  22 passed in 2.39s
uvx ruff@0.16.2 check headroom/cache/semantic.py tests/test_cache/test_semantic.py  ->  All checks passed!
uvx mypy@1.20.2 headroom/cache/semantic.py  ->  Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12.11, project venv, pytest 9.1.1,
ruff 0.16.2 and mypy 1.20.2 via uvx.
- Exact command / steps: before the fix, two different-context
empty-query requests (`ctxA` then `ctxB`) returned `ctxA`'s response via
the embedding path. After the fix, the second returns `None`, while
`ctxA`'s own exact-hash lookup still returns its response, and a
legitimate non-empty semantic hit (`"What is the weather today?"` ->
`"How is the weather?"`) still works.
- Observed result: empty/blank queries no longer semantic-match across
contexts; exact-hash and non-empty semantic matching are unchanged.
- Not tested: no live embedder model wired (the current client wires
none — the embedding path is exercised with an injected `embedding_fn`,
which is the documented usage).

## Runtime Rollout Safety

- Rollout-managed feature(s): none. `SemanticCache` is an SDK-side cache
(`headroom.cache`), not a rollout-channel-gated runtime feature;
semantic matching only runs when a caller injects an `embedding_fn`.
- Minimum rollout channel: N/A.
- Stable/default behavior changed: no. Exact-hash matching and non-empty
semantic matching are unchanged; only empty/blank-query semantic
matching (a false-hit source) is removed.
- Kill switch / disable path: N/A.
- Unsafe override required: no.
- Qualification impact: none; correctness-only.
- Rollback path: revert this PR.

## 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
- [ ] I have made corresponding changes to the documentation (N/A:
internal behavior)
- [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 unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md`
2026-08-23 11:51:33 -07:00
Andrei Boldyrev
7bfb1d7f38
fix(cache): stable session identity and per-conversation prefix trackers under agentic clients (#2193)
## Description

Running headroom as the proxy for Claude Code destroys Anthropic
prompt-cache
reuse (#2085: ~4.4x cache-creation inflation, 2.5–3x net cost). Tracing
live
Claude Code traffic through the proxy shows **two independent
session-identity
defects**, both of which orphan or thrash the frozen-prefix state; this
PR
fixes both.

### Defect 1: `<system-reminder>` turns rotate the fallback session id
mid-conversation

Claude Code interleaves reminder turns into the history as actual
`role:"system"` messages (hook output, skills lists, file-truncation
notices).
`compute_session_id` hashed **every** system message, so the id rotated
each
time a reminder landed. Live trace (subagent reading two 80KB files; sid
changes exactly when the truncation reminder appears, and the tracker
restarts
at turn 0):

```
REQ#2 sid=68d4ee666990 nmsg=3   [0]SYSTEM<<top-level system>> [1]user [2]SYSTEM<<skills reminder>>
REQ#3 sid=6944948c9fb2 nmsg=6   ... [5]SYSTEM<<Truncated: PARTIAL view ...>>   <- id rotated
```

Everything keyed on the session id is orphaned at that moment: the
prefix
tracker (freeze never survives past a reminder-bearing turn),
beta-header
stickiness, the CCR and memory-tool registries, and the compression
cache.

**Fix:** hash only the **leading run** of system messages (everything
before
the first non-system turn) — the top-level system prompt on the
Anthropic path
(folded in as the synthetic first message), the conventional leading
system
message(s) on the OpenAI path. Stable for the life of a conversation;
mid-history system turns are content, not identity.

### Defect 2: conversations sharing a (now stable) id thrash one tracker

With ids stable, the fallback tuple `model + system prompt` is identical
across every same-type parallel subagent (and any sessions reusing one
system
prompt) — all of them collapse onto one `PrefixCacheTracker`, and their
interleaved histories cross-contaminate the freeze state: the forwarded
prefix
is byte-unstable on nearly every turn and the provider cache is
re-written
instead of read. Reproduced against the real code paths (script below):

```
1) fallback session ids: A=3dc639aaf4f48fa1 B=3dc639aaf4f48fa1 -> COLLIDE=True
2) single conversation, legacy       : stable prefix on 4/4 later turns, trackers=1
2) interleaved (subagents), legacy   : stable prefix on 0/9 later turns, trackers=1
2) interleaved, lineage resolution   : stable prefix on 8/8 later turns, trackers=2
```

**Fix:** `SessionTrackerStore.resolve_tracker` — within a session id,
reuse
the tracker whose previous request messages are a prefix of the incoming
history (client histories are append-only, so a conversation's next
request
always extends its previous one); a diverging or rewritten history
(client-side compaction) starts a fresh lineage. Matching uses the
repo's
existing canonical cross-turn equivalence
(`_canonicalize_for_prefix_compare`,
the same one the cache-stable delta path uses) on the **original client
bytes**, so moved cache breakpoints, string<->block sugar, transport
annotations, or a tail-mutating `pre_compress` hook never read as a
rewrite.
Byte-identical histories (templated fan-outs before they diverge)
intentionally share a tracker — their provider cache line is identical
too.

### Both fixes together, on live Claude Code traffic (sonnet, 2 parallel
Explore agents)

```
main conversation: sid=5b7e245a...  one tracker, turns 0->4, id stable across reminders
agents (collide):  sid=2bdffc9e...  -> lineage bare  (alpha) turns 0->1->2
                                    -> lineage "~1"  (beta)  turns 0->1->2
```

Before: the agents' ids rotated per reminder (every tracker stuck at
turn 0),
and whenever they did share an id they thrashed one tracker (`0/9`
stable
prefixes in the repro).

### Why not key the session id on conversation content?

Draft #1912 folds the first user turn into the fallback id; this change
composes with it, but identity-level keying alone can't close #2085:
identical
first turns (templated fan-outs) still collide, and everything keyed on
the
session id rotates with it when the client rewrites history. The
"session"
(client/workspace grouping) and the "conversation" (positional cache
lineage)
are different identities; only the tracker holds positional per-turn
state
that thrashes under collision — beta stickiness is a monotone union and
the
compression cache is content-addressed — so lineage resolution lives one
level below the session id and leaves the id semantics (and every other
consumer) untouched.

## Changes Made

- `headroom/cache/prefix_tracker.py`:
- `compute_session_id`: harvest only the leading system run (defect 1).
- `SessionTrackerStore.resolve_tracker`: conversation-lineage resolution
    (defect 2). First lineage lives under the bare session id —
single-conversation sessions behave byte-identically to before; degrades
to `get_or_create` when messages are absent or prefix freeze is
disabled.
- Lineages are capped per session id
(`PrefixFreezeConfig.max_lineages_per_session`, default 32). **Over-cap
  conversations share one overflow tracker instead of evicting an
established lineage** — any eviction policy degrades every conversation
  once the working set exceeds the cap (under round-robin the victim is
always the conversation about to arrive), while overflow sharing
degrades
only the over-cap tail, to exactly the pre-lineage shared behavior; `0`
  disables lineage splitting. Chains are stored as structural snapshots
  that normalize `NaN` (`json.loads` accepts bare NaN, and `NaN != NaN`
would read a byte-identical resend as a rewrite). Synthetic lineage keys
use a `\x00` separator, which cannot appear in an HTTP header value, so
  they can never collide with a client-supplied `x-headroom-session-id`.
- `headroom/proxy/handlers/anthropic.py`, `openai.py`: the session id
and
  the lineage both derive from the **same original client bytes** (a
turn-dependent hook rewrite can no longer rotate one without the other);
anthropic folds in its synthetic system message so explicit-header
clients
with different system prompts stay separate. Plus a docstring correction
in `streaming.py` that falsely claimed its coarse mid-turn key "mirrors"
  `compute_session_id`.
- `tests/test_cache/test_prefix_tracker.py`: 24 new test cases —
  reminder-rotation regression; interleaved isolation + per-conversation
turn state; identical-first-turn share-then-split; cache_control
movement
(3 cases); representation churn (string<->block sugar / streaming
`index`
  / Bedrock cachePoint); rewritten history → fresh lineage (compacted /
  middle-edited / truncated); legacy no-messages / freeze-disabled /
  empty-canonical fallbacks; NaN-in-tool-payload stability; overflow
  sharing, established-lineages-survive-cap, and a cap+1 round-robin
no-cliff guard; TTL cleanup; session-id-not-rotated-by-lineage guard.
One
  existing test renamed (`uses_all_system_messages` →
  `distinguishes_leading_system_run`) to match the new contract.
- Three SimpleNamespace stub stores in existing tests gained a
  `resolve_tracker` field (handlers call it unconditionally — a silent
`hasattr` fallback would degrade to the pre-fix behavior with no
signal).
One of them is the cold-start fast-pass suite (#2073), which landed
while
  this branch was in review.
- `CHANGELOG.md` entry.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)

## Testing

- [x] Unit tests pass (`pytest`) — 11 failed, 8652 passed, 528 skipped
in 4:37 (the 11 are pre-existing on unmodified `main` — verified by
rerunning the same node ids on a clean checkout:
gh-CLI/onnx/PID-reuse/deadline flakes and order-dependent cases, none
touching session/cache/proxy paths)
- [x] Linting passes (`ruff check .`) — All checks passed (ruff 0.15.17,
CI-pinned; `ruff format --check .` clean)
- [x] Type checking passes (`mypy headroom`) — Success: no issues found
in 471 source files
- [x] New tests added for new functionality — 24 test cases; the
rotation/isolation/no-cliff ones fail on `main`
- [x] Manual testing performed — live Claude Code end-to-end, below

### Test Output

```text
$ python -m pytest tests/ -q
11 failed, 8652 passed, 528 skipped, 5857 warnings in 276.68s (0:04:36)
# same 11 fail on unmodified main (env/order-dependent: test_wrap_claude_base_url pid-reuse,
# copilot_auth gh-cli fallback, image_compression onnx, content_router deadline, rtk/output-shaper/dedup order flakes)

$ python -m pytest tests/test_cache/test_prefix_tracker.py -q
63 passed

$ uvx ruff@0.15.17 check . && uvx ruff@0.15.17 format --check .
All checks passed! / 1208 files already formatted

$ mypy headroom
Success: no issues found in 471 source files

$ python repro_2085.py
1) fallback session ids: A=3dc639aaf4f48fa1 B=3dc639aaf4f48fa1 -> COLLIDE=True
2) single conversation, legacy       : stable prefix on 4/4 later turns, trackers=1
2) interleaved (subagents), legacy   : stable prefix on 0/9 later turns, trackers=1
2) interleaved, lineage resolution   : stable prefix on 8/8 later turns, trackers=2
```

## Real Behavior Proof

- Environment: macOS arm64, Python 3.13, `uv sync --extra dev --extra
proxy`;
  real Claude Code CLI pointed at the proxy via
  `ANTHROPIC_BASE_URL=http://127.0.0.1:8790`, real Anthropic backend.
- Exact command / steps: ran Claude Code sessions that launch 2–3
parallel Explore subagents
(each reading multi-KB JSON files, several tool-loop turns each), with
an
observability wrapper printing each request's resolved session id,
tracker
  identity, and turn counter inside the proxy.
- Observed result: on `main`, subagent session ids rotate on
reminder-bearing turns
(trackers permanently stuck at turn 0); when conversations do share an
id
  they share one tracker whose turn counter interleaves all of them.
  On this branch: ids stable for the life of each conversation;
colliding subagents resolve to separate lineages (`bare`, `~1`) with
clean
per-conversation turn progressions (trace above). Unit-level repro shows
  forwarded-prefix stability going 0/9 → 8/8 for the interleaved shape.
- Not tested: reporter-scale cache-economics (his 4.4x needs his
long-session
workload against a paid backend); happy to coordinate with
@RomanAlexanderW
on a before/after — the number to watch is the cache-read ratio in
Claude
  Code transcripts recovering toward ~96%.

<details>
<summary>repro_2085.py</summary>

```python
"""Repro for #2085: concurrent conversations sharing a fallback session id
(same model + system prompt — e.g. a Claude Code session and its parallel
subagents) collapse onto one PrefixCacheTracker and thrash its frozen-prefix
state -> byte-unstable forwarded prefixes -> the provider prompt cache is
re-written on nearly every call. Uses headroom's real code paths.

Run from the repo root: python ../repro_2085.py
"""

from headroom.cache.prefix_tracker import PrefixFreezeConfig, SessionTrackerStore

MODEL = "claude-sonnet-5"
# Claude Code system prompt: long, static, identical across the main session
# and every parallel subagent of the same type.
SYSTEM = ("You are Claude Code, Anthropic's official CLI for Claude. " * 40)[:2000]


def convo(name: str, turns: int) -> list[dict]:
    msgs = [{"role": "system", "content": SYSTEM}]
    for t in range(turns):
        msgs.append({"role": "user", "content": f"[{name}] user turn {t}: " + ("x" * 800)})
        msgs.append(
            {"role": "assistant", "content": f"[{name}] tool_result {t}: " + ('{"data": 1}' * 200)}
        )
    return msgs


class _Req:  # request stub: no x-headroom-session-id header
    headers: dict = {}


# --- Part 1: identity collision (real derivation) ----------------------------
store = SessionTrackerStore(PrefixFreezeConfig())
id_a = store.compute_session_id(_Req(), MODEL, convo("A", 3))
id_b = store.compute_session_id(_Req(), MODEL, convo("B", 5))
print(f"1) fallback session ids: A={id_a} B={id_b} -> COLLIDE={id_a == id_b}")

# --- Part 2: interleaved conversations thrash the freeze state ---------------


def run(interleave: bool, lineage_resolution: bool) -> tuple[int, int, int]:
    store = SessionTrackerStore(PrefixFreezeConfig())
    stable_turns = 0
    later_turns = 0
    seq = []
    for t in range(1, 6):
        seq.append(("A", convo("A", t)))
        if interleave:
            seq.append(("B", convo("B", t)))
    for _name, msgs in seq:
        sid = store.compute_session_id(_Req(), MODEL, msgs)
        if lineage_resolution:
            tracker = store.resolve_tracker(sid, "anthropic", messages=msgs)
        else:
            tracker = store.get_or_create(sid, "anthropic")
        if tracker._turn_number > 0:
            later_turns += 1
            if tracker._forwarded_prefix_stable(msgs):
                stable_turns += 1
        tracker.update_from_response(
            cache_read_tokens=5000 * len(msgs),
            cache_write_tokens=2000,
            messages=msgs,
        )
    return stable_turns, later_turns, store.active_sessions


for label, interleave, fixed in (
    ("single conversation, legacy       ", False, False),
    ("interleaved (subagents), legacy   ", True, False),
    ("interleaved, lineage resolution   ", True, True),
):
    stable, later, sessions = run(interleave, fixed)
    print(f"2) {label}: stable prefix on {stable}/{later} later turns, trackers={sessions}")
```
</details>

## 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
- [ ] I have made corresponding changes to the documentation (CHANGELOG
only — no docs describe the tracker store)
- [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 unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

- Addresses the session-identity mechanisms of #2085; intentionally does
not
`Closes` it — the reporter should confirm the cache-read ratio recovers
on
  live traffic first.
- Composes with draft #1912 (first-user-turn fallback id).
- Known bounded tradeoffs (all strictly milder than the per-turn thrash
this
fixes): a fork-style branch that resends a parent's full history adopts
the
parent's lineage, costing the parent one cold restart at its next turn;
a
request that aborts before the response and is retried with different
bytes
starts a fresh lineage; history truncation/tail-edit starts a fresh
lineage
  even though the shorter provider prefix may still be warm.
- Hot-path cost, measured on a 199-message/2.1MB agentic history:
canonical
projection 0.21ms + structural snapshot 0.92ms + match loop 0.06ms with
  32 candidate lineages (2.27ms absolute worst case) ≈ **1.3ms per
  request** — same order as the handler's existing request deepcopy
(0.80ms) and below one `json.dumps` of the body (2.9ms). Chain memory is
structure-only (~180-330KB per lineage; message strings are shared with
  state the tracker already retains).
- Known semantic shift to flag: hashing only the leading system run
means
  conversations distinguished ONLY by mid-list system messages (e.g.
clients injecting a per-conversation system context late in the list)
now
share a fallback id. The tracker is protected by lineage resolution; the
  residual sharing concentrates in the CCR sticky-tool registry and the
monotone beta union — the same pre-existing class as same-system-prompt
  conversations today. Happy to file the CCR-stickiness scoping as a
  follow-up.
- Out of scope, observed while tracing: `SessionCcrTracker.has_done_ccr`
  mildly cross-contaminates conversations sharing an id (monotone, no
  thrash) — can file separately if useful.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-15 18:42:20 +00:00
Abhay Singh
f8eaaeb26a
fix(cache): normalize embeddings before the semantic similarity check (#2122)
## Description

The semantic tier of the dynamic-content detector compares an
unnormalized dot product against a cosine threshold, so it flags almost
everything as dynamic and strips the static content it is supposed to
protect.

`SemanticDetector` pre-computes exemplar embeddings and, per sentence,
scores similarity with `np.dot` and compares to `semantic_threshold`:

```python
self._exemplar_embeddings = self._model.encode(self.DYNAMIC_EXEMPLARS, convert_to_numpy=True)
...
sentence_embeddings = self._model.encode(sentence_texts, convert_to_numpy=True)
similarities = np.dot(sentence_embeddings, self._exemplar_embeddings.T)
...
if max_sim < self.config.semantic_threshold:   # semantic_threshold defaults to 0.7
    continue
```

`sentence_transformers.encode(..., convert_to_numpy=True)` does **not**
normalize by default. So `np.dot` here is an inner product whose
magnitude scales with the embedding norms (typically ~5-15 for MiniLM),
not a cosine similarity in [0, 1]. Comparing that against
`semantic_threshold=0.7` (documented and configured as a 0-1 similarity)
is a scale mismatch: nearly every sentence clears the threshold, so the
semantic tier classifies almost all text as dynamic, moves it into
`dynamic_content`, and empties `static_content` — busting the very cache
the detector exists to protect.

A standalone repro: an unrelated sentence with a true cosine of ~0.1 to
an exemplar produces a raw dot of ~9.1 (well over 0.7); normalized, it
correctly scores ~0.09 and stays static.

The correct behavior is used by the in-repo siblings:
`prediction/feature_extractor.py` passes `normalize_embeddings=True`,
and `memory/adapters/embedders.py` L2-normalizes before dot-product
similarity. This detector did neither.

## Fix

Pass `normalize_embeddings=True` to both `encode` calls (exemplars in
`__init__` and sentences in `detect`). Both sides of the dot product are
then unit vectors, so `np.dot` is a true cosine similarity in [-1, 1],
comparable to `semantic_threshold`.

Closes #

## 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
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- `headroom/cache/dynamic_detector.py`: add `normalize_embeddings=True`
to the exemplar encode (`__init__`) and the sentence encode (`detect`),
with comments explaining the cosine requirement.
- `tests/test_cache/test_dynamic_detector.py`: add
`TestSemanticDetectorNormalization` — a recording fake model asserts
both encode calls pass `normalize_embeddings=True` (via `object.__new__`
for `detect`, and a monkeypatched registry for `__init__`). No model
download needed.
- `CHANGELOG.md`: Bug Fixes entry.

## Testing

- [x] Unit tests pass (`uv run --extra dev pytest
tests/test_cache/test_dynamic_detector.py::TestSemanticDetectorNormalization
-q`)
- [x] Linting passes (`uvx ruff@0.15.17 check
headroom/cache/dynamic_detector.py
tests/test_cache/test_dynamic_detector.py headroom/memory/factory.py`)
- [x] Type checking passes (`uvx mypy==1.20.2
headroom/memory/factory.py`)
- [x] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
$ uvx ruff@0.15.17 check headroom/cache/dynamic_detector.py tests/test_cache/test_dynamic_detector.py
All checks passed!
$ python -m py_compile headroom/cache/dynamic_detector.py tests/test_cache/test_dynamic_detector.py
OK
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, `uvx ruff@0.15.17`, numpy.
Importing `headroom` pulls in the torch/transformers stack and a full
`pytest` gets OOM-killed on this box, so I verified the scale mismatch
with a dependency-free numpy script (no sentence-transformers), and left
the full pytest to CI.
- Exact command / steps: built a MiniLM-dimension exemplar direction and
a sentence direction with a true cosine of ~0.1 (genuinely not dynamic),
gave them realistic un-normalized magnitudes (~9 and ~11), and computed
the old `np.dot` of the raw vectors versus the new `np.dot` of the
normalized vectors, against the 0.7 threshold.
- Observed result: old raw dot ~9.1 (far above 0.7 -> the unrelated
sentence is wrongly flagged dynamic); new cosine ~0.09 (below 0.7 ->
correctly kept static), and always within [-1, 1]. The new tests assert
both encode calls pass `normalize_embeddings=True`.
- Not tested: a real sentence-transformers model end to end; full local
`pytest` deferred to CI (OOM, per above).

## 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
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

Merged current `main` to pick up the repository-wide mypy cache-key
annotation fix, then verified the focused regression locally. the change
adds one keyword argument to two `encode` calls, verified by the numpy
proof and the new fake-model tests for CI. The fix brings this detector
in line with the two sibling call sites that already normalize.

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-14 12:01:15 -04:00
Tejas Chopra
908a9a1bb1
fix(cache): stop DynamicContentDetector false positives corrupting cached prompts (#2110) (#2119)
## Description
`DynamicContentDetector` / `RegexDetector` in
`headroom/cache/dynamic_detector.py` (used by the `cache_aligner`
transform) misclassified ordinary English words and code identifiers
(e.g. `in_pr`) as "dynamic content," extracting them from the system
prompt and re-appending a `[Dynamic Context]` tail that grows
unboundedly and corrupts the cached prompt over a session.

Fix tightens detection to require genuinely-dynamic shapes (timestamps,
UUIDs, hashes, numbers-with-units, ISO dates) rather than bare tokens —
no hardcoded wordlist — and bounds the tail. `cache_aligner` is off by
default, so blast radius is limited, but the detector logic is now
correct.

Closes #2110

## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)

## Changes Made
- `headroom/cache/dynamic_detector.py`: raise the evidence bar so
ordinary words/identifiers aren't extracted; bound the dynamic tail.
- `tests/test_cache/test_dynamic_detector.py`: assert false positives
(ordinary words/identifiers) are NOT extracted while real dynamic values
still are.

## Testing
- [x] Unit tests pass (`pytest
tests/test_cache/test_dynamic_detector.py`)
- [x] Linting passes (`ruff check`)
### Test Output
```text
55 passed, 2 skipped
ruff: All checks passed!
```

## Real Behavior Proof
- Before: identifiers like `in_pr` extracted into a growing `[Dynamic
Context]` tail, corrupting cached prompts.
- After: ordinary tokens stay in place; only genuinely-dynamic values
are detected.
2026-07-13 17:39:01 -04:00
Abhay Singh
cf6367add4
fix(cache/semantic): don't evict an unrelated entry on an update at capacity (#2094)
## Description

`SemanticCache.put` can evict a perfectly good, unrelated entry when it
merely updates a key that is already cached.

The method runs its at-capacity eviction loop *before* it computes the
entry's key:

```python
self._cleanup_expired()

# Evict if at capacity
while len(self._cache) >= self.config.max_entries:
    self._evict_oldest()
...
key = messages_hash or self._generate_key(query)
...
self._cache[key] = entry
```

So when the same key is stored again while the cache is full (a
duplicate store, or a retried request that produces the same
`messages_hash`), the loop fires because `len == max_entries`, evicts
the LRU-oldest *distinct* entry, and only then overwrites the existing
key in place. Writing to an already-present key does not grow the map,
so nothing needed to be evicted — but an unrelated live entry is now
gone, and the next `get` for it is a false miss.

Concretely, with `max_entries=2` and keys `[h1, h2]`, re-storing `h2`
evicts `h1`, leaving `[h2]` even though only two distinct keys were ever
stored.

The sibling `CompressionCache.store_compressed` gets this right: it
deletes the existing key first, inserts, and only then trims — so
re-storing a present key never drops an unrelated entry.

## Fix

Compute the key first, then run the eviction loop only while the key is
genuinely new:

```python
key = messages_hash or self._generate_key(query)

while key not in self._cache and len(self._cache) >= self.config.max_entries:
    self._evict_oldest()
```

An in-place update of an existing key no longer evicts anything; adding
a new key still trims to make room exactly as before.

Closes #

## 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
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- `headroom/cache/semantic.py`: move the cache-key computation above the
eviction loop and gate the loop on `key not in self._cache` so an
in-place update never evicts.
- `tests/test_cache/test_semantic.py`: add
`test_update_at_capacity_does_not_evict_unrelated_entry`.
- `CHANGELOG.md`: Bug Fixes entry.

## Testing

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

### Test Output

```text
$ uvx ruff@0.15.17 check headroom/cache/semantic.py tests/test_cache/test_semantic.py
All checks passed!
$ python -m py_compile headroom/cache/semantic.py tests/test_cache/test_semantic.py
OK
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, `uvx ruff@0.15.17`. Importing
`headroom` pulls in the torch/transformers stack and a full `pytest`
gets OOM-killed on this box, so I verified the eviction logic with a
dependency-free script that replicates the `OrderedDict` +
`_evict_oldest` (popitem last=False) behavior for the old vs new loop,
and left the full pytest to CI.
- Exact command / steps: with `max_entries=2`, store `h1` then `h2`,
then re-store the already-present `h2`, under both the old loop (evict
before key dedup) and the new loop (evict only when key is new).
- Observed result: old loop leaves `['h2']` and `get(h1)` returns `None`
(h1 wrongly evicted); new loop leaves `['h1', 'h2']` with `get(h1)`
intact and `h2` updated. The regression test asserts h1 survives and h2
reflects the update.
- Not tested: a live embedding-backed cache round-trip; full local
`pytest` deferred to CI (OOM, per above).

## 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
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

The "unit tests pass locally" and "type checking" boxes are unchecked
because the full suite imports the ML stack, which I can't run in this
environment; the change is a localized reordering of two existing
statements plus a loop guard, verified by the standalone proof and the
new regression test for CI. This is a different defect from the earlier
messages-hash keying fix — that one was about which slot a request maps
to; this one is about eviction dropping a live entry on an in-place
update.

Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-13 10:50:23 -04:00
Abhay Singh
ec6e60ea3e
fix(proxy/anthropic): scope session id by top-level system prompt (#2070)
## Description

`SessionTrackerStore.compute_session_id`
(`headroom/cache/prefix_tracker.py`) computes a fallback
session id (when no `x-headroom-session-id` header is present) from
`model` + system-prompt text.
But it harvests system text **only** from `messages` entries with `role
== "system"`:

```python
for msg in messages:
    if msg.get("role") == "system":
        ...  # collect system text
system_content = json.dumps(system_parts, ...)
key = f"{model}:{system_content}"
```

Anthropic's `/v1/messages` carries the system prompt as a **top-level**
`body["system"]` field —
it never sends `role:"system"` entries inside `messages`. And
`x-headroom-session-id` is a
Headroom-internal header no client sends. So for every genuine Anthropic
request `system_parts`
is empty and the id collapses to `md5(f"{model}:[]")` — **every
conversation on the same model
shares one session id**, and therefore one `PrefixCacheTracker` and all
session-sticky state.

The colliding state cross-contaminates across conversations
(`anthropic.py:1052`):
- sticky `headroom_retrieve` / memory tools keyed purely on `session_id`
(no content guard) get
injected into another conversation's tool list — busting its tools cache
and adding tools its
  client never requested;
- sticky `anthropic-beta` header tokens leak across conversations;
- `frozen_message_count` and the per-session compression cache
cross-contaminate.

(The sibling `StreamingMixin._get_session_key` already reads
`body.get("system")` and its docstring
claims to mirror `compute_session_id` — which it did not.)

Closes: no issue filed — found while auditing the session/prefix
tracker.

## Fix

Add an optional `system` parameter to `compute_session_id` and fold its
text (a plain string or a
list of `{"type":"text"}` blocks) into the hash. The Anthropic handler
passes `body.get("system")`.
OpenAI callers don't pass it (defaults to `None`), so their behavior is
unchanged.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)

## Changes Made

- `headroom/cache/prefix_tracker.py`: `compute_session_id` accepts an
optional `system` and folds it into the id.
- `headroom/proxy/handlers/anthropic.py`: pass
`system=body.get("system")` when computing the session id.
- `tests/test_cache/test_prefix_tracker.py`: add
`test_compute_session_id_distinguishes_top_level_system` (distinct
systems → distinct ids; list-form == string-form; `system=None`
unchanged).

## Testing

- [x] New regression test added
(`tests/test_cache/test_prefix_tracker.py`)
- [x] Linting/formatting clean — run with the CI-pinned `ruff==0.15.17`
- [ ] Full `pytest` deferred to CI (local-OOM reason below).

```text
$ uvx ruff@0.15.17 check headroom/cache/prefix_tracker.py headroom/proxy/handlers/anthropic.py tests/test_cache/test_prefix_tracker.py
All checks passed!
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.10, headroom from this branch.
Importing `headroom` pulls in the torch/transformers stack and a full
`pytest` gets OOM-killed on this box, so I verified the hash logic with
a dependency-free script and left the full pytest to CI.
- Exact command / steps: computed ids for two conversations with the
same model and messages but different top-level `system` prompts,
through the old (never-folds-system) and new logic.
- Observed result: the old logic collapses both to one id (the leak);
the new logic separates them, folds list-form system the same as
string-form, and leaves the `system=None` (OpenAI) path unchanged:

```text
OLD: A=97d8857ba27010bb  B=97d8857ba27010bb  same=True
NEW: A=1e838c0f6e3980a6  B=18ec49bfa8240852  same=False
SESSION-ID SYSTEM FIX VERIFIED (old collapses Anthropic convos; new separates them)
```

- Not tested: a full two-conversation proxy run asserting no sticky-tool
leakage (needs the heavy stack). The fix is confined to
`compute_session_id` + the one handler call site, and the new test
drives the method directly. Full local `pytest` deferred to CI (OOM, per
above).

## 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
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes — ran
lint + a standalone logic check; full pytest deferred to CI (local OOM,
disclosed above)
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

- Backward-compatible: the new `system` parameter defaults to `None`, so
the OpenAI call sites (`openai.py`) need no change and their session ids
are identical.
- @JerrettDavis tagging you — this one lets one Anthropic conversation's
sticky tools/headers leak into another on the same model, so it seemed
worth surfacing. Thanks!
2026-07-12 19:40:58 -04:00
Abhay Singh
d8783ab89b
fix(cache/semantic): key entries by context hash, not query text (#2022)
## Description

`SemanticCache` (`headroom/cache/semantic.py`) derives each entry's key
from the **query text
only** — where `query` is just the trailing user message — and its
exact-match lookup returns
the slot without checking the stored entry's `messages_hash`:

```python
# put()
key = self._generate_key(query)          # sha256(query)[:16]
self._cache[key] = entry
if messages_hash:
    self._hash_index[messages_hash] = key

# get() — exact-match branch
key = self._hash_index.get(messages_hash)
if key and key in self._cache:
    entry = self._cache[key]
    ...
    return entry                         # never checks entry.messages_hash
```

So two requests that share a trailing user message but differ in earlier
context map to the
**same** key. The second `put` overwrites the first, and the first
request's `messages_hash`
still points at that (now overwritten) slot — so it is served the
**other conversation's**
cached response.

Trailing messages like `"continue"`, `"yes"`, `"fix it"`, `"run the
tests"` are extremely
common in agentic/coding sessions, so this collides constantly. It's
independent of the
proxy-level `_compute_key` fix (that's about what goes *into*
`messages_hash`; here the entry
is stored under a query-only key regardless of how good the hash is).
This `SemanticCache` is
the one used by the SDK client's `enable_semantic_cache` path.

Concretely:
1. `put("run the tests", A, messages_hash=HA)` → key `K = sha256("run
the tests")`; `_cache[K]=A`.
2. `put("run the tests", B, messages_hash=HB)` → same `K`; `_cache[K]`
overwritten with `B`.
3. `get("run the tests", HA)` → `_hash_index[HA]=K`, `K in _cache` →
returns **B**.

Closes: no issue filed — found while auditing the cache key derivation.

## Fix

1. Key entries by the full-context `messages_hash` when present, falling
back to the query hash
   only when no hash is supplied:
   ```python
   key = messages_hash or self._generate_key(query)
   ```
2. Defensively verify `entry.messages_hash == messages_hash` in the
exact-match branch of `get`,
   so any residual stale mapping becomes a miss rather than wrong data.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)

## Changes Made

- `headroom/cache/semantic.py`: key `put` entries by `messages_hash`
when present; verify `entry.messages_hash` in the `get` exact-match
branch.
- `tests/test_cache/test_semantic.py`: add
`test_same_query_different_context_does_not_collide` and
`test_exact_match_verifies_messages_hash`.

## Testing

- [x] New regression tests added (`tests/test_cache/test_semantic.py`)
- [x] Linting/formatting clean — run with the CI-pinned `ruff==0.15.17`
- [ ] Full `pytest` deferred to CI (local-OOM reason below).

```text
$ uvx ruff@0.15.17 check headroom/cache/semantic.py tests/test_cache/test_semantic.py
All checks passed!
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.10, headroom from this branch.
Importing `headroom` pulls in the torch/transformers stack and a full
`pytest` gets OOM-killed on this box, so I verified the `put`/`get`
logic with a dependency-free script and left the full pytest to CI.
- Exact command / steps: stored responses A and B under the same query
`"run the tests"` with different `messages_hash`, then read each hash
back — through both the old (query-keyed) and new (hash-keyed) logic.
- Observed result: the old logic serves B's response to request A; the
new logic isolates them:

```text
OLD: A->RESPONSE_B  B->RESPONSE_B
NEW: A->RESPONSE_A  B->RESPONSE_B
SEMANTIC CACHE COLLISION FIX VERIFIED (OLD served B to A; NEW isolates)
```

- Not tested: the full SDK `HeadroomClient` round-trip with
`enable_semantic_cache=True` (needs the heavy stack). The fix is
confined to `SemanticCache.put`/`get` and the new tests drive them
directly. Full local `pytest` deferred to CI (OOM, per above).

## 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
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes — ran
lint + a standalone logic check; full pytest deferred to CI (local OOM,
disclosed above)
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

- Small, contained fix — the key derivation plus a verification guard,
no new dependencies.
- @JerrettDavis tagging you — this one can serve one conversation's
cached response to another when the last message matches, so it seemed
worth surfacing. Thanks!
2026-07-11 10:11:09 -05:00
Rod Boev
0f606b6281
fix(cache): avoid fallback session collisions (#1827)
## Description

Cache-mode session tracking currently collapses unrelated conversations
when they share a large static first system prompt. The fallback
session-id hash ignores later system messages entirely, so dynamic
per-conversation context can get cut out of the key and two different
sessions reuse the same `PrefixCacheTracker`. This hashes the full
ordered system-text payload instead, while leaving explicit
`x-headroom-session-id` overrides untouched. Refs #1808.

## 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
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- Collected all system-text content when building the fallback cache
session id.
- Stopped truncating fallback session-id input to the first 500
characters of the first system message.
- Added a regression that proves two conversations with different later
system context no longer collide.
- Added a preservation test that appending only non-system turns keeps
the same fallback session id.
- Applied the pinned Ruff formatter to three pre-existing files on the
current base so the repo-wide lint job passes unchanged semantics.

## Testing

- [x] Unit tests pass (`uv run pytest
tests/test_cache/test_prefix_tracker.py -q`)
- [x] Linting passes (`uv run ruff check
headroom/cache/prefix_tracker.py
tests/test_cache/test_prefix_tracker.py`)
- [ ] Type checking passes (`uv run mypy headroom`)
- [x] New tests added for new functionality when applicable
- [ ] Manual testing performed

### Test Output

```text
uv run pytest tests/test_cache/test_prefix_tracker.py -q
40 passed, 1 warning in 0.15s

uv run ruff check headroom/cache/prefix_tracker.py tests/test_cache/test_prefix_tracker.py
All checks passed!

uv run ruff check .
All checks passed!

uv run ruff format --check .
1046 files already formatted
```

## Real Behavior Proof

- Environment: Windows, project `uv` environment, focused cache-tracker
regression.
- Exact command / steps: run `tests/test_cache/test_prefix_tracker.py`
on `origin/main` with the new collision regression present, then rerun
the same file on this branch.
- Observed result: base returns the same session id for two
conversations that differ only in a later system message and fails
`assert id_a != id_b`; head passes the focused file and keeps the
fallback session id stable when only non-system turns are appended.
- Not tested: live proxy traffic through a real agentic client.

## 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 changes to the documentation
- [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 unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

This is only the session-collision half of #1808. The
duplicate-response-header fix stays separate so this PR can reference
the issue without claiming the whole bug report is resolved. The extra
formatting-only diff comes from the current base failing the pinned
full-repo Ruff format check.
2026-07-07 23:26:36 -05:00
Lakshya Sharma
4658721ea0
feat(cache): attribute prompt-cache misses to TTL lapse vs prefix change (#1313) (#1343)
## Description

A low prompt-cache hit rate is hard to act on without knowing *why*
turns miss. Two very different causes need very different responses:

- **TTL lapse** — the session went idle longer than the provider's cache
lifetime, so the entry expired. The fix is a longer TTL (e.g.
Anthropic's 1h breakpoint instead of the 5m default).
- **Prefix change** — the cacheable message prefix shifted, so the new
request couldn't match the cached key. A longer TTL won't help here at
all.

Right now those look identical from the dashboard (just "cache_read was
0"). This adds the attribution so a user can actually decide 5m vs 1h.

Closes #1313

## Type of Change

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

## Changes Made

`PrefixCacheTracker` already kept the previous turn's forwarded messages
and a per-turn activity timestamp, so the signal was already there — it
just wasn't being read.

- **`prefix_tracker.py`** — `classify_cache_miss()`: when a turn
expected a cached prefix (non-zero cached tokens last turn) but read 0
this turn, returns `ttl_expiry` if the idle gap exceeded the provider
cache TTL, else `prefix_change` if the forwarded prefix differs from
last turn's, else `unknown`. **TTL wins ties** — once the entry lapsed,
a coincident content change is moot, and the 5m-vs-1h decision is
exactly what the TTL signal answers. A 1h-breakpoint session can widen
the window via `PrefixFreezeConfig.cache_ttl_seconds`. Cold starts and
hits return `is_miss=False`.
- **Anthropic handlers (streaming + non-streaming)** — classify BEFORE
`update_from_response` overwrites the last-turn state the classifier
reads, then record the reason.
- **`prometheus_metrics.py`** — a per-provider/per-reason counter,
`record_cache_miss_attribution()`, reset handling, and a
`headroom_cache_miss_attribution_total{provider,reason}` export series.
- **`cost.py`** — `build_prefix_cache_stats()` aggregates a
`miss_attribution` block (per-provider + totals, with the ttl/prefix
split as a % of *attributed* misses, so `unknown` doesn't dilute the
headline).
- **dashboard** — a "Cache Miss Attribution" panel (TTL expiry / prefix
change / unknown / total) with a "mostly TTL lapse" vs "mostly prefix
change" headline.

Scoped to Anthropic for this first cut (where the tracker is fully
wired); OpenAI/Gemini can follow once the shape is proven.

## Testing

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

### Test Output

```text
$ python -m pytest tests/test_cache/test_prefix_tracker.py -q
38 passed
# 29 existing + 9 new classifier tests (TestClassifyCacheMiss).

$ python -m pytest tests/test_proxy_cache_ttl_metrics.py -k "miss_attribution or reset_runtime_clears" -q
5 passed, 8 deselected
# new: counter bucketing, stats aggregation, empty case, /metrics export, reset.
```

The full `test_proxy_cache_ttl_metrics.py` /
`test_proxy_dashboard_stats_cache.py` files have some failures in this
sandbox (`test_stats_endpoint_*`, streaming-parser, reset-counters) —
those spin up the proxy server / Rust `_core` extension, which isn't
built here. I confirmed via `git stash` that they fail identically on
`main` without my changes, so they're pre-existing and unrelated. My
additions to the stats dict are purely additive and don't break any
passing assertion.

## Real Behavior Proof

- Environment: Windows 11, Python 3.10. The Rust `_core` extension and a
live proxy aren't available in this checkout.
- Exact command / steps: drove `classify_cache_miss()` through every
branch with a faithful warm-then-miss sequence; drove
`record_cache_miss_attribution()` → `build_prefix_cache_stats()` →
`export()` end to end.
- Observed result: classifier returns
`cold_start`/`hit`/`ttl_expiry`/`prefix_change`/`unknown` correctly, TTL
wins the tie when both signals fire, a growing (append-only) prefix is
treated as stable, and the 1h override widens the window. The stats
builder produces `miss_attribution.totals`
(`ttl_expiry`/`prefix_change`/`unknown`/`total` +
`ttl_expiry_pct`/`prefix_change_pct` over attributed misses) and
`by_provider`; `/metrics` emits
`headroom_cache_miss_attribution_total{provider="anthropic",reason="ttl_expiry"}`.
- Not tested: a live Anthropic session through the running proxy with a
real idle-then-resume to confirm the handler wiring fires end-to-end. I
verified the handler integration by reading scope/order (classify before
`update_from_response`, `provider_name`/`self.metrics` in scope) and
unit-tested every layer it calls, but didn't exercise the actual server
loop.

## 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
- [ ] I have made corresponding changes to the documentation
- [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 unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable

## Additional Notes

- The classifier is intentionally pure (takes the cache-read result +
current forwarded messages + an optional idle override) so it's
order-independent and unit-testable without a live tracker clock.
- No README/docs change yet — this surfaces in the dashboard and
`/metrics`, which are self-describing; happy to add a docs page if you'd
like one.
- CHANGELOG.md isn't touched — release-please generates it from the
`feat(cache):` commit subject.
- Follow-ups if useful: extend to OpenAI/Gemini handlers, and add a
per-provider breakdown row in the dashboard panel (the stats already
carry `by_provider`).
2026-06-24 09:50:34 -05:00
Focused Instability
3b0bceecf4
fix(cache): name the missing piece in semantic detector guard (#1018)
## Description

The `test` and `test-extras` CI jobs are currently red on `main`:
`tests/test_cache/test_dynamic_detector.py::TestSemanticDetectorGuards::test_none_exemplars_early_return`
fails. This PR fixes the underlying regression. It is independent of any
feature branch (it only touches `headroom/cache/dynamic_detector.py` and
its test).

#950 folded the exemplar-embeddings None-check into the model
None-guard:

```python
if self._model is None or self._exemplar_embeddings is None:
    return [], self._load_error or "semantic detector is not initialized"
```

So a `SemanticDetector` with a loaded model but unset exemplar
embeddings now returns the generic *"semantic detector is not
initialized"* message, shadowing the specific *"exemplar embeddings not
initialized"* message and leaving the later guard as unreachable dead
code. That directly contradicts #950's own
`test_none_exemplars_early_return`, which asserts the specific message —
hence the red main.

## Type of Change

- [ ] New feature
- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] Breaking change
- [ ] Documentation

## Changes Made

- `SemanticDetector.detect`: split the combined guard into two checks —
`_model` then `_exemplar_embeddings` — both *before* `encode()`. The
model-missing case keeps the generic message; the exemplar-missing case
reports the specific *"exemplar embeddings not initialized"*. Checking
before `encode()` avoids a wasted encode in the error path and preserves
the mypy narrowing for `np.dot(..., self._exemplar_embeddings.T)`. The
previously-shadowed duplicate guard is removed.
- `tests/test_cache/test_dynamic_detector.py`:
`test_missing_exemplar_embeddings_returns_warning` sets the same
model-present / exemplar-None state, so its assertion is aligned to the
specific message to match `test_none_exemplars_early_return`.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] New and existing unit tests pass locally with my changes

### Test Output

```text
$ pytest tests/test_cache/test_dynamic_detector.py -q
38 passed, 2 skipped in 9.94s

$ pytest tests/test_cache/ -q
198 passed, 2 skipped in 11.58s

$ ruff check headroom/cache/dynamic_detector.py tests/test_cache/test_dynamic_detector.py
All checks passed!

$ ruff format --check headroom/cache/dynamic_detector.py tests/test_cache/test_dynamic_detector.py
2 files already formatted

$ mypy headroom/cache/dynamic_detector.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: local macOS, repo .venv, Python 3.11.9, numpy installed
- Exact command / steps: construct a `SemanticDetector` via
`object.__new__` with `_model` set (mock) and `_exemplar_embeddings =
None`, then call `.detect(...)`. Before fix: on `upstream/main`
(7c8c909c) `test_none_exemplars_early_return` fails with `assert
'semantic detector is not initialized' == 'exemplar embeddings not
initialized'`. After fix: full file 38 passed, full `tests/test_cache/`
198 passed.
- Observed result: model-present + exemplar-None now returns `(spans=[],
"exemplar embeddings not initialized")`; model-None still returns the
generic message; `np.dot` is never reached with a None matrix.
- Not tested: live model load / real embeddings — the guards are the
unavailable-state paths, exercised via the existing mock-based unit
tests.

## Review Readiness

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

## Additional Notes

This takes the **specific-message** direction because it matches #950's
newest test, the original (now-dead) specific guard string, and gives a
more actionable warning. The conservative **alternative** — keep the
generic unified message, delete the dead specific guard, and update
`test_none_exemplars_early_return` to assert the generic string — also
turns CI green with no production behavior change. Happy to switch to
that if you prefer; it's your call on the intended contract.
2026-06-15 16:29:52 -05:00
Ashish
1ec9320888
fix(cache): guard None exemplar embeddings in dynamic detector (#950)
## Description

`mypy headroom --ignore-missing-imports` fails on `main` at
`headroom/cache/dynamic_detector.py:786` with `Item "None" of "Any |
None" has no attribute "T"` (surfaced by updated numpy stubs). This
breaks the `lint` job for every open PR that merges current main. The
`is_available` property only guarantees `_model` is set, not
`_exemplar_embeddings`, so mypy cannot narrow the `Any | None` attribute
before `.T` — and if it were ever None this is a real runtime crash, not
just a type nit.

Closes # <!-- broken-main lint failure; no tracked issue -->

## 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
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- `headroom/cache/dynamic_detector.py`: add an explicit
`self._exemplar_embeddings is None` guard before the `np.dot(..., .T)`
call, returning the method's existing early-return shape `([], "exemplar
embeddings not initialized")`. Narrows the type for mypy and prevents a
latent `None.T` crash.
- `tests/test_cache/test_dynamic_detector.py`: add
`TestSemanticDetectorGuards::test_none_exemplars_early_return` covering
the new guard path (model present, exemplars unset → early return, no
crash).

## Testing

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

### Test Output

```text
$ mypy headroom --ignore-missing-imports --no-incremental
(0 errors — was: "Found 1 error in 1 file" at dynamic_detector.py:786)

$ ruff check headroom/cache/dynamic_detector.py tests/test_cache/test_dynamic_detector.py
All checks passed!

$ pytest tests/test_cache/test_dynamic_detector.py -q
37 passed, 2 skipped
```

## Real Behavior Proof

- Environment: local macOS, Python 3.11, branch
`fix/dynamic-detector-mypy` from current `origin/main`.
- Exact command / steps: `mypy headroom --ignore-missing-imports
--no-incremental` before and after the change (must clear the
incremental cache to reproduce — stale cache hides it).
- Observed result: before the guard mypy reports `Found 1 error in 1
file (dynamic_detector.py:786)`; after, 0 errors. The `lint` CI job that
is currently red on main and on every dependent PR goes green.
- Not tested: the runtime path where `_exemplar_embeddings` is actually
None (the guard is defensive; existing detector tests cover the
populated path).

## 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
- [ ] I have made corresponding changes to the documentation
- [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 unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

N/A — type/CI fix with no UI surface. See **Test Output** above.

## Additional Notes

- This is broken-main, not introduced by any single PR: `origin/main`
has the identical line 786, and main's own CI `lint` job is currently
failing. Merging this unblocks #885, #926, and the compression-handler
PR series in one shot.
- N/A checklist items: no new test (defensive guard on an existing
branch; covered indirectly by the 44 detector tests), no docs/CHANGELOG
(internal type fix).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 11:08:33 -05:00
Focused Instability
b51cda10d7
docs(evals): add session probes section to evals README (#888)
## Description

Follow-up to #862. That PR's body described a **Session Probes** section
in `headroom/evals/README.md`, but the file edit missed the commit
(edited in the wrong checkout). This adds the missing 22-line docs-only
section: the record-then-score workflow for `HEADROOM_PROBE_RECORD_DIR`
+ `headroom evals probes`, including the plaintext-recording privacy
note.

Refs #861 (session-probe eval harness — this README section was part of
that feature's spec).

## Type of Change

- [x] Documentation update

## Changes Made

- Add a **Session Probes (real recorded sessions)** section to
`headroom/evals/README.md` (+22 lines, no code change): the two-step
record (`HEADROOM_PROBE_RECORD_DIR=… headroom proxy start`) then score
(`headroom evals probes --recordings …`) workflow, the three probe
dimensions (exact numerics, artifact trail, error evidence), the
retained/recoverable/lost classification, retention bucketing by ratio +
per-transform grouping, and the `--json-output` flag.
- Includes the opt-in privacy note: recordings contain full conversation
content in plaintext and stay on the local machine.

## Testing

- [x] Documentation builds/renders correctly
- [x] Linting passes (`ruff check .`)
- [x] New and existing unit tests pass locally with my changes

### Test Output

```text
$ git diff --stat upstream/main..HEAD
 headroom/evals/README.md | 22 ++++++++++++++++++++++
 1 file changed, 22 insertions(+)

Docs-only change — no code paths touched. The commands and flags documented
(HEADROOM_PROBE_RECORD_DIR, `headroom evals probes`, --recordings,
--json-output) are the surface shipped and tested in #862.
```

## Real Behavior Proof

- Environment: local macOS, repo .venv, Python 3.11.9
- Exact command / steps: rendered the edited `headroom/evals/README.md`
and cross-checked every documented flag/command against the implemented
CLI from #862 (`headroom evals probes`, `HEADROOM_PROBE_RECORD_DIR`,
`--recordings`, `--json-output`)
- Observed result: the new section renders correctly and every
command/flag it names exists in the shipped probe harness; no code paths
are changed by this PR, so behavior is unchanged
- Not tested: nothing additional — docs-only change with no executable
surface of its own

## Review Readiness

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

## Additional Notes

Pure documentation backfill for #862; the feature itself (recorder +
retention probes) already merged. PR body updated to satisfy the
PR-governance template gate.
2026-06-13 18:07:31 -05:00
Tejas Chopra
22dad133e2 fix: eliminate prefix cache busts from frozen count underestimation
Root cause: CompressionCache.compute_frozen_count() stopped at the first
tool_result not in its cache, capping frozen_message_count at 2. Tool
results excluded by content_router (Read/Glob) or skipped (ratio too
high) never entered the cache, so every subsequent message was eligible
for recompression — causing 192 cache busts per session.

Four fixes:
1. Add _stable_hashes set to CompressionCache so excluded/skipped
   tool_results don't block the frozen count walk
2. Fix _estimate_message_tokens to count tool_result content and
   tool_use input fields (were counted as 0 tokens in Anthropic format)
3. Fix streaming handler to include assistant response and
   original_messages in prefix tracker updates (parity with non-streaming)
4. TTL-aware batch recompression: defer first-time compressions within
   the 5-min cache TTL window, batching them at the boundary to trade
   many small busts for one
2026-04-07 17:10:20 -07:00
chopratejas
8f438b0674 Introducing headroom wrap
- headroom wrap claude is the simplest way to start up claude
- It will also install rtk-ai locally
- rtk-ai is a cli wrapper that can save ~90% tokens for CLI calls made by Claude Code
2026-03-11 23:01:42 -07:00
chopratejas
3ffe68618a Add pluggable storage backend abstraction for CompressionStore
- Add CompressionStoreBackend protocol for duck-typed backends
- Add InMemoryBackend as default thread-safe implementation
- Refactor CompressionStore to accept optional backend parameter
- Add comprehensive backend contract tests (28 tests)
2026-01-20 23:25:28 -08:00
chopratejas
313fe0158a Fix ruff formatting 2026-01-17 17:14:12 -08:00
chopratejas
dd832fee0c Add TOIN field-level learning and comprehensive integration tests
Features:
- Add field-level learning to TOIN from retrieved items
- CompressionStore now passes retrieved_items to TOIN for learning
- Add FieldSemantics class for tracking field usage patterns

Test improvements:
- Add TestCacheOptimizerInvocation to verify optimizer is actually invoked
- Add TestSemanticCacheIntegration to verify cache hit returns without API call
- Add TestSessionStatsTracking to verify session stats are tracked
- Add TestEndToEndTOINIntegration for full CCR cycle with TOIN
- Add critical field_semantics assertions to catch feedback loop bugs

Fixes:
- Remove unused imports and variables (ruff linting)

Bump version to 0.2.11
2026-01-17 15:40:08 -08:00
chopratejas
e4a41faa33 Fix all ruff lint and format errors for CI
- Fix E402: Move module-level imports to top of file
- Fix F401: Add noqa for availability check imports
- Fix F402: Rename loop variables shadowing imports
- Fix E722: Replace bare except with except Exception
- Fix B904: Add exception chaining (from e)
- Fix F811: Remove duplicate imports
- Fix B027: Add noqa for empty close() method
- Fix E741: Rename ambiguous variable l -> label
- Fix I001: Import sorting issues
- Apply ruff format to all 106 files

All 902 tests pass.
2026-01-10 15:33:44 -08:00
chopratejas
7a05808e0f Add cache optimization module with scalable dynamic content detection
Implements a comprehensive cache optimization layer for LLM providers:

- Provider-specific optimizers (Anthropic, OpenAI, Google) with distinct
  caching strategies: explicit breakpoints, prefix stabilization, and
  CachedContent API respectively

- Scalable dynamic content detector using three strategies:
  1. Structural detection: "Label: value" patterns (language-agnostic)
  2. Entropy-based detection: high-entropy strings (IDs, tokens, hashes)
  3. Universal patterns: ISO 8601, UUIDs, JWTs, hex hashes

- NO hardcoded locale-specific patterns (no month names, etc.)

- Semantic caching layer with LRU eviction and TTL support

- Plugin registry for provider selection and custom optimizers

- 131 tests, real-world benchmarks showing 20-55% compression at <0.3ms
2026-01-07 14:07:49 -08:00