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.
Files changed (46) hide show
  1. package/core/cli.js +43 -5
  2. package/core/embedding/embedding-cache.js +266 -18
  3. package/core/embedding/embedding-service.js +45 -9
  4. package/core/graph/graph-expansion.js +52 -12
  5. package/core/graph/graph-extractor.js +30 -1
  6. package/core/indexing/ast-chunker.js +331 -16
  7. package/core/indexing/chunking/chunk-builder.js +34 -1
  8. package/core/indexing/index-codebase-v21.js +31 -2
  9. package/core/indexing/index.js +6 -3
  10. package/core/indexing/indexer-ann.js +45 -6
  11. package/core/indexing/indexer-build.js +9 -1
  12. package/core/indexing/indexer-phases.js +6 -4
  13. package/core/indexing/indexing-file-policy.js +140 -0
  14. package/core/indexing/li-skip-policy.js +11 -220
  15. package/core/infrastructure/codebase-repository.js +21 -0
  16. package/core/infrastructure/config/embedding.js +20 -1
  17. package/core/infrastructure/config/graph.js +2 -2
  18. package/core/infrastructure/config/ranking.js +10 -0
  19. package/core/infrastructure/config/vector-store.js +1 -1
  20. package/core/infrastructure/coreml-cascade.js +236 -30
  21. package/core/infrastructure/coreml-cascade.json +25 -0
  22. package/core/infrastructure/index.js +17 -0
  23. package/core/infrastructure/init-config.js +216 -0
  24. package/core/infrastructure/language-patterns/registry-core.js +18 -0
  25. package/core/infrastructure/model-registry.js +12 -0
  26. package/core/infrastructure/native-inference.js +143 -51
  27. package/core/infrastructure/tree-sitter-provider.js +92 -2
  28. package/core/ranking/cascaded-scorer.js +6 -2
  29. package/core/ranking/file-kind-ranking.js +264 -0
  30. package/core/ranking/late-interaction-index.js +10 -4
  31. package/core/ranking/late-interaction-policy.js +304 -0
  32. package/core/search/context-expander.js +267 -28
  33. package/core/search/index.js +4 -0
  34. package/core/search/search-cli.js +3 -1
  35. package/core/search/search-pattern.js +4 -3
  36. package/core/search/search-postprocess.js +189 -8
  37. package/core/search/search-read-semantic.js +734 -0
  38. package/core/search/search-read.js +481 -0
  39. package/core/search/search-server.js +153 -5
  40. package/core/search/sweet-search.js +133 -16
  41. package/core/start-server.js +13 -2
  42. package/mcp/server.js +41 -0
  43. package/mcp/tool-handlers.js +117 -6
  44. package/package.json +9 -7
  45. package/scripts/init.js +386 -5
  46. 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
- let _liModel = null;
67
- let _liModelLoadPromise = null;
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
- let _liTokenizer = null;
71
- let _liTokenizerLoadPromise = null;
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 resolved = getCoremlCascadeResolvedDirs();
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
- * Load the native LI model (LateOn-Code FP32 safetensors + projection).
331
- * Returns the model instance or null if unavailable. Race-gated.
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 async function getNativeLiModel() {
334
- if (_liModel) return _liModel;
335
- if (_liModelLoadPromise) return _liModelLoadPromise;
336
- _liModelLoadPromise = (async () => {
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('lateon-code-fp32');
386
+ await fetchModel(variant.fp32RegistryKey);
341
387
 
342
- const entry = getModelEntry('lateon-code-fp32');
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(projPath) || !existsSync(configPath)) return null;
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
- _liModel = addon.NativeLateInteractionModel.load(
404
+ const model = addon.NativeLateInteractionModel.load(
356
405
  backbonePath,
357
- projPath,
406
+ projAbsPaths,
407
+ variant.projectionDims,
358
408
  configPath,
359
409
  cascade.liDir || undefined,
360
410
  );
361
- console.log(`[NativeInference] LI model loaded in ${Date.now() - t0}ms (dim: ${_liModel.dim}, device: ${addon.nativeInferenceDevice()})`);
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
- return _liModel;
416
+ const slot = _liModels.get(variant.fp32RegistryKey);
417
+ if (slot) slot.model = model;
418
+ return model;
364
419
  })();
365
- return _liModelLoadPromise;
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. Race-gated.
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
- if (_liTokenizer) return _liTokenizer;
373
- if (_liTokenizerLoadPromise) return _liTokenizerLoadPromise;
374
- _liTokenizerLoadPromise = (async () => {
375
- const entry = getModelEntry('lateon-code');
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
- _liTokenizer = await createTokenizer(tokenizerPath);
378
- return _liTokenizer;
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
- return _liTokenizerLoadPromise;
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
- return _liModel != null;
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
- if (_liModel) return _liModel;
525
- if (_liModelLoadPromise) return _liModelLoadPromise;
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
- _liModelLoadPromise = (async () => {
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('lateon-code-fp32');
616
+ await fetchModel(variant.fp32RegistryKey);
535
617
 
536
- const entry = getModelEntry('lateon-code-fp32');
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(projPath) || !existsSync(configPath)) return null;
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
- _liModel = addon.NativeLateInteractionModel.loadWithDevice(
636
+ const model = addon.NativeLateInteractionModel.loadWithDevice(
554
637
  backbonePath,
555
- projPath,
638
+ projAbsPaths,
639
+ variant.projectionDims,
556
640
  configPath,
557
641
  cascadeDir,
558
642
  deviceKind,
559
643
  );
560
- console.log(`[NativeInference] LI model loaded in ${Date.now() - t0}ms (dim: ${_liModel.dim}, device: ${deviceKind})`);
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
- return _liModel;
649
+ const slot = _liModels.get(variant.fp32RegistryKey);
650
+ if (slot) slot.model = model;
651
+ return model;
563
652
  })();
564
653
 
565
- return _liModelLoadPromise;
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
- if (!_liModel?.warmupForward) return;
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 _liModel.warmupForward();
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
- _liModel = null;
590
- _liModelLoadPromise = null;
683
+ _liModels.clear();
591
684
  _embTokenizer = null;
592
685
  _embTokenizerLoadPromise = null;
593
- _liTokenizer = null;
594
- _liTokenizerLoadPromise = null;
686
+ _liTokenizers.clear();
595
687
  _addon = null;
596
688
  _available = null;
597
689
  _coremlCascadeLogged = false;