@byline/search-analysis 4.9.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.
@@ -0,0 +1,310 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import { MAX_SEARCH_QUERY_LENGTH } from '@byline/core';
9
+ import { extractIdentifierSpans, maskIdentifierSpans } from './identifiers.js';
10
+ import { canonicalSegmenterLocale, resolveSearchLocale } from './locale.js';
11
+ import { normalizeForSearch } from './normalize.js';
12
+ const PIPELINE_VERSION = 'portable1';
13
+ const NORMALIZATION_VERSION = 'nfkc-lower1';
14
+ const LOCALE_VERSION = 'locale1';
15
+ const IDENTIFIER_VERSION = 'identifiers2';
16
+ const GRAM_VERSION = 'han-bigram1';
17
+ const wordSegmenters = new Map();
18
+ function wordSegmenter(locale) {
19
+ let segmenter = wordSegmenters.get(locale);
20
+ if (segmenter == null) {
21
+ segmenter = new Intl.Segmenter(locale, { granularity: 'word' });
22
+ wordSegmenters.set(locale, segmenter);
23
+ }
24
+ return segmenter;
25
+ }
26
+ export function createPortableSearchAnalyzer(options = {}) {
27
+ return new PortableSearchAnalyzerImpl(options);
28
+ }
29
+ class PortableSearchAnalyzerImpl {
30
+ fingerprint;
31
+ defaultLocale;
32
+ hanLocale;
33
+ hanBigrams;
34
+ expanders;
35
+ constructor(options) {
36
+ this.defaultLocale = canonicalSegmenterLocale(options.defaultLocale) ?? 'en';
37
+ this.hanLocale = options.hanLocale ?? 'zh';
38
+ this.hanBigrams = options.hanBigrams ?? true;
39
+ this.expanders = options.expanders ?? [];
40
+ this.fingerprint = [
41
+ PIPELINE_VERSION,
42
+ NORMALIZATION_VERSION,
43
+ `icu${process.versions.icu ?? 'unknown'}`,
44
+ LOCALE_VERSION,
45
+ `default-${this.defaultLocale}`,
46
+ `han-${this.hanLocale}`,
47
+ IDENTIFIER_VERSION,
48
+ this.hanBigrams ? GRAM_VERSION : 'han-bigram0',
49
+ ...this.expanders.map((expander, index) => `expander${index}-${encodeURIComponent(expander.fingerprint)}`),
50
+ ].join('+');
51
+ }
52
+ analyzeText(input) {
53
+ const normalization = normalizeForSearch(input.text);
54
+ const locale = resolveSearchLocale(normalization.value, input.locale, this.defaultLocale, this.hanLocale);
55
+ const identifiers = extractIdentifierSpans(normalization.value);
56
+ const masked = maskIdentifierSpans(normalization.value, identifiers);
57
+ const sourceTokens = [];
58
+ for (const segment of wordSegmenter(locale).segment(masked)) {
59
+ if (!segment.isWordLike || segment.segment.length === 0)
60
+ continue;
61
+ sourceTokens.push(logicalToken('exact', segment.segment, segment.index, segment.index + segment.segment.length, sourceTokens.length, locale, normalization));
62
+ }
63
+ for (const identifier of identifiers) {
64
+ const token = logicalToken('identifier', identifier.value, identifier.start, identifier.end, 0, locale, normalization);
65
+ token.identifierKind = identifier.kind;
66
+ sourceTokens.push(token);
67
+ }
68
+ sourceTokens.sort((a, b) => a.normalizedStart - b.normalizedStart || a.normalizedEnd - b.normalizedEnd);
69
+ assignLogicalPositions(sourceTokens);
70
+ const derivedTokens = [];
71
+ for (const token of sourceTokens) {
72
+ if (token.kind !== 'exact')
73
+ continue;
74
+ for (const expander of this.expanders) {
75
+ if (!expander.supports(locale))
76
+ continue;
77
+ for (const expansion of expander.expand(token)) {
78
+ if (expansion.value.length === 0 || expansion.value === token.value)
79
+ continue;
80
+ derivedTokens.push({ ...token, kind: expansion.kind, value: expansion.value });
81
+ }
82
+ }
83
+ }
84
+ const gramTokens = this.hanBigrams
85
+ ? buildHanBigrams(masked, locale, normalization, sourceTokens)
86
+ : [];
87
+ const exactTokens = sourceTokens.filter((token) => token.kind === 'exact');
88
+ const identifierTokens = sourceTokens.filter((token) => token.kind === 'identifier');
89
+ return {
90
+ original: input.text,
91
+ normalized: normalization.value,
92
+ locale,
93
+ tokens: [...sourceTokens, ...derivedTokens, ...gramTokens],
94
+ exactTokens,
95
+ derivedTokens,
96
+ identifierTokens,
97
+ gramTokens,
98
+ analyzerFingerprint: this.fingerprint,
99
+ };
100
+ }
101
+ analyzeQuery(input) {
102
+ if (input.query.length > MAX_SEARCH_QUERY_LENGTH) {
103
+ throw new RangeError(`Search query exceeds the maximum length of ${MAX_SEARCH_QUERY_LENGTH} UTF-16 code units`);
104
+ }
105
+ const matching = resolveMatching(input.matching);
106
+ const full = this.analyzeText({ text: input.query, locale: input.locale });
107
+ const parts = splitQueryParts(input.query);
108
+ const concepts = [];
109
+ const phrases = [];
110
+ const gramSequences = [];
111
+ for (const part of parts) {
112
+ const analyzed = this.analyzeText({ text: part.text, locale: full.locale });
113
+ const normalizedBase = normalizeForSearch(input.query.slice(0, part.start)).value.length;
114
+ const partConceptIndexes = [];
115
+ const sourceGroups = groupTokensByPosition([
116
+ ...analyzed.exactTokens,
117
+ ...analyzed.identifierTokens,
118
+ ]);
119
+ for (const sourceGroup of sourceGroups) {
120
+ const conceptIndex = concepts.length;
121
+ const queryToken = (token) => rebaseQueryToken(token, part.start, normalizedBase, conceptIndex);
122
+ const identifiers = sourceGroup.filter((token) => token.kind === 'identifier');
123
+ const exact = identifiers.length === 0 ? sourceGroup : [];
124
+ const source = identifiers[0] ?? exact[0];
125
+ if (source == null)
126
+ continue;
127
+ partConceptIndexes.push(conceptIndex);
128
+ concepts.push({
129
+ index: conceptIndex,
130
+ position: conceptIndex,
131
+ exactTokens: exact.map(queryToken),
132
+ stemTokens: identifiers.length === 0
133
+ ? analyzed.derivedTokens
134
+ .filter((token) => token.kind === 'stem' && token.position === source.position)
135
+ .map(queryToken)
136
+ : [],
137
+ lemmaTokens: identifiers.length === 0
138
+ ? analyzed.derivedTokens
139
+ .filter((token) => token.kind === 'lemma' && token.position === source.position)
140
+ .map(queryToken)
141
+ : [],
142
+ normalizedTokens: identifiers.length === 0
143
+ ? analyzed.derivedTokens
144
+ .filter((token) => token.kind === 'normalized' && token.position === source.position)
145
+ .map(queryToken)
146
+ : [],
147
+ identifierTokens: identifiers.map(queryToken),
148
+ gramTokens: analyzed.gramTokens
149
+ .filter((token) => token.normalizedStart >= source.normalizedStart &&
150
+ token.normalizedEnd <= source.normalizedEnd)
151
+ .map(queryToken),
152
+ });
153
+ }
154
+ if (analyzed.gramTokens.length > 0) {
155
+ gramSequences.push(analyzed.gramTokens.map((token) => rebaseQueryToken(token, part.start, normalizedBase, token.position)));
156
+ }
157
+ if (matching.phrase !== 'off' && part.quoted && partConceptIndexes.length > 0) {
158
+ phrases.push({ conceptIndexes: partConceptIndexes, explicit: true });
159
+ }
160
+ }
161
+ if (matching.phrase === 'required' && concepts.length > 0) {
162
+ phrases.length = 0;
163
+ phrases.push({
164
+ conceptIndexes: concepts.map((concept) => concept.index),
165
+ explicit: false,
166
+ });
167
+ }
168
+ return {
169
+ original: input.query,
170
+ normalized: full.normalized,
171
+ locale: full.locale,
172
+ concepts,
173
+ phrases,
174
+ gramSequences,
175
+ matching,
176
+ analyzerFingerprint: this.fingerprint,
177
+ };
178
+ }
179
+ }
180
+ function assignLogicalPositions(tokens) {
181
+ let position = -1;
182
+ let groupEnd = -1;
183
+ for (const token of tokens) {
184
+ if (position === -1 || token.normalizedStart >= groupEnd) {
185
+ position += 1;
186
+ groupEnd = token.normalizedEnd;
187
+ }
188
+ else {
189
+ groupEnd = Math.max(groupEnd, token.normalizedEnd);
190
+ }
191
+ token.position = position;
192
+ }
193
+ }
194
+ function groupTokensByPosition(tokens) {
195
+ const groups = new Map();
196
+ for (const token of tokens.toSorted((left, right) => left.position - right.position ||
197
+ left.normalizedStart - right.normalizedStart ||
198
+ left.normalizedEnd - right.normalizedEnd)) {
199
+ const group = groups.get(token.position);
200
+ if (group == null)
201
+ groups.set(token.position, [token]);
202
+ else
203
+ group.push(token);
204
+ }
205
+ return [...groups.values()];
206
+ }
207
+ function logicalToken(kind, value, normalizedStart, normalizedEnd, position, locale, normalization) {
208
+ const original = normalization.originalRange(normalizedStart, normalizedEnd);
209
+ return {
210
+ kind,
211
+ value,
212
+ normalizedStart,
213
+ normalizedEnd,
214
+ start: original.start,
215
+ end: original.end,
216
+ position,
217
+ locale,
218
+ };
219
+ }
220
+ function buildHanBigrams(text, locale, normalization, sourceTokens) {
221
+ const grams = [];
222
+ let sourceIndex = 0;
223
+ let previousPosition = 0;
224
+ for (const match of text.matchAll(/\p{Script=Han}+/gu)) {
225
+ const run = match[0];
226
+ const runStart = match.index;
227
+ if (run == null || runStart == null)
228
+ continue;
229
+ const characters = [];
230
+ let offset = 0;
231
+ for (const value of run) {
232
+ const start = runStart + offset;
233
+ offset += value.length;
234
+ characters.push({ value, start, end: runStart + offset });
235
+ }
236
+ for (let index = 0; index + 1 < characters.length; index++) {
237
+ const first = characters[index];
238
+ const second = characters[index + 1];
239
+ if (first == null || second == null)
240
+ continue;
241
+ while (sourceIndex < sourceTokens.length) {
242
+ const source = sourceTokens[sourceIndex];
243
+ if (source == null || source.normalizedEnd > first.start)
244
+ break;
245
+ previousPosition = source.position;
246
+ sourceIndex += 1;
247
+ }
248
+ const candidate = sourceTokens[sourceIndex];
249
+ const position = candidate != null &&
250
+ candidate.normalizedStart <= first.start &&
251
+ candidate.normalizedEnd >= first.end
252
+ ? candidate.position
253
+ : previousPosition;
254
+ grams.push(logicalToken('gram', first.value + second.value, first.start, second.end, position, locale, normalization));
255
+ }
256
+ }
257
+ return grams;
258
+ }
259
+ export function resolveMatching(matching) {
260
+ const operator = matching?.operator ?? 'all';
261
+ const phrase = matching?.phrase ?? 'auto';
262
+ const minimumShouldMatch = matching?.minimumShouldMatch;
263
+ if (minimumShouldMatch != null &&
264
+ (!Number.isSafeInteger(minimumShouldMatch) || minimumShouldMatch < 1)) {
265
+ throw new RangeError('minimumShouldMatch must be a positive safe integer');
266
+ }
267
+ if (operator === 'all' && minimumShouldMatch != null) {
268
+ throw new TypeError("minimumShouldMatch is only valid with operator: 'any'");
269
+ }
270
+ return {
271
+ operator,
272
+ phrase,
273
+ ...(minimumShouldMatch != null ? { minimumShouldMatch } : {}),
274
+ };
275
+ }
276
+ /** Split balanced ASCII-quoted spans while treating unmatched quotes as text. */
277
+ function splitQueryParts(query) {
278
+ const parts = [];
279
+ let cursor = 0;
280
+ while (cursor < query.length) {
281
+ const open = query.indexOf('"', cursor);
282
+ if (open < 0) {
283
+ pushPart(parts, query.slice(cursor), cursor, false);
284
+ break;
285
+ }
286
+ const close = query.indexOf('"', open + 1);
287
+ if (close < 0) {
288
+ pushPart(parts, query.slice(cursor).replaceAll('"', ' '), cursor, false);
289
+ break;
290
+ }
291
+ pushPart(parts, query.slice(cursor, open), cursor, false);
292
+ pushPart(parts, query.slice(open + 1, close), open + 1, true);
293
+ cursor = close + 1;
294
+ }
295
+ return parts;
296
+ }
297
+ function pushPart(parts, text, start, quoted) {
298
+ if (text.trim().length > 0)
299
+ parts.push({ text, start, quoted });
300
+ }
301
+ function rebaseQueryToken(token, originalBase, normalizedBase, position) {
302
+ return {
303
+ ...token,
304
+ normalizedStart: token.normalizedStart + normalizedBase,
305
+ normalizedEnd: token.normalizedEnd + normalizedBase,
306
+ start: token.start + originalBase,
307
+ end: token.end + originalBase,
308
+ position,
309
+ };
310
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ export {};
@@ -0,0 +1,183 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import { performance } from 'node:perf_hooks';
9
+ import { MAX_SEARCH_QUERY_LENGTH } from '@byline/core';
10
+ import { describe, expect, it } from 'vitest';
11
+ import { createPortableSearchAnalyzer, resolveMatching } from './analyzer.js';
12
+ import { extractIdentifierSpans } from './identifiers.js';
13
+ import { detectSearchLocale, resolveSearchLocale } from './locale.js';
14
+ import { normalizeForSearch } from './normalize.js';
15
+ describe('portable search analysis', () => {
16
+ it('normalizes compatibility forms while preserving original UTF-16 ranges', () => {
17
+ const normalized = normalizeForSearch('A ffi B');
18
+ expect(normalized.value).toBe('a ffi b');
19
+ expect(normalized.originalRange(2, 5)).toEqual({ start: 2, end: 3 });
20
+ const analyzed = createPortableSearchAnalyzer().analyzeText({ text: 'A ffi B' });
21
+ expect(analyzed.exactTokens.map(({ value, start, end }) => ({ value, start, end }))).toEqual([
22
+ { value: 'a', start: 0, end: 1 },
23
+ { value: 'ffi', start: 2, end: 3 },
24
+ { value: 'b', start: 4, end: 5 },
25
+ ]);
26
+ });
27
+ it('prefers a valid declared locale and otherwise falls back through script detection', () => {
28
+ expect(resolveSearchLocale('ภาษาไทย', 'en-US')).toBe('en-US');
29
+ expect(resolveSearchLocale('ภาษาไทย', 'not_a_locale')).toBe('th');
30
+ expect(detectSearchLocale('한국어')).toBe('ko');
31
+ expect(detectSearchLocale('日本語です')).toBe('ja');
32
+ expect(detectSearchLocale('数据库')).toBe('zh');
33
+ expect(detectSearchLocale('数据库', 'ja')).toBe('ja');
34
+ });
35
+ it('extracts protected identifiers before word segmentation', () => {
36
+ const analyzed = createPortableSearchAnalyzer().analyzeText({
37
+ text: 'See HTTPS://Example.com/a). mail Tést@Example.com SKU-42 v1.2.3 C++ C# Node.js @ผู้ใช้ #หัวข้อ',
38
+ });
39
+ expect(analyzed.identifierTokens.map(({ identifierKind, value }) => ({
40
+ identifierKind,
41
+ value,
42
+ }))).toEqual([
43
+ { identifierKind: 'url', value: 'https://example.com/a' },
44
+ { identifierKind: 'email', value: 'tést@example.com' },
45
+ { identifierKind: 'sku', value: 'sku-42' },
46
+ { identifierKind: 'version', value: 'v1.2.3' },
47
+ { identifierKind: 'technical', value: 'c++' },
48
+ { identifierKind: 'technical', value: 'c#' },
49
+ { identifierKind: 'technical', value: 'node.js' },
50
+ { identifierKind: 'mention', value: '@ผู้ใช้' },
51
+ { identifierKind: 'hashtag', value: '#หัวข้อ' },
52
+ ]);
53
+ expect(analyzed.exactTokens.map((token) => token.value)).toContain('see');
54
+ expect(analyzed.exactTokens.map((token) => token.value)).toContain('mail');
55
+ expect(analyzed.exactTokens.map((token) => token.value)).not.toContain('example');
56
+ expect(analyzed.exactTokens.map((token) => token.value)).not.toContain('tést');
57
+ });
58
+ it('retains SKU and version constituents at one logical source position', () => {
59
+ const analyzer = createPortableSearchAnalyzer();
60
+ const analyzed = analyzer.analyzeText({
61
+ text: 'COVID-19 cases utf-8 encoding Section 1.2',
62
+ });
63
+ const positions = [...analyzed.exactTokens, ...analyzed.identifierTokens].map(({ kind, value, position }) => ({ kind, value, position }));
64
+ expect(positions).toEqual(expect.arrayContaining([
65
+ { kind: 'exact', value: 'covid', position: 0 },
66
+ { kind: 'exact', value: '19', position: 0 },
67
+ { kind: 'identifier', value: 'covid-19', position: 0 },
68
+ { kind: 'exact', value: 'cases', position: 1 },
69
+ { kind: 'exact', value: 'utf', position: 2 },
70
+ { kind: 'exact', value: '8', position: 2 },
71
+ { kind: 'identifier', value: 'utf-8', position: 2 },
72
+ { kind: 'exact', value: 'encoding', position: 3 },
73
+ { kind: 'exact', value: 'section', position: 4 },
74
+ { kind: 'exact', value: '1.2', position: 5 },
75
+ { kind: 'identifier', value: '1.2', position: 5 },
76
+ ]));
77
+ });
78
+ it('uses a complete identifier as one query concept', () => {
79
+ const analyzer = createPortableSearchAnalyzer();
80
+ const cases = [
81
+ ['covid-19', 1],
82
+ ['covid-19 vaccine', 2],
83
+ ['utf-8 encoding', 2],
84
+ ['Section 1.2', 2],
85
+ ];
86
+ for (const [query, conceptCount] of cases) {
87
+ expect(analyzer.analyzeQuery({ query }).concepts, query).toHaveLength(conceptCount);
88
+ }
89
+ const quoted = analyzer.analyzeQuery({ query: '"covid-19 cases"' });
90
+ expect(quoted.concepts[0]?.identifierTokens.map((token) => token.value)).toEqual(['covid-19']);
91
+ expect(quoted.phrases).toEqual([{ conceptIndexes: [0, 1], explicit: true }]);
92
+ });
93
+ it('keeps adversarial identifier extraction below the quadratic baseline', () => {
94
+ extractIdentifierSpans('数'.repeat(1_000));
95
+ for (const character of ['数', 'a']) {
96
+ const shortStarted = performance.now();
97
+ extractIdentifierSpans(character.repeat(5_000));
98
+ const shortDuration = performance.now() - shortStarted;
99
+ const longStarted = performance.now();
100
+ extractIdentifierSpans(character.repeat(40_000));
101
+ const longDuration = performance.now() - longStarted;
102
+ // CI runs package suites concurrently, so this is a regression guard,
103
+ // not a production latency target. The old unbounded email rule took
104
+ // more than 10 seconds and grew roughly 50–60x for this 8x input.
105
+ expect(longDuration).toBeLessThan(Math.max(2_500, shortDuration * 32));
106
+ }
107
+ });
108
+ it('analyzes adversarial unbroken text within the unit-test timeout', () => {
109
+ const analyzer = createPortableSearchAnalyzer();
110
+ for (const text of ['数'.repeat(40_000), 'a'.repeat(40_000)]) {
111
+ expect(analyzer.analyzeText({ text }).normalized).toHaveLength(40_000);
112
+ }
113
+ });
114
+ it('rejects an over-long query before analysis', () => {
115
+ const analyzer = createPortableSearchAnalyzer();
116
+ expect(() => analyzer.analyzeQuery({ query: 'a'.repeat(MAX_SEARCH_QUERY_LENGTH) })).not.toThrow();
117
+ expect(() => analyzer.analyzeQuery({ query: 'a'.repeat(MAX_SEARCH_QUERY_LENGTH + 1) })).toThrow(RangeError);
118
+ });
119
+ it('emits ordered overlapping Han bigrams without replacing exact terms', () => {
120
+ const analyzed = createPortableSearchAnalyzer().analyzeText({ text: '数据库' });
121
+ expect(analyzed.exactTokens.map((token) => token.value).join('')).toBe('数据库');
122
+ expect(analyzed.gramTokens.map((token) => token.value)).toEqual(['数据', '据库']);
123
+ expect(analyzed.gramTokens.map((token) => token.position)).toEqual([0, 0]);
124
+ });
125
+ it('groups exact and expanded variants by concept and retains quoted phrase intent', () => {
126
+ const expander = {
127
+ fingerprint: 'english-test1',
128
+ supports: (locale) => locale.startsWith('en'),
129
+ expand: (token) => (token.value === 'running' ? [{ kind: 'stem', value: 'run' }] : []),
130
+ };
131
+ const analyzer = createPortableSearchAnalyzer({ expanders: [expander] });
132
+ const plan = analyzer.analyzeQuery({
133
+ query: '"Running Fast" Node.js',
134
+ locale: 'en',
135
+ matching: { operator: 'any', minimumShouldMatch: 2 },
136
+ });
137
+ expect(plan.matching).toEqual({
138
+ operator: 'any',
139
+ phrase: 'auto',
140
+ minimumShouldMatch: 2,
141
+ });
142
+ expect(plan.concepts).toHaveLength(3);
143
+ expect(plan.concepts[0]?.exactTokens[0]?.value).toBe('running');
144
+ expect(plan.concepts[0]?.stemTokens[0]?.value).toBe('run');
145
+ expect(plan.concepts[2]?.identifierTokens[0]?.value).toBe('node.js');
146
+ expect(plan.phrases).toEqual([{ conceptIndexes: [0, 1], explicit: true }]);
147
+ expect(plan.concepts[0]?.exactTokens[0]).toMatchObject({ start: 1, end: 8, position: 0 });
148
+ expect(plan.concepts[2]?.identifierTokens[0]).toMatchObject({
149
+ start: 15,
150
+ end: 22,
151
+ position: 2,
152
+ });
153
+ expect(plan.analyzerFingerprint).toContain('english-test1');
154
+ });
155
+ it('can require or disable phrase semantics explicitly', () => {
156
+ const analyzer = createPortableSearchAnalyzer();
157
+ expect(analyzer.analyzeQuery({
158
+ query: 'forest restoration',
159
+ matching: { phrase: 'required' },
160
+ }).phrases).toEqual([{ conceptIndexes: [0, 1], explicit: false }]);
161
+ expect(analyzer.analyzeQuery({
162
+ query: '"forest restoration"',
163
+ matching: { phrase: 'off' },
164
+ }).phrases).toEqual([]);
165
+ });
166
+ it('fingerprints every option that can change indexed terms', () => {
167
+ const baseline = createPortableSearchAnalyzer().fingerprint;
168
+ const locale = createPortableSearchAnalyzer({ defaultLocale: 'th' }).fingerprint;
169
+ const han = createPortableSearchAnalyzer({ hanLocale: 'ja' }).fingerprint;
170
+ const grams = createPortableSearchAnalyzer({ hanBigrams: false }).fingerprint;
171
+ expect(new Set([baseline, locale, han, grams]).size).toBe(4);
172
+ expect(baseline).toContain(`icu${process.versions.icu}`);
173
+ });
174
+ });
175
+ describe('matching policy', () => {
176
+ it('applies conservative defaults', () => {
177
+ expect(resolveMatching(undefined)).toEqual({ operator: 'all', phrase: 'auto' });
178
+ });
179
+ it('rejects invalid minimum-should-match policies', () => {
180
+ expect(() => resolveMatching({ operator: 'any', minimumShouldMatch: 0 })).toThrow(RangeError);
181
+ expect(() => resolveMatching({ operator: 'all', minimumShouldMatch: 1 })).toThrow(TypeError);
182
+ });
183
+ });
@@ -0,0 +1,30 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import type { PortableQueryPlan, PortableSearchAnalyzer } from './types.js';
9
+ export interface PortableHighlightOptions {
10
+ /** Maximum independently selected fragments. Defaults to 2. */
11
+ maxFragments?: number;
12
+ /** Maximum analyzed source terms in each snippet. Defaults to 24. */
13
+ maxWords?: number;
14
+ }
15
+ export interface HighlightPortableTextInput extends PortableHighlightOptions {
16
+ /** Original stored text from which source-preserving fragments are selected. */
17
+ text: string;
18
+ /** Query plan produced by the same analyzer used for the index. */
19
+ plan: PortableQueryPlan;
20
+ /** Analyzer used to recover source offsets from the stored text. */
21
+ analyzer: PortableSearchAnalyzer;
22
+ }
23
+ /**
24
+ * Build a backend-neutral matched snippet from portable logical-token offsets.
25
+ *
26
+ * The returned string contains `<mark>…</mark>` delimiters. Consumers must
27
+ * parse those delimiters and render the surrounding source text as text; they
28
+ * must not inject the complete result as trusted HTML.
29
+ */
30
+ export declare function highlightPortableText({ text, plan, analyzer, maxFragments, maxWords, }: HighlightPortableTextInput): string | undefined;