prompt-cache-fit 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- prompt_cache_fit-0.1.0/.gitignore +3 -0
- prompt_cache_fit-0.1.0/PKG-INFO +108 -0
- prompt_cache_fit-0.1.0/README.md +91 -0
- prompt_cache_fit-0.1.0/prompt_cache_fit.py +148 -0
- prompt_cache_fit-0.1.0/py.typed +0 -0
- prompt_cache_fit-0.1.0/pyproject.toml +27 -0
- prompt_cache_fit-0.1.0/test_prompt_cache_fit.py +191 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: prompt-cache-fit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Reorder LLM prompt blocks from least- to most-volatile so provider prefix caches stay hot, and measure whether it actually worked.
|
|
5
|
+
Project-URL: Homepage, https://github.com/pjdurden/prompt-cache-fit
|
|
6
|
+
Project-URL: Source, https://github.com/pjdurden/prompt-cache-fit
|
|
7
|
+
Author: Prajjwal Chittori
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: anthropic,llm,openai,prefix-cache,prompt-caching,prompt-engineering
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# prompt-cache-fit
|
|
19
|
+
|
|
20
|
+
Reorder LLM prompt blocks from least- to most-volatile so provider prefix caches stay hot, and measure whether it actually worked. This is the Python port of the JavaScript package of the same name.
|
|
21
|
+
|
|
22
|
+
## The problem
|
|
23
|
+
|
|
24
|
+
Anthropic, OpenAI, xAI and DeepSeek all cache prompt *prefixes*. The cache is only reusable up to the first byte that differs between two requests. Teams that put a timestamp, a request id, or "today's date" near the top of the system prompt throw away the cache for every token below it, even though the rest of the prompt (the system message, the tool schema, the RAG chunks) did not actually change. The published advice is "order your prompt from least-variable to most-variable", but every team hand-rolls that ordering, and almost nobody measures whether the reordering actually raised the hit rate. This package does the reordering and the measurement, nothing else: no tokenizer, no provider SDK, no network calls.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
pip install prompt-cache-fit
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from prompt_cache_fit import fit, hit_rate
|
|
36
|
+
|
|
37
|
+
static_block = {"text": "You are a helpful coding assistant.", "volatility": "static"}
|
|
38
|
+
tools_block = {"text": "Tools: search_docs(query), run_tests()", "volatility": "shared"}
|
|
39
|
+
session_block = {
|
|
40
|
+
"text": "User profile: senior backend engineer, prefers concise answers.",
|
|
41
|
+
"volatility": "session",
|
|
42
|
+
}
|
|
43
|
+
timestamp1 = {"text": "Timestamp: 2026-09-04T10:00:00Z", "volatility": "turn"}
|
|
44
|
+
question1 = {"text": "Turn: how do I retry a failed HTTP request?", "volatility": "turn"}
|
|
45
|
+
timestamp2 = {"text": "Timestamp: 2026-09-04T10:05:00Z", "volatility": "turn"}
|
|
46
|
+
question2 = {"text": "Turn: how do I cancel an in-flight fetch?", "volatility": "turn"}
|
|
47
|
+
|
|
48
|
+
# Hand-rolled: the volatile timestamp sits in the middle of the prompt.
|
|
49
|
+
hand_rolled1 = "\n\n".join(
|
|
50
|
+
b["text"] for b in [static_block, timestamp1, tools_block, session_block, question1]
|
|
51
|
+
)
|
|
52
|
+
hand_rolled2 = "\n\n".join(
|
|
53
|
+
b["text"] for b in [static_block, timestamp2, tools_block, session_block, question2]
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# fit(): volatile blocks (rank 'turn') sort to the end automatically.
|
|
57
|
+
fitted1 = fit([static_block, timestamp1, tools_block, session_block, question1])
|
|
58
|
+
fitted2 = fit([static_block, timestamp2, tools_block, session_block, question2])
|
|
59
|
+
|
|
60
|
+
print(hit_rate([hand_rolled1, hand_rolled2]).mean) # 0.2916666666666667
|
|
61
|
+
print(hit_rate([fitted1.text, fitted2.text]).mean) # 0.7777777777777778
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Moving the two `turn`-tier blocks to the end of the prompt takes the measured prefix hit rate on the second request from about 29% to about 78%, because the shared system prompt, tool schema and session block now form one unbroken prefix instead of being interrupted by the timestamp.
|
|
65
|
+
|
|
66
|
+
## API
|
|
67
|
+
|
|
68
|
+
### `fit(blocks, *, separator="\n\n") -> FitResult`
|
|
69
|
+
|
|
70
|
+
- `blocks: List[Dict[str, Any]]`, each dict with a required `text: str` key and optional `volatility: str`, `id`, `pin: bool` keys. Plain dicts are accepted; no class is required.
|
|
71
|
+
- `volatility` is one of `"static" | "shared" | "session" | "turn"`, ranked in that order (`static` sorts first, `turn` sorts last). Missing or unrecognized values default to `"turn"`; unknown strings never raise.
|
|
72
|
+
- `pin=True` on a block keeps that block at its original index. Every other block is sorted around it.
|
|
73
|
+
- The sort is stable: two blocks with the same rank keep their original relative order.
|
|
74
|
+
- `separator` (keyword-only, default `"\n\n"`): the string joined between rendered blocks.
|
|
75
|
+
- Returns a frozen `FitResult` dataclass:
|
|
76
|
+
- `blocks`: the input blocks, reordered.
|
|
77
|
+
- `text`: the reordered blocks joined with `separator`.
|
|
78
|
+
- `moved`: count of blocks whose index differs from the input.
|
|
79
|
+
- `stable_prefix_chars`: characters from the start of `text` through the end of the last block ranked below `turn`, including the separator that follows it if another block comes after. `0` if every block is `turn`.
|
|
80
|
+
|
|
81
|
+
### `shared_prefix(a, b) -> int`
|
|
82
|
+
|
|
83
|
+
Number of shared leading characters between two strings. `shared_prefix("abc", "abd")` is `2`. Returns `0` for empty strings.
|
|
84
|
+
|
|
85
|
+
### `hit_rate(prompts) -> HitRateReport`
|
|
86
|
+
|
|
87
|
+
`prompts` is a list of already-rendered prompt strings, in send order. For each prompt after the first, computes `shared_prefix(prompts[i-1], prompts[i]) / len(prompts[i])`. Returns a frozen `HitRateReport` dataclass:
|
|
88
|
+
|
|
89
|
+
- `per_request`: one fraction (`0..1`) per request after the first.
|
|
90
|
+
- `mean`: mean of `per_request`, `0` if fewer than 2 prompts were given.
|
|
91
|
+
- `cached_chars`: sum of shared prefix lengths across all requests after the first.
|
|
92
|
+
- `total_chars`: sum of `len(prompts[i])` for `i >= 1`.
|
|
93
|
+
|
|
94
|
+
An empty prompt string contributes `0` instead of dividing by zero.
|
|
95
|
+
|
|
96
|
+
### `VOLATILITY`
|
|
97
|
+
|
|
98
|
+
`{"static": 0, "shared": 1, "session": 2, "turn": 3}`, exported as a plain dict so forkers can add their own tiers or change the ranks.
|
|
99
|
+
|
|
100
|
+
## How it works
|
|
101
|
+
|
|
102
|
+
`fit()` is a stable sort by volatility rank plus a reinsertion pass for pinned blocks; `hit_rate()` is a character-by-character common-prefix scan. That is the whole library. It does not know how any provider actually tokenizes or chunks its cache, so `stable_prefix_chars` and `hit_rate` are character-level proxies, not a guarantee of a provider-side cache hit: real caches key on token boundaries and have their own minimum prefix length and TTL. This package also does not inject provider-specific cache markers (Anthropic's `cache_control`, OpenAI's automatic prefix caching, etc.) into the output; ordering the blocks correctly is a precondition for those markers to help, but adding the markers themselves is left to the caller, since the marker format is provider-specific and out of scope here.
|
|
103
|
+
|
|
104
|
+
The JavaScript version of this package lives at the repository root: https://github.com/pjdurden/prompt-cache-fit
|
|
105
|
+
|
|
106
|
+
## License
|
|
107
|
+
|
|
108
|
+
MIT
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# prompt-cache-fit
|
|
2
|
+
|
|
3
|
+
Reorder LLM prompt blocks from least- to most-volatile so provider prefix caches stay hot, and measure whether it actually worked. This is the Python port of the JavaScript package of the same name.
|
|
4
|
+
|
|
5
|
+
## The problem
|
|
6
|
+
|
|
7
|
+
Anthropic, OpenAI, xAI and DeepSeek all cache prompt *prefixes*. The cache is only reusable up to the first byte that differs between two requests. Teams that put a timestamp, a request id, or "today's date" near the top of the system prompt throw away the cache for every token below it, even though the rest of the prompt (the system message, the tool schema, the RAG chunks) did not actually change. The published advice is "order your prompt from least-variable to most-variable", but every team hand-rolls that ordering, and almost nobody measures whether the reordering actually raised the hit rate. This package does the reordering and the measurement, nothing else: no tokenizer, no provider SDK, no network calls.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
pip install prompt-cache-fit
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from prompt_cache_fit import fit, hit_rate
|
|
19
|
+
|
|
20
|
+
static_block = {"text": "You are a helpful coding assistant.", "volatility": "static"}
|
|
21
|
+
tools_block = {"text": "Tools: search_docs(query), run_tests()", "volatility": "shared"}
|
|
22
|
+
session_block = {
|
|
23
|
+
"text": "User profile: senior backend engineer, prefers concise answers.",
|
|
24
|
+
"volatility": "session",
|
|
25
|
+
}
|
|
26
|
+
timestamp1 = {"text": "Timestamp: 2026-09-04T10:00:00Z", "volatility": "turn"}
|
|
27
|
+
question1 = {"text": "Turn: how do I retry a failed HTTP request?", "volatility": "turn"}
|
|
28
|
+
timestamp2 = {"text": "Timestamp: 2026-09-04T10:05:00Z", "volatility": "turn"}
|
|
29
|
+
question2 = {"text": "Turn: how do I cancel an in-flight fetch?", "volatility": "turn"}
|
|
30
|
+
|
|
31
|
+
# Hand-rolled: the volatile timestamp sits in the middle of the prompt.
|
|
32
|
+
hand_rolled1 = "\n\n".join(
|
|
33
|
+
b["text"] for b in [static_block, timestamp1, tools_block, session_block, question1]
|
|
34
|
+
)
|
|
35
|
+
hand_rolled2 = "\n\n".join(
|
|
36
|
+
b["text"] for b in [static_block, timestamp2, tools_block, session_block, question2]
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# fit(): volatile blocks (rank 'turn') sort to the end automatically.
|
|
40
|
+
fitted1 = fit([static_block, timestamp1, tools_block, session_block, question1])
|
|
41
|
+
fitted2 = fit([static_block, timestamp2, tools_block, session_block, question2])
|
|
42
|
+
|
|
43
|
+
print(hit_rate([hand_rolled1, hand_rolled2]).mean) # 0.2916666666666667
|
|
44
|
+
print(hit_rate([fitted1.text, fitted2.text]).mean) # 0.7777777777777778
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Moving the two `turn`-tier blocks to the end of the prompt takes the measured prefix hit rate on the second request from about 29% to about 78%, because the shared system prompt, tool schema and session block now form one unbroken prefix instead of being interrupted by the timestamp.
|
|
48
|
+
|
|
49
|
+
## API
|
|
50
|
+
|
|
51
|
+
### `fit(blocks, *, separator="\n\n") -> FitResult`
|
|
52
|
+
|
|
53
|
+
- `blocks: List[Dict[str, Any]]`, each dict with a required `text: str` key and optional `volatility: str`, `id`, `pin: bool` keys. Plain dicts are accepted; no class is required.
|
|
54
|
+
- `volatility` is one of `"static" | "shared" | "session" | "turn"`, ranked in that order (`static` sorts first, `turn` sorts last). Missing or unrecognized values default to `"turn"`; unknown strings never raise.
|
|
55
|
+
- `pin=True` on a block keeps that block at its original index. Every other block is sorted around it.
|
|
56
|
+
- The sort is stable: two blocks with the same rank keep their original relative order.
|
|
57
|
+
- `separator` (keyword-only, default `"\n\n"`): the string joined between rendered blocks.
|
|
58
|
+
- Returns a frozen `FitResult` dataclass:
|
|
59
|
+
- `blocks`: the input blocks, reordered.
|
|
60
|
+
- `text`: the reordered blocks joined with `separator`.
|
|
61
|
+
- `moved`: count of blocks whose index differs from the input.
|
|
62
|
+
- `stable_prefix_chars`: characters from the start of `text` through the end of the last block ranked below `turn`, including the separator that follows it if another block comes after. `0` if every block is `turn`.
|
|
63
|
+
|
|
64
|
+
### `shared_prefix(a, b) -> int`
|
|
65
|
+
|
|
66
|
+
Number of shared leading characters between two strings. `shared_prefix("abc", "abd")` is `2`. Returns `0` for empty strings.
|
|
67
|
+
|
|
68
|
+
### `hit_rate(prompts) -> HitRateReport`
|
|
69
|
+
|
|
70
|
+
`prompts` is a list of already-rendered prompt strings, in send order. For each prompt after the first, computes `shared_prefix(prompts[i-1], prompts[i]) / len(prompts[i])`. Returns a frozen `HitRateReport` dataclass:
|
|
71
|
+
|
|
72
|
+
- `per_request`: one fraction (`0..1`) per request after the first.
|
|
73
|
+
- `mean`: mean of `per_request`, `0` if fewer than 2 prompts were given.
|
|
74
|
+
- `cached_chars`: sum of shared prefix lengths across all requests after the first.
|
|
75
|
+
- `total_chars`: sum of `len(prompts[i])` for `i >= 1`.
|
|
76
|
+
|
|
77
|
+
An empty prompt string contributes `0` instead of dividing by zero.
|
|
78
|
+
|
|
79
|
+
### `VOLATILITY`
|
|
80
|
+
|
|
81
|
+
`{"static": 0, "shared": 1, "session": 2, "turn": 3}`, exported as a plain dict so forkers can add their own tiers or change the ranks.
|
|
82
|
+
|
|
83
|
+
## How it works
|
|
84
|
+
|
|
85
|
+
`fit()` is a stable sort by volatility rank plus a reinsertion pass for pinned blocks; `hit_rate()` is a character-by-character common-prefix scan. That is the whole library. It does not know how any provider actually tokenizes or chunks its cache, so `stable_prefix_chars` and `hit_rate` are character-level proxies, not a guarantee of a provider-side cache hit: real caches key on token boundaries and have their own minimum prefix length and TTL. This package also does not inject provider-specific cache markers (Anthropic's `cache_control`, OpenAI's automatic prefix caching, etc.) into the output; ordering the blocks correctly is a precondition for those markers to help, but adding the markers themselves is left to the caller, since the marker format is provider-specific and out of scope here.
|
|
86
|
+
|
|
87
|
+
The JavaScript version of this package lives at the repository root: https://github.com/pjdurden/prompt-cache-fit
|
|
88
|
+
|
|
89
|
+
## License
|
|
90
|
+
|
|
91
|
+
MIT
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""
|
|
2
|
+
prompt-cache-fit
|
|
3
|
+
|
|
4
|
+
Providers cache prompt prefixes. The cache breaks at the first byte that
|
|
5
|
+
differs between two requests, so volatile content (timestamps, user ids,
|
|
6
|
+
the live turn) needs to sit at the end of the prompt, not the top. This
|
|
7
|
+
module reorders prompt blocks from least-volatile to most-volatile and
|
|
8
|
+
measures how much of the prefix is expected to survive between requests.
|
|
9
|
+
|
|
10
|
+
This is a Python port of the JavaScript package `prompt-cache-fit`. It
|
|
11
|
+
matches the JavaScript version's behavior exactly.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any, Dict, List, Optional
|
|
16
|
+
|
|
17
|
+
__all__ = ["VOLATILITY", "fit", "shared_prefix", "hit_rate", "FitResult", "HitRateReport"]
|
|
18
|
+
|
|
19
|
+
# Volatility tiers and their sort rank. Lower rank sorts first (stays near
|
|
20
|
+
# the top of the prompt, closer to the cacheable prefix). Exported as a
|
|
21
|
+
# plain dict so forkers can add their own tiers.
|
|
22
|
+
VOLATILITY: Dict[str, int] = {"static": 0, "shared": 1, "session": 2, "turn": 3}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _rank_of(volatility: Optional[str]) -> int:
|
|
26
|
+
"""
|
|
27
|
+
Resolve the sort rank for a volatility label. Missing or unrecognized
|
|
28
|
+
labels fall back to 'turn' (the most volatile tier) rather than raising.
|
|
29
|
+
"""
|
|
30
|
+
if volatility is not None and volatility in VOLATILITY:
|
|
31
|
+
return VOLATILITY[volatility]
|
|
32
|
+
return VOLATILITY["turn"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class FitResult:
|
|
37
|
+
blocks: List[Dict[str, Any]]
|
|
38
|
+
text: str
|
|
39
|
+
moved: int
|
|
40
|
+
stable_prefix_chars: int
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class HitRateReport:
|
|
45
|
+
per_request: List[float]
|
|
46
|
+
mean: float
|
|
47
|
+
cached_chars: int
|
|
48
|
+
total_chars: int
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def fit(blocks: List[Dict[str, Any]], *, separator: str = "\n\n") -> FitResult:
|
|
52
|
+
"""
|
|
53
|
+
Reorder prompt blocks from least-volatile to most-volatile so that the
|
|
54
|
+
shared, cacheable prefix is as long as possible, and render them into a
|
|
55
|
+
single prompt string.
|
|
56
|
+
|
|
57
|
+
blocks: a list of dicts, each with a required "text" key and optional
|
|
58
|
+
"volatility", "id", "pin" keys.
|
|
59
|
+
separator: the string joined between rendered blocks.
|
|
60
|
+
"""
|
|
61
|
+
if not isinstance(blocks, (list, tuple)) or len(blocks) == 0:
|
|
62
|
+
return FitResult(blocks=[], text="", moved=0, stable_prefix_chars=0)
|
|
63
|
+
|
|
64
|
+
blocks = list(blocks)
|
|
65
|
+
|
|
66
|
+
# Split into pinned (keep original index) and non-pinned (sortable) blocks.
|
|
67
|
+
pinned_indices: List[int] = []
|
|
68
|
+
non_pinned: List[Any] = [] # list of (block, index) tuples
|
|
69
|
+
for index, block in enumerate(blocks):
|
|
70
|
+
if block.get("pin") is True:
|
|
71
|
+
pinned_indices.append(index)
|
|
72
|
+
else:
|
|
73
|
+
non_pinned.append((block, index))
|
|
74
|
+
|
|
75
|
+
# Python's sorted() is stable, so ties keep original order.
|
|
76
|
+
non_pinned.sort(key=lambda item: _rank_of(item[0].get("volatility")))
|
|
77
|
+
|
|
78
|
+
# Reinsert pinned blocks at their original indices; fill the rest in
|
|
79
|
+
# sorted order.
|
|
80
|
+
result: List[Any] = [None] * len(blocks)
|
|
81
|
+
for idx in pinned_indices:
|
|
82
|
+
result[idx] = blocks[idx]
|
|
83
|
+
cursor = 0
|
|
84
|
+
for i in range(len(result)):
|
|
85
|
+
if result[i] is None:
|
|
86
|
+
result[i] = non_pinned[cursor][0]
|
|
87
|
+
cursor += 1
|
|
88
|
+
|
|
89
|
+
text = separator.join(block["text"] for block in result)
|
|
90
|
+
|
|
91
|
+
moved = 0
|
|
92
|
+
for i in range(len(blocks)):
|
|
93
|
+
if result[i] is not blocks[i]:
|
|
94
|
+
moved += 1
|
|
95
|
+
|
|
96
|
+
# Chars from the start through the end of the last block whose rank is
|
|
97
|
+
# less than 'turn' (3), including the separator that follows it if
|
|
98
|
+
# another block comes after it. 0 if every block is 'turn'.
|
|
99
|
+
last_stable_index = -1
|
|
100
|
+
for i in range(len(result)):
|
|
101
|
+
if _rank_of(result[i].get("volatility")) < VOLATILITY["turn"]:
|
|
102
|
+
last_stable_index = i
|
|
103
|
+
|
|
104
|
+
stable_prefix_chars = 0
|
|
105
|
+
if last_stable_index >= 0:
|
|
106
|
+
for i in range(last_stable_index + 1):
|
|
107
|
+
stable_prefix_chars += len(result[i]["text"])
|
|
108
|
+
if i < last_stable_index:
|
|
109
|
+
stable_prefix_chars += len(separator)
|
|
110
|
+
if last_stable_index < len(result) - 1:
|
|
111
|
+
stable_prefix_chars += len(separator)
|
|
112
|
+
|
|
113
|
+
return FitResult(blocks=result, text=text, moved=moved, stable_prefix_chars=stable_prefix_chars)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def shared_prefix(a: str, b: str) -> int:
|
|
117
|
+
"""Count the shared leading characters between two strings."""
|
|
118
|
+
length = min(len(a), len(b))
|
|
119
|
+
i = 0
|
|
120
|
+
while i < length and a[i] == b[i]:
|
|
121
|
+
i += 1
|
|
122
|
+
return i
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def hit_rate(prompts: List[str]) -> HitRateReport:
|
|
126
|
+
"""
|
|
127
|
+
Measure how much of each rendered prompt's prefix was shared with the
|
|
128
|
+
prompt sent immediately before it, as a proxy for provider prefix-cache
|
|
129
|
+
hit rate.
|
|
130
|
+
|
|
131
|
+
prompts: rendered prompt strings, in send order.
|
|
132
|
+
"""
|
|
133
|
+
per_request: List[float] = []
|
|
134
|
+
cached_chars = 0
|
|
135
|
+
total_chars = 0
|
|
136
|
+
|
|
137
|
+
for i in range(1, len(prompts)):
|
|
138
|
+
previous = prompts[i - 1]
|
|
139
|
+
current = prompts[i]
|
|
140
|
+
shared = shared_prefix(previous, current)
|
|
141
|
+
fraction = 0 if len(current) == 0 else shared / len(current)
|
|
142
|
+
per_request.append(fraction)
|
|
143
|
+
cached_chars += shared
|
|
144
|
+
total_chars += len(current)
|
|
145
|
+
|
|
146
|
+
mean = 0 if len(per_request) == 0 else sum(per_request) / len(per_request)
|
|
147
|
+
|
|
148
|
+
return HitRateReport(per_request=per_request, mean=mean, cached_chars=cached_chars, total_chars=total_chars)
|
|
File without changes
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "prompt-cache-fit"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Reorder LLM prompt blocks from least- to most-volatile so provider prefix caches stay hot, and measure whether it actually worked."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
authors = [{name = "Prajjwal Chittori"}]
|
|
13
|
+
keywords = ["llm", "prompt-caching", "prefix-cache", "anthropic", "openai", "prompt-engineering"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Typing :: Typed",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://github.com/pjdurden/prompt-cache-fit"
|
|
24
|
+
Source = "https://github.com/pjdurden/prompt-cache-fit"
|
|
25
|
+
|
|
26
|
+
[tool.hatch.build.targets.wheel]
|
|
27
|
+
include = ["prompt_cache_fit.py", "py.typed"]
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
from prompt_cache_fit import VOLATILITY, FitResult, HitRateReport, fit, hit_rate, shared_prefix
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TestVolatility(unittest.TestCase):
|
|
7
|
+
def test_volatility_exposes_the_exact_rank_table(self):
|
|
8
|
+
self.assertEqual(VOLATILITY, {"static": 0, "shared": 1, "session": 2, "turn": 3})
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestFit(unittest.TestCase):
|
|
12
|
+
def test_fit_on_an_empty_list_returns_the_exact_empty_result(self):
|
|
13
|
+
self.assertEqual(fit([]), FitResult(blocks=[], text="", moved=0, stable_prefix_chars=0))
|
|
14
|
+
|
|
15
|
+
def test_fit_leaves_order_unchanged_when_every_block_shares_a_volatility(self):
|
|
16
|
+
blocks = [
|
|
17
|
+
{"text": "p", "volatility": "session"},
|
|
18
|
+
{"text": "q", "volatility": "session"},
|
|
19
|
+
{"text": "r", "volatility": "session"},
|
|
20
|
+
]
|
|
21
|
+
result = fit(blocks)
|
|
22
|
+
self.assertEqual(result.moved, 0)
|
|
23
|
+
self.assertEqual(result.text, "p\n\nq\n\nr")
|
|
24
|
+
self.assertEqual(result.blocks, blocks)
|
|
25
|
+
|
|
26
|
+
def test_fit_sorts_by_volatility_rank_static_first(self):
|
|
27
|
+
a = {"text": "A", "volatility": "turn"}
|
|
28
|
+
b = {"text": "B", "volatility": "static"}
|
|
29
|
+
c = {"text": "C", "volatility": "shared"}
|
|
30
|
+
result = fit([a, b, c])
|
|
31
|
+
self.assertEqual(result.blocks, [b, c, a])
|
|
32
|
+
self.assertEqual(result.text, "B\n\nC\n\nA")
|
|
33
|
+
|
|
34
|
+
def test_fit_keeps_original_relative_order_for_blocks_tied_on_rank_stable_sort(self):
|
|
35
|
+
t1 = {"text": "x", "volatility": "turn", "id": "t1"}
|
|
36
|
+
s1 = {"text": "y", "volatility": "static", "id": "s1"}
|
|
37
|
+
t2 = {"text": "z", "volatility": "turn", "id": "t2"}
|
|
38
|
+
result = fit([t1, s1, t2])
|
|
39
|
+
self.assertEqual([block["id"] for block in result.blocks], ["s1", "t1", "t2"])
|
|
40
|
+
|
|
41
|
+
def test_fit_treats_a_missing_volatility_as_turn_default(self):
|
|
42
|
+
m = {"text": "M"}
|
|
43
|
+
n = {"text": "N", "volatility": "static"}
|
|
44
|
+
result = fit([m, n])
|
|
45
|
+
self.assertEqual(result.blocks, [n, m])
|
|
46
|
+
|
|
47
|
+
def test_fit_treats_an_unknown_volatility_string_as_turn_instead_of_raising(self):
|
|
48
|
+
x = {"text": "X", "volatility": "made-up-tier"}
|
|
49
|
+
y = {"text": "Y", "volatility": "static"}
|
|
50
|
+
try:
|
|
51
|
+
fit([x, y])
|
|
52
|
+
except Exception as exc: # pragma: no cover - fails the test if raised
|
|
53
|
+
self.fail(f"fit() raised unexpectedly for an unknown volatility: {exc!r}")
|
|
54
|
+
result = fit([x, y])
|
|
55
|
+
self.assertEqual(result.blocks, [y, x])
|
|
56
|
+
|
|
57
|
+
def test_fit_keeps_a_pinned_block_at_its_original_index_and_counts_moved_correctly(self):
|
|
58
|
+
a = {"text": "A", "volatility": "turn"}
|
|
59
|
+
b = {"text": "B", "volatility": "static", "pin": True}
|
|
60
|
+
c = {"text": "C", "volatility": "static"}
|
|
61
|
+
d = {"text": "D", "volatility": "turn"}
|
|
62
|
+
result = fit([a, b, c, d])
|
|
63
|
+
# B is static and would otherwise jump to index 0, but pin holds it at index 1.
|
|
64
|
+
self.assertEqual(result.blocks[1], b)
|
|
65
|
+
self.assertEqual(result.blocks, [c, b, a, d])
|
|
66
|
+
# A and C changed index; B (pinned) and D did not.
|
|
67
|
+
self.assertEqual(result.moved, 2)
|
|
68
|
+
|
|
69
|
+
def test_fit_supports_a_custom_separator(self):
|
|
70
|
+
result = fit([{"text": "a"}, {"text": "b"}], separator="|")
|
|
71
|
+
self.assertEqual(result.text, "a|b")
|
|
72
|
+
|
|
73
|
+
def test_fit_uses_double_newline_as_the_default_separator(self):
|
|
74
|
+
result = fit([{"text": "a"}, {"text": "b"}])
|
|
75
|
+
self.assertEqual(result.text, "a\n\nb")
|
|
76
|
+
|
|
77
|
+
def test_fit_omitted_separator_keyword_matches_explicit_default(self):
|
|
78
|
+
# The JS suite has a test asserting that an explicit null options
|
|
79
|
+
# object behaves identically to omitted options. There is no
|
|
80
|
+
# equivalent to port literally in Python: options are keyword
|
|
81
|
+
# arguments with defaults, so there is no separate "null options"
|
|
82
|
+
# state to test. This instead confirms that omitting the separator
|
|
83
|
+
# keyword produces the same result as passing the documented default
|
|
84
|
+
# explicitly.
|
|
85
|
+
omitted = fit([{"text": "a"}])
|
|
86
|
+
explicit_default = fit([{"text": "a"}], separator="\n\n")
|
|
87
|
+
self.assertEqual(omitted, explicit_default)
|
|
88
|
+
self.assertEqual(
|
|
89
|
+
omitted,
|
|
90
|
+
FitResult(blocks=[{"text": "a"}], text="a", moved=0, stable_prefix_chars=0),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def test_fit_computes_stable_prefix_chars_through_the_last_non_turn_block(self):
|
|
94
|
+
static_block = {"text": "sys", "volatility": "static"}
|
|
95
|
+
shared_block = {"text": "tools", "volatility": "shared"}
|
|
96
|
+
turn_block = {"text": "question", "volatility": "turn"}
|
|
97
|
+
result = fit([static_block, turn_block, shared_block])
|
|
98
|
+
self.assertEqual(result.blocks, [static_block, shared_block, turn_block])
|
|
99
|
+
# 'sys' (3) + sep (2) + 'tools' (5) + sep (2) = 12, up through and including
|
|
100
|
+
# the separator that follows the last non-turn block.
|
|
101
|
+
self.assertEqual(result.stable_prefix_chars, 12)
|
|
102
|
+
|
|
103
|
+
def test_fit_stable_prefix_chars_is_0_when_every_block_is_turn(self):
|
|
104
|
+
result = fit([{"text": "a"}, {"text": "b"}])
|
|
105
|
+
self.assertEqual(result.stable_prefix_chars, 0)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class TestSharedPrefix(unittest.TestCase):
|
|
109
|
+
def test_shared_prefix_counts_matching_leading_characters(self):
|
|
110
|
+
self.assertEqual(shared_prefix("abc", "abd"), 2)
|
|
111
|
+
self.assertEqual(shared_prefix("abc", "abc"), 3)
|
|
112
|
+
self.assertEqual(shared_prefix("ab", "abc"), 2)
|
|
113
|
+
|
|
114
|
+
def test_shared_prefix_handles_empty_strings(self):
|
|
115
|
+
self.assertEqual(shared_prefix("", ""), 0)
|
|
116
|
+
self.assertEqual(shared_prefix("abc", ""), 0)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class TestHitRate(unittest.TestCase):
|
|
120
|
+
def test_hit_rate_empty_reports_all_zeros_with_no_per_request_entries(self):
|
|
121
|
+
report = hit_rate([])
|
|
122
|
+
self.assertEqual(report.per_request, [])
|
|
123
|
+
self.assertEqual(report.mean, 0)
|
|
124
|
+
self.assertEqual(report.cached_chars, 0)
|
|
125
|
+
self.assertEqual(report.total_chars, 0)
|
|
126
|
+
|
|
127
|
+
def test_hit_rate_with_a_single_prompt_reports_all_zeros_with_no_per_request_entries(self):
|
|
128
|
+
report = hit_rate(["only one"])
|
|
129
|
+
self.assertEqual(report.per_request, [])
|
|
130
|
+
self.assertEqual(report.mean, 0)
|
|
131
|
+
self.assertEqual(report.cached_chars, 0)
|
|
132
|
+
self.assertEqual(report.total_chars, 0)
|
|
133
|
+
|
|
134
|
+
def test_hit_rate_guards_against_division_by_zero_on_an_empty_prompt(self):
|
|
135
|
+
report = hit_rate(["abc", ""])
|
|
136
|
+
self.assertEqual(report.per_request, [0])
|
|
137
|
+
self.assertEqual(report.mean, 0)
|
|
138
|
+
self.assertEqual(report.cached_chars, 0)
|
|
139
|
+
self.assertEqual(report.total_chars, 0)
|
|
140
|
+
|
|
141
|
+
def test_hit_rate_computes_per_request_fractions_mean_cached_chars_and_total_chars(self):
|
|
142
|
+
report = hit_rate(["hello world", "hello there", "hello there friend"])
|
|
143
|
+
# 'hello world' vs 'hello there' share 'hello ' -> 6 chars / 11 = 0.5454...
|
|
144
|
+
# 'hello there' vs 'hello there friend' share all 11 chars / 18 = 0.6111...
|
|
145
|
+
self.assertEqual(len(report.per_request), 2)
|
|
146
|
+
self.assertEqual(report.per_request[0], 6 / 11)
|
|
147
|
+
self.assertEqual(report.per_request[1], 11 / 18)
|
|
148
|
+
self.assertEqual(report.cached_chars, 6 + 11)
|
|
149
|
+
self.assertEqual(report.total_chars, 11 + 18)
|
|
150
|
+
self.assertEqual(report.mean, (6 / 11 + 11 / 18) / 2)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class TestReadmeWorkedCase(unittest.TestCase):
|
|
154
|
+
def test_readme_worked_case_moving_the_volatile_block_to_the_end_raises_the_measured_hit_rate(self):
|
|
155
|
+
static_block = {"text": "You are a helpful coding assistant.", "volatility": "static"}
|
|
156
|
+
tools_block = {"text": "Tools: search_docs(query), run_tests()", "volatility": "shared"}
|
|
157
|
+
session_block = {
|
|
158
|
+
"text": "User profile: senior backend engineer, prefers concise answers.",
|
|
159
|
+
"volatility": "session",
|
|
160
|
+
}
|
|
161
|
+
timestamp1 = {"text": "Timestamp: 2026-09-04T10:00:00Z", "volatility": "turn"}
|
|
162
|
+
question1 = {"text": "Turn: how do I retry a failed HTTP request?", "volatility": "turn"}
|
|
163
|
+
timestamp2 = {"text": "Timestamp: 2026-09-04T10:05:00Z", "volatility": "turn"}
|
|
164
|
+
question2 = {"text": "Turn: how do I cancel an in-flight fetch?", "volatility": "turn"}
|
|
165
|
+
|
|
166
|
+
# Hand-rolled order: the volatile timestamp sits in the middle.
|
|
167
|
+
hand_rolled1 = "\n\n".join(
|
|
168
|
+
block["text"] for block in [static_block, timestamp1, tools_block, session_block, question1]
|
|
169
|
+
)
|
|
170
|
+
hand_rolled2 = "\n\n".join(
|
|
171
|
+
block["text"] for block in [static_block, timestamp2, tools_block, session_block, question2]
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# fit(): volatile blocks pushed to the end.
|
|
175
|
+
fitted1 = fit([static_block, timestamp1, tools_block, session_block, question1])
|
|
176
|
+
fitted2 = fit([static_block, timestamp2, tools_block, session_block, question2])
|
|
177
|
+
|
|
178
|
+
hand_rolled_report = hit_rate([hand_rolled1, hand_rolled2])
|
|
179
|
+
fitted_report = hit_rate([fitted1.text, fitted2.text])
|
|
180
|
+
|
|
181
|
+
self.assertEqual(hand_rolled_report.mean, 63 / 216)
|
|
182
|
+
self.assertEqual(fitted_report.mean, 168 / 216)
|
|
183
|
+
self.assertGreater(fitted_report.mean, hand_rolled_report.mean)
|
|
184
|
+
|
|
185
|
+
# These are the exact numbers quoted in the README's worked example.
|
|
186
|
+
self.assertEqual(hand_rolled_report.mean, 0.2916666666666667)
|
|
187
|
+
self.assertEqual(fitted_report.mean, 0.7777777777777778)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
if __name__ == "__main__":
|
|
191
|
+
unittest.main()
|