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.
Files changed (24) hide show
  1. cacheverifier-0.2.0/.github/workflows/ci.yml +57 -0
  2. cacheverifier-0.2.0/CHANGELOG.md +18 -0
  3. {cacheverifier-0.1.0 → cacheverifier-0.2.0}/PKG-INFO +44 -2
  4. {cacheverifier-0.1.0 → cacheverifier-0.2.0}/README.md +39 -1
  5. {cacheverifier-0.1.0 → cacheverifier-0.2.0}/cacheverifier/__init__.py +1 -1
  6. cacheverifier-0.2.0/cacheverifier/__main__.py +44 -0
  7. cacheverifier-0.2.0/cacheverifier/_healthcheck/__init__.py +7 -0
  8. cacheverifier-0.2.0/cacheverifier/_healthcheck/_finetune.py +274 -0
  9. cacheverifier-0.2.0/cacheverifier/_healthcheck/cli.py +209 -0
  10. {cacheverifier-0.1.0 → cacheverifier-0.2.0}/pyproject.toml +26 -1
  11. cacheverifier-0.2.0/tests/test_healthcheck.py +98 -0
  12. cacheverifier-0.1.0/.github/workflows/ci.yml +0 -36
  13. cacheverifier-0.1.0/CHANGELOG.md +0 -8
  14. {cacheverifier-0.1.0 → cacheverifier-0.2.0}/.github/workflows/publish.yml +0 -0
  15. {cacheverifier-0.1.0 → cacheverifier-0.2.0}/.gitignore +0 -0
  16. {cacheverifier-0.1.0 → cacheverifier-0.2.0}/LICENSE +0 -0
  17. {cacheverifier-0.1.0 → cacheverifier-0.2.0}/cacheverifier/client.py +0 -0
  18. {cacheverifier-0.1.0 → cacheverifier-0.2.0}/cacheverifier/integrations/__init__.py +0 -0
  19. {cacheverifier-0.1.0 → cacheverifier-0.2.0}/cacheverifier/integrations/gptcache.py +0 -0
  20. {cacheverifier-0.1.0 → cacheverifier-0.2.0}/cacheverifier/py.typed +0 -0
  21. {cacheverifier-0.1.0 → cacheverifier-0.2.0}/examples/gptcache_example.py +0 -0
  22. {cacheverifier-0.1.0 → cacheverifier-0.2.0}/examples/quickstart.py +0 -0
  23. {cacheverifier-0.1.0 → cacheverifier-0.2.0}/tests/test_client.py +0 -0
  24. {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.1.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.1.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.1.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/*
@@ -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