@whiteslove/parsing-lexicon 0.9.6 → 0.9.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/README.md CHANGED
@@ -52,6 +52,8 @@ Lexical aliases belong here. Consumers keep source adapters, persistence, rankin
52
52
 
53
53
  The package must remain dependency-light and must never require a runtime lexicon HTTP service, Redis or a message broker.
54
54
 
55
+ Location entries expose their alias-matching regex through a lazy `re` getter, compiled on first match rather than at merge/import time. Merge helpers must copy entry fields without using object spread on a full entry (`{ ...entry }`), since spread invokes getters and would compile every alias regex eagerly during module load. See `AUDIT.md` for the history of this constraint.
56
+
55
57
  ## Usage
56
58
 
57
59
  ```js
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whiteslove/parsing-lexicon",
3
- "version": "0.9.6",
3
+ "version": "0.9.8",
4
4
  "description": "Shared deterministic multilingual parsing lexicon for WhitesLove housing and hiring services",
5
5
  "repository": {
6
6
  "type": "git",
@@ -16,6 +16,7 @@
16
16
  },
17
17
  "./lexicon-core": "./src/lexicon-core.js",
18
18
  "./normalization": "./src/normalization.js",
19
+ "./alias-prefilter": "./src/alias-prefilter.js",
19
20
  "./parser-core": {
20
21
  "types": "./src/parser-core.d.ts",
21
22
  "import": "./src/parser-core.js"
@@ -0,0 +1,145 @@
1
+ // Candidate prefilter for alias-backed location/entity lists.
2
+ //
3
+ // A country dictionary holds tens of thousands of entries, each carrying a
4
+ // lazily compiled `re` built by aliasesToRegex(). Scanning a list with
5
+ // `entries.find((entry) => entry.re.test(text))` therefore runs one large
6
+ // alternation regex per entry — ~14.5k of them for a single Uzbek listing,
7
+ // which costs seconds per call and dwarfs every other parsing step.
8
+ //
9
+ // Almost all of that work is provably wasted. aliasPattern() only relaxes
10
+ // *separators* between alias words; the letters and digits of an alias are
11
+ // matched literally (modulo the Karakalpak Latin equivalence classes and
12
+ // case). So the longest run of letters/digits inside an alias must appear as a
13
+ // contiguous substring of the text for that alias to match at all. Indexing
14
+ // entries by the first few characters of that run lets a text's own character
15
+ // n-grams select the handful of entries worth testing.
16
+ //
17
+ // The index is a filter, never a verdict: every surviving candidate is still
18
+ // matched with its real `re`, in the list's original order, so results are
19
+ // identical to a full scan. Folding is deliberately more aggressive than the
20
+ // pattern's own equivalences (Cyrillic ё/ў/қ/ғ… are collapsed): over-merging
21
+ // can only admit extra candidates, never discard a real match.
22
+
23
+ import { normalizeUnicode } from './normalization.js';
24
+
25
+ const GRAM = 4;
26
+
27
+ const PREFILTER_FOLD = Object.freeze({
28
+ // Karakalpak Latin equivalences aliasPattern() encodes as character classes.
29
+ á: 'a', ǵ: 'g', ı: 'i', ń: 'n', ó: 'o', ú: 'u',
30
+ // Safe over-merging: these only widen the candidate set.
31
+ ё: 'е', ў: 'у', қ: 'к', ғ: 'г', ҳ: 'х', ә: 'а', і: 'и',
32
+ ң: 'н', ө: 'о', ұ: 'у', ү: 'у', һ: 'х', є: 'е', ї: 'и', ґ: 'г',
33
+ });
34
+
35
+ const NON_ALNUM_RE = /[^\p{L}\p{N}]+/u;
36
+
37
+ function fold(value) {
38
+ let out = '';
39
+ for (const char of normalizeUnicode(value).toLocaleLowerCase()) {
40
+ out += PREFILTER_FOLD[char] ?? char;
41
+ }
42
+ return out;
43
+ }
44
+
45
+ /** The first GRAM characters of an alias's longest literal run, or null. */
46
+ function aliasGram(alias) {
47
+ let longest = '';
48
+ for (const run of fold(alias).split(NON_ALNUM_RE)) {
49
+ if (run.length > longest.length) longest = run;
50
+ }
51
+ return longest.length >= GRAM ? longest.slice(0, GRAM) : null;
52
+ }
53
+
54
+ /** Every GRAM-length window of the folded text. */
55
+ function textGrams(text) {
56
+ const folded = fold(text);
57
+ const grams = new Set();
58
+ for (let i = 0; i + GRAM <= folded.length; i += 1) {
59
+ grams.add(folded.slice(i, i + GRAM));
60
+ }
61
+ return grams;
62
+ }
63
+
64
+ function buildIndex(entries) {
65
+ const byGram = new Map();
66
+ // Entries whose every alias is shorter than GRAM cannot be indexed, so they
67
+ // are always tested. In practice this is a tiny tail.
68
+ const always = [];
69
+
70
+ for (let i = 0; i < entries.length; i += 1) {
71
+ const entry = entries[i];
72
+ const aliases = entry?.aliases?.length ? entry.aliases : [entry?.name].filter(Boolean);
73
+ const grams = [];
74
+ let indexable = aliases.length > 0;
75
+
76
+ for (const alias of aliases) {
77
+ const gram = aliasGram(alias);
78
+ if (!gram) {
79
+ indexable = false;
80
+ break;
81
+ }
82
+ grams.push(gram);
83
+ }
84
+
85
+ if (!indexable) {
86
+ always.push(i);
87
+ continue;
88
+ }
89
+ for (const gram of new Set(grams)) {
90
+ const bucket = byGram.get(gram);
91
+ if (bucket) bucket.push(i);
92
+ else byGram.set(gram, [i]);
93
+ }
94
+ }
95
+
96
+ return { byGram, always };
97
+ }
98
+
99
+ const INDEX_CACHE = new WeakMap();
100
+
101
+ function indexFor(entries) {
102
+ let index = INDEX_CACHE.get(entries);
103
+ if (!index) {
104
+ index = buildIndex(entries);
105
+ INDEX_CACHE.set(entries, index);
106
+ }
107
+ return index;
108
+ }
109
+
110
+ /**
111
+ * First entry in `entries` whose alias regex matches `text`.
112
+ *
113
+ * Equivalent to `entries.find((entry) => entry.re.test(text))`, including the
114
+ * "first in list order wins" tie-break, but only compiles and runs the regexes
115
+ * of entries the text could plausibly contain.
116
+ *
117
+ * `accept(entry, matchedText)` optionally vets each hit before it wins. It
118
+ * receives the substring the alias regex actually matched, which is what a
119
+ * caller needs to tell a genuine name from an alias that merely repeats some
120
+ * other place's name; rejecting a hit continues the scan rather than ending it.
121
+ */
122
+ export function matchFirstEntry(entries, text, accept) {
123
+ if (!Array.isArray(entries) || !entries.length) return undefined;
124
+ const value = String(text || '');
125
+ if (!value) return undefined;
126
+
127
+ const { byGram, always } = indexFor(entries);
128
+ const candidates = new Set(always);
129
+ for (const gram of textGrams(value)) {
130
+ const bucket = byGram.get(gram);
131
+ if (!bucket) continue;
132
+ for (const i of bucket) candidates.add(i);
133
+ }
134
+ if (!candidates.size) return undefined;
135
+
136
+ for (const i of [...candidates].sort((a, b) => a - b)) {
137
+ const entry = entries[i];
138
+ // `re` carries no /g flag, so exec() is stateless and safe to reuse here.
139
+ const match = entry?.re?.exec(value);
140
+ if (!match) continue;
141
+ if (accept && !accept(entry, match[0])) continue;
142
+ return entry;
143
+ }
144
+ return undefined;
145
+ }
@@ -9,6 +9,15 @@ const UZ_CONTEXTUAL_RENT_OUT_RE = /(?:^|[^\p{L}\p{N}_])(?:ijaraga|ижарага
9
9
  const UZ_PER_DAY_RE = /(?:^|[^\p{L}\p{N}_])(?:kuniga|кунига)(?=$|[^\p{L}\p{N}_])/iu;
10
10
  const UZ_DAILY_RENT_PRICE_RE = /(?:narx|нарх|ijara|ижара|to['’`]?lov|т[ўу]лов|оплата)[^.!?\r\n]{0,48}(?:kuniga|кунига)|(?:kuniga|кунига)[^.!?\r\n]{0,48}(?:narx|нарх|ijara|ижара|to['’`]?lov|т[ўу]лов|оплата)/iu;
11
11
 
12
+ // Short-stay wording is heavily inflected in RU/UK ("подобова оренда",
13
+ // "посуточной аренды") and routinely abbreviated on catalogue cards
14
+ // ("посут/почас", "подобово-погодинно"). HOUSING_DEAL_TYPES keeps the canonical
15
+ // dictionary forms; enumerating every inflection there would be brittle, so
16
+ // stems and established abbreviations are matched by pattern instead. A bare
17
+ // day-rate mention ("сутки/суток") counts too: it outranks a source's generic
18
+ // long-rent default even when nothing else resolves to shortRent.
19
+ const EXPLICIT_SHORT_STAY_RE = /(?:^|[^\p{L}\p{N}_])(?:сут(?:ки|ок)|посуточн\p{L}*|почасов\p{L}*|подобов\p{L}*|погодинн\p{L}*|подобу|посут|почас)(?=$|[^\p{L}\p{N}_])/iu;
20
+
12
21
  // "ищу квартиру" / "шукаю квартиру" style aliases only match a literal,
13
22
  // adjacent phrase. Real posts routinely insert a room count or adjective
14
23
  // between the search verb and the housing noun ("Ищу 2-комнатную квартиру"),
@@ -115,7 +124,7 @@ export const HOUSING_DEAL_TYPES = Object.freeze([
115
124
  group('shortRent', {
116
125
  ru: ['посуточно', 'посуточная аренда', 'на сутки', 'на час', 'почасово', 'краткосрочно'],
117
126
  en: ['daily rent', 'short term', 'short-term rent', 'per day', 'hourly'],
118
- uk: ['подобово', 'погодинно', 'на добу', 'на годину', 'короткострокова оренда'],
127
+ uk: ['подобово', 'подобова оренда', 'погодинно', 'на добу', 'на годину', 'короткострокова оренда'],
119
128
  ro: ['regim hotelier', 'pe zi', 'zilnic', 'pe noapte', 'închiriere pe termen scurt', 'inchiriere pe termen scurt'],
120
129
  uzLatn: ['kunlik', 'sutkaga', 'sutkalik', 'soatlik'],
121
130
  uzCyrl: ['кунлик', 'суткага', 'суткалик', 'соатлик'],
@@ -141,11 +150,19 @@ export function resolveHousingIntent(value) {
141
150
  const durationDeal = findCanonical(text, HOUSING_DEAL_TYPES, { partial: true });
142
151
  const hasContextualUzPerDay = UZ_PER_DAY_RE.test(text)
143
152
  && ((action === 'rentOut' || action === 'rentIn') || UZ_DAILY_RENT_PRICE_RE.test(text));
153
+ // An inflected/abbreviated short-stay stem is as authoritative as a
154
+ // dictionary alias, and outranks a longRent alias in the same text: a card
155
+ // reading "подобова оренда" is a day rental that happens to use the generic
156
+ // word for renting, not a long-term lease.
157
+ const hasShortStayStem = EXPLICIT_SHORT_STAY_RE.test(text);
158
+ const shortStay = durationDeal?.canonical === 'shortRent'
159
+ || hasContextualUzPerDay
160
+ || hasShortStayStem;
144
161
 
145
162
  if (action) {
146
163
  const base = HOUSING_ACTION_MAP[action];
147
164
  let dealType = base.dealType;
148
- if ((durationDeal?.canonical === 'shortRent' || hasContextualUzPerDay) && (action === 'rentOut' || action === 'rentIn')) {
165
+ if (shortStay && (action === 'rentOut' || action === 'rentIn')) {
149
166
  dealType = 'shortRent';
150
167
  }
151
168
  return Object.freeze({
@@ -155,11 +172,11 @@ export function resolveHousingIntent(value) {
155
172
  });
156
173
  }
157
174
 
158
- if (!durationDeal && !hasContextualUzPerDay) return null;
175
+ if (!durationDeal && !shortStay) return null;
159
176
  return Object.freeze({
160
177
  action: null,
161
178
  listingKind: null,
162
- dealType: durationDeal?.canonical || 'shortRent',
179
+ dealType: shortStay ? 'shortRent' : durationDeal.canonical,
163
180
  });
164
181
  }
165
182
 
@@ -178,11 +195,6 @@ export function classifyHousingDealType(value) {
178
195
  return null;
179
196
  }
180
197
 
181
- // A bare "сутки/суток" (day-rate) mention outranks a source's generic
182
- // long-rent default, even when the rest of the text does not otherwise
183
- // resolve to shortRent.
184
- const EXPLICIT_SHORT_STAY_RE = /(?:^|[^\p{L}\p{N}_])сут(?:ки|ок)(?=$|[^\p{L}\p{N}_])/iu;
185
-
186
198
  export function looksExplicitDailyRentalMention(value) {
187
199
  return EXPLICIT_SHORT_STAY_RE.test(String(value || ''));
188
200
  }