cacheverifier 0.5.0__tar.gz → 0.7.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.5.0 → cacheverifier-0.7.0}/CHANGELOG.md +28 -0
  2. {cacheverifier-0.5.0 → cacheverifier-0.7.0}/PKG-INFO +59 -3
  3. {cacheverifier-0.5.0 → cacheverifier-0.7.0}/README.md +53 -1
  4. {cacheverifier-0.5.0 → cacheverifier-0.7.0}/cacheverifier/__init__.py +11 -2
  5. {cacheverifier-0.5.0 → cacheverifier-0.7.0}/cacheverifier/_healthcheck/_finetune.py +6 -35
  6. cacheverifier-0.7.0/cacheverifier/_inference.py +162 -0
  7. {cacheverifier-0.5.0 → cacheverifier-0.7.0}/cacheverifier/client.py +72 -5
  8. cacheverifier-0.7.0/cacheverifier/local.py +504 -0
  9. {cacheverifier-0.5.0 → cacheverifier-0.7.0}/pyproject.toml +8 -2
  10. {cacheverifier-0.5.0 → cacheverifier-0.7.0}/tests/test_client.py +24 -0
  11. cacheverifier-0.7.0/tests/test_local.py +251 -0
  12. {cacheverifier-0.5.0 → cacheverifier-0.7.0}/.github/workflows/ci.yml +0 -0
  13. {cacheverifier-0.5.0 → cacheverifier-0.7.0}/.github/workflows/publish.yml +0 -0
  14. {cacheverifier-0.5.0 → cacheverifier-0.7.0}/.gitignore +0 -0
  15. {cacheverifier-0.5.0 → cacheverifier-0.7.0}/LICENSE +0 -0
  16. {cacheverifier-0.5.0 → cacheverifier-0.7.0}/cacheverifier/__main__.py +0 -0
  17. {cacheverifier-0.5.0 → cacheverifier-0.7.0}/cacheverifier/_healthcheck/__init__.py +0 -0
  18. {cacheverifier-0.5.0 → cacheverifier-0.7.0}/cacheverifier/_healthcheck/_base_models.py +0 -0
  19. {cacheverifier-0.5.0 → cacheverifier-0.7.0}/cacheverifier/_healthcheck/cli.py +0 -0
  20. {cacheverifier-0.5.0 → cacheverifier-0.7.0}/cacheverifier/integrations/__init__.py +0 -0
  21. {cacheverifier-0.5.0 → cacheverifier-0.7.0}/cacheverifier/integrations/gptcache.py +0 -0
  22. {cacheverifier-0.5.0 → cacheverifier-0.7.0}/cacheverifier/integrations/langchain.py +0 -0
  23. {cacheverifier-0.5.0 → cacheverifier-0.7.0}/cacheverifier/py.typed +0 -0
  24. {cacheverifier-0.5.0 → cacheverifier-0.7.0}/examples/gptcache_example.py +0 -0
  25. {cacheverifier-0.5.0 → cacheverifier-0.7.0}/examples/quickstart.py +0 -0
  26. {cacheverifier-0.5.0 → cacheverifier-0.7.0}/tests/test_gptcache_integration.py +0 -0
  27. {cacheverifier-0.5.0 → cacheverifier-0.7.0}/tests/test_healthcheck.py +0 -0
  28. {cacheverifier-0.5.0 → cacheverifier-0.7.0}/tests/test_langchain_integration.py +0 -0
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.0
4
+
5
+ `entries_to_refresh()` lists the cache entries your feedback reported stale,
6
+ or wrong more often than right, and `resolve_entry(key)` marks one refreshed
7
+ (`GET /v1/monitor/entries-to-refresh`, every plan). `feedback()` (on both
8
+ `CacheVerifier` and `LocalVerifier`) takes `cache_entry_id`, your own key for
9
+ the entry, which the list hands back so you can refresh or drop it directly.
10
+
11
+ ## 0.6.0
12
+
13
+ New `LocalVerifier` (`pip install "cacheverifier[local]"`, Managed plans):
14
+ scores gray-zone hits on your own machines with the model CacheVerifier
15
+ fine-tuned for you, deciding exactly as the hosted `/v1/verify` would. It
16
+ downloads the live model, checks it against reference verdicts from the
17
+ service before using it, switches to newer models as they go live, and, while
18
+ auditing is on, uploads each decision without the texts so audits and risk
19
+ monitoring keep working. Falls back to the hosted API until a model is ready.
20
+
21
+ `CacheVerifier.verify()` takes `similarity_score`, `cached_query` and
22
+ `runner_up_similarity_score` (all optional), which a fine-tuned model's joint
23
+ rules use; before this the client couldn't send them. `feedback()` takes
24
+ `cached_query` and `verification_id`. `VerifyResult` gains `decision_rule`,
25
+ `audit` and `verification_id`, which the API already returned.
26
+
27
+ The offline Health Check now truncates long inputs exactly as the service
28
+ does since its verifier-core 0.7.0: a long query next to a short answer keeps
29
+ the room the answer doesn't need, instead of being cut to half the budget.
30
+
3
31
  ## 0.5.0
4
32
 
5
33
  New LangChain integration: `cacheverifier.integrations.langchain.VerifiedCache`
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cacheverifier
3
- Version: 0.5.0
4
- Summary: Python client for the hosted CacheVerifier semantic-cache verification API
3
+ Version: 0.7.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
@@ -31,6 +31,10 @@ Requires-Dist: sentence-transformers<4,>=3.0; extra == 'healthcheck'
31
31
  Requires-Dist: torch>=2.2; extra == 'healthcheck'
32
32
  Provides-Extra: langchain
33
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'
34
38
  Description-Content-Type: text/markdown
35
39
 
36
40
  # cacheverifier
@@ -60,6 +64,8 @@ pip install cacheverifier
60
64
  pip install "cacheverifier[gptcache]"
61
65
  # with the offline Health Check (adds torch + sentence-transformers):
62
66
  pip install "cacheverifier[healthcheck]"
67
+ # to score with your fine-tuned model on your own machines (same deps):
68
+ pip install "cacheverifier[local]"
63
69
  ```
64
70
 
65
71
  Requires Python 3.9+. The only runtime dependency is `httpx` — the extras above
@@ -179,6 +185,19 @@ set_llm_cache(
179
185
  )
180
186
  ```
181
187
 
188
+ ## Entries to refresh
189
+
190
+ Send your cache's own key with feedback, and the service tells you which
191
+ entries to fix: ones reported stale, or wrong more often than right.
192
+
193
+ ```python
194
+ cv.feedback(query, cached_answer, was_correct=False, stale=True, cache_entry_id="faq:shipping")
195
+
196
+ for e in cv.entries_to_refresh():
197
+ my_cache.delete(e["cache_entry_id"]) # or regenerate the answer
198
+ cv.resolve_entry(e["key"])
199
+ ```
200
+
182
201
  ## Fine-tuning
183
202
 
184
203
  Once you have ~20+ feedback rows (the service found fine-tuning is often a net negative
@@ -198,6 +217,40 @@ if job.get("result_model_version"):
198
217
  `cv.dry_run([...])` reports the same baseline-vs-tuned AUC on examples you pass directly,
199
218
  without writing anything or deploying a model.
200
219
 
220
+ ## Local scoring (Managed plans)
221
+
222
+ `LocalVerifier` runs the model CacheVerifier fine-tuned for you on your own
223
+ machines: no network round trip on your request path, and your query and
224
+ answer texts stay with you. Fine-tuning, drift monitoring, audits and
225
+ certification keep running on the service.
226
+
227
+ ```python
228
+ from cacheverifier import LocalVerifier
229
+
230
+ cv = LocalVerifier(api_key="cv_...") # downloads your live model in the background
231
+ r = cv.verify(query, cached_answer, similarity_score=0.87, cached_query=cached_question)
232
+ if r.approved:
233
+ ... # serve from cache
234
+ else:
235
+ answer = call_llm(query)
236
+ if r.audit: # a randomized audit: report whether the cached answer was right
237
+ cv.feedback(query, cached_answer, was_correct=same_answer(answer, cached_answer), result=r)
238
+ ```
239
+
240
+ - It decides exactly as the hosted `/v1/verify` would with that model: every download
241
+ carries reference verdicts from the service, and a model that doesn't reproduce them
242
+ locally is not used.
243
+ - When a newer model goes live (after a fine-tune or a scheduled recalibration), it is
244
+ downloaded, checked, and switched to without a restart.
245
+ - While auditing is on, each decision is uploaded as scores, verdicts and a keyed hash of
246
+ the pair, never the texts, so audits and `/v1/monitor/risk` work as with the hosted API.
247
+ - Until a model is ready, calls go to the hosted API (`fallback="hosted"`, the default)
248
+ or are rejected (`fallback="reject"`). Pass `wait=True` to block until the first
249
+ download finishes (a model is ~330MB).
250
+ - The audit draw happens in this client. The risk bound assumes it is independent of
251
+ whether the answer is right, which the client guarantees unless it is modified.
252
+ - Not supported yet: the online ACI threshold (local scoring uses the model's fixed one).
253
+
201
254
  ## Local Health Check (offline)
202
255
 
203
256
  `cv.dry_run()` still uploads your examples to the API. If that's a blocker — a
@@ -259,15 +312,18 @@ verdict
259
312
 
260
313
  | method | endpoint |
261
314
  |---|---|
262
- | `verify(query, candidate_answer)` | `POST /v1/verify` |
315
+ | `verify(query, candidate_answer, similarity_score=..., cached_query=...)` | `POST /v1/verify` |
263
316
  | `verify_batch(pairs)` | `POST /v1/verify/batch` |
264
317
  | `feedback(...)` / `feedback_batch(items)` | `POST /v1/feedback` / `/batch` |
265
318
  | `finetune(...)` / `dry_run(examples, ...)` | `POST /v1/finetune/jobs` / `/dry-run` |
266
319
  | `get_finetune_job(id)` / `list_finetune_jobs()` | `GET /v1/finetune/jobs[/id]` |
267
320
  | `activate_model_version(id)` | `POST /v1/finetune/model-versions/{id}/activate` |
268
321
  | `drift_status()` | `GET /v1/monitor/drift-status` |
322
+ | `entries_to_refresh()` / `resolve_entry(key)` | `GET /v1/monitor/entries-to-refresh` / `POST .../{key}/resolve` |
269
323
  | `gray_zone_threshold()` | `GET /v1/monitor/gray-zone-threshold` |
270
324
  | `usage()` / `savings()` | `GET /v1/usage/status` / `/savings` |
325
+ | `LocalVerifier.verify(...)` / `verify_batch(...)` | local; decisions to `POST /v1/verify/decisions` |
326
+ | `LocalVerifier.refresh()` | `GET /v1/models/active` + `/v1/models/{id}/files/{name}` |
271
327
 
272
328
  Non-2xx responses raise `CacheVerifierError` (`.status_code`, `.detail`).
273
329
 
@@ -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
@@ -144,6 +146,19 @@ set_llm_cache(
144
146
  )
145
147
  ```
146
148
 
149
+ ## Entries to refresh
150
+
151
+ Send your cache's own key with feedback, and the service tells you which
152
+ entries to fix: ones reported stale, or wrong more often than right.
153
+
154
+ ```python
155
+ cv.feedback(query, cached_answer, was_correct=False, stale=True, cache_entry_id="faq:shipping")
156
+
157
+ for e in cv.entries_to_refresh():
158
+ my_cache.delete(e["cache_entry_id"]) # or regenerate the answer
159
+ cv.resolve_entry(e["key"])
160
+ ```
161
+
147
162
  ## Fine-tuning
148
163
 
149
164
  Once you have ~20+ feedback rows (the service found fine-tuning is often a net negative
@@ -163,6 +178,40 @@ if job.get("result_model_version"):
163
178
  `cv.dry_run([...])` reports the same baseline-vs-tuned AUC on examples you pass directly,
164
179
  without writing anything or deploying a model.
165
180
 
181
+ ## Local scoring (Managed plans)
182
+
183
+ `LocalVerifier` runs the model CacheVerifier fine-tuned for you on your own
184
+ machines: no network round trip on your request path, and your query and
185
+ answer texts stay with you. Fine-tuning, drift monitoring, audits and
186
+ certification keep running on the service.
187
+
188
+ ```python
189
+ from cacheverifier import LocalVerifier
190
+
191
+ cv = LocalVerifier(api_key="cv_...") # downloads your live model in the background
192
+ r = cv.verify(query, cached_answer, similarity_score=0.87, cached_query=cached_question)
193
+ if r.approved:
194
+ ... # serve from cache
195
+ else:
196
+ answer = call_llm(query)
197
+ if r.audit: # a randomized audit: report whether the cached answer was right
198
+ cv.feedback(query, cached_answer, was_correct=same_answer(answer, cached_answer), result=r)
199
+ ```
200
+
201
+ - It decides exactly as the hosted `/v1/verify` would with that model: every download
202
+ carries reference verdicts from the service, and a model that doesn't reproduce them
203
+ locally is not used.
204
+ - When a newer model goes live (after a fine-tune or a scheduled recalibration), it is
205
+ downloaded, checked, and switched to without a restart.
206
+ - While auditing is on, each decision is uploaded as scores, verdicts and a keyed hash of
207
+ the pair, never the texts, so audits and `/v1/monitor/risk` work as with the hosted API.
208
+ - Until a model is ready, calls go to the hosted API (`fallback="hosted"`, the default)
209
+ or are rejected (`fallback="reject"`). Pass `wait=True` to block until the first
210
+ download finishes (a model is ~330MB).
211
+ - The audit draw happens in this client. The risk bound assumes it is independent of
212
+ whether the answer is right, which the client guarantees unless it is modified.
213
+ - Not supported yet: the online ACI threshold (local scoring uses the model's fixed one).
214
+
166
215
  ## Local Health Check (offline)
167
216
 
168
217
  `cv.dry_run()` still uploads your examples to the API. If that's a blocker — a
@@ -224,15 +273,18 @@ verdict
224
273
 
225
274
  | method | endpoint |
226
275
  |---|---|
227
- | `verify(query, candidate_answer)` | `POST /v1/verify` |
276
+ | `verify(query, candidate_answer, similarity_score=..., cached_query=...)` | `POST /v1/verify` |
228
277
  | `verify_batch(pairs)` | `POST /v1/verify/batch` |
229
278
  | `feedback(...)` / `feedback_batch(items)` | `POST /v1/feedback` / `/batch` |
230
279
  | `finetune(...)` / `dry_run(examples, ...)` | `POST /v1/finetune/jobs` / `/dry-run` |
231
280
  | `get_finetune_job(id)` / `list_finetune_jobs()` | `GET /v1/finetune/jobs[/id]` |
232
281
  | `activate_model_version(id)` | `POST /v1/finetune/model-versions/{id}/activate` |
233
282
  | `drift_status()` | `GET /v1/monitor/drift-status` |
283
+ | `entries_to_refresh()` / `resolve_entry(key)` | `GET /v1/monitor/entries-to-refresh` / `POST .../{key}/resolve` |
234
284
  | `gray_zone_threshold()` | `GET /v1/monitor/gray-zone-threshold` |
235
285
  | `usage()` / `savings()` | `GET /v1/usage/status` / `/savings` |
286
+ | `LocalVerifier.verify(...)` / `verify_batch(...)` | local; decisions to `POST /v1/verify/decisions` |
287
+ | `LocalVerifier.refresh()` | `GET /v1/models/active` + `/v1/models/{id}/files/{name}` |
236
288
 
237
289
  Non-2xx responses raise `CacheVerifierError` (`.status_code`, `.detail`).
238
290
 
@@ -17,6 +17,15 @@ Research behind it: https://github.com/imxinchengyou/CacheVerifier
17
17
  """
18
18
 
19
19
  from cacheverifier.client import CacheVerifier, CacheVerifierError, ShadowVerdict, VerifyResult
20
+ from cacheverifier.local import LocalVerifier, ModelCheckFailed
20
21
 
21
- __version__ = "0.5.0"
22
- __all__ = ["CacheVerifier", "CacheVerifierError", "ShadowVerdict", "VerifyResult", "__version__"]
22
+ __version__ = "0.7.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"
@@ -105,6 +105,14 @@ class VerifyResult:
105
105
  - `shadow`: on a cold-start call, what the shared stock model would have
106
106
  decided (a `ShadowVerdict`); None otherwise, and on servers that don't
107
107
  send it.
108
+ - `decision_rule`: `"threshold"`, `"joint"` / `"joint_gap"` (a fitted rule
109
+ that also used your similarity scores), `"aci"`, or `"cold_start"`.
110
+ - `audit`: this approval was drawn for a randomized audit, so `approved`
111
+ is False: call your LLM, serve its answer, and report whether the
112
+ cached answer matched with `feedback(..., verification_id=...)`.
113
+ - `verification_id`: set while auditing is on; pass it to `feedback()`.
114
+ From `LocalVerifier` it is None until the decision has been uploaded:
115
+ pass the result itself as `feedback(..., result=r)` instead.
108
116
  """
109
117
 
110
118
  approved: bool
@@ -115,6 +123,11 @@ class VerifyResult:
115
123
  degraded: bool = False
116
124
  notice: str | None = None
117
125
  shadow: ShadowVerdict | None = None
126
+ decision_rule: str | None = None
127
+ audit: bool = False
128
+ verification_id: int | None = None
129
+ local_decision: str | None = None
130
+ """`LocalVerifier` only: its handle for this decision until upload."""
118
131
 
119
132
  @classmethod
120
133
  def _from_json(cls, d: dict[str, Any]) -> VerifyResult:
@@ -127,6 +140,9 @@ class VerifyResult:
127
140
  latency_ms=float(d["latency_ms"]),
128
141
  notice=d.get("notice"),
129
142
  shadow=ShadowVerdict._from_json(shadow) if shadow else None,
143
+ decision_rule=d.get("decision_rule"),
144
+ audit=bool(d.get("audit", False)),
145
+ verification_id=d.get("verification_id"),
130
146
  )
131
147
 
132
148
 
@@ -194,15 +210,36 @@ class CacheVerifier:
194
210
 
195
211
  # -- core: verify ----------------------------------------------------
196
212
 
197
- def verify(self, query: str, candidate_answer: str) -> VerifyResult:
213
+ def verify(
214
+ self,
215
+ query: str,
216
+ candidate_answer: str,
217
+ *,
218
+ similarity_score: float | None = None,
219
+ cached_query: str | None = None,
220
+ runner_up_similarity_score: float | None = None,
221
+ ) -> VerifyResult:
198
222
  """Approve or reject one gray-zone cache hit. `POST /v1/verify`.
199
223
 
224
+ The optional arguments are what your cache lookup already knows:
225
+ the hit's similarity score, the cached entry's own original query,
226
+ and the second-best match's similarity. A fine-tuned model uses them
227
+ when it learned a rule from them; they never hurt otherwise.
228
+
200
229
  On a timeout / connection error / 5xx / rate-limited 429 this returns a synthetic
201
230
  `VerifyResult` (`degraded=True`, `approved` per `fail_open`) instead of
202
231
  raising -- the verifier being unreachable should degrade to a normal
203
232
  cache miss, not an exception on your request path.
204
233
  """
205
- data = self._verify_call("/v1/verify", {"query": query, "candidate_answer": candidate_answer})
234
+ payload = {"query": query, "candidate_answer": candidate_answer}
235
+ payload.update(
236
+ _drop_none(
237
+ similarity_score=similarity_score,
238
+ cached_query=cached_query,
239
+ runner_up_similarity_score=runner_up_similarity_score,
240
+ )
241
+ )
242
+ data = self._verify_call("/v1/verify", payload)
206
243
  if data is None:
207
244
  return self._degraded_result()
208
245
  return VerifyResult._from_json(data)
@@ -273,6 +310,9 @@ class CacheVerifier:
273
310
  was_correct: bool,
274
311
  *,
275
312
  similarity_score: float | None = None,
313
+ cached_query: str | None = None,
314
+ verification_id: int | None = None,
315
+ cache_entry_id: str | None = None,
276
316
  stale: bool = False,
277
317
  idempotency_key: str | None = None,
278
318
  ) -> int:
@@ -286,6 +326,11 @@ class CacheVerifier:
286
326
  - `similarity_score`: your cache backend's own score for this
287
327
  candidate, if you still have it. Only used by
288
328
  `gray_zone_threshold()`; safe to omit.
329
+ - `cached_query`: the cached entry's own original query, if you have it.
330
+ - `verification_id`: from the `VerifyResult` this feedback is about
331
+ (always for an `audit` result) -- it ties the label to that verdict.
332
+ - `cache_entry_id`: your own key for the cache entry the answer came
333
+ from; `entries_to_refresh()` hands it back.
289
334
  - `stale`: set instead of a bare `was_correct=False` when the answer
290
335
  is wrong ONLY because a fact changed (price, date, ...), not a
291
336
  semantic mismatch. Stale rows are excluded from training and drift.
@@ -297,15 +342,22 @@ class CacheVerifier:
297
342
  "was_correct": was_correct,
298
343
  "stale": stale,
299
344
  }
300
- if similarity_score is not None:
301
- payload["similarity_score"] = similarity_score
345
+ payload.update(
346
+ _drop_none(
347
+ similarity_score=similarity_score,
348
+ cached_query=cached_query,
349
+ verification_id=verification_id,
350
+ cache_entry_id=cache_entry_id,
351
+ )
352
+ )
302
353
  headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
303
354
  return int(self._post("/v1/feedback", json=payload, headers=headers)["id"])
304
355
 
305
356
  def feedback_batch(self, items: Iterable[dict[str, Any]]) -> list[int]:
306
357
  """Bulk-upload feedback rows (max 500). Each item is a dict with
307
358
  `query`, `candidate_answer`, `was_correct`, and optionally
308
- `similarity_score` / `stale`. Returns the created row ids.
359
+ `similarity_score` / `cached_query` / `cache_entry_id` / `stale`.
360
+ Returns the created row ids.
309
361
  `POST /v1/feedback/batch`.
310
362
  """
311
363
  data = self._post("/v1/feedback/batch", json={"items": list(items)})
@@ -369,6 +421,21 @@ class CacheVerifier:
369
421
  """
370
422
  return self._get("/v1/monitor/gray-zone-threshold")
371
423
 
424
+ def entries_to_refresh(self) -> list[dict[str, Any]]:
425
+ """Cache entries your feedback reported stale, or wrong more often
426
+ than right -- refresh or drop them, then `resolve_entry(e["key"])`.
427
+ Each has `key`, `cache_entry_id` (if you sent one), `reason`
428
+ (`"stale"` / `"often_wrong"`), the answer, report counts and sample
429
+ queries. `GET /v1/monitor/entries-to-refresh`.
430
+ """
431
+ return list(self._get("/v1/monitor/entries-to-refresh")["entries"])
432
+
433
+ def resolve_entry(self, key: str) -> dict[str, Any]:
434
+ """Mark an entry refreshed: earlier feedback stops counting toward it.
435
+ `POST /v1/monitor/entries-to-refresh/{key}/resolve`.
436
+ """
437
+ return self._post(f"/v1/monitor/entries-to-refresh/{key}/resolve")
438
+
372
439
  def usage(self) -> dict[str, Any]:
373
440
  """`GET /v1/usage/status`."""
374
441
  return self._get("/v1/usage/status")