fhirsmith 0.13.3 → 0.13.4

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 CHANGED
@@ -5,6 +5,21 @@ All notable changes to the Health Intersections Node Server will be documented i
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [v0.13.4] - 2026-09-17
9
+
10
+ ### Changed
11
+
12
+ - Clean up TxCaps expansion parameter list
13
+
14
+ ### Fixed
15
+
16
+ - Fix performance issue with snomed text search
17
+ - clean up release process to avoid version mismatch, and to clean out old dockers
18
+
19
+ ### Tx Conformance Statement
20
+
21
+ FHIRsmith passed all 3507 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed+mimetypes+icd-11, tests v1.9.4, runner v6.10.4)
22
+
8
23
  ## [v0.13.3] - 2026-09-10
9
24
 
10
25
  ### Fixed
package/README.md CHANGED
@@ -259,6 +259,26 @@ Each GitHub Release includes:
259
259
  - `ghcr.io/healthintersections/fhirsmith:X.Y.Z`
260
260
  - **npm package** published to npmjs.org as `fhirsmith` *(if you add this)*
261
261
 
262
+ Release images carry OCI labels, so an image says which release it is without
263
+ having to trust the tag it was pulled under:
264
+
265
+ ```bash
266
+ docker inspect --format '{{json .Config.Labels}}' ghcr.io/healthintersections/fhirsmith:latest
267
+ ```
268
+
269
+ `org.opencontainers.image.version` is the release, `.revision` the commit it was
270
+ built from, and `.source` is what links the package to this repository on GHCR.
271
+
272
+ ### CI Images
273
+
274
+ Every push to main also publishes `:cibuild` and `:cibuild-<sha>` to the same
275
+ package. These are **not releases** - they are whatever was last merged. The
276
+ Docker Build workflow prunes all but the ten most recent afterwards, so they do
277
+ not bury the current release on the package page. To prune by hand (or to run
278
+ the one-off backfill of images published before that job existed), run the
279
+ **GHCR Cleanup** workflow from the Actions tab with `dry_run` on first, read the
280
+ log, then run it again with `dry_run` off.
281
+
262
282
  ### Creating a Release
263
283
 
264
284
  GitHub Actions will automatically:
@@ -283,7 +303,18 @@ GitHub Actions will automatically:
283
303
  ### Tx Conformance Statement
284
304
  {copy content from text-cases-summary.txt}
285
305
  ```
286
- 2. Update `package.json` & `package-lock.json` to have the same release version
306
+ 2. Update `package.json` & `package-lock.json` to the release version:
307
+
308
+ ```bash
309
+ npm version --no-git-tag-version X.Y.Z
310
+ npm install --package-lock-only
311
+ ```
312
+
313
+ This matters more than it looks. The server reports its version from
314
+ `package.json` (`server.js`, `tx/tx.js`) - not from the `APP_VERSION` build
315
+ arg - so a package.json left on the previous number ships a Docker image that
316
+ calls itself by the wrong release for good. The **Verify Version** job fails
317
+ the release if `package.json` or `package-lock.json` disagrees with the tag.
287
318
 
288
319
  3. Commit your changes:
289
320
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fhirsmith",
3
- "version": "0.13.3",
3
+ "version": "0.13.4",
4
4
  "txVersion": "1.9.5-SNAPSHOT",
5
5
  "description": "A Node.js server that provides a collection of tools to serve the FHIR ecosystem",
6
6
  "main": "server.js",
@@ -16,6 +16,8 @@ const {ConceptMap} = require("../library/conceptmap");
16
16
  const {ECLLexer, ECLParser, ECLNodeType, ECLTokenType} = require("../sct/ecl");
17
17
  const {Issue} = require("../library/operation-outcome");
18
18
  const {debugLog} = require("../operation-context");
19
+ const {SnomedTextIndex, rankMatches, filterTokens} = require("../sct/text-index");
20
+ const Logger = require("../../library/logger");
19
21
 
20
22
  // Symbol key under which a resolved ECL filter analysis is memoised directly on
21
23
  // the ValueSet compose.include.filter element (`fc`). Symbol-keyed and defined
@@ -127,6 +129,9 @@ class SnomedServices {
127
129
  this.strings = new SnomedStrings(sharedData.strings);
128
130
  this.words = new SnomedWords(sharedData.words);
129
131
  this.stems = new SnomedStems(sharedData.stems);
132
+ // Free-text search index, built on first search (see getTextIndex)
133
+ this._textIndex = null;
134
+ this._textIndexPromise = null;
130
135
  this.refs = new SnomedReferences(sharedData.refs);
131
136
  this.descriptions = new SnomedDescriptions(sharedData.desc);
132
137
  this.descriptionIndex = new SnomedDescriptionIndex(sharedData.descRef);
@@ -1482,16 +1487,104 @@ class SnomedServices {
1482
1487
  };
1483
1488
 
1484
1489
 
1485
- searchFilter(searchText, includeInactive = false, exactMatch = false) {
1486
- const result = new SnomedFilterContext();
1490
+ /**
1491
+ * Free-text search over active descriptions.
1492
+ *
1493
+ * Uses the in-memory word index (see tx/sct/text-index.js), built the first
1494
+ * time this edition is searched: every filter token (stop words aside) must
1495
+ * match a word of the concept's active descriptions, either as a prefix or
1496
+ * by sharing its stem. Filters with no indexable token (only 1-character
1497
+ * tokens), or an edition whose index failed to build, fall back to
1498
+ * scanSearch().
1499
+ *
1500
+ * Previously the third parameter was `exactMatch`, which the provider fed
1501
+ * from its unrelated `sort` flag - so $expand with a filter on an
1502
+ * include-all-codes value set matched ANY term by brute-force scan: 10k+
1503
+ * matches for a typical multi-word filter, with the event loop blocked for
1504
+ * the whole scan (tx.fhir.org incident 2026-09-17).
1505
+ *
1506
+ * @param {SearchFilterText} searchText
1507
+ * @param {boolean} includeInactive - include inactive concepts
1508
+ * @param {OperationContext|null} opContext - for yielding / deadline checks
1509
+ * @returns {Promise<SnomedFilterContext>}
1510
+ */
1511
+ async searchFilter(searchText, includeInactive = false, opContext = null) {
1512
+ const filterText = (searchText && searchText.filter ? searchText.filter : '').toLowerCase().trim();
1513
+ const tokens = filterTokens(filterText);
1514
+ if (tokens.length > 0) {
1515
+ const index = await this.getTextIndex(opContext);
1516
+ if (index) {
1517
+ const result = new SnomedFilterContext();
1518
+ const ordinals = index.search(tokens, opContext);
1519
+ if (opContext) {
1520
+ await opContext.checkAndYield('sct:searchFilter');
1521
+ }
1522
+ result.matches = rankMatches(this, index, ordinals, filterText, tokens, includeInactive);
1523
+ return result;
1524
+ }
1525
+ }
1526
+ return await this.scanSearch(filterText, includeInactive, opContext);
1527
+ }
1487
1528
 
1488
- // Simplified search - in full implementation would use stemming and word indexes
1489
- const searchTerms = searchText.filter.toLowerCase().split(/\s+/);
1529
+ /**
1530
+ * The edition's text index, building it on first use. The build is shared:
1531
+ * concurrent searches wait on the same promise, and time spent waiting is
1532
+ * not charged to the waiting operation's compute budget. Resolves to null
1533
+ * if the build failed (the next search retries it).
1534
+ * @param {OperationContext|null} opContext
1535
+ * @returns {Promise<SnomedTextIndex|null>}
1536
+ */
1537
+ async getTextIndex(opContext = null) {
1538
+ if (this._textIndex) {
1539
+ return this._textIndex;
1540
+ }
1541
+ if (!this._textIndexPromise) {
1542
+ const started = Date.now();
1543
+ // Logger fetched lazily: importers and tests require this module too
1544
+ const textIndexLog = Logger.getInstance().child({module: 'tx-sct'});
1545
+ this._textIndexPromise = SnomedTextIndex.build(this).then(index => {
1546
+ this._textIndex = index;
1547
+ textIndexLog.info(`SNOMED text index for ${this.versionUri}: ${index.words.length} words, built in ${Date.now() - started}ms`);
1548
+ return index;
1549
+ }, error => {
1550
+ this._textIndexPromise = null;
1551
+ textIndexLog.error(`SNOMED text index build failed for ${this.versionUri}: ${error.message}`);
1552
+ return null;
1553
+ });
1554
+ }
1555
+ if (opContext && typeof opContext.waitFor === 'function') {
1556
+ return await opContext.waitFor(this._textIndexPromise, 'sct:textIndex');
1557
+ }
1558
+ return await this._textIndexPromise;
1559
+ }
1560
+
1561
+ /**
1562
+ * Brute-force scan of every concept's active descriptions: every
1563
+ * whitespace-separated term of the filter must appear (as a substring) in
1564
+ * the same description. Expensive (hundreds of ms on a full edition), so it
1565
+ * yields through opContext.checkAndYield and the compute deadline applies.
1566
+ *
1567
+ * @param {string} filterText - lower-cased, trimmed
1568
+ * @param {boolean} includeInactive
1569
+ * @param {OperationContext|null} opContext
1570
+ * @returns {Promise<SnomedFilterContext>}
1571
+ */
1572
+ async scanSearch(filterText, includeInactive = false, opContext = null) {
1573
+ const result = new SnomedFilterContext();
1574
+ const searchTerms = filterText.split(/\s+/).filter(t => t.length > 0);
1490
1575
  const matches = [];
1576
+ if (searchTerms.length === 0) {
1577
+ result.matches = matches;
1578
+ return result;
1579
+ }
1491
1580
 
1492
- // Search through all concepts
1493
- for (let i = 0; i < this.concepts.count(); i++) {
1494
- const conceptIndex = i * this.concepts.constructor.CONCEPT_SIZE;
1581
+ const conceptCount = this.concepts.count();
1582
+ const conceptSize = this.concepts.constructor.CONCEPT_SIZE;
1583
+ for (let i = 0; i < conceptCount; i++) {
1584
+ if (opContext && (i & 0x3FF) === 0) {
1585
+ await opContext.checkAndYield('sct:searchFilter');
1586
+ }
1587
+ const conceptIndex = i * conceptSize;
1495
1588
 
1496
1589
  try {
1497
1590
  const concept = this.concepts.getConcept(conceptIndex);
@@ -1510,25 +1603,23 @@ class SnomedServices {
1510
1603
  const description = this.descriptions.getDescription(descIndex);
1511
1604
  if (description.active) {
1512
1605
  const term = this.strings.getEntry(description.iDesc).toLowerCase();
1513
-
1514
- if (exactMatch) {
1515
- // All search terms must be present
1516
- matchFound = searchTerms.every(searchTerm => term.includes(searchTerm));
1517
- } else {
1518
- // Any search term can match
1519
- matchFound = searchTerms.some(searchTerm => term.includes(searchTerm));
1520
- }
1521
-
1522
- if (matchFound) {
1606
+ if (searchTerms.every(searchTerm => term.includes(searchTerm))) {
1607
+ matchFound = true;
1523
1608
  // Calculate priority based on match quality
1524
- if (term === searchText.filter.toLowerCase()) {
1525
- priority = 100; // Exact match
1526
- } else if (term.startsWith(searchText.filter.toLowerCase())) {
1527
- priority = 50; // Prefix match
1609
+ let p;
1610
+ if (term === filterText) {
1611
+ p = 100; // Exact match
1612
+ } else if (term.startsWith(filterText)) {
1613
+ p = 50; // Prefix match
1528
1614
  } else {
1529
- priority = 10; // Contains match
1615
+ p = 10; // Contains match
1616
+ }
1617
+ if (p > priority) {
1618
+ priority = p;
1619
+ }
1620
+ if (priority === 100) {
1621
+ break;
1530
1622
  }
1531
- break;
1532
1623
  }
1533
1624
  }
1534
1625
  }
@@ -1541,6 +1632,9 @@ class SnomedServices {
1541
1632
  });
1542
1633
  }
1543
1634
  } catch (error) {
1635
+ if (error instanceof Issue || (error && error.abandoned)) {
1636
+ throw error;
1637
+ }
1544
1638
  // Skip problematic concepts
1545
1639
  continue;
1546
1640
  }
@@ -2448,8 +2542,10 @@ class SnomedProvider extends BaseCSServices {
2448
2542
  }
2449
2543
 
2450
2544
  // Search filter
2545
+ // eslint-disable-next-line no-unused-vars
2451
2546
  async searchFilter(filterContext, filter, sort) {
2452
- let f = this.sct.searchFilter(filter, false, sort);
2547
+ // `sort` is not a match-mode flag: results are always ranked.
2548
+ let f = await this.sct.searchFilter(filter, false, this.opContext);
2453
2549
  filterContext.filters.push(f);
2454
2550
  return f;
2455
2551
  }
@@ -24,8 +24,8 @@ const Extensions = {
24
24
  if (resource.jsonObj) {
25
25
  resource = resource.jsonObj
26
26
  }
27
- if (resource.implicitRules) {
28
- throw new Issue("error", "business-rule", null, null, 'Cannot process resource "'+name+'" due to the presence of implicit rules @'+place);
27
+ if (resource.implicitRules && 'https://hl7.org/fhir/implicit-rules/omitted-mandatory-interversion-code' != resource.implicitRules) {
28
+ throw new Issue("error", "business-rule", null, null, 'Cannot process resource "'+name+'" due to the presence of implicit rules \''+resource.implicitRules+'\' @'+place);
29
29
  }
30
30
  },
31
31
 
@@ -972,6 +972,39 @@ class OperationContext {
972
972
  }
973
973
  }
974
974
 
975
+ /**
976
+ * Await something this operation does not itself compute - typically a
977
+ * shared structure another request is building - without charging the wait
978
+ * to the compute deadline. The current compute slice is closed before
979
+ * waiting and a new one opened after; aborts if the client disconnected
980
+ * meanwhile.
981
+ *
982
+ * @param {Promise} promise
983
+ * @param {string} place - Location identifier for debugging
984
+ * @returns {Promise<*>} what the promise resolves to
985
+ */
986
+ async waitFor(promise, place = 'unknown') {
987
+ const now = performance.now();
988
+ this._clock.compute += now - this._clock.sliceStart;
989
+ let result;
990
+ try {
991
+ result = await promise;
992
+ } finally {
993
+ const resumed = performance.now();
994
+ this._clock.sliceStart = resumed;
995
+ this._clock.lastYield = resumed;
996
+ }
997
+ if (this._clock.clientGone) {
998
+ this.log(`Operation abandoned @ ${place}: client disconnected`);
999
+ const error = new Issue("error", "too-costly", null,
1000
+ `Operation abandoned at ${place}: the client disconnected before the response was ready`);
1001
+ error.abandoned = true;
1002
+ error.diagnostics = this.diagnostics();
1003
+ throw error;
1004
+ }
1005
+ return result;
1006
+ }
1007
+
975
1008
  unSeeAll() {
976
1009
  this.contexts = [];
977
1010
  }
@@ -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
+ };
@@ -861,6 +861,14 @@ class ValueSetExpander {
861
861
  this.addToTotal();
862
862
  }
863
863
  }
864
+ if (this.pageFilled()) {
865
+ // No total is reported for this path (noTotal above), so once
866
+ // the requested page is full there is nothing left to learn
867
+ // from the rest of the matches - and a text filter on a large
868
+ // code system can match tens of thousands of them, each
869
+ // costing a designations/properties lookup.
870
+ break;
871
+ }
864
872
  }
865
873
  this.worker.opContext.log('iterate filters done');
866
874
  }
@@ -1792,6 +1800,18 @@ class ValueSetExpander {
1792
1800
  pdv[valueName] = value;
1793
1801
  }
1794
1802
 
1803
+ /**
1804
+ * True when the requested page (offset + count) has been filled, so an
1805
+ * iteration that is not counting a total can stop. Exclusions are handled
1806
+ * after the fact and can remove codes already collected, so a value set
1807
+ * with any exclusion never stops early.
1808
+ * @returns {boolean}
1809
+ */
1810
+ pageFilled() {
1811
+ return this.count > 0 && this.offset > -1 && !this.hasExclusions
1812
+ && this.fullList.length >= this.count + this.offset;
1813
+ }
1814
+
1795
1815
  addToTotal(t = 1) {
1796
1816
  if (this.total > -1 && this.totalStatus != "off") {
1797
1817
  this.total = this.total + t;
@@ -429,22 +429,34 @@ class MetadataHandler {
429
429
  name: 'tx-resource',
430
430
  documentation: 'Additional valuesets needed for evaluation e.g. value sets referred to from the import statement of the value set being expanded'
431
431
  },
432
+ // Parameters the $expand handler reads - see handleTypeLevelExpand in
433
+ // tx/workers/expand.js and TxParameters.seeParameter in tx/params.js.
434
+ // Keep this list in step with those when adding a parameter.
432
435
  { name: '_incomplete' },
433
436
  { name: 'abstract' },
434
437
  { name: 'activeOnly' },
435
438
  { name: 'check-system-version' },
439
+ { name: 'check-valueset-version' },
436
440
  { name: 'count' },
437
441
  { name: 'default-to-latest-version' },
442
+ { name: 'default-valueset-version' },
443
+ { name: 'designation' },
438
444
  { name: 'displayLanguage' },
439
445
  { name: 'excludeNested' },
440
446
  { name: 'excludeNotForUI' },
441
447
  { name: 'excludePostCoordinated' },
448
+ { name: 'filter' },
442
449
  { name: 'force-system-version' },
450
+ { name: 'force-valueset-version' },
443
451
  { name: 'inactive' },
444
452
  { name: 'includeAlternateCodes' },
445
453
  { name: 'includeDefinition' },
446
454
  { name: 'includeDesignations' },
447
455
  { name: 'incomplete-ok' },
456
+ {
457
+ name: 'limit',
458
+ documentation: 'Upper bound on the size of the expansion; the operation fails as too-costly beyond it'
459
+ },
448
460
  { name: 'limitedExpansion' },
449
461
  {
450
462
  name: 'mode',
@@ -454,11 +466,19 @@ class MetadataHandler {
454
466
  { name: 'offset' },
455
467
  { name: 'profile' },
456
468
  { name: 'property' },
469
+ {
470
+ name: 'sort',
471
+ documentation: 'code | display | design | prop:{name}; prefix with - to reverse. Ignored for hierarchical expansions'
472
+ },
457
473
  { name: 'system-version' },
474
+ { name: 'url' },
475
+ { name: 'useSupplement' },
476
+ { name: 'valueSet' },
458
477
  {
459
478
  name: 'valueSetMode',
460
479
  documentation: '= CHECK_MEMBERSHIP_ONLY | NO_MEMBERSHIP_CHECK'
461
- }
480
+ },
481
+ { name: 'valueSetVersion' }
462
482
  ]
463
483
  };
464
484
  }