fhirsmith 0.11.2 → 0.13.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.
Files changed (68) hide show
  1. package/CHANGELOG.md +377 -2
  2. package/README.md +1 -1
  3. package/config-template.json +31 -1
  4. package/library/languages.js +191 -34
  5. package/library/path-safety.js +51 -0
  6. package/library/request-token.js +56 -0
  7. package/package.json +2 -2
  8. package/packages/packages.js +17 -118
  9. package/publisher/publisher.js +208 -30
  10. package/publisher/validation.js +210 -0
  11. package/server.js +24 -15
  12. package/stats-db.js +127 -0
  13. package/stats.js +172 -6
  14. package/translations/Messages.properties +5 -1
  15. package/tx/README.md +21 -0
  16. package/tx/cs/cs-api.js +30 -1
  17. package/tx/cs/cs-api.md +1 -1
  18. package/tx/cs/cs-cs.js +1 -1
  19. package/tx/cs/cs-icd11.js +805 -0
  20. package/tx/cs/cs-lang.js +331 -85
  21. package/tx/cs/cs-loinc.js +45 -4
  22. package/tx/cs/cs-mimetypes.js +492 -21
  23. package/tx/cs/cs-snomed.js +141 -58
  24. package/tx/cs/cs-ucum.js +33 -3
  25. package/tx/cs/icd11-expressions.js +243 -0
  26. package/tx/importers/export-icd11-codesystem.js +823 -0
  27. package/tx/importers/icd11-schema.md +285 -0
  28. package/tx/importers/import-icd11.js +861 -0
  29. package/tx/importers/import-loinc.module.js +14 -7
  30. package/tx/importers/import-sct.module.js +10 -1
  31. package/tx/library/codesystem.js +21 -27
  32. package/tx/library/conceptmap.js +39 -0
  33. package/tx/library/nesting-limit.js +79 -0
  34. package/tx/library/operation-outcome.js +97 -3
  35. package/tx/library/parameter-check.js +139 -0
  36. package/tx/library/valueset.js +16 -20
  37. package/tx/library.js +23 -1
  38. package/tx/ocl/cache/cache-utils.cjs +5 -3
  39. package/tx/ocl/cs-ocl.cjs +73 -17
  40. package/tx/ocl/jobs/background-queue.cjs +6 -3
  41. package/tx/ocl/mappers/concept-mapper.cjs +9 -1
  42. package/tx/ocl/shared/patches.cjs +48 -1
  43. package/tx/ocl/vs-ocl.cjs +15 -13
  44. package/tx/operation-context.js +220 -10
  45. package/tx/params.js +226 -175
  46. package/tx/sct/expressions.js +677 -15
  47. package/tx/tx.fhir.org.yml +3 -2
  48. package/tx/tx.js +72 -92
  49. package/tx/workers/batch-validate.js +11 -18
  50. package/tx/workers/batch.js +16 -28
  51. package/tx/workers/cache-control.js +87 -17
  52. package/tx/workers/closure.js +3 -8
  53. package/tx/workers/compare.js +6 -34
  54. package/tx/workers/expand.js +108 -60
  55. package/tx/workers/lookup.js +35 -40
  56. package/tx/workers/read.js +6 -40
  57. package/tx/workers/search.js +2 -8
  58. package/tx/workers/subsumes.js +58 -41
  59. package/tx/workers/translate.js +472 -47
  60. package/tx/workers/validate.js +83 -78
  61. package/tx/workers/worker.js +92 -11
  62. package/tx/xml/xml-base.js +264 -77
  63. package/tx/xversion/xv-conceptmap.js +81 -0
  64. package/tx/xversion/xv-parameters.js +8 -0
  65. package/tx/xversion/xv-valueset.js +39 -1
  66. package/utilities/merge-audit.py +178 -0
  67. package/utilities/why-merge-audit.md +39 -0
  68. package/tx/data/snomed-subset.txt +0 -84
@@ -6,9 +6,27 @@
6
6
  * Licensed under BSD-3-Clause
7
7
  */
8
8
 
9
+ const { SnomedConceptList } = require('./structures');
10
+
9
11
  const MAX_TERM_LENGTH = 1024;
12
+ // How deep concept-definition expansion may go while building a long normal form.
13
+ // Attribute values are themselves expanded, so a chain of definitions can descend a
14
+ // long way; in real data it is a handful of levels.
15
+ const MAX_NORMALISATION_DEPTH = 50;
16
+
10
17
  const NO_REFERENCE = 0xFFFFFFFF;
11
18
 
19
+ // MRCM (machine readable concept model) reference sets and metadata concepts.
20
+ // The range constraints in 723562003 are ECL, so they are only checked when an
21
+ // evaluator has been injected (SnomedServices does this - see eclConstraintMatcher).
22
+ const MRCM_ATTRIBUTE_DOMAIN_REFSET = 723561005n;
23
+ const MRCM_ATTRIBUTE_RANGE_REFSET = 723562003n;
24
+ // contentTypeId values that apply when validating a postcoordinated expression.
25
+ // 723593002 (precoordinated only) and 723595009 (new precoordinated only) are
26
+ // rules about authoring precoordinated concepts and do not constrain expressions.
27
+ const MRCM_CONTENT_TYPE_ALL = 723596005n;
28
+ const MRCM_CONTENT_TYPE_POSTCOORDINATED = 723594008n;
29
+
12
30
  // Expression status enumeration
13
31
  const SnomedExpressionStatus = {
14
32
  Unknown: 0,
@@ -582,6 +600,15 @@ class SnomedExpressionParser {
582
600
 
583
601
  this.ws();
584
602
  result.code = this.conceptId();
603
+ // Resolve the attribute name the same way concept() resolves a focus concept. Without this
604
+ // the name keeps NO_REFERENCE, and SnomedConcept.matches() then compares by reference on one
605
+ // side and by code on the other, which makes refinement matching (and so subsumption between
606
+ // two refined expressions) asymmetric.
607
+ if (this.conceptList) {
608
+ const found = this.conceptList.findConcept(result.code);
609
+ this.rule(found.found, 'Concept "' + result.code + '" not valid');
610
+ result.reference = found.index;
611
+ }
585
612
  this.ws();
586
613
 
587
614
  if (this.gchar('|')) {
@@ -1024,7 +1051,12 @@ class SnomedExpressionServices {
1024
1051
  }
1025
1052
 
1026
1053
  const result = new SnomedExpressionParser().parse(source);
1027
- this.checkExpression(result);
1054
+ // Resolve concept ids to references, but do NOT run checkExpression: the MRCM rules it
1055
+ // applies are the postcoordination ones, and this is a concept's own stored definition -
1056
+ // precoordinated content, authored under MRCM content types that do not apply here. Running
1057
+ // them rejects the normal form of any concept using an attribute that is precoordinated-only
1058
+ // (e.g. 246075003 |Causative agent| on a disorder), which broke subsumption for expressions.
1059
+ this.resolveExpressionReferences(result);
1028
1060
  return result;
1029
1061
  }
1030
1062
  }
@@ -1191,13 +1223,46 @@ class SnomedExpressionServices {
1191
1223
  /**
1192
1224
  * Normalize expression to normal form
1193
1225
  */
1194
- normaliseExpression(exp) {
1226
+ /**
1227
+ * Expand an expression to its long normal form, ready for structural comparison.
1228
+ *
1229
+ * A fully defined concept is replaced by its definition, because that definition is
1230
+ * both necessary and sufficient. A primitive concept is NOT replaced - its definition
1231
+ * is only necessary, and substituting it would make the expression look equivalent to
1232
+ * things it merely subsumes. But the primitive's defining attributes are still
1233
+ * entailed by it, so they are added alongside the concept rather than discarded:
1234
+ * `4846001 |Anicteric viral hepatitis|` is primitive and carries an inferred
1235
+ * `363698007 |Finding site| = 10200004 |Liver structure|`, so every instance of it
1236
+ * really is a disease with that finding site, and `64572001:{363698007=10200004}`
1237
+ * really does subsume it. Dropping those attributes - which is what this used to do -
1238
+ * lost that answer and reported not-subsumed, which is simply wrong.
1239
+ *
1240
+ * Keeping the concept itself in the focus is what stops the addition being unsound in
1241
+ * the other direction: the primitive stays as an atom that nothing else can match, so
1242
+ * an expression carrying the same attributes is never reported as equivalent to it.
1243
+ *
1244
+ * @param {SnomedExpression} exp
1245
+ * @param {number} [depth] - recursion guard, see MAX_NORMALISATION_DEPTH
1246
+ * @returns {SnomedExpression}
1247
+ */
1248
+ normaliseExpression(exp, depth = 0) {
1249
+ if (depth > MAX_NORMALISATION_DEPTH) {
1250
+ // Expanding attribute values can descend a long way, and truncating silently is
1251
+ // not safe in both directions: dropping conditions from the subsuming side makes
1252
+ // it demand less and so subsume more, which would be a false positive. Fail
1253
+ // instead, and let the caller report that it could not answer.
1254
+ throw new Error(`Could not normalise the expression: it nests more than ${MAX_NORMALISATION_DEPTH}`
1255
+ + ' levels deep once concept definitions are expanded');
1256
+ }
1195
1257
  const work = new SnomedExpression();
1196
1258
 
1197
1259
  // Process concepts
1198
1260
  for (const concept of exp.concepts) {
1199
- if (concept.reference === NO_REFERENCE || this.isPrimitive(concept.reference)) {
1261
+ if (concept.reference === NO_REFERENCE) {
1200
1262
  work.concepts.push(concept);
1263
+ } else if (this.isPrimitive(concept.reference)) {
1264
+ work.concepts.push(concept);
1265
+ this.addDefiningAttributes(concept.reference, work, depth);
1201
1266
  } else {
1202
1267
  const ex = this.createNormalForm(concept.reference);
1203
1268
  work.merge(ex);
@@ -1209,7 +1274,7 @@ class SnomedExpressionServices {
1209
1274
  const refDst = new SnomedRefinement();
1210
1275
  work.refinements.push(refDst);
1211
1276
  refDst.name = refSrc.name;
1212
- refDst.value = this.normaliseExpression(refSrc.value);
1277
+ refDst.value = this.normaliseExpression(refSrc.value, depth + 1);
1213
1278
  }
1214
1279
 
1215
1280
  // Process refinement groups
@@ -1221,7 +1286,7 @@ class SnomedExpressionServices {
1221
1286
  const refDst = new SnomedRefinement();
1222
1287
  grpDst.refinements.push(refDst);
1223
1288
  refDst.name = refSrc.name;
1224
- refDst.value = this.normaliseExpression(refSrc.value);
1289
+ refDst.value = this.normaliseExpression(refSrc.value, depth + 1);
1225
1290
  }
1226
1291
  }
1227
1292
 
@@ -1230,6 +1295,56 @@ class SnomedExpressionServices {
1230
1295
  return work2.canonical();
1231
1296
  }
1232
1297
 
1298
+ /**
1299
+ * Add a concept's own defining (non is-a) relationships to an expression, as grouped
1300
+ * and ungrouped refinements with their values normalised.
1301
+ *
1302
+ * Used for primitives, whose attributes are entailed but whose concept must stay in
1303
+ * the focus - see normaliseExpression. The stored normal form cannot be used for this:
1304
+ * it is built by createDefinedExpression, which stops at a primitive too, so the
1305
+ * cached normal form of a primitive is just the concept itself. Reading the
1306
+ * relationships here means existing caches keep working rather than needing a rebuild.
1307
+ *
1308
+ * @param {number} reference - the concept whose attributes to add
1309
+ * @param {SnomedExpression} work - expression to add them to
1310
+ * @param {number} depth - current normalisation depth
1311
+ */
1312
+ addDefiningAttributes(reference, work, depth) {
1313
+ const groups = new Map();
1314
+
1315
+ for (const relIndex of this.getDefiningRelationships(reference)) {
1316
+ const rel = this.relationships.getRelationship(relIndex);
1317
+
1318
+ const ref = new SnomedRefinement();
1319
+ ref.name = new SnomedConcept(rel.relType);
1320
+ ref.name.code = this.getConceptId(rel.relType);
1321
+
1322
+ const value = new SnomedExpression();
1323
+ const target = new SnomedConcept(rel.target);
1324
+ target.code = this.getConceptId(rel.target);
1325
+ value.concepts.push(target);
1326
+ ref.value = this.normaliseExpression(value, depth + 1);
1327
+
1328
+ if (rel.group === 0) {
1329
+ if (!work.hasRefinement(ref)) {
1330
+ work.refinements.push(ref);
1331
+ }
1332
+ } else {
1333
+ const key = rel.group.toString();
1334
+ if (!groups.has(key)) {
1335
+ groups.set(key, new SnomedRefinementGroup());
1336
+ }
1337
+ groups.get(key).refinements.push(ref);
1338
+ }
1339
+ }
1340
+
1341
+ for (const grp of groups.values()) {
1342
+ if (!work.hasRefinementGroup(grp)) {
1343
+ work.refinementGroups.push(grp);
1344
+ }
1345
+ }
1346
+ }
1347
+
1233
1348
  /**
1234
1349
  * Check if expression a subsumes expression b
1235
1350
  */
@@ -1255,10 +1370,20 @@ class SnomedExpressionServices {
1255
1370
  }
1256
1371
  }
1257
1372
 
1258
- // Check refinement groups
1373
+ // Check refinement groups. Every group in e1 has to be satisfied by SOME group in e2 - try
1374
+ // them all rather than only the first one whose attribute names line up. Two normalisations
1375
+ // of the same expression can put equivalent groups in a different order, and when several
1376
+ // groups share the same attribute names (e.g. two morphology/site groups on one disorder)
1377
+ // picking the first match and giving up made subsumption depend on that order.
1259
1378
  for (const r of e1.refinementGroups) {
1260
- const rt = this.findMatchingGroup(r, e2);
1261
- if (!rt || !this.subsumesGroup(r, rt)) {
1379
+ let ok = false;
1380
+ for (const rt of e2.refinementGroups) {
1381
+ if (this.subsumesGroup(r, rt)) {
1382
+ ok = true;
1383
+ break;
1384
+ }
1385
+ }
1386
+ if (!ok) {
1262
1387
  return false;
1263
1388
  }
1264
1389
  }
@@ -1443,6 +1568,28 @@ class SnomedExpressionServices {
1443
1568
  return null;
1444
1569
  }
1445
1570
 
1571
+ /**
1572
+ * Resolve every concept id in an expression to its internal reference, without applying any
1573
+ * of the concept model rules. Used for expressions the server generated itself (normal forms),
1574
+ * where the ids are known good and the MRCM postcoordination rules do not apply
1575
+ * @param {SnomedExpression} expression
1576
+ */
1577
+ resolveExpressionReferences(expression) {
1578
+ for (const concept of expression.concepts) {
1579
+ this.checkConcept(concept);
1580
+ }
1581
+ for (const refinement of expression.refinements) {
1582
+ this.checkConcept(refinement.name);
1583
+ this.resolveExpressionReferences(refinement.value);
1584
+ }
1585
+ for (const group of expression.refinementGroups) {
1586
+ for (const refinement of group.refinements) {
1587
+ this.checkConcept(refinement.name);
1588
+ this.resolveExpressionReferences(refinement.value);
1589
+ }
1590
+ }
1591
+ }
1592
+
1446
1593
  /**
1447
1594
  * Validate expression structure and concept references
1448
1595
  */
@@ -1454,6 +1601,9 @@ class SnomedExpressionServices {
1454
1601
  if (expression.hasRefinements()) {
1455
1602
  for (const refinement of expression.refinements) {
1456
1603
  this.checkRefinement(refinement);
1604
+ this.checkRefinementDomain(expression, refinement);
1605
+ this.checkRefinementRange(refinement);
1606
+ this.checkRefinementGrouped(refinement);
1457
1607
  }
1458
1608
  }
1459
1609
 
@@ -1461,11 +1611,526 @@ class SnomedExpressionServices {
1461
1611
  for (const group of expression.refinementGroups) {
1462
1612
  for (const refinement of group.refinements) {
1463
1613
  this.checkRefinement(refinement);
1614
+ this.checkRefinementDomain(expression, refinement);
1615
+ this.checkRefinementRange(refinement);
1616
+ }
1617
+ }
1618
+ }
1619
+
1620
+ this.checkExpressionCardinality(expression);
1621
+ }
1622
+
1623
+ /**
1624
+ * Check one refinement against the MRCM attribute domain reference set: the
1625
+ * focus concept the attribute is applied to must be in the attribute's domain.
1626
+ *
1627
+ * Every applicable rule must hold, mandatory or optional. Internationally
1628
+ * there is exactly one optional rule - Laterality in the lateralizable body
1629
+ * structure reference set - and it is the rule that stops laterality being
1630
+ * applied to a structure that cannot be lateralized, so it is enforced.
1631
+ *
1632
+ * Attributes with no rule in this edition's MRCM are not checked, and neither
1633
+ * is cardinality (checkExpressionCardinality), grouping (checkRefinementGrouped)
1634
+ * or the range constraint (checkRefinementRange, which needs ECL).
1635
+ *
1636
+ * @param {SnomedExpression} expression - the expression the refinement is on
1637
+ * @param {SnomedRefinement} refinement
1638
+ */
1639
+ checkRefinementDomain(expression, refinement) {
1640
+ const attribute = refinement.name ? refinement.name.reference : NO_REFERENCE;
1641
+ if (attribute === undefined || attribute === null || attribute === NO_REFERENCE) {
1642
+ return;
1643
+ }
1644
+ const rules = this.mrcmAttributeDomains().get(attribute);
1645
+ if (!rules || rules.length === 0) {
1646
+ // This edition's concept model says nothing about this attribute (3 active
1647
+ // attributes internationally have no domain rule and appear in no
1648
+ // postcoordination domain template), so there is nothing to check it against.
1649
+ return;
1650
+ }
1651
+
1652
+ // The focus may be a conjunction of concepts; the expression is a subtype of
1653
+ // each of them, so the attribute is in domain if any focus concept is.
1654
+ const focus = [];
1655
+ for (const concept of expression.concepts) {
1656
+ if (concept.reference !== undefined && concept.reference !== null && concept.reference !== NO_REFERENCE) {
1657
+ focus.push(concept.reference);
1658
+ }
1659
+ }
1660
+ if (focus.length === 0) {
1661
+ return;
1662
+ }
1663
+
1664
+ // Report every rule that failed, not just the first: a laterality on a
1665
+ // disorder breaks both the anatomical structure domain and the lateralizable
1666
+ // refset rule, and naming only one of them misleads.
1667
+ const failed = [];
1668
+ for (const rule of rules) {
1669
+ const ok = focus.some(f => rule.isRefset
1670
+ ? this.conceptInRefSet(rule.domain, f)
1671
+ : this.subsumes(rule.domain, f));
1672
+ if (!ok) {
1673
+ failed.push((rule.isRefset ? 'for concepts in ' : 'in the domain ') + this.describeConceptForMessage(rule.domain));
1674
+ }
1675
+ }
1676
+ if (failed.length > 0) {
1677
+ const attrDesc = this.describeConceptForMessage(attribute);
1678
+ const focusDesc = focus.map(f => this.describeConceptForMessage(f)).join(' + ');
1679
+ throw new Error(`The SNOMED CT concept model does not allow the attribute ${attrDesc} on ${focusDesc}`
1680
+ + ` (the attribute is only valid ${failed.join(', and ')})`);
1681
+ }
1682
+ }
1683
+
1684
+ /**
1685
+ * Check that an attribute the MRCM marks as grouped is actually in a relationship
1686
+ * group. This is only ever called for refinements that are NOT in a group.
1687
+ *
1688
+ * The MRCM attribute domain reference set carries a `grouped` flag per rule, and it
1689
+ * is a real constraint rather than a stylistic preference: `363698007 |Finding site|`
1690
+ * is grouped, so `40468003:363698007=10200004` is not a valid expression even though
1691
+ * every part of it is - it has to be written `40468003:{363698007=10200004}`. The
1692
+ * distinction matters because a group is what ties an attribute to the others that
1693
+ * apply with it, and an expression that omits the braces has not said which.
1694
+ *
1695
+ * SNOMED's postcoordination guide calls the ungrouped form Close To User Form and
1696
+ * defines a transformation ("Adding a Self-grouped Attribute") that converts it to
1697
+ * the Classifiable Form. That transformation is not implemented here, so the
1698
+ * ungrouped form is rejected rather than silently repaired: telling the caller their
1699
+ * expression is not classifiable is honest, quietly guessing what they meant is not.
1700
+ *
1701
+ * As elsewhere in this check, an attribute with no rule in this edition's MRCM is
1702
+ * left alone, and where several rules apply the strictest wins.
1703
+ *
1704
+ * @param {SnomedRefinement} refinement - a refinement outside any group
1705
+ */
1706
+ checkRefinementGrouped(refinement) {
1707
+ const attribute = refinement.name ? refinement.name.reference : NO_REFERENCE;
1708
+ if (attribute === undefined || attribute === null || attribute === NO_REFERENCE) {
1709
+ return;
1710
+ }
1711
+ const rules = this.mrcmAttributeDomains().get(attribute);
1712
+ if (!rules || rules.length === 0) {
1713
+ return;
1714
+ }
1715
+ if (!rules.some(rule => rule.grouped)) {
1716
+ return;
1717
+ }
1718
+ const attrDesc = this.describeConceptForMessage(attribute);
1719
+ throw new Error(`The SNOMED CT concept model requires the attribute ${attrDesc} to be in a`
1720
+ + ` relationship group, so it cannot be used ungrouped (write it as {${attrDesc} = ...})`);
1721
+ }
1722
+
1723
+ /**
1724
+ * The MRCM attribute domain reference set, indexed by attribute concept.
1725
+ * Built once per edition and cached; an edition with no MRCM yields an empty
1726
+ * map, which turns the domain check off rather than failing.
1727
+ *
1728
+ * @returns {Map<number, Array<{domain: number, isRefset: boolean, grouped: boolean, max: number, maxInGroup: number}>>}
1729
+ */
1730
+ mrcmAttributeDomains() {
1731
+ if (this._mrcmAttributeDomains) {
1732
+ return this._mrcmAttributeDomains;
1733
+ }
1734
+ const rules = new Map();
1735
+ this._mrcmAttributeDomains = rules;
1736
+
1737
+ const refSet = this.findRefSetByConceptId(MRCM_ATTRIBUTE_DOMAIN_REFSET);
1738
+ if (!refSet) {
1739
+ return rules; // no MRCM in this edition
1740
+ }
1741
+ const contentAll = this.conceptIndexOf(MRCM_CONTENT_TYPE_ALL);
1742
+ const contentPost = this.conceptIndexOf(MRCM_CONTENT_TYPE_POSTCOORDINATED);
1743
+
1744
+ // Every concept that defines a reference set, so a domain that is itself a
1745
+ // reference set (the lateralizable body structure refset) is tested by
1746
+ // membership rather than subsumption.
1747
+ const refSetConcepts = new Set();
1748
+ for (let i = 0; i < this.refSetIndex.count(); i++) {
1749
+ refSetConcepts.add(this.refSetIndex.getReferenceSet(i).definition);
1750
+ }
1751
+
1752
+ const members = this.refSetMembers.getMembers(refSet.membersByRef) || [];
1753
+ for (const member of members) {
1754
+ if (member.kind !== 0) {
1755
+ continue; // concept members only
1756
+ }
1757
+ // Additional columns are stored as (value, type) pairs, in file order:
1758
+ // domainId, grouped, attributeCardinality, attributeInGroupCardinality,
1759
+ // ruleStrengthId, contentTypeId.
1760
+ const values = this.refs.getReferences(member.values);
1761
+ if (!values || values.length < 12) {
1762
+ continue;
1763
+ }
1764
+ const domain = values[0];
1765
+ const contentType = values[10];
1766
+ if (contentType !== contentAll && contentType !== contentPost) {
1767
+ continue; // a rule about authoring precoordinated concepts
1768
+ }
1769
+ // The importer stores 0 for a referenced component it could not resolve,
1770
+ // which would otherwise read as a domain of whichever concept sorts first.
1771
+ if (domain === 0) {
1772
+ continue;
1773
+ }
1774
+ let list = rules.get(member.ref);
1775
+ if (!list) {
1776
+ list = [];
1777
+ rules.set(member.ref, list);
1778
+ }
1779
+ list.push({
1780
+ domain: domain,
1781
+ isRefset: refSetConcepts.has(domain),
1782
+ // The grouped column is a plain 0/1 (type 4), not a reference into the string
1783
+ // table like the cardinalities beside it - reading it as one yields garbage.
1784
+ grouped: values[2] === 1,
1785
+ // Only the maximums are enforced. A minimum ("1..1") says a concept in
1786
+ // this domain must have the attribute in its own definition; it does not
1787
+ // say a refinement of that concept has to restate it.
1788
+ max: this.parseCardinalityMax(values[4]), // attributeCardinality
1789
+ maxInGroup: this.parseCardinalityMax(values[6]) // attributeInGroupCardinality
1790
+ });
1791
+ }
1792
+ return rules;
1793
+ }
1794
+
1795
+ /**
1796
+ * Check one refinement's value against the MRCM attribute range: the range is
1797
+ * an ECL constraint, so this only runs when an evaluator has been injected
1798
+ * (`eclConstraintMatcher`, set by SnomedServices, which owns the ECL engine).
1799
+ * A matcher that returns null - an unparseable or unsupported constraint, such
1800
+ * as the concrete-value ranges "dec(>#0..)" - leaves the value unchecked
1801
+ * rather than rejecting it.
1802
+ *
1803
+ * @param {SnomedRefinement} refinement
1804
+ */
1805
+ checkRefinementRange(refinement) {
1806
+ const attribute = refinement.name ? refinement.name.reference : NO_REFERENCE;
1807
+ if (attribute === undefined || attribute === null || attribute === NO_REFERENCE) {
1808
+ return;
1809
+ }
1810
+ const constraint = this.mrcmAttributeRanges().get(attribute);
1811
+ if (!constraint || !refinement.value) {
1812
+ return;
1813
+ }
1814
+
1815
+ // A concrete value range (dec/int/str) is checked here; everything else is
1816
+ // ECL and needs the evaluator the provider injects.
1817
+ const concreteRange = this.parseConcreteRange(constraint);
1818
+ const value = this.concreteValueOf(refinement.value);
1819
+ let ok;
1820
+ if (concreteRange) {
1821
+ ok = this.concreteValueSatisfies(concreteRange, value);
1822
+ } else if (value !== null) {
1823
+ ok = false; // a concrete value where the model wants a concept
1824
+ } else if (!this.eclConstraintMatcher) {
1825
+ return;
1826
+ } else {
1827
+ try {
1828
+ ok = this.eclConstraintMatcher(constraint, refinement.value);
1829
+ } catch (error) {
1830
+ ok = null; // the evaluator could not decide; do not invent a violation
1831
+ }
1832
+ }
1833
+ // null = could not decide: a constraint naming concepts this edition does not carry.
1834
+ if (ok === false) {
1835
+ const concrete = this.concreteValueOf(refinement.value);
1836
+ const value = concrete !== null ? concrete.text : (refinement.value.concepts || [])
1837
+ .filter(c => c.reference !== undefined && c.reference !== null && c.reference !== NO_REFERENCE)
1838
+ .map(c => this.describeConceptForMessage(c.reference)).join(' + ');
1839
+ throw new Error(`The SNOMED CT concept model does not allow ${value || 'this value'}`
1840
+ + ` as the value of the attribute ${this.describeConceptForMessage(attribute)}`
1841
+ + ` (the value must satisfy "${constraint}")`);
1842
+ }
1843
+ }
1844
+
1845
+ /**
1846
+ * Parse an MRCM concrete value range such as "dec(>#0..)", "int(#1..#10)" or
1847
+ * "str(...)". Returns null for anything that is not one - those are ECL.
1848
+ *
1849
+ * @param {string} constraint
1850
+ * @returns {{type: string, lowOp: string, low: number, highOp: string, high: number}|null}
1851
+ */
1852
+ parseConcreteRange(constraint) {
1853
+ const match = /^\s*(dec|int|str)\s*\((.*)\)\s*$/.exec(constraint || '');
1854
+ if (!match) {
1855
+ return null;
1856
+ }
1857
+ const range = { type: match[1], lowOp: null, low: null, highOp: null, high: null };
1858
+ if (range.type === 'str') {
1859
+ return range; // no ordering on strings; only the type is checked
1860
+ }
1861
+ const parts = /^\s*([<>]=?)?\s*#?(-?\d+(?:\.\d+)?)?\s*\.\.\s*([<>]=?)?\s*#?(-?\d+(?:\.\d+)?)?\s*$/.exec(match[2]);
1862
+ if (!parts) {
1863
+ return range; // bounds we do not understand: the type check still applies
1864
+ }
1865
+ if (parts[2] !== undefined) { range.lowOp = parts[1] || '>='; range.low = parseFloat(parts[2]); }
1866
+ if (parts[4] !== undefined) { range.highOp = parts[3] || '<='; range.high = parseFloat(parts[4]); }
1867
+ return range;
1868
+ }
1869
+
1870
+ /**
1871
+ * The concrete value a refinement value carries, if it is one: SCG writes these
1872
+ * as #500 or "text" rather than as a concept.
1873
+ *
1874
+ * @param {SnomedExpression} expression - a refinement's value
1875
+ * @returns {{number: number|null, string: string|null, text: string}|null}
1876
+ */
1877
+ concreteValueOf(expression) {
1878
+ if (!expression || (expression.refinements || []).length > 0 || (expression.refinementGroups || []).length > 0) {
1879
+ return null;
1880
+ }
1881
+ const concepts = expression.concepts || [];
1882
+ if (concepts.length !== 1) {
1883
+ return null;
1884
+ }
1885
+ const concept = concepts[0];
1886
+ if (concept.decimal !== undefined && concept.decimal !== null && concept.decimal !== '') {
1887
+ return { number: parseFloat(concept.decimal), string: null, text: '#' + concept.decimal };
1888
+ }
1889
+ if (concept.literal !== undefined && concept.literal !== null && concept.literal !== '') {
1890
+ return { number: null, string: concept.literal, text: JSON.stringify(concept.literal) };
1891
+ }
1892
+ return null;
1893
+ }
1894
+
1895
+ /**
1896
+ * @param {Object} range - from parseConcreteRange
1897
+ * @param {Object|null} value - from concreteValueOf
1898
+ * @returns {boolean}
1899
+ */
1900
+ concreteValueSatisfies(range, value) {
1901
+ if (value === null) {
1902
+ return false; // a concept where the model wants a concrete value
1903
+ }
1904
+ if (range.type === 'str') {
1905
+ return value.string !== null;
1906
+ }
1907
+ if (value.number === null || !isFinite(value.number)) {
1908
+ return false;
1909
+ }
1910
+ if (range.type === 'int' && !Number.isInteger(value.number)) {
1911
+ return false;
1912
+ }
1913
+ if (range.low !== null) {
1914
+ if (range.lowOp === '>' && !(value.number > range.low)) return false;
1915
+ if (range.lowOp === '>=' && !(value.number >= range.low)) return false;
1916
+ }
1917
+ if (range.high !== null) {
1918
+ if (range.highOp === '<' && !(value.number < range.high)) return false;
1919
+ if (range.highOp === '<=' && !(value.number <= range.high)) return false;
1920
+ }
1921
+ return true;
1922
+ }
1923
+
1924
+ /**
1925
+ * The MRCM attribute range reference set, indexed by attribute concept, as the
1926
+ * rangeConstraint ECL string.
1927
+ *
1928
+ * Where an attribute has a range specifically for postcoordinated content
1929
+ * (contentTypeId 723594008) that one replaces the general "all content" range
1930
+ * rather than adding to it - 11 attributes internationally do this. After that
1931
+ * rule every attribute has exactly one applicable constraint, so there is no
1932
+ * question of ANDing or ORing them.
1933
+ *
1934
+ * @returns {Map<number, string>}
1935
+ */
1936
+ mrcmAttributeRanges() {
1937
+ if (this._mrcmAttributeRanges) {
1938
+ return this._mrcmAttributeRanges;
1939
+ }
1940
+ const ranges = new Map();
1941
+ this._mrcmAttributeRanges = ranges;
1942
+
1943
+ const refSet = this.findRefSetByConceptId(MRCM_ATTRIBUTE_RANGE_REFSET);
1944
+ if (!refSet) {
1945
+ return ranges;
1946
+ }
1947
+ const contentAll = this.conceptIndexOf(MRCM_CONTENT_TYPE_ALL);
1948
+ const contentPost = this.conceptIndexOf(MRCM_CONTENT_TYPE_POSTCOORDINATED);
1949
+ const fromPostRow = new Set();
1950
+
1951
+ const members = this.refSetMembers.getMembers(refSet.membersByRef) || [];
1952
+ for (const member of members) {
1953
+ if (member.kind !== 0) {
1954
+ continue;
1955
+ }
1956
+ // (value, type) pairs: rangeConstraint, attributeRule, ruleStrengthId, contentTypeId
1957
+ const values = this.refs.getReferences(member.values);
1958
+ if (!values || values.length < 8) {
1959
+ continue;
1960
+ }
1961
+ const contentType = values[6];
1962
+ if (contentType !== contentAll && contentType !== contentPost) {
1963
+ continue;
1964
+ }
1965
+ if (ranges.has(member.ref) && fromPostRow.has(member.ref) && contentType !== contentPost) {
1966
+ continue; // keep the postcoordination-specific range
1967
+ }
1968
+ let constraint;
1969
+ try {
1970
+ constraint = this.strings.getEntry(values[0]);
1971
+ } catch (error) {
1972
+ continue;
1973
+ }
1974
+ if (!constraint) {
1975
+ continue;
1976
+ }
1977
+ ranges.set(member.ref, constraint);
1978
+ if (contentType === contentPost) {
1979
+ fromPostRow.add(member.ref);
1980
+ }
1981
+ }
1982
+ return ranges;
1983
+ }
1984
+
1985
+ /**
1986
+ * The upper bound from an MRCM cardinality string such as "0..1", "0..*" or
1987
+ * "0..0". Returns Infinity for "*", and Infinity for anything unparseable, so
1988
+ * a value we do not understand cannot invent a violation.
1989
+ *
1990
+ * @param {number} stringRef - offset of the cardinality string
1991
+ * @returns {number}
1992
+ */
1993
+ parseCardinalityMax(stringRef) {
1994
+ let text;
1995
+ try {
1996
+ text = this.strings.getEntry(stringRef);
1997
+ } catch (error) {
1998
+ return Infinity;
1999
+ }
2000
+ const match = /^\s*\d+\s*\.\.\s*(\d+|\*)\s*$/.exec(text || '');
2001
+ if (!match) {
2002
+ return Infinity;
2003
+ }
2004
+ return match[1] === '*' ? Infinity : parseInt(match[1], 10);
2005
+ }
2006
+
2007
+ /**
2008
+ * Check the MRCM cardinality of the attributes used on one expression:
2009
+ * attributeCardinality caps how often an attribute may appear on the concept
2010
+ * at all, and attributeInGroupCardinality caps how often it may appear within
2011
+ * a single relationship group - a maximum of 0 there is how the MRCM says an
2012
+ * attribute is ungrouped (all 37 ungrouped attributes carry "0..0").
2013
+ *
2014
+ * Rules are ANDed, so where an attribute has several rules the tightest
2015
+ * maximum applies. Nested value expressions are checked separately, when
2016
+ * checkRefinement recurses into them.
2017
+ *
2018
+ * @param {SnomedExpression} expression
2019
+ */
2020
+ checkExpressionCardinality(expression) {
2021
+ const domains = this.mrcmAttributeDomains();
2022
+ if (domains.size === 0) {
2023
+ return;
2024
+ }
2025
+
2026
+ const groups = expression.hasRefinementGroups() ? expression.refinementGroups : [];
2027
+ const ungrouped = expression.hasRefinements() ? expression.refinements : [];
2028
+
2029
+ const total = new Map(); // attribute -> occurrences anywhere on this expression
2030
+ const count = (refinement, into) => {
2031
+ const attribute = refinement.name ? refinement.name.reference : NO_REFERENCE;
2032
+ if (attribute === undefined || attribute === null || attribute === NO_REFERENCE) {
2033
+ return;
2034
+ }
2035
+ into.set(attribute, (into.get(attribute) || 0) + 1);
2036
+ if (into !== total) {
2037
+ total.set(attribute, (total.get(attribute) || 0) + 1);
2038
+ }
2039
+ };
2040
+
2041
+ for (const refinement of ungrouped) {
2042
+ count(refinement, total);
2043
+ }
2044
+ const perGroup = [];
2045
+ for (const group of groups) {
2046
+ const counts = new Map();
2047
+ for (const refinement of group.refinements) {
2048
+ count(refinement, counts);
2049
+ }
2050
+ perGroup.push(counts);
2051
+ }
2052
+
2053
+ const limit = (attribute, field) => {
2054
+ const rules = domains.get(attribute);
2055
+ if (!rules || rules.length === 0) {
2056
+ return Infinity;
2057
+ }
2058
+ return rules.reduce((least, rule) => Math.min(least, rule[field]), Infinity);
2059
+ };
2060
+
2061
+ for (const [attribute, used] of total) {
2062
+ const max = limit(attribute, 'max');
2063
+ if (used > max) {
2064
+ throw new Error(`The SNOMED CT concept model allows the attribute ${this.describeConceptForMessage(attribute)}`
2065
+ + ` at most ${max} time(s) on one concept, but it is used ${used} times`);
2066
+ }
2067
+ }
2068
+ for (const counts of perGroup) {
2069
+ for (const [attribute, used] of counts) {
2070
+ const max = limit(attribute, 'maxInGroup');
2071
+ if (used > max) {
2072
+ if (max === 0) {
2073
+ throw new Error(`The SNOMED CT concept model does not allow the attribute ${this.describeConceptForMessage(attribute)}`
2074
+ + ` to appear in a relationship group`);
2075
+ }
2076
+ throw new Error(`The SNOMED CT concept model allows the attribute ${this.describeConceptForMessage(attribute)}`
2077
+ + ` at most ${max} time(s) in one relationship group, but it is used ${used} times`);
1464
2078
  }
1465
2079
  }
1466
2080
  }
1467
2081
  }
1468
2082
 
2083
+ /**
2084
+ * Is this concept a member of this reference set? Uses the per-concept list of
2085
+ * reference sets, so it costs a handful of comparisons rather than a scan of
2086
+ * the reference set's members.
2087
+ */
2088
+ conceptInRefSet(refSetConcept, conceptIndex) {
2089
+ try {
2090
+ const concept = this.concepts.getConcept(conceptIndex);
2091
+ if (!concept.refsets) {
2092
+ return false;
2093
+ }
2094
+ const list = this.refs.getReferences(concept.refsets);
2095
+ return !!list && list.includes(refSetConcept);
2096
+ } catch (error) {
2097
+ return false;
2098
+ }
2099
+ }
2100
+
2101
+ findRefSetByConceptId(conceptId) {
2102
+ const index = this.conceptIndexOf(conceptId);
2103
+ if (index === null) {
2104
+ return null;
2105
+ }
2106
+ for (let i = 0; i < this.refSetIndex.count(); i++) {
2107
+ const refSet = this.refSetIndex.getReferenceSet(i);
2108
+ if (refSet.definition === index) {
2109
+ return refSet;
2110
+ }
2111
+ }
2112
+ return null;
2113
+ }
2114
+
2115
+ conceptIndexOf(conceptId) {
2116
+ const result = this.concepts.findConcept(conceptId);
2117
+ return result.found ? result.index : null;
2118
+ }
2119
+
2120
+ /** "11204002 |Structure of porta hepatis|", falling back to the bare id */
2121
+ describeConceptForMessage(reference) {
2122
+ const id = this.getConceptId(reference);
2123
+ try {
2124
+ const displays = this.listDisplayNames(reference, 0);
2125
+ if (displays && displays.length > 0 && displays[0].term) {
2126
+ return `${id} |${displays[0].term}|`;
2127
+ }
2128
+ } catch (error) {
2129
+ // fall through to the bare id
2130
+ }
2131
+ return id;
2132
+ }
2133
+
1469
2134
  /**
1470
2135
  * Validate concept reference
1471
2136
  */
@@ -1675,9 +2340,7 @@ class SnomedExpressionServices {
1675
2340
  // In SNOMED CT, primitive concepts are not fully defined by their relationships
1676
2341
  try {
1677
2342
  const concept = this.concepts.getConcept(reference);
1678
- // Bit 0 typically indicates if concept is primitive (1) or defined (0)
1679
- // This may vary based on the specific implementation
1680
- return (concept.flags & 1) !== 0;
2343
+ return (concept.flags & SnomedConceptList.MASK_CONCEPT_PRIMITIVE) !== 0;
1681
2344
  } catch (error) {
1682
2345
  // If we can't read the concept, assume it's primitive for safety
1683
2346
  this.log.warn(`Warning: Could not check primitive status for concept ${reference}: ${error.message}`);
@@ -2002,10 +2665,9 @@ class SnomedExpressionServicesExtended extends SnomedExpressionServices {
2002
2665
  isPrimitive(reference) {
2003
2666
  try {
2004
2667
  const concept = this.concepts.getConcept(reference);
2005
- // In SNOMED CT, primitive concepts have the primitive flag set
2006
- // The definitionStatusId in RF2 determines this:
2007
- // 900000000000074008 = primitive, 900000000000073002 = fully defined
2008
- return (concept.flags & 1) !== 0;
2668
+ // definitionStatusId 900000000000074008 (primitive) is recorded at import time as
2669
+ // MASK_CONCEPT_PRIMITIVE in the concept flags byte
2670
+ return (concept.flags & SnomedConceptList.MASK_CONCEPT_PRIMITIVE) !== 0;
2009
2671
  } catch (error) {
2010
2672
  // If we can't read the concept, assume it's primitive for safety
2011
2673
  if (this.building) {