vecshift 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.
Files changed (62) hide show
  1. vecshift/__init__.py +15 -0
  2. vecshift/assets/bench.css +39 -0
  3. vecshift/assets/eval.css +91 -0
  4. vecshift/assets/report.css +252 -0
  5. vecshift/assets/report.js +50 -0
  6. vecshift/bench/__init__.py +18 -0
  7. vecshift/bench/corpus.py +175 -0
  8. vecshift/bench/generate.py +106 -0
  9. vecshift/bench/html.py +325 -0
  10. vecshift/bench/metrics.py +50 -0
  11. vecshift/bench/runner.py +183 -0
  12. vecshift/cli.py +236 -0
  13. vecshift/cli_apply.py +283 -0
  14. vecshift/cli_bench.py +354 -0
  15. vecshift/cli_cutover.py +431 -0
  16. vecshift/cli_eval.py +591 -0
  17. vecshift/cli_plan.py +335 -0
  18. vecshift/cli_style.py +57 -0
  19. vecshift/connectors/__init__.py +1 -0
  20. vecshift/connectors/pgvector/__init__.py +29 -0
  21. vecshift/connectors/pgvector/connection.py +155 -0
  22. vecshift/connectors/pgvector/documents.py +96 -0
  23. vecshift/connectors/pgvector/inspect.py +427 -0
  24. vecshift/connectors/pgvector/search.py +240 -0
  25. vecshift/connectors/pgvector/switch.py +481 -0
  26. vecshift/connectors/pgvector/target.py +195 -0
  27. vecshift/connectors/pgvector/writer.py +431 -0
  28. vecshift/core/__init__.py +4 -0
  29. vecshift/core/capabilities.py +33 -0
  30. vecshift/core/contracts.py +57 -0
  31. vecshift/core/fingerprint.py +57 -0
  32. vecshift/core/record.py +75 -0
  33. vecshift/doctor/__init__.py +15 -0
  34. vecshift/doctor/checks.py +490 -0
  35. vecshift/doctor/findings.py +78 -0
  36. vecshift/doctor/html.py +493 -0
  37. vecshift/doctor/profile.py +69 -0
  38. vecshift/embeddings/__init__.py +22 -0
  39. vecshift/embeddings/cache.py +86 -0
  40. vecshift/embeddings/providers.py +244 -0
  41. vecshift/embeddings/spec.py +240 -0
  42. vecshift/eval/__init__.py +20 -0
  43. vecshift/eval/html.py +444 -0
  44. vecshift/eval/metrics.py +81 -0
  45. vecshift/eval/queries.py +97 -0
  46. vecshift/eval/runner.py +394 -0
  47. vecshift/html_kit.py +143 -0
  48. vecshift/jobs/__init__.py +5 -0
  49. vecshift/jobs/spec.py +202 -0
  50. vecshift/migrate/__init__.py +6 -0
  51. vecshift/migrate/engine.py +272 -0
  52. vecshift/migrate/state.py +50 -0
  53. vecshift/planning/__init__.py +14 -0
  54. vecshift/planning/plan.py +87 -0
  55. vecshift/planning/planner.py +493 -0
  56. vecshift/py.typed +0 -0
  57. vecshift-0.1.0.dist-info/METADATA +264 -0
  58. vecshift-0.1.0.dist-info/RECORD +62 -0
  59. vecshift-0.1.0.dist-info/WHEEL +4 -0
  60. vecshift-0.1.0.dist-info/entry_points.txt +2 -0
  61. vecshift-0.1.0.dist-info/licenses/LICENSE +202 -0
  62. vecshift-0.1.0.dist-info/licenses/NOTICE +4 -0
vecshift/eval/html.py ADDED
@@ -0,0 +1,444 @@
1
+ """The ``vecshift eval --html`` report: one self-contained page, no network access."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from datetime import datetime
7
+
8
+ from vecshift.doctor.findings import Severity
9
+ from vecshift.doctor.html import _icon
10
+ from vecshift.eval.metrics import ALL
11
+ from vecshift.eval.runner import EvalReport, SideReport, SweepPoint
12
+ from vecshift.html_kit import asset, e, page, stamp
13
+
14
+ QUERY_SOURCES = {
15
+ "proxy": "sentences taken from the rows (no LLM)",
16
+ "labeled": "your labeled queries",
17
+ "generated": "written by an LLM from sampled rows",
18
+ "cross-language": "written by an LLM in the other language",
19
+ }
20
+ SETTING_NAMES = {"hnsw": "ef_search", "ivfflat": "probes"}
21
+ VERDICTS = {
22
+ "go": ("ok", Severity.OK, "GO: the new vectors are ready"),
23
+ "no_go": ("error", Severity.ERROR, "NO-GO: don't cut over yet"),
24
+ "inconclusive": ("warning", Severity.WARNING, "Inconclusive: not enough evidence"),
25
+ }
26
+
27
+
28
+ def _num(value: float | None) -> str:
29
+ return "—" if value is None else f"{value:.3f}"
30
+
31
+
32
+ def _ms(value: float | None) -> str:
33
+ if value is None:
34
+ return "—"
35
+ return f"{value:,.0f} ms" if value >= 100 else f"{value:.1f} ms"
36
+
37
+
38
+ def _slice_name(name: str) -> str:
39
+ return "All queries" if name == ALL else name.replace("→", " → ").capitalize()
40
+
41
+
42
+ def _delta(
43
+ before: float | None, after: float | None, *, higher_is_better: bool, ms: bool = False
44
+ ) -> str:
45
+ if before is None or after is None:
46
+ return ""
47
+ change = after - before
48
+ text = f"{change:+.1f} ms" if ms else f"{change:+.3f}"
49
+ if abs(change) < (0.05 if ms else 0.0005):
50
+ return (
51
+ f'<span class="delta">no change vs old {e(_ms(before) if ms else _num(before))}</span>'
52
+ )
53
+ better = (change > 0) == higher_is_better
54
+ arrow = "▲" if change > 0 else "▼"
55
+ tone = "good" if better else "bad"
56
+ was = _ms(before) if ms else _num(before)
57
+ return (
58
+ f'<span class="delta {tone}"><i aria-hidden="true">{arrow}</i>{e(text)} '
59
+ f"vs old {e(was)}</span>"
60
+ )
61
+
62
+
63
+ # --- Sections
64
+
65
+
66
+ def _verdict(report: EvalReport) -> str:
67
+ tone, severity, title = VERDICTS[report.verdict.status]
68
+ verdict = report.verdict
69
+ lead = verdict.reasons[0] if verdict.reasons else ""
70
+ rest = "".join(f"<li>{e(r)}</li>" for r in verdict.reasons[1:])
71
+ warnings = "".join(
72
+ f'<li class="warn">{_icon(Severity.WARNING)}<span>{e(w)}</span></li>'
73
+ for w in verdict.warnings
74
+ )
75
+ items = f'<ul class="reasons">{rest}{warnings}</ul>' if rest or warnings else ""
76
+ return (
77
+ f'<section class="card verdict" data-tone="{tone}" aria-labelledby="verdict-title">'
78
+ f"{_icon(severity, large=True)}"
79
+ f'<div><h2 id="verdict-title">{e(title)}</h2><p>{e(lead)}</p>{items}</div></section>'
80
+ )
81
+
82
+
83
+ def _tile(label: str, value: str, caption: str) -> str:
84
+ return (
85
+ f'<div class="card tile"><div class="tile-label">{e(label)}</div>'
86
+ f'<div class="tile-value">{e(value)}</div><div class="tile-caption">{caption}</div></div>'
87
+ )
88
+
89
+
90
+ def _tiles(report: EvalReport) -> str:
91
+ old, new = report.old, report.new
92
+ tiles = []
93
+ after = new.scores.get(ALL)
94
+ before = old.scores.get(ALL)
95
+ if after:
96
+ caption = (
97
+ _delta(before.recall_at_10, after.recall_at_10, higher_is_better=True)
98
+ if before
99
+ else "the old model wasn't available to compare"
100
+ )
101
+ tiles.append(_tile("Recall@10, new", _num(after.recall_at_10), caption))
102
+ if new.same_group_at_10 is not None:
103
+ tiles.append(
104
+ _tile(
105
+ "Same-document neighbours",
106
+ _num(new.same_group_at_10),
107
+ _delta(old.same_group_at_10, new.same_group_at_10, higher_is_better=True)
108
+ or "of each row's 10 nearest rows",
109
+ )
110
+ )
111
+ if new.end_to_end_p95 is not None:
112
+ tiles.append(
113
+ _tile(
114
+ "End-to-end p95, new",
115
+ _ms(new.end_to_end_p95),
116
+ _delta(old.end_to_end_p95, new.end_to_end_p95, higher_is_better=False, ms=True)
117
+ or "query embedding plus search",
118
+ )
119
+ )
120
+ point = new.current
121
+ if point and point.index_recall is not None:
122
+ setting = SETTING_NAMES.get(new.method or "", "")
123
+ where = f"at {setting}={point.setting}" if setting else "exact search"
124
+ tiles.append(
125
+ _tile(
126
+ "Index recall, new", f"{point.index_recall:.0%}", e(f"of the exact top 10, {where}")
127
+ )
128
+ )
129
+ tiles.append(
130
+ _tile(
131
+ "Queries",
132
+ f"{report.queries:,}",
133
+ e(QUERY_SOURCES.get(report.query_source, report.query_source)),
134
+ )
135
+ )
136
+ return (
137
+ '<section aria-label="Key numbers" style="margin-top:12px">'
138
+ f'<div class="tiles">{"".join(tiles)}</div></section>'
139
+ )
140
+
141
+
142
+ def _legend(*, ring: bool = False, line: bool = False) -> str:
143
+ shape = "key line" if line else "key"
144
+ items = [
145
+ f'<li><span class="{shape} old"></span>Old</li>',
146
+ f'<li><span class="{shape} new"></span>New</li>',
147
+ ]
148
+ if ring:
149
+ items.append('<li><span class="key ring"></span>Current setting</li>')
150
+ return f'<ul class="legend">{"".join(items)}</ul>'
151
+
152
+
153
+ def _quality(report: EvalReport) -> str:
154
+ old, new = report.old, report.new
155
+ head = (
156
+ "<figcaption><b>Search quality by language</b><span>Recall@10: how often the right "
157
+ "row is in the top 10. Rows are query → document scripts.</span></figcaption>"
158
+ )
159
+ if not new.scores:
160
+ return (
161
+ f'<figure class="card wide">{head}<p class="statement">No queries were run</p></figure>'
162
+ )
163
+ ticks = (0.0, 0.25, 0.5, 0.75, 1.0)
164
+ grid = "".join(f'<div class="db-grid" style="left:{t * 100:.0f}%"></div>' for t in ticks[1:-1])
165
+ rows = []
166
+ for name, after in new.scores.items():
167
+ before = old.scores.get(name)
168
+ label = _slice_name(name)
169
+ dots = []
170
+ if before:
171
+ lo, hi = sorted((before.recall_at_10, after.recall_at_10))
172
+ dots.append(
173
+ f'<div class="db-line" style="left:{lo * 100:.2f}%;'
174
+ f'width:{(hi - lo) * 100:.2f}%"></div>'
175
+ )
176
+ dots.append(
177
+ f'<span class="db-dot old" tabindex="0" '
178
+ f'style="left:{before.recall_at_10 * 100:.2f}%" '
179
+ f'data-tip-value="{before.recall_at_10:.3f}" data-tip-label="{e(label)} · old" '
180
+ f'aria-label="{e(label)}, old: recall@10 {before.recall_at_10:.3f}"></span>'
181
+ )
182
+ dots.append(
183
+ f'<span class="db-dot new" tabindex="0" style="left:{after.recall_at_10 * 100:.2f}%" '
184
+ f'data-tip-value="{after.recall_at_10:.3f}" data-tip-label="{e(label)} · new" '
185
+ f'aria-label="{e(label)}, new: recall@10 {after.recall_at_10:.3f}"></span>'
186
+ )
187
+ value = (
188
+ f"{_num(before.recall_at_10)} → {_num(after.recall_at_10)}"
189
+ if before
190
+ else _num(after.recall_at_10)
191
+ )
192
+ rows.append(
193
+ f'<div class="db-row"><div class="db-label">{e(label)}<span>{after.queries:,} queries'
194
+ f'</span></div><div class="db-track">{grid}{"".join(dots)}</div>'
195
+ f'<div class="db-value">{e(value)}</div></div>'
196
+ )
197
+ axis = (
198
+ '<div class="db-axis" aria-hidden="true"><span></span><div class="ticks">'
199
+ + "".join(f'<span style="left:{t * 100:.0f}%">{t:g}</span>' for t in ticks)
200
+ + '</div><span class="spacer"></span></div>'
201
+ )
202
+ table_rows = "".join(
203
+ f'<tr><td>{e(_slice_name(name))}</td><td class="num">{after.queries:,}</td>'
204
+ + "".join(
205
+ f'<td class="num">{_num(getattr(s, metric) if s else None)}</td>'
206
+ for s in (old.scores.get(name), after)
207
+ for metric in ("recall_at_1", "recall_at_10", "mrr_at_10")
208
+ )
209
+ + "</tr>"
210
+ for name, after in new.scores.items()
211
+ )
212
+ table = (
213
+ '<details class="table-view"><summary>View as table</summary><div class="table-scroll">'
214
+ '<table><thead><tr><th>Queries</th><th class="num">Count</th>'
215
+ '<th class="num">Old R@1</th><th class="num">Old R@10</th><th class="num">Old MRR</th>'
216
+ '<th class="num">New R@1</th><th class="num">New R@10</th><th class="num">New MRR</th>'
217
+ f"</tr></thead><tbody>{table_rows}</tbody></table></div></details>"
218
+ )
219
+ overlap = (
220
+ f'<p class="chart-note">The two sides share {report.result_overlap:.0%} of their '
221
+ "top-10 results.</p>"
222
+ if report.result_overlap is not None
223
+ else ""
224
+ )
225
+ legend = _legend() if old.scores else ""
226
+ return (
227
+ f'<figure class="card wide">{head}{legend}<div class="db" role="img" '
228
+ f'aria-label="Recall@10 by language, old and new">{"".join(rows)}</div>'
229
+ f"{axis}{overlap}{table}</figure>"
230
+ )
231
+
232
+
233
+ def _nice(value: float) -> float:
234
+ """A round number at or above ``value``, for an axis maximum."""
235
+ if value <= 0:
236
+ return 1.0
237
+ magnitude = 10.0 ** math.floor(math.log10(value))
238
+ for step in (1, 2, 2.5, 5, 10):
239
+ if step * magnitude >= value:
240
+ return step * magnitude
241
+ return 10 * magnitude # pragma: no cover
242
+
243
+
244
+ def _latency_chart(report: EvalReport) -> str:
245
+ sides = [s for s in (report.old, report.new) if s.sweep]
246
+ head = (
247
+ "<figcaption><b>Latency vs accuracy</b><span>Each point is one index setting: search p95 "
248
+ "against the share of the exact top 10 the index finds. Up and left is better."
249
+ "</span></figcaption>"
250
+ )
251
+ if not sides:
252
+ why = (
253
+ "Measured once the migration is complete"
254
+ if report.partial
255
+ else "No latency was measured"
256
+ )
257
+ return f'<figure class="card wide">{head}<p class="statement">{e(why)}</p></figure>'
258
+
259
+ points = [(s, p) for s in sides for p in s.sweep if p.latency and p.index_recall is not None]
260
+ slowest = max(p.latency.p95_ms for _, p in points if p.latency)
261
+ x_step = _nice(slowest * 1.1 / 4)
262
+ x_max = x_step * math.ceil(slowest * 1.1 / x_step)
263
+ low = min(p.index_recall for _, p in points if p.index_recall is not None)
264
+ # Fit the data, but always show at least five points of recall.
265
+ y_min = max(0.0, min(0.95, math.floor(low * 20) / 20))
266
+
267
+ def x(v: float) -> float:
268
+ return float(100 * v / x_max)
269
+
270
+ def y(v: float) -> float:
271
+ return float(100 * (v - y_min) / (1 - y_min))
272
+
273
+ y_ticks = [y_min, (y_min + 1) / 2, 1.0]
274
+ x_ticks = [x_step * i for i in range(round(x_max / x_step) + 1)]
275
+ grid = "".join(f'<div class="gridline" style="bottom:{y(t):.2f}%"></div>' for t in y_ticks[1:])
276
+ vgrid = "".join(f'<div class="vline" style="left:{x(t):.2f}%"></div>' for t in x_ticks[1:])
277
+ lines, marks, labels = [], [], []
278
+ ends: list[tuple[float, float, str]] = []
279
+ for side in sides:
280
+ coords = [
281
+ (x(p.latency.p95_ms), y(p.index_recall), p)
282
+ for p in side.sweep
283
+ if p.latency and p.index_recall is not None
284
+ ]
285
+ # In setting order, so the line shows what each step up buys and costs.
286
+ coords.sort(key=lambda c: c[2].setting or 0)
287
+ path = " ".join(f"{cx:.2f},{100 - cy:.2f}" for cx, cy, _ in coords)
288
+ lines.append(f'<polyline class="{side.side}" points="{path}"/>')
289
+ setting = SETTING_NAMES.get(side.method or "", "setting")
290
+ for cx, cy, point in coords:
291
+ name = f"{setting}={point.setting}" if point.setting is not None else "exact"
292
+ current = " current" if point.current else ""
293
+ lat = point.latency
294
+ p95 = _ms(lat.p95_ms if lat else None)
295
+ marks.append(
296
+ f'<span class="lp {side.side}{current}" tabindex="0" '
297
+ f'style="left:{cx:.2f}%;bottom:{cy:.2f}%" '
298
+ f'data-tip-value="{point.index_recall:.0%} · p95 {e(p95)}" '
299
+ f'data-tip-label="{side.side}, {e(name)}{" (current)" if point.current else ""}" '
300
+ f'aria-label="{side.side}, {e(name)}: index recall {point.index_recall:.0%}, '
301
+ f'search p95 {e(_ms(lat.p95_ms if lat else None))}"></span>'
302
+ )
303
+ if coords:
304
+ ends.append((coords[-1][0], coords[-1][1], side.side))
305
+ # End labels help only when they're apart; otherwise the legend and tooltips do the job.
306
+ crowded = len(ends) == 2 and (
307
+ abs(ends[0][0] - ends[1][0]) < 18 and abs(ends[0][1] - ends[1][1]) < 12
308
+ )
309
+ if not crowded:
310
+ for ex, ey, name in ends:
311
+ flip = " flip" if ex > 80 else ""
312
+ labels.append(
313
+ f'<span class="lc-label{flip}" style="left:{ex:.2f}%;bottom:{ey:.2f}%">'
314
+ f"{name}</span>"
315
+ )
316
+ yaxis = "".join(f'<span style="bottom:{y(t):.2f}%">{t * 100:g}%</span>' for t in y_ticks)
317
+ xaxis = "".join(f'<span style="left:{x(t):.2f}%">{round(t, 6):g} ms</span>' for t in x_ticks)
318
+ plot = (
319
+ f'<div class="lc" role="img" aria-label="Index recall against search p95, old and new">'
320
+ f'<div class="yaxis" aria-hidden="true" style="height:220px">{yaxis}</div>'
321
+ f'<div class="plot">{grid}{vgrid}<svg viewBox="0 0 100 100" preserveAspectRatio="none" '
322
+ f'aria-hidden="true">{"".join(lines)}</svg>{"".join(marks)}{"".join(labels)}</div>'
323
+ f'<div class="xaxis" aria-hidden="true">{xaxis}</div>'
324
+ '<div class="axis-title" aria-hidden="true">Search p95</div></div>'
325
+ )
326
+ rows = "".join(
327
+ f"<tr><td>{s.side}</td><td>{e(_setting(s, p))}</td>"
328
+ f'<td class="num">{_num(p.index_recall)}</td>'
329
+ + "".join(
330
+ f'<td class="num">{e(_ms(getattr(p.latency, f) if p.latency else None))}</td>'
331
+ for f in ("p50_ms", "p95_ms", "p99_ms")
332
+ )
333
+ + "</tr>"
334
+ for s in sides
335
+ for p in s.sweep
336
+ )
337
+ table = (
338
+ '<details class="table-view"><summary>View as table</summary><div class="table-scroll">'
339
+ '<table><thead><tr><th>Side</th><th>Setting</th><th class="num">Index recall</th>'
340
+ '<th class="num">p50</th><th class="num">p95</th><th class="num">p99</th></tr></thead>'
341
+ f"<tbody>{rows}</tbody></table></div></details>"
342
+ )
343
+ samples = next((p.latency.samples for _, p in points if p.latency), 0)
344
+ note = (
345
+ f'<p class="chart-note">{samples} queries per setting. Search times include the network '
346
+ "round trip from where vecshift ran.</p>"
347
+ )
348
+ legend = _legend(ring=True, line=True)
349
+ return f'<figure class="card wide">{head}{legend}{plot}{note}{table}</figure>'
350
+
351
+
352
+ def _setting(side: SideReport, point: SweepPoint) -> str:
353
+ if point.setting is None:
354
+ return "exact (no index)"
355
+ name = SETTING_NAMES.get(side.method or "", "setting")
356
+ return f"{name}={point.setting}" + (" (current)" if point.current else "")
357
+
358
+
359
+ def _latency_table(report: EvalReport) -> str:
360
+ old, new = report.old, report.new
361
+
362
+ def search(side: SideReport, field: str) -> str:
363
+ point = side.current
364
+ return _ms(getattr(point.latency, field) if point and point.latency else None)
365
+
366
+ def embed(side: SideReport, field: str) -> str:
367
+ return _ms(getattr(side.embed_latency, field) if side.embed_latency else None)
368
+
369
+ rows = [
370
+ ("Query embedding p50", embed(old, "p50_ms"), embed(new, "p50_ms"), ""),
371
+ ("Query embedding p95", embed(old, "p95_ms"), embed(new, "p95_ms"), ""),
372
+ ("Search p50 (current setting)", search(old, "p50_ms"), search(new, "p50_ms"), ""),
373
+ ("Search p95 (current setting)", search(old, "p95_ms"), search(new, "p95_ms"), ""),
374
+ ("Search p99 (current setting)", search(old, "p99_ms"), search(new, "p99_ms"), ""),
375
+ ("End-to-end p95", _ms(old.end_to_end_p95), _ms(new.end_to_end_p95), "total"),
376
+ ]
377
+ body = "".join(
378
+ f'<tr class="{cls}"><td>{e(label)}</td><td class="num">{e(a)}</td>'
379
+ f'<td class="num">{e(b)}</td></tr>'
380
+ for label, a, b, cls in rows
381
+ )
382
+ return (
383
+ '<section aria-labelledby="lat-title"><h2 id="lat-title">Latency</h2>'
384
+ '<div class="card lat"><div class="table-scroll"><table><thead><tr><th>Step</th>'
385
+ f'<th class="num">Old</th><th class="num">New</th></tr></thead><tbody>{body}</tbody>'
386
+ "</table></div></div></section>"
387
+ )
388
+
389
+
390
+ def _method(report: EvalReport) -> str:
391
+ old, new = report.old, report.new
392
+ parts = [
393
+ f"<p>Old vectors: <code>{e(old.column)}</code>, {e(old.model or 'model unknown')}. "
394
+ f"New vectors: <code>{e(new.column)}</code>, {e(new.model or '')}.</p>",
395
+ "<p>Each query was embedded by each side's model and searched in that side's column, "
396
+ "through its index. Documents weren't re-embedded: the vectors already in the database "
397
+ "were searched. Languages are told apart by script (Arabic or Latin letters).</p>",
398
+ *(f"<p>{e(side.note)}</p>" for side in (old, new) if side.note),
399
+ *(f"<p>{e(note)}</p>" for note in report.notes),
400
+ ]
401
+ if report.neighbour_overlap is not None:
402
+ parts.append(
403
+ f"<p>From stored vectors, each sampled row's 10 nearest rows overlap "
404
+ f"{report.neighbour_overlap:.0%} between the two sides.</p>"
405
+ )
406
+ return (
407
+ '<section aria-labelledby="method-title"><h2 id="method-title">How this was measured</h2>'
408
+ f'<div class="card method">{"".join(parts)}</div></section>'
409
+ )
410
+
411
+
412
+ def render_html(
413
+ report: EvalReport,
414
+ *,
415
+ job: str,
416
+ version: str | None = None,
417
+ generated_at: datetime | None = None,
418
+ ) -> str:
419
+ when = stamp(generated_at)
420
+ by = f"vecshift {version}" if version else "vecshift"
421
+ kind = "partial migration" if report.partial else "complete migration"
422
+ body = (
423
+ _verdict(report)
424
+ + _tiles(report)
425
+ + '<section aria-labelledby="charts-title"><h2 id="charts-title">Old vs new</h2>'
426
+ + f'<div class="charts">{_quality(report)}{_latency_chart(report)}</div></section>'
427
+ + _latency_table(report)
428
+ + _method(report)
429
+ )
430
+ footer = (
431
+ f"<p>Generated by {e(by)} on {e(when)}.</p>"
432
+ "<p>This page holds scores, timings, and column and model names, no document or query "
433
+ "text. It loads nothing from the network.</p>"
434
+ )
435
+ return page(
436
+ title=f"Migration eval · {job}",
437
+ product="vecshift eval",
438
+ heading=f"Old vs new vectors · {job}",
439
+ meta=[f"{report.queries:,} queries", kind, when],
440
+ body=body,
441
+ footer=footer,
442
+ generator=by,
443
+ extra_css=asset("bench.css") + asset("eval.css"),
444
+ )
@@ -0,0 +1,81 @@
1
+ """Scores from ranked result lists, and latency percentiles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Sequence
7
+ from dataclasses import asdict, dataclass
8
+ from typing import Any
9
+
10
+ from vecshift.eval.queries import EvalQuery
11
+
12
+ K = 10
13
+ ALL = "all"
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class Scores:
18
+ queries: int
19
+ recall_at_1: float
20
+ recall_at_10: float
21
+ mrr_at_10: float
22
+
23
+ def to_dict(self) -> dict[str, Any]:
24
+ return asdict(self)
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class Latency:
29
+ samples: int
30
+ p50_ms: float
31
+ p95_ms: float
32
+ p99_ms: float
33
+
34
+ def to_dict(self) -> dict[str, Any]:
35
+ return asdict(self)
36
+
37
+
38
+ def percentile(values: Sequence[float], pct: float) -> float:
39
+ """Nearest-rank percentile, so small samples report a value that was measured."""
40
+ ordered = sorted(values)
41
+ rank = max(1, math.ceil(pct / 100 * len(ordered)))
42
+ return ordered[rank - 1]
43
+
44
+
45
+ def latency(values: Sequence[float]) -> Latency | None:
46
+ if not values:
47
+ return None
48
+ return Latency(
49
+ len(values), percentile(values, 50), percentile(values, 95), percentile(values, 99)
50
+ )
51
+
52
+
53
+ def score(ranked: Sequence[Sequence[str]], relevant: Sequence[frozenset[str]]) -> Scores:
54
+ r1 = r10 = mrr = 0.0
55
+ for row, rel in zip(ranked, relevant, strict=True):
56
+ hits = [rank for rank, key in enumerate(row[:K], 1) if key in rel]
57
+ r1 += (1 if hits and hits[0] == 1 else 0) / len(rel)
58
+ r10 += len(hits) / len(rel)
59
+ mrr += 1 / hits[0] if hits else 0.0
60
+ n = len(relevant) or 1
61
+ return Scores(len(relevant), r1 / n, r10 / n, mrr / n)
62
+
63
+
64
+ def by_slice(ranked: Sequence[Sequence[str]], queries: Sequence[EvalQuery]) -> dict[str, Scores]:
65
+ """Scores for all queries, then for each query→document script pair."""
66
+ result = {ALL: score(ranked, [q.relevant for q in queries])}
67
+ for name in sorted({q.slice for q in queries}):
68
+ picked = [i for i, q in enumerate(queries) if q.slice == name]
69
+ result[name] = score([ranked[i] for i in picked], [queries[i].relevant for i in picked])
70
+ return result
71
+
72
+
73
+ def overlap(a: Sequence[Sequence[str]], b: Sequence[Sequence[str]], k: int = K) -> float:
74
+ """Average share of the top ``k`` results two searches have in common."""
75
+ if not a:
76
+ return 0.0
77
+ total = 0.0
78
+ for x, y in zip(a, b, strict=True):
79
+ size = min(k, max(len(x), len(y)))
80
+ total += len(set(x[:k]) & set(y[:k])) / size if size else 1.0
81
+ return total / len(a)
@@ -0,0 +1,97 @@
1
+ """Test queries for comparing the old and new vectors, in any mix of Arabic and Latin text."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ import re
7
+ from collections import Counter
8
+ from collections.abc import Sequence
9
+ from dataclasses import dataclass
10
+
11
+ ARABIC = re.compile(r"[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]")
12
+ LATIN = re.compile(r"[A-Za-z\u00C0-\u024F]")
13
+ # Sentence ends in Latin and Arabic text: . ! ? and the Arabic question mark and full stop.
14
+ _SENTENCE = re.compile(r"(?<=[.!?\u061F\u06D4])\s+|\n+")
15
+ _WORDS = re.compile(r"\S+")
16
+
17
+ SCRIPTS = ("arabic", "latin", "other")
18
+
19
+
20
+ def script(text: str) -> str:
21
+ """``arabic``, ``latin``, or ``other``: the writing system most of the letters use.
22
+
23
+ It's a script, not a language: English and French are both ``latin``. That's enough to
24
+ show whether a model handles Arabic, without a language-detection model.
25
+ """
26
+ arabic = len(ARABIC.findall(text))
27
+ latin = len(LATIN.findall(text))
28
+ if not arabic and not latin:
29
+ return "other"
30
+ return "arabic" if arabic >= latin else "latin"
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class EvalQuery:
35
+ text: str
36
+ relevant: frozenset[str]
37
+ """Keys of the rows that answer this query."""
38
+ language: str
39
+ """Script of the query."""
40
+ doc_language: str
41
+ """Script of the row that answers it."""
42
+
43
+ @property
44
+ def slice(self) -> str:
45
+ """Results are grouped by query and document script, e.g. ``arabic→latin``."""
46
+ return f"{self.language}→{self.doc_language}"
47
+
48
+
49
+ def _norm(sentence: str) -> str:
50
+ return " ".join(sentence.lower().split())
51
+
52
+
53
+ def proxy_queries(
54
+ rows: Sequence[tuple[str, str]], n: int, seed: int
55
+ ) -> tuple[list[EvalQuery], list[str]]:
56
+ """Make queries from the rows themselves, with no LLM.
57
+
58
+ One sentence of a row becomes a query whose answer is that row. The sentence is still
59
+ part of the stored vector's text, so these are easy known-item searches: good for
60
+ comparing the old and new vectors, generous as absolute numbers. Returns the queries
61
+ and notes for the report.
62
+ """
63
+ rng = random.Random(seed)
64
+ split = {key: [s.strip() for s in _SENTENCE.split(text) if s.strip()] for key, text in rows}
65
+ # Sentences repeated across rows (boilerplate) have more than one right answer.
66
+ counts = Counter(_norm(s) for sentences in split.values() for s in set(sentences))
67
+ candidates: dict[str, tuple[str, str]] = {}
68
+ for key, text in rows:
69
+ usable = [
70
+ s for s in split[key] if 5 <= len(_WORDS.findall(s)) <= 40 and counts[_norm(s)] == 1
71
+ ]
72
+ if usable:
73
+ candidates[key] = (rng.choice(usable), text)
74
+ continue
75
+ words = _WORDS.findall(text)
76
+ if len(words) >= 12:
77
+ candidates[key] = (" ".join(words[: min(12, len(words) // 2)]), text)
78
+ chosen = sorted(candidates)
79
+ rng.shuffle(chosen)
80
+ chosen = chosen[:n]
81
+ queries = [
82
+ EvalQuery(
83
+ candidates[key][0],
84
+ frozenset({key}),
85
+ script(candidates[key][0]),
86
+ script(candidates[key][1]),
87
+ )
88
+ for key in chosen
89
+ ]
90
+ notes = [
91
+ "Queries are sentences taken from the rows themselves (no LLM). The sentence is "
92
+ "still in the row's text, so absolute scores run high; the comparison between old "
93
+ "and new is what counts. Use --generate-queries or --queries for realistic numbers."
94
+ ]
95
+ if len(queries) < n:
96
+ notes.append(f"Only {len(queries)} rows had text long enough to make queries from.")
97
+ return queries, notes