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/cli_eval.py ADDED
@@ -0,0 +1,591 @@
1
+ """The ``vecshift eval`` command: is the new model better on your data, and how fast is it?"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import sys
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING, Annotated, Any
11
+
12
+ import typer
13
+
14
+ from vecshift.cli_plan import DEFAULT_JOB, _fail, load
15
+ from vecshift.migrate.engine import SETTLED_ROWS, SETTLED_SHARE
16
+
17
+ if TYPE_CHECKING:
18
+ import psycopg
19
+ from psycopg import sql
20
+
21
+ from vecshift.connectors.pgvector.search import PgSearcher
22
+ from vecshift.embeddings import ModelSpec
23
+ from vecshift.eval import EvalQuery, EvalReport, SideReport
24
+
25
+ EXIT_INCONCLUSIVE = 3
26
+ SETUP_TIMEOUT_MS = 10 * 60_000
27
+ GROUP_COLUMNS = ("document_id", "doc_id", "parent_id", "source_id", "file_id", "source", "url")
28
+ GROUP_KEYS = (*GROUP_COLUMNS, "file_name", "filename", "path", "title")
29
+ OTHER_LANGUAGE = {"arabic": "English", "latin": "Arabic"}
30
+
31
+
32
+ @dataclass(slots=True)
33
+ class _Columns:
34
+ old: str
35
+ new: str
36
+ stage: str
37
+ """``applied`` (before cutover) or ``cut_over``."""
38
+
39
+
40
+ def _columns(conn: psycopg.Connection, relid: int, live: str, target: str) -> _Columns:
41
+ from vecshift.connectors.pgvector.inspect import _columns as table_columns
42
+
43
+ names = table_columns(conn, relid)
44
+ previous = f"{live}_old"[:63]
45
+ if target in names:
46
+ return _Columns(live, target, "applied")
47
+ if previous in names:
48
+ return _Columns(previous, live, "cut_over")
49
+ raise _fail(f"There are no new vectors to evaluate yet: {target} doesn't exist.", "Run apply.")
50
+
51
+
52
+ def _metric(conn: psycopg.Connection, relid: int, column: str, configured: str | None) -> str:
53
+ if configured:
54
+ return configured
55
+ rows = conn.execute(
56
+ """
57
+ SELECT opc.opcname FROM pg_index i
58
+ JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = i.indkey[0]
59
+ JOIN pg_opclass opc ON opc.oid = i.indclass[0]
60
+ WHERE i.indrelid = %s AND a.attname = %s
61
+ """,
62
+ (relid, column),
63
+ ).fetchall()
64
+ for (name,) in rows:
65
+ for metric, suffix in (("cosine", "cosine_ops"), ("inner_product", "ip_ops")):
66
+ if str(name).endswith(suffix):
67
+ return metric
68
+ if str(name).endswith("l2_ops"):
69
+ return "l2"
70
+ return "cosine"
71
+
72
+
73
+ def _group(
74
+ conn: psycopg.Connection, relid: int, table: sql.Composable, option: str | None
75
+ ) -> tuple[sql.Composable | None, str | None]:
76
+ """How to tell which rows belong to the same document, and its name for the report."""
77
+ from psycopg import sql
78
+
79
+ from vecshift.connectors.pgvector.inspect import _columns as table_columns
80
+
81
+ names = table_columns(conn, relid)
82
+ if option:
83
+ column, _, key = option.partition(".")
84
+ if column not in names:
85
+ raise _fail(f"--group-by: there's no column {column!r}.")
86
+ if key:
87
+ if names[column] not in {"jsonb", "json"}:
88
+ raise _fail(f"--group-by: {column} isn't a JSON column.")
89
+ return sql.SQL("{}->>{}").format(sql.Identifier(column), sql.Literal(key)), option
90
+ return sql.Identifier(column), option
91
+ for column in GROUP_COLUMNS:
92
+ if column in names:
93
+ return sql.Identifier(column), column
94
+ for column, kind in names.items():
95
+ if kind != "jsonb":
96
+ continue
97
+ rows = conn.execute(
98
+ sql.SQL(
99
+ "SELECT DISTINCT jsonb_object_keys({c}) FROM (SELECT {c} FROM {t} "
100
+ "WHERE jsonb_typeof({c}) = 'object' LIMIT 200) s"
101
+ ).format(c=sql.Identifier(column), t=table)
102
+ ).fetchall()
103
+ keys = {str(r[0]) for r in rows}
104
+ for key in GROUP_KEYS:
105
+ if key in keys:
106
+ return (
107
+ sql.SQL("{}->>{}").format(sql.Identifier(column), sql.Literal(key)),
108
+ f"{column}.{key}",
109
+ )
110
+ return None, None
111
+
112
+
113
+ async def _working(spec: ModelSpec, dims: int | None) -> tuple[Any | None, str | None]:
114
+ """An embedder for ``spec`` if it answers and matches the column, else why not."""
115
+ from vecshift.embeddings import EmbeddingError, create_embedder
116
+
117
+ embedder = create_embedder(spec)
118
+ try:
119
+ vector = (await embedder.embed(["a short test query"], "query"))[0]
120
+ except EmbeddingError as exc:
121
+ await embedder.aclose()
122
+ return None, f"{spec.name} couldn't embed a test query: {exc}"
123
+ if dims and len(vector) != dims:
124
+ await embedder.aclose()
125
+ return None, (
126
+ f"{spec.name} returns {len(vector)} dimensions, but the column holds {dims}: "
127
+ "is that the model that made these vectors?"
128
+ )
129
+ return embedder, None
130
+
131
+
132
+ def _queries(
133
+ searcher: PgSearcher,
134
+ text: str,
135
+ rows: list[tuple[str, str]],
136
+ *,
137
+ count: int,
138
+ seed: int,
139
+ file: Path | None,
140
+ generator: ModelSpec | None,
141
+ cross_language: bool,
142
+ ) -> tuple[list[EvalQuery], str, list[str]]:
143
+ from vecshift.bench.corpus import CorpusError, Document, load_queries
144
+ from vecshift.bench.generate import GenerationError, QueryGenerator
145
+ from vecshift.eval import EvalQuery, proxy_queries, script
146
+
147
+ if file is not None:
148
+ try:
149
+ labeled = load_queries(file)[:count]
150
+ except CorpusError as exc:
151
+ raise _fail(str(exc)) from exc
152
+ keys = sorted({k for q in labeled for k in q.relevant})
153
+ texts = searcher.texts(text, keys)
154
+ queries = [
155
+ EvalQuery(
156
+ q.text,
157
+ q.relevant,
158
+ script(q.text),
159
+ script(" ".join(texts.get(k, "") for k in sorted(q.relevant))),
160
+ )
161
+ for q in labeled
162
+ ]
163
+ missing = [k for k in keys if k not in texts]
164
+ notes = [f"{len(missing)} labeled answers aren't in the table."] if missing else []
165
+ return queries, "labeled", notes
166
+ if generator is not None:
167
+ docs = [Document(key, body) for key, body in rows]
168
+ scripts = {key: script(body) for key, body in rows}
169
+
170
+ def language(doc: Document) -> str | None:
171
+ return OTHER_LANGUAGE.get(scripts[doc.id]) if cross_language else None
172
+
173
+ async def make() -> list[Any]:
174
+ gen = QueryGenerator(generator)
175
+ try:
176
+ return await gen.generate(docs, count, seed, language)
177
+ finally:
178
+ await gen.aclose()
179
+
180
+ try:
181
+ made = asyncio.run(make())
182
+ except GenerationError as exc:
183
+ raise _fail(str(exc)) from exc
184
+ queries = [
185
+ EvalQuery(q.text, q.relevant, script(q.text), scripts[next(iter(q.relevant))])
186
+ for q in made
187
+ ]
188
+ return queries, "cross-language" if cross_language else "generated", []
189
+ queries, notes = proxy_queries(rows, count, seed)
190
+ return queries, "proxy", notes
191
+
192
+
193
+ def _confirm(sends: list[tuple[str, str]], count: int, yes: bool) -> None:
194
+ remote = [(name, url) for name, url in sends if url]
195
+ if not remote:
196
+ return
197
+ out = sys.stderr
198
+ print("eval sends text to:", file=out)
199
+ for name, url in remote:
200
+ print(f" {name:<40} {url}", file=out)
201
+ print(
202
+ f"About {count:,} short queries (sentences from your rows, or generated from them).",
203
+ file=out,
204
+ )
205
+ if yes:
206
+ return
207
+ if not sys.stdin.isatty():
208
+ raise _fail("Not sending data without confirmation.", "Pass --yes to proceed.")
209
+ if not typer.confirm("Continue?", err=True):
210
+ raise typer.Exit(1)
211
+
212
+
213
+ def eval_(
214
+ job_file: Annotated[Path, typer.Argument(help="The job file.")] = DEFAULT_JOB,
215
+ num_queries: Annotated[int, typer.Option(min=5, max=10_000, help="Queries to evaluate.")] = 200,
216
+ queries: Annotated[
217
+ Path | None,
218
+ typer.Option(help='JSONL of labeled queries: {"query": ..., "relevant": [ids]} per line.'),
219
+ ] = None,
220
+ generate_queries: Annotated[
221
+ str | None,
222
+ typer.Option(
223
+ metavar="CHAT_MODEL",
224
+ help="Write realistic queries with this chat model, e.g. openai/gpt-4o-mini.",
225
+ ),
226
+ ] = None,
227
+ cross_language: Annotated[
228
+ bool,
229
+ typer.Option(
230
+ help="With --generate-queries: ask in the other language (Arabic for Latin-script "
231
+ "rows, English for Arabic ones)."
232
+ ),
233
+ ] = False,
234
+ old_model: Annotated[
235
+ str | None,
236
+ typer.Option(help="The model that made the old vectors (default: source.model)."),
237
+ ] = None,
238
+ group_by: Annotated[
239
+ str | None,
240
+ typer.Option(
241
+ metavar="COLUMN[.KEY]",
242
+ help="Which rows belong to one document, e.g. document_id or metadata.source.",
243
+ ),
244
+ ] = None,
245
+ neighbours: Annotated[
246
+ int, typer.Option(min=0, max=5_000, help="Rows whose nearest rows are compared.")
247
+ ] = 100,
248
+ tolerance: Annotated[
249
+ float, typer.Option(min=0, max=1, help="How far recall@10 may fall and still pass.")
250
+ ] = 0.02,
251
+ min_slice: Annotated[
252
+ int, typer.Option(min=1, help="Fewest queries for a language pair to decide the verdict.")
253
+ ] = 30,
254
+ max_p95_ms: Annotated[
255
+ float | None, typer.Option(help="Fail if the new p95 (embed + search) is above this.")
256
+ ] = None,
257
+ max_slowdown: Annotated[
258
+ float | None,
259
+ typer.Option(metavar="PERCENT", help="Fail if the new p95 is this much slower than old."),
260
+ ] = None,
261
+ dsn_env: Annotated[
262
+ str | None,
263
+ typer.Option(help="Read the connection string from this variable, e.g. a replica's."),
264
+ ] = None,
265
+ seed: Annotated[int, typer.Option(help="Seed for sampling.")] = 7,
266
+ yes: Annotated[
267
+ bool, typer.Option("--yes", "-y", help="Don't ask before sending data.")
268
+ ] = False,
269
+ output_json: Annotated[bool, typer.Option("--json", help="Print results as JSON.")] = False,
270
+ html: Annotated[
271
+ Path | None,
272
+ typer.Option("--html", dir_okay=False, help="Also write an HTML report to this file."),
273
+ ] = None,
274
+ ) -> None:
275
+ """Compare the old and new vectors on your data: search quality and latency. Read-only."""
276
+ from vecshift.connectors import pgvector
277
+ from vecshift.connectors.pgvector.inspect import TEXT_COLUMNS, TEXT_TYPES, _pick
278
+ from vecshift.connectors.pgvector.inspect import _columns as table_columns
279
+ from vecshift.connectors.pgvector.search import PgSearcher
280
+ from vecshift.connectors.pgvector.target import inspect_target, resolve_source_column
281
+ from vecshift.embeddings import parse_spec
282
+ from vecshift.eval import Gates, SideReport
283
+
284
+ job, settings, spec = load(job_file, dsn_env)
285
+ old_raw = old_model or job.source.model
286
+ old_spec = parse_spec(old_raw) if old_raw else None
287
+ generator = parse_spec(generate_queries) if generate_queries else None
288
+ if cross_language and generator is None:
289
+ raise _fail("--cross-language needs --generate-queries.")
290
+
291
+ try:
292
+ conn = pgvector.connect(settings)
293
+ except pgvector.ConnectError as exc:
294
+ raise _fail(f"Couldn't connect: {exc}", exc.hint) from exc
295
+ conn.rollback()
296
+ try:
297
+ # One read-only transaction for the setup queries, under a generous time limit.
298
+ conn.execute("SELECT set_config('statement_timeout', %s, true)", (str(SETUP_TIMEOUT_MS),))
299
+ live = resolve_source_column(
300
+ conn, job.source.table, job.source.vector_column, job.target.column
301
+ )
302
+ target = inspect_target(conn, job.source.table, live, job.target.column)
303
+ relid = target.source.relid
304
+ names = table_columns(conn, relid)
305
+ text = job.source.text_column or _pick(names, TEXT_COLUMNS, TEXT_TYPES)
306
+ if len(target.primary_key) != 1 or text is None:
307
+ raise _fail("eval needs a single-column primary key and a text column.")
308
+ cols = _columns(conn, relid, live, job.target.column)
309
+ metric = _metric(
310
+ conn, relid, cols.old, job.target.metric.value if job.target.metric else None
311
+ )
312
+ from psycopg import sql
313
+
314
+ table = sql.Identifier(target.source.schema, target.source.table)
315
+ group, group_name = _group(conn, relid, table, group_by)
316
+ dims = {
317
+ side: conn.execute(
318
+ "SELECT atttypmod FROM pg_attribute WHERE attrelid = %s AND attname = %s",
319
+ (relid, name),
320
+ ).fetchone()
321
+ for side, name in (("old", cols.old), ("new", cols.new))
322
+ }
323
+ missing = conn.execute(
324
+ sql.SQL(
325
+ "SELECT count(*) FILTER (WHERE {new} IS NULL), count(*) FILTER "
326
+ "(WHERE {new} IS NOT NULL) FROM {t} WHERE {txt} IS NOT NULL "
327
+ "AND btrim({txt}::text) <> ''"
328
+ ).format(new=sql.Identifier(cols.new), t=table, txt=sql.Identifier(text))
329
+ ).fetchone()
330
+ conn.rollback()
331
+ except pgvector.TargetSelectionError as exc:
332
+ conn.close()
333
+ raise _fail(str(exc)) from exc
334
+ except typer.Exit:
335
+ conn.close()
336
+ raise
337
+
338
+ pending, done = (int(missing[0]), int(missing[1])) if missing else (0, 0)
339
+ # A busy table always has a few rows waiting for a vector; that's not a partial migration.
340
+ partial = pending > max(SETTLED_ROWS, SETTLED_SHARE * done)
341
+ if not done:
342
+ conn.close()
343
+ raise _fail("The new column has no vectors yet.", "Run apply (or apply --until 10).")
344
+ searcher = PgSearcher(
345
+ conn,
346
+ schema=target.source.schema,
347
+ table=target.source.table,
348
+ pk=target.primary_key[0],
349
+ columns={"old": cols.old, "new": cols.new},
350
+ types={"old": names[cols.old], "new": names[cols.new]},
351
+ extension_schema=target.extension_schema,
352
+ metric=metric,
353
+ partial=partial,
354
+ group=group,
355
+ )
356
+ old = SideReport("old", cols.old, old_spec.name if old_spec else None)
357
+ new = SideReport("new", cols.new, spec.name)
358
+ try:
359
+ rows = searcher.sample(text, max(num_queries * 3, neighbours))
360
+ if not rows:
361
+ raise _fail("No rows have both an old and a new vector to compare.")
362
+ sends = [(spec.name, "" if spec.is_local else spec.url or "")]
363
+ if old_spec:
364
+ sends.append(
365
+ (f"{old_spec.name} (old)", "" if old_spec.is_local else old_spec.url or "")
366
+ )
367
+ if generator:
368
+ sends.append(
369
+ (
370
+ f"{generator.name} (writes queries)",
371
+ "" if generator.is_local else generator.url or "",
372
+ )
373
+ )
374
+ _confirm(sends, num_queries, yes)
375
+ built, source, notes = _queries(
376
+ searcher,
377
+ text,
378
+ rows,
379
+ count=num_queries,
380
+ seed=seed,
381
+ file=queries,
382
+ generator=generator,
383
+ cross_language=cross_language,
384
+ )
385
+ report = asyncio.run(
386
+ _evaluate(
387
+ searcher,
388
+ spec,
389
+ old_spec,
390
+ built,
391
+ [k for k, _ in rows[:neighbours]],
392
+ old=old,
393
+ new=new,
394
+ dims={k: (int(v[0]) if v and v[0] > 0 else None) for k, v in dims.items()},
395
+ source=source,
396
+ partial=partial,
397
+ gates=Gates(tolerance, min_slice, max_p95_ms, max_slowdown),
398
+ notes=notes,
399
+ on_step=(lambda s: None) if output_json else _step,
400
+ )
401
+ )
402
+ finally:
403
+ conn.close()
404
+
405
+ if group_name:
406
+ report.notes.append(f"Rows of the same document are matched by {group_name}.")
407
+ if pending:
408
+ report.notes.append(f"{pending:,} rows don't have a new vector yet.")
409
+ if output_json:
410
+ typer.echo(json.dumps(report.to_dict(), indent=2))
411
+ else:
412
+ _render(report, job.name)
413
+ if html is not None:
414
+ from vecshift import __version__
415
+ from vecshift.eval.html import render_html
416
+
417
+ html.write_text(render_html(report, job=job.name, version=__version__), encoding="utf-8")
418
+ if not output_json:
419
+ typer.echo(f"\nWrote {html}")
420
+ if report.verdict.status == "no_go":
421
+ raise typer.Exit(1)
422
+ if report.verdict.status == "inconclusive":
423
+ raise typer.Exit(EXIT_INCONCLUSIVE)
424
+
425
+
426
+ def _step(message: str) -> None:
427
+ typer.secho(f" {message}…", dim=True, err=True)
428
+
429
+
430
+ async def _evaluate(
431
+ searcher: PgSearcher,
432
+ spec: ModelSpec,
433
+ old_spec: ModelSpec | None,
434
+ queries: list[EvalQuery],
435
+ neighbour_keys: list[str],
436
+ *,
437
+ old: SideReport,
438
+ new: SideReport,
439
+ dims: dict[str, int | None],
440
+ source: str,
441
+ partial: bool,
442
+ gates: Any,
443
+ notes: list[str],
444
+ on_step: Any,
445
+ ) -> EvalReport:
446
+ from vecshift.eval import run
447
+
448
+ new_embedder, problem = await _working(spec, dims["new"])
449
+ if new_embedder is None:
450
+ raise _fail(problem or "The new model isn't working.")
451
+ old_embedder = None
452
+ if old_spec is None:
453
+ old.note = (
454
+ "No old model given (source.model or --old-model), so the old side is judged "
455
+ "from its stored vectors only."
456
+ )
457
+ else:
458
+ old_embedder, old.note = await _working(old_spec, dims["old"])
459
+ try:
460
+ return await run(
461
+ searcher,
462
+ {"old": old_embedder, "new": new_embedder},
463
+ queries,
464
+ neighbour_keys,
465
+ old=old,
466
+ new=new,
467
+ query_source=source,
468
+ partial=partial,
469
+ gates=gates,
470
+ notes=notes,
471
+ on_step=on_step,
472
+ )
473
+ finally:
474
+ await new_embedder.aclose()
475
+ if old_embedder is not None:
476
+ await old_embedder.aclose()
477
+
478
+
479
+ # --- Rendering
480
+
481
+
482
+ def _ms(value: float | None) -> str:
483
+ if value is None:
484
+ return "—"
485
+ return f"{value:,.0f} ms" if value >= 100 else f"{value:.1f} ms"
486
+
487
+
488
+ def _num(value: float | None) -> str:
489
+ return "—" if value is None else f"{value:.3f}"
490
+
491
+
492
+ def _change(before: float | None, after: float | None) -> str:
493
+ if before is None or after is None:
494
+ return ""
495
+ delta = after - before
496
+ color = typer.colors.GREEN if delta > 0.005 else typer.colors.RED if delta < -0.005 else None
497
+ return typer.style(f"{delta:+.3f}", fg=color)
498
+
499
+
500
+ def _render(report: EvalReport, name: str) -> None:
501
+ old, new = report.old, report.new
502
+ typer.secho(f"vecshift eval · {name}", bold=True)
503
+ typer.echo(f" Old {old.column} · {old.model or 'model unknown'}")
504
+ typer.echo(f" New {new.column} · {new.model}")
505
+ kind = "partial migration" if report.partial else "complete migration"
506
+ typer.echo(f" {report.queries:,} {report.query_source} queries · {kind}")
507
+ for side in (old, new):
508
+ if side.note:
509
+ typer.secho(f" ⚠ {side.note}", fg=typer.colors.YELLOW)
510
+
511
+ if new.scores:
512
+ typer.secho("\nSearch quality", bold=True)
513
+ typer.echo(f" {'':<30}{'old':>8}{'new':>8} change")
514
+ for key, after in new.scores.items():
515
+ before = old.scores.get(key)
516
+ label = f"{'all queries' if key == 'all' else key} ({after.queries})"
517
+ typer.echo(
518
+ f" Recall@10 {label:<20}{_num(before.recall_at_10 if before else None):>8}"
519
+ f"{_num(after.recall_at_10):>8} "
520
+ f"{_change(before.recall_at_10 if before else None, after.recall_at_10)}"
521
+ )
522
+ a_old, a_new = old.scores.get("all"), new.scores.get("all")
523
+ typer.echo(
524
+ f" {'Recall@1 (all)':<30}{_num(a_old.recall_at_1 if a_old else None):>8}"
525
+ f"{_num(a_new.recall_at_1 if a_new else None):>8}"
526
+ )
527
+ typer.echo(
528
+ f" {'MRR@10 (all)':<30}{_num(a_old.mrr_at_10 if a_old else None):>8}"
529
+ f"{_num(a_new.mrr_at_10 if a_new else None):>8}"
530
+ )
531
+ if report.result_overlap is not None:
532
+ typer.echo(f" Top-10 results in common: {report.result_overlap:.0%}")
533
+
534
+ if old.same_group_at_10 is not None or report.neighbour_overlap is not None:
535
+ typer.secho("\nNearest rows (from stored vectors)", bold=True)
536
+ if old.same_group_at_10 is not None or new.same_group_at_10 is not None:
537
+ typer.echo(
538
+ f" {'Same document in top 10':<30}{_num(old.same_group_at_10):>8}"
539
+ f"{_num(new.same_group_at_10):>8} "
540
+ f"{_change(old.same_group_at_10, new.same_group_at_10)}"
541
+ )
542
+ if report.neighbour_overlap is not None:
543
+ typer.echo(f" Nearest rows in common: {report.neighbour_overlap:.0%}")
544
+
545
+ if old.sweep or new.sweep or old.embed_latency or new.embed_latency:
546
+ typer.secho("\nLatency vs accuracy", bold=True)
547
+ typer.echo(f" {'Query embedding p50 / p95':<26}{_lat(old)} {_lat(new)}")
548
+ for side in (old, new):
549
+ if not side.sweep:
550
+ continue
551
+ setting = {"hnsw": "ef_search", "ivfflat": "probes"}.get(side.method or "", "exact")
552
+ typer.echo(f" {side.side} search ({side.method or 'no index'}):")
553
+ for point in side.sweep:
554
+ mark = " *" if point.current else ""
555
+ lat = point.latency
556
+ p50, p95, p99 = (lat.p50_ms, lat.p95_ms, lat.p99_ms) if lat else (None, None, None)
557
+ value = point.setting if point.setting is not None else "—"
558
+ typer.echo(
559
+ f" {setting}={value}{mark:<3} index recall {_num(point.index_recall)}"
560
+ f" p50 {_ms(p50)} p95 {_ms(p95)} p99 {_ms(p99)}"
561
+ )
562
+ samples = next((p.latency.samples for p in new.sweep if p.latency), 0)
563
+ typer.secho(
564
+ f" * current setting. {samples} queries per setting; search times include the "
565
+ "network round trip.",
566
+ dim=True,
567
+ )
568
+ typer.echo(
569
+ f" End-to-end p95 (embed + search): old {_ms(old.end_to_end_p95)} · "
570
+ f"new {_ms(new.end_to_end_p95)}"
571
+ )
572
+
573
+ for note in report.notes:
574
+ typer.secho(f"\n {note}", dim=True)
575
+ verdict = report.verdict
576
+ label, color = {
577
+ "go": ("GO", typer.colors.GREEN),
578
+ "no_go": ("NO-GO", typer.colors.RED),
579
+ "inconclusive": ("INCONCLUSIVE", typer.colors.YELLOW),
580
+ }[verdict.status]
581
+ typer.secho(f"\nVerdict: {label}", fg=color, bold=True)
582
+ for reason in verdict.reasons:
583
+ typer.echo(f" ✖ {reason}" if verdict.status != "go" else f" {reason}")
584
+ for warning in verdict.warnings:
585
+ typer.secho(f" ⚠ {warning}", fg=typer.colors.YELLOW)
586
+
587
+
588
+ def _lat(side: SideReport) -> str:
589
+ lat = side.embed_latency
590
+ text = f"{_ms(lat.p50_ms)} / {_ms(lat.p95_ms)}" if lat else "—"
591
+ return f"{side.side} {text}"