fhirsmith 0.18.1 → 0.18.2

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/tx/cs/cs-cs.js CHANGED
@@ -19,6 +19,9 @@ const regexUtilities = require("../../library/regex-utilities");
19
19
  // filter results do not depend on forIteration, so one form is stored per version.
20
20
  const FILTER_ANALYSIS = Symbol('filterAnalysis');
21
21
 
22
+ // The standard concept properties (http://hl7.org/fhir/concept-properties)
23
+ const CONCEPT_PROPERTIES = 'http://hl7.org/fhir/concept-properties#';
24
+
22
25
  // The texts of each concept as a text filter compares them, by concept. Folding is
23
26
  // the costly part of a search over a large code system, so it is done once per
24
27
  // concept, and dropped with the code system.
@@ -315,6 +318,46 @@ class FhirCodeSystemProvider extends BaseCSServices {
315
318
  return this.codeSystem.jsonObj.property || null;
316
319
  }
317
320
 
321
+ /**
322
+ * The codes this code system uses for one of the standard concept properties
323
+ * (http://hl7.org/fhir/concept-properties#<name>). A property declared with the
324
+ * standard uri counts whatever local code it was given. A code that the code
325
+ * system declares with a different uri is a different property, and does not
326
+ * count. A code the code system doesn't declare (or declares with no uri) is
327
+ * recognised by the code alone - the standard code, and any legacy aliases
328
+ *
329
+ * @param {string} name - the standard property code (e.g. notSelectable)
330
+ * @param {string[]} aliases - legacy codes also recognised when not declared otherwise
331
+ * @returns {Set<string>} the local codes for the property
332
+ */
333
+ standardPropertyCodes(name, aliases = []) {
334
+ const uri = CONCEPT_PROPERTIES + name;
335
+ const defs = this.propertyDefinitions() || [];
336
+ const codes = new Set();
337
+ for (const d of defs) {
338
+ if (d.uri === uri) {
339
+ codes.add(d.code);
340
+ }
341
+ }
342
+ for (const c of [name, ...aliases]) {
343
+ if (!defs.some(d => d.code === c && d.uri && d.uri !== uri)) {
344
+ codes.add(c);
345
+ }
346
+ }
347
+ return codes;
348
+ }
349
+
350
+ /**
351
+ * Whether the code system declares the code of a standard concept property for a
352
+ * property of its own (with a different uri), so that the code is not the standard one
353
+ * @param {string} name - the standard property code
354
+ * @returns {boolean}
355
+ */
356
+ redefinesStandardProperty(name) {
357
+ const uri = CONCEPT_PROPERTIES + name;
358
+ return (this.propertyDefinitions() || []).some(d => d.code === name && d.uri && d.uri !== uri);
359
+ }
360
+
318
361
  /**
319
362
  * @param {Languages} languages - Language specification
320
363
  * @returns {boolean} Whether any displays are available for the languages
@@ -524,14 +567,10 @@ class FhirCodeSystemProvider extends BaseCSServices {
524
567
  return false;
525
568
  }
526
569
 
527
- // Check for abstract property
570
+ // Check for the notSelectable property, by whatever code this code system gives it
528
571
  if (ctxt.concept.property && Array.isArray(ctxt.concept.property)) {
529
- const abstractProp = ctxt.concept.property.find(p =>
530
- p.code === 'abstract' ||
531
- p.code === 'not-selectable' ||
532
- p.code === 'notSelectable' ||
533
- p.uri === 'http://hl7.org/fhir/concept-properties#notSelectable'
534
- );
572
+ const codes = this.standardPropertyCodes('notSelectable', ['abstract', 'not-selectable']);
573
+ const abstractProp = ctxt.concept.property.find(p => codes.has(p.code));
535
574
  if (abstractProp && abstractProp.valueBoolean) {
536
575
  return true;
537
576
  }
@@ -1399,9 +1438,10 @@ class FhirCodeSystemProvider extends BaseCSServices {
1399
1438
  }
1400
1439
  }
1401
1440
 
1402
- // Handle known special properties
1441
+ // Handle known special properties - unless the code system uses the code for a
1442
+ // property of its own, in which case the property filter above applies
1403
1443
  const knownProperties = ['notSelectable', 'status', 'inactive', 'deprecated'];
1404
- if (knownProperties.includes(prop)) {
1444
+ if (knownProperties.includes(prop) && !this.redefinesStandardProperty(prop)) {
1405
1445
  results = await this._handleKnownPropertyFilter(filterContext, prop, op, value);
1406
1446
  }
1407
1447
 
@@ -1705,13 +1745,14 @@ class FhirCodeSystemProvider extends BaseCSServices {
1705
1745
  async _handleKnownPropertyFilter(filterContext, prop, op, value) {
1706
1746
  const results = new FhirCodeSystemProviderFilterContext();
1707
1747
  const allConcepts = this.codeSystem.getAllConcepts();
1748
+ const nsCodes = this.standardPropertyCodes('notSelectable', ['abstract', 'not-selectable']);
1708
1749
 
1709
1750
  for (const concept of allConcepts) {
1710
1751
  this.opContext.deadCheck('cs:knownPropertyFilter');
1711
1752
  let matches = false;
1712
1753
 
1713
1754
  if (prop === 'notSelectable') {
1714
- const abstractProp = (concept.property || []).find(p => p.code === 'abstract' || p.code === 'notSelectable' || p.uri === 'http://hl7.org/fhir/concept-properties#notSelectable');
1755
+ const abstractProp = (concept.property || []).find(p => nsCodes.has(p.code));
1715
1756
  let vv = abstractProp ? String(getValuePrimitive(abstractProp)) : null;
1716
1757
  if (op === '=') {
1717
1758
  matches = (vv === value);
package/tx/cs/cs-loinc.js CHANGED
@@ -492,7 +492,14 @@ class LoincServices extends BaseCSServices {
492
492
  } else {
493
493
  for (const row of rows) {
494
494
  if (this._hasProp(props, row.Relationship, true)) {
495
- this._addCodeProperty(params, 'property', row.Relationship, row.Code);
495
+ if (row.Relationship === 'CLASS') {
496
+ // CLASS is a string property - the class name (e.g. PULM). The Part code for the
497
+ // class is also returned, as partCode (an informal extension to the property)
498
+ this._addStringProperty(params, 'property', row.Relationship, row.Value || row.Code)
499
+ .part.push({name: 'partCode', valueCode: row.Code});
500
+ } else {
501
+ this._addCodeProperty(params, 'property', row.Relationship, row.Code);
502
+ }
496
503
  }
497
504
  }
498
505
  resolve();
@@ -37,7 +37,7 @@ const SNOMED_FILTER_ANALYSIS = Symbol('snomedFilterAnalysis');
37
37
  * returned as. An identifier in an unknown scheme is kept but not reported.
38
38
  */
39
39
  const IDENTIFIER_SCHEMES = {
40
- '30051010000102': { system: 'http://loinc.org', property: 'loinc' } // |LOINC code identifier| (LOINC Extension)
40
+ '30051010000102': { system: 'http://loinc.org', property: 'loinc', alias: 'LOINC' } // |LOINC code identifier| (LOINC Extension)
41
41
  };
42
42
 
43
43
  /** The $expand / ECL-free filter on alternate identifiers: mapped:<scheme> exists true|false */
@@ -206,6 +206,39 @@ class SnomedServices {
206
206
  this._mrcmRangeCache = new Map();
207
207
 
208
208
  this.#loadIdentifiers(sharedData.identifiers || []);
209
+ this.#loadConcreteValues(sharedData.concreteValues);
210
+ }
211
+
212
+ /**
213
+ * Index the concrete value relationships (cache version 19: rows of
214
+ * [sourceId, typeId, group, value], value as written in RF2 - "#500", "#5.5",
215
+ * a quoted string, or true/false) by source concept index, for the ECL
216
+ * evaluator. `concreteValues` stays null when the cache predates them, which
217
+ * is how a concrete-value refinement knows to report itself as unsupported
218
+ * rather than answering with nothing.
219
+ */
220
+ #loadConcreteValues(rows) {
221
+ this.concreteValues = null;
222
+ if (!rows) return;
223
+ this.concreteValues = new Map();
224
+ for (const [sourceId, typeId, group, raw] of rows) {
225
+ const src = this.concepts.findConcept(sourceId);
226
+ const type = this.concepts.findConcept(typeId);
227
+ if (!src.found || !type.found) continue;
228
+ let value;
229
+ if (/^#-?\d+(\.\d+)?$/.test(raw)) {
230
+ value = { kind: 'number', value: Number(raw.substring(1)), decimal: raw.includes('.') };
231
+ } else if (/^".*"$/.test(raw)) {
232
+ value = { kind: 'string', value: raw.substring(1, raw.length - 1) };
233
+ } else if (/^(true|false)$/i.test(raw)) {
234
+ value = { kind: 'boolean', value: raw.toLowerCase() === 'true' };
235
+ } else {
236
+ value = { kind: 'string', value: raw };
237
+ }
238
+ const list = this.concreteValues.get(src.index) || [];
239
+ list.push({ relType: type.index, group: group || 0, concrete: value });
240
+ this.concreteValues.set(src.index, list);
241
+ }
209
242
  }
210
243
 
211
244
  /**
@@ -912,14 +945,16 @@ class SnomedServices {
912
945
  * hierarchy operators on *, ^ and bracketed sets (applied per member)
913
946
  * AND / OR / MINUS compound expressions
914
947
  * !!> / !!< (top / bottom of set)
915
- * refinements (= against an expression, attribute AND/OR/",", groups,
916
- * attribute and group cardinality, attribute names that are expressions),
948
+ * refinements (= and != against an expression, reversed (R) attributes,
949
+ * attribute AND/OR/",", groups, attribute and group cardinality,
950
+ * attribute names that are expressions),
917
951
  * dotted expressions, {{ C ... }} concept filters, {{ D ... }} description
918
- * filters, comments
952
+ * filters, {{ + HISTORY... }} history supplements, concrete (numeric /
953
+ * string / boolean) values in refinements (cache version 19+), comments
954
+ *
955
+ * alternate identifiers (LOINC#code, from the identifier scheme aliases)
919
956
  *
920
- * Everything else (reverse attributes, != and concrete (numeric/string/
921
- * boolean) values in refinements, alternate identifiers, reference set field
922
- * selection on ^, {{ + ... }} history supplements) throws an informative
957
+ * Everything else (reference set field selection on ^) throws an informative
923
958
  * error which is reported as UNSUPPORTED_ECL.
924
959
  */
925
960
 
@@ -1068,9 +1103,6 @@ class SnomedServices {
1068
1103
  // ({{ M ... }}) are handled in _evalMemberOf; concept filters ({{ C ... }})
1069
1104
  // and description filters ({{ D ... }}) are applied to the result below.
1070
1105
  if (node.filters && node.filters.length > 0) {
1071
- if (node.filters.some(fc => fc.kind === '+')) {
1072
- throw new Error('ECL history supplements ({{ + ... }}) are not supported');
1073
- }
1074
1106
  const inner = { ...node, filters: undefined };
1075
1107
  let ctx = this._evalSubExpression(inner, opContext);
1076
1108
  const conceptFilters = node.filters.filter(fc => fc.kind === 'C');
@@ -1080,12 +1112,26 @@ class SnomedServices {
1080
1112
  for (const fc of node.filters.filter(fc => fc.kind === 'D')) {
1081
1113
  ctx = this._applyDescriptionFilter(ctx, fc.filters, opContext);
1082
1114
  }
1115
+ for (const fc of node.filters.filter(fc => fc.kind === '+')) {
1116
+ ctx = this._applyHistorySupplement(ctx, fc, opContext);
1117
+ }
1083
1118
  return ctx;
1084
1119
  }
1085
1120
 
1086
- // Alternate identifiers (ECL 2.1 "scheme#code")
1121
+ // Alternate identifiers (ECL 2.1 "scheme#code"): the concept(s) that carry
1122
+ // the code in that identifier scheme, then the operator applies to them as
1123
+ // to any set. The scheme is an alias from the ECL specification (LOINC) or
1124
+ // the identifier scheme concept's own id.
1087
1125
  if (focus.type === ECLNodeType.ALT_IDENTIFIER) {
1088
- throw new Error(`ECL alternate identifiers (${focus.scheme}#${focus.code}) are not supported - no alternate identifier reference sets are loaded`);
1126
+ const set = this._eclAltIdentifier(focus);
1127
+ if (!operator) {
1128
+ const result = new SnomedFilterContext();
1129
+ result.descendants = set;
1130
+ return result;
1131
+ }
1132
+ const base = new SnomedFilterContext();
1133
+ base.descendants = set;
1134
+ return this._eclApplyOperatorToSet(base, operator, opContext);
1089
1135
  }
1090
1136
 
1091
1137
  // Top / bottom of set: the members of the focus set with no ancestor
@@ -1160,6 +1206,32 @@ class SnomedServices {
1160
1206
  return result;
1161
1207
  };
1162
1208
 
1209
+ /**
1210
+ * Resolve an ECL alternate identifier (scheme#code) to concept indexes. The
1211
+ * scheme is a known alias (IDENTIFIER_SCHEMES[].alias, case-insensitive) or
1212
+ * an identifier scheme concept id. An unknown scheme, or a code nobody has,
1213
+ * is an invalid expression - the same standing as an unknown concept.
1214
+ */
1215
+ _eclAltIdentifier = function (node) {
1216
+ let schemeId = null;
1217
+ if (/^\d{6,18}$/.test(node.scheme)) {
1218
+ schemeId = node.scheme;
1219
+ } else {
1220
+ for (const [id, def] of Object.entries(IDENTIFIER_SCHEMES)) {
1221
+ if (def.alias && def.alias.toLowerCase() === node.scheme.toLowerCase()) schemeId = id;
1222
+ }
1223
+ }
1224
+ const invalid = (msg) => { const e = new Error(msg); e.eclInvalid = true; return e; };
1225
+ if (!schemeId) {
1226
+ throw invalid(`The alternate identifier scheme "${node.scheme}" is not known`);
1227
+ }
1228
+ const concepts = this.getConceptsForIdentifier(schemeId, node.code);
1229
+ if (concepts.length === 0) {
1230
+ throw invalid(`No concept has the alternate identifier ${node.scheme}#${node.code}`);
1231
+ }
1232
+ return concepts.map(id => this.concepts.findConcept(id)).filter(r => r.found).map(r => r.index);
1233
+ };
1234
+
1163
1235
  /**
1164
1236
  * !!> / !!< : keep the concepts in `set` that have no ancestor (top) or no
1165
1237
  * descendant (bottom) that is also in `set`. Walks the stated parents of each
@@ -1317,6 +1389,9 @@ class SnomedServices {
1317
1389
  }
1318
1390
  const refsetConcepts = this._eclResolveSet(this._evalECLNode(memberOfNode.refSet, opContext), opContext);
1319
1391
  const memberFilters = this._eclPrepareMemberFilters(memberOfNode.memberFilters, opContext);
1392
+ const rs = memberOfNode.refSet;
1393
+ const explicit = rs.type === ECLNodeType.CONCEPT_REFERENCE ||
1394
+ (rs.type === ECLNodeType.SUB_EXPRESSION_CONSTRAINT && !rs.operator && rs.focus.type === ECLNodeType.CONCEPT_REFERENCE);
1320
1395
 
1321
1396
  const members = new Set();
1322
1397
  for (const refsetIdx of refsetConcepts) {
@@ -1334,9 +1409,12 @@ class SnomedServices {
1334
1409
  }
1335
1410
  const memberList = this.refSetMembers.getMembers(membersRef) || [];
1336
1411
  // A reference set whose members are descriptions (a language reference
1337
- // set, say) has no concept members to return. Say so rather than
1338
- // answering with an empty set, which would read as "no members".
1339
- if (memberList.length > 0 && !memberList.some(m => m.kind === 0)) {
1412
+ // set, say) has no concept members to return. When it was named
1413
+ // explicitly, say so rather than answering with an empty set, which would
1414
+ // read as "no members". When it merely falls inside a computed operand
1415
+ // (^ *, ^ (< 900000000000455006)) it contributes nothing and the rest of
1416
+ // the operand still counts.
1417
+ if (explicit && memberList.length > 0 && !memberList.some(m => m.kind === 0)) {
1340
1418
  throw new Error(`The members of reference set ${this.concepts.getConceptId(refsetIdx)} are descriptions, not concepts, so ^ cannot be evaluated for it`);
1341
1419
  }
1342
1420
  for (const m of memberList) {
@@ -1356,6 +1434,82 @@ class SnomedServices {
1356
1434
  return result;
1357
1435
  };
1358
1436
 
1437
+ /**
1438
+ * Apply a history supplement ({{ + HISTORY... }}): add to the result the
1439
+ * inactive concepts that the selected historical association reference sets
1440
+ * associate with a concept in the result (the association rows whose
1441
+ * targetComponentId is in the result).
1442
+ *
1443
+ * Profiles (ECL 2.x): MIN = SAME AS; MOD = SAME AS, WAS A, REPLACED BY,
1444
+ * PARTIALLY EQUIVALENT TO; MAX (and a bare HISTORY) = every reference set
1445
+ * under 900000000000522004 |Historical association|; HISTORY (expr) = the
1446
+ * reference sets the expression yields. A reference set the edition does not
1447
+ * carry simply contributes nothing.
1448
+ */
1449
+ _applyHistorySupplement = function (ctx, hs, opContext) {
1450
+ const HISTORICAL_ASSOCIATION = '900000000000522004';
1451
+ const PROFILES = {
1452
+ MIN: ['900000000000527005'],
1453
+ MOD: ['900000000000527005', '900000000000528000', '900000000000526001', '1186924009']
1454
+ };
1455
+ let refsetIdxs;
1456
+ if (hs.profile === 'EXPLICIT') {
1457
+ refsetIdxs = this._eclResolveSet(this._evalECLNode(hs.refsets, opContext), opContext);
1458
+ } else if (hs.profile === 'MAX') {
1459
+ const root = this.concepts.findConcept(HISTORICAL_ASSOCIATION);
1460
+ refsetIdxs = root.found ? this._eclToIndexArray(this.filterIsA(HISTORICAL_ASSOCIATION, false)) : [];
1461
+ } else {
1462
+ refsetIdxs = PROFILES[hs.profile].map(id => this.concepts.findConcept(id)).filter(r => r.found).map(r => r.index);
1463
+ }
1464
+
1465
+ const base = this._eclResolveSet(ctx, opContext);
1466
+ const inResult = new Set(base);
1467
+ const added = new Set();
1468
+ for (const refsetIdx of refsetIdxs) {
1469
+ if (opContext) opContext.deadCheck('ecl:history');
1470
+ const byTarget = this._historyIndex(refsetIdx);
1471
+ if (!byTarget) continue;
1472
+ for (const [target, sources] of byTarget) {
1473
+ if (inResult.has(target)) {
1474
+ for (const src of sources) {
1475
+ if (!inResult.has(src)) added.add(src);
1476
+ }
1477
+ }
1478
+ }
1479
+ }
1480
+ const result = new SnomedFilterContext();
1481
+ result.descendants = base.concat([...added]);
1482
+ return result;
1483
+ };
1484
+
1485
+ /**
1486
+ * target concept index -> [referenced (inactive) concept indexes] for one
1487
+ * association reference set, built once per edition. null when the concept
1488
+ * is not a reference set with concept-valued members.
1489
+ */
1490
+ _historyIndex = function (refsetIdx) {
1491
+ this._historyIndexes = this._historyIndexes || new Map();
1492
+ if (this._historyIndexes.has(refsetIdx)) {
1493
+ return this._historyIndexes.get(refsetIdx);
1494
+ }
1495
+ let index = null;
1496
+ const refSet = this._findRefSetRecord(refsetIdx);
1497
+ if (refSet && refSet.membersByRef && refSet.membersByRef !== 0xFFFFFFFF) {
1498
+ for (const m of this.refSetMembers.getMembers(refSet.membersByRef) || []) {
1499
+ if (m.kind !== 0 || !m.values) continue;
1500
+ const values = this.refs.getReferences(m.values);
1501
+ // targetComponentId is the first additional field, stored as (value, type) with type 1 = concept
1502
+ if (!values || values.length < 2 || values[1] !== 1) continue;
1503
+ index = index || new Map();
1504
+ const list = index.get(values[0]) || [];
1505
+ list.push(m.ref);
1506
+ index.set(values[0], list);
1507
+ }
1508
+ }
1509
+ this._historyIndexes.set(refsetIdx, index);
1510
+ return index;
1511
+ };
1512
+
1359
1513
  /**
1360
1514
  * Apply {{ C ... }} concept filters to a result: each concept is tested on
1361
1515
  * its own active flag, module, effectiveTime and definition status. Several
@@ -1855,17 +2009,11 @@ class SnomedServices {
1855
2009
  * @returns {boolean}
1856
2010
  */
1857
2011
  _attributeMatches = function (rels, attr, groupFilter, opContext) {
1858
- if (attr.reverse) {
1859
- throw new Error('ECL reverse attributes (R) are not yet supported');
1860
- }
1861
2012
  if (!attr.comparison) {
1862
2013
  throw new Error('ECL attribute without a comparison is not supported');
1863
2014
  }
1864
- if (attr.comparison.type !== ECLNodeType.EXPRESSION_COMPARISON) {
1865
- throw new Error('ECL concrete values (numeric, string and boolean attribute values) are not supported - concrete value relationships are not loaded');
1866
- }
1867
- if (attr.comparison.operator !== ECLTokenType.EQUALS) {
1868
- throw new Error('ECL != in refinements is not yet supported');
2015
+ if (attr.comparison.type !== ECLNodeType.EXPRESSION_COMPARISON && !this.concreteValues) {
2016
+ throw new Error('ECL concrete values (numeric, string and boolean attribute values) cannot be evaluated - this edition was imported without its concrete value relationships');
1869
2017
  }
1870
2018
 
1871
2019
  const count = this._countAttributeMatches(rels, attr, groupFilter, opContext);
@@ -1895,15 +2043,30 @@ class SnomedServices {
1895
2043
  // fresh per request, so memoising on the node is safe within a request.
1896
2044
  let resolved = attr._eclResolved;
1897
2045
  if (!resolved) {
1898
- resolved = {
1899
- types: this._eclAttributeTypes(attr.name, opContext),
1900
- valueSet: new Set(this._eclResolveSet(this._evalECLNode(attr.comparison.value, opContext), opContext))
1901
- };
2046
+ resolved = { types: this._eclAttributeTypes(attr.name, opContext) };
2047
+ if (attr.comparison.type === ECLNodeType.EXPRESSION_COMPARISON) {
2048
+ resolved.valueSet = new Set(this._eclResolveSet(this._evalECLNode(attr.comparison.value, opContext), opContext));
2049
+ } else {
2050
+ resolved.concrete = this._eclConcreteMatcher(attr.comparison);
2051
+ }
1902
2052
  attr._eclResolved = resolved;
1903
2053
  }
1904
2054
  const types = resolved.types;
1905
2055
  const valueSet = resolved.valueSet;
1906
2056
 
2057
+ if (resolved.concrete) {
2058
+ // A concrete-value comparison: only the concrete rows count, each
2059
+ // occurrence being a (group, value) pair
2060
+ const matched = new Set();
2061
+ for (const rel of rels) {
2062
+ if (!rel.concrete) continue;
2063
+ if (!types.any && !types.types.has(rel.relType)) continue;
2064
+ if (groupFilter !== null && rel.group !== groupFilter) continue;
2065
+ if (resolved.concrete(rel.concrete)) matched.add(rel.group + ':' + JSON.stringify(rel.concrete.value));
2066
+ }
2067
+ return matched.size;
2068
+ }
2069
+
1907
2070
  // ECL cardinality counts OCCURRENCES of a matching attribute, per role group
1908
2071
  // — not distinct values. ECL runs against the necessary normal form, where a
1909
2072
  // role group has no redundant attributes, so the same (type, value) in two
@@ -1912,13 +2075,26 @@ class SnomedServices {
1912
2075
  // form, not to de-duplicating across groups). We therefore count distinct
1913
2076
  // (group, target) pairs: within a group filter this is the matching values
1914
2077
  // in that group; ungrouped it is the total occurrences across all groups.
2078
+ //
2079
+ // "!=" counts the occurrences whose value is NOT in the set (so "A != X"
2080
+ // matches a concept with at least one A whose value is something other than
2081
+ // X, and "[0..0] A != X" one whose every A is X).
2082
+ //
2083
+ // A reversed attribute ("R A = X") is matched against the concept's INBOUND
2084
+ // relationships: the concept is the value of an A attribute on some concept
2085
+ // in X. _conceptRelRecords supplies those tagged `inbound`; the records built
2086
+ // for a post-coordinated expression have none, so R matches nothing there.
2087
+ const notEquals = attr.comparison.operator === ECLTokenType.NOT_EQUALS;
2088
+ const reverse = !!attr.reverse;
1915
2089
  const matched = new Set();
1916
2090
  for (const rel of rels) {
1917
2091
  if (opContext) opContext.deadCheck('ecl:countAttributeMatches');
1918
- if (!rel.active) continue;
2092
+ if (!rel.active || rel.concrete) continue;
2093
+ if (!!rel.inbound !== reverse) continue;
1919
2094
  if (!types.any && !types.types.has(rel.relType)) continue;
1920
2095
  if (groupFilter !== null && rel.group !== groupFilter) continue;
1921
- if (valueSet.has(rel.target)) matched.add(rel.group + ':' + rel.target);
2096
+ const value = reverse ? rel.source : rel.target;
2097
+ if (valueSet.has(value) !== notEquals) matched.add(rel.group + ':' + value);
1922
2098
  }
1923
2099
  return matched.size;
1924
2100
  };
@@ -1983,12 +2159,58 @@ class SnomedServices {
1983
2159
  // target, group} — the shape the refinement matchers consume. Sharing this
1984
2160
  // shape lets the same matchers run against a post-coordinated expression's
1985
2161
  // relationships (see _expressionRelRecords).
2162
+ /**
2163
+ * A predicate over a stored concrete value for one comparison node:
2164
+ * numeric: = != < <= > >= against a number (integers and decimals compare as
2165
+ * numbers, so #5 = #5.0)
2166
+ * string: = / != against typed search terms (match / wild semantics, as for
2167
+ * description terms), any of a set
2168
+ * boolean: = / != true/false
2169
+ * A value of another kind never matches (and never "not-matches" either: a
2170
+ * string attribute is not "!= #5").
2171
+ */
2172
+ _eclConcreteMatcher = function (comparison) {
2173
+ switch (comparison.type) {
2174
+ case ECLNodeType.NUMERIC_COMPARISON: {
2175
+ const n = Number(comparison.value);
2176
+ const op = { [ECLTokenType.EQUALS]: '=', [ECLTokenType.NOT_EQUALS]: '!=', [ECLTokenType.LT]: '<', [ECLTokenType.LTE]: '<=', [ECLTokenType.GT]: '>', [ECLTokenType.GTE]: '>=' }[comparison.operator] || comparison.operator;
2177
+ return (v) => v.kind === 'number' && this._eclCompareValues(v.value, op, [n], (a, b) => a - b);
2178
+ }
2179
+ case ECLNodeType.STRING_COMPARISON: {
2180
+ const matchers = comparison.values.map(t => this._eclTermMatcher(typeof t === 'string' ? { kind: 'match', value: t } : t));
2181
+ const neg = comparison.operator === ECLTokenType.NOT_EQUALS;
2182
+ return (v) => v.kind === 'string' && (matchers.some(m => m(v.value)) !== neg);
2183
+ }
2184
+ case ECLNodeType.BOOLEAN_COMPARISON: {
2185
+ const neg = comparison.operator === ECLTokenType.NOT_EQUALS;
2186
+ return (v) => v.kind === 'boolean' && ((v.value === comparison.value) !== neg);
2187
+ }
2188
+ default:
2189
+ throw new Error(`Unsupported comparison ${comparison.type}`);
2190
+ }
2191
+ };
2192
+
1986
2193
  _conceptRelRecords = function (conceptIdx) {
1987
- const relIdxs = this.getConceptRelationships(conceptIdx);
1988
2194
  const out = [];
1989
- for (const relIdx of relIdxs) {
2195
+ for (const relIdx of this.getConceptRelationships(conceptIdx)) {
1990
2196
  out.push(this.relationships.getRelationship(relIdx));
1991
2197
  }
2198
+ // concrete value rows, for numeric / string / boolean comparisons
2199
+ if (this.concreteValues) {
2200
+ for (const cv of this.concreteValues.get(conceptIdx) || []) {
2201
+ out.push({ active: true, relType: cv.relType, group: cv.group, target: -1, concrete: cv.concrete });
2202
+ }
2203
+ }
2204
+ // inbound relationships, for reversed (R) attributes
2205
+ const concept = this.concepts.getConcept(conceptIdx);
2206
+ if (concept.inbounds) {
2207
+ for (const relIdx of this.refs.getReferences(concept.inbounds) || []) {
2208
+ const rel = this.relationships.getRelationship(relIdx);
2209
+ if (rel.active && rel.relType !== this.isAIndex) {
2210
+ out.push({ ...rel, inbound: true });
2211
+ }
2212
+ }
2213
+ }
1992
2214
  return out;
1993
2215
  };
1994
2216
 
@@ -1139,6 +1139,9 @@ class SnomedImporter {
1139
1139
  // Step 10a: Read alternate identifiers (e.g. the LOINC codes of the LOINC Extension)
1140
1140
  await this.readIdentifiers();
1141
1141
 
1142
+ // Step 10b: Read concrete value relationships (sct2_RelationshipConcreteValues)
1143
+ await this.readConcreteValues();
1144
+
1142
1145
  // Step 11: Link concepts
1143
1146
  this.linkConcepts();
1144
1147
 
@@ -1169,6 +1172,7 @@ class SnomedImporter {
1169
1172
  descriptions: [],
1170
1173
  relationships: [],
1171
1174
  identifiers: [],
1175
+ concreteValues: [],
1172
1176
  refsetDirectories: []
1173
1177
  };
1174
1178
 
@@ -1224,6 +1228,8 @@ class SnomedImporter {
1224
1228
  files.relationships.push(filePath);
1225
1229
  } else if (files.identifiers && firstLine.startsWith('alternateIdentifier\teffectiveTime\tactive\tmoduleId\tidentifierSchemeId\treferencedComponentId')) {
1226
1230
  files.identifiers.push(filePath);
1231
+ } else if (files.concreteValues && firstLine.startsWith('id\teffectiveTime\tactive\tmoduleId\tsourceId\tvalue\trelationshipGroup\ttypeId\tcharacteristicTypeId\tmodifierId')) {
1232
+ files.concreteValues.push(filePath);
1227
1233
  }
1228
1234
  } catch (error) {
1229
1235
  console.log(`DEBUG: Error reading file ${filePath}: ${error.message}`);
@@ -1782,6 +1788,58 @@ class SnomedImporter {
1782
1788
  }
1783
1789
  }
1784
1790
 
1791
+ /**
1792
+ * Read the concrete value relationship files (sct2_RelationshipConcreteValues_*):
1793
+ * the numeric and string attribute values of concepts (strength, count of base of
1794
+ * active ingredient, ...). Kept as rows [sourceId, typeId, group, value] - the value
1795
+ * as written in RF2 ("#500", "#5.5", "\"text\"", "true") - for the ECL evaluator;
1796
+ * they are not relationships to concepts, so they do not join the relationship
1797
+ * table. Only active, inferred rows; where a row appears more than once the latest
1798
+ * effectiveTime wins.
1799
+ */
1800
+ async readConcreteValues() {
1801
+ const rows = new Map(); // id -> {effectiveTime, active, row}
1802
+ for (const file of this.files.concreteValues || []) {
1803
+ const rl = readline.createInterface({
1804
+ input: fs.createReadStream(file),
1805
+ crlfDelay: Infinity
1806
+ });
1807
+ let lineCount = 0;
1808
+ for await (const line of rl) {
1809
+ lineCount++;
1810
+ if (lineCount === 1 || !line) continue;
1811
+ const parts = line.split('\t');
1812
+ if (parts.length < 10) continue;
1813
+ const [id, effectiveTime, active, , sourceId, value, group, typeId, characteristicTypeId] = parts.map(p => p.trim());
1814
+ if (characteristicTypeId !== '900000000000011006') continue; // inferred only
1815
+ const prior = rows.get(id);
1816
+ if (!prior || prior.effectiveTime <= effectiveTime) {
1817
+ rows.set(id, { effectiveTime, active: active === '1', row: [sourceId, typeId, parseInt(group, 10) || 0, value] });
1818
+ }
1819
+ }
1820
+ }
1821
+ this.concreteValues = [];
1822
+ let missing = 0;
1823
+ for (const r of rows.values()) {
1824
+ if (!r.active) continue;
1825
+ let known = false;
1826
+ try {
1827
+ known = this.conceptMap.has(BigInt(r.row[0])) && this.conceptMap.has(BigInt(r.row[1]));
1828
+ } catch {
1829
+ known = false;
1830
+ }
1831
+ if (known) {
1832
+ this.concreteValues.push(r.row);
1833
+ } else {
1834
+ missing++;
1835
+ }
1836
+ }
1837
+ this.concreteValues.sort((a, b) => (a.join('|') < b.join('|') ? -1 : 1));
1838
+ if (this.concreteValues.length > 0 || missing > 0) {
1839
+ console.log(`Concrete values: ${this.concreteValues.length}${missing ? ` (${missing} for concepts not in this edition, ignored)` : ''}`);
1840
+ }
1841
+ }
1842
+
1785
1843
  async readRelationships() {
1786
1844
  // Estimate total lines
1787
1845
  let totalLines = 0;
@@ -2823,11 +2881,14 @@ class SnomedImporter {
2823
2881
  progressBar?.update(50);
2824
2882
 
2825
2883
  const identifiers = this.identifiers || [];
2884
+ const concreteValues = this.concreteValues || [];
2826
2885
  await writer.writeCache({
2827
- // 18 = 17 plus the alternate identifiers. Only used when there are some, so that
2828
- // caches without them stay readable by servers that only know 17
2829
- version: identifiers.length > 0 ? '18' : '17',
2886
+ // 17 = the base format; 18 = 17 plus the alternate identifiers; 19 = 18 plus
2887
+ // the concrete value relationships. Each is only used when it has something
2888
+ // to carry, so that caches without them stay readable by older servers.
2889
+ version: concreteValues.length > 0 ? '19' : identifiers.length > 0 ? '18' : '17',
2830
2890
  identifiers: identifiers,
2891
+ concreteValues: concreteValues,
2831
2892
  versionUri: this.isTesting ? this.config.uri.replace("/sct/", "/xsct/") : this.config.uri,
2832
2893
  versionDate: this.extractDateFromUri(this.config.uri),
2833
2894
 
@@ -2914,10 +2975,14 @@ class SnomedCacheWriter {
2914
2975
  }
2915
2976
  buffers.push(this.writeInteger(data.defaultLanguage));
2916
2977
 
2917
- if (data.version === '18') {
2978
+ if (data.version === '18' || data.version === '19') {
2918
2979
  // alternate identifiers, as JSON: [[scheme, module, code, conceptId], ...]
2919
2980
  buffers.push(this.writeBytes(Buffer.from(JSON.stringify(data.identifiers || []), 'utf8')));
2920
2981
  }
2982
+ if (data.version === '19') {
2983
+ // concrete value relationships, as JSON: [[sourceId, typeId, group, value], ...]
2984
+ buffers.push(this.writeBytes(Buffer.from(JSON.stringify(data.concreteValues || []), 'utf8')));
2985
+ }
2921
2986
 
2922
2987
  // Write to file
2923
2988
  const finalBuffer = Buffer.concat(buffers);