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/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """VecShift: safe, observable embedding migrations for any vector store."""
2
+
3
+ from vecshift.core.capabilities import Capability
4
+ from vecshift.core.fingerprint import EmbeddingFingerprint
5
+ from vecshift.core.record import Record, SparseVector
6
+
7
+ __version__ = "0.1.0"
8
+
9
+ __all__ = [
10
+ "Capability",
11
+ "EmbeddingFingerprint",
12
+ "Record",
13
+ "SparseVector",
14
+ "__version__",
15
+ ]
@@ -0,0 +1,39 @@
1
+ /* vecshift bench leaderboard, on top of report.css */
2
+ .picks { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr)); gap: 12px; margin-top: 12px; }
3
+ .pick { padding: 16px 18px; min-width: 0; }
4
+ .pick .tile-label { font-weight: 600; }
5
+ .pick .who { margin-top: 6px; font-size: 17px; font-weight: 650; overflow-wrap: anywhere; }
6
+ .pick .why { margin-top: 2px; font-size: 13px; color: var(--ink-2); }
7
+
8
+ .lb { padding: 6px 0 4px; }
9
+ .lb .table-scroll { padding: 0 18px; }
10
+ .lb table { margin: 0; min-width: 760px; }
11
+ .lb th { font-size: 12px; white-space: nowrap; }
12
+ .lb td { white-space: nowrap; vertical-align: middle; }
13
+ .lb tr:last-child td { border-bottom: 0; }
14
+ .lb .rank { color: var(--muted); width: 1%; }
15
+ .lb .model { white-space: normal; min-width: 180px; }
16
+ .lb .model b { display: block; font-weight: 600; overflow-wrap: anywhere; }
17
+ .lb .model code { font-size: 11px; color: var(--muted); overflow-wrap: anywhere; }
18
+ .lb .best { font-weight: 700; }
19
+ .lb .failed td { color: var(--ink-2); }
20
+ .recall { display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
21
+ .recall .track { width: 72px; height: 6px; border-radius: 3px; background: color-mix(in oklab, var(--series) 16%, var(--surface)); overflow: hidden; }
22
+ .recall .track span { display: block; height: 100%; background: var(--series); border-radius: 3px; }
23
+
24
+ .scatter { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 0 8px; }
25
+ .scatter .plot { height: 220px; border-left: 1px solid var(--axis); }
26
+ .vline { position: absolute; top: 0; bottom: 0; border-left: 1px solid var(--grid); }
27
+ .pt {
28
+ position: absolute; width: 24px; height: 24px; margin: 0 0 -12px -12px; border-radius: 50%;
29
+ display: grid; place-items: center; font-size: 11px; font-weight: 700; color: #fff;
30
+ background: var(--series); box-shadow: 0 0 0 2px var(--surface); cursor: default;
31
+ }
32
+ .pt:hover, .pt:focus-visible { filter: brightness(1.15); z-index: 2; }
33
+ .xaxis.log { position: relative; display: block; height: 16px; }
34
+ .xaxis.log span { position: absolute; transform: translateX(-50%); }
35
+ .method { padding: 16px 18px; }
36
+ .method p { margin: 0 0 8px; color: var(--ink-2); }
37
+ .method p:last-child { margin-bottom: 0; }
38
+ .method .err { color: var(--ink); }
39
+ .chart-note { margin: 8px 0 0; font-size: 12px; color: var(--muted); }
@@ -0,0 +1,91 @@
1
+ /* vecshift eval report, on top of report.css. Old and new are two steps of one blue:
2
+ the lighter one is the baseline, the stronger one the new model. */
3
+ :root { --old: #86b6ef; --new: #2a78d6; }
4
+ @media (prefers-color-scheme: dark) {
5
+ :root:not([data-theme="light"]) { --old: #1c5cab; --new: #5598e7; }
6
+ }
7
+ :root[data-theme="dark"] { --old: #1c5cab; --new: #5598e7; }
8
+ @media print { :root, :root[data-theme="dark"] { --old: #86b6ef; --new: #2a78d6; } }
9
+
10
+ .reasons { margin: 10px 0 0; padding-left: 18px; color: var(--ink-2); }
11
+ .reasons li { margin: 2px 0; overflow-wrap: anywhere; }
12
+ .reasons .warn { list-style: none; margin-left: -18px; display: flex; gap: 6px; align-items: flex-start; }
13
+ .reasons .warn .sev { width: 16px; height: 16px; margin-top: 2px; }
14
+
15
+ .delta { display: inline-flex; align-items: center; gap: 4px; }
16
+ .delta i { font-style: normal; font-size: 11px; }
17
+ .delta.good i { color: var(--good); }
18
+ .delta.bad i { color: var(--critical); }
19
+
20
+ .legend { display: flex; flex-wrap: wrap; gap: 6px 16px; margin: -4px 0 12px; padding: 0; list-style: none; font-size: 12px; color: var(--ink-2); }
21
+ .legend li { display: inline-flex; align-items: center; gap: 6px; }
22
+ .key { width: 10px; height: 10px; border-radius: 50%; display: inline-block; flex: none; }
23
+ .key.line { width: 14px; height: 2px; border-radius: 1px; }
24
+ .key.old { background: var(--old); }
25
+ .key.new { background: var(--new); }
26
+ .key.ring { background: transparent; box-shadow: inset 0 0 0 2px var(--ink-2); }
27
+
28
+ /* Dumbbell: one row per language pair, old and new recall@10 on one track. */
29
+ .db { display: grid; gap: 4px; }
30
+ .db, .db-axis { --db-cols: minmax(96px, 30%) minmax(0, 1fr) 9.5em; }
31
+ .db-row { display: grid; grid-template-columns: var(--db-cols); gap: 12px; align-items: center; min-height: 32px; }
32
+ .db-label { font-size: 13px; color: var(--ink); overflow-wrap: anywhere; line-height: 1.25; }
33
+ .db-label span { display: block; font-size: 11px; color: var(--muted); }
34
+ .db-track { position: relative; height: 24px; }
35
+ .db-track::before { content: ""; position: absolute; left: 0; right: 0; top: 50%; border-top: 1px solid var(--grid); }
36
+ .db-grid { position: absolute; top: 0; bottom: 0; border-left: 1px solid var(--grid); }
37
+ .db-line { position: absolute; top: 50%; height: 2px; margin-top: -1px; background: var(--axis); border-radius: 1px; }
38
+ .db-dot {
39
+ position: absolute; top: 50%; width: 12px; height: 12px; margin: -6px 0 0 -6px; border-radius: 50%;
40
+ box-shadow: 0 0 0 2px var(--surface); cursor: default;
41
+ }
42
+ .db-dot::before { content: ""; position: absolute; inset: -6px; border-radius: 50%; }
43
+ .db-dot.old { background: var(--old); }
44
+ .db-dot.new { background: var(--new); z-index: 1; }
45
+ .db-dot:hover, .db-dot:focus-visible { filter: brightness(1.12); z-index: 2; outline: none; box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px var(--axis); }
46
+ .db-value { font-size: 12px; color: var(--ink-2); white-space: nowrap; font-variant-numeric: tabular-nums; text-align: right; }
47
+ .db-axis { display: grid; grid-template-columns: var(--db-cols); gap: 12px; }
48
+ .db-axis .ticks { position: relative; height: 16px; font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; }
49
+ .db-axis .ticks span { position: absolute; transform: translateX(-50%); }
50
+ .db-axis .ticks span:first-child { transform: none; }
51
+ .db-axis .ticks span:last-child { transform: translateX(-100%); }
52
+
53
+ /* Latency vs accuracy: index recall against search p95, one point per index setting. */
54
+ .lc { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 0 8px; }
55
+ .lc .yaxis { height: 220px; width: 40px; }
56
+ .lc .plot { height: 220px; border-left: 1px solid var(--axis); }
57
+ .lc svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
58
+ .lc polyline { fill: none; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; vector-effect: non-scaling-stroke; }
59
+ .lc polyline.old { stroke: var(--old); }
60
+ .lc polyline.new { stroke: var(--new); }
61
+ .lc .xaxis { position: relative; display: block; height: 16px; }
62
+ .lc .xaxis span { position: absolute; transform: translateX(-50%); white-space: nowrap; }
63
+ .lc .xaxis span:first-child { transform: none; }
64
+ .lc .xaxis span:last-child { transform: translateX(-100%); }
65
+ .lp {
66
+ position: absolute; width: 10px; height: 10px; margin: 0 0 -5px -5px; border-radius: 50%;
67
+ box-shadow: 0 0 0 2px var(--surface); cursor: default;
68
+ }
69
+ .lp::before { content: ""; position: absolute; inset: -7px; border-radius: 50%; }
70
+ .lp.old { background: var(--old); }
71
+ .lp.new { background: var(--new); z-index: 1; }
72
+ .lp.current { box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px var(--ink-2); }
73
+ .lp:hover, .lp:focus-visible { filter: brightness(1.12); z-index: 3; outline: none; }
74
+ .lc-label {
75
+ position: absolute; transform: translate(8px, 50%); font-size: 11px; color: var(--ink-2);
76
+ white-space: nowrap; pointer-events: none;
77
+ }
78
+ .lc-label.flip { transform: translate(calc(-100% - 8px), 50%); }
79
+
80
+ .lat { padding: 6px 0 4px; }
81
+ .lat .table-scroll { padding: 0 18px; }
82
+ .lat table { margin: 0; }
83
+ .lat tr:last-child td { border-bottom: 0; }
84
+ .lat td:first-child { color: var(--ink-2); }
85
+ .lat .total td { font-weight: 650; color: var(--ink); }
86
+ .lat td.num { white-space: nowrap; }
87
+ @media (max-width: 560px) {
88
+ .db, .db-axis { --db-cols: minmax(72px, 28%) minmax(0, 1fr) 7.5em; }
89
+ .db-row, .db-axis { gap: 8px; }
90
+ .db-axis .ticks span:nth-child(2), .db-axis .ticks span:nth-child(4) { display: none; }
91
+ }
@@ -0,0 +1,252 @@
1
+ /* vecshift doctor report. Self-contained: no fonts, scripts, or images are fetched. */
2
+ :root {
3
+ color-scheme: light;
4
+ --page: #f9f9f7;
5
+ --surface: #fcfcfb;
6
+ --ink: #0b0b0b;
7
+ --ink-2: #52514e;
8
+ --muted: #898781;
9
+ --grid: #e1e0d9;
10
+ --axis: #c3c2b7;
11
+ --border: rgba(11, 11, 11, 0.1);
12
+ --series: #2a78d6;
13
+ --mark-0: #a9a8a1; --mark-1: #839ab5; --mark-2: #5b8bc7; --mark-3: #2a78d6;
14
+ --good: #0ca30c;
15
+ --warning: #fab219;
16
+ --critical: #d03b3b;
17
+ --info: #52514e;
18
+ --on-status: #ffffff;
19
+ --on-warning: #0b0b0b;
20
+ --shadow: 0 1px 2px rgba(11, 11, 11, 0.04), 0 4px 16px rgba(11, 11, 11, 0.04);
21
+ --radius: 14px;
22
+ --mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
23
+ }
24
+ @media (prefers-color-scheme: dark) {
25
+ :root:not([data-theme="light"]) {
26
+ color-scheme: dark;
27
+ --page: #0d0d0d;
28
+ --surface: #1a1a19;
29
+ --ink: #ffffff;
30
+ --ink-2: #c3c2b7;
31
+ --grid: #2c2c2a;
32
+ --axis: #383835;
33
+ --border: rgba(255, 255, 255, 0.1);
34
+ --series: #3987e5;
35
+ --mark-0: #8a8983; --mark-1: #738ba5; --mark-2: #5a8ac5; --mark-3: #3987e5;
36
+ --info: #c3c2b7;
37
+ --on-status: #ffffff;
38
+ --shadow: none;
39
+ }
40
+ }
41
+ :root[data-theme="dark"] {
42
+ color-scheme: dark;
43
+ --page: #0d0d0d;
44
+ --surface: #1a1a19;
45
+ --ink: #ffffff;
46
+ --ink-2: #c3c2b7;
47
+ --grid: #2c2c2a;
48
+ --axis: #383835;
49
+ --border: rgba(255, 255, 255, 0.1);
50
+ --series: #3987e5;
51
+ --mark-0: #8a8983; --mark-1: #738ba5; --mark-2: #5a8ac5; --mark-3: #3987e5;
52
+ --info: #c3c2b7;
53
+ --shadow: none;
54
+ }
55
+
56
+ * { box-sizing: border-box; }
57
+ html { -webkit-text-size-adjust: 100%; }
58
+ body {
59
+ margin: 0;
60
+ background: var(--page);
61
+ color: var(--ink);
62
+ font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
63
+ }
64
+ code, .mono { font-family: var(--mono); font-size: 0.92em; }
65
+ .wrap { max-width: 1080px; margin: 0 auto; padding: 32px 16px 48px; }
66
+ @media (min-width: 720px) { .wrap { padding: 48px 32px 64px; } }
67
+
68
+ /* Header */
69
+ .brand {
70
+ display: flex; align-items: center; gap: 8px;
71
+ color: var(--ink-2); font-size: 13px; font-weight: 600; letter-spacing: 0.02em;
72
+ }
73
+ h1 {
74
+ margin: 10px 0 6px; font-size: clamp(22px, 4vw, 30px); line-height: 1.2;
75
+ font-weight: 650; overflow-wrap: anywhere;
76
+ }
77
+ .meta { margin: 0; color: var(--ink-2); font-size: 14px; }
78
+ .meta span + span::before { content: "·"; margin: 0 8px; color: var(--muted); }
79
+
80
+ section { margin-top: 28px; }
81
+ h2 { margin: 0 0 12px; font-size: 17px; font-weight: 650; }
82
+ .card {
83
+ background: var(--surface);
84
+ border: 1px solid var(--border);
85
+ border-radius: var(--radius);
86
+ box-shadow: var(--shadow);
87
+ }
88
+
89
+ /* Severity icons */
90
+ .sev { width: 18px; height: 18px; flex: none; display: block; }
91
+ .sev-lg { width: 32px; height: 32px; }
92
+
93
+ /* Verdict */
94
+ .verdict {
95
+ margin-top: 24px; padding: 20px 22px;
96
+ display: flex; gap: 16px; align-items: flex-start;
97
+ background: color-mix(in oklab, var(--tone) 7%, var(--surface));
98
+ border-color: color-mix(in oklab, var(--tone) 28%, transparent);
99
+ }
100
+ .verdict[data-tone="error"] { --tone: var(--critical); }
101
+ .verdict[data-tone="warning"] { --tone: var(--warning); }
102
+ .verdict[data-tone="ok"] { --tone: var(--good); }
103
+ .verdict[data-tone="info"] { --tone: var(--info); }
104
+ .verdict > div { min-width: 0; }
105
+ .verdict h2 { margin: 2px 0 2px; font-size: 20px; }
106
+ .verdict p { margin: 0; color: var(--ink-2); }
107
+ .counts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; padding: 0; list-style: none; }
108
+ .counts li {
109
+ display: inline-flex; align-items: center; gap: 6px;
110
+ padding: 3px 10px 3px 6px; border-radius: 999px;
111
+ background: var(--surface); border: 1px solid var(--border);
112
+ font-size: 13px; color: var(--ink-2);
113
+ }
114
+ .counts b { color: var(--ink); font-weight: 650; }
115
+ .counts .sev { width: 16px; height: 16px; }
116
+
117
+ /* KPI tiles */
118
+ .tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(150px, 100%), 1fr)); gap: 12px; }
119
+ .tile { padding: 16px 16px 14px; display: flex; flex-direction: column; min-width: 0; }
120
+ .tile-label { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--ink-2); }
121
+ .tile-label .sev { width: 15px; height: 15px; }
122
+ .tile-value { margin-top: 6px; font-size: 28px; line-height: 1.15; font-weight: 650; letter-spacing: -0.01em; }
123
+ .tile-caption { margin-top: 4px; font-size: 13px; color: var(--ink-2); overflow-wrap: anywhere; }
124
+ .meter {
125
+ margin-top: 12px; height: 6px; border-radius: 3px; overflow: hidden;
126
+ background: color-mix(in oklab, var(--fill) 18%, var(--surface));
127
+ }
128
+ .meter > span { display: block; height: 100%; border-radius: 3px; background: var(--fill); }
129
+ .meter[data-state="series"] { --fill: var(--series); }
130
+ .meter[data-state="warning"] { --fill: var(--warning); }
131
+ .meter[data-state="error"] { --fill: var(--critical); }
132
+ .tile .meter { margin-top: auto; }
133
+ .tile .tile-caption + .meter { margin-top: 12px; }
134
+
135
+ /* Findings */
136
+ .filters { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
137
+ .filters button {
138
+ font: inherit; font-size: 13px; color: var(--ink-2);
139
+ display: inline-flex; align-items: center; gap: 6px;
140
+ padding: 5px 12px; border-radius: 999px; cursor: pointer;
141
+ background: var(--surface); border: 1px solid var(--border);
142
+ }
143
+ .filters button:hover:not(:disabled) { color: var(--ink); border-color: var(--axis); }
144
+ .filters button[aria-pressed="true"] {
145
+ color: var(--surface); background: var(--ink); border-color: var(--ink);
146
+ }
147
+ .filters button:disabled { opacity: 0.45; cursor: default; }
148
+ .filters button:focus-visible, [tabindex]:focus-visible {
149
+ outline: 2px solid var(--series); outline-offset: 2px;
150
+ }
151
+ .findings { display: grid; gap: 10px; }
152
+ .finding { padding: 16px 18px; display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 4px 12px; }
153
+ .finding[hidden] { display: none; }
154
+ .finding .sev { margin-top: 2px; }
155
+ .finding-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; }
156
+ .finding h3 { margin: 0; font-size: 15px; font-weight: 650; }
157
+ .finding .label { font-size: 12px; font-weight: 600; color: var(--ink-2); text-transform: uppercase; letter-spacing: 0.04em; }
158
+ .finding p code, .finding h3 code, .fact dd code, figcaption code { overflow-wrap: anywhere; }
159
+ .finding .fid { margin-left: auto; font-family: var(--mono); font-size: 12px; color: var(--muted); }
160
+ .finding p { margin: 2px 0 0; color: var(--ink-2); grid-column: 2; overflow-wrap: anywhere; }
161
+ .finding .hint { color: var(--ink); }
162
+ .finding .hint::before { content: "→ "; color: var(--muted); }
163
+
164
+ /* Charts */
165
+ .charts { display: grid; gap: 12px; grid-template-columns: minmax(0, 1fr); }
166
+ @media (min-width: 760px) { .charts { grid-template-columns: repeat(2, minmax(0, 1fr)); } .charts .wide { grid-column: 1 / -1; } }
167
+ figure { margin: 0; padding: 18px 18px 14px; min-width: 0; }
168
+ figcaption { margin-bottom: 14px; }
169
+ figcaption b { display: block; font-size: 15px; font-weight: 650; }
170
+ figcaption span { font-size: 13px; color: var(--ink-2); }
171
+ .statement { margin: 4px 0 6px; font-size: 20px; font-weight: 600; overflow-wrap: anywhere; }
172
+ .statement + .note { margin: 0; color: var(--ink-2); font-size: 13px; }
173
+
174
+ .hist { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 0 8px; }
175
+ .yaxis { position: relative; width: 36px; height: 180px; font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; }
176
+ .yaxis span { position: absolute; right: 0; transform: translateY(50%); }
177
+ .plot { position: relative; height: 180px; border-bottom: 1px solid var(--axis); }
178
+ .gridline { position: absolute; left: 0; right: 0; border-top: 1px solid var(--grid); }
179
+ .bins { position: absolute; inset: 0; display: flex; align-items: stretch; }
180
+ .bin { flex: 1 1 0; display: flex; align-items: flex-end; justify-content: center; padding: 0 1px; cursor: default; }
181
+ .bin > i {
182
+ display: block; width: min(24px, 100%); border-radius: 4px 4px 0 0;
183
+ background: var(--series); transition: filter 0.12s;
184
+ }
185
+ .bin:hover > i, .bin:focus-visible > i { filter: brightness(1.18) saturate(1.05); }
186
+ .refline { position: absolute; top: 0; bottom: 0; border-left: 1px solid var(--ink-2); pointer-events: none; }
187
+ .refline span {
188
+ position: absolute; top: -2px; left: 6px; white-space: nowrap;
189
+ font-size: 11px; color: var(--ink-2); background: var(--surface); padding: 0 3px;
190
+ }
191
+ .refline.flip span { left: auto; right: 6px; }
192
+ .xaxis { grid-column: 2; display: flex; justify-content: space-between; margin-top: 6px; font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; }
193
+ .axis-title { grid-column: 2; text-align: center; font-size: 11px; color: var(--muted); margin-top: 2px; }
194
+
195
+ .bars { display: grid; gap: 10px; }
196
+ .bar-row {
197
+ display: grid; grid-template-columns: minmax(80px, 38%) minmax(0, 1fr); gap: 12px; align-items: center;
198
+ border-radius: 6px; cursor: default;
199
+ }
200
+ .bar-row:hover .bar, .bar-row:focus-visible .bar { filter: brightness(1.18) saturate(1.05); }
201
+ .bar-label { font-size: 13px; color: var(--ink-2); overflow-wrap: anywhere; line-height: 1.3; }
202
+ .bar-cell { display: flex; align-items: center; gap: 8px; min-width: 0; }
203
+ .bar { height: 14px; min-width: 3px; border-radius: 0 4px 4px 0; background: var(--series); flex: none; }
204
+ .bar-value { font-size: 12px; color: var(--ink-2); white-space: nowrap; font-variant-numeric: tabular-nums; }
205
+
206
+ details.table-view { margin-top: 14px; font-size: 13px; }
207
+ details.table-view summary { cursor: pointer; color: var(--ink-2); width: fit-content; }
208
+ details.table-view summary:hover { color: var(--ink); }
209
+ .table-scroll { overflow-x: auto; }
210
+ table { border-collapse: collapse; width: 100%; margin-top: 8px; font-variant-numeric: tabular-nums; }
211
+ th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--grid); }
212
+ th { color: var(--ink-2); font-weight: 600; }
213
+ td.num, th.num { text-align: right; }
214
+
215
+ /* Setup facts */
216
+ .facts { display: grid; grid-template-columns: minmax(0, 1fr); margin: 0; padding: 4px 18px; }
217
+ @media (min-width: 720px) { .facts { grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 32px; } }
218
+ .fact { display: grid; grid-template-columns: minmax(96px, 40%) minmax(0, 1fr); gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--grid); }
219
+ .fact dt { color: var(--ink-2); font-size: 13px; }
220
+ .fact dd { margin: 0; font-size: 14px; overflow-wrap: anywhere; }
221
+ @media (min-width: 720px) { .fact:nth-last-child(-n + 2) { border-bottom: 0; } }
222
+ @media (max-width: 719px) { .fact:last-child { border-bottom: 0; } }
223
+
224
+ footer { margin-top: 32px; color: var(--muted); font-size: 12px; }
225
+ footer p { margin: 4px 0; }
226
+
227
+ /* Tooltip */
228
+ .tip {
229
+ position: fixed; z-index: 10; pointer-events: none; display: none;
230
+ padding: 7px 10px; border-radius: 8px; font-size: 12px; line-height: 1.35;
231
+ background: var(--ink); color: var(--surface); box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
232
+ max-width: 260px;
233
+ }
234
+ .tip strong { display: block; font-size: 13px; }
235
+ .tip span { opacity: 0.8; }
236
+
237
+ .sr-only {
238
+ position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
239
+ overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
240
+ }
241
+
242
+ @media print {
243
+ :root, :root[data-theme="dark"] {
244
+ color-scheme: light;
245
+ --page: #ffffff; --surface: #ffffff; --ink: #0b0b0b; --ink-2: #52514e;
246
+ --grid: #e1e0d9; --axis: #c3c2b7; --border: rgba(11, 11, 11, 0.15);
247
+ --series: #2a78d6; --info: #52514e; --shadow: none;
248
+ }
249
+ .filters, details.table-view summary { display: none; }
250
+ .card, figure { break-inside: avoid; }
251
+ }
252
+ @media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
@@ -0,0 +1,50 @@
1
+ // Tooltips and the severity filter. The report is fully readable without this script.
2
+ (() => {
3
+ const tip = document.createElement("div");
4
+ tip.className = "tip";
5
+ tip.setAttribute("role", "tooltip");
6
+ document.body.append(tip);
7
+
8
+ const show = (el, x, y) => {
9
+ tip.replaceChildren();
10
+ const value = document.createElement("strong");
11
+ value.textContent = el.dataset.tipValue || "";
12
+ const label = document.createElement("span");
13
+ label.textContent = el.dataset.tipLabel || "";
14
+ tip.append(value, label);
15
+ tip.style.display = "block";
16
+ const pad = 12;
17
+ const w = tip.offsetWidth;
18
+ const h = tip.offsetHeight;
19
+ let left = x - w / 2;
20
+ left = Math.max(pad, Math.min(left, window.innerWidth - w - pad));
21
+ let top = y - h - 14;
22
+ if (top < pad) top = y + 18;
23
+ tip.style.left = `${left}px`;
24
+ tip.style.top = `${top}px`;
25
+ };
26
+ const hide = () => { tip.style.display = "none"; };
27
+
28
+ document.querySelectorAll("[data-tip-value]").forEach((el) => {
29
+ el.addEventListener("pointermove", (e) => show(el, e.clientX, e.clientY));
30
+ el.addEventListener("pointerleave", hide);
31
+ el.addEventListener("focus", () => {
32
+ const r = el.getBoundingClientRect();
33
+ show(el, r.left + r.width / 2, r.top);
34
+ });
35
+ el.addEventListener("blur", hide);
36
+ });
37
+ window.addEventListener("scroll", hide, { passive: true });
38
+
39
+ const buttons = document.querySelectorAll(".filters button");
40
+ const findings = document.querySelectorAll(".finding");
41
+ buttons.forEach((button) => {
42
+ button.addEventListener("click", () => {
43
+ const severity = button.dataset.filter;
44
+ buttons.forEach((b) => b.setAttribute("aria-pressed", String(b === button)));
45
+ findings.forEach((f) => {
46
+ f.hidden = severity !== "all" && f.dataset.severity !== severity;
47
+ });
48
+ });
49
+ });
50
+ })();
@@ -0,0 +1,18 @@
1
+ """``vecshift bench``: compare embedding models on a sample of your own data."""
2
+
3
+ from vecshift.bench.corpus import Benchmark, CorpusError, Document, Query
4
+ from vecshift.bench.metrics import Scores
5
+ from vecshift.bench.runner import BenchResult, ModelResult, PlanItem, plan, run
6
+
7
+ __all__ = [
8
+ "BenchResult",
9
+ "Benchmark",
10
+ "CorpusError",
11
+ "Document",
12
+ "ModelResult",
13
+ "PlanItem",
14
+ "Query",
15
+ "Scores",
16
+ "plan",
17
+ "run",
18
+ ]
@@ -0,0 +1,175 @@
1
+ """Documents and queries for a benchmark."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import random
7
+ import re
8
+ from collections import Counter
9
+ from collections.abc import Iterable, Sequence
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+
13
+ MAX_CHARS = 8000
14
+ """Longer documents are cut to this length, well inside common model input limits."""
15
+
16
+
17
+ class CorpusError(ValueError):
18
+ """Documents or queries couldn't be loaded."""
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class Document:
23
+ id: str
24
+ text: str
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class Query:
29
+ text: str
30
+ relevant: frozenset[str]
31
+ """IDs of the documents that answer this query."""
32
+
33
+
34
+ @dataclass(slots=True)
35
+ class Benchmark:
36
+ documents: list[Document]
37
+ queries: list[Query]
38
+ query_source: str
39
+ """``proxy``, ``generated``, or ``labeled``."""
40
+ notes: list[str] = field(default_factory=list)
41
+
42
+
43
+ def clip(text: str) -> str:
44
+ return text if len(text) <= MAX_CHARS else text[:MAX_CHARS]
45
+
46
+
47
+ def _read_jsonl(path: Path) -> Iterable[tuple[int, dict[str, object]]]:
48
+ try:
49
+ lines = path.read_text(encoding="utf-8").splitlines()
50
+ except OSError as exc:
51
+ raise CorpusError(f"Couldn't read {path}: {exc.strerror}") from exc
52
+ for number, line in enumerate(lines, 1):
53
+ if not line.strip():
54
+ continue
55
+ try:
56
+ row = json.loads(line)
57
+ except json.JSONDecodeError as exc:
58
+ raise CorpusError(f"{path}:{number}: not valid JSON ({exc.msg})") from exc
59
+ if not isinstance(row, dict):
60
+ raise CorpusError(f"{path}:{number}: expected a JSON object")
61
+ yield number, row
62
+
63
+
64
+ def load_documents(path: Path) -> list[Document]:
65
+ """Read ``{"id": ..., "text": ...}`` lines. ``id`` is optional and defaults to the line."""
66
+ docs: list[Document] = []
67
+ seen: set[str] = set()
68
+ for number, row in _read_jsonl(path):
69
+ text = row.get("text")
70
+ if not isinstance(text, str):
71
+ raise CorpusError(f'{path}:{number}: each line needs a "text" string')
72
+ if not text.strip():
73
+ continue
74
+ doc_id = str(row.get("id", number))
75
+ if doc_id in seen:
76
+ raise CorpusError(f"{path}:{number}: duplicate id {doc_id!r}")
77
+ seen.add(doc_id)
78
+ docs.append(Document(doc_id, clip(text)))
79
+ if not docs:
80
+ raise CorpusError(f"{path} has no documents")
81
+ return docs
82
+
83
+
84
+ def load_queries(path: Path) -> list[Query]:
85
+ """Read ``{"query": ..., "relevant": [ids]}`` lines."""
86
+ queries: list[Query] = []
87
+ for number, row in _read_jsonl(path):
88
+ text, relevant = row.get("query"), row.get("relevant")
89
+ if not isinstance(text, str) or not text.strip():
90
+ raise CorpusError(f'{path}:{number}: each line needs a "query" string')
91
+ if isinstance(relevant, (str, int)):
92
+ relevant = [relevant]
93
+ if not isinstance(relevant, list) or not relevant:
94
+ raise CorpusError(f'{path}:{number}: "relevant" must list at least one document id')
95
+ queries.append(Query(text.strip(), frozenset(str(r) for r in relevant)))
96
+ if not queries:
97
+ raise CorpusError(f"{path} has no queries")
98
+ return queries
99
+
100
+
101
+ def save_queries(path: Path, queries: Sequence[Query]) -> None:
102
+ lines = [
103
+ json.dumps({"query": q.text, "relevant": sorted(q.relevant)}, ensure_ascii=False)
104
+ for q in queries
105
+ ]
106
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
107
+
108
+
109
+ def sample(docs: Sequence[Document], n: int, keep: set[str], seed: int) -> list[Document]:
110
+ """Up to ``n`` documents, always including the IDs in ``keep``."""
111
+ kept = [d for d in docs if d.id in keep]
112
+ rest = [d for d in docs if d.id not in keep]
113
+ room = max(0, n - len(kept))
114
+ chosen = random.Random(seed).sample(rest, min(room, len(rest)))
115
+ return kept + chosen
116
+
117
+
118
+ def _norm(sentence: str) -> str:
119
+ return " ".join(sentence.lower().split())
120
+
121
+
122
+ _SENTENCE = re.compile(r"(?<=[.!?])\s+(?=[A-Z0-9\"'(])")
123
+ _WORDS = re.compile(r"\S+")
124
+
125
+
126
+ def proxy_queries(
127
+ docs: Sequence[Document], n: int, seed: int
128
+ ) -> tuple[list[Document], list[Query], list[str]]:
129
+ """Make queries from the documents themselves, with no LLM.
130
+
131
+ For each chosen document, one sentence becomes the query and is removed from the
132
+ document, so a model has to match it by meaning to the rest of the text (the
133
+ inverse cloze task). Single-sentence documents use their opening words instead.
134
+ Returns the edited documents, the queries, and notes for the report.
135
+ """
136
+ rng = random.Random(seed)
137
+ split = {doc.id: [s for s in _SENTENCE.split(doc.text.strip()) if s.strip()] for doc in docs}
138
+ # A sentence repeated across documents (boilerplate, templates) would make the true
139
+ # document the only one *without* the query text, so only unique sentences qualify.
140
+ counts = Counter(_norm(s) for sentences in split.values() for s in set(sentences))
141
+ candidates: dict[str, tuple[str, str]] = {}
142
+ for doc in docs:
143
+ sentences = split[doc.id]
144
+ usable = [
145
+ i
146
+ for i, s in enumerate(sentences)
147
+ if 6 <= len(_WORDS.findall(s)) <= 40 and counts[_norm(s)] == 1
148
+ ]
149
+ if len(sentences) >= 2 and usable:
150
+ i = rng.choice(usable)
151
+ rest = " ".join(s for j, s in enumerate(sentences) if j != i)
152
+ if len(_WORDS.findall(rest)) >= 8:
153
+ candidates[doc.id] = (sentences[i], rest)
154
+ continue
155
+ words = _WORDS.findall(doc.text)
156
+ if len(words) >= 20:
157
+ cut = min(12, len(words) // 3)
158
+ candidates[doc.id] = (" ".join(words[:cut]), " ".join(words[cut:]))
159
+
160
+ if not candidates:
161
+ raise CorpusError(
162
+ "The documents are too short to make proxy queries from. Use --generate-queries "
163
+ "or --queries instead."
164
+ )
165
+ chosen = set(rng.sample(sorted(candidates), min(n, len(candidates))))
166
+ edited = [Document(d.id, candidates[d.id][1]) if d.id in chosen else d for d in docs]
167
+ queries = [Query(candidates[i][0], frozenset({i})) for i in sorted(chosen)]
168
+ notes = [
169
+ "Queries are sentences taken out of the documents (a proxy, no LLM involved). "
170
+ "That's good for ranking models against each other; use --generate-queries or "
171
+ "--queries for numbers closer to real search."
172
+ ]
173
+ if len(chosen) < n:
174
+ notes.append(f"Only {len(chosen)} documents were long enough to make queries from.")
175
+ return edited, queries, notes