@whiteslove/parsing-lexicon 0.10.7 → 0.10.8
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/package.json +1 -1
- package/src/location-merge.js +48 -18
- package/src/normalization.js +105 -38
package/package.json
CHANGED
package/src/location-merge.js
CHANGED
|
@@ -31,18 +31,29 @@ export const LOCATION_LIST_KEYS = Object.freeze([
|
|
|
31
31
|
'searchClusters',
|
|
32
32
|
]);
|
|
33
33
|
|
|
34
|
+
// One shared accessor compiles an entry's alias regex on first use. Giving
|
|
35
|
+
// every entry its own getter closure cost ~100 bytes per entry, and the full
|
|
36
|
+
// dictionary set holds over 130,000 entries. The compiled regex lives in a
|
|
37
|
+
// WeakMap because entries are frozen.
|
|
38
|
+
const COMPILED_RE = new WeakMap();
|
|
39
|
+
const ENTRY_PROTOTYPE = Object.freeze({
|
|
40
|
+
get re() {
|
|
41
|
+
let re = COMPILED_RE.get(this);
|
|
42
|
+
if (!re) {
|
|
43
|
+
re = aliasesToRegex(this.aliases);
|
|
44
|
+
COMPILED_RE.set(this, re);
|
|
45
|
+
}
|
|
46
|
+
return re;
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
function frozenEntry(fields) {
|
|
51
|
+
return Object.freeze(Object.assign(Object.create(ENTRY_PROTOTYPE), fields));
|
|
52
|
+
}
|
|
53
|
+
|
|
34
54
|
export function locationEntry(name, ...aliases) {
|
|
35
55
|
const all = [...new Set([name, ...aliases].flat().filter(Boolean))];
|
|
36
|
-
|
|
37
|
-
return Object.freeze({
|
|
38
|
-
canonical: name,
|
|
39
|
-
name,
|
|
40
|
-
aliases: Object.freeze(all),
|
|
41
|
-
get re() {
|
|
42
|
-
re ||= aliasesToRegex(all);
|
|
43
|
-
return re;
|
|
44
|
-
},
|
|
45
|
-
});
|
|
56
|
+
return frozenEntry({ canonical: name, name, aliases: Object.freeze(all) });
|
|
46
57
|
}
|
|
47
58
|
|
|
48
59
|
export function locationEntries(rows = []) {
|
|
@@ -61,6 +72,26 @@ function entryData(entry) {
|
|
|
61
72
|
return data;
|
|
62
73
|
}
|
|
63
74
|
|
|
75
|
+
function uniqueTruthy(values) {
|
|
76
|
+
if (values.length > 32) return new Set(values).size === values.length && values.every(Boolean);
|
|
77
|
+
for (let i = 0; i < values.length; i += 1) {
|
|
78
|
+
if (!values[i]) return false;
|
|
79
|
+
for (let j = 0; j < i; j += 1) if (values[j] === values[i]) return false;
|
|
80
|
+
}
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Merging one entry with nothing returns an entry equal to itself when it is
|
|
85
|
+
// already frozen, canonical, untyped and carries a unique alias list that
|
|
86
|
+
// names it (every locationEntry()/mergeEntry() result does). Nearly every
|
|
87
|
+
// group at import time is a single such entry, so reuse it instead of
|
|
88
|
+
// rebuilding it. Map-data marking is by identity, so it is preserved as is.
|
|
89
|
+
function isFinishedEntry(entry) {
|
|
90
|
+
if (!Object.isFrozen(entry) || !entry.canonical || entry.type !== undefined || entry.entityType !== undefined) return false;
|
|
91
|
+
const aliases = entry.aliases;
|
|
92
|
+
return Array.isArray(aliases) && Object.isFrozen(aliases) && aliases.includes(entry.name) && uniqueTruthy(aliases);
|
|
93
|
+
}
|
|
94
|
+
|
|
64
95
|
function mergeEntry(existing, incoming) {
|
|
65
96
|
const aliases = [...new Set([
|
|
66
97
|
...(existing?.aliases || []),
|
|
@@ -73,19 +104,14 @@ function mergeEntry(existing, incoming) {
|
|
|
73
104
|
// aliases. It remains fallback-only only when every merged source is map
|
|
74
105
|
// data; otherwise the reviewed owner must retain its matching precedence.
|
|
75
106
|
const mapData = existing ? isMapDataEntry(existing) && isMapDataEntry(incoming) : isMapDataEntry(incoming);
|
|
76
|
-
|
|
77
|
-
const result = {
|
|
107
|
+
const result = frozenEntry({
|
|
78
108
|
...base,
|
|
79
109
|
canonical: base.canonical || base.name,
|
|
80
110
|
type: base.type || base.entityType,
|
|
81
111
|
aliases: Object.freeze(aliases),
|
|
82
|
-
|
|
83
|
-
re ||= aliasesToRegex(aliases);
|
|
84
|
-
return re;
|
|
85
|
-
},
|
|
86
|
-
};
|
|
112
|
+
});
|
|
87
113
|
if (mapData) MAP_DATA_ENTRIES.add(result);
|
|
88
|
-
return
|
|
114
|
+
return result;
|
|
89
115
|
}
|
|
90
116
|
|
|
91
117
|
function parentKey(entry) {
|
|
@@ -111,6 +137,10 @@ export function mergeLocationEntries(...lists) {
|
|
|
111
137
|
const result = [];
|
|
112
138
|
for (const canonical of order) {
|
|
113
139
|
const group = groups.get(canonical) || [];
|
|
140
|
+
if (group.length === 1) {
|
|
141
|
+
result.push(isFinishedEntry(group[0]) ? group[0] : mergeEntry(null, group[0]));
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
114
144
|
const scopedParents = [...new Set(group.map(parentKey).filter(Boolean))];
|
|
115
145
|
|
|
116
146
|
if (scopedParents.length <= 1) {
|
package/src/normalization.js
CHANGED
|
@@ -10,8 +10,8 @@ const DASHES_RE = /[‐‑‒–—―]/g;
|
|
|
10
10
|
// recent long inputs. Short strings (dictionary aliases, single words) are
|
|
11
11
|
// cheaper to normalize than to cache and are left alone; the cap bounds
|
|
12
12
|
// memory to a few recent texts per function.
|
|
13
|
-
const MEMO_MIN_LENGTH =
|
|
14
|
-
const MEMO_MAX_ENTRIES =
|
|
13
|
+
const MEMO_MIN_LENGTH = 24;
|
|
14
|
+
const MEMO_MAX_ENTRIES = 512;
|
|
15
15
|
|
|
16
16
|
function memoizeLongText(fn) {
|
|
17
17
|
const cache = new Map();
|
|
@@ -34,8 +34,15 @@ export const normalizeUnicode = memoizeLongText(function normalizeUnicode(value)
|
|
|
34
34
|
.replace(DASHES_RE, '-');
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
// Dictionary construction normalizes the same names and aliases hundreds of
|
|
38
|
+
// thousands of times (about 5x more calls than distinct strings at import), so
|
|
39
|
+
// short strings get a bounded cache of their own. Once it is full new strings
|
|
40
|
+
// are simply computed, never evicted: the entries that fill it first are the
|
|
41
|
+
// dictionary strings that every later match compares against.
|
|
42
|
+
const SHORT_CACHE_MAX_ENTRIES = 131072;
|
|
43
|
+
const shortMatchCache = new Map();
|
|
44
|
+
|
|
45
|
+
function normalizeForMatchUncached(value) {
|
|
39
46
|
return normalizeUnicode(value)
|
|
40
47
|
.toLocaleLowerCase()
|
|
41
48
|
.replace(/ё/g, 'е')
|
|
@@ -43,7 +50,19 @@ export const normalizeForMatch = memoizeLongText(function normalizeForMatch(valu
|
|
|
43
50
|
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
|
44
51
|
.replace(/\s+/g, ' ')
|
|
45
52
|
.trim();
|
|
46
|
-
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const normalizeForMatchLong = memoizeLongText(normalizeForMatchUncached);
|
|
56
|
+
|
|
57
|
+
/** Stable comparison form for parser dictionaries. */
|
|
58
|
+
export function normalizeForMatch(value) {
|
|
59
|
+
if (typeof value !== 'string' || value.length >= MEMO_MIN_LENGTH) return normalizeForMatchLong(value);
|
|
60
|
+
const hit = shortMatchCache.get(value);
|
|
61
|
+
if (hit !== undefined) return hit;
|
|
62
|
+
const result = normalizeForMatchUncached(value);
|
|
63
|
+
if (shortMatchCache.size < SHORT_CACHE_MAX_ENTRIES) shortMatchCache.set(value, result);
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
47
66
|
|
|
48
67
|
/** Search-only apostrophe compaction for Uzbek/Karakalpak variants. */
|
|
49
68
|
const normalizeCompactApostropheForMatch = memoizeLongText(function normalizeCompactApostropheForMatch(value) {
|
|
@@ -87,39 +106,50 @@ const KARAKALPAK_LATIN_PATTERN_EQUIVALENCE = Object.freeze({
|
|
|
87
106
|
u: '[uú]', ú: '[uú]',
|
|
88
107
|
});
|
|
89
108
|
|
|
109
|
+
// Every map below is keyed by single BMP characters, so one regex replace is
|
|
110
|
+
// identical to mapping the string character by character.
|
|
111
|
+
function foldByMap(map) {
|
|
112
|
+
const re = new RegExp(`[${Object.keys(map).join('')}]`, 'g');
|
|
113
|
+
return (text) => text.replace(re, (char) => map[char]);
|
|
114
|
+
}
|
|
115
|
+
const foldCyrillicChars = foldByMap(CYRILLIC_SEARCH_MAP);
|
|
116
|
+
const foldKazakhChars = foldByMap(KAZAKH_SEARCH_EQUIVALENCE);
|
|
117
|
+
const foldKarakalpakChars = foldByMap(KARAKALPAK_LATIN_SEARCH_EQUIVALENCE);
|
|
118
|
+
|
|
90
119
|
/** Search-oriented Cyrillic folding; canonical identity still comes from explicit aliases. */
|
|
91
120
|
export const foldCyrillicForSearch = memoizeLongText(function foldCyrillicForSearch(value) {
|
|
92
|
-
return normalizeUnicode(value)
|
|
93
|
-
.toLocaleLowerCase()
|
|
94
|
-
.split('')
|
|
95
|
-
.map((char) => CYRILLIC_SEARCH_MAP[char] ?? char)
|
|
96
|
-
.join('');
|
|
121
|
+
return foldCyrillicChars(normalizeUnicode(value).toLocaleLowerCase());
|
|
97
122
|
});
|
|
98
123
|
|
|
99
124
|
const foldKazakhForSearch = memoizeLongText(function foldKazakhForSearch(value) {
|
|
100
|
-
return normalizeUnicode(value)
|
|
101
|
-
.toLocaleLowerCase()
|
|
102
|
-
.split('')
|
|
103
|
-
.map((char) => KAZAKH_SEARCH_EQUIVALENCE[char] ?? char)
|
|
104
|
-
.join('');
|
|
125
|
+
return foldKazakhChars(normalizeUnicode(value).toLocaleLowerCase());
|
|
105
126
|
});
|
|
106
127
|
|
|
107
|
-
function foldKarakalpakLatinForSearch(value) {
|
|
108
|
-
return normalizeUnicode(value)
|
|
109
|
-
|
|
110
|
-
.split('')
|
|
111
|
-
.map((char) => KARAKALPAK_LATIN_SEARCH_EQUIVALENCE[char] ?? char)
|
|
112
|
-
.join('');
|
|
113
|
-
}
|
|
128
|
+
const foldKarakalpakLatinForSearch = memoizeLongText(function foldKarakalpakLatinForSearch(value) {
|
|
129
|
+
return foldKarakalpakChars(normalizeUnicode(value).toLocaleLowerCase());
|
|
130
|
+
});
|
|
114
131
|
|
|
115
|
-
|
|
132
|
+
function computeAliasKeys(value, transliteration) {
|
|
116
133
|
const forms = transliteration
|
|
117
134
|
? [value, foldCyrillicForSearch(value), foldKazakhForSearch(value), foldKarakalpakLatinForSearch(value)]
|
|
118
135
|
: [value];
|
|
119
|
-
return [...new Set(forms.flatMap((form) => [
|
|
136
|
+
return Object.freeze([...new Set(forms.flatMap((form) => [
|
|
120
137
|
normalizeForMatch(form),
|
|
121
138
|
normalizeCompactApostropheForMatch(form),
|
|
122
|
-
]).filter(Boolean))];
|
|
139
|
+
]).filter(Boolean))]);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// One parse asks every matcher for the keys of the same text, and each
|
|
143
|
+
// derivation folds the text through four scripts. Keyed by text, per mode.
|
|
144
|
+
const aliasKeysWithTransliteration = memoizeLongText((value) => computeAliasKeys(value, true));
|
|
145
|
+
const aliasKeysDirect = memoizeLongText((value) => computeAliasKeys(value, false));
|
|
146
|
+
|
|
147
|
+
function aliasKeys(value, transliteration) {
|
|
148
|
+
return (transliteration ? aliasKeysWithTransliteration : aliasKeysDirect)(value);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function normalizedAliasKeys(value, { transliteration = true } = {}) {
|
|
152
|
+
return [...aliasKeys(value, transliteration)];
|
|
123
153
|
}
|
|
124
154
|
|
|
125
155
|
export function aliasesOf(entry) {
|
|
@@ -137,7 +167,7 @@ function createAliasIndex(entries, { transliteration = true } = {}) {
|
|
|
137
167
|
const index = new Map();
|
|
138
168
|
for (const entry of entries || []) {
|
|
139
169
|
for (const value of valuesOf(entry)) {
|
|
140
|
-
for (const key of
|
|
170
|
+
for (const key of aliasKeys(value, transliteration)) {
|
|
141
171
|
if (!index.has(key)) index.set(key, entry);
|
|
142
172
|
}
|
|
143
173
|
}
|
|
@@ -206,6 +236,32 @@ export function getAliasOwnersIndex(entries, { transliteration = true } = {}) {
|
|
|
206
236
|
return index;
|
|
207
237
|
}
|
|
208
238
|
|
|
239
|
+
const PARTIAL_INFO = new WeakMap();
|
|
240
|
+
|
|
241
|
+
// An alias key is letters and digits joined by single spaces, and so is a text
|
|
242
|
+
// key, so `${alias}` is contained in the padded text exactly when the alias
|
|
243
|
+
// equals a run of whole words of the text. That lets a partial lookup probe the
|
|
244
|
+
// text's word n-grams instead of scanning every alias in the index. `ordinal`
|
|
245
|
+
// keeps each alias's position in the index, which is what orders a tie.
|
|
246
|
+
function partialInfo(index) {
|
|
247
|
+
let info = PARTIAL_INFO.get(index);
|
|
248
|
+
if (!info) {
|
|
249
|
+
const ordinal = new Map();
|
|
250
|
+
let maxWords = 1;
|
|
251
|
+
let position = 0;
|
|
252
|
+
for (const alias of index.keys()) {
|
|
253
|
+
ordinal.set(alias, position);
|
|
254
|
+
position += 1;
|
|
255
|
+
let words = 1;
|
|
256
|
+
for (let i = alias.indexOf(' '); i !== -1; i = alias.indexOf(' ', i + 1)) words += 1;
|
|
257
|
+
if (words > maxWords) maxWords = words;
|
|
258
|
+
}
|
|
259
|
+
info = { ordinal, maxWords };
|
|
260
|
+
PARTIAL_INFO.set(index, info);
|
|
261
|
+
}
|
|
262
|
+
return info;
|
|
263
|
+
}
|
|
264
|
+
|
|
209
265
|
/**
|
|
210
266
|
* Every entry tied for the longest partial alias match, in the order they
|
|
211
267
|
* first reach that length. Exact matches always resolve unambiguously to a
|
|
@@ -216,25 +272,36 @@ export function getAliasOwnersIndex(entries, { transliteration = true } = {}) {
|
|
|
216
272
|
export function findCanonicalCandidates(value, entries, { partial = false, transliteration = true } = {}) {
|
|
217
273
|
if (!value) return [];
|
|
218
274
|
const index = getAliasIndex(entries, { transliteration });
|
|
219
|
-
|
|
275
|
+
const textKeys = aliasKeys(value, transliteration);
|
|
276
|
+
for (const key of textKeys) {
|
|
220
277
|
const exact = index.get(key);
|
|
221
278
|
if (exact) return [exact];
|
|
222
279
|
}
|
|
223
280
|
if (!partial) return [];
|
|
224
281
|
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
282
|
+
const { ordinal, maxWords } = partialInfo(index);
|
|
283
|
+
const matched = new Map();
|
|
284
|
+
for (const key of textKeys) {
|
|
285
|
+
const words = key.split(' ');
|
|
286
|
+
for (let start = 0; start < words.length; start += 1) {
|
|
287
|
+
let phrase = '';
|
|
288
|
+
const stop = Math.min(words.length, start + maxWords);
|
|
289
|
+
for (let end = start; end < stop; end += 1) {
|
|
290
|
+
phrase = end === start ? words[end] : `${phrase} ${words[end]}`;
|
|
291
|
+
const position = ordinal.get(phrase);
|
|
292
|
+
if (position !== undefined) matched.set(phrase, position);
|
|
293
|
+
}
|
|
236
294
|
}
|
|
237
295
|
}
|
|
296
|
+
|
|
297
|
+
let bestLength = 0;
|
|
298
|
+
for (const alias of matched.keys()) if (alias.length > bestLength) bestLength = alias.length;
|
|
299
|
+
const longest = [...matched].filter(([alias]) => alias.length === bestLength).sort((a, b) => a[1] - b[1]);
|
|
300
|
+
const tied = [];
|
|
301
|
+
for (const [alias] of longest) {
|
|
302
|
+
const entry = index.get(alias);
|
|
303
|
+
if (!tied.includes(entry)) tied.push(entry);
|
|
304
|
+
}
|
|
238
305
|
return tied;
|
|
239
306
|
}
|
|
240
307
|
|