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 statements ``vecshift apply`` runs against PostgreSQL.
2
+
3
+ Everything here writes, so every statement is composed with ``psycopg.sql`` and runs under
4
+ a lock timeout: if a lock isn't free within a few seconds the statement gives up and retries
5
+ later, instead of queueing behind application queries and blocking them.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import secrets
12
+ import time
13
+ from collections.abc import Callable, Sequence
14
+ from dataclasses import dataclass
15
+ from typing import Any
16
+
17
+ import psycopg
18
+ from psycopg import errors, sql
19
+
20
+ from vecshift.connectors.pgvector.target import OPCLASS_SUFFIX
21
+
22
+ LOCK_TIMEOUT_MS = 2_000
23
+ """How long a schema change waits for its lock. Application writes queue behind a waiting
24
+ lock request, so this is also the longest they can stall; retries with backoff cover a
25
+ table that stays busy."""
26
+ STATEMENT_TIMEOUT_MS = 120_000
27
+ SCAN_TIMEOUT_MS = 30 * 60_000
28
+ """For full-table counts, which can take a while on large tables."""
29
+
30
+
31
+ class Busy(Exception):
32
+ """A lock couldn't be taken in time, even after retries."""
33
+
34
+
35
+ class AlreadyRunning(Exception):
36
+ """Another apply holds the lock for this table and column."""
37
+
38
+
39
+ def _limits(
40
+ executor: psycopg.Connection | psycopg.Cursor[Any],
41
+ statement_ms: int,
42
+ lock_ms: int | None = None,
43
+ ) -> None:
44
+ """Set timeouts for the current transaction only (like ``SET LOCAL``), as parameters."""
45
+ executor.execute("SELECT set_config('statement_timeout', %s, true)", (str(int(statement_ms)),))
46
+ if lock_ms is not None:
47
+ executor.execute("SELECT set_config('lock_timeout', %s, true)", (str(int(lock_ms)),))
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class Layout:
52
+ """Where things are. Names are raw identifiers; they're quoted when composed."""
53
+
54
+ schema: str
55
+ table: str
56
+ pk: str
57
+ text: str
58
+ target: str
59
+ vector_type: str
60
+ dims: int
61
+ extension_schema: str
62
+ live: str = ""
63
+ """The column the application searches. Cutover gives the new vectors this name."""
64
+
65
+ @property
66
+ def qualified(self) -> sql.Identifier:
67
+ return sql.Identifier(self.schema, self.table)
68
+
69
+ @property
70
+ def previous(self) -> str:
71
+ """Where cutover keeps the old vectors, for rollback."""
72
+ return f"{self.live}_old"[:63]
73
+
74
+ def trigger_name(self, column: str | None = None) -> str:
75
+ return f"vecshift_sync_{column or self.target}"[:63]
76
+
77
+ def function_name(self, column: str | None = None) -> str:
78
+ return f"vecshift_sync_{self.table}_{column or self.target}"[:63]
79
+
80
+ def index_name(self, method: str, column: str | None = None) -> str:
81
+ return f"{self.table}_{column or self.target}_{method}_idx"[:63]
82
+
83
+ @property
84
+ def lock_key(self) -> int:
85
+ digest = hashlib.sha256(f"vecshift:{self.schema}.{self.table}.{self.target}".encode())
86
+ return int.from_bytes(digest.digest()[:8], "big", signed=True)
87
+
88
+
89
+ @dataclass(frozen=True, slots=True)
90
+ class Row:
91
+ key: Any
92
+ """The primary key value, as the database returned it."""
93
+ text: str
94
+
95
+ @property
96
+ def id(self) -> str:
97
+ return str(self.key)
98
+
99
+
100
+ class PgWriter:
101
+ def __init__(
102
+ self,
103
+ conn: psycopg.Connection,
104
+ layout: Layout,
105
+ *,
106
+ lock_retries: int = 6,
107
+ lock_timeout_ms: int = LOCK_TIMEOUT_MS,
108
+ sleep: Callable[[float], None] = time.sleep,
109
+ ) -> None:
110
+ if not conn.autocommit:
111
+ raise ValueError("PgWriter needs an autocommit connection")
112
+ if layout.vector_type not in {"vector", "halfvec"}:
113
+ raise ValueError(f"unsupported vector type {layout.vector_type!r}")
114
+ self.conn = conn
115
+ self.layout = layout
116
+ self.lock_retries = lock_retries
117
+ self.lock_timeout_ms = int(lock_timeout_ms)
118
+ self._sleep = sleep
119
+
120
+ # --- plumbing
121
+
122
+ def _locked(self, statements: Sequence[sql.Composed | sql.SQL], what: str) -> None:
123
+ """Run statements in one transaction under a lock timeout, retrying when busy."""
124
+ delay = 1.0
125
+ for attempt in range(1, self.lock_retries + 1):
126
+ try:
127
+ with self.conn.transaction():
128
+ _limits(self.conn, STATEMENT_TIMEOUT_MS, self.lock_timeout_ms)
129
+ for statement in statements:
130
+ self.conn.execute(statement)
131
+ return
132
+ except errors.LockNotAvailable as exc:
133
+ if attempt == self.lock_retries:
134
+ raise Busy(
135
+ f"Couldn't {what}: the table stayed locked by other sessions. "
136
+ "Try again when it's quieter."
137
+ ) from exc
138
+ self._sleep(delay)
139
+ delay = min(delay * 2, 30)
140
+
141
+ def _type(self) -> sql.Composable:
142
+ lay = self.layout
143
+ return sql.SQL("{}({})").format(
144
+ sql.Identifier(lay.extension_schema, lay.vector_type), sql.Literal(lay.dims)
145
+ )
146
+
147
+ def _pending_filter(self) -> sql.Composable:
148
+ lay = self.layout
149
+ return sql.SQL(
150
+ "{target} IS NULL AND {text} IS NOT NULL AND btrim({text}::text) <> ''"
151
+ ).format(target=sql.Identifier(lay.target), text=sql.Identifier(lay.text))
152
+
153
+ # --- concurrency guard
154
+
155
+ def acquire(self) -> None:
156
+ """Make sure no other apply works on the same column at the same time.
157
+
158
+ Session-level advisory locks need a real session, so apply can't run through a
159
+ transaction pooler.
160
+ """
161
+ row = self.conn.execute(
162
+ "SELECT pg_try_advisory_lock(%s)", (self.layout.lock_key,)
163
+ ).fetchone()
164
+ if not row or not row[0]:
165
+ raise AlreadyRunning(
166
+ f"Another vecshift apply is already working on {self.layout.table}."
167
+ f"{self.layout.target}."
168
+ )
169
+
170
+ def release(self) -> None:
171
+ self.conn.execute("SELECT pg_advisory_unlock(%s)", (self.layout.lock_key,))
172
+
173
+ # --- schema changes
174
+
175
+ def column_exists(self) -> bool:
176
+ row = self.conn.execute(
177
+ """
178
+ SELECT 1 FROM pg_attribute
179
+ WHERE attrelid = %s::regclass AND attname = %s AND attnum > 0 AND NOT attisdropped
180
+ """,
181
+ (self._regclass(), self.layout.target),
182
+ ).fetchone()
183
+ return row is not None
184
+
185
+ def _regclass(self) -> str:
186
+ return sql.Identifier(self.layout.schema, self.layout.table).as_string(self.conn)
187
+
188
+ def ensure_column(self) -> bool:
189
+ """Add the target column if it's missing. Returns whether it was added."""
190
+ if self.column_exists():
191
+ return False
192
+ lay = self.layout
193
+ statement = sql.SQL("ALTER TABLE {table} ADD COLUMN IF NOT EXISTS {col} {type}").format(
194
+ table=lay.qualified, col=sql.Identifier(lay.target), type=self._type()
195
+ )
196
+ self._locked([statement], "add the new column")
197
+ return True
198
+
199
+ def trigger_exists(self, column: str | None = None) -> bool:
200
+ row = self.conn.execute(
201
+ "SELECT 1 FROM pg_trigger WHERE tgrelid = %s::regclass AND tgname = %s",
202
+ (self._regclass(), self.layout.trigger_name(column)),
203
+ ).fetchone()
204
+ return row is not None
205
+
206
+ def sync_function_sql(self, column: str | None = None) -> sql.Composed:
207
+ """A trigger function that clears ``column`` when a row's text changes.
208
+
209
+ If the same update also writes ``column`` itself (an application already writing
210
+ the new model's vectors), that value is kept.
211
+ """
212
+ lay = self.layout
213
+ col = sql.Identifier(column or lay.target)
214
+ tag = sql.SQL("$vs_" + secrets.token_hex(4) + "$")
215
+ body = sql.SQL(
216
+ "BEGIN IF NEW.{text} IS DISTINCT FROM OLD.{text} "
217
+ "AND NEW.{col} IS NOT DISTINCT FROM OLD.{col} THEN NEW.{col} := NULL; END IF; "
218
+ "RETURN NEW; END"
219
+ ).format(text=sql.Identifier(lay.text), col=col)
220
+ # A fixed search path finds pgvector's operators wherever the extension lives, and
221
+ # stops other schemas from shadowing them.
222
+ return sql.SQL(
223
+ "CREATE OR REPLACE FUNCTION {fn}() RETURNS trigger LANGUAGE plpgsql "
224
+ "SET search_path = pg_catalog, {ext} AS {tag}{body}{tag}"
225
+ ).format(
226
+ fn=sql.Identifier(lay.schema, lay.function_name(column)),
227
+ ext=sql.Identifier(lay.extension_schema),
228
+ tag=tag,
229
+ body=body,
230
+ )
231
+
232
+ def sync_trigger_sql(self, column: str | None = None) -> sql.Composed:
233
+ # Listing the guarded column makes the trigger depend on it, so dropping the column
234
+ # by hand fails clearly instead of leaving a trigger that breaks every update.
235
+ lay = self.layout
236
+ return sql.SQL(
237
+ "CREATE TRIGGER {name} BEFORE UPDATE OF {text}, {col} ON {table} "
238
+ "FOR EACH ROW EXECUTE FUNCTION {fn}()"
239
+ ).format(
240
+ name=sql.Identifier(lay.trigger_name(column)),
241
+ text=sql.Identifier(lay.text),
242
+ col=sql.Identifier(column or lay.target),
243
+ table=lay.qualified,
244
+ fn=sql.Identifier(lay.schema, lay.function_name(column)),
245
+ )
246
+
247
+ def drop_sync_sql(self, column: str | None = None) -> list[sql.Composed]:
248
+ lay = self.layout
249
+ return [
250
+ sql.SQL("DROP TRIGGER IF EXISTS {name} ON {table}").format(
251
+ name=sql.Identifier(lay.trigger_name(column)), table=lay.qualified
252
+ ),
253
+ sql.SQL("DROP FUNCTION IF EXISTS {fn}()").format(
254
+ fn=sql.Identifier(lay.schema, lay.function_name(column))
255
+ ),
256
+ ]
257
+
258
+ def ensure_trigger(self) -> bool:
259
+ """Clear a row's new vector whenever its text changes, so it gets re-embedded.
260
+
261
+ Returns whether the trigger was created. The function is always replaced, which
262
+ needs no lock on the table, so older installs pick up fixes.
263
+ """
264
+ if self.trigger_exists():
265
+ with self.conn.transaction():
266
+ _limits(self.conn, STATEMENT_TIMEOUT_MS, self.lock_timeout_ms)
267
+ self.conn.execute(self.sync_function_sql())
268
+ return False
269
+ self._locked([self.sync_function_sql(), self.sync_trigger_sql()], "add the sync trigger")
270
+ return True
271
+
272
+ # --- backfill
273
+
274
+ def total_count(self) -> int:
275
+ """Rows with text, which should all end up with a new vector."""
276
+ lay = self.layout
277
+ query = sql.SQL(
278
+ "SELECT count(*) FROM {table} WHERE {text} IS NOT NULL AND btrim({text}::text) <> ''"
279
+ ).format(table=lay.qualified, text=sql.Identifier(lay.text))
280
+ with self.conn.transaction():
281
+ _limits(self.conn, SCAN_TIMEOUT_MS)
282
+ row = self.conn.execute(query).fetchone()
283
+ return int(row[0]) if row else 0
284
+
285
+ def pending_count(self, exclude: Sequence[str] = ()) -> int:
286
+ lay = self.layout
287
+ query = sql.SQL("SELECT count(*) FROM {table} WHERE {pending}").format(
288
+ table=lay.qualified, pending=self._pending_filter()
289
+ )
290
+ params: tuple[Any, ...] = ()
291
+ if exclude:
292
+ query += sql.SQL(" AND NOT ({pk}::text = ANY(%s))").format(pk=sql.Identifier(lay.pk))
293
+ params = (list(exclude),)
294
+ with self.conn.transaction():
295
+ _limits(self.conn, SCAN_TIMEOUT_MS)
296
+ row = self.conn.execute(query, params).fetchone()
297
+ return int(row[0]) if row else 0
298
+
299
+ def fetch(self, after: Any, limit: int, exclude: Sequence[str] = ()) -> list[Row]:
300
+ """The next rows that need a vector, in primary-key order after ``after``."""
301
+ lay = self.layout
302
+ pk = sql.Identifier(lay.pk)
303
+ query = sql.SQL("SELECT {pk}, {text}::text FROM {table} WHERE {pending}").format(
304
+ pk=pk,
305
+ text=sql.Identifier(lay.text),
306
+ table=lay.qualified,
307
+ pending=self._pending_filter(),
308
+ )
309
+ params: list[Any] = []
310
+ if after is not None:
311
+ query += sql.SQL(" AND {pk} > %s").format(pk=pk)
312
+ params.append(after)
313
+ if exclude:
314
+ query += sql.SQL(" AND NOT ({pk}::text = ANY(%s))").format(pk=pk)
315
+ params.append(list(exclude))
316
+ query += sql.SQL(" ORDER BY {pk} LIMIT %s").format(pk=pk)
317
+ params.append(limit)
318
+ with self.conn.transaction():
319
+ _limits(self.conn, STATEMENT_TIMEOUT_MS)
320
+ rows = self.conn.execute(query, params).fetchall()
321
+ return [Row(key, text) for key, text in rows]
322
+
323
+ def write(self, items: Sequence[tuple[Row, Sequence[float]]]) -> int:
324
+ """Store vectors, but only where the row still needs one and its text is unchanged.
325
+
326
+ Returns how many rows were written. A row whose text changed after it was read is
327
+ left empty, so a later pass embeds the new text.
328
+ """
329
+ if not items:
330
+ return 0
331
+ lay = self.layout
332
+ statement = sql.SQL(
333
+ "UPDATE {table} SET {target} = %s::{type} "
334
+ "WHERE {pk} = %s AND {target} IS NULL AND {text}::text = %s"
335
+ ).format(
336
+ table=lay.qualified,
337
+ target=sql.Identifier(lay.target),
338
+ type=self._type(),
339
+ pk=sql.Identifier(lay.pk),
340
+ text=sql.Identifier(lay.text),
341
+ )
342
+ params = [
343
+ ("[" + ",".join(repr(float(x)) for x in vector) + "]", row.key, row.text)
344
+ for row, vector in items
345
+ ]
346
+ written = 0
347
+ delay = 1.0
348
+ for attempt in range(1, self.lock_retries + 1):
349
+ try:
350
+ with self.conn.transaction(), self.conn.cursor() as cur:
351
+ _limits(cur, STATEMENT_TIMEOUT_MS, self.lock_timeout_ms)
352
+ written = 0
353
+ for p in params:
354
+ cur.execute(statement, p)
355
+ written += cur.rowcount
356
+ return written
357
+ except errors.LockNotAvailable as exc:
358
+ if attempt == self.lock_retries:
359
+ raise Busy(
360
+ "Couldn't write vectors: rows stayed locked by other sessions."
361
+ ) from exc
362
+ self._sleep(delay)
363
+ delay = min(delay * 2, 30)
364
+ return written # pragma: no cover
365
+
366
+ # --- index
367
+
368
+ def index_state(self, method: str) -> str | None:
369
+ """``valid``, ``invalid`` (a failed concurrent build), or ``None`` when absent."""
370
+ row = self.conn.execute(
371
+ """
372
+ SELECT i.indisvalid FROM pg_index i
373
+ JOIN pg_class c ON c.oid = i.indexrelid
374
+ JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = i.indkey[0]
375
+ WHERE i.indrelid = %s::regclass AND c.relname = %s AND a.attname = %s
376
+ """,
377
+ (self._regclass(), self.layout.index_name(method), self.layout.target),
378
+ ).fetchone()
379
+ if row is None:
380
+ return None
381
+ return "valid" if row[0] else "invalid"
382
+
383
+ def build_index(self, method: str, metric: str, rows: int) -> str:
384
+ """Build the vector index concurrently, so writes continue. Returns what happened."""
385
+ if method not in {"hnsw", "ivfflat"} or metric not in OPCLASS_SUFFIX:
386
+ raise ValueError(f"unsupported index {method!r} / {metric!r}")
387
+ lay = self.layout
388
+ name = sql.Identifier(lay.schema, lay.index_name(method))
389
+ state = self.index_state(method)
390
+ if state == "valid":
391
+ return "exists"
392
+ # Concurrent builds can't run in a transaction and can take a long time, so the
393
+ # session's timeouts are lifted for these statements only.
394
+ self.conn.execute("SET statement_timeout = 0")
395
+ self.conn.execute("SET lock_timeout = 0")
396
+ try:
397
+ if state == "invalid":
398
+ self.conn.execute(sql.SQL("DROP INDEX CONCURRENTLY IF EXISTS {}").format(name))
399
+ statement = sql.SQL(
400
+ "CREATE INDEX CONCURRENTLY {name} ON {table} USING {method} ({col} {ops})"
401
+ ).format(
402
+ name=sql.Identifier(lay.index_name(method)),
403
+ table=lay.qualified,
404
+ method=sql.SQL(method),
405
+ col=sql.Identifier(lay.target),
406
+ ops=sql.Identifier(
407
+ lay.extension_schema, f"{lay.vector_type}_{OPCLASS_SUFFIX[metric]}"
408
+ ),
409
+ )
410
+ if method == "ivfflat":
411
+ lists = max(1, rows // 1000 if rows <= 1_000_000 else int(rows**0.5))
412
+ statement += sql.SQL(" WITH (lists = {})").format(sql.Literal(lists))
413
+ self.conn.execute(statement)
414
+ finally:
415
+ self.conn.execute("RESET statement_timeout")
416
+ self.conn.execute("RESET lock_timeout")
417
+ return "rebuilt" if state == "invalid" else "built"
418
+
419
+ # --- verification
420
+
421
+ def dimensions_in_use(self) -> set[int]:
422
+ lay = self.layout
423
+ query = sql.SQL(
424
+ "SELECT DISTINCT {fn}({target}) FROM (SELECT {target} FROM {table} "
425
+ "WHERE {target} IS NOT NULL LIMIT 1000) s"
426
+ ).format(
427
+ fn=sql.Identifier(lay.extension_schema, "vector_dims"),
428
+ target=sql.Identifier(lay.target),
429
+ table=lay.qualified,
430
+ )
431
+ return {int(r[0]) for r in self.conn.execute(query).fetchall()}
@@ -0,0 +1,4 @@
1
+ """Core engine types.
2
+
3
+ Nothing under ``vecshift.core`` may import from the CLI, API, or UI layers.
4
+ """
@@ -0,0 +1,33 @@
1
+ """Capability flags connectors declare, so the planner can choose a strategy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import StrEnum
6
+
7
+
8
+ class Capability(StrEnum):
9
+ """Something a connector can do.
10
+
11
+ The planner never assumes a capability a connector hasn't declared.
12
+ """
13
+
14
+ ALIAS = "alias"
15
+ """Atomic cutover by repointing an alias (OpenSearch, Elasticsearch, Qdrant)."""
16
+
17
+ NAMED_VECTORS = "named_vectors"
18
+ """Several vectors per record side by side (Qdrant)."""
19
+
20
+ CHANGE_FEED = "change_feed"
21
+ """Stream inserts, updates, and deletes (Postgres logical replication)."""
22
+
23
+ SCROLL = "scroll"
24
+ """Resumable full scans with a cursor."""
25
+
26
+ SPARSE = "sparse"
27
+ """Sparse vectors, for hybrid search."""
28
+
29
+ DELETE_DETECTION = "delete_detection"
30
+ """Report deletes directly rather than by diffing."""
31
+
32
+ STORES_TEXT = "stores_text"
33
+ """Original text is kept alongside vectors, so records can be re-embedded."""
@@ -0,0 +1,57 @@
1
+ """Plugin contracts. Third-party connectors and providers implement these.
2
+
3
+ Plugins register under the ``vecshift.plugins`` entry-point group.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from collections.abc import AsyncIterator, Sequence
9
+ from typing import Literal, Protocol, runtime_checkable
10
+
11
+ from vecshift.core.capabilities import Capability
12
+ from vecshift.core.fingerprint import EmbeddingFingerprint
13
+ from vecshift.core.record import Record
14
+
15
+ EmbedMode = Literal["document", "query"]
16
+
17
+
18
+ @runtime_checkable
19
+ class SourceConnector(Protocol):
20
+ """Reads records from a store."""
21
+
22
+ def capabilities(self) -> frozenset[Capability]: ...
23
+
24
+ async def count(self) -> int | None:
25
+ """Total records, or ``None`` if the store can't count cheaply."""
26
+ ...
27
+
28
+ def read(
29
+ self, batch_size: int, cursor: str | None = None
30
+ ) -> AsyncIterator[tuple[Sequence[Record], str | None]]:
31
+ """Yield batches with the cursor to resume after each one."""
32
+ ...
33
+
34
+
35
+ @runtime_checkable
36
+ class TargetConnector(Protocol):
37
+ """Writes records to a store."""
38
+
39
+ def capabilities(self) -> frozenset[Capability]: ...
40
+
41
+ async def upsert(self, records: Sequence[Record]) -> None:
42
+ """Idempotently write records, honouring tombstones and ``updated_at``."""
43
+ ...
44
+
45
+ async def count(self) -> int | None: ...
46
+
47
+
48
+ @runtime_checkable
49
+ class EmbeddingProvider(Protocol):
50
+ """Turns text into vectors."""
51
+
52
+ @property
53
+ def fingerprint(self) -> EmbeddingFingerprint: ...
54
+
55
+ async def embed(
56
+ self, texts: Sequence[str], mode: EmbedMode = "document"
57
+ ) -> list[list[float]]: ...
@@ -0,0 +1,57 @@
1
+ """Fingerprints that identify which vector space a vector belongs to.
2
+
3
+ Two vectors are only comparable if they were produced by the same model,
4
+ with the same dimensions, task mode, prefix, and normalization. A change to
5
+ any of those is a different vector space, and mixing spaces in one index
6
+ silently degrades retrieval. The fingerprint makes that provenance explicit.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ from dataclasses import asdict, dataclass
14
+
15
+ _HASH_LENGTH = 12
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class EmbeddingFingerprint:
20
+ """Everything that determines an embedding's vector space."""
21
+
22
+ provider: str
23
+ model: str
24
+ dimensions: int
25
+ version: str | None = None
26
+ task: str | None = None
27
+ prefix: str | None = None
28
+ normalized: bool = True
29
+
30
+ def __post_init__(self) -> None:
31
+ if not self.provider.strip():
32
+ raise ValueError("provider must not be empty")
33
+ if not self.model.strip():
34
+ raise ValueError("model must not be empty")
35
+ if self.dimensions <= 0:
36
+ raise ValueError(f"dimensions must be positive, got {self.dimensions}")
37
+
38
+ @property
39
+ def digest(self) -> str:
40
+ """A stable hash over every field, insensitive to provider/model casing."""
41
+ fields = asdict(self)
42
+ fields["provider"] = self.provider.strip().lower()
43
+ fields["model"] = self.model.strip().lower()
44
+ canonical = json.dumps(fields, sort_keys=True, separators=(",", ":"))
45
+ return hashlib.sha256(canonical.encode()).hexdigest()[:_HASH_LENGTH]
46
+
47
+ @property
48
+ def model_tag(self) -> str:
49
+ """A readable, unique tag such as ``openai/text-embedding-3-small@1536#a1b2c3d4e5f6``."""
50
+ return (
51
+ f"{self.provider.strip().lower()}/{self.model.strip().lower()}"
52
+ f"@{self.dimensions}#{self.digest}"
53
+ )
54
+
55
+ def is_compatible_with(self, other: EmbeddingFingerprint) -> bool:
56
+ """Whether vectors from ``self`` and ``other`` share one vector space."""
57
+ return self.digest == other.digest
@@ -0,0 +1,75 @@
1
+ """The canonical record every connector reads into and writes from.
2
+
3
+ Connectors convert between their store's native format and :class:`Record`,
4
+ so supporting N stores needs N connectors rather than N x N pairwise bridges.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from datetime import datetime
11
+ from typing import Any
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class SparseVector:
16
+ """A sparse vector as parallel index/value arrays."""
17
+
18
+ indices: tuple[int, ...]
19
+ values: tuple[float, ...]
20
+
21
+ def __post_init__(self) -> None:
22
+ if len(self.indices) != len(self.values):
23
+ raise ValueError(
24
+ f"indices and values must have equal length "
25
+ f"({len(self.indices)} != {len(self.values)})"
26
+ )
27
+
28
+
29
+ @dataclass(slots=True)
30
+ class Record:
31
+ """One document or chunk moving through a migration.
32
+
33
+ Attributes:
34
+ id: Stable identifier from the source store.
35
+ text: Original text. Required for re-embedding; ``None`` when the
36
+ store holds vectors only.
37
+ vectors: Dense vectors keyed by name (``"default"`` for single-vector stores).
38
+ sparse: Sparse vectors keyed by name, for hybrid search.
39
+ metadata: Arbitrary payload carried across unchanged unless remapped.
40
+ updated_at: Last-modified time, used to stop backfill from overwriting
41
+ newer writes that arrived through a change feed.
42
+ model_tag: Fingerprint of the vector space the vectors belong to.
43
+ deleted: Tombstone marker for deletes observed through a change feed.
44
+ """
45
+
46
+ id: str
47
+ text: str | None = None
48
+ vectors: dict[str, list[float]] = field(default_factory=dict)
49
+ sparse: dict[str, SparseVector] = field(default_factory=dict)
50
+ metadata: dict[str, Any] = field(default_factory=dict)
51
+ updated_at: datetime | None = None
52
+ model_tag: str | None = None
53
+ deleted: bool = False
54
+
55
+ @property
56
+ def can_reembed(self) -> bool:
57
+ """Whether this record carries the text needed to embed it again."""
58
+ return bool(self.text)
59
+
60
+ @classmethod
61
+ def tombstone(cls, id: str, updated_at: datetime | None = None) -> Record:
62
+ """A delete marker for ``id``."""
63
+ return cls(id=id, updated_at=updated_at, deleted=True)
64
+
65
+ def is_newer_than(self, other: Record) -> bool:
66
+ """Whether this record should win an upsert race against ``other``.
67
+
68
+ A record without ``updated_at`` never beats one that has it, so a
69
+ timestamp-less backfill can't overwrite a timestamped live write.
70
+ """
71
+ if self.updated_at is None:
72
+ return False
73
+ if other.updated_at is None:
74
+ return True
75
+ return self.updated_at > other.updated_at
@@ -0,0 +1,15 @@
1
+ """``vecshift doctor``: read-only diagnosis of an existing vector index."""
2
+
3
+ from vecshift.doctor.checks import run_checks
4
+ from vecshift.doctor.findings import Finding, Report, Severity
5
+ from vecshift.doctor.profile import AnnIndex, IndexProfile, Sample
6
+
7
+ __all__ = [
8
+ "AnnIndex",
9
+ "Finding",
10
+ "IndexProfile",
11
+ "Report",
12
+ "Sample",
13
+ "Severity",
14
+ "run_checks",
15
+ ]