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,431 @@
1
+ """The ``vecshift cutover`` and ``vecshift rollback`` commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import sys
8
+ from dataclasses import dataclass
9
+ from datetime import UTC, datetime
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING, Annotated, Any
12
+
13
+ import typer
14
+
15
+ from vecshift.cli_plan import DEFAULT_JOB, _fail, load
16
+ from vecshift.cli_style import finding_lines
17
+
18
+ if TYPE_CHECKING:
19
+ import psycopg
20
+
21
+ from vecshift.connectors.pgvector.switch import PgSwitch
22
+ from vecshift.connectors.pgvector.writer import Layout
23
+ from vecshift.doctor.findings import Finding
24
+ from vecshift.embeddings import ModelSpec
25
+ from vecshift.jobs import JobSpec
26
+ from vecshift.migrate import JobState
27
+
28
+ CATCH_UP_TRIES = 3
29
+
30
+
31
+ @dataclass(slots=True)
32
+ class _Located:
33
+ job: JobSpec
34
+ spec: ModelSpec
35
+ conn: psycopg.Connection
36
+ layout: Layout
37
+ state: JobState
38
+
39
+
40
+ def _locate(job_file: Path) -> _Located:
41
+ """Find the live, new, and previous columns, over a connection that can write."""
42
+ from vecshift.connectors import pgvector
43
+ from vecshift.connectors.pgvector.inspect import (
44
+ TEXT_COLUMNS,
45
+ TEXT_TYPES,
46
+ _columns,
47
+ _pick,
48
+ )
49
+ from vecshift.connectors.pgvector.target import inspect_target, resolve_source_column
50
+ from vecshift.connectors.pgvector.writer import Layout
51
+ from vecshift.migrate import JobState
52
+
53
+ job, settings, spec = load(job_file)
54
+ try:
55
+ conn = pgvector.connect_writer(settings)
56
+ except pgvector.ConnectError as exc:
57
+ raise _fail(f"Couldn't connect: {exc}", exc.hint) from exc
58
+ try:
59
+ live = resolve_source_column(
60
+ conn, job.source.table, job.source.vector_column, job.target.column
61
+ )
62
+ target = inspect_target(conn, job.source.table, live, job.target.column)
63
+ text = job.source.text_column or _pick(
64
+ _columns(conn, target.source.relid), TEXT_COLUMNS, TEXT_TYPES
65
+ )
66
+ except pgvector.TargetSelectionError as exc:
67
+ conn.close()
68
+ raise _fail(str(exc)) from exc
69
+ if len(target.primary_key) != 1 or text is None:
70
+ conn.close()
71
+ raise _fail("Cutover needs a single-column primary key and a text column.")
72
+ dims = target.column_dimensions or target.source.dimensions or spec.dimensions or 0
73
+ layout = Layout(
74
+ schema=target.source.schema,
75
+ table=target.source.table,
76
+ pk=target.primary_key[0],
77
+ text=text,
78
+ target=job.target.column,
79
+ vector_type=job.target.vector_type.value,
80
+ dims=dims,
81
+ extension_schema=target.extension_schema,
82
+ live=live,
83
+ )
84
+ return _Located(job, spec, conn, layout, JobState.for_job(job_file, job.name))
85
+
86
+
87
+ def _confirm(message: str, yes: bool) -> None:
88
+ if yes:
89
+ return
90
+ typer.echo(message, err=True)
91
+ if not sys.stdin.isatty():
92
+ raise _fail("Not changing the database without confirmation.", "Pass --yes to proceed.")
93
+ if not typer.confirm("Continue?", err=True):
94
+ raise typer.Exit(1)
95
+
96
+
97
+ def _record(state: JobState, event: str, layout: Layout) -> None:
98
+ state.history.append(
99
+ {
100
+ "event": event,
101
+ "at": datetime.now(UTC).isoformat(timespec="seconds"),
102
+ "table": f"{layout.schema}.{layout.table}",
103
+ "live": layout.live,
104
+ "previous": layout.previous,
105
+ "new": layout.target,
106
+ }
107
+ )
108
+ state.save()
109
+
110
+
111
+ def _catch_up(found: _Located, switch: PgSwitch) -> int:
112
+ """Embed rows that arrived or changed since the last apply. Returns rows written."""
113
+ from vecshift.cli_apply import _run
114
+ from vecshift.connectors.pgvector.writer import PgWriter
115
+ from vecshift.embeddings import create_embedder
116
+ from vecshift.embeddings.providers import CONCURRENCY
117
+
118
+ spec = found.spec
119
+ result = asyncio.run(
120
+ _run(
121
+ PgWriter(found.conn, found.layout),
122
+ create_embedder(spec),
123
+ found.state,
124
+ dims=found.layout.dims,
125
+ price_per_million=spec.price,
126
+ budget_usd=found.job.limits.budget_usd,
127
+ chunk_rows=spec.batch_size * CONCURRENCY,
128
+ index=None,
129
+ )
130
+ )
131
+ if result.status not in {"complete", "stopped"}:
132
+ raise _fail(f"The final catch-up stopped: {result.message}", "Nothing was switched.")
133
+ return result.rows_written
134
+
135
+
136
+ def _filler(found: _Located) -> Any:
137
+ """Embeds the few rows that arrive between the last catch-up and the switch."""
138
+ from vecshift.embeddings import EmbeddingError, create_embedder
139
+ from vecshift.migrate.engine import MAX_CHARS
140
+
141
+ spec, state, dims = found.spec, found.state, found.layout.dims
142
+
143
+ def fill(rows: list[Any]) -> list[tuple[Any, list[float]]]:
144
+ async def embed() -> tuple[list[list[float]], int]:
145
+ embedder = create_embedder(spec)
146
+ try:
147
+ vectors = await embedder.embed([r.text[:MAX_CHARS] for r in rows], "document")
148
+ return vectors, embedder.tokens
149
+ finally:
150
+ await embedder.aclose()
151
+
152
+ vectors, tokens = asyncio.run(embed())
153
+ if any(len(v) != dims for v in vectors):
154
+ raise EmbeddingError(f"{spec.name} returned vectors of the wrong size.")
155
+ state.tokens += tokens
156
+ if spec.price is not None:
157
+ state.spent_usd += tokens * spec.price / 1_000_000
158
+ return list(zip(rows, vectors, strict=True))
159
+
160
+ return fill
161
+
162
+
163
+ def _show(findings: list[Finding], output_json: bool) -> None:
164
+ if not output_json:
165
+ finding_lines(findings)
166
+
167
+
168
+ def _guarded(run: Any) -> Any:
169
+ """Turn database and lock problems into clean messages."""
170
+ import psycopg
171
+
172
+ from vecshift.connectors.pgvector.writer import AlreadyRunning, Busy
173
+ from vecshift.embeddings import EmbeddingError
174
+
175
+ try:
176
+ return run()
177
+ except AlreadyRunning as exc:
178
+ raise _fail(str(exc), "Wait for it to finish, or stop it first.") from exc
179
+ except (Busy, EmbeddingError) as exc:
180
+ raise _fail(str(exc), "Nothing was switched; try again.") from exc
181
+ except psycopg.Error as exc:
182
+ detail = str(exc).strip().splitlines()[0] if str(exc).strip() else type(exc).__name__
183
+ raise _fail(f"The database reported an error: {detail}", "Nothing was switched.") from exc
184
+
185
+
186
+ def cutover(
187
+ job_file: Annotated[Path, typer.Argument(help="The job file.")] = DEFAULT_JOB,
188
+ check: Annotated[
189
+ bool, typer.Option("--check", help="Only check that cutover is safe; change nothing.")
190
+ ] = False,
191
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Don't ask before switching.")] = False,
192
+ allow_missing: Annotated[
193
+ bool,
194
+ typer.Option(
195
+ "--allow-missing",
196
+ help="Switch even though the provider rejected some rows (they get no vector).",
197
+ ),
198
+ ] = False,
199
+ output_json: Annotated[bool, typer.Option("--json", help="Print the result as JSON.")] = False,
200
+ ) -> None:
201
+ """Switch searches to the new vectors by giving them the column name your app uses."""
202
+ from vecshift.connectors.pgvector.switch import PgSwitch, StillPending
203
+ from vecshift.connectors.pgvector.writer import PgWriter
204
+
205
+ found = _locate(job_file)
206
+ lay, state = found.layout, found.state
207
+ writer = PgWriter(found.conn, lay)
208
+ switch = PgSwitch(writer)
209
+ index = found.job.target.index.value
210
+ method = None if index == "none" else index
211
+
212
+ def run() -> dict[str, Any]:
213
+ writer.acquire()
214
+ try:
215
+ ready = switch.check(method)
216
+ blocked = any(
217
+ f.severity.rank == 3 and f.id != "cutover.pending" for f in ready.findings
218
+ )
219
+ if check or blocked:
220
+ return {"status": "ready" if ready.ok else "blocked", "readiness": ready}
221
+ if not output_json:
222
+ typer.secho(f"vecshift cutover · {found.job.name}", bold=True)
223
+ _show([f for f in ready.findings if f.severity.rank >= 1], output_json)
224
+ where = "your machine" if found.spec.is_local else found.spec.url
225
+ sends = (
226
+ f" First it embeds {ready.pending:,} rows that changed since the last apply, "
227
+ f"sending their text to {where}."
228
+ if ready.pending
229
+ else ""
230
+ )
231
+ _confirm(
232
+ f"\nCutover renames {lay.live} to {lay.previous} and {lay.target} to "
233
+ f"{lay.live} on {lay.schema}.{lay.table}, so searches use {found.spec.name} "
234
+ f"vectors.{sends} Switch your application to {found.spec.name} at the same time.",
235
+ yes,
236
+ )
237
+ allowed = len(state.failed) if allow_missing else 0
238
+ caught_up = _catch_up(found, switch) if ready.pending else 0
239
+ switch.prepare()
240
+ try:
241
+ for attempt in range(1, CATCH_UP_TRIES + 1):
242
+ try:
243
+ caught_up += switch.cutover(
244
+ allowed, fill=_filler(found), exclude=tuple(state.failed)
245
+ )
246
+ break
247
+ except StillPending as exc:
248
+ if attempt == CATCH_UP_TRIES:
249
+ hint = (
250
+ "The provider rejected some rows: fix their text, or pass "
251
+ "--allow-missing."
252
+ if state.failed
253
+ else "Rows keep changing; try again when writes are quieter."
254
+ )
255
+ raise _fail(
256
+ f"{exc.rows:,} rows still have no new vector.", hint
257
+ ) from exc
258
+ caught_up += _catch_up(found, switch)
259
+ finally:
260
+ switch.finish()
261
+ _record(state, "cutover", lay)
262
+ return {"status": "cut_over", "readiness": ready, "caught_up": caught_up}
263
+ finally:
264
+ writer.release()
265
+
266
+ try:
267
+ outcome = _guarded(run)
268
+ finally:
269
+ found.conn.close()
270
+
271
+ ready = outcome["readiness"]
272
+ if output_json:
273
+ typer.echo(
274
+ json.dumps(
275
+ {
276
+ "status": outcome["status"],
277
+ "live": lay.live,
278
+ "previous": lay.previous,
279
+ "new": lay.target,
280
+ "pending": ready.pending,
281
+ "caught_up": outcome.get("caught_up", 0),
282
+ "findings": [f.to_dict() for f in ready.findings],
283
+ }
284
+ )
285
+ )
286
+ elif outcome["status"] in {"ready", "blocked"}:
287
+ typer.secho(f"vecshift cutover · {found.job.name}", bold=True)
288
+ finding_lines(ready.findings)
289
+ verdict = (
290
+ ("Ready to cut over.", typer.colors.GREEN)
291
+ if ready.ok
292
+ else ("Not ready to cut over.", typer.colors.RED)
293
+ )
294
+ typer.secho(f"\n{verdict[0]}", fg=verdict[1], bold=True)
295
+ else:
296
+ typer.secho("\nCut over.", fg=typer.colors.GREEN, bold=True)
297
+ typer.echo(
298
+ f" {lay.table}.{lay.live} now holds {found.spec.name} vectors; the old ones are "
299
+ f"kept in {lay.previous}."
300
+ )
301
+ if outcome.get("caught_up"):
302
+ typer.echo(f" Embedded {outcome['caught_up']:,} late rows first.")
303
+ typer.echo(
304
+ f"\nNext: make sure your application embeds queries and new rows with "
305
+ f"{found.spec.name}.\nUndo with: vecshift rollback. When you're sure, drop "
306
+ f"{lay.previous} to free its space."
307
+ )
308
+ if outcome["status"] == "blocked":
309
+ raise typer.Exit(1)
310
+
311
+
312
+ def rollback(
313
+ job_file: Annotated[Path, typer.Argument(help="The job file.")] = DEFAULT_JOB,
314
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Don't ask before switching.")] = False,
315
+ output_json: Annotated[bool, typer.Option("--json", help="Print the result as JSON.")] = False,
316
+ ) -> None:
317
+ """Undo a cutover: give the old vectors their column name back."""
318
+ from vecshift.connectors.pgvector.switch import PgSwitch
319
+ from vecshift.connectors.pgvector.writer import PgWriter
320
+
321
+ found = _locate(job_file)
322
+ lay, state = found.layout, found.state
323
+ writer = PgWriter(found.conn, lay)
324
+ switch = PgSwitch(writer)
325
+
326
+ def run() -> int:
327
+ writer.acquire()
328
+ try:
329
+ stage = switch.stage()
330
+ if stage != "cut_over":
331
+ raise _fail(
332
+ "There's no cutover to roll back.",
333
+ f"Rollback needs {lay.previous}, holding the old vectors, and no "
334
+ f"{lay.target} column.",
335
+ )
336
+ bound = switch.dependents(lay.live) + switch.dependents(lay.previous)
337
+ if bound:
338
+ raise _fail(
339
+ f"Views or functions are bound to the vector columns: {', '.join(bound)}.",
340
+ "Drop them before rollback and recreate them after it.",
341
+ )
342
+ _confirm(
343
+ f"Rollback renames {lay.live} to {lay.target} and {lay.previous} to {lay.live} "
344
+ f"on {lay.schema}.{lay.table}, so searches use the old vectors again. Switch "
345
+ "your application back to the old model at the same time.",
346
+ yes,
347
+ )
348
+ missing = switch.rollback()
349
+ _record(state, "rollback", lay)
350
+ return missing
351
+ finally:
352
+ writer.release()
353
+
354
+ try:
355
+ missing = _guarded(run)
356
+ finally:
357
+ found.conn.close()
358
+
359
+ if output_json:
360
+ typer.echo(
361
+ json.dumps(
362
+ {"status": "rolled_back", "live": lay.live, "new": lay.target, "missing": missing}
363
+ )
364
+ )
365
+ return
366
+ typer.secho("\nRolled back.", fg=typer.colors.GREEN, bold=True)
367
+ typer.echo(
368
+ f" {lay.table}.{lay.live} holds the old vectors again; the new ones are back in "
369
+ f"{lay.target}, kept in sync for another cutover."
370
+ )
371
+ if missing:
372
+ typer.echo(
373
+ f" {missing:,} rows were added or edited after cutover and have no old-model "
374
+ "vector. Your application needs to embed them with the old model."
375
+ )
376
+
377
+
378
+ def cleanup(
379
+ job_file: Annotated[Path, typer.Argument(help="The job file.")] = DEFAULT_JOB,
380
+ yes: Annotated[
381
+ bool, typer.Option("--yes", "-y", help="Don't ask for the column name first.")
382
+ ] = False,
383
+ output_json: Annotated[bool, typer.Option("--json", help="Print the result as JSON.")] = False,
384
+ ) -> None:
385
+ """After cutover, drop the old vectors for good. This can't be undone."""
386
+ from vecshift.connectors.pgvector.switch import PgSwitch
387
+ from vecshift.connectors.pgvector.writer import PgWriter
388
+
389
+ found = _locate(job_file)
390
+ lay, state = found.layout, found.state
391
+ writer = PgWriter(found.conn, lay)
392
+ switch = PgSwitch(writer)
393
+
394
+ def run() -> None:
395
+ writer.acquire()
396
+ try:
397
+ if switch.stage() != "cut_over":
398
+ raise _fail(
399
+ "There's no cutover to clean up after.",
400
+ f"Cleanup drops {lay.previous}, which cutover creates.",
401
+ )
402
+ bound = switch.dependents(lay.previous)
403
+ if bound:
404
+ raise _fail(
405
+ f"Views or functions use {lay.previous}: {', '.join(bound)}.",
406
+ "Drop or change them first.",
407
+ )
408
+ if not yes:
409
+ typer.echo(
410
+ f"Cleanup drops {lay.schema}.{lay.table}.{lay.previous} and its index. The "
411
+ "old vectors are gone for good, and rollback is no longer possible.",
412
+ err=True,
413
+ )
414
+ if not sys.stdin.isatty():
415
+ raise _fail("Not dropping anything without confirmation.", "Pass --yes.")
416
+ typed = typer.prompt(f"Type {lay.previous} to confirm", err=True, default="")
417
+ if typed != lay.previous:
418
+ raise _fail("That didn't match, so nothing was dropped.")
419
+ switch.cleanup()
420
+ _record(state, "cleanup", lay)
421
+ finally:
422
+ writer.release()
423
+
424
+ try:
425
+ _guarded(run)
426
+ finally:
427
+ found.conn.close()
428
+ if output_json:
429
+ typer.echo(json.dumps({"status": "cleaned_up", "dropped": lay.previous}))
430
+ else:
431
+ typer.secho(f"\nDropped {lay.previous}. The migration is complete.", fg=typer.colors.GREEN)