cacheverifier 0.1.0__tar.gz → 0.3.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.3.0/.github/workflows/ci.yml +57 -0
- cacheverifier-0.3.0/CHANGELOG.md +32 -0
- {cacheverifier-0.1.0 → cacheverifier-0.3.0}/PKG-INFO +71 -3
- {cacheverifier-0.1.0 → cacheverifier-0.3.0}/README.md +66 -2
- {cacheverifier-0.1.0 → cacheverifier-0.3.0}/cacheverifier/__init__.py +1 -1
- cacheverifier-0.3.0/cacheverifier/__main__.py +44 -0
- cacheverifier-0.3.0/cacheverifier/_healthcheck/__init__.py +7 -0
- cacheverifier-0.3.0/cacheverifier/_healthcheck/_finetune.py +274 -0
- cacheverifier-0.3.0/cacheverifier/_healthcheck/cli.py +209 -0
- {cacheverifier-0.1.0 → cacheverifier-0.3.0}/cacheverifier/client.py +107 -7
- {cacheverifier-0.1.0 → cacheverifier-0.3.0}/cacheverifier/integrations/gptcache.py +20 -2
- {cacheverifier-0.1.0 → cacheverifier-0.3.0}/examples/quickstart.py +6 -1
- {cacheverifier-0.1.0 → cacheverifier-0.3.0}/pyproject.toml +26 -1
- {cacheverifier-0.1.0 → cacheverifier-0.3.0}/tests/test_client.py +96 -2
- cacheverifier-0.3.0/tests/test_healthcheck.py +100 -0
- cacheverifier-0.1.0/.github/workflows/ci.yml +0 -36
- cacheverifier-0.1.0/CHANGELOG.md +0 -8
- {cacheverifier-0.1.0 → cacheverifier-0.3.0}/.github/workflows/publish.yml +0 -0
- {cacheverifier-0.1.0 → cacheverifier-0.3.0}/.gitignore +0 -0
- {cacheverifier-0.1.0 → cacheverifier-0.3.0}/LICENSE +0 -0
- {cacheverifier-0.1.0 → cacheverifier-0.3.0}/cacheverifier/integrations/__init__.py +0 -0
- {cacheverifier-0.1.0 → cacheverifier-0.3.0}/cacheverifier/py.typed +0 -0
- {cacheverifier-0.1.0 → cacheverifier-0.3.0}/examples/gptcache_example.py +0 -0
- {cacheverifier-0.1.0 → cacheverifier-0.3.0}/tests/test_gptcache_integration.py +0 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
fail-fast: false
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: actions/setup-python@v5
|
|
18
|
+
with:
|
|
19
|
+
python-version: ${{ matrix.python-version }}
|
|
20
|
+
- run: python -m pip install --upgrade pip
|
|
21
|
+
- run: pip install -e ".[dev]"
|
|
22
|
+
- run: ruff check .
|
|
23
|
+
- run: pytest -q
|
|
24
|
+
- run: mypy cacheverifier
|
|
25
|
+
if: matrix.python-version != '3.9' # mypy config targets 3.10+
|
|
26
|
+
|
|
27
|
+
# `cacheverifier healthcheck` -- the base `test` job above installs only
|
|
28
|
+
# `.[dev]`, so it exercises the CLI dispatch and the missing-extra path;
|
|
29
|
+
# this job installs `.[healthcheck,dev]` and runs the real fine-tune
|
|
30
|
+
# end-to-end. One Python version is enough (the training path isn't
|
|
31
|
+
# version-sensitive) and CPU-only torch keeps the install lean -- the
|
|
32
|
+
# default wheel drags in ~3 GB of unused CUDA libraries whose import-time
|
|
33
|
+
# mmap alone can OOM a small runner.
|
|
34
|
+
healthcheck:
|
|
35
|
+
runs-on: ubuntu-latest
|
|
36
|
+
steps:
|
|
37
|
+
- uses: actions/checkout@v4
|
|
38
|
+
- uses: actions/setup-python@v5
|
|
39
|
+
with:
|
|
40
|
+
python-version: "3.11"
|
|
41
|
+
- run: python -m pip install --upgrade pip
|
|
42
|
+
- run: pip install torch --index-url https://download.pytorch.org/whl/cpu
|
|
43
|
+
- run: pip install -e ".[healthcheck,dev]"
|
|
44
|
+
- run: ruff check .
|
|
45
|
+
- run: mypy cacheverifier
|
|
46
|
+
- run: pytest -q tests/test_healthcheck.py
|
|
47
|
+
|
|
48
|
+
build:
|
|
49
|
+
runs-on: ubuntu-latest
|
|
50
|
+
steps:
|
|
51
|
+
- uses: actions/checkout@v4
|
|
52
|
+
- uses: actions/setup-python@v5
|
|
53
|
+
with:
|
|
54
|
+
python-version: "3.12"
|
|
55
|
+
- run: pip install build twine
|
|
56
|
+
- run: python -m build
|
|
57
|
+
- run: twine check dist/*
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
Make the request-path defaults safe. `verify()` and `verify_batch()` now use a
|
|
6
|
+
separate **1s timeout** (`verify_timeout=`, vs the unchanged 10s `timeout=` for
|
|
7
|
+
control-plane calls), and on a timeout / connection error / 5xx they **fail
|
|
8
|
+
closed** instead of raising: you get a synthetic `VerifyResult` with
|
|
9
|
+
`degraded=True` and `approved=False` (fall through to your LLM). Pass
|
|
10
|
+
`fail_open=True` to have those cases return `approved=True` instead. 4xx
|
|
11
|
+
responses (bad key, bad request, rate limit) still raise `CacheVerifierError`.
|
|
12
|
+
`VerifyResult` gains a `degraded` field; `model_version` is
|
|
13
|
+
`"verify_unavailable"` on a synthesized result. The GPTCache adapter takes the
|
|
14
|
+
same `verify_timeout` / `fail_open` arguments and returns `0.0` (don't reuse) on
|
|
15
|
+
a verifier outage by default.
|
|
16
|
+
|
|
17
|
+
## 0.2.0
|
|
18
|
+
|
|
19
|
+
Add a `cacheverifier` console script with a `healthcheck` subcommand:
|
|
20
|
+
`cacheverifier healthcheck traffic.jsonl` runs the hosted Health Check
|
|
21
|
+
Report's stock-vs-fine-tuned held-out AUC evaluation (`POST /v1/finetune/dry-run`)
|
|
22
|
+
entirely offline — no queries or answers leave the machine. Needs the new
|
|
23
|
+
`healthcheck` extra (`pip install "cacheverifier[healthcheck]"`: torch,
|
|
24
|
+
sentence-transformers, numpy); the base client stays `httpx`-only.
|
|
25
|
+
`--emit-summary` writes an aggregate-only JSON file with no text.
|
|
26
|
+
|
|
27
|
+
## 0.1.0
|
|
28
|
+
|
|
29
|
+
Initial release: thin `httpx`-based client for `/v1/verify`, `/v1/verify/batch`,
|
|
30
|
+
`/v1/feedback`(`/batch`), fine-tune jobs + dry run, drift and gray-zone-threshold
|
|
31
|
+
monitoring, and usage/savings. GPTCache `SimilarityEvaluation` adapter under
|
|
32
|
+
`cacheverifier.integrations.gptcache`.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: cacheverifier
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Python client for the hosted CacheVerifier semantic-cache verification API
|
|
5
5
|
Project-URL: Homepage, https://www.cacheverifier.com
|
|
6
6
|
Project-URL: Documentation, https://www.cacheverifier.com/docs
|
|
@@ -24,6 +24,10 @@ Requires-Dist: pytest>=7; extra == 'dev'
|
|
|
24
24
|
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
25
25
|
Provides-Extra: gptcache
|
|
26
26
|
Requires-Dist: gptcache>=0.1.30; extra == 'gptcache'
|
|
27
|
+
Provides-Extra: healthcheck
|
|
28
|
+
Requires-Dist: numpy<2,>=1.26; extra == 'healthcheck'
|
|
29
|
+
Requires-Dist: sentence-transformers<4,>=3.0; extra == 'healthcheck'
|
|
30
|
+
Requires-Dist: torch>=2.2; extra == 'healthcheck'
|
|
27
31
|
Description-Content-Type: text/markdown
|
|
28
32
|
|
|
29
33
|
# cacheverifier
|
|
@@ -51,9 +55,12 @@ zone", where a plain threshold match might be wrong.
|
|
|
51
55
|
pip install cacheverifier
|
|
52
56
|
# with the GPTCache adapter:
|
|
53
57
|
pip install "cacheverifier[gptcache]"
|
|
58
|
+
# with the offline Health Check (adds torch + sentence-transformers):
|
|
59
|
+
pip install "cacheverifier[healthcheck]"
|
|
54
60
|
```
|
|
55
61
|
|
|
56
|
-
Requires Python 3.9+. The only runtime dependency is `httpx
|
|
62
|
+
Requires Python 3.9+. The only runtime dependency is `httpx` — the extras above
|
|
63
|
+
are opt-in.
|
|
57
64
|
|
|
58
65
|
## Quickstart
|
|
59
66
|
|
|
@@ -84,8 +91,34 @@ cv.feedback(query, answer, was_correct=True, similarity_score=0.86)
|
|
|
84
91
|
|---|---|
|
|
85
92
|
| `approved` | serve the cached answer (`True`) or fall through (`False`) |
|
|
86
93
|
| `score` / `threshold` | `approved` is `score >= threshold` |
|
|
87
|
-
| `model_version` | `"stock"`, `"v<id>"` (fine-tuned), or `"
|
|
94
|
+
| `model_version` | `"stock"`, `"v<id>"` (fine-tuned), `"cold_start_fail_closed"` / `"cold_start_auto_pending"`, or `"verify_unavailable"` |
|
|
88
95
|
| `latency_ms` | server-side inference time |
|
|
96
|
+
| `degraded` | `True` when the call failed and this result was synthesized client-side |
|
|
97
|
+
|
|
98
|
+
## On your request path
|
|
99
|
+
|
|
100
|
+
`verify()` runs inline with your traffic, so its defaults are conservative:
|
|
101
|
+
|
|
102
|
+
- **1s timeout** (`verify_timeout=`), separate from the 10s `timeout=` used for
|
|
103
|
+
fine-tuning / feedback / monitoring calls. Warm verification is tens of
|
|
104
|
+
milliseconds server-side; 1s covers the network round trip and a cold model
|
|
105
|
+
load after a deploy without letting a stuck verifier stall your request.
|
|
106
|
+
- **Fails closed.** On a timeout, connection error, or 5xx, `verify()` does not
|
|
107
|
+
raise — it logs a warning on the `cacheverifier` logger and returns a
|
|
108
|
+
`VerifyResult` with `degraded=True` and `approved=False`, so you fall through
|
|
109
|
+
to your LLM exactly as you would on a cache miss. A `4xx` (bad key, bad
|
|
110
|
+
request, rate limit) still raises `CacheVerifierError`.
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
cv = CacheVerifier(api_key="cv_...", verify_timeout=1.0, fail_open=False) # the defaults
|
|
114
|
+
|
|
115
|
+
# fail_open=True instead returns approved=True on an outage — only if a
|
|
116
|
+
# stale-or-near-miss answer is acceptable for that traffic:
|
|
117
|
+
cv = CacheVerifier(api_key="cv_...", fail_open=True)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
There is no formal uptime SLA yet, which is the other reason the fallback path
|
|
121
|
+
is a built-in default rather than left to you.
|
|
89
122
|
|
|
90
123
|
## GPTCache
|
|
91
124
|
|
|
@@ -123,6 +156,41 @@ if job.get("result_model_version"):
|
|
|
123
156
|
`cv.dry_run([...])` reports the same baseline-vs-tuned AUC on examples you pass directly,
|
|
124
157
|
without writing anything or deploying a model.
|
|
125
158
|
|
|
159
|
+
## Local Health Check (offline)
|
|
160
|
+
|
|
161
|
+
`cv.dry_run()` still uploads your examples to the API. If that's a blocker — a
|
|
162
|
+
compliance review, or just not wanting production traffic to leave your network —
|
|
163
|
+
run the identical stock-vs-fine-tuned evaluation entirely on your own machine:
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
pip install "cacheverifier[healthcheck]"
|
|
167
|
+
|
|
168
|
+
cacheverifier healthcheck traffic.jsonl
|
|
169
|
+
cacheverifier healthcheck traffic.jsonl --emit-summary summary.json
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
`traffic.jsonl` is a JSON array or JSONL of `{"query", "candidate_answer", "was_correct"}`
|
|
173
|
+
rows **in arrival order** (the train/calibrate/test split is chronological, matching the
|
|
174
|
+
hosted service so the numbers are comparable). Optional per row: `"stale": true`.
|
|
175
|
+
|
|
176
|
+
Nothing is sent anywhere — the base model downloads once from Hugging Face, then it's
|
|
177
|
+
fully offline. `--emit-summary` writes an aggregate-only JSON file (AUCs, counts, rates —
|
|
178
|
+
no query or answer text) that's safe to share for a human read.
|
|
179
|
+
|
|
180
|
+
```
|
|
181
|
+
results
|
|
182
|
+
------------------------------------------------------------------
|
|
183
|
+
train / calibrate / test: 3349 / 419 / 419
|
|
184
|
+
stock verifier held-out AUC: 0.6120
|
|
185
|
+
fine-tuned held-out AUC: 0.7080 (delta +0.0960)
|
|
186
|
+
label-noise proxy (disagreement): 11.4%
|
|
187
|
+
ceiling status: still_improvable
|
|
188
|
+
|
|
189
|
+
verdict
|
|
190
|
+
------------------------------------------------------------------
|
|
191
|
+
IMPROVED -- fine-tuning on your own data helps this traffic
|
|
192
|
+
```
|
|
193
|
+
|
|
126
194
|
## API surface
|
|
127
195
|
|
|
128
196
|
| method | endpoint |
|
|
@@ -23,9 +23,12 @@ zone", where a plain threshold match might be wrong.
|
|
|
23
23
|
pip install cacheverifier
|
|
24
24
|
# with the GPTCache adapter:
|
|
25
25
|
pip install "cacheverifier[gptcache]"
|
|
26
|
+
# with the offline Health Check (adds torch + sentence-transformers):
|
|
27
|
+
pip install "cacheverifier[healthcheck]"
|
|
26
28
|
```
|
|
27
29
|
|
|
28
|
-
Requires Python 3.9+. The only runtime dependency is `httpx
|
|
30
|
+
Requires Python 3.9+. The only runtime dependency is `httpx` — the extras above
|
|
31
|
+
are opt-in.
|
|
29
32
|
|
|
30
33
|
## Quickstart
|
|
31
34
|
|
|
@@ -56,8 +59,34 @@ cv.feedback(query, answer, was_correct=True, similarity_score=0.86)
|
|
|
56
59
|
|---|---|
|
|
57
60
|
| `approved` | serve the cached answer (`True`) or fall through (`False`) |
|
|
58
61
|
| `score` / `threshold` | `approved` is `score >= threshold` |
|
|
59
|
-
| `model_version` | `"stock"`, `"v<id>"` (fine-tuned), or `"
|
|
62
|
+
| `model_version` | `"stock"`, `"v<id>"` (fine-tuned), `"cold_start_fail_closed"` / `"cold_start_auto_pending"`, or `"verify_unavailable"` |
|
|
60
63
|
| `latency_ms` | server-side inference time |
|
|
64
|
+
| `degraded` | `True` when the call failed and this result was synthesized client-side |
|
|
65
|
+
|
|
66
|
+
## On your request path
|
|
67
|
+
|
|
68
|
+
`verify()` runs inline with your traffic, so its defaults are conservative:
|
|
69
|
+
|
|
70
|
+
- **1s timeout** (`verify_timeout=`), separate from the 10s `timeout=` used for
|
|
71
|
+
fine-tuning / feedback / monitoring calls. Warm verification is tens of
|
|
72
|
+
milliseconds server-side; 1s covers the network round trip and a cold model
|
|
73
|
+
load after a deploy without letting a stuck verifier stall your request.
|
|
74
|
+
- **Fails closed.** On a timeout, connection error, or 5xx, `verify()` does not
|
|
75
|
+
raise — it logs a warning on the `cacheverifier` logger and returns a
|
|
76
|
+
`VerifyResult` with `degraded=True` and `approved=False`, so you fall through
|
|
77
|
+
to your LLM exactly as you would on a cache miss. A `4xx` (bad key, bad
|
|
78
|
+
request, rate limit) still raises `CacheVerifierError`.
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
cv = CacheVerifier(api_key="cv_...", verify_timeout=1.0, fail_open=False) # the defaults
|
|
82
|
+
|
|
83
|
+
# fail_open=True instead returns approved=True on an outage — only if a
|
|
84
|
+
# stale-or-near-miss answer is acceptable for that traffic:
|
|
85
|
+
cv = CacheVerifier(api_key="cv_...", fail_open=True)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
There is no formal uptime SLA yet, which is the other reason the fallback path
|
|
89
|
+
is a built-in default rather than left to you.
|
|
61
90
|
|
|
62
91
|
## GPTCache
|
|
63
92
|
|
|
@@ -95,6 +124,41 @@ if job.get("result_model_version"):
|
|
|
95
124
|
`cv.dry_run([...])` reports the same baseline-vs-tuned AUC on examples you pass directly,
|
|
96
125
|
without writing anything or deploying a model.
|
|
97
126
|
|
|
127
|
+
## Local Health Check (offline)
|
|
128
|
+
|
|
129
|
+
`cv.dry_run()` still uploads your examples to the API. If that's a blocker — a
|
|
130
|
+
compliance review, or just not wanting production traffic to leave your network —
|
|
131
|
+
run the identical stock-vs-fine-tuned evaluation entirely on your own machine:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
pip install "cacheverifier[healthcheck]"
|
|
135
|
+
|
|
136
|
+
cacheverifier healthcheck traffic.jsonl
|
|
137
|
+
cacheverifier healthcheck traffic.jsonl --emit-summary summary.json
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
`traffic.jsonl` is a JSON array or JSONL of `{"query", "candidate_answer", "was_correct"}`
|
|
141
|
+
rows **in arrival order** (the train/calibrate/test split is chronological, matching the
|
|
142
|
+
hosted service so the numbers are comparable). Optional per row: `"stale": true`.
|
|
143
|
+
|
|
144
|
+
Nothing is sent anywhere — the base model downloads once from Hugging Face, then it's
|
|
145
|
+
fully offline. `--emit-summary` writes an aggregate-only JSON file (AUCs, counts, rates —
|
|
146
|
+
no query or answer text) that's safe to share for a human read.
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
results
|
|
150
|
+
------------------------------------------------------------------
|
|
151
|
+
train / calibrate / test: 3349 / 419 / 419
|
|
152
|
+
stock verifier held-out AUC: 0.6120
|
|
153
|
+
fine-tuned held-out AUC: 0.7080 (delta +0.0960)
|
|
154
|
+
label-noise proxy (disagreement): 11.4%
|
|
155
|
+
ceiling status: still_improvable
|
|
156
|
+
|
|
157
|
+
verdict
|
|
158
|
+
------------------------------------------------------------------
|
|
159
|
+
IMPROVED -- fine-tuning on your own data helps this traffic
|
|
160
|
+
```
|
|
161
|
+
|
|
98
162
|
## API surface
|
|
99
163
|
|
|
100
164
|
| method | endpoint |
|
|
@@ -18,5 +18,5 @@ Research behind it: https://github.com/imxinchengyou/CacheVerifier
|
|
|
18
18
|
|
|
19
19
|
from cacheverifier.client import CacheVerifier, CacheVerifierError, VerifyResult
|
|
20
20
|
|
|
21
|
-
__version__ = "0.
|
|
21
|
+
__version__ = "0.3.0"
|
|
22
22
|
__all__ = ["CacheVerifier", "CacheVerifierError", "VerifyResult", "__version__"]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""`python -m cacheverifier` / the `cacheverifier` console script.
|
|
2
|
+
|
|
3
|
+
A thin argparse dispatcher. The base install (httpx only) provides
|
|
4
|
+
`--version` and `--help`; the `healthcheck` subcommand additionally needs
|
|
5
|
+
the `healthcheck` extra and imports nothing heavy until it runs.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from cacheverifier import __version__
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
17
|
+
parser = argparse.ArgumentParser(
|
|
18
|
+
prog="cacheverifier",
|
|
19
|
+
description="CacheVerifier -- hosted semantic-cache verification (https://www.cacheverifier.com).",
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument("--version", action="version", version=f"cacheverifier {__version__}")
|
|
22
|
+
subparsers = parser.add_subparsers(dest="command", metavar="<command>")
|
|
23
|
+
|
|
24
|
+
# Import lazily and defensively: a missing healthcheck extra must not
|
|
25
|
+
# break `cacheverifier --help` or `--version`. cli.add_subparser itself
|
|
26
|
+
# only touches argparse.
|
|
27
|
+
from cacheverifier._healthcheck import cli as healthcheck_cli
|
|
28
|
+
|
|
29
|
+
healthcheck_cli.add_subparser(subparsers)
|
|
30
|
+
|
|
31
|
+
return parser
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def main(argv: list[str] | None = None) -> int:
|
|
35
|
+
parser = _build_parser()
|
|
36
|
+
args = parser.parse_args(argv)
|
|
37
|
+
if not getattr(args, "command", None):
|
|
38
|
+
parser.print_help()
|
|
39
|
+
return 1
|
|
40
|
+
return int(args.func(args))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
if __name__ == "__main__":
|
|
44
|
+
sys.exit(main())
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""`cacheverifier healthcheck` -- run the hosted Health Check Report's
|
|
2
|
+
stock-vs-fine-tuned AUC evaluation entirely on your own machine.
|
|
3
|
+
|
|
4
|
+
Everything under here needs the `healthcheck` extra
|
|
5
|
+
(`pip install "cacheverifier[healthcheck]"`: torch, sentence-transformers,
|
|
6
|
+
scikit-learn, numpy) and is imported only when the subcommand runs.
|
|
7
|
+
"""
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Local fine-tune + held-out AUC evaluation for `cacheverifier healthcheck`.
|
|
2
|
+
|
|
3
|
+
This is a trimmed, self-contained port of the hosted service's
|
|
4
|
+
`verifier_core.finetune` / `verifier_core.cross_encoder` (the code behind
|
|
5
|
+
`POST /v1/finetune/dry-run`). It keeps the parts a Health Check needs --
|
|
6
|
+
the chronological train/calibrate/test split, held-out AUC for the stock
|
|
7
|
+
vs. fine-tuned model, a Youden's-J operating point, and the label-noise
|
|
8
|
+
and ceiling diagnostics -- and drops everything specific to running a
|
|
9
|
+
production tenant (Conformal Risk Control certification, cost-ratio
|
|
10
|
+
threshold grids, drift monitoring, operating-point-jump detection).
|
|
11
|
+
|
|
12
|
+
Nothing here is imported unless the `healthcheck` extra is installed and
|
|
13
|
+
the subcommand is actually run -- see `cacheverifier.__main__`.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import time
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import Any, cast
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
import torch
|
|
24
|
+
from sentence_transformers import CrossEncoder, InputExample
|
|
25
|
+
from torch.utils.data import DataLoader
|
|
26
|
+
|
|
27
|
+
# All four imports above are from the `healthcheck` extra. This module is
|
|
28
|
+
# only ever imported from inside `cacheverifier._healthcheck.cli.run()`,
|
|
29
|
+
# which catches the ImportError and points the user at
|
|
30
|
+
# `pip install "cacheverifier[healthcheck]"` -- so a plain
|
|
31
|
+
# `import cacheverifier` / `cacheverifier --help` never pays this cost.
|
|
32
|
+
|
|
33
|
+
DEFAULT_BASE_MODEL = "cross-encoder/ms-marco-MiniLM-L6-v2"
|
|
34
|
+
"""The stock off-the-shelf verifier every tenant starts on -- the same base
|
|
35
|
+
model the hosted service fine-tunes from."""
|
|
36
|
+
|
|
37
|
+
MAX_SEQUENCE_LENGTH = 128
|
|
38
|
+
"""Token cap applied identically at train and score time. Matches the
|
|
39
|
+
hosted service; BERT-style attention memory is O(n^2) in sequence length,
|
|
40
|
+
and this keeps a CPU run well-behaved."""
|
|
41
|
+
|
|
42
|
+
_CONTENT_TOKEN_BUDGET = MAX_SEQUENCE_LENGTH - 3 # [CLS] + 2x [SEP]
|
|
43
|
+
_MIN_QUERY_TOKENS = 32
|
|
44
|
+
|
|
45
|
+
TRAIN_BATCH_SIZE = 4
|
|
46
|
+
MIN_TRAIN_EXAMPLES = 20
|
|
47
|
+
"""Hard floor -- below this there aren't enough rows for one meaningful
|
|
48
|
+
epoch plus a held-out split."""
|
|
49
|
+
|
|
50
|
+
COLD_START_WARNING_THRESHOLD = 1000
|
|
51
|
+
"""The hosted service's research (Research page, Q2) found fine-tuning only
|
|
52
|
+
reliably positive across every dataset above roughly this many rows. Below
|
|
53
|
+
it a result is real but noisy."""
|
|
54
|
+
|
|
55
|
+
_MIN_TEST_FOR_THRESHOLD = 10
|
|
56
|
+
_MIN_TEST_FOR_CEILING_DIAGNOSIS = 50
|
|
57
|
+
_CEILING_TAIL_MARGIN = 0.15
|
|
58
|
+
_CEILING_AUC_CAP = 0.75
|
|
59
|
+
|
|
60
|
+
NOISE_WARNING_DISAGREEMENT_RATE = 0.20
|
|
61
|
+
"""`train_label_disagreement_rate` above this: worth auditing how the
|
|
62
|
+
correctness signal is produced."""
|
|
63
|
+
NOISE_HOLD_BACK_DISAGREEMENT_RATE = 0.30
|
|
64
|
+
"""Above this the hosted service holds a new model back for review."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class GrayZoneExample:
|
|
69
|
+
query: str
|
|
70
|
+
candidate_answer: str
|
|
71
|
+
was_correct: bool
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class HealthCheckResult:
|
|
76
|
+
n_train: int
|
|
77
|
+
n_calibrate: int
|
|
78
|
+
n_test: int
|
|
79
|
+
train_positive_rate: float
|
|
80
|
+
test_positive_rate: float
|
|
81
|
+
auc_baseline: float
|
|
82
|
+
auc_tuned: float
|
|
83
|
+
train_time_seconds: float
|
|
84
|
+
threshold: float | None
|
|
85
|
+
threshold_hit_rate: float | None
|
|
86
|
+
threshold_error_rate: float | None
|
|
87
|
+
train_label_disagreement_rate: float | None
|
|
88
|
+
ceiling_status: str | None
|
|
89
|
+
model_path: str
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def auc_delta(self) -> float:
|
|
93
|
+
return self.auc_tuned - self.auc_baseline
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _head_tail_truncate(ids: list[int], budget: int, head_ratio: float = 0.6) -> list[int]:
|
|
97
|
+
if len(ids) <= budget:
|
|
98
|
+
return ids
|
|
99
|
+
head_n = int(budget * head_ratio)
|
|
100
|
+
tail_n = budget - head_n
|
|
101
|
+
return ids[:head_n] + (ids[-tail_n:] if tail_n > 0 else [])
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def smart_truncate_pair(query: str, candidate_answer: str, tokenizer: Any) -> tuple[str, str]:
|
|
105
|
+
"""Tokenizer-aware head+tail truncation, applied identically at train
|
|
106
|
+
and score time (a model trained on one truncation policy and scored
|
|
107
|
+
with another sees a different token window than it was optimized
|
|
108
|
+
against). A no-op whenever the pair already fits."""
|
|
109
|
+
query_ids = tokenizer.encode(query, add_special_tokens=False)
|
|
110
|
+
answer_ids = tokenizer.encode(candidate_answer, add_special_tokens=False)
|
|
111
|
+
if len(query_ids) + len(answer_ids) <= _CONTENT_TOKEN_BUDGET:
|
|
112
|
+
return query, candidate_answer
|
|
113
|
+
|
|
114
|
+
query_budget = min(len(query_ids), max(_MIN_QUERY_TOKENS, _CONTENT_TOKEN_BUDGET // 2))
|
|
115
|
+
answer_budget = _CONTENT_TOKEN_BUDGET - query_budget
|
|
116
|
+
query_ids = _head_tail_truncate(query_ids, query_budget)
|
|
117
|
+
answer_ids = _head_tail_truncate(answer_ids, answer_budget)
|
|
118
|
+
return (
|
|
119
|
+
tokenizer.decode(query_ids, skip_special_tokens=True),
|
|
120
|
+
tokenizer.decode(answer_ids, skip_special_tokens=True),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def roc_auc(scores: np.ndarray, labels: np.ndarray) -> float:
|
|
125
|
+
"""Rank-based AUC, no sklearn dependency. NaN if either class is absent."""
|
|
126
|
+
order = np.argsort(scores)
|
|
127
|
+
ranks = np.empty_like(order, dtype=float)
|
|
128
|
+
ranks[order] = np.arange(len(scores))
|
|
129
|
+
pos_ranks = ranks[labels == 1]
|
|
130
|
+
n_pos, n_neg = int((labels == 1).sum()), int((labels == 0).sum())
|
|
131
|
+
if n_pos == 0 or n_neg == 0:
|
|
132
|
+
return float("nan")
|
|
133
|
+
return float((pos_ranks.sum() - n_pos * (n_pos - 1) / 2) / (n_pos * n_neg))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def select_threshold(scores: np.ndarray, labels: np.ndarray) -> float | None:
|
|
137
|
+
"""Youden's J: the threshold maximizing (TPR - FPR). None when the
|
|
138
|
+
held-out split is too small or single-class to calibrate from -- the
|
|
139
|
+
caller decides the fallback."""
|
|
140
|
+
n_pos = int((labels == 1).sum())
|
|
141
|
+
n_neg = int((labels == 0).sum())
|
|
142
|
+
if n_pos == 0 or n_neg == 0 or (n_pos + n_neg) < _MIN_TEST_FOR_THRESHOLD:
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
order = np.argsort(-scores)
|
|
146
|
+
sorted_scores = scores[order]
|
|
147
|
+
sorted_labels = labels[order]
|
|
148
|
+
tp = np.cumsum(sorted_labels == 1)
|
|
149
|
+
fp = np.cumsum(sorted_labels == 0)
|
|
150
|
+
youden_j = tp / n_pos - fp / n_neg
|
|
151
|
+
return float(sorted_scores[int(np.argmax(youden_j))])
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _hit_and_error_rate(scores: np.ndarray, labels: np.ndarray, threshold: float) -> tuple[float, float | None]:
|
|
155
|
+
approved = scores >= threshold
|
|
156
|
+
n_approved = int(approved.sum())
|
|
157
|
+
hit_rate = n_approved / len(scores) if len(scores) else 0.0
|
|
158
|
+
if n_approved == 0:
|
|
159
|
+
return hit_rate, None
|
|
160
|
+
return hit_rate, float(np.mean(labels[approved] == 0))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _diagnose_ceiling(scores: np.ndarray, labels: np.ndarray) -> str | None:
|
|
164
|
+
""""insufficient_data" | "possible_ceiling" | "still_improvable" -- is
|
|
165
|
+
the fine-tuned verifier plausibly undertrained (more data would help)
|
|
166
|
+
or capped by the domain's own ambiguity? Precision in the top score
|
|
167
|
+
decile vs. the base rate, same shape of check as the hosted service."""
|
|
168
|
+
n = len(labels)
|
|
169
|
+
if n < _MIN_TEST_FOR_CEILING_DIAGNOSIS:
|
|
170
|
+
return "insufficient_data"
|
|
171
|
+
base_rate = float(labels.mean())
|
|
172
|
+
top_decile_n = max(1, n // 10)
|
|
173
|
+
top_decile_idx = np.argsort(-scores)[:top_decile_n]
|
|
174
|
+
precision_at_top_decile = float(labels[top_decile_idx].mean())
|
|
175
|
+
auc = roc_auc(scores, labels)
|
|
176
|
+
if precision_at_top_decile - base_rate >= _CEILING_TAIL_MARGIN and auc < _CEILING_AUC_CAP:
|
|
177
|
+
return "possible_ceiling"
|
|
178
|
+
return "still_improvable"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def run_healthcheck(
|
|
182
|
+
examples: list[GrayZoneExample],
|
|
183
|
+
output_dir: str,
|
|
184
|
+
*,
|
|
185
|
+
base_model: str = DEFAULT_BASE_MODEL,
|
|
186
|
+
epochs: int = 1,
|
|
187
|
+
) -> HealthCheckResult:
|
|
188
|
+
"""Fine-tune `base_model` on a chronological prefix of `examples` and
|
|
189
|
+
measure held-out AUC for the stock vs. fine-tuned model.
|
|
190
|
+
|
|
191
|
+
`examples` must be in arrival (stream) order -- the train/calibrate/test
|
|
192
|
+
split is positional, not shuffled, matching the hosted service so the
|
|
193
|
+
numbers are comparable.
|
|
194
|
+
"""
|
|
195
|
+
if len(examples) < MIN_TRAIN_EXAMPLES:
|
|
196
|
+
raise ValueError(f"need at least {MIN_TRAIN_EXAMPLES} examples, got {len(examples)}")
|
|
197
|
+
|
|
198
|
+
split = max(1, int(len(examples) * 0.8))
|
|
199
|
+
train_rows, holdout_rows = examples[:split], examples[split:]
|
|
200
|
+
if not holdout_rows:
|
|
201
|
+
train_rows, holdout_rows = examples[:-1], examples[-1:]
|
|
202
|
+
calib_split = max(1, len(holdout_rows) // 2)
|
|
203
|
+
calib_rows, test_rows = holdout_rows[:calib_split], holdout_rows[calib_split:]
|
|
204
|
+
if not test_rows:
|
|
205
|
+
calib_rows, test_rows = holdout_rows[:-1], holdout_rows[-1:]
|
|
206
|
+
|
|
207
|
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
|
208
|
+
test_pairs = [(e.query, e.candidate_answer) for e in test_rows]
|
|
209
|
+
test_labels = np.array([1 if e.was_correct else 0 for e in test_rows])
|
|
210
|
+
calib_pairs = [(e.query, e.candidate_answer) for e in calib_rows]
|
|
211
|
+
calib_labels = np.array([1 if e.was_correct else 0 for e in calib_rows])
|
|
212
|
+
train_positive_rate = float(np.mean([1 if e.was_correct else 0 for e in train_rows]))
|
|
213
|
+
test_positive_rate = float(test_labels.mean()) if len(test_labels) else 0.0
|
|
214
|
+
|
|
215
|
+
baseline = CrossEncoder(base_model, device=device, max_length=MAX_SEQUENCE_LENGTH)
|
|
216
|
+
test_pairs_scored = [smart_truncate_pair(q, a, baseline.tokenizer) for q, a in test_pairs]
|
|
217
|
+
baseline_scores = np.array(baseline.predict(test_pairs_scored, batch_size=32, show_progress_bar=False))
|
|
218
|
+
auc_baseline = roc_auc(baseline_scores, test_labels)
|
|
219
|
+
del baseline
|
|
220
|
+
|
|
221
|
+
tuned = CrossEncoder(base_model, device=device, max_length=MAX_SEQUENCE_LENGTH)
|
|
222
|
+
train_examples = [
|
|
223
|
+
InputExample(
|
|
224
|
+
texts=list(smart_truncate_pair(e.query, e.candidate_answer, tuned.tokenizer)),
|
|
225
|
+
label=1.0 if e.was_correct else 0.0,
|
|
226
|
+
)
|
|
227
|
+
for e in train_rows
|
|
228
|
+
]
|
|
229
|
+
# A list[InputExample] is a valid PyTorch map-style dataset at runtime;
|
|
230
|
+
# sentence-transformers' pre-4.0 training API is built around exactly
|
|
231
|
+
# this, but the two libraries' stubs don't compose (InputExample isn't a
|
|
232
|
+
# typed Dataset). cast rather than `# type: ignore` so this stays clean
|
|
233
|
+
# whether or not torch's stubs are installed (base CI has no torch).
|
|
234
|
+
loader: DataLoader[Any] = DataLoader(cast(Any, train_examples), shuffle=True, batch_size=TRAIN_BATCH_SIZE)
|
|
235
|
+
t0 = time.time()
|
|
236
|
+
tuned.fit(train_dataloader=loader, epochs=epochs, show_progress_bar=False)
|
|
237
|
+
train_time_seconds = time.time() - t0
|
|
238
|
+
tuned.save(output_dir)
|
|
239
|
+
|
|
240
|
+
calib_scored = [smart_truncate_pair(q, a, tuned.tokenizer) for q, a in calib_pairs]
|
|
241
|
+
calib_scores = np.array(tuned.predict(calib_scored, batch_size=32, show_progress_bar=False))
|
|
242
|
+
threshold = select_threshold(calib_scores, calib_labels)
|
|
243
|
+
|
|
244
|
+
tuned_scores = np.array(tuned.predict(test_pairs_scored, batch_size=32, show_progress_bar=False))
|
|
245
|
+
auc_tuned = roc_auc(tuned_scores, test_labels)
|
|
246
|
+
ceiling_status = _diagnose_ceiling(tuned_scores, test_labels)
|
|
247
|
+
|
|
248
|
+
threshold_hit_rate: float | None = None
|
|
249
|
+
threshold_error_rate: float | None = None
|
|
250
|
+
train_label_disagreement_rate: float | None = None
|
|
251
|
+
if threshold is not None:
|
|
252
|
+
threshold_hit_rate, threshold_error_rate = _hit_and_error_rate(tuned_scores, test_labels, threshold)
|
|
253
|
+
train_pairs_scored = [smart_truncate_pair(e.query, e.candidate_answer, tuned.tokenizer) for e in train_rows]
|
|
254
|
+
train_scores = np.array(tuned.predict(train_pairs_scored, batch_size=32, show_progress_bar=False))
|
|
255
|
+
train_labels = np.array([1 if e.was_correct else 0 for e in train_rows])
|
|
256
|
+
train_pred = (train_scores >= threshold).astype(int)
|
|
257
|
+
train_label_disagreement_rate = float(np.mean(train_pred != train_labels))
|
|
258
|
+
|
|
259
|
+
return HealthCheckResult(
|
|
260
|
+
n_train=len(train_rows),
|
|
261
|
+
n_calibrate=len(calib_rows),
|
|
262
|
+
n_test=len(test_rows),
|
|
263
|
+
train_positive_rate=train_positive_rate,
|
|
264
|
+
test_positive_rate=test_positive_rate,
|
|
265
|
+
auc_baseline=auc_baseline,
|
|
266
|
+
auc_tuned=auc_tuned,
|
|
267
|
+
train_time_seconds=train_time_seconds,
|
|
268
|
+
threshold=threshold,
|
|
269
|
+
threshold_hit_rate=threshold_hit_rate,
|
|
270
|
+
threshold_error_rate=threshold_error_rate,
|
|
271
|
+
train_label_disagreement_rate=train_label_disagreement_rate,
|
|
272
|
+
ceiling_status=ceiling_status,
|
|
273
|
+
model_path=output_dir,
|
|
274
|
+
)
|