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
@@ -0,0 +1,493 @@
1
+ """Render a doctor :class:`Report` as one self-contained HTML file.
2
+
3
+ The page inlines its own styles and script and fetches nothing, so it can be opened
4
+ offline, attached to a ticket, or shared without sending data anywhere. It holds
5
+ statistics only: no vectors and no row text.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from datetime import datetime
12
+ from typing import Any
13
+
14
+ from vecshift.doctor.findings import Finding, Report, Severity
15
+ from vecshift.html_kit import compact, page, percent, stamp
16
+ from vecshift.html_kit import e as _e
17
+ from vecshift.html_kit import nice_ceiling as _nice_ceiling
18
+ from vecshift.html_kit import rich as _rich
19
+
20
+ MAX_BARS = 7
21
+ DASH = "\u2013" # en dash, for ranges
22
+
23
+ _ICONS = {
24
+ Severity.ERROR: (
25
+ '<circle cx="12" cy="12" r="11" fill="var(--critical)"/>'
26
+ '<path d="M8 8l8 8M16 8l-8 8" stroke="var(--on-status)" stroke-width="2.2" '
27
+ 'stroke-linecap="round"/>'
28
+ ),
29
+ Severity.WARNING: (
30
+ '<path d="M12 2.5 23 21.5H1z" fill="var(--warning)" stroke="var(--warning)" '
31
+ 'stroke-width="1.5" stroke-linejoin="round"/>'
32
+ '<path d="M12 9v5.5" stroke="var(--on-warning)" stroke-width="2.2" '
33
+ 'stroke-linecap="round"/><circle cx="12" cy="18" r="1.3" fill="var(--on-warning)"/>'
34
+ ),
35
+ Severity.INFO: (
36
+ '<circle cx="12" cy="12" r="11" fill="var(--info)"/>'
37
+ '<path d="M12 11v6" stroke="var(--surface)" stroke-width="2.2" stroke-linecap="round"/>'
38
+ '<circle cx="12" cy="7.5" r="1.4" fill="var(--surface)"/>'
39
+ ),
40
+ Severity.OK: (
41
+ '<circle cx="12" cy="12" r="11" fill="var(--good)"/>'
42
+ '<path d="m7.5 12.5 3 3 6-6.5" fill="none" stroke="var(--on-status)" '
43
+ 'stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>'
44
+ ),
45
+ }
46
+
47
+ _LABELS = {
48
+ Severity.ERROR: ("Error", "Errors"),
49
+ Severity.WARNING: ("Warning", "Warnings"),
50
+ Severity.INFO: ("Info", "Info"),
51
+ Severity.OK: ("OK", "OK"),
52
+ }
53
+
54
+
55
+ def _icon(severity: Severity, large: bool = False) -> str:
56
+ cls = "sev sev-lg" if large else "sev"
57
+ return f'<svg class="{cls}" viewBox="0 0 24 24" aria-hidden="true">{_ICONS[severity]}</svg>'
58
+
59
+
60
+ def _fmt_norm(x: float) -> str:
61
+ if x == 0:
62
+ return "0"
63
+ if abs(x) >= 100:
64
+ return f"{x:,.0f}"
65
+ if abs(x) >= 10:
66
+ return f"{x:.1f}"
67
+ return f"{x:.2f}"
68
+
69
+
70
+ @dataclass(frozen=True, slots=True)
71
+ class _Tile:
72
+ label: str
73
+ value: str
74
+ caption: str
75
+ state: Severity | None = None
76
+ meter: float | None = None
77
+
78
+
79
+ def _verdict(report: Report) -> str:
80
+ errors = report.count(Severity.ERROR)
81
+ warnings = report.count(Severity.WARNING)
82
+
83
+ def n(count: int, word: str) -> str:
84
+ return f"{count} {word}{'' if count == 1 else 's'}"
85
+
86
+ if report.sample_rows == 0 and not errors and not warnings:
87
+ tone, severity = "info", Severity.INFO
88
+ title, text = "Nothing to inspect", "No rows were visible, so only the schema was checked."
89
+ elif errors:
90
+ tone, severity = "error", Severity.ERROR
91
+ title = "Not ready to migrate"
92
+ text = f"Fix {n(errors, 'error')} before migrating"
93
+ text += f", and review {n(warnings, 'warning')}." if warnings else "."
94
+ elif warnings:
95
+ tone, severity = "warning", Severity.WARNING
96
+ title = "Ready to migrate, with caveats"
97
+ text = f"Review {n(warnings, 'warning')} before migrating."
98
+ else:
99
+ tone, severity = "ok", Severity.OK
100
+ title = "Ready to migrate"
101
+ text = "No problems found in the inspected rows."
102
+
103
+ counts = "".join(
104
+ f"<li>{_icon(s)}<span><b>{report.count(s)}</b> "
105
+ f"{_LABELS[s][0 if report.count(s) == 1 else 1].lower()}</span></li>"
106
+ for s in (Severity.ERROR, Severity.WARNING, Severity.INFO, Severity.OK)
107
+ )
108
+ return (
109
+ f'<section class="card verdict" data-tone="{tone}" aria-labelledby="verdict-title">'
110
+ f"{_icon(severity, large=True)}"
111
+ f'<div><h2 id="verdict-title">{_e(title)}</h2><p>{_e(text)}</p>'
112
+ f'<ul class="counts" aria-label="Findings by severity">{counts}</ul></div>'
113
+ "</section>"
114
+ )
115
+
116
+
117
+ def _tiles(report: Report) -> list[_Tile]:
118
+ f = report.facts
119
+ sample = f.get("sample", {})
120
+ rows = report.sample_rows
121
+ tiles: list[_Tile] = []
122
+
123
+ if report.estimated_rows is None:
124
+ tiles.append(_Tile("Rows", "Unknown", "table not analyzed yet"))
125
+ else:
126
+ exact = report.sample_method == "full table"
127
+ tiles.append(
128
+ _Tile(
129
+ "Rows",
130
+ compact(report.estimated_rows),
131
+ "exact count" if exact else "estimated from statistics",
132
+ )
133
+ )
134
+ tiles.append(_Tile("Inspected", compact(rows), report.sample_method))
135
+
136
+ dims: dict[str, int] = f.get("dimensions", {})
137
+ if len(dims) > 1:
138
+ tiles.append(
139
+ _Tile("Vector size", "Mixed", f"{len(dims)} sizes in the sample", Severity.ERROR)
140
+ )
141
+ elif report.declared_dimensions:
142
+ tiles.append(
143
+ _Tile(
144
+ "Vector size",
145
+ f"{report.declared_dimensions:,}",
146
+ f"{report.vector_type}, fixed by the schema",
147
+ )
148
+ )
149
+ elif dims:
150
+ (size,) = dims
151
+ tiles.append(
152
+ _Tile(
153
+ "Vector size",
154
+ f"{int(size):,}",
155
+ f"{report.vector_type}, not fixed by the schema",
156
+ Severity.WARNING,
157
+ )
158
+ )
159
+ else:
160
+ tiles.append(_Tile("Vector size", "—", f"{report.vector_type}, no vectors sampled"))
161
+
162
+ text_field = f.get("text_field")
163
+ present = sample.get("texts_present") or 0
164
+ if text_field is None:
165
+ tiles.append(_Tile("Re-embeddable", "None", "no source text column", Severity.ERROR, 0.0))
166
+ elif rows:
167
+ state = Severity.WARNING if present < rows else None
168
+ tiles.append(
169
+ _Tile(
170
+ "Re-embeddable",
171
+ percent(present, rows),
172
+ f"rows with text in {text_field}",
173
+ state,
174
+ present / rows,
175
+ )
176
+ )
177
+ else:
178
+ tiles.append(_Tile("Re-embeddable", "—", f"text in {text_field}"))
179
+
180
+ norms = f.get("norms")
181
+ if norms:
182
+ nonzero = norms["count"] - norms["zero"]
183
+ unit = norms["unit"]
184
+ # Zero vectors have their own finding; this tile is only about normalization.
185
+ norm_state = Severity.WARNING if 0 < unit < nonzero else None
186
+ caption = "unit-length vectors" if unit else "not normalized; fine with cosine"
187
+ tiles.append(
188
+ _Tile(
189
+ "Normalized",
190
+ percent(unit, norms["count"]),
191
+ caption,
192
+ norm_state,
193
+ unit / norms["count"],
194
+ )
195
+ )
196
+ else:
197
+ tiles.append(_Tile("Normalized", "—", "no vectors sampled"))
198
+
199
+ if text_field is not None and present:
200
+ dupes, base, what = sample.get("duplicate_texts", 0), present, "repeated texts"
201
+ else:
202
+ dupes, base, what = (
203
+ sample.get("duplicate_vectors", 0),
204
+ sample.get("vectors", 0),
205
+ ("repeated vectors"),
206
+ )
207
+ if base:
208
+ warn = any(x.id.startswith("duplicates.") for x in report.findings)
209
+ tiles.append(
210
+ _Tile(
211
+ "Duplicates",
212
+ percent(dupes, base),
213
+ f"{what} in the sample",
214
+ Severity.WARNING if warn else None,
215
+ dupes / base,
216
+ )
217
+ )
218
+ else:
219
+ tiles.append(_Tile("Duplicates", "—", "nothing sampled"))
220
+ return tiles
221
+
222
+
223
+ def _render_tile(t: _Tile) -> str:
224
+ flag = ""
225
+ if t.state is not None:
226
+ flag = f'{_icon(t.state)}<span class="sr-only">{_LABELS[t.state][0]}:</span>'
227
+ meter = ""
228
+ if t.meter is not None:
229
+ state = {Severity.ERROR: "error", Severity.WARNING: "warning"}.get(
230
+ t.state or Severity.OK, "series"
231
+ )
232
+ width = max(0.0, min(1.0, t.meter)) * 100
233
+ meter = (
234
+ f'<div class="meter" data-state="{state}" aria-hidden="true">'
235
+ f'<span style="width:{width:.1f}%"></span></div>'
236
+ )
237
+ return (
238
+ f'<div class="card tile"><div class="tile-label">{flag}{_e(t.label)}</div>'
239
+ f'<div class="tile-value">{_e(t.value)}</div>'
240
+ f'<div class="tile-caption">{_e(t.caption)}</div>{meter}</div>'
241
+ )
242
+
243
+
244
+ def _render_finding(finding: Finding) -> str:
245
+ hint = f'<p class="hint">{_rich(finding.hint)}</p>' if finding.hint else ""
246
+ label = _LABELS[finding.severity][0]
247
+ return (
248
+ f'<article class="card finding" data-severity="{finding.severity}">'
249
+ f"{_icon(finding.severity)}"
250
+ f'<div class="finding-head"><span class="label">{label}</span>'
251
+ f"<h3>{_rich(finding.title)}</h3>"
252
+ f'<span class="fid">{_e(finding.id)}</span></div>'
253
+ f"<p>{_rich(finding.detail)}</p>{hint}</article>"
254
+ )
255
+
256
+
257
+ def _findings(report: Report) -> str:
258
+ ordered = report.sorted_findings()
259
+ buttons = [
260
+ f'<button type="button" data-filter="all" aria-pressed="true">All {len(ordered)}</button>'
261
+ ]
262
+ for s in (Severity.ERROR, Severity.WARNING, Severity.INFO, Severity.OK):
263
+ n = report.count(s)
264
+ disabled = " disabled" if n == 0 else ""
265
+ buttons.append(
266
+ f'<button type="button" data-filter="{s}" aria-pressed="false"{disabled}>'
267
+ f"{_LABELS[s][1]} {n}</button>"
268
+ )
269
+ return (
270
+ '<section aria-labelledby="findings-title"><h2 id="findings-title">Findings</h2>'
271
+ f'<div class="filters" role="toolbar" aria-label="Filter findings">{"".join(buttons)}</div>'
272
+ f'<div class="findings">{"".join(_render_finding(x) for x in ordered)}</div></section>'
273
+ )
274
+
275
+
276
+ def _table(headers: tuple[str, str], rows: list[tuple[str, str]]) -> str:
277
+ body = "".join(f'<tr><td>{_e(a)}</td><td class="num">{_e(b)}</td></tr>' for a, b in rows)
278
+ return (
279
+ '<details class="table-view"><summary>View as table</summary>'
280
+ f'<div class="table-scroll"><table><thead><tr><th>{_e(headers[0])}</th>'
281
+ f'<th class="num">{_e(headers[1])}</th></tr></thead><tbody>{body}</tbody></table></div>'
282
+ "</details>"
283
+ )
284
+
285
+
286
+ def _histogram(norms: dict[str, Any] | None) -> str:
287
+ head = (
288
+ "<figcaption><b>Vector lengths</b><span>L2 norm of each sampled vector. "
289
+ "Normalized vectors sit at 1.0.</span></figcaption>"
290
+ )
291
+ if not norms:
292
+ return (
293
+ f'<figure class="card wide">{head}<p class="statement">No vectors sampled</p></figure>'
294
+ )
295
+ lo, hi = norms["min"], norms["max"]
296
+ tol = norms["tolerance"]
297
+ if hi - lo <= 2 * tol:
298
+ return (
299
+ f'<figure class="card wide">{head}'
300
+ f'<p class="statement">All {norms["count"]:,} vectors have length '
301
+ f"{_fmt_norm(norms['median'])}</p>"
302
+ f'<p class="note">The spread is under ±{tol}, so there is nothing to chart.</p>'
303
+ "</figure>"
304
+ )
305
+
306
+ bins: list[dict[str, float]] = norms["histogram"]
307
+ peak = max(int(b["count"]) for b in bins)
308
+ top = _nice_ceiling(peak)
309
+ grid = "".join(
310
+ f'<div class="gridline" style="bottom:{frac * 100:.0f}%"></div>' for frac in (0.5, 1.0)
311
+ )
312
+ yaxis = "".join(
313
+ f'<span style="bottom:{frac * 100:.0f}%">{compact(round(top * frac))}</span>'
314
+ for frac in (0.0, 0.5, 1.0)
315
+ )
316
+ cells = []
317
+ for b in bins:
318
+ count = int(b["count"])
319
+ height = 0.0 if count == 0 else max(1.5, 100 * count / top)
320
+ label = f"length {_fmt_norm(b['start'])}{DASH}{_fmt_norm(b['end'])}"
321
+ value = f"{count:,} vector{'' if count == 1 else 's'}"
322
+ cells.append(
323
+ f'<div class="bin" tabindex="0" data-tip-value="{_e(value)}" '
324
+ f'data-tip-label="{_e(label)}" aria-label="{_e(value)}, {_e(label)}">'
325
+ f'<i style="height:{height:.2f}%"></i></div>'
326
+ )
327
+ ref = ""
328
+ if lo <= 1.0 <= hi:
329
+ x = 100 * (1.0 - lo) / (hi - lo)
330
+ flip = " flip" if x > 80 else ""
331
+ ref = (
332
+ f'<div class="refline{flip}" style="left:{x:.2f}%" aria-hidden="true">'
333
+ "<span>1.0 = unit length</span></div>"
334
+ )
335
+ plot = (
336
+ '<div class="hist" role="img" aria-label="Histogram of vector lengths">'
337
+ f'<div class="yaxis" aria-hidden="true">{yaxis}</div>'
338
+ f'<div class="plot">{grid}<div class="bins">{"".join(cells)}</div>{ref}</div>'
339
+ f'<div class="xaxis" aria-hidden="true"><span>{_fmt_norm(lo)}</span>'
340
+ f"<span>{_fmt_norm((lo + hi) / 2)}</span><span>{_fmt_norm(hi)}</span></div>"
341
+ '<div class="axis-title" aria-hidden="true">vector length</div></div>'
342
+ )
343
+ table = _table(
344
+ ("Length", "Vectors"),
345
+ [
346
+ (f"{_fmt_norm(b['start'])}{DASH}{_fmt_norm(b['end'])}", f"{int(b['count']):,}")
347
+ for b in bins
348
+ ],
349
+ )
350
+ return f'<figure class="card wide">{head}{plot}{table}</figure>'
351
+
352
+
353
+ def _breakdown(
354
+ title: str, subtitle: str, counts: dict[str, int], noun: str, empty: str, single: str
355
+ ) -> str:
356
+ head = f"<figcaption><b>{_e(title)}</b><span>{_rich(subtitle)}</span></figcaption>"
357
+ if not counts:
358
+ return f'<figure class="card">{head}<p class="statement">{_e(empty)}</p></figure>'
359
+ total = sum(counts.values())
360
+ items = sorted(counts.items(), key=lambda kv: -kv[1])
361
+ if len(items) == 1:
362
+ (name, n) = items[0]
363
+ return (
364
+ f'<figure class="card">{head}<p class="statement">{_e(name)}</p>'
365
+ f'<p class="note">{_e(single.format(n=n))}</p></figure>'
366
+ )
367
+ if len(items) > MAX_BARS:
368
+ rest = sum(n for _, n in items[MAX_BARS - 1 :])
369
+ items = [*items[: MAX_BARS - 1], (f"Other ({len(items) - MAX_BARS + 1})", rest)]
370
+ peak = max(n for _, n in items)
371
+ rows = []
372
+ for name, n in items:
373
+ share = percent(n, total)
374
+ rows.append(
375
+ f'<div class="bar-row" tabindex="0" data-tip-value="{n:,} {noun} · {share}" '
376
+ f'data-tip-label="{_e(name)}" aria-label="{_e(name)}: {n:,} {noun}, {share}">'
377
+ f'<div class="bar-label">{_e(name)}</div><div class="bar-cell">'
378
+ f'<div class="bar" style="width:calc({100 * n / peak:.2f}% - 72px)"></div>'
379
+ f'<span class="bar-value">{n:,} · {share}</span></div></div>'
380
+ )
381
+ table = _table((title.split()[0], noun.capitalize()), [(k, f"{v:,}") for k, v in items])
382
+ return f'<figure class="card">{head}<div class="bars">{"".join(rows)}</div>{table}</figure>'
383
+
384
+
385
+ def _charts(report: Report) -> str:
386
+ f = report.facts
387
+ models = f.get("models", {})
388
+ model_field = f.get("model_field")
389
+ model_sub = f"Recorded in `{model_field}`" if model_field else "Where rows record their model"
390
+ dims = {f"{int(k):,} dimensions": v for k, v in f.get("dimensions", {}).items()}
391
+ models_card = _breakdown(
392
+ "Models",
393
+ model_sub,
394
+ models,
395
+ "vectors",
396
+ empty="No model recorded",
397
+ single="The only model named by the {n:,} sampled vectors that record one.",
398
+ )
399
+ sizes_card = _breakdown(
400
+ "Vector sizes",
401
+ "Dimensions per sampled vector",
402
+ dims,
403
+ "vectors",
404
+ empty="No vectors sampled",
405
+ single="All {n:,} sampled vectors have this size.",
406
+ )
407
+ return (
408
+ '<section aria-labelledby="sample-title"><h2 id="sample-title">Inside the sample</h2>'
409
+ f'<div class="charts">{_histogram(f.get("norms"))}{models_card}{sizes_card}</div>'
410
+ "</section>"
411
+ )
412
+
413
+
414
+ def _facts(report: Report, connection: str | None) -> str:
415
+ f = report.facts
416
+ yes_no = {True: "Yes", False: "No"}
417
+ if f.get("logical_replication") and f.get("has_primary_key"):
418
+ tracking = "Logical replication"
419
+ elif f.get("updated_at_field"):
420
+ tracking = f"Polling `{f['updated_at_field']}`"
421
+ else:
422
+ tracking = "None"
423
+ indexes = ", ".join(
424
+ f"{i['name']} ({i['method']}{', ' + i['metric'] if i.get('metric') else ''})"
425
+ for i in f.get("ann_indexes", [])
426
+ )
427
+ dims = (
428
+ f"`{report.vector_type}({report.declared_dimensions})`"
429
+ if report.declared_dimensions
430
+ else f"`{report.vector_type}` (size not fixed)"
431
+ )
432
+
433
+ def code(name: str | None, missing: str) -> str:
434
+ return f"`{name}`" if name else missing
435
+
436
+ rows: list[tuple[str, str]] = [
437
+ ("Column", code(report.target, "")),
438
+ ("Type", dims),
439
+ ("Source text", code(f.get("text_field"), "Not found")),
440
+ ("Model recorded in", code(f.get("model_field"), "Not recorded")),
441
+ ("Primary key", yes_no[bool(f.get("has_primary_key"))]),
442
+ ("Change tracking", tracking),
443
+ ("ANN indexes", indexes or "None"),
444
+ (
445
+ "Row-level security",
446
+ "Hides rows from this role" if f.get("rows_hidden_by_access_rules") else "Not limiting",
447
+ ),
448
+ ("Sampling", f"{report.sample_rows:,} rows, {report.sample_method}"),
449
+ ]
450
+ if connection:
451
+ rows.append(("Connection", f"`{connection}`"))
452
+ items = "".join(f'<div class="fact"><dt>{_e(k)}</dt><dd>{_rich(v)}</dd></div>' for k, v in rows)
453
+ return (
454
+ '<section aria-labelledby="setup-title"><h2 id="setup-title">Setup</h2>'
455
+ f'<dl class="card facts">{items}</dl></section>'
456
+ )
457
+
458
+
459
+ def render_html(
460
+ report: Report,
461
+ *,
462
+ connection: str | None = None,
463
+ connection_kind: str | None = None,
464
+ version: str | None = None,
465
+ generated_at: datetime | None = None,
466
+ ) -> str:
467
+ """The report as a complete HTML document.
468
+
469
+ ``connection`` must already have its password removed.
470
+ """
471
+ when = stamp(generated_at)
472
+ meta = [report.store, *([connection_kind] if connection_kind else []), when]
473
+ tiles = "".join(_render_tile(t) for t in _tiles(report))
474
+ by = f"vecshift {version}" if version else "vecshift"
475
+ body = (
476
+ f"{_verdict(report)}"
477
+ f'<section aria-label="Key numbers"><div class="tiles">{tiles}</div></section>'
478
+ f"{_findings(report)}{_charts(report)}{_facts(report, connection)}"
479
+ )
480
+ footer = (
481
+ f"<p>Generated by {_e(by)} on {_e(when)}.</p>"
482
+ "<p>This report holds statistics only: no vectors and no row text. It loads nothing "
483
+ "from the network.</p>"
484
+ )
485
+ return page(
486
+ title=f"Doctor report · {report.target}",
487
+ product="vecshift doctor",
488
+ heading=report.target,
489
+ meta=meta,
490
+ body=body,
491
+ footer=footer,
492
+ generator=by,
493
+ )
@@ -0,0 +1,69 @@
1
+ """What a connector learns about an index, in a store-independent shape.
2
+
3
+ Connectors fill in an :class:`IndexProfile`; the checks in :mod:`vecshift.doctor.checks`
4
+ only ever look at the profile, never at the store.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections import Counter
10
+ from dataclasses import dataclass, field
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class AnnIndex:
15
+ """An approximate-nearest-neighbour index over the vector field."""
16
+
17
+ name: str
18
+ method: str
19
+ """For example ``hnsw`` or ``ivfflat``."""
20
+ metric: str | None
21
+ """``cosine``, ``inner_product``, ``l2``, ``l1``, or ``None`` if unknown."""
22
+
23
+
24
+ @dataclass(slots=True)
25
+ class Sample:
26
+ """Statistics over a sample of rows. Vectors themselves are never fetched."""
27
+
28
+ rows: int
29
+ method: str
30
+ """How rows were chosen, for example ``"full table"`` or ``"table sample"``."""
31
+ null_vectors: int = 0
32
+ dimensions: Counter[int] = field(default_factory=Counter)
33
+ norms: list[float] = field(default_factory=list)
34
+ texts_present: int | None = None
35
+ """Rows with non-empty text, or ``None`` when there's no text field."""
36
+ duplicate_texts: int = 0
37
+ duplicate_vectors: int = 0
38
+ models: Counter[str] = field(default_factory=Counter)
39
+ """Recorded model per row, for rows that record one."""
40
+ model_source: str | None = None
41
+ """Where most rows record their model, for example ``metadata->>'model'``."""
42
+
43
+ @property
44
+ def vectors(self) -> int:
45
+ """Rows that have a vector."""
46
+ return self.rows - self.null_vectors
47
+
48
+
49
+ @dataclass(slots=True)
50
+ class IndexProfile:
51
+ """Everything the checks need to know about one vector field."""
52
+
53
+ store: str
54
+ target: str
55
+ """Human-readable location, for example ``public.documents.embedding``."""
56
+ vector_type: str
57
+ declared_dimensions: int | None
58
+ estimated_rows: int | None
59
+ sample: Sample
60
+ text_field: str | None = None
61
+ model_field: str | None = None
62
+ updated_at_field: str | None = None
63
+ has_primary_key: bool = True
64
+ logical_replication: bool = False
65
+ ann_indexes: list[AnnIndex] = field(default_factory=list)
66
+ max_indexable_dimensions: int | None = None
67
+ """The most dimensions the store can put in an ANN index, if it has a limit."""
68
+ rows_hidden_by_access_rules: bool = False
69
+ """Row-level security (or similar) may hide rows from the connecting role."""
@@ -0,0 +1,22 @@
1
+ """Embedding providers: turn text into vectors."""
2
+
3
+ from vecshift.embeddings.cache import CachedEmbedder, EmbeddingCache
4
+ from vecshift.embeddings.providers import (
5
+ EmbeddingError,
6
+ HashingEmbedder,
7
+ OpenAICompatEmbedder,
8
+ create_embedder,
9
+ )
10
+ from vecshift.embeddings.spec import ModelSpec, SpecError, parse_spec
11
+
12
+ __all__ = [
13
+ "CachedEmbedder",
14
+ "EmbeddingCache",
15
+ "EmbeddingError",
16
+ "HashingEmbedder",
17
+ "ModelSpec",
18
+ "OpenAICompatEmbedder",
19
+ "SpecError",
20
+ "create_embedder",
21
+ "parse_spec",
22
+ ]
@@ -0,0 +1,86 @@
1
+ """An on-disk embedding cache, so re-running a benchmark doesn't pay twice.
2
+
3
+ Keys are hashes of the model configuration, the mode, and the text; the text itself is
4
+ never stored. The cache lives in the user's cache directory, not the project.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import os
11
+ import sqlite3
12
+ from array import array
13
+ from collections.abc import Sequence
14
+ from pathlib import Path
15
+
16
+ from vecshift.embeddings.providers import Embedder, EmbedMode
17
+
18
+
19
+ def default_cache_path() -> Path:
20
+ base = os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache"
21
+ return Path(base) / "vecshift" / "embeddings.sqlite"
22
+
23
+
24
+ class EmbeddingCache:
25
+ def __init__(self, path: Path | None = None) -> None:
26
+ self.path = path or default_cache_path()
27
+ # Embeddings can be partly inverted back into text, so the cache is readable by
28
+ # its owner only, like ~/.ssh.
29
+ self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
30
+ os.chmod(self.path.parent, 0o700)
31
+ self._db = sqlite3.connect(self.path)
32
+ os.chmod(self.path, 0o600)
33
+ self._db.execute(
34
+ "CREATE TABLE IF NOT EXISTS embeddings (key BLOB PRIMARY KEY, vector BLOB NOT NULL)"
35
+ )
36
+
37
+ @staticmethod
38
+ def key(identity: str, mode: str, text: str) -> bytes:
39
+ return hashlib.sha256(f"{identity}\0{mode}\0{text}".encode()).digest()
40
+
41
+ def get_many(self, keys: Sequence[bytes]) -> dict[bytes, list[float]]:
42
+ found: dict[bytes, list[float]] = {}
43
+ for i in range(0, len(keys), 500):
44
+ chunk = list(keys[i : i + 500])
45
+ placeholders = ",".join("?" * len(chunk)) # only "?" marks; values are bound
46
+ rows = self._db.execute(
47
+ f"SELECT key, vector FROM embeddings WHERE key IN ({placeholders})", # noqa: S608
48
+ chunk,
49
+ ).fetchall()
50
+ for key, blob in rows:
51
+ found[key] = array("f", blob).tolist()
52
+ return found
53
+
54
+ def put_many(self, items: Sequence[tuple[bytes, Sequence[float]]]) -> None:
55
+ self._db.executemany(
56
+ "INSERT OR REPLACE INTO embeddings (key, vector) VALUES (?, ?)",
57
+ [(k, array("f", v).tobytes()) for k, v in items],
58
+ )
59
+ self._db.commit()
60
+
61
+ def close(self) -> None:
62
+ self._db.close()
63
+
64
+
65
+ class CachedEmbedder:
66
+ """Wraps an embedder; only texts missing from the cache reach the provider."""
67
+
68
+ def __init__(self, inner: Embedder, cache: EmbeddingCache) -> None:
69
+ self.inner = inner
70
+ self.cache = cache
71
+ self.hits = 0
72
+ self.misses = 0
73
+
74
+ async def embed(self, texts: Sequence[str], mode: EmbedMode = "document") -> list[list[float]]:
75
+ identity = self.inner.spec.identity
76
+ keys = [EmbeddingCache.key(identity, mode, t) for t in texts]
77
+ found = self.cache.get_many(keys)
78
+ missing = [i for i, k in enumerate(keys) if k not in found]
79
+ self.hits += len(texts) - len(missing)
80
+ self.misses += len(missing)
81
+ if missing:
82
+ vectors = await self.inner.embed([texts[i] for i in missing], mode)
83
+ self.cache.put_many([(keys[i], v) for i, v in zip(missing, vectors, strict=True)])
84
+ for i, v in zip(missing, vectors, strict=True):
85
+ found[keys[i]] = v
86
+ return [found[k] for k in keys]