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,490 @@
1
+ """The diagnostic checks. Each takes an :class:`IndexProfile` and returns findings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import statistics
6
+ from collections.abc import Callable
7
+ from typing import Any
8
+
9
+ from vecshift.doctor.findings import Finding, Report, Severity
10
+ from vecshift.doctor.profile import IndexProfile
11
+
12
+ NORM_TOLERANCE = 0.01
13
+ """How far from 1.0 a vector's L2 norm may be and still count as normalized."""
14
+
15
+ ZERO_NORM = 1e-9
16
+ DUPLICATE_WARNING_RATIO = 0.01
17
+ UNINDEXED_ROWS_WARNING = 100_000
18
+ HISTOGRAM_BINS = 20
19
+
20
+ Check = Callable[[IndexProfile], list[Finding]]
21
+
22
+
23
+ def _pct(part: int, whole: int) -> str:
24
+ if whole == 0:
25
+ return "0%"
26
+ value = 100 * part / whole
27
+ return f"{value:.0f}%" if value >= 1 or value == 0 else "<1%"
28
+
29
+
30
+ def _verb(count: int, singular: str, plural: str) -> str:
31
+ return singular if count == 1 else plural
32
+
33
+
34
+ def _breakdown(counts: dict[int, int] | dict[str, int]) -> str:
35
+ items = sorted(counts.items(), key=lambda kv: -kv[1])
36
+ return ", ".join(f"{k} ({v:,})" for k, v in items)
37
+
38
+
39
+ def check_access(p: IndexProfile) -> list[Finding]:
40
+ if not p.rows_hidden_by_access_rules:
41
+ return []
42
+ return [
43
+ Finding(
44
+ "access.row_security",
45
+ Severity.WARNING,
46
+ "Row-level security may hide rows",
47
+ "Row-level security is on for this table and the connecting role doesn't bypass "
48
+ "it, so this report only covers the rows that role can see.",
49
+ hint="Connect as the table owner, or a role with BYPASSRLS (on Supabase, the "
50
+ "postgres user), for a complete picture.",
51
+ )
52
+ ]
53
+
54
+
55
+ def check_text(p: IndexProfile) -> list[Finding]:
56
+ if p.text_field is None:
57
+ return [
58
+ Finding(
59
+ "text.missing",
60
+ Severity.ERROR,
61
+ "No source text: this index can't be re-embedded from itself",
62
+ "No text column was found next to the vectors. Re-embedding with a new model "
63
+ "needs the original text.",
64
+ hint="If the text is in a column with an unusual name, pass --text-column. If "
65
+ "it lives elsewhere (another table, object storage), keep that source "
66
+ "available for the migration.",
67
+ )
68
+ ]
69
+ rows = p.sample.rows
70
+ present = p.sample.texts_present or 0
71
+ if present < rows:
72
+ missing = rows - present
73
+ return [
74
+ Finding(
75
+ "text.partial",
76
+ Severity.WARNING,
77
+ "Some rows have no text",
78
+ f"{missing:,} of {rows:,} sampled rows ({_pct(missing, rows)}) "
79
+ f"{_verb(missing, 'has', 'have')} empty or missing `{p.text_field}`, so "
80
+ f"{_verb(missing, 'it', 'they')} can't be re-embedded.",
81
+ )
82
+ ]
83
+ return [
84
+ Finding(
85
+ "text.ok",
86
+ Severity.OK,
87
+ "Source text available",
88
+ f"Every sampled row has text in `{p.text_field}`, so the index can be re-embedded.",
89
+ )
90
+ ]
91
+
92
+
93
+ def check_null_vectors(p: IndexProfile) -> list[Finding]:
94
+ s = p.sample
95
+ if s.null_vectors == 0:
96
+ return []
97
+ return [
98
+ Finding(
99
+ "vectors.null",
100
+ Severity.WARNING,
101
+ "Some rows have no vector",
102
+ f"{s.null_vectors:,} of {s.rows:,} sampled rows ({_pct(s.null_vectors, s.rows)}) "
103
+ f"{_verb(s.null_vectors, 'has', 'have')} no embedding, so search can't find "
104
+ f"{_verb(s.null_vectors, 'it', 'them')}.",
105
+ hint="These are often rows whose embedding failed or hasn't run yet.",
106
+ )
107
+ ]
108
+
109
+
110
+ def check_dimensions(p: IndexProfile) -> list[Finding]:
111
+ findings: list[Finding] = []
112
+ dims = p.sample.dimensions
113
+ if len(dims) > 1:
114
+ findings.append(
115
+ Finding(
116
+ "dims.mixed",
117
+ Severity.ERROR,
118
+ "Vectors of different sizes in one index",
119
+ f"Sampled vectors have {len(dims)} different sizes: {_breakdown(dict(dims))}. "
120
+ "They come from different models and can't be compared with each other.",
121
+ hint="Find which rows came from which model, and re-embed the minority "
122
+ "with the majority model.",
123
+ )
124
+ )
125
+ if p.declared_dimensions is None:
126
+ findings.append(
127
+ Finding(
128
+ "dims.undeclared",
129
+ Severity.WARNING,
130
+ "Vector size isn't fixed by the schema",
131
+ f"The field is declared as `{p.vector_type}` without a size, so nothing stops "
132
+ "vectors of different sizes being written to it, and it can't be given an ANN "
133
+ "index for fast search.",
134
+ hint=f"Declare the size, for example `{p.vector_type}(1536)`, once every "
135
+ "vector has the same size.",
136
+ )
137
+ )
138
+ elif len(dims) <= 1:
139
+ findings.append(
140
+ Finding(
141
+ "dims.ok",
142
+ Severity.OK,
143
+ "Consistent vector size",
144
+ f"The schema fixes vectors at {p.declared_dimensions:,} dimensions.",
145
+ )
146
+ )
147
+
148
+ size = p.declared_dimensions or (max(dims) if dims else None)
149
+ limit = p.max_indexable_dimensions
150
+ if size and limit and size > limit and not p.ann_indexes:
151
+ findings.append(
152
+ Finding(
153
+ "dims.too_large_to_index",
154
+ Severity.WARNING,
155
+ "Too many dimensions for an ANN index",
156
+ f"{p.vector_type} vectors with {size:,} dimensions are over the "
157
+ f"{limit:,}-dimension limit for an index, so every search is an exact scan.",
158
+ hint="Index a halfvec cast instead (up to 4,000 dimensions), or truncate a "
159
+ "Matryoshka-trained model to fewer dimensions.",
160
+ )
161
+ )
162
+ return findings
163
+
164
+
165
+ def check_norms(p: IndexProfile) -> list[Finding]:
166
+ norms = p.sample.norms
167
+ if not norms:
168
+ return []
169
+ findings: list[Finding] = []
170
+ zero = sum(1 for n in norms if n < ZERO_NORM)
171
+ if zero:
172
+ findings.append(
173
+ Finding(
174
+ "norms.zero",
175
+ Severity.ERROR,
176
+ "Zero vectors",
177
+ f"{zero:,} sampled {_verb(zero, 'vector is', 'vectors are')} all zeros. Cosine "
178
+ "distance is undefined for zero vectors, so they never rank sensibly.",
179
+ hint="Zero vectors usually mean a failed embedding call stored a placeholder. "
180
+ "Re-embed those rows.",
181
+ )
182
+ )
183
+
184
+ nonzero = [n for n in norms if n >= ZERO_NORM]
185
+ if not nonzero:
186
+ return findings
187
+ unit = sum(1 for n in nonzero if abs(n - 1.0) <= NORM_TOLERANCE)
188
+ metrics = {i.metric for i in p.ann_indexes}
189
+
190
+ if 0 < unit < len(nonzero):
191
+ findings.append(
192
+ Finding(
193
+ "norms.mixed",
194
+ Severity.WARNING,
195
+ "Mix of normalized and unnormalized vectors",
196
+ f"{unit:,} of {len(nonzero):,} sampled vectors are unit length and the rest "
197
+ "aren't. That usually means two different pipelines or models wrote to "
198
+ "this index.",
199
+ )
200
+ )
201
+ elif unit == len(nonzero):
202
+ findings.append(
203
+ Finding(
204
+ "norms.ok",
205
+ Severity.OK,
206
+ "Vectors are normalized",
207
+ "Every sampled vector has unit length, so cosine, inner product, and L2 "
208
+ "distance all rank results the same way.",
209
+ )
210
+ )
211
+ else:
212
+ lo, hi = min(nonzero), max(nonzero)
213
+ findings.append(
214
+ Finding(
215
+ "norms.unnormalized",
216
+ Severity.INFO,
217
+ "Vectors aren't normalized",
218
+ f"Sampled vector lengths range from {lo:.3g} to {hi:.3g}. That's fine with "
219
+ "cosine distance; inner product and L2 will favour longer vectors.",
220
+ )
221
+ )
222
+
223
+ if unit < len(nonzero) and "inner_product" in metrics:
224
+ findings.append(
225
+ Finding(
226
+ "norms.inner_product_unnormalized",
227
+ Severity.WARNING,
228
+ "Inner-product index over unnormalized vectors",
229
+ "An inner-product index ranks longer vectors higher regardless of meaning "
230
+ "when vectors aren't normalized.",
231
+ hint="Normalize vectors before writing them, or switch the index to cosine.",
232
+ )
233
+ )
234
+ return findings
235
+
236
+
237
+ def check_duplicates(p: IndexProfile) -> list[Finding]:
238
+ s = p.sample
239
+ findings: list[Finding] = []
240
+ for kind, count, base, noun in (
241
+ ("text", s.duplicate_texts, s.texts_present or 0, "texts"),
242
+ ("vectors", s.duplicate_vectors, s.vectors, "vectors"),
243
+ ):
244
+ if base and count / base > DUPLICATE_WARNING_RATIO:
245
+ findings.append(
246
+ Finding(
247
+ f"duplicates.{kind}",
248
+ Severity.WARNING,
249
+ f"Duplicate {noun}",
250
+ f"{count:,} of {base:,} sampled rows ({_pct(count, base)}) "
251
+ f"{_verb(count, 'repeats', 'repeat')} a "
252
+ f"{noun[:-1]} that's already in the sample. Duplicates crowd out other "
253
+ "results and cost money to re-embed.",
254
+ hint="Deduplicate before migrating: you only pay to embed each text once.",
255
+ )
256
+ )
257
+ return findings
258
+
259
+
260
+ def check_models(p: IndexProfile) -> list[Finding]:
261
+ models = p.sample.models
262
+ if not models:
263
+ return [
264
+ Finding(
265
+ "models.untracked",
266
+ Severity.INFO,
267
+ "No record of which model made these vectors",
268
+ "Nothing next to the vectors says which embedding model produced them, so "
269
+ "a mixed or outdated index can't be detected directly.",
270
+ hint="Store a model tag with each row, for example the output of "
271
+ "`vecshift fingerprint`, in a column or in metadata under `model_tag`.",
272
+ )
273
+ ]
274
+ findings: list[Finding] = []
275
+ if len(models) > 1:
276
+ findings.append(
277
+ Finding(
278
+ "models.mixed",
279
+ Severity.ERROR,
280
+ "Vectors from more than one model",
281
+ f"`{p.model_field}` records {len(models)} different models: "
282
+ f"{_breakdown(dict(models))}. Vectors from different models aren't "
283
+ "comparable, so searches across them return poor results.",
284
+ hint="Re-embed the rows from the older model(s) with the current one.",
285
+ )
286
+ )
287
+ else:
288
+ (model,) = models
289
+ findings.append(
290
+ Finding(
291
+ "models.ok",
292
+ Severity.OK,
293
+ "One embedding model",
294
+ f"Every sampled row that records a model in `{p.model_field}` says `{model}`.",
295
+ )
296
+ )
297
+ recorded = sum(models.values())
298
+ if recorded < p.sample.vectors:
299
+ missing = p.sample.vectors - recorded
300
+ findings.append(
301
+ Finding(
302
+ "models.partial",
303
+ Severity.WARNING,
304
+ "Some rows don't record their model",
305
+ f"{missing:,} of {p.sample.vectors:,} sampled vectors "
306
+ f"{_verb(missing, 'has', 'have')} nothing in `{p.model_field}`, so "
307
+ f"{_verb(missing, 'its', 'their')} model is unknown.",
308
+ )
309
+ )
310
+ return findings
311
+
312
+
313
+ def check_indexes(p: IndexProfile) -> list[Finding]:
314
+ if p.ann_indexes:
315
+ described = ", ".join(
316
+ f"{i.name} ({i.method}{', ' + i.metric if i.metric else ''})" for i in p.ann_indexes
317
+ )
318
+ return [Finding("index.ok", Severity.OK, "ANN index present", described)]
319
+ rows = p.estimated_rows or 0
320
+ large = rows > UNINDEXED_ROWS_WARNING
321
+ return [
322
+ Finding(
323
+ "index.missing",
324
+ Severity.WARNING if large else Severity.INFO,
325
+ "No ANN index",
326
+ f"Searches scan every row exactly{f' (about {rows:,} rows)' if rows else ''}. "
327
+ + (
328
+ "That gets slow at this size."
329
+ if large
330
+ else "That's fine for small tables but slows down as the table grows."
331
+ ),
332
+ hint="Create an HNSW index with the operator class for your distance, for "
333
+ "example `vector_cosine_ops`.",
334
+ )
335
+ ]
336
+
337
+
338
+ def check_sync(p: IndexProfile) -> list[Finding]:
339
+ findings: list[Finding] = []
340
+ if not p.has_primary_key:
341
+ findings.append(
342
+ Finding(
343
+ "sync.no_primary_key",
344
+ Severity.ERROR,
345
+ "No primary key",
346
+ "Without a stable ID per row, a migration can't resume safely, avoid "
347
+ "duplicates, or match updates and deletes to the right row.",
348
+ hint="Add a primary key before migrating.",
349
+ )
350
+ )
351
+ if p.logical_replication and p.has_primary_key:
352
+ findings.append(
353
+ Finding(
354
+ "sync.logical_replication",
355
+ Severity.OK,
356
+ "Live writes can be streamed",
357
+ "Logical replication is enabled, so inserts, updates, and deletes made during "
358
+ "a migration can be streamed to the new index.",
359
+ )
360
+ )
361
+ elif p.updated_at_field:
362
+ findings.append(
363
+ Finding(
364
+ "sync.updated_at",
365
+ Severity.OK,
366
+ "Live writes can be tracked by timestamp",
367
+ f"`{p.updated_at_field}` lets a migration catch up on rows changed while it "
368
+ "ran. Deletes don't show up this way, so they need a separate reconciliation "
369
+ "pass by ID.",
370
+ )
371
+ )
372
+ else:
373
+ findings.append(
374
+ Finding(
375
+ "sync.none",
376
+ Severity.WARNING,
377
+ "No way to track writes during a migration",
378
+ "There's no logical replication and no updated-at timestamp column, so "
379
+ "changes made while a migration runs would be missed.",
380
+ hint="Add an `updated_at` column maintained by a trigger, or plan to pause "
381
+ "writes or dual-write during the migration.",
382
+ )
383
+ )
384
+ return findings
385
+
386
+
387
+ CHECKS: tuple[Check, ...] = (
388
+ check_access,
389
+ check_text,
390
+ check_null_vectors,
391
+ check_dimensions,
392
+ check_norms,
393
+ check_duplicates,
394
+ check_models,
395
+ check_indexes,
396
+ check_sync,
397
+ )
398
+
399
+ SAMPLE_CHECKS = {
400
+ check_text,
401
+ check_null_vectors,
402
+ check_dimensions,
403
+ check_norms,
404
+ check_duplicates,
405
+ check_models,
406
+ }
407
+
408
+
409
+ def _histogram(values: list[float], bins: int = HISTOGRAM_BINS) -> list[dict[str, float]]:
410
+ lo, hi = min(values), max(values)
411
+ if hi - lo < 1e-9:
412
+ return [{"start": lo, "end": hi, "count": len(values)}]
413
+ width = (hi - lo) / bins
414
+ counts = [0] * bins
415
+ for v in values:
416
+ counts[min(int((v - lo) / width), bins - 1)] += 1
417
+ return [
418
+ {"start": lo + i * width, "end": lo + (i + 1) * width, "count": c}
419
+ for i, c in enumerate(counts)
420
+ ]
421
+
422
+
423
+ def facts(profile: IndexProfile) -> dict[str, Any]:
424
+ """The measurements behind the findings, in a JSON-friendly shape."""
425
+ s = profile.sample
426
+ nonzero = [n for n in s.norms if n >= ZERO_NORM]
427
+ norms: dict[str, Any] | None = None
428
+ if s.norms:
429
+ norms = {
430
+ "count": len(s.norms),
431
+ "zero": len(s.norms) - len(nonzero),
432
+ "unit": sum(1 for n in nonzero if abs(n - 1.0) <= NORM_TOLERANCE),
433
+ "min": min(s.norms),
434
+ "median": statistics.median(s.norms),
435
+ "max": max(s.norms),
436
+ "tolerance": NORM_TOLERANCE,
437
+ "histogram": _histogram(s.norms),
438
+ }
439
+ return {
440
+ "text_field": profile.text_field,
441
+ "model_field": profile.model_field,
442
+ "updated_at_field": profile.updated_at_field,
443
+ "has_primary_key": profile.has_primary_key,
444
+ "logical_replication": profile.logical_replication,
445
+ "rows_hidden_by_access_rules": profile.rows_hidden_by_access_rules,
446
+ "max_indexable_dimensions": profile.max_indexable_dimensions,
447
+ "ann_indexes": [
448
+ {"name": i.name, "method": i.method, "metric": i.metric} for i in profile.ann_indexes
449
+ ],
450
+ "sample": {
451
+ "rows": s.rows,
452
+ "vectors": s.vectors,
453
+ "null_vectors": s.null_vectors,
454
+ "texts_present": s.texts_present,
455
+ "duplicate_texts": s.duplicate_texts,
456
+ "duplicate_vectors": s.duplicate_vectors,
457
+ },
458
+ "dimensions": {str(d): n for d, n in s.dimensions.most_common()},
459
+ "models": dict(s.models.most_common()),
460
+ "norms": norms,
461
+ }
462
+
463
+
464
+ def run_checks(profile: IndexProfile) -> Report:
465
+ """Run every check against ``profile`` and collect the findings."""
466
+ report = Report(
467
+ store=profile.store,
468
+ target=profile.target,
469
+ vector_type=profile.vector_type,
470
+ declared_dimensions=profile.declared_dimensions,
471
+ estimated_rows=profile.estimated_rows,
472
+ sample_rows=profile.sample.rows,
473
+ sample_method=profile.sample.method,
474
+ facts=facts(profile),
475
+ )
476
+ empty = profile.sample.rows == 0
477
+ if empty:
478
+ report.findings.append(
479
+ Finding(
480
+ "sample.empty",
481
+ Severity.INFO,
482
+ "No rows to inspect",
483
+ "The table is empty (or no rows are visible), so only schema checks ran.",
484
+ )
485
+ )
486
+ for check in CHECKS:
487
+ if empty and check in SAMPLE_CHECKS and check is not check_dimensions:
488
+ continue
489
+ report.findings.extend(check(profile))
490
+ return report
@@ -0,0 +1,78 @@
1
+ """Findings and the report that collects them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from enum import StrEnum
7
+ from typing import Any
8
+
9
+
10
+ class Severity(StrEnum):
11
+ OK = "ok"
12
+ INFO = "info"
13
+ WARNING = "warning"
14
+ ERROR = "error"
15
+
16
+ @property
17
+ def rank(self) -> int:
18
+ return _RANK[self]
19
+
20
+
21
+ _RANK = {Severity.OK: 0, Severity.INFO: 1, Severity.WARNING: 2, Severity.ERROR: 3}
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class Finding:
26
+ id: str
27
+ """Stable identifier such as ``dims.mixed``, for scripts and CI."""
28
+ severity: Severity
29
+ title: str
30
+ detail: str
31
+ hint: str | None = None
32
+
33
+ def to_dict(self) -> dict[str, Any]:
34
+ return {
35
+ "id": self.id,
36
+ "severity": str(self.severity),
37
+ "title": self.title,
38
+ "detail": self.detail,
39
+ "hint": self.hint,
40
+ }
41
+
42
+
43
+ @dataclass(slots=True)
44
+ class Report:
45
+ store: str
46
+ target: str
47
+ vector_type: str
48
+ declared_dimensions: int | None
49
+ estimated_rows: int | None
50
+ sample_rows: int
51
+ sample_method: str
52
+ findings: list[Finding] = field(default_factory=list)
53
+ facts: dict[str, Any] = field(default_factory=dict)
54
+ """What was measured, for reports and dashboards. See ``checks.facts``."""
55
+
56
+ @property
57
+ def worst(self) -> Severity:
58
+ return max((f.severity for f in self.findings), key=lambda s: s.rank, default=Severity.OK)
59
+
60
+ def count(self, severity: Severity) -> int:
61
+ return sum(1 for f in self.findings if f.severity is severity)
62
+
63
+ def sorted_findings(self) -> list[Finding]:
64
+ """Most severe first; ties keep check order."""
65
+ return sorted(self.findings, key=lambda f: -f.severity.rank)
66
+
67
+ def to_dict(self) -> dict[str, Any]:
68
+ return {
69
+ "store": self.store,
70
+ "target": self.target,
71
+ "vector_type": self.vector_type,
72
+ "declared_dimensions": self.declared_dimensions,
73
+ "estimated_rows": self.estimated_rows,
74
+ "sample": {"rows": self.sample_rows, "method": self.sample_method},
75
+ "summary": {str(s): self.count(s) for s in Severity},
76
+ "findings": [f.to_dict() for f in self.sorted_findings()],
77
+ "facts": self.facts,
78
+ }