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.
Files changed (28) hide show
  1. {cacheverifier-0.4.1 → cacheverifier-0.6.0}/CHANGELOG.md +37 -0
  2. {cacheverifier-0.4.1 → cacheverifier-0.6.0}/PKG-INFO +85 -3
  3. {cacheverifier-0.4.1 → cacheverifier-0.6.0}/README.md +76 -1
  4. {cacheverifier-0.4.1 → cacheverifier-0.6.0}/cacheverifier/__init__.py +12 -3
  5. {cacheverifier-0.4.1 → cacheverifier-0.6.0}/cacheverifier/_healthcheck/_finetune.py +6 -35
  6. cacheverifier-0.6.0/cacheverifier/_inference.py +162 -0
  7. {cacheverifier-0.4.1 → cacheverifier-0.6.0}/cacheverifier/client.py +83 -4
  8. cacheverifier-0.6.0/cacheverifier/integrations/langchain.py +140 -0
  9. cacheverifier-0.6.0/cacheverifier/local.py +502 -0
  10. {cacheverifier-0.4.1 → cacheverifier-0.6.0}/pyproject.toml +10 -3
  11. {cacheverifier-0.4.1 → cacheverifier-0.6.0}/tests/test_client.py +27 -1
  12. cacheverifier-0.6.0/tests/test_langchain_integration.py +90 -0
  13. cacheverifier-0.6.0/tests/test_local.py +251 -0
  14. {cacheverifier-0.4.1 → cacheverifier-0.6.0}/.github/workflows/ci.yml +0 -0
  15. {cacheverifier-0.4.1 → cacheverifier-0.6.0}/.github/workflows/publish.yml +0 -0
  16. {cacheverifier-0.4.1 → cacheverifier-0.6.0}/.gitignore +0 -0
  17. {cacheverifier-0.4.1 → cacheverifier-0.6.0}/LICENSE +0 -0
  18. {cacheverifier-0.4.1 → cacheverifier-0.6.0}/cacheverifier/__main__.py +0 -0
  19. {cacheverifier-0.4.1 → cacheverifier-0.6.0}/cacheverifier/_healthcheck/__init__.py +0 -0
  20. {cacheverifier-0.4.1 → cacheverifier-0.6.0}/cacheverifier/_healthcheck/_base_models.py +0 -0
  21. {cacheverifier-0.4.1 → cacheverifier-0.6.0}/cacheverifier/_healthcheck/cli.py +0 -0
  22. {cacheverifier-0.4.1 → cacheverifier-0.6.0}/cacheverifier/integrations/__init__.py +0 -0
  23. {cacheverifier-0.4.1 → cacheverifier-0.6.0}/cacheverifier/integrations/gptcache.py +0 -0
  24. {cacheverifier-0.4.1 → cacheverifier-0.6.0}/cacheverifier/py.typed +0 -0
  25. {cacheverifier-0.4.1 → cacheverifier-0.6.0}/examples/gptcache_example.py +0 -0
  26. {cacheverifier-0.4.1 → cacheverifier-0.6.0}/examples/quickstart.py +0 -0
  27. {cacheverifier-0.4.1 → cacheverifier-0.6.0}/tests/test_gptcache_integration.py +0 -0
  28. {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.1
4
- Summary: Python client for the hosted CacheVerifier semantic-cache verification API
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.4.1"
22
- __all__ = ["CacheVerifier", "CacheVerifierError", "VerifyResult", "__version__"]
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
- MAX_SEQUENCE_LENGTH = 128
40
- """Token cap applied identically at train and score time. Matches the
41
- hosted service; BERT-style attention memory is O(n^2) in sequence length,
42
- and this keeps a CPU run well-behaved."""
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"