sweet-search 2.4.2 → 2.5.2
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/core/cli.js +43 -5
- package/core/embedding/embedding-cache.js +266 -18
- package/core/embedding/embedding-service.js +45 -9
- package/core/graph/graph-expansion.js +52 -12
- package/core/graph/graph-extractor.js +30 -1
- package/core/indexing/ast-chunker.js +331 -16
- package/core/indexing/chunking/chunk-builder.js +34 -1
- package/core/indexing/index-codebase-v21.js +31 -2
- package/core/indexing/index.js +6 -3
- package/core/indexing/indexer-ann.js +45 -6
- package/core/indexing/indexer-build.js +9 -1
- package/core/indexing/indexer-phases.js +6 -4
- package/core/indexing/indexing-file-policy.js +140 -0
- package/core/indexing/li-skip-policy.js +11 -220
- package/core/infrastructure/codebase-repository.js +21 -0
- package/core/infrastructure/config/embedding.js +20 -1
- package/core/infrastructure/config/graph.js +2 -2
- package/core/infrastructure/config/ranking.js +10 -0
- package/core/infrastructure/config/vector-store.js +1 -1
- package/core/infrastructure/coreml-cascade.js +236 -30
- package/core/infrastructure/coreml-cascade.json +25 -0
- package/core/infrastructure/index.js +17 -0
- package/core/infrastructure/init-config.js +216 -0
- package/core/infrastructure/language-patterns/registry-core.js +18 -0
- package/core/infrastructure/model-registry.js +12 -0
- package/core/infrastructure/native-inference.js +143 -51
- package/core/infrastructure/tree-sitter-provider.js +92 -2
- package/core/ranking/cascaded-scorer.js +6 -2
- package/core/ranking/file-kind-ranking.js +264 -0
- package/core/ranking/late-interaction-index.js +10 -4
- package/core/ranking/late-interaction-policy.js +304 -0
- package/core/search/context-expander.js +267 -28
- package/core/search/index.js +4 -0
- package/core/search/search-cli.js +3 -1
- package/core/search/search-pattern.js +4 -3
- package/core/search/search-postprocess.js +189 -8
- package/core/search/search-read-semantic.js +734 -0
- package/core/search/search-read.js +481 -0
- package/core/search/search-server.js +153 -5
- package/core/search/sweet-search.js +133 -16
- package/core/start-server.js +13 -2
- package/mcp/server.js +41 -0
- package/mcp/tool-handlers.js +117 -6
- package/package.json +9 -7
- package/scripts/init.js +386 -5
- package/scripts/uninstall.js +152 -6
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared reader/writer for the init-managed `.sweet-search/config.json`
|
|
3
|
+
* file. Owned by the infrastructure layer because both `scripts/init.js`
|
|
4
|
+
* (writer) and `core/search/sweet-search.js` (reader) need it; placing
|
|
5
|
+
* the helper here avoids a runtime → scripts dependency that would break
|
|
6
|
+
* the DDD boundary check.
|
|
7
|
+
*
|
|
8
|
+
* The file shape is documented in `scripts/init.js::buildConfig` — this
|
|
9
|
+
* module only loads/writes raw JSON, never validates business invariants.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
|
|
15
|
+
import { LATE_INTERACTION_CONFIG } from './config/ranking.js';
|
|
16
|
+
|
|
17
|
+
export const INIT_DATA_DIR_NAME = '.sweet-search';
|
|
18
|
+
export const INIT_CONFIG_FILE_NAME = 'config.json';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Path to the project's persisted init config. Pure path math — does not
|
|
22
|
+
* verify the file exists.
|
|
23
|
+
*/
|
|
24
|
+
export function getInitConfigPath(projectRoot) {
|
|
25
|
+
return join(projectRoot, INIT_DATA_DIR_NAME, INIT_CONFIG_FILE_NAME);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Load the persisted init config. Returns null when the file is missing
|
|
30
|
+
* or unparseable — never throws. Callers fall back to defaults / env.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} projectRootOrDataDir - either the project root or the
|
|
33
|
+
* `.sweet-search/` directory itself (init.js passes the latter).
|
|
34
|
+
*/
|
|
35
|
+
export function loadInitConfig(projectRootOrDataDir) {
|
|
36
|
+
const candidates = [
|
|
37
|
+
join(projectRootOrDataDir, INIT_CONFIG_FILE_NAME),
|
|
38
|
+
join(projectRootOrDataDir, INIT_DATA_DIR_NAME, INIT_CONFIG_FILE_NAME),
|
|
39
|
+
];
|
|
40
|
+
for (const p of candidates) {
|
|
41
|
+
if (existsSync(p)) {
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(readFileSync(p, 'utf-8'));
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Atomically write the init config (tmp + rename). Caller passes a
|
|
54
|
+
* directory that already exists (init.js's `ensureDataDir`). Returns
|
|
55
|
+
* the path that was written.
|
|
56
|
+
*/
|
|
57
|
+
export function writeInitConfig(dataDir, config) {
|
|
58
|
+
const configPath = join(dataDir, INIT_CONFIG_FILE_NAME);
|
|
59
|
+
const tmpPath = configPath + '.tmp';
|
|
60
|
+
writeFileSync(tmpPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
|
|
61
|
+
renameSync(tmpPath, configPath);
|
|
62
|
+
return configPath;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Convenience accessor for the LI-policy section of the persisted init
|
|
67
|
+
* config. Returns `{ liModel, searchReranking }` if either field is
|
|
68
|
+
* present, otherwise an empty object — the resolver treats absent fields
|
|
69
|
+
* as "fall through to auto / config defaults".
|
|
70
|
+
*/
|
|
71
|
+
export function readPersistedLiPolicy(projectRoot) {
|
|
72
|
+
const cfg = loadInitConfig(projectRoot);
|
|
73
|
+
if (!cfg || typeof cfg !== 'object') return {};
|
|
74
|
+
const li = cfg.runtime?.li;
|
|
75
|
+
if (!li || typeof li !== 'object') return {};
|
|
76
|
+
const out = {};
|
|
77
|
+
if (typeof li.model === 'string') out.liModel = li.model;
|
|
78
|
+
if (typeof li.searchReranking === 'string') out.searchReranking = li.searchReranking;
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Runtime LI model resolution (Phase 4 follow-up).
|
|
84
|
+
//
|
|
85
|
+
// Background: scripts/init.js writes the user's chosen LI variant into
|
|
86
|
+
// `runtime.li.model` of `.sweet-search/config.json` (one of 'lateon-code',
|
|
87
|
+
// 'lateon-code-edge', or 'none'). However the runtime late-interaction
|
|
88
|
+
// machinery — encodeQuery, the LI index header check, the native model
|
|
89
|
+
// loader, the CoreML cascade dispatcher, the indexer, the prewarm hook —
|
|
90
|
+
// all read `LATE_INTERACTION_CONFIG.model` directly from
|
|
91
|
+
// `core/infrastructure/config/ranking.js`, which only honours
|
|
92
|
+
// `process.env.SWEET_SEARCH_LATE_INTERACTION_MODEL`. Without this bridge
|
|
93
|
+
// an edge-only init would silently boot the standard model on every search,
|
|
94
|
+
// breaking `read-semantic`, ColGrep, and every encodeQuery path even when
|
|
95
|
+
// the persisted choice is correct.
|
|
96
|
+
//
|
|
97
|
+
// `applyPersistedLiModel` closes that gap. It is called from one place per
|
|
98
|
+
// process entry point (SweetSearch ctor, read-semantic CLI/API, server
|
|
99
|
+
// startup, prewarm hook) and is fully idempotent + cheap.
|
|
100
|
+
//
|
|
101
|
+
// Precedence (most-specific wins):
|
|
102
|
+
// 1. explicit env var SWEET_SEARCH_LATE_INTERACTION_MODEL (CI / scripts)
|
|
103
|
+
// 2. persisted runtime.li.model in .sweet-search/config.json
|
|
104
|
+
// 3. config default ('lateon-code')
|
|
105
|
+
//
|
|
106
|
+
// `'none'` from persisted config maps to the same disabled state the
|
|
107
|
+
// indexer's `--no-late-interaction` flag uses (`model = false`), so
|
|
108
|
+
// `LATE_INTERACTION_CONFIG.enabled` collapses to false everywhere LI is
|
|
109
|
+
// consulted.
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
const VALID_RUNTIME_LI_MODEL_IDS = new Set([
|
|
113
|
+
'lateon-code',
|
|
114
|
+
'lateon-code-edge',
|
|
115
|
+
'none',
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Resolve the active LI runtime model id given precedence inputs. Pure;
|
|
120
|
+
* does no I/O of its own. Caller passes in the persisted record (or null).
|
|
121
|
+
*
|
|
122
|
+
* Returns one of:
|
|
123
|
+
* - 'lateon-code' → standard variant
|
|
124
|
+
* - 'lateon-code-edge' → edge variant
|
|
125
|
+
* - false → LI disabled (matches index-codebase-v21's `--no-late-interaction`)
|
|
126
|
+
*
|
|
127
|
+
* @param {string|null|undefined} persistedModel
|
|
128
|
+
* @param {object} [env=process.env]
|
|
129
|
+
* @param {string} [defaultModel='lateon-code']
|
|
130
|
+
*/
|
|
131
|
+
export function resolveRuntimeLiModel(
|
|
132
|
+
persistedModel,
|
|
133
|
+
env = process.env,
|
|
134
|
+
defaultModel = 'lateon-code',
|
|
135
|
+
) {
|
|
136
|
+
// 1. Explicit env override always wins (preserves the CI/script contract
|
|
137
|
+
// documented on LATE_INTERACTION_CONFIG.model).
|
|
138
|
+
const fromEnv = env?.SWEET_SEARCH_LATE_INTERACTION_MODEL;
|
|
139
|
+
if (typeof fromEnv === 'string' && fromEnv.length > 0) {
|
|
140
|
+
if (fromEnv === 'none') return false;
|
|
141
|
+
if (fromEnv === 'false') return false;
|
|
142
|
+
return fromEnv;
|
|
143
|
+
}
|
|
144
|
+
// 2. Persisted choice from .sweet-search/config.json.
|
|
145
|
+
if (typeof persistedModel === 'string' && VALID_RUNTIME_LI_MODEL_IDS.has(persistedModel)) {
|
|
146
|
+
if (persistedModel === 'none') return false;
|
|
147
|
+
return persistedModel;
|
|
148
|
+
}
|
|
149
|
+
// 3. Default — preserves the historical 'lateon-code' fallback.
|
|
150
|
+
return defaultModel;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Apply the persisted LI model choice from `.sweet-search/config.json`
|
|
155
|
+
* to the global `LATE_INTERACTION_CONFIG.model`, honouring the precedence
|
|
156
|
+
* ladder documented above. Idempotent + cheap; safe to call from every
|
|
157
|
+
* runtime entry point.
|
|
158
|
+
*
|
|
159
|
+
* Returns a small report object so the caller can log / surface the
|
|
160
|
+
* decision when verbose. Never throws.
|
|
161
|
+
*
|
|
162
|
+
* {
|
|
163
|
+
* applied: 'lateon-code' | 'lateon-code-edge' | false,
|
|
164
|
+
* before: <prior LATE_INTERACTION_CONFIG.model>,
|
|
165
|
+
* source: 'env' | 'persisted' | 'default',
|
|
166
|
+
* persistedModel: <raw persisted value or null>,
|
|
167
|
+
* changed: boolean,
|
|
168
|
+
* }
|
|
169
|
+
*
|
|
170
|
+
* @param {string} projectRoot
|
|
171
|
+
* @param {object} [opts]
|
|
172
|
+
* @param {object} [opts.env=process.env]
|
|
173
|
+
* @param {boolean} [opts.force=false] — when true, applies even if the
|
|
174
|
+
* active value already matches (used by tests resetting state).
|
|
175
|
+
*/
|
|
176
|
+
export function applyPersistedLiModel(projectRoot, opts = {}) {
|
|
177
|
+
const env = opts.env ?? process.env;
|
|
178
|
+
|
|
179
|
+
const before = LATE_INTERACTION_CONFIG.model;
|
|
180
|
+
|
|
181
|
+
let persistedModel = null;
|
|
182
|
+
try {
|
|
183
|
+
const persisted = readPersistedLiPolicy(projectRoot);
|
|
184
|
+
persistedModel = typeof persisted.liModel === 'string' ? persisted.liModel : null;
|
|
185
|
+
} catch {
|
|
186
|
+
persistedModel = null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const resolved = resolveRuntimeLiModel(persistedModel, env, 'lateon-code');
|
|
190
|
+
|
|
191
|
+
let source;
|
|
192
|
+
if (typeof env?.SWEET_SEARCH_LATE_INTERACTION_MODEL === 'string'
|
|
193
|
+
&& env.SWEET_SEARCH_LATE_INTERACTION_MODEL.length > 0) {
|
|
194
|
+
source = 'env';
|
|
195
|
+
} else if (persistedModel != null && VALID_RUNTIME_LI_MODEL_IDS.has(persistedModel)) {
|
|
196
|
+
source = 'persisted';
|
|
197
|
+
} else {
|
|
198
|
+
source = 'default';
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (opts.force === true || resolved !== before) {
|
|
202
|
+
// Mutates the shared LATE_INTERACTION_CONFIG singleton — this is the
|
|
203
|
+
// entire point of this helper. The ESM namespace binding is read-only
|
|
204
|
+
// but the OBJECT it references is the same reference every consumer
|
|
205
|
+
// (encodeQuery, indexer, native-inference, prewarm) holds.
|
|
206
|
+
LATE_INTERACTION_CONFIG.model = resolved;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
applied: resolved,
|
|
211
|
+
before,
|
|
212
|
+
source,
|
|
213
|
+
persistedModel,
|
|
214
|
+
changed: resolved !== before,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
@@ -82,12 +82,30 @@ export const CORE_LANGUAGES = {
|
|
|
82
82
|
relationships: {
|
|
83
83
|
extends: /class\s+\w+\s+extends\s+(\w+)/,
|
|
84
84
|
implements: /class\s+\w+(?:\s+extends\s+\w+)?\s+implements\s+([\w,\s]+)/,
|
|
85
|
+
// TS-specific: interface extends interface(s). Captures the
|
|
86
|
+
// comma-separated list (with optional generics) — splitting is
|
|
87
|
+
// handled by expandRelationshipTargets() via MULTI_TARGET_TYPES.
|
|
88
|
+
// Capture is lazy and bounded by either the opening `{` of the
|
|
89
|
+
// body or end-of-line, which covers both `extends X {`,
|
|
90
|
+
// `extends X {}` (empty body) and continuation-line `extends X`.
|
|
91
|
+
interfaceExtends: /^(?:export\s+)?interface\s+\w+(?:<[^>]*>)?\s+extends\s+([\w,\s<>.]+?)\s*(?:\{|$)/,
|
|
92
|
+
// TS-specific: explicit type-only imports/re-exports.
|
|
93
|
+
// The body of `import { type Foo, Bar }` (mixed inline-type
|
|
94
|
+
// imports) is still picked up by the regular `import` pattern
|
|
95
|
+
// for module-level dependency tracking; we don't try to split
|
|
96
|
+
// type/value at member level in regex.
|
|
97
|
+
typeImport: /^import\s+type\s+(?:\{[^}]+\}|\*\s+as\s+\w+|\w+)\s+from\s+['"]([^'"]+)['"]/,
|
|
98
|
+
typeReexport: /^export\s+type\s+(?:\{[^}]+\}|\*)\s+from\s+['"]([^'"]+)['"]/,
|
|
85
99
|
import: /import\s+(?:\{([^}]+)\}|(\w+))\s+from\s+['"]([^'"]+)['"]/,
|
|
86
100
|
require: /(?:const|let|var)\s+(?:\{[^}]+\}|\w+)\s*=\s*require\s*\(\s*['"]([^'"]+)['"]\s*\)/,
|
|
87
101
|
reexport: /export\s+(?:\{[^}]+\}|\*)\s+from\s+['"]([^'"]+)['"]/,
|
|
88
102
|
dynamicImport: /(?:await\s+)?import\s*\(\s*['"]([^'"]+)['"]\s*\)/,
|
|
89
103
|
methodCall: /(\w+)\s*\.\s*(\w+)\s*\(/,
|
|
90
104
|
decorator: /^@(\w+(?:\.\w+)*)/,
|
|
105
|
+
// TS-specific: generic constraints `<T extends Bar>`. JSX is
|
|
106
|
+
// safe because `<Component prop=...>` has no `extends` keyword;
|
|
107
|
+
// matching is conservative on the literal `extends` token.
|
|
108
|
+
genericConstraint: /<\s*\w+\s+extends\s+(\w+)/,
|
|
91
109
|
},
|
|
92
110
|
skipCallObjects: ["console", "Math", "JSON", "Object", "Array", "Promise", "process", "Buffer", "Date"],
|
|
93
111
|
},
|
|
@@ -103,6 +103,18 @@ export const MODEL_REGISTRY = {
|
|
|
103
103
|
],
|
|
104
104
|
},
|
|
105
105
|
|
|
106
|
+
'lateon-code-edge-fp32': {
|
|
107
|
+
hfId: 'lightonai/LateOn-Code-edge',
|
|
108
|
+
profile: 'full',
|
|
109
|
+
description: 'Late interaction edge model (FP32 safetensors, backbone 256d, 2-stage projection) for native inference',
|
|
110
|
+
files: [
|
|
111
|
+
{ path: 'model.safetensors', sizeBytes: 67195976, sha256: '7ffc36b8ff71367249cd5220dbdd4bdbe177bc0e305b2e978a8b598bd8296f04' },
|
|
112
|
+
{ path: '1_Dense/model.safetensors', sizeBytes: 524376, sha256: '9efb17fcb2106cd8fcb01d57a9cd9c997a487ad20630ec8e44ce3f9d89efe0a7' },
|
|
113
|
+
{ path: '2_Dense/model.safetensors', sizeBytes: 98392, sha256: 'a7a388138b3c4bb1a81c8c3bcb9de123f1e652b9e9464a72707ca19ee86a26b1' },
|
|
114
|
+
{ path: 'config.json', sizeBytes: 1252, sha256: null },
|
|
115
|
+
],
|
|
116
|
+
},
|
|
117
|
+
|
|
106
118
|
'ms-marco-tinybert': {
|
|
107
119
|
hfId: 'Xenova/ms-marco-TinyBERT-L-2-v2',
|
|
108
120
|
profile: 'full',
|
|
@@ -55,6 +55,7 @@ import { getModelCacheDir, fetchModel } from './model-fetcher.js';
|
|
|
55
55
|
import { getModelEntry } from './model-registry.js';
|
|
56
56
|
import { getCoremlCascadeResolvedDirs } from './coreml-cascade.js';
|
|
57
57
|
import { detectHardwareCapability } from './hardware-capability.js';
|
|
58
|
+
import { LATE_INTERACTION_CONFIG } from './config/ranking.js';
|
|
58
59
|
|
|
59
60
|
const require = createRequire(import.meta.url);
|
|
60
61
|
|
|
@@ -63,12 +64,21 @@ const require = createRequire(import.meta.url);
|
|
|
63
64
|
let _addon = null;
|
|
64
65
|
let _embeddingModel = null;
|
|
65
66
|
let _embeddingModelLoadPromise = null; // race-gate for concurrent first calls
|
|
66
|
-
|
|
67
|
-
|
|
67
|
+
// Per-variant LI model cache. Keyed by FP32 registry key
|
|
68
|
+
// ('lateon-code-fp32' or 'lateon-code-edge-fp32') so a variant swap
|
|
69
|
+
// inside a single process (e.g. ORT-eval session followed by native
|
|
70
|
+
// indexing) doesn't return a stale model. Each entry is
|
|
71
|
+
// `{ model, promise }` where `promise` race-gates concurrent first
|
|
72
|
+
// calls and `model` becomes non-null on resolution.
|
|
73
|
+
const _liModels = new Map();
|
|
68
74
|
let _embTokenizer = null;
|
|
69
75
|
let _embTokenizerLoadPromise = null;
|
|
70
|
-
|
|
71
|
-
|
|
76
|
+
// Per-variant LI tokenizer cache. Keyed by tokenizer source key
|
|
77
|
+
// (matches the ORT-side registry key — `lateon-code` /
|
|
78
|
+
// `lateon-code-edge`). Standard and edge tokenizer.json files are
|
|
79
|
+
// byte-identical today but per-variant resolution is correct and
|
|
80
|
+
// future-proof.
|
|
81
|
+
const _liTokenizers = new Map();
|
|
72
82
|
let _available = null;
|
|
73
83
|
let _coremlCascadeLogged = false;
|
|
74
84
|
|
|
@@ -123,12 +133,17 @@ function propagateCudaComputeCapToAddonEnv() {
|
|
|
123
133
|
* Logged exactly once per process so a mis-configured cascade surfaces
|
|
124
134
|
* at startup instead of silently falling through on every call.
|
|
125
135
|
*
|
|
136
|
+
* Routes the LI cascade dir to `coreml-cascade/li/` (standard) or
|
|
137
|
+
* `coreml-cascade/li-edge/` (edge) based on the active variant in
|
|
138
|
+
* `LATE_INTERACTION_CONFIG`. The embed cascade is shared.
|
|
139
|
+
*
|
|
126
140
|
* Always returns an object — never throws. The returned dirs can be
|
|
127
141
|
* `null`, which the Rust addon treats as "CoreML path disabled" and
|
|
128
142
|
* falls back to candle unconditionally.
|
|
129
143
|
*/
|
|
130
144
|
function resolveCoremlCascadeForAddon() {
|
|
131
|
-
const
|
|
145
|
+
const liVariantKey = LATE_INTERACTION_CONFIG.model;
|
|
146
|
+
const resolved = getCoremlCascadeResolvedDirs(liVariantKey);
|
|
132
147
|
if (!_coremlCascadeLogged) {
|
|
133
148
|
_coremlCascadeLogged = true;
|
|
134
149
|
const hw = detectHardwareCapability();
|
|
@@ -137,7 +152,7 @@ function resolveCoremlCascadeForAddon() {
|
|
|
137
152
|
if (resolved.embedDir || resolved.liDir) {
|
|
138
153
|
process.stderr.write(
|
|
139
154
|
`[NativeInference] CoreML cascade: ${resolved.status}` +
|
|
140
|
-
` (embed=${resolved.embedDir ? 'yes' : 'no'}, li=${resolved.liDir ? 'yes' : 'no'},` +
|
|
155
|
+
` (embed=${resolved.embedDir ? 'yes' : 'no'}, li=${resolved.liDir ? 'yes' : 'no'} [${liVariantKey}],` +
|
|
141
156
|
` chip=${hw.brandString || 'unknown'})\n`
|
|
142
157
|
);
|
|
143
158
|
} else if (hw.coremlCascadeEligible) {
|
|
@@ -327,57 +342,117 @@ export async function nativeEmbed(texts, options = {}) {
|
|
|
327
342
|
// ─── Late Interaction Model ───
|
|
328
343
|
|
|
329
344
|
/**
|
|
330
|
-
*
|
|
331
|
-
*
|
|
345
|
+
* Resolve the active LI variant from `LATE_INTERACTION_CONFIG`. Returns
|
|
346
|
+
* the manifest the native loaders need (registry keys + projection
|
|
347
|
+
* paths and dims). Pure helper — no I/O, no caching.
|
|
348
|
+
*
|
|
349
|
+
* Falls back to the standard `lateon-code` entry if the active config
|
|
350
|
+
* is missing fields (defensive — every shipping config has them).
|
|
332
351
|
*/
|
|
333
|
-
export
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
352
|
+
export function resolveNativeLiVariant() {
|
|
353
|
+
const cfg = LATE_INTERACTION_CONFIG.activeModel;
|
|
354
|
+
const cfgKey = LATE_INTERACTION_CONFIG.model;
|
|
355
|
+
if (!cfg) {
|
|
356
|
+
throw new Error(
|
|
357
|
+
`[NativeInference] LATE_INTERACTION_CONFIG.model='${cfgKey}' is not a known variant`,
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
const fp32RegistryKey = cfg.nativeRegistryKey || `${cfgKey}-fp32`;
|
|
361
|
+
return {
|
|
362
|
+
cfgKey, // 'lateon-code' | 'lateon-code-edge'
|
|
363
|
+
fp32RegistryKey, // 'lateon-code-fp32' | 'lateon-code-edge-fp32'
|
|
364
|
+
tokenizerKey: cfgKey, // tokenizer lives next to the ORT model
|
|
365
|
+
projectionPaths: cfg.projectionPaths, // ['1_Dense/...'] | ['1_Dense/...', '2_Dense/...']
|
|
366
|
+
projectionDims: cfg.projectionDims, // [128] | [512, 48]
|
|
367
|
+
tokenDimension: cfg.tokenDimension, // 128 | 48
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Internal: load the native LI model for a specific variant on the
|
|
373
|
+
* default device. Race-gated per variant via the `_liModels` Map so
|
|
374
|
+
* concurrent first callers share one load. Returns null if the addon
|
|
375
|
+
* isn't available or required files are missing.
|
|
376
|
+
*/
|
|
377
|
+
async function loadNativeLiVariantOnDefaultDevice(variant) {
|
|
378
|
+
const cached = _liModels.get(variant.fp32RegistryKey);
|
|
379
|
+
if (cached?.model) return cached.model;
|
|
380
|
+
if (cached?.promise) return cached.promise;
|
|
381
|
+
|
|
382
|
+
const promise = (async () => {
|
|
337
383
|
const addon = loadAddon();
|
|
338
384
|
if (!addon?.NativeLateInteractionModel) return null;
|
|
339
385
|
|
|
340
|
-
await fetchModel(
|
|
386
|
+
await fetchModel(variant.fp32RegistryKey);
|
|
341
387
|
|
|
342
|
-
const entry = getModelEntry(
|
|
388
|
+
const entry = getModelEntry(variant.fp32RegistryKey);
|
|
343
389
|
const modelDir = getModelCacheDir(entry.hfId);
|
|
344
390
|
const backbonePath = join(modelDir, 'model.safetensors');
|
|
345
|
-
const projPath = join(modelDir, '1_Dense', 'model.safetensors');
|
|
346
391
|
const configPath = join(modelDir, 'config.json');
|
|
392
|
+
const projAbsPaths = variant.projectionPaths.map((p) => join(modelDir, p));
|
|
347
393
|
|
|
348
|
-
if (!existsSync(backbonePath) || !existsSync(
|
|
394
|
+
if (!existsSync(backbonePath) || !existsSync(configPath)) return null;
|
|
395
|
+
if (!projAbsPaths.every(existsSync)) return null;
|
|
349
396
|
|
|
350
397
|
// Resolve the CoreML cascade dir for ModernBERT LI. Same contract
|
|
351
|
-
// as the embedding model above — see that comment.
|
|
398
|
+
// as the embedding model above — see that comment. The dir
|
|
399
|
+
// depends on the active variant (`coreml-cascade/li/` vs
|
|
400
|
+
// `coreml-cascade/li-edge/`).
|
|
352
401
|
const cascade = resolveCoremlCascadeForAddon();
|
|
353
402
|
|
|
354
403
|
const t0 = Date.now();
|
|
355
|
-
|
|
404
|
+
const model = addon.NativeLateInteractionModel.load(
|
|
356
405
|
backbonePath,
|
|
357
|
-
|
|
406
|
+
projAbsPaths,
|
|
407
|
+
variant.projectionDims,
|
|
358
408
|
configPath,
|
|
359
409
|
cascade.liDir || undefined,
|
|
360
410
|
);
|
|
361
|
-
console.log(
|
|
411
|
+
console.log(
|
|
412
|
+
`[NativeInference] LI model '${variant.cfgKey}' loaded in ${Date.now() - t0}ms `
|
|
413
|
+
+ `(dim: ${model.dim}, device: ${addon.nativeInferenceDevice()})`,
|
|
414
|
+
);
|
|
362
415
|
|
|
363
|
-
|
|
416
|
+
const slot = _liModels.get(variant.fp32RegistryKey);
|
|
417
|
+
if (slot) slot.model = model;
|
|
418
|
+
return model;
|
|
364
419
|
})();
|
|
365
|
-
|
|
420
|
+
|
|
421
|
+
_liModels.set(variant.fp32RegistryKey, { model: null, promise });
|
|
422
|
+
return promise;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Load the native LI model for the currently-configured variant.
|
|
427
|
+
* Returns the model instance or null if unavailable. Race-gated per
|
|
428
|
+
* variant.
|
|
429
|
+
*/
|
|
430
|
+
export async function getNativeLiModel() {
|
|
431
|
+
const variant = resolveNativeLiVariant();
|
|
432
|
+
return loadNativeLiVariantOnDefaultDevice(variant);
|
|
366
433
|
}
|
|
367
434
|
|
|
368
435
|
/**
|
|
369
|
-
* Get or create the LI tokenizer
|
|
436
|
+
* Get or create the LI tokenizer for the currently-configured variant.
|
|
437
|
+
* Race-gated per variant via the `_liTokenizers` Map.
|
|
370
438
|
*/
|
|
371
439
|
async function getLiTokenizer() {
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
440
|
+
const variant = resolveNativeLiVariant();
|
|
441
|
+
const cached = _liTokenizers.get(variant.tokenizerKey);
|
|
442
|
+
if (cached?.tokenizer) return cached.tokenizer;
|
|
443
|
+
if (cached?.promise) return cached.promise;
|
|
444
|
+
|
|
445
|
+
const promise = (async () => {
|
|
446
|
+
const entry = getModelEntry(variant.tokenizerKey);
|
|
376
447
|
const tokenizerPath = join(getModelCacheDir(entry.hfId), 'tokenizer.json');
|
|
377
|
-
|
|
378
|
-
|
|
448
|
+
const tokenizer = await createTokenizer(tokenizerPath);
|
|
449
|
+
const slot = _liTokenizers.get(variant.tokenizerKey);
|
|
450
|
+
if (slot) slot.tokenizer = tokenizer;
|
|
451
|
+
return tokenizer;
|
|
379
452
|
})();
|
|
380
|
-
|
|
453
|
+
|
|
454
|
+
_liTokenizers.set(variant.tokenizerKey, { tokenizer: null, promise });
|
|
455
|
+
return promise;
|
|
381
456
|
}
|
|
382
457
|
|
|
383
458
|
/**
|
|
@@ -457,7 +532,11 @@ export function isNativeEmbeddingModelLoaded() {
|
|
|
457
532
|
}
|
|
458
533
|
|
|
459
534
|
export function isNativeLiModelLoaded() {
|
|
460
|
-
|
|
535
|
+
// True only when the *active* variant is loaded — a stale standard
|
|
536
|
+
// model lingering after a config swap to edge would otherwise
|
|
537
|
+
// mask the fact that edge encoding still has to load.
|
|
538
|
+
const variant = resolveNativeLiVariant();
|
|
539
|
+
return _liModels.get(variant.fp32RegistryKey)?.model != null;
|
|
461
540
|
}
|
|
462
541
|
|
|
463
542
|
// ─── Device-explicit loading ───
|
|
@@ -518,28 +597,32 @@ export async function loadNativeEmbeddingModelWithDevice(deviceKind, cascadeDirO
|
|
|
518
597
|
}
|
|
519
598
|
|
|
520
599
|
/**
|
|
521
|
-
* Load the native LI model on a specific device
|
|
600
|
+
* Load the native LI model on a specific device for the
|
|
601
|
+
* currently-configured variant. Race-gated per variant.
|
|
522
602
|
*/
|
|
523
603
|
export async function loadNativeLiModelWithDevice(deviceKind, cascadeDirOverride) {
|
|
524
|
-
|
|
525
|
-
|
|
604
|
+
const variant = resolveNativeLiVariant();
|
|
605
|
+
const cached = _liModels.get(variant.fp32RegistryKey);
|
|
606
|
+
if (cached?.model) return cached.model;
|
|
607
|
+
if (cached?.promise) return cached.promise;
|
|
526
608
|
|
|
527
|
-
|
|
609
|
+
const promise = (async () => {
|
|
528
610
|
const addon = loadAddon();
|
|
529
611
|
if (!addon?.NativeLateInteractionModel?.loadWithDevice) return null;
|
|
530
612
|
|
|
531
613
|
// See loadNativeEmbeddingModelWithDevice for why this is CUDA-only.
|
|
532
614
|
if (deviceKind === 'cuda') propagateCudaComputeCapToAddonEnv();
|
|
533
615
|
|
|
534
|
-
await fetchModel(
|
|
616
|
+
await fetchModel(variant.fp32RegistryKey);
|
|
535
617
|
|
|
536
|
-
const entry = getModelEntry(
|
|
618
|
+
const entry = getModelEntry(variant.fp32RegistryKey);
|
|
537
619
|
const modelDir = getModelCacheDir(entry.hfId);
|
|
538
620
|
const backbonePath = join(modelDir, 'model.safetensors');
|
|
539
|
-
const projPath = join(modelDir, '1_Dense', 'model.safetensors');
|
|
540
621
|
const configPath = join(modelDir, 'config.json');
|
|
622
|
+
const projAbsPaths = variant.projectionPaths.map((p) => join(modelDir, p));
|
|
541
623
|
|
|
542
|
-
if (!existsSync(backbonePath) || !existsSync(
|
|
624
|
+
if (!existsSync(backbonePath) || !existsSync(configPath)) return null;
|
|
625
|
+
if (!projAbsPaths.every(existsSync)) return null;
|
|
543
626
|
|
|
544
627
|
// CUDA has no cascade — see the matching comment in
|
|
545
628
|
// loadNativeEmbeddingModelWithDevice.
|
|
@@ -550,19 +633,26 @@ export async function loadNativeLiModelWithDevice(deviceKind, cascadeDirOverride
|
|
|
550
633
|
);
|
|
551
634
|
|
|
552
635
|
const t0 = Date.now();
|
|
553
|
-
|
|
636
|
+
const model = addon.NativeLateInteractionModel.loadWithDevice(
|
|
554
637
|
backbonePath,
|
|
555
|
-
|
|
638
|
+
projAbsPaths,
|
|
639
|
+
variant.projectionDims,
|
|
556
640
|
configPath,
|
|
557
641
|
cascadeDir,
|
|
558
642
|
deviceKind,
|
|
559
643
|
);
|
|
560
|
-
console.log(
|
|
644
|
+
console.log(
|
|
645
|
+
`[NativeInference] LI model '${variant.cfgKey}' loaded in ${Date.now() - t0}ms `
|
|
646
|
+
+ `(dim: ${model.dim}, device: ${deviceKind})`,
|
|
647
|
+
);
|
|
561
648
|
|
|
562
|
-
|
|
649
|
+
const slot = _liModels.get(variant.fp32RegistryKey);
|
|
650
|
+
if (slot) slot.model = model;
|
|
651
|
+
return model;
|
|
563
652
|
})();
|
|
564
653
|
|
|
565
|
-
|
|
654
|
+
_liModels.set(variant.fp32RegistryKey, { model: null, promise });
|
|
655
|
+
return promise;
|
|
566
656
|
}
|
|
567
657
|
|
|
568
658
|
// ─── Warmup primitives ───
|
|
@@ -575,10 +665,14 @@ export async function warmupNativeEmbeddingModel() {
|
|
|
575
665
|
}
|
|
576
666
|
|
|
577
667
|
export async function warmupNativeLiModel() {
|
|
578
|
-
|
|
668
|
+
// Warm up only the *active* variant — warming up an unused stale
|
|
669
|
+
// variant would be wasted Metal queue time.
|
|
670
|
+
const variant = resolveNativeLiVariant();
|
|
671
|
+
const model = _liModels.get(variant.fp32RegistryKey)?.model;
|
|
672
|
+
if (!model?.warmupForward) return;
|
|
579
673
|
const t0 = Date.now();
|
|
580
|
-
await
|
|
581
|
-
console.log(`[NativeInference] LI warmup forward in ${Date.now() - t0}ms`);
|
|
674
|
+
await model.warmupForward();
|
|
675
|
+
console.log(`[NativeInference] LI warmup forward (${variant.cfgKey}) in ${Date.now() - t0}ms`);
|
|
582
676
|
}
|
|
583
677
|
|
|
584
678
|
// ─── Cleanup ───
|
|
@@ -586,12 +680,10 @@ export async function warmupNativeLiModel() {
|
|
|
586
680
|
export function unloadNativeModels() {
|
|
587
681
|
_embeddingModel = null;
|
|
588
682
|
_embeddingModelLoadPromise = null;
|
|
589
|
-
|
|
590
|
-
_liModelLoadPromise = null;
|
|
683
|
+
_liModels.clear();
|
|
591
684
|
_embTokenizer = null;
|
|
592
685
|
_embTokenizerLoadPromise = null;
|
|
593
|
-
|
|
594
|
-
_liTokenizerLoadPromise = null;
|
|
686
|
+
_liTokenizers.clear();
|
|
595
687
|
_addon = null;
|
|
596
688
|
_available = null;
|
|
597
689
|
_coremlCascadeLogged = false;
|