cacheverifier 0.5.0__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.5.0 → cacheverifier-0.6.0}/CHANGELOG.md +20 -0
  2. {cacheverifier-0.5.0 → cacheverifier-0.6.0}/PKG-INFO +45 -3
  3. {cacheverifier-0.5.0 → cacheverifier-0.6.0}/README.md +39 -1
  4. {cacheverifier-0.5.0 → cacheverifier-0.6.0}/cacheverifier/__init__.py +11 -2
  5. {cacheverifier-0.5.0 → cacheverifier-0.6.0}/cacheverifier/_healthcheck/_finetune.py +6 -35
  6. cacheverifier-0.6.0/cacheverifier/_inference.py +162 -0
  7. {cacheverifier-0.5.0 → cacheverifier-0.6.0}/cacheverifier/client.py +47 -4
  8. cacheverifier-0.6.0/cacheverifier/local.py +502 -0
  9. {cacheverifier-0.5.0 → cacheverifier-0.6.0}/pyproject.toml +8 -2
  10. cacheverifier-0.6.0/tests/test_local.py +251 -0
  11. {cacheverifier-0.5.0 → cacheverifier-0.6.0}/.github/workflows/ci.yml +0 -0
  12. {cacheverifier-0.5.0 → cacheverifier-0.6.0}/.github/workflows/publish.yml +0 -0
  13. {cacheverifier-0.5.0 → cacheverifier-0.6.0}/.gitignore +0 -0
  14. {cacheverifier-0.5.0 → cacheverifier-0.6.0}/LICENSE +0 -0
  15. {cacheverifier-0.5.0 → cacheverifier-0.6.0}/cacheverifier/__main__.py +0 -0
  16. {cacheverifier-0.5.0 → cacheverifier-0.6.0}/cacheverifier/_healthcheck/__init__.py +0 -0
  17. {cacheverifier-0.5.0 → cacheverifier-0.6.0}/cacheverifier/_healthcheck/_base_models.py +0 -0
  18. {cacheverifier-0.5.0 → cacheverifier-0.6.0}/cacheverifier/_healthcheck/cli.py +0 -0
  19. {cacheverifier-0.5.0 → cacheverifier-0.6.0}/cacheverifier/integrations/__init__.py +0 -0
  20. {cacheverifier-0.5.0 → cacheverifier-0.6.0}/cacheverifier/integrations/gptcache.py +0 -0
  21. {cacheverifier-0.5.0 → cacheverifier-0.6.0}/cacheverifier/integrations/langchain.py +0 -0
  22. {cacheverifier-0.5.0 → cacheverifier-0.6.0}/cacheverifier/py.typed +0 -0
  23. {cacheverifier-0.5.0 → cacheverifier-0.6.0}/examples/gptcache_example.py +0 -0
  24. {cacheverifier-0.5.0 → cacheverifier-0.6.0}/examples/quickstart.py +0 -0
  25. {cacheverifier-0.5.0 → cacheverifier-0.6.0}/tests/test_client.py +0 -0
  26. {cacheverifier-0.5.0 → cacheverifier-0.6.0}/tests/test_gptcache_integration.py +0 -0
  27. {cacheverifier-0.5.0 → cacheverifier-0.6.0}/tests/test_healthcheck.py +0 -0
  28. {cacheverifier-0.5.0 → cacheverifier-0.6.0}/tests/test_langchain_integration.py +0 -0
@@ -1,5 +1,25 @@
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
+
3
23
  ## 0.5.0
4
24
 
5
25
  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.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
@@ -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
@@ -198,6 +204,40 @@ if job.get("result_model_version"):
198
204
  `cv.dry_run([...])` reports the same baseline-vs-tuned AUC on examples you pass directly,
199
205
  without writing anything or deploying a model.
200
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
+
201
241
  ## Local Health Check (offline)
202
242
 
203
243
  `cv.dry_run()` still uploads your examples to the API. If that's a blocker — a
@@ -259,7 +299,7 @@ verdict
259
299
 
260
300
  | method | endpoint |
261
301
  |---|---|
262
- | `verify(query, candidate_answer)` | `POST /v1/verify` |
302
+ | `verify(query, candidate_answer, similarity_score=..., cached_query=...)` | `POST /v1/verify` |
263
303
  | `verify_batch(pairs)` | `POST /v1/verify/batch` |
264
304
  | `feedback(...)` / `feedback_batch(items)` | `POST /v1/feedback` / `/batch` |
265
305
  | `finetune(...)` / `dry_run(examples, ...)` | `POST /v1/finetune/jobs` / `/dry-run` |
@@ -268,6 +308,8 @@ verdict
268
308
  | `drift_status()` | `GET /v1/monitor/drift-status` |
269
309
  | `gray_zone_threshold()` | `GET /v1/monitor/gray-zone-threshold` |
270
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}` |
271
313
 
272
314
  Non-2xx responses raise `CacheVerifierError` (`.status_code`, `.detail`).
273
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
@@ -163,6 +165,40 @@ if job.get("result_model_version"):
163
165
  `cv.dry_run([...])` reports the same baseline-vs-tuned AUC on examples you pass directly,
164
166
  without writing anything or deploying a model.
165
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
+
166
202
  ## Local Health Check (offline)
167
203
 
168
204
  `cv.dry_run()` still uploads your examples to the API. If that's a blocker — a
@@ -224,7 +260,7 @@ verdict
224
260
 
225
261
  | method | endpoint |
226
262
  |---|---|
227
- | `verify(query, candidate_answer)` | `POST /v1/verify` |
263
+ | `verify(query, candidate_answer, similarity_score=..., cached_query=...)` | `POST /v1/verify` |
228
264
  | `verify_batch(pairs)` | `POST /v1/verify/batch` |
229
265
  | `feedback(...)` / `feedback_batch(items)` | `POST /v1/feedback` / `/batch` |
230
266
  | `finetune(...)` / `dry_run(examples, ...)` | `POST /v1/finetune/jobs` / `/dry-run` |
@@ -233,6 +269,8 @@ verdict
233
269
  | `drift_status()` | `GET /v1/monitor/drift-status` |
234
270
  | `gray_zone_threshold()` | `GET /v1/monitor/gray-zone-threshold` |
235
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}` |
236
274
 
237
275
  Non-2xx responses raise `CacheVerifierError` (`.status_code`, `.detail`).
238
276
 
@@ -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.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"
@@ -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,8 @@ 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,
276
315
  stale: bool = False,
277
316
  idempotency_key: str | None = None,
278
317
  ) -> int:
@@ -286,6 +325,9 @@ class CacheVerifier:
286
325
  - `similarity_score`: your cache backend's own score for this
287
326
  candidate, if you still have it. Only used by
288
327
  `gray_zone_threshold()`; safe to omit.
328
+ - `cached_query`: the cached entry's own original query, if you have it.
329
+ - `verification_id`: from the `VerifyResult` this feedback is about
330
+ (always for an `audit` result) -- it ties the label to that verdict.
289
331
  - `stale`: set instead of a bare `was_correct=False` when the answer
290
332
  is wrong ONLY because a fact changed (price, date, ...), not a
291
333
  semantic mismatch. Stale rows are excluded from training and drift.
@@ -297,8 +339,9 @@ class CacheVerifier:
297
339
  "was_correct": was_correct,
298
340
  "stale": stale,
299
341
  }
300
- if similarity_score is not None:
301
- payload["similarity_score"] = similarity_score
342
+ payload.update(
343
+ _drop_none(similarity_score=similarity_score, cached_query=cached_query, verification_id=verification_id)
344
+ )
302
345
  headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
303
346
  return int(self._post("/v1/feedback", json=payload, headers=headers)["id"])
304
347