@thyn-ai/fuse-mojo-core 0.1.7

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/NOTICE ADDED
@@ -0,0 +1,16 @@
1
+ NOTICE
2
+ ======
3
+
4
+ @thyn-ai/fuse-mojo-core includes a vendored copy of Fuse.js v7.1.0
5
+ (vendor/fuse.basic.cjs), used as the pure-JavaScript fallback backend on
6
+ platforms without a prebuilt native kernel, and as the reference oracle in
7
+ the differential test suite.
8
+
9
+ Fuse.js — Lightweight fuzzy-search
10
+ Copyright (c) Kiro Risk (http://kiro.me)
11
+ Licensed under the Apache License, Version 2.0
12
+ http://www.apache.org/licenses/LICENSE-2.0
13
+ https://github.com/krisk/Fuse
14
+
15
+ The remainder of this package is Copyright (c) 2026 Algenta,
16
+ Apache License, Version 2.0.
package/index.cjs ADDED
@@ -0,0 +1,147 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * @thyn-ai/fuse-mojo-core — drop-in faster replacement for Fuse.js v7 fuzzy search.
5
+ *
6
+ * The heavy Bitap matching runs on a native Mojo kernel when its shared
7
+ * library is available (macOS arm64 / Linux x64 platform packages); on any
8
+ * load failure the package transparently falls back to the vendored Fuse.js
9
+ * implementation (Apache-2.0, see vendor/ and NOTICE), so results are
10
+ * identical on every platform.
11
+ */
12
+
13
+ const {
14
+ DEFAULT_CONFIG,
15
+ UnsupportedOptionError,
16
+ validateOptions,
17
+ createKeyStore,
18
+ } = require('./src/options.cjs')
19
+ const { buildEngine, nativeSearch } = require('./src/engine.cjs')
20
+ const {
21
+ NativeIndex,
22
+ NativeUnavailable,
23
+ nativeAvailable,
24
+ backendInfo,
25
+ } = require('./src/native.cjs')
26
+ const VendorFuse = require('./vendor/fuse.basic.cjs')
27
+
28
+ class FuseMojo {
29
+ /**
30
+ * @param {Array} docs string list or array of objects
31
+ * @param {object} options Fuse.js-compatible options (see README for the
32
+ * supported subset; unsupported options throw UnsupportedOptionError)
33
+ */
34
+ constructor(docs, options = {}, index) {
35
+ if (index !== undefined) {
36
+ throw new UnsupportedOptionError('external index (Fuse.createIndex/parseIndex)')
37
+ }
38
+ this.options = { ...DEFAULT_CONFIG, ...options }
39
+ if (this.options.keys === undefined) {
40
+ this.options.keys = []
41
+ }
42
+ validateOptions(this.options)
43
+ this._keyStore = createKeyStore(this.options.keys)
44
+ this._fallback = null
45
+ this._engine = null
46
+ this._nativeIndex = null
47
+ this.setCollection(docs)
48
+ }
49
+
50
+ setCollection(docs, index) {
51
+ if (index !== undefined) {
52
+ throw new UnsupportedOptionError('external index (Fuse.createIndex/parseIndex)')
53
+ }
54
+ this._docs = docs
55
+ this._destroyNative()
56
+ this._fallback = null
57
+ this._engine = null
58
+ this._nativeIndex = null
59
+ try {
60
+ this._engine = buildEngine(docs, this._keyStore, this.options)
61
+ const nativeOptions = {
62
+ location: this.options.location,
63
+ distance: this.options.distance,
64
+ threshold: this.options.threshold,
65
+ minMatchCharLength: Math.max(this.options.minMatchCharLength, 0),
66
+ findAllMatches: this.options.findAllMatches,
67
+ ignoreLocation: this.options.ignoreLocation,
68
+ computeMatches: this.options.minMatchCharLength > 1 || this.options.includeMatches,
69
+ includeMatches: this.options.includeMatches,
70
+ }
71
+ this._nativeIndex = new NativeIndex(this._engine.chars, this._engine.offsets, nativeOptions)
72
+ this._backend = 'native'
73
+ } catch (err) {
74
+ if (!(err instanceof NativeUnavailable)) {
75
+ throw err
76
+ }
77
+ this._engine = null
78
+ this._nativeIndex = null
79
+ this._fallback = new VendorFuse(docs, this.options)
80
+ this._backend = 'fallback'
81
+ this._fallbackError = err.message
82
+ }
83
+ return this
84
+ }
85
+
86
+ /**
87
+ * @param {string} pattern search pattern (extended-search syntax is not
88
+ * supported; non-string patterns throw)
89
+ * @param {{limit?: number}} opts
90
+ * @returns {Array<{item: any, refIndex: number, score?: number, matches?: Array}>}
91
+ */
92
+ search(pattern, { limit = -1 } = {}) {
93
+ if (typeof pattern !== 'string') {
94
+ throw new UnsupportedOptionError(
95
+ `non-string query (${typeof pattern === 'object' && pattern !== null ? 'logical $and/$or query' : typeof pattern})`
96
+ )
97
+ }
98
+ if (this._backend === 'native') {
99
+ return nativeSearch(this._engine, this._nativeIndex, pattern, limit, this.options)
100
+ }
101
+ return this._fallback.search(pattern, { limit })
102
+ }
103
+
104
+ /** Which backend serves this instance: "native" or "fallback". */
105
+ get backend() {
106
+ return this._backend
107
+ }
108
+
109
+ /** Free the native index handle (also freed automatically on GC). */
110
+ destroy() {
111
+ this._destroyNative()
112
+ }
113
+
114
+ _destroyNative() {
115
+ if (this._nativeIndex) {
116
+ this._nativeIndex.destroy()
117
+ this._nativeIndex = null
118
+ }
119
+ }
120
+
121
+ static createIndex() {
122
+ throw new UnsupportedOptionError('Fuse.createIndex')
123
+ }
124
+
125
+ static parseIndex() {
126
+ throw new UnsupportedOptionError('Fuse.parseIndex')
127
+ }
128
+
129
+ static nativeAvailable() {
130
+ return nativeAvailable()
131
+ }
132
+
133
+ static backendInfo() {
134
+ return backendInfo()
135
+ }
136
+ }
137
+
138
+ FuseMojo.config = DEFAULT_CONFIG
139
+ FuseMojo.version = require('./package.json').version
140
+
141
+ module.exports = FuseMojo
142
+ module.exports.default = FuseMojo
143
+ module.exports.Fuse = FuseMojo
144
+ module.exports.FuseMojo = FuseMojo
145
+ module.exports.nativeAvailable = nativeAvailable
146
+ module.exports.backendInfo = backendInfo
147
+ module.exports.UnsupportedOptionError = UnsupportedOptionError
package/index.d.ts ADDED
@@ -0,0 +1,87 @@
1
+ // Type definitions for the supported drop-in surface of @thyn-ai/fuse-mojo-core.
2
+ // Mirrors the Fuse.js v7 API shape for the supported option subset.
3
+
4
+ declare namespace FuseMojo {
5
+ interface FuseKeyObject {
6
+ name: string | string[]
7
+ weight?: number
8
+ }
9
+
10
+ interface FuseOptions {
11
+ /** List of properties to search (dotted paths supported). */
12
+ keys?: Array<string | string[] | FuseKeyObject>
13
+ /** Give up threshold in [0, 1]; 0.0 requires a perfect match. Default 0.6. */
14
+ threshold?: number
15
+ /** Approximately where in the text the pattern is expected. Default 0. */
16
+ location?: number
17
+ /** How close the match must be to `location`. Default 100. */
18
+ distance?: number
19
+ /** Minimum matched-run length for a result to count. Default 1. */
20
+ minMatchCharLength?: number
21
+ /** Include the score in results. Default false. */
22
+ includeScore?: boolean
23
+ /** Include matched character index ranges in results. Default false. */
24
+ includeMatches?: boolean
25
+ /** Sort results by score. Default true. */
26
+ shouldSort?: boolean
27
+ /** Ignore `location`/`distance` when scoring. Default false. */
28
+ ignoreLocation?: boolean
29
+ /** Case-sensitive matching. Default false. */
30
+ isCaseSensitive?: boolean
31
+ /** Keep searching after a perfect match. Default false. */
32
+ findAllMatches?: boolean
33
+ /** Ignore the field-length norm in scoring. Default false. */
34
+ ignoreFieldNorm?: boolean
35
+ /** Field-length norm weight. Default 1. */
36
+ fieldNormWeight?: number
37
+ }
38
+
39
+ interface FuseSearchOptions {
40
+ limit?: number
41
+ }
42
+
43
+ type FuseIndexRange = [number, number]
44
+
45
+ interface FuseResultMatch {
46
+ indices: FuseIndexRange[]
47
+ value: string
48
+ key?: string | string[]
49
+ refIndex?: number
50
+ }
51
+
52
+ interface FuseResult<T> {
53
+ item: T
54
+ refIndex: number
55
+ score?: number
56
+ matches?: FuseResultMatch[]
57
+ }
58
+
59
+ interface BackendInfo {
60
+ native_available: boolean
61
+ native_source: string | null
62
+ abi_version_expected: number
63
+ abi_version_native: number | null
64
+ disabled_by_env: boolean
65
+ platform: string
66
+ arch: string
67
+ error: string | null
68
+ }
69
+ }
70
+
71
+ declare class FuseMojo<T = any> {
72
+ constructor(list: ReadonlyArray<T>, options?: FuseMojo.FuseOptions)
73
+ options: FuseMojo.FuseOptions
74
+ search(pattern: string, opts?: FuseMojo.FuseSearchOptions): Array<FuseMojo.FuseResult<T>>
75
+ setCollection(docs: ReadonlyArray<T>): this
76
+ readonly backend: 'native' | 'fallback'
77
+ destroy(): void
78
+
79
+ static config: FuseMojo.FuseOptions
80
+ static version: string
81
+ static nativeAvailable(): boolean
82
+ static backendInfo(): FuseMojo.BackendInfo
83
+ static createIndex(): never
84
+ static parseIndex(): never
85
+ }
86
+
87
+ export = FuseMojo
package/index.mjs ADDED
@@ -0,0 +1,7 @@
1
+ import FuseMojo from './index.cjs'
2
+
3
+ export default FuseMojo
4
+ export const Fuse = FuseMojo
5
+ export const nativeAvailable = FuseMojo.nativeAvailable
6
+ export const backendInfo = FuseMojo.backendInfo
7
+ export const UnsupportedOptionError = FuseMojo.UnsupportedOptionError
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@thyn-ai/fuse-mojo-core",
3
+ "version": "0.1.7",
4
+ "description": "Drop-in faster replacement for Fuse.js, powered by a Mojo kernel (pure-JS fallback included).",
5
+ "license": "Apache-2.0",
6
+ "author": "Algenta",
7
+ "keywords": [
8
+ "fuse",
9
+ "fuzzy",
10
+ "search",
11
+ "bitap",
12
+ "mojo",
13
+ "fuse.js"
14
+ ],
15
+ "type": "commonjs",
16
+ "main": "index.cjs",
17
+ "types": "index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./index.d.ts",
21
+ "import": "./index.mjs",
22
+ "require": "./index.cjs"
23
+ },
24
+ "./package.json": "./package.json"
25
+ },
26
+ "files": [
27
+ "index.cjs",
28
+ "index.mjs",
29
+ "index.d.ts",
30
+ "src/",
31
+ "vendor/",
32
+ "NOTICE"
33
+ ],
34
+ "engines": {
35
+ "node": ">=18"
36
+ },
37
+ "dependencies": {
38
+ "koffi": "^3.3.1"
39
+ },
40
+ "optionalDependencies": {
41
+ "@thyn-ai/fuse-mojo-darwin-arm64": "0.1.7",
42
+ "@thyn-ai/fuse-mojo-linux-x64": "0.1.7"
43
+ },
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "https://github.com/thyn-ai/mojo-kernels"
47
+ }
48
+ }
package/src/engine.cjs ADDED
@@ -0,0 +1,314 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Index construction and search orchestration for the native backend.
5
+ *
6
+ * The engine extracts every searchable string from the document collection
7
+ * (replicating the reference's index records, including the reversed order of
8
+ * array-valued sub-records), lowercases them once at build time, and hands a
9
+ * flat UTF-16 buffer to the native kernel. Each search call then maps to one
10
+ * kernel call per pattern chunk and combines per-text results exactly like
11
+ * the reference's BitapSearch/Fuse pipeline.
12
+ */
13
+
14
+ const { fuseGet, isBlank, createNormGetter, defaultSortFn } = require('./options.cjs')
15
+
16
+ const MAX_BITS = 32
17
+ const INT32_MAX = 0x7fffffff
18
+
19
+ class EngineBuildError extends Error {
20
+ constructor(message) {
21
+ super(message)
22
+ this.name = 'EngineBuildError'
23
+ this.code = 'FUSE_MOJO_ENGINE_BUILD'
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Split a (lowered) pattern into <=32-code-unit chunks, mirroring the
29
+ * reference: full chunks of 32, with the final chunk of `remainder` units
30
+ * taken from the tail so it always has length 32 when possible.
31
+ */
32
+ function chunkPattern(pattern) {
33
+ const len = pattern.length
34
+ const chunks = []
35
+ if (len > MAX_BITS) {
36
+ let i = 0
37
+ const remainder = len % MAX_BITS
38
+ const end = len - remainder
39
+ while (i < end) {
40
+ chunks.push({ text: pattern.slice(i, i + MAX_BITS), startIndex: i })
41
+ i += MAX_BITS
42
+ }
43
+ if (remainder) {
44
+ const startIndex = len - MAX_BITS
45
+ chunks.push({ text: pattern.slice(startIndex), startIndex })
46
+ }
47
+ } else {
48
+ chunks.push({ text: pattern, startIndex: 0 })
49
+ }
50
+ return chunks
51
+ }
52
+
53
+ function toU16(str) {
54
+ const out = new Uint16Array(str.length)
55
+ for (let i = 0; i < str.length; i += 1) {
56
+ out[i] = str.charCodeAt(i)
57
+ }
58
+ return out
59
+ }
60
+
61
+ /**
62
+ * Build the searchable-text engine. Every entry corresponds to one string the
63
+ * reference would run Bitap on, in the reference's iteration order:
64
+ * string list: one entry per non-blank document
65
+ * object list: per document, per configured key, one entry per value
66
+ * (array values contribute one entry per element, in the
67
+ * reversed order produced by the reference's stack DFS)
68
+ */
69
+ function buildEngine(docs, keyStore, options) {
70
+ const normOf = createNormGetter(options.fieldNormWeight, 3)
71
+ const nDocs = docs && typeof docs.length === 'number' ? docs.length : 0
72
+ const mode = typeof docs[0] === 'string' ? 'string' : 'object'
73
+ const entries = []
74
+
75
+ if (mode === 'string') {
76
+ for (let i = 0; i < nDocs; i += 1) {
77
+ const doc = docs[i]
78
+ if (doc === undefined || doc === null) {
79
+ continue
80
+ }
81
+ if (isBlank(doc)) {
82
+ continue
83
+ }
84
+ entries.push({ docIdx: i, keyIdx: -1, arrIdx: -1, value: doc, norm: normOf(doc) })
85
+ }
86
+ } else {
87
+ for (let i = 0; i < nDocs; i += 1) {
88
+ const doc = docs[i]
89
+ for (let k = 0; k < keyStore.length; k += 1) {
90
+ const key = keyStore[k]
91
+ const value = fuseGet(doc, key.path)
92
+ if (value === undefined || value === null) {
93
+ continue
94
+ }
95
+ if (Array.isArray(value)) {
96
+ // Reference _addObject: stack-based DFS pops later elements first,
97
+ // so sub-records come out reversed relative to document order.
98
+ const stack = [{ nestedArrIndex: -1, value }]
99
+ while (stack.length) {
100
+ const { nestedArrIndex, value: v } = stack.pop()
101
+ if (v === undefined || v === null) {
102
+ continue
103
+ }
104
+ if (typeof v === 'string' && !isBlank(v)) {
105
+ entries.push({ docIdx: i, keyIdx: k, arrIdx: nestedArrIndex, value: v, norm: normOf(v) })
106
+ } else if (Array.isArray(v)) {
107
+ for (let kk = 0; kk < v.length; kk += 1) {
108
+ stack.push({ nestedArrIndex: kk, value: v[kk] })
109
+ }
110
+ }
111
+ // Non-string leaves are silently ignored, as in the reference.
112
+ }
113
+ } else if (typeof value === 'string' && !isBlank(value)) {
114
+ entries.push({ docIdx: i, keyIdx: k, arrIdx: -1, value, norm: normOf(value) })
115
+ }
116
+ }
117
+ }
118
+ }
119
+
120
+ // Flatten the lowered texts into one UTF-16 buffer.
121
+ const loweredStrings = new Array(entries.length)
122
+ let total = 0
123
+ for (let e = 0; e < entries.length; e += 1) {
124
+ const s = options.isCaseSensitive ? entries[e].value : entries[e].value.toLowerCase()
125
+ loweredStrings[e] = s
126
+ total += s.length
127
+ }
128
+ if (total > INT32_MAX) {
129
+ throw new EngineBuildError(
130
+ `collection too large for the native kernel: ${total} UTF-16 code units (> 2^31)`
131
+ )
132
+ }
133
+ const chars = new Uint16Array(total)
134
+ const offsets = new Int32Array(entries.length + 1)
135
+ let off = 0
136
+ for (let e = 0; e < entries.length; e += 1) {
137
+ const s = loweredStrings[e]
138
+ for (let k = 0; k < s.length; k += 1) {
139
+ chars[off + k] = s.charCodeAt(k)
140
+ }
141
+ off += s.length
142
+ offsets[e + 1] = off
143
+ }
144
+
145
+ return { mode, entries, loweredStrings, chars, offsets, docs, keyStore }
146
+ }
147
+
148
+ /**
149
+ * Run a search on the native backend and format results identically to the
150
+ * reference `Fuse#search`.
151
+ */
152
+ function nativeSearch(engine, nativeIndex, pattern, limit, options) {
153
+ const { entries, loweredStrings, docs, keyStore } = engine
154
+ const nEntries = entries.length
155
+
156
+ const loweredPattern = options.isCaseSensitive ? pattern : pattern.toLowerCase()
157
+ if (!loweredPattern.length) {
158
+ return []
159
+ }
160
+ const chunks = chunkPattern(loweredPattern)
161
+ const singleChunk = chunks.length === 1
162
+ const includeMatches = options.includeMatches
163
+
164
+ const totalScore = new Float64Array(nEntries)
165
+ const hasMatch = new Uint8Array(nEntries)
166
+ const indicesByEntry = includeMatches ? new Array(nEntries).fill(null) : null
167
+
168
+ for (const chunk of chunks) {
169
+ const chunkU16 = toU16(chunk.text)
170
+ const { totalPairs, scores, isMatch, idxOffsets } = nativeIndex.searchChunk(
171
+ chunkU16,
172
+ chunk.startIndex,
173
+ singleChunk
174
+ )
175
+ let pairs = null
176
+ if (includeMatches && totalPairs > 0) {
177
+ pairs = nativeIndex.copyIndices(totalPairs)
178
+ }
179
+ for (let e = 0; e < nEntries; e += 1) {
180
+ // The reference folds every chunk's (clamped) score into the average,
181
+ // even for chunks that did not match.
182
+ totalScore[e] += scores[e]
183
+ if (isMatch[e]) {
184
+ hasMatch[e] = 1
185
+ if (includeMatches) {
186
+ const from = idxOffsets[e]
187
+ const to = idxOffsets[e + 1]
188
+ if (to > from) {
189
+ let arr = indicesByEntry[e]
190
+ if (arr === null) {
191
+ arr = []
192
+ indicesByEntry[e] = arr
193
+ }
194
+ for (let p = from; p < to; p += 1) {
195
+ arr.push([pairs[p * 2], pairs[p * 2 + 1]])
196
+ }
197
+ }
198
+ }
199
+ }
200
+ }
201
+ }
202
+
203
+ // Whole-pattern exact-equality fast path. For single-chunk patterns the
204
+ // kernel already applied it; for multi-chunk patterns it is replicated here
205
+ // (the reference checks pattern === text before running any chunk).
206
+ if (!singleChunk) {
207
+ for (let e = 0; e < nEntries; e += 1) {
208
+ if (loweredStrings[e] === loweredPattern) {
209
+ hasMatch[e] = 1
210
+ totalScore[e] = 0
211
+ if (includeMatches) {
212
+ indicesByEntry[e] = [[0, loweredStrings[e].length - 1]]
213
+ }
214
+ }
215
+ }
216
+ }
217
+
218
+ // Assemble per-document results with reference match-record shapes.
219
+ const results = []
220
+ if (engine.mode === 'string') {
221
+ for (let e = 0; e < nEntries; e += 1) {
222
+ if (!hasMatch[e]) continue
223
+ const entry = entries[e]
224
+ results.push({
225
+ idx: entry.docIdx,
226
+ matches: [
227
+ {
228
+ score: totalScore[e] / chunks.length,
229
+ value: entry.value,
230
+ norm: entry.norm,
231
+ indices: includeMatches ? indicesByEntry[e] || [] : undefined,
232
+ },
233
+ ],
234
+ })
235
+ }
236
+ } else {
237
+ let current = null
238
+ let currentDoc = -1
239
+ for (let e = 0; e < nEntries; e += 1) {
240
+ if (!hasMatch[e]) continue
241
+ const entry = entries[e]
242
+ const match = {
243
+ score: totalScore[e] / chunks.length,
244
+ key: keyStore[entry.keyIdx],
245
+ value: entry.value,
246
+ norm: entry.norm,
247
+ indices: includeMatches ? indicesByEntry[e] || [] : undefined,
248
+ }
249
+ if (entry.arrIdx > -1) {
250
+ match.idx = entry.arrIdx
251
+ }
252
+ if (entry.docIdx !== currentDoc) {
253
+ current = { idx: entry.docIdx, matches: [] }
254
+ results.push(current)
255
+ currentDoc = entry.docIdx
256
+ }
257
+ current.matches.push(match)
258
+ }
259
+ }
260
+
261
+ // Practical scoring: product over matches of score^(weight * norm).
262
+ for (const result of results) {
263
+ let score = 1
264
+ for (const match of result.matches) {
265
+ const weight = match.key ? match.key.weight : null
266
+ score *= Math.pow(
267
+ match.score === 0 && weight ? Number.EPSILON : match.score,
268
+ (weight || 1) * (options.ignoreFieldNorm ? 1 : match.norm)
269
+ )
270
+ }
271
+ result.score = score
272
+ }
273
+
274
+ if (options.shouldSort) {
275
+ results.sort(defaultSortFn)
276
+ }
277
+
278
+ if (typeof limit === 'number' && limit > -1) {
279
+ return formatResults(results.slice(0, limit), docs, options)
280
+ }
281
+ return formatResults(results, docs, options)
282
+ }
283
+
284
+ function formatResults(results, docs, options) {
285
+ const { includeMatches, includeScore } = options
286
+ return results.map((result) => {
287
+ const data = {
288
+ item: docs[result.idx],
289
+ refIndex: result.idx,
290
+ }
291
+ if (includeMatches) {
292
+ data.matches = []
293
+ for (const match of result.matches) {
294
+ if (!match.indices || !match.indices.length) {
295
+ continue
296
+ }
297
+ const obj = { indices: match.indices, value: match.value }
298
+ if (match.key) {
299
+ obj.key = match.key.src
300
+ }
301
+ if (match.idx > -1) {
302
+ obj.refIndex = match.idx
303
+ }
304
+ data.matches.push(obj)
305
+ }
306
+ }
307
+ if (includeScore) {
308
+ data.score = result.score
309
+ }
310
+ return data
311
+ })
312
+ }
313
+
314
+ module.exports = { buildEngine, nativeSearch, chunkPattern, EngineBuildError }