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.
- vecshift/__init__.py +15 -0
- vecshift/assets/bench.css +39 -0
- vecshift/assets/eval.css +91 -0
- vecshift/assets/report.css +252 -0
- vecshift/assets/report.js +50 -0
- vecshift/bench/__init__.py +18 -0
- vecshift/bench/corpus.py +175 -0
- vecshift/bench/generate.py +106 -0
- vecshift/bench/html.py +325 -0
- vecshift/bench/metrics.py +50 -0
- vecshift/bench/runner.py +183 -0
- vecshift/cli.py +236 -0
- vecshift/cli_apply.py +283 -0
- vecshift/cli_bench.py +354 -0
- vecshift/cli_cutover.py +431 -0
- vecshift/cli_eval.py +591 -0
- vecshift/cli_plan.py +335 -0
- vecshift/cli_style.py +57 -0
- vecshift/connectors/__init__.py +1 -0
- vecshift/connectors/pgvector/__init__.py +29 -0
- vecshift/connectors/pgvector/connection.py +155 -0
- vecshift/connectors/pgvector/documents.py +96 -0
- vecshift/connectors/pgvector/inspect.py +427 -0
- vecshift/connectors/pgvector/search.py +240 -0
- vecshift/connectors/pgvector/switch.py +481 -0
- vecshift/connectors/pgvector/target.py +195 -0
- vecshift/connectors/pgvector/writer.py +431 -0
- vecshift/core/__init__.py +4 -0
- vecshift/core/capabilities.py +33 -0
- vecshift/core/contracts.py +57 -0
- vecshift/core/fingerprint.py +57 -0
- vecshift/core/record.py +75 -0
- vecshift/doctor/__init__.py +15 -0
- vecshift/doctor/checks.py +490 -0
- vecshift/doctor/findings.py +78 -0
- vecshift/doctor/html.py +493 -0
- vecshift/doctor/profile.py +69 -0
- vecshift/embeddings/__init__.py +22 -0
- vecshift/embeddings/cache.py +86 -0
- vecshift/embeddings/providers.py +244 -0
- vecshift/embeddings/spec.py +240 -0
- vecshift/eval/__init__.py +20 -0
- vecshift/eval/html.py +444 -0
- vecshift/eval/metrics.py +81 -0
- vecshift/eval/queries.py +97 -0
- vecshift/eval/runner.py +394 -0
- vecshift/html_kit.py +143 -0
- vecshift/jobs/__init__.py +5 -0
- vecshift/jobs/spec.py +202 -0
- vecshift/migrate/__init__.py +6 -0
- vecshift/migrate/engine.py +272 -0
- vecshift/migrate/state.py +50 -0
- vecshift/planning/__init__.py +14 -0
- vecshift/planning/plan.py +87 -0
- vecshift/planning/planner.py +493 -0
- vecshift/py.typed +0 -0
- vecshift-0.1.0.dist-info/METADATA +264 -0
- vecshift-0.1.0.dist-info/RECORD +62 -0
- vecshift-0.1.0.dist-info/WHEEL +4 -0
- vecshift-0.1.0.dist-info/entry_points.txt +2 -0
- vecshift-0.1.0.dist-info/licenses/LICENSE +202 -0
- 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); }
|
vecshift/assets/eval.css
ADDED
|
@@ -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
|
+
]
|
vecshift/bench/corpus.py
ADDED
|
@@ -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
|