fhirsmith 0.17.0 → 0.18.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 (121) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +80 -13
  3. package/config-template.json +106 -1
  4. package/ips/ai-client.js +263 -0
  5. package/ips/composer.js +805 -0
  6. package/ips/editor-schema.js +289 -0
  7. package/ips/editor.js +290 -0
  8. package/ips/fhirpath-ips.js +397 -0
  9. package/ips/ips-codeable-concepts.md +1742 -0
  10. package/ips/ips-template.html +125 -0
  11. package/ips/ips-valuesets.js +159 -0
  12. package/ips/ips.js +1736 -0
  13. package/ips/membership.js +179 -0
  14. package/ips/pipeline.js +1012 -0
  15. package/ips/presenter.js +974 -0
  16. package/ips/profile-editor.js +578 -0
  17. package/ips/profiles.js +506 -0
  18. package/ips/readme.md +460 -0
  19. package/ips/render.js +1482 -0
  20. package/ips/search-params.js +83 -0
  21. package/ips/search.js +667 -0
  22. package/ips/share.js +178 -0
  23. package/ips/store.js +650 -0
  24. package/ips/terminology.js +395 -0
  25. package/ips/tx-client.js +202 -0
  26. package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
  27. package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
  28. package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
  29. package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
  30. package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
  31. package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
  32. package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
  33. package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
  34. package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
  35. package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
  36. package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
  37. package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
  38. package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
  39. package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
  40. package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
  41. package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
  42. package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
  43. package/ips/valuesets/ValueSet-ips-medication.json +50 -0
  44. package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
  45. package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
  46. package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
  47. package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
  48. package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
  49. package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
  50. package/ips/valuesets/ValueSet-ips-route.json +46 -0
  51. package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
  52. package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
  53. package/ips/valuesets/elements.json +44 -0
  54. package/library/daily-report.js +422 -0
  55. package/library/fhir-search-values.js +138 -0
  56. package/library/html-server.js +32 -23
  57. package/library/mailer.js +77 -0
  58. package/library/narrative-check.js +21 -2
  59. package/library/npm-audit.js +25 -0
  60. package/library/package-manager.js +4 -2
  61. package/library/sqlite-session-store.js +29 -1
  62. package/package.json +7 -2
  63. package/packages/packages.js +34 -3
  64. package/publisher/publisher.js +29 -0
  65. package/registry/auth-store.js +366 -0
  66. package/registry/auth.js +872 -0
  67. package/registry/crawler.js +41 -1
  68. package/registry/documentation/api.md +162 -0
  69. package/registry/documentation/configuration.md +93 -0
  70. package/registry/documentation/documents.md +74 -0
  71. package/registry/documentation/internals.md +151 -0
  72. package/registry/documentation/readme.md +100 -0
  73. package/registry/documentation/security.md +199 -0
  74. package/registry/documents.js +309 -0
  75. package/registry/openapi.yaml +30 -0
  76. package/registry/registry-template.html +1 -1
  77. package/registry/registry.js +230 -44
  78. package/registry/uptime.js +154 -0
  79. package/server.js +105 -44
  80. package/shl/shl.js +64 -9
  81. package/stats-db.js +93 -0
  82. package/stats.js +158 -1
  83. package/testing/search.js +2 -108
  84. package/testing/store.js +5 -0
  85. package/testing/testing.js +20 -0
  86. package/token/token-template.html +5 -4
  87. package/token/token.js +17 -10
  88. package/translations/Messages.properties +9 -0
  89. package/tx/README.md +52 -0
  90. package/tx/cs/cs-api.js +90 -14
  91. package/tx/cs/cs-cs.js +56 -1
  92. package/tx/cs/cs-icd11.js +366 -112
  93. package/tx/cs/cs-icf.js +418 -0
  94. package/tx/cs/cs-loinc.js +47 -25
  95. package/tx/cs/cs-omop.js +10 -3
  96. package/tx/cs/cs-snomed.js +918 -55
  97. package/tx/cs/cs-ucum.js +6 -0
  98. package/tx/cs/icd11-expressions.js +191 -78
  99. package/tx/importers/export-icd11-codesystem.js +58 -12
  100. package/tx/importers/icd11-schema.md +9 -5
  101. package/tx/library.js +98 -4
  102. package/tx/openapi-operations.js +4 -0
  103. package/tx/params.js +8 -1
  104. package/tx/provider.js +16 -4
  105. package/tx/sct/documentation.md +251 -0
  106. package/tx/sct/ecl.js +758 -46
  107. package/tx/sct/expressions.js +113 -52
  108. package/tx/syndication/syndication-feed.js +218 -0
  109. package/tx/syndication/syndication-store.js +415 -0
  110. package/tx/tx-html.js +15 -1
  111. package/tx/tx.fhir.org.yml +2 -3
  112. package/tx/tx.js +144 -2
  113. package/tx/vs/vs-vsac.js +25 -6
  114. package/tx/workers/expand.js +33 -6
  115. package/tx/workers/lookup.js +61 -6
  116. package/tx/workers/subsumes.js +1 -1
  117. package/tx/workers/translate.js +166 -15
  118. package/tx/workers/validate.js +128 -6
  119. package/utilities/make-sct-test-extension.js +264 -0
  120. package/xig/xig.js +32 -0
  121. package/registry/readme.md +0 -124
package/tx/library.js CHANGED
@@ -93,6 +93,12 @@ class Library {
93
93
  totalDownloaded = 0;
94
94
  vsacCfg = undefined;
95
95
 
96
+ /**
97
+ * {SyndicationStore|null} where the content this library loads is recorded for the
98
+ * syndication feed, when the server has one (see tx/syndication)
99
+ */
100
+ syndication = null;
101
+
96
102
  registerProvider(source, factory, isDefault = false) {
97
103
  this.#logSystem(factory.system(), factory.version(), source);
98
104
  if (isDefault || !this.codeSystemFactories.has(factory.system())) {
@@ -607,6 +613,7 @@ class Library {
607
613
  const loinc = new LoincServicesFactory(this.i18n, loincFN);
608
614
  await loinc.load();
609
615
  this.registerProvider(loincFN, loinc, isDefault);
616
+ this.#syndicateBinary('loinc', loincFN, loinc);
610
617
  }
611
618
 
612
619
  async loadRxnorm(details, isDefault, mode) {
@@ -617,6 +624,7 @@ class Library {
617
624
  const rxn = new RxNormServicesFactory(this.i18n, rxNormFN);
618
625
  await rxn.load();
619
626
  this.registerProvider(rxNormFN, rxn, isDefault);
627
+ this.#syndicateBinary('rxnorm', rxNormFN, rxn);
620
628
  }
621
629
 
622
630
  async loadNdc(details, isDefault, mode) {
@@ -627,6 +635,7 @@ class Library {
627
635
  const ndc = new NdcServicesFactory(this.i18n, ndcFN);
628
636
  await ndc.load();
629
637
  this.registerProvider(ndcFN, ndc, isDefault);
638
+ this.#syndicateBinary('ndc', ndcFN, ndc);
630
639
  }
631
640
 
632
641
  async loadUnii(details, isDefault, mode) {
@@ -637,6 +646,7 @@ class Library {
637
646
  const unii = new UniiServicesFactory(this.i18n, uniFN);
638
647
  await unii.load();
639
648
  this.registerProvider(uniFN, unii, isDefault);
649
+ this.#syndicateBinary('unii', uniFN, unii);
640
650
  }
641
651
 
642
652
  async loadSnomed(details, isDefault, mode) {
@@ -647,6 +657,7 @@ class Library {
647
657
  const sct = new SnomedServicesFactory(this.i18n, sctFN);
648
658
  await sct.load();
649
659
  this.registerProvider(sctFN, sct, isDefault);
660
+ this.#syndicateBinary('snomed', sctFN, sct);
650
661
  }
651
662
 
652
663
  async loadCpt(details, isDefault, mode) {
@@ -657,6 +668,8 @@ class Library {
657
668
  const cpt = new CPTServicesFactory(this.i18n, cptFN);
658
669
  await cpt.load();
659
670
  this.registerProvider(cptFN, cpt, isDefault);
671
+ // the store syndicates the public fragment only, never the full (licensed) database
672
+ this.#syndicateBinary('cpt', cptFN, cpt);
660
673
  }
661
674
 
662
675
  async loadOmop(details, isDefault, mode) {
@@ -667,6 +680,7 @@ class Library {
667
680
  const omop = new OMOPServicesFactory(this.i18n, omopFN);
668
681
  await omop.load();
669
682
  this.registerProvider(omopFN, omop, isDefault);
683
+ this.#syndicateBinary('omop', omopFN, omop);
670
684
  }
671
685
 
672
686
  /**
@@ -679,13 +693,69 @@ class Library {
679
693
  if (mode === "fetch" || mode === "npm") {
680
694
  return;
681
695
  }
696
+ // one file, so one syndication entry - identified by the MMS, if the file has it
697
+ let main = null;
682
698
  for (const sys of await ICD11ServicesFactory.listSystems(icdFN)) {
683
699
  const factory = new ICD11ServicesFactory(this.i18n, icdFN, sys.code);
684
700
  await factory.load();
685
701
  this.registerProvider(icdFN, factory, isDefault);
702
+ if (!main || sys.code === 'mms') {
703
+ main = factory;
704
+ }
705
+ }
706
+ if (main) {
707
+ this.#syndicateBinary('icd11', icdFN, main);
686
708
  }
687
709
  }
688
710
 
711
+ /**
712
+ * Records a binary source file (SNOMED cache, LOINC database etc) in the syndication store,
713
+ * if there is one. The store works in the background, and decides itself whether the file
714
+ * is one it syndicates; nothing it does can stop the library loading.
715
+ * @private
716
+ */
717
+ #syndicateBinary(sourceType, filePath, factory) {
718
+ if (!this.syndication) {
719
+ return;
720
+ }
721
+ try {
722
+ let name = null;
723
+ try {
724
+ name = factory.name();
725
+ } catch (e) {
726
+ // not every factory has a name
727
+ }
728
+ void this.syndication.inductBinary({
729
+ sourceType, filePath, baseUrl: this.baseUrl, system: factory.system(), version: factory.version(), name
730
+ });
731
+ } catch (error) {
732
+ this.log.warn(`Syndication: unable to record ${filePath}: ${error.message}`);
733
+ }
734
+ }
735
+
736
+ /**
737
+ * Records a package in the syndication store, if there is one.
738
+ * @param {PackageContentLoader} contentLoader - the loaded package
739
+ * @param {Object} source - {requestedVersion} for an npm: source, {url} for a url: source,
740
+ * {fhirVersion} (major.minor) for an endpoint's core package
741
+ */
742
+ syndicatePackage(contentLoader, source = {}) {
743
+ if (!this.syndication || !contentLoader || !contentLoader.package) {
744
+ return;
745
+ }
746
+ const pkg = contentLoader.package;
747
+ void this.syndication.inductPackage({
748
+ id: contentLoader.id(),
749
+ version: contentLoader.version(),
750
+ canonical: pkg.canonical,
751
+ title: pkg.title,
752
+ description: pkg.description,
753
+ url: source.url,
754
+ fhirVersion: source.fhirVersion,
755
+ requestedVersion: source.requestedVersion
756
+ });
757
+ }
758
+
689
759
  /**
690
760
  * Returns true if the given url/version should be excluded from npm/url package loading.
691
761
  * Matches against the ignored list using either plain url or url#version.
@@ -707,12 +777,12 @@ class Library {
707
777
  version = parts[1];
708
778
  }
709
779
  const packagePath = await packageManager.fetch(packageId, version);
710
- await this.#loadPackageFromPath(packagePath, mode, csOnly);
780
+ await this.#loadPackageFromPath(packagePath, mode, csOnly, { requestedVersion: version });
711
781
  }
712
782
 
713
783
  async loadUrl(packageManager, url, isDefault, mode, csOnly) {
714
784
  const packagePath = await packageManager.fetchUrl(url);
715
- await this.#loadPackageFromPath(packagePath, mode, csOnly);
785
+ await this.#loadPackageFromPath(packagePath, mode, csOnly, { url });
716
786
  }
717
787
 
718
788
  /**
@@ -723,13 +793,14 @@ class Library {
723
793
  * array-backed codeSystems list, breaking url sources).
724
794
  * @private
725
795
  */
726
- async #loadPackageFromPath(packagePath, mode, csOnly) {
796
+ async #loadPackageFromPath(packagePath, mode, csOnly, source) {
727
797
  if (mode === "fetch" || mode === "cs") {
728
798
  return;
729
799
  }
730
800
  const fullPackagePath = path.join(this.cacheFolder, packagePath);
731
801
  const contentLoader = new PackageContentLoader(fullPackagePath);
732
802
  await contentLoader.initialize();
803
+ this.syndicatePackage(contentLoader, source);
733
804
 
734
805
  this.packageSources.push(contentLoader.id()+"#"+contentLoader.version());
735
806
 
@@ -852,6 +923,27 @@ class Library {
852
923
  */
853
924
  async #loadLocalSource(contentLoader) {
854
925
  await contentLoader.initialize();
926
+ if (this.syndication) {
927
+ // each resource is an entry in its own right (those without a version are skipped)
928
+ for (const entry of contentLoader.index.files) {
929
+ if (this.#isIgnored(entry.url, entry.version)) {
930
+ continue;
931
+ }
932
+ let title = null;
933
+ let description = null;
934
+ try {
935
+ const json = JSON.parse(await fs.readFile(path.join(contentLoader.sourceFolder, entry.filename), 'utf8'));
936
+ title = json.title || json.name || null;
937
+ description = typeof json.description === 'string' ? json.description : null;
938
+ } catch (e) {
939
+ // initialize() already read it; if it can't now, the store will say so
940
+ }
941
+ void this.syndication.inductResource({
942
+ filePath: path.join(contentLoader.sourceFolder, entry.filename),
943
+ resourceType: entry.resourceType, url: entry.url, version: entry.version, title, description
944
+ });
945
+ }
946
+ }
855
947
 
856
948
  this.packageSources.push(contentLoader.id() + "#" + contentLoader.version());
857
949
 
@@ -1050,7 +1142,9 @@ class Library {
1050
1142
 
1051
1143
  // Load FHIR packages - these will be added to valueSetProviders first
1052
1144
  for (const packageId of fhirPackages) {
1053
- await provider.loadNpm(this.packageManager, this.cacheFolder, packageId, false, "npm", false);
1145
+ const contentLoader = await provider.loadNpm(this.packageManager, this.cacheFolder, packageId, false, "npm", false);
1146
+ // the core packages are the only version specific content in the syndication store
1147
+ this.syndicatePackage(contentLoader, { fhirVersion: VersionUtilities.getMajMin(String(fhirVersion)) });
1054
1148
  }
1055
1149
 
1056
1150
 
@@ -209,6 +209,7 @@ const OPERATIONS = [
209
209
  p('version', 'string', '1', 'The version of the code system'),
210
210
  p('coding', 'Coding', '1', 'The code, as a Coding'),
211
211
  p('property', 'code', '*', 'A property to return (by default, all of them)'),
212
+ p('post-coordination-metadata', 'boolean', '1', 'For a post-coordinated code: describe every post-coordination axis in full - the axis rules, and the axes that carry no value (ICD-11). By default only the axes that carry a value are listed'),
212
213
  ],
213
214
  out: [
214
215
  p('name', 'string', '1', 'The name of the code system', { min: 1 }),
@@ -283,11 +284,14 @@ const OPERATIONS = [
283
284
  parts: [
284
285
  p('relationship', 'code', '1', 'How the target relates to the source'),
285
286
  p('concept', 'Coding', '1', 'The target'),
287
+ p('normalized-concept', 'Coding', '1', 'The target in its code system\'s normal form, when the map states it in another form'),
288
+ p('note', 'string', '1', 'The target\'s code system is not known, so the code has not been validated'),
286
289
  p('product', '(parts)', '*', 'Another element of the product (element, concept)'),
287
290
  p('originMap', 'canonical', '1', 'The concept map'),
288
291
  p('sourceConcept', 'Coding', '1', 'What was translated (for a translation in reverse)')
289
292
  ]
290
293
  }),
294
+ p('error', 'string', '*', 'A target code the maps gave that is not valid in its code system'),
291
295
  p('used-conceptmap', 'canonical', '*', 'A concept map that was used')
292
296
  ]
293
297
  },
package/tx/params.js CHANGED
@@ -380,6 +380,12 @@ class TxParameters {
380
380
  if (claim('includeDefinition')) this.includeDefinition = asBool();
381
381
  break;
382
382
  }
383
+ // $lookup: describe each postcoordination axis in full (ICD-11) - the axis rules, and the
384
+ // axes that carry no value. Off unless asked for
385
+ case 'post-coordination-metadata': {
386
+ if (claim('post-coordination-metadata')) this.postCoordinationMetadata = asBool();
387
+ break;
388
+ }
383
389
  case 'activeOnly': {
384
390
  if (claim('activeOnly')) this.activeOnly = asBool();
385
391
  break;
@@ -624,7 +630,7 @@ class TxParameters {
624
630
  // are included, and diagnostics.
625
631
  // filter is free text, so JSON.stringify it to avoid delimiter collisions.
626
632
  s = s + 'f:' + JSON.stringify(this.filter || '') + '|' +
627
- b(this.abstractOk) + b(this.diagnostics);
633
+ b(this.abstractOk) + b(this.diagnostics) + b(this.postCoordinationMetadata);
628
634
  for (let t of this.FVersionRules) {
629
635
  s = s + t.asString() + '|';
630
636
  }
@@ -676,6 +682,7 @@ class TxParameters {
676
682
  this.hasVersionsMatch = other.hasVersionsMatch;
677
683
  this.hasDisplayWarning = other.hasDisplayWarning;
678
684
  this.sort = other.sort;
685
+ this.postCoordinationMetadata = other.postCoordinationMetadata;
679
686
 
680
687
  if (other.FSeen) {
681
688
  this.FSeen = new Set(other.FSeen);
package/tx/provider.js CHANGED
@@ -151,7 +151,7 @@ class Provider {
151
151
  validateParameter(opContext, "opContext", OperationContext);
152
152
  validateParameter(codeSystem, "codeSystem", CodeSystem);
153
153
  validateArrayParameter(supplements, "supplements", CodeSystem);
154
- return new FhirCodeSystemProvider(opContext, codeSystem, supplements);
154
+ return FhirCodeSystemProvider.create(opContext, codeSystem, supplements);
155
155
  }
156
156
 
157
157
 
@@ -160,6 +160,8 @@ class Provider {
160
160
  * space (CodeSystem/core-v3-MaritalStatus): the core packages for different FHIR versions
161
161
  * share ids, and so does other content (hl7.terminology), so no endpoint can serve them on
162
162
  * their own ids (see tx/library/resource-ids.js)
163
+ *
164
+ * @returns {Promise<PackageContentLoader|undefined>} the loaded package (mode "npm")
163
165
  */
164
166
  async loadNpm(packageManager, cacheFolder, details, isDefault, mode) {
165
167
  // Parse packageId and version from details (e.g., "hl7.terminology.r4#6.0.2")
@@ -216,6 +218,7 @@ class Provider {
216
218
  await cm.initialize();
217
219
  cm.assignIds(CORE_PREFIX);
218
220
  this.conceptMapProviders.push(cm);
221
+ return contentLoader;
219
222
  }
220
223
 
221
224
  getCodeSystemById(opContext, id) {
@@ -331,16 +334,25 @@ class Provider {
331
334
 
332
335
 
333
336
 
334
- async findConceptMapForTranslation(opContext, conceptMaps, sourceSystem, sourceScope, targetScope, targetSystem, sourceCode = null) {
337
+ async findConceptMapForTranslation(opContext, conceptMaps, sourceSystem, sourceVersion, sourceScope, targetScope, targetSystem, sourceCode = null) {
335
338
  for (let cmp of this.conceptMapProviders) {
336
339
  await cmp.findConceptMapForTranslation(opContext, conceptMaps, sourceSystem, sourceScope, targetScope, targetSystem, sourceCode);
337
340
  }
338
341
  if (sourceSystem && targetSystem) {
342
+ // Each code system is asked once - except the source system itself, where every
343
+ // version (every SNOMED edition, say) is asked, since whether a map exists and what
344
+ // it says can depend on which version the source coding is in. The factory decides
345
+ // from sourceVersion whether it is the one being asked about.
339
346
  let uris = new Set();
347
+ let asked = new Set(); // a factory is registered under several keys (system, system|version...)
340
348
  for (let csp of this.codeSystemFactories.values()) {
341
- if (!uris.has(csp.system())) {
349
+ if (asked.has(csp)) {
350
+ continue;
351
+ }
352
+ if (csp.system() === sourceSystem || !uris.has(csp.system())) {
342
353
  uris.add(csp.system());
343
- await csp.findImplicitConceptMaps(conceptMaps, sourceSystem, targetSystem);
354
+ asked.add(csp);
355
+ await csp.findImplicitConceptMaps(conceptMaps, sourceSystem, sourceVersion, targetSystem);
344
356
  }
345
357
  }
346
358
  }
@@ -0,0 +1,251 @@
1
+ # SNOMED CT support in FHIRsmith: known limitations
2
+
3
+ This file records what the SNOMED CT code system provider (`tx/cs/cs-snomed.js`,
4
+ `tx/sct/*`) does not do, so that nobody has to rediscover it from the code. It is
5
+ organised by area: ECL, post-coordinated expressions, and everything else. Where
6
+ a limitation is a deliberate decision rather than unfinished work, it says so.
7
+
8
+ The import pipeline (`tx/importers/import-sct.module.js`) reads the RF2 snapshot
9
+ concept, description (including text definitions, which share the description
10
+ file layout), inferred relationship, and reference set files. It does not read
11
+ stated relationships, the OWL axiom reference sets, or the concrete value
12
+ relationship file; several of the limitations below follow from that.
13
+
14
+ ## ECL
15
+
16
+ The parser (`tx/sct/ecl.js`) accepts the ECL 2.2 grammar with the exceptions
17
+ listed under "Not parsed". The evaluator (`SnomedServices.filterECL` and the
18
+ `_eval*` methods in `cs-snomed.js`) evaluates the subset listed under
19
+ "Evaluated"; anything the parser accepts but the evaluator does not implement
20
+ is reported as `UNSUPPORTED_ECL`, and a syntax error or an unknown concept as
21
+ `INVALID_ECL`. Both are OperationOutcomes with tx-issue-type `vs-invalid`.
22
+
23
+ Note that `ECLValidator.evaluate*` in `ecl.js` is an older, weaker evaluator
24
+ that the server does not use. `filterECL` is the one that matters.
25
+
26
+ ### Evaluated
27
+
28
+ - All eight hierarchy operators (`<`, `<<`, `<!`, `<<!`, `>`, `>>`, `>!`, `>>!`)
29
+ on a concept reference, on `*`, on `^ refset` and on a bracketed expression
30
+ (applied to every member of the set), and top/bottom of set (`!!> (...)`,
31
+ `!!< (...)`).
32
+ - `*` (every concept, active and inactive - `activeOnly` is the expansion's job).
33
+ - `^` member-of on a concept or any bracketed expression, with
34
+ `{{ M ... }}` member filters on `effectiveTime`, `active`, `moduleId` and the
35
+ reference set's own fields (concept, integer and string typed from the RF2
36
+ file name). Since inactive member rows are dropped at import, `active = false`
37
+ never matches.
38
+ - `AND` / `OR` / `MINUS` (and `,`), with the ECL 6.4 rule that conjunction and
39
+ disjunction cannot be mixed without brackets.
40
+ - Refinements: `=` against an expression value; attribute names that are a
41
+ concept, `*`, or any sub expression (`<< 762705008 = ...`); attribute sets
42
+ joined by `AND`, `OR` or `,`, with brackets; attribute groups, including
43
+ `AND`/`OR` inside and between groups; attribute and group cardinality,
44
+ including `[0..0]`. Cardinality counts occurrences per role group, per the
45
+ SNOMED Computable Languages Group ruling (see `_countAttributeMatches`).
46
+ - Dotted expressions (`< X . 363698007`, `. << 363698007`, `. (A OR B)`).
47
+ - `{{ C ... }}` concept filters on `active`, `definitionStatus`,
48
+ `definitionStatusId`, `moduleId` and `effectiveTime`.
49
+ - `{{ D ... }}` description filters on `term` (match semantics: each word of
50
+ the search term starts a word of the description, case-insensitively; and
51
+ `wild:` whole-term patterns), `language`, `typeId` / `type` (syn, fsn, def),
52
+ `dialectId` / `dialect` with acceptability sets (`(prefer)`, `(accept)` or
53
+ acceptability concept ids; the `dialect` aliases known are en-us, en-gb,
54
+ en-au, en-ca, en-nz, en-ie, fr-ca, fr-be, nl-be, nl-nl, es-es, sv-se, da-dk),
55
+ `moduleId`, `effectiveTime`, `active` and `id`. The filters in one block
56
+ must be satisfied by a single description; separate blocks may be satisfied
57
+ by different descriptions. Only active descriptions are considered unless
58
+ the block has an `active` filter.
59
+ - Comments (`/* ... */`), terms (`|...|`, not checked against descriptions).
60
+
61
+ ### Parsed but reported as unsupported
62
+
63
+ - Reverse attributes (`R 363698007 = ...`).
64
+ - `!=` against an expression value in a refinement.
65
+ - Concrete values in refinements - numeric (`= #5`, `>= #1.5`), string
66
+ (`= "x"`, `= match:"x"`), boolean (`= true`). The importer does not read
67
+ `sct2_RelationshipConcreteValues_*`, so no concrete values are in the cache
68
+ to compare against. Supporting them means extending the importer and the
69
+ relationship record, and rebuilding every cache.
70
+ - Alternate identifiers (`"ICD10#A01.1"`, `SCTID#123`). No alternate
71
+ identifier reference sets are loaded.
72
+ - Reference set field selection on member-of (`^ [targetComponentId] refset`,
73
+ `^ [referencedComponentId, targetComponentId] refset`). Deliberate: the
74
+ selected fields, and the referenced components of many reference sets,
75
+ are descriptions or other non-concept components, which an expansion has
76
+ no way to represent.
77
+ - History supplements (`{{ + HISTORY-MIN / MOD / MAX }}`). The association
78
+ reference sets they draw on are loaded, so this is implementable.
79
+
80
+ ### Not parsed
81
+
82
+ - `wild:` search terms are rejected in member filters (accepted in refinement
83
+ string values, which are then unsupported anyway).
84
+ - A concept id must be 6-18 digits; anything else is a syntax error (this is
85
+ what makes `<< abc` invalid rather than unsupported).
86
+
87
+ ### Other ECL notes
88
+
89
+ - Description filter term matching walks the descriptions of every concept in
90
+ the result set rather than using the text index, so `* {{ D term = "x" }}`
91
+ on a full edition scans every description. It yields to the event loop via
92
+ the operation's dead-check but is not fast.
93
+ - A `dialect` alias the server does not know is reported as unsupported, not
94
+ invalid; the alias table is `ECL_DIALECT_ALIASES` in `cs-snomed.js`.
95
+
96
+ - Term checking: a wrong term after a concept id (`10200004 |Lever structure|`)
97
+ is accepted silently. The tx-ecosystem test `expand-ecl-term-mismatch` expects
98
+ this.
99
+ - `^` on a concept that is not a reference set yields an empty result rather
100
+ than an error (Snowstorm's behaviour; agreed in the tx-ecosystem suite).
101
+ `^` on a reference set whose members are descriptions (a language reference
102
+ set) is reported as unsupported - there are no concepts to return.
103
+ - The ECL result for a value set filter is cached per (value set, code system
104
+ version), so the first expansion of a large constraint is slow and later ones
105
+ are not.
106
+
107
+ ## Post-coordinated expressions
108
+
109
+ Expressions are parsed by `SnomedExpressionParser` (`tx/sct/expressions.js`)
110
+ in SNOMED Compositional Grammar, including `===` / `<<<` definition status
111
+ markers, nested (bracketed) attribute values, concrete values and terms.
112
+ They are normalised, checked against the MRCM, and compared structurally.
113
+
114
+ ### Validation
115
+
116
+ `checkExpression` rejects an expression whose refinements break the concept
117
+ model (`$validate-code` then answers `result = false`): the attribute must be
118
+ allowed on the focus concept's domain (MRCM attribute domain reference set
119
+ `723561005`), the value must satisfy the attribute's range (`723562003`,
120
+ whether that is ECL or a concrete range such as `dec(>#0..)`), the attribute
121
+ and in-group cardinality maximums apply, and an attribute the MRCM marks as
122
+ grouped must be in a role group. Known limits:
123
+
124
+ - Minimum cardinality is not enforced (it constrains a concept's own
125
+ definition, not a refinement of it). The converse of the grouping rule
126
+ (grouped = 0 means must not be grouped) is enforced only through the
127
+ in-group cardinality `0..0`.
128
+ - Attributes with no MRCM rule in the loaded edition are not checked at all.
129
+ Internationally that is `704347000 |Observes|`, `131195008 |Subject of
130
+ information|` and `363705008 |Has definitional manifestation|`, and
131
+ `762951001 |Has ingredient|`, whose only domain row is scoped to new
132
+ precoordinated content (an MRCM inconsistency, not something to work around).
133
+ - An edition with no MRCM reference sets (e.g. the committed jest test cache)
134
+ gets no concept model checking at all; the checks go inert.
135
+ - The ungrouped "Close To User Form" is rejected rather than transformed to the
136
+ classifiable form. None of the seven Level 1 transformations in SNOMED's
137
+ postcoordination guide (refining an existing attribute, adding a self-grouped
138
+ attribute, adding severity, lateralising findings and procedures, adding
139
+ context to findings and procedures) is implemented.
140
+ - Negative concrete values (`#-5`) fail to parse ("Expected character } but
141
+ found -"). SCG permits them.
142
+ - Laterality on a non-lateralisable body structure is caught only when the
143
+ edition carries reference set `723264001`; the tx-ecosystem test edition
144
+ does not, so the IG suite cannot assert it.
145
+
146
+ ### Subsumption
147
+
148
+ `$subsumes` on expressions works by comparing normal forms: each side is
149
+ expanded through its fully defined concepts (primitives keep their atom and
150
+ contribute their defining attributes), then matched structurally, trying every
151
+ role group pairing.
152
+
153
+ - Positive answers (`subsumes`, `subsumed-by`, `equivalent`) are sound.
154
+ A negative answer is not provable this way: SNOMED's OWL axiom reference set
155
+ carries general concept inclusions and property chains whose entailments are
156
+ baked into the distributed inferred relationships for precoordinated concepts,
157
+ but an expression is something the classifier never saw. So when no
158
+ relationship is found and either side is an expression, the server returns an
159
+ OperationOutcome (HTTP 422, issue code `not-supported`, tx-issue-type
160
+ `cannot-determine`) rather than `not-subsumed`. Between two plain concepts all
161
+ four outcomes are sound (the transitive closure is the classifier's own
162
+ output).
163
+ - There is no reasoner. Removing the decline above would need an OWL
164
+ classifier over the axiom reference set (which is not imported).
165
+ - Normalisation depth is capped at 50 and throws rather than truncating,
166
+ because a truncated normal form is unsound in both directions.
167
+ - `subsumesTest` does not re-validate its inputs; `$subsumes` runs `locate()`
168
+ (and so `checkExpression`) on both codes first.
169
+
170
+ ### Lookup and display
171
+
172
+ - `$lookup` on an expression returns the rendered expression and its
173
+ components; the normal form, parents and children properties are returned
174
+ for single-concept expressions only.
175
+ - The display of an expression is the server's own rendering (no spaces,
176
+ `:{...}`), and every concept in it is given its preferred term.
177
+
178
+ ## Everything else
179
+
180
+ ### Data not imported
181
+
182
+ - Stated relationships and the OWL axiom reference sets. Everything is driven
183
+ by the distributed inferred relationships; there is no classification.
184
+ - Concrete value relationships (see ECL above).
185
+ - Alternate identifier reference sets.
186
+ - Inactive reference set member rows (only active rows are stored).
187
+ - Module ids and definition status on concepts are recorded by the current
188
+ importer; caches built before mid-2026 have neither (every concept looks
189
+ fully defined, module is 0). The jest cache `tx/data/snomed-testing.cache`
190
+ is one of those.
191
+
192
+ ### Language and display
193
+
194
+ - Description languages are mapped to a small fixed set at import
195
+ (`mapLanguageCode`); an unknown language is recorded as English. Norwegian
196
+ or Finnish descriptions, say, would be labelled `en`.
197
+ - `SnomedExpressionServices.codeForLanguage` has a stale language map that
198
+ disagrees with the importer; only the term is read from it today.
199
+ - With no `displayLanguage`, every edition defaults to US then GB English
200
+ preferred synonyms. A concept whose only English description is its FSN
201
+ gets its national-language preferred synonym instead.
202
+ - `sharedData.defaultLanguage` is hard-coded to 1 at import and
203
+ `getDisplayName` ignores the language argument it is passed.
204
+
205
+ ### Filters and implicit resources
206
+
207
+ - ValueSet filters supported on `compose.include.filter`: `concept` (`is-a`,
208
+ `descendent-of`, `in`), `inactive =`, `moduleId =`, `constraint =` (ECL),
209
+ `expressions =`, and `<attributeId> = <conceptId>`. Other operators on these
210
+ properties, and other properties, are rejected.
211
+ - Implicit value sets: `?fhir_vs`, `?fhir_vs=refset`, `?fhir_vs=refset/<id>`
212
+ and `?fhir_vs=isa/<id>`, with optional edition and version segments.
213
+ `?fhir_vs=ecl/<expression>` is not recognised; use a `constraint` filter.
214
+ - Implicit concept maps (`?fhir_cm=<id>`): only the four historical association
215
+ reference sets (POSSIBLY EQUIVALENT TO, REPLACED BY, SAME AS, ALTERNATIVE).
216
+ Simple and extended map reference sets (ICD-10, ICD-O, ...) are loaded but
217
+ are not exposed as implicit concept maps, and reverse translation is not
218
+ supported.
219
+ - Edition to edition translation (`<edition>/version/<date>?fhir_cm=edition/<module>`):
220
+ a `$translate` from an edition to one it depends on (per the module dependency
221
+ reference set) needs no map - the answer comes from the definitions. A concept in
222
+ the target edition's modules is its own translation; an inactive concept translates
223
+ through its historical associations; a fully defined extension concept translates
224
+ to its normal form (or to the target concept with that normal form, if there is
225
+ one), provided the normal form uses only target edition content; anything else
226
+ translates to its proximal target-edition ancestors as `source-is-narrower-than-target`,
227
+ unless the only such ancestors are top-level hierarchies, which is reported as a
228
+ reason for no translation. The map is found from `targetSystem` =
229
+ `http://snomed.info/sct|<target edition[/version/date]>`, or nominated by url.
230
+ - `$closure` works through `subsumesTest`, so adding a post-coordinated
231
+ expression to a closure table hits the `cannot-determine` rule above whenever
232
+ it is unrelated to an existing entry.
233
+
234
+ ### Text search
235
+
236
+ - The `filter` parameter on `$expand` uses an in-memory word index built the
237
+ first time an edition is searched (`tx/sct/text-index.js`): every token of
238
+ every active description, matched by prefix and by Porter stem. The stemmer
239
+ is English, so in other languages only prefix matching helps. Inactive
240
+ descriptions are not indexed.
241
+
242
+ ### Known data quality traps
243
+
244
+ - The three copies of the tx-ecosystem SNOMED test edition
245
+ (`data/terminology-cache/sct_test_*.cache`, `tx/data/snomed-testing.cache`,
246
+ `data/snomed-testing.cache`) drift. The committed jest cache is older than
247
+ the RF2 subset in `tx/data/snomed` and lacks module ids, definition status
248
+ and some concepts; rebuilding it changes test expectations.
249
+ - `tx.fhir.org` (the Java server) and FHIRsmith disagree on a few expected
250
+ results in the tx-ecosystem suite (display selection, MRCM); the suite
251
+ records FHIRsmith's reading where Grahame has decided it.