pakka 0.1.0__py3-none-any.whl
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.
- pakka/__init__.py +1 -0
- pakka/approve.py +44 -0
- pakka/bench.py +380 -0
- pakka/cache.py +83 -0
- pakka/cli.py +20 -0
- pakka/embed.py +104 -0
- pakka/flow.py +83 -0
- pakka/guard.py +238 -0
- pakka/pii.py +81 -0
- pakka/policy.py +45 -0
- pakka/redis_cache.py +62 -0
- pakka/review.py +74 -0
- pakka/testset.py +158 -0
- pakka/train.py +47 -0
- pakka/verify.py +128 -0
- pakka-0.1.0.dist-info/METADATA +290 -0
- pakka-0.1.0.dist-info/RECORD +21 -0
- pakka-0.1.0.dist-info/WHEEL +5 -0
- pakka-0.1.0.dist-info/entry_points.txt +2 -0
- pakka-0.1.0.dist-info/licenses/LICENSE +21 -0
- pakka-0.1.0.dist-info/top_level.txt +1 -0
pakka/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Pakka: tests, guards and safety rules for semantic caches."""
|
pakka/approve.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Approve answers waiting to enter the shared cache.
|
|
2
|
+
|
|
3
|
+
python -m pakka.approve data/pending.jsonl --to data/approved.jsonl
|
|
4
|
+
|
|
5
|
+
y = approve, n = discard, s = keep for later, q = quit. Both files are saved after every answer.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _write(path, rows):
|
|
14
|
+
path.write_text("".join(json.dumps(r, ensure_ascii=False) + "\n" for r in rows))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main(argv=None):
|
|
18
|
+
p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
19
|
+
p.add_argument("pending", type=Path)
|
|
20
|
+
p.add_argument("--to", type=Path, default=Path("data/approved.jsonl"))
|
|
21
|
+
args = p.parse_args(argv)
|
|
22
|
+
|
|
23
|
+
rows = [json.loads(line) for line in args.pending.read_text().splitlines() if line.strip()]
|
|
24
|
+
kept = []
|
|
25
|
+
for i, row in enumerate(rows):
|
|
26
|
+
print(f"\n[{i + 1}/{len(rows)}] Q: {row['question']}\n A: {row['answer']}")
|
|
27
|
+
answer = ""
|
|
28
|
+
while answer not in ("y", "n", "s", "q"):
|
|
29
|
+
answer = input("approve? [y/n/s/q] ").strip().lower()
|
|
30
|
+
if answer == "q":
|
|
31
|
+
kept += rows[i:]
|
|
32
|
+
break
|
|
33
|
+
if answer == "y":
|
|
34
|
+
with open(args.to, "a") as f:
|
|
35
|
+
f.write(json.dumps(row, ensure_ascii=False) + "\n")
|
|
36
|
+
if answer == "s":
|
|
37
|
+
kept.append(row)
|
|
38
|
+
_write(args.pending, kept + rows[i + 1:])
|
|
39
|
+
_write(args.pending, kept)
|
|
40
|
+
print(f"\n{len(kept)} left in {args.pending}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
if __name__ == "__main__":
|
|
44
|
+
main()
|
pakka/bench.py
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"""Measure a semantic cache on a labelled question set.
|
|
2
|
+
|
|
3
|
+
python -m pakka.bench --data data/bench_v1.jsonl --embedder exact --embedder paraphrase-multilingual-MiniLM-L12-v2
|
|
4
|
+
|
|
5
|
+
Each labelled row has the FAQ id it should match (`label`, or None for should-miss) and, after
|
|
6
|
+
lookup, what the cache returned (`matched_id`, `score`, `lookup_ms`). Rows tagged `split: dev`
|
|
7
|
+
choose the threshold; rows tagged `split: test` report the result, so the number is not tuned
|
|
8
|
+
on the data it is reported on.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import math
|
|
14
|
+
import statistics
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
from bisect import bisect_left
|
|
18
|
+
from collections import Counter
|
|
19
|
+
from itertools import accumulate
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def is_correct(row):
|
|
26
|
+
return row["matched_id"] == row["label"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def wilson(k, n, z=1.96):
|
|
30
|
+
"""95% Wilson score interval for k successes in n trials."""
|
|
31
|
+
if n == 0:
|
|
32
|
+
return 0.0, 1.0
|
|
33
|
+
p = k / n
|
|
34
|
+
centre = p + z * z / (2 * n)
|
|
35
|
+
margin = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n))
|
|
36
|
+
return (centre - margin) / (1 + z * z / n), (centre + margin) / (1 + z * z / n)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def is_served(row, threshold):
|
|
40
|
+
"""Answered from cache: similar enough, and no guard vetoed it."""
|
|
41
|
+
return row["score"] >= threshold and not row.get("blocked")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def evaluate(rows, threshold, llm_ms=0):
|
|
45
|
+
served = [r for r in rows if is_served(r, threshold)]
|
|
46
|
+
wrong = [r for r in served if not is_correct(r)]
|
|
47
|
+
vetoed = [r for r in rows if r["score"] >= threshold and r.get("blocked")]
|
|
48
|
+
return {
|
|
49
|
+
"threshold": threshold,
|
|
50
|
+
"hits": len(served),
|
|
51
|
+
"correct": len(served) - len(wrong),
|
|
52
|
+
"false_hits": len(wrong),
|
|
53
|
+
"missed": sum(r["label"] is not None and not is_served(r, threshold) for r in rows),
|
|
54
|
+
"blocked_by": dict(Counter(r["blocked"] for r in vetoed)),
|
|
55
|
+
"blocked_correct": sum(map(is_correct, vetoed)), # good hits a guard threw away
|
|
56
|
+
"hit_rate": len(served) / len(rows),
|
|
57
|
+
"false_hit_rate": len(wrong) / len(served) if served else 0.0,
|
|
58
|
+
"false_hit_upper": wilson(len(wrong), len(served))[1],
|
|
59
|
+
"false_by_kind": dict(Counter(r.get("kind") for r in wrong)),
|
|
60
|
+
"avg_latency_ms": sum(r["lookup_ms"] + (0 if is_served(r, threshold) else llm_ms)
|
|
61
|
+
for r in rows) / len(rows) if "lookup_ms" in rows[0] else None,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def sweep(rows, thresholds, llm_ms):
|
|
66
|
+
return [evaluate(rows, t, llm_ms) for t in thresholds]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def pchr_auc(rows):
|
|
70
|
+
"""Area under precision vs cache-hit ratio (Redis, EMNLP 2026): serve the k most similar
|
|
71
|
+
rows for every k and average the precision. Independent of any threshold."""
|
|
72
|
+
ranked = sorted(rows, key=lambda r: -1 if r.get("blocked") else r["score"], reverse=True)
|
|
73
|
+
correct, total = 0, 0.0
|
|
74
|
+
for k, row in enumerate(ranked, 1):
|
|
75
|
+
correct += is_correct(row)
|
|
76
|
+
total += correct / k
|
|
77
|
+
return total / len(ranked)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def pick_threshold(rows, budget):
|
|
81
|
+
"""Lowest threshold (most hits) whose false-hit rate upper bound stays within `budget`."""
|
|
82
|
+
served = sorted((r["score"], not is_correct(r)) for r in rows if not r.get("blocked"))
|
|
83
|
+
scores = [s for s, _ in served]
|
|
84
|
+
wrong_from = list(accumulate(w for _, w in reversed(served)))[::-1] + [0] # wrong among served[i:]
|
|
85
|
+
for t in sorted({r["score"] for r in rows}): # ascending, so the first fit serves the most
|
|
86
|
+
i = bisect_left(scores, t)
|
|
87
|
+
if wilson(wrong_from[i], len(served) - i)[1] <= budget:
|
|
88
|
+
return t
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def score_rows(cache, data, guard=None):
|
|
93
|
+
"""Look up every question once; with `guard(query, cached_question, cached_answer)`, also record its veto."""
|
|
94
|
+
rows = []
|
|
95
|
+
for item in data:
|
|
96
|
+
start = time.perf_counter()
|
|
97
|
+
match = cache.nearest(item["q"])
|
|
98
|
+
row = {**item, "matched_id": match.meta.get("id"), "matched_q": match.question, "score": match.score,
|
|
99
|
+
"lookup_ms": (time.perf_counter() - start) * 1000}
|
|
100
|
+
if guard:
|
|
101
|
+
start = time.perf_counter()
|
|
102
|
+
row["blocked"] = guard(item["q"], match.question, match.answer)
|
|
103
|
+
row["guard_ms"] = (time.perf_counter() - start) * 1000
|
|
104
|
+
rows.append(row)
|
|
105
|
+
return rows
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def verify_rows(rows, verify, floor):
|
|
109
|
+
"""Re-score grey-band candidates (at or above `floor`, not vetoed) with `verify(pairs) -> scores`, e.g. a
|
|
110
|
+
cross-encoder or Jev. A yes/no verifier instead vetoes like a guard ("verify") and the similarity still ranks.
|
|
111
|
+
Pairs are PII-masked, as `CachedAnswerer` sends them. Nothing below `floor` was checked, so it can't hit."""
|
|
112
|
+
from pakka.pii import mask
|
|
113
|
+
|
|
114
|
+
todo = [i for i, r in enumerate(rows) if r["score"] >= floor and not r.get("blocked")]
|
|
115
|
+
scores = dict(zip(todo, verify([(mask(rows[i]["q"]), mask(rows[i]["matched_q"])) for i in todo]) if todo else []))
|
|
116
|
+
|
|
117
|
+
def rescore(r, s):
|
|
118
|
+
if isinstance(s, bool):
|
|
119
|
+
return {**r, "blocked": None if s else "verify"}
|
|
120
|
+
return {**r, "score": float(s)}
|
|
121
|
+
|
|
122
|
+
return [rescore(r, scores[i]) if i in scores else {**r, "score": -1.0, "blocked": r.get("blocked") or "below_floor"}
|
|
123
|
+
for i, r in enumerate(rows)]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def replay_stream(vectors, rows, threshold, guard=None, block=256):
|
|
127
|
+
"""Stream protocol: questions arrive in order. Each is served the answer of the most similar question
|
|
128
|
+
stored so far if it scores at or above `threshold` and no guard vetoes it; otherwise the LLM answers and
|
|
129
|
+
the question is stored, so the cache grows with every miss. A hit is correct when both questions share
|
|
130
|
+
an equivalence class (`label`). `vectors` are the rows' unit embeddings, in the same order."""
|
|
131
|
+
bank, ids, size, out = np.empty_like(vectors), np.empty(len(rows), dtype=int), 0, []
|
|
132
|
+
for start in range(0, len(rows), block): # one matmul per block against everything stored before it
|
|
133
|
+
chunk = vectors[start:start + block]
|
|
134
|
+
before = size
|
|
135
|
+
prior = chunk @ bank[:before].T if before else None
|
|
136
|
+
best = prior.argmax(axis=1) if before else None
|
|
137
|
+
inner, fresh = chunk @ chunk.T, [] # questions stored inside this block
|
|
138
|
+
for k in range(len(chunk)):
|
|
139
|
+
i, j, score = start + k, None, -1.0
|
|
140
|
+
if before:
|
|
141
|
+
j, score = int(ids[best[k]]), float(prior[k, best[k]])
|
|
142
|
+
if fresh:
|
|
143
|
+
f = fresh[int(inner[k, fresh].argmax())]
|
|
144
|
+
if inner[k, f] > score: # ties go to the older entry, as in NumpyCache
|
|
145
|
+
j, score = start + f, float(inner[k, f])
|
|
146
|
+
blocked = guard(rows[i]["q"], rows[j]["q"], "") if guard and j is not None and score >= threshold else None
|
|
147
|
+
out.append({**rows[i], "matched_id": None if j is None else rows[j]["label"],
|
|
148
|
+
"matched_q": None if j is None else rows[j]["q"], "score": score, "blocked": blocked,
|
|
149
|
+
"cache_size": size})
|
|
150
|
+
if j is None or score < threshold or blocked:
|
|
151
|
+
bank[size], ids[size] = vectors[i], i
|
|
152
|
+
size += 1
|
|
153
|
+
fresh.append(k)
|
|
154
|
+
return out
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def pick_stream_threshold(vectors, rows, budget, guard=None):
|
|
158
|
+
"""(threshold, replayed rows): the lowest of 0.50, 0.51 ... 0.99 whose false-hit upper bound on the dev rows
|
|
159
|
+
fits `budget`, or (None, None). Dev rows come first in a stream, so only that prefix is replayed per try:
|
|
160
|
+
every 0.05 first, then the 0.01 steps just below the first fit (the bound widens again near 1.0, where
|
|
161
|
+
hits are few, so it can't bisect). The whole stream is replayed once, at the chosen threshold."""
|
|
162
|
+
grid, end = [round(0.5 + i / 100, 2) for i in range(50)], 1 + max(i for i, r in enumerate(rows) if r["split"] == "dev")
|
|
163
|
+
|
|
164
|
+
def fits(i):
|
|
165
|
+
dev = [r for r in replay_stream(vectors[:end], rows[:end], grid[i], guard) if r["split"] == "dev"]
|
|
166
|
+
return evaluate(dev, grid[i])["false_hit_upper"] <= budget
|
|
167
|
+
|
|
168
|
+
coarse = next((i for i in range(0, 50, 5) if fits(i)), None)
|
|
169
|
+
fine = range(max(coarse - 4, 0), coarse) if coarse is not None else range(46, 50)
|
|
170
|
+
best = next((i for i in fine if fits(i)), coarse)
|
|
171
|
+
return (grid[best], replay_stream(vectors, rows, grid[best], guard)) if best is not None else (None, None)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def pair_rows(vectors1, vectors2, pairs, guard=None):
|
|
175
|
+
"""Pairs protocol: a pair scoring at or above the threshold is a hit, and a false hit when it is labelled
|
|
176
|
+
different (0). Rows fit `evaluate` and `pick_threshold`: label None means the cache must miss."""
|
|
177
|
+
scores = np.einsum("ij,ij->i", vectors1, vectors2)
|
|
178
|
+
return [{**p, "label": 1 if p["label"] else None, "matched_id": 1, "score": float(s),
|
|
179
|
+
"blocked": guard(p["q2"], p["q"], "") if guard else None} for p, s in zip(pairs, scores)]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def caught(rows, threshold, lang):
|
|
183
|
+
pool = [r for r in rows if r["label"] is not None and r.get("lang") == lang]
|
|
184
|
+
if not pool:
|
|
185
|
+
return "-"
|
|
186
|
+
return f"{sum(is_served(r, threshold) and is_correct(r) for r in pool)}/{len(pool)}"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def report_encoder(name, rows, budget):
|
|
190
|
+
dev = [r for r in rows if r.get("split") != "test"]
|
|
191
|
+
test = [r for r in rows if r.get("split") == "test"] or dev
|
|
192
|
+
t = pick_threshold(dev, budget)
|
|
193
|
+
line = {"encoder": name, "auc": pchr_auc(test), "threshold": t,
|
|
194
|
+
"lookup_p50_ms": statistics.median(r["lookup_ms"] for r in test),
|
|
195
|
+
"guard_p50_us": statistics.median(r["guard_ms"] * 1000 for r in test) if "guard_ms" in test[0] else None}
|
|
196
|
+
if t is not None:
|
|
197
|
+
line.update(evaluate(test, t))
|
|
198
|
+
line.update({lang: caught(test, t, lang) for lang in ("en", "hinglish", "hi")})
|
|
199
|
+
return line
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def format_report(lines, budget, n_test):
|
|
203
|
+
out = [f"Threshold chosen on dev so the false-hit rate's 95% upper bound stays under {budget:.0%}; "
|
|
204
|
+
f"results on {n_test} held-out questions.\n",
|
|
205
|
+
"| Encoder | P-CHR AUC | Threshold | Hit rate | Correct | False hits | False-hit rate (95% upper) "
|
|
206
|
+
"| English caught | Hinglish caught | Hindi caught | Lookup p50 |",
|
|
207
|
+
"|---|---|---|---|---|---|---|---|---|---|---|"]
|
|
208
|
+
for x in lines:
|
|
209
|
+
if x["threshold"] is None:
|
|
210
|
+
out.append(f"| {x['encoder']} | {x['auc']:.3f} | none fits budget | | | | | | | | "
|
|
211
|
+
f"{x['lookup_p50_ms']:.0f} ms |")
|
|
212
|
+
continue
|
|
213
|
+
out.append(f"| {x['encoder']} | {x['auc']:.3f} | {x['threshold']:.3f} | {x['hit_rate']:.0%} "
|
|
214
|
+
f"| {x['correct']} | {x['false_hits']} | {x['false_hit_rate']:.1%} ({x['false_hit_upper']:.1%}) "
|
|
215
|
+
f"| {x['en']} | {x['hinglish']} | {x['hi']} | {x['lookup_p50_ms']:.0f} ms |")
|
|
216
|
+
kinds = sorted({k for x in lines for k in x.get("false_by_kind", {})}, key=str)
|
|
217
|
+
if kinds:
|
|
218
|
+
out += ["\nFalse hits by question type:\n", "| Encoder | " + " | ".join(map(str, kinds)) + " |",
|
|
219
|
+
"|---|" + "---|" * len(kinds)]
|
|
220
|
+
for x in lines:
|
|
221
|
+
if x["threshold"] is not None:
|
|
222
|
+
out.append(f"| {x['encoder']} | " + " | ".join(str(x["false_by_kind"].get(k, 0)) for k in kinds) + " |")
|
|
223
|
+
guarded = [x for x in lines if x.get("guard_p50_us") is not None and x["threshold"] is not None]
|
|
224
|
+
if guarded:
|
|
225
|
+
reasons = sorted({r for x in guarded for r in x["blocked_by"]})
|
|
226
|
+
out += ["\nGuard vetoes on held-out at the chosen threshold:\n",
|
|
227
|
+
"| Encoder | " + "".join(f"{r} | " for r in reasons) + "Good hits lost | Guard p50 |",
|
|
228
|
+
"|---|" + "---|" * (len(reasons) + 2)]
|
|
229
|
+
for x in guarded:
|
|
230
|
+
out.append(f"| {x['encoder']} | " + "".join(f"{x['blocked_by'].get(r, 0)} | " for r in reasons)
|
|
231
|
+
+ f"{x['blocked_correct']} | {x['guard_p50_us']:.0f} µs |")
|
|
232
|
+
return "\n".join(out)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def seeded(name, embed, faqs, keys=()):
|
|
236
|
+
"""A cache holding the FAQs, plus extra (question, faq_id) keys such as approved past questions."""
|
|
237
|
+
from pakka.cache import ExactCache, NumpyCache
|
|
238
|
+
|
|
239
|
+
cache = ExactCache() if name == "exact" else NumpyCache(embed)
|
|
240
|
+
answer = {f["id"]: f["answer"] for f in faqs}
|
|
241
|
+
for faq in faqs:
|
|
242
|
+
cache.store(faq["question"], faq["answer"], {"id": faq["id"]})
|
|
243
|
+
for question, label in keys:
|
|
244
|
+
cache.store(question, answer[label], {"id": label})
|
|
245
|
+
return cache
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def entry_floors(rows):
|
|
249
|
+
"""Per-FAQ floor: the highest score at which that FAQ was served wrongly (guard vetoes excluded)."""
|
|
250
|
+
floors = {}
|
|
251
|
+
for r in rows:
|
|
252
|
+
if r["matched_id"] and not r.get("blocked") and not is_correct(r):
|
|
253
|
+
floors[r["matched_id"]] = max(floors.get(r["matched_id"], -1.0), r["score"])
|
|
254
|
+
return floors
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def apply_floors(rows, floors):
|
|
258
|
+
"""A hit on an FAQ must score above that FAQ's floor."""
|
|
259
|
+
return [{**r, "blocked": r.get("blocked") or ("entry_floor" if r["score"] <= floors.get(r["matched_id"], -2.0) else None)}
|
|
260
|
+
for r in rows]
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def score_with_method(name, embed, faqs, data, guard=None, extra_keys=False, entry_thresholds=False):
|
|
264
|
+
"""Score every row. Dev rows are cross-fitted: each half gets extra keys and floors learned from the
|
|
265
|
+
other half, so the dev-picked threshold isn't fooled by questions matching themselves."""
|
|
266
|
+
dev = [r for r in data if r.get("split") != "test"]
|
|
267
|
+
test = [r for r in data if r.get("split") == "test"]
|
|
268
|
+
halves = [dev[0::2], dev[1::2]]
|
|
269
|
+
|
|
270
|
+
def score(rows, key_rows):
|
|
271
|
+
keys = [(r["q"], r["label"]) for r in key_rows if r["label"]] if extra_keys else []
|
|
272
|
+
return score_rows(seeded(name, embed, faqs, keys), rows, guard) if rows else []
|
|
273
|
+
|
|
274
|
+
scored = [score(halves[0], halves[1]), score(halves[1], halves[0])]
|
|
275
|
+
held_out = score(test, dev)
|
|
276
|
+
if entry_thresholds:
|
|
277
|
+
held_out = apply_floors(held_out, entry_floors(scored[0] + scored[1]))
|
|
278
|
+
scored = [apply_floors(scored[0], entry_floors(scored[1])), apply_floors(scored[1], entry_floors(scored[0]))]
|
|
279
|
+
return scored[0] + scored[1] + held_out
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def bench_verifier(name, cache_dir=None):
|
|
283
|
+
"""A cross-encoder, `jev-<version>` for TypeSafe's Jev, or `openrouter:<model>`, as `verify(pairs) -> scores`
|
|
284
|
+
(or yes/no), plus its median latency for one pair in ms."""
|
|
285
|
+
from pakka.verify import jev_verifier, openrouter_verifier
|
|
286
|
+
|
|
287
|
+
pair = ("refund kab milega?", "How long does a refund take?")
|
|
288
|
+
if name.startswith(("jev-", "openrouter:")):
|
|
289
|
+
if name.startswith("jev-"):
|
|
290
|
+
verify = jev_verifier(name, cache_dir and Path(cache_dir) / "jev" / f"{name}.json")
|
|
291
|
+
else:
|
|
292
|
+
model = name.removeprefix("openrouter:")
|
|
293
|
+
verify = openrouter_verifier(model, cache_dir and Path(cache_dir) / "openrouter" / f"{model.replace('/', '__')}.json")
|
|
294
|
+
predict = lambda: verify.ask(pair) # noqa: E731 uncached: a cache hit would hide the network
|
|
295
|
+
runs, warm = 7, 2
|
|
296
|
+
else:
|
|
297
|
+
from sentence_transformers import CrossEncoder
|
|
298
|
+
|
|
299
|
+
model = CrossEncoder(name)
|
|
300
|
+
verify = lambda pairs: model.predict(pairs, show_progress_bar=False).tolist() # noqa: E731
|
|
301
|
+
predict = lambda: model.predict([pair], show_progress_bar=False) # noqa: E731
|
|
302
|
+
runs, warm = 20, 5
|
|
303
|
+
timings = []
|
|
304
|
+
for _ in range(runs):
|
|
305
|
+
start = time.perf_counter()
|
|
306
|
+
predict()
|
|
307
|
+
timings.append((time.perf_counter() - start) * 1000)
|
|
308
|
+
return verify, statistics.median(timings[warm:])
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def main(argv=None):
|
|
312
|
+
p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
313
|
+
p.add_argument("--faqs", default="data/faqs.json")
|
|
314
|
+
p.add_argument("--data", default="data/bench_v1.jsonl")
|
|
315
|
+
p.add_argument("--embedder", action="append", help="sentence-transformers model, or 'exact' (repeatable; default paraphrase-multilingual-MiniLM-L12-v2)")
|
|
316
|
+
p.add_argument("--budget", type=float, default=0.05, help="max false-hit rate (95%% upper bound)")
|
|
317
|
+
p.add_argument("--thresholds", help="also print a sweep over these thresholds, e.g. 0.6,0.7,0.8")
|
|
318
|
+
p.add_argument("--llm-ms", type=float, default=1260, help="LLM latency charged to each miss in a sweep")
|
|
319
|
+
p.add_argument("--fail-above", type=float, help="exit 1 if any encoder's false-hit upper bound (with --guard: guarded setup) exceeds this")
|
|
320
|
+
p.add_argument("--guard", action="store_true", help="also report each encoder with pakka.guard rules on")
|
|
321
|
+
p.add_argument("--verify", action="append", default=[], help="cross-encoder, jev-1.14.0 (TypeSafe, needs TYPESAFE_API_KEY) or openrouter:<model> (needs OPENROUTER_API_KEY), to check grey-band hits (repeatable)")
|
|
322
|
+
p.add_argument("--floor", type=float, default=0.6, help="lowest similarity a verifier looks at")
|
|
323
|
+
p.add_argument("--extra-keys", action="store_true", help="also store dev paraphrases as keys (approved questions)")
|
|
324
|
+
p.add_argument("--entry-thresholds", action="store_true", help="per-FAQ floors learned from dev false hits")
|
|
325
|
+
p.add_argument("--embed-cache", help="directory that keeps each model's vectors, so reruns don't embed again")
|
|
326
|
+
args = p.parse_args(argv)
|
|
327
|
+
|
|
328
|
+
faqs = json.loads(Path(args.faqs).read_text())
|
|
329
|
+
data = [json.loads(line) for line in Path(args.data).read_text().splitlines() if line.strip()]
|
|
330
|
+
lines = []
|
|
331
|
+
method = {"extra_keys": args.extra_keys, "entry_thresholds": args.entry_thresholds}
|
|
332
|
+
suffix = (" + keys" if args.extra_keys else "") + (" + entry thresholds" if args.entry_thresholds else "")
|
|
333
|
+
from pakka.embed import DEFAULT_EMBEDDER, disk_cached, load_embedder
|
|
334
|
+
|
|
335
|
+
for name in args.embedder or [DEFAULT_EMBEDDER]:
|
|
336
|
+
from pakka.guard import check
|
|
337
|
+
|
|
338
|
+
embed = None if name == "exact" else load_embedder(name)
|
|
339
|
+
if embed is not None and args.embed_cache:
|
|
340
|
+
embed = disk_cached(embed, Path(args.embed_cache) / name.replace("/", "__") / f"{Path(args.data).stem}.npz")
|
|
341
|
+
embed([f["question"] for f in faqs] + [r["q"] for r in data]) # one batch for every new text
|
|
342
|
+
embed.save()
|
|
343
|
+
if embed is not None:
|
|
344
|
+
embed(["warm up"]) # first call pays one-time model setup
|
|
345
|
+
rows = score_with_method(name, embed, faqs, data, **method)
|
|
346
|
+
lines.append(report_encoder(name + suffix, rows, args.budget))
|
|
347
|
+
if args.guard:
|
|
348
|
+
rows = score_with_method(name, embed, faqs, data, guard=check, **method)
|
|
349
|
+
lines.append(report_encoder(f"{name}{suffix} + guards", rows, args.budget))
|
|
350
|
+
for model in args.verify:
|
|
351
|
+
verify, single_ms = bench_verifier(model, args.embed_cache)
|
|
352
|
+
line = report_encoder(f"{name}{suffix}{' + guards' if args.guard else ''} + {model}",
|
|
353
|
+
verify_rows(rows, verify, args.floor), args.budget)
|
|
354
|
+
line["lookup_p50_ms"] += single_ms
|
|
355
|
+
lines.append(line)
|
|
356
|
+
if args.thresholds:
|
|
357
|
+
print(f"\n{name} sweep (all rows):\n")
|
|
358
|
+
print(format_table(sweep(rows, [float(t) for t in args.thresholds.split(",")], args.llm_ms)))
|
|
359
|
+
print(format_report(lines, args.budget, sum(r.get("split") == "test" for r in data) or len(data)))
|
|
360
|
+
|
|
361
|
+
if args.fail_above is not None:
|
|
362
|
+
# With --guard, judge the setup you would deploy (guards on), not the unguarded comparison line.
|
|
363
|
+
gated = [x for x in lines if x["encoder"].endswith(" + guards")] if args.guard else lines
|
|
364
|
+
failing = [x["encoder"] for x in gated if x.get("false_hit_upper", 1.0) > args.fail_above]
|
|
365
|
+
if failing:
|
|
366
|
+
print(f"\nFAIL: false-hit upper bound above {args.fail_above:.1%} for {', '.join(failing)}")
|
|
367
|
+
sys.exit(1)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def format_table(results):
|
|
371
|
+
lines = ["| Threshold | Hit rate | Correct hits | False hits | Missed paraphrases | Avg latency |",
|
|
372
|
+
"|---|---|---|---|---|---|"]
|
|
373
|
+
for r in results:
|
|
374
|
+
lines.append(f"| {r['threshold']:.2f} | {r['hit_rate']:.0%} | {r['correct']} | {r['false_hits']} "
|
|
375
|
+
f"| {r['missed']} | {r['avg_latency_ms']:.0f} ms |")
|
|
376
|
+
return "\n".join(lines)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
if __name__ == "__main__":
|
|
380
|
+
main()
|
pakka/cache.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Cache backends.
|
|
2
|
+
|
|
3
|
+
Every backend answers one question: which stored question is nearest, and how similar is it
|
|
4
|
+
(cosine, higher is closer)? Deciding hit or miss is the caller's job, so a threshold sweep
|
|
5
|
+
needs only one lookup per question.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Match:
|
|
17
|
+
question: str
|
|
18
|
+
answer: str
|
|
19
|
+
score: float # cosine similarity
|
|
20
|
+
meta: dict = field(default_factory=dict)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class NumpyCache:
|
|
24
|
+
"""In-memory brute-force search over unit vectors, so a dot product is the cosine.
|
|
25
|
+
|
|
26
|
+
shortcut: O(n) scan per lookup, fine to ~100k entries; use RedisVLCache beyond that.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, embed, clock=time.time):
|
|
30
|
+
self.embed, self.clock = embed, clock
|
|
31
|
+
self._vectors = None
|
|
32
|
+
self._entries = [] # (question, answer, meta, expires_at or None)
|
|
33
|
+
self._expiring = False # any entry with a ttl; until then every entry is live
|
|
34
|
+
|
|
35
|
+
def store(self, question, answer, meta=None, ttl=None):
|
|
36
|
+
vector = self.embed([question])
|
|
37
|
+
self._vectors = vector if self._vectors is None else np.vstack([self._vectors, vector])
|
|
38
|
+
self._entries.append((question, answer, meta or {}, None if ttl is None else self.clock() + ttl))
|
|
39
|
+
self._expiring = self._expiring or ttl is not None
|
|
40
|
+
|
|
41
|
+
def nearest(self, question, allow=None):
|
|
42
|
+
"""Nearest live entry. `allow` maps meta fields to the values a match may have."""
|
|
43
|
+
if not self._entries:
|
|
44
|
+
return None
|
|
45
|
+
scores = self._vectors @ self.embed([question])[0]
|
|
46
|
+
if allow or self._expiring: # the per-entry check is a Python loop, so skip it when nothing can be filtered
|
|
47
|
+
now = self.clock()
|
|
48
|
+
live = np.array([(expires is None or expires > now) and all(meta.get(k) in v for k, v in (allow or {}).items())
|
|
49
|
+
for _, _, meta, expires in self._entries], dtype=bool)
|
|
50
|
+
if not live.any():
|
|
51
|
+
return None
|
|
52
|
+
scores = np.where(live, scores, -np.inf)
|
|
53
|
+
i = int(np.argmax(scores))
|
|
54
|
+
stored_question, answer, meta, _ = self._entries[i]
|
|
55
|
+
return Match(stored_question, answer, float(scores[i]), meta)
|
|
56
|
+
|
|
57
|
+
def forget(self, field, value):
|
|
58
|
+
"""Delete every entry whose meta[field] == value (e.g. one user's data). Returns how many."""
|
|
59
|
+
keep = [i for i, (_, _, meta, _) in enumerate(self._entries) if meta.get(field) != value]
|
|
60
|
+
removed = len(self._entries) - len(keep)
|
|
61
|
+
self._entries = [self._entries[i] for i in keep]
|
|
62
|
+
self._vectors = self._vectors[keep] if keep else None
|
|
63
|
+
return removed
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def normalize(text):
|
|
67
|
+
return " ".join(re.findall(r"\w+", text.lower()))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ExactCache:
|
|
71
|
+
"""Exact match after lowercasing and dropping punctuation: score 1.0 on a match, else 0.0.
|
|
72
|
+
The baseline every semantic cache has to beat."""
|
|
73
|
+
|
|
74
|
+
def __init__(self):
|
|
75
|
+
self._entries = {}
|
|
76
|
+
|
|
77
|
+
def store(self, question, answer, meta=None):
|
|
78
|
+
self._entries[normalize(question)] = Match(question, answer, 1.0, meta or {})
|
|
79
|
+
|
|
80
|
+
def nearest(self, question):
|
|
81
|
+
if not self._entries:
|
|
82
|
+
return None
|
|
83
|
+
return self._entries.get(normalize(question)) or Match("", "", 0.0, {})
|
pakka/cli.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""`pakka <command>`: bench a cache, review generated labels, approve queued answers."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
COMMANDS = {
|
|
6
|
+
"bench": ("pakka.bench", "measure hit rate and false hits on a labelled question set"),
|
|
7
|
+
"review": ("pakka.review", "settle labels two LLMs disagreed on"),
|
|
8
|
+
"approve": ("pakka.approve", "approve answers waiting to enter the shared cache"),
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main(argv=None):
|
|
13
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
14
|
+
if not argv or argv[0] not in COMMANDS:
|
|
15
|
+
listing = "\n".join(f" {name:8s} {help}" for name, (_, help) in COMMANDS.items())
|
|
16
|
+
print(f"usage: pakka <command> [options]\n\ncommands:\n{listing}", file=sys.stderr)
|
|
17
|
+
sys.exit(2)
|
|
18
|
+
module, _ = COMMANDS[argv[0]]
|
|
19
|
+
sys.argv[0] = f"pakka {argv[0]}" # so each command's --help shows the right name
|
|
20
|
+
__import__(module, fromlist=["main"]).main(argv[1:])
|
pakka/embed.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Sentence-transformers or API embedders that return unit vectors, so dot product equals cosine."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
API_URL = "https://openrouter.ai/api/v1/embeddings"
|
|
12
|
+
API_DIMENSIONS = 1024 # API models return Matryoshka vectors: 1024 keeps 150k texts in memory on 8 GB
|
|
13
|
+
# What a model card asks to put before each text (matched on the model name); a cache pair gets it on both sides.
|
|
14
|
+
PREFIXES = {"e5": "query: ", "qwen3-embedding": "Instruct: Retrieve semantically similar text.\nQuery: "}
|
|
15
|
+
DEFAULT_EMBEDDER = "paraphrase-multilingual-MiniLM-L12-v2" # ~9 ms a question on any CPU
|
|
16
|
+
GPU_EMBEDDER = "Qwen/Qwen3-Embedding-0.6B" # about twice the answers, but ~235 ms a question on a CPU: for a GPU
|
|
17
|
+
# MiniLM-L12 fine-tuned in Phase 9: CPU speed, ~3x the real-message answers *with a verifier*; no better without one
|
|
18
|
+
TUNED_EMBEDDER = "mohd010/pakka-minilm-l12-hinglish"
|
|
19
|
+
# Picked on HinglishCacheBench v1 dev with pakka.guard on, for a 10% false-hit budget (95% upper bound); the tuned
|
|
20
|
+
# model's on the half of v1 dev it wasn't trained on.
|
|
21
|
+
THRESHOLDS = {DEFAULT_EMBEDDER: 0.79, GPU_EMBEDDER: 0.925, TUNED_EMBEDDER: 0.906}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _prefix(model_name):
|
|
25
|
+
return next((p for key, p in PREFIXES.items() if key in model_name.lower()), "")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_embedder(model_name=DEFAULT_EMBEDDER, max_length=None, half=False, transport=None, batch=256):
|
|
29
|
+
"""`max_length` caps tokens per text (long chat prompts are cut there), never raising a model's own limit.
|
|
30
|
+
`half` loads float16 weights, half the memory, for a big model on a small machine; vectors stay float32.
|
|
31
|
+
`openrouter:<model>` (e.g. `openrouter:qwen/qwen3-embedding-8b`) embeds through OpenRouter's API with
|
|
32
|
+
OPENROUTER_API_KEY, `batch` texts per call."""
|
|
33
|
+
if model_name.startswith("openrouter:"):
|
|
34
|
+
return _api_embedder(model_name.removeprefix("openrouter:"), max_length, transport, batch)
|
|
35
|
+
import torch
|
|
36
|
+
from sentence_transformers import SentenceTransformer
|
|
37
|
+
|
|
38
|
+
model = SentenceTransformer(model_name, model_kwargs={"dtype": torch.float16} if half else None)
|
|
39
|
+
if max_length:
|
|
40
|
+
model.max_seq_length = min(model.max_seq_length, max_length)
|
|
41
|
+
prefix = _prefix(model_name)
|
|
42
|
+
|
|
43
|
+
def embed(texts):
|
|
44
|
+
vectors = np.asarray(model.encode([prefix + t for t in texts], convert_to_numpy=True), dtype=np.float32)
|
|
45
|
+
# Normalized here, in float32: a bf16 model (Qwen3's default) normalizes to within 0.4% of unit length
|
|
46
|
+
return vectors / np.linalg.norm(vectors, axis=1, keepdims=True)
|
|
47
|
+
|
|
48
|
+
return embed
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _api_embedder(model, max_length, transport, batch):
|
|
52
|
+
import httpx
|
|
53
|
+
|
|
54
|
+
key = os.environ.get("OPENROUTER_API_KEY")
|
|
55
|
+
if not key:
|
|
56
|
+
raise SystemExit(f"Set OPENROUTER_API_KEY to embed with {model} through OpenRouter")
|
|
57
|
+
client = httpx.Client(timeout=120, headers={"Authorization": f"Bearer {key}"}, transport=transport)
|
|
58
|
+
prefix = _prefix(model)
|
|
59
|
+
cut = max_length * 4 if max_length else None # shortcut: ~4 characters per token; use a tokenizer if it matters
|
|
60
|
+
|
|
61
|
+
def post(texts):
|
|
62
|
+
for attempt in range(5): # a long run meets the odd rate limit or dropped connection
|
|
63
|
+
try:
|
|
64
|
+
response = client.post(API_URL, json={"model": model, "input": texts, "dimensions": API_DIMENSIONS})
|
|
65
|
+
response.raise_for_status()
|
|
66
|
+
return [d["embedding"] for d in sorted(response.json()["data"], key=lambda d: d["index"])]
|
|
67
|
+
except (httpx.HTTPError, KeyError):
|
|
68
|
+
if attempt == 4:
|
|
69
|
+
raise
|
|
70
|
+
time.sleep(2 ** attempt)
|
|
71
|
+
|
|
72
|
+
def embed(texts):
|
|
73
|
+
chunks = [[prefix + (t[:cut] or " ") for t in texts[i:i + batch]] for i in range(0, len(texts), batch)]
|
|
74
|
+
with ThreadPoolExecutor(16) as pool: # a call takes seconds on some providers; keep many in flight
|
|
75
|
+
vectors = np.array([v for part in pool.map(post, chunks) for v in part], dtype=np.float32)
|
|
76
|
+
return vectors / np.linalg.norm(vectors, axis=1, keepdims=True)
|
|
77
|
+
|
|
78
|
+
return embed
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def disk_cached(embed, path):
|
|
82
|
+
"""`embed` backed by every vector it has made, kept at `path` (.npz keyed by a hash of the text), so a
|
|
83
|
+
model embeds each text once across runs. Texts it hasn't seen are embedded in one batch; `.save()` writes."""
|
|
84
|
+
path, store, new = Path(path), {}, []
|
|
85
|
+
if path.exists():
|
|
86
|
+
with np.load(path) as z:
|
|
87
|
+
store = dict(zip(z["keys"].tolist(), z["vectors"]))
|
|
88
|
+
|
|
89
|
+
def cached(texts):
|
|
90
|
+
keys = [hashlib.md5(t.encode()).hexdigest() for t in texts]
|
|
91
|
+
missing = {k: t for k, t in zip(keys, texts) if k not in store}
|
|
92
|
+
if missing:
|
|
93
|
+
store.update(zip(missing, embed(list(missing.values()))))
|
|
94
|
+
new.append(len(missing))
|
|
95
|
+
return np.stack([store[k] for k in keys])
|
|
96
|
+
|
|
97
|
+
def save():
|
|
98
|
+
if new:
|
|
99
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
np.savez(path, keys=np.array(list(store)), vectors=np.stack(list(store.values())))
|
|
101
|
+
new.clear()
|
|
102
|
+
|
|
103
|
+
cached.save = save
|
|
104
|
+
return cached
|