sweet-search 0.0.1 → 2.3.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.
- package/LICENSE +190 -0
- package/NOTICE +23 -0
- package/core/cli.js +51 -0
- package/core/config.js +27 -0
- package/core/embedding/embedding-cache.js +467 -0
- package/core/embedding/embedding-local-model.js +845 -0
- package/core/embedding/embedding-remote.js +492 -0
- package/core/embedding/embedding-service.js +712 -0
- package/core/embedding/embedding-telemetry.js +219 -0
- package/core/embedding/index.js +40 -0
- package/core/graph/community-detector.js +294 -0
- package/core/graph/graph-expansion.js +839 -0
- package/core/graph/graph-extractor.js +2304 -0
- package/core/graph/graph-search.js +2148 -0
- package/core/graph/hcgs-generator.js +666 -0
- package/core/graph/index.js +16 -0
- package/core/graph/leiden-algorithm.js +547 -0
- package/core/graph/relationship-resolver.js +366 -0
- package/core/graph/repo-map.js +408 -0
- package/core/graph/summary-manager.js +549 -0
- package/core/indexing/artifact-builder.js +1054 -0
- package/core/indexing/ast-chunker.js +709 -0
- package/core/indexing/chunking/chunk-builder.js +170 -0
- package/core/indexing/chunking/markdown-chunker.js +503 -0
- package/core/indexing/chunking/plaintext-chunker.js +104 -0
- package/core/indexing/dedup/dedup-phase.js +159 -0
- package/core/indexing/dedup/exemplar-selector.js +65 -0
- package/core/indexing/document-chunker.js +56 -0
- package/core/indexing/incremental-parser.js +390 -0
- package/core/indexing/incremental-tracker.js +761 -0
- package/core/indexing/index-codebase-v21.js +472 -0
- package/core/indexing/index-maintainer.mjs +1674 -0
- package/core/indexing/index.js +90 -0
- package/core/indexing/indexer-ann.js +1077 -0
- package/core/indexing/indexer-build.js +742 -0
- package/core/indexing/indexer-phases.js +800 -0
- package/core/indexing/indexer-pool.js +764 -0
- package/core/indexing/indexer-sparse-gram.js +98 -0
- package/core/indexing/indexer-utils.js +536 -0
- package/core/indexing/indexer-worker.js +148 -0
- package/core/indexing/li-skip-policy.js +225 -0
- package/core/indexing/merkle-tracker.js +244 -0
- package/core/indexing/model-pool.js +166 -0
- package/core/infrastructure/code-graph-repository.js +120 -0
- package/core/infrastructure/codebase-repository.js +131 -0
- package/core/infrastructure/config/dedup.js +54 -0
- package/core/infrastructure/config/embedding.js +298 -0
- package/core/infrastructure/config/graph.js +80 -0
- package/core/infrastructure/config/index.js +82 -0
- package/core/infrastructure/config/indexing.js +8 -0
- package/core/infrastructure/config/platform.js +254 -0
- package/core/infrastructure/config/ranking.js +221 -0
- package/core/infrastructure/config/search.js +396 -0
- package/core/infrastructure/config/translation.js +89 -0
- package/core/infrastructure/config/vector-store.js +114 -0
- package/core/infrastructure/constants.js +86 -0
- package/core/infrastructure/coreml-cascade.js +909 -0
- package/core/infrastructure/coreml-cascade.json +46 -0
- package/core/infrastructure/coreml-provider.js +81 -0
- package/core/infrastructure/db-utils.js +69 -0
- package/core/infrastructure/dedup-hashing.js +83 -0
- package/core/infrastructure/hardware-capability.js +332 -0
- package/core/infrastructure/index.js +104 -0
- package/core/infrastructure/language-patterns/maps.js +121 -0
- package/core/infrastructure/language-patterns/registry-core.js +323 -0
- package/core/infrastructure/language-patterns/registry-data-query.js +155 -0
- package/core/infrastructure/language-patterns/registry-object-oriented.js +285 -0
- package/core/infrastructure/language-patterns/registry-tooling.js +240 -0
- package/core/infrastructure/language-patterns/registry-web-style.js +143 -0
- package/core/infrastructure/language-patterns/registry.js +19 -0
- package/core/infrastructure/language-patterns.js +141 -0
- package/core/infrastructure/llm-provider.js +733 -0
- package/core/infrastructure/manifest.json +46 -0
- package/core/infrastructure/maxsim.wasm +0 -0
- package/core/infrastructure/model-fetcher.js +423 -0
- package/core/infrastructure/model-registry.js +214 -0
- package/core/infrastructure/native-inference.js +587 -0
- package/core/infrastructure/native-resolver.js +187 -0
- package/core/infrastructure/native-sparse-gram.js +257 -0
- package/core/infrastructure/native-tokenizer.js +160 -0
- package/core/infrastructure/onnx-mutex.js +45 -0
- package/core/infrastructure/onnx-session-utils.js +261 -0
- package/core/infrastructure/ort-pipeline.js +111 -0
- package/core/infrastructure/project-detector.js +102 -0
- package/core/infrastructure/quantization.js +410 -0
- package/core/infrastructure/simd-distance.js +502 -0
- package/core/infrastructure/simd-distance.wasm +0 -0
- package/core/infrastructure/tree-sitter-provider.js +665 -0
- package/core/infrastructure/webgpu-maxsim.js +222 -0
- package/core/query/index.js +35 -0
- package/core/query/intent-detector.js +201 -0
- package/core/query/intent-router.js +156 -0
- package/core/query/query-router-catboost.js +222 -0
- package/core/query/query-router-ml.js +266 -0
- package/core/query/query-router.js +213 -0
- package/core/ranking/cascaded-scorer.js +379 -0
- package/core/ranking/flashrank.js +810 -0
- package/core/ranking/index.js +49 -0
- package/core/ranking/late-interaction-index.js +2383 -0
- package/core/ranking/late-interaction-model.js +812 -0
- package/core/ranking/local-reranker.js +374 -0
- package/core/ranking/mmr.js +379 -0
- package/core/ranking/quality-scorer.js +363 -0
- package/core/search/context-expander.js +1167 -0
- package/core/search/dedup/sibling-expander.js +327 -0
- package/core/search/index.js +16 -0
- package/core/search/search-boost.js +259 -0
- package/core/search/search-cli.js +544 -0
- package/core/search/search-format.js +282 -0
- package/core/search/search-fusion.js +327 -0
- package/core/search/search-hybrid.js +204 -0
- package/core/search/search-pattern-chunks.js +337 -0
- package/core/search/search-pattern-planner.js +439 -0
- package/core/search/search-pattern-prefilter.js +412 -0
- package/core/search/search-pattern-ripgrep.js +663 -0
- package/core/search/search-pattern.js +463 -0
- package/core/search/search-postprocess.js +452 -0
- package/core/search/search-semantic.js +706 -0
- package/core/search/search-server.js +554 -0
- package/core/search/session-daemon-prewarm.mjs +164 -0
- package/core/search/session-warmup.js +595 -0
- package/core/search/sweet-search.js +632 -0
- package/core/search/warmup-metrics.js +532 -0
- package/core/start-server.js +6 -0
- package/core/training/query-router/features/extractor.js +762 -0
- package/core/training/query-router/features/multilingual-patterns.js +431 -0
- package/core/training/query-router/features/text-segmenter.js +303 -0
- package/core/training/query-router/features/unicode-utils.js +383 -0
- package/core/training/query-router/output/v45_router_d4.js +11521 -0
- package/core/training/query-router/output/v46_router_d4.js +11498 -0
- package/core/vector-store/binary-heap.js +227 -0
- package/core/vector-store/binary-hnsw-index.js +1004 -0
- package/core/vector-store/float-vector-store.js +234 -0
- package/core/vector-store/hnsw-index.js +580 -0
- package/core/vector-store/index.js +39 -0
- package/core/vector-store/seismic-index.js +498 -0
- package/core/vocabulary/index.js +84 -0
- package/core/vocabulary/vocab-constants.js +20 -0
- package/core/vocabulary/vocab-miner-extractors.js +375 -0
- package/core/vocabulary/vocab-miner-nl.js +404 -0
- package/core/vocabulary/vocab-miner-utils.js +146 -0
- package/core/vocabulary/vocab-miner.js +574 -0
- package/core/vocabulary/vocab-prewarm-cli.js +110 -0
- package/core/vocabulary/vocab-ranker.js +492 -0
- package/core/vocabulary/vocab-warmer.js +523 -0
- package/core/vocabulary/vocab-warmup-orchestrator.js +425 -0
- package/core/vocabulary/vocabulary-utils.js +704 -0
- package/crates/wasm-router/pkg/package.json +13 -0
- package/crates/wasm-router/pkg/query_router_wasm.d.ts +36 -0
- package/crates/wasm-router/pkg/query_router_wasm.js +271 -0
- package/crates/wasm-router/pkg/query_router_wasm_bg.wasm +0 -0
- package/crates/wasm-router/pkg/query_router_wasm_bg.wasm.d.ts +19 -0
- package/mcp/config-gen.js +121 -0
- package/mcp/server.js +335 -0
- package/mcp/tool-handlers.js +476 -0
- package/package.json +131 -9
- package/scripts/benchmark-harness.js +794 -0
- package/scripts/init.js +1058 -0
- package/scripts/smoke-test.js +435 -0
- package/scripts/uninstall.js +478 -0
- package/scripts/verify-runtime.js +176 -0
|
@@ -0,0 +1,812 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Late Interaction Model Singleton — LateOn-Code inference via ONNX Runtime
|
|
3
|
+
*
|
|
4
|
+
* Loads tokenizer via native-tokenizer.js, ONNX model via onnxruntime-node.
|
|
5
|
+
* Projection layers are detected at load time: if the ONNX output is raw backbone
|
|
6
|
+
* dimensions (768d full, 256d edge), projection weights are downloaded from
|
|
7
|
+
* safetensors files and applied after inference. If already baked in, skipped.
|
|
8
|
+
*
|
|
9
|
+
* Encoding asymmetry (critical):
|
|
10
|
+
* - Queries: [Q] prefix, max 256 tokens, NO skiplist, NO [MASK] padding
|
|
11
|
+
* - Documents: [D] prefix, max 2048 tokens, skiplist filters punctuation tokens
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import os from 'os';
|
|
15
|
+
import fs from 'fs';
|
|
16
|
+
import path from 'path';
|
|
17
|
+
import { join } from 'path';
|
|
18
|
+
import { LATE_INTERACTION_CONFIG } from '../infrastructure/config/index.js';
|
|
19
|
+
import { fetchModelFile, getModelCacheDir, resolveModelFile } from '../infrastructure/model-fetcher.js';
|
|
20
|
+
import { getModelEntry } from '../infrastructure/model-registry.js';
|
|
21
|
+
import { createTokenizer } from '../infrastructure/native-tokenizer.js';
|
|
22
|
+
import { isNativeInferenceAvailable, isNativeLiModelLoaded, nativeLiEncode, nativeLiEncodeTokenized } from '../infrastructure/native-inference.js';
|
|
23
|
+
// CoreML not used for LI models — see loadModel() comment for benchmarking rationale.
|
|
24
|
+
|
|
25
|
+
let lateInteractionPipeline = null;
|
|
26
|
+
let loadPromise = null;
|
|
27
|
+
let lateInteractionRuntimeConfig = {
|
|
28
|
+
intraOpThreads: null,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Lightweight timing accumulators for profiling (Phase 6a).
|
|
32
|
+
// Cleared on read via getLateInteractionTimings().
|
|
33
|
+
const _timings = { tokenize_us: 0, inference_us: 0, calls: 0 };
|
|
34
|
+
|
|
35
|
+
/** Read and reset accumulated tokenizer/inference timings. */
|
|
36
|
+
export function getLateInteractionTimings() {
|
|
37
|
+
const snap = { ..._timings };
|
|
38
|
+
_timings.tokenize_us = 0;
|
|
39
|
+
_timings.inference_us = 0;
|
|
40
|
+
_timings.calls = 0;
|
|
41
|
+
return snap;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Get the late interaction pipeline singleton (lazy-loaded).
|
|
46
|
+
* Returns null if late interaction is disabled.
|
|
47
|
+
*/
|
|
48
|
+
export async function getLateInteractionPipeline() {
|
|
49
|
+
if (!LATE_INTERACTION_CONFIG.enabled) return null;
|
|
50
|
+
if (lateInteractionPipeline) return lateInteractionPipeline;
|
|
51
|
+
if (loadPromise) return loadPromise;
|
|
52
|
+
loadPromise = loadModel();
|
|
53
|
+
lateInteractionPipeline = await loadPromise;
|
|
54
|
+
loadPromise = null;
|
|
55
|
+
return lateInteractionPipeline;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Check if the late interaction model is currently loaded */
|
|
59
|
+
export function isLateInteractionModelLoaded() { return lateInteractionPipeline != null; }
|
|
60
|
+
|
|
61
|
+
// Lightweight tokenizer + skiplist cache for native inference path
|
|
62
|
+
// (avoids loading the full ORT pipeline just for tokenization)
|
|
63
|
+
let _nativeLiTokenizer = null;
|
|
64
|
+
let _nativeLiSkiplist = null;
|
|
65
|
+
|
|
66
|
+
async function getNativeLiTokenizerAndSkiplist() {
|
|
67
|
+
if (_nativeLiTokenizer) return { tokenizer: _nativeLiTokenizer, skiplistTokenIds: _nativeLiSkiplist };
|
|
68
|
+
|
|
69
|
+
const modelConfig = LATE_INTERACTION_CONFIG.activeModel;
|
|
70
|
+
const tokenizerPath = join(getModelCacheDir(modelConfig.hfId), 'tokenizer.json');
|
|
71
|
+
_nativeLiTokenizer = await createTokenizer(tokenizerPath);
|
|
72
|
+
|
|
73
|
+
_nativeLiSkiplist = new Set();
|
|
74
|
+
for (const ch of LATE_INTERACTION_CONFIG.skiplistChars) {
|
|
75
|
+
const enc = _nativeLiTokenizer(ch, { add_special_tokens: false });
|
|
76
|
+
const ids = Array.from(enc.input_ids.data);
|
|
77
|
+
if (ids.length === 1) _nativeLiSkiplist.add(Number(ids[0]));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { tokenizer: _nativeLiTokenizer, skiplistTokenIds: _nativeLiSkiplist };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function configureLateInteractionRuntime(overrides = {}) {
|
|
84
|
+
lateInteractionRuntimeConfig = {
|
|
85
|
+
...lateInteractionRuntimeConfig,
|
|
86
|
+
...overrides,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function resetLateInteractionRuntime() {
|
|
91
|
+
lateInteractionRuntimeConfig = {
|
|
92
|
+
intraOpThreads: null,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Release model memory */
|
|
97
|
+
export async function unloadLateInteractionModel() {
|
|
98
|
+
if (lateInteractionPipeline) {
|
|
99
|
+
// Release ORT session. Note: ORT has a known native memory leak in
|
|
100
|
+
// session.release() (microsoft/onnxruntime#25325) — avoid frequent
|
|
101
|
+
// load/unload cycles. Prefer singleton reuse.
|
|
102
|
+
if (lateInteractionPipeline.session) {
|
|
103
|
+
try { await lateInteractionPipeline.session.release(); } catch { /* ignore */ }
|
|
104
|
+
}
|
|
105
|
+
// Release projection weight buffers
|
|
106
|
+
if (lateInteractionPipeline.projectionStages) {
|
|
107
|
+
for (const stage of lateInteractionPipeline.projectionStages) {
|
|
108
|
+
stage.weight = null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
lateInteractionPipeline = null;
|
|
113
|
+
loadPromise = null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// =========================================================================
|
|
117
|
+
// Safetensors Parser — extracts Float32 weight matrices
|
|
118
|
+
// =========================================================================
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Parse a safetensors file and extract the first Float32 weight tensor.
|
|
122
|
+
* Safetensors format: 8-byte LE header length + JSON header + raw tensor data.
|
|
123
|
+
* @param {string} filePath - Path to .safetensors file
|
|
124
|
+
* @returns {Float32Array} Weight matrix as flat array
|
|
125
|
+
*/
|
|
126
|
+
function parseSafetensorsWeight(filePath) {
|
|
127
|
+
const buffer = fs.readFileSync(filePath);
|
|
128
|
+
const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
|
129
|
+
|
|
130
|
+
const headerLen = Number(view.getBigUint64(0, true));
|
|
131
|
+
const headerJson = buffer.subarray(8, 8 + headerLen).toString('utf-8');
|
|
132
|
+
const header = JSON.parse(headerJson);
|
|
133
|
+
|
|
134
|
+
const dataStart = 8 + headerLen;
|
|
135
|
+
for (const [name, info] of Object.entries(header)) {
|
|
136
|
+
if (name === '__metadata__') continue;
|
|
137
|
+
if (info.dtype === 'F32') {
|
|
138
|
+
const byteOffset = dataStart + info.data_offsets[0];
|
|
139
|
+
const byteLength = info.data_offsets[1] - info.data_offsets[0];
|
|
140
|
+
// Copy to new buffer to avoid alignment issues
|
|
141
|
+
const copy = new Float32Array(byteLength / 4);
|
|
142
|
+
const src = buffer.subarray(byteOffset, byteOffset + byteLength);
|
|
143
|
+
copy.set(new Float32Array(src.buffer, src.byteOffset, src.length / 4));
|
|
144
|
+
return copy;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
throw new Error(`No F32 tensor found in ${filePath}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// =========================================================================
|
|
151
|
+
// Model Loading
|
|
152
|
+
// =========================================================================
|
|
153
|
+
|
|
154
|
+
async function loadModel() {
|
|
155
|
+
const modelConfig = LATE_INTERACTION_CONFIG.activeModel;
|
|
156
|
+
if (!modelConfig) throw new Error(`[LateInteraction] Unknown model: ${LATE_INTERACTION_CONFIG.model}`);
|
|
157
|
+
|
|
158
|
+
const start = Date.now();
|
|
159
|
+
console.log(`[LateInteraction] Loading ${LATE_INTERACTION_CONFIG.model} (${modelConfig.tokenDimension}d)...`);
|
|
160
|
+
|
|
161
|
+
// Load tokenizer from managed cache (native Rust → JS fallback)
|
|
162
|
+
const tokenizerPath = join(getModelCacheDir(modelConfig.hfId), 'tokenizer.json');
|
|
163
|
+
const tokenizer = await createTokenizer(tokenizerPath);
|
|
164
|
+
|
|
165
|
+
// Build skiplist token IDs from the 32 punctuation chars
|
|
166
|
+
const skiplistTokenIds = new Set();
|
|
167
|
+
for (const ch of LATE_INTERACTION_CONFIG.skiplistChars) {
|
|
168
|
+
const enc = tokenizer(ch, { add_special_tokens: false });
|
|
169
|
+
const ids = Array.from(enc.input_ids.data);
|
|
170
|
+
if (ids.length === 1) skiplistTokenIds.add(Number(ids[0]));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Download ONNX model if not cached
|
|
174
|
+
const registryKey = LATE_INTERACTION_CONFIG.model;
|
|
175
|
+
const onnxPath = await resolveOrFetchFile(modelConfig.hfId, modelConfig.onnxFile, registryKey);
|
|
176
|
+
|
|
177
|
+
// Create ORT session
|
|
178
|
+
const ort = await import('onnxruntime-node');
|
|
179
|
+
const { bestIntraOpThreads } = await import('../infrastructure/onnx-session-utils.js');
|
|
180
|
+
|
|
181
|
+
// CoreML is not used for late-interaction models. Benchmarking shows the
|
|
182
|
+
// LateOn-Code model partitions poorly onto CoreML (1343/2327 ops), causing
|
|
183
|
+
// constant CPU↔CoreML data transfer that makes inference ~18x slower.
|
|
184
|
+
// The MLProgram format fails entirely; NeuralNetwork loads but regresses.
|
|
185
|
+
//
|
|
186
|
+
// Phase 1a: harmonized session options with embedding path — adds graph
|
|
187
|
+
// optimization, memory arena, mem pattern, and optimized graph caching.
|
|
188
|
+
// executionMode defaults to 'sequential' (BERT encoder, no branch parallelism).
|
|
189
|
+
// Phase 1a/1c: LI session options benchmarked across hosts.
|
|
190
|
+
// Findings: graphOptimizationLevel 'all' triggers NchwcTransformer → 14% regression.
|
|
191
|
+
// 'extended' + memArena + memPattern: marginal overhead for no measurable gain.
|
|
192
|
+
// Conclusion: keep LI session lean. Only proven-beneficial options added.
|
|
193
|
+
const { getOptimizedGraphPath } = await import('../infrastructure/onnx-session-utils.js');
|
|
194
|
+
const session = await ort.InferenceSession.create(onnxPath, {
|
|
195
|
+
executionProviders: ['cpu'],
|
|
196
|
+
intraOpNumThreads: lateInteractionRuntimeConfig.intraOpThreads ?? bestIntraOpThreads(),
|
|
197
|
+
interOpNumThreads: 1,
|
|
198
|
+
optimizedModelFilePath: getOptimizedGraphPath(modelConfig.hfId, 'lateon'),
|
|
199
|
+
// Thread spinning keeps ORT worker threads hot between batches — trades idle
|
|
200
|
+
// CPU for lower per-batch latency during sustained indexing runs.
|
|
201
|
+
extra: {
|
|
202
|
+
session: {
|
|
203
|
+
intra_op: { allow_spinning: '1' },
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
const coremlActive = false;
|
|
208
|
+
|
|
209
|
+
// Probe: run a single inference to check output dimension
|
|
210
|
+
const probeTokenized = tokenizer('[Q] probe', { padding: true, truncation: true, max_length: 8 });
|
|
211
|
+
const probeHidden = await runRawInference(session, probeTokenized, ort);
|
|
212
|
+
const outputDim = probeHidden.dims[2];
|
|
213
|
+
|
|
214
|
+
// Determine if we need manual projection
|
|
215
|
+
let projectionStages = [];
|
|
216
|
+
const projectionBakedIn = outputDim === modelConfig.tokenDimension;
|
|
217
|
+
const projectionNeeded = outputDim === modelConfig.backboneDim;
|
|
218
|
+
|
|
219
|
+
if (projectionBakedIn) {
|
|
220
|
+
console.log(`[LateInteraction] ONNX output is ${outputDim}d — projection baked in`);
|
|
221
|
+
} else if (projectionNeeded) {
|
|
222
|
+
console.log(`[LateInteraction] ONNX output is raw ${outputDim}d backbone — loading projection weights...`);
|
|
223
|
+
|
|
224
|
+
// Load each projection weight and derive dims from weight.length + known input dim.
|
|
225
|
+
// Stage 1 input dim = backbone dim. Each subsequent stage input = previous output.
|
|
226
|
+
let currentInDim = modelConfig.backboneDim;
|
|
227
|
+
for (let i = 0; i < modelConfig.projectionPaths.length; i++) {
|
|
228
|
+
const weightPath = await resolveOrFetchFile(modelConfig.hfId, modelConfig.projectionPaths[i], registryKey);
|
|
229
|
+
const weight = parseSafetensorsWeight(weightPath);
|
|
230
|
+
// weight is [outDim, inDim] row-major → outDim = weight.length / inDim
|
|
231
|
+
if (weight.length % currentInDim !== 0) {
|
|
232
|
+
throw new Error(`[LateInteraction] Projection ${i + 1}: weight length ${weight.length} not divisible by input dim ${currentInDim}`);
|
|
233
|
+
}
|
|
234
|
+
const outDim = weight.length / currentInDim;
|
|
235
|
+
console.log(`[LateInteraction] Stage ${i + 1}: ${currentInDim}d → ${outDim}d (${weight.length} weights)`);
|
|
236
|
+
projectionStages.push({ weight, inDim: currentInDim, outDim });
|
|
237
|
+
currentInDim = outDim;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Verify final output matches expected token dimension
|
|
241
|
+
if (currentInDim !== modelConfig.tokenDimension) {
|
|
242
|
+
throw new Error(`[LateInteraction] Final projection output ${currentInDim}d !== expected ${modelConfig.tokenDimension}d`);
|
|
243
|
+
}
|
|
244
|
+
console.log(`[LateInteraction] Loaded ${projectionStages.length} projection stage(s): ${modelConfig.backboneDim}d → ${modelConfig.tokenDimension}d`);
|
|
245
|
+
} else {
|
|
246
|
+
throw new Error(`[LateInteraction] Unexpected ONNX output dim ${outputDim} (expected ${modelConfig.tokenDimension} or ${modelConfig.backboneDim})`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Warmup: ORT needs 10+ inference passes to stabilize JIT compilation,
|
|
250
|
+
// memory pool sizing, and thread pool scheduling. Warm up at batch sizes
|
|
251
|
+
// matching production document encoding traffic (single docs of varying
|
|
252
|
+
// lengths). Without this, the first ~10 real batches pay JIT + allocation
|
|
253
|
+
// costs and throughput appears to degrade.
|
|
254
|
+
const warmupDocs = [
|
|
255
|
+
'[D] function add(a, b) { return a + b; }',
|
|
256
|
+
'[D] export class AuthService { constructor(private jwtProvider) {} async login(credentials) { const user = await this.userRepo.findByEmail(credentials.email); if (!user) throw new UnauthorizedException(); return { token: this.jwtProvider.sign({ sub: user.id }) }; } }',
|
|
257
|
+
'[D] /**\n * SearchEngine handles full-text and vector search across indexed code.\n * Pipeline: tokenize → embed → HNSW ANN → rerank → filter.\n */\nexport class SearchEngine {\n constructor(private hnsw, private liIndex, private reranker) {}\n async search(query, options = {}) {\n const embedding = await this.embed(query);\n const candidates = await this.hnsw.search(embedding, options.topK || 100);\n const reranked = await this.reranker.rerank(query, candidates);\n return reranked.slice(0, options.limit || 10);\n }\n}',
|
|
258
|
+
];
|
|
259
|
+
for (let pass = 0; pass < 10; pass++) {
|
|
260
|
+
const doc = warmupDocs[pass % warmupDocs.length];
|
|
261
|
+
const warmupTokenized = tokenizer(doc, { padding: true, truncation: true, max_length: modelConfig.maxDocLength });
|
|
262
|
+
await runRawInference(session, warmupTokenized, ort);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const elapsed = Date.now() - start;
|
|
266
|
+
const epLabel = coremlActive ? 'coreml+cpu' : 'cpu';
|
|
267
|
+
console.log(`[LateInteraction] Loaded ${LATE_INTERACTION_CONFIG.model} in ${elapsed}ms (${modelConfig.tokenDimension}d, ep: ${epLabel}, skiplist: ${skiplistTokenIds.size} IDs, projection: ${projectionBakedIn ? 'baked' : 'manual'})`);
|
|
268
|
+
|
|
269
|
+
return { tokenizer, session, modelConfig, skiplistTokenIds, ort, projectionStages };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// =========================================================================
|
|
273
|
+
// Encode API
|
|
274
|
+
// =========================================================================
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Encode a query into per-token vectors.
|
|
278
|
+
* - Prepends [Q] prefix
|
|
279
|
+
* - Max 256 tokens
|
|
280
|
+
* - NO skiplist filtering (keeps ALL tokens)
|
|
281
|
+
* - NO [MASK] padding
|
|
282
|
+
*
|
|
283
|
+
* @param {string} text - Raw query text (without prefix)
|
|
284
|
+
* @returns {Float32Array[]} Array of L2-normalized token vectors
|
|
285
|
+
*/
|
|
286
|
+
export async function encodeQuery(text) {
|
|
287
|
+
if (!LATE_INTERACTION_CONFIG.enabled) return [];
|
|
288
|
+
|
|
289
|
+
// Native inference path: candle FP32 with Metal GPU
|
|
290
|
+
if (isNativeInferenceAvailable() && isNativeLiModelLoaded()) {
|
|
291
|
+
const modelConfig = LATE_INTERACTION_CONFIG.activeModel;
|
|
292
|
+
if (!modelConfig) return [];
|
|
293
|
+
const prefixed = modelConfig.queryPrefix + text;
|
|
294
|
+
const t0 = performance.now();
|
|
295
|
+
const result = await nativeLiEncode([prefixed], { maxLength: modelConfig.maxQueryLength });
|
|
296
|
+
const t1 = performance.now();
|
|
297
|
+
_timings.inference_us += Math.round((t1 - t0) * 1000);
|
|
298
|
+
_timings.calls++;
|
|
299
|
+
return result[0]; // Float32Array[] — one per token
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ORT fallback path
|
|
303
|
+
const pipeline = await getLateInteractionPipeline();
|
|
304
|
+
if (!pipeline) return [];
|
|
305
|
+
|
|
306
|
+
const { tokenizer, session, modelConfig, ort, projectionStages } = pipeline;
|
|
307
|
+
const prefixed = modelConfig.queryPrefix + text;
|
|
308
|
+
|
|
309
|
+
const t0 = performance.now();
|
|
310
|
+
const tokenized = tokenizer(prefixed, {
|
|
311
|
+
padding: true, truncation: true, max_length: modelConfig.maxQueryLength,
|
|
312
|
+
});
|
|
313
|
+
const t1 = performance.now();
|
|
314
|
+
const hidden = await runRawInference(session, tokenized, ort);
|
|
315
|
+
const t2 = performance.now();
|
|
316
|
+
|
|
317
|
+
_timings.tokenize_us += Math.round((t1 - t0) * 1000);
|
|
318
|
+
_timings.inference_us += Math.round((t2 - t1) * 1000);
|
|
319
|
+
_timings.calls++;
|
|
320
|
+
|
|
321
|
+
return projectAndNormalizeBatch(hidden, projectionStages, tokenized.attention_mask)[0];
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Encode documents into per-token vectors.
|
|
326
|
+
* - Prepends [D] prefix to each
|
|
327
|
+
* - Max 2048 tokens per doc
|
|
328
|
+
* - Skiplist filtering applied (removes punctuation token embeddings)
|
|
329
|
+
* - Optional token pooling (reduces token count by pool_factor)
|
|
330
|
+
* - Optional extended skiplist (additional code-noise tokens)
|
|
331
|
+
*
|
|
332
|
+
* @param {string[]} texts - Array of raw document texts (without prefix)
|
|
333
|
+
* @param {object} [options] - Encoding options
|
|
334
|
+
* @param {number} [options.poolFactor=1] - Token pooling factor (1=no pooling, 2=halve tokens, etc.)
|
|
335
|
+
* @param {boolean} [options.extendedSkiplist=false] - Use code-extended skiplist
|
|
336
|
+
* @returns {Float32Array[][]} Array of (array of token vectors) per document
|
|
337
|
+
*/
|
|
338
|
+
export async function encodeDocuments(texts, options = {}) {
|
|
339
|
+
// Default dispatcher: pick the best path that's available. Hybrid CPU+GPU
|
|
340
|
+
// dispatching is done at the indexer level (indexer-ann.js LI loop) which
|
|
341
|
+
// calls encodeDocumentsGpu and encodeDocumentsCpu directly in parallel.
|
|
342
|
+
//
|
|
343
|
+
// SWEET_SEARCH_LI_USE_CPU=1 forces the ORT INT8 CPU path even when the
|
|
344
|
+
// native Metal addon is available. Use this when running in parallel with
|
|
345
|
+
// the embedding phase: embed gets exclusive Metal access, LI gets exclusive
|
|
346
|
+
// CPU access — eliminating Metal queue contention that otherwise starves
|
|
347
|
+
// the LI GPU encoder when embed is feeding it a continuous stream of
|
|
348
|
+
// small batches.
|
|
349
|
+
const forceCpu = process.env.SWEET_SEARCH_LI_USE_CPU === '1';
|
|
350
|
+
if (!forceCpu && isNativeInferenceAvailable() && isNativeLiModelLoaded()) {
|
|
351
|
+
return encodeDocumentsGpu(texts, options);
|
|
352
|
+
}
|
|
353
|
+
return encodeDocumentsCpu(texts, options);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Encode documents using the native Metal GPU path (candle + Metal SDPA).
|
|
358
|
+
* Throws if the native addon is not available — the caller is expected to
|
|
359
|
+
* have verified availability before routing batches here.
|
|
360
|
+
*/
|
|
361
|
+
export async function encodeDocumentsGpu(texts, options = {}) {
|
|
362
|
+
const { poolFactor = 1, extendedSkiplist = false } = options;
|
|
363
|
+
|
|
364
|
+
const modelConfig = LATE_INTERACTION_CONFIG.activeModel;
|
|
365
|
+
if (!modelConfig) return texts.map(() => []);
|
|
366
|
+
|
|
367
|
+
// Lightweight tokenizer + skiplist (no ORT session needed)
|
|
368
|
+
const { tokenizer, skiplistTokenIds } = await getNativeLiTokenizerAndSkiplist();
|
|
369
|
+
|
|
370
|
+
let effectiveSkiplist = skiplistTokenIds;
|
|
371
|
+
if (extendedSkiplist) {
|
|
372
|
+
effectiveSkiplist = buildExtendedSkiplist(tokenizer, skiplistTokenIds);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const prefixedTexts = texts.map((text) => modelConfig.docPrefix + text);
|
|
376
|
+
|
|
377
|
+
// Tokenize once; reuse the same batch for both skiplist lookup and model
|
|
378
|
+
// inference. `nativeLiEncodeTokenized` accepts the already-tokenized batch
|
|
379
|
+
// directly so we don't double-tokenize.
|
|
380
|
+
const t0 = performance.now();
|
|
381
|
+
const tokenized = tokenizer(prefixedTexts, {
|
|
382
|
+
padding: true, truncation: true, max_length: modelConfig.maxDocLength,
|
|
383
|
+
});
|
|
384
|
+
const t1 = performance.now();
|
|
385
|
+
|
|
386
|
+
// Native inference — returns Float32Array[][] already projected + normalized
|
|
387
|
+
const allVectorsByDoc = await nativeLiEncodeTokenized(tokenized);
|
|
388
|
+
const t2 = performance.now();
|
|
389
|
+
|
|
390
|
+
_timings.tokenize_us += Math.round((t1 - t0) * 1000);
|
|
391
|
+
_timings.inference_us += Math.round((t2 - t1) * 1000);
|
|
392
|
+
_timings.calls++;
|
|
393
|
+
|
|
394
|
+
// Apply skiplist filtering
|
|
395
|
+
const { data: inputIdsData, dims: inputIdDims } = tokenized.input_ids;
|
|
396
|
+
const seqLen = inputIdDims[1];
|
|
397
|
+
const results = new Array(texts.length);
|
|
398
|
+
|
|
399
|
+
for (let docIdx = 0; docIdx < texts.length; docIdx++) {
|
|
400
|
+
const allVectors = allVectorsByDoc[docIdx];
|
|
401
|
+
const vectors = [];
|
|
402
|
+
for (let tokenIdx = 0; tokenIdx < allVectors.length; tokenIdx++) {
|
|
403
|
+
const rawId = inputIdsData[docIdx * seqLen + tokenIdx];
|
|
404
|
+
const inputId = typeof rawId === 'bigint' ? Number(rawId) : rawId;
|
|
405
|
+
if (!effectiveSkiplist.has(inputId)) {
|
|
406
|
+
vectors.push(allVectors[tokenIdx]);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
results[docIdx] = poolFactor > 1 ? poolTokens(vectors, poolFactor) : vectors;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return results;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Encode documents using the ORT INT8 CPU path (onnxruntime-node, accelerated
|
|
417
|
+
* by the platform BLAS — Accelerate/AMX on Apple Silicon, MKL on x86, etc).
|
|
418
|
+
* Returns the SAME shape as the GPU path so the caller can mix results from
|
|
419
|
+
* both pipelines transparently.
|
|
420
|
+
*
|
|
421
|
+
* Used by the opt-in hybrid dispatcher (SWEET_SEARCH_LI_HYBRID=1) and as the
|
|
422
|
+
* default fallback when the native GPU addon isn't available on the host.
|
|
423
|
+
*/
|
|
424
|
+
export async function encodeDocumentsCpu(texts, options = {}) {
|
|
425
|
+
const { poolFactor = 1, extendedSkiplist = false } = options;
|
|
426
|
+
|
|
427
|
+
const pipeline = await getLateInteractionPipeline();
|
|
428
|
+
if (!pipeline) return texts.map(() => []);
|
|
429
|
+
|
|
430
|
+
const { tokenizer, session, modelConfig, skiplistTokenIds, ort, projectionStages } = pipeline;
|
|
431
|
+
|
|
432
|
+
// Build effective skiplist (base + optional extended for code)
|
|
433
|
+
let effectiveSkiplist = skiplistTokenIds;
|
|
434
|
+
if (extendedSkiplist) {
|
|
435
|
+
effectiveSkiplist = buildExtendedSkiplist(tokenizer, skiplistTokenIds);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const prefixedTexts = texts.map((text) => modelConfig.docPrefix + text);
|
|
439
|
+
const t0 = performance.now();
|
|
440
|
+
const tokenized = tokenizer(prefixedTexts, {
|
|
441
|
+
padding: true,
|
|
442
|
+
truncation: true,
|
|
443
|
+
max_length: modelConfig.maxDocLength,
|
|
444
|
+
});
|
|
445
|
+
const t1 = performance.now();
|
|
446
|
+
const hidden = await runRawInference(session, tokenized, ort);
|
|
447
|
+
const t2 = performance.now();
|
|
448
|
+
|
|
449
|
+
_timings.tokenize_us += Math.round((t1 - t0) * 1000);
|
|
450
|
+
_timings.inference_us += Math.round((t2 - t1) * 1000);
|
|
451
|
+
_timings.calls++;
|
|
452
|
+
|
|
453
|
+
const allVectorsByDoc = projectAndNormalizeBatch(hidden, projectionStages, tokenized.attention_mask);
|
|
454
|
+
const { data: inputIdsData, dims: inputIdDims } = tokenized.input_ids;
|
|
455
|
+
const seqLen = inputIdDims[1];
|
|
456
|
+
const results = new Array(texts.length);
|
|
457
|
+
|
|
458
|
+
for (let docIdx = 0; docIdx < texts.length; docIdx++) {
|
|
459
|
+
const allVectors = allVectorsByDoc[docIdx];
|
|
460
|
+
const vectors = [];
|
|
461
|
+
const keptPreNorms = [];
|
|
462
|
+
const srcPreNorms = allVectors.preNorms;
|
|
463
|
+
for (let tokenIdx = 0; tokenIdx < allVectors.length; tokenIdx++) {
|
|
464
|
+
const rawId = inputIdsData[docIdx * seqLen + tokenIdx];
|
|
465
|
+
const inputId = typeof rawId === 'bigint' ? Number(rawId) : rawId;
|
|
466
|
+
if (!effectiveSkiplist.has(inputId)) {
|
|
467
|
+
vectors.push(allVectors[tokenIdx]);
|
|
468
|
+
if (srcPreNorms) keptPreNorms.push(srcPreNorms[tokenIdx]);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
if (srcPreNorms) vectors.preNorms = new Float32Array(keptPreNorms);
|
|
472
|
+
|
|
473
|
+
results[docIdx] = poolFactor > 1 ? poolTokens(vectors, poolFactor) : vectors;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return results;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// =========================================================================
|
|
480
|
+
// Projection + L2 Normalization
|
|
481
|
+
// =========================================================================
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Apply projection stages (if any) and L2-normalize per token.
|
|
485
|
+
* @param {object} hiddenTensor - ORT output tensor with .dims and .data
|
|
486
|
+
* @param {Array<{weight: Float32Array, inDim: number, outDim: number}>} projectionStages
|
|
487
|
+
* @returns {Float32Array[]} Array of L2-normalized token vectors
|
|
488
|
+
*/
|
|
489
|
+
function projectAndNormalizeBatch(hiddenTensor, projectionStages, attentionMask = null) {
|
|
490
|
+
const [batch, seqLen, hiddenDim] = hiddenTensor.dims;
|
|
491
|
+
let data = new Float32Array(hiddenTensor.data);
|
|
492
|
+
let currentDim = hiddenDim;
|
|
493
|
+
const maskData = attentionMask?.data || null;
|
|
494
|
+
|
|
495
|
+
// Apply sequential projection stages
|
|
496
|
+
for (const { weight, inDim, outDim } of projectionStages) {
|
|
497
|
+
const projected = new Float32Array(batch * seqLen * outDim);
|
|
498
|
+
for (let b = 0; b < batch; b++) {
|
|
499
|
+
for (let s = 0; s < seqLen; s++) {
|
|
500
|
+
const srcOff = (b * seqLen + s) * currentDim;
|
|
501
|
+
const dstOff = (b * seqLen + s) * outDim;
|
|
502
|
+
for (let o = 0; o < outDim; o++) {
|
|
503
|
+
let sum = 0;
|
|
504
|
+
for (let i = 0; i < currentDim; i++) sum += weight[o * currentDim + i] * data[srcOff + i];
|
|
505
|
+
projected[dstOff + o] = sum;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
data = projected;
|
|
510
|
+
currentDim = outDim;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// L2 normalize per token, grouped by batch item.
|
|
514
|
+
// Also record pre-normalization norms — these measure how much the model
|
|
515
|
+
// "activated" for each token. Low pre-norm = low information content.
|
|
516
|
+
// Stored on each per-document array for optional norm-based pruning.
|
|
517
|
+
const results = new Array(batch);
|
|
518
|
+
for (let b = 0; b < batch; b++) {
|
|
519
|
+
const vectors = [];
|
|
520
|
+
const preNorms = [];
|
|
521
|
+
for (let s = 0; s < seqLen; s++) {
|
|
522
|
+
if (maskData) {
|
|
523
|
+
const maskValue = maskData[b * seqLen + s];
|
|
524
|
+
if (typeof maskValue === 'bigint' ? maskValue === 0n : maskValue === 0) continue;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const offset = (b * seqLen + s) * currentDim;
|
|
528
|
+
let norm = 0;
|
|
529
|
+
for (let d = 0; d < currentDim; d++) norm += data[offset + d] * data[offset + d];
|
|
530
|
+
norm = Math.sqrt(norm) + 1e-12;
|
|
531
|
+
preNorms.push(norm);
|
|
532
|
+
const vec = new Float32Array(currentDim);
|
|
533
|
+
for (let d = 0; d < currentDim; d++) vec[d] = data[offset + d] / norm;
|
|
534
|
+
vectors.push(vec);
|
|
535
|
+
}
|
|
536
|
+
vectors.preNorms = new Float32Array(preNorms);
|
|
537
|
+
results[b] = vectors;
|
|
538
|
+
}
|
|
539
|
+
return results;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// =========================================================================
|
|
543
|
+
// Token Pooling (Phase 7.1)
|
|
544
|
+
// =========================================================================
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Hierarchical token pooling — reduces N tokens to ~N/poolFactor tokens.
|
|
548
|
+
* Uses agglomerative clustering (Ward-like, cosine distance) to merge the
|
|
549
|
+
* most similar tokens first, preserving semantic information far better than
|
|
550
|
+
* consecutive-pair averaging.
|
|
551
|
+
*
|
|
552
|
+
* CRA-1: Replaces naive consecutive-pair pooling with similarity-based
|
|
553
|
+
* hierarchical pooling per LIR'26 Workshop findings (arXiv 2603.22434).
|
|
554
|
+
*
|
|
555
|
+
* First token always preserved (protected_tokens=1, following PyLate convention).
|
|
556
|
+
*
|
|
557
|
+
* @param {Float32Array[]} tokens - L2-normalized token vectors
|
|
558
|
+
* @param {number} poolFactor - Pooling factor (2 = halve, 3 = third, etc.)
|
|
559
|
+
* @returns {Float32Array[]} Pooled and re-normalized token vectors
|
|
560
|
+
*/
|
|
561
|
+
export function poolTokens(tokens, poolFactor) {
|
|
562
|
+
if (!tokens || tokens.length === 0 || poolFactor <= 1) return tokens;
|
|
563
|
+
|
|
564
|
+
const dim = tokens[0].length;
|
|
565
|
+
|
|
566
|
+
// Protect first token
|
|
567
|
+
const protectedToken = tokens[0];
|
|
568
|
+
const rest = tokens.length - 1;
|
|
569
|
+
if (rest === 0) return [protectedToken];
|
|
570
|
+
|
|
571
|
+
const targetCount = Math.ceil(rest / poolFactor);
|
|
572
|
+
|
|
573
|
+
// For very small inputs (≤2 non-protected tokens) or targetCount >= rest,
|
|
574
|
+
// skip clustering overhead.
|
|
575
|
+
if (targetCount >= rest) {
|
|
576
|
+
return tokens;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Cap clustering input at 64 non-protected tokens to avoid O(n^3) blowup.
|
|
580
|
+
// If more tokens exist, pre-reduce with consecutive-pair averaging first.
|
|
581
|
+
const CLUSTER_CAP = 64;
|
|
582
|
+
let clusterInput = tokens.slice(1); // non-protected tokens
|
|
583
|
+
if (clusterInput.length > CLUSTER_CAP) {
|
|
584
|
+
const prePoolFactor = Math.ceil(clusterInput.length / CLUSTER_CAP);
|
|
585
|
+
const prePooled = [];
|
|
586
|
+
for (let i = 0; i < clusterInput.length; i += prePoolFactor) {
|
|
587
|
+
const groupEnd = Math.min(i + prePoolFactor, clusterInput.length);
|
|
588
|
+
const groupSize = groupEnd - i;
|
|
589
|
+
const avg = new Float32Array(dim);
|
|
590
|
+
for (let j = i; j < groupEnd; j++) {
|
|
591
|
+
for (let d = 0; d < dim; d++) avg[d] += clusterInput[j][d];
|
|
592
|
+
}
|
|
593
|
+
let norm = 0;
|
|
594
|
+
for (let d = 0; d < dim; d++) { avg[d] /= groupSize; norm += avg[d] * avg[d]; }
|
|
595
|
+
norm = Math.sqrt(norm) + 1e-12;
|
|
596
|
+
for (let d = 0; d < dim; d++) avg[d] /= norm;
|
|
597
|
+
prePooled.push(avg);
|
|
598
|
+
}
|
|
599
|
+
clusterInput = prePooled;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Recompute target for the (possibly pre-reduced) input
|
|
603
|
+
const clusterTarget = Math.max(1, Math.min(targetCount, clusterInput.length - 1));
|
|
604
|
+
if (clusterTarget >= clusterInput.length) {
|
|
605
|
+
return [protectedToken, ...clusterInput];
|
|
606
|
+
}
|
|
607
|
+
const restN = clusterInput.length;
|
|
608
|
+
|
|
609
|
+
// ---- Agglomerative clustering (average-link, cosine distance) ----
|
|
610
|
+
// Each cluster starts as a single token. We maintain cluster centroids
|
|
611
|
+
// (sum vectors + size) and a condensed distance matrix.
|
|
612
|
+
|
|
613
|
+
// Cluster state: indices 0..restN-1 map to clusterInput[0..restN-1]
|
|
614
|
+
const clusterSum = new Array(restN);
|
|
615
|
+
const clusterSize = new Uint16Array(restN);
|
|
616
|
+
const alive = new Uint8Array(restN); // 1 = active cluster
|
|
617
|
+
|
|
618
|
+
for (let i = 0; i < restN; i++) {
|
|
619
|
+
clusterSum[i] = new Float32Array(clusterInput[i]);
|
|
620
|
+
clusterSize[i] = 1;
|
|
621
|
+
alive[i] = 1;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Condensed upper-triangular cosine distance matrix.
|
|
625
|
+
// For L2-normalized vectors, cosine_distance = 1 - dot(a, b).
|
|
626
|
+
const nPairs = (restN * (restN - 1)) >> 1;
|
|
627
|
+
const dist = new Float32Array(nPairs);
|
|
628
|
+
|
|
629
|
+
for (let i = 0; i < restN; i++) {
|
|
630
|
+
const vi = clusterInput[i];
|
|
631
|
+
for (let j = i + 1; j < restN; j++) {
|
|
632
|
+
const vj = clusterInput[j];
|
|
633
|
+
let dot = 0;
|
|
634
|
+
for (let d = 0; d < dim; d++) dot += vi[d] * vj[d];
|
|
635
|
+
dist[i * restN - ((i * (i + 1)) >> 1) + j - i - 1] = 1 - dot;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
let numClusters = restN;
|
|
640
|
+
|
|
641
|
+
// Merge until we reach clusterTarget
|
|
642
|
+
while (numClusters > clusterTarget) {
|
|
643
|
+
let minDist = Infinity;
|
|
644
|
+
let mi = -1, mj = -1;
|
|
645
|
+
|
|
646
|
+
for (let i = 0; i < restN; i++) {
|
|
647
|
+
if (!alive[i]) continue;
|
|
648
|
+
for (let j = i + 1; j < restN; j++) {
|
|
649
|
+
if (!alive[j]) continue;
|
|
650
|
+
const idx = i * restN - ((i * (i + 1)) >> 1) + j - i - 1;
|
|
651
|
+
if (dist[idx] < minDist) {
|
|
652
|
+
minDist = dist[idx];
|
|
653
|
+
mi = i;
|
|
654
|
+
mj = j;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (mi < 0) break;
|
|
660
|
+
|
|
661
|
+
const sizeI = clusterSize[mi];
|
|
662
|
+
const sizeJ = clusterSize[mj];
|
|
663
|
+
const newSize = sizeI + sizeJ;
|
|
664
|
+
|
|
665
|
+
for (let d = 0; d < dim; d++) {
|
|
666
|
+
clusterSum[mi][d] += clusterSum[mj][d];
|
|
667
|
+
}
|
|
668
|
+
clusterSize[mi] = newSize;
|
|
669
|
+
alive[mj] = 0;
|
|
670
|
+
numClusters--;
|
|
671
|
+
|
|
672
|
+
// Update distances from mi to all other alive clusters.
|
|
673
|
+
const centroid = new Float32Array(dim);
|
|
674
|
+
let cNorm = 0;
|
|
675
|
+
for (let d = 0; d < dim; d++) {
|
|
676
|
+
centroid[d] = clusterSum[mi][d] / newSize;
|
|
677
|
+
cNorm += centroid[d] * centroid[d];
|
|
678
|
+
}
|
|
679
|
+
cNorm = Math.sqrt(cNorm) + 1e-12;
|
|
680
|
+
for (let d = 0; d < dim; d++) centroid[d] /= cNorm;
|
|
681
|
+
|
|
682
|
+
for (let k = 0; k < restN; k++) {
|
|
683
|
+
if (!alive[k] || k === mi) continue;
|
|
684
|
+
const sk = clusterSize[k];
|
|
685
|
+
let dot = 0;
|
|
686
|
+
for (let d = 0; d < dim; d++) {
|
|
687
|
+
dot += centroid[d] * (clusterSum[k][d] / sk);
|
|
688
|
+
}
|
|
689
|
+
let kNorm = 0;
|
|
690
|
+
for (let d = 0; d < dim; d++) {
|
|
691
|
+
const v = clusterSum[k][d] / sk;
|
|
692
|
+
kNorm += v * v;
|
|
693
|
+
}
|
|
694
|
+
kNorm = Math.sqrt(kNorm) + 1e-12;
|
|
695
|
+
|
|
696
|
+
const newDist = 1 - dot / kNorm;
|
|
697
|
+
const lo = Math.min(mi, k);
|
|
698
|
+
const hi = Math.max(mi, k);
|
|
699
|
+
const idx = lo * restN - ((lo * (lo + 1)) >> 1) + hi - lo - 1;
|
|
700
|
+
dist[idx] = newDist;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// ---- Extract pooled tokens ----
|
|
705
|
+
const pooled = [protectedToken];
|
|
706
|
+
|
|
707
|
+
for (let i = 0; i < restN; i++) {
|
|
708
|
+
if (!alive[i]) continue;
|
|
709
|
+
const avg = new Float32Array(dim);
|
|
710
|
+
const s = clusterSize[i];
|
|
711
|
+
for (let d = 0; d < dim; d++) avg[d] = clusterSum[i][d] / s;
|
|
712
|
+
|
|
713
|
+
// L2 re-normalize
|
|
714
|
+
let norm = 0;
|
|
715
|
+
for (let d = 0; d < dim; d++) norm += avg[d] * avg[d];
|
|
716
|
+
norm = Math.sqrt(norm) + 1e-12;
|
|
717
|
+
for (let d = 0; d < dim; d++) avg[d] /= norm;
|
|
718
|
+
|
|
719
|
+
pooled.push(avg);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
return pooled;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// =========================================================================
|
|
726
|
+
// Extended Skiplist for Code (Phase 7.2)
|
|
727
|
+
// =========================================================================
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Additional single-char tokens that are pure syntax noise in code.
|
|
731
|
+
* NOT IDF-based — explicitly curated to avoid removing meaningful tokens.
|
|
732
|
+
* Common tokens like `return`, `function`, `class` are NOT included.
|
|
733
|
+
*/
|
|
734
|
+
const CODE_EXTENDED_SKIPLIST_CHARS = [
|
|
735
|
+
'\t', '\n', '\r',
|
|
736
|
+
';', ',',
|
|
737
|
+
'\\', '`',
|
|
738
|
+
];
|
|
739
|
+
|
|
740
|
+
let _extendedSkiplistCache = null;
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Build extended skiplist = base punctuation + code-specific noise tokens.
|
|
744
|
+
* Cached after first build (tokenizer is deterministic).
|
|
745
|
+
*/
|
|
746
|
+
export function buildExtendedSkiplist(tokenizer, baseSkiplist) {
|
|
747
|
+
if (_extendedSkiplistCache) return _extendedSkiplistCache;
|
|
748
|
+
|
|
749
|
+
const extended = new Set(baseSkiplist);
|
|
750
|
+
for (const ch of CODE_EXTENDED_SKIPLIST_CHARS) {
|
|
751
|
+
const enc = tokenizer(ch, { add_special_tokens: false });
|
|
752
|
+
const ids = Array.from(enc.input_ids.data);
|
|
753
|
+
if (ids.length === 1) extended.add(Number(ids[0]));
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
_extendedSkiplistCache = extended;
|
|
757
|
+
return extended;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/** Reset extended skiplist cache (for testing) */
|
|
761
|
+
export function _resetExtendedSkiplistCache() { _extendedSkiplistCache = null; }
|
|
762
|
+
|
|
763
|
+
// =========================================================================
|
|
764
|
+
// ORT Inference (raw — no projection)
|
|
765
|
+
// =========================================================================
|
|
766
|
+
|
|
767
|
+
async function runRawInference(session, tokenized, ort) {
|
|
768
|
+
// Fast path: native tokenizer always produces BigInt64Array via formatResult.
|
|
769
|
+
// Fallback uses Uint32Array overlay to avoid BigInt() heap allocations.
|
|
770
|
+
let idData = tokenized.input_ids.data;
|
|
771
|
+
if (!(idData instanceof BigInt64Array)) {
|
|
772
|
+
const src = tokenized.input_ids.data;
|
|
773
|
+
idData = new BigInt64Array(src.length);
|
|
774
|
+
const u32 = new Uint32Array(idData.buffer);
|
|
775
|
+
for (let i = 0; i < src.length; i++) u32[i * 2] = Number(src[i]);
|
|
776
|
+
}
|
|
777
|
+
let maskData = tokenized.attention_mask.data;
|
|
778
|
+
if (!(maskData instanceof BigInt64Array)) {
|
|
779
|
+
const src = tokenized.attention_mask.data;
|
|
780
|
+
maskData = new BigInt64Array(src.length);
|
|
781
|
+
const u32 = new Uint32Array(maskData.buffer);
|
|
782
|
+
for (let i = 0; i < src.length; i++) u32[i * 2] = Number(src[i]);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const feeds = {
|
|
786
|
+
input_ids: new ort.Tensor('int64', idData, tokenized.input_ids.dims),
|
|
787
|
+
attention_mask: new ort.Tensor('int64', maskData, tokenized.attention_mask.dims),
|
|
788
|
+
};
|
|
789
|
+
|
|
790
|
+
const result = await session.run(feeds);
|
|
791
|
+
return result[session.outputNames[0]];
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// =========================================================================
|
|
795
|
+
// HuggingFace File Download + Cache
|
|
796
|
+
// =========================================================================
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Resolve or download a model file from HuggingFace using the managed model fetcher.
|
|
800
|
+
* Checks local cache first (with checksum validation for LFS files).
|
|
801
|
+
* Respects MODEL_DELIVERY_CONFIG.allowRuntimeModelDownload.
|
|
802
|
+
*/
|
|
803
|
+
async function resolveOrFetchFile(hfId, filePath, registryKey) {
|
|
804
|
+
const entry = getModelEntry(registryKey);
|
|
805
|
+
const fileInfo = entry?.files.find(f => f.path === filePath);
|
|
806
|
+
|
|
807
|
+
const destDir = getModelCacheDir(hfId);
|
|
808
|
+
return fetchModelFile(hfId, filePath, destDir, {
|
|
809
|
+
sha256: fileInfo?.sha256 || undefined,
|
|
810
|
+
expectedSize: fileInfo?.sizeBytes || undefined,
|
|
811
|
+
});
|
|
812
|
+
}
|