fhirsmith 0.11.1 → 0.12.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.
@@ -9,6 +9,17 @@
9
9
  const MAX_TERM_LENGTH = 1024;
10
10
  const NO_REFERENCE = 0xFFFFFFFF;
11
11
 
12
+ // MRCM (machine readable concept model) reference sets and metadata concepts.
13
+ // The range constraints in 723562003 are ECL, so they are only checked when an
14
+ // evaluator has been injected (SnomedServices does this - see eclConstraintMatcher).
15
+ const MRCM_ATTRIBUTE_DOMAIN_REFSET = 723561005n;
16
+ const MRCM_ATTRIBUTE_RANGE_REFSET = 723562003n;
17
+ // contentTypeId values that apply when validating a postcoordinated expression.
18
+ // 723593002 (precoordinated only) and 723595009 (new precoordinated only) are
19
+ // rules about authoring precoordinated concepts and do not constrain expressions.
20
+ const MRCM_CONTENT_TYPE_ALL = 723596005n;
21
+ const MRCM_CONTENT_TYPE_POSTCOORDINATED = 723594008n;
22
+
12
23
  // Expression status enumeration
13
24
  const SnomedExpressionStatus = {
14
25
  Unknown: 0,
@@ -1454,6 +1465,8 @@ class SnomedExpressionServices {
1454
1465
  if (expression.hasRefinements()) {
1455
1466
  for (const refinement of expression.refinements) {
1456
1467
  this.checkRefinement(refinement);
1468
+ this.checkRefinementDomain(expression, refinement);
1469
+ this.checkRefinementRange(refinement);
1457
1470
  }
1458
1471
  }
1459
1472
 
@@ -1461,11 +1474,483 @@ class SnomedExpressionServices {
1461
1474
  for (const group of expression.refinementGroups) {
1462
1475
  for (const refinement of group.refinements) {
1463
1476
  this.checkRefinement(refinement);
1477
+ this.checkRefinementDomain(expression, refinement);
1478
+ this.checkRefinementRange(refinement);
1479
+ }
1480
+ }
1481
+ }
1482
+
1483
+ this.checkExpressionCardinality(expression);
1484
+ }
1485
+
1486
+ /**
1487
+ * Check one refinement against the MRCM attribute domain reference set: the
1488
+ * focus concept the attribute is applied to must be in the attribute's domain.
1489
+ *
1490
+ * Every applicable rule must hold, mandatory or optional. Internationally
1491
+ * there is exactly one optional rule - Laterality in the lateralizable body
1492
+ * structure reference set - and it is the rule that stops laterality being
1493
+ * applied to a structure that cannot be lateralized, so it is enforced.
1494
+ *
1495
+ * Attributes with no rule in this edition's MRCM are not checked, and neither
1496
+ * are cardinality, grouping, or the range constraint (which needs ECL).
1497
+ *
1498
+ * @param {SnomedExpression} expression - the expression the refinement is on
1499
+ * @param {SnomedRefinement} refinement
1500
+ */
1501
+ checkRefinementDomain(expression, refinement) {
1502
+ const attribute = refinement.name ? refinement.name.reference : NO_REFERENCE;
1503
+ if (attribute === undefined || attribute === null || attribute === NO_REFERENCE) {
1504
+ return;
1505
+ }
1506
+ const rules = this.mrcmAttributeDomains().get(attribute);
1507
+ if (!rules || rules.length === 0) {
1508
+ // This edition's concept model says nothing about this attribute (3 active
1509
+ // attributes internationally have no domain rule and appear in no
1510
+ // postcoordination domain template), so there is nothing to check it against.
1511
+ return;
1512
+ }
1513
+
1514
+ // The focus may be a conjunction of concepts; the expression is a subtype of
1515
+ // each of them, so the attribute is in domain if any focus concept is.
1516
+ const focus = [];
1517
+ for (const concept of expression.concepts) {
1518
+ if (concept.reference !== undefined && concept.reference !== null && concept.reference !== NO_REFERENCE) {
1519
+ focus.push(concept.reference);
1520
+ }
1521
+ }
1522
+ if (focus.length === 0) {
1523
+ return;
1524
+ }
1525
+
1526
+ // Report every rule that failed, not just the first: a laterality on a
1527
+ // disorder breaks both the anatomical structure domain and the lateralizable
1528
+ // refset rule, and naming only one of them misleads.
1529
+ const failed = [];
1530
+ for (const rule of rules) {
1531
+ const ok = focus.some(f => rule.isRefset
1532
+ ? this.conceptInRefSet(rule.domain, f)
1533
+ : this.subsumes(rule.domain, f));
1534
+ if (!ok) {
1535
+ failed.push((rule.isRefset ? 'for concepts in ' : 'in the domain ') + this.describeConceptForMessage(rule.domain));
1536
+ }
1537
+ }
1538
+ if (failed.length > 0) {
1539
+ const attrDesc = this.describeConceptForMessage(attribute);
1540
+ const focusDesc = focus.map(f => this.describeConceptForMessage(f)).join(' + ');
1541
+ throw new Error(`The SNOMED CT concept model does not allow the attribute ${attrDesc} on ${focusDesc}`
1542
+ + ` (the attribute is only valid ${failed.join(', and ')})`);
1543
+ }
1544
+ }
1545
+
1546
+ /**
1547
+ * The MRCM attribute domain reference set, indexed by attribute concept.
1548
+ * Built once per edition and cached; an edition with no MRCM yields an empty
1549
+ * map, which turns the domain check off rather than failing.
1550
+ *
1551
+ * @returns {Map<number, Array<{domain: number, isRefset: boolean}>>}
1552
+ */
1553
+ mrcmAttributeDomains() {
1554
+ if (this._mrcmAttributeDomains) {
1555
+ return this._mrcmAttributeDomains;
1556
+ }
1557
+ const rules = new Map();
1558
+ this._mrcmAttributeDomains = rules;
1559
+
1560
+ const refSet = this.findRefSetByConceptId(MRCM_ATTRIBUTE_DOMAIN_REFSET);
1561
+ if (!refSet) {
1562
+ return rules; // no MRCM in this edition
1563
+ }
1564
+ const contentAll = this.conceptIndexOf(MRCM_CONTENT_TYPE_ALL);
1565
+ const contentPost = this.conceptIndexOf(MRCM_CONTENT_TYPE_POSTCOORDINATED);
1566
+
1567
+ // Every concept that defines a reference set, so a domain that is itself a
1568
+ // reference set (the lateralizable body structure refset) is tested by
1569
+ // membership rather than subsumption.
1570
+ const refSetConcepts = new Set();
1571
+ for (let i = 0; i < this.refSetIndex.count(); i++) {
1572
+ refSetConcepts.add(this.refSetIndex.getReferenceSet(i).definition);
1573
+ }
1574
+
1575
+ const members = this.refSetMembers.getMembers(refSet.membersByRef) || [];
1576
+ for (const member of members) {
1577
+ if (member.kind !== 0) {
1578
+ continue; // concept members only
1579
+ }
1580
+ // Additional columns are stored as (value, type) pairs, in file order:
1581
+ // domainId, grouped, attributeCardinality, attributeInGroupCardinality,
1582
+ // ruleStrengthId, contentTypeId.
1583
+ const values = this.refs.getReferences(member.values);
1584
+ if (!values || values.length < 12) {
1585
+ continue;
1586
+ }
1587
+ const domain = values[0];
1588
+ const contentType = values[10];
1589
+ if (contentType !== contentAll && contentType !== contentPost) {
1590
+ continue; // a rule about authoring precoordinated concepts
1591
+ }
1592
+ // The importer stores 0 for a referenced component it could not resolve,
1593
+ // which would otherwise read as a domain of whichever concept sorts first.
1594
+ if (domain === 0) {
1595
+ continue;
1596
+ }
1597
+ let list = rules.get(member.ref);
1598
+ if (!list) {
1599
+ list = [];
1600
+ rules.set(member.ref, list);
1601
+ }
1602
+ list.push({
1603
+ domain: domain,
1604
+ isRefset: refSetConcepts.has(domain),
1605
+ // Only the maximums are enforced. A minimum ("1..1") says a concept in
1606
+ // this domain must have the attribute in its own definition; it does not
1607
+ // say a refinement of that concept has to restate it.
1608
+ max: this.parseCardinalityMax(values[4]), // attributeCardinality
1609
+ maxInGroup: this.parseCardinalityMax(values[6]) // attributeInGroupCardinality
1610
+ });
1611
+ }
1612
+ return rules;
1613
+ }
1614
+
1615
+ /**
1616
+ * Check one refinement's value against the MRCM attribute range: the range is
1617
+ * an ECL constraint, so this only runs when an evaluator has been injected
1618
+ * (`eclConstraintMatcher`, set by SnomedServices, which owns the ECL engine).
1619
+ * A matcher that returns null - an unparseable or unsupported constraint, such
1620
+ * as the concrete-value ranges "dec(>#0..)" - leaves the value unchecked
1621
+ * rather than rejecting it.
1622
+ *
1623
+ * @param {SnomedRefinement} refinement
1624
+ */
1625
+ checkRefinementRange(refinement) {
1626
+ const attribute = refinement.name ? refinement.name.reference : NO_REFERENCE;
1627
+ if (attribute === undefined || attribute === null || attribute === NO_REFERENCE) {
1628
+ return;
1629
+ }
1630
+ const constraint = this.mrcmAttributeRanges().get(attribute);
1631
+ if (!constraint || !refinement.value) {
1632
+ return;
1633
+ }
1634
+
1635
+ // A concrete value range (dec/int/str) is checked here; everything else is
1636
+ // ECL and needs the evaluator the provider injects.
1637
+ const concreteRange = this.parseConcreteRange(constraint);
1638
+ const value = this.concreteValueOf(refinement.value);
1639
+ let ok;
1640
+ if (concreteRange) {
1641
+ ok = this.concreteValueSatisfies(concreteRange, value);
1642
+ } else if (value !== null) {
1643
+ ok = false; // a concrete value where the model wants a concept
1644
+ } else if (!this.eclConstraintMatcher) {
1645
+ return;
1646
+ } else {
1647
+ try {
1648
+ ok = this.eclConstraintMatcher(constraint, refinement.value);
1649
+ } catch (error) {
1650
+ ok = null; // the evaluator could not decide; do not invent a violation
1651
+ }
1652
+ }
1653
+ // null = could not decide: a constraint naming concepts this edition does not carry.
1654
+ if (ok === false) {
1655
+ const concrete = this.concreteValueOf(refinement.value);
1656
+ const value = concrete !== null ? concrete.text : (refinement.value.concepts || [])
1657
+ .filter(c => c.reference !== undefined && c.reference !== null && c.reference !== NO_REFERENCE)
1658
+ .map(c => this.describeConceptForMessage(c.reference)).join(' + ');
1659
+ throw new Error(`The SNOMED CT concept model does not allow ${value || 'this value'}`
1660
+ + ` as the value of the attribute ${this.describeConceptForMessage(attribute)}`
1661
+ + ` (the value must satisfy "${constraint}")`);
1662
+ }
1663
+ }
1664
+
1665
+ /**
1666
+ * Parse an MRCM concrete value range such as "dec(>#0..)", "int(#1..#10)" or
1667
+ * "str(...)". Returns null for anything that is not one - those are ECL.
1668
+ *
1669
+ * @param {string} constraint
1670
+ * @returns {{type: string, lowOp: string, low: number, highOp: string, high: number}|null}
1671
+ */
1672
+ parseConcreteRange(constraint) {
1673
+ const match = /^\s*(dec|int|str)\s*\((.*)\)\s*$/.exec(constraint || '');
1674
+ if (!match) {
1675
+ return null;
1676
+ }
1677
+ const range = { type: match[1], lowOp: null, low: null, highOp: null, high: null };
1678
+ if (range.type === 'str') {
1679
+ return range; // no ordering on strings; only the type is checked
1680
+ }
1681
+ const parts = /^\s*([<>]=?)?\s*#?(-?\d+(?:\.\d+)?)?\s*\.\.\s*([<>]=?)?\s*#?(-?\d+(?:\.\d+)?)?\s*$/.exec(match[2]);
1682
+ if (!parts) {
1683
+ return range; // bounds we do not understand: the type check still applies
1684
+ }
1685
+ if (parts[2] !== undefined) { range.lowOp = parts[1] || '>='; range.low = parseFloat(parts[2]); }
1686
+ if (parts[4] !== undefined) { range.highOp = parts[3] || '<='; range.high = parseFloat(parts[4]); }
1687
+ return range;
1688
+ }
1689
+
1690
+ /**
1691
+ * The concrete value a refinement value carries, if it is one: SCG writes these
1692
+ * as #500 or "text" rather than as a concept.
1693
+ *
1694
+ * @param {SnomedExpression} expression - a refinement's value
1695
+ * @returns {{number: number|null, string: string|null, text: string}|null}
1696
+ */
1697
+ concreteValueOf(expression) {
1698
+ if (!expression || (expression.refinements || []).length > 0 || (expression.refinementGroups || []).length > 0) {
1699
+ return null;
1700
+ }
1701
+ const concepts = expression.concepts || [];
1702
+ if (concepts.length !== 1) {
1703
+ return null;
1704
+ }
1705
+ const concept = concepts[0];
1706
+ if (concept.decimal !== undefined && concept.decimal !== null && concept.decimal !== '') {
1707
+ return { number: parseFloat(concept.decimal), string: null, text: '#' + concept.decimal };
1708
+ }
1709
+ if (concept.literal !== undefined && concept.literal !== null && concept.literal !== '') {
1710
+ return { number: null, string: concept.literal, text: JSON.stringify(concept.literal) };
1711
+ }
1712
+ return null;
1713
+ }
1714
+
1715
+ /**
1716
+ * @param {Object} range - from parseConcreteRange
1717
+ * @param {Object|null} value - from concreteValueOf
1718
+ * @returns {boolean}
1719
+ */
1720
+ concreteValueSatisfies(range, value) {
1721
+ if (value === null) {
1722
+ return false; // a concept where the model wants a concrete value
1723
+ }
1724
+ if (range.type === 'str') {
1725
+ return value.string !== null;
1726
+ }
1727
+ if (value.number === null || !isFinite(value.number)) {
1728
+ return false;
1729
+ }
1730
+ if (range.type === 'int' && !Number.isInteger(value.number)) {
1731
+ return false;
1732
+ }
1733
+ if (range.low !== null) {
1734
+ if (range.lowOp === '>' && !(value.number > range.low)) return false;
1735
+ if (range.lowOp === '>=' && !(value.number >= range.low)) return false;
1736
+ }
1737
+ if (range.high !== null) {
1738
+ if (range.highOp === '<' && !(value.number < range.high)) return false;
1739
+ if (range.highOp === '<=' && !(value.number <= range.high)) return false;
1740
+ }
1741
+ return true;
1742
+ }
1743
+
1744
+ /**
1745
+ * The MRCM attribute range reference set, indexed by attribute concept, as the
1746
+ * rangeConstraint ECL string.
1747
+ *
1748
+ * Where an attribute has a range specifically for postcoordinated content
1749
+ * (contentTypeId 723594008) that one replaces the general "all content" range
1750
+ * rather than adding to it - 11 attributes internationally do this. After that
1751
+ * rule every attribute has exactly one applicable constraint, so there is no
1752
+ * question of ANDing or ORing them.
1753
+ *
1754
+ * @returns {Map<number, string>}
1755
+ */
1756
+ mrcmAttributeRanges() {
1757
+ if (this._mrcmAttributeRanges) {
1758
+ return this._mrcmAttributeRanges;
1759
+ }
1760
+ const ranges = new Map();
1761
+ this._mrcmAttributeRanges = ranges;
1762
+
1763
+ const refSet = this.findRefSetByConceptId(MRCM_ATTRIBUTE_RANGE_REFSET);
1764
+ if (!refSet) {
1765
+ return ranges;
1766
+ }
1767
+ const contentAll = this.conceptIndexOf(MRCM_CONTENT_TYPE_ALL);
1768
+ const contentPost = this.conceptIndexOf(MRCM_CONTENT_TYPE_POSTCOORDINATED);
1769
+ const fromPostRow = new Set();
1770
+
1771
+ const members = this.refSetMembers.getMembers(refSet.membersByRef) || [];
1772
+ for (const member of members) {
1773
+ if (member.kind !== 0) {
1774
+ continue;
1775
+ }
1776
+ // (value, type) pairs: rangeConstraint, attributeRule, ruleStrengthId, contentTypeId
1777
+ const values = this.refs.getReferences(member.values);
1778
+ if (!values || values.length < 8) {
1779
+ continue;
1780
+ }
1781
+ const contentType = values[6];
1782
+ if (contentType !== contentAll && contentType !== contentPost) {
1783
+ continue;
1784
+ }
1785
+ if (ranges.has(member.ref) && fromPostRow.has(member.ref) && contentType !== contentPost) {
1786
+ continue; // keep the postcoordination-specific range
1787
+ }
1788
+ let constraint;
1789
+ try {
1790
+ constraint = this.strings.getEntry(values[0]);
1791
+ } catch (error) {
1792
+ continue;
1793
+ }
1794
+ if (!constraint) {
1795
+ continue;
1796
+ }
1797
+ ranges.set(member.ref, constraint);
1798
+ if (contentType === contentPost) {
1799
+ fromPostRow.add(member.ref);
1800
+ }
1801
+ }
1802
+ return ranges;
1803
+ }
1804
+
1805
+ /**
1806
+ * The upper bound from an MRCM cardinality string such as "0..1", "0..*" or
1807
+ * "0..0". Returns Infinity for "*", and Infinity for anything unparseable, so
1808
+ * a value we do not understand cannot invent a violation.
1809
+ *
1810
+ * @param {number} stringRef - offset of the cardinality string
1811
+ * @returns {number}
1812
+ */
1813
+ parseCardinalityMax(stringRef) {
1814
+ let text;
1815
+ try {
1816
+ text = this.strings.getEntry(stringRef);
1817
+ } catch (error) {
1818
+ return Infinity;
1819
+ }
1820
+ const match = /^\s*\d+\s*\.\.\s*(\d+|\*)\s*$/.exec(text || '');
1821
+ if (!match) {
1822
+ return Infinity;
1823
+ }
1824
+ return match[1] === '*' ? Infinity : parseInt(match[1], 10);
1825
+ }
1826
+
1827
+ /**
1828
+ * Check the MRCM cardinality of the attributes used on one expression:
1829
+ * attributeCardinality caps how often an attribute may appear on the concept
1830
+ * at all, and attributeInGroupCardinality caps how often it may appear within
1831
+ * a single relationship group - a maximum of 0 there is how the MRCM says an
1832
+ * attribute is ungrouped (all 37 ungrouped attributes carry "0..0").
1833
+ *
1834
+ * Rules are ANDed, so where an attribute has several rules the tightest
1835
+ * maximum applies. Nested value expressions are checked separately, when
1836
+ * checkRefinement recurses into them.
1837
+ *
1838
+ * @param {SnomedExpression} expression
1839
+ */
1840
+ checkExpressionCardinality(expression) {
1841
+ const domains = this.mrcmAttributeDomains();
1842
+ if (domains.size === 0) {
1843
+ return;
1844
+ }
1845
+
1846
+ const groups = expression.hasRefinementGroups() ? expression.refinementGroups : [];
1847
+ const ungrouped = expression.hasRefinements() ? expression.refinements : [];
1848
+
1849
+ const total = new Map(); // attribute -> occurrences anywhere on this expression
1850
+ const count = (refinement, into) => {
1851
+ const attribute = refinement.name ? refinement.name.reference : NO_REFERENCE;
1852
+ if (attribute === undefined || attribute === null || attribute === NO_REFERENCE) {
1853
+ return;
1854
+ }
1855
+ into.set(attribute, (into.get(attribute) || 0) + 1);
1856
+ if (into !== total) {
1857
+ total.set(attribute, (total.get(attribute) || 0) + 1);
1858
+ }
1859
+ };
1860
+
1861
+ for (const refinement of ungrouped) {
1862
+ count(refinement, total);
1863
+ }
1864
+ const perGroup = [];
1865
+ for (const group of groups) {
1866
+ const counts = new Map();
1867
+ for (const refinement of group.refinements) {
1868
+ count(refinement, counts);
1869
+ }
1870
+ perGroup.push(counts);
1871
+ }
1872
+
1873
+ const limit = (attribute, field) => {
1874
+ const rules = domains.get(attribute);
1875
+ if (!rules || rules.length === 0) {
1876
+ return Infinity;
1877
+ }
1878
+ return rules.reduce((least, rule) => Math.min(least, rule[field]), Infinity);
1879
+ };
1880
+
1881
+ for (const [attribute, used] of total) {
1882
+ const max = limit(attribute, 'max');
1883
+ if (used > max) {
1884
+ throw new Error(`The SNOMED CT concept model allows the attribute ${this.describeConceptForMessage(attribute)}`
1885
+ + ` at most ${max} time(s) on one concept, but it is used ${used} times`);
1886
+ }
1887
+ }
1888
+ for (const counts of perGroup) {
1889
+ for (const [attribute, used] of counts) {
1890
+ const max = limit(attribute, 'maxInGroup');
1891
+ if (used > max) {
1892
+ if (max === 0) {
1893
+ throw new Error(`The SNOMED CT concept model does not allow the attribute ${this.describeConceptForMessage(attribute)}`
1894
+ + ` to appear in a relationship group`);
1895
+ }
1896
+ throw new Error(`The SNOMED CT concept model allows the attribute ${this.describeConceptForMessage(attribute)}`
1897
+ + ` at most ${max} time(s) in one relationship group, but it is used ${used} times`);
1464
1898
  }
1465
1899
  }
1466
1900
  }
1467
1901
  }
1468
1902
 
1903
+ /**
1904
+ * Is this concept a member of this reference set? Uses the per-concept list of
1905
+ * reference sets, so it costs a handful of comparisons rather than a scan of
1906
+ * the reference set's members.
1907
+ */
1908
+ conceptInRefSet(refSetConcept, conceptIndex) {
1909
+ try {
1910
+ const concept = this.concepts.getConcept(conceptIndex);
1911
+ if (!concept.refsets) {
1912
+ return false;
1913
+ }
1914
+ const list = this.refs.getReferences(concept.refsets);
1915
+ return !!list && list.includes(refSetConcept);
1916
+ } catch (error) {
1917
+ return false;
1918
+ }
1919
+ }
1920
+
1921
+ findRefSetByConceptId(conceptId) {
1922
+ const index = this.conceptIndexOf(conceptId);
1923
+ if (index === null) {
1924
+ return null;
1925
+ }
1926
+ for (let i = 0; i < this.refSetIndex.count(); i++) {
1927
+ const refSet = this.refSetIndex.getReferenceSet(i);
1928
+ if (refSet.definition === index) {
1929
+ return refSet;
1930
+ }
1931
+ }
1932
+ return null;
1933
+ }
1934
+
1935
+ conceptIndexOf(conceptId) {
1936
+ const result = this.concepts.findConcept(conceptId);
1937
+ return result.found ? result.index : null;
1938
+ }
1939
+
1940
+ /** "11204002 |Structure of porta hepatis|", falling back to the bare id */
1941
+ describeConceptForMessage(reference) {
1942
+ const id = this.getConceptId(reference);
1943
+ try {
1944
+ const displays = this.listDisplayNames(reference, 0);
1945
+ if (displays && displays.length > 0 && displays[0].term) {
1946
+ return `${id} |${displays[0].term}|`;
1947
+ }
1948
+ } catch (error) {
1949
+ // fall through to the bare id
1950
+ }
1951
+ return id;
1952
+ }
1953
+
1469
1954
  /**
1470
1955
  * Validate concept reference
1471
1956
  */
@@ -30,7 +30,7 @@ sources:
30
30
  - cpt:CodeSystem-cpt.db|cpt-2023-fragment-0.1.db
31
31
  - omop:omop_v20250227.db
32
32
  - npm:hl7.terminology
33
- - npm:fhir.tx.support.r4
33
+ - npm:fhir.tx.support
34
34
  - npm:ihe.formatcode.fhir
35
35
  - npm:fhir.dicom
36
36
  - npm:hl7.fhir.us.core