voyageai-cli 1.10.0 → 1.12.0

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.
@@ -551,6 +551,83 @@ select:focus { outline: none; border-color: var(--accent); }
551
551
  .rank-differ { border-left-color: var(--yellow); }
552
552
  .rank-arrow { text-align: center; color: var(--text-muted); font-size: 18px; padding-top: 4px; }
553
553
 
554
+ /* Quantization charts */
555
+ .quant-charts { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 16px; }
556
+ @media (max-width: 768px) { .quant-charts { grid-template-columns: 1fr; } }
557
+
558
+ .quant-bar-group { margin-bottom: 14px; }
559
+ .quant-bar-label {
560
+ display: flex; justify-content: space-between; align-items: baseline;
561
+ margin-bottom: 4px; font-size: 13px;
562
+ }
563
+ .quant-bar-label .dtype-name { color: var(--accent); font-weight: 600; font-family: var(--mono); }
564
+ .quant-bar-label .dtype-value { color: var(--text-dim); font-family: var(--mono); font-size: 12px; }
565
+ .quant-bar-track {
566
+ height: 32px; background: var(--bg-input); border-radius: 6px;
567
+ overflow: hidden; position: relative;
568
+ }
569
+ .quant-bar-fill {
570
+ height: 100%; border-radius: 6px;
571
+ transition: width 0.8s cubic-bezier(0.22, 1, 0.36, 1);
572
+ display: flex; align-items: center; padding: 0 10px;
573
+ font-family: var(--mono); font-size: 12px; font-weight: 600;
574
+ color: #0a0a1a; white-space: nowrap; min-width: fit-content;
575
+ }
576
+ .quant-bar-fill.storage { background: linear-gradient(90deg, #00d4aa, #4ecdc4); }
577
+ .quant-bar-fill.latency { background: linear-gradient(90deg, #45b7d1, #82aaff); }
578
+ .quant-bar-badge {
579
+ position: absolute; right: 10px; top: 50%; transform: translateY(-50%);
580
+ font-size: 12px; color: var(--text-dim); font-family: var(--mono);
581
+ }
582
+
583
+ .quant-quality-meter { margin-bottom: 14px; }
584
+ .quant-meter-header {
585
+ display: flex; justify-content: space-between; align-items: center;
586
+ margin-bottom: 6px;
587
+ }
588
+ .quant-meter-header .dtype-name { color: var(--accent); font-weight: 600; font-family: var(--mono); font-size: 13px; }
589
+ .quant-meter-header .verdict-badge {
590
+ font-size: 12px; padding: 2px 8px; border-radius: 10px; font-weight: 600;
591
+ }
592
+ .quant-meter-header .verdict-badge.perfect { background: rgba(0,212,170,0.15); color: var(--green); }
593
+ .quant-meter-header .verdict-badge.good { background: rgba(255,217,61,0.15); color: var(--yellow); }
594
+ .quant-meter-header .verdict-badge.degraded { background: rgba(255,107,107,0.15); color: var(--red); }
595
+ .quant-meter-track {
596
+ height: 10px; background: var(--bg-input); border-radius: 5px; overflow: hidden;
597
+ }
598
+ .quant-meter-fill {
599
+ height: 100%; border-radius: 5px;
600
+ transition: width 0.8s cubic-bezier(0.22, 1, 0.36, 1);
601
+ }
602
+ .quant-meter-fill.perfect { background: linear-gradient(90deg, #00d4aa, #00e4ba); }
603
+ .quant-meter-fill.good { background: linear-gradient(90deg, #ffd93d, #ffe066); }
604
+ .quant-meter-fill.degraded { background: linear-gradient(90deg, #ff6b6b, #ff8e8e); }
605
+ .quant-meter-detail { font-size: 11px; color: var(--text-muted); margin-top: 4px; font-family: var(--mono); }
606
+
607
+ .quant-rank-cols {
608
+ display: grid; gap: 12px;
609
+ }
610
+ .quant-rank-col-header {
611
+ font-weight: 600; color: var(--accent); font-size: 13px; font-family: var(--mono);
612
+ margin-bottom: 8px; padding-bottom: 6px; border-bottom: 1px solid var(--border);
613
+ }
614
+ .quant-rank-item {
615
+ padding: 8px 10px; margin-bottom: 4px; border-radius: 6px;
616
+ font-size: 12px; position: relative; border-left: 3px solid transparent;
617
+ transition: background 0.2s;
618
+ }
619
+ .quant-rank-item:hover { background: rgba(255,255,255,0.03); }
620
+ .quant-rank-item.match { border-left-color: var(--green); background: rgba(0,212,170,0.06); }
621
+ .quant-rank-item.differ { border-left-color: var(--red); background: rgba(255,107,107,0.06); }
622
+ .quant-rank-item.baseline { border-left-color: var(--border); background: var(--bg-input); }
623
+ .quant-rank-pos {
624
+ display: inline-block; width: 22px; height: 22px; line-height: 22px;
625
+ text-align: center; border-radius: 50%; background: var(--bg-surface);
626
+ color: var(--accent); font-weight: 700; font-size: 11px; font-family: var(--mono);
627
+ margin-right: 8px;
628
+ }
629
+ .quant-rank-score { color: var(--text-muted); font-size: 11px; font-family: var(--mono); margin-top: 3px; }
630
+
554
631
  /* Cost calculator */
555
632
  .cost-slider-row {
556
633
  display: flex;
@@ -680,6 +757,57 @@ select:focus { outline: none; border-color: var(--accent); }
680
757
  margin-top: 4px;
681
758
  }
682
759
 
760
+ /* About page */
761
+ .about-container { max-width: 680px; margin: 0 auto; }
762
+ .about-header { display: flex; gap: 24px; align-items: center; margin-bottom: 24px; }
763
+ .about-avatar {
764
+ width: 120px; height: 120px;
765
+ border-radius: 50%;
766
+ border: 3px solid var(--accent);
767
+ box-shadow: 0 0 20px var(--accent-glow);
768
+ flex-shrink: 0;
769
+ }
770
+ .about-name { font-size: 24px; font-weight: 700; color: var(--text); }
771
+ .about-role { font-size: 14px; color: var(--accent); margin-top: 4px; }
772
+ .about-links { display: flex; gap: 12px; margin-top: 8px; }
773
+ .about-links a {
774
+ color: var(--text-dim);
775
+ font-size: 13px;
776
+ text-decoration: none;
777
+ transition: color 0.2s;
778
+ }
779
+ .about-links a:hover { color: var(--accent); }
780
+ .about-section { margin-bottom: 24px; }
781
+ .about-section-title {
782
+ font-size: 13px;
783
+ font-weight: 600;
784
+ color: var(--accent);
785
+ text-transform: uppercase;
786
+ letter-spacing: 0.5px;
787
+ margin-bottom: 8px;
788
+ }
789
+ .about-text { font-size: 14px; line-height: 1.8; color: var(--text); }
790
+ .about-text a { color: var(--accent); text-decoration: none; }
791
+ .about-text a:hover { text-decoration: underline; }
792
+ .about-disclaimer {
793
+ background: rgba(255, 215, 61, 0.08);
794
+ border: 1px solid rgba(255, 215, 61, 0.2);
795
+ border-radius: var(--radius);
796
+ padding: 16px 20px;
797
+ margin-top: 24px;
798
+ }
799
+ .about-disclaimer-title {
800
+ font-size: 13px;
801
+ font-weight: 600;
802
+ color: var(--warning);
803
+ margin-bottom: 6px;
804
+ }
805
+ .about-disclaimer-text {
806
+ font-size: 13px;
807
+ line-height: 1.7;
808
+ color: var(--text-dim);
809
+ }
810
+
683
811
  @media (max-width: 768px) {
684
812
  .compare-grid, .search-results { grid-template-columns: 1fr; }
685
813
  .nav { padding: 0 12px; }
@@ -711,6 +839,7 @@ select:focus { outline: none; border-color: var(--accent); }
711
839
  <button class="tab-btn" data-tab="search">🔍 Search</button>
712
840
  <button class="tab-btn" data-tab="benchmark">⏱ Benchmark</button>
713
841
  <button class="tab-btn" data-tab="explore">📚 Explore</button>
842
+ <button class="tab-btn" data-tab="about">ℹ️ About</button>
714
843
  </div>
715
844
 
716
845
  <div class="main">
@@ -745,6 +874,16 @@ select:focus { outline: none; border-color: var(--accent); }
745
874
  <option value="2048">2048</option>
746
875
  </select>
747
876
  </div>
877
+ <div class="option-group">
878
+ <span class="option-label">Output Type</span>
879
+ <select id="embedOutputDtype">
880
+ <option value="float">float (32-bit)</option>
881
+ <option value="int8">int8 (4× smaller)</option>
882
+ <option value="uint8">uint8 (4× smaller)</option>
883
+ <option value="binary">binary (32× smaller)</option>
884
+ <option value="ubinary">ubinary (32× smaller)</option>
885
+ </select>
886
+ </div>
748
887
  <button class="btn" id="embedBtn" onclick="doEmbed()">⚡ Embed</button>
749
888
  </div>
750
889
 
@@ -864,6 +1003,7 @@ Semantic search understands meaning beyond keyword matching</textarea>
864
1003
  <div class="bench-panels">
865
1004
  <button class="bench-panel-btn active" data-bench="latency">⚡ Latency</button>
866
1005
  <button class="bench-panel-btn" data-bench="ranking">🏆 Ranking</button>
1006
+ <button class="bench-panel-btn" data-bench="quantization">⚗️ Quantization</button>
867
1007
  <button class="bench-panel-btn" data-bench="cost">💰 Cost</button>
868
1008
  <button class="bench-panel-btn" data-bench="history">📊 History</button>
869
1009
  </div>
@@ -956,6 +1096,91 @@ Reranking models rescore initial search results to improve relevance ordering.</
956
1096
  </div>
957
1097
  </div>
958
1098
 
1099
+ <!-- ── Quantization Panel ── -->
1100
+ <div class="bench-view" id="bench-quantization">
1101
+ <div class="card">
1102
+ <div class="card-title">Quantization Benchmark</div>
1103
+ <p style="color:var(--text-dim);font-size:13px;margin-bottom:12px;">
1104
+ Compare how different output data types (float, int8, binary) affect storage size and ranking quality.
1105
+ Embeds the same corpus with each dtype and measures the tradeoff.
1106
+ </p>
1107
+ <div class="options-row" style="flex-wrap:wrap;">
1108
+ <div class="option-group">
1109
+ <span class="option-label">Model</span>
1110
+ <select id="quantModel"></select>
1111
+ </div>
1112
+ <div class="option-group">
1113
+ <span class="option-label">Dimensions</span>
1114
+ <select id="quantDimensions">
1115
+ <option value="">Default</option>
1116
+ <option value="256">256</option>
1117
+ <option value="512">512</option>
1118
+ <option value="1024">1024</option>
1119
+ <option value="2048">2048</option>
1120
+ </select>
1121
+ </div>
1122
+ <div class="option-group">
1123
+ <span class="option-label">Data Types</span>
1124
+ <div id="quantDtypeChecks" style="display:flex;gap:8px;flex-wrap:wrap;">
1125
+ <label style="display:flex;align-items:center;gap:4px;font-size:13px;cursor:pointer;color:var(--text);">
1126
+ <input type="checkbox" value="float" checked style="accent-color:var(--accent);">float
1127
+ </label>
1128
+ <label style="display:flex;align-items:center;gap:4px;font-size:13px;cursor:pointer;color:var(--text);">
1129
+ <input type="checkbox" value="int8" checked style="accent-color:var(--accent);">int8
1130
+ </label>
1131
+ <label style="display:flex;align-items:center;gap:4px;font-size:13px;cursor:pointer;color:var(--text);">
1132
+ <input type="checkbox" value="uint8" style="accent-color:var(--accent);">uint8
1133
+ </label>
1134
+ <label style="display:flex;align-items:center;gap:4px;font-size:13px;cursor:pointer;color:var(--text);">
1135
+ <input type="checkbox" value="ubinary" checked style="accent-color:var(--accent);">ubinary
1136
+ </label>
1137
+ <label style="display:flex;align-items:center;gap:4px;font-size:13px;cursor:pointer;color:var(--text);">
1138
+ <input type="checkbox" value="binary" style="accent-color:var(--accent);">binary
1139
+ </label>
1140
+ </div>
1141
+ </div>
1142
+ </div>
1143
+ <div style="margin-top:12px;">
1144
+ <span class="option-label">Query</span>
1145
+ <input type="text" id="quantQuery" placeholder="Search query..." value="How do I search for similar documents using embeddings?" style="width:100%;margin-bottom:8px;">
1146
+ </div>
1147
+ <div>
1148
+ <span class="option-label">Corpus (one document per line)</span>
1149
+ <textarea id="quantCorpus" rows="5" placeholder="Documents to embed...">Vector search finds documents by computing similarity between embedding vectors in high-dimensional space.
1150
+ MongoDB Atlas Vector Search lets you index and query vector embeddings alongside your operational data.
1151
+ Traditional full-text search uses inverted indexes to match keyword terms in documents.
1152
+ Cosine similarity measures the angle between two vectors, commonly used for semantic search.
1153
+ Database sharding distributes data across multiple servers for horizontal scalability.
1154
+ Embedding models convert text into dense numerical vectors that capture meaning.
1155
+ Approximate nearest neighbor algorithms like HNSW enable fast similarity search at scale.
1156
+ Reranking models rescore initial search results to improve relevance ordering.</textarea>
1157
+ </div>
1158
+ <div style="margin-top:12px;">
1159
+ <button class="btn" id="quantBtn" onclick="doBenchQuantization()">⚗️ Run Quantization Benchmark</button>
1160
+ </div>
1161
+ </div>
1162
+
1163
+ <div class="error-msg" id="quantError"></div>
1164
+
1165
+ <div class="result-section" id="quantResult">
1166
+ <div class="quant-charts">
1167
+ <div class="card">
1168
+ <div class="card-title">📦 Storage per Vector</div>
1169
+ <div id="quantStorageChart"></div>
1170
+ </div>
1171
+ <div class="card">
1172
+ <div class="card-title">⏱ API Latency</div>
1173
+ <div id="quantLatencyChart"></div>
1174
+ </div>
1175
+ </div>
1176
+ <div class="card">
1177
+ <div class="card-title">🎯 Ranking Quality vs Float Baseline</div>
1178
+ <div id="quantQualityMeters" style="margin-bottom:16px;"></div>
1179
+ <div id="quantRankGrid"></div>
1180
+ </div>
1181
+ </div>
1182
+ </div>
1183
+
959
1184
  <!-- ── Cost Panel ── -->
960
1185
  <div class="bench-view" id="bench-cost">
961
1186
  <div class="card">
@@ -1001,6 +1226,73 @@ Reranking models rescore initial search results to improve relevance ordering.</
1001
1226
 
1002
1227
  </div>
1003
1228
 
1229
+ <!-- ========== ABOUT TAB ========== -->
1230
+ <div class="tab-panel" id="tab-about">
1231
+ <div class="about-container">
1232
+ <div class="card">
1233
+ <div class="about-header">
1234
+ <img src="https://avatars.githubusercontent.com/u/192552?v=4" alt="Michael Lynn" class="about-avatar">
1235
+ <div>
1236
+ <div class="about-name">Michael Lynn</div>
1237
+ <div class="about-role">Principal Staff Developer Advocate · MongoDB</div>
1238
+ <div class="about-links">
1239
+ <a href="https://github.com/mrlynn" target="_blank" rel="noopener">🔗 GitHub</a>
1240
+ <a href="https://mlynn.org" target="_blank" rel="noopener">🌐 mlynn.org</a>
1241
+ <a href="https://www.npmjs.com/package/voyageai-cli" target="_blank" rel="noopener">📦 npm</a>
1242
+ </div>
1243
+ </div>
1244
+ </div>
1245
+
1246
+ <div class="about-section">
1247
+ <div class="about-section-title">About This Project</div>
1248
+ <div class="about-text">
1249
+ <strong>voyageai-cli</strong> (<code style="color:var(--accent);">vai</code>) is a community-built command-line tool for working with
1250
+ <a href="https://www.mongodb.com/docs/voyageai/" target="_blank">Voyage AI</a> embeddings, reranking, and
1251
+ <a href="https://www.mongodb.com/products/platform/atlas-vector-search" target="_blank">MongoDB Atlas Vector Search</a>.
1252
+ It was created to make it easier for developers to explore, benchmark, and integrate
1253
+ Voyage AI models into their applications — right from the terminal or this playground.
1254
+ </div>
1255
+ </div>
1256
+
1257
+ <div class="about-section">
1258
+ <div class="about-section-title">About Michael</div>
1259
+ <div class="about-text">
1260
+ Michael Lynn is a Principal Staff Developer Advocate at MongoDB with 25+ years in enterprise
1261
+ infrastructure and over a decade at MongoDB. He focuses on strategic developer relations,
1262
+ creating educational content around Vector Search, AI enablement, and developer tooling.
1263
+ He builds tools like this to help developers get hands-on with new technology faster.
1264
+ </div>
1265
+ </div>
1266
+
1267
+ <div class="about-section">
1268
+ <div class="about-section-title">What You Can Do Here</div>
1269
+ <div class="about-text">
1270
+ <strong>⚡ Embed</strong> — Generate vector embeddings for any text<br>
1271
+ <strong>⚖️ Compare</strong> — Measure cosine similarity between texts<br>
1272
+ <strong>🔍 Search</strong> — Semantic search with optional reranking<br>
1273
+ <strong>⏱ Benchmark</strong> — Compare model latency, ranking quality, and costs<br>
1274
+ <strong>📚 Explore</strong> — Learn about embeddings, vector search, RAG, and more
1275
+ </div>
1276
+ </div>
1277
+
1278
+ <div class="about-disclaimer">
1279
+ <div class="about-disclaimer-title">⚠️ Community Tool Disclaimer</div>
1280
+ <div class="about-disclaimer-text">
1281
+ This tool is <strong>not</strong> an official product of MongoDB, Inc. or Voyage AI.
1282
+ It is independently built and maintained by Michael Lynn as a community resource.
1283
+ It is not supported, endorsed, or guaranteed by either company. Use at your own discretion.
1284
+ For official documentation, visit
1285
+ <a href="https://www.mongodb.com/docs/voyageai/" target="_blank" style="color:var(--warning);">mongodb.com/docs/voyageai</a>.
1286
+ </div>
1287
+ </div>
1288
+ </div>
1289
+
1290
+ <div style="text-align:center;margin-top:16px;font-size:12px;color:var(--text-muted);">
1291
+ Made with ☕ and curiosity · <a href="https://github.com/mrlynn/voyageai-cli" target="_blank" style="color:var(--text-dim);">Source on GitHub</a>
1292
+ </div>
1293
+ </div>
1294
+ </div>
1295
+
1004
1296
  <!-- ========== EXPLORE TAB ========== -->
1005
1297
  <div class="tab-panel" id="tab-explore">
1006
1298
  <div style="margin-bottom:16px;">
@@ -1119,6 +1411,12 @@ function populateModelSelects() {
1119
1411
  }
1120
1412
 
1121
1413
  // ── API Helpers ──
1414
+ function formatBytesUI(bytes) {
1415
+ if (bytes >= 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
1416
+ if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
1417
+ return bytes + ' B';
1418
+ }
1419
+
1122
1420
  async function apiPost(url, body) {
1123
1421
  const res = await fetch(url, {
1124
1422
  method: 'POST',
@@ -1165,16 +1463,29 @@ window.doEmbed = async function() {
1165
1463
  const dims = document.getElementById('embedDimensions').value;
1166
1464
  const dimensions = dims ? parseInt(dims, 10) : undefined;
1167
1465
 
1168
- const data = await apiPost('/api/embed', { texts: [text], model, inputType, dimensions });
1466
+ const outputDtype = document.getElementById('embedOutputDtype').value;
1467
+ const body = { texts: [text], model, inputType, dimensions };
1468
+ if (outputDtype && outputDtype !== 'float') body.output_dtype = outputDtype;
1469
+
1470
+ const data = await apiPost('/api/embed', body);
1169
1471
  const emb = data.data[0].embedding;
1170
1472
  lastEmbedding = emb;
1171
1473
 
1172
1474
  // Stats
1475
+ const dtype = outputDtype || 'float';
1476
+ const bytesPerDim = (dtype === 'binary' || dtype === 'ubinary') ? 0.125 : (dtype === 'int8' || dtype === 'uint8') ? 1 : 4;
1477
+ const totalBytes = emb.length * bytesPerDim;
1478
+ const storageLine = dtype !== 'float'
1479
+ ? `<br><span style="color:var(--success)">📦 ${dtype}: ${formatBytesUI(totalBytes)}/vector (${(4 * emb.length / totalBytes).toFixed(0)}× smaller than float)</span>`
1480
+ : '';
1481
+
1173
1482
  const statsEl = document.getElementById('embedStats');
1174
1483
  statsEl.innerHTML = `
1175
1484
  <span class="stat"><span class="stat-label">Model</span><span class="stat-value">${data.model}</span></span>
1176
1485
  <span class="stat"><span class="stat-label">Dimensions</span><span class="stat-value">${emb.length}</span></span>
1177
1486
  <span class="stat"><span class="stat-label">Tokens</span><span class="stat-value">${data.usage?.total_tokens || '—'}</span></span>
1487
+ <span class="stat"><span class="stat-label">Type</span><span class="stat-value">${dtype}</span></span>
1488
+ ${storageLine}
1178
1489
  `;
1179
1490
 
1180
1491
  // Vector preview
@@ -1410,6 +1721,7 @@ const CONCEPT_META = {
1410
1721
  'api-access': { icon: '🌐', tab: 'embed' },
1411
1722
  'batch-processing': { icon: '📦', tab: 'embed' },
1412
1723
  benchmarking: { icon: '⏱', tab: 'benchmark' },
1724
+ quantization: { icon: '⚗️', tab: 'benchmark' },
1413
1725
  };
1414
1726
 
1415
1727
  let exploreConcepts = {};
@@ -1797,6 +2109,222 @@ function renderRankComparison(modelA, modelB, rankedA, rankedB, topK) {
1797
2109
  }
1798
2110
  }
1799
2111
 
2112
+ // ── Benchmark: Quantization ──
2113
+ function populateQuantModelSelect() {
2114
+ const sel = document.getElementById('quantModel');
2115
+ sel.innerHTML = '';
2116
+ embedModels.forEach(m => {
2117
+ const opt = document.createElement('option');
2118
+ opt.value = m.name;
2119
+ opt.textContent = m.name;
2120
+ sel.appendChild(opt);
2121
+ });
2122
+ // Default to voyage-4-large if available
2123
+ const preferred = embedModels.find(m => m.name === 'voyage-4-large');
2124
+ if (preferred) sel.value = preferred.name;
2125
+ }
2126
+
2127
+ function hammingSimUI(a, b) {
2128
+ // For binary/ubinary packed embeddings, compute agreement via dot product
2129
+ let dot = 0;
2130
+ for (let i = 0; i < a.length; i++) dot += a[i] * b[i];
2131
+ return dot;
2132
+ }
2133
+
2134
+ window.doBenchQuantization = async function() {
2135
+ hideError('quantError');
2136
+ const model = document.getElementById('quantModel').value;
2137
+ const dimsVal = document.getElementById('quantDimensions').value;
2138
+ const dimensions = dimsVal ? parseInt(dimsVal, 10) : undefined;
2139
+ const query = document.getElementById('quantQuery').value.trim();
2140
+ const corpusText = document.getElementById('quantCorpus').value.trim();
2141
+
2142
+ if (!query) { showError('quantError', 'Enter a query'); return; }
2143
+ if (!corpusText) { showError('quantError', 'Enter at least 2 documents'); return; }
2144
+
2145
+ const corpus = corpusText.split('\n').map(d => d.trim()).filter(Boolean);
2146
+ if (corpus.length < 2) { showError('quantError', 'Enter at least 2 documents'); return; }
2147
+
2148
+ const checks = document.querySelectorAll('#quantDtypeChecks input:checked');
2149
+ const dtypes = Array.from(checks).map(c => c.value);
2150
+ if (dtypes.length === 0) { showError('quantError', 'Select at least one data type'); return; }
2151
+
2152
+ setLoading('quantBtn', true);
2153
+
2154
+ try {
2155
+ const allTexts = [query, ...corpus];
2156
+ const resultsByDtype = {};
2157
+
2158
+ for (const dtype of dtypes) {
2159
+ const body = { texts: allTexts, model, inputType: 'document' };
2160
+ if (dimensions) body.dimensions = dimensions;
2161
+ if (dtype !== 'float') body.output_dtype = dtype;
2162
+
2163
+ const start = performance.now();
2164
+ const data = await apiPost('/api/embed', body);
2165
+ const elapsed = performance.now() - start;
2166
+
2167
+ const embeddings = data.data.map(d => d.embedding);
2168
+ const queryEmbed = embeddings[0];
2169
+ const dims = embeddings[0].length;
2170
+ const isBinary = (dtype === 'binary' || dtype === 'ubinary');
2171
+
2172
+ // Rank corpus documents by similarity
2173
+ const ranked = corpus.map((text, i) => {
2174
+ const docEmbed = embeddings[i + 1];
2175
+ let sim;
2176
+ if (isBinary) {
2177
+ sim = hammingSimUI(queryEmbed, docEmbed);
2178
+ } else {
2179
+ sim = cosineSim(queryEmbed, docEmbed);
2180
+ }
2181
+ return { index: i, text, similarity: sim };
2182
+ }).sort((a, b) => b.similarity - a.similarity);
2183
+
2184
+ // Calculate storage
2185
+ const actualDims = isBinary ? dims * 8 : dims;
2186
+ let bytesPerVec;
2187
+ if (dtype === 'float') bytesPerVec = dims * 4;
2188
+ else if (dtype === 'int8' || dtype === 'uint8') bytesPerVec = dims * 1;
2189
+ else bytesPerVec = dims; // binary/ubinary: dims is already 1/8th
2190
+
2191
+ resultsByDtype[dtype] = {
2192
+ dtype, latency: elapsed, dims, actualDims, bytesPerVec,
2193
+ tokens: data.usage?.total_tokens || 0, ranked,
2194
+ };
2195
+ }
2196
+
2197
+ const completed = Object.values(resultsByDtype);
2198
+ if (completed.length === 0) {
2199
+ showError('quantError', 'No data types completed successfully');
2200
+ return;
2201
+ }
2202
+
2203
+ // ── Render Charts ──
2204
+ const baseline = completed.find(r => r.dtype === 'float') || completed[0];
2205
+ const maxBytes = Math.max(...completed.map(r => r.bytesPerVec));
2206
+ const maxLatency = Math.max(...completed.map(r => r.latency));
2207
+ const DTYPE_COLORS = { float: '#00d4aa', int8: '#4ecdc4', uint8: '#45b7d1', ubinary: '#ffd93d', binary: '#ff6b6b' };
2208
+
2209
+ // ── Storage Bar Chart ──
2210
+ let storageHTML = '';
2211
+ for (const r of completed) {
2212
+ const pct = Math.max(8, (r.bytesPerVec / maxBytes) * 100);
2213
+ const totalMB = (r.bytesPerVec * 1_000_000) / (1024 * 1024);
2214
+ const sizeStr = totalMB >= 1024 ? `${(totalMB / 1024).toFixed(1)} GB` : `${totalMB.toFixed(0)} MB`;
2215
+ const savings = r.bytesPerVec < baseline.bytesPerVec
2216
+ ? `${(baseline.bytesPerVec / r.bytesPerVec).toFixed(0)}× smaller`
2217
+ : 'baseline';
2218
+ const color = DTYPE_COLORS[r.dtype] || '#82aaff';
2219
+ storageHTML += `<div class="quant-bar-group">
2220
+ <div class="quant-bar-label">
2221
+ <span class="dtype-name">${r.dtype}</span>
2222
+ <span class="dtype-value">${formatBytesUI(r.bytesPerVec)}/vec · ${sizeStr} @ 1M</span>
2223
+ </div>
2224
+ <div class="quant-bar-track">
2225
+ <div class="quant-bar-fill storage" style="width:${pct}%;background:linear-gradient(90deg, ${color}, ${color}cc);">${savings}</div>
2226
+ </div>
2227
+ </div>`;
2228
+ }
2229
+ document.getElementById('quantStorageChart').innerHTML = storageHTML;
2230
+
2231
+ // ── Latency Bar Chart ──
2232
+ let latencyHTML = '';
2233
+ const minLatency = Math.min(...completed.map(r => r.latency));
2234
+ for (const r of completed) {
2235
+ const pct = Math.max(8, (r.latency / maxLatency) * 100);
2236
+ const color = DTYPE_COLORS[r.dtype] || '#82aaff';
2237
+ const badge = r.latency === minLatency ? ' ⚡' : '';
2238
+ latencyHTML += `<div class="quant-bar-group">
2239
+ <div class="quant-bar-label">
2240
+ <span class="dtype-name">${r.dtype}</span>
2241
+ <span class="dtype-value">${r.latency.toFixed(0)}ms${badge}</span>
2242
+ </div>
2243
+ <div class="quant-bar-track">
2244
+ <div class="quant-bar-fill latency" style="width:${pct}%;background:linear-gradient(90deg, ${color}, ${color}cc);">${r.latency.toFixed(0)}ms</div>
2245
+ </div>
2246
+ </div>`;
2247
+ }
2248
+ document.getElementById('quantLatencyChart').innerHTML = latencyHTML;
2249
+
2250
+ // ── Quality Meters + Ranking Grid ──
2251
+ const topK = Math.min(5, corpus.length);
2252
+ const metersEl = document.getElementById('quantQualityMeters');
2253
+ const gridEl = document.getElementById('quantRankGrid');
2254
+ gridEl.innerHTML = '';
2255
+ metersEl.innerHTML = '';
2256
+
2257
+ if (completed.length >= 2 && baseline) {
2258
+ const baselineRanking = baseline.ranked.slice(0, topK).map(r => r.index);
2259
+
2260
+ // Quality meters for each non-baseline dtype
2261
+ let metersHTML = '';
2262
+ for (const r of completed) {
2263
+ if (r.dtype === baseline.dtype) continue;
2264
+ const otherRanking = r.ranked.slice(0, topK).map(x => x.index);
2265
+ const overlap = baselineRanking.filter(idx => otherRanking.includes(idx)).length;
2266
+ const overlapPct = (overlap / topK) * 100;
2267
+ const exactMatch = baselineRanking.every((idx, pos) => otherRanking[pos] === idx);
2268
+ const positionMatches = baselineRanking.filter((idx, pos) => otherRanking[pos] === idx).length;
2269
+ const posMatchPct = (positionMatches / topK) * 100;
2270
+
2271
+ let grade, gradeLabel, detail;
2272
+ if (exactMatch) {
2273
+ grade = 'perfect'; gradeLabel = '✓ Perfect';
2274
+ detail = `Identical ranking — all ${topK} positions match float baseline`;
2275
+ } else if (overlap === topK) {
2276
+ grade = 'good'; gradeLabel = '≈ Reordered';
2277
+ detail = `Same ${topK} documents, ${positionMatches}/${topK} in same position`;
2278
+ } else {
2279
+ grade = overlap >= topK * 0.6 ? 'good' : 'degraded';
2280
+ gradeLabel = `${overlapPct.toFixed(0)}% overlap`;
2281
+ detail = `${overlap}/${topK} documents match, ${positionMatches}/${topK} positions match`;
2282
+ }
2283
+
2284
+ metersHTML += `<div class="quant-quality-meter">
2285
+ <div class="quant-meter-header">
2286
+ <span class="dtype-name">${r.dtype}</span>
2287
+ <span class="verdict-badge ${grade}">${gradeLabel}</span>
2288
+ </div>
2289
+ <div class="quant-meter-track">
2290
+ <div class="quant-meter-fill ${grade}" style="width:${exactMatch ? 100 : posMatchPct}%"></div>
2291
+ </div>
2292
+ <div class="quant-meter-detail">${detail}</div>
2293
+ </div>`;
2294
+ }
2295
+ metersEl.innerHTML = metersHTML;
2296
+
2297
+ // Side-by-side ranking columns
2298
+ let rankHTML = `<div class="quant-rank-cols" style="grid-template-columns:repeat(${completed.length},1fr);">`;
2299
+ for (const r of completed) {
2300
+ rankHTML += `<div><div class="quant-rank-col-header">${r.dtype}${r === baseline ? ' (baseline)' : ''}</div>`;
2301
+ r.ranked.slice(0, topK).forEach((item, pos) => {
2302
+ const trunc = item.text.length > 55 ? item.text.slice(0, 52) + '…' : item.text;
2303
+ let cls = 'baseline';
2304
+ if (r !== baseline) {
2305
+ cls = (baseline.ranked[pos] && item.index === baseline.ranked[pos].index) ? 'match' : 'differ';
2306
+ }
2307
+ rankHTML += `<div class="quant-rank-item ${cls}" title="${item.text.replace(/"/g, '&quot;')}">
2308
+ <span class="quant-rank-pos">${pos + 1}</span>${trunc}
2309
+ <div class="quant-rank-score">${item.similarity.toFixed(4)} · doc ${item.index}</div>
2310
+ </div>`;
2311
+ });
2312
+ rankHTML += '</div>';
2313
+ }
2314
+ rankHTML += '</div>';
2315
+ gridEl.innerHTML = rankHTML;
2316
+ } else {
2317
+ metersEl.innerHTML = '<span style="color:var(--text-dim)">Select multiple data types (including float) to compare rankings.</span>';
2318
+ }
2319
+
2320
+ document.getElementById('quantResult').classList.add('visible');
2321
+ } catch (err) {
2322
+ showError('quantError', err.message);
2323
+ } finally {
2324
+ setLoading('quantBtn', false);
2325
+ }
2326
+ };
2327
+
1800
2328
  // ── Benchmark: Cost Calculator ──
1801
2329
  function initCostCalculator() {
1802
2330
  const tokSlider = document.getElementById('costTokens');
@@ -1936,6 +2464,7 @@ init = async function() {
1936
2464
  await _origInit();
1937
2465
  buildModelCheckboxes();
1938
2466
  populateBenchRankSelects();
2467
+ populateQuantModelSelect();
1939
2468
  initCostCalculator();
1940
2469
  renderHistory();
1941
2470
  };
@@ -0,0 +1,23 @@
1
+ 'use strict';
2
+
3
+ const { describe, it } = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const { Command } = require('commander');
6
+ const { registerAbout } = require('../../src/commands/about');
7
+
8
+ describe('about command', () => {
9
+ it('registers correctly on a program', () => {
10
+ const program = new Command();
11
+ registerAbout(program);
12
+ const aboutCmd = program.commands.find(c => c.name() === 'about');
13
+ assert.ok(aboutCmd, 'about command should be registered');
14
+ });
15
+
16
+ it('has --json option', () => {
17
+ const program = new Command();
18
+ registerAbout(program);
19
+ const aboutCmd = program.commands.find(c => c.name() === 'about');
20
+ const optionNames = aboutCmd.options.map(o => o.long);
21
+ assert.ok(optionNames.includes('--json'), 'should have --json option');
22
+ });
23
+ });