fhirsmith 0.13.3 → 0.14.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.
- package/CHANGELOG.md +47 -1
- package/README.md +48 -4
- package/config-template.json +70 -1
- package/library/cgroup-memory.js +113 -0
- package/library/folder-setup.js +8 -1
- package/nginx.md +391 -0
- package/package.json +3 -2
- package/publisher/git-remote.js +141 -0
- package/publisher/github-release.js +195 -0
- package/publisher/publisher.js +146 -14
- package/server.js +51 -14
- package/stats.js +19 -13
- package/testing/readme.md +112 -0
- package/testing/render.js +482 -0
- package/testing/search.js +378 -0
- package/testing/store.js +266 -0
- package/testing/testing-template.html +126 -0
- package/testing/testing.js +486 -0
- package/translations/Messages.properties +32 -0
- package/tx/closure/closure-store.js +396 -0
- package/tx/cs/cs-api.js +6 -1
- package/tx/cs/cs-rxnorm.js +35 -8
- package/tx/cs/cs-snomed.js +142 -27
- package/tx/html/codesystem-operations.liquid +177 -0
- package/tx/html/ecl-panel.liquid +93 -0
- package/tx/html/home-metrics.liquid +39 -76
- package/tx/html/tx-template.html +1 -0
- package/tx/library/conceptmap.js +22 -6
- package/tx/library/designations.js +12 -3
- package/tx/library/extensions.js +2 -2
- package/tx/library/operation-outcome.js +3 -1
- package/tx/library/renderer.js +42 -5
- package/tx/operation-context.js +111 -15
- package/tx/provider.js +85 -0
- package/tx/sct/expressions.js +128 -44
- package/tx/sct/text-index.js +419 -0
- package/tx/tx-html.js +426 -44
- package/tx/tx.js +117 -9
- package/tx/workers/cache-control.js +2 -2
- package/tx/workers/closure.js +483 -13
- package/tx/workers/expand.js +20 -0
- package/tx/workers/metadata.js +29 -4
- package/tx/workers/read.js +18 -3
- package/tx/workers/subsumes.js +18 -2
- package/tx/workers/translate.js +167 -20
- package/tx/workers/validate.js +61 -182
- package/tx/workers/worker.js +9 -0
- package/tx/xversion/xv-conceptmap.js +17 -8
- package/utilities/submit-test-report.js +150 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* In-memory word index for free-text SNOMED CT search ($expand ... filter=).
|
|
5
|
+
*
|
|
6
|
+
* The cache files produced by the Pascal importer carry no words/stems
|
|
7
|
+
* index, and the ones produced by the JS importer carry one keyed on
|
|
8
|
+
* language-specific stems of every description (inactive ones included). So
|
|
9
|
+
* rather than depend on what a given cache happens to hold, the index is
|
|
10
|
+
* built here, in memory, the first time an edition is text-searched:
|
|
11
|
+
*
|
|
12
|
+
* - every alphanumeric token (Unicode letters and digits, lower-cased,
|
|
13
|
+
* 2+ characters) of every ACTIVE description is a "word"
|
|
14
|
+
* - words[] is sorted, so a filter token matches every word it is a prefix
|
|
15
|
+
* of (a user who has typed "radioth" finds "radiotherapy")
|
|
16
|
+
* - each word is also filed under its Porter stem, so a filter token matches
|
|
17
|
+
* the other inflections of the same stem ("fractures" finds "fracture")
|
|
18
|
+
* - postings[w] is the ascending list of concept ORDINALS (reference /
|
|
19
|
+
* CONCEPT_SIZE) whose active descriptions contain word w
|
|
20
|
+
* - wordCounts[ordinal] is the number of distinct words the concept has,
|
|
21
|
+
* used to rank concepts whose terms are mostly the filter above ones where
|
|
22
|
+
* the filter is a small part of a long term
|
|
23
|
+
*
|
|
24
|
+
* A search requires every (non stop-word) filter token to match somewhere in
|
|
25
|
+
* the concept's active descriptions. For the full International Edition the
|
|
26
|
+
* build takes a few seconds and the index a few tens of MB; it yields to the
|
|
27
|
+
* event loop as it goes, and a search takes milliseconds once it exists.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const natural = require('natural');
|
|
31
|
+
|
|
32
|
+
const stemmer = natural.PorterStemmer;
|
|
33
|
+
|
|
34
|
+
// Only the short function words that make a multi-word filter fail to match
|
|
35
|
+
// anything when required. Not "with", "without", "no", "not" - those change
|
|
36
|
+
// the meaning of a clinical term.
|
|
37
|
+
const STOP_WORDS = new Set(['a', 'an', 'and', 'as', 'at', 'by', 'for', 'from', 'in', 'into', 'of', 'on', 'or', 'the', 'to']);
|
|
38
|
+
|
|
39
|
+
const TOKEN_RE = /[\p{L}\p{N}]+/gu;
|
|
40
|
+
|
|
41
|
+
const MIN_WORD_LENGTH = 2;
|
|
42
|
+
|
|
43
|
+
// How long the build runs before giving the event loop a turn (ms).
|
|
44
|
+
const BUILD_SLICE = 20;
|
|
45
|
+
|
|
46
|
+
// How many of the stem-ranked candidates get the (more expensive)
|
|
47
|
+
// description-level ranking.
|
|
48
|
+
const RANK_REFINE_LIMIT = 200;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Split text into lower-cased word tokens.
|
|
52
|
+
* @param {string} text
|
|
53
|
+
* @returns {string[]}
|
|
54
|
+
*/
|
|
55
|
+
function tokenize(text) {
|
|
56
|
+
if (!text) {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
return text.toLowerCase().match(TOKEN_RE) || [];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The tokens of a filter that the index can search on: 2+ characters, and
|
|
64
|
+
* stop words dropped unless nothing else is left.
|
|
65
|
+
* @param {string} filterText
|
|
66
|
+
* @returns {string[]} distinct tokens, in filter order
|
|
67
|
+
*/
|
|
68
|
+
function filterTokens(filterText) {
|
|
69
|
+
const tokens = [...new Set(tokenize(filterText))].filter(t => t.length >= MIN_WORD_LENGTH);
|
|
70
|
+
const content = tokens.filter(t => !STOP_WORDS.has(t));
|
|
71
|
+
return content.length > 0 ? content : tokens;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function yieldToEventLoop() {
|
|
75
|
+
return new Promise(resolve => setImmediate(resolve));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
class SnomedTextIndex {
|
|
79
|
+
constructor(words, postings, stemWords, wordCounts, conceptSize) {
|
|
80
|
+
this.words = words; // sorted string[]
|
|
81
|
+
this.postings = postings; // Uint32Array[] parallel to words
|
|
82
|
+
this.stemWords = stemWords; // Map stem -> word ids (number[])
|
|
83
|
+
this.wordCounts = wordCounts; // Uint16Array by concept ordinal
|
|
84
|
+
this.conceptSize = conceptSize;
|
|
85
|
+
this.scratch = new Uint8Array(wordCounts.length);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Build the index for a loaded edition. Yields to the event loop every
|
|
90
|
+
* BUILD_SLICE ms of work.
|
|
91
|
+
* @param {object} sct - SnomedServices
|
|
92
|
+
* @returns {Promise<SnomedTextIndex>}
|
|
93
|
+
*/
|
|
94
|
+
static async build(sct) {
|
|
95
|
+
const concepts = sct.concepts;
|
|
96
|
+
const conceptSize = concepts.constructor.CONCEPT_SIZE;
|
|
97
|
+
const conceptCount = concepts.count();
|
|
98
|
+
const descMaster = sct.descriptions.master;
|
|
99
|
+
|
|
100
|
+
const wordIds = new Map(); // word -> provisional id
|
|
101
|
+
const lists = []; // provisional id -> number[] of ordinals
|
|
102
|
+
const lastSeen = []; // provisional id -> last ordinal added
|
|
103
|
+
const wordCounts = new Uint16Array(conceptCount);
|
|
104
|
+
|
|
105
|
+
let sliceStart = performance.now();
|
|
106
|
+
for (let ordinal = 0; ordinal < conceptCount; ordinal++) {
|
|
107
|
+
if ((ordinal & 0xFF) === 0 && performance.now() - sliceStart >= BUILD_SLICE) {
|
|
108
|
+
await yieldToEventLoop();
|
|
109
|
+
sliceStart = performance.now();
|
|
110
|
+
}
|
|
111
|
+
const descriptionsRef = concepts.getDescriptions(ordinal * conceptSize);
|
|
112
|
+
if (descriptionsRef === 0) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
let distinct = 0;
|
|
116
|
+
for (const descIndex of sct.refs.getReferences(descriptionsRef)) {
|
|
117
|
+
if (descMaster.readUInt8(descIndex + 4) === 0) {
|
|
118
|
+
continue; // inactive description
|
|
119
|
+
}
|
|
120
|
+
const term = sct.strings.getEntry(descMaster.readUInt32LE(descIndex));
|
|
121
|
+
for (const word of tokenize(term)) {
|
|
122
|
+
if (word.length < MIN_WORD_LENGTH) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
let id = wordIds.get(word);
|
|
126
|
+
if (id === undefined) {
|
|
127
|
+
id = lists.length;
|
|
128
|
+
wordIds.set(word, id);
|
|
129
|
+
lists.push([]);
|
|
130
|
+
lastSeen.push(-1);
|
|
131
|
+
}
|
|
132
|
+
if (lastSeen[id] !== ordinal) {
|
|
133
|
+
lastSeen[id] = ordinal;
|
|
134
|
+
lists[id].push(ordinal);
|
|
135
|
+
distinct++;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
wordCounts[ordinal] = Math.min(distinct, 0xFFFF);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Final layout: words sorted, postings as typed arrays, stems -> words.
|
|
143
|
+
const words = [...wordIds.keys()].sort();
|
|
144
|
+
const postings = new Array(words.length);
|
|
145
|
+
const stemWords = new Map();
|
|
146
|
+
sliceStart = performance.now();
|
|
147
|
+
for (let i = 0; i < words.length; i++) {
|
|
148
|
+
if ((i & 0xFF) === 0 && performance.now() - sliceStart >= BUILD_SLICE) {
|
|
149
|
+
await yieldToEventLoop();
|
|
150
|
+
sliceStart = performance.now();
|
|
151
|
+
}
|
|
152
|
+
const word = words[i];
|
|
153
|
+
const id = wordIds.get(word);
|
|
154
|
+
postings[i] = Uint32Array.from(lists[id]);
|
|
155
|
+
lists[id] = null;
|
|
156
|
+
const stem = stemmer.stem(word);
|
|
157
|
+
let ids = stemWords.get(stem);
|
|
158
|
+
if (!ids) {
|
|
159
|
+
ids = [];
|
|
160
|
+
stemWords.set(stem, ids);
|
|
161
|
+
}
|
|
162
|
+
ids.push(i);
|
|
163
|
+
}
|
|
164
|
+
return new SnomedTextIndex(words, postings, stemWords, wordCounts, conceptSize);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The ids of the words a filter token matches.
|
|
169
|
+
*
|
|
170
|
+
* A token always matches the words that share its stem ("fractures" finds
|
|
171
|
+
* "fracture"). Only the LAST token of the filter also matches the words it
|
|
172
|
+
* is a prefix of - that one may be a word the user is still typing
|
|
173
|
+
* ("radioth" -> "radiotherapy"), whereas treating a completed word as a
|
|
174
|
+
* prefix matches unrelated terms ("anal" -> "analog").
|
|
175
|
+
*
|
|
176
|
+
* @param {string} token
|
|
177
|
+
* @param {boolean} allowPrefix
|
|
178
|
+
* @returns {number[]}
|
|
179
|
+
*/
|
|
180
|
+
wordsFor(token, allowPrefix = false) {
|
|
181
|
+
const ids = new Set();
|
|
182
|
+
let lo = 0;
|
|
183
|
+
let hi = this.words.length;
|
|
184
|
+
while (lo < hi) {
|
|
185
|
+
const mid = (lo + hi) >>> 1;
|
|
186
|
+
if (this.words[mid] < token) {
|
|
187
|
+
lo = mid + 1;
|
|
188
|
+
} else {
|
|
189
|
+
hi = mid;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (allowPrefix) {
|
|
193
|
+
for (let i = lo; i < this.words.length && this.words[i].startsWith(token); i++) {
|
|
194
|
+
ids.add(i);
|
|
195
|
+
}
|
|
196
|
+
} else if (lo < this.words.length && this.words[lo] === token) {
|
|
197
|
+
ids.add(lo);
|
|
198
|
+
}
|
|
199
|
+
const stemmed = this.stemWords.get(stemmer.stem(token));
|
|
200
|
+
if (stemmed) {
|
|
201
|
+
for (const i of stemmed) {
|
|
202
|
+
ids.add(i);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return [...ids];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Concept ordinals whose active descriptions match every token.
|
|
210
|
+
* @param {string[]} tokens - from filterTokens()
|
|
211
|
+
* @param {object} [opContext] - for deadCheck
|
|
212
|
+
* @returns {number[]} ascending ordinals
|
|
213
|
+
*/
|
|
214
|
+
search(tokens, opContext = null) {
|
|
215
|
+
// Deliberately synchronous: the scratch bitmap below is shared by every
|
|
216
|
+
// search on this edition, so nothing may interleave with it.
|
|
217
|
+
if (tokens.length === 0) {
|
|
218
|
+
return [];
|
|
219
|
+
}
|
|
220
|
+
const perToken = tokens.map((token, i) => {
|
|
221
|
+
const ids = this.wordsFor(token, i === tokens.length - 1);
|
|
222
|
+
let size = 0;
|
|
223
|
+
for (const id of ids) {
|
|
224
|
+
size += this.postings[id].length;
|
|
225
|
+
}
|
|
226
|
+
return { ids, size };
|
|
227
|
+
});
|
|
228
|
+
perToken.sort((a, b) => a.size - b.size);
|
|
229
|
+
if (perToken[0].size === 0) {
|
|
230
|
+
return [];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const mark = this.scratch;
|
|
234
|
+
// Candidates: the union for the rarest token.
|
|
235
|
+
let candidates;
|
|
236
|
+
const first = perToken[0].ids;
|
|
237
|
+
if (first.length === 1) {
|
|
238
|
+
candidates = Array.from(this.postings[first[0]]);
|
|
239
|
+
} else {
|
|
240
|
+
mark.fill(0);
|
|
241
|
+
for (const id of first) {
|
|
242
|
+
for (const ordinal of this.postings[id]) {
|
|
243
|
+
mark[ordinal] = 1;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
candidates = [];
|
|
247
|
+
for (let o = 0; o < mark.length; o++) {
|
|
248
|
+
if (mark[o]) {
|
|
249
|
+
candidates.push(o);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Narrow by each further token.
|
|
255
|
+
for (let t = 1; t < perToken.length && candidates.length > 0; t++) {
|
|
256
|
+
if (opContext) {
|
|
257
|
+
opContext.deadCheck('sct:textIndex');
|
|
258
|
+
}
|
|
259
|
+
mark.fill(0);
|
|
260
|
+
for (const id of perToken[t].ids) {
|
|
261
|
+
for (const ordinal of this.postings[id]) {
|
|
262
|
+
mark[ordinal] = 1;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
candidates = candidates.filter(o => mark[o] === 1);
|
|
266
|
+
}
|
|
267
|
+
return candidates;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Rank the matched concepts and turn them into SnomedFilterContext matches.
|
|
273
|
+
*
|
|
274
|
+
* Coarse rank for all: the share of the concept's words that the filter
|
|
275
|
+
* accounts for (a concept whose terms are just "appendicitis" beats one that
|
|
276
|
+
* mentions it in passing). The top RANK_REFINE_LIMIT are then checked
|
|
277
|
+
* against their active description terms: exact term 100, term starting with
|
|
278
|
+
* the filter 50, all filter tokens within a single description 30, else 10 -
|
|
279
|
+
* plus the coarse share as a tie-break.
|
|
280
|
+
*
|
|
281
|
+
* @param {object} sct - SnomedServices
|
|
282
|
+
* @param {SnomedTextIndex} index
|
|
283
|
+
* @param {number[]} ordinals
|
|
284
|
+
* @param {string} filterText - lower-cased, trimmed filter
|
|
285
|
+
* @param {string[]} tokens
|
|
286
|
+
* @param {boolean} includeInactive
|
|
287
|
+
* @returns {{index:number, term:bigint, priority:number}[]}
|
|
288
|
+
*/
|
|
289
|
+
function rankMatches(sct, index, ordinals, filterText, tokens, includeInactive) {
|
|
290
|
+
const size = index.conceptSize;
|
|
291
|
+
const conceptMaster = sct.concepts.master;
|
|
292
|
+
const wordCounts = index.wordCounts;
|
|
293
|
+
|
|
294
|
+
// Active concepts only (unless asked otherwise). The flags byte is read
|
|
295
|
+
// straight from the concept record: getConcept() builds an object per
|
|
296
|
+
// concept, and a one-word filter can match 100k+ of them.
|
|
297
|
+
const kept = [];
|
|
298
|
+
for (const ordinal of ordinals) {
|
|
299
|
+
if (!includeInactive && (conceptMaster.readUInt8(ordinal * size + 8) & 0x0F) !== 0) {
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
kept.push(ordinal);
|
|
303
|
+
}
|
|
304
|
+
if (kept.length === 0) {
|
|
305
|
+
return [];
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Coarse rank: the share of the concept's distinct words that the filter
|
|
309
|
+
// accounts for - a concept whose terms are little more than the filter
|
|
310
|
+
// beats one that mentions it in passing. Since the filter is fixed, that
|
|
311
|
+
// share is decided by the concept's word count, so the best
|
|
312
|
+
// RANK_REFINE_LIMIT are picked by counting sort on the word count rather
|
|
313
|
+
// than by sorting every match.
|
|
314
|
+
const counts = new Map();
|
|
315
|
+
for (const ordinal of kept) {
|
|
316
|
+
const c = wordCounts[ordinal] || 1;
|
|
317
|
+
counts.set(c, (counts.get(c) || 0) + 1);
|
|
318
|
+
}
|
|
319
|
+
const distinct = [...counts.keys()].sort((a, b) => a - b);
|
|
320
|
+
let cutoff = distinct[distinct.length - 1];
|
|
321
|
+
let running = 0;
|
|
322
|
+
for (const c of distinct) {
|
|
323
|
+
running += counts.get(c);
|
|
324
|
+
if (running >= RANK_REFINE_LIMIT) {
|
|
325
|
+
cutoff = c;
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const head = [];
|
|
331
|
+
const tail = [];
|
|
332
|
+
for (const ordinal of kept) {
|
|
333
|
+
if ((wordCounts[ordinal] || 1) <= cutoff) {
|
|
334
|
+
head.push(ordinal);
|
|
335
|
+
} else {
|
|
336
|
+
tail.push(ordinal);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const normalizedFilter = tokenize(filterText).join(' ');
|
|
341
|
+
const tokenStems = tokens.map(t => stemmer.stem(t));
|
|
342
|
+
const descMaster = sct.descriptions.master;
|
|
343
|
+
const scored = head.map(ordinal => {
|
|
344
|
+
const reference = ordinal * size;
|
|
345
|
+
const entry = {
|
|
346
|
+
index: reference,
|
|
347
|
+
ordinal,
|
|
348
|
+
base: 10,
|
|
349
|
+
termLength: Number.MAX_SAFE_INTEGER,
|
|
350
|
+
share: Math.min(1, tokens.length / (wordCounts[ordinal] || 1))
|
|
351
|
+
};
|
|
352
|
+
const descriptionsRef = sct.concepts.getDescriptions(reference);
|
|
353
|
+
if (!descriptionsRef) {
|
|
354
|
+
return entry;
|
|
355
|
+
}
|
|
356
|
+
for (const descIndex of sct.refs.getReferences(descriptionsRef)) {
|
|
357
|
+
if (descMaster.readUInt8(descIndex + 4) === 0) {
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
const termTokens = tokenize(sct.strings.getEntry(descMaster.readUInt32LE(descIndex)));
|
|
361
|
+
const normalizedTerm = termTokens.join(' ');
|
|
362
|
+
let base;
|
|
363
|
+
if (normalizedTerm === normalizedFilter) {
|
|
364
|
+
base = 100;
|
|
365
|
+
} else if (normalizedTerm.startsWith(normalizedFilter)) {
|
|
366
|
+
base = 50;
|
|
367
|
+
} else if (tokenStems.every((stem, t) => termTokens.some(w => w === tokens[t] || stemmer.stem(w) === stem))) {
|
|
368
|
+
base = 30;
|
|
369
|
+
} else {
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
// Best term wins; between equally good terms, the shorter one - it is
|
|
373
|
+
// the more specific match ("Diabetes mellitus" over "Diabetes mellitus
|
|
374
|
+
// screening declined").
|
|
375
|
+
if (base > entry.base || (base === entry.base && normalizedTerm.length < entry.termLength)) {
|
|
376
|
+
entry.base = base;
|
|
377
|
+
entry.termLength = normalizedTerm.length;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return entry;
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
scored.sort((a, b) =>
|
|
384
|
+
b.base - a.base ||
|
|
385
|
+
a.termLength - b.termLength ||
|
|
386
|
+
b.share - a.share ||
|
|
387
|
+
a.ordinal - b.ordinal);
|
|
388
|
+
|
|
389
|
+
const matches = new Array(scored.length + tail.length);
|
|
390
|
+
let at = 0;
|
|
391
|
+
for (const entry of scored) {
|
|
392
|
+
matches[at++] = {
|
|
393
|
+
index: entry.index,
|
|
394
|
+
term: sct.concepts.getConceptId(entry.index),
|
|
395
|
+
priority: entry.base + entry.share
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
// Everything else keeps concept order: these are the matches whose terms are
|
|
399
|
+
// long enough that the filter is a small part of them, and ranking them
|
|
400
|
+
// against each other would cost a pass over the whole match set for a
|
|
401
|
+
// difference no caller sees (count/offset paging stops long before).
|
|
402
|
+
for (const ordinal of tail) {
|
|
403
|
+
const reference = ordinal * size;
|
|
404
|
+
matches[at++] = {
|
|
405
|
+
index: reference,
|
|
406
|
+
term: sct.concepts.getConceptId(reference),
|
|
407
|
+
priority: 10
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
return matches;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
module.exports = {
|
|
414
|
+
SnomedTextIndex,
|
|
415
|
+
rankMatches,
|
|
416
|
+
filterTokens,
|
|
417
|
+
tokenize,
|
|
418
|
+
STOP_WORDS
|
|
419
|
+
};
|