cacheverifier 0.1.0__tar.gz → 0.2.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.2.0/.github/workflows/ci.yml +57 -0
- cacheverifier-0.2.0/CHANGELOG.md +18 -0
- {cacheverifier-0.1.0 → cacheverifier-0.2.0}/PKG-INFO +44 -2
- {cacheverifier-0.1.0 → cacheverifier-0.2.0}/README.md +39 -1
- {cacheverifier-0.1.0 → cacheverifier-0.2.0}/cacheverifier/__init__.py +1 -1
- cacheverifier-0.2.0/cacheverifier/__main__.py +44 -0
- cacheverifier-0.2.0/cacheverifier/_healthcheck/__init__.py +7 -0
- cacheverifier-0.2.0/cacheverifier/_healthcheck/_finetune.py +274 -0
- cacheverifier-0.2.0/cacheverifier/_healthcheck/cli.py +209 -0
- {cacheverifier-0.1.0 → cacheverifier-0.2.0}/pyproject.toml +26 -1
- cacheverifier-0.2.0/tests/test_healthcheck.py +98 -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.2.0}/.github/workflows/publish.yml +0 -0
- {cacheverifier-0.1.0 → cacheverifier-0.2.0}/.gitignore +0 -0
- {cacheverifier-0.1.0 → cacheverifier-0.2.0}/LICENSE +0 -0
- {cacheverifier-0.1.0 → cacheverifier-0.2.0}/cacheverifier/client.py +0 -0
- {cacheverifier-0.1.0 → cacheverifier-0.2.0}/cacheverifier/integrations/__init__.py +0 -0
- {cacheverifier-0.1.0 → cacheverifier-0.2.0}/cacheverifier/integrations/gptcache.py +0 -0
- {cacheverifier-0.1.0 → cacheverifier-0.2.0}/cacheverifier/py.typed +0 -0
- {cacheverifier-0.1.0 → cacheverifier-0.2.0}/examples/gptcache_example.py +0 -0
- {cacheverifier-0.1.0 → cacheverifier-0.2.0}/examples/quickstart.py +0 -0
- {cacheverifier-0.1.0 → cacheverifier-0.2.0}/tests/test_client.py +0 -0
- {cacheverifier-0.1.0 → cacheverifier-0.2.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,18 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
Add a `cacheverifier` console script with a `healthcheck` subcommand:
|
|
6
|
+
`cacheverifier healthcheck traffic.jsonl` runs the hosted Health Check
|
|
7
|
+
Report's stock-vs-fine-tuned held-out AUC evaluation (`POST /v1/finetune/dry-run`)
|
|
8
|
+
entirely offline — no queries or answers leave the machine. Needs the new
|
|
9
|
+
`healthcheck` extra (`pip install "cacheverifier[healthcheck]"`: torch,
|
|
10
|
+
sentence-transformers, numpy); the base client stays `httpx`-only.
|
|
11
|
+
`--emit-summary` writes an aggregate-only JSON file with no text.
|
|
12
|
+
|
|
13
|
+
## 0.1.0
|
|
14
|
+
|
|
15
|
+
Initial release: thin `httpx`-based client for `/v1/verify`, `/v1/verify/batch`,
|
|
16
|
+
`/v1/feedback`(`/batch`), fine-tune jobs + dry run, drift and gray-zone-threshold
|
|
17
|
+
monitoring, and usage/savings. GPTCache `SimilarityEvaluation` adapter under
|
|
18
|
+
`cacheverifier.integrations.gptcache`.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: cacheverifier
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.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
|
|
|
@@ -123,6 +130,41 @@ if job.get("result_model_version"):
|
|
|
123
130
|
`cv.dry_run([...])` reports the same baseline-vs-tuned AUC on examples you pass directly,
|
|
124
131
|
without writing anything or deploying a model.
|
|
125
132
|
|
|
133
|
+
## Local Health Check (offline)
|
|
134
|
+
|
|
135
|
+
`cv.dry_run()` still uploads your examples to the API. If that's a blocker — a
|
|
136
|
+
compliance review, or just not wanting production traffic to leave your network —
|
|
137
|
+
run the identical stock-vs-fine-tuned evaluation entirely on your own machine:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
pip install "cacheverifier[healthcheck]"
|
|
141
|
+
|
|
142
|
+
cacheverifier healthcheck traffic.jsonl
|
|
143
|
+
cacheverifier healthcheck traffic.jsonl --emit-summary summary.json
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
`traffic.jsonl` is a JSON array or JSONL of `{"query", "candidate_answer", "was_correct"}`
|
|
147
|
+
rows **in arrival order** (the train/calibrate/test split is chronological, matching the
|
|
148
|
+
hosted service so the numbers are comparable). Optional per row: `"stale": true`.
|
|
149
|
+
|
|
150
|
+
Nothing is sent anywhere — the base model downloads once from Hugging Face, then it's
|
|
151
|
+
fully offline. `--emit-summary` writes an aggregate-only JSON file (AUCs, counts, rates —
|
|
152
|
+
no query or answer text) that's safe to share for a human read.
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
results
|
|
156
|
+
------------------------------------------------------------------
|
|
157
|
+
train / calibrate / test: 3349 / 419 / 419
|
|
158
|
+
stock verifier held-out AUC: 0.6120
|
|
159
|
+
fine-tuned held-out AUC: 0.7080 (delta +0.0960)
|
|
160
|
+
label-noise proxy (disagreement): 11.4%
|
|
161
|
+
ceiling status: still_improvable
|
|
162
|
+
|
|
163
|
+
verdict
|
|
164
|
+
------------------------------------------------------------------
|
|
165
|
+
IMPROVED -- fine-tuning on your own data helps this traffic
|
|
166
|
+
```
|
|
167
|
+
|
|
126
168
|
## API surface
|
|
127
169
|
|
|
128
170
|
| 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
|
|
|
@@ -95,6 +98,41 @@ if job.get("result_model_version"):
|
|
|
95
98
|
`cv.dry_run([...])` reports the same baseline-vs-tuned AUC on examples you pass directly,
|
|
96
99
|
without writing anything or deploying a model.
|
|
97
100
|
|
|
101
|
+
## Local Health Check (offline)
|
|
102
|
+
|
|
103
|
+
`cv.dry_run()` still uploads your examples to the API. If that's a blocker — a
|
|
104
|
+
compliance review, or just not wanting production traffic to leave your network —
|
|
105
|
+
run the identical stock-vs-fine-tuned evaluation entirely on your own machine:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
pip install "cacheverifier[healthcheck]"
|
|
109
|
+
|
|
110
|
+
cacheverifier healthcheck traffic.jsonl
|
|
111
|
+
cacheverifier healthcheck traffic.jsonl --emit-summary summary.json
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`traffic.jsonl` is a JSON array or JSONL of `{"query", "candidate_answer", "was_correct"}`
|
|
115
|
+
rows **in arrival order** (the train/calibrate/test split is chronological, matching the
|
|
116
|
+
hosted service so the numbers are comparable). Optional per row: `"stale": true`.
|
|
117
|
+
|
|
118
|
+
Nothing is sent anywhere — the base model downloads once from Hugging Face, then it's
|
|
119
|
+
fully offline. `--emit-summary` writes an aggregate-only JSON file (AUCs, counts, rates —
|
|
120
|
+
no query or answer text) that's safe to share for a human read.
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
results
|
|
124
|
+
------------------------------------------------------------------
|
|
125
|
+
train / calibrate / test: 3349 / 419 / 419
|
|
126
|
+
stock verifier held-out AUC: 0.6120
|
|
127
|
+
fine-tuned held-out AUC: 0.7080 (delta +0.0960)
|
|
128
|
+
label-noise proxy (disagreement): 11.4%
|
|
129
|
+
ceiling status: still_improvable
|
|
130
|
+
|
|
131
|
+
verdict
|
|
132
|
+
------------------------------------------------------------------
|
|
133
|
+
IMPROVED -- fine-tuning on your own data helps this traffic
|
|
134
|
+
```
|
|
135
|
+
|
|
98
136
|
## API surface
|
|
99
137
|
|
|
100
138
|
| 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.2.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
|
+
)
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Argument parsing, IO, and the printed report for `cacheverifier healthcheck`.
|
|
2
|
+
|
|
3
|
+
The heavy work (torch, sentence-transformers) lives in `._finetune` and is
|
|
4
|
+
imported only inside `run()`, so `cacheverifier --help` stays light and
|
|
5
|
+
works without the `healthcheck` extra installed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
_MISSING_EXTRA_HINT = (
|
|
17
|
+
"the healthcheck extra is not installed -- run:\n"
|
|
18
|
+
' pip install "cacheverifier[healthcheck]"\n'
|
|
19
|
+
"(this pulls in torch + sentence-transformers; it is deliberately not a\n"
|
|
20
|
+
"dependency of the base client)"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def add_subparser(subparsers: argparse._SubParsersAction) -> None:
|
|
25
|
+
p = subparsers.add_parser(
|
|
26
|
+
"healthcheck",
|
|
27
|
+
help="run the Health Check evaluation locally (no data leaves your machine)",
|
|
28
|
+
description=(
|
|
29
|
+
"Fine-tune the stock verifier on a chronological prefix of your own "
|
|
30
|
+
"gray-zone cache hits and measure held-out AUC before and after -- the "
|
|
31
|
+
"same evaluation as POST /v1/finetune/dry-run, but entirely offline. "
|
|
32
|
+
"Your queries and answers never leave this host; only the optional "
|
|
33
|
+
"--emit-summary file (floats and counts, no text) can."
|
|
34
|
+
),
|
|
35
|
+
)
|
|
36
|
+
p.add_argument(
|
|
37
|
+
"input",
|
|
38
|
+
type=Path,
|
|
39
|
+
help="JSON array or JSONL of {query, candidate_answer, was_correct} rows, in arrival order",
|
|
40
|
+
)
|
|
41
|
+
p.add_argument(
|
|
42
|
+
"--emit-summary",
|
|
43
|
+
type=Path,
|
|
44
|
+
default=None,
|
|
45
|
+
metavar="PATH",
|
|
46
|
+
help="also write an aggregate-only JSON summary (no query/answer text) to PATH",
|
|
47
|
+
)
|
|
48
|
+
p.add_argument(
|
|
49
|
+
"--epochs",
|
|
50
|
+
type=int,
|
|
51
|
+
default=1,
|
|
52
|
+
help="fine-tuning epochs (default: 1, matching the hosted service)",
|
|
53
|
+
)
|
|
54
|
+
p.add_argument(
|
|
55
|
+
"--keep-model",
|
|
56
|
+
action="store_true",
|
|
57
|
+
help="keep the locally fine-tuned model directory instead of deleting it",
|
|
58
|
+
)
|
|
59
|
+
p.set_defaults(func=run)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _load_rows(path: Path) -> list[dict[str, Any]]:
|
|
63
|
+
text = path.read_text()
|
|
64
|
+
if text.lstrip().startswith("["):
|
|
65
|
+
rows = json.loads(text)
|
|
66
|
+
else:
|
|
67
|
+
rows = [json.loads(line) for line in text.splitlines() if line.strip()]
|
|
68
|
+
if not isinstance(rows, list):
|
|
69
|
+
# ValueError, not TypeError: this is bad input data, not a caller
|
|
70
|
+
# bug -- run() catches it alongside JSONDecodeError and prints it.
|
|
71
|
+
raise ValueError("input must be a JSON array or JSONL of objects") # noqa: TRY004
|
|
72
|
+
return rows
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _to_examples(rows: list[dict[str, Any]], example_cls: Any) -> tuple[list[Any], int]:
|
|
76
|
+
examples = []
|
|
77
|
+
n_stale = 0
|
|
78
|
+
for i, row in enumerate(rows):
|
|
79
|
+
missing = {"query", "candidate_answer", "was_correct"} - row.keys()
|
|
80
|
+
if missing:
|
|
81
|
+
raise ValueError(f"row {i}: missing key(s) {sorted(missing)}")
|
|
82
|
+
if row.get("stale"):
|
|
83
|
+
n_stale += 1
|
|
84
|
+
continue
|
|
85
|
+
examples.append(
|
|
86
|
+
example_cls(
|
|
87
|
+
query=str(row["query"]),
|
|
88
|
+
candidate_answer=str(row["candidate_answer"]),
|
|
89
|
+
was_correct=bool(row["was_correct"]),
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
return examples, n_stale
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _fmt(x: float | None, *, pct: bool = False) -> str:
|
|
96
|
+
if x is None:
|
|
97
|
+
return "n/a"
|
|
98
|
+
return f"{x * 100:.1f}%" if pct else f"{x:.4f}"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _verdict(delta: float) -> str:
|
|
102
|
+
if delta >= 0.02:
|
|
103
|
+
return "IMPROVED -- fine-tuning on your own data helps this traffic"
|
|
104
|
+
if delta <= -0.02:
|
|
105
|
+
return "WORSE -- fine-tuning hurt; your signal may be too noisy or too sparse"
|
|
106
|
+
return "NO CHANGE -- fine-tuning neither helped nor hurt measurably"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def run(args: argparse.Namespace) -> int:
|
|
110
|
+
try:
|
|
111
|
+
from cacheverifier._healthcheck import _finetune
|
|
112
|
+
except ImportError as e: # torch / sentence-transformers / numpy absent
|
|
113
|
+
print(f"error: {_MISSING_EXTRA_HINT}\n\n(import failed: {e})", file=sys.stderr)
|
|
114
|
+
return 2
|
|
115
|
+
|
|
116
|
+
input_path: Path = args.input
|
|
117
|
+
if not input_path.exists():
|
|
118
|
+
print(f"error: input file not found: {input_path}", file=sys.stderr)
|
|
119
|
+
return 2
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
rows = _load_rows(input_path)
|
|
123
|
+
examples, n_stale = _to_examples(rows, _finetune.GrayZoneExample)
|
|
124
|
+
except (ValueError, json.JSONDecodeError, OSError) as e:
|
|
125
|
+
print(f"error: {e}", file=sys.stderr)
|
|
126
|
+
return 2
|
|
127
|
+
|
|
128
|
+
n = len(examples)
|
|
129
|
+
print(f"\nCacheVerifier local Health Check -- {input_path}")
|
|
130
|
+
print("=" * 66)
|
|
131
|
+
print(f"rows read: {len(rows)}")
|
|
132
|
+
print(f"usable (non-stale): {n}")
|
|
133
|
+
if n_stale:
|
|
134
|
+
print(f"stale, excluded: {n_stale}")
|
|
135
|
+
|
|
136
|
+
if n < _finetune.MIN_TRAIN_EXAMPLES:
|
|
137
|
+
print(f"\nNeed at least {_finetune.MIN_TRAIN_EXAMPLES} non-stale rows to run; have {n}.", file=sys.stderr)
|
|
138
|
+
return 2
|
|
139
|
+
if n < _finetune.COLD_START_WARNING_THRESHOLD:
|
|
140
|
+
print(
|
|
141
|
+
f"\nNote: {n} rows is below the ~{_finetune.COLD_START_WARNING_THRESHOLD} the research "
|
|
142
|
+
"(cacheverifier.com/research, Q2) found fine-tuning reliably positive across every\n"
|
|
143
|
+
" dataset. A small-sample result here is real but noisy -- re-run as feedback grows."
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
import tempfile
|
|
147
|
+
|
|
148
|
+
out_dir = tempfile.mkdtemp(prefix="cacheverifier_healthcheck_")
|
|
149
|
+
print(f"\nfine-tuning locally (nothing sent) -> {out_dir}")
|
|
150
|
+
print("this takes a few minutes on CPU; the base model downloads once on first run...\n")
|
|
151
|
+
|
|
152
|
+
result = _finetune.run_healthcheck(examples, out_dir, base_model=_finetune.DEFAULT_BASE_MODEL, epochs=args.epochs)
|
|
153
|
+
|
|
154
|
+
print("results")
|
|
155
|
+
print("-" * 66)
|
|
156
|
+
print(f" train / calibrate / test: {result.n_train} / {result.n_calibrate} / {result.n_test}")
|
|
157
|
+
print(f" stock verifier held-out AUC: {_fmt(result.auc_baseline)}")
|
|
158
|
+
print(f" fine-tuned held-out AUC: {_fmt(result.auc_tuned)} (delta {result.auc_delta:+.4f})")
|
|
159
|
+
print(f" label-noise proxy (disagreement): {_fmt(result.train_label_disagreement_rate, pct=True)}")
|
|
160
|
+
if result.train_label_disagreement_rate is not None:
|
|
161
|
+
if result.train_label_disagreement_rate > _finetune.NOISE_HOLD_BACK_DISAGREEMENT_RATE:
|
|
162
|
+
print(" -> HIGH: the hosted service would hold this model back for review")
|
|
163
|
+
elif result.train_label_disagreement_rate > _finetune.NOISE_WARNING_DISAGREEMENT_RATE:
|
|
164
|
+
print(" -> elevated: worth auditing how your correctness signal is produced")
|
|
165
|
+
print(f" train / test positive rate: {_fmt(result.train_positive_rate, pct=True)} / {_fmt(result.test_positive_rate, pct=True)}")
|
|
166
|
+
print(f" ceiling status: {result.ceiling_status or 'n/a'}")
|
|
167
|
+
if result.threshold is not None:
|
|
168
|
+
print(
|
|
169
|
+
f" operating point @ picked threshold: hit rate {_fmt(result.threshold_hit_rate, pct=True)}, "
|
|
170
|
+
f"error rate {_fmt(result.threshold_error_rate, pct=True)}"
|
|
171
|
+
)
|
|
172
|
+
print("\nverdict")
|
|
173
|
+
print("-" * 66)
|
|
174
|
+
print(f" {_verdict(result.auc_delta)}\n")
|
|
175
|
+
|
|
176
|
+
if args.emit_summary is not None:
|
|
177
|
+
summary = {
|
|
178
|
+
"n_rows_read": len(rows),
|
|
179
|
+
"n_usable": n,
|
|
180
|
+
"n_stale_excluded": n_stale,
|
|
181
|
+
"n_train": result.n_train,
|
|
182
|
+
"n_calibrate": result.n_calibrate,
|
|
183
|
+
"n_test": result.n_test,
|
|
184
|
+
"auc_stock": round(result.auc_baseline, 4),
|
|
185
|
+
"auc_finetuned": round(result.auc_tuned, 4),
|
|
186
|
+
"auc_delta": round(result.auc_delta, 4),
|
|
187
|
+
"train_label_disagreement_rate": (
|
|
188
|
+
round(result.train_label_disagreement_rate, 4)
|
|
189
|
+
if result.train_label_disagreement_rate is not None
|
|
190
|
+
else None
|
|
191
|
+
),
|
|
192
|
+
"train_positive_rate": round(result.train_positive_rate, 4),
|
|
193
|
+
"test_positive_rate": round(result.test_positive_rate, 4),
|
|
194
|
+
"ceiling_status": result.ceiling_status,
|
|
195
|
+
"threshold_hit_rate": result.threshold_hit_rate,
|
|
196
|
+
"threshold_error_rate": result.threshold_error_rate,
|
|
197
|
+
}
|
|
198
|
+
args.emit_summary.write_text(json.dumps(summary, indent=2) + "\n")
|
|
199
|
+
print(f"aggregate summary written to {args.emit_summary}")
|
|
200
|
+
print("(floats and counts only -- no query or answer text -- safe to share for a human read)\n")
|
|
201
|
+
|
|
202
|
+
if args.keep_model:
|
|
203
|
+
print(f"fine-tuned model kept at {result.model_path}")
|
|
204
|
+
else:
|
|
205
|
+
import shutil
|
|
206
|
+
|
|
207
|
+
shutil.rmtree(out_dir, ignore_errors=True)
|
|
208
|
+
|
|
209
|
+
return 0
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cacheverifier"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Python client for the hosted CacheVerifier semantic-cache verification API"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -29,6 +29,9 @@ classifiers = [
|
|
|
29
29
|
]
|
|
30
30
|
dependencies = ["httpx>=0.24"]
|
|
31
31
|
|
|
32
|
+
[project.scripts]
|
|
33
|
+
cacheverifier = "cacheverifier.__main__:main"
|
|
34
|
+
|
|
32
35
|
[project.urls]
|
|
33
36
|
Homepage = "https://www.cacheverifier.com"
|
|
34
37
|
Documentation = "https://www.cacheverifier.com/docs"
|
|
@@ -37,6 +40,20 @@ Research = "https://github.com/imxinchengyou/CacheVerifier"
|
|
|
37
40
|
|
|
38
41
|
[project.optional-dependencies]
|
|
39
42
|
gptcache = ["gptcache>=0.1.30"]
|
|
43
|
+
# `cacheverifier healthcheck` -- runs the hosted Health Check Report's
|
|
44
|
+
# stock-vs-fine-tuned evaluation locally. Deliberately NOT a base
|
|
45
|
+
# dependency: torch + sentence-transformers dwarf the httpx-only client.
|
|
46
|
+
# sentence-transformers is pinned <4 for the pre-4.0 CrossEncoder.fit()
|
|
47
|
+
# API this uses (the >=4 line needs torch>=2.4, which drops older Pythons).
|
|
48
|
+
# Effectively wants Python >=3.10.
|
|
49
|
+
healthcheck = [
|
|
50
|
+
# numpy <2: the torch builds these versions pull are compiled against
|
|
51
|
+
# the numpy 1.x ABI ("Failed to initialize NumPy: _ARRAY_API not found"
|
|
52
|
+
# on numpy 2). Same constraint the hosted service uses.
|
|
53
|
+
"numpy>=1.26,<2",
|
|
54
|
+
"torch>=2.2",
|
|
55
|
+
"sentence-transformers>=3.0,<4",
|
|
56
|
+
]
|
|
40
57
|
dev = ["pytest>=7", "mypy>=1.5", "ruff>=0.4"]
|
|
41
58
|
|
|
42
59
|
[tool.hatch.build.targets.wheel]
|
|
@@ -60,3 +77,11 @@ warn_redundant_casts = true
|
|
|
60
77
|
[[tool.mypy.overrides]]
|
|
61
78
|
module = "gptcache.*"
|
|
62
79
|
ignore_missing_imports = true
|
|
80
|
+
|
|
81
|
+
[[tool.mypy.overrides]]
|
|
82
|
+
# The healthcheck extra's deps: not installed in the base test/type-check
|
|
83
|
+
# env (CI runs `pip install -e ".[dev]"`, not `[dev,healthcheck]`), and the
|
|
84
|
+
# `_healthcheck` subpackage is only importable when that extra is present
|
|
85
|
+
# anyway. numpy ships inline types but only when installed.
|
|
86
|
+
module = ["torch.*", "sentence_transformers.*", "numpy.*"]
|
|
87
|
+
ignore_missing_imports = true
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""CLI dispatch + Health Check runner.
|
|
2
|
+
|
|
3
|
+
The dispatch/parsing tests run everywhere (base install). The end-to-end
|
|
4
|
+
training test needs the `healthcheck` extra and is skipped when torch is
|
|
5
|
+
absent -- CI's base matrix skips it; the dedicated `healthcheck` job runs
|
|
6
|
+
it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import importlib.util
|
|
12
|
+
import json
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
from cacheverifier.__main__ import main
|
|
18
|
+
from cacheverifier._healthcheck import cli
|
|
19
|
+
|
|
20
|
+
HAS_TORCH = importlib.util.find_spec("torch") is not None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class _DummyExample:
|
|
25
|
+
query: str
|
|
26
|
+
candidate_answer: str
|
|
27
|
+
was_correct: bool
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TestDispatch:
|
|
31
|
+
def test_version(self, capsys):
|
|
32
|
+
with pytest.raises(SystemExit) as e:
|
|
33
|
+
main(["--version"])
|
|
34
|
+
assert e.value.code == 0
|
|
35
|
+
assert "cacheverifier 0.2.0" in capsys.readouterr().out
|
|
36
|
+
|
|
37
|
+
def test_no_command_prints_help_and_returns_1(self, capsys):
|
|
38
|
+
assert main([]) == 1
|
|
39
|
+
assert "healthcheck" in capsys.readouterr().out
|
|
40
|
+
|
|
41
|
+
def test_healthcheck_help(self):
|
|
42
|
+
with pytest.raises(SystemExit) as e:
|
|
43
|
+
main(["healthcheck", "--help"])
|
|
44
|
+
assert e.value.code == 0
|
|
45
|
+
|
|
46
|
+
@pytest.mark.skipif(HAS_TORCH, reason="torch present -- the missing-extra path can't be exercised")
|
|
47
|
+
def test_healthcheck_without_extra_points_at_pip_install(self, capsys, tmp_path):
|
|
48
|
+
f = tmp_path / "t.jsonl"
|
|
49
|
+
f.write_text("")
|
|
50
|
+
assert main(["healthcheck", str(f)]) == 2
|
|
51
|
+
assert 'pip install "cacheverifier[healthcheck]"' in capsys.readouterr().err
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TestParsing:
|
|
55
|
+
def test_load_rows_accepts_json_array_and_jsonl(self, tmp_path):
|
|
56
|
+
rows = [{"a": 1}, {"a": 2}]
|
|
57
|
+
arr = tmp_path / "a.json"
|
|
58
|
+
arr.write_text(json.dumps(rows))
|
|
59
|
+
jsonl = tmp_path / "a.jsonl"
|
|
60
|
+
jsonl.write_text("\n".join(json.dumps(r) for r in rows) + "\n")
|
|
61
|
+
assert cli._load_rows(arr) == rows
|
|
62
|
+
assert cli._load_rows(jsonl) == rows
|
|
63
|
+
|
|
64
|
+
def test_to_examples_skips_stale_and_counts_them(self):
|
|
65
|
+
rows = [
|
|
66
|
+
{"query": "q1", "candidate_answer": "a1", "was_correct": True},
|
|
67
|
+
{"query": "q2", "candidate_answer": "a2", "was_correct": False, "stale": True},
|
|
68
|
+
]
|
|
69
|
+
examples, n_stale = cli._to_examples(rows, _DummyExample)
|
|
70
|
+
assert len(examples) == 1
|
|
71
|
+
assert n_stale == 1
|
|
72
|
+
assert examples[0] == _DummyExample("q1", "a1", True)
|
|
73
|
+
|
|
74
|
+
def test_to_examples_rejects_missing_keys(self):
|
|
75
|
+
with pytest.raises(ValueError, match="missing key"):
|
|
76
|
+
cli._to_examples([{"query": "q"}], _DummyExample)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@pytest.mark.skipif(not HAS_TORCH, reason="needs the healthcheck extra (torch, sentence-transformers)")
|
|
80
|
+
def test_run_healthcheck_end_to_end(tmp_path):
|
|
81
|
+
from cacheverifier._healthcheck._finetune import GrayZoneExample, HealthCheckResult, run_healthcheck
|
|
82
|
+
|
|
83
|
+
groups = [
|
|
84
|
+
("how do I cancel my subscription", "Settings > Billing > Cancel.", "Settings > Billing > Pause a month."),
|
|
85
|
+
("how do I get a refund", "Orders > Return > Request refund.", "Orders > Return > Exchange size."),
|
|
86
|
+
("how do I reset my password", "Account > Security > Reset password.", "Account > Security > Enable 2FA."),
|
|
87
|
+
]
|
|
88
|
+
examples = []
|
|
89
|
+
for i in range(36):
|
|
90
|
+
q, good, bad = groups[i % len(groups)]
|
|
91
|
+
correct = i % 2 == 0
|
|
92
|
+
examples.append(GrayZoneExample(q, good if correct else bad, correct))
|
|
93
|
+
|
|
94
|
+
result = run_healthcheck(examples, str(tmp_path / "model"), epochs=1)
|
|
95
|
+
assert isinstance(result, HealthCheckResult)
|
|
96
|
+
assert result.n_train + result.n_calibrate + result.n_test == 36
|
|
97
|
+
assert 0.0 <= result.auc_tuned <= 1.0
|
|
98
|
+
assert (tmp_path / "model").is_dir()
|
|
@@ -1,36 +0,0 @@
|
|
|
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
|
-
build:
|
|
28
|
-
runs-on: ubuntu-latest
|
|
29
|
-
steps:
|
|
30
|
-
- uses: actions/checkout@v4
|
|
31
|
-
- uses: actions/setup-python@v5
|
|
32
|
-
with:
|
|
33
|
-
python-version: "3.12"
|
|
34
|
-
- run: pip install build twine
|
|
35
|
-
- run: python -m build
|
|
36
|
-
- run: twine check dist/*
|
cacheverifier-0.1.0/CHANGELOG.md
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
## 0.1.0
|
|
4
|
-
|
|
5
|
-
Initial release: thin `httpx`-based client for `/v1/verify`, `/v1/verify/batch`,
|
|
6
|
-
`/v1/feedback`(`/batch`), fine-tune jobs + dry run, drift and gray-zone-threshold
|
|
7
|
-
monitoring, and usage/savings. GPTCache `SimilarityEvaluation` adapter under
|
|
8
|
-
`cacheverifier.integrations.gptcache`.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|