Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,33 @@ All notable changes to the Health Intersections Node Server will be documented i
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [v0.13.3] - 2026-09-10

### Fixed

- Fix SNOMED CT display selection on national editions. `_displayRefsetOrder()` knew a display
language reference set for only six editions (International, US, UK and the test edition);
every other edition fell through to "the first description marked Preferred in any language
reference set", which on a multi-language edition means whichever description happened to be
stored first. The Belgian edition (11000172109) marks a Preferred synonym in Belgian French
(21000172104) and Belgian Dutch (31000172101) as well as in English, so a request that named
no `displayLanguage` got French for one concept, Dutch for the next and English for a third.
Editions not in the table now default to US then GB English, and the $lookup designation
order ranks the edition's display reference set ahead of "preferred anywhere", so the choice
no longer depends on import order. An edition with no English language reference set at all
still falls back to its own preferred synonym. This changes one tx-ecosystem expectation:
`bugs/sct-display-2` recorded "Counselling" for 409063005 on the International edition, which
is the GB English preferred term picked only because it is stored before the US one - the same
concept already expanded as "Counseling". The expectation is now "Counseling".
- Fix the language tag on the display designation in $expand. It was hard-coded to `en-US`
while its value came from the edition display, so on a multi-language edition a French or
Dutch term was published as English - which put it in an implementation guide's English
display column and made the real French designation read as a duplicate of it.

### Tx Conformance Statement

FHIRsmith passed all 3507 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed+mimetypes+icd-11, tests v1.9.5-SNAPSHOT, runner v6.10.4

## [v0.13.2] - 2026-09-05

### Added
Expand Down
22 changes: 11 additions & 11 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
"express-rate-limit": "^7.4.1",
"express-session": "^1.19.0",
"fast-xml-parser": "^5.3.4",
"fhir-validator-wrapper": "1.3.0",
"fhir-validator-wrapper": "1.4.0",
"fhirpath": "^4.8.3",
"fs-extra": "^11.3.3",
"ini": "^6.0.0",
Expand Down
54 changes: 54 additions & 0 deletions tests/cs/cs-snomed.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1012,6 +1012,60 @@ describe('SNOMED CT Subset Validation', () => {
});
});

describe('SNOMED CT display language selection', () => {
let factory;
let provider;
let opContext;

beforeAll(async () => {
const cacheFilePath = globalCacheFilePath || findAvailableCacheFile();
opContext = new OperationContext('en', await TestUtilities.loadTranslations(await TestUtilities.loadLanguageDefinitions()));
factory = new SnomedServicesFactory(opContext.i18n, cacheFilePath);
provider = await factory.build(opContext, []);
});

afterAll(() => {
if (provider) {
provider.sct.close();
}
});

// An edition FHIRsmith has never seen must still resolve its display through an
// English language reference set. Before this, an unlisted edition got an empty
// refset order and fell through to "preferred synonym in ANY language refset",
// which on a multi-language national edition (Belgium 11000172109: French
// 21000172104, Dutch 31000172101) returned whichever description happened to be
// stored first - so $lookup answered in French for one concept and Dutch for the
// next, and $expand tagged that term as en-US.
test('an edition not in the display-refset table defaults to US then GB English', () => {
const sct = provider.sct;
const savedEdition = sct.edition;
const savedOrder = sct._dispOrder;
try {
sct.edition = '99900001000000000'; // not a real edition
sct._dispOrder = null;
const order = sct._displayRefsetOrder();
const us = sct.concepts.findConcept(900000000000509007n);
expect(us.found).toBe(true);
expect(order.length).toBeGreaterThan(0);
expect(order[0]).toBe(us.index);
} finally {
sct.edition = savedEdition;
sct._dispOrder = savedOrder;
}
});

test('getDisplayNameEx reports the language the display is actually in', () => {
const sct = provider.sct;
const r = sct.concepts.findConcept(11687002n); // Gestational diabetes mellitus
expect(r.found).toBe(true);
const ex = sct.getDisplayNameEx(r.index);
expect(ex.term).toBe(sct.getDisplayName(r.index));
expect(ex.term.length).toBeGreaterThan(0);
expect(ex.lang).toBe('en');
});
});

/**
* SNOMED CT Test Prerequisites Check
*
Expand Down
144 changes: 103 additions & 41 deletions tx/cs/cs-snomed.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,21 @@ const {debugLog} = require("../operation-context");
// shared - it simply recomputes.
const SNOMED_FILTER_ANALYSIS = Symbol('snomedFilterAnalysis');

// Description language index -> language code. The index is assigned at import
// time by SnomedImporter.mapLanguageCode(); this table is its inverse and must
// be kept in step with it.
const SCT_LANGUAGE_CODES = {
1: 'en',
2: 'fr',
3: 'nl',
4: 'es',
5: 'sv',
6: 'da',
7: 'de',
8: 'it',
9: 'cs'
};

// Context kinds matching Pascal enum
const SnomedProviderContextKind = {
CODE: 0,
Expand Down Expand Up @@ -346,8 +361,20 @@ class SnomedServices {
// preferred synonym in more than one dialect at once (the International
// edition ships both US English 509007 and GB English 508004, each marking a
// different synonym Preferred). The edition default decides which term is
// shown. Editions not listed here fall back to "preferred synonym in any
// language refset" (the historical behaviour), so no edition regresses.
// shown.
//
// Editions not listed here default to English (US then GB) rather than to
// "preferred synonym in any language refset". A national edition carries a
// Preferred synonym in each of its own language refsets as well as in English
// (e.g. the Belgian edition 11000172109 marks a French term Preferred in
// 21000172104 and a Dutch one in 31000172101), so "any refset" resolved to
// whichever description happened to be stored first - French for one concept,
// Dutch for the next, English for a third. A caller who asked for no
// particular language got an arbitrary mix. English is the only defensible
// default: it is the one language every edition has, and it is what a request
// with no displayLanguage is taken to mean everywhere else in the server. An
// edition with no English at all still falls through to the any-refset step
// below, so nothing regresses.
_displayRefsetOrder() {
if (this._dispOrder) return this._dispOrder;
const idx = (id) => { const r = this.concepts.findConcept(id); return r.found ? r.index : -1; };
Expand All @@ -361,7 +388,7 @@ class SnomedServices {
'83821000000107': [GB], // UK Edition
'999000021000000109': [GB] // UK Clinical Edition
};
const ids = byEdition[String(this.edition)] || [];
const ids = byEdition[String(this.edition)] || [US, GB];
this._dispOrder = ids.map(idx).filter((i) => i >= 0);
return this._dispOrder;
}
Expand Down Expand Up @@ -400,59 +427,80 @@ class SnomedServices {
return false;
}

// Return a concept's display: the synonym marked Preferred in the US English
// language reference set; failing that the FSN; failing that the first active
// description. Previously this returned the first active description outright,
// which is only the preferred term by accident of import order.
getDisplayName(reference = 0) {
// Return a concept's display, together with the language it is actually in:
// the synonym marked Preferred in the edition's display language reference
// set; failing that a preferred synonym in any language refset; failing that
// the FSN; failing that the first active description. Previously this
// returned the first active description outright, which is only the preferred
// term by accident of import order.
//
// The language matters as much as the term: the display is emitted as a
// designation (DesignationUse.DISPLAY) during $expand, and tagging a French
// term as en-US puts it in an IG's English display column and makes the real
// French designation look like a duplicate.
//
// Returns {term, lang} - lang is a language code, or null when no description
// could be read.
getDisplayNameEx(reference = 0) {
const none = { term: '', lang: null };
try {
const concept = this.concepts.getConcept(reference);
const descriptionsRef = concept.descriptions;

if (descriptionsRef === 0) {
return '';
return none;
}

const descriptionIndices = this.refs.getReferences(descriptionsRef);
const K = this._displayConstants();
const pick = (description) => ({
term: this.strings.getEntry(description.iDesc).trim(),
lang: SCT_LANGUAGE_CODES[description.lang] || 'en'
});

// 1. Preferred synonym in the edition's default display refset(s), tried
// in priority order. This is what disambiguates dialects: on the
// International edition a concept may be Preferred in both US and GB
// English, and the edition default (US English) must win.
// English, and the edition default (US English) must win. It is also
// what keeps a multi-language national edition from picking a
// different language for each concept.
for (const refsetIdx of this._displayRefsetOrder()) {
for (const descIndex of descriptionIndices) {
const description = this.descriptions.getDescription(descIndex);
if (!description.active || description.kind !== K.synonym) continue;
if (this._descAcceptability(description, refsetIdx) === K.preferred) {
return this.strings.getEntry(description.iDesc).trim();
return pick(description);
}
}
}

// 2. Fallback: preferred synonym in ANY language refset; then FSN; then
// the first active description. Used for editions without a mapped
// default refset, and for concepts with no preferred synonym there.
let fsnTerm = '';
let firstActive = '';
// the first active description. Reached only by editions with no
// English language refset at all, and by concepts with no preferred
// synonym in the edition's display refsets.
let fsn = null;
let firstActive = null;
for (const descIndex of descriptionIndices) {
const description = this.descriptions.getDescription(descIndex);
if (!description.active) continue;
const term = this.strings.getEntry(description.iDesc).trim();
if (firstActive === '') firstActive = term;
if (firstActive === null) firstActive = pick(description);
if (this._synonymIsPreferred(description)) {
return term; // preferred synonym (any English language refset)
return pick(description); // preferred synonym (any language refset)
}
if (description.kind === K.fsn && fsnTerm === '') {
fsnTerm = term;
if (description.kind === K.fsn && fsn === null) {
fsn = pick(description);
}
}
return fsnTerm || firstActive || '';
return fsn || firstActive || none;
} catch (error) {
return '';
return none;
}
}

getDisplayName(reference = 0) {
return this.getDisplayNameEx(reference).term;
}

getConceptDescendants(reference) {
try {
const allDescRef = this.concepts.getAllDesc(reference);
Expand Down Expand Up @@ -1687,8 +1735,16 @@ class SnomedProvider extends BaseCSServices {
if (!prefSyns.has(langCode)) prefSyns.set(langCode, { langCode, use, term });
}
}
const display = this.sct.getDisplayName(ctxt.getReference());
if (display) displays.addDesignation(true, 'active', 'en-US', null, display);
// Tagged with the language the term is actually in, not a
// hard-coded en-US: on a multi-language edition the edition
// display can be French or Dutch, and mislabelling it as English
// is what puts it in an IG's English display column and makes the
// real French designation look like a duplicate of it. English
// stays 'en-US' (the term comes from the US English language
// reference set), so nothing changes for English editions.
const display = this.sct.getDisplayNameEx(ctxt.getReference());
const displayLang = (!display.lang || display.lang === 'en') ? 'en-US' : display.lang;
if (display.term) displays.addDesignation(true, 'active', displayLang, null, display.term);
// FSNs before synonyms, matching the designation order the published
// tx-ecosystem expectations were written against (sct/expand-inactive).
// Which of the two is chosen as the display is settled by
Expand All @@ -1697,14 +1753,31 @@ class SnomedProvider extends BaseCSServices {
for (const d of fsns.values()) displays.addDesignation(false, 'active', d.langCode, d.use, d.term);
for (const d of prefSyns.values()) displays.addDesignation(false, 'active', d.langCode, d.use, d.term);
} else {
// $lookup: emit every description (preferred synonym first so the
// display resolves correctly; order is not otherwise significant).
// $lookup: emit every description. Order matters only for the
// display: a request with no displayLanguage takes the first
// designation Designations._isPreferred() accepts, which is any
// SNOMED synonym. Ranking by the edition's display language
// reference set first (rather than by "preferred in any refset")
// keeps that pick in one language - otherwise a national edition
// answers $lookup in French for one concept and Dutch for the
// next, depending on description storage order.
const K2 = this.sct._displayConstants();
const displayRefsets = this.sct._displayRefsetOrder();
// Buckets: 0 = preferred in the edition's display refset, 1 =
// preferred in some other language refset, 2 = everything else
// (FSNs and acceptable synonyms, left in their original order -
// Array.sort is stable, so nothing else about the output moves).
const rank = (description) => {
if (description.kind !== K2.synonym) return 2;
for (const refsetIdx of displayRefsets) {
if (this.sct._descAcceptability(description, refsetIdx) === K2.preferred) return 0;
}
return this.sct._synonymIsPreferred(description) ? 1 : 2;
};
const orderedIndices = descriptionIndices.slice().sort((a, b) => {
const da = this.sct.descriptions.getDescription(a);
const db = this.sct.descriptions.getDescription(b);
const pa = this.sct._synonymIsPreferred(da) ? 0 : 1;
const pb = this.sct._synonymIsPreferred(db) ? 0 : 1;
return pa - pb;
return rank(da) - rank(db);
});
for (const descIndex of orderedIndices) {
const description = this.sct.descriptions.getDescription(descIndex);
Expand Down Expand Up @@ -1733,18 +1806,7 @@ class SnomedProvider extends BaseCSServices {
}

getLanguageCode(langIndex) {
const languageMap = {
1: 'en',
2: 'fr',
3: 'nl',
4: 'es',
5: 'sv',
6: 'da',
7: 'de',
8: 'it',
9: 'cs'
};
return languageMap[langIndex] || 'en';
return SCT_LANGUAGE_CODES[langIndex] || 'en';
}

// Lookup methods
Expand Down
Loading