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