cacheverifier 0.3.0__tar.gz → 0.4.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 → cacheverifier-0.4.0}/CHANGELOG.md +23 -0
  2. {cacheverifier-0.3.0 → cacheverifier-0.4.0}/PKG-INFO +23 -1
  3. {cacheverifier-0.3.0 → cacheverifier-0.4.0}/README.md +22 -0
  4. {cacheverifier-0.3.0 → cacheverifier-0.4.0}/cacheverifier/__init__.py +1 -1
  5. cacheverifier-0.4.0/cacheverifier/_healthcheck/_base_models.py +62 -0
  6. {cacheverifier-0.3.0 → cacheverifier-0.4.0}/cacheverifier/_healthcheck/_finetune.py +53 -11
  7. {cacheverifier-0.3.0 → cacheverifier-0.4.0}/cacheverifier/_healthcheck/cli.py +42 -3
  8. {cacheverifier-0.3.0 → cacheverifier-0.4.0}/pyproject.toml +1 -1
  9. cacheverifier-0.4.0/tests/test_healthcheck.py +205 -0
  10. cacheverifier-0.3.0/tests/test_healthcheck.py +0 -100
  11. {cacheverifier-0.3.0 → cacheverifier-0.4.0}/.github/workflows/ci.yml +0 -0
  12. {cacheverifier-0.3.0 → cacheverifier-0.4.0}/.github/workflows/publish.yml +0 -0
  13. {cacheverifier-0.3.0 → cacheverifier-0.4.0}/.gitignore +0 -0
  14. {cacheverifier-0.3.0 → cacheverifier-0.4.0}/LICENSE +0 -0
  15. {cacheverifier-0.3.0 → cacheverifier-0.4.0}/cacheverifier/__main__.py +0 -0
  16. {cacheverifier-0.3.0 → cacheverifier-0.4.0}/cacheverifier/_healthcheck/__init__.py +0 -0
  17. {cacheverifier-0.3.0 → cacheverifier-0.4.0}/cacheverifier/client.py +0 -0
  18. {cacheverifier-0.3.0 → cacheverifier-0.4.0}/cacheverifier/integrations/__init__.py +0 -0
  19. {cacheverifier-0.3.0 → cacheverifier-0.4.0}/cacheverifier/integrations/gptcache.py +0 -0
  20. {cacheverifier-0.3.0 → cacheverifier-0.4.0}/cacheverifier/py.typed +0 -0
  21. {cacheverifier-0.3.0 → cacheverifier-0.4.0}/examples/gptcache_example.py +0 -0
  22. {cacheverifier-0.3.0 → cacheverifier-0.4.0}/examples/quickstart.py +0 -0
  23. {cacheverifier-0.3.0 → cacheverifier-0.4.0}/tests/test_client.py +0 -0
  24. {cacheverifier-0.3.0 → cacheverifier-0.4.0}/tests/test_gptcache_integration.py +0 -0
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0
4
+
5
+ `cacheverifier healthcheck --base-model` picks the model the local Health
6
+ Check starts from: `ms_marco` (default, unchanged), `nli`, `multilingual` or
7
+ `multilingual_small` — the same choices as the hosted service's `base_model`
8
+ — or any Hugging Face model id / local model directory. Chinese and other
9
+ non-English traffic should use a multilingual base; the CLI now warns when
10
+ most of the input is CJK text and the base is English-only. The NLI base's
11
+ 3-class head is scored `P(entailment) - P(contradiction)` for the stock
12
+ baseline and re-initialized to a single logit for fine-tuning, matching the
13
+ hosted service. `--emit-summary` records the base model (a local directory
14
+ is recorded only as "local directory").
15
+
16
+ ## 0.3.1
17
+
18
+ `cacheverifier healthcheck` now actually fine-tunes. It passed no
19
+ `warmup_steps` to `CrossEncoder.fit`, whose default of 10000 kept a typical
20
+ run below a few percent of its learning rate, so `auc_tuned` came back equal
21
+ (or nearly equal) to `auc_baseline`. Warmup is now 10% of total steps and the
22
+ default is 3 epochs (`--epochs`), the same as the hosted service since
23
+ verifier-core 0.2.0. On a 500-row later holdout this took LmArena AUC from
24
+ 0.903 to 0.932 and AmazonHelp from 0.551 to 0.700. Runs take about 3x as long.
25
+
3
26
  ## 0.3.0
4
27
 
5
28
  Make the request-path defaults safe. `verify()` and `verify_batch()` now use a
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cacheverifier
3
- Version: 0.3.0
3
+ Version: 0.4.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
@@ -177,6 +177,28 @@ Nothing is sent anywhere — the base model downloads once from Hugging Face, th
177
177
  fully offline. `--emit-summary` writes an aggregate-only JSON file (AUCs, counts, rates —
178
178
  no query or answer text) that's safe to share for a human read.
179
179
 
180
+ **Choosing the base model.** `--base-model` takes the same values as the hosted
181
+ service's `base_model` fine-tune parameter, so local and hosted numbers stay comparable:
182
+
183
+ | `--base-model` | model | use for |
184
+ |---|---|---|
185
+ | `ms_marco` (default) | `cross-encoder/ms-marco-MiniLM-L6-v2` | English traffic |
186
+ | `nli` | `cross-encoder/nli-MiniLM2-L6-H768` | English; entailment pretraining, stronger on negation / entity swaps |
187
+ | `multilingual` | `BAAI/bge-reranker-base` | Chinese and other non-English traffic (~6 GB RAM to fine-tune) |
188
+ | `multilingual_small` | `cross-encoder/mmarco-mMiniLMv2-L12-H384-v1` | same, smaller (~3 GB RAM) |
189
+
190
+ The English bases read most Chinese characters as unknown tokens, so on Chinese
191
+ traffic their result doesn't mean anything — the CLI warns when it sees mostly CJK text
192
+ on an English base. You can also pass any Hugging Face model id or a local model
193
+ directory. If Hugging Face is unreachable from your network, set `HF_ENDPOINT` to a
194
+ mirror, or download the model elsewhere and pass its directory:
195
+
196
+ ```bash
197
+ cacheverifier healthcheck traffic.jsonl --base-model multilingual
198
+ HF_ENDPOINT=https://hf-mirror.com cacheverifier healthcheck traffic.jsonl --base-model multilingual_small
199
+ cacheverifier healthcheck traffic.jsonl --base-model ./models/bge-reranker-base
200
+ ```
201
+
180
202
  ```
181
203
  results
182
204
  ------------------------------------------------------------------
@@ -145,6 +145,28 @@ Nothing is sent anywhere — the base model downloads once from Hugging Face, th
145
145
  fully offline. `--emit-summary` writes an aggregate-only JSON file (AUCs, counts, rates —
146
146
  no query or answer text) that's safe to share for a human read.
147
147
 
148
+ **Choosing the base model.** `--base-model` takes the same values as the hosted
149
+ service's `base_model` fine-tune parameter, so local and hosted numbers stay comparable:
150
+
151
+ | `--base-model` | model | use for |
152
+ |---|---|---|
153
+ | `ms_marco` (default) | `cross-encoder/ms-marco-MiniLM-L6-v2` | English traffic |
154
+ | `nli` | `cross-encoder/nli-MiniLM2-L6-H768` | English; entailment pretraining, stronger on negation / entity swaps |
155
+ | `multilingual` | `BAAI/bge-reranker-base` | Chinese and other non-English traffic (~6 GB RAM to fine-tune) |
156
+ | `multilingual_small` | `cross-encoder/mmarco-mMiniLMv2-L12-H384-v1` | same, smaller (~3 GB RAM) |
157
+
158
+ The English bases read most Chinese characters as unknown tokens, so on Chinese
159
+ traffic their result doesn't mean anything — the CLI warns when it sees mostly CJK text
160
+ on an English base. You can also pass any Hugging Face model id or a local model
161
+ directory. If Hugging Face is unreachable from your network, set `HF_ENDPOINT` to a
162
+ mirror, or download the model elsewhere and pass its directory:
163
+
164
+ ```bash
165
+ cacheverifier healthcheck traffic.jsonl --base-model multilingual
166
+ HF_ENDPOINT=https://hf-mirror.com cacheverifier healthcheck traffic.jsonl --base-model multilingual_small
167
+ cacheverifier healthcheck traffic.jsonl --base-model ./models/bge-reranker-base
168
+ ```
169
+
148
170
  ```
149
171
  results
150
172
  ------------------------------------------------------------------
@@ -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.3.0"
21
+ __version__ = "0.4.0"
22
22
  __all__ = ["CacheVerifier", "CacheVerifierError", "VerifyResult", "__version__"]
@@ -0,0 +1,62 @@
1
+ """Base-model choices for `cacheverifier healthcheck --base-model`.
2
+
3
+ Kept free of torch / sentence-transformers so `cacheverifier healthcheck
4
+ --help` can list the choices without the `healthcheck` extra installed.
5
+ The aliases and model ids match the hosted service's `base_model` fine-tune
6
+ parameter, so a local run is comparable to `POST /v1/finetune/dry-run`
7
+ with the same value.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from pathlib import Path
14
+
15
+ DEFAULT_BASE_MODEL = "cross-encoder/ms-marco-MiniLM-L6-v2"
16
+ """The stock off-the-shelf verifier every tenant starts on -- the same base
17
+ model the hosted service fine-tunes from. English only."""
18
+
19
+ BASE_MODEL_CHOICES: dict[str, str] = {
20
+ "ms_marco": DEFAULT_BASE_MODEL,
21
+ # SNLI+MultiNLI entailment pretraining, same size as ms_marco. Ships a
22
+ # 3-class head; see _finetune for how it is scored and fine-tuned.
23
+ "nli": "cross-encoder/nli-MiniLM2-L6-H768",
24
+ # Non-English (e.g. Chinese) traffic: the English bases turn most
25
+ # Chinese characters into [UNK] at tokenization.
26
+ "multilingual": "BAAI/bge-reranker-base",
27
+ "multilingual_small": "cross-encoder/mmarco-mMiniLMv2-L12-H384-v1",
28
+ }
29
+
30
+ ENGLISH_ONLY_BASE_MODELS = frozenset({BASE_MODEL_CHOICES["ms_marco"], BASE_MODEL_CHOICES["nli"]})
31
+
32
+ _CJK_RE = re.compile(r"[぀-ヿ㐀-䶿一-鿿가-힯]")
33
+
34
+ CJK_WARNING_RATIO = 0.3
35
+ """Share of non-whitespace characters that are CJK above which an
36
+ English-only base model gets a warning."""
37
+
38
+
39
+ def resolve_base_model(value: str) -> str:
40
+ """An alias from `BASE_MODEL_CHOICES`, a Hugging Face model id
41
+ ("org/name"), or a local model directory (e.g. one downloaded from a
42
+ mirror or ModelScope). Raises ValueError for anything else, so a typo'd
43
+ alias fails immediately instead of after a failed download."""
44
+ if value in BASE_MODEL_CHOICES:
45
+ return BASE_MODEL_CHOICES[value]
46
+ # `Path("")` is the current directory -- never treat an empty value as
47
+ # a local model.
48
+ if value.strip() and Path(value).expanduser().is_dir():
49
+ return str(Path(value).expanduser())
50
+ if "/" in value and not value.startswith("/"):
51
+ return value
52
+ raise ValueError(
53
+ f"unknown base model {value!r}: use one of {', '.join(BASE_MODEL_CHOICES)}, "
54
+ "a Hugging Face model id (org/name), or a local model directory"
55
+ )
56
+
57
+
58
+ def cjk_ratio(texts: list[str]) -> float:
59
+ chars = "".join("".join(t.split()) for t in texts)
60
+ if not chars:
61
+ return 0.0
62
+ return len(_CJK_RE.findall(chars)) / len(chars)
@@ -24,15 +24,17 @@ import torch
24
24
  from sentence_transformers import CrossEncoder, InputExample
25
25
  from torch.utils.data import DataLoader
26
26
 
27
- # All four imports above are from the `healthcheck` extra. This module is
27
+ from cacheverifier._healthcheck._base_models import DEFAULT_BASE_MODEL
28
+
29
+ # The four third-party imports above are from the `healthcheck` extra. This module is
28
30
  # only ever imported from inside `cacheverifier._healthcheck.cli.run()`,
29
31
  # which catches the ImportError and points the user at
30
32
  # `pip install "cacheverifier[healthcheck]"` -- so a plain
31
33
  # `import cacheverifier` / `cacheverifier --help` never pays this cost.
32
34
 
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."""
35
+ NLI_LABEL_ORDER = ("contradiction", "entailment", "neutral")
36
+ """`cross-encoder/nli-*` models' class order, used when the loaded model
37
+ doesn't expose an `id2label` naming them."""
36
38
 
37
39
  MAX_SEQUENCE_LENGTH = 128
38
40
  """Token cap applied identically at train and score time. Matches the
@@ -43,6 +45,13 @@ _CONTENT_TOKEN_BUDGET = MAX_SEQUENCE_LENGTH - 3 # [CLS] + 2x [SEP]
43
45
  _MIN_QUERY_TOKENS = 32
44
46
 
45
47
  TRAIN_BATCH_SIZE = 4
48
+ TRAIN_EPOCHS = 3
49
+ TRAIN_WARMUP_FRACTION = 0.1
50
+ """Same as the hosted service (verifier-core 0.2.0). `CrossEncoder.fit`
51
+ defaults to `warmup_steps=10000`; without an explicit value a typical run
52
+ (a few hundred steps) never got past a few percent of its learning rate and
53
+ the "fine-tuned" model was a near monotone shift of the base model, so
54
+ `auc_tuned` came back equal to `auc_baseline`."""
46
55
  MIN_TRAIN_EXAMPLES = 20
47
56
  """Hard floor -- below this there aren't enough rows for one meaningful
48
57
  epoch plus a held-out split."""
@@ -178,12 +187,34 @@ def _diagnose_ceiling(scores: np.ndarray, labels: np.ndarray) -> str | None:
178
187
  return "still_improvable"
179
188
 
180
189
 
190
+ def _nli_column_indices(model: Any) -> tuple[int, int]:
191
+ """(entailment_idx, contradiction_idx) for a 3-class NLI cross-encoder."""
192
+ id2label = getattr(getattr(model, "config", None), "id2label", None)
193
+ if isinstance(id2label, dict) and len(id2label) == 3:
194
+ lut = {str(v).lower(): int(k) for k, v in id2label.items()}
195
+ if "entailment" in lut and "contradiction" in lut:
196
+ return lut["entailment"], lut["contradiction"]
197
+ return NLI_LABEL_ORDER.index("entailment"), NLI_LABEL_ORDER.index("contradiction")
198
+
199
+
200
+ def score_pairs(model: Any, pairs: list[tuple[str, str]], batch_size: int = 32) -> np.ndarray:
201
+ """Higher means "more likely an acceptable answer". A 1-logit model
202
+ (ms-marco, the multilingual rerankers, anything fine-tuned here) returns
203
+ its raw logit; a 3-class NLI model returns P(entailment) - P(contradiction)
204
+ -- the same scoring the hosted service uses for the stock NLI baseline."""
205
+ if int(model.config.num_labels) == 3:
206
+ ent, contra = _nli_column_indices(model)
207
+ probs = np.asarray(model.predict(pairs, batch_size=batch_size, show_progress_bar=False, apply_softmax=True))
208
+ return probs[:, ent] - probs[:, contra]
209
+ return np.asarray(model.predict(pairs, batch_size=batch_size, show_progress_bar=False))
210
+
211
+
181
212
  def run_healthcheck(
182
213
  examples: list[GrayZoneExample],
183
214
  output_dir: str,
184
215
  *,
185
216
  base_model: str = DEFAULT_BASE_MODEL,
186
- epochs: int = 1,
217
+ epochs: int = TRAIN_EPOCHS,
187
218
  ) -> HealthCheckResult:
188
219
  """Fine-tune `base_model` on a chronological prefix of `examples` and
189
220
  measure held-out AUC for the stock vs. fine-tuned model.
@@ -214,11 +245,21 @@ def run_healthcheck(
214
245
 
215
246
  baseline = CrossEncoder(base_model, device=device, max_length=MAX_SEQUENCE_LENGTH)
216
247
  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))
248
+ baseline_scores = score_pairs(baseline, test_pairs_scored)
218
249
  auc_baseline = roc_auc(baseline_scores, test_labels)
219
250
  del baseline
220
251
 
221
- tuned = CrossEncoder(base_model, device=device, max_length=MAX_SEQUENCE_LENGTH)
252
+ # num_labels=1 forces a single-logit head, matching the hosted service: a
253
+ # no-op for 1-logit bases, a deliberate head reinitialization (encoder
254
+ # body kept) for the 3-class NLI base. ignore_mismatched_sizes lets that
255
+ # 3->1 reinit happen instead of raising.
256
+ tuned = CrossEncoder(
257
+ base_model,
258
+ num_labels=1,
259
+ device=device,
260
+ max_length=MAX_SEQUENCE_LENGTH,
261
+ automodel_args={"ignore_mismatched_sizes": True},
262
+ )
222
263
  train_examples = [
223
264
  InputExample(
224
265
  texts=list(smart_truncate_pair(e.query, e.candidate_answer, tuned.tokenizer)),
@@ -233,15 +274,16 @@ def run_healthcheck(
233
274
  # whether or not torch's stubs are installed (base CI has no torch).
234
275
  loader: DataLoader[Any] = DataLoader(cast(Any, train_examples), shuffle=True, batch_size=TRAIN_BATCH_SIZE)
235
276
  t0 = time.time()
236
- tuned.fit(train_dataloader=loader, epochs=epochs, show_progress_bar=False)
277
+ warmup_steps = max(1, int(TRAIN_WARMUP_FRACTION * len(loader) * epochs))
278
+ tuned.fit(train_dataloader=loader, epochs=epochs, warmup_steps=warmup_steps, show_progress_bar=False)
237
279
  train_time_seconds = time.time() - t0
238
280
  tuned.save(output_dir)
239
281
 
240
282
  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))
283
+ calib_scores = score_pairs(tuned, calib_scored)
242
284
  threshold = select_threshold(calib_scores, calib_labels)
243
285
 
244
- tuned_scores = np.array(tuned.predict(test_pairs_scored, batch_size=32, show_progress_bar=False))
286
+ tuned_scores = score_pairs(tuned, test_pairs_scored)
245
287
  auc_tuned = roc_auc(tuned_scores, test_labels)
246
288
  ceiling_status = _diagnose_ceiling(tuned_scores, test_labels)
247
289
 
@@ -251,7 +293,7 @@ def run_healthcheck(
251
293
  if threshold is not None:
252
294
  threshold_hit_rate, threshold_error_rate = _hit_and_error_rate(tuned_scores, test_labels, threshold)
253
295
  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))
296
+ train_scores = score_pairs(tuned, train_pairs_scored)
255
297
  train_labels = np.array([1 if e.was_correct else 0 for e in train_rows])
256
298
  train_pred = (train_scores >= threshold).astype(int)
257
299
  train_label_disagreement_rate = float(np.mean(train_pred != train_labels))
@@ -13,6 +13,14 @@ import sys
13
13
  from pathlib import Path
14
14
  from typing import Any
15
15
 
16
+ from cacheverifier._healthcheck._base_models import (
17
+ BASE_MODEL_CHOICES,
18
+ CJK_WARNING_RATIO,
19
+ ENGLISH_ONLY_BASE_MODELS,
20
+ cjk_ratio,
21
+ resolve_base_model,
22
+ )
23
+
16
24
  _MISSING_EXTRA_HINT = (
17
25
  "the healthcheck extra is not installed -- run:\n"
18
26
  ' pip install "cacheverifier[healthcheck]"\n'
@@ -48,8 +56,19 @@ def add_subparser(subparsers: argparse._SubParsersAction) -> None:
48
56
  p.add_argument(
49
57
  "--epochs",
50
58
  type=int,
51
- default=1,
52
- help="fine-tuning epochs (default: 1, matching the hosted service)",
59
+ default=3,
60
+ help="fine-tuning epochs (default: 3, matching the hosted service)",
61
+ )
62
+ p.add_argument(
63
+ "--base-model",
64
+ default="ms_marco",
65
+ metavar="MODEL",
66
+ help=(
67
+ f"model to start from: {', '.join(BASE_MODEL_CHOICES)} (same choices as the hosted "
68
+ "service's base_model), a Hugging Face model id, or a local model directory. "
69
+ "Default ms_marco is English-only -- use multilingual or multilingual_small for "
70
+ "Chinese or other non-English traffic (they need several GB of RAM to fine-tune)"
71
+ ),
53
72
  )
54
73
  p.add_argument(
55
74
  "--keep-model",
@@ -125,6 +144,12 @@ def run(args: argparse.Namespace) -> int:
125
144
  print(f"error: {e}", file=sys.stderr)
126
145
  return 2
127
146
 
147
+ try:
148
+ base_model = resolve_base_model(args.base_model)
149
+ except ValueError as e:
150
+ print(f"error: {e}", file=sys.stderr)
151
+ return 2
152
+
128
153
  n = len(examples)
129
154
  print(f"\nCacheVerifier local Health Check -- {input_path}")
130
155
  print("=" * 66)
@@ -133,6 +158,17 @@ def run(args: argparse.Namespace) -> int:
133
158
  if n_stale:
134
159
  print(f"stale, excluded: {n_stale}")
135
160
 
161
+ print(f"base model: {base_model}")
162
+ if base_model in ENGLISH_ONLY_BASE_MODELS:
163
+ ratio = cjk_ratio([t for e in examples for t in (e.query, e.candidate_answer)])
164
+ if ratio > CJK_WARNING_RATIO:
165
+ print(
166
+ f"\nWarning: {ratio:.0%} of your text is Chinese/Japanese/Korean, but {base_model} is an "
167
+ "English-only model\n (it reads most CJK characters as unknown tokens), so this "
168
+ "result won't reflect your\n traffic. Re-run with --base-model multilingual "
169
+ "(or multilingual_small)."
170
+ )
171
+
136
172
  if n < _finetune.MIN_TRAIN_EXAMPLES:
137
173
  print(f"\nNeed at least {_finetune.MIN_TRAIN_EXAMPLES} non-stale rows to run; have {n}.", file=sys.stderr)
138
174
  return 2
@@ -149,7 +185,7 @@ def run(args: argparse.Namespace) -> int:
149
185
  print(f"\nfine-tuning locally (nothing sent) -> {out_dir}")
150
186
  print("this takes a few minutes on CPU; the base model downloads once on first run...\n")
151
187
 
152
- result = _finetune.run_healthcheck(examples, out_dir, base_model=_finetune.DEFAULT_BASE_MODEL, epochs=args.epochs)
188
+ result = _finetune.run_healthcheck(examples, out_dir, base_model=base_model, epochs=args.epochs)
153
189
 
154
190
  print("results")
155
191
  print("-" * 66)
@@ -175,6 +211,9 @@ def run(args: argparse.Namespace) -> int:
175
211
 
176
212
  if args.emit_summary is not None:
177
213
  summary = {
214
+ # A local directory's path can carry a username -- this file is
215
+ # meant to be shareable, so record only that it was local.
216
+ "base_model": "local directory" if Path(base_model).is_dir() else base_model,
178
217
  "n_rows_read": len(rows),
179
218
  "n_usable": n,
180
219
  "n_stale_excluded": n_stale,
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cacheverifier"
7
- version = "0.3.0"
7
+ version = "0.4.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"
@@ -0,0 +1,205 @@
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
+ from cacheverifier import __version__
36
+
37
+ assert f"cacheverifier {__version__}" in capsys.readouterr().out
38
+
39
+ def test_no_command_prints_help_and_returns_1(self, capsys):
40
+ assert main([]) == 1
41
+ assert "healthcheck" in capsys.readouterr().out
42
+
43
+ def test_healthcheck_help(self):
44
+ with pytest.raises(SystemExit) as e:
45
+ main(["healthcheck", "--help"])
46
+ assert e.value.code == 0
47
+
48
+ @pytest.mark.skipif(HAS_TORCH, reason="torch present -- the missing-extra path can't be exercised")
49
+ def test_healthcheck_without_extra_points_at_pip_install(self, capsys, tmp_path):
50
+ f = tmp_path / "t.jsonl"
51
+ f.write_text("")
52
+ assert main(["healthcheck", str(f)]) == 2
53
+ assert 'pip install "cacheverifier[healthcheck]"' in capsys.readouterr().err
54
+
55
+
56
+ class TestParsing:
57
+ def test_load_rows_accepts_json_array_and_jsonl(self, tmp_path):
58
+ rows = [{"a": 1}, {"a": 2}]
59
+ arr = tmp_path / "a.json"
60
+ arr.write_text(json.dumps(rows))
61
+ jsonl = tmp_path / "a.jsonl"
62
+ jsonl.write_text("\n".join(json.dumps(r) for r in rows) + "\n")
63
+ assert cli._load_rows(arr) == rows
64
+ assert cli._load_rows(jsonl) == rows
65
+
66
+ def test_to_examples_skips_stale_and_counts_them(self):
67
+ rows = [
68
+ {"query": "q1", "candidate_answer": "a1", "was_correct": True},
69
+ {"query": "q2", "candidate_answer": "a2", "was_correct": False, "stale": True},
70
+ ]
71
+ examples, n_stale = cli._to_examples(rows, _DummyExample)
72
+ assert len(examples) == 1
73
+ assert n_stale == 1
74
+ assert examples[0] == _DummyExample("q1", "a1", True)
75
+
76
+ def test_to_examples_rejects_missing_keys(self):
77
+ with pytest.raises(ValueError, match="missing key"):
78
+ cli._to_examples([{"query": "q"}], _DummyExample)
79
+
80
+
81
+ @pytest.mark.skipif(not HAS_TORCH, reason="needs the healthcheck extra (torch, sentence-transformers)")
82
+ def test_run_healthcheck_end_to_end(tmp_path):
83
+ from cacheverifier._healthcheck._finetune import GrayZoneExample, HealthCheckResult, run_healthcheck
84
+
85
+ groups = [
86
+ ("how do I cancel my subscription", "Settings > Billing > Cancel.", "Settings > Billing > Pause a month."),
87
+ ("how do I get a refund", "Orders > Return > Request refund.", "Orders > Return > Exchange size."),
88
+ ("how do I reset my password", "Account > Security > Reset password.", "Account > Security > Enable 2FA."),
89
+ ]
90
+ examples = []
91
+ for i in range(36):
92
+ q, good, bad = groups[i % len(groups)]
93
+ correct = i % 2 == 0
94
+ examples.append(GrayZoneExample(q, good if correct else bad, correct))
95
+
96
+ result = run_healthcheck(examples, str(tmp_path / "model"), epochs=1)
97
+ assert isinstance(result, HealthCheckResult)
98
+ assert result.n_train + result.n_calibrate + result.n_test == 36
99
+ assert 0.0 <= result.auc_tuned <= 1.0
100
+ assert (tmp_path / "model").is_dir()
101
+
102
+
103
+ @pytest.mark.skipif(not HAS_TORCH, reason="needs the healthcheck extra (torch)")
104
+ def test_run_healthcheck_warms_up_over_a_tenth_of_its_steps(tmp_path, monkeypatch):
105
+ # CrossEncoder.fit defaults to warmup_steps=10000, which kept a
106
+ # few-hundred-step run from ever reaching its learning rate.
107
+ from sentence_transformers import CrossEncoder
108
+
109
+ from cacheverifier._healthcheck import _finetune
110
+ from cacheverifier._healthcheck._finetune import GrayZoneExample, run_healthcheck
111
+
112
+ seen: dict = {}
113
+ real_fit = CrossEncoder.fit
114
+
115
+ def spy_fit(self, *args, **kwargs):
116
+ seen.update(kwargs, steps_per_epoch=len(kwargs["train_dataloader"]))
117
+ return real_fit(self, *args, **kwargs)
118
+
119
+ monkeypatch.setattr(CrossEncoder, "fit", spy_fit)
120
+ examples = [GrayZoneExample(f"question {i}", f"answer {i % 3}", i % 2 == 0) for i in range(40)]
121
+ run_healthcheck(examples, str(tmp_path / "model"))
122
+
123
+ assert seen["epochs"] == _finetune.TRAIN_EPOCHS == 3
124
+ expected = max(1, int(_finetune.TRAIN_WARMUP_FRACTION * seen["steps_per_epoch"] * seen["epochs"]))
125
+ assert seen["warmup_steps"] == expected
126
+
127
+
128
+ class TestBaseModel:
129
+ def test_aliases_match_the_hosted_service(self):
130
+ from cacheverifier._healthcheck._base_models import BASE_MODEL_CHOICES, resolve_base_model
131
+
132
+ assert set(BASE_MODEL_CHOICES) == {"ms_marco", "nli", "multilingual", "multilingual_small"}
133
+ assert resolve_base_model("ms_marco") == "cross-encoder/ms-marco-MiniLM-L6-v2"
134
+ assert resolve_base_model("multilingual") == "BAAI/bge-reranker-base"
135
+
136
+ def test_hf_id_and_local_dir_pass_through(self, tmp_path):
137
+ from cacheverifier._healthcheck._base_models import resolve_base_model
138
+
139
+ assert resolve_base_model("someorg/some-reranker") == "someorg/some-reranker"
140
+ assert resolve_base_model(str(tmp_path)) == str(tmp_path)
141
+
142
+ @pytest.mark.parametrize("bad", ["multilingal", "/no/such/model/dir", ""])
143
+ def test_typos_and_missing_paths_are_rejected(self, bad):
144
+ from cacheverifier._healthcheck._base_models import resolve_base_model
145
+
146
+ with pytest.raises(ValueError, match="unknown base model"):
147
+ resolve_base_model(bad)
148
+
149
+ def test_cjk_ratio(self):
150
+ from cacheverifier._healthcheck._base_models import cjk_ratio
151
+
152
+ assert cjk_ratio(["how do I cancel"]) == 0.0
153
+ assert cjk_ratio(["怎么取消订阅"]) == 1.0
154
+ assert cjk_ratio(["怎么取消 subscription"]) == 4 / 16
155
+ assert cjk_ratio([]) == 0.0
156
+
157
+ def test_help_lists_base_model_choices(self, capsys):
158
+ with pytest.raises(SystemExit):
159
+ main(["healthcheck", "--help"])
160
+ out = capsys.readouterr().out
161
+ assert "--base-model" in out and "multilingual_small" in out
162
+
163
+ @pytest.mark.skipif(not HAS_TORCH, reason="run() imports the healthcheck extra first")
164
+ def test_unknown_base_model_is_an_input_error(self, capsys, tmp_path):
165
+ f = tmp_path / "t.jsonl"
166
+ f.write_text(json.dumps({"query": "q", "candidate_answer": "a", "was_correct": True}) + "\n")
167
+ assert main(["healthcheck", str(f), "--base-model", "multilingal"]) == 2
168
+ assert "unknown base model" in capsys.readouterr().err
169
+
170
+ @pytest.mark.skipif(not HAS_TORCH, reason="run() imports the healthcheck extra first")
171
+ def test_chinese_data_on_english_model_warns(self, capsys, tmp_path):
172
+ # Below MIN_TRAIN_EXAMPLES, so run() stops before any download/training.
173
+ f = tmp_path / "t.jsonl"
174
+ f.write_text(
175
+ "\n".join(
176
+ json.dumps({"query": "怎么取消自动续费", "candidate_answer": "进入设置取消", "was_correct": True}, ensure_ascii=False)
177
+ for _ in range(3)
178
+ )
179
+ )
180
+ assert main(["healthcheck", str(f)]) == 2
181
+ out = capsys.readouterr().out
182
+ assert "English-only" in out and "--base-model multilingual" in out
183
+
184
+ assert main(["healthcheck", str(f), "--base-model", "multilingual_small"]) == 2
185
+ assert "English-only" not in capsys.readouterr().out
186
+
187
+
188
+ @pytest.mark.skipif(not HAS_TORCH, reason="needs the healthcheck extra (torch, sentence-transformers)")
189
+ def test_run_healthcheck_nli_base_reinitializes_to_single_logit(tmp_path):
190
+ """The NLI base ships a 3-class head: the stock baseline is scored
191
+ P(entailment) - P(contradiction), and fine-tuning reinitializes it to the
192
+ same 1-logit head as every other base (matching the hosted service)."""
193
+ from sentence_transformers import CrossEncoder
194
+
195
+ from cacheverifier._healthcheck._base_models import BASE_MODEL_CHOICES
196
+ from cacheverifier._healthcheck._finetune import GrayZoneExample, run_healthcheck
197
+
198
+ examples = [
199
+ GrayZoneExample("how do I cancel my plan", "Settings > Billing > Cancel." if i % 2 == 0 else "Settings > Pause.", i % 2 == 0)
200
+ for i in range(24)
201
+ ]
202
+ result = run_healthcheck(examples, str(tmp_path / "model"), base_model=BASE_MODEL_CHOICES["nli"], epochs=1)
203
+ assert 0.0 <= result.auc_baseline <= 1.0
204
+ assert 0.0 <= result.auc_tuned <= 1.0
205
+ assert CrossEncoder(str(tmp_path / "model")).config.num_labels == 1
@@ -1,100 +0,0 @@
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
- from cacheverifier import __version__
36
-
37
- assert f"cacheverifier {__version__}" in capsys.readouterr().out
38
-
39
- def test_no_command_prints_help_and_returns_1(self, capsys):
40
- assert main([]) == 1
41
- assert "healthcheck" in capsys.readouterr().out
42
-
43
- def test_healthcheck_help(self):
44
- with pytest.raises(SystemExit) as e:
45
- main(["healthcheck", "--help"])
46
- assert e.value.code == 0
47
-
48
- @pytest.mark.skipif(HAS_TORCH, reason="torch present -- the missing-extra path can't be exercised")
49
- def test_healthcheck_without_extra_points_at_pip_install(self, capsys, tmp_path):
50
- f = tmp_path / "t.jsonl"
51
- f.write_text("")
52
- assert main(["healthcheck", str(f)]) == 2
53
- assert 'pip install "cacheverifier[healthcheck]"' in capsys.readouterr().err
54
-
55
-
56
- class TestParsing:
57
- def test_load_rows_accepts_json_array_and_jsonl(self, tmp_path):
58
- rows = [{"a": 1}, {"a": 2}]
59
- arr = tmp_path / "a.json"
60
- arr.write_text(json.dumps(rows))
61
- jsonl = tmp_path / "a.jsonl"
62
- jsonl.write_text("\n".join(json.dumps(r) for r in rows) + "\n")
63
- assert cli._load_rows(arr) == rows
64
- assert cli._load_rows(jsonl) == rows
65
-
66
- def test_to_examples_skips_stale_and_counts_them(self):
67
- rows = [
68
- {"query": "q1", "candidate_answer": "a1", "was_correct": True},
69
- {"query": "q2", "candidate_answer": "a2", "was_correct": False, "stale": True},
70
- ]
71
- examples, n_stale = cli._to_examples(rows, _DummyExample)
72
- assert len(examples) == 1
73
- assert n_stale == 1
74
- assert examples[0] == _DummyExample("q1", "a1", True)
75
-
76
- def test_to_examples_rejects_missing_keys(self):
77
- with pytest.raises(ValueError, match="missing key"):
78
- cli._to_examples([{"query": "q"}], _DummyExample)
79
-
80
-
81
- @pytest.mark.skipif(not HAS_TORCH, reason="needs the healthcheck extra (torch, sentence-transformers)")
82
- def test_run_healthcheck_end_to_end(tmp_path):
83
- from cacheverifier._healthcheck._finetune import GrayZoneExample, HealthCheckResult, run_healthcheck
84
-
85
- groups = [
86
- ("how do I cancel my subscription", "Settings > Billing > Cancel.", "Settings > Billing > Pause a month."),
87
- ("how do I get a refund", "Orders > Return > Request refund.", "Orders > Return > Exchange size."),
88
- ("how do I reset my password", "Account > Security > Reset password.", "Account > Security > Enable 2FA."),
89
- ]
90
- examples = []
91
- for i in range(36):
92
- q, good, bad = groups[i % len(groups)]
93
- correct = i % 2 == 0
94
- examples.append(GrayZoneExample(q, good if correct else bad, correct))
95
-
96
- result = run_healthcheck(examples, str(tmp_path / "model"), epochs=1)
97
- assert isinstance(result, HealthCheckResult)
98
- assert result.n_train + result.n_calibrate + result.n_test == 36
99
- assert 0.0 <= result.auc_tuned <= 1.0
100
- assert (tmp_path / "model").is_dir()
File without changes
File without changes