cacheverifier 0.4.1__tar.gz → 0.6.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.
- {cacheverifier-0.4.1 → cacheverifier-0.6.0}/CHANGELOG.md +37 -0
- {cacheverifier-0.4.1 → cacheverifier-0.6.0}/PKG-INFO +85 -3
- {cacheverifier-0.4.1 → cacheverifier-0.6.0}/README.md +76 -1
- {cacheverifier-0.4.1 → cacheverifier-0.6.0}/cacheverifier/__init__.py +12 -3
- {cacheverifier-0.4.1 → cacheverifier-0.6.0}/cacheverifier/_healthcheck/_finetune.py +6 -35
- cacheverifier-0.6.0/cacheverifier/_inference.py +162 -0
- {cacheverifier-0.4.1 → cacheverifier-0.6.0}/cacheverifier/client.py +83 -4
- cacheverifier-0.6.0/cacheverifier/integrations/langchain.py +140 -0
- cacheverifier-0.6.0/cacheverifier/local.py +502 -0
- {cacheverifier-0.4.1 → cacheverifier-0.6.0}/pyproject.toml +10 -3
- {cacheverifier-0.4.1 → cacheverifier-0.6.0}/tests/test_client.py +27 -1
- cacheverifier-0.6.0/tests/test_langchain_integration.py +90 -0
- cacheverifier-0.6.0/tests/test_local.py +251 -0
- {cacheverifier-0.4.1 → cacheverifier-0.6.0}/.github/workflows/ci.yml +0 -0
- {cacheverifier-0.4.1 → cacheverifier-0.6.0}/.github/workflows/publish.yml +0 -0
- {cacheverifier-0.4.1 → cacheverifier-0.6.0}/.gitignore +0 -0
- {cacheverifier-0.4.1 → cacheverifier-0.6.0}/LICENSE +0 -0
- {cacheverifier-0.4.1 → cacheverifier-0.6.0}/cacheverifier/__main__.py +0 -0
- {cacheverifier-0.4.1 → cacheverifier-0.6.0}/cacheverifier/_healthcheck/__init__.py +0 -0
- {cacheverifier-0.4.1 → cacheverifier-0.6.0}/cacheverifier/_healthcheck/_base_models.py +0 -0
- {cacheverifier-0.4.1 → cacheverifier-0.6.0}/cacheverifier/_healthcheck/cli.py +0 -0
- {cacheverifier-0.4.1 → cacheverifier-0.6.0}/cacheverifier/integrations/__init__.py +0 -0
- {cacheverifier-0.4.1 → cacheverifier-0.6.0}/cacheverifier/integrations/gptcache.py +0 -0
- {cacheverifier-0.4.1 → cacheverifier-0.6.0}/cacheverifier/py.typed +0 -0
- {cacheverifier-0.4.1 → cacheverifier-0.6.0}/examples/gptcache_example.py +0 -0
- {cacheverifier-0.4.1 → cacheverifier-0.6.0}/examples/quickstart.py +0 -0
- {cacheverifier-0.4.1 → cacheverifier-0.6.0}/tests/test_gptcache_integration.py +0 -0
- {cacheverifier-0.4.1 → cacheverifier-0.6.0}/tests/test_healthcheck.py +0 -0
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.0
|
|
4
|
+
|
|
5
|
+
New `LocalVerifier` (`pip install "cacheverifier[local]"`, Managed plans):
|
|
6
|
+
scores gray-zone hits on your own machines with the model CacheVerifier
|
|
7
|
+
fine-tuned for you, deciding exactly as the hosted `/v1/verify` would. It
|
|
8
|
+
downloads the live model, checks it against reference verdicts from the
|
|
9
|
+
service before using it, switches to newer models as they go live, and, while
|
|
10
|
+
auditing is on, uploads each decision without the texts so audits and risk
|
|
11
|
+
monitoring keep working. Falls back to the hosted API until a model is ready.
|
|
12
|
+
|
|
13
|
+
`CacheVerifier.verify()` takes `similarity_score`, `cached_query` and
|
|
14
|
+
`runner_up_similarity_score` (all optional), which a fine-tuned model's joint
|
|
15
|
+
rules use; before this the client couldn't send them. `feedback()` takes
|
|
16
|
+
`cached_query` and `verification_id`. `VerifyResult` gains `decision_rule`,
|
|
17
|
+
`audit` and `verification_id`, which the API already returned.
|
|
18
|
+
|
|
19
|
+
The offline Health Check now truncates long inputs exactly as the service
|
|
20
|
+
does since its verifier-core 0.7.0: a long query next to a short answer keeps
|
|
21
|
+
the room the answer doesn't need, instead of being cut to half the budget.
|
|
22
|
+
|
|
23
|
+
## 0.5.0
|
|
24
|
+
|
|
25
|
+
New LangChain integration: `cacheverifier.integrations.langchain.VerifiedCache`
|
|
26
|
+
wraps any LangChain cache (e.g. `RedisSemanticCache`) and verifies each hit
|
|
27
|
+
before it is served; a rejected hit becomes a miss. Install with
|
|
28
|
+
`pip install "cacheverifier[langchain]"`.
|
|
29
|
+
|
|
30
|
+
`VerifyResult` gains two optional fields, both None unless the server sends
|
|
31
|
+
them:
|
|
32
|
+
|
|
33
|
+
- `notice`: the plain-language reason a call wasn't decided by a model (cold
|
|
34
|
+
start), which the API already returned but the client dropped.
|
|
35
|
+
- `shadow`: on a cold-start call, a `ShadowVerdict` with what the shared stock
|
|
36
|
+
model would have decided (`approved`, `score`, `threshold`, `latency_ms`).
|
|
37
|
+
The call itself is still rejected, so this is a preview: your first calls
|
|
38
|
+
on a new account show what the model thinks before anything is served.
|
|
39
|
+
|
|
3
40
|
## 0.4.1
|
|
4
41
|
|
|
5
42
|
`verify()` and `verify_batch()` now treat a **rate-limited 429** (one with a
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: cacheverifier
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Python client for the
|
|
3
|
+
Version: 0.6.0
|
|
4
|
+
Summary: Python client for the CacheVerifier semantic-cache verification API, hosted or scored locally
|
|
5
5
|
Project-URL: Homepage, https://www.cacheverifier.com
|
|
6
6
|
Project-URL: Documentation, https://www.cacheverifier.com/docs
|
|
7
7
|
Project-URL: Source, https://github.com/imxinchengyou/cacheverifier-python
|
|
@@ -19,6 +19,7 @@ Classifier: Typing :: Typed
|
|
|
19
19
|
Requires-Python: >=3.9
|
|
20
20
|
Requires-Dist: httpx>=0.24
|
|
21
21
|
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: langchain-core>=0.3; extra == 'dev'
|
|
22
23
|
Requires-Dist: mypy>=1.5; extra == 'dev'
|
|
23
24
|
Requires-Dist: pytest>=7; extra == 'dev'
|
|
24
25
|
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
@@ -28,6 +29,12 @@ Provides-Extra: healthcheck
|
|
|
28
29
|
Requires-Dist: numpy<2,>=1.26; extra == 'healthcheck'
|
|
29
30
|
Requires-Dist: sentence-transformers<4,>=3.0; extra == 'healthcheck'
|
|
30
31
|
Requires-Dist: torch>=2.2; extra == 'healthcheck'
|
|
32
|
+
Provides-Extra: langchain
|
|
33
|
+
Requires-Dist: langchain-core>=0.3; extra == 'langchain'
|
|
34
|
+
Provides-Extra: local
|
|
35
|
+
Requires-Dist: numpy<2,>=1.26; extra == 'local'
|
|
36
|
+
Requires-Dist: sentence-transformers<4,>=3.0; extra == 'local'
|
|
37
|
+
Requires-Dist: torch>=2.2; extra == 'local'
|
|
31
38
|
Description-Content-Type: text/markdown
|
|
32
39
|
|
|
33
40
|
# cacheverifier
|
|
@@ -57,6 +64,8 @@ pip install cacheverifier
|
|
|
57
64
|
pip install "cacheverifier[gptcache]"
|
|
58
65
|
# with the offline Health Check (adds torch + sentence-transformers):
|
|
59
66
|
pip install "cacheverifier[healthcheck]"
|
|
67
|
+
# to score with your fine-tuned model on your own machines (same deps):
|
|
68
|
+
pip install "cacheverifier[local]"
|
|
60
69
|
```
|
|
61
70
|
|
|
62
71
|
Requires Python 3.9+. The only runtime dependency is `httpx` — the extras above
|
|
@@ -94,6 +103,19 @@ cv.feedback(query, answer, was_correct=True, similarity_score=0.86)
|
|
|
94
103
|
| `model_version` | `"stock"`, `"v<id>"` (fine-tuned), `"cold_start_fail_closed"` / `"cold_start_auto_pending"`, or `"verify_unavailable"` |
|
|
95
104
|
| `latency_ms` | server-side inference time |
|
|
96
105
|
| `degraded` | `True` when the call failed and this result was synthesized client-side |
|
|
106
|
+
| `notice` | why no model decided the call (cold start); `None` otherwise |
|
|
107
|
+
| `shadow` | cold start only: a `ShadowVerdict` with what the shared stock model would have decided. The call is still rejected, so treat it as a preview |
|
|
108
|
+
|
|
109
|
+
A new account starts in cold start: every call returns `approved=False` until a
|
|
110
|
+
Health Check shows the shared model is accurate enough on your data. Look at
|
|
111
|
+
`result.shadow` to see what it would have decided in the meantime:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
result = cv.verify("when does my flight board", "Boarding starts 40 minutes before departure.")
|
|
115
|
+
if result.shadow:
|
|
116
|
+
print(result.notice)
|
|
117
|
+
print("stock model would approve:", result.shadow.approved, result.shadow.score)
|
|
118
|
+
```
|
|
97
119
|
|
|
98
120
|
## On your request path
|
|
99
121
|
|
|
@@ -139,6 +161,30 @@ evaluator.report_feedback(query, answer, was_correct=False, similarity_score=0.9
|
|
|
139
161
|
|
|
140
162
|
See [`examples/gptcache_example.py`](examples/gptcache_example.py).
|
|
141
163
|
|
|
164
|
+
## LangChain
|
|
165
|
+
|
|
166
|
+
`VerifiedCache` wraps any LangChain cache, typically a semantic one, and only
|
|
167
|
+
serves a hit after CacheVerifier approves it; a rejected hit is a miss, so
|
|
168
|
+
your model is called as usual. For chat models it verifies against the last
|
|
169
|
+
user message.
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
pip install "cacheverifier[langchain]"
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
from langchain_core.globals import set_llm_cache
|
|
177
|
+
from langchain_redis import RedisSemanticCache
|
|
178
|
+
from cacheverifier.integrations.langchain import VerifiedCache
|
|
179
|
+
|
|
180
|
+
set_llm_cache(
|
|
181
|
+
VerifiedCache(
|
|
182
|
+
RedisSemanticCache(redis_url="redis://localhost:6379", embeddings=embeddings),
|
|
183
|
+
api_key="cv_...",
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
```
|
|
187
|
+
|
|
142
188
|
## Fine-tuning
|
|
143
189
|
|
|
144
190
|
Once you have ~20+ feedback rows (the service found fine-tuning is often a net negative
|
|
@@ -158,6 +204,40 @@ if job.get("result_model_version"):
|
|
|
158
204
|
`cv.dry_run([...])` reports the same baseline-vs-tuned AUC on examples you pass directly,
|
|
159
205
|
without writing anything or deploying a model.
|
|
160
206
|
|
|
207
|
+
## Local scoring (Managed plans)
|
|
208
|
+
|
|
209
|
+
`LocalVerifier` runs the model CacheVerifier fine-tuned for you on your own
|
|
210
|
+
machines: no network round trip on your request path, and your query and
|
|
211
|
+
answer texts stay with you. Fine-tuning, drift monitoring, audits and
|
|
212
|
+
certification keep running on the service.
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
from cacheverifier import LocalVerifier
|
|
216
|
+
|
|
217
|
+
cv = LocalVerifier(api_key="cv_...") # downloads your live model in the background
|
|
218
|
+
r = cv.verify(query, cached_answer, similarity_score=0.87, cached_query=cached_question)
|
|
219
|
+
if r.approved:
|
|
220
|
+
... # serve from cache
|
|
221
|
+
else:
|
|
222
|
+
answer = call_llm(query)
|
|
223
|
+
if r.audit: # a randomized audit: report whether the cached answer was right
|
|
224
|
+
cv.feedback(query, cached_answer, was_correct=same_answer(answer, cached_answer), result=r)
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
- It decides exactly as the hosted `/v1/verify` would with that model: every download
|
|
228
|
+
carries reference verdicts from the service, and a model that doesn't reproduce them
|
|
229
|
+
locally is not used.
|
|
230
|
+
- When a newer model goes live (after a fine-tune or a scheduled recalibration), it is
|
|
231
|
+
downloaded, checked, and switched to without a restart.
|
|
232
|
+
- While auditing is on, each decision is uploaded as scores, verdicts and a keyed hash of
|
|
233
|
+
the pair, never the texts, so audits and `/v1/monitor/risk` work as with the hosted API.
|
|
234
|
+
- Until a model is ready, calls go to the hosted API (`fallback="hosted"`, the default)
|
|
235
|
+
or are rejected (`fallback="reject"`). Pass `wait=True` to block until the first
|
|
236
|
+
download finishes (a model is ~330MB).
|
|
237
|
+
- The audit draw happens in this client. The risk bound assumes it is independent of
|
|
238
|
+
whether the answer is right, which the client guarantees unless it is modified.
|
|
239
|
+
- Not supported yet: the online ACI threshold (local scoring uses the model's fixed one).
|
|
240
|
+
|
|
161
241
|
## Local Health Check (offline)
|
|
162
242
|
|
|
163
243
|
`cv.dry_run()` still uploads your examples to the API. If that's a blocker — a
|
|
@@ -219,7 +299,7 @@ verdict
|
|
|
219
299
|
|
|
220
300
|
| method | endpoint |
|
|
221
301
|
|---|---|
|
|
222
|
-
| `verify(query, candidate_answer)` | `POST /v1/verify` |
|
|
302
|
+
| `verify(query, candidate_answer, similarity_score=..., cached_query=...)` | `POST /v1/verify` |
|
|
223
303
|
| `verify_batch(pairs)` | `POST /v1/verify/batch` |
|
|
224
304
|
| `feedback(...)` / `feedback_batch(items)` | `POST /v1/feedback` / `/batch` |
|
|
225
305
|
| `finetune(...)` / `dry_run(examples, ...)` | `POST /v1/finetune/jobs` / `/dry-run` |
|
|
@@ -228,6 +308,8 @@ verdict
|
|
|
228
308
|
| `drift_status()` | `GET /v1/monitor/drift-status` |
|
|
229
309
|
| `gray_zone_threshold()` | `GET /v1/monitor/gray-zone-threshold` |
|
|
230
310
|
| `usage()` / `savings()` | `GET /v1/usage/status` / `/savings` |
|
|
311
|
+
| `LocalVerifier.verify(...)` / `verify_batch(...)` | local; decisions to `POST /v1/verify/decisions` |
|
|
312
|
+
| `LocalVerifier.refresh()` | `GET /v1/models/active` + `/v1/models/{id}/files/{name}` |
|
|
231
313
|
|
|
232
314
|
Non-2xx responses raise `CacheVerifierError` (`.status_code`, `.detail`).
|
|
233
315
|
|
|
@@ -25,6 +25,8 @@ pip install cacheverifier
|
|
|
25
25
|
pip install "cacheverifier[gptcache]"
|
|
26
26
|
# with the offline Health Check (adds torch + sentence-transformers):
|
|
27
27
|
pip install "cacheverifier[healthcheck]"
|
|
28
|
+
# to score with your fine-tuned model on your own machines (same deps):
|
|
29
|
+
pip install "cacheverifier[local]"
|
|
28
30
|
```
|
|
29
31
|
|
|
30
32
|
Requires Python 3.9+. The only runtime dependency is `httpx` — the extras above
|
|
@@ -62,6 +64,19 @@ cv.feedback(query, answer, was_correct=True, similarity_score=0.86)
|
|
|
62
64
|
| `model_version` | `"stock"`, `"v<id>"` (fine-tuned), `"cold_start_fail_closed"` / `"cold_start_auto_pending"`, or `"verify_unavailable"` |
|
|
63
65
|
| `latency_ms` | server-side inference time |
|
|
64
66
|
| `degraded` | `True` when the call failed and this result was synthesized client-side |
|
|
67
|
+
| `notice` | why no model decided the call (cold start); `None` otherwise |
|
|
68
|
+
| `shadow` | cold start only: a `ShadowVerdict` with what the shared stock model would have decided. The call is still rejected, so treat it as a preview |
|
|
69
|
+
|
|
70
|
+
A new account starts in cold start: every call returns `approved=False` until a
|
|
71
|
+
Health Check shows the shared model is accurate enough on your data. Look at
|
|
72
|
+
`result.shadow` to see what it would have decided in the meantime:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
result = cv.verify("when does my flight board", "Boarding starts 40 minutes before departure.")
|
|
76
|
+
if result.shadow:
|
|
77
|
+
print(result.notice)
|
|
78
|
+
print("stock model would approve:", result.shadow.approved, result.shadow.score)
|
|
79
|
+
```
|
|
65
80
|
|
|
66
81
|
## On your request path
|
|
67
82
|
|
|
@@ -107,6 +122,30 @@ evaluator.report_feedback(query, answer, was_correct=False, similarity_score=0.9
|
|
|
107
122
|
|
|
108
123
|
See [`examples/gptcache_example.py`](examples/gptcache_example.py).
|
|
109
124
|
|
|
125
|
+
## LangChain
|
|
126
|
+
|
|
127
|
+
`VerifiedCache` wraps any LangChain cache, typically a semantic one, and only
|
|
128
|
+
serves a hit after CacheVerifier approves it; a rejected hit is a miss, so
|
|
129
|
+
your model is called as usual. For chat models it verifies against the last
|
|
130
|
+
user message.
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
pip install "cacheverifier[langchain]"
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
from langchain_core.globals import set_llm_cache
|
|
138
|
+
from langchain_redis import RedisSemanticCache
|
|
139
|
+
from cacheverifier.integrations.langchain import VerifiedCache
|
|
140
|
+
|
|
141
|
+
set_llm_cache(
|
|
142
|
+
VerifiedCache(
|
|
143
|
+
RedisSemanticCache(redis_url="redis://localhost:6379", embeddings=embeddings),
|
|
144
|
+
api_key="cv_...",
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
```
|
|
148
|
+
|
|
110
149
|
## Fine-tuning
|
|
111
150
|
|
|
112
151
|
Once you have ~20+ feedback rows (the service found fine-tuning is often a net negative
|
|
@@ -126,6 +165,40 @@ if job.get("result_model_version"):
|
|
|
126
165
|
`cv.dry_run([...])` reports the same baseline-vs-tuned AUC on examples you pass directly,
|
|
127
166
|
without writing anything or deploying a model.
|
|
128
167
|
|
|
168
|
+
## Local scoring (Managed plans)
|
|
169
|
+
|
|
170
|
+
`LocalVerifier` runs the model CacheVerifier fine-tuned for you on your own
|
|
171
|
+
machines: no network round trip on your request path, and your query and
|
|
172
|
+
answer texts stay with you. Fine-tuning, drift monitoring, audits and
|
|
173
|
+
certification keep running on the service.
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
from cacheverifier import LocalVerifier
|
|
177
|
+
|
|
178
|
+
cv = LocalVerifier(api_key="cv_...") # downloads your live model in the background
|
|
179
|
+
r = cv.verify(query, cached_answer, similarity_score=0.87, cached_query=cached_question)
|
|
180
|
+
if r.approved:
|
|
181
|
+
... # serve from cache
|
|
182
|
+
else:
|
|
183
|
+
answer = call_llm(query)
|
|
184
|
+
if r.audit: # a randomized audit: report whether the cached answer was right
|
|
185
|
+
cv.feedback(query, cached_answer, was_correct=same_answer(answer, cached_answer), result=r)
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
- It decides exactly as the hosted `/v1/verify` would with that model: every download
|
|
189
|
+
carries reference verdicts from the service, and a model that doesn't reproduce them
|
|
190
|
+
locally is not used.
|
|
191
|
+
- When a newer model goes live (after a fine-tune or a scheduled recalibration), it is
|
|
192
|
+
downloaded, checked, and switched to without a restart.
|
|
193
|
+
- While auditing is on, each decision is uploaded as scores, verdicts and a keyed hash of
|
|
194
|
+
the pair, never the texts, so audits and `/v1/monitor/risk` work as with the hosted API.
|
|
195
|
+
- Until a model is ready, calls go to the hosted API (`fallback="hosted"`, the default)
|
|
196
|
+
or are rejected (`fallback="reject"`). Pass `wait=True` to block until the first
|
|
197
|
+
download finishes (a model is ~330MB).
|
|
198
|
+
- The audit draw happens in this client. The risk bound assumes it is independent of
|
|
199
|
+
whether the answer is right, which the client guarantees unless it is modified.
|
|
200
|
+
- Not supported yet: the online ACI threshold (local scoring uses the model's fixed one).
|
|
201
|
+
|
|
129
202
|
## Local Health Check (offline)
|
|
130
203
|
|
|
131
204
|
`cv.dry_run()` still uploads your examples to the API. If that's a blocker — a
|
|
@@ -187,7 +260,7 @@ verdict
|
|
|
187
260
|
|
|
188
261
|
| method | endpoint |
|
|
189
262
|
|---|---|
|
|
190
|
-
| `verify(query, candidate_answer)` | `POST /v1/verify` |
|
|
263
|
+
| `verify(query, candidate_answer, similarity_score=..., cached_query=...)` | `POST /v1/verify` |
|
|
191
264
|
| `verify_batch(pairs)` | `POST /v1/verify/batch` |
|
|
192
265
|
| `feedback(...)` / `feedback_batch(items)` | `POST /v1/feedback` / `/batch` |
|
|
193
266
|
| `finetune(...)` / `dry_run(examples, ...)` | `POST /v1/finetune/jobs` / `/dry-run` |
|
|
@@ -196,6 +269,8 @@ verdict
|
|
|
196
269
|
| `drift_status()` | `GET /v1/monitor/drift-status` |
|
|
197
270
|
| `gray_zone_threshold()` | `GET /v1/monitor/gray-zone-threshold` |
|
|
198
271
|
| `usage()` / `savings()` | `GET /v1/usage/status` / `/savings` |
|
|
272
|
+
| `LocalVerifier.verify(...)` / `verify_batch(...)` | local; decisions to `POST /v1/verify/decisions` |
|
|
273
|
+
| `LocalVerifier.refresh()` | `GET /v1/models/active` + `/v1/models/{id}/files/{name}` |
|
|
199
274
|
|
|
200
275
|
Non-2xx responses raise `CacheVerifierError` (`.status_code`, `.detail`).
|
|
201
276
|
|
|
@@ -16,7 +16,16 @@ Docs: https://www.cacheverifier.com/docs
|
|
|
16
16
|
Research behind it: https://github.com/imxinchengyou/CacheVerifier
|
|
17
17
|
"""
|
|
18
18
|
|
|
19
|
-
from cacheverifier.client import CacheVerifier, CacheVerifierError, VerifyResult
|
|
19
|
+
from cacheverifier.client import CacheVerifier, CacheVerifierError, ShadowVerdict, VerifyResult
|
|
20
|
+
from cacheverifier.local import LocalVerifier, ModelCheckFailed
|
|
20
21
|
|
|
21
|
-
__version__ = "0.
|
|
22
|
-
__all__ = [
|
|
22
|
+
__version__ = "0.6.0"
|
|
23
|
+
__all__ = [
|
|
24
|
+
"CacheVerifier",
|
|
25
|
+
"CacheVerifierError",
|
|
26
|
+
"LocalVerifier",
|
|
27
|
+
"ModelCheckFailed",
|
|
28
|
+
"ShadowVerdict",
|
|
29
|
+
"VerifyResult",
|
|
30
|
+
"__version__",
|
|
31
|
+
]
|
|
@@ -25,6 +25,7 @@ from sentence_transformers import CrossEncoder, InputExample
|
|
|
25
25
|
from torch.utils.data import DataLoader
|
|
26
26
|
|
|
27
27
|
from cacheverifier._healthcheck._base_models import DEFAULT_BASE_MODEL
|
|
28
|
+
from cacheverifier._inference import MAX_SEQUENCE_LENGTH, smart_truncate_pair
|
|
28
29
|
|
|
29
30
|
# The four third-party imports above are from the `healthcheck` extra. This module is
|
|
30
31
|
# only ever imported from inside `cacheverifier._healthcheck.cli.run()`,
|
|
@@ -36,13 +37,11 @@ NLI_LABEL_ORDER = ("contradiction", "entailment", "neutral")
|
|
|
36
37
|
"""`cross-encoder/nli-*` models' class order, used when the loaded model
|
|
37
38
|
doesn't expose an `id2label` naming them."""
|
|
38
39
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
_CONTENT_TOKEN_BUDGET = MAX_SEQUENCE_LENGTH - 3 # [CLS] + 2x [SEP]
|
|
45
|
-
_MIN_QUERY_TOKENS = 32
|
|
40
|
+
# Token cap and truncation shared with local scoring, so a Health Check
|
|
41
|
+
# trains with exactly what the service and `LocalVerifier` score with. Before
|
|
42
|
+
# 0.6.0 this module kept its own copy of the truncation, which had fallen
|
|
43
|
+
# behind the service's 0.7.0 change (a long query next to a short answer
|
|
44
|
+
# was cut to half the budget).
|
|
46
45
|
|
|
47
46
|
TRAIN_BATCH_SIZE = 4
|
|
48
47
|
TRAIN_EPOCHS = 3
|
|
@@ -102,34 +101,6 @@ class HealthCheckResult:
|
|
|
102
101
|
return self.auc_tuned - self.auc_baseline
|
|
103
102
|
|
|
104
103
|
|
|
105
|
-
def _head_tail_truncate(ids: list[int], budget: int, head_ratio: float = 0.6) -> list[int]:
|
|
106
|
-
if len(ids) <= budget:
|
|
107
|
-
return ids
|
|
108
|
-
head_n = int(budget * head_ratio)
|
|
109
|
-
tail_n = budget - head_n
|
|
110
|
-
return ids[:head_n] + (ids[-tail_n:] if tail_n > 0 else [])
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def smart_truncate_pair(query: str, candidate_answer: str, tokenizer: Any) -> tuple[str, str]:
|
|
114
|
-
"""Tokenizer-aware head+tail truncation, applied identically at train
|
|
115
|
-
and score time (a model trained on one truncation policy and scored
|
|
116
|
-
with another sees a different token window than it was optimized
|
|
117
|
-
against). A no-op whenever the pair already fits."""
|
|
118
|
-
query_ids = tokenizer.encode(query, add_special_tokens=False)
|
|
119
|
-
answer_ids = tokenizer.encode(candidate_answer, add_special_tokens=False)
|
|
120
|
-
if len(query_ids) + len(answer_ids) <= _CONTENT_TOKEN_BUDGET:
|
|
121
|
-
return query, candidate_answer
|
|
122
|
-
|
|
123
|
-
query_budget = min(len(query_ids), max(_MIN_QUERY_TOKENS, _CONTENT_TOKEN_BUDGET // 2))
|
|
124
|
-
answer_budget = _CONTENT_TOKEN_BUDGET - query_budget
|
|
125
|
-
query_ids = _head_tail_truncate(query_ids, query_budget)
|
|
126
|
-
answer_ids = _head_tail_truncate(answer_ids, answer_budget)
|
|
127
|
-
return (
|
|
128
|
-
tokenizer.decode(query_ids, skip_special_tokens=True),
|
|
129
|
-
tokenizer.decode(answer_ids, skip_special_tokens=True),
|
|
130
|
-
)
|
|
131
|
-
|
|
132
|
-
|
|
133
104
|
def roc_auc(scores: np.ndarray, labels: np.ndarray) -> float:
|
|
134
105
|
"""Rank-based AUC, no sklearn dependency. NaN if either class is absent."""
|
|
135
106
|
order = np.argsort(scores)
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""The scoring and decision steps the hosted service applies to one
|
|
2
|
+
(query, candidate_answer) pair, for running a downloaded model locally.
|
|
3
|
+
|
|
4
|
+
A port of the hosted service's inference path: input composition with the
|
|
5
|
+
`cached_query` diff, tokenizer-aware truncation, and the threshold / joint
|
|
6
|
+
decision rules. Training, certification and monitoring stay on the service.
|
|
7
|
+
Pure Python (the tokenizer is passed in), so it imports without the `local`
|
|
8
|
+
extra.
|
|
9
|
+
|
|
10
|
+
Kept in step with the service by two checks: the service's own test suite
|
|
11
|
+
compares these functions with its implementation, and every model download
|
|
12
|
+
carries reference verdicts that `LocalVerifier` reproduces before it decides
|
|
13
|
+
anything (see `cacheverifier.local`).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import difflib
|
|
19
|
+
import math
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
MAX_SEQUENCE_LENGTH = 128
|
|
24
|
+
"""Token cap applied at train and score time, as on the service."""
|
|
25
|
+
|
|
26
|
+
CONTENT_TOKEN_BUDGET = MAX_SEQUENCE_LENGTH - 3 # [CLS] + 2x [SEP]
|
|
27
|
+
MIN_QUERY_TOKENS = 32
|
|
28
|
+
QUERY_DIFF_MAX_WORDS = 12
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def query_diff_summary(current_query: str, cached_query: str) -> str:
|
|
32
|
+
"""Word-level diff of the cache entry's own query against the request's,
|
|
33
|
+
e.g. `removed: pause | added: cancel`."""
|
|
34
|
+
old, new = cached_query.split(), current_query.split()
|
|
35
|
+
sm = difflib.SequenceMatcher(a=old, b=new, autojunk=False)
|
|
36
|
+
removed: list[str] = []
|
|
37
|
+
added: list[str] = []
|
|
38
|
+
for tag, i1, i2, j1, j2 in sm.get_opcodes():
|
|
39
|
+
if tag in ("replace", "delete"):
|
|
40
|
+
removed.extend(old[i1:i2])
|
|
41
|
+
if tag in ("replace", "insert"):
|
|
42
|
+
added.extend(new[j1:j2])
|
|
43
|
+
parts = []
|
|
44
|
+
if removed:
|
|
45
|
+
parts.append("removed: " + " ".join(removed[:QUERY_DIFF_MAX_WORDS]))
|
|
46
|
+
if added:
|
|
47
|
+
parts.append("added: " + " ".join(added[:QUERY_DIFF_MAX_WORDS]))
|
|
48
|
+
return " | ".join(parts) if parts else "no change"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def compose_query_input(current_query: str, cached_query: str | None) -> str:
|
|
52
|
+
if not cached_query:
|
|
53
|
+
return current_query
|
|
54
|
+
return f"{current_query} [qdiff] {query_diff_summary(current_query, cached_query)}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _head_tail_truncate(ids: list[int], budget: int, head_ratio: float = 0.6) -> list[int]:
|
|
58
|
+
if len(ids) <= budget:
|
|
59
|
+
return ids
|
|
60
|
+
head_n = int(budget * head_ratio)
|
|
61
|
+
tail_n = budget - head_n
|
|
62
|
+
return ids[:head_n] + (ids[-tail_n:] if tail_n > 0 else [])
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def smart_truncate_pair(query: str, candidate_answer: str, tokenizer: Any) -> tuple[str, str]:
|
|
66
|
+
"""Tokenizer-aware head+tail truncation, a no-op whenever the pair fits.
|
|
67
|
+
The answer gets at most half the budget while the query wants more; the
|
|
68
|
+
query gets whatever the answer doesn't need."""
|
|
69
|
+
query_ids = tokenizer.encode(query, add_special_tokens=False)
|
|
70
|
+
answer_ids = tokenizer.encode(candidate_answer, add_special_tokens=False)
|
|
71
|
+
if len(query_ids) + len(answer_ids) <= CONTENT_TOKEN_BUDGET:
|
|
72
|
+
return query, candidate_answer
|
|
73
|
+
half = CONTENT_TOKEN_BUDGET // 2
|
|
74
|
+
query_budget = min(len(query_ids), max(MIN_QUERY_TOKENS, half, CONTENT_TOKEN_BUDGET - len(answer_ids)))
|
|
75
|
+
answer_budget = CONTENT_TOKEN_BUDGET - query_budget
|
|
76
|
+
query_ids = _head_tail_truncate(query_ids, query_budget)
|
|
77
|
+
answer_ids = _head_tail_truncate(answer_ids, answer_budget)
|
|
78
|
+
return (
|
|
79
|
+
tokenizer.decode(query_ids, skip_special_tokens=True),
|
|
80
|
+
tokenizer.decode(answer_ids, skip_special_tokens=True),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _sigmoid(x: float) -> float:
|
|
85
|
+
if x >= 0.0:
|
|
86
|
+
return 1.0 / (1.0 + math.exp(-x))
|
|
87
|
+
e = math.exp(x)
|
|
88
|
+
return e / (1.0 + e)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class JointRule:
|
|
93
|
+
"""A fitted joint decision rule, as the service stores it: approve iff
|
|
94
|
+
the verifier score reaches `verifier_floor` (when set) and the fused
|
|
95
|
+
probability over the standardized similarity, verifier score (and, with
|
|
96
|
+
`uses_gap`, the gap to the runner-up match) reaches `threshold`."""
|
|
97
|
+
|
|
98
|
+
coef_similarity: float
|
|
99
|
+
coef_verifier: float
|
|
100
|
+
intercept: float
|
|
101
|
+
similarity_mean: float
|
|
102
|
+
similarity_std: float
|
|
103
|
+
verifier_mean: float
|
|
104
|
+
verifier_std: float
|
|
105
|
+
threshold: float
|
|
106
|
+
coef_gap: float = 0.0
|
|
107
|
+
gap_mean: float = 0.0
|
|
108
|
+
gap_std: float = 1.0
|
|
109
|
+
uses_gap: bool = False
|
|
110
|
+
verifier_floor: float | None = None
|
|
111
|
+
|
|
112
|
+
def approves(
|
|
113
|
+
self, verifier_score: float, similarity_score: float, runner_up_similarity_score: float | None = None
|
|
114
|
+
) -> bool:
|
|
115
|
+
if self.verifier_floor is not None and verifier_score < self.verifier_floor:
|
|
116
|
+
return False
|
|
117
|
+
z_sim = (similarity_score - self.similarity_mean) / self.similarity_std
|
|
118
|
+
z_ver = (verifier_score - self.verifier_mean) / self.verifier_std
|
|
119
|
+
logit = self.coef_similarity * z_sim + self.coef_verifier * z_ver + self.intercept
|
|
120
|
+
if self.uses_gap:
|
|
121
|
+
if runner_up_similarity_score is None:
|
|
122
|
+
raise ValueError("this joint rule uses the similarity gap; runner_up_similarity_score is required")
|
|
123
|
+
z_gap = ((similarity_score - runner_up_similarity_score) - self.gap_mean) / self.gap_std
|
|
124
|
+
logit += self.coef_gap * z_gap
|
|
125
|
+
return _sigmoid(logit) >= self.threshold
|
|
126
|
+
|
|
127
|
+
@classmethod
|
|
128
|
+
def from_dict(cls, d: dict[str, Any]) -> JointRule:
|
|
129
|
+
floor = d.get("verifier_floor")
|
|
130
|
+
return cls(
|
|
131
|
+
coef_similarity=float(d["coef_similarity"]),
|
|
132
|
+
coef_verifier=float(d["coef_verifier"]),
|
|
133
|
+
intercept=float(d["intercept"]),
|
|
134
|
+
similarity_mean=float(d["similarity_mean"]),
|
|
135
|
+
similarity_std=float(d["similarity_std"]),
|
|
136
|
+
verifier_mean=float(d["verifier_mean"]),
|
|
137
|
+
verifier_std=float(d["verifier_std"]),
|
|
138
|
+
threshold=float(d["threshold"]),
|
|
139
|
+
coef_gap=float(d.get("coef_gap", 0.0)),
|
|
140
|
+
gap_mean=float(d.get("gap_mean", 0.0)),
|
|
141
|
+
gap_std=float(d.get("gap_std", 1.0)),
|
|
142
|
+
uses_gap=bool(d.get("uses_gap", False)),
|
|
143
|
+
verifier_floor=float(floor) if floor is not None else None,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def decide(
|
|
148
|
+
score: float,
|
|
149
|
+
threshold: float,
|
|
150
|
+
joint_rule: JointRule | None,
|
|
151
|
+
joint_rule_gap: JointRule | None,
|
|
152
|
+
similarity_score: float | None,
|
|
153
|
+
runner_up_similarity_score: float | None,
|
|
154
|
+
) -> tuple[bool, str]:
|
|
155
|
+
"""(approved, decision_rule), with the service's precedence: the gap
|
|
156
|
+
rule when both similarity scores are given, then the 2-feature rule
|
|
157
|
+
when the similarity is, else the plain threshold."""
|
|
158
|
+
if joint_rule_gap is not None and similarity_score is not None and runner_up_similarity_score is not None:
|
|
159
|
+
return joint_rule_gap.approves(score, similarity_score, runner_up_similarity_score), "joint_gap"
|
|
160
|
+
if joint_rule is not None and similarity_score is not None:
|
|
161
|
+
return joint_rule.approves(score, similarity_score), "joint"
|
|
162
|
+
return score >= threshold, "threshold"
|