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/jobs/spec.py ADDED
@@ -0,0 +1,202 @@
1
+ """The job spec, usually ``vecshift.yaml``.
2
+
3
+ One file describes a migration: where the vectors are, where the new ones go, which model
4
+ makes them, and the limits to respect. The CLI reads it today; the API and UI will read and
5
+ write the same file. It never holds credentials: the database connection string comes
6
+ from an environment variable the file names.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+ from enum import StrEnum
14
+ from pathlib import Path
15
+ from typing import Literal
16
+
17
+ import yaml
18
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
19
+
20
+ from vecshift.embeddings.spec import SpecError, parse_spec
21
+
22
+ _IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_$]*$")
23
+
24
+
25
+ class JobError(ValueError):
26
+ """The job file couldn't be read or isn't valid."""
27
+
28
+
29
+ class _Strict(BaseModel):
30
+ model_config = ConfigDict(extra="forbid", frozen=True)
31
+
32
+
33
+ def _identifier(value: str | None, what: str) -> str | None:
34
+ if value is not None and not _IDENT.match(value):
35
+ raise ValueError(f"{what} must be a plain identifier, got {value!r}")
36
+ return value
37
+
38
+
39
+ class Source(_Strict):
40
+ type: Literal["pgvector"] = "pgvector"
41
+ dsn_env: str = "VECSHIFT_DSN"
42
+ """Environment variable holding the connection string. The file never stores it."""
43
+ table: str
44
+ """``table`` or ``schema.table``."""
45
+ vector_column: str | None = None
46
+ text_column: str | None = None
47
+ model: str | None = None
48
+ """The model that made the current vectors, so ``eval`` can embed queries for them."""
49
+
50
+ @field_validator("table")
51
+ @classmethod
52
+ def _table(cls, value: str) -> str:
53
+ for part in value.split("."):
54
+ _identifier(part, "table")
55
+ if value.count(".") > 1:
56
+ raise ValueError("table must be table or schema.table")
57
+ return value
58
+
59
+ @field_validator("vector_column", "text_column")
60
+ @classmethod
61
+ def _columns(cls, value: str | None) -> str | None:
62
+ return _identifier(value, "column")
63
+
64
+ @field_validator("model")
65
+ @classmethod
66
+ def _old_model(cls, value: str | None) -> str | None:
67
+ if value is not None:
68
+ try:
69
+ parse_spec(value)
70
+ except SpecError as exc:
71
+ raise ValueError(str(exc)) from None
72
+ return value
73
+
74
+
75
+ class VectorType(StrEnum):
76
+ VECTOR = "vector"
77
+ HALFVEC = "halfvec"
78
+
79
+
80
+ class IndexMethod(StrEnum):
81
+ HNSW = "hnsw"
82
+ IVFFLAT = "ivfflat"
83
+ NONE = "none"
84
+
85
+
86
+ class Metric(StrEnum):
87
+ COSINE = "cosine"
88
+ INNER_PRODUCT = "inner_product"
89
+ L2 = "l2"
90
+
91
+
92
+ class Target(_Strict):
93
+ column: str = "embedding_v2"
94
+ """New column on the source table. Cutover swaps it with the old one by renaming."""
95
+ vector_type: VectorType = VectorType.VECTOR
96
+ index: IndexMethod = IndexMethod.HNSW
97
+ metric: Metric | None = None
98
+ """Distance for the index. Defaults to the existing index's metric, or cosine."""
99
+
100
+ @field_validator("column")
101
+ @classmethod
102
+ def _column(cls, value: str) -> str:
103
+ _identifier(value, "column")
104
+ return value
105
+
106
+
107
+ class Limits(_Strict):
108
+ budget_usd: float | None = Field(default=None, gt=0)
109
+ """Stop before spending more than this. Plan fails when the estimate exceeds it."""
110
+ tokens_per_minute: int | None = Field(default=None, gt=0)
111
+ """The provider's rate limit, used to estimate duration."""
112
+ requests_per_minute: int | None = Field(default=None, gt=0)
113
+
114
+
115
+ class JobSpec(_Strict):
116
+ version: Literal[1] = 1
117
+ name: str = "migration"
118
+ source: Source
119
+ target: Target = Target()
120
+ model: str
121
+ """A model spec, as for ``vecshift bench``: ``provider/model[,option=value]``."""
122
+ limits: Limits = Limits()
123
+
124
+ @field_validator("target", "limits", mode="before")
125
+ @classmethod
126
+ def _empty_section(cls, value: object) -> object:
127
+ # A section whose settings are all commented out reads as null: use the defaults.
128
+ return {} if value is None else value
129
+
130
+ @field_validator("model")
131
+ @classmethod
132
+ def _model(cls, value: str) -> str:
133
+ try:
134
+ parse_spec(value)
135
+ except SpecError as exc:
136
+ raise ValueError(str(exc)) from None
137
+ return value
138
+
139
+
140
+ def format_errors(error: ValidationError) -> str:
141
+ lines = []
142
+ for item in error.errors():
143
+ where = ".".join(str(p) for p in item["loc"]) or "file"
144
+ message = item["msg"].removeprefix("Value error, ")
145
+ if item["type"] == "extra_forbidden":
146
+ message = "unknown setting"
147
+ elif item["type"] == "missing":
148
+ message = "required"
149
+ lines.append(f" {where}: {message}")
150
+ return "\n".join(lines)
151
+
152
+
153
+ def load_job(path: Path) -> JobSpec:
154
+ try:
155
+ raw = yaml.safe_load(path.read_text(encoding="utf-8"))
156
+ except OSError as exc:
157
+ raise JobError(f"Couldn't read {path}: {exc.strerror}") from exc
158
+ except yaml.YAMLError as exc:
159
+ mark = getattr(exc, "problem_mark", None)
160
+ where = f" at line {mark.line + 1}" if mark else ""
161
+ raise JobError(f"{path} isn't valid YAML{where}") from exc
162
+ if not isinstance(raw, dict):
163
+ raise JobError(f"{path} should contain settings, starting with source: and model:")
164
+ try:
165
+ return JobSpec.model_validate(raw)
166
+ except ValidationError as exc:
167
+ raise JobError(f"{path} has problems:\n{format_errors(exc)}") from exc
168
+
169
+
170
+ def template(table: str, model: str, column: str = "embedding_v2") -> str:
171
+ """A commented job file to start from. Values are quoted, so any input stays valid YAML."""
172
+ name = json.dumps(table.split(".")[-1] + "-reembed")
173
+ return f"""# vecshift job: re-embed a pgvector column with a new model, side by side.
174
+ # Check it with `vecshift plan`. Nothing is changed until `vecshift apply`.
175
+ version: 1
176
+ name: {name}
177
+
178
+ source:
179
+ type: pgvector
180
+ # The connection string is read from this environment variable, never from this file.
181
+ dsn_env: VECSHIFT_DSN
182
+ table: {json.dumps(table)}
183
+ # vector_column: embedding # needed only if the table has several
184
+ # text_column: content # detected automatically when it's a common name
185
+ # model: openai/text-embedding-3-small # the model that made the current vectors, for eval
186
+
187
+ target:
188
+ # New vectors go in this column next to the old ones. Cutover renames the two columns
189
+ # in one transaction, and rollback renames them back.
190
+ column: {json.dumps(column)}
191
+ vector_type: vector # or halfvec: half the storage, indexable up to 4,000 dims
192
+ index: hnsw # hnsw, ivfflat, or none
193
+ # metric: cosine # defaults to the current index's metric, or cosine
194
+
195
+ # The new model, written as for `vecshift bench`, e.g. openai/text-embedding-3-large,dims=1024
196
+ model: {json.dumps(model)}
197
+
198
+ limits:
199
+ # budget_usd: 50 # plan fails if the estimate is higher; apply stops there
200
+ # tokens_per_minute: 1000000 # your provider rate limit, for the duration estimate
201
+ # requests_per_minute: 3000
202
+ """
@@ -0,0 +1,6 @@
1
+ """Carrying out a migration: backfill, keep in sync, index, and verify."""
2
+
3
+ from vecshift.migrate.engine import ApplyResult, Event, apply
4
+ from vecshift.migrate.state import JobState
5
+
6
+ __all__ = ["ApplyResult", "Event", "JobState", "apply"]
@@ -0,0 +1,272 @@
1
+ """The apply loop: fill the new column, keep up with writes, then index and verify.
2
+
3
+ It talks to the database through a small writer interface and to the model through the
4
+ embedding contract, so it can be tested with fakes and reused for other stores.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ import time
11
+ from collections.abc import Callable, Sequence
12
+ from dataclasses import dataclass, field
13
+ from typing import Any, Literal, Protocol
14
+
15
+ from vecshift.embeddings.providers import EmbeddingError
16
+ from vecshift.migrate.state import JobState
17
+
18
+ CHARS_PER_TOKEN = 4
19
+ MAX_PASSES = 5
20
+ SETTLED_ROWS = 100
21
+ SETTLED_SHARE = 0.001
22
+ """On a busy table some rows always change while a pass runs. Once no more than this many
23
+ remain (at least ``SETTLED_ROWS``), apply builds the index and finishes; the stragglers get
24
+ their vector on the next run, and cutover embeds them before it switches."""
25
+ """Catch-up passes after the first, for rows that changed or arrived during the run."""
26
+ MAX_CHARS = 24_000
27
+ """Longer texts are cut to this length, inside common model input limits (about 6K tokens)."""
28
+
29
+ Status = Literal["complete", "stopped", "budget", "failed"]
30
+
31
+
32
+ class Row(Protocol):
33
+ @property
34
+ def key(self) -> Any: ...
35
+ @property
36
+ def text(self) -> str: ...
37
+ @property
38
+ def id(self) -> str: ...
39
+
40
+
41
+ class Writer(Protocol):
42
+ def acquire(self) -> None: ...
43
+ def release(self) -> None: ...
44
+ def ensure_column(self) -> bool: ...
45
+ def ensure_trigger(self) -> bool: ...
46
+ def total_count(self) -> int: ...
47
+ def pending_count(self, exclude: Sequence[str] = ()) -> int: ...
48
+ def fetch(self, after: Any, limit: int, exclude: Sequence[str] = ()) -> Sequence[Row]: ...
49
+ def write(self, items: Sequence[tuple[Any, Sequence[float]]]) -> int: ...
50
+ def build_index(self, method: str, metric: str, rows: int) -> str: ...
51
+ def dimensions_in_use(self) -> set[int]: ...
52
+
53
+
54
+ class Embedder(Protocol):
55
+ tokens: int
56
+
57
+ async def embed(
58
+ self, texts: Sequence[str], mode: Literal["document", "query"] = ...
59
+ ) -> list[list[float]]: ...
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class Event:
64
+ kind: str
65
+ """``start``, ``column``, ``trigger``, ``batch``, ``pass``, ``index``, or ``done``."""
66
+ data: dict[str, Any] = field(default_factory=dict)
67
+
68
+
69
+ @dataclass(slots=True)
70
+ class ApplyResult:
71
+ status: Status
72
+ rows_written: int = 0
73
+ rows_failed: int = 0
74
+ remaining: int = 0
75
+ tokens: int = 0
76
+ spent_usd: float | None = 0.0
77
+ """This run's spend, or ``None`` when the model has no price."""
78
+ total_spent_usd: float | None = 0.0
79
+ seconds: float = 0.0
80
+ index: str = "pending"
81
+ """``built``, ``rebuilt``, ``exists``, ``skipped``, or ``pending``."""
82
+ message: str | None = None
83
+
84
+
85
+ class DimensionMismatch(Exception):
86
+ pass
87
+
88
+
89
+ async def _embed_isolating(
90
+ embedder: Embedder, rows: Sequence[Row], dims: int
91
+ ) -> tuple[list[tuple[Row, list[float]]], dict[str, str]]:
92
+ """Embed rows; if the provider rejects a request, split it to find the bad rows."""
93
+ texts = [r.text[:MAX_CHARS] for r in rows]
94
+ try:
95
+ vectors = await embedder.embed(texts, "document")
96
+ except EmbeddingError as exc:
97
+ if len(rows) == 1:
98
+ return [], {rows[0].id: str(exc)}
99
+ mid = len(rows) // 2
100
+ left, lf = await _embed_isolating(embedder, rows[:mid], dims)
101
+ right, rf = await _embed_isolating(embedder, rows[mid:], dims)
102
+ return left + right, {**lf, **rf}
103
+ for vector in vectors:
104
+ if len(vector) != dims:
105
+ raise DimensionMismatch(
106
+ f"The model returned {len(vector)} dimensions, but the column holds {dims}."
107
+ )
108
+ return list(zip(rows, vectors, strict=True)), {}
109
+
110
+
111
+ async def apply(
112
+ writer: Writer,
113
+ embedder: Embedder,
114
+ state: JobState,
115
+ *,
116
+ dims: int,
117
+ price_per_million: float | None,
118
+ budget_usd: float | None,
119
+ chunk_rows: int,
120
+ index: tuple[str, str] | None,
121
+ on_event: Callable[[Event], None] = lambda e: None,
122
+ should_stop: Callable[[], bool] = lambda: False,
123
+ until: float | None = None,
124
+ ) -> ApplyResult:
125
+ """Run (or resume) a migration. Safe to run again at any time to catch up.
126
+
127
+ ``until`` (a fraction, such as 0.5) stops once that share of the rows with text has a
128
+ new vector, so a migration can be checked part way and continued later.
129
+ """
130
+ started = time.monotonic()
131
+ result = ApplyResult(status="complete")
132
+ tokens_before = embedder.tokens
133
+ spent_before = state.spent_usd
134
+ state.runs += 1
135
+
136
+ def spent() -> float:
137
+ if price_per_million is None:
138
+ return 0.0
139
+ return (embedder.tokens - tokens_before) * price_per_million / 1_000_000
140
+
141
+ def finish(status: Status, message: str | None = None) -> ApplyResult:
142
+ result.status = status
143
+ result.message = message
144
+ result.tokens = embedder.tokens - tokens_before
145
+ run_spend = spent()
146
+ result.spent_usd = run_spend if price_per_million is not None else None
147
+ state.spent_usd = spent_before + run_spend
148
+ state.tokens += result.tokens
149
+ state.save()
150
+ result.total_spent_usd = state.spent_usd if price_per_million is not None else None
151
+ result.rows_failed = len(state.failed)
152
+ result.seconds = time.monotonic() - started
153
+ on_event(Event("done", {"status": status, "message": message}))
154
+ return result
155
+
156
+ writer.acquire()
157
+ try:
158
+ # The column has to exist before rows can be counted against it.
159
+ on_event(Event("column", {"added": writer.ensure_column()}))
160
+ on_event(Event("trigger", {"added": writer.ensure_trigger()}))
161
+ pending = writer.pending_count(tuple(state.failed))
162
+ quota: int | None = None
163
+ if until is not None:
164
+ total = writer.total_count()
165
+ done = total - writer.pending_count()
166
+ quota = max(0, math.ceil(total * until) - done)
167
+ pending = min(pending, quota)
168
+ on_event(Event("start", {"pending": pending, "spent_before": spent_before}))
169
+ settled = max(SETTLED_ROWS, math.ceil(SETTLED_SHARE * max(pending, state.rows_written)))
170
+
171
+ backlog = pending
172
+ for number in range(1, MAX_PASSES + 2):
173
+ after: Any = None
174
+ progress = 0
175
+ # A pass covers what was pending when it began; rows that arrive meanwhile wait
176
+ # for the next pass, so a pass ends even if inserts outpace embedding.
177
+ budget = backlog + chunk_rows
178
+ fetched = 0
179
+ while fetched < budget:
180
+ if should_stop():
181
+ result.remaining = writer.pending_count(tuple(state.failed))
182
+ return finish("stopped", "Stopped. Run apply again to continue.")
183
+ limit = chunk_rows
184
+ if quota is not None and result.rows_written >= quota:
185
+ result.remaining = writer.pending_count(tuple(state.failed))
186
+ if result.remaining:
187
+ return finish(
188
+ "stopped",
189
+ f"Reached {until or 0:.0%} of rows. Check the new vectors, then "
190
+ "run apply again to continue.",
191
+ )
192
+ break
193
+ if quota is not None:
194
+ limit = min(chunk_rows, quota - result.rows_written)
195
+ rows = writer.fetch(after, limit, tuple(state.failed))
196
+ if not rows:
197
+ break
198
+ fetched += len(rows)
199
+ after = rows[-1].key
200
+ if budget_usd is not None and price_per_million is not None:
201
+ estimate = sum(len(r.text[:MAX_CHARS]) for r in rows) / CHARS_PER_TOKEN
202
+ projected = spent_before + spent() + estimate * price_per_million / 1e6
203
+ if projected > budget_usd:
204
+ result.remaining = writer.pending_count(tuple(state.failed))
205
+ return finish(
206
+ "budget",
207
+ f"Stopped before the next batch would pass the ${budget_usd:,.2f} "
208
+ "budget. Raise limits.budget_usd and run apply again to continue.",
209
+ )
210
+ try:
211
+ pairs, failed = await _embed_isolating(embedder, rows, dims)
212
+ except DimensionMismatch as exc:
213
+ return finish("failed", str(exc))
214
+ except EmbeddingError as exc: # pragma: no cover - isolation catches these
215
+ return finish("failed", str(exc))
216
+ state.failed.update(failed)
217
+ written = writer.write(pairs)
218
+ progress += written
219
+ result.rows_written += written
220
+ state.rows_written += written
221
+ state.spent_usd = spent_before + spent()
222
+ state.save()
223
+ on_event(
224
+ Event(
225
+ "batch",
226
+ {
227
+ "pass": number,
228
+ "written": written,
229
+ "skipped": len(pairs) - written,
230
+ "failed": len(failed),
231
+ "rows_written": result.rows_written,
232
+ "spent_usd": state.spent_usd if price_per_million is not None else None,
233
+ },
234
+ )
235
+ )
236
+ remaining = writer.pending_count(tuple(state.failed))
237
+ on_event(Event("pass", {"number": number, "remaining": remaining}))
238
+ result.remaining = backlog = remaining
239
+ if remaining == 0 or progress == 0:
240
+ break
241
+
242
+ if result.remaining > settled:
243
+ return finish(
244
+ "stopped",
245
+ f"{result.remaining:,} rows kept changing during the run, faster than they "
246
+ "could be embedded. Run apply again to catch up.",
247
+ )
248
+ if index is None:
249
+ result.index = "skipped"
250
+ else:
251
+ on_event(Event("index", {"state": "building", "method": index[0]}))
252
+ result.index = writer.build_index(index[0], index[1], state.rows_written)
253
+ on_event(Event("index", {"state": result.index}))
254
+ # A big index takes minutes, and a busy table keeps changing meanwhile.
255
+ result.remaining = writer.pending_count(tuple(state.failed))
256
+ sizes = writer.dimensions_in_use()
257
+ if sizes and sizes != {dims}:
258
+ return finish("failed", f"Found vectors of sizes {sorted(sizes)} in the new column.")
259
+ failed_note = (
260
+ f" {len(state.failed):,} rows were rejected by the provider; see the state file."
261
+ if state.failed
262
+ else ""
263
+ )
264
+ if result.remaining:
265
+ return finish(
266
+ "complete",
267
+ f"Every row has a new vector except {result.remaining:,} that changed during "
268
+ f"the run; cutover (or the next apply) embeds them.{failed_note}",
269
+ )
270
+ return finish("complete", f"Every row with text has a new vector.{failed_note}")
271
+ finally:
272
+ writer.release()
@@ -0,0 +1,50 @@
1
+ """Progress that has to survive between runs: spend so far and failed rows.
2
+
3
+ The database itself records which rows are done (their new vector is filled in), so this
4
+ file only holds what the database can't: money spent across runs, for the budget, and rows
5
+ the provider rejected. It lives in ``.vecshift/`` next to the job file, readable by its owner
6
+ only, and never holds document text.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ from dataclasses import asdict, dataclass, field
14
+ from datetime import UTC, datetime
15
+ from pathlib import Path
16
+
17
+
18
+ @dataclass(slots=True)
19
+ class JobState:
20
+ path: Path
21
+ spent_usd: float = 0.0
22
+ tokens: int = 0
23
+ rows_written: int = 0
24
+ runs: int = 0
25
+ failed: dict[str, str] = field(default_factory=dict)
26
+ """Row ID → error, for rows the provider rejected. Text is never stored."""
27
+ history: list[dict[str, str]] = field(default_factory=list)
28
+ """Cutovers and rollbacks: what happened, when, and to which columns."""
29
+ updated_at: str | None = None
30
+
31
+ @classmethod
32
+ def for_job(cls, job_file: Path, name: str) -> JobState:
33
+ safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in name) or "job"
34
+ path = job_file.resolve().parent / ".vecshift" / f"{safe}.state.json"
35
+ if not path.exists():
36
+ return cls(path=path)
37
+ data = json.loads(path.read_text(encoding="utf-8"))
38
+ data.pop("path", None)
39
+ return cls(path=path, **data)
40
+
41
+ def save(self) -> None:
42
+ self.updated_at = datetime.now(UTC).isoformat(timespec="seconds")
43
+ self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
44
+ data = {k: v for k, v in asdict(self).items() if k != "path"}
45
+ tmp = self.path.with_suffix(".tmp")
46
+ # Write then rename, so a crash never leaves a half-written state file.
47
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
48
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
49
+ json.dump(data, fh, indent=2)
50
+ os.replace(tmp, self.path)
@@ -0,0 +1,14 @@
1
+ """``vecshift plan``: validate a migration job and estimate it before anything runs."""
2
+
3
+ from vecshift.planning.plan import Change, Estimates, Plan, ProbeResult
4
+ from vecshift.planning.planner import CHARS_PER_TOKEN, SampleStats, build_plan
5
+
6
+ __all__ = [
7
+ "CHARS_PER_TOKEN",
8
+ "Change",
9
+ "Estimates",
10
+ "Plan",
11
+ "ProbeResult",
12
+ "SampleStats",
13
+ "build_plan",
14
+ ]
@@ -0,0 +1,87 @@
1
+ """The result of planning a migration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass, field
6
+ from typing import Any
7
+
8
+ from vecshift.doctor.findings import Finding, Severity
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class Change:
13
+ kind: str
14
+ """``add_column``, ``embed``, ``index``, or ``cutover``."""
15
+ summary: str
16
+ sql: str | None = None
17
+ note: str | None = None
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class ProbeResult:
22
+ """Measured by embedding a few sample documents with the real model."""
23
+
24
+ documents: int
25
+ dimensions: int
26
+ tokens_per_char: float
27
+ docs_per_second: float
28
+
29
+
30
+ @dataclass(slots=True)
31
+ class Estimates:
32
+ rows: int | None = None
33
+ rows_exact: bool = False
34
+ tokens: int | None = None
35
+ tokens_method: str | None = None
36
+ cost_usd: float | None = None
37
+ requests: int | None = None
38
+ seconds: float | None = None
39
+ seconds_method: str | None = None
40
+ new_bytes: int | None = None
41
+ old_bytes: int | None = None
42
+ index_memory_bytes: int | None = None
43
+ maintenance_work_mem: int | None = None
44
+
45
+
46
+ @dataclass(slots=True)
47
+ class Plan:
48
+ job: str
49
+ source: str
50
+ target_column: str
51
+ model: str
52
+ dimensions: int | None
53
+ dimensions_source: str | None
54
+ """``spec``, ``known``, ``probe``, or ``None`` when unknown."""
55
+ vector_type: str
56
+ metric: str = "cosine"
57
+ """Distance for the new index: the job's, the current index's, or cosine."""
58
+ changes: list[Change] = field(default_factory=list)
59
+ estimates: Estimates = field(default_factory=Estimates)
60
+ findings: list[Finding] = field(default_factory=list)
61
+
62
+ @property
63
+ def errors(self) -> int:
64
+ return sum(1 for f in self.findings if f.severity is Severity.ERROR)
65
+
66
+ @property
67
+ def ok(self) -> bool:
68
+ return self.errors == 0
69
+
70
+ def sorted_findings(self) -> list[Finding]:
71
+ return sorted(self.findings, key=lambda f: -f.severity.rank)
72
+
73
+ def to_dict(self) -> dict[str, Any]:
74
+ return {
75
+ "job": self.job,
76
+ "ok": self.ok,
77
+ "source": self.source,
78
+ "target_column": self.target_column,
79
+ "model": self.model,
80
+ "dimensions": self.dimensions,
81
+ "dimensions_source": self.dimensions_source,
82
+ "vector_type": self.vector_type,
83
+ "metric": self.metric,
84
+ "changes": [asdict(c) for c in self.changes],
85
+ "estimates": asdict(self.estimates),
86
+ "findings": [f.to_dict() for f in self.sorted_findings()],
87
+ }